The Exam Blueprint · CDP · Chapter 9 of 9 · Vulnerability Management & Triage

Vulnerability Management & Triage

Every earlier chapter in this blueprint ended the same way: here's a scanner, here's how to run it, here's a trustworthy finding. None of them answered what happens next — and "next" is where most DevSecOps programs actually fail. Run Semgrep and a secrets scanner at commit, an SCA scan at build, a container and IaC scan before deploy, and a DAST pass before release, across even a modest number of repositories, and you get thousands of findings a week, a meaningful share of them the same underlying bug reported by two or three tools at once. This closing chapter covers how a platform like DefectDojo turns that flood into one system of record: the product hierarchy that organizes it, the deduplication logic that collapses four reports of one bug into one finding, the finding states that keep a triage decision from having to be remade every single day, and the severity-and-SLA workflow that decides — automatically, and durably across every rescan — what gets fixed this week, what gets tracked, and what gets formally accepted as a risk.

☺ Explain it like I'm 10

Imagine four different smoke detectors in your house, and every one of them screams the instant it smells anything at all — burnt toast, shower steam, a candle, an actual fire. If you just wire all four straight to one bell, you get four bells for one real fire, and you learn to tune the bell out entirely, including the one time it's real. Vulnerability management is putting one calm dispatcher between the smoke detectors and you: someone who notices three of those four bells are ringing about the exact same puff of smoke, merges them into a single report, and only wakes you up for the fire that's actually spreading — with a clock on the wall telling you exactly how fast you need to move.

🐢🐿️Your hosts for this chapter: Timmy the Turtle & Nutty the Squirrel — Timmy has spent this whole blueprint deciding what's severe enough to block a merge; Nutty is the one who actually files every finding away, notices when two scanners buried the same bug in different spots, and digs the right one back up the moment someone asks "didn't we already know about this?"

Chapter 9 of 9: the domain with no scanner of its own

☺ Like you're 10: Every earlier chapter handed you a report and stopped there. This is the only chapter that asks what happens to all of those reports afterward.

The same honesty note this blueprint has repeated at the start of every chapter matters more here than anywhere else: Practical DevSecOps doesn't publish a scored, percentage-weighted breakdown of its five live challenges, so the nine-chapter structure you've worked through is this course's own study framework, not an official domain weighting — confirm the current exam structure on the vendor's own page. Within that framework, this chapter closes the sequence for a structural reason: it's the only one with no scanner of its own to run. Chapters 1–2 set up the toolchain, chapter 3 decided where each gate physically sits, and chapter 5, chapter 4, chapter 6, chapter 7, and chapter 8 each produce their own stream of findings. This chapter is what happens once every one of those streams lands in the same place at the same time.

The scope here is deliberately narrow, given that everything upstream of it is already covered: (1) get findings that already exist into one system of record, (2) collapse duplicate reports of the same underlying bug into a single finding, (3) assign each surviving finding a severity and a service-level agreement, and (4) turn that into a ticket someone actually works — one that gets verified fixed, not merely marked closed. What this chapter does not re-cover: how to read a CVSS vector or an EPSS score, which is chapter 4's job, or how the resulting evidence gets packaged for an auditor, which is chapter 8's — this chapter is the layer sitting directly between the two.

The problem: four firehoses, one inbox nobody reads

☺ Like you're 10: One scanner's report is a to-do list you could actually read. Four scanners' reports, every day, across forty repositories, is a to-do list nobody could possibly read all of — so in practice, nobody does.

Picture one moderately-sized service running the pipeline this blueprint has been assembling: Semgrep and a secrets scanner at code review, an SCA scan against the resolved lockfile at build, a container and IaC scan before deploy, and a DAST pass against staging before release. Run daily, across even a modest forty-service organization, that's easily thousands of raw findings a week — and a meaningful fraction of them aren't forty distinct bugs. They're the same handful of real problems, reported repeatedly: the same vulnerable transitive dependency flagged by both Trivy's OS-package layer scan and OWASP Dependency-Check's language-level scan on one image; the same missing security header flagged by ZAP on every one of twelve near-identical staging endpoints; the same hardcoded credential caught once by a pre-commit hook and again by the pipeline's own gitleaks run against the exact same commit.

Without a layer that reconciles all of that, three things happen, in order, and they're predictable enough to plan against: the backlog's raw count stops meaning anything (is 4,000 open findings worse than 1,200, or just less deduplicated?), the same underlying bug gets triaged twice by two different people who reach two different conclusions, and — the failure mode that actually costs an organization something — everyone quietly stops opening the reports at all. That's alert fatigue, and it isn't a training problem. It's what reliably happens once a system produces more raw signal than any team could individually read, several times over, every single day.

Semgrep + gitleaks Ch. 5 · 212 findings/wk Dependency-Check + Trivy Ch. 4/7 · 340 findings/wk ZAP + Dastardly Ch. 6 · 89 findings/wk Checkov + tfsec Ch. 7 · 156 findings/wk Raw firehose — 797 findings this week ~40% is duplicate signal, not distinct bugs DefectDojo — Import / Reimport API dedup engine: hash_code · unique_id_from_tool One system of record — 480 unique findings ready for severity × SLA triage, next section

That 40% is not a made-up number to make the diagram dramatic — it's a conservative, commonly-cited real-world figure for how much of a multi-tool pipeline's raw output turns out to be duplicate reports of a smaller set of real issues, and it's exactly why deduplication earns its own section below rather than being treated as a footnote.

One system of record: DefectDojo's product hierarchy

☺ Like you're 10: Before you can file anything away neatly, you need labeled drawers. DefectDojo's drawers are Product Type, Product, Engagement, and Test — and every finding lives inside exactly one of each.

DefectDojo is an open-source vulnerability aggregation and management platform, an OWASP Flagship Project, purpose-built for exactly this job — ingest scanner output from dozens of tools, organize it, deduplicate it, and expose one queryable backlog instead of dozens of disconnected reports. Its data model is a strict hierarchy, and knowing where a finding sits in it is most of what "using DefectDojo well" actually means:

LevelWhat it representsWorked example
Product TypeA broad grouping — often a business unit, a portfolio, or a compliance boundary"Payments," "Internal Tools"
ProductOne deployable thing — usually maps 1:1 to a repository or a service"checkout-service"
EngagementA bounded period of testing against that product — a CI pipeline run, a sprint, a pen-test window"checkout-service · main · build #4821"
TestOne scanner's output within that engagement"Trivy Scan," "ZAP Scan," "Semgrep JSON Report"
FindingOne specific issue inside a Test — the atomic unit everything else in this chapter operates on"CVE-2024-XXXXX in follow-redirects@1.14.9"

That hierarchy is what makes cross-tool aggregation possible in the first place: a Trivy Test and an OWASP Dependency-Check Test scanning the same image both file into the same Engagement, under the same Product, which is exactly the scope the dedup engine (next section) compares within. Getting scan output into that structure is a pipeline step, not a manual upload — DefectDojo's REST API exposes an import-scan endpoint for a Test's first run and a reimport-scan endpoint for every run after that, keyed to the same Test so history accumulates instead of resetting:

# first run of this Test in this engagement
curl -s -X POST "https://defectdojo.internal.example.com/api/v2/import-scan/" \
  -H "Authorization: Token $DD_API_TOKEN" \
  -F "product_name=checkout-service" \
  -F "engagement_name=checkout-service · main · build #4821" \
  -F "scan_type=Trivy Scan" \
  -F "file=@trivy-report.json" \
  -F "minimum_severity=Low" \
  -F "active=true" \
  -F "verified=false"

# every run after that: reimport against the SAME test, not a fresh import
curl -s -X POST "https://defectdojo.internal.example.com/api/v2/reimport-scan/" \
  -H "Authorization: Token $DD_API_TOKEN" \
  -F "product_name=checkout-service" \
  -F "engagement_name=checkout-service · main · build #4821" \
  -F "test_id=$DD_TRIVY_TEST_ID" \
  -F "scan_type=Trivy Scan" \
  -F "file=@trivy-report.json"

That distinction between the two endpoints is worth memorizing on its own: import-scan creates a new Test and its findings from scratch. reimport-scan updates an existing Test — it's what makes "this finding is still open on day 12" and "this finding disappeared after the fix shipped, so DefectDojo auto-closed it" both possible without a human doing that bookkeeping by hand. Wiring the wrong one into a nightly pipeline run is a common, quiet mistake: point every run at import-scan and you get a fresh, disconnected Test every night instead of one Test with a real history — which breaks reimport-driven auto-close and the SLA aging this chapter builds toward.

DefectDojo ships parsers for well over a hundred scanners, covering essentially every tool this blueprint has named — the table below maps the ones already covered to the parser you'd select in scan_type. Parser names shift and multiply as DefectDojo adds and renames them between releases, so treat these as illustrative and confirm the current name in your installed version's Import Scan form before wiring a pipeline to it.

ToolChapter it's covered inTypical scan_type
SemgrepCh. 5"Semgrep JSON Report"
SpotBugs / SonarQubeCh. 5"SpotBugs Scan" / "SonarQube Scan"
gitleaks / TruffleHogCh. 5"Gitleaks Scan" / "Trufflehog Scan"
OWASP Dependency-CheckCh. 4"Dependency Check Scan"
npm audit / SafetyCh. 4"NPM Audit Scan" / "Safety Scan"
OWASP ZAPCh. 6"ZAP Scan"
Burp Suite DastardlyCh. 6a JUnit-family parser, since Dastardly emits JUnit XML
Trivy / Syft & GrypeCh. 7"Trivy Scan" / "Anchore Grype"
Checkov / tfsecCh. 7"Checkov Scan" / "Tfsec Scan"
OpenSCAPCh. 8an OpenSCAP-family parser, or a generic ARF/XCCDF import
Any SARIF-emitting toolseveral"SARIF" — the generic escape hatch for CodeQL and most modern SAST tools

Deduplication and finding states: the same bug, four different tools

☺ Like you're 10: Getting everything into one drawer doesn't help if the same paper is filed four times under four different names. This section is about noticing it's the same paper.

DefectDojo's deduplication engine decides whether a newly-imported finding is genuinely new or a repeat of one already on file, and it does that with one of a few configurable algorithms, set per scan type:

Two more settings decide how far dedup reaches. By default, DefectDojo deduplicates within one Engagement — findings from the Trivy Test and the Dependency-Check Test in the same build run get compared against each other. A "deduplicate across engagements" scope, configured at the Product level, is what lets a finding from today's build get matched against one still open from last week's, which is what actually makes the aging and SLA math in the next section mean anything — otherwise every new build would silently spawn a fresh copy of every still-unfixed finding. When two findings are judged the same, the older one stays Active and the newer one is automatically marked Duplicate, filed under the original rather than opening a second ticket for one bug.

Every finding also carries a status, and which status it's in is exactly what makes a triage decision durable instead of something re-litigated on every scan:

StateSet byWhat it means for the backlog and the next rescan
ActiveDefault on importCounts toward the open backlog and its SLA clock
VerifiedA human, after confirming the finding is realOften a prerequisite before it's pushed to a ticketing system — still counts toward SLA
False PositiveA triager, with a reason attachedExcluded from the backlog; on reimport, a matching hash/ID keeps it marked instead of reopening it
DuplicateThe dedup engine, automaticallyMerged under the original finding — no second ticket, no double-counted SLA breach
Risk AcceptedA formal Risk Acceptance record, with a business justification and an expiry dateExcluded from the active SLA clock until the acceptance expires and comes back for re-review
MitigatedIdeally, a rescan that no longer reproduces itClosed — the finding is gone from the backlog with an audit trail of when and how
Out of ScopeA triager, when a finding is real but outside this product's remitExcluded here; tracked against whichever product it actually belongs to

That "on reimport, a matching hash/ID keeps it marked instead of reopening it" line is the exact mechanism chapter 5 promised this chapter would explain: a triager who marks a finding False Positive today does not have to make that same call again tomorrow, next week, or after the next hundred commits, as long as the finding's dedup key stays stable across scans — which is precisely what a well-chosen hash_code or unique_id_from_tool configuration guarantees.

⚠ A dedup key tied to a line number breaks on the first unrelated commit

If a scan type's hash_code configuration includes something as volatile as an exact line number, an unrelated formatting change or an added import two lines above a finding shifts every line number below it — and every one of those findings reimports as "new," silently discarding every prior False Positive and Risk Accepted marking on the way. Prefer dedup keys built from stable identifiers (a CWE, a rule ID, a package coordinate, a normalized file path) over anything that shifts when unrelated code moves.

Severity plus SLA: turning a backlog into a schedule

☺ Like you're 10: Knowing how bad something is doesn't tell you when it has to be fixed by. This section is where "how bad" turns into an actual deadline on a calendar.

Chapter 4 already covered how to read a CVSS vector and stack an EPSS score and a CISA KEV hit on top of it to judge real-world likelihood — this chapter doesn't repeat that reasoning, it operationalizes it. A severity label sitting quietly in a backlog with no deadline attached isn't a triage decision, it's a tag. DefectDojo's SLA Configuration object, assignable per Product, attaches a day count to each severity and starts a clock the moment a finding becomes Active:

SeverityCommonly shipped default SLAEscalation trigger this chapter's earlier reasoning would add
Critical7 daysAutomatic if also on CISA's KEV catalog, regardless of the default window
High30 daysShortened if EPSS crosses a team-defined "likely exploited soon" threshold
Medium90 daysEscalated to High-track if it sits on an internet-facing endpoint
Low120 daysTracked, rarely escalated absent a KEV hit

Those day counts are commonly-shipped defaults, not a fixed standard — they're deliberately configurable per Product, because a Critical finding in an internal admin tool with no internet exposure and a Critical finding in a public payment API don't deserve the same clock, even though a scanner reports the same severity word for both. Once configured, DefectDojo's own reporting surfaces "days until SLA breach" per finding, and dashboards that roll findings up by age and severity are what actually let a team answer "are we getting better or worse at this," the same measurement discipline CALMS asks for everywhere else in a DevSecOps program.

⚠ Severity is not priority

This is the single most common mistake this chapter exists to correct, and it's the same one chapter 4 warned about at the CVSS-vector level: a Critical CVSS score sitting in code the application never actually calls is a lower real priority than a Medium sitting on a reachable, internet-facing endpoint with active exploitation reported. An SLA policy that only reads the severity field and ignores EPSS, KEV, and reachability is the same trap wearing a calendar instead of a number.

Closing the loop: from a finding to a fixed, verified ticket

☺ Like you're 10: Someone marking a ticket "done" and the bug actually being gone are two different facts. This section is about making sure the second one is what your dashboard actually believes.

An Active or Verified finding that's going to get worked needs to reach the people who fix things, in the tool they already live in — DefectDojo's Jira and GitHub Issues integrations push a finding out as a ticket automatically once it crosses a configured severity or SLA threshold, carrying the CVE, the CVSS vector, the affected file or endpoint, and a link back to the DefectDojo finding itself. That's the "risk-and-confidence-triaged noise into a fixed, tracked, closed ticket" pipeline chapter 6 promised this chapter would cover.

The trap sits on the way back. It's tempting to wire the sync bidirectionally and let closing the Jira ticket automatically flip the DefectDojo finding to Mitigated — but a ticket getting closed and a vulnerability actually being gone are two different facts, and only one of them is verifiable. An engineer can close a Jira ticket because they ran out of sprint, misjudged the fix, or fixed the wrong file. The trustworthy signal that a finding is actually resolved is the next scan: a reimport-scan that no longer reports that finding's dedup key is what DefectDojo uses to auto-close it as Mitigated with a real audit trail — code, fix, rescan, confirmed. A finding that comes back on a later scan after being marked Mitigated is automatically reopened, which is exactly the regression signal a status that trusted the ticket tracker alone would have silently missed.

Finding: CVE-2024-XXXXX in follow-redirects@1.14.9  ·  Severity: High  ·  SLA: 30d

  day 0   Active        → imported from Trivy Scan, SLA clock starts
  day 1   Verified      → triager confirms it's reachable, pushes to Jira PAY-4821
  day 9   (Jira PAY-4821 closed: "bumped dependency")
  day 9   Active        → still Active in DefectDojo — Jira closing alone changes nothing here
  day 11  reimport-scan → dependency still resolves to 1.14.9 in the lockfile
  day 11  Active        → correctly still open; the "fix" didn't actually land
  day 14  reimport-scan → dependency now resolves to 1.15.4
  day 14  Mitigated     → auto-closed by the rescan, not by the ticket status

That trace is the whole argument for treating a ticketing system's status as informational and a rescan as authoritative: day 9's Jira closure would have made a dashboard trusting ticket status alone report this finding as resolved for five extra days it was still exploitable.

Scaling past one repo: products, tags, and the metrics that matter

☺ Like you're 10: Everything above works for one service. This is what has to be true for it to still work once there are fifty.

Nothing about the hierarchy in this chapter's second section is repo-specific by accident — Product Type, Product, and tags exist so an organization with dozens of services can ask questions no single Test or Engagement could ever answer on its own: which product has the most SLA breaches this quarter, which Product Type is trending down in mean-time-to-remediate, which team's backlog is aging fastest. That's the aggregation chapter 5 pointed forward to when it said this chapter covers how dedup "scales past a single repo" — the same hash-based and tool-ID-based matching from the deduplication section applies identically whether the two findings being compared came from the same build or from two different products entirely, as long as the dedup scope is configured to look that far.

The metric that actually matters to a security or engineering leader reading a dashboard isn't the raw open-finding count — it's mean time to remediate (MTTR), tracked per severity, alongside the percentage of findings currently breaching their SLA. Those two numbers, trended sprint over sprint, are the direct security-domain analog of the deploy-frequency and lead-time metrics a DevOps team already tracks — which is exactly the "Measurement" pillar of CALMS this whole course started with, applied concretely rather than left as an abstract principle. The evidence those dashboards generate — a documented Risk Acceptance with an expiry date, an audit trail from Active through Verified to Mitigated, a reopened finding with its regression timestamp — is also exactly the artifact an auditor asks for, which is why this chapter feeds directly into chapter 8's compliance evidence pipeline rather than sitting apart from it.

Common exam traps

☺ Like you're 10: Most of the ways to lose points here aren't about not understanding triage — they're about a workflow that looks finished but quietly forgot to check the one thing that actually proves it.

🐿️ Nutty's drill · 15 min

Stand up DefectDojo locally (its own docker-compose setup is the fastest path), create one Product, and import two different sample reports — a Trivy JSON scan and an OWASP Dependency-Check XML report — against a container image with at least one shared vulnerable package between them. Check whether they land as two findings or dedup into one; if they don't dedup, look at each parser's configured algorithm and figure out why. Then mark one finding False Positive, reimport the exact same file, and confirm the marking survives. That's the entire chapter, in miniature, in about twenty minutes.

🎬 At the Shift-Left Squad
🦫

Benny: Dashboard says 1,900 open findings this week, down from 3,100 last week. That's real progress, right?

🐿️

Nutty: Or we just turned on cross-engagement dedup last Tuesday, and a thousand of those were never distinct bugs to begin with. Which changed — the bugs, or the counting?

🦫

Benny: ...the counting. Fair. Okay, separate thing — Jira ticket PAY-4821 is closed, so that follow-redirects CVE is fixed, yeah? I can mark it Mitigated.

🐢

Timmy: Did the rescan confirm it? A closed ticket tells me somebody stopped working on it. It doesn't tell me the vulnerable version is actually gone from the lockfile.

🦊

Foxy: Somebody should actually check, instead of trusting the ticket status.

🐿️

Nutty: Give it one more reimport cycle. If the finding's gone on its own, DefectDojo marks it Mitigated with the rescan as proof. If it's not — well, now we know before an auditor finds out for us.

🦫

Benny: ...it's still there. The bump only fixed the direct dependency, not the nested copy.

🐢

Timmy: Which is exactly why "the ticket's closed" was never the question. "Did the next scan agree" was.

✓ Checkpoint

1. Name the five levels of DefectDojo's data hierarchy, from broadest to most specific. 2. What's the practical difference between the import-scan and reimport-scan API endpoints, and what breaks if a pipeline always calls the former? 3. Name the three dedup algorithms this chapter covers and when each is the better choice. 4. Why does a False Positive marking need a dedup key built from stable fields, and what happens if the key includes something like an exact line number? 5. Why shouldn't closing a linked Jira ticket be allowed to automatically mark a DefectDojo finding as Mitigated?

Check your answers
  1. Product Type, Product, Engagement, Test, Finding — from a broad business-unit-level grouping down to one specific issue inside one scanner's output.
  2. import-scan creates a brand-new Test and its findings from scratch. reimport-scan updates an existing Test, which is what lets DefectDojo track a finding's age, auto-close it when a rescan stops reproducing it, and preserve triage markings like False Positive across runs. Always calling import-scan creates a disconnected Test on every run, breaking auto-close, SLA aging, and history entirely.
  3. hash_code (a computed hash from fields like title, CWE, and location — the default when a tool gives you no natural external ID), unique_id_from_tool (keyed off an identifier the scanner itself supplies, more precise when available — typical for SCA and container scans), and unique_id_from_tool_or_hash_code (tries the tool's ID first, falls back to the hash).
  4. Because the dedup key is what a rescan compares against to decide "is this the same finding I already triaged." If the key includes something volatile like an exact line number, an unrelated commit that shifts surrounding lines changes the key, so the next reimport treats a previously-triaged finding as brand new — silently discarding its False Positive or Risk Accepted marking and forcing a re-triage that shouldn't have been necessary.
  5. Because a closed ticket only proves someone stopped working on it, not that the underlying vulnerability is actually gone — an engineer can close a ticket for the wrong reason or ship an incomplete fix. Only a rescan that no longer reproduces the finding is verifiable proof, which is why DefectDojo ties Mitigated status to a clean reimport rather than to an external tracker's status field.

That closes all nine chapters of the Exam Blueprint. If you worked through it start to finish, revisit the CDP study plan to see how these nine chapters map onto an actual prep schedule, drill the material against the practice challenge bank and the mock exams, and keep the command & tool reference open while you do. For the platform-level view of what this chapter's findings feed into once they're compliance evidence rather than an engineering backlog, see maturity models: DSOMM, SAMM & BSIMM, and for the tool this whole chapter is built around, the DefectDojo tool page and the wider tool landscape.