Build1 publisher3 min readPublished
A name-matching SQL scanner missed four of five documented injection CVEs
inlet finds Python SQL call sites by matching names, and in four of five packages with documented injection CVEs the vulnerable string reached the database through a framework helper instead. Its author published the 0 for 5.
The Engineer · Build desk
What happened
- The evaluation used 15 unmodified PyPI packages: five carrying documented, independently verified SQL-injection CVEs with the fix commit or advisory located in advance, and ten popular packages as a noise floor.
- inlet missed four of the five outright, including Django's CVE-2022-28346, Apache Superset's CVE-2023-49736, Tortoise ORM's CVE-2020-11010 and the Airflow common-sql provider's CVE-2025-30473.
- Each of those four reached SQL through a framework abstraction such as hook.get_records() or field.like(), never through the .execute(), .raw(), .extra() or text() names the scanner matches.
- A rule written to kill peewee's .execute() name collision cut that package's 33 uncertain findings to 9, then silently dropped 94 real database call sites across the other nine packages.
- The author took the rule back out and recorded self.execute(x) as a hard limit of local syntax, in the same category as the tool's existing cross-function scope wall.
Compiled by The EngineerSomething wrong?How this is made
Why it matters
- constraint A matcher that works on names can only see call sites whose names it knows, so a codebase that wraps database access in its own repository or provider layer stays out of reach until the tool resolves calls across functions.
- cost Quieting one package's name collision cost 94 real database call sites elsewhere, and the people paying are reviewers who read a shorter report and cannot see what left it.
- decision Teams that treat a clean scan as a merge gate now have a documented case where four packages with known injection CVEs produced clean output from a tool whose fixtures all passed.
- precedent Ground truth anyone can check, a CVE plus its fix commit, is a test buyers can demand of any scanner that currently publishes only a fixture count.
Name matching is a decision about how much of a program you are willing to read. inlet picks out the call sites that look like SQL execution, raw DB API calls, Django's .raw() and .extra(), SQLAlchemy's text(), and labels each one parameterized, concatenated, or uncertain [1]. Everything it decides, it decides from inside one function; the author calls that boundary a cross-function scope wall and treats it as a hard limit [15].
Archery's CVE-2023-30556 is the case to study. Whether it counts as a catch depends on whether anyone opens the uncertain bucket. The exact vulnerable line did come out of the scan, labelled uncertain instead of concatenated. The f-string assignment turned out to sit inside a try: block that inlet's name resolution did not follow into [7].
The scan itself is cheap. About 772,000 lines of unmodified code went through in roughly 10.6 seconds [5], which works out at about 73,000 lines a second [16].
The noise-floor group found the bug that hand-written fixtures could not. peewee's query builder exposes an .execute(database) method that has nothing to do with a cursor's .execute(sql), and that collision accounted for 67 percent of the uncertain findings in the package [10]. The fix asked for positive evidence before treating an .execute()-shaped call as a database idiom: a string-shaped argument, or a .cursor() call in the receiver chain, or a conventional cursor or connection name [11]. On peewee it did exactly what it was built to do, cutting 33 uncertain findings to 9, with a diff confirming all 24 removals were the collision shape and no true positives lost [12].
Then the same rule ran over the other nine packages and silently dropped 94 real database call sites. Among them were Django's SchemaEditor.execute(), SQLAlchemy's Engine and Session internals and SQLModel's super().execute(), all lost because the receiver was named something generic like self [13]. The trade was 94 real call sites for 24 false ones, close to four to one [17].
On the revert, inlet's author wrote that "there is no way to tell a real DB wrapper from an unrelated same-named method using local syntax alone" [14]. The rule came out instead of being tuned further, and self.execute(x) went into the docs beside the scope wall as a limit of the approach [15].
For the 0 for 5 to say anything about your repository, the shape has to match. Group A was five packages chosen because a documented CVE existed and its fix commit or advisory could be located in advance [3]. Four of those bugs lived in framework or provider code, several calls from any name inlet knows [8]. If your SQL is a cursor.execute() carrying an f-string inside a view function, the seven original fixtures describe your code and the 0 for 5 does not [2]. A README that reports seven for seven is reporting on seven files someone wrote in order to be reported on. If your data access goes through a repository class or a task helper, you are the four, and the author put the 0/5 at the top of EVALUATION.md [9].
What to watch
- Whether inlet gains call resolution across function and module boundaries, and whether the four missed CVEs then classify as concatenated.
- Whether the Group A method, a CVE plus its located fix commit, is published as a harness other scanners can be run against.
- Whether a peewee-specific exclusion lands, since reverting the rule puts the 24 collision findings back in the uncertain bucket.