assay
Explore

Research

Pilot 001, second edition: what a blind run found

Three tools, four SQL injection shapes, attacks the tools never saw. Every tool shut the hole. What failed was the measurement.

Founder, Assay · Run 14 September, published · 8 min read

We gave three AI tools the same four SQL injection flaws and asked each to fix them. Then we attacked the patched code with payloads the tools had never seen, and compared ordinary behaviour against a known correct reference.

Every tool shut the hole. Not one held-out attack succeeded against any patch.

What failed was the measurement. A patch broke the application while the scanner reported success. A scanner alert closed because a line had been reformatted rather than fixed. And on one flaw the scanner raised no alert at all, so nothing was reported and nothing was repaired.

A closed alert is a fact about the diff. It is not a fact about the hole.

Results

Verdict per tool per finding across the standing blind cells.
FindingChatGPTClaude CodeCopilot Autofix
Quoted string 01VERIFIEDVERIFIEDnot supplied
Numeric context 04VERIFIEDNOT VERIFIEDVERIFIED
ORDER BY identifier 08NOT VERIFIEDVERIFIEDNOT VERIFIED
Second order 13VERIFIEDVERIFIEDno alert raised

10 scored cells · alert closed 9 of 10 · verified 7 of 10 · 117 held-out attacks fired, 0 succeeded

Every NOT VERIFIED verdict was earned on behaviour or on the scanner, never on the hole.

What we tested

A small Flask application with a SQLite database, carrying four deliberately written SQL injection flaws. We wrote the flaws, so the correct fix for each is known.

Four shapes · a patch that works on one often fails on another

Quoted stringSingle parameter. The baseline case.
Numeric contextNo quotes anywhere in the query, so a patch that escapes or strips quotes does nothing.
ORDER BY identifierA column name cannot be a bound parameter. The only correct defence is an allow-list.
Second orderThe input is stored safely in one function and reaches the vulnerable query in another.

For each flaw we wrote a held-out attack set and a set of ordinary inputs that must keep working. The fixer was given the file, the scanner finding, and exactly one reported payload. Nothing else. After the patch is applied, four checks run inside a container with no network egress, destroyed between patches.

Scored on every patch, mechanically

01A. Alert closed. Does the scanner still report the finding.
02B. Hole shut. Does any held-out attack still succeed. One success is a failure.
03C. Behaviour preserved. Does every ordinary input still match a baseline captured from a known correct reference.
04D. Provenance clean. Suppression markers, new findings, or an alert closed while attacks still succeed.

VERIFIED requires all four. NO ALERT and INSUFFICIENT EVIDENCE are reported outcomes, not silent failures.

The tools

As pinned in the evidence repository

ChatGPTModel ChatGPT Think. 2026-09-14. Findings 01, 04, 08, 13.
Claude CodeClaude Code 2.1.270, model Opus 5. 2026-09-14. Findings 01, 04, 08, 13.
Copilot AutofixGitHub Copilot Autofix on CodeQL 2.27.0, default suite, threat model remote. 2026-09-14. Findings 04, 08, 13.
CursorAttempted. The blind batch could not be completed within this edition’s window. Its earlier results are published marked provisional and count in no figure here.not counted

Finding one: the fix is secure and the application is broken

The ORDER BY flaw can only be fixed with an allow-list, and the table has five sortable columns, all of them in the seed file every tool was given.

The allow-list each tool wrote · finding 08

id, name, priceCopilot Autofix. ChatGPT. The same three columns, the same two missing.
id, name, price, category, skuClaude Code. All five.

Check C failed on the four inputs that sort by the missing columns and on nothing else · b07, b08, b09, b10

Sorting by either omitted column now fails, silently in one case and with a rejection in the other, and the scanner reported both patches clean. This reproduced three times across two vendors, on the contaminated corpus and the blind one, and did not occur with the third tool.

Finding two: the alert closed because the line was reformatted

Two tools closed the scanner alert on the ORDER BY flaw by splitting one formatted string into two adjacent ones, with the interpolation unchanged.

app/finding_08.py · chatgpt-blind+4 −1
38 conn = get_connection()
39 cur = conn.cursor()
40 
41- query = f"SELECT id, name, {column} AS featured FROM products ORDER BY {column} {direction}"
46+ query = (
47+ f"SELECT id, name, {column} AS featured "
48+ f"FROM products ORDER BY {column} {direction}"
49+ )
50 rows = cur.execute(query).fetchall()
Semgrep before: 1 finding · after: 0 findings · the column is still formatted into the SQL in two places
Semgrep findings on four versions of the module: hole open or shut, query as one string or two.
Semgrep findingsOne stringTwo strings
Hole open10
Hole shut10

The result follows the column, never the row.

Neither tool was evading anything; both tidied a long line while shutting the hole with the allow-list. The same mechanism would produce an identical green alert on a patch that fixed nothing, and until now we had only shown it with a patch we constructed for the purpose.

Finding three: no alert at all

On the second-order flaw, where the input is stored by one function and used by another, the scan completed normally.

CodeQL 2.27.0 · default suite · threat model remote · finding 13

