mirror of
https://github.com/norandom/OpenUltraSAST.git
synced 2026-10-16 19:59:26 +00:00
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:
+130
-4
@@ -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}")
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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())
|
||||
@@ -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)),
|
||||
)
|
||||
@@ -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"]),
|
||||
|
||||
@@ -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
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 ""
|
||||
@@ -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"))
|
||||
@@ -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]
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user