Tools Used in DevSecOps · OWASP Dependency-Check

OWASP Dependency-Check

OWASP Dependency-Check is the software composition analysis engine that stayed deliberately old-school while most of the SCA market moved toward proprietary, curated vulnerability databases. It doesn't buy or maintain its own intelligence feed. It builds a local mirror of the U.S. National Vulnerability Database (NVD), reads an identity off every dependency it can find using Common Platform Enumeration (CPE) strings, and matches one against the other. That design is exactly why the Certified DevSecOps Professional curriculum names it directly as the SCA tool to know cold: it's free, it's self-hostable, it runs entirely offline once its data is cached, and the way it can be wrong is worth understanding precisely, because the matching underneath it is a heuristic guess wearing a lookup's clothing.

☺ Explain it like I'm 10

Imagine a security guard checking people against a watchlist using nothing but a written description — "tall, dark jacket, glasses" — because that's the only kind of record the watchlist keeps. Most of the time that's good enough to catch the right person. But sometimes a completely innocent stranger fits the same rough description, and gets stopped for nothing. OWASP Dependency-Check works the same way with your dependencies: it usually can't scan an exact, unmistakable barcode for every library, so it builds its best guess of "who made this and what is it" out of scraps of evidence — a filename, a line buried in a manifest file — and once in a while that guess is confidently, specifically wrong.

🐢Your host for this topic: Timmy the Turtle — the guardrail from SAST, DAST & SCA is back, this time taking apart the one gate in his lineup that occasionally accuses an innocent dependency and needs a written, dated reason before he'll let anyone wave it through.

What Dependency-Check is, and the problem it solves

☺ Like you're 10: Two jobs live inside every SCA tool: figuring out exactly what you're running, and checking whether that thing is on a danger list. Dependency-Check does both itself, for free, using a public government database as its danger list.

Dependency-Check is an OWASP flagship project, created and long maintained by Jeremy Long, first released in the early 2010s and still under active development. It's Apache-2.0 licensed and ships in several forms that all share the same core engine: a standalone CLI (dependency-check.sh / dependency-check.bat), a Maven plugin, a Gradle plugin, an Ant task, and a Jenkins plugin — pick whichever one matches how the rest of your build already runs, not a separate tool with separate rules.

The problem it solves is a genuine mismatch most SCA discussions skip past. Ecosystem-native tools like npm audit or the GitHub Advisory Database work because their advisories are keyed to exact package-registry coordinates — this npm package name, this version range, this CVE — a clean lookup with almost no ambiguity. The NVD was never built that way. It was built decades ago to track vulnerabilities in operating systems and commercial software inventories, and it identifies affected products with CPE strings — cpe:2.3:a:apache:log4j:2.14.1:*:*:*:*:*:*:* — assigned by NVD analysts, not by package maintainers, and with no concept of "Maven coordinate" or "npm package" baked in anywhere. Dependency-Check's whole reason for existing is bridging that gap: turning a JAR, a wheel, or an assembly sitting on disk into a CPE-shaped identity guess, so it can be checked against a database that predates every modern package ecosystem it now gets asked to cover. See SAST, DAST & SCA for where SCA sits as one-third of a pipeline scan trio, and Software Composition Analysis in Depth for how dependency graphs resolve and how CVSS/EPSS turn a finding into a triage decision — this page goes one tool deeper into the mechanism both of those pages take as given.

◆ Key idea

Dependency-Check's core trade is that it's free and it works against a database that was never designed for this job. Everything else on this page — the analyzer pipeline, the false-positive patterns, the suppression workflow that exists as a first-class feature rather than an afterthought — is the tool managing that one structural mismatch.

How CPE matching actually works — and where it produces false positives

☺ Like you're 10: It can't always read an exact barcode, so it reads the label instead and makes its best guess at who made the product — and every guess is a chance to guess wrong.

