openclaw/test/scripts/ci-security-fast-workflow.test.ts
Peter Steinberger e63a41414f
fix(ci): harden pnpm bulk advisory retries (#137989)
Recover flapping npm audit requests with four attempts, Retry-After, a total deadline, and attempt logs. Restore fail-closed security-fast and prove recovery and persistent failure through both audit callers.
2026-09-04 00:00:11 -07:00

361 lines
13 KiB
TypeScript

import { execFileSync, spawnSync } from "node:child_process";
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { delimiter, join, resolve } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { parse } from "yaml";
import { useAutoCleanupTempDirTracker } from "../helpers/temp-dir.js";
type WorkflowStep = {
env?: Record<string, string>;
name?: string;
run?: string;
with?: Record<string, unknown>;
};
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
const workflowPath = ".github/workflows/ci.yml";
const scannerPath = "scripts/detect-private-keys.mts";
const localGitEnvironment = {
GIT_ALLOW_PROTOCOL: "file",
GIT_CONFIG_COUNT: "0",
GIT_CONFIG_GLOBAL: "/dev/null",
GIT_CONFIG_NOSYSTEM: "1",
GIT_TERMINAL_PROMPT: "0",
};
const localGitCommand = "GIT_ALLOW_PROTOCOL=file GIT_CONFIG_COUNT=0";
const neuteredScanner = "process.exit(0);\n";
function securityJob() {
const workflow = parse(readFileSync(workflowPath, "utf8")) as {
jobs: Record<string, { steps: WorkflowStep[] }>;
};
const job = workflow.jobs["security-fast"];
if (!job) {
throw new Error("security-fast job is missing");
}
return job;
}
function securityStep(name: string): WorkflowStep {
const step = securityJob().steps.find((candidate) => candidate.name === name);
if (!step) {
throw new Error(`security-fast step is missing: ${name}`);
}
return step;
}
function securityStepIndex(name: string): number {
const index = securityJob().steps.findIndex((candidate) => candidate.name === name);
if (index < 0) {
throw new Error(`security-fast step is missing: ${name}`);
}
return index;
}
function writeExecutable(filePath: string, source: string): void {
writeFileSync(filePath, source, "utf8");
chmodSync(filePath, 0o755);
}
function runGit(cwd: string, ...args: string[]): string {
return execFileSync("git", args, {
cwd,
encoding: "utf8",
env: { ...process.env, ...localGitEnvironment },
}).trim();
}
function runStep(
step: WorkflowStep,
cwd: string,
env: NodeJS.ProcessEnv,
): { status: number | null; stderr: string; stdout: string } {
if (!step.run) {
throw new Error(`workflow step has no shell body: ${step.name ?? "unknown"}`);
}
const result = spawnSync("bash", ["-euo", "pipefail", "-c", step.run], {
cwd,
encoding: "utf8",
env: { ...process.env, ...step.env, ...env },
});
return {
status: result.status,
stderr: result.stderr,
stdout: result.stdout,
};
}
function readGitHubEnvironment(filePath: string): Record<string, string> {
if (!existsSync(filePath)) {
return {};
}
return Object.fromEntries(
readFileSync(filePath, "utf8")
.trim()
.split("\n")
.filter(Boolean)
.map((line) => {
const separator = line.indexOf("=");
return [line.slice(0, separator), line.slice(separator + 1)];
}),
);
}
function createFixture() {
const root = tempDirs.make("openclaw-security-fast-");
const repo = join(root, "repo");
const bin = join(root, "bin");
const runnerTemp = join(root, "runner");
const githubEnv = join(root, "github-env");
mkdirSync(join(repo, ".github"), { recursive: true });
mkdirSync(join(repo, "scripts"));
mkdirSync(bin);
mkdirSync(runnerTemp);
runGit(repo, "init", "--initial-branch=main");
runGit(repo, "config", "user.name", "CI Fixture");
runGit(repo, "config", "user.email", "ci@example.invalid");
runGit(repo, "commit", "--allow-empty", "-m", "initial");
const missingPolicySha = runGit(repo, "rev-parse", "HEAD");
writeFileSync(join(repo, ".github", "zizmor.yml"), "rules:\n trusted-base: {}\n");
const realScanner = readFileSync(resolve(scannerPath));
writeFileSync(join(repo, scannerPath), realScanner);
runGit(repo, "add", ".");
runGit(repo, "commit", "-m", "base policy");
const baseSha = runGit(repo, "rev-parse", "HEAD");
// The candidate poisons every input it could: policy, hook config, and the
// scanner itself, while adding a key the neutered scanner would let through.
writeFileSync(join(repo, ".github", "zizmor.yml"), "rules:\n candidate-poison: {}\n");
writeFileSync(
join(repo, ".pre-commit-config.yaml"),
"repos:\n - repo: https://example.invalid/poison.git\n",
);
writeFileSync(join(repo, scannerPath), neuteredScanner);
writeFileSync(join(repo, "leaked.pem"), "-----BEGIN RSA PRIVATE KEY-----\nfixture only\n");
runGit(repo, "add", ".");
runGit(repo, "commit", "-m", "candidate policy");
mkdirSync(join(repo, ".ci-harness", ".github"), { recursive: true });
mkdirSync(join(repo, ".ci-harness", "scripts"), { recursive: true });
writeFileSync(
join(repo, ".ci-harness", ".github", "zizmor.yml"),
"rules:\n trusted-harness: {}\n",
);
writeFileSync(join(repo, ".ci-harness", scannerPath), realScanner);
const realGit = execFileSync("sh", ["-c", "command -v git"], {
encoding: "utf8",
}).trim();
writeExecutable(
join(bin, "git"),
`#!/bin/sh
skip_value=0
command=
for arg in "$@"; do
if [ "$skip_value" = 1 ]; then
skip_value=0
continue
fi
case "$arg" in
-c|-C|--config-env|--exec-path|--git-dir|--namespace|--work-tree)
skip_value=1
;;
-*)
;;
*)
command="$arg"
break
;;
esac
done
case "$command" in
clone|fetch|ls-remote)
echo "network Git is forbidden in security-fast" >&2
exit 97
;;
esac
exec "$OPENCLAW_TEST_REAL_GIT" "$@"
`,
);
return {
baseSha,
environment: {
GITHUB_ENV: githubEnv,
OPENCLAW_TEST_REAL_GIT: realGit,
PATH: `${bin}${delimiter}${process.env.PATH ?? ""}`,
RUNNER_TEMP: runnerTemp,
},
githubEnv,
missingPolicySha,
realScanner,
repo,
runnerTemp,
};
}
function prepareConfig(
fixture: ReturnType<typeof createFixture>,
eventName: "pull_request" | "push" | "workflow_dispatch",
baseSha = fixture.baseSha,
) {
const result = runStep(securityStep("Prepare trusted scanner config"), fixture.repo, {
...fixture.environment,
BASE_SHA: baseSha,
GITHUB_EVENT_NAME: eventName,
});
return {
githubEnvironment: readGitHubEnvironment(fixture.githubEnv),
result,
};
}
describe("security-fast workflow", () => {
it.each([0, 1, 2, 3, 130])(
"propagates audit exit %s in ordinary and scheduled CI",
(auditExit) => {
const repo = tempDirs.make("openclaw-audit-ci-");
mkdirSync(join(repo, "scripts", "pre-commit"), { recursive: true });
writeFileSync(
join(repo, "scripts", "pre-commit", "pnpm-audit-prod.mjs"),
`process.exit(${auditExit});\n`,
);
const result = runStep(securityStep("Audit production dependencies"), repo, {});
expect(result.status).toBe(auditExit);
expect(result.stdout).toBe("");
const scheduled = parse(readFileSync(".github/workflows/dependency-audit.yml", "utf8")) as {
jobs: { audit: { steps: WorkflowStep[] } };
};
const strictStep = scheduled.jobs.audit.steps.find(
(step) => step.name === "Audit production dependencies",
);
if (!strictStep) {
throw new Error("scheduled production audit step is missing");
}
const summary = join(repo, "summary.md");
const strict = runStep(strictStep, repo, { GITHUB_STEP_SUMMARY: summary });
expect(strict.status).toBe(auditExit);
expect(readFileSync(summary, "utf8")).toContain("Triage owner: @steipete");
},
);
it("generates the exact local-only scanner contract from trusted policy", () => {
const job = securityJob();
const checkoutHarness = securityStep("Checkout trusted CI harness");
const prepare = securityStep("Prepare trusted scanner config");
const install = securityStep("Install security scanners");
const detect = securityStep("Detect committed private keys");
const zizmor = securityStep("Audit changed GitHub workflows with zizmor");
expect(checkoutHarness.with?.ref).toBe("${{ github.workflow_sha }}");
expect(checkoutHarness.with?.["persist-credentials"]).toBe(false);
expect(checkoutHarness.with?.["sparse-checkout"]).toContain(".github/zizmor.yml");
expect(checkoutHarness.with?.["sparse-checkout"]).toContain(scannerPath);
expect(job.steps.some((step) => step.name === "Resolve Python runtime")).toBe(false);
expect(install.run).toContain("python3 --version");
expect(install.run).toContain("pre-commit==4.6.2 zizmor==1.29.0");
expect(install.run).not.toContain("pre-commit-hooks");
expect(prepare.run).not.toMatch(/origin\/|BASE_REF|PRE_COMMIT_CONFIG_PATH:-/u);
// The first-party key scan runs before any package install can fail or
// execute third-party code, and never through pre-commit.
expect(detect.run).toBe('node "$PRIVATE_KEY_SCANNER_PATH"');
expect(securityStepIndex("Setup Node.js")).toBeLessThan(securityStepIndex(detect.name!));
expect(securityStepIndex(detect.name!)).toBeLessThan(securityStepIndex(install.name!));
expect(zizmor.run).toContain(localGitCommand);
const fixture = createFixture();
const prepared = prepareConfig(fixture, "pull_request");
expect(prepared.result.status, `${prepared.result.stdout}${prepared.result.stderr}`).toBe(0);
const configPath = prepared.githubEnvironment.PRE_COMMIT_CONFIG_PATH;
if (!configPath) {
throw new Error("scanner config path was not published");
}
const config = parse(readFileSync(configPath, "utf8")) as {
repos: Array<{ hooks: Array<Record<string, unknown>>; repo: string }>;
};
expect(config.repos).toEqual([
{
repo: "local",
hooks: [
{
id: "zizmor",
name: "zizmor",
entry: "zizmor",
language: "system",
types: ["yaml"],
files: String.raw`(\.github/(workflows/.*|dependabot.ya?ml))|(action\.ya?ml)$`,
require_serial: true,
args: [
"--config",
join(fixture.runnerTemp, "zizmor.yml"),
"--persona=regular",
"--min-severity=medium",
"--min-confidence=medium",
],
exclude: "^(vendor/|apps/swabble/)",
},
],
},
]);
expect(readFileSync(join(fixture.runnerTemp, "zizmor.yml"), "utf8")).toBe(
"rules:\n trusted-base: {}\n",
);
expect(readFileSync(configPath, "utf8")).not.toContain("example.invalid");
const trustedScanner = prepared.githubEnvironment.PRIVATE_KEY_SCANNER_PATH;
expect(trustedScanner).toBe(join(fixture.runnerTemp, "detect-private-keys.mts"));
expect(readFileSync(trustedScanner!)).toEqual(fixture.realScanner);
const scanned = runStep(detect, fixture.repo, {
...fixture.environment,
...prepared.githubEnvironment,
});
expect(scanned.status).toBe(1);
expect(scanned.stderr).toContain("Private key found: leaked.pem (BEGIN RSA PRIVATE KEY)");
expect(scanned.stderr).toContain("[detect-private-keys] FAILED (exit 1)");
});
it("fails closed for a missing exact-base policy and trusts the harness otherwise", () => {
const missing = createFixture();
const rejected = prepareConfig(missing, "pull_request", missing.missingPolicySha);
expect(rejected.result.status).not.toBe(0);
expect(`${rejected.result.stdout}${rejected.result.stderr}`).toContain(
"trusted zizmor policy unavailable",
);
expect(rejected.githubEnvironment).toEqual({});
for (const eventName of ["push", "workflow_dispatch"] as const) {
const fixture = createFixture();
const prepared = prepareConfig(fixture, eventName);
expect(prepared.result.status, `${prepared.result.stdout}${prepared.result.stderr}`).toBe(0);
expect(readFileSync(join(fixture.runnerTemp, "zizmor.yml"), "utf8")).toBe(
"rules:\n trusted-harness: {}\n",
);
expect(readFileSync(join(fixture.runnerTemp, "detect-private-keys.mts"))).toEqual(
fixture.realScanner,
);
}
});
it("fails closed when the exact base has no scanner instead of running the candidate copy", () => {
const fixture = createFixture();
// A base with the policy but not the scanner: the bootstrap gap a
// candidate could otherwise exploit with a neutered scanner.
writeFileSync(join(fixture.repo, ".github", "zizmor.yml"), "rules:\n trusted-base: {}\n");
runGit(fixture.repo, "rm", "-q", "--cached", scannerPath);
runGit(fixture.repo, "add", ".github/zizmor.yml");
runGit(fixture.repo, "commit", "-m", "base without scanner");
const rejected = prepareConfig(
fixture,
"pull_request",
runGit(fixture.repo, "rev-parse", "HEAD"),
);
expect(rejected.result.status).not.toBe(0);
expect(`${rejected.result.stdout}${rejected.result.stderr}`).toContain(
"trusted private-key scanner unavailable",
);
expect(rejected.githubEnvironment).toEqual({});
expect(existsSync(join(fixture.runnerTemp, "detect-private-keys.mts"))).toBe(false);
});
});