Files
reverse-engineering-anything/scripts/lib/dependency-security-facts.mjs

247 lines
7.8 KiB
JavaScript

import { createHash } from "node:crypto";
import { isAbsolute, relative, sep } from "node:path";
const record = (value) =>
typeof value === "object" && value !== null && !Array.isArray(value);
const compare = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
const count = (value) => Number.isSafeInteger(value) && value >= 0;
export function registryIdentity(resolved, integrity) {
try {
const url = new URL(resolved);
return (
url.protocol === "https:" &&
url.hostname === "registry.npmjs.org" &&
url.port === "" &&
url.username === "" &&
url.password === "" &&
/^sha512-[A-Za-z0-9+/]{86}==$/u.test(integrity)
);
} catch {
return false;
}
}
export function requireRegistryLock(packages) {
for (const entry of packages)
if (!registryIdentity(entry.resolved, entry.integrity))
throw new Error(`Unsupported registry or integrity for ${entry.path}`);
}
export function advisoryFacts(value, exitCode) {
const counts = value?.metadata?.vulnerabilities;
const keys = ["info", "low", "moderate", "high", "critical", "total"];
if (
!record(counts) ||
!keys.every((key) => count(counts[key])) ||
keys.slice(0, -1).reduce((sum, key) => sum + counts[key], 0) !==
counts.total ||
value.error !== undefined
)
return {
status: "unavailable",
reason: "invalid_advisory_report",
affected_packages: null,
};
const status =
counts.total > 0
? "failed"
: exitCode === 0 && value.error === undefined
? "passed"
: "unavailable";
return {
status,
reason: status === "unavailable" ? "advisory_command_failed" : null,
affected_packages: counts.total,
severity: Object.fromEntries(keys.map((key) => [key, counts[key]])),
};
}
export function signatureFacts(value, exitCode) {
if (
!record(value) ||
!Array.isArray(value.invalid) ||
!Array.isArray(value.missing)
)
return {
status: "unavailable",
reason: "invalid_signature_report",
invalid: null,
missing: null,
verified: null,
};
const status =
value.invalid.length || value.missing.length
? "failed"
: exitCode === 0 && value.error === undefined
? "passed"
: "unavailable";
return {
status,
reason: status === "unavailable" ? "signature_command_failed" : null,
invalid: value.invalid.length,
missing: value.missing.length,
verified: null,
};
}
/** Use npm's resolved relationships, never implement a second dependency resolver. */
export function dependencyGraph(value, directory, installedPackages) {
if (!record(value) || value.path !== directory)
throw new Error("Installed tree root does not match the selected project");
const nodes = new Map(),
edges = new Map(),
omissions = new Map(),
unknowns = [];
const locked = new Map(installedPackages.map((entry) => [entry.path, entry]));
const pending = [{ value, from: null, dependency: null }];
while (pending.length) {
const item = pending.pop(),
node = item.value;
if (
!record(node) ||
typeof node.path !== "string" ||
!isAbsolute(node.path)
) {
unknowns.push({
from: item.from,
dependency: item.dependency,
reason: "unresolved_identity",
});
continue;
}
const path = relative(directory, node.path);
if (
path === ".." ||
path.startsWith(".." + sep) ||
isAbsolute(path) ||
typeof node.name !== "string" ||
node.name.length === 0 ||
typeof node.version !== "string" ||
node.version.length === 0
) {
unknowns.push({
from: item.from,
dependency: item.dependency,
reason: "invalid_package_identity",
});
continue;
}
const id = path.split(sep).join("/") || ".";
const selected = locked.get(id);
// npm ls may read package.json without integrity when its hidden cache is
// stale. Join npm's actual identities/edges to the selected project's lock,
// which owns declared integrity, using the exact installation path.
const matched =
selected?.name === node.name &&
selected.version === node.version &&
(node.resolved === undefined || selected.resolved === node.resolved);
if (
item.from !== null &&
(!matched ||
(node.integrity !== undefined && node.integrity !== selected.integrity))
)
unknowns.push({ id, reason: "installed_identity_not_bound_to_lock" });
if (
node.error !== undefined ||
(Array.isArray(node.problems) && node.problems.length)
)
unknowns.push({ id, reason: "npm_reported_problem" });
const fact = {
id,
name: node.name,
version: node.version,
integrity: matched ? selected.integrity : null,
source_url: matched
? selected.resolved
: typeof node.resolved === "string"
? node.resolved
: null,
};
if (nodes.has(id) && JSON.stringify(nodes.get(id)) !== JSON.stringify(fact))
unknowns.push({ id, reason: "conflicting_package_identity" });
else nodes.set(id, fact);
if (item.from !== null) {
const edge = { from: item.from, name: item.dependency, to: id };
edges.set(JSON.stringify(edge), edge);
}
if (node.dependencies === undefined) continue;
if (!record(node.dependencies)) {
unknowns.push({ id, reason: "invalid_dependency_relationships" });
continue;
}
for (const [name, child] of Object.entries(node.dependencies)) {
if (
record(child) &&
Object.keys(child).length === 0 &&
(Object.hasOwn(node.optionalDependencies ?? {}, name) ||
node.peerDependenciesMeta?.[name]?.optional === true)
) {
const omission = { from: id, name, reason: "optional_not_reported" };
omissions.set(JSON.stringify(omission), omission);
} else pending.push({ value: child, from: id, dependency: name });
}
}
const facts = {
nodes: [...nodes.values()].sort((a, b) => compare(a.id, b.id)),
edges: [...edges.values()].sort((a, b) =>
compare(JSON.stringify(a), JSON.stringify(b)),
),
omissions: [...omissions.values()].sort((a, b) =>
compare(JSON.stringify(a), JSON.stringify(b)),
),
};
return {
...facts,
unknowns,
fingerprint: unknowns.length
? null
: createHash("sha256")
.update(
JSON.stringify({
...facts,
// Preserve URLs as evidence, but exclude temporary archive locations
// from the content identity of otherwise identical installed trees.
nodes: facts.nodes.map(
({ source_url: _source, ...identity }) => identity,
),
}),
)
.digest("hex"),
};
}
/** Version drift is allowed for consumers; changed bytes of a locked identity are not. */
export function sourceIdentityDifferences(nodes, reference, runtimeOnly) {
const packages = new Map();
for (const entry of reference.filter((entry) => !runtimeOnly || !entry.dev)) {
if (!packages.has(entry.name)) packages.set(entry.name, []);
packages
.get(entry.name)
.push({ version: entry.version, integrity: entry.integrity });
}
return nodes
.filter(
(node) =>
node.id !== "." &&
(!runtimeOnly || node.id !== "node_modules/rea-agents"),
)
.flatMap((node) => {
const expected = packages.get(node.name) ?? [];
const sameVersion = expected.filter(
(entry) => entry.version === node.version,
);
if (sameVersion.some((entry) => entry.integrity === node.integrity))
return [];
return [
{
...node,
source_identities: expected,
reason: sameVersion.length
? node.integrity === null
? "integrity_unknown"
: "integrity_differs"
: "version_not_locked",
},
];
});
}