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 DatabaseGHSA-*

  • PyPI Advisory DatabasePYSEC-*

  • RustSecRUSTSEC-*

  • Go Vulnerability DatabaseGO-*

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 only when collection.vuln_scan_transitive is enabled. It defaults to off. 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.

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.


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.5

SPDX

2.3

Both are hand-written Go structs serialised with encoding/json. No CycloneDX or SPDX library is vendored.

CycloneDX 1.5 specifics. Uses the 1.5 metadata.tools object form rather than the deprecated bare array. Development dependencies are marked scope: "excluded", runtime dependencies "required". 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.

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.

Vulnerability-annotated SBOMs

Requesting the CycloneDX SBOM with ?vulns=1 appends a native CycloneDX 1.5 vulnerabilities array covering unresolved findings, with affects.ref pointing at the component’s purl. SPDX has no equivalent native representation, so the parameter returns 400 for that format.


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

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. All are queried anonymously with a single identifying User-Agent (aveloxis/{version}).

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.


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 off by default. Vulnerability counts reflect directly declared dependencies unless vuln_scan_transitive is enabled.

  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. Refresh procedure lives in the header of internal/collector/spdx_license_ids.txt.

  • 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

false

Scan the full lockfile closure, not just declared dependencies

mail.vuln_digest_include_transitive

false

Include transitive findings in operator digest email

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
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.5
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