mirror of
https://github.com/norandom/OpenUltraSAST.git
synced 2026-10-16 11:49:28 +00:00
merge: legacy cleanup -- audit by real imports, dead modules removed, unread config retired, archives, dockerignore, comments
Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01EmM1j46qgKMh3zqD1QMwEo
This commit is contained in:
+19
-58
@@ -20,10 +20,24 @@ _PLANE_RUN = "`ousast plane run`, see ops/ax/README.md"
|
||||
_RETIRED_PANELS = (
|
||||
f"LLM fusion panels were retired; fusion is deterministic; independent LLM agreement is the plane's `agree` task ({_PLANE_RUN})"
|
||||
)
|
||||
# Keys that loaded but were never read (retired 2026-10-02, legacy cleanup): accepting them silently would let
|
||||
# a user believe they configure something.
|
||||
_UNREAD_ON = "retired 2026-10-02 because nothing ever read it"
|
||||
_UNREAD_DYNAMIC = "no dynamic or deep tier exists; deep mode will define its own keys when it is built"
|
||||
_UNREAD_EVIDENCE = "evidence tiers come from the program model's ladder (model/ladder.py), not from configuration"
|
||||
_UNREAD_VARIANTS = "the structural variant search it configured was deleted with semantic/variants.py"
|
||||
RETIRED_KEYS: dict[tuple[str, str], str] = {
|
||||
("models", "verifier"): f"LLM verification runs on the plane (`verify` + `agree`, {_PLANE_RUN})",
|
||||
("fusion", "panel_model"): _RETIRED_PANELS,
|
||||
("fusion", "decider_model"): _RETIRED_PANELS,
|
||||
("models", "verifier"): f"{_RETIRED_ON}: LLM verification runs on the plane (`verify` + `agree`, {_PLANE_RUN})",
|
||||
("fusion", "panel_model"): f"{_RETIRED_ON}: {_RETIRED_PANELS}",
|
||||
("fusion", "decider_model"): f"{_RETIRED_ON}: {_RETIRED_PANELS}",
|
||||
("models", "patcher"): f"{_UNREAD_ON}: no patching stage calls a model",
|
||||
("dynamic", "enabled"): f"{_UNREAD_ON}: {_UNREAD_DYNAMIC}",
|
||||
("dynamic", "network_scope"): f"{_UNREAD_ON}: {_UNREAD_DYNAMIC}",
|
||||
("evidence", "minimum_report_verified"): f"{_UNREAD_ON}: {_UNREAD_EVIDENCE}",
|
||||
("evidence", "minimum_exploit"): f"{_UNREAD_ON}: {_UNREAD_EVIDENCE}",
|
||||
("evidence", "minimum_patch"): f"{_UNREAD_ON}: {_UNREAD_EVIDENCE}",
|
||||
("variants", "enabled"): f"{_UNREAD_ON}: {_UNREAD_VARIANTS}",
|
||||
("variants", "max_mechanisms"): f"{_UNREAD_ON}: {_UNREAD_VARIANTS}",
|
||||
}
|
||||
RETIRED_SECTION = "harnessx" # retired 2026-09-30: loads with one warning and is otherwise ignored (Req 3.3)
|
||||
RETIRED_SECTION_WARNING = (
|
||||
@@ -38,10 +52,10 @@ class RetiredConfigError(ValueError):
|
||||
|
||||
def _check_retired(data: dict[str, object]) -> None:
|
||||
problems = []
|
||||
for (section, key), replacement in RETIRED_KEYS.items():
|
||||
for (section, key), reason in RETIRED_KEYS.items():
|
||||
table = data.get(section)
|
||||
if isinstance(table, dict) and key in table:
|
||||
problems.append(f"`[{section}] {key}` was {_RETIRED_ON}: {replacement}. Remove the key.")
|
||||
problems.append(f"`[{section}] {key}` was {reason}. Remove the key.")
|
||||
if problems:
|
||||
raise RetiredConfigError("\n".join(problems))
|
||||
if RETIRED_SECTION in data:
|
||||
@@ -70,7 +84,6 @@ def load_dotenv(path: Path | None = None, *, force: bool = False) -> None:
|
||||
class ModelConfig:
|
||||
ranker: str | None = None
|
||||
hunter: str | None = None
|
||||
patcher: str | None = None
|
||||
judge: str | None = None # second, independent judgement before anything is published as proven
|
||||
chat_base_url: str | None = None # chat endpoint; independent of the embedding endpoint
|
||||
chat_api_key_env: str | None = None # environment variable holding that endpoint's key
|
||||
@@ -96,19 +109,6 @@ class SandboxConfig:
|
||||
pids_limit: int = 512
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DynamicConfig:
|
||||
enabled: bool = False
|
||||
network_scope: tuple[str, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EvidenceConfig:
|
||||
minimum_report_verified: str = "static_corroboration"
|
||||
minimum_exploit: str = "crash_reproduced"
|
||||
minimum_patch: str = "root_cause_explained"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class StaticAnalysisConfig:
|
||||
sarif_paths: tuple[str, ...] = ()
|
||||
@@ -160,13 +160,6 @@ class RegressConfig:
|
||||
images: tuple[tuple[str, str], ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class VariantsConfig:
|
||||
# corpus-seeded-mechanisms: structural variant search in MAP (standard/deep only).
|
||||
enabled: bool = True
|
||||
max_mechanisms: int = 500
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ModelLayerConfig:
|
||||
# contributor-scan: the model layer in MAP. Named for the LAYER, not the model: `ModelConfig` is already
|
||||
@@ -214,8 +207,6 @@ class ResolvedConfig:
|
||||
models: ModelConfig = ModelConfig()
|
||||
embeddings: EmbeddingConfig = EmbeddingConfig()
|
||||
sandbox: SandboxConfig = SandboxConfig()
|
||||
dynamic: DynamicConfig = DynamicConfig()
|
||||
evidence: EvidenceConfig = EvidenceConfig()
|
||||
static_analysis: StaticAnalysisConfig = StaticAnalysisConfig()
|
||||
score: ScoreConfig = ScoreConfig()
|
||||
ruleset: RulesetConfig = RulesetConfig()
|
||||
@@ -223,7 +214,6 @@ class ResolvedConfig:
|
||||
hardening: HardeningConfig = HardeningConfig()
|
||||
complexity: ComplexityConfig = ComplexityConfig()
|
||||
regress: RegressConfig = RegressConfig()
|
||||
variants: VariantsConfig = VariantsConfig()
|
||||
obligations: ObligationsConfig = ObligationsConfig()
|
||||
model: ModelLayerConfig = ModelLayerConfig()
|
||||
push: PushConfig = PushConfig()
|
||||
@@ -243,8 +233,6 @@ def load_config(config_path: Path | None = None, *, dotenv: bool = True) -> Reso
|
||||
models=_load_models(data.get("models", {})),
|
||||
embeddings=_load_embeddings(data.get("embeddings", {})),
|
||||
sandbox=_load_sandbox(data.get("sandbox", {})),
|
||||
dynamic=_load_dynamic(data.get("dynamic", {})),
|
||||
evidence=_load_evidence(data.get("evidence", {})),
|
||||
static_analysis=_load_static_analysis(data.get("static_analysis", {})),
|
||||
score=_load_score(data.get("score", {})),
|
||||
ruleset=_load_ruleset_config(data.get("ruleset", {})),
|
||||
@@ -252,7 +240,6 @@ def load_config(config_path: Path | None = None, *, dotenv: bool = True) -> Reso
|
||||
hardening=_load_hardening(data.get("hardening", {})),
|
||||
complexity=_load_complexity(data.get("complexity", {})),
|
||||
regress=_load_regress(data.get("regress", {})),
|
||||
variants=_load_variants(data.get("variants", {})),
|
||||
obligations=_load_obligations(data.get("obligations", {})),
|
||||
model=_load_model(data.get("model", {})),
|
||||
push=PushConfig.from_payload(data.get("push", {})),
|
||||
@@ -283,7 +270,6 @@ def _load_models(value: object) -> ModelConfig:
|
||||
return ModelConfig(
|
||||
ranker=_string(data.get("ranker")),
|
||||
hunter=_string(data.get("hunter")),
|
||||
patcher=_string(data.get("patcher")),
|
||||
judge=_string(data.get("judge")),
|
||||
chat_base_url=_string(data.get("chat_base_url")),
|
||||
chat_api_key_env=_string(data.get("chat_api_key_env")),
|
||||
@@ -310,23 +296,6 @@ def _load_sandbox(value: object) -> SandboxConfig:
|
||||
)
|
||||
|
||||
|
||||
def _load_dynamic(value: object) -> DynamicConfig:
|
||||
data = _section(value)
|
||||
scope = data.get("network_scope", [])
|
||||
if not isinstance(scope, list):
|
||||
scope = []
|
||||
return DynamicConfig(enabled=bool(data.get("enabled", False)), network_scope=tuple(str(item) for item in scope))
|
||||
|
||||
|
||||
def _load_evidence(value: object) -> EvidenceConfig:
|
||||
data = _section(value)
|
||||
return EvidenceConfig(
|
||||
minimum_report_verified=str(data.get("minimum_report_verified", "static_corroboration")),
|
||||
minimum_exploit=str(data.get("minimum_exploit", "crash_reproduced")),
|
||||
minimum_patch=str(data.get("minimum_patch", "root_cause_explained")),
|
||||
)
|
||||
|
||||
|
||||
def _int_value(value: object, default: int) -> int:
|
||||
if isinstance(value, bool):
|
||||
return default
|
||||
@@ -416,14 +385,6 @@ def _float_value(value: object, default: float) -> float:
|
||||
return default
|
||||
|
||||
|
||||
def _load_variants(value: object) -> VariantsConfig:
|
||||
data = _section(value)
|
||||
return VariantsConfig(
|
||||
enabled=bool(data.get("enabled", True)),
|
||||
max_mechanisms=_int_value(data.get("max_mechanisms"), 500),
|
||||
)
|
||||
|
||||
|
||||
def _load_model(value: object) -> ModelLayerConfig:
|
||||
data = _section(value)
|
||||
return ModelLayerConfig(
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
"""Fusion adjudication — OpenUltraCode-style two-panel deepening (Phase 13).
|
||||
"""Fusion adjudication — OpenUltraCode-style two-panel deepening (Phase 13 of the retired openrouter-sast-harness plan).
|
||||
|
||||
Fusion is the deepening mechanism for findings that need more reasoning than the
|
||||
ranker → hunter → verifier → mapping loop provides: critical/high severity, verifier
|
||||
disagreement, static-vs-semantic evidence conflict, findings that gate a risky fix or
|
||||
disclosure, or an explicit high-assurance request. Two panels independently review the
|
||||
bounded evidence (one steel-manning the vulnerability case, one the false-positive
|
||||
case), critique and revise, vote, and a decider issues the final disposition.
|
||||
case) and vote, and a decider issues the final disposition.
|
||||
|
||||
The panels and the decider are deterministic and auditable. Every fused finding receives
|
||||
exactly one of the five dispositions, and the decision discloses panel roles, votes and the
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Detection-quality gate — the hard feasibility constraint (Phase 4 task 8).
|
||||
"""Detection-quality gate — the hard feasibility constraint (Phase 4 task 8 of the retired openrouter-sast-harness plan).
|
||||
|
||||
Aggregate recall and false-positive rate over the benchmark corpus must clear the
|
||||
project goal: recall >= 90% AND false-positive rate < 10%. This gate is computed
|
||||
|
||||
@@ -181,7 +181,7 @@ def write_harness_config(
|
||||
"ranker": config.models.ranker,
|
||||
"hunter": config.models.hunter,
|
||||
"verifier": None, # schema kept byte for byte; LLM verification runs on the plane
|
||||
"patcher": config.models.patcher,
|
||||
"patcher": None, # schema kept; `[models] patcher` was retired 2026-10-02 (never read)
|
||||
},
|
||||
processor_versions={processor.spec.name: processor.spec.version for processor in processors},
|
||||
prompt_hashes={name: _hash_text(prompt) for name, prompt in sorted(prompts.items())},
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"""Narrow MCP server for OpenCode integration (Phase 12).
|
||||
"""Narrow MCP server for OpenCode integration (Phase 12 of the retired openrouter-sast-harness plan).
|
||||
|
||||
Exposes only stable, project-level operations over MCP stdio (newline-delimited
|
||||
JSON-RPC 2.0), implemented with the standard library only. No tool exposes arbitrary
|
||||
shell, Docker, or internal hunter tools — those stay inside controlled harness stages.
|
||||
shell, Docker, or internal hunter tools — those stay inside the scan's own stages.
|
||||
The pure :meth:`McpServer.handle` dispatch is separated from the :func:`serve` stdio
|
||||
loop so the surface is unit-testable without a live client.
|
||||
"""
|
||||
|
||||
@@ -6,9 +6,12 @@ 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
|
||||
load_bearing reachable by imports from a gate, the CLI, or a module run with ``python -m``
|
||||
standalone_capability reachable the same way, but its own entry point and users, not on the scan path
|
||||
orphaned not reachable from any of those -- it should not be in the tree
|
||||
|
||||
Reachability decides the class. ``SPEC_OWNED`` and ``STANDALONE`` only supply the reason text: being named
|
||||
by a spec does not keep a module nobody imports alive.
|
||||
|
||||
Pure stdlib, no imports of the package it audits: it reads the import graph with ``ast`` so a broken module
|
||||
is still classifiable.
|
||||
@@ -26,7 +29,7 @@ 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.
|
||||
# Reason text for reachable modules, by the requirement that owns them. Not a classification: see audit().
|
||||
SPEC_OWNED = {
|
||||
"push.cache": "pre-push-safety-net task 6.2: bounded artifact publication and leased eviction; runner integration in 6.4",
|
||||
"push.report": "pre-push-safety-net Req 5.1/5.2/5.5/7.3: compact output and complete artifacts; runner integration pending",
|
||||
@@ -38,16 +41,13 @@ SPEC_OWNED = {
|
||||
"push": "pre-push-safety-net Req 5.5/7.3: experimental push contract package; no hook installed",
|
||||
"push.contracts": "pre-push-safety-net Req 5.5/7.3: immutable comparisons and independent result statuses; runner pending",
|
||||
"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.endpoint": "model-grounded-detection Req 8: the bounded model client the hunter, the plane and the CLI share",
|
||||
"model.audit": "model-grounded-detection Req 3: this classifier; run by the maintainer, not by the scan",
|
||||
"model.ladder": "model-grounded-detection Req 5: the evidence ladder every finding carries",
|
||||
"model.taint": "model-grounded-detection Req 6.1: taint reachability over the CPG, the flow-family arbiter",
|
||||
"model.judge": "model-grounded-detection Req 8: one bounded question, checked against the model",
|
||||
"model.dominance": "model-grounded-detection Req 6.2: guard dominance, the arbiter for bugs that never crash",
|
||||
"model.config_value": "model-grounded-detection Req 6.3: constant abstraction for the configuration families",
|
||||
"model.calibrate": "model-grounded-detection Req 9: the corpus as the model's calibration set",
|
||||
"model.report": "model-grounded-detection Req 9.3/11.1: per-slice reporting with the overfitting gap",
|
||||
"model.execution": "model-grounded-detection Req 10: the deferred execution tier seam, adopted not built",
|
||||
"model.pipeline": "model-grounded-detection Req 8: the enumerator proposes, the LLM answers, the model disposes",
|
||||
"model.regions": "contributor-scan Req 2: regions from entry points, with the families each admits",
|
||||
"model.scan": "contributor-scan Req 3: the repository driver -- one CPG, one budget, ranked spend",
|
||||
@@ -81,14 +81,13 @@ SPEC_OWNED = {
|
||||
"plane.runner": "ai-service-plane Req 4.1-4.4: the ax-task-runner entrypoint (PID 1 of the task image); delivers the output directory",
|
||||
}
|
||||
|
||||
# Reachable modules that stand alone: their own CLI subcommand or external protocol, off the scan path.
|
||||
STANDALONE = {
|
||||
"push_scoring": "pre-push-safety-net task 1.3: frozen-profile diagnostic outcome scorer; python -m openultrasast.push_scoring",
|
||||
"push_inputs": "pre-push-safety-net task 1.2: offline input provenance validator; python -m openultrasast.push_inputs",
|
||||
"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",
|
||||
"stage_processors": "deterministic slot-contracted scan stages; pinned by the zero-dependency guard in test_gate",
|
||||
"slot_contract": "the slot/contract protocol stage_processors implements",
|
||||
}
|
||||
|
||||
|
||||
@@ -140,6 +139,10 @@ def import_graph(root: Path) -> dict[str, set[str]]:
|
||||
continue
|
||||
if target:
|
||||
imported.add(target)
|
||||
# `from .plane import engine_alerts` imports the submodule `plane.engine_alerts`, not only the
|
||||
# package; names that are not modules are dropped later because they are not in the graph.
|
||||
prefix = f"{target}." if target else ""
|
||||
imported.update(f"{prefix}{alias.name}" for alias in node.names if alias.name != "*")
|
||||
elif isinstance(node, ast.Import):
|
||||
for alias in node.names:
|
||||
if alias.name.startswith(f"{PACKAGE}."):
|
||||
@@ -148,6 +151,28 @@ def import_graph(root: Path) -> dict[str, set[str]]:
|
||||
return graph
|
||||
|
||||
|
||||
def main_modules(root: Path) -> set[str]:
|
||||
"""Modules with a ``__main__`` block: run by name (``python -m``, the plane runner), so they are entry points."""
|
||||
found: set[str] = set()
|
||||
for path in sorted(root.rglob("*.py")):
|
||||
if "__pycache__" in path.parts:
|
||||
continue
|
||||
try:
|
||||
tree = ast.parse(path.read_text())
|
||||
except (OSError, SyntaxError):
|
||||
continue
|
||||
for node in tree.body:
|
||||
if (
|
||||
isinstance(node, ast.If)
|
||||
and isinstance(node.test, ast.Compare)
|
||||
and isinstance(node.test.left, ast.Name)
|
||||
and node.test.left.id == "__name__"
|
||||
and any(isinstance(c, ast.Constant) and c.value == "__main__" for c in node.test.comparators)
|
||||
):
|
||||
found.add(module_name(path, root))
|
||||
return found
|
||||
|
||||
|
||||
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]
|
||||
@@ -173,24 +198,33 @@ def audit(root: Path) -> tuple[ModuleAudit, ...]:
|
||||
for candidate in (target, target.rsplit(".", 1)[0]):
|
||||
if candidate in importers and candidate != name:
|
||||
importers[candidate].add(name)
|
||||
live = _reachable(graph, ROOTS)
|
||||
run_by_name = main_modules(root)
|
||||
live = _reachable(graph, (*ROOTS, *sorted(run_by_name)))
|
||||
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:
|
||||
listed = SPEC_OWNED.get(name) or STANDALONE.get(name)
|
||||
# Reachability decides; the lists only supply reason text. A listed module nothing reaches is still
|
||||
# orphaned -- the list is what hid five dead modules behind "load_bearing" until 2026-10-02.
|
||||
if name not in live:
|
||||
reason = "no import path from an entry point or a python -m module"
|
||||
if who:
|
||||
reason += f" (imported only by unreachable {', '.join(who)})"
|
||||
if listed:
|
||||
reason += f"; listed as: {listed}"
|
||||
results.append(ModuleAudit(name, "orphaned", reason, who))
|
||||
elif name in STANDALONE:
|
||||
results.append(ModuleAudit(name, "standalone_capability", STANDALONE[name], who))
|
||||
elif 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))
|
||||
elif name in run_by_name:
|
||||
results.append(ModuleAudit(name, "load_bearing", f"python -m openultrasast.{name}", who))
|
||||
else:
|
||||
results.append(ModuleAudit(name, "orphaned", "no importer, no entry point, no surviving requirement", who))
|
||||
results.append(ModuleAudit(name, "load_bearing", f"reachable from {', '.join(ROOTS[:4])}", who))
|
||||
return tuple(results)
|
||||
|
||||
|
||||
@@ -211,4 +245,12 @@ def to_dict(results: Iterable[ModuleAudit]) -> dict[str, object]:
|
||||
}
|
||||
|
||||
|
||||
__all__ = ["ModuleAudit", "SPEC_OWNED", "STANDALONE", "audit", "import_graph", "module_name", "to_dict"]
|
||||
__all__ = ["ModuleAudit", "SPEC_OWNED", "STANDALONE", "audit", "import_graph", "main_modules", "module_name", "to_dict"]
|
||||
|
||||
|
||||
if __name__ == "__main__": # python -m openultrasast.model.audit [SRC] -- the manifest's regeneration command
|
||||
import json
|
||||
import sys
|
||||
|
||||
source = Path(sys.argv[1]) if len(sys.argv) > 1 else Path(__file__).resolve().parents[1]
|
||||
print(json.dumps(to_dict(audit(source)), indent=2))
|
||||
|
||||
@@ -1,180 +0,0 @@
|
||||
"""The corpus calibrates the model, not an LLM vote (model-grounded-detection, Req 9).
|
||||
|
||||
This is the inversion the whole feature rests on. In the prior architecture a pair the detector missed was a
|
||||
*detector* failure, to be averaged away over K runs and chased with a prompt change. Here the arbiter is
|
||||
deterministic: it will miss that pair the same way every time, and the only thing that moves it is better
|
||||
modelling. So a pair the model cannot split is a **named gap in the model**, recorded with its reason, and
|
||||
the report's job is to make those gaps legible by family and by slice rather than to produce a score.
|
||||
|
||||
`covered` means the model reached a strictly higher rung on the vulnerable side than on its fix. Flagging
|
||||
both sides equally is not coverage — it distinguishes nothing, which is exactly the failure the group-2
|
||||
measurement found taint reachability making on every parameterised SQL pair.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable, Mapping, Sequence
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TypedDict
|
||||
|
||||
|
||||
class FamilyGaps(TypedDict):
|
||||
"""Gaps for one family: how many pairs, and the tally of reasons behind them."""
|
||||
|
||||
pairs: int
|
||||
reasons: dict[str, int]
|
||||
|
||||
|
||||
# Ascending, matching `ladder.Rung`. A pair is split when the vulnerable side outranks its fix.
|
||||
_ORDER = {"none": 0, "": 0, "suspicion": 0, "model_corroborated": 1, "model_entailed": 2, "execution_confirmed": 3}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PairOutcome:
|
||||
"""What the model made of one vulnerable/fixed pair."""
|
||||
|
||||
pair: str
|
||||
family: str
|
||||
slice: str
|
||||
vuln_rung: str
|
||||
fixed_rung: str
|
||||
reason: str = ""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ModelGap:
|
||||
"""A pair the model could not split, and why. Not a miss to average — a gap to close."""
|
||||
|
||||
pair: str
|
||||
family: str
|
||||
slice: str
|
||||
reason: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HarvestRecipe:
|
||||
"""Where a pair comes from. The fix commit's parent is the vulnerable side, by construction."""
|
||||
|
||||
repo: str
|
||||
fix_commit: str
|
||||
relpath: str
|
||||
function: str = ""
|
||||
|
||||
@property
|
||||
def parent_ref(self) -> str:
|
||||
return f"{self.fix_commit}^"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CalibrationReport:
|
||||
covered: tuple[str, ...]
|
||||
gaps: tuple[ModelGap, ...]
|
||||
per_slice: Mapping[str, Mapping[str, int]]
|
||||
per_family: Mapping[str, Mapping[str, int]] = field(default_factory=dict)
|
||||
|
||||
def gaps_by_family(self) -> dict[str, FamilyGaps]:
|
||||
"""Gaps grouped by family, each with a tally of the reasons behind them."""
|
||||
out: dict[str, FamilyGaps] = {}
|
||||
for gap in self.gaps:
|
||||
block = out.setdefault(gap.family, {"pairs": 0, "reasons": {}})
|
||||
block["pairs"] += 1
|
||||
block["reasons"][gap.reason] = block["reasons"].get(gap.reason, 0) + 1
|
||||
return out
|
||||
|
||||
def to_markdown(self) -> str:
|
||||
"""Per-slice rows first; there is no headline here that is not backed by them (Req 9.3, 11.1)."""
|
||||
lines = [
|
||||
"## Model calibration",
|
||||
"",
|
||||
"Coverage is a pair the model *splits*: a strictly higher rung on",
|
||||
"the vulnerable side than on its fix. Everything else is a named gap.",
|
||||
"",
|
||||
"| slice | pairs | covered | gaps | coverage |",
|
||||
"|---|---|---|---|---|",
|
||||
]
|
||||
for slice_name, block in sorted(self.per_slice.items()):
|
||||
pairs = block["pairs"] or 1
|
||||
lines.append(f"| {slice_name} | {block['pairs']} | {block['covered']} | {block['gaps']} | {block['covered'] / pairs:.0%} |")
|
||||
by_family = self.gaps_by_family()
|
||||
if by_family:
|
||||
lines += ["", "### Gaps by family", ""]
|
||||
for family, gaps in sorted(by_family.items()):
|
||||
detail = ", ".join(f"{k} x{v}" for k, v in sorted(gaps["reasons"].items()))
|
||||
lines.append(f"- `{family}`: {gaps['pairs']} pair(s) — {detail}")
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def calibrate(outcomes: Iterable[PairOutcome]) -> CalibrationReport:
|
||||
"""Split the corpus into what the model covers and what it cannot, with reasons."""
|
||||
covered: list[str] = []
|
||||
gaps: list[ModelGap] = []
|
||||
per_slice: dict[str, dict[str, int]] = {}
|
||||
per_family: dict[str, dict[str, int]] = {}
|
||||
|
||||
for outcome in outcomes:
|
||||
split = _ORDER.get(outcome.vuln_rung, 0) > _ORDER.get(outcome.fixed_rung, 0)
|
||||
for key, store in ((outcome.slice, per_slice), (outcome.family, per_family)):
|
||||
block = store.setdefault(key, {"pairs": 0, "covered": 0, "gaps": 0})
|
||||
block["pairs"] += 1
|
||||
block["covered" if split else "gaps"] += 1
|
||||
if split:
|
||||
covered.append(outcome.pair)
|
||||
else:
|
||||
gaps.append(
|
||||
ModelGap(pair=outcome.pair, family=outcome.family, slice=outcome.slice, reason=outcome.reason or _default_reason(outcome))
|
||||
)
|
||||
return CalibrationReport(
|
||||
covered=tuple(sorted(covered)),
|
||||
gaps=tuple(sorted(gaps, key=lambda g: (g.family, g.pair))),
|
||||
per_slice=per_slice,
|
||||
per_family=per_family,
|
||||
)
|
||||
|
||||
|
||||
def _default_reason(outcome: PairOutcome) -> str:
|
||||
"""Every gap carries a reason, even when the caller supplied none — a bare gap teaches nothing."""
|
||||
if _ORDER.get(outcome.vuln_rung, 0) == 0:
|
||||
return "no_verdict_on_the_vulnerable_side"
|
||||
if outcome.vuln_rung == outcome.fixed_rung:
|
||||
return f"both_sides_reached_{outcome.vuln_rung}: the model distinguished nothing"
|
||||
return "fixed_side_outranked_the_vulnerable_one"
|
||||
|
||||
|
||||
def harvest_recipe(entry: Mapping[str, object]) -> HarvestRecipe:
|
||||
"""A pair from CVE history: the fix commit and its parent, with the fix diff as the oracle (Req 9.4).
|
||||
|
||||
Nothing here is hand-labelled. The recipe names where to fetch both sides; what makes the pair *true* is
|
||||
the fix diff itself, read by the model rather than annotated by us.
|
||||
"""
|
||||
return HarvestRecipe(
|
||||
repo=str(entry.get("repo", "")),
|
||||
fix_commit=str(entry.get("fix_commit", "")),
|
||||
relpath=str(entry.get("relpath", "")),
|
||||
function=str(entry.get("function", "") or ""),
|
||||
)
|
||||
|
||||
|
||||
def outcomes_from_rows(rows: Sequence[Mapping[str, object]]) -> list[PairOutcome]:
|
||||
"""Adapt the ceiling artifact's rows, so calibration reads the measurement rather than re-running it."""
|
||||
return [
|
||||
PairOutcome(
|
||||
pair=str(row.get("pair", "")),
|
||||
family=str(row.get("family", "")),
|
||||
slice=str(row.get("slice", "")),
|
||||
vuln_rung=str(row.get("vuln_rung", "none")),
|
||||
fixed_rung=str(row.get("fixed_rung", "none")),
|
||||
reason=str(row.get("miss_cause", "") or ""),
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
|
||||
|
||||
__all__ = [
|
||||
"CalibrationReport",
|
||||
"HarvestRecipe",
|
||||
"ModelGap",
|
||||
"PairOutcome",
|
||||
"calibrate",
|
||||
"harvest_recipe",
|
||||
"outcomes_from_rows",
|
||||
]
|
||||
@@ -1,118 +0,0 @@
|
||||
"""The execution tier: deferred, adopted, and deliberately narrow (model-grounded-detection, Req 10).
|
||||
|
||||
This is the rung this project does **not** build. Clearwing already runs sandbox lifecycles, container pools,
|
||||
sanitizer images, PoC replay and stability classification, and the corrective brief that started this feature
|
||||
concluded — from this project's own defect history — that reimplementing that class of machinery is exactly
|
||||
where we go wrong. So the module is a seam: it decides *eligibility*, calls an adopted tool, and records what
|
||||
happened. A test asserts structurally that it imports no container or subprocess machinery and defines no
|
||||
build/run/compile/patch function, because the failure mode to prevent is this file quietly growing a sandbox.
|
||||
|
||||
Eligibility is narrow on purpose, and both halves matter:
|
||||
|
||||
* **Only a family a static model cannot arbitrate.** Memory safety in C turns on runtime layout, so a crash
|
||||
is the right oracle. Every web/logic family has taint reachability, guard dominance or constant
|
||||
abstraction, and Req 10.3 forbids any of them depending on this tier.
|
||||
* **Only a finding the model could not decide.** The tier escalates uncertainty; it never re-checks a verdict
|
||||
the model already reached.
|
||||
|
||||
And a failure to reproduce is *not* a refutation. A sandbox that does not trigger a bug has not shown the bug
|
||||
is absent — it has shown this attempt did not trigger it. The candidate stays exactly where the model left it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from typing import Protocol
|
||||
|
||||
from .ladder import Rung
|
||||
|
||||
# Families whose bugs a static model genuinely cannot arbitrate. Everything else is served by taint
|
||||
# reachability, guard dominance or constant abstraction, and must not wait on a sandbox.
|
||||
EXECUTION_FAMILIES = frozenset({"memory"})
|
||||
|
||||
# Rungs that mean "the model could not decide". Anything above is already arbitrated.
|
||||
UNDECIDED = frozenset({"suspicion", ""})
|
||||
|
||||
CLEARWING_ENV = "OPENULTRASAST_CLEARWING"
|
||||
_OFF = frozenset({"0", "off", "false", "no"})
|
||||
_ON = frozenset({"1", "on", "true", "yes"})
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Candidate:
|
||||
"""What the execution tier is asked about: a finding the static model left undecided."""
|
||||
|
||||
finding_id: str
|
||||
family: str
|
||||
rung: str
|
||||
path: str
|
||||
line: int | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Outcome:
|
||||
"""What the tier concluded. ``rung`` is ``None`` whenever nothing was established."""
|
||||
|
||||
rung: Rung | None
|
||||
reason: str
|
||||
|
||||
|
||||
class ExecutionBackend(Protocol):
|
||||
def available(self) -> bool: ...
|
||||
|
||||
def reproduce(self, candidate: Candidate) -> bool: ...
|
||||
|
||||
|
||||
@dataclass
|
||||
class NullExecutionBackend:
|
||||
"""No adopted tool present. The normal case, and not an error."""
|
||||
|
||||
def available(self) -> bool:
|
||||
return False
|
||||
|
||||
def reproduce(self, candidate: Candidate) -> bool:
|
||||
del candidate
|
||||
return False
|
||||
|
||||
|
||||
def eligible(candidate: Candidate) -> bool:
|
||||
"""Is this candidate one the execution tier should even look at?"""
|
||||
return candidate.family in EXECUTION_FAMILIES and candidate.rung in UNDECIDED
|
||||
|
||||
|
||||
def confirm(candidate: Candidate, *, backend: ExecutionBackend | None = None) -> Outcome:
|
||||
"""Escalate one undecided candidate to the adopted execution tier, if there is one."""
|
||||
if not eligible(candidate):
|
||||
return Outcome(
|
||||
rung=None,
|
||||
reason=f"not_eligible: family {candidate.family!r} at rung {candidate.rung!r} is arbitrated statically",
|
||||
)
|
||||
engine = backend if backend is not None else resolve_execution_backend()
|
||||
if not engine.available():
|
||||
return Outcome(rung=None, reason="clearwing_unavailable: no adopted execution tier on this machine")
|
||||
if not engine.reproduce(candidate):
|
||||
# Deliberate wording: this is not a refutation. Failing to trigger a bug is not showing it is absent.
|
||||
return Outcome(rung=None, reason="not_reproduced: the attempt did not trigger it; the finding is unchanged")
|
||||
return Outcome(rung=Rung.EXECUTION_CONFIRMED, reason="reproduced in the adopted sandbox")
|
||||
|
||||
|
||||
def resolve_execution_backend() -> ExecutionBackend:
|
||||
"""The adopted tier for this machine. Absent by default; the environment flag is what the matrix drives."""
|
||||
flag = os.environ.get(CLEARWING_ENV, "").strip().lower()
|
||||
if flag in _OFF or flag not in _ON:
|
||||
return NullExecutionBackend()
|
||||
return NullExecutionBackend() # the adopted tool is wired here when a class needs it (Req 10.1)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"CLEARWING_ENV",
|
||||
"EXECUTION_FAMILIES",
|
||||
"Candidate",
|
||||
"ExecutionBackend",
|
||||
"NullExecutionBackend",
|
||||
"Outcome",
|
||||
"confirm",
|
||||
"eligible",
|
||||
"resolve_execution_backend",
|
||||
]
|
||||
@@ -1,136 +0,0 @@
|
||||
"""The LLM proposes; the model disposes (model-grounded-detection, Req 8).
|
||||
|
||||
This module is where the prior architecture's premise is inverted. There, the LLM was an oracle whose noise
|
||||
had to be averaged over K runs, floored per family, and gated by a sign test. Here it answers one bounded,
|
||||
typed question about a candidate it was *handed*, and its answer is checked against the CPG. Three outcomes,
|
||||
and they are exhaustive:
|
||||
|
||||
the model entails it report `model_entailed`, and never call the model at all
|
||||
the model contradicts it drop the claim, recording the contradiction
|
||||
the model cannot decide report `suspicion`, honestly
|
||||
|
||||
Nothing here averages, votes, retries for agreement, or maintains a budget: a deterministic arbiter removes
|
||||
the need for every one of those. The LLM is consulted only for the residual the graph cannot settle — whether
|
||||
a sanitizer on a real flow is actually sufficient — and even that answer only ever *confirms* a flow the CPG
|
||||
already found. It can never conjure one.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
from ..cpg.backend import CpgResult
|
||||
from ..redaction import redact_secrets
|
||||
from .ladder import Rung, Verdict
|
||||
from .specs import TaintSpec
|
||||
from .taint import verdict as taint_verdict
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
QUESTION = """You are judging ONE candidate a static model already found. Do not look for other issues.
|
||||
|
||||
A dataflow analysis resolved this path in {language} code:
|
||||
|
||||
source: {source}
|
||||
sink: {sink} (line {line}, function {function})
|
||||
a sanitizer appears on the path: {sanitized}
|
||||
|
||||
The graph has established that the value reaches the sink. The only open question is whether the sanitizer on
|
||||
that path actually makes it safe for the {family} weakness class.
|
||||
|
||||
Answer ONLY with JSON: {{"vulnerable": true|false, "why": "<one sentence>"}}"""
|
||||
|
||||
|
||||
def judge(
|
||||
cpg: CpgResult,
|
||||
spec: TaintSpec,
|
||||
*,
|
||||
function: str = "",
|
||||
client: Any | None = None,
|
||||
model: str = "",
|
||||
parameter_sources: bool = False,
|
||||
) -> Verdict:
|
||||
"""Arbitrate one candidate. Always returns a verdict; ``suspicion`` is a real answer, not a failure."""
|
||||
answer = taint_verdict(cpg, spec, function=function, parameter_sources=parameter_sources)
|
||||
|
||||
if answer is not None and answer.rung is Rung.ENTAILED:
|
||||
return answer # the graph decided; the model is not consulted
|
||||
|
||||
if answer is None:
|
||||
# No resolved flow. Whatever anyone claims about this site, the model saw nothing to support it.
|
||||
return Verdict(
|
||||
rung=Rung.SUSPICION,
|
||||
family=spec.family,
|
||||
contradiction="the model resolved no flow: no source reaches this sink",
|
||||
)
|
||||
|
||||
# A real flow through a sanitizer. Whether that sanitizer suffices is a semantic judgement the graph
|
||||
# cannot make, so it is the one thing the LLM is asked -- about evidence the model itself produced.
|
||||
if client is None:
|
||||
return Verdict(
|
||||
rung=Rung.SUSPICION,
|
||||
family=spec.family,
|
||||
witness=answer.witness,
|
||||
contradiction="no chat endpoint: the sanitizer's sufficiency was never judged",
|
||||
)
|
||||
|
||||
said_vulnerable = _ask(client, model, spec, answer.witness, function)
|
||||
if said_vulnerable is None:
|
||||
return Verdict(rung=Rung.SUSPICION, family=spec.family, witness=answer.witness, contradiction="the judge returned no usable answer")
|
||||
if not said_vulnerable:
|
||||
return Verdict(
|
||||
rung=Rung.SUSPICION,
|
||||
family=spec.family,
|
||||
witness=answer.witness,
|
||||
contradiction="the judge held the sanitizer on this path sufficient",
|
||||
)
|
||||
return answer # CORROBORATED: a real flow the judge agrees is not neutralised
|
||||
|
||||
|
||||
def _ask(client: Any, model: str, spec: TaintSpec, witness: str, function: str) -> bool | None:
|
||||
"""One question, one answer. No tools, no retries for agreement, no second opinion."""
|
||||
source, _, sink = witness.partition(" -> ")
|
||||
line = ""
|
||||
if "(line " in sink:
|
||||
sink, _, tail = sink.partition(" (line ")
|
||||
line = tail.split(",")[0].strip(") ")
|
||||
prompt = redact_secrets(
|
||||
QUESTION.format(
|
||||
language=spec.language,
|
||||
source=source.strip(),
|
||||
sink=sink.strip(),
|
||||
line=line or "?",
|
||||
function=function or "?",
|
||||
sanitized="yes",
|
||||
family=spec.family,
|
||||
)
|
||||
)
|
||||
try:
|
||||
response = client.complete(
|
||||
model=model,
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
tools=[], # the candidate is given; the model never searches for it
|
||||
json_object=True,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 -- an endpoint failure is a degradation, never a verdict
|
||||
logger.warning("judge call failed: %s", exc)
|
||||
return None
|
||||
return _vulnerable(getattr(response, "content", None))
|
||||
|
||||
|
||||
def _vulnerable(content: object) -> bool | None:
|
||||
if not isinstance(content, str) or not content.strip():
|
||||
return None
|
||||
try:
|
||||
payload = json.loads(content)
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
if isinstance(payload, Mapping) and isinstance(payload.get("vulnerable"), bool):
|
||||
return bool(payload["vulnerable"])
|
||||
return None
|
||||
|
||||
|
||||
__all__ = ["QUESTION", "judge"]
|
||||
@@ -4,8 +4,8 @@ Everything before this module built an *arbiter* — something that judges a flo
|
||||
found on its own. Nothing proposed candidates to it, which is why the arbiter's recall was never comparable to
|
||||
a detector's: it could only report what it happened to find unaided.
|
||||
|
||||
This is the wiring the architecture was designed around, and it turns on a distinction that `judge` alone
|
||||
conflated:
|
||||
This is the wiring the architecture was designed around, and it turns on a distinction that the first judge
|
||||
(`model/judge.py`, deleted 2026-10-02 because this module replaced it) conflated:
|
||||
|
||||
the model CONTRADICTS it has coverage at this site and says no — a sanitizer lies on the path, or the
|
||||
sink is written in its declared-safe shape. The claim is dropped (Req 8.2).
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Secret redaction for traces and reports (Phase 15 hardening).
|
||||
"""Secret redaction for traces and reports (Phase 15 hardening of the retired openrouter-sast-harness plan).
|
||||
|
||||
Scan artifacts (trace events, reports) can quote source code that contains live
|
||||
credentials. This module masks well-known secret shapes before they are persisted, so
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Trail of Bits skill index + router (Phase 14).
|
||||
"""Trail of Bits skill index + router (Phase 14 of the retired openrouter-sast-harness plan).
|
||||
|
||||
Security skills are scoped expertise, not global prompt bloat. Each skill is a small
|
||||
descriptor with routing metadata (languages, tags, vulnerability classes, stages) and
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
"""Slot-contract discipline for model-disabled deterministic stages (task 6.3).
|
||||
|
||||
The deterministic scan stages carry their work in named *slots* and run with model
|
||||
invocation disabled (``skip_model = True``). Each stage declares a read/write slot
|
||||
allow-list that is validated on every lifecycle hook, preserving the
|
||||
``ProcessorSpec.reads/writes`` provenance discipline of the sync ``HarnessRuntime``
|
||||
(the top-level driver).
|
||||
|
||||
This is the zero-dependency substrate for the deterministic pipeline.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from typing import Any, Protocol
|
||||
|
||||
from .harness import HarnessContractError
|
||||
|
||||
|
||||
class SlotContractError(HarnessContractError):
|
||||
"""Raised when a stage reads or writes a slot outside its declared allow-list."""
|
||||
|
||||
|
||||
class SlotContractMixin:
|
||||
"""A model-disabled stage that carries work in slots under a read/write allow-list."""
|
||||
|
||||
name: str = "slot-stage"
|
||||
version: str = "1"
|
||||
reads_slots: tuple[str, ...] = ()
|
||||
writes_slots: tuple[str, ...] = ()
|
||||
skip_model: bool = True # deterministic: never invokes a model
|
||||
|
||||
def run(self, slots: Mapping[str, Any]) -> dict[str, Any]:
|
||||
raise NotImplementedError
|
||||
|
||||
def validate_reads(self, slots: Mapping[str, Any]) -> None:
|
||||
missing = sorted(slot for slot in self.reads_slots if slot not in slots)
|
||||
if missing:
|
||||
raise SlotContractError(f"{self.name}: missing read slots: {', '.join(missing)}")
|
||||
|
||||
def validate_writes(self, updates: Mapping[str, Any]) -> None:
|
||||
undeclared = sorted(set(updates) - set(self.writes_slots))
|
||||
if undeclared:
|
||||
raise SlotContractError(f"{self.name}: undeclared write slots: {', '.join(undeclared)}")
|
||||
|
||||
def execute(self, slots: Mapping[str, Any]) -> dict[str, Any]:
|
||||
"""Validate the read allow-list, run the stage, validate the write allow-list."""
|
||||
self.validate_reads(slots) # pre-hook
|
||||
updates = self.run(slots) or {}
|
||||
if not isinstance(updates, dict):
|
||||
raise SlotContractError(f"{self.name}: stage returned a non-dict update")
|
||||
self.validate_writes(updates) # post-hook
|
||||
return updates
|
||||
|
||||
|
||||
class _Emitter(Protocol):
|
||||
def emit(self, event_type: Any, stage: str | None, payload: dict[str, Any]) -> None: ...
|
||||
|
||||
|
||||
class SlotPipeline:
|
||||
"""Runs slot processors in order, validating contracts and enforcing model-disable."""
|
||||
|
||||
def __init__(self, processors: list[SlotContractMixin]) -> None:
|
||||
for proc in processors:
|
||||
if not proc.skip_model:
|
||||
raise SlotContractError(f"{proc.name}: deterministic stage must be model-disabled (skip_model=True)")
|
||||
self._processors = processors
|
||||
|
||||
@property
|
||||
def processors(self) -> list[SlotContractMixin]:
|
||||
return list(self._processors)
|
||||
|
||||
def run(self, initial_slots: Mapping[str, Any], *, runtime: _Emitter | None = None) -> dict[str, Any]:
|
||||
"""Execute every stage; with ``runtime`` set, emit stage_start/stage_end lifecycle hooks."""
|
||||
slots: dict[str, Any] = dict(initial_slots)
|
||||
for proc in self._processors:
|
||||
if runtime is not None:
|
||||
runtime.emit(
|
||||
"stage_start", proc.name, {"reads": list(proc.reads_slots), "writes": list(proc.writes_slots), "skip_model": True}
|
||||
)
|
||||
updates = proc.execute(slots)
|
||||
slots.update(updates)
|
||||
if runtime is not None:
|
||||
runtime.emit("stage_end", proc.name, {"status": "succeeded", "wrote": sorted(updates)})
|
||||
return slots
|
||||
|
||||
|
||||
__all__ = ["SlotContractError", "SlotContractMixin", "SlotPipeline"]
|
||||
@@ -1,135 +0,0 @@
|
||||
"""Deterministic scan stages as model-disabled, slot-contracted processors (task 6.3).
|
||||
|
||||
Each stage is a :class:`slot_contract.SlotContractMixin` with ``skip_model = True`` and
|
||||
a declared read/write slot allow-list. Running them through a :class:`SlotPipeline`
|
||||
reproduces the deterministic quick-scan path byte-for-byte while enforcing the slot
|
||||
contract on every lifecycle hook. The hunter and verifier stages are *not* here; this
|
||||
module is the deterministic, model-free remainder that ``test_gate`` pins.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .findings import quick_scan_findings
|
||||
from .mapping import analyze_entry_points, attach_reachability_hints
|
||||
from .policy import load_policy
|
||||
from .preprocess import preprocess_repository
|
||||
from .rank import rank_targets
|
||||
from .ruleset import DEFAULT_RULESET_DIR, load_ruleset
|
||||
from .scoring import build_score_artifact
|
||||
from .slot_contract import SlotContractMixin, SlotPipeline
|
||||
|
||||
|
||||
class PreprocessProcessor(SlotContractMixin):
|
||||
name = "preprocess"
|
||||
reads_slots = ("root",)
|
||||
writes_slots = ("snapshot", "targets")
|
||||
|
||||
def run(self, slots: Mapping[str, Any]) -> dict[str, Any]:
|
||||
snapshot, targets = preprocess_repository(slots["root"])
|
||||
return {"snapshot": snapshot, "targets": targets}
|
||||
|
||||
|
||||
class EntryPointProcessor(SlotContractMixin):
|
||||
name = "entry_point"
|
||||
reads_slots = ("root", "targets")
|
||||
writes_slots = ("targets", "entry_points")
|
||||
|
||||
def run(self, slots: Mapping[str, Any]) -> dict[str, Any]:
|
||||
entry_points = analyze_entry_points(slots["root"], slots["targets"])
|
||||
targets = attach_reachability_hints(slots["targets"], entry_points)
|
||||
return {"targets": targets, "entry_points": entry_points}
|
||||
|
||||
|
||||
class RankProcessor(SlotContractMixin):
|
||||
name = "rank"
|
||||
reads_slots = ("targets",)
|
||||
writes_slots = ("rankings",)
|
||||
|
||||
def run(self, slots: Mapping[str, Any]) -> dict[str, Any]:
|
||||
return {"rankings": rank_targets(slots["targets"])}
|
||||
|
||||
|
||||
class QuickFindingsProcessor(SlotContractMixin):
|
||||
name = "quick_findings"
|
||||
reads_slots = ("root", "targets", "rankings", "ruleset", "policy")
|
||||
writes_slots = ("findings",)
|
||||
|
||||
def run(self, slots: Mapping[str, Any]) -> dict[str, Any]:
|
||||
findings = quick_scan_findings(slots["root"], slots["targets"], slots["rankings"], slots["ruleset"], slots["policy"])
|
||||
return {"findings": findings}
|
||||
|
||||
|
||||
class PolicyScoringProcessor(SlotContractMixin):
|
||||
name = "policy_scoring"
|
||||
reads_slots = ("findings",)
|
||||
writes_slots = ("reported_findings", "shadow_findings")
|
||||
|
||||
def run(self, slots: Mapping[str, Any]) -> dict[str, Any]:
|
||||
findings = slots["findings"]
|
||||
shadow = [finding for finding in findings if finding.status == "shadow"]
|
||||
reported = [finding for finding in findings if finding.status != "shadow"]
|
||||
return {"reported_findings": reported, "shadow_findings": shadow}
|
||||
|
||||
|
||||
class ScoreProcessor(SlotContractMixin):
|
||||
name = "score"
|
||||
reads_slots = ("reported_findings", "ruleset", "policy")
|
||||
writes_slots = ("score",)
|
||||
|
||||
def run(self, slots: Mapping[str, Any]) -> dict[str, Any]:
|
||||
cwe_by_rule = {rule.rule_id: rule.cwe for rule in slots["ruleset"]}
|
||||
reported = slots["reported_findings"]
|
||||
rule_cwe_by_id = {f.finding_id: cwe_by_rule.get(f.finding_id.split(":", 1)[0], "") for f in reported}
|
||||
return {"score": build_score_artifact(reported, rule_cwe_by_id, slots["policy"])}
|
||||
|
||||
|
||||
def deterministic_processors() -> list[SlotContractMixin]:
|
||||
"""The ordered deterministic stages (preprocess → entry-point → rank → findings → policy → score)."""
|
||||
return [
|
||||
PreprocessProcessor(),
|
||||
EntryPointProcessor(),
|
||||
RankProcessor(),
|
||||
QuickFindingsProcessor(),
|
||||
PolicyScoringProcessor(),
|
||||
ScoreProcessor(),
|
||||
]
|
||||
|
||||
|
||||
def build_deterministic_pipeline() -> SlotPipeline:
|
||||
return SlotPipeline(deterministic_processors())
|
||||
|
||||
|
||||
def run_quick_pipeline(
|
||||
root: Path | str,
|
||||
*,
|
||||
ruleset: Any = None,
|
||||
policy: Any = None,
|
||||
runtime: Any = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Run the deterministic quick-scan pipeline and return the final slot map.
|
||||
|
||||
Defaults to the bundled ruleset and the vendored policy, which makes the
|
||||
``reported_findings`` slot byte-identical to ``findings.quick_scan_findings`` on
|
||||
the same target.
|
||||
"""
|
||||
resolved_ruleset = ruleset if ruleset is not None else load_ruleset(DEFAULT_RULESET_DIR)
|
||||
resolved_policy = policy if policy is not None else load_policy()
|
||||
initial = {"root": Path(root), "ruleset": resolved_ruleset, "policy": resolved_policy}
|
||||
return build_deterministic_pipeline().run(initial, runtime=runtime)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"EntryPointProcessor",
|
||||
"PolicyScoringProcessor",
|
||||
"PreprocessProcessor",
|
||||
"QuickFindingsProcessor",
|
||||
"RankProcessor",
|
||||
"ScoreProcessor",
|
||||
"build_deterministic_pipeline",
|
||||
"deterministic_processors",
|
||||
"run_quick_pipeline",
|
||||
]
|
||||
Reference in New Issue
Block a user