Skip to content
Detection benchmark · Adversarial corpusPublished 2026-08-06 · Corrected 2026-08-08

Six of nine caught. Zero false positives.

A first-pass verification gate is only worth installing if it never cries wolf. So before we shipped six new rules for the bugs AI agents actually write, we built a corpus of nine of them, measured exactly how many the engine sees, and then pointed those rules at eight real repositories to find out how often they fire when nothing is wrong. This page is the whole method, the whole result, and the three bugs we do not catch.

Detected
6 / 9
False positives · this corpus
0
Repositories swept
8
Lines swept
177,703

§ 00 — Correction · 2026-08-08

A corpus we did not choose found false positives. Including a CRITICAL.


Everything below this section is a result on eight repositories we selected. Two days after publishing it we ran the same engine, and Semgrep CE beside it, over ten repositories selected by a rule written before either tool ran — top-starred, actively maintained, size-bounded — pinned to fixed commits. That corpus produced false positives this one did not.

The worst of them was a CRITICAL, and it was a textbook one. In firecrawl/firecrawl, apps/api/src/db/rpc.ts:98 is drizzle’s db.execute(sql`…`). A tagged template hands its interpolations to the tag as separate bound values; they never enter the string. We reported it CWE-89 CRITICAL with the words “untrusted input is concatenated into a SQL query”, which is the opposite of what that line does. Semgrep reported nothing on the file.

The same rule was silent on a real one in the same run: uptime-kuma’s server/monitor-types/postgres.js:63 executes a query string handed in by its caller. Semgrep flagged it. And 43% of our security findings on that corpus sat in test and fixture paths, against Semgrep’s 2% — mostly synthetic keys reported HIGH as committed credentials, three of them inside a repository’s unit tests for its own secret scanner.

All three are fixed in CLI 0.2.53, and the fix was measured on the same ten repositories rather than on ours. Published 0.2.51 against 0.2.53, same machine, same day: total findings 898 → 904, with eight of the ten repositories byte-identical. The drizzle CRITICAL is gone (CRITICALs 3 → 2). The uptime-kuma query is reported, along with the same shape in three sibling drivers Semgrep did not flag (rule sql-injection 1 → 7). Twenty-five synthetic test credentials moved from HIGH to LOW, taking HIGH-or-CRITICAL security findings from 77 to 58.

What we are not claiming.Not “now zero false positives”. Two CRITICALs remain and the benchmark judged both of them wrong: excalidraw’s Firebase web apiKey values, which Google documents as public client identifiers. That fix is a different one and has not been designed yet. Two synthetic keys also still report HIGH. Those are named in the 0.2.53 changelog rather than rounded away.

Why the number below still stands, and what it is worth.It was true of those eight repositories and it is left exactly as published. What it is not is a precision claim about this engine in general — and the difference between those two things is the entire content of this section. The corpus a tool’s authors choose is the sample that hides their blind spots. This page said precision would eventually break and the correction would get published. It broke, and this is it.

§ 01 — Thesis

A gate that cries wolf gets uninstalled. That makes precision the product.


Every automated reviewer trades recall against precision, and almost every vendor optimizes the side that demos well. Recall is easy to show on stage: point the tool at a repository, watch findings scroll. Precision is invisible until the fourth week, when a developer has dismissed the same wrong finding six times and turns the check off. At that point the tool’s recall is zero, because it is not running.

This is not our theory about developer psychology, it is the security industry’s measured cost. In a survey of 1,150 security leaders, teams reported spending 14.1 hours a week chasing false-positive alerts, and 73% said the time spent tracing alerts hurts their ability to focus on real threats.

Meanwhile the published accuracy numbers for AI code review are not good. Entelligence’s 2026 benchmark of 67 real production bugs put the field’s best result at 47.2% F1— nothing in it cleared 50%. DeepSource’s comparison on the OpenSSF CVE Benchmark reports 36.19% F1 for CodeRabbit; Entelligence reports 33.0% for the same product. Both benchmarks were published by companies selling competing tools, and each ranks itself first, which is the honest reason to quote the shape rather than the decimal: independent measurements keep landing under 50% F1 and no two agree. The market’s response was not higher accuracy, it was a new metric — “resolution rate,” which counts how often a developer accepted a suggestion rather than how often the tool was right.

