DEV Community

Adam Lewandowski
Adam Lewandowski

Posted on

Build a 5-Minute Morning Security Brief: CISA KEV + Microsoft MSRC to a Compliance-Drift Report in Node.js

If you run a Cisco/Windows/VMware estate, your morning vulnerability check probably looks like mine used to: open the CISA KEV catalog, open the MSRC update guide, open Cisco's software download pages to see if the gold-star release moved, skim a couple of news feeds, and try to remember what version of FTD is actually deployed. Twenty minutes of tab-juggling, most of it re-reading things that didn't change overnight.

This is a tutorial for automating that into a single dated HTML report that lands before you sit down. The tool is called sec-watch. It's around 600 lines of plain Node.js, no npm dependencies, and it answers three questions every morning:

  1. Is anything in my stack being actively exploited? (CISA Known Exploited Vulnerabilities catalog)
  2. What did the latest Patch Tuesday drop on my Windows Server and Configuration Manager estate? (Microsoft MSRC CVRF feed)
  3. Am I drifting off Cisco's recommended releases? (gold-star compliance for FTD, FMC, ISE, ASA, Secure Client)

Two of those come from clean, free, no-auth JSON feeds. The third does not, and the workaround for that is half the reason this article exists. There's also a scheduling trick at the end that I haven't seen used much: the script signals "action needed" to the scheduler through its exit code.

Architecture

config.mjs (your estate)      targets.json (last-known Cisco targets)
        │                              │
        ▼                              ▼
index.mjs ──▶ kev.mjs ────┐
          ──▶ msrc.mjs ───┼──▶ compliance.mjs ──▶ report.mjs ──▶ reports/YYYY-MM-DD.html
          ──▶ intel.json ─┘                                      reports/latest.html
                                                        └──▶ exit code 0 or 2
Enter fullscreen mode Exit fullscreen mode

Everything is a plain .mjs module. fetch and AbortSignal.timeout are built into Node 20+, so package.json is just metadata:

{
  "name": "sec-watch",
  "type": "module",
  "scripts": { "start": "node index.mjs", "open": "node index.mjs --open" },
  "engines": { "node": ">=20" }
}
Enter fullscreen mode Exit fullscreen mode

Step 1: Describe your estate in one config file

Everything downstream keys off a single config.mjs. Each tracked product gets a current version (what you actually run), plus keywords used to match it against the two feeds:

export const config = {
  lookbackDays: 3,      // how far back "new" vulns count
  kevTrackedDays: 90,   // actionable KEV window for tracked products

  tracked: [
    {
      key: "ftd",
      name: "Cisco Secure Firewall Threat Defense (FTD)",
      vendor: "Cisco",
      goldStar: true,
      current: "FILL ME e.g. 7.4.2.1",
      kevKeywords: ["firepower threat defense", "secure firewall threat defense", "ftd"],
    },
    {
      key: "winsrv2022",
      name: "Windows Server 2022",
      vendor: "Microsoft",
      goldStar: false,
      current: "FILL ME e.g. latest CU 2026-05",
      msrcKeywords: ["Windows Server 2022"],
    },
    {
      key: "esxi",
      name: "VMware ESXi",
      vendor: "VMware",
      goldStar: false,
      current: "FILL ME e.g. 8.0 U3",
      kevKeywords: ["esxi"],
    },
    // ...FMC, ISE, ASA, Secure Client, Server 2019/2025, MECM, SolarWinds, vCenter
  ],

  // Vendors you don't version-track but want KEV visibility on
  watchKeywords: [
    "ios xe", "fortinet", "pan-os", "citrix", "netscaler", "ivanti",
    "exchange server", "veeam", "vcenter", "solarwinds", "manageengine",
    // ...the full list in my config is ~60 entries
  ],
};
Enter fullscreen mode Exit fullscreen mode

Two deliberate choices here. First, kevKeywords and msrcKeywords are separate, because the two feeds name products differently (KEV says "Firepower Threat Defense", MSRC product names are long strings like "Windows Server 2022 (Server Core installation)"). Second, watchKeywords casts a wide net over adjacent infrastructure. You don't run FortiGate, but if a pre-auth RCE on a competing firewall goes into KEV, you want to know, because the same attacker attention tends to rotate through the whole edge-device category.

Step 2: Pull the KEV catalog

CISA publishes the entire Known Exploited Vulnerabilities catalog as one JSON file. No API key, no rate-limit dance:

const KEV_URL =
  "https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json";

export async function fetchKev() {
  const res = await fetch(KEV_URL, { signal: AbortSignal.timeout(30000) });
  if (!res.ok) throw new Error(`KEV fetch ${res.status}`);
  const json = await res.json();
  return json.vulnerabilities || [];
}
Enter fullscreen mode Exit fullscreen mode

Matching is a lowercase substring check across the fields KEV gives you:

function matches(vuln, keywords) {
  const hay = `${vuln.vendorProject} ${vuln.product} ${vuln.vulnerabilityName} ${vuln.shortDescription}`.toLowerCase();
  return keywords.some((k) => hay.includes(k.toLowerCase()));
}
Enter fullscreen mode Exit fullscreen mode

