Skip to content

Semgrep vs. CodeQL vs. OpenTaint: XSS Detection Depth Compared

We tested Semgrep, CodeQL, and OpenTaint on five progressively harder XSS cases in a Java Spring application — from direct returns to builder patterns with virtual dispatch — to show where each tool's analysis model hits its limit.

Mar 24, 2026

A SAST tool’s job is to follow data through the indirection a real codebase accumulates. The interesting question is not whether a tool finds XSS but how much code structure it can see through before losing the trail. We tested Semgrep, CodeQL, and OpenTaint on five Spring XSS cases of progressively deeper indirection. The pattern that emerges: each tool plateaus at a different depth, set by what the rule author has to declare versus what the engine can infer.

Each case measures two outcomes: false negatives (vulnerabilities the tool fails to detect) and false positives (secure code paths the tool incorrectly flags). The three tools under test:

  • Semgrep matches patterns syntactically, with taint-analysis support and broader inter-procedural coverage in Semgrep Code, its paid commercial edition. Results below distinguish Semgrep CE and Semgrep Code where they diverge.
  • CodeQL runs semantic analysis through a dedicated query language; we use its default java/xss rule. Free for open-source repositories, requires GitHub Advanced Security for private repos.
  • OpenTaint interprets Semgrep-style patterns as dataflow queries — metavariables are tracked as program values, not syntactic placeholders. Runs whole-program analysis against a build artifact, which is what enables the deeper tracking shown in the later cases. Java and Kotlin today, Apache 2.0 / MIT licensed.

Five test cases

XSS is well-understood. What varies is how much code complexity a tool can see through to find it.

The five test cases form a progression of analytical capabilities, each demanding something the previous one did not:

#Capability requiredWhat the code does
1Syntax matchingUser input returned directly
2Local dataflowValue assigned to variable before return
3Inter-procedural analysisValue flows through a helper method
4Field sensitivityValue passes through constructor chains and nested objects
5Pointer analysisValue flows through builder pattern with virtual dispatch

Each case reflects patterns that are routine in production code. The question is not whether XSS is dangerous — it is where these ordinary coding patterns cause a tool to lose track of the data.

Syntax matching — direct return

Consider a profile page that takes a greeting from the URL and echoes it back inside an HTML response — the simplest possible reflection: one endpoint, one parameter, no helpers. The controller below implements it.

ProfileController.java
@GetMapping("/profile/display")
@ResponseBody
public String displayUserProfile(
@RequestParam(defaultValue = "Welcome") String message) {
return "<html><body><h1>Profile Message: " + message + "</h1></body></html>";
}

A Semgrep rule to detect this pattern:

id: pattern.xss
patterns:
- pattern: |
@$ANNOTATION(...)
$RETURNTYPE $METHOD(..., $TYPE $PARAM,...) {
...
return "..." + $PARAM + "...";
...
}

All three tools detect this case. No surprise — it is the minimal XSS shape.

Results: ✅ Semgrep, ✅ CodeQL, ✅ OpenTaint

Local dataflow — variable assignment

The same endpoint, but with the input assigned to a local variable before the return. That single step breaks AST-pattern matchers: the tool now needs local dataflow tracking.

ProfileController.java
@GetMapping("/profile/status")
@ResponseBody
public String displayUserStatus(
@RequestParam(defaultValue = "Active") String message) {
String statusMessage = "<html><body><h1>User Status: " + message + "</h1></body></html>";
return statusMessage;
}

The vulnerable value is first assigned to statusMessage and then returned. The pattern rule from the first case no longer matches because the return statement contains a variable, not a concatenation.

The OpenTaint rule for this case is simpler than the first — because the engine treats the pattern as a dataflow query, not a syntax match:

id: pattern.xss
patterns:
- pattern: |
@$ANNOTATION(...)
$RETURNTYPE $METHOD(..., $TYPE $PARAM,...) {
...
return $PARAM;
...
}

This rule captures the core condition: user input reaches a return statement. OpenTaint tracks $PARAM through the assignment and finds it in statusMessage.

Semgrep can handle this scenario too, but requires switching to a taint rule with explicit source and sink definitions:

