Published-date-desc feeder returned the newest 300 per-asset rows, mostly low/medium; after the CVSS≥8/KEV/EUVD filter only 2 criticals survived. Same starving Newly Published had — same cure: distinct_cve=true collapses per-asset duplicates server-side and the deeper window (300) reliably fills the 10 slots.
855 lines
43 KiB
TypeScript
855 lines
43 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useState } from 'react';
|
|
import { useRouter } from 'next/navigation';
|
|
import {
|
|
ArrowUpRightIcon,
|
|
ArrowDownRightIcon,
|
|
SparklesIcon,
|
|
ClockIcon,
|
|
} from '@heroicons/react/24/outline';
|
|
import Link from 'next/link';
|
|
import api from '../lib/api';
|
|
import { DashboardStats, Vulnerability, AIPriorityResponse } from '../types';
|
|
import AIRecommendations from '@/components/AIRecommendations';
|
|
|
|
// Compact CVE table widget used twice on the dashboard (Recent Critical
|
|
// + Newly Published). Same shape, different feeder list so the operator
|
|
// can compare 'highest current risk' vs 'just published' at a glance.
|
|
function renderVulnWidget(opts: {
|
|
title: string;
|
|
subtitle: string;
|
|
vulns: Vulnerability[];
|
|
onRowClick: (cveId: string) => void;
|
|
viewAllHref?: string;
|
|
// EOL widget: the pseudo-CVE id ("EOL-CHROME-148") is noise — show the
|
|
// product/package name in the first column instead, with a "PRODUCT"
|
|
// header. Falls back to cve_id when no package name.
|
|
labelField?: 'cve_id' | 'package_name';
|
|
firstColHeader?: string;
|
|
// EOL widget: replace the (often empty) CPR column with the affected
|
|
// asset's hostname, linked to that asset's EOL findings.
|
|
assetColumn?: boolean;
|
|
}) {
|
|
const { title, subtitle, vulns, onRowClick, viewAllHref } = opts;
|
|
const labelField = opts.labelField || 'cve_id';
|
|
const firstColHeader = opts.firstColHeader || 'CVE';
|
|
const assetColumn = opts.assetColumn || false;
|
|
const firstColLabel = (v: Vulnerability) =>
|
|
labelField === 'package_name' ? (v.package_name || v.cve_id) : v.cve_id;
|
|
const prioCls = (p: number | null | undefined) =>
|
|
p == null ? 'text-gray-400'
|
|
: p >= 80 ? 'text-red-700 font-bold'
|
|
: p >= 50 ? 'text-orange-700 font-bold'
|
|
: p >= 20 ? 'text-yellow-700' : 'text-gray-600';
|
|
const cprCls = (c: number | null | undefined) =>
|
|
c == null ? 'text-gray-400'
|
|
: c >= 50 ? 'text-red-700 font-bold'
|
|
: c >= 20 ? 'text-orange-700' : 'text-gray-600';
|
|
return (
|
|
<div className="bg-white border border-gray-200 shadow-sm rounded-sm overflow-hidden flex flex-col">
|
|
<div className="p-4 border-b border-gray-200 flex justify-between items-baseline bg-gray-50/50">
|
|
<div>
|
|
<h3 className="text-base font-bold text-gray-900 font-mono">{title}</h3>
|
|
<p className="text-[11px] text-gray-500 font-mono mt-0.5">{subtitle}</p>
|
|
</div>
|
|
<Link href={viewAllHref || "/vulnerabilities"} prefetch className="text-truevuln-blue text-[10px] font-bold uppercase tracking-wider font-mono hover:text-blue-700">View All ></Link>
|
|
</div>
|
|
<div className="overflow-x-auto flex-1">
|
|
<table className="min-w-full divide-y divide-gray-200 table-fixed">
|
|
<colgroup>
|
|
<col className="w-[34%]" />
|
|
<col className="w-[12%]" />
|
|
<col className="w-[10%]" />
|
|
<col className="w-[12%]" />
|
|
<col className="w-[12%]" />
|
|
<col className="w-[20%]" />
|
|
</colgroup>
|
|
<thead className="bg-gray-50">
|
|
<tr>
|
|
<th className="px-2 py-2 text-left text-[10px] font-mono font-medium text-gray-500 uppercase">{firstColHeader}</th>
|
|
<th className="px-2 py-2 text-left text-[10px] font-mono font-medium text-gray-500 uppercase">Sev</th>
|
|
<th className="px-2 py-2 text-left text-[10px] font-mono font-medium text-gray-500 uppercase" title="CVSS Base Score">CVSS</th>
|
|
<th className="px-2 py-2 text-left text-[10px] font-mono font-medium text-gray-500 uppercase" title="Priority Score 0-100">PRIO</th>
|
|
{assetColumn
|
|
? <th className="px-2 py-2 text-left text-[10px] font-mono font-medium text-gray-500 uppercase" title="Affected asset — click for all its EOL findings">Asset</th>
|
|
: <th className="px-2 py-2 text-left text-[10px] font-mono font-medium text-gray-500 uppercase" title="CPR 0-100 (JacquesKruger)">CPR</th>}
|
|
<th className="px-2 py-2 text-left text-[10px] font-mono font-medium text-gray-500 uppercase">Flags</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="bg-white divide-y divide-gray-200 font-mono text-xs">
|
|
{vulns.map((vuln) => (
|
|
<tr key={vuln.id} onClick={() => onRowClick(vuln.cve_id)} className="cursor-pointer hover:bg-gray-50 transition-colors">
|
|
<td className="px-2 py-2 font-bold text-indigo-600 hover:text-indigo-800">
|
|
<div className="truncate" title={`${firstColLabel(vuln)} · ${vuln.cve_id}`}>{firstColLabel(vuln)}</div>
|
|
</td>
|
|
<td className="px-2 py-2">
|
|
<span className={`inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-bold ${vuln.severity === 'critical' ? 'bg-red-100 text-red-700'
|
|
: vuln.severity === 'high' ? 'bg-orange-100 text-orange-700'
|
|
: vuln.severity === 'medium' ? 'bg-blue-100 text-blue-700' : 'bg-green-100 text-green-700'
|
|
}`}>
|
|
{vuln.severity.toUpperCase().slice(0, 4)}
|
|
</span>
|
|
</td>
|
|
<td className="px-2 py-2 font-bold">{vuln.cvss_score ?? '—'}</td>
|
|
<td className={`px-2 py-2 ${prioCls(vuln.priority_score ?? null)}`}>
|
|
{vuln.priority_score == null ? '—' : vuln.priority_score.toFixed(0)}
|
|
</td>
|
|
{assetColumn ? (
|
|
<td className="px-2 py-2">
|
|
{vuln.asset_id ? (
|
|
<Link
|
|
href={`/vulnerabilities?asset_id=${vuln.asset_id}&eol=1`}
|
|
onClick={(e) => e.stopPropagation()}
|
|
className="text-indigo-600 hover:text-indigo-800 hover:underline truncate block"
|
|
title={`All EOL findings on ${vuln.asset_hostname || `asset #${vuln.asset_id}`}`}
|
|
>
|
|
{vuln.asset_hostname || `#${vuln.asset_id}`}
|
|
</Link>
|
|
) : '—'}
|
|
</td>
|
|
) : (
|
|
<td className={`px-2 py-2 ${cprCls(vuln.cpr_score ?? null)}`}>
|
|
{vuln.cpr_score == null ? '—' : vuln.cpr_score.toFixed(1)}
|
|
</td>
|
|
)}
|
|
<td className="px-2 py-2">
|
|
<div className="flex gap-1 flex-wrap">
|
|
{vuln.kev_listed && <span className="inline-flex items-center rounded bg-red-100 px-1 py-0.5 text-[9px] font-bold text-red-700">KEV</span>}
|
|
{vuln.euvd_listed && <span className="inline-flex items-center rounded bg-blue-100 px-1 py-0.5 text-[9px] font-bold text-blue-700">EUVD</span>}
|
|
{vuln.exploitation_status && vuln.exploitation_status !== 'none' && (
|
|
<span className={`inline-flex items-center rounded px-1 py-0.5 text-[9px] font-bold uppercase ${vuln.exploitation_status === 'widespread' ? 'bg-red-600 text-white'
|
|
: vuln.exploitation_status === 'active' ? 'bg-orange-100 text-orange-700'
|
|
: 'bg-yellow-100 text-yellow-700'
|
|
}`}>{vuln.exploitation_status.slice(0, 4)}</span>
|
|
)}
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
{vulns.length === 0 && (
|
|
<tr><td colSpan={6} className="text-center py-4 text-gray-500">No data.</td></tr>
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default function Dashboard() {
|
|
const router = useRouter();
|
|
const [stats, setStats] = useState<DashboardStats | null>(null);
|
|
const [recentVulns, setRecentVulns] = useState<Vulnerability[]>([]);
|
|
const [aiPriorities, setAiPriorities] = useState<AIPriorityResponse | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [aiLoading, setAiLoading] = useState(false);
|
|
const [aiError, setAiError] = useState<string | null>(null);
|
|
// AI audit hits an editor-gated endpoint → hide the controls from read-only.
|
|
const [userRole, setUserRole] = useState('');
|
|
useEffect(() => {
|
|
api.get('/auth/me').then(r => setUserRole(r.data?.role || '')).catch(() => { });
|
|
}, []);
|
|
const canEdit = userRole === 'admin' || userRole === 'editor';
|
|
const [aiSeverity, setAiSeverity] = useState<string>('all');
|
|
const [aiAbortController, setAiAbortController] = useState<AbortController | null>(null);
|
|
const [showHistory, setShowHistory] = useState(false);
|
|
const [historyReports, setHistoryReports] = useState<any[]>([]);
|
|
|
|
const fetchAIRecommendations = async (severityFilter?: string) => {
|
|
setAiLoading(true);
|
|
setAiError(null);
|
|
const controller = new AbortController();
|
|
setAiAbortController(controller);
|
|
try {
|
|
console.log(`Triggering AI Audit (Severity: ${severityFilter || aiSeverity})...`);
|
|
const params = (severityFilter || aiSeverity) !== 'all' ? { severity: severityFilter || aiSeverity } : {};
|
|
const res = await api.get('/api/v1/vulnerabilities/ai-prioritization', { params, signal: controller.signal });
|
|
console.log("AI Response received:", res.data);
|
|
setAiPriorities(res.data);
|
|
if (res.data.error) {
|
|
setAiError(res.data.error);
|
|
}
|
|
} catch (error: any) {
|
|
if (error.name === 'CanceledError' || error.code === 'ERR_CANCELED') {
|
|
console.log("AI Audit cancelled by user.");
|
|
setAiError(null);
|
|
} else {
|
|
console.error("Failed to fetch AI recommendations:", error);
|
|
setAiError(error.response?.data?.detail || "The AI session timed out. Please try again.");
|
|
}
|
|
} finally {
|
|
setAiLoading(false);
|
|
setAiAbortController(null);
|
|
}
|
|
};
|
|
|
|
const cancelAIAudit = () => {
|
|
if (aiAbortController) {
|
|
aiAbortController.abort();
|
|
}
|
|
};
|
|
|
|
const loadHistory = async () => {
|
|
try {
|
|
const res = await api.get('/api/v1/vulnerabilities/ai-history');
|
|
setHistoryReports(res.data);
|
|
setShowHistory(true);
|
|
} catch (e) {
|
|
console.error("Failed to load history", e);
|
|
}
|
|
};
|
|
|
|
const loadReport = (report: any) => {
|
|
setAiPriorities({
|
|
global_strategy: report.global_strategy,
|
|
recommendations: report.recommendations
|
|
});
|
|
setShowHistory(false);
|
|
};
|
|
|
|
// Number of enabled scan schedules — drives the "Sync active" badge
|
|
// in the stat cards. Hardcoded label before this commit always said
|
|
// "Sync active" even when no schedules existed, which tester flagged
|
|
// as misleading.
|
|
const [activeScheduleCount, setActiveScheduleCount] = useState<number | null>(null);
|
|
// Compliance widget feed
|
|
type ComplianceSummary = {
|
|
assets_with_compliance: number;
|
|
total_policies: number;
|
|
avg_score_all: number | null;
|
|
pass_total: number;
|
|
fail_total: number;
|
|
not_applicable_total: number;
|
|
worst_offenders: Array<{
|
|
asset_id: number;
|
|
hostname: string;
|
|
avg_score: number | null;
|
|
worst_policy: string | null;
|
|
}>;
|
|
};
|
|
const [compliance, setCompliance] = useState<ComplianceSummary | null>(null);
|
|
type URSRow = {
|
|
asset_id: number; hostname: string | null;
|
|
avs: number | null; ass: number | null; urs: number | null;
|
|
severity: 'CRITICAL'|'HIGH'|'MEDIUM'|'LOW'|'NONE';
|
|
criticality: string | null;
|
|
};
|
|
const [ursTop, setUrsTop] = useState<URSRow[]>([]);
|
|
// Two split widgets — Recent Critical (high impact, recently changed)
|
|
// and Newly Published (chronological first-seen) — plus a third for
|
|
// endoflife.date EOL pseudo-CVEs so unsupported software has its own
|
|
// dashboard surface (tester request).
|
|
const [criticalVulns, setCriticalVulns] = useState<Vulnerability[]>([]);
|
|
const [eolVulns, setEolVulns] = useState<Vulnerability[]>([]);
|
|
const [mobileVulns, setMobileVulns] = useState<Vulnerability[]>([]);
|
|
const [kevAdvisories, setKevAdvisories] = useState<any[]>([]);
|
|
|
|
useEffect(() => {
|
|
const fetchData = async () => {
|
|
try {
|
|
const [statsRes, vulnsRes, criticalRes, eolRes, mobileRes, kevRes, schedRes, compRes, ursRes] = await Promise.all([
|
|
api.get('/api/v1/vulnerabilities/reports/dashboard'),
|
|
// Newly Published: sort by published_date desc. distinct_cve=true
|
|
// collapses per-asset duplicates server-side so we reliably get 10
|
|
// distinct CVEs (client dedup alone starved when a CVE hit N assets).
|
|
api.get('/api/v1/vulnerabilities?limit=12&status=open&sort_by=published_date&sort_order=desc&distinct_cve=true'),
|
|
// Recent Critical: CVSS ≥ 8 OR KEV OR EUVD. Feeder sorts by CVE
|
|
// published date desc so the widget actually shows RECENT criticals
|
|
// (priority-sorted it pinned the same old 2021 KEV heavyweights
|
|
// forever — tester). Client tie-breaks same-day CVEs by CPR desc.
|
|
// distinct_cve + deep limit: the newest rows are mostly low/medium,
|
|
// so a shallow per-asset window left only 2 criticals (same starving
|
|
// Newly Published had before distinct_cve).
|
|
api.get('/api/v1/vulnerabilities?limit=300&status=open&sort_by=published_date&sort_order=desc&distinct_cve=true')
|
|
.catch(() => ({ data: { items: [] } })),
|
|
// EOL / EOS: pseudo-CVEs from endoflife.date check (cve_id
|
|
// starts with "EOL-"). Sort by detected_at desc so the freshest EOL
|
|
// findings surface first; distinct_cve collapses the same EOL stream
|
|
// across many assets to one row (else the widget starved to ~4).
|
|
api.get('/api/v1/vulnerabilities?limit=12&status=active&search=EOL-&sort_by=detected_at&sort_order=desc&distinct_cve=true')
|
|
.catch(() => ({ data: { items: [] } })),
|
|
// Mobile Security: vendor EOL/EOS + Android patch-level staleness on
|
|
// phones/tablets. cvss desc surfaces the worst first (EOL 9 → patch
|
|
// ≥1y 8 → EOL-soon 5.5 → end-of-active-support 3).
|
|
api.get('/api/v1/vulnerabilities?limit=15&status=active&finding_type=mobile&sort_by=cvss&sort_order=desc')
|
|
.catch(() => ({ data: { items: [] } })),
|
|
// Advisory feed: recently-added CISA KEV (actively exploited in the
|
|
// wild), independent of whether we have an affected asset yet.
|
|
api.get('/api/v1/advisories/kev-recent?limit=12')
|
|
.catch(() => ({ data: { items: [] } })),
|
|
api.get('/api/v1/scans/schedules').catch(() => ({ data: [] })),
|
|
api.get('/api/v1/compliance/summary').catch(() => ({ data: null })),
|
|
api.get('/api/v1/compliance/urs?limit=200').catch(() => ({ data: [] })),
|
|
]);
|
|
setStats(statsRes.data);
|
|
// EOL pseudo-CVEs have their own widget — skip them here so
|
|
// they don't take seats in Recent Critical / Newly Published.
|
|
const isRealCve = (v: Vulnerability) =>
|
|
!!v.cve_id && !v.cve_id.startsWith('EOL-') && !v.cve_id.startsWith('NESSUS-PLUGIN-');
|
|
|
|
// Newly Published — dedup by CVE-ID, keep first occurrence
|
|
// (already sorted by published_date desc).
|
|
const rawVulns: Vulnerability[] = vulnsRes.data.items || vulnsRes.data;
|
|
const seenPublished = new Set<string>();
|
|
const recentPublished: Vulnerability[] = [];
|
|
for (const v of rawVulns) {
|
|
if (!isRealCve(v)) continue;
|
|
if (v.cve_id && !seenPublished.has(v.cve_id)) {
|
|
seenPublished.add(v.cve_id);
|
|
recentPublished.push(v);
|
|
if (recentPublished.length >= 10) break;
|
|
}
|
|
}
|
|
setRecentVulns(recentPublished);
|
|
|
|
// Recent Critical — dedup + filter (CVSS ≥ 8 OR KEV OR EUVD). Feeder
|
|
// is published_date desc; tie-break same-day CVEs by CPR desc so the
|
|
// most exploitable of the newest sit on top.
|
|
const rawCritical: Vulnerability[] =
|
|
(criticalRes.data && (criticalRes.data.items || criticalRes.data)) || [];
|
|
const seenCritical = new Set<string>();
|
|
const recentCritical: Vulnerability[] = [];
|
|
for (const v of rawCritical) {
|
|
if (!isRealCve(v)) continue;
|
|
const isCritical =
|
|
(v.cvss_score ?? 0) >= 8.0 || v.kev_listed === true || v.euvd_listed === true;
|
|
if (!isCritical) continue;
|
|
if (v.cve_id && !seenCritical.has(v.cve_id)) {
|
|
seenCritical.add(v.cve_id);
|
|
recentCritical.push(v);
|
|
if (recentCritical.length >= 10) break;
|
|
}
|
|
}
|
|
recentCritical.sort((a: any, b: any) => {
|
|
const da = (a.published_date || '').slice(0, 10);
|
|
const db_ = (b.published_date || '').slice(0, 10);
|
|
if (da !== db_) return da < db_ ? 1 : -1; // newer day first
|
|
return (b.cpr_score ?? 0) - (a.cpr_score ?? 0);
|
|
});
|
|
setCriticalVulns(recentCritical);
|
|
|
|
// Mobile findings have their own widget — keep them out of the
|
|
// generic (desktop-software) EOL widget so the two stay cleanly apart.
|
|
const isMobileFinding = (v: Vulnerability) => !!v.cve_id && (
|
|
v.cve_id.startsWith('ANDROID-PATCH-') ||
|
|
v.cve_id.startsWith('EOL-IPHONE-') || v.cve_id.startsWith('EOL-IPAD-') ||
|
|
v.cve_id.startsWith('EOL-SAMSUNG-MOBILE-') || v.cve_id.startsWith('EOL-SAMSUNG-GALAXY-TAB-'));
|
|
|
|
// EOL widget — dedup by cve_id (one row per EOL stream is plenty).
|
|
const rawEol: Vulnerability[] = (eolRes.data && (eolRes.data.items || eolRes.data)) || [];
|
|
const seenEol = new Set<string>();
|
|
const recentEol: Vulnerability[] = [];
|
|
for (const v of rawEol) {
|
|
if (isMobileFinding(v)) continue;
|
|
if (v.cve_id && !seenEol.has(v.cve_id)) {
|
|
seenEol.add(v.cve_id);
|
|
recentEol.push(v);
|
|
if (recentEol.length >= 10) break;
|
|
}
|
|
}
|
|
setEolVulns(recentEol);
|
|
|
|
// Mobile Security widget — dedup by cve_id + asset (the SAME EOL
|
|
// stream on N different devices is N distinct findings).
|
|
const rawMobile: Vulnerability[] = (mobileRes.data && (mobileRes.data.items || mobileRes.data)) || [];
|
|
const seenMobile = new Set<string>();
|
|
const recentMobile: Vulnerability[] = [];
|
|
for (const v of rawMobile) {
|
|
const key = `${v.cve_id}|${v.asset_id}`;
|
|
if (!seenMobile.has(key)) {
|
|
seenMobile.add(key);
|
|
recentMobile.push(v);
|
|
if (recentMobile.length >= 10) break;
|
|
}
|
|
}
|
|
setMobileVulns(recentMobile);
|
|
|
|
setKevAdvisories((kevRes.data && kevRes.data.items) || []);
|
|
|
|
const schedules = Array.isArray(schedRes.data) ? schedRes.data : [];
|
|
setActiveScheduleCount(schedules.filter((s: any) => s.enabled).length);
|
|
setCompliance(compRes.data);
|
|
setUrsTop(Array.isArray(ursRes.data) ? ursRes.data : []);
|
|
} catch (error) {
|
|
console.error("Failed to fetch dashboard data:", error);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
fetchData();
|
|
}, []);
|
|
|
|
if (loading) {
|
|
return <div className="p-8 text-center">Loading Dashboard...</div>;
|
|
}
|
|
|
|
const statItems = [
|
|
{ name: 'CRITICAL', value: stats?.critical_count, change: '+0', changeType: 'increase', color: 'text-securis-danger' },
|
|
{ name: 'HIGH', value: stats?.high_count, change: '+0', changeType: 'decrease', color: 'text-truevuln-blue' },
|
|
{ name: 'MEDIUM', value: stats?.medium_count, change: '+0', changeType: 'increase', color: 'text-blue-600' },
|
|
{ name: 'LOW', value: stats?.low_count, change: '+0', changeType: 'decrease', color: 'text-green-600' },
|
|
];
|
|
return (
|
|
// Full available width (minus AppShell padding) — tester wanted the
|
|
// empty left/right gutters used on wide monitors. No max-width cap.
|
|
<div className="w-full">
|
|
{/* Header Section */}
|
|
<div className="md:flex md:items-center md:justify-between mb-8">
|
|
<div className="min-w-0 flex-1">
|
|
<h2 className="text-3xl font-bold leading-7 text-gray-900 sm:truncate sm:tracking-tight font-mono">
|
|
Vulnerability Dashboard
|
|
</h2>
|
|
<p className="mt-1 text-sm text-gray-500">Monitor and manage security vulnerabilities across your infrastructure</p>
|
|
</div>
|
|
<div className="mt-4 flex flex-wrap md:ml-4 md:mt-0 items-center gap-2 justify-end">
|
|
{canEdit && (<>
|
|
<div className="flex items-center ring-1 ring-inset ring-gray-300 rounded-md bg-white px-2">
|
|
<span className="text-xs font-mono text-gray-500 mr-1 uppercase font-bold text-indigo-600">Audit filter:</span>
|
|
<select
|
|
value={aiSeverity}
|
|
onChange={(e) => setAiSeverity(e.target.value)}
|
|
disabled={aiLoading}
|
|
className="bg-transparent text-sm font-semibold text-gray-900 py-2 focus:outline-none border-none cursor-pointer font-mono"
|
|
>
|
|
<option value="all">Any Severity</option>
|
|
<option value="critical">Critical Only</option>
|
|
<option value="high">High Only</option>
|
|
<option value="medium">Medium Only</option>
|
|
<option value="low">Low Only</option>
|
|
</select>
|
|
</div>
|
|
{aiLoading ? (
|
|
<button
|
|
onClick={cancelAIAudit}
|
|
className="inline-flex items-center rounded-md px-3 py-2 text-sm font-semibold shadow-sm ring-1 ring-inset ring-red-300 bg-red-50 text-red-700 hover:bg-red-100 transition-all"
|
|
>
|
|
<svg className="h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" /></svg>
|
|
Cancel AI Audit
|
|
</button>
|
|
) : (
|
|
<button
|
|
onClick={() => fetchAIRecommendations()}
|
|
className="inline-flex items-center rounded-md px-3 py-2 text-sm font-semibold shadow-sm ring-1 ring-inset ring-gray-300 bg-white text-gray-900 hover:bg-indigo-50 hover:ring-indigo-300 transition-all"
|
|
>
|
|
<SparklesIcon className="h-4 w-4 mr-2 text-indigo-600" />
|
|
AI Audit
|
|
</button>
|
|
)}
|
|
</>)}
|
|
{canEdit && (
|
|
<button
|
|
onClick={loadHistory}
|
|
className="inline-flex items-center rounded-md px-3 py-2 text-sm font-semibold shadow-sm ring-1 ring-inset ring-gray-300 bg-white text-gray-900 hover:bg-gray-50 transition-all ml-2"
|
|
title="View Scan History"
|
|
>
|
|
<ClockIcon className="h-4 w-4 text-gray-500" />
|
|
</button>
|
|
)}
|
|
{/* Was a dead button (no onClick) for every role — point it at the
|
|
reports page, which is where the exports actually live. */}
|
|
<Link
|
|
href="/reports"
|
|
className="inline-flex items-center 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"
|
|
>
|
|
Export Report
|
|
</Link>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Stats Grid */}
|
|
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-4 mb-8">
|
|
{statItems.map((item) => (
|
|
<div
|
|
key={item.name}
|
|
onClick={() => router.push(`/vulnerabilities?severity=${item.name.toLowerCase()}`)}
|
|
className="relative overflow-hidden rounded-sm bg-white border border-gray-200 p-6 shadow-sm cursor-pointer hover:border-gray-400 hover:shadow-md transition-all"
|
|
>
|
|
<dt>
|
|
<p className="truncate text-xs font-bold text-gray-500 uppercase tracking-wider font-mono">{item.name}</p>
|
|
</dt>
|
|
<dd className="flex items-baseline pb-1 sm:pb-2">
|
|
<p className={`text-4xl font-bold ${item.color || 'text-gray-900'} font-mono`}>{item.value ?? '-'}</p>
|
|
</dd>
|
|
<div className="flex items-center text-xs text-gray-500 gap-1.5">
|
|
{activeScheduleCount === null ? (
|
|
<span className="font-mono text-gray-400">checking…</span>
|
|
) : activeScheduleCount > 0 ? (
|
|
<>
|
|
<span className="inline-block h-1.5 w-1.5 rounded-full bg-emerald-500 animate-pulse" />
|
|
<span className="font-mono" title="Scheduled syncs enabled in Settings → Scan Schedules">
|
|
{activeScheduleCount} scheduled sync{activeScheduleCount === 1 ? '' : 's'}
|
|
</span>
|
|
</>
|
|
) : (
|
|
<>
|
|
<span className="inline-block h-1.5 w-1.5 rounded-full bg-gray-300" />
|
|
<span className="font-mono text-gray-400" title="No enabled scan schedules — data only updates on manual sync">
|
|
No scheduled syncs
|
|
</span>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* URS widget — Unified Risk Score (Plan E) — hidden until any asset has URS */}
|
|
{ursTop.some(r => r.urs !== null) && (() => {
|
|
const scored = ursTop.filter(r => r.urs !== null);
|
|
const avgUrs = scored.length ? scored.reduce((s, r) => s + (r.urs || 0), 0) / scored.length : null;
|
|
const counts = { CRITICAL: 0, HIGH: 0, MEDIUM: 0, LOW: 0, NONE: 0 } as Record<string, number>;
|
|
scored.forEach(r => { counts[r.severity] = (counts[r.severity] || 0) + 1; });
|
|
const top5 = [...scored].sort((a, b) => (b.urs || 0) - (a.urs || 0)).slice(0, 5);
|
|
const sevClass = (s: string) =>
|
|
s === 'CRITICAL' ? 'bg-red-700 text-white' :
|
|
s === 'HIGH' ? 'bg-red-100 text-red-700' :
|
|
s === 'MEDIUM' ? 'bg-orange-100 text-orange-700' :
|
|
s === 'LOW' ? 'bg-emerald-100 text-emerald-700' :
|
|
'bg-gray-100 text-gray-500';
|
|
const avgClass =
|
|
avgUrs === null ? 'text-gray-400' :
|
|
avgUrs >= 90 ? 'text-red-700' :
|
|
avgUrs >= 70 ? 'text-red-600' :
|
|
avgUrs >= 40 ? 'text-orange-700' :
|
|
'text-emerald-700';
|
|
return (
|
|
<div className="bg-white border border-gray-200 rounded-sm shadow-sm p-6 mb-8">
|
|
<div className="flex items-baseline justify-between mb-4">
|
|
<h3 className="text-lg font-bold text-gray-900 font-mono">
|
|
Unified Risk Score (URS)
|
|
</h3>
|
|
<Link href="/compliance" prefetch className="text-truevuln-blue text-xs font-bold uppercase tracking-wider font-mono hover:text-blue-700">
|
|
Open >
|
|
</Link>
|
|
</div>
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
|
<div>
|
|
<p className="text-xs uppercase tracking-wider text-gray-500 font-mono">Avg URS</p>
|
|
<p className={`text-4xl font-mono mt-1 ${avgClass}`}>
|
|
{avgUrs !== null ? avgUrs.toFixed(1) : '—'}
|
|
</p>
|
|
<p className="text-xs text-gray-400 font-mono mt-2">{scored.length} assets scored</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-xs uppercase tracking-wider text-gray-500 font-mono mb-2">Severity spread</p>
|
|
<div className="flex flex-wrap gap-1.5">
|
|
{(['CRITICAL','HIGH','MEDIUM','LOW','NONE'] as const).map(sev => (
|
|
counts[sev] > 0 && (
|
|
<span key={sev} className={`inline-flex items-center rounded px-2 py-0.5 text-[10px] font-bold ${sevClass(sev)}`}>
|
|
{sev} {counts[sev]}
|
|
</span>
|
|
)
|
|
))}
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<p className="text-xs uppercase tracking-wider text-gray-500 font-mono mb-2">Highest-risk assets</p>
|
|
<ul className="space-y-1 text-sm font-mono">
|
|
{top5.map(r => (
|
|
<li key={r.asset_id} className="flex justify-between gap-2">
|
|
<span className="truncate">{r.hostname || `#${r.asset_id}`}</span>
|
|
<span className="flex items-center gap-1.5">
|
|
<span className="text-gray-700 font-bold">
|
|
{r.urs !== null ? r.urs.toFixed(0) : '—'}
|
|
</span>
|
|
<span className={`inline-flex items-center rounded px-1.5 py-0.5 text-[9px] font-bold ${sevClass(r.severity)}`}>
|
|
{r.severity}
|
|
</span>
|
|
</span>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
})()}
|
|
|
|
{/* Compliance widget — hidden if no SCA data yet */}
|
|
{compliance && compliance.assets_with_compliance > 0 && (() => {
|
|
const score = compliance.avg_score_all;
|
|
const scoreCls = score === null ? 'text-gray-400'
|
|
: score >= 90 ? 'text-emerald-700'
|
|
: score >= 70 ? 'text-yellow-700'
|
|
: score >= 40 ? 'text-orange-700' : 'text-red-700';
|
|
const barCls = score === null ? 'bg-gray-200'
|
|
: score >= 90 ? 'bg-emerald-500'
|
|
: score >= 70 ? 'bg-yellow-500'
|
|
: score >= 40 ? 'bg-orange-500' : 'bg-red-500';
|
|
return (
|
|
<div className="bg-white border border-gray-200 rounded-sm shadow-sm p-6 mb-8">
|
|
<div className="flex items-baseline justify-between mb-4">
|
|
<h3 className="text-lg font-bold text-gray-900 font-mono">
|
|
Compliance (Wazuh SCA)
|
|
</h3>
|
|
<Link href="/compliance" prefetch className="text-truevuln-blue text-xs font-bold uppercase tracking-wider font-mono hover:text-blue-700">
|
|
Open >
|
|
</Link>
|
|
</div>
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
|
<div>
|
|
<p className="text-xs uppercase tracking-wider text-gray-500 font-mono">Avg score</p>
|
|
<p className={`text-4xl font-mono mt-1 ${scoreCls}`}>
|
|
{score !== null ? `${score.toFixed(1)}%` : '—'}
|
|
</p>
|
|
<div className="h-1.5 w-full overflow-hidden rounded-full bg-gray-100 mt-2">
|
|
<div className={`h-full ${barCls}`} style={{ width: `${score ?? 0}%` }} />
|
|
</div>
|
|
<p className="text-xs text-gray-400 font-mono mt-2">
|
|
{compliance.assets_with_compliance} assets · {compliance.total_policies} policy runs
|
|
</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-xs uppercase tracking-wider text-gray-500 font-mono">Check totals</p>
|
|
<div className="grid grid-cols-3 gap-2 mt-2 text-center">
|
|
<div>
|
|
<p className="text-xl font-mono font-bold text-emerald-700">{compliance.pass_total.toLocaleString()}</p>
|
|
<p className="text-[10px] uppercase text-gray-500 font-mono">Pass</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-xl font-mono font-bold text-red-700">{compliance.fail_total.toLocaleString()}</p>
|
|
<p className="text-[10px] uppercase text-gray-500 font-mono">Fail</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-xl font-mono font-bold text-gray-500">{compliance.not_applicable_total.toLocaleString()}</p>
|
|
<p className="text-[10px] uppercase text-gray-500 font-mono">N/A</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<p className="text-xs uppercase tracking-wider text-gray-500 font-mono mb-2">Worst offenders</p>
|
|
<ul className="space-y-1 text-sm font-mono">
|
|
{compliance.worst_offenders.slice(0, 3).map(o => {
|
|
const c = o.avg_score === null ? 'text-gray-400'
|
|
: o.avg_score >= 70 ? 'text-yellow-700'
|
|
: o.avg_score >= 40 ? 'text-orange-700' : 'text-red-700';
|
|
return (
|
|
<li key={o.asset_id} className="flex justify-between gap-2">
|
|
<Link href={`/compliance`} className="text-truevuln-blue hover:underline truncate" title={o.worst_policy || ''}>
|
|
{o.hostname}
|
|
</Link>
|
|
<span className={`${c} font-bold`}>
|
|
{o.avg_score !== null ? `${o.avg_score.toFixed(0)}%` : '—'}
|
|
</span>
|
|
</li>
|
|
);
|
|
})}
|
|
{compliance.worst_offenders.length === 0 && (
|
|
<li className="text-gray-400 text-xs">All assets above threshold.</li>
|
|
)}
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
})()}
|
|
|
|
{/* AI Smart Priorities — editor+ only (the audit that fills it is gated) */}
|
|
{canEdit && (
|
|
<AIRecommendations
|
|
recommendations={aiPriorities?.recommendations || []}
|
|
strategy={aiPriorities?.global_strategy}
|
|
loading={aiLoading}
|
|
error={aiError}
|
|
/>
|
|
)}
|
|
|
|
|
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8 mb-8">
|
|
{/* Chart Section Placeholder */}
|
|
<div className="lg:col-span-2 bg-white border border-gray-200 p-6 rounded-sm shadow-sm">
|
|
<div className="mb-6">
|
|
<h3 className="text-lg font-bold text-gray-900 font-mono">Severity Breakdown — Last 7 Days</h3>
|
|
</div>
|
|
<div className="h-48 flex items-end justify-between gap-4 px-4 pt-4 border-b border-gray-100 pb-2">
|
|
{stats?.severity_history.map((h, idx) => {
|
|
const maxCount = Math.max(...(stats?.severity_history.map(s => s.count) || [1]));
|
|
const height = stats?.severity_history ? (h.count / maxCount * 100) : 0;
|
|
const colors = ['bg-amber-900', 'bg-amber-700', 'bg-truevuln-blue', 'bg-blue-900', 'bg-red-700', 'bg-green-900', 'bg-blue-500'];
|
|
|
|
return (
|
|
<div
|
|
key={idx}
|
|
className={`w-full ${colors[idx % colors.length]} rounded-t-sm transition-all duration-500 ease-out`}
|
|
style={{ height: `${Math.max(height, 5)}%`, opacity: h.count === 0 ? 0.3 : 1 }}
|
|
title={`${h.day}: ${h.count} new vulns`}
|
|
></div>
|
|
);
|
|
})}
|
|
</div>
|
|
<div className="flex justify-between px-4 text-xs text-gray-400 font-mono mt-2">
|
|
{stats?.severity_history.map((h, idx) => (
|
|
<span key={idx} className="w-full text-center">{h.day}</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="bg-white border border-gray-200 p-6 rounded-sm shadow-sm">
|
|
<div className="mb-6">
|
|
<h3 className="text-lg font-bold text-gray-900 font-mono">Scan Coverage</h3>
|
|
</div>
|
|
|
|
<div className="space-y-6">
|
|
<div>
|
|
<div className="flex justify-between text-sm mb-1">
|
|
<span className="text-gray-500 font-mono">Assets Scanned</span>
|
|
<span className="font-bold flex font-mono">{stats?.scanned_assets.toLocaleString() || 0} <span className="text-gray-400 font-normal ml-1">/ {stats?.total_assets.toLocaleString() || 0}</span></span>
|
|
</div>
|
|
<div className="w-full bg-gray-100 rounded-full h-2.5">
|
|
<div
|
|
className="bg-truevuln-blue h-2.5 rounded-full"
|
|
style={{ width: `${stats?.total_assets ? (stats.scanned_assets / stats.total_assets * 100) : 0}%` }}
|
|
></div>
|
|
</div>
|
|
<div className="mt-2 text-right">
|
|
<span className="text-xs font-bold text-green-600 font-mono">
|
|
{stats?.total_assets ? (stats.scanned_assets / stats.total_assets * 100).toFixed(1) : '0.0'}% Coverage
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="pt-4 border-t border-gray-100">
|
|
<div className="flex justify-between text-sm py-1">
|
|
<span className="text-gray-500 font-mono">Last Full Scan</span>
|
|
<span className="font-mono text-gray-900">Continuous</span>
|
|
</div>
|
|
<div className="flex justify-between text-sm py-1">
|
|
<span className="text-gray-500 font-mono">Scan Frequency</span>
|
|
<span className="font-mono text-gray-900">Automated</span>
|
|
</div>
|
|
<div className="flex justify-between text-sm py-1">
|
|
<span className="text-gray-500 font-mono">Coverage Rate</span>
|
|
<span className={`font-mono font-bold ${stats?.total_assets && (stats.scanned_assets / stats.total_assets) === 1 ? 'text-green-600' : 'text-blue-600'}`}>
|
|
{stats?.total_assets ? (stats.scanned_assets / stats.total_assets * 100).toFixed(1) : '0.0'}%
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
|
|
|
|
{
|
|
showHistory && (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-gray-500 bg-opacity-75">
|
|
<div className="bg-white rounded-lg shadow-xl max-w-2xl w-full p-6 max-h-[80vh] overflow-y-auto">
|
|
<div className="flex justify-between items-center mb-4">
|
|
<h3 className="text-lg font-bold font-mono">AI Audit History</h3>
|
|
<button onClick={() => setShowHistory(false)} className="text-gray-400 hover:text-gray-600">
|
|
<span className="sr-only">Close</span>
|
|
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" /></svg>
|
|
</button>
|
|
</div>
|
|
<div className="space-y-2">
|
|
{historyReports.map((report) => (
|
|
<div key={report.id} onClick={() => loadReport(report)} className="p-4 border border-gray-200 rounded hover:bg-indigo-50 cursor-pointer flex justify-between items-center transition-colors">
|
|
<div>
|
|
<p className="font-bold text-sm text-gray-900">{new Date(report.created_at).toLocaleString()}</p>
|
|
<p className="text-xs text-gray-500 truncate max-w-md">{report.global_strategy.substring(0, 100)}...</p>
|
|
</div>
|
|
<span className="text-xs font-mono bg-indigo-100 text-indigo-700 px-2 py-1 rounded-full">
|
|
{report.recommendations_count} Findings
|
|
</span>
|
|
</div>
|
|
))}
|
|
{historyReports.length === 0 && <p className="text-gray-500 text-center py-4">No history available yet.</p>}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
{/* Recent Vulnerabilities — three split widgets:
|
|
left = Recent Critical CVEs (CVSS ≥ 8 OR KEV OR EUVD,
|
|
sorted by our DB updated_at desc — catches
|
|
Vulnrichment corrections + KEV/EUVD just-landed)
|
|
middle = Newly Published CVEs (sort published_date desc)
|
|
right = Newly EOL / EOS (endoflife.date pseudo-CVEs) */}
|
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 items-start">
|
|
{renderVulnWidget({
|
|
title: 'Recent Critical CVEs',
|
|
subtitle: 'CVSS ≥ 8 or KEV or EUVD · newest first, ties by CPR',
|
|
vulns: criticalVulns,
|
|
onRowClick: (cveId) => router.push(`/vulnerabilities?cve_id=${cveId}`),
|
|
viewAllHref: '/vulnerabilities?sort_by=published_date&sort_order=desc',
|
|
})}
|
|
{renderVulnWidget({
|
|
title: 'Newly Published CVEs',
|
|
subtitle: 'All severities · sorted by CVE published date',
|
|
vulns: recentVulns,
|
|
onRowClick: (cveId) => router.push(`/vulnerabilities?cve_id=${cveId}`),
|
|
viewAllHref: '/vulnerabilities?sort_by=published_date&sort_order=desc',
|
|
})}
|
|
{renderVulnWidget({
|
|
title: 'Newly EOL / EOS',
|
|
subtitle: 'End-of-life software · sorted by detection',
|
|
vulns: eolVulns,
|
|
labelField: 'package_name',
|
|
firstColHeader: 'Product',
|
|
assetColumn: true,
|
|
onRowClick: (cveId) => router.push(`/vulnerabilities?cve_id=${cveId}`),
|
|
// eol=1 drives the eolOnly toggle on the list page.
|
|
viewAllHref: '/vulnerabilities?eol=1&sort_by=detected_at&sort_order=desc',
|
|
})}
|
|
{renderVulnWidget({
|
|
title: 'Mobile Security · EOL & Patch Level',
|
|
subtitle: 'Phones & tablets · vendor EOL/EOS + Android patch staleness',
|
|
vulns: mobileVulns,
|
|
labelField: 'package_name',
|
|
firstColHeader: 'Device / Item',
|
|
assetColumn: true,
|
|
onRowClick: (cveId) => router.push(`/vulnerabilities?cve_id=${cveId}`),
|
|
viewAllHref: '/vulnerabilities?finding_type=mobile&sort_by=cvss&sort_order=desc',
|
|
})}
|
|
|
|
{/* Advisory feed — CISA KEV (actively exploited in the wild), independent
|
|
of asset findings. "In inventory" badge when we already track it. */}
|
|
<div className="bg-white border border-gray-200 shadow-sm rounded-sm overflow-hidden flex flex-col">
|
|
<div className="p-4 border-b border-gray-200 flex justify-between items-baseline bg-gray-50/50">
|
|
<div>
|
|
<h3 className="text-base font-bold text-gray-900 font-mono">Actively Exploited · CISA KEV</h3>
|
|
<p className="text-[11px] text-gray-500 font-mono mt-0.5">Newly added known-exploited CVEs · 🔒 = ransomware use</p>
|
|
</div>
|
|
<a href="https://www.cisa.gov/known-exploited-vulnerabilities-catalog" target="_blank" rel="noreferrer"
|
|
className="text-truevuln-blue text-[10px] font-bold uppercase tracking-wider font-mono hover:text-blue-700">View All ></a>
|
|
</div>
|
|
<div className="overflow-x-auto flex-1">
|
|
{kevAdvisories.length === 0 ? (
|
|
<p className="p-4 text-xs text-gray-400 font-mono">No KEV data.</p>
|
|
) : (
|
|
<table className="min-w-full text-sm">
|
|
<tbody className="divide-y divide-gray-100">
|
|
{kevAdvisories.map((k: any) => (
|
|
<tr key={k.cve_id} className="hover:bg-gray-50 cursor-pointer"
|
|
onClick={() => router.push(`/vulnerabilities?cve_id=${k.cve_id}`)}>
|
|
<td className="px-3 py-2 whitespace-nowrap">
|
|
<span className="font-mono text-truevuln-blue text-xs font-bold">{k.cve_id}</span>
|
|
{k.ransomware && <span title="Known ransomware campaign use" className="ml-1">🔒</span>}
|
|
</td>
|
|
<td className="px-3 py-2 text-xs text-gray-600 truncate max-w-[180px]" title={`${k.vendor || ''} ${k.product || ''}`}>
|
|
{[k.vendor, k.product].filter(Boolean).join(' · ')}
|
|
</td>
|
|
<td className="px-3 py-2 whitespace-nowrap text-[11px] text-gray-400 font-mono">{k.date_added}</td>
|
|
<td className="px-3 py-2 whitespace-nowrap text-right">
|
|
{k.in_inventory ? (
|
|
<span className="inline-flex items-center rounded-sm border border-red-200 bg-red-50 px-1.5 py-0.5 text-[10px] font-bold uppercase text-red-700"
|
|
title={`Present on ${k.asset_count} asset(s)`}>In inventory · {k.asset_count}</span>
|
|
) : (
|
|
<span className="inline-flex items-center rounded-sm border border-gray-200 bg-gray-50 px-1.5 py-0.5 text-[10px] font-mono uppercase text-gray-400">not seen</span>
|
|
)}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div >
|
|
);
|
|
}
|