mirror of
https://github.com/morluto/rea.git
synced 2026-10-27 18:05:45 +00:00
247 lines
7.8 KiB
JavaScript
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",
|
|
},
|
|
];
|
|
});
|
|
}
|