From 01a23e686df26ba9d1250a5bd16b54f4ede0c6bc Mon Sep 17 00:00:00 2001 From: Aleksandr Misonizhnik Date: Thu, 23 Jul 2026 18:00:58 +0200 Subject: [PATCH 01/12] feat(rules): express Spring whole-object source and sink taint via the star Replaces the two hard-coded Spring hacks with rule-level star operators: the controller parameter source is now `$*UNTRUSTED`, and the controller-return any-field sinks are expressed with a starred metavar. Both the source hack and the sink hack are deleted. Also restores the Z2F-gate bypass for controller-return sinks and tightens the source `$TYPE` regex, which the hack had been masking. --- rules/README.md | 77 +++++++++++++++++++ .../spring-response-injection-sinks.yaml | 2 +- .../spring-xss-html-response-sinks.yaml | 18 ++--- .../lib/spring/untrusted-data-source.yaml | 8 +- .../lib/spring/untrusted-path-source.yaml | 12 +-- .../spring/unvalidated-redirect-sinks.yaml | 2 +- 6 files changed, 98 insertions(+), 21 deletions(-) diff --git a/rules/README.md b/rules/README.md index d06d7bc57..a216228e0 100644 --- a/rules/README.md +++ b/rules/README.md @@ -155,6 +155,43 @@ Rules follow Semgrep syntax and concepts: - External references (OWASP, CWE, upstream rule sources) - Optional `license` and `provenance` +### Whole-Object Taint: the `$VAR*` Star Operator + +A metavariable occurrence in pattern text can be **starred** — `$VAR*` — to mark it as +**whole-object** taint scope: the metavariable's value *and* all of its nested fields, at +any depth (`{ $VAR, $VAR.* }`), instead of just the value itself. + +- **Adjacency matters.** The `*` must directly abut the metavariable with no whitespace. + `$X*` is the star operator; `$X * y` (space before `*`) is ordinary multiplication. + Write multiplication with a space to avoid ambiguity. +- **Where it's valid**: any metavariable occurrence inside pattern text — + `pattern-sources`, `pattern-sinks`, `pattern-sanitizers`, `pattern-propagators`, + `pattern-not` / `pattern-not-inside`. It's a per-occurrence annotation, not part of the + metavariable's identity: `$X` and `$X*` in the same rule still bind to the same value. +- **Not valid** in the `focus-metavariable` YAML field — that field always stays a plain, + starless name. +- Per operation: a starred **source** taints the value and all its fields; a starred + **sink**/condition matches if the value *or* any of its fields is tainted; a starred + **sanitizer** clears taint on the value and all its fields; a starred **propagator** + copies taint from/to the value and all its fields on the starred side. + +Example — a sink that should fire when a *field* of the returned object is tainted, not +just the top-level value: + +```yaml +# before: only matches when $X itself carries a taint mark +pattern-sinks: + - patterns: + - pattern: return $X; +``` + +```yaml +# after: also matches when a nested field of the returned object is tainted +pattern-sinks: + - patterns: + - pattern: return $X*; +``` + --- ## Testing and Rule Coverage @@ -262,6 +299,46 @@ When introducing or changing rules, follow these guidelines: --- +## Migration Notes + +### Spring controller-return sinks: implicit whole-object taint removed + +Previously, OpenTaint's Spring integration applied an **implicit** whole-object/any-field +widening to *every* controller-return taint sink, via a hardcoded internal mechanism +(`SpringRuleProvider`) that rewrote any method-exit sink whose position was the return +value into an any-field check — regardless of whether the rule itself asked for it. The +same mechanism implicitly tainted every field of a Spring controller-parameter source, not +just the parameter value. + +That hardcoded mechanism has been **removed**. The bundled Spring rules that relied on it +(`spring-response-injection-sink`, `spring-xss-html-response-sink`, +`spring-unvalidated-redirect-sink`, and the Spring untrusted-data/path sources) have been +updated to opt in explicitly with the `$VAR*` star operator described above, so their +behavior is unchanged. + +**If you maintain custom rules**, this is a behavior change to be aware of: a custom rule +with a return-value sink inside a Spring controller — + +```yaml +pattern-sinks: + - patterns: + - pattern: return $X; +``` + +— **no longer implicitly matches** when only a field of the returned object is tainted +(rather than `$X` itself). To restore that behavior, star the occurrence: + +```yaml +pattern-sinks: + - patterns: + - pattern: return $X*; +``` + +Likewise, a custom source rule matching a Spring controller parameter now taints only the +parameter value unless you star the occurrence (`$VAR*`) to also taint its fields. + +--- + ## License This project is released under the [MIT License](LICENSE). diff --git a/rules/ruleset/java/lib/spring/spring-response-injection-sinks.yaml b/rules/ruleset/java/lib/spring/spring-response-injection-sinks.yaml index 75de76dba..c3721348d 100644 --- a/rules/ruleset/java/lib/spring/spring-response-injection-sinks.yaml +++ b/rules/ruleset/java/lib/spring/spring-response-injection-sinks.yaml @@ -30,7 +30,7 @@ rules: @$ANNOTATION(...) $RETURNTYPE $METHOD(...) { ... - return $UNTRUSTED; + return $UNTRUSTED*; ... } - metavariable-pattern: diff --git a/rules/ruleset/java/lib/spring/spring-xss-html-response-sinks.yaml b/rules/ruleset/java/lib/spring/spring-xss-html-response-sinks.yaml index 6095dcc0a..f108426ea 100644 --- a/rules/ruleset/java/lib/spring/spring-xss-html-response-sinks.yaml +++ b/rules/ruleset/java/lib/spring/spring-xss-html-response-sinks.yaml @@ -69,11 +69,11 @@ rules: - patterns: - pattern-either: - pattern: | - return ResponseEntity.ok($UNTRUSTED); + return ResponseEntity.ok($UNTRUSTED*); - pattern: | - return new ResponseEntity($UNTRUSTED, (HttpStatusCode $STATUS)); + return new ResponseEntity($UNTRUSTED*, (HttpStatusCode $STATUS)); - pattern: | - return new ResponseEntity($UNTRUSTED, (HttpStatus $STATUS)); + return new ResponseEntity($UNTRUSTED*, (HttpStatus $STATUS)); - patterns: - patterns: - pattern-not-inside: | @@ -174,7 +174,7 @@ rules: $X = ResponseEntity.unprocessableEntity(); ... - pattern: | - return $X.body($UNTRUSTED); + return $X.body($UNTRUSTED*); - patterns: - patterns: @@ -207,7 +207,7 @@ rules: $H = new HttpHeaders(...); ... - pattern: | - return new ResponseEntity($UNTRUSTED, $H, ...); + return new ResponseEntity($UNTRUSTED*, $H, ...); - pattern-either: - pattern-inside: | @$ANNOTATION(...) @@ -342,7 +342,7 @@ rules: CompletableFuture $METHOD(...) { ... } - - pattern: return $UNTRUSTED; + - pattern: return $UNTRUSTED*; - patterns: - pattern-either: - pattern-inside: | @@ -463,7 +463,7 @@ rules: - pattern: '"image/svg+xml"' - pattern: MediaType.TEXT_HTML_VALUE - pattern: MediaType.IMAGE_SVG_XML_VALUE - - pattern: return $UNTRUSTED; + - pattern: return $UNTRUSTED*; - focus-metavariable: $UNTRUSTED - patterns: @@ -483,8 +483,8 @@ rules: ... } - pattern-either: - - pattern: return $X.contentType(MediaType.TEXT_HTML).body($UNTRUSTED); - - pattern: return $X.contentType(MediaType.IMAGE_SVG_XML).body($UNTRUSTED); + - pattern: return $X.contentType(MediaType.TEXT_HTML).body($UNTRUSTED*); + - pattern: return $X.contentType(MediaType.IMAGE_SVG_XML).body($UNTRUSTED*); - focus-metavariable: $UNTRUSTED diff --git a/rules/ruleset/java/lib/spring/untrusted-data-source.yaml b/rules/ruleset/java/lib/spring/untrusted-data-source.yaml index d6ed438f6..4415f2f14 100644 --- a/rules/ruleset/java/lib/spring/untrusted-data-source.yaml +++ b/rules/ruleset/java/lib/spring/untrusted-data-source.yaml @@ -51,12 +51,12 @@ rules: - patterns: - pattern: | @$ANNOTATION(...) - $RETURNTYPE $METHODNAME(..., $TYPE $UNTRUSTED,...) { + $RETURNTYPE $METHODNAME(..., $TYPE $UNTRUSTED*,...) { ... } - metavariable-regex: metavariable: $TYPE - regex: ^(?!(Integer|Long|Float|Double|Char|Boolean|int|long|float|double|char|boolean|BindingResult|HttpMethod|java.util.TimeZone|java.util.Locale|java.util.ZoneId)) + regex: ^(?!(Integer|Long|Float|Double|Char|Boolean|int|long|float|double|char|boolean|byte|short|BindingResult|HttpMethod|Continuation|java.util.TimeZone|java.util.Locale|java.util.ZoneId|.*\[\])) - metavariable-pattern: metavariable: $ANNOTATION patterns: @@ -68,8 +68,8 @@ rules: - pattern: PostMapping - pattern: PutMapping - pattern: | - $UNTRUSTED = (MessageBodyReader $READER).readFrom(...); + $UNTRUSTED* = (MessageBodyReader $READER).readFrom(...); - pattern: | Cookie $COOKIE = org.springframework.web.util.WebUtils.getCookie(...); ... - $UNTRUSTED = $COOKIE.getValue(); + $UNTRUSTED* = $COOKIE.getValue(); diff --git a/rules/ruleset/java/lib/spring/untrusted-path-source.yaml b/rules/ruleset/java/lib/spring/untrusted-path-source.yaml index ff09f31b4..e13943105 100644 --- a/rules/ruleset/java/lib/spring/untrusted-path-source.yaml +++ b/rules/ruleset/java/lib/spring/untrusted-path-source.yaml @@ -53,18 +53,18 @@ rules: - pattern-either: - pattern: | @$ANNOTATION($URL) - $RETURNTYPE $METHODNAME(..., @PathVariable $TYPE $UNTRUSTED,...) { + $RETURNTYPE $METHODNAME(..., @PathVariable $TYPE $UNTRUSTED*,...) { ... } - patterns: - pattern: | @$ANNOTATION(...) - $RETURNTYPE $METHODNAME(..., $TYPE $UNTRUSTED,...) { + $RETURNTYPE $METHODNAME(..., $TYPE $UNTRUSTED*,...) { ... } - pattern-not: | @$ANNOTATION(...) - $RETURNTYPE $METHODNAME(..., @PathVariable $TYPE $UNTRUSTED,...) { + $RETURNTYPE $METHODNAME(..., @PathVariable $TYPE $UNTRUSTED*,...) { ... } - metavariable-regex: @@ -72,7 +72,7 @@ rules: regex: .*\*.* - metavariable-regex: metavariable: $TYPE - regex: ^(?!(Integer|Long|Float|Double|Char|Boolean|int|long|float|double|char|boolean|BindingResult|HttpMethod|java.util.TimeZone|java.util.Locale|java.util.ZoneId)) + regex: ^(?!(Integer|Long|Float|Double|Char|Boolean|int|long|float|double|char|boolean|byte|short|BindingResult|HttpMethod|Continuation|java.util.TimeZone|java.util.Locale|java.util.ZoneId|.*\[\])) - metavariable-pattern: metavariable: $ANNOTATION patterns: @@ -84,8 +84,8 @@ rules: - pattern: PostMapping - pattern: PutMapping - pattern: | - $UNTRUSTED = (MessageBodyReader $READER).readFrom(...); + $UNTRUSTED* = (MessageBodyReader $READER).readFrom(...); - pattern: | Cookie $COOKIE = org.springframework.web.util.WebUtils.getCookie(...); ... - $UNTRUSTED = $COOKIE.getValue(); + $UNTRUSTED* = $COOKIE.getValue(); diff --git a/rules/ruleset/java/lib/spring/unvalidated-redirect-sinks.yaml b/rules/ruleset/java/lib/spring/unvalidated-redirect-sinks.yaml index a31db5e9e..e273d990e 100644 --- a/rules/ruleset/java/lib/spring/unvalidated-redirect-sinks.yaml +++ b/rules/ruleset/java/lib/spring/unvalidated-redirect-sinks.yaml @@ -20,7 +20,7 @@ rules: @$ANNOTATION(...) $RETURNTYPE $METHOD(...) { ... - return "$REDIRECT" + $URL; + return "$REDIRECT" + $URL*; ... } From 94661f9c2fb8f684789876dbc35e3d0f9cdd4ca9 Mon Sep 17 00:00:00 2001 From: Aleksandr Misonizhnik Date: Thu, 23 Jul 2026 18:00:58 +0200 Subject: [PATCH 02/12] refactor(rules): migrate the remaining source and sink rules to the star Keeps array and primitive parameters as plain value sources, stars the untrusted-path-source pattern-not with a fresh metavar, drops the List adapter overloads from the command-injection sink, and collapses the servlet upload source read-back -- all expressible directly now that a starred metavar means whole-object taint. Documents the pattern-not star limitation, the sink focus requirement and the Go parity story in the rules README. --- rules/README.md | 104 ++++++++++++++++++ .../lib/generic/command-injection-sinks.yaml | 25 +---- .../servlet-untrusted-data-source.yaml | 4 +- .../lib/spring/untrusted-data-source.yaml | 2 +- .../lib/spring/untrusted-path-source.yaml | 2 +- rules/test/rule-test.yaml | 1 + .../CommandInjectionSpringSamples.java | 31 ++++++ 7 files changed, 144 insertions(+), 25 deletions(-) diff --git a/rules/README.md b/rules/README.md index a216228e0..e49f34a32 100644 --- a/rules/README.md +++ b/rules/README.md @@ -192,6 +192,83 @@ pattern-sinks: - pattern: return $X*; ``` +#### Sinks: the star only takes effect under `focus-metavariable` + +For a starred **sink** metavar to actually widen the check, the occurrence must be pinned +with `focus-metavariable`. A bare `pattern` with no focus collapses the sink to a generic +"is *any* argument tainted" position check, which ignores the star entirely — starring the +metavar in that shape is a no-op. + +```yaml +# correct: focus-metavariable pins $Y as the sink position, so $Y* is honored +pattern-sinks: + - patterns: + - pattern: Sink($Y*) + - focus-metavariable: $Y +``` + +This applies to both Java and Go rules. + +#### `pattern-not` and the star operator (current limitation) + +`pattern-not` is a structural code-shape restriction, not a taint-scope annotation, but its +support for the star operator is currently limited. When a `pattern-not` occurrence shares a +taint metavar with a positive occurrence at the *same position*, the star must match: + +- `pattern-not $X*` against a positive `$X*` — supported, excludes the match. +- `pattern-not $X` against a positive plain `$X` — supported (unstarred/unstarred), excludes + the match. +- A positive `$X*` combined with an **unstarred** `pattern-not $X` at the same position is + **not yet supported**. The scoped "keep the field, drop the base" semantics this would + imply isn't implemented; the analyzer emits a non-fatal load-time diagnostic and, for now, + treats the combination as a full (exclude-all) match — the rule still loads. + +If your positive occurrence is starred, star the corresponding `pattern-not` occurrence too: + +```yaml +# not yet supported: emits a load-time diagnostic, treated as a full exclusion +pattern-sources: + - patterns: + - pattern: | + $METHOD(..., @PathVariable $TYPE $UNTRUSTED*, ...) { ... } + - pattern-not: | + $METHOD(..., @PathVariable $TYPE $UNTRUSTED, ...) { ... } +``` + +```yaml +# write this instead — star the pattern-not occurrence to match the positive +pattern-sources: + - patterns: + - pattern: | + $METHOD(..., @PathVariable $TYPE $UNTRUSTED*, ...) { ... } + - pattern-not: | + $METHOD(..., @PathVariable $TYPE $UNTRUSTED*, ...) { ... } +``` + +A scoped exclusion (drop only the field-taint arm while keeping the base-value arm live) is a +possible future refinement — it is not implemented today. + +#### Go support + +`$VAR*` works in Go rules with the same semantics as Java — `$X` is base-only taint, `$X*` is +base-plus-all-nested-fields — across `pattern-sources`, `pattern-sinks`, and +`pattern-sanitizers`. + +**Behavior change for existing Go rules:** plain `$X` sink checks are now strictly +base-only. Previously, a Go sink's `$X` matched coarsely (base value *or* any field/struct/map +taint on it). If a Go rule relies on field-taint matching at a sink, it must now star the +occurrence (`$X*`) to keep matching — see the [Migration Notes](#migration-notes) below. + +#### Known limitations + +- **Go typed-metavar star doesn't parse yet.** `$C* : T` (or `Type $X* = ...`) is not + supported in Go patterns. Use the bare-metavar form with `focus-metavariable` instead + (e.g. a receiver `$C` pinned via `focus-metavariable: $C`, dropping the type constraint). +- **The `pattern-not` coincidence diagnostic only fires for method-signature-level + coincidences** (e.g. a `pattern-not` on the same formal-parameter position as the starred + positive, as in the example above) — not for call-argument-shaped coincidences. The latter + still safely resolve to a full exclusion, but without the load-time diagnostic. + --- ## Testing and Rule Coverage @@ -337,6 +414,33 @@ pattern-sinks: Likewise, a custom source rule matching a Spring controller parameter now taints only the parameter value unless you star the occurrence (`$VAR*`) to also taint its fields. +### Go: sink `$X` is now strictly base-only + +Go's `$VAR*` star operator support (see above) came with a related default-semantics fix: +previously, a Go sink pattern's plain `$X` matched coarsely — it fired on taint anywhere on +the value, including its fields, structs, and maps. That coarse default has been corrected: +a plain `$X` sink now checks the base value only, matching Java's semantics. + +**If you maintain custom Go rules**, this is a behavior change to be aware of: a sink rule +that used to rely on `$X` catching field/struct/map taint — + +```yaml +pattern-sinks: + - patterns: + - pattern: Sink($X) + - focus-metavariable: $X +``` + +— no longer matches when only a field of `$X` is tainted. Star the occurrence to restore +that behavior: + +```yaml +pattern-sinks: + - patterns: + - pattern: Sink($X*) + - focus-metavariable: $X +``` + --- ## License diff --git a/rules/ruleset/java/lib/generic/command-injection-sinks.yaml b/rules/ruleset/java/lib/generic/command-injection-sinks.yaml index 143f4ab23..8ac9c46b9 100644 --- a/rules/ruleset/java/lib/generic/command-injection-sinks.yaml +++ b/rules/ruleset/java/lib/generic/command-injection-sinks.yaml @@ -13,37 +13,22 @@ rules: patterns: - pattern-either: - pattern: | - (ProcessBuilder $PB).command(..., $UNTRUSTED, ...); + (ProcessBuilder $PB).command(..., $UNTRUSTED*, ...); - pattern: - new ProcessBuilder(..., $UNTRUSTED, ...); - - pattern: - (java.util.List $ARGS).add($UNTRUSTED); - ... - new ProcessBuilder(..., $ARGS, ...); - - pattern: - (java.util.List $ARGS) = List.of(..., $UNTRUSTED, ...); - ... - new ProcessBuilder(..., $ARGS, ...); - - pattern: - (java.util.List $ARGS).add($UNTRUSTED); - ... - (ProcessBuilder $PB).command($ARGS); - - pattern: - (java.util.List $ARGS) = List.of(..., $UNTRUSTED, ...); - ... - (ProcessBuilder $PB).command($ARGS); + new ProcessBuilder(..., $UNTRUSTED*, ...); - patterns: - pattern: | - (ProcessBuilder $PB).command().$ADD(..., $UNTRUSTED, ...); + (ProcessBuilder $PB).command().$ADD(..., $UNTRUSTED*, ...); - metavariable-regex: metavariable: $ADD regex: (add|addAll) - patterns: - pattern: | - (java.lang.Runtime $R).$EXEC(..., $UNTRUSTED, ...); + (java.lang.Runtime $R).$EXEC(..., $UNTRUSTED*, ...); - metavariable-regex: metavariable: $EXEC regex: (exec|loadLibrary|load) + - focus-metavariable: $UNTRUSTED - id: java-expression-language-sinks options: diff --git a/rules/ruleset/java/lib/generic/servlet-untrusted-data-source.yaml b/rules/ruleset/java/lib/generic/servlet-untrusted-data-source.yaml index 8f5535d19..99c88cb28 100644 --- a/rules/ruleset/java/lib/generic/servlet-untrusted-data-source.yaml +++ b/rules/ruleset/java/lib/generic/servlet-untrusted-data-source.yaml @@ -65,9 +65,7 @@ rules: $UNTRUSTED = (MessageBodyReader $READER).readFrom(...); - patterns: - pattern: | - $FILES = ($FILE_UPLOAD_TYPE $SFU).parseRequest((HttpServletRequest $REQ)); - ... - $UNTRUSTED = $FILES.iterator().next().getName(); + $UNTRUSTED* = ($FILE_UPLOAD_TYPE $SFU).parseRequest((HttpServletRequest $REQ)); - metavariable-regex: metavariable: $FILE_UPLOAD_TYPE regex: .*FileUpload.* diff --git a/rules/ruleset/java/lib/spring/untrusted-data-source.yaml b/rules/ruleset/java/lib/spring/untrusted-data-source.yaml index 4415f2f14..6fdcd341f 100644 --- a/rules/ruleset/java/lib/spring/untrusted-data-source.yaml +++ b/rules/ruleset/java/lib/spring/untrusted-data-source.yaml @@ -56,7 +56,7 @@ rules: } - metavariable-regex: metavariable: $TYPE - regex: ^(?!(Integer|Long|Float|Double|Char|Boolean|int|long|float|double|char|boolean|byte|short|BindingResult|HttpMethod|Continuation|java.util.TimeZone|java.util.Locale|java.util.ZoneId|.*\[\])) + regex: ^(?!(Integer|Long|Float|Double|Char|Boolean|int|long|float|double|char|boolean|BindingResult|HttpMethod|Continuation|java.util.TimeZone|java.util.Locale|java.util.ZoneId)) - metavariable-pattern: metavariable: $ANNOTATION patterns: diff --git a/rules/ruleset/java/lib/spring/untrusted-path-source.yaml b/rules/ruleset/java/lib/spring/untrusted-path-source.yaml index e13943105..c41d07993 100644 --- a/rules/ruleset/java/lib/spring/untrusted-path-source.yaml +++ b/rules/ruleset/java/lib/spring/untrusted-path-source.yaml @@ -72,7 +72,7 @@ rules: regex: .*\*.* - metavariable-regex: metavariable: $TYPE - regex: ^(?!(Integer|Long|Float|Double|Char|Boolean|int|long|float|double|char|boolean|byte|short|BindingResult|HttpMethod|Continuation|java.util.TimeZone|java.util.Locale|java.util.ZoneId|.*\[\])) + regex: ^(?!(Integer|Long|Float|Double|Char|Boolean|int|long|float|double|char|boolean|BindingResult|HttpMethod|Continuation|java.util.TimeZone|java.util.Locale|java.util.ZoneId)) - metavariable-pattern: metavariable: $ANNOTATION patterns: diff --git a/rules/test/rule-test.yaml b/rules/test/rule-test.yaml index f59492377..28947381e 100644 --- a/rules/test/rule-test.yaml +++ b/rules/test/rule-test.yaml @@ -42,6 +42,7 @@ tests: positive: - security.commandinjection.CommandInjectionServletSamples$UnsafeCommandServlet#doGet - security.commandinjection.CommandInjectionSpringSamples$UnsafeCommandInjectionController#unsafePing + - security.commandinjection.CommandInjectionSpringSamples$UnsafeCommandInjectionController#unsafePingList - rule-id: java/security/crlf-injection.yaml#http-response-splitting positive: - security.crlfinjection.HttpResponseSplittingServletSamples$UnsafeHeaderServlet#doGet diff --git a/rules/test/src/main/java/security/commandinjection/CommandInjectionSpringSamples.java b/rules/test/src/main/java/security/commandinjection/CommandInjectionSpringSamples.java index 507d74d85..0d1e3df5d 100644 --- a/rules/test/src/main/java/security/commandinjection/CommandInjectionSpringSamples.java +++ b/rules/test/src/main/java/security/commandinjection/CommandInjectionSpringSamples.java @@ -2,6 +2,8 @@ import java.io.BufferedReader; import java.io.InputStreamReader; +import java.util.ArrayList; +import java.util.List; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestParam; @@ -39,6 +41,35 @@ public String unsafePing(@RequestParam String host) { } return output.toString(); } + + /** + * Unsafe endpoint that puts untrusted input into an argument list which is then + * passed to ProcessBuilder. Taint reaches the process via the list's element field, + * so the command-injection sink relies on whole-object (any-field) matching. + */ + @GetMapping("/os-command-injection-in-spring/unsafe-list") + public String unsafePingList(@RequestParam String host) { + List args = new ArrayList<>(); + args.add("ping"); + args.add("-c"); + args.add("4"); + args.add(host); // VULNERABLE: untrusted element flows into the argument list + + StringBuilder output = new StringBuilder(); + try { + Process process = new ProcessBuilder(args).start(); + try (BufferedReader reader = new BufferedReader( + new InputStreamReader(process.getInputStream()))) { + String line; + while ((line = reader.readLine()) != null) { + output.append(line).append('\n'); + } + } + } catch (Exception e) { + return "Error: " + e.getMessage(); + } + return output.toString(); + } } @RestController From cb10f69292b60b2510984f93f56d717be3012ae6 Mon Sep 17 00:00:00 2001 From: Aleksandr Misonizhnik Date: Thu, 23 Jul 2026 18:00:58 +0200 Subject: [PATCH 03/12] refactor(dataflow): delete the implicit array-element mechanism resolveArrayPosition was the last implicit type-triggered array mechanism: it silently gave every array- or Object-typed source ASSIGN position an element twin. The star operator expresses the same thing from the rules, and does it better -- the any-field star is recursive, so it also catches the deep Map flows the element-only twin missed. Array and vararg sink args are now starred explicitly, the implicit sink any-field emission is gone, and the Go side drops its blanket any-accessor emission in favour of explicit variadic taint in the Go model config. --- rules/ruleset/go/lib/cmdi-sinks.yaml | 4 +- rules/ruleset/go/lib/ssti-sinks.yaml | 4 +- rules/ruleset/go/lib/xss-sinks.yaml | 6 +- rules/ruleset/go/security/trust-boundary.yaml | 14 ++- .../lib/generic/code-injection-sinks.yaml | 91 +++++++------- .../java/lib/generic/logging-sinks.yaml | 7 +- .../servlet-response-injection-sinks.yaml | 6 +- .../servlet-xss-html-response-sinks.yaml | 6 +- .../ruleset/java/lib/generic/ssrf-sinks.yaml | 2 +- .../java/lib/spring/jdbc-sqli-sinks.yaml | 116 +++++++++--------- .../spring-response-injection-sinks.yaml | 8 +- .../spring-xss-html-response-sinks.yaml | 6 +- rules/ruleset/java/security/crypto.yaml | 6 +- .../external-configuration-control.yaml | 4 +- .../java/security/insecure-design.yaml | 10 +- 15 files changed, 157 insertions(+), 133 deletions(-) diff --git a/rules/ruleset/go/lib/cmdi-sinks.yaml b/rules/ruleset/go/lib/cmdi-sinks.yaml index 2f500e18e..2ce8f4eef 100644 --- a/rules/ruleset/go/lib/cmdi-sinks.yaml +++ b/rules/ruleset/go/lib/cmdi-sinks.yaml @@ -35,8 +35,8 @@ rules: import "os/exec" ... - pattern-either: - - pattern: "exec.Command(\"$NAME\", ..., $UNTRUSTED, ...)" - - pattern: "exec.CommandContext($CTX, \"$NAME\", ..., $UNTRUSTED, ...)" + - pattern: "exec.Command(\"$NAME\", ..., $*UNTRUSTED, ...)" + - pattern: "exec.CommandContext($CTX, \"$NAME\", ..., $*UNTRUSTED, ...)" - metavariable-regex: metavariable: $NAME regex: sh diff --git a/rules/ruleset/go/lib/ssti-sinks.yaml b/rules/ruleset/go/lib/ssti-sinks.yaml index 7ba59dc24..5c6d04767 100644 --- a/rules/ruleset/go/lib/ssti-sinks.yaml +++ b/rules/ruleset/go/lib/ssti-sinks.yaml @@ -21,7 +21,7 @@ rules: - pattern: "template.New($N).Parse($UNTRUSTED)" - pattern: "template.Must(template.New($N).Parse($UNTRUSTED))" - pattern: "($T : *template.Template).Parse($UNTRUSTED)" - - pattern: "template.New($N).ParseFiles($UNTRUSTED, ...)" + - pattern: "template.New($N).ParseFiles($*UNTRUSTED, ...)" - pattern: "template.New($N).ParseGlob($UNTRUSTED)" - focus-metavariable: $UNTRUSTED - patterns: @@ -32,6 +32,6 @@ rules: - pattern: "template.New($N).Parse($UNTRUSTED)" - pattern: "template.Must(template.New($N).Parse($UNTRUSTED))" - pattern: "($T : *template.Template).Parse($UNTRUSTED)" - - pattern: "template.New($N).ParseFiles($UNTRUSTED, ...)" + - pattern: "template.New($N).ParseFiles($*UNTRUSTED, ...)" - pattern: "template.New($N).ParseGlob($UNTRUSTED)" - focus-metavariable: $UNTRUSTED diff --git a/rules/ruleset/go/lib/xss-sinks.yaml b/rules/ruleset/go/lib/xss-sinks.yaml index 54dea4997..a37615133 100644 --- a/rules/ruleset/go/lib/xss-sinks.yaml +++ b/rules/ruleset/go/lib/xss-sinks.yaml @@ -29,10 +29,10 @@ rules: - pattern: "($O : *context.BeegoOutput).Body($UNTRUSTED)" - pattern: "($O : *context.BeegoOutput).JSON($UNTRUSTED, ...)" - pattern: "($UNTRUSTED : *web.Controller).ServeJSON()" - - pattern: "fmt.Fprint($W, $UNTRUSTED)" + - pattern: "fmt.Fprint($W, $*UNTRUSTED)" - pattern: "fmt.Fprintf($W, $UNTRUSTED, ...)" - - pattern: "fmt.Fprintf($W, $FORMAT, ..., $UNTRUSTED, ...)" - - pattern: "fmt.Fprintln($W, $UNTRUSTED)" + - pattern: "fmt.Fprintf($W, $FORMAT, ..., $*UNTRUSTED, ...)" + - pattern: "fmt.Fprintln($W, $*UNTRUSTED)" - pattern: "io.WriteString($W, $UNTRUSTED)" - pattern: "json.NewEncoder($W).Encode($UNTRUSTED)" - focus-metavariable: $UNTRUSTED diff --git a/rules/ruleset/go/security/trust-boundary.yaml b/rules/ruleset/go/security/trust-boundary.yaml index 6af8473b3..071410927 100644 --- a/rules/ruleset/go/security/trust-boundary.yaml +++ b/rules/ruleset/go/security/trust-boundary.yaml @@ -129,11 +129,19 @@ rules: - pattern: "($X : *context.BeegoInput).RequestBody" - pattern: "($X : *context.BeegoInput).Bind($PTR)" pattern-sinks: + # Star $C so a tainted cookie FIELD (Value/Path set from user input) is observed on the + # whole *http.Cookie via the any-field check. - patterns: - pattern-inside: | import "net/http" import web "github.com/beego/beego/v2/server/web" ... - - pattern-either: - - pattern: "http.SetCookie($W, $C)" - - pattern: "($C : *web.Controller).SetSession($K, $V)" + - pattern: "http.SetCookie($W, $*C)" + - focus-metavariable: $C + - patterns: + - pattern-inside: | + import "net/http" + import web "github.com/beego/beego/v2/server/web" + ... + - pattern: "($C : *web.Controller).SetSession($K, $*V)" + - focus-metavariable: $V diff --git a/rules/ruleset/java/lib/generic/code-injection-sinks.yaml b/rules/ruleset/java/lib/generic/code-injection-sinks.yaml index fbd67c0a5..de85ecb93 100644 --- a/rules/ruleset/java/lib/generic/code-injection-sinks.yaml +++ b/rules/ruleset/java/lib/generic/code-injection-sinks.yaml @@ -12,46 +12,47 @@ rules: - java patterns: - pattern-either: - - pattern: ognl.Ognl.getValue($INPUT,...); - - pattern: (com.opensymphony.xwork2.ognl.OgnlReflectionProvider $P).getGetMethod($T, $INPUT,...); - - pattern: (com.opensymphony.xwork2.ognl.OgnlReflectionProvider $P).getSetMethod($T, $INPUT,...); - - pattern: (com.opensymphony.xwork2.ognl.OgnlReflectionProvider $P).getField($T, $INPUT,...); - - pattern: (com.opensymphony.xwork2.ognl.OgnlReflectionProvider $P).setProperties($INPUT,...); - - pattern: (com.opensymphony.xwork2.ognl.OgnlReflectionProvider $P).setProperty($INPUT,...); - - pattern: (com.opensymphony.xwork2.ognl.OgnlReflectionProvider $P).getValue($INPUT,...); - - pattern: (com.opensymphony.xwork2.ognl.OgnlReflectionProvider $P).setValue($INPUT,...); - - pattern: (com.opensymphony.xwork2.util.reflection.ReflectionProvider $P).getGetMethod($T, $INPUT,...); - - pattern: (com.opensymphony.xwork2.util.reflection.ReflectionProvider $P).getSetMethod($T, $INPUT,...); - - pattern: (com.opensymphony.xwork2.util.reflection.ReflectionProvider $P).getField($T, $INPUT,...); - - pattern: (com.opensymphony.xwork2.util.reflection.ReflectionProvider $P).setProperties($INPUT,...); - - pattern: (com.opensymphony.xwork2.util.reflection.ReflectionProvider $P).setProperty($INPUT,...); - - pattern: (com.opensymphony.xwork2.util.reflection.ReflectionProvider $P).getValue($INPUT,...); - - pattern: (com.opensymphony.xwork2.util.reflection.ReflectionProvider $P).setValue($INPUT,...); - - pattern: (com.opensymphony.xwork2.util.reflection.ReflectionProvider $P).translateVariables($INPUT,...); - - pattern: com.opensymphony.xwork2.util.TextParseUtil.translateVariables($INPUT, ...); - - pattern: com.opensymphony.xwork2.util.TextParseUtil.translateVariablesCollection($INPUT,...); - - pattern: com.opensymphony.xwork2.util.TextParseUtil.shallBeIncluded($INPUT,...); + - pattern: ognl.Ognl.getValue($*INPUT,...); + - pattern: (com.opensymphony.xwork2.ognl.OgnlReflectionProvider $P).getGetMethod($T, $*INPUT,...); + - pattern: (com.opensymphony.xwork2.ognl.OgnlReflectionProvider $P).getSetMethod($T, $*INPUT,...); + - pattern: (com.opensymphony.xwork2.ognl.OgnlReflectionProvider $P).getField($T, $*INPUT,...); + - pattern: (com.opensymphony.xwork2.ognl.OgnlReflectionProvider $P).setProperties($*INPUT,...); + - pattern: (com.opensymphony.xwork2.ognl.OgnlReflectionProvider $P).setProperty($*INPUT,...); + - pattern: (com.opensymphony.xwork2.ognl.OgnlReflectionProvider $P).getValue($*INPUT,...); + - pattern: (com.opensymphony.xwork2.ognl.OgnlReflectionProvider $P).setValue($*INPUT,...); + - pattern: (com.opensymphony.xwork2.util.reflection.ReflectionProvider $P).getGetMethod($T, $*INPUT,...); + - pattern: (com.opensymphony.xwork2.util.reflection.ReflectionProvider $P).getSetMethod($T, $*INPUT,...); + - pattern: (com.opensymphony.xwork2.util.reflection.ReflectionProvider $P).getField($T, $*INPUT,...); + - pattern: (com.opensymphony.xwork2.util.reflection.ReflectionProvider $P).setProperties($*INPUT,...); + - pattern: (com.opensymphony.xwork2.util.reflection.ReflectionProvider $P).setProperty($*INPUT,...); + - pattern: (com.opensymphony.xwork2.util.reflection.ReflectionProvider $P).getValue($*INPUT,...); + - pattern: (com.opensymphony.xwork2.util.reflection.ReflectionProvider $P).setValue($*INPUT,...); + - pattern: (com.opensymphony.xwork2.util.reflection.ReflectionProvider $P).translateVariables($*INPUT,...); + - pattern: com.opensymphony.xwork2.util.TextParseUtil.translateVariables($*INPUT, ...); + - pattern: com.opensymphony.xwork2.util.TextParseUtil.translateVariablesCollection($*INPUT,...); + - pattern: com.opensymphony.xwork2.util.TextParseUtil.shallBeIncluded($*INPUT,...); # TODO: commaDelimitedStringToSet is propagator! - - pattern: com.opensymphony.xwork2.util.TextParseUtil.commaDelimitedStringToSet($INPUT,...); - - pattern: (com.opensymphony.xwork2.util.OgnlTextParser $P).evaluate($INPUT,...); - - pattern: (com.opensymphony.xwork2.util.OgnlTextParser $P).setProperties($INPUT,...); - - pattern: (com.opensymphony.xwork2.ognl.OgnlUtil $P).setProperty($INPUT,...); - - pattern: (com.opensymphony.xwork2.ognl.OgnlUtil $P).getValue($INPUT,...); - - pattern: (com.opensymphony.xwork2.ognl.OgnlUtil $P).setValue($INPUT,...); - - pattern: (com.opensymphony.xwork2.ognl.OgnlUtil $P).callMethod($INPUT,...); - - pattern: (com.opensymphony.xwork2.ognl.OgnlUtil $P).compile($INPUT,...); - - pattern: (org.apache.struts2.util.VelocityStrutsUtil $P).evaluate($INPUT,...); - - pattern: (org.apache.struts2.util.StrutsUtil $P).isTrue($INPUT,...); - - pattern: (org.apache.struts2.util.StrutsUtil $P).findString($INPUT,...); - - pattern: (org.apache.struts2.util.StrutsUtil $P).findValue($INPUT,...); - - pattern: (org.apache.struts2.util.StrutsUtil $P).getText($INPUT,...); - - pattern: (org.apache.struts2.util.StrutsUtil $P).translateVariables($INPUT,...); - - pattern: (org.apache.struts2.util.StrutsUtil $P).makeSelectList($INPUT,...); - - pattern: (org.apache.struts2.views.jsp.ui.OgnlTool $P).findValue($INPUT,...); - - pattern: (com.opensymphony.xwork2.util.ValueStack $P).findString($INPUT,...); - - pattern: (com.opensymphony.xwork2.util.ValueStack $P).findValue($INPUT,...); - - pattern: (com.opensymphony.xwork2.util.ValueStack $P).setValue($INPUT,...); - - pattern: (com.opensymphony.xwork2.util.ValueStack $P).setParameter($INPUT,...); + - pattern: com.opensymphony.xwork2.util.TextParseUtil.commaDelimitedStringToSet($*INPUT,...); + - pattern: (com.opensymphony.xwork2.util.OgnlTextParser $P).evaluate($*INPUT,...); + - pattern: (com.opensymphony.xwork2.util.OgnlTextParser $P).setProperties($*INPUT,...); + - pattern: (com.opensymphony.xwork2.ognl.OgnlUtil $P).setProperty($*INPUT,...); + - pattern: (com.opensymphony.xwork2.ognl.OgnlUtil $P).getValue($*INPUT,...); + - pattern: (com.opensymphony.xwork2.ognl.OgnlUtil $P).setValue($*INPUT,...); + - pattern: (com.opensymphony.xwork2.ognl.OgnlUtil $P).callMethod($*INPUT,...); + - pattern: (com.opensymphony.xwork2.ognl.OgnlUtil $P).compile($*INPUT,...); + - pattern: (org.apache.struts2.util.VelocityStrutsUtil $P).evaluate($*INPUT,...); + - pattern: (org.apache.struts2.util.StrutsUtil $P).isTrue($*INPUT,...); + - pattern: (org.apache.struts2.util.StrutsUtil $P).findString($*INPUT,...); + - pattern: (org.apache.struts2.util.StrutsUtil $P).findValue($*INPUT,...); + - pattern: (org.apache.struts2.util.StrutsUtil $P).getText($*INPUT,...); + - pattern: (org.apache.struts2.util.StrutsUtil $P).translateVariables($*INPUT,...); + - pattern: (org.apache.struts2.util.StrutsUtil $P).makeSelectList($*INPUT,...); + - pattern: (org.apache.struts2.views.jsp.ui.OgnlTool $P).findValue($*INPUT,...); + - pattern: (com.opensymphony.xwork2.util.ValueStack $P).findString($*INPUT,...); + - pattern: (com.opensymphony.xwork2.util.ValueStack $P).findValue($*INPUT,...); + - pattern: (com.opensymphony.xwork2.util.ValueStack $P).setValue($*INPUT,...); + - pattern: (com.opensymphony.xwork2.util.ValueStack $P).setParameter($*INPUT,...); + - focus-metavariable: $INPUT - id: dangerous-groovy-shell options: @@ -96,7 +97,11 @@ rules: provenance: https://find-sec-bugs.github.io/bugs.htm#SCRIPT_ENGINE_INJECTION languages: - java - pattern-either: - - pattern: (javax.script.ScriptEngine $SE).eval($UNTRUSTED) - - pattern: (javax.script.Invocable $INVC).invokeFunction(..., $UNTRUSTED) - - pattern: (javax.script.Invocable $INVC).invokeMethod(..., $UNTRUSTED) + patterns: + - pattern-either: + - pattern: (javax.script.ScriptEngine $SE).eval($UNTRUSTED) + # invokeFunction/invokeMethod pass args via an Object... vararg, so a tainted arg lands + # as a slice element — star to observe it via any-field. + - pattern: (javax.script.Invocable $INVC).invokeFunction(..., $*UNTRUSTED) + - pattern: (javax.script.Invocable $INVC).invokeMethod(..., $*UNTRUSTED) + - focus-metavariable: $UNTRUSTED diff --git a/rules/ruleset/java/lib/generic/logging-sinks.yaml b/rules/ruleset/java/lib/generic/logging-sinks.yaml index af89a3db5..df9e2a204 100644 --- a/rules/ruleset/java/lib/generic/logging-sinks.yaml +++ b/rules/ruleset/java/lib/generic/logging-sinks.yaml @@ -16,8 +16,8 @@ rules: pattern-sinks: - patterns: - pattern-either: - - pattern: $STATIC_LOGGER.$METHOD(...,$DATA,...); - - pattern: (Logger $LOG).$METHOD(..., $DATA,...); + - pattern: $STATIC_LOGGER.$METHOD(...,$*DATA,...); + - pattern: (Logger $LOG).$METHOD(..., $*DATA,...); - focus-metavariable: $DATA - metavariable-regex: metavariable: $METHOD @@ -45,7 +45,8 @@ rules: - java patterns: - pattern: | - (org.jboss.seam.log.Log $LOGGER).$LEVEL($DATA,...) + (org.jboss.seam.log.Log $LOGGER).$LEVEL($*DATA,...) + - focus-metavariable: $DATA - metavariable-regex: metavariable: $LEVEL regex: (debug|error|fatal|info|trace|warn) diff --git a/rules/ruleset/java/lib/generic/servlet-response-injection-sinks.yaml b/rules/ruleset/java/lib/generic/servlet-response-injection-sinks.yaml index 39fc88516..c45d236e8 100644 --- a/rules/ruleset/java/lib/generic/servlet-response-injection-sinks.yaml +++ b/rules/ruleset/java/lib/generic/servlet-response-injection-sinks.yaml @@ -48,13 +48,13 @@ rules: $W = (HttpServletResponse $RESPONSE).getWriter(...); ... - pattern: | - $W.$WRITE(..., $UNTRUSTED, ...); + $W.$WRITE(..., $*UNTRUSTED, ...); - patterns: - pattern-inside: | $S = (HttpServletResponse $RESPONSE).getOutputStream(...); ... - pattern: | - $S.$WRITE(..., $UNTRUSTED, ...); + $S.$WRITE(..., $*UNTRUSTED, ...); - pattern: (HttpServletResponse $RESPONSE).sendError($CODE, $UNTRUSTED) - - pattern: (JspWriter $W).$WRITE(..., $UNTRUSTED, ...) + - pattern: (JspWriter $W).$WRITE(..., $*UNTRUSTED, ...) - focus-metavariable: $UNTRUSTED diff --git a/rules/ruleset/java/lib/generic/servlet-xss-html-response-sinks.yaml b/rules/ruleset/java/lib/generic/servlet-xss-html-response-sinks.yaml index c36ea12ac..c04f6ccec 100644 --- a/rules/ruleset/java/lib/generic/servlet-xss-html-response-sinks.yaml +++ b/rules/ruleset/java/lib/generic/servlet-xss-html-response-sinks.yaml @@ -47,13 +47,13 @@ rules: - pattern: | $W = (HttpServletResponse $RESPONSE).getWriter(...); ... - $W.$WRITE(..., $UNTRUSTED, ...); + $W.$WRITE(..., $*UNTRUSTED, ...); - pattern: | $S = (HttpServletResponse $RESPONSE).getOutputStream(...); ... - $S.$WRITE(..., $UNTRUSTED, ...); + $S.$WRITE(..., $*UNTRUSTED, ...); - pattern: (HttpServletResponse $RESPONSE).sendError($CODE, $UNTRUSTED) - - pattern: (JspWriter $W).$WRITE(..., $UNTRUSTED, ...) + - pattern: (JspWriter $W).$WRITE(..., $*UNTRUSTED, ...) - pattern-not-inside: | (HttpServletResponse $RESPONSE).setContentType("$CT_SAFE"); diff --git a/rules/ruleset/java/lib/generic/ssrf-sinks.yaml b/rules/ruleset/java/lib/generic/ssrf-sinks.yaml index 335cb9562..3c635237d 100644 --- a/rules/ruleset/java/lib/generic/ssrf-sinks.yaml +++ b/rules/ruleset/java/lib/generic/ssrf-sinks.yaml @@ -51,5 +51,5 @@ rules: - pattern: new org.apache.commons.httpclient.methods.GetMethod(..., $UNTRUSTED, ...) - focus-metavariable: $UNTRUSTED - patterns: - - pattern: (org.apache.commons.httpclient.methods.GetMethod $GM).setQueryString(..., $UNTRUSTED, ...) + - pattern: (org.apache.commons.httpclient.methods.GetMethod $GM).setQueryString(..., $*UNTRUSTED, ...) - focus-metavariable: $UNTRUSTED diff --git a/rules/ruleset/java/lib/spring/jdbc-sqli-sinks.yaml b/rules/ruleset/java/lib/spring/jdbc-sqli-sinks.yaml index 5d9311a8a..432cc33ee 100644 --- a/rules/ruleset/java/lib/spring/jdbc-sqli-sinks.yaml +++ b/rules/ruleset/java/lib/spring/jdbc-sqli-sinks.yaml @@ -18,65 +18,65 @@ rules: pattern-sinks: - patterns: - pattern-either: - - pattern: (javax.jdo.PersistenceManager $PM).newQuery($UNTRUSTED) - - pattern: (javax.jdo.PersistenceManager $PM).newQuery(..., $UNTRUSTED) - - pattern: (javax.jdo.Query $Q).setFilter($UNTRUSTED) - - pattern: (javax.jdo.Query $Q).setGrouping($UNTRUSTED) - - pattern: (Statement $S).$SQLFUNC(..., $UNTRUSTED, ...) - - pattern: (CallableStatement $S).$SQLFUNC(..., $UNTRUSTED, ...) - - pattern: (PreparedStatement $P).$SQLFUNC(..., $UNTRUSTED, ...) - - pattern: (Connection $C).prepareStatement($UNTRUSTED, ...).$SQLFUNC(...) - - pattern: (Connection $C).prepareCall($UNTRUSTED, ...).$SQLFUNC(...) - - pattern: (io.vertx.sqlclient.SqlClient $O).query($UNTRUSTED, ...) - - pattern: (io.vertx.sqlclient.SqlClient $O).preparedQuery($UNTRUSTED, ...) - - pattern: (io.vertx.sqlclient.SqlConnection $O).prepare($UNTRUSTED, ...) - - pattern: (org.apache.turbine.om.peer.BasePeer $O).executeQuery($UNTRUSTED, ...) - - pattern: org.apache.torque.util.BasePeer.executeQuery($UNTRUSTED, ...) - - pattern: org.apache.torque.util.BasePeer.executeStatement($UNTRUSTED, ...) - - pattern: (javax.persistence.EntityManager $O).createQuery($UNTRUSTED, ...) - - pattern: (javax.persistence.EntityManager $O).createNativeQuery($UNTRUSTED, ...) - - pattern: (org.jdbi.v3.core.Handle $H).createQuery($UNTRUSTED, ...) - - pattern: (org.jdbi.v3.core.Handle $H).createScript($UNTRUSTED, ...) - - pattern: (org.jdbi.v3.core.Handle $H).createUpdate($UNTRUSTED, ...) - - pattern: (org.jdbi.v3.core.Handle $H).execute($UNTRUSTED, ...) - - pattern: (org.jdbi.v3.core.Handle $H).prepareBatch($UNTRUSTED, ...) - - pattern: (org.jdbi.v3.core.Handle $H).select($UNTRUSTED, ...) - - pattern: new org.jdbi.v3.core.statement.Script($H, $UNTRUSTED) - - pattern: new org.jdbi.v3.core.statement.Update($H, $UNTRUSTED) - - pattern: new org.jdbi.v3.core.statement.PreparedBatch($H, $UNTRUSTED) + - pattern: (javax.jdo.PersistenceManager $PM).newQuery($*UNTRUSTED) + - pattern: (javax.jdo.PersistenceManager $PM).newQuery(..., $*UNTRUSTED) + - pattern: (javax.jdo.Query $Q).setFilter($*UNTRUSTED) + - pattern: (javax.jdo.Query $Q).setGrouping($*UNTRUSTED) + - pattern: (Statement $S).$SQLFUNC(..., $*UNTRUSTED, ...) + - pattern: (CallableStatement $S).$SQLFUNC(..., $*UNTRUSTED, ...) + - pattern: (PreparedStatement $P).$SQLFUNC(..., $*UNTRUSTED, ...) + - pattern: (Connection $C).prepareStatement($*UNTRUSTED, ...).$SQLFUNC(...) + - pattern: (Connection $C).prepareCall($*UNTRUSTED, ...).$SQLFUNC(...) + - pattern: (io.vertx.sqlclient.SqlClient $O).query($*UNTRUSTED, ...) + - pattern: (io.vertx.sqlclient.SqlClient $O).preparedQuery($*UNTRUSTED, ...) + - pattern: (io.vertx.sqlclient.SqlConnection $O).prepare($*UNTRUSTED, ...) + - pattern: (org.apache.turbine.om.peer.BasePeer $O).executeQuery($*UNTRUSTED, ...) + - pattern: org.apache.torque.util.BasePeer.executeQuery($*UNTRUSTED, ...) + - pattern: org.apache.torque.util.BasePeer.executeStatement($*UNTRUSTED, ...) + - pattern: (javax.persistence.EntityManager $O).createQuery($*UNTRUSTED, ...) + - pattern: (javax.persistence.EntityManager $O).createNativeQuery($*UNTRUSTED, ...) + - pattern: (org.jdbi.v3.core.Handle $H).createQuery($*UNTRUSTED, ...) + - pattern: (org.jdbi.v3.core.Handle $H).createScript($*UNTRUSTED, ...) + - pattern: (org.jdbi.v3.core.Handle $H).createUpdate($*UNTRUSTED, ...) + - pattern: (org.jdbi.v3.core.Handle $H).execute($*UNTRUSTED, ...) + - pattern: (org.jdbi.v3.core.Handle $H).prepareBatch($*UNTRUSTED, ...) + - pattern: (org.jdbi.v3.core.Handle $H).select($*UNTRUSTED, ...) + - pattern: new org.jdbi.v3.core.statement.Script($H, $*UNTRUSTED) + - pattern: new org.jdbi.v3.core.statement.Update($H, $*UNTRUSTED) + - pattern: new org.jdbi.v3.core.statement.PreparedBatch($H, $*UNTRUSTED) - - pattern: (EntityManager $EM).createQuery($UNTRUSTED, ...) - - pattern: (EntityManager $EM).createNativeQuery($UNTRUSTED, ...) - - pattern: (javax.jdo.PersistenceManager $PM).newQuery($UNTRUSTED) - - pattern: (javax.jdo.PersistenceManager $PM).newQuery(..., $UNTRUSTED) - - pattern: (javax.jdo.Query $Q).setFilter($UNTRUSTED) - - pattern: (javax.jdo.Query $Q).setGrouping($UNTRUSTED) - - pattern: (org.springframework.jdbc.core.JdbcTemplate $TEMPL).$JDBC_TEMPLATE_METHOD($UNTRUSTED, ...); - - pattern: (org.springframework.jdbc.core.JdbcOperations $O).$JDBC_TEMPLATE_METHOD($UNTRUSTED, ...); - - pattern: new org.springframework.jdbc.core.PreparedStatementCreatorFactory($UNTRUSTED, ...) - - pattern: (org.springframework.jdbc.core.PreparedStatementCreatorFactory $F).newPreparedStatementCreator($UNTRUSTED, ...) - - pattern: org.springframework.jdbc.core.namedparam.NamedParameterBatchUpdateUtils.$M($UNTRUSTED, ...) - - pattern: org.springframework.jdbc.core.BatchUpdateUtils.$M($UNTRUSTED,...) - - pattern: org.hibernate.criterion.Restrictions.sqlRestriction($UNTRUSTED, ...) - - pattern: (org.hibernate.Session $S).createQuery((String $UNTRUSTED), ...) - - pattern: (org.hibernate.Session $S).createSQLQuery($UNTRUSTED, ...) - - pattern: (io.vertx.sqlclient.SqlClient $O).query($UNTRUSTED, ...) - - pattern: (io.vertx.sqlclient.SqlClient $O).preparedQuery($UNTRUSTED, ...) - - pattern: (io.vertx.sqlclient.SqlConnection $O).prepare($UNTRUSTED, ...) - - pattern: (org.apache.turbine.om.peer.BasePeer $O).executeQuery($UNTRUSTED, ...) - - pattern: org.apache.torque.util.BasePeer.executeQuery($UNTRUSTED, ...) - - pattern: org.apache.torque.util.BasePeer.executeStatement($UNTRUSTED, ...) - - pattern: (javax.persistence.EntityManager $O).createQuery($UNTRUSTED, ...) - - pattern: (javax.persistence.EntityManager $O).createNativeQuery($UNTRUSTED, ...) - - pattern: (org.jdbi.v3.core.Handle $H).createQuery($UNTRUSTED, ...) - - pattern: (org.jdbi.v3.core.Handle $H).createScript($UNTRUSTED, ...) - - pattern: (org.jdbi.v3.core.Handle $H).createUpdate($UNTRUSTED, ...) - - pattern: (org.jdbi.v3.core.Handle $H).execute($UNTRUSTED, ...) - - pattern: (org.jdbi.v3.core.Handle $H).prepareBatch($UNTRUSTED, ...) - - pattern: (org.jdbi.v3.core.Handle $H).select($UNTRUSTED, ...) - - pattern: new org.jdbi.v3.core.statement.Script($H, $UNTRUSTED) - - pattern: new org.jdbi.v3.core.statement.Update($H, $UNTRUSTED) - - pattern: new org.jdbi.v3.core.statement.PreparedBatch($H, $UNTRUSTED) + - pattern: (EntityManager $EM).createQuery($*UNTRUSTED, ...) + - pattern: (EntityManager $EM).createNativeQuery($*UNTRUSTED, ...) + - pattern: (javax.jdo.PersistenceManager $PM).newQuery($*UNTRUSTED) + - pattern: (javax.jdo.PersistenceManager $PM).newQuery(..., $*UNTRUSTED) + - pattern: (javax.jdo.Query $Q).setFilter($*UNTRUSTED) + - pattern: (javax.jdo.Query $Q).setGrouping($*UNTRUSTED) + - pattern: (org.springframework.jdbc.core.JdbcTemplate $TEMPL).$JDBC_TEMPLATE_METHOD($*UNTRUSTED, ...); + - pattern: (org.springframework.jdbc.core.JdbcOperations $O).$JDBC_TEMPLATE_METHOD($*UNTRUSTED, ...); + - pattern: new org.springframework.jdbc.core.PreparedStatementCreatorFactory($*UNTRUSTED, ...) + - pattern: (org.springframework.jdbc.core.PreparedStatementCreatorFactory $F).newPreparedStatementCreator($*UNTRUSTED, ...) + - pattern: org.springframework.jdbc.core.namedparam.NamedParameterBatchUpdateUtils.$M($*UNTRUSTED, ...) + - pattern: org.springframework.jdbc.core.BatchUpdateUtils.$M($*UNTRUSTED,...) + - pattern: org.hibernate.criterion.Restrictions.sqlRestriction($*UNTRUSTED, ...) + - pattern: (org.hibernate.Session $S).createQuery((String $*UNTRUSTED), ...) + - pattern: (org.hibernate.Session $S).createSQLQuery($*UNTRUSTED, ...) + - pattern: (io.vertx.sqlclient.SqlClient $O).query($*UNTRUSTED, ...) + - pattern: (io.vertx.sqlclient.SqlClient $O).preparedQuery($*UNTRUSTED, ...) + - pattern: (io.vertx.sqlclient.SqlConnection $O).prepare($*UNTRUSTED, ...) + - pattern: (org.apache.turbine.om.peer.BasePeer $O).executeQuery($*UNTRUSTED, ...) + - pattern: org.apache.torque.util.BasePeer.executeQuery($*UNTRUSTED, ...) + - pattern: org.apache.torque.util.BasePeer.executeStatement($*UNTRUSTED, ...) + - pattern: (javax.persistence.EntityManager $O).createQuery($*UNTRUSTED, ...) + - pattern: (javax.persistence.EntityManager $O).createNativeQuery($*UNTRUSTED, ...) + - pattern: (org.jdbi.v3.core.Handle $H).createQuery($*UNTRUSTED, ...) + - pattern: (org.jdbi.v3.core.Handle $H).createScript($*UNTRUSTED, ...) + - pattern: (org.jdbi.v3.core.Handle $H).createUpdate($*UNTRUSTED, ...) + - pattern: (org.jdbi.v3.core.Handle $H).execute($*UNTRUSTED, ...) + - pattern: (org.jdbi.v3.core.Handle $H).prepareBatch($*UNTRUSTED, ...) + - pattern: (org.jdbi.v3.core.Handle $H).select($*UNTRUSTED, ...) + - pattern: new org.jdbi.v3.core.statement.Script($H, $*UNTRUSTED) + - pattern: new org.jdbi.v3.core.statement.Update($H, $*UNTRUSTED) + - pattern: new org.jdbi.v3.core.statement.PreparedBatch($H, $*UNTRUSTED) - metavariable-regex: metavariable: $SQLFUNC regex: execute|executeQuery|createQuery|executeUpdate|executeLargeUpdate|query|addBatch|nativeSQL|create|prepare|prepareStatement|prepareCall diff --git a/rules/ruleset/java/lib/spring/spring-response-injection-sinks.yaml b/rules/ruleset/java/lib/spring/spring-response-injection-sinks.yaml index c3721348d..10b77084d 100644 --- a/rules/ruleset/java/lib/spring/spring-response-injection-sinks.yaml +++ b/rules/ruleset/java/lib/spring/spring-response-injection-sinks.yaml @@ -44,13 +44,13 @@ rules: - pattern: PostMapping - pattern: PutMapping - pattern: | - (HttpServletResponse $RESPONSE).getWriter(...).$WRITE(..., $UNTRUSTED, ...) + (HttpServletResponse $RESPONSE).getWriter(...).$WRITE(..., $*UNTRUSTED, ...) - pattern: | - (HttpServletResponse $RESPONSE).getOutputStream(...).$WRITE(..., $UNTRUSTED, ...) + (HttpServletResponse $RESPONSE).getOutputStream(...).$WRITE(..., $*UNTRUSTED, ...) - pattern: | (HttpServletResponse $RESPONSE).sendError($CODE, $UNTRUSTED) - pattern: | - (javax.servlet.ServletOutputStream $WRITER).$WRITE(..., $UNTRUSTED, ...) + (javax.servlet.ServletOutputStream $WRITER).$WRITE(..., $*UNTRUSTED, ...) - pattern: | - (ServletOutputStream $WRITER).$WRITE(..., $UNTRUSTED, ...) + (ServletOutputStream $WRITER).$WRITE(..., $*UNTRUSTED, ...) - focus-metavariable: $UNTRUSTED diff --git a/rules/ruleset/java/lib/spring/spring-xss-html-response-sinks.yaml b/rules/ruleset/java/lib/spring/spring-xss-html-response-sinks.yaml index f108426ea..9bb86c3b9 100644 --- a/rules/ruleset/java/lib/spring/spring-xss-html-response-sinks.yaml +++ b/rules/ruleset/java/lib/spring/spring-xss-html-response-sinks.yaml @@ -495,19 +495,19 @@ rules: ... $W = (HttpServletResponse $R).getWriter(...); ... - $W.$WRITE(..., $UNTRUSTED, ...); + $W.$WRITE(..., $*UNTRUSTED, ...); - pattern: | (HttpServletResponse $R).setHeader("Content-Type", "$CT_HTML"); ... $W = (HttpServletResponse $R).getWriter(...); ... - $W.$WRITE(..., $UNTRUSTED, ...); + $W.$WRITE(..., $*UNTRUSTED, ...); - pattern: | (HttpServletResponse $R).addHeader("Content-Type", "$CT_HTML"); ... $W = (HttpServletResponse $R).getWriter(...); ... - $W.$WRITE(..., $UNTRUSTED, ...); + $W.$WRITE(..., $*UNTRUSTED, ...); - metavariable-regex: metavariable: $CT_HTML regex: '^text/html(\s*;.*)?$' diff --git a/rules/ruleset/java/security/crypto.yaml b/rules/ruleset/java/security/crypto.yaml index 2265330f2..0894d6e04 100644 --- a/rules/ruleset/java/security/crypto.yaml +++ b/rules/ruleset/java/security/crypto.yaml @@ -878,12 +878,16 @@ rules: - kt options: primitive-tracking: true + # Star the digest at its assignment ($*DIGEST) so its element (any-field) taint is produced; + # the byte-by-byte read `$DIGEST[...]` at the use routes to the same any-field check (via the + # $X[...] -> star routing), so this matches both `digest[i]` indexing and a `for (byte b : + # digest)` loop where b is an element. The safe idiom uses String.format("%02X", ...). pattern: |- $X $METHOD(...) { ... MessageDigest $MD = ...; ... - $DIGEST = $MD.digest(...); + $*DIGEST = $MD.digest(...); ... Integer.toHexString($DIGEST[...]); } diff --git a/rules/ruleset/java/security/external-configuration-control.yaml b/rules/ruleset/java/security/external-configuration-control.yaml index 572b4ecfa..b8f6718af 100644 --- a/rules/ruleset/java/security/external-configuration-control.yaml +++ b/rules/ruleset/java/security/external-configuration-control.yaml @@ -145,8 +145,8 @@ rules: pattern-sinks: - patterns: - pattern-either: - - pattern: (BeanUtilsBean $B).populate(..., $UNTRUSTED); - - pattern: org.apache.commons.beanutils.BeanUtils.populate(..., $UNTRUSTED); + - pattern: (BeanUtilsBean $B).populate(..., $*UNTRUSTED); + - pattern: org.apache.commons.beanutils.BeanUtils.populate(..., $*UNTRUSTED); - focus-metavariable: $UNTRUSTED - id: sql-catalog-external-manipulation diff --git a/rules/ruleset/java/security/insecure-design.yaml b/rules/ruleset/java/security/insecure-design.yaml index 67f6ec4bd..ff030ed07 100644 --- a/rules/ruleset/java/security/insecure-design.yaml +++ b/rules/ruleset/java/security/insecure-design.yaml @@ -74,8 +74,14 @@ rules: - pattern: doPost - pattern: doDelete - pattern: doTrace - - pattern: | - $REQ.$FUNC(...) + - pattern-either: + # Star the assigned result so an array-typed request value (e.g. + # getParameterValues() -> String[]) taints its elements too; for a scalar + # result $* is base-or-any-field, so the base still matches. + - pattern: | + $*RESULT = $REQ.$FUNC(...) + - pattern: | + $REQ.$FUNC(...) - pattern-not: | $REQ.getSession() pattern-propagators: From aaf0bc042ac4739bc786ab83bcab08015d10840e Mon Sep 17 00:00:00 2001 From: Aleksandr Misonizhnik Date: Thu, 23 Jul 2026 18:00:58 +0200 Subject: [PATCH 04/12] fix(rules): whole-object servlet sources and starred value sanitizers Makes the servlet source whole-object and adds the channel-model getter passthroughs it reads back, and stars the xss and response-injection value sanitizers so a sanitized wrapper is recognised as clean at every depth. --- .../servlet-response-injection-sinks.yaml | 18 ++++++------ .../servlet-untrusted-data-source.yaml | 2 +- .../servlet-xss-html-response-sinks.yaml | 18 ++++++------ .../spring-xss-html-response-sinks.yaml | 28 +++++++++---------- 4 files changed, 33 insertions(+), 33 deletions(-) diff --git a/rules/ruleset/java/lib/generic/servlet-response-injection-sinks.yaml b/rules/ruleset/java/lib/generic/servlet-response-injection-sinks.yaml index c45d236e8..182cfee64 100644 --- a/rules/ruleset/java/lib/generic/servlet-response-injection-sinks.yaml +++ b/rules/ruleset/java/lib/generic/servlet-response-injection-sinks.yaml @@ -14,15 +14,15 @@ rules: pattern-sanitizers: - patterns: - pattern-either: - - pattern: Encode.forHtml(..., $UNTRUSTED, ...) - - pattern: (PolicyFactory $POLICY).sanitize(..., $UNTRUSTED, ...) - - pattern: (AntiSamy $AS).scan(..., $UNTRUSTED, ...) - - pattern: JSoup.clean(..., $UNTRUSTED, ...) - - pattern: HtmlUtils.htmlEscape(..., $UNTRUSTED, ...) - - pattern: org.apache.commons.lang.StringEscapeUtils.escapeHtml(..., $UNTRUSTED, ...) - - pattern: org.apache.commons.text.StringEscapeUtils.escapeHtml3(..., $UNTRUSTED, ...) - - pattern: org.apache.commons.text.StringEscapeUtils.escapeHtml4(..., $UNTRUSTED, ...) - - pattern: org.owasp.esapi.ESAPI.encoder().encodeForHTML(..., $UNTRUSTED, ...) + - pattern: Encode.forHtml(..., $*UNTRUSTED, ...) + - pattern: (PolicyFactory $POLICY).sanitize(..., $*UNTRUSTED, ...) + - pattern: (AntiSamy $AS).scan(..., $*UNTRUSTED, ...) + - pattern: JSoup.clean(..., $*UNTRUSTED, ...) + - pattern: HtmlUtils.htmlEscape(..., $*UNTRUSTED, ...) + - pattern: org.apache.commons.lang.StringEscapeUtils.escapeHtml(..., $*UNTRUSTED, ...) + - pattern: org.apache.commons.text.StringEscapeUtils.escapeHtml3(..., $*UNTRUSTED, ...) + - pattern: org.apache.commons.text.StringEscapeUtils.escapeHtml4(..., $*UNTRUSTED, ...) + - pattern: org.owasp.esapi.ESAPI.encoder().encodeForHTML(..., $*UNTRUSTED, ...) - focus-metavariable: $UNTRUSTED pattern-sinks: diff --git a/rules/ruleset/java/lib/generic/servlet-untrusted-data-source.yaml b/rules/ruleset/java/lib/generic/servlet-untrusted-data-source.yaml index 99c88cb28..0b3e47b3a 100644 --- a/rules/ruleset/java/lib/generic/servlet-untrusted-data-source.yaml +++ b/rules/ruleset/java/lib/generic/servlet-untrusted-data-source.yaml @@ -49,7 +49,7 @@ rules: - pattern-either: - patterns: - pattern: | - $RETURNTYPE $ENTRYPOINT(..., HttpServletRequest $UNTRUSTED,...) { + $RETURNTYPE $ENTRYPOINT(..., HttpServletRequest $*UNTRUSTED,...) { ... } - metavariable-pattern: diff --git a/rules/ruleset/java/lib/generic/servlet-xss-html-response-sinks.yaml b/rules/ruleset/java/lib/generic/servlet-xss-html-response-sinks.yaml index c04f6ccec..96fb4bb1a 100644 --- a/rules/ruleset/java/lib/generic/servlet-xss-html-response-sinks.yaml +++ b/rules/ruleset/java/lib/generic/servlet-xss-html-response-sinks.yaml @@ -17,15 +17,15 @@ rules: pattern-sanitizers: - patterns: - pattern-either: - - pattern: Encode.forHtml(..., $UNTRUSTED, ...) - - pattern: (PolicyFactory $POLICY).sanitize(..., $UNTRUSTED, ...) - - pattern: (AntiSamy $AS).scan(..., $UNTRUSTED, ...) - - pattern: JSoup.clean(..., $UNTRUSTED, ...) - - pattern: HtmlUtils.htmlEscape(..., $UNTRUSTED, ...) - - pattern: org.apache.commons.lang.StringEscapeUtils.escapeHtml(..., $UNTRUSTED, ...) - - pattern: org.apache.commons.text.StringEscapeUtils.escapeHtml3(..., $UNTRUSTED, ...) - - pattern: org.apache.commons.text.StringEscapeUtils.escapeHtml4(..., $UNTRUSTED, ...) - - pattern: org.owasp.esapi.ESAPI.encoder().encodeForHTML(..., $UNTRUSTED, ...) + - pattern: Encode.forHtml(..., $*UNTRUSTED, ...) + - pattern: (PolicyFactory $POLICY).sanitize(..., $*UNTRUSTED, ...) + - pattern: (AntiSamy $AS).scan(..., $*UNTRUSTED, ...) + - pattern: JSoup.clean(..., $*UNTRUSTED, ...) + - pattern: HtmlUtils.htmlEscape(..., $*UNTRUSTED, ...) + - pattern: org.apache.commons.lang.StringEscapeUtils.escapeHtml(..., $*UNTRUSTED, ...) + - pattern: org.apache.commons.text.StringEscapeUtils.escapeHtml3(..., $*UNTRUSTED, ...) + - pattern: org.apache.commons.text.StringEscapeUtils.escapeHtml4(..., $*UNTRUSTED, ...) + - pattern: org.owasp.esapi.ESAPI.encoder().encodeForHTML(..., $*UNTRUSTED, ...) - focus-metavariable: $UNTRUSTED pattern-sinks: diff --git a/rules/ruleset/java/lib/spring/spring-xss-html-response-sinks.yaml b/rules/ruleset/java/lib/spring/spring-xss-html-response-sinks.yaml index 9bb86c3b9..7fddf3da6 100644 --- a/rules/ruleset/java/lib/spring/spring-xss-html-response-sinks.yaml +++ b/rules/ruleset/java/lib/spring/spring-xss-html-response-sinks.yaml @@ -17,15 +17,15 @@ rules: pattern-sanitizers: - patterns: - pattern-either: - - pattern: Encode.forHtml(..., $UNTRUSTED, ...) - - pattern: (PolicyFactory $POLICY).sanitize(..., $UNTRUSTED, ...) - - pattern: (AntiSamy $AS).scan(..., $UNTRUSTED, ...) - - pattern: JSoup.clean(..., $UNTRUSTED, ...) - - pattern: HtmlUtils.htmlEscape(..., $UNTRUSTED, ...) - - pattern: org.apache.commons.lang.StringEscapeUtils.escapeHtml(..., $UNTRUSTED, ...) - - pattern: org.apache.commons.text.StringEscapeUtils.escapeHtml3(..., $UNTRUSTED, ...) - - pattern: org.apache.commons.text.StringEscapeUtils.escapeHtml4(..., $UNTRUSTED, ...) - - pattern: org.owasp.esapi.ESAPI.encoder().encodeForHTML(..., $UNTRUSTED, ...) + - pattern: Encode.forHtml(..., $*UNTRUSTED, ...) + - pattern: (PolicyFactory $POLICY).sanitize(..., $*UNTRUSTED, ...) + - pattern: (AntiSamy $AS).scan(..., $*UNTRUSTED, ...) + - pattern: JSoup.clean(..., $*UNTRUSTED, ...) + - pattern: HtmlUtils.htmlEscape(..., $*UNTRUSTED, ...) + - pattern: org.apache.commons.lang.StringEscapeUtils.escapeHtml(..., $*UNTRUSTED, ...) + - pattern: org.apache.commons.text.StringEscapeUtils.escapeHtml3(..., $*UNTRUSTED, ...) + - pattern: org.apache.commons.text.StringEscapeUtils.escapeHtml4(..., $*UNTRUSTED, ...) + - pattern: org.owasp.esapi.ESAPI.encoder().encodeForHTML(..., $*UNTRUSTED, ...) - focus-metavariable: $UNTRUSTED @@ -34,23 +34,23 @@ rules: - pattern: | $H.setContentType(MediaType.APPLICATION_JSON); ... - new ResponseEntity($UNTRUSTED, $H, ...); + new ResponseEntity($*UNTRUSTED, $H, ...); - pattern: | $H.setContentType(MediaType.APPLICATION_PDF); ... - new ResponseEntity($UNTRUSTED, $H, ...); + new ResponseEntity($*UNTRUSTED, $H, ...); - pattern: | $H.setContentType(MediaType.APPLICATION_OCTET_STREAM); ... - new ResponseEntity($UNTRUSTED, $H, ...); + new ResponseEntity($*UNTRUSTED, $H, ...); - pattern: | $H.setContentType(MediaType.TEXT_PLAIN); ... - new ResponseEntity($UNTRUSTED, $H, ...); + new ResponseEntity($*UNTRUSTED, $H, ...); - pattern: | $H.setContentType(MediaType.APPLICATION_XML); ... - new ResponseEntity($UNTRUSTED, $H, ...); + new ResponseEntity($*UNTRUSTED, $H, ...); - focus-metavariable: $UNTRUSTED pattern-sinks: From 59208d340c9455feb715031bb1a9c42bc28e3c55 Mon Sep 17 00:00:00 2001 From: Aleksandr Misonizhnik Date: Thu, 23 Jul 2026 18:00:58 +0200 Subject: [PATCH 05/12] fix(rules): rework trust-boundary-violation Collapses the source down to a single focused form, focuses and stars the session-store sink, and flags a tainted attribute NAME as well as a tainted value -- previously only the value was considered. --- .../java/security/insecure-design.yaml | 38 +++++++++++++------ rules/test/rule-test.yaml | 2 + .../insecuredesign/InsecureDesignSamples.java | 18 +++++++++ 3 files changed, 47 insertions(+), 11 deletions(-) diff --git a/rules/ruleset/java/security/insecure-design.yaml b/rules/ruleset/java/security/insecure-design.yaml index ff030ed07..2603af29d 100644 --- a/rules/ruleset/java/security/insecure-design.yaml +++ b/rules/ruleset/java/security/insecure-design.yaml @@ -74,26 +74,42 @@ rules: - pattern: doPost - pattern: doDelete - pattern: doTrace - - pattern-either: - # Star the assigned result so an array-typed request value (e.g. - # getParameterValues() -> String[]) taints its elements too; for a scalar - # result $* is base-or-any-field, so the base still matches. - - pattern: | - $*RESULT = $REQ.$FUNC(...) - - pattern: | - $REQ.$FUNC(...) - - pattern-not: | - $REQ.getSession() + # Star the assigned result so an array-typed request value (e.g. + # getParameterValues() -> String[]) taints its elements too; for a scalar + # result $* is base-or-any-field, so the base still matches. Focus pins the + # tainted value to $RESULT; at IR level every used call result is an + # assignment, so this single form also covers calls used directly as + # arguments. getSession is excluded as a $FUNC name constraint (the session + # object is trusted-side state, not untrusted input). + - pattern: | + $*RESULT = $REQ.$FUNC(...) + - metavariable-regex: + metavariable: $FUNC + regex: ^(?!getSession$).* + - focus-metavariable: $RESULT pattern-propagators: - pattern: $RES = org.owasp.esapi.ESAPI.encoder().encodeForHTML($IN) from: $IN to: $RES pattern-sinks: + # CWE-501 covers BOTH argument positions: user-controlled data stored as the attribute + # NAME crosses the trust boundary just like the stored VALUE (OWASP Benchmark's + # trustbound true positives are mostly setAttribute(taintedName, "constant")). One + # focused+starred sink per position keeps the check per-argument (no generic + # any-argument collapse) and lets each observe taint buried in a field/element of the + # stored object. + - patterns: + - pattern: (HttpServletRequest $REQ).getSession().$FUNC($*NAME, $VALUE); + - metavariable-regex: + metavariable: $FUNC + regex: ^(putValue|setAttribute)$ + - focus-metavariable: $NAME - patterns: - - pattern: (HttpServletRequest $REQ).getSession().$FUNC($NAME, $VALUE); + - pattern: (HttpServletRequest $REQ).getSession().$FUNC($NAME, $*VALUE); - metavariable-regex: metavariable: $FUNC regex: ^(putValue|setAttribute)$ + - focus-metavariable: $VALUE - id: cookie-missing-httponly severity: WARNING diff --git a/rules/test/rule-test.yaml b/rules/test/rule-test.yaml index 28947381e..a46c9f20d 100644 --- a/rules/test/rule-test.yaml +++ b/rules/test/rule-test.yaml @@ -314,6 +314,8 @@ tests: - rule-id: java/security/insecure-design.yaml#trust-boundary-violation positive: - security.insecuredesign.InsecureDesignSamples#mixesTrustedAndUntrustedInSessionInsecure + - security.insecuredesign.InsecureDesignSamples#storesWrappedUntrustedInSessionInsecure + - security.insecuredesign.InsecureDesignSamples#storesUntrustedAttributeNameInSessionInsecure negative: - security.insecuredesign.InsecureDesignSamples#validateBeforeCrossingTrustBoundarySecure - rule-id: java/security/ldap.yaml#java-anonymous-ldap diff --git a/rules/test/src/main/java/security/insecuredesign/InsecureDesignSamples.java b/rules/test/src/main/java/security/insecuredesign/InsecureDesignSamples.java index 950049968..ed68c86df 100644 --- a/rules/test/src/main/java/security/insecuredesign/InsecureDesignSamples.java +++ b/rules/test/src/main/java/security/insecuredesign/InsecureDesignSamples.java @@ -51,6 +51,24 @@ public void mixesTrustedAndUntrustedInSessionInsecure(HttpServletRequest request request.getSession().setAttribute("userProfile", username + ":" + theme); } + @GetMapping("/insecure-design/trust-boundary-violation/unsafe-wrapped") + public void storesWrappedUntrustedInSessionInsecure(HttpServletRequest request) { + // Insecure design: the untrusted value is buried inside a fresh container, so only a + // whole-object ($*) sink check observes it at the session store + String theme = request.getParameter("theme"); // untrusted + java.util.Map profile = new java.util.HashMap<>(); + profile.put("theme", theme); + request.getSession().setAttribute("userProfile", profile); + } + + @GetMapping("/insecure-design/trust-boundary-violation/unsafe-name") + public void storesUntrustedAttributeNameInSessionInsecure(HttpServletRequest request) { + // Insecure design: the untrusted value is the attribute NAME — the attacker seeds + // arbitrary session keys (the dominant OWASP Benchmark trustbound shape) + String key = request.getParameter("key"); // untrusted + request.getSession().setAttribute(key, "constant"); + } + @GetMapping("/insecure-design/trust-boundary-violation/safe") public void validateBeforeCrossingTrustBoundarySecure(HttpServletRequest request) { String username = (String) request.getSession().getAttribute("username"); From 77f52b328439add03d8cf04b3b8279f1d0252506 Mon Sep 17 00:00:00 2001 From: Aleksandr Misonizhnik Date: Thu, 23 Jul 2026 18:00:58 +0200 Subject: [PATCH 06/12] refactor(rules): field-sensitive java.io.File model and $*VAR syntax Makes the java.io.File model field-sensitive with starred path sinks, and migrates every starred metavar in the ruleset, the Spring rule provider and the rules README to the $*VAR spelling the parser accepts. --- rules/README.md | 46 +++-- .../lib/generic/command-injection-sinks.yaml | 8 +- .../lib/generic/path-traversal-sinks.yaml | 168 +++++++++--------- .../servlet-untrusted-data-source.yaml | 2 +- .../spring-response-injection-sinks.yaml | 2 +- .../spring-xss-html-response-sinks.yaml | 18 +- .../lib/spring/untrusted-data-source.yaml | 6 +- .../lib/spring/untrusted-path-source.yaml | 10 +- .../spring/unvalidated-redirect-sinks.yaml | 2 +- 9 files changed, 130 insertions(+), 132 deletions(-) diff --git a/rules/README.md b/rules/README.md index e49f34a32..1a3993977 100644 --- a/rules/README.md +++ b/rules/README.md @@ -155,19 +155,20 @@ Rules follow Semgrep syntax and concepts: - External references (OWASP, CWE, upstream rule sources) - Optional `license` and `provenance` -### Whole-Object Taint: the `$VAR*` Star Operator +### Whole-Object Taint: the `$*VAR` Star Operator -A metavariable occurrence in pattern text can be **starred** — `$VAR*` — to mark it as +A metavariable occurrence in pattern text can be **starred** — `$*VAR` — to mark it as **whole-object** taint scope: the metavariable's value *and* all of its nested fields, at any depth (`{ $VAR, $VAR.* }`), instead of just the value itself. -- **Adjacency matters.** The `*` must directly abut the metavariable with no whitespace. - `$X*` is the star operator; `$X * y` (space before `*`) is ordinary multiplication. - Write multiplication with a space to avoid ambiguity. +- **The star is a prefix**, bound directly onto the metavariable token right after the `$`: + `$*X` is the star operator. Because the star sits inside the metavar name, there is no + ambiguity with multiplication — both `$X * y` and the adjacent `$X*y` stay ordinary + multiplication (the retired suffix form `$X*` no longer means whole-object taint). - **Where it's valid**: any metavariable occurrence inside pattern text — `pattern-sources`, `pattern-sinks`, `pattern-sanitizers`, `pattern-propagators`, `pattern-not` / `pattern-not-inside`. It's a per-occurrence annotation, not part of the - metavariable's identity: `$X` and `$X*` in the same rule still bind to the same value. + metavariable's identity: `$X` and `$*X` in the same rule still bind to the same value. - **Not valid** in the `focus-metavariable` YAML field — that field always stays a plain, starless name. - Per operation: a starred **source** taints the value and all its fields; a starred @@ -189,7 +190,7 @@ pattern-sinks: # after: also matches when a nested field of the returned object is tainted pattern-sinks: - patterns: - - pattern: return $X*; + - pattern: return $*X; ``` #### Sinks: the star only takes effect under `focus-metavariable` @@ -200,10 +201,10 @@ with `focus-metavariable`. A bare `pattern` with no focus collapses the sink to metavar in that shape is a no-op. ```yaml -# correct: focus-metavariable pins $Y as the sink position, so $Y* is honored +# correct: focus-metavariable pins $Y as the sink position, so $*Y is honored pattern-sinks: - patterns: - - pattern: Sink($Y*) + - pattern: Sink($*Y) - focus-metavariable: $Y ``` @@ -215,10 +216,10 @@ This applies to both Java and Go rules. support for the star operator is currently limited. When a `pattern-not` occurrence shares a taint metavar with a positive occurrence at the *same position*, the star must match: -- `pattern-not $X*` against a positive `$X*` — supported, excludes the match. +- `pattern-not $*X` against a positive `$*X` — supported, excludes the match. - `pattern-not $X` against a positive plain `$X` — supported (unstarred/unstarred), excludes the match. -- A positive `$X*` combined with an **unstarred** `pattern-not $X` at the same position is +- A positive `$*X` combined with an **unstarred** `pattern-not $X` at the same position is **not yet supported**. The scoped "keep the field, drop the base" semantics this would imply isn't implemented; the analyzer emits a non-fatal load-time diagnostic and, for now, treats the combination as a full (exclude-all) match — the rule still loads. @@ -230,7 +231,7 @@ If your positive occurrence is starred, star the corresponding `pattern-not` occ pattern-sources: - patterns: - pattern: | - $METHOD(..., @PathVariable $TYPE $UNTRUSTED*, ...) { ... } + $METHOD(..., @PathVariable $TYPE $*UNTRUSTED, ...) { ... } - pattern-not: | $METHOD(..., @PathVariable $TYPE $UNTRUSTED, ...) { ... } ``` @@ -240,9 +241,9 @@ pattern-sources: pattern-sources: - patterns: - pattern: | - $METHOD(..., @PathVariable $TYPE $UNTRUSTED*, ...) { ... } + $METHOD(..., @PathVariable $TYPE $*UNTRUSTED, ...) { ... } - pattern-not: | - $METHOD(..., @PathVariable $TYPE $UNTRUSTED*, ...) { ... } + $METHOD(..., @PathVariable $TYPE $*UNTRUSTED, ...) { ... } ``` A scoped exclusion (drop only the field-taint arm while keeping the base-value arm live) is a @@ -250,20 +251,17 @@ possible future refinement — it is not implemented today. #### Go support -`$VAR*` works in Go rules with the same semantics as Java — `$X` is base-only taint, `$X*` is +`$*VAR` works in Go rules with the same semantics as Java — `$X` is base-only taint, `$*X` is base-plus-all-nested-fields — across `pattern-sources`, `pattern-sinks`, and `pattern-sanitizers`. **Behavior change for existing Go rules:** plain `$X` sink checks are now strictly base-only. Previously, a Go sink's `$X` matched coarsely (base value *or* any field/struct/map taint on it). If a Go rule relies on field-taint matching at a sink, it must now star the -occurrence (`$X*`) to keep matching — see the [Migration Notes](#migration-notes) below. +occurrence (`$*X`) to keep matching — see the [Migration Notes](#migration-notes) below. #### Known limitations -- **Go typed-metavar star doesn't parse yet.** `$C* : T` (or `Type $X* = ...`) is not - supported in Go patterns. Use the bare-metavar form with `focus-metavariable` instead - (e.g. a receiver `$C` pinned via `focus-metavariable: $C`, dropping the type constraint). - **The `pattern-not` coincidence diagnostic only fires for method-signature-level coincidences** (e.g. a `pattern-not` on the same formal-parameter position as the starred positive, as in the example above) — not for call-argument-shaped coincidences. The latter @@ -390,7 +388,7 @@ just the parameter value. That hardcoded mechanism has been **removed**. The bundled Spring rules that relied on it (`spring-response-injection-sink`, `spring-xss-html-response-sink`, `spring-unvalidated-redirect-sink`, and the Spring untrusted-data/path sources) have been -updated to opt in explicitly with the `$VAR*` star operator described above, so their +updated to opt in explicitly with the `$*VAR` star operator described above, so their behavior is unchanged. **If you maintain custom rules**, this is a behavior change to be aware of: a custom rule @@ -408,15 +406,15 @@ pattern-sinks: ```yaml pattern-sinks: - patterns: - - pattern: return $X*; + - pattern: return $*X; ``` Likewise, a custom source rule matching a Spring controller parameter now taints only the -parameter value unless you star the occurrence (`$VAR*`) to also taint its fields. +parameter value unless you star the occurrence (`$*VAR`) to also taint its fields. ### Go: sink `$X` is now strictly base-only -Go's `$VAR*` star operator support (see above) came with a related default-semantics fix: +Go's `$*VAR` star operator support (see above) came with a related default-semantics fix: previously, a Go sink pattern's plain `$X` matched coarsely — it fired on taint anywhere on the value, including its fields, structs, and maps. That coarse default has been corrected: a plain `$X` sink now checks the base value only, matching Java's semantics. @@ -437,7 +435,7 @@ that behavior: ```yaml pattern-sinks: - patterns: - - pattern: Sink($X*) + - pattern: Sink($*X) - focus-metavariable: $X ``` diff --git a/rules/ruleset/java/lib/generic/command-injection-sinks.yaml b/rules/ruleset/java/lib/generic/command-injection-sinks.yaml index 8ac9c46b9..ac6090b44 100644 --- a/rules/ruleset/java/lib/generic/command-injection-sinks.yaml +++ b/rules/ruleset/java/lib/generic/command-injection-sinks.yaml @@ -13,18 +13,18 @@ rules: patterns: - pattern-either: - pattern: | - (ProcessBuilder $PB).command(..., $UNTRUSTED*, ...); + (ProcessBuilder $PB).command(..., $*UNTRUSTED, ...); - pattern: - new ProcessBuilder(..., $UNTRUSTED*, ...); + new ProcessBuilder(..., $*UNTRUSTED, ...); - patterns: - pattern: | - (ProcessBuilder $PB).command().$ADD(..., $UNTRUSTED*, ...); + (ProcessBuilder $PB).command().$ADD(..., $*UNTRUSTED, ...); - metavariable-regex: metavariable: $ADD regex: (add|addAll) - patterns: - pattern: | - (java.lang.Runtime $R).$EXEC(..., $UNTRUSTED*, ...); + (java.lang.Runtime $R).$EXEC(..., $*UNTRUSTED, ...); - metavariable-regex: metavariable: $EXEC regex: (exec|loadLibrary|load) diff --git a/rules/ruleset/java/lib/generic/path-traversal-sinks.yaml b/rules/ruleset/java/lib/generic/path-traversal-sinks.yaml index 5e0a0a1ec..fa18ab5c6 100644 --- a/rules/ruleset/java/lib/generic/path-traversal-sinks.yaml +++ b/rules/ruleset/java/lib/generic/path-traversal-sinks.yaml @@ -19,97 +19,97 @@ rules: pattern-sinks: - patterns: - pattern-either: - - pattern: new java.io.FileReader($FILE, ...) - - pattern: new java.io.FileWriter($FILE, ...) - - pattern: new java.io.FileInputStream($FILE) - - pattern: new java.io.FileOutputStream($FILE, ...) - - pattern: new java.io.RandomAccessFile($FILE, ...) - - pattern: java.io.File.createTempFile($_, $_, $FILE) + - pattern: new java.io.FileReader($*FILE, ...) + - pattern: new java.io.FileWriter($*FILE, ...) + - pattern: new java.io.FileInputStream($*FILE) + - pattern: new java.io.FileOutputStream($*FILE, ...) + - pattern: new java.io.RandomAccessFile($*FILE, ...) + - pattern: java.io.File.createTempFile($_, $_, $*FILE) - - pattern: (java.io.File $FILE).exists() - - pattern: (java.io.File $FILE).isFile() - - pattern: (java.io.File $FILE).isDirectory() - - pattern: (java.io.File $FILE).delete() - - pattern: (java.io.File $FILE).deleteOnExit() - - pattern: (java.io.File $FILE).createNewFile() - - pattern: (java.io.File $FILE).mkdir() - - pattern: (java.io.File $FILE).mkdirs() - - pattern: (java.io.File $FILE).setExecutable(...) - - pattern: (java.io.File $FILE).setReadable(...) - - pattern: (java.io.File $FILE).setWritable(...) + - pattern: (java.io.File $*FILE).exists() + - pattern: (java.io.File $*FILE).isFile() + - pattern: (java.io.File $*FILE).isDirectory() + - pattern: (java.io.File $*FILE).delete() + - pattern: (java.io.File $*FILE).deleteOnExit() + - pattern: (java.io.File $*FILE).createNewFile() + - pattern: (java.io.File $*FILE).mkdir() + - pattern: (java.io.File $*FILE).mkdirs() + - pattern: (java.io.File $*FILE).setExecutable(...) + - pattern: (java.io.File $*FILE).setReadable(...) + - pattern: (java.io.File $*FILE).setWritable(...) - - pattern: java.nio.file.Files.copy(..., (java.nio.file.Path $FILE), ...) - - pattern: java.nio.file.Files.createDirectories($FILE, ...) - - pattern: java.nio.file.Files.createDirectory($FILE, ...) - - pattern: java.nio.file.Files.createFile($FILE, ...) - - pattern: java.nio.file.Files.createLink(..., $FILE, ...) - - pattern: java.nio.file.Files.createSymbolicLink(..., $FILE, ...) - - pattern: java.nio.file.Files.createTempFile((java.nio.file.Path $FILE), ...) - - pattern: java.nio.file.Files.createTempDirectory((java.nio.file.Path $FILE), ...) - - pattern: java.nio.file.Files.delete($FILE) - - pattern: java.nio.file.Files.deleteIfExists($FILE) - - pattern: java.nio.file.Files.exists($FILE, ...) - - pattern: java.nio.file.Files.find($FILE, ...) - - pattern: java.nio.file.Files.move(..., $FILE, ...) - - pattern: java.nio.file.Files.newBufferedReader($FILE, ...) - - pattern: java.nio.file.Files.newBufferedWriter($FILE, ...) - - pattern: java.nio.file.Files.newByteChannel($FILE, ...) - - pattern: java.nio.file.Files.newDirectoryStream($FILE, ...) - - pattern: java.nio.file.Files.newInputStream($FILE, ...) - - pattern: java.nio.file.Files.newOutputStream($FILE, ...) - - pattern: java.nio.file.Files.notExists($FILE, ...) - - pattern: java.nio.file.Files.readAllBytes($FILE, ...) - - pattern: java.nio.file.Files.readAllLines($FILE, ...) - - pattern: java.nio.file.Files.readSymbolicLink($FILE, ...) - - pattern: java.nio.file.Files.setLastModifiedTime($FILE, ...) - - pattern: java.nio.file.Files.setOwner($FILE, ...) - - pattern: java.nio.file.Files.setPosixFilePermissions($FILE, ...) - - pattern: java.nio.file.Files.walk($FILE, ...) - - pattern: java.nio.file.Files.walkFileTree($FILE, ...) - - pattern: java.nio.file.Files.write($FILE, ...) + - pattern: java.nio.file.Files.copy(..., (java.nio.file.Path $*FILE), ...) + - pattern: java.nio.file.Files.createDirectories($*FILE, ...) + - pattern: java.nio.file.Files.createDirectory($*FILE, ...) + - pattern: java.nio.file.Files.createFile($*FILE, ...) + - pattern: java.nio.file.Files.createLink(..., $*FILE, ...) + - pattern: java.nio.file.Files.createSymbolicLink(..., $*FILE, ...) + - pattern: java.nio.file.Files.createTempFile((java.nio.file.Path $*FILE), ...) + - pattern: java.nio.file.Files.createTempDirectory((java.nio.file.Path $*FILE), ...) + - pattern: java.nio.file.Files.delete($*FILE) + - pattern: java.nio.file.Files.deleteIfExists($*FILE) + - pattern: java.nio.file.Files.exists($*FILE, ...) + - pattern: java.nio.file.Files.find($*FILE, ...) + - pattern: java.nio.file.Files.move(..., $*FILE, ...) + - pattern: java.nio.file.Files.newBufferedReader($*FILE, ...) + - pattern: java.nio.file.Files.newBufferedWriter($*FILE, ...) + - pattern: java.nio.file.Files.newByteChannel($*FILE, ...) + - pattern: java.nio.file.Files.newDirectoryStream($*FILE, ...) + - pattern: java.nio.file.Files.newInputStream($*FILE, ...) + - pattern: java.nio.file.Files.newOutputStream($*FILE, ...) + - pattern: java.nio.file.Files.notExists($*FILE, ...) + - pattern: java.nio.file.Files.readAllBytes($*FILE, ...) + - pattern: java.nio.file.Files.readAllLines($*FILE, ...) + - pattern: java.nio.file.Files.readSymbolicLink($*FILE, ...) + - pattern: java.nio.file.Files.setLastModifiedTime($*FILE, ...) + - pattern: java.nio.file.Files.setOwner($*FILE, ...) + - pattern: java.nio.file.Files.setPosixFilePermissions($*FILE, ...) + - pattern: java.nio.file.Files.walk($*FILE, ...) + - pattern: java.nio.file.Files.walkFileTree($*FILE, ...) + - pattern: java.nio.file.Files.write($*FILE, ...) - - pattern: org.apache.commons.io.FileUtils.cleanDirectory(..., $FILE, ...) - - pattern: org.apache.commons.io.FileUtils.copyDirectory(..., $FILE, ...) - - pattern: org.apache.commons.io.FileUtils.copyFile(..., $FILE, ...) - - pattern: org.apache.commons.io.FileUtils.copyFileToDirectory(..., $FILE, ...) - - pattern: org.apache.commons.io.FileUtils.delete($FILE) - - pattern: org.apache.commons.io.FileUtils.deleteDirectory($FILE) - - pattern: org.apache.commons.io.FileUtils.deleteQuietly($FILE) - - pattern: org.apache.commons.io.FileUtils.forceDelete($FILE) - - pattern: org.apache.commons.io.FileUtils.forceDeleteOnExit($FILE) - - pattern: org.apache.commons.io.FileUtils.forceMkDir($FILE) - - pattern: org.apache.commons.io.FileUtils.forceMkDirParent($FILE) - - pattern: org.apache.commons.io.FileUtils.iterateFiles($FILE, ...) - - pattern: org.apache.commons.io.FileUtils.iterateFilesAndDirs($FILE, ...) - - pattern: org.apache.commons.io.FileUtils.listFiles($FILE, ...) - - pattern: org.apache.commons.io.FileUtils.listFilesAndDirs($FILE, ...) - - pattern: org.apache.commons.io.FileUtils.moveFile(..., $FILE, ...) - - pattern: org.apache.commons.io.FileUtils.moveToDirectory(..., $FILE, ...) - - pattern: org.apache.commons.io.FileUtils.newOutputStream($FILE, ...) - - pattern: org.apache.commons.io.FileUtils.openOutputStream($FILE, ...) - - pattern: org.apache.commons.io.FileUtils.openInputStream($FILE, ...) - - pattern: org.apache.commons.io.FileUtils.readFileToByteArray($FILE, ...) - - pattern: org.apache.commons.io.FileUtils.readFileToString($FILE, ...) - - pattern: org.apache.commons.io.FileUtils.readLines($FILE, ...) - - pattern: org.apache.commons.io.FileUtils.streamFiles($FILE, ...) - - pattern: org.apache.commons.io.FileUtils.touch($FILE, ...) - - pattern: org.apache.commons.io.FileUtils.write($FILE, ...) - - pattern: org.apache.commons.io.FileUtils.writeByteArrayToFile($FILE, ...) - - pattern: org.apache.commons.io.FileUtils.writeLines($FILE, ...) - - pattern: org.apache.commons.io.FileUtils.writeStringToFile($FILE, ...) + - pattern: org.apache.commons.io.FileUtils.cleanDirectory(..., $*FILE, ...) + - pattern: org.apache.commons.io.FileUtils.copyDirectory(..., $*FILE, ...) + - pattern: org.apache.commons.io.FileUtils.copyFile(..., $*FILE, ...) + - pattern: org.apache.commons.io.FileUtils.copyFileToDirectory(..., $*FILE, ...) + - pattern: org.apache.commons.io.FileUtils.delete($*FILE) + - pattern: org.apache.commons.io.FileUtils.deleteDirectory($*FILE) + - pattern: org.apache.commons.io.FileUtils.deleteQuietly($*FILE) + - pattern: org.apache.commons.io.FileUtils.forceDelete($*FILE) + - pattern: org.apache.commons.io.FileUtils.forceDeleteOnExit($*FILE) + - pattern: org.apache.commons.io.FileUtils.forceMkDir($*FILE) + - pattern: org.apache.commons.io.FileUtils.forceMkDirParent($*FILE) + - pattern: org.apache.commons.io.FileUtils.iterateFiles($*FILE, ...) + - pattern: org.apache.commons.io.FileUtils.iterateFilesAndDirs($*FILE, ...) + - pattern: org.apache.commons.io.FileUtils.listFiles($*FILE, ...) + - pattern: org.apache.commons.io.FileUtils.listFilesAndDirs($*FILE, ...) + - pattern: org.apache.commons.io.FileUtils.moveFile(..., $*FILE, ...) + - pattern: org.apache.commons.io.FileUtils.moveToDirectory(..., $*FILE, ...) + - pattern: org.apache.commons.io.FileUtils.newOutputStream($*FILE, ...) + - pattern: org.apache.commons.io.FileUtils.openOutputStream($*FILE, ...) + - pattern: org.apache.commons.io.FileUtils.openInputStream($*FILE, ...) + - pattern: org.apache.commons.io.FileUtils.readFileToByteArray($*FILE, ...) + - pattern: org.apache.commons.io.FileUtils.readFileToString($*FILE, ...) + - pattern: org.apache.commons.io.FileUtils.readLines($*FILE, ...) + - pattern: org.apache.commons.io.FileUtils.streamFiles($*FILE, ...) + - pattern: org.apache.commons.io.FileUtils.touch($*FILE, ...) + - pattern: org.apache.commons.io.FileUtils.write($*FILE, ...) + - pattern: org.apache.commons.io.FileUtils.writeByteArrayToFile($*FILE, ...) + - pattern: org.apache.commons.io.FileUtils.writeLines($*FILE, ...) + - pattern: org.apache.commons.io.FileUtils.writeStringToFile($*FILE, ...) - - pattern: new org.springframework.core.io.ClassPathResource($FILE, ...) - - pattern: org.springframework.util.ResourceUtils.getFile($FILE, ...) - - pattern: org.springframework.util.FileSystemUtils.$FILE_SYSTEM_UTILS_METHOD(..., $FILE, ...) - - pattern: org.springframework.util.FileCopyUtils.$FILE_COPY_UTILS_METHOD(..., $FILE, ...) - - pattern: new org.springframework.core.io.FileSystemResource($FILE) + - pattern: new org.springframework.core.io.ClassPathResource($*FILE, ...) + - pattern: org.springframework.util.ResourceUtils.getFile($*FILE, ...) + - pattern: org.springframework.util.FileSystemUtils.$FILE_SYSTEM_UTILS_METHOD(..., $*FILE, ...) + - pattern: org.springframework.util.FileCopyUtils.$FILE_COPY_UTILS_METHOD(..., $*FILE, ...) + - pattern: new org.springframework.core.io.FileSystemResource($*FILE) - - pattern: new javax.xml.transform.StreamSource($FILE, ...) - - pattern: new javax.activation.FileDataSource($FILE, ...) + - pattern: new javax.xml.transform.StreamSource($*FILE, ...) + - pattern: new javax.activation.FileDataSource($*FILE, ...) - patterns: - pattern-either: - - pattern: (Class $C).$CLASS_FUNC($FILE) - - pattern: (ClassLoader $CL).$CLASS_FUNC($FILE) + - pattern: (Class $C).$CLASS_FUNC($*FILE) + - pattern: (ClassLoader $CL).$CLASS_FUNC($*FILE) - metavariable-pattern: metavariable: $CLASS_FUNC pattern-either: diff --git a/rules/ruleset/java/lib/generic/servlet-untrusted-data-source.yaml b/rules/ruleset/java/lib/generic/servlet-untrusted-data-source.yaml index 0b3e47b3a..484f5ae47 100644 --- a/rules/ruleset/java/lib/generic/servlet-untrusted-data-source.yaml +++ b/rules/ruleset/java/lib/generic/servlet-untrusted-data-source.yaml @@ -65,7 +65,7 @@ rules: $UNTRUSTED = (MessageBodyReader $READER).readFrom(...); - patterns: - pattern: | - $UNTRUSTED* = ($FILE_UPLOAD_TYPE $SFU).parseRequest((HttpServletRequest $REQ)); + $*UNTRUSTED = ($FILE_UPLOAD_TYPE $SFU).parseRequest((HttpServletRequest $REQ)); - metavariable-regex: metavariable: $FILE_UPLOAD_TYPE regex: .*FileUpload.* diff --git a/rules/ruleset/java/lib/spring/spring-response-injection-sinks.yaml b/rules/ruleset/java/lib/spring/spring-response-injection-sinks.yaml index 10b77084d..b45323a18 100644 --- a/rules/ruleset/java/lib/spring/spring-response-injection-sinks.yaml +++ b/rules/ruleset/java/lib/spring/spring-response-injection-sinks.yaml @@ -30,7 +30,7 @@ rules: @$ANNOTATION(...) $RETURNTYPE $METHOD(...) { ... - return $UNTRUSTED*; + return $*UNTRUSTED; ... } - metavariable-pattern: diff --git a/rules/ruleset/java/lib/spring/spring-xss-html-response-sinks.yaml b/rules/ruleset/java/lib/spring/spring-xss-html-response-sinks.yaml index 7fddf3da6..70f91cf00 100644 --- a/rules/ruleset/java/lib/spring/spring-xss-html-response-sinks.yaml +++ b/rules/ruleset/java/lib/spring/spring-xss-html-response-sinks.yaml @@ -69,11 +69,11 @@ rules: - patterns: - pattern-either: - pattern: | - return ResponseEntity.ok($UNTRUSTED*); + return ResponseEntity.ok($*UNTRUSTED); - pattern: | - return new ResponseEntity($UNTRUSTED*, (HttpStatusCode $STATUS)); + return new ResponseEntity($*UNTRUSTED, (HttpStatusCode $STATUS)); - pattern: | - return new ResponseEntity($UNTRUSTED*, (HttpStatus $STATUS)); + return new ResponseEntity($*UNTRUSTED, (HttpStatus $STATUS)); - patterns: - patterns: - pattern-not-inside: | @@ -174,7 +174,7 @@ rules: $X = ResponseEntity.unprocessableEntity(); ... - pattern: | - return $X.body($UNTRUSTED*); + return $X.body($*UNTRUSTED); - patterns: - patterns: @@ -207,7 +207,7 @@ rules: $H = new HttpHeaders(...); ... - pattern: | - return new ResponseEntity($UNTRUSTED*, $H, ...); + return new ResponseEntity($*UNTRUSTED, $H, ...); - pattern-either: - pattern-inside: | @$ANNOTATION(...) @@ -342,7 +342,7 @@ rules: CompletableFuture $METHOD(...) { ... } - - pattern: return $UNTRUSTED*; + - pattern: return $*UNTRUSTED; - patterns: - pattern-either: - pattern-inside: | @@ -463,7 +463,7 @@ rules: - pattern: '"image/svg+xml"' - pattern: MediaType.TEXT_HTML_VALUE - pattern: MediaType.IMAGE_SVG_XML_VALUE - - pattern: return $UNTRUSTED*; + - pattern: return $*UNTRUSTED; - focus-metavariable: $UNTRUSTED - patterns: @@ -483,8 +483,8 @@ rules: ... } - pattern-either: - - pattern: return $X.contentType(MediaType.TEXT_HTML).body($UNTRUSTED*); - - pattern: return $X.contentType(MediaType.IMAGE_SVG_XML).body($UNTRUSTED*); + - pattern: return $X.contentType(MediaType.TEXT_HTML).body($*UNTRUSTED); + - pattern: return $X.contentType(MediaType.IMAGE_SVG_XML).body($*UNTRUSTED); - focus-metavariable: $UNTRUSTED diff --git a/rules/ruleset/java/lib/spring/untrusted-data-source.yaml b/rules/ruleset/java/lib/spring/untrusted-data-source.yaml index 6fdcd341f..2a25ad27f 100644 --- a/rules/ruleset/java/lib/spring/untrusted-data-source.yaml +++ b/rules/ruleset/java/lib/spring/untrusted-data-source.yaml @@ -51,7 +51,7 @@ rules: - patterns: - pattern: | @$ANNOTATION(...) - $RETURNTYPE $METHODNAME(..., $TYPE $UNTRUSTED*,...) { + $RETURNTYPE $METHODNAME(..., $TYPE $*UNTRUSTED,...) { ... } - metavariable-regex: @@ -68,8 +68,8 @@ rules: - pattern: PostMapping - pattern: PutMapping - pattern: | - $UNTRUSTED* = (MessageBodyReader $READER).readFrom(...); + $*UNTRUSTED = (MessageBodyReader $READER).readFrom(...); - pattern: | Cookie $COOKIE = org.springframework.web.util.WebUtils.getCookie(...); ... - $UNTRUSTED* = $COOKIE.getValue(); + $*UNTRUSTED = $COOKIE.getValue(); diff --git a/rules/ruleset/java/lib/spring/untrusted-path-source.yaml b/rules/ruleset/java/lib/spring/untrusted-path-source.yaml index c41d07993..648f018b7 100644 --- a/rules/ruleset/java/lib/spring/untrusted-path-source.yaml +++ b/rules/ruleset/java/lib/spring/untrusted-path-source.yaml @@ -53,18 +53,18 @@ rules: - pattern-either: - pattern: | @$ANNOTATION($URL) - $RETURNTYPE $METHODNAME(..., @PathVariable $TYPE $UNTRUSTED*,...) { + $RETURNTYPE $METHODNAME(..., @PathVariable $TYPE $*UNTRUSTED,...) { ... } - patterns: - pattern: | @$ANNOTATION(...) - $RETURNTYPE $METHODNAME(..., $TYPE $UNTRUSTED*,...) { + $RETURNTYPE $METHODNAME(..., $TYPE $*UNTRUSTED,...) { ... } - pattern-not: | @$ANNOTATION(...) - $RETURNTYPE $METHODNAME(..., @PathVariable $TYPE $UNTRUSTED*,...) { + $RETURNTYPE $METHODNAME(..., @PathVariable $TYPE $*UNTRUSTED,...) { ... } - metavariable-regex: @@ -84,8 +84,8 @@ rules: - pattern: PostMapping - pattern: PutMapping - pattern: | - $UNTRUSTED* = (MessageBodyReader $READER).readFrom(...); + $*UNTRUSTED = (MessageBodyReader $READER).readFrom(...); - pattern: | Cookie $COOKIE = org.springframework.web.util.WebUtils.getCookie(...); ... - $UNTRUSTED* = $COOKIE.getValue(); + $*UNTRUSTED = $COOKIE.getValue(); diff --git a/rules/ruleset/java/lib/spring/unvalidated-redirect-sinks.yaml b/rules/ruleset/java/lib/spring/unvalidated-redirect-sinks.yaml index e273d990e..49bc8b402 100644 --- a/rules/ruleset/java/lib/spring/unvalidated-redirect-sinks.yaml +++ b/rules/ruleset/java/lib/spring/unvalidated-redirect-sinks.yaml @@ -20,7 +20,7 @@ rules: @$ANNOTATION(...) $RETURNTYPE $METHOD(...) { ... - return "$REDIRECT" + $URL*; + return "$REDIRECT" + $*URL; ... } From c477f25c8ad678cf8dd96fe3d58f10a789fdec86 Mon Sep 17 00:00:00 2001 From: Aleksandr Misonizhnik Date: Wed, 29 Jul 2026 15:54:41 +0200 Subject: [PATCH 07/12] fix(rules): limit SMTP CRLF sinks to raw headers --- .../java/lib/generic/smtp-injection-sinks.yaml | 3 --- rules/ruleset/java/security/crlf-injection.yaml | 9 +++++---- rules/test/rule-test.yaml | 2 ++ .../SmtpCrlfInjectionSpringSamples.java | 14 ++++++++++++++ 4 files changed, 21 insertions(+), 7 deletions(-) diff --git a/rules/ruleset/java/lib/generic/smtp-injection-sinks.yaml b/rules/ruleset/java/lib/generic/smtp-injection-sinks.yaml index aa7d3ab28..cb4965d24 100644 --- a/rules/ruleset/java/lib/generic/smtp-injection-sinks.yaml +++ b/rules/ruleset/java/lib/generic/smtp-injection-sinks.yaml @@ -14,9 +14,6 @@ rules: pattern-sinks: - patterns: - pattern-either: - - pattern: "(MimeMessage $M).setSubject($UNTRUSTED)" - pattern: "(MimeMessage $M).setHeader(..., $UNTRUSTED, ...)" - pattern: "(MimeMessage $M).addHeader(..., $UNTRUSTED, ...)" - - pattern: "(MimeMessage $M).setDescription($UNTRUSTED)" - - pattern: "(MimeMessage $M).setDisposition($UNTRUSTED)" - focus-metavariable: $UNTRUSTED diff --git a/rules/ruleset/java/security/crlf-injection.yaml b/rules/ruleset/java/security/crlf-injection.yaml index df49178cc..4fbfecac9 100644 --- a/rules/ruleset/java/security/crlf-injection.yaml +++ b/rules/ruleset/java/security/crlf-injection.yaml @@ -74,8 +74,8 @@ rules: cwe: CWE-77 short-description: CRLF injection into SMTP message full-description: |- - SMTP header injection occurs when untrusted input reaches `MimeMessage` header-related APIs - (`setSubject`, `setHeader`, `addHeader`, and similar methods). + SMTP header injection occurs when untrusted input reaches raw `MimeMessage` header APIs + (`setHeader` and `addHeader`). Vulnerable example: @@ -103,8 +103,9 @@ rules: } ``` - Key vulnerable patterns covered by this rule include `MimeMessage.setSubject`, `setHeader`, `addHeader`, - `setDescription`, and `setDisposition` with tainted data. + The rule covers raw `MimeMessage.setHeader` and `addHeader` calls. Structured JavaMail + setters such as `setSubject`, `setDescription`, and `setDisposition` apply the API's + encoding, folding, or validation and are intentionally not treated as raw-header sinks. references: - https://owasp.org/www-community/vulnerabilities/CRLF_Injection - https://owasp.org/www-community/attacks/Email_Injection diff --git a/rules/test/rule-test.yaml b/rules/test/rule-test.yaml index a46c9f20d..a7cb6e211 100644 --- a/rules/test/rule-test.yaml +++ b/rules/test/rule-test.yaml @@ -51,6 +51,8 @@ tests: positive: - security.crlfinjection.SmtpCrlfInjectionServletSamples$UnsafeSmtpServlet#doPost - security.crlfinjection.SmtpCrlfInjectionSpringSamples$UnsafeSpringSmtpController#unsafe + negative: + - security.crlfinjection.SmtpCrlfInjectionSpringSamples$SafeHighLevelMimeFieldsController#highLevelFields - rule-id: java/security/crypto.yaml#aes-hardcoded-key positive: - security.crypto.CipherAndKeyCryptoSamples#aesWithHardcodedKey diff --git a/rules/test/src/main/java/security/crlfinjection/SmtpCrlfInjectionSpringSamples.java b/rules/test/src/main/java/security/crlfinjection/SmtpCrlfInjectionSpringSamples.java index 25acaaf24..5c95f10f5 100644 --- a/rules/test/src/main/java/security/crlfinjection/SmtpCrlfInjectionSpringSamples.java +++ b/rules/test/src/main/java/security/crlfinjection/SmtpCrlfInjectionSpringSamples.java @@ -90,4 +90,18 @@ public void safe(@RequestParam("to") String to, } } + @Controller + public static class SafeHighLevelMimeFieldsController { + + @PostMapping("/smtp-crlf/spring/high-level-fields") + public void highLevelFields(@RequestParam String value) throws MessagingException { + MimeMessage mimeMessage = new MimeMessage(Session.getInstance(new Properties())); + + // JavaMail encodes/folds these structured fields. Raw header APIs remain sinks. + mimeMessage.setSubject(value); + mimeMessage.setDescription(value); + mimeMessage.setDisposition(value); + } + } + } From 7e03238ad8405afe827258dd498f10b6e2453297 Mon Sep 17 00:00:00 2001 From: Aleksandr Misonizhnik Date: Wed, 29 Jul 2026 15:54:53 +0200 Subject: [PATCH 08/12] fix(rules): sanitize request URI redirect targets --- .../servlet-unvalidated-redirect-sinks.yaml | 5 +- .../spring/unvalidated-redirect-sinks.yaml | 64 +++++++++++-------- rules/test/rule-test.yaml | 2 + .../UnvalidatedRedirectServletSamples.java | 13 ++++ .../UnvalidatedRedirectSpringSamples.java | 8 +++ 5 files changed, 66 insertions(+), 26 deletions(-) diff --git a/rules/ruleset/java/lib/generic/servlet-unvalidated-redirect-sinks.yaml b/rules/ruleset/java/lib/generic/servlet-unvalidated-redirect-sinks.yaml index ce75c2f04..599a267a3 100644 --- a/rules/ruleset/java/lib/generic/servlet-unvalidated-redirect-sinks.yaml +++ b/rules/ruleset/java/lib/generic/servlet-unvalidated-redirect-sinks.yaml @@ -20,5 +20,8 @@ rules: - focus-metavariable: $URL pattern-sanitizers: - patterns: - - pattern: $URL = (HttpServletRequest $REQ).getContextPath(); + - pattern: $*URI = (HttpServletRequest $REQ).getRequestURI(); + - focus-metavariable: $URI + - patterns: + - pattern: $*URL = (HttpServletRequest $REQ).getContextPath(); - focus-metavariable: $URL diff --git a/rules/ruleset/java/lib/spring/unvalidated-redirect-sinks.yaml b/rules/ruleset/java/lib/spring/unvalidated-redirect-sinks.yaml index 49bc8b402..c7cc19f01 100644 --- a/rules/ruleset/java/lib/spring/unvalidated-redirect-sinks.yaml +++ b/rules/ruleset/java/lib/spring/unvalidated-redirect-sinks.yaml @@ -10,31 +10,45 @@ rules: provenance: https://github.com/semgrep/semgrep-rules/blob/develop/java/spring/security/audit/spring-unvalidated-redirect.yaml languages: - java - pattern-either: - - pattern: new RedirectView($URL); - - pattern: new ModelAndView("redirect:" + $URL); - - pattern: (HttpServletResponse $RES).sendRedirect($URL); - - pattern: (HttpServletResponse $RES).addHeader("Location", $URL); + mode: taint + pattern-sanitizers: - patterns: - - pattern: | - @$ANNOTATION(...) - $RETURNTYPE $METHOD(...) { - ... - return "$REDIRECT" + $*URL; - ... - } + - pattern: $*URI = (HttpServletRequest $REQ).getRequestURI(); + - focus-metavariable: $URI + pattern-sinks: + - patterns: + - pattern: new RedirectView($URL); + - focus-metavariable: $URL + - patterns: + - pattern: new ModelAndView("redirect:" + $URL); + - focus-metavariable: $URL + - patterns: + - pattern: (HttpServletResponse $RES).sendRedirect($URL); + - focus-metavariable: $URL + - patterns: + - pattern: (HttpServletResponse $RES).addHeader("Location", $URL); + - focus-metavariable: $URL + - patterns: + - pattern: | + @$ANNOTATION(...) + $RETURNTYPE $METHOD(...) { + ... + return "$REDIRECT" + $*URL; + ... + } - - metavariable-regex: - metavariable: $REDIRECT - regex: "redirect:.*" + - metavariable-regex: + metavariable: $REDIRECT + regex: "redirect:.*" - - metavariable-pattern: - metavariable: $ANNOTATION - patterns: - - pattern-either: - - pattern: RequestMapping - - pattern: DeleteMapping - - pattern: GetMapping - - pattern: PatchMapping - - pattern: PostMapping - - pattern: PutMapping + - metavariable-pattern: + metavariable: $ANNOTATION + patterns: + - pattern-either: + - pattern: RequestMapping + - pattern: DeleteMapping + - pattern: GetMapping + - pattern: PatchMapping + - pattern: PostMapping + - pattern: PutMapping + - focus-metavariable: $URL diff --git a/rules/test/rule-test.yaml b/rules/test/rule-test.yaml index a7cb6e211..f43b838eb 100644 --- a/rules/test/rule-test.yaml +++ b/rules/test/rule-test.yaml @@ -497,12 +497,14 @@ tests: - security.unvalidatedredirect.UnvalidatedRedirectServletSamples$UnsafeUnvalidatedRedirectServlet#doGet negative: - security.unvalidatedredirect.UnvalidatedRedirectServletSamples$SafeContextPathRedirectServlet#doGet + - security.unvalidatedredirect.UnvalidatedRedirectServletSamples$SafeRequestUriRedirectServlet#doGet - rule-id: java/security/unvalidated-redirect.yaml#unvalidated-redirect-in-spring-app positive: - security.unvalidatedredirect.UnvalidatedRedirectSpringSamples$UnsafeUnvalidatedRedirectController#unsafeRedirect - security.unvalidatedredirect.UnvalidatedRedirectSpringSamples$UnsafeUnvalidatedRedirectController#unsafeRedirectView negative: - security.unvalidatedredirect.UnvalidatedRedirectSpringSamples$SafeValidatedRedirectController#safeInternalRedirect + - security.unvalidatedredirect.UnvalidatedRedirectSpringSamples$SafeValidatedRedirectController#safeRequestUriRedirect - rule-id: java/security/weak-authentication.yaml#java-jwt-decode-without-verify positive: - security.weakauthentication.WeakAuthenticationSamples#decodeJwtWithoutVerifyInsecure diff --git a/rules/test/src/main/java/security/unvalidatedredirect/UnvalidatedRedirectServletSamples.java b/rules/test/src/main/java/security/unvalidatedredirect/UnvalidatedRedirectServletSamples.java index 74c77ab33..ee5f91ce2 100644 --- a/rules/test/src/main/java/security/unvalidatedredirect/UnvalidatedRedirectServletSamples.java +++ b/rules/test/src/main/java/security/unvalidatedredirect/UnvalidatedRedirectServletSamples.java @@ -81,4 +81,17 @@ protected void doGet(HttpServletRequest request, HttpServletResponse response) response.sendRedirect(url + "/home.jsp"); } } + + /** + * SAFE: getRequestURI() identifies the current request path rather than an + * attacker-selected redirect destination. + */ + public static class SafeRequestUriRedirectServlet extends HttpServlet { + + @Override + protected void doGet(HttpServletRequest request, HttpServletResponse response) + throws ServletException, IOException { + response.sendRedirect(request.getRequestURI() + "/"); + } + } } diff --git a/rules/test/src/main/java/security/unvalidatedredirect/UnvalidatedRedirectSpringSamples.java b/rules/test/src/main/java/security/unvalidatedredirect/UnvalidatedRedirectSpringSamples.java index ea8233cb1..8f8b09d47 100644 --- a/rules/test/src/main/java/security/unvalidatedredirect/UnvalidatedRedirectSpringSamples.java +++ b/rules/test/src/main/java/security/unvalidatedredirect/UnvalidatedRedirectSpringSamples.java @@ -6,6 +6,7 @@ import java.util.Set; import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.GetMapping; @@ -50,6 +51,12 @@ public String safeInternalRedirect(@RequestParam(value = "target", required = fa return "redirect:" + path; } + @GetMapping("/redirect/safe-request-uri") + public void safeRequestUriRedirect(HttpServletRequest request, HttpServletResponse response) + throws java.io.IOException { + response.sendRedirect(request.getRequestURI() + "/"); + } + @GetMapping("/redirect/safe-external") // TODO: uncomment it when conditional sanitizers are implemented // @NegativeRuleSample(value = "java/security/unvalidated-redirect.yaml", id = "unvalidated-redirect-in-spring-app") @@ -73,4 +80,5 @@ public String safeExternalRedirect(@RequestParam("url") String url, HttpServletR return "redirect:/home"; } } + } From 0f13e6e717464624e2552f2e74f44f87cf4fc9a1 Mon Sep 17 00:00:00 2001 From: Aleksandr Misonizhnik Date: Wed, 29 Jul 2026 15:54:58 +0200 Subject: [PATCH 09/12] fix(rules): exclude linkset responses from HTML sinks --- .../spring-xss-html-response-sinks.yaml | 4 ++++ rules/test/rule-test.yaml | 2 ++ .../xss/XssHtmlResponseSpringSamples.java | 22 +++++++++++++++++++ 3 files changed, 28 insertions(+) diff --git a/rules/ruleset/java/lib/spring/spring-xss-html-response-sinks.yaml b/rules/ruleset/java/lib/spring/spring-xss-html-response-sinks.yaml index 70f91cf00..89296498e 100644 --- a/rules/ruleset/java/lib/spring/spring-xss-html-response-sinks.yaml +++ b/rules/ruleset/java/lib/spring/spring-xss-html-response-sinks.yaml @@ -407,6 +407,8 @@ rules: - pattern: '"application/octet-stream"' - pattern: '"application/xml"' - pattern: '"text/xml"' + - pattern: '"application/linkset"' + - pattern: '"application/linkset+json"' - pattern: '"image/png"' - pattern: '"image/jpeg"' - pattern: '"image/gif"' @@ -435,6 +437,8 @@ rules: - pattern: '"application/octet-stream"' - pattern: '"application/xml"' - pattern: '"text/xml"' + - pattern: '"application/linkset"' + - pattern: '"application/linkset+json"' - pattern: '"image/png"' - pattern: '"image/jpeg"' - pattern: '"image/gif"' diff --git a/rules/test/rule-test.yaml b/rules/test/rule-test.yaml index f43b838eb..e2951b104 100644 --- a/rules/test/rule-test.yaml +++ b/rules/test/rule-test.yaml @@ -595,6 +595,8 @@ tests: - security.xss.XssHtmlResponseSpringSamples$Row36ResponseEntityAssignmentJsonController#row36 - security.xss.XssHtmlResponseSpringSamples$Row37ServletSetContentTypeJsonAssignmentController#row37 - security.xss.XssHtmlResponseSpringSamples$Row55BuilderChainHtmlEntityDiscardedController#row55 + - security.xss.XssHtmlResponseSpringSamples$Row57StringProducesLinksetController#row57 + - security.xss.XssHtmlResponseSpringSamples$Row58StringProducesLinksetJsonController#row58 - security.xss.XssHtmlResponseSpringSamples$SafeHtmlController#safeHtmlGreet - security.xss.XssHtmlResponseSpringSamples$SafeJsonStringReturnController#safeJsonStringReturn - security.xss.XssHtmlResponseSpringSamples$SafeStringReturnController#safeStringReturn diff --git a/rules/test/src/main/java/security/xss/XssHtmlResponseSpringSamples.java b/rules/test/src/main/java/security/xss/XssHtmlResponseSpringSamples.java index b71b51115..bb9b510de 100644 --- a/rules/test/src/main/java/security/xss/XssHtmlResponseSpringSamples.java +++ b/rules/test/src/main/java/security/xss/XssHtmlResponseSpringSamples.java @@ -510,4 +510,26 @@ public String row56(@RequestParam(required = false, defaultValue = "") String na return "

