#!/usr/bin/env python3 """Shared pair harvest library: extract one function from public parent/fix blobs. Maintainer-only; CI stays offline (tests run the extractors on local strings). Does not clone mozilla-central, gecko-dev, or Chromium. Advisory-only recipes (no parent and commit) are rejected. FixFox is not a fetch source. Modes (recipe ``mode``, default ``name``): - ``name`` anchor on the declarator of ``function`` (comment and string mentions ignored) - ``line_range`` copy ``line_start..line_end`` from the parent blob and ``fix_line_start..fix_line_end`` (default: same) from the fix blob - ``hunk`` take the function enclosing the first changed hunk on each side - ``enclosing`` take the function enclosing ``line`` on the parent blob and ``fix_line`` (default: same) on the fix blob; ``fix_path`` may differ Languages: c, cpp, java, javascript, typescript (brace-balanced), python (indentation), perl (``sub``). """ from __future__ import annotations import argparse import difflib import json import re import sys import tomllib from collections.abc import Sequence from pathlib import Path from typing import Any from urllib.parse import urlparse from urllib.request import Request, urlopen ROOT = Path(__file__).resolve().parent ALLOWED_HOSTS = { "raw.githubusercontent.com", "github.com", "phabricator.services.mozilla.com", "hg.mozilla.org", "hg-edge.mozilla.org", } BLOCKED_TOKENS = ("fixfox", "zenodo.org/record") MODES = ("name", "line_range", "hunk", "enclosing", "handler_context") _SUFFIX_LANGUAGE = { ".c": "c", ".h": "c", ".cc": "cpp", ".cpp": "cpp", ".hpp": "cpp", ".java": "java", ".js": "javascript", ".mjs": "javascript", ".cjs": "javascript", ".ts": "typescript", ".tsx": "typescript", ".py": "python", ".pl": "perl", ".pm": "perl", ".yml": "yaml", ".yaml": "yaml", } _HASH_COMMENT = {"python", "perl", "yaml"} _CONTEXT_LANGUAGES = {"yaml", "javascript", "typescript", "python"} _DEF_RE = re.compile(r"^(\s*)(?:async\s+)?def\s+([A-Za-z_]\w*)\s*\(") _CLASS_RE = re.compile(r"^(\s*)class\s+([A-Za-z_]\w*)\s*[(:]") _COMMENTED_CODE = re.compile(r"^#\s*(?:@|(?:async\s+)?def\s|class\s|function\s)") _CONTROL_HEAD = re.compile(r"^(?:else\s+)?(?:if|for|while|switch|catch|do|try|finally)\b") # A module-level import or its continuation: `import ... from 'x'`, `from 'x'`, `export ... from 'x'`. _IMPORT_LINE = re.compile(r"^(?:import\b|export\b.*\bfrom\b|from\s+[\'\"])") _INDENTED = {"python", "yaml"} class RecipeError(ValueError): """Invalid harvest recipe or extraction failure.""" # --- masking ----------------------------------------------------------------- def mask_comments_and_strings(source: str, language: str = "c") -> str: """Return ``source`` with comment and string-literal bodies replaced by spaces. Same length, newlines preserved, so indexes and line numbers line up with the original. Braces and identifiers inside comments or strings therefore never anchor an extraction. """ out = list(source) n = len(source) i = 0 hash_comments = language in _HASH_COMMENT slash_comments = not hash_comments while i < n: ch = source[i] nxt = source[i + 1] if i + 1 < n else "" if hash_comments and ch == "#": while i < n and source[i] != "\n": out[i] = " " i += 1 continue if slash_comments and ch == "/" and nxt == "/": while i < n and source[i] != "\n": out[i] = " " i += 1 continue if slash_comments and ch == "/" and nxt == "*": out[i] = " " out[i + 1] = " " i += 2 while i < n and not (source[i] == "*" and i + 1 < n and source[i + 1] == "/"): if source[i] != "\n": out[i] = " " i += 1 if i < n: out[i] = " " if i + 1 < n: out[i + 1] = " " i += 2 continue if language == "python" and ch in "\"'" and source.startswith(ch * 3, i): quote = ch * 3 for k in range(i, min(i + 3, n)): out[k] = " " # blank the delimiters too: a closing triple quote at column 0 is not a dedent i += 3 while i < n and not source.startswith(quote, i): if source[i] != "\n": out[i] = " " i += 1 for k in range(i, min(i + 3, n)): out[k] = " " i += 3 continue if ch in "\"'`": quote = ch i += 1 while i < n and source[i] != quote: if source[i] == "\\" and i + 1 < n: out[i] = " " if source[i + 1] != "\n": out[i + 1] = " " i += 2 continue if source[i] == "\n" and quote != "`": break # unterminated single-line literal; stop masking at EOL if source[i] != "\n": out[i] = " " i += 1 i += 1 continue i += 1 return "".join(out) # --- extraction -------------------------------------------------------------- def language_for(recipe: dict[str, Any]) -> str: language = str(recipe.get("language") or "").lower() if language: return {"c++": "cpp", "js": "javascript", "ts": "typescript"}.get(language, language) return _SUFFIX_LANGUAGE.get(Path(str(recipe.get("path", ""))).suffix.lower(), "c") def extract_function(source: str, function_name: str, language: str = "c") -> str: """Copy the named function from its declarator through its balanced body.""" if language == "python": return extract_python_block(source, function_name) masked = mask_comments_and_strings(source, language) needle = function_name idx = 0 while True: found = masked.find(needle, idx) if found < 0: raise RecipeError(f"function not found: {function_name}") idx = found + len(needle) before = masked[found - 1] if found else " " if before.isalnum() or before == "_" or before == ".": continue rest = masked[idx:].lstrip() if not (rest.startswith("(") or rest.startswith("<") or rest.startswith("{")): continue brace = masked.find("{", found) semi = masked.find(";", found) if brace < 0 or (0 <= semi < brace): continue # prototype / declaration, keep searching break start = _declarator_start(source, masked, found) end = _balanced_end(masked, brace) return source[start : end + 1].strip() + "\n" def extract_python_block(source: str, function_name: str) -> str: lines = source.splitlines(keepends=True) masked = mask_comments_and_strings(source, "python").splitlines(keepends=True) for index, line in enumerate(lines): match = _DEF_RE.match(line) if match is None or match.group(2) != function_name: continue indent = len(match.group(1)) start = _decorator_start(lines, index, indent) end = _python_block_end(masked, index, indent) return "".join(lines[start:end]).rstrip("\n") + "\n" raise RecipeError(f"function not found: {function_name}") def _decorator_start(lines: Sequence[str], index: int, indent: int) -> int: """First line of the decorator run above ``index``: multi-line decorators and comments included. A commented-out decorator is not noise: `#@token_required` above a handler is the whole bug in an absence row, and an excerpt that starts below it hides the only trace the guard ever existed. `@ns.doc(...)` routinely wraps over four lines, and stopping at its continuation drops the `@ns.route(...)` above it — the registration itself (learning-harness Req 10.2).""" first = index cursor = index - 1 while cursor >= 0: stripped = lines[cursor].strip() if not stripped: break if _indent(lines[cursor]) == indent and stripped.startswith("@"): first = cursor cursor -= 1 continue if _indent(lines[cursor]) == indent and stripped.startswith("#"): if _COMMENTED_CODE.match(stripped): first = cursor # `#@token_required` above the handler is the removed guard, not prose cursor -= 1 continue anchor = _decorator_anchor(lines, cursor, indent) if anchor is None: break first = anchor cursor = anchor - 1 return first def _decorator_anchor(lines: Sequence[str], cursor: int, indent: int) -> int | None: """The `@` line of the multi-line decorator whose last line is ``cursor``, or None when ``cursor`` is not one.""" for start in range(cursor, -1, -1): stripped = lines[start].strip() if _indent(lines[start]) == indent and stripped.startswith("@"): whole = " ".join(lines[position].strip() for position in range(start, cursor + 1)) opened = " ".join(lines[position].strip() for position in range(start, cursor)) balanced = whole.count("(") == whole.count(")") and opened.count("(") > opened.count(")") return start if balanced else None if stripped and _indent(lines[start]) <= indent: return None return None def extract_handler_context(source: str, function_name: str, language: str = "c", *, line: int | None = None) -> str: """The named handler with everything that says it is reachable: its own decorators, the class its framework registers (a `Resource`/`MethodView` method carries its route on the class, not on the function), and every statement outside it that names the function as an identifier — `app.add_url_rule(..., view_func=fn)`, `router.get('/x', fn)`, `path('x', fn)`, `app.use(fn)`. Comment and string mentions never count (the masked text is searched). Pass ``line`` when the name repeats in the file: `def get` appears once per resource class, so the name alone cannot say which handler a row labels. One excerpt per side, so guards living in decorators, routers and middleware are visible to the teacher and the scorer (authorization-obligations Req 7.1, learning-harness Req 10.2).""" lines = source.splitlines() if line is not None: bounds = innermost_function_bounds(source, int(line), language) if bounds is None: raise RecipeError(f"line {line} is not inside a function") start, end = bounds[0] - 1, bounds[1] # function_bounds_at anchors on the declarator; the decorators above it are part of the handler start = _decorator_start(lines, start, _indent(lines[start])) body = "\n".join(lines[start:end]) + "\n" else: body = extract_function(source, function_name, language) start = _start_line_of(source, body) - 1 end = start + len(body.splitlines()) header = _python_class_header(lines, start) if language == "python" else () masked_lines = mask_comments_and_strings(source, language).splitlines() mention = re.compile(rf"(? list[str]: """Each registration mention expanded to the statement that contains it, deduplicated and in order. A bare line is not always a statement: `export default { exportProject: expressify(exportProject) }` mentions the handler inside an object literal, and appending that one line leaves the excerpt unparsable — which costs the pair its function ranges and drops it out of every denominator.""" if language in _INDENTED: return [lines[index].strip() for index in hits] out: list[str] = [] taken: set[int] = set(covered) for index in hits: if index in taken: continue span = _statement_span(source, lines, index) if span is None: taken.add(index) out.append(lines[index].strip()) continue first, last = span if any(position in taken for position in range(first, last + 1)): continue taken.update(range(first, last + 1)) out.append("\n".join(lines[first : last + 1]).rstrip()) return out def _statement_span(source: str, lines: Sequence[str], index: int) -> tuple[int, int] | None: """The line range of the smallest balanced `(...)`/`{...}`/`[...]` construct enclosing ``index``, with the line that opens it; None when the line already stands on its own.""" masked_lines = mask_comments_and_strings(source, "javascript").splitlines() depth = 0 first = index for position in range(index, -1, -1): for char in reversed(masked_lines[position] if position < len(masked_lines) else ""): if char in ")}]": depth += 1 elif char in "({[": if depth == 0: first = position break depth -= 1 else: continue break else: return None if first == index and _balanced(masked_lines[index] if index < len(masked_lines) else ""): return None depth = 0 for last in range(first, len(lines)): line = masked_lines[last] if last < len(masked_lines) else "" depth += sum(line.count(char) for char in "({[") - sum(line.count(char) for char in ")}]") if last >= index and depth <= 0: return first, last return first, len(lines) - 1 def _balanced(line: str) -> bool: return sum(line.count(char) for char in "({[") == sum(line.count(char) for char in ")}]") def _indent(line: str) -> int: return len(line) - len(line.lstrip()) def _python_class_header(lines: list[str], start: int) -> tuple[int, ...]: """Line indexes of the class declaration enclosing the handler at ``start``, with its decorators. Flask-RESTX, Django REST framework and Flask's `MethodView` register the *class*: `@ns.route('/profile')` sits above `class UserProfile(Resource)`, and the method below it is the handler. An excerpt that starts at `def get` is both unreachable and unparsable (a bare indented block), so the class header travels with it.""" if start >= len(lines) or _indent(lines[start]) == 0: return () indent = _indent(lines[start]) for index in range(start - 1, -1, -1): match = _CLASS_RE.match(lines[index]) if match is None or len(match.group(1)) >= indent: continue return tuple(range(_decorator_start(lines, index, len(match.group(1))), index + 1)) return () def innermost_function_bounds(source: str, line: int, language: str = "c") -> tuple[int, int] | None: """1-based inclusive bounds of the *innermost* function containing ``line``. `function_bounds_at` returns the outermost top-level brace group, which is right for C and wrong for the JavaScript and TypeScript the absence corpus is made of: a handler declared inside a React component or a route module comes back as the whole 6000-line component, and the excerpt starts mid-parameter-list. Only the handler-context mode uses this, so hunk and enclosing rows keep the bounds they were harvested with.""" if language == "python": return function_bounds_at(source, line, language) masked = mask_comments_and_strings(source, language) offsets = _line_offsets(source) if line < 1 or line > len(offsets): return None target = offsets[line - 1] stack: list[int] = [] best: tuple[int, int] | None = None for pos, ch in enumerate(masked): if ch == "{": stack.append(pos) elif ch == "}" and stack: open_at = stack.pop() if pos < target: continue start = _declarator_start(source, masked, open_at) if not (start <= target <= pos): continue # the declarator line counts: `const f = async () => {` opens its brace after the anchor head = masked[start:open_at] if "(" not in head or ")" not in head or _CONTROL_HEAD.match(head.strip()): continue # an object literal, a bare block, or `if (...) {` — not a callable if head.count("{") != head.count("}"): continue # the declarator walk crossed an opening brace: this is a block inside the function, not the function span = (_line_of(offsets, start), _line_of(offsets, pos)) if best is None or span[0] > best[0]: best = span return best def extract_registration_context(source: str, function_name: str, language: str = "c") -> str: """The part of a *separate* document that registers ``function_name``, or "" when it names it nowhere. Some frameworks keep the route table out of the handler file: connexion registers by `operationId` in an OpenAPI document, an Express application registers in a router module. That statement carries the guard — the `security` block, the authorization middleware — so an absence row harvested without it cannot be judged at all. Comment and string mentions never count. Indented documents return the enclosing block with its ancestor headings; brace documents return the whole enclosing call, which is where the middleware list lives.""" masked_lines = mask_comments_and_strings(source, language).splitlines() lines = source.splitlines() mention = re.compile(rf"(? str: """The innermost block containing ``hit``, prefixed by the heading line of every ancestor above it.""" indent = _indent(lines[hit]) ancestors: list[int] = [] current = indent for index in range(hit - 1, -1, -1): if not lines[index].strip(): continue if _indent(lines[index]) < current: ancestors.append(index) current = _indent(lines[index]) if current == 0: break if not ancestors: return lines[hit] + "\n" inner = ancestors[0] end = inner + 1 while end < len(lines) and (not lines[end].strip() or _indent(lines[end]) > _indent(lines[inner])): end += 1 body = [*reversed(ancestors[1:]), *range(inner, end)] return "\n".join(lines[index] for index in body).rstrip("\n") + "\n" def _enclosing_call(source: str, masked_lines: list[str], lines: list[str], hit: int, function_name: str) -> str: """The whole call statement around the mention: `webRouter.post(` … `)` spans four lines and three of them are the guard.""" masked = "\n".join(masked_lines) offsets = _line_offsets(masked + "\n") at = masked.find(function_name, offsets[hit]) if at < 0: return lines[hit] + "\n" depth = 0 open_at = -1 for pos in range(at - 1, -1, -1): ch = masked[pos] if ch == ")": depth += 1 elif ch == "(": if depth == 0: open_at = pos break depth -= 1 if open_at < 0: return lines[hit] + "\n" depth = 0 close_at = len(masked) - 1 for pos in range(open_at, len(masked)): if masked[pos] == "(": depth += 1 elif masked[pos] == ")": depth -= 1 if depth == 0: close_at = pos break start = _line_of(offsets, open_at) - 1 end = _line_of(offsets, close_at) return "\n".join(lines[start:end]).rstrip("\n") + "\n" def extract_line_range(source: str, start: int, end: int) -> str: """Inclusive 1-based line range.""" lines = source.splitlines(keepends=True) if start < 1 or end < start or end > len(lines): raise RecipeError(f"line range {start}..{end} outside 1..{len(lines)}") return "".join(lines[start - 1 : end]).rstrip("\n") + "\n" def function_bounds_at(source: str, line: int, language: str = "c") -> tuple[int, int] | None: """1-based inclusive (start, end) of the function enclosing ``line``; None when not inside one.""" lines = source.splitlines(keepends=True) if line < 1 or line > len(lines): return None if language == "python": return _python_bounds_at(lines, line) masked = mask_comments_and_strings(source, language) offsets = _line_offsets(source) target = offsets[line - 1] depth = 0 open_at: int | None = None for pos, ch in enumerate(masked): if ch == "{": if depth == 0: open_at = pos depth += 1 elif ch == "}": depth -= 1 if depth == 0 and open_at is not None: if open_at <= target <= pos or _line_of(offsets, open_at) <= line <= _line_of(offsets, pos): start = _declarator_start(source, masked, open_at) return _line_of(offsets, start), _line_of(offsets, pos) open_at = None return None WHOLE_FILE_MAX_LINES = 150 def extract_hunk(parent_source: str, fix_source: str, language: str = "c") -> tuple[str, str]: """Functions enclosing the first changed hunk that sits inside a function on both sides. Fallback for small modules (npm one-file packages, route files) whose change is at module level: when both blobs are at most ``WHOLE_FILE_MAX_LINES`` lines, the whole file is the excerpt. Larger module-level changes are a RecipeError. """ parent_lines = parent_source.splitlines() fix_lines = fix_source.splitlines() matcher = difflib.SequenceMatcher(a=parent_lines, b=fix_lines, autojunk=False) changed = [(tag, i1, i2, j1, j2) for tag, i1, i2, j1, j2 in matcher.get_opcodes() if tag != "equal"] if not changed: raise RecipeError("parent and fix blobs are identical") for _tag, i1, i2, j1, j2 in changed: parent_line = i1 + 1 if i2 > i1 else max(i1, 1) fix_line = j1 + 1 if j2 > j1 else max(j1, 1) parent_bounds = function_bounds_at(parent_source, parent_line, language) fix_bounds = function_bounds_at(fix_source, fix_line, language) if parent_bounds is not None and fix_bounds is not None: return ( extract_line_range(parent_source, *parent_bounds), extract_line_range(fix_source, *fix_bounds), ) if len(parent_lines) <= WHOLE_FILE_MAX_LINES and len(fix_lines) <= WHOLE_FILE_MAX_LINES: return parent_source.rstrip("\n") + "\n", fix_source.rstrip("\n") + "\n" raise RecipeError("no changed hunk is inside a function on both sides and the file is too large to vendor whole") def _declarator_start(source: str, masked: str, anchor: int) -> int: """Start offset of the declarator statement that owns ``anchor`` (walk back to the previous ';', '}' or preprocessor line).""" pos = anchor - 1 while pos >= 0: ch = masked[pos] if ch in ";}": break if ch == "\n": line_start = masked.rfind("\n", 0, pos) + 1 if masked[line_start:pos].lstrip().startswith("#"): break pos -= 1 start = pos + 1 # Skip blank and comment-only lines between the boundary and the declarator, and any import or export # statement the boundary landed inside. JavaScript written without semicolons puts the nearest `}` in # `import { x } from '...'`, so the walk-back stops mid-statement and the excerpt does not parse at all. while True: line_end = source.find("\n", start) if line_end < 0: break line = masked[start:line_end].strip() if line and not _IMPORT_LINE.match(line): break start = line_end + 1 return start def _balanced_end(masked: str, brace: int) -> int: depth = 0 for pos in range(brace, len(masked)): ch = masked[pos] if ch == "{": depth += 1 elif ch == "}": depth -= 1 if depth == 0: return pos raise RecipeError("unbalanced braces") def _line_offsets(source: str) -> list[int]: offsets = [0] for index, ch in enumerate(source): if ch == "\n": offsets.append(index + 1) return offsets def _line_of(offsets: list[int], pos: int) -> int: low, high = 0, len(offsets) - 1 while low < high: mid = (low + high + 1) // 2 if offsets[mid] <= pos: low = mid else: high = mid - 1 return low + 1 def _python_block_end(lines: list[str], def_index: int, indent: int) -> int: """End index of the block starting at ``def_index``. Pass masked lines so the bodies of triple-quoted strings (blank after masking) never terminate the block.""" end = def_index + 1 last_code = def_index + 1 while end < len(lines): line = lines[end] stripped = line.strip() if stripped and not stripped.startswith("#"): if len(line) - len(line.lstrip()) <= indent: break last_code = end + 1 end += 1 return last_code def _python_bounds_at(lines: list[str], line: int) -> tuple[int, int] | None: index = line - 1 masked = mask_comments_and_strings("".join(lines), "python").splitlines(keepends=True) for def_index in range(index, -1, -1): match = _DEF_RE.match(lines[def_index]) if match is None: continue indent = len(match.group(1)) end = _python_block_end(masked, def_index, indent) if def_index <= index < end: return def_index + 1, end return None # --- recipes and IO ---------------------------------------------------------- def provenance_header(recipe: dict[str, Any], *, side: str, sha: str, upstream_start: int | None = None, relpath: str | None = None) -> str: lines = [ f"Provenance: {recipe.get('repo', '')} {recipe.get('function', '')} ({side}).", f"repo: {recipe.get('repo', '')}", f"commit: {sha}", f"parent: {recipe.get('parent', '')}", f"commit_url: {recipe.get('commit_url', '')}", f"cve: {recipe.get('cve', '')}", f"license: {recipe.get('license', '')}", f"function: {recipe.get('function', '')}", f"relpath: {recipe.get('relpath', recipe.get('path', ''))}", f"provenance: {recipe.get('provenance', 'human')}", f"mechanism: {recipe.get('mechanism', 'other')}", ] if upstream_start is not None: lines.append(f"upstream_start: {upstream_start}") # 1-based line of the excerpt body in the upstream file if relpath is not None: lines[8] = f"relpath: {relpath}" # a context document names itself, not the handler file language = language_for(recipe) if relpath is None else _SUFFIX_LANGUAGE.get(Path(relpath).suffix.lower(), "c") if language in _HASH_COMMENT: return "\n".join(f"# {line}" for line in lines) + "\n" if language in {"cpp", "java", "javascript", "typescript"}: return "\n".join(f"// {line}" for line in lines) + "\n" return "/* " + "\n * ".join(lines) + "\n */\n" def validate_recipe(recipe: dict[str, Any], *, require_license: bool = True) -> None: name = str(recipe.get("name", "")) if any(token in str(recipe).lower() for token in BLOCKED_TOKENS): raise RecipeError(f"{name}: embargoed or blocked dataset") if not recipe.get("parent") or not recipe.get("commit"): raise RecipeError(f"{name}: advisory-only recipe rejected; parent and commit required") mode = str(recipe.get("mode", "name")) if mode not in MODES: raise RecipeError(f"{name}: unknown mode {mode!r}; expected one of {MODES}") if not recipe.get("path"): raise RecipeError(f"{name}: path required") if mode in {"name", "handler_context"} and not recipe.get("function"): raise RecipeError(f"{name}: path and function required") if mode == "line_range" and not (isinstance(recipe.get("line_start"), int) and isinstance(recipe.get("line_end"), int)): raise RecipeError(f"{name}: line_range mode needs integer line_start and line_end") if mode == "enclosing" and not isinstance(recipe.get("line"), int): raise RecipeError(f"{name}: enclosing mode needs an integer line") if require_license and not recipe.get("license"): raise RecipeError(f"{name}: license required") for relpath in context_relpaths(recipe): suffix = Path(relpath).suffix.lower() if _SUFFIX_LANGUAGE.get(suffix) not in _CONTEXT_LANGUAGES: raise RecipeError(f"{name}: context document {relpath} has no supported registration reader") def github_raw_url(repo: str, sha: str, path: str) -> str: return f"https://raw.githubusercontent.com/{repo}/{sha}/{path}" def excerpt_paths(recipe: dict[str, Any], root: Path) -> tuple[Path, Path]: name = str(recipe["name"]) ext = Path(str(recipe.get("path", "a.c"))).suffix or ".c" folder = root / name return folder / f"vuln{ext}", folder / f"fixed{ext}" def context_relpaths(recipe: dict[str, Any]) -> tuple[str, ...]: """Repo-relative documents that register the handler, fetched at the same two commits as the excerpt.""" value = recipe.get("context") or () if isinstance(value, str): return (value,) return tuple(str(item) for item in value) def context_paths(recipe: dict[str, Any], root: Path) -> tuple[tuple[str, Path, Path], ...]: """`(relpath, vuln document, fixed document)` per context document. Each side keeps its own copy: the route table is part of the change often enough (a guard added to the router, not to the handler) that sharing one would hide the fix.""" folder = root / str(recipe["name"]) / "context" return tuple((relpath, folder / "vuln" / relpath, folder / "fixed" / relpath) for relpath in context_relpaths(recipe)) _TOKEN_PATTERNS: tuple[tuple[re.Pattern[str], str], ...] = ( (re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----.*?-----END [A-Z ]*PRIVATE KEY-----", re.DOTALL), "***REDACTED***"), (re.compile(r"(Bearer\s+)[A-Za-z0-9._\-]{12,}"), r"\1***REDACTED***"), (re.compile(r"\bsk-[A-Za-z0-9._\-]{16,}"), "***REDACTED***"), (re.compile(r"\bAKIA[0-9A-Z]{16}\b"), "***REDACTED***"), (re.compile(r"\bgh[pousr]_[A-Za-z0-9]{20,}"), "***REDACTED***"), (re.compile(r"\bxox[baprs]-[A-Za-z0-9-]{10,}"), "***REDACTED***"), (re.compile(r"\bAIza[A-Za-z0-9_\-]{20,}"), "***REDACTED***"), (re.compile(r"([a-zA-Z][a-zA-Z0-9+.\-]*://[^\s:/@]+:)[^\s:/@]{3,}(@)"), r"\1***REDACTED***\2"), # Generic sensitive assignment: ONLY quoted literal values, so redaction never breaks code # (`password = request.form[...]` and `token = getToken()` are code, not secrets). ( re.compile(r"(?i)\b(api[_-]?key|secret|token|password|passwd|pwd|access[_-]?key)\b(\s*[=:]\s*)(['\"])[A-Za-z0-9/+_\-]{8,}\3"), r"\1\2\3***REDACTED***\3", ), ) def redact(body: str) -> str: """Mask credential-shaped literals in an excerpt without changing code shape (mirrors openultrasast.redaction, quoted-only generic rule).""" for pattern, replacement in _TOKEN_PATTERNS: body = pattern.sub(replacement, body) return body def write_excerpt( path: Path, recipe: dict[str, Any], *, side: str, sha: str, body: str, upstream_start: int | None = None, relpath: str | None = None, ) -> None: path.parent.mkdir(parents=True, exist_ok=True) header = provenance_header(recipe, side=side, sha=sha, upstream_start=upstream_start, relpath=relpath) path.write_text(header + "\n" + redact(body), encoding="utf-8") def extract_pair_with_lines(recipe: dict[str, Any], parent_src: str, fix_src: str) -> tuple[tuple[str, int], tuple[str, int]]: """Like ``extract_pair`` but also returns the 1-based upstream start line of each excerpt body.""" language = language_for(recipe) mode = str(recipe.get("mode", "name")) if mode == "line_range": start, end = int(recipe["line_start"]), int(recipe["line_end"]) fix_start = int(recipe.get("fix_line_start", start)) fix_end = int(recipe.get("fix_line_end", end)) return (extract_line_range(parent_src, start, end), start), (extract_line_range(fix_src, fix_start, fix_end), fix_start) if mode == "hunk": return _hunk_with_lines(parent_src, fix_src, language) if mode == "enclosing": line = int(recipe["line"]) fix_line = int(recipe.get("fix_line", line)) parent_bounds = function_bounds_at(parent_src, line, language) fix_bounds = function_bounds_at(fix_src, fix_line, language) if parent_bounds is None or fix_bounds is None: raise RecipeError(f"{recipe.get('name')}: line {line}/{fix_line} is not inside a function") return (extract_line_range(parent_src, *parent_bounds), parent_bounds[0]), (extract_line_range(fix_src, *fix_bounds), fix_bounds[0]) function = str(recipe["function"]) if mode == "handler_context": parent_text, fix_text = _handler_context_pair(recipe, parent_src, fix_src, language) else: parent_text, fix_text = extract_function(parent_src, function, language), extract_function(fix_src, function, language) return (parent_text, _start_line_of(parent_src, parent_text)), (fix_text, _start_line_of(fix_src, fix_text)) def _handler_context_pair(recipe: dict[str, Any], parent_src: str, fix_src: str, language: str) -> tuple[str, str]: """Both sides with their registration. ``line``/``fix_line`` anchor when the name repeats or when the fixed twin is a different, correctly guarded handler from the same snapshot (a Real-Vuln-Benchmark trap): then ``fix_function`` names what the fixed side actually contains.""" line = recipe.get("line") fix_line = recipe.get("fix_line", line) return ( extract_handler_context(parent_src, str(recipe["function"]), language, line=int(line) if isinstance(line, int) else None), extract_handler_context( fix_src, str(recipe.get("fix_function") or recipe["function"]), language, line=int(fix_line) if isinstance(fix_line, int) else None, ), ) def _start_line_of(source: str, excerpt: str) -> int: first = excerpt.splitlines()[0] if excerpt.strip() else "" for index, line in enumerate(source.splitlines(), start=1): if line.rstrip() == first.rstrip(): return index return 1 def _hunk_with_lines(parent_source: str, fix_source: str, language: str) -> tuple[tuple[str, int], tuple[str, int]]: parent_lines = parent_source.splitlines() fix_lines = fix_source.splitlines() matcher = difflib.SequenceMatcher(a=parent_lines, b=fix_lines, autojunk=False) changed = [(tag, i1, i2, j1, j2) for tag, i1, i2, j1, j2 in matcher.get_opcodes() if tag != "equal"] if not changed: raise RecipeError("parent and fix blobs are identical") for _tag, i1, i2, j1, j2 in changed: parent_line = i1 + 1 if i2 > i1 else max(i1, 1) fix_line = j1 + 1 if j2 > j1 else max(j1, 1) parent_bounds = function_bounds_at(parent_source, parent_line, language) fix_bounds = function_bounds_at(fix_source, fix_line, language) if parent_bounds is not None and fix_bounds is not None: return (extract_line_range(parent_source, *parent_bounds), parent_bounds[0]), ( extract_line_range(fix_source, *fix_bounds), fix_bounds[0], ) if len(parent_lines) <= WHOLE_FILE_MAX_LINES and len(fix_lines) <= WHOLE_FILE_MAX_LINES: return (parent_source.rstrip("\n") + "\n", 1), (fix_source.rstrip("\n") + "\n", 1) raise RecipeError("no changed hunk is inside a function on both sides and the file is too large to vendor whole") def extract_pair(recipe: dict[str, Any], parent_src: str, fix_src: str) -> tuple[str, str]: """Apply the recipe's mode to two blobs. Pure; unit-tested offline.""" language = language_for(recipe) mode = str(recipe.get("mode", "name")) if mode == "line_range": start, end = int(recipe["line_start"]), int(recipe["line_end"]) fix_start = int(recipe.get("fix_line_start", start)) fix_end = int(recipe.get("fix_line_end", end)) return extract_line_range(parent_src, start, end), extract_line_range(fix_src, fix_start, fix_end) if mode == "hunk": return extract_hunk(parent_src, fix_src, language) if mode == "enclosing": line = int(recipe["line"]) fix_line = int(recipe.get("fix_line", line)) parent_bounds = function_bounds_at(parent_src, line, language) fix_bounds = function_bounds_at(fix_src, fix_line, language) if parent_bounds is None or fix_bounds is None: raise RecipeError(f"{recipe.get('name')}: line {line}/{fix_line} is not inside a function") return extract_line_range(parent_src, *parent_bounds), extract_line_range(fix_src, *fix_bounds) function = str(recipe["function"]) if mode == "handler_context": return _handler_context_pair(recipe, parent_src, fix_src, language) return extract_function(parent_src, function, language), extract_function(fix_src, function, language) def harvest_recipe(recipe: dict[str, Any], root: Path, *, require_license: bool = True) -> tuple[Path, Path]: """Fetch every blob the recipe names, extract, and only then write. Every fetch happens before the first write on purpose: a re-harvest that half succeeds would leave a reviewed excerpt truncated and its context document stale, and nothing downstream could tell that apart from a real upstream change (learning-harness Req 10.2).""" validate_recipe(recipe, require_license=require_license) if str(recipe.get("host", "github")) != "github": raise RecipeError(f"{recipe.get('name')}: live fetch currently implemented for host=github only") repo, parent, commit = str(recipe["repo"]), str(recipe["parent"]), str(recipe["commit"]) fix_repo = str(recipe.get("fix_repo", repo)) parent_src = fetch_url(github_raw_url(repo, parent, str(recipe["path"]))) # fix_path lets a pair take its fixed side from another file of the same snapshot # (Real-Vuln-Benchmark false-positive traps are the fixed twins of vulnerable findings). fix_src = fetch_url(github_raw_url(fix_repo, commit, str(recipe.get("fix_path", recipe["path"])))) function = str(recipe.get("function", "")) fix_function = str(recipe.get("fix_function") or function) documents: list[tuple[Path, str, str, str, str]] = [] for relpath, vuln_doc, fixed_doc in context_paths(recipe, root): language = _SUFFIX_LANGUAGE.get(Path(relpath).suffix.lower(), "c") for path, side, sha, wanted, source in ( (vuln_doc, "vuln", parent, function, fetch_url(github_raw_url(repo, parent, relpath))), (fixed_doc, "fixed", commit, fix_function, fetch_url(github_raw_url(fix_repo, commit, relpath))), ): stanza = extract_registration_context(source, wanted, language) if not stanza.strip(): raise RecipeError(f"{recipe.get('name')}: context document {relpath} never names {wanted}") documents.append((path, side, sha, stanza, relpath)) (parent_fn, parent_start), (fix_fn, fix_start) = extract_pair_with_lines(recipe, parent_src, fix_src) vuln_path, fixed_path = excerpt_paths(recipe, root) write_excerpt(vuln_path, recipe, side="vuln", sha=parent, body=parent_fn, upstream_start=parent_start) write_excerpt(fixed_path, recipe, side="fixed", sha=commit, body=fix_fn, upstream_start=fix_start) for path, side, sha, stanza, relpath in documents: write_excerpt(path, recipe, side=side, sha=sha, body=stanza, relpath=relpath) return vuln_path, fixed_path def materialize_pointer(recipe: dict[str, Any], cache_root: Path, *, slice_name: str) -> tuple[Path, Path]: """Harvest a non-vendored pair into ``cache_root///`` (Req 10.2). The cache lives outside the repository; a missing upstream license is allowed here because nothing is redistributed, the excerpts stay on the operator's machine. Redaction applies as for vendored excerpts. """ root = Path(cache_root) / slice_name vuln_path, fixed_path = excerpt_paths(recipe, root) if vuln_path.is_file() and fixed_path.is_file(): return vuln_path, fixed_path return harvest_recipe(recipe, root, require_license=False) def fetch_url(url: str, timeout: int = 60) -> str: host = urlparse(url).hostname or "" if host not in ALLOWED_HOSTS: raise RecipeError(f"host not allowed: {host}") request = Request(url, headers={"User-Agent": "OpenUltraSAST-pair-harvest"}) with urlopen(request, timeout=timeout) as response: # noqa: S310 — host allowlisted return response.read().decode("utf-8", "replace") def merge_recipes(existing: list[dict[str, Any]], new: list[dict[str, Any]]) -> tuple[list[dict[str, Any]], list[str]]: """Append ``new`` recipes whose name is not already present; existing rows are never rewritten (vendored labels stay put).""" names = {str(r["name"]) for r in existing} added = [r for r in new if str(r["name"]) not in names] return [*existing, *added], [str(r["name"]) for r in added] def load_recipes(path: Path) -> tuple[dict[str, Any], ...]: payload = tomllib.loads(path.read_text()) return tuple(dict(item) for item in payload.get("recipe", [])) def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--slice", default="vfc", help="slice directory under benchmarks/pairs (default: vfc)") parser.add_argument("--recipes", type=Path, default=None, help="recipes file (default: /recipes.toml)") parser.add_argument("--name", help="recipe name to harvest (omit with --all)") parser.add_argument("--all", action="store_true", help="process every recipe") parser.add_argument("--prefix", help="process every recipe whose name starts with this prefix") parser.add_argument("--fetch", action="store_true", help="HTTP-fetch and write excerpts; default is dry-run validate") parser.add_argument("--force", action="store_true", help="overwrite existing excerpts") parser.add_argument("--out", type=Path, default=None, help="excerpt root (default: /)") parser.add_argument("--report", type=Path, default=None, help="write a JSON row per recipe with its status and reason") args = parser.parse_args(argv) slice_root = ROOT / args.slice recipes_path = args.recipes or slice_root / "recipes.toml" out = args.out or slice_root loaded = load_recipes(recipes_path) recipes = {str(item["name"]): item for item in loaded} selected: list[dict[str, Any]] if args.all: selected = list(loaded) elif args.prefix: selected = [item for item in loaded if str(item["name"]).startswith(args.prefix)] elif args.name: if args.name not in recipes: print(f"unknown recipe: {args.name}", file=sys.stderr) return 2 selected = [recipes[args.name]] else: print("pass --name RECIPE, --prefix PREFIX, or --all", file=sys.stderr) return 2 failures = 0 report: list[dict[str, str]] = [] for recipe in selected: name = str(recipe["name"]) try: validate_recipe(recipe) except RecipeError as exc: print(str(exc), file=sys.stderr) report.append({"name": name, "status": "failed", "reason": str(exc)}) failures += 1 continue if not args.fetch: print(f"ok {name} parent={recipe['parent']} commit={recipe['commit']} mode={recipe.get('mode', 'name')}") report.append({"name": name, "status": "validated", "reason": ""}) continue vuln_path, fixed_path = excerpt_paths(recipe, out) if vuln_path.is_file() and fixed_path.is_file() and not args.force: print(f"skip {name} (exists)") report.append({"name": name, "status": "skipped", "reason": "excerpt already present"}) continue try: harvest_recipe(recipe, out) except (RecipeError, OSError) as exc: # The excerpt on disk is the reviewed one; a fetch that failed leaves it exactly as it was and says why. print(f"{name}: {exc}", file=sys.stderr) report.append({"name": name, "status": "failed", "reason": str(exc)}) failures += 1 continue print(f"wrote {name} -> {vuln_path.parent}") report.append({"name": name, "status": "written", "reason": ""}) if args.report is not None: args.report.parent.mkdir(parents=True, exist_ok=True) args.report.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8") return 1 if failures else 0 if __name__ == "__main__": raise SystemExit(main())