Skip to content

fix(operators): a range inside a list literal is compared, not evaluated, so a denylist admits every value - #747

Open
awsmadi wants to merge 4 commits into
aws-cloudformation:mainfrom
awsmadi:fix/range-in-list-is-a-range
Open

awsmadi wants to merge 4 commits into
aws-cloudformation:mainfrom
awsmadi:fix/range-in-list-is-a-range

Conversation

@awsmadi

@awsmadi awsmadi commented Sep 11, 2026 •

Copy link
Copy Markdown
Contributor

A range inside a list literal on the right of in or not in was decided by Vec::contains, which compares by PartialEq, and PartialEq has no arm relating a value to a range. So for a Port of 85, Port not in [r[80,90]] passed and Port in [r[80,90]] failed: a denylist of port ranges that admitted every port. A regex inside a list literal went down the same path into an unwrap() on fancy_regex's is_match, and a bare regex that compare_eq could not evaluate reached the _ => unreachable!() arm of match_value, so a pattern that exhausted the backtrack limit aborted the whole run instead of failing its clause.

What changed, one commit each:

  • d22c58d: the scalar arm of contained_in in guard/src/rules/eval/operators.rs asks compare_eq after PartialEq for each right-hand element, so a range nested in a list literal is decided by the range table; the path_value.rs change is the impl Eq doc comment, which claimed a range nested in a list literal never reaches eq; it does, through Vec::contains, and the comment now says so.
  • 25d72d0: match_value turns a RegexError into the clause's recorded reason instead of hitting unreachable!(), the scalar arm of contained_in reads that error rather than discarding it, and the String/Regex arms of PartialEq in path_value.rs change from unwrap() to unwrap_or(false). That last change has a cost, written beside the arms: a keys == /re/ key filter whose pattern cannot be evaluated now selects nothing for that key instead of panicking.
  • 42e9766: both arms of contained_in, scalar and list-valued, go through one walk, is_one_of, so Ports not in [r[80,90]] with Ports: [85] now fails as well; the branch for a left-hand list against a list of lists still uses rhsl.contains and is unchanged; its path_value.rs hunk updates the impl Eq doc comment to say both arms now reach compare_eq through is_one_of.
  • daffd2a: is_one_of skips the PartialEq pass for a regex element, so a pattern is compiled and matched once, in compare_eq; no verdict changes.

Tests, all in guard/src/rules/eval_tests.rs: a_range_inside_a_list_literal_is_a_range (wrapped and unwrapped, both polarities, a range beside a plain element) and a_range_in_a_list_denylist_denies_a_list_valued_property pin the range verdicts; a_regex_that_exceeds_the_backtrack_limit_fails_the_clause_instead_of_aborting and a_regex_in_a_list_literal_fails_the_clause_instead_of_aborting run the clause under catch_unwind and assert FAIL with a message saying the regular expression could not be evaluated; a_denylist_refuses_a_value_it_could_not_evaluate_in_either_spelling asserts the same FAIL and message for a scalar and a list-valued Size, without catch_unwind; an_ordinary_regex_comparison_is_unchanged is the control. I also removed probe_vacuous_pass_record_shape, a test of mine that asserted nothing. The Pull Request workflow run 40575627654 passed on this head.

Please review. The list-of-lists branch is unchanged here and is covered by #759.

awsmadi added a commit to awsmadi/cloudformation-guard that referenced this pull request Sep 11, 2026
This reverts commit d7f01ec.

The fix was extracted to aws-cloudformation#747, which carries a patch-identical commit, so it
was open in two places at once. Removing it here leaves aws-cloudformation#747 as the only one,
where it can be reviewed on its own instead of inside a 153-commit branch.

Reverted rather than dropped from history: this branch is already pushed, and
rewriting it would need a force-push. A revert reaches the same resulting diff
with a fast-forward, and cancels out cleanly if this branch is squash-merged --
the squash then contains neither the change nor its revert.

