- Add BU drift checker that classifies archived findings as BU reassignment, severity drift, closure, or decommission via unfiltered Ivanti API queries - Add post-sync anomaly summary with significance threshold and classification breakdown stored in ivanti_sync_anomaly_log table - Add per-finding BU tracking that detects BU changes across syncs and records them in ivanti_finding_bu_history table - Add drift guard that skips trend history writes when total drops more than 50% - Add CLOSED_GONE archive state for findings that vanish from the closed set - Add anomaly banner UI on Vulnerability Triage page for significant sync changes - Add API endpoints for anomaly latest/history and BU change tracking - Add diagnostic scripts for drift checking and BU reassignment verification - Add investigation document and xlsx export for the April 2026 BU reassignment incident where 109 findings were moved to SDIT-CSD-ITLS-PIES - Migrations required: add_closed_gone_state.js, add_sync_anomaly_tables.js
228 lines
7.9 KiB
JavaScript
228 lines
7.9 KiB
JavaScript
// AnomalyBanner.js
|
|
// Warning banner for the Vulnerability Triage page.
|
|
// Fetches the latest sync anomaly summary and displays a dismissible
|
|
// amber banner when a significant count change is detected.
|
|
|
|
import React, { useState, useEffect } from 'react';
|
|
import { AlertTriangle, X, ChevronDown, ChevronUp } from 'lucide-react';
|
|
|
|
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Style constants (inline style objects — matches IvantiCountsChart pattern)
|
|
// ---------------------------------------------------------------------------
|
|
const BANNER_CONTAINER = {
|
|
background: 'rgba(245, 158, 11, 0.15)',
|
|
border: '1px solid rgba(245, 158, 11, 0.3)',
|
|
borderRadius: '0.5rem',
|
|
padding: '0.75rem 1rem',
|
|
marginBottom: '1.25rem',
|
|
fontFamily: "'JetBrains Mono', monospace",
|
|
};
|
|
|
|
const HEADER_ROW = {
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
gap: '0.5rem',
|
|
};
|
|
|
|
const HEADER_LEFT = {
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: '0.5rem',
|
|
flex: 1,
|
|
minWidth: 0,
|
|
};
|
|
|
|
const ICON_STYLE = {
|
|
width: '16px',
|
|
height: '16px',
|
|
color: '#F59E0B',
|
|
flexShrink: 0,
|
|
};
|
|
|
|
const SUMMARY_TEXT = {
|
|
fontSize: '0.7rem',
|
|
color: '#FCD34D',
|
|
fontWeight: '600',
|
|
lineHeight: '1.4',
|
|
};
|
|
|
|
const TOGGLE_BTN = {
|
|
background: 'none',
|
|
border: 'none',
|
|
cursor: 'pointer',
|
|
padding: '2px',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
color: '#F59E0B',
|
|
opacity: 0.7,
|
|
};
|
|
|
|
const DISMISS_BTN = {
|
|
background: 'none',
|
|
border: 'none',
|
|
cursor: 'pointer',
|
|
padding: '2px',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
color: '#94A3B8',
|
|
opacity: 0.7,
|
|
flexShrink: 0,
|
|
};
|
|
|
|
const DETAIL_SECTION = {
|
|
marginTop: '0.625rem',
|
|
paddingTop: '0.5rem',
|
|
borderTop: '1px solid rgba(245, 158, 11, 0.15)',
|
|
};
|
|
|
|
const DETAIL_ROW = {
|
|
display: 'flex',
|
|
justifyContent: 'space-between',
|
|
alignItems: 'center',
|
|
padding: '0.2rem 0',
|
|
fontSize: '0.65rem',
|
|
color: '#CBD5E1',
|
|
};
|
|
|
|
const DETAIL_COUNT = {
|
|
fontWeight: '700',
|
|
color: '#FCD34D',
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Classification labels for display
|
|
// ---------------------------------------------------------------------------
|
|
const CLASSIFICATION_LABELS = {
|
|
bu_reassignment: 'BU reassignment',
|
|
severity_drift: 'severity drift',
|
|
closed_on_platform: 'closed on platform',
|
|
decommissioned: 'decommissioned',
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Build the summary text from anomaly data
|
|
// ---------------------------------------------------------------------------
|
|
function buildSummaryText(anomaly) {
|
|
const count = anomaly.newly_archived_count || 0;
|
|
const classification = anomaly.classification || {};
|
|
|
|
const parts = [];
|
|
for (const [key, label] of Object.entries(CLASSIFICATION_LABELS)) {
|
|
const val = classification[key];
|
|
if (val && val > 0) {
|
|
parts.push(`${val} ${label}`);
|
|
}
|
|
}
|
|
|
|
const breakdown = parts.length > 0 ? parts.join(', ') : 'unclassified';
|
|
return `${count} findings archived — ${breakdown}`;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Main component
|
|
// ---------------------------------------------------------------------------
|
|
export default function AnomalyBanner() {
|
|
const [anomaly, setAnomaly] = useState(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [dismissed, setDismissed] = useState(false);
|
|
const [expanded, setExpanded] = useState(false);
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
const load = async () => {
|
|
setLoading(true);
|
|
try {
|
|
const res = await fetch(`${API_BASE}/ivanti/findings/anomaly/latest`, {
|
|
credentials: 'include',
|
|
});
|
|
if (res.ok && !cancelled) {
|
|
const data = await res.json();
|
|
setAnomaly(data.anomaly || null);
|
|
}
|
|
} catch { /* silent — banner simply won't show */ }
|
|
finally { if (!cancelled) setLoading(false); }
|
|
};
|
|
load();
|
|
return () => { cancelled = true; };
|
|
}, []);
|
|
|
|
// Render nothing while loading, if dismissed, or if anomaly is not significant
|
|
if (loading || dismissed || !anomaly || !anomaly.is_significant) {
|
|
return null;
|
|
}
|
|
|
|
const classification = anomaly.classification || {};
|
|
|
|
return (
|
|
<div style={BANNER_CONTAINER}>
|
|
{/* ── Header row ─────────────────────────────────────── */}
|
|
<div style={HEADER_ROW}>
|
|
<div style={HEADER_LEFT}>
|
|
<AlertTriangle style={ICON_STYLE} />
|
|
<span style={SUMMARY_TEXT}>
|
|
{buildSummaryText(anomaly)}
|
|
</span>
|
|
<button
|
|
onClick={() => setExpanded(e => !e)}
|
|
style={TOGGLE_BTN}
|
|
title={expanded ? 'Collapse details' : 'Expand details'}
|
|
>
|
|
{expanded
|
|
? <ChevronUp style={{ width: '14px', height: '14px' }} />
|
|
: <ChevronDown style={{ width: '14px', height: '14px' }} />
|
|
}
|
|
</button>
|
|
</div>
|
|
<button
|
|
onClick={() => setDismissed(true)}
|
|
style={DISMISS_BTN}
|
|
title="Dismiss banner"
|
|
>
|
|
<X style={{ width: '14px', height: '14px' }} />
|
|
</button>
|
|
</div>
|
|
|
|
{/* ── Expandable detail section ───────────────────────── */}
|
|
{expanded && (
|
|
<div style={DETAIL_SECTION}>
|
|
{Object.entries(CLASSIFICATION_LABELS).map(([key, label]) => {
|
|
const val = classification[key] || 0;
|
|
if (val === 0) return null;
|
|
return (
|
|
<div key={key} style={DETAIL_ROW}>
|
|
<span>{label}</span>
|
|
<span style={DETAIL_COUNT}>{val}</span>
|
|
</div>
|
|
);
|
|
})}
|
|
{anomaly.open_count_delta != null && (
|
|
<div style={{ ...DETAIL_ROW, marginTop: '0.25rem', borderTop: '1px solid rgba(255,255,255,0.04)', paddingTop: '0.35rem' }}>
|
|
<span>open count delta</span>
|
|
<span style={{ fontWeight: '600', color: anomaly.open_count_delta < 0 ? '#10B981' : anomaly.open_count_delta > 0 ? '#EF4444' : '#475569' }}>
|
|
{anomaly.open_count_delta > 0 ? '+' : ''}{anomaly.open_count_delta}
|
|
</span>
|
|
</div>
|
|
)}
|
|
{anomaly.closed_count_delta != null && (
|
|
<div style={DETAIL_ROW}>
|
|
<span>closed count delta</span>
|
|
<span style={{ fontWeight: '600', color: '#475569' }}>
|
|
{anomaly.closed_count_delta > 0 ? '+' : ''}{anomaly.closed_count_delta}
|
|
</span>
|
|
</div>
|
|
)}
|
|
{anomaly.returned_count > 0 && (
|
|
<div style={DETAIL_ROW}>
|
|
<span>returned findings</span>
|
|
<span style={DETAIL_COUNT}>{anomaly.returned_count}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|