From 2b5c2c2c523af5e349b4bff8e8fd04a106897929 Mon Sep 17 00:00:00 2001 From: Aleksandr Misonizhnik Date: Thu, 23 Jul 2026 18:00:58 +0200 Subject: [PATCH 01/14] 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 | 8 +- .../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, 101 insertions(+), 24 deletions(-) diff --git a/rules/README.md b/rules/README.md index 96d5826c99..9a347815d8 100644 --- a/rules/README.md +++ b/rules/README.md @@ -173,6 +173,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 @@ -280,6 +317,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 3bd4675703..a3be434de3 100644 --- a/rules/ruleset/java/lib/spring/spring-response-injection-sinks.yaml +++ b/rules/ruleset/java/lib/spring/spring-response-injection-sinks.yaml @@ -31,28 +31,28 @@ rules: @$ANNOTATION(...) String $METHOD(...) { ... - return $UNTRUSTED; + return $UNTRUSTED*; ... } - pattern: | @$ANNOTATION(...) byte[] $METHOD(...) { ... - return $UNTRUSTED; + return $UNTRUSTED*; ... } - pattern: | @$ANNOTATION(...) ResponseEntity $METHOD(...) { ... - return $UNTRUSTED; + return $UNTRUSTED*; ... } - pattern: | @$ANNOTATION(...) ResponseEntity $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 af5372d647..652e44bb25 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,6 +483,6 @@ 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 cdc9c9a938..02ef8b9d97 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 f3d1a60065..439e71cc15 100644 --- a/rules/ruleset/java/lib/spring/untrusted-path-source.yaml +++ b/rules/ruleset/java/lib/spring/untrusted-path-source.yaml @@ -51,18 +51,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: @@ -70,7 +70,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: @@ -82,8 +82,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 ac3d146834..c08ec84517 100644 --- a/rules/ruleset/java/lib/spring/unvalidated-redirect-sinks.yaml +++ b/rules/ruleset/java/lib/spring/unvalidated-redirect-sinks.yaml @@ -18,7 +18,7 @@ rules: @$ANNOTATION(...) $RETURNTYPE $METHOD(...) { ... - return "$REDIRECT" + $UNTRUSTED; + return "$REDIRECT" + $UNTRUSTED*; ... } From b0f3e8c21731d287f5c166b26d5c180c8dc478fe Mon Sep 17 00:00:00 2001 From: Aleksandr Misonizhnik Date: Thu, 23 Jul 2026 18:00:58 +0200 Subject: [PATCH 02/14] 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 9a347815d8..f16085e204 100644 --- a/rules/README.md +++ b/rules/README.md @@ -210,6 +210,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 @@ -355,6 +432,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 30a18fce97..abe0a6a194 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 24ae6bdc28..96c6961250 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 02ef8b9d97..057484256a 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 439e71cc15..47d1b3c627 100644 --- a/rules/ruleset/java/lib/spring/untrusted-path-source.yaml +++ b/rules/ruleset/java/lib/spring/untrusted-path-source.yaml @@ -70,7 +70,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 8698ce3d4f..47876d11b9 100644 --- a/rules/test/rule-test.yaml +++ b/rules/test/rule-test.yaml @@ -46,6 +46,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 507d74d85e..0d1e3df5d6 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 429e866937ae68c2aa37fc5ecd63a29c74093c27 Mon Sep 17 00:00:00 2001 From: Aleksandr Misonizhnik Date: Thu, 23 Jul 2026 18:00:58 +0200 Subject: [PATCH 03/14] 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 | 14 +-- .../servlet-xss-html-response-sinks.yaml | 18 +-- .../ruleset/java/lib/generic/ssrf-sinks.yaml | 2 +- .../java/lib/spring/jdbc-sqli-sinks.yaml | 116 +++++++++--------- rules/ruleset/java/security/crypto.yaml | 6 +- .../external-configuration-control.yaml | 4 +- .../java/security/insecure-design.yaml | 10 +- 13 files changed, 160 insertions(+), 136 deletions(-) diff --git a/rules/ruleset/go/lib/cmdi-sinks.yaml b/rules/ruleset/go/lib/cmdi-sinks.yaml index 2f500e18e9..2ce8f4eef2 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 7ba59dc243..5c6d047674 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 54dea4997a..a37615133d 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 6af8473b35..071410927c 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 38e742b169..b592fc53e2 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($UNTRUSTED,...); - - pattern: (com.opensymphony.xwork2.ognl.OgnlReflectionProvider $P).getGetMethod($T, $UNTRUSTED,...); - - pattern: (com.opensymphony.xwork2.ognl.OgnlReflectionProvider $P).getSetMethod($T, $UNTRUSTED,...); - - pattern: (com.opensymphony.xwork2.ognl.OgnlReflectionProvider $P).getField($T, $UNTRUSTED,...); - - pattern: (com.opensymphony.xwork2.ognl.OgnlReflectionProvider $P).setProperties($UNTRUSTED,...); - - pattern: (com.opensymphony.xwork2.ognl.OgnlReflectionProvider $P).setProperty($UNTRUSTED,...); - - pattern: (com.opensymphony.xwork2.ognl.OgnlReflectionProvider $P).getValue($UNTRUSTED,...); - - pattern: (com.opensymphony.xwork2.ognl.OgnlReflectionProvider $P).setValue($UNTRUSTED,...); - - pattern: (com.opensymphony.xwork2.util.reflection.ReflectionProvider $P).getGetMethod($T, $UNTRUSTED,...); - - pattern: (com.opensymphony.xwork2.util.reflection.ReflectionProvider $P).getSetMethod($T, $UNTRUSTED,...); - - pattern: (com.opensymphony.xwork2.util.reflection.ReflectionProvider $P).getField($T, $UNTRUSTED,...); - - pattern: (com.opensymphony.xwork2.util.reflection.ReflectionProvider $P).setProperties($UNTRUSTED,...); - - pattern: (com.opensymphony.xwork2.util.reflection.ReflectionProvider $P).setProperty($UNTRUSTED,...); - - pattern: (com.opensymphony.xwork2.util.reflection.ReflectionProvider $P).getValue($UNTRUSTED,...); - - pattern: (com.opensymphony.xwork2.util.reflection.ReflectionProvider $P).setValue($UNTRUSTED,...); - - pattern: (com.opensymphony.xwork2.util.reflection.ReflectionProvider $P).translateVariables($UNTRUSTED,...); - - pattern: com.opensymphony.xwork2.util.TextParseUtil.translateVariables($UNTRUSTED, ...); - - pattern: com.opensymphony.xwork2.util.TextParseUtil.translateVariablesCollection($UNTRUSTED,...); - - pattern: com.opensymphony.xwork2.util.TextParseUtil.shallBeIncluded($UNTRUSTED,...); + - pattern: ognl.Ognl.getValue($*UNTRUSTED,...); + - pattern: (com.opensymphony.xwork2.ognl.OgnlReflectionProvider $P).getGetMethod($T, $*UNTRUSTED,...); + - pattern: (com.opensymphony.xwork2.ognl.OgnlReflectionProvider $P).getSetMethod($T, $*UNTRUSTED,...); + - pattern: (com.opensymphony.xwork2.ognl.OgnlReflectionProvider $P).getField($T, $*UNTRUSTED,...); + - pattern: (com.opensymphony.xwork2.ognl.OgnlReflectionProvider $P).setProperties($*UNTRUSTED,...); + - pattern: (com.opensymphony.xwork2.ognl.OgnlReflectionProvider $P).setProperty($*UNTRUSTED,...); + - pattern: (com.opensymphony.xwork2.ognl.OgnlReflectionProvider $P).getValue($*UNTRUSTED,...); + - pattern: (com.opensymphony.xwork2.ognl.OgnlReflectionProvider $P).setValue($*UNTRUSTED,...); + - pattern: (com.opensymphony.xwork2.util.reflection.ReflectionProvider $P).getGetMethod($T, $*UNTRUSTED,...); + - pattern: (com.opensymphony.xwork2.util.reflection.ReflectionProvider $P).getSetMethod($T, $*UNTRUSTED,...); + - pattern: (com.opensymphony.xwork2.util.reflection.ReflectionProvider $P).getField($T, $*UNTRUSTED,...); + - pattern: (com.opensymphony.xwork2.util.reflection.ReflectionProvider $P).setProperties($*UNTRUSTED,...); + - pattern: (com.opensymphony.xwork2.util.reflection.ReflectionProvider $P).setProperty($*UNTRUSTED,...); + - pattern: (com.opensymphony.xwork2.util.reflection.ReflectionProvider $P).getValue($*UNTRUSTED,...); + - pattern: (com.opensymphony.xwork2.util.reflection.ReflectionProvider $P).setValue($*UNTRUSTED,...); + - pattern: (com.opensymphony.xwork2.util.reflection.ReflectionProvider $P).translateVariables($*UNTRUSTED,...); + - pattern: com.opensymphony.xwork2.util.TextParseUtil.translateVariables($*UNTRUSTED, ...); + - pattern: com.opensymphony.xwork2.util.TextParseUtil.translateVariablesCollection($*UNTRUSTED,...); + - pattern: com.opensymphony.xwork2.util.TextParseUtil.shallBeIncluded($*UNTRUSTED,...); # TODO: commaDelimitedStringToSet is propagator! - - pattern: com.opensymphony.xwork2.util.TextParseUtil.commaDelimitedStringToSet($UNTRUSTED,...); - - pattern: (com.opensymphony.xwork2.util.OgnlTextParser $P).evaluate($UNTRUSTED,...); - - pattern: (com.opensymphony.xwork2.util.OgnlTextParser $P).setProperties($UNTRUSTED,...); - - pattern: (com.opensymphony.xwork2.ognl.OgnlUtil $P).setProperty($UNTRUSTED,...); - - pattern: (com.opensymphony.xwork2.ognl.OgnlUtil $P).getValue($UNTRUSTED,...); - - pattern: (com.opensymphony.xwork2.ognl.OgnlUtil $P).setValue($UNTRUSTED,...); - - pattern: (com.opensymphony.xwork2.ognl.OgnlUtil $P).callMethod($UNTRUSTED,...); - - pattern: (com.opensymphony.xwork2.ognl.OgnlUtil $P).compile($UNTRUSTED,...); - - pattern: (org.apache.struts2.util.VelocityStrutsUtil $P).evaluate($UNTRUSTED,...); - - pattern: (org.apache.struts2.util.StrutsUtil $P).isTrue($UNTRUSTED,...); - - pattern: (org.apache.struts2.util.StrutsUtil $P).findString($UNTRUSTED,...); - - pattern: (org.apache.struts2.util.StrutsUtil $P).findValue($UNTRUSTED,...); - - pattern: (org.apache.struts2.util.StrutsUtil $P).getText($UNTRUSTED,...); - - pattern: (org.apache.struts2.util.StrutsUtil $P).translateVariables($UNTRUSTED,...); - - pattern: (org.apache.struts2.util.StrutsUtil $P).makeSelectList($UNTRUSTED,...); - - pattern: (org.apache.struts2.views.jsp.ui.OgnlTool $P).findValue($UNTRUSTED,...); - - pattern: (com.opensymphony.xwork2.util.ValueStack $P).findString($UNTRUSTED,...); - - pattern: (com.opensymphony.xwork2.util.ValueStack $P).findValue($UNTRUSTED,...); - - pattern: (com.opensymphony.xwork2.util.ValueStack $P).setValue($UNTRUSTED,...); - - pattern: (com.opensymphony.xwork2.util.ValueStack $P).setParameter($UNTRUSTED,...); + - pattern: com.opensymphony.xwork2.util.TextParseUtil.commaDelimitedStringToSet($*UNTRUSTED,...); + - pattern: (com.opensymphony.xwork2.util.OgnlTextParser $P).evaluate($*UNTRUSTED,...); + - pattern: (com.opensymphony.xwork2.util.OgnlTextParser $P).setProperties($*UNTRUSTED,...); + - pattern: (com.opensymphony.xwork2.ognl.OgnlUtil $P).setProperty($*UNTRUSTED,...); + - pattern: (com.opensymphony.xwork2.ognl.OgnlUtil $P).getValue($*UNTRUSTED,...); + - pattern: (com.opensymphony.xwork2.ognl.OgnlUtil $P).setValue($*UNTRUSTED,...); + - pattern: (com.opensymphony.xwork2.ognl.OgnlUtil $P).callMethod($*UNTRUSTED,...); + - pattern: (com.opensymphony.xwork2.ognl.OgnlUtil $P).compile($*UNTRUSTED,...); + - pattern: (org.apache.struts2.util.VelocityStrutsUtil $P).evaluate($*UNTRUSTED,...); + - pattern: (org.apache.struts2.util.StrutsUtil $P).isTrue($*UNTRUSTED,...); + - pattern: (org.apache.struts2.util.StrutsUtil $P).findString($*UNTRUSTED,...); + - pattern: (org.apache.struts2.util.StrutsUtil $P).findValue($*UNTRUSTED,...); + - pattern: (org.apache.struts2.util.StrutsUtil $P).getText($*UNTRUSTED,...); + - pattern: (org.apache.struts2.util.StrutsUtil $P).translateVariables($*UNTRUSTED,...); + - pattern: (org.apache.struts2.util.StrutsUtil $P).makeSelectList($*UNTRUSTED,...); + - pattern: (org.apache.struts2.views.jsp.ui.OgnlTool $P).findValue($*UNTRUSTED,...); + - pattern: (com.opensymphony.xwork2.util.ValueStack $P).findString($*UNTRUSTED,...); + - pattern: (com.opensymphony.xwork2.util.ValueStack $P).findValue($*UNTRUSTED,...); + - pattern: (com.opensymphony.xwork2.util.ValueStack $P).setValue($*UNTRUSTED,...); + - pattern: (com.opensymphony.xwork2.util.ValueStack $P).setParameter($*UNTRUSTED,...); + - focus-metavariable: $UNTRUSTED - 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 c4dccd45ec..9491dc54c4 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(...,$UNTRUSTED,...); - - pattern: (Logger $LOG).$METHOD(..., $UNTRUSTED,...); + - pattern: $STATIC_LOGGER.$METHOD(...,$*UNTRUSTED,...); + - pattern: (Logger $LOG).$METHOD(..., $*UNTRUSTED,...); - focus-metavariable: $UNTRUSTED - metavariable-regex: metavariable: $METHOD @@ -43,7 +43,8 @@ rules: - java patterns: - pattern: | - (org.jboss.seam.log.Log $LOGGER).$LEVEL($UNTRUSTED,...) + (org.jboss.seam.log.Log $LOGGER).$LEVEL($*UNTRUSTED,...) + - focus-metavariable: $UNTRUSTED - 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 95e1987f99..f22eeebb8f 100644 --- a/rules/ruleset/java/lib/generic/servlet-response-injection-sinks.yaml +++ b/rules/ruleset/java/lib/generic/servlet-response-injection-sinks.yaml @@ -48,21 +48,21 @@ 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, ...) - pattern: | - (HttpServletResponse $RESPONSE).getWriter(...).$WRITE(..., $UNTRUSTED, ...) + (HttpServletResponse $RESPONSE).getWriter(...).$WRITE(..., $*UNTRUSTED, ...) - pattern: | - (HttpServletResponse $RESPONSE).getOutputStream(...).$WRITE(..., $UNTRUSTED, ...) + (HttpServletResponse $RESPONSE).getOutputStream(...).$WRITE(..., $*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/generic/servlet-xss-html-response-sinks.yaml b/rules/ruleset/java/lib/generic/servlet-xss-html-response-sinks.yaml index 206177af6d..9c729454ac 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"); @@ -86,37 +86,37 @@ 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, ...); - pattern: | (HttpServletResponse $R).setContentType("$CT_HTML"); ... $S = (HttpServletResponse $R).getOutputStream(...); ... - $S.$WRITE(..., $UNTRUSTED, ...); + $S.$WRITE(..., $*UNTRUSTED, ...); - pattern: | (HttpServletResponse $R).setHeader("Content-Type", "$CT_HTML"); ... $S = (HttpServletResponse $R).getOutputStream(...); ... - $S.$WRITE(..., $UNTRUSTED, ...); + $S.$WRITE(..., $*UNTRUSTED, ...); - pattern: | (HttpServletResponse $R).addHeader("Content-Type", "$CT_HTML"); ... $S = (HttpServletResponse $R).getOutputStream(...); ... - $S.$WRITE(..., $UNTRUSTED, ...); + $S.$WRITE(..., $*UNTRUSTED, ...); - metavariable-regex: metavariable: $CT_HTML regex: '^text/html(\s*;.*)?$' diff --git a/rules/ruleset/java/lib/generic/ssrf-sinks.yaml b/rules/ruleset/java/lib/generic/ssrf-sinks.yaml index 335cb9562e..3c635237d3 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 5d9311a8a4..432cc33ee1 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/security/crypto.yaml b/rules/ruleset/java/security/crypto.yaml index 2265330f2e..0894d6e04f 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 0e01bcae39..a772972c2a 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 67f6ec4bd6..ff030ed073 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 b18b6b4d009d7e4a8e9124cfffa1831d8e7be63e Mon Sep 17 00:00:00 2001 From: Aleksandr Misonizhnik Date: Thu, 23 Jul 2026 18:00:58 +0200 Subject: [PATCH 04/14] 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 f22eeebb8f..1fe1ee2cf9 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 96c6961250..8154c07b90 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 9c729454ac..56e57a933e 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 652e44bb25..58f3fda1c9 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 09008480a0d01dcf965cd528d20e0c71dad4caa3 Mon Sep 17 00:00:00 2001 From: Aleksandr Misonizhnik Date: Thu, 23 Jul 2026 18:00:58 +0200 Subject: [PATCH 05/14] 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 ff030ed073..2603af29d8 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 47876d11b9..a22eb68636 100644 --- a/rules/test/rule-test.yaml +++ b/rules/test/rule-test.yaml @@ -318,6 +318,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 950049968e..ed68c86df8 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 294e08026ee4848b5d54fa4833c9cec3bd95879c Mon Sep 17 00:00:00 2001 From: Aleksandr Misonizhnik Date: Thu, 23 Jul 2026 18:00:58 +0200 Subject: [PATCH 06/14] 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 | 8 +- .../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, 133 insertions(+), 135 deletions(-) diff --git a/rules/README.md b/rules/README.md index f16085e204..35e7327ef7 100644 --- a/rules/README.md +++ b/rules/README.md @@ -173,19 +173,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 @@ -207,7 +208,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` @@ -218,10 +219,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 ``` @@ -233,10 +234,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. @@ -248,7 +249,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, ...) { ... } ``` @@ -258,9 +259,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 @@ -268,20 +269,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 @@ -408,7 +406,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 @@ -426,15 +424,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. @@ -455,7 +453,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 abe0a6a194..b78ebf8a3b 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 0e0cc01832..5a80631a65 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($UNTRUSTED, ...) - - pattern: new java.io.FileWriter($UNTRUSTED, ...) - - pattern: new java.io.FileInputStream($UNTRUSTED) - - pattern: new java.io.FileOutputStream($UNTRUSTED, ...) - - pattern: new java.io.RandomAccessFile($UNTRUSTED, ...) - - pattern: java.io.File.createTempFile($_, $_, $UNTRUSTED) + - pattern: new java.io.FileReader($*UNTRUSTED, ...) + - pattern: new java.io.FileWriter($*UNTRUSTED, ...) + - pattern: new java.io.FileInputStream($*UNTRUSTED) + - pattern: new java.io.FileOutputStream($*UNTRUSTED, ...) + - pattern: new java.io.RandomAccessFile($*UNTRUSTED, ...) + - pattern: java.io.File.createTempFile($_, $_, $*UNTRUSTED) - - pattern: (java.io.File $UNTRUSTED).exists() - - pattern: (java.io.File $UNTRUSTED).isFile() - - pattern: (java.io.File $UNTRUSTED).isDirectory() - - pattern: (java.io.File $UNTRUSTED).delete() - - pattern: (java.io.File $UNTRUSTED).deleteOnExit() - - pattern: (java.io.File $UNTRUSTED).createNewFile() - - pattern: (java.io.File $UNTRUSTED).mkdir() - - pattern: (java.io.File $UNTRUSTED).mkdirs() - - pattern: (java.io.File $UNTRUSTED).setExecutable(...) - - pattern: (java.io.File $UNTRUSTED).setReadable(...) - - pattern: (java.io.File $UNTRUSTED).setWritable(...) + - pattern: (java.io.File $*UNTRUSTED).exists() + - pattern: (java.io.File $*UNTRUSTED).isFile() + - pattern: (java.io.File $*UNTRUSTED).isDirectory() + - pattern: (java.io.File $*UNTRUSTED).delete() + - pattern: (java.io.File $*UNTRUSTED).deleteOnExit() + - pattern: (java.io.File $*UNTRUSTED).createNewFile() + - pattern: (java.io.File $*UNTRUSTED).mkdir() + - pattern: (java.io.File $*UNTRUSTED).mkdirs() + - pattern: (java.io.File $*UNTRUSTED).setExecutable(...) + - pattern: (java.io.File $*UNTRUSTED).setReadable(...) + - pattern: (java.io.File $*UNTRUSTED).setWritable(...) - - pattern: java.nio.file.Files.copy(..., (java.nio.file.Path $UNTRUSTED), ...) - - pattern: java.nio.file.Files.createDirectories($UNTRUSTED, ...) - - pattern: java.nio.file.Files.createDirectory($UNTRUSTED, ...) - - pattern: java.nio.file.Files.createFile($UNTRUSTED, ...) - - pattern: java.nio.file.Files.createLink(..., $UNTRUSTED, ...) - - pattern: java.nio.file.Files.createSymbolicLink(..., $UNTRUSTED, ...) - - pattern: java.nio.file.Files.createTempFile((java.nio.file.Path $UNTRUSTED), ...) - - pattern: java.nio.file.Files.createTempDirectory((java.nio.file.Path $UNTRUSTED), ...) - - pattern: java.nio.file.Files.delete($UNTRUSTED) - - pattern: java.nio.file.Files.deleteIfExists($UNTRUSTED) - - pattern: java.nio.file.Files.exists($UNTRUSTED, ...) - - pattern: java.nio.file.Files.find($UNTRUSTED, ...) - - pattern: java.nio.file.Files.move(..., $UNTRUSTED, ...) - - pattern: java.nio.file.Files.newBufferedReader($UNTRUSTED, ...) - - pattern: java.nio.file.Files.newBufferedWriter($UNTRUSTED, ...) - - pattern: java.nio.file.Files.newByteChannel($UNTRUSTED, ...) - - pattern: java.nio.file.Files.newDirectoryStream($UNTRUSTED, ...) - - pattern: java.nio.file.Files.newInputStream($UNTRUSTED, ...) - - pattern: java.nio.file.Files.newOutputStream($UNTRUSTED, ...) - - pattern: java.nio.file.Files.notExists($UNTRUSTED, ...) - - pattern: java.nio.file.Files.readAllBytes($UNTRUSTED, ...) - - pattern: java.nio.file.Files.readAllLines($UNTRUSTED, ...) - - pattern: java.nio.file.Files.readSymbolicLink($UNTRUSTED, ...) - - pattern: java.nio.file.Files.setLastModifiedTime($UNTRUSTED, ...) - - pattern: java.nio.file.Files.setOwner($UNTRUSTED, ...) - - pattern: java.nio.file.Files.setPosixFilePermissions($UNTRUSTED, ...) - - pattern: java.nio.file.Files.walk($UNTRUSTED, ...) - - pattern: java.nio.file.Files.walkFileTree($UNTRUSTED, ...) - - pattern: java.nio.file.Files.write($UNTRUSTED, ...) + - pattern: java.nio.file.Files.copy(..., (java.nio.file.Path $*UNTRUSTED), ...) + - pattern: java.nio.file.Files.createDirectories($*UNTRUSTED, ...) + - pattern: java.nio.file.Files.createDirectory($*UNTRUSTED, ...) + - pattern: java.nio.file.Files.createFile($*UNTRUSTED, ...) + - pattern: java.nio.file.Files.createLink(..., $*UNTRUSTED, ...) + - pattern: java.nio.file.Files.createSymbolicLink(..., $*UNTRUSTED, ...) + - pattern: java.nio.file.Files.createTempFile((java.nio.file.Path $*UNTRUSTED), ...) + - pattern: java.nio.file.Files.createTempDirectory((java.nio.file.Path $*UNTRUSTED), ...) + - pattern: java.nio.file.Files.delete($*UNTRUSTED) + - pattern: java.nio.file.Files.deleteIfExists($*UNTRUSTED) + - pattern: java.nio.file.Files.exists($*UNTRUSTED, ...) + - pattern: java.nio.file.Files.find($*UNTRUSTED, ...) + - pattern: java.nio.file.Files.move(..., $*UNTRUSTED, ...) + - pattern: java.nio.file.Files.newBufferedReader($*UNTRUSTED, ...) + - pattern: java.nio.file.Files.newBufferedWriter($*UNTRUSTED, ...) + - pattern: java.nio.file.Files.newByteChannel($*UNTRUSTED, ...) + - pattern: java.nio.file.Files.newDirectoryStream($*UNTRUSTED, ...) + - pattern: java.nio.file.Files.newInputStream($*UNTRUSTED, ...) + - pattern: java.nio.file.Files.newOutputStream($*UNTRUSTED, ...) + - pattern: java.nio.file.Files.notExists($*UNTRUSTED, ...) + - pattern: java.nio.file.Files.readAllBytes($*UNTRUSTED, ...) + - pattern: java.nio.file.Files.readAllLines($*UNTRUSTED, ...) + - pattern: java.nio.file.Files.readSymbolicLink($*UNTRUSTED, ...) + - pattern: java.nio.file.Files.setLastModifiedTime($*UNTRUSTED, ...) + - pattern: java.nio.file.Files.setOwner($*UNTRUSTED, ...) + - pattern: java.nio.file.Files.setPosixFilePermissions($*UNTRUSTED, ...) + - pattern: java.nio.file.Files.walk($*UNTRUSTED, ...) + - pattern: java.nio.file.Files.walkFileTree($*UNTRUSTED, ...) + - pattern: java.nio.file.Files.write($*UNTRUSTED, ...) - - pattern: org.apache.commons.io.FileUtils.cleanDirectory(..., $UNTRUSTED, ...) - - pattern: org.apache.commons.io.FileUtils.copyDirectory(..., $UNTRUSTED, ...) - - pattern: org.apache.commons.io.FileUtils.copyFile(..., $UNTRUSTED, ...) - - pattern: org.apache.commons.io.FileUtils.copyFileToDirectory(..., $UNTRUSTED, ...) - - pattern: org.apache.commons.io.FileUtils.delete($UNTRUSTED) - - pattern: org.apache.commons.io.FileUtils.deleteDirectory($UNTRUSTED) - - pattern: org.apache.commons.io.FileUtils.deleteQuietly($UNTRUSTED) - - pattern: org.apache.commons.io.FileUtils.forceDelete($UNTRUSTED) - - pattern: org.apache.commons.io.FileUtils.forceDeleteOnExit($UNTRUSTED) - - pattern: org.apache.commons.io.FileUtils.forceMkDir($UNTRUSTED) - - pattern: org.apache.commons.io.FileUtils.forceMkDirParent($UNTRUSTED) - - pattern: org.apache.commons.io.FileUtils.iterateFiles($UNTRUSTED, ...) - - pattern: org.apache.commons.io.FileUtils.iterateFilesAndDirs($UNTRUSTED, ...) - - pattern: org.apache.commons.io.FileUtils.listFiles($UNTRUSTED, ...) - - pattern: org.apache.commons.io.FileUtils.listFilesAndDirs($UNTRUSTED, ...) - - pattern: org.apache.commons.io.FileUtils.moveFile(..., $UNTRUSTED, ...) - - pattern: org.apache.commons.io.FileUtils.moveToDirectory(..., $UNTRUSTED, ...) - - pattern: org.apache.commons.io.FileUtils.newOutputStream($UNTRUSTED, ...) - - pattern: org.apache.commons.io.FileUtils.openOutputStream($UNTRUSTED, ...) - - pattern: org.apache.commons.io.FileUtils.openInputStream($UNTRUSTED, ...) - - pattern: org.apache.commons.io.FileUtils.readFileToByteArray($UNTRUSTED, ...) - - pattern: org.apache.commons.io.FileUtils.readFileToString($UNTRUSTED, ...) - - pattern: org.apache.commons.io.FileUtils.readLines($UNTRUSTED, ...) - - pattern: org.apache.commons.io.FileUtils.streamFiles($UNTRUSTED, ...) - - pattern: org.apache.commons.io.FileUtils.touch($UNTRUSTED, ...) - - pattern: org.apache.commons.io.FileUtils.write($UNTRUSTED, ...) - - pattern: org.apache.commons.io.FileUtils.writeByteArrayToFile($UNTRUSTED, ...) - - pattern: org.apache.commons.io.FileUtils.writeLines($UNTRUSTED, ...) - - pattern: org.apache.commons.io.FileUtils.writeStringToFile($UNTRUSTED, ...) + - pattern: org.apache.commons.io.FileUtils.cleanDirectory(..., $*UNTRUSTED, ...) + - pattern: org.apache.commons.io.FileUtils.copyDirectory(..., $*UNTRUSTED, ...) + - pattern: org.apache.commons.io.FileUtils.copyFile(..., $*UNTRUSTED, ...) + - pattern: org.apache.commons.io.FileUtils.copyFileToDirectory(..., $*UNTRUSTED, ...) + - pattern: org.apache.commons.io.FileUtils.delete($*UNTRUSTED) + - pattern: org.apache.commons.io.FileUtils.deleteDirectory($*UNTRUSTED) + - pattern: org.apache.commons.io.FileUtils.deleteQuietly($*UNTRUSTED) + - pattern: org.apache.commons.io.FileUtils.forceDelete($*UNTRUSTED) + - pattern: org.apache.commons.io.FileUtils.forceDeleteOnExit($*UNTRUSTED) + - pattern: org.apache.commons.io.FileUtils.forceMkDir($*UNTRUSTED) + - pattern: org.apache.commons.io.FileUtils.forceMkDirParent($*UNTRUSTED) + - pattern: org.apache.commons.io.FileUtils.iterateFiles($*UNTRUSTED, ...) + - pattern: org.apache.commons.io.FileUtils.iterateFilesAndDirs($*UNTRUSTED, ...) + - pattern: org.apache.commons.io.FileUtils.listFiles($*UNTRUSTED, ...) + - pattern: org.apache.commons.io.FileUtils.listFilesAndDirs($*UNTRUSTED, ...) + - pattern: org.apache.commons.io.FileUtils.moveFile(..., $*UNTRUSTED, ...) + - pattern: org.apache.commons.io.FileUtils.moveToDirectory(..., $*UNTRUSTED, ...) + - pattern: org.apache.commons.io.FileUtils.newOutputStream($*UNTRUSTED, ...) + - pattern: org.apache.commons.io.FileUtils.openOutputStream($*UNTRUSTED, ...) + - pattern: org.apache.commons.io.FileUtils.openInputStream($*UNTRUSTED, ...) + - pattern: org.apache.commons.io.FileUtils.readFileToByteArray($*UNTRUSTED, ...) + - pattern: org.apache.commons.io.FileUtils.readFileToString($*UNTRUSTED, ...) + - pattern: org.apache.commons.io.FileUtils.readLines($*UNTRUSTED, ...) + - pattern: org.apache.commons.io.FileUtils.streamFiles($*UNTRUSTED, ...) + - pattern: org.apache.commons.io.FileUtils.touch($*UNTRUSTED, ...) + - pattern: org.apache.commons.io.FileUtils.write($*UNTRUSTED, ...) + - pattern: org.apache.commons.io.FileUtils.writeByteArrayToFile($*UNTRUSTED, ...) + - pattern: org.apache.commons.io.FileUtils.writeLines($*UNTRUSTED, ...) + - pattern: org.apache.commons.io.FileUtils.writeStringToFile($*UNTRUSTED, ...) - - pattern: new org.springframework.core.io.ClassPathResource($UNTRUSTED, ...) - - pattern: org.springframework.util.ResourceUtils.getFile($UNTRUSTED, ...) - - pattern: org.springframework.util.FileSystemUtils.$FILE_SYSTEM_UTILS_METHOD(..., $UNTRUSTED, ...) - - pattern: org.springframework.util.FileCopyUtils.$FILE_COPY_UTILS_METHOD(..., $UNTRUSTED, ...) - - pattern: new org.springframework.core.io.FileSystemResource($UNTRUSTED) + - pattern: new org.springframework.core.io.ClassPathResource($*UNTRUSTED, ...) + - pattern: org.springframework.util.ResourceUtils.getFile($*UNTRUSTED, ...) + - pattern: org.springframework.util.FileSystemUtils.$FILE_SYSTEM_UTILS_METHOD(..., $*UNTRUSTED, ...) + - pattern: org.springframework.util.FileCopyUtils.$FILE_COPY_UTILS_METHOD(..., $*UNTRUSTED, ...) + - pattern: new org.springframework.core.io.FileSystemResource($*UNTRUSTED) - - pattern: new javax.xml.transform.StreamSource($UNTRUSTED, ...) - - pattern: new javax.activation.FileDataSource($UNTRUSTED, ...) + - pattern: new javax.xml.transform.StreamSource($*UNTRUSTED, ...) + - pattern: new javax.activation.FileDataSource($*UNTRUSTED, ...) - patterns: - pattern-either: - - pattern: (Class $C).$CLASS_FUNC($UNTRUSTED) - - pattern: (ClassLoader $CL).$CLASS_FUNC($UNTRUSTED) + - pattern: (Class $C).$CLASS_FUNC($*UNTRUSTED) + - pattern: (ClassLoader $CL).$CLASS_FUNC($*UNTRUSTED) - 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 8154c07b90..c6c1cb2ba2 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 a3be434de3..8faaa9cd8e 100644 --- a/rules/ruleset/java/lib/spring/spring-response-injection-sinks.yaml +++ b/rules/ruleset/java/lib/spring/spring-response-injection-sinks.yaml @@ -31,28 +31,28 @@ rules: @$ANNOTATION(...) String $METHOD(...) { ... - return $UNTRUSTED*; + return $*UNTRUSTED; ... } - pattern: | @$ANNOTATION(...) byte[] $METHOD(...) { ... - return $UNTRUSTED*; + return $*UNTRUSTED; ... } - pattern: | @$ANNOTATION(...) ResponseEntity $METHOD(...) { ... - return $UNTRUSTED*; + return $*UNTRUSTED; ... } - pattern: | @$ANNOTATION(...) ResponseEntity $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 58f3fda1c9..ecfac5c0a1 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,6 +483,6 @@ 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 057484256a..a686c2744c 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 47d1b3c627..eb3614a241 100644 --- a/rules/ruleset/java/lib/spring/untrusted-path-source.yaml +++ b/rules/ruleset/java/lib/spring/untrusted-path-source.yaml @@ -51,18 +51,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: @@ -82,8 +82,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 c08ec84517..9b5747980b 100644 --- a/rules/ruleset/java/lib/spring/unvalidated-redirect-sinks.yaml +++ b/rules/ruleset/java/lib/spring/unvalidated-redirect-sinks.yaml @@ -18,7 +18,7 @@ rules: @$ANNOTATION(...) $RETURNTYPE $METHOD(...) { ... - return "$REDIRECT" + $UNTRUSTED*; + return "$REDIRECT" + $*UNTRUSTED; ... } From 1476b11f82fa8cb4a7a545d60ed79cccc7eefc64 Mon Sep 17 00:00:00 2001 From: Aleksandr Misonizhnik Date: Wed, 29 Jul 2026 15:54:41 +0200 Subject: [PATCH 07/14] 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 aa7d3ab285..cb4965d240 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 48310bc6f5..598088c6d0 100644 --- a/rules/ruleset/java/security/crlf-injection.yaml +++ b/rules/ruleset/java/security/crlf-injection.yaml @@ -71,8 +71,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: @@ -100,8 +100,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 a22eb68636..3fa3b17bef 100644 --- a/rules/test/rule-test.yaml +++ b/rules/test/rule-test.yaml @@ -55,6 +55,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 25acaaf248..5c95f10f5e 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 840021bd7573f883afc4fe6acfdd01dd768349b6 Mon Sep 17 00:00:00 2001 From: Aleksandr Misonizhnik Date: Wed, 29 Jul 2026 15:54:53 +0200 Subject: [PATCH 08/14] fix(rules): sanitize request URI redirect targets --- .../servlet-unvalidated-redirect-sinks.yaml | 5 +- .../spring/unvalidated-redirect-sinks.yaml | 56 +++++++++++-------- rules/test/rule-test.yaml | 2 + .../UnvalidatedRedirectServletSamples.java | 13 +++++ .../UnvalidatedRedirectSpringSamples.java | 8 +++ 5 files changed, 60 insertions(+), 24 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 d3d713b9bf..758311deac 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: $UNTRUSTED pattern-sanitizers: - patterns: - - pattern: $UNTRUSTED = (HttpServletRequest $REQ).getContextPath(); + - pattern: $*URI = (HttpServletRequest $REQ).getRequestURI(); + - focus-metavariable: $URI + - patterns: + - pattern: $*UNTRUSTED = (HttpServletRequest $REQ).getContextPath(); - focus-metavariable: $UNTRUSTED diff --git a/rules/ruleset/java/lib/spring/unvalidated-redirect-sinks.yaml b/rules/ruleset/java/lib/spring/unvalidated-redirect-sinks.yaml index 9b5747980b..2fac9ce9db 100644 --- a/rules/ruleset/java/lib/spring/unvalidated-redirect-sinks.yaml +++ b/rules/ruleset/java/lib/spring/unvalidated-redirect-sinks.yaml @@ -10,29 +10,39 @@ 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($UNTRUSTED); - - pattern: new ModelAndView("redirect:" + $UNTRUSTED); + mode: taint + pattern-sanitizers: - patterns: - - pattern: | - @$ANNOTATION(...) - $RETURNTYPE $METHOD(...) { - ... - return "$REDIRECT" + $*UNTRUSTED; - ... - } + - pattern: $*URI = (HttpServletRequest $REQ).getRequestURI(); + - focus-metavariable: $URI + pattern-sinks: + - patterns: + - pattern: new RedirectView($UNTRUSTED); + - focus-metavariable: $UNTRUSTED + - patterns: + - pattern: new ModelAndView("redirect:" + $UNTRUSTED); + - focus-metavariable: $UNTRUSTED + - patterns: + - pattern: | + @$ANNOTATION(...) + $RETURNTYPE $METHOD(...) { + ... + return "$REDIRECT" + $*UNTRUSTED; + ... + } - - 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: $UNTRUSTED diff --git a/rules/test/rule-test.yaml b/rules/test/rule-test.yaml index 3fa3b17bef..7bd3d5088e 100644 --- a/rules/test/rule-test.yaml +++ b/rules/test/rule-test.yaml @@ -505,7 +505,9 @@ tests: negative: - security.tagunification.RedirectTagEdgeSamples$SpringParameterToServletResponse#springParamToConstantServletRedirect - security.unvalidatedredirect.UnvalidatedRedirectServletSamples$SafeContextPathRedirectServlet#doGet + - security.unvalidatedredirect.UnvalidatedRedirectServletSamples$SafeRequestUriRedirectServlet#doGet - 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 74c77ab33f..ee5f91ce2c 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 ea8233cb17..8f8b09d472 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 9482984fd8cb68085a69569e3ad778e9f590dc54 Mon Sep 17 00:00:00 2001 From: Aleksandr Misonizhnik Date: Wed, 29 Jul 2026 15:54:58 +0200 Subject: [PATCH 09/14] 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 ecfac5c0a1..60215544fb 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 7bd3d5088e..339ebf2d4f 100644 --- a/rules/test/rule-test.yaml +++ b/rules/test/rule-test.yaml @@ -602,6 +602,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 b71b51115b..bb9b510de3 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 ae6bb3954a2050f282002438df7b646d8749193b Mon Sep 17 00:00:00 2001 From: Aleksandr Misonizhnik Date: Wed, 29 Jul 2026 17:35:44 +0200 Subject: [PATCH 10/14] 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 a772972c2a..ec23e387b8 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 339ebf2d4f..14fc18a93b 100644 --- a/rules/test/rule-test.yaml +++ b/rules/test/rule-test.yaml @@ -247,8 +247,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 0a084d0b13..7c1559ff97 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 ca7051496c67f2b381a38d26d64ad5e494ae3626 Mon Sep 17 00:00:00 2001 From: Aleksandr Misonizhnik Date: Thu, 13 Aug 2026 12:27:46 +0200 Subject: [PATCH 11/14] 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 2603af29d8..1ab7b7a079 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 3d8aaa54eacfd7ac15bf43ac66c07d40e4df9b47 Mon Sep 17 00:00:00 2001 From: Aleksandr Misonizhnik Date: Wed, 19 Aug 2026 22:18:37 +0000 Subject: [PATCH 12/14] 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. --- 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 5a80631a65..56b342a544 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 60215544fb..94a134e12c 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: From d57606de364e51bb0e292528cc167d650e958366 Mon Sep 17 00:00:00 2001 From: Aleksandr Misonizhnik Date: Mon, 24 Aug 2026 04:52:31 +0200 Subject: [PATCH 13/14] fix(rules): replace arrayToCommaDelimitedString propagator with toString() The per-method propagator worked around a broken config passthrough that carried element field structure (e.g. File#path) into a String result. Replace with a generic toString() propagator: the dataflow approximation for arrayToCommaDelimitedString now models the actual iteration + toString() call, so the propagator just needs to keep taint alive through toString(). --- rules/ruleset/java/lib/generic/path-traversal-sinks.yaml | 9 ++------- .../java/lib/spring/spring-xss-html-response-sinks.yaml | 8 ++------ 2 files changed, 4 insertions(+), 13 deletions(-) diff --git a/rules/ruleset/java/lib/generic/path-traversal-sinks.yaml b/rules/ruleset/java/lib/generic/path-traversal-sinks.yaml index 56b342a544..29c0e62968 100644 --- a/rules/ruleset/java/lib/generic/path-traversal-sinks.yaml +++ b/rules/ruleset/java/lib/generic/path-traversal-sinks.yaml @@ -13,13 +13,8 @@ rules: - 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 + - pattern: $RES = (Object $*OBJ).toString(); + from: $OBJ to: $RES pattern-sanitizers: - pattern: org.apache.commons.io.FilenameUtils.getName(...) 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 94a134e12c..7db5161a79 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 @@ -13,13 +13,9 @@ rules: - 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 + - pattern: $RES = (Object $*OBJ).toString(); + from: $OBJ to: $RES - pattern-sanitizers: - patterns: - pattern-either: From 2b1261c3518d6e93264048ad4af555b62d0c975d Mon Sep 17 00:00:00 2001 From: Aleksandr Misonizhnik Date: Thu, 27 Aug 2026 18:19:12 +0200 Subject: [PATCH 14/14] fix(rules): drop the stale note on bad-hexa-conversion The note said that the star gives the element taint through a routing at the use site. That is not how it works. The star gives the taint at the source, and the engine keeps it at the primitive element read. --- rules/ruleset/java/security/crypto.yaml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/rules/ruleset/java/security/crypto.yaml b/rules/ruleset/java/security/crypto.yaml index 0894d6e04f..ffd1dbde2b 100644 --- a/rules/ruleset/java/security/crypto.yaml +++ b/rules/ruleset/java/security/crypto.yaml @@ -878,10 +878,6 @@ 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(...) { ...