Add sync anomaly detection, BU drift monitoring, and findings count investigation
- 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
This commit is contained in:
227
frontend/src/components/pages/AnomalyBanner.js
Normal file
227
frontend/src/components/pages/AnomalyBanner.js
Normal file
@@ -0,0 +1,227 @@
|
||||
// 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user