So we set the bar where a gate has to be set. A finding that fires must be true at the line it names, or the rule does not ship. Recall is something we grow honestly, one rule at a time, in public. This page reports both halves.

Sources, in order: Illumio / Vitreous World alert survey (1,150 security leaders) · Entelligence 2026 code review benchmark · DeepSource AI code review tools comparison · DeepSource — notes on AI code review benchmarks

§ 02 — Method

How the corpus was built and what counted as a hit.


The corpus

Two files, 52 lines, nine planted defects. Every one is a mistake we have watched coding agents make in real repositories: the agent writes a loop that reads the related record per item, forgets an await on a write, spreads a request body into an update, wraps a call in an empty catch, compares with ==, or ships a handler with no tenant filter and no authorization check.

The corpus is deliberately small and deliberately ours. It is not a CVE set and it is not sampled from the wild, so it measures what our rules see on the bug classes we chose to target — nothing more. The counterweight to that self-selection is the sweep in §05, which is not ours at all.

What “detected” required

A hit had to land in the right file, on the right line, with a message that described the actual defect. “Somewhere in this file” is not a detection, because a gate that cannot point at the line cannot be acted on. All six hits below met that bar; each rule fired exactly once.

The scan also had to be complete before its output counted: two of two files scanned, no truncation, no degraded languages, no budget or resource limit reached. A green sheet from a scan that quietly stopped early is a lie, and our own history includes exactly that failure mode.

The refute-first protocol

Each of the eight sweep targets was worked by a separate reviewer whose job was to break the result, not confirm it. Four gates had to pass before any target could be recorded.

  1. 01

    Prove the scan was complete

    Every run records files scanned versus files offered, truncation, degraded languages, and budget state. A partial scan cannot produce a clean sheet, because a clean sheet from an engine that quietly gave up is the worst artifact in this business.

  2. 02

    Prove the rules were alive

    Zero findings and a broken rule registration look identical from the outside. So every target that returned zero also got a canary fixture pushed through the identical harness path; every rule under test had to fire at its planted line before the zero was accepted.

  3. 03

    Try to refute the zero by hand

    For each rule we searched the target for its candidate shape and read every near miss: catch clauses, loose comparisons, query sites in loops, write payloads. A zero is only publishable as a set of adjudicated true negatives, never as an absence of evidence.

  4. 04

    Adjudicate every fire against the source

    Each finding was opened at its line and argued both ways. The verdicts were true positive, defensible, or false positive — and defensible was treated as debt to be fixed before shipping, not as a win.

§ 03 — Corpus result

Six of nine, each at the exact line. Three misses, named.


Nine planted bugs — rule, CWE, severity, result
Bug classSiteRuleCWESeverityResult
N+1 queryA findUnique for the related customer inside a for-loop over invoices.invoices.ts:13db-call-in-loopCWE-1050MEDIUMCAUGHT
Floating database writeAn audit-log create whose promise is discarded — never awaited, returned, or handled.invoices.ts:21unawaited-persistenceCWE-252MEDIUMCAUGHT
Mass assignmentAn open-record payload spread wholesale into an update. Reported as the contract defect the evidence supports, not as a request-flow claim.invoices.ts:26open-record-writeCWE-915MEDIUMCAUGHT
Read-modify-write raceA balance read, incremented in memory, then written back to the same row with no transaction. The rule fires only when the write payload recomputes a field of the row just read — that arithmetic dependency is what keeps it silent on the atomic-increment fix and on compare-and-set writes.invoices.ts:31read-modify-write-raceCWE-362MEDIUMCAUGHT
Swallowed errorAn empty catch around an outbound fetch, with no logging and no comment.invoices.ts:39swallowed-errorCWE-1069LOWCAUGHT
Loose equalityamount == 0, where the string '0' and the empty string both pass the guard.invoices.ts:49loose-equalityCWE-697MEDIUMCAUGHT
IDORAn invoice fetched by id with no tenant filter, so any user can read any org.invoices.ts:4CWE-639MISSED
Pagination off-by-oneskip: pageNum * size, which drops one record when pages are 1-based.invoices.ts:43CWE-193MISSED
Missing authorizationA mutating POST route handler with no session or role check anywhere on the path.route.ts:4CWE-862MISSED

