Skip to content

Three proposals: surface rules that die, exclude test code, scope guards per account - #38

Draft
anthonyra wants to merge 3 commits into
Auditware:mainfrom
wuwei-labs:proposal/measured-rules
Draft

Three proposals: surface rules that die, exclude test code, scope guards per account#38
anthonyra wants to merge 3 commits into
Auditware:mainfrom
wuwei-labs:proposal/measured-rules

Conversation

@anthonyra

Copy link
Copy Markdown
Contributor

Draft, opened for direction rather than for merge. Three changes that came
out of running radar as a gate on two Solana repos. Each argues a separate
point; I'd rather find out which ones you agree with before polishing any of
them. There are rough edges — the third one in particular overlaps work you did
independently, and I'd like to talk about that overlap rather than assume mine
wins.


1. A rule that dies reads exactly like a rule that found nothing

exit_on_none / exit_on_value signal "abandon this item" by raising
StopIteration, and templates catch it with a bare except:. That handler also
catches every genuine bug in a rule. A TypeError from a bad comparison, a
NameError from a builtin the sandbox doesn't expose — all of it is swallowed
and the item is skipped, so a broken rule reports zero findings and a scan comes
back clean.

I hit this twice while writing rules. Once by putting a dict in a set, once by
calling str(), which isn't in the sandbox's builtins. Both times the rule went
to zero findings on its own vulnerable mock and nothing said a word.

This introduces RuleSkip for that control-flow signal, and narrows bare
except: to it in the AST transform, so anything else propagates. The reporting
chain behind it already existed and was already correct — nothing could reach
it.

Swept all Rust rules across every mock variant afterwards: 0 rules raise.

2. Test code is excluded by default, --include-tests puts it back

Tests unwrap, hardcode keys, reuse a PDA across two domains and skip owner
checks, because doing it the production way would obscure what's being tested.
Reporting that is a fixed cost on every scan, and it trains people to skim the
report — which is how the real finding gets missed.

Two filters, because Rust puts tests in two places: paths (tests/,
test-named files) and #[cfg(test)] / #[test] items inside the module they
test, which no path filter can see.

Measured on two real repos:

repo default --include-tests
srsly rust/ 78 files, 0 findings 113 files, 172 findings
antegen programs/ 35 files, 15 findings 61 files, 25 findings

On srsly, test code was 100% of the findings. On antegen, 9 of the 10 dropped
findings are inside a #[cfg(test)] mod tests in
thread/src/state/config.rs — a production file by every naming convention, so
the item filter is doing work the path filter structurally cannot.

Two implementation notes that might matter to you:

  • Pruning happens after span enrichment, deliberately. Enrichment hands out
    the nth textual occurrence of an identifier, so dropping nodes before it would
    shift the spans of nodes that remain. There's an E2E fixture with
    #[cfg(test)] mod tests placed above the production code, pinning a finding
    to the same line in both modes.
  • is_test_path judges directories relative to the scan root. Judged
    absolutely, a checkout that happens to live in ~/work/tests/my-program gets
    every file classified as test code and returns an empty scan with nothing to
    explain it — the exact silent-clean failure this feature must not introduce.

Deliberately not excluded: benches/ and examples/. A benchmark isn't
asserting correctness and an example is code users are invited to copy, so
neither has the written-unsafely-on-purpose property that justifies skipping
tests.

3. Three rules that keyed on Anchor's spelling — and where this overlaps #43713b0

unchecked_arithmetics, account_data_matching and
invoke_signed_unvalidated_seeds were the three noisiest rules on already-fixed
code. Measured with one harness across old and new templates, findings on fixed
code went 81 → 19.

Causes, per rule: saturating_* and wrapping_* read as no overflow handling
at all; one expression reporting the same line once per operator; index and
?-operand arithmetic that cannot wrap silently; guards written as named
helpers (check_owner, assert_owned_by) rather than require!; and CPI
wrapper functions whose accounts and seeds are all parameters, so the function
chose nothing.

The overlap. You fixed account_data_matching independently in 43713b0
accepting an if-guard, not only require!. That's the same false positive I
was chasing and your fix lands it. Mine goes further in a way I think matters,
and this is the part I most want your read on:

