Files
vulncheck/frontend/app/compliance/page.tsx
T
vulncheck e19c8d9da7 fix(app-scan): stop cross-release Windows false positives
Regression from 5703dfa. A Server 2016 host (10.0.14393.9234) was flagged with
CVE-2021-26432 'fixed in 10.0.17763.2114' — a Server 2019 build. My claim that
cvelistV5 ranges are release-bounded came from checking ONE modern record, and
older ones are not:

  CVE-2021-26432  version=10.0.0        lessThan=10.0.17763.2114   (generic floor)
  CVE-2026-47291  version=10.0.14393.0  lessThan=10.0.14393.9234   (release-bounded)

A '10.0.0' floor makes the range swallow every lower build, so a 14393 host fell
inside a 17763 fix. The OS scan now requires the floor and the fix to sit on the
same build line and skips the rest — such an entry carries no release info at
all, and the OS string alone can't supply it. Costs nothing in practice: Windows
servicing is cumulative, so a host behind on an old CVE is already flagged by
that line's newer ones. Existing false positives self-heal on the next scan via
the app-scan auto-resolve.

Also:
- scheduler job renamed to '(Windows OS)' — it covers client too, not just Server
- dashboard: AI Audit History hidden from read-only (AI endpoints are editor+)
- dashboard: 'Export Report' had no onClick at all (dead for every role) — now
  links to the reports page
- compliance: the Impact-CSV upload card is admin-only (the import endpoint is
  RequireAdmin), so it no longer 403s silently for other roles
2026-07-14 15:36:10 +02:00

