Files
vulncheck/frontend/app/advisories/page.tsx
T
vulncheck 57ed0b1534 feat(kev): alert on exploited CVEs that are open in our own environment
Actively-exploited CVEs were shown from CISA KEV only, on a dashboard
somebody had to be looking at. ENISA's EUVD exploited catalog was already
being fetched for enrichment but never surfaced as a KEV source.

- kev_service merges CISA KEV + ENISA EUVD (exploited only — an EU-Critical
  entry is a priority list, not an exploitation claim) into one entry per
  CVE carrying both sources.
- Inventory impact is counted honestly: active assets only (matching the
  vulnerabilities list), open findings apart from remediated ones, and an
  asset with both an open and a closed row counted once, as open. That gap
  is what made the badge say 64 where the filtered view listed 62.
- kev_alert_service mails the CVEs with open findings here — hostnames, IPs,
  counts, sources — once per CVE, again when more systems are affected.
  Hourly, plus straight after each threat-intel refresh.
- Dashboard pins what is open here above the newest listings; Advisories
  gains source filters, an alert panel and its config.

EUVD entries now carry an explicit `exploited` flag; caches predating it
cannot answer the question for an entry that is also EU-Critical, so they
are treated as stale and refetched once.
2026-08-15 09:37:16 +02:00