Both your v0.1.1 and my first attempt scope the guard to the function. That
makes a validated account excuse an unvalidated sibling in the same handler —
unpack two accounts, check the owner of one, and the other stops being
reported. It's the same shape as the file-scope suppression problem from #23,
one level down. So v0.2.0 here scopes per account: it resolves one-hop let
bindings, attributes each guard to the account it names, and asks the question
per unpacked account rather than per function.

api/tests/detection_fixtures/account_data_matching__unchecked_sibling.rs is
the case that separates them — your v0.1.1 and my earlier function-scoped
version both go silent on it.

I'm not attached to my implementation. If you'd rather keep v0.1.1's shape and
add per-account scoping to it, that's a fine outcome and I'll rework it.

There's one documented fallback I'm not happy about: when the receiver chain of
&mut info.data.borrow_mut() is dropped during enrichment, only borrow_mut
survives and the rule can't tell which account was touched, so it falls back to
function scope. That looks like an AST bug rather than a rule bug, and fixing it
would let the fallback be deleted. Happy to take that separately if you agree
with the diagnosis.


What's deliberately not in this branch

Three more changes exist in the fork that I left out rather than pad the diff:

  • Workspace-shaped mocks + whole-variant parsing. The template harness
    parses one file per AST while production parses the whole crate, so two-pass
    cross-file rules (pda_sharing, init_if_needed_reinitialization) have zero
    coverage of the pass that matters — they're structurally unable to fire in the
    harness while working fine in production. Fixing it means restructuring the
    mocks to {bad,good}/programs/<name>/src/, which conflicts heavily with the
    mock tree here. Worth its own PR if you want it.
  • The accuracy suite in CI. The fast test job has no rust_syn, so Rust
    fixture generation fails under || true and every Rust accuracy test silently
    skips — a skip isn't a failure, so two drifted expectations sat green. Moving
    those tests to the job that has rust_syn catches it.
  • An out-of-sample nightly scoring the rules against ScannerTruth's corpus-2
    with that pack's own scorer. Left out because it clones a third-party repo into
    CI and adds a baseline file someone would have to own. Mentioned only so the
    numbers above have a provenance.

Verification

pytest api/tests/ on this branch: 198 passed, 1 skipped, 6 failed. All six
failures are test_cli.py and are identical on stock upstream/main — they
shell out to the radar wrapper, which needs Docker inside the test container.

Also dropped from this branch after finding you'd already solved it, better: I
had a fix for [i] Results written to <path> printing on clean scans where no
file exists. copy_out() already gates that on the copy succeeding and warns on
failure, where mine stayed silent.

`exit_on_none` and `exit_on_value` raised `StopIteration`, and every one of the
124 templates catches them with a bare `except: continue` - which is not a
style choice but the only handler a rule can write, since the sandbox exposes
no builtin exception classes to name. Control flow and failure therefore shared
a channel: a template that referenced a forbidden builtin, or put an unhashable
value in a set, skipped every item and reported zero findings. No error, no
warning, a clean scan.

Three rules shipped broken exactly that way, all found by accident: `min` not
being in the sandbox (pda_sharing), `str` not being in it, and `to_result()`
returning an unhashable dict (both while cutting unchecked_arithmetics noise
this week). Each looked like a rule that had decided there was nothing to
report, which is the most expensive failure a scanner has - it is indistinguish-
able from the answer everyone wants.

`exit_on_*` now raise `RuleSkip`, and `SandboxTransformer` rewrites bare
handlers to catch that type alone. Real failures escape to `run_scan_task`,
which already records them, and to the controller, which already refuses to
call such a scan clean and exits 2. The whole chain for reporting a broken
template was built and correct; nothing could ever reach it.

Fixed in the sandbox rather than in 124 templates because `except:` is all a
template author has. Handlers that name a type are left alone.

Checked against every Rust rule over all 17 crates in scannertruth's pack, both
variants: 0 rules raise. No builtin rule was relying on the swallow, so this
surfaces future bugs without reclassifying any current scan as failed.
Test code is held to a different standard on purpose. A test unwraps, hardcodes
a key, reuses one PDA across two domains and skips the owner check, because
doing any of that the production way would obscure what is being tested or make
the case impossible to set up. Reporting those is not a finding, it is a fixed
cost every user pays on every scan - and it trains people to skim the report,
which is how the real finding gets missed.