id: taint.xss
mode: taint
pattern-sources:
- patterns:
- focus-metavariable: $PARAM
- pattern: |
@$ANNOTATION(...)
$RETURNTYPE $METHOD(..., $TYPE $PARAM,...) {
...
}
pattern-sinks:
- patterns:
- focus-metavariable: $SINK
- pattern: |
@$ANNOTATION(...)
$RETURNTYPE $METHOD(...) {
...
return $SINK;
...
}

Both approaches work. The difference is how much of the dataflow model has to be expressed in the rule itself. In OpenTaint, the engine infers the flow; in Semgrep, the rule author declares it.

Handling sanitization

The secure version escapes user input before including it in the response:

ProfileController.java
@GetMapping("/profile/secureStatus")
@ResponseBody
public String displaySecureUserStatus(
@RequestParam(defaultValue = "Active") String message) {
String htmlContent = "<html><body><h1>User Status: " +
HtmlUtils.htmlEscape(message) + "</h1></body></html>";
return htmlContent;
}

The basic rules above still flag this as vulnerable because they don’t recognize the sanitization function. To handle it, enhance the pattern rule with a negative pattern:

# pattern.xss — with sanitization
patterns:
- pattern: |
@$ANNOTATION(...)
$RETURNTYPE $METHOD(..., $TYPE $PARAM,...) {
...
return $PARAM;
...
}
- pattern-not: |
@$ANNOTATION(...)
$RETURNTYPE $METHOD(..., $TYPE $PARAM,...) {
...
return HtmlUtils.htmlEscape($PARAM);
...
}

Taint rules use sanitizer patterns instead:

# taint.xss — sanitizer
pattern-sanitizers:
- pattern: HtmlUtils.htmlEscape(...)

Results:

  • Semgrep (pattern): Misses the vulnerability — AST-pattern matchers cannot follow the assignment.
  • Semgrep (taint): Detects the vulnerability and can recognize sanitization.
  • CodeQL and ✅ OpenTaint (pattern and taint): Correctly handle both vulnerable and secure code.

From this point forward, Semgrep’s taint rules are used — pattern rules are insufficient. OpenTaint’s pattern rule from this case is reused unchanged for all remaining examples; results are shown for both rule types.

Inter-procedural analysis — function call boundary

Now move the concatenation into a private helper. The controller looks innocuous; the dangerous string is built one stack frame deeper. The tool must follow data across function boundaries.

DashboardController.java
@GetMapping("/dashboard/greeting")
@ResponseBody
public String generateDashboard(
@RequestParam(defaultValue = "Welcome") String greeting) {
String htmlContent = buildDashboardContent(greeting);
return htmlContent;
}
private static String buildDashboardContent(String greeting) {
return "<html><body><h1>Dashboard: " + greeting + "</h1></body></html>";
}

The secure version escapes inside the helper:

DashboardController.java
@GetMapping("/dashboard/secureGreeting")
@ResponseBody
public String generateSecureDashboard(
@RequestParam(defaultValue = "Welcome") String greeting) {
String htmlContent = buildSecureDashboardContent(greeting);
return htmlContent;
}
private static String buildSecureDashboardContent(String greeting) {
return "<html><body><h1>Dashboard: " +
HtmlUtils.htmlEscape(greeting) + "</h1></body></html>";
}

This is where the tools separate. Semgrep CE does not model what happens inside the callee — it can be configured to ignore callees, which avoids false positives on the secure version but introduces false negatives on the vulnerable one. Semgrep Code inspects the callee’s body and handles both correctly.

Results:

  • ⚠️ Semgrep CE: Can either produce false positives or false negatives — cannot see inside the callee.
  • Semgrep Code: Correctly handles both vulnerable and secure code.
  • CodeQL and ✅ OpenTaint: Correctly handle both vulnerable and secure code.

From this point, Semgrep Code is used for remaining examples since inter-procedural analysis is essential.

Field sensitivity — constructor chains

Imagine a notification system that wraps user-supplied content inside a structured template — a tree of objects (template → body → content → text) that the rendering layer reads selectively. The controller below builds that tree from a query parameter and returns the deepest field. Tracking the input through this construction requires field sensitivity: the analyzer has to know which field of which object holds the tainted value.

