Files
Open_Ultra_SAST/src/openultrasast/repos.py
T
Marius CiepluchandClaude Opus 5 8c387306be feat(repos): pinned known-vulnerable checkouts, offline by default
The pair corpus scores excerpts: a vulnerable function and its fixed twin,
handed to the engine with the answer already narrowed down. Nothing in it
exercises a repository -- region ranking, the budget, one CPG over
thousands of files, or whether a verdict that holds on fifteen lines still
holds when the source, the sink and the guard live in three modules.

benchmarks/repos/<name>.toml names a real checkout: url, a full 40-char
commit SHA, licence, and every known vulnerability with its file, function
and line. `ousast repos` lists them; `--fetch` materialises one; CI does
neither, and conftest clears the switch and redirects the cache so a
developer with warm checkouts cannot read them by accident.

Three rules the loader enforces rather than documents:

- A branch or tag is rejected. Either would make the same recipe mean
  different code next month while the baseline it produced kept the old
  numbers.
- `in_scope` must be stated per vulnerability, never defaulted. It decides
  whether "not found" is a miss or a non-question, which is the most
  important thing about the result.
- A recipe that declares `detection` must name something in scope to
  detect.

`ousast repos` does not trust a recipe either: against a materialised
checkout it checks HEAD against the pin, that the licence file is there,
and that each declared function appears above its declared line. That
earned its place immediately -- the first libpng recipe named LICENSE.md,
which is the filename at HEAD today and not at the pinned commit, where it
is still LICENSE.

Two checkouts, verified against the real repositories:

- vampi (MIT, 520 lines) is the DETECTION checkout. Excerpts of it are
  already in the pair corpus, including the update_password miss diagnosed
  earlier, so a whole-repository run says directly whether a verdict
  survives the move off the excerpt. Its routes register through an
  OpenAPI operationId, so it exercises registration-based entry-point
  mapping rather than the decorator path.
- libpng (libpng-2.0, 91k lines of C) is the ENVELOPE checkout, pinned at
  the parent of the commit that added the missing clamp. Its CVE is marked
  out of scope: an out-of-bounds read is not a family this engine
  arbitrates, and recording "not found" against it as a miss is the
  easiest way to make a measurement lie.

Nothing is vendored; the recipe records the licence so an artifact can say
what it ran against.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01T7XD7x5KonMdmGRkp63EQG
2026-09-09 08:10:59 +02:00

286 lines
11 KiB
Python

