fix(rules): improve precision and recall of java opengrep rules - #112
fix(rules): improve precision and recall of java opengrep rules#112David Larsen (dc-larsen) wants to merge 1 commit into
Conversation
Addresses a customer SAST evaluation that reported roughly 90% false positives from the Java rules and compared them unfavourably to CodeQL. Reproduced on six mature open source Java projects (guava, netty, spring-framework, commons-lang, commons-io, spring-petclinic, ~17,400 Java files): the rule set emitted 1,631 findings, and a hand adjudicated random sample of 40 contained zero true positives. Three rules produced 74% of that volume. Precision fixes: - java-empty-catch-block: 645 findings, all noise. Restrict to swallowed broad exceptions, exclude the conventional "ignored"/"expected" variable names, and exclude blocks carrying an explanatory comment. Comments are not AST nodes, so a documented catch block still looks empty to the matcher and had to be excluded textually. - java-reflection-injection: 296 findings. Was matching every method.invoke(), every newInstance() factory call, and Class.forName() on string constants. Converted to taint mode with servlet and Spring MVC sources and dynamic-class-loading and script-eval sinks. - java-system-out-usage: 263 findings. The message claims sensitive data in logs but the rule matched any println. Now requires the printed expression to reference something credential bearing. - java-hardcoded-credentials: matched on variable name alone, flagging KEY_ATTRIBUTE = "key" and SEC_WEBSOCKET_KEY1. Ported the value inspection approach already applied to the dotnet rules in SocketDev#63: bare "key" only counts in compound credential words, and values shaped like header names, property paths, or a restatement of the keyword itself are excluded. Now zero findings across all six mature libraries while still catching WebGoat's default credentials. - java-unsafe-deserialization: required the receiver to actually be an ObjectInputStream, and excluded calls inside a class's own readObject and readExternal implementations, which are the Serializable contract. - java-insecure-random: converted to taint mode. A weak PRNG is only a vulnerability when its output becomes a security value, not when it seeds a JMH benchmark or shuffles a list. - java-hardcoded-ip: required a full dotted quad and excluded loopback. - java-insecure-cookie: bound the setSecure(true) exclusion to the same variable, so one hardened cookie no longer exonerates every other cookie in the method. Recall fixes. Two systematic bugs suppressed entire categories: - Patterns using simple type names never matched fully qualified call sites, so java.security.MessageDigest.getInstance("MD5"), new java.util.Random() and new javax.servlet.http.Cookie() were all invisible. Added qualified variants throughout. - Crypto rules matched exact algorithm literals, so Cipher.getInstance("DES/CBC/PKCS5Padding") did not match "DES". Replaced with metavariable-regex over the transformation string, and covered the provider overloads of getInstance. Also added java-xss and java-xpath-injection, both taint mode, and converted java-ldap-injection and java-path-traversal to taint with Zip Slip and Spring multipart sources. Validated with opengrep 1.25.0. OWASP Benchmark v1.2 (2,740 annotated cases, ground truth): precision 64.5% -> 76.5% recall 12.4% -> 63.4% score 5.1 -> 42.6 securecookie, weakrand, crypto and hash reach 100% precision. Mature open source Java projects: 1,631 -> 130 findings (-92%) unique findings on mature libraries 1,536 -> 85 (-94.5%) WebGoat: 87 -> 45. The removed findings are lint noise and three reflection matches on factory calls; the planted vulnerabilities, including the Zip Slip, default credentials and weak PRNG, still fire. Methodology, per-category results, the known limits of OWASP Benchmark for pattern-based engines, and the remaining untouched noise sources are documented in docs/java-sast-benchmark.md, with a reusable scorer in scripts/score_owasp_benchmark.py.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 7 potential issues.
Bugbot Autofix is ON, but it could not run because the branch was deleted or merged before autofix could start.
Comment @cursor review or bugbot run to trigger another review on this PR
Reviewed by Cursor Bugbot for commit 667bf9c. Configure here.
| - pattern: KeyGenerator.getInstance("$TRANSFORM", ...) | ||
| - metavariable-regex: | ||
| metavariable: $TRANSFORM | ||
| regex: (?i)^(des|desede|tripledes|3des|rc2|rc4|arcfour|blowfish)([/].*)?$|^.*/ecb/.*$ |
There was a problem hiding this comment.
Weak cipher flags standard RSA
High Severity
The second alternative of the $TRANSFORM regex matches any literal containing /ecb/, so ordinary RSA transformations such as RSA/ECB/PKCS1Padding are reported as broken ECB. In Java those strings use ECB only as a placeholder, not the block-cipher mode the finding describes.
Reviewed by Cursor Bugbot for commit 667bf9c. Configure here.
| - pattern-not-inside: | | ||
| $T $COOKIE = new jakarta.servlet.http.Cookie(...); | ||
| ... | ||
| $COOKIE.setSecure(true); |
There was a problem hiding this comment.
Cookie exclusion spans other constructors
High Severity
pattern-not-inside still matches a region from one cookie assignment through that cookie's setSecure(true). A second new Cookie sitting between those statements is inside the region, so an unsecured cookie is dropped whenever another cookie in the same method is hardened.
Reviewed by Cursor Bugbot for commit 667bf9c. Configure here.
| # version strings such as "10.0" and "10.4". Loopback (127.x) and the | ||
| # wildcard bind address are not infrastructure disclosure and are excluded | ||
| # by the leading-octet alternation. | ||
| pattern-regex: '"(?:10|192\.168|172\.(?:1[6-9]|2[0-9]|3[01]))\.[0-9]{1,3}\.[0-9]{1,3}(?::[0-9]{1,5})?"' |
There was a problem hiding this comment.
Private IP regex misses 10.x
High Severity
The 10 branch only allows two more octets before the closing quote, so "10.0.0.1" does not match while three-part versions such as "10.2.3" do. The 192.168 and 172.16–31 branches correctly require a dotted quad.
Reviewed by Cursor Bugbot for commit 667bf9c. Configure here.
| # both write new java.util.Random() in full. | ||
| - pattern: new Random(...).$M(...) | ||
| - pattern: new java.util.Random(...).$M(...) | ||
| - pattern: (Random $R).$M(...) |
There was a problem hiding this comment.
Weak random misses nextBytes keys
High Severity
Random.nextBytes is void and fills the caller-supplied array in place, but these sources taint only the call expression. That taint never reaches SecretKeySpec or IvParameterSpec, so generating keys or IVs with a weak PRNG is missed.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit 667bf9c. Configure here.
| # both write new java.util.Random() in full. | ||
| - pattern: new Random(...).$M(...) | ||
| - pattern: new java.util.Random(...).$M(...) | ||
| - pattern: (Random $R).$M(...) |
There was a problem hiding this comment.
SecureRandom treated as weak source
Medium Severity
(Random $R).$M(...) matches any method on a Random-typed receiver. SecureRandom is commonly stored in a Random field, so correct token or key generation is still reported when the class or variable name looks security-related.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit 667bf9c. Configure here.
| - pattern-not-inside: | | ||
| public void readExternal($T $S) { ... } | ||
| - pattern-not-inside: | | ||
| public void readExternal($T $S) throws $EX { ... } |
There was a problem hiding this comment.
Serializable exclusion misses standard throws
Medium Severity
The readObject and readExternal exclusions only allow zero or one thrown type. The standard Serializable signature throws IOException, ClassNotFoundException, so legitimate readObject() calls inside those methods still match.
Reviewed by Cursor Bugbot for commit 667bf9c. Configure here.
| # containment check is what actually makes the path safe. | ||
| - patterns: | ||
| - pattern: $PATH.startsWith($BASE) | ||
| - focus-metavariable: $PATH |
There was a problem hiding this comment.
Path sanitizer does not persist
Medium Severity
The startsWith sanitizer focuses $PATH but omits by-side-effect: true, so later file sinks that use the same path stay tainted. A containment check therefore does not suppress the finding the way the nearby comment describes.
Reviewed by Cursor Bugbot for commit 667bf9c. Configure here.


