import { spawn } from "node:child_process"; import { constants } from "node:fs"; import { access, appendFile, 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 { readBoundedRegularFile } from "./lib/ci-artifact-validator.ts"; import { readProviderTrust } from "./lib/promotion-verifier.ts"; import { superviseProviderEvidence } from "./lib/provider-supervisor.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 archivePath = process.env.CANDIDATE_ARCHIVE_PATH; const archiveSha256 = process.env.CANDIDATE_ARCHIVE_SHA256; const publicKeyPath = kind === "vulnerability" ? process.env.VULNERABILITY_PUBLIC_KEY_PATH : process.env.PROVENANCE_PUBLIC_KEY_PATH; const keyId = kind === "vulnerability" ? process.env.VULNERABILITY_KEY_ID : process.env.PROVENANCE_KEY_ID; const runId = process.env.GITEA_RUN_ID ?? process.env.GITHUB_RUN_ID ?? process.env.CI_RUN_ID; const runAttemptSource = process.env.GITEA_RUN_ATTEMPT ?? process.env.GITHUB_RUN_ATTEMPT ?? process.env.CI_RUN_ATTEMPT; const sourceRevision = process.env.EXPECTED_SOURCE_REVISION ?? process.env.VITE_COMMIT_SHA; if ( !command || !reportPath || !sealedPath || !archivePath || !archiveSha256 || !publicKeyPath || !keyId || !runId || !runAttemptSource || !sourceRevision ) { process.stderr.write("Provider supervisor environment is incomplete\n"); process.exit(2); } const runAttempt = Number(runAttemptSource); if (!Number.isInteger(runAttempt) || runAttempt < 1 || runAttempt > 1_000) { throw new TypeError("provider supervisor run attempt is invalid"); } 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 trust = await readProviderTrust(workspaceRoot, publicKeyPath, keyId); if (!trust) throw new TypeError("provider supervisor trust key is invalid"); const supervised = await superviseProviderEvidence({ kind, archivePath, expectedArchiveSha256: archiveSha256, expectedRun: { id: runId, attempt: runAttempt, sourceRevision }, trust, executeProvider: async ({ candidateRoot, environment }) => { const childEnvironment = createProviderEnvironment(kind, reportPath, environment); await runProviderInSandbox( command, childEnvironment, rawDirectory, workspaceRoot, candidateRoot, ); }, captureReport: () => readBoundedRegularFile({ root: workspaceRoot, relativePath: path.relative(workspaceRoot, reportAbsolute).replaceAll(path.sep, "/"), maxBytes: 8_388_608, }), }); await assertSafePublishLeaf(sealedAbsolute, sealedPath); await writeValidatedJsonArtifact({ path: sealedPath, schema: kind === "vulnerability" ? vulnerabilityProviderReportSchema : provenanceProviderAttestationSchema, value: supervised.evidence, }); if (process.env.GITHUB_OUTPUT) { await appendFile( process.env.GITHUB_OUTPUT, `invocation_nonce=${supervised.invocationNonce}\n`, "utf8", ); } process.stdout.write(`${kind} provider supervised validation: PASS\n`); function createProviderEnvironment( providerKind: "vulnerability" | "provenance", rawReportPath: string, bindings: Readonly>, ): 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", ...bindings, ...(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, candidateRoot: 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, "--ro-bind", candidateRoot, "/candidate", "--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); }