Six findings, six true positives, zero false positives on the corpus. The scan was complete: two of two files, no truncation, no degraded languages. Two further findings came from repository-structure analyzers (missing README, no CI pipeline) and are unrelated to the planted bugs — they are neither counted as hits nor as noise.

One detail worth stating plainly, because it is the difference between a benchmark and a brochure: the mass-assignment bug is reported by open-record-write at MEDIUM, not by the HIGH request-flow rule. In the corpus the payload arrives as a Record<string, unknown> parameter, so the evidence at that line supports a contract claim, not a proven request flow. §07 explains why that distinction exists.

§ 04 — Misses

The three we do not catch, and the analysis each one actually needs.


These are the most interesting rows in the table. Each of the three is reachable with a regex that would also fire on correct code — which is exactly the trade this benchmark exists to refuse. Here is what each one really needs.

IDOR — no tenant filter on a read

The defect is the absence of a predicate, and absence is only a defect relative to a schema. A syntactic rule for "findUnique without an orgId" fires on every legitimate lookup by primary key in every repository on earth. Deciding this correctly means knowing which columns carry tenancy for this schema, whether the caller already constrained the query, and whether the handler is tenant-scoped upstream. That is source-to-sink dataflow plus schema ownership, not a node shape.

Missing authorization on a mutating route

The honest question is whether an authorization guard dominates every path that reaches the write — a guard can live in the handler, a wrapper, a layout, proxy middleware, or the framework config. Grepping the file for the word "session" is a coin flip, and a coin flip is a false positive half the time. This needs route enumeration and guard-reachability analysis over the call graph.

Pagination off-by-one

skip: pageNum * size is correct for 0-based pages and wrong for 1-based pages. The line is not the bug; the mismatch between the line and the caller convention is the bug, and the convention is not in the file. Closing this needs the caller contract — invariants or property tests, not a pattern.

This section listed four misses when the benchmark was first published. The read-modify-write race was named there as the most rule-able of them and the next one we intended to close. It shipped as read-modify-write-race and now sits in the caught half of the table above — moved because it was closed, not because the bar moved.

Two of the three — IDOR and missing authorization — were named here as the reason a dataflow substrate sat at the top of the engine roadmap. That substrate has since shipped as the hosted Route Authorization Graph, and this paragraph is the update it owes you. The table above is unchanged, and the count is still 6 of 9. The nine rows record what the deterministic rule pack does on this corpus, and the authorization graph is not a rule-pack rule: it is a hosted-only pass, off for every scan type except a security scan, and it runs no part of the CLI.

What it changes is the answer to the question, not the number in the table. Missing authorization (CWE-862) is detected today, and the shape it is tested against is this corpus’s bug 9 exactly — a mutating POST whose write happens in an imported helper, reported cross-file at HIGH with the entry and the write it reaches both named. IDOR (CWE-639) is detected today as well, but it does not fire on bug 1 of this fixture, and it will not. Two structural reasons, both of them permanent properties of this corpus rather than bugs waiting to be fixed: the analysis is intra-entry, and bug 1’s query lives in invoices.ts, a helper the route calls rather than the handler itself; and the tenancy oracle is read from a Prisma or Drizzle schema, which a two-file fixture does not have. A repository with no machine-readable schema produces no IDOR finding ever, and the pass records SKIPPED rather than a clean result, so a report cannot claim a class nothing checked. The row stays a miss because on this fixture it is one.

Where the detector does fire is a repository that has both — a framework entry convention and a schema. Its measured result, its corpus, and the five things it cannot see are on the code audit page. What this page will not do is quietly restate 6 of 9 as a better number because a different pass, in a different product surface, closed a class this fixture cannot exercise.

§ 05 — Adversarial sweep

