Hands-On Labs · The Capstone · Part 2 of 7 · SAST & Secrets Scanning

Part 2 — Wire In SAST & Secrets Scanning

Part 1 threat-modeled Vulnerly and ranked its SQL-injection path and its leaked payments-processor key among the top findings — on paper, in a saved model, before either one had cost anything. This part is where that ranking turns into an actual gate: you'll wire Semgrep and gitleaks into Vulnerly's pipeline as required, merge-blocking checks, watch them both immediately catch the two flaws the app already ships with, then fix each one for real — a parameterized query, and a secret that lives in HashiCorp Vault instead of a committed file — before proving the fix with a green pipeline run and a rotated, re-scanned commit. Part 3 assumes both gates are already sitting on main, watching every future pull request the same way.

☺ Explain it like I'm 10

Part 1 was drawing a map of every place a burglar might get in. This part is where you actually install two specific locks the map pointed at — a nosy neighbor who checks every note you tape to your own door for anything that looks like a spare key, and an inspector who reads the blueprint of anything you build onto the house looking for a wall with a hole already in it. You don't just install the locks and hope — you jiggle the door yourself first, on purpose, with the old broken lock still on it, so you actually watch it fail before you watch it hold.

🐢🐘Your hosts for this part: Timmy the Turtle & Ellie the Elephant — Timmy is the required check itself, the one that won't let a build through unscanned; Ellie is the reason the fix for a leaked secret is "rotate it," never "just delete the line and hope."
⚠ Where you are arriving from, and where you're headed

Arriving: a cloned vulnerly repo, a .github/workflows/ci.yml that runs npm install and nothing else, and a Part 1 threat model (security/threat-model.json) that already named the two flaws this page closes. Nothing has been fixed yet — Vulnerly still ships with a real-looking key committed in app/.env and a reconciliation-search endpoint that builds SQL with string concatenation. Leaving this page: two required, merge-blocking status checks on main (secrets-scan and sast-scan), a parameterized query, a rotated credential that lives only in a local Vault instance, and a .gitleaks.toml that allowlists the one historical commit you can't erase but have made harmless. Part 3 picks up exactly here and adds a third gate, Trivy, against the two dependencies Part 1's model flagged but this page never touches.

Where Part 1 left off, and what "done" means here

☺ Like you're 10: The map already circled two problem spots. This page is where you actually go stand in front of them with tools in hand.

If you haven't run it yet, Part 1 walked Vulnerly's data flow through STRIDE using OWASP Threat Dragon and came out with nine ranked findings, comfortably past the hub's five-threat minimum. Two of them are exactly what this page targets: a tampering threat on the reconciliation-search endpoint (its SQL string-concatenation) and an information-disclosure threat on the repository itself (the committed PAYMENTS_PROCESSOR_KEY). A third finding sits on that very same search endpoint — the search term gets echoed back into the HTML response unescaped, a reflected XSS — and it is deliberately not this page's job; Part 1's model assigned it to Part 6, alongside an unrelated IDOR on a different endpoint (/reconciliation/:id) that already uses a parameterized query and has nothing wrong with it SAST would ever catch. Keep those boundaries straight going in — this part fixes exactly two things, not everything Part 1 found on the same page of the model.

Confirm your starting state before you touch anything:

$ git clone https://github.com/<you>/vulnerly.git
$ cd vulnerly
$ cat .github/workflows/ci.yml
# name: ci
# on: [pull_request]
# jobs:
#   build:
#     runs-on: ubuntu-latest
#     steps:
#       - uses: actions/checkout@v4
#       - run: cd app && npm install
# (that's it — no security job exists yet, and nothing is a required check)

$ grep -n "PAYMENTS_PROCESSOR_KEY" app/.env
# PAYMENTS_PROCESSOR_KEY=sk_live_51NxVulnerlyDemoKeyDoNotUseAnywhere0000
# (line 2 — DATABASE_URL is line 1. "revoked-in-advance" per Part 1's own
#  comment, but treat every committed credential-shaped string as live
#  until you've personally confirmed otherwise — see the callout below.)

