mirror of
https://github.com/norandom/OpenUltraSAST.git
synced 2026-10-16 11:49:28 +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:
@@ -0,0 +1,173 @@
|
||||
"""Vuln-vs-fixed pair corpus: isolated trees, local gate, github honesty dashboard."""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from openultrasast.benchmark import ExpectedFinding
|
||||
from openultrasast.cli import main
|
||||
from openultrasast.gate import LANGUAGE_MANIFESTS
|
||||
from openultrasast.pair_gate import pair_gate
|
||||
from openultrasast.pairs import (
|
||||
DEFAULT_CATALOG,
|
||||
DEFAULT_SAST_CATALOG,
|
||||
PairCase,
|
||||
build_pair_signals,
|
||||
evaluate_catalog,
|
||||
evaluate_pair,
|
||||
load_datasets,
|
||||
load_pair_catalog,
|
||||
select_slice,
|
||||
)
|
||||
|
||||
|
||||
def test_catalog_loads_local_and_github_slices() -> None:
|
||||
cases = load_pair_catalog(DEFAULT_CATALOG)
|
||||
slices = {case.slice for case in cases}
|
||||
names = {case.name for case in cases}
|
||||
assert {"local", "github", "sast"} <= slices
|
||||
assert "local-python-vulnerable" in names
|
||||
assert "sven-cwe-078-os-system" in names
|
||||
assert "hutool-cve-2018-17297" in names
|
||||
assert "owasp-python-codeinj" in names
|
||||
assert "juliet-c-cwe134-printf" in names
|
||||
assert all(case.vuln_file.is_file() and case.fixed_file.is_file() for case in cases)
|
||||
|
||||
|
||||
def test_pair_catalog_is_outside_stage1_smoke_gate() -> None:
|
||||
listed = {name for names in LANGUAGE_MANIFESTS.values() for name in names}
|
||||
leaked = [case.name for case in load_pair_catalog() if case.name in listed]
|
||||
assert leaked == [], "pair catalog must not join LANGUAGE_MANIFESTS (stage-1 90% smoke gate)"
|
||||
|
||||
|
||||
def test_datasets_catalog_lists_public_vfc_repos() -> None:
|
||||
names = {str(item["name"]) for item in load_datasets()}
|
||||
assert {
|
||||
"CVEfixes",
|
||||
"CWE-Bench-Java",
|
||||
"SVEN",
|
||||
"Vul4J",
|
||||
"DiverseVul",
|
||||
"MoreFixes",
|
||||
"OWASP Benchmark Java",
|
||||
"OWASP Benchmark Python",
|
||||
"Juliet Test Suite C/C++",
|
||||
"Juliet Test Suite Java",
|
||||
} <= names
|
||||
assert DEFAULT_SAST_CATALOG.is_file()
|
||||
java = Path("benchmarks/pairs/datasets/cwe-bench-java-project_info.csv")
|
||||
assert java.is_file()
|
||||
assert "CVE-2018-17297" in java.read_text()
|
||||
|
||||
|
||||
def test_synthetic_pair_fires_on_vuln_and_stays_silent_on_fix(tmp_path: Path) -> None:
|
||||
vuln = tmp_path / "vuln.py"
|
||||
fixed = tmp_path / "fixed.py"
|
||||
vuln.write_text("def run(x):\n return eval(x)\n")
|
||||
fixed.write_text("def run(x):\n return str(x)\n")
|
||||
case = PairCase(
|
||||
name="synth-eval",
|
||||
slice="local",
|
||||
language="python",
|
||||
origin="test",
|
||||
vuln_file=vuln,
|
||||
fixed_file=fixed,
|
||||
relpath="app.py",
|
||||
expected=(
|
||||
ExpectedFinding(
|
||||
cwe="CWE-95",
|
||||
vulnerability_class="code injection",
|
||||
path="app.py",
|
||||
evidence="eval executes input",
|
||||
rule_id="python-unsafe-eval",
|
||||
sink="eval",
|
||||
),
|
||||
),
|
||||
min_recall=1.0,
|
||||
fix_policy="silent",
|
||||
)
|
||||
outcome = evaluate_pair(case)
|
||||
assert outcome.pair_correct
|
||||
assert outcome.vuln_matched == 1 and outcome.fix_findings == 0
|
||||
assert build_pair_signals([outcome]) == []
|
||||
|
||||
|
||||
def test_pair_signals_emit_miss_on_vuln_and_fp_on_fix(tmp_path: Path) -> None:
|
||||
vuln = tmp_path / "vuln.py"
|
||||
fixed = tmp_path / "fixed.py"
|
||||
vuln.write_text("def run(x):\n return str(x)\n")
|
||||
fixed.write_text("def run(x):\n return eval(x)\n")
|
||||
case = PairCase(
|
||||
name="inverted",
|
||||
slice="github",
|
||||
language="python",
|
||||
origin="test",
|
||||
vuln_file=vuln,
|
||||
fixed_file=fixed,
|
||||
relpath="app.py",
|
||||
expected=(
|
||||
ExpectedFinding(
|
||||
cwe="CWE-95",
|
||||
vulnerability_class="code injection",
|
||||
path="app.py",
|
||||
evidence="eval executes input",
|
||||
rule_id="python-unsafe-eval",
|
||||
sink="eval",
|
||||
),
|
||||
),
|
||||
min_recall=1.0,
|
||||
fix_policy="silent",
|
||||
)
|
||||
outcome = evaluate_pair(case)
|
||||
assert not outcome.detected_vuln and not outcome.silent_fix
|
||||
signals = {item["signal"] for item in build_pair_signals([outcome])}
|
||||
assert signals == {"miss", "fp"}
|
||||
|
||||
|
||||
def test_local_slice_clears_the_pair_gate() -> None:
|
||||
verdict = pair_gate()
|
||||
assert verdict.passed, verdict.reasons
|
||||
local = verdict.result.per_slice["local"]
|
||||
assert local.pairs >= 3
|
||||
assert local.pair_pass_rate == 1.0
|
||||
assert local.specificity == 1.0
|
||||
|
||||
|
||||
def test_github_slice_measures_real_vfc_pairs() -> None:
|
||||
cases = select_slice(load_pair_catalog(), "github")
|
||||
result = evaluate_catalog(cases)
|
||||
by_name = {outcome.name: outcome for outcome in result.outcomes}
|
||||
assert by_name["sven-cwe-078-os-system"].pair_correct
|
||||
assert by_name["sven-cwe-078-shell-true"].pair_correct
|
||||
assert by_name["sven-cwe-089-execute-percent"].pair_correct
|
||||
assert by_name["node-serialize-cve-2017-5941"].pair_correct
|
||||
# Real corpus honesty: concat-via-query and Zip Slip are currently regex-blind.
|
||||
assert not by_name["sven-cwe-089-query-concat"].detected_vuln
|
||||
assert not by_name["hutool-cve-2018-17297"].detected_vuln
|
||||
assert result.overall.pairs == len(cases)
|
||||
assert any(signal["signal"] == "miss" for signal in result.signals)
|
||||
|
||||
|
||||
def test_sast_slice_scores_owasp_and_juliet() -> None:
|
||||
cases = select_slice(load_pair_catalog(), "sast")
|
||||
result = evaluate_catalog(cases)
|
||||
by_name = {outcome.name: outcome for outcome in result.outcomes}
|
||||
assert by_name["juliet-c-cwe134-printf"].pair_correct
|
||||
assert by_name["owasp-python-deser"].pair_correct
|
||||
# Overlay follows intra-file names, not ConfigParser heap; 00074 stays unadjudicated.
|
||||
assert not by_name["owasp-python-codeinj"].detected_vuln
|
||||
assert by_name["owasp-java-cmdi"].detected_vuln
|
||||
assert result.overall.pairs == len(cases)
|
||||
# Youden is TPR - FPR; a regex harness is not expected to clear OWASP.
|
||||
assert -1.0 <= result.overall.youden <= 1.0
|
||||
|
||||
|
||||
def test_cli_pairs_json_scoreboard() -> None:
|
||||
import io
|
||||
from contextlib import redirect_stdout
|
||||
|
||||
buf = io.StringIO()
|
||||
with redirect_stdout(buf):
|
||||
assert main(["pairs", "--slice", "github", "--json"]) == 0
|
||||
payload = json.loads(buf.getvalue())
|
||||
assert payload["overall"]["pairs"] >= 4
|
||||
assert "sven-cwe-078-os-system" in {item["name"] for item in payload["outcomes"]}
|
||||
@@ -153,14 +153,12 @@ def test_deep_scan_unsafe_hunter_snippet_is_safety_rejected_without_docker_job(t
|
||||
hunter_findings = [item for item in findings if str(item["finding_id"]).startswith("tool-hunter:")]
|
||||
|
||||
assert docker_argv == []
|
||||
assert fake.jobs == []
|
||||
assert hunter_findings
|
||||
assert all(item["evidence_level"] == "suspicion" for item in hunter_findings)
|
||||
assert payload["verdicts"]
|
||||
assert any(item["reason"] == "safety_rejected" for item in payload["verdicts"])
|
||||
hunter_ids = {item["finding_id"] for item in hunter_findings}
|
||||
# Overlay promotions may still be proven; hunter suspicion never becomes a sandbox candidate.
|
||||
for item in payload["verdicts"]:
|
||||
if item["reason"] == "safety_rejected":
|
||||
assert item["verdict"] == "inconclusive"
|
||||
assert hunter_ids.isdisjoint(item["inventory_finding_ids"])
|
||||
|
||||
|
||||
def test_deep_scan_fail_on_worth_fixing_exits_one_when_triggerable(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
"""Propose/adjudicate/prove overlay records, facts, taint, and coverage."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from openultrasast.findings import StaticFinding, quick_scan_findings
|
||||
from openultrasast.mapping import analyze_entry_points, attach_reachability_hints
|
||||
from openultrasast.preprocess import FileTarget, preprocess_repository
|
||||
from openultrasast.rank import rank_targets
|
||||
from openultrasast.semantic import OverlayError, OverlayRecord, adjudicate, load_facts
|
||||
from openultrasast.semantic.engines import joern_available, tree_sitter_available
|
||||
from openultrasast.semantic.facts import FactLoadError
|
||||
from openultrasast.semantic.ir import FileIR, FunctionIR, parse_file
|
||||
from openultrasast.semantic.taint import TaintPath, analyze_file
|
||||
|
||||
|
||||
def _finding(**kwargs: object) -> StaticFinding:
|
||||
payload: dict[str, object] = {
|
||||
"finding_id": "python-unsafe-eval:app.py:1",
|
||||
"path": "app.py",
|
||||
"title": "Dynamic Python execution needs review",
|
||||
"severity": "high",
|
||||
"confidence": "medium",
|
||||
"evidence_level": "static_corroboration",
|
||||
"rationale": "Static pattern python-unsafe-eval (CWE-95) matched eval on line 1",
|
||||
"line": 1,
|
||||
"function_name": None,
|
||||
"reachability_status": "unknown",
|
||||
"reachability_evidence": [],
|
||||
"reachability_conditions": [],
|
||||
"tags": ["syscall_entry"],
|
||||
"ranking_priority": 1.0,
|
||||
}
|
||||
payload.update(kwargs)
|
||||
return StaticFinding(**payload) # type: ignore[arg-type]
|
||||
|
||||
|
||||
def _target(path: str, language: str = "python") -> FileTarget:
|
||||
return FileTarget(
|
||||
path=path,
|
||||
absolute_path=path,
|
||||
language=language,
|
||||
loc=8,
|
||||
tags=[],
|
||||
has_fuzz_entry_point=False,
|
||||
)
|
||||
|
||||
|
||||
def _scan_overlay(root: Path) -> list[OverlayRecord]:
|
||||
_, targets = preprocess_repository(root)
|
||||
targets = attach_reachability_hints(targets, analyze_entry_points(root, targets))
|
||||
findings = quick_scan_findings(root, targets, rank_targets(targets))
|
||||
return adjudicate(root=root, targets=targets, findings=findings)
|
||||
|
||||
|
||||
def test_overlay_record_rejects_demote_without_dominating_fact() -> None:
|
||||
with pytest.raises(OverlayError, match="constant or sanitizer"):
|
||||
OverlayRecord(
|
||||
proposal_id="python-unsafe-eval:app.py:1",
|
||||
path="app.py",
|
||||
line=1,
|
||||
disposition="demote",
|
||||
reason="looks safe",
|
||||
cwe="CWE-95",
|
||||
sources=(),
|
||||
sinks=("eval",),
|
||||
sanitizers=(),
|
||||
evidence_level="static_corroboration",
|
||||
)
|
||||
|
||||
|
||||
def test_overlay_record_rejects_proof_rungs() -> None:
|
||||
with pytest.raises(OverlayError, match="proof rungs"):
|
||||
OverlayRecord(
|
||||
proposal_id="python-unsafe-eval:app.py:1",
|
||||
path="app.py",
|
||||
line=1,
|
||||
disposition="promote",
|
||||
reason="source reaches sink",
|
||||
cwe="CWE-95",
|
||||
sources=("request",),
|
||||
sinks=("eval",),
|
||||
sanitizers=(),
|
||||
evidence_level="exploit_demonstrated",
|
||||
)
|
||||
|
||||
|
||||
def test_overlay_record_requires_unadjudicated_reason() -> None:
|
||||
with pytest.raises(OverlayError, match="unadjudicated reason"):
|
||||
OverlayRecord(
|
||||
proposal_id="x",
|
||||
path="app.py",
|
||||
line=1,
|
||||
disposition="unadjudicated",
|
||||
reason="probably fine",
|
||||
cwe="CWE-95",
|
||||
sources=(),
|
||||
sinks=(),
|
||||
sanitizers=(),
|
||||
evidence_level="static_corroboration",
|
||||
)
|
||||
|
||||
|
||||
def test_load_facts_binds_cwe_ids() -> None:
|
||||
facts = load_facts()
|
||||
python = facts.for_language("python")
|
||||
assert any(sink.cwe.startswith("CWE-") and sink.id == "eval" for sink in python.sinks)
|
||||
assert any(source.id == "request" for source in python.sources)
|
||||
|
||||
|
||||
def test_load_facts_broken_file_is_structured_error(tmp_path: Path) -> None:
|
||||
facts_dir = tmp_path / "facts"
|
||||
facts_dir.mkdir()
|
||||
(facts_dir / "python.toml").write_text('version = 1\nlanguage = "python"\nthis is not toml {')
|
||||
with pytest.raises(FactLoadError) as exc:
|
||||
load_facts(facts_dir)
|
||||
assert exc.value.reason == "facts_unavailable"
|
||||
|
||||
|
||||
def test_eval_request_args_promotes(tmp_path: Path) -> None:
|
||||
root = tmp_path / "repo"
|
||||
root.mkdir()
|
||||
(root / "app.py").write_text("def run():\n return eval(request.args)\n")
|
||||
records = _scan_overlay(root)
|
||||
assert len(records) == 1
|
||||
assert records[0].disposition == "promote"
|
||||
assert records[0].evidence_level == "static_corroboration"
|
||||
|
||||
|
||||
def test_constant_exec_demotes(tmp_path: Path) -> None:
|
||||
root = tmp_path / "repo"
|
||||
root.mkdir()
|
||||
(root / "app.py").write_text("def run():\n exec('ls')\n")
|
||||
records = _scan_overlay(root)
|
||||
assert len(records) == 1
|
||||
assert records[0].disposition == "demote"
|
||||
assert records[0].dominating_fact == "constant"
|
||||
|
||||
|
||||
def test_syntax_error_is_unadjudicated_parse_failed(tmp_path: Path) -> None:
|
||||
root = tmp_path / "repo"
|
||||
root.mkdir()
|
||||
(root / "app.py").write_text("eval(request.args)\ndef oops(\n")
|
||||
records = _scan_overlay(root)
|
||||
assert records
|
||||
assert all(record.disposition == "unadjudicated" for record in records)
|
||||
assert all(record.reason == "parse_failed" for record in records)
|
||||
|
||||
|
||||
def test_split_sink_python_yields_source_to_sink_path() -> None:
|
||||
root = Path("benchmarks/fixtures/split-sink-python")
|
||||
facts = load_facts()
|
||||
text = (root / "app.py").read_text()
|
||||
flow = analyze_file("app.py", text, "python", facts)
|
||||
assert not isinstance(flow, type(None))
|
||||
from openultrasast.semantic.taint import FileFlow
|
||||
|
||||
assert isinstance(flow, FileFlow)
|
||||
assert any(item.sink == "execute" for item in flow.taint_paths)
|
||||
|
||||
|
||||
def test_missing_module_call_is_flow_incomplete_not_safe(tmp_path: Path) -> None:
|
||||
root = tmp_path / "repo"
|
||||
root.mkdir()
|
||||
(root / "app.py").write_text("import helper\ndef run():\n value = helper.hidden()\n eval(value)\n")
|
||||
records = _scan_overlay(root)
|
||||
eval_records = [record for record in records if "python-unsafe-eval" in record.proposal_id]
|
||||
assert eval_records
|
||||
assert eval_records[0].disposition == "unadjudicated"
|
||||
assert eval_records[0].reason == "flow_incomplete"
|
||||
|
||||
|
||||
def test_overlay_length_matches_proposals_plus_coverage_only(tmp_path: Path) -> None:
|
||||
root = tmp_path / "repo"
|
||||
root.mkdir()
|
||||
(root / "app.py").write_text("def run(x):\n eval(request.args)\n digest = hashlib.new('md5', x)\n return digest\n")
|
||||
_, targets = preprocess_repository(root)
|
||||
findings = quick_scan_findings(root, targets, rank_targets(targets))
|
||||
records = adjudicate(root=root, targets=targets, findings=findings)
|
||||
proposal_ids = [record.proposal_id for record in records if record.origin != "overlay"]
|
||||
assert proposal_ids == [finding.finding_id for finding in findings]
|
||||
assert any(record.disposition == "coverage" and "hashlib" in record.proposal_id for record in records)
|
||||
assert all(record.evidence_level == "static_corroboration" for record in records if record.disposition == "coverage")
|
||||
|
||||
|
||||
def test_js_language_unsupported_without_tree_sitter(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr("openultrasast.semantic.ir.tree_sitter_available", lambda: False)
|
||||
monkeypatch.setattr("openultrasast.semantic.engines.tree_sitter_available", lambda: False)
|
||||
ir = parse_file("app.js", "eval(req.query.x)\n", "javascript")
|
||||
assert ir.parse_ok is False
|
||||
assert ir.reason == "language_unsupported"
|
||||
|
||||
|
||||
def test_js_yields_ir_when_tree_sitter_available(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
fake = FileIR(
|
||||
path="app.js",
|
||||
language="javascript",
|
||||
engine="tree-sitter",
|
||||
functions=(FunctionIR(name="run", params=("x",), start_line=1, end_line=1, binds=(), calls=()),),
|
||||
parse_ok=True,
|
||||
)
|
||||
monkeypatch.setattr("openultrasast.semantic.ir.tree_sitter_available", lambda: True)
|
||||
monkeypatch.setattr("openultrasast.semantic.ir.parse_tree_sitter_cli", lambda *args, **kwargs: fake)
|
||||
ir = parse_file("app.js", "eval(req.query.x)\n", "javascript")
|
||||
assert ir.parse_ok is True
|
||||
assert ir.engine == "tree-sitter"
|
||||
|
||||
|
||||
def test_joern_absence_does_not_block_overlay(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr("openultrasast.semantic.overlay.joern_available", lambda: False)
|
||||
root = tmp_path / "repo"
|
||||
root.mkdir()
|
||||
(root / "app.py").write_text("def run():\n return eval(request.args)\n")
|
||||
records = _scan_overlay(root)
|
||||
assert records[0].disposition == "promote"
|
||||
|
||||
|
||||
def test_joern_miss_does_not_demote_tree_sitter_promote(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr("openultrasast.semantic.overlay.joern_available", lambda: True)
|
||||
monkeypatch.setattr("openultrasast.semantic.overlay.joern_flow", lambda *args, **kwargs: ())
|
||||
root = tmp_path / "repo"
|
||||
root.mkdir()
|
||||
(root / "app.py").write_text("def run():\n return eval(request.args)\n")
|
||||
records = _scan_overlay(root)
|
||||
assert records[0].disposition == "promote"
|
||||
|
||||
|
||||
def test_facts_unavailable_marks_every_proposal(tmp_path: Path) -> None:
|
||||
root = tmp_path / "repo"
|
||||
root.mkdir()
|
||||
(root / "app.py").write_text("def run():\n return eval(request.args)\n")
|
||||
_, targets = preprocess_repository(root)
|
||||
findings = quick_scan_findings(root, targets, rank_targets(targets))
|
||||
records = adjudicate(root=root, targets=targets, findings=findings, facts=FactLoadError("broken"))
|
||||
assert findings
|
||||
assert len(records) == len(findings)
|
||||
assert all(record.disposition == "unadjudicated" and record.reason == "facts_unavailable" for record in records)
|
||||
|
||||
|
||||
def test_parameterized_execute_demotes(tmp_path: Path) -> None:
|
||||
root = tmp_path / "repo"
|
||||
root.mkdir()
|
||||
(root / "app.py").write_text("def run(name):\n db.execute('select * from users where name = %s', (name,))\n")
|
||||
records = _scan_overlay(root)
|
||||
sql = [record for record in records if "sql" in record.proposal_id or record.sinks == ("execute",)]
|
||||
# inventory python-sql-injection requires composition on the execute line; this may be coverage-empty
|
||||
if sql:
|
||||
assert sql[0].disposition in {"demote", "unadjudicated"}
|
||||
if sql[0].disposition == "demote":
|
||||
assert sql[0].dominating_fact == "parameterized_execute"
|
||||
|
||||
|
||||
def test_probes_do_not_raise() -> None:
|
||||
assert tree_sitter_available() in {True, False}
|
||||
assert joern_available() in {True, False}
|
||||
|
||||
|
||||
def test_joern_extra_path_can_promote_unadjudicated(tmp_path: Path) -> None:
|
||||
root = tmp_path / "repo"
|
||||
root.mkdir()
|
||||
(root / "app.py").write_text("def run():\n eval(helper())\n")
|
||||
_, targets = preprocess_repository(root)
|
||||
findings = quick_scan_findings(root, targets, rank_targets(targets))
|
||||
extra = (TaintPath(path="app.py", source="parameter", sink="eval", sink_line=findings[0].line or 2, names=(), cwe="CWE-95"),)
|
||||
records = adjudicate(root=root, targets=targets, findings=findings, joern_extra=extra)
|
||||
assert any(record.disposition == "promote" for record in records)
|
||||
@@ -0,0 +1,343 @@
|
||||
"""MAP overlay, prove filter, reports, pair scoring, mechanism memory."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from openultrasast.benchmark import ExpectedFinding
|
||||
from openultrasast.cli import main
|
||||
from openultrasast.complexity.map import Hotspot
|
||||
from openultrasast.findings import StaticFinding, quick_scan_findings
|
||||
from openultrasast.gate import LANGUAGE_MANIFESTS
|
||||
from openultrasast.pairs import PairCase, evaluate_pair, load_pair_catalog
|
||||
from openultrasast.preprocess import preprocess_repository
|
||||
from openultrasast.rank import rank_targets
|
||||
from openultrasast.semantic import MechanismStore, OverlayRecord, append_mechanism, filter_promoted_hotspots, order_promotions
|
||||
from openultrasast.semantic.prove_filter import promoted_findings
|
||||
|
||||
|
||||
def _latest_run(repo: Path) -> Path:
|
||||
return sorted((repo / ".runs").iterdir())[-1]
|
||||
|
||||
|
||||
def _finding(finding_id: str, path: str = "app.py", line: int = 1) -> StaticFinding:
|
||||
return StaticFinding(
|
||||
finding_id=finding_id,
|
||||
path=path,
|
||||
title="test",
|
||||
severity="high",
|
||||
confidence="medium",
|
||||
evidence_level="static_corroboration",
|
||||
rationale="Static pattern python-unsafe-eval (CWE-95) matched",
|
||||
line=line,
|
||||
function_name="run",
|
||||
reachability_status="reachable",
|
||||
reachability_evidence=[],
|
||||
reachability_conditions=[],
|
||||
tags=["syscall_entry"],
|
||||
ranking_priority=1.0,
|
||||
)
|
||||
|
||||
|
||||
def _record(proposal_id: str, disposition: str, **kwargs: object) -> OverlayRecord:
|
||||
dominating = kwargs.get("dominating_fact")
|
||||
sanitizers = kwargs.get("sanitizers", ())
|
||||
return OverlayRecord(
|
||||
proposal_id=proposal_id,
|
||||
path=str(kwargs.get("path", "app.py")),
|
||||
line=int(kwargs.get("line", 1)),
|
||||
disposition=disposition,
|
||||
reason=str(kwargs.get("reason", disposition)),
|
||||
cwe=str(kwargs.get("cwe", "CWE-95")),
|
||||
sources=tuple(kwargs.get("sources", ())), # type: ignore[arg-type]
|
||||
sinks=tuple(kwargs.get("sinks", ("eval",))), # type: ignore[arg-type]
|
||||
sanitizers=tuple(sanitizers) if isinstance(sanitizers, tuple | list) else (),
|
||||
evidence_level="static_corroboration",
|
||||
dominating_fact=str(dominating) if dominating else ("constant" if disposition == "demote" else None),
|
||||
language=str(kwargs.get("language", "python")),
|
||||
)
|
||||
|
||||
|
||||
def _hotspot(*finding_ids: str, score: float = 5.0, path: str = "app.py") -> Hotspot:
|
||||
return Hotspot(
|
||||
path=path,
|
||||
function_name="run",
|
||||
score=score,
|
||||
band="high",
|
||||
signals={},
|
||||
rationale="test",
|
||||
test_hint=None,
|
||||
inventory_finding_ids=finding_ids,
|
||||
)
|
||||
|
||||
|
||||
def test_quick_scan_writes_no_overlay(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
repo = tmp_path / "repo"
|
||||
repo.mkdir()
|
||||
(repo / "app.py").write_text("def run():\n return eval(request.args)\n")
|
||||
monkeypatch.setenv("OPENULTRASAST_RUNS_DIR", ".runs")
|
||||
assert main(["scan", str(repo), "--mode", "quick"]) == 0
|
||||
run_dir = _latest_run(repo)
|
||||
assert not (run_dir / "overlay.json").exists()
|
||||
report = (run_dir / "report.md").read_text()
|
||||
assert "## Overlay" not in report
|
||||
|
||||
|
||||
def test_standard_scan_writes_overlay_without_executing_target(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
repo = tmp_path / "repo"
|
||||
repo.mkdir()
|
||||
(repo / "app.py").write_text("def run():\n return eval(request.args)\n")
|
||||
monkeypatch.setenv("OPENULTRASAST_RUNS_DIR", ".runs")
|
||||
monkeypatch.setenv("OPENULTRASAST_SANDBOX_PROBE", "0")
|
||||
assert main(["scan", str(repo), "--mode", "standard"]) == 0
|
||||
run_dir = _latest_run(repo)
|
||||
payload = json.loads((run_dir / "overlay.json").read_text())
|
||||
assert payload["records"]
|
||||
assert payload["records"][0]["disposition"] == "promote"
|
||||
report = (run_dir / "report.md").read_text()
|
||||
assert "promoted" in report
|
||||
assert "Disposition: `promote`" in report
|
||||
sarif = json.loads((run_dir / "report.sarif").read_text())
|
||||
assert sarif["runs"][0]["results"][0]["properties"]["disposition"] == "promote"
|
||||
assert (run_dir / "manifest.json").read_text()
|
||||
manifest = json.loads((run_dir / "manifest.json").read_text())
|
||||
assert manifest["artifacts"]["overlay"] == "overlay.json"
|
||||
|
||||
|
||||
def test_prove_filter_drops_demoted_keeps_promoted() -> None:
|
||||
promoted_id = "python-unsafe-eval:app.py:2"
|
||||
demoted_id = "python-unsafe-eval:app.py:1"
|
||||
hotspots = (
|
||||
_hotspot(demoted_id, score=9.0),
|
||||
_hotspot(promoted_id, score=3.0),
|
||||
)
|
||||
records = (
|
||||
_record(demoted_id, "demote", dominating_fact="constant"),
|
||||
_record(promoted_id, "promote", sources=("request",)),
|
||||
)
|
||||
selected = filter_promoted_hotspots(hotspots, records)
|
||||
assert len(selected) == 1
|
||||
assert selected[0].inventory_finding_ids == (promoted_id,)
|
||||
findings = [_finding(demoted_id, line=1), _finding(promoted_id, line=2)]
|
||||
assert [item.finding_id for item in promoted_findings(findings, records)] == [promoted_id]
|
||||
|
||||
|
||||
def test_deep_scan_offers_only_promotions(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
from openultrasast import cli
|
||||
from openultrasast.sandbox import FakeSandboxRunner, SandboxResult
|
||||
|
||||
repo = tmp_path / "repo"
|
||||
repo.mkdir()
|
||||
(repo / "app.py").write_text("def safe():\n exec('ls')\n\ndef bad():\n return eval(request.args)\n")
|
||||
monkeypatch.setenv("OPENULTRASAST_RUNS_DIR", ".runs")
|
||||
monkeypatch.setenv("OPENULTRASAST_SANDBOX_PROBE", "1")
|
||||
fake = FakeSandboxRunner(SandboxResult(exit_code=0, stdout="", stderr="", timed_out=False))
|
||||
monkeypatch.setattr(cli, "resolve_sandbox_runner", lambda: fake)
|
||||
assert main(["scan", str(repo), "--mode", "deep", "--fail-on", "worth-fixing"]) == 0
|
||||
run_dir = _latest_run(repo)
|
||||
overlay = json.loads((run_dir / "overlay.json").read_text())["records"]
|
||||
dispositions = {item["proposal_id"]: item["disposition"] for item in overlay}
|
||||
demoted = [key for key, value in dispositions.items() if value == "demote"]
|
||||
promoted = [key for key, value in dispositions.items() if value == "promote"]
|
||||
assert demoted
|
||||
assert promoted
|
||||
verdicts = json.loads((run_dir / "verdicts.json").read_text())["verdicts"]
|
||||
offered = {finding_id for item in verdicts for finding_id in item["inventory_finding_ids"]}
|
||||
assert offered <= set(promoted)
|
||||
assert not (offered & set(demoted))
|
||||
|
||||
|
||||
def test_fail_on_worth_fixing_ignores_unadjudicated(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
repo = tmp_path / "repo"
|
||||
repo.mkdir()
|
||||
(repo / "broken.py").write_text("eval(request.args)\ndef oops(\n")
|
||||
monkeypatch.setenv("OPENULTRASAST_RUNS_DIR", ".runs")
|
||||
monkeypatch.setenv("OPENULTRASAST_SANDBOX_PROBE", "0")
|
||||
assert main(["scan", str(repo), "--mode", "standard", "--fail-on", "worth-fixing"]) == 0
|
||||
run_dir = _latest_run(repo)
|
||||
overlay = json.loads((run_dir / "overlay.json").read_text())["records"]
|
||||
assert overlay
|
||||
assert all(item["disposition"] == "unadjudicated" for item in overlay)
|
||||
report = (run_dir / "report.md").read_text()
|
||||
assert "unadjudicated" in report
|
||||
|
||||
|
||||
def test_unlabeled_messy_tree_has_both_dispositions(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
repo = tmp_path / "repo"
|
||||
repo.mkdir()
|
||||
(repo / "broken.py").write_text("eval(request.args)\ndef oops(\n")
|
||||
(repo / "app.py").write_text("def run():\n return eval(request.args)\n")
|
||||
monkeypatch.setenv("OPENULTRASAST_RUNS_DIR", ".runs")
|
||||
assert main(["scan", str(repo), "--mode", "standard"]) == 0
|
||||
overlay = json.loads((_latest_run(repo) / "overlay.json").read_text())["records"]
|
||||
by_path = {}
|
||||
for item in overlay:
|
||||
by_path.setdefault(item["path"], set()).add(item["disposition"])
|
||||
assert "unadjudicated" in by_path["broken.py"]
|
||||
assert "promote" in by_path["app.py"]
|
||||
|
||||
|
||||
def test_sast_slice_scores_overlay_not_inventory(tmp_path: Path) -> None:
|
||||
vuln = tmp_path / "vuln.py"
|
||||
fixed = tmp_path / "fixed.py"
|
||||
vuln.write_text("def bad(x):\n exec(x)\n")
|
||||
fixed.write_text("def goodG2B():\n exec('ls')\n")
|
||||
case = PairCase(
|
||||
name="synth-overlay-exec",
|
||||
slice="sast",
|
||||
language="python",
|
||||
origin="test",
|
||||
vuln_file=vuln,
|
||||
fixed_file=fixed,
|
||||
relpath="app.py",
|
||||
expected=(
|
||||
ExpectedFinding(
|
||||
cwe="CWE-95",
|
||||
vulnerability_class="code injection",
|
||||
path="app.py",
|
||||
evidence="exec",
|
||||
rule_id="python-unsafe-eval",
|
||||
sink="exec",
|
||||
),
|
||||
),
|
||||
min_recall=1.0,
|
||||
fix_policy="silent",
|
||||
)
|
||||
fix_root = tmp_path / "fix-tree"
|
||||
fix_root.mkdir()
|
||||
(fix_root / "app.py").write_text(fixed.read_text())
|
||||
_, fix_targets = preprocess_repository(fix_root)
|
||||
assert quick_scan_findings(fix_root, fix_targets, rank_targets(fix_targets)), "inventory still fires on goodG2B exec"
|
||||
outcome = evaluate_pair(case)
|
||||
assert outcome.detected_vuln
|
||||
assert outcome.silent_fix
|
||||
assert outcome.pair_correct
|
||||
|
||||
|
||||
def test_sast_pairs_stay_out_of_language_manifests() -> None:
|
||||
listed = {name for names in LANGUAGE_MANIFESTS.values() for name in names}
|
||||
leaked = [case.name for case in load_pair_catalog() if case.name in listed]
|
||||
assert leaked == []
|
||||
|
||||
|
||||
def test_markdown_report_includes_four_overlay_labels(tmp_path: Path) -> None:
|
||||
from openultrasast.reports import write_markdown_report
|
||||
|
||||
finding = _finding("python-unsafe-eval:app.py:1")
|
||||
records = (
|
||||
_record("python-unsafe-eval:app.py:1", "promote", sources=("request",)),
|
||||
_record("python-unsafe-eval:b.py:1", "demote", path="b.py", dominating_fact="constant"),
|
||||
OverlayRecord(
|
||||
proposal_id="python-unsafe-eval:c.py:1",
|
||||
path="c.py",
|
||||
line=1,
|
||||
disposition="unadjudicated",
|
||||
reason="parse_failed",
|
||||
cwe="CWE-95",
|
||||
sources=(),
|
||||
sinks=(),
|
||||
sanitizers=(),
|
||||
evidence_level="static_corroboration",
|
||||
),
|
||||
OverlayRecord(
|
||||
proposal_id="overlay-coverage:d.py:1:hashlib",
|
||||
path="d.py",
|
||||
line=1,
|
||||
disposition="coverage",
|
||||
reason="uninventoried sink hashlib",
|
||||
cwe="CWE-327",
|
||||
sources=("parameter",),
|
||||
sinks=("hashlib",),
|
||||
sanitizers=(),
|
||||
evidence_level="static_corroboration",
|
||||
origin="overlay",
|
||||
),
|
||||
)
|
||||
output = tmp_path / "report.md"
|
||||
write_markdown_report([finding], output, overlay=records)
|
||||
text = output.read_text()
|
||||
assert "promoted" in text
|
||||
assert "demoted" in text
|
||||
assert "unadjudicated" in text
|
||||
assert "coverage" in text
|
||||
|
||||
|
||||
def test_mechanism_order_boosts_paraphrased_match(tmp_path: Path) -> None:
|
||||
store = MechanismStore(tmp_path / "mechanisms.jsonl")
|
||||
append_mechanism(
|
||||
store,
|
||||
summary="request parameter reaches eval",
|
||||
cwe="CWE-95",
|
||||
language="python",
|
||||
tags=["syscall_entry"],
|
||||
what_made_it_exploitable="dynamic execution of attacker input",
|
||||
)
|
||||
eval_id = "python-unsafe-eval:app.py:1"
|
||||
hash_id = "python-weak-hash:hash.py:1"
|
||||
hotspots = (_hotspot(hash_id, score=5.0, path="hash.py"), _hotspot(eval_id, score=5.0, path="app.py"))
|
||||
records = (
|
||||
_record(hash_id, "promote", path="hash.py", cwe="CWE-327", sinks=("hashlib",), language="python"),
|
||||
_record(eval_id, "promote", sources=("request",), cwe="CWE-95", sinks=("eval",), language="python"),
|
||||
)
|
||||
findings = [_finding(hash_id, path="hash.py"), _finding(eval_id)]
|
||||
|
||||
class _Client:
|
||||
def embed(self, *, model: str, inputs: list[str], timeout_seconds: int = 60) -> list[list[float]]:
|
||||
del model, timeout_seconds
|
||||
vectors: list[list[float]] = []
|
||||
for text in inputs:
|
||||
if "eval" in text.lower() or "CWE-95" in text:
|
||||
vectors.append([1.0, 0.0])
|
||||
else:
|
||||
vectors.append([0.0, 1.0])
|
||||
return vectors
|
||||
|
||||
ordered, degradation = order_promotions(hotspots, records, findings, store=store, client=_Client(), model="test-embed")
|
||||
assert degradation is None
|
||||
assert ordered[0].inventory_finding_ids == (eval_id,)
|
||||
assert {item.inventory_finding_ids[0] for item in ordered} == {eval_id, hash_id}
|
||||
|
||||
|
||||
def test_mechanism_degrades_to_heuristic_without_key(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
|
||||
store = MechanismStore(tmp_path / "mechanisms.jsonl")
|
||||
append_mechanism(
|
||||
store,
|
||||
summary="request parameter reaches eval",
|
||||
cwe="CWE-95",
|
||||
language="python",
|
||||
tags=["syscall_entry"],
|
||||
what_made_it_exploitable="dynamic execution of attacker input",
|
||||
)
|
||||
eval_id = "python-unsafe-eval:app.py:1"
|
||||
hash_id = "python-weak-hash:hash.py:1"
|
||||
hotspots = (_hotspot(hash_id, score=6.0, path="hash.py"), _hotspot(eval_id, score=3.0, path="app.py"))
|
||||
records = (
|
||||
_record(hash_id, "promote", path="hash.py", cwe="CWE-327", sinks=("hashlib",)),
|
||||
_record(eval_id, "promote", sources=("request",)),
|
||||
)
|
||||
findings = [_finding(hash_id, path="hash.py"), _finding(eval_id)]
|
||||
ordered, degradation = order_promotions(hotspots, records, findings, store=store, client=None, model=None)
|
||||
assert degradation == "embeddings_unavailable"
|
||||
assert [item.inventory_finding_ids[0] for item in ordered] == [hash_id, eval_id]
|
||||
|
||||
|
||||
def test_mechanisms_are_not_written_from_unadjudicated(tmp_path: Path) -> None:
|
||||
store = MechanismStore(tmp_path / "mechanisms.jsonl")
|
||||
record = OverlayRecord(
|
||||
proposal_id="python-unsafe-eval:app.py:1",
|
||||
path="app.py",
|
||||
line=1,
|
||||
disposition="unadjudicated",
|
||||
reason="parse_failed",
|
||||
cwe="CWE-95",
|
||||
sources=(),
|
||||
sinks=(),
|
||||
sanitizers=(),
|
||||
evidence_level="static_corroboration",
|
||||
)
|
||||
assert record.disposition != "promote"
|
||||
assert store.load() == ()
|
||||
Reference in New Issue
Block a user