igneum/tools/ci/no-secrets-check.sh
igneum-labs addeb660fd Key custody: inventory, encrypted backup and restore, no-secrets CI check
docs/security/keys.md: every key the project depends on (the folder, the gh
keyring, the Vercel env of three projects, the GitHub secrets) with where it
lives, what it unlocks, the blast radius lost and leaked, who rotates it and
the rotation status, written from the files and the scripts that read them.
No value, no private fingerprint. Section 4: the second OTA signing key kept
offline, the app change (a key list plus revocation in the manifest), 0.3.9
as the carrier, and the emergency path if the one key leaks today (a manifest
signed with a new key is useless to 0.3.x apps; the mitigation in order).

tools/keys/backup.sh: ~/Desktop/igneum-keys-<date>.dmg, AES-256, hdiutil's
own prompt (never argv, history or a file), the folder minus build-slots,
dlsite-dir and pytools/, plus a README; attached read-only, every file
compared by sha256, listed, detached. --dry-run lists. restore.sh: --check
compares the image against the live folder without printing values, --to
copies back with 0600/0644 and 0700. test-backup.sh: the end-to-end test on a
scratch folder with a throwaway passphrase, 8 steps, passed.

tools/ci/no-secrets-check.sh, in ci.yml: no tracked file named like a key of
~/.config/igneum, no 64-hex value assigned to a token/key/secret name outside
tests and the allowlist (the OTA public key, the published Hardhat and Anvil
accounts); a --self-test fires on a known-bad tree first. 776 files, 0 hits.

Also: ~/.config/igneum, vercel/ and txgen/ are 0700 now (were 0755).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-05 17:01:01 +00:00

97 lines
6.6 KiB
Bash
Executable file