$ sed -n '9,17p' app/src/index.js
# // GET /reconciliation/search?merchant=foo
# // (1) string-concatenated SQL — classic injection point
# // (2) the search term is echoed back into HTML, unescaped — reflected XSS
# app.get("/reconciliation/search", async (req, res) => {
#   const term = req.query.merchant || "";
#   const sql = `SELECT * FROM transactions WHERE merchant_name LIKE '%${term}%'`;
#   const { rows } = await pool.query(sql);
#   res.send(`<h1>Results for ${term}</h1>` + JSON.stringify(rows));
# });

Both flaws are already sitting in main. That's deliberate — you're not going to write a new bug to prove the gates work; you're going to point two new gates at bugs that are already there and watch them do their job for the first time.

Wiring gitleaks in as a required, merge-blocking check

☺ Like you're 10: A checker that reads every page of every commit, past and present, looking for anything shaped like a real password.

Add a repo-local .gitleaks.toml at the root. You don't need a custom rule for this — gitleaks' default ruleset already recognizes the Stripe-style key shape Vulnerly's processor key was built to look like — but you do need somewhere to put the allowlist entry you'll add later, once the leak is actually rotated:

# .gitleaks.toml
[extend]
useDefault = true      # keep the ~100+ built-in rules; this file only adds to them

[allowlist]
description = "Fixtures and historical leaks that have already been rotated and triaged"
paths = [
  '''app/test/fixtures/.*'''
]
# commits = [ ]        # a rotated leak's SHA goes here once you reach that step below — not yet

Now add a job to the ci.yml that's already in the repo, right alongside the existing build job. Run the official image directly rather than the GitHub Marketplace action — its licensing terms for organization use have changed more than once, and the Docker image sidesteps the question entirely:

# .github/workflows/ci.yml — appended job
  secrets-scan:
    name: secrets-scan
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0        # full history — see the callout below
      - run: |
          docker run --rm -v "$PWD:/repo" zricethezav/gitleaks:latest \
            detect --source="/repo" --config="/repo/.gitleaks.toml" \
            --redact --exit-code 1 -v
⚠ Don't skip fetch-depth: 0

The default GitHub Actions checkout is shallow — one commit deep. gitleaks detect still runs, still exits 0, and still says "clean," because it genuinely never saw anything past the tip commit. A green secrets-scan check on a shallow clone isn't proof the repo is clean; it's an unasked question. Always fetch full history in this job specifically.

Last step for this section: make it count. In your repo's Settings → Branches, add (or edit) a branch protection rule for main, turn on Require status checks to pass before merging, and select secrets-scan from the list. Until you do this, a red check is just a red badge — nothing actually stops a merge.

Wiring Semgrep in as a required, merge-blocking check

☺ Like you're 10: A reader who understands code well enough to recognize a dangerous shape, no matter how it's spaced or named.

Vulnerly's SQL string-concatenation is exactly the shape a community Semgrep ruleset already knows to flag — a raw driver call fed a template-literal built from request input — so no custom rule is needed here, unlike the internal-wrapper case this course covers on secure coding patterns. Pin two curated packs rather than the unpinned --config=auto, so the gate's behavior doesn't shift under you between two otherwise-identical runs:

# .github/workflows/ci.yml — appended job
  sast-scan:
    name: sast-scan
    runs-on: ubuntu-latest
    container:
      image: semgrep/semgrep:latest
    steps:
      - uses: actions/checkout@v4
      - run: |
          semgrep scan --config=p/ci --config=p/owasp-top-ten --config=p/javascript \
            --error --json --output=semgrep-report.json app/src
      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: semgrep-report
          path: semgrep-report.json

--error is the flag doing the actual gating — it makes Semgrep exit non-zero the moment it has any finding at all, which is what turns "a report exists" into "the build fails." (Flags shown here are current as of Semgrep's CLI docs; run semgrep scan --help against your installed version before assuming exact names haven't shifted.) Uploading the JSON report even if: always() means you keep the evidence whether the job passes or fails — useful now, and exactly the kind of artifact Part 7 later imports into DefectDojo.

