Boolean expression support in the rules engine - #654
Boolean expression support in the rules engine#654Giulia Stocco (gfs) wants to merge 26 commits into
Conversation
There was a problem hiding this comment.
Pull request overview
This pull request extends Application Inspector’s rules engine to support author-defined boolean expressions over named pattern/condition labels, and adds applies_to_patterns to scope conditions to subsets of patterns. It also unifies sync/async analysis paths and includes multiple correctness fixes plus new tests that pin the intended semantics.
Changes:
- Add
expression,label, andapplies_to_patternssupport end-to-end (schema, model, translation, verification, evaluation). - Fix several matching/reporting issues (pattern index reporting, condition evaluation across all captures, sync/async parity, override containment).
- Add comprehensive xUnit coverage for expression parsing/behavior/validation and regression scenarios; bump OAT and minor version.
Show a summary per file
| File | Description |
|---|---|
| version.json | Bumps minor version to reflect behavior/output changes and new additive API surface. |
| rule-schema-v1.json | Updates rule schema for expression, label, applies_to_patterns, and new search_in options. |
| AppInspector/rules/samples/README.md | Documents expressions, labels, scoping, and authoring rules. |
| AppInspector/AppInspector.Commands.csproj | Bumps Microsoft.CST.OAT to 1.2.95. |
| AppInspector.RulesEngine/AppInspector.RulesEngine.csproj | Bumps Microsoft.CST.OAT to 1.2.95 in the engine project. |
| AppInspector.RulesEngine/SearchPattern.cs | Adds label to patterns for expression authoring. |
| AppInspector.RulesEngine/SearchCondition.cs | Adds label and applies_to_patterns to conditions. |
| AppInspector.RulesEngine/Rule.cs | Adds expression field to the rule model. |
| AppInspector.RulesEngine/RulesVerifier.cs | Adds verification for label validity, duplicates, applies_to scoping, and expression safety constraints. |
| AppInspector.RulesEngine/RuleProcessor.cs | Unifies sync/async analysis into one implementation; adds per-finding expression filtering; standardizes override removal. |
| AppInspector.RulesEngine/RuleExpression.cs | New internal parser/evaluator mirroring the engine’s left-to-right folding semantics. |
| AppInspector.RulesEngine/AbstractRuleSet.cs | Translates labels/expressions into OAT clauses and expressions; wires condition scoping. |
| AppInspector.RulesEngine/OatExtensions/WithinOperation.cs | Fixes within evaluation to consider all captures and supports pattern-scoped conditions. |
| AppInspector.RulesEngine/OatExtensions/WithinClause.cs | Adds AppliesToPatternIndices and guarding logic. |
| AppInspector.RulesEngine/OatExtensions/OatSubstringIndexOperation.cs | Fixes string/substring pattern index reporting. |
| AppInspector.RulesEngine/OatExtensions/OatSubstringIndexClause.cs | Adds PatternIndex to identify originating pattern. |
| AppInspector.RulesEngine/OatExtensions/OatRegexWithIndexOperation.cs | Switches regex index reporting to PatternIndex instead of clause.Label. |
| AppInspector.RulesEngine/OatExtensions/OatRegexWithIndexClause.cs | Adds PatternIndex to identify originating pattern. |
| AppInspector.Tests/RuleProcessor/WithinClauseMultiPatternTests.cs | New regression tests for condition evaluation across multiple pattern captures. |
| AppInspector.Tests/RuleProcessor/SyncAsyncParityTests.cs | New parity tests ensuring sync/async results match, including cancellation behavior. |
| AppInspector.Tests/RuleProcessor/RuleExpressionParserTests.cs | New tests for parsing and evaluation semantics (L2R folding, parentheses, malformed expressions). |
| AppInspector.Tests/RuleProcessor/RuleExpressionBehaviourTests.cs | New functional tests for expression-only and applies_to_patterns-only scenarios. |
| AppInspector.Tests/RuleProcessor/PatternIndexReportingTests.cs | New regression tests for correct pattern index reporting + confidence filtering. |
| AppInspector.Tests/RuleProcessor/OverrideTests.cs | New tests pinning override containment semantics and sync/async agreement. |
| AppInspector.Tests/RuleProcessor/OatExpressionSemanticsTests.cs | Characterization tests pinning OAT behaviors the translation depends on. |
| AppInspector.Tests/RuleProcessor/ExpressionValidationTests.cs | New tests asserting verification rejects malformed/unsafe authoring patterns. |
| AppInspector.Tests/RuleProcessor/ExpressionGenerationTests.cs | Golden tests ensuring legacy-generated expressions remain byte-for-byte stable for shipped rules. |
Review details
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
- Files reviewed: 27/27 changed files
- Comments generated: 3
- Review effort level: Lite
|
Copilot Generated COMPASS SDL Security Review: SDL Security ReviewScope: PR #654
🔴 Blocking
|
There was a problem hiding this comment.
Review details
Suppressed comments (1)
Previously missed (1) — in code that hasn't changed since the last review.
AppInspector.RulesEngine/RulesVerifier.cs:495
- This validation does not enforce the documented requirement that a condition be evaluated only after a pattern has supplied a finding. An expression such as
c AND pcan verify successfully, butWithinOperationreceives no prior captures forc, so the rule silently never matches. Validate expression order/control flow so every reachable condition term has a preceding pattern source.
var expression = rule.Expression!;
- Files reviewed: 30/30 changed files
- Comments generated: 4
- Review effort level: Balanced
There was a problem hiding this comment.
Review details
Suppressed comments (2)
Previously missed (2) — in code that hasn't changed since the last review.
AppInspector.RulesEngine/AbstractRuleSet.cs:188
- An explicitly empty
applies_to_patternslist is currently treated like omission because this guard requiresCount > 0. The schema says omission applies the condition to every pattern, whereas[]names no guarded patterns; with the current code, adding an empty list unexpectedly makes the condition guard all patterns. Initialize an empty set whenever the property is present so omission and an explicit empty scope retain distinct meanings.
if (condition.AppliesToPatterns is { Count: > 0 })
rule-schema-v1.json:277
- Adding
only-beforeto the schema exposes an existing off-by-one inWithinOperation: its boundary ends atfinding.Indexand then adds one to the length, so the first character of the finding is included in the supposedly before-only search. A condition matching that character can therefore pass even when it occurs only inside the finding. Fix the boundary to exclude the finding before accepting this value in the schema.
"pattern": "^(file|finding-region\\((0,\\s*[1-9]\\d*|-\\d+,\\s*\\d+)\\)|finding-only|same-line|same-file|only-before|only-after)$",
- Files reviewed: 30/30 changed files
- Comments generated: 2
- Review effort level: Balanced
There was a problem hiding this comment.
Review details
Suppressed comments (4)
Previously missed (1) — in code that hasn't changed since the last review.
AppInspector.RulesEngine/AbstractRuleSet.cs:188
- An explicitly empty
applies_to_patternsarray takes this false branch, leavingappliesToasnull;WithinClause.Guardsdefinesnullas guarding every pattern. That makes[]behave like omission even though the API and schema describe only omission as the all-pattern default. Preserve a non-null empty set so an empty list guards no patterns.
if (condition.AppliesToPatterns is { Count: > 0 })
AppInspector.RulesEngine/RuleProcessor.cs:162
- The authored expression is still evaluated by OAT before this loop, at rule/file scope. For
p AND NOT (a AND b), if onepfinding hasaand another hasb, OAT sees both conditions as true and suppresses the entireRuleCapture;FilterCapturesByExpressionnever receives either candidate even though each individually satisfies the expression. Candidate capture collection must not be gated by the aggregate authored expression—collect the relevant clause captures independently, then applyRuleExpressionper finding.
foreach (var ruleCapture in _analyzer.GetCaptures(rules, textContainer))
AppInspector.RulesEngine/RulesVerifier.cs:649
- This silently disables the “can report a finding” validation for any rule declaring more than 16 conditions, even when those conditions are unused. For example, such a rule with
a AND bthen verifies successfully, but the per-finding evaluator can never make both originating pattern labels true and reports nothing. Either reject expressions whose satisfiability cannot be checked within this bound or use a bounded symbolic approach; do not mark them verified without running the check.
if (conditionLabels.Count > maxConditionsToEnumerate)
{
yield break;
}
AppInspector.RulesEngine/RulesVerifier.cs:544
- Expression operands are never checked strictly against the declared labels. OAT treats an unmatched numeric operand as a clause-list index, while the per-finding evaluator only uses actual labels. For patterns labeled
fooand1, the expression0 OR 1therefore verifies and OAT captures both patterns, butFilterCapturesByExpressiontreats0as false and silently drops everyfoofinding. Validate every operand againstpatternLabels/conditionLabelsdirectly instead of accepting OAT's numeric fallback.
var tokens = expression.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries);
- Files reviewed: 32/32 changed files
- Comments generated: 1
- Review effort level: Balanced
195398d to
f0a22d0
Compare
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
Introduces the fields needed to describe a rule as a boolean expression over named patterns and conditions. Nothing reads them yet. Also extends the schema's search_in pattern with only-before and only-after, which the engine has always accepted, and allows whitespace and a negative second argument in finding-region. Schema version 1.1.0: all additions are backward compatible, so existing rules continue to validate unchanged.
Every string and substring pattern becomes its own clause holding a single element of Data, so deriving the reported index from the position within that list always yielded 0. A rule with several such patterns therefore reported Patterns[0] as the matching pattern no matter which one matched, and confidence filtering was applied against that wrong pattern too. Carry the index explicitly on the clause instead. This also frees Clause.Label, which the regex operation had been parsing as an integer, so it can later hold an author supplied name.
Characterization tests for the OAT contract that rule translation relies on: no operator precedence, parentheses group and also inherit accumulated captures, duplicate labels silently evaluate false, and an unclosed parenthesis throws during analysis rather than failing validation. These assert the engine's behaviour rather than ours, so a failure after an OAT upgrade means the translation layer needs review before taking the upgrade.
Complements the fix in 'Fix conditions dropping all but the first pattern's findings' with cases covering each pattern satisfying the condition, only the first, only the second, and neither.
AnalyzeFile and AnalyzeFileAsync are conveniences over the same engine but had drifted apart. The async path took the union of the within clause captures instead of intersecting them, so it reported duplicates as well as findings that the rule's conditions should have excluded. It also never populated StartLocationColumn or EndLocationColumn, and its bounds check on the pattern index was off by one. Extract the capture filtering and the match record construction so both paths share them.
Patterns and conditions may now name themselves, and a rule may supply the boolean expression combining those names instead of taking the generated pattern-OR, condition-AND shape. Conditions also resolve applies_to_patterns to the indices they guard, though nothing consumes that yet. Two related corrections fall out of numbering the clauses properly. The pattern index is now the index into rule.Patterns rather than the ordinal of the clauses successfully generated, which previously shifted every later pattern's reported index if one pattern produced no clause. Condition labels now start past the declared pattern count so a default condition label cannot collide with a default pattern label in that same case. A rule that supplies neither labels nor an expression must translate exactly as before, so ExpressionGenerationTests asserts the generated string byte for byte across the whole shipped rule set.
A malformed expression is not a harmless mistake: a duplicated label makes the engine abandon the rule so it silently never matches, and an unclosed parenthesis throws part way through a scan. The engine's own validation catches neither, and its parenthesis check is one directional, firing only when closing parentheses outnumber opening ones. Reject duplicate labels, labels containing whitespace or parentheses, unbalanced parentheses, and applies_to_patterns naming a pattern that does not exist. Also reject an expression that mixes operators at one nesting level without parentheses. Expressions fold strictly left to right with no precedence, so 'a OR b AND c' means '(a OR b) AND c' and is almost never what the author meant. A rule supplying an expression must express negation with NOT rather than negate_finding, so that negation has a single source.
The engine only reports whether a rule matched, so the reporting layer decided which findings to keep by intersecting the within clause captures. That encodes the assumption that conditions are always ANDed, and it produces silently wrong results once a rule supplies its own expression: in '(curl AND NOT tls13) OR wget' the intersection reports the curl finding that the expression excluded and drops the wget finding that satisfied it. Evaluate the expression per finding instead, treating each label as true when its clause reported that finding. Rules without an expression keep the existing intersection untouched. This makes the previously inexpressible cases work end to end, including which findings are reported rather than merely whether the rule matched: a condition guarding one pattern, a disjunction of conditions, and a negated conjunction that fires when any one required mitigation is missing.
Covers the new fields and, prominently, that expressions have no operator precedence and fold left to right, which is the most likely source of a rule that verifies but does not mean what its author intended.
The last divergence between the two entry points was how an override decides a finding is superseded. The sync path only required the overridden finding to start inside the overriding one, so a wider finding that merely overlapped was dropped. The async path required full containment, which is what the sync path's own comment described. Settle on containment and share one implementation. Over-removal is the worse failure mode here because it silently drops findings, and containment is what the documented intent already said. This also drops an off-by-one that suppressed a finding starting one character past the overriding match. No rule shipped with Application Inspector uses overrides, so this changes no default scan output, but downstream rule sets that do use it may see findings that were previously suppressed.
The two entry points had each grown their own copy of the scan loop, which is how they came to disagree about capture filtering, location columns, the pattern index bounds check and override overlap. Those symptoms are fixed, but the duplication that produced them was not, so the same drift could happen again. Collapse both onto a single AnalyzeTextContainer, with the cancellation token as the only difference between them. Also give the async read the same exception handling the sync read has. This is defensive rather than a fix for observable behaviour: FileEntry buffers the supplied stream on construction and absorbs a failing read there, producing empty content, so the processor does not see an unreadable stream in practice.
The engine's expression validation only reported unbalanced parentheses when closing ones outnumbered opening ones, so a trailing unclosed group passed validation and then threw part way through a scan. That is fixed upstream in microsoft/OAT#393, and the characterization tests now assert the unclosed cases are reported rather than missed. Evaluating an unbalanced expression still throws, so verification remains the gate that keeps a malformed rule away from the analyzer, and the balance check in RulesVerifier is kept: it produces an actionable rule error rather than an engine violation, and it still holds if the package is pinned back. Both projects referencing the package are moved together; AppInspector.Commands also pinned it.
Additive public API on the rules engine, so this is a minor rather than a patch release. Existing rule JSON is unchanged and continues to validate. Results are not bit identical for consumers who baseline scan output, because the bug fixes in this branch correct which findings are reported: string pattern matches now resolve to the pattern that actually matched, multi pattern rules with conditions no longer drop findings, the async path no longer over reports, and rule overrides now require containment.
Three points from automated review, all valid. The schema's search_in pattern allowed a negative lines-after value in finding-region, which the engine rejects, so a rule could pass schema validation and still fail verification. This was a regression introduced earlier in the branch while relaxing the pattern to tolerate a space after the comma; the original only allowed digits there. The new theory pins the accepted values against what the engine actually takes. Override removal collected into a List and then tested membership per match, which is quadratic once a rule set uses overrides. A HashSet gives the same equality semantics, since MatchRecord does not override Equals. Capture filtering built a ConcurrentDictionary for a local counter in a method with no concurrency. A plain Dictionary does the same work without the overhead.
The schema still accepted a positive lines-before value, which the engine rejects, and both parameters being 0, which the engine also rejects and which should be written as same-line instead. Encode all three constraints so a rule cannot pass schema validation and then fail verification. One pathological input, finding-region(-0,0), is still accepted by the schema and rejected by verification; contorting the pattern to exclude a negative zero is not worth the loss of readability.
Expression parsing recurses once per level of parenthesis nesting, with nothing bounding it, so a rule carrying a deeply nested expression exhausted the stack. That cannot be caught in .NET and takes the process down, which matters for a host embedding the rules engine and handing it rules it did not author. Confirmed by parsing a 200000 deep expression: the test host aborted outright. Cap nesting at 64, far above anything written by hand. The cap is applied in three places so it holds whichever way rules are loaded: the parser refuses to recurse past it, verification reports it as a rule error, and rule conversion refuses to build the rule at all. Conversion is the important one, because it is on every load path whether or not the caller verifies first. The schema also gains a maxLength on expression so oversized input is rejected at load. Parsed expressions were held in a static dictionary that was never evicted and keyed on rule-controlled strings, so a long lived host loading many rule sets grew monotonically. Cache against the rule instead, which bounds the cache by the number of rules loaded and releases it with the rule set. An expression that fails to parse discarded every finding for its rule and said nothing. Verification rejects these, so reaching that branch means the rule set was never verified; log an error rather than silently reporting nothing.
Verification ran a rule's self-tests even after recording that its expression was malformed. Running such a rule throws rather than simply not matching, so verification failed with an exception instead of returning a failed RuleStatus. Self-tests now run only once the rule is structurally sound, and any failure to execute one is reported as a verification error rather than escaping. The mixed-operator check grouped operators by nesting depth, so sibling groups were conflated and a correctly parenthesised expression such as '(a AND c) OR (b XOR c)' was rejected. Operators are now tracked per group. Expression nodes formed a left leaning tree, so evaluation recursed once per operator rather than once per nesting level, which the nesting cap does not bound. A flat expression of a million operands exhausted the stack; operands are now folded iteratively and only nesting recurses. A finding comes from exactly one pattern, so an expression naming two patterns in a conjunction can never report anything even though the rule matches at the engine level. Verification now rejects an expression that has no satisfying assignment, rather than leaving the author with a rule that silently reports nothing. Each of these is now covered by a test that fails without its fix.
…e files Captures accumulate in evaluation order, so a condition reached before any pattern has nothing to test and is always false. An expression such as 'c AND p' therefore verified clean and then never reported. This constraint was documented for authors but never enforced; verification now rejects it. The satisfiability check treats conditions as free booleans, which is what let that shape through, so the order check sits alongside it rather than replacing it. A file that cannot be read is analyzed as empty, which is the long standing behaviour of the synchronous path. That is defensible, since FileEntry already absorbs read failures when it buffers the stream and the enumerator records ScanState.Error for genuine IO failures, but it should not be silent: the read failure is now logged as an error on both paths rather than at debug.
Only one of the three per-file analysis paths caught exceptions. The synchronous path guarded the call when FileTimeOut was set and recorded ScanState.Error, but the non-timeout branch and the whole asynchronous path had no handler, so one file that failed to analyze aborted the entire run. Wrap both unguarded paths the same way, so the file is recorded as Error, the reason is logged, and the remaining files are still scanned. This also gives a read failure somewhere to be reported rather than being reported as a clean scan, which was the concern raised in review.
Reviewer asked why RuleExpression exists when the engine already evaluates expressions. Disabling FilterCapturesByExpression and running the suite answered it uncomfortably: all 446 tests still passed, so nothing demonstrated why it was needed. The engine's captures approximate per-finding satisfaction, because a sub-expression that evaluated false contributes none. That covers most shapes, including every one the suite exercised. It breaks when sibling conditions each succeed for a different finding: both contribute captures, and intersecting them demands a single finding that passed every condition, so nothing is reported. Add that case. With 'p AND (g1 OR g2)' over two findings that each satisfy one guard, the intersection reports none and per-finding evaluation reports both. It is now the only test that fails when the filter is disabled, which is the justification the code was missing. Also rename the generated expression local, since 'expression' sitting next to rule.Expression made it hard to see that an authored expression is consumed at all, and record on FilterCapturesByExpression why it cannot defer to the engine.
A label such as AND is read as an operator by the per-finding parser and as an operand by the engine, so the rule verifies, matches, and then reports nothing because the expression fails to parse. Reject the reserved names outright, case insensitively, rather than making the two evaluators agree on a name no author should be using anyway.
The scoping design from #650, declaring a condition on the pattern it guards, replaces the applies_to_patterns list this branch originally added.
f0a22d0 to
8fdb076
Compare
A label on a regex or regexword pattern, or on a pattern level condition, was discarded when the clause was built: the clause kept its automatic numeric label instead. An expression naming such a label then resolved to nothing and the engine threw while evaluating the rule, so the feature only worked for string and substring patterns and for rule level conditions. Pattern clauses no longer need numeric labels. The pattern a finding belongs to travels on the clause's PatternIndex, added earlier in this branch; the comment claiming otherwise described the code as it was before that. Correct the comment along with the behaviour.
The authored expression was handed straight to the engine, which evaluates it once for the file. A
negation was therefore satisfied by any single compliant finding, and a rule like
cookie AND NOT (secure AND httponly)
reported nothing at all on a file holding one correctly hardened cookie beside a vulnerable one.
That is the shape the feature exists to express, and it was the worked example in the docs.
The engine is now asked a weaker question: a plain disjunction of every clause, which is true when
the file is worth examining and still runs each clause so it contributes its captures. The authored
expression stays on the rule and decides, per finding, what is reported.
Three smaller defects found in the same review are fixed here because they sit in the code this
change touches:
Because the engine no longer sees the authored expression, it can no longer report operands that
name nothing, and an unresolved operand is simply false when findings are judged. Verification now
checks every operand against the rule's own labels, which also rejects bare numbers that used to
resolve through the engine's clause index fallback.
Counting parentheses to zero accepted 'a) AND (b', which closes a group it never opened and throws
when evaluated. The count is now also required never to go negative.
The parsed expression was cached in two fields, so a thread scanning in parallel could see a new
source paired with a stale or null parse and silently drop that rule's findings. Both now publish
together behind one reference.
Satisfiability checking was exponential in the number of conditions, up to 65536 assignments per
pattern, and above sixteen conditions it silently checked nothing at all. It now runs on a fixed
budget of evaluations and simply stops looking when that is spent.
Two existing tests changed with the behaviour. The generation test now asserts the split between the
two expressions rather than verbatim passthrough. The resilience test provoked its failure with an
unbalanced authored expression, which no longer reaches the engine, so it corrupts a label instead;
what it covers, that one unanalysable file does not abandon the scan, is unchanged.
Adds boolean expression support to the rules engine, so a rule can be described as an explicit expression over named patterns and conditions instead of the single hardcoded
(P0 OR P1 ...) AND C0 AND C1 ...shape. Along the way it fixes several bugs in the surrounding code.Closes #524.
Supersedes #650 — that branch is now the base of this one, so #650 can be closed unmerged. #652 was merged into #650 and comes along with it.
Relationship to #650
#650 fixed
WithinOperationreturning after its first capture, and added pattern level conditions. This branch needed the same code, and the two fixes overlapped enough that landing them separately would have meant resolving the same conflicts twice. So #650's two commits are replayed ontomainfirst and this work is rebased on top, giving one reviewable history.Where the two designs collided, the rule was: #650 wins on condition mechanics, this PR wins on expressions.
GovernedMatchesfix is kept over the deduplication approach originally used here; that commit is now tests only.pattern.conditionsis kept for scoping a condition to one pattern. Theapplies_to_patternsfield originally proposed here is dropped entirely — the two solved the same problem andpattern.conditionsneeds no new label resolution. Its tests were rewritten against nested conditions, so the capability stays covered.FilterCapturescombines both: Fix conditions dropping all but the first pattern's findings #650's gate semantics inside the sync/async unification from this branch.search_incombines both: Fix conditions dropping all but the first pattern's findings #650 correctly removedfile(never handled byGenerateCondition), and the finding-region constraints from this branch sit on top.Reviewing this
It is a big diff, so the commits are ordered to be read in sequence and each one builds and passes on its own.
Commits 1, 2, 4, 7 and 12 are standalone bug fixes with no dependency on the feature.
The highest risk commit is 10, because it changes which findings get reported. The legacy path is untouched when a rule has no
expression.What rule authors get
Two independent additions, either usable alone.
conditionson a pattern (from #650) scopes a condition to the pattern it guards, so guarding one pattern no longer requires splitting the rule in two:expressioncombines labels withAND,OR,XOR,NAND,NOR,NOT:That one fires when either required flag is missing. Expressed as two negated conditions it would instead mean "neither flag is present", which stays silent on partially hardened code, the common real world defect.
Bugs fixed
From #650:
WithinOperationreturned from inside its loop over captures. Seven shipped rules are under reporting today:AI016300,AI036000,AI036622,AI038210,AI038500,AI080001,AI084000.From this branch:
Data, so deriving the index from the position in that list always gave 0.MatchingPatternand confidence filtering resolved against the wrong pattern.AnalyzeFileAsynctook the union of within clause captures instead of intersecting them, so it emitted duplicates and findings the conditions should have excluded. It also never populatedStartLocationColumn/EndLocationColumnand had an off by one bounds check.ScanState.Errorrecord and the scan continues.Commit 13 then removes the duplicated scan loop that allowed 4 and 5 to happen, so both entry points share one implementation.
Behaviour changes to be aware of
Existing rule JSON is unchanged and continues to validate, but results are not bit identical, because the bug fixes correct which findings are reported:
No rule shipped with Application Inspector uses
overrides, so default scan output is unaffected by that last one. Anyone baselining scan output should expect a diff. This is why the version goes to 1.10 rather than a patch; the new public API on the rules engine is additive and independently forces a minor.Schema stays
rule-schema-v1.jsonsince every addition is backward compatible; it carries a"version": "1.1.0"annotation.Verification
verifyrulesreports zero failures across the shipped rule setOatExpressionSemanticsTestspins the engine behaviour the translation relies on: no operator precedence, parentheses group and inherit accumulated captures, duplicate labels evaluate falseNotes for the reviewer
a OR b AND cmeans(a OR b) AND c. Verification rejects an expression that mixes operators at one level without parentheses, which should stop that being a footgun in practice.RulesVerifierkeeps its own balance check.p AND (g1 OR g2)whereg1andg2match on separate lines. OAT's accumulated captures report nothing there.ConditionsSatisfiedByDifferentFindings_ReportBothis the single test that fails if that filtering is removed.NOTacross the whole file, so one compliant finding suppresses vulnerable siblings —NegationIsJudgedPerFinding_NotAcrossTheFilecovers this. A consequence is that the engine never sees the authored operands, so verification checks them against the rule's own labels rather than relying on OAT.overrides.