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.
132 lines
4.6 KiB
TypeScript
132 lines
4.6 KiB
TypeScript
#!/usr/bin/env node
|
|
// Flags committed private-key material from tracked files only. Dependency-free
|
|
// by design: the CI security-fast job runs it before any install and after
|
|
// checkout credentials are dropped, so it needs no network and executes no
|
|
// third-party hook code. Marker set mirrors pre-commit-hooks v6.0.0
|
|
// detect_private_key.py (byte substrings, not regexes) so local prek runs and
|
|
// CI agree on what counts as a key. Self-contained by contract: pull-request
|
|
// CI extracts the base-ref copy with `git show` and runs it from outside the
|
|
// candidate tree, so a relative import here would break that trusted path.
|
|
import { spawnSync } from "node:child_process";
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
const TOOL_NAME = "detect-private-keys";
|
|
|
|
// Assembled at load time so this file never contains a literal marker: any
|
|
// byte-substring scanner (this one, or the upstream hook that scans PRs) would
|
|
// otherwise flag its own table, and a path carve-out for the scanner would
|
|
// then be needed in every scanner config.
|
|
const KEY = "PRIVATE KEY";
|
|
export const PRIVATE_KEY_MARKERS: readonly string[] = [
|
|
`BEGIN RSA ${KEY}`,
|
|
`BEGIN DSA ${KEY}`,
|
|
`BEGIN EC ${KEY}`,
|
|
`BEGIN OPENSSH ${KEY}`,
|
|
`BEGIN ${KEY}`,
|
|
["PuTTY-User-Key-File-", "2"].join(""),
|
|
`BEGIN SSH2 ENCRYPTED ${KEY}`,
|
|
`BEGIN PGP ${KEY} BLOCK`,
|
|
`BEGIN ENCRYPTED ${KEY}`,
|
|
["BEGIN OpenVPN Static key V", "1"].join(""),
|
|
];
|
|
|
|
// Colocated test fixtures and the iOS Fastfile's marker-bearing string. The
|
|
// list lives here, not in a config the scanner reads, so CI can run a
|
|
// base-ref copy of this file and get the base-ref policy with it.
|
|
export const PRIVATE_KEY_SCAN_EXCLUDE = /(^|\/)(apps\/ios\/fastlane\/Fastfile$|.*\.test\.ts$)/;
|
|
|
|
const REGULAR_FILE_MODES = new Set(["100644", "100755"]);
|
|
|
|
export function findPrivateKeyMarker(content: Buffer): string | undefined {
|
|
return PRIVATE_KEY_MARKERS.find((marker) => content.includes(marker));
|
|
}
|
|
|
|
// Regular tracked files only: symlinks would double-scan or dangle, gitlinks
|
|
// are directories. Matches pre-commit's `types: [text]` scope closely enough
|
|
// that the current tree passes without a binary sniffer.
|
|
export function listTrackedRegularFiles(cwd: string): string[] {
|
|
const result = spawnSync("git", ["ls-files", "-z", "--stage"], {
|
|
cwd,
|
|
encoding: "utf8",
|
|
maxBuffer: 256 * 1024 * 1024,
|
|
});
|
|
if (result.error) {
|
|
throw result.error;
|
|
}
|
|
if (result.status !== 0) {
|
|
throw new Error(`git ls-files exited ${result.status}: ${result.stderr.trim()}`);
|
|
}
|
|
const files: string[] = [];
|
|
for (const entry of result.stdout.split("\0")) {
|
|
if (!entry) {
|
|
continue;
|
|
}
|
|
const tabIndex = entry.indexOf("\t");
|
|
const mode = entry.slice(0, entry.indexOf(" "));
|
|
if (REGULAR_FILE_MODES.has(mode)) {
|
|
files.push(entry.slice(tabIndex + 1));
|
|
}
|
|
}
|
|
return files;
|
|
}
|
|
|
|
export type PrivateKeyFinding = { file: string; marker: string };
|
|
|
|
export function scanFilesForPrivateKeys(
|
|
files: readonly string[],
|
|
cwd: string,
|
|
): PrivateKeyFinding[] {
|
|
const findings: PrivateKeyFinding[] = [];
|
|
for (const file of files) {
|
|
if (PRIVATE_KEY_SCAN_EXCLUDE.test(file)) {
|
|
continue;
|
|
}
|
|
const marker = findPrivateKeyMarker(fs.readFileSync(path.resolve(cwd, file)));
|
|
if (marker) {
|
|
findings.push({ file, marker });
|
|
}
|
|
}
|
|
return findings;
|
|
}
|
|
|
|
function main(argv: readonly string[]): number {
|
|
const cwd = process.cwd();
|
|
// Explicit paths come from pre-commit's staged-file batches; no args scans
|
|
// every tracked regular file (CI and `--all-files`).
|
|
const files = argv.length > 0 ? argv : listTrackedRegularFiles(cwd);
|
|
const findings = scanFilesForPrivateKeys(files, cwd);
|
|
if (findings.length === 0) {
|
|
console.log(`[${TOOL_NAME}] scanned ${files.length} files; no private keys found.`);
|
|
return 0;
|
|
}
|
|
for (const finding of findings) {
|
|
console.error(`Private key found: ${finding.file} (${finding.marker})`);
|
|
}
|
|
console.error("Remove or rotate the key material; test fixtures belong in *.test.ts files.");
|
|
return 1;
|
|
}
|
|
|
|
function isDirectRun(): boolean {
|
|
if (!process.argv[1]) {
|
|
return false;
|
|
}
|
|
const normalize = (value: string) =>
|
|
process.platform === "win32" ? path.resolve(value).toLowerCase() : path.resolve(value);
|
|
return normalize(process.argv[1]) === normalize(fileURLToPath(import.meta.url));
|
|
}
|
|
|
|
if (isDirectRun()) {
|
|
let exitCode: number;
|
|
try {
|
|
exitCode = main(process.argv.slice(2));
|
|
} catch (error) {
|
|
console.error(error);
|
|
exitCode = 1;
|
|
}
|
|
if (exitCode !== 0) {
|
|
console.error(`[${TOOL_NAME}] FAILED (exit ${exitCode})`);
|
|
}
|
|
process.exitCode = exitCode;
|
|
}
|