diff --git a/CHANGELOG.md b/CHANGELOG.md index 08bfe6e5..2f753ba6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -60,6 +60,7 @@ - fix: FeatureRegexRegistryControlSetMatchIncomplete now checks all Regex features instead of returning after the first @williballenthin (SURF-84) - fix: MissingStaticScope and MissingDynamicScope lint checks guard against absent scopes dict to prevent TypeError @williballenthin (SURF-83) - fix: MissingExampleOffset lint now reads scopes.static instead of obsolete scope key @williballenthin (SURF-82) +- fix: extend MissingExampleOffset lint to validate dynamic examples using (pid:N,tid:N,call:N) format @williballenthin #3058 - fix: invert scope filter in import-to-ida.py so function-scope rules are annotated instead of skipped @williballenthin (SURF-81) - fix: remove dead string literal in test_detect_duplicate_features @williballenthin (SURF-80) - fix: remove duplicate Rule.from_yaml call in test_scope_instruction_description @williballenthin (SURF-79) diff --git a/scripts/lint.py b/scripts/lint.py index 849aa3de..9821d6d7 100644 --- a/scripts/lint.py +++ b/scripts/lint.py @@ -247,17 +247,41 @@ class MissingExamples(Lint): class MissingExampleOffset(Lint): name = "missing example offset" - recommendation = "Add offset of example function" + recommendation = "Add offset of example (static: hash:0xADDR, dynamic: hash:(pid:N,tid:N,call:N))" + + STATIC_SCOPES_NEEDING_OFFSET = ("function", "basic block") + DYNAMIC_SCOPES_NEEDING_OFFSET = ("process", "thread", "call", "span of calls") def check_rule(self, ctx: Context, rule: Rule): - static_scope = rule.meta.get("scopes", {}).get("static") - if static_scope in ("function", "basic block"): - examples = rule.meta.get("examples") - if isinstance(examples, list): - for example in examples: - if example and ":" not in example: - logger.debug("example: %s", example) + scopes = rule.meta.get("scopes", {}) + static_scope = scopes.get("static") + dynamic_scope = scopes.get("dynamic") + + examples = rule.meta.get("examples") + if not isinstance(examples, list): + return False + + for example in examples: + if not example: + continue + + example_id, _, offset = example.partition(":") + + sample_path = ctx.samples.get(example_id) + is_dynamic_sample = sample_path is not None and "dynamic" in sample_path.parts + + if is_dynamic_sample: + if dynamic_scope in self.DYNAMIC_SCOPES_NEEDING_OFFSET: + if not offset or not offset.startswith("("): + logger.debug("example: %s (missing dynamic offset)", example) return True + else: + if static_scope in self.STATIC_SCOPES_NEEDING_OFFSET: + if not offset: + logger.debug("example: %s (missing static offset)", example) + return True + + return False class ExampleFileDNE(Lint): diff --git a/tests/test_scripts.py b/tests/test_scripts.py index 4b51049b..dea4ee43 100644 --- a/tests/test_scripts.py +++ b/tests/test_scripts.py @@ -282,7 +282,6 @@ def test_missing_example_offset_uses_scopes(): import lint as lint_module lint_instance = lint_module.MissingExampleOffset() - ctx = lint_module.Context(samples={}, rules=capa.rules.RuleSet([]), is_thorough=False) function_scope_rule_missing_offset = capa.rules.Rule.from_yaml( textwrap.dedent(""" @@ -298,6 +297,10 @@ def test_missing_example_offset_uses_scopes(): - api: CreateFile """) ) + + rules = capa.rules.RuleSet([function_scope_rule_missing_offset]) + ctx = lint_module.Context(samples={}, rules=rules, is_thorough=False) + assert lint_instance.check_rule(ctx, function_scope_rule_missing_offset) is True function_scope_rule_with_offset = capa.rules.Rule.from_yaml( @@ -332,6 +335,60 @@ def test_missing_example_offset_uses_scopes(): ) assert lint_instance.check_rule(ctx, file_scope_rule_no_offset) is not True + ctx_with_dynamic = lint_module.Context( + samples={"abc123_min_archive.zip": Path("tests/data/dynamic/vmray/abc123_min_archive.zip")}, + rules=rules, + is_thorough=False, + ) + + dynamic_example_missing_offset = capa.rules.Rule.from_yaml( + textwrap.dedent(""" + rule: + meta: + name: test rule dynamic example missing offset + scopes: + static: basic block + dynamic: call + examples: + - abc123_min_archive.zip + features: + - api: CreateFile + """) + ) + assert lint_instance.check_rule(ctx_with_dynamic, dynamic_example_missing_offset) is True + + dynamic_example_with_offset = capa.rules.Rule.from_yaml( + textwrap.dedent(""" + rule: + meta: + name: test rule dynamic example with offset + scopes: + static: basic block + dynamic: call + examples: + - abc123_min_archive.zip:(pid:2932,tid:2928,call:354) + features: + - api: CreateFile + """) + ) + assert lint_instance.check_rule(ctx_with_dynamic, dynamic_example_with_offset) is not True + + dynamic_file_scope_no_offset = capa.rules.Rule.from_yaml( + textwrap.dedent(""" + rule: + meta: + name: test rule dynamic file scope no offset + scopes: + static: file + dynamic: file + examples: + - abc123_min_archive.zip + features: + - string: test + """) + ) + assert lint_instance.check_rule(ctx_with_dynamic, dynamic_file_scope_no_offset) is not True + def test_feature_regex_registry_control_set_checks_all_features(): sys.path.insert(0, str(CD / ".." / "scripts"))