Get alerted when a new CVE hits what you're running in production
Short answer: a CI scan tells you about a dependency when you build. To hear about a vulnerability published after you deployed, something has to keep checking what is actually installed in the running service. With Dynv, add --vulns to dynv run: the service checks its installed packages against OSV.dev at boot, whenever they change, and every six hours, and alerts you by email, webhook or Slack. Today that means within hours of an advisory reaching OSV, not instantly.
This page shows the setup, what a real run produced, how to verify a webhook, and the limits. Everything was run with the current CLI on 2026-11-01 except the webhook delivery itself, which we could not receive on a local machine (Dynv’s server will not call loopback or private addresses); the signature check was tested against the documented format.
What is checked, and when
Per the vulnerability monitoring docs, with --vulns on, dynv run reads the packages the service has installed and matches them against OSV.dev:
- at boot (the first scan starts about 30 seconds after the service starts, on a low-priority thread, so it never competes with your app’s startup);
- when the installed files change;
- every six hours while the service runs (jittered by about ten percent; change it with
--vulns-intervalorDYNV_VULNS_INTERVAL).
It reads the real installed state (node_modules, Python *.dist-info, Go build info in binaries, lockfiles as a fallback), not just a manifest, for npm, PyPI, Go, crates.io, RubyGems and Packagist, plus OS packages on Debian, Ubuntu and Alpine.
You are alerted when a new advisory is published for a package that was already running, or when a newly deployed version is already vulnerable. The first report for a service with findings is one summary event, not one alert per finding; after that only genuinely new findings alert.
Instant advisory alerts are coming soon. Today the delay for a new advisory is the time until the service’s next scheduled check (up to about six hours), plus however long OSV takes to publish the record. Don’t treat this as a pager for zero-day response; treat it as a daily-safety net that does not depend on you remembering to scan.
Turn it on
dynv run --vulns -- node app.js
# or, with no change to the command (handy on platforms like Coolify):
DYNV_VULNS=1
--vulns needs a service token (the normal dynv run setup, see the quickstart). Two flags worth knowing: --vulns-dir DIR for the app directory to scan (default: the current directory), and --vulns-no-system.
Use --vulns-no-system on a host that runs other things. In our first test run the monitor reported “inventory: 142 packages from 5 source(s)” for a tiny Node app, because it also read the host machine’s system Python packages, and the report was full of findings that had nothing to do with the app. Inside a container this is usually what you want (the OS packages are part of what you ship); on a shared host, it is noise.
A real run
A service named shop2 with [email protected] installed, started with the system packages excluded:
$ dynv run --vulns --vulns-no-system -- node app.js
dynv: vulns: inventory: 1 packages from 1 source(s)
dynv: vulns: reported 6 finding(s) (0 critical, 3 high); alert sent
Then we “deployed” a new version that added [email protected] and restarted:
dynv: vulns: inventory: 2 packages from 1 source(s)
dynv: vulns: reported 8 finding(s) (1 critical, 3 high); alert sent
The alert feed from the CLI shows one event per change, each counting only what is new:
$ dynv vulns events
2026-11-01 01:04Z shop2 vulnerable deploy 1 critical, 1 medium
2026-11-01 01:03Z shop2 first report 3 high, 3 medium
And the detail, decrypted on your machine with your master token:
$ dynv vulns --process shop2
shop2 — 2 instance(s): 1 critical, 3 high, 4 medium
critical minimist 1.2.0 1.2.6 GHSA-xvch-5gv4-984h direct
high lodash 4.17.15 4.17.21 GHSA-35jh-r3h4-6jhm direct
...
fix: upgrade minimist 1.2.0 → 1.2.6
(That output shows the “vulnerable deploy” kind. A new advisory against a package that was already running produces the same kind of event with the kind new_advisory; we cannot trigger one on demand, so we have not run that case.)
Where alerts go
Alerts go to email, signed webhooks and Slack-compatible webhooks; every alert also appears in the dashboard’s Security feed. Each channel has its own minimum severity (default high), and a webhook can be limited to specific services so production and staging alert in different places. You set these up in the dashboard’s Security page (or the API).
What an alert contains is deliberately thin. Dynv’s server never sees package names, versions or advisory IDs: the service seals the full report under a key the server cannot open, and the server only sees severity counts. So the email or webhook says something like “2 newly published vulnerabilities affect service pKx9aB3cD2mZ5nQ8 (2 high)” with a link, and the dashboard, which decrypts in your browser, shows which packages. That is the cost of zero-knowledge, stated plainly: an alert is less convenient than “lodash 4.17.15 → 4.17.21”. The documented webhook body:
{
"type": "vulnerability.alert",
"kind": "new_advisory",
"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
}
(abridged from the docs; the full body also has event_id and account_id.) Requests carry X-Dynv-Signature: t=<unix seconds>,v1=<hex> where v1 is HMAC-SHA256 of "<t>.<raw body>" with the webhook secret, and X-Dynv-Event. Verify against the raw body, compare in constant time, and reject old timestamps:
import { createHmac, timingSafeEqual } from 'node:crypto';
export function verify(rawBody, header, secret, now = Date.now() / 1000) {
const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')));
const t = Number(parts.t);
if (!parts.v1 || !Number.isFinite(t) || Math.abs(now - t) > 300) return false;
const want = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest();
const got = Buffer.from(parts.v1, 'hex');
return got.length === want.length && timingSafeEqual(got, want);
}
We tested this against the documented scheme: a correctly signed body passes; a tampered body, wrong secret, a ten-minute-old timestamp and a garbage header all fail. Delivery has a 10-second timeout, no redirects and three attempts (after 10 s and 60 s); webhooks cannot target private or loopback addresses; the secret is shown once at creation.
Limits to know about
- Only running services are re-checked. A stopped service is not re-matched (it is also not exposed). For images you haven’t deployed, scan in CI (how).
- It reports; it doesn’t block. A vulnerability alert is a known-advisory match, not a statement that you are exploitable (triage).
- A compromised service can lie about its own findings (report zero, or flood alerts for itself). It cannot affect another service or account.
- Latency is bounded by the schedule and by OSV. See above; instant alerts are coming soon.
- KEV and EPSS data are not shown yet.
- OSV sees package names and versions. Each service queries OSV.dev for the (ecosystem, name, version) tuples it runs, from its own IP; private names matching
DYNV_VULNS_PRIVATEare never sent, andDYNV_OSV_URLcan point at a mirror (privacy section).
Facts last verified . Spotted an error? Tell us.