measure: Semgrep arm on the valid pilot, our rules and facts converted

0 of 12 positives; 12.6 false alarms per 100 ordinary pushes (2.1 on changed
lines only). The misses come from what the rules and facts express (access
control lives in obligations, which do not convert; sources cover request
objects only), not from the engine. Comparison arm only: a Semgrep miss is
never evidence against a hunter finding.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Marius Ciepluch
2026-10-13 13:10:19 +02:00
co-authored by Claude Opus 5.5
parent d08dea19b8
commit c5bf52f47d
12 changed files with 4465 additions and 0 deletions
+397
View File
@@ -0,0 +1,397 @@
"""Offline coverage of the Semgrep benchmark arm: rule conversion, result mapping, record shape."""
import hashlib
import importlib
import json
import shutil
import sys
from pathlib import Path
import pytest
import yaml
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
semgrep_arm = importlib.import_module("benchmarks.valid.semgrep_arm")
catalog = importlib.import_module("benchmarks.valid.catalog")
score = importlib.import_module("benchmarks.unseen.score")
RULES_TOML = """
[[rule]]
rule_id = "python-shell-true"
title = "Subprocess shell execution needs review"
languages = ["python"]
cwe = "CWE-78"
tags = ["syscall_entry"]
status = "enabled"
precision_estimate = 0.0
version = "1"
pattern = '''subprocess\\.[a-zA-Z_]+\\s*\\([^\\n]*shell\\s*=\\s*True'''
[[rule]]
rule_id = "php-sql-shadow"
title = "PHP SQL call composed from a variable"
languages = ["php"]
cwe = "CWE-89"
tags = ["injection"]
status = "shadow"
precision_estimate = 0.25
version = "1"
pattern = '''mysqli_query\\s*\\([^\\n]*\\$'''
[[rule]]
rule_id = "php-sql-bounded"
title = "PHP sprintf-built SQL"
languages = ["php"]
cwe = "CWE-89"
tags = ["injection"]
status = "enabled"
precision_estimate = 0.1
version = "1"
pattern = '''(sprintf)\\((?=(?:(?!\\);)[\\s\\S]){0,2000}?'%s')'''
[[rule]]
rule_id = "groovy-xss-unescaped"
title = "Groovy template unescaped output needs review"
languages = ["groovy"]
cwe = "CWE-79"
tags = ["injection"]
status = "enabled"
precision_estimate = 0.0
version = "1"
pattern = '''(yieldUnescaped)\\s+[A-Za-z_]'''
"""
FACTS_TOML = """
version = "1"
language = "python"
[[source]]
id = "request"
patterns = ["request.args", "request.get_json", "input("]
[[sink]]
id = "os.system"
cwe = "CWE-78"
calls = ["os.system", "subprocess.run"]
rule_ids = ["python-os-command"]
[[sink]]
id = "execute"
cwe = "CWE-89"
calls = ["execute"]
rule_ids = []
[[sink]]
id = "hashlib"
cwe = "CWE-327"
calls = ["hashlib.md5", "hashlib.new"]
rule_ids = []
weak_literals = ["md5", "sha1"]
[[sanitizer]]
id = "shlex.quote"
calls = ["shlex.quote"]
[[sanitizer]]
id = "parameterized_execute"
calls = ["execute"]
parameterized = true
[[sanitizer]]
id = "url_allowlist"
calls = ["url_has_allowed_host_and_scheme"]
guard = true
"""
SEMGREP_SAMPLE = {
"version": "1.179.0",
"results": [
{
"check_id": "rules.ousast.python.taint.os.system",
"path": "app/views.py",
"start": {"line": 12, "col": 5, "offset": 300},
"end": {"line": 12, "col": 30, "offset": 325},
"extra": {
"message": "os.system: untrusted input reaches os.system, subprocess.run",
"metadata": {"family": "injection", "cwe": "CWE-78", "origin": "sink:python:os.system", "precision_estimate": "unmeasured"},
"severity": "ERROR",
"lines": ' os.system("ls " + c)',
"dataflow_trace": {"taint_source": ["CliLoc", [{"path": "app/views.py", "start": {"line": 11}}, "request.args"]]},
},
},
{
"check_id": "rules.ousast.python.regex.python-os-command",
"path": "app/views.py",
"start": {"line": 12, "col": 5, "offset": 300},
"end": {"line": 12, "col": 15, "offset": 310},
"extra": {
"message": "Python OS command execution needs review",
"metadata": {"family": "injection", "cwe": "CWE-78", "origin": "python-os-command", "precision_estimate": "0.0"},
"severity": "WARNING",
"lines": ' os.system("ls " + c)',
},
},
],
"errors": [],
"paths": {"scanned": ["app/views.py"]},
}
@pytest.fixture
def ruleset(tmp_path):
root = tmp_path / "ruleset"
(root / "python").mkdir(parents=True)
(root / "python" / "rules.toml").write_text(RULES_TOML)
(root / "obligations").mkdir()
(root / "obligations" / "python.toml").write_text('[[operation]]\nid = "x"\n[[discharger]]\nid = "y"\n')
facts = root / "semantic"
facts.mkdir()
(facts / "python.toml").write_text(FACTS_TOML)
return root
def test_regex_rules_convert_with_metadata_and_skip_reasons(ruleset):
converted, skipped, rewrites = semgrep_arm.convert_regex_rules(ruleset)
by_origin = {rule["metadata"]["origin"]: rule for rules in converted.values() for rule in rules}
shell = by_origin["python-shell-true"]
assert shell["languages"] == ["python"]
assert shell["pattern-regex"] == r"subprocess\.[a-zA-Z_]+\s*\([^\n]*shell\s*=\s*True"
assert shell["metadata"]["family"] == "injection"
assert shell["metadata"]["cwe"] == "CWE-78"
assert shell["metadata"]["precision_estimate"] == "0.0"
assert shell["id"].startswith("ousast.python.regex.")
groovy = by_origin["groovy-xss-unescaped"]
assert groovy["languages"] == ["generic"]
assert groovy["paths"] == {"include": ["*.groovy", "*.tpl"]}
assert groovy["metadata"]["family"] == "output_encoding"
assert by_origin["php-sql-bounded"]["pattern-regex"] == r"(sprintf)\((?=(?:(?!\);)[\s\S])*?'%s')"
assert [rewrite["origin"] for rewrite in rewrites] == ["php-sql-bounded"]
assert [entry["origin"] for entry in skipped] == ["php-sql-shadow"]
assert "shadow" in skipped[0]["reason"]
def test_emit_floor_skips_rules_below_precision(ruleset):
_, skipped, _ = semgrep_arm.convert_regex_rules(ruleset, min_precision=0.05)
reasons = {entry["origin"]: entry["reason"] for entry in skipped}
assert reasons["python-shell-true"].startswith("emit floor")
assert reasons["groovy-xss-unescaped"].startswith("emit floor")
assert "php-sql-bounded" not in reasons
def test_fact_triple_becomes_a_taint_rule(ruleset):
converted, skipped, lossy = semgrep_arm.convert_facts(ruleset / "semantic")
rules = {rule["metadata"]["origin"]: rule for rule in converted["python"]}
taint = rules["sink:python:os.system"]
assert taint["mode"] == "taint"
assert taint["languages"] == ["python"]
assert taint["metadata"] == {
"family": "injection",
"cwe": "CWE-78",
"origin": "sink:python:os.system",
"precision_estimate": "unmeasured",
"kind": "taint",
"calls": ["os.system", "subprocess.run"],
}
sources = [entry["pattern"] for entry in taint["pattern-sources"][0]["pattern-either"]]
assert {"request.args", "request.get_json(...)", "input(...)"} <= set(sources)
sinks = [entry["pattern"] for entry in taint["pattern-sinks"][0]["pattern-either"]]
assert sinks == ["os.system(...)", "subprocess.run(...)"]
sanitizers = [entry["pattern"] for entry in taint["pattern-sanitizers"][0]["pattern-either"]]
assert sanitizers == ["shlex.quote(...)"]
execute = rules["sink:python:execute"]["pattern-sinks"][0]["pattern-either"]
assert execute[0] == {"patterns": [{"pattern": "execute($QUERY)"}, {"focus-metavariable": "$QUERY"}]}
weak = rules["sink:python:hashlib"]
assert "mode" not in weak and weak["metadata"]["kind"] == "weak_literal" and weak["metadata"]["family"] == "config_secrets"
reasons = {entry["origin"]: entry["reason"] for entry in skipped}
assert reasons["sanitizer:python:url_allowlist"].startswith("guard")
assert reasons["sanitizer:python:parameterized_execute"].startswith("parameterized")
assert lossy == []
def test_write_rules_emits_one_yaml_per_language_and_a_report(ruleset, tmp_path):
out = tmp_path / "rules"
report = semgrep_arm.write_rules(out, ruleset_dir=ruleset, facts_dir=ruleset / "semantic", semgrep="/nonexistent/semgrep")
assert sorted(path.name for path in out.glob("*.yaml")) == ["groovy.yaml", "php.yaml", "python.yaml"]
assert report["files"] == {"groovy.yaml": 1, "php.yaml": 1, "python.yaml": 4}
loaded = yaml.safe_load((out / "python.yaml").read_text())
ids = [rule["id"] for rule in loaded["rules"]]
assert len(ids) == len(set(ids)) == 4
for rule in loaded["rules"]:
assert set(rule) >= {"id", "languages", "severity", "message", "metadata"}
assert {"family", "cwe", "origin", "precision_estimate"} <= set(rule["metadata"])
assert isinstance(rule["metadata"]["precision_estimate"], str)
assert ("pattern-regex" in rule) or ("pattern-either" in rule) or (rule.get("mode") == "taint")
saved = json.loads((out / "conversion_report.json").read_text())
assert saved["obligations"]["skipped_tables"] == 2
assert saved["regex"]["converted"] == ["groovy-xss-unescaped", "php-sql-bounded", "python-shell-true"]
assert saved["facts"]["converted"] == ["sink:python:execute", "sink:python:hashlib", "sink:python:os.system"]
# A missing binary is recorded, never reported as a successful validation.
assert report["validation"]["exit"] is None and "not found" in report["validation"]["error"]
def test_result_to_defect_mapping():
defects = [semgrep_arm.result_defect(result) for result in SEMGREP_SAMPLE["results"]]
assert defects[0] == {
"defect_id": "sink:python:os.system:app/views.py:12",
"family": "injection",
"cwe": "CWE-78",
"locations": [["app/views.py", 12]],
"source": "semgrep",
"rule_id": "rules.ousast.python.taint.os.system",
"origin": "sink:python:os.system",
"title": "os.system: untrusted input reaches os.system, subprocess.run",
"rationale": "os.system: untrusted input reaches os.system, subprocess.run",
"confidence": "ERROR",
"dataflow": True,
}
assert defects[1]["dataflow"] is False
assert defects[1]["confidence"] == "WARNING"
assert defects[1]["origin"] == "python-os-command"
def _change(**overrides):
change = dict(
id="fixture-positive",
repo="fixture/app",
repository_url="https://example.invalid/app",
kind="vulnerability",
base="b" * 40,
head="a" * 40,
family="injection",
cwe="CWE-78",
path="app/views.py",
function="handler",
function_lines=[10, 14],
files=[{"path": "app/views.py", "head_lines": [[10, 14]]}],
method="fix_site",
slice="1",
)
change.update(overrides)
return change
def test_record_shape_is_accepted_by_the_scorer(tmp_path):
out = tmp_path / "semgrep"
change = _change()
digest = hashlib.sha256(json.dumps(change, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode()).hexdigest()
semgrep_arm._write_json(out / "inputs.json", [{"item_id": change["id"], "change": change, "input_digest": digest}])
record = {
"id": change["id"],
"done": True,
"status": "semgrep_covered",
"input_digest": digest,
"instrument": {
"hook_exit": 0,
"hook_bytes_read": 412,
"changed_bytes": {"app/views.py": 412},
"head_verified": True,
"base_verified": True,
"failure": None,
"suspect_fast": False,
"semgrep": {"exit": 0, "wall_seconds": 4.9, "scanned": ["app/views.py"], "errors": [], "results": 2},
},
"push": {
"admission": {"defects": [semgrep_arm.result_defect(result) for result in SEMGREP_SAMPLE["results"]], "dispositions": []},
"quick_tier": [],
},
"skipped": [],
}
semgrep_arm._write_json(semgrep_arm._record_path(out, change["id"]), record)
rows = score.load_records(out)
assert len(rows) == 1 and rows[0]["status"] == "semgrep_covered"
assert score.coverage(rows[0]) == "quick_only"
assert score.catches(rows[0]) == ["alerts"]
assert score.false_alarm(rows[0])
# A family mismatch is not a catch, exactly as the hunter arm is scored.
rows = score.load_records(out)
rows[0]["family"] = "path"
assert score.catches(rows[0]) == []
scored = score.score(rows)
assert scored["coverage"]["quick_only"] == 1
def test_instrument_failure_record_is_not_covered(tmp_path):
out = tmp_path / "semgrep"
change = _change()
digest = "0" * 64
semgrep_arm._write_json(out / "inputs.json", [{"item_id": change["id"], "change": change, "input_digest": digest}])
record = {
"id": change["id"],
"done": True,
"status": "instrument_failure",
"input_digest": digest,
"instrument": {
"hook_exit": 2,
"hook_bytes_read": 0,
"changed_bytes": {},
"head_verified": True,
"base_verified": True,
"failure": "semgrep_exit_2",
"suspect_fast": False,
},
"push": {"admission": {"defects": [], "dispositions": []}, "quick_tier": []},
"skipped": [],
}
semgrep_arm._write_json(semgrep_arm._record_path(out, change["id"]), record)
rows = score.load_records(out)
assert score.coverage(rows[0]) == "instrument_failure"
assert not score.false_alarm(rows[0])
@pytest.fixture
def fixture(tmp_path):
root = tmp_path / "repo"
root.mkdir()
git = catalog.local_git(root)
git.run("init", "-q")
git.run("config", "user.name", "Fixture")
git.run("config", "user.email", "[email protected]")
heads = []
for text in (
"import os\nfrom flask import request\n\n\ndef handler():\n c = request.args.get('c')\n os.system('ls ' + c)\n",
"# Later checkout\n",
):
(root / "app.py").write_text(text)
git.run("add", "app.py")
git.run("-c", "core.hooksPath=/dev/null", "commit", "-qm", "fixture")
heads.append(git.run("rev-parse", "HEAD").strip())
change = _change(
base=heads[1],
head=heads[0],
path="app.py",
function="handler",
function_lines=[5, 7],
files=[{"path": "app.py", "head_lines": [[5, 7]]}, {"path": "gone.py", "head_lines": [[1, 1]]}],
)
frozen = tmp_path / "frozen.json"
frozen.write_text(
json.dumps({"changes": [change], "sha256": hashlib.sha256(json.dumps([change], sort_keys=True).encode()).hexdigest()})
)
repos = tmp_path / "repos.json"
repos.write_text(json.dumps({change["repo"]: str(root)}))
return {"root": root, "git": git, "change": change, "frozen": frozen, "repos": repos}
@pytest.mark.skipif(shutil.which("semgrep") is None, reason="semgrep binary not installed")
def test_run_scans_changed_files_and_cleans_up(fixture, tmp_path):
rules = tmp_path / "rules"
semgrep_arm.write_rules(rules, semgrep="/nonexistent/semgrep")
out = tmp_path / "out"
assert semgrep_arm.run(fixture["frozen"], fixture["repos"], rules, out, only="vulnerability") == 0
rows = score.load_records(out)
assert len(rows) == 1
record = rows[0]
assert record["status"] == "semgrep_covered"
assert record["instrument"]["changed_bytes"] == {"app.py": record["instrument"]["hook_bytes_read"]}
assert record["instrument"]["hook_bytes_read"] > 0
assert record["instrument"]["semgrep"]["exit"] == 0 and not record["instrument"]["suspect_fast"]
assert record["skipped"] == [{"path": "gone.py", "reason": "missing_at_head"}]
assert record["instrument"]["semgrep"]["scanned"] == ["app.py"]
origins = {defect["origin"] for defect in record["push"]["admission"]["defects"]}
assert {"python-os-command", "sink:python:os.system"} <= origins
assert any(defect["dataflow"] for defect in record["push"]["admission"]["defects"])
assert score.coverage(record) == "quick_only"
assert score.catches(record) == ["alerts"]
assert fixture["git"].run("worktree", "list").strip().count("\n") == 0
# A second run resumes without touching the record.
assert semgrep_arm.run(fixture["frozen"], fixture["repos"], rules, out, only="vulnerability") == 0