#!/usr/bin/env bash
# No secret in the tree, for CI (ci.yml) and for a pre-push look on the Mac.
#
# Two checks over the tracked files (git ls-files; the working tree when not in a git checkout):
# 1. file NAMES: nothing tracked may be named like a file of ~/.config/igneum (ota-signing-key, relay-token,
# relay-key, dl-token, log-intake-key, hetzner-token, desec-token, dev-fee-*.json, wallets.json, vercel auth.json,
# their .next and .old-<date> variants), nor igneum-app.json (the packaged config carries the intake key),
# igneum-log-key.txt, igneum-relay-clients.zip (the relay token and key baked in), *.env, .env*, a bare `env`.
# 2. file CONTENTS: a 64-hex string (optionally 0x-prefixed) assigned to a name ending in token, key, secret,
# password or passphrase (`KEY = "<64 hex>"`, `token: <64 hex>`, `x-igneum-key: <64 hex>`), case-insensitive,
# in non-test files. Skipped: files with `test` in the name, proving/fixtures/, infra/cloud-devnet/results/,
# *.log, vendor/, node_modules/, target/. Allowlisted by path (ALLOW below, each with its reason): the OTA
# public key in the app (public by design) and the published Hardhat/Anvil developer accounts the devnet tools
# use (public test vectors; never fund them on a real network).
#
# tools/ci/no-secrets-check.sh # exit 1 on any hit, hits printed with the hex masked
# tools/ci/no-secrets-check.sh --self-test # the name rule and the content rule must fire on a known-bad case and
# # stay quiet on a known-good one (the gate rule of CLAUDE.md)
set -euo pipefail
HERE="$(cd "$(dirname "$0")" && pwd)"
REPO="$(cd "$HERE/../.." && pwd)"
# 1. forbidden basenames (grep -E, anchored on the basename)
NAME_RULES='^(ota-signing-key|relay-token|relay-key|dl-token|log-intake-key|hetzner-token|desec-token)(\.next|\.old-[0-9-]+)?$|^(dev-fee-devnet|dev-fee-release|wallets|auth|igneum-app)\.json$|^igneum-log-key\.txt$|^igneum-relay-clients\.zip$|\.env$|^\.env|^env$|\.pem$|^id_(rsa|ed25519)$'
# the public counterpart is fine
NAME_ALLOW='^ota-signing-key\.pub$'
# 2. a 64-hex value assigned to a secret-looking name
HEX='(0x)?[0-9a-fA-F]{64}([^0-9a-fA-F]|$)'
CONTENT_RULE="(token|key|secret|password|passphrase)[\"']?[[:space:]]*[:=][[:space:]]*[\"']?${HEX}"
# paths that may carry such a line, with the reason
ALLOW=(
'app/igneum-app/src/manifest.rs' # OTA_PUBLIC_KEY_HEX: the public half of the signing key, compiled into every app
'site/api/faucet.test.mjs' # Anvil developer account 0, a published test vector
'tools/exec-attacks/lib/common.mjs' # Hardhat/Anvil developer accounts 1, 4, 5, 15, 16: published, devnet 4463 only
'tools/evm-smoke/smoke.mjs' # the same published accounts
)
SKIP_PATH='(^|/)(vendor|node_modules|target|tests?|proving/fixtures|infra/cloud-devnet/results)(/|$)|\.log$'
is_test_name() { # a basename with "test" in it (test-publish-jobs.sh, faucet.test.mjs, notices.test.mjs), not "testnet"
local b="${1##*/}"; b="${b//testnet/}"; case "$b" in *test*) return 0 ;; *) return 1 ;; esac
}
list_files() {
if git -C "$REPO" rev-parse --is-inside-work-tree >/dev/null 2>&1; then git -C "$REPO" ls-files; else (cd "$REPO" && find . -type f | sed 's#^\./##'); fi
}
run_checks() { # $1 = root, reads the file list on stdin; prints hits, returns 1 on any
local root="$1" bad=0 f base
local -a content_files=()
while IFS= read -r f; do
[ -n "$f" ] || continue
base="${f##*/}"
if printf '%s' "$base" | grep -qE "$NAME_RULES" && ! printf '%s' "$base" | grep -qE "$NAME_ALLOW"; then
echo "secret file name tracked: $f"; bad=1
fi
if ! printf '%s' "$f" | grep -qE "$SKIP_PATH" && ! is_test_name "$f"; then
local allowed=0 a; for a in "${ALLOW[@]}"; do [ "$f" = "$a" ] && allowed=1; done
[ $allowed -eq 0 ] && [ -f "$root/$f" ] && content_files+=("$f")
fi
done
if [ ${#content_files[@]} -gt 0 ]; then
local hits
hits="$(cd "$root" && printf '%s\n' "${content_files[@]}" | tr '\n' '\0' | xargs -0 grep -nIiE "$CONTENT_RULE" 2>/dev/null | sed -E 's/(0x)?[0-9a-fA-F]{64}/<64-hex>/g' | cut -c1-160 || true)"
if [ -n "$hits" ]; then echo "a 64-hex value next to token/key/secret:"; printf '%s\n' "$hits" | sed 's/^/ /'; bad=1; fi
fi
echo "checked ${#content_files[@]} files for contents"
return $bad
}
if [ "${1:-}" = "--self-test" ]; then
T="$(mktemp -d)"; trap 'rm -rf "$T"' EXIT
mkdir -p "$T/good/src" "$T/bad/src" "$T/bad/cfg"
# a good tree: a hash next to an unrelated word, a 32-hex id, the public key in the allowlisted path, a test file
printf 'sha256 = "%s"\nlet id = "%s";\n' "$(printf 'a%.0s' $(seq 64))" "$(printf 'b%.0s' $(seq 32))" > "$T/good/src/ok.rs"
mkdir -p "$T/good/app/igneum-app/src"; printf 'pub const OTA_PUBLIC_KEY_HEX: &str = "%s";\n' "$(printf 'c%.0s' $(seq 64))" > "$T/good/app/igneum-app/src/manifest.rs"
printf 'const KEY = "0x%s";\n' "$(printf 'd%.0s' $(seq 64))" > "$T/good/src/vectors.test.mjs"
printf 'x\n' > "$T/good/src/ota-signing-key.pub"
# a bad tree: a tracked key file, and three content shapes
printf 'x\n' > "$T/bad/cfg/ota-signing-key"; printf 'x\n' > "$T/bad/cfg/dl-token.old-2026-10-05"; printf 'x\n' > "$T/bad/cfg/igneum-app.json"
printf 'FAUCET_KEY=0x%s\n' "$(printf 'e%.0s' $(seq 64))" > "$T/bad/src/a.sh"
printf 'const signingKey = "%s";\n' "$(printf 'f%.0s' $(seq 64))" > "$T/bad/src/b.mjs"
printf 'curl -H "x-igneum-key: %s"\n' "$(printf '0%.0s' $(seq 64))" > "$T/bad/src/c.md"
good_out="$( (cd "$T/good" && find . -type f | sed 's#^\./##') | run_checks "$T/good" 2>&1)" && good_rc=0 || good_rc=$?
bad_out="$( (cd "$T/bad" && find . -type f | sed 's#^\./##') | run_checks "$T/bad" 2>&1)" && bad_rc=0 || bad_rc=$?
echo "self-test good tree: rc $good_rc"; printf '%s\n' "$good_out" | sed 's/^/ /'
echo "self-test bad tree: rc $bad_rc"; printf '%s\n' "$bad_out" | sed 's/^/ /'
[ $good_rc -eq 0 ] || { echo "SELF-TEST FAILED: the good tree was flagged"; exit 1; }
[ $bad_rc -ne 0 ] || { echo "SELF-TEST FAILED: the bad tree passed"; exit 1; }
for want in 'cfg/ota-signing-key' 'cfg/dl-token.old-2026-10-05' 'cfg/igneum-app.json' 'src/a.sh' 'src/b.mjs' 'src/c.md'; do
printf '%s' "$bad_out" | grep -q "$want" || { echo "SELF-TEST FAILED: $want not reported"; exit 1; }
done
printf '%s' "$bad_out" | grep -qE '[0-9a-f]{64}' && { echo "SELF-TEST FAILED: a hex value was printed"; exit 1; }
echo "self-test passed: the name rule and the content rule fire on the bad tree and not on the good one"
exit 0
fi
if list_files | run_checks "$REPO"; then echo "no-secrets: 0 hits"; else echo "no-secrets: HITS (above)"; exit 1; fi