If you ship Java code, you ship a target. Java runs the backends of banks, hospitals, governments, and half the Fortune 500 — which is exactly why attackers study it so closely. A Java security scanner (more precisely, a SAST — Static Application Security Testing — tool) reads your source code without executing it and flags the flaw patterns attackers actually exploit: injection, broken deserialization, XML external entities, path traversal, and the rest of the greatest-hits list.
This guide is practical, not a vendor pitch. You’ll learn which vulnerability classes matter most in Java, see real vulnerable code next to its fixed version, compare the main scanning tools honestly, and wire scanning into a Maven or Gradle build in about ten minutes.
Why Java needs security scanning (not just code review)
Human code review catches logic errors and design smells. It is terrible at catching security flaws at scale, for three reasons:
- Vulnerabilities hide in data flow, not in single lines. A SQL injection isn’t visible at the
executeQuery()call — it’s visible in the three-method chain that carried untrusted input there. Reviewers read diffs; scanners trace taint. - Java’s ecosystem is enormous. Spring, Jakarta EE, Hibernate, Jackson, JAXB, Log4j — every framework has its own dangerous defaults. No reviewer memorizes all of them.
- Attackers automate; defenders should too. Exploit kits don’t hand-read your code. They fuzz endpoints and match known flaw patterns. A scanner applies the same pattern discipline to your side.
Two real incidents show why this matters beyond theory. Log4Shell (CVE-2021-44228) turned a ubiquitous logging library into remote code execution via a JNDI lookup in a log string — a flaw class (untrusted input reaching a dangerous sink) that taint analysis is designed to catch. Spring4Shell (CVE-2022-22965) exploited data binding in Spring MVC to reach internal classloader properties. Both were dependency problems and code-pattern problems — which is why you need two different kinds of scanning, covered next.
SAST vs. SCA: scan your code AND your dependencies
Beginners conflate two tools that answer different questions:
| SAST (this guide’s focus) | SCA (Software Composition Analysis) | |
|---|---|---|
| Question | “Is my code vulnerable?” | “Are my libraries vulnerable?” |
| Input | Your source code | Your dependency list (pom.xml, gradle.lock) |
| Finds | SQLi, XXE, deserialization, path traversal | Known CVEs in Log4j, Spring, Jackson, etc. |
| Example tools | Semgrep, SpotBugs+FindSecBugs, SonarQube, Snyk Code | OWASP Dependency-Check, Snyk Open Source, Trivy |
You need both. Log4Shell was an SCA finding (vulnerable library version); the unsafe JNDI lookup pattern in your own code is a SAST finding. Most “best Java scanner” roundups — including the well-known Finite State comparison — test only SCA tools against the Spring Framework. That’s useful but incomplete: it tells you nothing about flaws in code your team wrote this sprint. This guide covers the SAST side, with an SCA note where relevant. New to SAST? Read our complete guide to Static Application Security Testing first.
The 5 vulnerability classes every Java scanner must catch
Below are five flaw patterns with minimal, compilable Java examples — first the vulnerable version, then the fix. These are the exact patterns good SAST rulesets (Semgrep’s Java rules, FindSecBugs detectors, SonarQube taint rules) are built to detect.
1. XML External Entity (XXE) injection — CWE-611
Java’s XML parsers enable DTDs and external entities by default. If your endpoint parses user-supplied XML, an attacker can read local files (/etc/passwd) or probe internal services.
// VULNERABLE: external entities enabled by default
import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import org.w3c.dom.Document;
import java.io.ByteArrayInputStream;
import java.nio.charset.StandardCharsets;
public class XmlParser {
public Document parseUserXml(String xml) throws Exception {
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
DocumentBuilder builder = factory.newDocumentBuilder();
return builder.parse(new ByteArrayInputStream(xml.getBytes(StandardCharsets.UTF_8)));
}
}
An attacker submits <?xml version="1.0"?><!DOCTYPE r [<!ENTITY xxe SYSTEM "file:///etc/passwd">]><r>&xxe;</r> and your server happily resolves it.
// FIXED: disable DTDs and external entities entirely (OWASP guidance)
import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import org.w3c.dom.Document;
import java.io.ByteArrayInputStream;
import java.nio.charset.StandardCharsets;
public class XmlParser {
public Document parseUserXml(String xml) throws Exception {
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
factory.setFeature("http://xml.org/sax/features/external-general-entities", false);
factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
factory.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false);
factory.setXIncludeAware(false);
factory.setExpandEntityReferences(false);
DocumentBuilder builder = factory.newDocumentBuilder();
return builder.parse(new ByteArrayInputStream(xml.getBytes(StandardCharsets.UTF_8)));
}
}
Apply the same hardening to SAXParserFactory, XMLReader, TransformerFactory, and JAXB Unmarshaller — every one of them is vulnerable by default.
2. Insecure deserialization — CWE-502
Java native deserialization (ObjectInputStream.readObject()) will instantiate any class on the classpath described by the byte stream. If attackers control those bytes — a session cookie, a message queue payload, an uploaded file — they get gadget-chain remote code execution. This flaw class refuses to die because it looks so innocent:
// VULNERABLE: deserializes untrusted bytes with no class restrictions
import java.io.ByteArrayInputStream;
import java.io.ObjectInputStream;
public class SessionStore {
public Object restoreSession(byte[] data) throws Exception {
try (ObjectInputStream in = new ObjectInputStream(new ByteArrayInputStream(data))) {
return in.readObject(); // attacker controls 'data'
}
}
}
// FIXED: allowlist which classes may be deserialized
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InvalidClassException;
import java.io.ObjectInputStream;
import java.io.ObjectStreamClass;
import java.util.Set;
public class SessionStore {
// Java 9+. On Java 8, use Collections.unmodifiableSet(new HashSet<>(...)).
private static final Set<String> ALLOWED = Set.of(
"com.example.UserSession",
"com.example.CartState"
);
public Object restoreSession(byte[] data) throws Exception {
try (ObjectInputStream in = new ObjectInputStream(new ByteArrayInputStream(data)) {
@Override
protected Class<?> resolveClass(ObjectStreamClass desc)
throws IOException, ClassNotFoundException {
if (!ALLOWED.contains(desc.getName())) {
throw new InvalidClassException("Blocked deserialization of " + desc.getName());
}
return super.resolveClass(desc);
}
}) {
return in.readObject();
}
}
}
Even better: avoid native serialization for untrusted data entirely. Prefer JSON (Jackson with default typing disabled) or another format with no code-execution semantics.
3. SQL injection via JDBC — CWE-89
Still the most common critical finding in Java codebases, decades after the fix became textbook knowledge. String concatenation builds the query structure; user input must only ever be data:
// VULNERABLE: input concatenated into the SQL string
import java.sql.Connection;
import java.sql.ResultSet;
import java.sql.Statement;
public class UserDao {
private final Connection connection;
public UserDao(Connection connection) {
this.connection = connection;
}
public ResultSet findByUsername(String username) throws Exception {
Statement stmt = connection.createStatement();
String sql = "SELECT id, username, email FROM users WHERE username = '" + username + "'";
return stmt.executeQuery(sql); // ' OR '1'='1 dumps the table
}
}
// FIXED: parameter binding separates code from data
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
public class UserDao {
private final Connection connection;
public UserDao(Connection connection) {
this.connection = connection;
}
public ResultSet findByUsername(String username) throws Exception {
String sql = "SELECT id, username, email FROM users WHERE username = ?";
PreparedStatement stmt = connection.prepareStatement(sql);
stmt.setString(1, username);
return stmt.executeQuery();
}
}
Caveat scanners know and juniors don’t: PreparedStatement only protects values. Table names, column names, and ORDER BY directions can’t be parameterized — those need an allowlist.
4. Path traversal — CWE-22
File APIs resolve .. silently. Any endpoint that maps a request parameter to a filesystem path without validation serves arbitrary files:
// VULNERABLE: "../" sequences escape the upload directory
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
public class FileService {
private static final Path BASE_DIR = Paths.get("/var/app/uploads");
public byte[] readUserFile(String fileName) throws IOException {
Path target = BASE_DIR.resolve(fileName); // "../../etc/passwd" escapes
return Files.readAllBytes(target);
}
}
// FIXED: normalize, then verify containment inside BASE_DIR
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
public class FileService {
private static final Path BASE_DIR = Paths.get("/var/app/uploads").toAbsolutePath().normalize();
public byte[] readUserFile(String fileName) throws IOException {
Path target = BASE_DIR.resolve(fileName).normalize();
if (!target.startsWith(BASE_DIR)) {
throw new SecurityException("Invalid file path: " + fileName);
}
return Files.readAllBytes(target);
}
}
Rejecting filenames containing .. with a string check is not equivalent — normalization defeats double-encoding and mixed-separator tricks that string checks miss.
5. Broken password hashing — CWE-327 / CWE-916
MD5 and SHA-1 are message digests, not password hashes: they’re fast by design, which is exactly what password-cracking rigs want. Storing MD5(password) is barely better than plaintext:
// VULNERABLE: fast, unsalted, GPU-friendly
import java.security.MessageDigest;
import java.nio.charset.StandardCharsets;
public class PasswordHasher {
public String hash(String password) throws Exception {
MessageDigest md = MessageDigest.getInstance("MD5");
byte[] digest = md.digest(password.getBytes(StandardCharsets.UTF_8));
StringBuilder sb = new StringBuilder();
for (byte b : digest) sb.append(String.format("%02x", b));
return sb.toString();
}
}
// FIXED: adaptive, salted hashing (requires spring-security-crypto on the classpath)
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
public class PasswordHasher {
private final BCryptPasswordEncoder encoder = new BCryptPasswordEncoder(12);
public String hash(String password) {
return encoder.encode(password); // salt generated automatically
}
public boolean matches(String rawPassword, String storedHash) {
return encoder.matches(rawPassword, storedHash);
}
}
Not on Spring? Use PBKDF2WithHmacSHA256 from javax.crypto (JDK built-in) or jBCrypt/Argon2 libraries. Never roll your own KDF.
Java security scanner tools, compared honestly
No vendor pays for placement here. Each tool below is described by what it actually does well and where it disappoints.
| Tool | Type | Cost | Strengths | Weaknesses |
|---|---|---|---|---|
| Semgrep + Java rules | SAST (pattern + taint) | Free (OSS engine); paid Pro tier | Blazing fast CI scans; huge community Java ruleset (Spring, servlets, JDBC); custom rules your team owns; SARIF output | Pattern-based: misses deep inter-procedural flows the big engines catch |
| SpotBugs + FindSecBugs | Bytecode bug finder + security plugin | Free, open source | Zero-config second opinion in CI; FindSecBugs detectors map to real CWE patterns; catches crypto and injection bugs | Noisy; bytecode-level findings can be cryptic; project is community-maintained |
| SonarQube | Code quality + SAST | Free Community Build; paid editions | Best-in-class dashboards and quality gates; taint analysis for injection flaws; 600+ Java rules | Real taint analysis and security reports need Developer Edition+; needs a server |
| Snyk Code | SAST (proprietary) | Free tier with limits; paid | Developer-friendly IDE/PR workflow; AI fix suggestions; good Java/Kotlin coverage | Free tier caps out fast on real codebases; proprietary engine you can’t tune |
| CodeQL | SAST (semantic) | Free for public repos; paid for private | Deepest analysis of the free options; excellent Spring/JDBC models; GitHub-native | Requires building a CodeQL database; overkill for quick PR checks |
| OWASP Dependency-Check | SCA, not SAST | Free | Catches known CVEs in your dependencies | High false-positive rate; answers a different question than code scanning |
The pragmatic stack for most teams: Semgrep in CI for fast security feedback on every pull request, SpotBugs + FindSecBugs as a free second opinion, and an SCA tool (Dependency-Check or Trivy) for the dependency side. Add SonarQube when you want quality gates and trend dashboards; buy an enterprise SAST (Checkmarx, Fortify, Veracode) when compliance reporting demands it.
A note on the famous Finite State comparison: it benchmarked SCA tools (Dependency-Check, Snyk, Mend) against the Spring Framework and ranked them by CVE counts. That’s a dependency-scanning bake-off, not a test of who finds flaws in your code. Don’t buy a SAST based on SCA numbers.
Running a security scan on your Maven or Gradle project
Theory is nice; a scanner running on every pull request is better. Here’s the minimum viable setup.
Option A: Semgrep (fastest path, 5 minutes)
Semgrep needs no build, no server, no account for the open-source engine:
# Install (Linux/macOS; Windows via pip or Docker)
pip install semgrep
# Scan a Java project with the community ruleset
cd your-java-project
semgrep scan --config auto
--config auto pulls the registry rules matching your languages — for a Java repo that includes the Java ruleset (injection, XXE, deserialization, Spring-specific patterns). For CI, emit SARIF so GitHub/GitLab render it natively:
semgrep scan --config auto --sarif --output semgrep.sarif
A minimal GitHub Actions workflow:
name: security-scan
on: [push, pull_request]
jobs:
semgrep:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Semgrep
run: pip install semgrep
- name: Scan Java sources
run: semgrep scan --config auto --sarif --output semgrep.sarif
- name: Upload results
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: semgrep.sarif
Option B: SpotBugs + FindSecBugs in Maven
Add the plugin (check Maven Central for the latest versions) and run mvn spotbugs:check to fail the build on findings, or mvn spotbugs:gui to browse them:
<build>
<plugins>
<plugin>
<groupId>com.github.spotbugs</groupId>
<artifactId>spotbugs-maven-plugin</artifactId>
<version>4.10.5.0</version>
<configuration>
<effort>Max</effort>
<threshold>Medium</threshold>
<plugins>
<plugin>
<groupId>com.h3xstream.findsecbugs</groupId>
<artifactId>findsecbugs-plugin</artifactId>
<version>1.14.0</version>
</plugin>
</plugins>
</configuration>
<executions>
<execution>
<goals><goal>check</goal></goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
Note: SpotBugs analyzes bytecode, so it runs after compilation (mvn compile first, or bind it to the verify phase). The FindSecBugs plugin adds the security detectors — without it, SpotBugs is a bug finder, not a security scanner.
Option C: Gradle
// build.gradle — versions verified 15 Oct 2026 (Gradle Plugin Portal / Maven Central)
plugins {
id "com.github.spotbugs" version "6.5.13"
}
dependencies {
// FindSecBugs detectors for the SpotBugs task
spotbugsPlugins "com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0"
}
spotbugs {
effort = "max"
reportLevel = "medium"
}
Then ./gradlew spotbugsMain. For Semgrep on Gradle projects, the same CLI workflow as Maven applies — Semgrep reads source directly, build system agnostic.
Don’t forget the dependency side (SCA)
SAST won’t tell you that your log4j-core:2.14.1 is exploitable. Pair any of the above with:
# OWASP Dependency-Check (free, runs locally)
dependency-check --scan . --format HTML --out reports/
# or Trivy for a faster, broader scan (vulns + misconfigurations)
trivy fs .
Making scan results trustworthy: taming false positives
Every team that abandons SAST does so for the same reason: alert fatigue. Three practices keep the signal usable:
- Gate on new code, not the whole codebase. Pointing a scanner at a 10-year-old monolith produces thousands of findings and one demoralized team. Configure your CI to fail only on findings in changed lines (Semgrep’s
--baseline-ref, SonarQube’s “new code” definition). Backlog the rest. - Tune, don’t tolerate. If a rule fires 50 times and all 50 are wrong, disable or customize that rule — don’t train developers to ignore the scanner. Semgrep’s per-rule
paths:andexclude:make this surgical. - Teach the scanner your sanitizers. Every mature codebase has a
SecurityUtils.sanitize()or validation helper. Pattern-based tools don’t know it’s safe unless you tell them (Semgrep taint-mode sanitizers, SonarQube custom sanitizer configuration). Ten minutes of configuration eliminates whole finding categories permanently.
A scanner that blocks the build on 3 true positives beats a dashboard with 3,000 ignored warnings. Start strict on new code; expand from there.
Try it without installing anything: scan Java with AutoSAST
If you want to see what a Semgrep-based scan reports on real Java code before wiring anything into CI, AutoSAST runs Semgrep’s analysis — including its Java rulesets covering the injection, deserialization, XXE, and Spring patterns from this guide — against a ZIP upload or a GitHub repository URL, then adds AI-written remediation for each finding. No account, no local install: upload, scan, read the report, and take the fixes back to your IDE.
It’s a triage and learning tool, not a replacement for CI-integrated scanning — but it’s the fastest way to answer “what would a scanner say about my code?”
Frequently asked questions
What is a Java security scanner? A Java security scanner is a static analysis (SAST) tool that examines Java source code or bytecode without executing it, looking for vulnerability patterns such as SQL injection, cross-site scripting, XXE, insecure deserialization, and hardcoded secrets. It reports each finding with the file, line number, and (in good tools) a suggested fix.
What’s the difference between SAST and SCA for Java projects?
SAST analyzes code you wrote for flaw patterns (injection, deserialization, path traversal). SCA (Software Composition Analysis) checks libraries you depend on against databases of known CVEs (e.g., a vulnerable Log4j version). You need both: Log4Shell was an SCA finding, while an unsafe ObjectInputStream in your own code is a SAST finding.
Can a Java security scanner find all vulnerabilities? No. Scanners excel at known flaw patterns but can’t reason about business-logic flaws (broken access control, workflow abuse) or vulnerabilities requiring runtime context. Treat scanning as one layer: combine it with dependency scanning, code review, and periodic penetration testing.
Is SpotBugs with FindSecBugs enough for Java security scanning? It’s an excellent free starting point and catches real issues (crypto misuse, injection sinks, XXE). But it’s bytecode-based, can be noisy, and lacks the taint-tracking depth of Semgrep Pro, SonarQube Developer Edition, or CodeQL. Most teams outgrow it as their AppSec program matures — which is fine; free tools that teach you what to look for have done their job.
How often should I run security scans on Java code? On every pull request for SAST (fast tools like Semgrep add seconds to CI), and on every dependency change for SCA. A nightly full scan catches what incremental scans miss. Never let scanning become a quarterly “security week” ritual — vulnerabilities ship daily, so detection should too.
Will adding a security scanner slow down my Maven/Gradle build? Semgrep typically adds under a minute for mid-size projects since it scans source directly. SpotBugs needs compiled bytecode, adding a few minutes depending on project size. Run fast scanners on every PR and slower, deeper tools (SonarQube, CodeQL) on a nightly schedule if build time is a concern.
The bottom line
Java’s biggest security risks aren’t exotic — they’re the same five patterns repeating in every codebase: injection, deserialization, XXE, path traversal, and broken cryptography. A scanner doesn’t replace secure coding knowledge, but it applies that knowledge tirelessly to every line of every pull request.
Start today: run Semgrep’s auto config against your Java project (five minutes, no account), fix what it finds using the patterns in this guide, then make it a CI gate so the flaws stay fixed. Your future self — the one not writing an incident report at 2 AM — will thank you.