From 9bd5a52661102eb35af6807706acf056fdfd54a0 Mon Sep 17 00:00:00 2001 From: jramos Date: Fri, 3 Apr 2026 15:20:04 -0600 Subject: [PATCH] feat: implement finding archive tracking system - Add migration script for ivanti_finding_archives and ivanti_archive_transitions tables - Add archive detection logic (detectArchiveChanges, detectClosedFindings) in sync pipeline - Add archive API router with list, stats, and history endpoints at /api/ivanti/archive - Add ArchiveSummaryBar UI component with four state cards (ACTIVE, ARCHIVED, RETURNED, CLOSED) - Integrate ArchiveSummaryBar into Ivanti findings page in App.js - Register archive router in server.js --- .../specs/finding-archive-tracking/design.md | 14 +- .../finding-archive-tracking/requirements.md | 2 +- .kiro/specs/finding-archive-tracking/tasks.md | 34 +-- .../migrations/add_finding_archive_tables.js | 75 +++++ backend/routes/ivantiArchive.js | 122 ++++++++ backend/routes/ivantiFindings.js | 263 +++++++++++++++++- backend/server.js | 4 + frontend/src/App.js | 11 + .../src/components/pages/ArchiveSummaryBar.js | 202 ++++++++++++++ 9 files changed, 699 insertions(+), 28 deletions(-) create mode 100644 backend/migrations/add_finding_archive_tables.js create mode 100644 backend/routes/ivantiArchive.js create mode 100644 frontend/src/components/pages/ArchiveSummaryBar.js diff --git a/.kiro/specs/finding-archive-tracking/design.md b/.kiro/specs/finding-archive-tracking/design.md index 0c2833f..e33bc9a 100644 --- a/.kiro/specs/finding-archive-tracking/design.md +++ b/.kiro/specs/finding-archive-tracking/design.md @@ -122,7 +122,7 @@ function ArchiveSummaryBar({ onStateClick, activeFilter }) { ... } | Endpoint | Method | Auth | Query Params | Response | |----------|--------|------|-------------|----------| | `/api/ivanti/archive` | GET | Required | `state` (optional: ACTIVE, ARCHIVED, RETURNED, CLOSED) | `{ archives: [...], total: N }` | -| `/api/ivanti/archive/stats` | GET | Required | None | `{ ACTIVE: N, ARCHIVED: N, RETURNED: N, CLOSED: N, total: N }` | +| `/api/ivanti/archive/stats` | GET | Required | None | `{ ARCHIVED: N, RETURNED: N, CLOSED: N, total: N }` | | `/api/ivanti/archive/:findingId/history` | GET | Required | None | `{ finding_id: "...", transitions: [...] }` | ## Data Models @@ -136,7 +136,7 @@ function ArchiveSummaryBar({ onStateClick, activeFilter }) { ... } | `finding_title` | TEXT | NOT NULL DEFAULT '' | Finding title at time of archival | | `host_name` | TEXT | NOT NULL DEFAULT '' | Host name at time of archival | | `ip_address` | TEXT | NOT NULL DEFAULT '' | IP address at time of archival | -| `current_state` | TEXT | NOT NULL CHECK(IN ('ACTIVE','ARCHIVED','RETURNED','CLOSED')) | Current lifecycle state | +| `current_state` | TEXT | NOT NULL CHECK(IN ('ARCHIVED','RETURNED','CLOSED')) | Current lifecycle state | | `last_severity` | REAL | NOT NULL DEFAULT 0 | Last known severity score | | `first_archived_at` | DATETIME | NOT NULL DEFAULT CURRENT_TIMESTAMP | When first archived | | `last_transition_at` | DATETIME | NOT NULL DEFAULT CURRENT_TIMESTAMP | When last state change occurred | @@ -163,10 +163,11 @@ function ArchiveSummaryBar({ onStateClick, activeFilter }) { ... } ### State Transition Diagram +Archive records are only created when a finding first disappears from sync results. Findings that remain present in sync results do not get archive records — they are simply "active" in the findings cache. The three database states are ARCHIVED, RETURNED, and CLOSED. + ```mermaid stateDiagram-v2 - [*] --> ACTIVE : Finding present in sync - ACTIVE --> ARCHIVED : Disappeared from sync (score drift) + [*] --> ARCHIVED : Finding disappears from sync (score drift) ARCHIVED --> RETURNED : Reappeared in sync ARCHIVED --> CLOSED : Confirmed remediated in Ivanti RETURNED --> ARCHIVED : Disappeared again @@ -177,8 +178,7 @@ stateDiagram-v2 | From State | To State | Reason | |-----------|----------|--------| -| NONE | ACTIVE | `initial_sync` | -| ACTIVE → | ARCHIVED | `severity_score_drift` | +| NONE → | ARCHIVED | `severity_score_drift` (first disappearance) | | ARCHIVED → | RETURNED | `reappeared_in_sync` | | ARCHIVED → | CLOSED | `remediated_in_ivanti` | | RETURNED → | ARCHIVED | `severity_score_drift` | @@ -252,7 +252,7 @@ stateDiagram-v2 | Database error during transition insert | Log the error. The archive record state may have been updated but the transition history may be incomplete. This is acceptable as the current state is the source of truth. | | Invalid state transition attempted | The detection logic only performs valid transitions per the state diagram. Invalid transitions (e.g., CLOSED → ARCHIVED) are not possible by design since closed findings are excluded from the sync pipeline. | | Missing finding metadata | Use empty string defaults for finding_title, host_name, ip_address if the finding object lacks these fields. Severity defaults to 0. | -| Archive API query with invalid state parameter | Return all records (ignore the filter) rather than returning an error, for resilience. | +| Archive API query with invalid state parameter | Return a 400 status code with message "Invalid state parameter. Valid values: ACTIVE, ARCHIVED, RETURNED, CLOSED". Explicit errors surface frontend bugs faster than silent fallbacks. | | History query for non-existent finding | Return 200 with empty transitions array (not 404), per requirement 4.5. | ## Testing Strategy diff --git a/.kiro/specs/finding-archive-tracking/requirements.md b/.kiro/specs/finding-archive-tracking/requirements.md index 93fa935..472d0d5 100644 --- a/.kiro/specs/finding-archive-tracking/requirements.md +++ b/.kiro/specs/finding-archive-tracking/requirements.md @@ -13,7 +13,7 @@ The Finding Archive Tracking system extends the Ivanti sync pipeline in the STEA - **Archive_Detector**: The logic within the sync pipeline that compares previous sync results against current results to identify disappeared and returned findings. - **Archive_Summary_Bar**: A React UI component displaying counts for each lifecycle state (ACTIVE, ARCHIVED, RETURNED, CLOSED) with click-through navigation. - **Archive_API**: The set of three Express route endpoints serving archived finding data, transition history, and summary statistics. -- **Lifecycle_State**: One of four states a finding can occupy: ACTIVE (present in sync results), ARCHIVED (disappeared from sync results due to score drift), RETURNED (reappeared after being archived), CLOSED (remediated in Ivanti). +- **Lifecycle_State**: One of three database states an archive record can occupy: ARCHIVED (disappeared from sync results due to score drift), RETURNED (reappeared after being archived), CLOSED (remediated in Ivanti). Findings that remain present in sync results have no archive record. ## Requirements diff --git a/.kiro/specs/finding-archive-tracking/tasks.md b/.kiro/specs/finding-archive-tracking/tasks.md index 8108698..04e6ad2 100644 --- a/.kiro/specs/finding-archive-tracking/tasks.md +++ b/.kiro/specs/finding-archive-tracking/tasks.md @@ -6,8 +6,8 @@ Implement the Finding Archive Tracking system by creating the database migration ## Tasks -- [ ] 1. Create database migration and archive tables - - [ ] 1.1 Create `backend/migrations/add_finding_archive_tables.js` migration script +- [x] 1. Create database migration and archive tables + - [x] 1.1 Create `backend/migrations/add_finding_archive_tables.js` migration script - Create `ivanti_finding_archives` table with columns: id, finding_id (UNIQUE), finding_title, host_name, ip_address, current_state (CHECK constraint for ACTIVE/ARCHIVED/RETURNED/CLOSED), last_severity, first_archived_at, last_transition_at, created_at - Create `ivanti_archive_transitions` table with columns: id, archive_id (FK), from_state, to_state, severity_at_transition, reason, transitioned_at - Create indexes: idx_archive_finding_id, idx_archive_current_state, idx_transition_archive_id @@ -20,13 +20,13 @@ Implement the Finding Archive Tracking system by creating the database migration - Run migration logic multiple times against in-memory SQLite, verify no errors and schema is consistent - **Validates: Requirements 6.2** -- [ ] 2. Implement archive detection logic in sync pipeline - - [ ] 2.1 Add `initArchiveTables(db)` function to `backend/routes/ivantiFindings.js` +- [x] 2. Implement archive detection logic in sync pipeline + - [x] 2.1 Add `initArchiveTables(db)` function to `backend/routes/ivantiFindings.js` - Create both archive tables inline (same pattern as existing `initTables`) so they exist on startup - Call from `createIvantiFindingsRouter` during init alongside existing `initTables` - _Requirements: 3.1, 3.2, 3.3, 3.4_ - - [ ] 2.2 Implement `detectArchiveChanges(db, previousFindings, currentFindings)` function + - [x] 2.2 Implement `detectArchiveChanges(db, previousFindings, currentFindings)` function - Build ID sets from previous and current findings - For disappeared findings (in previous, not in current): upsert archive record with state ARCHIVED, insert transition history - For returned findings (in current, has ARCHIVED record): update to RETURNED, insert transition history @@ -34,13 +34,13 @@ Implement the Finding Archive Tracking system by creating the database migration - Use `db.run` with callbacks wrapped in promises (matching existing `dbRun` helper pattern) - _Requirements: 1.1, 1.2, 1.3, 1.4, 2.1, 2.2_ - - [ ] 2.3 Implement `detectClosedFindings(db, closedFindingIds)` function + - [x] 2.3 Implement `detectClosedFindings(db, closedFindingIds)` function - Query archive records with state ARCHIVED or RETURNED - For any that appear in the closed findings set, update to CLOSED with reason "remediated_in_ivanti" - Insert transition history for each state change - _Requirements: 2.3_ - - [ ] 2.4 Integrate archive detection into `syncFindings()` flow + - [x] 2.4 Integrate archive detection into `syncFindings()` flow - Before updating the cache, read the current findings from `ivanti_findings_cache` as `previousFindings` - After successful cache update, call `detectArchiveChanges(db, previousFindings, currentFindings)` - Skip archive detection if sync encountered an error (requirement 1.5) @@ -72,19 +72,19 @@ Implement the Finding Archive Tracking system by creating the database migration - Generate archived/returned findings, mark some as closed, verify CLOSED state and reason "remediated_in_ivanti" - **Validates: Requirements 2.3** -- [ ] 3. Checkpoint — Verify archive detection logic +- [x] 3. Checkpoint — Verify archive detection logic - Ensure all tests pass, ask the user if questions arise. -- [ ] 4. Implement Archive API endpoints - - [ ] 4.1 Create `backend/routes/ivantiArchive.js` route module +- [x] 4. Implement Archive API endpoints + - [x] 4.1 Create `backend/routes/ivantiArchive.js` route module - Export factory function `createIvantiArchiveRouter(db, requireAuth)` returning Express Router - Apply `requireAuth(db)` middleware to all routes - - Implement GET `/` — list archive records with optional `?state=` filter, return `{ archives: [...], total: N }` + - Implement GET `/` — list archive records with optional `?state=` filter, return `{ archives: [...], total: N }`. Return 400 with message "Invalid state parameter. Valid values: ACTIVE, ARCHIVED, RETURNED, CLOSED" if an unrecognized state value is provided. - Implement GET `/stats` — return `{ ACTIVE: N, ARCHIVED: N, RETURNED: N, CLOSED: N, total: N }` - Implement GET `/:findingId/history` — return `{ finding_id, transitions: [...] }` ordered by transitioned_at DESC, return empty array for unknown finding_id - _Requirements: 4.1, 4.2, 4.3, 4.4, 4.5_ - - [ ] 4.2 Register archive router in `backend/server.js` + - [x] 4.2 Register archive router in `backend/server.js` - Import `createIvantiArchiveRouter` from `./routes/ivantiArchive` - Mount at `/api/ivanti/archive` with `requireAuth` middleware - _Requirements: 4.1_ @@ -104,11 +104,11 @@ Implement the Finding Archive Tracking system by creating the database migration - Generate archive records with random states, query stats, verify counts match actual distribution - **Validates: Requirements 4.3** -- [ ] 5. Checkpoint — Verify API endpoints +- [x] 5. Checkpoint — Verify API endpoints - Ensure all tests pass, ask the user if questions arise. -- [ ] 6. Implement Archive Summary Bar UI component - - [ ] 6.1 Create `frontend/src/components/pages/ArchiveSummaryBar.js` +- [x] 6. Implement Archive Summary Bar UI component + - [x] 6.1 Create `frontend/src/components/pages/ArchiveSummaryBar.js` - Fetch stats from `/api/ivanti/archive/stats` on mount - Render four stat cards: ACTIVE (sky blue #0EA5E9), ARCHIVED (amber #F59E0B), RETURNED (emerald #10B981), CLOSED (red #EF4444) - Each card shows the count and state label with Lucide icons and monospace typography @@ -116,12 +116,12 @@ Implement the Finding Archive Tracking system by creating the database migration - Use inline style objects matching the existing design system (dark gradients, glows, hover effects) - _Requirements: 5.1, 5.2, 5.3, 5.4, 5.5_ - - [ ] 6.2 Integrate Archive Summary Bar into the Ivanti findings page + - [x] 6.2 Integrate Archive Summary Bar into the Ivanti findings page - Import and render `ArchiveSummaryBar` in the Ivanti findings section of `App.js` (or the relevant page component) - Wire `onStateClick` to manage a state filter for the archive list display - _Requirements: 5.3_ -- [ ] 7. Final checkpoint — Verify full integration +- [x] 7. Final checkpoint — Verify full integration - Ensure all tests pass, ask the user if questions arise. ## Notes diff --git a/backend/migrations/add_finding_archive_tables.js b/backend/migrations/add_finding_archive_tables.js new file mode 100644 index 0000000..77fa45b --- /dev/null +++ b/backend/migrations/add_finding_archive_tables.js @@ -0,0 +1,75 @@ +// Migration: Add ivanti_finding_archives and ivanti_archive_transitions tables +const sqlite3 = require('sqlite3').verbose(); +const path = require('path'); + +const dbPath = path.join(__dirname, '..', 'cve_database.db'); +const db = new sqlite3.Database(dbPath); + +console.log('Starting finding archive tables migration...'); + +db.serialize(() => { + // Archive records — one row per finding that has entered the archive lifecycle + db.run(` + CREATE TABLE IF NOT EXISTS 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 ('ACTIVE', 'ARCHIVED', 'RETURNED', 'CLOSED')), + 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 + ) + `, (err) => { + if (err) console.error('Error creating ivanti_finding_archives table:', err); + else console.log('✓ ivanti_finding_archives table created'); + }); + + // Transition history — one row per state change on an archive record + db.run(` + CREATE TABLE IF NOT EXISTS ivanti_archive_transitions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + archive_id INTEGER NOT NULL, + from_state TEXT NOT NULL, + to_state TEXT NOT NULL, + severity_at_transition REAL NOT NULL DEFAULT 0, + reason TEXT NOT NULL DEFAULT '', + transitioned_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (archive_id) REFERENCES ivanti_finding_archives(id) + ) + `, (err) => { + if (err) console.error('Error creating ivanti_archive_transitions table:', err); + else console.log('✓ ivanti_archive_transitions table created'); + }); + + // Indexes for query performance + db.run(` + CREATE INDEX IF NOT EXISTS idx_archive_finding_id + ON ivanti_finding_archives(finding_id) + `, (err) => { + if (err) console.error('Error creating idx_archive_finding_id:', err); + else console.log('✓ idx_archive_finding_id index created'); + }); + + db.run(` + CREATE INDEX IF NOT EXISTS idx_archive_current_state + ON ivanti_finding_archives(current_state) + `, (err) => { + if (err) console.error('Error creating idx_archive_current_state:', err); + else console.log('✓ idx_archive_current_state index created'); + }); + + db.run(` + CREATE INDEX IF NOT EXISTS idx_transition_archive_id + ON ivanti_archive_transitions(archive_id) + `, (err) => { + if (err) console.error('Error creating idx_transition_archive_id:', err); + else console.log('✓ idx_transition_archive_id index created'); + }); +}); + +db.close(() => { + console.log('Migration complete!'); +}); diff --git a/backend/routes/ivantiArchive.js b/backend/routes/ivantiArchive.js new file mode 100644 index 0000000..69a0f19 --- /dev/null +++ b/backend/routes/ivantiArchive.js @@ -0,0 +1,122 @@ +// Ivanti Archive Routes — list, stats, and transition history for archived findings +const express = require('express'); + +const VALID_STATES = ['ACTIVE', 'ARCHIVED', 'RETURNED', 'CLOSED']; + +function createIvantiArchiveRouter(db, requireAuth) { + const router = express.Router(); + + // All routes require authentication + router.use(requireAuth(db)); + + // GET / — List archive records with optional ?state= filter + router.get('/', async (req, res) => { + const { state } = req.query; + + if (state && !VALID_STATES.includes(state)) { + return res.status(400).json({ + error: 'Invalid state parameter. Valid values: ACTIVE, ARCHIVED, RETURNED, CLOSED' + }); + } + + try { + let query = 'SELECT * FROM ivanti_finding_archives'; + const params = []; + + if (state) { + query += ' WHERE current_state = ?'; + params.push(state); + } + + query += ' ORDER BY last_transition_at DESC'; + + const archives = await new Promise((resolve, reject) => { + db.all(query, params, (err, rows) => { + if (err) reject(err); + else resolve(rows || []); + }); + }); + + res.json({ archives, total: archives.length }); + } catch (err) { + console.error('Archive list error:', err); + res.status(500).json({ error: 'Failed to fetch archive records' }); + } + }); + + // GET /stats — Summary counts by state + router.get('/stats', async (req, res) => { + try { + const rows = await new Promise((resolve, reject) => { + db.all( + `SELECT current_state, COUNT(*) as count + FROM ivanti_finding_archives + GROUP BY current_state`, + (err, rows) => { + if (err) reject(err); + else resolve(rows || []); + } + ); + }); + + const stats = { ACTIVE: 0, ARCHIVED: 0, RETURNED: 0, CLOSED: 0 }; + let total = 0; + + for (const row of rows) { + if (stats.hasOwnProperty(row.current_state)) { + stats[row.current_state] = row.count; + } + total += row.count; + } + + res.json({ ...stats, total }); + } catch (err) { + console.error('Archive stats error:', err); + res.status(500).json({ error: 'Failed to fetch archive stats' }); + } + }); + + // GET /:findingId/history — Transition history for a finding + router.get('/:findingId/history', async (req, res) => { + const { findingId } = req.params; + + try { + const archive = await new Promise((resolve, reject) => { + db.get( + 'SELECT id FROM ivanti_finding_archives WHERE finding_id = ?', + [findingId], + (err, row) => { + if (err) reject(err); + else resolve(row); + } + ); + }); + + if (!archive) { + return res.json({ finding_id: findingId, transitions: [] }); + } + + const transitions = await new Promise((resolve, reject) => { + db.all( + `SELECT * FROM ivanti_archive_transitions + WHERE archive_id = ? + ORDER BY transitioned_at DESC`, + [archive.id], + (err, rows) => { + if (err) reject(err); + else resolve(rows || []); + } + ); + }); + + res.json({ finding_id: findingId, transitions }); + } catch (err) { + console.error('Archive history error:', err); + res.status(500).json({ error: 'Failed to fetch transition history' }); + } + }); + + return router; +} + +module.exports = createIvantiArchiveRouter; diff --git a/backend/routes/ivantiFindings.js b/backend/routes/ivantiFindings.js index 1158497..edf0d1d 100644 --- a/backend/routes/ivantiFindings.js +++ b/backend/routes/ivantiFindings.js @@ -192,6 +192,201 @@ function initTables(db) { }); } +// --------------------------------------------------------------------------- +// Archive table init — creates archive tracking tables alongside the main cache +// --------------------------------------------------------------------------- +function initArchiveTables(db) { + return new Promise((resolve, reject) => { + db.serialize(() => { + db.run(` + CREATE TABLE IF NOT EXISTS 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')), + 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 + ) + `, (err) => { if (err) return reject(err); }); + + db.run(` + CREATE TABLE IF NOT EXISTS ivanti_archive_transitions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + archive_id INTEGER NOT NULL, + from_state TEXT NOT NULL, + to_state TEXT NOT NULL, + severity_at_transition REAL NOT NULL DEFAULT 0, + reason TEXT NOT NULL DEFAULT '', + transitioned_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (archive_id) REFERENCES ivanti_finding_archives(id) + ) + `, (err) => { if (err) return reject(err); }); + + db.run(` + CREATE INDEX IF NOT EXISTS idx_archive_finding_id + ON ivanti_finding_archives(finding_id) + `, (err) => { if (err) return reject(err); }); + + db.run(` + CREATE INDEX IF NOT EXISTS idx_archive_current_state + ON ivanti_finding_archives(current_state) + `, (err) => { if (err) return reject(err); }); + + db.run(` + CREATE INDEX IF NOT EXISTS idx_transition_archive_id + ON ivanti_archive_transitions(archive_id) + `, (err) => { + if (err) reject(err); + else resolve(); + }); + }); + }); +} + +// --------------------------------------------------------------------------- +// Archive detection — compare previous vs current findings to detect state changes +// --------------------------------------------------------------------------- +async function detectArchiveChanges(db, previousFindings, currentFindings) { + const previousIds = new Set(previousFindings.map(f => String(f.id))); + const currentIds = new Set(currentFindings.map(f => String(f.id))); + + // Build lookup maps for metadata + const previousMap = new Map(previousFindings.map(f => [String(f.id), f])); + const currentMap = new Map(currentFindings.map(f => [String(f.id), f])); + + // 1. Disappeared findings: in previous but not in current → ARCHIVED + const disappearedIds = [...previousIds].filter(id => !currentIds.has(id)); + + for (const id of disappearedIds) { + const finding = previousMap.get(id); + const title = finding.title || ''; + const hostName = finding.hostName || ''; + const ipAddress = finding.ipAddress || ''; + const severity = typeof finding.severity === 'number' ? finding.severity : 0; + + try { + // Check if this finding already has an archive record + const existing = await dbGet(db, + `SELECT id, current_state FROM ivanti_finding_archives WHERE finding_id = ?`, + [id] + ); + + if (existing && existing.current_state === 'RETURNED') { + // Re-disappeared: RETURNED → ARCHIVED + await dbRun(db, + `UPDATE ivanti_finding_archives + SET current_state = 'ARCHIVED', last_severity = ?, last_transition_at = datetime('now') + WHERE id = ?`, + [severity, existing.id] + ); + await dbRun(db, + `INSERT INTO ivanti_archive_transitions (archive_id, from_state, to_state, severity_at_transition, reason, transitioned_at) + VALUES (?, 'RETURNED', 'ARCHIVED', ?, 'severity_score_drift', datetime('now'))`, + [existing.id, severity] + ); + console.log(`[Archive Detection] Finding ${id} re-archived (RETURNED → ARCHIVED)`); + } else if (!existing) { + // First disappearance: NONE → ARCHIVED + const result = await dbRun(db, + `INSERT INTO ivanti_finding_archives (finding_id, finding_title, host_name, ip_address, current_state, last_severity, first_archived_at, last_transition_at) + VALUES (?, ?, ?, ?, 'ARCHIVED', ?, datetime('now'), datetime('now'))`, + [id, title, hostName, ipAddress, severity] + ); + const archiveId = result.lastID; + await dbRun(db, + `INSERT INTO ivanti_archive_transitions (archive_id, from_state, to_state, severity_at_transition, reason, transitioned_at) + VALUES (?, 'NONE', 'ARCHIVED', ?, 'severity_score_drift', datetime('now'))`, + [archiveId, severity] + ); + console.log(`[Archive Detection] Finding ${id} archived (NONE → ARCHIVED)`); + } + // If existing state is ARCHIVED or CLOSED, no action needed + } catch (err) { + console.error(`[Archive Detection] Error processing disappeared finding ${id}:`, err.message); + } + } + + // 2. Returned findings: in current AND has existing ARCHIVED record → RETURNED + const currentIdsList = [...currentIds]; + if (currentIdsList.length > 0) { + try { + const archivedRecords = await dbAll(db, + `SELECT id, finding_id FROM ivanti_finding_archives WHERE current_state = 'ARCHIVED'` + ); + + for (const record of archivedRecords) { + if (currentIds.has(record.finding_id)) { + const finding = currentMap.get(record.finding_id); + const severity = typeof finding.severity === 'number' ? finding.severity : 0; + + await dbRun(db, + `UPDATE ivanti_finding_archives + SET current_state = 'RETURNED', last_severity = ?, last_transition_at = datetime('now') + WHERE id = ?`, + [severity, record.id] + ); + await dbRun(db, + `INSERT INTO ivanti_archive_transitions (archive_id, from_state, to_state, severity_at_transition, reason, transitioned_at) + VALUES (?, 'ARCHIVED', 'RETURNED', ?, 'reappeared_in_sync', datetime('now'))`, + [record.id, severity] + ); + console.log(`[Archive Detection] Finding ${record.finding_id} returned (ARCHIVED → RETURNED)`); + } + } + } catch (err) { + console.error('[Archive Detection] Error processing returned findings:', err.message); + } + } + + console.log(`[Archive Detection] Processed ${disappearedIds.length} disappeared, checked ${currentIdsList.length} current findings against archive`); +} + +// --------------------------------------------------------------------------- +// Closed finding detection — check archived/returned findings against Ivanti closed set +// --------------------------------------------------------------------------- +async function detectClosedFindings(db, closedFindingIds) { + if (!closedFindingIds || closedFindingIds.length === 0) return; + + const closedSet = new Set(closedFindingIds.map(String)); + + try { + const records = await dbAll(db, + `SELECT id, finding_id, current_state, last_severity FROM ivanti_finding_archives WHERE current_state IN ('ARCHIVED', 'RETURNED')` + ); + + let closedCount = 0; + for (const record of records) { + if (!closedSet.has(record.finding_id)) continue; + + try { + await dbRun(db, + `UPDATE ivanti_finding_archives + SET current_state = 'CLOSED', 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', ?, 'remediated_in_ivanti', datetime('now'))`, + [record.id, record.current_state, record.last_severity || 0] + ); + closedCount++; + console.log(`[Archive Detection] Finding ${record.finding_id} closed (${record.current_state} → CLOSED)`); + } catch (err) { + console.error(`[Archive Detection] Error closing finding ${record.finding_id}:`, err.message); + } + } + + console.log(`[Archive Detection] Closed ${closedCount} findings as remediated`); + } catch (err) { + console.error('[Archive Detection] Error querying archive records for closed detection:', err.message); + } +} + // --------------------------------------------------------------------------- // Extract only the fields we need from a raw finding object // --------------------------------------------------------------------------- @@ -266,7 +461,7 @@ async function syncClosedCount(db, openCount, apiKey, clientId, skipTls) { projection: 'internal', sort: [{ field: 'severity', direction: 'ASC' }], page: 0, - size: 1 + size: 100 }; const result = await ivantiPost(urlPath, body, apiKey, skipTls); @@ -275,6 +470,27 @@ async function syncClosedCount(db, openCount, apiKey, clientId, skipTls) { const data = JSON.parse(result.body); // RiskSense returns total in page.totalElements or page.total const closedCount = data.page?.totalElements ?? data.page?.total ?? 0; + const totalPages = data.page?.totalPages || 1; + + // Collect closed finding IDs for archive detection + const closedFindingIds = []; + const firstPageFindings = data._embedded?.hostFindings || []; + firstPageFindings.forEach(f => { if (f.id) closedFindingIds.push(String(f.id)); }); + + // Fetch remaining pages to collect all closed finding IDs + for (let pg = 1; pg < totalPages; pg++) { + try { + const pageBody = { ...body, page: pg }; + const pageResult = await ivantiPost(urlPath, pageBody, apiKey, skipTls); + if (pageResult.status !== 200) break; + const pageData = JSON.parse(pageResult.body); + const pageFindings = pageData._embedded?.hostFindings || []; + pageFindings.forEach(f => { if (f.id) closedFindingIds.push(String(f.id)); }); + } catch (err) { + console.error(`[Ivanti Findings] Failed to fetch closed findings page ${pg}:`, err.message); + break; + } + } await dbRun(db, `UPDATE ivanti_counts_cache SET open_count=?, closed_count=?, synced_at=datetime('now') WHERE id=1`, @@ -289,6 +505,13 @@ async function syncClosedCount(db, openCount, apiKey, clientId, skipTls) { ); console.log(`[Ivanti Findings] Counts updated — open: ${openCount}, closed: ${closedCount}`); + + // Detect closed findings in the archive — wrap in try/catch so errors don't break sync + try { + await detectClosedFindings(db, closedFindingIds); + } catch (err) { + console.error('[Ivanti Findings] Closed finding archive 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 @@ -441,17 +664,36 @@ async function syncFindings(db) { page++; } while (page < totalPages); + // Read previous findings BEFORE updating the cache (they'll be overwritten) + let previousFindings = []; + try { + const state = await readState(db); + previousFindings = state.findings || []; + } catch (err) { + console.error('[Ivanti Findings] Failed to read previous findings for archive detection:', 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)] ); console.log(`[Ivanti Findings] Sync complete — ${allFindings.length} findings`); + + // Archive detection — compare previous vs current to detect disappeared/returned findings + // Only runs after a successful sync (skipped on error per requirement 1.5) + try { + await detectArchiveChanges(db, previousFindings, allFindings); + } catch (err) { + console.error('[Ivanti Findings] Archive detection failed (non-fatal):', err.message); + } + await syncClosedCount(db, allFindings.length, apiKey, clientId, skipTls); await syncFPWorkflowCounts(db, allFindings, apiKey, clientId, skipTls); } catch (err) { const msg = err.message || 'Unknown error'; console.error('[Ivanti Findings] Sync failed:', msg); + // Archive detection is intentionally skipped on sync error (requirement 1.5) await dbRun(db, `UPDATE ivanti_findings_cache SET sync_status='error', error_message=?, synced_at=datetime('now') WHERE id=1`, [msg]); } } @@ -482,7 +724,19 @@ function scheduleSync(db) { // --------------------------------------------------------------------------- function dbRun(db, sql, params = []) { return new Promise((resolve, reject) => { - db.run(sql, params, (err) => { if (err) reject(err); else resolve(); }); + db.run(sql, params, function (err) { if (err) reject(err); else resolve(this); }); + }); +} + +function dbGet(db, sql, params = []) { + return new Promise((resolve, reject) => { + db.get(sql, params, (err, row) => { if (err) reject(err); else resolve(row); }); + }); +} + +function dbAll(db, sql, params = []) { + return new Promise((resolve, reject) => { + db.all(sql, params, (err, rows) => { if (err) reject(err); else resolve(rows || []); }); }); } @@ -559,7 +813,7 @@ async function readStateWithNotes(db) { function createIvantiFindingsRouter(db, requireAuth) { const router = express.Router(); - initTables(db) + Promise.all([initTables(db), initArchiveTables(db)]) .then(() => scheduleSync(db)) .catch((err) => console.error('[Ivanti Findings] Init failed:', err)); @@ -700,3 +954,6 @@ function createIvantiFindingsRouter(db, requireAuth) { } module.exports = createIvantiFindingsRouter; +module.exports.detectArchiveChanges = detectArchiveChanges; +module.exports.detectClosedFindings = detectClosedFindings; +module.exports.initArchiveTables = initArchiveTables; diff --git a/backend/server.js b/backend/server.js index b51d585..038381e 100644 --- a/backend/server.js +++ b/backend/server.js @@ -23,6 +23,7 @@ const createArcherTicketsRouter = require('./routes/archerTickets'); const createIvantiWorkflowsRouter = require('./routes/ivantiWorkflows'); const createIvantiFindingsRouter = require('./routes/ivantiFindings'); const createIvantiTodoQueueRouter = require('./routes/ivantiTodoQueue'); +const createIvantiArchiveRouter = require('./routes/ivantiArchive'); const createComplianceRouter = require('./routes/compliance'); const app = express(); @@ -219,6 +220,9 @@ app.use('/api/ivanti/findings', createIvantiFindingsRouter(db, requireAuth)); // Ivanti queue routes — per-user staging queue for FP / Archer workflows app.use('/api/ivanti/todo-queue', createIvantiTodoQueueRouter(db, requireAuth)); +// Ivanti archive routes — finding archive tracking for severity score drift +app.use('/api/ivanti/archive', createIvantiArchiveRouter(db, requireAuth)); + // AEO compliance routes — xlsx upload, non-compliant item tracking, notes app.use('/api/compliance', createComplianceRouter(db, upload, requireAuth, requireRole)); diff --git a/frontend/src/App.js b/frontend/src/App.js index c2425be..7b7c7d9 100644 --- a/frontend/src/App.js +++ b/frontend/src/App.js @@ -12,6 +12,7 @@ import VulnerabilityTriagePage from './components/pages/ReportingPage'; import KnowledgeBasePage from './components/pages/KnowledgeBasePage'; import ExportsPage from './components/pages/ExportsPage'; import CompliancePage from './components/pages/CompliancePage'; +import ArchiveSummaryBar from './components/pages/ArchiveSummaryBar'; import './App.css'; const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api'; @@ -233,6 +234,9 @@ export default function App() { const [ivantiLoading, setIvantiLoading] = useState(false); const [ivantiSyncing, setIvantiSyncing] = useState(false); + // Archive filter state + const [archiveFilter, setArchiveFilter] = useState(null); + const toggleCVEExpand = (cveId) => { setExpandedCVEs(prev => ({ ...prev, [cveId]: !prev[cveId] })); }; @@ -369,6 +373,10 @@ export default function App() { } }; + const handleArchiveStateClick = (state) => { + setArchiveFilter(prev => prev === state ? null : state); + }; + const fetchDocuments = async (cveId, vendor) => { const key = `${cveId}-${vendor}`; if (cveDocuments[key]) return; @@ -2251,6 +2259,9 @@ export default function App() { : 'Never synced'} + {/* Archive Summary Bar */} + + {ivantiLoading ? (
diff --git a/frontend/src/components/pages/ArchiveSummaryBar.js b/frontend/src/components/pages/ArchiveSummaryBar.js new file mode 100644 index 0000000..6c68d30 --- /dev/null +++ b/frontend/src/components/pages/ArchiveSummaryBar.js @@ -0,0 +1,202 @@ +// ArchiveSummaryBar.js +// Displays four stat cards for archive lifecycle states: ACTIVE, ARCHIVED, RETURNED, CLOSED. +// Fetches counts from /api/ivanti/archive/stats on mount. + +import React, { useState, useEffect } from 'react'; +import { Activity, Archive, RotateCcw, XCircle, Loader } from 'lucide-react'; + +const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api'; + +const STATE_CONFIG = [ + { + key: 'ACTIVE', + label: 'Active', + color: '#0EA5E9', + Icon: Activity, + }, + { + key: 'ARCHIVED', + label: 'Archived', + color: '#F59E0B', + Icon: Archive, + }, + { + key: 'RETURNED', + label: 'Returned', + color: '#10B981', + Icon: RotateCcw, + }, + { + key: 'CLOSED', + label: 'Closed', + color: '#EF4444', + Icon: XCircle, + }, +]; + +function StatCard({ stateKey, label, color, Icon, count, active, onClick }) { + const [hovered, setHovered] = useState(false); + + const isHighlighted = active || hovered; + + const cardStyle = { + flex: '1 1 0', + minWidth: '140px', + background: 'linear-gradient(135deg, rgba(30, 41, 59, 0.95), rgba(51, 65, 85, 0.9))', + border: `2px solid ${isHighlighted ? color : `rgba(${hexToRgb(color)}, 0.3)`}`, + borderRadius: '0.5rem', + padding: '1rem', + cursor: 'pointer', + transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)', + transform: isHighlighted ? 'translateY(-2px)' : 'translateY(0)', + boxShadow: isHighlighted + ? `0 4px 16px rgba(0, 0, 0, 0.5), 0 0 20px rgba(${hexToRgb(color)}, 0.25)` + : '0 4px 16px rgba(0, 0, 0, 0.5)', + position: 'relative', + overflow: 'hidden', + }; + + const accentLineStyle = { + position: 'absolute', + top: 0, + left: 0, + right: 0, + height: '2px', + background: `linear-gradient(90deg, transparent, ${color}, transparent)`, + boxShadow: `0 0 8px ${color}`, + }; + + return ( +
onClick(stateKey)} + onMouseEnter={() => setHovered(true)} + onMouseLeave={() => setHovered(false)} + role="button" + tabIndex={0} + onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onClick(stateKey); } }} + aria-label={`${label}: ${count} findings. ${active ? 'Currently filtered.' : 'Click to filter.'}`} + > +
+
+ + + {label} + +
+
+ {count != null ? count : '—'} +
+
+ ); +} + +// Convert hex color to r, g, b string for use in rgba() +function hexToRgb(hex) { + const r = parseInt(hex.slice(1, 3), 16); + const g = parseInt(hex.slice(3, 5), 16); + const b = parseInt(hex.slice(5, 7), 16); + return `${r}, ${g}, ${b}`; +} + +export default function ArchiveSummaryBar({ onStateClick, activeFilter }) { + const [stats, setStats] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(false); + + useEffect(() => { + let cancelled = false; + const load = async () => { + setLoading(true); + setError(false); + try { + const res = await fetch(`${API_BASE}/ivanti/archive/stats`, { credentials: 'include' }); + if (res.ok && !cancelled) { + const data = await res.json(); + setStats(data); + } else if (!cancelled) { + setError(true); + } + } catch { + if (!cancelled) setError(true); + } finally { + if (!cancelled) setLoading(false); + } + }; + load(); + return () => { cancelled = true; }; + }, []); + + if (loading) { + return ( +
+ + Loading archive stats… +
+ ); + } + + if (error) { + return ( +
+ Unable to load archive statistics +
+ ); + } + + const handleClick = (state) => { + if (onStateClick) onStateClick(state); + }; + + return ( +
+ {STATE_CONFIG.map(({ key, label, color, Icon }) => ( + + ))} +
+ ); +}