Compare commits
4 Commits
2b4ec5d8e2
...
1ef57b0504
| Author | SHA1 | Date | |
|---|---|---|---|
| 1ef57b0504 | |||
|
|
d1fe0bf455 | ||
|
|
3f7887eba6 | ||
|
|
9bd5a52661 |
16
.kiro/hooks/check-component-conventions.kiro.hook
Normal file
16
.kiro/hooks/check-component-conventions.kiro.hook
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"enabled": true,
|
||||
"name": "Check Component Conventions",
|
||||
"description": "On save of files in frontend/src/components/, verifies the component follows project conventions and flags deviations as inline comments.",
|
||||
"version": "1",
|
||||
"when": {
|
||||
"type": "fileEdited",
|
||||
"patterns": [
|
||||
"frontend/src/components/**/*.js"
|
||||
]
|
||||
},
|
||||
"then": {
|
||||
"type": "askAgent",
|
||||
"prompt": "Review the saved component file and verify it follows these project conventions:\n\n1. Functional component with hooks (no class components)\n2. Uses Lucide icons for iconography (not raw SVGs or other icon libraries)\n3. Uses inline styles or existing CSS classes from App.css (no CSS modules, no styled-components)\n4. Fetches data with fetch() using relative API paths and credentials: 'include' (no axios, no absolute URLs)\n5. Handles loading and error states when fetching data\n\nFor any deviations found, add inline comments in the code flagging the issue, e.g. // ⚠️ CONVENTION: Use lucide-react icons instead of raw SVGs\n\nOnly flag actual deviations. Do not modify working logic or refactor the component."
|
||||
}
|
||||
}
|
||||
16
.kiro/hooks/jsdoc-route-docs.kiro.hook
Normal file
16
.kiro/hooks/jsdoc-route-docs.kiro.hook
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"enabled": true,
|
||||
"name": "JSDoc Route Documentation",
|
||||
"description": "On save of files in backend/routes/, ensures every exported route handler has a JSDoc comment documenting the HTTP method, path, query parameters, request body shape, and response shape. Uses the existing documentation style in the file. Does not add comments to internal helper functions.",
|
||||
"version": "1",
|
||||
"when": {
|
||||
"type": "fileEdited",
|
||||
"patterns": [
|
||||
"backend/routes/*.js"
|
||||
]
|
||||
},
|
||||
"then": {
|
||||
"type": "askAgent",
|
||||
"prompt": "Review the saved route file and ensure every exported route handler (e.g., router.get, router.post, router.put, router.patch, router.delete) has a JSDoc comment directly above it documenting: the HTTP method, the route path, any query parameters, the request body shape (if applicable), and the response shape. Match the existing documentation style already used in the file. Do NOT add JSDoc comments to internal helper functions that are not route handlers. Only add missing documentation — do not modify or remove existing JSDoc comments that are already correct."
|
||||
}
|
||||
}
|
||||
16
.kiro/hooks/sqlite3-safety-check.kiro.hook
Normal file
16
.kiro/hooks/sqlite3-safety-check.kiro.hook
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"enabled": true,
|
||||
"name": "SQLite3 Safety Check",
|
||||
"description": "On save of files containing db.run, db.get, or db.all, verifies all sqlite3 calls use parameterized queries (? placeholders) instead of string concatenation, handle the error parameter first in every callback, and use hardcoded table/column names. Flags violations as inline comments prefixed with \"// FIXME:\".",
|
||||
"version": "1",
|
||||
"when": {
|
||||
"type": "fileEdited",
|
||||
"patterns": [
|
||||
"backend/**/*.js"
|
||||
]
|
||||
},
|
||||
"then": {
|
||||
"type": "askAgent",
|
||||
"prompt": "The saved file may contain sqlite3 calls (db.run, db.get, or db.all). Scan the file and verify all sqlite3 calls follow these rules:\n\n1. Parameterized queries only: All SQL queries must use ? placeholders for dynamic values. Never use string concatenation or template literals to inject values into SQL strings.\n2. Error-first callbacks: Every callback passed to db.run, db.get, or db.all must handle the error parameter first (e.g., `if (err) { ... }`).\n3. Hardcoded table/column names: All table and column names in SQL strings must be hardcoded string literals, never sourced from variables or parameters.\n\nIf the file does not contain any db.run, db.get, or db.all calls, skip the check silently.\n\nFor any violations found, add an inline comment on the offending line prefixed with \"// FIXME:\" describing the specific issue. Do not modify any other code."
|
||||
}
|
||||
}
|
||||
16
.kiro/hooks/verify-migration-pattern.kiro.hook
Normal file
16
.kiro/hooks/verify-migration-pattern.kiro.hook
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"enabled": true,
|
||||
"name": "Verify Migration Pattern",
|
||||
"description": "On save or create of migration files (migrate*.js), verifies the migration follows existing project patterns: uses CREATE TABLE IF NOT EXISTS, includes explicit column types, adds appropriate indexes, and wraps multiple statements in transactions. Compares against existing migrations for style consistency.",
|
||||
"version": "1",
|
||||
"when": {
|
||||
"type": "fileEdited",
|
||||
"patterns": [
|
||||
"**/migrate*.js"
|
||||
]
|
||||
},
|
||||
"then": {
|
||||
"type": "askAgent",
|
||||
"prompt": "A migration file was just saved. Review the edited file and verify it follows the existing migration pattern used in this project. Check the existing migrations in backend/migrations/ for reference, then verify the edited file:\n\n1. Uses CREATE TABLE IF NOT EXISTS (not just CREATE TABLE)\n2. Includes all columns with explicit SQLite types (TEXT, INTEGER, REAL, etc.)\n3. Adds appropriate indexes for foreign keys and frequently queried columns\n4. Wraps operations in a serialized transaction (db.serialize + db.run(\"BEGIN TRANSACTION\") / COMMIT) if there are multiple statements\n5. Follows the same callback-based db.run() style as existing migrations\n6. Includes proper error handling\n\nCompare the file against the existing migrations in backend/migrations/ for style consistency. Report any deviations or issues found."
|
||||
}
|
||||
}
|
||||
16
.kiro/hooks/verify-new-migration.kiro.hook
Normal file
16
.kiro/hooks/verify-new-migration.kiro.hook
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"enabled": true,
|
||||
"name": "Verify New Migration",
|
||||
"description": "On creation of new migration files in backend/migrations/, verifies the migration follows existing project patterns: uses CREATE TABLE IF NOT EXISTS, includes explicit column types, adds appropriate indexes, and wraps multiple statements in transactions.",
|
||||
"version": "1",
|
||||
"when": {
|
||||
"type": "fileCreated",
|
||||
"patterns": [
|
||||
"**/migrations/*.js"
|
||||
]
|
||||
},
|
||||
"then": {
|
||||
"type": "askAgent",
|
||||
"prompt": "A new migration file was just created. Review the file and verify it follows the existing migration pattern used in this project. Check the existing migrations in backend/migrations/ for reference, then verify the new file:\n\n1. Uses CREATE TABLE IF NOT EXISTS (not just CREATE TABLE)\n2. Includes all columns with explicit SQLite types (TEXT, INTEGER, REAL, etc.)\n3. Adds appropriate indexes for foreign keys and frequently queried columns\n4. Wraps operations in a serialized transaction (db.serialize + db.run(\"BEGIN TRANSACTION\") / COMMIT) if there are multiple statements\n5. Follows the same callback-based db.run() style as existing migrations\n6. Includes proper error handling\n\nCompare the file against the existing migrations in backend/migrations/ for style consistency. Report any deviations or issues found."
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
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 ('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!');
|
||||
});
|
||||
162
backend/routes/ivantiArchive.js
Normal file
162
backend/routes/ivantiArchive.js
Normal file
@@ -0,0 +1,162 @@
|
||||
// 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 filtering.
|
||||
*
|
||||
* @query {string} [state] - Filter by lifecycle state (ACTIVE, ARCHIVED, RETURNED, CLOSED)
|
||||
* @returns {Object} 200 - { archives: Array<ArchiveRecord>, total: number }
|
||||
* @returns {Object} 400 - { error: string } when state param is invalid
|
||||
* @returns {Object} 500 - { error: string } on database failure
|
||||
*/
|
||||
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 of archive records by lifecycle state.
|
||||
* ACTIVE is implicit: live findings in the cache that have no ARCHIVED/RETURNED archive record.
|
||||
*
|
||||
* @returns {Object} 200 - { ACTIVE: number, ARCHIVED: number, RETURNED: number, CLOSED: number, total: number }
|
||||
* @returns {Object} 500 - { error: string } on database failure
|
||||
*/
|
||||
router.get('/stats', async (req, res) => {
|
||||
try {
|
||||
// Count archive records by state
|
||||
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 };
|
||||
|
||||
for (const row of rows) {
|
||||
if (stats.hasOwnProperty(row.current_state)) {
|
||||
stats[row.current_state] = row.count;
|
||||
}
|
||||
}
|
||||
|
||||
// Compute ACTIVE: total live findings minus those with ARCHIVED or RETURNED records
|
||||
const cacheRow = await new Promise((resolve, reject) => {
|
||||
db.get(
|
||||
'SELECT total FROM ivanti_findings_cache WHERE id = 1',
|
||||
(err, row) => {
|
||||
if (err) reject(err);
|
||||
else resolve(row);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
const liveFindingsCount = (cacheRow && cacheRow.total) || 0;
|
||||
// Findings that are ARCHIVED or RETURNED are "missing" from the live set,
|
||||
// so ACTIVE = live count (all findings currently present in sync results)
|
||||
stats.ACTIVE = liveFindingsCount;
|
||||
|
||||
const total = stats.ACTIVE + stats.ARCHIVED + stats.RETURNED + stats.CLOSED;
|
||||
|
||||
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 specific archived finding, ordered by most recent first.
|
||||
* Returns an empty transitions array if the finding has no archive record.
|
||||
*
|
||||
* @param {string} findingId - Ivanti finding identifier (route param)
|
||||
* @returns {Object} 200 - { finding_id: string, transitions: Array<TransitionRecord> }
|
||||
* @returns {Object} 500 - { error: string } on database failure
|
||||
*/
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -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,12 @@ export default function App() {
|
||||
const [ivantiLoading, setIvantiLoading] = useState(false);
|
||||
const [ivantiSyncing, setIvantiSyncing] = useState(false);
|
||||
|
||||
// Archive filter state
|
||||
const [archiveFilter, setArchiveFilter] = useState(null);
|
||||
const [archiveRefreshKey, setArchiveRefreshKey] = useState(0);
|
||||
const [archiveList, setArchiveList] = useState([]);
|
||||
const [archiveListLoading, setArchiveListLoading] = useState(false);
|
||||
|
||||
const toggleCVEExpand = (cveId) => {
|
||||
setExpandedCVEs(prev => ({ ...prev, [cveId]: !prev[cveId] }));
|
||||
};
|
||||
@@ -366,6 +373,22 @@ export default function App() {
|
||||
console.error('Error syncing Ivanti workflows:', err);
|
||||
} finally {
|
||||
setIvantiSyncing(false);
|
||||
setArchiveRefreshKey(k => k + 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleArchiveStateClick = (state) => {
|
||||
const newFilter = archiveFilter === state ? null : state;
|
||||
setArchiveFilter(newFilter);
|
||||
if (newFilter) {
|
||||
setArchiveListLoading(true);
|
||||
fetch(`${API_BASE}/ivanti/archive?state=${newFilter}`, { credentials: 'include' })
|
||||
.then(res => res.ok ? res.json() : Promise.reject())
|
||||
.then(data => setArchiveList(data.archives || []))
|
||||
.catch(() => setArchiveList([]))
|
||||
.finally(() => setArchiveListLoading(false));
|
||||
} else {
|
||||
setArchiveList([]);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2251,6 +2274,49 @@ export default function App() {
|
||||
: 'Never synced'}
|
||||
</div>
|
||||
|
||||
{/* Archive Summary Bar */}
|
||||
<ArchiveSummaryBar onStateClick={handleArchiveStateClick} activeFilter={archiveFilter} refreshKey={archiveRefreshKey} />
|
||||
|
||||
{/* Archive list — shown when a state card is clicked */}
|
||||
{archiveFilter && (
|
||||
<div style={{ marginBottom: '1rem' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.5rem' }}>
|
||||
<span style={{ fontFamily: 'monospace', fontSize: '0.75rem', color: '#94A3B8', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
||||
{archiveFilter} findings
|
||||
</span>
|
||||
<button
|
||||
onClick={() => { setArchiveFilter(null); setArchiveList([]); }}
|
||||
style={{ background: 'none', border: 'none', color: '#94A3B8', cursor: 'pointer', fontFamily: 'monospace', fontSize: '0.7rem' }}
|
||||
>
|
||||
✕ Clear
|
||||
</button>
|
||||
</div>
|
||||
{archiveListLoading ? (
|
||||
<div style={{ textAlign: 'center', padding: '1rem', color: '#94A3B8', fontFamily: 'monospace', fontSize: '0.75rem' }}>Loading…</div>
|
||||
) : archiveList.length === 0 ? (
|
||||
<div style={{ textAlign: 'center', padding: '1rem', color: '#64748B', fontFamily: 'monospace', fontSize: '0.72rem', border: '1px dashed rgba(100, 116, 139, 0.3)', borderRadius: '0.375rem' }}>
|
||||
No {archiveFilter.toLowerCase()} findings
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ maxHeight: '240px', overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: '0.375rem' }}>
|
||||
{archiveList.map((a) => (
|
||||
<div key={a.id} style={{ background: 'linear-gradient(135deg, rgba(30, 41, 59, 0.85), rgba(51, 65, 85, 0.75))', border: '1px solid rgba(100, 116, 139, 0.25)', borderRadius: '0.375rem', padding: '0.5rem' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'start', gap: '0.5rem', marginBottom: '0.25rem' }}>
|
||||
<span style={{ fontFamily: 'monospace', fontSize: '0.7rem', fontWeight: '600', color: '#E2E8F0' }}>{a.finding_title || a.finding_id}</span>
|
||||
<span style={{ fontFamily: 'monospace', fontSize: '0.6rem', padding: '0.15rem 0.35rem', borderRadius: '0.25rem', background: 'rgba(100, 116, 139, 0.2)', border: '1px solid rgba(100, 116, 139, 0.4)', color: '#94A3B8', whiteSpace: 'nowrap' }}>
|
||||
{a.last_severity?.toFixed(1) ?? '—'}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ fontFamily: 'monospace', fontSize: '0.65rem', color: '#64748B' }}>
|
||||
{a.host_name}{a.ip_address ? ` (${a.ip_address})` : ''}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{ivantiLoading ? (
|
||||
<div className="text-center py-8">
|
||||
<Loader className="w-6 h-6 text-teal-400 animate-spin mx-auto mb-2" />
|
||||
|
||||
205
frontend/src/components/pages/ArchiveSummaryBar.js
Normal file
205
frontend/src/components/pages/ArchiveSummaryBar.js
Normal file
@@ -0,0 +1,205 @@
|
||||
// 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, refreshKey }) {
|
||||
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();
|
||||
|
||||
// Re-fetch every 60s so stats stay reasonably fresh after syncs
|
||||
const interval = setInterval(load, 60000);
|
||||
return () => { cancelled = true; clearInterval(interval); };
|
||||
}, [refreshKey]);
|
||||
|
||||
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