The Exam Blueprint · Chapter 5 of 9 · Static Analysis & Secrets Detection

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.

☺ Explain it like I'm 10

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.

🐢🐘Your hosts for this chapter: Timmy the Turtle & Ellie the Elephant — Timmy won't let an unscanned build past the gate, and Ellie never sets a secret down. Between them, this chapter is really two different jobs sharing one gate.

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:

QuestionSAST findingSecrets finding
What's actually being matched?A pattern in program logic — a source reaching a sinkA 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 branchRarely, once verified — a live API check confirms the credential still works
Correct first responseA judgment call: fix now, ticket it, or document a risk-acceptRotate the credential immediately — no judgment call to make
Does editing the file resolve it?Yes, once the sink is provably safeNo — 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 reviewingOutside 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:

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:

ToolAnalysis basisScopeBest tuning lever
SemgrepAST pattern matching, with lightweight dataflow in later rule tiersPolyglot — one rule syntax across dozens of languagesCustom rules for your own internal APIs; baseline scans so only new findings gate
CodeQLCompiles code into a queryable relational database; real interprocedural taint trackingDeep, per-language query packs (Java, JS/TS, Python, C/C++, Go, ...)Custom QL queries; inline suppression comments tied to a specific query ID
SonarQubeA bundled rule engine plus "Security Hotspots" that require human reviewBroad multi-language, wrapped in a Quality Gate workflowQuality Gate conditions scoped to the "New Code" period, not the whole repo
SpotBugs + FindSecBugsBytecode-level dataflow analysis (SpotBugs core) plus security-specific detectors (FindSecBugs plugin)Java / JVM bytecode onlyConfidence + priority filters; FindSecBugs detector include/exclude lists
BanditDirect AST walk of Python source — no bytecode, no compiled databasePython onlyThe 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:

# .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 inject

Triage 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.

SAST finding raised in CI or a PR Reproduce: trace source → sink, look for a sanitizer Real, reachable, no sanitizer? NO Tune the rule's pattern or scope and document why — don't just suppress the one line Fix now, or log a risk-accept with an expiry date — either way it's written down, not closed YES

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 blocked

Step 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.

⚠ Watch out

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-export

The correct response sequence, in order, and why the order matters:

  1. Treat it as compromised the instant "Verified" appears. There's no triage question to resolve first — sts:GetCallerIdentity already proved this is a real, currently-usable AWS credential, not a string that resembles one.
  2. 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.
  3. 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.
  4. 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."

◆ Key idea

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.

🎬 At the Shift-Left Squad
🦫

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.

✓ Checkpoint

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
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.