feat(archive): add finding archive tracking to Ivanti sync pipeline

Adds a four-state lifecycle tracker (ACTIVE → ARCHIVED → RETURNED → CLOSED)
to detect and monitor findings that disappear from Ivanti sync results due to
severity score drift rather than actual remediation.

- Archive detection runs automatically after each sync, comparing previous
  and current finding sets to identify disappearances and reappearances
- Full transition history stored in ivanti_finding_archives and
  ivanti_archive_transitions tables with timestamps and severity scores
- Three new API endpoints: /api/ivanti/archive, /api/ivanti/archive/stats,
  /api/ivanti/archive/:findingId/history
- Archive Summary Bar UI on the home page shows counts for each state
  (Active, Archived, Returned, Closed) with click-through finding lists
- Two new migrations: add_finding_archive_tables, add_archer_tickets_timestamps
- Mermaid diagram support added to Knowledge Base viewer
This commit is contained in:
2026-04-06 09:51:56 -06:00
17 changed files with 1553 additions and 3 deletions

View 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."
}
}

View 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."
}
}

View 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."
}
}

View 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."
}
}

View 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."
}
}

View File

@@ -0,0 +1,293 @@
# Design Document: Finding Archive Tracking
## Overview
The Finding Archive Tracking system adds a detection layer to the existing Ivanti sync pipeline that identifies findings which disappear from sync results due to severity score drift. It tracks these findings through a four-state lifecycle (ACTIVE → ARCHIVED → RETURNED → CLOSED) with full transition history stored in two new SQLite tables. Three new API endpoints expose archive data, and an Archive Summary Bar UI component provides at-a-glance state counts on the Ivanti dashboard.
The system integrates directly into the existing `syncFindings()` function in `ivantiFindings.js`, comparing current sync results against the previous set to detect disappearances and reappearances. This approach requires no additional API calls to Ivanti and leverages the already-cached findings data.
## Architecture
```mermaid
flowchart TD
subgraph Ivanti Sync Pipeline
A[syncFindings] --> B[Fetch all pages from Ivanti API]
B --> C[Store findings in ivanti_findings_cache]
C --> D[Archive Detection]
end
subgraph Archive Detection
D --> E{Compare previous vs current finding IDs}
E -->|Missing from current| F[Create/Update Archive Record → ARCHIVED]
E -->|Returned in current| G[Update Archive Record → RETURNED]
E -->|Closed in Ivanti| H[Update Archive Record → CLOSED]
F --> I[Insert Transition History]
G --> I
H --> I
end
subgraph Archive API
J[GET /api/ivanti/archive] --> K[(ivanti_finding_archives)]
L[GET /api/ivanti/archive/:findingId/history] --> M[(ivanti_archive_transitions)]
N[GET /api/ivanti/archive/stats] --> K
end
subgraph Frontend
O[Archive Summary Bar] -->|fetch stats| N
O -->|click state| J
P[Transition History Panel] -->|fetch history| L
end
```
### Integration Points
1. **Sync Pipeline Hook**: Archive detection runs after `syncFindings()` successfully stores new findings in the cache. It reads the previous findings from the cache before the update, then compares against the new set.
2. **Route Registration**: The archive router is mounted at `/api/ivanti/archive` in `server.js`, following the same factory pattern as existing Ivanti routes.
3. **Frontend Integration**: The Archive Summary Bar is rendered on the existing Ivanti findings page, above the findings table.
## Components and Interfaces
### 1. Archive Detection Module (`detectArchiveChanges`)
Located within `backend/routes/ivantiFindings.js`, this async function runs after a successful sync.
```javascript
/**
* Compare previous and current finding sets to detect archive state changes.
* @param {sqlite3.Database} db - SQLite database instance
* @param {Array} previousFindings - Findings from before the sync update
* @param {Array} currentFindings - Findings from the latest sync
*/
async function detectArchiveChanges(db, previousFindings, currentFindings) {
// 1. Build ID sets from previous and current
// 2. Disappeared = in previous but not in current → ARCHIVED
// 3. Returned = in current AND has existing ARCHIVED record → RETURNED
// 4. For each state change, upsert archive record + insert transition
}
```
### 2. Closed Finding Detection (`detectClosedFindings`)
Runs during the closed count sync to detect findings that transitioned to CLOSED in Ivanti.
```javascript
/**
* Check archived findings against Ivanti closed findings to detect remediation.
* @param {sqlite3.Database} db - SQLite database instance
* @param {Array} closedFindingIds - IDs of findings confirmed closed in Ivanti
*/
async function detectClosedFindings(db, closedFindingIds) {
// For each archived/returned finding, if it appears in closed set → CLOSED
}
```
### 3. Archive API Router (`createIvantiArchiveRouter`)
Located at `backend/routes/ivantiArchive.js`, follows the existing factory pattern.
```javascript
/**
* @param {sqlite3.Database} db - SQLite database instance
* @param {Function} requireAuth - Auth middleware factory
* @returns {express.Router}
*/
function createIvantiArchiveRouter(db, requireAuth) {
const router = express.Router();
router.use(requireAuth(db));
// GET / - List archive records, optional ?state= filter
// GET /stats - Summary counts by state
// GET /:findingId/history - Transition history for a finding
return router;
}
```
### 4. Archive Summary Bar Component (`ArchiveSummaryBar`)
Located at `frontend/src/components/pages/ArchiveSummaryBar.js`.
```javascript
/**
* Displays four stat cards for ACTIVE, ARCHIVED, RETURNED, CLOSED counts.
* @param {Object} props
* @param {Function} props.onStateClick - Callback when a state card is clicked
* @param {string|null} props.activeFilter - Currently selected state filter
*/
function ArchiveSummaryBar({ onStateClick, activeFilter }) { ... }
```
### API Endpoint Specifications
| 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 | `{ ARCHIVED: N, RETURNED: N, CLOSED: N, total: N }` |
| `/api/ivanti/archive/:findingId/history` | GET | Required | None | `{ finding_id: "...", transitions: [...] }` |
## Data Models
### `ivanti_finding_archives` Table
| Column | Type | Constraints | Description |
|--------|------|-------------|-------------|
| `id` | INTEGER | PRIMARY KEY AUTOINCREMENT | Row ID |
| `finding_id` | TEXT | NOT NULL UNIQUE | Ivanti finding identifier |
| `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 ('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 |
| `created_at` | DATETIME | DEFAULT CURRENT_TIMESTAMP | Row creation time |
**Indexes:**
- `idx_archive_finding_id` on `finding_id`
- `idx_archive_current_state` on `current_state`
### `ivanti_archive_transitions` Table
| Column | Type | Constraints | Description |
|--------|------|-------------|-------------|
| `id` | INTEGER | PRIMARY KEY AUTOINCREMENT | Row ID |
| `archive_id` | INTEGER | NOT NULL, FK → ivanti_finding_archives(id) | Parent archive record |
| `from_state` | TEXT | NOT NULL | Previous state (or 'NONE' for initial) |
| `to_state` | TEXT | NOT NULL | New state |
| `severity_at_transition` | REAL | NOT NULL DEFAULT 0 | Severity score at time of transition |
| `reason` | TEXT | NOT NULL DEFAULT '' | Human-readable reason |
| `transitioned_at` | DATETIME | NOT NULL DEFAULT CURRENT_TIMESTAMP | When transition occurred |
**Indexes:**
- `idx_transition_archive_id` on `archive_id`
### 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
[*] --> ARCHIVED : Finding disappears from sync (score drift)
ARCHIVED --> RETURNED : Reappeared in sync
ARCHIVED --> CLOSED : Confirmed remediated in Ivanti
RETURNED --> ARCHIVED : Disappeared again
RETURNED --> CLOSED : Confirmed remediated in Ivanti
```
### Valid State Transitions
| From State | To State | Reason |
|-----------|----------|--------|
| NONE → | ARCHIVED | `severity_score_drift` (first disappearance) |
| ARCHIVED → | RETURNED | `reappeared_in_sync` |
| ARCHIVED → | CLOSED | `remediated_in_ivanti` |
| RETURNED → | ARCHIVED | `severity_score_drift` |
| RETURNED → | CLOSED | `remediated_in_ivanti` |
## Correctness Properties
*A property is a characteristic or behavior that should hold true across all valid executions of a system — essentially, a formal statement about what the system should do. Properties serve as the bridge between human-readable specifications and machine-verifiable correctness guarantees.*
### Property 1: Disappeared findings are archived with complete metadata
*For any* set of previous findings and current findings, every finding present in the previous set but absent from the current set should have an Archive_Record with state ARCHIVED, and that record should contain the correct finding_id, finding_title, host_name, ip_address, and last_severity matching the original finding's data.
**Validates: Requirements 1.1, 1.2, 2.2**
### Property 2: Returned findings transition from ARCHIVED to RETURNED
*For any* finding that has an Archive_Record with state ARCHIVED, if that finding reappears in the current sync results, the Archive_Record state should be updated to RETURNED and the last_severity should reflect the finding's current severity score.
**Validates: Requirements 1.3**
### Property 3: Re-disappeared findings transition from RETURNED to ARCHIVED
*For any* finding that has an Archive_Record with state RETURNED, if that finding disappears from the current sync results, the Archive_Record state should be updated back to ARCHIVED.
**Validates: Requirements 1.4**
### Property 4: Every state transition produces a history record with all required fields
*For any* state transition on an Archive_Record, a Transition_History row should be inserted containing a valid archive_id, the correct from_state and to_state, a severity_at_transition value, a non-empty reason string, and a transitioned_at timestamp.
**Validates: Requirements 2.1**
### Property 5: Closed findings transition to CLOSED state
*For any* finding that has an Archive_Record with state ARCHIVED or RETURNED, if that finding appears in the Ivanti closed findings set, the Archive_Record state should be updated to CLOSED and the transition reason should be "remediated_in_ivanti".
**Validates: Requirements 2.3**
### Property 6: State filter returns only matching records
*For any* set of Archive_Records with various states, querying the archive list endpoint with a state filter should return only records whose current_state matches the filter, and the count should equal the number of records in that state.
**Validates: Requirements 4.1**
### Property 7: Transition history is ordered by timestamp descending
*For any* finding with multiple Transition_History entries, the history endpoint should return entries ordered by transitioned_at descending, such that each entry's timestamp is greater than or equal to the next entry's timestamp.
**Validates: Requirements 4.2**
### Property 8: Stats counts match actual record distribution
*For any* set of Archive_Records, the stats endpoint should return counts where the sum of all state counts equals the total number of Archive_Records, and each individual state count matches the actual number of records in that state.
**Validates: Requirements 4.3**
### Property 9: Migration idempotency
*For any* number of consecutive executions of the migration script, the resulting database schema should be identical and no errors should occur on subsequent runs.
**Validates: Requirements 6.2**
## Error Handling
| Scenario | Handling |
|----------|----------|
| Sync fails (API error, timeout) | Archive detection is skipped entirely for that cycle. No archive records are created or modified. The sync error is logged as usual. |
| Database error during archive upsert | Log the error, continue processing remaining findings. Do not abort the entire archive detection pass. |
| 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 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
### Unit Tests
Unit tests cover specific examples and edge cases:
- Migration script creates both tables and all indexes (example, Req 3.13.4)
- Archive detection skips when sync errors occur (example, Req 1.5)
- Unauthenticated requests return 401 (example, Req 4.4)
- History endpoint returns empty array for unknown finding (edge case, Req 4.5)
- Archive Summary Bar renders four stat cards (example, Req 5.1)
- Archive Summary Bar fetches stats on mount (example, Req 5.2)
- Clicking a state card triggers filter callback (example, Req 5.3)
### Property-Based Tests
Property-based tests use a PBT library (e.g., `fast-check`) to verify universal properties across generated inputs. Each test runs a minimum of 100 iterations.
| Property | Test Description | Tag |
|----------|-----------------|-----|
| Property 1 | Generate random previous/current finding sets, run detection, verify all disappeared findings have correct ARCHIVED records | **Feature: finding-archive-tracking, Property 1: Disappeared findings are archived with complete metadata** |
| Property 2 | Generate archived findings, add some back to current set, verify RETURNED state | **Feature: finding-archive-tracking, Property 2: Returned findings transition from ARCHIVED to RETURNED** |
| Property 3 | Generate returned findings, remove some from current set, verify ARCHIVED state | **Feature: finding-archive-tracking, Property 3: Re-disappeared findings transition from RETURNED to ARCHIVED** |
| Property 4 | Generate random state transitions, verify each produces a complete history row | **Feature: finding-archive-tracking, Property 4: Every state transition produces a history record** |
| Property 5 | Generate archived/returned findings, mark some as closed, verify CLOSED state and reason | **Feature: finding-archive-tracking, Property 5: Closed findings transition to CLOSED state** |
| Property 6 | Generate archive records with random states, query with filter, verify only matching records returned | **Feature: finding-archive-tracking, Property 6: State filter returns only matching records** |
| Property 7 | Generate multiple transitions for a finding, query history, verify descending order | **Feature: finding-archive-tracking, Property 7: Transition history is ordered by timestamp descending** |
| Property 8 | Generate archive records with random states, query stats, verify counts match | **Feature: finding-archive-tracking, Property 8: Stats counts match actual record distribution** |
| Property 9 | Run migration N times, verify no errors and schema is consistent | **Feature: finding-archive-tracking, Property 9: Migration idempotency** |
### Testing Tools
- **Test runner**: Jest (via react-scripts for frontend, direct for backend)
- **Property-based testing**: `fast-check` library
- **Database**: In-memory SQLite (`:memory:`) for isolated test runs
- **HTTP testing**: `supertest` for API endpoint tests

View File

@@ -0,0 +1,86 @@
# Requirements Document
## Introduction
The Finding Archive Tracking system extends the Ivanti sync pipeline in the STEAM Security Dashboard to detect and track findings that disappear from sync results due to severity score drift (not remediation). Findings follow a four-state lifecycle (ACTIVE → ARCHIVED → RETURNED → CLOSED) with full transition history, enabling the security team to maintain visibility into findings that fall below the severity threshold and may reappear.
## Glossary
- **Sync_Pipeline**: The existing Ivanti/RiskSense host finding sync process that fetches open findings matching BU and severity filters on a daily schedule.
- **Finding**: A single host-level vulnerability record identified by a unique `finding_id` from Ivanti/RiskSense.
- **Archive_Record**: A database row in the `ivanti_finding_archives` table tracking a finding's current lifecycle state and metadata.
- **Transition_History**: A database row in the `ivanti_archive_transitions` table recording a single state change event with timestamps, severity scores, and reason.
- **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 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
### Requirement 1: Archive Detection During Sync
**User Story:** As a security analyst, I want the system to automatically detect findings that disappear from sync results, so that I can track findings lost due to severity score drift rather than actual remediation.
#### Acceptance Criteria
1. WHEN the Sync_Pipeline completes a sync, THE Archive_Detector SHALL compare the current sync result finding IDs against the previous sync result finding IDs to identify findings that are no longer present.
2. WHEN a finding is present in the previous sync but absent from the current sync, THE Archive_Detector SHALL create an Archive_Record with state ARCHIVED, recording the finding metadata, last known severity score, and a timestamp.
3. WHEN a finding already has an Archive_Record with state ARCHIVED and the finding reappears in the current sync results, THE Archive_Detector SHALL update the Archive_Record state to RETURNED and record the new severity score.
4. WHEN a finding has an Archive_Record with state RETURNED and the finding disappears again from sync results, THE Archive_Detector SHALL update the Archive_Record state to ARCHIVED and record the severity score at time of disappearance.
5. IF the Sync_Pipeline encounters a sync error, THEN THE Archive_Detector SHALL skip archive detection for that sync cycle to avoid false positives from incomplete data.
### Requirement 2: Lifecycle State Transitions
**User Story:** As a security analyst, I want every state change to be recorded with context, so that I can audit the full history of a finding's lifecycle.
#### Acceptance Criteria
1. WHEN an Archive_Record changes state, THE Sync_Pipeline SHALL insert a Transition_History row containing the previous state, new state, timestamp, severity score at time of transition, and a reason string.
2. THE Archive_Record SHALL store the finding_id, finding_title, host_name, ip_address, current state, last known severity score, initial archive timestamp, and last transition timestamp.
3. WHEN a finding is confirmed as remediated (closed) in Ivanti, THE Sync_Pipeline SHALL update the Archive_Record state to CLOSED and record a Transition_History entry with reason "remediated_in_ivanti".
4. THE Transition_History SHALL store the archive_record_id, from_state, to_state, transition timestamp, severity_at_transition, and reason.
### Requirement 3: Database Schema
**User Story:** As a developer, I want the archive data stored in two normalized SQLite tables, so that the data model supports efficient queries and maintains referential integrity.
#### Acceptance Criteria
1. THE Sync_Pipeline SHALL create an `ivanti_finding_archives` table with columns for id, finding_id (unique), finding_title, host_name, ip_address, current_state, last_severity, first_archived_at, last_transition_at, and created_at.
2. THE Sync_Pipeline SHALL create an `ivanti_archive_transitions` table with columns for id, archive_id (foreign key to ivanti_finding_archives), from_state, to_state, severity_at_transition, reason, and transitioned_at.
3. THE Sync_Pipeline SHALL create indexes on ivanti_finding_archives(finding_id) and ivanti_finding_archives(current_state) for query performance.
4. THE Sync_Pipeline SHALL create an index on ivanti_archive_transitions(archive_id) for efficient history lookups.
### Requirement 4: Archive API Endpoints
**User Story:** As a frontend developer, I want REST API endpoints to query archived findings, transition history, and summary statistics, so that I can build the archive tracking UI.
#### Acceptance Criteria
1. WHEN a GET request is made to `/api/ivanti/archive`, THE Archive_API SHALL return a list of all Archive_Records with optional filtering by current_state query parameter.
2. WHEN a GET request is made to `/api/ivanti/archive/:findingId/history`, THE Archive_API SHALL return the Transition_History entries for the specified finding ordered by transitioned_at descending.
3. WHEN a GET request is made to `/api/ivanti/archive/stats`, THE Archive_API SHALL return an object containing the count of Archive_Records in each Lifecycle_State (ACTIVE, ARCHIVED, RETURNED, CLOSED).
4. WHEN an unauthenticated request is made to any Archive_API endpoint, THE Archive_API SHALL return a 401 status code.
5. WHEN a GET request is made to `/api/ivanti/archive/:findingId/history` with a finding_id that has no Archive_Record, THE Archive_API SHALL return an empty transitions array with a 200 status code.
### Requirement 5: Archive Summary Bar UI
**User Story:** As a security analyst, I want a visual summary bar on the Ivanti dashboard showing counts for each archive state, so that I can quickly assess the archive landscape and navigate to details.
#### Acceptance Criteria
1. THE Archive_Summary_Bar SHALL display four stat cards showing the count of findings in each Lifecycle_State: ACTIVE, ARCHIVED, RETURNED, and CLOSED.
2. WHEN the Archive_Summary_Bar loads, THE Archive_Summary_Bar SHALL fetch data from the `/api/ivanti/archive/stats` endpoint.
3. WHEN a user clicks a state card in the Archive_Summary_Bar, THE Archive_Summary_Bar SHALL filter the displayed archive list to show only findings in that state.
4. THE Archive_Summary_Bar SHALL use the existing design system colors: sky blue (#0EA5E9) for ACTIVE, amber (#F59E0B) for ARCHIVED, emerald (#10B981) for RETURNED, and red (#EF4444) for CLOSED.
5. THE Archive_Summary_Bar SHALL use Lucide icons and monospace typography consistent with the existing dashboard design system.
### Requirement 6: Migration Script
**User Story:** As a developer, I want a standalone migration script to create the archive tables, so that the schema can be applied to existing deployments following the established migration pattern.
#### Acceptance Criteria
1. THE migration script SHALL be located at `backend/migrations/add_finding_archive_tables.js` and follow the existing migration pattern of opening the database, running DDL statements in `db.serialize()`, and closing the connection.
2. THE migration script SHALL use `CREATE TABLE IF NOT EXISTS` and `CREATE INDEX IF NOT EXISTS` to be idempotent.
3. WHEN the migration script is executed, THE migration script SHALL log progress messages for each table and index created.

View File

@@ -0,0 +1,134 @@
# Implementation Plan: Finding Archive Tracking
## Overview
Implement the Finding Archive Tracking system by creating the database migration, archive detection logic within the existing sync pipeline, three API endpoints via a new route module, and an Archive Summary Bar UI component. Each task builds incrementally — schema first, then detection logic, then API, then frontend.
## Tasks
- [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
- Use `CREATE TABLE IF NOT EXISTS` and `CREATE INDEX IF NOT EXISTS` for idempotency
- Follow existing migration pattern: open db, `db.serialize()`, log progress, close db
- _Requirements: 3.1, 3.2, 3.3, 3.4, 6.1, 6.2, 6.3_
- [ ]* 1.2 Write property test for migration idempotency
- **Property 9: Migration idempotency**
- Run migration logic multiple times against in-memory SQLite, verify no errors and schema is consistent
- **Validates: Requirements 6.2**
- [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_
- [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
- For re-disappeared findings (has RETURNED record, not in current): update to ARCHIVED, insert transition history
- 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_
- [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_
- [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)
- Call `detectClosedFindings` during `syncClosedCount` with closed finding IDs
- _Requirements: 1.1, 1.5, 2.3_
- [ ]* 2.5 Write property test for archive detection — disappeared findings
- **Property 1: Disappeared findings are archived with complete metadata**
- Generate random previous/current finding sets using fast-check, run detectArchiveChanges against in-memory SQLite, verify all disappeared findings have ARCHIVED records with correct metadata
- **Validates: Requirements 1.1, 1.2, 2.2**
- [ ]* 2.6 Write property test for archive detection — returned findings
- **Property 2: Returned findings transition from ARCHIVED to RETURNED**
- Generate archived findings, add some back to current set, verify RETURNED state and updated severity
- **Validates: Requirements 1.3**
- [ ]* 2.7 Write property test for archive detection — re-disappeared findings
- **Property 3: Re-disappeared findings transition from RETURNED to ARCHIVED**
- Generate returned findings, remove some from current set, verify ARCHIVED state
- **Validates: Requirements 1.4**
- [ ]* 2.8 Write property test for transition history completeness
- **Property 4: Every state transition produces a history record with all required fields**
- Generate random state transitions, verify each produces a complete history row with archive_id, from_state, to_state, severity_at_transition, reason, transitioned_at
- **Validates: Requirements 2.1**
- [ ]* 2.9 Write property test for closed finding detection
- **Property 5: Closed findings transition to CLOSED state**
- Generate archived/returned findings, mark some as closed, verify CLOSED state and reason "remediated_in_ivanti"
- **Validates: Requirements 2.3**
- [x] 3. Checkpoint — Verify archive detection logic
- Ensure all tests pass, ask the user if questions arise.
- [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 }`. 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_
- [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_
- [ ]* 4.3 Write property test for state filtering
- **Property 6: State filter returns only matching records**
- Generate archive records with random states, query with filter, verify only matching records returned
- **Validates: Requirements 4.1**
- [ ]* 4.4 Write property test for history ordering
- **Property 7: Transition history is ordered by timestamp descending**
- Generate multiple transitions for a finding, query history, verify descending timestamp order
- **Validates: Requirements 4.2**
- [ ]* 4.5 Write property test for stats accuracy
- **Property 8: Stats counts match actual record distribution**
- Generate archive records with random states, query stats, verify counts match actual distribution
- **Validates: Requirements 4.3**
- [x] 5. Checkpoint — Verify API endpoints
- Ensure all tests pass, ask the user if questions arise.
- [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
- Accept `onStateClick` callback prop and `activeFilter` prop for highlighting the selected state
- 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_
- [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_
- [x] 7. Final checkpoint — Verify full integration
- Ensure all tests pass, ask the user if questions arise.
## Notes
- Tasks marked with `*` are optional and can be skipped for faster MVP
- Each task references specific requirements for traceability
- Checkpoints ensure incremental validation
- Property tests use `fast-check` library with minimum 100 iterations per test
- All backend code uses callback-based SQLite API wrapped in promises (matching existing patterns)
- All frontend code uses plain JavaScript (no TypeScript)

27
.kiro/steering/product.md Normal file
View File

@@ -0,0 +1,27 @@
# Product Overview
The STEAM Security Dashboard is a self-hosted vulnerability management tool for the NTS-AEO-STEAM and NTS-AEO-ACCESS-ENG business units. It centralizes CVE tracking, Ivanti host finding triage, AEO compliance posture monitoring, FP/Archer exception workflows, and internal documentation in a single interface.
## Core Capabilities
- Searchable CVE list with per-vendor tracking and document storage
- NVD API integration for auto-populating CVE metadata
- Ivanti/RiskSense integration for syncing open host findings with FP workflow tracking
- Reporting page with charts, advanced filtering, inline editing, and CSV/XLSX export
- Ivanti Queue for batch-processing FP, Archer, and CARD workflows
- AEO Compliance page with weekly xlsx upload, diff preview, per-team metric health cards, and device-level violation tracking
- Archer risk acceptance ticket tracking (EXC numbers) linked to CVE/vendor pairs
- Knowledge base for internal documentation and policies
- Role-based access control (viewer, editor, admin) with full audit trail
## User Roles
| Role | Permissions |
|------|------------|
| viewer | Read-only access to all data |
| editor | All viewer permissions plus create/update operations |
| admin | All editor permissions plus delete, user management, and audit log access |
## Teams Tracked
Only **STEAM** and **ACCESS-ENG** teams are tracked in the compliance module.

View File

@@ -0,0 +1,83 @@
# Project Structure & Conventions
## Directory Layout
```
cve-dashboard/
├── backend/ # Express API server
│ ├── server.js # Main entry point — app setup, middleware, CVE/document routes inline
│ ├── setup.js # One-time DB init + default admin creation
│ ├── cve_database.db # SQLite database (gitignored)
│ ├── uploads/ # File storage (gitignored)
│ ├── routes/ # Express route modules (factory pattern)
│ │ ├── auth.js
│ │ ├── users.js
│ │ ├── auditLog.js
│ │ ├── nvdLookup.js
│ │ ├── knowledgeBase.js
│ │ ├── archerTickets.js
│ │ ├── ivantiWorkflows.js
│ │ ├── ivantiFindings.js
│ │ ├── ivantiTodoQueue.js
│ │ └── compliance.js
│ ├── middleware/
│ │ └── auth.js # requireAuth(db), requireRole(...roles)
│ ├── helpers/
│ │ └── auditLog.js # logAudit() — fire-and-forget DB insert
│ ├── migrations/ # Sequential migration scripts (run manually with node)
│ └── scripts/ # Python utilities (compliance parsing, CSV import)
├── frontend/ # React 19 SPA (Create React App)
│ └── src/
│ ├── App.js # Main dashboard — CVE list, filters, modals, inline styles
│ ├── App.css # Global styles and CSS variables
│ ├── contexts/
│ │ └── AuthContext.js # Auth state provider (login, logout, role helpers)
│ └── components/
│ ├── LoginForm.js
│ ├── NavDrawer.js
│ ├── UserMenu.js
│ ├── CalendarWidget.js
│ ├── UserManagement.js
│ ├── AuditLog.js
│ ├── NvdSyncModal.js
│ ├── KnowledgeBaseModal.js
│ ├── KnowledgeBaseViewer.js
│ └── pages/ # Full-page views
│ ├── ReportingPage.js
│ ├── CompliancePage.js
│ ├── ComplianceUploadModal.js
│ ├── ComplianceDetailPanel.js
│ ├── ComplianceChartsPanel.js
│ ├── IvantiCountsChart.js
│ ├── KnowledgeBasePage.js
│ └── ExportsPage.js
├── docs/ # Internal documentation (markdown)
├── start-servers.sh # Start both servers in background
├── stop-servers.sh # Stop both servers
└── DESIGN_SYSTEM.md # UI design system reference (colors, typography, components)
```
## Backend Conventions
- Route modules export a factory function: `function createXxxRouter(db, ...middleware)` that returns an Express Router.
- The `db` (sqlite3 Database instance) is passed via dependency injection from `server.js`.
- Auth middleware: `requireAuth(db)` validates session cookie, attaches `req.user`. `requireRole('editor', 'admin')` checks role.
- All state-changing actions call `logAudit(db, { userId, username, action, entityType, entityId, details, ipAddress })`.
- Input validation is done inline in route handlers with early-return error responses.
- SQLite queries use the callback-based `db.run()`, `db.get()`, `db.all()` API.
- API routes are prefixed with `/api`. All endpoints except login/logout require a valid session cookie.
- CVE and document routes are defined inline in `server.js`; feature routes are in separate modules under `routes/`.
## Frontend Conventions
- Single-page app with page-level navigation managed in `App.js` (no React Router).
- Auth state managed via React Context (`AuthContext`). Use `useAuth()` hook for login/logout/role checks.
- API calls use `fetch()` with `credentials: 'include'` for cookie-based auth.
- API base URL from `process.env.REACT_APP_API_BASE`.
- Styling uses a mix of inline style objects (defined as constants in component files) and `App.css` global styles.
- Dark theme with a "tactical intelligence" aesthetic — see `DESIGN_SYSTEM.md` for color palette, typography, and component specs.
- Icons from `lucide-react`. Charts from `recharts`.
- Page components live in `components/pages/`. Shared components live in `components/`.
- No TypeScript — the project uses plain JavaScript throughout.

78
.kiro/steering/tech.md Normal file
View File

@@ -0,0 +1,78 @@
# Tech Stack & Build System
## Stack
| Layer | Technology |
|-------|-----------|
| Backend | Node.js 18+, Express 5 |
| Database | SQLite3 (file: `backend/cve_database.db`) |
| Auth | bcryptjs, cookie-based sessions (httpOnly, 24h expiry) |
| File uploads | Multer 2 (10MB limit) |
| Frontend | React 19 (Create React App / react-scripts 5) |
| UI Icons | lucide-react |
| Charts | recharts |
| Spreadsheet parsing | xlsx (frontend), pandas + openpyxl (backend Python scripts) |
| Markdown rendering | react-markdown |
| Diagrams | mermaid |
## Common Commands
### Backend
```bash
cd backend
node setup.js # Initialize DB, tables, indexes, default admin user
node server.js # Start backend on port 3001
```
### Frontend
```bash
cd frontend
npm install # Install dependencies
npm start # Dev server on port 3000
npm run build # Production build
npm test # Run tests (react-scripts test)
```
### Both servers (from project root)
```bash
./start-servers.sh # Start backend + frontend in background
./stop-servers.sh # Stop all servers
```
### Database Migrations (run from `backend/` in order)
```bash
node migrations/add_knowledge_base_table.js
node migrations/add_archer_tickets_table.js
node migrations/add_ivanti_sync_table.js
node migrations/add_ivanti_findings_tables.js
node migrations/add_ivanti_todo_queue_table.js
node migrations/add_card_workflow_type.js
node migrations/add_todo_queue_ip_address.js
node migrations/add_compliance_tables.js
```
### Python Scripts (from `backend/scripts/`)
```bash
# Compliance xlsx parsing (called automatically by upload flow)
python3 parse_compliance_xlsx.py <file>
# Bulk notes import
python3 import_notes_from_csv.py input.csv --dry-run
python3 import_notes_from_csv.py input.csv
```
Python dependencies: `pandas>=2.0.0`, `openpyxl>=3.0.0` (install via apt or venv).
## Environment Configuration
- `backend/.env` — PORT, CORS_ORIGINS, SESSION_SECRET, NVD_API_KEY, Ivanti API credentials
- `frontend/.env` — REACT_APP_API_BASE, REACT_APP_API_HOST
- Both `.env` files are gitignored; see `.env.example` files for templates.
- React caches env vars at build/start time — restart the frontend process after changes.
## Default Ports
| Service | URL |
|---------|-----|
| Frontend | http://localhost:3000 |
| Backend API | http://localhost:3001 |

View 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!');
});

View 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;

View File

@@ -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;

View File

@@ -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));

View File

@@ -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,12 @@ 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 [archiveRefreshKey, setArchiveRefreshKey] = useState(0);
const [archiveList, setArchiveList] = useState([]);
const [archiveListLoading, setArchiveListLoading] = useState(false);
const toggleCVEExpand = (cveId) => { const toggleCVEExpand = (cveId) => {
setExpandedCVEs(prev => ({ ...prev, [cveId]: !prev[cveId] })); setExpandedCVEs(prev => ({ ...prev, [cveId]: !prev[cveId] }));
}; };
@@ -366,6 +373,22 @@ export default function App() {
console.error('Error syncing Ivanti workflows:', err); console.error('Error syncing Ivanti workflows:', err);
} finally { } finally {
setIvantiSyncing(false); 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'} : 'Never synced'}
</div> </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 ? ( {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" />

View 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>
);
}