Skip to content

Close the remaining RNG guard bypasses and self-test the guard - #108

Merged
kwsantiago merged 3 commits into
mainfrom
harden-rng-guard
Aug 2, 2026
Merged

Close the remaining RNG guard bypasses and self-test the guard#108
kwsantiago merged 3 commits into
mainfrom
harden-rng-guard

Conversation

@kwsantiago

@kwsantiago kwsantiago commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Summary

Four of the seven bypasses recorded in keep-node-2sul still worked against the current script. This closes them and adds scripts/test-rng-hygiene.sh, which asserts every known bypass still fails.

What was still bypassable

Verified by reproduction, each against a positive control that does fail the guard:

bypass before
let src = "/dev/" + "urandom" passed
d=urandom; head -c 32 /dev/$d passed
the same read in scripts/x.bash unscanned
the same read in an extensionless file unscanned

The other three from that issue were already fixed by #104: quote-aware comment splitting, a blank line ending the marker block, and rule 3 pinning the draw structurally rather than grepping for "refusing to format". The issue was written before that landed, so this PR only addresses what actually reproduced.

Fixes

File selection. The extension gate was the cheapest bypass: rename the file and the rules never see it. list_sources now also takes *.bash, and sweeps every tracked file whose first line is a sh, bash or python shebang, so an extensionless hook is scanned too.

Concatenation. "/dev/" + "urandom" and the ++ spelling read the same file at runtime while matching no line regex. Rules now match against a normalised copy with quote-adjacent concatenation collapsed. Findings are still reported against the raw line, so output shows what the author wrote.

Runtime device paths. Indirection through a variable defeats any literal match, so there is a new rule: a /dev/ path assembled from a variable is a finding unless marked. A dynamic device path is rare here, and the marker documents it when it is deliberate.

The self-test

The durable half. This guard has now had seven bypasses across two rounds, and a scanner that quietly stops scanning reports a clean tree exactly like a clean tree does. scripts/test-rng-hygiene.sh stages probe files into a throwaway GIT_INDEX_FILE, so it never touches the working tree or staged changes, and asserts 11 rejections plus 2 acceptances.

It leads with a positive control: if a plain /dev/urandom ever stops failing, the whole file is meaningless and it says so.

Test plan

  • All four reproduced bypasses now caught; clean tree still passes
  • Self-test passes on the fixed guard: 13/13
  • Regression control: run against the pre-fix guard it reports 7 BYPASS lines and exits 1. It detects the regression rather than passing regardless
  • Real git index verified untouched after a full self-test run
  • bash -n clean on both scripts, ci.yml parses, nix fmt reports 0 changed
  • CI

Not claimed: this makes the guard harder to defeat, it does not make it undefeatable. A line-oriented scanner can always be outrun by enough indirection. What changed is that the cheap routes are closed and the known ones are now asserted on every run.

Summary by CodeRabbit

  • Tests

    • Added automated self-tests for RNG hygiene checks, including bypass patterns, approved usage, error reporting, and false-positive detection.
  • Chores

    • Integrated the RNG hygiene self-tests into CI.
    • Expanded scanning to cover additional tracked script files and dynamically constructed device paths.
    • Excluded the hygiene checker and its self-test from their own scans.

@coderabbitai

coderabbitai Bot commented Aug 2, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@kwsantiago, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 46 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 919eb9fc-3e98-491c-b890-6ce0885fc6f9

📥 Commits

Reviewing files that changed from the base of the PR and between ddef424 and 5af7809.

📒 Files selected for processing (1)
  • scripts/test-rng-hygiene.sh

Walkthrough

The RNG hygiene guard now scans more script types, detects constructed device paths, and rejects dynamic /dev paths. A self-test harness covers bypass and sanctioned patterns. CI runs the self-test before the existing fallback scan.

Changes

RNG hygiene validation

