Skip to content

Repository files navigation

patchwatch

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.

CI Node Runtime dependencies Tests


The problem

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 medium

That is real output against a real 600-package lockfile. Four findings, not four hundred.


What it does

  • 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.20 against < 4.17.21 is a hit; 4.17.21 is 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 scan needs no Telegram and exits non-zero on findings.

Architecture

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
Loading
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

Quickstart

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 start

Then 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

docker compose up -d      # state persists in the patchwatch-data volume

CI gate

- 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 matters

Design notes

The interesting decisions, and what they cost.

Re-scoring CVSS instead of trusting the feeds

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.

Failing open on uncertainty

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.

Content-addressed dedupe

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.

Cursors advance only after a fully successful fetch

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.

First run seeds instead of alerting

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.

One JSON file instead of SQLite

The working set is a few thousand advisory hashes and a handful of chats. A single file written by atomic rename (temp file → fsyncrename) 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.

Zero runtime dependencies

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.


Threat model

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.


Tests

$ npm test
# tests 233
# suites 45
# pass 233
# fail 0

The 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-combination roundUp equivalence 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 lying content-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, never false).

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).


Prior art

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.

License

MIT

About

Vulnerability alerts for the things you actually run: correlates GHSA + NVD against your dependency tree, scores every advisory from its CVSS vector, and pushes only what affects an installed version.

Topics

Resources

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages