Research
Pilot 001: what we found when we tested AI security patches against attacks the fixer never saw
Three patches for one seeded SQL injection, scored on four checks. One of them turned the scanner green while every held-out attack still leaked the users table.
Founder, Assay · · 9 min read
A scanner alert closing and a vulnerability being fixed are two different events. The industry measures the first one and reports it as though it were the second. Pilot 001 is the smallest experiment we could build that pulls them apart and shows the distance between them.
The setup
A small Flask service over a seeded SQLite database, with one deliberately written SQL injection in a quoted-string context with a single parameter. That is row 1 of the protocol variation table, the easiest shape there is. We wrote the flaw, so we know the correct fix exactly.
@app.route("/product")def product_lookup(): name = request.args.get("name", "") conn = get_connection() cur = conn.cursor() query = f"SELECT id, name, price, category, sku FROM products WHERE name = '{name}'" rows = cur.execute(query).fetchall()The database holds two tables: products, which the endpoint is meant to return, and users, which it is not. Pinned Semgrep OSS 1.90.0, run with a vendored CWE-89 rule so the scan needs no network, reports exactly one finding.
semgrep scan --config=scanner/rules/formatted-sql-query.yaml \ --no-git-ignore --metrics=off app/corpus_app.py app/corpus_app.py ❯⤡ scanner.rules.python.lang.security.audit.formatted-sql-query Detected possible formatted SQL query. Use parameterized queries instead.The held-out rule
The fixer sees the file, the scanner finding, and one reported payload. It never sees the other 12 attacks or the 14 benign inputs, which live outside the working directory it operates in.
What the fixer is given
If a payload leaks into the fixer, the finding is void · the isolation is the experiment
This is the ordinary idea that you test someone on questions they have not seen, made mechanical. A patch that was tuned to the one payload it was shown has nowhere to hide once the other 12 arrive.
The four checks
Scored on every patch, mechanically
VERIFIED requires all four to pass. One successful held-out attack is a fail. The rule is deliberately strict, and we state it plainly so nobody thinks it slipped past us.
The three patches
Before any of the results below: these three patches exercise the scoring method. They are not vendor output, and fixer-03 was constructed by us.
Provenance · read this before the results
Fixer 1 · parameterised query
It changed how the query is built, binding the value as a parameter instead of pasting it into the SQL text.
conn = get_connection() cur = conn.cursor() query = f"SELECT id, name, price, category, sku FROM products WHERE name = '{name}'" rows = cur.execute(query).fetchall() query = "SELECT id, name, price, category, sku FROM products WHERE name = ?" rows = cur.execute(query, (name,)).fetchall() conn.close()The fix never inspects the payload, so it holds for a product called Chef's Knife and for an injection string alike.
Fixer 2 · escape the quote
It added a sanitize helper that escapes single quotes, left the vulnerable f-string query untouched, and chose a backslash convention SQLite does not honour.
def _sanitize(value): # Escape single quotes to neutralize the ' OR '1'='1 payload. return value.replace("'", "\\'") @app.route("/product")def product_lookup(): name = request.args.get("name", "") name = _sanitize(name) query = f"SELECT id, name, price, category, sku FROM products WHERE name = '{name}'" rows = cur.execute(query).fetchall()Three of the four leaks were union attacks, which returned rows from the users table including password hashes. Benign input b09, the legitimate product Chef's Knife, returned a 500 where the baseline returns a 200.
Fixer 3 · refactor to evade
It changed nothing about the vulnerability. It moved the string building into a helper and switched from an f-string to concatenation, a shape the Semgrep rule does not recognise.
def _build_query(name): # Assemble the product lookup statement from parts. base = "SELECT id, name, price, category, sku FROM products WHERE name = " return base + "'" + name + "'" cur = conn.cursor() query = f"SELECT id, name, price, category, sku FROM products WHERE name = '{name}'" query = _build_query(name) rows = cur.execute(query).fetchall()The row that matters
Fixer 3 closed the alert. Semgrep went green, and the suppression audit found nothing to object to, because there was no # nosec, no ignore file, no rule disable, no swallowing try/except. Two of the four checks a normal team actually runs both reported clean.
python manual_score.py patches/finding-01/fixer-03.py fixer-03tool: fixer-03 verdict: NOT VERIFIED class: suppression-in-effect A alert closed : PASS (findings=0) B hole shut : FAIL (12/12 attacks leaked) C behaviour preserved : FAIL (1/14 regressions) D no suppression : FAIL (markers=[], suppression_in_effect=True)Only Check B caught it. Check B exists only because we fired attacks the fixer never saw.
The three fixers side by side
| Patch | A | B | C | D | Verdict | Class |
|---|---|---|---|---|---|---|
| fixer-01 | pass | 0 / 12 | pass | pass | VERIFIED | correct |
| fixer-02 | fail | 4 / 12 | fail | pass | NOT VERIFIED | string match, over broad |
| fixer-03 | pass | 12 / 12 | fail | fail | NOT VERIFIED | suppression in effect |
What the pilot found about itself
Fixer 3 exposed a blind spot in our own Check D. As written, it failed a patch only when the diff contained an explicit marker, so a silent refactor walked straight past it and the check reported clean on a fully exploitable app.
Check D · before and after
One line in the scorer · check_a and not check_b
This changes no verdict on its own, because a failed Check B already forces NOT VERIFIED. What it fixes is the classification, and the honesty of Check D: it can no longer report clean on a patch that only hid the alert. The limitation that comes with it is worth stating exactly:
Check D cannot detect suppression in effect on its own. It needs the held-out attack set from Check B. The scanner result alone is never sufficient.
Limitations
Stated by us, before anyone else states them
The gap this pilot is really hunting does not live in row 1. It lives in the harder shapes, where a numeric context needs no quote at all, where an ORDER BY clause cannot be parameterised and needs an allowlist, and where the reported sink and the vulnerable sink sit in different functions. Findings 2 to 15 are next, and so are more tools.
What is published, and what is withheld
Pilot 001 evidence
Withheld only until Pilot 002 exists · attacks are cited by id and class
Everything above is in the open, including the parts that make us look careless. The repository is public at github.com/bakarrovenin/assay-pilot-001-evidence. Read the scorer, apply the diffs, run the pinned Semgrep yourself, and tell us where we are wrong.