"""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 from pathlib import Path from .source import Candidate, Rejected 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 def run(self, *args: str, allowed=(0,)) -> str: try: done = self.runner( ["git", "-c", "core.quotePath=false", "-C", str(self.path), *args], stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.PIPE, 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") self.last_blob_bytes = len(done.stdout) return done.stdout.decode("utf-8", errors="replace") 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 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 ("", 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 = [("", 1, max(1, len(text.splitlines())))] if name == "" 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"] != "": 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"], "") 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*(?:#|//|/\*|\*|