351 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
// Security Advisory Feeds — CISA KEV (actively exploited) plus configurable
// RSS sources (ZDI / CERT-EU / BSI / Cisco / custom). These sources publish
// ahead of NVD/cvelistV5, so this page is the early-warning surface.
import { useEffect, useState } from 'react';
import api from '../../lib/api';
type FeedItem = { title: string; link: string; date: string; summary: string };
type Feed = { id: string; name: string; url: string; enabled: boolean; items: FeedItem[]; error: string | null };
type FeedCfg = { id: string; name: string; url: string; enabled: boolean };
export default function AdvisoriesPage() {
const [kev, setKev] = useState<any[]>([]);
// KEV view controls — '' = every source (CISA KEV + ENISA EUVD merged).
const [kevSource, setKevSource] = useState('');
const [onlyMine, setOnlyMine] = useState(false);
const [alerts, setAlerts] = useState<any>(null);
const [sending, setSending] = useState(false);
const [alertCfg, setAlertCfg] = useState({ enabled: true, recipients: '' });
const [alertCfgMsg, setAlertCfgMsg] = useState('');
const [feeds, setFeeds] = useState<Feed[]>([]);
const [fetchedAt, setFetchedAt] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [userRole, setUserRole] = useState('');
const [openFeed, setOpenFeed] = useState<string | null>(null);
// Admin config editor
const [cfg, setCfg] = useState<FeedCfg[]>([]);
const [showCfg, setShowCfg] = useState(false);
const [cfgMsg, setCfgMsg] = useState('');
// Items kept per feed. Held as a string so the field can be cleared while
// typing; the bounds come from the API, so there is one definition of them.
const [maxItems, setMaxItems] = useState('30');
const [limits, setLimits] = useState({ def: 30, min: 1, max: 500 });
const kevUrl = () => {
const p = new URLSearchParams({ limit: '15' });
if (kevSource) p.set('sources', kevSource);
if (onlyMine) p.set('in_inventory_only', 'true');
return `/api/v1/advisories/kev-recent?${p}`;
};
const load = async () => {
try {
const [k, f, me, al] = await Promise.all([
api.get(kevUrl()).catch(() => ({ data: { items: [] } })),
api.get('/api/v1/advisories/feeds').catch(() => ({ data: { feeds: [], fetched_at: null } })),
api.get('/auth/me').catch(() => ({ data: {} })),
api.get('/api/v1/advisories/kev-alerts').catch(() => ({ data: null })),
]);
setKev(k.data?.items || []);
setAlerts(al.data || null);
if (al.data) setAlertCfg({
enabled: al.data.enabled !== false,
recipients: al.data.recipients_setting || '',
});
setFeeds(f.data?.feeds || []);
setFetchedAt(f.data?.fetched_at || null);
setUserRole(me.data?.role || '');
// config mirror for the admin editor (from the cache view — same rows)
setCfg((f.data?.feeds || []).map((x: Feed) => ({ id: x.id, name: x.name, url: x.url, enabled: x.enabled })));
setLimits({
def: f.data?.max_items_default ?? 30,
min: f.data?.max_items_min ?? 1,
max: f.data?.max_items_max ?? 500,
});
setMaxItems(String(f.data?.max_items ?? f.data?.max_items_default ?? 30));
} finally { setLoading(false); }
};
useEffect(() => { load(); }, []);
// Source / inventory filter changes refetch only the KEV list — the RSS
// feeds and the alert panel are unaffected by them.
useEffect(() => {
let stale = false;
api.get(kevUrl())
.then((r) => { if (!stale) setKev(r.data?.items || []); })
.catch(() => { });
return () => { stale = true; };
}, [kevSource, onlyMine]);
const saveAlertCfg = async () => {
try {
await api.put('/api/v1/settings/kev_alert_enabled', { value: alertCfg.enabled ? 'true' : 'false' });
await api.put('/api/v1/settings/kev_alert_recipients', { value: alertCfg.recipients.trim() });
setAlertCfgMsg('Saved.');
await load();
} catch (e: any) {
setAlertCfgMsg(e?.response?.data?.detail || 'Save failed');
}
};
const sendAlerts = async () => {
if (!confirm('Send the KEV alert mail now to the configured recipients?')) return;
setSending(true);
try {
const r = await api.post('/api/v1/advisories/kev-alerts/run');
const d = r.data || {};
alert(d.skipped
? `Nothing sent: ${d.skipped}`
: `Sent ${d.emails_sent} mail(s) covering ${d.alerts} CVE(s) on ${d.assets} system(s).`
+ (d.emails_failed ? ` ${d.emails_failed} failed — check the notification log.` : ''));
await load();
} catch (e: any) {
alert(e?.response?.data?.detail || 'Sending failed');
} finally { setSending(false); }
};
const refresh = async () => {
setRefreshing(true);
try { await api.post('/api/v1/advisories/feeds/refresh'); await load(); }
catch (e: any) { alert(e?.response?.data?.detail || 'Refresh failed'); }
finally { setRefreshing(false); }
};
const saveCfg = async () => {
// Say no here rather than let the server silently clamp — a limit that
// quietly becomes something else is worse than a rejected one.
const n = Number(maxItems);
if (!maxItems.trim() || !Number.isFinite(n) || !Number.isInteger(n)
|| n < limits.min || n > limits.max) {
setCfgMsg(`Items per feed must be a whole number between ${limits.min} and ${limits.max}.`);
return;
}
try {
await api.put('/api/v1/settings/advisory_feeds_config', { value: JSON.stringify(cfg) });
await api.put('/api/v1/settings/advisory_feeds_max_items', { value: String(n) });
setCfgMsg('Saved — refreshing feeds…');
await api.post('/api/v1/advisories/feeds/refresh').catch(() => { });
await load();
setCfgMsg('Saved.');
} catch (e: any) {
setCfgMsg(e?.response?.data?.detail || 'Save failed');
}
};
const canEdit = userRole === 'admin' || userRole === 'editor';
if (loading) return <div className="p-8">Loading Advisories...</div>;
return (
<div className="p-8">
<div className="flex items-start justify-between mb-6">
<div>
<h2 className="text-3xl font-bold text-gray-900 font-mono">Security Advisory Feeds</h2>
<p className="mt-1 text-sm text-gray-500">
Early-warning sources that often publish before NVD / cvelistV5.
{fetchedAt && <span className="ml-2 font-mono text-xs text-gray-400">Last fetch: {new Date(fetchedAt).toLocaleString()}</span>}
</p>
</div>
<div className="flex gap-2">
{userRole === 'admin' && (
<button onClick={() => setShowCfg(!showCfg)} className="rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50">
⚙️ Configure
</button>
)}
{canEdit && (
<button onClick={refresh} disabled={refreshing} className="rounded-md bg-truevuln-blue px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-600 disabled:opacity-50">
{refreshing ? 'Refreshing…' : 'Refresh now'}
</button>
)}
</div>
</div>
{/* Admin: feed configuration */}
{showCfg && userRole === 'admin' && (
<div className="bg-white border border-gray-200 shadow-sm rounded-sm p-4 mb-6">
<h3 className="text-sm font-bold font-mono text-gray-900 mb-2">Feed configuration</h3>
<p className="text-xs text-gray-500 mb-3">Enable/disable sources or add a custom RSS/Atom URL. Feeds with DOCTYPE/ENTITY declarations are refused (XXE protection).</p>
<div className="space-y-2">
{cfg.map((f, i) => (
<div key={i} className="flex items-center gap-2">
<input type="checkbox" checked={f.enabled} onChange={(e) => { const n = [...cfg]; n[i] = { ...f, enabled: e.target.checked }; setCfg(n); }} className="h-4 w-4 rounded border-gray-300 text-truevuln-blue" />
<input type="text" value={f.name} onChange={(e) => { const n = [...cfg]; n[i] = { ...f, name: e.target.value }; setCfg(n); }} className="w-64 rounded-md border-gray-300 text-xs font-mono h-8 px-2" />
<input type="text" value={f.url} onChange={(e) => { const n = [...cfg]; n[i] = { ...f, url: e.target.value }; setCfg(n); }} className="flex-1 rounded-md border-gray-300 text-xs font-mono h-8 px-2" />
<button onClick={() => setCfg(cfg.filter((_, j) => j !== i))} className="text-red-600 text-xs px-2"></button>
</div>
))}
</div>
<div className="flex items-center gap-2 mt-4 pt-3 border-t border-gray-100">
<label htmlFor="max-items" className="text-xs font-mono text-gray-700">Items per feed</label>
<input
id="max-items" type="number" inputMode="numeric"
min={limits.min} max={limits.max} step={1}
value={maxItems}
onChange={(e) => setMaxItems(e.target.value)}
className="w-24 rounded-md border-gray-300 text-xs font-mono h-8 px-2"
/>
<span className="text-xs text-gray-500">
{limits.min}{limits.max}, default {limits.def}. Applies to every feed;
takes effect on the next refresh. A feed that publishes fewer simply returns fewer.
</span>
</div>
<div className="flex items-center gap-2 mt-3">
<button onClick={() => setCfg([...cfg, { id: `custom-${Date.now()}`, name: 'Custom feed', url: '', enabled: true }])} className="text-xs font-mono px-3 py-1.5 border border-gray-300 rounded-md hover:bg-gray-50">+ Add feed</button>
<button onClick={saveCfg} className="text-xs font-mono px-3 py-1.5 bg-truevuln-blue text-white rounded-md hover:bg-blue-600">Save</button>
{cfgMsg && <span className="text-xs font-mono text-gray-500">{cfgMsg}</span>}
</div>
</div>
)}
{/* Actively exploited — every KEV source we have, merged. A CVE
listed by both carries both badges instead of appearing twice. */}
<div className="bg-white border border-gray-200 shadow-sm rounded-sm mb-6">
<div className="px-4 py-3 border-b border-gray-100 bg-red-50 flex items-center justify-between gap-3 flex-wrap">
<h3 className="text-sm font-bold font-mono text-red-800">
Actively Exploited KEV (latest additions)
<span className="ml-2 font-normal text-red-700/70">CISA KEV · ENISA EUVD</span>
</h3>
<div className="flex items-center gap-3 text-xs font-mono">
<label className="flex items-center gap-1 text-red-800">
<input type="checkbox" checked={onlyMine} onChange={(e) => setOnlyMine(e.target.checked)}
className="h-3.5 w-3.5 rounded border-red-300 text-red-700" />
Only in my inventory
</label>
<select value={kevSource} onChange={(e) => setKevSource(e.target.value)}
className="rounded-md border-gray-300 text-xs font-mono h-7 py-0 pl-2 pr-7">
<option value="">All sources</option>
<option value="cisa">CISA KEV only</option>
<option value="euvd">ENISA EUVD only</option>
</select>
</div>
</div>
<ul className="divide-y divide-gray-100">
{kev.length === 0 && <li className="px-4 py-3 text-sm text-gray-400 font-mono">
{onlyMine ? 'Nothing actively exploited is open in your inventory.' : 'No KEV data yet — run Refresh Threat Intel.'}
</li>}
{kev.map((k: any, i: number) => (
<li key={i} className="px-4 py-2 flex items-center justify-between gap-3 text-sm">
<div className="min-w-0">
<a href={`/vulnerabilities?cve_id=${k.cve_id}`} className="font-mono font-semibold text-truevuln-blue hover:underline">{k.cve_id}</a>
{(k.sources || []).map((s: string) => (
<span key={s} title={s === 'euvd' ? `ENISA EUVD — exploited${k.euvd_id ? ` (${k.euvd_id})` : ''}` : 'CISA KEV'}
className={`ml-1 rounded px-1 py-0.5 text-[9px] font-bold font-mono uppercase ${s === 'euvd' ? 'bg-indigo-100 text-indigo-700' : 'bg-red-100 text-red-700'}`}>
{s === 'euvd' ? 'EUVD' : 'CISA'}
</span>
))}
{k.ransomware && <span title="Known ransomware campaign use" className="ml-1">🔒</span>}
<span className="ml-2 text-gray-600">{k.name || k.description || [k.vendor, k.product].filter(Boolean).join(' · ')}</span>
</div>
<div className="flex items-center gap-2 flex-none text-xs font-mono">
{k.in_inventory && (
<span className={`rounded px-1.5 py-0.5 font-bold ${k.open_asset_count > 0 ? 'bg-red-100 text-red-700' : 'bg-green-100 text-green-700'}`}
title={`${k.open_asset_count} asset(s) still open, ${k.patched_asset_count} already remediated`}>
{k.open_asset_count > 0 ? `IN INVENTORY · ${k.open_asset_count}` : 'REMEDIATED'}
</span>
)}
<span className="text-gray-400">{k.date_added || ''}</span>
</div>
</li>
))}
</ul>
</div>
{/* Immediate alerting: exploited in the wild AND open here. */}
{alerts && (
<div className="bg-white border border-gray-200 shadow-sm rounded-sm mb-6">
<div className="px-4 py-3 border-b border-gray-100 bg-gray-50 flex items-center justify-between gap-3 flex-wrap">
<div>
<h3 className="text-sm font-bold font-mono text-gray-900">
KEV Alerting actively exploited &amp; open in your environment
</h3>
<p className="text-xs text-gray-500 mt-0.5">
{alerts.enabled ? 'Enabled' : 'Disabled'} · checked hourly ·
{' '}{alerts.pending} pending · recipients: {(alerts.recipients || []).join(', ') || 'none configured'}
</p>
</div>
{canEdit && (
<button onClick={sendAlerts} disabled={sending}
className="rounded-md bg-red-700 px-3 py-2 text-xs font-semibold text-white shadow-sm hover:bg-red-800 disabled:opacity-50">
{sending ? 'Sending…' : 'Send alert mail now'}
</button>
)}
</div>
{/* Admin config — the alert mail points recipients here. */}
{userRole === 'admin' && (
<div className="px-4 py-3 border-b border-gray-100 bg-gray-50/50 flex items-center gap-3 flex-wrap text-xs font-mono">
<label className="flex items-center gap-1">
<input type="checkbox" checked={alertCfg.enabled}
onChange={(e) => setAlertCfg({ ...alertCfg, enabled: e.target.checked })}
className="h-3.5 w-3.5 rounded border-gray-300 text-red-700" />
Alerting enabled
</label>
<label className="flex items-center gap-1 flex-1 min-w-[280px]">
Recipients
<input type="text" value={alertCfg.recipients}
onChange={(e) => setAlertCfg({ ...alertCfg, recipients: e.target.value })}
placeholder="empty = notification defaults (admins)"
className="flex-1 rounded-md border-gray-300 text-xs font-mono h-7 px-2" />
</label>
<button onClick={saveAlertCfg}
className="px-3 py-1.5 border border-gray-300 rounded-md hover:bg-gray-50">Save</button>
{alertCfgMsg && <span className="text-gray-500">{alertCfgMsg}</span>}
</div>
)}
<ul className="divide-y divide-gray-100">
{(alerts.items || []).length === 0 && (
<li className="px-4 py-3 text-sm text-gray-400 font-mono">
Nothing actively exploited is open on an active asset. 🎉
</li>
)}
{(alerts.items || []).map((a: any) => (
<li key={a.cve_id} className="px-4 py-2 text-sm">
<div className="flex items-center justify-between gap-3">
<a href={`/vulnerabilities?cve_id=${a.cve_id}`} className="font-mono font-semibold text-truevuln-blue hover:underline">{a.cve_id}</a>
<span className="flex-none text-xs font-mono">
<span className="rounded px-1.5 py-0.5 bg-red-100 text-red-700 font-bold">{a.open_asset_count} OPEN</span>
{a.patched_asset_count > 0 && <span className="ml-1 text-gray-400">{a.patched_asset_count} remediated</span>}
{a.previously_notified && <span className="ml-2 text-gray-400">already alerted</span>}
</span>
</div>
<p className="text-xs text-gray-500 font-mono mt-0.5 truncate">
{(a.hosts || []).map((h: any) => h.hostname).join(', ')}
{a.hosts_truncated > 0 && ` … +${a.hosts_truncated} more`}
</p>
</li>
))}
</ul>
</div>
)}
{/* RSS feeds */}
{feeds.filter(f => f.enabled).map((f) => (
<div key={f.id} className="bg-white border border-gray-200 shadow-sm rounded-sm mb-4">
<button onClick={() => setOpenFeed(openFeed === f.id ? null : f.id)} className="w-full px-4 py-3 border-b border-gray-100 bg-gray-50 flex items-center justify-between text-left">
<h3 className="text-sm font-bold font-mono text-gray-900">{f.name}
<span className="ml-2 text-xs font-normal text-gray-400">{f.items.length} items</span>
{f.error && <span className="ml-2 text-xs text-red-600">fetch failed: {f.error}</span>}
</h3>
<span className="text-gray-400 text-xs">{openFeed === f.id ? '▲' : '▼'}</span>
</button>
{(openFeed === f.id || feeds.filter(x => x.enabled).length <= 2) && (
<ul className="divide-y divide-gray-100">
{f.items.slice(0, 15).map((it, i) => (
<li key={i} className="px-4 py-2 text-sm">
<a href={it.link} target="_blank" rel="noopener noreferrer" className="font-medium text-truevuln-blue hover:underline">{it.title}</a>
<span className="ml-2 text-xs text-gray-400 font-mono">{it.date}</span>
{it.summary && <p className="text-xs text-gray-500 mt-0.5 line-clamp-2">{it.summary}</p>}
</li>
))}
{f.items.length === 0 && !f.error && <li className="px-4 py-3 text-sm text-gray-400 font-mono">No items.</li>}
</ul>
)}
</div>
))}
</div>
);
}