Skip to content

Boolean expression support in the rules engine - #654

Open
Giulia Stocco (gfs) wants to merge 26 commits into
mainfrom
feature/rule-boolean-expressions
Open

Boolean expression support in the rules engine#654
Giulia Stocco (gfs) wants to merge 26 commits into
mainfrom
feature/rule-boolean-expressions

Conversation

@gfs

@gfs Giulia Stocco (gfs) commented Aug 16, 2026

Copy link
Copy Markdown
Collaborator

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 WithinOperation returning 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 onto main first 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.

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.

# Commit Kind
1 Fix conditions dropping all but the first pattern's findings bug fix (#650)
2 Make pattern-level conditions actually gate their pattern (#652) bug fix (#650)
3 Add expression and label to the rule model model + schema
4 Report the pattern that actually matched for string patterns bug fix
5 Pin the expression semantics the rules engine depends on tests only
6 Add regression tests for conditions across multiple pattern captures tests only
7 Unify the synchronous and asynchronous analysis paths bug fix
8 Translate author supplied labels and expressions feature
9 Validate rule expressions and labels feature
10 Report the findings that satisfy the rule expression feature
11 Document rule expressions docs
12 Apply rule overrides consistently in both analysis paths bug fix
13 Share one implementation between the sync and async analysis paths refactor
14 Take OAT 1.2.95 for the parenthesis balance fix dependency
15 Bump minor version for the rules engine work release
16–26 Review response: schema alignment, nesting bounds, defect fixes, hardening, docs review

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.

conditions on a pattern (from #650) scopes a condition to the pattern it guards, so guarding one pattern no longer requires splitting the rule in two:

"patterns": [
  {
    "pattern": "curl", "type": "substring", "label": "curl",
    "conditions": [
      { "pattern": { "pattern": "--tlsv1.3", "type": "substring" },
        "search_in": "same-line", "negate_finding": true }
    ]
  },
  { "pattern": "wget", "type": "substring", "label": "wget" }
]

expression combines labels with AND, OR, XOR, NAND, NOR, NOT:

"expression": "cookie AND NOT (secure AND httponly)"

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:

  1. Multi pattern rules with a condition reported only the first pattern's findings. WithinOperation returned from inside its loop over captures. Seven shipped rules are under reporting today: AI016300, AI036000, AI036622, AI038210, AI038500, AI080001, AI084000.
  2. Pattern level conditions did not gate their pattern (Make pattern-level conditions actually gate their pattern #652).

From this branch:

  1. String and substring patterns always reported pattern index 0. Each becomes its own clause holding a single element of Data, so deriving the index from the position in that list always gave 0. MatchingPattern and confidence filtering resolved against the wrong pattern.
  2. The async path over reported. AnalyzeFileAsync took the union of within clause captures instead of intersecting them, so it emitted duplicates and findings the conditions should have excluded. It also never populated StartLocationColumn/EndLocationColumn and had an off by one bounds check.
  3. Overrides used different overlap rules per path. Sync required only that the overridden finding start inside the overriding one; async required containment. Settled on containment, which is what the sync code's own comment described.
  4. One unreadable file abandoned the entire scan. Two per file paths had no exception handling, so a single failure propagated out of the scan loop. Each file now fails to its own ScanState.Error record 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:

  • multi pattern rules with conditions no longer drop findings, affecting the seven default rules listed above (more results)
  • string pattern matches now resolve to the pattern that actually matched
  • the async path no longer over reports (fewer results)
  • overrides now require containment (previously suppressed findings may return)

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.json since every addition is backward compatible; it carries a "version": "1.1.0" annotation.

Verification

  • 491 tests pass on net9.0 and net10.0; full solution builds clean including netstandard2.1
  • verifyrules reports zero failures across the shipped rule set
  • every one of the 26 commits was independently built and tested in a clean worktree, growing 354 to 491 tests
  • each bug fix was confirmed by reverting the fix and watching the new test fail
  • a golden test asserts the generated expression string is byte for byte unchanged for every shipped rule
  • OatExpressionSemanticsTests pins the engine behaviour the translation relies on: no operator precedence, parentheses group and inherit accumulated captures, duplicate labels evaluate false

Notes for the reviewer

  • Expressions have no operator precedence and fold strictly left to right, so a OR b AND c means (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.
  • Evaluating an unbalanced expression still throws even on OAT 1.2.95, which only fixed the validation gap (Add validation for unbalanced parentheses in rules OAT#393). Verification is therefore the gate that keeps a malformed rule away from the analyzer, and RulesVerifier keeps its own balance check.
  • Per-finding expression evaluation earns its place on one case: sibling conditions each satisfied by a different finding, e.g. p AND (g1 OR g2) where g1 and g2 match on separate lines. OAT's accumulated captures report nothing there. ConditionsSatisfiedByDifferentFindings_ReportBoth is the single test that fails if that filtering is removed.
  • A rule carries two expressions. The engine gets a plain disjunction of every clause, which only decides whether a file is worth examining; the authored expression stays on the rule and decides, per finding, what is reported. Handing the authored expression to the engine instead evaluates NOT across the whole file, so one compliant finding suppresses vulnerable siblings — NegationIsJudgedPerFinding_NotAcrossTheFile covers 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.
  • Still outstanding: DevSkim has not been built against this. Worth checking its rule set verifies unchanged, and whether any of its rules use overrides.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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, and applies_to_patterns support 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

Comment thread rule-schema-v1.json Outdated
Comment thread AppInspector.RulesEngine/RuleProcessor.cs Outdated
Comment thread AppInspector.RulesEngine/RuleProcessor.cs Outdated
@gfs

Copy link
Copy Markdown
Collaborator Author

Copilot Generated COMPASS SDL Security Review:

SDL Security Review

Scope: PR #654 feature/rule-boolean-expressionsmain ("Boolean expression support in the rules engine"). Application source only; tests, README, version.json reviewed and clean. Deps are exact-pinned (Microsoft.CST.OAT 1.2.87 → 1.2.95) — no issue.

Note: git/gh-via-git are blocked by the active sandbox policy (/Users/giulia/Library/Caches/github-copilot-git-2.53.0-4/etc/gitconfig). I used the GitHub REST API for the diff. Consider allowing that path via /sandbox.


🔴 Blocking

AppInspector.RulesEngine/RuleExpression.cs — unbounded parser recursion (uncatchable stack exhaustion)

ParseSequence and ParseTerm are mutually recursive with no depth limit; recursion depth equals parenthesis nesting depth:

if (tokens[index].Kind == TokenKind.OpenParen)
{
    index++;
    inner = ParseSequence(tokens, ref index);   // line 140 -> ParseTerm -> ParseSequence ...

Tokenize (lines 61–72) happily emits one OpenParen token per ( character within a single whitespace-delimited token, so a rule containing "expression": "((((…(0)…))))" yields tens of thousands of nested frames.

Nothing in the chain bounds this:

  • rule-schema-v1.json declares expression as a bare {"type": "string"} — no maxLength, no pattern.
  • RulesVerifier.ValidateExpression (added in this PR) checks only balance (depth != 0) and mixed operators. Deeply nested but balanced input with a single label and no operators passes cleanly.
  • AbstractRuleSet.cs:159 forwards the raw string verbatim:
    Expression = string.IsNullOrWhiteSpace(rule.Expression) ? expression.ToString() : rule.Expression

Attack vector / impact: an attacker who controls a rule file — a third-party rule pack (the repo ships packrules for exactly this distribution model), a -r/--custom-rules-path input, or rules supplied to the AppInspector.RulesEngine NuGet library by a hosting service — triggers StackOverflowException. In .NET Core this exception cannot be caught; it terminates the process immediately, bypassing every try/catch in the analyze pipeline. For an embedding scan service this is an unauthenticated, one-shot, full-process DoS costing ~200 KB of input. --disable-custom-rule-verification removes even the partial verifier gate.

Fix: thread a depth counter through ParseSequence/ParseTerm and return null past a cap (e.g. 64); add a matching nesting/length bound in ValidateExpression and a maxLength to the schema so this is rejected at load time rather than at evaluation time.

Assumption stated explicitly: this is 🔴 on the basis that rule content can originate outside the operator's trust boundary. If rule files are strictly trusted-operator-only in your threat model, downgrade to 🟡 — the fix is cheap either way.


🟡 Warning

AppInspector.RulesEngine/RuleExpression.cs:20,34 — unbounded process-wide cache

private static readonly ConcurrentDictionary<string, RuleExpression?> Cache = new();
...
return Cache.GetOrAdd(expression, static toParse => { ... });

Static, never evicted, keyed on the raw expression string, and it also stores null for every malformed expression. It outlives any RuleSet instance, so a long-running host that loads many or repeated rulesets grows memory monotonically, with the key material controlled by rule content. Bound the cache (size-capped/LRU) or scope it to the rule set.

AppInspector.RulesEngine/RuleProcessor.cs:264 + RulesVerifier.ValidateExpression — silent fail-open on bad expressions

if (oatRule.Expression is null || RuleExpression.TryParse(oatRule.Expression) is not { } expression)
{
    return new List<(int, Boundary)>();
}

A malformed expression discards every finding for that rule and logs nothing — the rule silently reports zero results even though the engine considered it matched. Relatedly, ValidateExpression verifies clause labels, duplicates, and applies_to_patterns targets, but never checks that labels referenced by the expression resolve to a real clause. An unresolved label evaluates to false via reportedByLabel.TryGetValue(...), so a single typo silently disables a detection rule (or, under NOT, silently makes it always-true).

In a security-analysis tool a silently disabled rule is a fail-open detection gap. Log an error in the TryParse-failed branch, and add unresolved-label detection to ValidateExpression for parity with the existing applies_to_patterns check.


Reviewed and clear

  • OatSubstringIndexOperation / OatRegexWithIndexOperation / OatRegexWithIndexClause / OatSubstringIndexClause: replacing Convert.ToInt32(clause.Label) with an explicit PatternIndex removes a parse-failure path; BuildMatchRecord still range-checks patternIndex before indexing Patterns[].
  • WithinOperation / WithinClause: Guards() defaults to null (guards everything), so existing rules keep prior semantics.
  • RemoveOverriddenMatches: containment logic is byte-identical to main; the PR only de-duplicates the sync/async copies. Its O(n²) behavior is pre-existing, not a changed-line finding.
  • Schema: pattern/condition label correctly constrained to ^[^\s()]+$.
  • No secrets, crypto, injection, path-traversal, SSRF, auth, or cookie changes in this diff.

REQUEST CHANGES

@gfs
Giulia Stocco (gfs) requested a balanced review from Copilot August 16, 2026 15:43
@gfs
Giulia Stocco (gfs) marked this pull request as ready for review August 16, 2026 15:43

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 p can verify successfully, but WithinOperation receives no prior captures for c, 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

Comment thread AppInspector.RulesEngine/RulesVerifier.cs Outdated
Comment thread AppInspector.RulesEngine/RulesVerifier.cs Outdated
Comment thread AppInspector.RulesEngine/RuleExpression.cs Outdated
Comment thread AppInspector.RulesEngine/RuleProcessor.cs

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_patterns list is currently treated like omission because this guard requires Count > 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-before to the schema exposes an existing off-by-one in WithinOperation: its boundary ends at finding.Index and 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

Comment thread AppInspector.RulesEngine/RulesVerifier.cs
Comment thread AppInspector.RulesEngine/RuleProcessor.cs

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_patterns array takes this false branch, leaving appliesTo as null; WithinClause.Guards defines null as 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 one p finding has a and another has b, OAT sees both conditions as true and suppresses the entire RuleCapture; FilterCapturesByExpression never 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 apply RuleExpression per 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 b then 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 foo and 1, the expression 0 OR 1 therefore verifies and OAT captures both patterns, but FilterCapturesByExpression treats 0 as false and silently drops every foo finding. Validate every operand against patternLabels/conditionLabels directly 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

Comment thread AppInspector.RulesEngine/RulesVerifier.cs
@gfs
Giulia Stocco (gfs) force-pushed the feature/rule-boolean-expressions branch from 195398d to f0a22d0 Compare August 18, 2026 20:05
Giulia Stocco (gfs) and others added 15 commits August 18, 2026 13:07
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.
@gfs
Giulia Stocco (gfs) force-pushed the feature/rule-boolean-expressions branch from f0a22d0 to 8fdb076 Compare August 18, 2026 20:07
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.
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.

Expand Condition Functionality

2 participants