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