refactor: generate CI workflow from gate contracts
This commit is contained in:
@@ -0,0 +1,248 @@
|
||||
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<void> {
|
||||
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<void>((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<boolean> {
|
||||
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<void> {
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user