Docs / Vulnerability monitoring

Vulnerability monitoring

CVEs are published every day, against packages that are already running in production. With vulnerability monitoring, each service checks what is actually installed and tells you when it becomes vulnerable, without a separate scanner and without handing Dynv your package list.

What it does

With monitoring on, dynv run reads the packages its own service has installed and matches them against OSV.dev: at boot, when the installed files change, and every 6 hours while the service runs. You get an alert when:

  • a new advisory is published for a package that was already running, so a CVE published tomorrow for today's deployment does not have to wait for a release; and
  • a newly deployed version is already vulnerable.

It reads the real installed state, not just a manifest: node_modules rather than package.json, and the build info embedded in Go binaries. Lockfiles supply the dependency graph and stand in when installed packages are absent.

EcosystemWhat is scanned
npmInstalled node_modules (including pnpm), plus package-lock.json, pnpm-lock.yaml and yarn.lock.
PyPI*.dist-info and *.egg-info metadata in the app, .venv and system site-packages, plus lockfiles and pinned requirements.
GoBuild info embedded in Go binaries, including the toolchain as stdlib; go.mod as a fallback.
crates.ioCargo.lock.
RubyGems, PackagistGemfile.lock, composer.lock.
Debian, Ubuntu, AlpineThe OS package database (dpkg or apk), with the release from /etc/os-release. Skip with --vulns-no-system.

Monitoring is off by default and enabled per service. It never scans on a request path and never competes with your app's startup: the first scan starts about 30 seconds after boot on a low-priority thread.

Turn it on

Add --vulns to dynv run, or set DYNV_VULNS=1 in the service's environment (handy in Coolify, where it needs no image change):

sh
dynv run --vulns -- node server.js

# the same, from the environment
DYNV_VULNS=1 dynv run -- node server.js
FlagEnvironmentMeaning
--vulns-dir DIRDYNV_VULNS_DIRApp directory to scan. Default: the current directory.
--vulns-interval SECONDSDYNV_VULNS_INTERVALHow often to re-check for new advisories. Default 21600 (6 hours), jittered by ±10%.
--vulns-no-system-Do not read OS package databases or system site-packages.
-DYNV_OSV_URLOSV API base URL: a mirror or proxy instead of https://api.osv.dev.

The first report for a service with findings is one summary event, not one alert per finding. After that, only genuinely new findings alert. A key rotation starts a silent baseline, so it cannot cause an alert storm.

Privacy

Dynv is zero-knowledge, and a package inventory is sensitive metadata: "which customer runs which vulnerable version, right now" would be a target list. So the service does the matching, and the server receives counts, not findings.

That is why alerts contain counts and a link instead of "lodash 4.17.15 → 4.17.21": the server that sends them cannot read the details. The dashboard shows the full picture because it decrypts locally. Rich alerts that never touch Dynv's server are planned, with the service posting details straight to your webhook.

Residual risks, stated plainly:

  • A compromised service can lie about its own findings: report zero, or flood alerts for itself. It cannot touch another service or account; its token writes only its own state.
  • A stopped service is not re-matched. It is also not exposed. For images that are not running yet, scan them in CI with dynv scan.
  • Viewers can read findings, as they can read every secret in the account.
  • Alerts are opaque. A count and a link are less convenient than a package name. The dashboard and CLI have the details.

Alerts

Alerts go to email, signed webhooks and Slack-compatible webhooks. Every alert also appears in the dashboard's Security feed. Each channel has a minimum severity, high by default: an event is delivered only if one of its new findings is at or above that threshold. A webhook can be limited to a list of service ids.

Signed webhooks

A JSON webhook receives a POST with a body like this:

application/json
{
  "type": "vulnerability.alert",
  "event_id": "V1s7mQ2pXk9aB3cD",
  "kind": "new_advisory",
  "account_id": "a8Kd0pQ2mZx4R7tY",
  "process_id": "pKx9aB3cD2mZ5nQ8",
  "new": { "critical": 0, "high": 2, "medium": 0, "low": 0, "unknown": 0 },
  "max_severity": "high",
  "instances": 2,
  "summary": "2 newly published vulnerabilities affect service pKx9aB3cD2mZ5nQ8 (2 high)",
  "url": "https://app.dynv.dev/#/security/pKx9aB3cD2mZ5nQ8",
  "created_at": 1730000000000
}

