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 */}
+