From 57d6faa9df80af3a1fc5ee73a636351769f58809 Mon Sep 17 00:00:00 2001 From: Abdul Samad Date: Sun, 28 Jun 2026 23:43:31 +0500 Subject: [PATCH 1/2] Invalidate stale rule caches missing bytes_prefix_index Bump cache format to v2 and reject cached rulesets whose feature index schema is outdated, instead of crashing at match time with AttributeError. Fixes #2961 --- capa/rules/cache.py | 17 +++++++++++++++-- tests/test_rule_cache.py | 26 ++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/capa/rules/cache.py b/capa/rules/cache.py index a461997610..d90fb81b9c 100644 --- a/capa/rules/cache.py +++ b/capa/rules/cache.py @@ -78,7 +78,15 @@ def get_cache_path(cache_dir: Path, id: CacheIdentifier) -> Path: MAGIC = b"capa" -VERSION = b"\x00\x00\x00\x01" +VERSION = b"\x00\x00\x00\x02" + + +def _is_valid_cached_ruleset(ruleset: capa.rules.RuleSet) -> bool: + """Return False when a cached ruleset uses an outdated internal schema.""" + for feature_index in ruleset._feature_indexes_by_scopes.values(): + if not hasattr(feature_index, "bytes_prefix_index"): + return False + return True @dataclass @@ -158,12 +166,17 @@ def load_cached_ruleset(cache_dir: Path, rule_contents: list[bytes]) -> Optional try: cache = RuleCache.load(buf) - except AssertionError: + except (AssertionError, AttributeError, EOFError, pickle.UnpicklingError, ValueError, TypeError): logger.debug("rule set cache is invalid: %s", path) # delete the cache that seems to be invalid. path.unlink() return None + if not _is_valid_cached_ruleset(cache.ruleset): + logger.debug("rule set cache uses an outdated schema: %s", path) + path.unlink() + return None + return cache.ruleset diff --git a/tests/test_rule_cache.py b/tests/test_rule_cache.py index 3aa0c97df9..acf96384de 100644 --- a/tests/test_rule_cache.py +++ b/tests/test_rule_cache.py @@ -63,6 +63,12 @@ ) +class StaleFeatureIndex: + def __init__(self, index): + self.rules_by_feature = index.rules_by_feature + self.string_rules = index.string_rules + + def test_ruleset_cache_ids(): rs = capa.rules.RuleSet([R1]) content = capa.rules.cache.get_ruleset_content(rs) @@ -94,6 +100,26 @@ def test_ruleset_cache_save_load(): assert capa.rules.cache.load_cached_ruleset(cache_dir, content) is not None +def test_ruleset_cache_rejects_outdated_schema(): + rs = capa.rules.RuleSet([R1]) + content = capa.rules.cache.get_ruleset_content(rs) + id = capa.rules.cache.compute_cache_identifier(content) + cache_dir = capa.rules.cache.get_default_cache_directory() + path = capa.rules.cache.get_cache_path(cache_dir, id) + with contextlib.suppress(OSError): + path.unlink() + + for scope, index in list(rs._feature_indexes_by_scopes.items()): + rs._feature_indexes_by_scopes[scope] = StaleFeatureIndex(index) + + cache = capa.rules.cache.RuleCache(id, rs) + path.write_bytes(cache.dump()) + assert path.exists() + + assert capa.rules.cache.load_cached_ruleset(cache_dir, content) is None + assert not path.exists() + + def test_ruleset_cache_invalid(): rs = capa.rules.RuleSet([R1]) content = capa.rules.cache.get_ruleset_content(rs) From 749da2ae5522c0ed577a92f300d5c28117223492 Mon Sep 17 00:00:00 2001 From: Abdul Samad Date: Mon, 29 Jun 2026 00:09:39 +0500 Subject: [PATCH 2/2] Use getattr when validating cached ruleset schema Avoid AttributeError when unpickled rulesets lack _feature_indexes_by_scopes. --- capa/rules/cache.py | 5 ++++- tests/test_rule_cache.py | 17 +++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/capa/rules/cache.py b/capa/rules/cache.py index d90fb81b9c..59457f6327 100644 --- a/capa/rules/cache.py +++ b/capa/rules/cache.py @@ -83,7 +83,10 @@ def get_cache_path(cache_dir: Path, id: CacheIdentifier) -> Path: def _is_valid_cached_ruleset(ruleset: capa.rules.RuleSet) -> bool: """Return False when a cached ruleset uses an outdated internal schema.""" - for feature_index in ruleset._feature_indexes_by_scopes.values(): + feature_indexes = getattr(ruleset, "_feature_indexes_by_scopes", None) + if feature_indexes is None: + return False + for feature_index in feature_indexes.values(): if not hasattr(feature_index, "bytes_prefix_index"): return False return True diff --git a/tests/test_rule_cache.py b/tests/test_rule_cache.py index acf96384de..568d7375a0 100644 --- a/tests/test_rule_cache.py +++ b/tests/test_rule_cache.py @@ -120,6 +120,23 @@ def test_ruleset_cache_rejects_outdated_schema(): assert not path.exists() +def test_ruleset_cache_rejects_missing_feature_indexes(): + rs = capa.rules.RuleSet([R1]) + content = capa.rules.cache.get_ruleset_content(rs) + id = capa.rules.cache.compute_cache_identifier(content) + cache_dir = capa.rules.cache.get_default_cache_directory() + path = capa.rules.cache.get_cache_path(cache_dir, id) + with contextlib.suppress(OSError): + path.unlink() + + cache = capa.rules.cache.RuleCache(id, object()) + path.write_bytes(cache.dump()) + assert path.exists() + + assert capa.rules.cache.load_cached_ruleset(cache_dir, content) is None + assert not path.exists() + + def test_ruleset_cache_invalid(): rs = capa.rules.RuleSet([R1]) content = capa.rules.cache.get_ruleset_content(rs)