diff --git a/changelog.d/security-seeded-bench.md b/changelog.d/security-seeded-bench.md
new file mode 100644
index 000000000..1083a47d4
--- /dev/null
+++ b/changelog.d/security-seeded-bench.md
@@ -0,0 +1,2 @@
+### Added
+- Seeded-vulnerability bench for security review: `scripts/eval-fixtures/security-seeded/` holds 12 small synthetic diffs (one each of SQL injection, XSS, SSRF, path traversal, missing authorization, hardcoded secret with a fake value, unsafe deserialization, command injection, open redirect, IDOR, TOCTOU, weak hash) plus 4 look-alike safe decoys, with `keys.json` mapping each to its expected CWE and file:line. `scripts/security_bench_score.py ARM=DIR ...` scores any number of arms (for example Perun against anthropics/claude-code-security-review) on precision, recall with a bootstrap 95% CI (reusing `rival_eval.boot`) and decoy false positives, and fails closed on a missing arm dir or per-diff findings file. Offline; no model is called. Tests in `scripts/test_security_bench_score.py`, wired into `scripts/test-ci-gates.sh`.
diff --git a/scripts/eval-fixtures/security-seeded/diffs/01-sqli.diff b/scripts/eval-fixtures/security-seeded/diffs/01-sqli.diff
new file mode 100644
index 000000000..927844e66
--- /dev/null
+++ b/scripts/eval-fixtures/security-seeded/diffs/01-sqli.diff
@@ -0,0 +1,11 @@
+diff --git a/app/orders.py b/app/orders.py
+new file mode 100644
+--- /dev/null
++++ b/app/orders.py
+@@ -0,0 +1,6 @@
++import sqlite3
++
++def find_orders(db, customer_name):
++ cur = db.cursor()
++ cur.execute("SELECT id, total FROM orders WHERE customer = '" + customer_name + "'")
++ return cur.fetchall()
diff --git a/scripts/eval-fixtures/security-seeded/diffs/02-xss.diff b/scripts/eval-fixtures/security-seeded/diffs/02-xss.diff
new file mode 100644
index 000000000..ccaa7145a
--- /dev/null
+++ b/scripts/eval-fixtures/security-seeded/diffs/02-xss.diff
@@ -0,0 +1,10 @@
+diff --git a/app/greet.py b/app/greet.py
+new file mode 100644
+--- /dev/null
++++ b/app/greet.py
+@@ -0,0 +1,5 @@
++from flask import request
++
++def greet():
++ name = request.args.get("name", "")
++ return "
Hello " + name + "
"
diff --git a/scripts/eval-fixtures/security-seeded/diffs/03-ssrf.diff b/scripts/eval-fixtures/security-seeded/diffs/03-ssrf.diff
new file mode 100644
index 000000000..da46ca45f
--- /dev/null
+++ b/scripts/eval-fixtures/security-seeded/diffs/03-ssrf.diff
@@ -0,0 +1,12 @@
+diff --git a/app/preview.py b/app/preview.py
+new file mode 100644
+--- /dev/null
++++ b/app/preview.py
+@@ -0,0 +1,7 @@
++import urllib.request
++from flask import request
++
++def link_preview():
++ url = request.args["url"]
++ with urllib.request.urlopen(url, timeout=5) as resp:
++ return resp.read(2048)
diff --git a/scripts/eval-fixtures/security-seeded/diffs/04-path-traversal.diff b/scripts/eval-fixtures/security-seeded/diffs/04-path-traversal.diff
new file mode 100644
index 000000000..d709c2f42
--- /dev/null
+++ b/scripts/eval-fixtures/security-seeded/diffs/04-path-traversal.diff
@@ -0,0 +1,13 @@
+diff --git a/app/files.py b/app/files.py
+new file mode 100644
+--- /dev/null
++++ b/app/files.py
+@@ -0,0 +1,8 @@
++import os
++from flask import request, send_file
++
++UPLOAD_DIR = "/srv/acme/uploads"
++
++def download():
++ name = request.args["name"]
++ return send_file(os.path.join(UPLOAD_DIR, name))
diff --git a/scripts/eval-fixtures/security-seeded/diffs/05-authz-bypass.diff b/scripts/eval-fixtures/security-seeded/diffs/05-authz-bypass.diff
new file mode 100644
index 000000000..4bdc27c3f
--- /dev/null
+++ b/scripts/eval-fixtures/security-seeded/diffs/05-authz-bypass.diff
@@ -0,0 +1,18 @@
+diff --git a/app/admin.py b/app/admin.py
+new file mode 100644
+--- /dev/null
++++ b/app/admin.py
+@@ -0,0 +1,13 @@
++from flask import Flask, session
++
++app = Flask(__name__)
++
++@app.route("/admin/users//delete", methods=["POST"])
++def delete_user(uid):
++ if "user_id" not in session:
++ return "login required", 401
++ remove_user(uid)
++ return "ok"
++
++def remove_user(uid):
++ pass
diff --git a/scripts/eval-fixtures/security-seeded/diffs/06-hardcoded-secret.diff b/scripts/eval-fixtures/security-seeded/diffs/06-hardcoded-secret.diff
new file mode 100644
index 000000000..6d09d43a1
--- /dev/null
+++ b/scripts/eval-fixtures/security-seeded/diffs/06-hardcoded-secret.diff
@@ -0,0 +1,11 @@
+diff --git a/app/billing.py b/app/billing.py
+new file mode 100644
+--- /dev/null
++++ b/app/billing.py
+@@ -0,0 +1,6 @@
++import requests
++
++API_KEY = "EXAMPLE_not_real_0000"
++
++def charge(amount):
++ return requests.post("https://billing.example.com/charge", auth=(API_KEY, ""), data={"amount": amount})
diff --git a/scripts/eval-fixtures/security-seeded/diffs/07-deserialization.diff b/scripts/eval-fixtures/security-seeded/diffs/07-deserialization.diff
new file mode 100644
index 000000000..c564f1f64
--- /dev/null
+++ b/scripts/eval-fixtures/security-seeded/diffs/07-deserialization.diff
@@ -0,0 +1,11 @@
+diff --git a/app/session_store.py b/app/session_store.py
+new file mode 100644
+--- /dev/null
++++ b/app/session_store.py
+@@ -0,0 +1,6 @@
++import base64
++import pickle
++
++def load_session(cookie_value):
++ raw = base64.b64decode(cookie_value)
++ return pickle.loads(raw)
diff --git a/scripts/eval-fixtures/security-seeded/diffs/08-command-injection.diff b/scripts/eval-fixtures/security-seeded/diffs/08-command-injection.diff
new file mode 100644
index 000000000..833e50281
--- /dev/null
+++ b/scripts/eval-fixtures/security-seeded/diffs/08-command-injection.diff
@@ -0,0 +1,9 @@
+diff --git a/app/thumbs.py b/app/thumbs.py
+new file mode 100644
+--- /dev/null
++++ b/app/thumbs.py
+@@ -0,0 +1,4 @@
++import subprocess
++
++def make_thumbnail(filename):
++ subprocess.run("convert " + filename + " -resize 64x64 thumb.png", shell=True, check=True)
diff --git a/scripts/eval-fixtures/security-seeded/diffs/09-open-redirect.diff b/scripts/eval-fixtures/security-seeded/diffs/09-open-redirect.diff
new file mode 100644
index 000000000..5937cae51
--- /dev/null
+++ b/scripts/eval-fixtures/security-seeded/diffs/09-open-redirect.diff
@@ -0,0 +1,10 @@
+diff --git a/app/login.py b/app/login.py
+new file mode 100644
+--- /dev/null
++++ b/app/login.py
+@@ -0,0 +1,5 @@
++from flask import request, redirect
++
++def after_login():
++ target = request.args.get("next", "/")
++ return redirect(target)
diff --git a/scripts/eval-fixtures/security-seeded/diffs/10-idor.diff b/scripts/eval-fixtures/security-seeded/diffs/10-idor.diff
new file mode 100644
index 000000000..6582eaff7
--- /dev/null
+++ b/scripts/eval-fixtures/security-seeded/diffs/10-idor.diff
@@ -0,0 +1,14 @@
+diff --git a/app/invoices.py b/app/invoices.py
+new file mode 100644
+--- /dev/null
++++ b/app/invoices.py
+@@ -0,0 +1,9 @@
++from flask import Flask, session, jsonify
++
++app = Flask(__name__)
++
++@app.route("/invoices/")
++def get_invoice(invoice_id):
++ if "user_id" not in session:
++ return "login required", 401
++ return jsonify(Invoice.query.get(invoice_id).to_dict())
diff --git a/scripts/eval-fixtures/security-seeded/diffs/11-toctou.diff b/scripts/eval-fixtures/security-seeded/diffs/11-toctou.diff
new file mode 100644
index 000000000..a5a7489f1
--- /dev/null
+++ b/scripts/eval-fixtures/security-seeded/diffs/11-toctou.diff
@@ -0,0 +1,11 @@
+diff --git a/app/export.py b/app/export.py
+new file mode 100644
+--- /dev/null
++++ b/app/export.py
+@@ -0,0 +1,6 @@
++import os
++
++def write_report(path, data):
++ if not os.path.exists(path):
++ with open(path, "w") as fh:
++ fh.write(data)
diff --git a/scripts/eval-fixtures/security-seeded/diffs/12-crypto-misuse.diff b/scripts/eval-fixtures/security-seeded/diffs/12-crypto-misuse.diff
new file mode 100644
index 000000000..1dd2f086c
--- /dev/null
+++ b/scripts/eval-fixtures/security-seeded/diffs/12-crypto-misuse.diff
@@ -0,0 +1,9 @@
+diff --git a/app/passwords.py b/app/passwords.py
+new file mode 100644
+--- /dev/null
++++ b/app/passwords.py
+@@ -0,0 +1,4 @@
++import hashlib
++
++def hash_password(password):
++ return hashlib.md5(password.encode()).hexdigest()
diff --git a/scripts/eval-fixtures/security-seeded/diffs/13-decoy-sqli-param.diff b/scripts/eval-fixtures/security-seeded/diffs/13-decoy-sqli-param.diff
new file mode 100644
index 000000000..2a9b60bf7
--- /dev/null
+++ b/scripts/eval-fixtures/security-seeded/diffs/13-decoy-sqli-param.diff
@@ -0,0 +1,9 @@
+diff --git a/app/orders_safe.py b/app/orders_safe.py
+new file mode 100644
+--- /dev/null
++++ b/app/orders_safe.py
+@@ -0,0 +1,4 @@
++def find_orders(db, customer_name):
++ cur = db.cursor()
++ cur.execute("SELECT id, total FROM orders WHERE customer = ?", (customer_name,))
++ return cur.fetchall()
diff --git a/scripts/eval-fixtures/security-seeded/diffs/14-decoy-escaped-html.diff b/scripts/eval-fixtures/security-seeded/diffs/14-decoy-escaped-html.diff
new file mode 100644
index 000000000..3b69657ee
--- /dev/null
+++ b/scripts/eval-fixtures/security-seeded/diffs/14-decoy-escaped-html.diff
@@ -0,0 +1,11 @@
+diff --git a/app/greet_safe.py b/app/greet_safe.py
+new file mode 100644
+--- /dev/null
++++ b/app/greet_safe.py
+@@ -0,0 +1,6 @@
++from flask import request
++from markupsafe import escape
++
++def greet():
++ name = request.args.get("name", "")
++ return "Hello " + str(escape(name)) + "
"
diff --git a/scripts/eval-fixtures/security-seeded/diffs/15-decoy-fixed-argv.diff b/scripts/eval-fixtures/security-seeded/diffs/15-decoy-fixed-argv.diff
new file mode 100644
index 000000000..fe6c68f9c
--- /dev/null
+++ b/scripts/eval-fixtures/security-seeded/diffs/15-decoy-fixed-argv.diff
@@ -0,0 +1,9 @@
+diff --git a/app/thumbs_safe.py b/app/thumbs_safe.py
+new file mode 100644
+--- /dev/null
++++ b/app/thumbs_safe.py
+@@ -0,0 +1,4 @@
++import subprocess
++
++def make_thumbnail(path):
++ subprocess.run(["convert", "--", path, "-resize", "64x64", "thumb.png"], check=True)
diff --git a/scripts/eval-fixtures/security-seeded/diffs/16-decoy-env-secret.diff b/scripts/eval-fixtures/security-seeded/diffs/16-decoy-env-secret.diff
new file mode 100644
index 000000000..fec830bc0
--- /dev/null
+++ b/scripts/eval-fixtures/security-seeded/diffs/16-decoy-env-secret.diff
@@ -0,0 +1,13 @@
+diff --git a/app/billing_safe.py b/app/billing_safe.py
+new file mode 100644
+--- /dev/null
++++ b/app/billing_safe.py
+@@ -0,0 +1,8 @@
++import os
++import requests
++
++API_KEY = os.environ["BILLING_API_KEY"]
++EXAMPLE_PLACEHOLDER = "replace-me-in-your-env-file"
++
++def charge(amount):
++ return requests.post("https://billing.example.com/charge", auth=(API_KEY, ""), data={"amount": amount})
diff --git a/scripts/eval-fixtures/security-seeded/keys.json b/scripts/eval-fixtures/security-seeded/keys.json
new file mode 100644
index 000000000..a98ee949c
--- /dev/null
+++ b/scripts/eval-fixtures/security-seeded/keys.json
@@ -0,0 +1,118 @@
+{
+ "01-sqli": {
+ "cwe": "CWE-89",
+ "file": "app/orders.py",
+ "line": 5,
+ "marker": "execute(",
+ "equiv": []
+ },
+ "02-xss": {
+ "cwe": "CWE-79",
+ "file": "app/greet.py",
+ "line": 5,
+ "marker": "\"Hello \"",
+ "equiv": []
+ },
+ "03-ssrf": {
+ "cwe": "CWE-918",
+ "file": "app/preview.py",
+ "line": 6,
+ "marker": "urlopen(",
+ "equiv": []
+ },
+ "04-path-traversal": {
+ "cwe": "CWE-22",
+ "file": "app/files.py",
+ "line": 8,
+ "marker": "send_file(",
+ "equiv": [
+ "CWE-23",
+ "CWE-73"
+ ]
+ },
+ "05-authz-bypass": {
+ "cwe": "CWE-862",
+ "file": "app/admin.py",
+ "line": 9,
+ "marker": "remove_user(uid)",
+ "equiv": [
+ "CWE-306",
+ "CWE-285",
+ "CWE-284"
+ ]
+ },
+ "06-hardcoded-secret": {
+ "cwe": "CWE-798",
+ "file": "app/billing.py",
+ "line": 3,
+ "marker": "API_KEY =",
+ "equiv": [
+ "CWE-259",
+ "CWE-321"
+ ]
+ },
+ "07-deserialization": {
+ "cwe": "CWE-502",
+ "file": "app/session_store.py",
+ "line": 6,
+ "marker": "pickle.loads",
+ "equiv": []
+ },
+ "08-command-injection": {
+ "cwe": "CWE-78",
+ "file": "app/thumbs.py",
+ "line": 4,
+ "marker": "shell=True",
+ "equiv": [
+ "CWE-77"
+ ]
+ },
+ "09-open-redirect": {
+ "cwe": "CWE-601",
+ "file": "app/login.py",
+ "line": 5,
+ "marker": "redirect(",
+ "equiv": []
+ },
+ "10-idor": {
+ "cwe": "CWE-639",
+ "file": "app/invoices.py",
+ "line": 9,
+ "marker": "Invoice.query.get",
+ "equiv": [
+ "CWE-284",
+ "CWE-863"
+ ]
+ },
+ "11-toctou": {
+ "cwe": "CWE-367",
+ "file": "app/export.py",
+ "line": 5,
+ "marker": "open(path",
+ "equiv": [
+ "CWE-362"
+ ]
+ },
+ "12-crypto-misuse": {
+ "cwe": "CWE-327",
+ "file": "app/passwords.py",
+ "line": 4,
+ "marker": "md5(",
+ "equiv": [
+ "CWE-328",
+ "CWE-916"
+ ]
+ },
+ "13-decoy-sqli-param": {
+ "cwe": null
+ },
+ "14-decoy-escaped-html": {
+ "cwe": null
+ },
+ "15-decoy-fixed-argv": {
+ "cwe": null
+ },
+ "16-decoy-env-secret": {
+ "cwe": null
+ }
+}
diff --git a/scripts/security_bench_score.py b/scripts/security_bench_score.py
new file mode 100644
index 000000000..977b9ae3a
--- /dev/null
+++ b/scripts/security_bench_score.py
@@ -0,0 +1,153 @@
+#!/usr/bin/env python3
+"""Score security-review arms on the seeded-vulnerability fixtures (offline; no model is called).
+
+usage: security_bench_score.py ARM=DIR [ARM=DIR ...] [--keys PATH] [--tol N]
+
+Contract: each ARM dir holds one `.json` per diff in keys.json: a JSON list of findings
+`{"file": str, "line": int, "cwe": "CWE-89" | "89" | 89 (optional)}`. An empty list means the arm reviewed that diff and
+found nothing. A SARIF or tool report must be converted to this shape first (file = artifact path, line = region
+startLine, cwe = the CWE tag if the tool gives one).
+Matching: file by normalized suffix (a leading ./, a/ or b/ and absolute prefixes are ignored); CWE by integer id, equal to
+the key's or to one of its `equiv` ids (parent/child pairs); line within --tol (default 1) of the key. A finding with no
+cwe is scored on file+line only and the arm result carries `cwe_missing` (its count). Findings on the same file with the
+same CWE within --tol of each other are collapsed before precision (`dup_findings` counts the collapsed ones). Per arm:
+precision (TP / findings, null with none), recall (matched keys / seeded keys) with the bootstrap 95% CI from
+rival_eval.boot, decoy_fp (decoy diffs with any finding / decoy diffs). Fails closed (exit 2): missing arm dir or
+per-diff file, malformed findings (a bool or non-int line included), invalid keys, negative --tol, duplicate arm names.
+"""
+import argparse
+import json
+import re
+import sys
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).resolve().parent))
+from rival_eval import boot # noqa: E402
+
+KEYS = Path(__file__).resolve().parent / "eval-fixtures/security-seeded/keys.json"
+
+
+def die(msg):
+ print(msg, file=sys.stderr)
+ sys.exit(2)
+
+
+def cwe_id(x):
+ """Integer CWE id from 'CWE-89', ' cwe-89', '89' or 89; None when absent or unparseable (bool is not an id)."""
+ if isinstance(x, int) and not isinstance(x, bool):
+ return x
+ m = re.search(r"\d+", x) if isinstance(x, str) else None
+ return int(m.group()) if m else None
+
+
+def norm_path(p):
+ p = re.sub(r"^(\./|[ab]/)+", "", p.strip().replace("\\", "/"))
+ return p.lstrip("/")
+
+
+def same_file(a, b):
+ a, b = norm_path(a), norm_path(b)
+ return a == b or a.endswith("/" + b) or b.endswith("/" + a)
+
+
+def is_line(x):
+ return isinstance(x, int) and not isinstance(x, bool)
+
+
+def check_keys(keys):
+ if not isinstance(keys, dict) or not keys:
+ die("invalid keys: empty or not an object")
+ for i, k in keys.items():
+ if not isinstance(k, dict) or "cwe" not in k:
+ die(f"invalid keys: {i} lacks cwe")
+ if k["cwe"] is None:
+ continue
+ if cwe_id(k["cwe"]) is None or not isinstance(k.get("file"), str) or not is_line(k.get("line")):
+ die(f"invalid keys: {i} needs a CWE id, file and int line")
+ if not all(cwe_id(e) is not None for e in k.get("equiv", [])):
+ die(f"invalid keys: {i} has a bad equiv")
+ if all(k["cwe"] is None for k in keys.values()):
+ die("invalid keys: no seeded vulnerability")
+
+
+def load(arm_dir, ids):
+ """Return {diff-id: findings}; exit 2 if the dir or any diff's file is missing or malformed."""
+ d = Path(arm_dir)
+ if not d.is_dir():
+ die(f"missing arm dir: {d}")
+ out = {}
+ for i in ids:
+ try:
+ f = json.loads((d / f"{i}.json").read_text())
+ assert isinstance(f, list)
+ for x in f:
+ assert isinstance(x, dict) and isinstance(x.get("file"), str) and is_line(x.get("line"))
+ assert x.get("cwe") is None or isinstance(x["cwe"], (str, int))
+ except (OSError, ValueError, AssertionError):
+ die(f"missing or malformed findings for {i} in {d}")
+ out[i] = f
+ return out
+
+
+def dedup(fs, tol):
+ kept = []
+ for f in fs:
+ if not any(same_file(f["file"], g["file"]) and cwe_id(f.get("cwe")) == cwe_id(g.get("cwe"))
+ and abs(f["line"] - g["line"]) <= tol for g in kept):
+ kept.append(f)
+ return kept
+
+
+def score(keys, found, tol=1):
+ check_keys(keys)
+ tp = n = dups = decoys = decoy_fp = nocwe = 0
+ hits = []
+ for i, k in keys.items():
+ raw = found[i]
+ fs = dedup(raw, tol)
+ dups += len(raw) - len(fs)
+ n += len(fs)
+ nocwe += sum(cwe_id(f.get("cwe")) is None for f in fs)
+ if k["cwe"] is None:
+ decoys += 1
+ decoy_fp += bool(fs)
+ continue
+ ok = {cwe_id(k["cwe"])} | {cwe_id(e) for e in k.get("equiv", [])}
+ hit = any(same_file(f["file"], k["file"]) and cwe_id(f.get("cwe")) in ok | {None}
+ and abs(f["line"] - k["line"]) <= tol for f in fs)
+ tp += hit
+ hits.append(float(hit))
+ lo, hi = boot(hits)
+ r = {"precision": tp / n if n else None, "recall": tp / len(hits), "recall_ci95": [lo, hi], "findings": n,
+ "dup_findings": dups, "true_positives": tp, "decoy_fp": f"{decoy_fp}/{decoys}"}
+ if nocwe:
+ r["cwe_missing"] = nocwe
+ return r
+
+
+def main(argv=None):
+ ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
+ ap.add_argument("arms", nargs="+", metavar="ARM=DIR")
+ ap.add_argument("--keys", default=str(KEYS))
+ ap.add_argument("--tol", type=int, default=1)
+ a = ap.parse_args(argv)
+ if a.tol < 0:
+ die("--tol must be >= 0")
+ try:
+ keys = json.loads(Path(a.keys).read_text())
+ except (OSError, ValueError) as e:
+ die(f"unreadable keys: {e}")
+ check_keys(keys)
+ res = {}
+ for spec in a.arms:
+ name, _, d = spec.partition("=")
+ if not d:
+ die(f"bad arm spec (want ARM=DIR): {spec}")
+ if name in res:
+ die(f"duplicate arm name: {name}")
+ res[name] = score(keys, load(d, keys), a.tol)
+ print(json.dumps(res, indent=2))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/test-ci-gates.sh b/scripts/test-ci-gates.sh
index 7a452b713..8408491e9 100644
--- a/scripts/test-ci-gates.sh
+++ b/scripts/test-ci-gates.sh
@@ -4145,6 +4145,13 @@ else
record 1 "impact_map/context_pack: callers, callees, caps, bounded pack on a synthetic repo"; tail -5 "$WORK/impact-map.log"
fi
+# security_bench_score.py + seeded fixtures: own per-feature file (scripts/test_security_bench_score.py), one case here.
+if python3 "$ROOT/scripts/test_security_bench_score.py" >"$WORK/security-bench.log" 2>&1; then
+ record 0 "security_bench_score: seeded fixtures, precision/recall/decoy FP, fails closed on a missing arm"
+else
+ record 1 "security_bench_score: seeded fixtures, precision/recall/decoy FP, fails closed on a missing arm"; tail -5 "$WORK/security-bench.log"
+fi
+
# install.sh AGENTS.md blocks are compact (<= 6 lines each, markers included)
# and a re-run replaces them in place (idempotent), so a target repo's own
# AGENTS.md size gate is not broken by the default install (#1309).
diff --git a/scripts/test_security_bench_score.py b/scripts/test_security_bench_score.py
new file mode 100644
index 000000000..ff81efcd6
--- /dev/null
+++ b/scripts/test_security_bench_score.py
@@ -0,0 +1,162 @@
+#!/usr/bin/env python3
+"""Tests for scripts/security_bench_score.py and its seeded fixtures. Offline."""
+import json
+import re
+import subprocess
+import sys
+import tempfile
+import unittest
+from pathlib import Path
+
+HERE = Path(__file__).resolve().parent
+sys.path.insert(0, str(HERE))
+import security_bench_score as s # noqa: E402
+
+FIX = HERE / "eval-fixtures/security-seeded"
+KEYS = json.loads((FIX / "keys.json").read_text())
+VULN = {i: k for i, k in KEYS.items() if k["cwe"]}
+FIRST, K1 = next(iter(VULN.items()))
+
+
+def find(k, **kw):
+ return {**{"file": k["file"], "line": k["line"], "cwe": k["cwe"]}, **kw}
+
+
+def arm(root, name, per_diff):
+ d = Path(root) / name
+ d.mkdir()
+ for i in KEYS:
+ (d / f"{i}.json").write_text(json.dumps(per_diff.get(i, [])))
+ return d
+
+
+def added_lines(diff_id):
+ lines = (FIX / "diffs" / f"{diff_id}.diff").read_text().split("\n")
+ start = next(n for n, l in enumerate(lines) if l.startswith("@@"))
+ return [l[1:] for l in lines[start + 1:] if l.startswith("+")]
+
+
+class Fixtures(unittest.TestCase):
+ def test_shape(self):
+ self.assertEqual((len(VULN), len(KEYS) - len(VULN)), (12, 4))
+ self.assertEqual(len({k["cwe"] for k in VULN.values()}), 12)
+ self.assertEqual({p.stem for p in (FIX / "diffs").glob("*.diff")}, set(KEYS))
+
+ def test_key_line_holds_its_marker_and_data_is_fictional(self):
+ for i, k in VULN.items():
+ self.assertIn(k["marker"], added_lines(i)[k["line"] - 1], i)
+ txt = "".join(p.read_text() for p in (FIX / "diffs").glob("*.diff"))
+ self.assertFalse(re.search(r"AKIA[0-9A-Z]{16}|gh[pousr]_\w{36,}|sk_(live|test)_|@(?!example\.com)\w+\.(com|org)\b", txt))
+
+ def test_decoy_argv_ends_options(self):
+ self.assertIn('"--", path', (FIX / "diffs/15-decoy-fixed-argv.diff").read_text())
+
+
+class Normalize(unittest.TestCase):
+ def test_cwe_forms(self):
+ for x in ("CWE-89", "cwe-89", " CWE-89", "89", 89):
+ self.assertEqual(s.cwe_id(x), 89)
+ self.assertIsNone(s.cwe_id(True))
+ self.assertIsNone(s.cwe_id("none"))
+
+ def test_paths(self):
+ for a in ("./app/x.py", "a/app/x.py", "b/app/x.py", "/repo/work/app/x.py", "app/x.py"):
+ self.assertTrue(s.same_file(a, "app/x.py"), a)
+ self.assertFalse(s.same_file("app/y.py", "app/x.py"))
+ self.assertFalse(s.same_file("notapp/x.py", "app/x.py"))
+
+
+class Score(unittest.TestCase):
+ def scored(self, per_diff, **kw):
+ with tempfile.TemporaryDirectory() as t:
+ return s.score(KEYS, s.load(arm(t, "a", per_diff), KEYS), **kw)
+
+ def test_perfect_noisy_and_empty(self):
+ perfect = {i: [find(k, line=k["line"] + 1)] for i, k in VULN.items()}
+ noisy = dict(perfect)
+ noisy["13-decoy-sqli-param"] = [{"file": "app/orders_safe.py", "line": 3, "cwe": "CWE-89"}]
+ p, n, e = self.scored(perfect), self.scored(noisy), self.scored({})
+ self.assertEqual((p["precision"], p["recall"], p["decoy_fp"]), (1.0, 1.0, "0/4"))
+ self.assertEqual((n["recall"], n["decoy_fp"]), (1.0, "1/4"))
+ self.assertAlmostEqual(n["precision"], 12 / 13)
+ self.assertEqual((e["precision"], e["recall"]), (None, 0.0))
+
+ def test_tol_boundary(self):
+ self.assertEqual(self.scored({FIRST: [find(K1, line=K1["line"] + 1)]})["true_positives"], 1)
+ self.assertEqual(self.scored({FIRST: [find(K1, line=K1["line"] + 2)]})["true_positives"], 0)
+ self.assertEqual(self.scored({FIRST: [find(K1, line=K1["line"] + 2)]}, tol=2)["true_positives"], 1)
+
+ def test_wrong_file_and_wrong_cwe(self):
+ self.assertEqual(self.scored({FIRST: [find(K1, file="app/other.py")]})["true_positives"], 0)
+ self.assertEqual(self.scored({FIRST: [find(K1, cwe="CWE-1")]})["true_positives"], 0)
+
+ def test_normalization_and_equiv(self):
+ self.assertEqual(self.scored({FIRST: [find(K1, file="b/./" + K1["file"], cwe=89)]})["true_positives"], 1)
+ i, k = "10-idor", KEYS["10-idor"]
+ self.assertEqual(self.scored({i: [find(k, cwe="CWE-284")]})["true_positives"], 1)
+ self.assertEqual(self.scored({FIRST: [find(K1, cwe="CWE-284")]})["true_positives"], 0)
+
+ def test_missing_cwe_scored_on_file_line(self):
+ r = self.scored({FIRST: [{"file": K1["file"], "line": K1["line"]}]})
+ self.assertEqual((r["true_positives"], r["cwe_missing"]), (1, 1))
+ self.assertNotIn("cwe_missing", self.scored({}))
+
+ def test_duplicates_collapse(self):
+ r = self.scored({FIRST: [find(K1), find(K1, line=K1["line"] + 1), find(K1, cwe="89")]})
+ self.assertEqual((r["true_positives"], r["findings"], r["dup_findings"], r["precision"]), (1, 1, 2, 1.0))
+ far = self.scored({FIRST: [find(K1), find(K1, line=K1["line"] + 9)]})
+ self.assertEqual((far["findings"], far["precision"]), (2, 0.5))
+
+ def test_decoy_fp_alongside_tp(self):
+ r = self.scored({FIRST: [find(K1)], "14-decoy-escaped-html": [{"file": "app/greet_safe.py", "line": 6, "cwe": 79}]})
+ self.assertEqual((r["true_positives"], r["decoy_fp"], r["precision"]), (1, "1/4", 0.5))
+
+
+class FailClosed(unittest.TestCase):
+ def code(self, argv):
+ with self.assertRaises(SystemExit) as c:
+ s.main(argv)
+ return c.exception.code
+
+ def test_arms(self):
+ with tempfile.TemporaryDirectory() as t:
+ ok = arm(t, "ok", {})
+ self.assertEqual(self.code([f"ok={ok}", f"gone={Path(t) / 'nope'}"]), 2)
+ self.assertEqual(self.code([f"ok={ok}", f"ok={ok}"]), 2)
+ self.assertEqual(self.code(["nodir"]), 2)
+ self.assertEqual(self.code([f"ok={ok}", "--tol", "-1"]), 2)
+
+ def test_malformed_findings(self):
+ bad = ['{"not": "a list"}', "not json{", '[{"file": "a.py", "line": true}]', '[{"file": "a.py"}]',
+ '[{"file": "a.py", "line": 3, "cwe": [1]}]']
+ with tempfile.TemporaryDirectory() as t:
+ ok = arm(t, "ok", {})
+ (ok / "01-sqli.json").unlink()
+ self.assertEqual(self.code([f"ok={ok}"]), 2)
+ for b in bad:
+ (ok / "01-sqli.json").write_text(b)
+ self.assertEqual(self.code([f"ok={ok}"]), 2, b)
+
+ def test_invalid_keys(self):
+ cases = ["{}", "[]", '{"x": {"cwe": null}}', '{"x": {"file": "a", "line": 1}}',
+ '{"x": {"cwe": "CWE-89", "file": "a", "line": true}}', '{"x": {"cwe": "oops", "file": "a", "line": 1}}',
+ '{"x": {"cwe": "CWE-89", "file": "a", "line": 1, "equiv": ["zzz"]}}', "not json"]
+ with tempfile.TemporaryDirectory() as t:
+ for n, c in enumerate(cases):
+ kp = Path(t) / f"k{n}.json"
+ kp.write_text(c)
+ self.assertEqual(self.code(["a=" + t, "--keys", str(kp)]), 2, c)
+
+ def test_cli(self):
+ with tempfile.TemporaryDirectory() as t:
+ p = subprocess.run([sys.executable, str(HERE / "security_bench_score.py"), f"a={arm(t, 'a', {})}"],
+ capture_output=True, text=True)
+ q = subprocess.run([sys.executable, str(HERE / "security_bench_score.py"), f"a={Path(t) / 'x'}"],
+ capture_output=True, text=True)
+ self.assertEqual(p.returncode, 0, p.stderr)
+ self.assertEqual(json.loads(p.stdout)["a"]["recall"], 0.0)
+ self.assertEqual(q.returncode, 2)
+
+
+if __name__ == "__main__":
+ unittest.main()