Skip to content
180 changes: 180 additions & 0 deletions review-enrichment/src/analyzers/provenance.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
// Provenance & integrity-attestation analyzer (#1518). Two categories of finding:
// 1. Newly-added npm / PyPI packages that lack published provenance attestations — checked via the npm
// registry attestations API and the PyPI simple repository JSON API (PEP 740). Missing attestations mean
// the package was not built through a verifiable CI pipeline, a supply-chain risk the no-checkout
// reviewer cannot detect.
// 2. Binary files and vendored/minified code committed by the PR — artifacts without an auditable source
// the reviewer can inspect. Detected purely by path pattern + extension (no network).
import type { EnrichRequest, ProvenanceFinding } from "../types.js";
import { extractDependencyChanges } from "./dependency-scan.js";

const MAX_ATTESTATION_CHECKS = 20; // bound network round-trips
const MAX_FINDINGS = 30; // keep the brief bounded

// Compiled/non-source binary artifact extensions.
const BINARY_EXT_RE =
/\.(exe|dll|so|dylib|bin|pyc|pyo|class|jar|war|ear|wasm|o|a)$/i;
// Vendored / embedded third-party source trees.
const VENDORED_PATH_RE =
/(?:^|\/)(?:vendor|node_modules|third[_-]party|vendors)\//;
// Minified files carry no reviewable source in the diff (effectively vendored).
const MINIFIED_RE = /\.min\.[cm]?[jt]s$|\.min\.css$/i;

// Loose safety guards: packages come from parsed manifests, but cap lengths before hitting APIs.
const MAX_PKG_LEN = 200;
const MAX_VER_LEN = 100;
// Version strings must start with a digit and contain only sane chars (end-anchored to reject spaces/pipes).
const VERSION_SAFE_RE = /^[0-9][0-9A-Za-z._+-]*$/;

export function isSafeToCheck(pkg: string, version: string): boolean {
return (
pkg.length <= MAX_PKG_LEN &&
version.length <= MAX_VER_LEN &&
VERSION_SAFE_RE.test(version)
);
}

/** Classify a newly-added file by path as binary or vendored. Returns null for ordinary source files. */
export function classifyAddedFile(
path: string,
): "binary" | "vendored" | null {
if (VENDORED_PATH_RE.test(path)) return "vendored";
if (MINIFIED_RE.test(path)) return "vendored";
if (BINARY_EXT_RE.test(path)) return "binary";
return null;
}

/** Check whether an npm package version has published provenance attestations (SLSA/sigstore). Returns true
* when attested OR when the check cannot be completed (fail-safe: only flag on a confident negative). */
export async function hasNpmAttestation(
pkg: string,
version: string,
fetchImpl: typeof fetch,
signal?: AbortSignal,
): Promise<boolean> {
if (signal?.aborted) return true;
try {
const res = await fetchImpl(
`https://registry.npmjs.org/-/npm/v1/attestations/${encodeURIComponent(`${pkg}@${version}`)}`,
{ signal },
);
if (res.status === 404) return false; // unambiguously absent
if (!res.ok) return true; // other registry error → fail-safe
const data = (await res.json()) as { attestations?: unknown[] };
return (data.attestations?.length ?? 0) > 0;
} catch {
return true; // network / parse error → fail-safe
}
}

/** Match a PyPI distribution filename to an exact package version.
* PEP 503: -, _, . are equivalent in distribution names. The version must be followed by a wheel
* component separator (-) or an sdist archive extension (.tar / .zip) to reject substrings like
* `2.31.0` inside `2.31.0.post1` or `12.31.0`. */
export function matchesPypiVersion(
filename: string,
pkg: string,
version: string,
): boolean {
const normalizedPkg = pkg.toLowerCase().replace(/[-_.]/g, "[-_.]");
const escapedVersion = version.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
return new RegExp(
`^${normalizedPkg}-${escapedVersion}(?:-|\\.(?:tar|zip))`,
"i",
).test(filename);
}

/** Check whether a PyPI package version has published provenance (PEP 740 via the simple repository JSON
* API). Returns true when provenance is found OR when the check cannot be completed (fail-safe). */
export async function hasPypiProvenance(
pkg: string,
version: string,
fetchImpl: typeof fetch,
signal?: AbortSignal,
): Promise<boolean> {
if (signal?.aborted) return true;
try {
const res = await fetchImpl(
`https://pypi.org/simple/${encodeURIComponent(pkg.toLowerCase())}/`,
{
signal,
headers: { Accept: "application/vnd.pypi.simple.v1+json" },
},
);
if (!res.ok) return true; // fail-safe
const data = (await res.json()) as {
files?: Array<{ filename: string; provenance?: string }>;
};
const versionFiles = (data.files ?? []).filter((f) =>
matchesPypiVersion(f.filename, pkg, version),
);
if (!versionFiles.length) return true; // can't determine → don't flag
return versionFiles.some((f) => Boolean(f.provenance));
} catch {
return true; // fail-safe
}
}

interface ScanOptions {
signal?: AbortSignal;
}

