mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 09:39:25 +00:00
* fix(ci): scan committed private keys without network or third-party hooks The security-fast job ran `pre-commit run --all-files detect-private-key`, whose environment init fetches every pinned hook repo before scanning a single file. First-attempt Blacksmith runners intermittently rejected the anonymous git-over-HTTPS fetch (`could not read Username`), failing three consecutive PR heads on 2026-09-02, and handing the job token to that fetch was rejected in review because it reaches third-party hook code. Replace the step with an in-repo, dependency-free scanner (`scripts/detect-private-keys.mts`) that mirrors the pre-commit-hooks v6.0.0 marker set over every tracked regular file, runs on plain Node after checkout credentials are dropped, and ends failures with the `[detect-private-keys] FAILED (exit N)` wrapper trailer. The local pre-commit config now calls the same script, so one exclude list owns both CI and local runs. * fix(ci): run the base-ref private-key scanner before pre-commit Pull requests now select scripts/detect-private-keys.mts from the base SHA alongside the trusted pre-commit config, so a candidate cannot weaken the marker or exclude list it is scanned by; the scanner is self-contained so the extracted copy runs from RUNNER_TEMP. The scan moves ahead of every pre-commit step so hook-repo initialization can no longer prevent it. Docs describe the exact scan scope, and a workflow guard executes both step bodies against a candidate that neuters the scanner. * fix(ci): stage the private-key scanner behind the existing trusted hook Stage one of the rollout: CI keeps running the trusted base-config detect-private-key hook, which now executes the in-repo scanner, and moves Node 24 setup ahead of pre-commit so the local hook has a type-stripping Node on main pushes. The base-ref scanner step and its workflow guard land in a stacked follow-up once the scanner exists on main, so that step can fail closed instead of running a candidate-controlled fallback. * chore(ci): keep stage one to the scanner and its tests Restore the hook config, workflow, and security docs to main so this stage changes no detector: CI and local pre-commit keep the pinned upstream hook until the base-ref scanner step lands in the follow-up. Assemble the marker table at load time so the scanner source never contains a literal marker; the upstream hook then passes over it and no scanner config needs a self carve-out.
119 lines
4.8 KiB
TypeScript
119 lines
4.8 KiB
TypeScript
// Covers the in-repo private-key scanner that replaced the network-dependent
|
|
// pre-commit detect-private-key hook in the CI security-fast job.
|
|
import { spawnSync } from "node:child_process";
|
|
import fs from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { afterEach, describe, expect, it } from "vitest";
|
|
import {
|
|
findPrivateKeyMarker,
|
|
PRIVATE_KEY_MARKERS,
|
|
PRIVATE_KEY_SCAN_EXCLUDE,
|
|
} from "../../scripts/detect-private-keys.mts";
|
|
|
|
const SCRIPT_PATH = fileURLToPath(
|
|
new URL("../../scripts/detect-private-keys.mts", import.meta.url),
|
|
);
|
|
|
|
function runScanner(cwd: string, args: string[] = [], scriptPath = SCRIPT_PATH) {
|
|
return spawnSync(process.execPath, [scriptPath, ...args], { cwd, encoding: "utf8" });
|
|
}
|
|
|
|
describe("detect-private-keys markers", () => {
|
|
it.each(PRIVATE_KEY_MARKERS)("flags %s anywhere in file bytes", (marker) => {
|
|
const content = Buffer.from(`prefix\n-----${marker}-----\nnot key material\n`);
|
|
expect(findPrivateKeyMarker(content)).toBe(marker);
|
|
});
|
|
|
|
it("ignores public material and certificates", () => {
|
|
expect(
|
|
findPrivateKeyMarker(
|
|
Buffer.from("-----BEGIN CERTIFICATE-----\n-----BEGIN PUBLIC KEY-----\n"),
|
|
),
|
|
).toBeUndefined();
|
|
});
|
|
|
|
it("keeps its own source free of literal markers instead of excluding itself", () => {
|
|
const source = fs.readFileSync(SCRIPT_PATH);
|
|
expect(findPrivateKeyMarker(source)).toBeUndefined();
|
|
expect(PRIVATE_KEY_SCAN_EXCLUDE.test("scripts/detect-private-keys.mts")).toBe(false);
|
|
});
|
|
|
|
it("excludes colocated test fixtures and the iOS Fastfile, nothing else", () => {
|
|
expect(PRIVATE_KEY_SCAN_EXCLUDE.test("src/infra/push-apns.test.ts")).toBe(true);
|
|
expect(PRIVATE_KEY_SCAN_EXCLUDE.test("apps/ios/fastlane/Fastfile")).toBe(true);
|
|
expect(PRIVATE_KEY_SCAN_EXCLUDE.test("src/infra/push-apns.ts")).toBe(false);
|
|
expect(PRIVATE_KEY_SCAN_EXCLUDE.test("docs/push-apns.test.ts.md")).toBe(false);
|
|
expect(PRIVATE_KEY_SCAN_EXCLUDE.test("vendor/apps/ios/fastlane/Fastfile.bak")).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("detect-private-keys CLI", () => {
|
|
let repoDir: string | undefined;
|
|
|
|
afterEach(() => {
|
|
if (repoDir) {
|
|
fs.rmSync(repoDir, { recursive: true, force: true });
|
|
repoDir = undefined;
|
|
}
|
|
});
|
|
|
|
function createTrackedRepo(files: Record<string, string>) {
|
|
repoDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "detect-private-keys-")));
|
|
for (const [file, content] of Object.entries(files)) {
|
|
fs.writeFileSync(path.join(repoDir, file), content);
|
|
}
|
|
fs.symlinkSync("clean.txt", path.join(repoDir, "link.txt"));
|
|
fs.writeFileSync(path.join(repoDir, "untracked.pem"), "-----BEGIN DSA PRIVATE KEY-----\n");
|
|
expect(spawnSync("git", ["init", "-q", "."], { cwd: repoDir }).status).toBe(0);
|
|
expect(
|
|
spawnSync("git", ["add", "--", ...Object.keys(files), "link.txt"], { cwd: repoDir }).status,
|
|
).toBe(0);
|
|
return repoDir;
|
|
}
|
|
|
|
it("fails with the wrapper trailer when a tracked file carries a key marker", () => {
|
|
const cwd = createTrackedRepo({
|
|
"clean.txt": "nothing to see\n",
|
|
"leaked.pem": "-----BEGIN RSA PRIVATE KEY-----\nfixture only, no key material\n",
|
|
"fixture.test.ts": "-----BEGIN EC PRIVATE KEY-----\n",
|
|
});
|
|
// Pull-request CI runs a `git show` copy of the base-ref scanner from
|
|
// outside the candidate tree, so the file must work without siblings.
|
|
const standaloneCopy = path.join(fs.mkdtempSync(path.join(os.tmpdir(), "dpk-copy-")), "s.mts");
|
|
fs.copyFileSync(SCRIPT_PATH, standaloneCopy);
|
|
|
|
const result = runScanner(cwd, [], standaloneCopy);
|
|
fs.rmSync(path.dirname(standaloneCopy), { recursive: true, force: true });
|
|
|
|
expect(result.status).toBe(1);
|
|
expect(result.stderr).toContain("Private key found: leaked.pem (BEGIN RSA PRIVATE KEY)");
|
|
expect(result.stderr).not.toContain("fixture.test.ts");
|
|
expect(result.stderr).not.toContain("untracked.pem");
|
|
expect(result.stderr.trimEnd().endsWith("[detect-private-keys] FAILED (exit 1)")).toBe(true);
|
|
});
|
|
|
|
it("passes a clean tree and scans only explicit paths when given", () => {
|
|
const cwd = createTrackedRepo({
|
|
"clean.txt": "nothing to see\n",
|
|
"leaked.pem": "PuTTY-User-Key-File-2: ssh-rsa\n",
|
|
});
|
|
|
|
expect(runScanner(cwd, ["clean.txt"])).toMatchObject({
|
|
status: 0,
|
|
stdout: expect.stringContaining("scanned 1 files; no private keys found"),
|
|
});
|
|
expect(runScanner(cwd).status).toBe(1);
|
|
});
|
|
|
|
it("reports a crash as a failure instead of a silent pass", () => {
|
|
const cwd = createTrackedRepo({ "clean.txt": "nothing to see\n" });
|
|
|
|
const result = runScanner(cwd, ["missing.txt"]);
|
|
|
|
expect(result.status).toBe(1);
|
|
expect(result.stderr).toContain("ENOENT");
|
|
expect(result.stderr).toContain("[detect-private-keys] FAILED (exit 1)");
|
|
});
|
|
});
|