Copia local (nao fork, nao submodule) de safedeps, sca-audit, secrets-scan, code-review-security, api-security-review, web-security-review, cve-triage, patch-prioritization, dependency-scanning e hermaguard -- commit exato de origem e licenca de cada uma em skills/NOTICE.md. Elimina o passo de clone externo pro pilar de seguranca do wf-security-review: git clone deste repo ja traz tudo funcional. Corrige tambem o comando de instalacao pro Claude Code nos dois READMEs e no docs/SKILLS.md: Claude Code exige um diretorio por skill (skills/<nome>/SKILL.md), nao aceita os .md soltos que o comando antigo copiava -- confirmado testando a instalacao real.
194 lines
7.8 KiB
Python
194 lines
7.8 KiB
Python
"""
|
|
Hermaguard Loop Closure — Test Suite
|
|
|
|
Proves the suppression loop is actually closed end-to-end:
|
|
hermaguard-compile-rules emits YAML → hermaguard-prescan loads it →
|
|
matching findings are suppressed. No placebo assertions.
|
|
"""
|
|
|
|
import importlib
|
|
import importlib.util
|
|
import json
|
|
import os
|
|
import sqlite3
|
|
import sys
|
|
import tempfile
|
|
import unittest
|
|
from unittest import mock
|
|
from pathlib import Path
|
|
|
|
ROOT = Path(__file__).resolve().parent
|
|
|
|
|
|
def _load_module(name, relpath):
|
|
spec = importlib.util.spec_from_file_location(name, ROOT / relpath)
|
|
if spec is None or spec.loader is None:
|
|
raise ImportError(f"Could not load {relpath}")
|
|
mod = importlib.util.module_from_spec(spec)
|
|
sys.modules[name] = mod
|
|
spec.loader.exec_module(mod)
|
|
return mod
|
|
|
|
|
|
PRES = _load_module("hermaguard_prescan", "tools/hermaguard-prescan/hermaguard-prescan.py")
|
|
|
|
|
|
class TestRuleParsing(unittest.TestCase):
|
|
def test_parses_compile_rules_output_format(self):
|
|
# Exact format hermaguard-compile-rules writes (json.dumps pattern)
|
|
text = (
|
|
"# Hermaguard Suppression Rules\n"
|
|
"- rule_id: HG-SUPPRESS-001\n"
|
|
' pattern: "loose equality on status check"\n'
|
|
" agent: edge_case\n"
|
|
" dismissals: 3\n"
|
|
"\n"
|
|
"- rule_id: HG-SUPPRESS-002\n"
|
|
' pattern: "harmless getattr default"\n'
|
|
" agent: adversarial\n"
|
|
" dismissals: 4\n"
|
|
)
|
|
rules = PRES._parse_rules_text(text)
|
|
self.assertEqual(len(rules), 2)
|
|
self.assertEqual(rules[0]["pattern"], "loose equality on status check")
|
|
self.assertEqual(rules[1]["dismissals"], 4)
|
|
|
|
def test_parses_pure_json_array(self):
|
|
rules = PRES._parse_rules_text(json.dumps([
|
|
{"rule_id": "R1", "pattern": "pat", "agent": "a", "dismissals": 3},
|
|
]))
|
|
self.assertEqual(len(rules), 1)
|
|
self.assertEqual(rules[0]["pattern"], "pat")
|
|
|
|
def test_empty_file_no_rules(self):
|
|
self.assertEqual(PRES._parse_rules_text("# only comments\n"), [])
|
|
|
|
def test_missing_file_returns_no_rules(self):
|
|
with tempfile.TemporaryDirectory() as d:
|
|
rules, src = PRES.load_suppression_rules(os.path.join(d, "nope.yaml"))
|
|
self.assertEqual(rules, [])
|
|
self.assertIsNone(src)
|
|
|
|
def test_explicit_path_loaded(self):
|
|
with tempfile.TemporaryDirectory() as d:
|
|
p = os.path.join(d, "rules.yaml")
|
|
Path(p).write_text('- rule_id: R1\n pattern: "x"\n agent: a\n dismissals: 3\n')
|
|
rules, src = PRES.load_suppression_rules(p)
|
|
self.assertEqual(len(rules), 1)
|
|
self.assertEqual(src, p)
|
|
|
|
|
|
class TestSuppressionApplication(unittest.TestCase):
|
|
def _f(self, msg, rule_id="B101", file="src/app.py"):
|
|
return {"tool": "bandit", "rule_id": rule_id, "severity": "LOW",
|
|
"file": file, "line": 1, "message": msg, "category": "security"}
|
|
|
|
def test_matching_pattern_suppressed(self):
|
|
rules = [{"pattern": "assert used in production", "file_scope": ""}]
|
|
kept, sup = PRES.apply_suppression_rules(
|
|
[self._f("B101: assert used in production code")], rules)
|
|
self.assertEqual(len(sup), 1)
|
|
self.assertEqual(kept, [])
|
|
|
|
def test_non_matching_kept(self):
|
|
rules = [{"pattern": "something else entirely", "file_scope": ""}]
|
|
kept, sup = PRES.apply_suppression_rules(
|
|
[self._f("assert used in production code")], rules)
|
|
self.assertEqual(len(kept), 1)
|
|
self.assertEqual(sup, [])
|
|
|
|
def test_file_scope_restricts(self):
|
|
rules = [{"pattern": "assert", "file_scope": "src/tests_helper.py"}]
|
|
kept, sup = PRES.apply_suppression_rules(
|
|
[self._f("assert used", file="src/app.py")], rules)
|
|
self.assertEqual(len(kept), 1) # different file — not suppressed
|
|
rules2 = [{"pattern": "assert", "file_scope": "src/app.py"}]
|
|
kept2, sup2 = PRES.apply_suppression_rules(
|
|
[self._f("assert used", file="src/app.py")], rules2)
|
|
self.assertEqual(len(sup2), 1)
|
|
|
|
def test_no_rules_passthrough(self):
|
|
findings = [self._f("anything")]
|
|
kept, sup = PRES.apply_suppression_rules(findings, [])
|
|
self.assertEqual(kept, findings)
|
|
self.assertEqual(sup, [])
|
|
|
|
|
|
class TestEndToEndLoop(unittest.TestCase):
|
|
"""compile-rules DB → YAML → prescan applies it. The whole point."""
|
|
|
|
def test_full_loop(self):
|
|
CR = _load_module("hermaguard_compile_rules", "tools/hermaguard-compile-rules/hermaguard-compile-rules.py")
|
|
|
|
with tempfile.TemporaryDirectory() as d:
|
|
db = os.path.join(d, "feedback.db")
|
|
# Build a feedback DB with one pattern dismissed 3x
|
|
conn = sqlite3.connect(db)
|
|
conn.executescript("""
|
|
CREATE TABLE findings (
|
|
finding_id TEXT PRIMARY KEY,
|
|
run_id TEXT,
|
|
severity TEXT,
|
|
source_agent TEXT,
|
|
file_path TEXT,
|
|
trigger_condition TEXT,
|
|
status TEXT,
|
|
triage_note TEXT,
|
|
ts TEXT
|
|
);
|
|
""")
|
|
for i in range(3):
|
|
conn.execute(
|
|
"INSERT INTO findings VALUES (?,?,?,?,?,?,?,?,?)",
|
|
(f"F{i}", "run1", "LOW", "edge-case-hunter", "src/app.py",
|
|
"assert used in production", "dismissed-false-positive", "", "2026-01-01"))
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
with unittest.mock.patch.dict(os.environ, {"HERMAGUARD_FEEDBACK_DB": db}):
|
|
# compile-rules reads env at import time — reload with the env set
|
|
spec2 = importlib.util.spec_from_file_location(
|
|
"cr2", ROOT / "tools/hermaguard-compile-rules/hermaguard-compile-rules.py")
|
|
if spec2 is None or spec2.loader is None:
|
|
raise ImportError("could not load compile-rules")
|
|
cr2 = importlib.util.module_from_spec(spec2)
|
|
sys.modules["cr2"] = cr2
|
|
spec2.loader.exec_module(cr2)
|
|
rules = cr2.compile_rules(min_dismissals=3)
|
|
|
|
self.assertEqual(len(rules), 1)
|
|
self.assertEqual(rules[0]["pattern"], "assert used in production")
|
|
|
|
# Write the YAML exactly as compile-rules main() writes it
|
|
yaml_path = os.path.join(d, "hermaguard-rules.yaml")
|
|
with open(yaml_path, "w") as f:
|
|
f.write("# auto\n")
|
|
for r in rules:
|
|
f.write(f"- rule_id: {r['rule_id']}\n")
|
|
f.write(f" pattern: {json.dumps(r['pattern'])}\n")
|
|
f.write(f" agent: {r['agent']}\n")
|
|
f.write(f" dismissals: {r['dismissals']}\n\n")
|
|
|
|
# Prescan side: load and apply
|
|
loaded, src = PRES.load_suppression_rules(yaml_path)
|
|
self.assertEqual(len(loaded), 1)
|
|
self.assertEqual(src, yaml_path)
|
|
|
|
findings = [
|
|
{"tool": "bandit", "rule_id": "B101", "severity": "LOW",
|
|
"file": "src/app.py", "line": 10,
|
|
"message": "Assert used in production guard", "category": "security"},
|
|
{"tool": "bandit", "rule_id": "B608", "severity": "HIGH",
|
|
"file": "src/db.py", "line": 20,
|
|
"message": "hardcoded_sql_expressions", "category": "security"},
|
|
]
|
|
kept, suppressed = PRES.apply_suppression_rules(findings, loaded)
|
|
self.assertEqual(len(kept), 1)
|
|
self.assertEqual(len(suppressed), 1)
|
|
self.assertEqual(kept[0]["rule_id"], "B608") # the real finding survives
|
|
self.assertEqual(suppressed[0]["rule_id"], "B101") # the noise is gone
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|