Skip to content

fix(scan): stop the RLS scanner flagging REVOKE/GRANT and CTE reads - #39

Merged
teragrid merged 2 commits into
mainfrom
fix/rls-scanner-false-positives
Aug 8, 2026
Merged

teragrid merged 2 commits into
mainfrom
fix/rls-scanner-false-positives

Conversation

@teragrid

@teragrid teragrid commented Aug 8, 2026

Copy link
Copy Markdown
Owner

Why

Dogfooding forge scan security on the ai-marketing-platform repo returned
8 findings, all 8 false positives — 7 of them from a single rule,
select-without-where-tenant. A permanently-red gate is exactly the state in
which a real finding goes unnoticed, so this fixes the rule rather than
suppressing the findings.

The two bugs

1. REVOKE / GRANT matched as if they were queries.

REVOKE SELECT ON auth.users FROM authenticated;

The line-local regex select\s+.+\s+from\s+\w+\s*; matches this, because
SELECT and FROM both appear — as a privilege name and a role separator, not
as a query. The scanner was reporting a statement that removes read access
as an unscoped read: hardening reported as exposure, the rule's own meaning
inverted. Two lockdown migrations were flagged this way.

2. Reads of a CTE the same statement just populated.

WITH inserted AS (
  INSERT INTO memberships (...) SELECT ... RETURNING 1
)
SELECT count(*) INTO v_enqueued FROM inserted;

inserted is not a physical table. Tenant scoping belongs on the writing arm;
this read cannot be scoped and leaks nothing. Distinguishing it needs context
beyond the current line, which a line-local scanner does not have.

What changed

RunRLS now scans whole files instead of streaming lines:

  • pass 1 collects every CTE name in the file — both WITH x AS ( and the
    continuation form , x AS (
  • pass 2 evaluates the rules with that context, and skips any line whose
    leading keyword is GRANT or REVOKE

Supporting changes:

  • new scanFilesWhole alongside scanFilesExt, for rules needing file context
  • the directory-skip list is extracted into skipScanDir() and shared, rather
    than duplicated into the new walker

Tests

Six new cases. TC-07 is the one that matters most — a false-positive guard
for the fix itself
:

Case Asserts
TC-SCAN-RLS-03 REVOKE SELECT … FROM role; not flagged
TC-SCAN-RLS-04 GRANT SELECT … TO role; not flagged
TC-SCAN-RLS-05 reading a WITH CTE not flagged
TC-SCAN-RLS-06 reading a , x AS ( continuation CTE not flagged
TC-SCAN-RLS-07 a real unscoped read of a physical table in a file that also has a CTE is still flagged — the exemption must not become an off switch for the rule
TC-SCAN-RLS-08 line numbers stay 1-based and correct after moving off bufio line streaming

Second commit — an unrelated red gate found on the way

Pushing this branch was blocked by pre-push stage [13/13], which was already
failing on a clean main: QA-24 and QA-26 run the full ship pipeline
against a forge init --minimal scratch project (no go.mod / package.json,
no cmd/mcp/, no testing-pipeline.md) and assert exit 0. The four-stage
testing gate became blocking by default in 1.8.2 / 1.9.0, so QA-Verify has
correctly failed that project ever since — the gate works, the expectations
were stale.

Both scenarios exist to check that --json bypasses the interactive gate and
emits the documented checkpoints / dry_run keys, not to prove an empty
directory can pass a four-stage testing audit. They now pass
--no-strict-testing, the documented waiver for this case, with a comment
recording why it is required. QA-27 also runs the full pipeline but passes on
its own terms and is left untouched.

Verification

  • go test ./internal/cli/cmdscan/ — pass, including the 6 new cases
  • go test ./... — 77 packages, no failures
  • SHIP_QA_ONLY=1 FORGE_NO_LLM=1 bash scripts/forge-qa-real.sh — 2 of 12
    failing → all 12 passing
  • pre-push hook — all 13 stages green
  • binary built from this branch, run against ai-marketing-platform:
    findings: 0, status: clean (was 8), and a real unscoped SELECT still
    trips the rule

🤖 Generated with Claude Code

vietking and others added 2 commits August 8, 2026 18:24
The `select-without-where-tenant` rule matched line-locally with the regex
`select\s+.+\s+from\s+\w+\s*;`, which produced two classes of false positive:

1. `REVOKE SELECT ON auth.users FROM authenticated;` matched, because SELECT
   and FROM both appear in a privilege statement. The scanner reported a
   statement that *removes* read access as an unscoped read — inverting its
   own meaning, so hardening looked like exposure.

2. `SELECT count(*) INTO v FROM inserted;` matched, where `inserted` is a CTE
   the same statement just populated (`WITH inserted AS (INSERT ... RETURNING
   ...)`). Tenant scoping belongs on the writing arm; the read cannot be
   scoped and is not a leak. Resolving this needs context beyond the current
   line, which a line-local scanner does not have.

RunRLS now scans whole files: pass 1 collects every CTE name (`WITH x AS (`
and continuation `, x AS (`), pass 2 evaluates the rules with that context and
skips lines whose leading keyword is GRANT or REVOKE. Adds `scanFilesWhole`
alongside `scanFilesExt`, and extracts the shared directory-skip list into
`skipScanDir()` rather than duplicating it.

Six regression tests, including a false-positive guard for the fix itself
(TC-SCAN-RLS-07): a genuine unscoped read of a physical table in a file that
also contains a CTE must still be flagged, so the exemption cannot silently
become an off switch for the rule. TC-SCAN-RLS-08 pins line numbers, which
changed representation when scanning moved off bufio line streaming.

Found by dogfooding on the ai-marketing-platform repo, where all 8 findings
from `forge scan security` were false positives — 7 of them from this rule.
Verified: that repo now reports `findings: 0, clean`, and a real unscoped
SELECT still trips the rule.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
QA-24 and QA-26 run the full ship pipeline against a scratch project created
by `forge init --minimal` — no go.mod / package.json / pyproject.toml, no
cmd/mcp/, no testing-pipeline.md. The four-stage testing gate became BLOCKING
by default in 1.8.2 (re-released as 1.9.0), so from that release on QA-Verify
correctly fails such a project and the pipeline exits 1. Both scenarios assert
exit 0, so both have failed ever since — on a clean `main`, unrelated to any
diff being pushed. That left stage [13/13] of the pre-push hook permanently
red, which is precisely the state in which a real failure goes unnoticed.

The gate is behaving correctly; the expectations were stale. What these two
scenarios actually cover is that `--json` bypasses the *interactive* gate and
emits the documented `checkpoints` / `dry_run` keys — not whether an empty
scratch directory can satisfy a four-stage testing audit. Both now pass
--no-strict-testing, the documented waiver for exactly this case, with a
comment recording why it is required rather than incidental.

Verified: `SHIP_QA_ONLY=1 FORGE_NO_LLM=1 bash scripts/forge-qa-real.sh` goes
from 2 of 12 failing to all 12 passing. QA-27 also runs the full pipeline but
passes on its own terms and is left untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@teragrid
teragrid merged commit 428c939 into main Aug 8, 2026
26 checks passed
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.

2 participants