Eight repositories, 177,703 lines, zero false positives.


Detection on a corpus you wrote yourself proves almost nothing about precision. The real test is pointing new rules at code that was never meant to trip them. We swept seven public SaaS starters and boilerplates — chosen because they are dense with exactly the shapes these rules look at — plus CodeTruss itself, and read every single fire by hand against its source.

Eight repositories — coverage, fires, adjudication
TargetFilesLOCScannedNew-rule firesAdjudication
jonradoff/lastsaas130 catch clauses, every one handling or documenting. Zero loose comparisons in TypeScript. Go backend sits outside SAST language coverage and the report says so.26048,692187 / 1870No fires
nextjs/saas-starterEight catch clauses, all logging and responding. Every Drizzle write awaited, returned, or sitting in a Promise.all argument position the anchor structurally excludes.553,57642 / 420No fires
boxyhq/saas-starter-kitThe two hits that produced the severity split. Eight-plus loose comparisons elsewhere in the repo were correctly suppressed against non-coercion-prone literals (POST, per_unit, tiered).32717,157261 / 2612Both true, both re-priced
get-convex/convex-saasTwo discarded ctx.db.patch promises in convex/app.ts. Per-key queries inside asyncMap callbacks were correctly not flagged as N+1 by the function-boundary gate.815,13158 / 582Two true positives
ixartz/SaaS-BoilerplateWeak evidence, and we say so: the repository contains no candidate shapes at all — no try/catch, no loose equality, no query sites — so it exercised almost none of the suppression logic.1513,46588 / 880No fires
sudharsangs/nextjs-multitenant-saas-boilerplateThe one empty catch wraps a clipboard write and is suppressed by the cleanup carve-out. A real per-item query inside Promise.all was left alone by the function-boundary gate — a deliberate miss, logged as one.1008,66079 / 790No fires
fastapi/full-stack-fastapi-templateThe Python backend idioms these rules were designed around — session.commit(), Model.model_validate — were correctly left alone. No except/pass anywhere in the backend.22810,069105 / 1050No fires
CodeTruss (this repository)All 19 SAST findings came from older rules. Four near-miss sites held: two best-effort cleanup catches in the CLI, and two bounded-lookback loops with early exits.69580,953380 / 3800No fires

Four fires across 177,703 lines. Two were clean true positives in a public template (§06). Two were factually true but priced wrong, and rather than publish around them we changed the engine (§07). No fire in the sweep was false at its line.

Six targets returned zero. We do not present those as wins by themselves — a zero earns nothing until you have proved the rules were alive and read every near miss in the file. Two targets contain almost no candidate shapes at all, and the table says so instead of counting them as evidence.

The sixth rule, read-modify-write-race, shipped after the table above and was swept the same way: the same eight targets plus the corpus, re-measured at 1,917 files and 181,585 lines. It is a larger line count than the 177,703 above only because this repository itself grew between the two runs; the eight targets are the same eight. One hit came back across all of it — the corpus true positive at invoices.ts:31 — and nothing fired anywhere else. The loose version of the shape (any read followed by any write to the same model) was measured at 20 pairs in this repository alone, 17 of them benign. Requiring the write to recompute a field of the row just read is the entire difference between 17 arguments and one finding.

Everything in this section is a result on eight repositories wepicked, and that is the sweep’s limit rather than a detail of it. Read §00 before quoting the number: on ten repositories picked by a stated rule instead, this engine produced false positives, including a CRITICAL.

§ 06 — Case study

A real dropped write, in a template thousands of people fork.


The sweep’s two true positives both landed in get-convex/convex-saas, at convex/app.ts lines 108 and 119. Both are Convex mutation handlers whose entire purpose is to persist one change, and in both the database call is written as a bare statement with its promise discarded.

What makes these adjudicable rather than arguable is the evidence sitting in the same file: sibling mutations in that module await the identical call. The intended shape is unambiguous, and the two sites do not match it. If the write rejects — schema validation, a conflict, a transient failure — the handler has already returned success and the error is discarded.

This is the class of defect that survives review precisely because it looks finished. Nothing about the line is ugly. There is no missing brace, no obvious smell, no failing test. One keyword is absent, and a write silently stops being guaranteed.

