diff --git a/README.md b/README.md index e53be8d83..627e0afbb 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ What npmx offers: - **Fast search** – quick package search with instant results - **Package details** – READMEs, versions, dependencies, and metadata - **Code viewer** – browse package source code with syntax highlighting and permalink to specific lines -- **Provenance indicators** – verified build badges for packages with npm provenance +- **Provenance indicators** – verified build badges and provenance section below the README - **Multi-provider repository support** – stars/forks from GitHub, GitLab, Bitbucket, Codeberg, Gitee, Sourcehut, Forgejo, Gitea, Radicle, and Tangled - **JSR availability** – see if scoped packages are also available on JSR - **Package badges** – module format (ESM/CJS/dual), TypeScript types (with `@types/*` links), and engine constraints diff --git a/app/components/PackageProvenanceSection.vue b/app/components/PackageProvenanceSection.vue new file mode 100644 index 000000000..73f316536 --- /dev/null +++ b/app/components/PackageProvenanceSection.vue @@ -0,0 +1,96 @@ + + + diff --git a/app/pages/package/[...package].vue b/app/pages/package/[...package].vue index a302b6a96..3b20532b2 100644 --- a/app/pages/package/[...package].vue +++ b/app/pages/package/[...package].vue @@ -2,6 +2,7 @@ import type { NpmVersionDist, PackumentVersion, + ProvenanceDetails, ReadmeResponse, SkillsListResponse, } from '#shared/types' @@ -158,6 +159,39 @@ const { data: vulnTree, status: vulnTreeStatus } = useDependencyAnalysis( () => resolvedVersion.value ?? '', ) +const { + data: provenanceData, + status: provenanceStatus, + execute: fetchProvenance, +} = useLazyFetch( + () => { + const v = displayVersion.value + if (!v || !hasProvenance(v)) return '' + return `/api/registry/provenance/${packageName.value}/v/${v.version}` + }, + { + default: () => null, + server: false, + immediate: false, + }, +) +if (import.meta.client) { + watch( + displayVersion, + v => { + if (v && hasProvenance(v) && provenanceStatus.value === 'idle') { + fetchProvenance() + } + }, + { immediate: true }, + ) +} + +const provenanceBadgeMounted = shallowRef(false) +onMounted(() => { + provenanceBadgeMounted.value = true +}) + // Keep latestVersion for comparison (to show "(latest)" badge) const latestVersion = computed(() => { if (!pkg.value) return null @@ -523,16 +557,26 @@ defineOgImageComponent('Package', { > v{{ resolvedVersion }} - - + {{ $t('package.readme.view_on_github') }}

