OutOfMemoryError in JavaSecuritySensor (com.sonar.security) on full-project taint analysis

Summary

On a full-project (“branch”) analysis of a large Java code base, the SonarCloud Java security/taint engine (JavaSecuritySensor, com.sonar.security.*) runs out of heap with java.lang.OutOfMemoryError: Java heap space while simulating UCFGs. The crash is an unbounded StringBuilder allocation inside the analyzer, not steady-state heap pressure:

  • The engine’s own per-UCFG memory usage gauge never exceeds ~14.4 GB.
  • A GC log of the same run shows the live set then grows monotonically to the 40 GB heap ceiling and the final Full GC (with soft-reference clearing) reclaims 0 bytes - i.e. the 40 GB is all live/reachable, ~36 GB of it in humongous objects (>16 MB each).
  • Raising the scanner engine heap to -Xmx40g on a 50 GB container still OOMs. More heap only postpones the crash.

The analyzer already detects that several sinks are too expensive (Too high simulation costs for sink ... This sink will not be analyzed any further) and skips them - but on a subsequent UCFG it still enters an unbounded allocation and dies. This looks like a missing/ineffective guard on a single sink’s cost computation.

Environment

Item Value
Platform SonarQube Cloud (SonarCloud)
Project key vendavo_eps-core
Analysis type Full project analysis (not a PR)
org.sonarqube Gradle plugin 7.3.1.8318
Scanner engine JRE OpenJDK 21.0.9+10-LTS, G1GC
Scanner engine heap -Xmx40g -Xms4g (via sonar.scanner.javaOpts)
Container memory 50 GB
Build JDK / sonar.java.source 25
Files indexed 15 536
Run timestamp (UTC) 2026-06-23, sensor started 18:09:40, OOM 18:20:12

Plugin/version banners from the console:

17:46:06  org.sonarqube Gradle plugin 7.3.1.8318
17:46:09  Communicating with SonarQube Cloud
17:46:13  Starting SonarScanner Engine...
17:46:21  Project key: vendavo_eps-core
17:46:58  Configured Java source version (sonar.java.source): 25

Symptom / timeline (from scanner console)

18:09:40  Sensor JavaSecuritySensor [security]
18:09:40  29 taint analysis rules enabled.
18:09:59  Analyzing 99984 UCFGs to detect vulnerabilities.
18:11:30  All rules entry points : 1332
18:11:30  Retained UCFGs : 31677
18:11:33  0 / 31677 UCFGs simulated, memory usage: 7579 MB
...
18:14:09  13749 / 31677 UCFGs simulated, memory usage: 14401 MB   <- peak the gauge ever reports
...
18:16:54  23117 / 31677 UCFGs simulated, memory usage: 5631 MB    <- LAST progress line (~73%)
          (no further progress line is ever printed)
18:20:12  java.lang.OutOfMemoryError: Java heap space
18:20:53  > Task :sonar FAILED

The engine stops emitting progress after UCFG 23117 / 31677. For the next ~3.5 minutes it is stuck inside the simulation of a single UCFG, allocating without bound, then OOMs.

“Too high simulation costs” sinks that were cut off before the crash (14 total)

Before the crash the engine prints, for 14 distinct sinks:

Too high simulation costs for sink in <redacted-source-NN>:<line>. This sink will not be analyzed any further.

(Source paths anonymized - see the sanitized excerpt.) These 14 sinks are all in DB / SQL / OMI / object-metadata / formatter / tracer code - deeply branched string building and SQL/metadata assembly. The cost guard fires for them, but does not prevent the OOM on a later sink.

OOM stack trace (top frames; analyzer classes are obfuscated)

