mirror of
https://github.com/trailofbits/skills.git
synced 2026-09-14 14:28:48 +08:00
cfe5d7b161
* rust-review: add Rust security review plugin Add the rust-review plugin: a comprehensive Rust security review skill with clustered finders covering memory safety, concurrency/data races, panic-induced DoS, FFI/cross-language boundaries, error handling, resource handling, async runtime, and static hygiene. Includes worker, dedup-judge, fp-judge, and planner agents, SARIF generation with rule descriptions and regression tests, deterministic cluster chunking, and Codex skills mapping. Versioned at 1.0.0 and registered in the marketplace, CODEOWNERS, and root README. * c-review: backport rust-review protocol fixes and planner chunking Port the language-agnostic fixes made while building rust-review (which was ported from c-review) back into c-review: - worker/fp-judge: force findings, coverage gate, and REPORT.md to disk via Write instead of returning content in the reply (orchestrator context-bloat hardening); add a pre-complete file-existence check. - worker: move the cache-primer block below the normal self-check and pre-work budget so a non-primer worker does not start under a global "no tool calls" rule. - planner: add --max-passes-per-worker (default 4) with deterministic split_oversized_clusters chunking; skill passes the flag and documents the chunked-subset worker rule. - scripts: add test_split.py and test_generate_sarif.py regression tests. The SARIF test caught a missing RULE_DESCRIPTIONS entry for uninitialized-data, now added. Bump c-review to 1.2.0. * c-review/rust-review: validate artifacts, index-aware SARIF, protocol cleanups - Add validate_artifacts.py (+ tests) to both plugins to check worker shard, coverage, and finding files before accepting completions. - generate_sarif.py now reads the canonical findings-index.txt when present, falling back to findings/*.md only if the index is absent. - Merge the worker step-6 verification paragraphs and drop orchestrator -internal Phase 7 / plan.json jargon in favor of worker-facing stakes. - Tighten uninitialized-read-finder guidance: primitive integers still require initialization. * rust-review/c-review: per-cluster max_passes_per_worker override Lets output-heavy clusters declare a smaller manifest-level max_passes_per_worker so each expensive pass group gets its own worker, validated by a single shared cluster_max_passes_per_worker helper and honored by split_oversized_clusters via an explicit override (0 is rejected rather than silently falling back to the global cap). rust-review opts in concurrency-locking and recursion-dos; c-review ports the capability for parity. validate_artifacts now accepts grouped or repeated --claimed-count values. * rust-review: broaden bug-class coverage with capability-gated clusters Add layout-safety, input-os-safety, and info-disclosure clusters behind new has_packed_repr / has_fs_io capability gates so packed-repr, path, and pointer-exposure passes only run where they apply, and gate unsafe-only passes behind has_unsafe to cut noise on safe crates. Extend existing clusters with new bug classes: RefCell double-borrow panics, unflushed BufWriter, string-comparison bypasses, serialize_struct mismatches, nondeterminism, in-collection key mutation, and destructor-skip cleanup leaks. Fix detector regexes that missed or over-matched real Rust (packed-field borrows, RefCell try_borrow_mut, HashMap substrings, path push, packed inner attrs, fs/path probes) and add a regression test pinning them to snippets. * fix dedup * safety-net check for REPORT.md * on-disk data -> shards reconciliation * on-disk data -> shards reconciliation - v2 * ls -> glob * memory-safety gate * path validation * fix numbers/counting * rm PACKEDREF from FFI cluster prompt, it is in layout-safety * fix unsafe-boundary count * minor fixes for prompts * do not filter unknown-severity findings, just mark them as such * fix minor behavior changes in worker * Correctness: - generate_sarif: clamp startLine >=1 (`:0` produced schema-invalid SARIF) - generate_sarif: don't drop a judged survivor with blank severity - dedup-judge: Tier-2 carry-forward so a primary can't be demoted/orphaned - dedup-judge: crash-recovery unions shards with findings/*.md (empty-shard trap) Robustness: - generate_sarif: skip frontmatter-less files; add originalUriBaseIds Contracts: - SKILL: gate dedup-judge before fp-judge (prevent concurrent-spawn race) - worker: verbatim coverage cells; sub_prompt_paths omitted-not-empty; skip_subclasses reserved; Codebase comma format * improve prompts regexes, add missing deconflictions * prompt factual fixes * fix dozen of small prompt inconsistencies and add missing sections * more prompt fixes, fix retry guard in SKILL, small fixes in agents * dozen more small fixes * final regex fixes * fixes from rust to c-review * agents cannot use write tool for reports (strange cc limitation) - bypass via bash * spawnings agents is capped to 20 - explicit handling for that * fix glob -> read (glob is blocked for agents that has also bash) * fix regex patterns to work with grep * soften output requirements - they were violated anyway * consolidated clusters are no longer chunked — one worker owns the whole cluster, builds its shared Phase-A inventory once, and runs every phase * fix judge finding counting and low-severity guidance * fix metadata * small fix for skipped findings * Carry forward guard for `also_known_as` bucket * Gracefully handle parse_frontmatter error * Extend has_ffi coverage * Broader gate for has_concurrency * Update FFI-safe layout regex to support C, C+packed, and C+u32 in unsafe-boundary and dyn-trait-ffi-finder prompts * Small refine of regex patterns * Improve regex patterns for recursive type detection to include Mutex and RwLock * rm global .codex/rust-review * backport fixes to c-review * merge changes * Backport SARIF merge-survivor + malformed-frontmatter guards to c-review, mark missing locations, fix prompt-regex test extractor, and harden planner/validator scripts across both review plugins * fix pytest * fix global gitignore, adds / and ruff_cache * small fixes from pr-review * small fixes from pr-review - 2 * fix copilot finding --------- Co-authored-by: GrosQuildu <e2.8a.95@gmail.com>
539 lines
20 KiB
Python
539 lines
20 KiB
Python
"""Regression tests for generate_sarif.py rule metadata."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from generate_sarif import RULE_DESCRIPTIONS, build_sarif
|
|
|
|
|
|
def _write_finding(
|
|
findings_dir: Path,
|
|
*,
|
|
fid: str,
|
|
bug_class: str,
|
|
title: str,
|
|
location: str,
|
|
severity: str = "HIGH",
|
|
fp_verdict: str | None = "TRUE_POSITIVE",
|
|
merged_into: str | None = None,
|
|
) -> None:
|
|
findings_dir.mkdir(parents=True, exist_ok=True)
|
|
fp_line = f"fp_verdict: {fp_verdict}\n" if fp_verdict is not None else ""
|
|
merged_line = f"merged_into: {merged_into}\n" if merged_into else ""
|
|
content = f"""---
|
|
id: {fid}
|
|
bug_class: {bug_class}
|
|
title: {title}
|
|
location: {location}
|
|
severity: {severity}
|
|
{fp_line}{merged_line}\
|
|
confidence: High
|
|
attack_vector: Remote
|
|
exploitability: Reliable
|
|
---
|
|
|
|
Body.
|
|
"""
|
|
(findings_dir / f"{fid}.md").write_text(content, encoding="utf-8")
|
|
|
|
|
|
def _rule_by_id(sarif: dict, rule_id: str) -> dict:
|
|
rules = sarif["runs"][0]["tool"]["driver"]["rules"]
|
|
for rule in rules:
|
|
if rule["id"] == rule_id:
|
|
return rule
|
|
raise KeyError(rule_id)
|
|
|
|
|
|
@pytest.fixture
|
|
def output_dir(tmp_path: Path) -> Path:
|
|
(tmp_path / "context.md").write_text(
|
|
"---\nthreat_model: REMOTE\nseverity_filter: all\n---\n",
|
|
encoding="utf-8",
|
|
)
|
|
findings = tmp_path / "findings"
|
|
_write_finding(
|
|
findings,
|
|
fid="BOF-001",
|
|
bug_class="buffer-overflow-unsafe",
|
|
title="Unchecked get_unchecked on attacker index",
|
|
location="src/lib.rs:42",
|
|
)
|
|
_write_finding(
|
|
findings,
|
|
fid="PTRCAST-001",
|
|
bug_class="pointer-cast",
|
|
title="usize to *mut T via as without provenance",
|
|
location="src/ffi.rs:10",
|
|
)
|
|
return tmp_path
|
|
|
|
|
|
def test_build_sarif_uses_rust_rule_descriptions(output_dir: Path) -> None:
|
|
sarif = build_sarif(output_dir)
|
|
rules = sarif["runs"][0]["tool"]["driver"]["rules"]
|
|
rule_ids = {r["id"] for r in rules}
|
|
assert rule_ids == {"buffer-overflow-unsafe", "pointer-cast"}
|
|
|
|
bof = _rule_by_id(sarif, "buffer-overflow-unsafe")
|
|
assert bof["shortDescription"]["text"] == RULE_DESCRIPTIONS["buffer-overflow-unsafe"]
|
|
assert bof["shortDescription"]["text"] != "Buffer Overflow Unsafe"
|
|
|
|
ptr = _rule_by_id(sarif, "pointer-cast")
|
|
assert ptr["shortDescription"]["text"] == RULE_DESCRIPTIONS["pointer-cast"]
|
|
assert ptr["shortDescription"]["text"] != "Pointer Cast"
|
|
|
|
|
|
def test_build_sarif_result_rule_id_matches_bug_class(output_dir: Path) -> None:
|
|
sarif = build_sarif(output_dir)
|
|
results = sarif["runs"][0]["results"]
|
|
assert len(results) == 2
|
|
by_rule = {r["ruleId"]: r for r in results}
|
|
assert by_rule["buffer-overflow-unsafe"]["message"]["text"] == (
|
|
"Unchecked get_unchecked on attacker index"
|
|
)
|
|
assert by_rule["pointer-cast"]["properties"]["bug_class"] == "pointer-cast"
|
|
|
|
|
|
def test_build_sarif_uses_canonical_findings_index(tmp_path: Path) -> None:
|
|
(tmp_path / "context.md").write_text(
|
|
"---\nthreat_model: REMOTE\nseverity_filter: all\n---\n",
|
|
encoding="utf-8",
|
|
)
|
|
findings = tmp_path / "findings"
|
|
_write_finding(
|
|
findings,
|
|
fid="BOF-001",
|
|
bug_class="buffer-overflow-unsafe",
|
|
title="Indexed judged finding",
|
|
location="src/lib.rs:42",
|
|
)
|
|
_write_finding(
|
|
findings,
|
|
fid="UAF-001",
|
|
bug_class="use-after-free",
|
|
title="Orphaned unjudged finding",
|
|
location="src/lib.rs:99",
|
|
fp_verdict=None,
|
|
)
|
|
(tmp_path / "findings-index.txt").write_text(
|
|
f"{findings / 'BOF-001.md'}\n\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
sarif = build_sarif(tmp_path)
|
|
|
|
results = sarif["runs"][0]["results"]
|
|
assert [r["properties"]["finding_id"] for r in results] == ["BOF-001"]
|
|
assert results[0]["properties"]["unjudged"] is False
|
|
|
|
|
|
def test_missing_index_entry_is_skipped_not_crash(tmp_path: Path) -> None:
|
|
"""A stale findings-index.txt entry pointing at a file that no longer exists must
|
|
be skipped with a warning, not raise FileNotFoundError — Phase-8b's safety net
|
|
must still produce REPORT.sarif from the survivors that do exist."""
|
|
(tmp_path / "context.md").write_text(
|
|
"---\nthreat_model: REMOTE\nseverity_filter: all\n---\n", encoding="utf-8"
|
|
)
|
|
findings = tmp_path / "findings"
|
|
_write_finding(
|
|
findings,
|
|
fid="BOF-001",
|
|
bug_class="buffer-overflow-unsafe",
|
|
title="real",
|
|
location="src/a.rs:1",
|
|
)
|
|
(tmp_path / "findings-index.txt").write_text(
|
|
f"{findings / 'BOF-001.md'}\n{findings / 'GHOST-404.md'}\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
results = build_sarif(tmp_path)["runs"][0]["results"]
|
|
assert [r["properties"]["finding_id"] for r in results] == ["BOF-001"]
|
|
|
|
|
|
def test_build_sarif_empty_findings(tmp_path: Path) -> None:
|
|
(tmp_path / "context.md").write_text(
|
|
"---\nthreat_model: REMOTE\nseverity_filter: all\n---\n",
|
|
encoding="utf-8",
|
|
)
|
|
(tmp_path / "findings").mkdir()
|
|
sarif = build_sarif(tmp_path)
|
|
run = sarif["runs"][0]
|
|
assert run["tool"]["driver"]["name"] == "rust-review"
|
|
assert run["tool"]["driver"]["rules"] == []
|
|
assert run["results"] == []
|
|
|
|
|
|
def test_rule_descriptions_cover_manifest_bug_classes() -> None:
|
|
"""Every manifest bug_class should have an explicit SARIF description."""
|
|
import json
|
|
|
|
manifest_path = Path(__file__).resolve().parents[1] / "prompts/clusters/manifest.json"
|
|
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
|
bug_classes = [p["bug_class"] for cluster in manifest["clusters"] for p in cluster["passes"]]
|
|
missing = [bc for bc in bug_classes if bc not in RULE_DESCRIPTIONS]
|
|
assert missing == [], f"missing RULE_DESCRIPTIONS for: {missing}"
|
|
|
|
|
|
def test_unjudged_finding_survives_strict_filter_with_marker(tmp_path: Path) -> None:
|
|
"""A partial-run finding with no fp_verdict must NOT be silently dropped by a
|
|
strict severity_filter; it is surfaced and clearly marked as unvalidated."""
|
|
(tmp_path / "context.md").write_text(
|
|
"---\nthreat_model: REMOTE\nseverity_filter: high\n---\n",
|
|
encoding="utf-8",
|
|
)
|
|
findings = tmp_path / "findings"
|
|
findings.mkdir()
|
|
# No fp_verdict and no severity — exactly what a worker writes before the
|
|
# fp-judge runs. Confidence High infers only MEDIUM severity, so a naive
|
|
# severity filter (high) would otherwise drop it.
|
|
(findings / "UAF-001.md").write_text(
|
|
"---\nid: UAF-001\nbug_class: use-after-free\n"
|
|
"title: Dangling pointer after free\nlocation: src/lib.rs:5\n"
|
|
"confidence: High\n---\n\nBody.\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
results = build_sarif(tmp_path)["runs"][0]["results"]
|
|
|
|
assert len(results) == 1
|
|
result = results[0]
|
|
assert result["properties"]["finding_id"] == "UAF-001"
|
|
assert result["properties"]["unjudged"] is True
|
|
assert result["properties"]["severity_validated"] is False
|
|
assert result["message"]["text"].startswith("[UNVALIDATED SEVERITY")
|
|
|
|
|
|
def test_judged_finding_below_filter_is_still_dropped(tmp_path: Path) -> None:
|
|
"""The unjudged exemption must not leak into judged findings: a judged LOW
|
|
survivor is still filtered out under severity_filter=high."""
|
|
(tmp_path / "context.md").write_text(
|
|
"---\nthreat_model: REMOTE\nseverity_filter: high\n---\n",
|
|
encoding="utf-8",
|
|
)
|
|
findings = tmp_path / "findings"
|
|
_write_finding(
|
|
findings,
|
|
fid="UAF-001",
|
|
bug_class="use-after-free",
|
|
title="Low-sev judged finding",
|
|
location="src/lib.rs:5",
|
|
severity="LOW",
|
|
fp_verdict="TRUE_POSITIVE",
|
|
)
|
|
|
|
assert build_sarif(tmp_path)["runs"][0]["results"] == []
|
|
|
|
|
|
def test_judged_fp_findings_are_dropped(tmp_path: Path) -> None:
|
|
"""fp-judge-rejected findings (FALSE_POSITIVE / LIKELY_FP) must never reach
|
|
SARIF; a TRUE_POSITIVE in the same dir still surfaces. This guards the core
|
|
fp-judge stage — a regression here would ship false positives to users."""
|
|
(tmp_path / "context.md").write_text(
|
|
"---\nthreat_model: REMOTE\nseverity_filter: all\n---\n", encoding="utf-8"
|
|
)
|
|
findings = tmp_path / "findings"
|
|
_write_finding(
|
|
findings,
|
|
fid="BOF-001",
|
|
bug_class="buffer-overflow-unsafe",
|
|
title="real",
|
|
location="src/a.rs:1",
|
|
fp_verdict="TRUE_POSITIVE",
|
|
)
|
|
_write_finding(
|
|
findings,
|
|
fid="BOF-002",
|
|
bug_class="buffer-overflow-unsafe",
|
|
title="false positive",
|
|
location="src/b.rs:1",
|
|
fp_verdict="FALSE_POSITIVE",
|
|
)
|
|
_write_finding(
|
|
findings,
|
|
fid="BOF-003",
|
|
bug_class="buffer-overflow-unsafe",
|
|
title="likely false positive",
|
|
location="src/c.rs:1",
|
|
fp_verdict="LIKELY_FP",
|
|
)
|
|
|
|
results = build_sarif(tmp_path)["runs"][0]["results"]
|
|
assert [r["properties"]["finding_id"] for r in results] == ["BOF-001"]
|
|
|
|
|
|
def test_zero_line_is_clamped_to_one(tmp_path: Path) -> None:
|
|
"""A location ending in :0 must not emit region.startLine 0 — the SARIF schema
|
|
minimum is 1, and a 0 makes the whole REPORT.sarif fail strict validation /
|
|
GitHub code-scanning ingestion."""
|
|
(tmp_path / "context.md").write_text(
|
|
"---\nthreat_model: REMOTE\nseverity_filter: all\n---\n", encoding="utf-8"
|
|
)
|
|
findings = tmp_path / "findings"
|
|
_write_finding(
|
|
findings,
|
|
fid="BOF-001",
|
|
bug_class="buffer-overflow-unsafe",
|
|
title="zero line",
|
|
location="src/lib.rs:0",
|
|
)
|
|
|
|
results = build_sarif(tmp_path)["runs"][0]["results"]
|
|
region = results[0]["locations"][0]["physicalLocation"]["region"]
|
|
assert region["startLine"] == 1
|
|
|
|
|
|
def test_judged_survivor_missing_severity_is_surfaced_not_dropped(tmp_path: Path) -> None:
|
|
"""A judged survivor whose severity the fp-judge failed to write must be
|
|
surfaced (marked unvalidated) even under a strict filter — the safety net must
|
|
not silently delete a confirmed true positive."""
|
|
(tmp_path / "context.md").write_text(
|
|
"---\nthreat_model: REMOTE\nseverity_filter: high\n---\n", encoding="utf-8"
|
|
)
|
|
findings = tmp_path / "findings"
|
|
findings.mkdir()
|
|
(findings / "BOF-001.md").write_text(
|
|
"---\nid: BOF-001\nbug_class: buffer-overflow-unsafe\n"
|
|
"title: Confirmed but severity not written\nlocation: src/lib.rs:5\n"
|
|
"fp_verdict: TRUE_POSITIVE\nconfidence: High\n---\n\nBody.\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
results = build_sarif(tmp_path)["runs"][0]["results"]
|
|
assert len(results) == 1
|
|
result = results[0]
|
|
assert result["properties"]["finding_id"] == "BOF-001"
|
|
assert result["properties"]["severity_validated"] is False
|
|
assert result["message"]["text"].startswith("[UNVALIDATED SEVERITY")
|
|
|
|
|
|
def test_malformed_frontmatter_finding_is_skipped_not_crash(tmp_path: Path) -> None:
|
|
"""Regression for malformed frontmatter: a scalar then a list item on one key
|
|
used to raise AttributeError and abort the run, so no REPORT.sarif was emitted
|
|
at all. The malformed file must be skipped so survivors still surface."""
|
|
(tmp_path / "context.md").write_text(
|
|
"---\nthreat_model: REMOTE\nseverity_filter: all\n---\n", encoding="utf-8"
|
|
)
|
|
findings = tmp_path / "findings"
|
|
_write_finding(
|
|
findings,
|
|
fid="BOF-001",
|
|
bug_class="buffer-overflow-unsafe",
|
|
title="real",
|
|
location="src/a.rs:1",
|
|
)
|
|
# Scalar then list item on one key: parse_frontmatter appends to the scalar.
|
|
(findings / "MALFORMED.md").write_text(
|
|
"---\nid: MALFORMED\nbug_class: use-after-free\n"
|
|
"title: bad frontmatter\nlocation: src/a.rs:42\n"
|
|
" - src/b.rs:88\nseverity: HIGH\n---\n\nBody.\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
run = build_sarif(tmp_path)["runs"][0]
|
|
assert [r["properties"]["finding_id"] for r in run["results"]] == ["BOF-001"]
|
|
# The malformed file is surfaced in the invocation, not only on stderr.
|
|
invocation = run["invocations"][0]
|
|
assert invocation["properties"]["skipped_findings"] == 1
|
|
assert any("MALFORMED" in p for p in invocation["properties"]["skipped_paths"])
|
|
|
|
|
|
def test_frontmatterless_finding_is_skipped_not_phantom(tmp_path: Path) -> None:
|
|
"""A finding file with no parseable frontmatter must be skipped, not emitted as
|
|
a phantom result with ruleId 'unknown' and an empty id/uri."""
|
|
(tmp_path / "context.md").write_text(
|
|
"---\nthreat_model: REMOTE\nseverity_filter: all\n---\n", encoding="utf-8"
|
|
)
|
|
findings = tmp_path / "findings"
|
|
_write_finding(
|
|
findings,
|
|
fid="BOF-001",
|
|
bug_class="buffer-overflow-unsafe",
|
|
title="real",
|
|
location="src/a.rs:1",
|
|
)
|
|
(findings / "broken.md").write_text("no frontmatter here\n", encoding="utf-8")
|
|
|
|
results = build_sarif(tmp_path)["runs"][0]["results"]
|
|
assert [r["properties"]["finding_id"] for r in results] == ["BOF-001"]
|
|
assert all(r["ruleId"] != "unknown" for r in results)
|
|
|
|
|
|
def test_clean_run_reports_zero_skips(output_dir: Path) -> None:
|
|
"""A healthy run records skipped_findings: 0 and adds no skip notifications."""
|
|
invocation = build_sarif(output_dir)["runs"][0]["invocations"][0]
|
|
assert invocation["executionSuccessful"] is True
|
|
assert invocation["properties"]["skipped_findings"] == 0
|
|
assert "skipped_paths" not in invocation["properties"]
|
|
assert "toolExecutionNotifications" not in invocation
|
|
|
|
|
|
def test_skipped_findings_surfaced_in_invocation(tmp_path: Path) -> None:
|
|
"""Dropped finding files (missing index entry + frontmatterless file) must be
|
|
surfaced in the artifact — a count, the paths, and one warning notification
|
|
each — while executionSuccessful stays True and good findings still emit."""
|
|
(tmp_path / "context.md").write_text(
|
|
"---\nthreat_model: REMOTE\nseverity_filter: all\n---\n", encoding="utf-8"
|
|
)
|
|
findings = tmp_path / "findings"
|
|
_write_finding(
|
|
findings,
|
|
fid="BOF-001",
|
|
bug_class="buffer-overflow-unsafe",
|
|
title="real",
|
|
location="src/a.rs:1",
|
|
)
|
|
(findings / "broken.md").write_text("no frontmatter here\n", encoding="utf-8")
|
|
# Index lists the good file, a frontmatterless file, and a ghost (unreadable).
|
|
(tmp_path / "findings-index.txt").write_text(
|
|
f"{findings / 'BOF-001.md'}\n{findings / 'broken.md'}\n{findings / 'GHOST-404.md'}\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
run = build_sarif(tmp_path)["runs"][0]
|
|
invocation = run["invocations"][0]
|
|
# Good finding still emitted; the run is not marked failed.
|
|
assert [r["properties"]["finding_id"] for r in run["results"]] == ["BOF-001"]
|
|
assert invocation["executionSuccessful"] is True
|
|
# Both drops surfaced in the artifact.
|
|
assert invocation["properties"]["skipped_findings"] == 2
|
|
assert len(invocation["properties"]["skipped_paths"]) == 2
|
|
notifications = invocation["toolExecutionNotifications"]
|
|
assert len(notifications) == 2
|
|
assert all(n["level"] == "warning" for n in notifications)
|
|
assert any("GHOST-404" in n["message"]["text"] for n in notifications)
|
|
assert any("broken.md" in n["message"]["text"] for n in notifications)
|
|
|
|
|
|
def test_merged_finding_whose_target_was_fp_rejected_is_emitted(tmp_path: Path) -> None:
|
|
"""A finding merged into an FP-rejected primary must not inherit the rejection;
|
|
its own TRUE_POSITIVE verdict must still surface."""
|
|
(tmp_path / "context.md").write_text(
|
|
"---\nthreat_model: REMOTE\nseverity_filter: all\n---\n", encoding="utf-8"
|
|
)
|
|
findings = tmp_path / "findings"
|
|
_write_finding(
|
|
findings,
|
|
fid="DUP-A",
|
|
bug_class="buffer-overflow-unsafe",
|
|
title="real bug, folded into DUP-B",
|
|
location="src/a.rs:10",
|
|
fp_verdict="TRUE_POSITIVE",
|
|
merged_into="DUP-B",
|
|
)
|
|
_write_finding(
|
|
findings,
|
|
fid="DUP-B",
|
|
bug_class="buffer-overflow-unsafe",
|
|
title="the duplicate, later judged FP",
|
|
location="src/a.rs:10",
|
|
fp_verdict="FALSE_POSITIVE",
|
|
)
|
|
|
|
result_ids = [
|
|
r["properties"]["finding_id"] for r in build_sarif(tmp_path)["runs"][0]["results"]
|
|
]
|
|
assert result_ids == ["DUP-A"]
|
|
|
|
|
|
def test_merged_finding_with_surviving_target_is_skipped(tmp_path: Path) -> None:
|
|
"""When the merge target survives, the merged finding is still skipped — no
|
|
false duplicate."""
|
|
(tmp_path / "context.md").write_text(
|
|
"---\nthreat_model: REMOTE\nseverity_filter: all\n---\n", encoding="utf-8"
|
|
)
|
|
findings = tmp_path / "findings"
|
|
_write_finding(
|
|
findings,
|
|
fid="DUP-A",
|
|
bug_class="buffer-overflow-unsafe",
|
|
title="folded duplicate",
|
|
location="src/a.rs:10",
|
|
fp_verdict="TRUE_POSITIVE",
|
|
merged_into="DUP-B",
|
|
)
|
|
_write_finding(
|
|
findings,
|
|
fid="DUP-B",
|
|
bug_class="buffer-overflow-unsafe",
|
|
title="surviving primary",
|
|
location="src/a.rs:10",
|
|
fp_verdict="TRUE_POSITIVE",
|
|
)
|
|
|
|
result_ids = [
|
|
r["properties"]["finding_id"] for r in build_sarif(tmp_path)["runs"][0]["results"]
|
|
]
|
|
assert result_ids == ["DUP-B"]
|
|
|
|
|
|
def test_merged_finding_whose_target_is_missing_is_emitted(tmp_path: Path) -> None:
|
|
"""A finding merged into a missing target id (aborted dedup / stale field)
|
|
must survive."""
|
|
(tmp_path / "context.md").write_text(
|
|
"---\nthreat_model: REMOTE\nseverity_filter: all\n---\n", encoding="utf-8"
|
|
)
|
|
findings = tmp_path / "findings"
|
|
_write_finding(
|
|
findings,
|
|
fid="DUP-A",
|
|
bug_class="buffer-overflow-unsafe",
|
|
title="orphaned by missing target",
|
|
location="src/a.rs:10",
|
|
fp_verdict="TRUE_POSITIVE",
|
|
merged_into="DUP-GHOST",
|
|
)
|
|
|
|
result_ids = [
|
|
r["properties"]["finding_id"] for r in build_sarif(tmp_path)["runs"][0]["results"]
|
|
]
|
|
assert result_ids == ["DUP-A"]
|
|
|
|
|
|
def test_location_parts_branch_coverage() -> None:
|
|
"""Cover location_parts shapes: plain, markdown-link, trailing-colon, multi,
|
|
bare path, and the :0 clamp."""
|
|
from generate_sarif import location_parts
|
|
|
|
assert location_parts("src/lib.rs:42") == ("src/lib.rs", 42)
|
|
assert location_parts("[src/lib.rs](/abs/src/lib.rs):42") == ("src/lib.rs", 42)
|
|
assert location_parts("src/lib.rs:") == ("src/lib.rs", 1)
|
|
assert location_parts("src/lib.rs") == ("src/lib.rs", 1)
|
|
assert location_parts("a.rs:1, b.rs:2") == ("a.rs:1, b.rs:2", 1)
|
|
assert location_parts("src/lib.rs:0") == ("src/lib.rs", 1)
|
|
assert location_parts(None) == ("", 1)
|
|
|
|
|
|
def test_finding_with_no_location_is_marked(tmp_path: Path) -> None:
|
|
"""A survivor with no `location` must be emitted (not dropped) but flagged:
|
|
empty URI, `location_missing: True`, and a `LOCATION MISSING` title marker so
|
|
the phantom `:1` location is never read as a real one."""
|
|
(tmp_path / "context.md").write_text(
|
|
"---\nthreat_model: REMOTE\nseverity_filter: all\n---\n", encoding="utf-8"
|
|
)
|
|
findings = tmp_path / "findings"
|
|
findings.mkdir()
|
|
(findings / "BOF-001.md").write_text(
|
|
"---\nid: BOF-001\nbug_class: buffer-overflow-unsafe\n"
|
|
"title: no location recorded\nseverity: HIGH\n"
|
|
"fp_verdict: TRUE_POSITIVE\nconfidence: High\n---\n\nBody.\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
result = build_sarif(tmp_path)["runs"][0]["results"][0]
|
|
assert result["properties"]["location_missing"] is True
|
|
assert "LOCATION MISSING" in result["message"]["text"]
|
|
loc = result["locations"][0]["physicalLocation"]
|
|
assert loc["artifactLocation"]["uri"] == ""
|
|
assert loc["region"]["startLine"] == 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
import sys
|
|
|
|
raise SystemExit(pytest.main([__file__, *sys.argv[1:]]))
|