Add archive activity sparkline to findings trend chart and update investigation doc
This commit is contained in:
@@ -108,6 +108,19 @@ NONE ──→ ARCHIVED ──→ RETURNED ──→ ARCHIVED (cycle)
|
|||||||
| `CLOSED` | Confirmed present in the Ivanti closed findings set |
|
| `CLOSED` | Confirmed present in the Ivanti closed findings set |
|
||||||
| `CLOSED_GONE` | Was confirmed closed, then disappeared from the closed 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
|
## Recommended Follow-Up
|
||||||
@@ -203,12 +216,7 @@ Given the scale (124 findings disappearing simultaneously), a bulk operation on
|
|||||||
|
|
||||||
### Diagnostic script
|
### 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:
|
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.
|
||||||
|
|
||||||
```bash
|
|
||||||
cd backend
|
|
||||||
node scripts/drift-check.js
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -409,8 +417,4 @@ These findings are not found in Ivanti at any BU, severity, or state. All are Op
|
|||||||
|
|
||||||
### Diagnostic scripts
|
### Diagnostic scripts
|
||||||
|
|
||||||
```bash
|
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.
|
||||||
cd backend
|
|
||||||
node scripts/drift-check.js
|
|
||||||
node scripts/bu-reassignment-check.js
|
|
||||||
```
|
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
// IvantiCountsChart.js
|
// IvantiCountsChart.js
|
||||||
// Collapsible trend panel for the Vulnerability Triage page.
|
// 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 React, { useState, useEffect, useMemo } from 'react';
|
||||||
import {
|
import {
|
||||||
LineChart, Line,
|
LineChart, Line,
|
||||||
|
BarChart, Bar, Cell,
|
||||||
XAxis, YAxis, CartesianGrid,
|
XAxis, YAxis, CartesianGrid,
|
||||||
Tooltip, Legend, ReferenceLine,
|
Tooltip, Legend,
|
||||||
ResponsiveContainer,
|
ResponsiveContainer,
|
||||||
} from 'recharts';
|
} from 'recharts';
|
||||||
import { TrendingUp, ChevronDown, ChevronUp, Loader } from 'lucide-react';
|
import { TrendingUp, ChevronDown, ChevronUp, Loader } from 'lucide-react';
|
||||||
@@ -17,13 +19,15 @@ const AMBER = '#F59E0B';
|
|||||||
const SKY = '#0EA5E9';
|
const SKY = '#0EA5E9';
|
||||||
const GREEN = '#10B981';
|
const GREEN = '#10B981';
|
||||||
const RED = '#EF4444';
|
const RED = '#EF4444';
|
||||||
|
const ROSE = '#F43F5E';
|
||||||
|
const TEAL = '#14B8A6';
|
||||||
|
|
||||||
const AXIS_STYLE = { fontFamily: 'monospace', fontSize: '0.6rem', fill: '#475569' };
|
const AXIS_STYLE = { fontFamily: 'monospace', fontSize: '0.6rem', fill: '#475569' };
|
||||||
const GRID_STYLE = { stroke: 'rgba(255,255,255,0.05)', strokeDasharray: '3 3' };
|
const GRID_STYLE = { stroke: 'rgba(255,255,255,0.05)', strokeDasharray: '3 3' };
|
||||||
const LEGEND_STYLE = { fontFamily: 'monospace', fontSize: '0.62rem', color: '#64748B' };
|
const LEGEND_STYLE = { fontFamily: 'monospace', fontSize: '0.62rem', color: '#64748B' };
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Custom dark tooltip
|
// Custom dark tooltip — main trend chart
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
function DarkTooltip({ active, payload, label }) {
|
function DarkTooltip({ active, payload, label }) {
|
||||||
if (!active || !payload?.length) return null;
|
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 (
|
||||||
|
<div style={{
|
||||||
|
background: 'rgba(10,17,32,0.97)',
|
||||||
|
border: '1px solid rgba(244,63,94,0.3)',
|
||||||
|
borderRadius: '0.375rem',
|
||||||
|
padding: '0.5rem 0.75rem',
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontSize: '0.7rem',
|
||||||
|
minWidth: '180px',
|
||||||
|
}}>
|
||||||
|
<div style={{ color: ROSE, marginBottom: '0.35rem', fontWeight: '700', fontSize: '0.65rem' }}>
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
{archived > 0 && (
|
||||||
|
<div style={{ color: ROSE, marginTop: '0.125rem', display: 'flex', justifyContent: 'space-between', gap: '1rem' }}>
|
||||||
|
<span style={{ opacity: 0.8 }}>Archived</span>
|
||||||
|
<span style={{ fontWeight: '700' }}>{archived}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{returned > 0 && (
|
||||||
|
<div style={{ color: TEAL, marginTop: '0.125rem', display: 'flex', justifyContent: 'space-between', gap: '1rem' }}>
|
||||||
|
<span style={{ opacity: 0.8 }}>Returned</span>
|
||||||
|
<span style={{ fontWeight: '700' }}>{returned}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{archived === 0 && returned === 0 && (
|
||||||
|
<div style={{ color: '#475569', marginTop: '0.125rem' }}>No archive activity</div>
|
||||||
|
)}
|
||||||
|
{classification && archived > 0 && (
|
||||||
|
<div style={{ borderTop: '1px solid rgba(255,255,255,0.06)', marginTop: '0.35rem', paddingTop: '0.3rem' }}>
|
||||||
|
{classification.bu_reassignment > 0 && (
|
||||||
|
<div style={{ color: '#FB923C', fontSize: '0.62rem', display: 'flex', justifyContent: 'space-between', gap: '1rem' }}>
|
||||||
|
<span style={{ opacity: 0.8 }}>BU reassignment</span>
|
||||||
|
<span>{classification.bu_reassignment}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{classification.severity_drift > 0 && (
|
||||||
|
<div style={{ color: '#A78BFA', fontSize: '0.62rem', display: 'flex', justifyContent: 'space-between', gap: '1rem' }}>
|
||||||
|
<span style={{ opacity: 0.8 }}>Severity drift</span>
|
||||||
|
<span>{classification.severity_drift}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{classification.closed_on_platform > 0 && (
|
||||||
|
<div style={{ color: SKY, fontSize: '0.62rem', display: 'flex', justifyContent: 'space-between', gap: '1rem' }}>
|
||||||
|
<span style={{ opacity: 0.8 }}>Closed on platform</span>
|
||||||
|
<span>{classification.closed_on_platform}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{classification.decommissioned > 0 && (
|
||||||
|
<div style={{ color: '#94A3B8', fontSize: '0.62rem', display: 'flex', justifyContent: 'space-between', gap: '1rem' }}>
|
||||||
|
<span style={{ opacity: 0.8 }}>Decommissioned</span>
|
||||||
|
<span>{classification.decommissioned}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Shorten YYYY-MM-DD to MM/DD/YY
|
// Shorten YYYY-MM-DD to MM/DD/YY
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -70,6 +147,12 @@ function fmtDate(d) {
|
|||||||
return 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
|
// Main component
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -77,17 +160,27 @@ export default function IvantiCountsChart() {
|
|||||||
const [collapsed, setCollapsed] = useState(false);
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [history, setHistory] = useState([]);
|
const [history, setHistory] = useState([]);
|
||||||
|
const [anomalies, setAnomalies] = useState([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API_BASE}/ivanti/findings/counts/history`, { credentials: 'include' });
|
const [countsRes, anomalyRes] = await Promise.all([
|
||||||
if (res.ok && !cancelled) {
|
fetch(`${API_BASE}/ivanti/findings/counts/history`, { credentials: 'include' }),
|
||||||
const d = await res.json();
|
fetch(`${API_BASE}/ivanti/findings/anomaly/history`, { credentials: 'include' }),
|
||||||
|
]);
|
||||||
|
if (!cancelled) {
|
||||||
|
if (countsRes.ok) {
|
||||||
|
const d = await countsRes.json();
|
||||||
setHistory(d.history || []);
|
setHistory(d.history || []);
|
||||||
}
|
}
|
||||||
|
if (anomalyRes.ok) {
|
||||||
|
const d = await anomalyRes.json();
|
||||||
|
setAnomalies(d.history || []);
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch { /* silent — chart shows no-data state */ }
|
} catch { /* silent — chart shows no-data state */ }
|
||||||
finally { if (!cancelled) setLoading(false); }
|
finally { if (!cancelled) setLoading(false); }
|
||||||
};
|
};
|
||||||
@@ -100,6 +193,45 @@ export default function IvantiCountsChart() {
|
|||||||
[history]
|
[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
|
// Compute a simple delta label for the latest vs previous point
|
||||||
const deltaLabel = useMemo(() => {
|
const deltaLabel = useMemo(() => {
|
||||||
if (chartData.length < 2) return null;
|
if (chartData.length < 2) return null;
|
||||||
@@ -178,6 +310,8 @@ export default function IvantiCountsChart() {
|
|||||||
: 'Need at least 2 days of syncs to display a trend'}
|
: 'Need at least 2 days of syncs to display a trend'}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
<>
|
||||||
|
{/* ── Main trend chart ──────────────────────── */}
|
||||||
<ResponsiveContainer width="100%" height={220}>
|
<ResponsiveContainer width="100%" height={220}>
|
||||||
<LineChart data={chartData} margin={{ top: 4, right: 12, bottom: 0, left: -12 }}>
|
<LineChart data={chartData} margin={{ top: 4, right: 12, bottom: 0, left: -12 }}>
|
||||||
<CartesianGrid {...GRID_STYLE} />
|
<CartesianGrid {...GRID_STYLE} />
|
||||||
@@ -199,6 +333,37 @@ export default function IvantiCountsChart() {
|
|||||||
/>
|
/>
|
||||||
</LineChart>
|
</LineChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
|
|
||||||
|
{/* ── Archive activity sparkline ────────────── */}
|
||||||
|
{hasArchiveActivity && (
|
||||||
|
<div style={{ marginTop: '0.5rem' }}>
|
||||||
|
<div style={{
|
||||||
|
fontFamily: 'monospace', fontSize: '0.58rem', color: '#334155',
|
||||||
|
textTransform: 'uppercase', letterSpacing: '0.08em',
|
||||||
|
marginBottom: '0.25rem',
|
||||||
|
}}>
|
||||||
|
Archive Activity
|
||||||
|
</div>
|
||||||
|
<ResponsiveContainer width="100%" height={64}>
|
||||||
|
<BarChart data={archiveData} margin={{ top: 2, right: 12, bottom: 0, left: -12 }}>
|
||||||
|
<CartesianGrid {...GRID_STYLE} />
|
||||||
|
<XAxis dataKey="date" tick={false} axisLine={false} />
|
||||||
|
<YAxis tick={AXIS_STYLE} allowDecimals={false} width={30} />
|
||||||
|
<Tooltip content={<ArchiveTooltip />} />
|
||||||
|
<Bar dataKey="archived" name="Archived" stackId="a" maxBarSize={12}>
|
||||||
|
{archiveData.map((entry, idx) => (
|
||||||
|
<Cell
|
||||||
|
key={`arch-${idx}`}
|
||||||
|
fill={entry.is_significant ? ROSE : 'rgba(244,63,94,0.5)'}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Bar>
|
||||||
|
<Bar dataKey="returned" name="Returned" stackId="a" fill={TEAL} maxBarSize={12} />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user