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:
1
.kiro/specs/sync-anomaly-detection/.config.kiro
Normal file
1
.kiro/specs/sync-anomaly-detection/.config.kiro
Normal file
@@ -0,0 +1 @@
|
||||
{"specId": "a3e7c1d2-8f4b-4e9a-b6d1-2c5f8a9e3b7d", "workflowType": "requirements-first", "specType": "feature"}
|
||||
454
.kiro/specs/sync-anomaly-detection/design.md
Normal file
454
.kiro/specs/sync-anomaly-detection/design.md
Normal 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 |
|
||||
| 8–12: 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 1–6
|
||||
- `backend/__tests__/anomaly-api.property.test.js` — Properties 7–12
|
||||
- `backend/__tests__/sync-anomaly-detection.test.js` — Unit and integration tests
|
||||
- `frontend/src/components/pages/__tests__/AnomalyBanner.test.js` — UI component tests
|
||||
112
.kiro/specs/sync-anomaly-detection/requirements.md
Normal file
112
.kiro/specs/sync-anomaly-detection/requirements.md
Normal 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.
|
||||
178
.kiro/specs/sync-anomaly-detection/tasks.md
Normal file
178
.kiro/specs/sync-anomaly-detection/tasks.md
Normal 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
|
||||
130
backend/migrations/add_closed_gone_state.js
Normal file
130
backend/migrations/add_closed_gone_state.js
Normal file
@@ -0,0 +1,130 @@
|
||||
// Migration: Add CLOSED_GONE state to ivanti_finding_archives
|
||||
//
|
||||
// The archive table tracks findings that disappear from the Open findings set.
|
||||
// Previously it only tracked: ARCHIVED → RETURNED → CLOSED.
|
||||
//
|
||||
// This migration adds a CLOSED_GONE state for findings that were confirmed
|
||||
// in the Ivanti Closed set but then disappeared from it on a subsequent sync.
|
||||
// This closes a visibility gap where findings could vanish from the Closed API
|
||||
// results (e.g., due to VRR rescore below the severity threshold) without
|
||||
// being tracked.
|
||||
//
|
||||
// SQLite does not support ALTER TABLE to modify CHECK constraints, so this
|
||||
// migration recreates the table with the expanded constraint.
|
||||
//
|
||||
// Safe to re-run — uses IF NOT EXISTS and checks for existing data.
|
||||
//
|
||||
// Usage: node backend/migrations/add_closed_gone_state.js
|
||||
|
||||
const path = require('path');
|
||||
const sqlite3 = require('sqlite3').verbose();
|
||||
const dbPath = path.join(__dirname, '..', 'cve_database.db');
|
||||
const db = new sqlite3.Database(dbPath);
|
||||
|
||||
console.log('Starting CLOSED_GONE state migration...');
|
||||
|
||||
function run(sql, params = []) {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.run(sql, params, function (err) {
|
||||
if (err) reject(err);
|
||||
else resolve(this);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function all(sql, params = []) {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.all(sql, params, (err, rows) => {
|
||||
if (err) reject(err);
|
||||
else resolve(rows || []);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function migrate() {
|
||||
// Check if the table already has the CLOSED_GONE state
|
||||
const tableInfo = await all("SELECT sql FROM sqlite_master WHERE name='ivanti_finding_archives'");
|
||||
if (tableInfo.length > 0 && tableInfo[0].sql.includes('CLOSED_GONE')) {
|
||||
console.log('✓ ivanti_finding_archives already has CLOSED_GONE state — skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
if (tableInfo.length === 0) {
|
||||
// Table doesn't exist yet — create it fresh with the new constraint
|
||||
await run(`
|
||||
CREATE TABLE ivanti_finding_archives (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
finding_id TEXT NOT NULL UNIQUE,
|
||||
finding_title TEXT NOT NULL DEFAULT '',
|
||||
host_name TEXT NOT NULL DEFAULT '',
|
||||
ip_address TEXT NOT NULL DEFAULT '',
|
||||
current_state TEXT NOT NULL CHECK(current_state IN ('ARCHIVED','RETURNED','CLOSED','CLOSED_GONE')),
|
||||
last_severity REAL NOT NULL DEFAULT 0,
|
||||
first_archived_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
last_transition_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
console.log('✓ Created ivanti_finding_archives with CLOSED_GONE state');
|
||||
return;
|
||||
}
|
||||
|
||||
// Table exists but needs the constraint updated — recreate with data migration
|
||||
console.log(' Recreating table with expanded CHECK constraint...');
|
||||
|
||||
await run('BEGIN TRANSACTION');
|
||||
try {
|
||||
// 1. Rename existing table
|
||||
await run('ALTER TABLE ivanti_finding_archives RENAME TO ivanti_finding_archives_old');
|
||||
|
||||
// 2. Create new table with expanded constraint
|
||||
await run(`
|
||||
CREATE TABLE ivanti_finding_archives (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
finding_id TEXT NOT NULL UNIQUE,
|
||||
finding_title TEXT NOT NULL DEFAULT '',
|
||||
host_name TEXT NOT NULL DEFAULT '',
|
||||
ip_address TEXT NOT NULL DEFAULT '',
|
||||
current_state TEXT NOT NULL CHECK(current_state IN ('ARCHIVED','RETURNED','CLOSED','CLOSED_GONE')),
|
||||
last_severity REAL NOT NULL DEFAULT 0,
|
||||
first_archived_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
last_transition_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
|
||||
// 3. Copy data
|
||||
await run(`
|
||||
INSERT INTO ivanti_finding_archives
|
||||
(id, finding_id, finding_title, host_name, ip_address, current_state,
|
||||
last_severity, first_archived_at, last_transition_at, created_at)
|
||||
SELECT id, finding_id, finding_title, host_name, ip_address, current_state,
|
||||
last_severity, first_archived_at, last_transition_at, created_at
|
||||
FROM ivanti_finding_archives_old
|
||||
`);
|
||||
|
||||
// 4. Recreate indexes
|
||||
await run('CREATE INDEX IF NOT EXISTS idx_archive_finding_id ON ivanti_finding_archives(finding_id)');
|
||||
await run('CREATE INDEX IF NOT EXISTS idx_archive_current_state ON ivanti_finding_archives(current_state)');
|
||||
|
||||
// 5. Drop old table
|
||||
await run('DROP TABLE ivanti_finding_archives_old');
|
||||
|
||||
await run('COMMIT');
|
||||
console.log('✓ ivanti_finding_archives updated with CLOSED_GONE state');
|
||||
} catch (err) {
|
||||
await run('ROLLBACK').catch(() => {});
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
migrate()
|
||||
.then(() => {
|
||||
console.log('Migration complete.');
|
||||
db.close();
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('Migration failed:', err);
|
||||
db.close();
|
||||
process.exit(1);
|
||||
});
|
||||
90
backend/migrations/add_sync_anomaly_tables.js
Normal file
90
backend/migrations/add_sync_anomaly_tables.js
Normal file
@@ -0,0 +1,90 @@
|
||||
// Migration: Add sync anomaly detection and BU drift monitoring tables
|
||||
//
|
||||
// Creates two new tables:
|
||||
// - ivanti_sync_anomaly_log — stores one row per sync cycle with the
|
||||
// anomaly summary breakdown (count deltas, classification, significance).
|
||||
// - ivanti_finding_bu_history — records BU change events detected on
|
||||
// individual findings across syncs.
|
||||
//
|
||||
// Safe to re-run — uses CREATE TABLE IF NOT EXISTS and CREATE INDEX IF NOT EXISTS.
|
||||
//
|
||||
// Usage: node backend/migrations/add_sync_anomaly_tables.js
|
||||
|
||||
const path = require('path');
|
||||
const sqlite3 = require('sqlite3').verbose();
|
||||
const dbPath = path.join(__dirname, '..', 'cve_database.db');
|
||||
const db = new sqlite3.Database(dbPath);
|
||||
|
||||
console.log('Starting sync anomaly tables migration...');
|
||||
|
||||
function run(sql, params = []) {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.run(sql, params, function (err) {
|
||||
if (err) reject(err);
|
||||
else resolve(this);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function all(sql, params = []) {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.all(sql, params, (err, rows) => {
|
||||
if (err) reject(err);
|
||||
else resolve(rows || []);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function migrate() {
|
||||
// 1. Create ivanti_sync_anomaly_log table
|
||||
await run(`
|
||||
CREATE TABLE IF NOT EXISTS ivanti_sync_anomaly_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
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
|
||||
)
|
||||
`);
|
||||
console.log('✓ ivanti_sync_anomaly_log table ready');
|
||||
|
||||
// 2. Create ivanti_finding_bu_history table
|
||||
await run(`
|
||||
CREATE TABLE IF NOT EXISTS ivanti_finding_bu_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
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
|
||||
)
|
||||
`);
|
||||
console.log('✓ ivanti_finding_bu_history table ready');
|
||||
|
||||
// 3. Create indexes
|
||||
await run('CREATE INDEX IF NOT EXISTS idx_anomaly_sync_timestamp ON ivanti_sync_anomaly_log(sync_timestamp)');
|
||||
console.log('✓ idx_anomaly_sync_timestamp index ready');
|
||||
|
||||
await run('CREATE INDEX IF NOT EXISTS idx_bu_history_finding_id ON ivanti_finding_bu_history(finding_id)');
|
||||
console.log('✓ idx_bu_history_finding_id index ready');
|
||||
|
||||
await run('CREATE INDEX IF NOT EXISTS idx_bu_history_detected_at ON ivanti_finding_bu_history(detected_at)');
|
||||
console.log('✓ idx_bu_history_detected_at index ready');
|
||||
}
|
||||
|
||||
migrate()
|
||||
.then(() => {
|
||||
console.log('Migration complete.');
|
||||
db.close();
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('Migration failed:', err);
|
||||
db.close();
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -168,7 +168,7 @@ function initArchiveTables(db) {
|
||||
finding_title TEXT NOT NULL DEFAULT '',
|
||||
host_name TEXT NOT NULL DEFAULT '',
|
||||
ip_address TEXT NOT NULL DEFAULT '',
|
||||
current_state TEXT NOT NULL CHECK(current_state IN ('ARCHIVED','RETURNED','CLOSED')),
|
||||
current_state TEXT NOT NULL CHECK(current_state IN ('ARCHIVED','RETURNED','CLOSED','CLOSED_GONE')),
|
||||
last_severity REAL NOT NULL DEFAULT 0,
|
||||
first_archived_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
last_transition_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
@@ -305,7 +305,24 @@ async function detectArchiveChanges(db, previousFindings, currentFindings) {
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[Archive Detection] Processed ${disappearedIds.length} disappeared, checked ${currentIdsList.length} current findings against archive`);
|
||||
// Count returned findings for anomaly summary
|
||||
let returnedCount = 0;
|
||||
if (currentIdsList.length > 0) {
|
||||
try {
|
||||
// Count how many ARCHIVED records transitioned to RETURNED in this cycle
|
||||
// (already handled above, just count them)
|
||||
const archivedForCount = await dbAll(db,
|
||||
`SELECT id, finding_id FROM ivanti_finding_archives WHERE current_state = 'RETURNED' AND last_transition_at >= datetime('now', '-1 minute')`
|
||||
);
|
||||
returnedCount = archivedForCount.length;
|
||||
} catch (err) {
|
||||
// Non-fatal — returnedCount stays 0
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[Archive Detection] Processed ${disappearedIds.length} disappeared, ${returnedCount} returned, checked ${currentIdsList.length} current findings against archive`);
|
||||
|
||||
return { disappearedIds, returnedCount };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -350,6 +367,54 @@ async function detectClosedFindings(db, closedFindingIds) {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Closed-gone detection — find archive CLOSED findings that vanished from the
|
||||
// Ivanti closed API set. These are findings we previously confirmed as closed
|
||||
// but that no longer appear in the closed results (likely VRR rescore below
|
||||
// the severity threshold).
|
||||
// ---------------------------------------------------------------------------
|
||||
async function detectClosedGoneFindings(db, closedFindingIds) {
|
||||
if (!closedFindingIds) return;
|
||||
|
||||
const closedSet = new Set(closedFindingIds.map(String));
|
||||
|
||||
try {
|
||||
// Get all findings we previously marked as CLOSED in the archive
|
||||
const records = await dbAll(db,
|
||||
`SELECT id, finding_id, last_severity FROM ivanti_finding_archives WHERE current_state = 'CLOSED'`
|
||||
);
|
||||
|
||||
let goneCount = 0;
|
||||
for (const record of records) {
|
||||
// If this finding is still in the closed API set, it's fine
|
||||
if (closedSet.has(record.finding_id)) continue;
|
||||
|
||||
try {
|
||||
await dbRun(db,
|
||||
`UPDATE ivanti_finding_archives
|
||||
SET current_state = 'CLOSED_GONE', last_transition_at = datetime('now')
|
||||
WHERE id = ?`,
|
||||
[record.id]
|
||||
);
|
||||
await dbRun(db,
|
||||
`INSERT INTO ivanti_archive_transitions (archive_id, from_state, to_state, severity_at_transition, reason, transitioned_at)
|
||||
VALUES (?, 'CLOSED', 'CLOSED_GONE', ?, 'disappeared_from_closed_set', datetime('now'))`,
|
||||
[record.id, record.last_severity || 0]
|
||||
);
|
||||
goneCount++;
|
||||
} catch (err) {
|
||||
console.error(`[Archive Detection] Error marking finding ${record.finding_id} as CLOSED_GONE:`, err.message);
|
||||
}
|
||||
}
|
||||
|
||||
if (goneCount > 0) {
|
||||
console.warn(`[Archive Detection] ${goneCount} previously-closed findings disappeared from the Ivanti closed set (CLOSED → CLOSED_GONE)`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Archive Detection] Error in closed-gone detection:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Extract only the fields we need from a raw finding object
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -461,14 +526,36 @@ async function syncClosedCount(db, openCount, apiKey, clientId, skipTls) {
|
||||
[openCount, closedCount]
|
||||
);
|
||||
|
||||
// Drift guard — if the new total (open+closed) drops by more than 50%
|
||||
// compared to the most recent history snapshot, skip writing to history.
|
||||
// This prevents partial API responses from corrupting the trend chart.
|
||||
const newTotal = openCount + closedCount;
|
||||
let skipHistory = false;
|
||||
try {
|
||||
const prev = await dbGet(db,
|
||||
`SELECT open_count, closed_count FROM ivanti_counts_history ORDER BY recorded_at DESC LIMIT 1`
|
||||
);
|
||||
if (prev) {
|
||||
const prevTotal = (prev.open_count || 0) + (prev.closed_count || 0);
|
||||
if (prevTotal > 0 && newTotal < prevTotal * 0.5) {
|
||||
console.warn(`[Ivanti Findings] Drift guard triggered — new total ${newTotal} is <50% of previous ${prevTotal}. Skipping history write.`);
|
||||
skipHistory = true;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Ivanti Findings] Drift guard check failed (non-fatal):', err.message);
|
||||
}
|
||||
|
||||
// Append a snapshot to history — every sync is stored; the history
|
||||
// endpoint aggregates to last-per-day at query time (Option B).
|
||||
if (!skipHistory) {
|
||||
await dbRun(db,
|
||||
`INSERT INTO ivanti_counts_history (open_count, closed_count) VALUES (?, ?)`,
|
||||
[openCount, closedCount]
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`[Ivanti Findings] Counts updated — open: ${openCount}, closed: ${closedCount}`);
|
||||
console.log(`[Ivanti Findings] Counts updated — open: ${openCount}, closed: ${closedCount}${skipHistory ? ' (history write skipped — drift guard)' : ''}`);
|
||||
|
||||
// Detect closed findings in the archive — wrap in try/catch so errors don't break sync
|
||||
try {
|
||||
@@ -476,6 +563,13 @@ async function syncClosedCount(db, openCount, apiKey, clientId, skipTls) {
|
||||
} catch (err) {
|
||||
console.error('[Ivanti Findings] Closed finding archive detection failed (non-fatal):', err.message);
|
||||
}
|
||||
|
||||
// Detect findings that vanished from the closed set — CLOSED → CLOSED_GONE
|
||||
try {
|
||||
await detectClosedGoneFindings(db, closedFindingIds);
|
||||
} catch (err) {
|
||||
console.error('[Ivanti Findings] Closed-gone detection failed (non-fatal):', err.message);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Ivanti Findings] Failed to fetch closed count:', err.message);
|
||||
// Still update open count so it stays in sync; leave closed_count as-is
|
||||
@@ -637,6 +731,29 @@ async function syncFindings(db) {
|
||||
console.error('[Ivanti Findings] Failed to read previous findings for archive detection:', err.message);
|
||||
}
|
||||
|
||||
// Per-finding BU comparison — detect BU changes across syncs (Task 5.1)
|
||||
try {
|
||||
const previousMap = new Map(previousFindings.map(f => [String(f.id), f]));
|
||||
for (const finding of allFindings) {
|
||||
try {
|
||||
const prev = previousMap.get(String(finding.id));
|
||||
if (prev && prev.buOwnership && finding.buOwnership && prev.buOwnership !== finding.buOwnership) {
|
||||
await dbRun(db,
|
||||
`INSERT INTO ivanti_finding_bu_history (finding_id, finding_title, host_name, previous_bu, new_bu, detected_at)
|
||||
VALUES (?, ?, ?, ?, ?, datetime('now'))`,
|
||||
[String(finding.id), finding.title || '', finding.hostName || '', prev.buOwnership, finding.buOwnership]
|
||||
);
|
||||
console.log(`[BU Tracking] Finding ${finding.id} BU changed: ${prev.buOwnership} → ${finding.buOwnership}`);
|
||||
}
|
||||
// First-time findings (no prev entry) — store BU without recording a change event
|
||||
} catch (err) {
|
||||
console.error(`[BU Tracking] Error recording BU change for finding ${finding.id}:`, err.message);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[BU Tracking] BU comparison failed (non-fatal):', err.message);
|
||||
}
|
||||
|
||||
await dbRun(db,
|
||||
`UPDATE ivanti_findings_cache SET total=?, findings_json=?, synced_at=datetime('now'), sync_status='success', error_message=NULL WHERE id=1`,
|
||||
[allFindings.length, JSON.stringify(allFindings)]
|
||||
@@ -646,14 +763,60 @@ async function syncFindings(db) {
|
||||
|
||||
// Archive detection — compare previous vs current to detect disappeared/returned findings
|
||||
// Only runs after a successful sync (skipped on error per requirement 1.5)
|
||||
let archiveResult = { disappearedIds: [], returnedCount: 0 };
|
||||
try {
|
||||
await detectArchiveChanges(db, previousFindings, allFindings);
|
||||
archiveResult = await detectArchiveChanges(db, previousFindings, allFindings) || { disappearedIds: [], returnedCount: 0 };
|
||||
} catch (err) {
|
||||
console.error('[Ivanti Findings] Archive detection failed (non-fatal):', err.message);
|
||||
}
|
||||
|
||||
// Read previous counts BEFORE syncClosedCount updates them — needed for anomaly deltas
|
||||
let previousOpenCount = 0;
|
||||
let previousClosedCount = 0;
|
||||
try {
|
||||
const prevCounts = await dbGet(db,
|
||||
`SELECT open_count, closed_count FROM ivanti_counts_cache WHERE id = 1`
|
||||
);
|
||||
if (prevCounts) {
|
||||
previousOpenCount = prevCounts.open_count || 0;
|
||||
previousClosedCount = prevCounts.closed_count || 0;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Ivanti Findings] Failed to read previous counts for anomaly summary (non-fatal):', err.message);
|
||||
}
|
||||
|
||||
await syncClosedCount(db, allFindings.length, apiKey, clientId, skipTls);
|
||||
await syncFPWorkflowCounts(db, allFindings, apiKey, clientId, skipTls);
|
||||
|
||||
// Post-sync: BU drift checker for newly archived findings
|
||||
let classificationBreakdown = { bu_reassignment: 0, severity_drift: 0, closed_on_platform: 0, decommissioned: 0 };
|
||||
try {
|
||||
classificationBreakdown = await runBUDriftChecker(db, archiveResult.disappearedIds, apiKey, clientId, skipTls);
|
||||
} catch (err) {
|
||||
console.error('[Ivanti Findings] BU drift checker failed (non-fatal):', err.message);
|
||||
}
|
||||
|
||||
// Post-sync: Compute and store anomaly summary
|
||||
try {
|
||||
const currentCounts = await dbGet(db,
|
||||
`SELECT open_count, closed_count FROM ivanti_counts_cache WHERE id = 1`
|
||||
);
|
||||
const currentOpenCount = currentCounts?.open_count || 0;
|
||||
const currentClosedCount = currentCounts?.closed_count || 0;
|
||||
const openCountDelta = currentOpenCount - previousOpenCount;
|
||||
const closedCountDelta = currentClosedCount - previousClosedCount;
|
||||
|
||||
await computeAnomalySummary(
|
||||
db,
|
||||
openCountDelta,
|
||||
closedCountDelta,
|
||||
archiveResult.disappearedIds.length,
|
||||
archiveResult.returnedCount,
|
||||
classificationBreakdown
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('[Ivanti Findings] Anomaly summary failed (non-fatal):', err.message);
|
||||
}
|
||||
} catch (err) {
|
||||
const msg = err.message || 'Unknown error';
|
||||
console.error('[Ivanti Findings] Sync failed:', msg);
|
||||
@@ -771,6 +934,151 @@ async function readStateWithNotes(db) {
|
||||
return state;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// BU Drift Checker — post-sync classification of newly archived findings
|
||||
// ---------------------------------------------------------------------------
|
||||
const EXPECTED_BUS = new Set(['NTS-AEO-ACCESS-ENG', 'NTS-AEO-STEAM']);
|
||||
|
||||
async function runBUDriftChecker(db, newlyArchivedIds, apiKey, clientId, skipTls) {
|
||||
const summary = { bu_reassignment: 0, severity_drift: 0, closed_on_platform: 0, decommissioned: 0 };
|
||||
|
||||
if (!newlyArchivedIds || newlyArchivedIds.length === 0) return summary;
|
||||
|
||||
const urlPath = `/client/${encodeURIComponent(clientId)}/hostFinding/search`;
|
||||
const chunkSize = 50;
|
||||
|
||||
// Collect all API results across batches
|
||||
const foundMap = new Map();
|
||||
|
||||
for (let i = 0; i < newlyArchivedIds.length; i += chunkSize) {
|
||||
const chunk = newlyArchivedIds.slice(i, i + chunkSize);
|
||||
const idList = chunk.join(',');
|
||||
|
||||
try {
|
||||
const filters = [
|
||||
{
|
||||
field: 'id',
|
||||
exclusive: false,
|
||||
operator: 'IN',
|
||||
orWithPrevious: false,
|
||||
implicitFilters: [],
|
||||
value: idList,
|
||||
caseSensitive: false
|
||||
}
|
||||
];
|
||||
|
||||
let page = 0;
|
||||
let totalPages = 1;
|
||||
|
||||
do {
|
||||
const body = {
|
||||
filters,
|
||||
projection: 'internal',
|
||||
sort: [{ field: 'severity', direction: 'ASC' }],
|
||||
page,
|
||||
size: 100
|
||||
};
|
||||
|
||||
const result = await ivantiPost(urlPath, body, apiKey, skipTls);
|
||||
if (result.status !== 200) {
|
||||
console.error(`[BU Drift Checker] API returned status ${result.status} for batch starting at index ${i}`);
|
||||
break;
|
||||
}
|
||||
|
||||
const data = JSON.parse(result.body);
|
||||
totalPages = data.page?.totalPages || 1;
|
||||
const findings = data._embedded?.hostFindings || [];
|
||||
|
||||
for (const f of findings) {
|
||||
const bu = f.assetCustomAttributes?.['1550_host_1']?.[0] || 'UNKNOWN';
|
||||
const severity = typeof f.severity === 'number' ? f.severity : parseFloat(f.severity) || 0;
|
||||
const state = f.status || f.generic_state || '';
|
||||
foundMap.set(String(f.id), { bu, severity, state });
|
||||
}
|
||||
|
||||
page++;
|
||||
} while (page < totalPages);
|
||||
|
||||
console.log(`[BU Drift Checker] Batch ${Math.floor(i / chunkSize) + 1}: queried ${chunk.length} IDs, found ${foundMap.size} so far`);
|
||||
} catch (err) {
|
||||
console.error(`[BU Drift Checker] Error querying batch at index ${i}:`, err.message);
|
||||
// Skip failed batch, continue with remaining
|
||||
}
|
||||
}
|
||||
|
||||
// Classify each archived finding and update the archive transition reason
|
||||
for (const id of newlyArchivedIds) {
|
||||
const found = foundMap.get(id);
|
||||
let classification;
|
||||
let reason;
|
||||
|
||||
if (!found) {
|
||||
classification = 'decommissioned';
|
||||
reason = 'decommissioned';
|
||||
} else if (!EXPECTED_BUS.has(found.bu)) {
|
||||
classification = 'bu_reassignment';
|
||||
reason = `bu_reassignment:${found.bu}`;
|
||||
} else if (found.severity < 8.5) {
|
||||
classification = 'severity_drift';
|
||||
reason = `severity_drift:${found.severity}`;
|
||||
} else if (found.state === 'Closed') {
|
||||
classification = 'closed_on_platform';
|
||||
reason = 'closed_on_platform';
|
||||
} else {
|
||||
// BU matches, severity >= 8.5, not closed — unexpected, leave as default
|
||||
classification = 'decommissioned';
|
||||
reason = 'decommissioned';
|
||||
}
|
||||
|
||||
summary[classification] = (summary[classification] || 0) + 1;
|
||||
|
||||
// Update the most recent archive transition reason for this finding
|
||||
try {
|
||||
const archive = await dbGet(db,
|
||||
`SELECT id FROM ivanti_finding_archives WHERE finding_id = ?`,
|
||||
[id]
|
||||
);
|
||||
if (archive) {
|
||||
await dbRun(db,
|
||||
`UPDATE ivanti_archive_transitions SET reason = ?
|
||||
WHERE archive_id = ? AND id = (
|
||||
SELECT id FROM ivanti_archive_transitions
|
||||
WHERE archive_id = ? ORDER BY transitioned_at DESC LIMIT 1
|
||||
)`,
|
||||
[reason, archive.id, archive.id]
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[BU Drift Checker] Error updating transition reason for finding ${id}:`, err.message);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[BU Drift Checker] Classification complete:`, summary);
|
||||
return summary;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Anomaly Summary — compute and store post-sync anomaly report
|
||||
// ---------------------------------------------------------------------------
|
||||
async function computeAnomalySummary(db, openCountDelta, closedCountDelta, newlyArchivedCount, returnedCount, classificationBreakdown) {
|
||||
try {
|
||||
const isSignificant = newlyArchivedCount > 5 ? 1 : 0;
|
||||
const classificationJson = JSON.stringify(classificationBreakdown || {});
|
||||
|
||||
await dbRun(db,
|
||||
`INSERT INTO ivanti_sync_anomaly_log
|
||||
(sync_timestamp, open_count_delta, closed_count_delta, newly_archived_count, returned_count, classification_json, is_significant)
|
||||
VALUES (datetime('now'), ?, ?, ?, ?, ?, ?)`,
|
||||
[openCountDelta, closedCountDelta, newlyArchivedCount, returnedCount, classificationJson, isSignificant]
|
||||
);
|
||||
|
||||
console.log(`[Anomaly Summary] Sync anomaly logged — open_delta: ${openCountDelta}, closed_delta: ${closedCountDelta}, archived: ${newlyArchivedCount}, returned: ${returnedCount}, significant: ${!!isSignificant}`);
|
||||
console.log(`[Anomaly Summary] Classification:`, classificationBreakdown);
|
||||
} catch (err) {
|
||||
console.error('[Anomaly Summary] Failed to write anomaly summary (non-fatal):', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Router
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -898,6 +1206,152 @@ function createIvantiFindingsRouter(db, requireAuth) {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/ivanti/findings/anomaly/latest
|
||||
*
|
||||
* Return the most recent anomaly summary row from ivanti_sync_anomaly_log.
|
||||
* The classification_json column is parsed into an object in the response.
|
||||
*
|
||||
* @returns {Object} 200 - { anomaly: Object|null }
|
||||
* @returns {Object} 500 - { error: string } on database error
|
||||
*/
|
||||
router.get('/anomaly/latest', async (req, res) => {
|
||||
try {
|
||||
const row = await dbGet(db,
|
||||
`SELECT id, sync_timestamp, open_count_delta, closed_count_delta,
|
||||
newly_archived_count, returned_count, classification_json, is_significant
|
||||
FROM ivanti_sync_anomaly_log
|
||||
ORDER BY sync_timestamp DESC LIMIT 1`
|
||||
);
|
||||
if (!row) return res.json({ anomaly: null });
|
||||
let classification = {};
|
||||
try { classification = JSON.parse(row.classification_json || '{}'); } catch (_) {}
|
||||
res.json({
|
||||
anomaly: {
|
||||
id: row.id,
|
||||
sync_timestamp: row.sync_timestamp,
|
||||
open_count_delta: row.open_count_delta,
|
||||
closed_count_delta: row.closed_count_delta,
|
||||
newly_archived_count: row.newly_archived_count,
|
||||
returned_count: row.returned_count,
|
||||
classification,
|
||||
is_significant: !!row.is_significant
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[Ivanti Findings] GET /anomaly/latest error:', err.message);
|
||||
res.status(500).json({ error: 'Database error reading latest anomaly' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/ivanti/findings/anomaly/history
|
||||
*
|
||||
* Return anomaly history. Accepts optional `from` and `to` query parameters
|
||||
* (ISO date strings) for date-range filtering (inclusive). If neither is
|
||||
* provided, returns the last 30 rows ordered by sync_timestamp descending.
|
||||
*
|
||||
* @query {string} [from] - Inclusive start date (ISO string)
|
||||
* @query {string} [to] - Inclusive end date (ISO string)
|
||||
*
|
||||
* @returns {Object} 200 - { history: Array<Object> }
|
||||
* @returns {Object} 500 - { error: string } on database error
|
||||
*/
|
||||
router.get('/anomaly/history', async (req, res) => {
|
||||
try {
|
||||
const { from, to } = req.query;
|
||||
let rows;
|
||||
|
||||
if (from && to) {
|
||||
rows = await dbAll(db,
|
||||
`SELECT sync_timestamp, open_count_delta, closed_count_delta,
|
||||
newly_archived_count, returned_count, classification_json, is_significant
|
||||
FROM ivanti_sync_anomaly_log
|
||||
WHERE sync_timestamp >= ? AND sync_timestamp <= ?
|
||||
ORDER BY sync_timestamp DESC`,
|
||||
[from, to]
|
||||
);
|
||||
} else {
|
||||
rows = await dbAll(db,
|
||||
`SELECT sync_timestamp, open_count_delta, closed_count_delta,
|
||||
newly_archived_count, returned_count, classification_json, is_significant
|
||||
FROM ivanti_sync_anomaly_log
|
||||
ORDER BY sync_timestamp DESC LIMIT 30`
|
||||
);
|
||||
}
|
||||
|
||||
const history = rows.map(row => {
|
||||
let classification = {};
|
||||
try { classification = JSON.parse(row.classification_json || '{}'); } catch (_) {}
|
||||
return {
|
||||
sync_timestamp: row.sync_timestamp,
|
||||
open_count_delta: row.open_count_delta,
|
||||
closed_count_delta: row.closed_count_delta,
|
||||
newly_archived_count: row.newly_archived_count,
|
||||
returned_count: row.returned_count,
|
||||
classification,
|
||||
is_significant: !!row.is_significant
|
||||
};
|
||||
});
|
||||
|
||||
res.json({ history });
|
||||
} catch (err) {
|
||||
console.error('[Ivanti Findings] GET /anomaly/history error:', err.message);
|
||||
res.status(500).json({ error: 'Database error reading anomaly history' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/ivanti/findings/bu-changes
|
||||
*
|
||||
* Return all BU change events from ivanti_finding_bu_history,
|
||||
* ordered by detected_at descending (newest first).
|
||||
*
|
||||
* @returns {Object} 200 - { changes: Array<Object> }
|
||||
* @returns {Object} 500 - { error: string } on database error
|
||||
*/
|
||||
router.get('/bu-changes', async (req, res) => {
|
||||
try {
|
||||
const rows = await dbAll(db,
|
||||
`SELECT id, finding_id, finding_title, host_name, previous_bu, new_bu, detected_at
|
||||
FROM ivanti_finding_bu_history
|
||||
ORDER BY detected_at DESC`
|
||||
);
|
||||
res.json({ changes: rows });
|
||||
} catch (err) {
|
||||
console.error('[Ivanti Findings] GET /bu-changes error:', err.message);
|
||||
res.status(500).json({ error: 'Database error reading BU changes' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/ivanti/findings/:findingId/bu-history
|
||||
*
|
||||
* Return BU change history for a specific finding from ivanti_finding_bu_history,
|
||||
* ordered by detected_at descending (newest first).
|
||||
*
|
||||
* @param {string} findingId - The finding identifier (URL param)
|
||||
*
|
||||
* @returns {Object} 200 - { finding_id: string, history: Array<Object> }
|
||||
* @returns {Object} 500 - { error: string } on database error
|
||||
*/
|
||||
router.get('/:findingId/bu-history', async (req, res) => {
|
||||
try {
|
||||
const { findingId } = req.params;
|
||||
const rows = await dbAll(db,
|
||||
`SELECT previous_bu, new_bu, detected_at
|
||||
FROM ivanti_finding_bu_history
|
||||
WHERE finding_id = ?
|
||||
ORDER BY detected_at DESC`,
|
||||
[findingId]
|
||||
);
|
||||
res.json({ finding_id: findingId, history: rows });
|
||||
} catch (err) {
|
||||
console.error('[Ivanti Findings] GET /:findingId/bu-history error:', err.message);
|
||||
res.status(500).json({ error: 'Database error reading finding BU history' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /api/ivanti/findings/:findingId/override
|
||||
*
|
||||
@@ -982,3 +1436,6 @@ module.exports = createIvantiFindingsRouter;
|
||||
module.exports.detectArchiveChanges = detectArchiveChanges;
|
||||
module.exports.detectClosedFindings = detectClosedFindings;
|
||||
module.exports.initArchiveTables = initArchiveTables;
|
||||
module.exports.runBUDriftChecker = runBUDriftChecker;
|
||||
module.exports.computeAnomalySummary = computeAnomalySummary;
|
||||
module.exports.extractFinding = extractFinding;
|
||||
|
||||
270
backend/scripts/bu-reassignment-check.js
Normal file
270
backend/scripts/bu-reassignment-check.js
Normal file
@@ -0,0 +1,270 @@
|
||||
#!/usr/bin/env node
|
||||
// bu-reassignment-check.js — Check if disappeared findings were reassigned to a different BU
|
||||
//
|
||||
// Queries Ivanti for the specific finding IDs that are completely gone from our
|
||||
// BU-filtered results, using NO filters at all (just the finding IDs).
|
||||
// If they come back with a different BU, that confirms BU reassignment.
|
||||
//
|
||||
// Usage: node backend/scripts/bu-reassignment-check.js
|
||||
|
||||
require('dotenv').config({ path: require('path').join(__dirname, '..', '.env') });
|
||||
|
||||
const path = require('path');
|
||||
const sqlite3 = require('sqlite3').verbose();
|
||||
const { ivantiPost } = require('../helpers/ivantiApi');
|
||||
|
||||
const DB_PATH = path.join(__dirname, '..', 'cve_database.db');
|
||||
|
||||
function dbAll(db, sql, params = []) {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.all(sql, params, (err, rows) => {
|
||||
if (err) reject(err);
|
||||
else resolve(rows || []);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function queryByFindingIds(findingIds, apiKey, clientId, skipTls) {
|
||||
const urlPath = `/client/${encodeURIComponent(clientId)}/hostFinding/search`;
|
||||
const allResults = [];
|
||||
|
||||
// Ivanti's IN filter can handle batches — but let's chunk to be safe
|
||||
const chunkSize = 50;
|
||||
for (let i = 0; i < findingIds.length; i += chunkSize) {
|
||||
const chunk = findingIds.slice(i, i + chunkSize);
|
||||
const idList = chunk.join(',');
|
||||
|
||||
// Query with ONLY the finding ID filter — no BU, no severity, no state
|
||||
const filters = [
|
||||
{
|
||||
field: 'id',
|
||||
exclusive: false,
|
||||
operator: 'IN',
|
||||
orWithPrevious: false,
|
||||
implicitFilters: [],
|
||||
value: idList,
|
||||
caseSensitive: false
|
||||
}
|
||||
];
|
||||
|
||||
let page = 0;
|
||||
let totalPages = 1;
|
||||
|
||||
do {
|
||||
const body = {
|
||||
filters,
|
||||
projection: 'internal',
|
||||
sort: [{ field: 'severity', direction: 'ASC' }],
|
||||
page,
|
||||
size: 100
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await ivantiPost(urlPath, body, apiKey, skipTls);
|
||||
if (result.status !== 200) {
|
||||
console.error(` API returned status ${result.status} for chunk starting at ${i}`);
|
||||
break;
|
||||
}
|
||||
|
||||
const data = JSON.parse(result.body);
|
||||
totalPages = data.page?.totalPages || 1;
|
||||
const findings = data._embedded?.hostFindings || [];
|
||||
|
||||
for (const f of findings) {
|
||||
const bu = f.assetCustomAttributes?.['1550_host_1']?.[0] || 'UNKNOWN';
|
||||
allResults.push({
|
||||
id: String(f.id),
|
||||
severity: typeof f.severity === 'number' ? f.severity : parseFloat(f.severity) || 0,
|
||||
title: f.title || '',
|
||||
hostName: f.host?.hostName || '',
|
||||
ipAddress: f.host?.ipAddress || '',
|
||||
state: f.status || f.generic_state || '',
|
||||
bu,
|
||||
// Check for FP workflow
|
||||
fpWorkflow: extractFP(f)
|
||||
});
|
||||
}
|
||||
|
||||
console.error(` Chunk ${Math.floor(i/chunkSize)+1}: page ${page+1}/${totalPages}, ${findings.length} results`);
|
||||
page++;
|
||||
} catch (err) {
|
||||
console.error(` Error querying chunk at ${i}:`, err.message);
|
||||
break;
|
||||
}
|
||||
} while (page < totalPages);
|
||||
}
|
||||
|
||||
return allResults;
|
||||
}
|
||||
|
||||
function extractFP(f) {
|
||||
const wfDist = f.workflowDistribution || {};
|
||||
const fpBuckets = [
|
||||
...(wfDist.approvedWorkflows || []),
|
||||
...(wfDist.actionableWorkflows || []),
|
||||
...(wfDist.requestedWorkflows || []),
|
||||
...(wfDist.reworkedWorkflows || []),
|
||||
...(wfDist.rejectedWorkflows || []),
|
||||
...(wfDist.expiredWorkflows || []),
|
||||
].filter(w => (w.generatedId || '').startsWith('FP#'));
|
||||
const entry = fpBuckets[0];
|
||||
if (!entry) return null;
|
||||
return { id: entry.generatedId, state: entry.state };
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const apiKey = process.env.IVANTI_API_KEY;
|
||||
const clientId = process.env.IVANTI_CLIENT_ID || '1550';
|
||||
const skipTls = process.env.IVANTI_SKIP_TLS === 'true';
|
||||
|
||||
if (!apiKey) {
|
||||
console.error('IVANTI_API_KEY not set');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const db = new sqlite3.Database(DB_PATH);
|
||||
|
||||
// Get the 124 finding IDs that were completely gone from BU-filtered results
|
||||
const goneFindings = await dbAll(db,
|
||||
`SELECT finding_id, last_severity, finding_title, current_state
|
||||
FROM ivanti_finding_archives
|
||||
WHERE current_state IN ('ARCHIVED', 'CLOSED')`
|
||||
);
|
||||
|
||||
const goneIds = goneFindings.map(f => f.finding_id);
|
||||
console.error(`\n=== BU Reassignment Check ===`);
|
||||
console.error(`Querying Ivanti for ${goneIds.length} disappeared finding IDs (no BU/severity/state filter)...\n`);
|
||||
|
||||
const results = await queryByFindingIds(goneIds, apiKey, clientId, skipTls);
|
||||
|
||||
const foundMap = new Map(results.map(r => [r.id, r]));
|
||||
|
||||
// Categorize
|
||||
const reassigned = []; // Found with different BU
|
||||
const sameBU = []; // Found with same BU (STEAM or ACCESS-ENG)
|
||||
const notFound = []; // Still not found even without filters
|
||||
const withFP = []; // Has an FP workflow (any state)
|
||||
|
||||
for (const arch of goneFindings) {
|
||||
const found = foundMap.get(arch.finding_id);
|
||||
if (!found) {
|
||||
notFound.push(arch);
|
||||
} else if (found.bu !== 'NTS-AEO-ACCESS-ENG' && found.bu !== 'NTS-AEO-STEAM') {
|
||||
reassigned.push({ ...arch, currentBU: found.bu, currentSeverity: found.severity, currentState: found.state, fp: found.fpWorkflow });
|
||||
if (found.fpWorkflow) withFP.push({ ...arch, ...found });
|
||||
} else {
|
||||
sameBU.push({ ...arch, currentBU: found.bu, currentSeverity: found.severity, currentState: found.state, fp: found.fpWorkflow });
|
||||
if (found.fpWorkflow) withFP.push({ ...arch, ...found });
|
||||
}
|
||||
}
|
||||
|
||||
console.log('');
|
||||
console.log('='.repeat(130));
|
||||
console.log('BU REASSIGNMENT CHECK RESULTS');
|
||||
console.log('='.repeat(130));
|
||||
|
||||
console.log(`\nREASSIGNED TO DIFFERENT BU: ${reassigned.length} findings`);
|
||||
console.log('-'.repeat(130));
|
||||
if (reassigned.length > 0) {
|
||||
console.log(
|
||||
'Finding ID'.padEnd(14) +
|
||||
'Archive State'.padEnd(15) +
|
||||
'Last Sev'.padEnd(10) +
|
||||
'Current Sev'.padEnd(13) +
|
||||
'Current BU'.padEnd(30) +
|
||||
'FP Workflow'.padEnd(25) +
|
||||
'Title'
|
||||
);
|
||||
console.log('-'.repeat(130));
|
||||
for (const f of reassigned) {
|
||||
const fpStr = f.fp ? `${f.fp.id} (${f.fp.state})` : '-';
|
||||
console.log(
|
||||
f.finding_id.padEnd(14) +
|
||||
f.current_state.padEnd(15) +
|
||||
f.last_severity.toFixed(2).padEnd(10) +
|
||||
f.currentSeverity.toFixed(2).padEnd(13) +
|
||||
f.currentBU.padEnd(30) +
|
||||
fpStr.padEnd(25) +
|
||||
f.finding_title.substring(0, 40)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\nSTILL SAME BU (but missing from filtered results): ${sameBU.length} findings`);
|
||||
console.log('-'.repeat(130));
|
||||
if (sameBU.length > 0) {
|
||||
console.log(
|
||||
'Finding ID'.padEnd(14) +
|
||||
'Archive State'.padEnd(15) +
|
||||
'Last Sev'.padEnd(10) +
|
||||
'Current Sev'.padEnd(13) +
|
||||
'Current BU'.padEnd(30) +
|
||||
'Current State'.padEnd(15) +
|
||||
'Title'
|
||||
);
|
||||
console.log('-'.repeat(130));
|
||||
for (const f of sameBU) {
|
||||
console.log(
|
||||
f.finding_id.padEnd(14) +
|
||||
f.current_state.padEnd(15) +
|
||||
f.last_severity.toFixed(2).padEnd(10) +
|
||||
f.currentSeverity.toFixed(2).padEnd(13) +
|
||||
f.currentBU.padEnd(30) +
|
||||
f.currentState.padEnd(15) +
|
||||
f.finding_title.substring(0, 40)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\nCOMPLETELY GONE (not found even without any filters): ${notFound.length} findings`);
|
||||
if (notFound.length > 0 && notFound.length <= 20) {
|
||||
console.log('-'.repeat(130));
|
||||
for (const f of notFound) {
|
||||
console.log(` ${f.finding_id} ${f.last_severity.toFixed(2)} ${f.finding_title.substring(0, 60)}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (withFP.length > 0) {
|
||||
console.log(`\nFINDINGS WITH FP WORKFLOWS: ${withFP.length}`);
|
||||
console.log('-'.repeat(130));
|
||||
for (const f of withFP) {
|
||||
const fpStr = f.fpWorkflow ? `${f.fpWorkflow.id} (${f.fpWorkflow.state})` : f.fp ? `${f.fp.id} (${f.fp.state})` : '-';
|
||||
console.log(` ${f.finding_id || f.id} ${fpStr} ${f.bu || f.currentBU} ${(f.finding_title || f.title || '').substring(0, 50)}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Summary
|
||||
console.log('');
|
||||
console.log('='.repeat(130));
|
||||
console.log('SUMMARY');
|
||||
console.log('='.repeat(130));
|
||||
console.log(` Total disappeared findings checked: ${goneFindings.length}`);
|
||||
console.log(` Reassigned to different BU: ${reassigned.length}`);
|
||||
console.log(` Still same BU (unexpected): ${sameBU.length}`);
|
||||
console.log(` Completely gone from platform: ${notFound.length}`);
|
||||
console.log(` Have FP workflows: ${withFP.length}`);
|
||||
|
||||
if (reassigned.length > 0) {
|
||||
const buCounts = {};
|
||||
reassigned.forEach(f => { buCounts[f.currentBU] = (buCounts[f.currentBU] || 0) + 1; });
|
||||
console.log('\n BU reassignment breakdown:');
|
||||
for (const [bu, cnt] of Object.entries(buCounts).sort((a, b) => b[1] - a[1])) {
|
||||
console.log(` ${bu}: ${cnt} findings`);
|
||||
}
|
||||
}
|
||||
|
||||
if (reassigned.length > goneFindings.length * 0.5) {
|
||||
console.log('\n VERDICT: BU REASSIGNMENT CONFIRMED.');
|
||||
} else if (notFound.length > goneFindings.length * 0.5) {
|
||||
console.log('\n VERDICT: Findings removed from platform entirely (decommission or data purge).');
|
||||
} else {
|
||||
console.log('\n VERDICT: Mixed causes — review individual categories above.');
|
||||
}
|
||||
|
||||
db.close();
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error('Fatal error:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
275
backend/scripts/drift-check.js
Normal file
275
backend/scripts/drift-check.js
Normal file
@@ -0,0 +1,275 @@
|
||||
#!/usr/bin/env node
|
||||
// drift-check.js — One-time diagnostic to confirm host-level VRR score drift
|
||||
//
|
||||
// Queries Ivanti WITHOUT the severity filter for the same BUs, then cross-
|
||||
// references the results against our archived finding IDs to see if they
|
||||
// still exist at lower severity scores.
|
||||
//
|
||||
// Usage: node backend/scripts/drift-check.js
|
||||
//
|
||||
// Output: prints a comparison table and summary. Does NOT modify cve_database.db
|
||||
// permanently — uses a temporary in-memory table for the comparison.
|
||||
|
||||
require('dotenv').config({ path: require('path').join(__dirname, '..', '.env') });
|
||||
|
||||
const path = require('path');
|
||||
const sqlite3 = require('sqlite3').verbose();
|
||||
const { ivantiPost } = require('../helpers/ivantiApi');
|
||||
|
||||
const DB_PATH = path.join(__dirname, '..', 'cve_database.db');
|
||||
|
||||
// Same BU filter, NO severity filter, NO state filter — get everything
|
||||
const ALL_FINDINGS_FILTERS = [
|
||||
{
|
||||
field: 'assetCustomAttributes.1550_host_1.value',
|
||||
exclusive: false,
|
||||
operator: 'IN',
|
||||
orWithPrevious: false,
|
||||
implicitFilters: [],
|
||||
value: 'NTS-AEO-ACCESS-ENG,NTS-AEO-STEAM',
|
||||
caseSensitive: false
|
||||
}
|
||||
];
|
||||
|
||||
function dbAll(db, sql, params = []) {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.all(sql, params, (err, rows) => {
|
||||
if (err) reject(err);
|
||||
else resolve(rows || []);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function dbRun(db, sql, params = []) {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.run(sql, params, function (err) {
|
||||
if (err) reject(err);
|
||||
else resolve(this);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchAllFindings(apiKey, clientId, skipTls, state) {
|
||||
const urlPath = `/client/${encodeURIComponent(clientId)}/hostFinding/search`;
|
||||
const filters = [
|
||||
...ALL_FINDINGS_FILTERS,
|
||||
{
|
||||
field: 'generic_state',
|
||||
exclusive: false,
|
||||
operator: 'EXACT',
|
||||
orWithPrevious: false,
|
||||
implicitFilters: [],
|
||||
value: state,
|
||||
caseSensitive: false
|
||||
}
|
||||
];
|
||||
|
||||
let allFindings = [];
|
||||
let page = 0;
|
||||
let totalPages = 1;
|
||||
|
||||
do {
|
||||
const body = {
|
||||
filters,
|
||||
projection: 'internal',
|
||||
sort: [{ field: 'severity', direction: 'ASC' }],
|
||||
page,
|
||||
size: 100
|
||||
};
|
||||
|
||||
const result = await ivantiPost(urlPath, body, apiKey, skipTls);
|
||||
if (result.status !== 200) {
|
||||
console.error(` API returned status ${result.status} on page ${page}`);
|
||||
break;
|
||||
}
|
||||
|
||||
const data = JSON.parse(result.body);
|
||||
totalPages = data.page?.totalPages || 1;
|
||||
const findings = data._embedded?.hostFindings || [];
|
||||
|
||||
for (const f of findings) {
|
||||
allFindings.push({
|
||||
id: String(f.id),
|
||||
severity: typeof f.severity === 'number' ? f.severity : parseFloat(f.severity) || 0,
|
||||
title: f.title || '',
|
||||
hostName: f.host?.hostName || '',
|
||||
state
|
||||
});
|
||||
}
|
||||
|
||||
console.error(` ${state} page ${page + 1}/${totalPages} — ${allFindings.length} findings so far`);
|
||||
page++;
|
||||
} while (page < totalPages);
|
||||
|
||||
return allFindings;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const apiKey = process.env.IVANTI_API_KEY;
|
||||
const clientId = process.env.IVANTI_CLIENT_ID || '1550';
|
||||
const skipTls = process.env.IVANTI_SKIP_TLS === 'true';
|
||||
|
||||
if (!apiKey) {
|
||||
console.error('IVANTI_API_KEY not set in backend/.env');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.error('=== Drift Check: Querying Ivanti WITHOUT severity filter ===\n');
|
||||
|
||||
// Fetch all Open findings (no severity filter)
|
||||
console.error('Fetching ALL Open findings (no severity filter)...');
|
||||
const openFindings = await fetchAllFindings(apiKey, clientId, skipTls, 'Open');
|
||||
console.error(` Total Open (all severities): ${openFindings.length}\n`);
|
||||
|
||||
// Fetch all Closed findings (no severity filter)
|
||||
console.error('Fetching ALL Closed findings (no severity filter)...');
|
||||
const closedFindings = await fetchAllFindings(apiKey, clientId, skipTls, 'Closed');
|
||||
console.error(` Total Closed (all severities): ${closedFindings.length}\n`);
|
||||
|
||||
const allFindings = [...openFindings, ...closedFindings];
|
||||
const findingMap = new Map(allFindings.map(f => [f.id, f]));
|
||||
|
||||
console.error(`Total findings across both states: ${allFindings.length}\n`);
|
||||
|
||||
// Open the database and get archived finding IDs
|
||||
const db = new sqlite3.Database(DB_PATH);
|
||||
|
||||
const archived = await dbAll(db,
|
||||
`SELECT finding_id, last_severity, finding_title, host_name, current_state
|
||||
FROM ivanti_finding_archives
|
||||
WHERE current_state IN ('ARCHIVED', 'CLOSED')
|
||||
ORDER BY current_state, last_severity DESC`
|
||||
);
|
||||
|
||||
console.log('');
|
||||
console.log('='.repeat(120));
|
||||
console.log('DRIFT CHECK RESULTS');
|
||||
console.log('='.repeat(120));
|
||||
console.log('');
|
||||
|
||||
// Categorize results
|
||||
const drifted = []; // Found in API at lower severity (below 8.5)
|
||||
const stillHigh = []; // Found in API, severity still >= 8.5
|
||||
const gone = []; // Not found in API at all (any severity)
|
||||
const stateChanged = []; // Found but in different state
|
||||
|
||||
for (const arch of archived) {
|
||||
const current = findingMap.get(arch.finding_id);
|
||||
if (!current) {
|
||||
gone.push(arch);
|
||||
} else if (current.severity < 8.5) {
|
||||
drifted.push({ ...arch, currentSeverity: current.severity, currentState: current.state });
|
||||
} else {
|
||||
stillHigh.push({ ...arch, currentSeverity: current.severity, currentState: current.state });
|
||||
}
|
||||
}
|
||||
|
||||
// Print drifted findings
|
||||
console.log(`CONFIRMED SCORE DRIFT (now below 8.5): ${drifted.length} findings`);
|
||||
console.log('-'.repeat(120));
|
||||
if (drifted.length > 0) {
|
||||
console.log(
|
||||
'Finding ID'.padEnd(14) +
|
||||
'Archive State'.padEnd(15) +
|
||||
'Last Severity'.padEnd(15) +
|
||||
'Current Severity'.padEnd(18) +
|
||||
'Delta'.padEnd(10) +
|
||||
'Current State'.padEnd(15) +
|
||||
'Title'
|
||||
);
|
||||
console.log('-'.repeat(120));
|
||||
for (const f of drifted) {
|
||||
const delta = (f.currentSeverity - f.last_severity).toFixed(2);
|
||||
console.log(
|
||||
f.finding_id.padEnd(14) +
|
||||
f.current_state.padEnd(15) +
|
||||
f.last_severity.toFixed(2).padEnd(15) +
|
||||
f.currentSeverity.toFixed(2).padEnd(18) +
|
||||
delta.padEnd(10) +
|
||||
f.currentState.padEnd(15) +
|
||||
f.finding_title.substring(0, 50)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('');
|
||||
console.log(`STILL HIGH SEVERITY (>= 8.5, should be in filtered results): ${stillHigh.length} findings`);
|
||||
console.log('-'.repeat(120));
|
||||
if (stillHigh.length > 0) {
|
||||
console.log(
|
||||
'Finding ID'.padEnd(14) +
|
||||
'Archive State'.padEnd(15) +
|
||||
'Last Severity'.padEnd(15) +
|
||||
'Current Severity'.padEnd(18) +
|
||||
'Current State'.padEnd(15) +
|
||||
'Title'
|
||||
);
|
||||
console.log('-'.repeat(120));
|
||||
for (const f of stillHigh) {
|
||||
console.log(
|
||||
f.finding_id.padEnd(14) +
|
||||
f.current_state.padEnd(15) +
|
||||
f.last_severity.toFixed(2).padEnd(15) +
|
||||
f.currentSeverity.toFixed(2).padEnd(18) +
|
||||
f.currentState.padEnd(15) +
|
||||
f.finding_title.substring(0, 50)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('');
|
||||
console.log(`COMPLETELY GONE (not in API at any severity): ${gone.length} findings`);
|
||||
console.log('-'.repeat(120));
|
||||
if (gone.length > 0) {
|
||||
console.log(
|
||||
'Finding ID'.padEnd(14) +
|
||||
'Archive State'.padEnd(15) +
|
||||
'Last Severity'.padEnd(15) +
|
||||
'Title'
|
||||
);
|
||||
console.log('-'.repeat(120));
|
||||
for (const f of gone) {
|
||||
console.log(
|
||||
f.finding_id.padEnd(14) +
|
||||
f.current_state.padEnd(15) +
|
||||
f.last_severity.toFixed(2).padEnd(15) +
|
||||
f.finding_title.substring(0, 50)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Summary
|
||||
console.log('');
|
||||
console.log('='.repeat(120));
|
||||
console.log('SUMMARY');
|
||||
console.log('='.repeat(120));
|
||||
console.log(` Archived/Closed findings checked: ${archived.length}`);
|
||||
console.log(` Confirmed score drift (< 8.5): ${drifted.length}`);
|
||||
console.log(` Still high severity (>= 8.5): ${stillHigh.length}`);
|
||||
console.log(` Completely gone from API: ${gone.length}`);
|
||||
console.log('');
|
||||
|
||||
if (drifted.length > 0) {
|
||||
const avgDelta = drifted.reduce((sum, f) => sum + (f.currentSeverity - f.last_severity), 0) / drifted.length;
|
||||
const minNew = Math.min(...drifted.map(f => f.currentSeverity));
|
||||
const maxNew = Math.max(...drifted.map(f => f.currentSeverity));
|
||||
console.log(` Score drift range: ${minNew.toFixed(2)} – ${maxNew.toFixed(2)} (avg delta: ${avgDelta.toFixed(2)})`);
|
||||
}
|
||||
|
||||
if (drifted.length > archived.length * 0.5) {
|
||||
console.log('\n VERDICT: Host-level VRR score drift CONFIRMED.');
|
||||
console.log(' The majority of disappeared findings still exist in Ivanti but at lower severity scores.');
|
||||
} else if (drifted.length > 0) {
|
||||
console.log('\n VERDICT: Partial score drift detected. Some findings drifted, others may have been removed.');
|
||||
} else if (gone.length > archived.length * 0.5) {
|
||||
console.log('\n VERDICT: Score drift NOT confirmed. Most findings are completely gone from the API.');
|
||||
console.log(' This suggests BU reassignment, host decommission, or a platform-side data issue.');
|
||||
}
|
||||
|
||||
db.close();
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error('Fatal error:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
197
backend/scripts/export-reassigned-findings.js
Normal file
197
backend/scripts/export-reassigned-findings.js
Normal file
@@ -0,0 +1,197 @@
|
||||
#!/usr/bin/env node
|
||||
// export-reassigned-findings.js — Generate an xlsx with findings reassigned to SDIT-CSD-ITLS-PIES
|
||||
//
|
||||
// Pulls data from the archive database and the BU reassignment check results.
|
||||
// Outputs to docs/reassigned-findings-2026-04-24.xlsx
|
||||
//
|
||||
// Usage: node backend/scripts/export-reassigned-findings.js
|
||||
|
||||
require('dotenv').config({ path: require('path').join(__dirname, '..', '.env') });
|
||||
|
||||
const path = require('path');
|
||||
const sqlite3 = require('sqlite3').verbose();
|
||||
const XLSX = require('xlsx');
|
||||
const { ivantiPost } = require('../helpers/ivantiApi');
|
||||
|
||||
const DB_PATH = path.join(__dirname, '..', 'cve_database.db');
|
||||
const OUTPUT_PATH = path.join(__dirname, '..', '..', 'docs', 'reassigned-findings-2026-04-24.xlsx');
|
||||
|
||||
function dbAll(db, sql, params = []) {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.all(sql, params, (err, rows) => {
|
||||
if (err) reject(err);
|
||||
else resolve(rows || []);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function queryByFindingIds(findingIds, apiKey, clientId, skipTls) {
|
||||
const urlPath = `/client/${encodeURIComponent(clientId)}/hostFinding/search`;
|
||||
const results = new Map();
|
||||
const chunkSize = 50;
|
||||
|
||||
for (let i = 0; i < findingIds.length; i += chunkSize) {
|
||||
const chunk = findingIds.slice(i, i + chunkSize);
|
||||
const idList = chunk.join(',');
|
||||
const filters = [{
|
||||
field: 'id', exclusive: false, operator: 'IN',
|
||||
orWithPrevious: false, implicitFilters: [],
|
||||
value: idList, caseSensitive: false
|
||||
}];
|
||||
|
||||
let page = 0;
|
||||
let totalPages = 1;
|
||||
do {
|
||||
try {
|
||||
const body = { filters, projection: 'internal', sort: [{ field: 'severity', direction: 'ASC' }], page, size: 100 };
|
||||
const result = await ivantiPost(urlPath, body, apiKey, skipTls);
|
||||
if (result.status !== 200) break;
|
||||
const data = JSON.parse(result.body);
|
||||
totalPages = data.page?.totalPages || 1;
|
||||
const findings = data._embedded?.hostFindings || [];
|
||||
for (const f of findings) {
|
||||
const bu = f.assetCustomAttributes?.['1550_host_1']?.[0] || 'UNKNOWN';
|
||||
const wfDist = f.workflowDistribution || {};
|
||||
const fpBuckets = [
|
||||
...(wfDist.approvedWorkflows || []), ...(wfDist.actionableWorkflows || []),
|
||||
...(wfDist.requestedWorkflows || []), ...(wfDist.reworkedWorkflows || []),
|
||||
...(wfDist.rejectedWorkflows || []), ...(wfDist.expiredWorkflows || []),
|
||||
].filter(w => (w.generatedId || '').startsWith('FP#'));
|
||||
const fp = fpBuckets[0] || null;
|
||||
results.set(String(f.id), {
|
||||
bu,
|
||||
severity: typeof f.severity === 'number' ? f.severity : parseFloat(f.severity) || 0,
|
||||
state: f.status || '',
|
||||
fpId: fp ? fp.generatedId : '',
|
||||
fpState: fp ? fp.state : '',
|
||||
hostName: f.host?.hostName || '',
|
||||
ipAddress: f.host?.ipAddress || '',
|
||||
title: f.title || '',
|
||||
});
|
||||
}
|
||||
page++;
|
||||
} catch (err) {
|
||||
console.error(` Error on batch at ${i}:`, err.message);
|
||||
break;
|
||||
}
|
||||
} while (page < totalPages);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const apiKey = process.env.IVANTI_API_KEY;
|
||||
const clientId = process.env.IVANTI_CLIENT_ID || '1550';
|
||||
const skipTls = process.env.IVANTI_SKIP_TLS === 'true';
|
||||
|
||||
const db = new sqlite3.Database(DB_PATH);
|
||||
|
||||
// Get all archived/closed findings from the archive
|
||||
const archived = await dbAll(db,
|
||||
`SELECT finding_id, last_severity, finding_title, host_name, ip_address, current_state,
|
||||
DATE(first_archived_at) as archived_date, DATE(last_transition_at) as last_transition_date
|
||||
FROM ivanti_finding_archives
|
||||
WHERE current_state IN ('ARCHIVED', 'CLOSED')
|
||||
ORDER BY current_state, last_severity DESC`
|
||||
);
|
||||
|
||||
const ids = archived.map(a => a.finding_id);
|
||||
console.log(`Querying Ivanti for ${ids.length} findings...`);
|
||||
const currentData = await queryByFindingIds(ids, apiKey, clientId, skipTls);
|
||||
|
||||
// Build rows for each sheet
|
||||
const reassignedRows = [];
|
||||
const goneRows = [];
|
||||
const sameBuRows = [];
|
||||
|
||||
for (const arch of archived) {
|
||||
const current = currentData.get(arch.finding_id);
|
||||
|
||||
if (!current) {
|
||||
goneRows.push({
|
||||
'Finding ID': arch.finding_id,
|
||||
'Title': arch.finding_title,
|
||||
'Last Severity': arch.last_severity,
|
||||
'Host': arch.host_name,
|
||||
'IP Address': arch.ip_address,
|
||||
'Archive State': arch.current_state,
|
||||
'Archived Date': arch.archived_date,
|
||||
'Status': 'Gone from platform',
|
||||
});
|
||||
} else if (current.bu !== 'NTS-AEO-ACCESS-ENG' && current.bu !== 'NTS-AEO-STEAM') {
|
||||
reassignedRows.push({
|
||||
'Finding ID': arch.finding_id,
|
||||
'Title': current.title || arch.finding_title,
|
||||
'Last Severity (STEAM)': arch.last_severity,
|
||||
'Current Severity': current.severity,
|
||||
'Host': current.hostName || arch.host_name,
|
||||
'IP Address': current.ipAddress || arch.ip_address,
|
||||
'Previous BU': 'NTS-AEO-STEAM / ACCESS-ENG',
|
||||
'Current BU': current.bu,
|
||||
'Current State': current.state,
|
||||
'FP Workflow': current.fpId ? `${current.fpId} (${current.fpState})` : '',
|
||||
'Archive State': arch.current_state,
|
||||
'Archived Date': arch.archived_date,
|
||||
});
|
||||
} else {
|
||||
sameBuRows.push({
|
||||
'Finding ID': arch.finding_id,
|
||||
'Title': current.title || arch.finding_title,
|
||||
'Severity': current.severity,
|
||||
'Host': current.hostName || arch.host_name,
|
||||
'IP Address': current.ipAddress || arch.ip_address,
|
||||
'BU': current.bu,
|
||||
'Current State': current.state,
|
||||
'FP Workflow': current.fpId ? `${current.fpId} (${current.fpState})` : '',
|
||||
'Archive State': arch.current_state,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Create workbook
|
||||
const wb = XLSX.utils.book_new();
|
||||
|
||||
// Sheet 1: Reassigned findings
|
||||
const ws1 = XLSX.utils.json_to_sheet(reassignedRows);
|
||||
// Set column widths
|
||||
ws1['!cols'] = [
|
||||
{ wch: 14 }, { wch: 55 }, { wch: 18 }, { wch: 16 },
|
||||
{ wch: 30 }, { wch: 16 }, { wch: 28 }, { wch: 24 },
|
||||
{ wch: 14 }, { wch: 24 }, { wch: 14 }, { wch: 14 },
|
||||
];
|
||||
XLSX.utils.book_append_sheet(wb, ws1, 'Reassigned to SDIT-PIES');
|
||||
|
||||
// Sheet 2: Gone from platform
|
||||
if (goneRows.length > 0) {
|
||||
const ws2 = XLSX.utils.json_to_sheet(goneRows);
|
||||
ws2['!cols'] = [
|
||||
{ wch: 14 }, { wch: 55 }, { wch: 14 }, { wch: 30 },
|
||||
{ wch: 16 }, { wch: 14 }, { wch: 14 }, { wch: 20 },
|
||||
];
|
||||
XLSX.utils.book_append_sheet(wb, ws2, 'Gone from Platform');
|
||||
}
|
||||
|
||||
// Sheet 3: Still same BU
|
||||
if (sameBuRows.length > 0) {
|
||||
const ws3 = XLSX.utils.json_to_sheet(sameBuRows);
|
||||
ws3['!cols'] = [
|
||||
{ wch: 14 }, { wch: 55 }, { wch: 10 }, { wch: 30 },
|
||||
{ wch: 16 }, { wch: 24 }, { wch: 14 }, { wch: 24 }, { wch: 14 },
|
||||
];
|
||||
XLSX.utils.book_append_sheet(wb, ws3, 'Still Same BU');
|
||||
}
|
||||
|
||||
// Write file
|
||||
XLSX.writeFile(wb, OUTPUT_PATH);
|
||||
console.log(`\nExported to: ${OUTPUT_PATH}`);
|
||||
console.log(` Reassigned: ${reassignedRows.length}`);
|
||||
console.log(` Gone: ${goneRows.length}`);
|
||||
console.log(` Same BU: ${sameBuRows.length}`);
|
||||
|
||||
db.close();
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error('Fatal error:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
416
docs/findings-count-investigation-2026-04-24.md
Normal file
416
docs/findings-count-investigation-2026-04-24.md
Normal file
@@ -0,0 +1,416 @@
|
||||
# Findings Count Drop Investigation — 2026-04-24
|
||||
|
||||
## Summary
|
||||
|
||||
On 2026-04-24, the Findings Trend chart showed a sharp drop in both open and closed counts. The total (open + closed) fell from ~170 to 31, which is inconsistent with normal finding lifecycle behavior where findings move between open and closed but the total remains roughly stable.
|
||||
|
||||
---
|
||||
|
||||
## Timeline
|
||||
|
||||
| Date | Open | Closed | Total | Notes |
|
||||
|---|---|---|---|---|
|
||||
| 04/02 | 127 | 52 | 179 | Baseline |
|
||||
| 04/11 | 116 | 51 | 167 | Normal fluctuation |
|
||||
| 04/19 | 114 | 50 | 164 | Normal fluctuation |
|
||||
| 04/20 | 86 | 84 | 170 | Batch of findings closed — total stable |
|
||||
| 04/23 | 60 | 110 | 170 | Continued closure — total stable |
|
||||
| 04/24 | 15 | 16 | 31 | Anomalous drop |
|
||||
|
||||
The 04/20 and 04/23 snapshots show the expected pattern: open decreases, closed increases, total stays at ~170. The 04/24 snapshot breaks this pattern — both open and closed dropped simultaneously.
|
||||
|
||||
---
|
||||
|
||||
## Root Cause Analysis
|
||||
|
||||
### What the dashboard queries
|
||||
|
||||
The Ivanti sync fetches findings using two API calls, both filtered to:
|
||||
|
||||
- **BU:** `NTS-AEO-ACCESS-ENG`, `NTS-AEO-STEAM`
|
||||
- **Severity range:** `8.5–9.9` VRR
|
||||
- **State:** `Open` (first call) or `Closed` (second call)
|
||||
|
||||
Any finding that no longer matches all three criteria will not appear in the results.
|
||||
|
||||
### What happened
|
||||
|
||||
A re-test of the Ivanti API on 04/24 confirmed the API itself is returning only 15 open and 16 closed findings (`totalElements` field). This is not a pagination bug or partial response — the API is reporting these as the complete result sets.
|
||||
|
||||
### Likely explanation: VRR rescore
|
||||
|
||||
The most probable cause is a bulk VRR (Vulnerability Risk Rating) rescore on the Ivanti platform. If Ivanti recalculated severity scores and a large number of findings dropped below the `8.5` threshold, they would vanish from both the open and closed query results.
|
||||
|
||||
**Key detail:** The archive table stores `last_severity` — the score at the time the finding was last seen in our sync, not the current score in Ivanti. Archived findings show severities of 9.0–9.9, but this reflects their pre-rescore values. After a rescore, these same findings could now be rated below 8.5, which is why they no longer appear in our filtered queries.
|
||||
|
||||
This explains why:
|
||||
|
||||
- **Open findings dropped** from 60 to 15 — rescored findings fell below 8.5
|
||||
- **Closed findings dropped** from 110 to 16 — the same rescore affected closed findings too
|
||||
- **Archive caught 67 disappearances** from the open set, but did not previously track disappearances from the closed set
|
||||
|
||||
### Alternative explanations
|
||||
|
||||
- **BU reassignment:** Findings moved out of `NTS-AEO-ACCESS-ENG` or `NTS-AEO-STEAM` would also disappear. Less likely at this scale.
|
||||
- **Ivanti platform issue:** Temporary data availability problem. Can be ruled out if the counts remain low on subsequent syncs.
|
||||
- **Finding decommission:** Hosts removed from Ivanti entirely. Possible for some findings but unlikely for ~140 at once.
|
||||
|
||||
---
|
||||
|
||||
## Accounting
|
||||
|
||||
As of 04/24:
|
||||
|
||||
| Category | Count | Description |
|
||||
|---|---|---|
|
||||
| Open (API) | 15 | Currently in Ivanti open set, severity 8.5–9.9 |
|
||||
| Closed (API) | 16 | Currently in Ivanti closed set, severity 8.5–9.9 |
|
||||
| Archived | 67 | Disappeared from open set, not found in closed set |
|
||||
| Archive-Closed | 63 | Were archived, then confirmed in Ivanti closed set |
|
||||
| Returned | 1 | Was archived, then reappeared in open set |
|
||||
| **Tracked total** | **162** | |
|
||||
| **Expected total** | **~170** | |
|
||||
| **Unaccounted** | **~8** | Normal churn (decommissions, new findings offsetting) |
|
||||
|
||||
The 63 archive-closed findings were previously part of the ~110 closed count on 04/23. They have since disappeared from the closed API results (likely rescored below 8.5). Before this fix, disappearances from the closed set were not tracked.
|
||||
|
||||
---
|
||||
|
||||
## Fixes Applied
|
||||
|
||||
### 1. Bad data point removed
|
||||
|
||||
The 04/24 history row (15 open / 16 closed) was deleted from `ivanti_counts_history` to prevent it from skewing the trend chart.
|
||||
|
||||
### 2. Drift guard added
|
||||
|
||||
Before writing to `ivanti_counts_history`, the sync now compares the new total (open + closed) against the most recent history entry. If the new total drops below 50% of the previous total, the history write is skipped and a warning is logged. The live cache (`ivanti_counts_cache`) is still updated so current counts remain accurate.
|
||||
|
||||
### 3. Closed-set disappearance tracking (CLOSED_GONE)
|
||||
|
||||
A new archive state `CLOSED_GONE` was added. On each sync, findings previously marked as `CLOSED` in the archive are checked against the current closed API results. If a finding is no longer in the closed set, it transitions to `CLOSED_GONE` with reason `disappeared_from_closed_set`. This closes the visibility gap where findings could vanish from the closed API results without being tracked.
|
||||
|
||||
**Migration required:** `node backend/migrations/add_closed_gone_state.js`
|
||||
|
||||
### Archive state machine (updated)
|
||||
|
||||
```
|
||||
NONE ──→ ARCHIVED ──→ RETURNED ──→ ARCHIVED (cycle)
|
||||
│ │
|
||||
▼ ▼
|
||||
CLOSED ──→ CLOSED_GONE
|
||||
```
|
||||
|
||||
| State | Meaning |
|
||||
|---|---|
|
||||
| `ARCHIVED` | Disappeared from the open findings set; not found in closed set |
|
||||
| `RETURNED` | Was archived but reappeared in the open set |
|
||||
| `CLOSED` | Confirmed present in the Ivanti closed findings set |
|
||||
| `CLOSED_GONE` | Was confirmed closed, then disappeared from the closed set |
|
||||
|
||||
---
|
||||
|
||||
## Recommended Follow-Up
|
||||
|
||||
1. **Check with Ivanti platform team** whether a bulk VRR rescore occurred around 04/23–04/24.
|
||||
2. **Monitor the next few syncs** to see if counts stabilize at the new level or recover.
|
||||
3. **Consider querying without the severity filter** as a one-time diagnostic to see the true total of findings across all severities for the two BUs. This would confirm whether the findings still exist at lower severity scores.
|
||||
|
||||
---
|
||||
|
||||
## Appendix: Cached Data Analysis
|
||||
|
||||
A cross-reference of the 04/22 findings export against the current cached data and archive was performed to test the score drift hypothesis.
|
||||
|
||||
### Export reconciliation (04/22 export — ~80 open findings)
|
||||
|
||||
| Current status | Count |
|
||||
|---|---|
|
||||
| Still open in API | 8 |
|
||||
| Archived (disappeared from open set) | 44 |
|
||||
| Closed (confirmed in Ivanti closed set) | 26 |
|
||||
| Untracked | 0 |
|
||||
| **Total** | **78** |
|
||||
|
||||
Every finding from the export is accounted for. Zero findings are untracked.
|
||||
|
||||
### What disappeared on 04/24 (43 findings archived that day)
|
||||
|
||||
| Vulnerability | Count | Last-seen severity |
|
||||
|---|---|---|
|
||||
| OpenSSH regreSSHion (CVE-2024-6387) | 36 | 9.38 |
|
||||
| OpenSSH Multiple Security Vulnerabilities | 3 | 9.9 |
|
||||
| Rocky Linux sudo update (RLSA-2025:9978) | 2 | 9.06 |
|
||||
| Rocky Linux sqlite update (RLSA-2025:20936) | 1 | 9.9 |
|
||||
| Rocky Linux sqlite update (RLSA-2025:11992) | 1 | 9.9 |
|
||||
|
||||
### What survived (15 findings still in API)
|
||||
|
||||
| Vulnerability | Count | Current severity |
|
||||
|---|---|---|
|
||||
| OpenSSH Multiple Security Vulnerabilities | 9 | 9.9 |
|
||||
| OpenSSH regreSSHion (CVE-2024-6387) | 4 | 9.38 |
|
||||
| OpenSSH 7.4 Not Installed Multiple Vulnerabilities | 1 | 9.18 |
|
||||
| Rocky Linux sudo update (RLSA-2025:9978) | 1 | 9.06 |
|
||||
|
||||
### Conclusion: host-level VRR drift
|
||||
|
||||
The pattern is consistent with **host-level VRR score drift**, not a blanket CVE rescore. Key evidence:
|
||||
|
||||
- **Selective disappearance within the same CVE:** 36 of 40 regreSSHion findings disappeared, but 4 survived at the same last-seen severity (9.38). If the CVE itself were rescored, all would be affected equally.
|
||||
- **Same pattern for OpenSSH Multiple:** 3 of 12 disappeared at 9.9, while 9 survived at 9.9.
|
||||
- **High last-seen severities:** All disappeared findings had severities well above the 8.5 threshold (9.06–9.9), but `last_severity` reflects the score at time of last sync, not the current Ivanti score. A host-level rescore could move individual findings below 8.5 while leaving others on different hosts unchanged.
|
||||
|
||||
Ivanti calculates VRR per host-finding combination using factors like network exposure, asset criticality, and compensating controls. A platform-side recalculation of these host-level factors would produce exactly this pattern — some hosts for the same CVE drop below threshold while others remain above it.
|
||||
|
||||
**To fully confirm:** Query Ivanti without the severity filter for the disappeared finding IDs and check their current VRR scores. If they now show scores below 8.5, host-level drift is confirmed.
|
||||
|
||||
---
|
||||
|
||||
## Appendix B: Unfiltered API Query Results (04/24)
|
||||
|
||||
A follow-up diagnostic queried Ivanti **without the severity filter** to check whether the disappeared findings still exist at lower severity scores.
|
||||
|
||||
### Unfiltered totals
|
||||
|
||||
| State | Count (no severity filter) | Count (8.5–9.9 filter) |
|
||||
|---|---|---|
|
||||
| Open | 1,404 | 15 |
|
||||
| Closed | 280 | 16 |
|
||||
| **Total** | **1,684** | **31** |
|
||||
|
||||
The BUs have 1,684 total findings across all severities. The severity filter narrows this to 31.
|
||||
|
||||
### Cross-reference against 130 archived/closed findings
|
||||
|
||||
| Category | Count | Meaning |
|
||||
|---|---|---|
|
||||
| Completely gone from API | 124 | Not in Ivanti at any severity, open or closed |
|
||||
| Confirmed score drift | 1 | Juniper finding dropped from 9.0 to 7.57 |
|
||||
| Still high severity (>= 8.5) | 5 | Still in Ivanti closed set at original scores |
|
||||
|
||||
### Verdict: Score drift hypothesis DISPROVED
|
||||
|
||||
Only 1 of 130 findings actually drifted below the severity threshold. **124 findings are completely absent from the Ivanti API at any severity in any state.** They were not rescored — they were removed from the platform entirely.
|
||||
|
||||
This rules out VRR score drift as the primary cause and points to one of:
|
||||
|
||||
- **Host decommission / asset removal** — the hosts were removed from Ivanti's asset inventory
|
||||
- **BU reassignment** — the hosts were moved out of `NTS-AEO-ACCESS-ENG` / `NTS-AEO-STEAM` to a different business unit
|
||||
- **Platform-side data cleanup** — findings were purged or merged on the Ivanti side
|
||||
|
||||
Given the scale (124 findings disappearing simultaneously), a bulk operation on the Ivanti platform is the most likely explanation. This should be raised with the Ivanti platform administrators to determine what changed.
|
||||
|
||||
### 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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Appendix C: BU Reassignment Confirmation (04/24)
|
||||
|
||||
A follow-up query searched for the disappeared finding IDs with **no filters at all** (no BU, no severity, no state) to determine whether the findings still exist in Ivanti under a different business unit.
|
||||
|
||||
### Results
|
||||
|
||||
| Category | Count | Detail |
|
||||
|---|---|---|
|
||||
| Reassigned to `SDIT-CSD-ITLS-PIES` | 109 | Hosts moved to different BU |
|
||||
| Still same BU (STEAM/ACCESS-ENG) | 6 | 5 closed (timing), 1 severity drift (7.57) |
|
||||
| Completely gone from platform | 15 | Not found at any BU, severity, or state |
|
||||
|
||||
### Verdict: BU REASSIGNMENT CONFIRMED
|
||||
|
||||
**109 of 130 disappeared findings were reassigned from `NTS-AEO-STEAM` / `NTS-AEO-ACCESS-ENG` to `SDIT-CSD-ITLS-PIES`.** The severity scores are unchanged — the findings still exist at 9.38 and 9.9 — but they no longer match the dashboard's BU filter.
|
||||
|
||||
This is not score drift, not a platform bug, and not a data purge. It is a deliberate (or accidental) bulk BU reassignment on the Ivanti platform.
|
||||
|
||||
### FP workflow impact
|
||||
|
||||
69 of the 109 reassigned findings have FP workflows attached, predominantly `FP#0000459 (Approved)`. These are false positive approvals that were submitted by the STEAM/ACCESS-ENG team. The FP workflows followed the findings to the new BU. This should be reviewed with the team that performed the reassignment to determine whether the FP approvals are still valid under the new BU context.
|
||||
|
||||
### 15 truly gone findings
|
||||
|
||||
15 findings are not found in Ivanti at any BU, severity, or state. These are likely decommissioned hosts. All 15 are `OpenSSH Remote Unauthenticated Code Execution Vulnerability (regreSSHion)` at severity 9.30–9.38.
|
||||
|
||||
### Reassigned findings — 109 findings moved to `SDIT-CSD-ITLS-PIES`
|
||||
|
||||
**With approved FP workflows (58 findings):**
|
||||
|
||||
| Finding ID | Severity | FP Workflow | Host | IP Address |
|
||||
|---|---|---|---|---|
|
||||
| `2687687777` | 9.38 | FP#0000459 (Approved) | syn-098-120-000-078 | 98.120.0.78 |
|
||||
| `2687714078` | 9.38 | FP#0000459 (Approved) | syn-098-120-032-185 | 98.120.32.185 |
|
||||
| `2561784254` | 9.38 | FP#0000459 (Approved) | mon15-agg-sw | 10.240.78.177 |
|
||||
| `2561788625` | 9.38 | FP#0000459 (Approved) | mon16-agg-sw | 10.240.78.176 |
|
||||
| `2689641701` | 9.38 | FP#0000459 (Approved) | mon15-sw14 | 10.240.78.133 |
|
||||
| `2689642036` | 9.38 | FP#0000459 (Approved) | mon15-sw11 | 10.240.78.130 |
|
||||
| `2689642107` | 9.38 | FP#0000459 (Approved) | mon19-sw3 | 10.240.78.150 |
|
||||
| `2689642299` | 9.38 | FP#0000459 (Approved) | mon16-sw2 | 10.240.78.107 |
|
||||
| `2689643552` | 9.38 | FP#0000459 (Approved) | mon16-sw5 | 10.240.78.110 |
|
||||
| `2689645817` | 9.38 | FP#0000459 (Approved) | mon16-sw1 | 10.240.78.106 |
|
||||
| `2689646279` | 9.38 | FP#0000459 (Approved) | mon19-sw2 | 10.240.78.149 |
|
||||
| `2689647223` | 9.38 | FP#0000459 (Approved) | mon19-sw7 | 10.240.78.154 |
|
||||
| `2689647732` | 9.38 | FP#0000459 (Approved) | mon16-sw6 | 10.240.78.111 |
|
||||
| `2689662078` | 9.38 | FP#0000459 (Approved) | mon19-sw6 | 10.240.78.153 |
|
||||
| `2689662169` | 9.38 | FP#0000459 (Approved) | mon15-sw13 | 10.240.78.132 |
|
||||
| `2689667727` | 9.38 | FP#0000459 (Approved) | mon16-sw10 | 10.240.78.115 |
|
||||
| `2689674347` | 9.38 | FP#0000459 (Approved) | mon16-sw4 | 10.240.78.109 |
|
||||
| `2689680179` | 9.38 | FP#0000459 (Approved) | mon16-sw7 | 10.240.78.112 |
|
||||
| `2689687694` | 9.38 | FP#0000459 (Approved) | mon16-sw14 | 10.240.78.119 |
|
||||
| `2689703211` | 9.38 | FP#0000459 (Approved) | mon16-sw9 | 10.240.78.114 |
|
||||
| `2689704574` | 9.38 | FP#0000459 (Approved) | mon16-sw13 | 10.240.78.118 |
|
||||
| `2689707099` | 9.38 | FP#0000459 (Approved) | mon16-sw12 | 10.240.78.117 |
|
||||
| `2689711822` | 9.38 | FP#0000459 (Approved) | mon16-sw3 | 10.240.78.108 |
|
||||
| `2689712725` | 9.38 | FP#0000459 (Approved) | mon19-sw8 | 10.240.78.155 |
|
||||
| `2689715642` | 9.38 | FP#0000459 (Approved) | mon19-sw10 | 10.240.78.157 |
|
||||
| `2689717728` | 9.38 | FP#0000459 (Approved) | mon19-sw4 | 10.240.78.151 |
|
||||
| `2689721708` | 9.38 | FP#0000459 (Approved) | mon16-sw11 | 10.240.78.116 |
|
||||
| `2689722995` | 9.38 | FP#0000459 (Approved) | mon19-sw5 | 10.240.78.152 |
|
||||
| `2689723147` | 9.38 | FP#0000459 (Approved) | mon19-sw14 | 10.240.78.161 |
|
||||
| `2689723478` | 9.38 | FP#0000459 (Approved) | mon19-sw13 | 10.240.78.160 |
|
||||
| `2689723840` | 9.38 | FP#0000459 (Approved) | mon19-sw12 | 10.240.78.159 |
|
||||
| `2697106042` | 9.38 | FP#0000459 (Approved) | mon19-sw11 | 10.240.78.158 |
|
||||
| `2697107537` | 9.38 | FP#0000459 (Approved) | mon15-sw4 | 10.240.78.123 |
|
||||
| `2697108314` | 9.38 | FP#0000459 (Approved) | mon20-sw4 | 10.240.78.137 |
|
||||
| `2726771499` | 9.38 | FP#0000459 (Approved) | mon19-sw1 | 10.240.78.148 |
|
||||
| `2726805076` | 9.38 | FP#0000459 (Approved) | mon15-sw6 | 10.240.78.125 |
|
||||
| `2726863413` | 9.38 | FP#0000459 (Approved) | mon19-sw9 | 10.240.78.156 |
|
||||
| `2283414173` | 9.38 | FP#0000459 (Approved) | | 10.241.0.63 |
|
||||
| `2283664248` | 9.38 | FP#0000459 (Approved) | apc01se1shcc-n01-bmc | 10.244.11.51 |
|
||||
| `2460786621` | 9.38 | FP#0000459 (Approved) | | 172.27.72.1 |
|
||||
| `2521773008` | 9.38 | FP#0000459 (Approved) | | 96.37.185.145 |
|
||||
| `2663675680` | 9.38 | FP#0000459 (Approved) | mon17-sw9 | 10.240.78.170 |
|
||||
| `2663676188` | 9.38 | FP#0000459 (Approved) | mon17-sw11 | 10.240.78.172 |
|
||||
| `2663676366` | 9.38 | FP#0000459 (Approved) | mon17-sw8 | 10.240.78.169 |
|
||||
| `2663676895` | 9.38 | FP#0000459 (Approved) | mon17-sw5 | 10.240.78.166 |
|
||||
| `2663677778` | 9.38 | FP#0000459 (Approved) | mon17-sw13 | 10.240.78.174 |
|
||||
| `2663677987` | 9.38 | FP#0000459 (Approved) | mon17-sw12 | 10.240.78.173 |
|
||||
| `2663681315` | 9.38 | FP#0000459 (Approved) | mon17-sw6 | 10.240.78.167 |
|
||||
| `2663683699` | 9.38 | FP#0000459 (Approved) | mon17-sw14 | 10.240.78.175 |
|
||||
| `2663685466` | 9.38 | FP#0000459 (Approved) | mon17-sw7 | 10.240.78.168 |
|
||||
| `2663695383` | 9.38 | FP#0000459 (Approved) | mon17-sw10 | 10.240.78.171 |
|
||||
| `2744240319` | 9.38 | FP#0000459 (Approved) | syn-066-061-128-010 | 66.61.128.10 |
|
||||
| `2744252609` | 9.38 | FP#0000459 (Approved) | apa01se1shcc-bvi101-secondary | 66.61.128.233 |
|
||||
| `2744261786` | 9.38 | FP#0000459 (Approved) | syn-066-061-128-049 | 66.61.128.49 |
|
||||
| `2744295544` | 9.38 | FP#0000459 (Approved) | syn-066-061-128-018 | 66.61.128.18 |
|
||||
| `2312013545` | 9.90 | FP#0000459 (Approved) | | 10.244.4.26 |
|
||||
| `2329805541` | 9.90 | FP#0000459 (Approved) | | 10.244.11.5 |
|
||||
| `2329818159` | 9.90 | FP#0000459 (Approved) | | 10.244.11.6 |
|
||||
|
||||
**With rejected FP workflows (8 findings):**
|
||||
|
||||
| Finding ID | Severity | FP Workflow | Host | IP Address |
|
||||
|---|---|---|---|---|
|
||||
| `2281232044` | 9.38 | FP#0000460 (Rejected) | apc15se1shcc-n03 | 10.244.4.55 |
|
||||
| `2281440017` | 9.38 | FP#0000460 (Rejected) | apc01se1shcc-n03-bmc | 10.244.11.53 |
|
||||
| `2282142049` | 9.38 | FP#0000460 (Rejected) | | 10.244.4.30 |
|
||||
| `2282338246` | 9.38 | FP#0000460 (Rejected) | apc04se1shcc-n01-cimc | 10.244.11.63 |
|
||||
| `2283364439` | 9.90 | FP#0000470 (Rejected) | | 24.28.208.125 |
|
||||
| `2283577805` | 9.90 | FP#0000470 (Rejected) | syn-024-028-210-101 | 24.28.210.101 |
|
||||
| `2283734550` | 9.90 | FP#0000452 (Rejected) | | 10.244.11.27 |
|
||||
| `2286607835` | 9.90 | FP#0000452 (Rejected) | | 10.240.1.203 |
|
||||
|
||||
**Without FP workflows (43 findings):**
|
||||
|
||||
| Finding ID | Severity | Host | IP Address | Title |
|
||||
|---|---|---|---|---|
|
||||
| `2289169183` | 9.90 | | 10.240.78.20 | IPMI 2.0 RAKP Authentication |
|
||||
| `2458498036` | 9.90 | eon-node-dhcp | | OpenSSH Multiple Security Vulnerabilities |
|
||||
| `2352647807` | 9.90 | localhost | | Rocky Linux sqlite update (RLSA-2025:20936) |
|
||||
| `2312562977` | 9.90 | rphy-runner-falconv | | Rocky Linux sqlite update (RLSA-2025:11992) |
|
||||
| `2352629939` | 9.90 | rphy-runner-falconv | | Rocky Linux sqlite update (RLSA-2025:20936) |
|
||||
| `2281281250` | 9.38 | | 172.16.1.229 | OpenSSH regreSSHion |
|
||||
| `2282419417` | 9.38 | | 10.244.11.96 | OpenSSH regreSSHion |
|
||||
| `2282688566` | 9.38 | apc02se1shcc-n01-cimc | 10.244.11.54 | OpenSSH regreSSHion |
|
||||
| `2283112486` | 9.38 | apc14se1shcc-n02 | 10.244.4.51 | OpenSSH regreSSHion |
|
||||
| `2283720427` | 9.38 | | 10.244.11.86 | OpenSSH regreSSHion |
|
||||
| `2283873511` | 9.38 | apc02se1shcc-n02-cimc | 10.244.11.55 | OpenSSH regreSSHion |
|
||||
| `2284154592` | 9.38 | syn-024-028-208-105 | 24.28.208.105 | OpenSSH regreSSHion |
|
||||
| `2284337626` | 9.38 | apc14se1shcc-n01 | 10.244.4.50 | OpenSSH regreSSHion |
|
||||
| `2284372435` | 9.38 | apc15se1shcc-n01 | 10.244.4.53 | OpenSSH regreSSHion |
|
||||
| `2284395753` | 9.38 | apc07se1shcc-n02-cimc | 10.244.11.73 | OpenSSH regreSSHion |
|
||||
| `2284622624` | 9.38 | apc04se1shcc-n02-cimc | 10.244.11.64 | OpenSSH regreSSHion |
|
||||
| `2284681286` | 9.38 | apc15se1shcc-n02 | 10.244.4.54 | OpenSSH regreSSHion |
|
||||
| `2285988119` | 9.38 | | 10.244.4.28 | OpenSSH regreSSHion |
|
||||
| `2286255181` | 9.38 | | 10.244.11.94 | OpenSSH regreSSHion |
|
||||
| `2286422988` | 9.38 | c220-wzp27340ss5 | 10.241.0.43 | OpenSSH regreSSHion |
|
||||
| `2286541484` | 9.38 | apc02se1shcc-n03-cimc | 10.244.11.56 | OpenSSH regreSSHion |
|
||||
| `2286589497` | 9.38 | apc05se1shcc-n01-bmc | 10.244.11.66 | OpenSSH regreSSHion |
|
||||
| `2287156417` | 9.38 | apc13se1shcc-n01 | 10.244.4.47 | OpenSSH regreSSHion |
|
||||
| `2287168608` | 9.38 | apc13se1shcc-n03 | 10.244.4.49 | OpenSSH regreSSHion |
|
||||
| `2287400005` | 9.38 | apc14se1shcc-n03 | 10.244.4.52 | OpenSSH regreSSHion |
|
||||
| `2287503960` | 9.38 | apc07se1shcc-n01-cimc | 10.244.11.72 | OpenSSH regreSSHion |
|
||||
| `2287822934` | 9.38 | apc02ctsbcom7-n03-cimc | 10.244.4.25 | OpenSSH regreSSHion |
|
||||
| `2287849796` | 9.38 | | 10.244.4.29 | OpenSSH regreSSHion |
|
||||
| `2287917789` | 9.38 | apc07se1shcc-n03-cimc | 10.244.11.74 | OpenSSH regreSSHion |
|
||||
| `2287954330` | 9.38 | apc13se1shcc-n02 | 10.244.4.48 | OpenSSH regreSSHion |
|
||||
| `2288500154` | 9.38 | apc04se1shcc-n03-cimc | 10.244.11.65 | OpenSSH regreSSHion |
|
||||
| `2288545686` | 9.38 | apc02ctsbcom7-n02-cimc | 10.244.4.24 | OpenSSH regreSSHion |
|
||||
| `2288829837` | 9.38 | | 10.244.11.87 | OpenSSH regreSSHion |
|
||||
| `2288874420` | 9.38 | apc05se1shcc-n03-bmc | 10.244.11.68 | OpenSSH regreSSHion |
|
||||
| `2289487733` | 9.38 | apc05se1shcc-n02-bmc | 10.244.11.67 | OpenSSH regreSSHion |
|
||||
| `2289651084` | 9.38 | apc02ctsbcom7-n01-cimc | 10.244.4.23 | OpenSSH regreSSHion |
|
||||
| `2289802898` | 9.38 | | 10.244.11.57 | OpenSSH regreSSHion |
|
||||
| `2454510043` | 9.38 | | 10.244.11.95 | OpenSSH regreSSHion |
|
||||
| `2687702557` | 9.38 | syn-098-120-032-145 | 98.120.32.145 | OpenSSH regreSSHion |
|
||||
| `2687710954` | 9.38 | syn-098-120-000-129 | 98.120.0.129 | OpenSSH regreSSHion |
|
||||
| `2284209398` | 9.06 | rphy-runner-vecima | 68.114.184.84 | Rocky Linux sudo update (RLSA-2025:9978) |
|
||||
| `2288585418` | 9.06 | rphy-runner-falconv | | Rocky Linux sudo update (RLSA-2025:9978) |
|
||||
| `2728824329` | 8.50 | localhost | | Rocky Linux kernel update (RLSA-2026:6570) |
|
||||
|
||||
---
|
||||
|
||||
### Still same BU — 6 findings
|
||||
|
||||
| Finding ID | Severity | Current State | BU | Host | IP Address |
|
||||
|---|---|---|---|---|---|
|
||||
| `2359379898` | 9.06 | Closed | NTS-AEO-STEAM | aeo-bpa-app-01-lab | |
|
||||
| `2286639694` | 9.38 | Closed | NTS-AEO-STEAM | syn-024-024-116-183 | 24.24.116.183 |
|
||||
| `2744295322` | 7.57 | Open | NTS-AEO-STEAM | ana01pongcoc1 | 96.37.185.81 |
|
||||
| `2687694321` | 9.38 | Closed | NTS-AEO-ACCESS-ENG | asa04chaococ1 | 98.120.32.167 |
|
||||
| `2687701818` | 9.38 | Closed | NTS-AEO-ACCESS-ENG | asr01chaococ1 | 98.120.32.180 |
|
||||
| `2687702475` | 9.38 | Closed | NTS-AEO-ACCESS-ENG | asr02chaococ1 | 98.120.32.181 |
|
||||
|
||||
> Finding `2744295322` is the only confirmed score drift case — dropped from 9.0 to 7.57. The other 5 are in the Closed state and still match the BU and severity filters; they were likely closed between syncs.
|
||||
|
||||
---
|
||||
|
||||
### Completely gone from platform — 15 findings
|
||||
|
||||
These findings are not found in Ivanti at any BU, severity, or state. All are OpenSSH regreSSHion (CVE-2024-6387).
|
||||
|
||||
| Finding ID | Last Severity | Host | IP Address |
|
||||
|---|---|---|---|
|
||||
| `2283426805` | 9.38 | | 10.244.3.136 |
|
||||
| `2284481283` | 9.38 | | 10.244.3.165 |
|
||||
| `2285495688` | 9.38 | | 10.244.3.134 |
|
||||
| `2285658756` | 9.38 | | 10.244.3.137 |
|
||||
| `2285828688` | 9.38 | | 10.244.3.133 |
|
||||
| `2286763965` | 9.38 | | 10.244.3.135 |
|
||||
| `2286932880` | 9.38 | | 10.244.3.166 |
|
||||
| `2288594216` | 9.38 | | 10.244.3.164 |
|
||||
| `2289475366` | 9.38 | | 10.244.3.132 |
|
||||
| `2662566450` | 9.38 | syn-065-185-198-071 | 65.185.198.71 |
|
||||
| `2662633263` | 9.38 | syn-065-185-198-070 | 65.185.198.70 |
|
||||
| `2687700013` | 9.38 | syn-098-120-032-166 | 98.120.32.166 |
|
||||
| `2687707862` | 9.38 | syn-098-120-032-182 | 98.120.32.182 |
|
||||
| `2613547630` | 9.30 | 096-037-187-009 | 96.37.187.9 |
|
||||
| `2613548575` | 9.30 | 096-037-187-017 | 96.37.187.17 |
|
||||
|
||||
> The `10.244.3.x` subnet (9 findings) suggests a cluster of hosts that were decommissioned or removed from Ivanti's asset inventory entirely.
|
||||
|
||||
---
|
||||
|
||||
### Diagnostic scripts
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
node scripts/drift-check.js
|
||||
node scripts/bu-reassignment-check.js
|
||||
```
|
||||
BIN
docs/reassigned-findings-2026-04-24.xlsx
Normal file
BIN
docs/reassigned-findings-2026-04-24.xlsx
Normal file
Binary file not shown.
227
frontend/src/components/pages/AnomalyBanner.js
Normal file
227
frontend/src/components/pages/AnomalyBanner.js
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { RefreshCw, Loader, AlertCircle, PieChart, ChevronUp, ChevronDown, Chevr
|
||||
import * as XLSX from 'xlsx';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import IvantiCountsChart from './IvantiCountsChart';
|
||||
import AnomalyBanner from './AnomalyBanner';
|
||||
import CveTooltip from '../CveTooltip';
|
||||
import RedirectModal from '../RedirectModal';
|
||||
import AtlasBadge from '../AtlasBadge';
|
||||
@@ -4633,6 +4634,7 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
|
||||
{/* ----------------------------------------------------------------
|
||||
Panel 1.5 — Open vs Closed trend over time
|
||||
---------------------------------------------------------------- */}
|
||||
{metricsTab === 'ivanti' && <AnomalyBanner />}
|
||||
{metricsTab === 'ivanti' && <IvantiCountsChart />}
|
||||
|
||||
{/* ----------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user