Layer / File(s) Summary
Expand RNG hygiene detection
scripts/check-rng-hygiene.sh
The guard scans tracked shell and Python files identified by extensions or shebangs. It detects adjacent string concatenation and rejects dynamic /dev paths without an opt-out marker.
Exercise the guard in CI
scripts/test-rng-hygiene.sh, .github/workflows/ci.yml
The self-test stages temporary probes, validates rejected and allowed patterns, cleans up temporary state, and runs before the existing fallback scan.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant CI workflow
  participant test-rng-hygiene.sh
  participant Git temporary index
  participant check-rng-hygiene.sh
  CI workflow->>test-rng-hygiene.sh: run self-test
  test-rng-hygiene.sh->>Git temporary index: stage probe file
  test-rng-hygiene.sh->>check-rng-hygiene.sh: scan staged probe
  check-rng-hygiene.sh-->>test-rng-hygiene.sh: report finding or success
  test-rng-hygiene.sh-->>CI workflow: return test status
Loading

Possibly related PRs

Suggested labels: full-ci

Poem

A rabbit checks the random stream,
With probes arranged in rows.
The guard spots hidden device paths,
And CI confirms what shows.
Hop, hop—clean scripts pass!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 66.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main changes: closing RNG guard bypasses and adding a guard self-test.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch harden-rng-guard

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@kwsantiago

Copy link
Copy Markdown
Contributor Author

CI failed the self-test, and the cause was not what I first assumed. Both fixes are pushed.

What actually broke. scripts/test-rng-hygiene.sh carries literal /dev/urandom in its probe fixtures, so once committed the guard scanned the test file and flagged it. It passed locally only because the file was untracked at the time and git ls-files did not list it.

That is the exact trap already documented in the guard, five lines above the code I was editing:

Caught only after committing: run untracked, git ls-files did not list it and it passed locally while failing in CI.

SELF now excludes both scripts by path, for the same reason it excluded the guard: markers would work, but spending them here blunts their signal everywhere else.

The harness flaw that hid it. My first diagnosis was that the temp index was empty. Wrong, but chasing it exposed something worse. The test only checked the guard's exit code, so any failure counted as a successful detection. If the guard had aborted for an unrelated reason, all eleven reject cases would have reported ok while detecting nothing, and only the two accept cases would have complained. A test that credits the right answer for the wrong reason is the same failure shape this guard exists to prevent.

Two changes:

  • reject cases now require the guard's output to name the probe file, so an unrelated abort is reported as WRONG REASON rather than credited
  • the temp index is built with git read-tree HEAD instead of copying .git/index, and the harness aborts if fewer than 10 files end up staged, so "the guard scanned almost nothing" can never look like a pass

State now: guard clean on the tree, self-test 13/13, nix fmt 0 changed.

Worth recording that the self-test earned its place on its first run: it caught a defect in itself before it could certify a weakened guard.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (1)
scripts/check-rng-hygiene.sh (1)

181-199: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Dead normalise() function has drifted from scan()'s inline normalization.

normalise() (Line 186-188) strips single-quote concatenation ('/dev/' + 'urandom') via \x27, but shellcheck flags it as never invoked (SC2329). scan() reimplements the double-quote case inline (Line 195-196) but never applies the single-quote pattern, so the two implementations are now out of sync.

This does not currently open a bypass: any /dev/' or /dev/" boundary is already caught directly by the dyndev_bad rule (Line 220) matching the raw, unnormalized line. But the duplication is a maintenance hazard — a future contributor could reasonably assume normalise() is wired in and rely on it, or extend only one of the two implementations, silently reintroducing drift between what is documented and what actually executes.

Either wire the single-quote handling into scan() and drop the standalone function, or remove normalise() if it is genuinely obsolete.

