Files
Open_Ultra_SAST/benchmarks/measurements/2026-09-30-php-quick-rules/measure_php_quick.py
T
Marius CiepluchandClaude Opus 5.5 d1aabf0f66 ci: green again -- format and lint the whole tree; push the scan and runner images to GHCR on tags
CI and the release job check `ruff format --check .` and `ruff check .` over the
whole repository, while the local gate checked only src and tests, so eight
benchmark scripts drifted and the v2.0.0 release job stopped before building.
The fixes are formatting, two noqa comments and one renamed loop variable; no
script computes anything differently. The local gate now checks the whole tree.

The release workflow gains an images job: Dockerfile -> ghcr.io/<owner>/openultrasast,
plane/Dockerfile.runner -> ghcr.io/<owner>/openultrasast-runner, tagged with the
version, and the digests written to the job summary.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EmM1j46qgKMh3zqD1QMwEo
2026-10-02 20:09:28 +02:00

182 lines
8.9 KiB
Python

"""Measure the PHP quick-mode rules on the PHP development corpus (never population-v2).
Pins: pmpro vulnerable / fixed / benign (benchmarks/push/inputs.json, role development) and the spent
population-v1 PHP cases' vulnerable and fixed pins, exported beforehand with benchmarks/independent/evaluate.py's
`export` into <corpus>/checkouts/<case>/<label> (pmpro from the repo cache, benign = vulnerable + the declared
authored edit).
Known sites: pmpro's is the declared CVE-2023-23488 function (getMemberOrderByCode) on each pin; a v1 case's are
its fix hunks (vulnerable side on the vulnerable pin, fixed side on the fixed pin; test paths dropped) widened by
evaluate.LINE_WINDOW, plus the declared sink line where the fix does not touch the sink (LearnPress).
The instrument must have read its input: every pin reports the PHP files and bytes the scan read, and a pin with
zero PHP bytes fails the run.
Usage: python measure_php_quick.py <corpus dir> <out.json>
"""
from __future__ import annotations
import json
import sys
import time
from collections import Counter, defaultdict
from pathlib import Path
sys.path.insert(0, "benchmarks/independent")
import evaluate # noqa: E402
from openultrasast.findings import quick_scan_findings # noqa: E402
from openultrasast.mapping import analyze_entry_points, attach_reachability_hints # noqa: E402
from openultrasast.preprocess import preprocess_repository # noqa: E402
from openultrasast.rank import rank_targets # noqa: E402
V1 = ("wp-ultimate-member-sqli", "wp-learnpress-sqli", "phpmyfaq-captcha-ua-sqli", "yeswiki-bazar-form-sqli")
DECLARED = {"wp-learnpress-sqli": ("inc/Databases/class-lp-db.php", "$FIELDS = implode( ',', array_unique( $filter->only_fields ) );")}
W = evaluate.LINE_WINDOW
def scan(root: Path) -> tuple[list, dict]:
_, targets = preprocess_repository(root)
php = [t for t in targets if t.language == "php"]
read = {"php_files": len(php), "php_bytes": sum(len((root / t.path).read_bytes()) for t in php)}
if not read["php_bytes"]:
raise SystemExit(f"instrument failure: no PHP bytes read under {root}")
targets = attach_reachability_hints(targets, analyze_entry_points(root, targets))
started = time.monotonic()
findings = [f for f in quick_scan_findings(root, targets, rank_targets(targets)) if f.finding_id.startswith("php-")]
read["scan_seconds"] = round(time.monotonic() - started, 1)
return findings, read
def function_range(path: Path, name: str) -> tuple[int, int]:
lines = path.read_text(errors="replace").splitlines()
start = next(i for i, line in enumerate(lines, 1) if f"function {name}(" in line)
end = next((i for i, line in enumerate(lines[start:], start + 1) if "function " in line), len(lines))
return start, end - 1
def line_of(path: Path, needle: str) -> int:
return next(i for i, line in enumerate(path.read_text(errors="replace").splitlines(), 1) if needle in line)
def at(finding, sites: dict[str, list[tuple[int, int]]]) -> bool:
return any(first <= (finding.line or -1) <= last for first, last in sites.get(finding.path, []))
def summarize(findings: list, sites: dict[str, list[tuple[int, int]]]) -> dict:
hits = [f for f in findings if at(f, sites)]
return {
"findings": len(findings),
"by_rule": dict(sorted(Counter(f.finding_id.split(":", 1)[0] for f in findings).items())),
"by_rule_enabled": len([f for f in findings if f.status == "enabled"]),
"at_known_site": [f.finding_id for f in hits],
}
def main() -> int:
corpus, out = Path(sys.argv[1]), Path(sys.argv[2])
checkouts = corpus / "checkouts"
result: dict = {"window": W, "cases": {}}
# pmpro: the declared CVE function on each pin.
pmpro_dirs = {
"vulnerable": Path.home() / ".cache/openultrasast/repos/pmpro/1a900be3f09f",
"fixed": Path.home() / ".cache/openultrasast/repos/pmpro/2f65530f9442",
"benign": checkouts / "pmpro" / "benign",
}
pmpro: dict = {}
for label, root in pmpro_dirs.items():
findings, read = scan(root)
rel = "classes/class.memberorder.php"
first, last = function_range(root / rel, "getMemberOrderByCode")
sites = {rel: [(first, last)]}
pmpro[label] = {"root": str(root), "read": read, "known_site": f"{rel}:{first}-{last}", **summarize(findings, sites)}
pmpro[label]["_ids"] = sorted(f.finding_id for f in findings)
print("pmpro", label, read, pmpro[label]["findings"], pmpro[label]["at_known_site"], file=sys.stderr)
result["cases"]["pmpro"] = pmpro
for case in evaluate.cases():
if case["id"] not in V1:
continue
entry: dict = {}
for label, side in (("vulnerable", "old"), ("fixed", "new")):
root = checkouts / case["id"] / label
findings, read = scan(root)
sites = {
path: [(a - W, b + W) for a, b in ranges]
for path, ranges in evaluate.hunks(case, side).items()
if path.endswith(".php") and not path.startswith(("tests/", "test/")) and "/tests/" not in path
}
if case["id"] in DECLARED:
rel, needle = DECLARED[case["id"]]
n = line_of(root / rel, needle)
sites.setdefault(rel, []).append((n - W, n + W))
entry[label] = {"read": read, "known_sites": {p: r for p, r in sorted(sites.items())}, **summarize(findings, sites)}
entry[label]["_ids"] = sorted(f.finding_id for f in findings)
print(case["id"], label, read, entry[label]["findings"], entry[label]["at_known_site"], file=sys.stderr)
result["cases"][case["id"]] = entry
result["fixtures"] = fixtures()
result["rules"] = per_rule(result)
for pins in result["cases"].values():
for pin in pins.values():
pin.pop("_ids")
out.write_text(json.dumps(result, indent=1, sort_keys=True) + "\n")
return 0
def fixtures() -> dict:
"""The committed fixture benchmarks, scored exactly as `ousast benchmark` scores them."""
from openultrasast.benchmark import BenchmarkRun, evaluate_benchmark, load_benchmark_manifest, resolve_benchmark_source
from openultrasast.gate import _quick_scan
result = {}
for name in ("php-vulnerable", "php-benign"):
path = Path("benchmarks/manifests") / f"{name}.toml"
manifest = load_benchmark_manifest(path)
target = resolve_benchmark_source(path, manifest)
findings = _quick_scan(target)
run = BenchmarkRun(benchmark_run_id="php-quick", root=Path("/tmp/php-quick"), manifest=manifest)
bench = evaluate_benchmark(run=run, mode="quick", findings=findings, scan_id=None, scan_run_dir=None)
m = bench.metrics
result[name] = {
"expected": m.expected_findings_total, "matched": m.matched_findings_total, "findings": m.actual_findings_total,
"false_positives": [fp.finding_id for fp in bench.false_positives], "missed": [x.rule_id or x.cwe for x in bench.misses],
"true_positives": sorted({f.finding_id for f in findings} - {fp.finding_id for fp in bench.false_positives}),
} # fmt: skip
return result
def per_rule(result: dict) -> dict:
"""Firings per rule and the status/precision policy applied to them (see the module docstring of the ruleset).
enabled -- the rule hit a known vulnerable site on the corpus, or it never fired on the corpus (no false-alert
pressure measured; fixture evidence only);
shadow -- it fired on the corpus but never at a known site: every corpus firing was on code whose known bug is
elsewhere, and it fired as often on the fixed pins.
precision lower bound = (known-site hits on vulnerable pins + fixture true positives)
/ (firings on vulnerable pins + fixture firings, both twins); an unlabeled firing counts false.
"""
rows: dict = defaultdict(lambda: defaultdict(int))
for pins in result["cases"].values():
for label, pin in pins.items():
for fid in pin["_ids"]:
rows[fid.split(":", 1)[0]][label] += 1
for fid in pin["at_known_site"]:
rows[fid.split(":", 1)[0]][label + "_at_site"] += 1
for _name, fixture in result["fixtures"].items():
for fid in fixture["true_positives"]:
rows[fid.split(":", 1)[0]]["fixture_tp"] += 1
for fid in fixture["false_positives"]:
rows[fid.split(":", 1)[0]]["fixture_fp"] += 1
rules = {}
for rule, row in sorted(rows.items()):
fired, at_site = row["vulnerable"], row["vulnerable_at_site"]
status = "enabled" if at_site or not fired else "shadow"
denominator = fired + row["fixture_tp"] + row["fixture_fp"]
precision = round((at_site + row["fixture_tp"]) / denominator, 3) if denominator else None
rules[rule] = {**dict(sorted(row.items())), "status": status, "precision_lower_bound": precision, "n": denominator}
return rules
if __name__ == "__main__":
raise SystemExit(main())