Skip to content

fix(matchers): refuse regexes that fail only at the end of a run - #213

Merged
donislawdev merged 2 commits into
masterfrom
fix/regex-guard-and-apply-scope
Sep 28, 2026
Merged

donislawdev merged 2 commits into
masterfrom
fix/regex-guard-and-apply-scope

Conversation

@donislawdev

Copy link
Copy Markdown
Owner

What was wrong

1. The slow-regex guard only ever tried runs the pattern matches. A re: expression is timed on repeated runs (1111, aaaa, 1:1:, a.a.) before it is accepted. A repeat inside a repeat only blows up when such a run is followed by something that makes the whole match fail. On a bare run it matches at once, so the whole shape walked through. Measured on 0.7.0 code, all accepted:

pattern where it runs cost once accepted
re:^(\w+\s?)+$ process names 558 ms for one name (StartMenuExperienceHost.exe)
re:^([\d:]+)+$ destination address, every packet 2.7 s per packet on a real IPv6 address
re:(a+)+$, re:^(\d+)+$, re:^((a|b|c)+(d|e)*)+$ any field refused once a tail is added

A pattern near the budget, re:(a|1)*.*.*z, was refused 20 times in 40. The same text could pass when the form checked it and fail when "Apply" compiled it again.

2. re raises more than re.error. re:a{99999999999} raises OverflowError, and a few thousand nested groups raise RecursionError. Every caller of parse_matcher catches only ValueError. The results:

  • --dry-run exited 1 with a traceback instead of the CONFIG code;
  • the expression tester raised although its docstring promises it never does;
  • the Control page raised on every keystroke.

3. An expression that could not be read at apply switched its field off. For the destination and the target process, off means every connection is impaired. A field that could not be read therefore widened the session to all traffic.

The fix

  • Probe tail. matchers._REGEX_PROBE_TAILS = ("", "!"): every run is also tried followed by a character none of the probe alphabets contains.
    • Every pattern above is now refused within 20 characters, and (a|1)*.*.*z in 40 of 40 runs.
    • Checking eleven ordinary patterns now costs 0.021-0.044 ms instead of 0.013-0.025 ms.
    • Patterns that still pass cost at most about 1 ms per search at 256 characters.
  • One exception choke point. re.error, OverflowError and RecursionError from re.compile all become the ordinary "not a valid regular expression" ValueError, in the one function every caller passes through.
  • Accepted patterns are kept. _accepted_regex uses functools.lru_cache(maxsize=256).
    • lru_cache keeps nothing for a call that raises, so only acceptances are remembered.
    • A pattern accepted once stays accepted for the life of the process, so the form's answer and Apply's answer can no longer differ.
    • A refusal is judged again every time.
    • Later applies, scenario steps and keystrokes no longer pay for the probe.
  • Apply never widens the scope.
    • A destination or block expression that cannot be read at apply leaves that field as it was.
    • apply_targeting keeps the target in force instead of calling set_target(False).
    • An empty field still switches its part off on purpose, and the missing-psutil branch is unchanged.
    • The log line log.filter_skipped now says the field was left as it was, in all three languages.
  • Comments and docs:
    • the claim that ^(a+)+$ is harmless is corrected in the code comment and a test docstring;
    • a comment said the capture heartbeat covers long process names. Process patterns never run per packet, and the comment now gives the measured bound instead;
    • the one known limit is written down: a repeat that explodes only on a single letter the probes do not contain, such as (x+x+)+y;
    • README has a new item under "Cases that trip people up".

exprtest.MAX_VALUE_CHARS stays 256. With the tail, the shapes that still pass cost at most about 1 ms per search at that length, and cutting it to the 45 characters the ladder covers would refuse long process names the tester exists to try. The comment now gives the measurement.

Tests rewritten on purpose

  • test_matchers.py::test_the_patterns_a_person_would_actually_write_are_still_accepted listed ^((a|b|c)+(d|e)*)+$ as fine. It is the ladder's blind spot, not a good pattern. It moved to the refused list, and the unambiguous ^((a|b|c)(d|e)*)+$ took its place.
  • test_settings_config_scenario.py::test_apply_settings_bad_expression_disables_dest_targeting and ..._blocking pinned "switch it off". They are now test_a_bad_destination_at_apply_leaves_the_previous_one_in_place and test_a_bad_block_at_apply_leaves_the_previous_one_in_place. Each covers a fresh engine and one that already had a value.
  • test_processes.py::test_apply_targeting_logs_and_disables_on_a_bad_expression is now ..._keeps_the_target_on_a_bad_expression.

