DAST, SAST, SCA, container and IaC will each report the same root cause differently. Deduplication is not cosmetic - it is what makes the number on the dashboard mean something.
Point five security disciplines at one application and the same weakness comes back five times, worded five different ways. SCA names a CVE and a package version. SAST names a file and a line number. DAST names a URL and a parameter. A container scan names an OS package inside a layer. An imported third-party pentest names the same thing again in its own format. Five rows, one root cause - and if your platform counts them as five problems, the number on the dashboard stops meaning anything.
This is the dedup problem, and it is not cosmetic. The backlog count drives the SLA clock, the risk band, the remediation campaign and the board slide. Inflate it five-fold and every metric built on top inherits the lie. The fix is a fingerprint - but the tempting version of that fix is the one that quietly makes things worse.
The obvious move is one universal identity key: hash the CWE, the application, the URL and the parameter, and call anything with a matching hash a duplicate. It looks clean. It is wrong, because it only describes one kind of finding. A dependency CVE has no URL and no parameter. A SAST flaw lives at a file and a line, not an endpoint. A missing security header is one host-wide misconfiguration, not a per-page bug. Force all of them through a URL+parameter formula and two things break at once.
Findings that should merge never collide, because the fields the formula reads are empty for half of them - so the SCA row and the imported pentest row for the same Log4Shell stay separate. And findings that should stay distinct get crushed together, because a header alert raised on three hundred crawled URLs has three hundred different URLs and no parameter. One formula cannot be the identity of five different kinds of fact.
apPosture's fingerprint is source-aware. `fingerprint(finding, application_id)` does not apply one formula; it picks the key from the fields the finding actually carries, so each class of finding is identified by what genuinely makes it unique. A dependency finding is its CVE on its component. A code finding is its weakness class at its file and line. A site-wide passive alert is its name on its host. A dynamic finding keeps the historical URL+parameter scheme. The branch is chosen by inspecting the finding, not configured by the user.
The keys are versioned. Every fingerprint is prefixed `v{FINGERPRINT_VERSION}|...`, so if the scheme ever changes the old and new identities are distinguishable and history is never silently re-merged. The exact branches, read straight from the engine:
| Source class | Identity key | Why that key |
|---|---|---|
| Dependency (SCA / container) | cve | component | The same CVE on the same package is one weakness wherever it is reported - this is the cross-tool merge key for our SCA and an imported report. |
| Advisory, no CVE | advisory-id | component | A GHSA or curated id on a package, so two tools citing the same advisory still merge while distinct advisories stay apart. |
| Code (SAST) | vuln_class | file:line | A source flaw is identified by what it is and exactly where it lives in the tree. |
| Site-wide passive | name | host | A missing header or banner leak is one host-global misconfiguration, collapsed once per host instead of once per crawled URL. |
| Dynamic (DAST / Nuclei) | vuln_class | normalized_url | parameter | The historical runtime scheme: weakness class at a path with sorted parameter names, host and values dropped. |
Two details in that last row matter. The URL is normalized - the path with its parameter names sorted, while the scheme, host and concrete values are dropped - so the same bug found through two different links does not split into two records. And because native DAST findings carry no component and no file path, they fall through to exactly the same key they always had, which means adding the other branches changed no existing dynamic fingerprint.
Here is the inversion that makes dedup worth doing. In a naive system, more scanners reporting the same issue means a longer list. In a correlated system, more scanners reporting the same issue means higher confidence in a single item. The Vulnerability record stores the set of distinct sources that saw it - `sources=[sca, trivy, dast]` - and the risk score reads that set as corroboration.
Concretely, the risk score starts from the severity band, then adds points when the evidence is stronger: an exploit confirmed against the running app, the application running in production, and - the relevant part here - a bonus when at least two distinct sources independently land on the same weakness. Agreement across tools is signal, so it lifts the item up the queue rather than splitting it into more rows. The list gets shorter and the things left on it get more trustworthy at the same time.
Collapsing duplicates is the floor, not the ceiling. SAST and DAST findings of the same class deliberately land in separate vulnerabilities, because file:line and URL are genuinely different identities - one says where in the code, the other says where on the wire. The hybrid pass then connects them: when DAST has proven a class exploitable on an application and SAST has located that same class in source, the source-side vulnerability is marked exploit-confirmed and reachable. SAST says where to fix it; DAST proves it is real. That pairing is the highest-confidence finding a security team can hold, and it rises straight to the top of the funnel.
The same machinery quietly ends the double-counting between your own scans and outside work. An imported third-party pentest merges onto your records on the shared CVE or advisory, so an external assessment stops re-inflating a backlog your own tools already cover. One record per root cause, carrying every source that observed it, every location it touches, and the strongest proof anyone produced.
That is the whole point of an ASPM unit of work. The dashboard count is no longer the sum of how many tools you happen to run; it is the number of real problems you actually have. A posture you can reason about, instead of a pile of alerts you have stopped reading.