There’s a pattern in security tooling that I’ve seen across multiple roles, multiple products, and multiple organizations. The team buys the SAST tool. The first scan runs. The report comes back with somewhere between 200 and 2,000 findings. Somebody, usually me or somebody like me, sits down to triage them.
The first triage takes a long time. The second triage is shorter. By the tenth triage, you start to recognize the patterns, the same five rules that produce the same five categories of finding, the same false-positive shapes, the same handful of real issues mixed in.
I haven’t counted exactly, but I’ve sat through somewhere around 200 SAST scan triages over the past decade. I want to share what I actually found in those scans. Not what the marketing decks say SAST tools find. What the scans, in production codebases, with real engineering teams, were actually telling us.
This is the working engineer’s version, and it’s substantially less dramatic than the vendor’s pitch.
The 95-percent observation
The number that’s stuck in my head is 95. About 95% of the findings, across the scans I’ve worked on, were one of three things:
The same handful of false positives, repeated across the codebase, caused by a framework or library pattern the scanner didn’t understand.
Real findings of low operational severity, usually in code that wasn’t reachable from external traffic or didn’t handle sensitive data.
Real findings of medium severity in test code, sample code, or example configurations that weren’t part of the production deployment.
The remaining 5% is where the actual security work lived. The high-severity, externally reachable, sensitive-data-handling findings that genuinely needed to be fixed before the next release.
This isn’t a complaint about SAST tools. They’re doing the work they’re supposed to do: catching patterns. The patterns are real. What the tools can’t do, and what most teams underweight, is decide which patterns matter for their specific application.
The false positives that ate the most time
By volume, the false-positive category that ate the most of my time was framework-pattern blindness.
The scanner doesn’t understand your DAO wrapper. Your DAO wrapper does parameter binding correctly, but the scanner can’t trace through the wrapper to see that the binding happened. So every database call gets flagged for SQL injection, even though the binding pattern is correct.
The scanner doesn’t understand your authentication filter. The filter validates and sanitizes the request before it reaches the controller. But the scanner sees the controller method receiving raw request data and doesn’t realize the filter has already cleaned it.
The scanner doesn’t understand your templating engine. The template encodes output before it hits the browser, but the scanner sees the unescaped value being passed to the template and flags it as XSS.
These aren’t bugs in the scanner. They’re the unavoidable result of the scanner not having a complete model of your codebase. Static analysis is approximation; the approximation has gaps.
The remediation is custom rules. You write a suppression rule that tells the scanner “this method does sanitization.” You spend half a day. You eliminate 200 findings. You ship the rule. The next scan is cleaner.
The teams that don’t do this are the teams where the security program eventually collapses. The signal-to-noise ratio destroys credibility. Developers stop reading the reports. Findings accumulate. By the time somebody tries to revive the program, the backlog is so large that nobody can tell what’s real anymore.
The tuning that paid off most for me wasn’t a suppression rule at all — it was cadence. When I first brought SAST into a CI/CD pipeline, the scan ran the way scans usually start out: periodically, against the whole codebase, producing a report that went to a list. The report was enormous, most of it was old code nobody was touching, and it arrived days or weeks after the commits it described. Developers treated it as weather. The change was to move scanning into the flow of work: scan on the pull request, report only what the change introduced, and gate on new findings rather than the historical backlog. The legacy findings didn’t disappear — they became a baseline to burn down deliberately, on its own schedule — but a developer now saw two findings on the code they wrote this morning instead of eight hundred findings on a decade of history. Two findings on your own fresh code is something you just fix. Eight hundred on everyone’s old code is something you ignore. Same tool, same rules, same codebase; the finding count that mattered dropped from “unreadable” to “actionable” the day the cadence changed.
The findings that were real but didn’t matter
Most teams don’t have a way of expressing “this finding is real but not important.” They have severity (which the tool sets) and status (open, fixed, won’t-fix). What they don’t have is operational context.
A SQL injection finding in a sample script that’s used to seed test databases is real. It’s not important. The script runs in dev, with synthetic data, on credentials that have no production access. Fixing it is fine. Treating it as a release blocker is silly.
A hardcoded secret finding in a unit test that uses "test-password" to instantiate a test fixture is real. It’s not important. The string isn’t a secret in any operational sense. Fixing it is busywork.
A path traversal finding in an admin tool that’s only run by developers on their laptops is real. It’s not important. The threat model doesn’t include the developer’s own machine.
The teams that don’t develop a vocabulary for “real but not important” end up doing one of two things. They fix everything, which is expensive and slow and produces resentment. Or they ignore everything, which is the same outcome with a different feel.
The functional version is risk acceptance with documented compensating controls. The finding is acknowledged. The reason it’s not being fixed is captured. The compensating control (test code, isolated environment, internal-only access) is documented. A re-evaluation date is set. The finding is closed with documentation, not with a “fixed” status that pretends it was fixed.
The 5% that actually mattered
The findings that earned their keep were usually concentrated in a small number of files, in a small number of patterns. The categories that stand out:
Authorization gaps. The most common high-severity finding category. A controller method that should check whether the requesting user has access to the resource it’s returning, and doesn’t. SAST tools catch the simpler cases (no access check at all). The harder cases (an access check that’s wrong, or right for some calls but not others) usually require code review.
Direct object references. Usually paired with authorization gaps. The endpoint takes a resource ID from the request and uses it to look up the resource. If the ID can be enumerated and the access check is missing or weak, any authenticated user can access any resource by changing the ID.
Cryptographic missteps. Hardcoded keys. Weak hashing for passwords. The wrong random number generator for security tokens. These are the findings where the code is simply wrong, and the fix is mechanical (replace with the correct primitive). High severity, low effort to fix, the most satisfying category to triage.
Injection vulnerabilities in the obvious patterns. SQL injection where the query is concatenated, command injection in a system call, LDAP injection in a search filter. The textbook examples that the textbooks describe.
Information disclosure in error handlers. A 500 response that returns the stack trace, including a query, including a parameter value. The error doesn’t get exploited directly, but the information accelerates other attacks.
These five categories accounted for most of the high-severity findings I’d actually want a release blocked for. There are other categories, of course, but they were less common in the codebases I worked on, and the prioritization I’d give to a team with limited triage time would start with these.
The finding I remember most wasn’t remarkable for what it was — it was remarkable for where it had been hiding, and for who found it. A production database connection string, credentials included, sitting in an application.properties file. Hardcoded-secret findings are usually the noisiest of the noisy categories: test passwords, dummy keys in fixtures, sample configs. This one was real — production datasource credentials, in a repository readable by every developer and every CI job, committed years earlier and surviving untouched. And no scan had ever flagged it, because the scan configuration excluded properties files, the way many do by default — they’re “configuration, not code,” and including them multiplies the noise. Every scan of that codebase had come back clean on secrets, and every scan was technically right about the files it had examined. I found it the unglamorous way: I was in the properties file for an ordinary reason, saw the connection string, and realized what I was looking at. I reported it to the other developers, and the reaction confirmed the pattern — nobody was surprised that the string existed; everybody was surprised it counted. It had been visible for years to anyone who opened the file. Visible isn’t the same as seen. The fix was mechanical — rotate the credentials, externalize the values out of source control into managed secrets. The expensive part was the question the fix forced: what else could those credentials have reached, for how many years, for how wide an audience? Nobody could answer precisely, which is its own lesson. The finding was high severity; the scope exclusion that kept it off every report was higher.
What the scans don’t catch
This is the harder lesson. The 95% you can ignore. The 5% you should fix. The category I’d add: the 0% the scan doesn’t see at all.
SAST doesn’t catch business logic flaws. The discount code that can be applied multiple times. The privilege escalation that happens through a sequence of legitimate API calls. The race condition in the order processing flow. The weakness in the password reset mechanism that lets an attacker take over an account through a sequence of innocuous-looking steps.
SAST doesn’t catch architectural flaws. The microservice that should be authenticating but isn’t because somebody assumed internal traffic was trusted. The data flow that exposes PHI to a logging system that wasn’t designed to hold PHI. The single point of failure that could be mitigated by a different topology.
SAST doesn’t catch the things that depend on context the scanner doesn’t have. The SSRF that’s only exploitable if the application is deployed in a specific cloud environment. The XXE that’s only triggered by a specific request shape that’s not in the scanner’s test patterns. The deserialization vulnerability that depends on what classes are on the classpath at runtime.
The scans I’ve worked through always missed these categories. They’re not bugs in the tool. They’re outside what the tool can do.
The case that taught me this hardest wasn’t found by a pen test — it was found by a launch. A product that had run cleanly in the US was being prepared for a European rollout, and the pre-launch compliance review turned the logs into a problem. The application logged fields that were unremarkable under the US definition of protected health information; under the European rules, some of those same fields were regulated data, and the moment the product went live there they would be flowing into logging infrastructure that was never designed or authorized to hold them. Every scan of that codebase had been clean, and every scan was right, by its own lights: the code wrote the same fields to the same logs it always had. The scanner had no concept of jurisdiction. What data is “sensitive” isn’t a property of the code; it’s a property of where the code runs and which law applies, and that property was about to change while the code stayed identical. We scrambled a dedicated release to strip the logging for the European deployment before go-live. The scanner never disagreed with us before or after — it had no opinion, because the defect never existed at the layer static analysis can see.
The single most useful tuning
If I had to pick one tuning that produced the most value across the scans I worked on, it’s the one above: cadence and scope, not rules. Scanning at the pull request, on the delta, with the gate on new code only. It took days to wire up, not months. It saved more cumulative triage time than every suppression rule I’ve written combined, because it stopped the noise from being generated as a single overwhelming batch in the first place.
That’s not a generalizable answer. The specific answer depends on the specific codebase. But the pattern is generalizable: the tuning that pays back the most is the rule that addresses your codebase’s most-used pattern that the scanner doesn’t understand.
The way to find it: look at the top five rules by finding count after the first scan. Sample from each. Identify which ones are systematically false-positive due to a pattern the scanner can’t trace. Write the suppression rule. Re-scan. Reassess.
This is the work most teams skip because it doesn’t feel like security work. It feels like tooling work. It is tooling work. It’s also the work that determines whether the security program produces signal or noise for the next several years.
What I’d tell the version of me from ten years ago
If I could send a memo back to the version of me who was triaging his first SAST report:
Spend more time on tuning, less on individual findings. The first time through, your instinct will be to fix everything the scanner reports. Don’t. Categorize first. Tune second. Fix third.
Operational severity matters more than technical severity. CVSS doesn’t know your application. You do. Two findings with the same CVSS score can have wildly different real risk. Re-rank against your own context.
Most findings are noise. The signal is in a small fraction. Get good at filtering quickly so you can spend time on the part that matters.
Don’t trust the scan as the ceiling of security. SAST is a baseline, not a guarantee. The high-severity findings the scanner doesn’t see are where most real-world breaches actually originate.
The program is more important than the tool. A medium-quality SAST tool with disciplined triage produces better outcomes than a high-quality tool with chaotic triage. Invest in the workflow.
But if I could only send back one line, it would be this: the scanner answers exactly the questions you ask it, and every surprise of my career came from a question I didn’t ask. The scan that ran on a schedule instead of on the change — nobody had asked “when would a developer actually act on this?” The production credentials sitting in a properties file for years — nobody had asked “what did we exclude from scope, and what lives there?” The logs that would have become a violation the day the product crossed a border — nobody had asked “sensitive according to whom?” None of those were tool failures. The tool did what it was configured to do, every time. The failures were in the configuration of our attention. So the advice isn’t “trust the tool less.” It’s: every time the scan comes back clean, spend ten minutes asking what the scan didn’t look at, couldn’t see, or wasn’t told mattered. The findings report tells you what the tool knows. The exclusion list, the scan cadence, and the deployment map tell you what it doesn’t. I spent my first years reading the former. The incidents all came from the latter.
After 200 scans, the thing I’m most certain of is that SAST is a useful part of a security program, not the program itself. The 95% noise rate is not the tool’s fault; it’s a structural property of static analysis. The 5% signal is real and valuable. The job is to know which is which, fast enough that you can focus on the part that matters.
That skill takes time to develop. The first dozen scans, you’ll spend too long on individual findings. The next dozen, you’ll start to see patterns. By the time you’ve worked through 50, you’ll be triaging in hours what used to take weeks.
The tool isn’t the variable. The judgment is.