java.lang.OutOfMemoryError: Java heap space
    at java.base/java.util.Arrays.copyOf(Unknown Source)
    at java.base/java.lang.AbstractStringBuilder.ensureCapacityInternal(Unknown Source)
    at java.base/java.lang.AbstractStringBuilder.append(Unknown Source)
    at java.base/java.lang.StringBuilder.append(Unknown Source)
    at com.sonar.security.J.D.C.J.F(na:116)
    at com.sonar.security.J.D.C.J.H(na:2236)
    at com.sonar.security.J.D.C.J.H(na:2757)
    at com.sonar.security.J.D.E.c.C(na:1428)
    at com.sonar.security.J.D.E.c$$Lambda.get(Unknown Source)
    at com.sonar.security.J.D.E.c.A(na:2442)
    at com.sonar.security.J.D.E.c.B(na:575)
    at com.sonar.security.J.D.E.c.A(na:2183)
    at com.sonar.security.J.D.B.N.A(na:3023)
    at com.sonar.security.J.D.E.c.C(na:832)
    ... (recursion through com.sonar.security.J.D.E.c.A/B/C and J.D.B.*) ...
    at com.sonar.security.J.D.I$_B.A(na:3103)
    at com.sonar.security.J.D.I$_B.D(na:2096)
    at com.sonar.security.J.D.I$_B.C(na:26)

The allocation that fails is StringBuilder.appendArrays.copyOf directly under com.sonar.security.J.D.C.J.F - i.e. the engine is building one ever-growing String inside the security analyzer.

GC-log evidence (proves it is unbounded live growth, not a sizing problem)

Same run, scanner engine JRE OpenJDK 21.0.9, G1GC, -Xmx40g -Xms4g, 50 GB container.

Healthy phase (first ~30 min): after-GC live set ~4-7 GB, pre-GC peaks ~12-15 GB, GC fully effective. (This is the same ~14 GB the per-UCFG gauge reports - it is NOT the failure point.)

Runaway phase (~18:17:55 onward, ~2.5 min): the after-GC floor ratchets up and never recovers:

18:17:55  GC(440)  Pause Young  40728M->12072M(40960M)   (last effective collection)
18:18:44  GC(470)  Pause Young  40047M->22271M(40960M)
18:19:30  GC(522)  Pause Full   30757M->30752M(40960M)    (reclaimed  5 MB)
18:19:37  GC(523)  Pause Full   30752M->30751M(40960M)    (reclaimed  1 MB, took 7.2 s)
18:19:54  GC(545)  Pause Full   39103M->39103M(40960M)    (reclaimed  0)
18:20:03  GC(559)  Pause Full   40223M->40223M(40960M)    (reclaimed  0)
18:20:11  GC(574)  Pause Full   40319M->40319M(40960M)    "maximal full compaction, clearing soft references" -> 0 freed
18:20:12  -> OutOfMemoryError

At JVM exit: heap 98.7% full (41 377 474 K / 41 943 040 K), and 1136 humongous regions x 32 MB = ~36 GB - i.e. ~36 GB sits in individual objects larger than 16 MB. The last GC that ran before the OOM was triggered by a G1 Humongous Allocation.

A Full GC + soft-reference clear reclaiming 0 bytes proves the entire 40 GB is live and reachable - not garbage waiting to be collected. This matches the stack trace: a single StringBuilder (a humongous char[]/byte[]) being grown without bound.

Why we believe this is an analyzer defect (and not our configuration)

  1. More heap does not fix it. We ran the engine at 25 GB, 30 GB and 40 GB on containers up to 50 GB - all OOM. The GC log shows 0 reclaimable memory at the ceiling, so the live set genuinely exceeds 40 GB and is still growing when it dies.
  2. The cost guard is incomplete. The engine identifies 14 sinks as Too high simulation costs and skips them, yet still OOMs on a later UCFG via an unbounded StringBuilder.append. The guard that protects those 14 sinks does not protect the allocation in com.sonar.security.J.D.C.J.F.
  3. The gauge hides it. The per-UCFG memory usage gauge maxes at ~14 GB because it is sampled between UCFG simulations; the blow-up happens within a single simulation/cost computation, where no sample is taken.
  4. PR analysis is fine. The same code base analyzed in PR mode (data-flow scoped to changed files) completes normally - it simulates a small fraction of the 31677 UCFGs and never reaches the pathological sink. The failure is specific to full-project scope.

What we’d like

  • Confirmation of whether the unbounded StringBuilder build under com.sonar.security.J.D.C.J.F (string/path/state serialization during sink cost computation or reporting) can be bounded the same way the Too high simulation costs cutoff bounds the 14 sinks above.
  • Guidance on any supported way to keep PR taint analysis while making the full-project taint analysis resilient (server-side cap, per-sink budget, etc.) - short of deactivating the taint rules in the quality profile, which would also disable the valuable PR analysis.

