feat(hunter): add standard-mode scheduling

This commit is contained in:
Marius Ciepluch
2026-06-18 10:59:25 +02:00
parent cad70721cb
commit be65aed96e
6 changed files with 318 additions and 25 deletions
+24 -9
View File
@@ -7,6 +7,7 @@ from pathlib import Path
from .config import load_config
from .findings import quick_scan_findings, write_findings
from .harness import HarnessRuntime, HarnessTraceWriter, write_harness_config
from .hunter import run_hunter_pool, write_hunter_trajectories
from .index import build_code_chunks
from .mapping import analyze_entry_points, attach_reachability_hints, ingest_sarif, write_entry_points, write_static_hints
from .preprocess import preprocess_repository, write_preprocess_artifact
@@ -40,8 +41,8 @@ def main(argv: list[str] | None = None) -> int:
def _scan(path: Path, config_path: Path, mode: str, fail_on: str) -> int:
if mode != "quick":
raise SystemExit(f"mode {mode!r} is specified but only quick preprocess artifacts are implemented")
if mode == "deep":
raise SystemExit("mode 'deep' is specified but sandboxed dynamic analysis is not implemented")
if not path.exists() or not path.is_dir():
raise SystemExit(f"scan path is not a directory: {path}")
@@ -66,13 +67,22 @@ def _scan(path: Path, config_path: Path, mode: str, fail_on: str) -> int:
write_preprocess_artifact(snapshot, targets, run.root / "preprocess" / "file_targets.json")
rankings = runtime.run_stage("rank", lambda: rank_targets(targets))
write_rankings(rankings, run.root / "rank" / "ranking.json")
findings = runtime.run_stage("quick_findings", lambda: quick_scan_findings(run.target, targets, rankings))
if mode == "standard":
hunter_result = runtime.run_stage("hunter_pool", lambda: run_hunter_pool(run.target, targets, rankings, scan_id=run.scan_id))
findings = hunter_result.findings
trajectories = hunter_result.trajectories
else:
findings = runtime.run_stage("quick_findings", lambda: quick_scan_findings(run.target, targets, rankings))
trajectories = []
findings_path = run.root / "findings.json"
verification_path = run.root / "verification.json"
markdown_path = run.root / "report.md"
sarif_path = run.root / "report.sarif"
manifest_path = run.root / "manifest.json"
trajectories_path = run.root / "traces" / "hunter_trajectories.jsonl"
write_findings(findings, findings_path)
if trajectories:
write_hunter_trajectories(trajectories, trajectories_path)
verifications = runtime.run_stage("verify", lambda: verify_findings(findings))
write_verification_results(verifications, verification_path)
runtime.run_stage("report", lambda: write_markdown_report(findings, markdown_path, verifications))
@@ -83,12 +93,13 @@ def _scan(path: Path, config_path: Path, mode: str, fail_on: str) -> int:
run=run,
findings=findings,
verifications=verifications,
artifact_paths={
"findings": findings_path,
"verification": verification_path,
"markdown": markdown_path,
"sarif": sarif_path,
},
artifact_paths=_artifact_paths(
findings=findings_path,
verification=verification_path,
markdown=markdown_path,
sarif=sarif_path,
trajectories=trajectories_path if trajectories else None,
),
path=manifest_path,
),
)
@@ -110,6 +121,10 @@ def _load_static_hints(sarif_paths: tuple[str, ...]) -> list[object]:
return hints
def _artifact_paths(**paths: Path | None) -> dict[str, Path]:
return {name: path for name, path in paths.items() if path is not None}
def _index(path: Path, config_path: Path, chunk_lines: int) -> int:
if not path.exists() or not path.is_dir():
raise SystemExit(f"index path is not a directory: {path}")
+161
View File
@@ -0,0 +1,161 @@
from __future__ import annotations
import json
from concurrent.futures import ThreadPoolExecutor
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Literal
from .findings import StaticFinding, quick_scan_findings
from .preprocess import FileTarget
from .rank import RankingScore
HunterTier = Literal["A", "B", "C"]
@dataclass(frozen=True)
class HunterBudget:
tier: HunterTier
max_targets: int
max_findings_per_target: int
retrieval_budget_chars: int
skill_budget_chars: int
@dataclass(frozen=True)
class HunterTask:
target: FileTarget
ranking: RankingScore
tier: HunterTier
budget: HunterBudget
selected_skills: list[str]
retrieval_context: str
@dataclass(frozen=True)
class HunterTrajectory:
scan_id: str
target_path: str
tier: HunterTier
priority: float
selected_skills: list[str]
retrieval_context_chars: int
findings: list[str]
status: str
@dataclass(frozen=True)
class HunterPoolResult:
findings: list[StaticFinding]
trajectories: list[HunterTrajectory]
DEFAULT_BUDGETS = {
"A": HunterBudget(tier="A", max_targets=20, max_findings_per_target=20, retrieval_budget_chars=6000, skill_budget_chars=2500),
"B": HunterBudget(tier="B", max_targets=40, max_findings_per_target=10, retrieval_budget_chars=4000, skill_budget_chars=1500),
"C": HunterBudget(tier="C", max_targets=80, max_findings_per_target=5, retrieval_budget_chars=2000, skill_budget_chars=800),
}
def assign_tier(priority: float) -> HunterTier:
if priority >= 3:
return "A"
if priority >= 2:
return "B"
return "C"
def schedule_hunter_tasks(
targets: list[FileTarget],
rankings: list[RankingScore],
*,
retrieval_context_by_path: dict[str, str] | None = None,
) -> list[HunterTask]:
target_by_path = {target.path: target for target in targets}
retrieval_context_by_path = retrieval_context_by_path or {}
scheduled: list[HunterTask] = []
per_tier_counts: dict[HunterTier, int] = {"A": 0, "B": 0, "C": 0}
for ranking in sorted(rankings, key=lambda item: (-item.priority, item.path)):
target = target_by_path.get(ranking.path)
if target is None:
continue
tier = assign_tier(ranking.priority)
budget = DEFAULT_BUDGETS[tier]
if per_tier_counts[tier] >= budget.max_targets:
continue
per_tier_counts[tier] += 1
scheduled.append(
HunterTask(
target=target,
ranking=ranking,
tier=tier,
budget=budget,
selected_skills=select_skill_snippets(target),
retrieval_context=retrieval_context_by_path.get(target.path, "")[: budget.retrieval_budget_chars],
)
)
return scheduled
def run_hunter_pool(root: Path, targets: list[FileTarget], rankings: list[RankingScore], *, scan_id: str) -> HunterPoolResult:
tasks = schedule_hunter_tasks(targets, rankings, retrieval_context_by_path=build_retrieval_context_by_path(root, targets))
findings: list[StaticFinding] = []
trajectories: list[HunterTrajectory] = []
max_workers = min(8, max(1, len(tasks)))
with ThreadPoolExecutor(max_workers=max_workers) as executor:
for task_findings, trajectory in executor.map(lambda task: _run_hunter_task(root, scan_id, task), tasks):
findings.extend(task_findings)
trajectories.append(trajectory)
return HunterPoolResult(findings=sorted(findings, key=lambda item: item.finding_id), trajectories=trajectories)
def write_hunter_trajectories(trajectories: list[HunterTrajectory], path: Path) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("".join(json.dumps(asdict(trajectory), sort_keys=True) + "\n" for trajectory in trajectories))
def build_retrieval_context_by_path(root: Path, targets: list[FileTarget], *, max_chars: int = 6000) -> dict[str, str]:
contexts: dict[str, str] = {}
for target in targets:
source = _read_source(root / target.path, max_chars=max_chars)
if source:
contexts[target.path] = f"[repo_code] {target.path}\n{source}"
return contexts
def select_skill_snippets(target: FileTarget, *, max_snippets: int = 4) -> list[str]:
tags = set(target.tags)
snippets: list[str] = []
if target.language in {"c", "cpp", "c++"} or "memory_unsafe" in tags:
snippets.extend(["address-sanitizer", "libfuzzer", "harness-writing"])
if "parser" in tags or "fuzzable" in tags:
snippets.extend(["fuzzing-dictionary", "fuzzing-obstacles"])
if "crypto" in tags:
snippets.extend(["constant-time-analysis", "wycheproof"])
if "auth_boundary" in tags or "network_entry" in tags:
snippets.extend(["sharp-edges", "insecure-defaults"])
if "deserialization" in tags:
snippets.append("semgrep-rule-creator")
return sorted(dict.fromkeys(snippets))[:max_snippets]
def _run_hunter_task(root: Path, scan_id: str, task: HunterTask) -> tuple[list[StaticFinding], HunterTrajectory]:
findings = quick_scan_findings(root, [task.target], [task.ranking])[: task.budget.max_findings_per_target]
return findings, HunterTrajectory(
scan_id=scan_id,
target_path=task.target.path,
tier=task.tier,
priority=task.ranking.priority,
selected_skills=task.selected_skills,
retrieval_context_chars=len(task.retrieval_context),
findings=[finding.finding_id for finding in findings],
status="completed",
)
def _read_source(path: Path, *, max_chars: int) -> str:
try:
return path.read_text(errors="ignore")[:max_chars]
except OSError:
return ""
+13 -6
View File
@@ -88,12 +88,7 @@ def write_manifest(
"verification_status": verification_by_id[finding.finding_id].status
if finding.finding_id in verification_by_id
else "not_run",
"artifact_refs": {
"findings_json": _relative_artifact(run.root, artifact_paths["findings"]),
"verification_json": _relative_artifact(run.root, artifact_paths["verification"]),
"markdown": _relative_artifact(run.root, artifact_paths["markdown"]),
"sarif": _relative_artifact(run.root, artifact_paths["sarif"]),
},
"artifact_refs": _artifact_refs(run.root, artifact_paths),
}
for finding in findings
],
@@ -170,6 +165,18 @@ def _verification_by_id(verifications: list[VerificationResult]) -> dict[str, Ve
return {result.finding_id: result for result in verifications}
def _artifact_refs(root: Path, artifact_paths: dict[str, Path]) -> dict[str, str]:
refs = {
"findings_json": _relative_artifact(root, artifact_paths["findings"]),
"verification_json": _relative_artifact(root, artifact_paths["verification"]),
"markdown": _relative_artifact(root, artifact_paths["markdown"]),
"sarif": _relative_artifact(root, artifact_paths["sarif"]),
}
if "trajectories" in artifact_paths:
refs["trajectories_jsonl"] = _relative_artifact(root, artifact_paths["trajectories"])
return refs
def _relative_artifact(root: Path, path: Path) -> str:
try:
return path.relative_to(root).as_posix()