"""Pinned known-vulnerable repository checkouts (Req 4.1).
The corpus scores excerpts. Nothing in it exercises a whole repository: region ranking, the budget, one CPG
over thousands of files, or whether a verdict that holds on a fifteen-line excerpt still holds when the
source, the sink and the guard live in three different modules. These recipes name real checkouts so that
question can be measured instead of assumed.
Two rules carried over from the pointer pairs, for the same reasons:
* **Offline by default.** A recipe is inert until someone asks for the network, by ``--fetch`` for one run or
``OPENULTRASAST_REPOS_NETWORK=1``. CI sets neither, so CI never fetches.
* **Pinned, not tracked.** ``commit`` must be a full SHA. A branch or a tag would make the same recipe mean
different code next month, and a measurement against moving code is not a baseline.
A third rule is specific to these: **a recipe declares what it can measure.** ``in_scope`` says, per known
vulnerability, whether it belongs to a family the model layer arbitrates at all. A memory-safety CVE in a C
library is a fine envelope test and an impossible detection test, and a measurement that does not separate
the two invites reading "not found" as a miss when it was never in scope.
"""
from __future__ import annotations
import os
import re
import subprocess
import tomllib
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from pathlib import Path
REPO_CACHE_ENV = "OPENULTRASAST_REPO_CACHE"
REPOS_NETWORK_ENV = "OPENULTRASAST_REPOS_NETWORK"
DEFAULT_REPO_CACHE = Path.home() / ".cache" / "openultrasast" / "repos"
DEFAULT_REPO_DIR = Path("benchmarks/repos")
MEASURES = ("envelope", "detection")
_SHA = re.compile(r"^[0-9a-f]{40}$")
_RECIPE_REQUIRED = ("name", "url", "commit", "language", "license", "license_file", "measures")
_KNOWN_REQUIRED = ("id", "family", "weakness", "file", "function", "line", "reference")
# How far above the declared line the function signature may sit. This is a consistency check between
# three fields, not a parse: C functions in libpng run past ninety lines, so a tight window rejects
# honest recipes, and a window this size still catches a line number that belongs to a different
# function entirely.
_FUNCTION_WINDOW = 200
class RepoRecipeError(ValueError):
"""A recipe that cannot be trusted to name one checkout."""
class RepoUnavailable(RuntimeError):
"""The checkout is not on disk and the network was not offered."""
@dataclass(frozen=True)
class KnownVulnerability:
"""One documented vulnerability, and whether this engine could decide it even in principle."""
id: str
family: str
weakness: str
file: str
function: str
line: int
reference: str
in_scope: bool
@property
def location(self) -> str:
return f"{self.file}:{self.function}:{self.line}"
@dataclass(frozen=True)
class RepoRecipe:
name: str
url: str
commit: str
language: str
license: str
license_file: str
measures: tuple[str, ...]
known: tuple[KnownVulnerability, ...]
environment: Mapping[str, str]
note: str
@property
def in_scope(self) -> tuple[KnownVulnerability, ...]:
"""The known vulnerabilities a rung can be reported for; the rest are envelope-only."""
return tuple(item for item in self.known if item.in_scope)
def load_repo_recipes(directory: Path = DEFAULT_REPO_DIR) -> tuple[RepoRecipe, ...]:
if not directory.is_dir():
return ()
return tuple(load_repo_recipe(path) for path in sorted(directory.glob("*.toml")))
def load_repo_recipe(path: Path) -> RepoRecipe:
data = tomllib.loads(path.read_text())
missing = [key for key in _RECIPE_REQUIRED if not data.get(key)]
if missing:
raise RepoRecipeError(f"{path.name}: missing {', '.join(missing)}")
commit = str(data["commit"])
if not _SHA.match(commit):
# A tag moves when it is re-cut and a branch moves every week. Either would make this recipe name
# different code over time while the baseline it produced kept the old numbers.
raise RepoRecipeError(f"{path.name}: commit must be a full 40-character SHA, not {commit!r}")
measures = tuple(str(item) for item in data["measures"])
unknown = [item for item in measures if item not in MEASURES]
if unknown:
raise RepoRecipeError(f"{path.name}: unknown measures {unknown}; expected any of {list(MEASURES)}")
known = tuple(_known(item, path.name) for item in data.get("known", ()))
if "detection" in measures and not any(item.in_scope for item in known):
raise RepoRecipeError(f"{path.name}: declares detection but names no in-scope vulnerability to detect")
return RepoRecipe(
name=str(data["name"]),
url=str(data["url"]),
commit=commit,
language=str(data["language"]),
license=str(data["license"]),
license_file=str(data["license_file"]),
measures=measures,
known=known,
environment={str(k): str(v) for k, v in dict(data.get("environment", {})).items()},
note=str(data.get("note", "")),
)
def _known(item: object, name: str) -> KnownVulnerability:
if not isinstance(item, dict):
raise RepoRecipeError(f"{name}: each [[known]] entry must be a table")
missing = [key for key in _KNOWN_REQUIRED if item.get(key) in (None, "")]
if missing:
raise RepoRecipeError(f"{name}: [[known]] missing {', '.join(missing)}")
if "in_scope" not in item:
# Deliberately not defaulted. Whether the engine could ever decide this is the difference between a
# miss and a non-question, and a default would silently pick one.
raise RepoRecipeError(f"{name}: [[known]] {item['id']} must state in_scope explicitly")
return KnownVulnerability(
id=str(item["id"]),
family=str(item["family"]),
weakness=str(item["weakness"]),
file=str(item["file"]),
function=str(item["function"]),
line=int(item["line"]),
reference=str(item["reference"]),
in_scope=bool(item["in_scope"]),
)
def repo_cache_dir() -> Path:
"""Checkout root; outside the repository unless the operator points it elsewhere."""
return Path(os.environ.get(REPO_CACHE_ENV) or DEFAULT_REPO_CACHE).expanduser()
def network_allowed(fetch: bool | None = None) -> bool:
"""``repos --fetch`` for one run, or OPENULTRASAST_REPOS_NETWORK=1; CI sets neither."""
if fetch is not None:
return fetch
return os.environ.get(REPOS_NETWORK_ENV, "") == "1"
def checkout_path(recipe: RepoRecipe, cache_root: Path | None = None) -> Path:
"""Where this exact commit lives. Keyed by SHA, so a re-pin never overwrites an older baseline's source."""
return (cache_root or repo_cache_dir()) / recipe.name / recipe.commit[:12]
def resolve(recipe: RepoRecipe, *, fetch: bool | None = None, cache_root: Path | None = None) -> Path:
"""The checkout on disk, fetching only if the network was offered.
A warm cache spares the fetch; it never turns an offline run into a networked one.
"""
dest = checkout_path(recipe, cache_root)
if (dest / ".git").is_dir():
return dest
if not network_allowed(fetch):
raise RepoUnavailable(
f"{recipe.name} is not checked out at {recipe.commit[:12]}; run with --fetch or {REPOS_NETWORK_ENV}=1 to fetch it"
)
_fetch(recipe, dest)
return dest
def _fetch(recipe: RepoRecipe, dest: Path) -> None:
"""Fetch just the pinned commit. Falls back to a full clone where the server refuses a SHA fetch."""
dest.mkdir(parents=True, exist_ok=True)
try:
_git(dest, "init", "--quiet")
_git(dest, "remote", "add", "origin", recipe.url)
_git(dest, "fetch", "--quiet", "--depth", "1", "origin", recipe.commit)
_git(dest, "checkout", "--quiet", "FETCH_HEAD")
except subprocess.CalledProcessError:
for child in dest.iterdir():
_remove(child)
_git(dest.parent, "clone", "--quiet", recipe.url, dest.name)
_git(dest, "checkout", "--quiet", recipe.commit)
def _remove(path: Path) -> None:
if path.is_dir() and not path.is_symlink():
for child in path.iterdir():
_remove(child)
path.rmdir()
else:
path.unlink()
def _git(cwd: Path, *args: str) -> str:
result = subprocess.run( # noqa: S603 - fixed program, arguments from a committed recipe
["git", *args],
cwd=cwd,
check=True,
capture_output=True,
text=True,
timeout=1800,
)
return result.stdout.strip()
def verify(recipe: RepoRecipe, root: Path) -> tuple[str, ...]:
"""Problems with a materialised checkout: empty means the recipe describes what is actually there.
This is what keeps a recipe from drifting into fiction. Every field that claims something about the code
-- the commit, the licence file, and each vulnerability's file and function -- is checked against the
checkout rather than trusted.
"""
problems: list[str] = []
try:
head = _git(root, "rev-parse", "HEAD")
except (subprocess.CalledProcessError, OSError) as exc:
return (f"cannot read HEAD: {exc}",)
if head != recipe.commit:
problems.append(f"HEAD is {head[:12]}, recipe pins {recipe.commit[:12]}")
if not (root / recipe.license_file).is_file():
problems.append(f"license_file {recipe.license_file} is not in the checkout")
for item in recipe.known:
problems.extend(_verify_known(item, root))
return tuple(problems)
def _verify_known(item: KnownVulnerability, root: Path) -> tuple[str, ...]:
path = root / item.file
if not path.is_file():
return (f"{item.id}: {item.file} is not in the checkout",)
lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
if not 1 <= item.line <= len(lines):
return (f"{item.id}: line {item.line} is outside {item.file} ({len(lines)} lines)",)
# The declared line is where the vulnerability is, not where the function opens, so look for the name in
# a window above it rather than on it.
window = lines[max(item.line - _FUNCTION_WINDOW, 0) : item.line]
if not any(item.function in line for line in window):
return (f"{item.id}: {item.function} is not within {_FUNCTION_WINDOW} lines above {item.file}:{item.line}",)
return ()
def recipe_payload(recipe: RepoRecipe) -> dict[str, object]:
"""The recipe as it should appear in a measurement artifact (Req 4.2, 10.4)."""
return {
"name": recipe.name,
"url": recipe.url,
"commit": recipe.commit,
"language": recipe.language,
"license": recipe.license,
"measures": list(recipe.measures),
"known": [
{
"id": item.id,
"family": item.family,
"weakness": item.weakness,
"location": item.location,
"in_scope": item.in_scope,
}
for item in recipe.known
],
}
def select(recipes: Sequence[RepoRecipe], name: str | None) -> tuple[RepoRecipe, ...]:
if name in (None, "all"):
return tuple(recipes)
return tuple(recipe for recipe in recipes if recipe.name == name)