Files
dependency-guard/lib/verify.js
T

170 lines
8.5 KiB
JavaScript

import { decodeBase64Json, fetchJson, normalizeRepository, runCommand } from "./util.js";
export async function fetchPackageDocument(name, dependencies) {
return fetchJson(`https://registry.npmjs.org/${encodeURIComponent(name)}`, dependencies);
}
export async function verifyDirectPackage(item, document, policy, dependencies) {
const failures = [];
const warnings = [];
const versionData = document.versions?.[item.version];
if (!versionData) return result(item, failures.concat("exact version is absent from npm registry metadata"), warnings);
const signatures = versionData.dist?.signatures;
if (!Array.isArray(signatures) || signatures.length === 0 || signatures.some((entry) => !entry.keyid || !entry.sig)) {
failures.push("npm registry metadata has no valid registry signature");
}
const actualRepository = normalizeRepository(versionData.repository ?? document.repository);
const configuredRepository = policy.trustedRepositories[item.name]
? normalizeRepository(policy.trustedRepositories[item.name])
: null;
if (configuredRepository && actualRepository !== configuredRepository) {
failures.push(`repository mismatch: expected ${configuredRepository}, registry reports ${actualRepository ?? "no GitHub repository"}`);
}
const repository = configuredRepository ?? actualRepository;
if (!repository) failures.push("no verifiable GitHub upstream repository is available");
const trustedMaintainers = policy.trustedMaintainers[item.name];
if (trustedMaintainers) {
const actual = [...new Set((versionData.maintainers ?? document.maintainers ?? []).map((entry) => entry.name?.toLowerCase()).filter(Boolean))].sort();
const trusted = [...new Set(trustedMaintainers.map((entry) => entry.toLowerCase()))].sort();
if (actual.length === 0) failures.push("registry metadata has no maintainers to compare with policy");
if (actual.join("\n") !== trusted.join("\n")) {
failures.push(`registry maintainer set changed: expected ${trusted.join(", ")}; found ${actual.join(", ") || "none"}`);
}
}
const lifecycle = ["preinstall", "install", "postinstall"].filter((name) => versionData.scripts?.[name]);
if (lifecycle.length && !policy.allowDirectLifecycleScripts.includes(item.name)) {
failures.push(`direct package declares lifecycle hook(s): ${lifecycle.join(", ")}`);
}
const publishedAt = document.time?.[item.version];
let releaseAgeHours = null;
if (!publishedAt || !Number.isFinite(Date.parse(publishedAt))) {
warnings.push("registry metadata has no valid release timestamp");
} else {
releaseAgeHours = (dependencies.now() - Date.parse(publishedAt)) / 3_600_000;
if (releaseAgeHours < policy.minimumReleaseAgeHours) {
const message = `release is ${Math.max(0, releaseAgeHours).toFixed(1)} hours old; policy requires ${policy.minimumReleaseAgeHours}`;
(policy.freshReleaseAction === "fail" ? failures : warnings).push(message);
}
}
let provenance = "none";
if (repository) {
const attestations = versionData.dist?.attestations;
const provenanceUrl = attestations?.provenance?.predicateType === "https://slsa.dev/provenance/v1"
? attestations.url
: attestations?.provenance?.url;
if (provenanceUrl) {
provenance = "slsa";
try {
if (new URL(provenanceUrl).protocol !== "https:") throw new Error("attestation URL is not HTTPS");
await verifyProvenance(provenanceUrl, item, repository, dependencies);
} catch (error) {
const message = `SLSA provenance: ${error.message}`;
if (hasValidSourceException(item, policy, dependencies)) {
provenance = "policy-exception";
warnings.push(`${message}; accepted by reviewed sourceEvidenceExceptions policy`);
} else {
failures.push(message);
}
}
} else {
provenance = "gitHead-tag";
try {
await verifyGitFallback(versionData.gitHead, item, repository, dependencies);
} catch (error) {
const message = `git fallback: ${error.message}`;
if (hasValidSourceException(item, policy, dependencies)) {
provenance = "policy-exception";
warnings.push(`${message}; accepted by reviewed sourceEvidenceExceptions policy`);
} else {
failures.push(message);
}
}
}
}
return { ...result(item, failures, warnings), repository, releaseAgeHours, provenance, maintainers: (versionData.maintainers ?? document.maintainers ?? []).map((entry) => entry.name).filter(Boolean) };
}
async function verifyProvenance(url, item, repository, dependencies) {
const data = await fetchJson(url, dependencies);
const attestations = data.attestations ?? (data.bundle ? [data] : []);
const attestation = attestations.find((entry) => String(entry.predicateType ?? "").includes("slsa")) ?? attestations[0];
const envelope = attestation?.bundle?.dsseEnvelope ?? attestation?.dsseEnvelope;
const statement = decodeBase64Json(envelope?.payload);
const predicate = statement.predicate ?? {};
const workflow = predicate.buildDefinition?.externalParameters?.workflow ?? predicate.invocation?.configSource ?? {};
const sourceRepository = normalizeRepository(workflow.repository ?? workflow.uri);
const sourceRef = workflow.ref ?? workflow.digest?.ref;
const dependenciesList = predicate.buildDefinition?.resolvedDependencies ?? predicate.materials ?? [];
const source = dependenciesList.find((entry) => normalizeProvenanceRepository(entry.uri) === normalizeRepository(repository));
const commit = source?.digest?.gitCommit ?? source?.digest?.sha1;
const resolvedRef = provenanceRef(source?.uri) ?? sourceRef;
if (sourceRepository !== normalizeRepository(repository)) throw new Error(`source repository mismatch (${sourceRepository ?? "missing"})`);
if (!/^[0-9a-f]{7,64}$/i.test(commit ?? "")) throw new Error("source commit is missing");
if (typeof resolvedRef !== "string" || !resolvedRef.startsWith("refs/")) throw new Error("resolved source ref is missing");
if (resolvedRef.startsWith("refs/tags/")) {
await requireRemoteTag(repository, resolvedRef, commit, dependencies);
} else {
await requireVersionTag(repository, item.version, commit, dependencies);
}
}
function normalizeProvenanceRepository(value) {
if (typeof value !== "string") return normalizeRepository(value);
return normalizeRepository(value.replace(/@refs\/(?:tags|heads)\/.*$/, ""));
}
async function verifyGitFallback(gitHead, item, repository, dependencies) {
if (!/^[0-9a-f]{7,64}$/i.test(gitHead ?? "")) throw new Error("npm gitHead is missing or invalid");
const refs = await listRemoteTags(repository, `refs/tags/*${item.version}*`, dependencies);
const found = refs.some((ref) => ref.sha.toLowerCase() === gitHead.toLowerCase());
if (!found) throw new Error(`no Git tag containing ${item.version} points to npm gitHead`);
}
async function requireRemoteTag(repository, ref, commit, dependencies) {
const refs = await listRemoteTags(repository, ref, dependencies);
if (!refs.some((entry) => entry.sha.toLowerCase() === commit.toLowerCase())) {
throw new Error(`${ref} does not point to provenance commit`);
}
}
async function requireVersionTag(repository, version, commit, dependencies) {
const refs = await listRemoteTags(repository, `refs/tags/*${version}*`, dependencies);
if (!refs.some((entry) => entry.sha.toLowerCase() === commit.toLowerCase())) {
throw new Error(`no Git tag containing ${version} points to provenance commit`);
}
}
async function listRemoteTags(repository, pattern, dependencies) {
if (dependencies.remoteRefs) return dependencies.remoteRefs(repository, pattern);
const run = dependencies.run ?? runCommand;
const result = await run("git", ["ls-remote", "--tags", `${repository}.git`, pattern, `${pattern}^{}`], {
timeoutMs: dependencies.timeoutMs,
maxOutputBytes: 2_000_000,
});
if (result.code !== 0) throw new Error(`git ls-remote failed: ${result.stderr || `exit ${result.code}`}`);
return String(result.stdout).split("\n").filter(Boolean).map((line) => {
const [sha, ref] = line.split(/\s+/, 2);
return { sha, ref };
});
}
function provenanceRef(value) {
if (typeof value !== "string") return null;
return value.match(/@(refs\/(?:tags|heads)\/.*)$/)?.[1] ?? null;
}
function hasValidSourceException(item, policy, dependencies) {
const exception = policy.sourceEvidenceExceptions[item.name];
return exception?.version === item.version && Date.parse(exception.expiresAt) > dependencies.now();
}
function result(item, failures, warnings) {
return { name: item.name, version: item.version, dev: item.dev, failures, warnings };
}