/** * Analysis for research report EFF-RR-2026-009 (draft): edge devices after the patch. * * node research/edge-devices-2026/analyse.mjs [advisories.csv] * * Inputs (see README.md for how to retrieve them): * kev.json CISA Known Exploited Vulnerabilities catalogue (JSON feed) * advisories.csv optional; the manual vendor-advisory register for RQ3, in the format of * the advisory-register-template.csv this script writes * * The edge category is read from research/kev-2026/analyse.mjs so that this report and * EFF-RR-2026-001 always use the same definition. * * Outputs: results.json (every number in the report), edge-2023-2026.csv (one coded row per * entry), families.csv (one row per product family) and advisory-register-template.csv. */ import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs'; import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; const [kevPath, out, advisoryPath] = process.argv.slice(2); if (!out) { console.error('Usage: node analyse.mjs [advisories.csv]'); process.exit(1); } const kev = JSON.parse(readFileSync(kevPath, 'utf8')); /* ---------- Edge definition, shared with EFF-RR-2026-001 ---------- */ const rr001 = readFileSync(join(dirname(fileURLToPath(import.meta.url)), '../kev-2026/analyse.mjs'), 'utf8'); const m = rr001.match(/\['edge', 'Network edge and remote access', \/(.*)\/i\]/); if (!m) throw new Error('Edge rule not found in research/kev-2026/analyse.mjs'); const EDGE = new RegExp(m[1], 'i'); const isEdge = (v) => EDGE.test(`${v.vendorProject.trim()} ${v.product.trim()}`); /* ---------- Coding rules ---------- */ // Product family. KEV names one product line in several ways ("NetScaler", "NetScaler ADC and // Gateway"), so entries are grouped by these rules, tested in order against // " ". Unmatched entries form a family of their own // " ". const FAMILIES = [ ['Palo Alto Networks PAN-OS', /palo alto.*pan-os/i], ['Palo Alto Networks Expedition', /palo alto.*expedition/i], ['Fortinet FortiOS and FortiProxy', /fortinet.*(fortios|fortiproxy|fortigate)/i], ['Fortinet FortiWeb', /fortinet.*fortiweb/i], ['Fortinet FortiManager', /fortinet.*fortimanager/i], ['Fortinet FortiMail', /fortinet.*fortimail/i], ['Fortinet FortiSandbox', /fortinet.*fortisandbox/i], ['Citrix NetScaler ADC and Gateway', /citrix.*netscaler/i], ['Ivanti Connect Secure and Policy Secure', /ivanti.*(connect secure|policy secure)/i], ['Ivanti Cloud Services Appliance', /ivanti.*(cloud services? appliance|epm csa)/i], ['Cisco ASA and FTD', /cisco.*(adaptive security appliance|\basa\b|threat defense|\bftd\b)/i], ['Cisco Firewall Management Center', /cisco.*(management center|\bfmc\b)/i], ['Cisco SD-WAN', /cisco.*sd-wan/i], ['Cisco IOS, IOS XE, IOS XR and NX-OS', /cisco.*(\bios\b|nx-os)/i], ['Cisco Identity Services Engine', /cisco.*identity services engine/i], ['Cisco Secure Email Gateway', /cisco.*email gateway/i], ['Juniper Junos OS', /juniper.*junos/i], ['SonicWall SMA1000', /sonicwall.*sma ?1000/i], ['SonicWall SMA100', /sonicwall.*sma ?100\b/i], ['SonicWall SonicOS', /sonicwall.*sonicos/i], ['F5 BIG-IP', /f5.*big-ip/i], ['Barracuda Email Security Gateway', /barracuda/i], ['Check Point Security Gateway', /check point/i], ['BeyondTrust Remote Support and PRA', /beyondtrust/i], ['Array Networks ArrayOS AG', /array networks/i], ['Progress Kemp LoadMaster', /loadmaster/i], ['Zyxel firewalls', /zyxel.*firewall/i], ['DrayTek Vigor', /draytek/i], ['Versa Director and Concerto', /^versa /i], // anchored: "traversal" appears in many vulnerability names ['Arista VeloCloud Orchestrator', /velocloud/i], ]; const familyOf = (v) => { const s = `${v.vendorProject.trim()} ${v.product.trim()} ${v.vulnerabilityName || ''}`; for (const [name, re] of FAMILIES) if (re.test(s)) return name; return `${v.vendorProject.trim()} ${v.product.trim()}`; }; // Device class. Consumer and small-office devices are sold, supported and replaced very // differently from enterprise appliances, so they are reported separately. const SOHO = /d-link|tp-link|asus|dd-wrt|zyxel.*(dsl|emg|p660)|small business rv|tl-w|archer/i; const deviceClass = (v) => (SOHO.test(`${v.vendorProject} ${v.product}`) ? 'soho' : 'enterprise'); // End of life: CISA or the vendor states in the entry that the product is, or could be, // end-of-life or end-of-service. KEV's standard "discontinue use of the product if mitigations // are unavailable" is not counted; it appears in almost every entry. "Definite" separates // entries that say the product IS end-of-life from CISA's hedged "could be end-of-life". const EOL = /end.of.life|\beol\b|end-of-service|end-of-support|no longer supported|legacy .* products/i; const EOL_DEFINITE = /(is|are) end-of-life|(has|have) reached (their )?end-of-life|reached end-of-life status|legacy .* products|zyxel-eol/i; const eol = (v) => (EOL.test(`${v.requiredAction} ${v.shortDescription} ${v.notes}`) ? (EOL_DEFINITE.test(`${v.requiredAction} ${v.shortDescription} ${v.notes}`) ? 'definite' : 'possible') : ''); // Response beyond patching, entry by entry (RQ4). Two kinds of wording that CISA adds to // individual entries, separate from the catalogue-wide "Forensics Triage Requirements" text: // compromiseCheck the entry tells organisations to look for compromise: "check for signs of // potential compromise", "evidence of compromise", "may have been compromised", // hunt activities, or an emergency directive with hunt guidance; // cisaInstructions the entry points to CISA's own mitigation instructions for that flaw // instead of (or as well as) the vendor's. // Phrases are chosen so that "supply-chain compromise" or a vendor URL containing "Compromise" // do not count. const COMPROMISE_CHECK = /signs of (potential )?compromise|evidence of compromise|may have been compromised|hunt activities|hunt (&|and) hardening|emergency directive|\bED \d{2}-\d{2}\b/i; const CISA_INSTRUCTIONS = /CISA (mitigation )?instructions/i; // The catalogue-wide wording CISA added to every new entry from mid-2026. const TRIAGE_TEMPLATE = /Forensics Triage Requirements/; // Authentication needed, from the KEV short description. Coded "unclear" when the description // does not say; these need manual review against the advisory before publication. // Precedence: an explicit statement about the attacker ("unauthenticated attacker", // "authenticated attacker") wins over a weakness name such as "missing authentication", which // describes the flaw rather than what the attacker needs. const PRE_EXPLICIT = /unauthenticated|without (prior )?authentication|unauthori[sz]ed (remote )?(attacker|user)/i; const POST_AUTH = /(? { const d = v.shortDescription; return PRE_EXPLICIT.test(d) ? 'pre' : POST_AUTH.test(d) ? 'post' : PRE_WEAK.test(d) ? 'pre' : 'unclear'; }; // Management plane: the description says the flaw is reached through the management or // administrative interface (as opposed to the VPN, gateway or traffic-handling service). const MGMT = /management (web )?(interface|portal|console|ui|plane)|admin(istrative|istration)? (web )?(interface|portal|console|ui|page)|web ui|web-based management|configuration utility|j-web/i; /* ---------- Helpers ---------- */ const days = (a, b) => Math.round((Date.parse(b) - Date.parse(a)) / 86400000); const pct = (n, d) => (d ? Math.round((n / d) * 1000) / 10 : 0); const median = (xs) => { if (!xs.length) return null; const s = [...xs].sort((a, b) => a - b); const k = s.length >> 1; return s.length % 2 ? s[k] : (s[k - 1] + s[k]) / 2; }; const count = (rows, key) => rows.reduce((acc, r) => ((acc[key(r)] = (acc[key(r)] || 0) + 1), acc), {}); const by = (obj) => Object.entries(obj).sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])); const share = (set, f) => { const n = set.filter(f).length; return { n, of: set.length, pct: pct(n, set.length) }; }; const csvCell = (v) => { const s = v === null || v === undefined ? '' : String(v); return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s; }; const toCsv = (cols, rows) => [cols.join(','), ...rows.map((r) => cols.map((c) => csvCell(r[c])).join(','))].join('\n') + '\n'; /* ---------- Coding ---------- */ const WINDOW_START = '2023-01-01'; const rows = kev.vulnerabilities.map((v) => ({ cve: v.cveID, cveYear: Number(v.cveID.split('-')[1]), vendor: v.vendorProject.trim(), product: v.product.trim(), dateAdded: v.dateAdded, yearAdded: v.dateAdded.slice(0, 4), edge: isEdge(v), family: familyOf(v), deviceClass: deviceClass(v), ransomware: v.knownRansomwareCampaignUse === 'Known', forensicTriage: v.forensicTriage === 'Yes', triageTemplate: TRIAGE_TEMPLATE.test(v.requiredAction), compromiseCheck: COMPROMISE_CHECK.test(`${v.requiredAction} ${v.notes}`), cisaInstructions: CISA_INSTRUCTIONS.test(`${v.requiredAction} ${v.notes}`), eol: eol(v), auth: auth(v), mgmt: MGMT.test(v.shortDescription), chained: /\bchain(ed|ing)?\b/i.test(`${v.shortDescription} ${v.notes}`), // description says the flaw is used with another })); const w = rows.filter((r) => r.dateAdded >= WINDOW_START); const edge = w.filter((r) => r.edge); const other = w.filter((r) => !r.edge); const years = [...new Set(w.map((r) => r.yearAdded))].sort(); /* ---------- RQ1–2: recurrence and clustering ---------- */ // An episode is a run of additions to one family with no gap longer than EPISODE_GAP days. // A family "recurs" when it has more than one episode: it was exploited again after the // earlier problem had been public for over a month. const EPISODE_GAP = 30; const families = Object.entries(edge.reduce((acc, r) => ((acc[r.family] ||= []).push(r), acc), {})).map(([name, list]) => { const dates = [...new Set(list.map((r) => r.dateAdded))].sort(); const gaps = dates.slice(1).map((d, i) => days(dates[i], d)); const episodes = 1 + gaps.filter((g) => g > EPISODE_GAP).length; return { family: name, vendor: list[0].vendor, deviceClass: list[0].deviceClass, entries: list.length, additionDates: dates.length, episodes, first: dates[0], last: dates.at(-1), medianGapDays: median(gaps.filter((g) => g > EPISODE_GAP)), ransomware: list.filter((r) => r.ransomware).length, years: [...new Set(list.map((r) => r.yearAdded))].sort().join(' '), }; }).sort((a, b) => b.entries - a.entries || a.family.localeCompare(b.family)); const recurring = families.filter((f) => f.episodes > 1); // Same-day clusters: two or more entries for one vendor added on the same day. Usually an // exploit chain or one campaign using several flaws. const clusters = Object.values(edge.reduce((acc, r) => ((acc[`${r.vendor}|${r.dateAdded}`] ||= []).push(r), acc), {})).filter((l) => l.length > 1); /* ---------- RQ4: response beyond patching ---------- */ // The per-entry forensicTriage flag is compared only for entries added since CISA began // applying it, so earlier entries do not dilute the comparison. const triageStart = rows.filter((r) => r.triageTemplate).map((r) => r.dateAdded).sort()[0] || null; const sinceTriage = (set) => (triageStart ? set.filter((r) => r.dateAdded >= triageStart) : []); /* ---------- RQ3: optional advisory register ---------- */ let advisories = null; if (advisoryPath && existsSync(advisoryPath)) { const [head, ...lines] = readFileSync(advisoryPath, 'utf8').trim().split(/\r?\n/); const cols = head.split(','); const recs = lines.map((l) => Object.fromEntries(l.split(',').map((x, i) => [cols[i], x.trim()]))).filter((r) => r.fixDate && r.firstExploitedDate); const lead = recs.map((r) => days(r.firstExploitedDate, r.fixDate)); // positive: exploited before a fix existed advisories = { coded: recs.length, exploitedBeforeFix: lead.filter((d) => d > 0).length, exploitedSameDayAsFix: lead.filter((d) => d === 0).length, exploitedAfterFix: lead.filter((d) => d < 0).length, medianDaysFixMinusExploit: median(lead), }; } /* ---------- Results ---------- */ const results = { source: { catalogVersion: kev.catalogVersion, dateReleased: kev.dateReleased, totalEntries: rows.length }, window: { start: WINDOW_START, end: kev.dateReleased.slice(0, 10), entries: w.length, edge: edge.length, other: other.length }, edgeByYear: Object.fromEntries(years.map((y) => [y, edge.filter((r) => r.yearAdded === y).length])), deviceClass: count(edge, (r) => r.deviceClass), recurrence: { episodeGapDays: EPISODE_GAP, families: families.length, familiesWith2PlusEntries: families.filter((f) => f.entries > 1).length, familiesWith3PlusEntries: families.filter((f) => f.entries > 2).length, recurringFamilies: recurring.length, entriesInRecurringFamilies: share(edge, (r) => recurring.some((f) => f.family === r.family)), medianGapBetweenEpisodesDays: median(recurring.flatMap((f) => { const dates = [...new Set(edge.filter((r) => r.family === f.family).map((r) => r.dateAdded))].sort(); return dates.slice(1).map((d, i) => days(dates[i], d)).filter((g) => g > EPISODE_GAP); })), topFamilies: families.slice(0, 12).map(({ family, entries, episodes, first, last, ransomware }) => ({ family, entries, episodes, first, last, ransomware })), familiesByVendorTop6: Object.fromEntries(['Cisco', 'Fortinet', 'Ivanti', 'Citrix', 'Palo Alto Networks', 'SonicWall'].map((v) => [v, families.filter((f) => f.vendor === v).map((f) => f.family)])), }, clusters: { count: clusters.length, entries: clusters.reduce((a, l) => a + l.length, 0), entriesPct: pct(clusters.reduce((a, l) => a + l.length, 0), edge.length), chainMentioned: { edge: share(edge, (r) => r.chained), other: share(other, (r) => r.chained) }, largest: clusters.sort((a, b) => b.length - a.length).slice(0, 8).map((l) => ({ vendor: l[0].vendor, dateAdded: l[0].dateAdded, n: l.length, cves: l.map((r) => r.cve) })), }, beyondPatching: { triageTemplateStart: triageStart, triageTemplateCoverage: { edge: share(sinceTriage(edge), (r) => r.triageTemplate), other: share(sinceTriage(other), (r) => r.triageTemplate) }, forensicTriageFlagSinceStart: { edge: share(sinceTriage(edge), (r) => r.forensicTriage), other: share(sinceTriage(other), (r) => r.forensicTriage) }, forensicTriageFlagWindow: { edge: share(edge, (r) => r.forensicTriage), other: share(other, (r) => r.forensicTriage) }, entryGuidance: { compromiseCheck: { edge: share(edge, (r) => r.compromiseCheck), other: share(other, (r) => r.compromiseCheck) }, cisaInstructions: { edge: share(edge, (r) => r.cisaInstructions), other: share(other, (r) => r.cisaInstructions) }, either: { edge: share(edge, (r) => r.compromiseCheck || r.cisaInstructions), other: share(other, (r) => r.compromiseCheck || r.cisaInstructions) }, edgeShareOfAll: share(w.filter((r) => r.compromiseCheck || r.cisaInstructions), (r) => r.edge), edgeByYear: Object.fromEntries(years.map((y) => [y, edge.filter((r) => r.yearAdded === y && (r.compromiseCheck || r.cisaInstructions)).length])), entries: w.filter((r) => r.compromiseCheck || r.cisaInstructions).map((r) => ({ cve: r.cve, vendor: r.vendor, product: r.product, dateAdded: r.dateAdded, edge: r.edge, compromiseCheck: r.compromiseCheck, cisaInstructions: r.cisaInstructions })), }, }, endOfLife: { edge: share(edge, (r) => r.eol), other: share(other, (r) => r.eol), edgeDefinite: share(edge, (r) => r.eol === 'definite'), edgeBySoho: { soho: share(edge.filter((r) => r.deviceClass === 'soho'), (r) => r.eol), enterprise: share(edge.filter((r) => r.deviceClass === 'enterprise'), (r) => r.eol) }, byVendor: by(count(edge.filter((r) => r.eol), (r) => r.vendor)), cveOlderThan3YearsAtAddition: share(edge.filter((r) => r.eol), (r) => Number(r.yearAdded) - r.cveYear > 3), entries: edge.filter((r) => r.eol).map((r) => ({ cve: r.cve, vendor: r.vendor, product: r.product, dateAdded: r.dateAdded })), }, access: { auth: { edge: count(edge, (r) => r.auth), other: count(other, (r) => r.auth) }, preAuthOfCoded: { edge: share(edge.filter((r) => r.auth !== 'unclear'), (r) => r.auth === 'pre'), other: share(other.filter((r) => r.auth !== 'unclear'), (r) => r.auth === 'pre') }, managementPlane: share(edge, (r) => r.mgmt), }, ransomware: { edge: share(edge, (r) => r.ransomware), recurringFamilies: share(edge.filter((r) => recurring.some((f) => f.family === r.family)), (r) => r.ransomware), nonRecurringFamilies: share(edge.filter((r) => !recurring.some((f) => f.family === r.family)), (r) => r.ransomware), }, advisories, }; mkdirSync(out, { recursive: true }); writeFileSync(join(out, 'results.json'), JSON.stringify(results, null, 2) + '\n'); writeFileSync(join(out, 'edge-2023-2026.csv'), toCsv(['cve', 'vendor', 'product', 'family', 'deviceClass', 'dateAdded', 'ransomware', 'forensicTriage', 'compromiseCheck', 'cisaInstructions', 'eol', 'auth', 'mgmt', 'chained'], edge)); writeFileSync(join(out, 'families.csv'), toCsv(['family', 'vendor', 'deviceClass', 'entries', 'additionDates', 'episodes', 'first', 'last', 'medianGapDays', 'ransomware', 'years'], families)); // RQ3 register: one row per edge entry from the six most-exploited vendors, to be completed by // hand from vendor advisories. fixDate is the first fixed release or hotfix; firstExploitedDate // is the earliest exploitation date stated by the vendor or CISA. Dates are YYYY-MM-DD. const RQ3_VENDORS = /^(Cisco|Fortinet|Ivanti|Citrix|Palo Alto Networks|SonicWall)$/; writeFileSync(join(out, 'advisory-register-template.csv'), toCsv(['cve', 'vendor', 'family', 'dateAdded', 'fixDate', 'firstExploitedDate', 'source'], edge.filter((r) => RQ3_VENDORS.test(r.vendor)))); console.log(`Coded ${edge.length} edge entries of ${w.length} (${WINDOW_START} to ${results.window.end}) from catalogue ${results.source.catalogVersion}.`);