♻️ Proposed fix: fold single-quote handling into `scan()` and drop the dead function
-normalise() {
-  sed -E 's/"[ \t]*\+\+?[ \t]*"//g; s/"[ \t]*"//g; s/\x27[ \t]*\+\+?[ \t]*\x27//g'
-}
-
 scan() { # $1 = ERE
-  printf '%s\n' "$CODE" | awk -v pat="$1" '{
+  printf '%s\n' "$CODE" | awk -v pat="$1" -v sq="'" '{
     raw = $0
     line = $0; sub(/^[^:]*:[0-9]+:/, "", line)
     norm = line
     gsub(/"[ \t]*\+\+?[ \t]*"/, "", norm)
     gsub(/"[ \t]*"/, "", norm)
+    gsub(sq "[ \t]*\\+\\+?[ \t]*" sq, "", norm)
+    gsub(sq "[ \t]*" sq, "", norm)
     if (line ~ pat || norm ~ pat) print raw
   }'
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/check-rng-hygiene.sh` around lines 181 - 199, Remove the unused
normalise() function and consolidate its single-quote concatenation handling
into scan() alongside the existing double-quote normalization. Ensure scan()
checks both normalized forms against the pattern while preserving raw-line
reporting.

Source: Linters/SAST tools

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@scripts/test-rng-hygiene.sh`:
- Line 15: Guard the directory change in the test script by making the cd
command fail immediately when it cannot enter the repository root. Ensure
subsequent $GUARD and probe-file checks never run from an unintended working
directory.
- Around line 108-114: Update the scripts passed to probe_hash_string.sh and
probe_marker_string.sh so each combines its string/marker statement with the
/dev/urandom assignment on one physical line, matching the split_line() one-line
bypass scenario. Keep the existing probe expectations and descriptions, while
ensuring the tests exercise quote-aware comment and marker handling rather than
independently flagging a second-line hazard.
- Around line 27-53: Update run_probe to check whether the target path in name
already exists before printf writes the probe content; if it exists, report the
harness failure, increment fails, and return without modifying or deleting that
file. Preserve the existing temporary index setup and cleanup behavior for newly
created probe files.

---

Nitpick comments:
In `@scripts/check-rng-hygiene.sh`:
- Around line 181-199: Remove the unused normalise() function and consolidate
its single-quote concatenation handling into scan() alongside the existing
double-quote normalization. Ensure scan() checks both normalized forms against
the pattern while preserving raw-line reporting.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: e73cc362-f1b8-4556-a52f-024702676646

📥 Commits

Reviewing files that changed from the base of the PR and between 2966184 and ddef424.

📒 Files selected for processing (3)
  • .github/workflows/ci.yml
  • scripts/check-rng-hygiene.sh
  • scripts/test-rng-hygiene.sh

Comment thread scripts/test-rng-hygiene.sh Outdated
Comment thread scripts/test-rng-hygiene.sh
Comment thread scripts/test-rng-hygiene.sh Outdated
@kwsantiago

Copy link
Copy Markdown
Contributor Author

All three fixed. The third was the one that mattered.

The probes did not test what they claimed. probe_hash_string.sh and probe_marker_string.sh split the marker and the hazard across two lines. The bypass they exist to rule out is a single line, printf "#x"; pass=$(head -c 32 /dev/urandom), where a naive index($0, "#") truncates the line and hides the read. Split across two, the second line is caught whatever the comment splitter does, so the probes passed regardless.

Proved it rather than reasoning about it. Temporarily reintroduced the naive splitter the guard used to have:

probe form naive splitter quote-aware splitter
two lines (what I shipped) passed, detected nothing passed
one line (corrected) BYPASS reported passed

So the corrected probes fail against the defect and pass against the fix, which is the only thing that makes them regression tests. Both are now single-line, with a comment saying why the line break matters.

This is the second instance in this file of a test that would have credited the right answer for the wrong reason, after the exit-code-only check fixed earlier in this PR. Worth noting the pattern rather than just fixing the instance.

Guarded the cd. cd "$(dirname "$0")/.." || exit 1. A silent cd failure would have made every relative path resolve somewhere else and produced misattributed results, which is the opposite of what this file is for.

Corrected the working-tree claim, which was mine to get wrong. The PR description said the self-test runs "without modifying the working tree". Only the index is untouched, via GIT_INDEX_FILE. The probe genuinely is written to disk, because the guard's shebang sweep reads bytes with head rather than going through git, so a real file has to exist while it runs. It is removed on every path including the EXIT trap, and run_probe now refuses to run rather than clobber a file that already exists. The comment in the file now says this plainly instead of overstating it.

Self-test 13/13 on the fixed guard, and it still reports failures against a deliberately broken one.

@kwsantiago
kwsantiago merged commit 4acf17d into main Aug 2, 2026
7 checks passed
@kwsantiago
kwsantiago deleted the harden-rng-guard branch August 2, 2026 16:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant