Skip to main content
Blog

Field notes

Where Spring SAST findings go to die: sanitizers, config, and framework context

Three finding patterns that survive a scanner and die on evidence — a validated binder, a config-gated sink, and a test-only path — and what it takes to close each one.

ICTX Team7 min read

Spring Boot queues have a particular texture. The findings are not random — the same three or four shapes come back on every scan of every service, and each one is a place where the scanner is looking at the code and the answer lives somewhere else: in an annotation, in a YAML file, in a source set.

Below are three patterns, anonymised from real triage, with the evidence that actually settles each. They are not exotic. Between them they account for a large share of what a Spring team spends its triage afternoons on.

Pattern 1 — injection through a validated binder

A controller takes a request object. A field of that object reaches a query or a command. The rule sees a request-derived value flowing to a sink, reports CWE-89, and it is right about the flow.

FINDING  CWE-89  jdbc-sqli
  src/main/java/com/example/report/ReportController.java:52

  @PostMapping("/reports")
  ReportView export(@Valid @RequestBody ReportRequest req) {
      return service.export(req.getSortColumn());     // ← flagged
  }

What the rule cannot see is what happened before the method body ran. Spring binds the body, and the @Valid annotation makes the binder run Bean Validation constraints before dispatch. If the field carries a pattern constraint, the value that reaches the sink is already restricted to a fixed alphabet — and if validation fails, the method is never entered at all.

EVIDENCE
  binder      @Valid on parameter 0        ReportController.java:52
  constraint  @Pattern("^[a-z_]{1,32}$")   ReportRequest.java:31
  binding     failure → 400 before body    (MethodArgumentNotValidException)
  sink class  SQL identifier position      ReportService.java:88

  VERDICT   close — value constrained to ^[a-z_]{1,32}$ at ReportRequest.java:31
  ASSUMPTION  the constraint stays on the field; removal reopens this finding

Two details decide whether this close is legitimate, and both are easy to get wrong.

First, the annotation has to actually be enforced. @Valid on the controller parameter triggers validation on binding; a constraint annotation sitting on a field of an object that nothing validates does nothing at all, and it looks identical in a diff. The evidence has to name the enforcement point, not just the constraint.

Second, the constraint has to be sufficient for that sink. A pattern that permits only lowercase letters and underscores is decisive for an identifier interpolated into a query. The same pattern would be worthless if the value were being written into an HTML attribute, and a length-only constraint is worthless for both. Sanitization is a pairing with a sink class, never a boolean.

Where this pattern goes the other way: a validated field whose constraint is @NotBlank, or a group-scoped constraint that does not apply on this path. Then the flow is real, the finding is promoted, and the evidence says which constraint was found and why it does not cover the sink.

Pattern 2 — the config-gated sink

A dangerous call exists in the code, and it is unambiguously dangerous. It is also behind a property that is off everywhere the service is deployed.

FINDING  CWE-94  expression-injection
  src/main/java/com/example/admin/ScriptEndpoint.java:37

  @ConditionalOnProperty("app.admin.scripting.enabled")
  @Bean ScriptEndpoint scriptEndpoint(...) { ... }

The scanner reads Java. The answer is in the property sources. Spring Boot’s externalized configuration resolves properties from a documented precedence chain — defaults, profile files, environment variables, command line — and @ConditionalOnProperty decides whether the bean is registered at all. If the property is false in the default and in every shipped profile, the endpoint does not exist in the running application.

EVIDENCE
  gate        @ConditionalOnProperty("app.admin.scripting.enabled")
  default     app.admin.scripting.enabled=false   application.yml:14
  profiles    prod / staging / default            (no override found)
  override    ENV APP_ADMIN_SCRIPTING_ENABLED     not set in indexed manifests

  VERDICT   open — policy queue, not closed
  MISSING   deploy-time property sources are outside the repository

