Vulnerability Scanning and SBOM Generation

How Aveloxis determines what a repository depends on, whether those dependencies have known vulnerabilities, and how it emits a Software Bill of Materials.

This page describes what the system does today, including its known limitations. It is the reference to hand someone who asks “what tooling are you using for vulnerabilities and SBOMs?”


The short answer

Aveloxis does not wrap an existing scanner. There is no Syft, no Trivy, no Grype, no Dependency-Track, and no vendored SBOM library. The pipeline is first-party Go that reads dependency manifests directly, queries OSV.dev for advisories, and serialises SBOMs by hand.

The dependency surface is deliberately small. go.mod declares six direct requirements — google/uuid, jackc/pgx/v5, spf13/cobra, golang.org/x/net, golang.org/x/oauth2, gopkg.in/yaml.v3 — and none of them are security or SBOM libraries. google/uuid is the only third-party package that touches the SBOM path at all, and only to mint document identifiers.

Three external binaries are shelled out to (ScanCode, OpenSSF Scorecard, scc), plus git and curl. Everything else is ours.


Where this runs

Vulnerability scanning and SBOM generation are the last two phases of the per-repository collection pipeline. They depend on earlier phases having populated the dependency tables:

Phase

Step

Produces

3

Facade — bare clone + git log

commit history, working tree for analysis

4

Analysis — manifest + lockfile parsing, libyear

repo_dependencies, repo_deps_libyear, repo_lockfile_packages

4b

OpenSSF Scorecard

repo_deps_scorecard

—

ScanCode (decoupled worker, own cadence)

aveloxis_scan.scancode_file_results

6

SBOM generation

repo_sbom_scans (CycloneDX + SPDX)

7

Vulnerability scan

repo_deps_vulnerabilities

Analysis must run after facade because it needs the clone. SBOM and vulnerability scanning both read from the dependency tables, so they run last.


Vulnerability scanning

Data source: OSV.dev

OSV.dev is the only vulnerability data source. No other advisory feed is queried.

Batch query

POST https://api.osv.dev/v1/querybatch

Detail fetch

GET https://api.osv.dev/v1/vulns/{id}

Query format

Package URL (purl) — e.g. pkg:npm/lodash@4.17.20

Authentication

None. OSV.dev is a free public service.

Stored source value

osv.dev

The scan is deliberately two-phase. querybatch returns only ID stubs ({id, modified}) — it does not return severity, summary, affected ranges, aliases, or references. Those come from a second pass of per-ID GET /v1/vulns/{id} calls, run with a concurrency limit of 6.

This matters historically: the original implementation parsed detail fields out of the querybatch response, which meant every field except vuln_id was empty on every row ever stored. Fixed in v0.27.4; existing rows self-heal on each repository’s next scan.

Advisory sources reach us through OSV

OSV.dev is an aggregator. The advisories that surface in repo_deps_vulnerabilities originate from:

  • NVD — CVE identifiers

  • GitHub Advisory Database — GHSA-*

  • PyPI Advisory Database — PYSEC-*

  • RustSec — RUSTSEC-*

  • Go Vulnerability Database — GO-*

CVE identifiers are extracted from OSV’s aliases array, so a GHSA record with a CVE alias yields both.

We do not query NIST NVD directly. Mapping a purl to a CPE — NVD’s identifier scheme — is unreliable, and a bad mapping produces false positives that are worse than the coverage gained. OSV’s per-ecosystem version-range matching is authoritative in a way CPE matching is not.

Severity

Severity labels (CRITICAL / HIGH / MEDIUM / LOW) are read from OSV’s database_specific.severity where present — GHSA always populates it — with MODERATE folded to MEDIUM. These are trustworthy.

Severity scores are computed from the advisory’s CVSS vector with the real CVSS v3.0/v3.1 base-score formula (internal/collector/cvss.go, verified against published specification vectors; CVSS v2 is implemented for old records). A vector we cannot score — CVSS v4-only, or malformed — yields no score (stored as 0), never a guessed value. Scores stored before v0.27.23 came from a six-bucket approximation; they heal on each repository’s next scan or via aveloxis heal-vulnerabilities --rescore-only. See Known limitations.

Direct vs transitive

Direct dependencies — those declared in a manifest — are always scanned.

Transitive dependencies (the full lockfile closure) are scanned when collection.vuln_scan_transitive is enabled. It defaults to on since v0.27.136 (flipped on the 2026-08-21 canary evidence; set it to false explicitly to opt out). When on, every finding carries dependency_kind of direct or transitive, and the API splits counts so a transitive-heavy total can never headline as though it were direct exposure.

Findings also carry version_resolution, recording how confidently we know the installed version: locked (from a lockfile), exact (pinned ==), bounded-range, range-floor (>=X, floor scanned), or unpinned. A >= requirement is not resolved to the latest satisfying version — actual resolution depends on the whole dependency graph, so the floor is scanned and labelled as such.

An unpinned dependency declares no version at all, so OSV.dev is queried without one and answers with every advisory ever published for the package. Since v0.29.70 those findings are not counted as dependency exposure — whether the version in use is affected is unknown, the VEX under_investigation class (GitHub’s dependency graph and Trivy’s default mode report nothing in this case). They are counted and listed apart as “dependencies with no declared version — exposure unknown”. GitHub Actions dependencies are the exception: a floating ref (@v4, @main) is stored unpinned too, but its advisories were matched against the ref, so they stay exposure. A range floor, by contrast, stays exposure: the lowest version the declared range allows is affected, which is labelled “lowest allowed” rather than “installed” (the version actually installed may be newer and not affected).

Lifecycle

Findings are never deleted. Each carries first_detected_at, last_seen_at, and resolved_at. After a complete successful scan, findings absent from the current result set are stamped resolved_at rather than removed, preserving the historical record. A reappearing vulnerability clears resolved_at and becomes current again. Dashboards count unresolved findings only.

Caching

An in-process cache (osv_cache.go) holds purl → vulnerability-ID results and ID → detail records with a 24-hour TTL. Negative results are cached deliberately — most packages have no advisories, and caching the absence is where most of the traffic reduction comes from. Failed fetches are never cached.

This is politeness infrastructure for a free public service, not just a performance optimisation.

The package-centred views

repo_deps_vulnerabilities is repository-centred. Two Aveloxis-owned materialized views, explorer_package_exposure and explorer_package_advisory, regroup it per package across the fleet — reach, transitive share, median days open, version fragmentation, the lead advisory — for the GUI’s dependencies page and the /api/v1/supply-chain/packages endpoints. Every column, how it is derived from this table, and the views’ own build and refresh lifecycle (collection.supply_chain_refresh_hours) are documented in Materialized views → Supply-chain package views.


SBOM generation

Both formats are generated per repository and stored in repo_sbom_scans. Generation failure is non-fatal to the collection job.

Format

Spec version

CycloneDX

1.7

SPDX

2.3

Both are hand-written Go structs serialised with encoding/json. License expressions are parsed and validated through internal/spdx (since v0.29.67), which wraps github.com/github/go-spdx/v2.

CycloneDX 1.7 specifics (1.5 until v0.29.67). Documents carry $schema and specVersion: 1.7. Uses the metadata.tools object form rather than the deprecated bare array. Component scope follows the dependency’s stored scope (v0.27.46): runtime → "required", optional/peer → "optional", dev/test/build → "excluded". ScanCode-detected licenses and copyrights are placed in evidence.licenses and evidence.copyright, kept deliberately separate from registry-declared licenses so consumers can tell asserted metadata from observed evidence.

SPDX 2.3 specifics. Package identifiers are derived from sha256(name@version), so they are stable across regenerations. Package URLs are emitted in externalRefs with referenceCategory: PACKAGE-MANAGER and referenceType: purl. Per-dependency licenseConcluded is always NOASSERTION — ScanCode runs at repository root, not per dependency, so we have no basis to conclude a license for an individual package.

License expressions (v0.29.67, worklist 53). Every license field goes through db.NormalizeLicenseToSPDX, which turns registry spellings into canonical SPDX: MIT/Apache-2.0 and mit or apache-2.0 become MIT OR Apache-2.0, and synonyms inside an expression resolve (MIT License AND Apache 2.0 becomes MIT AND Apache-2.0). A known name followed by the word “License” or “Licence” is that name (Apache 2.0 License and The MPL 2.0 License read Apache-2.0 and MPL-2.0, 0BSD License reads 0BSD); a word before “License” that is not a known name or an SPDX identifier (Commercial License) keeps the text.

