Files
openclaw/scripts/plugin-npm-security-scan.mts
2026-08-20 23:41:42 -07:00

169 lines
5.9 KiB
TypeScript

import { mkdir, writeFile } from "node:fs/promises";
import { dirname, resolve } from "node:path";
import { pathToFileURL } from "node:url";
import {
constrainPluginNpmSecurityScanReport,
MAX_PUBLISHABLE_PLUGIN_PACKAGES,
parsePluginNpmSecurityArtifactDownloadRejections,
runPluginNpmSecurityScan,
type PluginNpmSecurityScanReport,
} from "./lib/plugin-npm-security-scan.mts";
import { readBoundedRegularFile } from "./plugin-publication-artifact.mjs";
const MAX_EXPECTED_PACKAGES_JSON_BYTES = 256 * 1024;
const MAX_ARTIFACT_PLAN_JSON_BYTES = 256 * 1024;
type ParsedArgs = {
artifactPlanPath: string | null;
artifactRoot: string;
candidateSha: string;
expectedPackages: unknown;
outputPath: string;
preDownloadErrors: string[];
preDownloadRejectedPackageNames: string[];
toolingSha: string;
};
function parseArgs(argv: string[]): ParsedArgs {
const values = new Map<string, string>();
for (let index = 0; index < argv.length; index += 2) {
const name = argv[index];
const value = argv[index + 1];
if (!name?.startsWith("--") || value === undefined || values.has(name)) {
throw new Error(`Invalid plugin npm security scan argument near ${name}.`);
}
values.set(name, value);
}
const artifactRoot = values.get("--artifact-root") ?? "";
const artifactPlan = values.get("--artifact-plan") ?? "";
const candidateSha = values.get("--candidate-sha") ?? "";
const expectedPackagesJson = values.get("--expected-packages-json") ?? "";
const outputPath = values.get("--report") ?? "";
const toolingSha = values.get("--tooling-sha") ?? "";
if (!artifactRoot) {
throw new Error("--artifact-root is required.");
}
if (!/^[0-9a-f]{40}$/u.test(candidateSha)) {
throw new Error("--candidate-sha must be a full lowercase commit SHA.");
}
if (!/^[0-9a-f]{40}$/u.test(toolingSha)) {
throw new Error("--tooling-sha must be a full lowercase commit SHA.");
}
if (
!expectedPackagesJson ||
Buffer.byteLength(expectedPackagesJson, "utf8") > MAX_EXPECTED_PACKAGES_JSON_BYTES
) {
throw new Error("--expected-packages-json is outside the byte limit.");
}
if (!outputPath) {
throw new Error("--report is required.");
}
const expectedPackages = JSON.parse(expectedPackagesJson) as unknown;
if (
!Array.isArray(expectedPackages) ||
expectedPackages.length > MAX_PUBLISHABLE_PLUGIN_PACKAGES
) {
throw new Error("--expected-packages-json is not a bounded package inventory.");
}
const artifactPlanPath = artifactPlan ? resolve(artifactPlan) : null;
const preDownload = artifactPlanPath
? parsePluginNpmSecurityArtifactDownloadRejections({
candidateSha,
expectedPackages,
plan: JSON.parse(
readBoundedRegularFile(artifactPlanPath, {
label: "Plugin security artifact download plan",
maxBytes: MAX_ARTIFACT_PLAN_JSON_BYTES,
}).toString("utf8"),
) as unknown,
})
: { errors: [], rejectedPackageNames: [] };
return {
artifactPlanPath,
artifactRoot: resolve(artifactRoot),
candidateSha,
expectedPackages,
outputPath: resolve(outputPath),
preDownloadErrors: preDownload.errors,
preDownloadRejectedPackageNames: preDownload.rejectedPackageNames,
toolingSha,
};
}
async function writeReport(outputPath: string, report: PluginNpmSecurityScanReport): Promise<void> {
const constrained = constrainPluginNpmSecurityScanReport(report);
await mkdir(dirname(outputPath), { recursive: true });
await writeFile(outputPath, `${JSON.stringify(constrained)}\n`, "utf8");
}
function sanitizeErrorMessage(error: unknown, args: ParsedArgs | undefined): string {
let message = error instanceof Error ? error.message : String(error);
for (const [path, replacement] of [
[args?.artifactRoot, "<artifacts>"],
[args?.artifactPlanPath ?? undefined, "<artifact-plan>"],
[args?.outputPath ? dirname(args.outputPath) : undefined, "<report-dir>"],
[process.cwd(), "<tooling>"],
] as const) {
if (path) {
message = message.replaceAll(path, replacement);
}
}
return message
.replaceAll(/\/(?:private\/)?tmp\/openclaw-plugin-npm-scan-[^/\s:]+/gu, "<scanner-stage>")
.replaceAll(/(^|[\s:(])\/[^ \t\n\r:,)\]}]+/gu, "$1<path>");
}
function failureReport(args: ParsedArgs, message: string): PluginNpmSecurityScanReport {
return {
candidateSha: args.candidateSha,
errors: [message],
layout: null,
packages: [],
scanScope: "supplemental-inert-package-input",
schemaVersion: 1,
status: "fail",
summary: {
findingCount: 0,
packageCount: 0,
reviewedCriticalFindingCount: 0,
unexpectedCriticalFindingCount: 0,
},
toolingSha: args.toolingSha,
};
}
async function main(argv = process.argv.slice(2)): Promise<number> {
let args: ParsedArgs | undefined;
try {
args = parseArgs(argv);
const report = await runPluginNpmSecurityScan({
artifactRoot: args.artifactRoot,
candidateSha: args.candidateSha,
expectedPackages: args.expectedPackages,
preDownloadErrors: args.preDownloadErrors,
preDownloadRejectedPackageNames: args.preDownloadRejectedPackageNames,
toolingDir: process.cwd(),
toolingSha: args.toolingSha,
});
await writeReport(args.outputPath, report);
console.log(
`Plugin npm security scan ${report.status}: ${report.summary.packageCount} packages, layout=${report.layout ?? "unknown"}, candidate=${report.candidateSha}, tooling=${report.toolingSha}`,
);
for (const error of report.errors) {
console.error(`- ${error}`);
}
return report.status === "pass" ? 0 : 1;
} catch (error) {
const message = sanitizeErrorMessage(error, args);
console.error(`Plugin npm security scan failed: ${message}`);
if (args) {
await writeReport(args.outputPath, failureReport(args, message));
}
return 1;
}
}
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
process.exitCode = await main();
}