This one does not close, and the reason is worth being pedantic about. The repository is not the deployment. A property can be set by an environment variable in a platform config we never see, by a config server, by a secret manager, by someone’s Helm values file in another repo. What the evidence establishes is “not enabled in any configuration present here,” which is a genuinely useful thing to know and is not the same claim as “not enabled.”

So it goes to a policy queue with the gate and the resolved defaults attached. Someone who knows the deployment answers it in thirty seconds. That is a much smaller job than the one the raw finding presented, and it is honest about who holds the missing fact. A tool that closes this on the YAML default alone is guessing about infrastructure it cannot see.

Pattern 3 — the test-only path

Hardcoded credentials in a fixture. A permissive TLS setting in an integration test’s configuration. A command built from a string in a test utility. Scanners flag all of these at production severity because severity describes the weakness class, not the instance.

FINDING  CWE-798  hardcoded-credentials
  src/test/java/com/example/it/AuthFixture.java:22

EVIDENCE
  source set        src/test/java        (Maven testSourceDirectory)
  packaging         excluded from jar    (no test classes in build output)
  callers           3, all in src/test   (no main-source reference)
  test scope only   junit, testcontainers on the dependency path

  VERDICT   close — test-only source set, no main-source caller
  ASSUMPTION  no build profile packages test sources into a shipped artifact

The path-based version of this — “it is under src/test” — is right most of the time and wrong in the cases that matter. Shared test utilities get promoted into a main source set. A module publishes a test-jar that a downstream service depends on at runtime. A fixture credential is the same string as a deployed default because someone copied it.

Which is why the evidence lists callers rather than only the directory. The decisive signal is that nothing outside the test source set references it, and it is checkable: the caller list is right there. If a main-source caller appears, the same rule produces a promotion instead of a close, on the same evidence.

What these three have in common

In every case the scanner was correct about the code and incomplete about the system. The deciding fact was an annotation the framework acts on, a property resolved outside the class, or a build-level boundary. None of it is exotic — it is the ordinary machinery of a Spring application, and it is exactly the context a rule that ships to everybody cannot contain.

Which is also why we work in Java and Spring first. The framework is heavily annotation-driven and its configuration model is documented and resolvable, so the deciding facts are extractable rather than guessable.

Limitations

  • JavaScript and TypeScript are not indexed. Plainly: if your Spring service has a front end, or your platform is Node-based, we produce no deep evidence for that code today. Findings there are not closed on weak signals; they stay open and the limitation is stated on the run. If your stack is mostly JS or TS, this tool is not useful to you yet.
  • Configuration outside the repository is invisible. Pattern 2 is the general case. Anything decided by a platform, a config server, or a deploy pipeline in another repository can only ever produce an open finding with the gate stated — never a close.
  • Custom validation is not automatically credited. A hand-written validator that does the right thing is not recognised as a sanitizer unless it is modelled. That produces promotions on safe code — a cost we prefer to the alternative, but a real cost, and the reason the promoted queue needs review rather than automatic action.
  • Runtime rewiring defeats all three patterns. Bean definitions registered programmatically, aspects that alter the call chain, or reflective wiring can invalidate the static picture. Where we detect it, the finding stays open.

The version of this that scales

A Spring engineer can settle any one of these findings by hand in a few minutes. The problem is that a scan produces hundreds and the reasoning is never written down, so the next scan starts from zero and the next engineer reaches a different conclusion.

What changes that is making the evidence the output — the constraint and its enforcement point, the resolved property and its precedence chain, the caller list and the source set — so a close is a record somebody can check rather than a status somebody set. How it works walks through the extraction, and the directory lists the repositories we have published runs for, each with its own limitations next to its numbers.

Written by ICTX Team

The two people building ICTX, a local triage layer that reads your scanner's SARIF and attaches the code evidence behind each verdict.

We publish the runs we make decisions from, limitations first, and we answer disputes in public.

Dispute this

Think a verdict here is wrong, or a claim is unsupported? Send the finding and the reason to hello@ictx.sh. We answer in public, credit you when you are right, and correct the post rather than quietly editing it.