- +
+
+
+ + +
+
+
+
diff --git a/i18n/locales/en.json b/i18n/locales/en.json index f67cdff4f..7518b487c 100644 --- a/i18n/locales/en.json +++ b/i18n/locales/en.json @@ -210,6 +210,17 @@ "view_on_github": "View on GitHub", "toc_title": "Outline" }, + "provenance_section": { + "title": "Provenance", + "built_and_signed_on": "Built and signed on {provider}", + "view_build_summary": "View build summary", + "source_commit": "Source Commit", + "build_file": "Build File", + "public_ledger": "Public Ledger", + "transparency_log_entry": "Transparency log entry", + "view_more_details": "View more details", + "error_loading": "Failed to load provenance details" + }, "keywords_title": "Keywords", "compatibility": "Compatibility", "card": { @@ -610,7 +621,8 @@ "provenance": { "verified": "verified", "verified_title": "Verified provenance", - "verified_via": "Verified: published via {provider}" + "verified_via": "Verified: published via {provider}", + "view_more_details": "View more details" }, "jsr": { "title": "also available on JSR", diff --git a/lunaria/files/en-GB.json b/lunaria/files/en-GB.json index 39a1a00bb..583ca875d 100644 --- a/lunaria/files/en-GB.json +++ b/lunaria/files/en-GB.json @@ -210,6 +210,17 @@ "view_on_github": "View on GitHub", "toc_title": "Outline" }, + "provenance_section": { + "title": "Provenance", + "built_and_signed_on": "Built and signed on {provider}", + "view_build_summary": "View build summary", + "source_commit": "Source Commit", + "build_file": "Build File", + "public_ledger": "Public Ledger", + "transparency_log_entry": "Transparency log entry", + "view_more_details": "View more details", + "error_loading": "Failed to load provenance details" + }, "keywords_title": "Keywords", "compatibility": "Compatibility", "card": { @@ -610,7 +621,8 @@ "provenance": { "verified": "verified", "verified_title": "Verified provenance", - "verified_via": "Verified: published via {provider}" + "verified_via": "Verified: published via {provider}", + "view_more_details": "View more details" }, "jsr": { "title": "also available on JSR", diff --git a/lunaria/files/en-US.json b/lunaria/files/en-US.json index f67cdff4f..7518b487c 100644 --- a/lunaria/files/en-US.json +++ b/lunaria/files/en-US.json @@ -210,6 +210,17 @@ "view_on_github": "View on GitHub", "toc_title": "Outline" }, + "provenance_section": { + "title": "Provenance", + "built_and_signed_on": "Built and signed on {provider}", + "view_build_summary": "View build summary", + "source_commit": "Source Commit", + "build_file": "Build File", + "public_ledger": "Public Ledger", + "transparency_log_entry": "Transparency log entry", + "view_more_details": "View more details", + "error_loading": "Failed to load provenance details" + }, "keywords_title": "Keywords", "compatibility": "Compatibility", "card": { @@ -610,7 +621,8 @@ "provenance": { "verified": "verified", "verified_title": "Verified provenance", - "verified_via": "Verified: published via {provider}" + "verified_via": "Verified: published via {provider}", + "view_more_details": "View more details" }, "jsr": { "title": "also available on JSR", diff --git a/nuxt.config.ts b/nuxt.config.ts index 8ea9a5d08..e104e46f3 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -102,6 +102,7 @@ export default defineNuxtConfig({ '/package-docs/:scope/:pkg/v/**': { isr: true, cache: { maxAge: 365 * 24 * 60 * 60 } }, '/api/registry/docs/**': { isr: true, cache: { maxAge: 365 * 24 * 60 * 60 } }, '/api/registry/file/**': { isr: true, cache: { maxAge: 365 * 24 * 60 * 60 } }, + '/api/registry/provenance/**': { isr: true, cache: { maxAge: 365 * 24 * 60 * 60 } }, '/api/registry/files/**': { isr: true, cache: { maxAge: 365 * 24 * 60 * 60 } }, '/_avatar/**': { isr: 3600, diff --git a/server/api/registry/provenance/[...pkg].get.ts b/server/api/registry/provenance/[...pkg].get.ts new file mode 100644 index 000000000..9cb705920 --- /dev/null +++ b/server/api/registry/provenance/[...pkg].get.ts @@ -0,0 +1,69 @@ +import * as v from 'valibot' +import { PackageRouteParamsSchema } from '#shared/schemas/package' +import type { NpmVersionDist } from '#shared/types' +import { CACHE_MAX_AGE_ONE_HOUR, ERROR_PROVENANCE_FETCH_FAILED } from '#shared/utils/constants' +import { + parseAttestationToProvenanceDetails, + type NpmAttestationsResponse, +} from '#server/utils/provenance' + +/** + * GET /api/registry/provenance/:name/v/:version + * + * Returns parsed provenance details for a package version (build summary, source commit, build file, public ledger). + * Version is required. Returns null when the version has no attestations or parsing fails. + */ +export default defineCachedEventHandler( + async event => { + const pkgParamSegments = getRouterParam(event, 'pkg')?.split('/') ?? [] + + const { rawPackageName, rawVersion } = parsePackageParams(pkgParamSegments) + + if (!rawVersion) { + throw createError({ + statusCode: 400, + message: 'Version is required for provenance.', + }) + } + + try { + const parsed = v.parse(PackageRouteParamsSchema, { + packageName: rawPackageName, + version: rawVersion, + }) as { packageName: string; version: string } + const { packageName, version } = parsed + + const packument = await fetchNpmPackage(packageName) + const versionData = packument.versions[version] + if (!versionData) { + throw createError({ + statusCode: 404, + message: `Version ${version} not found for package ${packageName}.`, + }) + } + const dist = versionData.dist as NpmVersionDist | undefined + const attestationsUrl = dist?.attestations?.url + + if (!attestationsUrl) { + return null + } + + const response = await $fetch(attestationsUrl) + const details = parseAttestationToProvenanceDetails(response) + return details + } catch (error: unknown) { + handleApiError(error, { + statusCode: 502, + message: ERROR_PROVENANCE_FETCH_FAILED, + }) + } + }, + { + maxAge: CACHE_MAX_AGE_ONE_HOUR, + swr: true, + getKey: event => { + const pkg = getRouterParam(event, 'pkg') ?? '' + return `provenance:v1:${pkg.replace(/\/+$/, '').trim()}` + }, + }, +) diff --git a/server/utils/provenance.ts b/server/utils/provenance.ts new file mode 100644 index 000000000..796b45c36 --- /dev/null +++ b/server/utils/provenance.ts @@ -0,0 +1,143 @@ +import type { ProvenanceDetails } from '#shared/types' + +const SLSA_PROVENANCE_V1 = 'https://slsa.dev/provenance/v1' +const SLSA_PROVENANCE_V0_2 = 'https://slsa.dev/provenance/v0.2' + +const PROVIDER_IDS: Record = { + 'https://github.com/actions/runner/github-hosted': { + provider: 'github', + providerLabel: 'GitHub Actions', + }, + 'https://github.com/actions/runner': { provider: 'github', providerLabel: 'GitHub Actions' }, +} + +/** GitLab uses project-specific builder IDs: https://gitlab.com//-/runners/ */ +function getProviderInfo(builderId: string): { provider: string; providerLabel: string } { + const exact = PROVIDER_IDS[builderId] + if (exact) return exact + if (builderId.includes('gitlab.com') && builderId.includes('/runners/')) + return { provider: 'gitlab', providerLabel: 'GitLab CI' } + return { provider: 'unknown', providerLabel: builderId ? 'CI' : 'Unknown' } +} + +const SIGSTORE_SEARCH_BASE = 'https://search.sigstore.dev' + +/** SLSA provenance v1 predicate; optional v0.2 fields for fallback */ +interface SlsaPredicate { + buildDefinition?: { + externalParameters?: { + workflow?: { + repository?: string + path?: string + ref?: string + } + } + resolvedDependencies?: Array<{ + uri?: string + digest?: { gitCommit?: string } + }> + } + runDetails?: { + builder?: { id?: string } + metadata?: { invocationId?: string } + } + /** v0.2 */ + builder?: { id?: string } + /** v0.2 */ + metadata?: { buildInvocationId?: string } +} + +interface AttestationItem { + predicateType?: string + bundle?: { + dsseEnvelope?: { payload?: string } + verificationMaterial?: { + tlogEntries?: Array<{ logIndex?: string }> + } + } +} + +export interface NpmAttestationsResponse { + attestations?: AttestationItem[] +} + +function decodePayload( + payloadBase64: string | undefined, +): { predicateType?: string; predicate?: SlsaPredicate } | null { + if (!payloadBase64 || typeof payloadBase64 !== 'string') return null + try { + const decoded = Buffer.from(payloadBase64, 'base64').toString('utf-8') + return JSON.parse(decoded) as { predicateType?: string; predicate?: SlsaPredicate } + } catch { + return null + } +} + +function repoUrlToCommitUrl(repository: string, sha: string): string { + const normalized = repository.replace(/\/$/, '').replace(/\.git$/, '') + if (normalized.includes('github.com')) return `${normalized}/commit/${sha}` + if (normalized.includes('gitlab.com')) return `${normalized}/-/commit/${sha}` + return `${normalized}/commit/${sha}` +} + +function repoUrlToBlobUrl(repository: string, path: string, ref = 'main'): string { + const normalized = repository.replace(/\/$/, '').replace(/\.git$/, '') + if (normalized.includes('github.com')) return `${normalized}/blob/${ref}/${path}` + if (normalized.includes('gitlab.com')) return `${normalized}/-/blob/${ref}/${path}` + return `${normalized}/blob/${ref}/${path}` +} + +/** + * Parse npm attestations API response into ProvenanceDetails. + * Prefers SLSA provenance v1; falls back to v0.2 for provider label and ledger only (no source commit/build file from v0.2). + * @public + */ +export function parseAttestationToProvenanceDetails(response: unknown): ProvenanceDetails | null { + const body = response as NpmAttestationsResponse + const list = body?.attestations + if (!Array.isArray(list)) return null + + const slsaAttestation = + list.find(a => a.predicateType === SLSA_PROVENANCE_V1) ?? + list.find(a => a.predicateType === SLSA_PROVENANCE_V0_2) + if (!slsaAttestation?.bundle?.dsseEnvelope) return null + + const payload = decodePayload(slsaAttestation.bundle.dsseEnvelope.payload) + if (!payload?.predicate) return null + + const pred = payload.predicate as SlsaPredicate + const builderId = pred.runDetails?.builder?.id ?? pred.builder?.id ?? '' + const providerInfo = getProviderInfo(builderId) + + const workflow = pred.buildDefinition?.externalParameters?.workflow + const repo = workflow?.repository?.replace(/\/$/, '').replace(/\.git$/, '') ?? '' + const workflowPath = workflow?.path ?? '' + const ref = workflow?.ref?.replace(/^refs\/heads\//, '').replace(/^refs\/tags\//, '') ?? 'main' + + const resolved = pred.buildDefinition?.resolvedDependencies?.[0] + const commitSha = resolved?.digest?.gitCommit ?? '' + + const rawInvocationId = + pred.runDetails?.metadata?.invocationId ?? pred.metadata?.buildInvocationId + const buildSummaryUrl = + rawInvocationId?.startsWith('http://') || rawInvocationId?.startsWith('https://') + ? rawInvocationId + : undefined + const sourceCommitUrl = repo && commitSha ? repoUrlToCommitUrl(repo, commitSha) : undefined + const buildFileUrl = repo && workflowPath ? repoUrlToBlobUrl(repo, workflowPath, ref) : undefined + + const tlogEntries = slsaAttestation.bundle.verificationMaterial?.tlogEntries + const logIndex = tlogEntries?.[0]?.logIndex + const publicLedgerUrl = logIndex ? `${SIGSTORE_SEARCH_BASE}/?logIndex=${logIndex}` : undefined + + return { + provider: providerInfo.provider, + providerLabel: providerInfo.providerLabel, + buildSummaryUrl, + sourceCommitUrl, + sourceCommitSha: commitSha || undefined, + buildFileUrl, + buildFilePath: workflowPath || undefined, + publicLedgerUrl, + } +} diff --git a/shared/types/npm-registry.ts b/shared/types/npm-registry.ts index 12638907a..40cab9baa 100644 --- a/shared/types/npm-registry.ts +++ b/shared/types/npm-registry.ts @@ -212,6 +212,30 @@ export interface NpmVersionDist { attestations?: NpmVersionAttestations } +/** + * Parsed provenance details for display (from attestation bundle SLSA predicate). + * Used by the provenance API and PackageProvenanceSection. + * @public + */ +export interface ProvenanceDetails { + /** Provider ID (e.g. "github", "gitlab") */ + provider: string + /** Human-readable provider label (e.g. "GitHub Actions") */ + providerLabel: string + /** Link to build run summary (e.g. GitHub Actions run URL) */ + buildSummaryUrl?: string + /** Link to source commit in repository */ + sourceCommitUrl?: string + /** Source commit SHA (short or full) */ + sourceCommitSha?: string + /** Link to workflow/build config file in repo */ + buildFileUrl?: string + /** Workflow path (e.g. ".github/workflows/release.yml") */ + buildFilePath?: string + /** Link to transparency log entry (e.g. Sigstore search) */ + publicLedgerUrl?: string +} + /** * Download counts API response * From https://api.npmjs.org/downloads/ diff --git a/shared/utils/constants.ts b/shared/utils/constants.ts index 2c5817c1b..1229a2d50 100644 --- a/shared/utils/constants.ts +++ b/shared/utils/constants.ts @@ -21,6 +21,7 @@ export const ERROR_CALC_INSTALL_SIZE_FAILED = 'Failed to calculate install size. export const NPM_MISSING_README_SENTINEL = 'ERROR: No README data found!' export const ERROR_JSR_FETCH_FAILED = 'Failed to fetch package from JSR registry.' export const ERROR_NPM_FETCH_FAILED = 'Failed to fetch package from npm registry.' +export const ERROR_PROVENANCE_FETCH_FAILED = 'Failed to fetch provenance.' export const UNSET_NUXT_SESSION_PASSWORD = 'NUXT_SESSION_PASSWORD not set' export const ERROR_SUGGESTIONS_FETCH_FAILED = 'Failed to fetch suggestions.' export const ERROR_SKILLS_FETCH_FAILED = 'Failed to fetch skills.' diff --git a/test/nuxt/a11y.spec.ts b/test/nuxt/a11y.spec.ts index 7b476da84..bbdf014d6 100644 --- a/test/nuxt/a11y.spec.ts +++ b/test/nuxt/a11y.spec.ts @@ -103,6 +103,7 @@ import { HeaderSearchBox, LicenseDisplay, LoadingSpinner, + PackageProvenanceSection, OrgMembersPanel, OrgOperationsQueue, OrgTeamsPanel, @@ -950,6 +951,40 @@ describe('component accessibility audits', () => { }) }) + describe('PackageProvenanceSection', () => { + it('should have no accessibility violations with minimal details', async () => { + const component = await mountSuspended(PackageProvenanceSection, { + props: { + details: { + provider: 'github', + providerLabel: 'GitHub Actions', + }, + }, + }) + const results = await runAxe(component) + expect(results.violations).toEqual([]) + }) + + it('should have no accessibility violations with full details', async () => { + const component = await mountSuspended(PackageProvenanceSection, { + props: { + details: { + provider: 'github', + providerLabel: 'GitHub Actions', + buildSummaryUrl: 'https://github.com/owner/repo/actions/runs/123', + sourceCommitUrl: 'https://github.com/owner/repo/commit/abc123', + sourceCommitSha: 'abc123def456', + buildFileUrl: 'https://github.com/owner/repo/blob/main/.github/workflows/release.yml', + buildFilePath: '.github/workflows/release.yml', + publicLedgerUrl: 'https://search.sigstore.dev/example', + }, + }, + }) + const results = await runAxe(component) + expect(results.violations).toEqual([]) + }) + }) + describe('OrgOperationsQueue', () => { it('should have no accessibility violations', async () => { const component = await mountSuspended(OrgOperationsQueue)