import { spawn } from "node:child_process"; import { constants } from "node:fs"; import { access, lstat, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { provenanceProviderAttestationSchema, vulnerabilityProviderReportSchema, } from "./lib/provider-evidence.ts"; import { validateProviderUpload } from "./lib/provider-upload-validator.ts"; import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts"; import { assertSafePublishLeaf, ensureSafePublishDirectory, } from "./lib/ci-gate-log.ts"; const kind = process.argv[process.argv.indexOf("--kind") + 1]; if (kind !== "vulnerability" && kind !== "provenance") { process.stderr.write("Usage: run-and-validate-provider --kind vulnerability|provenance\n"); process.exit(2); } const command = kind === "vulnerability" ? process.env.VULNERABILITY_PROVIDER_COMMAND : process.env.PROVENANCE_PROVIDER_COMMAND; const reportPath = kind === "vulnerability" ? process.env.VULNERABILITY_REPORT_PATH : process.env.PROVENANCE_ATTESTATION_PATH; const sealedPath = process.env.VALIDATED_PROVIDER_REPORT_PATH; const candidateLockfile = process.env.CANDIDATE_LOCKFILE_PATH; const archivePath = process.env.CANDIDATE_ARCHIVE_PATH; const archiveSha256 = process.env.CANDIDATE_ARCHIVE_SHA256; const candidateDistSha256 = process.env.CANDIDATE_DIST_SHA256; if ( !command || !reportPath || !sealedPath || !candidateLockfile || !archivePath || !archiveSha256 || !candidateDistSha256 ) { process.stderr.write("Provider supervisor environment is incomplete\n"); process.exit(2); } const workspaceRoot = process.cwd(); const reportAbsolute = path.resolve(reportPath); const rawDirectory = path.dirname(reportAbsolute); const sealedAbsolute = path.resolve(sealedPath); if ( path.basename(rawDirectory) !== "untrusted" || path.dirname(rawDirectory) !== path.dirname(sealedAbsolute) || reportAbsolute === sealedAbsolute ) { throw new TypeError("provider raw and sealed evidence paths are not isolated"); } await prepareMissingProviderOutput(workspaceRoot, reportAbsolute, reportPath, "raw provider report"); await prepareMissingProviderOutput(workspaceRoot, sealedAbsolute, sealedPath, "sealed provider report"); await access("/usr/bin/bwrap", constants.X_OK).catch(() => { throw new Error("provider sandbox unavailable: /usr/bin/bwrap is required"); }); const childEnvironment = createProviderEnvironment(kind, reportPath, { candidateLockfile, archivePath, archiveSha256, candidateDistSha256, }); await runProviderInSandbox(command, childEnvironment, rawDirectory, workspaceRoot); const parsed = await validateProviderUpload({ kind, candidateRoot: path.dirname(path.resolve(candidateLockfile)), archivePath, expectedArchiveSha256: archiveSha256, reportPath, workspaceRoot, expectedDistSha256: candidateDistSha256, }); await assertSafePublishLeaf(sealedAbsolute, sealedPath); await writeValidatedJsonArtifact({ path: sealedPath, schema: kind === "vulnerability" ? vulnerabilityProviderReportSchema : provenanceProviderAttestationSchema, value: parsed, }); process.stdout.write(`${kind} provider supervised validation: PASS\n`); function createProviderEnvironment( providerKind: "vulnerability" | "provenance", rawReportPath: string, candidate: Readonly<{ candidateLockfile: string; archivePath: string; archiveSha256: string; candidateDistSha256: string; }>, ): NodeJS.ProcessEnv { const environment: NodeJS.ProcessEnv = { PATH: process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin", HOME: "/tmp/provider-home", TMPDIR: "/tmp", CI: "true", GITHUB_ENV: "/tmp/github-env", GITHUB_PATH: "/tmp/github-path", CANDIDATE_LOCKFILE_PATH: candidate.candidateLockfile, CANDIDATE_ARCHIVE_PATH: candidate.archivePath, CANDIDATE_ARCHIVE_SHA256: candidate.archiveSha256, CANDIDATE_DIST_SHA256: candidate.candidateDistSha256, ...(providerKind === "vulnerability" ? { VULNERABILITY_REPORT_PATH: rawReportPath } : { PROVENANCE_ATTESTATION_PATH: rawReportPath }), }; for (const name of ["LANG", "LC_ALL", "HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY"] as const) { if (process.env[name]) environment[name] = process.env[name]; } const credentialPrefix = `${providerKind.toUpperCase()}_PROVIDER_`; for (const [name, value] of Object.entries(process.env)) { if (name.startsWith(credentialPrefix) && !name.endsWith("_COMMAND") && value) { environment[name] = value; } } return environment; } async function runProviderInSandbox( command: string, environment: NodeJS.ProcessEnv, rawDirectory: string, workspaceRoot: string, ): Promise { const scratch = await mkdtemp(path.join(tmpdir(), "ci-provider-sandbox-")); try { await mkdir(path.join(scratch, "provider-home")); await writeFile(path.join(scratch, "node"), "", { mode: 0o500 }); const arguments_ = [ "--die-with-parent", "--new-session", "--as-pid-1", "--unshare-pid", "--unshare-ipc", "--unshare-uts", "--dev", "/dev", "--proc", "/proc", "--bind", scratch, "/tmp", "--dir", "/etc", ]; for (const source of ["/usr", "/bin", "/lib", "/lib64"]) { if (await exists(source)) arguments_.push("--ro-bind", source, source); } // setup-node commonly installs outside /usr. Expose only the exact trusted // runtime binary, never its credential-bearing user/toolcache directory. arguments_.push("--ro-bind", process.execPath, "/tmp/node"); for (const source of [ "/etc/ca-certificates", "/etc/ssl", "/etc/resolv.conf", "/etc/hosts", "/etc/nsswitch.conf", "/etc/passwd", "/etc/group", ]) { if (await exists(source)) arguments_.push("--ro-bind", source, source); } for (const directory of missingDestinationAncestors(workspaceRoot)) { arguments_.push("--dir", directory); } arguments_.push( "--ro-bind", workspaceRoot, workspaceRoot, ); if (await exists(path.join(workspaceRoot, ".git"))) { arguments_.push("--tmpfs", path.join(workspaceRoot, ".git")); } arguments_.push( "--bind", rawDirectory, rawDirectory, "--chdir", workspaceRoot, "/bin/sh", "-eu", "-c", command, ); await new Promise((resolve, reject) => { const child = spawn("/usr/bin/bwrap", arguments_, { env: { ...environment, PATH: `/tmp:${environment.PATH ?? ""}` }, stdio: "inherit", }); let settled = false; const finish = (error?: Error): void => { if (settled) return; settled = true; clearTimeout(timeout); error ? reject(error) : resolve(); }; const timeout = setTimeout(() => { child.kill("SIGKILL"); finish(new Error("sandboxed external provider command timed out")); }, 30 * 60 * 1_000); child.once("error", (error) => finish(error)); child.once("close", (code, signal) => { if (code === 0 && signal === null) finish(); else finish(new Error(`sandboxed external provider failed: exit=${code ?? "none"}, signal=${signal ?? "none"}`)); }); }); } finally { await rm(scratch, { recursive: true, force: true }); } } function missingDestinationAncestors(target: string): string[] { const ancestors: string[] = []; let current = path.dirname(path.resolve(target)); while (current !== path.parse(current).root && !["/usr", "/bin", "/lib", "/lib64", "/tmp"].includes(current)) { ancestors.push(current); current = path.dirname(current); } return ancestors.reverse(); } async function exists(target: string): Promise { try { await lstat(target); return true; } catch (error) { if (hasErrorCode(error, "ENOENT")) return false; throw error; } } async function prepareMissingProviderOutput( root: string, absolutePath: string, configuredPath: string, label: string, ): Promise { await ensureSafePublishDirectory(root, path.dirname(absolutePath)); await assertSafePublishLeaf(absolutePath, configuredPath); try { await lstat(absolutePath); throw new Error(`${label} already exists: ${configuredPath}`); } catch (error) { if (!hasErrorCode(error, "ENOENT")) throw error; } } function hasErrorCode(error: unknown, code: string): boolean { return Boolean(error && typeof error === "object" && "code" in error && error.code === code); }