CI gate: the feature-branch hook runs the never-push classes (no-secrets, identity grep) beside the structural checks; harness summaries write keys through a redacting writer with its own check
The class (7 October 2026, 11:26 to 12:47 UK): three fork-gate summaries on ca3-v4-node carried the miners' vote-key hashes under "key" and eight CI runs went red on "no secret file names and no 64-hex secrets in the tree" while the pushing lanes saw nothing: the light gate ran only conflict markers and Windows paths. - tools/ci/pre-push.sh: never_push_checks() (identity grep, no-secrets) runs on every ref from --hook, and inside the full gate where the identity grep already sat; the self-test asserts the wiring; the light gate is about 20 s on the Mac. - infra/fast-time/lib/redact-keys.mjs: writeSummary() shortens every 64-hex value under a key-shaped field to 8 hex and an ellipsis and refuses a text the no-secrets rule would flag (line named); --self-test and --check; the gate runs the self-test. fork-gate.mjs adopts it on ca3-v4-node. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
0e4ea6909d
commit
3fae316607
2 changed files with 155 additions and 9 deletions
132
infra/fast-time/lib/redact-keys.mjs
Normal file
132
infra/fast-time/lib/redact-keys.mjs
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
#!/usr/bin/env node
|
||||
// Harness summaries never carry a raw key. Every fast-time runner that writes a summary JSON writes it through
|
||||
// writeSummary(): a 64-hex value under a key-shaped field (a name ending in key, keys, token, secret, password or
|
||||
// passphrase, or anything nested under one) is shortened to its first 8 hex characters and an ellipsis, and the
|
||||
// serialised text is then checked with the same rule as tools/ci/no-secrets-check.sh before it touches the disk.
|
||||
// A summary that would fail the no-secrets gate is refused here, at the source, with the line named.
|
||||
//
|
||||
// The class (7 October 2026, 11:26 to 12:47 UK): fork-gate.mjs wrote the two miners' vote-key hashes into
|
||||
// joint_sinks.*.key and split_sinks.*.key of three gate summaries under docs/plans/counter-asic-3-gate; eight CI runs
|
||||
// on ca3-v4-node went red on "no secret file names and no 64-hex secrets in the tree" and no pushing lane saw it,
|
||||
// because the feature-branch hook ran only the structural checks and the red watcher posted master and release-* only.
|
||||
//
|
||||
// import { writeSummary } from './lib/redact-keys.mjs'; writeSummary(OUT, summary);
|
||||
// node infra/fast-time/lib/redact-keys.mjs --self-test a key field is shortened, a hash field is left alone,
|
||||
// a raw key in the text fails the writer's own check
|
||||
// node infra/fast-time/lib/redact-keys.mjs --check <file>... exit 1 if any file carries a raw key line (the hits named)
|
||||
import { mkdirSync, writeFileSync, readFileSync, existsSync, rmSync, mkdtempSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
|
||||
// the content rule of tools/ci/no-secrets-check.sh, verbatim in spirit: a 64-hex value (0x optional) assigned to a name
|
||||
// ending in token, key, secret, password or passphrase
|
||||
export const RAW_KEY_LINE = /(token|key|secret|password|passphrase)["']?\s*[:=]\s*["']?(0x)?[0-9a-fA-F]{64}(?![0-9a-fA-F])/i;
|
||||
export const KEY_FIELD = /(key|keys|token|secret|password|passphrase)$/i;
|
||||
const HEX64 = /^(0x)?[0-9a-fA-F]{64}$/;
|
||||
|
||||
export function shortHex(v) {
|
||||
if (typeof v !== 'string' || !HEX64.test(v)) return v;
|
||||
const head = v.startsWith('0x') ? 10 : 8;
|
||||
return v.slice(0, head) + '…';
|
||||
}
|
||||
|
||||
// A copy of `value` with every 64-hex string under a key-shaped field shortened. Nothing else changes: hashes, digests
|
||||
// and ids under other names stay whole. The input object is never mutated (the runner keeps comparing live keys).
|
||||
export function redactKeys(value, underKeyField = false) {
|
||||
if (Array.isArray(value)) return value.map((v) => redactKeys(v, underKeyField));
|
||||
if (value && typeof value === 'object') {
|
||||
const out = {};
|
||||
for (const [k, v] of Object.entries(value)) out[k] = redactKeys(v, underKeyField || KEY_FIELD.test(k));
|
||||
return out;
|
||||
}
|
||||
return underKeyField ? shortHex(value) : value;
|
||||
}
|
||||
|
||||
// The line numbers (1-based) of `text` that the no-secrets gate would flag.
|
||||
export function rawKeyLines(text) {
|
||||
return text.split('\n').map((l, i) => (RAW_KEY_LINE.test(l) ? i + 1 : 0)).filter(Boolean);
|
||||
}
|
||||
|
||||
// The text a summary is written as: redacted, then checked. Throws when a raw key survives (a key inside a free-text
|
||||
// field such as a quoted log line), so the runner fails before the tree does.
|
||||
export function summaryText(summary) {
|
||||
const text = JSON.stringify(redactKeys(summary), null, 2);
|
||||
const lines = rawKeyLines(text);
|
||||
if (lines.length) throw new Error(`summary carries a raw 64-hex key at line${lines.length > 1 ? 's' : ''} ${lines.join(', ')}: the no-secrets gate would refuse it; shorten it with redactKeys or drop the field`);
|
||||
return text;
|
||||
}
|
||||
|
||||
export function writeSummary(file, summary) {
|
||||
mkdirSync(dirname(file), { recursive: true });
|
||||
const text = summaryText(summary);
|
||||
writeFileSync(file, text);
|
||||
return text;
|
||||
}
|
||||
|
||||
const hex = (c) => c.repeat(64);
|
||||
|
||||
function selfTest() {
|
||||
const fails = [];
|
||||
const summary = {
|
||||
pass: true, keys: { a: hex('a'), b: hex('b') },
|
||||
joint_sinks: { a: { hash: hex('c'), blocks: 1, key: hex('a') }, b: { hash: hex('d'), blocks: 2, key: '0x' + hex('b') } },
|
||||
samples: [{ t: 1, a: { sink: hex('c').slice(0, 16), key: hex('a') } }],
|
||||
vote_key: hex('e'), signingKey: hex('f'), a_token: hex('1'), digest: hex('2'), exec_restart_hash: hex('3'), id32: 'a'.repeat(32),
|
||||
};
|
||||
const before = JSON.stringify(summary);
|
||||
const out = redactKeys(summary);
|
||||
if (JSON.stringify(summary) !== before) fails.push('redactKeys mutated its input');
|
||||
if (out.keys.a !== 'aaaaaaaa…' || out.keys.b !== 'bbbbbbbb…') fails.push(`keys.* not shortened: ${out.keys.a} ${out.keys.b}`);
|
||||
if (out.joint_sinks.a.key !== 'aaaaaaaa…') fails.push(`joint_sinks.a.key not shortened: ${out.joint_sinks.a.key}`);
|
||||
if (out.joint_sinks.b.key !== '0xbbbbbbbb…') fails.push(`a 0x key kept its prefix wrong: ${out.joint_sinks.b.key}`);
|
||||
if (out.samples[0].a.key !== 'aaaaaaaa…') fails.push('a key inside an array element was not shortened');
|
||||
if (out.vote_key !== 'eeeeeeee…' || out.signingKey !== 'ffffffff…' || out.a_token !== '11111111…') fails.push('a name ending in key/Key/token was not shortened');
|
||||
if (out.joint_sinks.a.hash !== hex('c') || out.digest !== hex('2') || out.exec_restart_hash !== hex('3')) fails.push('a hash, digest or *_hash field was changed');
|
||||
if (out.id32 !== 'a'.repeat(32) || out.joint_sinks.a.blocks !== 1) fails.push('a non-key value was changed');
|
||||
let text;
|
||||
try { text = summaryText(summary); } catch (e) { fails.push(`summaryText refused a redactable summary: ${e.message}`); }
|
||||
if (text && /[0-9a-f]{64}/.test(text.replace(new RegExp(`"(hash|digest|exec_restart_hash)": "(0x)?[0-9a-f]{64}"`, 'g'), ''))) fails.push('a raw 64-hex key survived in the written text');
|
||||
if (text && rawKeyLines(text).length) fails.push('the written text would fail the no-secrets rule');
|
||||
// the writer's own check: a raw key line in the text fails, under each shape the gate catches
|
||||
for (const t of [`{\n "key": "${hex('9')}"\n}`, `KEY=0x${hex('8')}`, `x-igneum-key: ${hex('7')}`, `const signingKey = "${hex('6')}";`]) {
|
||||
if (!rawKeyLines(t).length) fails.push(`rawKeyLines missed: ${t.slice(0, 30)}`);
|
||||
}
|
||||
if (rawKeyLines(`{\n "hash": "${hex('5')}",\n "id": "${'4'.repeat(32)}"\n}`).length) fails.push('rawKeyLines flagged a hash or a 32-hex id');
|
||||
// a key hiding in free text under a field the redactor does not know: summaryText refuses it, and names the line
|
||||
let refused = false;
|
||||
try { summaryText({ ok: true, refusal_example: `Fork choice: refused block by key: ${hex('9')}` }); } catch (e) { refused = /line 3/.test(e.message); }
|
||||
if (!refused) fails.push('summaryText did not refuse (or did not name the line of) a raw key inside a free-text field');
|
||||
// writeSummary writes the redacted text, and --check on a raw file fails
|
||||
const dir = mkdtempSync(join(tmpdir(), 'redact-keys-'));
|
||||
try {
|
||||
const good = join(dir, 'nested', 'good.json'); writeSummary(good, summary);
|
||||
if (!existsSync(good) || rawKeyLines(readFileSync(good, 'utf8')).length) fails.push('writeSummary wrote a raw key or nothing');
|
||||
if (JSON.parse(readFileSync(good, 'utf8')).keys.a !== 'aaaaaaaa…') fails.push('the written file does not parse back to the shortened key');
|
||||
const bad = join(dir, 'bad.json'); writeFileSync(bad, JSON.stringify({ keys: { a: hex('a') }, sink: { key: hex('a') } }, null, 2));
|
||||
if (checkFiles([good]).length) fails.push('--check flagged the redacted file');
|
||||
const hits = checkFiles([bad]);
|
||||
if (hits.length !== 1 || !/bad\.json:\d+/.test(hits[0])) fails.push(`--check did not name the raw file and line: ${hits.join(' | ')}`);
|
||||
if (hits.some((h) => /[0-9a-f]{64}/.test(h))) fails.push('--check printed a 64-hex value');
|
||||
} finally { rmSync(dir, { recursive: true, force: true }); }
|
||||
if (fails.length) { for (const f of fails) console.error(`self-test failed: ${f}`); process.exit(1); }
|
||||
console.log('self-test passed: key-shaped fields shorten to 8 hex and an ellipsis, hashes stay whole, the input is not mutated, a raw key in the text fails the writer\'s own check with its line, --check names a raw file without printing the key');
|
||||
}
|
||||
|
||||
export function checkFiles(files) {
|
||||
const hits = [];
|
||||
for (const f of files) {
|
||||
if (!existsSync(f)) { hits.push(`${f}: missing`); continue; }
|
||||
for (const n of rawKeyLines(readFileSync(f, 'utf8'))) hits.push(`${f}:${n}: a raw 64-hex key under a key-shaped field`);
|
||||
}
|
||||
return hits;
|
||||
}
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
const args = process.argv.slice(2);
|
||||
if (args[0] === '--self-test') selfTest();
|
||||
else if (args[0] === '--check') {
|
||||
const hits = checkFiles(args.slice(1));
|
||||
if (hits.length) { for (const h of hits) console.error(h); process.exit(1); }
|
||||
console.log(`redact-keys: ${args.length - 1} file(s), no raw key`);
|
||||
} else { console.error('usage: redact-keys.mjs --self-test | --check <file>...'); process.exit(2); }
|
||||
}
|
||||
|
|
@ -5,10 +5,11 @@
|
|||
#
|
||||
# tools/ci/pre-push.sh # the full gate over this working tree (any worktree; reads the tree, writes nothing in it)
|
||||
# tools/ci/pre-push.sh --ci # the same, with the site built in place (a CI checkout is disposable)
|
||||
# tools/ci/pre-push.sh --hook # from .git/hooks/pre-push: full gate for master and release-*, the two structural
|
||||
# # checks (conflict markers, Windows paths) for every other ref
|
||||
# tools/ci/pre-push.sh --self-test # the runner fires on a known failure, passes a known success, and the hook picks the
|
||||
# # right gate from the ref lines
|
||||
# tools/ci/pre-push.sh --hook # from .git/hooks/pre-push: full gate for master and release-*; the light gate for every
|
||||
# # other ref: the two structural checks (conflict markers, Windows paths) and the two
|
||||
# # never-push classes (the no-secrets check, the identity grep), about 20 s on the Mac
|
||||
# tools/ci/pre-push.sh --self-test # the runner fires on a known failure, passes a known success, the hook picks the
|
||||
# # right gate from the ref lines, and the light gate carries the never-push classes
|
||||
# tools/ci/pre-push.sh --list # the check names, one per line
|
||||
#
|
||||
# What is NOT here, on purpose: the three compile-or-compute jobs (igneum-pow tests, igneum-census, the simulators) and
|
||||
|
|
@ -51,12 +52,20 @@ structural_checks() {
|
|||
run "every tracked path is valid on Windows (colon, trailing dot, reserved names, length)" bash tools/ci/windows-paths-check.sh
|
||||
}
|
||||
|
||||
never_push_checks() {
|
||||
# The two never-push classes, on EVERY ref (7 October 2026: three gate summaries on ca3-v4-node carried a 64-hex key
|
||||
# through eight red CI runs in 80 minutes; the feature-branch hook ran only the structural checks, so no lane saw it).
|
||||
# A secret or an identity leak must not reach the remote on any branch; together about 20 s on the Mac.
|
||||
run "identity grep of the public export list and the served site" bash tools/ci/identity-check.sh
|
||||
run "no secret file names and no 64-hex secrets in the tree" bash -c 'bash tools/ci/no-secrets-check.sh --self-test && bash tools/ci/no-secrets-check.sh'
|
||||
}
|
||||
|
||||
tree_checks() {
|
||||
run "site build (in a temporary copy locally, in place in CI)" site_build
|
||||
run "internal link check of site/*.html" node tools/ci/link-check.mjs
|
||||
run "every served page carries the full header (six items, Download, burger)" node tools/ci/site-nav-check.mjs
|
||||
run "ledger sentences present verbatim on their public pages" node tools/ci/ledger-text-check.mjs
|
||||
run "identity grep of the public export list and the served site" bash tools/ci/identity-check.sh
|
||||
never_push_checks
|
||||
run "shell inside .github/workflows parses (bash -n, the PowerShell 5.1 rule)" node tools/ci/check-workflow-shell.mjs
|
||||
run "PowerShell drive-reference check (\$name: in a double-quoted string)" bash tools/ci/ps-drive-ref-check.sh
|
||||
run "copied sources are re-stamped before a build" bash tools/ci/copied-sources-check.sh
|
||||
|
|
@ -90,10 +99,10 @@ tree_checks() {
|
|||
run "ship tool self-test" node tools/ship-app.mjs --self-test
|
||||
run "relay unit tests" node --test relay/test/parse.test.mjs relay/test/auth.test.mjs relay/test/wake.test.mjs relay/test/ember.test.mjs
|
||||
run "miner app notice strip and update card tests" node --test app/igneum-app/ui/notices.test.mjs app/igneum-app/ui/update-card.test.mjs app/igneum-app/ui/view.test.mjs app/igneum-app/ui/tune-line.test.mjs
|
||||
run "no secret file names and no 64-hex secrets in the tree" bash -c 'bash tools/ci/no-secrets-check.sh --self-test && bash tools/ci/no-secrets-check.sh'
|
||||
run "launch gates: every row with its check, the handoff text clean (self-test, then the tree)" bash -c 'node tools/ci/launch-gates-check.mjs --self-test && node tools/ci/launch-gates-check.mjs'
|
||||
run "income per tier: the public table equals its inputs, the schedule arithmetic" bash -c 'node tools/launch/income-tiers.mjs --check && node --test tools/launch/income-tiers.test.mjs'
|
||||
run "hash-origin report: a known-finished day and a known-failed day" node --test tools/observer/hash-origin.test.mjs
|
||||
run "harness summaries never carry a raw 64-hex key (the writer's own redaction and check)" node infra/fast-time/lib/redact-keys.mjs --self-test
|
||||
}
|
||||
|
||||
gated_refs() {
|
||||
|
|
@ -126,7 +135,12 @@ case "$MODE" in
|
|||
[ "$(printf 'refs/heads/x 1 refs/heads/release-0.3.15 2\n' | gated_refs)" = full ] || { echo "self-test failed: a push to release-0.3.15 did not select the full gate"; fails=1; }
|
||||
[ "$(printf 'refs/heads/x 1 refs/heads/x 2\n' | gated_refs)" = light ] || { echo "self-test failed: a push to a feature branch selected the full gate"; fails=1; }
|
||||
[ "$(printf '' | gated_refs)" = light ] || { echo "self-test failed: a push with no refs selected the full gate"; fails=1; }
|
||||
[ "$fails" = 0 ] && echo "self-test passed: a failing check is RED and fails the gate, a passing one is ok; master and release-* select the full gate, other refs the light one"
|
||||
# the light gate carries the two never-push classes beside the structural checks, and the full gate runs them too
|
||||
declare -f never_push_checks | grep -q 'tools/ci/no-secrets-check.sh' || { echo "self-test failed: the never-push checks do not run the no-secrets check"; fails=1; }
|
||||
declare -f never_push_checks | grep -q 'tools/ci/identity-check.sh' || { echo "self-test failed: the never-push checks do not run the identity grep"; fails=1; }
|
||||
grep -qE '^\s+structural_checks; never_push_checks; finish "feature branch"' "$0" || { echo "self-test failed: the hook's light gate does not run the never-push checks"; fails=1; }
|
||||
declare -f tree_checks | grep -q 'never_push_checks' || { echo "self-test failed: the full gate does not run the never-push checks"; fails=1; }
|
||||
[ "$fails" = 0 ] && echo "self-test passed: a failing check is RED and fails the gate, a passing one is ok; master and release-* select the full gate, other refs the light one (structural checks, no-secrets, identity grep)"
|
||||
exit $fails ;;
|
||||
list)
|
||||
grep -E '^\s+run "' "$0" | sed -E 's/^\s+run "([^"]+)".*/\1/' ;;
|
||||
|
|
@ -136,8 +150,8 @@ case "$MODE" in
|
|||
echo "pre-push gate: a push to master or release-*, the full gate (the same checks CI runs):"
|
||||
structural_checks; tree_checks; finish "push to master or release-*"
|
||||
else
|
||||
echo "pre-push gate: a feature branch, the two structural checks:"
|
||||
structural_checks; finish "feature branch"
|
||||
echo "pre-push gate: a feature branch, the light gate (the two structural checks, the no-secrets check, the identity grep):"
|
||||
structural_checks; never_push_checks; finish "feature branch"
|
||||
fi ;;
|
||||
ci|local)
|
||||
[ "$MODE" = ci ] && echo "pre-push gate in CI (the same script as the local hook):" || echo "pre-push gate over this working tree:"
|
||||
|
|
|
|||
Loading…
Reference in a new issue