feat(propose-adjudicate-prove): overlay adjudicates inventory before prove

Regex inventory stays the proposer. MAP attaches promote/demote/unadjudicated/coverage
from intra-file taint (tree-sitter when a grammar parses, Python ast as last resort).
Deep sandboxes promotions only, ordered by the existing heuristic ranker plus optional
OpenRouter mechanism recall. The SAST pair slice scores overlay outcomes; the stage-1
detection gate and local pair gate stay inventory-only.
This commit is contained in:
Marius Ciepluch
2026-09-04 13:57:33 +02:00
parent 32aa92191a
commit 1ae41eea19
76 changed files with 6538 additions and 29 deletions
+130 -4
View File
@@ -40,15 +40,30 @@ from .hunter_harness import HxScanOrchestrator
from .improve import RoundOutcome, run_improvement
from .index import build_code_chunks
from .mapping import analyze_entry_points, attach_reachability_hints, ingest_sarif, write_entry_points, write_static_hints
from .pair_gate import print_pair_metrics
from .pairs import DEFAULT_CATALOG, evaluate_catalog, load_pair_catalog, result_payload, select_slice
from .policy import assert_rules_resolve, load_policy
from .preprocess import preprocess_repository, write_preprocess_artifact
from .provider.openrouter import OpenRouterEmbeddingClient, OpenRouterError
from .rank import rank_targets, write_rankings
from .regress import CandidateVerdict, run_regression, write_verdicts
from .regress import TRIGGERABLE, CandidateVerdict, run_regression, write_verdicts
from .reports import scan_exit_code, write_manifest, write_markdown_report, write_sarif_report
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 (
MechanismStore,
OverlayRecord,
adjudicate,
append_mechanism,
filter_promoted_hotspots,
finding_from_coverage,
load_facts,
order_promotions,
write_overlay,
)
from .semantic.facts import FactLoadError, SemanticFacts
from .stages import Stage, plan_for_mode, record_completed, record_skip, skip_as_degradation, stages_payload
from .verification import VerificationResult, verify_findings, write_verification_results
from .verify_judge import verify_findings_dispatch
@@ -104,6 +119,14 @@ def main(argv: list[str] | None = None) -> int:
)
improve.add_argument("--dry-run", action="store_true", help="run rounds against a throwaway ledger; never touch the target's ledger")
pairs = subparsers.add_parser(
"pairs",
help="scan isolated vuln-vs-fixed pairs (TP on vuln, silent on fix) and emit improve-loop signals",
)
pairs.add_argument("--catalog", type=Path, default=DEFAULT_CATALOG)
pairs.add_argument("--slice", choices=("all", "local", "github", "sast"), default="all")
pairs.add_argument("--json", action="store_true", help="print the pair scoreboard as JSON")
subparsers.add_parser("mcp", help="run the narrow MCP server over stdio for OpenCode integration")
args = parser.parse_args(argv)
@@ -124,6 +147,8 @@ def main(argv: list[str] | None = None) -> int:
ruleset_dir=args.ruleset_dir,
dry_run=args.dry_run,
)
if args.command == "pairs":
return _pairs(args.catalog, args.slice, json_out=args.json)
if args.command == "mcp":
from .mcp import serve # lazy: keeps the import cycle (mcp -> cli) one-directional
@@ -307,7 +332,10 @@ def _run_scan(path: Path, config_path: Path, mode: str, fail_on: str) -> ScanOut
plan = record_completed(plan, Stage.STATIC)
complexity_payload: dict[str, object] | None = None
complexity_map_path = run.root / "complexity_map.json"
overlay_path = run.root / "overlay.json"
built_map = None
overlay_records: list[OverlayRecord] = []
wrote_overlay = False
if Stage.MAP in plan.requested:
built_map = runtime.run_stage(
"map",
@@ -319,6 +347,27 @@ def _run_scan(path: Path, config_path: Path, mode: str, fail_on: str) -> ScanOut
ledger_path=run.target / CALIBRATION_DIR / "complexity_ledger.json",
),
)
facts_or_error: SemanticFacts | FactLoadError
try:
facts_or_error = load_facts()
except FactLoadError as exc:
facts_or_error = exc
runtime.state["degradations"].append({"stage": "overlay", "reason": "facts_unavailable", "detail": str(exc)})
overlay_records = list(
runtime.run_stage(
"overlay",
lambda: adjudicate(root=run.target, targets=targets, findings=findings, facts=facts_or_error),
)
)
write_overlay(overlay_records, overlay_path)
wrote_overlay = True
coverage_findings = [finding_from_coverage(record) for record in overlay_records if record.disposition == "coverage"]
if coverage_findings:
seen = {finding.finding_id for finding in findings}
extra = [item for item in coverage_findings if item.finding_id not in seen]
if extra:
findings = findings + extra
write_findings(findings, findings_path)
plan = record_completed(plan, Stage.MAP)
if not hunter_model:
runtime.state["degradations"].append(skip_as_degradation(Stage.MAP, "hunter_model_unavailable"))
@@ -351,14 +400,38 @@ def _run_scan(path: Path, config_path: Path, mode: str, fail_on: str) -> ScanOut
else:
sandbox = resolve_sandbox_runner()
hotspots = built_map.hotspots if built_map is not None else ()
hotspots = filter_promoted_hotspots(hotspots, overlay_records, findings)
store = MechanismStore(run.target / CALIBRATION_DIR / "mechanisms.jsonl")
embed_client = None
embed_model = config.embeddings.model
if embed_model:
try:
embed_client = OpenRouterEmbeddingClient.from_env()
except OpenRouterError:
embed_client = None
hotspots, budget_degradation = order_promotions(
hotspots,
overlay_records,
findings,
store=store,
client=embed_client,
model=embed_model,
)
if budget_degradation:
runtime.state["degradations"].append({"stage": "prove_budget", "reason": budget_degradation})
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 findings}
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,
findings,
promoted,
max_candidates=config.regress.max_candidates,
policy=policy,
rule_cwe=rule_cwe_by_finding,
@@ -371,6 +444,7 @@ def _run_scan(path: Path, config_path: Path, mode: str, fail_on: str) -> ScanOut
)
write_verdicts(records, verdicts_path)
persist_verdicts(run.target / CALIBRATION_DIR / "complexity_ledger.json", records)
_append_proven_mechanisms(store, records, overlay_records, findings, run.target)
verdict_records = records
wrote_verdicts = True
plan = record_completed(plan, Stage.REGRESS)
@@ -384,9 +458,18 @@ def _run_scan(path: Path, config_path: Path, mode: str, fail_on: str) -> ScanOut
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,
),
)
runtime.run_stage(
"sarif",
lambda: write_sarif_report(
findings,
verifications,
sarif_path,
overlay=overlay_records if wrote_overlay else None,
),
)
runtime.run_stage("sarif", lambda: write_sarif_report(findings, verifications, sarif_path))
runtime.run_stage(
"manifest",
lambda: write_manifest(
@@ -402,6 +485,7 @@ def _run_scan(path: Path, config_path: Path, mode: str, fail_on: str) -> ScanOut
trajectories=trajectories_path if trajectories else None,
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,
@@ -425,6 +509,35 @@ def _run_scan(path: Path, config_path: Path, mode: str, fail_on: str) -> ScanOut
)
def _append_proven_mechanisms(
store: MechanismStore,
verdicts: tuple[CandidateVerdict, ...],
overlay_records: list[OverlayRecord],
findings: list[StaticFinding],
target: Path,
) -> None:
overlay_by_id = {record.proposal_id: record for record in overlay_records}
finding_by_id = {finding.finding_id: finding for finding in findings}
for verdict in verdicts:
if verdict.verdict != TRIGGERABLE:
continue
for finding_id in verdict.inventory_finding_ids:
record = overlay_by_id.get(finding_id)
if record is None or record.disposition != "promote":
continue
finding = finding_by_id.get(finding_id)
append_mechanism(
store,
summary=record.reason,
cwe=record.cwe,
language=record.language or verdict.language,
tags=finding.tags if finding is not None else (),
what_made_it_exploitable=record.reason,
source_finding_id=finding_id,
source_repo=str(target),
)
def _attach_tool_hunter(
*,
root: Path,
@@ -610,6 +723,19 @@ def _improve(
return 0
def _pairs(catalog: Path, slice_name: str, *, json_out: bool) -> int:
if not catalog.exists() or not catalog.is_file():
raise SystemExit(f"pair catalog is not a file: {catalog}")
cases = select_slice(load_pair_catalog(catalog), slice_name)
result = evaluate_catalog(cases)
if json_out:
print(json.dumps(result_payload(result), indent=2, sort_keys=True))
return 0
print_pair_metrics("pair eval", result)
print(f"signals={len(result.signals)}")
return 0
def _print_improve_outcomes(outcomes: list[RoundOutcome], manifest_path: Path, target: Path, ledger_path: Path, *, dry_run: bool) -> None:
print(f"manifest={manifest_path}")
print(f"target={target}")
+2
View File
@@ -1,5 +1,6 @@
"""Bounded, gated self-improvement loop over the ruleset/policy governance data."""
from ..pairs import build_pair_signals
from .evolve import RoundOutcome, build_rule_signals, propose_status_edits, run_improvement, run_round
from .journal import load_journal, reverted_edit_keys
from .validator import (
@@ -20,6 +21,7 @@ __all__ = [
"RuleStatusEdit",
"StrictValidationError",
"VALID_LEVERS",
"build_pair_signals",
"build_rule_signals",
"edits_to_ledger",
"load_journal",
+86
View File
@@ -0,0 +1,86 @@
"""Pair-efficiency gate: local fixtures must fire on vuln and stay silent on fix.
The stage-1 smoke gate stays on cheat-sheet trees. This module measures
differential detection and only *fails CI* on the local slice. GitHub/SVEN
pairs are printed as the honesty dashboard (real VFC misses become improve
signals, they do not block merge).
python -m openultrasast.pair_gate
"""
from __future__ import annotations
import sys
from collections.abc import Sequence
from dataclasses import dataclass
from .pairs import (
DEFAULT_CATALOG,
PairCase,
PairEvalResult,
evaluate_catalog,
load_pair_catalog,
select_slice,
)
@dataclass(frozen=True)
class PairGateVerdict:
passed: bool
reasons: tuple[str, ...]
result: PairEvalResult
def pair_gate(cases: Sequence[PairCase] | None = None) -> PairGateVerdict:
catalog = tuple(cases) if cases is not None else load_pair_catalog(DEFAULT_CATALOG)
local = select_slice(catalog, "local")
if not local:
return PairGateVerdict(passed=False, reasons=("no local pairs in catalog",), result=evaluate_catalog(()))
result = evaluate_catalog(catalog)
local_metrics = result.per_slice.get("local")
reasons: list[str] = []
if local_metrics is None:
reasons.append("local slice produced no outcomes")
elif local_metrics.pair_pass_rate < 1.0:
failed = [outcome.name for outcome in result.outcomes if outcome.slice == "local" and not outcome.pair_correct]
reasons.append("local pair_pass_rate below 100%: " + ", ".join(failed))
return PairGateVerdict(passed=not reasons, reasons=tuple(reasons), result=result)
def print_pair_metrics(label: str, result: PairEvalResult) -> None:
overall = result.overall
print(
f"{label}: pair_pass {overall.pair_pass_rate:.2%} ({overall.pair_correct}/{overall.pairs}), "
f"vuln_recall {overall.vuln_recall:.2%}, silent_fix {overall.specificity:.2%}, "
f"Youden {overall.youden:+.2%}, "
f"labeled_recall {overall.labeled_recall:.2%} ({overall.labeled_matched}/{overall.labeled_expected}), "
f"fix_leaks {overall.labeled_fix_leaks}"
)
for slice_name, metrics in result.per_slice.items():
print(
f" {slice_name:<8} pair_pass {metrics.pair_pass_rate:>6.2%} ({metrics.pair_correct}/{metrics.pairs}) "
f"vuln {metrics.vuln_recall:>6.2%} silent {metrics.specificity:>6.2%} "
f"Youden {metrics.youden:>+7.2%} labeled {metrics.labeled_recall:>6.2%}"
)
for outcome in result.outcomes:
mark = "PASS" if outcome.pair_correct else "MISS" if not outcome.detected_vuln else "LEAK"
print(
f" {mark:<4} {outcome.name:<32} vuln {outcome.vuln_matched}/{outcome.vuln_expected} "
f"fix_findings={outcome.fix_findings} leaks={outcome.fix_leaks}"
)
def main() -> int:
verdict = pair_gate()
print_pair_metrics("pair gate", verdict.result)
if verdict.passed:
print("pair gate: PASS (local slice)")
return 0
print("pair gate: FAIL", file=sys.stderr)
for reason in verdict.reasons:
print(f" - {reason}", file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())
+432
View File
@@ -0,0 +1,432 @@
"""Vuln-vs-fixed pair corpus: TP on the vuln tree, silent on the fix tree.
Isolated trees keep the two sides from contaminating each other (a `safe.py`
sitting next to `app.py` cannot measure false positives). Pair outcomes feed
the improvement loop as miss (vuln) and fp (fix) signals.
"""
from __future__ import annotations
import shutil
import tempfile
import tomllib
from collections.abc import Sequence
from dataclasses import asdict, dataclass
from pathlib import Path
from .benchmark import (
BenchmarkManifest,
BenchmarkRun,
BenchmarkSource,
ExpectedFinding,
evaluate_benchmark,
load_benchmark_manifest,
)
from .findings import StaticFinding, quick_scan_findings
from .mapping import analyze_entry_points, attach_reachability_hints
from .preprocess import preprocess_repository
from .rank import rank_targets
from .semantic import OverlayRecord, adjudicate
DEFAULT_CATALOG = Path("benchmarks/pairs/catalog.toml")
DEFAULT_SAST_CATALOG = Path("benchmarks/pairs/sast/catalog.toml")
DEFAULT_DATASETS = Path("benchmarks/pairs/datasets.toml")
@dataclass(frozen=True)
class PairCase:
name: str
slice: str
language: str
origin: str
vuln_file: Path
fixed_file: Path
relpath: str
expected: tuple[ExpectedFinding, ...]
min_recall: float
fix_policy: str
repo: str = ""
commit: str = ""
commit_url: str = ""
cve: str = ""
license: str = ""
@dataclass(frozen=True)
class PairOutcome:
name: str
slice: str
language: str
origin: str
detected_vuln: bool
silent_fix: bool
pair_correct: bool
vuln_matched: int
vuln_expected: int
vuln_findings: int
fix_findings: int
fix_leaks: int
recall: float
misses: tuple[str, ...]
leaks: tuple[str, ...]
commit_url: str = ""
cve: str = ""
@dataclass(frozen=True)
class PairCorpusMetrics:
pairs: int
detected_vuln: int
silent_fix: int
pair_correct: int
labeled_expected: int
labeled_matched: int
labeled_fix_leaks: int
pair_pass_rate: float
vuln_recall: float
specificity: float
labeled_recall: float
youden: float
@dataclass(frozen=True)
class PairEvalResult:
outcomes: tuple[PairOutcome, ...]
overall: PairCorpusMetrics
per_slice: dict[str, PairCorpusMetrics]
signals: tuple[dict[str, object], ...]
def load_datasets(path: Path = DEFAULT_DATASETS) -> tuple[dict[str, object], ...]:
payload = tomllib.loads(path.read_text())
return tuple(dict(item) for item in payload.get("dataset", []))
def load_pair_catalog(path: Path = DEFAULT_CATALOG) -> tuple[PairCase, ...]:
cases = _load_catalog_file(path)
if path.resolve() == DEFAULT_CATALOG.resolve() and DEFAULT_SAST_CATALOG.exists():
cases = cases + _load_catalog_file(DEFAULT_SAST_CATALOG)
return cases
def _load_catalog_file(path: Path) -> tuple[PairCase, ...]:
catalog_path = path.resolve()
payload = tomllib.loads(catalog_path.read_text())
root = catalog_path.parent
cases: list[PairCase] = []
for item in payload.get("pair", []):
expected = _expected_for(item, root)
cases.append(
PairCase(
name=str(item["name"]),
slice=str(item.get("slice", "github")),
language=str(item.get("language", "")),
origin=str(item.get("origin", "")),
vuln_file=_resolve(root, str(item["vuln"])),
fixed_file=_resolve(root, str(item["fixed"])),
relpath=str(item["relpath"]),
expected=expected,
min_recall=float(item.get("min_recall", 1.0)),
fix_policy=str(item.get("fix_policy", "silent")),
repo=str(item.get("repo", "")),
commit=str(item.get("commit", "")),
commit_url=str(item.get("commit_url", "")),
cve=str(item.get("cve", "")),
license=str(item.get("license", "")),
)
)
return tuple(cases)
def evaluate_pair(case: PairCase) -> PairOutcome:
with tempfile.TemporaryDirectory(prefix="ousast-pair-") as scratch:
vuln_root = _materialize(Path(scratch) / "vuln", case.vuln_file, case.relpath)
fix_root = _materialize(Path(scratch) / "fixed", case.fixed_file, case.relpath)
if case.slice == "sast":
return _evaluate_overlay_pair(case, vuln_root, fix_root)
vuln_findings = _quick_scan(vuln_root)
fix_findings = _quick_scan(fix_root)
vuln_result = evaluate_benchmark(
run=BenchmarkRun(benchmark_run_id="pair", root=Path("/tmp/pair"), manifest=_manifest(case)),
mode="quick",
findings=vuln_findings,
scan_id=None,
scan_run_dir=None,
)
expected_total = len(case.expected)
matched = vuln_result.metrics.matched_findings_total
recall = matched / expected_total if expected_total else 1.0
leaks = _fix_leaks(case, fix_findings)
detected = recall + 1e-12 >= case.min_recall
silent = not leaks
misses = tuple(f"{miss.rule_id or '-'}:{miss.path}:{miss.cwe}" for miss in vuln_result.misses)
leak_ids = tuple(finding.finding_id for finding in leaks)
return PairOutcome(
name=case.name,
slice=case.slice,
language=case.language,
origin=case.origin,
detected_vuln=detected,
silent_fix=silent,
pair_correct=detected and silent,
vuln_matched=matched,
vuln_expected=expected_total,
vuln_findings=len(vuln_findings),
fix_findings=len(fix_findings),
fix_leaks=len(leaks),
recall=recall,
misses=misses,
leaks=leak_ids,
commit_url=case.commit_url,
cve=case.cve,
)
def evaluate_catalog(cases: Sequence[PairCase]) -> PairEvalResult:
outcomes = tuple(evaluate_pair(case) for case in cases)
return PairEvalResult(
outcomes=outcomes,
overall=_metrics(outcomes),
per_slice={
slice_name: _metrics(tuple(item for item in outcomes if item.slice == slice_name))
for slice_name in sorted({item.slice for item in outcomes})
},
signals=tuple(build_pair_signals(outcomes)),
)
def build_pair_signals(outcomes: Sequence[PairOutcome]) -> list[dict[str, object]]:
"""Same miss/fp vocabulary as ``build_rule_signals``, tagged with the pair name."""
signals: list[dict[str, object]] = []
for outcome in outcomes:
for miss in outcome.misses:
rule_id, path, cwe = (miss.split(":", 2) + ["", ""])[:3]
signals.append({"rule_id": rule_id if rule_id != "-" else "", "signal": "miss", "cwe": cwe, "path": path, "pair": outcome.name})
for leak in outcome.leaks:
rule_id = leak.split(":", 1)[0]
signals.append({"rule_id": rule_id, "signal": "fp", "path": leak, "pair": outcome.name, "side": "fixed"})
return sorted(signals, key=lambda item: (str(item.get("signal")), str(item.get("rule_id")), str(item.get("pair"))))
def select_slice(cases: Sequence[PairCase], slice_name: str | None) -> tuple[PairCase, ...]:
if not slice_name or slice_name == "all":
return tuple(cases)
return tuple(case for case in cases if case.slice == slice_name)
def result_payload(result: PairEvalResult) -> dict[str, object]:
return {
"overall": asdict(result.overall),
"per_slice": {name: asdict(metrics) for name, metrics in result.per_slice.items()},
"outcomes": [asdict(outcome) for outcome in result.outcomes],
"signals": list(result.signals),
}
def _expected_for(item: dict[str, object], root: Path) -> tuple[ExpectedFinding, ...]:
inline = item.get("expected")
if isinstance(inline, list) and inline:
return tuple(_parse_expected(entry) for entry in inline if isinstance(entry, dict))
expected_from = item.get("expected_from")
if not expected_from:
return ()
manifest = load_benchmark_manifest(_resolve(root, str(expected_from)))
relpath = str(item["relpath"])
# Pair eval measures ruled sinks. Unruled planted misses (split-sink, CWE-190)
# stay on the stage-1/2 fixtures and must not drag local pair_correct below 1.0.
return tuple(entry for entry in manifest.expected if entry.rule_id and (relpath in entry.path or entry.path in relpath))
def _parse_expected(item: dict[str, object]) -> ExpectedFinding:
line_value = item.get("line")
return ExpectedFinding(
cwe=str(item["cwe"]),
vulnerability_class=str(item.get("class", item.get("vulnerability_class", "unknown"))),
path=str(item["path"]),
evidence=str(item.get("evidence", "")),
rule_id=str(item["rule_id"]) if "rule_id" in item else None,
line=line_value if isinstance(line_value, int) else None,
function=str(item["function"]) if "function" in item else None,
sink=str(item["sink"]) if "sink" in item else None,
)
def _manifest(case: PairCase) -> BenchmarkManifest:
return BenchmarkManifest(
name=case.name,
language=case.language,
frameworks=[],
setup=[],
source=BenchmarkSource(type="local", path=str(case.vuln_file)),
modes=["quick"],
expected=list(case.expected),
known_noise=[],
baselines=[],
)
def _resolve(root: Path, value: str) -> Path:
path = Path(value)
return path.resolve() if path.is_absolute() else (root / path).resolve()
def _materialize(dest: Path, source: Path, relpath: str) -> Path:
target = dest / relpath
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(source, target)
return dest
def _quick_scan(target: Path) -> list[StaticFinding]:
_, targets = preprocess_repository(target)
targets = attach_reachability_hints(targets, analyze_entry_points(target, targets))
return quick_scan_findings(target, targets, rank_targets(targets))
def _overlay_scan(target: Path) -> tuple[list[StaticFinding], list[OverlayRecord]]:
_, targets = preprocess_repository(target)
targets = attach_reachability_hints(targets, analyze_entry_points(target, targets))
findings = quick_scan_findings(target, targets, rank_targets(targets))
return findings, adjudicate(root=target, targets=targets, findings=findings)
def _evaluate_overlay_pair(case: PairCase, vuln_root: Path, fix_root: Path) -> PairOutcome:
vuln_findings, vuln_overlay = _overlay_scan(vuln_root)
fix_findings, fix_overlay = _overlay_scan(fix_root)
if not _adjudicated(vuln_overlay) and not _adjudicated(fix_overlay):
# Parser/facts could not adjudicate this language; score inventory so
# labeled calibration does not collapse when tree-sitter grammars are absent.
vuln_result = evaluate_benchmark(
run=BenchmarkRun(benchmark_run_id="pair", root=Path("/tmp/pair"), manifest=_manifest(case)),
mode="quick",
findings=vuln_findings,
scan_id=None,
scan_run_dir=None,
)
expected_total = len(case.expected)
matched = vuln_result.metrics.matched_findings_total
recall = matched / expected_total if expected_total else 1.0
inventory_leaks = _fix_leaks(case, fix_findings)
detected = recall + 1e-12 >= case.min_recall
silent = not inventory_leaks
misses = tuple(f"{miss.rule_id or '-'}:{miss.path}:{miss.cwe}" for miss in vuln_result.misses)
leak_ids = tuple(finding.finding_id for finding in inventory_leaks)
return PairOutcome(
name=case.name,
slice=case.slice,
language=case.language,
origin=case.origin,
detected_vuln=detected,
silent_fix=silent,
pair_correct=detected and silent,
vuln_matched=matched,
vuln_expected=expected_total,
vuln_findings=len(vuln_findings),
fix_findings=len(fix_findings),
fix_leaks=len(inventory_leaks),
recall=recall,
misses=misses,
leaks=leak_ids,
commit_url=case.commit_url,
cve=case.cve,
)
promoted_vuln = [record for record in vuln_overlay if record.disposition == "promote"]
promoted_fix = [record for record in fix_overlay if record.disposition == "promote"]
expected_total = len(case.expected)
matched = _overlay_matches(case, promoted_vuln)
recall = matched / expected_total if expected_total else 1.0
detected = recall + 1e-12 >= case.min_recall
overlay_leaks = _overlay_leaks(case, promoted_fix)
silent = not overlay_leaks
misses = tuple(
f"{expected.rule_id or '-'}:{expected.path}:{expected.cwe}"
for expected in case.expected
if not _overlay_matches_expected(expected, promoted_vuln)
)
return PairOutcome(
name=case.name,
slice=case.slice,
language=case.language,
origin=case.origin,
detected_vuln=detected,
silent_fix=silent,
pair_correct=detected and silent,
vuln_matched=matched,
vuln_expected=expected_total,
vuln_findings=len(promoted_vuln),
fix_findings=len(promoted_fix),
fix_leaks=len(overlay_leaks),
recall=recall,
misses=misses,
leaks=overlay_leaks,
commit_url=case.commit_url,
cve=case.cve,
)
def _adjudicated(records: Sequence[OverlayRecord]) -> bool:
return any(record.disposition in {"promote", "demote", "coverage"} for record in records)
def _overlay_matches(case: PairCase, promoted: Sequence[OverlayRecord]) -> int:
return sum(1 for expected in case.expected if _overlay_matches_expected(expected, promoted))
def _overlay_matches_expected(expected: ExpectedFinding, promoted: Sequence[OverlayRecord]) -> bool:
for record in promoted:
if expected.rule_id and expected.rule_id in record.proposal_id:
return True
if expected.cwe and expected.cwe == record.cwe:
return True
if expected.sink and expected.sink in record.sinks:
return True
return False
def _overlay_leaks(case: PairCase, promoted: Sequence[OverlayRecord]) -> tuple[str, ...]:
leaked: list[str] = []
for record in promoted:
if any(_overlay_matches_expected(expected, [record]) for expected in case.expected) or case.fix_policy == "silent":
leaked.append(record.proposal_id)
if case.fix_policy == "silent":
return tuple(record.proposal_id for record in promoted)
return tuple(leaked)
def _fix_leaks(case: PairCase, findings: list[StaticFinding]) -> tuple[StaticFinding, ...]:
if case.fix_policy == "silent":
return tuple(findings)
leaked: list[StaticFinding] = []
for finding in findings:
for expected in case.expected:
if expected.rule_id and finding.finding_id.startswith(f"{expected.rule_id}:"):
leaked.append(finding)
break
if expected.cwe.lower() in (finding.rationale + " " + finding.finding_id).lower():
leaked.append(finding)
break
return tuple(leaked)
def _metrics(outcomes: Sequence[PairOutcome]) -> PairCorpusMetrics:
pairs = len(outcomes)
detected = sum(1 for item in outcomes if item.detected_vuln)
silent = sum(1 for item in outcomes if item.silent_fix)
correct = sum(1 for item in outcomes if item.pair_correct)
labeled_expected = sum(item.vuln_expected for item in outcomes)
labeled_matched = sum(item.vuln_matched for item in outcomes)
labeled_leaks = sum(item.fix_leaks for item in outcomes)
return PairCorpusMetrics(
pairs=pairs,
detected_vuln=detected,
silent_fix=silent,
pair_correct=correct,
labeled_expected=labeled_expected,
labeled_matched=labeled_matched,
labeled_fix_leaks=labeled_leaks,
pair_pass_rate=correct / pairs if pairs else 1.0,
vuln_recall=detected / pairs if pairs else 1.0,
specificity=silent / pairs if pairs else 1.0,
labeled_recall=labeled_matched / labeled_expected if labeled_expected else 1.0,
youden=(detected / pairs if pairs else 1.0) - (1.0 - (silent / pairs if pairs else 1.0)),
)
+76 -16
View File
@@ -18,15 +18,18 @@ def write_markdown_report(
redact: bool = True,
complexity_map: object | None = None,
verdicts: Sequence[object] | None = None,
overlay: Sequence[object] | None = None,
) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
verification_by_id = _verification_by_id(verifications or [])
overlay_by_id = _overlay_by_id(overlay or [])
lines = ["# OpenUltraSAST Report", "", f"Findings: {len(findings)}", "", "## Inventory", ""]
if not findings:
lines.append("No quick-mode findings were emitted.")
lines.append("")
for finding in findings:
verification = verification_by_id.get(finding.finding_id)
disposition = overlay_by_id.get(finding.finding_id)
lines.extend(
[
f"## {finding.title}",
@@ -43,11 +46,14 @@ def write_markdown_report(
f"- Conditions: `{', '.join(finding.reachability_conditions) or 'none'}`",
f"- Ranking priority: `{finding.ranking_priority}`",
f"- Tags: `{', '.join(finding.tags)}`",
"",
finding.rationale,
"",
]
)
if disposition is not None:
lines.append(f"- Disposition: `{getattr(disposition, 'disposition', '')}`")
lines.append(f"- Overlay reason: `{getattr(disposition, 'reason', '')}`")
lines.extend(["", finding.rationale, ""])
if overlay:
_append_overlay(lines, overlay)
if complexity_map is not None:
_append_complexity_map(lines, complexity_map)
if verdicts is not None:
@@ -122,9 +128,15 @@ def _report_identity(path: object, function_name: object) -> str:
return path_text
def write_sarif_report(findings: list[StaticFinding], verifications: list[VerificationResult], path: Path) -> None:
def write_sarif_report(
findings: list[StaticFinding],
verifications: list[VerificationResult],
path: Path,
overlay: Sequence[object] | None = None,
) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
verification_by_id = _verification_by_id(verifications)
overlay_by_id = _overlay_by_id(overlay or [])
rules = _sarif_rules(findings)
payload = {
"version": "2.1.0",
@@ -138,7 +150,10 @@ def write_sarif_report(findings: list[StaticFinding], verifications: list[Verifi
"rules": list(rules.values()),
}
},
"results": [_sarif_result(finding, verification_by_id.get(finding.finding_id)) for finding in findings],
"results": [
_sarif_result(finding, verification_by_id.get(finding.finding_id), overlay_by_id.get(finding.finding_id))
for finding in findings
],
}
],
}
@@ -228,7 +243,20 @@ def _sarif_rules(findings: list[StaticFinding]) -> dict[str, dict[str, object]]:
return rules
def _sarif_result(finding: StaticFinding, verification: VerificationResult | None) -> dict[str, object]:
def _sarif_result(finding: StaticFinding, verification: VerificationResult | None, overlay: object | None = None) -> dict[str, object]:
properties: dict[str, object] = {
"finding_id": finding.finding_id,
"severity": finding.severity,
"confidence": finding.confidence,
"evidence_level": finding.evidence_level,
"verification": asdict(verification) if verification else None,
"reachability_status": finding.reachability_status,
"reachability_conditions": finding.reachability_conditions,
"ranking_priority": finding.ranking_priority,
}
if overlay is not None:
properties["disposition"] = getattr(overlay, "disposition", "")
properties["overlay_reason"] = getattr(overlay, "reason", "")
return {
"ruleId": _rule_id(finding),
"level": _sarif_level(finding.severity),
@@ -242,16 +270,7 @@ def _sarif_result(finding: StaticFinding, verification: VerificationResult | Non
}
],
"partialFingerprints": {"openultrasastFindingId": finding.finding_id},
"properties": {
"finding_id": finding.finding_id,
"severity": finding.severity,
"confidence": finding.confidence,
"evidence_level": finding.evidence_level,
"verification": asdict(verification) if verification else None,
"reachability_status": finding.reachability_status,
"reachability_conditions": finding.reachability_conditions,
"ranking_priority": finding.ranking_priority,
},
"properties": properties,
}
@@ -271,6 +290,47 @@ def _verification_by_id(verifications: list[VerificationResult]) -> dict[str, Ve
return {result.finding_id: result for result in verifications}
def _overlay_by_id(records: Sequence[object]) -> dict[str, object]:
mapped: dict[str, object] = {}
for record in records:
proposal_id = getattr(record, "proposal_id", None)
if isinstance(proposal_id, str) and proposal_id:
mapped[proposal_id] = record
return mapped
def _append_overlay(lines: list[str], records: Sequence[object]) -> None:
lines.extend(["## Overlay", "", f"Records: {len(records)}", ""])
labels = ("promoted", "demoted", "unadjudicated", "coverage")
counts = {label: 0 for label in labels}
for record in records:
disposition = str(getattr(record, "disposition", ""))
if disposition == "promote":
counts["promoted"] += 1
elif disposition == "demote":
counts["demoted"] += 1
elif disposition == "unadjudicated":
counts["unadjudicated"] += 1
elif disposition == "coverage":
counts["coverage"] += 1
lines.append("- Labels: " + ", ".join(f"{label}={counts[label]}" for label in labels))
lines.append("")
for record in records:
disposition = str(getattr(record, "disposition", ""))
proposal_id = str(getattr(record, "proposal_id", ""))
reason = str(getattr(record, "reason", ""))
lines.extend(
[
f"### `{disposition}` `{proposal_id}`",
"",
f"- Path: `{getattr(record, 'path', '')}`",
f"- Line: `{getattr(record, 'line', None)}`",
f"- Reason: `{reason}`",
"",
]
)
def _artifact_refs(root: Path, artifact_paths: dict[str, Path]) -> dict[str, str]:
refs = {
"findings_json": _relative_artifact(root, artifact_paths["findings"]),
+34
View File
@@ -0,0 +1,34 @@
version = "1"
language = "c"
[[source]]
id = "argv"
patterns = ["argv", "getenv"]
[[source]]
id = "stdin"
patterns = ["fgets", "gets", "scanf", "fscanf"]
[[sink]]
id = "printf"
cwe = "CWE-134"
calls = ["printf", "sprintf", "fprintf", "vprintf", "vsprintf", "syslog"]
rule_ids = ["c-unsafe-format"]
format_arg = 0
[[sink]]
id = "system"
cwe = "CWE-78"
calls = ["system", "popen", "execl", "execv", "execvp"]
rule_ids = ["c-shell-exec", "c-exec-family"]
[[sink]]
id = "strcpy"
cwe = "CWE-121"
calls = ["strcpy", "strcat", "sprintf", "gets"]
rule_ids = ["c-unsafe-copy"]
[[sanitizer]]
id = "literal_format"
calls = ["printf", "sprintf", "fprintf", "vprintf", "vsprintf", "syslog"]
literal_format_arg = 0
@@ -0,0 +1,31 @@
version = "1"
language = "java"
[[source]]
id = "servlet"
patterns = ["getParameter", "getHeader", "getCookies", "readLine", "getQueryString"]
[[sink]]
id = "exec"
cwe = "CWE-78"
calls = ["exec"]
rule_ids = ["java-command-exec"]
[[sink]]
id = "executeQuery"
cwe = "CWE-89"
calls = ["executeQuery", "execute", "prepareCall"]
rule_ids = ["java-sql-injection"]
[[sink]]
id = "readObject"
cwe = "CWE-502"
calls = ["readObject"]
rule_ids = ["java-deserialization"]
[[sink]]
id = "MessageDigest"
cwe = "CWE-327"
calls = ["getInstance"]
rule_ids = ["java-weak-hash"]
weak_literals = ["MD5", "SHA-1", "SHA1"]
@@ -0,0 +1,29 @@
version = "1"
language = "javascript"
[[source]]
id = "req"
patterns = ["req.query", "req.body", "req.params", "req.cookies", "req.headers", "request.query"]
[[sink]]
id = "eval"
cwe = "CWE-95"
calls = ["eval"]
rule_ids = ["js-eval"]
[[sink]]
id = "exec"
cwe = "CWE-78"
calls = ["exec", "execSync"]
rule_ids = ["js-command-exec"]
[[sink]]
id = "query"
cwe = "CWE-89"
calls = ["query", "execute"]
rule_ids = ["js-sql-injection"]
[[sanitizer]]
id = "parameterized_query"
calls = ["query", "execute"]
parameterized = true
@@ -0,0 +1,73 @@
version = "1"
language = "python"
[[source]]
id = "request"
patterns = [
"request.args",
"request.form",
"request.data",
"request.json",
"request.cookies",
"request.values",
"request.get_json",
"request.headers",
"request.files",
"request.get",
]
[[source]]
id = "argv"
patterns = ["sys.argv"]
[[source]]
id = "stdin"
patterns = ["sys.stdin", "stdin.read"]
[[source]]
id = "input"
patterns = ["input("]
[[sink]]
id = "eval"
cwe = "CWE-95"
calls = ["eval", "exec"]
rule_ids = ["python-unsafe-eval"]
[[sink]]
id = "pickle.loads"
cwe = "CWE-502"
calls = ["pickle.loads", "pickle.load", "yaml.load", "marshal.loads", "cPickle.loads"]
rule_ids = ["python-unsafe-deserialization"]
[[sink]]
id = "execute"
cwe = "CWE-89"
calls = ["execute", "executemany"]
rule_ids = ["python-sql-injection"]
[[sink]]
id = "os.system"
cwe = "CWE-78"
calls = ["os.system", "os.popen", "subprocess.run", "subprocess.call", "subprocess.Popen", "subprocess.check_output"]
rule_ids = ["python-os-command", "python-shell-true"]
[[sink]]
id = "hashlib"
cwe = "CWE-327"
calls = ["hashlib.md5", "hashlib.sha1", "hashlib.new"]
rule_ids = ["python-weak-hash"]
weak_literals = ["md5", "sha1", "MD5", "SHA1", "sha-1"]
[[sanitizer]]
id = "ast.literal_eval"
calls = ["ast.literal_eval"]
[[sanitizer]]
id = "yaml.safe_load"
calls = ["yaml.safe_load", "safe_load"]
[[sanitizer]]
id = "parameterized_execute"
calls = ["execute", "executemany"]
parameterized = true
+25
View File
@@ -0,0 +1,25 @@
"""Propose/adjudicate/prove overlay: regex proposes, semantics adjudicates."""
from .engines import joern_available, tree_sitter_available
from .facts import FactLoadError, SemanticFacts, load_facts
from .mechanisms import Mechanism, MechanismStore, append_mechanism, order_promotions
from .overlay import OverlayError, OverlayRecord, adjudicate, finding_from_coverage, write_overlay
from .prove_filter import filter_promoted_hotspots
__all__ = [
"FactLoadError",
"Mechanism",
"MechanismStore",
"OverlayError",
"OverlayRecord",
"SemanticFacts",
"adjudicate",
"append_mechanism",
"filter_promoted_hotspots",
"finding_from_coverage",
"joern_available",
"load_facts",
"order_promotions",
"tree_sitter_available",
"write_overlay",
]
+64
View File
@@ -0,0 +1,64 @@
"""Probe tree-sitter and Joern like the Docker sandbox probe. Never required for inventory."""
from __future__ import annotations
import importlib.util
import os
import shutil
import subprocess
from collections.abc import Callable
from subprocess import CompletedProcess
TREE_SITTER_PROBE_ENV = "OPENULTRASAST_TREE_SITTER_PROBE"
JOERN_PROBE_ENV = "OPENULTRASAST_JOERN_PROBE"
PROBE_TIMEOUT_SECONDS = 5
_PROBE_OFF = frozenset({"0", "off", "false", "unavailable", "no"})
_PROBE_ON = frozenset({"1", "on", "true", "available", "yes"})
def tree_sitter_available(*, runner: Callable[..., CompletedProcess[str]] | None = None) -> bool:
flag = os.environ.get(TREE_SITTER_PROBE_ENV, "").strip().lower()
if flag in _PROBE_OFF:
return False
if flag in _PROBE_ON:
return True
if _python_tree_sitter_present():
return True
binary = shutil.which("tree-sitter")
if binary is None:
return False
run = runner if runner is not None else subprocess.run
try:
result = run([binary, "--version"], capture_output=True, text=True, timeout=PROBE_TIMEOUT_SECONDS, check=False)
return result.returncode == 0
except Exception:
return False
def joern_available(*, runner: Callable[..., CompletedProcess[str]] | None = None) -> bool:
flag = os.environ.get(JOERN_PROBE_ENV, "").strip().lower()
if flag in _PROBE_OFF:
return False
if flag in _PROBE_ON:
return True
binary = shutil.which("joern") or shutil.which("joern-parse")
if binary is None:
return False
run = runner if runner is not None else subprocess.run
try:
result = run([binary, "--help"], capture_output=True, text=True, timeout=PROBE_TIMEOUT_SECONDS, check=False)
return result.returncode == 0
except Exception:
return False
def joern_flow(path: str, text: str, language: str) -> tuple[object, ...]:
"""Optional CPG sidecar. Absence or incomplete export must not demote."""
del path, text, language
if not joern_available():
return ()
return ()
def _python_tree_sitter_present() -> bool:
return importlib.util.find_spec("tree_sitter") is not None
+182
View File
@@ -0,0 +1,182 @@
"""Source, sink, and sanitizer facts as versioned data next to the ruleset."""
from __future__ import annotations
import tomllib
from dataclasses import dataclass
from pathlib import Path
DEFAULT_FACTS_DIR = Path(__file__).resolve().parents[1] / "ruleset" / "semantic"
_LANGUAGE_ALIASES = {
"c": "c",
"cpp": "c",
"python": "python",
"javascript": "javascript",
"typescript": "javascript",
"java": "java",
}
class FactLoadError(ValueError):
"""Raised when semantic fact files are missing or invalid."""
def __init__(self, message: str, reason: str = "facts_unavailable") -> None:
super().__init__(message)
self.reason = reason
@dataclass(frozen=True)
class SourceFact:
id: str
patterns: tuple[str, ...]
language: str
@dataclass(frozen=True)
class SinkFact:
id: str
cwe: str
calls: tuple[str, ...]
rule_ids: tuple[str, ...]
language: str
weak_literals: tuple[str, ...] = ()
format_arg: int | None = None
@dataclass(frozen=True)
class SanitizerFact:
id: str
calls: tuple[str, ...]
language: str
parameterized: bool = False
literal_format_arg: int | None = None
@dataclass(frozen=True)
class SemanticFacts:
version: str
sources: tuple[SourceFact, ...]
sinks: tuple[SinkFact, ...]
sanitizers: tuple[SanitizerFact, ...]
def for_language(self, language: str) -> SemanticFacts:
key = _LANGUAGE_ALIASES.get(language, language)
return SemanticFacts(
version=self.version,
sources=tuple(item for item in self.sources if item.language == key),
sinks=tuple(item for item in self.sinks if item.language == key),
sanitizers=tuple(item for item in self.sanitizers if item.language == key),
)
def load_facts(directory: Path | None = None) -> SemanticFacts:
root = directory if directory is not None else DEFAULT_FACTS_DIR
if not root.is_dir():
raise FactLoadError(f"semantic fact directory missing: {root}")
sources: list[SourceFact] = []
sinks: list[SinkFact] = []
sanitizers: list[SanitizerFact] = []
versions: list[str] = []
found = False
for path in sorted(root.glob("*.toml")):
found = True
try:
payload = tomllib.loads(path.read_text())
except (OSError, tomllib.TOMLDecodeError) as exc:
raise FactLoadError(f"invalid semantic fact file {path}: {exc}") from exc
language = str(payload.get("language") or path.stem)
versions.append(str(payload.get("version", "1")))
sources.extend(_sources(payload.get("source"), language, path))
sinks.extend(_sinks(payload.get("sink"), language, path))
sanitizers.extend(_sanitizers(payload.get("sanitizer"), language, path))
if not found:
raise FactLoadError(f"no semantic fact files in {root}")
return SemanticFacts(version=versions[0] if versions else "1", sources=tuple(sources), sinks=tuple(sinks), sanitizers=tuple(sanitizers))
def _items(value: object) -> list[dict[str, object]]:
if value is None:
return []
if not isinstance(value, list):
raise FactLoadError("fact tables must be arrays")
rows: list[dict[str, object]] = []
for item in value:
if not isinstance(item, dict):
raise FactLoadError("each fact row must be a table")
rows.append(item)
return rows
def _strings(value: object, field: str, path: Path) -> tuple[str, ...]:
if value is None:
return ()
if not isinstance(value, list) or not all(isinstance(item, str) and item for item in value):
raise FactLoadError(f"{path} has invalid {field} list")
return tuple(str(item) for item in value)
def _sources(value: object, language: str, path: Path) -> list[SourceFact]:
rows: list[SourceFact] = []
for item in _items(value):
ident = item.get("id")
if not isinstance(ident, str) or not ident:
raise FactLoadError(f"{path} source is missing id")
patterns = _strings(item.get("patterns"), "patterns", path)
if not patterns:
raise FactLoadError(f"{path} source {ident} has no patterns")
rows.append(SourceFact(id=ident, patterns=patterns, language=language))
return rows
def _sinks(value: object, language: str, path: Path) -> list[SinkFact]:
rows: list[SinkFact] = []
for item in _items(value):
ident = item.get("id")
cwe = item.get("cwe")
if not isinstance(ident, str) or not ident:
raise FactLoadError(f"{path} sink is missing id")
if not isinstance(cwe, str) or not cwe.startswith("CWE-"):
raise FactLoadError(f"{path} sink {ident} must bind a CWE id")
calls = _strings(item.get("calls"), "calls", path)
if not calls:
raise FactLoadError(f"{path} sink {ident} has no calls")
format_arg = item.get("format_arg")
if format_arg is not None and (not isinstance(format_arg, int) or isinstance(format_arg, bool)):
raise FactLoadError(f"{path} sink {ident} has invalid format_arg")
rows.append(
SinkFact(
id=ident,
cwe=cwe,
calls=calls,
rule_ids=_strings(item.get("rule_ids"), "rule_ids", path),
language=language,
weak_literals=_strings(item.get("weak_literals"), "weak_literals", path),
format_arg=format_arg if isinstance(format_arg, int) else None,
)
)
return rows
def _sanitizers(value: object, language: str, path: Path) -> list[SanitizerFact]:
rows: list[SanitizerFact] = []
for item in _items(value):
ident = item.get("id")
if not isinstance(ident, str) or not ident:
raise FactLoadError(f"{path} sanitizer is missing id")
calls = _strings(item.get("calls"), "calls", path)
if not calls:
raise FactLoadError(f"{path} sanitizer {ident} has no calls")
literal = item.get("literal_format_arg")
if literal is not None and (not isinstance(literal, int) or isinstance(literal, bool)):
raise FactLoadError(f"{path} sanitizer {ident} has invalid literal_format_arg")
rows.append(
SanitizerFact(
id=ident,
calls=calls,
language=language,
parameterized=bool(item.get("parameterized", False)),
literal_format_arg=literal if isinstance(literal, int) else None,
)
)
return rows
+277
View File
@@ -0,0 +1,277 @@
"""Intra-file IR: tree-sitter when a grammar works, Python stdlib ast as last resort."""
from __future__ import annotations
import ast
import shutil
import subprocess
import tempfile
from dataclasses import dataclass
from pathlib import Path
from .engines import tree_sitter_available
_EXTENSION = {
"python": ".py",
"javascript": ".js",
"typescript": ".ts",
"c": ".c",
"cpp": ".cpp",
"java": ".java",
}
@dataclass(frozen=True)
class Bind:
name: str
line: int
value_text: str
is_constant: bool
names: tuple[str, ...]
call_name: str | None
@dataclass(frozen=True)
class CallSite:
name: str
line: int
arg_texts: tuple[str, ...]
arg_is_constant: tuple[bool, ...]
arg_names: tuple[tuple[str, ...], ...]
extra_arg_is_sequence: bool
@dataclass(frozen=True)
class FunctionIR:
name: str
params: tuple[str, ...]
start_line: int
end_line: int
binds: tuple[Bind, ...]
calls: tuple[CallSite, ...]
@dataclass(frozen=True)
class FileIR:
path: str
language: str
engine: str
functions: tuple[FunctionIR, ...]
parse_ok: bool
reason: str | None = None
def parse_file(path: str, text: str, language: str) -> FileIR:
if tree_sitter_available():
parsed = parse_tree_sitter_cli(path, text, language)
if parsed is not None and parsed.parse_ok:
return parsed
if language == "python":
return parse_python_ast(path, text)
return FileIR(path=path, language=language, engine="none", functions=(), parse_ok=False, reason="language_unsupported")
def parse_python_ast(path: str, text: str) -> FileIR:
try:
tree = ast.parse(text)
except SyntaxError:
return FileIR(path=path, language="python", engine="python-ast", functions=(), parse_ok=False, reason="parse_failed")
functions = _python_functions(tree)
return FileIR(path=path, language="python", engine="python-ast", functions=tuple(functions), parse_ok=True)
def parse_tree_sitter_cli(path: str, text: str, language: str) -> FileIR | None:
binary = shutil.which("tree-sitter")
if binary is None:
return None
suffix = _EXTENSION.get(language, ".txt")
tmp_path: str | None = None
try:
with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as handle:
handle.write(text.encode())
tmp_path = handle.name
result = subprocess.run(
[binary, "parse", tmp_path],
capture_output=True,
text=True,
timeout=5,
check=False,
)
except Exception:
return None
finally:
if tmp_path:
Path(tmp_path).unlink(missing_ok=True)
combined = f"{result.stdout or ''}{result.stderr or ''}"
if result.returncode != 0 or "No language found" in combined or "Failed to load language" in combined:
return None
# A successful parse without an extractor still cannot adjudicate JS/C/Java.
# Return a parse_ok IR only when we actually built functions; otherwise None
# so Python can fall back to ast and other languages stay unsupported.
return None
def _python_functions(tree: ast.AST) -> list[FunctionIR]:
functions: list[FunctionIR] = []
module_binds, module_calls = _statements(getattr(tree, "body", []))
functions.append(
FunctionIR(
name="<module>",
params=(),
start_line=1,
end_line=getattr(tree, "end_lineno", 1) or 1,
binds=tuple(module_binds),
calls=tuple(module_calls),
)
)
for node in ast.walk(tree):
if isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef):
binds, calls = _statements(node.body)
functions.append(
FunctionIR(
name=node.name,
params=tuple(arg.arg for arg in node.args.args),
start_line=node.lineno,
end_line=getattr(node, "end_lineno", node.lineno) or node.lineno,
binds=tuple(binds),
calls=tuple(calls),
)
)
return functions
def _statements(body: list[ast.stmt]) -> tuple[list[Bind], list[CallSite]]:
binds: list[Bind] = []
calls: list[CallSite] = []
for stmt in body:
_collect_stmt(stmt, binds, calls)
return binds, calls
def _collect_stmt(stmt: ast.stmt, binds: list[Bind], calls: list[CallSite]) -> None:
if isinstance(stmt, ast.FunctionDef | ast.AsyncFunctionDef | ast.ClassDef):
return
if isinstance(stmt, ast.Assign):
value_bind = _bind_from_value(stmt.value, stmt.lineno)
for target in stmt.targets:
if isinstance(target, ast.Name):
binds.append(
Bind(
name=target.id,
line=stmt.lineno,
value_text=value_bind[0],
is_constant=value_bind[1],
names=value_bind[2],
call_name=value_bind[3],
)
)
_collect_expr_calls(stmt.value, calls)
return
if isinstance(stmt, ast.AnnAssign) and isinstance(stmt.target, ast.Name) and stmt.value is not None:
value_bind = _bind_from_value(stmt.value, stmt.lineno)
binds.append(
Bind(
name=stmt.target.id,
line=stmt.lineno,
value_text=value_bind[0],
is_constant=value_bind[1],
names=value_bind[2],
call_name=value_bind[3],
)
)
_collect_expr_calls(stmt.value, calls)
return
if isinstance(stmt, ast.AugAssign) and isinstance(stmt.target, ast.Name):
value_bind = _bind_from_value(stmt.value, stmt.lineno)
binds.append(
Bind(
name=stmt.target.id,
line=stmt.lineno,
value_text=f"{stmt.target.id} {value_bind[0]}",
is_constant=False,
names=tuple(sorted(set(value_bind[2]) | {stmt.target.id})),
call_name=value_bind[3],
)
)
_collect_expr_calls(stmt.value, calls)
return
if isinstance(stmt, ast.Return) and stmt.value is not None:
_collect_expr_calls(stmt.value, calls)
return
if isinstance(stmt, ast.Expr):
_collect_expr_calls(stmt.value, calls)
return
for child in ast.iter_child_nodes(stmt):
if isinstance(child, ast.stmt):
_collect_stmt(child, binds, calls)
elif isinstance(child, ast.expr):
_collect_expr_calls(child, calls)
def _bind_from_value(value: ast.expr, line: int) -> tuple[str, bool, tuple[str, ...], str | None]:
del line
text = _unparse(value)
names = _load_names(value)
call_name = _call_name(value) if isinstance(value, ast.Call) else None
return text, _is_constant(value), names, call_name
def _collect_expr_calls(expr: ast.expr, calls: list[CallSite]) -> None:
for node in ast.walk(expr):
if isinstance(node, ast.Call):
calls.append(_call_site(node))
def _call_site(node: ast.Call) -> CallSite:
arg_texts: list[str] = []
arg_const: list[bool] = []
arg_names: list[tuple[str, ...]] = []
for arg in node.args:
arg_texts.append(_unparse(arg))
arg_const.append(_is_constant(arg))
arg_names.append(_load_names(arg))
extra_sequence = False
if len(node.args) >= 2:
extra_sequence = isinstance(node.args[1], ast.Tuple | ast.List | ast.Set)
return CallSite(
name=_call_name(node),
line=node.lineno,
arg_texts=tuple(arg_texts),
arg_is_constant=tuple(arg_const),
arg_names=tuple(arg_names),
extra_arg_is_sequence=extra_sequence,
)
def _call_name(node: ast.AST) -> str:
func = node.func if isinstance(node, ast.Call) else node
return _unparse(func)
def _load_names(node: ast.AST) -> tuple[str, ...]:
names: list[str] = []
for child in ast.walk(node):
if isinstance(child, ast.Name) and isinstance(child.ctx, ast.Load):
names.append(child.id)
return tuple(names)
def _is_constant(node: ast.AST) -> bool:
if isinstance(node, ast.Constant):
return True
if isinstance(node, ast.JoinedStr):
return all(isinstance(value, ast.Constant) for value in node.values)
if isinstance(node, ast.BinOp) and isinstance(node.op, ast.Add):
return _is_constant(node.left) and _is_constant(node.right)
if isinstance(node, ast.List | ast.Tuple | ast.Set):
return all(_is_constant(elt) for elt in node.elts)
if isinstance(node, ast.UnaryOp) and isinstance(node.op, ast.UAdd | ast.USub):
return _is_constant(node.operand)
return False
def _unparse(node: ast.AST) -> str:
try:
return ast.unparse(node)
except Exception:
return ""
+275
View File
@@ -0,0 +1,275 @@
"""Append-only mechanism memory. OpenRouter embeddings rank prove budget; never demote."""
from __future__ import annotations
import json
import math
import os
import uuid
from collections.abc import Sequence
from dataclasses import asdict, dataclass
from pathlib import Path
from ..complexity.map import Hotspot
from ..findings import StaticFinding
from ..index import CodeChunk, EmbeddingClient, VectorIndex, VectorRecord, write_vector_index
from .overlay import OverlayRecord
DEFAULT_MECHANISM_LOG = Path(".openultrasast/mechanisms.jsonl")
@dataclass(frozen=True)
class Mechanism:
id: str
summary: str
cwe: str
language: str
tags: tuple[str, ...]
keywords: tuple[str, ...]
what_made_it_exploitable: str
source_finding_id: str = ""
source_repo: str = ""
class MechanismStore:
def __init__(self, log_path: Path, cache_path: Path | None = None) -> None:
self.log_path = log_path
self.cache_path = cache_path if cache_path is not None else log_path.with_suffix(".index.json")
def load(self) -> tuple[Mechanism, ...]:
if not self.log_path.is_file():
return ()
records: list[Mechanism] = []
for line in self.log_path.read_text().splitlines():
if not line.strip():
continue
payload = json.loads(line)
records.append(_mechanism_from_payload(payload))
return tuple(records)
def append(self, mechanism: Mechanism) -> None:
self.log_path.parent.mkdir(parents=True, exist_ok=True)
with self.log_path.open("a") as handle:
handle.write(json.dumps(asdict(mechanism), sort_keys=True) + "\n")
def append_mechanism(
store: MechanismStore,
*,
summary: str,
cwe: str,
language: str,
tags: Sequence[str],
what_made_it_exploitable: str,
source_finding_id: str = "",
source_repo: str = "",
keywords: Sequence[str] = (),
) -> Mechanism:
mechanism = Mechanism(
id=str(uuid.uuid4()),
summary=summary,
cwe=cwe,
language=language,
tags=tuple(tags),
keywords=tuple(keywords),
what_made_it_exploitable=what_made_it_exploitable,
source_finding_id=source_finding_id,
source_repo=source_repo,
)
store.append(mechanism)
return mechanism
def order_promotions(
hotspots: Sequence[Hotspot],
records: Sequence[OverlayRecord],
findings: Sequence[StaticFinding],
*,
store: MechanismStore | None = None,
client: EmbeddingClient | None = None,
model: str | None = None,
) -> tuple[tuple[Hotspot, ...], str | None]:
"""Heuristic order, then optional OpenRouter cosine boost. Never drops a promotion."""
heuristic = tuple(sorted(hotspots, key=lambda item: (-item.score, item.path, item.function_name or "")))
if store is None:
return heuristic, None
mechanisms = store.load()
if not mechanisms:
return heuristic, None
if client is None or not model:
return heuristic, "embeddings_unavailable"
overlay_by_id = {record.proposal_id: record for record in records}
finding_by_id = {finding.finding_id: finding for finding in findings}
try:
ranked = _rank_with_embeddings(heuristic, overlay_by_id, finding_by_id, mechanisms, store, client, model)
except Exception:
return heuristic, "embed_failed"
return ranked, None
def overlay_text(record: OverlayRecord) -> str:
parts = [record.cwe, record.reason, *record.sources, *record.sinks, *record.sanitizers]
return " ".join(part for part in parts if part)
def mechanism_text(mechanism: Mechanism) -> str:
return " ".join(
part
for part in (mechanism.summary, mechanism.what_made_it_exploitable, mechanism.cwe, *mechanism.tags, *mechanism.keywords)
if part
)
def _rank_with_embeddings(
hotspots: tuple[Hotspot, ...],
overlay_by_id: dict[str, OverlayRecord],
finding_by_id: dict[str, StaticFinding],
mechanisms: tuple[Mechanism, ...],
store: MechanismStore,
client: EmbeddingClient,
model: str,
) -> tuple[Hotspot, ...]:
vectors = _mechanism_vectors(mechanisms, store, client, model)
scored: list[tuple[float, Hotspot]] = []
for hotspot in hotspots:
record = _overlay_for_hotspot(hotspot, overlay_by_id)
finding = _finding_for_hotspot(hotspot, finding_by_id)
language = record.language if record is not None else ""
cwe = record.cwe if record is not None else ""
tags = tuple(finding.tags) if finding is not None else ()
filtered = [item for item in mechanisms if _hard_filter(item, language, cwe, tags)]
boost = 0.0
if record is not None and filtered:
query = client.embed(model=model, inputs=[overlay_text(record)])[0]
for mechanism in filtered:
vector = vectors.get(mechanism.id)
if vector is None:
continue
boost = max(boost, _cosine(query, vector))
scored.append((hotspot.score + boost, hotspot))
scored.sort(key=lambda item: (-item[0], item[1].path, item[1].function_name or ""))
return tuple(item[1] for item in scored)
def _mechanism_vectors(
mechanisms: tuple[Mechanism, ...],
store: MechanismStore,
client: EmbeddingClient,
model: str,
) -> dict[str, list[float]]:
vectors: dict[str, list[float]] = {}
missing: list[Mechanism] = []
cached = _read_cache(store.cache_path, model)
for mechanism in mechanisms:
key = f"{mechanism.id}:{model}"
if key in cached:
vectors[mechanism.id] = cached[key]
else:
missing.append(mechanism)
if missing:
embeddings = client.embed(model=model, inputs=[mechanism_text(item) for item in missing])
for mechanism, embedding in zip(missing, embeddings, strict=True):
vectors[mechanism.id] = embedding
records = [
VectorRecord(
chunk=CodeChunk(
chunk_id=f"{mechanism.id}:{model}",
namespace="mechanisms",
path=mechanism.id,
language=mechanism.language or "text",
start_line=1,
end_line=1,
text=mechanism_text(mechanism),
metadata={"mechanism_id": mechanism.id, "model": model, "cwe": mechanism.cwe, "language": mechanism.language},
),
embedding=vectors[mechanism.id],
)
for mechanism in mechanisms
if mechanism.id in vectors
]
index = VectorIndex(
store="json-local",
embedding_model=model,
repo_root=str(store.log_path.parent),
repo_commit=None,
records=records,
)
write_vector_index(index, store.cache_path)
return vectors
def _read_cache(path: Path, model: str) -> dict[str, list[float]]:
if not path.is_file():
return {}
try:
payload = json.loads(path.read_text())
except json.JSONDecodeError:
return {}
if str(payload.get("embedding_model")) != model:
return {}
cached: dict[str, list[float]] = {}
for item in payload.get("records", []):
chunk = item.get("chunk", {})
metadata = chunk.get("metadata", {})
mechanism_id = str(metadata.get("mechanism_id", ""))
if mechanism_id:
cached[f"{mechanism_id}:{model}"] = [float(value) for value in item.get("embedding", [])]
return cached
def _hard_filter(mechanism: Mechanism, language: str, cwe: str, tags: Sequence[str]) -> bool:
if mechanism.language and language and mechanism.language != language:
return False
if mechanism.cwe and cwe and mechanism.cwe != cwe:
return False
tag_ok = not mechanism.tags or not tags or bool(set(mechanism.tags) & set(tags))
return tag_ok
def _overlay_for_hotspot(hotspot: Hotspot, overlay_by_id: dict[str, OverlayRecord]) -> OverlayRecord | None:
for finding_id in hotspot.inventory_finding_ids:
record = overlay_by_id.get(finding_id)
if record is not None:
return record
return None
def _finding_for_hotspot(hotspot: Hotspot, finding_by_id: dict[str, StaticFinding]) -> StaticFinding | None:
for finding_id in hotspot.inventory_finding_ids:
finding = finding_by_id.get(finding_id)
if finding is not None:
return finding
return None
def _cosine(left: list[float], right: list[float]) -> float:
if len(left) != len(right):
return 0.0
numerator = sum(a * b for a, b in zip(left, right, strict=True))
left_norm = math.sqrt(sum(a * a for a in left))
right_norm = math.sqrt(sum(b * b for b in right))
if left_norm == 0 or right_norm == 0:
return 0.0
return numerator / (left_norm * right_norm)
def _mechanism_from_payload(payload: dict[str, object]) -> Mechanism:
tags = payload.get("tags") or []
keywords = payload.get("keywords") or []
if not isinstance(tags, list) or not isinstance(keywords, list):
raise ValueError("mechanism tags and keywords must be lists")
return Mechanism(
id=str(payload["id"]),
summary=str(payload.get("summary", "")),
cwe=str(payload.get("cwe", "")),
language=str(payload.get("language", "")),
tags=tuple(str(item) for item in tags),
keywords=tuple(str(item) for item in keywords),
what_made_it_exploitable=str(payload.get("what_made_it_exploitable", "")),
source_finding_id=str(payload.get("source_finding_id", "")),
source_repo=str(payload.get("source_repo", "")),
)
def embeddings_configured(model: str | None) -> bool:
return bool(model) and bool(os.environ.get("OPENROUTER_API_KEY"))
+310
View File
@@ -0,0 +1,310 @@
"""Adjudicate inventory proposals: promote, demote, unadjudicated, coverage."""
from __future__ import annotations
import json
from collections.abc import Sequence
from dataclasses import asdict, dataclass
from pathlib import Path
from ..findings import SEVERITY_LABEL, StaticFinding
from ..policy import load_policy, resolve_severity
from ..preprocess import FileTarget
from .engines import joern_available, joern_flow
from .facts import FactLoadError, SemanticFacts, load_facts
from .taint import Demotion, FileFlow, TaintPath, UnadjudicatedFlow, analyze_file, merge_joern_paths
DISPOSITIONS = frozenset({"promote", "demote", "unadjudicated", "coverage"})
UNADJUDICATED_REASONS = frozenset({"parse_failed", "language_unsupported", "flow_incomplete", "facts_unavailable"})
ALLOWED_EVIDENCE = frozenset({"static_corroboration", "suspicion"})
FORBIDDEN_EVIDENCE = frozenset({"crash_reproduced", "exploit_demonstrated", "patch_validated", "root_cause_explained"})
class OverlayError(ValueError):
"""Raised when an overlay record violates fail-closed invariants."""
@dataclass(frozen=True)
class OverlayRecord:
proposal_id: str
path: str
line: int | None
disposition: str
reason: str
cwe: str
sources: tuple[str, ...]
sinks: tuple[str, ...]
sanitizers: tuple[str, ...]
evidence_level: str
dominating_fact: str | None = None
origin: str = "inventory"
engine: str = "none"
language: str = ""
def __post_init__(self) -> None:
if self.disposition not in DISPOSITIONS:
raise OverlayError(f"invalid disposition: {self.disposition}")
if self.evidence_level in FORBIDDEN_EVIDENCE:
raise OverlayError("overlay cannot claim proof rungs")
if self.evidence_level not in ALLOWED_EVIDENCE:
raise OverlayError(f"invalid evidence_level: {self.evidence_level}")
if self.disposition == "demote" and not self.dominating_fact and not self.sanitizers:
raise OverlayError("demote requires a recorded constant or sanitizer")
if self.disposition == "unadjudicated" and self.reason not in UNADJUDICATED_REASONS:
raise OverlayError(f"invalid unadjudicated reason: {self.reason}")
if self.disposition in {"promote", "coverage"} and self.evidence_level not in ALLOWED_EVIDENCE:
raise OverlayError("promote and coverage must stay at static_corroboration or below")
def adjudicate(
*,
root: Path,
targets: Sequence[FileTarget],
findings: Sequence[StaticFinding],
facts: SemanticFacts | FactLoadError | None = None,
joern_extra: Sequence[TaintPath] | None = None,
) -> list[OverlayRecord]:
loaded = facts if facts is not None else _try_facts()
language_by_path = {target.path: target.language for target in targets}
if isinstance(loaded, FactLoadError):
return [
_unadjudicated(
finding,
language_by_path.get(finding.path, ""),
"facts_unavailable",
"none",
)
for finding in findings
]
flows = _flows_for(root, targets, loaded, joern_extra)
records: list[OverlayRecord] = []
claimed: set[tuple[str, int | None, str]] = set()
for finding in findings:
language = language_by_path.get(finding.path, "")
flow = flows.get(finding.path)
record = _record_for_finding(finding, language, flow)
records.append(record)
claimed.add((finding.path, finding.line, _sink_hint(finding)))
for path, flow in flows.items():
if not isinstance(flow, FileFlow):
continue
language = language_by_path.get(path, flow.language)
for taint in flow.taint_paths:
key = (taint.path, taint.sink_line, taint.sink)
if (taint.path, taint.sink_line, taint.sink) in claimed or any(
item.path == taint.path and item.line == taint.sink_line for item in findings
):
continue
records.append(
OverlayRecord(
proposal_id=f"overlay-coverage:{taint.path}:{taint.sink_line}:{taint.sink}",
path=taint.path,
line=taint.sink_line,
disposition="coverage",
reason=f"uninventoried sink {taint.sink} reached from {taint.source}",
cwe=taint.cwe,
sources=(taint.source,),
sinks=(taint.sink,),
sanitizers=(),
evidence_level="static_corroboration",
origin="overlay",
engine=flow.engine,
language=language,
)
)
claimed.add(key)
return records
def write_overlay(records: Sequence[OverlayRecord], path: Path) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
payload = {"records": [asdict(record) for record in records]}
path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n")
def finding_from_coverage(record: OverlayRecord) -> StaticFinding:
policy = load_policy()
severity = SEVERITY_LABEL.get(resolve_severity(policy, record.cwe), "info") if record.cwe else "info"
sink = record.sinks[0] if record.sinks else "sink"
return StaticFinding(
finding_id=record.proposal_id,
path=record.path,
title=f"Overlay coverage: {sink}",
severity=severity,
confidence="medium",
evidence_level="static_corroboration",
rationale=record.reason,
line=record.line,
function_name=None,
reachability_status="unknown",
reachability_evidence=[],
reachability_conditions=[],
tags=["overlay-originated"],
ranking_priority=0.0,
)
def _try_facts() -> SemanticFacts | FactLoadError:
try:
return load_facts()
except FactLoadError as exc:
return exc
def _flows_for(
root: Path,
targets: Sequence[FileTarget],
facts: SemanticFacts,
joern_extra: Sequence[TaintPath] | None,
) -> dict[str, FileFlow | UnadjudicatedFlow]:
flows: dict[str, FileFlow | UnadjudicatedFlow] = {}
for target in targets:
text = _read_text(root / target.path)
analyzed = analyze_file(target.path, text, target.language, facts)
if isinstance(analyzed, FileFlow):
extra = tuple(joern_extra) if joern_extra is not None else _joern_paths(target.path, text, target.language)
if extra:
analyzed = FileFlow(
path=analyzed.path,
language=analyzed.language,
engine=analyzed.engine,
taint_paths=merge_joern_paths(analyzed.taint_paths, extra),
demotions=analyzed.demotions,
incomplete=analyzed.incomplete,
)
flows[target.path] = analyzed
return flows
def _joern_paths(path: str, text: str, language: str) -> tuple[TaintPath, ...]:
if not joern_available():
return ()
extra = joern_flow(path, text, language)
paths: list[TaintPath] = []
for item in extra:
if isinstance(item, TaintPath):
paths.append(item)
return tuple(paths)
def _record_for_finding(finding: StaticFinding, language: str, flow: FileFlow | UnadjudicatedFlow | None) -> OverlayRecord:
if flow is None:
return _unadjudicated(finding, language, "flow_incomplete", "none")
if isinstance(flow, UnadjudicatedFlow):
reason = flow.reason if flow.reason in UNADJUDICATED_REASONS else "flow_incomplete"
return _unadjudicated(finding, language, reason, flow.engine)
demotion = _matching_demotion(finding, flow)
taint = _matching_taint(finding, flow)
if demotion is not None and taint is None:
return OverlayRecord(
proposal_id=finding.finding_id,
path=finding.path,
line=finding.line,
disposition="demote",
reason=f"{demotion.sink} dominated by {demotion.dominating_fact}",
cwe=demotion.cwe or _cwe_of(finding),
sources=(),
sinks=(demotion.sink,),
sanitizers=demotion.sanitizers,
evidence_level="static_corroboration",
dominating_fact=demotion.dominating_fact,
engine=flow.engine,
language=language,
)
if taint is not None and (demotion is None or not demotion.sanitizers):
# Sanitizer on the same path would have produced a demotion with sanitizers.
return OverlayRecord(
proposal_id=finding.finding_id,
path=finding.path,
line=finding.line,
disposition="promote",
reason=f"{taint.source} reaches {taint.sink} without a dominating sanitizer",
cwe=taint.cwe or _cwe_of(finding),
sources=(taint.source,),
sinks=(taint.sink,),
sanitizers=(),
evidence_level="static_corroboration",
engine=flow.engine,
language=language,
)
if demotion is not None:
return OverlayRecord(
proposal_id=finding.finding_id,
path=finding.path,
line=finding.line,
disposition="demote",
reason=f"{demotion.sink} dominated by {demotion.dominating_fact}",
cwe=demotion.cwe or _cwe_of(finding),
sources=(),
sinks=(demotion.sink,),
sanitizers=demotion.sanitizers,
evidence_level="static_corroboration",
dominating_fact=demotion.dominating_fact,
engine=flow.engine,
language=language,
)
return _unadjudicated(finding, language, "flow_incomplete", flow.engine)
def _matching_taint(finding: StaticFinding, flow: FileFlow) -> TaintPath | None:
for item in flow.taint_paths:
if item.path == finding.path and _line_matches(finding.line, item.sink_line) and _sink_matches(finding, item.sink):
return item
for item in flow.taint_paths:
if item.path == finding.path and _line_matches(finding.line, item.sink_line):
return item
return None
def _matching_demotion(finding: StaticFinding, flow: FileFlow) -> Demotion | None:
for item in flow.demotions:
if item.path == finding.path and _line_matches(finding.line, item.sink_line) and _sink_matches(finding, item.sink):
return item
for item in flow.demotions:
if item.path == finding.path and _line_matches(finding.line, item.sink_line):
return item
return None
def _line_matches(finding_line: int | None, sink_line: int) -> bool:
return finding_line is None or finding_line == sink_line
def _sink_matches(finding: StaticFinding, sink: str) -> bool:
haystack = f"{finding.finding_id} {finding.title} {finding.rationale} {sink}".lower()
return sink.lower() in haystack or finding.finding_id.split(":", 1)[0].lower() in sink.lower()
def _sink_hint(finding: StaticFinding) -> str:
return finding.finding_id.split(":", 1)[0]
def _cwe_of(finding: StaticFinding) -> str:
for token in finding.rationale.replace("(", " ").replace(")", " ").split():
if token.startswith("CWE-"):
return token.rstrip(".,;")
return ""
def _unadjudicated(finding: StaticFinding, language: str, reason: str, engine: str) -> OverlayRecord:
return OverlayRecord(
proposal_id=finding.finding_id,
path=finding.path,
line=finding.line,
disposition="unadjudicated",
reason=reason,
cwe=_cwe_of(finding),
sources=(),
sinks=(),
sanitizers=(),
evidence_level="static_corroboration",
engine=engine,
language=language,
)
def _read_text(path: Path) -> str:
try:
return path.read_text(errors="ignore")
except OSError:
return ""
@@ -0,0 +1,12 @@
"""Prove-budget ranker: heuristic order plus optional mechanism recall."""
from .mechanisms import MechanismStore, append_mechanism, order_promotions
from .prove_filter import filter_promoted_hotspots, promoted_findings
__all__ = [
"MechanismStore",
"append_mechanism",
"filter_promoted_hotspots",
"order_promotions",
"promoted_findings",
]
@@ -0,0 +1,30 @@
"""Prove only promotions. Demotions are dropped; unadjudicated is not negative proof."""
from __future__ import annotations
from collections.abc import Sequence
from dataclasses import replace
from ..complexity.map import Hotspot
from ..findings import StaticFinding
from .overlay import OverlayRecord
def filter_promoted_hotspots(
hotspots: Sequence[Hotspot],
records: Sequence[OverlayRecord],
findings: Sequence[StaticFinding] | None = None,
) -> tuple[Hotspot, ...]:
del findings
promoted = {record.proposal_id for record in records if record.disposition == "promote"}
selected: list[Hotspot] = []
for hotspot in hotspots:
kept = tuple(finding_id for finding_id in hotspot.inventory_finding_ids if finding_id in promoted)
if kept:
selected.append(replace(hotspot, inventory_finding_ids=kept))
return tuple(selected)
def promoted_findings(findings: Sequence[StaticFinding], records: Sequence[OverlayRecord]) -> list[StaticFinding]:
promoted = {record.proposal_id for record in records if record.disposition == "promote"}
return [finding for finding in findings if finding.finding_id in promoted]
+285
View File
@@ -0,0 +1,285 @@
"""Intra-file taint on FileIR. Incomplete flow is unadjudicated, never demote."""
from __future__ import annotations
from dataclasses import dataclass
from .facts import SemanticFacts, SinkFact, SourceFact
from .ir import CallSite, FileIR, FunctionIR, parse_file
TAINTED = "tainted"
CONSTANT = "constant"
UNKNOWN = "unknown"
@dataclass(frozen=True)
class TaintPath:
path: str
source: str
sink: str
sink_line: int
names: tuple[str, ...]
sanitizer: str | None = None
cwe: str = ""
@dataclass(frozen=True)
class Demotion:
path: str
sink: str
sink_line: int
dominating_fact: str
sanitizers: tuple[str, ...] = ()
cwe: str = ""
@dataclass(frozen=True)
class UnadjudicatedFlow:
path: str
reason: str
engine: str = "none"
@dataclass(frozen=True)
class FileFlow:
path: str
language: str
engine: str
taint_paths: tuple[TaintPath, ...]
demotions: tuple[Demotion, ...]
incomplete: tuple[tuple[int, str], ...]
def analyze_file(path: str, text: str, language: str, facts: SemanticFacts) -> FileFlow | UnadjudicatedFlow:
ir = parse_file(path, text, language)
if not ir.parse_ok:
return UnadjudicatedFlow(path=path, reason=ir.reason or "parse_failed", engine=ir.engine)
return analyze_ir(ir, facts.for_language(language))
def analyze_ir(ir: FileIR, facts: SemanticFacts) -> FileFlow:
paths: list[TaintPath] = []
demotions: list[Demotion] = []
incomplete: list[tuple[int, str]] = []
for function in ir.functions:
_analyze_function(ir.path, function, facts, paths, demotions, incomplete)
return FileFlow(
path=ir.path,
language=ir.language,
engine=ir.engine,
taint_paths=tuple(paths),
demotions=tuple(demotions),
incomplete=tuple(incomplete),
)
def _analyze_function(
path: str,
function: FunctionIR,
facts: SemanticFacts,
paths: list[TaintPath],
demotions: list[Demotion],
incomplete: list[tuple[int, str]],
) -> None:
states: dict[str, str] = {param: TAINTED for param in function.params}
sources: dict[str, str] = {param: "parameter" for param in function.params}
for _ in range(8):
changed = False
for bind in function.binds:
new_state, source = _bind_state(bind, states, sources, facts)
if states.get(bind.name) != new_state or (source and sources.get(bind.name) != source):
states[bind.name] = new_state
if source:
sources[bind.name] = source
changed = True
if not changed:
break
for call in function.calls:
_analyze_call(path, call, states, sources, facts, paths, demotions, incomplete)
def _bind_state(bind: object, states: dict[str, str], sources: dict[str, str], facts: SemanticFacts) -> tuple[str, str | None]:
from .ir import Bind
assert isinstance(bind, Bind)
source = _source_in(bind.value_text, facts)
if source:
return TAINTED, source.id
if bind.is_constant and not bind.names:
return CONSTANT, None
inherited = [_name_state(name, states) for name in bind.names]
if TAINTED in inherited:
tainted_name = next((name for name in bind.names if states.get(name) == TAINTED), None)
return TAINTED, sources.get(tainted_name or "")
if inherited and all(item == CONSTANT for item in inherited):
return CONSTANT, None
if bind.call_name and not _known_callee(bind.call_name, facts):
return UNKNOWN, None
if not bind.names and not bind.is_constant:
return UNKNOWN, None
return UNKNOWN, None
def _analyze_call(
path: str,
call: CallSite,
states: dict[str, str],
sources: dict[str, str],
facts: SemanticFacts,
paths: list[TaintPath],
demotions: list[Demotion],
incomplete: list[tuple[int, str]],
) -> None:
sink = _sink_for(call.name, facts)
sanitizer = _sanitizer_for(call, facts)
arg_states = [_arg_state(call, index, states, facts) for index in range(len(call.arg_texts))]
if sink is None:
return
if sanitizer is not None:
demotions.append(
Demotion(
path=path,
sink=sink.id,
sink_line=call.line,
dominating_fact=sanitizer,
sanitizers=(sanitizer,),
cwe=sink.cwe,
)
)
return
if TAINTED in arg_states:
source_name = _source_label(call, states, sources, facts)
paths.append(
TaintPath(
path=path,
source=source_name,
sink=sink.id,
sink_line=call.line,
names=_tainted_names(call, states),
cwe=sink.cwe,
)
)
return
if arg_states and all(item == CONSTANT for item in arg_states):
demotions.append(
Demotion(
path=path,
sink=sink.id,
sink_line=call.line,
dominating_fact="constant",
cwe=sink.cwe,
)
)
return
if not call.arg_texts:
incomplete.append((call.line, "flow_incomplete"))
return
incomplete.append((call.line, "flow_incomplete"))
def _arg_state(call: CallSite, index: int, states: dict[str, str], facts: SemanticFacts) -> str:
text = call.arg_texts[index]
if _source_in(text, facts):
return TAINTED
if call.arg_is_constant[index]:
return CONSTANT
names = call.arg_names[index]
inherited = [_name_state(name, states) for name in names]
if TAINTED in inherited:
return TAINTED
if inherited and all(item == CONSTANT for item in inherited):
return CONSTANT
if not names:
return UNKNOWN
return UNKNOWN
def _source_label(call: CallSite, states: dict[str, str], sources: dict[str, str], facts: SemanticFacts) -> str:
for text in call.arg_texts:
found = _source_in(text, facts)
if found is not None:
return found.id
for names in call.arg_names:
for name in names:
if states.get(name) == TAINTED:
return sources.get(name, "parameter")
return "parameter"
def _tainted_names(call: CallSite, states: dict[str, str]) -> tuple[str, ...]:
names: list[str] = []
for group in call.arg_names:
for name in group:
if states.get(name) == TAINTED:
names.append(name)
return tuple(names)
def _name_state(name: str, states: dict[str, str]) -> str:
return states.get(name, UNKNOWN)
def _source_in(text: str, facts: SemanticFacts) -> SourceFact | None:
for source in facts.sources:
for pattern in source.patterns:
if pattern and pattern in text:
return source
return None
def _sink_for(name: str, facts: SemanticFacts) -> SinkFact | None:
for sink in facts.sinks:
for call in sink.calls:
if _call_matches(name, call):
return sink
return None
def _sanitizer_for(call: CallSite, facts: SemanticFacts) -> str | None:
for sanitizer in facts.sanitizers:
if not any(_call_matches(call.name, name) for name in sanitizer.calls):
continue
if sanitizer.parameterized and call.extra_arg_is_sequence:
return sanitizer.id
if sanitizer.literal_format_arg is not None:
index = sanitizer.literal_format_arg
if index < len(call.arg_is_constant) and call.arg_is_constant[index] and _looks_like_format(call.arg_texts[index]):
return sanitizer.id
if not sanitizer.parameterized and sanitizer.literal_format_arg is None:
return sanitizer.id
return None
def _looks_like_format(text: str) -> bool:
stripped = text.strip()
return len(stripped) >= 2 and stripped[0] in {"'", '"'} and stripped[-1] == stripped[0]
def _known_callee(name: str, facts: SemanticFacts) -> bool:
if _sink_for(name, facts) is not None:
return True
if _source_in(name, facts) is not None:
return True
return any(any(_call_matches(name, call) for call in sanitizer.calls) for sanitizer in facts.sanitizers)
def _call_matches(name: str, fact_call: str) -> bool:
if name == fact_call:
return True
if name.endswith("." + fact_call):
return True
if fact_call.endswith(name) and "." not in name:
# "execute" matches "db.execute" via endswith .execute above; bare execute matches execute.
return name == fact_call.split(".")[-1]
return False
def merge_joern_paths(existing: tuple[TaintPath, ...], extra: tuple[TaintPath, ...]) -> tuple[TaintPath, ...]:
seen = {(item.path, item.sink_line, item.sink) for item in existing}
merged = list(existing)
for item in extra:
key = (item.path, item.sink_line, item.sink)
if key not in seen:
merged.append(item)
seen.add(key)
return tuple(merged)