Two filters, because Rust puts tests in two places. Integration tests and
fixtures live in files, under a `tests/`/`test/`/`testing/`/`__tests__`
directory or in a file named for testing. Unit tests live *inside* the module
they test, as `#[cfg(test)] mod tests` - the dominant convention, and one no
path filter can see - so items are also pruned by attribute: `#[cfg(test)]`
(including nested, as in `cfg(any(test, feature = "x"))`), `#[test]`,
`#[bench]`, and any attribute path whose last segment is `test`, which is what
makes `#[tokio::test]` and `#[actix_rt::test]` one rule rather than a list.

Pruned after span enrichment, not before. Positions are handed out by scanning
the source text and consuming the nth occurrence of each identifier, and the
text still contains the tests either way. Pruning first would let the surviving
nodes start consuming from the first occurrence, which for a `#[cfg(test)] mod
tests` written above the code it tests is the one inside the tests - moving the
reported line of production findings. Dropping nodes afterwards cannot move the
span of anything that remains, and a fixture with the test module on top pins
that.

Two ways this could have introduced a silent empty scan, both closed:

  - Directory names are judged relative to the scanned root. Judged absolutely,
    a checkout living in `~/work/tests/my-program` has every file classified as
    test code and comes back clean with nothing to explain it.
  - Whenever files are skipped the run says how many, and how to get them back.

`benches/` and `examples/` are deliberately not skipped: a benchmark is not
asserting correctness, and an example is code users are invited to copy, so
neither has the written-unsafely-on-purpose property that justifies skipping
tests.
Measured against scannertruth's regression pack, counting findings on the
*fixed* variant of each case - code with the bug already patched, where every
finding is by construction a false positive:

  unchecked_arithmetics             37 -> 13
  account_data_matching             23 -> 2
  invoke_signed_unvalidated_seeds   21 -> 4

All three shared a cause. They keyed on Anchor's spelling of an idea and ran,
under the accent silo, mostly against native programs that spell it otherwise.

`invoke_signed_unvalidated_seeds` accepted only `require_keys_eq`. Native code
proves the same thing with a named helper - `check_account_owner`,
`assert_derivation`, `check_validator_stake_address` - so every native
`invoke_signed` reported whether or not the keys were pinned. It now accepts a
`check_`/`assert_`/`validate_`/`verify_` call, deliberately without filtering
on subject words: what has to be proved before lending a signature varies per
program, and guessing which words count would rebuild the same mismatch
somewhere else. It also stops reporting CPI wrappers, which were 11 of the 21:
a function whose accounts and seeds all arrive as parameters chose nothing and
has nothing to prove, and saying otherwise asserts the program validated
nothing when the validation is one frame up.

`account_data_matching` accepted `require!` and an if-comparison on `owner`.
The 23 were `check_program_account(mint_info.owner)?` and
`assert_owned_by(token_info, &spl_token::ID)?` - the check, written the way SPL
and Metaplex write it. Widening it to the helper family is not enough on its
own, though: asked per function, a checked sibling excuses an unchecked account
sitting next to it, which is how these bugs get past review in the first place.
So the exemption is per account - each unpack asks whether *its* account was
proved, following the `let` binding once, since the guard names `mint_info`
while the unpack names `mint_data`.

One weakening is deliberate and marked. The enrichment keeps `borrow_mut` but
drops the receiver chain of `&mut mint_info.data.borrow_mut()`, so that binding
resolves to a borrow and to no account at all - and that is how token-2022 and
stake-pool read every account they touch. Where a binding reads account data
but names no account, the rule falls back to asking whether the function proved
anything. An account named directly at the unpack never takes that path.

`unchecked_arithmetics` reported index math, and arithmetic on types whose
operators are already checked. `data[index..index + 2]` cannot wrap silently -
the bounds check panics - and `(a + b)?` does not compile for two u64s, so the
shape itself proves the program overloaded the operator to return an Option.
Floats are exempt for the reason `integer_division_overflow` already exempts
them. Two tempting exemptions were left out: arithmetic in an `if` condition
wraps before the comparison runs, and `s.len() - SCALE` underflows whenever the
string is shorter than the scale.

Every one of these is a narrowing, so each gets fixtures in both directions -
the gate that exists because rules regressed this way in Auditware#33. The unchecked
sibling, the unguarded native `invoke_signed` and a plain wrapping balance
update must still report; the helper-checked owner, the CPI wrapper and the
fallible operator must stay silent.
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