/** Analyzer entrypoint: scan for newly-added deps lacking provenance attestations + binary/vendored files. */
export async function scanProvenance(
req: EnrichRequest,
fetchImpl: typeof fetch = fetch,
options: ScanOptions = {},
): Promise<ProvenanceFinding[]> {
const findings: ProvenanceFinding[] = [];

// 1. Binary / vendored file detection — pure, no network.
for (const file of req.files ?? []) {
if (file.status !== "added") continue;
const kind = classifyAddedFile(file.path);
if (kind) {
findings.push({ kind, file: file.path });
if (findings.length >= MAX_FINDINGS) return findings;
}
}

// 2. Attestation checks — network, bounded by MAX_ATTESTATION_CHECKS.
const changes = extractDependencyChanges(req.files ?? []).slice(
0,
MAX_ATTESTATION_CHECKS,
);
for (const change of changes) {
if (options.signal?.aborted) break;
if (findings.length >= MAX_FINDINGS) break;
if (!isSafeToCheck(change.package, change.to)) continue;

let attested: boolean;
if (change.ecosystem === "npm") {
attested = await hasNpmAttestation(
change.package,
change.to,
fetchImpl,
options.signal,
);
} else if (change.ecosystem === "PyPI") {
attested = await hasPypiProvenance(
change.package,
change.to,
fetchImpl,
options.signal,
);
} else {
continue; // Go and other ecosystems — no provenance API to check yet
}

if (!attested) {
findings.push({
kind: "no-attestation",
ecosystem: change.ecosystem,
package: change.package,
version: change.to,
});
}
}

return findings;
}
2 changes: 2 additions & 0 deletions review-enrichment/src/brief.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { scanInstallScripts } from "./analyzers/install-scripts.js";
import { scanActionPins } from "./analyzers/actions-pin.js";
import { scanEol } from "./analyzers/eol-check.js";
import { scanRedos } from "./analyzers/redos.js";
import { scanProvenance } from "./analyzers/provenance.js";
import { scanCodeowners } from "./analyzers/codeowners.js";
import { scanSecretLog } from "./analyzers/secret-log.js";
import { renderBrief } from "./render.js";
Expand All @@ -29,6 +30,7 @@ const ANALYZERS: Record<keyof BriefFindings, AnalyzerFn> = {
actionPin: (req) => scanActionPins(req),
eol: (req) => scanEol(req),
redos: (req) => scanRedos(req),
provenance: (req, signal) => scanProvenance(req, fetch, { signal }),
codeowners: (req, signal) => scanCodeowners(req, fetch, { signal }),
secretLog: (req, signal) => scanSecretLog(req, signal),
};
Expand Down
35 changes: 35 additions & 0 deletions review-enrichment/src/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,41 @@ export function renderBrief(
}
}

const provenance = findings.provenance ?? [];
if (provenance.length) {
const noAttest = provenance.filter((f) => f.kind === "no-attestation");
const binaries = provenance.filter((f) => f.kind === "binary");
const vendored = provenance.filter((f) => f.kind === "vendored");
if (noAttest.length) {
lines.push(
"### Dependencies without provenance attestation (supply-chain integrity risk)",
);
for (const f of noAttest) {
lines.push(
`- ${safeCodeSpan(`${f.package!}@${f.version!}`)} (${f.ecosystem!}): no published SLSA/sigstore attestation — package was not built through a verifiable CI pipeline`,
);
}
}
if (binaries.length) {
lines.push("### Binary files committed (no reviewable source)");
for (const f of binaries) {
lines.push(
`- ${safeCodeSpan(f.file!)} — binary artifact without source documentation`,
);
}
}
if (vendored.length) {
lines.push(
"### Vendored or minified code committed (audit source before merging)",
);
for (const f of vendored) {
lines.push(
`- ${safeCodeSpan(f.file!)} — vendored or minified code without upstream source reference`,
);
}
}
}

const codeownersViolations = findings.codeowners ?? [];
if (codeownersViolations.length) {
const allOwners = new Set(codeownersViolations.flatMap((f) => f.owners));
Expand Down
15 changes: 15 additions & 0 deletions review-enrichment/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,20 @@ export interface RedosFinding {
pattern: string;
}

/** A newly-added dependency (npm/PyPI) lacking a published provenance attestation, or a binary/vendored file
* committed without auditable source — supply-chain integrity risks the no-checkout reviewer cannot verify. */
export interface ProvenanceFinding {
kind: "no-attestation" | "binary" | "vendored";
/** Ecosystem — set for no-attestation findings. */
ecosystem?: string;
/** Package name — set for no-attestation findings. */
package?: string;
/** Resolved version — set for no-attestation findings. */
version?: string;
/** File path — set for binary and vendored findings. */
file?: string;
}

/** A changed file governed by a CODEOWNERS rule where the PR author is not listed as an owner (#1515).
* The blast radius (distinct ownership domains crossed) is derived at render time from the full findings set. */
export interface CodeownersFinding {
Expand All @@ -118,6 +132,7 @@ export interface BriefFindings {
installScript?: InstallScriptFinding[];
eol?: EolFinding[];
redos?: RedosFinding[];
provenance?: ProvenanceFinding[];
codeowners?: CodeownersFinding[];
secretLog?: SecretLogFinding[];
}
Expand Down
Loading