mirror of
https://github.com/norandom/OpenUltraSAST.git
synced 2026-10-16 11:49:28 +00:00
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
286 lines
11 KiB
Python
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)
|