New guards

  • test_matchers.py:
    • test_a_pattern_that_fails_only_at_its_end_is_refused_too;
    • test_a_pattern_the_parser_cannot_build_is_a_value_error_on_every_kind;
    • test_an_accepted_pattern_is_not_judged_again.
  • test_nettools_exprtest.py::test_a_regex_re_cannot_build_is_a_verdict_not_an_exception.
  • test_cli_runtime.py::test_exit_code_config_for_bad_input: two new cases, a regex re cannot build and one too slow per packet.
  • Six new mutation entries (the tail, the exception list, the cache, and the three "leave it as it was" branches). All six were run with tools/mutate.py and all were caught. The two existing guard mutations keep their anchor lines unchanged.

Checks run locally

  • internal_tools/guards.py --strong --run --lint: 97 test files, plus ruff and mypy on the changed modules. It found two failures, both from the nesting-depth ratchet in test_code_shape.py: the tail loop made _blows_the_budget the twelfth function nested four levels deep, against a frozen count of eleven.
  • The probes are now built once at import as a tuple. That flattens the function and saves building the strings on every check. After that change:
    • the shape tests, the matcher tests and the mutation registry pass;
    • the three ladder mutations were run again and all were caught.
  • After merging master (which now includes the fail-open change), the tests of both changes pass (180).

The full suite was not run locally. CI runs it on Linux and Windows.

🤖 Generated with Claude Code

donislawdev and others added 2 commits September 28, 2026 23:59
The slow-regex guard timed a pattern only on repeated runs it matches,
so a repeat inside a repeat, which blows up when the run is followed by
something that makes the match fail, walked through. Measured on 0.7.0:
^(\w+\s?)+$ spent 558 ms on one process name, and ^([\d:]+)+$ in the
destination field 2.7 s per packet on a real IPv6 address. A pattern
near the budget was refused 20 times in 40, so the form could accept a
text that Apply then refused.

- The ladder also tries every run followed by a character no probe
  alphabet contains; all of the above are refused within 20 characters.
- re.error, OverflowError and RecursionError from re.compile all become
  the ordinary "not a valid regular expression" ValueError. The last two
  escaped every caller: --dry-run exited 1 with a traceback, the
  expression tester raised and the Control page raised per keystroke.
- Accepted patterns are kept (lru_cache keeps nothing that raised), so
  validation and apply give the same answer; a refusal is judged again.
- At apply, a destination, block or target expression that cannot be
  read leaves the field as it was instead of switching it off, which
  for a destination or a target meant impairing all traffic.

Three tests that pinned the old behaviour are rewritten on purpose; new
tests and six mutation entries guard the change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Sep 28, 2026

Copy link
Copy Markdown

Warning

Review limit reached

Next included review available in 23 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Repository UI (base), Organization UI (inherited)

Review profile: ASSERTIVE

Plan: Advanced

Run ID: e4104e11-62e1-4041-bd30-c4ba98746d8b

📥 Commits

Reviewing files that changed from the base of the PR and between c333071 and b84a1a8.

📒 Files selected for processing (14)
  • CHANGELOG.md
  • README.md
  • beantester/matchers.py
  • beantester/nettools/exprtest.py
  • beantester/settings.py
  • lang/en.json
  • lang/pl.json
  • lang/zh.json
  • tests/test_cli_runtime.py
  • tests/test_matchers.py
  • tests/test_mutation_registry.py
  • tests/test_nettools_exprtest.py
  • tests/test_processes.py
  • tests/test_settings_config_scenario.py

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@donislawdev
donislawdev merged commit 420caf8 into master Sep 28, 2026
15 checks passed
@donislawdev
donislawdev deleted the fix/regex-guard-and-apply-scope branch September 28, 2026 22:08
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.

1 participant