Skip to content

Vulnerability Management

github-actions[bot] edited this page Aug 6, 2026 · 1 revision

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.

Limitations

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

  • CVSS v4.0 vectors are retained but not scored; those report an unknown severity band, treated as Medium.
  • Maven and Gradle without dependency locking have no lockfile to read, so those projects appear in the unaudited count rather than being silently omitted.
  • Most lockfiles record no dependency kind, so a transitive package is assumed runtime-reachable. Only a directly declared dev dependency is known not to be.
  • Feature-gated optional dependencies are not resolved.
  • A scan run concurrently with vuln sync can see a partially swapped corpus. Re-run it; do not scan during a sync.
  • Live per-package OSV querying is not implemented.

See also

Clone this wiki locally