Every scan runs a pipeline of analyzers, one per artifact type — a Jar Analyzer and Archive Analyzer for Java, an Assembly Analyzer for .NET, analyzers for Python's requirements.txt/Pipfile, Node's package-lock.json, Ruby's Gemfile, Go modules, and more, each enabled or disabled depending on what a scan finds in the target directory. Each analyzer's job is to extract evidence — candidate vendor, product, and version strings — from whatever it can read: a JAR's MANIFEST.MF attributes (Implementation-Vendor, Implementation-Title), an embedded pom.properties file, the artifact's own filename, or package-registry metadata where one exists. Every piece of evidence carries a confidence level — HIGH, MEDIUM, or LOW — reflecting how much the analyzer trusts where that string came from; a filename guess is weighted far less than an exact embedded Maven coordinate.

One analyzer gets a genuine shortcut around all that guessing: the Central Analyzer hashes a JAR with SHA-1 and queries Maven Central's search API directly. If the hash matches an unmodified artifact that's actually published on Central, Dependency-Check gets back the exact GroupId:ArtifactId:Version coordinate — no fuzzy matching required, and false-positive risk drops sharply for that dependency. That lookup needs outbound network access at scan time, and it only works for artifacts published to Central unmodified — a vendored, patched, or internally rebuilt JAR gets none of that benefit and falls back to ordinary evidence collection.

Everything else feeds into CPE identification: the collected evidence, weighted by confidence, drives a fuzzy search — built on a Lucene index — against a local copy of the NVD's own CPE dictionary. The best-scoring candidate CPE is accepted as this dependency's identity, and that CPE is then used to query the local mirror of the NVD's CVE data for anything that lists it as affected. Two independent guesses, evidence-to-CPE and CPE-to-CVE, stacked on top of each other — and the first of those two guesses is where nearly every false positive in this tool's history has come from.

From an artifact on disk to a finding — or a false positive checkout.jar the artifact under scan Evidence collection MANIFEST.MF · pom.properties · filename weighted HIGH / MEDIUM / LOW confidence Central Analyzer (optional) SHA-1 → Maven Central search API exact GAV if unmodified — skips the guessing Candidate CPE construction Lucene fuzzy search against the local NVD CPE dictionary Correct CPE match CVE lookup in the local NVD mirror → a real, actionable finding Wrong / ambiguous CPE match name collision · shaded jar · version mismatch → flags the wrong, or an unrelated, product

Three shapes of false positive account for nearly everything this pipeline produces:

⚠ Watch out

A clean Dependency-Check report is not the claim "nothing in this build has a known CVE." It's the narrower claim "nothing we could confidently CPE-match had a known CVE." A dependency whose manifest evidence is too sparse to build any confident CPE candidate produces silence, not a finding — the same category of blind spot a suppressed false positive creates on purpose, except this one nobody chose. Reading "0 vulnerabilities" as "this build is safe" skips exactly the step this whole section just walked through.

The suppression-file workflow

☺ Like you're 10: When the guess is wrong, you don't turn the whole guesser off — you write down, in one specific sentence, exactly which guess was wrong, why, and when to look again, and only that one guess goes quiet.

Because false positives are a known, expected cost of CPE-based matching rather than a rare bug, Dependency-Check ships a first-class suppression file mechanism instead of leaving teams to fork the tool or silence it entirely. A suppression file is XML, validated against the project's own suppression schema, and it can live locally in the repo or be fetched from an https:// URL at scan time — which is what lets a security team maintain one organization-wide suppression list that every repository's pipeline pulls centrally, rather than each team hand-copying entries.

