Files
openrig/scripts/portability-report.mjs
Seán McNamara 4c0af6c536 fix: normalize diff prefixes in portability reports (#2148)
The portability report passes explicit `a/` and `b/` diff prefixes to its own `git diff`, so a user's `diff.mnemonicPrefix` setting no longer changes the file names it reports. The test covers both mnemonic-prefix settings with both `core.quotePath` settings. Default-config output is unchanged.

2 files, +4 -3.
2026-10-26 22:30:36 -07:00

188 lines
8.0 KiB
JavaScript

#!/usr/bin/env node
// Lists lines added by a change that contain values tied to one machine, network or account, so
// the author can replace them with portable ones or keep them on purpose. Findings never fail it: a finding is a question, not a verdict. It exits 0
// with or without findings and 2 on an operational error (bad argument, git failure).
//
// What it looks for:
// 1. Credentials: keys, tokens, passwords, key blocks.
// 2. Absolute home paths, IP addresses and local-network hostnames.
// 3. Temporary and instance-specific directories.
// 4. Email addresses.
// Rig, seat and agent names are ordinary identifiers and are not reported.
//
// Usage:
// node scripts/portability-report.mjs # merge-base with origin/main .. HEAD
// node scripts/portability-report.mjs --from A --to B # a commit range
// node scripts/portability-report.mjs --staged # what is staged for the next commit
// add --out report.md to also write the report to a file
// add --locations-only to list file and line without quoting the matched text (for public CI output)
import { execFileSync } from "node:child_process";
import { writeFileSync, realpathSync } from "node:fs";
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
export const CHECKS = [
{
category: "Credential",
why: "looks like a credential; load it from the environment or a secret store instead",
pattern:
/-----BEGIN [A-Z ]*PRIVATE KEY-----|\b(?:sk-ant-[A-Za-z0-9_-]{20,}|sk-[A-Za-z0-9_-]{32,}|gh[pousr]_[A-Za-z0-9]{30,}|github_pat_[A-Za-z0-9_]{30,}|AKIA[0-9A-Z]{16}|xox[baprs]-[A-Za-z0-9-]{10,}|AIza[0-9A-Za-z_-]{35}|pk_(?:live|test)_[A-Za-z0-9]{16,}|sk_(?:live|test)_[A-Za-z0-9]{16,})\b|\b(?:api[_-]?key|secret|token|password|passwd)\b["']?\s*[:=]\s*["'](?![0-9a-f]{8}-[0-9a-f]{4}-)(?=[^"']*\d)(?=[^"']*[A-Za-z])[^"'\s$<{]{16,}["']/i,
},
{
category: "Home path",
why: "absolute path under one person's home directory; will not exist on another machine",
pattern: /(?:\/Users|\/home)\/(?!(?:example|you|your-?name|username|user|me|someone|name|op|x|test|tester|runner)\/)[A-Za-z0-9._-]+\/|[A-Za-z]:\\Users\\(?!(?:example|you|user|username)\\)[A-Za-z0-9._-]+\\/,
},
{
category: "Machine path",
why: "temporary or instance-specific directory tied to one machine",
pattern: /\/private\/tmp\/[A-Za-z0-9._-]+|\/var\/folders\/[A-Za-z0-9_]+\/|\/tmp\/claude-\d+|\.openrig-[a-z0-9-]*-[0-9a-f]{6,}/,
},
{
category: "Network address",
why: "IP address or local-network hostname that only resolves on one network",
pattern: /(?<![\w.])(?!127\.|0\.0\.0\.0(?![\w.])|255\.|192\.0\.2\.|198\.51\.100\.|203\.0\.113\.)(?:(?:25[0-5]|2[0-4]\d|1?\d?\d)\.){3}(?:25[0-5]|2[0-4]\d|1?\d?\d)(?![\w.])|(?<![\w.-])[a-z0-9-]+\.(?:local|lan|internal|ts\.net|home\.arpa)\b(?![.\w-])/i,
},
{
category: "Email address",
why: "contact details belong in project documentation, not in code",
pattern: /\b(?![A-Za-z0-9._%+-]*noreply)[A-Za-z0-9._%+-]+@(?!(?:example|test|localhost|openrig)\b)[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)*\.[A-Za-z]{2,}\b/,
},
];
// Parses `git diff -U0` output into the lines it adds, with their new line numbers.
export function addedLines(diffText) {
const lines = [];
let file = null;
let lineNumber = 0;
let remainingNewLines = 0;
for (const raw of diffText.split("\n")) {
if (remainingNewLines > 0 && raw.startsWith("+")) {
lines.push({ file, line: lineNumber, text: raw.slice(1) });
lineNumber += 1;
remainingNewLines -= 1;
} else if (remainingNewLines > 0 && raw.startsWith(" ")) {
lineNumber += 1;
remainingNewLines -= 1;
} else if (raw.startsWith("+++ ")) {
file = raw === "+++ /dev/null" ? null : raw.slice(4).split("\t", 1)[0].replace(/^b\//, "");
} else if (raw.startsWith("@@")) {
const hunk = /\+(\d+)(?:,(\d+))?/.exec(raw);
lineNumber = Number(hunk?.[1] ?? 0);
remainingNewLines = Number(hunk?.[2] ?? (hunk ? 1 : 0));
}
}
return lines;
}
export function findPortabilityIssues(lines, checks = CHECKS) {
const findings = [];
for (const entry of lines) {
for (const check of checks) {
const match = check.pattern.exec(entry.text);
if (match) findings.push({ ...entry, category: check.category, why: check.why, match: match[0] });
}
}
return findings;
}
export function renderReport(findings, label, { locationsOnly = false } = {}) {
const out = [`# Portability report: ${label}`, ""];
if (findings.length === 0) {
out.push("No matching machine-specific values detected.");
return `${out.join("\n")}\n`;
}
out.push(
`${findings.length} added line(s) contain values that may not work on another machine. For each`,
"one, replace it with a portable value or placeholder, or keep it because it is intended.",
"",
);
// A report can be public (a CI summary), so a suspected credential is never repeated in it,
// including through another category's excerpt of the same line.
const secretsByLine = new Map();
for (const { file, line, match, category } of findings) {
if (category !== "Credential") continue;
const key = `${file}:${line}`;
secretsByLine.set(key, [...(secretsByLine.get(key) ?? []), match]);
}
const withhold = (value, key) =>
(secretsByLine.get(key) ?? []).reduce((text, secret) => text.replaceAll(secret, "[withheld]"), value);
for (const check of CHECKS) {
const group = findings.filter((finding) => finding.category === check.category);
if (group.length === 0) continue;
out.push(`## ${check.category} (${group.length})`, "", `_${check.why}_`, "");
for (const { file, line, match, text } of group) {
if (locationsOnly) {
out.push(`- \`${file}:${line}\``);
continue;
}
if (check.category === "Credential") {
out.push(`- \`${file}:${line}\` matched \`${match.slice(0, 6)}…\` (value withheld)`);
continue;
}
const key = `${file}:${line}`;
const safe = withhold(text.trim(), key);
const excerpt = safe.length > 140 ? `${safe.slice(0, 140)}…` : safe;
out.push(`- \`${key}\` matched \`${withhold(match, key)}\`: ${excerpt.replaceAll("|", "\\|")}`);
}
out.push("");
}
return `${out.join("\n")}\n`;
}
function parseArguments(argv) {
const options = { staged: false, locationsOnly: false };
for (let index = 0; index < argv.length; index += 1) {
const key = argv[index];
if (key === "--staged") options.staged = true;
else if (key === "--locations-only") options.locationsOnly = true;
else if (["--from", "--to", "--out", "--repo"].includes(key)) options[key.slice(2)] = argv[++index];
else throw new Error(`Unknown argument: ${key}`);
}
return options;
}
function git(repo, args) {
return execFileSync("git", args, { cwd: repo, encoding: "utf8", maxBuffer: 256 * 1024 * 1024 });
}
export function main(argv = process.argv.slice(2)) {
const options = parseArguments(argv);
const repo = resolve(options.repo ?? ".");
let range;
let label;
if (options.staged) {
range = ["--cached"];
label = "staged changes";
} else {
const to = options.to ?? "HEAD";
const from = options.from ?? git(repo, ["merge-base", "origin/main", to]).trim();
range = [`${from}..${to}`];
label = `${from.slice(0, 8)}..${to.length > 12 ? to.slice(0, 8) : to}`;
}
const diff = git(repo, ["-c", "core.quotePath=false", "diff", "-U0", "--no-color", "--src-prefix=a/", "--dst-prefix=b/", "--diff-filter=ACMR", ...range]);
const report = renderReport(findPortabilityIssues(addedLines(diff)), label, {
locationsOnly: options.locationsOnly,
});
process.stdout.write(report);
if (options.out) writeFileSync(options.out, report);
return 0;
}
function invokedDirectly() {
try {
return Boolean(process.argv[1]) && realpathSync(fileURLToPath(import.meta.url)) === realpathSync(process.argv[1]);
} catch { return false; }
}
if (invokedDirectly()) {
try {
process.exitCode = main();
} catch (error) {
process.stderr.write(`${error.message}\n`);
process.exitCode = 2;
}
}