2026-09-03 22:36:02 +02:00
|
|
|
|
"""Candidate selection and safety-gated regression execution."""
|
2026-09-03 22:12:45 +02:00
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
2026-09-03 23:43:15 +02:00
|
|
|
|
import json
|
2026-09-06 10:06:57 +02:00
|
|
|
|
from collections.abc import Callable, Mapping, Sequence
|
2026-09-04 00:18:33 +02:00
|
|
|
|
from dataclasses import asdict, dataclass, replace
|
2026-09-03 22:48:10 +02:00
|
|
|
|
from pathlib import Path
|
2026-09-03 22:12:45 +02:00
|
|
|
|
|
2026-09-03 22:36:02 +02:00
|
|
|
|
from ..complexity.ledger import hotspot_key, select_forced_candidates
|
|
|
|
|
|
from ..complexity.map import Hotspot
|
2026-09-03 22:48:10 +02:00
|
|
|
|
from ..config import SandboxConfig
|
2026-09-03 22:36:02 +02:00
|
|
|
|
from ..findings import StaticFinding
|
2026-09-03 23:43:15 +02:00
|
|
|
|
from ..preprocess import LANGUAGE_BY_EXTENSION
|
2026-09-03 22:12:45 +02:00
|
|
|
|
from ..sandbox import SandboxJob, SandboxResult, SandboxRunner
|
2026-09-03 23:43:15 +02:00
|
|
|
|
from ..sandbox.runner import WORKSPACE_MOUNT
|
2026-09-03 22:48:10 +02:00
|
|
|
|
from .recipes import recipe_for
|
2026-09-03 22:12:45 +02:00
|
|
|
|
from .safety import UnsafeSnippetError, check_snippet_safety
|
2026-09-03 22:59:01 +02:00
|
|
|
|
from .verdict import (
|
|
|
|
|
|
INCONCLUSIVE,
|
|
|
|
|
|
MISSING_RECIPE,
|
|
|
|
|
|
NOT_TRIGGERABLE,
|
|
|
|
|
|
SAFETY_REJECTED,
|
|
|
|
|
|
TRIGGERABLE,
|
|
|
|
|
|
RegressionVerdict,
|
2026-09-03 23:43:15 +02:00
|
|
|
|
is_worth_fixing,
|
2026-09-03 22:59:01 +02:00
|
|
|
|
verdict_from_result,
|
|
|
|
|
|
)
|
2026-09-03 22:12:45 +02:00
|
|
|
|
|
2026-09-03 23:43:15 +02:00
|
|
|
|
DEFAULT_IMAGES = {
|
|
|
|
|
|
"python": "python:3.12-alpine",
|
|
|
|
|
|
"javascript": "node:22-alpine",
|
|
|
|
|
|
"c": "gcc:13",
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-03 22:59:01 +02:00
|
|
|
|
__all__ = [
|
|
|
|
|
|
"INCONCLUSIVE",
|
|
|
|
|
|
"MISSING_RECIPE",
|
|
|
|
|
|
"NOT_TRIGGERABLE",
|
|
|
|
|
|
"SAFETY_REJECTED",
|
|
|
|
|
|
"TRIGGERABLE",
|
2026-09-03 23:43:15 +02:00
|
|
|
|
"CandidateVerdict",
|
2026-09-03 22:59:01 +02:00
|
|
|
|
"RegressionRunner",
|
|
|
|
|
|
"RegressionVerdict",
|
2026-09-03 23:43:15 +02:00
|
|
|
|
"run_regression",
|
2026-09-03 22:59:01 +02:00
|
|
|
|
"select_candidates",
|
2026-09-03 23:43:15 +02:00
|
|
|
|
"snippet_for",
|
|
|
|
|
|
"write_verdicts",
|
2026-09-03 22:59:01 +02:00
|
|
|
|
]
|
2026-09-03 22:12:45 +02:00
|
|
|
|
|
|
|
|
|
|
|
2026-09-03 23:43:15 +02:00
|
|
|
|
@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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-03 22:36:02 +02:00
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-05 14:55:00 +02:00
|
|
|
|
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."""
|
2026-09-03 22:36:02 +02:00
|
|
|
|
return Hotspot(
|
|
|
|
|
|
path=finding.path,
|
|
|
|
|
|
function_name=finding.function_name,
|
|
|
|
|
|
score=0.0,
|
|
|
|
|
|
band="low",
|
|
|
|
|
|
signals={},
|
2026-09-05 14:55:00 +02:00
|
|
|
|
rationale=rationale,
|
2026-09-03 22:36:02 +02:00
|
|
|
|
test_hint=None,
|
|
|
|
|
|
inventory_finding_ids=(finding.finding_id,),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-05 14:55:00 +02:00
|
|
|
|
def _hotspot_from_forced_finding(finding: StaticFinding) -> Hotspot:
|
|
|
|
|
|
return hotspot_from_finding(finding, rationale="forced reachable severity-5 inventory")
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-03 22:12:45 +02:00
|
|
|
|
class RegressionRunner:
|
|
|
|
|
|
"""Run a snippet in the sandbox only after the structural safety check passes."""
|
|
|
|
|
|
|
|
|
|
|
|
def __init__(self, sandbox: SandboxRunner) -> None:
|
|
|
|
|
|
self._sandbox = sandbox
|
|
|
|
|
|
|
2026-09-03 22:48:10 +02:00
|
|
|
|
def run_recipe(
|
|
|
|
|
|
self,
|
|
|
|
|
|
language: str,
|
|
|
|
|
|
snippet: str,
|
|
|
|
|
|
image: str,
|
|
|
|
|
|
sandbox_limits: SandboxConfig,
|
|
|
|
|
|
*,
|
|
|
|
|
|
repo_root: Path,
|
2026-09-06 10:06:57 +02:00
|
|
|
|
oracle: Callable[[SandboxResult], bool] | None = None,
|
2026-09-03 22:48:10 +02:00
|
|
|
|
) -> RegressionVerdict:
|
|
|
|
|
|
job = recipe_for(language, snippet, image, sandbox_limits, repo_root=repo_root)
|
|
|
|
|
|
if job is None:
|
2026-09-03 22:59:01 +02:00
|
|
|
|
return verdict_from_result(recipe_missing=True)
|
2026-09-06 10:06:57 +02:00
|
|
|
|
return self.run_snippet(snippet, job, oracle=oracle)
|
2026-09-03 22:48:10 +02:00
|
|
|
|
|
2026-09-06 10:06:57 +02:00
|
|
|
|
def run_snippet(
|
|
|
|
|
|
self, snippet: str, job: SandboxJob | None, *, oracle: Callable[[SandboxResult], bool] | None = None
|
|
|
|
|
|
) -> RegressionVerdict:
|
2026-09-03 22:48:10 +02:00
|
|
|
|
if job is None:
|
2026-09-03 22:59:01 +02:00
|
|
|
|
return verdict_from_result(recipe_missing=True)
|
2026-09-03 22:12:45 +02:00
|
|
|
|
try:
|
|
|
|
|
|
check_snippet_safety(snippet)
|
|
|
|
|
|
except UnsafeSnippetError:
|
2026-09-03 22:59:01 +02:00
|
|
|
|
return verdict_from_result(safety_rejected=True)
|
2026-09-06 10:06:57 +02:00
|
|
|
|
return verdict_from_result(self._sandbox.run(job), oracle=oracle)
|
2026-09-03 23:43:15 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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)
|
2026-09-04 00:04:36 +02:00
|
|
|
|
snippet = _proposed_snippet(hotspot, findings) or snippet_for(language, hotspot.path, hotspot.function_name)
|
2026-09-03 23:43:15 +02:00
|
|
|
|
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)
|
2026-09-04 00:18:33 +02:00
|
|
|
|
mapped = replace(mapped, worth_fixing=is_worth_fixing(mapped.verdict, reachability))
|
2026-09-03 23:43:15 +02:00
|
|
|
|
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,
|
2026-09-04 00:18:33 +02:00
|
|
|
|
worth_fixing=mapped.worth_fixing,
|
2026-09-03 23:43:15 +02:00
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
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")
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-04 00:04:36 +02:00
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-03 23:43:15 +02:00
|
|
|
|
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"
|