The useful part is what happens after matching. Tracked products get split into a recent actionable window and an all-time count:

const recentCutoff = Date.now() - (config.kevTrackedDays || 90) * 86400000;
const trackedRecent = trackedUniq.filter((v) => new Date(v.dateAdded).getTime() >= recentCutoff);
const trackedOlder = trackedUniq.length - trackedRecent.length;
Enter fullscreen mode Exit fullscreen mode

Why bother? Because ASA and ISE have KEV entries going back years. If you're current on patches, those are history, not work. Showing "2 new in the last 90 days, plus 11 older entries you should already have covered" keeps the report honest without drowning the signal. Watch-list vendors get an even tighter filter: only entries added within lookbackDays (3 days) appear at all.

Step 3: Parse Patch Tuesday from the MSRC CVRF feed

Microsoft's CVRF API is also free and unauthenticated. You list the monthly documents, grab the newest, and walk its vulnerability array:

const UPDATES_URL = "https://api.msrc.microsoft.com/cvrf/v3.0/updates";
Enter fullscreen mode Exit fullscreen mode

The non-obvious part is that severity and exploitation status live in a Threats array, tagged by numeric type:

// Threat Type per MSRC CVRF: 0=Impact, 1=Exploit Status, 3=Severity
function severityOf(vuln) {
  const t = (vuln.Threats || []).find((x) => x.Type === 3);
  return t?.Description?.Value || "";
}
Enter fullscreen mode Exit fullscreen mode

Each CVE lists affected products by ID, so you build an ID-to-name map from the document's ProductTree, then match names against your msrcKeywords. A monthly document covers hundreds of CVEs across everything Microsoft ships; after filtering to Windows Server and Configuration Manager, a typical month leaves 40 to 80 relevant entries. Those get sorted so the report reads top-down in priority order:

// Sort: critical/exploited first, then by CVSS
const rank = (s) => (/critical/i.test(s) ? 2 : /important/i.test(s) ? 1 : 0);
findings.sort((a, b) => rank(b.severity) - rank(a.severity) || b.cvss - a.cvss);
Enter fullscreen mode Exit fullscreen mode

That exploited-and-critical-first ordering repeats everywhere in sec-watch. The report renderer applies the same idea to research highlights ((b.exploited - a.exploited) || (sevRank(b.severity) - sevRank(a.severity))), and rows with known ransomware campaign use get a red row tint. A morning brief you read in five minutes must put the item that ruins your week on line one.

Step 4: Gold-star compliance drift

Cisco marks a "Suggested Release" with a gold star on its download pages. In a typical FTD/ISE shop that star is the de facto patch-level standard: an auditor asks whether you're on it, and change requests reference it. But there is no API for it. The star lives in web pages, and occasionally moves.

sec-watch handles this with a two-layer design. targets.json stores the last-known-good target per product, with the source URL and the date it was seen:

{
  "cisco_gold_star": {
    "ftd": {
      "suggested_release": "7.6.4",
      "source": "https://www.cisco.com/.../threat-defense-compatibility.html",
      "seen": "2026-05-27"
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Each run, fresh research results merge into this file, but only when the lookup actually returned a version:

export async function mergeAndSaveTargets(targets, webGoldStar, dateStr) {
  targets.cisco_gold_star = targets.cisco_gold_star || {};
  let changed = false;
  for (const g of webGoldStar || []) {
    if (!g.key) continue;
    if (g.suggested_release && g.suggested_release.trim()) {
      targets.cisco_gold_star[g.key] = {
        suggested_release: g.suggested_release.trim(),
        source: g.source || "",
        notes: g.notes || "",
        seen: dateStr,
      };
      changed = true;
    }
  }
  // ...persist if changed
  return targets;
}
Enter fullscreen mode Exit fullscreen mode

So a failed lookup degrades to "compare against the last version we verified" instead of losing compliance data. The report notes the fallback so you know the target might be stale.

Version comparison itself is deliberately dumb. Cisco writes versions as "7.4.2.1", "Version 7.4.2.1", or "3.3 Patch 4" depending on the page, so both sides get normalized before an equality check:

function normalize(v) {
  if (!v) return "";
  return String(v)
    .toLowerCase()
    .replace(/version|release|patch/g, (m) => (m === "patch" ? "p" : ""))
    .replace(/[^0-9a-z.]/g, "")
    .replace(/\.+/g, ".")
    .replace(/^\.|\.$/g, "");
}
Enter fullscreen mode Exit fullscreen mode

Equal means COMPLIANT, different means DRIFT, and an unfilled current means UNKNOWN_CURRENT. No greater-than or less-than logic, on purpose: gold-star compliance means being on the suggested release, and being ahead of it is still a state worth flagging to a human.

Step 5: The research step, and the intel.json escape hatch

The gold-star lookup and a "notable new vulnerabilities" section both need live web research. My first version shelled out to an LLM CLI (Claude Code) with web-search tools enabled, prompted it for strict JSON, and parsed the result. It works, and the code is in the repo, but a nested CLI call that browses the web can take minutes and occasionally times out. That's a bad property for the one step of your morning pipeline that runs unattended.

So the workflow I actually use is the --intel flag. Any research process, human or AI, writes a plain intel.json file, and the report renders from that:

if (USE_INTEL) {
  // Manual daily workflow: read intel.json populated in-session
  // from live web research. No nested CLI -> no timeouts / contention.
  const intel = JSON.parse(await readFile(new URL("./intel.json", import.meta.url), "utf8"));
  web = {
    cisco_gold_star: intel.cisco_gold_star || [],
    highlights: intel.highlights || [],
    analyst_note: intel.analyst_note || "",
    web_ok: true,
  };
}
Enter fullscreen mode Exit fullscreen mode

Each highlight is a structured record: category, product, CVE, severity, an exploited boolean, a summary, a recommended action, and a real source URL. In practice I do the research in an interactive AI session each morning (with a strict instruction: only items from the last 3 days, every item needs a source URL you actually visited), it writes intel.json, and then node index.mjs --intel --open renders everything in a few seconds. The file format is the contract; where the research comes from is swappable. If you want zero AI involvement, run node index.mjs --no-web and you still get KEV, MSRC, and compliance against the stored targets.

Step 6: Schedule it, and make the exit code mean something

The last lines of index.mjs are my favorite part of the project:

// Exit non-zero if there's drift or an actively-exploited tracked product, so
// a scheduler can flag the run as "action needed".
const actionNeeded = compliance.summary.drift > 0 || (kev.tracked || []).length > 0;
process.exitCode = actionNeeded ? 2 : 0;
Enter fullscreen mode Exit fullscreen mode

Exit 0 means the estate is quiet: no drift, no fresh KEV entries against tracked products. Exit 2 means the report contains something that needs a human. Windows Task Scheduler records the last run result per task, so a glance at the console (or any monitoring that watches task results) distinguishes "ran fine, nothing urgent" from "ran fine, go read the report" without opening the HTML. The same convention plugs straight into cron plus an alerting wrapper, or a CI job that fails visibly on exit 2. Reserving 2 keeps it distinct from exit 1, which the fatal-error handler uses, so crashes and findings never look alike.

Registration is a short PowerShell script using the native scheduled-task cmdlets. The settings matter more than the trigger:

$Settings = New-ScheduledTaskSettingsSet -StartWhenAvailable -RunOnlyIfNetworkAvailable `
  -ExecutionTimeLimit (New-TimeSpan -Minutes 20) -DontStopIfGoingOnBatteries -AllowStartIfOnBatteries
Enter fullscreen mode Exit fullscreen mode

-StartWhenAvailable is the one people forget: if the machine was asleep at 07:00, the task runs at wake instead of silently skipping the day. -RunOnlyIfNetworkAvailable avoids a run full of fetch errors on a cold boot. The script also resolves the full path to node.exe before registering, because Task Scheduler does not reliably inherit your interactive PATH.

Every run writes both a dated file (reports/2026-05-27.html) and a stable reports/latest.html, so you can bookmark one URL and also keep an audit trail of what you knew on a given morning.

Honest limitations

  • Keyword matching is crude. Substring matching against KEV text can over-match (short keywords like "ftd" hitting unrelated descriptions) and under-match (a vendor renames a product line). Keep keywords specific and skim the raw catalog occasionally to check your filters.
  • The gold-star target is only as good as the research. There is no Cisco API for the suggested release, and the star sometimes lags or differs between the compatibility matrix and the download portal. The report footer says it plainly: verify against Cisco.com before acting. This tool is a prompt, not an authority.
  • MSRC coverage is the latest monthly document only. Out-of-band releases usually revise the current month's document, but a revision to an older month would be missed.
  • Version comparison is string equality. "7.6.4" versus "7.6.4.1" is DRIFT even though you might be deliberately ahead. That's a feature for compliance reporting and an annoyance otherwise.
  • It watches feeds, not your boxes. The current versions in config are hand-maintained. If you upgrade FTD and forget to update the config, the report lies to you. Wiring it to pull versions from FMC's API or your CMDB is the obvious next step.
  • KEV and Patch Tuesday are not the whole threat picture. Vendor advisories (Cisco PSIRT, VMware VMSA) publish before things reach KEV. The research step partly covers this gap, but a feed-only run has a blind spot measured in days.

None of that stops the tool from doing its actual job: replacing twenty minutes of tab-juggling with five minutes of reading, every morning, with a paper trail. Build the boring version first. The feeds are free, the whole thing is dependency-free Node, and once the report is landing daily you'll know exactly which of the limitations above is worth fixing for your estate.


Adam Lewandowski is a network and security engineer (CompTIA Security+, CCNA, CCNP, VMware VCP-DCV) with several years of hands-on enterprise operations across Cisco FTD/FMC/ISE, Windows Server, MECM, and VMware. He builds automation that takes the repetitive judgment calls out of security operations — without taking the humans out of the loop. Find him on LinkedIn.

Top comments (0)