Conflict resolved in guard/src/rules/eval/operators.rs. A later commit on this
branch, e220608 ("Give an unevaluatable regex a clause verdict instead of
aborting the run"), rewrote the same expression into a loop so it could read
compare_eq s RegexError instead of discarding it. That arm exists only because
this commit put a compare_eq call there, and compare_eq is where the range table
lives, so there is no version of it that keeps the regex diagnostic and still
reverts the range behaviour -- the two are the same call. The pre-fix form,
rhsl.contains(rest), is restored.

The two helpers e220608 added, unanswerable_reason and not_comparable_because,
are kept: match_value calls both independently, so neither becomes dead.

Consequence to expect: e220608 s
a_regex_in_a_list_literal_fails_the_clause_instead_of_aborting covers the
IN [/re/] spellings through this arm, and they no longer reach compare_eq, so it
loses its subject. It does not panic -- e220608 also made PartialEq::eq answer
unwrap_or(false) in guard/src/rules/path_value.rs, a separate region this revert
does not touch.

guard/src/rules/eval_tests.rs and guard/src/rules/path_value.rs auto-merged and
are the exact inverse of what this commit did to them: +42/-48 and +3/-9 against
its +48/-42 and +9/-3. This commit s path_value.rs change was doc comment only.
Reverting it also restores probe_vacuous_pass_record_shape, the assertion-free
test this commit removed.
awsmadi added a commit to awsmadi/cloudformation-guard that referenced this pull request Sep 14, 2026
…ing the run"

This reverts commit e220608.

Moved to aws-cloudformation#747, which now carries it as a cherry-pick with the same patch id. It
was written on top of aws-cloudformation#747's `compare_eq` call in `contained_in` -- the operators.rs
blob it edits is the blob aws-cloudformation#747's commit produced -- so with aws-cloudformation#747 reverted here its
list-literal arm has nothing to read. Three cases of
`a_regex_in_a_list_literal_fails_the_clause_instead_of_aborting` failed against
this branch as a result: `IN [/re/]` no longer reaches `compare_eq`, so no reason
is recorded, and `NOT IN [/re/]` answers PASS where the test asserts FAIL. On aws-cloudformation#747
the call is there, so the commit means the same thing it meant here.

Reverted rather than dropped from history, for the reason the four reverts below
it give: this branch is already pushed, and rewriting it would need a force-push.
A revert reaches the same resulting diff with a fast-forward, and cancels out
cleanly if this branch is squash-merged.

Conflicts resolved in two files.

guard/src/rules/eval/operators.rs: the revert wanted to restore
`rhsl.iter().any(|elem| elem == rest || compare_eq(rest, elem).unwrap_or(false))`,
which is aws-cloudformation#747's line and not this branch's -- 420ef28 already put
`rhsl.contains(rest)` back. HEAD is kept. The other side would have reinstated the
fix this branch removed, which is the one thing this revert must not do. The rest
of the file reverts without argument: `unanswerable_reason` and
`not_comparable_because` go, and `match_value` gets back its
`Err(Error::NotComparable(reason))` arm and its `_ => unreachable!()`.

guard/src/rules/eval_tests.rs: this commit appended 171 lines at an anchor that has
since gained more. Only those 171 were removed, located as a contiguous block and
compared line for line against what the commit inserted rather than matched by eye.
One later test sits after them, `an_inapplicable_dependent_rule_does_not_fail_the_reference`,
and its 114 lines stay. The resulting change to the file is 171 deletions and no
insertions, the exact inverse of the insertion.

guard/src/rules/path_value.rs auto-merged and is the exact inverse of what this
commit did to it: the two `unwrap_or(false)` arms go back to `unwrap()` under the
comment they carried before.

Consequence to name: on this branch a regex that exhausts fancy_regex's backtrack
budget aborts the process again, at exit 101, and takes the other rules' verdicts
with it. That fix now lives on aws-cloudformation#747 alone, which is the point of moving it.
`contained_in` decided list membership with `Vec::contains`, which compares by `PartialEq`. `PartialEq`
is asked `element == value`, so for a list holding a range it asked `RangeInt == Int` -- the direction
that has no arm, and must not get one, because `eq` has to stay symmetric while membership does not.
A range nested in a list literal therefore matched nothing, in either polarity. For a `Port` of 85:

    clause                        before   fixed
    Port in [r[80,90]]            FAIL     pass
    Port not in [r[80,90]]        pass     FAIL
    Port in [r[10,20]]            FAIL     FAIL    <- control, 85 is outside
    Port not in [r[10,20]]        pass     pass    <- control
    Port in r[80,90]              pass     pass    <- control, unwrapped
    Port in [r[10,20], 85]        pass     pass    <- control, plain element matches

The second row is the one that matters: a denylist of forbidden port ranges admitted every port. The
same question spelled without the list was always answered correctly, because `Port in r[80,90]`
reaches `compare_eq`, and `compare_eq` is where the range table lives. Two spellings of one question
disagreed, and only one of them was ever tested.

So `contained_in` now asks `compare_eq` as well. `eq` is still asked first, and not out of caution: it
is the only one of the two that relates a range to an equal range, so `compare_eq` alone would lose
`%range in [r[80,90]]`. `compare_eq` can add a match, never remove one, which is why nothing else
moves -- an incomparable pair still answers "no match" rather than raising, as `contains` did.

The doc comment on `impl Eq` claimed the opposite of the measurement: that a range nested in a list
literal never arrives at `eq`. It does arrive, through `Vec::contains`, and that is precisely why the
removed membership arms were not reaching it -- they were written for the direction `contains` does not
ask. Corrected, since a comment that states the inverse of the behaviour is worse than none.

Also removes `probe_vacuous_pass_record_shape`, which an earlier commit of mine left behind. It has no
assertions: it prints a record tree and returns `Ok`. It was scaffolding for reading the record shape,
that shape is asserted by real tests now, and a test that cannot fail is worse than absent -- it counts
as coverage.

Verification: 891 tests, from 873 less the two the probe was contributing. Reverting just the
`contained_in` hunk fails three of the ten new cells and names them, so the table is load-bearing
rather than decorative. `clippy -- -D warnings` and `clippy --all-targets -- -D warnings` clean,
`fmt --check` and `typos` clean. The 440-cell matrix moves zero cells. Registry differential against
the merge-base: 190 pairs, 1,956 expectation checks, zero exit-code changes and zero content changes --
no published rule uses a range token at all, so this could not have moved one.
A rules file comparing a property against a regex that holds a lookaround and a nested
quantifier aborted the process at exit 101. Seven spellings reached it: `== /re/`,
`!= /re/`, `IN /re/` and `NOT IN /re/` panicked at operators.rs:216, and `IN [/re/]`,
`NOT IN [/re/]` and `IN [/re/, 5]` panicked at path_value.rs:299. Every other rule in
the file lost its verdict along with the one holding the regex, and with the regex
clause in a `when` gate the file reported nothing at all. All seven now exit 19 naming
the path and the reason, and a sibling rule still reports its own result.

fancy_regex returns a `Result` from `is_match` rather than a `bool` because a
backtracking engine can run out of budget instead of answering. A pattern needs a
lookaround or a backreference to be put on that engine at all, neither being
expressible in the linear automaton, and a nested quantifier then makes the number of
paths to try grow with the length of the subject. `/(?!zzz)(\w+\s?)+!/` against eighty
characters holding no `!` exceeds the limit while the same pattern against fifteen does
not, so a rule that passes review can still abort on a longer UserData or policy
document.

`match_value` had arms for `Ok` and for `Error::NotComparable` and met everything else
with `unreachable!()`. `compare_eq` reports this as `Error::RegexError`, which is not
`NotComparable`, so it landed on the catch-all. The missing arm now routes it where
`NotComparable` already went, so the clause fails and the run reports it. Those two are
the only variants the five comparators produce, the four ordering ones going through
`compare_values` whose every error is `NotComparable`, and the residual arm reports
rather than aborts because no comparator failure justifies discarding the other rules.
fancy_regex's own message is used instead of `RegexError`'s, which reads "Regex
expression parse error" and would describe a pattern that parsed and then ran out of
backtracking.

The second site needed a decision. `PartialEq::eq` returns `bool` and cannot report an
error, which is why the `unwrap` was there, under a comment reading "given that we have
already validated the regular expression". That premise is false: parse-time validation
proves the pattern compiles and says nothing about whether a match completes. Removing
the arms was not available either, because the map key filter in `QueryResolver::select`
decides `keys == /re/` through them, aws_meta_appender depends on that, and five tests
fail if they stop matching. So `eq` answers `false`, and the cost is written down where
it is paid: through `eq` a regex that cannot be evaluated reads as "not equal", and a
key filter selects nothing for that key. docs/KNOWN_ISSUES.md already records the silent
conversion of a suppressed error to `false` under PartialEq as a tracked defect.

The spelling a rule author writes does not pay that cost. `IN [/re/]` reaches
`contained_in`, which already consulted `compare_eq` after `eq`; it now reads
`compare_eq`'s error instead of discarding it, so the bracketed and unwrapped spellings
give the same verdict and the same message. An element that matches still wins over an
element that could not be evaluated, the answer then not having depended on the one that
failed.

Only `RegexError` is promoted in that loop. `NotComparable` keeps its `unwrap_or(false)`
reading deliberately: `NOT IN` against an operand of a kind it cannot be compared with
currently passes, and `incomparable_membership` emits a deprecation notice for that
spelling so rule authors hear about the change before a pipeline does. Promoting it here
landed that change without its notice and moved cells of
`every_operator_and_operand_shape_agrees_with_a_stated_oracle` and
`generated_rule_shapes_hold_the_evaluator_invariants`.

Tests use `catch_unwind`, so the absence of the panic is asserted rather than assumed;
all seven fail against the previous build by reporting the panic, which an assertion on
a returned status could not do, never being reached.

Verified: 1279 tests before and 1309 after with no failures, the 30 added being 15 cases
counted in each of the two suites that compile the module. clippy --release
--all-targets -D warnings, fmt --check and typos are clean. The 2666-pair cross product
of guard/resources/validate shows zero exit-code and zero text differences against the
previous build, as does the 193-pair rule and test cross product of
aws-guard-rules-registry at 6aca96e, which stays at 193 of 193 exiting 0. Ordinary regex
comparisons are byte-for-byte identical across five output formats in both spellings and
both polarities.
@awsmadi
awsmadi force-pushed the fix/range-in-list-is-a-range branch from e8daafa to 25d72d0 Compare October 14, 2026 17:46
} else {
false
match Regex::new(r.as_str()) {
Ok(regex) => regex.is_match(s.as_str()).unwrap_or(false),

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.

Blocking: a list-valued left-hand side still decides IN through PartialEq, so NOT IN [/re/] now passes

This unwrap_or(false) is still reached from the list arm of contained_in, which the PR didn't change. That arm builds diff with rhsl.contains(each) (operators.rs L322), so membership goes through Regex == String here and never reaches compare_eq.

Take Properties.Names NOT IN [/(?!x)((a+)+)b/] where Names is a list. The query doesn't flatten the list, so InOperation sends it to that list arm. On main this line panicked. With this change it returns false, so every element lands in diff. The ListIn negation then sees an empty reverse diff and reports success, and the clause passes with exit 0. IN on the same input fails, but with the generic "was not present" message instead of the regex reason.

So this spelling goes from a loud crash to a silent pass. The template author controls the value, so padding one list element past the backtrack budget gets it through a denylist.

The range fix in d22c58d has the same gap. Ports NOT IN [r[80,90]] with Ports: [85] still passes, because RangeInt == Int returns false.

This contradicts "All seven now exit 19" in the commit message and the doc comment at path_value.rs L356-361. Only the scalar arm (L350) asks compare_eq.

Suggested fix: move the loop at L382-402 into a helper and call it from both arms.

fn membership(value: &PathAwareValue, list: &[PathAwareValue]) -> Result<bool, String> {
    let mut unanswerable = None;
    for elem in list {
        if !matches!(elem, PathAwareValue::Regex(_)) && elem == value {
            return Ok(true);
        }
        match compare_eq(value, elem) {
            Ok(true) => return Ok(true),
            Err(err @ Error::RegexError(_)) => {
                unanswerable.get_or_insert_with(|| unanswerable_reason(err));
            }
            _ => {}
        }
    }
    unanswerable.map_or(Ok(false), Err)
}

// list arm:
// for each in lhsl {
//     match membership(each, rhsl) {
//         Ok(true) => {}
//         Ok(false) => diff.push(Rc::new(each.clone())),
//         Err(reason) => return not_comparable_because(lhs_value, rhs_value, reason),
//     }
// }

Skipping eq for a Regex element also stops each pattern from being compiled and matched twice, since compare_eq already handles Regex/String and Regex/Regex.

Please add eval_tests cases with a list-valued property, Ports: [85] and Names: [<30 a's>], each in both polarities. I traced this through the code at 25d72d0 but haven't run it, and those cases would confirm it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

You're right, and thanks for tracing it through. The list arm decided membership with rhsl.contains(each), so a list-valued left-hand side never reached compare_eq: Ports NOT IN [r[80,90]] with Ports: [85] and Names NOT IN [/(?!x)((a+)+)b/] with a list-valued Names both passed at exit 0 on 25d72d0.

42e9766 moves the per-element walk into one helper that both arms call: PartialEq first, then compare_eq, an element that matches winning over one that could not be evaluated, and a RegexError with no match reported as the clause's reason instead of read as false. It adds eval_tests cases with a list-valued property for [85] against [r[80,90]] and for a thirty-a string against that pattern, in both polarities, asserting the recorded reason for the regex cells and the status for the range cells, with plain-element and out-of-range controls. On 25d72d0 nine of the twenty new cells fail; on 42e9766 all pass. The path_value.rs comment that described the scalar arm as if it were both is reworded.

The nested-list branch (rhsl[0].is_list()) is unchanged here, so Size NOT IN [[/re/]] with a list-valued Size still has the same gap; #759 rewrites that branch, and changing it here as well would widen the conflict between the two. I can fold it into this PR instead if you'd rather have it here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

daffd2a also skips the equality pass for a regex element, as you suggested, so a pattern is compiled and matched once, in compare_eq.

…s of contained_in

The list-valued arm of contained_in decided membership with
Vec::contains, which is PartialEq. PartialEq has no arm that relates a
range to an int, and it reads a regex whose match ran out of
backtracking budget as false. So for a list-valued property,
`Ports NOT IN [r[80,90]]` with `Ports: [85]` passed, and
`Names NOT IN [/(?!x)((a+)+)b/]` passed too, while the scalar arm
already answered both correctly. `IN` failed the clause without saying
the regex could not be evaluated.

Move the per-element walk into is_one_of, which asks PartialEq and then
compare_eq, and call it from both arms through elements_not_matched. An
element that matches wins over one that could not be evaluated, and a
RegexError with no match becomes the clause's reason instead of being
read as a miss. The nested-list branch is unchanged.

Reword the path_value.rs comment that described the scalar arm as if it
were both arms.

Add eval_tests cases with a list-valued property in both polarities: a
range inside a list literal and an unevaluatable regex, each with
plain-element and out-of-range controls so a constant answer cannot pass.
The equality pass in is_one_of compiled and matched a regex element before compare_eq did the same work for the same answer. Skip that pass for a regex element; no verdict changes.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants