I wanted a simple answer for our repos. Which dependencies have known vulnerabilities, which of those are actually malicious, and which findings are real versus noise. I picked Dependency-Track as the open-source base for tracking that. Getting the rest of the way took most of the work. Ingestion from GitHub, two more vulnerability sources on top of the built-in one, real malware detection, and a way to separate real risk from CVSS-driven noise.

GitHub’s SBOM format doesn’t match Dependency-Track’s

The plan was to pull each repo’s SBOM from GitHub’s native dependency graph API and push it into Dependency-Track. GitHub’s endpoint exports SPDX 2.3 JSON. Dependency-Track’s upload endpoint only accepts CycloneDX. It never supported SPDX. An older version of that support existed and was removed, because SPDX’s external-reference model creates ambiguity about component identity that a security-focused tool doesn’t want.

The synchronous GET /repos/{owner}/{repo}/dependency-graph/sbom endpoint I originally built against is deprecated, and GitHub removes it entirely on 2026-11-13. The replacement is an async report flow. Kick off a report with generate-report, then poll fetch-report until it 302s to the finished document instead of returning 202 “still working.” Two differences from the old endpoint showed up only against live repos, never in the docs. The finished report is the SPDX document directly, not wrapped in {"sbom": {...}}. And there’s no branch or ref parameter at all. generate-report always uses the repository’s default branch, so a per-repo branch override in config is no longer something GitHub’s API can honor.

Every sync does GitHub SPDX export, convert, then Dependency-Track upload. My first attempt used cyclonedx-cli, the standard tool for this. It silently dropped every dependency relationship in the process. The source SBOM had 3886 DEPENDS_ON edges. The converted output had zero. cyclonedx-dotnet-library’s own docs list “Relationship Information” as “implementation pending,” so this isn’t a bug, just an unimplemented feature. I switched to syft convert, which maps SPDX relationships onto CycloneDX’s dependency graph correctly. 3885 of 3886 edges survived on the real file.

Two more fixes were needed on top of that. Syft always emits the newest CycloneDX spec version with no way to pin it, and this Dependency-Track version doesn’t understand it yet, so the converter downgrades the spec version after the fact. Syft also never promotes the SPDX-declared root package into CycloneDX’s metadata.component. That left Dependency-Track with no way to know which components were the project’s own direct dependencies, so its Dependency Graph view stayed empty even though the full transitive graph was there. I fixed it by finding the SPDX DESCRIBES relationship and promoting that package myself.

Dependency-Track’s Dependency Graph view showing the full transitive component tree after fixing the root-package promotion

Adding a second vulnerability source

Dependency-Track ships with NVD matching built in. That’s a decent start and also a trap. The initial NVD mirror took roughly 15 minutes to download, and every scan run before that finished came back clean, not because the dependencies were clean but because there was nothing to match against yet. Zero findings and “we checked, you’re fine” look identical if you’re not watching whether the database has actually finished loading.

I added Trivy next, which needs a philosophy shift Dependency-Track’s UI doesn’t really flag. It isn’t a scanner you enable. It’s a client that talks to a Trivy server you stand up yourself. Once that was running (pinned to a specific image digest, more on that below), it justified its own existence within the first sync. It flagged CVE-2023-4863 in a pillow component, a libwebp heap overflow on CISA’s KEV list and sitting at the 99.95th EPSS percentile, close to the highest-confidence “this is being exploited right now” signal that scoring system produces. The internal NVD matcher hadn’t caught it.

Dependency-Track finding for CVE-2023-4863 in pillow 8.4.0, flagged by Trivy at High severity

Why Sonatype OSS Index didn’t survive

I ran Sonatype OSS Index alongside NVD and Trivy for a while. Free tier, npm/PyPI/Maven-focused, straightforward to wire up, and it earned its place in the pipeline the same way Trivy did, by catching things the other sources missed.

Then Sonatype migrated OSS Index to a credit-based product called Sonatype Guide, with a 500-credit-per-month free tier. Dependency-Track’s own setup docs never flagged that. The credit-to-component conversion rate isn’t documented anywhere either, not even in DT’s own GitHub discussions about the migration, where one user reported needing roughly 52,000 credits a month for their real setup, over a hundred times the free allotment. Two projects here, one with about 1,800 components, burned through 496.7 of 500 credits in a day and a half at a 2-hour sync interval. Every OSS Index call has returned 402 Payment Required since.

A smaller sync interval isn’t the fix. That just buys time until the monthly reset. It doesn’t solve a free tier that looks structurally undersized for what Dependency-Track actually sends it. Rather than pay for a service the free tier can’t realistically support, I pulled OSS Index out of the pipeline entirely.

The replacement was already sitting in Dependency-Track, unused. A native OSV.dev data source, separate from the custom OSV malware check described below. It mirrors OSV’s full advisory feed directly into DT’s own database on a daily schedule. No API key, no rate limit, no cost, and it feeds the same Internal analyzer that already handles NVD.

It’s marked a “preview feature” in DT’s docs, and it earned that label. The first attempt to enable it silently failed to save, confirmed by querying /api/v1/integration/osv/ecosystem/inactive and seeing the selected ecosystems still listed as inactive. The mirror job only checks which ecosystems are enabled at server startup, not the moment the setting is saved, so a restart is needed to make a saved change take effect immediately instead of waiting for the job’s own schedule. Past both of those, it worked cleanly. 24,865 PyPI advisories, 8,701 Go, 226,798 npm, mirrored and indexed within a couple of minutes of restarting.

Three sources feed this pipeline now, and the one that replaced a paid-adjacent quota problem costs nothing and can’t run out.

Malware detection is a different problem than vulnerability scanning

A CVE means a package has a bug. Malware means someone deliberately published that package to attack whoever installs it. Typosquatting, credential theft, a backdoored release. Different threat model, different data sources.

OSV.dev is the right free primary source. It has a dedicated MAL-YYYY-NNNN record type for malicious packages, sourced from OpenSSF’s ossf/malicious-packages, the same curated dataset GitHub’s own Dependabot malware alerts run on. No API key, no published rate limit, and it’s batch-friendly, although “no documented limit” turned out to mean “there’s an undocumented one.” /v1/querybatch returns 400 too many queries above exactly 1000 entries per call. I found that by binary-searching the batch size against the live API. Nothing documents it anywhere.

The bigger gap surfaced when I actually validated detection against real, historical attacks instead of trusting the implementation. I tested the checker against flatmap-stream@0.1.1, event-stream@3.3.6, and ua-parser-js@0.7.29, three well-documented supply chain compromises. It caught one out of three. event-stream and ua-parser-js are real malware, and OSV has records for both, but those records are filed as GHSA advisories, not MAL- entries, and the check was only looking for the MAL- prefix. The actual signal that distinguishes them is CWE-506, “Embedded Malicious Code,” which both carry. I fixed it by adding a second pass that resolves the CWE for every non-MAL- candidate ID the batch query returns, deduplicated and fetched concurrently so it doesn’t meaningfully slow a sync. I re-ran the same three-attack test afterward: 3 for 3, no false positives on the clean controls.

This needed to be tested without actually running any malware. These checks are metadata lookups. You send a package name, ecosystem, and version as a string and read the response. You never install or execute the package, whether you’re testing a known-bad version or a repo’s real dependency tree. I also ran a full pipeline test with a disposable public GitHub repo whose package.json listed ua-parser-js@0.7.29 as a dependency, never installed, added temporarily to the sync config, confirmed the alert fired end to end through the real path, then removed it.

I added OpenSourceMalware.com as a secondary source on top of OSV. Its free tier is a per-package lookup with an unpublished rate limit. The efficient “poll for new threats” endpoint is paid-only, so looping that call over an entire dependency tree every sync isn’t viable. Instead it only runs against components new since the last sync, tracked in a small JSON file alongside the sync config. A project’s first sync makes every component look “new” against an empty baseline. That’s establishing the baseline, not a burst of new dependencies, so I detect and skip that case explicitly rather than burning through rate limit on day one.

Why not just use GitHub’s native malware alerts and skip the custom pipeline, since the sync already authenticates to GitHub per repo anyway? GitHub’s own writeup on extending Dependabot malware alerts past npm confirms their importer pulls from the same ossf/malicious-packages repo OSV’s MAL- records come from, and explicitly filters out anything already tagged as GitHub’s own prior contribution to avoid a re-import loop. So the OSV MAL- leg of this check is largely the same data GitHub already surfaces for free, through a different door. It stayed in mainly because OSV needed no extra login on top of what the sync already does. OpenSourceMalware.com is the part that’s actually additive. It’s a separate, human-reviewed submission pipeline, not sourced from OpenSSF’s data at all, so it can catch something GitHub’s own alerts structurally have no visibility into.

KEV and EPSS as the noise filter

Enabling three vulnerability sources on a project with ~1800 components produced roughly 70 findings on first sync. Most weren’t urgent. A pandas finding scored CRITICAL turned out to be a 2020 CVE for a function only exploitable if you deserialize untrusted pickle files, on a version two majors newer than the affected range. NVD’s own record notes the CVE is disputed. CVSS alone doesn’t distinguish that from something real.

Dependency-Track finding for CVE-2020-13091 in pandas, showing Critical CVSS severity, the disputed pickle-deserialization description, and the audit trail recording it suppressed after a NOT_AFFECTED triage verdict

Two free, deterministic filters run automatically on every sync:

  • CISA KEV, cross-referencing every finding against the actively-exploited-in-the-wild catalog. A match means stop reading CVSS scores and go fix it.
  • EPSS, which estimates real-world exploitation probability independent of severity. Most brand-new CVEs simply don’t have a score yet. That’s a coverage gap, not evidence of safety, and it’s easy to misread as one.

Neither is a substitute for real triage. No match means “not currently flagged by these two signals,” not “safe.”

What’s next

All of this gets the pipeline to a wall of prioritized findings, real vulnerabilities, real malware, ranked by what’s actually exploitable rather than raw CVSS. It doesn’t answer whether any of it actually applies to this specific codebase. A CVE in a parsing library means nothing if the vulnerable function is never called. That’s a code-reading problem, not a data-ingestion one, and it’s where an LLM actually earns its keep instead of just summarizing a feed.

The next post covers the triage workflow itself: why an off-the-shelf reachability tool doesn’t cover this stack, the MCP server that lets an agent trace a finding against real usage before recording a verdict, why a NOT_AFFECTED verdict needs to expire when the code around it changes, and the dashboard that makes an overdue project impossible to miss.