525 lines
27 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";
import { useEffect, useState } from 'react';
import api from '../../lib/api';
import { ArrowPathIcon, CheckBadgeIcon, ExclamationTriangleIcon } from '@heroicons/react/24/outline';
type AssetSummary = {
asset_id: number;
hostname: string;
operating_system: string | null;
policy_count: number;
avg_score: number | null;
worst_score: number | null;
worst_policy: string | null;
last_synced: string | null;
};
type GlobalSummary = {
assets_with_compliance: number;
total_policies: number;
avg_score_all: number | null;
pass_total: number;
fail_total: number;
not_applicable_total: number;
worst_offenders: AssetSummary[];
};
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;
criticality_factor: number | null;
policy_count: number | null;
};
/**
* Normalises a thrown axios/fetch error into a renderable string.
* FastAPI returns 422 errors as { detail: [{type,loc,msg,input}, ...] }
* — rendering that array directly crashes React with #31. Flatten to text.
*/
function formatApiError(e: any, fallback: string): string {
const detail = e?.response?.data?.detail;
if (!detail) return e?.message || fallback;
if (typeof detail === 'string') return detail;
if (Array.isArray(detail)) {
return detail
.map((d: any) => {
if (typeof d === 'string') return d;
const loc = Array.isArray(d?.loc) ? d.loc.join('.') : '';
return `${loc ? loc + ': ' : ''}${d?.msg || JSON.stringify(d)}`;
})
.join('; ');
}
try { return JSON.stringify(detail); } catch { return fallback; }
}
function ursColor(severity: string): string {
switch (severity) {
case 'CRITICAL': return 'bg-red-700 text-white';
case 'HIGH': return 'bg-red-100 text-red-700';
case 'MEDIUM': return 'bg-orange-100 text-orange-700';
case 'LOW': return 'bg-emerald-100 text-emerald-700';
default: return 'bg-gray-100 text-gray-500';
}
}
type PolicyResult = {
id: number;
asset_id: number;
policy_id: string;
policy_name: string | null;
policy_description: string | null;
total_checks: number;
pass_count: number;
fail_count: number;
not_applicable_count: number;
score: number | null;
end_scan: string | null;
last_synced: string;
};
function scoreColor(score: number | null): string {
if (score === null) return 'text-gray-400';
if (score >= 90) return 'text-emerald-700 font-bold';
if (score >= 70) return 'text-yellow-700 font-bold';
if (score >= 40) return 'text-orange-700 font-bold';
return 'text-red-700 font-bold';
}
function scoreBg(score: number | null): string {
if (score === null) return 'bg-gray-200';
if (score >= 90) return 'bg-emerald-500';
if (score >= 70) return 'bg-yellow-500';
if (score >= 40) return 'bg-orange-500';
return 'bg-red-500';
}
export default function CompliancePage() {
const [summary, setSummary] = useState<GlobalSummary | null>(null);
const [assets, setAssets] = useState<AssetSummary[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [selectedAsset, setSelectedAsset] = useState<number | null>(null);
const [assetPolicies, setAssetPolicies] = useState<PolicyResult[]>([]);
const [policyLoading, setPolicyLoading] = useState(false);
const [err, setErr] = useState<string | null>(null);
const [ursRows, setUrsRows] = useState<URSRow[]>([]);
const [avsMode, setAvsMode] = useState<'hybrid' | 'avg' | 'max'>('hybrid');
const [impactStats, setImpactStats] = useState<Array<{benchmark: string; count: number; avg_impact: number | null}>>([]);
const fetchAll = async () => {
setLoading(true);
setErr(null);
try {
const [s, a, u, imp] = await Promise.all([
api.get('/api/v1/compliance/summary'),
api.get('/api/v1/compliance/assets?limit=200'),
api.get(`/api/v1/compliance/urs?avs_mode=${avsMode}&limit=200`),
api.get('/api/v1/compliance/impacts/stats').catch(() => ({ data: [] })),
]);
setSummary(s.data);
setAssets(Array.isArray(a.data) ? a.data : []);
// Defensive: backend could 500 with non-array body — never let
// .map crash the page.
setUrsRows(Array.isArray(u.data) ? u.data : []);
setImpactStats(Array.isArray(imp.data) ? imp.data : []);
} catch (e: any) {
setErr(formatApiError(e, 'Failed to load compliance data.'));
} finally {
setLoading(false);
}
};
useEffect(() => { fetchAll(); }, [avsMode]);
// CSV upload state. The import endpoint is admin-only, so non-admins were
// shown an upload control that silently 403'd.
const [uploading, setUploading] = useState(false);
const [isAdmin, setIsAdmin] = useState(false);
useEffect(() => {
api.get('/auth/me').then(r => setIsAdmin((r.data?.role || '') === 'admin')).catch(() => { });
}, []);
const uploadCsvs = async (filelist: FileList | null) => {
if (!filelist || filelist.length === 0) return;
setUploading(true);
setErr(null);
try {
const fd = new FormData();
Array.from(filelist).forEach(f => fd.append('files', f));
const r = await api.post('/api/v1/compliance/impacts/import', fd, {
headers: { 'Content-Type': 'multipart/form-data' },
});
const d = r.data || {};
const perFile = d.per_file || {};
const firstFile: any = Object.values(perFile)[0] || {};
let msg = `Impacts imported: ${d.imported ?? 0} rows from ${d.files ?? 0} files. `;
msg += `Skipped: no-id=${d.skipped_no_id ?? 0}, not-cis=${d.skipped_not_cis ?? 0}.`;
if ((d.imported ?? 0) === 0) {
msg += `\n\nDIAGNOSE (first file):\n`;
msg += `Headers: ${(firstFile.detected_headers || []).join(', ')}\n`;
msg += `ID column used: ${firstFile.id_column_used || '(none recognised — check CSV header names)'}\n`;
msg += `Impact column used: ${firstFile.impact_column_used || '(none — defaulting to 50)'}\n`;
if (Array.isArray(firstFile.preview) && firstFile.preview.length > 0) {
msg += `Sample skipped rows:\n`;
firstFile.preview.forEach((p: any) => {
msg += ` row ${p.row}: ${p.reason}` +
(p.value ? ` — value="${p.value}"` : '') +
(p.sample ? ` — sample=${JSON.stringify(p.sample)}` : '') + '\n';
});
}
}
alert(msg);
await fetchAll();
} catch (e: any) {
setErr(formatApiError(e, 'Impact CSV upload failed.'));
} finally {
setUploading(false);
}
};
const recomputeURS = async () => {
try {
await api.post(`/api/v1/compliance/urs/recompute-all?avs_mode=${avsMode}`);
await fetchAll();
} catch (e: any) {
setErr(formatApiError(e, 'URS recompute failed.'));
}
};
const refreshAll = async () => {
if (!confirm('Refresh SCA results for every Wazuh-linked asset? This pulls /sca/{agent_id} per agent.')) return;
setRefreshing(true);
setErr(null);
try {
const r = await api.post('/api/v1/compliance/refresh');
alert(`Compliance refreshed: ${r.data.assets_synced} assets, ${r.data.policies_synced} policy results.${(r.data.errors || []).length ? ' Some errors — see backend logs.' : ''}`);
await fetchAll();
} catch (e: any) {
setErr(formatApiError(e, 'Refresh failed.'));
} finally {
setRefreshing(false);
}
};
const openAsset = async (assetId: number) => {
setSelectedAsset(assetId);
setPolicyLoading(true);
setAssetPolicies([]);
try {
const r = await api.get(`/api/v1/compliance/${assetId}`);
setAssetPolicies(r.data);
} catch (e) {
setAssetPolicies([]);
} finally {
setPolicyLoading(false);
}
};
const refreshAsset = async (assetId: number) => {
try {
await api.post(`/api/v1/compliance/${assetId}/refresh`);
await openAsset(assetId);
await fetchAll();
} catch {
// ignore — keep modal open
}
};
if (loading) return <div className="p-8 text-center font-mono text-gray-500">Loading compliance data</div>;
return (
<div className="w-full p-4 sm:p-6 lg:p-8">
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-gray-900 font-mono flex items-center gap-2">
<CheckBadgeIcon className="h-7 w-7 text-emerald-600" />
Compliance
</h1>
<p className="text-sm text-gray-500 font-mono mt-1">
Wazuh SCA results pass / fail per check, aggregated per policy and asset.
</p>
</div>
<button
onClick={refreshAll}
disabled={refreshing}
className="inline-flex items-center gap-2 rounded-md bg-truevuln-blue px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-600 disabled:opacity-50 font-mono"
>
<ArrowPathIcon className={`h-4 w-4 ${refreshing ? 'animate-spin' : ''}`} />
{refreshing ? 'Refreshing…' : 'Refresh All'}
</button>
</div>
{err && (
<div className="mb-4 rounded-md bg-red-50 border border-red-200 p-3 text-sm text-red-700 font-mono">
{err}
</div>
)}
{/* Global summary cards */}
{summary && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
<div className="bg-white rounded-lg border border-gray-200 shadow-sm p-4">
<p className="text-xs uppercase tracking-wider text-gray-500 font-mono">Avg score</p>
<p className={`text-3xl font-mono mt-1 ${scoreColor(summary.avg_score_all)}`}>
{summary.avg_score_all !== null ? `${summary.avg_score_all.toFixed(1)}%` : '—'}
</p>
</div>
<div className="bg-white rounded-lg border border-gray-200 shadow-sm p-4">
<p className="text-xs uppercase tracking-wider text-gray-500 font-mono">Assets covered</p>
<p className="text-3xl font-mono text-gray-900 mt-1">{summary.assets_with_compliance}</p>
<p className="text-xs text-gray-400 font-mono">{summary.total_policies} policy runs</p>
</div>
<div className="bg-white rounded-lg border border-gray-200 shadow-sm p-4">
<p className="text-xs uppercase tracking-wider text-gray-500 font-mono">Pass total</p>
<p className="text-3xl font-mono text-emerald-700 mt-1">{summary.pass_total.toLocaleString()}</p>
</div>
<div className="bg-white rounded-lg border border-gray-200 shadow-sm p-4">
<p className="text-xs uppercase tracking-wider text-gray-500 font-mono">Fail total</p>
<p className="text-3xl font-mono text-red-700 mt-1">{summary.fail_total.toLocaleString()}</p>
<p className="text-xs text-gray-400 font-mono">{summary.not_applicable_total.toLocaleString()} N/A</p>
</div>
</div>
)}
{/* Worst offenders */}
{summary && Array.isArray(summary.worst_offenders) && summary.worst_offenders.length > 0 && (
<div className="bg-white rounded-lg border border-gray-200 shadow-sm p-4 mb-8">
<h2 className="text-sm font-bold text-gray-900 font-mono mb-3 flex items-center gap-2">
<ExclamationTriangleIcon className="h-4 w-4 text-orange-600" />
Worst-Performing Assets
</h2>
<ul className="divide-y divide-gray-100">
{summary.worst_offenders.map(a => (
<li key={a.asset_id} className="py-2 flex items-center justify-between text-sm font-mono">
<button onClick={() => openAsset(a.asset_id)} className="text-truevuln-blue hover:underline text-left">
{a.hostname}
</button>
<span className="text-gray-500 text-xs truncate ml-2 max-w-xs" title={a.worst_policy || ''}>
worst: {a.worst_policy || '—'}
</span>
<span className={`ml-3 ${scoreColor(a.avg_score)}`}>
{a.avg_score !== null ? `${a.avg_score.toFixed(1)}%` : '—'}
</span>
</li>
))}
</ul>
</div>
)}
{/* URS table */}
<div className="bg-white rounded-lg border border-gray-200 shadow-sm overflow-hidden mb-8">
<div className="px-4 py-3 border-b border-gray-100 bg-gray-50 flex items-center justify-between">
<div>
<h2 className="text-sm font-bold text-gray-900 font-mono">
Unified Risk Score (URS)
</h2>
<p className="text-[11px] text-gray-500 font-mono">
URS = ((AVS + ASS) / 2) × criticality. 90+ CRITICAL · 70+ HIGH · 40+ MEDIUM · 1+ LOW
</p>
</div>
<div className="flex items-center gap-2">
<select
value={avsMode}
onChange={(e) => setAvsMode(e.target.value as any)}
className="text-xs border border-gray-300 rounded px-2 py-1 font-mono"
title="AVS calculation mode"
>
<option value="hybrid">AVS: hybrid (0.7×avg + 0.3×max)</option>
<option value="avg">AVS: avg</option>
<option value="max">AVS: max</option>
</select>
<button
onClick={recomputeURS}
className="text-xs px-2 py-1 border border-gray-300 rounded hover:bg-gray-50 font-mono"
>
recompute
</button>
</div>
</div>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200 text-sm font-mono">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-2 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">Hostname</th>
<th className="px-4 py-2 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">Criticality</th>
<th className="px-4 py-2 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">AVS</th>
<th className="px-4 py-2 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">ASS</th>
<th className="px-4 py-2 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">URS</th>
<th className="px-4 py-2 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">Severity</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 bg-white">
{ursRows.length === 0 && (
<tr><td colSpan={6} className="px-4 py-6 text-center text-gray-500">No URS data recompute first.</td></tr>
)}
{ursRows.map(r => (
<tr key={r.asset_id} className="hover:bg-gray-50">
<td className="px-4 py-2 text-truevuln-blue font-bold">{r.hostname || `#${r.asset_id}`}</td>
<td className="px-4 py-2 text-gray-700 uppercase text-xs">{r.criticality || 'normal'}<span className="text-gray-400 ml-1">×{r.criticality_factor ?? 1.0}</span></td>
<td className="px-4 py-2 text-gray-700">{r.avs !== null ? r.avs.toFixed(1) : '—'}</td>
<td className="px-4 py-2 text-gray-700">{r.ass !== null ? r.ass.toFixed(1) : '—'}</td>
<td className="px-4 py-2 font-bold text-gray-900">{r.urs !== null ? r.urs.toFixed(1) : '—'}</td>
<td className="px-4 py-2">
<span className={`inline-flex items-center rounded px-2 py-0.5 text-[10px] font-bold ${ursColor(r.severity)}`}>
{r.severity}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* Impact CSV upload — admin only (POST /compliance/impacts/import) */}
{isAdmin && (
<div className="bg-white rounded-lg border border-gray-200 shadow-sm p-4 mb-8">
<div className="flex items-center justify-between mb-3">
<div>
<h2 className="text-sm font-bold text-gray-900 font-mono">Compliance Impact CSVs</h2>
<p className="text-[11px] text-gray-500 font-mono">
CIS-Benchmark exports (cis_id, title, impact). Multi-upload supported.
Filename benchmark label.
</p>
</div>
<label className="text-xs px-3 py-2 border border-gray-300 rounded hover:bg-gray-50 font-mono cursor-pointer">
{uploading ? 'Uploading…' : '+ Upload CSVs'}
<input
type="file"
multiple
accept=".csv,text/csv"
disabled={uploading}
onChange={(e) => uploadCsvs(e.target.files)}
className="hidden"
/>
</label>
</div>
{impactStats.length === 0 ? (
<p className="text-sm text-gray-400 font-mono">
No impact data loaded yet. Without impacts, ASS falls back to plain pass%.
</p>
) : (
<ul className="text-xs font-mono divide-y divide-gray-100">
{impactStats.map(s => (
<li key={s.benchmark} className="flex justify-between py-1">
<span className="truncate" title={s.benchmark}>{s.benchmark}</span>
<span className="text-gray-500">
{s.count} checks · avg impact {s.avg_impact ?? '—'}
</span>
</li>
))}
</ul>
)}
</div>
)}
{/* All assets table */}
<div className="bg-white rounded-lg border border-gray-200 shadow-sm overflow-hidden">
<div className="px-4 py-3 border-b border-gray-100 bg-gray-50">
<h2 className="text-sm font-bold text-gray-900 font-mono">All Assets</h2>
</div>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200 text-sm font-mono">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-2 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">Hostname</th>
<th className="px-4 py-2 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">OS</th>
<th className="px-4 py-2 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">Policies</th>
<th className="px-4 py-2 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">Avg</th>
<th className="px-4 py-2 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">Worst</th>
<th className="px-4 py-2 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">Last sync</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 bg-white">
{assets.length === 0 && (
<tr><td colSpan={6} className="px-4 py-6 text-center text-gray-500">
No compliance data yet. Click <b>Refresh All</b> to pull SCA results from Wazuh.
</td></tr>
)}
{assets.map(a => (
<tr key={a.asset_id} className="hover:bg-gray-50 cursor-pointer" onClick={() => openAsset(a.asset_id)}>
<td className="px-4 py-2 text-truevuln-blue font-bold">{a.hostname}</td>
<td className="px-4 py-2 text-gray-600 truncate max-w-xs">{a.operating_system || '—'}</td>
<td className="px-4 py-2 text-gray-700">{a.policy_count}</td>
<td className={`px-4 py-2 ${scoreColor(a.avg_score)}`}>
{a.avg_score !== null ? `${a.avg_score.toFixed(1)}%` : '—'}
</td>
<td className={`px-4 py-2 ${scoreColor(a.worst_score)}`}>
{a.worst_score !== null ? `${a.worst_score.toFixed(1)}%` : '—'}
</td>
<td className="px-4 py-2 text-gray-500 text-xs">
{a.last_synced ? new Date(a.last_synced).toLocaleString('de-CH') : '—'}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* Asset detail modal */}
{selectedAsset !== null && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-gray-700/70">
<div className="bg-white rounded-lg shadow-xl max-w-3xl w-full max-h-[85vh] overflow-hidden flex flex-col">
<div className="px-4 py-3 border-b border-gray-100 flex items-center justify-between">
<h3 className="text-base font-bold font-mono text-gray-900">
Compliance asset #{selectedAsset}
</h3>
<div className="flex items-center gap-2">
<button
onClick={() => refreshAsset(selectedAsset)}
className="text-xs px-2 py-1 border border-gray-300 rounded hover:bg-gray-50 font-mono"
title="Re-fetch from Wazuh"
>
refresh
</button>
<button
onClick={() => setSelectedAsset(null)}
className="text-gray-400 hover:text-gray-600 text-xl leading-none"
>
×
</button>
</div>
</div>
<div className="overflow-y-auto p-4 space-y-2">
{policyLoading && <p className="text-sm font-mono text-gray-500">Loading</p>}
{!policyLoading && assetPolicies.length === 0 && (
<p className="text-sm font-mono text-gray-500">No policies yet try refresh.</p>
)}
{assetPolicies.map(p => (
<div key={p.id} className="border border-gray-200 rounded-md p-3">
<div className="flex items-baseline justify-between gap-3 mb-1">
<p className="text-sm font-bold text-gray-900 font-mono truncate" title={p.policy_name || p.policy_id}>
{p.policy_name || p.policy_id}
</p>
<span className={`text-base font-mono ${scoreColor(p.score)}`}>
{p.score !== null ? `${p.score.toFixed(1)}%` : '—'}
</span>
</div>
<div className="h-1.5 w-full overflow-hidden rounded-full bg-gray-100 mb-2">
<div className={`h-full ${scoreBg(p.score)}`} style={{ width: `${p.score ?? 0}%` }} />
</div>
<div className="flex justify-between text-xs text-gray-500 font-mono">
<span title="passed checks" className="text-emerald-700"> {p.pass_count}</span>
<span title="failed checks" className="text-red-700"> {p.fail_count}</span>
<span title="not applicable" className="text-gray-500"> {p.not_applicable_count}</span>
<span title="total checks" className="text-gray-400">Σ {p.total_checks}</span>
<span className="text-gray-400 truncate">
scan: {p.end_scan ? new Date(p.end_scan).toLocaleDateString('de-CH') : '—'}
</span>
</div>
</div>
))}
</div>
</div>
</div>
)}
</div>
);
}