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.
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.
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.
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:
| Level | What it represents | Worked example |
|---|---|---|
| Product Type | A broad grouping — often a business unit, a portfolio, or a compliance boundary | "Payments," "Internal Tools" |
| Product | One deployable thing — usually maps 1:1 to a repository or a service | "checkout-service" |
| Engagement | A bounded period of testing against that product — a CI pipeline run, a sprint, a pen-test window | "checkout-service · main · build #4821" |
| Test | One scanner's output within that engagement | "Trivy Scan," "ZAP Scan," "Semgrep JSON Report" |
| Finding | One 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.
| Tool | Chapter it's covered in | Typical scan_type |
|---|---|---|
| Semgrep | Ch. 5 | "Semgrep JSON Report" |
| SpotBugs / SonarQube | Ch. 5 | "SpotBugs Scan" / "SonarQube Scan" |
| gitleaks / TruffleHog | Ch. 5 | "Gitleaks Scan" / "Trufflehog Scan" |
| OWASP Dependency-Check | Ch. 4 | "Dependency Check Scan" |
| npm audit / Safety | Ch. 4 | "NPM Audit Scan" / "Safety Scan" |
| OWASP ZAP | Ch. 6 | "ZAP Scan" |
| Burp Suite Dastardly | Ch. 6 | a JUnit-family parser, since Dastardly emits JUnit XML |
| Trivy / Syft & Grype | Ch. 7 | "Trivy Scan" / "Anchore Grype" |
| Checkov / tfsec | Ch. 7 | "Checkov Scan" / "Tfsec Scan" |
| OpenSCAP | Ch. 8 | an OpenSCAP-family parser, or a generic ARF/XCCDF import |
| Any SARIF-emitting tool | several | "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:
hash_code— DefectDojo computes a hash from a configurable set of fields (commonly title, CWE, description, and file path or endpoint) and treats two findings with a matching hash as the same finding. This is the default for most SAST- and DAST-style parsers, where there's no single natural external identifier.unique_id_from_tool— dedup keys off an identifier the scanning tool itself supplies (a rule ID plus a stable location, or a CVE plus a package coordinate), which is more precise than a computed hash when the tool actually gives you one — most SCA and container-scan parsers use this.unique_id_from_tool_or_hash_code— tries the tool's own ID first and falls back to the computed hash, a pragmatic middle ground when a tool's IDs are only sometimes present.
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:
| State | Set by | What it means for the backlog and the next rescan |
|---|---|---|
| Active | Default on import | Counts toward the open backlog and its SLA clock |
| Verified | A human, after confirming the finding is real | Often a prerequisite before it's pushed to a ticketing system — still counts toward SLA |
| False Positive | A triager, with a reason attached | Excluded from the backlog; on reimport, a matching hash/ID keeps it marked instead of reopening it |
| Duplicate | The dedup engine, automatically | Merged under the original finding — no second ticket, no double-counted SLA breach |
| Risk Accepted | A formal Risk Acceptance record, with a business justification and an expiry date | Excluded from the active SLA clock until the acceptance expires and comes back for re-review |
| Mitigated | Ideally, a rescan that no longer reproduces it | Closed — the finding is gone from the backlog with an audit trail of when and how |
| Out of Scope | A triager, when a finding is real but outside this product's remit | Excluded 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.
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:
| Severity | Commonly shipped default SLA | Escalation trigger this chapter's earlier reasoning would add |
|---|---|---|
| Critical | 7 days | Automatic if also on CISA's KEV catalog, regardless of the default window |
| High | 30 days | Shortened if EPSS crosses a team-defined "likely exploited soon" threshold |
| Medium | 90 days | Escalated to High-track if it sits on an internet-facing endpoint |
| Low | 120 days | Tracked, 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.
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 statusThat 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.
- Treating raw finding counts as a trend. "We went from 3,000 to 1,800 open findings" means nothing on its own if the dedup configuration, the scan scope, or the set of enabled scanners changed in between. Compare like scopes, or compare nothing at all.
import-scanon every run instead ofreimport-scan. Creates a fresh, disconnected Test every time instead of one Test with real history — breaks auto-close on fix, breaks SLA aging, and silently multiplies "new" findings that were never actually new.- A dedup key built on a volatile field. Line numbers, timestamps, or anything else that shifts on an unrelated commit will cause a stable finding to reimport as "new," discarding every prior False Positive or Risk Accepted marking on the way — see the warning above.
- Letting a ticket tracker's status drive the finding's status. A closed Jira ticket is not proof of a fix. Only a rescan that no longer reproduces the finding is — trust the reimport, not the ticket.
- Suppressing without an expiry. A Risk Acceptance with no review date is functionally identical to a finding nobody ever revisits — the same failure mode chapter 4 flagged for an undated suppression file entry, just one layer up the stack.
- One SLA table for every product. A Critical in an internet-facing payment API and a Critical in an internal admin tool are not the same real-world risk even when the scanner reports the identical severity word — SLA configuration is meant to be tuned per product, not copy-pasted once and forgotten.
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.
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.
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
- Product Type, Product, Engagement, Test, Finding — from a broad business-unit-level grouping down to one specific issue inside one scanner's output.
import-scancreates a brand-new Test and its findings from scratch.reimport-scanupdates 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 callingimport-scancreates a disconnected Test on every run, breaking auto-close, SLA aging, and history entirely.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), andunique_id_from_tool_or_hash_code(tries the tool's ID first, falls back to the hash).- 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.
- 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.