Compound (taint) custom rules
Most custom rules match one pattern in one place: a forbidden API call, a hardcoded value, a metadata flag. A compound rule matches a flow: it connects a place where untrusted data enters (a source) to a place where that data is dangerous (a sink), and emits only when the Apex taint engine can prove the data flows from one to the other. This is the rule kind you reach for when "this call is fine here but dangerous if it reads attacker data" describes the risk better than any single line of code.
This page explains the match.type: compound custom-rule kind: what its
parts are, how the engine connects them, and how to write one. It also covers
the Checkmarx import path, which produces compound rules automatically.
When to use a compound rule
Use a compound rule when the finding depends on data flow, not on a single construct:
- A request parameter or LLM response reaching a dynamic-SOQL string.
- A PHI field reaching a debug log or an outbound callout.
- User-controlled input reaching a manual-DOM write.
If instead you can express the risk as "this exact pattern is always wrong" (a banned method, a forbidden metadata value), a simple single-pattern rule is the right tool and a compound rule is overkill.
The three parts
A compound rule has two required parts and one optional part:
source: the sub-pattern that introduces taint. This is where untrusted data enters: a request parameter, a public method argument, an LLM or Einstein response, a read of a sensitive field. Anything the source matches is marked tainted.sink: the sub-pattern where tainted data is dangerous. This is the rendering or execution surface: a dynamic-query builder, a manual-DOM write, a log statement, an outbound callout. The finding emits when a tainted value reaches a sink.propagator(optional): a sub-pattern that clears taint. This is the sanitizer: an escaping call, an allowlist check, a bind-variable conversion. When a tainted value passes through a propagator before reaching the sink, the taint is cleared and no finding emits.
The engine's job is the connection: it tracks tainted values from each source match, through assignments, method calls, and returns, and reports a finding when one arrives at a sink without having passed through a propagator.
How an author writes one
A compound rule is YAML. You declare the kind with match.type: compound,
then give each part a pattern. The shape:
id: SF-CUSTOM-PARAM-SOQL-001
title: Request parameter reaching dynamic SOQL
severity: high
match:
type: compound
source:
# where untrusted data enters
pattern: ApexPages.currentPage().getParameters().get($PARAM)
sink:
# where tainted data is dangerous
pattern: Database.query($Q)
propagator:
# optional: what clears taint
pattern: String.escapeSingleQuotes($X)
message: >
A request parameter reaches a dynamic SOQL query without being
escaped or bound. Use a bind variable, or escape the value with
String.escapeSingleQuotes, before it reaches Database.query.
Reading that rule: a value read from a page parameter is a source; a value
handed to Database.query is a sink; a value passed through
String.escapeSingleQuotes is sanitized. The engine emits
SF-CUSTOM-PARAM-SOQL-001 only for a parameter value that reaches
Database.query without going through the escape call. A query that binds
the value, or escapes it first, does not trigger.
Guidance for writing each part:
- Keep the source broad enough to catch the real entry points but narrow enough that it does not mark trusted constants as tainted. A source that matches too much produces flows that are technically tainted but not attacker-controlled.
- Keep the sink specific to the dangerous operation. Matching the exact dynamic-query or DOM-write call keeps the finding precise.
- Add a propagator for every sanitizer your codebase actually uses. A missing propagator is the most common cause of a compound rule firing on code that is already safe: the data is sanitized, but by a call the rule does not know clears taint.
- Write the
messageso it answers both "what is wrong" and "what to do next." A reviewer reads the message, not the rule body.
The Checkmarx import path
You do not have to author every compound rule by hand. A Checkmarx CxSAST
query is itself a source-to-sink flow definition, so it maps cleanly onto the
compound rule kind. The vulkro-sf rules import-checkmarx command reads a
Checkmarx query export and emits a compound rule: the query's source list
becomes the rule's source, its sink list becomes the sink, and any
sanitizer it declares becomes a propagator.
vulkro-sf rules import-checkmarx apex-soql-injection.cxql -o rules/apex-soql.yaml
Review the generated YAML before adding it to your rule set: where a
Checkmarx construct has no taint-engine equivalent, the translator leaves it
commented with a # TODO marker rather than guessing. This path lets a team
that already maintains Checkmarx queries (because Salesforce runs Checkmarx
during the AppExchange Security Review) carry that work into local
vulkro-sf runs without rewriting each flow from scratch.
How it relates to the built-in detectors
The built-in taint detectors (injection, XSS, PHI flow, LLM output reaching a sink) are compound rules at heart: each connects a source to a sink through the same engine. A custom compound rule plugs into that same machinery, so it inherits the same propagation, the same sanitizer handling, and the same confidence calibration the built-in rules use. Authoring a compound rule is, in effect, teaching the engine one more source-to-sink flow to watch for.