Files
tech-log-frontend/scripts/lib/supply-chain.ts
T

515 lines
16 KiB
TypeScript

import { createHash } from "node:crypto";
import { readFile } from "node:fs/promises";
export type DependencyScope = "production" | "development";
export type LockfilePackage = Readonly<{
name: string;
version: string;
integrity: string;
}>;
export type DependencyInventoryRow = Readonly<{
name: string;
version: string;
direct: boolean;
scope: DependencyScope;
optional: boolean;
license: string;
dependencies: readonly string[];
}>;
export type DependencyUpgrade = Readonly<{
name: string;
from: string;
to: string;
}>;
export type DependencyInventoryDiff = Readonly<{
added: readonly string[];
removed: readonly string[];
changed: readonly string[];
upgrades: readonly DependencyUpgrade[];
}>;
type MutableDependencyRecord = {
name: string;
version: string;
direct: boolean;
scope: DependencyScope;
optional: boolean;
packagePath: string;
dependencies: Set<string>;
};
type Document = Readonly<Record<string, unknown>>;
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function recordValue(value: unknown): Record<string, unknown> {
return isRecord(value) ? value : {};
}
function recordRows(value: unknown): Record<string, unknown>[] {
return Array.isArray(value) ? value.filter(isRecord) : [];
}
function stringRows(value: unknown): string[] {
return Array.isArray(value)
? value.filter((entry): entry is string => typeof entry === "string")
: [];
}
function dependencyIdentity(row: Readonly<Record<string, unknown>>): string {
return `${String(row.name ?? "")}@${String(row.version ?? "")}`;
}
export function canonicalizeSupplyChainValue(value: unknown): unknown {
if (Array.isArray(value)) {
return value
.map(canonicalizeSupplyChainValue)
.sort((left, right) =>
String(JSON.stringify(left)).localeCompare(String(JSON.stringify(right))),
);
}
if (isRecord(value)) {
return Object.fromEntries(
Object.entries(value)
.sort(([left], [right]) => left.localeCompare(right))
.map(([key, item]) => [key, canonicalizeSupplyChainValue(item)]),
);
}
return value;
}
export function supplyChainDigest(value: unknown): string {
return createHash("sha256")
.update(JSON.stringify(canonicalizeSupplyChainValue(value)))
.digest("hex");
}
export function parsePnpmLockfilePackages(
lockfile: string,
): LockfilePackage[] {
const entries: LockfilePackage[] = [];
let inPackages = false;
let current: { name: string; version: string; integrity: string } | null = null;
for (const line of lockfile.split(/\r?\n/)) {
if (line === "packages:") {
inPackages = true;
continue;
}
if (line === "snapshots:") {
if (current) entries.push(current);
break;
}
if (!inPackages) continue;
const packageMatch = line.match(/^ {2}(\S.*):$/);
if (packageMatch?.[1]) {
if (current) entries.push(current);
const key = packageMatch[1].replace(/^['"]|['"]$/g, "");
const separator = key.lastIndexOf("@");
current = {
name: key.slice(0, separator),
version: key.slice(separator + 1),
integrity: "",
};
continue;
}
const integrityMatch = line.match(/\bintegrity:\s*([^,}\s]+)/);
if (current && integrityMatch?.[1]) {
current.integrity = integrityMatch[1];
}
}
return entries.sort((left, right) =>
`${left.name}@${left.version}`.localeCompare(
`${right.name}@${right.version}`,
),
);
}
export function isValidSha512Integrity(integrity: string): boolean {
if (!integrity.startsWith("sha512-")) return false;
try {
return Buffer.from(integrity.slice("sha512-".length), "base64").length === 64;
} catch {
return false;
}
}
export function normalizeLicense(raw: unknown): string {
if (typeof raw === "string" && raw.trim()) return raw.trim();
if (isRecord(raw) && typeof raw.type === "string") return raw.type;
if (Array.isArray(raw)) {
const licenses = raw
.map(normalizeLicense)
.filter((license) => license !== "NOASSERTION");
return licenses.length > 0 ? licenses.join(" OR ") : "NOASSERTION";
}
return "NOASSERTION";
}
export async function flattenPnpmDependencyTree(
root: Record<string, unknown>,
directProduction: Readonly<Record<string, string>>,
directDevelopment: Readonly<Record<string, string>>,
): Promise<DependencyInventoryRow[]> {
const records = new Map<string, MutableDependencyRecord>();
const directIds = new Set<string>();
for (const [name, rawDependency] of Object.entries(
recordValue(root.dependencies),
)) {
if (Object.hasOwn(directProduction, name) && isRecord(rawDependency)) {
directIds.add(`${name}@${String(rawDependency.version ?? "")}`);
}
}
for (const [name, rawDependency] of Object.entries(
recordValue(root.devDependencies),
)) {
if (Object.hasOwn(directDevelopment, name) && isRecord(rawDependency)) {
directIds.add(`${name}@${String(rawDependency.version ?? "")}`);
}
}
function visit(
node: Record<string, unknown>,
scope: DependencyScope,
optionalPath: boolean,
): void {
const groups = {
dependencies: node.dependencies,
devDependencies: node.devDependencies,
optionalDependencies: node.optionalDependencies,
};
for (const [groupName, group] of Object.entries(groups)) {
for (const [name, rawDependency] of Object.entries(recordValue(group))) {
if (!isRecord(rawDependency)) continue;
const version = String(rawDependency.version ?? "");
const packagePath = String(rawDependency.path ?? "");
const identity = `${name}@${version}`;
const childScope: DependencyScope =
scope === "production" && groupName !== "devDependencies"
? "production"
: "development";
const childOptional =
optionalPath || groupName === "optionalDependencies";
const previous = records.get(identity);
const dependencies = previous?.dependencies ?? new Set<string>();
for (const childGroup of [
rawDependency.dependencies,
rawDependency.optionalDependencies,
]) {
for (const [childName, rawChild] of Object.entries(
recordValue(childGroup),
)) {
if (isRecord(rawChild)) {
dependencies.add(
`${childName}@${String(rawChild.version ?? "")}`,
);
}
}
}
records.set(identity, {
name,
version,
direct: directIds.has(identity),
scope:
previous?.scope === "production" || childScope === "production"
? "production"
: "development",
optional: previous
? previous.optional && childOptional
: childOptional,
packagePath: previous?.packagePath || packagePath,
dependencies,
});
visit(rawDependency, childScope, childOptional);
}
}
}
const productionRoot: Record<string, unknown> = {
dependencies: Object.fromEntries(
Object.entries(recordValue(root.dependencies)).filter(([name]) =>
Object.hasOwn(directProduction, name),
),
),
};
const developmentRoot: Record<string, unknown> = {
devDependencies: Object.fromEntries(
Object.entries(recordValue(root.devDependencies)).filter(([name]) =>
Object.hasOwn(directDevelopment, name),
),
),
};
visit(productionRoot, "production", false);
visit(developmentRoot, "development", false);
const result: DependencyInventoryRow[] = [];
for (const record of records.values()) {
let license = "NOASSERTION";
let optional = record.optional;
if (record.packagePath) {
try {
const parsed: unknown = JSON.parse(
await readFile(`${record.packagePath}/package.json`, "utf8"),
);
const manifest = recordValue(parsed);
license = normalizeLicense(manifest.license ?? manifest.licenses);
} catch {
// Platform-specific optional packages may not be materialized locally.
optional = true;
}
}
result.push({
name: record.name,
version: record.version,
direct: record.direct,
scope: record.scope,
optional,
license,
dependencies: [...record.dependencies].sort(),
});
}
return result.sort((left, right) =>
`${left.name}@${left.version}`.localeCompare(
`${right.name}@${right.version}`,
),
);
}
export function diffDependencyInventories(
before: Document,
after: Document,
): DependencyInventoryDiff {
const beforeRows = recordRows(before.dependencies);
const afterRows = recordRows(after.dependencies);
const beforeMap = new Map(
beforeRows.map((row) => [dependencyIdentity(row), row] as const),
);
const afterMap = new Map(
afterRows.map((row) => [dependencyIdentity(row), row] as const),
);
const added = [...afterMap.keys()].filter((key) => !beforeMap.has(key));
const removed = [...beforeMap.keys()].filter((key) => !afterMap.has(key));
const changed: string[] = [];
for (const key of [...beforeMap.keys()].filter((item) => afterMap.has(item))) {
if (supplyChainDigest(beforeMap.get(key)) !== supplyChainDigest(afterMap.get(key))) {
changed.push(key);
}
}
const upgrades: DependencyUpgrade[] = [];
for (const removedKey of removed) {
const previous = beforeMap.get(removedKey);
if (!previous) continue;
const replacement = added.find(
(addedKey) => afterMap.get(addedKey)?.name === previous.name,
);
const next = replacement ? afterMap.get(replacement) : undefined;
if (next) {
upgrades.push({
name: String(previous.name ?? ""),
from: String(previous.version ?? ""),
to: String(next.version ?? ""),
});
}
}
return Object.freeze({
added: Object.freeze(added.sort()),
removed: Object.freeze(removed.sort()),
changed: Object.freeze(changed.sort()),
upgrades: Object.freeze(
upgrades.sort((left, right) => left.name.localeCompare(right.name)),
),
});
}
export function validateLicensePolicy(
inventory: Document,
policy: Document,
) {
const allowed = new Set(stringRows(policy.allowedLicenses));
const denied = stringRows(policy.deniedLicensePatterns);
const failures: string[] = [];
const results: Array<Readonly<{
package: string;
license: string;
passed: boolean;
reason: string | null;
}>> = [];
for (const dependency of recordRows(inventory.dependencies)) {
const license = String(dependency.license ?? "NOASSERTION");
const explicitlyDenied = denied.some((pattern) =>
new RegExp(pattern, "i").test(license),
);
const unknownAccepted =
license === "NOASSERTION" && dependency.optional === true;
const passed =
!explicitlyDenied && (allowed.has(license) || unknownAccepted);
results.push({
package: dependencyIdentity(dependency),
license,
passed,
reason: unknownAccepted ? "platform-optional-not-materialized" : null,
});
if (!passed) {
failures.push(
`${dependencyIdentity(dependency)} has disallowed license ${license}`,
);
}
}
return Object.freeze({
passed: failures.length === 0,
failures: Object.freeze(failures),
results: Object.freeze(results),
});
}
export function validateDependencyReview(
diff: DependencyInventoryDiff,
inventory: Document,
evidenceFile: Document,
) {
const byIdentity = new Map(
recordRows(inventory.dependencies).map(
(row) => [dependencyIdentity(row), row] as const,
),
);
const evidence = new Map<string, Record<string, unknown>>();
for (const entry of recordRows(evidenceFile.changes)) {
if (typeof entry.changeId === "string") evidence.set(entry.changeId, entry);
}
const highRisk = diff.added.filter((identity) => {
const row = byIdentity.get(identity);
return row?.direct === true && row.scope === "production";
});
const failures: string[] = [];
for (const identity of highRisk) {
const changeId = `add:${identity}`;
const entry = evidence.get(changeId);
if (!entry) {
failures.push(`high-risk dependency missing review: ${changeId}`);
continue;
}
for (const field of ["owner", "reviewer", "reason", "rollback"] as const) {
const value = entry[field];
if (typeof value !== "string" || !value.trim()) {
failures.push(`${changeId} missing ${field}`);
}
}
if (entry.owner === entry.reviewer) {
failures.push(`${changeId} may not be self-approved`);
}
}
return Object.freeze({
passed: failures.length === 0,
highRisk: Object.freeze(highRisk),
failures: Object.freeze(failures),
});
}
const severityRank: ReadonlyMap<string, number> = new Map([
["unknown", 0],
["low", 1],
["moderate", 2],
["high", 3],
["critical", 4],
]);
export function validateVulnerabilityReport(
report: Document,
policy: Document,
exceptionFile: Document,
lockfileSha256: string,
now: Date = new Date(),
) {
const failures: string[] = [];
if (report.scannedLockfileSha256 !== lockfileSha256) {
failures.push("vulnerability report lockfile digest mismatch");
}
if (typeof report.provider !== "string" || !report.provider.trim()) {
failures.push("vulnerability report provider missing");
}
const threshold = severityRank.get(String(policy.blockAtSeverity)) ?? 3;
const exceptions = recordRows(exceptionFile.exceptions);
const blocking: string[] = [];
for (const finding of recordRows(report.findings)) {
const severity = String(finding.severity ?? "unknown").toLowerCase();
if ((severityRank.get(severity) ?? 0) < threshold) continue;
const exception = exceptions.find(
(entry) =>
entry.vulnerabilityId === finding.id &&
entry.packageName === finding.packageName,
);
const expiry =
typeof exception?.expiresAt === "string"
? Date.parse(exception.expiresAt)
: Number.NaN;
const validException = Boolean(
exception &&
typeof exception.owner === "string" &&
exception.owner.trim() &&
typeof exception.reviewer === "string" &&
exception.reviewer.trim() &&
exception.owner !== exception.reviewer &&
typeof exception.reason === "string" &&
exception.reason.trim() &&
Number.isFinite(expiry) &&
expiry > now.getTime(),
);
if (!validException) {
blocking.push(
`${String(finding.id)}:${String(finding.packageName)}@${String(finding.version)}:${severity}`,
);
}
}
return Object.freeze({
passed: failures.length === 0 && blocking.length === 0,
failures: Object.freeze(failures),
blocking: Object.freeze(blocking),
});
}
export function verifySupplyChainCoherence(
sbom: Document,
inventory: Document,
provenance: Document,
distDigest: string,
) {
const failures: string[] = [];
const componentCount = Array.isArray(sbom.components)
? sbom.components.length
: -1;
const dependencyCount = Array.isArray(inventory.dependencies)
? inventory.dependencies.length
: -2;
if (componentCount !== dependencyCount) {
failures.push("SBOM component count does not match inventory");
}
const metadata = recordValue(sbom.metadata);
const properties = recordRows(metadata.properties);
if (
properties.find(
(property) =>
property.name === "ca:lockfileSha256" &&
property.value === inventory.lockfileSha256,
) === undefined
) {
failures.push("SBOM lockfile digest does not match inventory");
}
const subject = recordRows(provenance.subject)[0];
const subjectDigest = recordValue(subject?.digest);
if (subjectDigest.sha256 !== distDigest) {
failures.push("provenance subject does not match built dist digest");
}
const predicate = recordValue(provenance.predicate);
const materials = recordValue(predicate.materials);
if (materials.lockfileSha256 !== inventory.lockfileSha256) {
failures.push("provenance lockfile material does not match inventory");
}
return Object.freeze({
passed: failures.length === 0,
failures: Object.freeze(failures),
});
}