mirror of
https://github.com/norandom/OpenUltraSAST.git
synced 2026-10-16 03:49:26 +00:00
src/openultrasast/source.py replaces the copies in semantic/functions.py, plane/tasks/repo_facts.py, learn/features.py, learn/excerpt.py and plane/tasks/verify.py. Two engines share one sentinel (<module>), one DECLARATION/KEYWORDS/RESERVED set and one alias map: the parser engine (ranges, enclosing, bounds, call_sites, callers_of, facts_for) for the scorer, the overlay and the hunter's annotations; the line-pattern engine (declared_functions, enclosing_at, function_span, function_bounds, the brace matcher) that labels, features, roles, excerpts and plane facts were built on. learn no longer imports plane.tasks. Frozen records that store the old sentinel are mapped on read (module_function). Gate: labels rebuilt before and after are identical, 7,921 rows, after sentinel normalisation (benchmarks/measurements/2026-10-13-source-module- label-diff/record.json). 490 targeted tests pass; 3,312 collect. Net: 657 lines deleted, 212 added. benchmarks/unseen keeps <global> for its own frozen manifests. Co-Authored-By: Claude Fable 5.1 <[email protected]>
250 lines
12 KiB
Python
250 lines
12 KiB
Python
"""Materialise and verify the advisory-fix pairs (both sides of each advisory-named function at a real fix).
|
|
|
|
Each pair in ``catalog.toml`` names a public fix commit, its parent, the file and the advisory-named function. This
|
|
script reads the two versions of that one file from a shallow BLOBLESS fetch (``--filter=blob:none``, depth 2) kept in
|
|
the pair cache (``$OPENULTRASAST_PAIR_CACHE`` or ``~/.cache/openultrasast/pairs``), cuts the function out of each side
|
|
with the label builder's own matcher (``openultrasast.learn.excerpt.function_bounds``), and writes the two excerpts
|
|
where ``load_pair_catalog`` looks for a pointer pair (``<cache>/advisory-fixes/<name>/{vuln,fixed}<ext>``). Nothing is
|
|
vendored into the repository, and no scanner, rule, engine or model ever runs on these repositories.
|
|
|
|
Verification (the default, offline) re-reads the cached excerpts the way ``ousast learn labels`` does and fails a
|
|
pair unless: the fix commit differs from its parent, the function is declared on both sides, and its body (the lines
|
|
after the declaration, whitespace-normalised) differs between them. It reports the bytes it read per pair, so a pass
|
|
over an unread cache cannot look like a clean result.
|
|
|
|
PYTHONPATH=src python benchmarks/pairs/advisory-fixes/materialize.py # verify the cache (offline)
|
|
PYTHONPATH=src python benchmarks/pairs/advisory-fixes/materialize.py --fetch # fetch what is missing, verify
|
|
PYTHONPATH=src python benchmarks/pairs/advisory-fixes/materialize.py --candidate --repo o/r --commit F \
|
|
--parent P --path a/b.py --function f --language python --name o-r-f # check one candidate (research)
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import importlib.util
|
|
import json
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import tomllib
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
HERE = Path(__file__).resolve().parent
|
|
CATALOG = HERE / "catalog.toml"
|
|
SLICE = "advisory-fixes"
|
|
MIN_FREE_BYTES = 1_200_000_000 # stop before the host disk drops under 1.2 GB
|
|
MAX_REPO_KB = 150_000 # repositories over ~150 MB are skipped
|
|
_SHA = re.compile(r"[0-9a-f]{40}")
|
|
|
|
|
|
def _harvest() -> Any:
|
|
spec = importlib.util.spec_from_file_location("ousast_pair_harvest", HERE.parent / "harvest.py")
|
|
assert spec is not None and spec.loader is not None
|
|
module = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(module)
|
|
return module
|
|
|
|
|
|
def cache_root() -> Path:
|
|
from openultrasast.pairs import pair_cache_dir
|
|
|
|
return pair_cache_dir() / SLICE
|
|
|
|
|
|
def _git(clone: Path, *args: str, check: bool = True) -> subprocess.CompletedProcess[bytes]:
|
|
return subprocess.run(["git", "-C", str(clone), *args], capture_output=True, check=check, timeout=600)
|
|
|
|
|
|
def _has_commit(clone: Path, sha: str) -> bool:
|
|
return _git(clone, "cat-file", "-e", f"{sha}^{{commit}}", check=False).returncode == 0
|
|
|
|
|
|
def repo_metadata(repo: str) -> dict[str, Any]:
|
|
"""Size (KB) and SPDX licence from the GitHub API through ``gh`` (metadata only)."""
|
|
out = subprocess.run(
|
|
["gh", "api", f"repos/{repo}", "--jq", "{size: .size, license: .license.spdx_id, archived: .archived}"],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=60,
|
|
)
|
|
if out.returncode != 0:
|
|
raise RuntimeError(f"gh api repos/{repo}: {out.stderr.strip()[:200]}")
|
|
return dict(json.loads(out.stdout))
|
|
|
|
|
|
def fetch(repo: str, commit: str, parent: str) -> Path:
|
|
"""A shallow blobless clone holding ``commit`` and ``parent`` (blobs arrive lazily, one per ``git show``)."""
|
|
free = shutil.disk_usage(Path.home()).free
|
|
if free < MIN_FREE_BYTES:
|
|
raise RuntimeError(f"free disk {free / 1e9:.2f} GB is under the 1.2 GB floor; stopping")
|
|
clone = cache_root() / "_clones" / repo.replace("/", "__")
|
|
if not (clone / ".git").is_dir():
|
|
clone.mkdir(parents=True, exist_ok=True)
|
|
_git(clone, "init", "-q")
|
|
_git(clone, "remote", "add", "origin", f"https://github.com/{repo}.git")
|
|
_git(clone, "config", "remote.origin.promisor", "true")
|
|
_git(clone, "config", "remote.origin.partialclonefilter", "blob:none")
|
|
_git(clone, "config", "extensions.partialClone", "origin")
|
|
for sha, depth in ((commit, "2"), (parent, "1")):
|
|
if not _has_commit(clone, sha):
|
|
_git(clone, "fetch", "-q", "--filter=blob:none", f"--depth={depth}", "origin", sha)
|
|
return clone
|
|
|
|
|
|
def show(clone: Path, sha: str, path: str) -> str:
|
|
return _git(clone, "show", f"{sha}:{path}").stdout.decode("utf-8", "replace")
|
|
|
|
|
|
def _comment(language: str) -> str:
|
|
return "#" if language in {"python", "ruby"} else "//"
|
|
|
|
|
|
def _body(lines: list[str], span: tuple[int, int]) -> str:
|
|
return re.sub(r"\s+", " ", "\n".join(lines[span[0] + 1 : span[1]])).strip()
|
|
|
|
|
|
def extract(source: str, language: str, function: str) -> tuple[list[str], tuple[int, int]] | None:
|
|
from openultrasast.source import canonical_language, function_bounds
|
|
|
|
lines = source.splitlines()
|
|
span = function_bounds(lines, canonical_language(language), function)
|
|
return (lines, span) if span else None
|
|
|
|
|
|
def write_side(path: Path, pair: dict[str, Any], *, side: str, sha: str, lines: list[str], span: tuple[int, int]) -> None:
|
|
mark = _comment(str(pair["language"]))
|
|
header = [
|
|
f"Provenance: {pair['repo']} {pair['function']} ({side}).",
|
|
f"repo: {pair['repo']}",
|
|
f"commit: {sha}",
|
|
f"parent: {pair['parent']}",
|
|
f"commit_url: {pair.get('commit_url', '')}",
|
|
f"cve: {pair.get('cve', '')}",
|
|
f"license: {pair.get('license', '')}",
|
|
f"function: {pair['function']}",
|
|
f"relpath: {pair['path']}",
|
|
f"upstream_start: {span[0] + 1}",
|
|
]
|
|
body = "\n".join(lines[span[0] : span[1]])
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text("\n".join(f"{mark} {line}" for line in header) + "\n" + _harvest().redact(body) + "\n", encoding="utf-8")
|
|
|
|
|
|
def excerpt_paths(pair: dict[str, Any]) -> tuple[Path, Path]:
|
|
ext = Path(str(pair["path"])).suffix or ".c"
|
|
folder = cache_root() / str(pair["name"])
|
|
return folder / f"vuln{ext}", folder / f"fixed{ext}"
|
|
|
|
|
|
def materialise(pair: dict[str, Any], *, force: bool = False) -> dict[str, Any]:
|
|
"""Fetch both sides of one pair and write its excerpts; the source bytes read are reported."""
|
|
vuln_path, fixed_path = excerpt_paths(pair)
|
|
if vuln_path.is_file() and fixed_path.is_file() and not force:
|
|
return {"name": pair["name"], "fetched": False}
|
|
commit, parent = str(pair["commit"]), str(pair["parent"])
|
|
clone = fetch(str(pair["repo"]), commit, parent)
|
|
vuln_src = show(clone, parent, str(pair["path"]))
|
|
fixed_src = show(clone, commit, str(pair.get("fix_path") or pair["path"]))
|
|
function, language = str(pair["function"]), str(pair["language"])
|
|
vuln, fixed = extract(vuln_src, language, function), extract(fixed_src, language, function)
|
|
if vuln is None or fixed is None:
|
|
missing = [side for side, got in (("vulnerable", vuln), ("fixed", fixed)) if got is None]
|
|
raise RuntimeError(f"{function} not declared on the {' and '.join(missing)} side")
|
|
write_side(vuln_path, pair, side="vuln", sha=parent, lines=vuln[0], span=vuln[1])
|
|
write_side(fixed_path, pair, side="fixed", sha=commit, lines=fixed[0], span=fixed[1])
|
|
return {"name": pair["name"], "fetched": True, "source_bytes": len(vuln_src.encode()) + len(fixed_src.encode())}
|
|
|
|
|
|
def verify(pair: dict[str, Any]) -> dict[str, Any]:
|
|
"""The label builder's check, from the cached excerpts: declared on both sides, body changed, a real fix."""
|
|
from openultrasast.source import canonical_language, function_bounds
|
|
|
|
result: dict[str, Any] = {"name": pair["name"], "ok": False, "bytes_read": 0}
|
|
commit, parent = str(pair.get("commit", "")), str(pair.get("parent", ""))
|
|
if not (_SHA.fullmatch(commit) and _SHA.fullmatch(parent)) or commit == parent:
|
|
result["reason"] = "fix commit must be a full SHA distinct from its parent"
|
|
return result
|
|
language = canonical_language(str(pair["language"]))
|
|
spans = {}
|
|
for side, path in zip(("vuln", "fixed"), excerpt_paths(pair), strict=True):
|
|
if not path.is_file():
|
|
result["reason"] = f"{side} excerpt not materialised ({path})"
|
|
return result
|
|
text = path.read_text(encoding="utf-8")
|
|
result["bytes_read"] += len(text.encode())
|
|
lines = text.splitlines()
|
|
span = function_bounds(lines, language, str(pair["function"]))
|
|
if span is None:
|
|
result["reason"] = f"{pair['function']} is not declared on the {side} side"
|
|
return result
|
|
spans[side] = _body(lines, span)
|
|
if not spans["vuln"] or spans["vuln"] == spans["fixed"]:
|
|
result["reason"] = "function body is unchanged by the fix"
|
|
return result
|
|
result["ok"] = True
|
|
return result
|
|
|
|
|
|
def _flatten(item: dict[str, Any]) -> dict[str, Any]:
|
|
pair = dict(item)
|
|
expected = (item.get("expected") or [{}])[0]
|
|
pair.setdefault("function", expected.get("function", ""))
|
|
return pair
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
parser.add_argument("--catalog", type=Path, default=CATALOG)
|
|
parser.add_argument("--fetch", action="store_true", help="fetch missing sides over the network, then verify")
|
|
parser.add_argument("--force", action="store_true", help="re-fetch and rewrite excerpts that exist")
|
|
parser.add_argument("--json", type=Path, help="write the per-pair report here")
|
|
parser.add_argument("--candidate", action="store_true", help="check one candidate given on the command line")
|
|
for key in ("repo", "commit", "parent", "path", "fix-path", "function", "language", "name"):
|
|
parser.add_argument(f"--{key}")
|
|
args = parser.parse_args(argv)
|
|
if args.candidate:
|
|
pair = {k: getattr(args, k.replace("-", "_")) for k in ("repo", "commit", "parent", "path", "function", "language", "name")}
|
|
pair["fix_path"] = args.fix_path
|
|
try:
|
|
meta = repo_metadata(pair["repo"])
|
|
if int(meta.get("size") or 0) > MAX_REPO_KB:
|
|
print(json.dumps({"name": pair["name"], "ok": False, "reason": f"repository is {meta['size']} KB (> 150 MB)", **meta}))
|
|
return 1
|
|
fetched = materialise(pair, force=args.force)
|
|
except (RuntimeError, subprocess.CalledProcessError, subprocess.TimeoutExpired) as exc:
|
|
detail = (
|
|
exc.stderr.decode(errors="replace")[:200] if isinstance(exc, subprocess.CalledProcessError) and exc.stderr else str(exc)
|
|
)
|
|
print(json.dumps({"name": pair["name"], "ok": False, "reason": f"fetch: {detail}"}))
|
|
return 1
|
|
report = {**verify(pair), **{k: v for k, v in fetched.items() if k != "name"}, **meta}
|
|
print(json.dumps(report))
|
|
return 0 if report["ok"] else 1
|
|
pairs = [_flatten(item) for item in tomllib.loads(args.catalog.read_text()).get("pair", [])]
|
|
rows = []
|
|
for pair in pairs:
|
|
fetched: dict[str, Any] = {}
|
|
if args.fetch:
|
|
try:
|
|
fetched = materialise(pair, force=args.force)
|
|
except (RuntimeError, subprocess.CalledProcessError, subprocess.TimeoutExpired) as exc:
|
|
fetched = {"fetch_error": str(exc)[:200]}
|
|
rows.append({**verify(pair), **{k: v for k, v in fetched.items() if k != "name"}})
|
|
failed = [row for row in rows if not row["ok"]]
|
|
for row in rows:
|
|
print(
|
|
f"{'ok ' if row['ok'] else 'FAIL'} {row['name']}: {row['bytes_read']} bytes read"
|
|
+ ("" if row["ok"] else f" -- {row['reason']}")
|
|
)
|
|
total = sum(row["bytes_read"] for row in rows)
|
|
print(f"{len(rows) - len(failed)}/{len(rows)} pairs verified; {total} excerpt bytes read")
|
|
if args.json:
|
|
args.json.write_text(json.dumps({"pairs": rows, "verified": len(rows) - len(failed), "bytes_read": total}, indent=2) + "\n")
|
|
return 1 if failed or not rows else 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|