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..6993695a 100644 --- a/src/app/[...parts]/_page/DiffIntro/DiffIntro.tsx +++ b/src/app/[...parts]/_page/DiffIntro/DiffIntro.tsx @@ -35,6 +35,8 @@ const DiffIntro = forwardRef, DiffIntroProps>( left={ } @@ -46,6 +48,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 56aadf74..bbeed4e4 100644 --- a/src/app/[...parts]/_page/DiffIntro/SpecBox.tsx +++ b/src/app/[...parts]/_page/DiffIntro/SpecBox.tsx @@ -9,10 +9,18 @@ import ServiceLinks from "./ServiceLinks"; 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) => (
+ } + center={ + + } + right={ + + } + /> + ); +} diff --git a/src/app/[...parts]/_page/SourceDiff/TrustBadge.tsx b/src/app/[...parts]/_page/SourceDiff/TrustBadge.tsx new file mode 100644 index 00000000..0a7a1fee --- /dev/null +++ b/src/app/[...parts]/_page/SourceDiff/TrustBadge.tsx @@ -0,0 +1,163 @@ +import { Check, FileCode, Info, ShieldCheck, XCircle } from "lucide-react"; +import ExternalLink from "^/components/ExternalLink"; +import Skeleton from "^/components/ui/Skeleton"; +import Tooltip from "^/components/ui/Tooltip"; +import getTrustEvidence, { + type TrustEvidence, +} from "^/lib/api/npm/getTrustEvidence"; +import { cx } from "^/lib/cva"; +import type SimplePackageSpec from "^/lib/SimplePackageSpec"; +import suspense from "^/lib/suspense"; + +export interface TrustBadgeProps { + className?: string; + pkg: SimplePackageSpec; + comparisonPkg?: SimplePackageSpec; + isTarget?: boolean; +} + +const TRUST_RANK = { + trustedPublisher: 2, + provenance: 1, + undefined: 0, +} as const; + +function getTrustRank(evidence: TrustEvidence): number { + return TRUST_RANK[evidence ?? "undefined"]; +} + +const shared = cx( + "my-1 flex min-h-6 flex-col items-center justify-center gap-1", +); + +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 { + const iconClass = "size-5"; + switch (evidence) { + case "trustedPublisher": + return ; + 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, + 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); + + return ( +
+
+ {icon} + {label} + {evidence !== undefined ? ( + + + + ) : null} +
+ {isDowngrade ? ( +
+ ⚠️ Trust downgrade +
+ ) : null} + {isUpgrade ? ( +
+ ✓ Trust improved +
+ ) : null} + {provenanceUrl ? ( +
+ + + Source + +
+ ) : null} +
+ ); +} + +function TrustBadgeFallback({ className }: TrustBadgeProps) { + return ( +
+ +
+ ); +} + +const SuspensedTrustBadge = suspense(TrustBadge, TrustBadgeFallback); + +export default SuspensedTrustBadge; 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={ <> + ; + }; +} + +interface SLSAV1Payload { + _type?: string; + subject?: Array<{ + name?: string; + }>; + predicateType?: string; + predicate?: SLSAv1Predicate; +} + +interface AttestationBundle { + predicateType?: string; + bundle?: { + dsseEnvelope?: { + payload: string; + payloadType?: string; + [key: string]: unknown; + }; + [key: string]: unknown; + }; + [key: string]: unknown; +} + +interface ProvenanceReference { + url?: string; + predicateType?: string; +} + +// Extended types for manifest with trust-related fields +interface ExtendedNpmUser { + trustedPublisher?: boolean; + [key: string]: unknown; +} + +interface ExtendedDist { + attestations?: { + url?: string; + provenance?: ProvenanceReference; + [key: string]: unknown; + }; + [key: string]: unknown; +} + +/** + * Get trust evidence for a specific package version from the packument. + * Based on pnpm's getTrustEvidence function: + * https://github.com/pnpm/pnpm/blob/main/resolving/npm-resolver/src/trustChecks.ts + */ +export function getTrustEvidenceFromManifest( + manifest: Manifest, +): TrustEvidence { + // Check for trustedPublisher on _npmUser + const npmUser = manifest._npmUser as unknown as ExtendedNpmUser | undefined; + if (npmUser?.trustedPublisher) { + return "trustedPublisher"; + } + // Check for provenance attestations in dist + const dist = manifest.dist as unknown as ExtendedDist | undefined; + if (dist?.attestations?.provenance) { + return "provenance"; + } + 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 + */ +async function getProvenanceUrl( + manifest: Manifest, +): Promise { + const dist = manifest.dist as unknown as ExtendedDist | undefined; + const attestations = dist?.attestations; + if (!attestations?.url) { + return undefined; + } + + // 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.bundle?.dsseEnvelope && + (bundle.predicateType === "https://slsa.dev/provenance/v1" || + bundle.predicateType?.includes("slsa.dev/provenance")), + ); + + if (!provenanceBundle?.bundle?.dsseEnvelope?.payload) { + return undefined; + } + + try { + // Decode base64 payload + const payload = Buffer.from( + provenanceBundle.bundle.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; + } + + // Validate it's a GitHub URL (security: only allow github.com as the exact hostname) + if (!isValidGitHubUrl(repository)) { + return undefined; + } + + // Get commit SHA from resolvedDependencies + const deps = predicate.buildDefinition.resolvedDependencies; + if (!deps || !Array.isArray(deps) || deps.length === 0) { + return undefined; + } + + 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]}/tree/${commitSha}`; + } catch (e) { + console.warn("Failed to parse provenance URL", e); + } + + return undefined; +} + +/** + * 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 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; + } +} + +/** + * Separate function that takes only packagename and version for better caching. + */ +async function getTrustInfoForVersion( + packageName: string, + version: string, +): Promise { + "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); + // Both provenance and trustedPublisher have provenance data + // (trustedPublisher always includes provenance) + const provenanceUrl = + evidence === "provenance" || evidence === "trustedPublisher" + ? await 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 }; +}