ExplanationsVulnerability ManagementVulnerability Matching

Vulnerability Matching

When you upload an SBOM (Software Bill of Materials) to DevGuard, the system automatically scans all components to identify known vulnerabilities. This document explains how DevGuard’s vulnerability matching engine works.

Continuous Re-Scanning

DevGuard does not just scan your SBOM once. Every SBOM stored in DevGuard is automatically re-scanned whenever the vulnerability database is updated. This means:

  • New vulnerabilities are detected retroactively — if a CVE is published for a dependency you already use, DevGuard will find it without requiring a new scan from your CI/CD pipeline.
  • Fixed vulnerabilities are updated — when a vulnerability entry is corrected or withdrawn, your results are updated accordingly.
  • No action required — re-scanning happens server-side. As long as your SBOM is stored in DevGuard, it stays current against the latest threat intelligence.

This is a key advantage of server-side matching over client-side scanners: your security posture is continuously evaluated, not just at the point of scanning.

💡

Important Note There is no single source of truth for vulnerability matching. Key steps such as component identification, vulnerability database lookup, and version comparison lack internationally agreed-upon standards. As a result, different scanners may use different identifiers for the same component, and vulnerability databases may apply incompatible versioning schemes.

DevGuard mitigates these challenges by adopting the Package URL (PURL) standard for component identification and by supporting multiple versioning schemes. Wherever possible, DevGuard aligns with the standards and conventions used by the osv.dev vulnerability database.

Overview

The matching process consists of several steps:

  1. SBOM Parsing: The uploaded SBOM (CycloneDX format) is parsed into a graph structure
  2. Component Extraction: Each component with a Package URL (PURL) is extracted
  3. Database Query: Components are matched against DevGuard’s vulnerability database
  4. Version Comparison: Version ranges are evaluated to determine if a component is affected
  5. Result Aggregation: Matched vulnerabilities are collected and deduplicated

The Matching Pipeline

Step 1: SBOM Scanner

The sbomScanner iterates through all components in the SBOM graph. For each component that has a valid Package URL (PURL), it queries the vulnerability database:

SBOM Graph → Component Extraction → PURL Parsing → Vulnerability Lookup

Components without a PURL are skipped since they cannot be matched against the database.

Step 2: PURL Parsing and Normalization

DevGuard uses the Package URL (PURL) standard for component identification. When a PURL is parsed, DevGuard creates a match context that determines how to search the database:

Version TypeDescriptionExample
Semantic VersionStandard semver format1.2.3, 2.0.0-beta.1
Exact VersionNon-semver strings matched exactly20240101, custom-build
Ecosystem SpecificPlatform-specific versioning (deb, rpm, apk)2.47.3-0+deb13u1, 1.2.3-r4
Empty VersionNo version specifiedComponents without version info

The PURL is split into:

  • Search PURL: The package identifier without version (used for database lookup)
  • Normalized Version: The version string prepared for comparison
  • Qualifiers: Additional metadata like architecture, distro, or epoch

Step 3: Database Query

DevGuard maintains an affected_components table that stores vulnerability data from multiple sources. Each affected component record contains:

  • PURL without version: Package identifier for lookup
  • Ecosystem: The package ecosystem (e.g., Debian:12, Alpine:v3.22)
  • Type: Package type (e.g., golang, npm, deb, apk)
  • Version ranges: semver_introduced, semver_fixed, version_introduced, version_fixed
  • CVE associations: Links to CVE records with exploit and relationship data

The query is built dynamically based on the version interpretation type:

Semantic Version Matching

For semver packages, DevGuard uses range-based queries in the database:

WHERE purl = 'pkg:npm/lodash'
  AND (
    version = '4.17.10'
    OR (semver_introduced IS NULL AND semver_fixed > '4.17.10')
    OR (semver_introduced <= '4.17.10' AND semver_fixed IS NULL)
    OR (semver_introduced <= '4.17.10' AND semver_fixed > '4.17.10')
  )

This matches if:

  • The exact version is listed as affected
  • The version is below a fixed version (with no introduced version)
  • The version is at or above the introduced version (with no fixed version)
  • The version falls within the introduced-to-fixed range

Ecosystem-Specific Matching

For platform packages (Debian, Alpine, RPM), version comparison happens in Go code after the initial database query, using ecosystem-specific version parsers:

  • Debian (deb): Uses epoch-aware comparison (e.g., 1:2.47.3-0+deb13u1)
  • Alpine (apk): Supports Alpine-specific version suffixes (e.g., 1.2.3-r4)
  • RPM: Handles RPM version-release format

Ecosystem-specific versions often cannot be directly compared using semver rules. DevGuard uses specialized parsers for each ecosystem to ensure accurate version matching.

Step 4: Qualifier and Ecosystem Filtering

When a distro qualifier is present in the PURL, DevGuard filters results to match the specific distribution:

pkg:deb/debian/git@2.47.3?distro=debian-13.2

This is matched against affected components with ecosystem = 'Debian:13'.

For Alpine:

pkg:apk/alpine/curl@8.14.1-r2?distro=3.22.2

This matches ecosystem = 'Alpine:v3.22'.

Step 5: Version Range Verification