Reported here as an analysis result on public MIT-licensed source at the commit we scanned, not as a vulnerability disclosure or a criticism of the project. Convex ships a good template; this is what a first-pass gate is for.

§ 07 — Severity honesty

Two findings were true and still wrong. So we split the rule.


In boxyhq/saas-starter-kit the mass-assignment rule fired at HIGH on models/subscription.ts:40, where a helper takes data: any and passes it wholesale into an update. Every factual claim in that finding was true. The severity was not: the only caller is an allowlisted literal built from a verified Stripe webhook, so nothing in the evidence showed a request body reaching the write.

A HIGH finding asserts something the analysis had not established. Under our own standard that is a defect in the rule, even though the pattern claim held. Calling it “technically correct” and shipping is how tools earn the reputation this benchmark exists to avoid.

So the rule was split before release. The HIGH rule now requires actual request-flow evidence at the site. The open-record contract — a payload typed any or Record<string, unknown> written straight into a record — reports separately at MEDIUM, which is what that evidence actually supports: the contract invites mass assignment the day one new call site is fed by a request.

The second hit was an N+1 in a manual admin script. True shape, irrelevant cost, so script and seed paths are now excluded from that rule rather than argued with in the report.

Before / after · models/subscription.ts:40


Before the split
HIGH · Mass assignment: request body written wholesale to a database record

Asserts a request flow that the evidence at this line does not contain.

After the split
MEDIUM · Database write helper accepts an open-record payload

Says exactly what is on the page: the type contract lets any caller key become a column update.


Severity is a claim about evidence. It never gets to run ahead of the analysis that produced it.

§ 08 — Reproducibility

The corpus, in full. Copy it and run it yourself.


A benchmark you cannot re-run is marketing. Both corpus files are printed here complete — 52 lines total. Drop them into a repository at these paths and run codetruss review --task “Benchmark reproduction”.

The free local CLI reports five security findings — lines 13, 21, 26, 39, and 49 of src/lib/invoices.ts — and nothing in src/app/api/invoices/route.ts. The sixth row of the table above, the read-modify-write race at invoices.ts:31, is produced by read-modify-write-race, which runs only in the hosted audit. So 6 of 9 is the hosted rule-pack number, and 5 of 9 is what a local run on your own machine will show you. If you want the sixth on the same corpus, it takes a hosted scan; §09 has the rest of that split.

src/lib/invoices.ts
import { db } from './db'

// BUG 1 (IDOR): no orgId filter — any user reads any org's invoice
export async function getInvoice(invoiceId: string) {
  return db.invoice.findUnique({ where: { id: invoiceId } })
}

// BUG 2 (N+1): query inside a loop
export async function listWithCustomers(orgId: string) {
  const invoices = await db.invoice.findMany({ where: { orgId } })
  const out = []
  for (const inv of invoices) {
    const customer = await db.customer.findUnique({ where: { id: inv.customerId } })
    out.push({ ...inv, customer })
  }
  return out
}

// BUG 3 (floating promise): not awaited — write silently lost on error
export function recordAudit(orgId: string, action: string) {
  db.auditLog.create({ data: { orgId, action } })
}

// BUG 4 (mass assignment): spreads raw client input into an update
export async function updateInvoice(id: string, body: Record<string, unknown>) {
  return db.invoice.update({ where: { id }, data: { ...body } })
}

// BUG 5 (race): read-modify-write without a transaction
export async function incrementBalance(id: string, amount: number) {
  const inv = await db.invoice.findUnique({ where: { id } })
  return db.invoice.update({ where: { id }, data: { balance: inv.balance + amount } })
}

// BUG 6 (swallowed error): empty catch hides failures
export async function sendReceipt(id: string) {
  try {
    await fetch(`https://mail.example.com/send?invoice=${id}`)
  } catch {}
}

// BUG 7 (off-by-one): page 1 skips a record
export async function page(orgId: string, pageNum: number, size = 20) {
  return db.invoice.findMany({ where: { orgId }, skip: pageNum * size, take: size })
}