NotificationController.java
@GetMapping("/notifications/template")
@ResponseBody
public String generateTemplate(
@RequestParam(defaultValue = "New Message") String content) {
Profile.MessageTemplate template = new Profile.MessageTemplate(content);
return template.body.content.text;
}

The HTML content is built through a chain of constructors:

public MessageTemplate(String text) {
this.body = new MessageBody("<html>" + text + "</html>");
}
public MessageBody(String text) {
this.content = new MessageContent("<body>" + text + "</body>");
}
public MessageContent(String text) {
this.text = "<h1>Notification: " + text + "</h1>";
}

All three tools detect this first constructor-based example. Each version also has a secure variant that reads secureText instead of text — the MessageContent constructor escapes the value with HtmlUtils.htmlEscape before storing it:

Profile.java
public class MessageContent {
public String text;
public String secureText;
public MessageContent(String text) {
this.text = "<h1>Notification: " + text + "</h1>";
this.secureText = "<h1>Notification: " + HtmlUtils.htmlEscape(text) + "</h1>";
}
}
// NotificationController.java — secure version
@GetMapping("/notifications/secureTemplate")
@ResponseBody
public String generateSecureTemplate(
@RequestParam(defaultValue = "New Message") String content) {
Profile.MessageTemplate template = new Profile.MessageTemplate(content);
return template.body.content.secureText;
}

The next variant extends the field chain further:

NotificationController.java
@GetMapping("/notifications/generate")
@ResponseBody
public String generateNotification(
@RequestParam(defaultValue = "New Message") String content) {
Profile.UserProfile profile = new Profile.UserProfile(content);
return profile.settings.config.template.body.content.text;
}

And its secure counterpart:

// NotificationController.java — secure version
@GetMapping("/notifications/secureGenerate")
@ResponseBody
public String generateSecureNotification(
@RequestParam(defaultValue = "New Message") String content) {
Profile.UserProfile profile = new Profile.UserProfile(content);
return profile.settings.config.template.body.content.secureText;
}

Here the tools diverge. Semgrep Code and OpenTaint track the deeper field chain. CodeQL does not report the vulnerability — its taint-tracking model does not propagate through field stores and loads on heap objects beyond a limited depth, so the six-deep accessor chain exceeds what its default java/xss query tracks. On the secure variants, Semgrep Code produces false positives — it flags the sanitized secureText field as vulnerable, unable to distinguish it from the tainted text field on the same object.

Results:

  • ⚠️ Semgrep Code: Detects both simple and complex vulnerable versions but produces false positives on secure variants.
  • ⚠️ CodeQL: Handles the simple version but misses the complex one.
  • OpenTaint: Correctly handles both versions, filtering out secure variants.

Pointer analysis — builder pattern with virtual dispatch

The final case uses a builder pattern. Method chaining returns the same instance, and a field assigned in one call is read in the next — the analyzer must carry the field across the chained call to keep the value reachable at the sink.

MessageController.java
@GetMapping("/message/display")
@ResponseBody
public String displayMessage(
@RequestParam(defaultValue = "Welcome") String message) {
String page = new HtmlPageBuilder().message(message).buildPage();
return page;
}

The builder class stores the message in a field and constructs the response:

private String message = "";
public HtmlPageBuilder message(String message) {
this.message = message;
return this;
}
public String buildPage() {
return "<html><body><h1>Profile Message: " + message + "</h1></body></html>";
}

CodeQL and OpenTaint detect the vulnerability. Semgrep Code does not — its analysis model doesn’t carry the field assigned in .message() across the chained call to .buildPage().

The next variant adds an interface-based formatter:

MessageController.java
@GetMapping("/message/format")
@ResponseBody
public String formatMessage(
@RequestParam(defaultValue = "Welcome") String message) {
String page = new HtmlPageBuilder().message(message)
.format(new DefaultFormatter()).buildPage();
return page;
}

The formatter interface adds another analytical requirement: the analyzer must continue dataflow through a function that takes an interface parameter and invokes a virtual method on it.

public HtmlPageBuilder format(IFormatter formatter) {
this.message = formatter.format(this.message);
return this;
}
public class DefaultFormatter implements IFormatter {
@Override
public String format(String value) {
return value; // No actual formatting
}
}

