548 lines
17 KiB
JavaScript
548 lines
17 KiB
JavaScript
import { createHash } from "node:crypto";
|
|
import { readFile } from "node:fs/promises";
|
|
|
|
/** @param {unknown} value @returns {unknown} */
|
|
export function canonicalizeSupplyChainValue(value) {
|
|
if (Array.isArray(value)) {
|
|
return value
|
|
.map(canonicalizeSupplyChainValue)
|
|
.sort((left, right) =>
|
|
JSON.stringify(left).localeCompare(JSON.stringify(right)),
|
|
);
|
|
}
|
|
if (value && typeof value === "object") {
|
|
return Object.fromEntries(
|
|
Object.entries(value)
|
|
.sort(([left], [right]) => left.localeCompare(right))
|
|
.map(([key, item]) => [key, canonicalizeSupplyChainValue(item)]),
|
|
);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
/** @param {unknown} value */
|
|
export function supplyChainDigest(value) {
|
|
return createHash("sha256")
|
|
.update(JSON.stringify(canonicalizeSupplyChainValue(value)))
|
|
.digest("hex");
|
|
}
|
|
|
|
/** @param {string} lockfile */
|
|
export function parsePnpmLockfilePackages(lockfile) {
|
|
const entries =
|
|
/** @type {Array<{name: string, version: string, integrity: string}>} */ (
|
|
[]
|
|
);
|
|
let inPackages = false;
|
|
/** @type {{name: string, version: string, integrity: string} | null} */
|
|
let current = 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) {
|
|
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) {
|
|
current.integrity = integrityMatch[1];
|
|
}
|
|
}
|
|
return entries.sort((left, right) =>
|
|
`${left.name}@${left.version}`.localeCompare(
|
|
`${right.name}@${right.version}`,
|
|
),
|
|
);
|
|
}
|
|
|
|
/** @param {string} integrity */
|
|
export function isValidSha512Integrity(integrity) {
|
|
if (!integrity.startsWith("sha512-")) return false;
|
|
try {
|
|
return Buffer.from(integrity.slice("sha512-".length), "base64").length === 64;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {unknown} raw
|
|
* @returns {string}
|
|
*/
|
|
export function normalizeLicense(raw) {
|
|
if (typeof raw === "string" && raw.trim()) return raw.trim();
|
|
if (
|
|
raw &&
|
|
typeof raw === "object" &&
|
|
"type" in 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";
|
|
}
|
|
|
|
/**
|
|
* @param {Record<string, unknown>} root
|
|
* @param {Readonly<Record<string, string>>} directProduction
|
|
* @param {Readonly<Record<string, string>>} directDevelopment
|
|
*/
|
|
export async function flattenPnpmDependencyTree(
|
|
root,
|
|
directProduction,
|
|
directDevelopment,
|
|
) {
|
|
const records =
|
|
/** @type {Map<string, {
|
|
* name: string,
|
|
* version: string,
|
|
* direct: boolean,
|
|
* scope: "production" | "development",
|
|
* optional: boolean,
|
|
* packagePath: string,
|
|
* dependencies: Set<string>
|
|
* }>} */ (new Map());
|
|
const directIds = new Set();
|
|
for (const [name, rawDependency] of Object.entries(
|
|
/** @type {Record<string, unknown>} */ (root.dependencies ?? {}),
|
|
)) {
|
|
if (
|
|
Object.hasOwn(directProduction, name) &&
|
|
rawDependency &&
|
|
typeof rawDependency === "object" &&
|
|
!Array.isArray(rawDependency)
|
|
) {
|
|
directIds.add(
|
|
`${name}@${String(
|
|
/** @type {Record<string, unknown>} */ (rawDependency).version ?? "",
|
|
)}`,
|
|
);
|
|
}
|
|
}
|
|
for (const [name, rawDependency] of Object.entries(
|
|
/** @type {Record<string, unknown>} */ (root.devDependencies ?? {}),
|
|
)) {
|
|
if (
|
|
Object.hasOwn(directDevelopment, name) &&
|
|
rawDependency &&
|
|
typeof rawDependency === "object" &&
|
|
!Array.isArray(rawDependency)
|
|
) {
|
|
directIds.add(
|
|
`${name}@${String(
|
|
/** @type {Record<string, unknown>} */ (rawDependency).version ?? "",
|
|
)}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {Record<string, unknown>} node
|
|
* @param {"production" | "development"} scope
|
|
* @param {boolean} optionalPath
|
|
*/
|
|
function visit(node, scope, optionalPath) {
|
|
for (const [groupName, group] of Object.entries({
|
|
dependencies: node.dependencies,
|
|
devDependencies: node.devDependencies,
|
|
optionalDependencies: node.optionalDependencies,
|
|
})) {
|
|
if (!group || typeof group !== "object" || Array.isArray(group)) continue;
|
|
for (const [name, rawDependency] of Object.entries(group)) {
|
|
if (
|
|
!rawDependency ||
|
|
typeof rawDependency !== "object" ||
|
|
Array.isArray(rawDependency)
|
|
) {
|
|
continue;
|
|
}
|
|
const dependency =
|
|
/** @type {Record<string, unknown>} */ (rawDependency);
|
|
const version = String(dependency.version ?? "");
|
|
const packagePath = String(dependency.path ?? "");
|
|
const identity = `${name}@${version}`;
|
|
const childScope =
|
|
scope === "production" && groupName !== "devDependencies"
|
|
? "production"
|
|
: "development";
|
|
const childOptional =
|
|
optionalPath || groupName === "optionalDependencies";
|
|
const previous = records.get(identity);
|
|
const dependencies = previous?.dependencies ?? new Set();
|
|
for (const childGroup of [
|
|
dependency.dependencies,
|
|
dependency.optionalDependencies,
|
|
]) {
|
|
if (
|
|
!childGroup ||
|
|
typeof childGroup !== "object" ||
|
|
Array.isArray(childGroup)
|
|
) {
|
|
continue;
|
|
}
|
|
for (const [childName, rawChild] of Object.entries(childGroup)) {
|
|
if (
|
|
rawChild &&
|
|
typeof rawChild === "object" &&
|
|
!Array.isArray(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(dependency, childScope, childOptional);
|
|
}
|
|
}
|
|
}
|
|
|
|
const productionRoot = {
|
|
dependencies: Object.fromEntries(
|
|
Object.entries(
|
|
/** @type {Record<string, unknown>} */ (root.dependencies ?? {}),
|
|
).filter(([name]) => Object.hasOwn(directProduction, name)),
|
|
),
|
|
};
|
|
const developmentRoot = {
|
|
devDependencies: Object.fromEntries(
|
|
Object.entries(
|
|
/** @type {Record<string, unknown>} */ (root.devDependencies ?? {}),
|
|
).filter(([name]) => Object.hasOwn(directDevelopment, name)),
|
|
),
|
|
};
|
|
visit(productionRoot, "production", false);
|
|
visit(developmentRoot, "development", false);
|
|
|
|
const result = [];
|
|
for (const record of records.values()) {
|
|
let license = "NOASSERTION";
|
|
let optional = record.optional;
|
|
if (record.packagePath) {
|
|
try {
|
|
const manifest = JSON.parse(
|
|
await readFile(`${record.packagePath}/package.json`, "utf8"),
|
|
);
|
|
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}`,
|
|
),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @param {Readonly<Record<string, unknown>>} before
|
|
* @param {Readonly<Record<string, unknown>>} after
|
|
*/
|
|
export function diffDependencyInventories(before, after) {
|
|
const beforeRows =
|
|
/** @type {Array<Record<string, unknown>>} */ (before.dependencies ?? []);
|
|
const afterRows =
|
|
/** @type {Array<Record<string, unknown>>} */ (after.dependencies ?? []);
|
|
const beforeMap = new Map(
|
|
beforeRows.map((row) => [`${row.name}@${row.version}`, row]),
|
|
);
|
|
const afterMap = new Map(
|
|
afterRows.map((row) => [`${row.name}@${row.version}`, row]),
|
|
);
|
|
const added = [...afterMap.keys()].filter((key) => !beforeMap.has(key));
|
|
const removed = [...beforeMap.keys()].filter((key) => !afterMap.has(key));
|
|
const changed = [];
|
|
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 = [];
|
|
for (const removedKey of removed) {
|
|
const previous = beforeMap.get(removedKey);
|
|
const replacement = added.find(
|
|
(addedKey) => afterMap.get(addedKey)?.name === previous?.name,
|
|
);
|
|
if (replacement) {
|
|
upgrades.push({
|
|
name: previous?.name,
|
|
from: previous?.version,
|
|
to: afterMap.get(replacement)?.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) =>
|
|
String(left.name).localeCompare(String(right.name)),
|
|
),
|
|
),
|
|
});
|
|
}
|
|
|
|
/**
|
|
* @param {Readonly<Record<string, unknown>>} inventory
|
|
* @param {Readonly<Record<string, unknown>>} policy
|
|
*/
|
|
export function validateLicensePolicy(inventory, policy) {
|
|
const allowed = new Set(
|
|
/** @type {string[]} */ (policy.allowedLicenses ?? []),
|
|
);
|
|
const denied = /** @type {string[]} */ (policy.deniedLicensePatterns ?? []);
|
|
const failures = [];
|
|
const results = [];
|
|
for (const dependency of /** @type {Array<Record<string, unknown>>} */ (
|
|
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: `${dependency.name}@${dependency.version}`,
|
|
license,
|
|
passed,
|
|
reason: unknownAccepted ? "platform-optional-not-materialized" : null,
|
|
});
|
|
if (!passed) {
|
|
failures.push(
|
|
`${dependency.name}@${dependency.version} has disallowed license ${license}`,
|
|
);
|
|
}
|
|
}
|
|
return Object.freeze({
|
|
passed: failures.length === 0,
|
|
failures: Object.freeze(failures),
|
|
results: Object.freeze(results),
|
|
});
|
|
}
|
|
|
|
/**
|
|
* @param {ReturnType<typeof diffDependencyInventories>} diff
|
|
* @param {Readonly<Record<string, unknown>>} inventory
|
|
* @param {Readonly<Record<string, unknown>>} evidenceFile
|
|
*/
|
|
export function validateDependencyReview(diff, inventory, evidenceFile) {
|
|
const rows =
|
|
/** @type {Array<Record<string, unknown>>} */ (inventory.dependencies ?? []);
|
|
const byIdentity = new Map(
|
|
rows.map((row) => [`${row.name}@${row.version}`, row]),
|
|
);
|
|
const evidence = new Map(
|
|
/** @type {Array<Record<string, unknown>>} */ (
|
|
evidenceFile.changes ?? []
|
|
).map((entry) => [entry.changeId, entry]),
|
|
);
|
|
const highRisk = diff.added.filter((identity) => {
|
|
const row = byIdentity.get(identity);
|
|
return row?.direct === true && row.scope === "production";
|
|
});
|
|
const failures = [];
|
|
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"]) {
|
|
if (typeof entry[field] !== "string" || !entry[field].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 = new Map([
|
|
["unknown", 0],
|
|
["low", 1],
|
|
["moderate", 2],
|
|
["high", 3],
|
|
["critical", 4],
|
|
]);
|
|
|
|
/**
|
|
* @param {Readonly<Record<string, unknown>>} report
|
|
* @param {Readonly<Record<string, unknown>>} policy
|
|
* @param {Readonly<Record<string, unknown>>} exceptionFile
|
|
* @param {string} lockfileSha256
|
|
* @param {Date} [now]
|
|
*/
|
|
export function validateVulnerabilityReport(
|
|
report,
|
|
policy,
|
|
exceptionFile,
|
|
lockfileSha256,
|
|
now = new Date(),
|
|
) {
|
|
const failures = [];
|
|
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 =
|
|
/** @type {Array<Record<string, unknown>>} */ (
|
|
exceptionFile.exceptions ?? []
|
|
);
|
|
const blocking = [];
|
|
for (const finding of /** @type {Array<Record<string, unknown>>} */ (
|
|
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 =
|
|
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(
|
|
`${finding.id}:${finding.packageName}@${finding.version}:${severity}`,
|
|
);
|
|
}
|
|
}
|
|
return Object.freeze({
|
|
passed: failures.length === 0 && blocking.length === 0,
|
|
failures: Object.freeze(failures),
|
|
blocking: Object.freeze(blocking),
|
|
});
|
|
}
|
|
|
|
/**
|
|
* @param {Readonly<Record<string, unknown>>} sbom
|
|
* @param {Readonly<Record<string, unknown>>} inventory
|
|
* @param {Readonly<Record<string, unknown>>} provenance
|
|
* @param {string} distDigest
|
|
*/
|
|
export function verifySupplyChainCoherence(
|
|
sbom,
|
|
inventory,
|
|
provenance,
|
|
distDigest,
|
|
) {
|
|
const failures = [];
|
|
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 =
|
|
/** @type {Record<string, unknown>} */ (sbom.metadata ?? {});
|
|
const properties =
|
|
/** @type {Array<{name?: string, value?: string}>} */ (
|
|
metadata.properties ?? []
|
|
);
|
|
if (properties.find(
|
|
/** @param {{name?: string, value?: string}} property */
|
|
(property) =>
|
|
property.name === "ca:lockfileSha256" &&
|
|
property.value === inventory.lockfileSha256,
|
|
) === undefined) {
|
|
failures.push("SBOM lockfile digest does not match inventory");
|
|
}
|
|
const subject =
|
|
/** @type {Array<Record<string, unknown>>} */ (provenance.subject ?? [])[0];
|
|
const subjectDigest =
|
|
/** @type {Record<string, unknown>} */ (subject?.digest ?? {});
|
|
if (subjectDigest.sha256 !== distDigest) {
|
|
failures.push("provenance subject does not match built dist digest");
|
|
}
|
|
const predicate =
|
|
/** @type {Record<string, unknown>} */ (provenance.predicate ?? {});
|
|
const materials =
|
|
/** @type {Record<string, unknown>} */ (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),
|
|
});
|
|
}
|