Part 2 of a 6-post series on vulnerability management. Part 1 covered why most VMPs fail. This one is about the scanner architecture itself: what to scan, how to keep the noise manageable, and where you’ll end up building something yourself.

Most VMP writing starts with “pick a tool.” That’s the wrong altitude. Tool selection is downstream of the architecture decisions — what’s in scope, where scanning happens, how findings are deduped and enriched, and what actually reaches the queue engineering has to work.

I designed the scanner surface for a fully custom, budget-constrained VMP I recently built end to end. No enterprise scanner license, no fancy CNAPP contract, no security team of five to babysit tuning. Just a set of open-source scanners wired into a central aggregator, and a lot of decisions about what to leave out.

Here’s how that architecture came together, what I got right, and one thing I’d change if starting over.


What’s Actually in Scope

Coverage decisions are the first big trade-off. Everything you scan produces findings; every finding costs engineering time. The question isn’t “what could we scan” but “what’s worth the cost of scanning.”

The surface I ended up with:

  • IaC (Terraform primarily) — misconfigurations at the point they’re introduced
  • SCA (application dependencies) — CVEs in third-party libraries
  • Secrets — credentials committed to source, both freshly-added and historical
  • Dockerfiles — base image freshness, root user, obvious build-time misconfigs
  • ECR (container images) — post-build image scanning against known CVEs
  • SBOM-driven news aggregator — a workflow that reconciles new CVE announcements against packages already installed across the estate

What I deliberately kept out:

  • DAST — a real discipline in its own right; a VMP that pretends to be a DAST program is worse than one that doesn’t try
  • Runtime agents — the operational overhead and licensing cost weren’t justified at this scale; the reconciliation problem (which image is actually running where) got solved a different way
  • Cloud posture scanning (Prowler / Steampipe / native tooling) — this was covered by a separate security audit cadence, and folding it into the VMP would have muddled the shared-responsibility model between the security and platform teams

That last decision matters. A VMP that swallows every kind of security signal becomes a firehose. Keeping it scoped to code, dependencies, secrets, and images meant engineering knew what to expect from it and what came through other channels.


Where the Scanning Happens

Three tiers, each in the place it belongs.

CI/CD (Trivy). IaC, SCA, secrets, and Dockerfiles all scan inside the existing build pipeline. This is the right place for them: findings surface at the moment the code changes, before anything ships, and the developer who introduced the issue is still in context. Trivy runs against the checkout, results funnel into the central aggregator, and the pipeline fails loudly on new critical findings while warning on the rest.

Registry (Trivy on ECR). Container image scans run post-push against the registry. This catches vulnerabilities in base images and system packages that don’t come from application dependency files. Same reporting sink as CI/CD.

Orchestration and news (n8n). SBOM collection across repositories runs as an n8n workflow. Same platform handles the CVE news aggregator: it pulls new CVE announcements from feeds and cross-references against the SBOM inventory. When a package that’s actually installed somewhere gets a fresh CVE, a finding gets created in the aggregator just like anything else. n8n gets used a lot in this stack — it’s the low-cost automation glue that means nobody has to babysit a bespoke Python service for scheduling and workflow orchestration.

Central aggregator (DefectDojo). Everything funnels into DefectDojo as the single source of truth. Findings from CI/CD, findings from ECR, findings from the news aggregator — one system, one dedup layer, one prioritization pipeline. DefectDojo pushes tickets to Jira, which is where engineering already works.

The pattern here is that no scanner is a source of truth on its own. Every scanner writes into DefectDojo; DefectDojo is what the process operates on. This matters because it lets you change scanners later without changing the process — and it lets you dedupe across scanners that would otherwise flag the same thing three times.


The Deduplication Layer

Multiple scanners find the same thing. Without a dedup layer, engineering gets three tickets for one CVE.

DefectDojo’s custom deduplication selectors are the mechanism I used. Two selectors, one per finding type:

  • SCA selector: identity key is package name + package version + CVE ID. Different scanners describing the same vulnerable dependency collapse into one finding.
  • IaC selector: identity key is rule ID + file path + resource identifier. The same misconfiguration flagged by two rulesets or two runs becomes one finding, not two.

The choice of identity key does most of the work. Too broad and you’ll collapse findings that shouldn’t be collapsed (the same CVE in two different services is genuinely two problems if the fix is separate). Too narrow and you get the exact duplicates you were trying to prevent.

SCA is the easier case: CVE IDs are canonical, package names and versions are stable identifiers. IaC is genuinely harder because different scanners describe the same misconfiguration in different words. For anything more exotic than SCA and IaC, expect to iterate on the identity key over time as edge cases show up.

The important thing is that dedup happens in the aggregator, not in individual scanners. Scanners are naive by design — they find things. The aggregator is where the reasoning lives.


Enrichment: EPSS + Tags