<?xml version="1.0" encoding="UTF-8"?>
<suppressions xmlns="https://jeremylong.github.io/DependencyCheck/dependency-suppression.1.3.xsd">

  <!-- FALSE POSITIVE: CPE-name collision. This is Spring Framework,
       matched against an unrelated product sharing the same vendor
       string in the NVD's own CPE dictionary. Matches by CPE, so it
       will keep applying across future versions too — reviewed on
       every dependency bump as a matter of habit, not just once. -->
  <suppress>
    <notes><![CDATA[
      False positive — CPE vendor/product collision, not our Spring
      Framework artifact. See go/odc-fp-log entry #14. Reviewed 2026-06-02.
    ]]></notes>
    <packageUrl regex="true">^pkg:maven/org\.springframework/spring\-core@.*$</packageUrl>
    <cpe>cpe:/a:pivotal_software:spring_framework</cpe>
  </suppress>

  <!-- RISK ACCEPTANCE, not a false positive: real CVE, unreachable code
       path. Pinned to the EXACT file by SHA-1, so upgrading the jar at
       all invalidates the suppression automatically. Also time-boxed —
       it expires and the finding reappears if nobody revisits it. -->
  <suppress until="2026-12-31Z">
    <notes><![CDATA[
      CVE-2023-XXXXX: annotation processor is compile-time only, never
      packaged into the runtime artifact. Ticket SEC-4821. Revisit at
      next major upgrade or expiry, whichever comes first.
    ]]></notes>
    <sha1>3D2E5C7A1B4F9E0C6A8D2B1F4E7C9A0D3B5F8E12</sha1>
    <cve>CVE-2023-XXXXX</cve>
  </suppress>

</suppressions>
MatcherIdentifies the suppression byUse it when
<sha1>The exact file hash of one specific artifactYou want the suppression to stop applying the instant the dependency is upgraded — the safest, narrowest option
<packageUrl regex="true">A package-URL (purl) pattern, optionally as a regexYou want a false-positive suppression to keep applying across a whole family of versions without re-adding it every bump
<cpe>The specific (wrong) CPE identifier that was matchedNarrowing an already-scoped packageUrl or gav match to one specific bad identification
<gav> / <filePath regex="true">A Maven groupId:artifactId:version pattern, or a path patternEcosystem- or layout-specific matching, e.g. everything under a known-vendored directory
<cve> / <vulnerabilityName>One or more specific CVE IDs or advisory namesScoping the suppression to exactly the CVEs you've actually reviewed, not every future one on the same component
<cvssBelow>A CVSS score ceilingBlanket-suppressing low-severity noise on a component you've already accepted broadly (use sparingly — it's the least specific option)