// BUG 8 (loose equality): '0' == 0 is true, so zero-amount passes
export function isPaid(amount: unknown) {
  return amount == 0
}
src/app/api/invoices/route.ts
import { getInvoice, updateInvoice } from '@/lib/invoices'

// BUG 9 (missing authz): no session/role check on a mutating route
export async function POST(req: Request) {
  const body = await req.json()
  return Response.json(await updateInvoice(body.id, body))
}

export async function GET(req: Request) {
  const { searchParams } = new URL(req.url)
  return Response.json(await getInvoice(searchParams.get('id')!))
}

The six rules

db-call-in-loop
CWE-1050 · MEDIUM · awaited DB read in a loop whose arguments change per iteration
unawaited-persistence
CWE-252 · MEDIUM · a write whose promise is discarded entirely
mass-assignment
CWE-915 · HIGH · a request object reaching a write payload
open-record-write
CWE-915 · MEDIUM · an any/Record payload written wholesale to a record
read-modify-write-race · hosted only
CWE-362 · MEDIUM · a row read, recomputed arithmetically, and written back outside a transaction. Not in the local CLI subset, so it does not fire on the reproduction above.
swallowed-error
CWE-1069 · LOW · an empty catch with no handling, logging, or comment
loose-equality
CWE-697 · MEDIUM · == or != against a coercion-prone literal

The silences are the design

Each rule carries explicit carve-outs, and they are why the sweep came back clean: x == null is never flagged; a comment inside a catch is documented intent; best-effort cleanup is exempt; a function boundary between the loop and the query means the query belongs to a callback; bounded loops with early exits are searches, not N+1; a read-modify-write pair goes quiet under a transaction, a row lock, a compare-and-set guard, or an atomic increment operator; and script, seed, and migration paths are out of scope for cost rules.

§ 09 — Limits

What this benchmark does not show.


It is not a comparative claim.We did not run CodeRabbit, Semgrep, Snyk, Bugbot, or any other tool against this corpus. The third-party accuracy numbers quoted in §01 are those vendors’ and researchers’ published figures, cited so you can check them; they are not measurements we made, and they were not produced on this corpus. Nothing here says we score higher than anyone.

The corpus is ours. Nine bug classes we selected, planted in code we wrote. It measures the rules we built against the bugs we chose to target. A corpus written by someone else would produce a different detection rate, and the honest expectation is that it would be lower.

Six of nine is still a low recall number, and it is the number. Three of the nine bug classes here are invisible to this engine today, including the two with the worst security consequences. If your risk is IDOR, this is not yet the tool that finds it.

Zero false positives is a result on eight repositories,not a law of nature. It is 177,703 lines of mostly TypeScript SaaS code. This paragraph used to end “when precision breaks, the fix is the rule, and the correction gets published” as a promise. Precision broke on 2026-08-08, on a ten-repository corpus selected by a rule instead of by us, and §00 is that correction. Alien idioms, other languages, and other frameworks will keep producing fires we have to argue with; the commitment is not that it stops happening, it is that you read about it here.

Detection is not the whole verification story. All six rules run in the hosted analysis. Five of them — db-call-in-loop, unawaited-persistence, open-record-write, swallowed-error, and loose-equality — also run locally in the CLI, each of which earned its place by matching the hosted parser with zero disagreement across the real-repository corpus. That is a parser-parity result and not a precision one; the source comment beside that rule list claimed both until §00. read-modify-write-race stays hosted. The local CLI runs that subset alongside the registry analyzers and discloses which passes did not apply rather than implying coverage it does not have. Where a run could not analyze something, the receipt says so.

Everything here is a snapshot. The public repositories were scanned at the commits available on 2026-08-06; upstream code changes. Findings are analysis results on public source, not vulnerability disclosures, and no project named here is affiliated with or endorsing CodeTruss.

Run the same gate on your own repository.

The CLI is free and runs locally: it classifies every changed path, runs the deterministic analyzers and your own verification commands, and leaves a signed receipt that records what was checked and what was not. The hosted audit adds the whole-repository pass these six rules run in.