Vulnerability alerts for the things you actually run.
Correlates GitHub Security Advisories and NVD against your real dependency tree, scores every advisory from its CVSS vector, and pushes only the ones that affect a version you have installed — to Telegram, with inline triage.
Advisory feeds are firehoses. GHSA publishes hundreds of advisories a week across twelve ecosystems, and almost none of them are about software you run. The two common responses are both bad: subscribe to everything and learn to ignore it, or subscribe to nothing and find out from someone else.
The useful signal is narrow — this advisory affects a version you have installed, and here is what to upgrade to — and producing it requires actually comparing versions against affected ranges rather than matching on package names.
$ patchwatch scan
Scanning 600 packages from ./package-lock.json (floor: medium)
HIGH 7.5 GHSA-mh99-v99m-4gvg brace-expansion fix=5.0.8
brace-expansion: DoS via unbounded expansion length causing an out-of-memory process crash
HIGH 7.5 GHSA-r28c-9q8g-f849 postcss fix=8.5.18
PostCSS: Path Traversal in Previous Source Map Auto-Loading leads to Arbitrary .map File Disclosure
HIGH 7.5 GHSA-6g55-p6wh-862q postcss fix=8.5.12
PostCSS: Arbitrary file read via attacker-controlled sourceMappingURL in CSS comments
HIGH — GHSA-f88m-g3jw-g9cj sharp fix=0.35.0
sharp inherited vulnerabilities in libvips
4 finding(s) at or above mediumThat is real output against a real 600-package lockfile. Four findings, not four hundred.
- Two feeds, one model. GHSA for language packages (precise: it publishes version ranges). NVD for everything a lockfile can't see — OS packages, appliances, firmware — matched by keyword against CPE vendor/product strings. Records describing the same CVE are collapsed, preferring the one that carries version data.
- Version-range matching, not name matching.
lodash 4.17.20against< 4.17.21is a hit;4.17.21is silence. Handles semver, four-segment NuGet/Maven versions, PEP 440, and prerelease precedence. - CVSS computed from the vector. Every advisory is re-scored from its vector string rather than trusting the feed's number, so a severity floor means the same thing across sources. Disagreements are logged.
- Confirmed vs. unverified, stated explicitly. A version-verified hit and a keyword guess look different in the alert. Anything the tool could not determine is escalated with a caveat rather than dropped.
- Alerts that don't repeat. Content-addressed dedupe: a typo fix in a description stays silent, a severity escalation or a newly-discovered affected version alerts again.
- Runs as a CI gate too.
patchwatch scanneeds no Telegram and exits non-zero on findings.
flowchart LR
subgraph feeds [Upstream feeds]
GHSA[GHSA REST<br/>package ranges]
NVD[NVD CVE 2.0<br/>CPE vendor/product]
end
subgraph ingest [Ingest]
GHSA --> NORM[Normalise<br/>to Advisory]
NVD --> NORM
NORM --> CVSS[CVSS v3.1<br/>re-score]
CVSS --> COLLAPSE[Collapse<br/>shared CVEs]
end
subgraph match [Match]
COLLAPSE --> M{Match per chat}
WL[(Watchlist<br/>lockfile + /watch)] --> M
M -->|version in range| CONF[confirmed]
M -->|keyword / unparseable| UNK[unverified]
end
subgraph deliver [Deliver]
CONF --> DEDUPE{Seen before?}
UNK --> DEDUPE
DEDUPE -->|no| TG[Telegram<br/>rate-limited]
DEDUPE -->|yes| DROP[drop]
end
LEDGER[(Durable ledger<br/>cursors + dedupe)] <--> DEDUPE
LEDGER <--> ingest
| Module | Responsibility |
|---|---|
src/cvss.ts |
CVSS v3.1 base score from the spec equations |
src/version.ts |
Cross-ecosystem version precedence and range evaluation |
src/sources/{ghsa,nvd}.ts |
Feed clients, normalisation, incremental cursors |
src/matcher.ts |
Advisory × watchlist → match with a confidence level |
src/store.ts |
Atomic-rename persistence, content-addressed dedupe ledger |
src/pipeline.ts |
Ingest → dedupe → match → deliver, and cursor discipline |
src/telegram/ |
Bot API client, MarkdownV2 rendering, commands, transports |
git clone https://github.com/Iuke1/patchwatch && cd patchwatch
npm ci && npm run build
cp .env.example .env
# Set TELEGRAM_BOT_TOKEN (from @BotFather).
# Optionally set GITHUB_TOKEN — it raises the GHSA rate limit from 60/hr to 5000/hr.
npm startThen message the bot:
/start
/watch npm lodash 4.17.20 → version-verified alerts
/watch pip requests → any version of requests
/watch nginx → keyword watch against NVD
/severity high
/explain CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H
Or drive the whole watchlist from a lockfile with WATCH_MANIFEST=./package-lock.json.
Lock the bot down once you know your chat id (the startup logs print it on first /start): set ALLOWED_CHAT_IDS. A bot's @username is discoverable, so without an allowlist anyone who finds it can read your watchlist — which is a list of the vulnerable software versions you run.
docker compose up -d # state persists in the patchwatch-data volume- run: npm ci && npm run build
- run: WATCH_MANIFEST=./package-lock.json SEVERITY_FLOOR=high node dist/src/index.js scan
# exits 1 on findings, 2 on operational failure — the distinction mattersThe interesting decisions, and what they cost.
The feeds disagree. NVD's baseScore always matches its vector. GHSA's cvss.score is sometimes 0 on freshly-ingested advisories, and its severity band is occasionally hand-assigned and doesn't match the band implied by its own vector. GHSA also says moderate where CVSS says medium.
Computing from the vector gives one authoritative number, so SEVERITY_FLOOR=high means the same thing regardless of source. The cost is ~150 lines implementing the spec equations and a permanent obligation to track CVSS versions — v4.0 vectors are rejected and fall back to the feed's score, which is a real gap.
The roundUp docstring makes an empirical claim (that the spec formulation and naive Math.ceil agree for base scores), and a test enumerates all 2,496 metric combinations to keep that comment honest rather than letting it rot into a lie.
satisfies() returns boolean | undefined, and undefined — "I couldn't compare these" — is escalated into an alert carrying a caveat rather than silently dropped.
This is a deliberate bias. A spurious alert costs someone fifteen seconds. A missed one defeats the entire purpose of the tool. So an unparseable version, an advisory with no published range, and a package watched without a pinned version all produce an alert marked ❓ rather than nothing. It is noisier, and it is the right trade for this problem.
Keying the ledger on advisory ID alone means a revision never re-alerts — including a revision that raises severity from medium to critical. Keying on updatedAt means every description typo re-alerts. Neither is acceptable.
The key hashes only the fields that change what a responder would do: severity, score, affected ranges, withdrawal status. Cosmetic revisions are silent; genuine escalations fire again. The key is also per-chat, so one chat's delivery can't suppress another's.
A partial fetch that advanced the cursor would skip advisories permanently, with no error and no alert — the only bug in this program that is completely invisible. One feed failing keeps its own cursor and does not stop the other from alerting.
The corollary: the dedupe ledger is marked before the send is attempted, and is deliberately not rolled back on failure. Re-alerting for hours because one chat is unreachable is worse than losing one alert to a transient error.
Point this at a 2,000-package lockfile and the backfill window contains hundreds of matching advisories. Delivering them all would be useless and would get the bot rate-limited into a temporary ban — silent failure during exactly the window when something critical lands. The first poll populates the ledger and reports what it suppressed.
The working set is a few thousand advisory hashes and a handful of chats. A single file written by atomic rename (temp file → fsync → rename) makes the deployment docker run -v ./data:/data with no sidecar, and a crash mid-write leaves the previous good state rather than a truncated file. Flushes are debounced and coalesced, so a burst of 200 alerts costs one write.
Past ~10⁵ watchlist entries the flush cost stops being free and this should become SQLite. The Store interface is narrow enough to make that change contained.
Every dependency in a security tool is part of that tool's own attack surface, and a vulnerability scanner compromised through its dependency tree is an unusually bad headline. Node 20 provides everything needed: fetch, AbortSignal.any, node:test, node:crypto. CI asserts the count stays at zero.
The cost is real: a hand-rolled version comparator, a small .env parser, and a token bucket, all of which are solved problems. For a security tool with a small surface, that seemed like the right side of the trade.
What this defends against, and what it doesn't.
| Surface | Control |
|---|---|
Anyone who finds the bot's @username |
ALLOWED_CHAT_IDS, enforced once before dispatch — not per handler |
| Forged webhook updates | X-Telegram-Bot-Api-Secret-Token compared with timingSafeEqual; a forged update naming an allowlisted chat is the real attack, since authorisation trusts chat.id |
| Webhook memory exhaustion | Body cap enforced on the running total, not the declared content-length |
| Advisory text as injection | Advisory titles are attacker-influenced (anyone can file a PR that becomes an advisory). Every interpolated value is MarkdownV2-escaped by construction; truncation is escape-aware so it can't emit a dangling backslash |
| Credential leakage via logs | The Telegram bot token lives in the URL path, so the logger scrubs URLs and redacts any key matching `token |
| Upstream rate-limit bans | Token buckets per upstream, retry_after-aware backoff, full jitter |
| Container compromise | Non-root user, read-only root filesystem, all capabilities dropped, no-new-privileges |
Not defended against: a malicious GHSA/NVD response is trusted as data (it is parsed defensively and size-capped, but a false advisory produces a false alert); anyone with the bot token has full control of the bot; the state file is not encrypted at rest.
$ npm test
# tests 233
# suites 45
# pass 233
# fail 0The tests worth looking at:
cvss.test.ts— scores checked against published values for real vulnerabilities (Log4Shell, Heartbleed, BlueKeep) rather than snapshots of this implementation's own output. Plus the exhaustive 2,496-combinationroundUpequivalence check.pipeline.test.ts— the real pipeline, store, and Telegram client against a stub of the Bot API. Only the network is faked, so cursor discipline, dedupe-across-restart, first-run suppression, and unsubscribing blocked chats are properties of the shipped code path.webhook.test.ts— security tests, not plumbing tests: wrong secret of the same length, prefix of the real secret, oversized body with a lyingcontent-length, and an assertion that a forged update never reaches the handler.version.test.ts— the full semver §11 precedence chain, and the fail-open contract (undefined, neverfalse).
Three of these tests found real bugs during development, including a version parser that silently rejected every numeric-only prerelease identifier (1.0.0-1).
Dependabot and Renovate solve the adjacent problem — they open PRs, which is better than alerting when the fix is a version bump. osv-scanner and Trivy do richer scanning against more ecosystems.
patchwatch covers the gap those leave: push notification for things that aren't a version bump — an unpatched advisory, an OS package or appliance no lockfile knows about, a CVE you need to hear about at 2am rather than in a PR queue on Monday. If you only need lockfile PRs, use Dependabot.
MIT