Hello, " + name + "!

"; } } + + @RestController + public static class Row57StringProducesLinksetController { + + @GetMapping(value = "/xss-in-spring-app/row-57", produces = "application/linkset") + public String row57(@RequestParam(required = false, defaultValue = "") String name) { + return "rel=\"item\"; anchor=\"" + name + "\""; + } + } + + @RestController + @org.springframework.web.bind.annotation.RequestMapping( + value = "/xss-in-spring-app/row-58", + produces = "application/linkset+json" + ) + public static class Row58StringProducesLinksetJsonController { + + @GetMapping + public String row58(@RequestParam(required = false, defaultValue = "") String name) { + return "{\"linkset\":[{\"anchor\":\"" + name + "\"}]}"; + } + } } From 41b19ba7305c459406b81d7b088021ec486b0afd Mon Sep 17 00:00:00 2001 From: Aleksandr Misonizhnik Date: Wed, 29 Jul 2026 17:35:44 +0200 Subject: [PATCH 10/12] fix(rules): keep allowlisted servlet values untrusted --- .../external-configuration-control.yaml | 34 ++++++++----------- rules/test/rule-test.yaml | 2 +- .../BeanInjectionSamples.java | 24 ++++++++++++- 3 files changed, 39 insertions(+), 21 deletions(-) diff --git a/rules/ruleset/java/security/external-configuration-control.yaml b/rules/ruleset/java/security/external-configuration-control.yaml index b8f6718af..f30cdc23f 100644 --- a/rules/ruleset/java/security/external-configuration-control.yaml +++ b/rules/ruleset/java/security/external-configuration-control.yaml @@ -2,20 +2,19 @@ rules: - id: bean-injection severity: ERROR message: >- - An attacker can set arbitrary bean properties that can compromise system integrity. - An attacker can leverage this functionality to access special bean properties like class.classLoader - that will allow them to override system properties and potentially execute arbitrary code. + Untrusted request data reaches generic bean population and may modify security-sensitive properties. metadata: cwe: CWE-15 - short-description: An attacker can set arbitrary bean properties + short-description: Untrusted request data reaches generic bean population full-description: |- - When untrusted data is passed directly into `org.apache.commons.beanutils.BeanUtilsBean.populate(...)`, - an attacker can control which bean properties are set and to what values. - Because BeanUtils uses reflection and supports nested property paths, - this can lead to a *mass assignment* (over-posting) vulnerability: - users can modify fields that should be server-controlled (e.g., roles, flags, internal configuration), - and, in some environments and older BeanUtils versions, even reach sensitive objects via special properties like `class` - (e.g., `class.classLoader...`) and chain this into more severe attacks. + `org.apache.commons.beanutils.BeanUtilsBean.populate(...)` converts and assigns map values + through reflection. Request-controlled map keys can produce a *mass assignment* (over-posting) + vulnerability by selecting fields that should remain server-controlled (for example roles, + flags, or internal configuration). Request-controlled values under server-selected keys still + require validation before they reach this generic binding API. + + In some environments and older BeanUtils versions, attacker-selected nested property paths + can reach sensitive objects through special properties such as `class.classLoader`. ```java import org.apache.commons.beanutils.BeanUtilsBean; @@ -41,9 +40,11 @@ rules: To remediate this issue, never pass raw, unvalidated, or unfiltered external input directly to `BeanUtilsBean.populate`. Use one or more of the following strategies: - 1. **Whitelist properties before populating** (recommended pattern) + 1. **Allowlist properties and validate their values before populating** - Only allow a fixed set of safe property names to be set from user input. + Only allow a fixed set of safe property names and convert each request value into a + validated domain value before adding it to the map. Allowlisting keys alone prevents + arbitrary-property selection but does not validate their values. ```java import org.apache.commons.beanutils.BeanUtilsBean; @@ -58,8 +59,7 @@ rules: if (rawParams.containsKey(name)) { String[] values = rawParams.get(name); if (values != null && values.length > 0) { - // Add any additional validation/sanitization here - safeParams.put(name, values[0]); + safeParams.put(name, validateProperty(name, values[0])); } } } @@ -138,10 +138,6 @@ rules: message: Beans are populated from untrusted user-controlled data languages: - java - pattern-propagators: - - pattern: $MAP = (javax.servlet.http.HttpServletRequest $REQ).getParameterMap() - from: $REQ - to: $MAP pattern-sinks: - patterns: - pattern-either: diff --git a/rules/test/rule-test.yaml b/rules/test/rule-test.yaml index e2951b104..a5dc4b85b 100644 --- a/rules/test/rule-test.yaml +++ b/rules/test/rule-test.yaml @@ -243,8 +243,8 @@ tests: - rule-id: java/security/external-configuration-control.yaml#bean-injection positive: - security.externalconfigurationcontrol.BeanInjectionSamples#doGet - negative: - security.externalconfigurationcontrol.BeanInjectionSamples#doPost + - security.externalconfigurationcontrol.BeanInjectionSamples#doPut - rule-id: java/security/external-configuration-control.yaml#sql-catalog-external-manipulation positive: - security.externalconfigurationcontrol.SqlCatalogServletSamples$UnsafeCatalogServlet#doGet diff --git a/rules/test/src/main/java/security/externalconfigurationcontrol/BeanInjectionSamples.java b/rules/test/src/main/java/security/externalconfigurationcontrol/BeanInjectionSamples.java index 0a084d0b1..7c1559ff9 100644 --- a/rules/test/src/main/java/security/externalconfigurationcontrol/BeanInjectionSamples.java +++ b/rules/test/src/main/java/security/externalconfigurationcontrol/BeanInjectionSamples.java @@ -66,7 +66,8 @@ protected void doGet(HttpServletRequest request, HttpServletResponse resp) { private static final Set ALLOWED_PROPERTIES = Set.of("username", "email"); /** - * Negative sample: only whitelisted properties are populated, sensitive ones remain server-controlled. + * Positive sample: property names are whitelisted, but request-controlled property values still + * reach the generic bean population API. */ @Override protected void doPost(HttpServletRequest request, HttpServletResponse resp) { @@ -93,4 +94,25 @@ protected void doPost(HttpServletRequest request, HttpServletResponse resp) { throw new RuntimeException(e); } } + + /** + * Positive sample: an untrusted value is copied under a fixed property name and still reaches + * the generic bean population API. + */ + @Override + protected void doPut(HttpServletRequest request, HttpServletResponse resp) { + UserDto user = new UserDto(); + Map safeParams = new HashMap<>(); + + String[] usernames = request.getParameterMap().get("username"); + if (usernames != null && usernames.length > 0) { + safeParams.put("username", usernames[0]); + } + + try { + BeanUtilsBean.getInstance().populate(user, safeParams); + } catch (IllegalAccessException | InvocationTargetException e) { + throw new RuntimeException(e); + } + } } From 44877fbe0364a9630415676a4498206872101d77 Mon Sep 17 00:00:00 2001 From: Aleksandr Misonizhnik Date: Thu, 13 Aug 2026 12:27:46 +0200 Subject: [PATCH 11/12] fix(rules): tie the trust-boundary source to the request receiver The one false positive the rule-tests have been carrying since before this batch: InsecureDesignSamples#validateBeforeCrossingTrustBoundarySecure reads a value back out of the session and stores it again, which is the textbook safe shape, and it reported. The source pattern was `$*RESULT = $REQ.$FUNC(...)` with $REQ bound by the sibling pattern-inside and getSession excluded by a $FUNC regex. Probing the analyzer with three rewritten regexes shows what actually happens: ^zzzNoSuchMethodzzz$ -> 0 findings (the regex constraint is applied) ^getSession$ -> 2 findings (getSession calls do reach the sink) ^getAttribute$ -> 2 findings, including the FP The last one is the answer: `session.getAttribute(..)` matches `$REQ.$FUNC(..)` because the $REQ binding from pattern-inside is not enforced in the sibling pattern, so any receiver matches. Reading back out of the session became a source, and the getSession exclusion never saw the call it was meant to stop. Typing the receiver in the pattern itself - `(HttpServletRequest $REQ).$FUNC(...)`, which is what the sink patterns already do - keeps the three true positives and drops the false one. The rule-tests are now 0 FP / 0 FN for the first time. --- rules/ruleset/java/security/insecure-design.yaml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/rules/ruleset/java/security/insecure-design.yaml b/rules/ruleset/java/security/insecure-design.yaml index 2603af29d..1ab7b7a07 100644 --- a/rules/ruleset/java/security/insecure-design.yaml +++ b/rules/ruleset/java/security/insecure-design.yaml @@ -80,9 +80,13 @@ rules: # tainted value to $RESULT; at IR level every used call result is an # assignment, so this single form also covers calls used directly as # arguments. getSession is excluded as a $FUNC name constraint (the session - # object is trusted-side state, not untrusted input). + # object is trusted-side state, not untrusted input). The receiver carries its + # type in the pattern instead of relying on the $REQ bound by pattern-inside: + # that binding is not enforced across sibling patterns, so an untyped $REQ also + # matches session.getAttribute(..) - reading back out of the session became a + # source and the getSession exclusion never saw the call it was meant to stop. - pattern: | - $*RESULT = $REQ.$FUNC(...) + $*RESULT = (HttpServletRequest $REQ).$FUNC(...) - metavariable-regex: metavariable: $FUNC regex: ^(?!getSession$).* From e9f5c2b7a633aef6b6355a0f2e9b67e73cf98f0d Mon Sep 17 00:00:00 2001 From: Aleksandr Misonizhnik Date: Wed, 19 Aug 2026 22:18:37 +0000 Subject: [PATCH 12/12] fix(rules): propagate element taint through arrayToCommaDelimitedString `StringUtils.arrayToCommaDelimitedString(dir.listFiles())` joins `String.valueOf(element)` for every element, so a mark on an element *object* -- or on any field of it, e.g. `java.io.File#path` -- belongs on the returned String as a plain value. The library model instead copies `arg(0)[*]` verbatim, which carries the element's own accessors over into a String result; `result.java.io.File#path` is correctly rejected by the type checker and the flow dies there. WebGoat's ResponseEntity.status(..).body( StringUtils.arrayToCommaDelimitedString(catPicture.getParentFile().listFiles()) .getBytes()) lost its `xss-in-spring-app` finding at ProfileUploadRetrieval.java:114 that way. A propagator with a **starred** `from` reads through the element's fields and assigns a plain value, which is exactly the join's semantics. The star has to sit on the pattern occurrence -- the `from:`/`to:` YAML fields stay starless, like `focus-metavariable`. Verified: dropping the star loses the shape again; on WebGoat the propagator adds exactly one finding, the lost one (65 vs 64 results). Propagators are per-rule, and only `mode: taint` lib rules -- the sink libs -- have the slot, so this repeats in each sink lib where the helper can appear on a flow. Co-Authored-By: Claude Opus 5 (1M context) --- rules/ruleset/java/lib/generic/path-traversal-sinks.yaml | 9 +++++++++ .../java/lib/spring/spring-xss-html-response-sinks.yaml | 8 +++++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/rules/ruleset/java/lib/generic/path-traversal-sinks.yaml b/rules/ruleset/java/lib/generic/path-traversal-sinks.yaml index fa18ab5c6..c76bc4236 100644 --- a/rules/ruleset/java/lib/generic/path-traversal-sinks.yaml +++ b/rules/ruleset/java/lib/generic/path-traversal-sinks.yaml @@ -12,6 +12,15 @@ rules: languages: - java mode: taint + pattern-propagators: + # `arrayToCommaDelimitedString` joins `String.valueOf(element)` for every element, so a mark + # sitting on an element *object* -- or on any field of it, e.g. `java.io.File#path` for + # `dir.listFiles()` -- lands in the returned String as a plain value. The library model copies + # `arg(0)[*]` verbatim, which carries the element's own accessors over into a String result and + # is rejected by the type checker; the starred `from` reads through them instead. + - pattern: $RES = org.springframework.util.StringUtils.arrayToCommaDelimitedString($*ARR); + from: $ARR + to: $RES pattern-sanitizers: - pattern: org.apache.commons.io.FilenameUtils.getName(...) - pattern: org.apache.commons.io.FilenameUtils.getExtension(...) diff --git a/rules/ruleset/java/lib/spring/spring-xss-html-response-sinks.yaml b/rules/ruleset/java/lib/spring/spring-xss-html-response-sinks.yaml index 89296498e..096378791 100644 --- a/rules/ruleset/java/lib/spring/spring-xss-html-response-sinks.yaml +++ b/rules/ruleset/java/lib/spring/spring-xss-html-response-sinks.yaml @@ -12,7 +12,13 @@ rules: languages: - java mode: taint - + pattern-propagators: + # See the same propagator in lib/generic/path-traversal-sinks.yaml: the join reads + # `String.valueOf(element)`, so a mark on an element object or on any of its fields + # (`File#path` for `dir.listFiles()`) reaches the returned String as a plain value. + - pattern: $RES = org.springframework.util.StringUtils.arrayToCommaDelimitedString($*ARR); + from: $ARR + to: $RES pattern-sanitizers: - patterns: