Skip to content

Vulnerability Management

github-actions[bot] edited this page Aug 7, 2026 · 2 revisions

Vulnerability Management

synaptic vuln audits a repository's resolved dependencies against an OSV advisory corpus, decides whether each vulnerability actually applies here, and keeps an auditable record of what was found and what was decided about it.

Analysis never touches the network. The only network step is fetching the advisory corpus, which is cached locally and can be disabled entirely.

Quick start

# First run fetches the advisory corpus into ~/.synaptic/advisories.
synaptic vuln scan

# Persist findings to the auditable ledger.
synaptic vuln scan --record

# Ask whether a package is safe before adding it.
synaptic vuln check cargo:openssl --version 0.10.55
corpus: ~/.synaptic/advisories/cargo (2698 advisories, 0 unreadable, newest 2026-08-06T09:15:04Z)
packages scanned: 450
findings: 1 (0 applicable, 0 suppressed by exception)

[P2] RUSTSEC-2023-0071 cargo:rsa@0.9.10
  Marvin Attack: potential key recovery through timing sidechannels
  id=vuln_finding_2852589210b6008289db2c9e state=ReviewRequired severity=Medium (5.9)
  path: cargo:synaptic-sqlaudit@0.9.0 -> cargo:sqlx@0.8.6 -> cargo:sqlx-mysql@0.8.6 -> cargo:rsa@0.9.10
  fix: NoFixAvailable

Every report names its corpus and that corpus's newest advisory. A scanner that hides its corpus invites you to read "no findings" as "no vulnerabilities".

Applicability, not just version matching

Each finding carries an evidence ladder:

evidence:
  [Gate] VersionInAffectedRange: the resolved version falls inside an affected range
  [LowersPriority] NoFirstPartyUsage: no first-party usage observed; this bounds nothing on its own
  [Informational] AdvisoryNamesNoFunctions: the advisory names no vulnerable functions, so reachability is undecidable from it
State Meaning
Applicable the version is in range and the vulnerable code is reached, or the package is a used direct runtime dependency
ReviewRequired in range, but reachability could not be confirmed
NotApplicable the advisory was withdrawn, or the version is outside every affected range

NotApplicable is only ever reached through those two gates. Unreachable symbols, absent first-party usage and development-only scope are all recorded and all de-rank a finding, but none of them makes it not-applicable. Static reachability is incomplete in every language Synaptic reads, dispatch can be dynamic, and advisory function lists are frequently absent. "We found nothing" is not "there is nothing". A test sweeps all 128 boolean input combinations to keep that invariant true.

When a graph is present, reachability uses it: SDK stub nodes for external packages, and whether first-party code reaches them. Without a graph the scan still runs, it simply produces fewer raising signals.

Priority

priority = severity band x applicability x runtime reachability

CVSS base scores come from the advisory's own v3.x vector. No environmental re-scoring is performed. Unknown severity is treated as Medium, not Low: an advisory nobody scored is not thereby harmless. Fix availability is deliberately not an input, because an unfixed critical is not less urgent than a fixed one.

Ecosystem coverage

vuln scan discovers every lockfile in the repository and audits them together, so a polyglot repository is scanned as a whole.

Lockfile Ecosystem Dependency paths
Cargo.lock cargo yes
package-lock.json (v1, v2, v3) npm yes
pnpm-lock.yaml npm yes
yarn.lock npm yes
poetry.lock PyPI yes
uv.lock PyPI yes
composer.lock Packagist yes
Gemfile.lock RubyGems yes
packages.lock.json NuGet yes
Podfile.lock CocoaPods yes
go.mod Go no
Package.resolved SwiftPM no
pubspec.lock Pub no
mix.lock Hex no
gradle.lockfile Maven no

Discovery honours .gitignore. Generated and vendored trees carry other projects' lockfiles, and auditing them reports dependencies the repository does not have. On Synaptic itself, descending into the ignored synaptic-out/ directory picked up 24 vendored lockfiles and turned a one-second scan into a ten-minute one.

"Dependency paths: no" means the format records resolved versions but not what each package depends on, so those findings name the vulnerable package without a chain to it. They are still detected, prioritised and remediable.

Every row is backed by a fixture in the crate's coverage test, which fails if a format is added without one, so this table cannot drift from what actually parses.

Ecosystems without a corpus are reported, never assumed clean

packages scanned: 452
WARNING: 1 package(s) NOT audited (no corpus for npm); they are not known to be clean

Advisory corpus

Resolved in order: --advisories <dir> if given, otherwise the shared cache at ~/.synaptic/advisories/<ecosystem>/, downloaded from OSV's bulk export on first use and refreshed after seven days.

synaptic vuln sync                    # refresh the cargo corpus
synaptic vuln sync --ecosystem pypi   # another ecosystem
synaptic vuln scan --offline          # never fetch; fail if nothing is cached

Bulk export is used rather than per-package API queries because it costs one request instead of one per package, everything afterwards works offline, and it never discloses what this repository depends on.

Exports above 64 MB are refused rather than downloaded silently. npm's is about 218 MB, so it needs one explicit fetch:

synaptic vuln sync --ecosystem npm --max-bytes 300000000

--offline never fetches. With nothing cached it fails rather than reporting a clean scan against an empty corpus, because "no findings" and "no advisories" must never look alike.

Agent integration

Three MCP tools let assistants avoid generating known-vulnerable code:

Tool Purpose
vuln_check_dependency called before writing a dependency into a manifest
vuln_findings current findings with applicability and priority
vuln_explain one finding's evidence, path, plan and history

Point the server at a corpus with SYNAPTIC_VULN_ADVISORIES, or place one at .synaptic/vuln/advisories. The server never downloads: an operator decides explicitly what corpus an agent answers from.

vuln_check_dependency returns allowed, constrained or blocked plus the constraint to use. Where several advisories affect a package it returns the strictest floor:

Blocked cargo:openssl at 0.10.55
Use >=0.10.72. This constraint comes from advisory metadata and has NOT been
checked against a registry, so confirm the version resolves.
- RUSTSEC-2023-0072: affected; fixed in 0.10.60
- RUSTSEC-2024-0357: affected; fixed in 0.10.66
- RUSTSEC-2025-0004: affected; fixed in 0.10.70
- RUSTSEC-2025-0022: affected; fixed in 0.10.72

Two honesty rules the tools hold to, because an agent cannot verify them itself: a constraint is Unverified (the advisory says it fixes the issue; whether such a release exists has not been checked), and no corpus configured means unknown, never safe.

Policy and accepted risk

.synaptic/vuln-policy.toml, created by synaptic vuln init:

schema = 1

[[deny]]
package = "npm:request"
reason = "unmaintained"
replacement = "npm:undici"

[[pin]]
package = "cargo:example-crate"
minimum = "0.10.66"
reason = "organisation floor"

[[exception]]
finding = "vuln_finding_..."
reason = "vulnerable path is not reachable in this build"
expires = "2027-02-01"
approved_by = "security-review"

Exception expiry is mandatory: a policy whose exception lacks a valid YYYY-MM-DD expiry is rejected. Combined with --fail-on, the expiry is enforced rather than advisory. With a live exception the scan reports the finding as suppressed and exits 0; once the date passes the same command reports it as active and exits non-zero, failing the build until someone renews or removes the acceptance.

synaptic vuln accept vuln_finding_... \
  --reason "vulnerable path unreachable" \
  --until 2027-02-01 \
  --approved-by security-review

Unlike a cargo-deny ignore, which never expires and carries no approver, this record is versioned in the repository and comes back on its own.

Audit ledger

.synaptic/vuln/findings/<id>.json, one file per finding. Identity is a digest of repository, advisory, package and resolved version, so rescanning is idempotent and a finding accumulates history rather than fragmenting.

The repository half of that digest is the git remote normalized to host/namespace/repository, not the checkout path. A policy exception names a finding id, so a path-derived id would differ on every machine and a shared exception would silently fail to match.

Decisions are appended, never rewritten. Records are written through a temporary file and renamed, so an interrupted write cannot truncate an audit record.

Scans in CI

Trigger How
Scheduled a cron job running synaptic vuln scan --record --fail-on p1
Dependency change the same command in a job watching lockfile paths
Ad hoc synaptic vuln scan / synaptic vuln check
Agent the three MCP tools

--fail-on <p0|p1|p2|p3> exits non-zero when any finding is at or above that priority, which is what makes it usable as a gate.

Dependency kind

Four formats record what a package is needed for, and the scan reads it: package-lock.json (dev), composer.lock (packages-dev), pubspec.lock (dependency: "direct dev") and poetry.lock (groups or category).

For formats that record edges but no kind, Cargo above all, the manifest supplies the kind and the lockfile supplies the edges. A crate reached only through a [dev-dependencies] entry is therefore known to be development-only, not just the entry itself.

Where nothing records a kind, runtime use is assumed and the finding says so: its evidence carries dependency_scope_unrecorded, which is a different claim from development_only_dependency. One is a reading, the other is an assumption, and the report never lets the second pass for the first.

A development-only or feature-gated finding is de-ranked. Neither is ever dismissed: a feature this build leaves off is one another build turns on.

Asking OSV directly

The bulk export stays the default for a whole-repository scan: one request, it works offline afterwards, and it tells nobody what this repository depends on.

synaptic vuln scan --online                  # add the API to the local corpora
synaptic vuln check cargo:serde              # queries the API directly
synaptic vuln check cargo:serde --offline    # local corpus only

vuln check asks about a single package, so it goes online by default and says which source answered. vuln scan stays offline-first, because a scan would send the whole dependency list.

SYNAPTIC_OFFLINE=1 disables every online path regardless of flags. A failed query is reported, never read as an empty result: check falls back to a local corpus and labels the answer DEGRADED.

Limitations

Each gap makes the tool report less certainty, never more.

  • CVSS v4.0 vectors that do not parse report an unknown severity band, treated as Medium. Parseable ones are scored, and a v4 vector wins over a v3 one.
  • CVSS threat and environmental metrics are not applied; scores are base scores, as they are for v3.
  • Maven and Gradle without dependency locking are audited from pom.xml declarations only. Transitive dependencies are invisible, so the ecosystem is reported as direct declarations only and counted apart from the fully resolved ones. build.gradle versions are not parsed; use an SBOM.
  • Eleven of the fifteen formats record no dependency kind. Where they also record no edges, every package in them is assumed runtime-reachable.
  • uv.lock records dev groups as edges from the root rather than per package, and is not read for scope.
  • Cargo feature resolution covers this repository's own manifests only. A dependency gated behind a disabled feature of a registry crate is not resolved, because that needs the registry index.
  • A live query covers only the ecosystems OSV publishes a name for, and it discloses the queried package list to OSV.

See also

Clone this wiki locally