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.
351 lines
22 KiB
TypeScript
351 lines
22 KiB
TypeScript
"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 & 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>
|
||
);
|
||
}
|