Part 3 — Add an SCA Gate
This is the deep version of Secure a Pipeline's third stage. Part 1's threat model already named lodash@4.17.15 and jsonwebtoken@8.5.1 by version, sitting in vulnerly's package.json since the very first commit, and Part 2 closed the leaked key and the SQL injection. Nobody has actually pointed a scanner at the dependency tree yet — and, just as importantly, nobody has checked whether either of those two flagged packages is genuinely reachable from anything a merchant can influence, or whether a threat model's "this looks old and risky" is the whole story. That's this part's job: run Trivy against Vulnerly's manifest, report-only first; triage every finding by CVSS and EPSS and CISA's KEV catalog and actual reachability in Vulnerly's own code, not by severity number alone; upgrade the one dependency that turns out to matter; suppress the rest with reasoning specific enough that a teammate could verify it without re-doing your work; and only then flip the gate from report-only to build-blocking, with a CycloneDX SBOM attached to every single build from here on.
Arriving: Part 2 already made secrets-scan and sast-scan required, merge-blocking checks in vulnerly's ci.yml — the committed .env is gone, the payments-processor key lives in Vault, and the reconciliation search endpoint's SQL is parameterized. Vulnerly's dependency tree itself is untouched since Part 1's seed commit. Leaving this page: a third required check, sca-scan, wired into the same pipeline; a .trivyignore where every accepted finding carries its own dated, specific reasoning — checked by a small script that rejects a blank or copy-pasted one; lodash upgraded for real, closing the one CVE that turned out to be genuinely reachable; and a CycloneDX SBOM attached to every build from this part forward, whether the scan passes or fails. Part 4 picks up exactly here and rebuilds the Dockerfile against this same, now-SCA-gated dependency tree.
Imagine a fire marshal hands you a list of five things in your house that could theoretically catch fire, sorted by how big the fire would be if each one did. The biggest one on the list is a space heater in a closet nobody's opened in a year, unplugged, disconnected at the breaker. The third-biggest is a frayed cord on the lamp you actually use every single night. If you only look at the size column, you fix the space heater first and go to bed with the frayed cord still plugged in. This part is learning to ask the second question — not just "how bad would this be," but "can this thing actually start a fire, right now, the way it's sitting" — before deciding what to fix tonight and what to write down and revisit later.
package.json answer for the two dependency versions Rocky flagged by name.What Part 2 leaves behind, and what this part adds
☺ Like you're 10: Part 2 already locked the door and the window. This part checks whether something already inside the house — sitting there since move-in day — is itself the problem.
You need what you already have from Parts 1 and 2 — git, Node.js (LTS) with npm, and the vulnerly repo pushed with Part 2's fixes on main — plus the Trivy CLI locally, so you can iterate on findings before pushing rather than waiting on a CI run for every triage decision. This part still doesn't need Docker or kind; like Part 1, it's pipeline- and dependency-level work, not a running deployment — that arrives in Part 4.
| Thing | State after Part 2 | What this part does to it |
|---|---|---|
| Dependency manifest | app/package.json / app/package-lock.json — lodash@4.17.15 and jsonwebtoken@8.5.1, untouched since Part 1's seed commit | Gains exactly one version bump. Nothing else in the manifest changes. |
| CI pipeline | .github/workflows/ci.yml — secrets-scan and sast-scan, both required | Gains a third required job, sca-scan, plus an SBOM step that runs regardless of whether the scan passes |
| Suppression record | Doesn't exist yet | Created this part — .trivyignore, one entry per accepted finding, each with its own dated reasoning |
| SBOM | Doesn't exist yet | Generated fresh on every CI run from this part onward, uploaded as a build artifact on every run, pass or fail |
| Vulnerly's own source | app/src/middleware/auth.js and app/src/jobs/export.js exist — the merchant session check and the S3 export job Part 1's diagram named, neither one shown in Part 1's own trimmed listing | You'll read both files closely in the triage section below — that's where the answer to "is this actually reachable" lives |
Part 1's threat model called both lodash@4.17.15 and jsonwebtoken@8.5.1 "reachable through normal request handling" — and it wasn't wrong to flag both. A STRIDE pass works at the resolution of "this dependency is old and carries a known CVE," which is exactly the right level of detail for a design-time review with no scanner output yet to react to. It doesn't verify, line by line, whether the specific vulnerable function inside that dependency is ever actually called with attacker-influenced data. That's a different, deeper question — and it's this part's whole job.
Running the scan — report-only first, exactly like Ledgerly staged it
☺ Like you're 10: Before flipping a switch that could stop everyone's work, read the whole list of what it would have stopped first.
Install Trivy locally and run it against app/ in report-only mode — no --exit-code 1 yet, so nothing blocks anything while you're still reading the backlog. This mirrors how Ledgerly's own postmortem describes staging its SCA rollout after the incident: see the real findings first, decide what they mean, then make the check start saying no.
curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/bin trivy fs --scanners vuln --severity CRITICAL,HIGH,MEDIUM,LOW --exit-code 0 --format table app
If you'd rather use this course's own named CDP tool instead, OWASP Dependency-Check covers the same ground — everything below still applies, just aimed at its own report and its own suppression.xml mechanism, covered later on this page:
dependency-check.sh --project vulnerly --scan app --format ALL --out ./odc-report --failOnCVSS 11 # --failOnCVSS 11 is above the 0-10 scale on purpose — it makes this run report-only too
The condensed, reformatted output — advisory identifiers, exact wording, and severity buckets below are illustrative of the real report's shape, run the command yourself for current numbers:
app/package.json ================== Total: 4 (CRITICAL: 1, HIGH: 3, MEDIUM: 0, LOW: 0) ┌────────────────┬────────────────┬──────────┬────────────────┬───────────────┬──────────────────────────────────────┐ │ Library │ Vulnerability │ Severity │ Installed Ver. │ Fixed Ver. │ Title │ ├────────────────┼────────────────┼──────────┼────────────────┼───────────────┼──────────────────────────────────────┤ │ jsonwebtoken │ CVE-2022-23529 │ CRITICAL │ 8.5.1 │ 9.0.0 │ jsonwebtoken: verify() insecure with │ │ │ │ │ │ │ a dynamically resolved key │ │ jsonwebtoken │ CVE-2022-23540 │ HIGH │ 8.5.1 │ 9.0.0 │ jsonwebtoken: algorithm not enforced, │ │ │ │ │ │ │ enabling algorithm confusion │ │ lodash │ CVE-2021-23337 │ HIGH │ 4.17.15 │ 4.17.21 │ lodash: command injection via template │ │ lodash │ CVE-2020-8203 │ HIGH │ 4.17.15 │ 4.17.19 │ lodash: prototype pollution via │ │ │ │ │ │ │ zipObjectDeep │ └────────────────┴────────────────┴──────────┴────────────────┴───────────────┴──────────────────────────────────────┘
Read by severity alone, the report ranks itself for you: fix jsonwebtoken first, it's the CRITICAL; lodash can wait. That ranking is exactly what the next section exists to check before you act on it.
Triage by exploitability, not by CVSS alone
☺ Like you're 10: A 9.8 sitting behind a locked door you never open is less urgent, right now, than a 7.2 sitting on a doorknob everyone touches on the way out.
Software Composition Analysis in Depth covers this general framework — reading a CVSS vector for what it actually claims, stacking EPSS on top of it for likelihood, checking CISA's Known Exploited Vulnerabilities catalog for confirmed exploitation — so this page doesn't re-derive it. What it does instead is run that framework for real against these four findings, then go one layer past even that: a manual reachability check against Vulnerly's own source, which is the one signal neither CVSS nor EPSS nor KEV can give you, because none of them have read your code.
Start with the numbers. EPSS and KEV both update independently of this page — confirm current figures at first.org/epss and CISA's own KEV catalog before treating any specific score below as current; what matters here is the shape of the comparison, not the exact decimal:
# EPSS — the likelihood half CVSS doesn't give you curl -s "https://api.first.org/data/v1/epss?cve=CVE-2022-23529" | python3 -m json.tool curl -s "https://api.first.org/data/v1/epss?cve=CVE-2021-23337" | python3 -m json.tool # CISA's Known Exploited Vulnerabilities catalog — grep for a confirmed hit, # not a prediction curl -s https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json \ | grep -c "CVE-2022-23529\|CVE-2022-23540\|CVE-2021-23337\|CVE-2020-8203" # 0 — none of the four are on KEV at the time of writing
Then read Vulnerly's own source at the exact call sites, which is what a scanner never does. Two files hold every answer this section needs — neither shown in Part 1's own trimmed listing, since Part 1 only walked the two routes relevant to its own findings:
// app/src/middleware/auth.js — the merchant session check
const jwt = require("jsonwebtoken");
function requireAuth(req, res, next) {
const token = (req.headers.authorization || "").replace(/^Bearer /, "");
try {
// a single static string from the environment, never a callback or
// a per-request key lookup — and the algorithm is pinned, not inferred
req.merchant = jwt.verify(token, process.env.JWT_SECRET, { algorithms: ["HS256"] });
next();
} catch {
res.status(401).json({ error: "invalid session" });
}
}
module.exports = { requireAuth };// app/src/jobs/export.js — the scheduled S3 export job Part 1's diagram named
const _ = require("lodash");
const coverSheetTpl = "<h1>Export for <%= merchantName %></h1><p>Generated <%= generatedAt %></p>";
async function buildCoverSheet(merchant) {
const render = _.template(coverSheetTpl); // CVE-2021-23337's exact sink
return render({ merchantName: merchant.display_name, generatedAt: new Date().toISOString() });
}# confirm jsonwebtoken has exactly one call site, and read what it does
$ grep -rn "jwt\.\(verify\|sign\)" app/src
app/src/middleware/auth.js:7: req.merchant = jwt.verify(token, process.env.JWT_SECRET, { algorithms: ["HS256"] });
# confirm lodash's dangerous functions are actually called, and by whom
$ grep -rn "_\.template\|zipObjectDeep\|defaultsDeep" app/src
app/src/jobs/export.js:6: const render = _.template(coverSheetTpl);
# confirm merchant.display_name is attacker-influenced, not server-generated
$ grep -n "displayName" app/src/routes/merchants.js
app/src/routes/merchants.js:5: const { displayName } = req.body; // no sanitization, merchant sets this themselvesThat last grep closes the loop: a merchant can PATCH /merchants/me with any displayName they like, it lands in merchant.display_name with no sanitization, and the export job later feeds that exact value into _.template() — lodash's CVE-2021-23337 sink, reachable end to end from a request a merchant controls. jwt.verify(), by contrast, has exactly one call site in the whole codebase, and it passes a fixed environment-variable string, never a dynamically resolved key or a JWKS callback — the specific mechanism CVE-2022-23529 depends on — and it already restricts verification to one symmetric algorithm, which is exactly what closes off CVE-2022-23540's algorithm-confusion path. The dependency itself carries both CVEs regardless of how it's used; Vulnerly's own usage simply never walks into either vulnerable branch.
| CVE | Package | Scanner severity | On CISA KEV? | Reachable in Vulnerly? | Verdict |
|---|---|---|---|---|---|
| CVE-2022-23529 | jsonwebtoken@8.5.1 | CRITICAL | No | No — no dynamic key resolver anywhere in the codebase | Suppress, documented |
| CVE-2022-23540 | jsonwebtoken@8.5.1 | HIGH | No | No — algorithms explicitly pinned to ["HS256"] | Suppress, documented |
| CVE-2021-23337 | lodash@4.17.15 | HIGH | No | Yes — merchant-set displayName flows into _.template() | Upgrade |
| CVE-2020-8203 | lodash@4.17.15 | HIGH | No | Same resolved copy as the row above — not independently invoked by name in Vulnerly's own code | Upgrade (closes along with the row above) |
The scanner's own severity column would have you fix the CRITICAL first and leave the two HIGHs for later — exactly backwards from what actually matters here. Neither jsonwebtoken CVE is on KEV, neither has a meaningfully elevated EPSS score, and neither vulnerable code path is reachable in this specific codebase — three independent signals, all pointing the same direction, none of them the CVSS number. Treating a raw severity column as a priority order without checking any of the other three is the single most common triage mistake this exercise exists to correct.
Fixing the one dependency that actually matters
☺ Like you're 10: Out of four suspects, only one had the means, the motive, and the opportunity — fix that one for real, not the loudest one.
lodash@4.17.15 → 4.17.21 is a patch-level bump with no breaking API changes, closing both CVE-2021-23337 and CVE-2020-8203 in one move — the second one rides along on the same fix regardless of whether it was independently confirmed reachable, because there's only one resolved copy of lodash in this tree to begin with, not the diamond-dependency split the SCA blueprint chapter warns can leave a "fixed" package still vulnerable at a second, nested depth. Confirm that with npm ls before and after, since a bump that only touches the top-level manifest and leaves a nested copy behind is exactly that trap:
{
"name": "vulnerly-api",
"dependencies": {
"express": "^4.19.2",
"pg": "^8.11.5",
"dotenv": "^16.4.5",
- "lodash": "4.17.15",
+ "lodash": "4.17.21",
"jsonwebtoken": "8.5.1"
}
}$ npm ls lodash vulnerly-api@1.0.0 └── lodash@4.17.15 # before $ npm install $ npm ls lodash vulnerly-api@1.0.0 └── lodash@4.17.21 # after — one copy, one resolved version, nothing nested elsewhere $ trivy fs --scanners vuln --severity CRITICAL,HIGH --exit-code 0 app # CVE-2021-23337 and CVE-2020-8203: gone. CVE-2022-23529 and CVE-2022-23540: still here — expected, jsonwebtoken untouched.
jsonwebtoken stays pinned at 8.5.1 for now, deliberately. The real fix — 9.0.0 — is a breaking major version (it changes default algorithm handling and tightens what verify() accepts), and forcing that migration today, with no reachable vulnerability actually driving it, is exactly the reflexive "upgrade everything the scanner mentions" behavior this exercise is built to push back against. It gets suppressed instead — with real, checkable reasoning, next.
Suppressing the rest — with reasoning, not a blanket ignore
☺ Like you're 10: "Not exploitable," with nothing underneath it, isn't an answer — it's a hope that nobody ever asks the follow-up question.
Trivy reads a plain-text .trivyignore in the same convention the Trivy tool page already covers — one CVE ID per line, with comment lines above it. What that page doesn't show is what "a real reason" actually has to look like versus what it's tempting to write under deadline pressure. Here's the second one first, so the contrast is unmistakable:
# BAD — a blanket ignore. This is exactly what this part's done-when # criteria, and the check below, are built to catch and reject: CVE-2022-23529 CVE-2022-23540 # not exploitable
And here's the file that actually ships — two entries, two genuinely different arguments, because the two CVEs fail for two different mechanisms and a shared one-line excuse would paper over that:
# CVE-2022-23529 — jsonwebtoken arbitrary-code-execution via a dynamically
# resolved verification key (e.g. a JWKS lookup keyed by attacker input).
# Vulnerly's only jwt.verify() call site is app/src/middleware/auth.js:7,
# which passes a single static string from process.env.JWT_SECRET — never
# a callback, never a per-request key lookup. The vulnerable code path
# requires a dynamic key resolver that does not exist anywhere in this
# codebase. grep -rn "jwt.verify" app/src confirms one call site, reviewed
# by hand 2026-08-17. Revisit if auth.js ever moves to asymmetric keys or
# a JWKS endpoint, or at the next planned jsonwebtoken major-version bump.
CVE-2022-23529
# CVE-2022-23540 — jsonwebtoken algorithm-confusion when a verifier
# doesn't restrict which signing algorithms it accepts. Same call site,
# app/src/middleware/auth.js:7, already passes an explicit allowlist —
# { algorithms: ["HS256"] } — so an attacker cannot re-sign a token with
# a different algorithm and have it accepted. Reviewed 2026-08-17. Revisit
# under the same conditions as CVE-2022-23529 above.
CVE-2022-23540Length alone doesn't prove a reason is true — but it's a cheap, real floor against the blank or one-line version, which is the failure mode that actually shows up under deadline pressure. A small script, run as its own CI step, enforces that floor mechanically rather than relying on a reviewer to notice a thin comment:
#!/usr/bin/env bash
# scripts/check-suppression-reasoning.sh — rejects a .trivyignore entry with
# no substantive comment above it. Length isn't truthfulness — this doesn't
# replace a human reading the PR — but it stops the empty-reason and the
# one-word-reason failure mode outright, before a reviewer even opens the file.
set -euo pipefail
file="${1:-.trivyignore}"
comment=""
fail=0
while IFS= read -r line; do
if [[ "$line" =~ ^#\ (.*) ]]; then
comment+="${BASH_REMATCH[1]} "
elif [[ "$line" =~ ^CVE- ]]; then
if [[ ${#comment} -lt 60 ]]; then
echo "BLOCKED: $line has no substantive justification above it (${#comment} chars, need >=60)"
fail=1
fi
comment=""
fi
done < "$file"
exit $failUsing OWASP Dependency-Check instead, the same two decisions become suppression.xml entries in the schema that page covers — a packageUrl match scoped to the package, an until expiry so the acceptance doesn't quietly outlive its own review date, and the identical reasoning in the <notes> block:
<?xml version="1.0" encoding="UTF-8"?>
<suppressions xmlns="https://jeremylong.github.io/DependencyCheck/dependency-suppression.1.3.xsd">
<suppress until="2026-12-31Z">
<notes><![CDATA[
CVE-2022-23529 — jsonwebtoken dynamic-key-resolution RCE. Vulnerly's
only jwt.verify() call site (app/src/middleware/auth.js) passes a
static process.env.JWT_SECRET string, never a dynamic key resolver
or JWKS callback — the vulnerable code path is never reached.
Reviewed 2026-08-17. Revisit if auth.js changes to asymmetric keys.
]]></notes>
<packageUrl regex="true">^pkg:npm/jsonwebtoken@.*$</packageUrl>
<cve>CVE-2022-23529</cve>
</suppress>
<suppress until="2026-12-31Z">
<notes><![CDATA[
CVE-2022-23540 — jsonwebtoken algorithm-confusion. Same call site
already restricts verification to { algorithms: ["HS256"] }, so an
attacker cannot substitute a different signing algorithm. Reviewed
2026-08-17.
]]></notes>
<packageUrl regex="true">^pkg:npm/jsonwebtoken@.*$</packageUrl>
<cve>CVE-2022-23540</cve>
</suppress>
</suppressions>"Suppress the rest" was never permission to make the finding disappear quietly. Every suppressed CVE in this file still names the exact call site, the exact mechanism that makes it unreachable, the date it was reviewed, and the specific condition that would reopen the question. A teammate — or you, eleven months from now — can check every one of those claims against the actual source in about the time it takes to run the two grep commands above. That checkability is the entire difference between a documented risk decision and a vulnerability with extra steps.
Generating an SBOM on every build
☺ Like you're 10: Whether the gate lets today's build through or stops it cold, write down exactly what's inside it either way.
Syft catalogs the exact resolved dependency tree — the same one npm ls confirmed above, not the loose ranges in package.json — into a CycloneDX file. Run it locally once to see the shape before wiring it into CI:
curl -sfL https://raw.githubusercontent.com/anchore/syft/main/install.sh | sh -s -- -b /usr/local/bin syft dir:./app -o cyclonedx-json=sbom.cdx.json grep -A1 '"name": "lodash"' sbom.cdx.json # "name": "lodash", # "version": "4.17.21"
That last check is worth doing by hand once: an SBOM that still lists lodash@4.17.15 after your "fix" means the bump never actually landed in the lockfile Syft is reading — the exact diamond-dependency failure mode the SCA blueprint chapter covers, showing up here as a concrete, checkable fact instead of an abstract warning.
Flipping the gate from report-only to build-blocking
☺ Like you're 10: Now that you've actually read the whole backlog and decided what each finding means, it's safe to let the gate start saying no.
Add sca-scan to ci.yml, alongside Part 2's secrets-scan and sast-scan. The scan step itself changes exactly one thing from the report-only version above — --exit-code 0 becomes --exit-code 1, gated by the ignorefile you just wrote:
# .github/workflows/ci.yml — building on Part 2's required checks
jobs:
secrets-scan:
# ...Part 2 — gitleaks, required
sast-scan:
# ...Part 2 — Semgrep, required
sca-scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Trivy
run: curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/bin
- name: SCA scan — dependency vulnerabilities
run: >
trivy fs --scanners vuln app
--severity CRITICAL,HIGH --exit-code 1
--ignorefile .trivyignore --format table
- name: Suppression file must carry real reasoning
if: always()
run: chmod +x scripts/check-suppression-reasoning.sh && ./scripts/check-suppression-reasoning.sh .trivyignore
- name: Generate SBOM
if: always()
run: |
curl -sfL https://raw.githubusercontent.com/anchore/syft/main/install.sh | sh -s -- -b /usr/local/bin
syft dir:./app -o cyclonedx-json=sbom.cdx.json
- name: Upload SBOM artifact
if: always()
uses: actions/upload-artifact@v4
with:
name: sbom-${{ github.sha }}
path: sbom.cdx.jsonThe two if: always() lines matter as much as anything above them: an SBOM only attached to builds that already passed tells you nothing about the build that just failed, which is exactly backwards — the failing build is the one you'd most want an inventory of, to answer "did this ever ship" the moment someone asks next quarter. Then, the same way Part 2 added secrets-scan and sast-scan, add sca-scan as a required status check under the repo's branch protection rule — Settings → Branches → Branch protection rule → Require status checks to pass — so the gate isn't opt-in.
Proving the gate actually blocks
☺ Like you're 10: Don't just believe the lock works because you installed it — push on the door yourself and watch it hold.
Two separate things need proving here, and it's tempting to only do the first. Prove both, or you haven't actually verified the gate — you've verified that it exists.
# 1) Prove the gate blocks a real regression git checkout -b prove-sca-gate # revert app/package.json's lodash line back to "4.17.15", commit, push git commit -am "temp: reintroduce vulnerable lodash to prove the gate blocks it" git push -u origin prove-sca-gate # open a PR — sca-scan fails on CVE-2021-23337 and CVE-2020-8203, both unsuppressed # confirm the SBOM artifact is STILL attached to this failing run # now restore the real fix and push again git checkout main -- app/package.json app/package-lock.json git commit -am "restore lodash@4.17.21" git push # sca-scan passes; a fresh SBOM artifact is attached to this run too # 2) Prove the reasoning check blocks an undocumented suppression echo "CVE-2022-99999" >> .trivyignore # no comment above it, on purpose ./scripts/check-suppression-reasoning.sh .trivyignore # BLOCKED: CVE-2022-99999 has no substantive justification above it (0 chars, need >=60) git checkout .trivyignore # discard the test line
Both checks matter for different reasons. The first proves sca-scan genuinely blocks a real, previously-fixed regression rather than passing silently the way a forgotten --exit-code 0 would. The second proves the guardrail this part's done-when actually cares about — a suppression file that's technically present but hollow — gets caught mechanically, not left to whoever happens to review the next PR closely enough to notice.
What "done" looks like for Part 3
☺ Like you're 10: A gate that says no to a real problem, a paper trail that explains every finding it lets through, and a receipt for what shipped either way.
At the end of this part: lodash upgraded to 4.17.21 in both package.json and the lockfile, closing CVE-2021-23337 and CVE-2020-8203 for real; a .trivyignore with exactly two entries, each naming its own call site, its own mechanism, its own review date, and its own reopening condition; a sca-scan job that's a required, merge-blocking status check; a reasoning-length check that fails on a blank or one-line suppression; and a CycloneDX SBOM attached to every single build, whether it passed or failed. Nothing here is thrown away. Part 4 opens by rewriting the Dockerfile against this exact dependency tree — the one your SBOM now describes precisely — and signing the resulting image so nothing after this point ships without a traceable origin.
Benny: Report's in. One critical, three highs. I'll knock out the critical first — that's jsonwebtoken, right?
Timmy: Before you touch anything — is that critical actually reachable, or are we about to force a major-version bump on a path nobody can walk into?
Foxy: Good question. What's the one call site again? Show me, don't just tell me.
Benny: auth.js, line 7. Static secret from the environment, algorithm pinned to HS256. Huh — the vulnerable path needs a dynamic key lookup we don't have.
Rocky: I flagged that dependency by version back in Part 1 — I didn't check whether the call site was actually exploitable. That's fair, that's not what a threat model checks.
Timmy: Right. So it gets suppressed — with the call site, the mechanism, and a date, not "not exploitable" and a shrug. And the lodash finding, the one that's only HIGH on paper, gets fixed for real.
Nutty: I'll be checking that suppression file in about six months, when I file Part 7's compliance evidence. If it just says "fine" with no reasoning, that's not evidence — that's a vulnerability wearing a disguise.
Benny: Understood. Writing the actual reason now, not the fast version.
Milestones
☺ Like you're 10: Tick a box only once you've actually watched it happen on your own screen — a step that "sounds right" isn't the same as one you've verified.
Work these in order — each depends on the state the one before it leaves. Progress saves in this browser.
trivy fs --scanners vuln --exit-code 0 --format table app, with no blocking flag yet.api.first.org/data/v1/epss for each CVE, and grep CISA's KEV feed for all four IDs.jwt.verify()'s one call site and what it passes; confirm _.template()'s one call site and trace displayName back to an unsanitized request body.lodash to 4.17.21 in package.json, run npm install, and confirm with npm ls lodash that exactly one resolved copy exists at the new version.trivy fs run no longer reports CVE-2021-23337 or CVE-2020-8203..trivyignore with real, per-finding reasoning.trivyignore, with no other context, could explain why each finding is safe to ship without asking you a follow-up question.scripts/check-suppression-reasoning.sh exactly as shown, and run it locally against your own .trivyignore.syft dir:./app -o cyclonedx-json=sbom.cdx.json, then grep the output for lodash's listed version.lodash@4.17.21, not the old 4.17.15.sca-scan to ci.yml as build-blocking, plus the SBOM step--exit-code 1, the --ignorefile flag, and both SBOM-related steps set to if: always().main shows sca-scan running as a check.sca-scan a required status checkmain → require sca-scan to pass before merging, alongside Part 2's two checks.sca-scan is red, with no override available to anyone but an admin.lodash pin, and a test suppression line with no comment above it.1. Why did the scanner's own severity column rank the wrong finding as most urgent, and what three additional signals corrected that ranking? 2. Walk through exactly how you confirmed CVE-2021-23337 was reachable and CVE-2022-23529 was not — name the specific files and lines. 3. Why does jsonwebtoken stay pinned at 8.5.1 instead of getting bumped to 9.0.0 along with lodash? 4. What does the reasoning-length check in scripts/check-suppression-reasoning.sh actually verify, and what does it explicitly not verify? 5. Why do the SBOM-generation and upload steps in ci.yml both need if: always()?
Check your answers
- CVSS alone scored the two
jsonwebtokenCVEs highest (CRITICAL and HIGH), while bothlodashCVEs scored HIGH — a raw severity read would fixjsonwebtokenfirst. Checking EPSS, CISA's KEV catalog, and a manual reachability grep against Vulnerly's own source flipped that: neitherjsonwebtokenCVE's vulnerable mechanism exists in Vulnerly's one call site, whilelodash's dangerous function is reachable end-to-end from an unsanitized, merchant-controlled field. grep -rn "_\.template\|zipObjectDeep\|defaultsDeep" app/srcfound exactly one call, inapp/src/jobs/export.js, passingmerchant.display_name— traced back throughapp/src/routes/merchants.jsto an unsanitizedreq.body.displayNamea merchant sets themselves viaPATCH /merchants/me.grep -rn "jwt\.\(verify\|sign\)" app/srcfound exactly one call, inapp/src/middleware/auth.js:7, passing a staticprocess.env.JWT_SECRETstring and an explicit{ algorithms: ["HS256"] }allowlist — neither the dynamic-key-resolution path CVE-2022-23529 depends on, nor the unrestricted-algorithm path CVE-2022-23540 depends on, exists at that call site.- Because neither of its two CVEs is reachable in Vulnerly's actual usage, and the fix is a breaking major-version bump with real migration cost (changed default algorithm handling, tighter
verify()input requirements). Forcing that migration with no reachable vulnerability actually driving it is the reflexive "upgrade everything the scanner names" behavior this exercise is built to push back against — a documented, dated suppression is the more honest outcome here. - It verifies that a substantive-length comment (at least 60 characters) sits directly above every bare CVE line in
.trivyignore, catching the blank-reason and one-word-reason failure modes mechanically. It explicitly does not verify that the reasoning is true — a long, confident, wrong explanation would still pass. That's why the script's own comment calls it a floor, not a replacement for a human actually reviewing the PR. - Because an SBOM only attached to builds that already passed tells you nothing about the build that just failed — and the failing build, the one
sca-scanjust blocked, is exactly the one you'd most want a precise inventory of if someone later asks "did this version ever get built or shipped."if: always()makes sure that question has an answer regardless of which way the scan came out.
Part 3 leaves vulnerly with a build-blocking SCA gate, a documented suppression file a script actually checks, and a CycloneDX SBOM on every build. Continue to Capstone Part 4 — Harden & Sign the Container, where Benny rewrites the Dockerfile against this exact dependency tree and Pip refuses to let the resulting image deploy unsigned. Or step back to the full capstone hub, revisit Part 2, or read Software Composition Analysis in Depth and Vulnerability Management & Triage for the theory behind what you just built.