mirror of
https://github.com/norandom/OpenUltraSAST.git
synced 2026-10-16 11:49:28 +00:00
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
182 lines
8.9 KiB
Python
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())
|