diff --git a/.github/scripts/sigilix_sarif_contract.py b/.github/scripts/sigilix_sarif_contract.py index fd10958..5ecf29b 100644 --- a/.github/scripts/sigilix_sarif_contract.py +++ b/.github/scripts/sigilix_sarif_contract.py @@ -1,5 +1,6 @@ import argparse import json +import re import sys @@ -253,6 +254,66 @@ def _sanitize_run_results(run): run["results"] = [result for result in results if isinstance(result, dict)] +# SIG-184 Phase 3 — canonical vuln-class stamping. The engine's receipt-join +# (verify-finding-receipt.ts) confirms a security finding into the VERIFIED tier +# only when an independent runner scan flagged the SAME canonical class at the +# same path/line. These class strings MUST match the engine's CanonicalVulnClass +# enum (src/shared/domain/vuln-class.ts) VERBATIM — keep the two in lockstep. +# +# CREDIBILITY (Codex + DeepSeek sign-off): NOT loose substring matching. We map a +# native tool ruleId to the set of classes whose anchored pattern matches; a +# result is stamped ONLY when EXACTLY ONE distinct class matches. Zero or >1 +# matches → left UNSET (the engine treats absence as INCONCLUSIVE, never a false +# VERIFIED). So an ambiguous/colliding ruleId is fail-safe, never mis-stamped. +_RULE_CLASS_PATTERNS = { + "semgrep": [ + (re.compile(r"dangerous-exec|command[-_.]?inj|os[-_.]?command|dangerous-subprocess|spawn.*shell"), "command-injection"), + (re.compile(r"nosql[-_.]?inj"), "nosql-injection"), + (re.compile(r"(?1 → fail-safe: leave unset (engine → INCONCLUSIVE). + if not isinstance(props, dict): + props = {} + result["properties"] = props + props["sigilixRuleClass"] = next(iter(matched)) + return run + + def _main(argv): parser = argparse.ArgumentParser(description="Attach Sigilix metadata to SARIF runs.") parser.add_argument("tool_id", choices=sorted(KNOWN_TOOL_IDS)) @@ -275,6 +336,9 @@ def _main(argv): if args.cap is not None: _, summary = cap_run_results(run, args.cap) attach_sigilix_metadata(run, args.tool_id, summary) + # SIG-184 Phase 3: stamp the canonical vuln-class on each final result, + # after capping/metadata (so it runs over the assembled output). + stamp_rule_classes(run, args.tool_id) normalized_runs.append(run) if args.ensure_run and not normalized_runs: normalized_runs.append(empty_metadata_run(args.tool_id)) diff --git a/.github/scripts/sigilix_sarif_test.py b/.github/scripts/sigilix_sarif_test.py index f5cc419..fda8111 100644 --- a/.github/scripts/sigilix_sarif_test.py +++ b/.github/scripts/sigilix_sarif_test.py @@ -13,6 +13,7 @@ _main as contract_main, attach_sigilix_metadata, cap_results, + stamp_rule_classes, ) from sigilix_sarif_merge import _main as sarif_merge_main from sigilix_sarif_merge import merge_sarif_documents @@ -943,5 +944,44 @@ def test_normalize_path_falls_back_for_paths_outside_base_dir(self): self.assertEqual(normalize_path("", base_dir="/repo"), ".") +class StampRuleClassesTest(unittest.TestCase): + """SIG-184 Phase 3 — canonical vuln-class stamping (credibility core).""" + + @staticmethod + def _run(rule_id, props=None): + result = {"ruleId": rule_id} + if props is not None: + result["properties"] = props + run = {"tool": {"driver": {"name": "Semgrep"}}, "results": [result]} + stamp_rule_classes(run, "semgrep") + return run["results"][0].get("properties", {}).get("sigilixRuleClass") + + def test_stamps_command_injection(self): + self.assertEqual( + self._run("javascript.lang.security.audit.dangerous-exec.dangerous-exec"), + "command-injection", + ) + + def test_stamps_sql_not_nosql(self): + self.assertEqual(self._run("python.lang.security.audit.sql-injection"), "sql-injection") + self.assertEqual(self._run("javascript.lang.security.nosql-injection"), "nosql-injection") + + def test_unknown_rule_left_unset(self): + self.assertIsNone(self._run("javascript.lang.best-practice.no-console-log")) + + def test_strips_preexisting_untrusted_class(self): + # A class smuggled in raw tool output must be removed, then re-derived. + self.assertIsNone(self._run("some.unmapped.rule", {"sigilixRuleClass": "command-injection"})) + + def test_collision_safe_unset_when_two_classes_match(self): + # A contrived ruleId hitting two patterns → exactly-one guard leaves it unset. + self.assertIsNone(self._run("sql-injection-and-ssrf-combo")) + + def test_unknown_tool_id_strips_only(self): + run = {"tool": {"driver": {}}, "results": [{"ruleId": "dangerous-exec", "properties": {"sigilixRuleClass": "x"}}]} + stamp_rule_classes(run, "eslint") # no patterns for eslint → strip only + self.assertNotIn("sigilixRuleClass", run["results"][0]["properties"]) + + if __name__ == "__main__": unittest.main()