The last two posts here were about a Linux kernel TCP flaw and PHP2Shell. In both cases the weekly dependency scan said nothing, because neither bug lived in a package from our lockfile. One was in the kernel, the other in the PHP runtime. We heard about them from people, not from CI.
So we added a small job: every morning at 10:00 (GMT+7) it pulls the cve.now feed for our stack and posts the CVEs from the last 24 hours to the team’s Mattermost channel. This post walks through how it works, the few details that turned out to matter, and what changes if you post to Slack instead.
Why a dependency scan isn’t enough
Tools like npm audit, Dependabot or Trivy look at what you have installed. That leaves two gaps:
- The runtime layer. The kernel, nginx, OpenSSL, PostgreSQL, Redis, Chrome (if you render PDFs or screenshots with it). None of these are in
package.json. - Advisories with no fix yet. A scanner keyed on “vulnerable version → fixed version” often stays silent until a fixed release exists. That’s the window where a workaround matters most.
A feed filtered by technology covers both. It’s noisier, but a daily digest in a channel is easy to skim.
The shape of it
Two files, no npm install:
.github/workflows/cve-alert.yml: a cron trigger plus a manual trigger.scripts/security/cve-alert.mjs: about 200 lines, using only Node’s built-infetch,parseArgsandtimers/promises.
The stack is a plain array, and the feed URL is built from it:
export const TECH = [
"chrome", "docker", "linux-kernel", "nextjs", "nginx", "node",
"openssl", "php", "postgresql", "react", "redis", "wordpress",
];
export const FEED_URL = `https://cve.now/feed?type=cve&tech=${TECH.join(",")}&format=json`;
The workflow does a sparse checkout of just the script folder, runs the script’s tests, and only then runs the script. If someone breaks the formatting code, the tests fail and nothing gets posted:
on:
schedule:
- cron: "0 3 * * *" # 10:00 GMT+7, reports the 24h before
workflow_dispatch:
inputs:
date:
description: "GMT+7 day to report (YYYY-MM-DD). Empty = the last 24h"
required: false
dry_run:
description: "Print instead of posting to Mattermost"
type: boolean
default: false
# A manual run overlapping the schedule would double-post; queue, don't cancel.
concurrency:
group: cve-alert
cancel-in-progress: false
jobs:
alert:
runs-on: self-hosted
timeout-minutes: 10
steps:
- uses: actions/checkout@v5
with:
sparse-checkout: scripts/security
- uses: actions/setup-node@v5
with:
node-version: 22
- run: node --test scripts/security/__tests__/cve-alert.test.mjs
- env:
MATTERMOST_WEBHOOK_URL: ${{ secrets.MATTERMOST_WEBHOOK_URL }}
REPORT_DATE: ${{ inputs.date }}
DRY_RUN: ${{ inputs.dry_run && '--dry-run' || '' }}
run: node scripts/security/cve-alert.mjs $REPORT_DATE $DRY_RUN
Detail 1: the report window
The first version reported “today’s CVEs”, which at 10:00 meant everything from 10:00 to midnight was never reported. The fix is a rolling 24-hour window, with one twist: floor the run time to the hour.
GitHub’s cron is best effort. A job scheduled for 10:00 can start at 10:20, or later. If the window were [now - 24h, now), a late run would shift the window and either skip or repeat CVEs. Flooring makes every run on a given day report exactly [10:00 yesterday, 10:00 today), so back-to-back runs meet with no gap and no overlap:
export function reportWindow(date, now = Date.now()) {
if (date) {
assertDate(date);
const from = Date.parse(`${date}T00:00:00+07:00`);
return { from, to: from + DAY, label: date };
}
const to = Math.floor(now / HOUR) * HOUR;
const from = to - DAY;
return { from, to, label: `${gmt7Time(from)} → ${gmt7Time(to)} GMT+7` };
}
Passing a date instead reports one whole GMT+7 day, which is handy for backfills. The date is validated properly: 2026-13-45 is an error, not a green run that reports “0 CVEs”.
The feed returns at most 200 items (roughly two days). If the feed is full and every item is inside the window, older items in the window may have been cut off, so the script prints a ::warning:: that shows up in the Actions UI.
Detail 2: the feed is untrusted input
This one is easy to miss. CVE titles and summaries are written by third parties, and you’re about to post them into a chat as a bot. Without care, a summary containing @channel pings the whole team, and a title containing ]( can break out of the link text. So before anything is posted:
// Feed text is third-party: one line, no @channel/@all pings or ~channel
// links (a zero-width space after @ or ~ stops Mattermost parsing them),
// clipped last so `max` bounds the final length.
const clean = (s, max) => {
const line = String(s ?? "")
.replace(/\s+/g, " ")
.trim()
.replace(/[@~]/g, "$&");
return line.length > max ? `${line.slice(0, max - 1)}…` : line;
};
export function formatItem(i) {
// Escape \ [ ] so the title can't break out of the link text.
const title = clean(String(i.title ?? "").replace(/[\\[\]]/g, "\\$&"), TITLE_MAX);
const link = CVE_URL.test(i.url ?? "") ? `[${title}](${i.url})` : title;
return `- ${link}\n ${clean(i.content_text, SUMMARY_MAX)}`;
}
A few things worth pointing out:
- The backslash is escaped too. Otherwise a title ending in
\escapes the closing]and breaks the link. - Only URLs matching
^https://cve\.now/...(up to 100 chars) become links. Anything else is shown as plain text. - Clipping happens after escaping, so the length limit is the real final length.
Detail 3: fitting Mattermost’s post limit
Mattermost rejects posts over 16,383 characters, and a busy day can have 100+ CVEs. Because titles are clipped to 150 chars and summaries to 300, one item is at most about 580 characters. That makes 20 items per post safe whatever the feed sends (worst case about 11.6k). The script splits the list into posts of 20, and only the first post gets the header:
#### :rotating_light: CVE early warnings — 2026-11-05 10:00 → 2026-11-06 10:00 GMT+7 (70)
- [CVE-2026-121043: UI misrepresentation in Payments in Google Chrome prior to 157.0.8103.38 …](https://cve.now/CVE-2026-121043)
Update: CVSS score 5.4 published. Google Chrome desktop web browser is affected …
- …
Detail 4: retries, without double posting
Both the feed and the webhook go through one small helper: a 15-second timeout per attempt, and up to 3 attempts on network errors, 429 and 5xx, with 1 s and 2 s backoff. Other 4xx responses fail right away, since retrying a bad request won’t help.
export async function request(fetch, url, init, sleep = delay, tries = 3) {
for (let n = 1; ; n++) {
try {
const res = await fetch(url, { ...init, signal: AbortSignal.timeout(TIMEOUT_MS) });
if (n >= tries || (res.status !== 429 && res.status < 500)) return res;
} catch (err) {
if (n >= tries) throw err;
}
await sleep(1000 * 2 ** (n - 1));
}
}
Without retries, a single Mattermost hiccup on post 3 of 5 would fail the run, and re-running it would post 1 and 2 again. Posts are sent in order, and the run stops at the first one that still fails after retries.
There’s one known edge left: if the webhook accepts a post but the response times out, the retry posts it twice. That’s rare, and a duplicate alert is better than a missing one.
Posting to Slack instead
The fetch, the window, the splitting and the retries don’t change. What changes is the formatting, because Slack doesn’t use Markdown. It uses its own format, mrkdwn, and incoming webhooks behave a bit differently.
Links and escaping. Slack links are written <url|text>, not [text](url). Square brackets mean nothing, so there’s no \[ escaping. Instead, Slack treats <, > and & as control characters, and you must replace them with <, > and &. That one step is also the safety step: a title can’t close the link early with >, and it can’t smuggle in a mention.
Mentions. In Slack a real @channel ping is written <!channel> (or <!here>, or <@U12345> for a person). Plain text @channel in a webhook message stays plain text, unless you set link_names: true. So with < escaped and link_names left off, feed text can’t ping anyone, and the zero-width space trick isn’t needed. ~channel is a Mattermost thing and has no meaning in Slack.
// Slack mrkdwn: only & < > are special. Escaping them is enough to stop
// <!channel> pings and to stop a title breaking out of <url|text>.
const escapeSlack = (s) =>
s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
const clean = (s, max) => {
const line = String(s ?? "").replace(/\s+/g, " ").trim();
const clipped = line.length > max ? `${line.slice(0, max - 1)}…` : line;
return escapeSlack(clipped);
};
export function formatItem(i) {
const title = clean(i.title, TITLE_MAX);
const link = CVE_URL.test(i.url ?? "") ? `<${i.url}|${title}>` : title;
return `• ${link}\n ${clean(i.content_text, SUMMARY_MAX)}`;
}
Note the order flip: here I clip before escaping, so a & never gets cut in half. The final text can then be a little longer than max (each & grows by 4 chars), which is fine given how much room the limits leave.
Header. mrkdwn has no #### headings. Use bold instead: *:rotating_light: CVE early warnings — ...*. Bullet lists aren’t real lists either, so • reads better than -.
Size. Slack truncates a message’s text at 40,000 characters, and recommends staying under 4,000. With the same clipping, 20 items is about 11k, so on Slack I’d drop to about 6 per post. If you switch to Block Kit for nicer layout, the limits are tighter: 3,000 chars per section block and 50 blocks per message.
Unfurls. Slack previews links in webhook messages. Twenty CVE links means twenty previews, which buries the list. Turn them off in the payload.
Channel and bot name. Webhooks from a Slack app are tied to one channel when you install them, and Slack ignores channel, username and icon_emoji in the payload. So the payload is just:
body: JSON.stringify({ text, unfurl_links: false, unfurl_media: false }),
To post to a different channel, create another webhook.
Rate limits. Incoming webhooks allow about one message per second per channel. Five posts in a row can hit that, and Slack answers 429 with a Retry-After header (in seconds). The retry helper already retries on 429, but it’s worth respecting the header instead of the fixed 1 s/2 s backoff:
const wait = Number(res.headers.get("retry-after")) * 1000 || 1000 * 2 ** (n - 1);
await sleep(wait);
Errors. A failing Slack webhook returns a plain-text reason in the body (invalid_payload, channel_not_found, no_text), so the existing throw new Error(... await post.text()) already gives a useful log line.
| Mattermost | Slack | |
|---|---|---|
| Link | [text](url) | <url|text> |
| Escape | \ [ ], zero-width space after @ ~ | & < > as HTML entities |
| Heading | #### ... | *bold* |
| Post limit | 16,383 chars | 40,000 (4,000 recommended) |
| Channel/username in payload | Yes (if allowed) | Ignored, fixed by the webhook |
| Link previews | Server setting | unfurl_links: false |
Testing without the network
run() takes fetch, log, sleep and now as parameters, defaulting to the real ones. Tests pass fakes, so they run instantly with no network, no backoff waits and a fixed clock. The suite uses only node:test:
- the window: flooring, a late cron start, back-to-back runs meeting exactly, half-open bounds
- sanitising:
@,~,\,[,], newlines, foreign or overlong URLs, missing fields - a worst-case post-size check against the 16,383 limit
- retries and timeouts, send order, stopping at a post that keeps failing
- the real CLI run end to end with the feed stubbed via
node --import
A dry run against the live feed for one busy day gave 81 CVEs, sent as 5 posts (20 + 20 + 20 + 20 + 1).
Takeaways
- Dependency scanners and CVE feeds answer different questions. You want both.
- If a cron job reports “the last N hours”, floor the time, or late starts will drift your window.
- Anything from a third-party feed that you post as a bot is untrusted input: neutralise mentions, escape markup, allowlist links, and clip after escaping.
- Retry transient failures in a job that posts in parts, or a re-run will duplicate what already went out.
- Modern Node has
fetch,parseArgs,AbortSignal.timeoutandnode:test. A job like this needs no dependencies.