Raw CVSS is a bad prioritization signal on its own. A CVSS 9.8 without exploit code sitting on a service with no internet exposure and no sensitive data is a lower priority than a CVSS 7.5 with active exploitation on a customer-facing service handling PII.

Two enrichment layers turn raw findings into actionable priority:

EPSS. The Exploit Prediction Scoring System gives you a probability that a CVE will be exploited in the next 30 days. Layering EPSS onto CVSS separates “high-severity theoretical” from “high-severity actively exploited.” Findings with EPSS above a threshold jump the queue regardless of the CVSS value.

Tag-driven criticality. Each repository gets tags describing its security posture: internet-exposed, handles-pii, internal-only, sandbox, etc. Those tags feed into the criticality calculation for findings originating in that repo. An internet-exposed + handles-pii service bumps every finding’s priority; a sandbox project deprioritizes findings that would otherwise trigger an SLA clock.

This turns a CVSS × EPSS × exposure × data sensitivity calculation into automatic priority instead of manual triage. Which is what “prioritization that actually works” means in practice — the queue engineering sees is already reasoned about, not raw.

The specifics of the prioritization model deserve their own post (that’s Part 3 in this series). For scanner architecture purposes, the point is: enrichment happens at ingestion, driven by data the aggregator has access to. If you have to enrich manually per finding, you don’t have a working pipeline — you have a manual triage queue with more steps.


The Secrets Scanner Problem

Trivy Community Edition’s secrets scanner is regex-only. It matches strings that look like credentials. It doesn’t try them.

This is a real problem in a VMP. A regex-only scanner in a CI pipeline is a noise generator: dozens of false positives for every real secret. AWS access keys that were rotated a year ago still trigger. Test fixtures with obvious dummy values trigger. Base64-encoded strings that happen to match the shape of a JWT trigger. Engineering learns to ignore the secrets scanner, which means they’ll miss the real ones when they show up.

Two options: accept the noise (and watch the secrets scanner become the finding type nobody looks at), or add a validation layer.

I built one. CredWatch takes the same regex-surfaced candidates and actually tries them against the relevant service. Revoked keys fail silently. Active keys confirm. Only the confirmed ones become findings that reach the queue. The noise drops to near zero, the signal jumps from “someone check this” to “this credential works right now, rotate it immediately.”

The general lesson is worth writing down: coverage without validation is a noise pipe. If a scanner can’t tell you whether the thing it flagged is real, either it needs a validation layer downstream, or you’re using it in the wrong place. Regex-based secrets scanning is fine for a filesystem sweep where a false positive costs 30 seconds. It’s poison in a CI pipeline where an unvalidated flag blocks a merge or drowns the two real findings among 200 dead keys.

If a scanner in your stack fails this test, either add validation or move it out of the blocking path. Don’t just accept the noise.


What I’d Change Starting Over

Ownership and posture metadata belongs in the repository, not in the aggregator.

The tag-based enrichment layer works, but the tags currently live centrally — attached to repositories in the scanner/aggregator configuration. This has two problems that get worse over time: tags drift out of sync with reality (a service becomes internet-exposed and nobody updates its tag), and onboarding a new repository into the VMP is a configuration task outside the code, done by the security team.

If I were starting again, I’d put a security metadata file in every repository — something like .security.yml — that declares exposure, data sensitivity, and ownership as code. The scanner pipeline reads that file at scan time and propagates its contents into DefectDojo. Any change to security posture is a PR the owning team makes, reviewed like any other code change. Onboarding a new repo means dropping the file in.

This is a small architectural shift with a large process consequence: it moves security metadata from something a central team maintains to something engineering owns as code. Which is the right place for it — the team building a service knows its exposure and data sensitivity better than a central function does, and they know it now, not last quarter when someone last audited the tag list.

The rest of the architecture I’d keep. The three-tier scanning model (CI/CD, registry, orchestration), DefectDojo as the aggregator, custom dedup selectors, EPSS-plus-tag prioritization — all of that earns its keep. The tag layer just wants to live in the repo, not outside it.


Key Takeaway

Scanner architecture for a working VMP is less about tool selection and more about three decisions:

  1. Scope. Say no to categories you can’t do well. A tightly-scoped VMP that runs is worth more than a comprehensive one that drowns.
  2. Deduplication. Findings feed into a single aggregator with per-type identity keys. Dedup is the aggregator’s job, not the scanners'.
  3. Enrichment. Raw CVSS is a bad prioritization signal alone. Add EPSS for exploitability, tags for exposure and data sensitivity, and let the ingestion layer do the reasoning so engineering sees a queue that’s already thought-through.

You can build all of this on open-source scanners (Trivy for CI, n8n for orchestration, DefectDojo for aggregation) plus one scanner you’ll probably end up writing yourself (mine was for secrets validation). No enterprise contract required. The architectural decisions are what make it work — not the badge on the tool.

Next in this series: prioritization that actually works — moving beyond CVSS as a triage input.