Skip to content

AhoCorasick with regex literals (#3073) - #3145

Open
corkamig wants to merge 4 commits into
mandiant:masterfrom
corkamig:feature/string-literal-prefilter
Open

AhoCorasick with regex literals (#3073)#3145
corkamig wants to merge 4 commits into
mandiant:masterfrom
corkamig:feature/string-literal-prefilter

Conversation

@corkamig

Copy link
Copy Markdown
Contributor

An Aho-Corasick automaton is generated from regex literals bigger than 3.
No performance hit should happen on smaller files.
This patch has been kept to a minimum of maintenance, while speeds up of 16-32x can be expected on files with a lot of detected strings.

Checklist

@williballenthin williballenthin left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

this is cool!

do you have any benchmark results that you can share?

Comment thread CHANGELOG.md
# Change Log

## master (unreleased)
- Aho-Corasick automaton generation and scan of regex literals to speed up rule matching corkami@google.com

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

reference original issue, too

Comment thread requirements.txt
viv-utils==0.8.0
vivisect==1.3.2
msgspec==0.21.1
pyahocorasick>=2.0

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

requirements.txt should contain pinned versions, not ranges

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

(we need to add this advice to AGENTS.md, too)

Comment thread capa/rules/__init__.py
Comment on lines +72 to +75
try:
import re._parser as re_parser
except ImportError:
import sre_parse as re_parser # type: ignore

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

move imports to top of file, unless there's an import cycle or good reason for it (is there?).

also, what is sre_parse?

@williballenthin williballenthin Aug 12, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

ah, i guess maybe you use this as a sort of feature flag to signal when to use A-C matching. please use another mechanism for this. see later comment.

Comment thread capa/rules/__init__.py
Comment on lines +97 to +113
if op == re_parser.LITERAL:
cur_run.append(chr(av))
elif op == re_parser.SUBPATTERN:
sub_ast = av[-1] if isinstance(av, (tuple, list)) else av
sub_runs = walk_ast(sub_ast)
flush()
runs.update(sub_runs)
elif op in (re_parser.MAX_REPEAT, re_parser.MIN_REPEAT):
min_rep, _max_rep, sub_ast = av[0], av[1], av[2]
if min_rep >= 1 and len(sub_ast) == 1 and sub_ast[0][0] == re_parser.LITERAL:
cur_run.append(chr(sub_ast[0][1]))
else:
flush()
if min_rep >= 1:
sub_runs = walk_ast(sub_ast)
runs.update(sub_runs)
elif op == re_parser.BRANCH:

@williballenthin williballenthin Aug 12, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

it would be helpful, but not required, to see examples of the state of the regex or something in code comments here, so readers can more easily understand the code.

like:

# regex:  (aaa|bbb)
#          ^   ^     pointing here, next step will be XYZ

or whatever makes sense to understand the parsing/walking

Comment thread capa/rules/__init__.py
Comment on lines +85 to +91
def flush():
nonlocal cur_run
if cur_run:
s = "".join(cur_run)
if len(s) >= _STRING_LITERAL_MIN:
runs.add(s)
cur_run = []

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

note to self: walk_ast is probably not performance sensitive (O(#of rules), run upon initial rule parsing), so its probably ok to create function objects and closures here.

Comment thread capa/rules/__init__.py
else:
lit_set = req_lits
for lit in lit_set:
if len(lit) >= _STRING_LITERAL_MIN:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

this is already part of the API contract for the _compute_best_required_literals so i think we can drop this check.

Comment thread capa/rules/__init__.py Outdated
Comment on lines +2311 to +2312
if isinstance(wanted_feature, capa.features.common.Substring):
candidate_rule_names.add(rule_name)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

do we have to check that the casing matches here? i think the substring literal as added as lowercase, though the found string may not have the same case?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

str(string_feature.value)[_end_index-len(wanted_feature.value):_end_index] == wanted_feature.value

or something

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

worst case, we do a little too much work as a rule is evaluated that won't match. so the tradeoff is whether or not the case validation is easy/fast or not. already this filter is probably very effective.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

substring: WwW ends up FP-ing on string: www.google.com

Comment thread capa/rules/__init__.py Outdated
Comment on lines +2314 to +2315
if wanted_feature.re.search(str(string_feature.value)):
candidate_rule_names.add(rule_name)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

likewise, does it make sense to do any validation of the literal match casing before doing the full regex?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

but do we have access to the wanted literal here? maybe not, so maybe this isn't possible.

@williballenthin williballenthin Aug 12, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

regex: /WwW/ FPs on string: www.google.com

Comment thread tests/test_match.py
assert capa.rules._required_literal("test[0-9]+") == "test"
assert capa.rules._required_literal("a[0-9]b") is None
assert capa.rules._required_literal("(test|b)") is None
assert capa.rules._required_literal("(test|example)") == {"test", "example"}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

what about (test|(foo|bar)) i assume this is {test, foo, bar}. would like to confirm how nesting works.

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.

foo and bar are shorter than 4, so the whole outcome is None.
Nesting works as you expected with all literals being longer.

assert capa.rules._required_literal("(test|(foo|bar))") is None
assert capa.rules._required_literal("(test|(example|another))") == {"test", "example", "another"}

Comment thread tests/test_match.py
assert "test regex confirmation" not in matches3


def test_differential_parity_prefilter_on_vs_off(monkeypatch):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

i'd prefer to reset string_literal_index after the index construction, rather than use the presence of imports to signal feature flags. we don't use this latter pattern today, so it would be new and maybe confusing/inconsistent.

@corkamig
corkamig removed the request for review from mike-hunhoff August 12, 2026 13:14
@corkamig

Copy link
Copy Markdown
Contributor Author

this is cool!

do you have any benchmark results that you can share?

Hash Size Strings FILE FUNC TOTAL
0761142efbda6c4b1e801223de723578 8.7 MB 20,622 38.42ms / 1.26s (32.8x) 54.78ms / 337.21ms (6.2x) 93.20ms / 1.60s (17.14x)
0cf0d7ad8799997801cc96b05c2fa2ee 348 KB 1,557 2.74ms / 89.66ms (32.8x) 3.93ms / 23.09ms (5.9x) 6.67ms / 112.75ms (16.91x)
112f9f0e8d349858a80dd8c14190e620 9.2 MB 84,366 3.13s / 7.60s (2.4x) 1.23s / 2.42s (2.0x) 4.36s / 10.02s (2.30x)

@williballenthin

Copy link
Copy Markdown
Collaborator

whoa that's outstanding. i didn't realize the impact would be so large. great work!

@williballenthin

Copy link
Copy Markdown
Collaborator

i think we should add a pass to the rule linter to flag regexes for which we can't extract a literal.

@mr-tz

mr-tz commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator

Wow, great performance improvements!

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.

3 participants