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
This commit is contained in:
@@ -122,7 +122,7 @@ function ArchiveSummaryBar({ onStateClick, activeFilter }) { ... }
|
|||||||
| Endpoint | Method | Auth | Query Params | Response |
|
| Endpoint | Method | Auth | Query Params | Response |
|
||||||
|----------|--------|------|-------------|----------|
|
|----------|--------|------|-------------|----------|
|
||||||
| `/api/ivanti/archive` | GET | Required | `state` (optional: ACTIVE, ARCHIVED, RETURNED, CLOSED) | `{ archives: [...], total: N }` |
|
| `/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: [...] }` |
|
| `/api/ivanti/archive/:findingId/history` | GET | Required | None | `{ finding_id: "...", transitions: [...] }` |
|
||||||
|
|
||||||
## Data Models
|
## Data Models
|
||||||
@@ -136,7 +136,7 @@ function ArchiveSummaryBar({ onStateClick, activeFilter }) { ... }
|
|||||||
| `finding_title` | TEXT | NOT NULL DEFAULT '' | Finding title at time of archival |
|
| `finding_title` | TEXT | NOT NULL DEFAULT '' | Finding title at time of archival |
|
||||||
| `host_name` | TEXT | NOT NULL DEFAULT '' | Host name 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 |
|
| `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 |
|
| `last_severity` | REAL | NOT NULL DEFAULT 0 | Last known severity score |
|
||||||
| `first_archived_at` | DATETIME | NOT NULL DEFAULT CURRENT_TIMESTAMP | When first archived |
|
| `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 |
|
| `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
|
### 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
|
```mermaid
|
||||||
stateDiagram-v2
|
stateDiagram-v2
|
||||||
[*] --> ACTIVE : Finding present in sync
|
[*] --> ARCHIVED : Finding disappears from sync (score drift)
|
||||||
ACTIVE --> ARCHIVED : Disappeared from sync (score drift)
|
|
||||||
ARCHIVED --> RETURNED : Reappeared in sync
|
ARCHIVED --> RETURNED : Reappeared in sync
|
||||||
ARCHIVED --> CLOSED : Confirmed remediated in Ivanti
|
ARCHIVED --> CLOSED : Confirmed remediated in Ivanti
|
||||||
RETURNED --> ARCHIVED : Disappeared again
|
RETURNED --> ARCHIVED : Disappeared again
|
||||||
@@ -177,8 +178,7 @@ stateDiagram-v2
|
|||||||
|
|
||||||
| From State | To State | Reason |
|
| From State | To State | Reason |
|
||||||
|-----------|----------|--------|
|
|-----------|----------|--------|
|
||||||
| NONE | ACTIVE | `initial_sync` |
|
| NONE → | ARCHIVED | `severity_score_drift` (first disappearance) |
|
||||||
| ACTIVE → | ARCHIVED | `severity_score_drift` |
|
|
||||||
| ARCHIVED → | RETURNED | `reappeared_in_sync` |
|
| ARCHIVED → | RETURNED | `reappeared_in_sync` |
|
||||||
| ARCHIVED → | CLOSED | `remediated_in_ivanti` |
|
| ARCHIVED → | CLOSED | `remediated_in_ivanti` |
|
||||||
| RETURNED → | ARCHIVED | `severity_score_drift` |
|
| 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. |
|
| 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. |
|
| 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. |
|
| 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. |
|
| History query for non-existent finding | Return 200 with empty transitions array (not 404), per requirement 4.5. |
|
||||||
|
|
||||||
## Testing Strategy
|
## Testing Strategy
|
||||||
|
|||||||
@@ -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_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_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.
|
- **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
|
## Requirements
|
||||||
|
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ Implement the Finding Archive Tracking system by creating the database migration
|
|||||||
|
|
||||||
## Tasks
|
## Tasks
|
||||||
|
|
||||||
- [ ] 1. Create database migration and archive tables
|
- [x] 1. Create database migration and archive tables
|
||||||
- [ ] 1.1 Create `backend/migrations/add_finding_archive_tables.js` migration script
|
- [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_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 `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
|
- 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
|
- Run migration logic multiple times against in-memory SQLite, verify no errors and schema is consistent
|
||||||
- **Validates: Requirements 6.2**
|
- **Validates: Requirements 6.2**
|
||||||
|
|
||||||
- [ ] 2. Implement archive detection logic in sync pipeline
|
- [x] 2. Implement archive detection logic in sync pipeline
|
||||||
- [ ] 2.1 Add `initArchiveTables(db)` function to `backend/routes/ivantiFindings.js`
|
- [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
|
- Create both archive tables inline (same pattern as existing `initTables`) so they exist on startup
|
||||||
- Call from `createIvantiFindingsRouter` during init alongside existing `initTables`
|
- Call from `createIvantiFindingsRouter` during init alongside existing `initTables`
|
||||||
- _Requirements: 3.1, 3.2, 3.3, 3.4_
|
- _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
|
- 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 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
|
- 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)
|
- 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_
|
- _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
|
- 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"
|
- For any that appear in the closed findings set, update to CLOSED with reason "remediated_in_ivanti"
|
||||||
- Insert transition history for each state change
|
- Insert transition history for each state change
|
||||||
- _Requirements: 2.3_
|
- _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`
|
- Before updating the cache, read the current findings from `ivanti_findings_cache` as `previousFindings`
|
||||||
- After successful cache update, call `detectArchiveChanges(db, previousFindings, currentFindings)`
|
- After successful cache update, call `detectArchiveChanges(db, previousFindings, currentFindings)`
|
||||||
- Skip archive detection if sync encountered an error (requirement 1.5)
|
- 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"
|
- Generate archived/returned findings, mark some as closed, verify CLOSED state and reason "remediated_in_ivanti"
|
||||||
- **Validates: Requirements 2.3**
|
- **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.
|
- Ensure all tests pass, ask the user if questions arise.
|
||||||
|
|
||||||
- [ ] 4. Implement Archive API endpoints
|
- [x] 4. Implement Archive API endpoints
|
||||||
- [ ] 4.1 Create `backend/routes/ivantiArchive.js` route module
|
- [x] 4.1 Create `backend/routes/ivantiArchive.js` route module
|
||||||
- Export factory function `createIvantiArchiveRouter(db, requireAuth)` returning Express Router
|
- Export factory function `createIvantiArchiveRouter(db, requireAuth)` returning Express Router
|
||||||
- Apply `requireAuth(db)` middleware to all routes
|
- 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 `/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
|
- 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_
|
- _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`
|
- Import `createIvantiArchiveRouter` from `./routes/ivantiArchive`
|
||||||
- Mount at `/api/ivanti/archive` with `requireAuth` middleware
|
- Mount at `/api/ivanti/archive` with `requireAuth` middleware
|
||||||
- _Requirements: 4.1_
|
- _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
|
- Generate archive records with random states, query stats, verify counts match actual distribution
|
||||||
- **Validates: Requirements 4.3**
|
- **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.
|
- Ensure all tests pass, ask the user if questions arise.
|
||||||
|
|
||||||
- [ ] 6. Implement Archive Summary Bar UI component
|
- [x] 6. Implement Archive Summary Bar UI component
|
||||||
- [ ] 6.1 Create `frontend/src/components/pages/ArchiveSummaryBar.js`
|
- [x] 6.1 Create `frontend/src/components/pages/ArchiveSummaryBar.js`
|
||||||
- Fetch stats from `/api/ivanti/archive/stats` on mount
|
- 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)
|
- 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
|
- 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)
|
- 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_
|
- _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)
|
- 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
|
- Wire `onStateClick` to manage a state filter for the archive list display
|
||||||
- _Requirements: 5.3_
|
- _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.
|
- Ensure all tests pass, ask the user if questions arise.
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|||||||
75
backend/migrations/add_finding_archive_tables.js
Normal file
75
backend/migrations/add_finding_archive_tables.js
Normal file
@@ -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!');
|
||||||
|
});
|
||||||
122
backend/routes/ivantiArchive.js
Normal file
122
backend/routes/ivantiArchive.js
Normal file
@@ -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;
|
||||||
@@ -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
|
// 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',
|
projection: 'internal',
|
||||||
sort: [{ field: 'severity', direction: 'ASC' }],
|
sort: [{ field: 'severity', direction: 'ASC' }],
|
||||||
page: 0,
|
page: 0,
|
||||||
size: 1
|
size: 100
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = await ivantiPost(urlPath, body, apiKey, skipTls);
|
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);
|
const data = JSON.parse(result.body);
|
||||||
// RiskSense returns total in page.totalElements or page.total
|
// RiskSense returns total in page.totalElements or page.total
|
||||||
const closedCount = data.page?.totalElements ?? data.page?.total ?? 0;
|
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,
|
await dbRun(db,
|
||||||
`UPDATE ivanti_counts_cache SET open_count=?, closed_count=?, synced_at=datetime('now') WHERE id=1`,
|
`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}`);
|
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) {
|
} catch (err) {
|
||||||
console.error('[Ivanti Findings] Failed to fetch closed count:', err.message);
|
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
|
// Still update open count so it stays in sync; leave closed_count as-is
|
||||||
@@ -441,17 +664,36 @@ async function syncFindings(db) {
|
|||||||
page++;
|
page++;
|
||||||
} while (page < totalPages);
|
} 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,
|
await dbRun(db,
|
||||||
`UPDATE ivanti_findings_cache SET total=?, findings_json=?, synced_at=datetime('now'), sync_status='success', error_message=NULL WHERE id=1`,
|
`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)]
|
[allFindings.length, JSON.stringify(allFindings)]
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log(`[Ivanti Findings] Sync complete — ${allFindings.length} findings`);
|
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 syncClosedCount(db, allFindings.length, apiKey, clientId, skipTls);
|
||||||
await syncFPWorkflowCounts(db, allFindings, apiKey, clientId, skipTls);
|
await syncFPWorkflowCounts(db, allFindings, apiKey, clientId, skipTls);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const msg = err.message || 'Unknown error';
|
const msg = err.message || 'Unknown error';
|
||||||
console.error('[Ivanti Findings] Sync failed:', msg);
|
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]);
|
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 = []) {
|
function dbRun(db, sql, params = []) {
|
||||||
return new Promise((resolve, reject) => {
|
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) {
|
function createIvantiFindingsRouter(db, requireAuth) {
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
initTables(db)
|
Promise.all([initTables(db), initArchiveTables(db)])
|
||||||
.then(() => scheduleSync(db))
|
.then(() => scheduleSync(db))
|
||||||
.catch((err) => console.error('[Ivanti Findings] Init failed:', err));
|
.catch((err) => console.error('[Ivanti Findings] Init failed:', err));
|
||||||
|
|
||||||
@@ -700,3 +954,6 @@ function createIvantiFindingsRouter(db, requireAuth) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
module.exports = createIvantiFindingsRouter;
|
module.exports = createIvantiFindingsRouter;
|
||||||
|
module.exports.detectArchiveChanges = detectArchiveChanges;
|
||||||
|
module.exports.detectClosedFindings = detectClosedFindings;
|
||||||
|
module.exports.initArchiveTables = initArchiveTables;
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ const createArcherTicketsRouter = require('./routes/archerTickets');
|
|||||||
const createIvantiWorkflowsRouter = require('./routes/ivantiWorkflows');
|
const createIvantiWorkflowsRouter = require('./routes/ivantiWorkflows');
|
||||||
const createIvantiFindingsRouter = require('./routes/ivantiFindings');
|
const createIvantiFindingsRouter = require('./routes/ivantiFindings');
|
||||||
const createIvantiTodoQueueRouter = require('./routes/ivantiTodoQueue');
|
const createIvantiTodoQueueRouter = require('./routes/ivantiTodoQueue');
|
||||||
|
const createIvantiArchiveRouter = require('./routes/ivantiArchive');
|
||||||
const createComplianceRouter = require('./routes/compliance');
|
const createComplianceRouter = require('./routes/compliance');
|
||||||
|
|
||||||
const app = express();
|
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
|
// Ivanti queue routes — per-user staging queue for FP / Archer workflows
|
||||||
app.use('/api/ivanti/todo-queue', createIvantiTodoQueueRouter(db, requireAuth));
|
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
|
// AEO compliance routes — xlsx upload, non-compliant item tracking, notes
|
||||||
app.use('/api/compliance', createComplianceRouter(db, upload, requireAuth, requireRole));
|
app.use('/api/compliance', createComplianceRouter(db, upload, requireAuth, requireRole));
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import VulnerabilityTriagePage from './components/pages/ReportingPage';
|
|||||||
import KnowledgeBasePage from './components/pages/KnowledgeBasePage';
|
import KnowledgeBasePage from './components/pages/KnowledgeBasePage';
|
||||||
import ExportsPage from './components/pages/ExportsPage';
|
import ExportsPage from './components/pages/ExportsPage';
|
||||||
import CompliancePage from './components/pages/CompliancePage';
|
import CompliancePage from './components/pages/CompliancePage';
|
||||||
|
import ArchiveSummaryBar from './components/pages/ArchiveSummaryBar';
|
||||||
import './App.css';
|
import './App.css';
|
||||||
|
|
||||||
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
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 [ivantiLoading, setIvantiLoading] = useState(false);
|
||||||
const [ivantiSyncing, setIvantiSyncing] = useState(false);
|
const [ivantiSyncing, setIvantiSyncing] = useState(false);
|
||||||
|
|
||||||
|
// Archive filter state
|
||||||
|
const [archiveFilter, setArchiveFilter] = useState(null);
|
||||||
|
|
||||||
const toggleCVEExpand = (cveId) => {
|
const toggleCVEExpand = (cveId) => {
|
||||||
setExpandedCVEs(prev => ({ ...prev, [cveId]: !prev[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 fetchDocuments = async (cveId, vendor) => {
|
||||||
const key = `${cveId}-${vendor}`;
|
const key = `${cveId}-${vendor}`;
|
||||||
if (cveDocuments[key]) return;
|
if (cveDocuments[key]) return;
|
||||||
@@ -2251,6 +2259,9 @@ export default function App() {
|
|||||||
: 'Never synced'}
|
: 'Never synced'}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Archive Summary Bar */}
|
||||||
|
<ArchiveSummaryBar onStateClick={handleArchiveStateClick} activeFilter={archiveFilter} />
|
||||||
|
|
||||||
{ivantiLoading ? (
|
{ivantiLoading ? (
|
||||||
<div className="text-center py-8">
|
<div className="text-center py-8">
|
||||||
<Loader className="w-6 h-6 text-teal-400 animate-spin mx-auto mb-2" />
|
<Loader className="w-6 h-6 text-teal-400 animate-spin mx-auto mb-2" />
|
||||||
|
|||||||
202
frontend/src/components/pages/ArchiveSummaryBar.js
Normal file
202
frontend/src/components/pages/ArchiveSummaryBar.js
Normal file
@@ -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 (
|
||||||
|
<div
|
||||||
|
style={cardStyle}
|
||||||
|
onClick={() => 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.'}`}
|
||||||
|
>
|
||||||
|
<div style={accentLineStyle} />
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '0.625rem' }}>
|
||||||
|
<Icon
|
||||||
|
style={{
|
||||||
|
width: '16px',
|
||||||
|
height: '16px',
|
||||||
|
color: color,
|
||||||
|
filter: isHighlighted ? `drop-shadow(0 0 4px ${color})` : 'none',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span style={{
|
||||||
|
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
|
||||||
|
fontSize: '0.7rem',
|
||||||
|
fontWeight: '600',
|
||||||
|
color: color,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: '0.08em',
|
||||||
|
textShadow: isHighlighted ? `0 0 8px rgba(${hexToRgb(color)}, 0.5)` : 'none',
|
||||||
|
}}>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
|
||||||
|
fontSize: '1.75rem',
|
||||||
|
fontWeight: '700',
|
||||||
|
color: '#F8FAFC',
|
||||||
|
lineHeight: 1,
|
||||||
|
textShadow: `0 0 16px rgba(${hexToRgb(color)}, 0.3)`,
|
||||||
|
}}>
|
||||||
|
{count != null ? count : '—'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
gap: '0.5rem', padding: '1.25rem',
|
||||||
|
color: '#94A3B8', fontFamily: 'monospace', fontSize: '0.75rem',
|
||||||
|
}}>
|
||||||
|
<Loader style={{ width: '14px', height: '14px', animation: 'spin 1s linear infinite' }} />
|
||||||
|
Loading archive stats…
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
padding: '1rem', textAlign: 'center',
|
||||||
|
color: '#94A3B8', fontFamily: 'monospace', fontSize: '0.72rem',
|
||||||
|
border: '1px dashed rgba(239, 68, 68, 0.2)', borderRadius: '0.375rem',
|
||||||
|
}}>
|
||||||
|
Unable to load archive statistics
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClick = (state) => {
|
||||||
|
if (onStateClick) onStateClick(state);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
gap: '0.75rem',
|
||||||
|
marginBottom: '1.25rem',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
}}>
|
||||||
|
{STATE_CONFIG.map(({ key, label, color, Icon }) => (
|
||||||
|
<StatCard
|
||||||
|
key={key}
|
||||||
|
stateKey={key}
|
||||||
|
label={label}
|
||||||
|
color={color}
|
||||||
|
Icon={Icon}
|
||||||
|
count={stats?.[key] ?? 0}
|
||||||
|
active={activeFilter === key}
|
||||||
|
onClick={handleClick}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user