Notice the deliberate asymmetry the two examples above show. A <sha1>-pinned suppression forces a re-review on every version bump, because the hash simply stops matching — the safest default for a genuine risk-acceptance decision. A <packageUrl> or bare <cpe> match keeps silently applying to every future version of that dependency, which is exactly the convenience you want for a confirmed false positive (you shouldn't have to re-suppress the same wrong guess every release) and exactly the risk you don't want for a real, accepted vulnerability (a newly-introduced, unrelated CVE on that same component would vanish just as quietly). Pick the matcher for what you're actually suppressing, not out of habit. The HTML report itself helps here — most current releases print a ready-to-copy suppression snippet, pre-filled with the finding's own hash and identifiers, right next to each result, which is the fastest way to get the matcher fields right without hand-typing a SHA-1.

⚠ A suppression with no notes and no date is a vulnerability with extra steps

Every <suppress> block should carry a <notes> explanation and, for anything that isn't a confirmed false positive, an until expiry date. An entry with neither is indistinguishable, months later, from a finding nobody ever actually reviewed — see Software Composition Analysis in Depth for the same rule applied generally across every SCA tool's suppression mechanism, not just this one.

Running it as a build step: Maven, Gradle, and the CLI

☺ Like you're 10: Three different doors into the same house — pick whichever one matches how the rest of your build already runs.

Maven

The dependency-check-maven plugin is the natural fit for a Java build, because it can read the resolved dependency tree Maven itself already computed instead of re-resolving anything. Bind the check goal to the verify phase so it runs after packaging, alongside your integration tests; a multi-module reactor should use the aggregate goal instead, so every submodule's dependencies land in one consolidated report rather than one report per module.

<plugin>
  <groupId>org.owasp</groupId>
  <artifactId>dependency-check-maven</artifactId>
  <version>10.0.4</version>  <!-- check Maven Central for the current release before pinning -->
  <configuration>
    <nvdApiKey>${env.NVD_API_KEY}</nvdApiKey>
    <suppressionFiles>
      <suppressionFile>config/suppression.xml</suppressionFile>
    </suppressionFiles>
    <formats>
      <format>HTML</format>
      <format>SARIF</format>
    </formats>
    <failBuildOnCVSS>7</failBuildOnCVSS>
  </configuration>
  <executions>
    <execution>
      <goals><goal>aggregate</goal></goals>  <!-- use "check" instead for a single-module build -->
    </execution>
  </executions>
</plugin>

# runs automatically in the verify phase:
$ mvn verify
# or invoke the goal directly, without running the rest of the build:
$ mvn org.owasp:dependency-check-maven:check

Gradle

The equivalent Gradle plugin applies with the same shape of configuration block, and it exposes its work as ordinary Gradle tasks rather than a single bound goal — which matters, because it lets you separate "refresh the local NVD cache" from "actually scan and gate," a distinction the operations section below leans on.

plugins {
    id 'org.owasp.dependencycheck' version '10.0.4'  // check the Gradle Plugin Portal for current
}

dependencyCheck {
    failBuildOnCVSS = 7
    suppressionFile = 'config/suppression.xml'
    formats = ['HTML', 'JSON', 'SARIF']
    nvd {
        apiKey = System.getenv('NVD_API_KEY')
    }
    data {
        directory = System.getenv('ODC_DATA_DIR') ?: "${System.getProperty('user.home')}/.dependency-check-data"
    }
}

# ./gradlew dependencyCheckAnalyze   — the main scan + gate task
# ./gradlew dependencyCheckUpdate    — refresh the local NVD cache only, no scan
# ./gradlew dependencyCheckAggregate — one report across a multi-project build
# ./gradlew dependencyCheckPurge     — delete the local cache and start clean

Standalone CLI

Outside a JVM build — a polyglot repo, a Node or Python service, or CI running the tool as an isolated step against a set of build artifacts — the CLI covers the same ground directly, and every flag above has a direct equivalent:

$ dependency-check.sh \
    --project "checkout-service" \
    --scan ./target/*.jar \
    --format ALL \
    --suppression config/suppression.xml \
    --failOnCVSS 7 \
    --nvdApiKey "$NVD_API_KEY" \
    --data /var/cache/odc-data \
    --out ./odc-report

--format ALL writes every supported format — HTML, XML, CSV, JSON, JUNIT, and SARIF — into --out in one pass, so downstream consumers (a human reading the HTML report, a JUnit-format test-result panel in CI, DefectDojo's Dependency Check Scan import parser reading the XML) each get the shape they want without a second run. On Windows, the same binary ships as dependency-check.bat with identical flags.

Day-to-day operations: the NVD API key, caching, and offline scans

☺ Like you're 10: The first run has to copy the whole danger list to your own shelf before it can check anything against it — do that copying once, on purpose, instead of by accident every single time.

Before a scan can match anything, Dependency-Check needs a local copy of the relevant slice of the NVD's CVE and CPE data, stored by default in an embedded H2 database. Since NIST introduced its NVD API 2.0 and tightened rate limits on unauthenticated access, running without a registered API key means a severely throttled trickle of requests — a first-time database population that can take hours, or simply time out inside a CI job's default timeout window. Registering a free key at nvd.nist.gov/developers/request-an-api-key and passing it via --nvdApiKey / nvdApiKey / nvd.apiKey raises that ceiling substantially and is close to a hard requirement for using this tool in CI at all.

The second half of the same problem is caching. An ephemeral CI runner that starts from a clean filesystem on every job re-downloads and rebuilds that entire local mirror from scratch every single run — slow, wasteful, and exactly the kind of thing a persistent cache action or mounted volume exists to prevent. The more production-grade pattern goes one step further: point every scanning job's --data (or data.directory) at a shared database server — Dependency-Check supports MySQL, PostgreSQL, Oracle, and SQL Server as alternatives to the embedded H2 file — refresh it on its own schedule with a dedicated update-only job, and have every actual scan run in read-only mode against that already-warm cache instead of managing its own.

# a scheduled job (nightly cron, or a dedicated CI pipeline) — refreshes
# the shared cache; does NOT scan anything, so it never fails a build
$ dependency-check.sh --updateonly --nvdApiKey "$NVD_API_KEY" --data /shared/odc-data

# every actual pipeline scan — reads the already-warm cache, never touches the network for data
$ dependency-check.sh --project checkout --scan ./target/*.jar \
    --data /shared/odc-data --noupdate --failOnCVSS 7 --out ./odc-report

# fully air-gapped: pre-populate --data on a machine with internet access, ship the
# directory into the isolated environment, and --noupdate makes sure it never tries to reach out
◆ Key idea

Every naive Dependency-Check setup makes the same mistake once: treating the local NVD mirror as disposable per-job state instead of shared, deliberately-refreshed infrastructure. One warm cache updated on a schedule, many read-only scans against it, is the difference between a two-minute pipeline step and a pipeline step that spends most of its time re-downloading a government database.

Gotchas and failure modes

☺ Like you're 10: Most of the surprises trace back to one of two things: the guess in the middle being wrong, or the local copy of the danger list not being ready yet.

Where it sits against Snyk, Syft & Grype, and npm-audit-style scanners

☺ Like you're 10: Every SCA tool trades the same two things against each other — how it knows what's vulnerable, and how sure it can be about what you're actually running — just in different proportions.

The real question is rarely "which SCA tool is best" in the abstract — it's which matching basis fits the ecosystem and the false-positive tolerance of the pipeline in front of you.

ToolMatching basisFalse-positive profileCost / offline
OWASP Dependency-CheckEvidence-derived CPE identity, fuzzy-matched against the NVDHigher — CPE identification is a genuine heuristic guess; managed via suppression filesFree, self-hosted, fully offline once its data is cached
SnykA proprietary, curated vulnerability database mapped to exact package-registry coordinatesLower — identity comes from the package manifest itself, not an inferred guessCommercial; hosted service by default, with usage tiers and IDE/PR integration
Syft & GrypeSyft's own SBOM of exact package coordinates, scanned by Grype against several aggregated vulnerability feedsLow for the SBOM-identified packages — the identification step is a real inventory, not a guessFree, open-source, and can run fully offline against a pre-built SBOM
npm audit / GitHub Advisory DatabaseDirect registry lookup — the exact package name and version from the lockfile against an advisory keyed the same wayLowest of the four — no identity inference step exists at allFree, and built into the npm CLI itself, but scoped to its own ecosystem only

Dependency-Check's honest niche is the combination no other row in that table matches at once: zero licensing cost, genuinely offline-capable once the cache is warm, and broad multi-ecosystem coverage through one engine — paid for with a false-positive rate the ecosystem-native and commercially-curated alternatives don't carry, because they were built around exact identity instead of an inferred one. A mature pipeline commonly runs it precisely where that trade pays off (Java and .NET builds with no free ecosystem-native advisory feed as good as npm's or PyPI's) and lets an exact-match tool cover ecosystems that already have one. See Software Bills of Materials for how Syft's SBOM-first approach avoids the identity-guessing step structurally rather than managing it after the fact, and Vulnerability Management & Triage for how a Dependency-Check finding and a Trivy or Grype finding on the same underlying package get deduplicated into one entry instead of two tickets for one bug.

🎬 At the Shift-Left Squad
🦫

Benny the Beaver: Dependency-Check just failed my build over a Critical on spring-core. I checked the CPE it matched — it's not even our Spring Framework, it's some completely unrelated product.

🐢

Timmy the Turtle: That's the classic CPE collision. Don't turn the gate off — write the suppression, with the actual reason, and move on.

🦊

Foxy: Wait, how do you know it's a false positive and not just a scary-looking real finding you'd rather not deal with?

🦫

Benny: Because the CPE it matched belongs to a totally different vendor. Same vendor/product string, unrelated software — I checked the dictionary entry myself before touching anything.

🐢

Timmy: And use packageUrl for that one, not a sha1 pin — it's a wrong guess about the identity itself, not a risk you're accepting on this one version. It should keep applying next release too.

🐿️

Nutty the Squirrel: Which means it still goes in the archive with a note and today's date, exactly like any other suppression. "Wrong CPE, confirmed by reading the dictionary entry" is a perfectly good reason — as long as it's written down.

🦝

Rocky the Raccoon: I'll still go double-check it myself later. "The CPE was obviously wrong" is exactly the kind of confident claim I go looking for holes in.

✓ Checkpoint

1. Walk through the two guesses Dependency-Check makes to turn a JAR into a finding, in order. 2. Name two of the three common causes of CPE-matching false positives this page covers, and give a one-sentence description of each. 3. In a suppression file, what's the practical difference between suppressing by <sha1> versus by <packageUrl> or bare <cpe> — and which one should you use for a genuine risk-acceptance decision versus a confirmed false positive? 4. Why does this tool need a registered NVD API key to work well in CI, and what's the shared-cache pattern that keeps every pipeline job from re-downloading the whole database? 5. Name the Maven goal used for a single-module build versus the one used for a multi-module reactor.

Check your answers
  1. First, evidence collected from the artifact (manifest attributes, embedded pom.properties, filename, or an exact Maven Central match via the Central Analyzer) is fuzzy-matched against the local NVD CPE dictionary to guess the artifact's identity. Second, that guessed CPE is used to look up known CVEs against the local NVD CVE mirror. Both steps are guesses; the first is where nearly all false positives originate.
  2. Any two of: name/vendor-product collisions in the CPE dictionary (a generic evidence string matching an unrelated product that happens to share the same vendor/product name); shaded/uber/fat JARs mixing several libraries' evidence together, producing a CPE that doesn't correspond to any single real dependency (or losing the real vulnerable component's evidence entirely); and version-string granularity mismatches between the dependency's actual version and what the matched CPE entry expects.
  3. A <sha1> suppression is pinned to one exact file — upgrading the dependency at all invalidates it automatically, forcing a fresh review. A <packageUrl> or bare <cpe> suppression keeps applying across every future version of that dependency without being re-added. Use <sha1> for a genuine risk-acceptance decision on a real vulnerability (so it doesn't silently keep suppressing an unrelated future CVE on the same component); use <packageUrl>/<cpe> for a confirmed, structural false positive you don't want to re-suppress every release.
  4. Since the NVD introduced API 2.0 and tightened unauthenticated rate limits, scanning without a registered key means a severely throttled first-time database population that can take hours or time out in CI. The shared-cache pattern points every scanning job's data directory at one centrally-maintained database (or a shared external database server), refreshed on its own schedule by a dedicated update-only job, so ordinary scans read an already-warm cache in read-only mode instead of each rebuilding their own copy from scratch.
  5. check for a single-module build; aggregate for a multi-module Maven reactor, so every submodule's dependencies land in one consolidated report instead of one report per module.

This page is the tool-level companion to Software Composition Analysis in Depth, which covers dependency-graph resolution, transitive vulnerabilities, and reading CVSS/EPSS to triage exactly the kind of finding this tool produces. For the SBOM half of "what's actually in this build," see Software Bills of Materials and Dependency & License Risk Management; for how a Dependency-Check finding gets deduplicated against everything else your pipeline runs, see Vulnerability Management & Triage and the DefectDojo tool page. And for the wider SCA landscape this tool sits inside, see The Tool Landscape.