Attachments

  • gc-sonar.log - full G1 GC log of the failing run (heap trajectory + humongous region counts).
  • console-sonar-excerpt.log - sanitized excerpt of the scanner console, JavaSecuritySensor phase only (sensor start 18:09:40> Task :sonar FAILED 18:20:53): rule/UCFG counts, all 14 Too high simulation costs lines, the simulation progress with the memory usage gauge, and the full OOM stack. The 14 source file paths are anonymized; the full console is withheld for confidentiality. We can share the real sink paths/line numbers over a private support channel if needed, but they are ordinary DB/SQL/OMI/formatter code - the issue is the analyzer’s allocation behavior, not the source.

console-sonar-excerpt.log (6.2 KB)

gc-sonar.log (884.4 KB)

Hey @dpilar ,

Thank you for the report, the great investigation, and all the details. I gather the report is AI-driven/-supported, and I have to say this really is a great usage of AI. It really helped to drill down the issue fast! :slightly_smiling_face: :+1: This is tremendously helpful.

Your top-level conclusion is correct: This is an analyzer defect, not your configuration, and adding heap was never going to fix it.

I will internally release a fixed version of the analyzer and deploy it to SonarQube Cloud so you can test it. We reproduced this independently, so we don’t need your redacted sink paths (or related information). Howver, once the update is live, it’d be great if you could confirm your full-project analysis completes normally! :folded_hands: Happy to follow up here when it ships (shouldn’t take very long). :slight_smile:

In case your interested, here’s some technical details:

What was happening?

During taint analysis, the engine reconstructs the possible string values that flow through your code (so it can reason about SQL, URLs, paths, etc.). That reconstruction had no upper bound on the length of a single reconstructed string. Most limits in the engine are structural (i.e., they cap how many pieces a value has, not how many characters it ends up with), so a certain shape of string-building could grow one string without bound until the heap is exhausted. That matches everything you observed: the blow-up happens inside a single simulation step (so the per-UCFG gauge, sampled between steps, never sees it), and it manifests as one (or possibly more) enormous char[] which cannot be recollected by a GC event.

About the “Too high simulation costs for sink” messages

Good instinct that a guard was missing. This particular one is a different mechanism, but, as you’ve pointed out (and to answer your first question), yes, the reconstructed string lengths can be bounded the same way.

Why PR analysis works

Exactly as you deduced, PR analysis is scoped to changed files and simply never reaches the pathological value on your full project. To answer your second question, there’s not much you can do on your side to fix it, short of deactivating the taint rules. An alternative would be to identify the culprit files in your codebase leading to the problem, and exclude them from analysis temporarily. However, as the fix should be deployed soon, I do not think it’s worth the effort on your side – up to you, of course.

Thanks again for the excellent diagnostics.

Thanks for your explanation. The unbounded single-string reconstruction lines up exactly with what the GC log showed (one humongous char[] growing until the heap was 100% live), so it’s good to see it confirmed analyzer-side.

And yes, the diagnosis was AI-driven, which helped a lot with the investigation.

Appreciate you committing to a fix. Since we’re on SonarQube Cloud it’ll roll in automatically, so I’ll re-enable the full-project analysis once the patch is out and confirm here.

Thanks again.

Confirmed fixed on our side. Thanks Malte and team!

Since yesterday our full-project analysis of vendavo_eps-core completes successfully again, with no OutOfMemoryError. What stands out: it now finishes even at a scanner engine heap of just 8 GB, whereas before it would OOM even at 40 GB. JavaSecuritySensor completes in about 9 minutes and the run ends with ANALYSIS SUCCESSFUL.

The GC log confirms the root cause is gone: in the failing runs the humongous regions grew monotonically to the 40 GB ceiling and a full GC (with soft-reference clearing) reclaimed 0 bytes (all live), whereas now the humongous regions are collected normally and the heap stays comfortably low. So the unbounded single-string reconstruction is clearly bounded now.

Really appreciate the quick fix.