Files
Andrea Cappa (zi0Black) cfe5d7b161 Rust review plugin (#178)
* 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>
2026-06-30 11:14:46 -04:00

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:]]))