Static Analysis & Secrets Detection
Static analysis and secrets detection both live at the same spot in the pipeline — the commit — and a lot of DevSecOps overviews lump them into one "shift-left scanning" sentence. The CDP blueprint expects you to know why that's an oversimplification: a SAST engine reasons about what your code does, a secrets scanner reasons about what a string looks like, and the tuning strategy, the failure mode, and the correct response to a hit diverge sharply once you look past the shared timing. This chapter goes past the category overview in SAST, DAST & SCA and the program-level view in secrets management into what the exam actually grades: tuning a rule set so real findings survive contact with a real team, triaging a false positive without disabling the rule that would have caught the next real one, naming the exact tools the curriculum names — SpotBugs, TruffleHog, Bandit — and walking one finding all the way from a raw scanner alert to a merged fix.
Imagine two different checks on an essay you're about to turn in. One is a grammar checker — it reads every sentence, understands the rules of grammar, and flags "this sentence is broken" only when it actually is; sometimes it's wrong, and you argue with it. The other check doesn't read for grammar at all — it just scans the whole page for anything that looks like a real phone number or a real password, because if one is there, it's already a problem, no argument needed. You'd never "fix" a leaked phone number by rewriting the sentence around it — you'd call and get a new number. Static analysis is the grammar checker. Secrets detection is the phone-number scan. Same essay, same due date, completely different jobs.
Two gates, one commit — and why they're not the same job
☺ Like you're 10: Both checks happen the moment you hit save, but one is judging your logic and the other is judging your text — mixing them up is the single biggest trap in this chapter.
A SAST engine builds a model of your program — an abstract syntax tree, a control-flow graph, sometimes a full interprocedural data-flow graph — and asks whether a dangerous pattern of operations exists: does untrusted input reach a dangerous function without passing through anything that neutralizes it. A secrets scanner builds no model of your program at all. It treats every file as an undifferentiated stream of bytes and asks a completely different question: does this string's shape or randomness match what a real credential looks like. It has no concept of a function, a variable, or a call graph, and it doesn't need one — a leaked AWS key is exactly as dangerous sitting in a comment, a log fixture, or a README as it is sitting in application code.
That difference in what's being modeled cascades into everything downstream:
| Question | SAST finding | Secrets finding |
|---|---|---|
| What's actually being matched? | A pattern in program logic — a source reaching a sink | A pattern or entropy score in raw text, independent of code logic |
| Can it legitimately be "false"? | Yes — unreachable code, an already-validated input, a dead branch | Rarely, once verified — a live API check confirms the credential still works |
| Correct first response | A judgment call: fix now, ticket it, or document a risk-accept | Rotate the credential immediately — no judgment call to make |
| Does editing the file resolve it? | Yes, once the sink is provably safe | No — git history keeps the old value forever, editing does nothing |
| Where does the actual fix happen? | Inside the codebase, in the diff you're already reviewing | Outside the codebase, in whatever system issues that credential |
Keep that table in your head for the rest of this chapter — it's the single distinction the exam checks for most often, usually by handing you a scenario with both kinds of finding in the same pull request and grading whether you respond to each one correctly.
How a SAST engine actually finds a bug
☺ Like you're 10: The engine doesn't "read" your code like a person — it turns it into a map of every path data can travel, then checks whether any path runs from something untrusted straight into something dangerous.
Every SAST engine starts by parsing source (or bytecode) into a structured representation — an abstract syntax tree (AST) — and building a control-flow graph on top of it. Deeper engines go one step further and build a data-flow graph that tracks a specific value as it's assigned, passed as an argument, and reassigned across functions. Three vocabulary terms carry almost every rule you'll see on the exam:
- Source — where untrusted data enters the program: an HTTP request parameter, a form field, a message off a queue, a file read from user upload.
- Sink — a dangerous operation that shouldn't receive unsanitized input: a SQL
executecall,eval(), a shell command, a raw file-system path, an XML parser with external entities enabled. - Sanitizer — anything on the path between them that neutralizes the taint: a parameterized query binding, an allow-list validator, an output encoder. A source reaching a sink with a sanitizer in between is safe; the same source reaching the same sink with nothing in between is the finding.
The engines named across this course's tool pages implement that model at different depths, and the exam expects you to know which is which:
| Tool | Analysis basis | Scope | Best tuning lever |
|---|---|---|---|
| Semgrep | AST pattern matching, with lightweight dataflow in later rule tiers | Polyglot — one rule syntax across dozens of languages | Custom rules for your own internal APIs; baseline scans so only new findings gate |
| CodeQL | Compiles code into a queryable relational database; real interprocedural taint tracking | Deep, per-language query packs (Java, JS/TS, Python, C/C++, Go, ...) | Custom QL queries; inline suppression comments tied to a specific query ID |
| SonarQube | A bundled rule engine plus "Security Hotspots" that require human review | Broad multi-language, wrapped in a Quality Gate workflow | Quality Gate conditions scoped to the "New Code" period, not the whole repo |
| SpotBugs + FindSecBugs | Bytecode-level dataflow analysis (SpotBugs core) plus security-specific detectors (FindSecBugs plugin) | Java / JVM bytecode only | Confidence + priority filters; FindSecBugs detector include/exclude lists |
| Bandit | Direct AST walk of Python source — no bytecode, no compiled database | Python only | The confidence/severity matrix; inline # nosec <check-id> with a linked reason |
Notice that SpotBugs and FindSecBugs are not the same tool wearing two names — this is a common exam wrinkle. SpotBugs (the modern fork of the older FindBugs project) is a general-purpose bytecode analyzer that ships bug patterns for things like null-pointer dereferences, resource leaks, and bad equals/hashCode implementations — useful, but not security-focused. FindSecBugs is a plugin that layers a security-specific detector catalog on top of SpotBugs's existing dataflow engine — detector families like SQL_INJECTION_JDBC, XSS_SERVLET, COMMAND_INJECTION, HARD_CODE_PASSWORD, and WEAK_MESSAGE_DIGEST_MD5. Run SpotBugs alone in a Java pipeline and you get code-quality findings with no security detectors at all; the security value in the worked example below comes specifically from FindSecBugs riding on SpotBugs's engine, not from SpotBugs by itself.
Tuning rules and triaging false positives
☺ Like you're 10: A rule set that flags everything gets ignored, and a rule set that gets weakened every time it's wrong stops catching anything real — tuning is finding the narrow path between those two failures.
An out-of-the-box SAST rule set is written to be broadly applicable across every codebase that might install it, which means it's necessarily imprecise about any one codebase's actual patterns. Tuning is the exam-relevant skill of narrowing that rule set until its output is mostly signal, without silently deleting the coverage that would have caught tomorrow's real bug. Four techniques cover most of what shows up on the exam:
- Baselining, not blanket suppression. Run the scanner once against the existing codebase, accept the current findings as a starting baseline, and gate the pipeline only on new findings introduced by a change — Semgrep's
--baseline-commitflag and SonarQube's "New Code" period both implement exactly this. A 3,000-line legacy repo with 400 pre-existing findings shouldn't block every future PR until all 400 are cleared; it should stop growing that number on day one. - Confidence and severity thresholds, tuned per rule category. Bandit reports both a severity and a confidence independently; Semgrep rules carry a
confidencemetadata field; SonarQube's Quality Gate conditions are configured per-severity. Gating merge-blocking onHIGHconfidence while routingMEDIUMandLOWto a dashboard for periodic review keeps the hard gate trustworthy without throwing away lower-confidence signal entirely. - Documented, expiring suppressions — never silent ones. An inline suppression comment (
# nosec B608,// NOSONAR, a CodeQL query-level exclusion) is sometimes the right call, but only when it carries a linked ticket and a reason a reviewer can evaluate. A suppression with no justification is functionally identical to disabling the rule for that one line forever — nobody ever goes back and re-checks it. - Writing your own rules. Off-the-shelf rule packs cannot see your team's internal footgun APIs — a database wrapper that skips parameter binding, an internal templating helper that doesn't auto-escape. A custom Semgrep rule closes exactly that gap:
# .semgrep/rules/no-raw-db-wrapper.yaml
# Off-the-shelf rules can't see our own footgun API — write one that can.
rules:
- id: acme-raw-query-wrapper-no-bind
languages: [python]
severity: ERROR
message: >
Db.raw() concatenates its argument directly into the SQL string.
Use Db.query() with bound parameters instead. See go/db-wrapper.
metadata:
cwe: "CWE-89: SQL Injection"
confidence: HIGH
patterns:
- pattern: Db.raw($QUERY)
- pattern-not: Db.raw("...") # a pure string literal has nothing to injectTriage is the decision that happens every time a finding — new or existing — actually lands in front of a human, and it always resolves the same open question first: is this real, and is it reachable.
Findings that pile up unreviewed are also where a dedicated triage layer earns its place: a platform like DefectDojo ingests output from every scanner in the pipeline — SAST, SCA, DAST, secrets — dedupes overlapping findings, and carries a false-positive marking forward across re-scans so the same triage decision doesn't have to be made twice. See Vulnerability Management & Triage for how that aggregation scales past a single repo.
Worked example: from a SpotBugs finding to a shipped fix
☺ Like you're 10: Read the scanner's message, find the exact spot where untrusted data meets a dangerous function, patch that exact spot, then run the scanner again to prove it's actually gone.
Here's the full loop the exam wants you to be able to run without hesitating. A merge-blocking FindSecBugs check fails on a Java servlet:
$ mvn com.github.spotbugs:spotbugs-maven-plugin:check
[ERROR] Bug type: SQL_INJECTION_JDBC
[ERROR] Priority: High Confidence: Medium CWE: 89
[ERROR] Class: com.acme.orders.OrderLookupServlet
[ERROR] Method: doGet(HttpServletRequest, HttpServletResponse)
[ERROR] Line: 47
[ERROR] A prepared statement is generated from a nonconstant String;
[ERROR] if this string is externally influenced, this may be SQL injection.
[ERROR]
[ERROR] BUILD FAILURE — 1 High-priority security finding, merge blockedStep 1 — read the finding, don't just read the message. The bug type SQL_INJECTION_JDBC and CWE-89 tell you the category before you've even opened the file: this is FindSecBugs's SQL injection detector, which means its dataflow engine already traced a specific source-to-sink path — it doesn't fire on vague suspicion.
Step 2 — open line 47 and trace the same path by hand. This is the part triage actually consists of:
// BEFORE — OrderLookupServlet.java, lines 44-49
// FindSecBugs's taint trace: getParameter() is a declared source,
// executeQuery() is a declared sink, and nothing in between neutralizes
// the value — string concatenation only, no bind variable.
String customerId = request.getParameter("customerId"); // source
String sql = "SELECT * FROM orders WHERE customer_id = '"
+ customerId + "'"; // taint flows in, unsanitized
ResultSet rs = statement.executeQuery(sql); // sink
// AFTER — the value is bound as a parameter, never concatenated into
// the SQL text itself; the driver escapes it before it ever reaches the DB
String customerId = request.getParameter("customerId");
String sql = "SELECT * FROM orders WHERE customer_id = ?";
PreparedStatement ps = connection.prepareStatement(sql);
ps.setString(1, customerId); // driver-escaped, not concatenated
ResultSet rs = ps.executeQuery();Step 3 — confirm it's real, not a false positive. doGet handles an unauthenticated servlet endpoint, customerId comes straight from a query parameter with no allow-list validation anywhere upstream, and the concatenation reaches executeQuery directly. Real, reachable, no sanitizer — the "YES" branch of the triage flow above, so this gets fixed now, not ticketed.
Step 4 — apply the fix and re-run locally before pushing. PreparedStatement is a sanitizer boundary FindSecBugs recognizes explicitly — the driver, not string concatenation, is what places the value into the query, so the taint path FindSecBugs was tracing no longer exists. Re-running spotbugs:check reports zero SQL_INJECTION_JDBC findings, and the merge gate goes green.
The same finding in Python wears different vocabulary but is the identical bug. Bandit's check B608 (hardcoded_sql_expressions) flags an f-string or %-formatted string handed to cursor.execute(), and its report looks like >> Issue: [B608:hardcoded_sql_expressions] Possible SQL injection vector through string-based query construction. Severity: Medium Confidence: Medium. The fix is the same parameterized-query pattern, just spelled cursor.execute(query, (customer_id,)) instead of a PreparedStatement. CWE-89 and its remediation are language-independent; only the detector name and the syntax change.
Secrets detection: pattern, entropy, and live verification
☺ Like you're 10: A secrets scanner doesn't understand your code at all — it just checks whether a piece of text looks like a real password, and then, when it can, actually asks the real service if that password still works.
A secrets scanner has no source, sink, or sanitizer to reason about. It works two ways instead, usually combined: signature matching against known credential shapes (an AWS access key always starts AKIA, a GitHub personal access token starts ghp_, a Stripe live key starts sk_live_, a PEM-encoded private key starts with a recognizable header line), and entropy scoring — a generic-looking 40-character random string with no known prefix still has high Shannon entropy, which is what a signature match alone would miss entirely.
The tool the curriculum leans on hardest for the second half of this chapter is TruffleHog, specifically because of what happens after a match: for many well-known secret types, TruffleHog makes a live, read-only API call against the actual vendor — AWS's sts:GetCallerIdentity, a scoped GitHub API check — and splits its output into Verified and Unverified results. A verified hit isn't "a string that looks like a credential"; it's confirmation that a real, currently-active credential is sitting in the repository right now. That single distinction is what makes secrets triage faster and more decisive than SAST triage: there's no reachability analysis to argue about, because the tool already asked the credential itself. TruffleHog also defaults to scanning the entire git history — every commit, every branch — not just the current diff, which is why it's commonly run both as a CI gate on new commits and as a standalone deep scan when onboarding a legacy repository that's never been checked before.
Gitleaks takes a different position in the same pipeline: it's fast, config-driven (a TOML rule file), and doesn't perform live verification by default — which is exactly why it's the tool teams reach for as the pre-commit and CI diff-scanning layer, catching a leak in milliseconds before it's even verified, with TruffleHog's slower, verified deep scan running as a second layer against full history. Tuning a secrets scanner looks nothing like tuning a SAST rule, because a false positive here is almost never "the logic doesn't actually reach a dangerous state" — it's "this is an obviously fake key in a test fixture or a documentation example." The right fix is an explicit allowlist, not a severity threshold:
# .gitleaks.toml — remove a real false-positive source, not the detection itself
[[rules]]
id = "generic-api-key"
regex = '''(?i)(api[_-]?key)["']?\s*[:=]\s*["'][0-9a-zA-Z]{32,}["']'''
[allowlist]
description = "Fixtures and docs use obviously-fake keys, never live secrets"
paths = [
'''test/fixtures/.*''',
'''docs/examples/.*'''
]
regexes = [
'''sk_test_FAKEKEYFORDOCSONLY[0-9a-zA-Z]*'''
]Notice what that config does not do — it doesn't lower a severity threshold or narrow the detection pattern itself the way a SAST rule tune might. It carves out two specific, reviewed paths and one specific, obviously-fake string. Detection stays exactly as sensitive everywhere else in the repository.
The single most common mistake this chapter tests for is applying the SAST mental model to a secrets finding: read the alert, edit the line, commit the fix, watch the scanner go green, move on. That workflow is correct for a SAST finding, because the fix genuinely lives in the current state of the file. It does nothing for a secrets finding — the scanner going green just means the current commit no longer contains the string; the credential itself is unchanged, still valid, and still sitting in every earlier commit, clone, fork, and CI cache that ever touched it. A secrets finding isn't closed by an edit. It's closed by rotating the credential at the system that issued it.
Worked example: a verified secret in CI
☺ Like you're 10: When the scanner confirms the password still works, you don't debate it — you go change the password first, and clean up the old commits second.
A pull request trips a TruffleHog CI gate:
$ trufflehog git file://. --since-commit main --only-verified
✅ Found verified result 🔑
Detector Type: AWS
Decoder Type: PLAIN
Raw result: AKIAABCDEFGHIJKLMNOP...
Commit: a1c9e2f (42 commits back, branch: feature/reporting-export)
File: scripts/export_to_s3.py
Line: 18
Verified with: sts:GetCallerIdentity → arn:aws:iam::482910XXXXXX:user/reporting-exportThe correct response sequence, in order, and why the order matters:
- Treat it as compromised the instant "Verified" appears. There's no triage question to resolve first —
sts:GetCallerIdentityalready proved this is a real, currently-usable AWS credential, not a string that resembles one. - Rotate it at the source immediately — deactivate the IAM key and issue a replacement — before touching git at all. The exposure window is bounded by how fast this step happens, not by how fast the branch gets cleaned up.
- Only then clean the git history if policy requires it (
git filter-repo, BFG). This step tidies the record; it does not undo the exposure — anyone who already cloned the branch had the old key regardless of what history looks like afterward. - Re-run the scanner against full history to confirm no other verified hits remain, then merge. A single verified finding is rarely the only one in a branch that's been open for 42 commits.
Compare that sequence to how the SpotBugs example resolved: there, the fix (a code change) came first and the re-scan came second, full stop. Here, the fix (a credential rotation, entirely outside the codebase) comes first, and the git and re-scan steps are cleanup that follows it. Same three-step shape — confirm, fix, verify — completely different definition of "fix."
A SAST rule blocks bad logic; a secrets scanner blocks bad content. Tune the first by narrowing what counts as dangerous in your own codebase. Trust the second's verification more than its pattern match. And never let "we suppressed it" stand in for either job actually getting done — an undocumented suppression and an unrotated verified secret are the same failure wearing two different scanners.
Common exam traps
☺ Like you're 10: Most of the wrong answers on this chapter come from treating the two scanners as interchangeable, or treating a quiet gate as a clean one.
- Ranking a verified secret by severity instead of acting on it immediately. The exam wants "rotate now" as the first action on a verified hit, not "file it as High and prioritize next sprint" the way a SAST finding might legitimately be handled.
- Believing
.gitignoreprotects a file that's already been committed. It only stops future additions. A secret committed before the ignore rule existed is still in history and needs a history scan (trufflehog git file://. --since-commit <first-commit>or a full unscoped run), not a.gitignoreentry, to surface it. - Treating a suppression as a resolution. A
# nosecor// NOSONARwith no linked justification is a silent, permanent hole in the gate. It's also a compliance gap, not just a security one — see Compliance as Code at Scale for how an auditor expects that trail to look. - Assuming "SpotBugs" alone catches security bugs. Vanilla SpotBugs ships general bug-pattern detectors, not security ones — the security detector catalog comes from the FindSecBugs plugin layered on top, and a Java pipeline that runs SpotBugs without FindSecBugs is not doing SAST at all, whatever the CI log implies.
- Running only diff-based secrets scanning and assuming the repo is clean. A diff scan only ever sees what changed since the base branch. A secret committed years before scanning was ever turned on stays invisible until someone runs a deliberate full-history pass.
- Over-tuning a SAST rule into silence. Disabling an entire rule category because of one noisy false positive is how the real SQL injection two months later goes uncaught. Narrow the rule's pattern or scope — as in the custom Semgrep example above — don't turn it off.
Benny: FindSecBugs flagged line 47 — SQL_INJECTION_JDBC. I'll just slap a suppression on it, the deploy's already late.
Timmy: Trace it first. Source, sink, sanitizer — is there actually nothing in between?
Benny: ...no. Straight concatenation from getParameter into executeQuery. Fine, that one's real. Fixing it properly.
Ellie: While you're in there — TruffleHog just verified an AWS key forty-two commits back on this same branch. Confirmed, not maybe.
Benny: Same fix, right? I'll edit the line and re-scan.
Ellie: Editing the line does nothing — that key is still live in every commit before this one. Rotate it in IAM first. History cleanup comes after, if it comes at all.
Foxy: So one gate, two findings, and they don't even resolve the same way?
Timmy: That's the whole chapter, Foxy. SAST judges the logic. Ellie judges whether the credential's still breathing. Neither one covers for the other.
Two chapters bracket this one. If the gate vocabulary — sources, sinks, quality gates, blocking versus advisory checks — felt unfamiliar, back up to DevOps Foundations & the CDP Toolchain and Secure SDLC Gates & the DevSecOps Maturity Model first. From here, Software Composition Analysis in Depth covers the majority of a codebase that neither of today's scanners ever looks at — the dependencies — and Dynamic Analysis in Practice picks up everything invisible until the application is actually running. For the hands-on version of everything above, Part 2 of the capstone wires both scanners into a real pipeline, and the leaked credential triage drill is a timed run of the TruffleHog scenario in this chapter.
1. What does a SAST engine model that a secrets scanner never does, and what does the secrets scanner check instead? 2. Why does a TruffleHog "Verified" result remove the reachability debate that a SAST finding usually requires? 3. What's the actual relationship between SpotBugs and FindSecBugs? 4. In the AWS key worked example, why does rotating the credential have to happen before any git history cleanup? 5. Why is baselining important for getting a newly-tuned SAST rule set actually adopted by a team?
Check your answers
- A SAST engine models program logic — a control-flow or data-flow graph tracing a source to a sink through the code's own operations. A secrets scanner models none of that; it matches raw text against known credential signatures and entropy scores, with no concept of what the surrounding code does.
- Because "Verified" means TruffleHog already made a live, read-only API call (such as AWS's
sts:GetCallerIdentity) and confirmed the credential is currently active — there's no "is this actually exploitable" judgment left to make, unlike a SAST finding where reachability still has to be traced by hand. - SpotBugs is a general-purpose bytecode analyzer with no security-specific detectors on its own. FindSecBugs is a plugin that adds a security detector catalog (SQL_INJECTION_JDBC, XSS_SERVLET, COMMAND_INJECTION, and similar) on top of SpotBugs's existing dataflow engine — the security value in a Java pipeline comes from running both together, not SpotBugs alone.
- Because the exposure window is bounded by how fast the credential itself stops working, not by how fast the git history looks clean. Anyone who already cloned the repository, forked it, or cached it in a CI runner still has the old key regardless of what later history rewriting does — only rotation actually closes the exposure.
- Without a baseline, a newly-tuned rule set surfaces every pre-existing finding in the whole codebase at once, blocking merges on old debt nobody asked this PR to fix. Baselining accepts the current state and gates only on new findings, so the team adopts the tuning instead of disabling the gate to get unblocked.