43rules run.
0results returned.

The rule that did not fire · py/sql-injection

The result was identical on the corpus with every comment describing the flaw removed, so the silence is a property of the analysis. Two of the three tools fixed this flaw correctly when they were told about it; the third was never told.

A tension worth stating

On the numeric flaw, one tool bound the parameter and nothing else. Another bound the parameter and also validated the input as an integer.

app/finding_04.py · claude-code-blind+4 −1
29- pid = request.args.get("id", "0")
29+ try:
30+ pid = int(request.args.get("id", "0"))
31+ except ValueError:
32+ return jsonify({"error": "id must be an integer"}), 400
41 results = [dict(row) for row in rows]
42 return jsonify({"query_id": pid, "count": len(results), "results": results})
Line 42 unchanged: query_id now echoes 3 where the baseline echoes "3" · 14 of 14 benign inputs regressed

The minimal fix passed and the stricter one failed check C, because the check compares responses exactly and the response contract changed. The check is correct, a reader may still feel the stricter fix was the better engineering, and we would rather publish the tension than quietly resolve it.

What this does not show

Stated by us, before anyone else states them

One of everythingOne weakness class. One language. One small application. Four flaws. Three tools. Ten scored cells.
A signal, not a rateNo ranking should be read from it. The strongest result here, the narrow allow-list, reproduced across two vendors and is still a single flaw shape.
Not holes left openOn this corpus the tools did not leave a vulnerability open, once.
The easiest caseA sixty line file with the flaw on screen. A fixer that succeeds here may not succeed where the sink is a thousand lines from the input.
A real advantageEvery tool was told where to look, with a reported payload.
Assumed, not observedResults assume each tool worked from the material it was given and did not search for this corpus, which is public. We cannot observe a hosted tool’s browsing.

Why every earlier result was discarded

An earlier run of the same experiment produced similar numbers. We discarded it. The vulnerable files carried comments explaining the flaw and, in one case, naming the correct fix, so every tool had been handed the answer. We found this while preparing a second run, stripped the corpus, rebuilt the handoff material, and ran everything again.

The earlier results are published, marked provisional, with the reason: a valid test of the harness and an invalid measurement of the tools. For one tool the blind result was identical to the contaminated one, check for check. That is a useful data point and not a general one.

What our own method got wrong

Seven times, a check of ours passed for a reason unrelated to the thing it was meant to establish.

Each failed in the flattering direction

App never startedA patch that failed to start scored as a patch that shut the hole, because every attack failed to connect and every failure was recorded as an attack that did not succeed.note 4
Guard read the treeA guard reported no leak before publication, correctly, about the working tree, while the answer key sat in an earlier commit that would have been public permanently.note 6
Claimed, not codedA published note claimed a verification that did not exist in code.note 7
Check A false failA correct fix was failed by our own check A, because an allow-list cannot remove the pattern the rule matches.note 9
Answer in proseThe corpus handed the fixer the answer in prose.note 12
Correct by definitionA guard searched for payloads, oracles and fix code, and was correct by its own definition, while explanatory prose walked past it.note 12
Passed on an incomplete treeA leak guard reported clean while the corpus files it should have scanned were not yet tracked, so a payload inside them was invisible to it.note 14

A check that wrongly accuses a good patch is investigated within the hour. A check that wrongly clears a bad one is the answer nobody interrogates. All seven are documented in full in the methodology notes, including how each was found and closed. A check that has only ever passed has not been tested.

The seventh was found after this run, while building the benchmark, and is counted here because it is the same failure in a new place: a check of ours passing for a reason unrelated to what it was meant to establish. It is note 14 in the methodology notes.

What would change our mind

Stated before the next edition, not after

Holes left openIf a broader corpus shows tools leaving holes open at any material rate, the original framing was right and we will say so.
No reproductionIf the behavioural failures do not reproduce on other flaw classes, the over-broad result is a curiosity about one shape rather than a finding.
Second order trackedIf a scanner tracks second-order flow reliably, that gap closes.

None of that would change the central claim, which does not depend on how well the tools perform. A closed alert would still be a fact about the diff.

What is published

Pilot 001 evidence

PublishedThe harness, the corpus, the scorer, every patch diff, the per-check evidence, the pinned versions, and the methodology notes.
WithheldThe held-out attack sets, because publishing them allows a patch to be tuned against them.

That is the only thing we hold back · and this is the reason

The repository is public at github.com/bakarrovenin/assay-pilot-001-evidence.

Right of reply

Every tool named here was contacted with its results on 14 September, before publication. As of publication only automated acknowledgements have arrived; none of the three has responded substantively. This edition will be updated, unedited, with any reply that follows. Responses appear below.

Responses · unedited

ChatGPTAutomated acknowledgement only. No substantive response yet.
Claude CodeAutomated acknowledgement only. No substantive response yet.
Copilot AutofixAutomated acknowledgement only. No substantive response yet.

Nullius in verba.

← All evidence

Join the waitlist

A verifier that runs on every pull request, on your own repositories, is in development. Leave an address and we will tell you when it is ready.

One message when your slot opens. Nothing else, ever.