Files
Open_Ultra_SAST/benchmarks/unseen/extract.py
T
Marius CiepluchandClaude Opus 5.5 285bf58f78 unseen-eval: sourcing memory -- compact journal records, bounded caches, streamed history (4.6 GB -> 107 MB RSS)
The sourcing run was killed four times (exit 143/137) after growing to 4.6 GB
RSS on the 7.7 GB VM. Live after the fix: flat 107 MB through 3,029
advisories. Implemented with Codex.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-05 09:18:54 +02:00

520 lines
22 KiB
Python

"""Git-only change extraction; clients and subprocess execution are injectable.
No scanner, engine or model runs here. Function boundaries are syntax, not a security
judgement: the first removed code line of a fix is the SZZ sink proxy. Ambiguous sites
are dropped, and the recorded SZZ bias remains subject to task 2.6's label check.
"""
from __future__ import annotations
import ast
import json
import re
import subprocess
import tempfile
from contextlib import contextmanager
from pathlib import Path
from .source import Candidate, Rejected
from .storage import working_store
LANGUAGES = {".py", ".js", ".jsx", ".mjs", ".cjs", ".ts", ".tsx", ".php", ".java"}
HUNK = re.compile(r"^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@", re.M)
SHA = re.compile(r"[a-f0-9]{40}")
BIAS = {
"introducing": "SZZ last-writer proxy: refactors, moves and reformats can misidentify the introduction in either direction.",
"last_touch": "Pessimistic lower bound: the flaw may already exist in base, so a delta hook can correctly stay quiet.",
}
class InstrumentFailure(ValueError):
"""A failed read or command must never masquerade as an empty history."""
class Git:
def __init__(self, path: Path, *, runner=None, limit_bytes: int | None = None):
self.path = path
self.runner = runner or subprocess.run
self.bytes_read = 0
self.files_read = 0
self.last_blob_bytes = 0
self.limit_bytes = limit_bytes
@contextmanager
def output(self, *args: str, allowed=(0,)):
# Git can expand a tiny pack into enormous stdout. Spool before decoding, including
# stderr, so neither subprocess.communicate nor an error can consume the VM's RAM.
with tempfile.TemporaryFile() as stdout, tempfile.TemporaryFile() as stderr:
try:
done = self.runner(
["git", "-c", "core.quotePath=false", "-C", str(self.path), *args],
stdin=subprocess.DEVNULL,
stdout=stdout,
stderr=stderr,
timeout=180,
check=False,
)
except Exception:
raise InstrumentFailure("git_launch_failed") from None
if done.returncode not in allowed:
raise InstrumentFailure("git_exit_" + str(done.returncode))
if self.limit_bytes is not None and self.disk_bytes() > self.limit_bytes:
raise InstrumentFailure("clone_size_cap")
# Injected runners may return bytes instead of writing the supplied file.
if done.stdout is not None:
stdout.write(done.stdout)
stdout.seek(0)
yield stdout
def run(self, *args: str, allowed=(0,)) -> str:
with self.output(*args, allowed=allowed) as stdout:
raw = stdout.read(8 * 1024 * 1024 + 1)
if len(raw) > 8 * 1024 * 1024:
raise InstrumentFailure("git_output_size_cap")
self.last_blob_bytes = len(raw)
return raw.decode("utf-8", errors="replace")
def history_entries(self, head):
with self.output("log", "--first-parent", "--format=%H%x00%P%x00%ct%x00%an%x00%B%x1e", head) as stdout:
pending = b""
while chunk := stdout.read(64 * 1024):
entries = (pending + chunk).split(b"\x1e")
pending = entries.pop()
for entry in entries:
if len(entry) > 1024 * 1024:
raise InstrumentFailure("git_history_record_size_cap")
yield entry.decode("utf-8", errors="replace")
if len(pending) > 1024 * 1024:
raise InstrumentFailure("git_history_record_size_cap")
if pending.strip():
raise InstrumentFailure("invalid_history")
def disk_bytes(self) -> int:
return sum(p.stat().st_size for p in self.path.rglob("*") if p.is_file() and not p.is_symlink())
def parents(self, sha: str) -> list[str]:
return self.run("rev-list", "--parents", "-n", "1", sha).split()[1:]
def blob(self, sha: str, path: str) -> str | None:
# Missing is established by a successful tree read, never by swallowing a show error.
if not self.run("ls-tree", sha, "--", path).strip():
return None
size = int(self.run("cat-file", "-s", f"{sha}:{path}").strip())
if size > 512 * 1024:
raise InstrumentFailure("source_blob_size_cap")
text = self.run("show", f"{sha}:{path}")
if Path(path).suffix in LANGUAGES:
self.files_read += 1
self.bytes_read += self.last_blob_bytes
return text
def stats(self, base: str, head: str) -> list[tuple[str, int, int]]:
rows = []
for line in self.run("diff", "--no-renames", "--numstat", base, head, "--").splitlines():
added, removed, path = line.split("\t", 2)
rows.append((path, int(added) if added.isdigit() else 0, int(removed) if removed.isdigit() else 0))
return rows
def hunks(self, base: str, head: str, path: str) -> list[tuple[int, int, int, int]]:
diff = self.run("diff", "--no-ext-diff", "--no-renames", "-U0", base, head, "--", path)
return [(int(m[1]), int(m[2] or 1), int(m[3]), int(m[4] or 1)) for m in HUNK.finditer(diff)]
def ancestor(self, older: str, newer: str) -> bool:
# rev-list avoids interpreting a command failure as a negative ancestry result.
return not self.run("rev-list", "--max-count=1", older, "--not", newer).strip()
def blame(self, revision: str, path: str, line: int) -> tuple[str, str, int]:
text = self.run("blame", "-w", "-M", "-C", "--porcelain", f"-L{line},{line}", revision, "--", path)
header = text.splitlines()[0].split()
if len(header) < 3 or not SHA.fullmatch(header[0]):
raise InstrumentFailure("invalid_blame")
filename = next((row[9:] for row in text.splitlines() if row.startswith("filename ")), path)
if filename.startswith('"'):
filename = json.loads(filename)
return header[0], filename, int(header[1])
def functions(path: str, text: str) -> list[tuple[str, int, int]]:
"""Conservative syntax boundaries. No scanner import; unresolved sites are rejected."""
if Path(path).suffix == ".py":
try:
tree = ast.parse(text)
except SyntaxError:
raise Rejected("function_parse") from None
return [
(n.name, n.lineno, n.end_lineno or n.lineno) for n in ast.walk(tree) if isinstance(n, ast.FunctionDef | ast.AsyncFunctionDef)
]
# Blank comments and quoted strings before brace matching. Keep newlines/offsets.
masked = re.sub(
r'/\*.*?\*/|//[^\n]*|\#[^\n]*|"(?:\\.|[^"\\])*"|\'(?:\\.|[^\'\\])*\'|`(?:\\.|[^`\\])*`',
lambda m: "".join("\n" if c == "\n" else " " for c in m[0]),
text,
flags=re.S,
)
declaration = re.compile(
r"\bfunction\s*&?\s*(\w+)\s*\([^)]*\)\s*(?::[^\n{]+)?\s*\{|"
r"\b(?:const|let|var)\s+(\w+)\s*=\s*(?:async\s*)?(?:\([^)]*\)|\w+)\s*=>\s*\{|"
r"\b([\w$]+)\s*\([^;{}]*\)\s*(?:throws\s+[\w., ]+)?\s*\{"
)
found = []
for match in declaration.finditer(masked):
name = next(g for g in match.groups() if g)
if name in {"if", "while", "for", "switch", "catch", "with", "synchronized"}:
continue
depth = 1
end = match.end()
while end < len(masked) and depth:
depth += (masked[end] == "{") - (masked[end] == "}")
end += 1
if depth == 0:
found.append((name, masked.count("\n", 0, match.start()) + 1, masked.count("\n", 0, end) + 1))
return found
def enclosing(path: str, text: str, line: int, *, global_ok=False) -> tuple[str, int, int] | None:
spans = [s for s in functions(path, text) if s[1] <= line <= s[2]]
if spans:
return min(spans, key=lambda s: s[2] - s[1])
return ("<global>", 1, max(1, len(text.splitlines()))) if global_ok else None
def normalized(text: str) -> str:
return re.sub(r"\s+", "", text)
def site_at(git, revision: str, path: str, name: str):
text = git.blob(revision, path)
if text is None:
return None
spans = [("<global>", 1, max(1, len(text.splitlines())))] if name == "<global>" else [s for s in functions(path, text) if s[0] == name]
if len(spans) != 1:
return None
return text, spans[0]
def fix_sites(git, fix: str, parent: str, *, global_ok=False) -> list[dict]:
sites = []
for path, _, _ in git.stats(parent, fix):
if Path(path).suffix not in LANGUAGES:
continue
text = git.blob(parent, path)
if text is None:
continue
lines = text.splitlines()
for start, count, _, _ in git.hunks(parent, fix, path):
for line in range(max(1, start), max(1, start) + max(1, count)):
if line > len(lines):
continue
span = enclosing(path, text, line, global_ok=global_ok)
if span is None:
continue
sink_line = line if count else span[1]
sink = lines[sink_line - 1]
if not sink.strip() or sink.lstrip().startswith(("#", "//", "/*", "*")):
continue
key = (path, span[0])
if any((s["path"], s["function"]) == key for s in sites):
continue
sites.append({"path": path, "function": span[0], "line": sink_line, "text": sink, "absence": count == 0, "span": span})
return sites
def version_bracket(git, candidate: Candidate, revision: str) -> bool:
if not candidate.first_affected:
return True
tags = git.run("tag", "--sort=version:refname").splitlines()
matching = [t for t in tags if t.removeprefix("v") == candidate.first_affected]
if len(matching) != 1:
return False
affected = matching[0]
earlier = [t for t in tags[: tags.index(affected)] if git.ancestor(t, affected)]
return bool(earlier) and git.ancestor(revision, affected) and not git.ancestor(revision, earlier[-1])
def change_files(git, base: str, head: str) -> list[dict]:
return [
{"path": path, "head_lines": [[max(1, new), max(1, new + max(count, 1) - 1)] for _, _, new, count in git.hunks(base, head, path)]}
for path, _, _ in git.stats(base, head)
]
def introducing(git, candidate: Candidate) -> dict:
from collections import Counter
if git.parents(candidate.fix) != [candidate.parent]:
raise Rejected("fix_parents")
sites = fix_sites(git, candidate.fix, candidate.parent, global_ok=candidate.family == "config_secrets")
reasons = Counter()
def record(method, base, head, path, name):
resolved = site_at(git, head, path, name)
if resolved is None:
return None
return {
"kind": "vulnerability",
"base": base,
"head": head,
"method": method,
"family": candidate.family,
"function": name,
"path": path,
"function_lines": list(resolved[1][1:]),
"files": change_files(git, base, head),
"post_cutoff": candidate.post_cutoff,
"advisory": candidate.advisory,
"bias": BIAS[method],
"fallback_reasons": dict(reasons),
}
for site in sites:
revision, path, line = candidate.parent, site["path"], site["line"]
for _ in range(6): # initial blame plus at most five walk-back steps
blamed, blamed_path, blamed_line = git.blame(revision, path, line)
parents = git.parents(blamed)
if len(parents) != 1:
reasons["merge_or_root"] += 1
break
base = parents[0]
head_site = site_at(git, blamed, blamed_path, site["function"])
if head_site is None:
reasons["unresolved_function"] += 1
break
parent_text = git.blob(base, blamed_path)
if parent_text is not None and site["function"] != "<global>":
matching = [span for span in functions(blamed_path, parent_text) if span[0] == site["function"]]
if len(matching) > 1:
reasons["ambiguous_parent"] += 1
break
old_site = site_at(git, base, blamed_path, site["function"])
occurrences = []
if old_site:
text, (_, start, end) = old_site
occurrences = [
i for i, value in enumerate(text.splitlines(), 1) if start <= i <= end and normalized(value) == normalized(site["text"])
]
if occurrences:
reasons["text_present"] += 1
revision, path, line = base, blamed_path, occurrences[0]
continue
stats = git.stats(base, blamed)
if len(stats) > 50 or sum(a + d for _, a, d in stats) > 2000:
reasons["size_cap"] += 1
break
if not version_bracket(git, candidate, blamed):
reasons["version_bracket"] += 1
break
result = record("introducing", base, blamed, blamed_path, site["function"])
if result:
return result
else:
reasons["walkback_limit"] += 1
for site in sites:
# Numeric function extent implements log -L for all five languages, including
# languages for which Git has no built-in :function: driver.
_, start, end = site["span"]
history = git.run("log", "--format=%H", "--no-patch", f"-L{start},{end}:{site['path']}", candidate.parent).splitlines()
if not history:
continue
latest = history[0]
parents = git.parents(latest)
if len(parents) != 1:
continue
result = record("last_touch", parents[0], candidate.parent, site["path"], site["function"])
if result:
return result
raise Rejected("no_range")
SECURITY = re.compile(r"secur|cve|ghsa|vuln|inject|xss|csrf|ssrf|exploit|sanitiz|escap", re.I)
DEPENDENCY = re.compile(r"\bbump\b|\bupgrade .* from\b", re.I)
MANIFESTS = {
"package.json",
"package-lock.json",
"yarn.lock",
"pnpm-lock.yaml",
"composer.json",
"composer.lock",
"pom.xml",
"build.gradle",
"build.gradle.kts",
"gradle.lockfile",
"pyproject.toml",
"setup.py",
"setup.cfg",
"Pipfile",
"Pipfile.lock",
"poetry.lock",
"uv.lock",
}
def generated_path(path: str) -> bool:
return bool({"vendor", "node_modules", "dist"} & set(Path(path).parts)) or path.endswith(".min.js")
def manifest(path: str) -> bool:
name = Path(path).name
return name in MANIFESTS or name.endswith(".lock") or bool(re.fullmatch(r"requirements[^/]*\.txt", name))
def ordinary_rejection(row: dict, fixes: set[str], known_functions: set[tuple[str, str]]) -> str | None:
if len(row["parents"]) != 1:
return "merge_or_root"
if row["head"] in fixes:
return "advisory_fix"
if SECURITY.search(row["message"]):
return "security"
if re.search(r"^Revert|This reverts commit", row["message"], re.I | re.M):
return "revert"
if re.search(r"dependabot|renovate|\[bot\]", row["author"], re.I) or DEPENDENCY.search(row["message"]):
return "dependency"
files = row["files"]
if files and all(manifest(f["path"]) for f in files):
return "manifest_only"
source = [f for f in files if Path(f["path"]).suffix in LANGUAGES and not manifest(f["path"])]
if not source:
return "no_source"
if all(f["generated"] or generated_path(f["path"]) for f in source):
return "generated"
if any((f["path"], name) in known_functions for f in files for name in f["functions"]):
return "vulnerable_function"
if any((f["path"], "<global>") in known_functions for f in files):
return "vulnerable_function"
if sum(f["added"] + f["removed"] for f in source) > 1000:
return "size_cap"
return None
def generated(git, revision: str, path: str, text: str) -> bool:
import fnmatch
if generated_path(path):
return True
header = "\n".join(text.splitlines()[:20])
if re.search(r"(?im)^\s*(?:#|//|/\*|\*|<!--).*?(?:@generated|auto.?generated|generated\s+(?:by|file|code)|do not edit)", header):
return True
result = False
parts = Path(path).parts
for index in range(len(parts)):
directory = "/".join(parts[:index])
attributes = git.blob(revision, (directory + "/" if directory else "") + ".gitattributes") or ""
relative = "/".join(parts[index:])
for line in attributes.splitlines():
tokens = line.split()
if not tokens or tokens[0].startswith("#"):
continue
pattern = tokens[0].lstrip("/")
target = relative if "/" in pattern else Path(relative).name
if fnmatch.fnmatchcase(target, pattern):
for token in tokens[1:]:
if token in {"linguist-generated", "linguist-generated=true"}:
result = True
elif token in {"-linguist-generated", "!linguist-generated", "linguist-generated=false"}:
result = False
return result
def ordinary_history(git, head: str):
"""Yield one commit at a time; no repository-wide blob or diff retention."""
for entry in git.history_entries(head):
if not entry.strip():
continue
fields = entry.strip().split("\x00")
if len(fields) != 5 or not SHA.fullmatch(fields[0]):
raise InstrumentFailure("invalid_history")
sha, parents, timestamp, author, message = fields
row = {"head": sha, "parents": parents.split(), "timestamp": int(timestamp), "author": author, "message": message, "files": []}
if len(row["parents"]) != 1:
yield row
continue
base = row["parents"][0]
for path, added, removed in git.stats(base, sha):
hunks = git.hunks(base, sha, path)
info = {
"path": path,
"added": added,
"removed": removed,
"head_lines": [[max(1, n), max(1, n + max(c, 1) - 1)] for _, _, n, c in hunks],
"functions": [],
"generated": False,
}
if Path(path).suffix in LANGUAGES:
# Inspect both sides: deleting a vulnerable function is still touching it.
for revision, side in ((base, 0), (sha, 2)):
text = git.blob(revision, path)
if text is None:
continue
spans = functions(path, text)
for hunk in hunks:
start, count = hunk[side : side + 2]
for name, lo, hi in spans:
if max(1, start) <= hi and start + max(count, 1) - 1 >= lo:
info["functions"].append(name)
info["generated"] |= generated(git, revision, path, text)
row["files"].append(info)
yield row
def draw_ordinary(rows, fixes: set[str], known_functions: set[tuple[str, str]], *, seed: int) -> tuple[list[dict], dict]:
"""Spill eligible history to disk; keep the original seeded stratified draw exactly."""
with working_store() as db:
return _draw_ordinary(db, rows, fixes, known_functions, seed=seed)
def _draw_ordinary(db, rows, fixes, known_functions, *, seed):
import random
from collections import Counter
counts = Counter()
earliest = latest = None
db.execute("CREATE TABLE eligible (head TEXT, timestamp INTEGER, bucket INTEGER, value TEXT, third INTEGER)")
for row in rows:
timestamp = row["timestamp"]
earliest = timestamp if earliest is None else min(earliest, timestamp)
latest = timestamp if latest is None else max(latest, timestamp)
reason = ordinary_rejection(row, fixes, known_functions)
counts[reason or "eligible"] += 1
if reason is not None:
continue
size = sum(f["added"] + f["removed"] for f in row["files"] if Path(f["path"]).suffix in LANGUAGES and not manifest(f["path"]))
bucket = 0 if size <= 10 else 1 if size <= 50 else 2 if size <= 200 else 3
value = {
"kind": "ordinary",
"base": row["parents"][0],
"head": row["head"],
"method": "first_parent",
"size_bucket": bucket,
"files": [{"path": f["path"], "head_lines": f["head_lines"]} for f in row["files"]],
}
db.execute("INSERT INTO eligible VALUES (?, ?, ?, ?, NULL)", (row["head"], timestamp, bucket, json.dumps(value)))
if counts["eligible"] < 40:
failure = Rejected("ordinary_floor")
failure.counts = dict(counts)
raise failure
db.execute("UPDATE eligible SET third = min(2, 3 * (timestamp - ?) / ?)", (earliest, max(1, latest - earliest + 1)))
cells = {
(third, bucket): count for third, bucket, count in db.execute("SELECT third, bucket, count(*) FROM eligible GROUP BY third, bucket")
}
if {t for t, _ in cells} != {0, 1, 2}:
failure = Rejected("time_thirds")
failure.counts = dict(counts)
raise failure
allocation = Counter()
targets = {cell: 12 * count / counts["eligible"] for cell, count in cells.items()}
for third in range(3):
cell = max(sorted(c for c in cells if c[0] == third), key=lambda c: targets[c])
allocation[cell] = 1
while sum(allocation.values()) < 12:
cell = max(sorted(c for c in cells if allocation[c] < cells[c]), key=lambda c: targets[c] - allocation[c])
allocation[cell] += 1
db.execute("CREATE INDEX eligible_order ON eligible(third, bucket, head)")
rng = random.Random(seed)
drawn = []
for cell in sorted(cells):
for position in rng.sample(range(cells[cell]), allocation[cell]):
(value,) = db.execute(
"SELECT value FROM eligible WHERE third=? AND bucket=? ORDER BY head LIMIT 1 OFFSET ?", (*cell, position)
).fetchone()
drawn.append({**json.loads(value), "time_third": cell[0]})
return drawn, dict(counts)