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 @@
{"specId": "a3e7c1d2-8f4b-4e9a-b6d1-2c5f8a9e3b7d", "workflowType": "requirements-first", "specType": "feature"}

View File

@@ -0,0 +1,454 @@
# Design Document: Sync Anomaly Detection and BU Drift Monitoring
## Overview
This feature extends the Ivanti sync pipeline to automatically classify why findings disappear from filtered sync results. The current archive system detects disappearances but labels them all as `severity_score_drift` — a default that proved incorrect during the April 2026 incident where 109 findings silently disappeared due to a bulk BU reassignment.
The design adds three capabilities to the existing `ivantiFindings.js` sync pipeline:
1. **BU Drift Checker** — a post-sync step that queries the Ivanti API without BU/severity filters for newly archived finding IDs, classifying each disappearance as `bu_reassignment`, `severity_drift`, `closed_on_platform`, or `decommissioned`.
2. **Sync Anomaly Summary** — a structured report computed after each sync that breaks down count changes by cause and stores the result in a new `ivanti_sync_anomaly_log` table.
3. **Finding-Level BU Tracking** — per-finding BU comparison during `syncFindings()` that detects BU changes across syncs and records them in a new `ivanti_finding_bu_history` table.
The approach formalizes the ad-hoc diagnostic patterns from `drift-check.js` and `bu-reassignment-check.js` into the automated sync pipeline, with results surfaced through new API endpoints and an anomaly banner on the Vulnerability Triage page.
---
## Architecture
The feature integrates into the existing sync pipeline as post-sync steps, keeping the core sync logic unchanged. No new route modules are created — all new endpoints and logic live within the existing `ivantiFindings.js` module and its factory-pattern router.
```mermaid
flowchart TD
A[syncFindings - fetch all pages] --> B[Compare previous vs current findings]
B --> C[detectArchiveChanges - existing]
B --> D[BU comparison - new]
D --> E[Insert BU changes into ivanti_finding_bu_history]
C --> F[syncClosedCount - existing]
F --> G[detectClosedFindings - existing]
G --> H[detectClosedGoneFindings - existing]
H --> I[runBUDriftChecker - new]
I --> J[Batch unfiltered queries for newly archived IDs]
J --> K[Classify each: bu_reassignment / severity_drift / closed_on_platform / decommissioned]
K --> L[Update archive transition reasons]
L --> M[computeAnomalySummary - new]
M --> N[Insert row into ivanti_sync_anomaly_log]
style I fill:#F59E0B,color:#000
style D fill:#F59E0B,color:#000
style M fill:#F59E0B,color:#000
```
**Key design decisions:**
- **Post-sync, not inline**: The BU drift checker runs after all existing sync steps complete. This means a sync failure does not block drift checking of previously archived findings, and drift checking failures do not block the sync.
- **Same module, no new route file**: The anomaly and BU history endpoints are added to the existing `createIvantiFindingsRouter`. This keeps the Ivanti findings API surface in one place and avoids a new factory-pattern module for four endpoints.
- **Batched unfiltered queries**: Finding IDs are chunked into groups of 50 for the unfiltered Ivanti API call, matching the pattern proven in `bu-reassignment-check.js`. This stays within API limits while keeping the number of HTTP calls manageable.
- **BU comparison in syncFindings**: The per-finding BU comparison happens during the existing previous-vs-current comparison in `syncFindings()`, before the cache is overwritten. This is the only point where both the old and new BU values are available in memory.
---
## Components and Interfaces
### 1. BU Drift Checker (`runBUDriftChecker`)
A new async function added to `ivantiFindings.js` that runs after `detectClosedGoneFindings()` in the sync pipeline.
**Signature:**
```javascript
async function runBUDriftChecker(db, newlyArchivedIds, apiKey, clientId, skipTls)
```
**Parameters:**
- `db` — SQLite database instance
- `newlyArchivedIds` — array of finding ID strings that were newly archived in this sync cycle (from `detectArchiveChanges`)
- `apiKey`, `clientId`, `skipTls` — Ivanti API credentials (same as existing sync functions)
**Behavior:**
1. If `newlyArchivedIds` is empty, return immediately (no API calls).
2. Chunk the IDs into batches of 50.
3. For each batch, call `ivantiPost()` with a filter on `id` field only (no BU, severity, or state filters) — the same unfiltered query pattern used in `bu-reassignment-check.js`.
4. For each finding ID, classify the result:
- **Found, BU differs from expected** → `bu_reassignment`
- **Found, BU matches, severity < 8.5** → `severity_drift`
- **Found, BU matches, state is Closed** → `closed_on_platform`
- **Not found** → `decommissioned`
5. Update the corresponding `ivanti_archive_transitions` row's `reason` field with the classification.
6. Return a classification summary object: `{ bu_reassignment: N, severity_drift: N, closed_on_platform: N, decommissioned: N }`.
**Expected BUs** are the same values used in `FINDINGS_FILTERS`: `NTS-AEO-ACCESS-ENG` and `NTS-AEO-STEAM`.
**Error handling:** If an individual batch API call fails, log the error and skip that batch. The findings in the failed batch retain their default `severity_score_drift` reason. The function never throws — it returns whatever partial results it collected.
### 2. Anomaly Summary Computation (`computeAnomalySummary`)
A new async function that runs after the BU drift checker completes.
**Signature:**
```javascript
async function computeAnomalySummary(db, openCountDelta, closedCountDelta, newlyArchivedCount, returnedCount, classificationBreakdown)
```
**Parameters:**
- `db` — SQLite database instance
- `openCountDelta` — integer, current open count minus previous open count
- `closedCountDelta` — integer, current closed count minus previous closed count
- `newlyArchivedCount` — integer, number of findings archived in this sync
- `returnedCount` — integer, number of findings that returned in this sync
- `classificationBreakdown` — object from `runBUDriftChecker`, e.g. `{ bu_reassignment: 38, severity_drift: 5, ... }`
**Behavior:**
1. Determine `is_significant`: true if `newlyArchivedCount > 5`.
2. Insert a row into `ivanti_sync_anomaly_log` with all fields.
3. Log the summary to console.
### 3. Finding-Level BU Comparison
Integrated into `syncFindings()` between reading previous findings and writing the new cache. Uses the existing `previousFindings` and `allFindings` arrays.
**Logic:**
```
for each finding in allFindings:
previousFinding = previousMap.get(finding.id)
if previousFinding exists AND previousFinding.buOwnership !== finding.buOwnership
AND both values are non-empty:
INSERT into ivanti_finding_bu_history
```
The `buOwnership` field is already extracted by `extractFinding()` from `assetCustomAttributes['1550_host_1']`. No changes to `extractFinding()` are needed — it already stores `buOwnership` on each finding object.
### 4. New API Endpoints
All endpoints are added to the existing `createIvantiFindingsRouter` and require authentication via `requireAuth(db)`.
| Method | Path | Description |
|---|---|---|
| GET | `/api/ivanti/findings/anomaly/latest` | Returns the most recent anomaly summary row |
| GET | `/api/ivanti/findings/anomaly/history` | Returns anomaly history (last 30 or date-filtered) |
| GET | `/api/ivanti/findings/bu-changes` | Returns all BU change events, newest first |
| GET | `/api/ivanti/findings/:findingId/bu-history` | Returns BU change history for a specific finding |
**GET /anomaly/latest response:**
```json
{
"anomaly": {
"id": 1,
"sync_timestamp": "2026-04-24T12:00:00",
"open_count_delta": -45,
"closed_count_delta": -94,
"newly_archived_count": 45,
"returned_count": 0,
"classification": {
"bu_reassignment": 38,
"severity_drift": 1,
"closed_on_platform": 4,
"decommissioned": 2
},
"is_significant": true
}
}
```
Returns `{ anomaly: null }` if no anomaly records exist.
**GET /anomaly/history query parameters:**
- `from` (optional) — ISO date string, inclusive start
- `to` (optional) — ISO date string, inclusive end
- If neither provided, returns last 30 rows
**GET /bu-changes response:**
```json
{
"changes": [
{
"id": 1,
"finding_id": "2687687777",
"finding_title": "OpenSSH regreSSHion",
"host_name": "syn-098-120-000-078",
"previous_bu": "NTS-AEO-STEAM",
"new_bu": "SDIT-CSD-ITLS-PIES",
"detected_at": "2026-04-24T12:00:00"
}
]
}
```
**GET /:findingId/bu-history response:**
```json
{
"finding_id": "2687687777",
"history": [
{
"previous_bu": "NTS-AEO-STEAM",
"new_bu": "SDIT-CSD-ITLS-PIES",
"detected_at": "2026-04-24T12:00:00"
}
]
}
```
### 5. Anomaly Banner Component (`AnomalyBanner.js`)
A new React component placed in `frontend/src/components/pages/AnomalyBanner.js`, rendered on the Vulnerability Triage page above the `IvantiCountsChart`.
**Props:** None — fetches its own data from `/api/ivanti/findings/anomaly/latest`.
**Behavior:**
1. On mount, fetch the latest anomaly summary.
2. If `is_significant` is false or no anomaly exists, render nothing.
3. If `is_significant` is true, render a warning banner with:
- Amber background tint (`rgba(245, 158, 11, 0.15)`) with amber border (`rgba(245, 158, 11, 0.3)`)
- `AlertTriangle` icon from lucide-react
- Summary text: "45 findings archived — 38 BU reassignment, 5 severity drift, 2 decommissioned"
- Expandable detail section (click to toggle) showing affected findings grouped by classification
- Dismiss button (X icon) that hides the banner for the current session via `useState`
4. Uses monospace typography and dark theme colors per `DESIGN_SYSTEM.md`.
**Session dismiss:** Uses React state only — no localStorage. The banner reappears on page reload, which is appropriate since the anomaly data persists until the next sync produces a non-significant result.
```mermaid
stateDiagram-v2
[*] --> Loading: Component mounts
Loading --> Hidden: No anomaly or not significant
Loading --> Visible: Significant anomaly
Visible --> Expanded: Click breakdown text
Expanded --> Visible: Click breakdown text
Visible --> Dismissed: Click dismiss
Expanded --> Dismissed: Click dismiss
Dismissed --> [*]
```
### 6. Migration Script
Located at `backend/migrations/add_sync_anomaly_tables.js`. Uses the same pattern as existing migrations (`add_closed_gone_state.js`): standalone Node script, opens the database directly, uses `CREATE TABLE IF NOT EXISTS` and `CREATE INDEX IF NOT EXISTS` for idempotency.
---
## Data Models
### New Table: `ivanti_sync_anomaly_log`
Stores one row per sync cycle with the anomaly summary.
| Column | Type | Constraints | Description |
|---|---|---|---|
| `id` | INTEGER | PRIMARY KEY AUTOINCREMENT | Unique row identifier |
| `sync_timestamp` | DATETIME | NOT NULL DEFAULT CURRENT_TIMESTAMP | When the sync completed |
| `open_count_delta` | INTEGER | NOT NULL DEFAULT 0 | Current open count minus previous open count |
| `closed_count_delta` | INTEGER | NOT NULL DEFAULT 0 | Current closed count minus previous closed count |
| `newly_archived_count` | INTEGER | NOT NULL DEFAULT 0 | Number of findings archived in this sync |
| `returned_count` | INTEGER | NOT NULL DEFAULT 0 | Number of findings that returned in this sync |
| `classification_json` | TEXT | NOT NULL DEFAULT '{}' | JSON object: `{ bu_reassignment, severity_drift, closed_on_platform, decommissioned }` |
| `is_significant` | INTEGER | NOT NULL DEFAULT 0 | 1 if `newly_archived_count > 5`, else 0 |
| `created_at` | DATETIME | DEFAULT CURRENT_TIMESTAMP | Row creation timestamp |
**Indexes:**
- `idx_anomaly_sync_timestamp` on `sync_timestamp` — for efficient latest-record and date-range queries
### New Table: `ivanti_finding_bu_history`
Stores BU change events detected during sync.
| Column | Type | Constraints | Description |
|---|---|---|---|
| `id` | INTEGER | PRIMARY KEY AUTOINCREMENT | Unique row identifier |
| `finding_id` | TEXT | NOT NULL | Ivanti finding identifier |
| `finding_title` | TEXT | NOT NULL DEFAULT '' | Finding title at time of detection |
| `host_name` | TEXT | NOT NULL DEFAULT '' | Host name at time of detection |
| `previous_bu` | TEXT | NOT NULL | BU value from previous sync |
| `new_bu` | TEXT | NOT NULL | BU value from current sync |
| `detected_at` | DATETIME | NOT NULL DEFAULT CURRENT_TIMESTAMP | When the change was detected |
| `created_at` | DATETIME | DEFAULT CURRENT_TIMESTAMP | Row creation timestamp |
**Indexes:**
- `idx_bu_history_finding_id` on `finding_id` — for per-finding history lookups
- `idx_bu_history_detected_at` on `detected_at` — for chronological queries
### Modified: `ivanti_archive_transitions.reason` field
No schema change needed — the `reason` column is already `TEXT NOT NULL DEFAULT ''`. The change is in the values written:
| Previous values | New values |
|---|---|
| `severity_score_drift` | `bu_reassignment:<new_bu>` |
| `reappeared_in_sync` | `severity_drift:<new_severity>` |
| `remediated_in_ivanti` | `closed_on_platform` |
| `disappeared_from_closed_set` | `decommissioned` |
Existing rows with `severity_score_drift` are not modified — the enhanced reasons apply only to transitions created after deployment.
### Existing: `ivanti_findings_cache.findings_json`
No schema change. The `buOwnership` field is already present in each finding object within the JSON array, extracted by `extractFinding()` from `assetCustomAttributes['1550_host_1']`.
---
## Correctness Properties
*A property is a characteristic or behavior that should hold true across all valid executions of a system — essentially, a formal statement about what the system should do. Properties serve as the bridge between human-readable specifications and machine-verifiable correctness guarantees.*
### Property 1: Classification correctness
*For any* finding returned by an unfiltered Ivanti API query, the BU drift classifier SHALL produce the correct classification based on the combination of BU value, severity, and state:
- BU not in {NTS-AEO-ACCESS-ENG, NTS-AEO-STEAM} → `bu_reassignment`
- BU matches expected, severity < 8.5 → `severity_drift`
- BU matches expected, severity >= 8.5, state is Closed → `closed_on_platform`
- Finding not returned by API → `decommissioned`
**Validates: Requirements 1.2, 1.3, 1.4, 1.5**
### Property 2: Archive transition reason formatting
*For any* classification result, the archive transition reason field SHALL be formatted correctly:
- `bu_reassignment` classification with BU value B → reason is `bu_reassignment:B`
- `severity_drift` classification with severity S → reason is `severity_drift:S`
- `closed_on_platform` → reason is `closed_on_platform`
- `decommissioned` → reason is `decommissioned`
**Validates: Requirements 6.1, 6.2, 6.3, 6.4**
### Property 3: Batch size constraint
*For any* list of finding IDs of length N, the BU drift checker SHALL partition them into ceil(N/50) batches where each batch contains at most 50 IDs and the union of all batches equals the original list.
**Validates: Requirements 1.7**
### Property 4: Significance threshold
*For any* non-negative integer `newly_archived_count`, the anomaly summary's `is_significant` flag SHALL be true if and only if `newly_archived_count > 5`.
**Validates: Requirements 2.7**
### Property 5: Count delta computation
*For any* pair of non-negative integers (previous_count, current_count), the anomaly summary SHALL compute the delta as `current_count - previous_count` for both open and closed counts.
**Validates: Requirements 2.1**
### Property 6: BU extraction preservation
*For any* raw Ivanti finding object with a non-empty `assetCustomAttributes['1550_host_1']` array, `extractFinding` SHALL produce a finding object whose `buOwnership` field equals the first element of that array.
**Validates: Requirements 3.1**
### Property 7: BU change detection and recording
*For any* finding that appears in both the previous and current sync results with different non-empty `buOwnership` values, the sync pipeline SHALL insert exactly one row into `ivanti_finding_bu_history` with the correct `finding_id`, `previous_bu`, and `new_bu`. *For any* finding that appears for the first time (no previous entry) or has the same BU value, no history row SHALL be inserted.
**Validates: Requirements 3.2, 3.3, 3.6**
### Property 8: Latest anomaly returns most recent
*For any* non-empty sequence of anomaly summary rows with distinct timestamps, the `/anomaly/latest` endpoint SHALL return the row with the maximum `sync_timestamp`.
**Validates: Requirements 2.5**
### Property 9: Anomaly history ordering and limit
*For any* set of N anomaly summary rows, the `/anomaly/history` endpoint (without date parameters) SHALL return min(N, 30) rows ordered by `sync_timestamp` descending.
**Validates: Requirements 2.6, 7.2**
### Property 10: Date-range filtering with complete response shape
*For any* date range [from, to] and set of anomaly summary rows, the `/anomaly/history` endpoint SHALL return only rows whose `sync_timestamp` falls within the range (inclusive), ordered by `sync_timestamp` descending. Each returned row SHALL include `sync_timestamp`, `open_count_delta`, `closed_count_delta`, `newly_archived_count`, `returned_count`, `classification` (parsed as an object from `classification_json`), and `is_significant`.
**Validates: Requirements 7.1, 7.4**
### Property 11: BU changes endpoint ordering
*For any* set of BU change history rows, the `/bu-changes` endpoint SHALL return all rows ordered by `detected_at` descending.
**Validates: Requirements 3.4**
### Property 12: Per-finding BU history filtering
*For any* finding ID F and set of BU history rows across multiple findings, the `/:findingId/bu-history` endpoint SHALL return only rows where `finding_id = F`, ordered by `detected_at` descending.
**Validates: Requirements 3.5**
---
## Error Handling
### BU Drift Checker Errors
- **Individual batch API failure**: Log the error with the batch range, skip the batch, continue with remaining batches. Findings in the failed batch retain the default `severity_score_drift` reason. The function returns partial results.
- **All batches fail**: The classification breakdown will be all zeros. The anomaly summary is still written with `newly_archived_count` reflecting the archive detection results (which don't depend on the drift checker).
- **API timeout**: The existing 15-second timeout in `ivantiPost()` applies. Timed-out batches are treated as failed batches.
- **Malformed API response**: If `JSON.parse` fails on the response body, treat the batch as failed. Log the raw response length for debugging.
### Anomaly Summary Errors
- **Database write failure**: Log the error. The sync itself has already completed successfully — the anomaly summary is informational. Do not retry.
- **Missing previous counts**: If no previous anomaly row exists (first sync after deployment), use 0 for previous counts. The first anomaly row will have deltas equal to the current counts.
### BU Comparison Errors
- **Database insert failure**: Log the error for the specific finding, continue processing remaining findings. BU comparison failures are non-fatal.
- **Missing buOwnership field**: If either the previous or current finding has an empty/undefined `buOwnership`, skip the comparison for that finding (per requirement 3.6).
### API Endpoint Errors
- **Database read failure**: Return 500 with a generic error message. Do not expose internal error details.
- **Invalid date parameters**: If `from` or `to` are not valid ISO date strings, ignore them and fall back to the default last-30 behavior. Log a warning.
- **Authentication failure**: Handled by existing `requireAuth(db)` middleware — returns 401.
### Migration Errors
- **Table already exists**: `CREATE TABLE IF NOT EXISTS` handles this silently.
- **Index already exists**: `CREATE INDEX IF NOT EXISTS` handles this silently.
- **Database locked**: The migration script opens its own connection. If the server is running, SQLite's WAL mode allows concurrent reads. If a write lock conflict occurs, the migration will fail with a clear error message and can be retried.
---
## Testing Strategy
### Property-Based Tests
Property-based testing is appropriate for this feature because the core logic involves classification functions, data transformations, and query behaviors that have clear input/output relationships and universal properties.
**Library:** [fast-check](https://github.com/dubzzz/fast-check) — the standard PBT library for JavaScript/Node.js.
**Configuration:**
- Minimum 100 iterations per property test
- Each test tagged with: `Feature: sync-anomaly-detection, Property {N}: {title}`
**Properties to implement:**
| Property | Test approach |
|---|---|
| 1: Classification correctness | Generate random {bu, severity, state, found} tuples, verify classifier output |
| 2: Reason formatting | Generate random classification results, verify reason string format |
| 3: Batch size constraint | Generate random-length ID arrays, verify chunking |
| 4: Significance threshold | Generate random integers, verify is_significant flag |
| 5: Delta computation | Generate random count pairs, verify subtraction |
| 6: BU extraction | Generate random raw finding objects, verify buOwnership extraction |
| 7: BU change detection | Generate random previous/current finding pairs, verify history insertion |
| 812: API query properties | Generate random DB state, verify endpoint responses |
### Unit Tests (Example-Based)
Unit tests cover specific scenarios, edge cases, and integration points not suited for PBT:
- **Migration idempotency**: Run migration twice, verify no errors on second run (Req 4.6)
- **API error resilience**: Mock `ivantiPost` to return errors, verify drift checker doesn't throw (Req 1.6)
- **Anomaly banner rendering**: Mock API response, verify banner shows/hides based on `is_significant` (Req 5.2, 5.3)
- **Banner dismiss**: Click dismiss button, verify banner hidden (Req 5.4)
- **Banner expand/collapse**: Click breakdown text, verify detail section toggles (Req 5.7)
- **Authentication enforcement**: Unauthenticated requests return 401 (Req 7.3)
- **Fixed reason strings**: Verify `decommissioned` and `closed_on_platform` are exact strings (Req 6.3, 6.4)
- **Backward compatibility**: Existing `severity_score_drift` rows are not modified (Req 6.5)
### Integration Tests
- **End-to-end sync with drift checker**: Mock Ivanti API, run full sync pipeline, verify anomaly log and BU history tables are populated correctly
- **API endpoint responses**: Seed database, call each endpoint, verify response shape and content
### Test File Locations
- `backend/__tests__/bu-drift-classification.property.test.js` — Properties 16
- `backend/__tests__/anomaly-api.property.test.js` — Properties 712
- `backend/__tests__/sync-anomaly-detection.test.js` — Unit and integration tests
- `frontend/src/components/pages/__tests__/AnomalyBanner.test.js` — UI component tests

View File

@@ -0,0 +1,112 @@
# Requirements Document
## Introduction
The Sync Anomaly Detection and BU Drift Monitoring feature extends the Ivanti sync pipeline to automatically classify why findings disappear from sync results. The current archive system detects disappearances but cannot distinguish between BU reassignment, severity score drift, and host decommission. This feature adds three capabilities: post-sync BU drift spot-checks against the unfiltered Ivanti API, a structured sync anomaly summary that breaks down count changes by cause, and per-finding BU tracking that records the business unit on each cached finding and detects BU changes across syncs. Together, these close the visibility gap exposed by the April 2026 incident where 109 findings silently disappeared due to a bulk BU reassignment from NTS-AEO-STEAM to SDIT-CSD-ITLS-PIES.
## Glossary
- **Sync_Pipeline**: The existing Ivanti/RiskSense host finding sync process in `backend/routes/ivantiFindings.js` that fetches open and closed findings matching BU and severity filters on a daily schedule.
- **Finding**: A single host-level vulnerability record identified by a unique `finding_id` from Ivanti/RiskSense, cached in `ivanti_findings_cache`.
- **Archive_Detector**: The existing logic within the Sync_Pipeline that compares previous sync results against current results to identify disappeared and returned findings, writing to `ivanti_finding_archives` and `ivanti_archive_transitions`.
- **BU_Drift_Checker**: New post-sync logic that queries the Ivanti API without BU filters for a sample of archived findings to determine whether they were reassigned to a different business unit.
- **Anomaly_Summary**: A structured report generated after each sync that categorizes finding count changes by cause (BU reassignment, severity drift, closure, decommission) and stores the results for API retrieval and UI display.
- **Sync_Anomaly_Log**: A new database table (`ivanti_sync_anomaly_log`) that stores one row per sync cycle containing the anomaly summary breakdown and metadata.
- **Finding_BU_History**: A new database table (`ivanti_finding_bu_history`) that records BU changes detected on individual findings across syncs.
- **BU_Field**: The `assetCustomAttributes.1550_host_1` attribute on an Ivanti host finding that identifies the owning business unit (e.g., `NTS-AEO-STEAM`, `NTS-AEO-ACCESS-ENG`).
- **Anomaly_Banner**: A React UI component displayed on the Vulnerability Triage page that surfaces the most recent sync anomaly summary when significant count changes are detected.
- **Unfiltered_Query**: An Ivanti API call that searches by finding ID only, without BU or severity filters, used to determine the current BU and severity of a finding that disappeared from filtered results.
- **Spot_Check**: A targeted Unfiltered_Query for a batch of recently archived findings, performed after each sync to classify disappearance causes without querying every archived finding.
## Requirements
### Requirement 1: BU Drift Detection on Sync
**User Story:** As a security analyst, I want the system to automatically check whether archived findings were reassigned to a different BU, so that I can distinguish BU reassignment from score drift or decommission without running manual diagnostic scripts.
#### Acceptance Criteria
1. WHEN the Sync_Pipeline completes a successful sync and new findings have been archived, THE BU_Drift_Checker SHALL query the Ivanti API using an Unfiltered_Query for the finding IDs of all newly archived findings from that sync cycle.
2. WHEN the Unfiltered_Query returns a finding with a BU_Field value different from `NTS-AEO-ACCESS-ENG` or `NTS-AEO-STEAM`, THE BU_Drift_Checker SHALL classify that finding as `bu_reassignment` and record the new BU value in the archive transition reason.
3. WHEN the Unfiltered_Query returns a finding with a severity below 8.5 and the BU_Field still matches `NTS-AEO-ACCESS-ENG` or `NTS-AEO-STEAM`, THE BU_Drift_Checker SHALL classify that finding as `severity_drift` and record the new severity in the archive transition.
4. WHEN the Unfiltered_Query does not return a finding at all, THE BU_Drift_Checker SHALL classify that finding as `decommissioned`.
5. WHEN the Unfiltered_Query returns a finding with a state of `Closed` and the BU_Field still matches the expected BUs, THE BU_Drift_Checker SHALL classify that finding as `closed_on_platform`.
6. IF the Unfiltered_Query fails due to an API error, THEN THE BU_Drift_Checker SHALL log the error and leave the archive transition reason as the existing default (`severity_score_drift`) without blocking the sync completion.
7. THE BU_Drift_Checker SHALL batch finding IDs into groups of 50 for the Unfiltered_Query to stay within Ivanti API limits.
### Requirement 2: Sync Anomaly Summary
**User Story:** As a security analyst, I want a post-sync summary that explains significant count changes, so that I have immediate visibility into what happened without manual investigation.
#### Acceptance Criteria
1. WHEN the Sync_Pipeline completes a successful sync, THE Anomaly_Summary SHALL compute the difference between the current open count and the previous open count, and between the current closed count and the previous closed count.
2. WHEN findings have been newly archived during the sync, THE Anomaly_Summary SHALL include a breakdown by classification: count of `bu_reassignment`, count of `severity_drift`, count of `closed_on_platform`, and count of `decommissioned`.
3. WHEN findings have transitioned from ARCHIVED to RETURNED during the sync, THE Anomaly_Summary SHALL include the count of returned findings.
4. THE Anomaly_Summary SHALL be stored as a row in the Sync_Anomaly_Log table with the sync timestamp, open count delta, closed count delta, classification breakdown as a JSON object, and the total number of newly archived findings.
5. WHEN a GET request is made to `/api/ivanti/findings/anomaly/latest`, THE Sync_Pipeline SHALL return the most recent Anomaly_Summary row.
6. WHEN a GET request is made to `/api/ivanti/findings/anomaly/history`, THE Sync_Pipeline SHALL return the last 30 Anomaly_Summary rows ordered by sync timestamp descending.
7. WHEN the total number of newly archived findings in a single sync exceeds 5, THE Anomaly_Summary SHALL flag the sync as `significant` in the stored record.
### Requirement 3: Finding-Level BU Tracking
**User Story:** As a security analyst, I want the system to store the BU on each cached finding and detect when a finding's BU changes across syncs, so that BU reassignment is tracked as a distinct event from disappearance.
#### Acceptance Criteria
1. THE Sync_Pipeline SHALL store the BU_Field value (`buOwnership`) on each finding in the `ivanti_findings_cache` JSON payload, preserving the value extracted from `assetCustomAttributes.1550_host_1`.
2. WHEN the Sync_Pipeline processes a sync result, THE Sync_Pipeline SHALL compare each finding's current BU_Field value against the previously cached BU_Field value for the same finding ID.
3. WHEN a finding's BU_Field value differs from the previously cached value and both values are non-empty, THE Sync_Pipeline SHALL insert a row into the Finding_BU_History table recording the finding_id, previous BU, new BU, and detection timestamp.
4. WHEN a GET request is made to `/api/ivanti/findings/bu-changes`, THE Sync_Pipeline SHALL return all Finding_BU_History rows ordered by detected_at descending.
5. WHEN a GET request is made to `/api/ivanti/findings/:findingId/bu-history`, THE Sync_Pipeline SHALL return the Finding_BU_History rows for the specified finding ordered by detected_at descending.
6. IF a finding appears in the sync results for the first time with no previously cached BU_Field value, THEN THE Sync_Pipeline SHALL store the BU_Field value without recording a BU change event.
### Requirement 4: Database Schema for Anomaly and BU Tracking
**User Story:** As a developer, I want the anomaly and BU tracking data stored in normalized SQLite tables, so that the data model supports efficient queries and integrates with the existing migration pattern.
#### Acceptance Criteria
1. THE migration script SHALL create an `ivanti_sync_anomaly_log` table with columns for id (autoincrement primary key), sync_timestamp (datetime), open_count_delta (integer), closed_count_delta (integer), newly_archived_count (integer), returned_count (integer), classification_json (text storing the breakdown object), is_significant (boolean integer), and created_at (datetime).
2. THE migration script SHALL create an `ivanti_finding_bu_history` table with columns for id (autoincrement primary key), finding_id (text), finding_title (text), host_name (text), previous_bu (text), new_bu (text), detected_at (datetime), and created_at (datetime).
3. THE migration script SHALL create an index on `ivanti_sync_anomaly_log(sync_timestamp)` for efficient latest-record queries.
4. THE migration script SHALL create an index on `ivanti_finding_bu_history(finding_id)` for efficient per-finding history lookups.
5. THE migration script SHALL create an index on `ivanti_finding_bu_history(detected_at)` for efficient chronological queries.
6. THE migration script SHALL be located at `backend/migrations/add_sync_anomaly_tables.js` and use `CREATE TABLE IF NOT EXISTS` and `CREATE INDEX IF NOT EXISTS` to be idempotent.
### Requirement 5: Anomaly Banner UI
**User Story:** As a security analyst, I want a banner on the Vulnerability Triage page that surfaces the latest sync anomaly summary, so that significant count changes are immediately visible without navigating to a separate page.
#### Acceptance Criteria
1. WHEN the Vulnerability Triage page loads, THE Anomaly_Banner SHALL fetch the latest Anomaly_Summary from `/api/ivanti/findings/anomaly/latest`.
2. WHEN the latest Anomaly_Summary has `is_significant` set to true, THE Anomaly_Banner SHALL display a warning banner showing the total count change and the classification breakdown (e.g., "45 findings archived — 38 BU reassignment, 5 closed, 2 decommissioned").
3. WHEN the latest Anomaly_Summary has `is_significant` set to false, THE Anomaly_Banner SHALL not display a banner.
4. THE Anomaly_Banner SHALL include a dismiss button that hides the banner for the current session.
5. THE Anomaly_Banner SHALL use amber (#F59E0B) background tint and the AlertTriangle icon from Lucide, consistent with the existing dashboard warning patterns.
6. THE Anomaly_Banner SHALL use monospace typography and the dark theme color palette defined in DESIGN_SYSTEM.md.
7. WHEN the user clicks the classification breakdown text in the Anomaly_Banner, THE Anomaly_Banner SHALL expand to show a detailed list of affected findings grouped by classification.
### Requirement 6: Archive Transition Reason Enhancement
**User Story:** As a security analyst, I want archive transitions to record the specific reason a finding disappeared (BU reassignment, severity drift, decommission, closure), so that the archive history provides actionable context instead of a generic "severity_score_drift" label.
#### Acceptance Criteria
1. WHEN the BU_Drift_Checker classifies a newly archived finding as `bu_reassignment`, THE Archive_Detector SHALL update the corresponding `ivanti_archive_transitions` row's reason field to `bu_reassignment:<new_bu>` where `<new_bu>` is the BU the finding was reassigned to.
2. WHEN the BU_Drift_Checker classifies a newly archived finding as `severity_drift`, THE Archive_Detector SHALL update the corresponding `ivanti_archive_transitions` row's reason field to `severity_drift:<new_severity>` where `<new_severity>` is the finding's current severity score.
3. WHEN the BU_Drift_Checker classifies a newly archived finding as `decommissioned`, THE Archive_Detector SHALL update the corresponding `ivanti_archive_transitions` row's reason field to `decommissioned`.
4. WHEN the BU_Drift_Checker classifies a newly archived finding as `closed_on_platform`, THE Archive_Detector SHALL update the corresponding `ivanti_archive_transitions` row's reason field to `closed_on_platform`.
5. THE existing archive transition rows with reason `severity_score_drift` SHALL remain unchanged — the enhanced reasons apply only to transitions created after this feature is deployed.
### Requirement 7: Anomaly History API for Trend Analysis
**User Story:** As a security analyst, I want to view anomaly history over time, so that I can identify patterns in BU reassignments and score drift across multiple sync cycles.
#### Acceptance Criteria
1. WHEN a GET request is made to `/api/ivanti/findings/anomaly/history` with optional query parameters `from` and `to` (ISO date strings), THE Sync_Pipeline SHALL return Anomaly_Summary rows within the specified date range.
2. WHEN no `from` or `to` parameters are provided, THE Sync_Pipeline SHALL return the last 30 Anomaly_Summary rows.
3. WHEN an unauthenticated request is made to any anomaly or BU history endpoint, THE Sync_Pipeline SHALL return a 401 status code.
4. THE anomaly history response SHALL include each row's sync_timestamp, open_count_delta, closed_count_delta, newly_archived_count, returned_count, classification_json (parsed as an object), and is_significant flag.

View File

@@ -0,0 +1,178 @@
# Implementation Plan: Sync Anomaly Detection and BU Drift Monitoring
## Overview
This plan implements the sync anomaly detection feature in incremental steps: database migration first, then core classification and summary logic, BU tracking in the sync pipeline, new API endpoints, archive transition enhancement, and finally the React anomaly banner. Each task builds on the previous, with property-based tests validating correctness properties from the design document.
## Tasks
- [x] 1. Create database migration script
- [x] 1.1 Create `backend/migrations/add_sync_anomaly_tables.js`
- Create `ivanti_sync_anomaly_log` table with columns: id (autoincrement PK), sync_timestamp (datetime NOT NULL DEFAULT CURRENT_TIMESTAMP), open_count_delta (integer NOT NULL DEFAULT 0), closed_count_delta (integer NOT NULL DEFAULT 0), newly_archived_count (integer NOT NULL DEFAULT 0), returned_count (integer NOT NULL DEFAULT 0), classification_json (text NOT NULL DEFAULT '{}'), is_significant (integer NOT NULL DEFAULT 0), created_at (datetime DEFAULT CURRENT_TIMESTAMP)
- Create `ivanti_finding_bu_history` table with columns: id (autoincrement PK), finding_id (text NOT NULL), finding_title (text NOT NULL DEFAULT ''), host_name (text NOT NULL DEFAULT ''), previous_bu (text NOT NULL), new_bu (text NOT NULL), detected_at (datetime NOT NULL DEFAULT CURRENT_TIMESTAMP), created_at (datetime DEFAULT CURRENT_TIMESTAMP)
- Create index `idx_anomaly_sync_timestamp` on `ivanti_sync_anomaly_log(sync_timestamp)`
- Create index `idx_bu_history_finding_id` on `ivanti_finding_bu_history(finding_id)`
- Create index `idx_bu_history_detected_at` on `ivanti_finding_bu_history(detected_at)`
- Use `CREATE TABLE IF NOT EXISTS` and `CREATE INDEX IF NOT EXISTS` for idempotency
- Follow the standalone Node script pattern from `add_closed_gone_state.js` (open DB directly, promise-based helpers, run/all wrappers)
- _Requirements: 4.1, 4.2, 4.3, 4.4, 4.5, 4.6_
- [x] 1.2 Run migration and verify tables exist
- Execute `node backend/migrations/add_sync_anomaly_tables.js`
- Verify both tables and all three indexes were created
- Run migration a second time to confirm idempotency (no errors on re-run)
- _Requirements: 4.6_
- [x] 2. Implement BU drift classifier and batch logic
- [x] 2.1 Implement `runBUDriftChecker` function in `backend/routes/ivantiFindings.js`
- Add `runBUDriftChecker(db, newlyArchivedIds, apiKey, clientId, skipTls)` async function
- Chunk `newlyArchivedIds` into batches of 50
- For each batch, call `ivantiPost()` with a filter on `id` field only (no BU, severity, or state filters) using the unfiltered query pattern from `bu-reassignment-check.js`
- Classify each finding: BU not in {NTS-AEO-ACCESS-ENG, NTS-AEO-STEAM} → `bu_reassignment`; BU matches + severity < 8.5 → `severity_drift`; BU matches + state Closed → `closed_on_platform`; not found → `decommissioned`
- Update the corresponding `ivanti_archive_transitions` row's reason field with the formatted classification
- Return classification summary object: `{ bu_reassignment: N, severity_drift: N, closed_on_platform: N, decommissioned: N }`
- Wrap each batch in try/catch — log errors, skip failed batches, never throw
- _Requirements: 1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 1.7_
- [ ]* 2.2 Write property test for classification correctness
- **Property 1: Classification correctness**
- Generate random {bu, severity, state, found} tuples, verify classifier produces the correct classification based on BU value, severity threshold (8.5), and state
- **Validates: Requirements 1.2, 1.3, 1.4, 1.5**
- [ ]* 2.3 Write property test for archive transition reason formatting
- **Property 2: Archive transition reason formatting**
- Generate random classification results with BU values and severity scores, verify reason string format: `bu_reassignment:B`, `severity_drift:S`, `closed_on_platform`, `decommissioned`
- **Validates: Requirements 6.1, 6.2, 6.3, 6.4**
- [ ]* 2.4 Write property test for batch size constraint
- **Property 3: Batch size constraint**
- Generate random-length ID arrays (0 to 500), verify chunking produces ceil(N/50) batches, each batch has at most 50 IDs, and the union of all batches equals the original list
- **Validates: Requirements 1.7**
- [x] 3. Implement anomaly summary computation
- [x] 3.1 Implement `computeAnomalySummary` function in `backend/routes/ivantiFindings.js`
- Add `computeAnomalySummary(db, openCountDelta, closedCountDelta, newlyArchivedCount, returnedCount, classificationBreakdown)` async function
- Compute `is_significant`: true if `newlyArchivedCount > 5`
- Insert a row into `ivanti_sync_anomaly_log` with all fields
- Log the summary to console
- Wrap in try/catch — log errors, never throw (anomaly summary is informational)
- _Requirements: 2.1, 2.4, 2.7_
- [ ]* 3.2 Write property test for significance threshold
- **Property 4: Significance threshold**
- Generate random non-negative integers for `newly_archived_count`, verify `is_significant` is true if and only if `newly_archived_count > 5`
- **Validates: Requirements 2.7**
- [ ]* 3.3 Write property test for count delta computation
- **Property 5: Count delta computation**
- Generate random pairs of non-negative integers (previous_count, current_count), verify delta equals `current_count - previous_count` for both open and closed counts
- **Validates: Requirements 2.1**
- [x] 4. Checkpoint — Verify core logic
- Ensure all tests pass, ask the user if questions arise.
- [x] 5. Integrate BU comparison into syncFindings
- [x] 5.1 Add per-finding BU comparison logic to `syncFindings()` in `backend/routes/ivantiFindings.js`
- After reading `previousFindings` and before writing the new cache, compare each finding's `buOwnership` against the previous finding's `buOwnership`
- When both values are non-empty and differ, insert a row into `ivanti_finding_bu_history` with finding_id, finding_title, host_name, previous_bu, new_bu, and detected_at
- When a finding appears for the first time (no previous entry), store the BU without recording a change event
- Wrap in try/catch per finding — log errors, continue processing remaining findings
- _Requirements: 3.1, 3.2, 3.3, 3.6_
- [ ]* 5.2 Write property test for BU extraction preservation
- **Property 6: BU extraction preservation**
- Generate random raw Ivanti finding objects with varying `assetCustomAttributes['1550_host_1']` arrays, verify `extractFinding` produces a finding whose `buOwnership` equals the first element of that array
- **Validates: Requirements 3.1**
- [ ]* 5.3 Write property test for BU change detection
- **Property 7: BU change detection and recording**
- Generate random previous/current finding pairs with varying BU values (same, different, empty), verify that a BU history row is inserted only when both values are non-empty and differ
- **Validates: Requirements 3.2, 3.3, 3.6**
- [x] 6. Wire drift checker and anomaly summary into sync pipeline
- [x] 6.1 Integrate `runBUDriftChecker` and `computeAnomalySummary` into `syncFindings()` flow
- Collect `newlyArchivedIds` from `detectArchiveChanges` (modify it to return the list of disappeared IDs)
- Collect `returnedCount` from `detectArchiveChanges` (count of ARCHIVED → RETURNED transitions)
- After `detectClosedGoneFindings`, call `runBUDriftChecker` with the newly archived IDs
- Compute `openCountDelta` and `closedCountDelta` by comparing current counts against previous counts from `ivanti_counts_cache`
- Call `computeAnomalySummary` with all collected metrics
- Wrap both calls in try/catch — failures are non-fatal and must not block sync completion
- Export `runBUDriftChecker`, `computeAnomalySummary`, and `extractFinding` from the module for testing
- _Requirements: 1.1, 2.1, 2.2, 2.3, 2.4, 2.7_
- [x] 7. Checkpoint — Verify pipeline integration
- Ensure all tests pass, ask the user if questions arise.
- [x] 8. Add anomaly and BU history API endpoints
- [x] 8.1 Add `GET /anomaly/latest` endpoint to `createIvantiFindingsRouter`
- Query `ivanti_sync_anomaly_log` for the row with the maximum `sync_timestamp`
- Parse `classification_json` into an object in the response
- Return `{ anomaly: row }` or `{ anomaly: null }` if no records exist
- _Requirements: 2.5_
- [x] 8.2 Add `GET /anomaly/history` endpoint to `createIvantiFindingsRouter`
- Accept optional `from` and `to` query parameters (ISO date strings)
- If date params provided, filter by `sync_timestamp` range (inclusive)
- If no date params, return last 30 rows ordered by `sync_timestamp` descending
- Parse `classification_json` into an object for each row
- Return each row with: sync_timestamp, open_count_delta, closed_count_delta, newly_archived_count, returned_count, classification (parsed object), is_significant
- _Requirements: 2.6, 7.1, 7.2, 7.3, 7.4_
- [x] 8.3 Add `GET /bu-changes` endpoint to `createIvantiFindingsRouter`
- Query all rows from `ivanti_finding_bu_history` ordered by `detected_at` descending
- Return `{ changes: rows }`
- _Requirements: 3.4_
- [x] 8.4 Add `GET /:findingId/bu-history` endpoint to `createIvantiFindingsRouter`
- Query `ivanti_finding_bu_history` where `finding_id` matches the URL param, ordered by `detected_at` descending
- Return `{ finding_id, history: rows }`
- Place this route definition carefully to avoid conflicts with existing `/:findingId/note` and `/:findingId/override` routes
- _Requirements: 3.5_
- [ ]* 8.5 Write property tests for API query behaviors
- **Property 8: Latest anomaly returns most recent** — Generate random sequences of anomaly rows with distinct timestamps, verify `/anomaly/latest` returns the row with the maximum timestamp
- **Property 9: Anomaly history ordering and limit** — Generate random sets of N anomaly rows, verify `/anomaly/history` returns min(N, 30) rows ordered by timestamp descending
- **Property 10: Date-range filtering with complete response shape** — Generate random date ranges and anomaly rows, verify only rows within range are returned with correct fields
- **Property 11: BU changes endpoint ordering** — Generate random BU change rows, verify `/bu-changes` returns all rows ordered by `detected_at` descending
- **Property 12: Per-finding BU history filtering** — Generate random BU history rows across multiple findings, verify `/:findingId/bu-history` returns only matching rows ordered by `detected_at` descending
- **Validates: Requirements 2.5, 2.6, 3.4, 3.5, 7.1, 7.2, 7.4**
- [x] 9. Checkpoint — Verify endpoints and properties
- Ensure all tests pass, ask the user if questions arise.
- [x] 10. Implement Anomaly Banner UI component
- [x] 10.1 Create `frontend/src/components/pages/AnomalyBanner.js`
- Fetch latest anomaly summary from `/api/ivanti/findings/anomaly/latest` on mount
- If `is_significant` is false or no anomaly exists, render nothing
- If `is_significant` is true, render a warning banner with:
- Amber background tint (`rgba(245, 158, 11, 0.15)`) with amber border (`rgba(245, 158, 11, 0.3)`)
- `AlertTriangle` icon from lucide-react
- Summary text showing total count change and classification breakdown (e.g., "45 findings archived — 38 BU reassignment, 5 severity drift, 2 decommissioned")
- Expandable detail section (click to toggle) for affected findings grouped by classification
- Dismiss button (X icon) that hides the banner for the current session via `useState`
- Use monospace typography (`JetBrains Mono`) and dark theme colors per `DESIGN_SYSTEM.md`
- Match the inline style pattern used by `IvantiCountsChart.js`
- _Requirements: 5.1, 5.2, 5.3, 5.4, 5.5, 5.6, 5.7_
- [x] 10.2 Integrate `AnomalyBanner` into the Vulnerability Triage page
- Import and render `AnomalyBanner` above the `IvantiCountsChart` component on the Vulnerability Triage page
- _Requirements: 5.1_
- [x] 11. Enhance archive transition reasons
- [x] 11.1 Verify archive transition reason updates from `runBUDriftChecker`
- Confirm that `runBUDriftChecker` (task 2.1) correctly updates `ivanti_archive_transitions.reason` with formatted values: `bu_reassignment:<new_bu>`, `severity_drift:<new_severity>`, `closed_on_platform`, `decommissioned`
- Confirm existing rows with `severity_score_drift` are not modified — enhanced reasons apply only to new transitions
- _Requirements: 6.1, 6.2, 6.3, 6.4, 6.5_
- [x] 12. Final checkpoint — Ensure all tests pass
- Ensure all tests pass, ask the user if questions arise.
## Notes
- Tasks marked with `*` are optional and can be skipped for faster MVP
- Each task references specific requirements for traceability
- Checkpoints ensure incremental validation after each major integration point
- Property tests validate the 12 correctness properties defined in the design document
- The migration must be run before any other tasks (task 1)
- All new functions are added to the existing `ivantiFindings.js` module — no new route files
- The BU comparison in `syncFindings` (task 5) must happen before the cache is overwritten, which is the only point where both old and new BU values are in memory