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.
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 findingTwo 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 repositoryThis 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.