Add sast-scan to the same branch protection rule you just edited for secrets-scan, so both are required. CodeQL or SonarQube would slot into this same job shape if you'd rather use one of those instead — see CodeQL and SonarQube — but Semgrep's speed is specifically why it belongs on every single pull request rather than on a schedule.

First push: seeded flaws still in the branch PR opened app/.env + index.js secrets-scan: FAIL app/.env:2 — leak found exit code 1 sast-scan: FAIL index.js:14 — CWE-89 1 finding, --error Merge: BLOCKED 2 required checks failing Fix push: parameterized query + Vault-backed secret Fix commit same PR, new commit secrets-scan: PASS 1 leak, allowlisted by SHA, exit code 0 sast-scan: PASS 0 findings in index.js Merge: ALLOWED both required checks green The branch protection rule doesn't change between the two rows — only what the code does when it hits the same two gates does. "Allowlisted" is not "absent": the historical leak still shows up, named, with a reason — see the section below.

Pushing the seeded flaws through the new gates

☺ Like you're 10: Open the front door on purpose with the old broken lock still attached, and watch the new alarm actually go off.

Push a branch off main — no code changes yet — and open a pull request. Both new jobs run against exactly what's already there:

$ git checkout -b part2/wire-in-gates
$ git push -u origin part2/wire-in-gates
$ gh pr create --fill

# secrets-scan output (redacted, as --redact intends):
Finding:     REDACTED
Secret:      REDACTED
RuleID:      stripe-access-token
Entropy:     4.61
File:        app/.env
Line:        2
Fingerprint: app/.env:stripe-access-token:2
Commit:      6e2a91f...

3:41PM INF 8 commits scanned.
3:41PM WARN leaks found: 1
Error: Process completed with exit code 1.

# sast-scan output:
app/src/index.js
   ❯❯❱ javascript.lang.security.audit.sqli.node-postgres-sqli
          String concatenation with a non-literal variable in a database
          query. If term is user-controlled and reaches this call
          unsanitized, an attacker can alter the query's structure.
          CWE-89: SQL Injection · OWASP A03:2021 - Injection

          14┆ const sql = `SELECT * FROM transactions WHERE merchant_name LIKE '%${term}%'`;

Ran 3 rule packs on 6 files: 1 finding.
Error: Process completed with exit code 1.

(The exact Semgrep rule ID shown above is illustrative of the finding's shape and CWE mapping — Registry rule slugs are versioned and can shift; run the scan yourself and read what your installed ruleset actually reports rather than pattern-matching against a memorized ID. Note also what Semgrep did not flag on this same line block: the unescaped res.send a few lines down is a real reflected-XSS finding too, just not one this SAST pack happens to catch — Part 6's ZAP scan is what finds that one, from the outside, the way an attacker actually would.)

The pull request now shows two required checks, both red, and GitHub refuses the merge button outright — not a warning banner someone could dismiss, an actual disabled button. That's the entire point of "required," and it's worth confirming with your own eyes once: try to merge anyway, and watch it refuse.

Fixing the SQL injection

☺ Like you're 10: Instead of gluing the customer's own text straight into the sentence you send the database, you hand the database a blank and the text separately, and it fills in the blank safely itself.

The fix is a parameterized query — the database driver substitutes the value itself, outside of query-string parsing, so there's no way for the value to be interpreted as SQL syntax no matter what it contains. For a LIKE query, the wildcard characters move into the bound value instead of the query string:

// app/src/index.js — BEFORE (seeded; this is the line Semgrep flags)
app.get("/reconciliation/search", async (req, res) => {
  const term = req.query.merchant || "";
  const sql = `SELECT * FROM transactions WHERE merchant_name LIKE '%${term}%'`;
  const { rows } = await pool.query(sql);
  res.send(`

Results for ${term}

` + JSON.stringify(rows)); }); // app/src/index.js — AFTER (this part's fix — one line changed) app.get("/reconciliation/search", async (req, res) => { const term = req.query.merchant || ""; const { rows } = await pool.query( "SELECT * FROM transactions WHERE merchant_name LIKE $1", [`%${term}%`] ); res.send(`

Results for ${term}