Why
A customer SAST evaluation reported roughly 90% false positives from our Java rules and compared them unfavourably to CodeQL. Their engineer's read was that Semgrep matches patterns in single files while CodeQL traces paths across files. Part of that is structural, but most of what they saw was fixable rule defects.
I reproduced it. On six mature, heavily reviewed open source Java projects (guava, netty, spring-framework, commons-lang, commons-io, spring-petclinic — about 17,400 Java files) the current rules emit 1,631 findings. I hand adjudicated a random sample of 40 of them: zero true positives. Three rules produced 74% of the volume.
This is the Java equivalent of the .NET work in #63, using the same method.
What was wrong
Noise.
java-empty-catch-block(645 findings) fired oncatch (NullPointerException tolerated) {}and on every catch block documented with a comment.java-reflection-injection(296) matched everymethod.invoke(), everynewInstance()factory call, andClass.forName("sun.misc.Cleaner")on a string constant.java-system-out-usage(263) claimed "sensitive information written to log files" but matched anyprintln.java-hardcoded-credentialsmatched on variable name alone — the exact defect fixed for .NET in #63 — flaggingKEY_ATTRIBUTE = "key"andSEC_WEBSOCKET_KEY1 = "Sec-WebSocket-Key1".Two systematic bugs silently suppressed whole categories.
Patterns written with simple type names never matched fully qualified call sites:
And the crypto rules matched exact algorithm literals, so
Cipher.getInstance("DES/CBC/PKCS5Padding")never matched a rule looking for"DES". Together these meantweakrand,hash,cryptoandsecurecookiescored zero recall on the benchmark despite having rules for them.Results
Benchmarked with opengrep 1.25.0 against OWASP Benchmark v1.2 (2,740 annotated servlets, 1,415 real vulnerabilities and 1,325 deliberate non-vulnerabilities, with published ground truth).
securecookie,weakrand,cryptoandhashnow run at 100% precision (recall 100%, 91.7%, 74.6%, 69.0%).On the mature open source corpus, which is the honest proxy for what a customer has to triage:
java-empty-catch-block,java-reflection-injection,java-system-out-usageandjava-hardcoded-credentialsnow emit zero findings across all six mature libraries.WebGoat goes 87 → 45. I checked every removed finding: they are lint noise plus three reflection matches on factory calls and a JDK dynamic proxy. The planted vulnerabilities still fire, including the Zip Slip in
ProfileZipSlip, the default credentials inDefaultCredentialsTask, and the weak PRNG inPasswordResetLink.Changes
Precision:
java-empty-catch-block,java-reflection-injection(→ taint),java-system-out-usage,java-hardcoded-credentials,java-unsafe-deserialization,java-insecure-random(→ taint),java-hardcoded-ip,java-insecure-cookie.Recall: qualified-name variants throughout,
metavariable-regexover crypto transformation strings, provider overloads ofgetInstance, andjava-ldap-injection/java-path-traversalconverted to taint with Zip Slip and Spring multipart sources.New rules:
java-xssandjava-xpath-injection, both taint mode. XSS was the single largest recall gap at 246 missed real vulnerabilities.Two changes worth calling out because they are subtle:
java-insecure-cookie'ssetSecure(true)exclusion is now bound to the same metavariable. Previously anysetSecure(true)in scope exonerated every other cookie in the method — this is the same class of bug as theStartsWithsanitizer fix in fix(rules): improve precision of 4 high-FP dotnet opengrep rules #63.java-empty-catch-blockexcludes commented blocks withpattern-not-regex. Comments are not AST nodes, so a documented catch block still looks empty to the matcher.Reproducing
docs/java-sast-benchmark.mdhas the method, the per-category numbers, andscripts/score_owasp_benchmark.pyscores an opengrep JSON run against the benchmark CSV.Two things I want to be honest about
A chunk of OWASP Benchmark's designated false positives are not fixable by any pattern engine. They are unreachable-branch traps:
Solving that needs constant propagation plus path sensitivity. opengrep's taint analysis is path insensitive, so it reports the dead branch. This caps achievable precision on
sqli,cmdiandpathtraverno matter how the rules are written, and it is the real substance behind the CodeQL comparison. Please don't read the residual FPs in those categories as rule defects without opening the test case.I deliberately left five rules alone, and they are now the largest remaining noise sources on real code. Documented in the doc, listed here so they don't get lost:
java-template-injection(20 findings, matches any.process(...)),java-xxe-vulnerability(14, matchesDocumentBuilderFactory.newInstance()without checking whether secure features are set),java-unsafe-deserialization(14 residual, library serialization helpers),java-jndi-injection(8, matches any.lookup(...)), andjava-sql-injection(4,$STMT.execute(...)matches any method namedexecute). There is also notrustboundrule at all, which is 126 unscored benchmark cases.Testing
opengrep --validateclean at 32 rules. Fullpytestsuite: 339 passed.Note
Medium Risk
Large changes to customer-facing SAST behavior can miss real issues or change alert volume sharply; changes are rule/config only with documented benchmarks, not runtime security code.
Overview
Overhauls
socket_basics/rules/java.ymlto cut false positives on mature OSS (~92% fewer findings) while raising OWASP Benchmark recall (12.4% → 63.4%) and precision (64.5% → 76.5%).Precision: Noisy lint-style rules are tightened—
java-empty-catch-block,java-system-out-usage, andjava-hardcoded-credentialsuse stricter metavariable/regex filters and test-path excludes;java-reflection-injectionmoves to taint (drops blanketinvoke/newInstance/Runtime.exec);java-insecure-randomonly flags weak PRNG output flowing into security-sensitive sinks; deserialization, cookies, and hardcoded-IP rules get narrower patterns and exclusions.Recall: Crypto/hash/random/cookie rules gain fully qualified call sites and
metavariable-regexon algorithm/transformation strings; LDAP and path traversal become taint rules with servlet/Spring sources, Zip Slip upload sources, and expandedjava.io/Filessinks; newjava-xssandjava-xpath-injectiontaint rules fill benchmark gaps.Tooling: Adds
docs/java-sast-benchmark.md(corpora, commands, before/after metrics) andscripts/score_owasp_benchmark.pyto score opengrep JSON against OWASP Benchmark v1.2 with CWE-matched category stats and per-rule TP/FP.Reviewed by Cursor Bugbot for commit 667bf9c. Configure here.