From 5cfdf208bf8e3869902fc34c129479a9b5b1d3c7 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Sun, 20 Sep 2026 23:15:53 +0000 Subject: [PATCH] fix(ci): detect the Hypatia caller job structurally, not by last bare key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first cut of propagate-hypatia-caller-id.sh identified the caller as "the last bare `key:` line above the reusable `uses:` line". The estate sweep hit two shapes that breaks on: * a job-level `permissions:` block (three live wrappers) — the heuristic reported the caller id as `permissions` and rewrote the permissions key itself, which would have produced an invalid wrapper; * a job key with a trailing comment — the heuristic walked back past it and resolved to `jobs:`. Detect the caller structurally instead: the nearest job key — a line indented exactly two spaces whose value begins with `key:` (trailing comment allowed) — above the reusable `uses:` line, and rewrite that line by number. The rewrite is now self-asserting: it is accepted only when it is a one-line in-place replacement (same line count, exactly one differing line, that line the key line, now canonical). Trailing-newline state is preserved so a wrapper without a final newline does not gain one. Anything else is refused and the file is left byte-identical. Fixtures added for all three shapes; 27/27 green. Refs: hyperpolymath/tropical-types#17 (the defect class this sweep removes). --- scripts/propagate-hypatia-caller-id.sh | 75 ++++++++++++++--- .../tests/propagate-hypatia-caller-id-test.sh | 84 +++++++++++++++++++ 2 files changed, 149 insertions(+), 10 deletions(-) diff --git a/scripts/propagate-hypatia-caller-id.sh b/scripts/propagate-hypatia-caller-id.sh index 486943d5d..67354ee84 100755 --- a/scripts/propagate-hypatia-caller-id.sh +++ b/scripts/propagate-hypatia-caller-id.sh @@ -136,11 +136,28 @@ process_repo() { # $1 = repository directory return 0 fi - # The caller job is the one whose `uses:` names the estate's scan reusable. - caller="$(awk ' - /^[[:space:]]*[A-Za-z0-9_.-]+:[[:space:]]*$/ { key=$1; sub(/:$/, "", key) } - /^[[:space:]]+uses:.*hypatia-scan-reusable\.ya?ml@/ { print key; exit } + # The caller job is the job whose `uses:` names the estate's scan reusable. + # Detect it structurally: the nearest job key — a line indented exactly two + # spaces whose value begins with `key:` (an optional trailing comment is + # allowed) — above the reusable `uses:` line. + # + # Why not "the last bare `key:` line above the call": that heuristic mistook a + # job-level `permissions:` block for the job key (three live repositories have + # one) and rewrote the permissions key itself. It also picked up `jobs:` when + # the job key carried a trailing comment. Both shapes now have fixtures. + uses_line="$(awk ' + /^[[:space:]]*uses:.*hypatia-scan-reusable\.ya?ml@/ { print NR; exit } ' "$wf")" + caller=""; caller_key_line="" + if [ -n "$uses_line" ]; then + caller_info="$(awk -v upto="$uses_line" ' + NR < upto && /^ [A-Za-z0-9_.-]+:[[:space:]]*(#.*)?$/ { k=$1; sub(/:$/, "", k); ln=NR } + END { if (ln) print ln, k } + ' "$wf")" + caller_key_line="${caller_info%% *}" + caller="${caller_info##* }" + [ "$caller_info" = "$caller_key_line" ] && { caller=""; caller_key_line=""; } + fi if [ -z "$caller" ]; then printf '%s\t%s\t-\tunchanged-shape\n' "$(basename "$repo")" ".github/workflows/hypatia-scan.yml" @@ -161,14 +178,52 @@ process_repo() { # $1 = repository directory fi if [ "$verdict" = "stage" ] && [ "$MODE_FIX" = 1 ]; then - # Rewrite only the job key line: same indentation, same position. + # Rewrite only the detected job key line, in place: same indentation, same + # position, any trailing comment preserved. Addressed by line number, so a + # nested `permissions:` key, a comment, or any other `scan:`-shaped line + # elsewhere in the file cannot be touched by accident. local tmp; tmp="$(mktemp)" - awk -v from="$caller" -v to="$CANONICAL" ' - BEGIN { done = 0 } - !done && $0 ~ "^[[:space:]]*" from ":[[:space:]]*$" { sub(from ":", to ":"); done = 1 } + awk -v n="$caller_key_line" -v to="$CANONICAL" ' + NR == n { + match($0, /^[[:space:]]*/) + ind = substr($0, 1, RLENGTH) + rest = substr($0, RLENGTH + 1) + sub(/^[A-Za-z0-9_.-]+:/, to ":", rest) + $0 = ind rest + } { print } - ' "$wf" > "$tmp" && mv "$tmp" "$wf" - verdict="staged" + ' "$wf" > "$tmp" + # Preserve the file's trailing-newline state: a wrapper that ends without a + # newline must not gain one, so the diff stays a single-line replacement. + if [ -n "$(tail -c 1 "$wf" || true)" ]; then + printf '%s' "$(cat "$tmp")" > "$tmp.trimmed" && mv "$tmp.trimmed" "$tmp" + fi + # Accept only a one-line replacement in place: same number of lines, exactly + # one differing line, that line is the detected key line, and it now carries + # the canonical id. Anything else is refused and the file is left untouched. + report="$(awk -v n="$caller_key_line" -v want="$CANONICAL" ' + NR == FNR { old[FNR] = $0; nold = FNR; next } + { new[FNR] = $0; nnew = FNR } + END { + if (nold != nnew) { printf "line count %d -> %d", nold, nnew; exit } + changed = 0; elsewhere = 0 + for (i = 1; i <= nnew; i++) { + if (old[i] != new[i]) { changed++; if (i != n) elsewhere = 1 } + } + if (changed != 1) { printf "%d lines changed", changed; exit } + if (elsewhere) { printf "the changed line is not the key line"; exit } + if (new[n] !~ ("^ " want ":")) { printf "the key line is not canonical"; exit } + printf "ok" + } + ' "$wf" "$tmp")" + if [ "$report" = "ok" ]; then + mv "$tmp" "$wf" + verdict="staged" + else + rm -f "$tmp" + verdict="unchanged-shape" + note "$(basename "$repo"): rewrite refused — $report" + fi fi printf '%s\t%s\t%s\t%s\n' "$(basename "$repo")" ".github/workflows/hypatia-scan.yml" "$caller" "$verdict" diff --git a/scripts/tests/propagate-hypatia-caller-id-test.sh b/scripts/tests/propagate-hypatia-caller-id-test.sh index f22067d70..52eed82db 100755 --- a/scripts/tests/propagate-hypatia-caller-id-test.sh +++ b/scripts/tests/propagate-hypatia-caller-id-test.sh @@ -100,6 +100,90 @@ check "fix-keeps-the-pin" "1" "$(printf '%s\n' "$after_safe" | grep -c '84355587 check "fix-keeps-secrets-inherit" "1" "$(printf '%s\n' "$after_safe" | grep -c '^[[:space:]]*secrets: inherit$')" check "fix-is-idempotent" "canonical" "$(HYPATIA_REQUIRED_JSON="$work/safe.json" bash "$SCRIPT" "$work/fleet" 2>/dev/null | awk -F'\t' '$1=="bbb-needs-rename"{print $4}')" +# --- shapes seen in the live estate, which the first detection missed ------- + +# ggg: a job-level `permissions:` block inside the caller job. The old +# "last bare `key:` line" heuristic reported the caller as `permissions` +# and rewrote the permissions key itself. +mkdir -p "$work/fleet/ggg-job-permissions/.github/workflows" +cat > "$work/fleet/ggg-job-permissions/.github/workflows/hypatia-scan.yml" <<'EOF' +name: Hypatia Security Scan +on: + push: + pull_request: + +permissions: + actions: read + contents: read + +jobs: + scan: + permissions: + actions: read + contents: read + security-events: write + uses: hyperpolymath/standards/.github/workflows/hypatia-scan-reusable.yml@210f14e753c80064ec1bcae72f1d654dd9b0e687 + secrets: inherit +EOF + +# hhh: the job key carries a trailing comment. The old heuristic skipped it (the +# line is not a "bare" key) and picked up `jobs:` instead. +mkdir -p "$work/fleet/hhh-commented-key/.github/workflows" +cat > "$work/fleet/hhh-commented-key/.github/workflows/hypatia-scan.yml" <<'EOF' +name: Hypatia Security Scan +on: [push] + +jobs: + scan: # estate scan gate + uses: hyperpolymath/standards/.github/workflows/hypatia-scan-reusable.yml@210f14e753c80064ec1bcae72f1d654dd9b0e687 + secrets: inherit +EOF + +# iii: no trailing newline on the last line. The rewrite must still be a +# single-line replacement (a `\ No newline at end of file` marker is not +# a changed line). +mkdir -p "$work/fleet/iii-no-newline/.github/workflows" +printf '%s' 'name: Hypatia Security Scan +on: [push] + +jobs: + scan: + uses: hyperpolymath/standards/.github/workflows/hypatia-scan-reusable.yml@8f2ee50841e216cd8c192eeb68953118190f105c' > "$work/fleet/iii-no-newline/.github/workflows/hypatia-scan.yml" + +job_key() { # the job key governing the reusable call, detected structurally + local n; n="$(awk '/^[[:space:]]*uses:.*hypatia-scan-reusable\.ya?ml@/ { print NR; exit }' "$1")" + awk -v n="$n" 'NR]'; } + +before_ggg="$(cat "$work/fleet/ggg-job-permissions/.github/workflows/hypatia-scan.yml")" +before_hhh="$(cat "$work/fleet/hhh-commented-key/.github/workflows/hypatia-scan.yml")" +before_iii="$(cat "$work/fleet/iii-no-newline/.github/workflows/hypatia-scan.yml")" + +out="$(HYPATIA_REQUIRED_JSON="$work/safe.json" bash "$SCRIPT" --fix "$work/fleet" 2>/dev/null)" +check "job-level-permissions: the caller is the job key, not permissions" "scan" \ + "$(printf '%s\n' "$out" | awk -F'\t' '$1=="ggg-job-permissions"{print $3}')" +check "job-level-permissions: staged" "staged" \ + "$(printf '%s\n' "$out" | awk -F'\t' '$1=="ggg-job-permissions"{print $4}')" +check "job-level-permissions: job key renamed" "hypatia" \ + "$(job_key "$work/fleet/ggg-job-permissions/.github/workflows/hypatia-scan.yml")" +check "job-level-permissions: nested permissions block untouched" "1" \ + "$(grep -c '^ permissions:$' "$work/fleet/ggg-job-permissions/.github/workflows/hypatia-scan.yml")" +check "job-level-permissions: one-line replacement" "2" \ + "$(changed_lines "$before_ggg" "$(cat "$work/fleet/ggg-job-permissions/.github/workflows/hypatia-scan.yml")")" +check "commented-key: job key renamed" "hypatia" \ + "$(job_key "$work/fleet/hhh-commented-key/.github/workflows/hypatia-scan.yml")" +check "commented-key: trailing comment preserved" "1" \ + "$(grep -c '^ hypatia: # estate scan gate$' "$work/fleet/hhh-commented-key/.github/workflows/hypatia-scan.yml")" +check "commented-key: one-line replacement" "2" \ + "$(changed_lines "$before_hhh" "$(cat "$work/fleet/hhh-commented-key/.github/workflows/hypatia-scan.yml")")" +check "no-newline: job key renamed" "hypatia" \ + "$(job_key "$work/fleet/iii-no-newline/.github/workflows/hypatia-scan.yml")" +check "no-newline: one-line replacement" "2" \ + "$(changed_lines "$before_iii" "$(cat "$work/fleet/iii-no-newline/.github/workflows/hypatia-scan.yml")")" +check "fix-is-idempotent-for-the-new-shapes" "canonical" \ + "$(HYPATIA_REQUIRED_JSON="$work/safe.json" bash "$SCRIPT" "$work/fleet" 2>/dev/null | awk -F'\t' '$1=="ggg-job-permissions"{print $4}')" + # Unreadable requirements: never rename on an unknown requirement set. wrapper "$work/fleet/fff-unverified" scan out="$(bash "$SCRIPT" --no-network "$work/fleet" 2>/dev/null)"