Synaptic documentation
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 synccan see a partially swapped corpus. Re-run it; do not scan during a sync. - Live per-package OSV querying is not implemented.
See also
- Commands for the full flag reference
- MCP Server for the agent-facing tools
- Configuration for policy file placement