From d3e464bc6f3d58e92999080640354bc51a4cc796 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 9 Jan 2026 10:52:19 +0000 Subject: [PATCH 01/17] Initial plan From 14356e2032cda51d40429b53dc53a2652629bfe2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 9 Jan 2026 11:12:10 +0000 Subject: [PATCH 02/17] Implement trust level comparison feature - Created API functions to fetch trust evidence from packument - Created TrustComparison component to display trust levels - Created TrustDiff component with suspense wrapper - Added TrustDiff above other service comparisons in page.tsx - Supports provenance and trustedPublisher trust evidence - Shows trust downgrade/upgrade warnings with colors - Includes provenance link functionality Co-authored-by: oBusk <13413409+oBusk@users.noreply.github.com> --- next-env.d.ts | 2 +- .../_page/TrustComparison/TrustComparison.tsx | 171 ++++++++++++++++++ .../_page/TrustComparison/index.tsx | 36 ++++ src/app/[...parts]/_page/TrustDiff.tsx | 45 +++++ src/app/[...parts]/page.tsx | 9 + src/lib/api/npm/getTrustEvidence.ts | 117 ++++++++++++ src/lib/api/npm/trustComparison.ts | 20 ++ 7 files changed, 399 insertions(+), 1 deletion(-) create mode 100644 src/app/[...parts]/_page/TrustComparison/TrustComparison.tsx create mode 100644 src/app/[...parts]/_page/TrustComparison/index.tsx create mode 100644 src/app/[...parts]/_page/TrustDiff.tsx create mode 100644 src/lib/api/npm/getTrustEvidence.ts create mode 100644 src/lib/api/npm/trustComparison.ts diff --git a/next-env.d.ts b/next-env.d.ts index c4b7818f..9edff1c7 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/dev/types/routes.d.ts"; +import "./.next/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/src/app/[...parts]/_page/TrustComparison/TrustComparison.tsx b/src/app/[...parts]/_page/TrustComparison/TrustComparison.tsx new file mode 100644 index 00000000..4023c500 --- /dev/null +++ b/src/app/[...parts]/_page/TrustComparison/TrustComparison.tsx @@ -0,0 +1,171 @@ +import { type FunctionComponent } from "react"; +import ExternalLink from "^/components/ExternalLink"; +import type { TrustEvidence } from "^/lib/api/npm/getTrustEvidence"; +import { cx } from "^/lib/cva"; +import type SimplePackageSpec from "^/lib/SimplePackageSpec"; +import Halfs from "../DiffIntro/Halfs"; + +const TRUST_RANK = { + trustedPublisher: 2, + provenance: 1, + undefined: 0, +} as const; + +function getTrustRank(evidence: TrustEvidence): number { + return TRUST_RANK[evidence ?? "undefined"]; +} + +function getTrustLabel(evidence: TrustEvidence): string { + switch (evidence) { + case "trustedPublisher": + return "Trusted Publisher"; + case "provenance": + return "Provenance"; + default: + return "No trust evidence"; + } +} + +function getTrustColor(evidence: TrustEvidence, isDowngrade: boolean): string { + if (isDowngrade) { + return "text-red-600 dark:text-red-400"; + } + switch (evidence) { + case "trustedPublisher": + return "text-green-600 dark:text-green-400"; + case "provenance": + return "text-blue-600 dark:text-blue-400"; + default: + return "text-gray-500 dark:text-gray-400"; + } +} + +interface TrustBoxProps { + evidence: TrustEvidence; + provenanceUrl?: string; + isDowngrade?: boolean; + isUpgrade?: boolean; +} + +const TrustBox: FunctionComponent = ({ + evidence, + provenanceUrl, + isDowngrade = false, + isUpgrade = false, +}) => { + const label = getTrustLabel(evidence); + const colorClass = getTrustColor(evidence, isDowngrade); + + const content = ( +
+

{label}

+ {isDowngrade ?

⚠️ Trust downgrade

: null} + {isUpgrade ?

✓ Trust improvement

: null} +
+ ); + + if (provenanceUrl) { + return ( + + {content} + + ); + } + + return content; +}; + +export interface TrustComparisonProps { + a: SimplePackageSpec; + b: SimplePackageSpec; + aEvidence: TrustEvidence; + bEvidence: TrustEvidence; + aProvenanceUrl?: string; + bProvenanceUrl?: string; +} + +/** The padding of the center column and the right/left half has to be the same to line up */ +const COMMON_PADDING = "p-2"; + +const TrustComparison: FunctionComponent = ({ + aEvidence, + bEvidence, + aProvenanceUrl, + bProvenanceUrl, +}) => { + const aRank = getTrustRank(aEvidence); + const bRank = getTrustRank(bEvidence); + const isDowngrade = bRank < aRank; + const isUpgrade = bRank > aRank; + + const compareUrl = + aProvenanceUrl && bProvenanceUrl + ? getGitHubCompareUrl(aProvenanceUrl, bProvenanceUrl) + : undefined; + + return ( + <> +
+ Trust Level +
+ + } + center={ +
+

Trust

+
+ } + right={ + + } + /> + {compareUrl ? ( +
+ + Compare source on GitHub + +
+ ) : null} + + ); +}; + +/** + * Extract GitHub compare URL from two provenance URLs + */ +function getGitHubCompareUrl(aUrl: string, bUrl: string): string | undefined { + const aMatch = aUrl.match( + /github\.com\/([^/]+\/[^/]+)\/commit\/([a-f0-9]+)/, + ); + const bMatch = bUrl.match( + /github\.com\/([^/]+\/[^/]+)\/commit\/([a-f0-9]+)/, + ); + + if (aMatch && bMatch && aMatch[1] === bMatch[1]) { + const repo = aMatch[1]; + const aCommit = aMatch[2]; + const bCommit = bMatch[2]; + return `https://github.com/${repo}/compare/${aCommit}...${bCommit}`; + } + + return undefined; +} + +export default TrustComparison; diff --git a/src/app/[...parts]/_page/TrustComparison/index.tsx b/src/app/[...parts]/_page/TrustComparison/index.tsx new file mode 100644 index 00000000..173c5163 --- /dev/null +++ b/src/app/[...parts]/_page/TrustComparison/index.tsx @@ -0,0 +1,36 @@ +import { type FunctionComponent } from "react"; +import Skeleton from "^/components/ui/Skeleton"; +import { cx } from "^/lib/cva"; +import Halfs from "../DiffIntro/Halfs"; + +const COMMON_PADDING = "p-2"; + +export const TrustComparisonSkeleton: FunctionComponent = () => { + return ( + <> +
+ Trust Level +
+ + + + } + center={ +
+

Trust

+
+ } + right={ +
+ +
+ } + /> + + ); +}; + +export { default } from "./TrustComparison"; diff --git a/src/app/[...parts]/_page/TrustDiff.tsx b/src/app/[...parts]/_page/TrustDiff.tsx new file mode 100644 index 00000000..1a25480f --- /dev/null +++ b/src/app/[...parts]/_page/TrustDiff.tsx @@ -0,0 +1,45 @@ +import { cacheLife } from "next/cache"; +import trustComparison from "^/lib/api/npm/trustComparison"; +import type SimplePackageSpec from "^/lib/SimplePackageSpec"; +import suspense from "^/lib/suspense"; +import measuredPromise from "^/lib/utils/measuredPromise"; +import TrustComparison, { TrustComparisonSkeleton } from "./TrustComparison"; + +export interface TrustDiffProps { + a: SimplePackageSpec; + b: SimplePackageSpec; + specs: [string, string]; +} + +const TrustDiffInner = async ({ specs, a, b }: TrustDiffProps) => { + "use cache"; + + // Cache for hours since trust evidence doesn't change often + cacheLife("hours"); + + const { result, time } = await measuredPromise(trustComparison(specs)); + + console.log("Trust comparison", { specs, time }); + + // Don't render if both packages have no trust evidence + if (result.a.evidence == null && result.b.evidence == null) { + return null; + } + + return ( + + ); +}; + +const TrustDiffSkeleton = () => ; + +const TrustDiff = suspense(TrustDiffInner, TrustDiffSkeleton); + +export default TrustDiff; diff --git a/src/app/[...parts]/page.tsx b/src/app/[...parts]/page.tsx index d3d223e4..2942e8e8 100644 --- a/src/app/[...parts]/page.tsx +++ b/src/app/[...parts]/page.tsx @@ -15,6 +15,7 @@ import DiffIntro from "./_page/DiffIntro"; import NpmDiff from "./_page/NpmDiff"; import PackagephobiaDiff from "./_page/PackagephobiaDiff"; import { type DIFF_TYPE_PARAM_NAME } from "./_page/paramNames"; +import TrustDiff from "./_page/TrustDiff"; export interface DiffPageProps { params: Promise<{ parts: string | string[] }>; @@ -75,6 +76,14 @@ const DiffPageInner = async ({ b={b} services={ <> + { + "use cache"; + + cacheLife("hours"); + + const pack = await packument(packageName, { + // Response is too large to cache in Next's Data Cache; always fetch + cache: "no-store", + }); + + const manifest = pack.versions[version]; + if (!manifest) { + return { evidence: undefined }; + } + + const evidence = getTrustEvidenceFromManifest(manifest); + const provenanceUrl = + evidence === "provenance" ? getProvenanceUrl(manifest) : undefined; + + return { evidence, provenanceUrl }; +} + +/** + * Get trust information for a package spec + */ +export default async function getTrustEvidence( + spec: string | SimplePackageSpec, +): Promise { + const { name, version } = + typeof spec === "string" ? createSimplePackageSpec(spec) : spec; + + if (!name || !version) { + return { evidence: undefined }; + } + + return getTrustInfoForVersion(name, version); +} diff --git a/src/lib/api/npm/trustComparison.ts b/src/lib/api/npm/trustComparison.ts new file mode 100644 index 00000000..30d655dc --- /dev/null +++ b/src/lib/api/npm/trustComparison.ts @@ -0,0 +1,20 @@ +import getTrustEvidence, { type TrustInfo } from "./getTrustEvidence"; + +export interface TrustComparisonResult { + a: TrustInfo; + b: TrustInfo; +} + +/** + * Fetch trust evidence for both packages in a comparison + */ +export default async function trustComparison( + specs: [string, string], +): Promise { + const [a, b] = await Promise.all([ + getTrustEvidence(specs[0]), + getTrustEvidence(specs[1]), + ]); + + return { a, b }; +} From e9fbc626609bbc6323d06be12aa0764ac6d1cdd5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 9 Jan 2026 11:30:25 +0000 Subject: [PATCH 03/17] Address feedback: Show trust level even when both lack evidence and improve provenance extraction - Always display Trust Level section (showing "No trust evidence" is valuable) - Enhanced provenance URL extraction to support multiple SLSA formats - Added proper TypeScript types for attestation structures - Fixed type safety issues with extended manifest properties Co-authored-by: oBusk <13413409+oBusk@users.noreply.github.com> --- next-env.d.ts | 2 +- src/app/[...parts]/_page/TrustDiff.tsx | 6 +- src/lib/api/npm/getTrustEvidence.ts | 166 ++++++++++++++++++++++--- 3 files changed, 148 insertions(+), 26 deletions(-) diff --git a/next-env.d.ts b/next-env.d.ts index 9edff1c7..c4b7818f 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/types/routes.d.ts"; +import "./.next/dev/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/src/app/[...parts]/_page/TrustDiff.tsx b/src/app/[...parts]/_page/TrustDiff.tsx index 1a25480f..f34d09d0 100644 --- a/src/app/[...parts]/_page/TrustDiff.tsx +++ b/src/app/[...parts]/_page/TrustDiff.tsx @@ -21,11 +21,7 @@ const TrustDiffInner = async ({ specs, a, b }: TrustDiffProps) => { console.log("Trust comparison", { specs, time }); - // Don't render if both packages have no trust evidence - if (result.a.evidence == null && result.b.evidence == null) { - return null; - } - + // Always render - showing "no trust evidence" is also valuable information return ( ; +} + +interface ProvenancePayload { + subject?: Array<{ + name?: string; + }>; + predicate?: SLSAPredicate; +} + +// Extended types for manifest with trust-related fields +interface ExtendedNpmUser { + trustedPublisher?: boolean; + [key: string]: unknown; +} + +interface ExtendedDist { + attestations?: { + provenance?: ProvenanceAttestation; + }; + [key: string]: unknown; +} + /** * Get trust evidence for a specific package version from the packument. * Based on pnpm's getTrustEvidence function: @@ -20,13 +67,15 @@ export function getTrustEvidenceFromManifest( manifest: Manifest, ): TrustEvidence { // Check for trustedPublisher on _npmUser - // The trustedPublisher property is not in the standard Person type but may be present - if ((manifest._npmUser as any)?.trustedPublisher) { + const npmUser = manifest._npmUser as unknown as + | ExtendedNpmUser + | undefined; + if (npmUser?.trustedPublisher) { return "trustedPublisher"; } // Check for provenance attestations in dist - // The attestations property is not in the standard PackageDist type but may be present - if ((manifest.dist as any)?.attestations?.provenance) { + const dist = manifest.dist as unknown as ExtendedDist | undefined; + if (dist?.attestations?.provenance) { return "provenance"; } return undefined; @@ -37,30 +86,76 @@ export function getTrustEvidenceFromManifest( */ function getProvenanceUrl(manifest: Manifest): string | undefined { // The attestations property is not in the standard PackageDist type but may be present - const provenance = (manifest.dist as any)?.attestations?.provenance; + const dist = manifest.dist as unknown as ExtendedDist | undefined; + const attestations = dist?.attestations; + if (!attestations) { + return undefined; + } + + // Provenance is typically the first attestation with SLSA predicate type + const provenance = attestations.provenance; if (!provenance) { return undefined; } // Try to extract the source repository URL from provenance - // The provenance object typically contains a predicateType and subject - // We need to look for the source repository in the provenance data + // Based on SLSA provenance v0.2 and v1.0 format try { - if (typeof provenance === "object" && provenance.predicateType) { - // Provenance attestations follow SLSA format - // The source repo info is in the build details - const buildConfig = provenance.predicate?.buildConfig; - if (buildConfig?.source?.repository?.url) { - const repoUrl = buildConfig.source.repository.url; - const commitSha = buildConfig.source.digest?.sha1; - - if (commitSha) { - // Convert git URL to GitHub web URL - const match = repoUrl.match( - /github\.com[:/]([^/]+\/[^/.]+)/, + // The provenance attestation contains a bundle with a dsseEnvelope + // which has a payload that needs to be decoded + let predicateObj: ProvenancePayload; + + // Check if provenance is already an object or needs to be parsed + if (typeof provenance === "string") { + predicateObj = JSON.parse(provenance) as ProvenancePayload; + } else if (provenance.dsseEnvelope?.payload) { + // Decode base64 payload + const payload = Buffer.from( + provenance.dsseEnvelope.payload, + "base64", + ).toString("utf-8"); + predicateObj = JSON.parse(payload) as ProvenancePayload; + } else { + predicateObj = provenance as ProvenancePayload; + } + + // Extract source commit from predicate + // SLSA v0.2 format: predicate.materials[].uri + // SLSA v1.0 format: predicate.buildDefinition.externalParameters.source + const predicate = + predicateObj.predicate || + (predicateObj as unknown as SLSAPredicate); + + // Try SLSA v1.0 format first + if (predicate.buildDefinition?.externalParameters?.source) { + const source = predicate.buildDefinition.externalParameters.source; + if (source.repository && source.ref) { + return extractGitHubUrl(source.repository, source.ref); + } + } + + // Try SLSA v0.2 format + if (Array.isArray(predicate.materials)) { + for (const material of predicate.materials) { + if ( + material.uri && + material.uri.includes("github.com") && + material.digest?.sha1 + ) { + return extractGitHubUrl(material.uri, material.digest.sha1); + } + } + } + + // Fallback: try to find any GitHub reference in the subject + if (predicateObj.subject && Array.isArray(predicateObj.subject)) { + for (const subject of predicateObj.subject) { + if (subject.name && subject.name.includes("github.com")) { + const match = subject.name.match( + /github\.com[:/]([^/]+\/[^/@]+)(?:@|\/tree\/)([a-f0-9]{40})/, ); if (match) { - return `https://github.com/${match[1]}/commit/${commitSha}`; + return `https://github.com/${match[1]}/commit/${match[2]}`; } } } @@ -72,6 +167,37 @@ function getProvenanceUrl(manifest: Manifest): string | undefined { return undefined; } +/** + * Extract a GitHub commit URL from a repository URL and commit reference + */ +function extractGitHubUrl( + repoUrl: string, + commitRef: string, +): string | undefined { + // Handle various GitHub URL formats + const match = repoUrl.match(/github\.com[:/]([^/]+\/[^/.@]+?)(?:\.git)?$/); + if (!match) { + return undefined; + } + + const repo = match[1]; + + // Extract commit SHA from ref (could be refs/heads/main or just a SHA) + const commitSha = commitRef; + if (commitRef.startsWith("refs/")) { + // This is a branch ref, not a commit SHA + // We can't create a direct commit URL without the actual SHA + return undefined; + } + + // Validate it looks like a SHA (40 hex characters) + if (/^[a-f0-9]{40}$/i.test(commitSha)) { + return `https://github.com/${repo}/commit/${commitSha}`; + } + + return undefined; +} + /** * Separate function that takes only packagename and version for better caching. */ From 3d0ef9ae4e059369126123881ccacd2f1d22dd86 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 9 Jan 2026 11:54:04 +0000 Subject: [PATCH 04/17] Fix provenance URL extraction and security issue - Fetch attestation bundle from npm registry URL - Parse SLSA v1.0 provenance format correctly - Extract commit SHA from resolvedDependencies - Fix security: validate GitHub URLs use exact hostname - Remove unsafe .includes('github.com') checks - All tests and linting pass Co-authored-by: oBusk <13413409+oBusk@users.noreply.github.com> --- src/lib/api/npm/getTrustEvidence.ts | 245 +++++++++++++++------------- 1 file changed, 133 insertions(+), 112 deletions(-) diff --git a/src/lib/api/npm/getTrustEvidence.ts b/src/lib/api/npm/getTrustEvidence.ts index 98d6b8af..72c9e572 100644 --- a/src/lib/api/npm/getTrustEvidence.ts +++ b/src/lib/api/npm/getTrustEvidence.ts @@ -11,38 +11,48 @@ export interface TrustInfo { provenanceUrl?: string; } -// Types for provenance attestation structures -interface ProvenanceAttestation { - dsseEnvelope?: { - payload: string; - [key: string]: unknown; - }; - predicate?: unknown; - [key: string]: unknown; -} - -interface SLSAPredicate { +// Types for SLSA provenance v1.0 structures +interface SLSAv1Predicate { buildDefinition?: { externalParameters?: { - source?: { - repository?: string; + workflow?: { ref?: string; + repository?: string; + path?: string; }; }; + resolvedDependencies?: Array<{ + uri?: string; + digest?: { + gitCommit?: string; + sha1?: string; + }; + }>; }; - materials?: Array<{ - uri?: string; - digest?: { - sha1?: string; - }; - }>; } -interface ProvenancePayload { +interface SLSAV1Payload { + _type?: string; subject?: Array<{ name?: string; }>; - predicate?: SLSAPredicate; + predicateType?: string; + predicate?: SLSAv1Predicate; +} + +interface AttestationBundle { + predicateType?: string; + dsseEnvelope?: { + payload: string; + payloadType?: string; + [key: string]: unknown; + }; + [key: string]: unknown; +} + +interface ProvenanceReference { + url?: string; + predicateType?: string; } // Extended types for manifest with trust-related fields @@ -53,7 +63,9 @@ interface ExtendedNpmUser { interface ExtendedDist { attestations?: { - provenance?: ProvenanceAttestation; + url?: string; + provenance?: ProvenanceReference; + [key: string]: unknown; }; [key: string]: unknown; } @@ -67,9 +79,7 @@ export function getTrustEvidenceFromManifest( manifest: Manifest, ): TrustEvidence { // Check for trustedPublisher on _npmUser - const npmUser = manifest._npmUser as unknown as - | ExtendedNpmUser - | undefined; + const npmUser = manifest._npmUser as unknown as ExtendedNpmUser | undefined; if (npmUser?.trustedPublisher) { return "trustedPublisher"; } @@ -81,85 +91,111 @@ export function getTrustEvidenceFromManifest( return undefined; } +/** + * Fetch attestation bundle from npm registry + */ +async function fetchAttestationBundle( + url: string, +): Promise { + try { + const response = await fetch(url, { + headers: { + Accept: "application/json", + }, + }); + if (!response.ok) { + return null; + } + const data = (await response.json()) as { attestations?: unknown }; + if ( + data.attestations && + Array.isArray(data.attestations) && + data.attestations.length > 0 + ) { + return data.attestations as AttestationBundle[]; + } + } catch (e) { + console.warn("Failed to fetch attestation bundle", e); + } + return null; +} + /** * Extract provenance URL from manifest if available */ -function getProvenanceUrl(manifest: Manifest): string | undefined { - // The attestations property is not in the standard PackageDist type but may be present +async function getProvenanceUrl( + manifest: Manifest, +): Promise { const dist = manifest.dist as unknown as ExtendedDist | undefined; const attestations = dist?.attestations; - if (!attestations) { + if (!attestations?.url) { return undefined; } - // Provenance is typically the first attestation with SLSA predicate type - const provenance = attestations.provenance; - if (!provenance) { + // Fetch the attestation bundle from npm registry + const bundles = await fetchAttestationBundle(attestations.url); + if (!bundles) { + return undefined; + } + + // Find the SLSA provenance attestation + const provenanceBundle = bundles.find( + (bundle) => + bundle.dsseEnvelope && + (bundle.predicateType === "https://slsa.dev/provenance/v1" || + bundle.predicateType?.includes("slsa.dev/provenance")), + ); + + if (!provenanceBundle?.dsseEnvelope?.payload) { return undefined; } - // Try to extract the source repository URL from provenance - // Based on SLSA provenance v0.2 and v1.0 format try { - // The provenance attestation contains a bundle with a dsseEnvelope - // which has a payload that needs to be decoded - let predicateObj: ProvenancePayload; - - // Check if provenance is already an object or needs to be parsed - if (typeof provenance === "string") { - predicateObj = JSON.parse(provenance) as ProvenancePayload; - } else if (provenance.dsseEnvelope?.payload) { - // Decode base64 payload - const payload = Buffer.from( - provenance.dsseEnvelope.payload, - "base64", - ).toString("utf-8"); - predicateObj = JSON.parse(payload) as ProvenancePayload; - } else { - predicateObj = provenance as ProvenancePayload; + // Decode base64 payload + const payload = Buffer.from( + provenanceBundle.dsseEnvelope.payload, + "base64", + ).toString("utf-8"); + const predicateObj = JSON.parse(payload) as SLSAV1Payload; + + // Extract from SLSA v1.0 format + const predicate = predicateObj.predicate; + if (!predicate?.buildDefinition) { + return undefined; + } + + // Get repository URL from external parameters + const repository = + predicate.buildDefinition.externalParameters?.workflow?.repository; + if (!repository) { + return undefined; } - // Extract source commit from predicate - // SLSA v0.2 format: predicate.materials[].uri - // SLSA v1.0 format: predicate.buildDefinition.externalParameters.source - const predicate = - predicateObj.predicate || - (predicateObj as unknown as SLSAPredicate); - - // Try SLSA v1.0 format first - if (predicate.buildDefinition?.externalParameters?.source) { - const source = predicate.buildDefinition.externalParameters.source; - if (source.repository && source.ref) { - return extractGitHubUrl(source.repository, source.ref); - } + // Validate it's a GitHub URL (security: only allow github.com as the exact hostname) + if (!isValidGitHubUrl(repository)) { + return undefined; } - // Try SLSA v0.2 format - if (Array.isArray(predicate.materials)) { - for (const material of predicate.materials) { - if ( - material.uri && - material.uri.includes("github.com") && - material.digest?.sha1 - ) { - return extractGitHubUrl(material.uri, material.digest.sha1); - } - } + // Get commit SHA from resolvedDependencies + const deps = predicate.buildDefinition.resolvedDependencies; + if (!deps || !Array.isArray(deps) || deps.length === 0) { + return undefined; } - // Fallback: try to find any GitHub reference in the subject - if (predicateObj.subject && Array.isArray(predicateObj.subject)) { - for (const subject of predicateObj.subject) { - if (subject.name && subject.name.includes("github.com")) { - const match = subject.name.match( - /github\.com[:/]([^/]+\/[^/@]+)(?:@|\/tree\/)([a-f0-9]{40})/, - ); - if (match) { - return `https://github.com/${match[1]}/commit/${match[2]}`; - } - } - } + const commitSha = deps[0]?.digest?.gitCommit; + if (!commitSha || !/^[a-f0-9]{40}$/i.test(commitSha)) { + return undefined; } + + // Extract owner/repo from GitHub URL + const repoMatch = repository.match( + /^https:\/\/github\.com\/([^/]+\/[^/]+?)(?:\.git)?$/, + ); + if (!repoMatch) { + return undefined; + } + + return `https://github.com/${repoMatch[1]}/commit/${commitSha}`; } catch (e) { console.warn("Failed to parse provenance URL", e); } @@ -168,34 +204,17 @@ function getProvenanceUrl(manifest: Manifest): string | undefined { } /** - * Extract a GitHub commit URL from a repository URL and commit reference + * Validate that a URL is a proper GitHub repository URL + * Security: Only allow github.com as the exact hostname (not as part of path or subdomain) */ -function extractGitHubUrl( - repoUrl: string, - commitRef: string, -): string | undefined { - // Handle various GitHub URL formats - const match = repoUrl.match(/github\.com[:/]([^/]+\/[^/.@]+?)(?:\.git)?$/); - if (!match) { - return undefined; - } - - const repo = match[1]; - - // Extract commit SHA from ref (could be refs/heads/main or just a SHA) - const commitSha = commitRef; - if (commitRef.startsWith("refs/")) { - // This is a branch ref, not a commit SHA - // We can't create a direct commit URL without the actual SHA - return undefined; - } - - // Validate it looks like a SHA (40 hex characters) - if (/^[a-f0-9]{40}$/i.test(commitSha)) { - return `https://github.com/${repo}/commit/${commitSha}`; +function isValidGitHubUrl(url: string): boolean { + try { + const parsed = new URL(url); + // Only allow github.com as the hostname + return parsed.hostname === "github.com" && parsed.protocol === "https:"; + } catch { + return false; } - - return undefined; } /** @@ -221,7 +240,9 @@ async function getTrustInfoForVersion( const evidence = getTrustEvidenceFromManifest(manifest); const provenanceUrl = - evidence === "provenance" ? getProvenanceUrl(manifest) : undefined; + evidence === "provenance" + ? await getProvenanceUrl(manifest) + : undefined; return { evidence, provenanceUrl }; } From 100d2f4bc3eb8684ef0b0d9f3f1362736b98c6d7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 9 Jan 2026 11:58:53 +0000 Subject: [PATCH 05/17] Co-authored-by: oBusk <13413409+oBusk@users.noreply.github.com> --- next-env.d.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/next-env.d.ts b/next-env.d.ts index c4b7818f..9edff1c7 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/dev/types/routes.d.ts"; +import "./.next/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. From 88fc42f75f3bea43040e742ea22835d33c76f666 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 9 Jan 2026 13:00:12 +0000 Subject: [PATCH 06/17] Fix provenance URL extraction - correct nested bundle structure - Update AttestationBundle type to include nested bundle object - Fix extraction to use bundle.bundle.dsseEnvelope.payload - Provenance URLs should now be correctly extracted from npm attestations - All tests pass, linting passes, typecheck passes Co-authored-by: oBusk <13413409+oBusk@users.noreply.github.com> --- next-env.d.ts | 2 +- src/lib/api/npm/getTrustEvidence.ts | 15 +++++++++------ 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/next-env.d.ts b/next-env.d.ts index 9edff1c7..c4b7818f 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/types/routes.d.ts"; +import "./.next/dev/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/src/lib/api/npm/getTrustEvidence.ts b/src/lib/api/npm/getTrustEvidence.ts index 72c9e572..c69f4325 100644 --- a/src/lib/api/npm/getTrustEvidence.ts +++ b/src/lib/api/npm/getTrustEvidence.ts @@ -42,9 +42,12 @@ interface SLSAV1Payload { interface AttestationBundle { predicateType?: string; - dsseEnvelope?: { - payload: string; - payloadType?: string; + bundle?: { + dsseEnvelope?: { + payload: string; + payloadType?: string; + [key: string]: unknown; + }; [key: string]: unknown; }; [key: string]: unknown; @@ -141,19 +144,19 @@ async function getProvenanceUrl( // Find the SLSA provenance attestation const provenanceBundle = bundles.find( (bundle) => - bundle.dsseEnvelope && + bundle.bundle?.dsseEnvelope && (bundle.predicateType === "https://slsa.dev/provenance/v1" || bundle.predicateType?.includes("slsa.dev/provenance")), ); - if (!provenanceBundle?.dsseEnvelope?.payload) { + if (!provenanceBundle?.bundle?.dsseEnvelope?.payload) { return undefined; } try { // Decode base64 payload const payload = Buffer.from( - provenanceBundle.dsseEnvelope.payload, + provenanceBundle.bundle.dsseEnvelope.payload, "base64", ).toString("utf-8"); const predicateObj = JSON.parse(payload) as SLSAV1Payload; From 07a3b8b6de7d9f844a4dcb86f07f09439759d2b4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 9 Jan 2026 13:02:49 +0000 Subject: [PATCH 07/17] Co-authored-by: oBusk <13413409+oBusk@users.noreply.github.com> --- next-env.d.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/next-env.d.ts b/next-env.d.ts index c4b7818f..9edff1c7 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/dev/types/routes.d.ts"; +import "./.next/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. From 2f9c082a636049ecd3dba63abac03cd90615ee3e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 9 Jan 2026 13:13:12 +0000 Subject: [PATCH 08/17] Show provenance links for trustedPublisher and link to repo tree - Extract provenance URL for both provenance and trustedPublisher evidence (trustedPublisher always includes provenance data) - Change provenance link from /commit/ to /tree/ to link to repo at that hash - Update compare URL regex to handle both /commit/ and /tree/ URLs - Compare button now shows when either/both packages have provenance URLs - All tests pass, linting passes, build succeeds Co-authored-by: oBusk <13413409+oBusk@users.noreply.github.com> --- .../[...parts]/_page/TrustComparison/TrustComparison.tsx | 5 +++-- src/lib/api/npm/getTrustEvidence.ts | 6 ++++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/app/[...parts]/_page/TrustComparison/TrustComparison.tsx b/src/app/[...parts]/_page/TrustComparison/TrustComparison.tsx index 4023c500..a669f90e 100644 --- a/src/app/[...parts]/_page/TrustComparison/TrustComparison.tsx +++ b/src/app/[...parts]/_page/TrustComparison/TrustComparison.tsx @@ -151,11 +151,12 @@ const TrustComparison: FunctionComponent = ({ * Extract GitHub compare URL from two provenance URLs */ function getGitHubCompareUrl(aUrl: string, bUrl: string): string | undefined { + // Match both /commit/ and /tree/ URLs const aMatch = aUrl.match( - /github\.com\/([^/]+\/[^/]+)\/commit\/([a-f0-9]+)/, + /github\.com\/([^/]+\/[^/]+)\/(?:commit|tree)\/([a-f0-9]+)/, ); const bMatch = bUrl.match( - /github\.com\/([^/]+\/[^/]+)\/commit\/([a-f0-9]+)/, + /github\.com\/([^/]+\/[^/]+)\/(?:commit|tree)\/([a-f0-9]+)/, ); if (aMatch && bMatch && aMatch[1] === bMatch[1]) { diff --git a/src/lib/api/npm/getTrustEvidence.ts b/src/lib/api/npm/getTrustEvidence.ts index c69f4325..ba049f57 100644 --- a/src/lib/api/npm/getTrustEvidence.ts +++ b/src/lib/api/npm/getTrustEvidence.ts @@ -198,7 +198,7 @@ async function getProvenanceUrl( return undefined; } - return `https://github.com/${repoMatch[1]}/commit/${commitSha}`; + return `https://github.com/${repoMatch[1]}/tree/${commitSha}`; } catch (e) { console.warn("Failed to parse provenance URL", e); } @@ -242,8 +242,10 @@ async function getTrustInfoForVersion( } const evidence = getTrustEvidenceFromManifest(manifest); + // Both provenance and trustedPublisher have provenance data + // (trustedPublisher always includes provenance) const provenanceUrl = - evidence === "provenance" + evidence === "provenance" || evidence === "trustedPublisher" ? await getProvenanceUrl(manifest) : undefined; From fd8fdb93fb1e429d06363d86bd78027036a6603a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 9 Jan 2026 13:16:30 +0000 Subject: [PATCH 09/17] Co-authored-by: oBusk <13413409+oBusk@users.noreply.github.com> --- next-env.d.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/next-env.d.ts b/next-env.d.ts index 9edff1c7..c4b7818f 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/types/routes.d.ts"; +import "./.next/dev/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. From d2e7a44de8802c658e1f8ee14431b08251879628 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 9 Jan 2026 13:28:39 +0000 Subject: [PATCH 10/17] Redesign Trust Level UI with larger icons and better layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add large icons: ✕ for no evidence, ✓ for provenance, 🛡️✓ for trusted publisher - Add info icons (ℹ️) with tooltips explaining each trust level - Move provenance source link below the badge (📦 Source) - Simplify compare link to "⇄ Compare source" with icon - Improve spacing and layout with flex columns - All tests pass, linting passes, build succeeds Co-authored-by: oBusk <13413409+oBusk@users.noreply.github.com> --- next-env.d.ts | 2 +- .../_page/TrustComparison/TrustComparison.tsx | 89 ++++++++++++++----- 2 files changed, 70 insertions(+), 21 deletions(-) diff --git a/next-env.d.ts b/next-env.d.ts index c4b7818f..9edff1c7 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/dev/types/routes.d.ts"; +import "./.next/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/src/app/[...parts]/_page/TrustComparison/TrustComparison.tsx b/src/app/[...parts]/_page/TrustComparison/TrustComparison.tsx index a669f90e..eb9ccc85 100644 --- a/src/app/[...parts]/_page/TrustComparison/TrustComparison.tsx +++ b/src/app/[...parts]/_page/TrustComparison/TrustComparison.tsx @@ -22,7 +22,32 @@ function getTrustLabel(evidence: TrustEvidence): string { case "provenance": return "Provenance"; default: - return "No trust evidence"; + return "No Evidence"; + } +} + +function getTrustTooltip(evidence: TrustEvidence): string { + switch (evidence) { + case "trustedPublisher": + return "Published using a trusted GitHub Actions workflow with OIDC authentication. Highest level of trust."; + case "provenance": + return "Published with SLSA provenance attestation. Cryptographically verifiable build information."; + default: + return "This package was published without provenance attestation or trusted publisher verification."; + } +} + +function getTrustIcon(evidence: TrustEvidence): string { + switch (evidence) { + case "trustedPublisher": + // Shield with checkmark + return "🛡️✓"; + case "provenance": + // Checkmark + return "✓"; + default: + // X mark + return "✕"; } } @@ -54,28 +79,52 @@ const TrustBox: FunctionComponent = ({ isUpgrade = false, }) => { const label = getTrustLabel(evidence); + const tooltip = getTrustTooltip(evidence); + const icon = getTrustIcon(evidence); const colorClass = getTrustColor(evidence, isDowngrade); - const content = ( -
-

{label}

- {isDowngrade ?

⚠️ Trust downgrade

: null} - {isUpgrade ?

✓ Trust improvement

: null} + return ( +
+
+ +

{label}

+ + ℹ️ + +
+ {isDowngrade ? ( +

+ ⚠️ Trust downgrade +

+ ) : null} + {isUpgrade ? ( +

+ ✓ Trust improvement +

+ ) : null} + {provenanceUrl ? ( +
+ + 📦 Source + +
+ ) : null}
); - - if (provenanceUrl) { - return ( - - {content} - - ); - } - - return content; }; export interface TrustComparisonProps { @@ -139,7 +188,7 @@ const TrustComparison: FunctionComponent = ({ href={compareUrl} className="text-blue-600 hover:underline dark:text-blue-400" > - Compare source on GitHub + ⇄ Compare source
) : null} From 8ad222868abfd96b8ab6d2274c90ebb3083f760f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 9 Jan 2026 13:33:35 +0000 Subject: [PATCH 11/17] Show trust level comparison in diff view Co-authored-by: oBusk <13413409+oBusk@users.noreply.github.com> --- next-env.d.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/next-env.d.ts b/next-env.d.ts index 9edff1c7..c4b7818f 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/types/routes.d.ts"; +import "./.next/dev/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. From e4fb3528b570e5e1559ced1764bf7ce08468c687 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 9 Jan 2026 13:46:55 +0000 Subject: [PATCH 12/17] Replace emoji icons with lucide-react icons and use Tooltip component - Replace emoji icons with proper lucide-react SVG icons: - XCircle for no evidence - CheckCircle2 for provenance - Shield + CheckCircle2 overlay for trusted publisher - Replace native title attributes with existing Tooltip component - Add Info icon with tooltip for trust level explanations - Convert compare link to Button component with outline variant - Use PackageOpen icon for source links - Use GitCompareArrows icon for compare button - All tests pass, linting passes, build succeeds Co-authored-by: oBusk <13413409+oBusk@users.noreply.github.com> --- .../_page/TrustComparison/TrustComparison.tsx | 69 ++++++++++++------- 1 file changed, 45 insertions(+), 24 deletions(-) diff --git a/src/app/[...parts]/_page/TrustComparison/TrustComparison.tsx b/src/app/[...parts]/_page/TrustComparison/TrustComparison.tsx index eb9ccc85..8b6a3386 100644 --- a/src/app/[...parts]/_page/TrustComparison/TrustComparison.tsx +++ b/src/app/[...parts]/_page/TrustComparison/TrustComparison.tsx @@ -1,5 +1,17 @@ +"use client"; + +import { + CheckCircle2, + GitCompareArrows, + Info, + PackageOpen, + Shield, + XCircle, +} from "lucide-react"; import { type FunctionComponent } from "react"; import ExternalLink from "^/components/ExternalLink"; +import Button from "^/components/ui/Button"; +import Tooltip from "^/components/ui/Tooltip"; import type { TrustEvidence } from "^/lib/api/npm/getTrustEvidence"; import { cx } from "^/lib/cva"; import type SimplePackageSpec from "^/lib/SimplePackageSpec"; @@ -37,17 +49,22 @@ function getTrustTooltip(evidence: TrustEvidence): string { } } -function getTrustIcon(evidence: TrustEvidence): string { +function getTrustIcon(evidence: TrustEvidence): React.ReactNode { switch (evidence) { case "trustedPublisher": // Shield with checkmark - return "🛡️✓"; + return ( +
+ + +
+ ); case "provenance": // Checkmark - return "✓"; + return ; default: // X mark - return "✕"; + return ; } } @@ -87,21 +104,21 @@ const TrustBox: FunctionComponent = ({
- +

{label}

- - ℹ️ - + + +
{isDowngrade ? (

@@ -117,9 +134,10 @@ const TrustBox: FunctionComponent = ({

- 📦 Source + + Source
) : null} @@ -183,13 +201,16 @@ const TrustComparison: FunctionComponent = ({ } /> {compareUrl ? ( -
- - ⇄ Compare source - +
+
) : null} From 48329cddcf8f12d038a172b9749294ee7e61ca54 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 9 Jan 2026 13:59:49 +0000 Subject: [PATCH 13/17] Refine Trust Level UI based on feedback - Move Info icon inline with trust level labels (Provenance/Trusted Publisher) - Reserve space in left column with invisible placeholder for alignment - Replace PackageOpen icon with FileCode (looks less like an insect) - Change compare button from outline to secondary variant (filled like npm diff button) - Add bottom margin (mb-4) to Trust Level section for spacing from Packagephobia - All tests pass, linting passes, build succeeds Co-authored-by: oBusk <13413409+oBusk@users.noreply.github.com> --- .../_page/TrustComparison/TrustComparison.tsx | 41 +++++++++++-------- 1 file changed, 25 insertions(+), 16 deletions(-) diff --git a/src/app/[...parts]/_page/TrustComparison/TrustComparison.tsx b/src/app/[...parts]/_page/TrustComparison/TrustComparison.tsx index 8b6a3386..1dc43c94 100644 --- a/src/app/[...parts]/_page/TrustComparison/TrustComparison.tsx +++ b/src/app/[...parts]/_page/TrustComparison/TrustComparison.tsx @@ -2,9 +2,9 @@ import { CheckCircle2, + FileCode, GitCompareArrows, Info, - PackageOpen, Shield, XCircle, } from "lucide-react"; @@ -109,16 +109,20 @@ const TrustBox: FunctionComponent = ({ )} > -

{label}

- - - +
+

{label}

+ {evidence !== undefined ? ( + + + + ) : null} +
{isDowngrade ? (

@@ -129,14 +133,17 @@ const TrustBox: FunctionComponent = ({

✓ Trust improvement

- ) : null} + ) : ( + // Reserve space to keep alignment when there's no upgrade/downgrade message +

Placeholder

+ )} {provenanceUrl ? (
- + Source
@@ -201,8 +208,8 @@ const TrustComparison: FunctionComponent = ({ } /> {compareUrl ? ( -
-
- ) : null} + ) : ( +
+ )} ); }; From 7eea0252f51b1c80da9bfc11253780666ff95afc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 9 Jan 2026 22:59:54 +0000 Subject: [PATCH 14/17] Move trust badges to intro section and use ShieldCheck icon - Replace Shield+CheckCircle2 combination with ShieldCheck icon (centered checkmark) - Replace CheckCircle2 with simple Check icon for provenance - Create TrustBadge component to display in SpecBox (intro section) - Create CompareSourceButton component for compare functionality - Move trust display above PublishDate in intro - Remove TrustDiff from services section - Trust badges now appear as small inline badges in the intro - Compare source button appears below the package comparison boxes - All tests pass, linting passes, build succeeds Co-authored-by: oBusk <13413409+oBusk@users.noreply.github.com> --- .../_page/DiffIntro/CompareSourceButton.tsx | 85 +++++++++++++ .../[...parts]/_page/DiffIntro/DiffIntro.tsx | 6 + .../[...parts]/_page/DiffIntro/SpecBox.tsx | 6 + .../[...parts]/_page/DiffIntro/TrustBadge.tsx | 117 ++++++++++++++++++ .../_page/TrustComparison/TrustComparison.tsx | 17 +-- src/app/[...parts]/page.tsx | 9 -- 6 files changed, 220 insertions(+), 20 deletions(-) create mode 100644 src/app/[...parts]/_page/DiffIntro/CompareSourceButton.tsx create mode 100644 src/app/[...parts]/_page/DiffIntro/TrustBadge.tsx diff --git a/src/app/[...parts]/_page/DiffIntro/CompareSourceButton.tsx b/src/app/[...parts]/_page/DiffIntro/CompareSourceButton.tsx new file mode 100644 index 00000000..42ed47b1 --- /dev/null +++ b/src/app/[...parts]/_page/DiffIntro/CompareSourceButton.tsx @@ -0,0 +1,85 @@ +import { GitCompareArrows } from "lucide-react"; +import ExternalLink from "^/components/ExternalLink"; +import Button from "^/components/ui/Button"; +import Skeleton from "^/components/ui/Skeleton"; +import getTrustEvidence from "^/lib/api/npm/getTrustEvidence"; +import { cx } from "^/lib/cva"; +import type SimplePackageSpec from "^/lib/SimplePackageSpec"; +import suspense from "^/lib/suspense"; + +export interface CompareSourceButtonProps { + className?: string; + a: SimplePackageSpec; + b: SimplePackageSpec; +} + +const shared = cx("my-2 flex items-center justify-center"); + +/** + * Extract GitHub compare URL from two provenance URLs + */ +function getGitHubCompareUrl(aUrl: string, bUrl: string): string | undefined { + // Match both /commit/ and /tree/ URLs + const aMatch = aUrl.match( + /github\.com\/([^/]+\/[^/]+)\/(?:commit|tree)\/([a-f0-9]+)/, + ); + const bMatch = bUrl.match( + /github\.com\/([^/]+\/[^/]+)\/(?:commit|tree)\/([a-f0-9]+)/, + ); + + if (aMatch && bMatch && aMatch[1] === bMatch[1]) { + const repo = aMatch[1]; + const aCommit = aMatch[2]; + const bCommit = bMatch[2]; + return `https://github.com/${repo}/compare/${aCommit}...${bCommit}`; + } + + return undefined; +} + +async function CompareSourceButton({ + a, + b, + className, +}: CompareSourceButtonProps) { + const aResult = await getTrustEvidence(a); + const bResult = await getTrustEvidence(b); + + const compareUrl = + aResult.provenanceUrl && bResult.provenanceUrl + ? getGitHubCompareUrl(aResult.provenanceUrl, bResult.provenanceUrl) + : undefined; + + if (!compareUrl) { + return null; + } + + return ( +
+ +
+ ); +} + +function CompareSourceButtonFallback({ className }: CompareSourceButtonProps) { + return ( +
+ +
+ ); +} + +const SuspensedCompareSourceButton = suspense( + CompareSourceButton, + CompareSourceButtonFallback, +); + +export default SuspensedCompareSourceButton; diff --git a/src/app/[...parts]/_page/DiffIntro/DiffIntro.tsx b/src/app/[...parts]/_page/DiffIntro/DiffIntro.tsx index 27a1776b..693c7d5e 100644 --- a/src/app/[...parts]/_page/DiffIntro/DiffIntro.tsx +++ b/src/app/[...parts]/_page/DiffIntro/DiffIntro.tsx @@ -6,6 +6,7 @@ import { cx } from "^/lib/cva"; import { type NpmDiffOptions } from "^/lib/npmDiff"; import type SimplePackageSpec from "^/lib/SimplePackageSpec"; import contentVisibility from "^/lib/utils/contentVisibility"; +import CompareSourceButton from "./CompareSourceButton"; import Halfs from "./Halfs"; import Options from "./Options"; import SpecBox from "./SpecBox"; @@ -50,6 +51,11 @@ const DiffIntro = forwardRef, DiffIntroProps>( /> } /> + {services} diff --git a/src/app/[...parts]/_page/DiffIntro/SpecBox.tsx b/src/app/[...parts]/_page/DiffIntro/SpecBox.tsx index 56aadf74..8d55608a 100644 --- a/src/app/[...parts]/_page/DiffIntro/SpecBox.tsx +++ b/src/app/[...parts]/_page/DiffIntro/SpecBox.tsx @@ -5,6 +5,7 @@ import type SimplePackageSpec from "^/lib/SimplePackageSpec"; import { simplePackageSpecToString } from "^/lib/SimplePackageSpec"; import PublishDate from "./PublishDate"; import ServiceLinks from "./ServiceLinks"; +import TrustBadge from "./TrustBadge"; interface SpecBoxProps extends HTMLAttributes { pkg: SimplePackageSpec; @@ -15,6 +16,11 @@ const SpecBox = forwardRef( ({ pkg, pkgClassName, ...props }, ref) => (
+ ; + case "provenance": + return ; + default: + return ; + } +} + +function getTrustColor(evidence: TrustEvidence): string { + switch (evidence) { + case "trustedPublisher": + return "text-green-600 dark:text-green-400"; + case "provenance": + return "text-blue-600 dark:text-blue-400"; + default: + return "text-gray-500 dark:text-gray-400"; + } +} + +async function TrustBadge({ pkg, className }: TrustBadgeProps) { + const { evidence, provenanceUrl } = await getTrustEvidence(pkg); + + const label = getTrustLabel(evidence); + const tooltip = getTrustTooltip(evidence); + const icon = getTrustIcon(evidence); + const colorClass = getTrustColor(evidence); + + const content = ( +
+ {icon} + {label} + {evidence !== undefined ? ( + + + + ) : null} +
+ ); + + if (provenanceUrl) { + return ( + + {content} + + ); + } + + return content; +} + +function TrustBadgeFallback({ className }: TrustBadgeProps) { + return ( +
+ +
+ ); +} + +const SuspensedTrustBadge = suspense(TrustBadge, TrustBadgeFallback); + +export default SuspensedTrustBadge; diff --git a/src/app/[...parts]/_page/TrustComparison/TrustComparison.tsx b/src/app/[...parts]/_page/TrustComparison/TrustComparison.tsx index 1dc43c94..b12dcc79 100644 --- a/src/app/[...parts]/_page/TrustComparison/TrustComparison.tsx +++ b/src/app/[...parts]/_page/TrustComparison/TrustComparison.tsx @@ -1,11 +1,11 @@ "use client"; import { - CheckCircle2, + Check, FileCode, GitCompareArrows, Info, - Shield, + ShieldCheck, XCircle, } from "lucide-react"; import { type FunctionComponent } from "react"; @@ -52,16 +52,11 @@ function getTrustTooltip(evidence: TrustEvidence): string { function getTrustIcon(evidence: TrustEvidence): React.ReactNode { switch (evidence) { case "trustedPublisher": - // Shield with checkmark - return ( -
- - -
- ); + // Shield with checkmark centered + return ; case "provenance": - // Checkmark - return ; + // Simple checkmark + return ; default: // X mark return ; diff --git a/src/app/[...parts]/page.tsx b/src/app/[...parts]/page.tsx index 2942e8e8..d3d223e4 100644 --- a/src/app/[...parts]/page.tsx +++ b/src/app/[...parts]/page.tsx @@ -15,7 +15,6 @@ import DiffIntro from "./_page/DiffIntro"; import NpmDiff from "./_page/NpmDiff"; import PackagephobiaDiff from "./_page/PackagephobiaDiff"; import { type DIFF_TYPE_PARAM_NAME } from "./_page/paramNames"; -import TrustDiff from "./_page/TrustDiff"; export interface DiffPageProps { params: Promise<{ parts: string | string[] }>; @@ -76,14 +75,6 @@ const DiffPageInner = async ({ b={b} services={ <> - Date: Sat, 10 Jan 2026 22:38:48 +0000 Subject: [PATCH 15/17] Refine trust badges: larger icons, trust warnings, separate source links, cleanup - Increase icon size from 4 to 5 (more attention-grabbing) - Change font from text-xs to text-sm and medium to semibold - Add red background highlight for trust downgrades (very prominent warning) - Show "Trust improved" message for trust upgrades - Make source link separate from badge (badge not clickable, only source link) - Remove unused TrustComparison and TrustDiff components (cleanup) - Pass comparison info to SpecBox to enable trust warnings - CompareSourceButton already validates same repo (no changes needed) - All tests pass, linting passes, build succeeds Co-authored-by: oBusk <13413409+oBusk@users.noreply.github.com> --- next-env.d.ts | 2 +- .../[...parts]/_page/DiffIntro/DiffIntro.tsx | 4 + .../[...parts]/_page/DiffIntro/SpecBox.tsx | 12 +- .../[...parts]/_page/DiffIntro/TrustBadge.tsx | 112 +++++--- .../_page/TrustComparison/TrustComparison.tsx | 246 ------------------ .../_page/TrustComparison/index.tsx | 36 --- src/app/[...parts]/_page/TrustDiff.tsx | 41 --- 7 files changed, 95 insertions(+), 358 deletions(-) delete mode 100644 src/app/[...parts]/_page/TrustComparison/TrustComparison.tsx delete mode 100644 src/app/[...parts]/_page/TrustComparison/index.tsx delete mode 100644 src/app/[...parts]/_page/TrustDiff.tsx diff --git a/next-env.d.ts b/next-env.d.ts index c4b7818f..9edff1c7 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/dev/types/routes.d.ts"; +import "./.next/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/src/app/[...parts]/_page/DiffIntro/DiffIntro.tsx b/src/app/[...parts]/_page/DiffIntro/DiffIntro.tsx index 693c7d5e..27094115 100644 --- a/src/app/[...parts]/_page/DiffIntro/DiffIntro.tsx +++ b/src/app/[...parts]/_page/DiffIntro/DiffIntro.tsx @@ -36,6 +36,8 @@ const DiffIntro = forwardRef, DiffIntroProps>( left={ } @@ -47,6 +49,8 @@ const DiffIntro = forwardRef, DiffIntroProps>( right={ } diff --git a/src/app/[...parts]/_page/DiffIntro/SpecBox.tsx b/src/app/[...parts]/_page/DiffIntro/SpecBox.tsx index 8d55608a..08112aef 100644 --- a/src/app/[...parts]/_page/DiffIntro/SpecBox.tsx +++ b/src/app/[...parts]/_page/DiffIntro/SpecBox.tsx @@ -10,15 +10,25 @@ import TrustBadge from "./TrustBadge"; interface SpecBoxProps extends HTMLAttributes { pkg: SimplePackageSpec; pkgClassName?: string; + /** + * Other package for comparison. If provided, will show trust warnings/improvements. + */ + comparisonPkg?: SimplePackageSpec; + /** + * Whether this is the "from" or "to" package in the comparison + */ + isTarget?: boolean; } const SpecBox = forwardRef( - ({ pkg, pkgClassName, ...props }, ref) => ( + ({ pkg, pkgClassName, comparisonPkg, isTarget, ...props }, ref) => (
; @@ -61,47 +75,79 @@ function getTrustColor(evidence: TrustEvidence): string { } } -async function TrustBadge({ pkg, className }: TrustBadgeProps) { +async function TrustBadge({ + pkg, + className, + comparisonPkg, + isTarget, +}: TrustBadgeProps) { const { evidence, provenanceUrl } = await getTrustEvidence(pkg); + // Determine trust change if comparing + let isDowngrade = false; + let isUpgrade = false; + if (comparisonPkg && isTarget) { + const { evidence: comparisonEvidence } = + await getTrustEvidence(comparisonPkg); + const currentRank = getTrustRank(evidence); + const comparisonRank = getTrustRank(comparisonEvidence); + isDowngrade = currentRank < comparisonRank; + isUpgrade = currentRank > comparisonRank; + } + const label = getTrustLabel(evidence); const tooltip = getTrustTooltip(evidence); const icon = getTrustIcon(evidence); const colorClass = getTrustColor(evidence); - const content = ( -
- {icon} - {label} - {evidence !== undefined ? ( - - + + ) : null} +
+ {isDowngrade ? ( +
+ ⚠️ Trust downgrade +
+ ) : null} + {isUpgrade ? ( +
+ ✓ Trust improved +
+ ) : null} + {provenanceUrl ? ( +
+ - - - + + Source + +
) : null}
); - - if (provenanceUrl) { - return ( - - {content} - - ); - } - - return content; } function TrustBadgeFallback({ className }: TrustBadgeProps) { diff --git a/src/app/[...parts]/_page/TrustComparison/TrustComparison.tsx b/src/app/[...parts]/_page/TrustComparison/TrustComparison.tsx deleted file mode 100644 index b12dcc79..00000000 --- a/src/app/[...parts]/_page/TrustComparison/TrustComparison.tsx +++ /dev/null @@ -1,246 +0,0 @@ -"use client"; - -import { - Check, - FileCode, - GitCompareArrows, - Info, - ShieldCheck, - XCircle, -} from "lucide-react"; -import { type FunctionComponent } from "react"; -import ExternalLink from "^/components/ExternalLink"; -import Button from "^/components/ui/Button"; -import Tooltip from "^/components/ui/Tooltip"; -import type { TrustEvidence } from "^/lib/api/npm/getTrustEvidence"; -import { cx } from "^/lib/cva"; -import type SimplePackageSpec from "^/lib/SimplePackageSpec"; -import Halfs from "../DiffIntro/Halfs"; - -const TRUST_RANK = { - trustedPublisher: 2, - provenance: 1, - undefined: 0, -} as const; - -function getTrustRank(evidence: TrustEvidence): number { - return TRUST_RANK[evidence ?? "undefined"]; -} - -function getTrustLabel(evidence: TrustEvidence): string { - switch (evidence) { - case "trustedPublisher": - return "Trusted Publisher"; - case "provenance": - return "Provenance"; - default: - return "No Evidence"; - } -} - -function getTrustTooltip(evidence: TrustEvidence): string { - switch (evidence) { - case "trustedPublisher": - return "Published using a trusted GitHub Actions workflow with OIDC authentication. Highest level of trust."; - case "provenance": - return "Published with SLSA provenance attestation. Cryptographically verifiable build information."; - default: - return "This package was published without provenance attestation or trusted publisher verification."; - } -} - -function getTrustIcon(evidence: TrustEvidence): React.ReactNode { - switch (evidence) { - case "trustedPublisher": - // Shield with checkmark centered - return ; - case "provenance": - // Simple checkmark - return ; - default: - // X mark - return ; - } -} - -function getTrustColor(evidence: TrustEvidence, isDowngrade: boolean): string { - if (isDowngrade) { - return "text-red-600 dark:text-red-400"; - } - switch (evidence) { - case "trustedPublisher": - return "text-green-600 dark:text-green-400"; - case "provenance": - return "text-blue-600 dark:text-blue-400"; - default: - return "text-gray-500 dark:text-gray-400"; - } -} - -interface TrustBoxProps { - evidence: TrustEvidence; - provenanceUrl?: string; - isDowngrade?: boolean; - isUpgrade?: boolean; -} - -const TrustBox: FunctionComponent = ({ - evidence, - provenanceUrl, - isDowngrade = false, - isUpgrade = false, -}) => { - const label = getTrustLabel(evidence); - const tooltip = getTrustTooltip(evidence); - const icon = getTrustIcon(evidence); - const colorClass = getTrustColor(evidence, isDowngrade); - - return ( -
-
- -
-

{label}

- {evidence !== undefined ? ( - - - - ) : null} -
-
- {isDowngrade ? ( -

- ⚠️ Trust downgrade -

- ) : null} - {isUpgrade ? ( -

- ✓ Trust improvement -

- ) : ( - // Reserve space to keep alignment when there's no upgrade/downgrade message -

Placeholder

- )} - {provenanceUrl ? ( -
- - - Source - -
- ) : null} -
- ); -}; - -export interface TrustComparisonProps { - a: SimplePackageSpec; - b: SimplePackageSpec; - aEvidence: TrustEvidence; - bEvidence: TrustEvidence; - aProvenanceUrl?: string; - bProvenanceUrl?: string; -} - -/** The padding of the center column and the right/left half has to be the same to line up */ -const COMMON_PADDING = "p-2"; - -const TrustComparison: FunctionComponent = ({ - aEvidence, - bEvidence, - aProvenanceUrl, - bProvenanceUrl, -}) => { - const aRank = getTrustRank(aEvidence); - const bRank = getTrustRank(bEvidence); - const isDowngrade = bRank < aRank; - const isUpgrade = bRank > aRank; - - const compareUrl = - aProvenanceUrl && bProvenanceUrl - ? getGitHubCompareUrl(aProvenanceUrl, bProvenanceUrl) - : undefined; - - return ( - <> -
- Trust Level -
- - } - center={ -
-

Trust

-
- } - right={ - - } - /> - {compareUrl ? ( -
- -
- ) : ( -
- )} - - ); -}; - -/** - * Extract GitHub compare URL from two provenance URLs - */ -function getGitHubCompareUrl(aUrl: string, bUrl: string): string | undefined { - // Match both /commit/ and /tree/ URLs - const aMatch = aUrl.match( - /github\.com\/([^/]+\/[^/]+)\/(?:commit|tree)\/([a-f0-9]+)/, - ); - const bMatch = bUrl.match( - /github\.com\/([^/]+\/[^/]+)\/(?:commit|tree)\/([a-f0-9]+)/, - ); - - if (aMatch && bMatch && aMatch[1] === bMatch[1]) { - const repo = aMatch[1]; - const aCommit = aMatch[2]; - const bCommit = bMatch[2]; - return `https://github.com/${repo}/compare/${aCommit}...${bCommit}`; - } - - return undefined; -} - -export default TrustComparison; diff --git a/src/app/[...parts]/_page/TrustComparison/index.tsx b/src/app/[...parts]/_page/TrustComparison/index.tsx deleted file mode 100644 index 173c5163..00000000 --- a/src/app/[...parts]/_page/TrustComparison/index.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import { type FunctionComponent } from "react"; -import Skeleton from "^/components/ui/Skeleton"; -import { cx } from "^/lib/cva"; -import Halfs from "../DiffIntro/Halfs"; - -const COMMON_PADDING = "p-2"; - -export const TrustComparisonSkeleton: FunctionComponent = () => { - return ( - <> -
- Trust Level -
- - -
- } - center={ -
-

Trust

-
- } - right={ -
- -
- } - /> - - ); -}; - -export { default } from "./TrustComparison"; diff --git a/src/app/[...parts]/_page/TrustDiff.tsx b/src/app/[...parts]/_page/TrustDiff.tsx deleted file mode 100644 index f34d09d0..00000000 --- a/src/app/[...parts]/_page/TrustDiff.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import { cacheLife } from "next/cache"; -import trustComparison from "^/lib/api/npm/trustComparison"; -import type SimplePackageSpec from "^/lib/SimplePackageSpec"; -import suspense from "^/lib/suspense"; -import measuredPromise from "^/lib/utils/measuredPromise"; -import TrustComparison, { TrustComparisonSkeleton } from "./TrustComparison"; - -export interface TrustDiffProps { - a: SimplePackageSpec; - b: SimplePackageSpec; - specs: [string, string]; -} - -const TrustDiffInner = async ({ specs, a, b }: TrustDiffProps) => { - "use cache"; - - // Cache for hours since trust evidence doesn't change often - cacheLife("hours"); - - const { result, time } = await measuredPromise(trustComparison(specs)); - - console.log("Trust comparison", { specs, time }); - - // Always render - showing "no trust evidence" is also valuable information - return ( - - ); -}; - -const TrustDiffSkeleton = () => ; - -const TrustDiff = suspense(TrustDiffInner, TrustDiffSkeleton); - -export default TrustDiff; From d1dafaa4a65843d820473aab77c457adb608505e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 10 Jan 2026 22:42:54 +0000 Subject: [PATCH 16/17] Show trust level badges in diff intro section Co-authored-by: oBusk <13413409+oBusk@users.noreply.github.com> --- next-env.d.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/next-env.d.ts b/next-env.d.ts index 9edff1c7..c4b7818f 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/types/routes.d.ts"; +import "./.next/dev/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. From 62ce130956df316d63c33962984afd868b82c19b Mon Sep 17 00:00:00 2001 From: Oscar Busk Date: Mon, 12 Jan 2026 22:36:43 +0100 Subject: [PATCH 17/17] WIP --- .../[...parts]/_page/DiffIntro/DiffIntro.tsx | 6 --- .../[...parts]/_page/DiffIntro/SpecBox.tsx | 8 ---- .../_page/SourceDiff/SourceDiff.tsx | 42 +++++++++++++++++++ .../{DiffIntro => SourceDiff}/TrustBadge.tsx | 0 src/app/[...parts]/_page/SourceDiff/index.ts | 3 ++ src/app/[...parts]/page.tsx | 2 + 6 files changed, 47 insertions(+), 14 deletions(-) create mode 100644 src/app/[...parts]/_page/SourceDiff/SourceDiff.tsx rename src/app/[...parts]/_page/{DiffIntro => SourceDiff}/TrustBadge.tsx (100%) create mode 100644 src/app/[...parts]/_page/SourceDiff/index.ts diff --git a/src/app/[...parts]/_page/DiffIntro/DiffIntro.tsx b/src/app/[...parts]/_page/DiffIntro/DiffIntro.tsx index 27094115..6993695a 100644 --- a/src/app/[...parts]/_page/DiffIntro/DiffIntro.tsx +++ b/src/app/[...parts]/_page/DiffIntro/DiffIntro.tsx @@ -6,7 +6,6 @@ import { cx } from "^/lib/cva"; import { type NpmDiffOptions } from "^/lib/npmDiff"; import type SimplePackageSpec from "^/lib/SimplePackageSpec"; import contentVisibility from "^/lib/utils/contentVisibility"; -import CompareSourceButton from "./CompareSourceButton"; import Halfs from "./Halfs"; import Options from "./Options"; import SpecBox from "./SpecBox"; @@ -55,11 +54,6 @@ const DiffIntro = forwardRef, DiffIntroProps>( /> } /> - {services} diff --git a/src/app/[...parts]/_page/DiffIntro/SpecBox.tsx b/src/app/[...parts]/_page/DiffIntro/SpecBox.tsx index 08112aef..bbeed4e4 100644 --- a/src/app/[...parts]/_page/DiffIntro/SpecBox.tsx +++ b/src/app/[...parts]/_page/DiffIntro/SpecBox.tsx @@ -5,7 +5,6 @@ import type SimplePackageSpec from "^/lib/SimplePackageSpec"; import { simplePackageSpecToString } from "^/lib/SimplePackageSpec"; import PublishDate from "./PublishDate"; import ServiceLinks from "./ServiceLinks"; -import TrustBadge from "./TrustBadge"; interface SpecBoxProps extends HTMLAttributes { pkg: SimplePackageSpec; @@ -24,13 +23,6 @@ const SpecBox = forwardRef( ({ pkg, pkgClassName, comparisonPkg, isTarget, ...props }, ref) => (
- + } + center={ + + } + right={ + + } + /> + ); +} diff --git a/src/app/[...parts]/_page/DiffIntro/TrustBadge.tsx b/src/app/[...parts]/_page/SourceDiff/TrustBadge.tsx similarity index 100% rename from src/app/[...parts]/_page/DiffIntro/TrustBadge.tsx rename to src/app/[...parts]/_page/SourceDiff/TrustBadge.tsx diff --git a/src/app/[...parts]/_page/SourceDiff/index.ts b/src/app/[...parts]/_page/SourceDiff/index.ts new file mode 100644 index 00000000..49529f8c --- /dev/null +++ b/src/app/[...parts]/_page/SourceDiff/index.ts @@ -0,0 +1,3 @@ +import SourceDiff from "./SourceDiff"; + +export default SourceDiff; diff --git a/src/app/[...parts]/page.tsx b/src/app/[...parts]/page.tsx index d3d223e4..bd513f2b 100644 --- a/src/app/[...parts]/page.tsx +++ b/src/app/[...parts]/page.tsx @@ -15,6 +15,7 @@ import DiffIntro from "./_page/DiffIntro"; import NpmDiff from "./_page/NpmDiff"; import PackagephobiaDiff from "./_page/PackagephobiaDiff"; import { type DIFF_TYPE_PARAM_NAME } from "./_page/paramNames"; +import SourceDiff from "./_page/SourceDiff/SourceDiff"; export interface DiffPageProps { params: Promise<{ parts: string | string[] }>; @@ -75,6 +76,7 @@ const DiffPageInner = async ({ b={b} services={ <> +