Skip to content

Commit cc60590

Browse files
committed
feat(provenance): add provenance utility functions
1 parent 64f9601 commit cc60590

1 file changed

Lines changed: 143 additions & 0 deletions

File tree

server/utils/provenance.ts

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
import type { ProvenanceDetails } from '#shared/types'
2+
3+
const SLSA_PROVENANCE_V1 = 'https://slsa.dev/provenance/v1'
4+
const SLSA_PROVENANCE_V0_2 = 'https://slsa.dev/provenance/v0.2'
5+
6+
const PROVIDER_IDS: Record<string, { provider: string; providerLabel: string }> = {
7+
'https://github.com/actions/runner/github-hosted': {
8+
provider: 'github',
9+
providerLabel: 'GitHub Actions',
10+
},
11+
'https://github.com/actions/runner': { provider: 'github', providerLabel: 'GitHub Actions' },
12+
}
13+
14+
/** GitLab uses project-specific builder IDs: https://gitlab.com/<path>/-/runners/<id> */
15+
function getProviderInfo(builderId: string): { provider: string; providerLabel: string } {
16+
const exact = PROVIDER_IDS[builderId]
17+
if (exact) return exact
18+
if (builderId.includes('gitlab.com') && builderId.includes('/runners/'))
19+
return { provider: 'gitlab', providerLabel: 'GitLab CI' }
20+
return { provider: 'unknown', providerLabel: builderId ? 'CI' : 'Unknown' }
21+
}
22+
23+
const SIGSTORE_SEARCH_BASE = 'https://search.sigstore.dev'
24+
25+
/** SLSA provenance v1 predicate; optional v0.2 fields for fallback */
26+
interface SlsaPredicate {
27+
buildDefinition?: {
28+
externalParameters?: {
29+
workflow?: {
30+
repository?: string
31+
path?: string
32+
ref?: string
33+
}
34+
}
35+
resolvedDependencies?: Array<{
36+
uri?: string
37+
digest?: { gitCommit?: string }
38+
}>
39+
}
40+
runDetails?: {
41+
builder?: { id?: string }
42+
metadata?: { invocationId?: string }
43+
}
44+
/** v0.2 */
45+
builder?: { id?: string }
46+
/** v0.2 */
47+
metadata?: { buildInvocationId?: string }
48+
}
49+
50+
interface AttestationItem {
51+
predicateType?: string
52+
bundle?: {
53+
dsseEnvelope?: { payload?: string }
54+
verificationMaterial?: {
55+
tlogEntries?: Array<{ logIndex?: string }>
56+
}
57+
}
58+
}
59+
60+
export interface NpmAttestationsResponse {
61+
attestations?: AttestationItem[]
62+
}
63+
64+
function decodePayload(
65+
payloadBase64: string | undefined,
66+
): { predicateType?: string; predicate?: SlsaPredicate } | null {
67+
if (!payloadBase64 || typeof payloadBase64 !== 'string') return null
68+
try {
69+
const decoded = Buffer.from(payloadBase64, 'base64').toString('utf-8')
70+
return JSON.parse(decoded) as { predicateType?: string; predicate?: SlsaPredicate }
71+
} catch {
72+
return null
73+
}
74+
}
75+
76+
function repoUrlToCommitUrl(repository: string, sha: string): string {
77+
const normalized = repository.replace(/\/$/, '').replace(/\.git$/, '')
78+
if (normalized.includes('github.com')) return `${normalized}/commit/${sha}`
79+
if (normalized.includes('gitlab.com')) return `${normalized}/-/commit/${sha}`
80+
return `${normalized}/commit/${sha}`
81+
}
82+
83+
function repoUrlToBlobUrl(repository: string, path: string, ref = 'main'): string {
84+
const normalized = repository.replace(/\/$/, '').replace(/\.git$/, '')
85+
if (normalized.includes('github.com')) return `${normalized}/blob/${ref}/${path}`
86+
if (normalized.includes('gitlab.com')) return `${normalized}/-/blob/${ref}/${path}`
87+
return `${normalized}/blob/${ref}/${path}`
88+
}
89+
90+
/**
91+
* Parse npm attestations API response into ProvenanceDetails.
92+
* Prefers SLSA provenance v1; falls back to v0.2 for provider label and ledger only (no source commit/build file from v0.2).
93+
* @public
94+
*/
95+
export function parseAttestationToProvenanceDetails(response: unknown): ProvenanceDetails | null {
96+
const body = response as NpmAttestationsResponse
97+
const list = body?.attestations
98+
if (!Array.isArray(list)) return null
99+
100+
const slsaAttestation =
101+
list.find(a => a.predicateType === SLSA_PROVENANCE_V1) ??
102+
list.find(a => a.predicateType === SLSA_PROVENANCE_V0_2)
103+
if (!slsaAttestation?.bundle?.dsseEnvelope) return null
104+
105+
const payload = decodePayload(slsaAttestation.bundle.dsseEnvelope.payload)
106+
if (!payload?.predicate) return null
107+
108+
const pred = payload.predicate as SlsaPredicate
109+
const builderId = pred.runDetails?.builder?.id ?? pred.builder?.id ?? ''
110+
const providerInfo = getProviderInfo(builderId)
111+
112+
const workflow = pred.buildDefinition?.externalParameters?.workflow
113+
const repo = workflow?.repository?.replace(/\/$/, '').replace(/\.git$/, '') ?? ''
114+
const workflowPath = workflow?.path ?? ''
115+
const ref = workflow?.ref?.replace(/^refs\/heads\//, '') ?? 'main'
116+
117+
const resolved = pred.buildDefinition?.resolvedDependencies?.[0]
118+
const commitSha = resolved?.digest?.gitCommit ?? ''
119+
120+
const rawInvocationId =
121+
pred.runDetails?.metadata?.invocationId ?? pred.metadata?.buildInvocationId
122+
const buildSummaryUrl =
123+
rawInvocationId?.startsWith('http://') || rawInvocationId?.startsWith('https://')
124+
? rawInvocationId
125+
: undefined
126+
const sourceCommitUrl = repo && commitSha ? repoUrlToCommitUrl(repo, commitSha) : undefined
127+
const buildFileUrl = repo && workflowPath ? repoUrlToBlobUrl(repo, workflowPath, ref) : undefined
128+
129+
const tlogEntries = slsaAttestation.bundle.verificationMaterial?.tlogEntries
130+
const logIndex = tlogEntries?.[0]?.logIndex
131+
const publicLedgerUrl = logIndex ? `${SIGSTORE_SEARCH_BASE}/?logIndex=${logIndex}` : undefined
132+
133+
return {
134+
provider: providerInfo.provider,
135+
providerLabel: providerInfo.providerLabel,
136+
buildSummaryUrl,
137+
sourceCommitUrl,
138+
sourceCommitSha: commitSha || undefined,
139+
buildFileUrl,
140+
buildFilePath: workflowPath || undefined,
141+
publicLedgerUrl,
142+
}
143+
}

0 commit comments

Comments
 (0)