515 lines
16 KiB
TypeScript
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),
|
|
});
|
|
}
|