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 + |
commit history, working tree for analysis |
4 |
Analysis — manifest + lockfile parsing, libyear |
|
4b |
OpenSSF Scorecard |
|
— |
ScanCode (decoupled worker, own cadence) |
|
6 |
SBOM generation |
|
7 |
Vulnerability scan |
|
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 |
|
Detail fetch |
|
Query format |
Package URL (purl) — e.g. |
Authentication |
None. OSV.dev is a free public service. |
Stored |
|
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 isApache-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@licensetag) heading the text (after only comment markers, FreeBSD’s/*-included, a shebang, copyright lines that state no license, or a bare@licensemarker, 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,WITHexceptions included. A tag whose value is not a valid expression keeps the text; a bare@licensemarker or an@licensecopyright 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
WITHterms.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.svgreads “GPL 3.0”):[](<CC0 deed>)reads, a badge whose alt text, link or image names another license does not. HashiCorp’sMozilla Public License, version 2.0body 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 itMPL-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 oneEND 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
-onlyidentifier, 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 filewithout 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, andlicenseConcludedfrom ScanCode, carry the normalized expression when it is valid SPDX, andNOASSERTIONotherwise; they never carry free text. “Valid” follows the official SPDX tools:ID+is accepted exactly whereID-or-lateris a list ID (GPL-2.0+,AGPL-3.0+), not on other IDs (Apache-2.0+), which those tools reject. AnANDis never rewritten asOR. EveryLicenseRef-a document uses is declared inhasExtractedLicensingInfos. Aveloxis does not capture license text, soextractedTextsays 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’slicenseCommentssays 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
licensesentry:license.idfor an SPDX ID listed in the 1.7 schema’s license enum;expressionfor 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.0among them);license.namefor free text;no entry at all for a no-license value (
NOASSERTION,NONE,N/A), where SPDX saysNOASSERTION.
Registry licenses carry
acknowledgement: declared, and ScanCode’s evidence carriesacknowledgement: concluded.Validation:
internal/collector/sbom_spdx_expressions_test.govalidates seeded documents in both formats against the full official schemas (CycloneDX 1.7.2 and SPDX 2.3, committed undertestdata/sbom_schemas/). It also checks every license field withinternal/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 carryNOASSERTIONlicenses — 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 asphp) is still one component in both formats — with no purl, anaveloxis:<graph key>@<version>bom-ref, its graph edges intact, and never vulnerability-scanned.CycloneDX
dependencies— the root stilldependsOnthe DIRECT set (unchanged from the flat shape), and parent components nowdependsOntheir 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_ONrelationships. 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 |
|
Python |
|
Go |
|
Rust |
|
Ruby |
|
Java / Kotlin |
|
PHP |
|
Elixir |
|
Swift |
|
Dart |
|
Scala |
|
.NET |
|
Haskell |
|
C / C++ |
|
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 |
|
PyPI |
|
Cargo |
|
RubyGems |
|
Packagist |
|
Hex |
|
Pub |
|
SwiftPM |
|
NuGet |
|
Maven |
|
Hackage |
|
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 |
|
per-file license + copyright detection → SBOM evidence fields |
OpenSSF Scorecard |
|
|
supply-chain security posture → |
scc |
|
|
code size and complexity → |
|
— |
system prerequisite |
cloning, log walking, tree listing |
|
— |
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.
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.Per-dependency licenses are
NOASSERTIONin SPDX. ScanCode runs at repository root only.C/C++ yields no dependency rows. Recognised for language detection only.
Single advisory source. An OSV.dev outage means no vulnerability data for its duration. There is no secondary feed and no fallback.
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.
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.namenow emit aslicense.id. (Since v0.29.67 the list lives ininternal/spdx/spdx_data.tsvwith each ID’s OSI status, generated bygo 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 |
|---|---|---|
|
|
Scan the full lockfile closure, not just declared dependencies |
|
|
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 |
|
|
v0.27.47: inventory workflow |
|
|
Include transitive findings in operator digest email |
|
|
v0.27.46: include non-runtime-scope findings (dev/test/build/optional/peer) in the operator digest |
|
|
Severity floor for the operator digest |
|
|
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