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>
322 lines
11 KiB
Python
322 lines
11 KiB
Python
#!/usr/bin/env python3
|
|
# /// script
|
|
# requires-python = ">=3.11"
|
|
# dependencies = []
|
|
# ///
|
|
"""Validate Phase 7 worker artifacts against plan.json."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import re
|
|
import sys
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from generate_sarif import split_frontmatter
|
|
|
|
# Finding IDs embedded in coverage `filed:` outcomes (e.g. "filed: BOF-001").
|
|
FINDING_ID_RE = re.compile(r"\b[A-Z][A-Z0-9_]*-\d{3,}\b")
|
|
|
|
|
|
def frontmatter_id(path: Path) -> str | None:
|
|
"""Return the `id:` value from a finding file's YAML frontmatter, or None.
|
|
|
|
Parses with generate_sarif's own `split_frontmatter` — the exact code the
|
|
report generator runs — so a file this check accepts is one generate_sarif
|
|
will also accept. A malformed frontmatter (e.g. a scalar key followed by a
|
|
` - ` list item) raises here exactly as it does in generate_sarif; the
|
|
caller turns that into a hard validation error rather than passing a file the
|
|
report stage would silently drop from results.
|
|
|
|
Phase 7 otherwise keys everything on the filename stem; without this the
|
|
frontmatter `id` (which dedup/fp-judge and generate_sarif trust) can silently
|
|
disagree with the filename (e.g. a file named BOF-001.md carrying id: UAF-999).
|
|
"""
|
|
try:
|
|
text = path.read_text(encoding="utf-8")
|
|
except OSError:
|
|
return None
|
|
frontmatter, _ = split_frontmatter(text)
|
|
value = frontmatter.get("id")
|
|
return None if value is None else str(value)
|
|
|
|
|
|
def normalize_worker_id(value: str) -> str:
|
|
if value.startswith("worker-"):
|
|
suffix = value.removeprefix("worker-")
|
|
else:
|
|
suffix = value
|
|
if not suffix.isdigit():
|
|
raise ValueError(f"invalid worker id: {value!r}")
|
|
return f"worker-{int(suffix)}"
|
|
|
|
|
|
def flatten_claimed_count_args(values: list[list[str]]) -> list[str]:
|
|
return [value for group in values for value in group]
|
|
|
|
|
|
def parse_claimed_counts(values: list[str]) -> dict[str, int]:
|
|
claimed: dict[str, int] = {}
|
|
for value in values:
|
|
worker, sep, count = value.partition("=")
|
|
if not sep:
|
|
raise ValueError(f"invalid --claimed-count {value!r}; expected worker-N=N")
|
|
worker_id = normalize_worker_id(worker)
|
|
try:
|
|
claimed_count = int(count)
|
|
except ValueError as exc:
|
|
raise ValueError(f"invalid claimed count for {worker_id}: {count!r}") from exc
|
|
if claimed_count < 0:
|
|
raise ValueError(f"invalid claimed count for {worker_id}: {claimed_count}")
|
|
claimed[worker_id] = claimed_count
|
|
return claimed
|
|
|
|
|
|
def _output_dir(plan: dict[str, Any], plan_path: Path) -> Path:
|
|
configured = plan.get("run", {}).get("output_dir")
|
|
if configured:
|
|
return Path(configured)
|
|
return plan_path.parent
|
|
|
|
|
|
def _worker_map(plan: dict[str, Any]) -> dict[str, dict[str, Any]]:
|
|
workers: dict[str, dict[str, Any]] = {}
|
|
for worker in plan.get("workers", []):
|
|
if "worker_n" not in worker:
|
|
raise ValueError(f"plan worker entry missing 'worker_n': {worker!r}")
|
|
worker_id = normalize_worker_id(str(worker["worker_n"]))
|
|
workers[worker_id] = worker
|
|
return workers
|
|
|
|
|
|
def _read_shard(shard_path: Path, output_dir: Path) -> tuple[list[Path], dict[str, Path]]:
|
|
paths: list[Path] = []
|
|
ids: dict[str, Path] = {}
|
|
for line in shard_path.read_text(encoding="utf-8").splitlines():
|
|
raw = line.strip()
|
|
if not raw:
|
|
continue
|
|
path = Path(raw)
|
|
if not path.is_absolute():
|
|
path = output_dir / path
|
|
paths.append(path)
|
|
ids[path.stem] = path
|
|
return paths, ids
|
|
|
|
|
|
def _is_separator(cells: list[str]) -> bool:
|
|
return all(re.fullmatch(r":?-{3,}:?", cell) for cell in cells)
|
|
|
|
|
|
def _parse_coverage_rows(coverage_path: Path) -> tuple[dict[tuple[str, str], str], list[str]]:
|
|
rows: dict[tuple[str, str], str] = {}
|
|
errors: list[str] = []
|
|
|
|
for line_no, raw in enumerate(coverage_path.read_text(encoding="utf-8").splitlines(), 1):
|
|
stripped = raw.strip()
|
|
if not stripped.startswith("|"):
|
|
continue
|
|
cells = [cell.strip() for cell in stripped.strip("|").split("|")]
|
|
if len(cells) < 3:
|
|
continue
|
|
if cells[0].lower() == "pass prefix" or _is_separator(cells):
|
|
continue
|
|
key = (cells[0], cells[1])
|
|
if key in rows:
|
|
errors.append(
|
|
f"{coverage_path}: duplicate coverage row for {cells[0]} / {cells[1]} "
|
|
f"at line {line_no}"
|
|
)
|
|
rows[key] = cells[2]
|
|
|
|
return rows, errors
|
|
|
|
|
|
def _valid_cleared(outcome: str) -> bool:
|
|
lowered = outcome.lower()
|
|
return lowered == "cleared" or lowered.startswith("cleared ") or lowered.startswith("cleared(")
|
|
|
|
|
|
def _validate_worker(
|
|
*,
|
|
worker_id: str,
|
|
worker: dict[str, Any],
|
|
output_dir: Path,
|
|
claimed_counts: dict[str, int],
|
|
) -> list[str]:
|
|
errors: list[str] = []
|
|
worker_n = int(worker_id.removeprefix("worker-"))
|
|
shard_path = output_dir / "findings-index.d" / f"worker-{worker_n}.txt"
|
|
coverage_path = output_dir / "coverage" / f"worker-{worker_n}.md"
|
|
|
|
shard_paths: list[Path] = []
|
|
shard_ids: dict[str, Path] = {}
|
|
if not shard_path.is_file():
|
|
errors.append(f"{worker_id}: missing shard {shard_path}")
|
|
else:
|
|
shard_paths, shard_ids = _read_shard(shard_path, output_dir)
|
|
findings_dir = (output_dir / "findings").resolve()
|
|
for path in shard_paths:
|
|
if not path.is_file():
|
|
errors.append(f"{worker_id}: shard references missing finding file {path}")
|
|
continue
|
|
if path.resolve().parent != findings_dir:
|
|
errors.append(
|
|
f"{worker_id}: shard references finding file outside findings/: {path}"
|
|
)
|
|
try:
|
|
declared = frontmatter_id(path)
|
|
except Exception as exc:
|
|
# generate_sarif's frontmatter parser raises on a malformed block
|
|
# (e.g. a scalar key then a ` - ` list item), so the report
|
|
# generator would skip this file and drop the finding from results.
|
|
# Surface it as a hard error instead of passing a file that vanishes.
|
|
errors.append(
|
|
f"{worker_id}: finding {path.name} has unparseable frontmatter "
|
|
f"(generate_sarif would drop it): {exc}"
|
|
)
|
|
continue
|
|
if declared is None:
|
|
errors.append(f"{worker_id}: finding {path.name} has no parseable frontmatter id")
|
|
elif declared != path.stem:
|
|
errors.append(
|
|
f"{worker_id}: finding {path.name} frontmatter id {declared!r} "
|
|
f"does not match its filename stem {path.stem!r}"
|
|
)
|
|
|
|
if worker_id in claimed_counts and claimed_counts[worker_id] != len(shard_paths):
|
|
errors.append(
|
|
f"{worker_id}: claimed {claimed_counts[worker_id]} finding files but shard has "
|
|
f"{len(shard_paths)} entries"
|
|
)
|
|
|
|
if not coverage_path.is_file():
|
|
errors.append(f"{worker_id}: missing coverage file {coverage_path}")
|
|
return errors
|
|
|
|
coverage_rows, coverage_errors = _parse_coverage_rows(coverage_path)
|
|
errors.extend(f"{worker_id}: {error}" for error in coverage_errors)
|
|
|
|
declared_ids: set[str] = set()
|
|
pass_prefixes = worker.get("pass_prefixes", [])
|
|
bug_classes = worker.get("pass_bug_classes", [])
|
|
for prefix, bug_class in zip(pass_prefixes, bug_classes, strict=True):
|
|
outcome = coverage_rows.get((prefix, bug_class))
|
|
if outcome is None:
|
|
errors.append(f"{worker_id}: missing coverage row for {prefix} / {bug_class}")
|
|
continue
|
|
|
|
lowered = outcome.lower()
|
|
if lowered.startswith("filed:"):
|
|
finding_ids = FINDING_ID_RE.findall(outcome)
|
|
if not finding_ids:
|
|
errors.append(f"{worker_id}: filed outcome for {prefix} has no finding IDs")
|
|
continue
|
|
for finding_id in finding_ids:
|
|
declared_ids.add(finding_id)
|
|
if not finding_id.startswith(f"{prefix}-"):
|
|
errors.append(
|
|
f"{worker_id}: filed ID {finding_id} does not match pass prefix {prefix}"
|
|
)
|
|
path = shard_ids.get(finding_id)
|
|
if path is None:
|
|
errors.append(f"{worker_id}: filed ID {finding_id} is absent from shard")
|
|
elif not path.is_file():
|
|
errors.append(f"{worker_id}: filed ID {finding_id} points to missing {path}")
|
|
elif _valid_cleared(outcome):
|
|
continue
|
|
else:
|
|
errors.append(f"{worker_id}: invalid coverage outcome for {prefix}: {outcome}")
|
|
|
|
extra_ids = sorted(set(shard_ids) - declared_ids)
|
|
for finding_id in extra_ids:
|
|
errors.append(f"{worker_id}: shard ID {finding_id} is not declared in coverage")
|
|
|
|
return errors
|
|
|
|
|
|
def validate_plan(
|
|
plan_path: Path,
|
|
*,
|
|
workers: list[str] | None = None,
|
|
claimed_counts: dict[str, int] | None = None,
|
|
) -> list[str]:
|
|
plan = json.loads(plan_path.read_text(encoding="utf-8"))
|
|
output_dir = _output_dir(plan, plan_path)
|
|
worker_by_id = _worker_map(plan)
|
|
claimed_counts = claimed_counts or {}
|
|
|
|
if workers is None:
|
|
selected = sorted(worker_by_id)
|
|
else:
|
|
selected = [normalize_worker_id(worker) for worker in workers]
|
|
|
|
errors: list[str] = []
|
|
for worker_id in selected:
|
|
worker = worker_by_id.get(worker_id)
|
|
if worker is None:
|
|
errors.append(f"{worker_id}: not present in {plan_path}")
|
|
continue
|
|
errors.extend(
|
|
_validate_worker(
|
|
worker_id=worker_id,
|
|
worker=worker,
|
|
output_dir=output_dir,
|
|
claimed_counts=claimed_counts,
|
|
)
|
|
)
|
|
return errors
|
|
|
|
|
|
def parse_args(argv: list[str]) -> argparse.Namespace:
|
|
parser = argparse.ArgumentParser(
|
|
description="Validate review worker shards and coverage artifacts against plan.json."
|
|
)
|
|
parser.add_argument("plan_json", type=Path)
|
|
parser.add_argument(
|
|
"--worker",
|
|
action="append",
|
|
help="Worker to validate (worker-N or N). Repeat to validate multiple workers.",
|
|
)
|
|
parser.add_argument(
|
|
"--claimed-count",
|
|
action="append",
|
|
nargs="+",
|
|
default=[],
|
|
metavar="worker-N=N",
|
|
help=(
|
|
"Expected finding count parsed from worker complete lines. Repeat the flag or "
|
|
"pass multiple worker-N=N values after one flag."
|
|
),
|
|
)
|
|
return parser.parse_args(argv)
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
args = parse_args(sys.argv[1:] if argv is None else argv)
|
|
try:
|
|
claimed_counts = parse_claimed_counts(flatten_claimed_count_args(args.claimed_count))
|
|
errors = validate_plan(
|
|
args.plan_json,
|
|
workers=args.worker,
|
|
claimed_counts=claimed_counts,
|
|
)
|
|
except (OSError, ValueError, KeyError, json.JSONDecodeError) as exc:
|
|
print(f"validate_artifacts: {exc}", file=sys.stderr)
|
|
return 2
|
|
|
|
if errors:
|
|
for error in errors:
|
|
print(error, file=sys.stderr)
|
|
return 1
|
|
|
|
worker_label = "all workers" if args.worker is None else ", ".join(args.worker)
|
|
print(f"validate_artifacts: OK ({worker_label})")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|