111 lines
5.1 KiB
JavaScript
111 lines
5.1 KiB
JavaScript
import { mkdir, writeFile } from "node:fs/promises";
|
|
import { dirname, resolve } from "node:path";
|
|
import { COMMAND_TIMEOUT_MS, HTTP_TIMEOUT_MS, REPORT_VERSION } from "./constants.js";
|
|
import { inspectLock, readProject } from "./project.js";
|
|
import { fetchPackageDocument, verifyDirectPackage } from "./verify.js";
|
|
import { redact, runCommand } from "./util.js";
|
|
|
|
export async function checkProject(options, injected = {}) {
|
|
const projectPath = resolve(options.path);
|
|
const run = injected.run ?? runCommand;
|
|
const dependencies = {
|
|
fetchImpl: injected.fetch ?? globalThis.fetch,
|
|
remoteRefs: injected.remoteRefs,
|
|
run,
|
|
now: injected.now ?? Date.now,
|
|
timeoutMs: options.httpTimeoutMs ?? HTTP_TIMEOUT_MS,
|
|
};
|
|
const report = {
|
|
reportVersion: REPORT_VERSION,
|
|
generatedAt: new Date(dependencies.now()).toISOString(),
|
|
projectPath,
|
|
status: "fail",
|
|
summary: {},
|
|
lockfile: {},
|
|
packages: [],
|
|
commands: [],
|
|
failures: [],
|
|
warnings: [],
|
|
};
|
|
|
|
try {
|
|
const project = await readProject(projectPath);
|
|
const lockInspection = inspectLock(project.lock, options.policy, dependencies.now());
|
|
report.lockfile = { lockfileVersion: project.lock.lockfileVersion, registryEntries: lockInspection.registryEntries };
|
|
report.failures.push(...lockInspection.failures.map((message) => `lockfile: ${message}`));
|
|
|
|
for (const item of project.direct) {
|
|
try {
|
|
const document = await fetchPackageDocument(item.name, dependencies);
|
|
const packageResult = await verifyDirectPackage(item, document, options.policy, dependencies);
|
|
const pack = await execute(run, "npm", ["pack", `${item.name}@${item.version}`, "--dry-run", "--json", "--ignore-scripts"], projectPath, options.commandTimeoutMs, 25_000_000);
|
|
report.commands.push(pack.evidence);
|
|
if (pack.result.code !== 0) {
|
|
packageResult.failures.push(`npm pack failed${pack.result.timedOut ? " (timed out)" : ""}`);
|
|
} else {
|
|
try {
|
|
const packed = JSON.parse(pack.result.stdout)[0];
|
|
packageResult.tarballSizeBytes = packed.size;
|
|
packageResult.unpackedSizeBytes = packed.unpackedSize;
|
|
const archiveException = options.policy.archiveSizeExceptions[item.name];
|
|
const archiveExceptionValid = archiveException?.version === item.version && Date.parse(archiveException.expiresAt) > dependencies.now();
|
|
const tarballLimit = archiveExceptionValid ? archiveException.maxTarballSizeBytes ?? options.policy.maxTarballSizeBytes : options.policy.maxTarballSizeBytes;
|
|
const unpackedLimit = archiveExceptionValid ? archiveException.maxUnpackedSizeBytes ?? options.policy.maxUnpackedSizeBytes : options.policy.maxUnpackedSizeBytes;
|
|
if (!Number.isFinite(packed.size) || packed.size > tarballLimit) packageResult.failures.push(`tarball size ${packed.size ?? "unknown"} exceeds ${tarballLimit}`);
|
|
if (!Number.isFinite(packed.unpackedSize) || packed.unpackedSize > unpackedLimit) packageResult.failures.push(`unpacked size ${packed.unpackedSize ?? "unknown"} exceeds ${unpackedLimit}`);
|
|
} catch {
|
|
packageResult.failures.push("npm pack returned invalid JSON evidence");
|
|
}
|
|
}
|
|
report.packages.push(packageResult);
|
|
} catch (error) {
|
|
report.packages.push({ name: item.name, version: item.version, dev: item.dev, failures: [error.message], warnings: [] });
|
|
}
|
|
}
|
|
|
|
if (!options.skipInstall) {
|
|
for (const args of [
|
|
["ci", "--ignore-scripts"],
|
|
["audit", "signatures"],
|
|
["audit", "--omit=dev"],
|
|
]) {
|
|
const command = await execute(run, "npm", args, projectPath, options.commandTimeoutMs);
|
|
report.commands.push(command.evidence);
|
|
if (command.result.code !== 0) report.failures.push(`npm ${args.join(" ")} failed${command.result.timedOut ? " (timed out)" : ""}`);
|
|
if (command.result.code !== 0 && args[0] === "ci") break;
|
|
}
|
|
}
|
|
} catch (error) {
|
|
report.failures.push(error.message);
|
|
}
|
|
|
|
for (const item of report.packages) {
|
|
report.failures.push(...item.failures.map((message) => `${item.name}@${item.version}: ${message}`));
|
|
report.warnings.push(...item.warnings.map((message) => `${item.name}@${item.version}: ${message}`));
|
|
}
|
|
report.status = report.failures.length ? "fail" : "pass";
|
|
report.summary = {
|
|
directPackages: report.packages.length,
|
|
failures: report.failures.length,
|
|
warnings: report.warnings.length,
|
|
commands: report.commands.length,
|
|
};
|
|
await mkdir(dirname(resolve(options.report)), { recursive: true });
|
|
await writeFile(resolve(options.report), `${JSON.stringify(report, null, 2)}\n`, { mode: 0o600 });
|
|
return report;
|
|
}
|
|
|
|
async function execute(run, command, args, cwd, timeoutMs = COMMAND_TIMEOUT_MS, maxOutputBytes = 20_000) {
|
|
const result = await run(command, args, { cwd, timeoutMs, maxOutputBytes });
|
|
return {
|
|
result,
|
|
evidence: {
|
|
command: [command, ...args].join(" "),
|
|
exitCode: result.code,
|
|
timedOut: Boolean(result.timedOut),
|
|
stdout: redact(result.stdout ?? ""),
|
|
stderr: redact(result.stderr ?? ""),
|
|
},
|
|
};
|
|
}
|