Injection into a query is still the second most dangerous weakness in software. CWE-89 sits at #2 in the 2025 CWE Top 25, decades after bind variables solved it in principle. On Salesforce the query language is SOQL, the platform does a lot to make the safe path the default, and the bug still ships.
It ships for two reasons that have little to do with the textbook example. The first is that String.escapeSingleQuotes is treated as a universal fix when it only works inside a quoted string literal. Put the escaped value in an ORDER BY, a LIMIT or a field name and it protects nothing. The second is that Flow builders can write the same bug without a line of Apex: a screen field flows into a Get Records filter in a Flow that runs in system context, and the record filter is now attacker-controlled.
This post walks both paths with code, shows what the fix looks like in each, and explains what Vulkro reports for them, including the test class it emits as proof for the Apex case.
Path one: dynamic SOQL in Apex
The textbook case is string concatenation into Database.query:
public with sharing class ContactSearch {
@AuraEnabled
public static List<Contact> search(String lastName) {
String soql = 'SELECT Id, Email FROM Contact WHERE LastName = \'' + lastName + '\'';
return Database.query(soql);
}
}A caller who passes x' OR Name != ' rewrites the WHERE clause. The fix everyone knows is a bind variable, which keeps the value out of the query text entirely:
public with sharing class ContactSearch {
@AuraEnabled
public static List<Contact> search(String lastName) {
return [SELECT Id, Email FROM Contact WHERE LastName = :lastName];
}
}Vulkro's apex-taint-soql-injection rule follows a value from a caller-controlled source (an @AuraEnabled parameter, a Visualforce controller property, a REST request) to a dynamic query, and it clears the finding when the value reaches the query through a bind variable or passes through String.escapeSingleQuotes into a quoted slot. When the value crosses a method boundary on the way, the cross-method Apex taint engine keeps following it, so a helper that builds the query string does not hide the flow.
Path one, variant: escaping in a slot that has no quotes
Here is the version that passes most code reviews:
public with sharing class ContactList {
@AuraEnabled
public static List<Contact> sorted(String orderBy) {
String safe = String.escapeSingleQuotes(orderBy);
return Database.query('SELECT Id, Email FROM Contact ORDER BY ' + safe + ' LIMIT 50');
}
}escapeSingleQuotes adds a backslash before every single quote. In ORDER BY, there are no quotes to escape. The caller can pass Email LIMIT 1000 OFFSET 0 -- or, more usefully, a field name they are not supposed to read sorted in a way that leaks its contents by position. The same applies to a LIMIT, a numeric or date comparison, an object name, an IN list built from the value, or a SOSL string.
Vulkro reports this with the escape-single-quotes-call rule, which is specifically about an escaped value landing in an unquoted slot. The fix is an allow list, because there is no escaping that makes an identifier safe:
private static final Set<String> SORTABLE = new Set<String>{ 'Email', 'LastName', 'CreatedDate' };
@AuraEnabled
public static List<Contact> sorted(String orderBy) {
if (!SORTABLE.contains(orderBy)) {
throw new AuraHandledException('Unsupported sort field');
}
return Database.query('SELECT Id, Email FROM Contact ORDER BY ' + orderBy + ' LIMIT 50');
}The same detector page carries a migration advisory for queries still using WITH SECURITY_ENFORCED, pointing at USER_MODE. That is a field-level security question rather than an injection one, but it tends to live in the same classes.
Path two: a Flow that runs as the system
A Flow has no concatenation operator, so it cannot have SOQL injection in the string sense. What it can have is the same outcome: a value the caller controls deciding which records a query returns, evaluated with permissions the caller does not have.
The ingredients are a screen component or input variable, a Get Records element whose filter uses that variable, and a Flow that runs in system context (or has no runInMode set, which on several flow types means the same thing). The user types a value, the Flow looks up records matching it without sharing, and the screen displays them. Nothing in the Flow asked whether this user may see those records.
In the metadata the shape is recognisable:
<recordLookups>
<name>Get_Account</name>
<object>Account</object>
<filters>
<field>AccountNumber</field>
<operator>EqualTo</operator>
<value><elementReference>Account_Number_Input</elementReference></value>
</filters>
</recordLookups>
<runInMode>SystemModeWithoutSharing</runInMode>Vulkro's Flow rules treat this as the data-flow problem it is. SF-FLOW-TAINT-001 reports caller input (a screen field, an input variable or the triggering record) reaching a query filter or an action in a system-context flow. flow-dynamic-filter-from-input reports the filter shape itself. flow-runinmode-elevated flags system mode or a missing runInMode, and two narrower rules cover the higher-impact cases: flow-runinmode-system-mode-recorddeletes for deletes in system mode and flow-system-mode-sensitive-field-write for writes to sensitive fields.
The fix is usually a decision, not a code change: does this Flow need to run as the system at all? If it does, the filter must be constrained by something the user cannot type, such as the running user's Id or an account they already own.
Why system context is the multiplier
The incident that made this concrete for the Agentforce era was ForcedLeak. Security Affairs reported the chain on 2025-09-27: a Web-to-Lead Description field carried 42,000 characters of injected instructions, and the Apex action that executed on the agent's behalf ran without sharing. The injection was into a prompt rather than a query, but the reason it mattered was the same. Input from outside the org reached something that ran with elevated permissions. Vulkro tracks it as item 4 of its breach class map.
Two least-privilege rules cover the Apex side of that pattern directly. SF-PRIVESC-SYS-001 reports a without sharing class doing DML with request data and no CRUD/FLS check. SF-PRIVESC-SYS-002 reports Apex that inserts a PermissionSetAssignment based on request-controlled input, which is the shortest route from an injection to an administrator.
Proof you can run
For eligible Apex findings, including SOQL injection, the prove subcommand emits an @isTest class that exercises the flagged path with a hostile input. Exit code 1 means a harness was emitted. You run it in a scratch org like any other test, and the result is a yes or no that does not depend on anyone's reading of the code.
This matters for the escaping variant above, where the argument in review is usually "but we escape it". A test that passes a crafted ORDER BY through the method and asserts on what comes back ends that argument in either direction.
For the whole package, vulkro-sf scan ./force-app runs the Apex and Flow rules together, and vulkro-sf antipatterns ./force-app adds the Well-Architected anti-pattern checks that catch the sharing and run-mode decisions before they become findings. Agents working in the repo can call the same analysis through the MCP sf_scan_project tool.
Two limits apply to all of this. Vulkro does not know whether a Flow is only ever launched by administrators, whether a without sharing class is reachable from a guest profile, or whether a field in an ORDER BY is harmless to expose. It reports the shape and the data flow. The decision about whether the elevated context is justified is yours, and the finding is there to make sure the decision gets made.
What to do this week
- Search the Apex for
escapeSingleQuotesand check what slot each escaped value lands in. Anything outside a quoted literal needs an allow list. - List every Flow with
SystemModeWithoutSharingor norunInMode, and for each one ask whether a screen field or input variable reaches a Get Records filter. - Run
vulkro-sf scan ./force-appand read theapex-taint-soql-injection,escape-single-quotes-callandSF-FLOW-TAINT-001findings first. - For any disputed Apex finding, run
proveand execute the emitted test in a scratch org.
The Apex detectors page lists every rule and what clears it. To run them on your own package, start the 14-day trial.