Fix conditions dropping all but the first pattern's findings - #650
Fix conditions dropping all but the first pattern's findings#650Giulia Stocco (gfs) wants to merge 2 commits into
Conversation
WithinOperation.WithinOperationDelegate returned from inside the loop over its input captures, so only the first capture was ever evaluated. Every pattern clause in a rule produces its own capture, which meant a rule with more than one pattern and any condition reported only the findings of its first pattern. Hoist the return out of the loop so all pattern captures are evaluated, and skip captures produced by other WithinClauses. Conditions each filter the raw pattern matches independently and RuleProcessor intersects the survivors to AND them together, so consuming an already-filtered capture would double count matches and break that intersection. Seven default rules have multiple patterns and a condition and were under-reporting as a result: AI016300, AI036000, AI036622, AI038210, AI038500, AI080001 and AI084000. Scans using them will now report the findings of their remaining patterns. Also surface the reason a rule failed in verifyrules text output. RuleStatus carries Errors, OatIssues and SchemaValidationErrors, but the writer printed only "Status: False", so OAT rule violations - including the duplicate clause label and clause grammar checks that OAT already performs - were invisible outside of the test suite. OatIssues is now materialized because it is a generator that re-runs the whole check on each enumeration and is now read more than once. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a2cc370f-007a-455a-a154-b1e1e19384f9
* Initial plan
* Add pattern-level conditions and language filtering for conditions
- Add Conditions property to SearchPattern for pattern-specific conditions
- Add AppliesTo and DoesNotApplyTo to SearchCondition for language filtering
- Update JSON schema to support new fields
- Implement pattern condition processing in AbstractRuleSet
- Add language filter properties to WithinClause
- Implement language filtering logic in WithinOperation
- Expression format now supports: (pattern1 AND cond1) OR (pattern2 AND cond2)...
Co-authored-by: gfs <98900+gfs@users.noreply.github.com>
* Add tests for pattern-level conditions and language filtering
- Test pattern-level conditions parsing
- Test language filters (applies_to and does_not_apply_to)
- All tests passing successfully
Co-authored-by: gfs <98900+gfs@users.noreply.github.com>
* Address code review comments
- Rename abbreviated parameters to full names for clarity
- Change snake_case variable to camelCase
- Add clarifying comment for language filter behavior
Co-authored-by: gfs <98900+gfs@users.noreply.github.com>
* Fix pattern index labeling and capture handling issues
- Separate stable pattern label from OAT expression clause numbering to prevent pattern index mismatches
- Fix WithinOperation to preserve all captures when skipping language-filtered conditions
- Update schema to include only-before and only-after in search_in regex
- Add runtime tests for pattern-level conditions and language filtering
Co-authored-by: gfs <98900+gfs@users.noreply.github.com>
* Make pattern-level conditions actually gate their pattern
Pattern-level conditions as merged in the draft did not work: every rule
that used them produced an OAT rule that OAT itself rejected, so the rule
silently never matched anything.
Four defects, each independently fatal:
1. Label collision. Pattern clauses must keep bare numeric labels because
OatRegexWithIndexOperation parses the label back into an index into
Rule.Patterns. Conditions were drawing from the same counter, so a
rule's second pattern and its first condition could both be labelled
"1". OAT's Analyzer.Evaluate returns (false, null) on a duplicate label
and fails the whole rule. Conditions now use a separate "c{n}"
namespace. This also stops conditions from shifting pattern indexes.
2. Illegal "((" token. The expression was seeded with "(" and each
conditioned pattern group added another, producing "((0 AND 1) OR 2)".
OAT's validator rejects a token beginning with two open parens. The
outer group is now only added when the pattern body does not already
start with one, which is safe because OAT evaluates strictly left to
right with no operator precedence.
3. FilterCaptures treated all conditions as rule-level. It intersected
every within-capture against every match, so a condition attached to
one pattern suppressed findings from its siblings. Conditions now carry
OwnerPatternIndex and FilterCaptures intersects each match only against
the gates that apply to it. Comparing against the conditions the rule
declares (not just the gates that materialized) keeps a pattern whose
condition failed outright from leaking its raw matches.
4. Language-skip was order dependent. When a condition did not apply to
the file's language it returned whatever captures had accumulated so
far, which for a later condition included earlier patterns' matches.
The skip path now emits a pass-through gate built from the matches that
condition governs, so a skipped condition is a no-op regardless of
where it is declared.
Adds runtime tests covering the satisfied/unsatisfied paths, sibling
independence, rule- and pattern-level conditions combined, order
independence of language-skipped conditions, and a well-formedness check
asserting the generated expression and zero OAT validation issues for six
condition layouts.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: a2cc370f-007a-455a-a154-b1e1e19384f9
---------
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: a2cc370f-007a-455a-a154-b1e1e19384f9
SDL Security Review
Scope: changed lines only, application source. Reviewed: 🔴 BlockingNone. 🟡 WarningNone. 🟢 Informational1. Algorithmic work amplification on attacker-supplied source files —
|
| SDL area | Result |
|---|---|
| Expression injection | AbstractRuleSet builds an OAT boolean expression by string concatenation, which is the shape that usually earns a blocking finding. Every token is derived from integer counters (patternLabel, ConditionLabel → "c" + int) and never from rule-file text, so no rule content reaches the expression grammar. ✅ |
| Injection (SQL / OS / XSS / format string / eval) | None introduced. ✅ |
| Unsafe deserialization | New SearchCondition.AppliesTo/DoesNotApplyTo and SearchPattern.Conditions deserialize via System.Text.Json into concrete typed models. No polymorphic type resolution, no TypeNameHandling equivalent. ✅ |
| Path traversal / SSRF | No file-path or URL handling added in product code. ✅ |
| Secrets | No hardcoded credentials, keys, or tokens in the diff. The test_token / javascript_only strings in PatternConditionsTests.cs are pattern literals, not credentials. ✅ |
| Cryptography | No cryptographic primitives, RNG, hashing-for-security, or TLS configuration touched. ✅ |
| Culture-sensitive comparison in allow/deny logic | ConditionAppliesToLanguage uses StringComparer.OrdinalIgnoreCase for both the allow list and the deny list. This is the right call — culture-sensitive comparison in list matching is a known bypass vector (e.g. Turkish dotless-I). Enumerable.Contains with that comparer is also null-safe, so a null or absent Language cannot throw. ✅ |
| Fail-safe direction of the new language filter | When a condition does not apply to the file's language, the new pass-through gate returns everything the condition governs and filters nothing. The failure direction is therefore more findings, never fewer — including for inverted (negate_finding) conditions used to suppress false positives. For a detection tool, failing toward over-reporting is the correct posture. ✅ |
| Concurrency | The old ConcurrentDictionary in FilterCaptures is replaced with plain HashSet/Dictionary. Safe: all four collections (ruleGates, patternGates, allMatches, seen) are locals of a single FilterCaptures invocation, never shared. The one piece of shared state read, ruleCapture.Rule.Clauses, is enumerated read-only and rules are constructed before analysis begins. GovernedMatches is static and allocates its own state. ✅ |
| Gate identity | Boundary is a reference type with no Equals/GetHashCode override, so the new HashSet<(int, Boundary)> gate lookups match on reference identity. The original Boundary instances are threaded through from the pattern captures into passed/failed and into the gates unmodified, so gate.Contains(match) resolves correctly — and this matches the pre-existing ConcurrentDictionary<(int, Boundary), int> semantics. Not a security issue; noted because the correctness of the new gate intersection depends on it. ✅ |
| Error handling | No exceptions swallowed in security-critical paths. ✅ |
| Resource handling | The new TestVerifyRulesCmd temp file uses a Guid.NewGuid()-randomized name (not predictable in a world-writable temp dir) and is deleted in a finally. ✅ |
CodeQL (csharp, javascript-typescript) and the SDL Agentless Tag check are green on this head.
APPROVE — no blocking or warning-severity SDL issues in the changed lines.
Problem
WithinOperation.WithinOperationDelegatehad itsreturnstatement inside theforeachloop over its input captures:Every pattern clause in a rule contributes its own
ClauseCapture. Because the delegate returned after the first one, any rule with more than one pattern and at least one condition reported only the findings of its first pattern.Measured on
main(3b89116), a rule with patternsalpha/beta/gammaand asame-linecondition ongate:alpha gate\nbeta gate\ngamma gate[alpha][alpha, beta, gamma]beta gate\nalpha gate(order swapped)[beta][alpha, beta]How it got here
Worth spelling out, because it explains why the fix is two changes rather than one.
#423 (
c055aa3) established the original design: thereturnwas outside the capture loop, and the operation pruned each pattern capture in place viatcc.Result.RemoveAll(toRemove). Iterating every capture was essential — that was the mechanism by which each pattern clause's match list got cleaned up.At that point within-captures were
TypedClauseCapture<List<Boundary>>while pattern captures wereTypedClauseCapture<List<(int, Boundary)>>, so the type test in the loop structurally excluded other conditions' captures.#495 (
dce5493, "Refactor Conditions") changed two things at once: it replaced in-place pruning with accumulatingpassed/failedand returning a new capture, and it changed the within-capture type toTypedClauseCapture<List<(int, Boundary)>>— making it indistinguishable from a pattern capture. In the process thereturnended up inside the loop.Those two defects concealed each other. Because the delegate bailed after the first capture, it never reached another condition's capture, so the lost type-based exclusion never manifested.
Fix
Hoist the
returnback out of the loop so all captures accumulate first, and skip captures produced by otherWithinClauses — restoring the exclusion the type change silently removed.That second half matters. Conditions each filter the raw pattern matches independently, and
RuleProcessor.FilterCapturesintersects the survivors to AND the conditions together. Letting a condition consume another condition's already-filtered capture double counts matches and breaks that intersection. The existingWithinClauseWithMultipleConditionstest catches exactly this, and did catch it while I was writing the fix.Impact
Seven shipped default rules have multiple patterns plus a condition and are under-reporting today:
AI016300,AI036000,AI036622,AI038210,AI038500,AI080001,AI084000Scans using them will now report additional findings. This is a behavior change worth a release note. All default rules still verify (
verifyrules -r AppInspector/rules/default/).Also: surface why a rule failed verification
RuleStatuscarriesErrors,OatIssuesandSchemaValidationErrors, butVerifyRulesTextWriterprinted only:OAT's own
EnumerateRuleIssuesalready detects real problems — duplicate clause labels, malformed clause grammar — andRulesVerifieralready collects them, but the only place they were ever surfaced wasTestDefaultRules. A user runningverifyruleson a broken custom rule got a bareFalsewith no explanation. Failing rules now print their errors, OAT issues, and schema errors indented beneath the status line.OatIssuesis also now materialized — it was a generator that re-ran the entire check on each enumeration, andRuleStatus.Verifiedplus the writer read it more than once.Tests
Three new tests, all of which fail on
mainand pass here:MultiplePatternsWithConditionReportAllMatchingPatterns— three patterns with asame-linecondition, covering all-gated, partially-gated, and ungated contentMultiplePatternsWithConditionAreOrderIndependent— swapping pattern order must not change the resultTextWriterReportsWhyARuleFailedVerification— verify output contains the failure reason, not justStatus: FalseFull suite: 350 passing, 0 failing (347 before, plus these 3).