Three proposals: surface rules that die, exclude test code, scope guards per account - #38
Draft
anthonyra wants to merge 3 commits into
Draft
Three proposals: surface rules that die, exclude test code, scope guards per account#38anthonyra wants to merge 3 commits into
anthonyra wants to merge 3 commits into
Conversation
`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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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_valuesignal "abandon this item" by raisingStopIteration, and templates catch it with a bareexcept:. That handler alsocatches every genuine bug in a rule. A
TypeErrorfrom a bad comparison, aNameErrorfrom a builtin the sandbox doesn't expose — all of it is swallowedand 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 wentto zero findings on its own vulnerable mock and nothing said a word.
This introduces
RuleSkipfor that control-flow signal, and narrows bareexcept:to it in the AST transform, so anything else propagates. The reportingchain 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-testsputs it backTests 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 theytest, which no path filter can see.
Measured on two real repos:
--include-testsrust/programs/On srsly, test code was 100% of the findings. On antegen, 9 of the 10 dropped
findings are inside a
#[cfg(test)] mod testsinthread/src/state/config.rs— a production file by every naming convention, sothe item filter is doing work the path filter structurally cannot.
Two implementation notes that might matter to you:
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 testsplaced above the production code, pinning a findingto the same line in both modes.
is_test_pathjudges directories relative to the scan root. Judgedabsolutely, a checkout that happens to live in
~/work/tests/my-programgetsevery 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/andexamples/. A benchmark isn'tasserting 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_matchingandinvoke_signed_unvalidated_seedswere the three noisiest rules on already-fixedcode. Measured with one harness across old and new templates, findings on fixed
code went 81 → 19.
Causes, per rule:
saturating_*andwrapping_*read as no overflow handlingat all; one expression reporting the same line once per operator; index and
?-operand arithmetic that cannot wrap silently; guards written as namedhelpers (
check_owner,assert_owned_by) rather thanrequire!; and CPIwrapper functions whose accounts and seeds are all parameters, so the function
chose nothing.
The overlap. You fixed
account_data_matchingindependently in 43713b0 —accepting an if-guard, not only
require!. That's the same false positive Iwas 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
letbindings, 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.rsisthe 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, onlyborrow_mutsurvives 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:
parses one file per AST while production parses the whole crate, so two-pass
cross-file rules (
pda_sharing,init_if_needed_reinitialization) have zerocoverage 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 themock tree here. Worth its own PR if you want it.
testjob has norust_syn, so Rustfixture generation fails under
|| trueand every Rust accuracy test silentlyskips — a skip isn't a failure, so two drifted expectations sat green. Moving
those tests to the job that has
rust_syncatches it.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 sixfailures are
test_cli.pyand are identical on stockupstream/main— theyshell out to the
radarwrapper, 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 nofile exists.
copy_out()already gates that on the copy succeeding and warns onfailure, where mine stayed silent.