From 00a6f7ae0ffaf3e7e6f8a0ac8e4726eafdbcddf0 Mon Sep 17 00:00:00 2001 From: root Date: Fri, 24 Apr 2026 21:06:35 +0000 Subject: [PATCH] Add archive activity sparkline to findings trend chart and update investigation doc --- ...findings-count-investigation-2026-04-24.md | 26 ++- .../src/components/pages/IvantiCountsChart.js | 221 +++++++++++++++--- 2 files changed, 208 insertions(+), 39 deletions(-) diff --git a/docs/findings-count-investigation-2026-04-24.md b/docs/findings-count-investigation-2026-04-24.md index 7d973db..16fd5a3 100644 --- a/docs/findings-count-investigation-2026-04-24.md +++ b/docs/findings-count-investigation-2026-04-24.md @@ -108,6 +108,19 @@ NONE ──→ ARCHIVED ──→ RETURNED ──→ ARCHIVED (cycle) | `CLOSED` | Confirmed present in the Ivanti closed findings set | | `CLOSED_GONE` | Was confirmed closed, then disappeared from the closed set | +### 4. Automated sync anomaly detection + +The manual diagnostic work from this investigation was formalized into an automated feature in the sync pipeline (`backend/routes/ivantiFindings.js`). After each sync, the system now: + +- **Classifies disappearances** — queries Ivanti without BU/severity filters for newly archived finding IDs and labels each as `bu_reassignment`, `severity_drift`, `closed_on_platform`, or `decommissioned`. The classification is stored on the archive transition record, replacing the generic `severity_score_drift` default. +- **Logs anomaly summaries** — writes a breakdown of count changes to `ivanti_sync_anomaly_log` after each sync, flagging syncs where more than 5 findings are archived as significant. +- **Tracks BU changes per finding** — compares each finding's BU against the previous sync and records changes in `ivanti_finding_bu_history`. +- **Surfaces anomalies in the UI** — an amber warning banner on the Vulnerability Triage page displays the latest anomaly summary when a significant count change is detected. + +API endpoints for anomaly data: `GET /api/ivanti/findings/anomaly/latest`, `GET /api/ivanti/findings/anomaly/history`, `GET /api/ivanti/findings/bu-changes`, `GET /api/ivanti/findings/:findingId/bu-history`. + +**Migration required:** `node backend/migrations/add_sync_anomaly_tables.js` + --- ## Recommended Follow-Up @@ -203,12 +216,7 @@ Given the scale (124 findings disappearing simultaneously), a bulk operation on ### Diagnostic script -The unfiltered query was performed using `backend/scripts/drift-check.js`. This script queries Ivanti without the severity filter and cross-references results against the archive table. It can be re-run at any time to check the current state: - -```bash -cd backend -node scripts/drift-check.js -``` +The unfiltered query was originally performed using `backend/scripts/drift-check.js`. This logic has since been automated by the sync anomaly detection feature — the BU drift checker in `backend/routes/ivantiFindings.js` now runs these checks automatically after each sync. See the anomaly API endpoints (`/api/ivanti/findings/anomaly/latest`, `/api/ivanti/findings/bu-changes`) for current data. --- @@ -409,8 +417,4 @@ These findings are not found in Ivanti at any BU, severity, or state. All are Op ### Diagnostic scripts -```bash -cd backend -node scripts/drift-check.js -node scripts/bu-reassignment-check.js -``` +The `drift-check.js` and `bu-reassignment-check.js` scripts used during this investigation have been removed from the repository. Their logic is now automated by the sync anomaly detection feature in `backend/routes/ivantiFindings.js`, which classifies disappearances as BU reassignment, severity drift, closure, or decommission after each sync. diff --git a/frontend/src/components/pages/IvantiCountsChart.js b/frontend/src/components/pages/IvantiCountsChart.js index 7bdbb6a..6adae36 100644 --- a/frontend/src/components/pages/IvantiCountsChart.js +++ b/frontend/src/components/pages/IvantiCountsChart.js @@ -1,12 +1,14 @@ // IvantiCountsChart.js // Collapsible trend panel for the Vulnerability Triage page. -// Shows open vs closed Ivanti finding counts over time (last sync per day). +// Shows open vs closed Ivanti finding counts over time (last sync per day), +// with a separate sparkline row for archived/returned finding activity. import React, { useState, useEffect, useMemo } from 'react'; import { LineChart, Line, + BarChart, Bar, Cell, XAxis, YAxis, CartesianGrid, - Tooltip, Legend, ReferenceLine, + Tooltip, Legend, ResponsiveContainer, } from 'recharts'; import { TrendingUp, ChevronDown, ChevronUp, Loader } from 'lucide-react'; @@ -17,13 +19,15 @@ const AMBER = '#F59E0B'; const SKY = '#0EA5E9'; const GREEN = '#10B981'; const RED = '#EF4444'; +const ROSE = '#F43F5E'; +const TEAL = '#14B8A6'; const AXIS_STYLE = { fontFamily: 'monospace', fontSize: '0.6rem', fill: '#475569' }; const GRID_STYLE = { stroke: 'rgba(255,255,255,0.05)', strokeDasharray: '3 3' }; const LEGEND_STYLE = { fontFamily: 'monospace', fontSize: '0.62rem', color: '#64748B' }; // --------------------------------------------------------------------------- -// Custom dark tooltip +// Custom dark tooltip — main trend chart // --------------------------------------------------------------------------- function DarkTooltip({ active, payload, label }) { if (!active || !payload?.length) return null; @@ -60,6 +64,79 @@ function DarkTooltip({ active, payload, label }) { ); } +// --------------------------------------------------------------------------- +// Custom dark tooltip — archive activity sparkline +// --------------------------------------------------------------------------- +function ArchiveTooltip({ active, payload, label }) { + if (!active || !payload?.length) return null; + + const archived = payload.find(p => p.dataKey === 'archived')?.value || 0; + const returned = payload.find(p => p.dataKey === 'returned')?.value || 0; + + // Parse classification if present + const dataPoint = payload[0]?.payload; + const classification = dataPoint?.classification; + + return ( +
+
+ {label} +
+ {archived > 0 && ( +
+ Archived + {archived} +
+ )} + {returned > 0 && ( +
+ Returned + {returned} +
+ )} + {archived === 0 && returned === 0 && ( +
No archive activity
+ )} + {classification && archived > 0 && ( +
+ {classification.bu_reassignment > 0 && ( +
+ BU reassignment + {classification.bu_reassignment} +
+ )} + {classification.severity_drift > 0 && ( +
+ Severity drift + {classification.severity_drift} +
+ )} + {classification.closed_on_platform > 0 && ( +
+ Closed on platform + {classification.closed_on_platform} +
+ )} + {classification.decommissioned > 0 && ( +
+ Decommissioned + {classification.decommissioned} +
+ )} +
+ )} +
+ ); +} + // --------------------------------------------------------------------------- // Shorten YYYY-MM-DD to MM/DD/YY // --------------------------------------------------------------------------- @@ -70,6 +147,12 @@ function fmtDate(d) { return d; } +// Extract YYYY-MM-DD from a datetime string +function extractDate(ts) { + if (!ts) return ''; + return ts.split('T')[0].split(' ')[0]; +} + // --------------------------------------------------------------------------- // Main component // --------------------------------------------------------------------------- @@ -77,16 +160,26 @@ export default function IvantiCountsChart() { const [collapsed, setCollapsed] = useState(false); const [loading, setLoading] = useState(true); const [history, setHistory] = useState([]); + const [anomalies, setAnomalies] = useState([]); useEffect(() => { let cancelled = false; const load = async () => { setLoading(true); try { - const res = await fetch(`${API_BASE}/ivanti/findings/counts/history`, { credentials: 'include' }); - if (res.ok && !cancelled) { - const d = await res.json(); - setHistory(d.history || []); + const [countsRes, anomalyRes] = await Promise.all([ + fetch(`${API_BASE}/ivanti/findings/counts/history`, { credentials: 'include' }), + fetch(`${API_BASE}/ivanti/findings/anomaly/history`, { credentials: 'include' }), + ]); + if (!cancelled) { + if (countsRes.ok) { + const d = await countsRes.json(); + setHistory(d.history || []); + } + if (anomalyRes.ok) { + const d = await anomalyRes.json(); + setAnomalies(d.history || []); + } } } catch { /* silent — chart shows no-data state */ } finally { if (!cancelled) setLoading(false); } @@ -100,6 +193,45 @@ export default function IvantiCountsChart() { [history] ); + // Build archive activity data aligned to the same date axis as the main chart. + // Aggregate anomaly rows by date (take the last sync per day, matching the + // counts history pattern), then merge onto the chartData date set. + const archiveData = useMemo(() => { + if (!anomalies.length || !chartData.length) return []; + + // Group anomalies by date, keep the latest per day + const byDate = {}; + for (const a of anomalies) { + const rawDate = extractDate(a.sync_timestamp); + const dateKey = fmtDate(rawDate); + // anomaly/history returns newest first, so first seen per date is the latest + if (!byDate[dateKey]) { + byDate[dateKey] = a; + } + } + + // Map onto the chart date axis so both charts share the same X positions + return chartData.map(point => { + const anomaly = byDate[point.date]; + if (anomaly) { + return { + date: point.date, + archived: anomaly.newly_archived_count || 0, + returned: anomaly.returned_count || 0, + classification: anomaly.classification || {}, + is_significant: anomaly.is_significant, + }; + } + return { date: point.date, archived: 0, returned: 0, classification: {}, is_significant: false }; + }); + }, [anomalies, chartData]); + + // Check if there's any archive activity worth showing + const hasArchiveActivity = useMemo( + () => archiveData.some(d => d.archived > 0 || d.returned > 0), + [archiveData] + ); + // Compute a simple delta label for the latest vs previous point const deltaLabel = useMemo(() => { if (chartData.length < 2) return null; @@ -178,27 +310,60 @@ export default function IvantiCountsChart() { : 'Need at least 2 days of syncs to display a trend'} ) : ( - - - - - - } /> - - - - - + <> + {/* ── Main trend chart ──────────────────────── */} + + + + + + } /> + + + + + + + {/* ── Archive activity sparkline ────────────── */} + {hasArchiveActivity && ( +
+
+ Archive Activity +
+ + + + + + } /> + + {archiveData.map((entry, idx) => ( + + ))} + + + + +
+ )} + )} )}