The Validator That Only Guards One Door
Northwind Payroll runs a Java 11 Spring Boot monolith (about 850,000 lines) with 12 delivery teams. A nightly CxSAST scan on the Checkmarx Default preset reports 37 Command Injection flows in the reporting module. A typical path starts at a REST parameter reportName in ReportController, passes through ReportService.export(), and ends in CommandRunner.run(String), which builds a ProcessBuilder. The controller calls CommandValidator.isSafe(), which rejects ; & | and backticks, but the same service is also invoked by a nightly Quartz job and an internal /admin/report endpoint that never call the validator. The team argues these are false positives because a validator exists. Security policy forbids Not Exploitable without a documented compensating control, and the release freeze is in five days. The security champion has two days of triage time.
1. The team wants all 37 results marked Not Exploitable. Is that defensible? Show how you would use the result path and call-graph evidence to decide, and what state you would set.
Read the worked answer
No, not as a blanket action. CxSAST attaches a path to every result; open each one in the portal or CxAudit and expand the nodes. Check whether CommandValidator appears on that path, whether it runs before the sink, and whether the value that reaches ProcessBuilder is the validated value. A validator that only logs, or runs after the command is built, changes nothing.
Then enumerate callers of ReportService.export() in the IDE. The Quartz job and /admin/report do not call the validator, so those flows remain tainted even if the REST path is clean. Group the 37 by entry point. The REST subset might be defensible as 'validated on all paths', but only if the validator is actually sufficient for a shell context. The character set (; & | and backticks) is incomplete for /bin/sh -c: $(), newlines, and ${IFS} still get through. So it is not a proven sanitizer even on the guarded path.
Set Confirmed for the unvalidated entry points and fix them. Keep the REST-only flows at To Verify until the validator is hardened or the code changes; only then mark Not Exploitable, category 'sanitized', with a one-line proof naming the function, the accepted character policy, and the test evidence.
2. Leadership offers three options: (a) add a custom sanitizer query for CommandValidator, (b) change the service to pass an argument list to ProcessBuilder without a shell, (c) raise the query threshold. Recommend and justify.
Read the worked answer
Option (b) is the durable fix. Passing an argument list to ProcessBuilder without invoking a shell removes the metacharacter problem: there is no shell to interpret ;, $(), or newlines, and command and arguments are separated by the API. If the change is made inside CommandRunner, it also fixes all three callers, not just the controller.
Option (a) can be legitimate, but only after the validator is proven complete and applied on every path. A sanitizer model is global to the query: any code anywhere that passes data through CommandValidator will be treated as safe. Modeling a denylist that rejects four characters would suppress real findings, including the scheduled-job flows. That is how a true positive becomes a permanent blind spot.
Option (c) changes reporting, not risk. Raising the threshold on a Command Injection query is hard to defend in an audit and does not affect the vulnerable call path. A reasonable combined plan: harden CommandRunner now, keep the 37 results Confirmed until the fix ships, and if the validator remains as defense-in-depth, model it later with tests proving rejection of shell metacharacters, including newline and $().
3. What preventive actions would you take so the same pattern does not return in the next release?
Read the worked answer
Prevent recurrence at the API and in the pipeline. Publish a secure coding rule: no Runtime.exec or shell invocation with concatenated strings; use the approved process wrapper, which takes a command plus an argument list. Add a custom query that flags direct ProcessBuilder or Runtime.exec calls outside the wrapper, so new violations show up as new results rather than relying on reviewer memory.
Add the rule to the code review checklist and to the IDE plugin so developers see it before commit. For the reporting module, add regression tests that pass hostile names (a;rm -rf /, $(id), a newline) and assert that the command is rejected or safely separated. Give the 12 teams a short session using their own results as examples, and track new direct-exec findings per sprint.
Finally, review the validator itself. If it stays, make it reject on an allowlist rather than a denylist, and centralize the call inside CommandRunner so no caller can bypass it. Consider a guard test that fails when a new caller of export() appears without the validation wrapper.