mirror of
https://github.com/norandom/OpenUltraSAST.git
synced 2026-10-18 04:29:27 +00:00
chore(p0): retire regress, the skill router, the module audit tool and vocabulary.sc
Off every active line (PATHFINDER-2026-10-13/03-unified-proposal.md, cuts). Deleted: src/openultrasast/regress/ and the deep-mode REGRESS branch (deep now records a regress_retired degradation and equals standard; proof is the search-with-proof arm's job), the sandbox docker runner and probe that only regress used, --fail-on worth-fixing, RegressConfig, the two ledger helpers only regress called; skills.py and index.py with the hunter pool's skill routing; model/audit.py; cpg/queries/vocabulary.sc with benchmarks/push/ontology_gap.py. Docs and the scan skill describe the retirement. Net: 3,895 lines deleted, 86 added. Targeted tests: 180 passed, 28 skipped; 3,331 tests collect. Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
94e96c6885
commit
f5a904b7f2
@@ -34,7 +34,6 @@ from .calibration import (
|
||||
write_ranking_calibrations,
|
||||
)
|
||||
from .complexity import map as complexity_map
|
||||
from .complexity.ledger import persist_verdicts
|
||||
from .config import ModelLayerConfig, ObligationsConfig, ResolvedConfig, RetiredConfigError, load_config, load_dotenv
|
||||
from .findings import StaticFinding, quick_scan_findings, uncovered_languages, write_findings
|
||||
from .fusion import FusionDecision, fuse_findings
|
||||
@@ -60,7 +59,6 @@ from .preprocess import FileTarget, preprocess_repository, write_preprocess_arti
|
||||
from .profiling import start as start_profiling
|
||||
from .provenance import fingerprint
|
||||
from .rank import rank_obligations, rank_targets, write_rankings
|
||||
from .regress import TRIGGERABLE, CandidateVerdict, run_regression, write_verdicts
|
||||
from .reports import scan_exit_code, write_manifest, write_markdown_report, write_sarif_report
|
||||
from .repos import (
|
||||
DEFAULT_REPO_DIR,
|
||||
@@ -74,12 +72,10 @@ from .repos import select as select_repo
|
||||
from .repos import verify as verify_repo
|
||||
from .ruleset import DEFAULT_RULESET_DIR, load_ruleset
|
||||
from .run import ScanRun, create_scan_run
|
||||
from .sandbox import resolve_sandbox_probe, resolve_sandbox_runner
|
||||
from .scoring import build_score_artifact
|
||||
from .semantic import (
|
||||
OverlayRecord,
|
||||
adjudicate,
|
||||
filter_promoted_hotspots,
|
||||
finding_from_coverage,
|
||||
load_facts,
|
||||
write_overlay,
|
||||
@@ -224,7 +220,7 @@ def _main(argv: list[str] | None) -> int:
|
||||
scan.add_argument("path", type=Path)
|
||||
scan.add_argument("--mode", choices=("quick", "standard", "deep"), default="quick")
|
||||
scan.add_argument("--config", type=Path, default=Path("openultrasast.toml"))
|
||||
scan.add_argument("--fail-on", choices=("never", "findings", "verified", "worth-fixing"), default="never")
|
||||
scan.add_argument("--fail-on", choices=("never", "findings", "verified"), default="never")
|
||||
|
||||
benchmark = subparsers.add_parser("benchmark", help="run a benchmark manifest and write scoreboard artifacts")
|
||||
benchmark.add_argument("manifest", type=Path)
|
||||
@@ -756,52 +752,9 @@ def _run_scan(path: Path, config_path: Path, mode: str, fail_on: str) -> ScanOut
|
||||
"hotspot_count": len(built_map.hotspots),
|
||||
"heuristic_only": built_map.heuristic_only,
|
||||
}
|
||||
verdicts_path = run.root / "verdicts.json"
|
||||
wrote_verdicts = False
|
||||
verdict_records: tuple[CandidateVerdict, ...] = ()
|
||||
if Stage.REGRESS in plan.requested:
|
||||
probe = resolve_sandbox_probe()
|
||||
if not probe.available():
|
||||
plan = record_skip(plan, Stage.REGRESS, "sandbox_unavailable")
|
||||
runtime.state["degradations"].append(skip_as_degradation(Stage.REGRESS, "sandbox_unavailable"))
|
||||
else:
|
||||
sandbox = resolve_sandbox_runner()
|
||||
hotspots = built_map.hotspots if built_map is not None else ()
|
||||
hotspots = filter_promoted_hotspots(hotspots, overlay_records, findings)
|
||||
promoted = [
|
||||
finding
|
||||
for finding in findings
|
||||
if finding.finding_id in {record.proposal_id for record in overlay_records if record.disposition == "promote"}
|
||||
]
|
||||
cwe_by_rule_id = {rule.rule_id: rule.cwe for rule in ruleset}
|
||||
rule_cwe_by_finding = {finding.finding_id: cwe_by_rule_id.get(finding.finding_id.split(":", 1)[0], "") for finding in promoted}
|
||||
languages_by_path = {target.path: target.language for target in targets}
|
||||
records = runtime.run_stage(
|
||||
"regress",
|
||||
lambda: run_regression(
|
||||
hotspots,
|
||||
promoted,
|
||||
max_candidates=config.regress.max_candidates,
|
||||
policy=policy,
|
||||
rule_cwe=rule_cwe_by_finding,
|
||||
languages_by_path=languages_by_path,
|
||||
repo_root=run.target,
|
||||
sandbox=sandbox,
|
||||
sandbox_limits=config.sandbox,
|
||||
images=dict(config.regress.images),
|
||||
),
|
||||
)
|
||||
write_verdicts(records, verdicts_path)
|
||||
persist_verdicts(run.target / CALIBRATION_DIR / "complexity_ledger.json", records)
|
||||
# Req 6.4: proof rungs live on verdicts, not on StaticFinding, so obligations re-rank once the sandbox has spoken.
|
||||
proven_ids = {fid for record in records if record.verdict == TRIGGERABLE for fid in record.inventory_finding_ids}
|
||||
if proven_ids and any(finding.finding_id.startswith("obligation:") for finding in findings):
|
||||
findings = rank_obligations(findings, proven_ids=proven_ids)
|
||||
write_findings(findings, findings_path)
|
||||
verdict_records = records
|
||||
wrote_verdicts = True
|
||||
plan = record_completed(plan, Stage.REGRESS)
|
||||
worth_fixing_payload = _worth_fixing_payload(verdict_records) if wrote_verdicts else None
|
||||
runtime.state["degradations"].append(skip_as_degradation(Stage.REGRESS, "regress_retired"))
|
||||
plan = record_skip(plan, Stage.REGRESS, "regress_retired")
|
||||
# Req 5.6: what each family could see, for the report. Absent when the model layer did not run --
|
||||
# and a scan with no model layer states no limits because it made no claims.
|
||||
coverage_rows = model_payload.get("coverage") if isinstance(model_payload, dict) else None
|
||||
@@ -814,7 +767,6 @@ def _run_scan(path: Path, config_path: Path, mode: str, fail_on: str) -> ScanOut
|
||||
verifications,
|
||||
redact=config.hardening.redact_secrets,
|
||||
complexity_map=built_map,
|
||||
verdicts=verdict_records if wrote_verdicts else None,
|
||||
overlay=overlay_records if wrote_overlay else None,
|
||||
obligations=obligations_cited or None,
|
||||
obligations_summary=obligations_payload or None,
|
||||
@@ -851,7 +803,6 @@ def _run_scan(path: Path, config_path: Path, mode: str, fail_on: str) -> ScanOut
|
||||
fusion=fusion_path if fusion_decisions else None,
|
||||
complexity_map=complexity_map_path if complexity_payload is not None else None,
|
||||
overlay=overlay_path if wrote_overlay else None,
|
||||
verdicts=verdicts_path if wrote_verdicts else None,
|
||||
),
|
||||
path=manifest_path,
|
||||
score=score_artifact.to_dict(),
|
||||
@@ -859,7 +810,6 @@ def _run_scan(path: Path, config_path: Path, mode: str, fail_on: str) -> ScanOut
|
||||
fusion=[_fusion_summary(decision) for decision in fusion_decisions] or None,
|
||||
stages=stages_payload(plan),
|
||||
complexity=complexity_payload,
|
||||
worth_fixing=worth_fixing_payload,
|
||||
provenance=provenance.to_dict(),
|
||||
obligations=obligations_payload,
|
||||
model=model_payload,
|
||||
@@ -873,7 +823,7 @@ def _run_scan(path: Path, config_path: Path, mode: str, fail_on: str) -> ScanOut
|
||||
ranked_target_count=len(rankings),
|
||||
finding_count=len(findings),
|
||||
calibrations_applied=len(applied_calibrations),
|
||||
exit_code=scan_exit_code(findings, verifications, fail_on, worth_fixing_verdicts=verdict_records),
|
||||
exit_code=scan_exit_code(findings, verifications, fail_on),
|
||||
notices=tuple(
|
||||
f"language not covered: {item['language']} ({item['count']} files)"
|
||||
for item in runtime.state["degradations"]
|
||||
@@ -966,23 +916,6 @@ def _load_static_hints(sarif_paths: tuple[str, ...]) -> list[object]:
|
||||
return hints
|
||||
|
||||
|
||||
def _worth_fixing_payload(verdicts: tuple[CandidateVerdict, ...]) -> dict[str, object]:
|
||||
worth = [record for record in verdicts if record.worth_fixing]
|
||||
return {
|
||||
"count": len(worth),
|
||||
"verdicts": [
|
||||
{
|
||||
"path": record.path,
|
||||
"function_name": record.function_name,
|
||||
"verdict": record.verdict,
|
||||
"reason": record.reason,
|
||||
"worth_fixing": True,
|
||||
}
|
||||
for record in worth
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _artifact_paths(**paths: Path | None) -> dict[str, Path]:
|
||||
return {name: path for name, path in paths.items() if path is not None}
|
||||
|
||||
|
||||
@@ -6,10 +6,7 @@ from .ledger import (
|
||||
apply_overlay,
|
||||
hotspot_key,
|
||||
load_ledger,
|
||||
must_keep_as_candidate,
|
||||
persist_verdicts,
|
||||
record_verdict,
|
||||
select_forced_candidates,
|
||||
write_ledger,
|
||||
)
|
||||
from .map import ComplexityMap, Hotspot, build_complexity_map, write_complexity_map
|
||||
@@ -27,10 +24,7 @@ __all__ = [
|
||||
"collect_signals",
|
||||
"hotspot_key",
|
||||
"load_ledger",
|
||||
"must_keep_as_candidate",
|
||||
"persist_verdicts",
|
||||
"record_verdict",
|
||||
"select_forced_candidates",
|
||||
"write_complexity_map",
|
||||
"write_ledger",
|
||||
]
|
||||
|
||||
@@ -8,8 +8,6 @@ from dataclasses import asdict, dataclass, replace
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from ..findings import StaticFinding
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .map import Hotspot
|
||||
|
||||
@@ -19,8 +17,6 @@ VERDICT_DELTAS = {
|
||||
"triggerable": TRIGGERABLE_DELTA,
|
||||
"not_triggerable": NOT_TRIGGERABLE_DELTA,
|
||||
}
|
||||
FORCED_CANDIDATE_SEVERITY = 5
|
||||
FORCED_CANDIDATE_REACHABILITY = frozenset({"reachable", "inferred-file-surface"})
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -57,27 +53,6 @@ def write_ledger(path: Path, ledger: Mapping[str, LedgerEntry | Mapping[str, obj
|
||||
path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n")
|
||||
|
||||
|
||||
def persist_verdicts(path: Path, verdicts: Sequence[object]) -> dict[str, LedgerEntry]:
|
||||
"""Record triggerable / not_triggerable outcomes into the loop-owned complexity ledger."""
|
||||
ledger = load_ledger(path)
|
||||
changed = False
|
||||
for item in verdicts:
|
||||
verdict = str(getattr(item, "verdict", ""))
|
||||
if verdict not in VERDICT_DELTAS:
|
||||
continue
|
||||
raw_function = getattr(item, "function_name", None)
|
||||
ledger = record_verdict(
|
||||
ledger,
|
||||
path=str(getattr(item, "path", "")),
|
||||
function_name=raw_function if isinstance(raw_function, str) else None,
|
||||
verdict=verdict,
|
||||
)
|
||||
changed = True
|
||||
if changed:
|
||||
write_ledger(path, ledger)
|
||||
return ledger
|
||||
|
||||
|
||||
def record_verdict(
|
||||
ledger: Mapping[str, LedgerEntry | Mapping[str, object]],
|
||||
*,
|
||||
@@ -130,49 +105,6 @@ def apply_overlay(
|
||||
return tuple(sorted(adjusted, key=lambda item: (-item.score, item.path, item.function_name or "")))
|
||||
|
||||
|
||||
def must_keep_as_candidate(
|
||||
finding: StaticFinding,
|
||||
policy_severity: int | object,
|
||||
reachability: str | None = None,
|
||||
) -> bool:
|
||||
status = finding.reachability_status if reachability is None else reachability
|
||||
return _as_severity(policy_severity) >= FORCED_CANDIDATE_SEVERITY and status in FORCED_CANDIDATE_REACHABILITY
|
||||
|
||||
|
||||
def select_forced_candidates(
|
||||
findings: Sequence[StaticFinding],
|
||||
policy: Mapping[str, object],
|
||||
*,
|
||||
rule_cwe: Mapping[str, str] | None = None,
|
||||
) -> tuple[StaticFinding, ...]:
|
||||
cwe_by_id = rule_cwe or {}
|
||||
return tuple(
|
||||
finding
|
||||
for finding in findings
|
||||
if must_keep_as_candidate(finding, _policy_severity_for(finding, policy, cwe_by_id), finding.reachability_status)
|
||||
)
|
||||
|
||||
|
||||
def _policy_severity_for(finding: StaticFinding, policy: Mapping[str, object], cwe_by_id: Mapping[str, str]) -> int:
|
||||
if finding.finding_id in policy:
|
||||
return _as_severity(policy[finding.finding_id])
|
||||
cwe = cwe_by_id.get(finding.finding_id)
|
||||
if cwe is not None and cwe in policy:
|
||||
return _as_severity(policy[cwe])
|
||||
return 0
|
||||
|
||||
|
||||
def _as_severity(value: object) -> int:
|
||||
if isinstance(value, bool):
|
||||
return 0
|
||||
if isinstance(value, int):
|
||||
return value
|
||||
severity = getattr(value, "severity", None)
|
||||
if isinstance(severity, bool) or not isinstance(severity, int):
|
||||
return 0
|
||||
return severity
|
||||
|
||||
|
||||
def _coerce_entry(value: object) -> LedgerEntry | None:
|
||||
if isinstance(value, LedgerEntry):
|
||||
return value
|
||||
|
||||
@@ -25,8 +25,8 @@ _UNREAD_DYNAMIC = "no dynamic or deep tier exists; deep mode will define its own
|
||||
_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"
|
||||
_UNREAD_SANDBOX = (
|
||||
"the sandbox always runs with `--network none` and a read-only workspace (sandbox/runner.py); that is an "
|
||||
"invariant the runner enforces, not a knob"
|
||||
"the search sandbox enforces isolation and read-only source (search/_sandbox.py); that is an "
|
||||
"invariant, not a knob"
|
||||
)
|
||||
_UNREAD_TOP_K = "no stage read it; `[complexity] max_hunter_hotspots` is the one complexity key the scan reads"
|
||||
_UNREAD_EMBEDDINGS = (
|
||||
@@ -156,13 +156,6 @@ class ComplexityConfig:
|
||||
max_hunter_hotspots: int = 8
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RegressConfig:
|
||||
# Stage-3 candidate cap and optional per-language sandbox image pins. Isolation limits stay on SandboxConfig.
|
||||
max_candidates: int = 5
|
||||
images: tuple[tuple[str, str], ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ModelLayerConfig:
|
||||
# contributor-scan: the model layer in MAP. Named for the LAYER, not the model: `ModelConfig` is already
|
||||
@@ -215,7 +208,6 @@ class ResolvedConfig:
|
||||
fusion: FusionConfig = FusionConfig()
|
||||
hardening: HardeningConfig = HardeningConfig()
|
||||
complexity: ComplexityConfig = ComplexityConfig()
|
||||
regress: RegressConfig = RegressConfig()
|
||||
obligations: ObligationsConfig = ObligationsConfig()
|
||||
model: ModelLayerConfig = ModelLayerConfig()
|
||||
push: PushConfig = PushConfig()
|
||||
@@ -240,7 +232,6 @@ def load_config(config_path: Path | None = None, *, dotenv: bool = True) -> Reso
|
||||
fusion=_load_fusion(data.get("fusion", {})),
|
||||
hardening=_load_hardening(data.get("hardening", {})),
|
||||
complexity=_load_complexity(data.get("complexity", {})),
|
||||
regress=_load_regress(data.get("regress", {})),
|
||||
obligations=_load_obligations(data.get("obligations", {})),
|
||||
model=_load_model(data.get("model", {})),
|
||||
push=PushConfig.from_payload(data.get("push", {})),
|
||||
@@ -344,24 +335,6 @@ def _load_complexity(value: object) -> ComplexityConfig:
|
||||
)
|
||||
|
||||
|
||||
def _load_regress(value: object) -> RegressConfig:
|
||||
data = _section(value)
|
||||
return RegressConfig(
|
||||
max_candidates=_int_value(data.get("max_candidates"), 5),
|
||||
images=_string_map(data.get("images")),
|
||||
)
|
||||
|
||||
|
||||
def _string_map(value: object) -> tuple[tuple[str, str], ...]:
|
||||
data = _section(value)
|
||||
images: list[tuple[str, str]] = []
|
||||
for key, item in data.items():
|
||||
pinned = _string(item)
|
||||
if pinned is not None:
|
||||
images.append((str(key), pinned))
|
||||
return tuple(sorted(images))
|
||||
|
||||
|
||||
def _float_value(value: object, default: float) -> float:
|
||||
if isinstance(value, bool):
|
||||
return default
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
// What this repository calls that the ontology does not model (contributor-scan 7.1).
|
||||
//
|
||||
// Five of seven families established nothing in the 2026-09 family census, and every one of them failed for
|
||||
// an ontology reason with the flow engine ready and nothing to aim at: no HTTP client for a server-side
|
||||
// request forgery, no collection read for an object-reference defect, no WordPress spelling for a
|
||||
// deserialization. Each was found by investigating one family at a time. This answers the question directly.
|
||||
//
|
||||
// It needs no dataflow. The structural pass already walks every call, which is why a census that took three
|
||||
// investigations can be one query.
|
||||
//
|
||||
// What it reports is a CANDIDATE LIST, never an ontology. A name here becomes a fact only when someone
|
||||
// resolves it to a library API, states the weakness class and records provenance. Adding a name because a
|
||||
// subject called it is how a general-purpose tool becomes a fixture of its own benchmark.
|
||||
//
|
||||
// Parameters: cpgFile, modelled (comma-separated tokens any fact table names), sources (comma-separated
|
||||
// source patterns, used only to say whether a call sits in a method that reads untrusted input).
|
||||
// Output: fenced JSON array of {name, spelling, count, modelled, nearSource, file, line}.
|
||||
@main def exec(cpgFile: String, modelled: String = "", sources: String = "") = {
|
||||
importCpg(cpgFile)
|
||||
|
||||
def split(raw: String): List[String] = raw.split(",").map(_.trim).filter(_.nonEmpty).toList
|
||||
|
||||
val modelledTokens = split(modelled)
|
||||
val sourcePatterns = split(sources)
|
||||
|
||||
def boundedPattern(t: String) =
|
||||
java.util.regex.Pattern.compile("(?<![A-Za-z0-9_$])" + java.util.regex.Pattern.quote(t) + "(?![A-Za-z0-9_$])")
|
||||
|
||||
val patterns = modelledTokens.map(t => (t, boundedPattern(t)))
|
||||
|
||||
// The callee, which is the only part of a call that names what is being called. Matching a whole node's
|
||||
// text is the defect this project has now fixed six times.
|
||||
def calleeText(code: String): String = {
|
||||
val open = code.indexOf('(')
|
||||
val head = if (open < 0) code else code.substring(0, open)
|
||||
head.trim.takeRight(120)
|
||||
}
|
||||
|
||||
// Names the FRONTEND synthesised are not names the code calls. jssrc2cpg lowers a destructuring to
|
||||
// `_tmp_3.push` and an array literal to `__ecma.Array.factory`, and on the first measured subject those
|
||||
// were the two most frequent candidates in the list -- noise at the top of a worklist is how a worklist
|
||||
// stops being read.
|
||||
def synthesised(name: String): Boolean =
|
||||
name.startsWith("<") || name.startsWith("__ecma") || name.matches("^_tmp_?[0-9]*\\..*") || name.startsWith("_tmp")
|
||||
|
||||
val calls = cpg.call.filterNot(c => c.name.startsWith("<operator") || synthesised(c.name)).l
|
||||
|
||||
// Memoised per method: does this method read something the ontology calls untrusted input? A gap that sits
|
||||
// beside a source is worth more attention than one that does not, and that is a ranking hint rather than a
|
||||
// claim about any flow.
|
||||
val sourceMemo = scala.collection.mutable.Map.empty[String, Boolean]
|
||||
def nearSource(c: io.shiftleft.codepropertygraph.generated.nodes.Call): Boolean =
|
||||
if (sourcePatterns.isEmpty) false
|
||||
// CPS-IT/mailqueue Extension::KEY has a null .method; it cannot be near a method source.
|
||||
else Option(c.method).exists { m =>
|
||||
sourceMemo.getOrElseUpdate(
|
||||
m.fullName,
|
||||
m.ast.isCall.exists(x => sourcePatterns.exists(p => boundedPattern(p).matcher(x.code).find()))
|
||||
)
|
||||
}
|
||||
|
||||
def isModelled(c: io.shiftleft.codepropertygraph.generated.nodes.Call): Boolean = {
|
||||
val callee = calleeText(c.code)
|
||||
patterns.exists { case (token, pattern) => c.name == token || pattern.matcher(callee).find() }
|
||||
}
|
||||
|
||||
case class Row(name: String, spelling: String, modelledHere: Boolean, near: Boolean, file: String, line: Int)
|
||||
|
||||
val rows = calls.map { c =>
|
||||
Row(
|
||||
c.name,
|
||||
calleeText(c.code),
|
||||
isModelled(c),
|
||||
nearSource(c),
|
||||
c.file.name.l.headOption.getOrElse(""),
|
||||
c.lineNumber.getOrElse(-1)
|
||||
)
|
||||
}
|
||||
|
||||
val grouped = rows.groupBy(_.name).toList.map { case (name, items) =>
|
||||
val first = items.head
|
||||
ujson.Obj(
|
||||
"name" -> name,
|
||||
// The commonest qualified spelling, because `get` and `needle.get` are different leads.
|
||||
"spelling" -> items.groupBy(_.spelling).toList.maxBy(_._2.size)._1,
|
||||
"count" -> items.size,
|
||||
"modelled" -> items.exists(_.modelledHere),
|
||||
"nearSource" -> items.count(_.near),
|
||||
"file" -> first.file,
|
||||
"spellings" -> items.map(_.spelling).distinct.size,
|
||||
"line" -> first.line
|
||||
)
|
||||
}
|
||||
|
||||
// Unmodelled first and by frequency, because that ordering IS the worklist.
|
||||
val ordered = grouped.sortBy(o => (o("modelled").bool, -o("count").num.toInt))
|
||||
|
||||
println("---OUSAST-CPG-BEGIN---")
|
||||
println(ujson.write(ujson.Arr(ordered: _*)))
|
||||
println("---OUSAST-CPG-END---")
|
||||
}
|
||||
@@ -21,7 +21,6 @@ class HunterBudget:
|
||||
max_targets: int
|
||||
max_findings_per_target: int
|
||||
retrieval_budget_chars: int
|
||||
skill_budget_chars: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -30,7 +29,6 @@ class HunterTask:
|
||||
ranking: RankingScore
|
||||
tier: HunterTier
|
||||
budget: HunterBudget
|
||||
selected_skills: list[str]
|
||||
retrieval_context: str
|
||||
|
||||
|
||||
@@ -40,7 +38,6 @@ class HunterTrajectory:
|
||||
target_path: str
|
||||
tier: HunterTier
|
||||
priority: float
|
||||
selected_skills: list[str]
|
||||
retrieval_context_chars: int
|
||||
findings: list[str]
|
||||
status: str
|
||||
@@ -53,9 +50,9 @@ class HunterPoolResult:
|
||||
|
||||
|
||||
DEFAULT_BUDGETS = {
|
||||
"A": HunterBudget(tier="A", max_targets=20, max_findings_per_target=20, retrieval_budget_chars=6000, skill_budget_chars=2500),
|
||||
"B": HunterBudget(tier="B", max_targets=40, max_findings_per_target=10, retrieval_budget_chars=4000, skill_budget_chars=1500),
|
||||
"C": HunterBudget(tier="C", max_targets=80, max_findings_per_target=5, retrieval_budget_chars=2000, skill_budget_chars=800),
|
||||
"A": HunterBudget(tier="A", max_targets=20, max_findings_per_target=20, retrieval_budget_chars=6000),
|
||||
"B": HunterBudget(tier="B", max_targets=40, max_findings_per_target=10, retrieval_budget_chars=4000),
|
||||
"C": HunterBudget(tier="C", max_targets=80, max_findings_per_target=5, retrieval_budget_chars=2000),
|
||||
}
|
||||
|
||||
|
||||
@@ -93,7 +90,6 @@ def schedule_hunter_tasks(
|
||||
ranking=ranking,
|
||||
tier=tier,
|
||||
budget=budget,
|
||||
selected_skills=select_skill_snippets(target),
|
||||
retrieval_context=retrieval_context_by_path.get(target.path, "")[: budget.retrieval_budget_chars],
|
||||
)
|
||||
)
|
||||
@@ -134,13 +130,6 @@ def build_retrieval_context_by_path(root: Path, targets: list[FileTarget], *, ma
|
||||
return contexts
|
||||
|
||||
|
||||
def select_skill_snippets(target: FileTarget, *, max_snippets: int = 4) -> list[str]:
|
||||
"""Route the target to its most relevant Trail of Bits skills (ids), via the skill index."""
|
||||
from .skills import route_skill_ids
|
||||
|
||||
return route_skill_ids(target, max_snippets=max_snippets)
|
||||
|
||||
|
||||
def _run_hunter_task(
|
||||
root: Path,
|
||||
scan_id: str,
|
||||
@@ -154,7 +143,6 @@ def _run_hunter_task(
|
||||
target_path=task.target.path,
|
||||
tier=task.tier,
|
||||
priority=task.ranking.priority,
|
||||
selected_skills=task.selected_skills,
|
||||
retrieval_context_chars=len(task.retrieval_context),
|
||||
findings=[finding.finding_id for finding in findings],
|
||||
status="completed",
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
"""Text chunking for the skill router (`skills.py`).
|
||||
|
||||
The vector index that once lived beside the chunker (`VectorIndex`, `build_vector_index`, `query_vector_index`,
|
||||
`build_retrieval_package`) and the `ousast index` subcommand that fed it were removed on 2026-10-02: nothing ever
|
||||
read the `chunks.json` they wrote. The decision engine's embeddings live in `learn/embeddings.py`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from dataclasses import dataclass
|
||||
|
||||
VALID_NAMESPACES = {"repo_code", "docs", "static_findings", "mechanisms", "skills", "traces"}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CodeChunk:
|
||||
chunk_id: str
|
||||
namespace: str
|
||||
path: str
|
||||
language: str
|
||||
start_line: int
|
||||
end_line: int
|
||||
text: str
|
||||
metadata: dict[str, str | int | bool]
|
||||
|
||||
|
||||
def chunk_text_namespace(
|
||||
*,
|
||||
namespace: str,
|
||||
path: str,
|
||||
text: str,
|
||||
metadata: dict[str, str | int | bool] | None = None,
|
||||
max_lines: int = 80,
|
||||
) -> list[CodeChunk]:
|
||||
if namespace not in VALID_NAMESPACES:
|
||||
raise ValueError(f"unsupported namespace: {namespace}")
|
||||
if max_lines < 1:
|
||||
raise ValueError("max_lines must be at least 1")
|
||||
lines = text.splitlines()
|
||||
chunks: list[CodeChunk] = []
|
||||
for start in range(0, len(lines), max_lines):
|
||||
chunk_lines = lines[start : start + max_lines]
|
||||
if not any(line.strip() for line in chunk_lines):
|
||||
continue
|
||||
start_line = start + 1
|
||||
end_line = start + len(chunk_lines)
|
||||
chunk_text = "\n".join(chunk_lines)
|
||||
chunk_metadata: dict[str, str | int | bool] = {
|
||||
"path": path,
|
||||
"namespace": namespace,
|
||||
"start_line": start_line,
|
||||
"end_line": end_line,
|
||||
}
|
||||
if metadata:
|
||||
chunk_metadata.update({key: value for key, value in metadata.items() if isinstance(value, str | int | bool)})
|
||||
chunks.append(
|
||||
CodeChunk(
|
||||
chunk_id=_chunk_id(f"{namespace}:{path}", start_line, end_line, chunk_text),
|
||||
namespace=namespace,
|
||||
path=path,
|
||||
language=str(chunk_metadata.get("language", "text")),
|
||||
start_line=start_line,
|
||||
end_line=end_line,
|
||||
text=chunk_text,
|
||||
metadata=chunk_metadata,
|
||||
)
|
||||
)
|
||||
return chunks
|
||||
|
||||
|
||||
def _chunk_id(path: str, start_line: int, end_line: int, text: str) -> str:
|
||||
digest = hashlib.sha256(f"{path}:{start_line}:{end_line}:{text}".encode()).hexdigest()[:16]
|
||||
return f"{path}:{start_line}-{end_line}:{digest}"
|
||||
@@ -1,260 +0,0 @@
|
||||
"""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 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.
|
||||
"""
|
||||
|
||||
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__")
|
||||
|
||||
# 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",
|
||||
"push.policy": "pre-push-safety-net Req 2.4/3.1/3.4/4.1: targeted same-semantics base comparison; replay integration pending",
|
||||
"push.snapshot": "pre-push-safety-net Req 1.1/1.3/1.4/7.1: local immutable ref resolution; runner integration pending",
|
||||
"contracts": "pre-push-safety-net Req 7.3: strict shared manifest serialization, independent of push",
|
||||
"model.contracts": "pre-push-safety-net Req 4.1/7.3: generic deadline, change and exact scope contracts; integration pending",
|
||||
"cpg.artifact": "pre-push-safety-net Req 4.3/7.3: backend graph identity and live lease contract; validation integration pending",
|
||||
"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 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.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.report": "model-grounded-detection Req 9.3/11.1: per-slice reporting with the overfitting gap",
|
||||
"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",
|
||||
"cpg": "model-grounded-detection Req 4: the CPG seam package",
|
||||
"cpg.backend": "model-grounded-detection Req 4.1/4.5: the single Joern subprocess boundary",
|
||||
"cpg.capability": "model-grounded-detection Req 4.2/4.3: the engine probe that degrades to suspicion",
|
||||
"plane": "ai-service-plane Req 1-4: the service plane package; runner and reconciler integration in tasks 3-4",
|
||||
"plane.budget": "ai-service-plane Req 2.3/2.4: metered chat client with usd and calls ceilings; task integration in tasks 3, 6",
|
||||
"plane.manifests": "ai-service-plane Req 1.1-1.4: ax-shaped Task/Workspace/Model and the Run kind, validated before anything runs",
|
||||
"plane.tasks": "ai-service-plane Req 4.1: the task entrypoint package the runner imports by name; runner integration pending (task 3)",
|
||||
"plane.tasks.repo_facts": "ai-service-plane Req 5.1/5.2: source-only functions and callers per file; runner integration pending",
|
||||
"plane.tasks.verify": "ai-service-plane Req 5.3/2.1-2.3/7.2: the batched hunt with known callers, metered and resumable per file hunt",
|
||||
"plane.tasks.agree": "ai-service-plane Req 5.3/2.1: agreement across two verify passes with per-candidate cost, turns and site match",
|
||||
"plane.reconciler": "ai-service-plane Req 3.1-3.5, 7.3: ax-backed Run execution, status attribution and doctor; `ousast plane`",
|
||||
"plane.generate": "ai-service-plane task 7: an increment's Workspaces, per-case Tasks and Run from a recorded set",
|
||||
"plane.memory": "harnessx-removal Req 6.1/6.4: the memory store (file, S3), ingest and fact reuse; `ousast plane remember`",
|
||||
"plane.tasks.remember": "harnessx-removal Req 6.1: a case's delivered artifacts as memory rows, model-free",
|
||||
"plane.tasks.alerts": "harnessx-removal Req 6.3: quick-mode alerts on a case's vulnerable and fixed pins, model-free",
|
||||
"plane.engine_alerts": "harnessx-removal Req 6.3: engine alerts where quick mode has no rules (PHP), on the host",
|
||||
"plane.tasks.loop": "harnessx-removal Req 6.3: the improvement loop's snapshot, measure, propose and improve steps",
|
||||
"plane.tasks.features": "learned-decision-engine Req 1.1-1.3/7.2: one allow-listed feature record per candidate, model-free",
|
||||
"plane.tasks.roles": (
|
||||
"learned-decision-engine Req 8.1: model role classification per file chunk (model_sinks.py moved in); "
|
||||
"its Run template comes with the harvest (task 5)"
|
||||
),
|
||||
"learn.program": (
|
||||
"learned-decision-engine Req 3.1/3.3: the AI classifier program (signature, Retrieve -> Classify(k), response "
|
||||
"cache); `ousast learn compile` (task 6.5) and the decide task (task 8) are its callers"
|
||||
),
|
||||
"plane.workspaces": "ai-service-plane task 4: Workspace manifests per case pin of a population; `ousast plane workspaces`",
|
||||
"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 = {
|
||||
"search.demo_replay": "benchmarks standalone: no-model stored demo replay; python -m benchmarks.search.demo_replay",
|
||||
"search.coordinator": (
|
||||
"search-with-proof Req 1/2/4: injected search executor and offline board checkpoint inspector; agent worker pending"
|
||||
),
|
||||
"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",
|
||||
}
|
||||
|
||||
|
||||
@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)
|
||||
# `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}."):
|
||||
imported.add(alias.name[len(PACKAGE) + 1 :])
|
||||
graph[name] = imported
|
||||
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]
|
||||
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)
|
||||
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]))
|
||||
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 run_by_name:
|
||||
results.append(ModuleAudit(name, "load_bearing", f"python -m openultrasast.{name}", who))
|
||||
else:
|
||||
results.append(ModuleAudit(name, "load_bearing", f"reachable from {', '.join(ROOTS[:4])}", 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", "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,36 +0,0 @@
|
||||
"""Isolated regression snippets and verdicts."""
|
||||
|
||||
from .candidate import CandidateVerdict, RegressionRunner, run_regression, select_candidates, write_verdicts
|
||||
from .recipes import recipe_for
|
||||
from .safety import UnsafeSnippetError, check_snippet_safety
|
||||
from .verdict import (
|
||||
ALREADY_COVERED,
|
||||
INCONCLUSIVE,
|
||||
MISSING_RECIPE,
|
||||
NOT_TRIGGERABLE,
|
||||
SAFETY_REJECTED,
|
||||
TRIGGERABLE,
|
||||
RegressionVerdict,
|
||||
is_worth_fixing,
|
||||
verdict_from_result,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"ALREADY_COVERED",
|
||||
"INCONCLUSIVE",
|
||||
"MISSING_RECIPE",
|
||||
"NOT_TRIGGERABLE",
|
||||
"SAFETY_REJECTED",
|
||||
"TRIGGERABLE",
|
||||
"CandidateVerdict",
|
||||
"RegressionRunner",
|
||||
"RegressionVerdict",
|
||||
"UnsafeSnippetError",
|
||||
"check_snippet_safety",
|
||||
"is_worth_fixing",
|
||||
"recipe_for",
|
||||
"run_regression",
|
||||
"select_candidates",
|
||||
"verdict_from_result",
|
||||
"write_verdicts",
|
||||
]
|
||||
@@ -1,251 +0,0 @@
|
||||
"""Candidate selection and safety-gated regression execution."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import Callable, Mapping, Sequence
|
||||
from dataclasses import asdict, dataclass, replace
|
||||
from pathlib import Path
|
||||
|
||||
from ..complexity.ledger import hotspot_key, select_forced_candidates
|
||||
from ..complexity.map import Hotspot
|
||||
from ..config import SandboxConfig
|
||||
from ..findings import StaticFinding
|
||||
from ..preprocess import LANGUAGE_BY_EXTENSION
|
||||
from ..sandbox import SandboxJob, SandboxResult, SandboxRunner
|
||||
from ..sandbox.runner import WORKSPACE_MOUNT
|
||||
from .recipes import recipe_for
|
||||
from .safety import UnsafeSnippetError, check_snippet_safety
|
||||
from .verdict import (
|
||||
INCONCLUSIVE,
|
||||
MISSING_RECIPE,
|
||||
NOT_TRIGGERABLE,
|
||||
SAFETY_REJECTED,
|
||||
TRIGGERABLE,
|
||||
RegressionVerdict,
|
||||
is_worth_fixing,
|
||||
verdict_from_result,
|
||||
)
|
||||
|
||||
DEFAULT_IMAGES = {
|
||||
"python": "python:3.12-alpine",
|
||||
"javascript": "node:22-alpine",
|
||||
"c": "gcc:13",
|
||||
}
|
||||
|
||||
__all__ = [
|
||||
"INCONCLUSIVE",
|
||||
"MISSING_RECIPE",
|
||||
"NOT_TRIGGERABLE",
|
||||
"SAFETY_REJECTED",
|
||||
"TRIGGERABLE",
|
||||
"CandidateVerdict",
|
||||
"RegressionRunner",
|
||||
"RegressionVerdict",
|
||||
"run_regression",
|
||||
"select_candidates",
|
||||
"snippet_for",
|
||||
"write_verdicts",
|
||||
]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CandidateVerdict:
|
||||
path: str
|
||||
function_name: str | None
|
||||
language: str
|
||||
verdict: str
|
||||
reason: str
|
||||
inventory_finding_ids: tuple[str, ...]
|
||||
worth_fixing: bool = False
|
||||
|
||||
|
||||
def select_candidates(
|
||||
hotspots: Sequence[Hotspot],
|
||||
findings: Sequence[StaticFinding],
|
||||
*,
|
||||
max_candidates: int,
|
||||
policy_severity_by_id: Mapping[str, object] | None = None,
|
||||
rule_cwe: Mapping[str, str] | None = None,
|
||||
) -> tuple[Hotspot, ...]:
|
||||
"""Select min(max_candidates, ranked hotspots) ∪ sev-5 reachable inventory.
|
||||
|
||||
The cap applies to the ranked hotspot slice. Forced policy-severity-5
|
||||
reachable (or inferred-file-surface) inventory stays in the set even when a
|
||||
ledger overlay demoted its hotspot out of that slice.
|
||||
"""
|
||||
ranked = tuple(sorted(hotspots, key=lambda item: (-item.score, item.path, item.function_name or "")))
|
||||
selected = list(ranked[: max(max_candidates, 0)])
|
||||
seen = {hotspot_key(item.path, item.function_name) for item in selected}
|
||||
extras: list[Hotspot] = []
|
||||
for finding in select_forced_candidates(findings, policy_severity_by_id or {}, rule_cwe=rule_cwe):
|
||||
hotspot = _hotspot_for_finding(finding, ranked) or _hotspot_from_forced_finding(finding)
|
||||
key = hotspot_key(hotspot.path, hotspot.function_name)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
extras.append(hotspot)
|
||||
return tuple(selected + extras)
|
||||
|
||||
|
||||
def _hotspot_for_finding(finding: StaticFinding, hotspots: Sequence[Hotspot]) -> Hotspot | None:
|
||||
for hotspot in hotspots:
|
||||
if finding.finding_id in hotspot.inventory_finding_ids:
|
||||
return hotspot
|
||||
key = hotspot_key(finding.path, finding.function_name)
|
||||
for hotspot in hotspots:
|
||||
if hotspot_key(hotspot.path, hotspot.function_name) == key:
|
||||
return hotspot
|
||||
return None
|
||||
|
||||
|
||||
def hotspot_from_finding(finding: StaticFinding, *, rationale: str) -> Hotspot:
|
||||
"""A zero-score hotspot wrapping one finding: forced sev-5 inventory or a variant of a known mechanism."""
|
||||
return Hotspot(
|
||||
path=finding.path,
|
||||
function_name=finding.function_name,
|
||||
score=0.0,
|
||||
band="low",
|
||||
signals={},
|
||||
rationale=rationale,
|
||||
test_hint=None,
|
||||
inventory_finding_ids=(finding.finding_id,),
|
||||
)
|
||||
|
||||
|
||||
def _hotspot_from_forced_finding(finding: StaticFinding) -> Hotspot:
|
||||
return hotspot_from_finding(finding, rationale="forced reachable severity-5 inventory")
|
||||
|
||||
|
||||
class RegressionRunner:
|
||||
"""Run a snippet in the sandbox only after the structural safety check passes."""
|
||||
|
||||
def __init__(self, sandbox: SandboxRunner) -> None:
|
||||
self._sandbox = sandbox
|
||||
|
||||
def run_recipe(
|
||||
self,
|
||||
language: str,
|
||||
snippet: str,
|
||||
image: str,
|
||||
sandbox_limits: SandboxConfig,
|
||||
*,
|
||||
repo_root: Path,
|
||||
oracle: Callable[[SandboxResult], bool] | None = None,
|
||||
) -> RegressionVerdict:
|
||||
job = recipe_for(language, snippet, image, sandbox_limits, repo_root=repo_root)
|
||||
if job is None:
|
||||
return verdict_from_result(recipe_missing=True)
|
||||
return self.run_snippet(snippet, job, oracle=oracle)
|
||||
|
||||
def run_snippet(
|
||||
self, snippet: str, job: SandboxJob | None, *, oracle: Callable[[SandboxResult], bool] | None = None
|
||||
) -> RegressionVerdict:
|
||||
if job is None:
|
||||
return verdict_from_result(recipe_missing=True)
|
||||
try:
|
||||
check_snippet_safety(snippet)
|
||||
except UnsafeSnippetError:
|
||||
return verdict_from_result(safety_rejected=True)
|
||||
return verdict_from_result(self._sandbox.run(job), oracle=oracle)
|
||||
|
||||
|
||||
def snippet_for(language: str, path: str, function_name: str | None) -> str:
|
||||
"""Render a scratch snippet that loads the candidate source inside the sandbox."""
|
||||
relative = path.replace("\\", "/")
|
||||
workspace_path = f"{WORKSPACE_MOUNT}/{relative}"
|
||||
identity = function_name or relative
|
||||
if language == "python":
|
||||
return (
|
||||
f"# candidate {identity}\n"
|
||||
"from pathlib import Path\n"
|
||||
f"source = Path({workspace_path!r}).read_text()\n"
|
||||
f"compile(source, {relative!r}, 'exec')\n"
|
||||
)
|
||||
if language == "javascript":
|
||||
return (
|
||||
f"// candidate {identity}\n"
|
||||
"const fs = require('fs');\n"
|
||||
f"const source = fs.readFileSync({workspace_path!r}, 'utf8');\n"
|
||||
"if (!source.length) { process.exit(1); }\n"
|
||||
)
|
||||
if language == "c":
|
||||
return f"/* candidate {identity} */\nint main(void) {{ return 0; }}\n"
|
||||
return ""
|
||||
|
||||
|
||||
def run_regression(
|
||||
hotspots: Sequence[Hotspot],
|
||||
findings: Sequence[StaticFinding],
|
||||
*,
|
||||
max_candidates: int,
|
||||
policy: Mapping[str, object],
|
||||
rule_cwe: Mapping[str, str],
|
||||
languages_by_path: Mapping[str, str],
|
||||
repo_root: Path,
|
||||
sandbox: SandboxRunner,
|
||||
sandbox_limits: SandboxConfig,
|
||||
images: Mapping[str, str],
|
||||
) -> tuple[CandidateVerdict, ...]:
|
||||
"""Select candidates under the cap, run recipes in the sandbox, and return verdicts."""
|
||||
selected = select_candidates(
|
||||
hotspots,
|
||||
findings,
|
||||
max_candidates=max_candidates,
|
||||
policy_severity_by_id=policy,
|
||||
rule_cwe=rule_cwe,
|
||||
)
|
||||
runner = RegressionRunner(sandbox)
|
||||
findings_by_id = {finding.finding_id: finding for finding in findings}
|
||||
records: list[CandidateVerdict] = []
|
||||
for hotspot in selected:
|
||||
language = _language_for(hotspot.path, languages_by_path)
|
||||
snippet = _proposed_snippet(hotspot, findings) or snippet_for(language, hotspot.path, hotspot.function_name)
|
||||
image = images.get(language) or DEFAULT_IMAGES.get(language, "ousast-missing-image")
|
||||
mapped = runner.run_recipe(language, snippet, image, sandbox_limits, repo_root=repo_root)
|
||||
reachability = _reachability_for(hotspot, findings_by_id)
|
||||
mapped = replace(mapped, worth_fixing=is_worth_fixing(mapped.verdict, reachability))
|
||||
records.append(
|
||||
CandidateVerdict(
|
||||
path=hotspot.path,
|
||||
function_name=hotspot.function_name,
|
||||
language=language,
|
||||
verdict=mapped.verdict,
|
||||
reason=mapped.reason,
|
||||
inventory_finding_ids=hotspot.inventory_finding_ids,
|
||||
worth_fixing=mapped.worth_fixing,
|
||||
)
|
||||
)
|
||||
return tuple(records)
|
||||
|
||||
|
||||
def write_verdicts(verdicts: Sequence[CandidateVerdict], path: Path) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
payload = {"verdicts": [asdict(item) for item in verdicts]}
|
||||
path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n")
|
||||
|
||||
|
||||
def _proposed_snippet(hotspot: Hotspot, findings: Sequence[StaticFinding]) -> str | None:
|
||||
for finding in findings:
|
||||
snippet = finding.proposed_snippet
|
||||
if not snippet:
|
||||
continue
|
||||
if finding.path == hotspot.path or finding.finding_id in hotspot.inventory_finding_ids:
|
||||
return snippet
|
||||
return None
|
||||
|
||||
|
||||
def _language_for(path: str, languages_by_path: Mapping[str, str]) -> str:
|
||||
if path in languages_by_path:
|
||||
return languages_by_path[path]
|
||||
return LANGUAGE_BY_EXTENSION.get(Path(path).suffix.lower(), "unknown")
|
||||
|
||||
|
||||
def _reachability_for(hotspot: Hotspot, findings_by_id: Mapping[str, StaticFinding]) -> str:
|
||||
statuses = [
|
||||
findings_by_id[finding_id].reachability_status for finding_id in hotspot.inventory_finding_ids if finding_id in findings_by_id
|
||||
]
|
||||
for status in statuses:
|
||||
if is_worth_fixing(TRIGGERABLE, status):
|
||||
return status
|
||||
return statuses[0] if statuses else "unknown"
|
||||
@@ -1,71 +0,0 @@
|
||||
"""Language command recipes for isolated regression snippets."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from ..config import SandboxConfig
|
||||
from ..sandbox.runner import SCRATCH_MOUNT, WORKSPACE_MOUNT, SandboxJob
|
||||
|
||||
_PYTHON_FILE = "case.py"
|
||||
_JS_FILE = "case.js"
|
||||
_C_FILE = "case.c"
|
||||
_C_BIN = "case"
|
||||
|
||||
|
||||
def recipe_for(
|
||||
language: str,
|
||||
snippet: str,
|
||||
image: str,
|
||||
sandbox_limits: SandboxConfig,
|
||||
*,
|
||||
repo_root: Path,
|
||||
) -> SandboxJob | None:
|
||||
"""Return a sandbox job for `language`, or None when no recipe exists."""
|
||||
key = language.strip().lower()
|
||||
if key == "python":
|
||||
return _job(
|
||||
image,
|
||||
sandbox_limits,
|
||||
repo_root,
|
||||
command=("env", f"PYTHONPATH={WORKSPACE_MOUNT}", "python", f"{SCRATCH_MOUNT}/{_PYTHON_FILE}"),
|
||||
scratch_files={_PYTHON_FILE: snippet},
|
||||
)
|
||||
if key == "javascript":
|
||||
return _job(
|
||||
image,
|
||||
sandbox_limits,
|
||||
repo_root,
|
||||
command=("env", f"NODE_PATH={WORKSPACE_MOUNT}", "node", f"{SCRATCH_MOUNT}/{_JS_FILE}"),
|
||||
scratch_files={_JS_FILE: snippet},
|
||||
)
|
||||
if key == "c":
|
||||
source = f"{SCRATCH_MOUNT}/{_C_FILE}"
|
||||
binary = f"{SCRATCH_MOUNT}/{_C_BIN}"
|
||||
return _job(
|
||||
image,
|
||||
sandbox_limits,
|
||||
repo_root,
|
||||
command=("sh", "-c", f"cc {source} -o {binary} && {binary}"),
|
||||
scratch_files={_C_FILE: snippet},
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _job(
|
||||
image: str,
|
||||
sandbox_limits: SandboxConfig,
|
||||
repo_root: Path,
|
||||
*,
|
||||
command: tuple[str, ...],
|
||||
scratch_files: dict[str, str],
|
||||
) -> SandboxJob:
|
||||
return SandboxJob(
|
||||
image=image,
|
||||
command=command,
|
||||
repo_root=repo_root,
|
||||
scratch_files=scratch_files,
|
||||
timeout_seconds=sandbox_limits.timeout_seconds,
|
||||
memory_mb=sandbox_limits.memory_mb,
|
||||
pids_limit=sandbox_limits.pids_limit,
|
||||
)
|
||||
@@ -1,81 +0,0 @@
|
||||
"""Structural deny-list for untrusted regression snippets.
|
||||
|
||||
Snippets are data for `/scratch` inside the sandbox. Mentions of sockets, curl,
|
||||
host networking, or writes under the source mount (`/workspace`) are rejected
|
||||
before any `SandboxRunner.run` call.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from ..sandbox.runner import WORKSPACE_MOUNT
|
||||
|
||||
_WORKSPACE = re.escape(WORKSPACE_MOUNT)
|
||||
|
||||
|
||||
class UnsafeSnippetError(ValueError):
|
||||
"""Raised when a snippet fails the structural sandbox safety check."""
|
||||
|
||||
|
||||
_HOST_NETWORK = re.compile(
|
||||
r"--net(?:work)?(?:\s*=\s*|\s+|['\"]\s*,\s*['\"])host\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_SOCKET_WORD = re.compile(r"\bsocket\b", re.IGNORECASE)
|
||||
_CURL_WORD = re.compile(r"\bcurl\b", re.IGNORECASE)
|
||||
_OPEN_WORKSPACE_WRITE = re.compile(
|
||||
rf"open\s*\(\s*[fFrRbBuU]*['\"]{_WORKSPACE}[^'\"]*['\"]\s*,\s*(?:mode\s*=\s*)?[fFrRbBuU]*['\"][^'\"]*[wax+]",
|
||||
re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
_PATH_WORKSPACE_WRITE = re.compile(
|
||||
rf"Path\s*\(\s*['\"]{_WORKSPACE}[^'\"]*['\"]\s*\)\s*\.\s*"
|
||||
r"(?:write_text|write_bytes|touch|mkdir|unlink|rmdir|replace|chmod|rename|open)\b",
|
||||
re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
_FS_WORKSPACE_WRITE = re.compile(
|
||||
r"(?:writeFile(?:Sync)?|appendFile(?:Sync)?|mkdir(?:Sync)?|rm(?:dir)?(?:Sync)?|"
|
||||
rf"unlink(?:Sync)?|rename(?:Sync)?)\s*\(\s*['\"]{_WORKSPACE}",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_FOPEN_WORKSPACE_WRITE = re.compile(
|
||||
rf"fopen\s*\(\s*['\"]{_WORKSPACE}[^'\"]*['\"]\s*,\s*['\"][^'\"]*[wax+]",
|
||||
re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
_SHUTIL_OS_WORKSPACE_WRITE = re.compile(
|
||||
r"(?:shutil\.(?:copy(?:2)?|copyfile|copytree|move|rmtree)|"
|
||||
r"os\.(?:remove|unlink|replace|rename|mkdir|makedirs|rmdir|chmod))"
|
||||
rf"\s*\([^;]{{0,240}}{_WORKSPACE}",
|
||||
re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
_REDIRECT_WORKSPACE = re.compile(rf"(?:>>|>)\s*['\"]?{_WORKSPACE}\b", re.IGNORECASE)
|
||||
|
||||
_WORKSPACE_WRITES = (
|
||||
_OPEN_WORKSPACE_WRITE,
|
||||
_PATH_WORKSPACE_WRITE,
|
||||
_FS_WORKSPACE_WRITE,
|
||||
_FOPEN_WORKSPACE_WRITE,
|
||||
_SHUTIL_OS_WORKSPACE_WRITE,
|
||||
_REDIRECT_WORKSPACE,
|
||||
)
|
||||
|
||||
|
||||
def check_snippet_safety(snippet: str) -> None:
|
||||
"""Return None when `snippet` is structurally safe; raise otherwise."""
|
||||
lowered = snippet.lower()
|
||||
if "docker.sock" in lowered:
|
||||
raise UnsafeSnippetError("snippet mentions the Docker socket")
|
||||
if _SOCKET_WORD.search(snippet) is not None:
|
||||
raise UnsafeSnippetError("snippet mentions socket")
|
||||
if _CURL_WORD.search(snippet) is not None:
|
||||
raise UnsafeSnippetError("snippet mentions curl")
|
||||
if _HOST_NETWORK.search(snippet) is not None:
|
||||
raise UnsafeSnippetError("snippet requests host network")
|
||||
if _writes_under_workspace(snippet):
|
||||
raise UnsafeSnippetError(f"snippet writes under {WORKSPACE_MOUNT}")
|
||||
|
||||
|
||||
def _writes_under_workspace(snippet: str) -> bool:
|
||||
if WORKSPACE_MOUNT not in snippet.lower():
|
||||
return False
|
||||
return any(pattern.search(snippet) is not None for pattern in _WORKSPACE_WRITES)
|
||||
@@ -1,110 +0,0 @@
|
||||
"""Map sandbox outcomes to verdicts and the worth-fixing gate."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
|
||||
from ..sandbox import SandboxResult
|
||||
|
||||
TRIGGERABLE = "triggerable"
|
||||
NOT_TRIGGERABLE = "not_triggerable"
|
||||
ALREADY_COVERED = "already_covered"
|
||||
INCONCLUSIVE = "inconclusive"
|
||||
|
||||
MISSING_RECIPE = "missing_recipe"
|
||||
SAFETY_REJECTED = "safety_rejected"
|
||||
SANDBOX_UNAVAILABLE = "sandbox_unavailable"
|
||||
TIMEOUT = "timeout"
|
||||
COVERED_TEST_PASSED = "covered_test_passed"
|
||||
ASSERTION = "assertion"
|
||||
SANITIZER_ABORT = "sanitizer_abort"
|
||||
CRASH = "crash"
|
||||
NONZERO_EXIT = "nonzero_exit"
|
||||
EXIT_ZERO = "exit_zero"
|
||||
ORACLE_FIRED = "oracle_fired"
|
||||
ORACLE_QUIET = "oracle_quiet"
|
||||
|
||||
WORTH_FIXING_REACHABILITY = frozenset({"reachable", "inferred-file-surface"})
|
||||
|
||||
_SANITIZER_MARKERS = (
|
||||
"AddressSanitizer",
|
||||
"UndefinedBehaviorSanitizer",
|
||||
"ThreadSanitizer",
|
||||
"MemorySanitizer",
|
||||
"LeakSanitizer",
|
||||
)
|
||||
_ASSERTION_MARKERS = (
|
||||
"AssertionError",
|
||||
"Assertion failed",
|
||||
"assertion failed",
|
||||
"assert failed",
|
||||
)
|
||||
_CRASH_MARKERS = (
|
||||
"Fatal Python error",
|
||||
"Segmentation fault",
|
||||
"SIGSEGV",
|
||||
"SIGABRT",
|
||||
"Aborted",
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RegressionVerdict:
|
||||
verdict: str
|
||||
reason: str = ""
|
||||
worth_fixing: bool = False
|
||||
|
||||
|
||||
def is_worth_fixing(verdict: str, reachability: str) -> bool:
|
||||
"""True iff the candidate is triggerable on a reachable or inferred-file-surface finding."""
|
||||
return verdict == TRIGGERABLE and reachability in WORTH_FIXING_REACHABILITY
|
||||
|
||||
|
||||
def verdict_from_result(
|
||||
result: SandboxResult | None = None,
|
||||
*,
|
||||
recipe_missing: bool = False,
|
||||
sandbox_missing: bool = False,
|
||||
safety_rejected: bool = False,
|
||||
covering_test: bool = False,
|
||||
oracle: Callable[[SandboxResult], bool] | None = None,
|
||||
) -> RegressionVerdict:
|
||||
"""Map a sandbox observation onto one of the four regression verdicts.
|
||||
|
||||
``oracle`` is how a family says what "the vulnerability fired" means for it: when one is given it
|
||||
decides triggerability instead of the exit code, because a crash and a demonstrated injection are
|
||||
not the same evidence. An inconclusive run stays inconclusive either way.
|
||||
"""
|
||||
if recipe_missing:
|
||||
return RegressionVerdict(verdict=INCONCLUSIVE, reason=MISSING_RECIPE)
|
||||
if sandbox_missing:
|
||||
return RegressionVerdict(verdict=INCONCLUSIVE, reason=SANDBOX_UNAVAILABLE)
|
||||
if safety_rejected:
|
||||
return RegressionVerdict(verdict=INCONCLUSIVE, reason=SAFETY_REJECTED)
|
||||
if result is None:
|
||||
return RegressionVerdict(verdict=INCONCLUSIVE, reason=SANDBOX_UNAVAILABLE)
|
||||
if result.timed_out:
|
||||
return RegressionVerdict(verdict=INCONCLUSIVE, reason=TIMEOUT)
|
||||
if covering_test and result.exit_code == 0:
|
||||
return RegressionVerdict(verdict=ALREADY_COVERED, reason=COVERED_TEST_PASSED)
|
||||
if oracle is not None:
|
||||
return (
|
||||
RegressionVerdict(verdict=TRIGGERABLE, reason=ORACLE_FIRED)
|
||||
if oracle(result)
|
||||
else RegressionVerdict(verdict=NOT_TRIGGERABLE, reason=ORACLE_QUIET)
|
||||
)
|
||||
if result.exit_code != 0:
|
||||
return RegressionVerdict(verdict=TRIGGERABLE, reason=_nonzero_reason(result))
|
||||
return RegressionVerdict(verdict=NOT_TRIGGERABLE, reason=EXIT_ZERO)
|
||||
|
||||
|
||||
def _nonzero_reason(result: SandboxResult) -> str:
|
||||
text = f"{result.stdout}\n{result.stderr}".lower()
|
||||
if any(marker.lower() in text for marker in _SANITIZER_MARKERS):
|
||||
return SANITIZER_ABORT
|
||||
if any(marker.lower() in text for marker in _ASSERTION_MARKERS):
|
||||
return ASSERTION
|
||||
if any(marker.lower() in text for marker in _CRASH_MARKERS):
|
||||
return CRASH
|
||||
return NONZERO_EXIT
|
||||
@@ -17,7 +17,6 @@ def write_markdown_report(
|
||||
*,
|
||||
redact: bool = True,
|
||||
complexity_map: object | None = None,
|
||||
verdicts: Sequence[object] | None = None,
|
||||
overlay: Sequence[object] | None = None,
|
||||
mechanisms: Mapping[str, Mapping[str, object]] | None = None,
|
||||
obligations: Mapping[str, Mapping[str, object]] | None = None,
|
||||
@@ -103,8 +102,6 @@ def write_markdown_report(
|
||||
_append_overlay(lines, overlay)
|
||||
if complexity_map is not None:
|
||||
_append_complexity_map(lines, complexity_map)
|
||||
if verdicts is not None:
|
||||
_append_worth_fixing(lines, verdicts)
|
||||
report = "\n".join(lines).rstrip() + "\n"
|
||||
if redact:
|
||||
from .redaction import redact_secrets
|
||||
@@ -149,25 +146,6 @@ def _append_complexity_map(lines: list[str], complexity_map: object) -> None:
|
||||
lines.extend(["", rationale, ""] if rationale else [""])
|
||||
|
||||
|
||||
def _append_worth_fixing(lines: list[str], verdicts: Sequence[object]) -> None:
|
||||
worth = [item for item in verdicts if getattr(item, "worth_fixing", False)]
|
||||
lines.extend(["## Worth fixing", "", f"Count: {len(worth)}", ""])
|
||||
if not worth:
|
||||
lines.extend(["No worth-fixing verdicts were produced.", ""])
|
||||
return
|
||||
for item in worth:
|
||||
identity = _report_identity(getattr(item, "path", ""), getattr(item, "function_name", None))
|
||||
lines.extend(
|
||||
[
|
||||
f"### `{identity}`",
|
||||
"",
|
||||
f"- Verdict: `{getattr(item, 'verdict', '')}`",
|
||||
f"- Reason: `{getattr(item, 'reason', '')}`",
|
||||
"",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def _report_identity(path: object, function_name: object) -> str:
|
||||
path_text = str(path or "")
|
||||
if function_name:
|
||||
@@ -227,7 +205,6 @@ def write_manifest(
|
||||
fusion: list[dict[str, object]] | None = None,
|
||||
stages: dict[str, object] | None = None,
|
||||
complexity: dict[str, object] | None = None,
|
||||
worth_fixing: dict[str, object] | None = None,
|
||||
provenance: dict[str, object] | None = None,
|
||||
variants: dict[str, object] | None = None,
|
||||
obligations: dict[str, object] | None = None,
|
||||
@@ -265,8 +242,6 @@ def write_manifest(
|
||||
payload["stages"] = stages
|
||||
if complexity is not None:
|
||||
payload["complexity"] = complexity
|
||||
if worth_fixing is not None:
|
||||
payload["worth_fixing"] = worth_fixing
|
||||
if provenance is not None:
|
||||
payload["provenance"] = provenance
|
||||
if variants is not None:
|
||||
@@ -282,7 +257,6 @@ def scan_exit_code(
|
||||
findings: list[StaticFinding],
|
||||
verifications: list[VerificationResult],
|
||||
policy: str,
|
||||
worth_fixing_verdicts: Sequence[object] | None = None,
|
||||
) -> int:
|
||||
if policy == "never":
|
||||
return 0
|
||||
@@ -290,8 +264,6 @@ def scan_exit_code(
|
||||
return 1 if findings else 0
|
||||
if policy == "verified":
|
||||
return 1 if any(result.verified for result in verifications) else 0
|
||||
if policy == "worth-fixing":
|
||||
return 1 if any(getattr(verdict, "worth_fixing", False) for verdict in worth_fixing_verdicts or ()) else 0
|
||||
raise ValueError(f"unknown fail policy: {policy}")
|
||||
|
||||
|
||||
@@ -463,7 +435,7 @@ _COVERAGE_DEGRADATIONS: dict[str, str] = {
|
||||
"policy_invalid": "The `{stage}` policy could not be loaded, so that stage decided nothing. {detail}",
|
||||
"max_findings_exceeded": "Only the first {requested} of {actual} findings are reported (`max_findings`).",
|
||||
"hunter_model_unavailable": "The `{stage}` stage needs a model and none is available, so it was skipped.",
|
||||
"sandbox_unavailable": "Docker is not available, so no finding was reproduced in the sandbox (REGRESS skipped).",
|
||||
"regress_retired": "the sandboxed snippet stage was retired on 2026-10-13; execution proof comes from the search-with-proof arm",
|
||||
"language_not_covered": (
|
||||
"language not covered: {language} ({count} files). No quick rules{engine} exist for it, so **silence about "
|
||||
"these files is not a clean result**."
|
||||
|
||||
@@ -1,25 +1,5 @@
|
||||
"""Isolated Docker CLI sandbox."""
|
||||
"""Shared sandbox job and result types."""
|
||||
|
||||
from .probe import FakeSandboxProbe, SandboxProbe, resolve_sandbox_probe
|
||||
from .runner import (
|
||||
DockerCliRunner,
|
||||
FakeSandboxRunner,
|
||||
SandboxJob,
|
||||
SandboxResult,
|
||||
SandboxRunner,
|
||||
build_docker_argv,
|
||||
resolve_sandbox_runner,
|
||||
)
|
||||
from .runner import SandboxJob, SandboxResult
|
||||
|
||||
__all__ = [
|
||||
"DockerCliRunner",
|
||||
"FakeSandboxProbe",
|
||||
"FakeSandboxRunner",
|
||||
"SandboxJob",
|
||||
"SandboxProbe",
|
||||
"SandboxResult",
|
||||
"SandboxRunner",
|
||||
"build_docker_argv",
|
||||
"resolve_sandbox_probe",
|
||||
"resolve_sandbox_runner",
|
||||
]
|
||||
__all__ = ["SandboxJob", "SandboxResult"]
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
"""Docker CLI presence and usability probe."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
from collections.abc import Callable
|
||||
from subprocess import CompletedProcess
|
||||
|
||||
PROBE_TIMEOUT_SECONDS = 5
|
||||
PROBE_ENV = "OPENULTRASAST_SANDBOX_PROBE"
|
||||
|
||||
_DOCKER_INFO = ("docker", "info")
|
||||
_PROBE_OFF = frozenset({"0", "off", "false", "unavailable", "no"})
|
||||
_PROBE_ON = frozenset({"1", "on", "true", "available", "yes"})
|
||||
|
||||
|
||||
class SandboxProbe:
|
||||
"""Report whether `docker info` succeeds without raising into the scan."""
|
||||
|
||||
def __init__(self, *, runner: Callable[..., CompletedProcess[str]] | None = None) -> None:
|
||||
self._runner = runner
|
||||
|
||||
def available(self) -> bool:
|
||||
run = self._runner if self._runner is not None else subprocess.run
|
||||
try:
|
||||
result = run(
|
||||
list(_DOCKER_INFO),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=PROBE_TIMEOUT_SECONDS,
|
||||
check=False,
|
||||
)
|
||||
return result.returncode == 0
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
class FakeSandboxProbe:
|
||||
"""Test double that reports a programmed availability without touching Docker."""
|
||||
|
||||
def __init__(self, available: bool) -> None:
|
||||
self._available = available
|
||||
|
||||
def available(self) -> bool:
|
||||
return self._available
|
||||
|
||||
|
||||
def resolve_sandbox_probe() -> SandboxProbe | FakeSandboxProbe:
|
||||
"""Return a real probe, or a fake when OPENULTRASAST_SANDBOX_PROBE is set."""
|
||||
flag = os.environ.get(PROBE_ENV, "").strip().lower()
|
||||
if flag in _PROBE_OFF:
|
||||
return FakeSandboxProbe(False)
|
||||
if flag in _PROBE_ON:
|
||||
return FakeSandboxProbe(True)
|
||||
return SandboxProbe()
|
||||
@@ -1,28 +1,9 @@
|
||||
"""Docker CLI sandbox runner that never mounts the host Docker socket."""
|
||||
"""Job and result types shared by the search sandbox and verifier."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
from collections.abc import Callable, Mapping
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from subprocess import CompletedProcess
|
||||
from typing import Protocol
|
||||
|
||||
NONROOT_USER = "65534:65534"
|
||||
WORKSPACE_MOUNT = "/workspace"
|
||||
SCRATCH_MOUNT = "/scratch"
|
||||
SCRATCH_SEED_MOUNT = "/scratch-in"
|
||||
RUNNER_ENV = "OPENULTRASAST_SANDBOX_RUNNER"
|
||||
_COPY_SEED_THEN_EXEC = 'cp -r /scratch-in/. /scratch/ && exec "$@"'
|
||||
_SEED_ARGV0 = "ousast-sandbox"
|
||||
_FAKE_RUNNER = frozenset({"fake"})
|
||||
|
||||
|
||||
class SandboxRunner(Protocol):
|
||||
def run(self, spec: SandboxJob) -> SandboxResult: ...
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -43,158 +24,3 @@ class SandboxResult:
|
||||
stderr: str
|
||||
timed_out: bool
|
||||
|
||||
|
||||
class FakeSandboxRunner:
|
||||
"""Record jobs and return a programmed result without a Docker daemon."""
|
||||
|
||||
def __init__(self, result: SandboxResult | None = None) -> None:
|
||||
self.jobs: list[SandboxJob] = []
|
||||
self.result = result if result is not None else SandboxResult(exit_code=0, stdout="", stderr="", timed_out=False)
|
||||
|
||||
def run(self, spec: SandboxJob) -> SandboxResult:
|
||||
self.jobs.append(spec)
|
||||
return self.result
|
||||
|
||||
|
||||
def resolve_sandbox_runner() -> SandboxRunner:
|
||||
"""Return the Docker CLI runner, or a fake when OPENULTRASAST_SANDBOX_RUNNER=fake."""
|
||||
flag = os.environ.get(RUNNER_ENV, "").strip().lower()
|
||||
if flag in _FAKE_RUNNER:
|
||||
return FakeSandboxRunner()
|
||||
return DockerCliRunner()
|
||||
|
||||
|
||||
class DockerCliRunner:
|
||||
"""Run a job with Docker CLI flags that match the threat model."""
|
||||
|
||||
def __init__(self, *, runner: Callable[..., CompletedProcess[str]] | None = None) -> None:
|
||||
self._runner = runner
|
||||
|
||||
def run(self, spec: SandboxJob) -> SandboxResult:
|
||||
if spec.scratch_files:
|
||||
with tempfile.TemporaryDirectory(prefix="ousast-scratch-") as tmp:
|
||||
seed = Path(tmp)
|
||||
_write_scratch_files(seed, spec.scratch_files)
|
||||
return self._invoke(_argv_with_scratch_seed(spec, seed), spec)
|
||||
return self._invoke(build_docker_argv(spec), spec)
|
||||
|
||||
def _invoke(self, argv: list[str], spec: SandboxJob) -> SandboxResult:
|
||||
_reject_unsafe_argv(argv)
|
||||
run = self._runner if self._runner is not None else subprocess.run
|
||||
try:
|
||||
completed = run(
|
||||
argv,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=spec.timeout_seconds,
|
||||
check=False,
|
||||
)
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
return SandboxResult(
|
||||
exit_code=-1,
|
||||
stdout=_as_text(exc.stdout),
|
||||
stderr=_as_text(exc.stderr),
|
||||
timed_out=True,
|
||||
)
|
||||
return SandboxResult(
|
||||
exit_code=completed.returncode,
|
||||
stdout=completed.stdout or "",
|
||||
stderr=completed.stderr or "",
|
||||
timed_out=False,
|
||||
)
|
||||
|
||||
|
||||
def build_docker_argv(job: SandboxJob) -> list[str]:
|
||||
argv = [*_isolation_argv(job), job.image, *job.command]
|
||||
_reject_unsafe_argv(argv)
|
||||
return argv
|
||||
|
||||
|
||||
def _isolation_argv(job: SandboxJob) -> list[str]:
|
||||
repo = str(job.repo_root.resolve())
|
||||
return [
|
||||
"docker",
|
||||
"run",
|
||||
"--rm",
|
||||
"--init",
|
||||
"--network",
|
||||
"none",
|
||||
"--read-only",
|
||||
"--tmpfs",
|
||||
f"{SCRATCH_MOUNT}:rw,exec",
|
||||
"--memory",
|
||||
f"{job.memory_mb}m",
|
||||
"--pids-limit",
|
||||
str(job.pids_limit),
|
||||
"--cap-drop",
|
||||
"ALL",
|
||||
"--security-opt",
|
||||
"no-new-privileges",
|
||||
"--user",
|
||||
NONROOT_USER,
|
||||
"--mount",
|
||||
f"type=bind,src={repo},dst={WORKSPACE_MOUNT},ro",
|
||||
]
|
||||
|
||||
|
||||
def _argv_with_scratch_seed(job: SandboxJob, seed: Path) -> list[str]:
|
||||
argv = [
|
||||
*_isolation_argv(job),
|
||||
"--mount",
|
||||
f"type=bind,src={seed.resolve()},dst={SCRATCH_SEED_MOUNT},ro",
|
||||
job.image,
|
||||
"/bin/sh",
|
||||
"-c",
|
||||
_COPY_SEED_THEN_EXEC,
|
||||
_SEED_ARGV0,
|
||||
*job.command,
|
||||
]
|
||||
_reject_unsafe_argv(argv)
|
||||
return argv
|
||||
|
||||
|
||||
def _write_scratch_files(root: Path, files: Mapping[str, str]) -> None:
|
||||
resolved_root = root.resolve()
|
||||
for relative, content in files.items():
|
||||
candidate = Path(relative)
|
||||
if candidate.is_absolute():
|
||||
raise ValueError(f"scratch path {relative!r} must be relative to {SCRATCH_MOUNT}")
|
||||
destination = (resolved_root / candidate).resolve()
|
||||
try:
|
||||
common = os.path.commonpath([str(resolved_root), str(destination)])
|
||||
except ValueError as exc:
|
||||
raise ValueError(f"scratch path {relative!r} escapes {SCRATCH_MOUNT}") from exc
|
||||
if common != str(resolved_root):
|
||||
raise ValueError(f"scratch path {relative!r} escapes {SCRATCH_MOUNT}")
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
destination.write_text(content)
|
||||
_chmod_seed_tree(resolved_root)
|
||||
|
||||
|
||||
def _chmod_seed_tree(root: Path) -> None:
|
||||
# Container user 65534 must be able to traverse and read the seed bind.
|
||||
for dirpath, _dirnames, filenames in os.walk(root):
|
||||
Path(dirpath).chmod(0o755)
|
||||
for name in filenames:
|
||||
(Path(dirpath) / name).chmod(0o644)
|
||||
|
||||
|
||||
def _reject_unsafe_argv(argv: list[str]) -> None:
|
||||
joined = " ".join(argv)
|
||||
if "/var/run/docker.sock" in joined:
|
||||
raise ValueError("sandbox argv must not mount the host Docker socket")
|
||||
if "--privileged" in argv:
|
||||
raise ValueError("sandbox argv must not be privileged")
|
||||
for index, item in enumerate(argv):
|
||||
if item in {"--network", "--net"} and index + 1 < len(argv) and argv[index + 1] == "host":
|
||||
raise ValueError("sandbox argv must not use host network")
|
||||
if item in {"--network=host", "--net=host"}:
|
||||
raise ValueError("sandbox argv must not use host network")
|
||||
|
||||
|
||||
def _as_text(value: str | bytes | None) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, bytes):
|
||||
return value.decode(errors="replace")
|
||||
return value
|
||||
|
||||
@@ -1,372 +0,0 @@
|
||||
"""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
|
||||
a short operating checklist. The router selects the most relevant skills for a target
|
||||
and stage; ``select_skill_context`` packs their checklists into a char budget; and
|
||||
``skill_chunks`` chunks the guidance into the ``skills`` retrieval namespace. Nothing
|
||||
here imports a model or the optional extra — it is pure routing data.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from .index import CodeChunk, chunk_text_namespace
|
||||
from .preprocess import FileTarget
|
||||
|
||||
# Pipeline stages a skill can apply to.
|
||||
STAGES = ("map", "hunt", "verify", "fix")
|
||||
|
||||
# FileTarget tags → vulnerability classes the router reasons over.
|
||||
TAG_TO_VULN = {
|
||||
"memory_unsafe": "memory-safety",
|
||||
"parser": "parsing",
|
||||
"fuzzable": "parsing",
|
||||
"crypto": "crypto",
|
||||
"deserialization": "deserialization",
|
||||
"auth_boundary": "access-control",
|
||||
"network_entry": "access-control",
|
||||
"privileged": "access-control",
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SkillDescriptor:
|
||||
id: str
|
||||
title: str
|
||||
summary: str
|
||||
checklist: str
|
||||
languages: tuple[str, ...] = () # empty => language-agnostic
|
||||
tags: tuple[str, ...] = ()
|
||||
vulnerability_classes: tuple[str, ...] = ()
|
||||
stages: tuple[str, ...] = () # empty => any stage
|
||||
|
||||
def descriptor(self) -> dict[str, object]:
|
||||
return {
|
||||
"id": self.id,
|
||||
"title": self.title,
|
||||
"summary": self.summary,
|
||||
"languages": list(self.languages),
|
||||
"tags": list(self.tags),
|
||||
"vulnerability_classes": list(self.vulnerability_classes),
|
||||
"stages": list(self.stages),
|
||||
}
|
||||
|
||||
|
||||
# Curated inventory aligned with the spec's Trail of Bits skill routing examples.
|
||||
SKILL_INVENTORY: tuple[SkillDescriptor, ...] = (
|
||||
SkillDescriptor(
|
||||
"c-review",
|
||||
"C/C++ security review",
|
||||
"Manual review discipline for memory-unsafe code.",
|
||||
"Audit buffer indexing, object lifetime, integer overflow before length math, and unchecked copies.",
|
||||
languages=("c", "cpp", "c++"),
|
||||
tags=("memory_unsafe",),
|
||||
vulnerability_classes=("memory-safety",),
|
||||
stages=("map", "hunt"),
|
||||
),
|
||||
SkillDescriptor(
|
||||
"address-sanitizer",
|
||||
"AddressSanitizer",
|
||||
"Detect memory-safety violations at runtime.",
|
||||
"Build with -fsanitize=address; reproduce the overflow/UAF; capture the ASan trace as crash evidence.",
|
||||
languages=("c", "cpp", "c++"),
|
||||
tags=("memory_unsafe",),
|
||||
vulnerability_classes=("memory-safety",),
|
||||
stages=("hunt", "verify"),
|
||||
),
|
||||
SkillDescriptor(
|
||||
"libfuzzer",
|
||||
"libFuzzer harnessing",
|
||||
"In-process coverage-guided fuzzing of C/C++ entries.",
|
||||
"Write a LLVMFuzzerTestOneInput harness over the parser entry; minimize crashing inputs.",
|
||||
languages=("c", "cpp", "c++"),
|
||||
tags=("memory_unsafe", "fuzzable", "parser"),
|
||||
vulnerability_classes=("memory-safety", "parsing"),
|
||||
stages=("hunt", "verify"),
|
||||
),
|
||||
SkillDescriptor(
|
||||
"harness-writing",
|
||||
"Fuzz harness writing",
|
||||
"Build deterministic harnesses around attacker-controlled entries.",
|
||||
"Identify the smallest attacker-controlled entry; build a deterministic harness; seed a corpus.",
|
||||
languages=("c", "cpp", "c++"),
|
||||
tags=("memory_unsafe", "fuzzable", "parser"),
|
||||
vulnerability_classes=("memory-safety", "parsing"),
|
||||
stages=("hunt", "verify"),
|
||||
),
|
||||
SkillDescriptor(
|
||||
"fuzzing-dictionary",
|
||||
"Fuzzing dictionary",
|
||||
"Reach deep parser states with a format dictionary.",
|
||||
"Extract format tokens/magic bytes into a fuzzing dictionary to reach deep parser states.",
|
||||
tags=("parser", "fuzzable"),
|
||||
vulnerability_classes=("parsing",),
|
||||
stages=("verify",),
|
||||
),
|
||||
SkillDescriptor(
|
||||
"fuzzing-obstacles",
|
||||
"Fuzzing obstacles",
|
||||
"Remove coverage barriers that block the fuzzer.",
|
||||
"Remove checksums/magic gates that block coverage; document each bypass.",
|
||||
tags=("parser", "fuzzable"),
|
||||
vulnerability_classes=("parsing",),
|
||||
stages=("verify",),
|
||||
),
|
||||
SkillDescriptor(
|
||||
"atheris",
|
||||
"Atheris (Python fuzzing)",
|
||||
"Coverage-guided fuzzing for Python parsers.",
|
||||
"Fuzz the Python parser entry with Atheris; assert on uncaught exceptions and resource blowups.",
|
||||
languages=("python",),
|
||||
tags=("parser", "fuzzable"),
|
||||
vulnerability_classes=("parsing",),
|
||||
stages=("hunt", "verify"),
|
||||
),
|
||||
SkillDescriptor(
|
||||
"property-based-testing",
|
||||
"Property-based testing",
|
||||
"Encode parser invariants as properties.",
|
||||
"Encode parser invariants as Hypothesis properties; shrink counterexamples.",
|
||||
languages=("python",),
|
||||
tags=("parser",),
|
||||
vulnerability_classes=("parsing",),
|
||||
stages=("verify",),
|
||||
),
|
||||
SkillDescriptor(
|
||||
"constant-time-analysis",
|
||||
"Constant-time analysis",
|
||||
"Find secret-dependent timing in crypto code.",
|
||||
"Find secret-dependent branches/indexing/early-exit comparisons; require constant-time primitives.",
|
||||
tags=("crypto",),
|
||||
vulnerability_classes=("crypto",),
|
||||
stages=("hunt", "verify"),
|
||||
),
|
||||
SkillDescriptor(
|
||||
"wycheproof",
|
||||
"Wycheproof vectors",
|
||||
"Stress crypto usage with known-edge test vectors.",
|
||||
"Run Wycheproof test vectors against the crypto usage; flag edge-case acceptance.",
|
||||
tags=("crypto",),
|
||||
vulnerability_classes=("crypto",),
|
||||
stages=("verify",),
|
||||
),
|
||||
SkillDescriptor(
|
||||
"vector-forge",
|
||||
"Crypto vector forging",
|
||||
"Forge boundary vectors to probe crypto APIs.",
|
||||
"Forge boundary test vectors (zero/identity/malleable signatures) to probe the crypto API.",
|
||||
tags=("crypto",),
|
||||
vulnerability_classes=("crypto",),
|
||||
stages=("verify",),
|
||||
),
|
||||
SkillDescriptor(
|
||||
"zeroize-audit",
|
||||
"Secret zeroization audit",
|
||||
"Verify secrets are wiped after use.",
|
||||
"Verify secrets are zeroized on drop; check for lingering copies.",
|
||||
languages=("rust",),
|
||||
tags=("crypto",),
|
||||
vulnerability_classes=("crypto",),
|
||||
stages=("verify",),
|
||||
),
|
||||
SkillDescriptor(
|
||||
"cargo-fuzz",
|
||||
"cargo-fuzz",
|
||||
"Coverage-guided fuzzing for Rust entries.",
|
||||
"Add a cargo-fuzz target over the entry; run under ASan.",
|
||||
languages=("rust",),
|
||||
tags=("fuzzable", "parser"),
|
||||
vulnerability_classes=("memory-safety", "parsing"),
|
||||
stages=("hunt", "verify"),
|
||||
),
|
||||
SkillDescriptor(
|
||||
"sarif-parsing",
|
||||
"SARIF parsing",
|
||||
"Normalize external tool output into the map.",
|
||||
"Normalize SARIF locations/rules/fingerprints into FileTarget static hints.",
|
||||
vulnerability_classes=(),
|
||||
stages=("map",),
|
||||
),
|
||||
SkillDescriptor(
|
||||
"semgrep",
|
||||
"Semgrep mapping",
|
||||
"Pattern sinks and iterate variants.",
|
||||
"Pattern the sink shape; iterate variants; export normalized findings.",
|
||||
vulnerability_classes=("injection", "deserialization"),
|
||||
stages=("map",),
|
||||
),
|
||||
SkillDescriptor(
|
||||
"semgrep-rule-creator",
|
||||
"Semgrep rule creator",
|
||||
"Author a Semgrep rule for a sink.",
|
||||
"Author a Semgrep rule for the sink; validate against the fixture.",
|
||||
tags=("deserialization",),
|
||||
vulnerability_classes=("injection", "deserialization"),
|
||||
stages=("map",),
|
||||
),
|
||||
SkillDescriptor(
|
||||
"semgrep-rule-variant-creator",
|
||||
"Semgrep variant creator",
|
||||
"Widen sink coverage with variants.",
|
||||
"Generate sink variants (aliases, wrappers) to widen coverage without false positives.",
|
||||
vulnerability_classes=("injection",),
|
||||
stages=("map",),
|
||||
),
|
||||
SkillDescriptor(
|
||||
"codeql",
|
||||
"CodeQL dataflow",
|
||||
"Model source→sink taint paths.",
|
||||
"Model source→sink taint with a CodeQL query; inspect the dataflow path.",
|
||||
vulnerability_classes=("injection",),
|
||||
stages=("map",),
|
||||
),
|
||||
SkillDescriptor(
|
||||
"differential-review",
|
||||
"Differential review",
|
||||
"Scope the changed attack surface in a diff.",
|
||||
"Scope the changed attack surface and new trust-boundary crossings in the diff.",
|
||||
stages=("map", "fix"),
|
||||
),
|
||||
SkillDescriptor(
|
||||
"graph-evolution",
|
||||
"Graph evolution",
|
||||
"Bound a patch's blast radius.",
|
||||
"Trace how the call/dataflow graph changed; bound the patch blast radius.",
|
||||
stages=("fix",),
|
||||
),
|
||||
SkillDescriptor(
|
||||
"sharp-edges",
|
||||
"Sharp edges review",
|
||||
"Map misuse-prone APIs at the boundary.",
|
||||
"Map misuse-prone APIs, insecure defaults, and confusing configuration at the boundary.",
|
||||
tags=("auth_boundary", "network_entry"),
|
||||
vulnerability_classes=("access-control", "api-misuse"),
|
||||
stages=("map", "hunt"),
|
||||
),
|
||||
SkillDescriptor(
|
||||
"insecure-defaults",
|
||||
"Insecure defaults review",
|
||||
"Flag unsafe defaults exposed at entries.",
|
||||
"Flag unsafe default flags/permissions/timeouts exposed at the entry.",
|
||||
tags=("auth_boundary", "network_entry"),
|
||||
vulnerability_classes=("access-control", "api-misuse"),
|
||||
stages=("map", "hunt"),
|
||||
),
|
||||
)
|
||||
|
||||
_BY_ID = {skill.id: skill for skill in SKILL_INVENTORY}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SkillMatch:
|
||||
skill: SkillDescriptor
|
||||
score: int
|
||||
reasons: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
def route_skills(
|
||||
*,
|
||||
language: str | None = None,
|
||||
tags: tuple[str, ...] = (),
|
||||
vulnerability_classes: tuple[str, ...] = (),
|
||||
stage: str | None = None,
|
||||
max_snippets: int = 4,
|
||||
) -> list[SkillDescriptor]:
|
||||
"""Select the most relevant skills for the given routing signals.
|
||||
|
||||
A skill is excluded when its language list is non-empty and ``language`` is not in
|
||||
it, or its stage list is non-empty and ``stage`` is not in it. Eligibility requires
|
||||
at least one topical signal (tag/vulnerability-class/stage) — a pure language match
|
||||
never selects a skill. Ranked by score, then id, for determinism.
|
||||
"""
|
||||
tag_set, vuln_set = set(tags), set(vulnerability_classes)
|
||||
matches: list[SkillMatch] = []
|
||||
for skill in SKILL_INVENTORY:
|
||||
if skill.languages and language is not None and language not in skill.languages:
|
||||
continue
|
||||
if skill.stages and stage is not None and stage not in skill.stages:
|
||||
continue
|
||||
signal = 2 * len(vuln_set & set(skill.vulnerability_classes)) + len(tag_set & set(skill.tags))
|
||||
if stage is not None and stage in skill.stages:
|
||||
signal += 1
|
||||
if signal == 0:
|
||||
continue # no topical match — do not select on language alone
|
||||
rank = signal + (3 if skill.languages and language in skill.languages else 0)
|
||||
matches.append(SkillMatch(skill=skill, score=rank))
|
||||
matches.sort(key=lambda match: (-match.score, match.skill.id))
|
||||
return [match.skill for match in matches[:max_snippets]]
|
||||
|
||||
|
||||
def _vuln_classes_for_tags(tags: tuple[str, ...]) -> tuple[str, ...]:
|
||||
return tuple(sorted({TAG_TO_VULN[tag] for tag in tags if tag in TAG_TO_VULN}))
|
||||
|
||||
|
||||
def route_skills_for_target(target: FileTarget, *, stage: str | None = None, max_snippets: int = 4) -> list[SkillDescriptor]:
|
||||
"""Route skills for a scan target (language + tags + derived vulnerability classes)."""
|
||||
tags = tuple(target.tags)
|
||||
return route_skills(
|
||||
language=target.language,
|
||||
tags=tags,
|
||||
vulnerability_classes=_vuln_classes_for_tags(tags),
|
||||
stage=stage,
|
||||
max_snippets=max_snippets,
|
||||
)
|
||||
|
||||
|
||||
def route_skill_ids(target: FileTarget, *, stage: str | None = None, max_snippets: int = 4) -> list[str]:
|
||||
return [skill.id for skill in route_skills_for_target(target, stage=stage, max_snippets=max_snippets)]
|
||||
|
||||
|
||||
def select_skill_context(target: FileTarget, *, stage: str | None = None, budget_chars: int, max_snippets: int = 4) -> str:
|
||||
"""Pack routed skill checklists into a prompt context capped at ``budget_chars``."""
|
||||
if budget_chars <= 0:
|
||||
return ""
|
||||
parts: list[str] = []
|
||||
used = 0
|
||||
for skill in route_skills_for_target(target, stage=stage, max_snippets=max_snippets):
|
||||
snippet = f"### {skill.title}\n{skill.checklist}"
|
||||
if not parts and len(snippet) > budget_chars:
|
||||
return snippet[:budget_chars] # first snippet alone exceeds the cap → truncate
|
||||
if used + len(snippet) + (1 if parts else 0) > budget_chars:
|
||||
break
|
||||
used += len(snippet) + (1 if parts else 0)
|
||||
parts.append(snippet)
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
def skill_chunks(skills: tuple[SkillDescriptor, ...] | None = None, *, max_lines: int = 40) -> list[CodeChunk]:
|
||||
"""Chunk skill operating guidance into the ``skills`` retrieval namespace."""
|
||||
chunks: list[CodeChunk] = []
|
||||
for skill in skills or SKILL_INVENTORY:
|
||||
text = f"# {skill.title}\n{skill.summary}\n\nChecklist:\n{skill.checklist}"
|
||||
chunks.extend(
|
||||
chunk_text_namespace(
|
||||
namespace="skills",
|
||||
path=f"skills/{skill.id}.md",
|
||||
text=text,
|
||||
metadata={
|
||||
"skill_id": skill.id,
|
||||
"languages": ",".join(skill.languages),
|
||||
"tags": ",".join(skill.tags),
|
||||
"vulnerability_classes": ",".join(skill.vulnerability_classes),
|
||||
"stages": ",".join(skill.stages),
|
||||
},
|
||||
max_lines=max_lines,
|
||||
)
|
||||
)
|
||||
return chunks
|
||||
|
||||
|
||||
__all__ = [
|
||||
"SKILL_INVENTORY",
|
||||
"STAGES",
|
||||
"TAG_TO_VULN",
|
||||
"SkillDescriptor",
|
||||
"route_skill_ids",
|
||||
"route_skills",
|
||||
"route_skills_for_target",
|
||||
"select_skill_context",
|
||||
"skill_chunks",
|
||||
]
|
||||
Reference in New Issue
Block a user