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:
root
2026-04-24 20:34:34 +00:00
parent 5ffedad02f
commit 6ee68f5521
14 changed files with 2817 additions and 8 deletions

View 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>
);
}