mirror of
https://github.com/norandom/OpenUltraSAST.git
synced 2026-10-18 12:39:28 +00:00
feat(model-grounded-detection): group 1, subtract the noise architecture
99 source modules -> 81. 832 tests -> 646. The whole learning/ package (16 modules, ~4,100 lines) and six semantic/ modules removed, with the three gates byte-identical to 2026-09-06-gate-baseline.txt at every step. The sequence is what made it safe: vocabularies ported first (1.1), importers detached second (1.2-1.3), deletion third (1.4). Every import break during the deletion was therefore a detachment miss in the importer and was fixed there -- no module was restored to satisfy one. Both misses were the same shape and were fixed as a class: operations.py and shapes.py each imported trailing_name from the deleted variants.py, so it moved to semantic/ir.py, its natural home, and both importers changed in one edit. Ported before deleting: the closed guard vocabulary (verbatim, nothing else holds it) into model/specs.py, which derives TaintSpec and DominanceSpec from the retained ruleset facts rather than copying them. Re-homed: families -> model/taxonomy, candidates -> model/candidates, endpoint -> model/endpoint, plus Region, labeled_family and labeled_spans. labeled_family no longer falls back to the LLM classifier -- an unlabeled pair is honestly unknown. Four deviations, each recorded in the spec notes with its reason. improve/ evolve.py and validator.py were stripped, not deleted: each mixes the mechanism lever (gone) with the deterministic rule-status loop `ousast improve` still runs, which has no LLM in it; a test now asserts the lever's absence by symbol so it cannot creep back. The learning CLI group became `model candidates`, because deleting it outright would have orphaned the retained enumerator by removing its only entry point. thinking moved from [learning] to [models]. A gap named rather than papered over: no retained sink fact routes by CWE to path, output_encoding, untrusted_destination or prototype -- the same hole the entailment ceiling diagnosed. The test asserts the seeded set and the gap set exactly, so task 4.2 must update it when it authors those sinks. The audit found exactly one genuine orphan: vectorstore.py, a bakeoff harness with no production caller and no CLI, alive only through its own test. The five named subsystems each carry an explicit decision. Manifest committed, with a test asserting the tree still matches it. Group 2 (the Joern go/no-go) has not started. 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
fc4dab97fb
commit
9ed0e45c5f
@@ -0,0 +1,7 @@
|
||||
"""The model layer: a deterministic program model that arbitrates detector claims.
|
||||
|
||||
The taxonomy routes work, the candidate enumerator feeds the `suspicion` band, the specs carry the security
|
||||
vocabularies, and (from group 2) the CPG-backed taint and dominance checks establish the rungs above it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -0,0 +1,166 @@
|
||||
"""Module audit: what is load-bearing, what stands alone, and what is orphaned (Req 3).
|
||||
|
||||
The tool outgrew itself — 99 modules and 832 tests at the point this feature began, a large share of them
|
||||
serving an architecture its own measurements retired. This classifier exists so outgrowth is visible and
|
||||
checkable rather than a thing someone notices two specs later.
|
||||
|
||||
A module is:
|
||||
|
||||
load_bearing reachable from a gate, the scan CLI, or a surviving requirement
|
||||
standalone_capability its own entry point and users, not on the scan path
|
||||
orphaned no importer, no entry point, no requirement — it should not be in the tree
|
||||
|
||||
Pure stdlib, no imports of the package it audits: it reads the import graph with ``ast`` so a broken module
|
||||
is still classifiable.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
from collections.abc import Iterable, Mapping
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
PACKAGE = "openultrasast"
|
||||
|
||||
# Entry points a module can be reachable from. The three gates are the contract; the CLI is the product.
|
||||
ROOTS = ("gate", "map_gate", "pair_gate", "cli", "__main__")
|
||||
|
||||
# Modules that stand alone: their own CLI subcommand or external protocol, deliberately off the scan path.
|
||||
SPEC_OWNED = {
|
||||
"model.specs": "model-grounded-detection Req 7.2-7.4: the security vocabularies the CPG queries are parameterised by",
|
||||
"model.endpoint": "model-grounded-detection Req 8: the judge client, wired by model/judge.py in group 3",
|
||||
"model.audit": "model-grounded-detection Req 3: this classifier; run by the maintainer, not by the scan",
|
||||
}
|
||||
|
||||
STANDALONE = {
|
||||
"mcp": "narrow MCP server over stdio; its own `ousast mcp` entry point and OpenCode integration",
|
||||
"skills": "skill router; consumed by mcp and by the OpenCode integration, not by the scan path",
|
||||
"fusion": "two-panel adjudication engine; reached from the scan's report stage and used standalone",
|
||||
"harness_ext": "HarnessX extension surface; loaded by name when the optional harness is present",
|
||||
"hunter_harness": "harness wiring for the tool hunter; used by the hunter path and by mcp",
|
||||
"stage_processors": "HarnessX slot-contracted scan stages; pinned by the zero-dependency guard in test_gate",
|
||||
"slot_contract": "the slot/contract protocol stage_processors implements",
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ModuleAudit:
|
||||
module: str
|
||||
classification: str # load_bearing | standalone_capability | orphaned
|
||||
reason: str
|
||||
importers: tuple[str, ...]
|
||||
|
||||
|
||||
def module_name(path: Path, root: Path) -> str:
|
||||
relative = path.relative_to(root).with_suffix("")
|
||||
parts = [part for part in relative.parts if part != "__init__"]
|
||||
return ".".join(parts)
|
||||
|
||||
|
||||
def import_graph(root: Path) -> dict[str, set[str]]:
|
||||
"""``module -> the modules it imports`` for every module under ``root``, resolved to package-relative names."""
|
||||
graph: dict[str, set[str]] = {}
|
||||
for path in sorted(root.rglob("*.py")):
|
||||
if "__pycache__" in path.parts:
|
||||
continue
|
||||
name = module_name(path, root)
|
||||
# For a package's `__init__`, the containing package *is* this module, so `from .x import y` resolves to
|
||||
# `<name>.x`; for a plain module it resolves against the parent. Getting this backwards made every module
|
||||
# a package re-exports look unimported.
|
||||
is_package = path.name == "__init__.py"
|
||||
imported: set[str] = set()
|
||||
try:
|
||||
tree = ast.parse(path.read_text())
|
||||
except (OSError, SyntaxError):
|
||||
graph[name] = imported
|
||||
continue
|
||||
package_parts = name.split(".") if is_package else name.split(".")[:-1]
|
||||
package_parts = [part for part in package_parts if part]
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.ImportFrom):
|
||||
if node.level:
|
||||
base = package_parts[: len(package_parts) - node.level + 1]
|
||||
target = ".".join([*base, node.module]) if node.module else ".".join(base)
|
||||
else:
|
||||
target = node.module or ""
|
||||
if target.startswith(f"{PACKAGE}."):
|
||||
target = target[len(PACKAGE) + 1 :]
|
||||
elif target == PACKAGE:
|
||||
target = ""
|
||||
else:
|
||||
continue
|
||||
if target:
|
||||
imported.add(target)
|
||||
elif isinstance(node, ast.Import):
|
||||
for alias in node.names:
|
||||
if alias.name.startswith(f"{PACKAGE}."):
|
||||
imported.add(alias.name[len(PACKAGE) + 1 :])
|
||||
graph[name] = imported
|
||||
return graph
|
||||
|
||||
|
||||
def _reachable(graph: Mapping[str, set[str]], roots: Iterable[str]) -> set[str]:
|
||||
seen: set[str] = set()
|
||||
stack = [root for root in roots if root in graph]
|
||||
while stack:
|
||||
current = stack.pop()
|
||||
if current in seen:
|
||||
continue
|
||||
seen.add(current)
|
||||
for target in graph.get(current, ()):
|
||||
# an import of `a.b` also keeps the package `a` alive
|
||||
for candidate in (target, target.rsplit(".", 1)[0]):
|
||||
if candidate in graph and candidate not in seen:
|
||||
stack.append(candidate)
|
||||
return seen
|
||||
|
||||
|
||||
def audit(root: Path) -> tuple[ModuleAudit, ...]:
|
||||
"""Classify every module under ``root``."""
|
||||
graph = import_graph(root)
|
||||
importers: dict[str, set[str]] = {name: set() for name in graph}
|
||||
for name, targets in graph.items():
|
||||
for target in targets:
|
||||
for candidate in (target, target.rsplit(".", 1)[0]):
|
||||
if candidate in importers and candidate != name:
|
||||
importers[candidate].add(name)
|
||||
live = _reachable(graph, ROOTS)
|
||||
results: list[ModuleAudit] = []
|
||||
for name in sorted(graph):
|
||||
if not name: # the package __init__ itself
|
||||
continue
|
||||
who = tuple(sorted(importers[name]))
|
||||
if name in SPEC_OWNED:
|
||||
results.append(ModuleAudit(name, "load_bearing", SPEC_OWNED[name], who))
|
||||
elif name in ROOTS:
|
||||
results.append(ModuleAudit(name, "load_bearing", "entry point", who))
|
||||
elif name in STANDALONE:
|
||||
results.append(ModuleAudit(name, "standalone_capability", STANDALONE[name], who))
|
||||
elif name in live:
|
||||
results.append(ModuleAudit(name, "load_bearing", f"reachable from {', '.join(ROOTS[:3])} or the CLI", who))
|
||||
elif who:
|
||||
results.append(ModuleAudit(name, "load_bearing", f"imported by {', '.join(who)}", who))
|
||||
else:
|
||||
results.append(ModuleAudit(name, "orphaned", "no importer, no entry point, no surviving requirement", who))
|
||||
return tuple(results)
|
||||
|
||||
|
||||
def to_dict(results: Iterable[ModuleAudit]) -> dict[str, object]:
|
||||
rows = list(results)
|
||||
counts: dict[str, int] = {}
|
||||
for row in rows:
|
||||
counts[row.classification] = counts.get(row.classification, 0) + 1
|
||||
return {
|
||||
"modules": len(rows),
|
||||
"counts": dict(sorted(counts.items())),
|
||||
"orphaned": [row.module for row in rows if row.classification == "orphaned"],
|
||||
"standalone": {row.module: row.reason for row in rows if row.classification == "standalone_capability"},
|
||||
"rows": [
|
||||
{"module": row.module, "classification": row.classification, "reason": row.reason, "importers": list(row.importers)}
|
||||
for row in rows
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
__all__ = ["ModuleAudit", "SPEC_OWNED", "STANDALONE", "audit", "import_graph", "module_name", "to_dict"]
|
||||
@@ -0,0 +1,388 @@
|
||||
"""What a detector is asked to judge, enumerated deterministically (constrained-detector, Req 1).
|
||||
|
||||
The detector stops searching. Measured on 2026-09-06, 120 runs of the unconstrained loop over the same twelve pairs
|
||||
produced 120 distinct tool-call trajectories, and outcome disagreement tracked trajectory divergence rather than
|
||||
judgement: the model was spending its budget guessing grep patterns to find sinks the semantic IR already knows.
|
||||
|
||||
So something deterministic decides what the model looks at, and that enumerator becomes a hard ceiling on recall.
|
||||
Which is why it was measured before any of this was built: the pattern ruleset finds a candidate inside the labeled
|
||||
function for **12.1%** of the corpus — below the 64.9% the unconstrained loop already reaches — while the IR's call
|
||||
sites and bindings reach **96.6%**, a median of ten sites per function. The ruleset is never the generator here.
|
||||
|
||||
Ordering is `(path, line, name)` and the bound is a constant, so the batches the judgment step forms, and therefore
|
||||
the sequence of model calls, are determined by the input alone.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
CandidateKind = Literal["call", "bind", "operation", "config"]
|
||||
|
||||
# What can carry each family's bug. An injection lives at a call; an access-control bug is the *absence* of a guard
|
||||
# around an operation, so its candidate is the operation and what the model needs is the guards in scope.
|
||||
FAMILY_SHAPE: dict[str, tuple[CandidateKind, ...]] = {
|
||||
"injection": ("call",),
|
||||
"path": ("call",),
|
||||
"deserialization": ("call",),
|
||||
"untrusted_destination": ("call",),
|
||||
"output_encoding": ("call",),
|
||||
"memory": ("call",),
|
||||
"prototype": ("call", "bind"),
|
||||
"access_control": ("operation",),
|
||||
# A permissive default is as often an argument to a call — `cors({origin: "*"})`, `app.use(session({secure:
|
||||
# false}))` — as it is a binding. Measured: with bindings alone the shape missed `createApplication` and
|
||||
# `getAllowedHosts`, whose whole bug is inside a call argument.
|
||||
"config_secrets": ("call", "bind", "config"),
|
||||
"unknown": ("call", "bind"),
|
||||
}
|
||||
|
||||
# The p90 of the measured distribution is 22 sites per labeled function, so two batches cover all but the tail.
|
||||
MAX_CANDIDATES_PER_CALL = 12
|
||||
_MAX_TEXT = 400
|
||||
_LANGUAGE = {"python": "python", "javascript": "javascript", "typescript": "typescript", "c_cpp": "c", "java": "java"}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Candidate:
|
||||
"""One site the model will be asked a single bounded question about."""
|
||||
|
||||
path: str
|
||||
line: int
|
||||
kind: CandidateKind
|
||||
name: str
|
||||
text: str
|
||||
function: str
|
||||
arg_texts: tuple[str, ...] = ()
|
||||
arg_names: tuple[tuple[str, ...], ...] = ()
|
||||
# What the IR resolved for the arguments, nearest binding first. This is the work the model used to do by
|
||||
# grepping, and the reason it no longer has to.
|
||||
binding_chain: tuple[str, ...] = ()
|
||||
guards: tuple[str, ...] = ()
|
||||
|
||||
@property
|
||||
def id(self) -> str:
|
||||
return f"{self.path}:{self.line}:{self.name}"
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"id": self.id,
|
||||
"kind": self.kind,
|
||||
"name": self.name,
|
||||
"line": self.line,
|
||||
"function": self.function,
|
||||
"text": self.text,
|
||||
"arguments": list(self.arg_texts),
|
||||
"binding_chain": list(self.binding_chain),
|
||||
"guards": list(self.guards),
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CandidateSet:
|
||||
"""The sites to judge, what was dropped past the bound, and why there is nothing when there is nothing."""
|
||||
|
||||
candidates: tuple[Candidate, ...] = ()
|
||||
discarded: int = 0
|
||||
reason: str = "" # "", unsupported_language, no_candidate
|
||||
function_found: bool = True
|
||||
|
||||
|
||||
def enumerate_candidates(
|
||||
root: Path,
|
||||
region: object,
|
||||
family: str,
|
||||
*,
|
||||
taxonomy: object,
|
||||
limit: int = MAX_CANDIDATES_PER_CALL,
|
||||
) -> CandidateSet:
|
||||
"""Every site in the region that could carry ``family``'s bug, ordered and bounded."""
|
||||
del taxonomy # the family id is the key; the taxonomy validates it elsewhere
|
||||
from ..semantic.ir import parse_file
|
||||
|
||||
path = str(getattr(region, "path", "") or "")
|
||||
wanted = str(getattr(region, "function", "") or "")
|
||||
target = root / path
|
||||
if not target.is_file():
|
||||
return CandidateSet(reason="unsupported_language", function_found=False)
|
||||
text = target.read_text(errors="ignore")
|
||||
ir = parse_file(path, text, _language_of(path))
|
||||
if not ir.parse_ok:
|
||||
return CandidateSet(reason="unsupported_language", function_found=False)
|
||||
functions = [item for item in ir.functions if item.name != "<module>" and (not wanted or item.name == wanted)]
|
||||
if wanted and not functions:
|
||||
return CandidateSet(reason="no_candidate", function_found=False)
|
||||
kinds = FAMILY_SHAPE.get(family, ("call", "bind"))
|
||||
lines = text.splitlines()
|
||||
found: list[Candidate] = []
|
||||
for function in functions:
|
||||
guards = _guards(ir, function, text, path) if "operation" in kinds else ()
|
||||
found.extend(_from_function(path, function, kinds, lines, guards))
|
||||
found.sort(key=lambda item: (item.path, item.line, item.name))
|
||||
# One question per site: a chained expression reaches the IR as several calls at the same line and name — one
|
||||
# bare, one carrying the arguments — and asking twice would spend budget and double-count the answer. Keep the
|
||||
# richest, because `filter_by(user_id=owner)` and a bare `filter_by` are the same site and only one of them
|
||||
# says what the constraint was.
|
||||
best: dict[str, Candidate] = {}
|
||||
for item in found:
|
||||
current = best.get(item.id)
|
||||
if current is None or (len(item.arg_texts), len(item.binding_chain)) > (len(current.arg_texts), len(current.binding_chain)):
|
||||
best[item.id] = item
|
||||
found = sorted(best.values(), key=lambda item: (item.path, item.line, item.name))
|
||||
if not found:
|
||||
return CandidateSet(reason="no_candidate")
|
||||
return CandidateSet(candidates=tuple(found[:limit]), discarded=max(len(found) - limit, 0))
|
||||
|
||||
|
||||
def _from_function(path: str, function: object, kinds: Sequence[str], lines: Sequence[str], guards: tuple[str, ...]): # type: ignore[no-untyped-def]
|
||||
binds = tuple(getattr(function, "binds", ()) or ())
|
||||
name = str(getattr(function, "name", ""))
|
||||
out: list[Candidate] = []
|
||||
if "call" in kinds or "operation" in kinds:
|
||||
kind: CandidateKind = "operation" if "operation" in kinds else "call"
|
||||
for site in getattr(function, "calls", ()) or ():
|
||||
out.append(
|
||||
Candidate(
|
||||
path=path,
|
||||
line=site.line,
|
||||
kind=kind,
|
||||
name=site.name,
|
||||
text=_line_text(lines, site.line),
|
||||
function=name,
|
||||
arg_texts=tuple(site.arg_texts),
|
||||
arg_names=tuple(tuple(names) for names in site.arg_names),
|
||||
binding_chain=_chain(binds, [n for names in site.arg_names for n in names], site.line),
|
||||
guards=guards,
|
||||
)
|
||||
)
|
||||
if "bind" in kinds or "config" in kinds:
|
||||
for bind in binds:
|
||||
if "config" in kinds and "bind" not in kinds and not bind.is_constant:
|
||||
continue
|
||||
out.append(
|
||||
Candidate(
|
||||
path=path,
|
||||
line=bind.line,
|
||||
kind="config" if (bind.is_constant and "config" in kinds) else "bind",
|
||||
name=bind.name,
|
||||
text=_line_text(lines, bind.line),
|
||||
function=name,
|
||||
arg_texts=(bind.value_text[:_MAX_TEXT],),
|
||||
arg_names=(tuple(bind.names),),
|
||||
binding_chain=_chain(binds, list(bind.names), bind.line),
|
||||
guards=guards,
|
||||
)
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def _chain(binds: Sequence[object], names: Sequence[str], before: int, depth: int = 4) -> tuple[str, ...]:
|
||||
"""Walk the bindings backwards from the names an argument mentions, nearest first.
|
||||
|
||||
This is exactly the work the unconstrained loop was doing with `grep_repo`, and it is deterministic."""
|
||||
chain: list[str] = []
|
||||
seen: set[str] = set()
|
||||
wanted = [name for name in names if name]
|
||||
for _ in range(depth):
|
||||
step = None
|
||||
for bind in sorted(binds, key=lambda item: -int(getattr(item, "line", 0))):
|
||||
line = int(getattr(bind, "line", 0))
|
||||
bound = str(getattr(bind, "name", ""))
|
||||
if line >= before or bound not in wanted or bound in seen:
|
||||
continue
|
||||
step = bind
|
||||
break
|
||||
if step is None:
|
||||
break
|
||||
seen.add(str(step.name)) # type: ignore[attr-defined]
|
||||
chain.append(f"{step.name} = {str(step.value_text)[:_MAX_TEXT]}") # type: ignore[attr-defined]
|
||||
before = int(step.line) # type: ignore[attr-defined]
|
||||
wanted = [name for name in getattr(step, "names", ()) or () if name]
|
||||
if not wanted:
|
||||
break
|
||||
return tuple(chain)
|
||||
|
||||
|
||||
def _guards(ir: object, function: object, text: str, path: str) -> tuple[str, ...]:
|
||||
"""Decorators and in-body witnesses that discharge an obligation on this function, by identifier only."""
|
||||
try:
|
||||
from ..semantic.facts import load_facts
|
||||
from ..semantic.obligations import load_obligation_facts
|
||||
from ..semantic.obligations.operations import find_discharges
|
||||
except ImportError: # pragma: no cover - the obligations facts are shipped, this is belt and braces
|
||||
return ()
|
||||
name = str(getattr(function, "name", ""))
|
||||
try:
|
||||
witnesses = find_discharges(ir, load_obligation_facts(), load_facts(), text=text) # type: ignore[arg-type]
|
||||
except Exception: # noqa: BLE001 - a missing fact file must not stop enumeration
|
||||
return ()
|
||||
del path
|
||||
return tuple(sorted({item.detail or item.fact_id for item in witnesses if item.function == name}))
|
||||
|
||||
|
||||
def _line_text(lines: Sequence[str], line: int) -> str:
|
||||
return lines[line - 1].strip()[:_MAX_TEXT] if 0 < line <= len(lines) else ""
|
||||
|
||||
|
||||
def _language_of(path: str) -> str:
|
||||
from ..preprocess import detect_language
|
||||
|
||||
return _LANGUAGE.get(detect_language(Path(path)), detect_language(Path(path)))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CandidateReport:
|
||||
"""The ceiling: what a candidate-driven detector could find before any model is asked anything.
|
||||
|
||||
This is the number that decides whether the design continues. A generator that cannot put a candidate inside
|
||||
the labeled function can never find that pair's bug however good the judgement is, so the ceiling is published
|
||||
as the limit on recall rather than discovered later as a disappointment (Req 1.3)."""
|
||||
|
||||
generator: str = "ir"
|
||||
per_family: dict[str, dict[str, object]] = field(default_factory=dict)
|
||||
per_slice: dict[str, dict[str, object]] = field(default_factory=dict)
|
||||
gaps: tuple[tuple[str, str, str], ...] = () # (slice, pair, reason)
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"generator": self.generator,
|
||||
"per_family": {name: dict(block) for name, block in sorted(self.per_family.items())},
|
||||
"per_slice": {name: dict(block) for name, block in sorted(self.per_slice.items())},
|
||||
"gaps": [list(row) for row in self.gaps],
|
||||
}
|
||||
|
||||
|
||||
def ceiling(cases: Sequence[object], *, taxonomy: object, generator: str = "ir") -> CandidateReport:
|
||||
"""Per family and per slice, the fraction of labeled functions a generator can put a candidate inside.
|
||||
|
||||
``generator`` is ``ir`` or ``ruleset``. The ruleset arm exists so the comparison that rejected it — 12.1%
|
||||
against 96.6% on the web slices — stays a measurement rather than a remembered number."""
|
||||
import tempfile
|
||||
|
||||
from ..pairs import _materialize_side, _overlay_scan, _quick_scan
|
||||
|
||||
families: dict[str, dict[str, int]] = {}
|
||||
slices: dict[str, dict[str, int]] = {}
|
||||
gaps: list[tuple[str, str, str]] = []
|
||||
sites: dict[str, list[int]] = {}
|
||||
for case in cases:
|
||||
family = labeled_family(case)
|
||||
slice_name = str(getattr(case, "slice", "") or "")
|
||||
region_path = str(getattr(case, "relpath", "") or "")
|
||||
with tempfile.TemporaryDirectory(prefix="ousast-ceiling-") as scratch:
|
||||
root = _materialize_side(Path(scratch) / "vuln", case, side="vuln") # type: ignore[arg-type]
|
||||
if generator == "ruleset":
|
||||
ranges = _overlay_scan(root).ranges
|
||||
spans = labeled_spans(case, ranges)
|
||||
found = [
|
||||
finding
|
||||
for finding in _quick_scan(root)
|
||||
if finding.line is not None and any(path == finding.path and start <= finding.line <= end for path, start, end in spans)
|
||||
]
|
||||
count, reason = len(found), ("" if found else "no_candidate")
|
||||
else:
|
||||
labeled = [str(getattr(row, "function", "") or "") for row in getattr(case, "expected", ()) or ()]
|
||||
result = enumerate_candidates(
|
||||
root,
|
||||
Region(region_path, next((name for name in labeled if name), None)),
|
||||
family,
|
||||
taxonomy=taxonomy,
|
||||
limit=10**6, # the ceiling is about coverage, never about the batch bound
|
||||
)
|
||||
count, reason = len(result.candidates), result.reason
|
||||
for bucket, key in ((families, family), (slices, slice_name)):
|
||||
block = bucket.setdefault(key, {"pairs": 0, "with_candidate": 0})
|
||||
block["pairs"] += 1
|
||||
block["with_candidate"] += 1 if count else 0
|
||||
if count:
|
||||
sites.setdefault(family, []).append(count)
|
||||
else:
|
||||
gaps.append((slice_name, str(getattr(case, "name", "")), reason or "no_candidate"))
|
||||
per_family: dict[str, dict[str, object]] = {}
|
||||
per_slice: dict[str, dict[str, object]] = {}
|
||||
for bucket, out in ((families, per_family), (slices, per_slice)):
|
||||
for key, block in bucket.items():
|
||||
pairs = block["pairs"]
|
||||
row: dict[str, object] = {
|
||||
"pairs": pairs,
|
||||
"with_candidate": block["with_candidate"],
|
||||
"ceiling": (block["with_candidate"] / pairs) if pairs else 0.0,
|
||||
}
|
||||
if bucket is families:
|
||||
row["sites"] = _distribution(sites.get(key, []))
|
||||
out[key] = row
|
||||
return CandidateReport(generator=generator, per_family=per_family, per_slice=per_slice, gaps=tuple(sorted(gaps)))
|
||||
|
||||
|
||||
def _distribution(counts: list[int]) -> dict[str, int]:
|
||||
if not counts:
|
||||
return {"min": 0, "median": 0, "p90": 0, "max": 0}
|
||||
counts = sorted(counts)
|
||||
return {
|
||||
"min": counts[0],
|
||||
"median": counts[len(counts) // 2],
|
||||
"p90": counts[min(int(len(counts) * 0.9), len(counts) - 1)],
|
||||
"max": counts[-1],
|
||||
}
|
||||
|
||||
|
||||
def labeled_family(case: object) -> str:
|
||||
"""The family a pair is labeled with, or ``unknown``.
|
||||
|
||||
The deleted ``learning/rounds.py`` fell back to the LLM classifier here. Nothing does now: an unlabeled
|
||||
pair is honestly ``unknown`` rather than guessed, which is the same discipline the evidence ladder applies
|
||||
to a claim the model cannot arbitrate.
|
||||
"""
|
||||
for row in getattr(case, "expected", ()) or ():
|
||||
family = getattr(row, "family", None)
|
||||
if family:
|
||||
return str(family)
|
||||
return "unknown"
|
||||
|
||||
|
||||
def labeled_spans(
|
||||
case: object, ranges: Mapping[str, Sequence[tuple[str, int, int]]], *, function: str = ""
|
||||
) -> tuple[tuple[str, int, int], ...]:
|
||||
"""The (path, start, end) spans of the labeled functions; ``function`` overrides the label for the fixed side.
|
||||
|
||||
Re-homed from the deleted ``learning/scoring.py`` — the enumerator's ceiling needs it to know which region a
|
||||
candidate had to fall inside, and it carries none of the K-run machinery that surrounded it.
|
||||
"""
|
||||
wanted = {function} if function else {str(getattr(row, "function", "") or "") for row in getattr(case, "expected", ()) or ()}
|
||||
wanted.discard("")
|
||||
found: list[tuple[str, int, int]] = []
|
||||
for path, entries in ranges.items():
|
||||
for name, start, end in entries:
|
||||
if name in wanted:
|
||||
found.append((path, start, end))
|
||||
return tuple(found)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Region:
|
||||
"""The two fields ``enumerate_candidates`` reads: where to look, and which function bounds the search.
|
||||
|
||||
Public since the detector module that used to own this type was removed with the noise architecture — the
|
||||
enumerator still needs a name for its input, and callers still need one to construct.
|
||||
"""
|
||||
|
||||
path: str
|
||||
function: str | None
|
||||
|
||||
|
||||
__all__ = [
|
||||
"FAMILY_SHAPE",
|
||||
"ceiling",
|
||||
"MAX_CANDIDATES_PER_CALL",
|
||||
"Candidate",
|
||||
"CandidateKind",
|
||||
"CandidateReport",
|
||||
"CandidateSet",
|
||||
"Region",
|
||||
"enumerate_candidates",
|
||||
"labeled_family",
|
||||
"labeled_spans",
|
||||
]
|
||||
@@ -0,0 +1,228 @@
|
||||
"""The chat endpoint, resolved independently of embeddings (learning-harness, Req 6.6).
|
||||
|
||||
Detectors, the classifier's model tier, the proposer and the judge all talk to one chat endpoint;
|
||||
prove-order retrieval keeps its own embedding endpoint, because the chat provider we default to has
|
||||
none. Resolution order is explicit override, the scripted client flag, an endpoint named in the
|
||||
config, DeepSeek, then OpenRouter. ``DeepSeekChatClient`` adapts the provider's quirks: thinking is
|
||||
on by default there and silently ignores ``temperature``, so it is disabled explicitly; the reasoning
|
||||
field is kept on the reply for the caller to replay across tool turns; JSON mode has no schema and
|
||||
occasionally returns empty content, so one retry is built in. Prices are data, and an unpriced model
|
||||
reports no cost rather than a made-up one.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from typing import Literal
|
||||
|
||||
from ..config import ResolvedConfig
|
||||
from ..provider.openrouter import OpenRouterChatClient, OpenRouterError
|
||||
from ..tool_hunter import CLIENT_ENV, ChatClient, ChatResponse, chat_response_from_message, scripted_hunter_client
|
||||
|
||||
DEEPSEEK_BASE_URL = "https://api.deepseek.com" # no version suffix: the platform rejects /v1
|
||||
DEEPSEEK_KEY_ENV = "DEEPSEEK_API_KEY"
|
||||
DEEPSEEK_BASE_ENV = "DEEPSEEK_BASE_URL"
|
||||
OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
|
||||
DEFAULT_DETECTOR_MODEL = "deepseek-v4-flash"
|
||||
DEFAULT_JUDGE_MODEL = "deepseek-v4-pro"
|
||||
Provider = Literal["deepseek", "openrouter", "custom", "scripted"]
|
||||
_SCRIPTED_FLAGS = frozenset({"scripted", "script", "dump", "dump-only", "unsafe", "unsafe-snippet"})
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Prices:
|
||||
"""USD per million tokens. DeepSeek platform pricing, peak rate, recorded 2026-09-06."""
|
||||
|
||||
cache_hit_per_m: float
|
||||
input_per_m: float
|
||||
output_per_m: float
|
||||
|
||||
|
||||
_PRICES: dict[str, Prices] = {
|
||||
"deepseek-v4-flash": Prices(cache_hit_per_m=0.014, input_per_m=0.44, output_per_m=1.32),
|
||||
"deepseek-v4-flash-vision-exp": Prices(cache_hit_per_m=0.014, input_per_m=0.44, output_per_m=1.32),
|
||||
"deepseek-v4-pro": Prices(cache_hit_per_m=0.044, input_per_m=1.32, output_per_m=3.96),
|
||||
}
|
||||
|
||||
|
||||
def price_of(model: str) -> Prices | None:
|
||||
return _PRICES.get(model.strip())
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ChatEndpoint:
|
||||
"""What the run should record about where the model calls went. Never holds the key."""
|
||||
|
||||
provider: Provider
|
||||
base_url: str
|
||||
thinking: bool # the provider's models reason by default, so the adapter disables it explicitly
|
||||
prices: Prices | None = None
|
||||
|
||||
def cost(self, usage: Mapping[str, object] | None) -> float | None:
|
||||
"""Dollars for one call from the provider's own usage fields, or None when the model has no recorded price."""
|
||||
if self.prices is None or not isinstance(usage, Mapping):
|
||||
return None
|
||||
cache_hit = _tokens(usage.get("prompt_cache_hit_tokens"))
|
||||
miss = usage.get("prompt_cache_miss_tokens")
|
||||
input_tokens = _tokens(miss) if miss is not None else max(_tokens(usage.get("prompt_tokens")) - cache_hit, 0)
|
||||
output_tokens = _tokens(usage.get("completion_tokens"))
|
||||
return (
|
||||
cache_hit * self.prices.cache_hit_per_m + input_tokens * self.prices.input_per_m + output_tokens * self.prices.output_per_m
|
||||
) / 1_000_000
|
||||
|
||||
|
||||
class DeepSeekChatClient:
|
||||
"""Adapt an OpenAI-shaped chat client to what detectors and the classifier need."""
|
||||
|
||||
def __init__(self, client: OpenRouterChatClient, *, disable_thinking: bool = True, endpoint: ChatEndpoint | None = None) -> None:
|
||||
self._client = client
|
||||
self._disable_thinking = disable_thinking
|
||||
self.endpoint = endpoint
|
||||
self.usage: list[dict[str, object]] = [] # one entry per call, for the round's cost meter
|
||||
|
||||
def complete(
|
||||
self,
|
||||
*,
|
||||
model: str,
|
||||
messages: list[dict[str, object]],
|
||||
tools: list[dict[str, object]],
|
||||
timeout_seconds: int = 60,
|
||||
json_object: bool = False,
|
||||
logprobs: bool = False,
|
||||
) -> ChatResponse:
|
||||
response = self._call(
|
||||
model=model, messages=messages, tools=tools, timeout_seconds=timeout_seconds, json_object=json_object, logprobs=logprobs
|
||||
)
|
||||
if json_object and not (response.content or "").strip():
|
||||
response = self._call(
|
||||
model=model, messages=messages, tools=tools, timeout_seconds=timeout_seconds, json_object=json_object, logprobs=logprobs
|
||||
) # documented behaviour: JSON mode occasionally returns empty content. One retry, never a loop.
|
||||
return response
|
||||
|
||||
def cost_usd(self) -> float:
|
||||
"""Recorded cost of the calls made so far; calls whose model has no price contribute nothing."""
|
||||
if self.endpoint is None:
|
||||
return 0.0
|
||||
return sum(self.endpoint.cost(entry) or 0.0 for entry in self.usage)
|
||||
|
||||
def _call(
|
||||
self,
|
||||
*,
|
||||
model: str,
|
||||
messages: Sequence[Mapping[str, object]],
|
||||
tools: Sequence[Mapping[str, object]],
|
||||
timeout_seconds: int,
|
||||
json_object: bool,
|
||||
logprobs: bool,
|
||||
) -> ChatResponse:
|
||||
extra: dict[str, object] = {}
|
||||
if self._disable_thinking:
|
||||
extra["thinking"] = {"type": "disabled"} # otherwise temperature is silently ignored
|
||||
if json_object:
|
||||
extra["response_format"] = {"type": "json_object"}
|
||||
if logprobs:
|
||||
extra["logprobs"] = True
|
||||
extra["top_logprobs"] = 1
|
||||
raw = getattr(self._client, "complete_chat_raw", None)
|
||||
if callable(raw):
|
||||
payload = raw(
|
||||
model=model, messages=list(messages), tools=list(tools) or None, timeout_seconds=timeout_seconds, extra_body=extra
|
||||
)
|
||||
usage = payload.get("usage") if isinstance(payload, dict) else None
|
||||
if isinstance(usage, dict):
|
||||
self.usage.append(dict(usage))
|
||||
message = _message_of(payload)
|
||||
else: # a stub or a client without the raw call
|
||||
message = self._client.complete_chat(
|
||||
model=model, messages=list(messages), tools=list(tools) or None, timeout_seconds=timeout_seconds, extra_body=extra
|
||||
)
|
||||
return chat_response_from_message(message)
|
||||
|
||||
|
||||
def resolve_chat_endpoint(config: ResolvedConfig, *, override: ChatClient | None = None) -> tuple[ChatClient, ChatEndpoint] | None:
|
||||
"""The chat client for detectors, classifier, proposer and judge, with what to record about it."""
|
||||
if override is not None:
|
||||
return override, ChatEndpoint(provider="scripted", base_url="", thinking=False)
|
||||
flag = os.environ.get(CLIENT_ENV, "").strip().lower()
|
||||
if flag in _SCRIPTED_FLAGS:
|
||||
return scripted_hunter_client(flag), ChatEndpoint(provider="scripted", base_url="", thinking=False)
|
||||
model = resolve_models(config)[0]
|
||||
configured = config.models.chat_base_url
|
||||
key_env = config.models.chat_api_key_env
|
||||
if configured or key_env:
|
||||
key = os.environ.get(key_env or DEEPSEEK_KEY_ENV, "")
|
||||
base_url = configured or DEEPSEEK_BASE_URL
|
||||
return _deepseek_client(key, base_url, model, provider=_provider_of(base_url), thinking=config.models.thinking)
|
||||
deepseek_key = os.environ.get(DEEPSEEK_KEY_ENV)
|
||||
if deepseek_key:
|
||||
return _deepseek_client(
|
||||
deepseek_key,
|
||||
os.environ.get(DEEPSEEK_BASE_ENV, DEEPSEEK_BASE_URL),
|
||||
model,
|
||||
provider="deepseek",
|
||||
thinking=config.models.thinking,
|
||||
)
|
||||
if os.environ.get("OPENROUTER_API_KEY"):
|
||||
try:
|
||||
client = OpenRouterChatClient.from_env()
|
||||
except OpenRouterError:
|
||||
return None
|
||||
endpoint = ChatEndpoint(provider="openrouter", base_url=client.base_url, thinking=False, prices=price_of(model))
|
||||
return DeepSeekChatClient(client, disable_thinking=False, endpoint=endpoint), endpoint
|
||||
return None
|
||||
|
||||
|
||||
def resolve_models(config: ResolvedConfig) -> tuple[str, str]:
|
||||
"""(detector model, judge model) from `[models]`, then the shipped defaults."""
|
||||
detector = config.models.hunter or DEFAULT_DETECTOR_MODEL
|
||||
judge = config.models.judge or DEFAULT_JUDGE_MODEL
|
||||
return detector, judge
|
||||
|
||||
|
||||
def _deepseek_client(key: str, base_url: str, model: str, *, provider: Provider, thinking: bool = False) -> tuple[ChatClient, ChatEndpoint]:
|
||||
endpoint = ChatEndpoint(provider=provider, base_url=base_url.rstrip("/"), thinking=thinking, prices=price_of(model))
|
||||
client = OpenRouterChatClient(api_key=key, base_url=endpoint.base_url)
|
||||
return DeepSeekChatClient(client, disable_thinking=not thinking, endpoint=endpoint), endpoint
|
||||
|
||||
|
||||
def _provider_of(base_url: str) -> Provider:
|
||||
host = base_url.lower()
|
||||
if "deepseek" in host:
|
||||
return "deepseek"
|
||||
return "openrouter" if "openrouter" in host else "custom"
|
||||
|
||||
|
||||
def _message_of(payload: object) -> Mapping[str, object]:
|
||||
"""The reply message, carrying the choice's `logprobs` with it.
|
||||
|
||||
OpenAI-shaped providers put `logprobs` on the *choice*, beside the message rather than inside it. Reading only
|
||||
the message meant the confidence signal was always absent, so a knob meant to let the classifier abstain when
|
||||
it is unsure read as "never unsure" whatever the model said."""
|
||||
if isinstance(payload, Mapping):
|
||||
choices = payload.get("choices")
|
||||
if isinstance(choices, Sequence) and choices and isinstance(choices[0], Mapping):
|
||||
choice = choices[0]
|
||||
message = choice.get("message")
|
||||
if isinstance(message, Mapping):
|
||||
logprobs = choice.get("logprobs")
|
||||
return {**message, "logprobs": logprobs} if logprobs is not None else message
|
||||
return {}
|
||||
|
||||
|
||||
def _tokens(value: object) -> int:
|
||||
return int(value) if isinstance(value, int | float) else 0
|
||||
|
||||
|
||||
__all__ = [
|
||||
"DEEPSEEK_BASE_URL",
|
||||
"DEFAULT_DETECTOR_MODEL",
|
||||
"DEFAULT_JUDGE_MODEL",
|
||||
"ChatEndpoint",
|
||||
"DeepSeekChatClient",
|
||||
"Prices",
|
||||
"price_of",
|
||||
"resolve_chat_endpoint",
|
||||
"resolve_models",
|
||||
]
|
||||
@@ -0,0 +1,156 @@
|
||||
"""The security vocabularies the model layer arbitrates with (model-grounded-detection, Req 7.2-7.4).
|
||||
|
||||
Two kinds of knowledge live here, and they are held differently on purpose.
|
||||
|
||||
The **guard vocabulary** is ported verbatim from ``semantic/variants.py``, whose optimisation loop this
|
||||
feature deleted. Nothing else holds it: it is the closed set of ways a fix discharges a flow, and it tells
|
||||
the model what a *fixed* twin looks like. Order matters — first match wins.
|
||||
|
||||
The **taint and dominance specs** are *derived* from data that survives: the source/sink/sanitizer facts in
|
||||
``ruleset/semantic`` and the obligated operations and dischargers in ``ruleset/obligations``. Copying them
|
||||
here would create a second place to update, so this module reads them through the retained loaders and
|
||||
groups them by family via the taxonomy's CWE map.
|
||||
|
||||
Read-only by construction (Req 7.4, the verifier boundary): this module exposes no writer, opens no file for
|
||||
writing, and holds no mutable module state. An LLM or a future optimiser has nothing here to rewrite.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
|
||||
from ..semantic.facts import SemanticFacts, load_facts
|
||||
from ..semantic.obligations.facts import ObligationFacts, load_obligation_facts
|
||||
from .taxonomy import FamilyTaxonomy, load_families
|
||||
|
||||
# --- The guard vocabulary (ported verbatim; nothing else holds it) ----------------------------------------
|
||||
|
||||
GUARD_KINDS = ("null_test", "bounds_test", "allowlist_test", "auth_check", "parameterized_call", "type_change", "none")
|
||||
|
||||
# Closed guard classification over the statements a fix added inside the labeled function.
|
||||
# Order matters: the first matching kind wins. These classify the *fix*, they never detect.
|
||||
GUARD_PATTERNS: tuple[tuple[str, re.Pattern[str]], ...] = (
|
||||
("parameterized_call", re.compile(r"execute\w*\([^,]+,\s*[\(\[]|prepareStatement\(|\?\s*['\"]?\s*[,)]|\$\d\b|%s['\"]\s*,\s*[\(\[]")),
|
||||
(
|
||||
"auth_check",
|
||||
re.compile(
|
||||
r"\b(request\.user|req\.user|current_user|request\.state|session\[|is_authenticated|authoriz|require_auth"
|
||||
r"|login_required|verify_token|check_permission|owner_id\s*[!=]=)"
|
||||
),
|
||||
),
|
||||
(
|
||||
"allowlist_test",
|
||||
re.compile(
|
||||
r"\bnot in\b|\bin\s*[\(\{\[]|\.includes\(|\.has\(|\bstartswith\(|\bstartsWith\(|\bendswith\(|\bendsWith\("
|
||||
r"|re\.(?:match|fullmatch)\(|\.test\(|allow_?list|whitelist|\bALLOWED\b|\bsafe_join\(|secure_filename\("
|
||||
r"|\brealpath\(|\bnormpath\(|\bresolve\("
|
||||
),
|
||||
),
|
||||
("bounds_test", re.compile(r"\blen\(|\.length\b|\bsizeof\(|\bstrn?len\(|\bsnprintf\(|\bstrlcpy\(|\bmin\(|\bmax\(|\s(?:<|>|<=|>=)\s")),
|
||||
(
|
||||
"null_test",
|
||||
re.compile(
|
||||
r"\bis None\b|\bis not None\b|[!=]= *NULL\b|[!=]==? *null\b|\bnullptr\b|\bif\s*\(\s*!\s*\w|\bif not \w+\s*:"
|
||||
r"|\bif\s+\w+\s+is\s+None|\?\?|\?\."
|
||||
),
|
||||
),
|
||||
(
|
||||
"type_change",
|
||||
re.compile(r"\bint\(|\bparseInt\(|\bNumber\(|\(int\)|\(size_t\)|\(unsigned\b|\bstr\(|\.toString\(|\bstatic_cast<|\bBigInt\("),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def classify_guard_text(text: str) -> str:
|
||||
"""The guard kind the given statements express, or ``none`` when no known guard is recognised."""
|
||||
for kind, pattern in GUARD_PATTERNS:
|
||||
if pattern.search(text):
|
||||
return kind
|
||||
return "none"
|
||||
|
||||
|
||||
# --- The specs the CPG queries are parameterised by ------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TaintSpec:
|
||||
"""What a flow family's source→sink−sanitizer query matches on, for one language."""
|
||||
|
||||
family: str
|
||||
language: str
|
||||
sources: tuple[str, ...]
|
||||
sinks: tuple[str, ...]
|
||||
sanitizers: tuple[str, ...]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DominanceSpec:
|
||||
"""What an absence family's guard-dominates-operation query matches on, for one language.
|
||||
|
||||
``dischargers`` are the guards that discharge an obligation; the query asks whether one of them dominates
|
||||
the operation on every path, which is the arbiter for the classes that never crash.
|
||||
"""
|
||||
|
||||
family: str
|
||||
language: str
|
||||
operations: tuple[str, ...]
|
||||
dischargers: tuple[str, ...]
|
||||
|
||||
|
||||
def taint_specs(
|
||||
*,
|
||||
language: str,
|
||||
facts: SemanticFacts | None = None,
|
||||
taxonomy: FamilyTaxonomy | None = None,
|
||||
) -> Mapping[str, TaintSpec]:
|
||||
"""One ``TaintSpec`` per flow family that has a sink in this language, grouped through the taxonomy's CWE map.
|
||||
|
||||
Sources and sanitizers are language-wide: a family does not get its own notion of what untrusted input is,
|
||||
and a sanitizer that breaks one flow breaks it whatever the sink's family.
|
||||
"""
|
||||
scoped = (facts if facts is not None else load_facts()).for_language(language)
|
||||
families = taxonomy if taxonomy is not None else load_families()
|
||||
sources = tuple(sorted({pattern for fact in scoped.sources for pattern in fact.patterns}))
|
||||
sanitizers = tuple(sorted({call for fact in scoped.sanitizers for call in fact.calls}))
|
||||
by_family: dict[str, set[str]] = {}
|
||||
for sink in scoped.sinks:
|
||||
family = families.family_of_cwe(sink.cwe)
|
||||
if family is None:
|
||||
continue
|
||||
by_family.setdefault(family.id, set()).update(sink.calls)
|
||||
return {
|
||||
family_id: TaintSpec(family=family_id, language=language, sources=sources, sinks=tuple(sorted(calls)), sanitizers=sanitizers)
|
||||
for family_id, calls in sorted(by_family.items())
|
||||
}
|
||||
|
||||
|
||||
def dominance_specs(*, language: str, facts: ObligationFacts | None = None) -> Mapping[str, DominanceSpec]:
|
||||
"""One ``DominanceSpec`` per absence family for this language.
|
||||
|
||||
The obligation facts are authored for authorization, so every obligated operation routes to
|
||||
``access_control``; the shape generalises when a second absence family is added to the taxonomy.
|
||||
"""
|
||||
scoped = (facts if facts is not None else load_obligation_facts()).for_language(language)
|
||||
if not scoped.operations:
|
||||
return {}
|
||||
operations = tuple(sorted({call for fact in scoped.operations for call in fact.calls}))
|
||||
dischargers = tuple(
|
||||
sorted(
|
||||
{call for fact in scoped.dischargers for call in fact.calls}
|
||||
| {decorator for fact in scoped.dischargers for decorator in fact.decorators}
|
||||
)
|
||||
)
|
||||
return {"access_control": DominanceSpec(family="access_control", language=language, operations=operations, dischargers=dischargers)}
|
||||
|
||||
|
||||
__all__ = [
|
||||
"GUARD_KINDS",
|
||||
"GUARD_PATTERNS",
|
||||
"DominanceSpec",
|
||||
"TaintSpec",
|
||||
"classify_guard_text",
|
||||
"dominance_specs",
|
||||
"taint_specs",
|
||||
]
|
||||
@@ -0,0 +1,189 @@
|
||||
"""The closed family taxonomy (learning-harness, Req 1).
|
||||
|
||||
Ten families, loaded from ``ruleset/families.toml``. A family is the only key that routes work and
|
||||
scores a pair: CWE ids and mechanism ids are attributes reached *through* a family, so no caller may
|
||||
group by them. Every family names what can verify a claim in it (a sandbox canary, static
|
||||
corroboration, or nothing), which is what keeps reporting honest one layer up. An unknown field, a
|
||||
family outside the closed set, a missing verifier kind or a CWE claimed twice is a data bug and fails
|
||||
loud, naming the offender.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import tomllib
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
FAMILY_IDS = (
|
||||
"injection",
|
||||
"path",
|
||||
"deserialization",
|
||||
"access_control",
|
||||
"output_encoding",
|
||||
"untrusted_destination",
|
||||
"prototype",
|
||||
"config_secrets",
|
||||
"memory",
|
||||
"unknown",
|
||||
)
|
||||
VERIFIER_KINDS = ("canary", "static", "none")
|
||||
Relation = Literal["same", "parent_child", "lateral", "fabricated"]
|
||||
DEFAULT_FAMILIES_PATH = Path(__file__).resolve().parents[1] / "ruleset" / "families.toml"
|
||||
|
||||
_FAMILY_FIELDS = frozenset({"id", "description", "cwes", "mechanisms", "verifier", "parent"})
|
||||
_TOP_FIELDS = frozenset({"version", "family"})
|
||||
|
||||
|
||||
class FamiliesError(ValueError):
|
||||
"""Raised when the taxonomy file names a family, field, verifier or CWE outside the closed sets."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Family:
|
||||
id: str # FAMILY_IDS
|
||||
description: str
|
||||
cwes: frozenset[str]
|
||||
mechanisms: frozenset[str]
|
||||
verifier: str # VERIFIER_KINDS
|
||||
parent: str | None = None # a family this one specializes; partial credit when one side is the other's parent
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FamilyTaxonomy:
|
||||
version: str
|
||||
families: tuple[Family, ...]
|
||||
|
||||
def by_id(self, family_id: str) -> Family:
|
||||
for family in self.families:
|
||||
if family.id == family_id:
|
||||
return family
|
||||
raise FamiliesError(f"unknown family {family_id!r}; expected one of {', '.join(f.id for f in self.families)}")
|
||||
|
||||
def family_of_cwe(self, cwe: str) -> Family | None:
|
||||
"""The family that claims this CWE, or None when nobody has placed it yet (a data gap, not `unknown`)."""
|
||||
token = cwe.strip().upper()
|
||||
return next((family for family in self.families if token in family.cwes), None)
|
||||
|
||||
def family_of_mechanism(self, mechanism: str) -> Family | None:
|
||||
return next((family for family in self.families if mechanism in family.mechanisms), None)
|
||||
|
||||
@property
|
||||
def hierarchical(self) -> bool:
|
||||
"""True when some family declares a parent. While this is False, partial credit can never be earned."""
|
||||
return any(family.parent for family in self.families)
|
||||
|
||||
def related(self, left: str, right: str) -> Relation:
|
||||
"""How two family answers relate. Abstention is not partial credit: ``unknown`` against a real
|
||||
family is ``lateral``, never ``parent_child``."""
|
||||
known = {family.id: family for family in self.families}
|
||||
if left not in known or right not in known:
|
||||
return "fabricated"
|
||||
if left == right:
|
||||
return "same"
|
||||
if known[left].parent == right or known[right].parent == left:
|
||||
return "parent_child"
|
||||
return "lateral"
|
||||
|
||||
|
||||
def load_families(path: Path | None = None, *, require_all: bool = True) -> FamilyTaxonomy:
|
||||
"""Load the taxonomy. ``require_all`` demands the full closed set in order (the shipped file);
|
||||
a scoped fixture may declare a subset, but never a family outside ``FAMILY_IDS``."""
|
||||
source = path if path is not None else DEFAULT_FAMILIES_PATH
|
||||
try:
|
||||
payload = tomllib.loads(source.read_text(encoding="utf-8"))
|
||||
except OSError as exc:
|
||||
raise FamiliesError(f"families file unreadable: {source}") from exc
|
||||
except tomllib.TOMLDecodeError as exc:
|
||||
raise FamiliesError(f"families file is not valid TOML: {source}: {exc}") from exc
|
||||
unknown_top = sorted(set(payload) - _TOP_FIELDS)
|
||||
if unknown_top:
|
||||
raise FamiliesError(f"{source}: unknown top-level field(s) {', '.join(unknown_top)}")
|
||||
version = str(payload.get("version", "")).strip()
|
||||
if not version:
|
||||
raise FamiliesError(f"{source}: version is required")
|
||||
families: list[Family] = []
|
||||
claimed: dict[str, str] = {}
|
||||
claimed_mechanisms: dict[str, str] = {}
|
||||
for entry in payload.get("family", []):
|
||||
if not isinstance(entry, dict):
|
||||
raise FamiliesError(f"{source}: every [[family]] entry must be a table")
|
||||
family = _family(source, entry)
|
||||
if any(existing.id == family.id for existing in families):
|
||||
raise FamiliesError(f"{source}: family {family.id!r} declared twice")
|
||||
for cwe in sorted(family.cwes):
|
||||
if cwe in claimed:
|
||||
raise FamiliesError(f"{source}: {cwe} is claimed by both {claimed[cwe]!r} and {family.id!r}")
|
||||
claimed[cwe] = family.id
|
||||
for mechanism in sorted(family.mechanisms):
|
||||
if mechanism in claimed_mechanisms:
|
||||
raise FamiliesError(
|
||||
f"{source}: mechanism {mechanism} is claimed by both {claimed_mechanisms[mechanism]!r} and {family.id!r}"
|
||||
)
|
||||
claimed_mechanisms[mechanism] = family.id
|
||||
families.append(family)
|
||||
_check_parents(source, families)
|
||||
if require_all and tuple(family.id for family in families) != FAMILY_IDS:
|
||||
raise FamiliesError(f"{source}: expected exactly {', '.join(FAMILY_IDS)} in that order, got {', '.join(f.id for f in families)}")
|
||||
if not families:
|
||||
raise FamiliesError(f"{source}: no families declared")
|
||||
return FamilyTaxonomy(version=version, families=tuple(families))
|
||||
|
||||
|
||||
def _family(source: Path, entry: dict[str, object]) -> Family:
|
||||
unknown_fields = sorted(set(entry) - _FAMILY_FIELDS)
|
||||
if unknown_fields:
|
||||
raise FamiliesError(f"{source}: family {entry.get('id')!r} has unknown field(s) {', '.join(unknown_fields)}")
|
||||
family_id = str(entry.get("id", "")).strip()
|
||||
if family_id not in FAMILY_IDS:
|
||||
raise FamiliesError(f"{source}: unknown family {family_id!r}; expected one of {', '.join(FAMILY_IDS)}")
|
||||
verifier = str(entry.get("verifier", "")).strip()
|
||||
if verifier not in VERIFIER_KINDS:
|
||||
raise FamiliesError(f"{source}: family {family_id!r} has verifier {verifier!r}; expected one of {', '.join(VERIFIER_KINDS)}")
|
||||
description = str(entry.get("description", "")).strip()
|
||||
if not description:
|
||||
raise FamiliesError(f"{source}: family {family_id!r} needs a description")
|
||||
cwes = _tokens(source, family_id, entry.get("cwes", ()), "cwes", upper=True)
|
||||
mechanisms = _tokens(source, family_id, entry.get("mechanisms", ()), "mechanisms")
|
||||
parent = entry.get("parent")
|
||||
return Family(
|
||||
id=family_id,
|
||||
description=description,
|
||||
cwes=frozenset(cwes),
|
||||
mechanisms=frozenset(mechanisms),
|
||||
verifier=verifier,
|
||||
parent=str(parent) if parent else None,
|
||||
)
|
||||
|
||||
|
||||
def _tokens(source: Path, family_id: str, value: object, field: str, *, upper: bool = False) -> list[str]:
|
||||
if not isinstance(value, list) or any(not isinstance(item, str) for item in value):
|
||||
raise FamiliesError(f"{source}: family {family_id!r} field {field} must be a list of strings")
|
||||
tokens = [item.strip().upper() if upper else item.strip() for item in value]
|
||||
duplicates = sorted({token for token in tokens if tokens.count(token) > 1})
|
||||
if duplicates:
|
||||
raise FamiliesError(f"{source}: family {family_id!r} lists {', '.join(duplicates)} twice in {field}")
|
||||
return tokens
|
||||
|
||||
|
||||
def _check_parents(source: Path, families: list[Family]) -> None:
|
||||
ids = {family.id for family in families}
|
||||
for family in families:
|
||||
if family.parent is None:
|
||||
continue
|
||||
if family.parent not in ids:
|
||||
raise FamiliesError(f"{source}: family {family.id!r} declares unknown parent {family.parent!r}")
|
||||
if family.parent == family.id:
|
||||
raise FamiliesError(f"{source}: family {family.id!r} is its own parent")
|
||||
|
||||
|
||||
__all__ = [
|
||||
"DEFAULT_FAMILIES_PATH",
|
||||
"FAMILY_IDS",
|
||||
"VERIFIER_KINDS",
|
||||
"FamiliesError",
|
||||
"Family",
|
||||
"FamilyTaxonomy",
|
||||
"Relation",
|
||||
"load_families",
|
||||
]
|
||||
Reference in New Issue
Block a user