kind is new_advisory, new_deploy, initial or test. instances is how many instances of the service reported. Requests carry two headers:

  • X-Dynv-Signature: t=<unix seconds>,v1=<hex>, where v1 is HMAC-SHA256(secret, "<t>.<raw body>");
  • X-Dynv-Event, the event kind.

Verify the signature against the raw request body, before JSON parsing. Compare in constant time, and reject a timestamp older than 5 minutes so a captured request cannot be replayed indefinitely.

node
import crypto from 'node:crypto';

// raw is the exact request body as received (a Buffer), not a re-serialized object.
function verify(raw, header, secret) {
  const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')));
  const t = Number(parts.t);
  if (!Number.isInteger(t) || Math.abs(Date.now() / 1000 - t) > 300) return false;
  const want = crypto.createHmac('sha256', secret).update(t + '.').update(raw).digest();
  const got = Buffer.from(parts.v1 || '', 'hex');
  return got.length === want.length && crypto.timingSafeEqual(got, want);
}
python
import hashlib, hmac, time

def verify(raw: bytes, header: str, secret: str) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
    try:
        t = int(parts["t"])
    except (KeyError, ValueError):
        return False
    if abs(time.time() - t) > 300:
        return False
    want = hmac.new(secret.encode(), parts["t"].encode() + b"." + raw, hashlib.sha256).hexdigest()
    return hmac.compare_digest(want, parts.get("v1", ""))

Delivery is a 10-second timeout with no redirects and 3 attempts, waiting 10 seconds and then 60 seconds between them. Webhooks cannot target private or loopback addresses. A secret is shown once, when the webhook is created.

Slack and compatible services

A slack-format webhook sends {"text": …, "blocks": […]}, which Slack, Mattermost, Discord's /slack endpoint and Google Chat's simple format accept. It is signed the same way, and the payload still contains no package names.

Thresholds, filters, mute and ignore

  • Thresholds: email and each webhook have their own minimum severity, default high.
  • Per-service filters: a webhook may list the service ids it should receive, so production alerts and staging alerts can go to different places.
  • Mute a service until a time: no deliveries, but events still appear in the feed. null or a past time unmutes it.
  • Ignore an advisory by id or alias, account-wide or for one service, with a reason and an optional expiry. Ignore rules are sealed under your account key, so Dynv's server cannot read them; the dashboard, CLI and MCP apply them when displaying findings and when gating CI. The server never alerts twice for a known finding, so an ignore does not need to reach it.
  • Dedupe across replicas: reports are compared-and-swapped on a revision. Ten replicas that boot with the same vulnerable package produce one alert, not ten.
  • Rate limits: at most 6 alert deliveries per service, then 30 per account, per hour, so one compromised service can't silence the others. Events beyond that stay in the feed, marked suppressed, and are summarized in one "held back by rate limits" alert per service per hour, sent once that hour is over.
  • Verified email only: a new alert address receives a six-digit code; alerts go to it only after you enter the code in Settings. Test alerts are limited to 5 per hour.
  • Scoped webhooks stay scoped: if every service a webhook is limited to is deleted, the webhook is disabled, never widened to all services.
  • Webhook limit: 10 per account. Creating an 11th returns 409 with code webhook_limit.

CLI

dynv scan: CI, no account

dynv scan discovers packages, matches them against OSV and prints a table, or JSON with --json. It needs no Dynv account, token or server, and sends nothing to Dynv. Use it for pull requests and images that are not running yet.

