Dealing with False Positives in Gadriel Code
Audience: engineers and security owners who run gadriel code scan in
local dev, pre-commit, and CI, and who need to understand why a finding is
(or is not) blocking, and how to dispose of one that is wrong.
Every static analyzer produces false positives (FPs). What distinguishes Gadriel Code is that false-positive handling is a first-class part of the scoring model, not a bolt-on suppression list. A finding is never simply "on" or "off": it carries an explicit confidence tier, an effective-risk band that discounts severity by that confidence, a provenance record for its taint source, and a domain that decides whether it participates in the CI gate at all. This page explains each of those layers, how they combine, and the concrete commands you use to triage and dismiss a finding.
The governing principle behind all of it:
A false positive costs a developer five minutes. A suppressed true positive costs a breach. The two errors are not symmetric. Every suppression or demotion in Gadriel Code is fail-open: it keeps the finding unless there is positive proof of safety. "We're not sure" always keeps the finding.
1. Confidence tiers
Every finding carries a confidence_tier of High, Medium, or Low.
Confidence answers a different question from severity:
- Severity = how bad is this if it is real? (
Critical … Info) - Confidence = how sure is the engine that it is real? (
High / Medium / Low)
Confidence is derived deterministically from the evidence the rule
actually had — not from an LLM, not from a guess. The derivation
(crates/code-security/gadriel-finding-types/src/confidence.rs,
derive_confidence_tier) maps (scan_type, pattern_type, gates) to a tier
with no I/O, so the same finding always gets the same tier.
1.1 How the tier is derived from evidence
The stronger the corroborating evidence a rule matched on, the higher the
tier. A summary of the SAST derivation (the fullest table lives in
confidence.rs):
| Evidence the rule matched on | pattern_type | Tier |
|---|---|---|
| Source→sink taint flow | ast_flow | Medium |
A dangerous call and a requires_import gate proving the dangerous library is in scope | ast_call + import gate | Medium |
| A dangerous call and a value/entropy gate on the argument | ast_call + entropy gate | Medium |
| A bare dangerous call, no import/taint/entropy corroboration | ast_call | Low |
| An assignment / string / regex / structural-absence match | ast_assign, ast_string, regex_pattern, ast_missing | Low |
Other scanners calibrate the same way:
| Scanner | High when… | Medium/Low when… |
|---|---|---|
| SCA (dependencies) | OSV-confirmed advisory (real package+version hit against a curated feed) | typosquat guess (math_probability) or raw manifest regex (manifest_entry) → Medium |
| Secrets | value passes a Shannon-entropy gate | no entropy gate → Low |
| Config / Container | matched a real structural construct (schema/AST) | fired on mere file presence/absence → Medium |
| API | schema-aware OpenAPI / dataflow / GVL match | (always High) |
Two bounded adjustments can move a tier by one band only, never more:
- High-signal floor (
apply_high_signal_floor): some rules match a code fact that is the vulnerability —jwt.decode(..., verify=False),yaml.loadRCE,md5for hashing,random.choicefor a token. These are structurallyast_callwith no import gate, so the table above would band them Low and drop a confirmed-real bug out of the gate. A rule opts in viahigh_signal = true, which raises a structural Low → Medium (never higher). This is a floor, not an override. - Per-rule calibration (
derive_confidence_tier_calibrated): offline benchmark data can promote (Low→Medium, at n ≥ 20 fires with a Wilson-lower TP-rate ≥ 0.85) or demote (→Low, when Wilson-lower FP-rate ≥ 0.50) a rule within the same one-band clamp.
1.2 Low-confidence SAST findings are advisory by default
This is the single most important FP rule to understand:
A SAST finding whose confidence_tier is Low and whose
confidence-adjusted effective-risk band is Low or Info is moved to the
advisory (non-gating) stream. It stays in findings.json, still shows in the
report's advisory section — it just no longer blocks CI.
Why: across the Round-2 50-repo benchmark, bare-pattern SAST matches (no import gate, no taint trace, no entropy check) ran at roughly 26% precision — three noise findings for every real one. Gating on them is what makes a scanner "cry wolf." Moving them out of the gate removed ~200 gate-blocking findings per benchmark run without demoting a single true positive — every TP in the corpus was either Medium/High confidence, or had a High/Critical band even after the confidence discount.
A high severity does not save a finding from this demotion. A
Low-confidence SAST finding is routed to a non-gating advisory domain
(see Scoring & Verdicts §4), so it never
reaches the --fail-on severity comparison regardless of its raw severity. Its
effective risk (severity × confidence, §2) has a band of Low, which is the
intuition for why it should not block; severity alone never forces a block.
Scope guard (recall preservation): this gate applies to SAST only. It does not touch Secrets (a no-entropy-gate secret can still be a real committed credential) or SCA (always High confidence anyway).
Implementation: Finding::domain() in finding.rs.
2. Effective risk = severity × confidence
Showing raw severity alone makes a low-confidence false positive scream "CRITICAL" and erodes trust. Effective Risk is the number a human should actually triage on — the same idea as CVSS's Report Confidence temporal metric, which discounts an unconfirmed score.
effective_score = severity_base × confidence_weight
with (effective_risk.rs):
| Severity | severity_base | Confidence | confidence_weight | |
|---|---|---|---|---|
| Critical | 9.5 | High | 1.00 | |
| High | 7.5 | Medium | 0.55 | |
| Medium | 5.0 | Low | 0.25 | |
| Low | 2.5 | |||
| Info | 0.5 |
The score is then banded into Critical / High / Medium / Low / Info, with two
invariants:
- De-escalation only. The effective band is capped at the raw severity band — confidence can only lower risk, never inflate it. (A High×High finding scores 7.5 but stays in the High band; it never crosses into Critical.)
unverifiedflag. When raw severity is High/Critical but confidence is Low, the finding is stampedunverifiedso UIs render anUNVERIFIEDbadge — "needs triage", not "the sky is falling".
Worked examples:
| Finding | Calculation | Effective band | Notes |
|---|---|---|---|
Confirmed critical (Critical × High) | 9.5 × 1.00 = 9.5 | Critical | Stays critical, gates. |
Unverified critical (Critical × Low) | 9.5 × 0.25 = 2.375 | Low | unverified = true. If SAST → advisory (§1.2). |
Unverified high (High × Low) | 7.5 × 0.25 = 1.875 | Low | unverified = true. |
High confidence high (High × High) | 7.5 × 1.00 = 7.5 | High | Capped at severity band, does not escalate. |
How this reaches the gate today — via the finding domain. The
--fail-on <severity> threshold is compared against the raw severity of
gating-domain findings only. Confidence controls gating indirectly: a
Low-confidence SAST finding is placed in a non-gating advisory domain,
so a Low-confidence SAST High never reaches the --fail-on
comparison at all. Raw severity and confidence are both preserved on the
finding, so nothing is hidden. (The sub-10%-FP roadmap moves
toward having the gate reason about the effective band directly; the
effective-risk column above is the prioritization signal in the meantime.)
3. Source provenance
The second structural FP defense is where the tainted data came from.
A rule that flags "attacker-controlled input reaches a
dangerous sink" is only a real vulnerability if the input is actually
attacker-controlled. Historically the taint engines could not tell
getenv() from an HTTP body — every origin looked equally dangerous — which
produced a large class of FPs (the NO_PROVENANCE class: ~11% of all
FPs in the 31-repo audit).
Every taint origin now carries a provenance tier:
| Tier | Example sources | Attacker-controlled? |
|---|---|---|
| Remote | recv, read, HTTP request.*, req.* | Yes — the real threat model |
| LocalCli | argv, stdin, os.Args, process.argv | Only under specific escalation (e.g. setuid) |
| Env | getenv, os.environ, env::var | No — the process's own trusted environment |
| Filesystem | fread, getline, file contents | No (by default) |
| Const | string/number literals | Never tainted — hard invariant |
A rule declares which tiers it fires on via
requires_source_provenance: { fire: [...], demote: [...] } (GVL schema,
back-compatible: absent = legacy = every non-Const tier fires). A web-injection
rule (SQLi / XSS / traversal / SSRF) declares fire: [remote] and
demote: [local_cli, env]. When a finding's dominant source tier is not in
the rule's fire set, it is demoted out of the gating stream — not deleted.
3.1 Why the SSLKEYLOGFILE debug hook does not gate
The 50-repo FP study's single worst gating false positive was this line in
mongoose (mongoose.c:16172):
cchar *keylogfile = getenv("SSLKEYLOGFILE"); // Env-tier sourceif (keylogfile == NULL) return;FILE *f = fopen(keylogfile, "a"); // path-open sink
This is the industry-standard TLS-debug hook, compiled only under
#ifdef MG_TLS_SSLKEYLOGFILE, reading the process's own trusted environment. It
is not attacker-remote, yet it used to gate CI at High severity.
This is fixed with a sink taxonomy:
CommandExecsinks (system,popen,execl…,posix_spawn) — a non-remote source is still dangerous here (setuid privilege escalation), so these keep the "High/Critical stay visible" carve-out.argv → system()in a setuid binary remains a gating finding.PathOrMemorysinks (fopen,open,malloc,memcpy, …) — a program opening its own env-configured path, or sizing a buffer from a CLI arg, is genuinely low-risk when the source is non-remote. For these, a non-remote source demotes the finding.
The demotion works by setting confidence_tier → Low, which then flows through
the confidence→domain gate (§1.2) and reclassifies the finding to
the non-gating advisory domain. The finding stays in findings.json at its
original severity, carries provenance: {tier: env, demoted: true}, and no
longer gates CI. Fail-open default: a sink in neither list is treated as
CommandExec (keep-visible).
4. Dismissing a finding
When triage confirms a finding is wrong (or right), you record that decision
with gadriel code fix.
4.1 Mark a false positive
bashgadriel code fix <FINDING_ID> --false-positive --reason "debug-only endpoint, gated behind admin auth"
This is a zero-LLM, deterministic path. It:
- Writes a durable waiver to
.gadriel-waivers.tomlat the repo root, keyed by(rule_id, path, line)— not byrule_idalone, so a second finding of the same rule at a different location is not covered. - Updates the per-repo Bayesian posterior (
β += 1) as a soft learning signal. - Appends an audit event (
confirmed_false_positive,agent_runner: human) to.security/audit-log.jsonl.
On future scans the finding is segregated, not deleted — it still appears in a separate "Waived (FP)" report section, but the security gate and KPIs ignore it.
Note on the posterior vs. the waiver. The Bayesian posterior is
deliberately anti-gaming: a repeatedly re-detected finding is clawed back
toward "real" on every rescan, so a stable FP can never be durably suppressed
through dismissals alone. That is why the waiver file exists as the orthogonal
hard disposition layer — the same pattern every mature SAST tool ships
(Semgrep .semgrepignore, CodeQL baselines, cargo audit --ignore).
The MCP tool dismiss_false_positive (used by AI coding agents on your behalf)
writes the identical (rule_id, path, line) waiver schema.
4.2 Confirm real, or hand to an AI agent
bashgadriel code fix <FINDING_ID> --confirm-real # records α += 1, zero LLM, audit eventgadriel code fix <FINDING_ID> # routes to the per-pillar AI agent for# confirmation + a remediation suggestion
The bare gadriel code fix <id> form invokes the reasoning runner (per-pillar
agent) to confirm the finding and propose a fix, writing its verdict to the
audit log. Sending source to the model requires explicit consent (the
send_source gate).
5. The waiver file
.gadriel-waivers.toml lives at the repo root and is committed with your code.
toml[[waiver]]rule_id = "CODE-W1-AI-001"path = "src/health/echo.py" # optional glob; default: any pathline = 42 # optional exact linescope = "echo_handler" # optional symbol/scope substringreason = "debug-only endpoint, gated behind admin auth"expires = "2026-12-31" # optional; an expired waiver is ignoredmode = "segregate" # "segregate" (default) | "suppress"
Matching. A waiver matches a finding on rule_id plus any of the optional
narrowing keys that are present (path glob, exact line, scope substring).
Absent keys are unconstrained.
Modes.
| Mode | Effect |
|---|---|
segregate (default) | Keep the finding, stamp it waived; shown in a separate report section, excluded from the gate and KPIs. |
suppress | Drop the finding from output entirely. |
Lifecycle & expiry.
- A missing waiver file is the common case and a cheap no-op.
- A malformed file logs a warning and yields an empty set — a broken waiver file must never crash a scan or silently disable all gating.
- An expired waiver (
expiresin the past) is ignored — the finding becomes active again and gates. Set expiries deliberately: a waiver with an expiry is a promise to revisit, and CI will start failing on the finding the day after it lapses. Prefer time-boxed waivers over permanent ones.
The waiver layer is independent of both the audit log (the history) and the posterior (the soft signal): the waiver is the durable disposition.
6. The FP-prevention program
Confidence, effective risk, and provenance are the runtime instruments. Behind them is a standing engineering program whose explicit target is a gating FP rate under 10%, measured and budgeted rather than discovered by users.
The recall-preservation principle governs everything. Every gate below defaults to keeping the finding and acts only on positive proof of benignity:
| Mechanism | Suppresses only when… | Otherwise |
|---|---|---|
| Provenance | source is proven non-attacker (Const, Env→PathOrMemory) | keep |
| Reachability | finding is proven unreachable from every entry point | keep — "reachability unknown" never suppresses |
| Rule quarantine | a rule is proven over its FP budget on labelled data and has no TP on the recall floor | keep — any floor TP protects the rule |
Engine-level gates that recognise provably-safe code:
- Sanitizer / guard recognition — a known sanitizer or bound-check that dominates the sink.
- Literal / const suppression —
Const-tier sources never taint (hard invariant); constant-propagation for class-level string attributes. - Test / tooling-path exclusion — findings in test and tooling paths are
demoted (with an adversarial "a real vuln in a dir merely named
test" must-still-fire fixture to prevent over-reach). - Framework scoping —
requires_importgates so a rule only fires when the dangerous library is actually in scope.
Standing benchmark & per-rule budgets:
- A consolidated
gadriel-fp-benchmarkcorpus with a discovery tier and a standing precision/recall tier (SHA-pinned repos, labelledvuln_by_design | clean_hardened | mixed), run nightly. - Per-rule FP budgets with Wilson-bounded gates and an auto-quarantine concept (reversible, per-rule demotion) rather than an all-or-nothing build break.
- Recall anchors are mandatory: at least one
vuln_by_designtarget per language family before that family's rows count toward recall. C and Rust are flaggedNEEDS_RECALL_ANCHORuntil anchored — so a precision win can never be bought with an unmeasured recall loss. - Every FP fixed becomes a
ground_truth=Fregression entry so a PR that re-introduces it fails.
Honest scope. This program raises precision; it does not add new detections, and recall on real CVEs is bootstrap-level. Suppression is always fail-open.
7. Tier-2 semantic verification (opt-in FP instrument)
Gadriel Code can optionally ask the host LLM to help refute a false
positive. This is opt-in — it is off by default and
enabled per scan via the .gadriel.toml [tier2] opt-in / the CLI flag.
It is not a detector; it is a precision instrument with a
deliberately narrow surface.
Eligibility is bounded by construction to one band: a Tier-2 refutation only
changes anything for scan_type == Sast && confidence_tier == Medium. SAST-Low
is already non-gating (§1.2) and SAST never emits High deterministically — so
Medium is the only band where a refutation can flip the gating decision.
What a refutation does: when the host LLM is asked to actively try to prove
finding {id} is a false positive and returns a concrete safety-mechanism
citation, the fusion engine (tier2_fusion.rs) writes exactly one field —
confidence_tier → Low — and stamps an ai_refutation audit block. The
confidence→domain gate then reclassifies the finding as non-gating advisory. Severity
is never touched, and a deterministic code-vuln can never be suppressed by any
AI input (locked by tests such as
never_suppress_deterministic_code_vuln_survives_all_ai_inputs).
The three-way LLM verdict maps onto existing, tested fusion arms:
| LLM verdict | Effect |
|---|---|
| Confirmed | Corroboration annotation (DeterministicAiCorroborated); may bump confidence one band on strong dataflow overlap. |
| Refuted | Demotion arm: confidence_tier → Low → non-gating. |
| Needs more context ("I can't tell") | No change — the honest answer never forces a confirm/refute. |
Budget is spent only where it can flip a decision: a boundary-proximate Medium finding from a historically-noisy rule with ambiguous reachability is prioritised over one sitting deep inside "clearly Medium" (where an LLM read risks a hallucinated demotion for no gain).
8. Practical triage workflow
When a scan surfaces findings, work them in effective-risk order:
- Read the gating stream first. Only
CodeVulnandSupplyChaindomains gate (§ scoring-and-verdicts.md). Everything in the advisory section (hardening, CI/config, coverage, AI advisory, and demoted Low-confidence SAST) is informational — triage it, but it will not block your build. - For each gating finding, look at effective risk and the
unverifiedbadge, not raw severity. AnUNVERIFIEDbadge means the engine has not confirmed it — treat it as "needs a human look", starting with the highest effective band. - Check provenance. If the taint source is
Env/LocalCli/Conston a path/memory sink, it is likely already demoted; if it somehow gates, that's a strong FP signal worth a--false-positive. - Decide: waive or fix.
- Waive (
gadriel code fix <id> --false-positive) when the code is genuinely safe for a reason the engine can't see (an auth gate upstream, a trusted operator-only path, a debug-only hook). Add areason, and set anexpiresif the safety argument is temporary. - Fix the code when the finding is real, or run
gadriel code fix <id>(bare) to get an AI-proposed remediation. Use--confirm-realto record a true positive without a code change (e.g. tracked in a ticket).
- Waive (
- Commit
.gadriel-waivers.tomlalongside the code so the disposition is durable and reviewable, and re-run the scan to confirm a clean gate.
Rule of thumb: waive the finding, not the rule. A (rule_id, path, line) waiver disposes of one specific false positive; if a whole rule is
systematically wrong, that belongs in the benchmark/quarantine program (§6), not
in per-repo waivers.
Cross-references
- scoring-and-verdicts.md — the risk formula, pillar/overall scores, PASS/PARTIAL/FAIL verdicts, coverage status, and exit codes.
- Ground truth in source:
crates/code-security/gadriel-finding-types/src/confidence.rs,.../effective_risk.rs,.../finding.rs(domain()),crates/gadriel-cli/src/commands/code/waivers.rs,crates/gadriel-cli/src/commands/code/fix.rs.