For ecosystem-specific packages, after retrieving potential matches from the database, DevGuard performs precise version checking using the CheckVersion function:

match, err := CheckVersion(
    component.Version,           // exact version if any
    component.VersionIntroduced, // first affected version
    component.VersionFixed,      // first fixed version
    lookingForVersion,           // version from SBOM
    component.Type,              // "deb", "rpm", or "apk"
)

A component is considered affected if:

  • It matches an exact affected version, OR
  • Its version is >= introduced AND < fixed

Step 6: CVE Deduplication

Vulnerabilities may be reported under multiple CVE IDs (aliases). DevGuard automatically deduplicates these:

  • If CVE-A aliases CVE-B (unidirectional), keep CVE-A
  • If CVE-A and CVE-B alias each other (bidirectional), keep the lexicographically smaller one

This prevents the same vulnerability from being counted multiple times.

Step 7: Path-Based Vulnerability Creation

After matching a CVE to a component, DevGuard does not simply create one vulnerability per (CVE, package) pair. Instead, it creates one vulnerability instance per dependency path that leads to the affected component.

Consider lodash@4.17.10 appearing twice in your dependency tree:

my-app → express → lodash@4.17.10       (path 1)
my-app → test-utils → lodash@4.17.10    (path 2)

DevGuard creates two separate vulnerability instances for the same CVE—one for each path. This distinction is critical for three reasons:

  1. Different risk profiles: Each path has a different depth, which affects the risk score. The express path may be production-critical while the test-utils path only runs in CI.
  2. Independent triage: You can mark the test-only path as “not affected” while prioritizing a fix for the production path. A blanket (CVE, package) model would force you into a single decision for both.
  3. Shareable VEX rules: When you dismiss a vulnerability, the dependency path records where in the chain the assessment applies. This makes it possible to share VEX assessments that other projects can reuse—and enables crowdsourced vulnerability assessment and automated reachability analysis tools to produce precise, generalizable results.

Duplicate Vulnerabilities Across Ecosystems

You may notice the same vulnerability appearing under different identifiers in your scan results — for example, a Go vulnerability listed as both GHSA-xxxx-xxxx-xxxx and GO-2025-xxxx. This is not a bug. It happens because the OSV.dev database aggregates vulnerability data from multiple ecosystem-specific sources, each of which may independently track the same issue:

SourceIdentifier FormatExample
GitHub Advisory DatabaseGHSA-xxxx-xxxx-xxxxGHSA-f6x5-jh6r-wrfv
Go Vulnerability DatabaseGO-YYYY-NNNNGO-2025-4134
NVDCVE-YYYY-NNNNCVE-2025-12345
Python Advisory DatabasePYSEC-YYYY-NNNNPYSEC-2025-42

Why does this happen?

Each ecosystem maintainer (Go team, GitHub, NVD, etc.) independently reviews and publishes advisories. A vulnerability in golang.org/x/crypto may be published by the Go team as GO-2025-4134 and separately by GitHub as GHSA-f6x5-jh6r-wrfv. OSV.dev links these as aliases, but they remain distinct entries.

Pros

  • More complete coverage — different sources may have different details. The Go advisory might include the exact affected function, while the GHSA entry might have the CVSS score. Together, they provide a fuller picture.
  • Faster detection — some sources publish advisories faster than others. Having multiple sources means vulnerabilities are detected as soon as any one source publishes.
  • Ecosystem-specific context — each entry may contain version ranges, fix versions, and severity scores specific to that ecosystem’s conventions.

Cons

  • Apparent duplicates in results — the same underlying issue can show up as multiple rows in the scan output, which can look noisy at first glance.
  • Different severity scores — the GHSA entry and the NVD entry may assign different CVSS scores, which can be confusing.

How DevGuard handles this

DevGuard uses the alias information from OSV.dev to deduplicate vulnerabilities during the matching process (see Step 6: CVE Deduplication above). However, both identifiers are still shown in the output so you can look up the original advisory in whichever source you prefer.

If you see a vulnerability with a Risk score of 0 alongside a related entry with a higher score, the zero-risk entry is typically from a source that does not provide CVSS data (e.g., Go advisories). DevGuard keeps both for traceability.

Example Matching Flow

Consider an SBOM containing:

pkg:npm/lodash@4.17.10
  1. Parse PURL: Extract npm, lodash, version 4.17.10
  2. Normalize Version: Convert to semver 4.17.10
  3. Query Database: Find affected components for pkg:npm/lodash
  4. Match Ranges: Check if 4.17.10 falls within any vulnerable range
  5. Return CVEs: Return CVE-2019-10744, CVE-2020-8203, etc.

For a Debian package:

pkg:deb/debian/openssl@3.0.13-1~deb12u1?distro=debian-12
  1. Parse PURL: Extract deb, debian, openssl, version 3.0.13-1~deb12u1
  2. Interpret Version: Ecosystem-specific (Debian)
  3. Query Database: Find affected components for pkg:deb/debian/openssl with ecosystem LIKE 'Debian:12%'
  4. Fetch Candidates: Get all potentially affected entries
  5. Version Check: Use Debian version parser to compare against each candidate
  6. Return CVEs: Return matching vulnerabilities