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(); 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 { 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, ""], [args?.artifactPlanPath ?? undefined, ""], [args?.outputPath ? dirname(args.outputPath) : undefined, ""], [process.cwd(), ""], ] as const) { if (path) { message = message.replaceAll(path, replacement); } } return message .replaceAll(/\/(?:private\/)?tmp\/openclaw-plugin-npm-scan-[^/\s:]+/gu, "") .replaceAll(/(^|[\s:(])\/[^ \t\n\r:,)\]}]+/gu, "$1"); } 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 { 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(); }