Detecting the vulnerability does not require resolving which concrete implementation of format() runs — assuming any IFormatter implementation may pass the value through is enough. What the analyzer must do is follow formatter.format(this.message) instead of treating the virtual call as opaque.

Semgrep Code and CodeQL miss the interface-based case. OpenTaint reports it.

The secure version uses an EscapeFormatter that sanitizes the input:

MessageController.java
@GetMapping("/message/escape")
@ResponseBody
public String escapeMessage(
@RequestParam(defaultValue = "Welcome") String message) {
String page = new HtmlPageBuilder().message(message)
.format(new EscapeFormatter()).buildPage();
return page;
}

Filtering this case is where pointer analysis earns its name. The analyzer has to know specifically that the formatter parameter holds an EscapeFormatter instance — not just some IFormatter — so the virtual call resolves to the sanitizer rather than to DefaultFormatter, which returns its input unchanged. Without that precision, the analyzer has to consider every IFormatter implementation as possible — including DefaultFormatter — and so flags the secure variant as a false positive. OpenTaint correctly identifies this as safe.

Results:

  • Semgrep Code: Misses both builder variants.
  • ⚠️ CodeQL: Handles the simple builder but misses the interface-based version.
  • OpenTaint: Detects both patterns, resolves virtual dispatch, and correctly filters the secure EscapeFormatter variant.

Scope

Five cases, one application, one vulnerability class. XSS in a Spring Boot project isolates analytical depth but says nothing about language breadth or performance at scale. A tool that handles all five cases here may still miss patterns in other frameworks.

Results summary

Test CaseSemgrep CESemgrep CodeCodeQLOpenTaint
1. Direct return✅ Pattern
✅ Taint
✅ Pattern
✅ Taint
✅ Built-in✅ Pattern
✅ Taint
2. Local variable assignment❌ Pattern
✅ Taint
❌ Pattern
✅ Taint
✅ Built-in✅ Pattern
✅ Taint
3. Inter-procedural flow❌ Pattern
⚠️ Taint
❌ Pattern
✅ Taint
✅ Built-in✅ Pattern
✅ Taint
4. Field sensitivity — constructor chains❌ Pattern
⚠️ Taint
❌ Pattern
⚠️ Taint
⚠️ Built-in✅ Pattern
✅ Taint
5. Pointer analysis — builder pattern with virtual dispatch❌ Pattern
❌ Taint
❌ Pattern
❌ Taint
⚠️ Built-in✅ Pattern
✅ Taint

Legend

  • Correctly detected vulnerability and identified secure code.
  • Missed the vulnerability.
  • ⚠️ Partial success or inconsistent results across complexity variants.

Takeaways

AST-pattern rules. Whole-program taint analysis. Each tool plateaus at a different depth of analysis:

  • Semgrep CE handles syntax matching and local taint tracking but stops at function boundaries.
  • Semgrep Code extends through inter-procedural analysis and field sensitivity but produces false positives on secure field variants and does not follow builder patterns or virtual dispatch.
  • CodeQL covers most cases but its analysis limits surface at deep field chains and virtual calls.
  • OpenTaint tracks data through all five cases — including builder state, constructor chains, and interface dispatch — using the same pattern rules throughout.

The key design difference: in Semgrep, the rule author declares the dataflow model — sources, sinks, sanitizers. In OpenTaint, the engine infers it. A pattern that mentions a parameter and a return statement is enough for the engine to recover the full flow — across assignments, method calls, and object boundaries.

Production codebases are never simple. Helpers, builders, persistence layers, and interface calls accumulate as code matures — and each one is a place where a scanner can lose the thread. The gap between what a tool sees and what’s actually there widens with every layer of indirection. A tool that covers today’s code may not cover tomorrow’s, and rules that describe what to find — not how to track it — are the ones that keep up.

All five cases are runnable end-to-end in the java-spring-demo project. For a deeper look at what Spring-specific data flows OpenTaint can model — dependency injection, JPA persistence, and cross-endpoint tracking — see Taint Analysis for Spring: Data Flow Beyond the Call Graph.

To try OpenTaint on your own project, see the quick start guide.