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()