mirror of
https://github.com/norandom/OpenUltraSAST.git
synced 2026-10-16 03:49:26 +00:00
feat(learning-harness): declared twins and a fixture-difficulty check
The five byte-identical vibe-py twins now say so in the catalog instead of only hashing to it, and a declared limit that names one of the unscorable vocabulary's own reasons keeps that spelling, so `identical_twin` stays one fact. Leave-one-out over vibe-py consequently runs on 30 rows, not 35. The difficulty check measures three axes the corpus varies on: binding hops from the identity source to the constrained use, registration distance, and guard distance. Measured first, and it failed: the shared obligations fixture reached (1, 0, 2) where the vibe-py absence rows reach (5, 2, 2). Every fixture handler carried its own route decorator, so nothing in the suite exercised a handler registered from another document and no binding chain was longer than one hop -- the checker was passing on a simpler world than the one it meets. `from_spec` closes that with the connexion shape those rows really have. The verdict is committed beside the numbers. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01T7XD7x5KonMdmGRkp63EQG
This commit is contained in:
co-authored by
Claude Opus 5
parent
1843ef9a02
commit
d1faa04771
@@ -143,7 +143,7 @@
|
||||
- _Depends: 1.3_
|
||||
- _Requirements: 10.2_
|
||||
|
||||
- [ ] 4.2 Identical twins and the fixture-difficulty check
|
||||
- [x] 4.2 Identical twins and the fixture-difficulty check
|
||||
- The five identical vibe-py twins carry the identical-twin known-limit reason in the catalog; a test derives a difficulty vector for a unit fixture (binding hops to the identity source, registration present, cross-file registration) and for the corpus rows its docstring names, and fails when the fixture is strictly easier.
|
||||
- Observable: the twins are excluded from every denominator with their reason listed; the shared obligations fixture is compared against the re-harvested vampi rows and the check's verdict is recorded.
|
||||
- _Depends: 4.1_
|
||||
@@ -233,3 +233,6 @@
|
||||
- **3.1, refusals were `continue` statements.** `Refusal.degradation()` existed and nothing called it. The mechanism lever now records every candidate it declined with the holdout pairs that taught it, leave-one-out records the pairs it kept out of the teaching set, and `improve` carries both into the round outcome. `semantic.seed` reported `split:train` for a train-split identical twin, which is simply untrue; an unscorable row now reports its own reason. `evaluate_profiles` and `evaluate_mechanism_profiles` are deliberately split-blind and now say so in their docstrings: they measure a ruleset and never learn from a pair, so a filter there would only shrink the evidence.
|
||||
- Test-quality repairs the review asked for, each of which changed a number: two split tests were measuring an `x = 1` fixture that teaches nothing whatever the rule says, so both arms now use a pair that really carries a shape and the lever test asserts the same shape *is* admitted when a train pair taught it; the two CLI scoreboard tests asserted against output that was all zeros, and now use a side-aware detector stub (the shipped `scripted` stub answers twice, then goes quiet, and puts its finding outside the labeled function — three separate ways to make a family number zero); `ScriptedChatClient` gained an opt-in `cycle` so a stub cannot silently run out of answers mid-run; the sandbox-candidate assertion in `tests/test_learning_scan.py` compared verdict paths against a prefix no implementation would produce and now compares finding ids; the manifest's classifier block reported `measure_classifier([])`, all zeros, and now reports what this scan actually routed.
|
||||
- Carry-forward, unchanged and still capping every holdout number: the nineteen straddling repositories, and — found during task 4.1 — **nine of the ten duplicate groups sit on both sides of the split**, which the repository-level split cannot see because the duplicates are different repositories.
|
||||
- Task 4.2 (2026-09-06, RED first: 2 failing tests in `tests/test_fixture_difficulty.py`, and the check's own arm — `test_the_check_catches_a_fixture_that_is_easier_on_one_axis` — fails if the fixture hardening is reverted). Two parts:
|
||||
- **The five twins declare their limit.** A content hash is evidence; the catalog row is the maintainer standing behind it, and without one a re-harvest that changes a byte turns a known-unscorable row back into a scored one with no review. A declared limit that names one of the vocabulary's own reasons keeps that spelling, so `identical_twin` declared and `identical_twin` computed stay one fact instead of splitting across two buckets in every aggregation. `semantic.seed._skip_reason` was reordered for the same reason: an unscorable row reports `unscorable:<reason>` before either the known-limit or the split branch can claim it. Consequence, recorded because it moves a published number: leave-one-out over vibe-py now runs on **30 rows, not 35** — a pair whose two sides are the same code cannot be held out against itself.
|
||||
- **The fixture-difficulty check** (`learning/difficulty.py`), on three axes the corpus really varies on: binding hops from the identity source to the constrained use, registration distance (0 decorator on the handler, 1 statement elsewhere in the file, 2 another document), and guard distance (0 decorator, 1 call in the body, 2 nothing visible). The comparison is per axis over the whole set, because a suite covers a bug shape with several handlers between them. **Measured first, and it failed: the shared obligations fixture reached (1, 0, 2) where the vibe-py absence rows reach (5, 2, 2).** Every shared handler carried its own route decorator, so nothing in the suite exercised a handler registered from another document and no chain was longer than one binding — the checker was passing on a simpler world than the one it meets. `from_spec` was added to the fixture: the connexion shape those rows really have, a token validated by a call rather than a decorator and five bindings between the token and the constraint. The verdict is committed as `benchmarks/measurements/2026-09-06-fixture-difficulty.json` with both reaches and the history of the first measurement. One existing expectation moved with it (`from_spec` reads the user row and then the book: two real operations).
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
{
|
||||
"axes": [
|
||||
"binding_hops",
|
||||
"registration_distance",
|
||||
"guard_distance"
|
||||
],
|
||||
"command": "uv run pytest tests/test_fixture_difficulty.py",
|
||||
"corpus": [
|
||||
{
|
||||
"binding_hops": 5,
|
||||
"function": "get_by_title",
|
||||
"guard_distance": 1,
|
||||
"registration_distance": 2
|
||||
},
|
||||
{
|
||||
"binding_hops": 2,
|
||||
"function": "update_password",
|
||||
"guard_distance": 1,
|
||||
"registration_distance": 2
|
||||
},
|
||||
{
|
||||
"binding_hops": 0,
|
||||
"function": "get",
|
||||
"guard_distance": 2,
|
||||
"registration_distance": 0
|
||||
},
|
||||
{
|
||||
"binding_hops": 0,
|
||||
"function": "delete",
|
||||
"guard_distance": 2,
|
||||
"registration_distance": 0
|
||||
}
|
||||
],
|
||||
"corpus_reach": [
|
||||
5,
|
||||
2,
|
||||
2
|
||||
],
|
||||
"corpus_rows": [
|
||||
"vampi-books-get-by-title",
|
||||
"vampi-users-update-password",
|
||||
"threatbyte-api-v1-get",
|
||||
"threatbyte-api-v1-delete"
|
||||
],
|
||||
"fixture": [
|
||||
{
|
||||
"binding_hops": 0,
|
||||
"function": "guarded",
|
||||
"guard_distance": 0,
|
||||
"registration_distance": 0
|
||||
},
|
||||
{
|
||||
"binding_hops": 1,
|
||||
"function": "constrained",
|
||||
"guard_distance": 0,
|
||||
"registration_distance": 0
|
||||
},
|
||||
{
|
||||
"binding_hops": 1,
|
||||
"function": "constrained_too",
|
||||
"guard_distance": 1,
|
||||
"registration_distance": 0
|
||||
},
|
||||
{
|
||||
"binding_hops": 0,
|
||||
"function": "leaky",
|
||||
"guard_distance": 2,
|
||||
"registration_distance": 0
|
||||
},
|
||||
{
|
||||
"binding_hops": 5,
|
||||
"function": "from_spec",
|
||||
"guard_distance": 1,
|
||||
"registration_distance": 2
|
||||
}
|
||||
],
|
||||
"fixture_reach": [
|
||||
5,
|
||||
2,
|
||||
2
|
||||
],
|
||||
"fixture_source": "tests/test_obligation_operations.py::APP",
|
||||
"generated": "2026-09-06",
|
||||
"history": "First measured with the fixture reaching (1, 0, 2) against a corpus reaching (5, 2, 2): every shared obligations handler carried its route decorator, so nothing in the suite exercised a handler registered from another document, and no chain was longer than one binding. `from_spec` was added to the fixture to close that, and it is the connexion shape the vibe-py rows really have.",
|
||||
"note": "learning-harness Req 10.4. Higher is harder on every axis. `binding_hops` counts the assignments between the identity source and the constrained use; `registration_distance` is 0 for a route decorator on the handler, 1 for a statement elsewhere in the same file, 2 for another document entirely; `guard_distance` is 0 for a decorator guard, 1 for a call in the body, 2 for no visible guard. The comparison is per axis over the whole set: a suite covers a bug shape with several handlers between them, but its hardest case on an axis must not fall short of the corpus rows its tests claim to cover.",
|
||||
"strictly_easier": false,
|
||||
"verdict": "fixture_matches_corpus"
|
||||
}
|
||||
@@ -2324,6 +2324,7 @@ fix_policy = "silent"
|
||||
provenance = "human"
|
||||
split = "holdout"
|
||||
review_tier = "seeded"
|
||||
known_limit = "identical_twin"
|
||||
|
||||
[[pair.expected]]
|
||||
cwe = "CWE-89"
|
||||
@@ -3207,6 +3208,7 @@ fix_policy = "silent"
|
||||
provenance = "human"
|
||||
split = "train"
|
||||
review_tier = "seeded"
|
||||
known_limit = "identical_twin"
|
||||
|
||||
[[pair.expected]]
|
||||
cwe = "CWE-1336"
|
||||
@@ -3236,6 +3238,7 @@ fix_policy = "silent"
|
||||
provenance = "human"
|
||||
split = "holdout"
|
||||
review_tier = "seeded"
|
||||
known_limit = "identical_twin"
|
||||
|
||||
[[pair.expected]]
|
||||
cwe = "CWE-79"
|
||||
@@ -3354,6 +3357,7 @@ fix_policy = "silent"
|
||||
provenance = "human"
|
||||
split = "holdout"
|
||||
review_tier = "seeded"
|
||||
known_limit = "identical_twin"
|
||||
|
||||
[[pair.context]]
|
||||
relpath = "openapi_specs/openapi3.yml"
|
||||
@@ -3656,6 +3660,7 @@ fix_policy = "silent"
|
||||
provenance = "human"
|
||||
split = "train"
|
||||
review_tier = "seeded"
|
||||
known_limit = "identical_twin"
|
||||
|
||||
[[pair.expected]]
|
||||
cwe = "CWE-502"
|
||||
|
||||
@@ -2290,6 +2290,7 @@ trap_id = "dsvw-fp-001"
|
||||
acceptable_cwes = ["CWE-89", "CWE-564", "CWE-943"]
|
||||
evidence = "SQL injection via id parameter directly concatenated into SELECT query without sanitization"
|
||||
commit_url = "https://github.com/stamparm/DSVW/blob/7d40f4b7939c901610ed9b85724552d60e7d63fa/dsvw.py#L30"
|
||||
known_limit = "identical_twin"
|
||||
split = "holdout"
|
||||
|
||||
[[recipe]]
|
||||
@@ -3167,6 +3168,7 @@ trap_id = "python-insecure-app-fp-001"
|
||||
acceptable_cwes = ["CWE-1336", "CWE-94", "CWE-95"]
|
||||
evidence = "Server-Side Template Injection via Jinja2. User-controlled 'name' query parameter is interpolated into an f-string on line 38 to build 'content', which is then passed to Template(content).render() on line 41. An attacker can inject Jinja2 t"
|
||||
commit_url = "https://github.com/kolega-ai/realvuln-python-insecure-app/blob/80b894a2f754590747a31c92b2088be086421953/app/main.py#L41"
|
||||
known_limit = "identical_twin"
|
||||
split = "train"
|
||||
|
||||
[[recipe]]
|
||||
@@ -3198,6 +3200,7 @@ trap_id = "python-ssti-fp-001"
|
||||
acceptable_cwes = ["CWE-79", "CWE-80"]
|
||||
evidence = "The Jinja2 Environment is created with default settings (autoescape=False). User input from the 'username' query parameter is concatenated into the template string and the rendered output is returned as HTMLResponse. An attacker can inject "
|
||||
commit_url = "https://github.com/TheWation/PythonSSTI/blob/76339c27d5a870c0a81bad6aee5f9bd516592145/main.py#L14"
|
||||
known_limit = "identical_twin"
|
||||
split = "holdout"
|
||||
|
||||
[[recipe]]
|
||||
@@ -3328,6 +3331,7 @@ evidence = "Broken Object Level Authorization (BOLA). When vuln=1, get_by_title
|
||||
commit_url = "https://github.com/erev0s/VAmPI/blob/1713b54b601ad29582581eeda4b31fceb1319874/api_views/books.py#L51"
|
||||
family = "access_control"
|
||||
context = ["openapi_specs/openapi3.yml"]
|
||||
known_limit = "identical_twin"
|
||||
split = "holdout"
|
||||
|
||||
[[recipe]]
|
||||
@@ -3672,6 +3676,7 @@ trap_id = "vulnpy-fp-011"
|
||||
acceptable_cwes = ["CWE-502", "CWE-94"]
|
||||
evidence = "User input encoded to bytes and passed to pickle.load(), which can execute arbitrary code via crafted pickle payloads"
|
||||
commit_url = "https://github.com/Contrast-Security-OSS/vulnpy/blob/d1d0f64c06f038fe59a049e0e6001bfaf9839697/src/vulnpy/trigger/deserialization.py#L8"
|
||||
known_limit = "identical_twin"
|
||||
split = "train"
|
||||
|
||||
[[recipe]]
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
"""How hard a piece of code makes an absence bug to see (learning-harness, Req 10.4).
|
||||
|
||||
A unit fixture that is easier than the corpus is worse than no fixture: the checker passes on it, the number
|
||||
looks like capability, and the corpus rows it claims to cover keep failing for reasons nobody is looking at. The
|
||||
three dimensions below are the ones the corpus actually varies on, measured on the vulnerable side:
|
||||
|
||||
- ``binding_hops`` — how many assignments sit between the identity source and the constrained use. `filter_by(
|
||||
user_id=request.user.id)` is nothing to trace; `resp = token_validator(...)` then `User.query.filter_by(
|
||||
username=resp['sub'])` then `filter_by(user=user, ...)` is two.
|
||||
- ``registration_distance`` — 0 when the route decorator sits on the handler, 1 when a statement elsewhere in the
|
||||
same file registers it, 2 when the registration lives in another document entirely (connexion's `operationId`,
|
||||
an express router). Two is where the entry-point mapper stops being able to name the handler at all.
|
||||
- ``guard_distance`` — 0 when a decorator carries the guard, 1 when the guard is a call in the body, 2 when the
|
||||
handler carries no visible guard. A decorator is a pattern; a call in the body has to be understood.
|
||||
|
||||
Higher is harder on every axis, so "strictly easier" is: no dimension harder, at least one easier.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import re
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass
|
||||
|
||||
IDENTITY_SOURCES = ("current_user", "request.user", "session", "g.user", "auth", "token", "jwt", "claims", "sub", "principal")
|
||||
GUARD_TOKENS = ("login_required", "token_required", "requires_auth", "authenticate", "authorize", "token_validator", "current_user")
|
||||
_ROUTE_DECORATOR = re.compile(r"\.(route|get|post|put|patch|delete)\b")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DifficultyVector:
|
||||
"""One handler's difficulty. Higher is harder on every axis."""
|
||||
|
||||
function: str
|
||||
binding_hops: int
|
||||
registration_distance: int
|
||||
guard_distance: int
|
||||
|
||||
def axes(self) -> tuple[int, int, int]:
|
||||
return (self.binding_hops, self.registration_distance, self.guard_distance)
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"function": self.function,
|
||||
"binding_hops": self.binding_hops,
|
||||
"registration_distance": self.registration_distance,
|
||||
"guard_distance": self.guard_distance,
|
||||
}
|
||||
|
||||
|
||||
def difficulty_of(source: str, function: str, *, context: Sequence[str] = ()) -> DifficultyVector:
|
||||
"""The vector for one Python handler. ``context`` is any document that registers it from outside the file."""
|
||||
try:
|
||||
tree = ast.parse(source)
|
||||
except SyntaxError:
|
||||
return DifficultyVector(function=function, binding_hops=0, registration_distance=2, guard_distance=2)
|
||||
node = _function(tree, function)
|
||||
if node is None:
|
||||
return DifficultyVector(function=function, binding_hops=0, registration_distance=2, guard_distance=2)
|
||||
decorators = [ast.unparse(item) for item in node.decorator_list] + _class_decorators(tree, node)
|
||||
return DifficultyVector(
|
||||
function=function,
|
||||
binding_hops=_binding_hops(node),
|
||||
registration_distance=_registration_distance(source, tree, function, decorators, context),
|
||||
guard_distance=_guard_distance(node, decorators),
|
||||
)
|
||||
|
||||
|
||||
def hardest(vectors: Sequence[DifficultyVector]) -> tuple[int, int, int]:
|
||||
"""The per-axis maximum over a set of handlers: what the set can be asked to cope with."""
|
||||
if not vectors:
|
||||
return (0, 0, 0)
|
||||
return tuple(max(row.axes()[index] for row in vectors) for index in range(3)) # type: ignore[return-value]
|
||||
|
||||
|
||||
def strictly_easier(fixture: Sequence[DifficultyVector], corpus: Sequence[DifficultyVector]) -> bool:
|
||||
"""True when the fixture set reaches no further than the corpus on any axis and falls short on at least one.
|
||||
|
||||
Per axis and over the whole set, because a suite covers a bug shape with several handlers between them: one
|
||||
fixture handler may carry the long binding chain and another the missing guard. What must not happen is a suite
|
||||
whose hardest case on some axis is easier than the corpus rows its tests claim to cover."""
|
||||
if not corpus:
|
||||
return False
|
||||
limit, reach = hardest(corpus), hardest(fixture)
|
||||
return all(reach[index] <= limit[index] for index in range(3)) and any(reach[index] < limit[index] for index in range(3))
|
||||
|
||||
|
||||
def _function(tree: ast.AST, name: str) -> ast.FunctionDef | ast.AsyncFunctionDef | None:
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name == name:
|
||||
return node
|
||||
return None
|
||||
|
||||
|
||||
def _class_decorators(tree: ast.AST, node: ast.AST) -> list[str]:
|
||||
for klass in ast.walk(tree):
|
||||
if isinstance(klass, ast.ClassDef) and any(child is node for child in klass.body):
|
||||
return [ast.unparse(item) for item in klass.decorator_list]
|
||||
return []
|
||||
|
||||
|
||||
def _binding_hops(node: ast.FunctionDef | ast.AsyncFunctionDef) -> int:
|
||||
"""Assignments that carry the identity forward before it constrains anything."""
|
||||
tainted: set[str] = set()
|
||||
hops = 0
|
||||
for statement in ast.walk(node):
|
||||
if not isinstance(statement, ast.Assign):
|
||||
continue
|
||||
text = ast.unparse(statement.value)
|
||||
if any(token in text for token in IDENTITY_SOURCES) or any(name in text for name in tainted):
|
||||
hops += 1
|
||||
for target in statement.targets:
|
||||
tainted.update(part.id for part in ast.walk(target) if isinstance(part, ast.Name))
|
||||
return hops
|
||||
|
||||
|
||||
def _registration_distance(source: str, tree: ast.AST, function: str, decorators: Sequence[str], context: Sequence[str]) -> int:
|
||||
if any(_ROUTE_DECORATOR.search(item) for item in decorators):
|
||||
return 0
|
||||
mention = re.compile(rf"(?<![\w.]){re.escape(function)}(?!\w)")
|
||||
body = _function(tree, function)
|
||||
span = range(getattr(body, "lineno", 0), getattr(body, "end_lineno", 0) + 1) if body is not None else range(0)
|
||||
for number, line in enumerate(source.splitlines(), start=1):
|
||||
if number not in span and mention.search(line) and not line.strip().startswith(("def ", "async def ", "#")):
|
||||
return 1
|
||||
if any(re.search(rf"(?<![\w]){re.escape(function)}(?!\w)", item) for item in context):
|
||||
return 2
|
||||
return 2 # nothing in reach registers it, which is at least as hard as a registration in another document
|
||||
|
||||
|
||||
def _guard_distance(node: ast.FunctionDef | ast.AsyncFunctionDef, decorators: Sequence[str]) -> int:
|
||||
if any(token in item.lower() for item in decorators for token in GUARD_TOKENS):
|
||||
return 0
|
||||
body = ast.unparse(node).lower()
|
||||
return 1 if any(token in body for token in GUARD_TOKENS) else 2
|
||||
|
||||
|
||||
__all__ = ["GUARD_TOKENS", "IDENTITY_SOURCES", "DifficultyVector", "difficulty_of", "hardest", "strictly_easier"]
|
||||
@@ -625,10 +625,18 @@ def _body_digest(path: Path) -> str | None:
|
||||
return hashlib.sha256(body.encode()).hexdigest() if body else None
|
||||
|
||||
|
||||
# The reasons a row may carry verbatim from the catalog, because the loader computes them under the same names.
|
||||
UNSCORABLE_REASONS = frozenset({"identical_twin"})
|
||||
|
||||
|
||||
def _unscorable_reason(known_limit: object, vuln_file: Path, fixed_file: Path, *, vendored: bool) -> str | None:
|
||||
"""Why this pair cannot be scored, or None. A declared known limit wins over anything computed."""
|
||||
"""Why this pair cannot be scored, or None. A declared known limit wins over anything computed.
|
||||
|
||||
A limit that names one of the vocabulary's own reasons keeps that spelling: `identical_twin` declared and
|
||||
`identical_twin` computed are one corpus fact, and two spellings would split it across two buckets in every
|
||||
aggregation (Req 4.1, 10.3)."""
|
||||
if known_limit:
|
||||
return f"known_limit:{known_limit}"
|
||||
return str(known_limit) if str(known_limit) in UNSCORABLE_REASONS else f"known_limit:{known_limit}"
|
||||
if not vendored:
|
||||
return None
|
||||
digest = _body_digest(vuln_file)
|
||||
|
||||
@@ -195,14 +195,16 @@ def _obligation_facts() -> ObligationFacts:
|
||||
def _skip_reason(case: PairCase) -> str | None:
|
||||
from ..learning.split import is_teacher
|
||||
|
||||
if not is_teacher(case) and case.review_tier in GATING_TIERS and case.vendored and not case.known_limit:
|
||||
# learning-harness Req 5.1: only train-split, scorable pairs teach. Say which of the two it was: two
|
||||
# vibe-py rows are train-split identical twins, and calling them `split:train` is simply untrue.
|
||||
return f"unscorable:{case.unscorable}" if case.unscorable else f"split:{case.split}"
|
||||
if case.review_tier not in GATING_TIERS:
|
||||
return f"tier:{case.review_tier}"
|
||||
if case.unscorable:
|
||||
# One spelling per corpus fact. `identical_twin` declared in the catalog and `identical_twin` computed from
|
||||
# the bytes are the same reason, and calling one of them `split:train` is simply untrue (Req 5.1, 10.3).
|
||||
return f"unscorable:{case.unscorable}"
|
||||
if case.known_limit:
|
||||
return f"known_limit:{case.known_limit}"
|
||||
if not is_teacher(case) and case.vendored:
|
||||
return f"split:{case.split}"
|
||||
if not case.vuln_file.is_file() or not case.fixed_file.is_file():
|
||||
return "pointer_pair_not_cached" if not case.vendored else "excerpt_missing"
|
||||
return None
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
"""A unit fixture may not be easier than the corpus it claims to cover (learning-harness 4.2, Req 10.3, 10.4).
|
||||
|
||||
Two things a corpus number cannot survive: a byte-identical pair counted as a detector result, and a fixture that
|
||||
passes because the world it describes is simpler than the one the detector meets. The first is excluded by name;
|
||||
the second is measured on three axes the corpus actually varies on, and the comparison is recorded.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
from test_obligation_operations import APP # noqa: E402
|
||||
|
||||
from openultrasast.learning.difficulty import DifficultyVector, difficulty_of, hardest, strictly_easier # noqa: E402
|
||||
from openultrasast.pairs import DEFAULT_CATALOG, load_pair_catalog, select_vendored # noqa: E402
|
||||
|
||||
TWINS = {
|
||||
"dsvw-dsvw-do-get",
|
||||
"python-insecure-app-main-try-hack-me",
|
||||
"pythonssti-main-read-root",
|
||||
"vampi-books-get-by-title",
|
||||
"vulnpy-deserialization-do-pickle-load",
|
||||
}
|
||||
# The rows the shared obligations fixture claims to cover: the vibe-py absence pairs.
|
||||
COVERED = ("vampi-books-get-by-title", "vampi-users-update-password", "threatbyte-api-v1-get", "threatbyte-api-v1-delete")
|
||||
FIXTURE_HANDLERS = ("guarded", "constrained", "constrained_too", "leaky", "from_spec")
|
||||
REPORT = Path("benchmarks/measurements/2026-09-06-fixture-difficulty.json")
|
||||
|
||||
|
||||
# --- identical twins ---------------------------------------------------------
|
||||
|
||||
|
||||
def test_every_identical_twin_declares_the_limit_rather_than_only_hashing_to_it() -> None:
|
||||
"""A content hash is evidence; the catalog row is the maintainer standing behind it.
|
||||
|
||||
A re-harvest that changes one byte would otherwise turn a known-unscorable row back into a scored one with no
|
||||
review, and the number would move for a reason nobody chose."""
|
||||
cases = {case.name: case for case in select_vendored(load_pair_catalog(DEFAULT_CATALOG))}
|
||||
for name in sorted(TWINS):
|
||||
assert cases[name].known_limit == "identical_twin", f"{name} is a twin by content but says nothing in the catalog"
|
||||
assert cases[name].unscorable == "identical_twin", "the declared limit and the computed one must be one reason, not two"
|
||||
|
||||
|
||||
def test_the_twins_leave_every_denominator_with_their_reason_listed() -> None:
|
||||
from openultrasast.learning.families import load_families
|
||||
from openultrasast.learning.scoring import PairFamilyScore, aggregate
|
||||
|
||||
cases = [case for case in select_vendored(load_pair_catalog(DEFAULT_CATALOG)) if case.name in TWINS]
|
||||
scores = [
|
||||
PairFamilyScore(
|
||||
pair=case.name, family="injection", slice=case.slice, runs=(), outcome="unscorable", unscorable_reason=case.unscorable
|
||||
)
|
||||
for case in cases
|
||||
]
|
||||
metrics = aggregate(scores, taxonomy=load_families())["injection"]
|
||||
assert metrics.scorable == 0
|
||||
assert metrics.unscorable == {"identical_twin": len(TWINS)}
|
||||
|
||||
|
||||
# --- the difficulty check ----------------------------------------------------
|
||||
|
||||
|
||||
def _corpus_vectors() -> list[DifficultyVector]:
|
||||
cases = {case.name: case for case in select_vendored(load_pair_catalog(DEFAULT_CATALOG))}
|
||||
vectors = []
|
||||
for name in COVERED:
|
||||
case = cases[name]
|
||||
function = next(row.function for row in case.expected if row.function)
|
||||
context = [vuln.read_text() for _relpath, vuln, _fixed in case.context_files]
|
||||
vectors.append(difficulty_of(case.vuln_file.read_text(), function, context=context))
|
||||
return vectors
|
||||
|
||||
|
||||
def test_the_shared_obligations_fixture_is_not_easier_than_the_rows_it_covers() -> None:
|
||||
"""The check Req 10.4 asks for, on the fixture every obligations test shares."""
|
||||
fixture = [difficulty_of(APP, name) for name in FIXTURE_HANDLERS]
|
||||
corpus = _corpus_vectors()
|
||||
assert not strictly_easier(fixture, corpus), (
|
||||
f"the fixture reaches {hardest(fixture)} where the corpus reaches {hardest(corpus)} "
|
||||
"(binding hops, registration distance, guard distance)"
|
||||
)
|
||||
|
||||
|
||||
def test_the_check_catches_a_fixture_that_is_easier_on_one_axis(tmp_path: Path) -> None:
|
||||
"""Mutation of the check itself: drop the corpus-shaped handler and it has to fail."""
|
||||
without = [difficulty_of(APP, name) for name in FIXTURE_HANDLERS if name != "from_spec"]
|
||||
assert strictly_easier(without, _corpus_vectors())
|
||||
assert hardest(without)[1] == 0, "every remaining handler carries its route decorator; nothing is registered elsewhere"
|
||||
|
||||
|
||||
def test_the_verdict_is_recorded_beside_the_numbers_it_explains() -> None:
|
||||
assert REPORT.is_file(), "the fixture-difficulty verdict is committed, not recomputed from memory"
|
||||
payload = json.loads(REPORT.read_text())
|
||||
fixture = [difficulty_of(APP, name) for name in FIXTURE_HANDLERS]
|
||||
corpus = _corpus_vectors()
|
||||
assert payload["fixture"] == [row.to_dict() for row in fixture]
|
||||
assert payload["corpus"] == [row.to_dict() for row in corpus]
|
||||
assert payload["strictly_easier"] is False and payload["verdict"] == "fixture_matches_corpus"
|
||||
assert payload["axes"] == ["binding_hops", "registration_distance", "guard_distance"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("source", "expected"),
|
||||
[
|
||||
("@app.route('/x')\ndef h():\n return Book.query.filter_by(user_id=request.user.id).first()\n", (0, 0, 2)),
|
||||
("@app.route('/x')\n@login_required\ndef h():\n return 1\n", (0, 0, 0)),
|
||||
("def h():\n u = current_user.id\n v = u\n return v\n", (2, 2, 1)),
|
||||
("app.add_url_rule('/x', view_func=h)\n\n\ndef h():\n return 1\n", (0, 1, 2)),
|
||||
],
|
||||
)
|
||||
def test_each_axis_is_read_off_the_code(source: str, expected: tuple[int, int, int]) -> None:
|
||||
assert difficulty_of(source, "h").axes() == expected
|
||||
+3
-1
@@ -103,7 +103,9 @@ def test_cli_pairs_loo_writes_the_artifact_offline(tmp_path: Path, monkeypatch:
|
||||
with redirect_stdout(buf):
|
||||
assert main(["pairs", "--slice", "vibe-py", "--loo", "--json", "--loo-out", str(tmp_path / "loo.json")]) == 0
|
||||
payload = json.loads(buf.getvalue())
|
||||
assert payload["per_slice"]["vibe-py"]["pairs"] == 35 and "found_by" in payload["outcomes"][0]
|
||||
# 35 vendored rows less the five byte-identical twins, which task 4.2 declared unscorable in the catalog: a
|
||||
# pair whose two sides are the same code cannot be held out against itself.
|
||||
assert payload["per_slice"]["vibe-py"]["pairs"] == 30 and "found_by" in payload["outcomes"][0]
|
||||
assert json.loads((tmp_path / "loo.json").read_text()) == payload
|
||||
|
||||
|
||||
|
||||
@@ -19,7 +19,19 @@ APP = (
|
||||
"@app.route('/books/any/<title>')\ndef leaky(title):\n"
|
||||
" owner = request.args.get('owner')\n"
|
||||
" return Book.query.filter_by(user_id=owner, book_title=title).first()\n\n\n"
|
||||
"@app.route('/health')\ndef health():\n return 'ok'\n"
|
||||
"@app.route('/health')\ndef health():\n return 'ok'\n\n\n"
|
||||
# Registered from a separate OpenAPI document, guarded by a call rather than a decorator, and five bindings
|
||||
# between the token and the constraint: the shape the vibe-py absence rows really have. Without it this fixture
|
||||
# is strictly easier than the corpus it claims to cover (learning-harness Req 10.4).
|
||||
"def from_spec(book_title):\n"
|
||||
" resp = token_validator(request.headers.get('Authorization'))\n"
|
||||
" if 'error' in resp:\n"
|
||||
" return None\n"
|
||||
" claims = resp\n"
|
||||
" subject = claims['sub']\n"
|
||||
" user = User.query.filter_by(username=subject).first()\n"
|
||||
" owner = user.id\n"
|
||||
" return Book.query.filter_by(user_id=owner, book_title=book_title).first()\n"
|
||||
)
|
||||
|
||||
|
||||
@@ -60,6 +72,9 @@ def test_find_operations_names_kind_fact_function_and_resource() -> None:
|
||||
("constrained", "protected_read", "book"),
|
||||
("constrained_too", "protected_read", "book"),
|
||||
("leaky", "protected_read", "book"),
|
||||
# `from_spec` reads the user row to resolve the token subject and then the book: two operations, both real
|
||||
("from_spec", "protected_read", "user"),
|
||||
("from_spec", "protected_read", "book"),
|
||||
]
|
||||
assert all(op.path == "app.py" and op.fact_id == "orm-read" and op.line > 0 for op in ops)
|
||||
assert not [op for op in ops if op.function == "health"] # a constant return is no operation
|
||||
|
||||
Reference in New Issue
Block a user