Vulnerabilities and Threats¶
This page covers the two halves of the security analysis: matching — which advisories apply to the installed packages — and reversal — what each match means for this system, culminating in ranked attack chains and supply-chain findings. Matching lives in src/startup_sbom/vulns/, reversal in src/startup_sbom/threat/, and supply-chain analysis in src/startup_sbom/supplychain/. All of it reads only the report and bundled data files; the optional online layer is described in Intelligence and AI.
Vulnerability sources¶
Sources are selected with --sources (default osv,arch); every source failure or coverage gap becomes an explicit limitation in the report, never a silent "0 vulnerabilities".
OSV.dev (vulns/osv.py) — distro-aware matching, verified against the live API:
- Distro records are indexed by source package (Debian, Ubuntu, Alpine, Rocky, SUSE, …), so queries use
Package.source_namewith the source version. Red Hat and AlmaLinux list binary names, so RPM packages are queried by both. - The version is sent exactly as the distro writes it, epoch included; ecosystem strings come verbatim from
OSInfo.vuln_ecosystems(e.g.Debian:12,Ubuntu:22.04:LTS,Alpine:v3.20). - Red Hat and SLES publish per-repository/per-module streams that cannot be recovered from the package database, so those are queried by base ecosystem and the
affectedentries re-evaluated locally. /v1/querybatchreturns ids only; full records are hydrated via/v1/vulns/{id}and cached by(id, modified).- Rolling Debian derivatives that OSV.dev has no ecosystem for (Kali, and other derivatives whose
ID_LIKEcontainsdebianand whose codename marks them as rolling —rolling,testing,sid,unstable) are matched against the advisories of Debian testing as a derivative fallback (distro.derivative_fallback_ecosystem()): currentlyforky, i.e.Debian:14. An unversionedDebianquery is deliberately not used, since it would accept the affected ranges of every Debian release. Derivatives pinned to a stable Debian release under their own codename are not guessed at, and Ubuntu derivatives are mapped exactly throughUBUNTU_CODENAME. The vendor's revision suffix is removed before comparing (1.2.3-1+kali1→1.2.3-1,2.4-0kali2→2.4-0) byversions.strip_derivative_suffix(), conservatively: the stripped version is used only if it sorts at or below the installed one in dpkg order, so a package can only look less patched, never more. Fallback matches carry at most medium confidence, and low confidence when a suffix was stripped (the vendor modified that package); the report records a coverage limitation saying how many packages were matched this way.--no-derivative-fallbackturns it off, which leaves such systems without OS-package coverage (also recorded as a limitation).
Arch security tracker (vulns/arch.py) — Arch is not in OSV. The tracker's /issues/all.json AVG groups are keyed by binary pkgname; a package is vulnerable iff affected <= installed < fixed under pacman's vercmp. The tracker has been effectively unmaintained since mid-2025, so every Arch scan records that as a coverage limitation and results carry medium confidence.
NVD (opt-in, vulns/nvd.py) — CPE range matching only for packages no other source covers, never a keyword search. The product-level virtualMatchString is queried with the installed version as a range bound and every returned CVE's configurations are re-checked locally. Distro backports are invisible to CPE data, so NVD results always carry low confidence. Enable with --sources osv,arch,nvd and NVD_API_KEY; requests are paced to NVD's published limits.
Enrichment — FIRST EPSS (daily bulk CSV preferred, so one request covers every CVE and works offline from cache; JSON API fallback chunked to its limits) and the CISA KEV catalog (date added, due date, ransomware use, required action). Both are cached by startup-sbom db update and skippable with --no-epss / --no-kev.
Records from all sources are normalised (vulns/normalize.py) — CVE identity extraction, CVSS parsing through the cvss library, severity resolution (CVSS v4 > v3.1 > v3.0 > v2 > distro rating) — and merged per CVE and package, collapsing duplicates such as Debian's DEBIAN-CVE-* + DSA-*.
Version comparators¶
vulns/versions.py ports the reference algorithms so results are identical to the distribution tooling: dpkg verrevcmp (~ ordering, epochs, revisions), rpmvercmp (including ~ pre- and ^ post-release, compared as epoch:version-release), apk-tools apk_version_compare (suffix ordering _alpha < _beta < _pre < _rc < release < …), pacman alpm_pkg_vercmp, PEP 440 for PyPI and a SemVer-like ordering for other language ecosystems.
Threat reversal¶
threat.reverse_vulnerabilities() turns each vulnerability into a Threat grounded in what the scan observed. Where data is missing the analysis stays conservative (unknown attack vector, no privilege gain, lower score) rather than guessing.
- Reachability (
reachability.py) — where the vulnerable package sits, decided only from observed facts, strongest first:network_service,boot_service,linked_by_service,on_demand_service(socket/timer/dbus),setuid,kernel,installed.setuidcovers both a package that ships a setuid/setgid/capability binary and a library that a root-granting setuid or capability binary of another package loads through a recorded link edge (the pipeline computes the library closures of privileged binaries for this). Every classification carries a witness path, e.g.["systemd:ssh.service", "/usr/sbin/sshd", ".../libsystemd.so.0", ".../liblzma.so.5", "pkg:deb/debian/liblzma5@5.6.1-1"]. - Triggers (
triggers.py) — what actually makes the vulnerable code run, and who can cause it; see Triggers below. - CVSS pre/post-conditions (
cvss.py) — attacker pre-conditions (attack vector, privileges required, user interaction) are read from the base metrics via thecvsslibrary; scores are never recomputed by hand, and a missing or unparsable vector yields "unknown". Combined with the service's run-as user, this produces the threat'sprecondition("remote", "local:www-data") andpostcondition("root", …) under the rules in Attacker post-conditions. - CWE consequences (
cwe.py) — consequence classes (code execution, privilege escalation, authentication bypass, information disclosure, tampering, DoS, supply-chain compromise) come from MITRE's Common Consequences data (bundledcwe.json), inherited fromChildOfancestors in the CWE-1000 research view when the weakness lists none; the CVSS impact metrics disambiguate memory-corruption weaknesses between code execution and crashes. - ATT&CK (
attack.py) — technique mapping in priority order: CTID per-CVE mappings (KEV set +attack_to_cve), the CTID methodology vulnerability-type table joined to CWE, CWE → CAPEC → ATT&CK taxonomy links (capped), and finally a context-derived generic exploitation technique (T1190, T1210, T1068, T1203, T1211, T1212). - STRIDE — each threat is tagged with its STRIDE categories, derived from the consequence classes and the CVSS vector facts.
- SSVC (
ssvc.py) — the CISA Coordinator tree v2.0.3 (CERT/CC tables, Apache-2.0) decides Track / Track* / Attend / Act from exploitation, automatability, technical impact and — since Mission & Well-Being cannot be observed in a root filesystem — the scan's observed System Exposure (small/controlled/open) as the situated proxy. The BOD 26-04 deployer timeline tree is evaluated as well; both decision paths are recorded as traces with exact flip conditions (Intelligence and AI describes the traces). - Transparent risk score (
scoring.py, formularisk/1):
risk = 100 × likelihood × reachability × impact × privilege_gain × Π mitigations
likelihood = min(1, max(0.6·EPSS + 0.4·min(1, exploitability/3.887),
1.0 if KEV, exploit-maturity floor))
reachability = weight of the observed execution path (network_service 1.0 … installed 0.15)
impact = strongest consequence class (RCE/supply-chain 1.0 … DoS 0.3)
privilege = 1.0 root/host, 0.6 service account, 0.3 no attacker-state change
mitigation = one multiplier per hardening directive that applies to this threat
Kernel packages inside a container image are down-weighted (they are not the running kernel). The score is exactly decomposable into per-factor contributions — the decomposition machinery lives in explain/attribution.py.
Each threat also carries a plain-English narrative, a remediation (the fixed version when one exists), evidence ids and a weakest-link confidence.
Triggers¶
Reachability says where a vulnerable package sits; a trigger says what executes it. threat.triggers lists every observed activation path of the package (at most 16), most attacker-accessible first. Each Trigger records the kind, the subject (a Service.id, or the binary path for setuid triggers), fired_by — the least-privileged party able to cause the activation — the identity the code runs_as, an optional via path and a detail sentence:
kind |
Source | fired_by |
|---|---|---|
boot, entrypoint |
an automatically started service or the container entry process | system |
timer, path |
a systemd timer or path unit | system |
cron |
a cron job (Init Systems) | system |
socket, dbus |
socket or D-Bus activation | local user (any local process may open the socket or send the request) |
device |
a program run by a udev rule (Init Systems) | device |
setuid |
a setuid/setgid/capability binary shipped by the package, or a root-granting one that loads the vulnerable library (via holds the load path) |
local user |
kernel |
an installed kernel package (not inside container images) | local user (runs as kernel) |
A service trigger becomes fired_by: network whenever the service has a network-exposed listener, and its detail names the listeners. Only services that start automatically produce triggers.
A threat with no trigger is installed code that nothing on the system runs on anyone's behalf: no started service, scheduled job, device rule, setuid binary or kernel path. Its evidence says so explicitly. Triggers are listed in each threat's evidence (trigger: setuid /usr/bin/sudo (fired by local user, runs as root) via …), shown in the Trigger column of the console, Markdown and HTML threat tables (Outputs), and counted in the threat-stage digest (Live Scanning).
Attacker post-conditions¶
reverse._postcondition() decides the attacker state after exploitation. The state only advances when both hold:
- the flaw grants control — a code-execution, authentication-bypass or supply-chain consequence — or is a privilege-escalation weakness (a privilege-escalation CWE or consequence, or integrity-high corruption inside a privileged component); and
- something on the system runs the vulnerable code with more privilege than the attacker holds: a service running as root, a setuid-root or dangerous-capability binary that ships the code or loads it through a recorded link edge, or the kernel.
Otherwise the attacker keeps their own state, however the advisory is worded. In detail:
- Services (
network_service,boot_service,linked_by_service,on_demand_service): a root service yieldsroot. A non-root service yieldslocal:<service account>— a privilege-escalation weakness stops at the service account unless a privileged path also exists (the package is the kernel, or a setuid-root/capability binary ships or loads it) and the flaw needs only local access, in which case it yieldsroot. - Privileged binaries and the kernel yield
root. A shipped binary that is only setgid, or setuid to a non-root owner, yields no root gain. - Installed-only code keeps the attacker's own privileges: exploiting a library nothing privileged runs gains nothing.
- CVSS impact gate. A parsed CVSS vector with
I:N/A:N(confidentiality-only impact) never yields code execution or privilege gain, whatever the CWE or advisory text suggests: it is disclosure. The gate is on by default;--no-cvss-impact-gate(ScanOptions.cvss_impact_gate=False) disables it.
The reason is recorded in each threat's evidence (attacker state: local → local (installed only: …)). This matters most for chains: before these rules, a confidentiality-only flaw in an installed-only Python package (CVSS C:H/I:N/A:N) was turned into local:unprivileged → root and persistence chains through R5 merely because some setuid binary existed on the system; it now produces none.
Attack chains¶
threat/rules.py is a forward-chaining rule set over a FactBase extracted from the report (threats, services, privileged binaries, the kernel, container posture, writable units, blast-radius edges). Each rule yields edges in the privilege lattice remote → local:<user> → root → container_escape/host → persistence:
| Rule | Edge |
|---|---|
| R1 / R2 | Remote exploit of a network-reachable service (unauthenticated / requiring an account) → foothold as its run-as user |
| R3 | Local privilege escalation to root |
| R4 | Setuid/privileged binary with a local privilege-escalation flaw |
| R5 | Library flaw reached through a setuid/capability binary that loads it (a recorded link edge) |
| R6 | Writable unit/service file → persistence or escalation |
| R7 | Kernel vulnerability exploitable by a local user |
| R8 | Dangerous ambient/file capability |
| R9 | Container escape (privileged container, mounted engine socket, …) |
| R10 | Supply-chain compromise propagating along code-loading edges |
| R11 | Persistence establishment |
| R12 | Hardening that prunes an edge or downgrades its likelihood (multiplicative) |
R3, R4, R5 and R7 fire only for threats whose post-condition is root, that grant control or escalate privilege, and whose attack vector is local (or unknown). R5 requires evidence that the privileged binary actually maps the vulnerable library: a recorded link edge from its closure, or — only when no closure could be recorded (link analysis off or the binary unreadable) — the C library/loader package itself (libc6, glibc, musl), which every dynamically linked program maps. A setuid binary merely being present on the system is not enough.
threat/chains.py takes the fixpoint of these edges from each entry state, enumerates the simple paths to the strongest reachable goal (pure Python, no graph library) and scores each chain as 100 · Π p_step · goal impact (formula chain/1), exactly decomposed by LMDI into the chain's explanation. Chains are ranked, capped at ScanOptions.max_chains (default 25), given an SSVC decision and a step-by-step narrative, and rendered as graphs in several formats (Outputs).
Supply-chain analysis¶
supplychain.analyze() answers two questions:
- Where does the code come from? Repository provenance per package, inferred the way package managers themselves do it, from files inside the root only — apt
sources.list/deb822 plus cachedPackagesindexes, pacman sync databases, apk indexes, rpm signature keys. Finding kinds includethird_party_repo,insecure_repository,third_party_key,unsigned_package,local_build,not_in_repository,repo_version_mismatch,repo_checksum_mismatch,unknown_signing_key,modified_file,unmanaged_binary,suspicious_version,eol_distro,embedded_language_packagesandvendor_concentration. Findings on packages that matter at boot are reported individually; the same signal elsewhere is aggregated per kind. Optional--integrityhashing verifies on-disk bytes against recorded package digests, prioritised by what actually runs and bounded. End-of-life status comes from endoflife.date (--no-eolto skip). - How far does an issue reach? A vendor → source package → binary package → file → service graph built from real code-loading edges (
links,dlopens,interprets,preload,loads_config) recorded by the ELF link analysis. For every vulnerable or high-risk package a confidence-weighted strongest-path search computes the blast radius: the services it reaches, with one example path per service — exactly the xz → liblzma → libsystemd → sshd class of chain that declared dependencies miss.
Vendor attribution normalises the most authoritative per-ecosystem field (rpm VENDOR, deb Maintainer, apk m:, pacman %PACKAGER%) so distribution teams group together and Unknown Packager becomes local build.