Skip to content

Honor unless: exclusions in search rules - #77

Open
juangaitanv wants to merge 1 commit into
mainfrom
juan/unless-exclusions
Open

juangaitanv wants to merge 1 commit into
mainfrom
juan/unless-exclusions

Conversation

@juangaitanv

@juangaitanv juangaitanv commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

The bug

rules/javascript/frontend_security.ron ships three unless: exclusion lists — lines 20 (js-dom-xss-001), 64 (js-react-dangerously-set-inner-html-001) and 98 (js-dom-xss-003) — but UnifiedRule (src/models.rs) had no unless field, and the struct does not set serde(deny_unknown_fields). RON parsed the key into nothing and no error surfaced. Every one of those 26 exclusions has been inert in every scan since it was written: sanitized innerHTML, DOMPurify-wrapped dangerouslySetInnerHTML, and static-string document.write all still reported as DOM XSS.

The fix

  • src/models.rs:190pub unless: Option<Vec<String>>, with the same skip_serializing_if / default attributes as its neighbors. Search-mode only; taint rules already express this with sanitizers.
  • src/rules.rs:403rule_matches_pattern_unified now gates a positive match on unless. Every finding path inherits it from this one place: check_rule_against_node (src/scanner/scanning_logic.rs:24) and the per-node rule filter (:184). The remaining callers do not produce findings — rule_might_match_function (:573) is a prefilter that runs before the gate, and has_matching_rules (:612) passes a bare function name, which these rules' positive patterns cannot match.
  • src/rules.rs:422/441/465matching_patterns, unless_scope_range, tightest_segment_span.

Chosen semantics

An exclusion vetoes a finding only when it matches inside the byte span the rule's pattern actually covered — never the whole line.

  • The span of a glob/substring pattern is the tightest in-order span of its literal segments, anchored right-to-left: the last segment at its last occurrence, each earlier segment at its last occurrence before the one that follows. regex: and escaped-dot forms use the regex match range. Forms that resolve no span (escaped taint patterns) fall back to the whole text, i.e. today's behavior.
  • Every pattern that matched gets its span checked, not just the first. A rule's patterns overlap — dangerouslySetInnerHTML*__html* matches a narrower span than *dangerouslySetInnerHTML*user* — and a sanitizer sitting outside the narrowest match still belongs to the same expression.

Why not whole-line scoping: getElementById and querySelector are themselves unless entries of js-dom-xss-001. Under line scoping, almost every genuine DOM-XSS finding would be suppressed, because that is how you get the element in the first place.

The line-scoping bug this avoids

The reference implementation on the abandoned html_support branch was line-scoped, and review of the old PR #9 flagged it: any unless value anywhere on the line suppressed unrelated matches on that line. Two concrete regressions, both covered by tests:

el.addEventListener('click', h); div.innerHTML = userInput;   // addEventListener is an unless entry
document.write("safe"); document.write(userInput);            // document\.write\(['"] is an unless entry

Both still report. Right-to-left anchoring is what makes the second one work — a leftmost-first span would stretch from the quoted first call to user in the second and hand the exclusion a quote to veto on.

Verification

cargo build --release → exit 0. make check (clippy fix, format, tests) → exit 0, 278 passed. Full cargo test → exit 0: unit 108 → 120, lib 64, harness 28, acceptance 9 scenarios / 34 steps, e2e 5, integration 9, strictness 51, doctest 1 — all unchanged from the pre-edit baseline on this branch's base.

End-to-end against the release binary on a JS fixture: sanitized innerHTML and static document.write no longer report; the raw assignment, the raw document.write, and the assignment sharing a line with addEventListener all still do.

New tests in tests/unit/unless_exclusion_tests.rs:

Test Covers
frontend_rules_parse_their_unless_lists the inert-field bug directly — asserts 16/5/5 entries survive RON parsing
innerhtml_rule_excludes_sanitized_assignment exclusion 1 (DOMPurify.sanitize)
dangerously_set_inner_html_rule_excludes_sanitized_html exclusion 2
document_write_rule_excludes_static_string exclusion 3 (document\.write\(['"])
*_reports_* (3 tests) each rule still fires on the genuinely vulnerable form
unrelated_unless_text_on_the_same_line_does_not_suppress line-scoping regression (addEventListener)
safe_document_write_earlier_on_the_line_does_not_suppress line-scoping regression (right-to-left anchoring)
sanitized_sibling_expression_does_not_suppress_a_raw_one sanitized statement next to a raw one
rule_without_unless_is_unaffected, empty_unless_list_never_suppresses no behavior change without unless

The existing UnifiedRule literals in three unit test files gained unless: None; nothing else changed.

Notes

  • "window\\.location\\.href(?!.*=)" (frontend_security.ron:31) is permanently inert — Rust's regex crate has no lookahead, Regex::new returns Err, and matches_regex returns false. Left alone; not this PR's scope.
  • deny_unknown_fields would have caught this class of bug at load time, but it would also hard-fail any rule file carrying a stray key. Worth doing deliberately as a follow-up, not as a side effect here.
  • pattern_match_range / first_positive_match_range are deliberately untouched — they feed markup line attribution and must keep mirroring matches_unified_pattern's "no range for glob" behavior, or markup findings move lines.
  • The vendored binary in fusion/vendor/sighthound/ is not refreshed here; vendoring follows separately per repo convention (build_all_platforms.sh).

https://claude.ai/code/session_01SEPg7aLSDDusz5xJ22rQJS

`rules/javascript/frontend_security.ron` ships three `unless:` blocks (lines
20, 64, 98), but `UnifiedRule` had no `unless` field and serde does not set
`deny_unknown_fields` — RON parsed them into nothing, so every exclusion was
inert and the false positives they name still fired.

Add the field and evaluate it in `rule_matches_pattern_unified`, scoped to the
matched byte span rather than the whole text: an `unless` string belonging to an
unrelated statement on the same line no longer swallows a real finding. Spans
are anchored right-to-left so a safe earlier call cannot stretch the scope over
a later vulnerable one.

Claude-Session: https://claude.ai/code/session_01SEPg7aLSDDusz5xJ22rQJS
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