Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,71 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

## [Unreleased]

### Added
- Two new Java SAST rules, both taint mode: `java-xss` (CWE-79) and
`java-xpath-injection` (CWE-643). XSS was the largest recall gap in the Java
rule set, accounting for 246 missed true positives on the OWASP Benchmark
corpus. (#112)
- `scripts/score_owasp_benchmark.py` scores an OpenGrep JSON run against the
OWASP Benchmark v1.2 `expectedresults` CSV, reporting per-category precision,
recall, false positive rate and the Benchmark score, plus per-rule TP/FP
counts. (#112)
- `docs/java-sast-benchmark.md` documents the benchmarking method, the
before/after numbers, the known limits of OWASP Benchmark for pattern-based
engines, and the remaining noise sources in the Java rules. (#112)
- Java rule regression fixtures under `tests/fixtures/opengrep/java` with
`// ruleid:` and `// ok:` annotations, exercised by
`tests/test_java_opengrep_rules.py`. The tests skip when `opengrep` is not on
`PATH`, so they are a no-op for contributors who only touch Python. (#112)

### Fixed
- **Java SAST precision.** Twelve Java rules were rewritten after a customer
evaluation reported roughly 90% false positives. On six mature open source
Java projects (~17,400 files) the rule set previously emitted 1,631 findings,
of which a hand adjudicated random sample of 40 contained no true positives.
`java-empty-catch-block`, `java-reflection-injection`,
`java-system-out-usage` and `java-hardcoded-credentials` produced most of that
volume and now report nothing on those projects. `java-reflection-injection`,
`java-insecure-random`, `java-path-traversal` and `java-ldap-injection` were
converted to taint mode. (#112)
- **Java SAST recall.** Two systematic defects suppressed whole categories.
Patterns written with 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
invisible; qualified variants were added throughout. Crypto rules matched
exact algorithm literals, so `Cipher.getInstance("DES/CBC/PKCS5Padding")`
never matched a rule looking for `"DES"`; these now use `metavariable-regex`
over the transformation string and cover the provider overloads of
`getInstance`. (#112)
- `java-insecure-cookie` no longer drops an unhardened cookie when a
neighbouring cookie in the same method calls `setSecure(true)`. The exclusion
is bound per variable and also recognises hardening through a field. (#112)
- `java-weak-cipher` no longer reports `RSA/ECB/PKCS1Padding`, where `ECB` is a
JCA placeholder rather than a block mode. `AES/ECB/...` is still reported.
(#112)
- `java-hardcoded-ip` now requires a full dotted quad, so version strings such
as `"10.0"` and `"10.2.3"` are no longer reported and `"10.0.0.1"` is. (#112)
- `java-path-traversal` honours a `startsWith` containment check at later file
sinks, and detects Zip Slip via `ZipEntry.getName()` and Spring multipart
uploads via `MultipartFile.getOriginalFilename()`. (#112)
- `java-insecure-random` detects weak key and IV material generated through
`Random.nextBytes(array)`, and no longer treats `pivot`, `divisor`, `spinner`,
`monkey` or `author` as security-relevant names. (#112)
- `java-unsafe-deserialization` no longer reports SnakeYAML loads that use
`new Yaml(new SafeConstructor())`, which is the remediation the rule itself
recommends, including the SnakeYAML 2.0 `SafeConstructor(LoaderOptions)`
form, and no longer matches unrelated `readObject()` APIs such as
BouncyCastle's `PEMParser`. (#112)
- `java-ldap-injection` no longer reports a Lucene `IndexSearcher.search()` call
as a CRITICAL LDAP injection. Sinks are type constrained to the LDAP APIs.
(#112)
- `java-sql-injection` no longer reports `MessageDigest.update(input)` as SQL
injection. The untyped `$TEMPLATE.update(...)` sink matched any method named
`update`, which produced 93 CRITICAL findings on the OWASP Benchmark's hash
test cases. The crypto receivers (`MessageDigest`, `Mac`, `Cipher`,
`Signature`, `Checksum`) are now subtracted, and `queryForRowSet` and
`batchUpdate` were added as sinks. (#112)

## [3.1.0] - 2026-09-02

### Added
Expand Down
270 changes: 270 additions & 0 deletions docs/java-sast-benchmark.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,270 @@
# Java SAST rule benchmarking

This document records how `socket_basics/rules/java.yml` is measured and what the
current numbers are. It exists so the next person changing a Java rule can tell
whether they improved it or just moved the noise around.

## Why

A customer SAST evaluation reported roughly 90% false positives from our Java
rules, and compared us unfavourably to CodeQL. Reproducing that on real code
confirmed it: on six mature, heavily reviewed open source Java projects
(~17,400 Java files) the rule set emitted **1,631 findings**, and a hand
adjudicated random sample of 40 of them contained **zero true positives**.

## Corpora

Two corpora, because they answer different questions.

| Corpus | What it answers | Source |
|---|---|---|
| OWASP Benchmark v1.2 | Precision and recall against ground truth | `github.com/OWASP-Benchmark/BenchmarkJava` |
| Mature OSS Java projects | How much noise a real user has to triage | guava, netty, spring-framework, commons-lang, commons-io, spring-petclinic |
| WebGoat | Whether we still catch deliberately planted vulnerabilities | `github.com/WebGoat/WebGoat` |

OWASP Benchmark ships 2,740 annotated servlets (1,415 real vulnerabilities,
1,325 deliberate non-vulnerabilities) with an `expectedresults-1.2.csv` giving
the category, CWE, and whether each case is genuinely vulnerable. It is the
standard scoring corpus for Java SAST.

The mature-OSS corpus is the honest proxy for customer experience. These
libraries are not web applications and contain essentially no reachable
instances of these vulnerability classes, so nearly every finding is noise.
Alert volume there is the number that maps to triage burden.

## Running it

@lelia lelia Sep 7, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: the rules have no regression coverage in tests/. Everything verified in this review came from a dozen small Java fixture files (one positive and one negative case per rule) scanned with a local opengrep binary. Checking those in under tests/fixtures/opengrep/java/ with a pytest that skips when opengrep is not on PATH would catch the regex regressions found here (RSA/ECB, 10.x, substring iv/pin/key) the next time someone edits java.yml, without needing the OWASP corpus.


Pin both the engine and the corpus, or the numbers below will not reproduce.

- **Engine.** Measured with **opengrep 1.25.0**. The images pin
`OPENGREP_VERSION=v1.26.0` in `Dockerfile` and `Dockerfile.heavy`; rule
behaviour was identical on 1.19.0, 1.25.0 and 1.26.0 everywhere it was
checked, but re-measure if you change the pin.
- **Corpus.** BenchmarkJava at commit
[`51f0a7c`](https://github.com/OWASP-Benchmark/BenchmarkJava/commit/51f0a7cf8bb9d17ce1f6d72598c1d1c6ce90f661)
(2026-08-31). A `--depth 1` clone of `main` moves, and both the test cases and
the `expectedresults` CSV have changed between Benchmark releases.

```bash
git clone https://github.com/OWASP-Benchmark/BenchmarkJava.git
git -C BenchmarkJava checkout 51f0a7cf8bb9d17ce1f6d72598c1d1c6ce90f661

opengrep --json --dataflow-traces --quiet -a --no-git-ignore \
--config socket_basics/rules/java.yml \
--output results.json \
BenchmarkJava/src/main/java

python3 scripts/score_owasp_benchmark.py results.json \
BenchmarkJava/expectedresults-1.2.csv
```

The scorer reports per-category precision, recall, false positive rate and the
OWASP Benchmark score (`TPR - FPR`), plus per-rule TP/FP counts so you can see
which rule is responsible for a regression.

## Regression tests

The full corpora are not needed to catch the regressions that are easiest to
reintroduce. `tests/fixtures/opengrep/java` holds one small Java file per rule
area, annotated `// ruleid: <rule>` for a line that must be reported and
`// ok: <rule>` for one that must not. `tests/test_java_opengrep_rules.py`
scans them and diffs against the annotations:

```bash
pytest tests/test_java_opengrep_rules.py
```

The tests skip when `opengrep` is not on `PATH`. Add a case to the relevant
fixture whenever you change a rule; the substring, qualified-name and
containment-check bugs found in review are all covered there now.

Two behaviours worth knowing when editing these:

- `metavariable-regex` **anchors at the start** of the metavariable text, so
every alternation branch needs its own leading `.*`.
- Prefer scoped `(?i:...)` groups over a leading global `(?i)`. A global flag
also lowercases the deliberately case-sensitive camelCase branches, which is
how `pivot`, `divisor`, `spinner` and `monkey` were matching `iv`, `pin` and
`key`.
- opengrep's default ignore list skips any directory named `tests/`, so the test
harness passes explicit file paths rather than the fixture directory.

## Results

Measured with opengrep 1.25.0.

@lelia lelia Sep 7, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reproducibility: two things to pin so the next person gets the same numbers.

  1. Engine version. The Dockerfile ships OPENGREP_VERSION=v1.26.0 and this was measured on 1.25.0; note the shipped pin here or re-measure on it. (FWIW every behavior I checked was identical on 1.19.0.)
  2. BenchmarkJava checkout. git clone --depth 1 of main moves; record the commit or tag (v1.2) used, since the expectedresults CSV and test cases have changed between benchmark releases.


Scan time on BenchmarkJava went from 6s to 11s, roughly +70%. The extra cost is
the taint-mode conversions and the wider sink lists. It is small in absolute
terms on a 2,766 file corpus, but it is not free, and it is worth re-checking if
more rules move to taint mode. Run the corpora sequentially: seven concurrent
opengrep processes on one machine thrash badly.

### OWASP Benchmark v1.2 (ground truth)

Both columns are produced by the current `scripts/score_owasp_benchmark.py`,
so they share a denominator. An earlier revision of this table quoted a Before
column from a scorer that still mapped the non-existent
`java-trust-boundary-violation` rule, which counted Benchmark's 126
`trustbound` cases against recall on the Before side only.

| | Before | After |
|---|---|---|
| Precision | 64.5% | **76.7%** |
| Recall | 13.2% | **70.3%** |
| False positive rate | 7.6% | 22.2% |
| Benchmark score (TPR - FPR) | 5.6 | **48.2** |
| True positives found | 176 | **937** |

Per category, after the change:

| Category | Precision | Recall |
|---|---|---|
| securecookie | 100.0% | 100.0% |
| weakrand | 100.0% | 100.0% |
| crypto | 100.0% | 74.6% |
| hash | 100.0% | 69.0% |
| xpathi | 60.0% | 80.0% |
| ldapi | 58.3% | 77.8% |
| xss | 67.4% | 70.7% |
| pathtraver | 56.1% | 69.2% |
| sqli | 66.0% | 52.2% |
| cmdi | 63.6% | 44.4% |

**Cross-category findings are not counted.** Following the OWASP method, the
scorer only credits a finding when the rule's category matches the CWE the test
case targets; a finding of a different class in that file is discarded rather
than counted as a false positive. That is worth stating because it hid a real
bug. Before this change, 93 discarded pairs were `java-sql-injection` firing on
`hash` test cases, because the untyped `$TEMPLATE.update(...)` sink matched
`MessageDigest.update(input)`. Subtracting the crypto receivers fixed it and
raised `sqli` recall at the same time. 40 discarded pairs remain, all `java-xss`
on `sqli` and `xpathi` cases, where the test genuinely echoes the tainted value
into the response; those look like real findings the OWASP scoring model
discards. Counting all 40 as false positives would put overall precision at
74.0% rather than 76.7%.

The headline false positive rate rises because the rule set now detects seven
categories it previously scored zero on. Precision, which is the share of
emitted findings that are real, is the comparable number and it improved.

### Mature open source Java projects (triage burden)

| | Before | After | Change |
|---|---|---|---|
| Total findings | 1,631 | 129 | **-92%** |
| Unique findings, mature libraries only | 1,536 | 84 | **-94.5%** |

Two caveats on these counts, both of which apply equally to the Before and
After columns:

- **Part of the reduction is scoping, not rule logic.** Several rules gained a
`paths: exclude` block for test, benchmark and example directories. 429 of the
1,631 baseline findings (26%) sit in paths that are now excluded, concentrated
in `java-system-out-usage` (212 of its 263, mostly netty's `example` module),
`java-insecure-random` (82 of 102) and `java-hardcoded-ip` (35 of 48). Those
three rules would still be much quieter without the exclusions, but "zero
findings" for them is scoping plus logic, not logic alone.
- **guava is counted twice.** The repository ships `guava/` and a near-identical
`android/guava/` mirror, and both are scanned. Exactly half of guava's
findings are mirror duplicates (245 of 490 before, 10 of 20 after), and the
same applies to its share of the ~17,400 file count.

Three rules produced 74% of the original noise: `java-empty-catch-block` (645),
`java-reflection-injection` (296) and `java-system-out-usage` (263). All three
now emit zero findings on the mature-library corpus.

### WebGoat (deliberately vulnerable)

87 findings before, 45 after. The removed findings were lint noise
(`java-system-out-usage` 26, `java-hardcoded-ip` 5, `java-empty-catch-block` 4)
plus three `java-reflection-injection` matches on factory `newInstance()` calls
and a JDK dynamic proxy. The security findings, including the Zip Slip in
`ProfileZipSlip`, the default credentials in `DefaultCredentialsTask`, and the
weak PRNG in `PasswordResetLink`, are still reported.

## Known limits

**OWASP Benchmark's designated false positives are adversarial toward
pattern-based engines.** A large share of them are unreachable-branch traps:

```java
String guess = "ABC";
char switchTarget = guess.charAt(1); // always 'B', the safe branch
switch (switchTarget) {
case 'A': bar = param; break; // tainted, but dead code
case 'B': bar = "bob"; break; // always taken
}
```

Resolving these requires constant propagation plus path sensitivity. opengrep's
taint analysis is path insensitive, so it reports the dead tainted branch. This
is the structural difference behind the "Semgrep matches patterns, CodeQL traces
paths" comparison, and it caps achievable precision on `sqli`, `cmdi` and
`pathtraver` regardless of how the rules are written. Do not read the remaining
FPs in those categories as fixable rule defects without checking the test case
first.

**Declared types are all the matcher sees.** A typed metavariable such as
`(Random $R)` matches on the *declared* type, so a `SecureRandom` assigned into
a `Random`-typed variable is still treated as a weak PRNG:

```java
Random rng = new SecureRandom(); // declared Random
String token = Long.toString(rng.nextLong(), 36); // reported
```

There is no clean pattern-level fix. Narrowing the source to
`new Random(...)` only would lose every case where the PRNG is held in a field,
which is the common real-world shape, so this is accepted as a known false
positive rather than traded for the recall.

**Comment exclusions are statement scoped.** `java-empty-catch-block` excludes
a catch block carrying an explanatory comment with `pattern-not-regex`, because
comments are not AST nodes. The regex is applied to the whole matched `try`
statement, so a comment in one catch clause suppresses an empty broad catch
beside it:

```java
try { work(); }
catch (IOException e) { // fine, fall through
} catch (Exception e) { } // not reported
```

Anchoring the regex to the clause that binds the exception variable is not
expressible in a single rule. The same code without the comment on the first
clause is reported.

**Ambiguous names in `java-insecure-random`.** The sink matches a security
value by name, and `key` is genuinely ambiguous in Java. `rememberMeKey` (a
Benchmark true positive) and `shardKey` or `cacheKey` (map keys, not secrets)
are the same shape, so the rule reports all of them. Narrowing `key` to compound
forms only, as `java-hardcoded-credentials` does, would drop `weakrand` recall
from 100%. The short words `iv`, `pin`, `otp`, `key` and `auth` are matched only
at a word boundary, so `pivot`, `divisor`, `spinner`, `monkey` and `author` are
not reported, but `keyIndex` and `ivLen` still are.

**`java-unsafe-deserialization` still reports library plumbing.** Guava's and
commons-lang's serialization helpers take a caller-supplied `ObjectInputStream`
and call `readObject()` on it, outside the `Serializable` contract methods the
rule excludes. Whether that is a finding depends on who calls the helper, which
is not visible to the rule.

**Remaining noise not addressed here.** On the mature-library corpus these rules
were untouched by this change and are still the largest remaining sources:

| Rule | Findings | Cause |
|---|---|---|
| `java-template-injection` | 20 | Matches any `.process(...)` call |
| `java-xxe-vulnerability` | 14 | Matches `DocumentBuilderFactory.newInstance()` unconditionally, ignoring whether secure features are set |
| `java-unsafe-deserialization` | 14 | Library serialization helpers that accept a caller-supplied `ObjectInputStream` |
| `java-jndi-injection` | 8 | Matches any `.lookup(...)` call |
| `java-sql-injection` | 4 | `$STMT.execute(...)` and `$TEMPLATE.query(...)` sinks match any method of those names |
| `java-unvalidated-redirect` | 4 | All four are Spring's own `RedirectView`, which is the framework's redirect implementation |

Every one of these is the same defect class the change fixes elsewhere: an
untyped receiver on a common method name. The pattern to follow is a typed
metavariable plus a fully qualified variant, as in `java-ldap-injection`.

`trustbound` (CWE-501) has no rule at all; OWASP Benchmark scores 126 cases for
it, and `scripts/score_owasp_benchmark.py` deliberately omits the category so it
reads as unscored rather than as a rule at 0% recall.
Loading