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 ( +