Some registries (PyPI especially) store a whole license text or a license notice in the license field. Aveloxis recognizes the common ones by their wording, and answers only what the text states exactly (worklist 60):

  • A notice is read from the clause that names the version. An FSF notice with “(at your option) any later version” (or “or later”, “or newer” and similar, right after the version) is GPL-3.0-or-later / GPL-2.0-or-later, and an Apache notice with a range is Apache-2.0+. A + right after the version (GPL-2.0+, GPLv3+) is the same range.

  • Comment markers (#, //, *, --) are ignored, so a header from a source file reads like the plain header.

  • A notice is read only when every “license” and “terms” word in it is the license’s own name or a reference back to it (“the License”, “LICENSE.txt”, the license publisher’s URL). Any other “… license” (“a proprietary license”, “a separate license agreement”) means another license, so the text stays text. So does a notice naming more than one version (“version 2 or 3”, “2 and/or 3”), a range that depends on something (“any later version approved by the author”), a version not tied to the license’s name (a version counts only as part of the name, right after it or after the FSF’s “as published by the Free Software Foundation; either”, or as “version N of the License” in the name’s sentence; “the version 3 branch” is not the license’s), a version that belongs to the product (“MyTool v3”, or one in another sentence), or a range, choice or exception somewhere else (“Requires Python 3.8 or later”, “at your choice”), mentions of the version that disagree (“the files in contrib/ are GPL-2.0+” next to “version 2”), or other terms (“a license agreement”, “an EULA”, “except for”).

  • After a range phrase, the rest of its clause may be only the FSF’s own “as published by the Free Software Foundation” or a bare pointer to this license’s text (“see LICENSE”, “see COPYING”, the gnu.org or apache.org URL). Anything else (“pending approval by the author”, “see COPYING, but only with the author’s permission”) keeps the text.

  • An SPDX-License-Identifier: tag (or a JSDoc @license tag) heading the text (after only comment markers, FreeBSD’s /*- included, a shebang, copyright lines that state no license, or a bare @license marker, and in a text without a GNU or Apache license body, comment markers or not) is the author’s exact statement and is used as it stands, WITH exceptions included. A tag whose value is not a valid expression keeps the text; a bare @license marker or an @license copyright line is not a tag. Any other tag must be a valid expression that agrees with the license the text is read as, whichever wording identified it (the GPL, Apache, MPL, CC0 and Unlicense readers, or the MIT, BSD, ISC and PSF fingerprints). Otherwise the tag describes bundled code (or something unreadable), and the text stays text.

  • The Classpath exception (on GPL-2.0) and the LLVM exception (on Apache-2.0) are kept as WITH terms.

  • Two license texts in one field (a GPL-2.0 and a GPL-3.0 text, a GPL and an Apache text, or a GPL or Apache text next to an MIT, BSD, ISC, PSF, MPL, Unlicense or CC0 text) stay text: they are not one license.

  • A copyright line above a tag that carries a license word (“Licensed under …”) or names a license in any spelling (AGPLv3, MPL version 2.0, CC-BY-SA-4.0, Apache/2.0) is a second statement, and the text stays text. A holder named like a license (“Internet Systems Consortium, Inc. (“ISC”)”, “The Apache Software Foundation”) keeps the text too; a name only counts as a whole word, so “Mitchell” is not “MIT”. In Chinese and Japanese, which do not space words, a name touching other characters (“采用MIT许可证”) still counts, and so does a name after a digit or an underscore (“0BSD”).

  • The MPL, CC0 and Unlicense texts are read the same way (worklist 61), either as a license body or as a notice. Markdown blockquote markers (> ) are ignored, like comment markers.

    • A body is read by its own title and wording, and the text around it may state nothing a notice could. A copyright line may stand above it, and so may a title heading that is the whole prefix (“License”, “The LibTom license”); a heading naming another license keeps the text. A prefix that names the license itself must be an agreeing notice or a heading or badge of only its name (”# The Unlicense”, “# MPL 2.0 License”, “Creative Commons CC0 License”, “Unlicense (Public Domain)”); a negated or passing mention (“This project is not released under the Unlicense.”) keeps the text. A Markdown link in a grant counts only when it points at the license’s own URL (its own site, or its OSI or SPDX page). A badge is read by its alt text and its link, and by its image URL only when that names another license (a shields.io badge shows its path: .../badge/License-GPL_3.0-blue.svg reads “GPL 3.0”): [![License: CC0-1.0](https://img.shields.io/badge/License-CC0_1.0-lightgrey.svg)](<CC0 deed>) reads, a badge whose alt text, link or image names another license does not. HashiCorp’s Mozilla Public License, version 2.0 body reads.

    • The CC0 legal code is found by its last sentence and its opening block: its title lines, CC0’s own URL, “Official translations of this legal tool are available”, its disclaimer, its “Statement of Purpose” heading and bare AsciiDoc attributes (:sectnums!:), each optional and in any heading style, beginning a line and running into “The laws of most jurisdictions throughout the world automatically confer”. A field with a value (:License: GPL-3.0) or another license’s URL there is a statement, and keeps the text. Above it may stand a heading, a CC0 badge, or a grant that agrees (“X is licensed under the Creative Commons Zero v1.0 Universal”, the US-government template’s “we waive copyright and related rights in the work worldwide through the CC0 1.0 Universal public domain dedication”). A mention of CC0 that is not such a grant (“This dataset is not released under CC0 1.0 Universal.”, the US-government template waiving only “the font software modifications”) keeps the text. Other grants that cover only part of the work can still read, as the known limit on partial coverage says.

    • An MPL-2.0 body under an MPL-2.0 notice that agrees with it reads MPL-2.0, as a GPL or Apache body under its own notice does (X is licensed under the [MPL](https://www.mozilla.org/MPL/2.0/) version 2.0: above the body, Markdown links and emphasis included). A notice above that states a range, the no-copyleft exhibit, another version or another license keeps the text.

    • An MPL notice goes through the notice reader above (MPL-1.0, MPL-1.1, MPL-2.0; Exhibit B’s “Incompatible With Secondary Licenses” statement makes it MPL-2.0-no-copyleft-exception; MPL has no “or later” ID, so a range keeps the text).

    • Mozilla’s license block. With one ***** BEGIN LICENSE BLOCK ***** and one END LICENSE BLOCK, the block’s contents are read as the notice. The text outside may carry no license word, no GPL-family name (GPL, LGPL, AGPL, FDL, GNU), no license named with its version (CC-BY-4.0, EPL-2.0, Apache 2.0), and no version, range or exception. Another product’s name there is description (certifi’s LICENSE names “an Apache+mod_ssl webserver” around its block).

    • A CC0 or Unlicense notice has a notice’s shape: the CC0 waiver clause; the Unlicense’s opening dedication; a grant (“released under the Unlicense”, “dedicated to the public domain under CC0”, “licensed under a Creative Commons CC0 license”), the name ending its clause, so “under CC0-style terms” is not one, with no negation in its clause (“not”, “nothing”, “none”, “neither”, “formerly”; a period ends the clause only after a word of five letters or more, so “e.g.” or “Jan.” does not); or a field that is only the license’s name and its own URLs (“CC0 1.0 Universal (CC0 1.0) Public Domain Dedication, Creative Commons, ”).

    • What keeps the text: another license named anywhere (“dual licensed under the MIT license or the Mozilla Public License 2.0”, a GPL or LGPL next to CC0), “all rights reserved” next to a CC0 or Unlicense dedication, a passing mention that is not a grant (“we considered CC0”, “uses CC0 textures”, a JSON "dataLicense":"CC0-1.0"), and a Creative Commons Attribution text, which the old CC0 fingerprint read as CC0-1.0.

  • A full GPL license text on its own is the -only identifier, the ScanCode convention, read from its own title; the same holds for the Apache-2.0 text. A notice above the title decides the range when it names the same version; if it says anything else, the text stays text. Text appended after the license’s terms counts only as an exception to that license, and appended text that changes it (an additional permission, the Commons Clause) keeps the text.

  • Anything the text cannot state exactly stays free text, so SPDX says NOASSERTION. That covers an LGPL or AGPL text, a notice naming two GPL versions, and any other exception.

Known limits of the license reader (decided). The reader answers only what a text states exactly, and keeps the text when it cannot tell. Some shapes cannot be told apart without an open-ended list of phrases; for each of those, the reader takes a fixed side, listed here with the reason. They are deliberate: a change to any of them should come with real files that show the need, not only a constructed sentence. They concern license texts; a field of 80 characters or fewer is read as a license name.

  • Partial coverage can read as the license. “Some of the fonts are distributed under the Mozilla Public License 2.0; see the fonts directory …” answers MPL-2.0. Telling a statement about part of the code from one about all of it needs a phrase list; the GPL and Apache notice readers share this limit.

  • A provenance note reads as the license. “Initial script taken from etcd under the Apache 2.0 license” answers Apache-2.0, for the same reason.

  • A negation after the version is not read. “The MPL 2.0 does not apply to vendor/” answers MPL-2.0. A negation after the version is sometimes the license’s own clause: real Apache headers write (the "License") you may not use this file without the semicolon. A negation before the version in its clause (“This file is not covered by version 3 of the GPL”) does keep the text.

  • In a notice, only “not”, “no”, “never”, “without”, “cannot” and “n’t” are negations. “None of the files in this repository are licensed under the MPL 2.0” answers MPL-2.0. A notice sits under copyright lines whose company histories use “formerly”, “previously”, “none” and the like (a real Apache header under “Lightbend Inc. (formerly Typesafe Inc.)”), and those words must not withdraw it. A CC0 or Unlicense grant, a short sentence of its own, does count “nothing”, “none”, “neither”, “nor”, “formerly”, “previously” and “once”.

  • Words after a grant are not read. “Released under the Unlicense, except the logo” and “Is this released under the Unlicense? We are still deciding” answer Unlicense; so do modal grants (“it would be nice if this were released under CC0”). A negation in the grant’s own clause (“NOT released under”, “Nothing here is released under”) keeps the text.

  • In a CC0 or Unlicense grant, a clause ends at a period only after a word of five letters or more. “Nothing in this repository (e.g. the sprites) is released under CC0” keeps the text: “e.g.”, “Jan.”, “Inc.” and “Corp.” do not end the clause, so the negation still counts. A sentence that ends in a short word runs on into the next one, which can only keep more text. The GPL, Apache and MPL notice readers end a clause at every period followed by a space except “e.g.” and “i.e.”.

  • A license named on a copyright line above an SPDX tag keeps the text, in any spelling. A holder named like a license (“Internet Systems Consortium, Inc. (“ISC”)”, “The Apache Software Foundation”) keeps it too: a list of license spellings was never complete, and keeping the text is the safe error.

  • The other-license list is a heuristic. A license outside the list, named without the word “license”, is not seen as a second license (an unlisted name in a heading above a body, for example).

  • Outside a Mozilla license block, a bare license name is not seen. With one BEGIN LICENSE BLOCK / END LICENSE BLOCK, text outside the block that names another license with no license word, no GPL-family name and no version (“the test fixtures are MIT”) is not seen, because product names there (“an Apache+mod_ssl webserver”, in certifi’s LICENSE) are description.

  • A PHPDoc @license <URL> line keeps its text. Reading it would need a table from URLs to licenses; Packagist’s own license field is already an SPDX identifier.

  • A license body is recognized, not compared word for word. Its title, its own markers and its ending identify it; a sentence inserted inside a recognized body is not read. The GPL, Apache, MPL, CC0 and Unlicense body readers all work this way; a modified license text is a different question from which license a text states.

  • A truncated license body keeps its text. An MPL-2.0 text cut before Exhibit B, for example, is not the whole body.

  • The reader is for license fields and license files. A README or a whole source file read at once usually keeps its text (code and prose around a notice are more than the notice); the notice on its own reads.

  • SPDX: licenseDeclared, and licenseConcluded from ScanCode, carry the normalized expression when it is valid SPDX, and NOASSERTION otherwise; they never carry free text. “Valid” follows the official SPDX tools: ID+ is accepted exactly where ID-or-later is a list ID (GPL-2.0+, AGPL-3.0+), not on other IDs (Apache-2.0+), which those tools reject. An AND is never rewritten as OR. Every LicenseRef- a document uses is declared in hasExtractedLicensingInfos. Aveloxis does not capture license text, so extractedText says that and names the source.

  • Disclosed assumption: RubyGems and Hex publish license lists without saying how they combine. The analysis stores them as OR, the common dual-licensing reading, and the SPDX package’s licenseComments says the relationship was assumed. Composer documents its list as a choice, so a Packagist package needs no note.

  • CycloneDX: each component has at most one licenses entry:

    • license.id for an SPDX ID listed in the 1.7 schema’s license enum;

    • expression for any other valid expression, including a single ID newer than the enum (the enum is frozen per schema version, and the 1.5 enum lacks many current SPDX IDs, Unicode-3.0 among them);

    • license.name for free text;

    • no entry at all for a no-license value (NOASSERTION, NONE, N/A), where SPDX says NOASSERTION.

    Registry licenses carry acknowledgement: declared, and ScanCode’s evidence carries acknowledgement: concluded.

  • Validation: internal/collector/sbom_spdx_expressions_test.go validates seeded documents in both formats against the full official schemas (CycloneDX 1.7.2 and SPDX 2.3, committed under testdata/sbom_schemas/). It also checks every license field with internal/spdx.Validate, because neither schema checks expression grammar.

The documentNamespace is an https://aveloxis.io/spdx/... URI. Per the SPDX specification this is an identifier, not a resolvable endpoint; nothing is served there.

Scope-typed relationships (v0.27.46). Non-runtime dependencies use the SPDX 2.3 typed dependency relationships instead of the baseline DEPENDS_ON: DEV_DEPENDENCY_OF, TEST_DEPENDENCY_OF, BUILD_DEPENDENCY_OF, OPTIONAL_DEPENDENCY_OF, and PROVIDED_DEPENDENCY_OF (npm peers — the consumer provides them at runtime). These forms are inverted per the spec: the package is the subject and the root package the object. The scope→output mappings live in internal/model/depscope.go alongside the vocabulary itself (runtime/dev/test/build/optional/peer; unknown values present as runtime everywhere — fail toward visibility). ?scope=runtime on the SBOM endpoint filters either format to the shipped surface.

Real dependency graphs (v0.27.134, C2)

When a repository has C2 data (vuln_scan_transitive on → lockfile transitives and edges stored), both SBOM formats carry the real dependency graph instead of the historical flat shape:

  • Components/packages — lockfile transitives join the document (purl via the same builder the vulnerability scan uses; bom-ref = purl; SPDXID = the stable sha256(name@version) convention). Transitive entries carry NOASSERTION licenses — lockfiles record no license data, and absence beats guessing. Duplicates against direct dependencies collapse to one entry so document-validity uniqueness rules hold: SPDX has always keyed that on graph key + version, and since v0.29.58 CycloneDX does the same, so a package with no valid purl (a SwiftPM pin with no namespace, a composer platform package such as php) is still one component in both formats — with no purl, an aveloxis:<graph key>@<version> bom-ref, its graph edges intact, and never vulnerability-scanned.

  • CycloneDX dependencies — the root still dependsOn the DIRECT set (unchanged from the flat shape), and parent components now dependsOn their real children from the stored edges; packages no edge reaches stay explicit leaves. Every ref in the array resolves to a component in the document — an edge naming a package that is not in the closure contributes nothing rather than emitting a dangling ref.

  • SPDX relationships — the same edges become package→package DEPENDS_ON relationships. Transitives no edge can reach stay relationship-less on purpose: relating them to the root would fabricate directness.

Edge endpoints resolve at NAME level per ecosystem (the v0.27.133 edge model), with the libyear/lockfile ecosystem-vocabulary split bridged by a small alias fold (rubygems↔gem, packagist↔composer, swiftpm↔swift, elixir↔hex, dart↔pub, haskell↔hackage, golang↔go). Fleets with the knob off store no transitives/edges, so their SBOMs are byte-identical to the pre-v0.27.134 output — the gate is data presence, downstream of the knob. Category-C lockfile formats (no edge data) produce flat-but-complete documents: their transitives appear as components without parent links.

Vulnerability-annotated SBOMs

Requesting the CycloneDX SBOM with ?vulns=1 appends a native CycloneDX vulnerabilities array covering unresolved findings, with affects.ref pointing at the component’s purl. SPDX 2.3 has no native vulnerability section; since v0.27.46 the same parameter attaches package-level externalRefs (referenceCategory: SECURITY, referenceType: advisory, OSV locator) — the spec’s conformant vehicle (SPDX 3.0’s security profile is the eventual richer home). Findings match by purl first, then by package name; resolved-historical and self-advisory findings are excluded from both formats. Since v0.29.70 an unpinned dependency’s advisories (exposure unknown — see above) are kept but marked: CycloneDX gives them analysis.state: "in_triage" with the reason in analysis.detail; SPDX, which has no status field, puts the same reason in the advisory reference’s comment.


What feeds the dependency graph

Manifest parsing — 14 ecosystems

Parsers are hand-written (regex, JSON, line scanning). There is no TOML or XML library — pom.xml and Directory.Packages.props are parsed by hand.

Ecosystem

Manifests

JavaScript

package.json, yarn.lock

Python

requirements.txt, setup.py, setup.cfg, pyproject.toml, Pipfile, poetry.lock

Go

go.mod, go.sum

Rust

Cargo.toml, Cargo.lock

Ruby

Gemfile, Gemfile.lock

Java / Kotlin

pom.xml, build.gradle, build.gradle.kts

PHP

composer.json

Elixir

mix.exs

Swift

Package.swift

Dart

pubspec.yaml

Scala

build.sbt

.NET

packages.config, Directory.Packages.props

Haskell

package.yaml, stack.yaml

C / C++

Makefile, CMakeLists.txt

C/C++ manifests are recognised for language detection only — there is no parse implementation, so they yield no dependency rows. There is no widely adopted C/C++ dependency manifest to target.

Lockfile parsing — 18 formats

(Roster count = 18 registered filenames; yarn.lock additionally sniffs the berry (__metadata:) flavor since v0.27.133 — before that, berry files silently parsed to zero entries because berry writes version: X where the v1 scanner matched only version "X".)

Dependency edges + parent-chain attribution (v0.27.133, C2)

Under vuln_scan_transitive, the parsers also extract parent→child DEPENDENCY EDGES into aveloxis_data.repo_lockfile_edges (name-level children with the raw declared constraint; snapshot-replaced with the package rows in one transaction). Edge-bearing formats: package-lock v1/v2/v3, yarn v1 + berry, pnpm (v5-v9 — v9 edges come from the snapshots: section), bun.lock, poetry-style TOML (poetry/uv/pdm + Cargo.lock’s dependencies = [...] arrays), Gemfile.lock (the 6-space constraint lines), composer.lock (require maps, platform pseudo-packages skipped), packages.lock.json, mix.lock (dep tuples). Edge-less by construction (attribution honestly impossible from the file): Pipfile.lock, pubspec.lock, Package.resolved, gradle.lockfile, stack.yaml.lock, bun.lockb.

Go gets its closure from the TOOLCHAIN instead (the summary/14 C2 item): go list -m all (the resolved MVS build list → transitive package rows) + go mod graph (requirement edges), run in the analysis checkout, LookPath-gated, 5-minute-bounded, best-effort.

The vulnerabilities API walks the edges child→parents (BFS, shortest first, cycle-safe, depth-capped 20) from each transitive finding to the DIRECT roots that pull it in, and serves up to 3 chains per finding as introduced_by — absence means “attribution unavailable”, never “no parents”.

Lockfiles are the disambiguator: they turn “some version satisfying >=2.0” into “2.5.0 is installed”, which is what makes a vulnerability match meaningful.

Ecosystem

Lockfiles

npm

package-lock.json, yarn.lock, pnpm-lock.yaml, bun.lock, bun.lockb

PyPI

poetry.lock, Pipfile.lock, uv.lock, pdm.lock

Cargo

Cargo.lock

RubyGems

Gemfile.lock

Packagist

composer.lock

Hex

mix.lock

Pub

pubspec.lock

SwiftPM

Package.resolved

NuGet

packages.lock.json

Maven

gradle.lockfile

Hackage

stack.yaml.lock

bun.lockb is binary and is detected but not parsed. Parsing is best-effort throughout: a malformed lockfile logs a warning and is skipped, never failing the analysis run.

Two deliberate exclusions: requirements.txt is never treated as a lockfile, even when fully hash-pinned, because it typically carries the same version ambiguity as any other manifest. And Go needs no lockfile — go.mod under Minimal Version Selection is already exact.

Libyear registries — 12

Dependency freshness (“how many years behind is this dependency?”) is resolved against upstream registries, queried anonymously with a single identifying User-Agent (aveloxis/{version}).

Two lookups are the exception (v0.29.56): a Go module’s licence and a SwiftPM package’s releases come from the GitHub API, and both lease a key from the shared pool (SR-20) rather than going out anonymously — GitHub allows anonymous callers only 60 requests an hour per IP, which is why 99.5% of Go dependency rows had no licence before that release. Without GitHub keys those two lookups fail and are reported; nothing else in this list needs a key.

npm · PyPI · Go module proxy · crates.io · RubyGems · Maven Central · Packagist · Hex.pm · NuGet · pub.dev · SwiftPM (resolved via GitHub releases, as Swift has no central registry) · Hackage

The User-Agent is load-bearing, not courtesy: crates.io returns HTTP 403 to default client user-agents under its crawler policy. That silently zeroed every Rust dependency until v0.27.19.


External binaries

Binary

Project

Installed by

Purpose

ScanCode Toolkit (mini)

nexB / AboutCode

aveloxis install-tools (pipx)

per-file license + copyright detection → SBOM evidence fields

OpenSSF Scorecard

ossf/scorecard

aveloxis install-tools (GitHub release tarball)

supply-chain security posture → repo_deps_scorecard

scc

boyter/scc

aveloxis install-tools (go install)

code size and complexity → repo_labor

git

—

system prerequisite

cloning, log walking, tree listing

curl

—

system prerequisite

libyear registry lookups

ScanCode is optional — if it is not installed, scanning is skipped and SBOMs are emitted without evidence fields.


What is not part of this pipeline

deps.dev and ecosyste.ms are queried by Aveloxis, but by the distribution tracking subsystem, which answers “where is this repository published?” — not “what does it depend on?” and not “is it vulnerable?” Nothing from either service flows into repo_deps_vulnerabilities or into purl construction. See Distribution tracking.

OpenCVE appears as app.opencve.io links in the web interface. These are constructed URLs for human navigation. OpenCVE is never called as an API.


Batch-size cap (v0.27.48). OSV documents a hard limit of 1,000 queries per querybatch call; the scan chunks its cache-miss set at osvBatchMaxQueries accordingly. Found by the local knob canary when a pnpm transitive closure exceeded the cap and the whole request 400’d — production had never hit it because vuln_scan_transitive still shipped off at the time (it defaults ON since v0.27.136).

Namespace rule (v0.29.58). The purl spec makes the namespace mandatory for maven (the groupId), swift (the package’s host and owner path) and composer (the vendor), and OSV enforces it: one pkg:swift/alamofire@5.8.0 in a batch rejects the whole batch with error in query at index N: namespace is required, and four repositories lost their entire scan to it in one production run. A composer manifest lists platform packages with no vendor (php, ext-mbstring), and a Gradle shorthand can drop the group. Such a purl is never minted (purlForPackage) and never sent (wireValidPurl — legacy rows minted before the rule are dropped at the wire and counted in malformed_dropped). A SwiftPM Package.resolved pin carries its namespace since v0.29.59: the pin’s repository URL supplies the host and owner (repo_lockfile_packages.purl_namespace, github.com/Alamofire) and the repository name as spelled there, so the transitive purl is pkg:swift/github.com/Alamofire/Alamofire@5.8.0 — the same shape the direct Package.swift writer emits, so the two kinds are one purl in the scan and in both SBOMs. OSV matches Swift purls case-sensitively (probed 2026-09-22: github.com/apple/swift-nio answers, Swift-NIO does not), which is why the spelling comes from the URL, not the lowercased identity — and why a manifest that spells the URL non-canonically (Apple/Swift-NIO, which GitHub serves) mints a purl OSV does not match; the spelling is the manifest’s, not resolved from the forge. Rows stored before v0.29.59 carry no namespace and stay purl-less until the repository’s next analysis rewrites them. Both SBOM formats still list such a package, purl-less (see “Real dependency graphs” above). Types where the spec leaves the namespace optional (npm, pypi, golang, cargo, gem, nuget, hex, pub, hackage) are untouched. When OSV does reject a batch, the error now names the purl at the reported index, not only the index.

Known limitations

Documented deliberately so consumers can calibrate. Remediation history is in summary/16-vuln-sbom-gap-closure-plan.md; several original findings were closed in v0.27.23 and are recorded below so readers of older SBOMs and findings know what changed.

  1. Transitive scanning is on by default since v0.27.136 (off from v0.27.21 to v0.27.135). On fleets that opt out with vuln_scan_transitive: false, vulnerability counts reflect directly declared dependencies only.

  2. Per-dependency licenses are NOASSERTION in SPDX. ScanCode runs at repository root only.

  3. C/C++ yields no dependency rows. Recognised for language detection only.

  4. Single advisory source. An OSV.dev outage means no vulnerability data for its duration. There is no secondary feed and no fallback.

  5. CVSS v4 vectors are not scored. v4 scoring is a MacroVector lookup, not a formula; a v4-only advisory keeps its vector for display but gets no numeric score. Its severity label still arrives from the advisory database.

  6. External tool versions are unpinned (installed at @latest, auto-updated monthly). Since v0.27.23 each SBOM records the ScanCode version that produced its evidence, so documents are attributable — but two runs months apart may still legitimately differ.

Closed in v0.27.23

  • CVSS scores are now computed, not approximated. Scores before v0.27.23 came from a six-value substring lookup (and were not even monotonic in severity). They are now computed with the CVSS v3.0/v3.1 base-score formula (v2 for old records), verified against published specification vectors. An unscoreable vector yields no score, never a guess. Stored rows heal on each repository’s next scan, or fleet-wide in one pass via aveloxis heal-vulnerabilities --rescore-only (no OSV traffic).

  • The SPDX license identifier list is now the official one (733 identifiers, embedded at build time) instead of a hand-maintained ~70-entry allowlist. Valid identifiers that were previously demoted to license.name now emit as license.id. (Since v0.29.67 the list lives in internal/spdx/spdx_data.tsv with each ID’s OSI status, generated by go run ./scripts/gen_spdx_data.)

  • The OSV client is hardened: 30-second timeout (was unbounded) and an identifying aveloxis/<version> User-Agent (was anonymous).


Configuration

Key

Default

Effect

collection.vuln_scan_transitive

true (since v0.27.136)

Scan the full lockfile closure, not just declared dependencies

collection.dev_build_deps

false

v0.27.45: expand Python collection to dev/test/build/optional manifest families (requirements variants, pyproject build-system/optional/PEP 735/poetry groups, Pipfile dev-packages, setup extras). The findings-volume driver — canary before enabling

collection.github_actions_deps

false

v0.27.47: inventory workflow uses: references as build-scope deps and scan them against OSV’s “GitHub Actions” ecosystem (versionless query + client-side ref evaluation; SHA pins produce no findings)

mail.vuln_digest_include_transitive

false

Include transitive findings in operator digest email

mail.vuln_digest_include_dev

false

v0.27.46: include non-runtime-scope findings (dev/test/build/optional/peer) in the operator digest

mail.vuln_digest_min_severity

HIGH

Severity floor for the operator digest

mail.vuln_digest_interval_hours

24

Digest cadence

Operator commands

aveloxis install-tools        # install scc, scorecard, scancode
REPO_ID=123                   # aveloxis_data.repos.repo_id
aveloxis sbom "$REPO_ID"      # generate SBOMs for one repository
aveloxis heal-vulnerabilities # re-fetch detail for findings stored before v0.27.4
aveloxis heal-vulnerabilities --rescore-only
                              # recompute cvss_score from stored vectors
                              # (no OSV traffic) — heals pre-v0.27.23 scores

API endpoints

GET /api/v1/repos/{id}/vulnerabilities        # findings, most critical first
GET /api/v1/repos/{id}/sbom?format=cyclonedx  # CycloneDX 1.7
GET /api/v1/repos/{id}/sbom?format=spdx       # SPDX 2.3
GET /api/v1/repos/{id}/sbom?format=cyclonedx&vulns=1  # with vulnerabilities array
GET /api/v1/repos/{id}/licenses               # dependency license breakdown
GET /api/v1/repos/{id}/scorecard              # OpenSSF Scorecard results