` + JSON.stringify(rows)); });

Notice exactly one line changed, and it's the query — not the res.send line directly below it. term is still echoed straight into the HTML response with no escaping, which is a real, separate finding (reflected XSS) on this same handler. Part 1's model assigned that one to Part 6, where a ZAP scan confirms it from the outside the same way a real attacker would find it. Rewriting the response line here would technically make the app safer, but it would also mean Part 6's "does ZAP still find this?" drill has nothing left to find — leave it alone, on purpose, so that later part still has a real bug to catch.

Confirm locally before you even push:

$ semgrep scan --config=p/ci --config=p/owasp-top-ten --config=p/javascript app/src
# Ran 3 rule packs on 6 files: 0 findings.

Fixing the leaked key — Vault, rotation, and the historical commit

☺ Like you're 10: Change the lock, don't just paint over the note that told everyone where the old key was hidden.

This is the flaw secrets management already warned about by name: a .env file with a real value checked into the repo is functionally a hardcoded secret with one extra layer of indirection, even though nothing in app/src/index.js currently reads PAYMENTS_PROCESSOR_KEY at all — Vulnerly's seed doesn't wire up a processor integration yet. That's not a reason to leave it: a credential-shaped string sitting in git history is a liability whether or not any code path consumes it today, and the fix is the same either way — get the real value out of the repo and into somewhere that can hand it out on a lease.

Stand up a local Vault dev server — throwaway, in-memory, exactly what this sandbox capstone calls for:

$ vault server -dev -dev-root-token-id=root &
# Vault dev server is now running. In dev mode Vault auto-unseals and
# mounts a kv-v2 engine at "secret/" for you — nothing else to enable.
# Root Token: root

$ export VAULT_ADDR='http://127.0.0.1:8200'
$ export VAULT_TOKEN='root'

$ vault kv put secret/vulnerly/processor-key value=sk_live_51NxVulnerlyRotated0001NeverCommitted
# ==== Secret Path ====
# secret/data/vulnerly/processor-key
Key                Value
---                -----
created_time       2026-08-17T14:52:03Z
version            1
⚠ Don't paste the new value anywhere git will see it

Not in a commit message, not in a "for reference" code comment, not in a README. The whole point of this exercise is that the real value lives in exactly one place — Vault — and every other place it might otherwise end up (a file, a doc, a Slack message) is a second copy waiting to leak the same way the first one did. If you need to prove the new value exists, prove it by reading it back from Vault, not by writing it down anywhere else.

Wire up the pattern any future consumer of the key — or a developer running locally — is expected to use, so the value never has to touch a file in this repo again:

# app/scripts/load-secrets.sh — new file, this part's fix
#!/bin/sh
set -e
export PAYMENTS_PROCESSOR_KEY="$(vault kv get -field=value secret/vulnerly/processor-key)"
exec "$@"
# app/package.json — add a start script that runs through the loader
  "scripts": {
    "start": "./scripts/load-secrets.sh node src/index.js"
  }

One real dotenv behavior makes this safe rather than fragile: require("dotenv").config(), which app/src/index.js already calls on line 4, does not overwrite a variable that's already set in the environment. Because load-secrets.sh exports PAYMENTS_PROCESSOR_KEY before Node even starts, dotenv sees it already set and leaves it alone — so PAYMENTS_PROCESSOR_KEY can stay entirely absent from app/.env.example with no risk of a blank value silently winning later, once some future part actually wires up a processor call that reads it.

Now stop committing the real file, and remove it from the git index — not just from your working directory:

$ git rm --cached app/.env
$ echo "app/.env" >> .gitignore
$ cat > app/.env.example <<'EOF'
DATABASE_URL=postgres://vulnerly:vulnerly@localhost:5432/vulnerly
# PAYMENTS_PROCESSOR_KEY intentionally omitted — sourced from Vault via
# app/scripts/load-secrets.sh, never from a file in this repo.
EOF
$ git add .gitignore app/.env.example

None of that touches the commit from Part 1's scaffold that still has the real key sitting in its diff. git rm only changes what the latest tree looks like — the earlier commit object is immutable, still reachable with git log -p, and gitleaks' full-history scan will find it there forever unless you tell it, explicitly, that this specific historical finding has already been handled:

# .gitleaks.toml — the allowlist entry this part adds
[allowlist]
description = "Fixtures and historical leaks that have already been rotated and triaged"
paths = [
  '''app/test/fixtures/.*'''
]
commits = [
  # PAYMENTS_PROCESSOR_KEY leaked in app/.env, Part 1 scaffold.
  # Rotated in Vault 2026-08-17 (PR #2); old value confirmed dead.
  # Left in history on purpose — see gitleaks docs on why rewriting
  # shared history doesn't reach forks/clones/CI caches anyway.
  "6e2a91f4c7b03d5a891e6f2c4d7b8a9013e5f6c2"
]

That's the difference between "allowlisted" and "ignored": the finding is still named, in the file, with a reason and a date, reviewable by anyone who reads this repo later. It doesn't disappear from the scan output silently — it moves from "leak" to "known, rotated, accepted," which is a very different claim and the only honest one to make.

Proving the fix: green pipeline, rotated commit, and a regression branch that gets rejected

☺ Like you're 10: Don't just say the lock works — try the old key on it yourself, then try to sneak the broken lock back in and watch it get refused too.

Push the fix commit — the parameterized query, the load-secrets.sh wiring, the .gitignore/.env.example swap, and the allowlist entry — to the same pull request, and confirm both checks flip green:

$ git add -A
$ git commit -m "part2: parameterize search query, move processor key to Vault"
$ git push

$ gitleaks detect --source . --config .gitleaks.toml -v
# 3:58PM INF 9 commits scanned.
# 3:58PM INF no leaks found (1 allowlisted finding suppressed)

$ semgrep scan --config=p/ci --config=p/owasp-top-ten --config=p/javascript app/src
# Ran 3 rule packs on 6 files: 0 findings.

Both required checks on the pull request go green. Merge it. That alone isn't the full done-when for this part, though — a fresh branch that reintroduces either flaw has to be rejected automatically, not just "would probably be caught if someone looked." Prove it, deliberately, on a throwaway branch you delete afterward:

$ git checkout -b part2/prove-the-gate main
# hand-edit app/src/index.js: swap the $1-bound LIKE query back to the
# template-literal string-concat version shown earlier on this page
$ git commit -am "temp: reintroduce the string-concat query to prove the gate"
$ git push -u origin part2/prove-the-gate
$ gh pr create --fill
# sast-scan: FAIL — same finding, same file, same line. Merge blocked.

$ git branch -D part2/prove-the-gate         # local
$ git push origin --delete part2/prove-the-gate   # and on the remote — this branch never merges

That's the actual done-when for this part — not "the diagram in this page says it should work," but a check you ran yourself, against your own repo, that failed exactly when it was supposed to.

Done whenHow you check it
secrets-scan and sast-scan are both required status checks on mainSettings → Branches → the branch protection rule for main lists both by name
Both gates caught the seeded flaws before any fix landedThe first pull request's check run logs show both jobs exiting non-zero, not just "some findings"
The SQL injection is fixed; the reflected XSS one line below it is deliberately notindex.js's search handler uses $1 parameter binding; res.send still echoes term unescaped
The leaked key is rotated, not just deletedvault kv get secret/vulnerly/processor-key returns a value you generated after the leak; the old literal is dead and appears nowhere outside the allowlisted historical commit
The historical commit is allowlisted, named, and dated — not erased.gitleaks.toml's [allowlist].commits contains the SHA with a comment; gitleaks detect reports it as suppressed, not absent
A regression branch is rejected automaticallyYou reintroduced one seeded flaw on a throwaway branch and watched CI fail it, then deleted the branch
◆ Key idea

"Rotate the credential" and "fix the code pattern" are two different fixes for two different halves of the same mistake, and this part only counts as done if both happened. Rotating a key that's still hardcoded in a committed file just means the next leak has a different value. Fixing the pattern (Vault, not a file) without rotating the exposed value just means the old key — already sitting in history, already readable by anyone with clone access — is still live and usable. Vulnerly needed both, and so does every real leak this course keeps coming back to.

🎬 At the Shift-Left Squad
🦫

Benny: I wrote that query when I scaffolded this thing. In my defense, the search worked fine in every test I ran.

🐢

Timmy: "Worked fine" and "safe" aren't the same claim, Benny. That's exactly why it's a required check now instead of something we trust people to remember.

🦊

Foxy: Wait — nothing even calls PAYMENTS_PROCESSOR_KEY yet. Why does an unused variable need rotating at all?

🐘

Ellie: Because "unused" describes the code, not the string sitting in git history. Anyone with clone access can read it whether or not a function ever calls it. I don't hand out anything I can't tell you the exact lease on, used or not.

🦫

Benny: Fine, fine — it's in Vault now. So the old key in that first commit doesn't matter anymore, right? I can just leave it.

🐘

Ellie: It matters exactly as much as it did the second it was pushed. I don't care that it's "old" — I rotated it because it was exposed, full stop. The allowlist entry says we know it's there and dealt with it. It does not say it never happened.

🐢

Timmy: And nobody take my word for any of this. Push the old query back on a throwaway branch and watch me actually stop it. A gate you haven't watched fail is a gate you're just hoping works.

What "done" looks like for Part 2

☺ Like you're 10: Two working alarms, one fixed lock, one changed key, and proof you tried to sneak past both and got caught.

At the end of this part, Vulnerly's main branch has two required, merge-blocking checks that didn't exist an hour ago, a reconciliation-search query that can no longer have its structure altered by user input, and a payments-processor key that lives in exactly one place — a Vault KV path — instead of a git history that nothing can truly scrub. The reflected XSS on that same search handler and the IDOR on /reconciliation/:id are both still open on purpose, named in Part 1's model, waiting for Part 6. Part 3 starts from here and adds a third required check — Trivy against app/package.json's two stale dependencies — onto the exact same ci.yml and the exact same branch protection rule you just built, so confirm both checks are still green on main before you move on.

🐢 Timmy's checkpoint

1. Why did the leaked processor key need rotating even though no code in Vulnerly currently reads it? 2. What's the difference between deleting app/.env from the latest commit and what gitleaks' full-history scan still sees, and what closes that gap for good? 3. Why does the .gitleaks.toml allowlist entry include a SHA and a dated reason instead of just suppressing the rule entirely? 4. The reflected XSS sits one line below the SQL injection in the exact same handler. Why doesn't this part fix that too?

Check your answers
  1. Because the risk lives in the git history, not in whether a function currently calls process.env.PAYMENTS_PROCESSOR_KEY. Anyone with read access to the repo — a fork, a leaked clone, a former contractor's stale credentials — can read a committed .env file's contents regardless of whether the app consumes that value today or only will once some later part wires up a real processor integration.
  2. git rm --cached app/.env only changes what the current tree looks like. The earlier commit object that added the real value is immutable and still fully readable via git log -p, in any existing clone, fork, or CI cache — gitleaks detect's full-history scan finds it there regardless of what the latest commit shows. What actually closes the exposure is rotating the credential at the source, treating it as compromised from the moment it was committed.
  3. Because "allowlisted" has to mean "reviewed and accepted," not "hidden." A bare suppression makes a real historical leak invisible to the next person reading the config; a SHA plus a dated, specific reason keeps the finding auditable — anyone can see exactly what leaked, when it was rotated, and why the entry exists, instead of just seeing a clean scan and assuming nothing ever went wrong.
  4. Because it wasn't this part's scope — Part 1's threat model already assigned it to Part 6, where a ZAP scan confirms it from the outside, the same way a real attacker would find it rather than by reading the source. Fixing every finding on a file the moment any part happens to touch it would leave Part 6 with nothing left to demonstrate; leaving it named and untouched here is deliberate, not an oversight.

Part 2 leaves you with two real, proven gates and one fixed pattern in each direction — code and secret. Continue to Part 3 — Add an SCA Gate, where the same ci.yml gets a third required check for the two dependencies Part 1's model flagged but this page never touched. Or step back to the full capstone track to see how this part fits the other six, and revisit secrets management, static analysis & secrets detection, Semgrep, gitleaks, and HashiCorp Vault for the concepts behind what you just built.