Skip to content

Finding Triage

0sec runs a triage pipeline between the research agent and the blind verify agent. Every finding walks through a stack of independent filters; each can kill, downgrade, or boost it. Most are deterministic, zero-cost, and run before any verification token is spent.

What the ablation measured (2026-04-11). The stack strictly beats the no-triage baseline on XBOW black-box, is a Pareto tradeoff on white-box (2 flags at limit=50 for 63% fewer findings), and is a no-op on npm-bench. Layer 11 (EGATS) is the one broken layer and is opt-in only (0sec#116). Numbers: FP Reduction Moat; narrative: 2026-04-11 ablation.

Each stage is configurable via env vars and lives under packages/core/src/triage/.

triage/holding-it-wrong.ts — always on. Kills findings where the “vulnerability” is the documented behavior of the sink: fs.writeFile as arbitrary file write, vm.compileFunction as code execution, toFunction(cb) as callback injection. Downgrades to info and skips downstream verification.

triage/feature-extractor.ts — always available. Builds a 45-element numeric vector per finding: response shape (status, size, reflection, error markers), payload signals (encoding, sink class, parameter location), and category priors. Handcrafted features alone hit ~77% recall / 16% FPR, and the vector feeds into neural embeddings for downstream ML. See FEATURE_NAMES for the full list, or Feature Extractor and Triage Dataset.

triage/oracles.ts — always on for supported categories. Deterministic, category-specific verification. If the oracle proves the exploit, accept with no LLM call.

CategoryOracleProof
SQLiverifySqliSQL error signatures + timing delta under sleep payloads
Reflected XSSverifyReflectedXssUnique token reflected in an executable context
SSRFverifySsrfOut-of-band callback (spins a local listener)
RCEverifyRceCommand output round-trips through the response
Path traversalverifyPathTraversal/etc/passwd signature (or Windows equivalent)
IDORverifyIdorDifferential response across identities

Dispatch by category with verifyOracleByCategory(finding, target).

triage/reachability.ts0SEC_FEATURE_REACHABILITY_GATE=1. When source is available, walks imports, route mounts, and framework entry points to check whether the sink is reachable from an HTTP handler, CLI main, or user-facing API. Dead code and test-only paths are suppressed before LLM tokens are spent.

Today it’s a zero-dependency grep/pattern pass and deliberately conservative: when it can’t make a confident call it returns reachable: true with low confidence so later stages still run. A tree-sitter interprocedural upgrade is planned.

5. Multi-modal agreement (foxguard × 0sec)

Section titled “5. Multi-modal agreement (foxguard × 0sec)”

triage/multi-modal.ts0SEC_FEATURE_MULTIMODAL=1. When both source and the foxguard binary are present, 0sec runs foxguard on the same code and cross-checks each finding against its SARIF:

  • Both fire on the same file/category → auto-accept, high confidence.
  • Only 0sec fires, foxguard scanned the file cleanly → down-weight or auto-reject.
  • foxguard didn’t scan the file → no signal.
Terminal window
env 0SEC_FEATURE_MULTIMODAL=1 \
0sec scan --target https://example.com --repo ./source

triage/pov-gate.ts0SEC_FEATURE_POV_GATE=1. Grounded in All You Need Is A Fuzzing Brain (arXiv:2509.07225): if an agent can’t build a working PoC in N turns, the finding is almost certainly a false positive.

Spins up a small, tightly-scoped agent loop with one job: build an exploit that actually runs and returns category-specific proof. hasPov: true boosts confidence and attaches the artifact; hasPov: false downgrades to info and sets triageNote = "no_pov".

triage/structured-verify.ts — default when a runtime is available. The single-shot blind verify is split into four focused subtasks, each with category-specific prompts:

  1. Reachability — can external input trigger the vuln?
  2. Payload validation — does the PoC demonstrate the claim?
  3. Impact assessment — what’s the real-world impact?
  4. Exploit confirmation — reproduce with only the PoC and target path.

Any step failure marks the finding a false positive.

0SEC_FEATURE_CONSENSUS_VERIFY=1. Runs the structured verify N times (different seeds) and takes the majority vote via runSelfConsistencyVerify. Trades tokens for confidence on ambiguous findings.

triage/memories.ts0SEC_FEATURE_TRIAGE_MEMORIES=1. Semgrep-style per-target FP context that learns from human triage. When a user marks a finding FP (and says why), the reason is stored as a TriageMemory. On later scans, memories are injected as few-shot examples into the verify prompt, and a strong match auto-rejects without a verify call.

Scope hierarchy: global (every scan), package (targets under a package prefix), target (exact URL or path). Relevance is a token-overlap heuristic today; an embedding ranker can replace scoreMemory without API changes.

Terminal window
# Mark a finding FP and remember why
0sec triage mark-fp <finding-id> --reason "test fixture, not prod"
# Add a standalone memory
0sec triage memory add --finding <id> --reason "sink is harmless helper" \
--scope package --scope-value my-pkg
# List memories
0sec triage memory list --scope target

Planned — not implemented. There is no triage/adversarial.ts module and no 0SEC_FEATURE_DEBATE flag in the engine. The intent: a prosecutor (finding is real) and a defender (it’s an FP) argue from fresh contexts, and a skeptical judge picks the winner — each seeing only the other’s written arguments, never the research agent’s chain of thought. The design follows the open-source read of Anthropic’s debate paper (arXiv:2402.06782); the point is to keep the two agents’ errors independent.

Its goal is partly served today by the cross-family refuter (stages/hunt-cross-family.ts, on by default on the hunt path), which forces the refute pass onto a different model family than the finder.

Section titled “11. EGATS — Evidence-Gated Attack Tree Search”

--egats or 0SEC_FEATURE_EGATS=1. Beam-search over an explicit hypothesis tree: the agent proposes attack branches with required evidence and only expands branches where prior evidence is observed; dead hypotheses are pruned. Highest variance in the pipeline — use it for breadth (unknown-class vulns), not depth on a known lead. Removed from the default aliases after the ablation (0sec#116).

Env varDefaultStage
0SEC_FEATURE_HOLDING_IT_WRONGon1
0SEC_FEATURE_EVIDENCE_GATEon2
0SEC_FEATURE_REACHABILITY_GATEoff4
0SEC_FEATURE_MULTIMODALoff5
0SEC_FEATURE_POV_GATEoff6
0SEC_FEATURE_PUBLISHABILITY_GATEoff6
0SEC_FEATURE_POC_GEN_STATICoff6
0SEC_FEATURE_CONSENSUS_VERIFYoff8
0SEC_FEATURE_LEARNED_ROUTERoffrouter
0SEC_FEATURE_DYNAMIC_TRIAGEoffrouter

0SEC_FEATURE_TRIAGE_MEMORIES, _DEBATE, and _EGATS were in earlier versions of this table but no longer exist as separate toggles — egats was removed from the default aliases after the ablation (0sec#116). See Features for the full env-var inventory.

Turning the moat on is a measurement, not an upgrade. Its effect is slice-dependent: a win on XBOW black-box, a 0-2 flag cost on white-box, a no-op on npm-bench. The “~60% fewer findings” number people remember is the moat working as intended; the flag count (the ground-truth-correct outcome) stayed roughly flat. Enable it to re-measure, not to score better.

Every gate is off by default. fp-moat names the set:

Terminal window
0sec scan --features fp-moat --target https://example.com
# or, for templated CI:
env 0SEC_FEATURE_PRESET=fp-moat 0sec scan --target https://example.com

It expands to REACHABILITY_GATE, MULTIMODAL, PUBLISHABILITY_GATE, POV_GATE, POC_GEN_STATIC, and CONSENSUS_VERIFY. Membership lives in packages/core/src/agent/feature-presets.ts and is pinned by test.

A flag you set yourself always wins, so you can ablate one layer:

Terminal window
env 0SEC_FEATURE_POV_GATE=0 0sec scan --features fp-moat

The preset deliberately omits LEARNED_ROUTER and DYNAMIC_TRIAGE — those decide which layers to skip per finding, so enabling them alongside the moat would suppress the layers you’re trying to measure.

Each layer records a verdict on the finding as it runs. findings show renders it:

Terminal window
0sec findings show <id>
Triage provenance:
FP moat NOT engaged: no opt-in moat layer ran for this finding (always-on filters only)
Layers: 3 executed, 5 skipped, 3 unrecorded | 412ms | $0.0000
+ holding_it_wrong executed(pass) — no holding-it-wrong pattern matched
+ evidence_gate executed(pass) — evidence_completeness=0.83 > 0.5
- reachability skipped(skip) — 0SEC_FEATURE_REACHABILITY_GATE=0

Three things to know:

  • It’s derived from verdicts stored on the finding, not your current shell. A default-scan finding reports the moat as not engaged even if you’ve exported every flag — otherwise re-reading an old finding could overstate what it went through.
  • skippedunrecorded. skipped means the layer recorded that it stood down (with the flag or missing precondition named); unrecorded means no verdict exists at all.
  • Three layers are permanently unrecorded: structured_verify, consensus, and kernel_oracle emit no verdict, so their execution can’t be observed. They’re listed in UNINSTRUMENTED_LAYERS and reported with an explicit reason, not quietly counted as skipped. Until that changes, they can’t back an FP-moat claim.