Files
cve-dashboard/frontend/src/components/pages/ScanPosturePage.js
Jordan Ramos 416bfd2e28 Add Infoblox DNS lookup, Scan Posture page, and Ivanti OS fields
- Infoblox WAPI integration (read-only) for resolving IPs to FQDNs
  - backend/helpers/infobloxApi.js — Basic auth, host/PTR/IPv6 lookups
  - backend/routes/infoblox.js — /api/infoblox endpoints
  - Globe icon on Reporting page hostName/dns columns for one-click DNS lookup
  - Pending: WAPI credentials with API access permissions

- Scan Posture page for Access Ops platform/version tracking
  - backend/routes/scanPosture.js
  - frontend/src/components/pages/ScanPosturePage.js
  - Page visibility and nav drawer entries

- Ivanti findings OS field enrichment
  - Migration to add os_name/os_class/os_version columns
  - Backfill script for existing findings
  - ivantiFindings route updates to persist OS data on sync
2026-08-21 10:33:46 -06:00

470 lines
22 KiB
JavaScript

import React, { useState, useEffect, useCallback } from 'react';
import { Monitor, Shield, AlertTriangle, Clock, ChevronDown, ChevronUp, ChevronRight, RefreshCw, Server, Wifi, X } from 'lucide-react';
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
// ---------------------------------------------------------------------------
// Color helpers
// ---------------------------------------------------------------------------
const SEVERITY_COLORS = {
critical: '#EF4444',
high: '#F59E0B',
medium: '#3B82F6',
low: '#10B981',
};
function vendorColor(vendor) {
const v = (vendor || '').toUpperCase();
if (v.includes('CISCO')) return '#0EA5E9';
if (v.includes('JUNIPER')) return '#10B981';
if (v.includes('ARISTA')) return '#8B5CF6';
if (v.includes('NOKIA')) return '#F97316';
if (v.includes('RED HAT')) return '#EF4444';
if (v.includes('LINUX')) return '#F97316';
if (v.includes('ROCKY')) return '#14B8A6';
if (v.includes('ALMA')) return '#6366F1';
if (v.includes('MICROSOFT')) return '#3B82F6';
if (v.includes('DELL')) return '#A78BFA';
if (v.includes('ADVA')) return '#EC4899';
if (v.includes('VECIMA')) return '#84CC16';
if (v.includes('ADTRAN')) return '#F472B6';
return '#64748B';
}
function scanTypeLabel(type) {
if (type === 'agent') return 'Agent';
if (type === 'network') return 'Network';
return 'Mixed';
}
function formatDate(dateStr) {
if (!dateStr) return 'N/A';
const d = new Date(dateStr);
if (isNaN(d.getTime())) return dateStr;
const now = new Date();
const diffMs = now - d;
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffDays === 0) return 'Today';
if (diffDays === 1) return 'Yesterday';
if (diffDays < 7) return `${diffDays}d ago`;
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
}
function formatModel(model) {
if (!model || model === 'UNSPECIFIED') return null;
return model;
}
// ---------------------------------------------------------------------------
// Main Page Component
// ---------------------------------------------------------------------------
export default function ScanPosturePage() {
const [platforms, setPlatforms] = useState([]);
const [summary, setSummary] = useState(null);
const [expandedTile, setExpandedTile] = useState(null);
const [tileDetails, setTileDetails] = useState(null);
const [loading, setLoading] = useState(true);
const [detailLoading, setDetailLoading] = useState(false);
const [error, setError] = useState(null);
const fetchData = useCallback(async () => {
setLoading(true);
setError(null);
try {
const [platRes, sumRes] = await Promise.all([
fetch(`${API_BASE}/scan-posture/platforms`, { credentials: 'include' }),
fetch(`${API_BASE}/scan-posture/summary`, { credentials: 'include' }),
]);
if (!platRes.ok) throw new Error(`Platforms: ${platRes.status}`);
if (!sumRes.ok) throw new Error(`Summary: ${sumRes.status}`);
const platData = await platRes.json();
const sumData = await sumRes.json();
setPlatforms(platData.platforms || []);
setSummary(sumData);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}, []);
useEffect(() => { fetchData(); }, [fetchData]);
const handleTileClick = async (platform) => {
const key = `${platform.platform_vendor}|${platform.platform_model}`;
if (expandedTile === key) {
setExpandedTile(null);
setTileDetails(null);
return;
}
setExpandedTile(key);
setDetailLoading(true);
setTileDetails(null);
try {
const res = await fetch(
`${API_BASE}/scan-posture/platforms/${encodeURIComponent(platform.platform_vendor)}/${encodeURIComponent(platform.platform_model)}/details`,
{ credentials: 'include' }
);
if (!res.ok) throw new Error(`Details: ${res.status}`);
const data = await res.json();
setTileDetails(data);
} catch (_err) {
setTileDetails({ os_versions: [] });
} finally {
setDetailLoading(false);
}
};
const handleCollapse = () => {
setExpandedTile(null);
setTileDetails(null);
};
if (loading) {
return (
<div style={styles.loadingContainer}>
<RefreshCw style={{ width: 24, height: 24, color: '#0EA5E9', animation: 'spin 1s linear infinite' }} />
<span style={{ color: '#94A3B8', marginLeft: 8 }}>Loading scan posture data...</span>
</div>
);
}
if (error) {
return (
<div style={styles.errorContainer}>
<AlertTriangle style={{ width: 20, height: 20, color: '#F59E0B' }} />
<span style={{ color: '#F59E0B', marginLeft: 8 }}>{error}</span>
<button onClick={fetchData} style={styles.retryBtn}>Retry</button>
</div>
);
}
return (
<div style={styles.page}>
<div style={styles.header}>
<div>
<h1 style={styles.title}>Scan Posture</h1>
<p style={styles.subtitle}>Platform and code version rollup executive view</p>
</div>
<button onClick={fetchData} style={styles.refreshBtn}>
<RefreshCw style={{ width: 14, height: 14 }} />
Refresh
</button>
</div>
{summary && <SummaryBar summary={summary} />}
<TileGrid
platforms={platforms}
expandedTile={expandedTile}
tileDetails={tileDetails}
detailLoading={detailLoading}
onTileClick={handleTileClick}
onCollapse={handleCollapse}
/>
</div>
);
}
// ---------------------------------------------------------------------------
// Summary Bar
// ---------------------------------------------------------------------------
function SummaryBar({ summary }) {
const stats = [
{ label: 'Platforms', value: summary.platform_count || 0, icon: Monitor, color: '#0EA5E9' },
{ label: 'Devices', value: summary.total_devices || 0, icon: Server, color: '#10B981' },
{ label: 'Findings', value: summary.total_findings || 0, icon: Shield, color: '#F59E0B' },
{ label: 'Critical', value: summary.critical_findings || 0, icon: AlertTriangle, color: '#EF4444' },
{ label: 'Last Scan', value: formatDate(summary.last_scan_time), icon: Clock, color: '#8B5CF6' },
];
return (
<div style={styles.summaryBar}>
{stats.map(({ label, value, icon: Icon, color }) => (
<div key={label} style={styles.summaryItem}>
<Icon style={{ width: 14, height: 14, color, flexShrink: 0 }} />
<span style={styles.summaryValue}>{typeof value === 'number' ? value.toLocaleString() : value}</span>
<span style={styles.summaryLabel}>{label}</span>
</div>
))}
</div>
);
}
// ---------------------------------------------------------------------------
// Tile Grid
// ---------------------------------------------------------------------------
function TileGrid({ platforms, expandedTile, tileDetails, detailLoading, onTileClick, onCollapse }) {
if (platforms.length === 0) {
return (
<div style={styles.emptyState}>
<Monitor style={{ width: 40, height: 40, color: '#334155' }} />
<p style={{ color: '#64748B', marginTop: 12 }}>No platform data available yet.</p>
<p style={{ color: '#475569', fontSize: '0.75rem' }}>OS data is populated during Ivanti sync.</p>
</div>
);
}
return (
<div style={styles.tileGrid}>
{platforms.map((p) => {
const key = `${p.platform_vendor}|${p.platform_model}`;
const isExpanded = expandedTile === key;
return (
<div key={key} style={{ gridColumn: isExpanded ? '1 / -1' : undefined }}>
<PlatformTile
platform={p}
isExpanded={isExpanded}
details={isExpanded ? tileDetails : null}
detailLoading={isExpanded && detailLoading}
onClick={() => onTileClick(p)}
onCollapse={onCollapse}
/>
</div>
);
})}
</div>
);
}
// ---------------------------------------------------------------------------
// Platform Tile (expandable)
// ---------------------------------------------------------------------------
function PlatformTile({ platform, isExpanded, details, detailLoading, onClick, onCollapse }) {
const color = vendorColor(platform.platform_vendor);
const [hovered, setHovered] = useState(false);
const model = formatModel(platform.platform_model);
return (
<div
style={{
...styles.tile,
borderColor: isExpanded ? `${color}60` : hovered ? `${color}50` : `${color}25`,
background: isExpanded ? `${color}08` : hovered ? `${color}06` : 'rgba(15, 23, 42, 0.6)',
transform: !isExpanded && hovered ? 'translateY(-1px)' : 'none',
}}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
>
<button onClick={onClick} style={styles.tileClickArea}>
<div style={styles.tileHeader}>
<div style={{ ...styles.tileIcon, background: `${color}18`, borderColor: `${color}40` }}>
<Monitor style={{ width: 16, height: 16, color }} />
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ ...styles.tileName, color }}>{platform.platform_vendor}</div>
{model && <div style={styles.tileModel}>{model}</div>}
</div>
<div style={styles.tileHeaderRight}>
<div style={styles.tileScanBadge}>
{platform.primary_scan_type === 'agent' ? (
<Server style={{ width: 10, height: 10, color: '#10B981' }} />
) : (
<Wifi style={{ width: 10, height: 10, color: '#F59E0B' }} />
)}
<span style={{ fontSize: '0.6rem', color: '#64748B', textTransform: 'uppercase' }}>
{scanTypeLabel(platform.primary_scan_type)}
</span>
</div>
{isExpanded ? (
<ChevronUp style={{ width: 14, height: 14, color: '#64748B' }} />
) : (
<ChevronDown style={{ width: 14, height: 14, color: '#475569' }} />
)}
</div>
</div>
<div style={styles.tileStats}>
<div style={styles.tileStat}>
<span style={styles.tileStatValue}>{Number(platform.device_count).toLocaleString()}</span>
<span style={styles.tileStatLabel}>Devices</span>
</div>
<div style={styles.tileStat}>
<span style={styles.tileStatValue}>{Number(platform.finding_count).toLocaleString()}</span>
<span style={styles.tileStatLabel}>Findings</span>
</div>
{Number(platform.critical_count) > 0 && (
<div style={styles.tileStat}>
<span style={{ ...styles.tileStatValue, color: SEVERITY_COLORS.critical }}>
{Number(platform.critical_count).toLocaleString()}
</span>
<span style={styles.tileStatLabel}>Critical</span>
</div>
)}
{Number(platform.high_count) > 0 && (
<div style={styles.tileStat}>
<span style={{ ...styles.tileStatValue, color: SEVERITY_COLORS.high }}>
{Number(platform.high_count).toLocaleString()}
</span>
<span style={styles.tileStatLabel}>High</span>
</div>
)}
<div style={{ ...styles.tileStat, marginLeft: 'auto' }}>
<span style={{ ...styles.tileStatValue, fontSize: '0.75rem', color: '#94A3B8' }}>
{formatDate(platform.last_scan_time)}
</span>
<span style={styles.tileStatLabel}>Last Scan</span>
</div>
</div>
</button>
{/* Expanded detail section */}
{isExpanded && (
<div style={styles.expandedSection}>
<div style={styles.expandedHeader}>
<span style={{ fontSize: '0.7rem', color: '#94A3B8', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
{platform.platform_vendor} {model || ''} Code Versions & CVEs
</span>
<button onClick={onCollapse} style={styles.collapseBtn}>
<X style={{ width: 12, height: 12 }} />
Collapse
</button>
</div>
{detailLoading ? (
<div style={{ padding: '1.5rem', textAlign: 'center' }}>
<RefreshCw style={{ width: 16, height: 16, color: '#0EA5E9', animation: 'spin 1s linear infinite' }} />
</div>
) : details && details.os_versions ? (
<div style={styles.expandedContent}>
{details.os_versions.map((osVersion) => (
<OSVersionSection key={osVersion.os_name} osVersion={osVersion} color={color} />
))}
{details.os_versions.length === 0 && (
<div style={{ padding: '1rem', textAlign: 'center', color: '#64748B', fontSize: '0.75rem' }}>
No detailed data available for this platform.
</div>
)}
</div>
) : null}
</div>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// OS Version Section (collapsible, shows CVEs and hostnames)
// ---------------------------------------------------------------------------
function OSVersionSection({ osVersion, color }) {
const [expanded, setExpanded] = useState(false);
const hostCount = osVersion.host_count || 0;
const hasCves = osVersion.cves && osVersion.cves.length > 0;
const hasHostnames = osVersion.hostnames && osVersion.hostnames.length > 0;
return (
<div style={styles.osSection}>
<button onClick={() => setExpanded(!expanded)} style={styles.osSectionHeader}>
{expanded ? (
<ChevronDown style={{ width: 12, height: 12, color: '#64748B', flexShrink: 0 }} />
) : (
<ChevronRight style={{ width: 12, height: 12, color: '#64748B', flexShrink: 0 }} />
)}
<span style={{ ...styles.osName, color }}>{osVersion.os_name}</span>
<span style={styles.osMeta}>{hostCount} host{hostCount !== 1 ? 's' : ''}</span>
<span style={styles.osMeta}>{osVersion.finding_count || 0} finding{osVersion.finding_count !== 1 ? 's' : ''}</span>
{osVersion.max_severity && (
<span style={{
...styles.severityBadge,
background: osVersion.max_severity >= 9 ? `${SEVERITY_COLORS.critical}15` : osVersion.max_severity >= 7 ? `${SEVERITY_COLORS.high}15` : `${SEVERITY_COLORS.medium}15`,
color: osVersion.max_severity >= 9 ? SEVERITY_COLORS.critical : osVersion.max_severity >= 7 ? SEVERITY_COLORS.high : SEVERITY_COLORS.medium,
}}>
{Number(osVersion.max_severity).toFixed(1)}
</span>
)}
{hasCves && (
<span style={styles.cveBadge}>{osVersion.cves.length} CVE{osVersion.cves.length !== 1 ? 's' : ''}</span>
)}
</button>
{expanded && (
<div style={styles.osExpandedContent}>
{hasCves && (
<div style={styles.osSubSection}>
<div style={styles.osSubLabel}>CVEs</div>
<div style={styles.osCveList}>
{osVersion.cves.map((cve) => (
<a
key={cve}
href={`https://nvd.nist.gov/vuln/detail/${cve}`}
target="_blank"
rel="noopener noreferrer"
style={{ ...styles.hostCveLink, color }}
>
{cve}
</a>
))}
</div>
</div>
)}
{hasHostnames && (
<div style={styles.osSubSection}>
<div style={styles.osSubLabel}>Hosts</div>
<div style={styles.osHostnameList}>
{osVersion.hostnames.map((name) => (
<span key={name} style={styles.hostnameChip}>{name}</span>
))}
</div>
</div>
)}
</div>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// Styles
// ---------------------------------------------------------------------------
const styles = {
page: { padding: '1.5rem', maxWidth: '1400px', margin: '0 auto' },
header: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.5rem' },
title: { fontSize: '1.25rem', fontWeight: '700', color: '#E2E8F0', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: '0.05em', margin: 0 },
subtitle: { fontSize: '0.75rem', color: '#64748B', margin: '2px 0 0 0' },
refreshBtn: { display: 'flex', alignItems: 'center', gap: '0.375rem', padding: '0.375rem 0.75rem', background: 'rgba(14, 165, 233, 0.08)', border: '1px solid rgba(14, 165, 233, 0.25)', borderRadius: '6px', color: '#0EA5E9', fontSize: '0.7rem', fontFamily: 'monospace', textTransform: 'uppercase', cursor: 'pointer' },
retryBtn: { marginLeft: 12, padding: '4px 12px', background: 'rgba(245, 158, 11, 0.15)', border: '1px solid rgba(245, 158, 11, 0.4)', borderRadius: '4px', color: '#F59E0B', fontSize: '0.7rem', cursor: 'pointer' },
summaryBar: { display: 'flex', gap: '1.5rem', padding: '0.875rem 1.25rem', background: 'rgba(15, 23, 42, 0.5)', border: '1px solid rgba(51, 65, 85, 0.4)', borderRadius: '8px', marginBottom: '1.5rem', flexWrap: 'wrap' },
summaryItem: { display: 'flex', alignItems: 'center', gap: '0.375rem' },
summaryValue: { fontSize: '0.85rem', fontWeight: '600', color: '#E2E8F0', fontFamily: 'monospace' },
summaryLabel: { fontSize: '0.65rem', color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.04em' },
tileGrid: { display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: '0.75rem' },
tile: { borderRadius: '10px', border: '1px solid', transition: 'all 0.15s ease', overflow: 'hidden' },
tileClickArea: { display: 'flex', flexDirection: 'column', padding: '0.875rem 1rem', cursor: 'pointer', textAlign: 'left', width: '100%', background: 'none', border: 'none', color: 'inherit' },
tileHeader: { display: 'flex', alignItems: 'center', gap: '0.625rem', marginBottom: '0.5rem' },
tileIcon: { width: '30px', height: '30px', borderRadius: '6px', border: '1px solid', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 },
tileName: { fontSize: '0.8rem', fontWeight: '700', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: '0.04em', lineHeight: 1.2 },
tileModel: { fontSize: '0.65rem', color: '#94A3B8', fontFamily: 'monospace', marginTop: '1px' },
tileHeaderRight: { display: 'flex', alignItems: 'center', gap: '0.5rem', marginLeft: 'auto' },
tileScanBadge: { display: 'flex', alignItems: 'center', gap: '3px', padding: '2px 6px', background: 'rgba(30, 41, 59, 0.6)', borderRadius: '4px' },
tileStats: { display: 'flex', gap: '1rem', alignItems: 'flex-end' },
tileStat: { display: 'flex', flexDirection: 'column' },
tileStatValue: { fontSize: '0.9rem', fontWeight: '700', color: '#E2E8F0', fontFamily: 'monospace' },
tileStatLabel: { fontSize: '0.55rem', color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.04em' },
// Expanded section
expandedSection: { borderTop: '1px solid rgba(51, 65, 85, 0.3)', background: 'rgba(15, 23, 42, 0.3)' },
expandedHeader: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '0.5rem 1rem', borderBottom: '1px solid rgba(51, 65, 85, 0.2)' },
collapseBtn: { display: 'flex', alignItems: 'center', gap: '4px', padding: '3px 8px', background: 'rgba(100, 116, 139, 0.15)', border: '1px solid rgba(100, 116, 139, 0.3)', borderRadius: '4px', color: '#94A3B8', fontSize: '0.6rem', fontFamily: 'monospace', textTransform: 'uppercase', cursor: 'pointer' },
expandedContent: { padding: '0.5rem 0.75rem', maxHeight: '500px', overflowY: 'auto' },
// OS Version sections
osSection: { marginBottom: '0.25rem', borderRadius: '6px', border: '1px solid rgba(51, 65, 85, 0.2)', overflow: 'hidden' },
osSectionHeader: { display: 'flex', alignItems: 'center', gap: '0.5rem', padding: '0.5rem 0.75rem', width: '100%', background: 'rgba(30, 41, 59, 0.4)', border: 'none', cursor: 'pointer', textAlign: 'left', color: 'inherit' },
osName: { fontSize: '0.75rem', fontWeight: '600', fontFamily: 'monospace', flex: 1 },
osMeta: { fontSize: '0.6rem', color: '#64748B', whiteSpace: 'nowrap' },
// OS version CVE list & badges
severityBadge: { padding: '1px 5px', borderRadius: '3px', fontSize: '0.6rem', fontWeight: '700', fontFamily: 'monospace' },
cveBadge: { fontSize: '0.55rem', color: '#94A3B8', background: 'rgba(51, 65, 85, 0.4)', padding: '1px 5px', borderRadius: '3px' },
osExpandedContent: { padding: '0.5rem 0.75rem 0.5rem 1.75rem' },
osSubSection: { marginBottom: '0.5rem' },
osSubLabel: { fontSize: '0.6rem', color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.04em', marginBottom: '0.25rem', fontFamily: 'monospace' },
osCveList: { display: 'flex', flexWrap: 'wrap', gap: '0.375rem' },
hostCveLink: { fontSize: '0.65rem', fontFamily: 'monospace', fontWeight: '500', textDecoration: 'none', padding: '1px 4px', borderRadius: '3px', background: 'rgba(30, 41, 59, 0.5)' },
osHostnameList: { display: 'flex', flexWrap: 'wrap', gap: '0.375rem' },
hostnameChip: { fontSize: '0.6rem', fontFamily: 'monospace', color: '#CBD5E1', padding: '2px 6px', borderRadius: '3px', background: 'rgba(30, 41, 59, 0.5)', border: '1px solid rgba(51, 65, 85, 0.3)' },
// States
loadingContainer: { display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '4rem 0' },
errorContainer: { display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '4rem 0' },
emptyState: { display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: '4rem 0' },
};