sh
dynv scan [DIR] [--json] [--fail-on critical|high|medium|low|unknown|none] \
  [--system] [--offline --advisories DIR] [--osv-url URL]
  • --fail-on defaults to high. It exits 1 if any finding is at or above the threshold, 0 otherwise, and 2 on an error.
  • --system also reads OS packages and system site-packages. It is for images, not repositories.
  • --offline --advisories DIR matches against local OSV JSON records, with no network at all (for example behind an air-gapped mirror).
  • --osv-url URL or DYNV_OSV_URL points at a mirror or proxy.

A GitHub Actions job that fails the build on a high or critical finding:

.github/workflows/vulns.yml
name: vulnerabilities
on: [push, pull_request]
jobs:
  scan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
      - run: npm ci
      - run: curl -fsSL https://dynv.dev/install.sh | sh
      - run: dynv scan --fail-on high

For another ecosystem, install its dependencies instead of npm ci; the scan finds the rest. The installer verifies the binary's SHA-256 and installs to /usr/local/bin where it is writable.

dynv vulns: what your services report

Admin commands decrypt reports locally with your master token, apply your ignores and print what is open. dynv vulns on its own is the same as dynv vulns ls.

sh
# open findings, all services
dynv vulns

# one service, at least high, machine-readable, fail CI
dynv vulns --process web --min high --json --fail-on high

# follow the alert feed, or show recent events
dynv vulns watch
dynv vulns events --limit 20 --json
CommandMeaning
dynv vulns ls [--process NAME] [--min S] [--fail-on S] [--all] [--json]List open findings, with ignores applied. --all includes ignored findings; --fail-on exits 1 if any open finding is at or above the severity.
dynv vulns watch [--interval SECONDS]Poll the alert feed and print new events.
dynv vulns events [--limit N] [--json]Recent alert events, newest first (default 20).
dynv vulns ignore ID --reason TEXT [--process NAME] [--expires 30d]Ignore an advisory (id or alias), account-wide or for one service. The reason is sealed and never reaches the server. A duration such as 30d, 12h or 90m sets an expiry.
dynv vulns unignore ID [--process NAME]Remove matching ignore rules.

Ignore and unignore are writes: they need a writer (owner) master token. The read commands work with any master token.

MCP

Two read-only tools are always available to the local MCP server, even under --read-only. They decrypt locally with the session's master token; the server is not involved beyond returning the sealed reports it already holds.

ToolArgumentsReturns
list_vulnerabilities{process?, min_severity?}Open findings per service, filtered by service and minimum severity, with ignores applied.
explain_vulnerability{id, process?}Affected services and instances, the dependency path, the fixed version, and the upgrade or override instruction for that ecosystem.

See MCP for agents for setup and the rest of the tool set.

Limits and safety

Discovery is conservative by construction:

  • Allowlisted file names only. It opens only files in the table above, plus executables checked for the Go build-info magic. It never opens .env*, keys or certificates: a file called secrets.json is not read whatever it contains.
  • Symlinks are never followed, inside or outside the app directory. Outside roots are fixed system paths only (/var/lib/dpkg/status, /lib/apk/db/installed, /etc/os-release and system site-packages). .git is skipped.
  • Bounded work. At most 200,000 directory entries, depth 24, 64 MiB per file, 512 MiB read in total and 60 seconds for a walk; a partial scan is marked truncated. Parsers are pure functions of bytes with size caps and are fuzzed with garbage and truncated input.
  • Cheap re-checks. After the first scan, only the sizes and modification times of the files it read are checked, every 10 minutes. A full re-scan happens only when one changed.
  • Low priority. Discovery runs on its own thread at nice 19, about 30 seconds after boot, never on a request path. A failed scan or an OSV outage is a log line, not a crash.
  • Bounded reports. Each sealed report and known set is at most 256 KiB, under the 1 MiB request limit. There are at most 32 live instances per service (a 33rd evicts the oldest), and a token can write at most 12 reports per hour per instance and 48 per hour in total.

Not yet (phase 2): Maven, Gradle and NuGet; rpm; EPSS scores and CISA's KEV catalogue; SARIF output; the GitHub Action's scan input; cargo auditable sections; and the SDK direct-mode reporter. Dynv's server currently holds only counts and sealed reports, so use dynv scan for anything that is not a running service.