Tester: Wazuh's vuln-detector misses some products (MS365, mRemoteNG,
…). Chosen approach (safe option): surface the GAP, make NO automatic
CVE assignment → zero false positives.
Backend
- GET /api/v1/assets/{id}/coverage-gap: fetches syscollector packages,
cross-references against the asset's open-vuln package_name blob
(token match, len>=3 to avoid substring noise). Returns packages
WITHOUT any finding, each annotated with its endoflife.date status
when known (EOL / EOL SOON / out-of-active-support) — a hint only,
never a CVE claim. Pure local data, no NVD, no rate-limit risk.
Frontend
- Amber check-circle action per Wazuh-linked asset opens a modal
listing the gap packages (name / version / EOL hint), with a clear
"investigate manually — no automatic CVE claim" disclaimer.
Operator workflow: spot a high-value uncovered package (e.g. an EOL
mRemoteNG), investigate in NVD/vendor advisories, mark accordingly.
Deliberately conservative — keeps data quality intact.
726 lines
43 KiB
TypeScript
726 lines
43 KiB
TypeScript
"use client";
|
||
|
||
import { useEffect, useState } from 'react';
|
||
import api from '../../lib/api';
|
||
import { Asset, UserInfo, Group } from '../../types';
|
||
import Link from 'next/link';
|
||
import { PencilSquareIcon, TrashIcon, ArrowPathIcon, UserGroupIcon, ChevronDownIcon } from '@heroicons/react/24/outline';
|
||
import { UserCircleIcon } from '@heroicons/react/24/solid';
|
||
|
||
export default function AssetsPage() {
|
||
const [assets, setAssets] = useState<Asset[]>([]);
|
||
const [users, setUsers] = useState<UserInfo[]>([]);
|
||
const [groups, setGroups] = useState<Group[]>([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [rescanLoading, setRescanLoading] = useState<number | null>(null);
|
||
const [nessusRescanLoading, setNessusRescanLoading] = useState<number | null>(null);
|
||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||
const [searchText, setSearchText] = useState('');
|
||
const [showInactive, setShowInactive] = useState(false);
|
||
// Coverage-gap report modal
|
||
const [gapReport, setGapReport] = useState<any | null>(null);
|
||
const [gapLoading, setGapLoading] = useState<number | null>(null);
|
||
const [selectedIds, setSelectedIds] = useState<number[]>([]);
|
||
|
||
// Form State
|
||
const [editingId, setEditingId] = useState<number | null>(null);
|
||
const [formData, setFormData] = useState<{
|
||
hostname: string;
|
||
ip_address: string;
|
||
operating_system: string;
|
||
os_version: string;
|
||
description: string;
|
||
policy_id?: number | null;
|
||
criticality: 'low' | 'normal' | 'high' | 'critical';
|
||
}>({
|
||
hostname: '',
|
||
ip_address: '',
|
||
operating_system: '',
|
||
os_version: '',
|
||
description: '',
|
||
policy_id: undefined,
|
||
criticality: 'normal',
|
||
});
|
||
|
||
const [policies, setPolicies] = useState<{ id: number, name: string }[]>([]);
|
||
|
||
const fetchAssets = async () => {
|
||
try {
|
||
const params: any = {};
|
||
if (searchText) params.search = searchText;
|
||
if (showInactive) params.include_inactive = true;
|
||
|
||
const [assetsRes, usersRes, groupsRes] = await Promise.all([
|
||
api.get('/api/v1/assets', { params }),
|
||
api.get('/auth/users').catch(() => ({ data: [] })),
|
||
api.get('/api/v1/groups')
|
||
]);
|
||
setAssets(assetsRes.data);
|
||
setUsers(usersRes.data);
|
||
setGroups(groupsRes.data);
|
||
} catch (error) {
|
||
console.error("Failed to fetch assets:", error);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
const fetchPolicies = async () => {
|
||
try {
|
||
const response = await api.get('/api/v1/policies');
|
||
setPolicies(response.data);
|
||
} catch (error) {
|
||
console.error("Failed to fetch policies:", error);
|
||
}
|
||
};
|
||
|
||
useEffect(() => {
|
||
fetchAssets();
|
||
fetchPolicies();
|
||
}, []);
|
||
|
||
// Re-fetch when the inactive toggle flips.
|
||
useEffect(() => {
|
||
fetchAssets();
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [showInactive]);
|
||
|
||
const resetForm = () => {
|
||
setFormData({
|
||
hostname: '',
|
||
ip_address: '',
|
||
operating_system: '',
|
||
os_version: '',
|
||
description: '',
|
||
policy_id: undefined,
|
||
criticality: 'normal',
|
||
});
|
||
setEditingId(null);
|
||
setIsModalOpen(false);
|
||
};
|
||
|
||
const handleSaveAsset = async (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
try {
|
||
if (editingId) {
|
||
// Update
|
||
await api.put(`/api/v1/assets/${editingId}`, formData);
|
||
} else {
|
||
// Create
|
||
await api.post('/api/v1/assets', formData);
|
||
}
|
||
resetForm();
|
||
fetchAssets();
|
||
} catch (error: any) {
|
||
console.error("Failed to save asset:", error);
|
||
const msg = error.response?.data?.detail || "Failed to save asset.";
|
||
alert(`Error: ${msg}`);
|
||
}
|
||
};
|
||
|
||
const handleAssign = async (assetId: number, value: string) => {
|
||
try {
|
||
const payload: any = {};
|
||
if (value === "") {
|
||
payload.user_id = null;
|
||
payload.group_id = null;
|
||
} else if (value.startsWith("group_")) {
|
||
payload.group_id = parseInt(value.replace("group_", ""));
|
||
payload.user_id = null;
|
||
} else {
|
||
payload.user_id = parseInt(value);
|
||
payload.group_id = null;
|
||
}
|
||
|
||
await api.patch(`/api/v1/assets/${assetId}/assign`, payload);
|
||
// Refetch to get updated groups array
|
||
fetchAssets();
|
||
} catch (error) {
|
||
console.error("Failed to assign user/group", error);
|
||
fetchAssets(); // Revert on failure
|
||
}
|
||
};
|
||
|
||
const handleEdit = (asset: Asset) => {
|
||
setEditingId(asset.id);
|
||
setFormData({
|
||
hostname: asset.hostname,
|
||
ip_address: asset.ip_address,
|
||
operating_system: asset.operating_system,
|
||
os_version: asset.os_version || '',
|
||
description: asset.description || '',
|
||
policy_id: asset.policy_id,
|
||
criticality: (asset as any).criticality || 'normal',
|
||
});
|
||
setIsModalOpen(true);
|
||
};
|
||
|
||
const handleCoverageGap = async (asset: Asset) => {
|
||
setGapLoading(asset.id);
|
||
try {
|
||
const res = await api.get(`/api/v1/assets/${asset.id}/coverage-gap`);
|
||
setGapReport(res.data);
|
||
} catch (e: any) {
|
||
alert(`Coverage-gap failed: ${e?.response?.data?.detail || e?.message || 'unknown'}`);
|
||
} finally {
|
||
setGapLoading(null);
|
||
}
|
||
};
|
||
|
||
const handleRescan = async (id: number) => {
|
||
setRescanLoading(id);
|
||
try {
|
||
const res = await api.post(`/api/v1/assets/${id}/rescan`);
|
||
alert(res.data.message);
|
||
fetchAssets();
|
||
} catch (error: any) {
|
||
console.error("Rescan failed:", error);
|
||
alert(error.response?.data?.detail || "Rescan failed.");
|
||
} finally {
|
||
setRescanLoading(null);
|
||
}
|
||
};
|
||
|
||
const handleNessusRescan = async (asset: Asset) => {
|
||
if (!asset.ip_address) {
|
||
alert('Asset has no IP address — Nessus cannot target it.');
|
||
return;
|
||
}
|
||
if (!confirm(
|
||
`Launch a targeted Nessus scan for ${asset.hostname} (${asset.ip_address})?\n\n` +
|
||
`Uses the scan configured in Settings → Tenable Nessus (default_scan_ids), ` +
|
||
`but targets only this host's IP. After the scan completes in Nessus, ` +
|
||
`use "Sync Data (Nessus)" to import the updated findings.`
|
||
)) return;
|
||
|
||
setNessusRescanLoading(asset.id);
|
||
try {
|
||
const res = await api.post('/api/v1/vulnerabilities/nessus/scan-host', {
|
||
asset_id: asset.id,
|
||
});
|
||
alert(res.data.message || 'Nessus scan launched successfully.');
|
||
} catch (error: any) {
|
||
const detail = error.response?.data?.detail || 'Failed to launch Nessus scan.';
|
||
alert(`Nessus rescan failed: ${detail}`);
|
||
} finally {
|
||
setNessusRescanLoading(null);
|
||
}
|
||
};
|
||
|
||
const handleDeleteAsset = async (id: number) => {
|
||
if (!confirm("Are you sure you want to delete this asset?")) return;
|
||
try {
|
||
await api.delete(`/api/v1/assets/${id}`);
|
||
fetchAssets();
|
||
} catch (error) {
|
||
console.error("Failed to delete asset:", error);
|
||
alert("Failed to delete asset.");
|
||
}
|
||
};
|
||
|
||
const handleBulkUpdate = async (type: 'user' | 'group' | 'policy', value: string | number | null) => {
|
||
if (selectedIds.length === 0) return;
|
||
// Skip if no value selected (placeholder option)
|
||
if (value === "" || value === null) return;
|
||
|
||
try {
|
||
const payload: any = {
|
||
asset_ids: selectedIds
|
||
};
|
||
|
||
// Handle "unassign" special value
|
||
if (value === "unassign") {
|
||
payload.assigned_user_id = -1;
|
||
payload.assigned_group_id = -1;
|
||
} else if (type === 'user') {
|
||
payload.assigned_user_id = Number(value);
|
||
} else if (type === 'group') {
|
||
payload.assigned_group_id = Number(value);
|
||
} else if (type === 'policy') {
|
||
payload.policy_id = Number(value);
|
||
}
|
||
|
||
await api.post('/api/v1/assets/bulk-update', payload);
|
||
setSelectedIds([]);
|
||
fetchAssets();
|
||
} catch (error) {
|
||
console.error("Bulk update failed", error);
|
||
alert("Bulk update failed.");
|
||
}
|
||
};
|
||
|
||
const toggleSelectAll = () => {
|
||
if (selectedIds.length === assets.length) {
|
||
setSelectedIds([]);
|
||
} else {
|
||
setSelectedIds(assets.map(a => a.id));
|
||
}
|
||
};
|
||
|
||
const toggleSelect = (id: number) => {
|
||
setSelectedIds(prev =>
|
||
prev.includes(id) ? prev.filter(i => i !== id) : [...prev, id]
|
||
);
|
||
};
|
||
|
||
if (loading) return <div className="p-8">Loading Assets...</div>;
|
||
|
||
return (
|
||
<div className="max-w-7xl mx-auto">
|
||
<div className="flex md:items-center md:justify-between mb-8">
|
||
<div>
|
||
<h2 className="text-3xl font-bold leading-7 text-gray-900 font-mono">
|
||
Assets Inventory
|
||
</h2>
|
||
</div>
|
||
<div className="mt-4 flex md:ml-4 md:mt-0 items-center gap-3">
|
||
<div className="relative">
|
||
<input
|
||
type="text"
|
||
placeholder="Search assets..."
|
||
value={searchText}
|
||
onChange={(e) => setSearchText(e.target.value)}
|
||
onKeyDown={(e) => e.key === 'Enter' && fetchAssets()}
|
||
className="block w-64 rounded-sm border-0 py-1.5 pl-3 pr-10 text-gray-900 ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-vulncheck-blue sm:text-sm sm:leading-6 font-mono"
|
||
/>
|
||
<button
|
||
onClick={fetchAssets}
|
||
className="absolute inset-y-0 right-0 flex items-center pr-3"
|
||
>
|
||
<svg className="h-4 w-4 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
<label className="flex items-center gap-1.5 text-xs font-mono text-gray-600 cursor-pointer whitespace-nowrap" title="Show soft-inactive + decommissioned assets (no source sync within the threshold)">
|
||
<input
|
||
type="checkbox"
|
||
checked={showInactive}
|
||
onChange={(e) => setShowInactive(e.target.checked)}
|
||
className="h-4 w-4 rounded border-gray-300 text-vulncheck-blue focus:ring-vulncheck-blue"
|
||
/>
|
||
Show inactive
|
||
</label>
|
||
<button
|
||
type="button"
|
||
onClick={async () => {
|
||
if (!confirm('Refresh network-exposure scores from Wazuh syscollector ports for all assets?')) return;
|
||
try {
|
||
const r = await api.post('/api/v1/assets/refresh-exposure');
|
||
alert(`Exposure refreshed: ${r.data?.exposed || 0} of ${r.data?.assets || 0} assets have exposed listeners.`);
|
||
fetchAssets();
|
||
} catch (e: any) {
|
||
alert(`Failed: ${e?.response?.data?.detail || e?.message || 'unknown'}`);
|
||
}
|
||
}}
|
||
className="inline-flex items-center rounded-sm border border-purple-500 text-purple-700 px-3 py-2 text-xs font-mono font-semibold hover:bg-purple-50 whitespace-nowrap"
|
||
title="Pull open listeners from Wazuh and recompute exposure scores"
|
||
>
|
||
⚡ Exposure
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => { resetForm(); setIsModalOpen(true); }}
|
||
className="inline-flex items-center rounded-sm bg-vulncheck-blue px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-600 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 font-mono whitespace-nowrap"
|
||
>
|
||
Add Asset
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Bulk Actions Bar */}
|
||
{selectedIds.length > 0 && (
|
||
<div className="sticky top-0 z-40 bg-vulncheck-blue/90 backdrop-blur-sm text-white px-6 py-3 mb-4 rounded-sm shadow-lg flex items-center justify-between animate-in slide-in-from-top duration-300">
|
||
<div className="flex items-center gap-4">
|
||
<span className="font-mono font-bold">{selectedIds.length} Assets selected</span>
|
||
<div className="h-6 w-px bg-white/20" />
|
||
|
||
<div className="flex items-center gap-2">
|
||
<span className="text-xs font-mono opacity-80 uppercase">Assign to:</span>
|
||
<select
|
||
onChange={(e) => {
|
||
const val = e.target.value;
|
||
if (val === 'unassign') {
|
||
handleBulkUpdate('user', 'unassign');
|
||
} else if (val.startsWith('group_')) {
|
||
handleBulkUpdate('group', val.replace('group_', ''));
|
||
} else {
|
||
handleBulkUpdate('user', val);
|
||
}
|
||
}}
|
||
className="bg-white/10 border border-white/20 rounded-sm text-xs py-1 px-2 focus:bg-white focus:text-gray-900 transition-all cursor-pointer"
|
||
value=""
|
||
>
|
||
<option value="" className="text-gray-900">User / Group</option>
|
||
<optgroup label="Users" className="text-gray-900">
|
||
{users.map(u => <option key={`bulk-u-${u.id}`} value={u.id}>{u.username}</option>)}
|
||
</optgroup>
|
||
<optgroup label="Groups" className="text-gray-900">
|
||
{groups.map(g => <option key={`bulk-g-${g.id}`} value={`group_${g.id}`}>{g.name}</option>)}
|
||
</optgroup>
|
||
<option value="unassign" className="text-gray-900">Unassign</option>
|
||
</select>
|
||
|
||
<select
|
||
onChange={(e) => handleBulkUpdate('policy', e.target.value)}
|
||
className="bg-white/10 border border-white/20 rounded-sm text-xs py-1 px-2 focus:bg-white focus:text-gray-900 transition-all cursor-pointer"
|
||
value=""
|
||
>
|
||
<option value="" className="text-gray-900">Policy</option>
|
||
{policies.map(p => <option key={`bulk-p-${p.id}`} value={p.id}>{p.name}</option>)}
|
||
<option value="" className="text-gray-900">None</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<button
|
||
onClick={() => setSelectedIds([])}
|
||
className="text-xs hover:underline opacity-80"
|
||
>
|
||
Clear Selection
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
{/* Modal */}
|
||
{isModalOpen && (
|
||
<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-md w-full p-6">
|
||
<h3 className="text-lg font-bold mb-4 font-mono">{editingId ? 'Edit Asset' : 'Add New Asset'}</h3>
|
||
<form onSubmit={handleSaveAsset} className="space-y-4">
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 font-mono">Hostname</label>
|
||
<input
|
||
type="text"
|
||
required
|
||
value={formData.hostname}
|
||
onChange={(e) => setFormData({ ...formData, hostname: e.target.value })}
|
||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-vulncheck-blue focus:ring-vulncheck-blue sm:text-sm"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 font-mono">IP Address</label>
|
||
<input
|
||
type="text"
|
||
value={formData.ip_address}
|
||
onChange={(e) => setFormData({ ...formData, ip_address: e.target.value })}
|
||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-vulncheck-blue focus:ring-vulncheck-blue sm:text-sm"
|
||
/>
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 font-mono">Operating System</label>
|
||
<input
|
||
type="text"
|
||
value={formData.operating_system}
|
||
onChange={(e) => setFormData({ ...formData, operating_system: e.target.value })}
|
||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-vulncheck-blue focus:ring-vulncheck-blue sm:text-sm"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 font-mono">Version</label>
|
||
<input
|
||
type="text"
|
||
value={formData.os_version}
|
||
onChange={(e) => setFormData({ ...formData, os_version: e.target.value })}
|
||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-vulncheck-blue focus:ring-vulncheck-blue sm:text-sm"
|
||
placeholder="e.g. 22.04"
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 font-mono">Description</label>
|
||
<input
|
||
type="text"
|
||
value={formData.description}
|
||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-vulncheck-blue focus:ring-vulncheck-blue sm:text-sm"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 font-mono">
|
||
Asset Criticality
|
||
<span className="text-xs text-gray-400 ml-1">(URS multiplier)</span>
|
||
</label>
|
||
<select
|
||
value={formData.criticality}
|
||
onChange={(e) => setFormData({ ...formData, criticality: e.target.value as any })}
|
||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-vulncheck-blue focus:ring-vulncheck-blue sm:text-sm font-mono"
|
||
>
|
||
<option value="low">Low (×0.7)</option>
|
||
<option value="normal">Normal (×1.0)</option>
|
||
<option value="high">High (×1.3)</option>
|
||
<option value="critical">Critical (×1.5)</option>
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 font-mono">Assign Policy</label>
|
||
<select
|
||
value={formData.policy_id || ''}
|
||
onChange={(e) => setFormData({ ...formData, policy_id: e.target.value ? Number(e.target.value) : null })}
|
||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-vulncheck-blue focus:ring-vulncheck-blue sm:text-sm"
|
||
>
|
||
<option value="">-- No Policy --</option>
|
||
{policies.map(p => (
|
||
<option key={p.id} value={p.id}>{p.name}</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
<div className="flex justify-end gap-2 mt-6">
|
||
<button
|
||
type="button"
|
||
onClick={resetForm}
|
||
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50"
|
||
>
|
||
Cancel
|
||
</button>
|
||
<button
|
||
type="submit"
|
||
className="px-4 py-2 text-sm font-medium text-white bg-vulncheck-blue rounded-md hover:bg-blue-600"
|
||
>
|
||
{editingId ? 'Update Asset' : 'Create Asset'}
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<div className="bg-white border border-gray-200 shadow-sm rounded-sm overflow-hidden">
|
||
<div className="overflow-x-auto">
|
||
<table className="min-w-full divide-y divide-gray-200">
|
||
<thead className="bg-gray-50">
|
||
<tr>
|
||
<th scope="col" className="px-6 py-3 text-left">
|
||
<input
|
||
type="checkbox"
|
||
checked={assets.length > 0 && selectedIds.length === assets.length}
|
||
onChange={toggleSelectAll}
|
||
className="h-4 w-4 rounded border-gray-300 text-vulncheck-blue focus:ring-vulncheck-blue"
|
||
/>
|
||
</th>
|
||
<th scope="col" className="px-6 py-3 text-left text-xs font-mono font-medium text-gray-500 uppercase tracking-wider">Hostname</th>
|
||
<th scope="col" className="px-6 py-3 text-left text-xs font-mono font-medium text-gray-500 uppercase tracking-wider">IP Address</th>
|
||
<th scope="col" className="px-6 py-3 text-left text-xs font-mono font-medium text-gray-500 uppercase tracking-wider">OS</th>
|
||
<th scope="col" className="px-6 py-3 text-left text-xs font-mono font-medium text-gray-500 uppercase tracking-wider">Status</th>
|
||
<th scope="col" className="px-6 py-3 text-left text-xs font-mono font-medium text-gray-500 uppercase tracking-wider" title="Network exposure from open listeners (VNC/RDP/Telnet/...)">Exposure</th>
|
||
<th scope="col" className="px-6 py-3 text-left text-xs font-mono font-medium text-gray-500 uppercase tracking-wider">Last Scan</th>
|
||
<th scope="col" className="px-6 py-3 text-left text-xs font-mono font-medium text-gray-500 uppercase tracking-wider">Policy</th>
|
||
<th scope="col" className="px-6 py-3 text-left text-xs font-mono font-medium text-gray-500 uppercase tracking-wider">Assigned To</th>
|
||
<th scope="col" className="px-6 py-3 text-right text-xs font-mono font-medium text-gray-500 uppercase tracking-wider">Actions</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="bg-white divide-y divide-gray-200 font-mono text-sm">
|
||
{assets.map((asset) => (
|
||
<tr key={asset.id} className={selectedIds.includes(asset.id) ? 'bg-blue-50/50' : ''}>
|
||
<td className="px-6 py-4">
|
||
<input
|
||
type="checkbox"
|
||
checked={selectedIds.includes(asset.id)}
|
||
onChange={() => toggleSelect(asset.id)}
|
||
className="h-4 w-4 rounded border-gray-300 text-vulncheck-blue focus:ring-vulncheck-blue"
|
||
/>
|
||
</td>
|
||
<td className="px-6 py-4 whitespace-nowrap font-bold text-vulncheck-blue">
|
||
<Link href={`/vulnerabilities?asset_id=${asset.id}`} className="hover:underline">
|
||
{asset.hostname}
|
||
</Link>
|
||
</td>
|
||
<td className="px-6 py-4 text-gray-500">{asset.ip_address}</td>
|
||
<td className="px-6 py-4 text-gray-500">
|
||
{asset.operating_system}
|
||
{asset.os_version && <span className="text-gray-400 ml-1 text-xs">v{asset.os_version}</span>}
|
||
</td>
|
||
<td className="px-6 py-4 whitespace-nowrap">
|
||
<span
|
||
className={`inline-flex items-center rounded-md px-2 py-1 text-xs font-medium
|
||
${asset.status === 'active' ? 'bg-green-100 text-green-700'
|
||
: asset.status === 'inactive' ? 'bg-amber-100 text-amber-700'
|
||
: 'bg-gray-200 text-gray-600'}`}
|
||
title={asset.status === 'inactive'
|
||
? 'No source sync within the threshold — history kept, hidden from default view'
|
||
: asset.status === 'decommissioned' ? 'Operator-retired' : 'Reported by a source'}
|
||
>
|
||
{asset.status.toUpperCase()}
|
||
</span>
|
||
</td>
|
||
<td className="px-6 py-4 whitespace-nowrap">
|
||
{asset.network_exposure_score != null && asset.network_exposure_score > 0 ? (
|
||
<span
|
||
className={`inline-flex items-center rounded-md px-2 py-0.5 text-xs font-bold font-mono
|
||
${asset.network_exposure_score >= 60 ? 'bg-red-100 text-red-700'
|
||
: asset.network_exposure_score >= 30 ? 'bg-orange-100 text-orange-700'
|
||
: 'bg-yellow-100 text-yellow-800'}`}
|
||
title={(asset.exposed_services || []).map(s => `${s.service} :${s.port}`).join(', ') || 'Exposed listeners'}
|
||
>
|
||
{asset.network_exposure_score.toFixed(0)}
|
||
{asset.exposed_services && asset.exposed_services.length > 0 && (
|
||
<span className="ml-1 font-normal text-[10px] opacity-80">
|
||
{asset.exposed_services.slice(0, 2).map(s => s.service.split(' ')[0]).join('/')}
|
||
{asset.exposed_services.length > 2 ? '…' : ''}
|
||
</span>
|
||
)}
|
||
</span>
|
||
) : (
|
||
<span className="text-gray-300 text-xs">—</span>
|
||
)}
|
||
</td>
|
||
<td className="px-6 py-4 text-gray-500">{asset.last_scan ? new Date(asset.last_scan).toLocaleDateString() : 'Never'}</td>
|
||
<td className="px-6 py-4 whitespace-nowrap">
|
||
{asset.policy_name ? (
|
||
<span className="inline-flex items-center gap-1.5 rounded-full bg-blue-50 px-2 py-1 text-xs font-medium text-blue-700 ring-1 ring-inset ring-blue-700/10">
|
||
<svg className="h-1.5 w-1.5 fill-blue-400" viewBox="0 0 6 6" aria-hidden="true">
|
||
<circle cx={3} cy={3} r={3} />
|
||
</svg>
|
||
{asset.policy_name}
|
||
</span>
|
||
) : (
|
||
<span className="text-gray-400 text-xs italic">Default</span>
|
||
)}
|
||
</td>
|
||
<td className="px-6 py-4 whitespace-nowrap">
|
||
<div className="flex items-center gap-2">
|
||
{asset.groups && asset.groups.length > 0 ? (
|
||
<UserGroupIcon className="h-5 w-5 text-indigo-600" />
|
||
) : (
|
||
<UserCircleIcon className={`h-5 w-5 ${asset.assigned_user_id ? 'text-indigo-600' : 'text-gray-300'}`} />
|
||
)}
|
||
<div className="relative">
|
||
<select
|
||
value={
|
||
asset.groups && asset.groups.length > 0
|
||
? `group_${groups.find(g => g.name === asset.groups![0])?.id || ''}`
|
||
: (asset.assigned_user_id ? String(asset.assigned_user_id) : "")
|
||
}
|
||
onChange={(e) => handleAssign(asset.id, e.target.value)}
|
||
className="block w-full appearance-none rounded-md border-0 bg-transparent py-1.5 pl-2 pr-8 text-xs font-medium text-gray-900 focus:ring-1 focus:ring-inset focus:ring-indigo-600 cursor-pointer hover:bg-gray-50 transition-colors truncate"
|
||
style={{ maxWidth: '140px' }}
|
||
>
|
||
<option value="">Unassigned</option>
|
||
<optgroup label="Users">
|
||
{users.map(u => (
|
||
<option key={`u-${u.id}`} value={u.id}>{u.username}</option>
|
||
))}
|
||
</optgroup>
|
||
<optgroup label="Groups">
|
||
{groups.map(g => (
|
||
<option key={`g-${g.id}`} value={`group_${g.id}`}>{g.name}</option>
|
||
))}
|
||
</optgroup>
|
||
</select>
|
||
<div className="pointer-events-none absolute inset-y-0 right-0 flex items-center px-2 text-gray-400">
|
||
<ChevronDownIcon className="h-3 w-3" aria-hidden="true" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</td>
|
||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||
<div className="flex justify-end gap-x-2">
|
||
{asset.wazuh_agent_id && (
|
||
<button
|
||
onClick={() => handleRescan(asset.id)}
|
||
disabled={rescanLoading === asset.id}
|
||
title="Rescan with Wazuh"
|
||
className={`${rescanLoading === asset.id ? 'text-gray-300' : 'text-indigo-600 hover:text-indigo-900'}`}
|
||
>
|
||
<ArrowPathIcon className={`h-5 w-5 ${rescanLoading === asset.id ? 'animate-spin' : ''}`} />
|
||
</button>
|
||
)}
|
||
{asset.ip_address && (
|
||
<button
|
||
onClick={() => handleNessusRescan(asset)}
|
||
disabled={nessusRescanLoading === asset.id}
|
||
title="Launch targeted Nessus scan for this host"
|
||
className={`${nessusRescanLoading === asset.id ? 'text-gray-300' : 'text-purple-600 hover:text-purple-900'}`}
|
||
>
|
||
{nessusRescanLoading === asset.id
|
||
? <ArrowPathIcon className="h-5 w-5 animate-spin" />
|
||
: <svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor" title="Nessus">
|
||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 3c-4.97 0-9 4.03-9 9s4.03 9 9 9 9-4.03 9-9-4.03-9-9-9zm0 0v18M3 12h18" />
|
||
</svg>
|
||
}
|
||
</button>
|
||
)}
|
||
{asset.wazuh_agent_id && (
|
||
<button
|
||
onClick={() => handleCoverageGap(asset)}
|
||
disabled={gapLoading === asset.id}
|
||
title="Coverage gap — installed packages with no vuln finding"
|
||
className={`${gapLoading === asset.id ? 'text-gray-300' : 'text-amber-600 hover:text-amber-800'}`}
|
||
>
|
||
{gapLoading === asset.id
|
||
? <ArrowPathIcon className="h-5 w-5 animate-spin" />
|
||
: <svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor">
|
||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
|
||
</svg>
|
||
}
|
||
</button>
|
||
)}
|
||
<button
|
||
onClick={() => handleEdit(asset)}
|
||
className="text-vulncheck-blue hover:text-blue-900"
|
||
>
|
||
<PencilSquareIcon className="h-5 w-5" />
|
||
</button>
|
||
<button
|
||
onClick={() => handleDeleteAsset(asset.id)}
|
||
className="text-red-600 hover:text-red-900"
|
||
>
|
||
<TrashIcon className="h-5 w-5" />
|
||
</button>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
{assets.length === 0 && (
|
||
<tr><td colSpan={6} className="text-center py-4">No assets found.</td></tr>
|
||
)}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Coverage-gap modal — installed packages with no finding */}
|
||
{gapReport && (
|
||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" onClick={() => setGapReport(null)}>
|
||
<div className="bg-white rounded-lg shadow-xl max-w-2xl w-full max-h-[80vh] flex flex-col" onClick={(e) => e.stopPropagation()}>
|
||
<div className="p-4 border-b border-gray-200">
|
||
<h3 className="text-lg font-bold font-mono text-gray-900">Coverage Gap — {gapReport.hostname}</h3>
|
||
<p className="text-xs text-gray-500 font-mono mt-1">
|
||
{gapReport.gap_count} of {gapReport.total_packages} installed packages have NO open vuln finding.
|
||
Investigate manually — VulnCheck makes no automatic CVE claim here.
|
||
</p>
|
||
</div>
|
||
<div className="overflow-y-auto p-4">
|
||
{gapReport.gap_count === 0 ? (
|
||
<p className="text-sm text-green-700 font-mono">All installed packages map to a finding — no gap.</p>
|
||
) : (
|
||
<table className="min-w-full text-xs font-mono">
|
||
<thead className="text-gray-500 uppercase">
|
||
<tr><th className="text-left py-1">Package</th><th className="text-left py-1">Version</th><th className="text-left py-1">EOL hint</th></tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-gray-100">
|
||
{gapReport.gap.map((g: any, i: number) => (
|
||
<tr key={i}>
|
||
<td className="py-1 pr-3 text-gray-900">{g.name}</td>
|
||
<td className="py-1 pr-3 text-gray-500">{g.version || '—'}</td>
|
||
<td className="py-1">
|
||
{g.eol_hint ? (
|
||
<span className={`rounded px-1.5 py-0.5 font-bold ${g.eol_hint === 'EOL' ? 'bg-red-100 text-red-700' : g.eol_hint.startsWith('EOL SOON') ? 'bg-orange-100 text-orange-700' : 'bg-yellow-100 text-yellow-800'}`}>{g.eol_hint}</span>
|
||
) : <span className="text-gray-300">—</span>}
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
)}
|
||
</div>
|
||
<div className="p-3 border-t border-gray-200 text-right">
|
||
<button onClick={() => setGapReport(null)} className="rounded-md bg-gray-100 px-4 py-1.5 text-sm font-semibold text-gray-700 hover:bg-gray-200">Close</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|