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/compliance-schema-watcher.kiro.hook b/.kiro/hooks/compliance-schema-watcher.kiro.hook new file mode 100644 index 0000000..fba8bf0 --- /dev/null +++ b/.kiro/hooks/compliance-schema-watcher.kiro.hook @@ -0,0 +1,13 @@ +{ + "enabled": true, + "name": "Compliance Schema Watcher", + "description": "Manually triggered before uploading a compliance xlsx to the dashboard. Diffs the xlsx structure against the parser's hand-maintained dicts (METRIC_CATEGORIES, CORE_COLS, SKIP_SHEETS) and flags anything that would cause silent data loss or misclassification. Prompts for the xlsx path and report mode.", + "version": "1", + "when": { + "type": "userTriggered" + }, + "then": { + "type": "askAgent", + "prompt": "You are the Compliance Schema Watcher agent. Follow the instructions in `.kiro/agents/compliance-schema-watcher.md` exactly.\n\nAsk the user to provide the following two inputs:\n\n1. **Path to the xlsx file:** Absolute path, or a filename to look for in `.compliance-staging/` then `~/Downloads/`. Example: `.compliance-staging/NTS_AEO_2026_04_15.xlsx`\n2. **Mode:** \"report only\" (surface drift findings in chat, no file edits) or \"report + propose edits\" (surface drift and draft specific dict changes for `backend/scripts/parse_compliance_xlsx.py`)\n\nOnce you have both inputs, follow the full schema drift workflow described in `.kiro/agents/compliance-schema-watcher.md`: resolve the file path, read the parser dicts, run the helper script to extract xlsx structure, diff against the parser's expectations, and output a categorised report with a pre-upload verdict." + } +} \ No newline at end of file diff --git a/.kiro/hooks/doc-review-trigger.kiro.hook b/.kiro/hooks/doc-review-trigger.kiro.hook new file mode 100644 index 0000000..5b088d8 --- /dev/null +++ b/.kiro/hooks/doc-review-trigger.kiro.hook @@ -0,0 +1,13 @@ +{ + "enabled": true, + "name": "Doc Review", + "description": "Manually triggered after merging to master. Reads the recent git diff, classifies the changes, and proposes documentation updates following the doc-updater decision tree and doc-standards.md conventions.", + "version": "1", + "when": { + "type": "userTriggered" + }, + "then": { + "type": "askAgent", + "prompt": "Run a documentation review against the latest changes on master. Follow these steps exactly:\n\n1. Run `git log --oneline -10` to see recent commits. If any commit message contains `[skip-docs]`, stop and report NO_DOC_UPDATE_NEEDED.\n\n2. Run `git diff HEAD~1 --stat` to get the list of changed files, then `git diff HEAD~1` to get the full diff. If the diff is larger than 500 lines, report NEEDS_HUMAN_REVIEW with a summary of which areas likely need docs.\n\n3. Read `.kiro/agents/doc-updater.md` for the full decision tree and `.kiro/steering/doc-standards.md` for formatting conventions.\n\n4. Follow the doc-updater decision tree: triage the change, decide if docs need updating, survey existing docs (README.md, docs/ folder), and propose surgical edits.\n\n5. For any proposed changes, apply them directly to the doc files. Only touch README.md and files under docs/. Never touch code files.\n\n6. After applying changes, output the SUMMARY block from the decision tree so Jordan can review what was changed and why." + } +} \ No newline at end of file diff --git a/.kiro/hooks/ivanti-api-debugger.kiro.hook b/.kiro/hooks/ivanti-api-debugger.kiro.hook new file mode 100644 index 0000000..1ac5e80 --- /dev/null +++ b/.kiro/hooks/ivanti-api-debugger.kiro.hook @@ -0,0 +1,13 @@ +{ + "enabled": true, + "name": "Ivanti API Debugger", + "description": "Manually triggered when debugging a failing Ivanti API call. Prompts for the endpoint, request payload, and error response, then invokes the ivanti-api-debugger agent to diagnose the issue and update ivanti-api-reference.md with any findings.", + "version": "1", + "when": { + "type": "userTriggered" + }, + "then": { + "type": "askAgent", + "prompt": "You are the Ivanti API Debugger agent. Follow the instructions in `.kiro/agents/ivanti-api-debugger.md` exactly.\n\nAsk the user to provide the following (one clarifying question, not five — accept whatever they paste and infer the rest):\n\n1. The failing endpoint or route — either the Ivanti API path (e.g. `/workflowBatch/falsePositive/request`) or the backend route file/handler that makes the call (e.g. `backend/routes/ivantiWorkflows.js`)\n2. The request payload they sent (curl, JSON body, or code snippet)\n3. The response or error they got back (HTTP status, response body, or error message)\n\nOnce you have that context, follow the full diagnostic workflow described in `.kiro/agents/ivanti-api-debugger.md`: read the relevant route/service code, cross-reference `docs/ivanti-api-reference.md`, check for common Ivanti failure modes, form a hypothesis, and propose a concrete next request to try. If the user confirms a finding, update `docs/ivanti-api-reference.md` using its existing structure." + } +} \ 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/security-audit-tracker.kiro.hook b/.kiro/hooks/security-audit-tracker.kiro.hook new file mode 100644 index 0000000..3146434 --- /dev/null +++ b/.kiro/hooks/security-audit-tracker.kiro.hook @@ -0,0 +1,13 @@ +{ + "enabled": true, + "name": "Security Audit Tracker", + "description": "Manually triggered to scan the codebase for security issues and maintain a living audit tracker document. Prompts for scan scope (full repo or specific path) and mode (report only or report + update tracker). Invokes the security-audit-tracker agent for static analysis and doc tracking.", + "version": "1", + "when": { + "type": "userTriggered" + }, + "then": { + "type": "askAgent", + "prompt": "You are the Security Audit Tracker agent. Follow the instructions in `.kiro/agents/security-audit-tracker.md` exactly.\n\nAsk the user to provide the following two inputs:\n\n1. **Scope:** \"full repo\" to scan the entire codebase, or a specific path/module to focus on (e.g. `backend/routes/`, `frontend/src/components/`, `backend/helpers/ivantiApi.js`)\n2. **Mode:** \"scan only\" (report findings to chat, no file writes) or \"scan + update tracker\" (report findings and merge them into the tracker doc at `docs/security-audit-tracker.md`)\n\nOnce you have both inputs, follow the full diagnostic and tracking workflow described in `.kiro/agents/security-audit-tracker.md`: determine scope, check for the tracker doc (create it if missing), scan for the security failure modes listed in the agent spec, cross-reference against previously tracked findings, and output a prioritised report. In \"scan + update tracker\" mode, also merge findings into the tracker doc and update its metadata." + } +} \ 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..a6e2468 --- /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 in backend/migrations/, verifies the migration follows existing project patterns: uses CREATE TABLE IF NOT EXISTS, includes explicit column types, adds appropriate indexes, and wraps multiple statements in transactions.", + "version": "1", + "when": { + "type": "fileCreated", + "patterns": [ + "**/migrations/*.js" + ] + }, + "then": { + "type": "askAgent", + "prompt": "A new migration file was just created. Review the file and verify it follows the existing migration pattern used in this project. Check the existing migrations in backend/migrations/ for reference, then verify the new file:\n\n1. Uses CREATE TABLE IF NOT EXISTS (not just CREATE TABLE)\n2. Includes all columns with explicit SQLite types (TEXT, INTEGER, REAL, etc.)\n3. Adds appropriate indexes for foreign keys and frequently queried columns\n4. Wraps operations in a serialized transaction (db.serialize + db.run(\"BEGIN TRANSACTION\") / COMMIT) if there are multiple statements\n5. Follows the same callback-based db.run() style as existing migrations\n6. Includes proper error handling\n\nCompare the file against the existing migrations in backend/migrations/ for style consistency. Report any deviations or issues found." + } +} \ No newline at end of file diff --git a/.kiro/specs/admin-page-overhaul/.config.kiro b/.kiro/specs/admin-page-overhaul/.config.kiro new file mode 100644 index 0000000..448f681 --- /dev/null +++ b/.kiro/specs/admin-page-overhaul/.config.kiro @@ -0,0 +1 @@ +{"specId": "30e46443-e636-4df1-bb98-886f403b2e32", "workflowType": "requirements-first", "specType": "feature"} \ No newline at end of file diff --git a/.kiro/specs/admin-page-overhaul/design.md b/.kiro/specs/admin-page-overhaul/design.md new file mode 100644 index 0000000..4f9ef5a --- /dev/null +++ b/.kiro/specs/admin-page-overhaul/design.md @@ -0,0 +1,423 @@ +# Design Document: Admin Page Overhaul + +## Overview + +The Admin Page Overhaul replaces the current inline `UserManagement` modal rendering on the admin page with a full-page, themed admin panel. The new `AdminPage` component follows the same layout conventions as `CompliancePage`, `ExportsPage`, and `KnowledgeBasePage` — a top-level page component rendered in the main content area of `App.js` when `currentPage === 'admin'`. + +The page consolidates three admin functions into a single tabbed interface: + +1. **User Management** — themed table with inline add/edit forms, group badges, and active status toggles +2. **Audit Log** — paginated, filterable log table with action-type badges and date range filters +3. **System Info** — stat cards showing user counts, audit log totals, and recent activity + +All sections use the dark tactical intelligence theme defined in `DESIGN_SYSTEM.md` and `App.css` — `intel-card` containers, `intel-button` controls, `intel-input` form fields, `status-badge` action labels, and `stat-card` stat displays. + +### Design Decisions + +- **New component, not a wrapper.** The existing `UserManagement.js` and `AuditLog.js` are white-background modals with Tailwind utility classes. Wrapping them would create visual inconsistency. The `AdminPage` component builds themed versions of both panels from scratch, reusing the same backend API endpoints. +- **Existing modals preserved.** The `UserMenu` quick-access links ("Manage Users", "Audit Log") continue to open the existing modal components. This keeps the quick-access workflow intact while the admin page provides the full-featured experience. +- **No new backend endpoints.** All data comes from existing routes: `GET /api/users`, `POST/PATCH/DELETE /api/users/:id`, `GET /api/audit-logs`, `GET /api/audit-logs/actions`. +- **Inline styles + App.css classes.** Follows the project convention of defining style constants in the component file and referencing `App.css` classes (`intel-card`, `intel-button`, `data-row`, etc.) where available. + +## Architecture + +```mermaid +graph TD + A[App.js] -->|currentPage === 'admin' && isAdmin| B[AdminPage] + B --> C[Tab Navigation] + C -->|"User Management"| D[UserManagementPanel] + C -->|"Audit Log"| E[AuditLogPanel] + C -->|"System Info"| F[SystemInfoPanel] + + D -->|GET /api/users| G[Backend: users.js] + D -->|POST /api/users| G + D -->|PATCH /api/users/:id| G + D -->|DELETE /api/users/:id| G + + E -->|GET /api/audit-logs| H[Backend: auditLog.js] + E -->|GET /api/audit-logs/actions| H + + F -->|GET /api/users| G + F -->|GET /api/audit-logs?limit=10| H +``` + +### Component Hierarchy + +``` +AdminPage +├── PageHeader (title + accent glow) +├── TabNavigation (User Management | Audit Log | System Info) +├── UserManagementPanel +│ ├── AddUserButton / InlineForm +│ ├── UserTable +│ │ └── UserRow (group badge, status toggle, edit/delete actions) +│ ├── ErrorBanner +│ └── SuccessToast +├── AuditLogPanel +│ ├── FilterBar (username, action, entity type, start date, end date) +│ ├── LogTable +│ │ └── LogRow (timestamp, user, action badge, entity, details, IP) +│ ├── Pagination +│ ├── EmptyState +│ └── ErrorBanner +└── SystemInfoPanel + ├── StatCards (total users, active users, audit entries, recent logins) + └── RecentActivityList (10 most recent audit entries) +``` + +### Data Flow + +1. `App.js` renders `` when `currentPage === 'admin'` and `isAdmin()` returns true. Non-admin users are redirected to home. +2. `AdminPage` manages the active tab in local state (default: `'users'`). +3. Each panel fetches its own data on mount using `fetch()` with `credentials: 'include'`. +4. Mutations (create, update, delete user) trigger a re-fetch of the user list. Success/error feedback is shown inline. +5. Audit log panel manages its own pagination and filter state, re-fetching on filter apply or page change. +6. System info panel fetches user list and recent audit logs on mount, computing derived stats client-side. + +## Components and Interfaces + +### AdminPage (main component) + +```javascript +// frontend/src/components/pages/AdminPage.js +export default function AdminPage() { + // Props: none (reads auth context internally) + // State: + // activeTab: 'users' | 'audit' | 'system' + // Renders: PageHeader, TabNavigation, conditional panel +} +``` + +### TabNavigation + +```javascript +// Internal to AdminPage +// Props: +// activeTab: string +// onTabChange: (tab: string) => void +// Tabs: [ +// { id: 'users', label: 'User Management', icon: Shield }, +// { id: 'audit', label: 'Audit Log', icon: Clock }, +// { id: 'system', label: 'System Info', icon: Activity }, +// ] +``` + +Styling: monospace uppercase text, `--intel-accent` border and background on active tab, transparent with muted text on inactive tabs. Matches the tab pattern used in `CompliancePage` (team tabs). + +### UserManagementPanel + +```javascript +// Internal to AdminPage +// State: +// users: Array +// loading: boolean +// error: string | null +// showForm: boolean +// editingUser: User | null +// formData: { username, email, password, group } +// formError: string +// successMessage: string +// +// API calls: +// GET /api/users → fetch all users +// POST /api/users → create user +// PATCH /api/users/:id → update user (fields, group, is_active) +// DELETE /api/users/:id → delete user +// +// Group badge colors (themed): +// Admin: --intel-danger (#EF4444) +// Standard_User: --intel-accent (#0EA5E9) +// Leadership: --intel-warning (#F59E0B) +// Read_Only: --text-muted (#94A3B8) +``` + +### AuditLogPanel + +```javascript +// Internal to AdminPage +// State: +// logs: Array +// loading: boolean +// error: string | null +// pagination: { page, limit, total, totalPages } +// filters: { user, action, entityType, startDate, endDate } +// actions: string[] (populated from /api/audit-logs/actions) +// +// API calls: +// GET /api/audit-logs?page=&limit=25&user=&action=&entityType=&startDate=&endDate= +// GET /api/audit-logs/actions +// +// Action badge colors (themed): +// login/logout: --intel-success (#10B981) +// *_create: --intel-accent (#0EA5E9) +// *_update/*_edit: --intel-warning (#F59E0B) +// *_delete: --intel-danger (#EF4444) +// default: --text-muted (#94A3B8) +``` + +### SystemInfoPanel + +```javascript +// Internal to AdminPage +// State: +// users: Array +// recentLogs: Array +// loading: boolean +// errors: { users: string | null, logs: string | null } +// +// Derived stats: +// totalUsers: users.length +// activeUsers: users.filter(u => u.is_active).length +// recentLogins: users.filter(u => u.last_login && withinLast7Days(u.last_login)).length +// totalAuditEntries: fetched from audit-logs pagination.total +// +// API calls: +// GET /api/users +// GET /api/audit-logs?limit=10&page=1 +``` + +### Integration with App.js + +```javascript +// In App.js, replace: +// {currentPage === 'admin' && isAdmin() && ( +//
+// setCurrentPage('home')} /> +//
+// )} +// +// With: +// {currentPage === 'admin' && isAdmin() && } +// {currentPage === 'admin' && !isAdmin() && /* redirect to home */} +// +// Keep existing modal triggers: +// {showUserManagement && } +// {showAuditLog && } +``` + +## Data Models + +### User (from GET /api/users) + +```javascript +{ + id: number, + username: string, + email: string, + group: 'Admin' | 'Standard_User' | 'Leadership' | 'Read_Only', + is_active: 0 | 1, + created_at: string, // ISO datetime + last_login: string | null // ISO datetime +} +``` + +### AuditLogEntry (from GET /api/audit-logs) + +```javascript +{ + id: number, + user_id: number, + username: string, + action: string, // e.g. 'login', 'user_create', 'cve_delete' + entity_type: string, // e.g. 'auth', 'user', 'cve', 'document' + entity_id: string | null, + details: string | null, // JSON string + ip_address: string | null, + created_at: string // ISO datetime +} +``` + +### AuditLogPagination (from GET /api/audit-logs response) + +```javascript +{ + logs: AuditLogEntry[], + pagination: { + page: number, + limit: number, + total: number, + totalPages: number + } +} +``` + +### Tab Configuration + +```javascript +const TABS = [ + { id: 'users', label: 'User Management', icon: Shield }, + { id: 'audit', label: 'Audit Log', icon: Clock }, + { id: 'system', label: 'System Info', icon: Activity }, +]; +``` + +### Group Badge Theme Map + +```javascript +const GROUP_BADGE_THEMED = { + Admin: { bg: 'rgba(239,68,68,0.15)', border: '#EF4444', text: '#FCA5A5' }, + Standard_User: { bg: 'rgba(14,165,233,0.15)', border: '#0EA5E9', text: '#7DD3FC' }, + Leadership: { bg: 'rgba(245,158,11,0.15)', border: '#F59E0B', text: '#FCD34D' }, + Read_Only: { bg: 'rgba(148,163,184,0.15)', border: '#94A3B8', text: '#CBD5E1' }, +}; +``` + +### Action Badge Theme Map + +```javascript +const ACTION_BADGE_THEMED = { + login: { bg: 'rgba(16,185,129,0.15)', border: '#10B981', text: '#6EE7B7' }, + logout: { bg: 'rgba(148,163,184,0.15)', border: '#94A3B8', text: '#CBD5E1' }, + login_failed: { bg: 'rgba(239,68,68,0.15)', border: '#EF4444', text: '#FCA5A5' }, + user_create: { bg: 'rgba(14,165,233,0.15)', border: '#0EA5E9', text: '#7DD3FC' }, + user_update: { bg: 'rgba(245,158,11,0.15)', border: '#F59E0B', text: '#FCD34D' }, + user_delete: { bg: 'rgba(239,68,68,0.15)', border: '#EF4444', text: '#FCA5A5' }, + cve_create: { bg: 'rgba(14,165,233,0.15)', border: '#0EA5E9', text: '#7DD3FC' }, + cve_edit: { bg: 'rgba(245,158,11,0.15)', border: '#F59E0B', text: '#FCD34D' }, + cve_delete: { bg: 'rgba(239,68,68,0.15)', border: '#EF4444', text: '#FCA5A5' }, + document_upload: { bg: 'rgba(139,92,246,0.15)', border: '#8B5CF6', text: '#C4B5FD' }, + document_delete: { bg: 'rgba(239,68,68,0.15)', border: '#EF4444', text: '#FCA5A5' }, +}; +``` + +## 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: Group badge color mapping is total and correct + +*For any* valid user group string (`Admin`, `Standard_User`, `Leadership`, `Read_Only`), the group badge styling function SHALL return a non-null object with `bg`, `border`, and `text` fields matching the themed color for that group. *For any* string that is not one of the four valid groups, the function SHALL return the default muted styling. + +**Validates: Requirements 3.3** + +### Property 2: Edit form population preserves user data + +*For any* user object with arbitrary `username`, `email`, and `group` values, populating the edit form from that user SHALL result in `formData.username === user.username`, `formData.email === user.email`, and `formData.group === user.group`, with `formData.password` set to an empty string. + +**Validates: Requirements 3.5** + +### Property 3: Self-modification prevention + +*For any* user list that contains the currently authenticated admin user, the admin user's own row SHALL have the group dropdown disabled and the active status toggle disabled. *For any* other user in the list, those controls SHALL be enabled. + +**Validates: Requirements 3.8** + +### Property 4: Action badge color mapping is total and correct + +*For any* known audit log action string (from the set of defined actions: `login`, `logout`, `login_failed`, `user_create`, `user_update`, `user_delete`, `cve_create`, `cve_edit`, `cve_delete`, `document_upload`, `document_delete`), the action badge styling function SHALL return the correct themed color object. *For any* unknown action string, the function SHALL return the default muted styling. + +**Validates: Requirements 4.4** + +### Property 5: Applying filters resets pagination to page 1 + +*For any* combination of filter values (username text, action type, entity type, start date, end date) and *for any* current page number, applying the filters SHALL result in a fetch call with `page=1`. + +**Validates: Requirements 4.7** + +### Property 6: Recent login count computation + +*For any* list of user objects with random `last_login` timestamps (including null values), the computed "recent logins" count SHALL equal the number of users whose `last_login` is non-null and falls within the last 7 days from the current time. + +**Validates: Requirements 5.1** + +### Property 7: Admin-only access control + +*For any* user object, the admin page content SHALL be rendered if and only if `user.group === 'Admin'`. When `user.group` is any value other than `'Admin'`, the system SHALL redirect to the home page. + +**Validates: Requirements 6.1, 6.2** + +## Error Handling + +### User Management Panel + +| Error Scenario | Handling | +|---|---| +| `GET /api/users` fails | Display error banner with `--intel-danger` styling. User table is hidden. Retry on next tab switch or manual refresh. | +| `POST /api/users` fails (validation) | Display `formError` message below the form in danger color. Form remains open for correction. | +| `POST /api/users` fails (409 conflict) | Display "Username or email already exists" in `formError`. | +| `PATCH /api/users/:id` fails | Display inline error. Revert optimistic UI changes if any. | +| `DELETE /api/users/:id` fails | Display alert with error message. User list unchanged. | +| Self-demotion attempt | Group dropdown disabled for current user. Backend returns 400 if bypassed. | +| Self-deactivation attempt | Toggle disabled for current user. Backend returns 400 if bypassed. | + +### Audit Log Panel + +| Error Scenario | Handling | +|---|---| +| `GET /api/audit-logs` fails | Display error banner with `--intel-danger` styling. Table hidden. | +| `GET /api/audit-logs/actions` fails | Action filter dropdown shows no options. Non-critical — silently ignored. | +| Invalid date range (start > end) | Client-side: no validation needed — backend handles gracefully by returning empty results. | +| Empty result set | Display "No audit log entries found" message in `--text-muted` color. | + +### System Info Panel + +| Error Scenario | Handling | +|---|---| +| `GET /api/users` fails | Affected stat cards (total users, active users, recent logins) show "Unable to load" fallback text. | +| `GET /api/audit-logs` fails | Audit entries stat card and recent activity list show "Unable to load" fallback. | +| Partial failure (one endpoint fails) | Only the affected cards show fallback. Successfully loaded cards display normally. | + +### Success Feedback + +- Create user: green success toast "User created successfully" auto-dismisses after 2 seconds. +- Update user: green success toast "User updated successfully" auto-dismisses after 2 seconds. +- Delete user: green success toast "User deleted" auto-dismisses after 2 seconds. +- Toggle active status: immediate UI update, no toast (inline visual feedback is sufficient). + +## Testing Strategy + +### Unit Tests (Example-Based) + +Unit tests cover specific rendering, interaction, and integration scenarios: + +**AdminPage structure:** +- Renders page header with "Admin Panel" title +- Defaults to User Management tab on mount +- Switches panels when tabs are clicked +- Only renders when user is admin (access control) + +**UserManagementPanel:** +- Renders user table with all required columns +- Displays themed group badges for each group type +- Shows inline form when "Add User" is clicked +- Populates form with user data when edit is clicked +- Shows confirmation dialog on delete +- Disables self-modification controls for current user +- Displays error banner on API failure +- Displays success toast on successful operations + +**AuditLogPanel:** +- Renders log table with all required columns +- Displays themed action badges +- Renders filter controls (username, action, entity type, dates) +- Fetches page 1 when filters are applied +- Navigates pages with pagination controls +- Shows empty state when no results +- Shows error banner on API failure + +**SystemInfoPanel:** +- Renders four stat cards with correct labels +- Computes derived stats correctly from mock data +- Shows recent activity list with up to 10 entries +- Shows fallback message when an API call fails + +### Property-Based Tests + +Property-based tests use [fast-check](https://github.com/dubzzz/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 group strings (valid + invalid), verify badge function returns correct colors | Feature: admin-page-overhaul, Property 1: Group badge color mapping is total and correct | +| Property 2 | Generate random user objects, verify edit form population matches user fields exactly | Feature: admin-page-overhaul, Property 2: Edit form population preserves user data | +| Property 3 | Generate random user lists containing the current admin, verify self-edit controls are disabled | Feature: admin-page-overhaul, Property 3: Self-modification prevention | +| Property 4 | Generate random action strings (known + unknown), verify badge function returns correct colors | Feature: admin-page-overhaul, Property 4: Action badge color mapping is total and correct | +| Property 5 | Generate random filter states and current page numbers, verify fetch is called with page=1 | Feature: admin-page-overhaul, Property 5: Applying filters resets pagination to page 1 | +| Property 6 | Generate random user lists with random last_login timestamps, verify recent login count matches manual computation | Feature: admin-page-overhaul, Property 6: Recent login count computation | +| Property 7 | Generate random user objects with random groups, verify admin page renders iff group === 'Admin' | Feature: admin-page-overhaul, Property 7: Admin-only access control | + +### Test Configuration + +- **Library:** fast-check (JavaScript property-based testing) +- **Runner:** Jest (via react-scripts test) +- **Iterations:** Minimum 100 per property test (`fc.assert(property, { numRuns: 100 })`) +- **Tag format:** Comment at top of each property test referencing the design property diff --git a/.kiro/specs/admin-page-overhaul/requirements.md b/.kiro/specs/admin-page-overhaul/requirements.md new file mode 100644 index 0000000..2d61457 --- /dev/null +++ b/.kiro/specs/admin-page-overhaul/requirements.md @@ -0,0 +1,108 @@ +# Requirements Document + +## Introduction + +The STEAM Security Dashboard currently has an Admin page (`currentPage === 'admin'`) that renders the `UserManagement` modal component inline — the same modal triggered from the top-right `UserMenu`. The page does not follow the dashboard's dark "tactical intelligence" theme and provides no audit log viewing or other administrative capabilities. This feature overhauls the admin page into a dedicated, full-page admin panel that matches the design system and consolidates user management, audit log viewing, and system administration into a single cohesive interface accessible only to Admin-group users. + +## Glossary + +- **Admin_Page**: The full-page admin panel rendered when `currentPage === 'admin'`, replacing the current inline modal rendering +- **Dashboard**: The STEAM Security Dashboard application +- **Design_System**: The color palette, typography, component specs, and interaction patterns defined in `DESIGN_SYSTEM.md` and `App.css` +- **Audit_Log_Panel**: The section of the Admin_Page that displays paginated, filterable audit log entries +- **User_Management_Panel**: The section of the Admin_Page that displays the user list and provides create, edit, delete, and activate/deactivate operations +- **Admin_User**: A user whose `user_group` is `Admin` +- **Tab_Navigation**: The in-page navigation component that switches between Admin_Page sections (User Management, Audit Log, System Info) +- **System_Info_Panel**: The section of the Admin_Page that displays system metadata such as active user count, recent login activity, and database statistics + +## Requirements + +### Requirement 1: Admin Page Layout and Theme Compliance + +**User Story:** As an admin, I want the admin page to follow the same dark tactical intelligence theme as the rest of the dashboard, so that the experience is visually consistent. + +#### Acceptance Criteria + +1. THE Admin_Page SHALL use the Design_System color palette — `--intel-darkest` for the page background, `--intel-dark` and `--intel-medium` for card backgrounds, and `--intel-accent` for interactive elements +2. THE Admin_Page SHALL render as a full-page view within the main content area, matching the layout pattern used by other page components (CompliancePage, ExportsPage, KnowledgeBasePage) +3. THE Admin_Page SHALL display a page header with the title "Admin Panel" styled in monospace uppercase with the accent text glow defined in the Design_System +4. THE Admin_Page SHALL use `intel-card` styled containers for each content section, with the standard gradient backgrounds, border glow, and shadow depth defined in the Design_System +5. THE Admin_Page SHALL use `intel-button` styled controls for all interactive buttons, with primary, danger, and success variants as appropriate +6. THE Admin_Page SHALL use `intel-input` styled form fields for all text inputs, selects, and date pickers + +### Requirement 2: Tab-Based Section Navigation + +**User Story:** As an admin, I want to navigate between admin sections using tabs, so that I can quickly switch between user management, audit logs, and system information without leaving the page. + +#### Acceptance Criteria + +1. THE Admin_Page SHALL display a Tab_Navigation component with tabs for "User Management", "Audit Log", and "System Info" +2. WHEN an Admin_User clicks a tab, THE Tab_Navigation SHALL switch the visible content section to the selected tab and visually indicate the active tab using the `--intel-accent` color +3. THE Admin_Page SHALL default to the "User Management" tab when first loaded +4. THE Tab_Navigation SHALL use monospace uppercase text with letter spacing consistent with the Design_System label typography + +### Requirement 3: Themed User Management Panel + +**User Story:** As an admin, I want to manage users directly within the themed admin page instead of a white modal overlay, so that user management feels integrated into the dashboard. + +#### Acceptance Criteria + +1. THE User_Management_Panel SHALL display a table of all users with columns for username, email, group, active status, and last login +2. THE User_Management_Panel SHALL style the user table with dark theme rows using `data-row` hover effects and `--text-primary` / `--text-secondary` text colors from the Design_System +3. THE User_Management_Panel SHALL display group badges using severity-style badge coloring — Admin in danger color, Standard_User in accent color, Leadership in warning color, Read_Only in muted color +4. WHEN an Admin_User clicks "Add User", THE User_Management_Panel SHALL display an inline form styled with `intel-input` fields and `intel-button` controls +5. WHEN an Admin_User clicks the edit action on a user row, THE User_Management_Panel SHALL populate the inline form with that user's current data for editing +6. WHEN an Admin_User clicks the delete action on a user row, THE User_Management_Panel SHALL display a confirmation prompt before sending the delete request +7. WHEN an Admin_User toggles a user's active status, THE User_Management_Panel SHALL send a PATCH request and update the displayed status without a full page reload +8. THE User_Management_Panel SHALL prevent an Admin_User from changing their own group or deactivating their own account +9. IF a user management API request fails, THEN THE User_Management_Panel SHALL display an error message styled with the `--intel-danger` color + +### Requirement 4: Themed Audit Log Panel + +**User Story:** As an admin, I want to view audit logs in a themed, filterable table within the admin page, so that I can monitor system activity without opening a separate modal. + +#### Acceptance Criteria + +1. THE Audit_Log_Panel SHALL fetch and display paginated audit log entries from the `/api/audit-logs` endpoint +2. THE Audit_Log_Panel SHALL display columns for timestamp, username, action, entity type, entity ID, details, and IP address +3. THE Audit_Log_Panel SHALL style the log table with dark theme rows, monospace font for timestamps and IP addresses, and `data-row` hover effects +4. THE Audit_Log_Panel SHALL display action type badges using color-coded `status-badge` styling — login actions in success color, delete actions in danger color, create actions in accent color, update actions in warning color +5. THE Audit_Log_Panel SHALL provide filter controls for username (text search), action type (dropdown populated from `/api/audit-logs/actions`), entity type (dropdown), start date, and end date +6. THE Audit_Log_Panel SHALL style all filter controls using `intel-input` and `intel-button` components from the Design_System +7. WHEN an Admin_User applies filters, THE Audit_Log_Panel SHALL re-fetch audit logs from page 1 with the selected filter parameters +8. WHEN an Admin_User clicks a pagination control, THE Audit_Log_Panel SHALL fetch the requested page and display a page indicator showing current page, total pages, and total entry count +9. THE Audit_Log_Panel SHALL display a "No audit log entries found" message styled with `--text-muted` color when the query returns zero results +10. IF the audit log API request fails, THEN THE Audit_Log_Panel SHALL display an error message styled with the `--intel-danger` color + +### Requirement 5: System Info Panel + +**User Story:** As an admin, I want to see a summary of system health and usage statistics, so that I can quickly assess the state of the dashboard. + +#### Acceptance Criteria + +1. THE System_Info_Panel SHALL display stat cards showing: total user count, active user count, total audit log entries, and count of users who logged in within the last 7 days +2. THE System_Info_Panel SHALL style each stat card using the `stat-card` pattern from the Design_System with the accent-colored top bar and hover lift effect +3. THE System_Info_Panel SHALL display a "Recent Activity" section showing the 10 most recent audit log entries in a compact list format +4. WHEN the System_Info_Panel loads, THE System_Info_Panel SHALL fetch statistics from the existing `/api/users` and `/api/audit-logs` endpoints +5. IF any statistics API request fails, THEN THE System_Info_Panel SHALL display a fallback "Unable to load" message in the affected stat card + +### Requirement 6: Access Control + +**User Story:** As a non-admin user, I want to be prevented from accessing the admin page, so that sensitive administrative functions are protected. + +#### Acceptance Criteria + +1. THE Dashboard SHALL render the Admin_Page content only when the authenticated user belongs to the Admin group +2. WHEN a non-admin user navigates to the admin page, THE Dashboard SHALL redirect the user to the home page +3. THE NavDrawer SHALL continue to display the "Admin Panel" navigation item only for Admin-group users +4. THE UserMenu SHALL continue to provide "Manage Users" and "Audit Log" quick-access links for Admin-group users, opening the respective modals as before + +### Requirement 7: Loading and Error States + +**User Story:** As an admin, I want to see clear loading indicators and error messages, so that I know when data is being fetched and when something goes wrong. + +#### Acceptance Criteria + +1. WHILE data is being fetched for any Admin_Page section, THE Admin_Page SHALL display a loading spinner styled with the `loading-spinner` class and `--intel-accent` color +2. IF an API request returns an error, THEN THE Admin_Page SHALL display the error message in a container styled with `--intel-danger` border and text color +3. WHEN an Admin_User performs a successful create, update, or delete operation, THE Admin_Page SHALL display a brief success notification styled with `--intel-success` color diff --git a/.kiro/specs/admin-page-overhaul/tasks.md b/.kiro/specs/admin-page-overhaul/tasks.md new file mode 100644 index 0000000..fe78c43 --- /dev/null +++ b/.kiro/specs/admin-page-overhaul/tasks.md @@ -0,0 +1,160 @@ +# Implementation Plan: Admin Page Overhaul + +## Overview + +Replace the current inline `UserManagement` modal rendering on the admin page with a full-page, themed `AdminPage` component. The new component lives at `frontend/src/components/pages/AdminPage.js` and provides three tabbed panels — User Management, Audit Log, and System Info — all styled with the dark tactical intelligence theme. No new backend endpoints are needed; the component reuses existing `/api/users` and `/api/audit-logs` routes. Existing modal components (`UserManagement`, `AuditLog`) are preserved for quick-access from `UserMenu`. + +## Tasks + +- [x] 1. Create AdminPage component with page header and tab navigation + - [x] 1.1 Create `frontend/src/components/pages/AdminPage.js` with the page shell + - Import React, useState, useAuth from AuthContext, and lucide-react icons (Shield, Clock, Activity) + - Define `API_BASE` constant matching project convention + - Define `TABS` array: `[{ id: 'users', label: 'User Management', icon: Shield }, { id: 'audit', label: 'Audit Log', icon: Clock }, { id: 'system', label: 'System Info', icon: Activity }]` + - Render page header with "Admin Panel" title in monospace uppercase with `--intel-accent` text glow + - Render tab navigation bar with monospace uppercase text, `--intel-accent` active styling, and muted inactive styling matching the CompliancePage team-tab pattern + - Manage `activeTab` state defaulting to `'users'` + - Conditionally render placeholder `
` for each panel based on `activeTab` + - _Requirements: 1.1, 1.2, 1.3, 2.1, 2.2, 2.3, 2.4_ + + - [x] 1.2 Integrate AdminPage into App.js + - Import `AdminPage` from `./components/pages/AdminPage` + - Replace the existing `{currentPage === 'admin' && isAdmin() && (
setCurrentPage('home')} />
)}` block with `{currentPage === 'admin' && isAdmin() && }` + - Add non-admin redirect: `{currentPage === 'admin' && !isAdmin() && setCurrentPage('home')}` (or useEffect equivalent) + - Keep existing `{showUserManagement && }` and `{showAuditLog && }` modal triggers unchanged + - _Requirements: 1.2, 6.1, 6.2, 6.3, 6.4_ + +- [-] 2. Implement UserManagementPanel + - [x] 2.1 Build the themed user table and group badges + - Define `GROUP_BADGE_THEMED` map with themed colors: Admin → danger, Standard_User → accent, Leadership → warning, Read_Only → muted + - Fetch users from `GET /api/users` with `credentials: 'include'` on panel mount + - Render user table with columns: username, email, group, active status, last login + - Style table with dark theme rows using `data-row` hover effects and `--text-primary` / `--text-secondary` text colors + - Render group badges using the themed color map with severity-style badge coloring + - Display loading spinner (`loading-spinner` class, `--intel-accent` color) while fetching + - Display error banner with `--intel-danger` styling on fetch failure + - _Requirements: 3.1, 3.2, 3.3, 7.1, 7.2_ + + - [x] 2.2 Implement inline add/edit form and CRUD operations + - Add "Add User" button styled with `intel-button` primary variant + - Show inline form with `intel-input` styled fields for username, email, password, and group dropdown + - On edit action: populate form with selected user's data (username, email, group; password blank) + - On form submit: POST (create) or PATCH (update) to `/api/users` or `/api/users/:id` + - On delete action: show confirmation prompt, then DELETE to `/api/users/:id` + - On active status toggle: PATCH to `/api/users/:id` with `is_active` toggled, update UI without full reload + - Prevent self-modification: disable group dropdown and active toggle for the current authenticated user's row + - Display form validation errors with `--intel-danger` color + - Display success toast with `--intel-success` color, auto-dismiss after 2 seconds + - _Requirements: 3.4, 3.5, 3.6, 3.7, 3.8, 3.9, 7.3_ + + - [ ] 2.3 Write property test: Group badge color mapping is total and correct + - **Property 1: Group badge color mapping is total and correct** + - Install `fast-check` as a dev dependency in `frontend/` + - Create test file `frontend/src/components/pages/__tests__/AdminPage.property.test.js` + - Generate random strings including the four valid groups and arbitrary invalid strings + - Verify the badge function returns correct themed colors for valid groups and default muted styling for invalid groups + - Use `fc.assert(property, { numRuns: 100 })` + - **Validates: Requirements 3.3** + + - [ ] 2.4 Write property test: Edit form population preserves user data + - **Property 2: Edit form population preserves user data** + - Generate random user objects with arbitrary username, email, and group values + - Verify that populating the edit form results in `formData.username === user.username`, `formData.email === user.email`, `formData.group === user.group`, and `formData.password === ''` + - Use `fc.assert(property, { numRuns: 100 })` + - **Validates: Requirements 3.5** + + - [ ] 2.5 Write property test: Self-modification prevention + - **Property 3: Self-modification prevention** + - Generate random user lists that include a user matching the current admin's ID + - Verify the admin's own row has group dropdown disabled and active toggle disabled + - Verify all other users have those controls enabled + - Use `fc.assert(property, { numRuns: 100 })` + - **Validates: Requirements 3.8** + +- [x] 3. Checkpoint — Verify user management panel + - Ensure all tests pass, ask the user if questions arise. + +- [-] 4. Implement AuditLogPanel + - [x] 4.1 Build the themed audit log table with action badges and filters + - Define `ACTION_BADGE_THEMED` map with themed colors: login/success → green, delete → danger, create → accent, update → warning, default → muted + - Fetch audit logs from `GET /api/audit-logs?page=1&limit=25` with `credentials: 'include'` on panel mount + - Fetch action types from `GET /api/audit-logs/actions` for the action filter dropdown + - Render log table with columns: timestamp, username, action, entity type, entity ID, details, IP address + - Style timestamps and IP addresses with monospace font + - Render action type badges using the themed color map + - Style table with dark theme rows and `data-row` hover effects + - Display loading spinner while fetching, error banner on failure + - Display "No audit log entries found" message with `--text-muted` color when results are empty + - _Requirements: 4.1, 4.2, 4.3, 4.4, 4.9, 4.10, 7.1, 7.2_ + + - [x] 4.2 Implement filter controls and pagination + - Render filter bar with: username text input, action type dropdown, entity type dropdown, start date picker, end date picker + - Style all filter controls with `intel-input` and `intel-button` components + - On filter apply: re-fetch audit logs from page 1 with selected filter parameters + - Render pagination controls showing current page, total pages, and total entry count + - On page change: fetch the requested page + - _Requirements: 4.5, 4.6, 4.7, 4.8_ + + - [ ] 4.3 Write property test: Action badge color mapping is total and correct + - **Property 4: Action badge color mapping is total and correct** + - Generate random action strings including all known actions and arbitrary unknown strings + - Verify the badge function returns correct themed colors for known actions and default muted styling for unknown actions + - Use `fc.assert(property, { numRuns: 100 })` + - **Validates: Requirements 4.4** + + - [ ] 4.4 Write property test: Applying filters resets pagination to page 1 + - **Property 5: Applying filters resets pagination to page 1** + - Generate random filter combinations (username text, action type, entity type, start date, end date) and random current page numbers + - Verify that applying filters results in a fetch call with `page=1` + - Use `fc.assert(property, { numRuns: 100 })` + - **Validates: Requirements 4.7** + +- [-] 5. Implement SystemInfoPanel + - [x] 5.1 Build stat cards and recent activity list + - Fetch users from `GET /api/users` and recent audit logs from `GET /api/audit-logs?limit=10&page=1` on panel mount + - Compute derived stats: total users (`users.length`), active users (`users.filter(u => u.is_active)`), recent logins (users with `last_login` within last 7 days), total audit entries (from pagination.total) + - Render four stat cards using the `stat-card` pattern with accent-colored top bar and hover lift effect + - Render "Recent Activity" section showing the 10 most recent audit log entries in a compact list format + - Show "Unable to load" fallback in affected stat cards when individual API requests fail + - Display loading spinner while fetching + - _Requirements: 5.1, 5.2, 5.3, 5.4, 5.5, 7.1_ + + - [ ] 5.2 Write property test: Recent login count computation + - **Property 6: Recent login count computation** + - Generate random user lists with random `last_login` timestamps (including null values) + - Verify the computed "recent logins" count equals the number of users whose `last_login` is non-null and falls within the last 7 days + - Use `fc.assert(property, { numRuns: 100 })` + - **Validates: Requirements 5.1** + +- [x] 6. Checkpoint — Verify all panels and integration + - Ensure all tests pass, ask the user if questions arise. + +- [-] 7. Access control and final wiring + - [x] 7.1 Verify access control integration + - Confirm `AdminPage` reads auth context via `useAuth()` and only renders content for Admin-group users + - Confirm `App.js` redirects non-admin users to home when `currentPage === 'admin'` + - Confirm `NavDrawer` continues to show "Admin Panel" only for Admin-group users (no changes needed — verify existing behavior) + - Confirm `UserMenu` quick-access links ("Manage Users", "Audit Log") continue to open existing modal components (no changes needed — verify existing behavior) + - _Requirements: 6.1, 6.2, 6.3, 6.4_ + + - [ ] 7.2 Write property test: Admin-only access control + - **Property 7: Admin-only access control** + - Generate random user objects with random group values + - Verify admin page content renders if and only if `user.group === 'Admin'` + - Verify non-Admin groups trigger redirect to home + - Use `fc.assert(property, { numRuns: 100 })` + - **Validates: Requirements 6.1, 6.2** + +- [x] 8. Final checkpoint — Ensure all tests pass + - 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 validate universal correctness properties from the design document using fast-check +- Unit tests validate specific examples and edge cases +- Existing `UserManagement.js` and `AuditLog.js` modal components are not modified — they remain for UserMenu quick-access +- All styling follows the project convention of inline styles + App.css classes (no Tailwind in the new component) +- The `fast-check` library must be installed as a dev dependency before running property tests diff --git a/.kiro/specs/archive-finding-clarity/.config.kiro b/.kiro/specs/archive-finding-clarity/.config.kiro new file mode 100644 index 0000000..9e434c1 --- /dev/null +++ b/.kiro/specs/archive-finding-clarity/.config.kiro @@ -0,0 +1 @@ +{"specId": "acff93bd-0045-4fcd-b948-cc52c7cc5ec6", "workflowType": "requirements-first", "specType": "feature"} \ No newline at end of file diff --git a/.kiro/specs/archive-finding-clarity/design.md b/.kiro/specs/archive-finding-clarity/design.md new file mode 100644 index 0000000..08ec206 --- /dev/null +++ b/.kiro/specs/archive-finding-clarity/design.md @@ -0,0 +1,196 @@ +# Design Document: Archive Finding Clarity + +## Overview + +This feature enhances the Ivanti Archive Findings panel on the STEAM Security Dashboard homepage to provide clearer context for archived findings. The changes span both backend (related active finding detection) and frontend (card rendering improvements). + +The core additions are: +1. **Finding ID display** — Show the Ivanti finding ID on each archive card for cross-referencing +2. **Historical severity labeling** — Prefix severity with "Last seen:" to clarify it's a snapshot +3. **Related active finding detection** — Server-side matching of archived findings against the current findings cache by hostname + title +4. **Visual status indicators** — Icon and border color distinctions based on whether a related active finding exists + +All matching is performed server-side in a single pass to avoid per-card API calls and keep the archive panel responsive. + +## Architecture + +The feature touches two layers: + +```mermaid +flowchart LR + subgraph Backend + A[GET /api/ivanti/archive?state=X] --> B[Query ivanti_finding_archives] + B --> C[Parse ivanti_findings_cache JSON once] + C --> D[Match each archive record against active findings] + D --> E[Return archives with related_active field] + end + subgraph Frontend + E --> F[App.js archiveList.map] + F --> G[Render enhanced Archive Cards] + end +``` + +**Key design decision:** The related finding lookup is embedded in the existing `GET /api/ivanti/archive` endpoint rather than exposed as a separate endpoint. This avoids N+1 API calls from the frontend and keeps the archive panel's fetch pattern unchanged (single request per state filter click). + +### Data Flow + +1. User clicks a state card in `ArchiveSummaryBar` → triggers `handleArchiveStateClick(state)` in `App.js` +2. Frontend calls `GET /api/ivanti/archive?state={state}` +3. Backend queries `ivanti_finding_archives` for matching state +4. Backend reads `ivanti_findings_cache` row (id=1), parses `findings_json` once +5. For each archive record, backend runs the matching function against the parsed active findings +6. Backend returns `{ archives: [...], total: N }` where each archive object now includes a `related_active` field +7. Frontend renders each archive card with the new fields: finding ID, "Last seen:" severity, optional badge, icon/border + +## Components and Interfaces + +### Backend: Modified Archive Route (`backend/routes/ivantiArchive.js`) + +**Changes to `GET /` handler:** + +```javascript +// New matching function added to the module +function findRelatedActive(archive, activeFindings) { + // Returns { id, title, severity } or null +} +``` + +**`findRelatedActive(archive, activeFindings)` logic:** +- Input: one archive record, array of parsed active findings +- Filter active findings where: + - `hostName` exactly matches `archive.host_name` (case-sensitive, matching existing DB convention) + - AND the archive's `finding_title` is a case-insensitive substring of the active finding's `title`, OR vice versa + - AND the active finding's `id` is NOT equal to `archive.finding_id` +- If multiple matches, return the one with the highest `severity` +- If no matches, return `null` + +**Modified response shape:** +```javascript +// Before +{ id, finding_id, finding_title, host_name, ip_address, current_state, last_severity, ... } + +// After — same fields plus: +{ ...existing, related_active: null | { id: string, title: string, severity: number } } +``` + +### Frontend: Modified Archive Card Rendering (`frontend/src/App.js`) + +The `archiveList.map()` block in `App.js` is updated to render: + +1. **Finding title** (existing, unchanged) +2. **Finding ID** — new line below title, monospace, muted color (`#64748B`), font size `0.6rem`. Truncated with ellipsis at 20 characters, full value in `title` attribute for tooltip. +3. **Severity badge** — changed from raw number to "Last seen: X.X" format. Null/zero shows "Last seen: —". +4. **Related active badge** — conditional. When `related_active` is non-null, shows "Similar finding active" with the related finding's ID and severity, styled with accent color (`#0EA5E9`). +5. **Icon** — `AlertTriangle` (from lucide-react) when `related_active` is non-null, `CheckCircle` when null. +6. **Left border** — `#F59E0B` (amber) when `related_active` is non-null, `#10B981` (green) when null. + +### No New Components + +The archive card is rendered inline in `App.js` (not a separate component), consistent with the existing pattern. The changes modify the existing `archiveList.map()` JSX block. No new React components are introduced. + +### No New API Endpoints + +The related finding detection is added to the existing `GET /api/ivanti/archive` route. The `ArchiveSummaryBar` component and its `/stats` endpoint are unchanged. + +## Data Models + +### Existing Tables (unchanged) + +**`ivanti_finding_archives`** +| Column | Type | Description | +|--------|------|-------------| +| id | INTEGER PK | Auto-increment row ID | +| finding_id | TEXT UNIQUE | Ivanti finding identifier | +| finding_title | TEXT | Finding title at archive time | +| host_name | TEXT | Hostname | +| ip_address | TEXT | IP address | +| current_state | TEXT | ARCHIVED, RETURNED, or CLOSED | +| last_severity | REAL | Severity at last transition | +| first_archived_at | DATETIME | First archive timestamp | +| last_transition_at | DATETIME | Last state change timestamp | + +**`ivanti_findings_cache`** (row id=1) +| Column | Type | Description | +|--------|------|-------------| +| findings_json | TEXT | JSON array of active findings | +| total | INTEGER | Count of cached findings | + +Each entry in `findings_json` has the shape produced by `extractFinding()` in `ivantiFindings.js`: +```javascript +{ id, title, severity, vrrGroup, hostName, ipAddress, dns, status, slaStatus, dueDate, lastFoundOn, buOwnership, cves, workflow } +``` + +### No Schema Changes + +This feature requires no database migrations. All data needed for the matching logic already exists in the two tables above. + +## 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: Finding ID display with truncation + +*For any* archive record, the rendered card SHALL display the finding_id. If the finding_id is longer than 20 characters, the displayed text SHALL be truncated to 20 characters followed by an ellipsis. If the finding_id is 20 characters or fewer, it SHALL be displayed in full. + +**Validates: Requirements 1.1, 1.2** + +### Property 2: Historical severity labeling + +*For any* archive record, the rendered severity display SHALL contain the text "Last seen:" followed by the severity value formatted to one decimal place. When the severity is null or zero, the display SHALL show "Last seen: —". + +**Validates: Requirements 2.1, 2.3** + +### Property 3: API response structure — related_active always present + +*For any* request to the archive API and *for any* archive record in the response, the record SHALL contain a `related_active` field that is either `null` or an object with `id` (string), `title` (string), and `severity` (number) properties. + +**Validates: Requirements 3.1, 3.4** + +### Property 4: Matching logic — hostname and title substring + +*For any* archived finding and *for any* active finding, the active finding is a related match if and only if: (a) the active finding's hostname exactly equals the archive's hostname, AND (b) the archive's title is a case-insensitive substring of the active finding's title OR the active finding's title is a case-insensitive substring of the archive's title, AND (c) the active finding's ID is not equal to the archive's finding_id. + +**Validates: Requirements 3.2, 3.5** + +### Property 5: Highest severity selection + +*For any* archived finding with multiple matching active findings, the `related_active` field SHALL contain the match with the highest severity value. + +**Validates: Requirements 3.3** + +### Property 6: Badge visibility matches related_active presence + +*For any* archive record, the "Similar finding active" badge SHALL be displayed if and only if the `related_active` field is non-null. When displayed, the badge SHALL include the related finding's ID and severity. + +**Validates: Requirements 4.1, 4.3** + +### Property 7: Icon and border determined by related_active, not lifecycle state + +*For any* archive record, regardless of its lifecycle state (ARCHIVED, RETURNED, or CLOSED), the icon and left border color SHALL be determined solely by whether `related_active` is non-null (alert icon + amber border) or null (check icon + green border). + +**Validates: Requirements 5.1, 5.2, 5.3** + +## Error Handling + +### Backend + +| Scenario | Handling | +|----------|----------| +| `findings_json` is malformed or unparseable | Catch JSON.parse error, log warning, treat as empty array (all `related_active` fields become `null`) | +| `findings_json` column is NULL | Default to empty array | +| `ivanti_findings_cache` row missing (id=1) | Default to empty array — no related matches | +| Database query failure on archive records | Return 500 with `{ error: 'Failed to fetch archive records' }` (existing behavior) | +| Database query failure on findings cache | Log error, continue with empty active findings (graceful degradation) | + +### Frontend + +| Scenario | Handling | +|----------|----------| +| `related_active` field missing from response | Treat as `null` (no badge, green/check styling) | +| `finding_id` is empty string | Display finding title only (existing fallback behavior) | +| `last_severity` is undefined | Display "Last seen: —" | +| API returns error | Existing error handling in `handleArchiveStateClick` already catches and shows empty state | + +## Testing Strategy + +Testing is performed manually on the dev server. No automated tests are required for this feature. diff --git a/.kiro/specs/archive-finding-clarity/requirements.md b/.kiro/specs/archive-finding-clarity/requirements.md new file mode 100644 index 0000000..71a3475 --- /dev/null +++ b/.kiro/specs/archive-finding-clarity/requirements.md @@ -0,0 +1,82 @@ +# Requirements Document + +## Introduction + +The Ivanti Archive Findings panel on the STEAM Security Dashboard homepage displays findings that have transitioned through the archive lifecycle (Active, Archived, Returned, Closed). The current archive cards show the finding title, hostname, IP address, and a raw severity number — but lack clarity in several areas. Users cannot see the Ivanti finding ID for cross-referencing, the severity score appears to be a current value when it is actually a historical snapshot, and there is no indication when a related finding with the same title still exists on the same host under a different Ivanti finding ID. + +This feature improves archive card clarity by adding finding IDs, labeling severity as historical, introducing a "related active finding" indicator, and using visual icon distinctions to communicate resolution status at a glance. + +## Glossary + +- **Archive_Card**: A single rendered entry in the archive findings list on the homepage, representing one row from the `ivanti_finding_archives` table. +- **Archive_Panel**: The section of the homepage that contains the ArchiveSummaryBar stat cards and the expandable archive findings list. +- **Finding_ID**: The stable Ivanti-assigned identifier for a host finding (stored as `finding_id` in `ivanti_finding_archives`). Finding IDs do not change with score drift or rescoring. +- **Last_Severity**: The severity score recorded at the time a finding was archived or last transitioned between states. It is a historical snapshot, not a live risk assessment. +- **Current_Findings_Cache**: The `ivanti_findings_cache` table containing the latest synced findings as a JSON array. Each cached finding has fields including `id`, `title`, `severity`, `hostName`, and `ipAddress`. +- **Related_Active_Finding**: A finding in the Current_Findings_Cache that shares the same hostname and a similar title with an archived finding but has a different Finding_ID, indicating a genuinely distinct but related finding is still open on the same host. +- **Archive_API**: The backend endpoint `GET /api/ivanti/archive` that returns archive records filtered by lifecycle state. +- **Related_Findings_Endpoint**: A new backend endpoint that accepts archived finding details and returns matching active findings from the Current_Findings_Cache. + +## Requirements + +### Requirement 1: Display Finding ID on Archive Cards + +**User Story:** As a security analyst, I want to see the Ivanti finding ID on each archive card, so that I can cross-reference archived findings with the Reporting page. + +#### Acceptance Criteria + +1. THE Archive_Card SHALL display the Finding_ID in monospace font below the finding title. +2. WHEN the Finding_ID is longer than 20 characters, THE Archive_Card SHALL truncate the Finding_ID with an ellipsis and display the full value in a tooltip on hover. +3. THE Archive_Card SHALL render the Finding_ID with a visually distinct style (muted color, smaller font size) so it is clearly secondary to the finding title. + +### Requirement 2: Historical Severity Labeling + +**User Story:** As a security analyst, I want the severity score on archive cards to be clearly labeled as a historical value, so that I do not mistake it for a current risk assessment. + +#### Acceptance Criteria + +1. THE Archive_Card SHALL display the Last_Severity with a "Last seen:" prefix label (e.g., "Last seen: 9.4"). +2. THE Archive_Card SHALL render the severity label in a muted, secondary style that visually distinguishes it from live severity badges used elsewhere in the dashboard. +3. WHEN the Last_Severity value is null or zero, THE Archive_Card SHALL display "Last seen: —" as a placeholder. + +### Requirement 3: Related Active Finding Detection API + +**User Story:** As a security analyst, I want the system to detect when an archived finding has a related active finding on the same host, so that I can understand whether similar risk still exists. + +#### Acceptance Criteria + +1. THE Archive_API SHALL return a `related_active` field for each archive record, containing either `null` (no match) or an object with the matching active finding's `id`, `title`, and `severity`. +2. WHEN matching archived findings to active findings, THE Related_Findings_Endpoint SHALL compare by exact hostname match AND case-insensitive substring containment of the archived finding title within the active finding title (or vice versa). +3. WHEN multiple active findings match a single archived finding, THE Related_Findings_Endpoint SHALL return the match with the highest severity. +4. IF the Current_Findings_Cache contains no findings or is empty, THEN THE Related_Findings_Endpoint SHALL return `null` for all `related_active` fields. +5. THE Related_Findings_Endpoint SHALL exclude matches where the active finding's `id` is identical to the archived finding's Finding_ID. + +### Requirement 4: Related Active Finding Indicator on Archive Cards + +**User Story:** As a security analyst, I want to see a visual indicator on archive cards when a related active finding exists on the same host, so that I can quickly identify findings that may still represent active risk. + +#### Acceptance Criteria + +1. WHEN an archive record has a non-null `related_active` field, THE Archive_Card SHALL display a badge reading "Similar finding active" with the related finding's ID and current severity. +2. THE Archive_Card SHALL render the related active badge using the dashboard accent color (#0EA5E9) to distinguish it from the archive card's own severity display. +3. WHEN an archive record has a null `related_active` field, THE Archive_Card SHALL not display any related-finding badge. + +### Requirement 5: Visual Icon Distinction by Resolution Status + +**User Story:** As a security analyst, I want archive cards to use different icons and border colors based on whether a related active finding exists, so that I can scan the list and quickly distinguish fully resolved findings from those with ongoing similar risk. + +#### Acceptance Criteria + +1. WHEN an archive record has a non-null `related_active` field, THE Archive_Card SHALL display an alert-style icon (e.g., `AlertTriangle` from lucide-react) and use a warning-toned left border color (#F59E0B). +2. WHEN an archive record has a null `related_active` field, THE Archive_Card SHALL display a check-style icon (e.g., `CheckCircle` from lucide-react) and use a success-toned left border color (#10B981). +3. THE Archive_Card SHALL apply the icon and border color consistently regardless of the archive lifecycle state (ARCHIVED, RETURNED, or CLOSED). + +### Requirement 6: Performance of Related Finding Lookup + +**User Story:** As a security analyst, I want the archive panel to load promptly even when checking for related active findings, so that the feature does not degrade the homepage experience. + +#### Acceptance Criteria + +1. THE Archive_API SHALL compute related active finding matches server-side within the existing archive list query, avoiding separate per-card API calls from the frontend. +2. WHEN the Current_Findings_Cache JSON is parsed for matching, THE Archive_API SHALL parse the cache once per request and reuse the parsed result across all archive records in the response. +3. THE Archive_API response time for a filtered archive list SHALL remain under 500ms for up to 200 archive records. diff --git a/.kiro/specs/archive-finding-clarity/tasks.md b/.kiro/specs/archive-finding-clarity/tasks.md new file mode 100644 index 0000000..163b3e1 --- /dev/null +++ b/.kiro/specs/archive-finding-clarity/tasks.md @@ -0,0 +1,70 @@ +# Implementation Plan: Archive Finding Clarity + +## Overview + +Enhance the Ivanti Archive Findings panel to display finding IDs, label severity as historical, detect related active findings server-side, and apply visual icon/border distinctions based on resolution status. Changes span `backend/routes/ivantiArchive.js` (matching logic + enriched response) and `frontend/src/App.js` (card rendering updates). No new components, endpoints, or migrations. + +## Tasks + +- [x] 1. Add `findRelatedActive` function and enrich the GET `/` handler in `backend/routes/ivantiArchive.js` + - [x] 1.1 Add the `findRelatedActive(archive, activeFindings)` helper function + - Add function above `createIvantiArchiveRouter` or inside the module scope + - Filter active findings where `hostName` exactly matches `archive.host_name` + - AND the archive's `finding_title` is a case-insensitive substring of the active finding's `title`, or vice versa + - AND the active finding's `id` is NOT equal to `archive.finding_id` + - If multiple matches, return the one with the highest `severity` as `{ id, title, severity }` + - If no matches, return `null` + - _Requirements: 3.1, 3.2, 3.3, 3.5_ + + - [x] 1.2 Modify the `GET /` handler to parse findings cache and enrich archive records + - After fetching archive rows, query `ivanti_findings_cache` (id=1) for `findings_json` + - Parse `findings_json` once with `JSON.parse`; default to empty array if NULL, missing row, or parse error + - Log a warning on parse failure, do not throw + - For each archive record, call `findRelatedActive(archive, parsedFindings)` and attach the result as `related_active` + - Return the enriched archives array in the existing `{ archives, total }` response shape + - _Requirements: 3.1, 3.4, 6.1, 6.2_ + +- [x] 2. Checkpoint — Verify backend changes + - Ensure the backend starts without errors, ask the user if questions arise. + +- [x] 3. Update archive card rendering in `frontend/src/App.js` + - [x] 3.1 Add `AlertTriangle` and `CheckCircle` to the lucide-react import + - Locate the existing lucide-react import statement in `App.js` + - Add `AlertTriangle` and `CheckCircle` if not already imported + - _Requirements: 5.1, 5.2_ + + - [x] 3.2 Add Finding ID display below the finding title + - Inside the `archiveList.map()` block, add a new line below the title `` + - Render `a.finding_id` in monospace font, `0.6rem` size, muted color `#64748B` + - If `finding_id` length exceeds 20 characters, truncate displayed text to 20 chars + ellipsis + - Set the full `finding_id` as the `title` attribute for hover tooltip + - _Requirements: 1.1, 1.2, 1.3_ + + - [x] 3.3 Change severity badge to "Last seen: X.X" format + - In the severity `` within the archive card, replace `{a.last_severity?.toFixed(1) ?? '—'}` with `Last seen: {a.last_severity?.toFixed(1) ?? '—'}` + - Null or zero severity displays as "Last seen: —" + - _Requirements: 2.1, 2.2, 2.3_ + + - [x] 3.4 Add conditional "Similar finding active" badge + - When `a.related_active` is non-null, render a badge below the host info line + - Badge text: "Similar finding active" with the related finding's ID and severity + - Style with accent color `#0EA5E9`, monospace font, `0.6rem` size + - When `a.related_active` is null, render nothing + - _Requirements: 4.1, 4.2, 4.3_ + + - [x] 3.5 Add icon and left border color based on `related_active` + - When `a.related_active` is non-null: render `AlertTriangle` icon and set left border to `3px solid #F59E0B` (amber) + - When `a.related_active` is null: render `CheckCircle` icon and set left border to `3px solid #10B981` (green) + - Place the icon at the left side of the card header row, before the title + - Apply consistently regardless of archive lifecycle state (ARCHIVED, RETURNED, CLOSED) + - _Requirements: 5.1, 5.2, 5.3_ + +- [x] 4. Final checkpoint — Verify full feature + - Ensure the frontend compiles without errors, ask the user if questions arise. + +## Notes + +- No automated tests — feature is validated manually on the dev server per user preference +- No new components, endpoints, or database migrations required +- The `findRelatedActive` function parses the findings cache once per request for performance (Requirement 6.2) +- Each task references specific requirements for traceability diff --git a/.kiro/specs/atlas-action-plans/.config.kiro b/.kiro/specs/atlas-action-plans/.config.kiro new file mode 100644 index 0000000..bca3c22 --- /dev/null +++ b/.kiro/specs/atlas-action-plans/.config.kiro @@ -0,0 +1 @@ +{"specId": "aa138cae-9fbf-47bf-9dc3-1169456f5706", "workflowType": "requirements-first", "specType": "feature"} diff --git a/.kiro/specs/atlas-action-plans/design.md b/.kiro/specs/atlas-action-plans/design.md new file mode 100644 index 0000000..11b6455 --- /dev/null +++ b/.kiro/specs/atlas-action-plans/design.md @@ -0,0 +1,497 @@ +# Design Document: Atlas Action Plans Integration + +## Overview + +This feature integrates the Atlas InfoSec action plans API into the STEAM Security Dashboard, allowing users to view and manage compliance action plans for host findings directly from the ReportingPage. The integration follows the existing proxy-and-cache pattern used by the Ivanti integration — a backend helper handles HTTP communication with the external API, a SQLite cache stores host-level status for fast page loads, and Express routes expose both cached status and proxied CRUD operations to the React frontend. + +The frontend adds two visual elements to the ReportingPage: a small badge in the Host column indicating action plan coverage, and a slide-out panel for viewing, creating, and updating plans. A manual sync button — matching the existing Ivanti sync button pattern — lets users refresh cached Atlas data on demand. + +### Key Design Decisions + +- **Proxy pattern over direct frontend calls**: The frontend never talks to Atlas directly. All Atlas API calls go through the STEAM backend, which handles authentication, TLS configuration, and audit logging. This keeps Atlas credentials server-side and provides a single audit trail. +- **Cache-then-fetch**: The ReportingPage loads cached badge data from SQLite on mount (fast), and users trigger a manual sync to refresh from Atlas (slow, one API call per host). This matches the existing Ivanti sync UX. +- **Sequential host sync (not bulk GET)**: The Atlas API only exposes per-host `GET /hosts/{host_id}/action-plans`. There is no bulk status endpoint, so the sync iterates over unique host IDs from the Ivanti cache. Concurrency is limited to avoid overwhelming Atlas. +- **Basic Auth with runtime base64 encoding**: Atlas uses `Authorization: Basic ` rather than the API key pattern used by Ivanti. The helper computes this at request time from environment variables. +- **ID mapping**: Ivanti `host.hostId` maps directly to Atlas `host_id` in URL paths. Ivanti `f.id` maps to Atlas `active_host_findings_id` in request bodies. No translation layer is needed. + +## Architecture + +```mermaid +graph TD + subgraph Frontend + RP[ReportingPage] + AB[AtlasBadge] + SP[AtlasSlideOutPanel] + end + + subgraph Backend + AR[Atlas Router
/api/atlas/*] + AH[Atlas Helper
atlasApi.js] + AC[(Atlas Cache
SQLite)] + IC[(Ivanti Cache
SQLite)] + AL[Audit Log] + end + + subgraph External + ATLAS[Atlas InfoSec API
https://atlas-infosec.caas.charterlab.com] + end + + RP -->|GET /api/atlas/status| AR + RP -->|POST /api/atlas/sync| AR + AB -->|click| SP + SP -->|GET /api/atlas/hosts/:id/action-plans| AR + SP -->|PUT /api/atlas/hosts/:id/action-plans| AR + SP -->|PATCH /api/atlas/hosts/:id/action-plans| AR + + AR -->|read cached status| AC + AR -->|read host IDs| IC + AR -->|GET, PUT, PATCH, POST| AH + AR -->|logAudit| AL + AH -->|HTTPS + Basic Auth| ATLAS + AR -->|upsert cache| AC +``` + +### Data Flow: Page Load + +1. ReportingPage mounts, fetches Ivanti findings from existing cache (existing behavior) +2. ReportingPage fetches `GET /api/atlas/status` — returns all cached Atlas rows +3. Frontend builds a `Map` and passes it to table rendering +4. Each Host column cell checks the map — if a match exists, renders an AtlasBadge + +### Data Flow: Manual Sync + +1. User clicks Atlas sync button +2. Frontend sends `POST /api/atlas/sync` +3. Backend extracts unique `hostId` values from `ivanti_findings_cache.findings_json` +4. Backend calls `GET /hosts/{host_id}/action-plans` for each host (with concurrency limit of 5) +5. Backend upserts each result into `atlas_action_plans_cache` +6. Backend returns summary `{ synced, withPlans, failed }` +7. Frontend re-fetches `GET /api/atlas/status` and updates badges + +### Data Flow: Create/Update Plan + +1. User clicks AtlasBadge → slide-out panel opens +2. Panel fetches `GET /api/atlas/hosts/:hostId/action-plans` for live data +3. User fills create form or edits existing plan +4. Frontend sends `PUT` (create) or `PATCH` (update) to `/api/atlas/hosts/:hostId/action-plans` +5. Backend validates request body, proxies to Atlas API, logs audit entry +6. On success, panel refreshes plan list and frontend re-fetches cached status + +## Components and Interfaces + +### Backend: Atlas API Helper (`backend/helpers/atlasApi.js`) + +A new helper module following the same pattern as `ivantiApi.js` — promise-based HTTP using Node's `https` module, with TLS skip support. + +```javascript +// Exported functions +function atlasRequest(method, urlPath, body, options) +// method: 'GET' | 'PUT' | 'PATCH' | 'POST' +// urlPath: e.g. '/hosts/29329662/action-plans' +// body: object | null (null for GET) +// options: { timeout?: number } +// Returns: Promise<{ status: number, body: string }> + +// Convenience wrappers +function atlasGet(urlPath, options) +function atlasPut(urlPath, body, options) +function atlasPatch(urlPath, body, options) +function atlasPost(urlPath, body, options) +``` + +**Configuration** (read from `process.env` at module load): +- `ATLAS_API_URL` — base URL (e.g. `https://atlas-infosec.caas.charterlab.com`) +- `ATLAS_API_USER` — service account username +- `ATLAS_API_PASS` — service account password +- `ATLAS_SKIP_TLS` — `'true'` to disable certificate verification + +**Auth header**: `Authorization: Basic ${Buffer.from(user + ':' + pass).toString('base64')}` + +**Timeouts**: 15s default for single-host endpoints, 60s for bulk. Passed via `options.timeout`. + +**Error handling**: Network errors and timeouts reject the promise. Non-2xx responses resolve normally with `{ status, body }` — the caller decides how to handle them. + +### Backend: Migration (`backend/migrations/add_atlas_action_plans_cache.js`) + +Creates the `atlas_action_plans_cache` table following the existing migration pattern. + +```javascript +// Table schema +db.run(` + CREATE TABLE IF NOT EXISTS atlas_action_plans_cache ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + host_id INTEGER NOT NULL UNIQUE, + has_action_plan INTEGER NOT NULL DEFAULT 0, + plan_count INTEGER NOT NULL DEFAULT 0, + plans_json TEXT NOT NULL DEFAULT '[]', + synced_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP + ) +`); + +db.run(` + CREATE INDEX IF NOT EXISTS idx_atlas_cache_host_id + ON atlas_action_plans_cache(host_id) +`); +``` + +### Backend: Atlas Router (`backend/routes/atlas.js`) + +Factory function pattern: `createAtlasRouter(db, requireAuth)` returns an Express Router mounted at `/api/atlas`. + +| Method | Path | Auth | Group | Description | +|--------|------|------|-------|-------------| +| `GET` | `/status` | requireAuth | any | Return all cached Atlas rows | +| `POST` | `/sync` | requireAuth | Admin, Standard_User | Sync Atlas data for all Ivanti hosts | +| `GET` | `/hosts/:hostId/action-plans` | requireAuth | any | Proxy to Atlas GET plans | +| `PUT` | `/hosts/:hostId/action-plans` | requireAuth | Admin, Standard_User | Proxy to Atlas create plan | +| `PATCH` | `/hosts/:hostId/action-plans` | requireAuth | Admin, Standard_User | Proxy to Atlas update plan | +| `POST` | `/hosts/bulk-action-plans` | requireAuth | Admin, Standard_User | Proxy to Atlas bulk create | + +**Sync implementation**: +1. Parse `ivanti_findings_cache.findings_json` to extract unique `hostId` values (skip nulls) +2. Process hosts in batches of 5 concurrent requests using `Promise.allSettled` +3. For each host, call `atlasGet('/hosts/' + hostId + '/action-plans')` +4. On 2xx: upsert cache row with plan count and summary JSON +5. On non-2xx: increment failure counter, log warning, continue +6. Return `{ synced: N, withPlans: N, failed: N }` + +**Validation (PUT create)**: +- `plan_type` must be one of: `decommission`, `remediation`, `false_positive`, `risk_acceptance`, `scan_exclusion` +- `commit_date` must match `/^\d{4}-\d{2}-\d{2}$/` +- `hostId` param must be a positive integer + +**Validation (PATCH update)**: +- `action_plan_id` must be a non-empty string +- `updates` must be a non-null object + +**Validation (POST bulk)**: +- `host_ids` must be a non-empty array of positive integers +- `plan_type` and `commit_date` validated same as PUT + +### Frontend: AtlasBadge Component + +A small inline badge rendered inside the Host column cell, next to the hostname. Clicking it opens the slide-out panel. + +**Props**: `{ hostId, atlasStatus, onClick }` + +**Rendering logic**: +- If `atlasStatus` is `undefined` (host not in Atlas cache): render nothing +- If `atlasStatus.has_action_plan === 0`: render warning badge (amber border, "0" text) +- If `atlasStatus.plan_count > 0`: render success badge (emerald border, count text) + +**Style**: Small pill badge using the design system's badge pattern — monospace font, 0.58rem, inline-flex, with border color indicating status. Positioned after the hostname text in the OverrideCell wrapper. + +### Frontend: AtlasSlideOutPanel Component + +A right-side drawer panel, similar in concept to the existing FP submission detail panels. Renders over the table content with a semi-transparent backdrop. + +**Props**: `{ hostId, hostName, onClose, canWrite }` + +**Sections**: +1. **Header**: hostname, host ID, close button +2. **Plan list**: fetched from `GET /api/atlas/hosts/:hostId/action-plans` on open. Each plan shows type, commit date, status, and optional VNR/EXC references +3. **Create form** (if `canWrite`): plan type dropdown, commit date picker, optional fields (qualys_id, active_host_findings_id, jira_vnr, archer_exc) +4. **Edit capability** (if `canWrite`): inline edit on existing plans, submits via PATCH + +**State management**: Local component state — plan list, loading, error, form values. No global state needed since the panel is ephemeral. + +### Frontend: Atlas Sync Button + +A new button in the ReportingPage toolbar, placed adjacent to the existing Ivanti sync button. Uses the same styling pattern — `RefreshCw` icon, monospace uppercase text, sky blue accent color. Differentiated by a `Database` icon prefix and "Atlas" label. + +**State**: `atlasSyncing` boolean, `atlasStatus` map (keyed by hostId), `atlasError` string. + +### Server.js Integration + +```javascript +const createAtlasRouter = require('./routes/atlas'); +// ... +app.use('/api/atlas', createAtlasRouter(db, requireAuth)); +``` + +## Data Models + +### Atlas Cache Table (`atlas_action_plans_cache`) + +| Column | Type | Constraints | Description | +|--------|------|-------------|-------------| +| `id` | INTEGER | PRIMARY KEY AUTOINCREMENT | Row ID | +| `host_id` | INTEGER | NOT NULL UNIQUE | Ivanti host ID (= Atlas host_id) | +| `has_action_plan` | INTEGER | NOT NULL DEFAULT 0 | 1 if any plans exist, 0 otherwise | +| `plan_count` | INTEGER | NOT NULL DEFAULT 0 | Number of action plans | +| `plans_json` | TEXT | NOT NULL DEFAULT '[]' | JSON array of plan summaries | +| `synced_at` | DATETIME | NOT NULL DEFAULT CURRENT_TIMESTAMP | Last sync timestamp | + +**Index**: `idx_atlas_cache_host_id` on `host_id` + +### Plan Summary JSON Shape (stored in `plans_json`) + +```json +[ + { + "action_plan_id": "ap-123", + "plan_type": "remediation", + "commit_date": "2026-07-01", + "status": "active", + "qualys_id": "QID-12345", + "active_host_findings_id": 2281281250, + "jira_vnr": null, + "archer_exc": null + } +] +``` + +The exact shape depends on what the Atlas API returns. The backend stores the raw response array as-is, extracting only `plan_count` and `has_action_plan` for the cache columns. + +### Atlas API Request/Response Shapes + +**Create (PUT `/hosts/{host_id}/action-plans`)**: +```json +{ + "plan_type": "remediation", + "commit_date": "2026-07-01", + "active_host_findings_id": 2281281250 +} +``` + +**Update (PATCH `/hosts/{host_id}/action-plans`)**: +```json +{ + "action_plan_id": "ap-123", + "updates": { + "commit_date": "2026-08-01" + } +} +``` + +**Bulk Create (POST `/hosts/create-bulk-action-plans`)**: +```json +{ + "host_ids": [29329662, 29329663], + "plan_type": "decommission", + "commit_date": "2026-07-01" +} +``` + +### Environment Variables + +| Variable | Required | Description | +|----------|----------|-------------| +| `ATLAS_API_URL` | Yes | Atlas InfoSec API base URL | +| `ATLAS_API_USER` | Yes | Service account username for Basic Auth | +| `ATLAS_API_PASS` | Yes | Service account password for Basic Auth | +| `ATLAS_SKIP_TLS` | No | Set to `true` to skip TLS cert verification (default: `false`) | + +### Frontend State Shape + +```javascript +// Atlas status map — keyed by hostId (number) +const atlasStatusMap = new Map([ + [29329662, { host_id: 29329662, has_action_plan: 1, plan_count: 2, synced_at: '2026-07-01 12:00:00' }], + [29329663, { host_id: 29329663, has_action_plan: 0, plan_count: 0, synced_at: '2026-07-01 12:00:00' }], +]); +``` + + +## 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: Basic Auth header round-trip + +*For any* pair of (username, password) strings, the Authorization header produced by the Atlas helper should decode (via base64) to exactly `username:password`. + +**Validates: Requirements 1.2** + +### Property 2: Non-2xx responses resolve with status and body + +*For any* HTTP status code in the range 400–599 and any response body string, the Atlas helper should resolve the promise with an object containing that exact status code and body, rather than rejecting. + +**Validates: Requirements 1.7** + +### Property 3: Error messages contain method and path + +*For any* HTTP method string and URL path string, when a network error or timeout occurs, the Atlas helper's rejection error message should contain both the method and the path. + +**Validates: Requirements 1.8** + +### Property 4: Unique host ID extraction + +*For any* array of finding objects (each with an optional `hostId` field), the sync operation should extract exactly the set of unique, non-null `hostId` values — no duplicates, no nulls. + +**Validates: Requirements 3.2** + +### Property 5: Cache upsert derives correct plan_count and has_action_plan + +*For any* host ID and any array of action plan objects returned by the Atlas API, after upserting into the cache, the stored `plan_count` should equal the array length and `has_action_plan` should equal 1 if the array is non-empty, 0 otherwise. + +**Validates: Requirements 3.4** + +### Property 6: Sync response count invariant + +*For any* sync operation over N unique hosts where M hosts fail, the response should satisfy: `synced + failed = N` and `withPlans <= synced`. + +**Validates: Requirements 3.6** + +### Property 7: Status endpoint returns all cached rows with required fields + +*For any* set of rows inserted into the Atlas cache table, the `GET /api/atlas/status` endpoint should return exactly that many rows, and each row should contain `host_id`, `has_action_plan`, `plan_count`, and `synced_at` fields. + +**Validates: Requirements 4.2, 4.3** + +### Property 8: plan_type validation + +*For any* string, the PUT endpoint's plan_type validation should accept the string if and only if it is one of: `decommission`, `remediation`, `false_positive`, `risk_acceptance`, `scan_exclusion`. + +**Validates: Requirements 5.3** + +### Property 9: commit_date validation + +*For any* string, the PUT endpoint's commit_date validation should accept the string if and only if it matches the pattern `YYYY-MM-DD` (four digits, hyphen, two digits, hyphen, two digits). + +**Validates: Requirements 5.4** + +### Property 10: PATCH body validation + +*For any* request body object, the PATCH endpoint should accept it if and only if `action_plan_id` is a non-empty string and `updates` is a non-null object. + +**Validates: Requirements 5.7** + +### Property 11: Bulk request validation + +*For any* request body object, the bulk POST endpoint should accept it if and only if `host_ids` is a non-empty array of positive integers, `plan_type` is one of the five valid types, and `commit_date` matches `YYYY-MM-DD`. + +**Validates: Requirements 5.10** + +### Property 12: Non-2xx Atlas response passthrough + +*For any* non-2xx status code and error body returned by the Atlas API, the proxy route should return that same status code and body to the frontend client. + +**Validates: Requirements 5.12** + +### Property 13: Badge visibility and content + +*For any* finding with a `hostId` and any atlas status map, the AtlasBadge should render if and only if the `hostId` exists as a key in the map. When rendered with `plan_count > 0`, the badge text should contain the plan count value. + +**Validates: Requirements 6.2, 6.5, 6.6** + +### Property 14: Panel displays all plan fields + +*For any* action plan object containing `plan_type`, `commit_date`, `status`, and optional reference fields (`jira_vnr`, `archer_exc`), the rendered slide-out panel should include all non-null field values in its output. + +**Validates: Requirements 7.3** + +## Error Handling + +### Atlas API Communication Errors + +| Error Scenario | Handling | User Impact | +|----------------|----------|-------------| +| Network timeout (15s/60s) | Helper rejects promise with descriptive error | Sync: host skipped, counted as failed. Proxy: 502 returned to frontend with error message | +| DNS resolution failure | Helper rejects promise | Same as timeout | +| TLS certificate error (when `ATLAS_SKIP_TLS` is false) | Helper rejects promise | Same as timeout | +| Atlas API returns 401 (bad credentials) | Helper resolves with `{ status: 401, body }` | Sync: all hosts fail. Proxy: 401 forwarded to frontend. Error banner shown | +| Atlas API returns 404 (host not found) | Helper resolves with `{ status: 404, body }` | Sync: host skipped. Proxy: 404 forwarded to frontend | +| Atlas API returns 422 (validation error) | Helper resolves with `{ status: 422, body }` | Proxy: 422 forwarded to frontend. Panel shows validation error | +| Atlas API returns 500 (server error) | Helper resolves with `{ status: 500, body }` | Sync: host skipped. Proxy: 500 forwarded to frontend | + +### Backend Validation Errors + +| Error Scenario | HTTP Status | Response | +|----------------|-------------|----------| +| Missing or invalid `plan_type` | 400 | `{ error: 'plan_type must be one of: decommission, remediation, ...' }` | +| Missing or invalid `commit_date` | 400 | `{ error: 'commit_date must be a valid YYYY-MM-DD date string' }` | +| Missing `action_plan_id` on PATCH | 400 | `{ error: 'action_plan_id is required and must be a non-empty string' }` | +| Missing `updates` on PATCH | 400 | `{ error: 'updates is required and must be an object' }` | +| Empty or invalid `host_ids` on bulk | 400 | `{ error: 'host_ids must be a non-empty array of positive integers' }` | +| Non-integer `hostId` URL param | 400 | `{ error: 'hostId must be a positive integer' }` | +| Unauthenticated request | 401 | `{ error: 'Authentication required' }` | +| Viewer group on restricted endpoint | 403 | `{ error: 'Insufficient permissions', required: [...], current: 'Viewer' }` | + +### Frontend Error Handling + +- **Sync failure**: Error banner displayed below the Atlas sync button (matching existing Ivanti sync error pattern). Button re-enabled. +- **Panel fetch failure**: "Failed to load action plans" message inside the panel with a retry button. +- **Create/update failure**: Error message displayed inline in the form, preserving user input for correction. +- **Network error**: Generic "Unable to reach server" message with retry option. + +### Environment Configuration Errors + +- If `ATLAS_API_URL`, `ATLAS_API_USER`, or `ATLAS_API_PASS` are not set, the Atlas helper logs a warning at module load time. All Atlas API calls will fail with a descriptive error rather than crashing the server. +- The Atlas router checks for helper availability and returns 503 if Atlas is not configured. + +## Testing Strategy + +### Unit Tests + +Unit tests cover specific examples, edge cases, and integration points: + +**Atlas Helper (`atlasApi.js`)**: +- Correct URL construction from base URL + path +- Basic Auth header format for known credentials +- TLS skip flag respected (rejectUnauthorized option) +- Timeout values for single vs bulk endpoints +- GET, PUT, PATCH, POST methods set correctly + +**Atlas Router validation**: +- Valid plan_type values accepted, invalid rejected +- Valid commit_date formats accepted, invalid rejected +- PATCH body with missing action_plan_id rejected +- Bulk request with empty host_ids rejected +- Non-integer hostId param rejected +- Auth middleware applied to correct endpoints (401/403 responses) + +**Atlas Cache operations**: +- Upsert creates new row when host_id doesn't exist +- Upsert updates existing row when host_id exists +- Status endpoint returns empty array when cache is empty +- Migration is idempotent (runs twice without error) + +**Frontend components**: +- AtlasBadge renders nothing when host not in status map +- AtlasBadge renders warning style when plan_count is 0 +- AtlasBadge renders success style when plan_count > 0 +- AtlasSlideOutPanel shows create form for Admin/Standard_User +- AtlasSlideOutPanel hides create form for Viewer +- Sync button disabled during sync, re-enabled after + +### Property-Based Tests + +Property-based tests verify universal properties across generated inputs. Each test runs a minimum of 100 iterations. + +**Library**: [fast-check](https://github.com/dubzzz/fast-check) — the standard PBT library for JavaScript/Node.js. + +**Configuration**: Each property test runs with `{ numRuns: 100 }` minimum. + +**Tag format**: Each test includes a comment referencing its design property: +```javascript +// Feature: atlas-action-plans, Property 1: Basic Auth header round-trip +``` + +| Property | Test Description | Generator Strategy | +|----------|-----------------|-------------------| +| Property 1 | Encode then decode Basic Auth header | Generate random (user, pass) string pairs, verify round-trip | +| Property 2 | Non-2xx status codes resolve | Generate integers 400–599 and random body strings | +| Property 3 | Error messages contain method and path | Generate random method names and URL path strings | +| Property 4 | Unique host ID extraction | Generate arrays of objects with optional numeric hostId fields | +| Property 5 | Cache upsert correctness | Generate (hostId, planArray) pairs, verify derived fields | +| Property 6 | Sync count invariant | Generate (totalHosts, failureCount) pairs, verify arithmetic | +| Property 7 | Status returns all cached rows | Generate N cache rows, verify response count and fields | +| Property 8 | plan_type validation | Generate random strings, verify acceptance matches valid set | +| Property 9 | commit_date validation | Generate random strings, verify acceptance matches date pattern | +| Property 10 | PATCH body validation | Generate random objects with varying field presence | +| Property 11 | Bulk validation | Generate objects with varying host_ids, plan_type, commit_date | +| Property 12 | Error passthrough | Generate non-2xx codes and body strings, verify forwarding | +| Property 13 | Badge visibility and content | Generate findings and status maps, verify render logic | +| Property 14 | Panel plan field display | Generate plan objects, verify all non-null fields appear | + +### Integration Tests + +Integration tests verify end-to-end behavior with mocked Atlas API: + +- Full sync flow: populate Ivanti cache → trigger sync → verify Atlas cache populated +- Create plan flow: send PUT → verify Atlas API called → verify audit logged +- Update plan flow: send PATCH → verify Atlas API called → verify audit logged +- Bulk create flow: send POST → verify Atlas API called with correct body +- Error resilience: mix of successful and failing hosts during sync +- Auth enforcement: verify 401/403 for each endpoint with wrong credentials/group diff --git a/.kiro/specs/atlas-action-plans/requirements.md b/.kiro/specs/atlas-action-plans/requirements.md new file mode 100644 index 0000000..3ded4fc --- /dev/null +++ b/.kiro/specs/atlas-action-plans/requirements.md @@ -0,0 +1,164 @@ +# Requirements Document + +## Introduction + +Integrate the Atlas InfoSec action plans API into the STEAM Security Dashboard so that users can view and manage compliance action plans for host findings directly from the ReportingPage. This eliminates the need to context-switch to the separate Atlas InfoSec web tool. The integration uses STEAM's Ivanti findings as the source of truth and checks which hosts also exist in Atlas, displaying action plan status badges and providing a slide-out panel for plan creation and management. + +## Glossary + +- **Dashboard**: The STEAM Security Dashboard frontend React application +- **Backend**: The STEAM Security Dashboard Express.js API server +- **Atlas_API**: The Atlas InfoSec REST API at `https://atlas-infosec.caas.charterlab.com`, documented in `docs/atlasinfosec-api-spec.json` +- **Atlas_Helper**: The backend helper module (`backend/helpers/atlasApi.js`) responsible for HTTP communication with the Atlas_API +- **Atlas_Cache**: A SQLite table storing host-level action plan status per `hostId`, refreshed on-demand via manual sync +- **Atlas_Router**: The backend Express route module (`backend/routes/atlas.js`) exposing Atlas-related endpoints under `/api/atlas` +- **ReportingPage**: The existing frontend page (`frontend/src/components/pages/ReportingPage.js`) displaying Ivanti host findings +- **Action_Plan**: A compliance plan created in Atlas InfoSec for a host finding, with a type (decommission, remediation, false_positive, risk_acceptance, scan_exclusion), a commit date, and optional reference fields +- **Host_ID**: The shared numeric identifier linking an Ivanti host finding (`host.hostId`) to an Atlas host (`host_id` URL parameter) +- **Finding_ID**: The Ivanti finding-level identifier (`f.id`) that maps to Atlas's `active_host_findings_id` +- **Slide_Out_Panel**: A right-side drawer component on the ReportingPage for viewing and managing action plans for a specific host +- **Atlas_Badge**: A visual indicator on the ReportingPage Host column showing whether a host exists in Atlas and its action plan coverage status +- **Ivanti_Cache**: The existing `ivanti_findings_cache` SQLite table holding synced Ivanti host findings + +## Requirements + +### Requirement 1: Atlas API Helper Module + +**User Story:** As a backend developer, I want a centralized helper module for Atlas InfoSec API communication, so that all Atlas HTTP calls use consistent authentication, TLS handling, and error management. + +#### Acceptance Criteria + +1. THE Atlas_Helper SHALL send all requests to the Atlas_API base URL configured via the `ATLAS_API_URL` environment variable +2. THE Atlas_Helper SHALL include a Basic Auth `Authorization` header computed by base64-encoding the `ATLAS_API_USER` and `ATLAS_API_PASS` environment variable values at runtime +3. WHEN the `ATLAS_SKIP_TLS` environment variable is set to `true`, THE Atlas_Helper SHALL disable TLS certificate verification for Atlas_API requests +4. WHEN the `ATLAS_SKIP_TLS` environment variable is not set or set to `false`, THE Atlas_Helper SHALL enforce TLS certificate verification for Atlas_API requests +5. THE Atlas_Helper SHALL support GET, PUT, PATCH, and POST HTTP methods for communicating with the Atlas_API +6. THE Atlas_Helper SHALL set a request timeout of 15 seconds for single-host endpoints and 60 seconds for bulk endpoints +7. WHEN the Atlas_API returns a non-2xx status code, THE Atlas_Helper SHALL resolve the promise with the status code and response body without throwing an exception +8. WHEN a network error or timeout occurs, THE Atlas_Helper SHALL reject the promise with a descriptive error message including the HTTP method and URL path + +### Requirement 2: Atlas Cache Table and Migration + +**User Story:** As a system administrator, I want Atlas action plan status cached locally in SQLite, so that the ReportingPage can render badges without calling the Atlas_API on every page load. + +#### Acceptance Criteria + +1. THE Backend SHALL provide a migration script (`backend/migrations/add_atlas_action_plans_cache.js`) that creates the Atlas_Cache table +2. THE Atlas_Cache table SHALL store one row per Host_ID with columns for: `host_id` (integer, unique), `has_action_plan` (integer, 0 or 1), `plan_count` (integer), `plans_json` (text, JSON array of plan summaries), and `synced_at` (datetime) +3. THE migration script SHALL create an index on the `host_id` column of the Atlas_Cache table +4. THE migration script SHALL follow the existing migration pattern: open the database at `backend/cve_database.db`, use `db.serialize()`, log progress to the console, and close the database on completion +5. WHEN the migration script is run multiple times, THE migration script SHALL complete without errors by using `CREATE TABLE IF NOT EXISTS` and `CREATE INDEX IF NOT EXISTS` + +### Requirement 3: Atlas Sync Route + +**User Story:** As a dashboard user, I want to trigger a manual sync of Atlas action plan data, so that the badge indicators on the ReportingPage reflect the current state of action plans in Atlas. + +#### Acceptance Criteria + +1. THE Atlas_Router SHALL expose a `POST /api/atlas/sync` endpoint that requires authentication and membership in the Admin or Standard_User group +2. WHEN the sync endpoint is called, THE Atlas_Router SHALL extract unique Host_ID values from the Ivanti_Cache findings +3. WHEN unique Host_ID values are extracted, THE Atlas_Router SHALL call the Atlas_API `GET /hosts/{host_id}/action-plans` endpoint for each Host_ID to retrieve action plan data +4. WHEN action plan data is retrieved for a Host_ID, THE Atlas_Router SHALL upsert the Atlas_Cache row for that Host_ID with the plan count, plan summary JSON, and current timestamp +5. WHEN the Atlas_API returns a non-2xx response for a specific Host_ID, THE Atlas_Router SHALL skip that host and continue processing remaining hosts +6. WHEN the sync completes, THE Atlas_Router SHALL return a JSON response containing the count of hosts synced, the count of hosts with action plans, and the count of hosts that failed +7. THE Atlas_Router SHALL log an audit entry for each sync operation with the initiating user and result summary + +### Requirement 4: Atlas Status Route + +**User Story:** As a frontend developer, I want a single endpoint that returns cached Atlas status for all hosts, so that the ReportingPage can render badges without individual API calls per row. + +#### Acceptance Criteria + +1. THE Atlas_Router SHALL expose a `GET /api/atlas/status` endpoint that requires authentication +2. WHEN the status endpoint is called, THE Atlas_Router SHALL return all rows from the Atlas_Cache table as a JSON array +3. THE status response SHALL include for each host: `host_id`, `has_action_plan`, `plan_count`, and `synced_at` + +### Requirement 5: Atlas Action Plan Proxy Routes + +**User Story:** As a dashboard user, I want to create, view, and update Atlas action plans from the STEAM Dashboard, so that I do not need to switch to the Atlas InfoSec web tool. + +#### Acceptance Criteria + +1. THE Atlas_Router SHALL expose a `GET /api/atlas/hosts/:hostId/action-plans` endpoint that requires authentication and proxies to the Atlas_API `GET /hosts/{host_id}/action-plans` endpoint +2. THE Atlas_Router SHALL expose a `PUT /api/atlas/hosts/:hostId/action-plans` endpoint that requires authentication and membership in the Admin or Standard_User group +3. WHEN the PUT endpoint receives a request body, THE Atlas_Router SHALL validate that `plan_type` is one of: decommission, remediation, false_positive, risk_acceptance, scan_exclusion +4. WHEN the PUT endpoint receives a request body, THE Atlas_Router SHALL validate that `commit_date` is present and is a valid date string in YYYY-MM-DD format +5. WHEN validation passes, THE Atlas_Router SHALL proxy the request body to the Atlas_API `PUT /hosts/{host_id}/action-plans` endpoint and return the Atlas_API response +6. THE Atlas_Router SHALL expose a `PATCH /api/atlas/hosts/:hostId/action-plans` endpoint that requires authentication and membership in the Admin or Standard_User group +7. WHEN the PATCH endpoint receives a request body, THE Atlas_Router SHALL validate that `action_plan_id` (string) and `updates` (object) are present +8. WHEN validation passes, THE Atlas_Router SHALL proxy the request body to the Atlas_API `PATCH /hosts/{host_id}/action-plans` endpoint and return the Atlas_API response +9. THE Atlas_Router SHALL expose a `POST /api/atlas/hosts/bulk-action-plans` endpoint that requires authentication and membership in the Admin or Standard_User group +10. WHEN the bulk endpoint receives a request body, THE Atlas_Router SHALL validate that `host_ids` is a non-empty array of integers, `plan_type` is valid, and `commit_date` is a valid YYYY-MM-DD date string +11. WHEN validation passes, THE Atlas_Router SHALL proxy the request body to the Atlas_API `POST /hosts/create-bulk-action-plans` endpoint and return the Atlas_API response +12. WHEN any proxy endpoint receives a non-2xx response from the Atlas_API, THE Atlas_Router SHALL return the Atlas_API status code and error body to the frontend +13. THE Atlas_Router SHALL log an audit entry for each create (PUT) and update (PATCH) action plan operation with the user, Host_ID, and plan type + +### Requirement 6: Atlas Badge on ReportingPage + +**User Story:** As a dashboard user, I want to see at a glance which hosts in the findings table have Atlas action plans, so that I can prioritize hosts that still need compliance attention. + +#### Acceptance Criteria + +1. WHEN the ReportingPage loads, THE Dashboard SHALL fetch cached Atlas status from `GET /api/atlas/status` and store the result in component state +2. WHEN a finding row's Host_ID matches an entry in the Atlas status data, THE Dashboard SHALL display an Atlas_Badge in the Host column next to the hostname +3. WHEN a host exists in Atlas but has zero action plans, THE Atlas_Badge SHALL display with a warning style indicating the host needs attention +4. WHEN a host exists in Atlas and has one or more active action plans, THE Atlas_Badge SHALL display with a success style indicating the host is covered +5. WHEN a finding row's Host_ID does not match any entry in the Atlas status data, THE Dashboard SHALL display no Atlas_Badge for that row +6. THE Atlas_Badge SHALL display the action plan count as text within the badge when the host has one or more plans + +### Requirement 7: Atlas Slide-Out Panel + +**User Story:** As a dashboard user, I want to click an Atlas badge to see full action plan details and create or update plans, so that I can manage compliance without leaving the ReportingPage. + +#### Acceptance Criteria + +1. WHEN a user clicks an Atlas_Badge, THE Dashboard SHALL open the Slide_Out_Panel on the right side of the ReportingPage +2. WHEN the Slide_Out_Panel opens, THE Dashboard SHALL fetch full action plan details from `GET /api/atlas/hosts/:hostId/action-plans` and display them in the panel +3. THE Slide_Out_Panel SHALL display each existing action plan with: plan type, commit date, status, and any associated VNR or EXC reference numbers +4. WHEN the user is in the Admin or Standard_User group, THE Slide_Out_Panel SHALL display a form to create a new action plan with fields for: plan type (dropdown selector), commit date (date picker), qualys_id (optional text input), active_host_findings_id (optional numeric input), jira_vnr (optional text input), and archer_exc (optional text input) +5. WHEN the user submits the create form with valid data, THE Dashboard SHALL send a PUT request to `/api/atlas/hosts/:hostId/action-plans` and refresh the plan list on success +6. WHEN the user is in the Admin or Standard_User group, THE Slide_Out_Panel SHALL provide an edit capability for existing action plans +7. WHEN the user submits an update with valid data, THE Dashboard SHALL send a PATCH request to `/api/atlas/hosts/:hostId/action-plans` and refresh the plan list on success +8. WHEN the Atlas_API returns an error for a create or update operation, THE Slide_Out_Panel SHALL display the error message to the user +9. WHEN the user clicks outside the Slide_Out_Panel or clicks a close button, THE Dashboard SHALL close the panel +10. WHEN the user is in the Viewer group, THE Slide_Out_Panel SHALL display existing plans in read-only mode without the create or edit forms + +### Requirement 8: Atlas Sync Button on ReportingPage + +**User Story:** As a dashboard user, I want a manual sync button for Atlas data on the ReportingPage, so that I can refresh action plan status on demand. + +#### Acceptance Criteria + +1. THE Dashboard SHALL display an Atlas sync button on the ReportingPage near the existing Ivanti sync button +2. WHEN the user is in the Admin or Standard_User group, THE Atlas sync button SHALL be enabled +3. WHEN the user is in the Viewer group, THE Atlas sync button SHALL be disabled with a tooltip indicating insufficient permissions +4. WHEN the user clicks the Atlas sync button, THE Dashboard SHALL send a POST request to `/api/atlas/sync` +5. WHILE the Atlas sync is in progress, THE Atlas sync button SHALL display a loading indicator and be disabled to prevent duplicate requests +6. WHEN the Atlas sync completes successfully, THE Dashboard SHALL refresh the Atlas status data and update all Atlas_Badge indicators on the page +7. WHEN the Atlas sync fails, THE Dashboard SHALL display an error notification with the failure reason + +### Requirement 9: Environment Configuration + +**User Story:** As a system administrator, I want Atlas API credentials and configuration documented alongside existing environment variables, so that deployment setup is straightforward. + +#### Acceptance Criteria + +1. THE Backend SHALL read the Atlas_API base URL from the `ATLAS_API_URL` environment variable +2. THE Backend SHALL read the Atlas service account username from the `ATLAS_API_USER` environment variable +3. THE Backend SHALL read the Atlas service account password from the `ATLAS_API_PASS` environment variable +4. THE Backend SHALL read the TLS verification skip flag from the `ATLAS_SKIP_TLS` environment variable +5. THE Backend SHALL document all four Atlas environment variables in `backend/.env.example` with descriptive comments + +### Requirement 10: Access Control + +**User Story:** As a security administrator, I want Atlas operations restricted by user group, so that only authorized users can modify action plans or trigger syncs. + +#### Acceptance Criteria + +1. THE Atlas_Router SHALL allow all authenticated users to access the `GET /api/atlas/status` endpoint +2. THE Atlas_Router SHALL allow all authenticated users to access the `GET /api/atlas/hosts/:hostId/action-plans` endpoint +3. THE Atlas_Router SHALL restrict the `POST /api/atlas/sync` endpoint to users in the Admin or Standard_User group +4. THE Atlas_Router SHALL restrict the `PUT /api/atlas/hosts/:hostId/action-plans` endpoint to users in the Admin or Standard_User group +5. THE Atlas_Router SHALL restrict the `PATCH /api/atlas/hosts/:hostId/action-plans` endpoint to users in the Admin or Standard_User group +6. THE Atlas_Router SHALL restrict the `POST /api/atlas/hosts/bulk-action-plans` endpoint to users in the Admin or Standard_User group +7. WHEN an unauthorized user attempts a restricted operation, THE Atlas_Router SHALL return HTTP 403 with an error message indicating insufficient permissions diff --git a/.kiro/specs/atlas-action-plans/tasks.md b/.kiro/specs/atlas-action-plans/tasks.md new file mode 100644 index 0000000..853ff54 --- /dev/null +++ b/.kiro/specs/atlas-action-plans/tasks.md @@ -0,0 +1,262 @@ +# Implementation Plan: Atlas Action Plans Integration + +## Overview + +Integrate the Atlas InfoSec action plans API into the STEAM Security Dashboard. The implementation follows the existing proxy-and-cache pattern — backend helper for HTTP communication, SQLite cache for fast page loads, Express routes for proxied CRUD, and React frontend components for badge display and plan management. Tasks are ordered for incremental progress: environment config, backend helper, migration, routes, server wiring, then frontend components. + +## Tasks + +- [x] 1. Add Atlas environment variables to `.env.example` + - Append `ATLAS_API_URL`, `ATLAS_API_USER`, `ATLAS_API_PASS`, and `ATLAS_SKIP_TLS` to `backend/.env.example` with descriptive comments, following the existing Ivanti variable block pattern + - _Requirements: 9.1, 9.2, 9.3, 9.4, 9.5_ + +- [ ] 2. Implement Atlas API helper module + - [x] 2.1 Create `backend/helpers/atlasApi.js` with `atlasRequest`, `atlasGet`, `atlasPut`, `atlasPatch`, `atlasPost` functions + - Read `ATLAS_API_URL`, `ATLAS_API_USER`, `ATLAS_API_PASS`, `ATLAS_SKIP_TLS` from `process.env` at module load + - Compute `Authorization: Basic ` header at request time + - Use Node.js `https` module following the `ivantiApi.js` pattern + - Support GET, PUT, PATCH, POST methods with `rejectUnauthorized` controlled by `ATLAS_SKIP_TLS` + - Default timeout 15s for single-host endpoints, 60s via `options.timeout` for bulk + - Resolve non-2xx responses with `{ status, body }` without throwing + - Reject on network errors/timeouts with a message containing the HTTP method and URL path + - Log a warning at module load if required env vars are missing + - _Requirements: 1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 1.7, 1.8_ + + - [ ] 2.2 Write property test: Basic Auth header round-trip + - **Property 1: Basic Auth header round-trip** + - Generate random (username, password) string pairs, verify base64 decode yields `username:password` + - **Validates: Requirements 1.2** + + - [ ] 2.3 Write property test: Non-2xx responses resolve with status and body + - **Property 2: Non-2xx responses resolve with status and body** + - Generate integers 400–599 and random body strings, verify promise resolves with `{ status, body }` + - **Validates: Requirements 1.7** + + - [ ] 2.4 Write property test: Error messages contain method and path + - **Property 3: Error messages contain method and path** + - Generate random method names and URL path strings, verify rejection message includes both + - **Validates: Requirements 1.8** + +- [x] 3. Checkpoint — Verify Atlas helper module + - Ensure all tests pass, ask the user if questions arise. + +- [ ] 4. Create Atlas cache migration + - [x] 4.1 Create `backend/migrations/add_atlas_action_plans_cache.js` + - Follow the existing migration pattern from `add_ivanti_findings_tables.js`: open `backend/cve_database.db`, use `db.serialize()`, log progress, close on completion + - Create `atlas_action_plans_cache` table with columns: `id` (INTEGER PRIMARY KEY AUTOINCREMENT), `host_id` (INTEGER NOT NULL UNIQUE), `has_action_plan` (INTEGER NOT NULL DEFAULT 0), `plan_count` (INTEGER NOT NULL DEFAULT 0), `plans_json` (TEXT NOT NULL DEFAULT '[]'), `synced_at` (DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP) + - Create index `idx_atlas_cache_host_id` on `host_id` + - Use `CREATE TABLE IF NOT EXISTS` and `CREATE INDEX IF NOT EXISTS` for idempotency + - _Requirements: 2.1, 2.2, 2.3, 2.4, 2.5_ + + - [ ]* 4.2 Write unit tests for migration idempotency + - Verify migration runs twice without errors + - Verify table and index exist after migration + - _Requirements: 2.5_ + +- [ ] 5. Implement Atlas router with all endpoints + - [x] 5.1 Create `backend/routes/atlas.js` with `createAtlasRouter(db, requireAuth)` factory function + - Import `requireGroup` from `../middleware/auth`, `logAudit` from `../helpers/auditLog`, and Atlas helper functions from `../helpers/atlasApi` + - Check Atlas helper availability; return 503 if Atlas is not configured + - _Requirements: 10.1, 10.2, 10.3, 10.4, 10.5, 10.6, 10.7_ + + - [x] 5.2 Implement `GET /status` endpoint + - Require authentication (any group) + - Return all rows from `atlas_action_plans_cache` as JSON array with `host_id`, `has_action_plan`, `plan_count`, `synced_at` + - _Requirements: 4.1, 4.2, 4.3, 10.1_ + + - [x] 5.3 Implement `POST /sync` endpoint + - Require authentication and Admin or Standard_User group + - Extract unique non-null `hostId` values from `ivanti_findings_cache.findings_json` + - Call `atlasGet('/hosts/' + hostId + '/action-plans')` for each host with concurrency limit of 5 using `Promise.allSettled` + - On 2xx: upsert cache row with `plan_count`, `has_action_plan`, `plans_json`, and current timestamp + - On non-2xx: increment failure counter, log warning, continue + - Return `{ synced, withPlans, failed }` summary + - Log audit entry with initiating user and result summary + - _Requirements: 3.1, 3.2, 3.3, 3.4, 3.5, 3.6, 3.7, 10.3_ + + - [x] 5.4 Implement `GET /hosts/:hostId/action-plans` proxy endpoint + - Require authentication (any group) + - Validate `hostId` is a positive integer + - Proxy to Atlas API `GET /hosts/{host_id}/action-plans` and return the response + - Forward non-2xx Atlas responses to the client + - _Requirements: 5.1, 5.12, 10.2_ + + - [x] 5.5 Implement `PUT /hosts/:hostId/action-plans` proxy endpoint + - Require authentication and Admin or Standard_User group + - Validate `hostId` is a positive integer + - Validate `plan_type` is one of: decommission, remediation, false_positive, risk_acceptance, scan_exclusion + - Validate `commit_date` is present and matches `YYYY-MM-DD` format + - Proxy validated body to Atlas API `PUT /hosts/{host_id}/action-plans` + - Log audit entry with user, hostId, and plan type + - _Requirements: 5.2, 5.3, 5.4, 5.5, 5.12, 5.13, 10.4_ + + - [x] 5.6 Implement `PATCH /hosts/:hostId/action-plans` proxy endpoint + - Require authentication and Admin or Standard_User group + - Validate `action_plan_id` is a non-empty string and `updates` is a non-null object + - Proxy validated body to Atlas API `PATCH /hosts/{host_id}/action-plans` + - Log audit entry with user, hostId, and plan type + - _Requirements: 5.6, 5.7, 5.8, 5.12, 5.13, 10.5_ + + - [x] 5.7 Implement `POST /hosts/bulk-action-plans` proxy endpoint + - Require authentication and Admin or Standard_User group + - Validate `host_ids` is a non-empty array of positive integers, `plan_type` is valid, `commit_date` matches `YYYY-MM-DD` + - Proxy validated body to Atlas API `POST /hosts/create-bulk-action-plans` + - _Requirements: 5.9, 5.10, 5.11, 5.12, 10.6_ + + - [ ] 5.8 Write property test: Unique host ID extraction + - **Property 4: Unique host ID extraction** + - Generate arrays of finding objects with optional numeric `hostId` fields, verify extracted set has no duplicates and no nulls + - **Validates: Requirements 3.2** + + - [ ] 5.9 Write property test: Cache upsert derives correct plan_count and has_action_plan + - **Property 5: Cache upsert derives correct plan_count and has_action_plan** + - Generate (hostId, planArray) pairs, verify `plan_count` equals array length and `has_action_plan` equals 1 if non-empty, 0 otherwise + - **Validates: Requirements 3.4** + + - [ ] 5.10 Write property test: Sync response count invariant + - **Property 6: Sync response count invariant** + - Generate (totalHosts, failureCount) pairs, verify `synced + failed = totalHosts` and `withPlans <= synced` + - **Validates: Requirements 3.6** + + - [ ] 5.11 Write property test: Status endpoint returns all cached rows with required fields + - **Property 7: Status endpoint returns all cached rows with required fields** + - Generate N cache rows, insert into DB, verify response count and field presence + - **Validates: Requirements 4.2, 4.3** + + - [ ] 5.12 Write property test: plan_type validation + - **Property 8: plan_type validation** + - Generate random strings, verify acceptance if and only if string is one of the five valid types + - **Validates: Requirements 5.3** + + - [ ] 5.13 Write property test: commit_date validation + - **Property 9: commit_date validation** + - Generate random strings, verify acceptance if and only if string matches `YYYY-MM-DD` pattern + - **Validates: Requirements 5.4** + + - [ ] 5.14 Write property test: PATCH body validation + - **Property 10: PATCH body validation** + - Generate random objects with varying field presence, verify acceptance if and only if `action_plan_id` is a non-empty string and `updates` is a non-null object + - **Validates: Requirements 5.7** + + - [ ] 5.15 Write property test: Bulk request validation + - **Property 11: Bulk request validation** + - Generate objects with varying `host_ids`, `plan_type`, `commit_date`, verify acceptance matches combined validation rules + - **Validates: Requirements 5.10** + + - [ ]* 5.16 Write property test: Non-2xx Atlas response passthrough + - **Property 12: Non-2xx Atlas response passthrough** + - Generate non-2xx status codes and body strings, verify proxy route returns same status and body + - **Validates: Requirements 5.12** + +- [x] 6. Checkpoint — Verify backend routes and properties + - Ensure all tests pass, ask the user if questions arise. + +- [x] 7. Mount Atlas router in server.js + - Add `const createAtlasRouter = require('./routes/atlas');` import alongside existing route imports + - Add `app.use('/api/atlas', createAtlasRouter(db, requireAuth));` mount alongside existing route mounts + - _Requirements: 3.1, 4.1, 5.1, 5.2, 5.6, 5.9_ + +- [ ] 8. Implement frontend Atlas components + - [x] 8.1 Create AtlasBadge component in `frontend/src/components/AtlasBadge.js` + - Accept props: `{ hostId, atlasStatus, onClick }` + - Render nothing if `atlasStatus` is undefined (host not in cache) + - Render warning badge (amber border, "0" text) if `has_action_plan === 0` + - Render success badge (emerald border, plan count text) if `plan_count > 0` + - Use design system badge pattern: monospace font, 0.58rem, inline-flex, pill shape + - _Requirements: 6.2, 6.3, 6.4, 6.5, 6.6_ + + - [ ]* 8.2 Write property test: Badge visibility and content + - **Property 13: Badge visibility and content** + - Generate findings with `hostId` and atlas status maps, verify badge renders if and only if `hostId` exists in map, and badge text contains plan count when `plan_count > 0` + - **Validates: Requirements 6.2, 6.5, 6.6** + + - [x] 8.3 Create AtlasSlideOutPanel component in `frontend/src/components/AtlasSlideOutPanel.js` + - Accept props: `{ hostId, hostName, onClose, canWrite }` + - Fetch action plans from `GET /api/atlas/hosts/:hostId/action-plans` on open + - Display header with hostname, host ID, and close button + - Display each plan with: plan type, commit date, status, VNR/EXC references + - Show create form (plan type dropdown, commit date picker, optional fields: qualys_id, active_host_findings_id, jira_vnr, archer_exc) when `canWrite` is true + - Submit create via `PUT /api/atlas/hosts/:hostId/action-plans`, refresh plan list on success + - Show inline edit capability for existing plans when `canWrite` is true + - Submit updates via `PATCH /api/atlas/hosts/:hostId/action-plans`, refresh plan list on success + - Display error messages from Atlas API inline in the panel + - Close on backdrop click or close button + - Hide create/edit forms for Viewer group (read-only mode) + - _Requirements: 7.1, 7.2, 7.3, 7.4, 7.5, 7.6, 7.7, 7.8, 7.9, 7.10_ + + - [ ]* 8.4 Write property test: Panel displays all plan fields + - **Property 14: Panel displays all plan fields** + - Generate plan objects with `plan_type`, `commit_date`, `status`, and optional reference fields, verify all non-null field values appear in rendered output + - **Validates: Requirements 7.3** + + - [ ] 8.5 Write unit tests for AtlasBadge and AtlasSlideOutPanel + - Test AtlasBadge renders nothing when host not in status map + - Test AtlasBadge renders warning style when `plan_count` is 0 + - Test AtlasBadge renders success style when `plan_count > 0` + - Test AtlasSlideOutPanel shows create form for Admin/Standard_User + - Test AtlasSlideOutPanel hides create form for Viewer + - _Requirements: 6.2, 6.3, 6.4, 6.5, 7.4, 7.10_ + +- [ ] 9. Integrate Atlas badge and sync button into ReportingPage + - [x] 9.1 Add Atlas status state and fetch to ReportingPage + - Add `atlasStatus` state (Map keyed by hostId), `atlasSyncing` boolean, `atlasError` string + - Fetch `GET /api/atlas/status` on mount and build the status map + - _Requirements: 6.1_ + + - [x] 9.2 Render AtlasBadge in Host column cells + - In the Host column cell renderer, check `atlasStatus` map for the finding's `hostId` + - Render AtlasBadge inline after the hostname text when a match exists + - Wire badge `onClick` to open the AtlasSlideOutPanel with the host's ID and name + - _Requirements: 6.2, 6.3, 6.4, 6.5, 6.6, 7.1_ + + - [x] 9.3 Add Atlas sync button to ReportingPage toolbar + - Place adjacent to existing Ivanti sync button, using same styling pattern (RefreshCw icon, monospace uppercase text, sky blue accent) + - Differentiate with Database icon prefix and "Atlas" label + - Enable for Admin and Standard_User groups, disable for Viewer with tooltip + - On click: send `POST /api/atlas/sync`, show loading indicator, disable button + - On success: re-fetch `GET /api/atlas/status` and update all badges + - On failure: display error notification + - _Requirements: 8.1, 8.2, 8.3, 8.4, 8.5, 8.6, 8.7_ + + - [x] 9.4 Wire AtlasSlideOutPanel into ReportingPage + - Add state for selected host (`atlasSelectedHostId`, `atlasSelectedHostName`, `atlasPanelOpen`) + - Render AtlasSlideOutPanel conditionally when `atlasPanelOpen` is true + - Pass `canWrite` based on user group (Admin or Standard_User) + - On panel close: clear selected host state + - On plan create/update success: re-fetch atlas status to update badges + - _Requirements: 7.1, 7.2, 7.9, 7.10_ + +- [x] 10. Checkpoint — Verify full integration + - Ensure all tests pass, ask the user if questions arise. + +- [ ] 11. Write integration tests + - [ ]* 11.1 Write integration tests for Atlas sync flow + - Populate Ivanti cache with test findings, trigger sync with mocked Atlas API, verify Atlas cache populated correctly + - Test error resilience: mix of successful and failing hosts during sync + - _Requirements: 3.2, 3.3, 3.4, 3.5, 3.6_ + + - [ ]* 11.2 Write integration tests for Atlas proxy routes + - Test create plan flow: send PUT, verify Atlas API called, verify audit logged + - Test update plan flow: send PATCH, verify Atlas API called, verify audit logged + - Test bulk create flow: send POST, verify Atlas API called with correct body + - _Requirements: 5.1, 5.2, 5.5, 5.6, 5.8, 5.11, 5.13_ + + - [ ] 11.3 Write integration tests for access control + - Verify 401 for unauthenticated requests on all endpoints + - Verify 403 for Viewer group on restricted endpoints (sync, PUT, PATCH, bulk POST) + - Verify 200 for Viewer group on read endpoints (status, GET plans) + - _Requirements: 10.1, 10.2, 10.3, 10.4, 10.5, 10.6, 10.7_ + +- [x] 12. Final checkpoint — Ensure all tests pass + - 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 validate universal correctness properties from the design document using fast-check +- Unit tests validate specific examples and edge cases +- The backend follows the existing factory function pattern (`createAtlasRouter(db, requireAuth)`) +- The Atlas helper follows the existing `ivantiApi.js` pattern (promise-based HTTP with Node.js `https` module) +- The migration follows the existing pattern from `add_ivanti_findings_tables.js` diff --git a/.kiro/specs/atlas-metrics-report/.config.kiro b/.kiro/specs/atlas-metrics-report/.config.kiro new file mode 100644 index 0000000..9fa32d1 --- /dev/null +++ b/.kiro/specs/atlas-metrics-report/.config.kiro @@ -0,0 +1 @@ +{"specId": "a3e7c1d2-8f4b-4a91-b6e3-9d2f5c8a1b74", "workflowType": "requirements-first", "specType": "feature"} \ No newline at end of file diff --git a/.kiro/specs/atlas-metrics-report/design.md b/.kiro/specs/atlas-metrics-report/design.md new file mode 100644 index 0000000..8016e5f --- /dev/null +++ b/.kiro/specs/atlas-metrics-report/design.md @@ -0,0 +1,362 @@ +# Design Document: Atlas Metrics Report + +## Overview + +This feature adds a tab system to the existing Metric Graphs panel on the ReportingPage, separating Ivanti donut charts from new Atlas coverage charts. A new `GET /api/atlas/metrics` endpoint aggregates cached Atlas action plan data into chart-ready metrics. The frontend renders three new donut charts — coverage, plan type distribution, and plan status distribution — under an "Atlas Coverage" tab, while the existing four Ivanti donuts move under an "Ivanti Findings" tab. + +### Key Design Decisions + +- **Server-side aggregation**: The metrics endpoint computes counts and distributions on the backend rather than shipping raw `plans_json` to the client. This keeps the frontend simple and avoids parsing potentially large JSON arrays in the browser. +- **Reuse existing donut helpers**: The new Atlas donut charts reuse the `polarToCartesian` and `donutArcPath` SVG helper functions already defined in ReportingPage.js, along with the same dimensions (180px size, 72px outer radius, 48px inner radius). This keeps the visual language consistent. +- **Fetch once, refresh on sync**: Atlas metrics are fetched once on page mount and re-fetched only after a successful Atlas sync. Tab switches do not trigger new API calls. +- **No server.js changes**: The new endpoint is added inside the existing `createAtlasRouter(db, requireAuth)` factory function in `backend/routes/atlas.js`. The router is already mounted at `/api/atlas` in server.js. +- **Tab state is local**: The active tab is stored in React component state — no URL params, no localStorage. The default is "Ivanti Findings" to preserve the existing experience. +- **IvantiCountsChart conditional visibility**: The trend chart below the Metric Graphs panel is only shown when the Ivanti tab is active, since it has no relevance to Atlas data. + +## Architecture + +```mermaid +graph TD + subgraph Frontend - ReportingPage + TS[Tab System
Ivanti Findings | Atlas Coverage] + ID[Ivanti Donuts
StatusDonut, ActionCoverageDonut,
FPWorkflowDonut x2] + AD[Atlas Donuts
CoverageDonut, PlanTypeDonut,
PlanStatusDonut] + IC[IvantiCountsChart] + end + + subgraph Backend - Atlas Router + ME[GET /api/atlas/metrics] + AC[(atlas_action_plans_cache
SQLite)] + end + + TS -->|tab = ivanti| ID + TS -->|tab = ivanti| IC + TS -->|tab = atlas| AD + AD -->|fetch on mount + after sync| ME + ME -->|SELECT + aggregate| AC +``` + +### Data Flow: Metrics Fetch + +1. ReportingPage mounts → calls `GET /api/atlas/metrics` +2. Backend queries all rows from `atlas_action_plans_cache`, including `plans_json` +3. Backend iterates rows, parses each `plans_json`, counts plans by type and status +4. Backend returns aggregated JSON: `{ totalHosts, hostsWithPlans, hostsWithoutPlans, plansByType, plansByStatus, totalPlans }` +5. Frontend stores result in `atlasMetrics` state +6. When "Atlas Coverage" tab is active, three donut components render from this state + +### Data Flow: Refresh After Sync + +1. User clicks Atlas sync button → `POST /api/atlas/sync` (existing) +2. On success, frontend calls `GET /api/atlas/metrics` again +3. Atlas donut charts re-render with updated data + +## Components and Interfaces + +### Backend: GET /api/atlas/metrics Endpoint + +Added inside the existing `createAtlasRouter(db, requireAuth)` factory function in `backend/routes/atlas.js`. + +| Method | Path | Auth | Group | Description | +|--------|------|------|-------|-------------| +| `GET` | `/metrics` | requireAuth | any | Return aggregated Atlas metrics for chart rendering | + +**Response shape**: + +```json +{ + "totalHosts": 42, + "hostsWithPlans": 28, + "hostsWithoutPlans": 14, + "plansByType": { + "decommission": 5, + "remediation": 18, + "false_positive": 3, + "risk_acceptance": 8, + "scan_exclusion": 2 + }, + "plansByStatus": { + "active": 25, + "expired": 7, + "completed": 4 + }, + "totalPlans": 36 +} +``` + +**Implementation approach**: + +1. Query all rows: `SELECT has_action_plan, plans_json FROM atlas_action_plans_cache` +2. Initialize counters: `totalHosts = rows.length`, `hostsWithPlans = 0`, `hostsWithoutPlans = 0`, `plansByType = {}`, `plansByStatus = {}`, `totalPlans = 0` +3. For each row: + - If `has_action_plan === 1`, increment `hostsWithPlans`; else increment `hostsWithoutPlans` + - Try to parse `plans_json`; on failure, skip plan details for that row + - For each plan in the parsed array, increment the corresponding `plansByType[plan.plan_type]` and `plansByStatus[plan.status]` counters, and increment `totalPlans` +4. Return the aggregated object + +Uses the existing `dbAll` promise wrapper already defined in `atlas.js`. + +### Frontend: Tab System + +A horizontal tab bar rendered inside the Metric Graphs panel header, to the right of the "Metric Graphs" title and `PieChart` icon. + +**State**: `metricsTab` — `'ivanti'` (default) or `'atlas'` + +**Tab bar structure**: + +``` +┌──────────────────────────────────────────────────────────────┐ +│ [PieChart icon] METRIC GRAPHS [Ivanti Findings] [Atlas Coverage] │ +└──────────────────────────────────────────────────────────────┘ +``` + +**Styling**: +- Tabs use `role="tab"` and `aria-selected`; the content area uses `role="tabpanel"` +- Active tab: `color: #F59E0B`, `borderBottom: 2px solid #F59E0B` +- Inactive tab: `color: #64748B`, no bottom border +- Hover on inactive: `background: rgba(245, 158, 11, 0.06)` +- Font: `'JetBrains Mono', monospace`, `0.7rem`, `uppercase`, `letterSpacing: 0.08em` +- Tabs are keyboard navigable via Tab and Enter keys + +### Frontend: Atlas Metrics State + +New state variables in the ReportingPage component: + +```javascript +const [metricsTab, setMetricsTab] = useState('ivanti'); +const [atlasMetrics, setAtlasMetrics] = useState(null); +const [atlasMetricsLoading, setAtlasMetricsLoading] = useState(false); +const [atlasMetricsError, setAtlasMetricsError] = useState(null); +``` + +New fetch function: + +```javascript +const fetchAtlasMetrics = useCallback(async () => { + setAtlasMetricsLoading(true); + setAtlasMetricsError(null); + try { + const res = await fetch(`${API_BASE}/atlas/metrics`, { credentials: 'include' }); + if (res.ok) { + const data = await res.json(); + setAtlasMetrics(data); + } else { + const err = await res.json().catch(() => ({})); + setAtlasMetricsError(err.error || 'Failed to fetch Atlas metrics'); + } + } catch (err) { + setAtlasMetricsError(err.message); + } finally { + setAtlasMetricsLoading(false); + } +}, []); +``` + +Called on mount (in the existing `useEffect`) and after a successful Atlas sync. + +### Frontend: Atlas Donut Charts + +Three new inline components defined in ReportingPage.js, following the same pattern as `StatusDonut`, `ActionCoverageDonut`, and `FPWorkflowDonut`. All reuse the existing `polarToCartesian` and `donutArcPath` helper functions. + +**AtlasCoverageDonut** +- Props: `{ hostsWithPlans, hostsWithoutPlans, totalHosts }` +- Segments: emerald (`#10B981`) for with plans, amber (`#F59E0B`) for without plans +- Center text: `totalHosts` count, "HOSTS" label +- Empty state: "No data — run Atlas Sync" + +**AtlasPlanTypeDonut** +- Props: `{ plansByType, totalPlans }` +- Color map: `decommission: #EF4444`, `remediation: #0EA5E9`, `false_positive: #A855F7`, `risk_acceptance: #F59E0B`, `scan_exclusion: #64748B` +- Center text: `totalPlans` count, "PLANS" label +- Legend: only shows types with count > 0 +- Empty state: "No plans — run Atlas Sync" + +**AtlasPlanStatusDonut** +- Props: `{ plansByStatus, totalPlans }` +- Color map: `active: #10B981`, `expired: #EF4444`, `completed: #0EA5E9`, fallback: `#64748B` +- Center text: `totalPlans` count, "STATUS" label +- Legend: only shows statuses with count > 0 +- Empty state: "No plans — run Atlas Sync" + +All three follow the same SVG dimensions: 180px size, 72px outer radius, 48px inner radius. + +### Frontend: Metric Graphs Panel Layout + +When "Ivanti Findings" tab is active: +- Existing four donuts in horizontal flex row with dividers (unchanged) +- IvantiCountsChart rendered below the panel (unchanged) + +When "Atlas Coverage" tab is active: +- Three Atlas donuts in horizontal flex row with dividers (same layout pattern) +- IvantiCountsChart hidden + +## Data Models + +### Metrics Endpoint Response + +| Field | Type | Description | +|-------|------|-------------| +| `totalHosts` | integer | Count of all rows in `atlas_action_plans_cache` | +| `hostsWithPlans` | integer | Count of rows where `has_action_plan = 1` | +| `hostsWithoutPlans` | integer | Count of rows where `has_action_plan = 0` | +| `plansByType` | object | Map of plan type string → integer count | +| `plansByStatus` | object | Map of plan status string → integer count | +| `totalPlans` | integer | Sum of all plans across all hosts | + +### Existing Atlas Cache Table (no changes) + +The `atlas_action_plans_cache` table is unchanged. The metrics endpoint reads from it: + +| Column | Type | Used by metrics endpoint | +|--------|------|--------------------------| +| `has_action_plan` | INTEGER | Counting hosts with/without plans | +| `plans_json` | TEXT | Parsed to count plans by type and status | + +### Plan Object Shape (within `plans_json`) + +The metrics endpoint reads `plan_type` and `status` from each plan object in the JSON array. The Atlas API returns these fields as strings. Example: + +```json +{ + "action_plan_id": "ap-123", + "plan_type": "remediation", + "status": "active", + "commit_date": "2026-07-01" +} +``` + +Known `plan_type` values: `decommission`, `remediation`, `false_positive`, `risk_acceptance`, `scan_exclusion` + +Known `status` values: `active`, `expired`, `completed` (the frontend handles unknown values with a neutral color) + + +## 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: Metrics aggregation correctness + +*For any* array of cache rows — each with a `has_action_plan` flag (0 or 1) and a `plans_json` string that is either valid JSON containing an array of plan objects (each with `plan_type` and `status` fields) or invalid JSON — the metrics aggregation function SHALL produce: +- `totalHosts` equal to the number of rows +- `hostsWithPlans + hostsWithoutPlans` equal to `totalHosts` +- `hostsWithPlans` equal to the count of rows where `has_action_plan === 1` +- `totalPlans` equal to the sum of plan array lengths across all rows with valid JSON +- Each key in `plansByType` maps to the count of plans with that `plan_type` across all valid rows +- Each key in `plansByStatus` maps to the count of plans with that `status` across all valid rows +- Rows with invalid `plans_json` are counted in `totalHosts` and `hostsWithPlans`/`hostsWithoutPlans` but their plans are excluded from `plansByType`, `plansByStatus`, and `totalPlans` + +**Validates: Requirements 1.3, 1.4, 1.5** + +### Property 2: Coverage donut data correctness + +*For any* non-negative integer pair `(hostsWithPlans, hostsWithoutPlans)` where `totalHosts = hostsWithPlans + hostsWithoutPlans > 0`, the Coverage Donut SHALL display `totalHosts` as center text, and the legend SHALL show counts matching the input values with percentages that equal `(count / totalHosts) * 100` for each segment. + +**Validates: Requirements 3.3, 3.4** + +### Property 3: Plan type donut data correctness + +*For any* `plansByType` object mapping plan type strings to positive integer counts, and `totalPlans` equal to the sum of those counts, the Plan Type Donut SHALL display `totalPlans` as center text, the legend SHALL include only types with count greater than zero, and each legend entry's percentage SHALL equal `(count / totalPlans) * 100`. + +**Validates: Requirements 4.3, 4.4** + +### Property 4: Plan status donut data correctness + +*For any* `plansByStatus` object mapping status strings to positive integer counts, and `totalPlans` equal to the sum of those counts, the Plan Status Donut SHALL display `totalPlans` as center text, the legend SHALL include only statuses with count greater than zero, and each legend entry's percentage SHALL equal `(count / totalPlans) * 100`. + +**Validates: Requirements 5.3, 5.4** + +### Property 5: Plan status color assignment + +*For any* status string, the Plan Status Donut color assignment function SHALL return `#10B981` if the status is `"active"`, `#EF4444` if `"expired"`, `#0EA5E9` if `"completed"`, and `#64748B` for any other string value. + +**Validates: Requirements 5.2** + +## Error Handling + +### Backend Errors + +| Error Scenario | Handling | HTTP Status | Response | +|----------------|----------|-------------|----------| +| Atlas not configured (missing env vars) | Return 503 with descriptive message | 503 | `{ error: 'Atlas API is not configured...' }` | +| Database query failure | Catch error, log, return 500 | 500 | `{ error: 'Failed to fetch Atlas metrics.' }` | +| Invalid JSON in `plans_json` column | Skip that row's plan details, continue processing | N/A (handled internally) | Metrics still returned with correct counts for valid rows | +| Empty cache table | Return metrics object with all zeros | 200 | `{ totalHosts: 0, hostsWithPlans: 0, hostsWithoutPlans: 0, plansByType: {}, plansByStatus: {}, totalPlans: 0 }` | +| Unauthenticated request | Auth middleware rejects | 401 | `{ error: 'Authentication required' }` | + +### Frontend Errors + +| Error Scenario | Handling | User Impact | +|----------------|----------|-------------| +| Metrics fetch returns non-200 | Store error message in `atlasMetricsError` state | Error message displayed in Atlas Coverage tab content area | +| Metrics fetch network failure | Catch error, store message | Error message displayed with failure reason | +| Metrics fetch in progress | `atlasMetricsLoading = true` | Loading spinner shown in Atlas Coverage tab content area | +| Atlas metrics data is null (not yet fetched) | Donut components check for null/undefined | Loading state or empty state shown | + +## Testing Strategy + +### Unit Tests + +Unit tests cover specific examples, edge cases, and integration points: + +**Backend — Metrics Aggregation (`GET /api/atlas/metrics`)**: +- Returns all-zero metrics when cache table is empty +- Correctly counts hosts with and without plans from seeded data +- Correctly aggregates plansByType from multiple hosts +- Correctly aggregates plansByStatus from multiple hosts +- Skips plan details for rows with invalid JSON but still counts the host +- Returns 503 when Atlas is not configured +- Requires authentication (returns 401 without session) + +**Frontend — Tab System**: +- Renders two tabs with correct labels +- Defaults to "Ivanti Findings" tab on mount +- Switches content when tab is clicked +- Active tab has correct ARIA attributes (`aria-selected="true"`) +- Tab panel has `role="tabpanel"` attribute +- Keyboard navigation works (Tab + Enter) + +**Frontend — Atlas Donut Charts**: +- Coverage donut shows "No data — run Atlas Sync" when totalHosts is 0 +- Plan type donut shows "No plans — run Atlas Sync" when totalPlans is 0 +- Plan status donut shows "No plans — run Atlas Sync" when totalPlans is 0 +- SVG dimensions are 180px with 72px outer and 48px inner radius +- Color assignments match specification for each plan type +- Color assignments match specification for known statuses + +**Frontend — Data Fetching**: +- Fetches metrics on mount +- Re-fetches metrics after successful Atlas sync +- Does not re-fetch on tab switch +- Shows loading indicator while fetch is in progress +- Shows error message when fetch fails + +**Frontend — IvantiCountsChart Visibility**: +- Rendered when Ivanti tab is active +- Not rendered when Atlas tab is active + +### Property-Based Tests + +Property-based tests verify universal properties across generated inputs. Each test runs a minimum of 100 iterations. + +**Library**: [fast-check](https://github.com/dubzzz/fast-check) — the standard PBT library for JavaScript/Node.js. + +**Configuration**: Each property test runs with `{ numRuns: 100 }` minimum. + +**Tag format**: Each test includes a comment referencing its design property: +```javascript +// Feature: atlas-metrics-report, Property 1: Metrics aggregation correctness +``` + +| Property | Test Description | Generator Strategy | +|----------|-----------------|-------------------| +| Property 1 | Metrics aggregation correctness | Generate arrays of objects with `has_action_plan` (0 or 1) and `plans_json` (either valid JSON array of `{plan_type, status}` objects or random invalid strings). Extract the aggregation logic into a pure function, call it with generated input, verify all invariants. | +| Property 2 | Coverage donut data correctness | Generate random `(hostsWithPlans, hostsWithoutPlans)` pairs of non-negative integers (at least one > 0). Render `AtlasCoverageDonut`, verify center text equals sum and legend percentages are mathematically correct. | +| Property 3 | Plan type donut data correctness | Generate random `plansByType` objects with 1–5 plan type keys mapped to positive integers. Render `AtlasPlanTypeDonut`, verify center text equals sum and legend entries match input. | +| Property 4 | Plan status donut data correctness | Generate random `plansByStatus` objects with 1–4 status keys mapped to positive integers. Render `AtlasPlanStatusDonut`, verify center text equals sum and legend entries match input. | +| Property 5 | Plan status color assignment | Generate random strings (mix of known statuses and arbitrary strings). Verify the color function returns the correct color for known statuses and the fallback for unknowns. | + +### Integration Tests + +- Full metrics flow: seed `atlas_action_plans_cache` with varied data → call `GET /api/atlas/metrics` → verify response matches expected aggregation +- Empty cache flow: ensure empty cache returns all-zero metrics +- Corrupt data flow: seed cache with mix of valid and invalid `plans_json` → verify metrics are correct for valid rows diff --git a/.kiro/specs/atlas-metrics-report/requirements.md b/.kiro/specs/atlas-metrics-report/requirements.md new file mode 100644 index 0000000..1c9a0c4 --- /dev/null +++ b/.kiro/specs/atlas-metrics-report/requirements.md @@ -0,0 +1,124 @@ + # Requirements Document + +## Introduction + +Add a tab system to the existing Metric Graphs panel on the ReportingPage so that Atlas-specific coverage metrics live alongside the existing Ivanti donut charts without cluttering the current layout. The existing four donut charts (Open vs Closed, Action Coverage, FP Finding Status, FP Workflow Status) move under an "Ivanti Findings" tab. A new "Atlas Coverage" tab displays donut charts derived from the cached Atlas action plan data — plan coverage, plan type breakdown, and plan status distribution. A new backend endpoint on the existing Atlas router aggregates the cached data into chart-ready metrics, keeping server.js untouched. + +## Glossary + +- **Dashboard**: The STEAM Security Dashboard frontend React application +- **ReportingPage**: The existing frontend page (`frontend/src/components/pages/ReportingPage.js`) displaying Ivanti host findings, metric charts, and the findings table +- **Metric_Graphs_Panel**: The existing panel on the ReportingPage containing four SVG donut charts and the IvantiCountsChart trend line +- **Tab_System**: A horizontal tab bar added to the Metric_Graphs_Panel header that switches between Ivanti and Atlas chart views +- **Atlas_Cache**: The existing `atlas_action_plans_cache` SQLite table storing per-host action plan status, including `plans_json` +- **Atlas_Router**: The existing Express route module (`backend/routes/atlas.js`) mounted at `/api/atlas` +- **Atlas_Metrics_Endpoint**: A new `GET /api/atlas/metrics` endpoint on the Atlas_Router that returns aggregated chart data +- **Coverage_Donut**: A donut chart showing hosts with action plans vs hosts without action plans +- **Plan_Type_Donut**: A donut chart showing the distribution of action plans across the five plan types (decommission, remediation, false_positive, risk_acceptance, scan_exclusion) +- **Plan_Status_Donut**: A donut chart showing the distribution of action plans across their status values (e.g. active, expired, completed) +- **Action_Plan**: A compliance plan in Atlas with a type, commit date, and status — stored in the `plans_json` column of the Atlas_Cache +- **Host_ID**: The shared numeric identifier linking an Ivanti host finding to an Atlas host + +## Requirements + +### Requirement 1: Atlas Metrics Aggregation Endpoint + +**User Story:** As a frontend developer, I want a single endpoint that returns pre-aggregated Atlas metrics, so that the frontend can render donut charts without parsing raw plan JSON on the client. + +#### Acceptance Criteria + +1. THE Atlas_Router SHALL expose a `GET /api/atlas/metrics` endpoint that requires authentication +2. WHEN the metrics endpoint is called, THE Atlas_Router SHALL query all rows from the Atlas_Cache table including the `plans_json` column +3. WHEN rows are retrieved, THE Atlas_Router SHALL compute and return a JSON object containing: `totalHosts` (integer count of all cached hosts), `hostsWithPlans` (integer count of hosts where `has_action_plan` equals 1), `hostsWithoutPlans` (integer count of hosts where `has_action_plan` equals 0), `plansByType` (object mapping each plan type string to its integer count across all hosts), `plansByStatus` (object mapping each plan status string to its integer count across all hosts), and `totalPlans` (integer sum of all plans across all hosts) +4. WHEN the Atlas_Cache table is empty, THE Atlas_Router SHALL return the metrics object with all counts set to zero and `plansByType` and `plansByStatus` as empty objects +5. IF a row's `plans_json` column contains invalid JSON, THEN THE Atlas_Router SHALL skip that row's plan details and continue processing remaining rows +6. THE Atlas_Metrics_Endpoint SHALL NOT modify server.js — the endpoint SHALL be added to the existing Atlas_Router module only + +### Requirement 2: Tab System in Metric Graphs Panel + +**User Story:** As a dashboard user, I want the Metric Graphs panel to have tabs, so that I can switch between Ivanti findings metrics and Atlas coverage metrics without the panel becoming overcrowded. + +#### Acceptance Criteria + +1. THE Dashboard SHALL display a horizontal tab bar in the Metric_Graphs_Panel header area, to the right of the "Metric Graphs" title +2. THE Tab_System SHALL contain exactly two tabs labeled "Ivanti Findings" and "Atlas Coverage" +3. WHEN the ReportingPage loads, THE Tab_System SHALL default to the "Ivanti Findings" tab as the active tab +4. WHEN the "Ivanti Findings" tab is active, THE Metric_Graphs_Panel SHALL display the existing four donut charts (Open vs Closed, Action Coverage, FP Finding Status, FP Workflow Status) in their current layout +5. WHEN the "Atlas Coverage" tab is active, THE Metric_Graphs_Panel SHALL display the Atlas-specific donut charts (Coverage_Donut, Plan_Type_Donut, Plan_Status_Donut) in a horizontal flex row matching the existing chart layout pattern +6. THE Tab_System SHALL visually indicate the active tab using the design system accent color and a bottom border highlight +7. THE Tab_System SHALL use monospace font, uppercase text, and letter spacing consistent with the existing Metric_Graphs_Panel header style +8. WHEN the user switches tabs, THE Metric_Graphs_Panel content SHALL update immediately without a full page reload + +### Requirement 3: Atlas Coverage Donut Chart + +**User Story:** As a dashboard user, I want to see what percentage of cached hosts have Atlas action plans, so that I can gauge overall compliance coverage at a glance. + +#### Acceptance Criteria + +1. WHEN the "Atlas Coverage" tab is active, THE Dashboard SHALL display a Coverage_Donut chart showing hosts with plans vs hosts without plans +2. THE Coverage_Donut SHALL use emerald (`#10B981`) for hosts with plans and amber (`#F59E0B`) for hosts without plans +3. THE Coverage_Donut SHALL display the total host count as center text with a "HOSTS" label below it +4. THE Coverage_Donut SHALL include a legend showing the count and percentage for each segment +5. WHEN the Atlas_Cache contains no data, THE Coverage_Donut SHALL display a "No data — run Atlas Sync" message instead of an empty chart +6. THE Coverage_Donut SHALL follow the same SVG donut dimensions and styling as the existing StatusDonut component (180px size, 72px outer radius, 48px inner radius) + +### Requirement 4: Plan Type Distribution Donut Chart + +**User Story:** As a dashboard user, I want to see how action plans are distributed across plan types, so that I can understand the remediation strategy mix. + +#### Acceptance Criteria + +1. WHEN the "Atlas Coverage" tab is active, THE Dashboard SHALL display a Plan_Type_Donut chart showing the count of plans per type +2. THE Plan_Type_Donut SHALL assign a distinct color to each plan type: decommission (`#EF4444`), remediation (`#0EA5E9`), false_positive (`#A855F7`), risk_acceptance (`#F59E0B`), scan_exclusion (`#64748B`) +3. THE Plan_Type_Donut SHALL display the total plan count as center text with a "PLANS" label below it +4. THE Plan_Type_Donut SHALL include a legend showing the label, count, and percentage for each plan type that has a count greater than zero +5. WHEN no plans exist in the Atlas_Cache, THE Plan_Type_Donut SHALL display a "No plans — run Atlas Sync" message instead of an empty chart +6. THE Plan_Type_Donut SHALL follow the same SVG donut dimensions and styling as the existing donut chart components + +### Requirement 5: Plan Status Distribution Donut Chart + +**User Story:** As a dashboard user, I want to see how action plans are distributed across statuses, so that I can identify how many plans are active vs expired or completed. + +#### Acceptance Criteria + +1. WHEN the "Atlas Coverage" tab is active, THE Dashboard SHALL display a Plan_Status_Donut chart showing the count of plans per status value +2. THE Plan_Status_Donut SHALL assign colors to known statuses: active (`#10B981`), expired (`#EF4444`), completed (`#0EA5E9`), and use a neutral color (`#64748B`) for any unrecognized status values +3. THE Plan_Status_Donut SHALL display the total plan count as center text with a "STATUS" label below it +4. THE Plan_Status_Donut SHALL include a legend showing the label, count, and percentage for each status that has a count greater than zero +5. WHEN no plans exist in the Atlas_Cache, THE Plan_Status_Donut SHALL display a "No plans — run Atlas Sync" message instead of an empty chart +6. THE Plan_Status_Donut SHALL follow the same SVG donut dimensions and styling as the existing donut chart components + +### Requirement 6: Atlas Metrics Data Fetching + +**User Story:** As a frontend developer, I want the Atlas metrics fetched efficiently and kept in sync with the Atlas cache, so that the charts reflect the latest synced data without unnecessary API calls. + +#### Acceptance Criteria + +1. WHEN the ReportingPage mounts, THE Dashboard SHALL fetch Atlas metrics from `GET /api/atlas/metrics` and store the result in component state +2. WHEN an Atlas sync completes successfully (via the existing Atlas sync button), THE Dashboard SHALL re-fetch Atlas metrics from `GET /api/atlas/metrics` to update the charts +3. WHILE the Atlas metrics fetch is in progress, THE Dashboard SHALL display a loading indicator in the Atlas Coverage tab content area +4. IF the Atlas metrics fetch fails, THEN THE Dashboard SHALL display an error message in the Atlas Coverage tab content area with the failure reason +5. THE Dashboard SHALL NOT fetch Atlas metrics on every tab switch — the data SHALL be fetched once on mount and refreshed only after a sync operation + +### Requirement 7: IvantiCountsChart Visibility with Tabs + +**User Story:** As a dashboard user, I want the IvantiCountsChart trend line to remain visible when the Ivanti Findings tab is active, so that the existing reporting experience is preserved. + +#### Acceptance Criteria + +1. WHEN the "Ivanti Findings" tab is active, THE Dashboard SHALL display the IvantiCountsChart trend line below the Metric_Graphs_Panel, matching its current position +2. WHEN the "Atlas Coverage" tab is active, THE Dashboard SHALL hide the IvantiCountsChart trend line since it is not relevant to Atlas metrics +3. THE IvantiCountsChart component SHALL continue to function identically to its current behavior when visible + +### Requirement 8: Tab System Styling and Accessibility + +**User Story:** As a dashboard user, I want the tab system to be visually consistent with the existing design system and keyboard accessible, so that the interface feels cohesive and usable. + +#### Acceptance Criteria + +1. THE Tab_System tabs SHALL use inline styles consistent with the design system: dark background, monospace font at 0.7rem, uppercase text, 0.08em letter spacing +2. THE active tab SHALL have a bottom border of 2px solid using the panel accent color (`#F59E0B`) and brighter text color (`#F59E0B`) +3. THE inactive tab SHALL have muted text color (`#64748B`) and no bottom border highlight +4. WHEN the user hovers over an inactive tab, THE tab SHALL display a subtle background color change to indicate interactivity +5. THE Tab_System tabs SHALL use `role="tab"`, `aria-selected`, and `role="tabpanel"` attributes for screen reader accessibility +6. THE Tab_System tabs SHALL be keyboard navigable using Tab and Enter keys diff --git a/.kiro/specs/atlas-metrics-report/tasks.md b/.kiro/specs/atlas-metrics-report/tasks.md new file mode 100644 index 0000000..7f7f4ef --- /dev/null +++ b/.kiro/specs/atlas-metrics-report/tasks.md @@ -0,0 +1,163 @@ +# Implementation Plan: Atlas Metrics Report + +## Overview + +Add a tab system to the Metric Graphs panel on the ReportingPage, with an "Ivanti Findings" tab (existing donuts) and an "Atlas Coverage" tab (three new donut charts). A new `GET /api/atlas/metrics` endpoint aggregates cached Atlas action plan data into chart-ready metrics. All backend changes stay within `backend/routes/atlas.js`. Frontend changes are in `frontend/src/components/pages/ReportingPage.js`. + +## Tasks + +- [x] 1. Implement the Atlas metrics aggregation endpoint + - [x] 1.1 Add `GET /metrics` route inside the existing `createAtlasRouter` factory function in `backend/routes/atlas.js` + - Query all rows from `atlas_action_plans_cache` using the existing `dbAll` helper: `SELECT has_action_plan, plans_json FROM atlas_action_plans_cache` + - Extract the aggregation logic into a pure function `aggregateAtlasMetrics(rows)` that takes an array of `{ has_action_plan, plans_json }` objects and returns `{ totalHosts, hostsWithPlans, hostsWithoutPlans, plansByType, plansByStatus, totalPlans }` + - For each row: count hosts with/without plans based on `has_action_plan`; parse `plans_json` and count plans by `plan_type` and `status`; skip plan details for rows with invalid JSON + - Return 503 if Atlas is not configured; return 500 on DB errors; require authentication via `requireAuth(db)` + - Return all-zero metrics with empty objects when the cache table is empty + - Do NOT modify `server.js` — the route is added inside the existing router factory + - _Requirements: 1.1, 1.2, 1.3, 1.4, 1.5, 1.6_ + + - [x] 1.2 Write property test for metrics aggregation (Property 1) + - **Property 1: Metrics aggregation correctness** + - Extract `aggregateAtlasMetrics` as a pure exported function for testability + - Use fast-check to generate arrays of objects with `has_action_plan` (0 or 1) and `plans_json` (valid JSON arrays of `{ plan_type, status }` objects or invalid strings) + - Verify: `totalHosts === rows.length`, `hostsWithPlans + hostsWithoutPlans === totalHosts`, `hostsWithPlans` equals count of rows where `has_action_plan === 1`, `totalPlans` equals sum of valid plan array lengths, `plansByType` and `plansByStatus` counts match individual plan fields, rows with invalid JSON are counted in host totals but excluded from plan counts + - **Validates: Requirements 1.3, 1.4, 1.5** + + - [ ]* 1.3 Write unit tests for the metrics endpoint + - Test empty cache returns all-zero metrics + - Test correct host counting with seeded data + - Test correct plansByType and plansByStatus aggregation + - Test rows with invalid `plans_json` are handled gracefully + - Test 503 response when Atlas is not configured + - Test 401 response for unauthenticated requests + - _Requirements: 1.1, 1.2, 1.3, 1.4, 1.5_ + +- [x] 2. Checkpoint — Verify backend endpoint + - Ensure all tests pass, ask the user if questions arise. + +- [x] 3. Add tab system to the Metric Graphs panel + - [x] 3.1 Add tab state and tab bar UI to the Metric Graphs panel header in `ReportingPage.js` + - Add `metricsTab` state initialized to `'ivanti'` + - Render a horizontal tab bar to the right of the "Metric Graphs" title with two tabs: "Ivanti Findings" and "Atlas Coverage" + - Active tab styling: `color: #F59E0B`, `borderBottom: 2px solid #F59E0B` + - Inactive tab styling: `color: #64748B`, no bottom border + - Hover on inactive: `background: rgba(245, 158, 11, 0.06)` + - Font: `'JetBrains Mono', monospace`, `0.7rem`, `uppercase`, `letterSpacing: 0.08em` + - Add `role="tab"`, `aria-selected` attributes on tabs; `role="tabpanel"` on content area + - Tabs navigable via Tab and Enter keys + - _Requirements: 2.1, 2.2, 2.3, 2.6, 2.7, 2.8, 8.1, 8.2, 8.3, 8.4, 8.5, 8.6_ + + - [x] 3.2 Conditionally render Ivanti donuts vs Atlas content based on active tab + - When `metricsTab === 'ivanti'`: show existing four donut charts in their current layout (unchanged) + - When `metricsTab === 'atlas'`: show placeholder for Atlas donut charts (to be implemented in task 5) + - _Requirements: 2.4, 2.5_ + + - [x] 3.3 Conditionally render IvantiCountsChart based on active tab + - Show `IvantiCountsChart` only when `metricsTab === 'ivanti'` + - Hide it when `metricsTab === 'atlas'` + - _Requirements: 7.1, 7.2, 7.3_ + +- [x] 4. Add Atlas metrics data fetching + - [x] 4.1 Add Atlas metrics state and fetch function to `ReportingPage.js` + - Add state: `atlasMetrics` (null), `atlasMetricsLoading` (false), `atlasMetricsError` (null) + - Add `fetchAtlasMetrics` callback that calls `GET /api/atlas/metrics` with `credentials: 'include'` + - On success: store data in `atlasMetrics`; on error: store message in `atlasMetricsError` + - Set loading state during fetch + - _Requirements: 6.1, 6.3, 6.4_ + + - [x] 4.2 Call `fetchAtlasMetrics` on mount and after successful Atlas sync + - Add `fetchAtlasMetrics()` call in the existing mount `useEffect` + - After a successful Atlas sync (existing sync handler), call `fetchAtlasMetrics()` to refresh + - Do NOT re-fetch on tab switch + - _Requirements: 6.1, 6.2, 6.5_ + +- [x] 5. Implement Atlas donut chart components + - [x] 5.1 Implement `AtlasCoverageDonut` component in `ReportingPage.js` + - Props: `{ hostsWithPlans, hostsWithoutPlans, totalHosts }` + - Segments: emerald (`#10B981`) for with plans, amber (`#F59E0B`) for without plans + - Center text: `totalHosts` count, "HOSTS" label + - Legend: count and percentage for each segment + - Empty state: "No data — run Atlas Sync" when `totalHosts === 0` + - Reuse existing `polarToCartesian` and `donutArcPath` helpers; same dimensions (180px, 72px outer, 48px inner) + - Follow the same SVG and styling pattern as `StatusDonut` + - _Requirements: 3.1, 3.2, 3.3, 3.4, 3.5, 3.6_ + + - [x] 5.2 Implement `AtlasPlanTypeDonut` component in `ReportingPage.js` + - Props: `{ plansByType, totalPlans }` + - Color map: `decommission: #EF4444`, `remediation: #0EA5E9`, `false_positive: #A855F7`, `risk_acceptance: #F59E0B`, `scan_exclusion: #64748B` + - Center text: `totalPlans` count, "PLANS" label + - Legend: only show types with count > 0, with label, count, and percentage + - Empty state: "No plans — run Atlas Sync" when `totalPlans === 0` + - Same SVG dimensions and styling pattern + - _Requirements: 4.1, 4.2, 4.3, 4.4, 4.5, 4.6_ + + - [x] 5.3 Implement `AtlasPlanStatusDonut` component in `ReportingPage.js` + - Props: `{ plansByStatus, totalPlans }` + - Color map: `active: #10B981`, `expired: #EF4444`, `completed: #0EA5E9`, fallback: `#64748B` + - Extract a `getStatusColor(status)` helper function for color assignment + - Center text: `totalPlans` count, "STATUS" label + - Legend: only show statuses with count > 0, with label, count, and percentage + - Empty state: "No plans — run Atlas Sync" when `totalPlans === 0` + - Same SVG dimensions and styling pattern + - _Requirements: 5.1, 5.2, 5.3, 5.4, 5.5, 5.6_ + + - [x] 5.4 Write property test for Coverage donut data correctness (Property 2) + - **Property 2: Coverage donut data correctness** + - Use fast-check to generate random `(hostsWithPlans, hostsWithoutPlans)` pairs of non-negative integers where at least one > 0 + - Render `AtlasCoverageDonut`, verify center text equals `totalHosts`, legend percentages equal `(count / totalHosts) * 100` + - **Validates: Requirements 3.3, 3.4** + + - [x] 5.5 Write property test for Plan type donut data correctness (Property 3) + - **Property 3: Plan type donut data correctness** + - Use fast-check to generate random `plansByType` objects with 1–5 plan type keys mapped to positive integers + - Render `AtlasPlanTypeDonut`, verify center text equals sum of counts, legend entries match input, percentages are correct + - **Validates: Requirements 4.3, 4.4** + + - [x] 5.6 Write property test for Plan status donut data correctness (Property 4) + - **Property 4: Plan status donut data correctness** + - Use fast-check to generate random `plansByStatus` objects with 1–4 status keys mapped to positive integers + - Render `AtlasPlanStatusDonut`, verify center text equals sum of counts, legend entries match input, percentages are correct + - **Validates: Requirements 5.3, 5.4** + + - [x] 5.7 Write property test for Plan status color assignment (Property 5) + - **Property 5: Plan status color assignment** + - Use fast-check to generate random strings (mix of known statuses and arbitrary strings) + - Verify `getStatusColor` returns `#10B981` for "active", `#EF4444` for "expired", `#0EA5E9` for "completed", `#64748B` for any other string + - **Validates: Requirements 5.2** + + - [ ]* 5.8 Write unit tests for Atlas donut components + - Test Coverage donut empty state message when totalHosts is 0 + - Test Plan type donut empty state message when totalPlans is 0 + - Test Plan status donut empty state message when totalPlans is 0 + - Test SVG dimensions are 180px with correct outer/inner radius + - Test color assignments for each plan type and status + - _Requirements: 3.5, 4.5, 5.5, 3.6, 4.6, 5.6_ + +- [x] 6. Wire Atlas donuts into the Atlas Coverage tab + - [x] 6.1 Render Atlas donut charts in the Atlas Coverage tab content area + - When `metricsTab === 'atlas'`: render `AtlasCoverageDonut`, `AtlasPlanTypeDonut`, `AtlasPlanStatusDonut` in a horizontal flex row with dividers (same layout pattern as Ivanti donuts) + - Pass data from `atlasMetrics` state to each donut component + - Show loading indicator while `atlasMetricsLoading` is true + - Show error message when `atlasMetricsError` is set + - Add chart labels above each donut: "Host Coverage", "Plan Types", "Plan Status" — matching existing label style + - _Requirements: 2.5, 3.1, 4.1, 5.1, 6.3, 6.4_ + + - [ ]* 6.2 Write integration tests for the full metrics flow + - Test: fetch metrics on mount populates Atlas donut charts + - Test: tab switch does not trigger re-fetch + - Test: loading state shown during fetch + - Test: error state shown on fetch failure + - _Requirements: 6.1, 6.2, 6.3, 6.4, 6.5_ + +- [x] 7. Final checkpoint — Ensure all tests pass + - 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 +- All backend changes are confined to `backend/routes/atlas.js` — server.js is NOT modified +- The `aggregateAtlasMetrics` function is extracted as a pure function for testability and property-based testing +- Property tests use fast-check with `{ numRuns: 100 }` minimum +- Checkpoints ensure incremental validation after backend and full integration +- The existing donut chart pattern (StatusDonut, ActionCoverageDonut, FPWorkflowDonut) serves as the template for all three Atlas donut components diff --git a/.kiro/specs/batch-finding-disposition/.config.kiro b/.kiro/specs/batch-finding-disposition/.config.kiro new file mode 100644 index 0000000..6291b54 --- /dev/null +++ b/.kiro/specs/batch-finding-disposition/.config.kiro @@ -0,0 +1 @@ +{"specId": "9f5c16d4-43ea-4d7a-beb1-9329d79a5acc", "workflowType": "requirements-first", "specType": "feature"} \ No newline at end of file diff --git a/.kiro/specs/batch-finding-disposition/design.md b/.kiro/specs/batch-finding-disposition/design.md new file mode 100644 index 0000000..b3201e1 --- /dev/null +++ b/.kiro/specs/batch-finding-disposition/design.md @@ -0,0 +1,331 @@ +# Design Document: Batch Finding Disposition + +## Overview + +This feature adds multi-select capability to the Vulnerability Triage page's findings table, enabling engineers to select multiple findings and add them all to the Ivanti Queue in a single operation. The current flow requires clicking each finding individually, configuring a popover, and submitting one at a time — this design replaces that with a batch selection toolbar and a bulk-add API endpoint while preserving the existing single-select popover for one-off additions. + +The design touches three layers: +1. A new `POST /api/ivanti/todo-queue/batch` backend endpoint that accepts an array of findings in a single transactional insert +2. Frontend multi-select state management (selection set, shift-click range select, select-all) +3. A sticky Selection Toolbar component with workflow type toggles, vendor input, and batch submit + +## Architecture + +The feature extends the existing Ivanti Queue subsystem without introducing new services or tables. The `ivanti_todo_queue` table schema is unchanged — batch add simply inserts multiple rows in a single SQLite transaction. + +```mermaid +flowchart TD + subgraph Frontend ["Frontend (ReportingPage.js)"] + CB[Row Checkboxes] --> SS[Selection State
Set of finding IDs] + SS --> ST[Selection Toolbar] + ST -->|"Add to Queue"| BA[Batch API Call] + CB -->|"No selection + click"| PO[AddToQueuePopover
existing single-add] + end + + subgraph Backend ["Backend (ivantiTodoQueue.js)"] + BA -->|"POST /batch"| BH[Batch Handler] + BH -->|"BEGIN TRANSACTION"| DB[(ivanti_todo_queue)] + BH -->|"logAudit()"| AL[(audit_logs)] + PO -->|"POST /"| SH[Single Handler
existing] + SH --> DB + end +``` + +### Key Design Decisions + +1. **No new database table or migration** — batch insert reuses the existing `ivanti_todo_queue` schema. Each finding becomes its own row, identical to what the single-add endpoint creates. + +2. **SQLite transaction for atomicity** — all findings in a batch are inserted inside `db.serialize()` with `BEGIN TRANSACTION` / `COMMIT`. If any insert fails, the entire batch is rolled back. This satisfies the all-or-nothing requirement (Req 3.7, 3.8, 3.11). + +3. **Selection state lives in the VulnerabilityTriagePage component** — a `Set` of finding IDs managed via `useState`. This keeps the selection co-located with the existing `findings`, `sorted`, `filtered`, and `queueItems` state. No new context or global store needed. + +4. **Dual-mode checkbox behavior** — when no findings are selected, clicking a checkbox opens the existing `AddToQueuePopover` (preserving the single-select flow per Req 5). Once one or more findings are selected, subsequent checkbox clicks toggle selection instead. This is the simplest UX that satisfies both Req 1 and Req 5. + +5. **Selection Toolbar as inline sticky bar** — rendered between the table header controls and the `` element, using `position: sticky` to stay visible during scroll. This avoids portal complexity and keeps the toolbar visually anchored to the table. + +6. **200-item batch limit** — prevents oversized payloads and keeps SQLite transaction time reasonable. The findings table typically has 200-800 rows, so this covers most realistic batch sizes. + +## Components and Interfaces + +### Backend + +#### `POST /api/ivanti/todo-queue/batch` + +Added to the existing `createIvantiTodoQueueRouter` factory in `backend/routes/ivantiTodoQueue.js`. + +**Request body:** +```json +{ + "findings": [ + { + "finding_id": "FID-12345", + "finding_title": "OpenSSL vulnerability", + "cves": ["CVE-2024-0001"], + "ip_address": "10.0.1.50" + } + ], + "workflow_type": "FP", + "vendor": "Juniper" +} +``` + +**Validation rules:** +- `findings` — array, 1–200 items +- Each item: `finding_id` required, non-empty string; `finding_title`, `cves`, `ip_address` optional +- `workflow_type` — must be `FP`, `Archer`, or `CARD` +- `vendor` — required non-empty string (≤200 chars) for FP/Archer; ignored for CARD +- If any finding fails validation, reject entire batch with 400 + +**Auth:** `requireAuth(db)`, `requireGroup('Admin', 'Standard_User')` + +**Response (201):** +```json +{ + "items": [ + { + "id": 42, + "user_id": 1, + "finding_id": "FID-12345", + "finding_title": "OpenSSL vulnerability", + "cves_json": "[\"CVE-2024-0001\"]", + "ip_address": "10.0.1.50", + "vendor": "Juniper", + "workflow_type": "FP", + "status": "pending", + "created_at": "2025-01-15 12:00:00", + "updated_at": "2025-01-15 12:00:00", + "cves": ["CVE-2024-0001"] + } + ] +} +``` + +**Error responses:** +- `400` — validation failure (descriptive message) +- `401` — not authenticated +- `403` — insufficient permissions +- `500` — database transaction failure (all inserts rolled back) + +### Frontend + +#### Selection State (in VulnerabilityTriagePage) + +New state variables added to the main component: + +```javascript +const [selectedIds, setSelectedIds] = useState(new Set()); // Set of finding IDs +const [lastClickedId, setLastClickedId] = useState(null); // for shift-click range select +const [batchSubmitting, setBatchSubmitting] = useState(false); // loading state +const [batchError, setBatchError] = useState(null); // error message from failed batch +const [batchWorkflowType, setBatchWorkflowType] = useState('FP'); +const [batchVendor, setBatchVendor] = useState(''); +``` + +#### Checkbox Click Logic + +``` +onClick(finding, event): + if finding is already queued → return (no-op) + if selectedIds.size === 0 AND not shift-click: + → open AddToQueuePopover (existing single-select flow) + else: + if shift-click AND lastClickedId exists: + → range-select all visible findings between lastClickedId and finding.id + else: + → toggle finding.id in selectedIds + set lastClickedId = finding.id +``` + +#### SelectionToolbar Component + +Rendered inline above the table when `selectedIds.size > 0`. Contains: +- Selected count badge +- "Clear Selection" button +- Workflow type toggle buttons (FP / Archer / CARD) with existing color scheme +- Vendor text input (hidden when CARD selected) +- "Add to Queue" submit button (disabled until valid) +- Error message display area + +#### Selection Persistence Across Filters + +When `columnFilters`, `actionFilter`, or `excFilter` change, the selection set is pruned to only include IDs that remain in the `filtered` array. This is done via a `useEffect` that intersects `selectedIds` with the current filtered finding IDs. + +#### Select All / Deselect All + +The checkbox column header renders a "Select All" control when `selectedIds.size > 0` or as a standard header otherwise. Clicking it: +- If not all visible non-queued findings are selected → selects all visible non-queued findings +- If all are already selected → deselects all + +## Data Models + +### Database Schema (unchanged) + +The `ivanti_todo_queue` table is reused as-is: + +```sql +CREATE TABLE ivanti_todo_queue ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + finding_id TEXT NOT NULL, + finding_title TEXT, + cves_json TEXT, + ip_address TEXT, + vendor TEXT NOT NULL, + workflow_type TEXT NOT NULL CHECK(workflow_type IN ('FP', 'Archer', 'CARD')), + status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending', 'complete')), + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +); +``` + +Each batch-added finding creates one row, identical to single-add. The `vendor` and `workflow_type` are shared across all findings in a batch (set once in the toolbar). + +### API Request Schema + +``` +BatchAddRequest { + findings: Array<{ + finding_id: string (required, non-empty, trimmed) + finding_title: string | null (max 500 chars) + cves: string[] | null + ip_address: string | null (max 64 chars) + }> (1–200 items) + workflow_type: "FP" | "Archer" | "CARD" + vendor: string (required for FP/Archer, ≤200 chars; empty/absent for CARD) +} +``` + +### Frontend State Shape + +``` +Selection State: + selectedIds: Set — finding IDs currently selected + lastClickedId: string | null — last checkbox clicked (for shift-range) + batchSubmitting: boolean — true while POST /batch in flight + batchError: string | null — error message from last failed batch + batchWorkflowType: "FP" | "Archer" | "CARD" + batchVendor: string +``` + + +## 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: Selection pruning preserves only visible findings + +*For any* set of selected finding IDs and any set of currently visible (filtered) finding IDs, pruning the selection after a filter change should produce exactly the intersection of the two sets — every ID in the result is both selected and visible, and no visible selected ID is lost. + +**Validates: Requirements 1.4** + +### Property 2: Select-all produces the complete visible non-queued set + +*For any* list of visible findings and any set of queued finding IDs, the select-all operation should produce a set containing exactly the IDs of visible findings that are not in the queued set — no queued findings included, no non-queued visible findings omitted. + +**Validates: Requirements 1.6** + +### Property 3: Submit button enabled state matches validation rule + +*For any* workflow type (FP, Archer, CARD) and any vendor string, the "Add to Queue" button should be enabled if and only if the workflow type is CARD, or the vendor string trimmed is non-empty. No other combination should enable the button. + +**Validates: Requirements 2.7** + +### Property 4: Batch size validation accepts only 1–200 items + +*For any* integer N representing the number of findings in a batch request, the endpoint should accept the request (assuming all other fields are valid) if and only if 1 ≤ N ≤ 200. Arrays of size 0 or greater than 200 should be rejected with a 400 response. + +**Validates: Requirements 3.2** + +### Property 5: Vendor validation is conditional on workflow type + +*For any* workflow type and any vendor string, the batch endpoint should require a non-empty vendor of 200 characters or fewer when workflow_type is FP or Archer, and should accept any vendor value (including empty or absent) when workflow_type is CARD. + +**Validates: Requirements 3.5, 3.6** + +### Property 6: One invalid finding rejects the entire batch + +*For any* valid batch of findings, if exactly one finding is replaced with an invalid finding (empty finding_id, missing finding_id, or non-string finding_id) at any position in the array, the entire batch should be rejected with a 400 response and zero rows should be inserted. + +**Validates: Requirements 3.3, 3.8** + +### Property 7: Successful batch response matches request + +*For any* valid batch request of N findings, the 201 response should contain exactly N items, each with a unique numeric `id`, and the set of `finding_id` values in the response should equal the set of `finding_id` values in the request. + +**Validates: Requirements 3.9** + +### Property 8: Shift-click range select covers exactly the between range + +*For any* sorted list of visible findings, any last-clicked index, and any current-click index, the shift-click range select should produce a set containing exactly the non-queued findings between those two indices (inclusive), regardless of which index is larger. + +**Validates: Requirements 6.1** + +## Error Handling + +### Backend Errors + +| Scenario | Response | Behavior | +|----------|----------|----------| +| Empty findings array or > 200 items | 400 | `{ error: "findings array must contain 1-200 items." }` | +| Any finding missing/empty finding_id | 400 | `{ error: "Each finding must have a non-empty finding_id string." }` | +| Invalid workflow_type | 400 | `{ error: "workflow_type must be FP, Archer, or CARD." }` | +| Missing vendor for FP/Archer | 400 | `{ error: "vendor is required for FP and Archer workflows." }` | +| Vendor exceeds 200 chars | 400 | `{ error: "vendor must be under 200 chars." }` | +| Not authenticated | 401 | Standard auth middleware response | +| Insufficient permissions (Read_Only) | 403 | Standard group middleware response | +| SQLite transaction failure | 500 | Transaction rolled back, `{ error: "Internal server error." }` | + +### Frontend Errors + +| Scenario | Behavior | +|----------|----------| +| Batch POST returns 4xx/5xx | Display error message in Selection Toolbar, keep selection intact | +| Network failure during batch POST | Display "Network error — please try again" in toolbar, keep selection | +| Batch POST timeout | Same as network failure handling | + +### Edge Cases + +- **Duplicate finding_ids in batch**: Allowed — the same finding could appear on multiple hosts. The backend does not enforce uniqueness on finding_id within a batch. +- **Finding already in queue**: The frontend prevents selecting already-queued findings (checkbox is disabled), so duplicates should not reach the API. No server-side duplicate check is added to keep the endpoint simple. +- **Concurrent batch submissions**: The SQLite transaction serializes writes. If two users submit overlapping batches, both succeed independently (each user has their own queue scoped by user_id). +- **Selection of 0 findings**: The "Add to Queue" button is only rendered when selectedIds.size > 0, so this state cannot be reached through the UI. The backend still validates for it. + +## Testing Strategy + +### Unit Tests + +Focus on specific examples and edge cases: + +- **Backend validation**: Test each validation rule with concrete valid/invalid inputs (empty array, 201 items, missing finding_id, invalid workflow_type, vendor edge cases) +- **Transaction rollback**: Mock a database error mid-insert, verify no rows are committed +- **Frontend checkbox dual-mode**: Test that clicking with empty selection opens popover, clicking with existing selection toggles selection +- **Toolbar visibility**: Test toolbar appears/disappears based on selection state +- **Clear selection**: Test that clear button empties selection +- **Escape key**: Test that Escape clears selection +- **Select-all toggle**: Test select-all and deselect-all behavior +- **Queue panel update**: Test that successful batch updates queueItems state + +### Property-Based Tests + +Using [fast-check](https://github.com/dubzzz/fast-check) for JavaScript property-based testing. + +Each property test runs a minimum of 100 iterations with randomly generated inputs. Tests are tagged with their corresponding design property. + +| Property | What's Generated | What's Verified | +|----------|-----------------|-----------------| +| Property 1: Selection pruning | Random sets of selected IDs and filtered IDs | Result = intersection of both sets | +| Property 2: Select-all | Random finding lists and queued ID sets | Result = visible IDs minus queued IDs | +| Property 3: Submit enabled | Random workflow types and vendor strings | Enabled iff CARD or non-empty vendor | +| Property 4: Batch size | Random integers 0–300 | Accepted iff 1 ≤ N ≤ 200 | +| Property 5: Vendor validation | Random workflow types and vendor strings (0–300 chars) | Conditional acceptance rule | +| Property 6: Invalid finding rejection | Valid batches with one injected invalid item | Entire batch rejected, 0 rows inserted | +| Property 7: Response shape | Valid batches of 1–50 findings | Response count matches, IDs match | +| Property 8: Range select | Random sorted lists and two index positions | Correct range of non-queued findings | + +### Integration Tests + +- End-to-end batch submission: POST valid batch, verify rows in database, verify response shape +- Auth enforcement: Verify 401 for unauthenticated, 403 for Read_Only users +- Transaction atomicity: Verify rollback on database error +- Frontend → Backend: Mock API, verify correct request payload from toolbar submit diff --git a/.kiro/specs/batch-finding-disposition/requirements.md b/.kiro/specs/batch-finding-disposition/requirements.md new file mode 100644 index 0000000..e045b35 --- /dev/null +++ b/.kiro/specs/batch-finding-disposition/requirements.md @@ -0,0 +1,97 @@ +# Requirements Document + +## Introduction + +The Batch Finding Disposition feature adds multi-select capability to the Vulnerability Triage page's findings table, allowing engineers to select multiple findings at once and add them all to the Ivanti Queue with a shared workflow type and vendor in a single operation. Currently, each finding must be individually clicked, configured via a popover, and submitted — a repetitive process that slows down triage when working through many findings. This feature replaces that one-at-a-time flow with a batch selection toolbar and a bulk-add API endpoint. + +## Glossary + +- **Findings_Table**: The sortable, filterable table of Ivanti host findings rendered in the VulnerabilityTriagePage component (`ReportingPage.js`), where each row represents one finding. +- **Selection_Toolbar**: A floating toolbar that appears above the Findings_Table when one or more findings are selected via their row checkboxes, displaying the count of selected findings and batch action controls. +- **Batch_Add_Panel**: The inline panel within the Selection_Toolbar that provides workflow type selection (FP, Archer, CARD), an optional vendor input, and a submit button for adding all selected findings to the queue in one operation. +- **Todo_Queue_API**: The backend Express router at `/api/ivanti/todo-queue` that manages CRUD operations on the `ivanti_todo_queue` table. +- **Queue_Panel**: The existing right-side slide-out panel (`QueuePanel` component) that displays the user's current queue items grouped by vendor. +- **Workflow_Type**: One of three disposition categories: FP (false positive), Archer (risk acceptance), or CARD (remediation card). Each finding added to the queue is assigned exactly one Workflow_Type. +- **Finding**: A single Ivanti host vulnerability record containing an ID, title, CVEs, IP address, severity, and other metadata. + +## Requirements + +### Requirement 1: Multi-Select Findings via Row Checkboxes + +**User Story:** As an engineer, I want to select multiple findings using checkboxes so that I can batch-process them instead of handling each one individually. + +#### Acceptance Criteria + +1. THE Findings_Table SHALL render a checkbox in the first column of each finding row that is not already in the queue. +2. WHEN a user clicks a finding row's checkbox, THE Findings_Table SHALL toggle that Finding's selected state without opening the AddToQueuePopover. +3. WHEN one or more findings are selected, THE Findings_Table SHALL visually distinguish selected rows from unselected rows using a highlighted background. +4. THE Findings_Table SHALL maintain the selected findings set across sort and filter changes, removing only findings that are no longer visible after filtering. +5. WHEN a finding is already in the queue, THE Findings_Table SHALL display that row's checkbox as checked and disabled, preventing re-selection. +6. WHILE findings are selected, THE Findings_Table SHALL display a "Select All (visible)" control in the checkbox column header that selects all visible, non-queued findings. +7. WHEN the "Select All" control is clicked while all visible non-queued findings are already selected, THE Findings_Table SHALL deselect all findings. + +### Requirement 2: Selection Toolbar with Batch Actions + +**User Story:** As an engineer, I want a toolbar that appears when I have findings selected so that I can see how many are selected and take batch actions on them. + +#### Acceptance Criteria + +1. WHEN one or more findings are selected, THE Selection_Toolbar SHALL appear as a sticky bar above the Findings_Table header row. +2. THE Selection_Toolbar SHALL display the count of currently selected findings. +3. THE Selection_Toolbar SHALL provide a "Clear Selection" button that deselects all findings and hides the Selection_Toolbar. +4. THE Selection_Toolbar SHALL provide workflow type toggle buttons for FP, Archer, and CARD, matching the existing color scheme (FP: amber, Archer: blue, CARD: green). +5. WHEN the selected Workflow_Type is FP or Archer, THE Selection_Toolbar SHALL display a vendor text input field. +6. WHEN the selected Workflow_Type is CARD, THE Selection_Toolbar SHALL hide the vendor input field and display a "No vendor required" indicator. +7. THE Selection_Toolbar SHALL provide an "Add to Queue" submit button that is enabled only when a Workflow_Type is selected and vendor is provided (for FP/Archer) or Workflow_Type is CARD. +8. THE Selection_Toolbar SHALL follow the existing dark theme design system (monospace fonts, dark gradient backgrounds, accent-colored borders). + +### Requirement 3: Bulk Add to Queue API Endpoint + +**User Story:** As an engineer, I want the backend to accept multiple findings in a single request so that batch additions are processed efficiently. + +#### Acceptance Criteria + +1. THE Todo_Queue_API SHALL expose a `POST /api/ivanti/todo-queue/batch` endpoint that accepts an array of finding objects with a shared workflow_type and vendor. +2. THE Todo_Queue_API SHALL validate that the findings array contains between 1 and 200 items. +3. THE Todo_Queue_API SHALL validate that each finding object contains a non-empty finding_id string. +4. THE Todo_Queue_API SHALL validate that workflow_type is one of FP, Archer, or CARD. +5. WHEN workflow_type is FP or Archer, THE Todo_Queue_API SHALL validate that vendor is a non-empty string of 200 characters or fewer. +6. WHEN workflow_type is CARD, THE Todo_Queue_API SHALL accept an empty or absent vendor field. +7. THE Todo_Queue_API SHALL insert all valid findings into the `ivanti_todo_queue` table within a single database transaction. +8. IF any finding in the batch fails validation, THEN THE Todo_Queue_API SHALL reject the entire batch and return a 400 response with a descriptive error message. +9. THE Todo_Queue_API SHALL return a 201 response containing the array of newly created queue items with their assigned IDs. +10. THE Todo_Queue_API SHALL require authentication and the Admin or Standard_User group. +11. IF a database error occurs during the transaction, THEN THE Todo_Queue_API SHALL roll back all inserts and return a 500 response. + +### Requirement 4: Frontend Batch Submission Flow + +**User Story:** As an engineer, I want clicking "Add to Queue" on the toolbar to submit all selected findings at once so that I save time during triage. + +#### Acceptance Criteria + +1. WHEN the user clicks "Add to Queue" on the Selection_Toolbar, THE Findings_Table SHALL send a single POST request to `POST /api/ivanti/todo-queue/batch` containing all selected findings with the chosen workflow_type and vendor. +2. WHILE the batch request is in progress, THE Selection_Toolbar SHALL disable the "Add to Queue" button and display a loading indicator. +3. WHEN the batch request succeeds, THE Findings_Table SHALL add all returned queue items to the local queue state, clear the selection, and hide the Selection_Toolbar. +4. WHEN the batch request succeeds, THE Findings_Table SHALL update each newly queued finding's row checkbox to show the checked-and-disabled (already queued) state. +5. IF the batch request fails, THEN THE Selection_Toolbar SHALL display the error message returned by the API and keep the current selection intact. +6. WHEN the batch request succeeds and the Queue_Panel is open, THE Queue_Panel SHALL reflect the newly added items immediately without requiring a manual refresh. + +### Requirement 5: Preserve Single-Select Popover Flow + +**User Story:** As an engineer, I want to still be able to add a single finding to the queue quickly without going through the batch flow, so that simple one-off additions remain fast. + +#### Acceptance Criteria + +1. WHEN no findings are currently selected and a user clicks a finding row's checkbox, THE Findings_Table SHALL open the existing AddToQueuePopover for that single finding. +2. WHEN one or more findings are already selected and a user clicks another finding row's checkbox, THE Findings_Table SHALL add that finding to the selection set instead of opening the AddToQueuePopover. +3. THE AddToQueuePopover SHALL continue to use the existing single-item `POST /api/ivanti/todo-queue` endpoint for individual additions. + +### Requirement 6: Keyboard Accessibility for Multi-Select + +**User Story:** As an engineer, I want to use keyboard shortcuts to speed up multi-select so that I can triage even faster. + +#### Acceptance Criteria + +1. WHEN a user holds Shift and clicks a finding row's checkbox, THE Findings_Table SHALL select all visible findings between the last clicked checkbox and the current checkbox (range select). +2. THE Selection_Toolbar SHALL be navigable via keyboard Tab order, with all interactive elements (workflow buttons, vendor input, submit button) reachable by Tab key. +3. WHEN the Escape key is pressed while the Selection_Toolbar is visible, THE Findings_Table SHALL clear the selection and hide the Selection_Toolbar. diff --git a/.kiro/specs/batch-finding-disposition/tasks.md b/.kiro/specs/batch-finding-disposition/tasks.md new file mode 100644 index 0000000..60b6989 --- /dev/null +++ b/.kiro/specs/batch-finding-disposition/tasks.md @@ -0,0 +1,116 @@ +# Implementation Plan: Batch Finding Disposition + +## Overview + +Add multi-select capability to the Vulnerability Triage findings table with a batch-add-to-queue API endpoint. The backend gets a new `POST /api/ivanti/todo-queue/batch` route in `ivantiTodoQueue.js`. The frontend gets selection state, checkbox dual-mode logic, a SelectionToolbar component, shift-click range select, select-all, and Escape-to-clear — all within `ReportingPage.js`. + +## Tasks + +- [x] 1. Add `POST /api/ivanti/todo-queue/batch` endpoint + - [x] 1.1 Add batch route handler to `backend/routes/ivantiTodoQueue.js` + - Add `POST /batch` route inside `createIvantiTodoQueueRouter`, before the `POST /` route + - Apply `requireAuth(db)` and `requireGroup('Admin', 'Standard_User')` middleware + - Validate request body: `findings` array (1–200 items), each with non-empty `finding_id` string + - Validate `workflow_type` is one of `FP`, `Archer`, `CARD` + - Validate `vendor`: required non-empty string ≤200 chars for FP/Archer; ignored for CARD + - If any validation fails, return 400 with descriptive error message and reject entire batch + - _Requirements: 3.1, 3.2, 3.3, 3.4, 3.5, 3.6, 3.8, 3.10_ + - [x] 1.2 Implement transactional batch insert with SQLite + - Use `db.serialize()` with `BEGIN TRANSACTION` / `COMMIT` to insert all findings atomically + - For each finding: insert row into `ivanti_todo_queue` with `user_id`, `finding_id`, `finding_title`, `cves_json`, `ip_address`, `vendor`, `workflow_type` + - On success: fetch all inserted rows, parse `cves_json` back to arrays, return 201 with `{ items: [...] }` + - On any DB error: `ROLLBACK` the transaction and return 500 + - _Requirements: 3.7, 3.8, 3.9, 3.11_ + - [x] 1.3 Add audit logging for batch additions + - After successful commit, call `logAudit(db, { ... })` with action `'batch_add_to_queue'`, entityType `'ivanti_todo_queue'`, and details including the count and workflow_type + - Import `logAudit` from `../helpers/auditLog` + - _Requirements: 3.7_ + +- [x] 2. Checkpoint — Verify backend endpoint + - Ensure the batch endpoint is syntactically correct and the route file has no errors. Ask the user if questions arise. + +- [x] 3. Add multi-select state and checkbox dual-mode logic to `ReportingPage.js` + - [x] 3.1 Add selection state variables to `VulnerabilityTriagePage` + - Add `selectedIds` (`new Set()`), `lastClickedId` (null), `batchSubmitting` (false), `batchError` (null), `batchWorkflowType` ('FP'), `batchVendor` ('') as new `useState` hooks + - _Requirements: 1.1, 2.1_ + - [x] 3.2 Implement checkbox dual-mode click handler + - Replace the existing `
` onClick in the checkbox cell with new logic: + - If finding is already queued → no-op (existing behavior) + - If `selectedIds.size === 0` AND not shift-click → open `AddToQueuePopover` (preserves single-select flow) + - If shift-click AND `lastClickedId` exists → range-select all visible non-queued findings between `lastClickedId` and current finding in the `sorted` array + - Otherwise → toggle finding.id in `selectedIds` + - Always update `lastClickedId` when toggling selection + - _Requirements: 1.1, 1.2, 5.1, 5.2, 6.1_ + - [x] 3.3 Add visual highlighting for selected rows + - When a finding's ID is in `selectedIds`, apply a highlighted background (e.g. `rgba(14,165,233,0.12)`) to the row + - Override the existing alternating row background and hover for selected rows + - _Requirements: 1.3_ + - [x] 3.4 Disable checkbox for already-queued findings + - Keep existing behavior: queued findings show checked + disabled checkbox, preventing re-selection + - Ensure queued findings are excluded from shift-click range select and select-all + - _Requirements: 1.5_ + +- [x] 4. Implement Select All / Deselect All in column header + - Modify the checkbox column `` to render a clickable "Select All" checkbox when `selectedIds.size > 0` or when the user interacts with it + - Click behavior: if not all visible non-queued findings are selected → select all visible non-queued; if all are selected → deselect all + - _Requirements: 1.6, 1.7_ + +- [x] 5. Add selection pruning on filter changes + - Add a `useEffect` that watches `filtered` (the filtered findings array) and prunes `selectedIds` to only include IDs still present in the filtered set + - This ensures selection stays consistent when `columnFilters`, `actionFilter`, or `excFilter` change + - _Requirements: 1.4_ + +- [x] 6. Implement SelectionToolbar component + - [x] 6.1 Create the `SelectionToolbar` inline component in `ReportingPage.js` + - Render between the panel header controls and the `` element, only when `selectedIds.size > 0` + - Use `position: sticky` with appropriate `top` value to stay visible during scroll + - Follow the dark theme design system: monospace fonts, dark gradient background, accent-colored borders + - _Requirements: 2.1, 2.8_ + - [x] 6.2 Add toolbar controls: count badge, Clear Selection, workflow toggles, vendor input, submit button + - Display selected count badge (e.g. "12 selected") + - "Clear Selection" button that empties `selectedIds` and hides toolbar + - Workflow type toggle buttons (FP / Archer / CARD) using existing color scheme: FP = amber (`#F59E0B`), Archer = blue (`#0EA5E9`), CARD = green (`#10B981`) + - Vendor text input (hidden when CARD is selected, show "No vendor required" indicator for CARD) + - "Add to Queue" submit button — enabled only when workflow_type is CARD, or vendor is non-empty + - _Requirements: 2.2, 2.3, 2.4, 2.5, 2.6, 2.7_ + +- [x] 7. Implement batch submission flow + - [x] 7.1 Add `submitBatch` async function to `VulnerabilityTriagePage` + - Build request payload from `selectedIds` (map each ID to its finding object from `sorted`/`filtered` for `finding_id`, `finding_title`, `cves`, `ip_address`), plus `batchWorkflowType` and `batchVendor` + - POST to `${API_BASE}/ivanti/todo-queue/batch` with `credentials: 'include'` + - Set `batchSubmitting = true` before request, `false` after + - _Requirements: 4.1, 4.2_ + - [x] 7.2 Handle batch success response + - On 201: merge returned items into `queueItems` state (sorted by vendor then id, matching existing pattern) + - Clear `selectedIds`, reset `batchWorkflowType` to 'FP', reset `batchVendor` to '', clear `batchError` + - The newly queued findings will automatically show as checked+disabled via the existing `isQueued()` helper + - _Requirements: 4.3, 4.4, 4.6_ + - [x] 7.3 Handle batch error response + - On 4xx/5xx: parse error message from response JSON, set `batchError` to display in toolbar + - On network failure: set `batchError` to "Network error — please try again" + - Keep selection intact on error so user can retry + - _Requirements: 4.5_ + +- [x] 8. Add Escape key handler to clear selection + - Add a `useEffect` with a `keydown` listener for Escape that clears `selectedIds` when the SelectionToolbar is visible (i.e. `selectedIds.size > 0`) + - Ensure it doesn't conflict with the existing Escape handler on `AddToQueuePopover` + - _Requirements: 6.3_ + +- [x] 9. Ensure keyboard Tab accessibility for SelectionToolbar + - Verify all interactive elements in the toolbar (workflow buttons, vendor input, submit button, clear button) are focusable via Tab key + - Use native `
` element, in the same position as the existing `SelectionToolbar` for batch FP submissions. The bulk hide toolbar appears alongside (or replaces) the existing selection toolbar depending on context. + +### 6. Per-Row Hide Button and Selection Checkbox + +Two new fixed columns are added to the table, before the existing checkbox column: + +| Column | Width | Content | Position | +|--------|-------|---------|----------| +| Selection checkbox | 36px | `Square` / `CheckSquare` icon (lucide-react) | First column | +| Hide button | 36px | `EyeOff` icon button | Second column | + +Both columns are fixed (not managed by `ColumnManager`) and use sticky positioning in the header. + +### 7. Select All Checkbox + +Rendered in the table header for the selection column: + +- **Unchecked** (`Square` icon): No rows selected +- **Checked** (`CheckSquare` icon): All visible rows selected +- **Indeterminate** (`MinusSquare` icon): Some but not all visible rows selected + +## Data Models + +### localStorage Schema + +**Key:** `steam_findings_hidden_rows` + +**Value:** JSON array of Finding ID strings + +```json +["12345", "67890", "11111"] +``` + +**Constraints:** +- Finding IDs are stored as strings for consistent comparison +- The array may contain IDs that no longer exist in the current findings dataset (stale IDs are retained) +- An empty array `[]` or missing key both mean "no rows hidden" +- If the stored value fails JSON parsing, it is treated as empty (all rows visible) + +### Component State + +| State Variable | Type | Persisted | Description | +|---------------|------|-----------|-------------| +| `hiddenRowIds` | `Set` | Yes (localStorage) | Finding IDs currently hidden | +| `selectedRowIds` | `Set` | No (transient) | Finding IDs currently selected via checkboxes | + +### Derived Data + +| Variable | Derivation | Used By | +|----------|-----------|---------| +| `visibleFindings` | `findings.filter(f => !hiddenRowIds.has(f.id))` | ActionCoverageDonut, filter pipeline | +| `filtered` | `visibleFindings` → column filters → action filter → EXC filter | Sort, table render | +| `sorted` | `filtered` → sort comparator | Table render, export | + + +## 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: Hidden row filtering invariant + +*For any* array of findings and *any* set of hidden Finding_IDs, the `visibleFindings` array SHALL never contain a finding whose ID is in the hidden set. + +**Validates: Requirements 1.1, 1.2, 4.1, 4.3, 5.1, 5.2, 6.1, 6.2** + +### Property 2: localStorage round-trip preserves hidden row state + +*For any* set of valid Finding_ID strings, calling `saveHiddenRows(set)` followed by `loadHiddenRows()` SHALL return a set containing exactly the same elements. + +**Validates: Requirements 2.1, 2.2** + +### Property 3: Corrupted localStorage produces empty set + +*For any* string that is not a valid JSON array of strings, `loadHiddenRows()` SHALL return an empty set, and no error SHALL be thrown. + +**Validates: Requirements 2.4** + +### Property 4: Restore removes exactly the specified ID + +*For any* non-empty set of hidden Finding_IDs and *any* ID in that set, calling `restoreRow(id)` SHALL produce a new hidden set that is equal to the original set minus that single ID. + +**Validates: Requirements 3.3** + +### Property 5: Bulk hide produces the union of hidden and selected sets + +*For any* set of currently hidden Finding_IDs and *any* set of selected Finding_IDs, calling `hideSelectedRows()` SHALL produce a hidden set equal to the union of both sets, and the selection set SHALL be empty afterward. + +**Validates: Requirements 8.5, 8.6** + +### Property 6: Selection is always a subset of visible rows + +*For any* set of selected Finding_IDs and *any* change to the visible row set (via filter changes or row hiding), the resulting selection set SHALL be a subset of the current visible row IDs. + +**Validates: Requirements 8.8** + +### Property 7: Select all produces exactly the visible row ID set + +*For any* array of currently visible (sorted) findings, calling `toggleSelectAll` when no rows are selected SHALL produce a selection set equal to the set of all visible Finding_IDs. Calling `toggleSelectAll` when all rows are selected SHALL produce an empty selection set. + +**Validates: Requirements 8.2, 8.3** + +## Error Handling + +| Scenario | Behavior | +|----------|----------| +| localStorage unavailable (private browsing, quota exceeded) | `saveHiddenRows` fails silently via try/catch. `loadHiddenRows` returns empty set. All rows remain visible. Feature degrades to session-only (hidden state lost on reload). | +| Corrupted localStorage value | `loadHiddenRows` catches JSON parse error and returns empty set. No error shown to user. | +| Stale Finding_ID in hidden set (ID no longer in findings after sync) | ID is retained in localStorage. The `filter()` call simply doesn't match any finding, so no visible effect. If the finding reappears in a future sync, it will be hidden again automatically. | +| Empty findings array | `visibleFindings` is empty. `selectedRowIds` is empty. Charts show "No data" state. Export produces headers only. All controls render but have no actionable items. | +| Bulk hide with empty selection | The "Hide Selected" button is only shown when `selectedRowIds.size > 0`, so this state is unreachable via the UI. If called programmatically, `hideSelectedRows` is a no-op (union with empty set). | +| Select all with no visible rows | `toggleSelectAll` produces an empty set (no rows to select). | + +## Testing Strategy + +### Property-Based Tests + +The feature's core logic — set operations, filtering, localStorage serialization — is well-suited for property-based testing. The functions under test are pure or near-pure (localStorage can be mocked), and the input space (sets of string IDs, arrays of finding objects) is large. + +**Library:** [fast-check](https://github.com/dubzzz/fast-check) — the standard PBT library for JavaScript/React projects. + +**Configuration:** Each property test runs a minimum of 100 iterations. + +**Tag format:** Each test is tagged with a comment: `// Feature: reporting-row-visibility, Property N: ` + +**Properties to implement:** + +| Property | Test Description | Key Generators | +|----------|-----------------|----------------| +| 1 | Filter findings by hidden set, verify no hidden ID in output | `fc.array(findingArb)`, `fc.uniqueArray(fc.string())` | +| 2 | Save then load hidden rows, verify round-trip equality | `fc.uniqueArray(fc.stringOf(fc.constantFrom(...digits), {minLength: 1}))` | +| 3 | Set corrupted string in localStorage, verify loadHiddenRows returns empty set | `fc.string()` filtered to exclude valid JSON arrays | +| 4 | Remove one ID from hidden set, verify set difference | `fc.uniqueArray(fc.string(), {minLength: 1})`, pick random element | +| 5 | Union hidden + selected sets, verify result and empty selection | `fc.uniqueArray(fc.string())` × 2 | +| 6 | Generate selection + filter change, verify selection ⊆ visible | `fc.uniqueArray(fc.string())` for selection and visible sets | +| 7 | Select all from visible set, verify equality; toggle again, verify empty | `fc.array(findingArb)` | + +### Unit Tests (Example-Based) + +Unit tests cover specific scenarios, UI rendering, and edge cases that don't benefit from randomized input: + +- **Hide button renders on each row** (Req 1.3) — verify EyeOff icon in fixed column +- **Hide button visible for viewer role** (Req 1.4) — render with read-only auth context +- **localStorage write on hide/restore** (Req 2.3) — mock localStorage, verify setItem called +- **Row Visibility Manager button shows count** (Req 3.1) — verify "Hidden (N)" text +- **Row Visibility Manager popover lists hidden findings** (Req 3.2) — click button, verify list +- **Restore All clears all hidden rows** (Req 3.4) — verify empty set after restoreAll +- **"Hidden (0)" when no rows hidden** (Req 3.5) — verify button text and empty message +- **Chart re-renders after hide** (Req 4.2) — verify ActionCoverageDonut receives updated findings +- **Hidden state preserved across sync** (Req 5.3) — simulate sync, verify hiddenRowIds unchanged +- **Stale IDs retained silently** (Req 5.4) — hide ID, remove from findings, verify no error +- **Bulk action toolbar appears with count** (Req 8.4) — select rows, verify toolbar renders +- **Indeterminate checkbox state** (Req 8.9) — partial selection, verify MinusSquare icon +- **Toolbar hidden when no selection** (Req 8.10) — empty selection, verify no toolbar +- **Styling consistency** (Req 7.1, 7.2, 7.3, 8.11) — snapshot tests for visual consistency diff --git a/.kiro/specs/reporting-row-visibility/requirements.md b/.kiro/specs/reporting-row-visibility/requirements.md new file mode 100644 index 0000000..2db7e63 --- /dev/null +++ b/.kiro/specs/reporting-row-visibility/requirements.md @@ -0,0 +1,115 @@ +# Requirements Document + +## Introduction + +The Reporting page in the STEAM Security Dashboard displays a table of Ivanti host findings with columns for finding ID, severity, title, CVEs, hostname, IP address, DNS, due date, SLA status, BU ownership, workflow, last found date, and notes. Some findings have manually entered notes such as "NOT STEAM/ACCESS", "MongoDB Update", or other free-text annotations indicating that work is being done outside of the automated FP or Archer exception workflows. These manually-noted findings are classified as "pending" in the Action Coverage donut chart, inflating the pending count even though they represent active remediation efforts. + +Users need the ability to temporarily hide specific rows from the table view — similar to how columns can already be hidden via the ColumnManager popover. Hidden rows should be excluded from the visible table and from the Action Coverage chart calculations, but the underlying data must remain intact. The feature should persist across page reloads and provide a clear mechanism to reveal hidden rows or restore them individually. + +## Glossary + +- **Reporting_Table**: The findings data table rendered on the Reporting page, displaying one row per Ivanti host finding with sortable, filterable columns. +- **Row_Visibility_State**: A client-side record of which finding IDs have been hidden by the user. Stored in browser localStorage for persistence across sessions. +- **Hidden_Row**: A finding whose ID is present in the Row_Visibility_State hidden set. Hidden rows are excluded from the visible table and from chart metric calculations. +- **ColumnManager**: The existing popover component on the Reporting page that allows users to show/hide columns and reorder them via drag-and-drop. The row-hiding feature follows a similar UX pattern. +- **Action_Coverage_Chart**: The donut chart on the Reporting page that classifies open findings into three categories — FP Request, Archer Exception, and Pending — based on workflow status and note content. +- **Row_Visibility_Manager**: A new UI component that provides controls for viewing and restoring hidden rows, analogous to the ColumnManager for columns. +- **Finding_ID**: The unique Ivanti-assigned identifier for each host finding, used as the key for tracking hidden rows. +- **Row_Selection_State**: A transient client-side record of which Finding_IDs are currently selected via checkboxes. This state is not persisted and resets on page reload or after a bulk action completes. +- **Selection_Checkbox**: A checkbox control rendered in a fixed column on each visible row, used to toggle that row's inclusion in the Row_Selection_State. +- **Select_All_Checkbox**: A checkbox control rendered in the table header that toggles selection of all currently visible (non-hidden, post-filter) rows. +- **Bulk_Action_Toolbar**: A contextual toolbar that appears above the Reporting_Table when one or more rows are selected, displaying the count of selected rows and bulk action controls. + +## Requirements + +### Requirement 1: Hide Individual Rows from the Reporting Table + +**User Story:** As a security analyst, I want to hide specific rows in the Reporting table by clicking a hide control on each row, so that I can remove manually-handled findings from view without deleting them. + +#### Acceptance Criteria + +1. THE Reporting_Table SHALL display a hide button on each row that, when clicked, adds the row's Finding_ID to the Row_Visibility_State hidden set. +2. WHEN a row's Finding_ID is added to the Row_Visibility_State hidden set, THE Reporting_Table SHALL immediately remove that row from the visible table without a page reload. +3. THE hide button SHALL be rendered as an icon button (using the `EyeOff` icon from lucide-react) in a fixed column that is not managed by the ColumnManager. +4. WHEN the user has no write permissions (viewer role), THE Reporting_Table SHALL still display the hide button, as row visibility is a personal view preference and not a data modification. + +### Requirement 2: Persist Hidden Row State Across Sessions + +**User Story:** As a security analyst, I want my hidden row selections to persist when I navigate away and return to the Reporting page, so that I do not have to re-hide the same rows every session. + +#### Acceptance Criteria + +1. THE Row_Visibility_State SHALL be stored in browser localStorage under a dedicated key (e.g., `steam_findings_hidden_rows`). +2. WHEN the Reporting page loads, THE Reporting_Table SHALL read the Row_Visibility_State from localStorage and exclude hidden Finding_IDs from the visible table. +3. WHEN the Row_Visibility_State changes (row hidden or restored), THE Reporting_Table SHALL write the updated state to localStorage immediately. +4. IF localStorage is unavailable or the stored value is corrupted, THEN THE Reporting_Table SHALL treat all rows as visible and continue operating without error. + +### Requirement 3: Row Visibility Manager Panel + +**User Story:** As a security analyst, I want a panel that shows me which rows are currently hidden and lets me restore them, so that I can manage my hidden rows and bring back findings I no longer want to hide. + +#### Acceptance Criteria + +1. THE Row_Visibility_Manager SHALL be accessible via a toolbar button placed adjacent to the existing ColumnManager button, using the `EyeOff` icon and displaying a count of currently hidden rows (e.g., "Hidden (3)"). +2. WHEN the Row_Visibility_Manager button is clicked, THE Row_Visibility_Manager SHALL open a popover panel listing all currently hidden findings by Finding_ID and title. +3. THE Row_Visibility_Manager panel SHALL provide a restore button (using the `Eye` icon) next to each hidden finding entry that, when clicked, removes that Finding_ID from the Row_Visibility_State and returns the row to the visible table. +4. THE Row_Visibility_Manager panel SHALL provide a "Restore All" button that clears the entire Row_Visibility_State and returns all hidden rows to the visible table. +5. WHEN no rows are hidden, THE Row_Visibility_Manager button SHALL display "Hidden (0)" and the popover panel SHALL display a message indicating no rows are hidden. + +### Requirement 4: Exclude Hidden Rows from Action Coverage Metrics + +**User Story:** As a security analyst, I want hidden rows to be excluded from the Action Coverage donut chart, so that manually-handled findings I have hidden do not inflate the "Pending" count. + +#### Acceptance Criteria + +1. THE Action_Coverage_Chart SHALL compute its FP Request, Archer Exception, and Pending counts using only visible (non-hidden) findings. +2. WHEN a row is hidden or restored, THE Action_Coverage_Chart SHALL recalculate and re-render immediately to reflect the updated visible finding set. +3. THE Action_Coverage_Chart segment click filtering SHALL operate only on visible findings, so clicking a segment filters within the non-hidden set. + +### Requirement 5: Hidden Row Interaction with Existing Filters + +**User Story:** As a security analyst, I want row hiding to work correctly alongside column filters, sort order, and the action coverage chart filter, so that hiding rows does not interfere with other table controls. + +#### Acceptance Criteria + +1. THE Reporting_Table SHALL apply row hiding before column filters, so that hidden rows are excluded from the dataset before any column filter, sort, or action coverage filter is applied. +2. WHEN a finding is hidden and a column filter is active, THE Reporting_Table SHALL not include the hidden finding in filter value dropdowns or filter counts. +3. WHEN findings are synced from Ivanti (Sync button), THE Row_Visibility_State SHALL be preserved — previously hidden Finding_IDs remain hidden if they still exist in the refreshed dataset. +4. IF a hidden Finding_ID no longer exists in the synced findings data, THEN THE Row_Visibility_State SHALL retain the ID silently (no error) so that it is automatically re-hidden if the finding reappears in a future sync. + +### Requirement 6: Export Behavior for Hidden Rows + +**User Story:** As a security analyst, I want CSV and XLSX exports to include only visible rows by default, so that my exports reflect the same filtered view I see on screen. + +#### Acceptance Criteria + +1. WHEN the user exports data via CSV or XLSX, THE Reporting_Table SHALL export only the currently visible (non-hidden, post-filter) rows. +2. THE export SHALL respect all active filters (column filters, action coverage filter, EXC filter) in addition to row hiding, exporting only the intersection of all active view constraints. + +### Requirement 7: Visual Styling Consistency + +**User Story:** As a security analyst, I want the row-hiding controls to match the existing dashboard aesthetic, so that the feature feels native to the application. + +#### Acceptance Criteria + +1. THE hide button on each row SHALL use the same icon size (13px), color palette (muted slate for default, accent blue on hover), and monospace font styling as existing toolbar controls. +2. THE Row_Visibility_Manager popover SHALL use the same panel styling (dark gradient background, accent border, box shadow) as the existing ColumnManager popover. +3. THE Row_Visibility_Manager toolbar button SHALL use the same button styling (padding, border radius, font size, uppercase text) as the existing ColumnManager and Queue toolbar buttons. + +### Requirement 8: Bulk Hide Rows via Multi-Select + +**User Story:** As a security analyst, I want to select multiple rows and hide them all at once, so that I can quickly clear out batches of manually-handled findings without clicking hide on each row individually. + +#### Acceptance Criteria + +1. THE Reporting_Table SHALL display a Selection_Checkbox on each visible row in a fixed column that is not managed by the ColumnManager, positioned before the hide button column. +2. THE Reporting_Table SHALL display a Select_All_Checkbox in the table header of the selection column that, when checked, adds all currently visible (non-hidden, post-filter) Finding_IDs to the Row_Selection_State. +3. WHEN the Select_All_Checkbox is unchecked, THE Reporting_Table SHALL remove all Finding_IDs from the Row_Selection_State. +4. WHEN one or more Finding_IDs are present in the Row_Selection_State, THE Bulk_Action_Toolbar SHALL appear above the Reporting_Table displaying the count of selected rows (e.g., "3 rows selected") and a "Hide Selected" button using the `EyeOff` icon. +5. WHEN the "Hide Selected" button is clicked, THE Reporting_Table SHALL add all Finding_IDs in the Row_Selection_State to the Row_Visibility_State hidden set in a single operation. +6. WHEN a bulk hide operation completes, THE Reporting_Table SHALL clear the Row_Selection_State so that no rows remain selected. +7. WHEN a bulk hide operation completes, THE Action_Coverage_Chart SHALL recalculate and re-render immediately to reflect the updated visible finding set. +8. WHEN column filters or the action coverage filter change the set of visible rows, THE Row_Selection_State SHALL remove any Finding_IDs that are no longer visible, so that the selection always reflects the current filtered view. +9. THE Select_All_Checkbox SHALL display an indeterminate state when some but not all visible rows are selected. +10. WHEN no rows are selected, THE Bulk_Action_Toolbar SHALL not be displayed. +11. THE Selection_Checkbox, Select_All_Checkbox, and Bulk_Action_Toolbar SHALL use the same color palette (muted slate for default, accent blue for checked/active state), monospace font styling, and dark gradient background as existing toolbar controls defined in the design system. diff --git a/.kiro/specs/reporting-row-visibility/tasks.md b/.kiro/specs/reporting-row-visibility/tasks.md new file mode 100644 index 0000000..748aaa0 --- /dev/null +++ b/.kiro/specs/reporting-row-visibility/tasks.md @@ -0,0 +1,127 @@ +# Implementation Plan: Reporting Row Visibility + +## Overview + +This plan implements row-level visibility controls for the Reporting page's findings table. All changes are contained within `frontend/src/components/pages/ReportingPage.js` — no new files, no backend changes. The implementation adds hidden row state management (localStorage-persisted), a visibility filtering step in the data pipeline, selection checkboxes with bulk hide, a Row Visibility Manager popover, chart/export integration, and per-row hide buttons. Each task builds incrementally on the previous one, wiring everything together by the final step. + +## Tasks + +- [x] 1. Add hidden row state management and localStorage helpers + - Add the `HIDDEN_ROWS_KEY` constant (`'steam_findings_hidden_rows'`) + - Implement `loadHiddenRows()` function that reads from localStorage, parses JSON, returns a `Set` (empty set on parse failure or missing key) + - Implement `saveHiddenRows(hiddenSet)` function that serializes the set to a JSON array and writes to localStorage (silent catch on failure) + - Add `hiddenRowIds` state initialized via `useState(loadHiddenRows)` + - Implement `hideRow(findingId)` callback that adds a string ID to the set and persists + - Implement `restoreRow(findingId)` callback that removes a string ID from the set and persists + - Implement `restoreAllRows()` callback that clears the set and persists an empty set + - _Requirements: 1.1, 2.1, 2.2, 2.3, 2.4_ + +- [ ]* 1.1 Write property test: localStorage round-trip preserves hidden row state + - **Property 2: localStorage round-trip preserves hidden row state** + - Generate arbitrary sets of valid Finding_ID strings, call `saveHiddenRows` then `loadHiddenRows`, assert the returned set contains exactly the same elements + - **Validates: Requirements 2.1, 2.2** + +- [ ]* 1.2 Write property test: corrupted localStorage produces empty set + - **Property 3: Corrupted localStorage produces empty set** + - Generate arbitrary strings that are not valid JSON arrays of strings, set them in localStorage under the hidden rows key, call `loadHiddenRows`, assert the result is an empty set and no error is thrown + - **Validates: Requirements 2.4** + +- [x] 2. Insert visibility filtering into the data pipeline + - Add `visibleFindings` useMemo that filters `findings` by excluding any finding whose `String(f.id)` is in `hiddenRowIds` (short-circuit when set is empty) + - Modify the existing `filtered` useMemo to start from `visibleFindings` instead of `findings` + - Ensure column filter dropdowns, action filter, and EXC filter all operate on the post-hide dataset + - _Requirements: 1.2, 5.1, 5.2_ + +- [ ]* 2.1 Write property test: hidden row filtering invariant + - **Property 1: Hidden row filtering invariant** + - Generate arbitrary arrays of finding objects and arbitrary sets of hidden Finding_IDs, compute `visibleFindings`, assert no finding in the output has an ID present in the hidden set + - **Validates: Requirements 1.1, 1.2, 4.1, 4.3, 5.1, 5.2, 6.1, 6.2** + +- [x] 3. Integrate hidden rows with chart and export + - Pass `visibleFindings` (instead of `findings`) to the `ActionCoverageDonut` component's `findings` prop + - Modify the CSV export function to use the sorted/filtered visible rows (already derived from `visibleFindings` via the pipeline) + - Modify the XLSX export function to use the sorted/filtered visible rows + - Verify that chart segment click filtering operates on the visible set + - _Requirements: 4.1, 4.2, 4.3, 6.1, 6.2_ + +- [x] 4. Checkpoint — Verify core hide/restore pipeline + - Ensure all tests pass, ask the user if questions arise. + +- [x] 5. Add selection state and bulk hide logic + - Add `selectedRowIds` state as `useState(new Set())` + - Implement `toggleRowSelection(findingId)` callback that adds/removes a string ID from the selection set + - Implement `toggleSelectAll()` callback that selects all visible sorted row IDs when not all are selected, or clears selection when all are selected + - Implement `hideSelectedRows()` callback that unions `selectedRowIds` into `hiddenRowIds`, persists, and clears the selection set + - Add a `useEffect` that prunes `selectedRowIds` to only include IDs present in the current `sorted` array whenever `sorted` changes + - _Requirements: 8.1, 8.2, 8.3, 8.5, 8.6, 8.8_ + +- [ ]* 5.1 Write property test: bulk hide produces union of hidden and selected sets + - **Property 5: Bulk hide produces the union of hidden and selected sets** + - Generate two arbitrary sets of Finding_ID strings (hidden and selected), simulate `hideSelectedRows`, assert the resulting hidden set equals the union and the selection set is empty + - **Validates: Requirements 8.5, 8.6** + +- [ ]* 5.2 Write property test: selection is always a subset of visible rows + - **Property 6: Selection is always a subset of visible rows** + - Generate arbitrary selection and visible row sets, simulate the pruning effect, assert the resulting selection is a subset of visible row IDs + - **Validates: Requirements 8.8** + +- [ ]* 5.3 Write property test: select all produces exactly the visible row ID set + - **Property 7: Select all produces exactly the visible row ID set** + - Generate an arbitrary array of finding objects representing sorted visible rows, simulate `toggleSelectAll` from empty selection, assert the selection equals the full visible ID set; toggle again, assert empty + - **Validates: Requirements 8.2, 8.3** + +- [ ]* 5.4 Write property test: restore removes exactly the specified ID + - **Property 4: Restore removes exactly the specified ID** + - Generate a non-empty set of hidden Finding_IDs, pick a random element, simulate `restoreRow`, assert the result equals the original set minus that single ID + - **Validates: Requirements 3.3** + +- [x] 6. Add selection checkbox column and select-all checkbox to the table + - Import `Square`, `CheckSquare`, and `MinusSquare` icons from lucide-react + - Add a fixed 36px selection checkbox column as the first column in the table header and body + - Render `Select_All_Checkbox` in the header: `CheckSquare` when all selected, `MinusSquare` when partially selected, `Square` when none selected; onClick calls `toggleSelectAll` + - Render `Selection_Checkbox` on each row: `CheckSquare` when selected, `Square` when not; onClick calls `toggleRowSelection(finding.id)` + - Style checkboxes with muted slate default color, accent blue when checked/active, matching existing icon sizing + - _Requirements: 8.1, 8.2, 8.3, 8.9, 8.11_ + +- [x] 7. Add per-row hide button column + - Add a fixed 36px hide button column as the second column (after selection checkbox) in the table header and body + - Render an `EyeOff` icon button on each row; onClick calls `hideRow(finding.id)` + - Style the button with 13px icon size, muted slate default color, accent blue on hover, matching existing toolbar icon patterns + - The column header cell is empty (no label) + - _Requirements: 1.1, 1.3, 1.4, 7.1_ + +- [x] 8. Implement BulkHideToolbar component + - Create inline `BulkHideToolbar` component accepting `count`, `onHide`, and `onClear` props + - Render "{count} rows selected" label, "Hide Selected" button with `EyeOff` icon, and "Clear" button + - Style with dark gradient background, accent border, monospace font, matching existing toolbar patterns + - Render the toolbar above the table inside the scroll container, only when `selectedRowIds.size > 0` + - Wire `onHide` to `hideSelectedRows` and `onClear` to clearing the selection set + - _Requirements: 8.4, 8.5, 8.6, 8.7, 8.10, 8.11_ + +- [x] 9. Checkpoint — Verify selection and bulk hide UI + - Ensure all tests pass, ask the user if questions arise. + +- [x] 10. Implement RowVisibilityManager popover component + - Create inline `RowVisibilityManager` component accepting `hiddenRowIds`, `findings`, `onRestore`, and `onRestoreAll` props + - Add `open` state for popover visibility, with outside-click-to-close behavior (same pattern as existing `ColumnManager`) + - Render a toolbar button with `EyeOff` icon and "Hidden (N)" count text, styled to match the existing ColumnManager and Queue toolbar buttons (same padding, border radius, font size, uppercase text) + - When open, render a popover panel listing hidden findings by Finding_ID and title (looked up from the full `findings` array) + - Each entry has an `Eye` icon restore button that calls `onRestore(findingId)` + - Include a "Restore All" button at the bottom that calls `onRestoreAll` + - When `hiddenRowIds.size === 0`, show "No rows hidden" message in the popover + - Use dark gradient background, accent border, and box shadow matching the ColumnManager popover + - Place the button in the toolbar div adjacent to the ColumnManager button + - _Requirements: 3.1, 3.2, 3.3, 3.4, 3.5, 7.2, 7.3_ + +- [x] 11. Final checkpoint — Verify complete feature integration + - Ensure all tests pass, ask the user if questions arise. + +## Notes + +- Tasks marked with `*` are optional and can be skipped for faster MVP +- All changes are contained within `frontend/src/components/pages/ReportingPage.js` — no new files needed +- The design uses JavaScript throughout; fast-check is the PBT library +- Each task references specific requirements for traceability +- Checkpoints ensure incremental validation +- Property tests validate the 7 correctness properties defined in the design document +- Unit tests validate specific UI rendering scenarios and edge cases diff --git a/.kiro/specs/sync-anomaly-detection/.config.kiro b/.kiro/specs/sync-anomaly-detection/.config.kiro new file mode 100644 index 0000000..a165d9d --- /dev/null +++ b/.kiro/specs/sync-anomaly-detection/.config.kiro @@ -0,0 +1 @@ +{"specId": "a3e7c1d2-8f4b-4e9a-b6d1-2c5f8a9e3b7d", "workflowType": "requirements-first", "specType": "feature"} \ No newline at end of file diff --git a/.kiro/specs/sync-anomaly-detection/design.md b/.kiro/specs/sync-anomaly-detection/design.md new file mode 100644 index 0000000..d153159 --- /dev/null +++ b/.kiro/specs/sync-anomaly-detection/design.md @@ -0,0 +1,454 @@ +# Design Document: Sync Anomaly Detection and BU Drift Monitoring + +## Overview + +This feature extends the Ivanti sync pipeline to automatically classify why findings disappear from filtered sync results. The current archive system detects disappearances but labels them all as `severity_score_drift` — a default that proved incorrect during the April 2026 incident where 109 findings silently disappeared due to a bulk BU reassignment. + +The design adds three capabilities to the existing `ivantiFindings.js` sync pipeline: + +1. **BU Drift Checker** — a post-sync step that queries the Ivanti API without BU/severity filters for newly archived finding IDs, classifying each disappearance as `bu_reassignment`, `severity_drift`, `closed_on_platform`, or `decommissioned`. +2. **Sync Anomaly Summary** — a structured report computed after each sync that breaks down count changes by cause and stores the result in a new `ivanti_sync_anomaly_log` table. +3. **Finding-Level BU Tracking** — per-finding BU comparison during `syncFindings()` that detects BU changes across syncs and records them in a new `ivanti_finding_bu_history` table. + +The approach formalizes the ad-hoc diagnostic patterns from `drift-check.js` and `bu-reassignment-check.js` into the automated sync pipeline, with results surfaced through new API endpoints and an anomaly banner on the Vulnerability Triage page. + +--- + +## Architecture + +The feature integrates into the existing sync pipeline as post-sync steps, keeping the core sync logic unchanged. No new route modules are created — all new endpoints and logic live within the existing `ivantiFindings.js` module and its factory-pattern router. + +```mermaid +flowchart TD + A[syncFindings - fetch all pages] --> B[Compare previous vs current findings] + B --> C[detectArchiveChanges - existing] + B --> D[BU comparison - new] + D --> E[Insert BU changes into ivanti_finding_bu_history] + C --> F[syncClosedCount - existing] + F --> G[detectClosedFindings - existing] + G --> H[detectClosedGoneFindings - existing] + H --> I[runBUDriftChecker - new] + I --> J[Batch unfiltered queries for newly archived IDs] + J --> K[Classify each: bu_reassignment / severity_drift / closed_on_platform / decommissioned] + K --> L[Update archive transition reasons] + L --> M[computeAnomalySummary - new] + M --> N[Insert row into ivanti_sync_anomaly_log] + + style I fill:#F59E0B,color:#000 + style D fill:#F59E0B,color:#000 + style M fill:#F59E0B,color:#000 +``` + +**Key design decisions:** + +- **Post-sync, not inline**: The BU drift checker runs after all existing sync steps complete. This means a sync failure does not block drift checking of previously archived findings, and drift checking failures do not block the sync. +- **Same module, no new route file**: The anomaly and BU history endpoints are added to the existing `createIvantiFindingsRouter`. This keeps the Ivanti findings API surface in one place and avoids a new factory-pattern module for four endpoints. +- **Batched unfiltered queries**: Finding IDs are chunked into groups of 50 for the unfiltered Ivanti API call, matching the pattern proven in `bu-reassignment-check.js`. This stays within API limits while keeping the number of HTTP calls manageable. +- **BU comparison in syncFindings**: The per-finding BU comparison happens during the existing previous-vs-current comparison in `syncFindings()`, before the cache is overwritten. This is the only point where both the old and new BU values are available in memory. + +--- + +## Components and Interfaces + +### 1. BU Drift Checker (`runBUDriftChecker`) + +A new async function added to `ivantiFindings.js` that runs after `detectClosedGoneFindings()` in the sync pipeline. + +**Signature:** +```javascript +async function runBUDriftChecker(db, newlyArchivedIds, apiKey, clientId, skipTls) +``` + +**Parameters:** +- `db` — SQLite database instance +- `newlyArchivedIds` — array of finding ID strings that were newly archived in this sync cycle (from `detectArchiveChanges`) +- `apiKey`, `clientId`, `skipTls` — Ivanti API credentials (same as existing sync functions) + +**Behavior:** +1. If `newlyArchivedIds` is empty, return immediately (no API calls). +2. Chunk the IDs into batches of 50. +3. For each batch, call `ivantiPost()` with a filter on `id` field only (no BU, severity, or state filters) — the same unfiltered query pattern used in `bu-reassignment-check.js`. +4. For each finding ID, classify the result: + - **Found, BU differs from expected** → `bu_reassignment` + - **Found, BU matches, severity < 8.5** → `severity_drift` + - **Found, BU matches, state is Closed** → `closed_on_platform` + - **Not found** → `decommissioned` +5. Update the corresponding `ivanti_archive_transitions` row's `reason` field with the classification. +6. Return a classification summary object: `{ bu_reassignment: N, severity_drift: N, closed_on_platform: N, decommissioned: N }`. + +**Expected BUs** are the same values used in `FINDINGS_FILTERS`: `NTS-AEO-ACCESS-ENG` and `NTS-AEO-STEAM`. + +**Error handling:** If an individual batch API call fails, log the error and skip that batch. The findings in the failed batch retain their default `severity_score_drift` reason. The function never throws — it returns whatever partial results it collected. + +### 2. Anomaly Summary Computation (`computeAnomalySummary`) + +A new async function that runs after the BU drift checker completes. + +**Signature:** +```javascript +async function computeAnomalySummary(db, openCountDelta, closedCountDelta, newlyArchivedCount, returnedCount, classificationBreakdown) +``` + +**Parameters:** +- `db` — SQLite database instance +- `openCountDelta` — integer, current open count minus previous open count +- `closedCountDelta` — integer, current closed count minus previous closed count +- `newlyArchivedCount` — integer, number of findings archived in this sync +- `returnedCount` — integer, number of findings that returned in this sync +- `classificationBreakdown` — object from `runBUDriftChecker`, e.g. `{ bu_reassignment: 38, severity_drift: 5, ... }` + +**Behavior:** +1. Determine `is_significant`: true if `newlyArchivedCount > 5`. +2. Insert a row into `ivanti_sync_anomaly_log` with all fields. +3. Log the summary to console. + +### 3. Finding-Level BU Comparison + +Integrated into `syncFindings()` between reading previous findings and writing the new cache. Uses the existing `previousFindings` and `allFindings` arrays. + +**Logic:** +``` +for each finding in allFindings: + previousFinding = previousMap.get(finding.id) + if previousFinding exists AND previousFinding.buOwnership !== finding.buOwnership + AND both values are non-empty: + INSERT into ivanti_finding_bu_history +``` + +The `buOwnership` field is already extracted by `extractFinding()` from `assetCustomAttributes['1550_host_1']`. No changes to `extractFinding()` are needed — it already stores `buOwnership` on each finding object. + +### 4. New API Endpoints + +All endpoints are added to the existing `createIvantiFindingsRouter` and require authentication via `requireAuth(db)`. + +| Method | Path | Description | +|---|---|---| +| GET | `/api/ivanti/findings/anomaly/latest` | Returns the most recent anomaly summary row | +| GET | `/api/ivanti/findings/anomaly/history` | Returns anomaly history (last 30 or date-filtered) | +| GET | `/api/ivanti/findings/bu-changes` | Returns all BU change events, newest first | +| GET | `/api/ivanti/findings/:findingId/bu-history` | Returns BU change history for a specific finding | + +**GET /anomaly/latest response:** +```json +{ + "anomaly": { + "id": 1, + "sync_timestamp": "2026-04-24T12:00:00", + "open_count_delta": -45, + "closed_count_delta": -94, + "newly_archived_count": 45, + "returned_count": 0, + "classification": { + "bu_reassignment": 38, + "severity_drift": 1, + "closed_on_platform": 4, + "decommissioned": 2 + }, + "is_significant": true + } +} +``` + +Returns `{ anomaly: null }` if no anomaly records exist. + +**GET /anomaly/history query parameters:** +- `from` (optional) — ISO date string, inclusive start +- `to` (optional) — ISO date string, inclusive end +- If neither provided, returns last 30 rows + +**GET /bu-changes response:** +```json +{ + "changes": [ + { + "id": 1, + "finding_id": "2687687777", + "finding_title": "OpenSSH regreSSHion", + "host_name": "syn-098-120-000-078", + "previous_bu": "NTS-AEO-STEAM", + "new_bu": "SDIT-CSD-ITLS-PIES", + "detected_at": "2026-04-24T12:00:00" + } + ] +} +``` + +**GET /:findingId/bu-history response:** +```json +{ + "finding_id": "2687687777", + "history": [ + { + "previous_bu": "NTS-AEO-STEAM", + "new_bu": "SDIT-CSD-ITLS-PIES", + "detected_at": "2026-04-24T12:00:00" + } + ] +} +``` + +### 5. Anomaly Banner Component (`AnomalyBanner.js`) + +A new React component placed in `frontend/src/components/pages/AnomalyBanner.js`, rendered on the Vulnerability Triage page above the `IvantiCountsChart`. + +**Props:** None — fetches its own data from `/api/ivanti/findings/anomaly/latest`. + +**Behavior:** +1. On mount, fetch the latest anomaly summary. +2. If `is_significant` is false or no anomaly exists, render nothing. +3. If `is_significant` is true, render a warning banner with: + - Amber background tint (`rgba(245, 158, 11, 0.15)`) with amber border (`rgba(245, 158, 11, 0.3)`) + - `AlertTriangle` icon from lucide-react + - Summary text: "45 findings archived — 38 BU reassignment, 5 severity drift, 2 decommissioned" + - Expandable detail section (click to toggle) showing affected findings grouped by classification + - Dismiss button (X icon) that hides the banner for the current session via `useState` +4. Uses monospace typography and dark theme colors per `DESIGN_SYSTEM.md`. + +**Session dismiss:** Uses React state only — no localStorage. The banner reappears on page reload, which is appropriate since the anomaly data persists until the next sync produces a non-significant result. + +```mermaid +stateDiagram-v2 + [*] --> Loading: Component mounts + Loading --> Hidden: No anomaly or not significant + Loading --> Visible: Significant anomaly + Visible --> Expanded: Click breakdown text + Expanded --> Visible: Click breakdown text + Visible --> Dismissed: Click dismiss + Expanded --> Dismissed: Click dismiss + Dismissed --> [*] +``` + +### 6. Migration Script + +Located at `backend/migrations/add_sync_anomaly_tables.js`. Uses the same pattern as existing migrations (`add_closed_gone_state.js`): standalone Node script, opens the database directly, uses `CREATE TABLE IF NOT EXISTS` and `CREATE INDEX IF NOT EXISTS` for idempotency. + +--- + +## Data Models + +### New Table: `ivanti_sync_anomaly_log` + +Stores one row per sync cycle with the anomaly summary. + +| Column | Type | Constraints | Description | +|---|---|---|---| +| `id` | INTEGER | PRIMARY KEY AUTOINCREMENT | Unique row identifier | +| `sync_timestamp` | DATETIME | NOT NULL DEFAULT CURRENT_TIMESTAMP | When the sync completed | +| `open_count_delta` | INTEGER | NOT NULL DEFAULT 0 | Current open count minus previous open count | +| `closed_count_delta` | INTEGER | NOT NULL DEFAULT 0 | Current closed count minus previous closed count | +| `newly_archived_count` | INTEGER | NOT NULL DEFAULT 0 | Number of findings archived in this sync | +| `returned_count` | INTEGER | NOT NULL DEFAULT 0 | Number of findings that returned in this sync | +| `classification_json` | TEXT | NOT NULL DEFAULT '{}' | JSON object: `{ bu_reassignment, severity_drift, closed_on_platform, decommissioned }` | +| `is_significant` | INTEGER | NOT NULL DEFAULT 0 | 1 if `newly_archived_count > 5`, else 0 | +| `created_at` | DATETIME | DEFAULT CURRENT_TIMESTAMP | Row creation timestamp | + +**Indexes:** +- `idx_anomaly_sync_timestamp` on `sync_timestamp` — for efficient latest-record and date-range queries + +### New Table: `ivanti_finding_bu_history` + +Stores BU change events detected during sync. + +| Column | Type | Constraints | Description | +|---|---|---|---| +| `id` | INTEGER | PRIMARY KEY AUTOINCREMENT | Unique row identifier | +| `finding_id` | TEXT | NOT NULL | Ivanti finding identifier | +| `finding_title` | TEXT | NOT NULL DEFAULT '' | Finding title at time of detection | +| `host_name` | TEXT | NOT NULL DEFAULT '' | Host name at time of detection | +| `previous_bu` | TEXT | NOT NULL | BU value from previous sync | +| `new_bu` | TEXT | NOT NULL | BU value from current sync | +| `detected_at` | DATETIME | NOT NULL DEFAULT CURRENT_TIMESTAMP | When the change was detected | +| `created_at` | DATETIME | DEFAULT CURRENT_TIMESTAMP | Row creation timestamp | + +**Indexes:** +- `idx_bu_history_finding_id` on `finding_id` — for per-finding history lookups +- `idx_bu_history_detected_at` on `detected_at` — for chronological queries + +### Modified: `ivanti_archive_transitions.reason` field + +No schema change needed — the `reason` column is already `TEXT NOT NULL DEFAULT ''`. The change is in the values written: + +| Previous values | New values | +|---|---| +| `severity_score_drift` | `bu_reassignment:` | +| `reappeared_in_sync` | `severity_drift:` | +| `remediated_in_ivanti` | `closed_on_platform` | +| `disappeared_from_closed_set` | `decommissioned` | + +Existing rows with `severity_score_drift` are not modified — the enhanced reasons apply only to transitions created after deployment. + +### Existing: `ivanti_findings_cache.findings_json` + +No schema change. The `buOwnership` field is already present in each finding object within the JSON array, extracted by `extractFinding()` from `assetCustomAttributes['1550_host_1']`. + + +--- + +## 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: Classification correctness + +*For any* finding returned by an unfiltered Ivanti API query, the BU drift classifier SHALL produce the correct classification based on the combination of BU value, severity, and state: +- BU not in {NTS-AEO-ACCESS-ENG, NTS-AEO-STEAM} → `bu_reassignment` +- BU matches expected, severity < 8.5 → `severity_drift` +- BU matches expected, severity >= 8.5, state is Closed → `closed_on_platform` +- Finding not returned by API → `decommissioned` + +**Validates: Requirements 1.2, 1.3, 1.4, 1.5** + +### Property 2: Archive transition reason formatting + +*For any* classification result, the archive transition reason field SHALL be formatted correctly: +- `bu_reassignment` classification with BU value B → reason is `bu_reassignment:B` +- `severity_drift` classification with severity S → reason is `severity_drift:S` +- `closed_on_platform` → reason is `closed_on_platform` +- `decommissioned` → reason is `decommissioned` + +**Validates: Requirements 6.1, 6.2, 6.3, 6.4** + +### Property 3: Batch size constraint + +*For any* list of finding IDs of length N, the BU drift checker SHALL partition them into ceil(N/50) batches where each batch contains at most 50 IDs and the union of all batches equals the original list. + +**Validates: Requirements 1.7** + +### Property 4: Significance threshold + +*For any* non-negative integer `newly_archived_count`, the anomaly summary's `is_significant` flag SHALL be true if and only if `newly_archived_count > 5`. + +**Validates: Requirements 2.7** + +### Property 5: Count delta computation + +*For any* pair of non-negative integers (previous_count, current_count), the anomaly summary SHALL compute the delta as `current_count - previous_count` for both open and closed counts. + +**Validates: Requirements 2.1** + +### Property 6: BU extraction preservation + +*For any* raw Ivanti finding object with a non-empty `assetCustomAttributes['1550_host_1']` array, `extractFinding` SHALL produce a finding object whose `buOwnership` field equals the first element of that array. + +**Validates: Requirements 3.1** + +### Property 7: BU change detection and recording + +*For any* finding that appears in both the previous and current sync results with different non-empty `buOwnership` values, the sync pipeline SHALL insert exactly one row into `ivanti_finding_bu_history` with the correct `finding_id`, `previous_bu`, and `new_bu`. *For any* finding that appears for the first time (no previous entry) or has the same BU value, no history row SHALL be inserted. + +**Validates: Requirements 3.2, 3.3, 3.6** + +### Property 8: Latest anomaly returns most recent + +*For any* non-empty sequence of anomaly summary rows with distinct timestamps, the `/anomaly/latest` endpoint SHALL return the row with the maximum `sync_timestamp`. + +**Validates: Requirements 2.5** + +### Property 9: Anomaly history ordering and limit + +*For any* set of N anomaly summary rows, the `/anomaly/history` endpoint (without date parameters) SHALL return min(N, 30) rows ordered by `sync_timestamp` descending. + +**Validates: Requirements 2.6, 7.2** + +### Property 10: Date-range filtering with complete response shape + +*For any* date range [from, to] and set of anomaly summary rows, the `/anomaly/history` endpoint SHALL return only rows whose `sync_timestamp` falls within the range (inclusive), ordered by `sync_timestamp` descending. Each returned row SHALL include `sync_timestamp`, `open_count_delta`, `closed_count_delta`, `newly_archived_count`, `returned_count`, `classification` (parsed as an object from `classification_json`), and `is_significant`. + +**Validates: Requirements 7.1, 7.4** + +### Property 11: BU changes endpoint ordering + +*For any* set of BU change history rows, the `/bu-changes` endpoint SHALL return all rows ordered by `detected_at` descending. + +**Validates: Requirements 3.4** + +### Property 12: Per-finding BU history filtering + +*For any* finding ID F and set of BU history rows across multiple findings, the `/:findingId/bu-history` endpoint SHALL return only rows where `finding_id = F`, ordered by `detected_at` descending. + +**Validates: Requirements 3.5** + +--- + +## Error Handling + +### BU Drift Checker Errors + +- **Individual batch API failure**: Log the error with the batch range, skip the batch, continue with remaining batches. Findings in the failed batch retain the default `severity_score_drift` reason. The function returns partial results. +- **All batches fail**: The classification breakdown will be all zeros. The anomaly summary is still written with `newly_archived_count` reflecting the archive detection results (which don't depend on the drift checker). +- **API timeout**: The existing 15-second timeout in `ivantiPost()` applies. Timed-out batches are treated as failed batches. +- **Malformed API response**: If `JSON.parse` fails on the response body, treat the batch as failed. Log the raw response length for debugging. + +### Anomaly Summary Errors + +- **Database write failure**: Log the error. The sync itself has already completed successfully — the anomaly summary is informational. Do not retry. +- **Missing previous counts**: If no previous anomaly row exists (first sync after deployment), use 0 for previous counts. The first anomaly row will have deltas equal to the current counts. + +### BU Comparison Errors + +- **Database insert failure**: Log the error for the specific finding, continue processing remaining findings. BU comparison failures are non-fatal. +- **Missing buOwnership field**: If either the previous or current finding has an empty/undefined `buOwnership`, skip the comparison for that finding (per requirement 3.6). + +### API Endpoint Errors + +- **Database read failure**: Return 500 with a generic error message. Do not expose internal error details. +- **Invalid date parameters**: If `from` or `to` are not valid ISO date strings, ignore them and fall back to the default last-30 behavior. Log a warning. +- **Authentication failure**: Handled by existing `requireAuth(db)` middleware — returns 401. + +### Migration Errors + +- **Table already exists**: `CREATE TABLE IF NOT EXISTS` handles this silently. +- **Index already exists**: `CREATE INDEX IF NOT EXISTS` handles this silently. +- **Database locked**: The migration script opens its own connection. If the server is running, SQLite's WAL mode allows concurrent reads. If a write lock conflict occurs, the migration will fail with a clear error message and can be retried. + +--- + +## Testing Strategy + +### Property-Based Tests + +Property-based testing is appropriate for this feature because the core logic involves classification functions, data transformations, and query behaviors that have clear input/output relationships and universal properties. + +**Library:** [fast-check](https://github.com/dubzzz/fast-check) — the standard PBT library for JavaScript/Node.js. + +**Configuration:** +- Minimum 100 iterations per property test +- Each test tagged with: `Feature: sync-anomaly-detection, Property {N}: {title}` + +**Properties to implement:** + +| Property | Test approach | +|---|---| +| 1: Classification correctness | Generate random {bu, severity, state, found} tuples, verify classifier output | +| 2: Reason formatting | Generate random classification results, verify reason string format | +| 3: Batch size constraint | Generate random-length ID arrays, verify chunking | +| 4: Significance threshold | Generate random integers, verify is_significant flag | +| 5: Delta computation | Generate random count pairs, verify subtraction | +| 6: BU extraction | Generate random raw finding objects, verify buOwnership extraction | +| 7: BU change detection | Generate random previous/current finding pairs, verify history insertion | +| 8–12: API query properties | Generate random DB state, verify endpoint responses | + +### Unit Tests (Example-Based) + +Unit tests cover specific scenarios, edge cases, and integration points not suited for PBT: + +- **Migration idempotency**: Run migration twice, verify no errors on second run (Req 4.6) +- **API error resilience**: Mock `ivantiPost` to return errors, verify drift checker doesn't throw (Req 1.6) +- **Anomaly banner rendering**: Mock API response, verify banner shows/hides based on `is_significant` (Req 5.2, 5.3) +- **Banner dismiss**: Click dismiss button, verify banner hidden (Req 5.4) +- **Banner expand/collapse**: Click breakdown text, verify detail section toggles (Req 5.7) +- **Authentication enforcement**: Unauthenticated requests return 401 (Req 7.3) +- **Fixed reason strings**: Verify `decommissioned` and `closed_on_platform` are exact strings (Req 6.3, 6.4) +- **Backward compatibility**: Existing `severity_score_drift` rows are not modified (Req 6.5) + +### Integration Tests + +- **End-to-end sync with drift checker**: Mock Ivanti API, run full sync pipeline, verify anomaly log and BU history tables are populated correctly +- **API endpoint responses**: Seed database, call each endpoint, verify response shape and content + +### Test File Locations + +- `backend/__tests__/bu-drift-classification.property.test.js` — Properties 1–6 +- `backend/__tests__/anomaly-api.property.test.js` — Properties 7–12 +- `backend/__tests__/sync-anomaly-detection.test.js` — Unit and integration tests +- `frontend/src/components/pages/__tests__/AnomalyBanner.test.js` — UI component tests diff --git a/.kiro/specs/sync-anomaly-detection/requirements.md b/.kiro/specs/sync-anomaly-detection/requirements.md new file mode 100644 index 0000000..a8d0c36 --- /dev/null +++ b/.kiro/specs/sync-anomaly-detection/requirements.md @@ -0,0 +1,112 @@ +# Requirements Document + +## Introduction + +The Sync Anomaly Detection and BU Drift Monitoring feature extends the Ivanti sync pipeline to automatically classify why findings disappear from sync results. The current archive system detects disappearances but cannot distinguish between BU reassignment, severity score drift, and host decommission. This feature adds three capabilities: post-sync BU drift spot-checks against the unfiltered Ivanti API, a structured sync anomaly summary that breaks down count changes by cause, and per-finding BU tracking that records the business unit on each cached finding and detects BU changes across syncs. Together, these close the visibility gap exposed by the April 2026 incident where 109 findings silently disappeared due to a bulk BU reassignment from NTS-AEO-STEAM to SDIT-CSD-ITLS-PIES. + +## Glossary + +- **Sync_Pipeline**: The existing Ivanti/RiskSense host finding sync process in `backend/routes/ivantiFindings.js` that fetches open and closed 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, cached in `ivanti_findings_cache`. +- **Archive_Detector**: The existing logic within the Sync_Pipeline that compares previous sync results against current results to identify disappeared and returned findings, writing to `ivanti_finding_archives` and `ivanti_archive_transitions`. +- **BU_Drift_Checker**: New post-sync logic that queries the Ivanti API without BU filters for a sample of archived findings to determine whether they were reassigned to a different business unit. +- **Anomaly_Summary**: A structured report generated after each sync that categorizes finding count changes by cause (BU reassignment, severity drift, closure, decommission) and stores the results for API retrieval and UI display. +- **Sync_Anomaly_Log**: A new database table (`ivanti_sync_anomaly_log`) that stores one row per sync cycle containing the anomaly summary breakdown and metadata. +- **Finding_BU_History**: A new database table (`ivanti_finding_bu_history`) that records BU changes detected on individual findings across syncs. +- **BU_Field**: The `assetCustomAttributes.1550_host_1` attribute on an Ivanti host finding that identifies the owning business unit (e.g., `NTS-AEO-STEAM`, `NTS-AEO-ACCESS-ENG`). +- **Anomaly_Banner**: A React UI component displayed on the Vulnerability Triage page that surfaces the most recent sync anomaly summary when significant count changes are detected. +- **Unfiltered_Query**: An Ivanti API call that searches by finding ID only, without BU or severity filters, used to determine the current BU and severity of a finding that disappeared from filtered results. +- **Spot_Check**: A targeted Unfiltered_Query for a batch of recently archived findings, performed after each sync to classify disappearance causes without querying every archived finding. + +## Requirements + +### Requirement 1: BU Drift Detection on Sync + +**User Story:** As a security analyst, I want the system to automatically check whether archived findings were reassigned to a different BU, so that I can distinguish BU reassignment from score drift or decommission without running manual diagnostic scripts. + +#### Acceptance Criteria + +1. WHEN the Sync_Pipeline completes a successful sync and new findings have been archived, THE BU_Drift_Checker SHALL query the Ivanti API using an Unfiltered_Query for the finding IDs of all newly archived findings from that sync cycle. +2. WHEN the Unfiltered_Query returns a finding with a BU_Field value different from `NTS-AEO-ACCESS-ENG` or `NTS-AEO-STEAM`, THE BU_Drift_Checker SHALL classify that finding as `bu_reassignment` and record the new BU value in the archive transition reason. +3. WHEN the Unfiltered_Query returns a finding with a severity below 8.5 and the BU_Field still matches `NTS-AEO-ACCESS-ENG` or `NTS-AEO-STEAM`, THE BU_Drift_Checker SHALL classify that finding as `severity_drift` and record the new severity in the archive transition. +4. WHEN the Unfiltered_Query does not return a finding at all, THE BU_Drift_Checker SHALL classify that finding as `decommissioned`. +5. WHEN the Unfiltered_Query returns a finding with a state of `Closed` and the BU_Field still matches the expected BUs, THE BU_Drift_Checker SHALL classify that finding as `closed_on_platform`. +6. IF the Unfiltered_Query fails due to an API error, THEN THE BU_Drift_Checker SHALL log the error and leave the archive transition reason as the existing default (`severity_score_drift`) without blocking the sync completion. +7. THE BU_Drift_Checker SHALL batch finding IDs into groups of 50 for the Unfiltered_Query to stay within Ivanti API limits. + +### Requirement 2: Sync Anomaly Summary + +**User Story:** As a security analyst, I want a post-sync summary that explains significant count changes, so that I have immediate visibility into what happened without manual investigation. + +#### Acceptance Criteria + +1. WHEN the Sync_Pipeline completes a successful sync, THE Anomaly_Summary SHALL compute the difference between the current open count and the previous open count, and between the current closed count and the previous closed count. +2. WHEN findings have been newly archived during the sync, THE Anomaly_Summary SHALL include a breakdown by classification: count of `bu_reassignment`, count of `severity_drift`, count of `closed_on_platform`, and count of `decommissioned`. +3. WHEN findings have transitioned from ARCHIVED to RETURNED during the sync, THE Anomaly_Summary SHALL include the count of returned findings. +4. THE Anomaly_Summary SHALL be stored as a row in the Sync_Anomaly_Log table with the sync timestamp, open count delta, closed count delta, classification breakdown as a JSON object, and the total number of newly archived findings. +5. WHEN a GET request is made to `/api/ivanti/findings/anomaly/latest`, THE Sync_Pipeline SHALL return the most recent Anomaly_Summary row. +6. WHEN a GET request is made to `/api/ivanti/findings/anomaly/history`, THE Sync_Pipeline SHALL return the last 30 Anomaly_Summary rows ordered by sync timestamp descending. +7. WHEN the total number of newly archived findings in a single sync exceeds 5, THE Anomaly_Summary SHALL flag the sync as `significant` in the stored record. + +### Requirement 3: Finding-Level BU Tracking + +**User Story:** As a security analyst, I want the system to store the BU on each cached finding and detect when a finding's BU changes across syncs, so that BU reassignment is tracked as a distinct event from disappearance. + +#### Acceptance Criteria + +1. THE Sync_Pipeline SHALL store the BU_Field value (`buOwnership`) on each finding in the `ivanti_findings_cache` JSON payload, preserving the value extracted from `assetCustomAttributes.1550_host_1`. +2. WHEN the Sync_Pipeline processes a sync result, THE Sync_Pipeline SHALL compare each finding's current BU_Field value against the previously cached BU_Field value for the same finding ID. +3. WHEN a finding's BU_Field value differs from the previously cached value and both values are non-empty, THE Sync_Pipeline SHALL insert a row into the Finding_BU_History table recording the finding_id, previous BU, new BU, and detection timestamp. +4. WHEN a GET request is made to `/api/ivanti/findings/bu-changes`, THE Sync_Pipeline SHALL return all Finding_BU_History rows ordered by detected_at descending. +5. WHEN a GET request is made to `/api/ivanti/findings/:findingId/bu-history`, THE Sync_Pipeline SHALL return the Finding_BU_History rows for the specified finding ordered by detected_at descending. +6. IF a finding appears in the sync results for the first time with no previously cached BU_Field value, THEN THE Sync_Pipeline SHALL store the BU_Field value without recording a BU change event. + +### Requirement 4: Database Schema for Anomaly and BU Tracking + +**User Story:** As a developer, I want the anomaly and BU tracking data stored in normalized SQLite tables, so that the data model supports efficient queries and integrates with the existing migration pattern. + +#### Acceptance Criteria + +1. THE migration script SHALL create an `ivanti_sync_anomaly_log` table with columns for id (autoincrement primary key), sync_timestamp (datetime), open_count_delta (integer), closed_count_delta (integer), newly_archived_count (integer), returned_count (integer), classification_json (text storing the breakdown object), is_significant (boolean integer), and created_at (datetime). +2. THE migration script SHALL create an `ivanti_finding_bu_history` table with columns for id (autoincrement primary key), finding_id (text), finding_title (text), host_name (text), previous_bu (text), new_bu (text), detected_at (datetime), and created_at (datetime). +3. THE migration script SHALL create an index on `ivanti_sync_anomaly_log(sync_timestamp)` for efficient latest-record queries. +4. THE migration script SHALL create an index on `ivanti_finding_bu_history(finding_id)` for efficient per-finding history lookups. +5. THE migration script SHALL create an index on `ivanti_finding_bu_history(detected_at)` for efficient chronological queries. +6. THE migration script SHALL be located at `backend/migrations/add_sync_anomaly_tables.js` and use `CREATE TABLE IF NOT EXISTS` and `CREATE INDEX IF NOT EXISTS` to be idempotent. + +### Requirement 5: Anomaly Banner UI + +**User Story:** As a security analyst, I want a banner on the Vulnerability Triage page that surfaces the latest sync anomaly summary, so that significant count changes are immediately visible without navigating to a separate page. + +#### Acceptance Criteria + +1. WHEN the Vulnerability Triage page loads, THE Anomaly_Banner SHALL fetch the latest Anomaly_Summary from `/api/ivanti/findings/anomaly/latest`. +2. WHEN the latest Anomaly_Summary has `is_significant` set to true, THE Anomaly_Banner SHALL display a warning banner showing the total count change and the classification breakdown (e.g., "45 findings archived — 38 BU reassignment, 5 closed, 2 decommissioned"). +3. WHEN the latest Anomaly_Summary has `is_significant` set to false, THE Anomaly_Banner SHALL not display a banner. +4. THE Anomaly_Banner SHALL include a dismiss button that hides the banner for the current session. +5. THE Anomaly_Banner SHALL use amber (#F59E0B) background tint and the AlertTriangle icon from Lucide, consistent with the existing dashboard warning patterns. +6. THE Anomaly_Banner SHALL use monospace typography and the dark theme color palette defined in DESIGN_SYSTEM.md. +7. WHEN the user clicks the classification breakdown text in the Anomaly_Banner, THE Anomaly_Banner SHALL expand to show a detailed list of affected findings grouped by classification. + +### Requirement 6: Archive Transition Reason Enhancement + +**User Story:** As a security analyst, I want archive transitions to record the specific reason a finding disappeared (BU reassignment, severity drift, decommission, closure), so that the archive history provides actionable context instead of a generic "severity_score_drift" label. + +#### Acceptance Criteria + +1. WHEN the BU_Drift_Checker classifies a newly archived finding as `bu_reassignment`, THE Archive_Detector SHALL update the corresponding `ivanti_archive_transitions` row's reason field to `bu_reassignment:` where `` is the BU the finding was reassigned to. +2. WHEN the BU_Drift_Checker classifies a newly archived finding as `severity_drift`, THE Archive_Detector SHALL update the corresponding `ivanti_archive_transitions` row's reason field to `severity_drift:` where `` is the finding's current severity score. +3. WHEN the BU_Drift_Checker classifies a newly archived finding as `decommissioned`, THE Archive_Detector SHALL update the corresponding `ivanti_archive_transitions` row's reason field to `decommissioned`. +4. WHEN the BU_Drift_Checker classifies a newly archived finding as `closed_on_platform`, THE Archive_Detector SHALL update the corresponding `ivanti_archive_transitions` row's reason field to `closed_on_platform`. +5. THE existing archive transition rows with reason `severity_score_drift` SHALL remain unchanged — the enhanced reasons apply only to transitions created after this feature is deployed. + +### Requirement 7: Anomaly History API for Trend Analysis + +**User Story:** As a security analyst, I want to view anomaly history over time, so that I can identify patterns in BU reassignments and score drift across multiple sync cycles. + +#### Acceptance Criteria + +1. WHEN a GET request is made to `/api/ivanti/findings/anomaly/history` with optional query parameters `from` and `to` (ISO date strings), THE Sync_Pipeline SHALL return Anomaly_Summary rows within the specified date range. +2. WHEN no `from` or `to` parameters are provided, THE Sync_Pipeline SHALL return the last 30 Anomaly_Summary rows. +3. WHEN an unauthenticated request is made to any anomaly or BU history endpoint, THE Sync_Pipeline SHALL return a 401 status code. +4. THE anomaly history response SHALL include each row's sync_timestamp, open_count_delta, closed_count_delta, newly_archived_count, returned_count, classification_json (parsed as an object), and is_significant flag. diff --git a/.kiro/specs/sync-anomaly-detection/tasks.md b/.kiro/specs/sync-anomaly-detection/tasks.md new file mode 100644 index 0000000..f0c28b6 --- /dev/null +++ b/.kiro/specs/sync-anomaly-detection/tasks.md @@ -0,0 +1,178 @@ +# Implementation Plan: Sync Anomaly Detection and BU Drift Monitoring + +## Overview + +This plan implements the sync anomaly detection feature in incremental steps: database migration first, then core classification and summary logic, BU tracking in the sync pipeline, new API endpoints, archive transition enhancement, and finally the React anomaly banner. Each task builds on the previous, with property-based tests validating correctness properties from the design document. + +## Tasks + +- [x] 1. Create database migration script + - [x] 1.1 Create `backend/migrations/add_sync_anomaly_tables.js` + - Create `ivanti_sync_anomaly_log` table with columns: id (autoincrement PK), sync_timestamp (datetime NOT NULL DEFAULT CURRENT_TIMESTAMP), open_count_delta (integer NOT NULL DEFAULT 0), closed_count_delta (integer NOT NULL DEFAULT 0), newly_archived_count (integer NOT NULL DEFAULT 0), returned_count (integer NOT NULL DEFAULT 0), classification_json (text NOT NULL DEFAULT '{}'), is_significant (integer NOT NULL DEFAULT 0), created_at (datetime DEFAULT CURRENT_TIMESTAMP) + - Create `ivanti_finding_bu_history` table with columns: id (autoincrement PK), finding_id (text NOT NULL), finding_title (text NOT NULL DEFAULT ''), host_name (text NOT NULL DEFAULT ''), previous_bu (text NOT NULL), new_bu (text NOT NULL), detected_at (datetime NOT NULL DEFAULT CURRENT_TIMESTAMP), created_at (datetime DEFAULT CURRENT_TIMESTAMP) + - Create index `idx_anomaly_sync_timestamp` on `ivanti_sync_anomaly_log(sync_timestamp)` + - Create index `idx_bu_history_finding_id` on `ivanti_finding_bu_history(finding_id)` + - Create index `idx_bu_history_detected_at` on `ivanti_finding_bu_history(detected_at)` + - Use `CREATE TABLE IF NOT EXISTS` and `CREATE INDEX IF NOT EXISTS` for idempotency + - Follow the standalone Node script pattern from `add_closed_gone_state.js` (open DB directly, promise-based helpers, run/all wrappers) + - _Requirements: 4.1, 4.2, 4.3, 4.4, 4.5, 4.6_ + + - [x] 1.2 Run migration and verify tables exist + - Execute `node backend/migrations/add_sync_anomaly_tables.js` + - Verify both tables and all three indexes were created + - Run migration a second time to confirm idempotency (no errors on re-run) + - _Requirements: 4.6_ + +- [x] 2. Implement BU drift classifier and batch logic + - [x] 2.1 Implement `runBUDriftChecker` function in `backend/routes/ivantiFindings.js` + - Add `runBUDriftChecker(db, newlyArchivedIds, apiKey, clientId, skipTls)` async function + - Chunk `newlyArchivedIds` into batches of 50 + - For each batch, call `ivantiPost()` with a filter on `id` field only (no BU, severity, or state filters) using the unfiltered query pattern from `bu-reassignment-check.js` + - Classify each finding: BU not in {NTS-AEO-ACCESS-ENG, NTS-AEO-STEAM} → `bu_reassignment`; BU matches + severity < 8.5 → `severity_drift`; BU matches + state Closed → `closed_on_platform`; not found → `decommissioned` + - Update the corresponding `ivanti_archive_transitions` row's reason field with the formatted classification + - Return classification summary object: `{ bu_reassignment: N, severity_drift: N, closed_on_platform: N, decommissioned: N }` + - Wrap each batch in try/catch — log errors, skip failed batches, never throw + - _Requirements: 1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 1.7_ + + - [ ]* 2.2 Write property test for classification correctness + - **Property 1: Classification correctness** + - Generate random {bu, severity, state, found} tuples, verify classifier produces the correct classification based on BU value, severity threshold (8.5), and state + - **Validates: Requirements 1.2, 1.3, 1.4, 1.5** + + - [ ]* 2.3 Write property test for archive transition reason formatting + - **Property 2: Archive transition reason formatting** + - Generate random classification results with BU values and severity scores, verify reason string format: `bu_reassignment:B`, `severity_drift:S`, `closed_on_platform`, `decommissioned` + - **Validates: Requirements 6.1, 6.2, 6.3, 6.4** + + - [ ]* 2.4 Write property test for batch size constraint + - **Property 3: Batch size constraint** + - Generate random-length ID arrays (0 to 500), verify chunking produces ceil(N/50) batches, each batch has at most 50 IDs, and the union of all batches equals the original list + - **Validates: Requirements 1.7** + +- [x] 3. Implement anomaly summary computation + - [x] 3.1 Implement `computeAnomalySummary` function in `backend/routes/ivantiFindings.js` + - Add `computeAnomalySummary(db, openCountDelta, closedCountDelta, newlyArchivedCount, returnedCount, classificationBreakdown)` async function + - Compute `is_significant`: true if `newlyArchivedCount > 5` + - Insert a row into `ivanti_sync_anomaly_log` with all fields + - Log the summary to console + - Wrap in try/catch — log errors, never throw (anomaly summary is informational) + - _Requirements: 2.1, 2.4, 2.7_ + + - [ ]* 3.2 Write property test for significance threshold + - **Property 4: Significance threshold** + - Generate random non-negative integers for `newly_archived_count`, verify `is_significant` is true if and only if `newly_archived_count > 5` + - **Validates: Requirements 2.7** + + - [ ]* 3.3 Write property test for count delta computation + - **Property 5: Count delta computation** + - Generate random pairs of non-negative integers (previous_count, current_count), verify delta equals `current_count - previous_count` for both open and closed counts + - **Validates: Requirements 2.1** + +- [x] 4. Checkpoint — Verify core logic + - Ensure all tests pass, ask the user if questions arise. + +- [x] 5. Integrate BU comparison into syncFindings + - [x] 5.1 Add per-finding BU comparison logic to `syncFindings()` in `backend/routes/ivantiFindings.js` + - After reading `previousFindings` and before writing the new cache, compare each finding's `buOwnership` against the previous finding's `buOwnership` + - When both values are non-empty and differ, insert a row into `ivanti_finding_bu_history` with finding_id, finding_title, host_name, previous_bu, new_bu, and detected_at + - When a finding appears for the first time (no previous entry), store the BU without recording a change event + - Wrap in try/catch per finding — log errors, continue processing remaining findings + - _Requirements: 3.1, 3.2, 3.3, 3.6_ + + - [ ]* 5.2 Write property test for BU extraction preservation + - **Property 6: BU extraction preservation** + - Generate random raw Ivanti finding objects with varying `assetCustomAttributes['1550_host_1']` arrays, verify `extractFinding` produces a finding whose `buOwnership` equals the first element of that array + - **Validates: Requirements 3.1** + + - [ ]* 5.3 Write property test for BU change detection + - **Property 7: BU change detection and recording** + - Generate random previous/current finding pairs with varying BU values (same, different, empty), verify that a BU history row is inserted only when both values are non-empty and differ + - **Validates: Requirements 3.2, 3.3, 3.6** + +- [x] 6. Wire drift checker and anomaly summary into sync pipeline + - [x] 6.1 Integrate `runBUDriftChecker` and `computeAnomalySummary` into `syncFindings()` flow + - Collect `newlyArchivedIds` from `detectArchiveChanges` (modify it to return the list of disappeared IDs) + - Collect `returnedCount` from `detectArchiveChanges` (count of ARCHIVED → RETURNED transitions) + - After `detectClosedGoneFindings`, call `runBUDriftChecker` with the newly archived IDs + - Compute `openCountDelta` and `closedCountDelta` by comparing current counts against previous counts from `ivanti_counts_cache` + - Call `computeAnomalySummary` with all collected metrics + - Wrap both calls in try/catch — failures are non-fatal and must not block sync completion + - Export `runBUDriftChecker`, `computeAnomalySummary`, and `extractFinding` from the module for testing + - _Requirements: 1.1, 2.1, 2.2, 2.3, 2.4, 2.7_ + +- [x] 7. Checkpoint — Verify pipeline integration + - Ensure all tests pass, ask the user if questions arise. + +- [x] 8. Add anomaly and BU history API endpoints + - [x] 8.1 Add `GET /anomaly/latest` endpoint to `createIvantiFindingsRouter` + - Query `ivanti_sync_anomaly_log` for the row with the maximum `sync_timestamp` + - Parse `classification_json` into an object in the response + - Return `{ anomaly: row }` or `{ anomaly: null }` if no records exist + - _Requirements: 2.5_ + + - [x] 8.2 Add `GET /anomaly/history` endpoint to `createIvantiFindingsRouter` + - Accept optional `from` and `to` query parameters (ISO date strings) + - If date params provided, filter by `sync_timestamp` range (inclusive) + - If no date params, return last 30 rows ordered by `sync_timestamp` descending + - Parse `classification_json` into an object for each row + - Return each row with: sync_timestamp, open_count_delta, closed_count_delta, newly_archived_count, returned_count, classification (parsed object), is_significant + - _Requirements: 2.6, 7.1, 7.2, 7.3, 7.4_ + + - [x] 8.3 Add `GET /bu-changes` endpoint to `createIvantiFindingsRouter` + - Query all rows from `ivanti_finding_bu_history` ordered by `detected_at` descending + - Return `{ changes: rows }` + - _Requirements: 3.4_ + + - [x] 8.4 Add `GET /:findingId/bu-history` endpoint to `createIvantiFindingsRouter` + - Query `ivanti_finding_bu_history` where `finding_id` matches the URL param, ordered by `detected_at` descending + - Return `{ finding_id, history: rows }` + - Place this route definition carefully to avoid conflicts with existing `/:findingId/note` and `/:findingId/override` routes + - _Requirements: 3.5_ + + - [ ]* 8.5 Write property tests for API query behaviors + - **Property 8: Latest anomaly returns most recent** — Generate random sequences of anomaly rows with distinct timestamps, verify `/anomaly/latest` returns the row with the maximum timestamp + - **Property 9: Anomaly history ordering and limit** — Generate random sets of N anomaly rows, verify `/anomaly/history` returns min(N, 30) rows ordered by timestamp descending + - **Property 10: Date-range filtering with complete response shape** — Generate random date ranges and anomaly rows, verify only rows within range are returned with correct fields + - **Property 11: BU changes endpoint ordering** — Generate random BU change rows, verify `/bu-changes` returns all rows ordered by `detected_at` descending + - **Property 12: Per-finding BU history filtering** — Generate random BU history rows across multiple findings, verify `/:findingId/bu-history` returns only matching rows ordered by `detected_at` descending + - **Validates: Requirements 2.5, 2.6, 3.4, 3.5, 7.1, 7.2, 7.4** + +- [x] 9. Checkpoint — Verify endpoints and properties + - Ensure all tests pass, ask the user if questions arise. + +- [x] 10. Implement Anomaly Banner UI component + - [x] 10.1 Create `frontend/src/components/pages/AnomalyBanner.js` + - Fetch latest anomaly summary from `/api/ivanti/findings/anomaly/latest` on mount + - If `is_significant` is false or no anomaly exists, render nothing + - If `is_significant` is true, render a warning banner with: + - Amber background tint (`rgba(245, 158, 11, 0.15)`) with amber border (`rgba(245, 158, 11, 0.3)`) + - `AlertTriangle` icon from lucide-react + - Summary text showing total count change and classification breakdown (e.g., "45 findings archived — 38 BU reassignment, 5 severity drift, 2 decommissioned") + - Expandable detail section (click to toggle) for affected findings grouped by classification + - Dismiss button (X icon) that hides the banner for the current session via `useState` + - Use monospace typography (`JetBrains Mono`) and dark theme colors per `DESIGN_SYSTEM.md` + - Match the inline style pattern used by `IvantiCountsChart.js` + - _Requirements: 5.1, 5.2, 5.3, 5.4, 5.5, 5.6, 5.7_ + + - [x] 10.2 Integrate `AnomalyBanner` into the Vulnerability Triage page + - Import and render `AnomalyBanner` above the `IvantiCountsChart` component on the Vulnerability Triage page + - _Requirements: 5.1_ + +- [x] 11. Enhance archive transition reasons + - [x] 11.1 Verify archive transition reason updates from `runBUDriftChecker` + - Confirm that `runBUDriftChecker` (task 2.1) correctly updates `ivanti_archive_transitions.reason` with formatted values: `bu_reassignment:`, `severity_drift:`, `closed_on_platform`, `decommissioned` + - Confirm existing rows with `severity_score_drift` are not modified — enhanced reasons apply only to new transitions + - _Requirements: 6.1, 6.2, 6.3, 6.4, 6.5_ + +- [x] 12. Final checkpoint — Ensure all tests pass + - 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 after each major integration point +- Property tests validate the 12 correctness properties defined in the design document +- The migration must be run before any other tasks (task 1) +- All new functions are added to the existing `ivantiFindings.js` module — no new route files +- The BU comparison in `syncFindings` (task 5) must happen before the cache is overwritten, which is the only point where both old and new BU values are in memory diff --git a/.kiro/specs/user-profile/.config.kiro b/.kiro/specs/user-profile/.config.kiro new file mode 100644 index 0000000..48b4988 --- /dev/null +++ b/.kiro/specs/user-profile/.config.kiro @@ -0,0 +1 @@ +{"specId": "74f6201d-ed0f-4df3-86a2-4a0767dd497c", "workflowType": "requirements-first", "specType": "feature"} \ No newline at end of file diff --git a/.kiro/specs/user-profile/design.md b/.kiro/specs/user-profile/design.md new file mode 100644 index 0000000..dd102ef --- /dev/null +++ b/.kiro/specs/user-profile/design.md @@ -0,0 +1,361 @@ +# Design Document: User Profile + +## Overview + +This feature adds a self-service user profile to the STEAM Security Dashboard. It introduces three capabilities: a profile panel accessible from the UserMenu dropdown (displaying account details and a password change form), a dedicated backend API endpoint for fetching profile data, and visual fixes to the UserMenu component to match the dark dashboard theme. + +The scope is intentionally narrow — no new database tables or migrations are required. The existing `users` table already stores all needed fields (`username`, `email`, `user_group`, `created_at`, `last_login`). The backend adds two new routes to the existing auth router. The frontend adds one new component (`UserProfilePanel`) and modifies the existing `UserMenu` component for theming and profile access. + +### Key Design Decisions + +1. **Profile endpoint on the auth router** — The profile data is user-scoped and session-authenticated, so it belongs alongside `/api/auth/me` rather than on the admin-only `/api/users` router. This avoids granting non-admin users access to the users management routes. + +2. **Password change on the auth router** — Self-service password change is an auth concern, not a user-management concern. Placing it at `POST /api/auth/change-password` keeps it separate from the admin `PATCH /api/users/:id` endpoint that already supports admin-initiated password resets. + +3. **Rate limiting via express-rate-limit** — The project already uses `express-rate-limit` for login throttling. The password change endpoint reuses the same library with a tighter limit (5 attempts per 15 minutes) scoped per session cookie using a custom `keyGenerator`. + +4. **No new database tables or migrations** — All required data already exists in the `users` table. The `created_at` and `last_login` columns are present in the schema. No schema changes are needed. + +5. **Modal-based profile panel** — The profile panel is implemented as a modal overlay (consistent with existing modals like NvdSyncModal, UserManagement) rather than a separate page, since the app uses no client-side router. + +6. **Inline style objects for theming** — Consistent with the project's existing pattern where components define style constants as JavaScript objects. The UserMenu theming fix converts Tailwind utility classes to inline styles matching the design system. + +--- + +## Architecture + +```mermaid +sequenceDiagram + participant U as User + participant UM as UserMenu + participant UPP as UserProfilePanel + participant API as Auth API + participant DB as SQLite + + U->>UM: Clicks "My Profile" + UM->>UPP: Opens modal (showProfile=true) + UPP->>API: GET /api/auth/profile + API->>DB: SELECT user by session + DB-->>API: User row + API-->>UPP: { id, username, email, group, created_at, last_login } + UPP-->>U: Displays profile data + + U->>UPP: Submits password change form + UPP->>UPP: Client-side validation (match, length) + UPP->>API: POST /api/auth/change-password + API->>API: Rate limit check (5/15min) + API->>DB: SELECT password_hash WHERE id = ? + DB-->>API: password_hash + API->>API: bcrypt.compare(currentPassword, hash) + API->>API: bcrypt.hash(newPassword, 10) + API->>DB: UPDATE users SET password_hash = ? + API->>DB: INSERT audit_logs (password_change) + API-->>UPP: { message: 'Password changed successfully' } + UPP-->>U: Success message, form cleared +``` + +### Component Hierarchy + +``` +App.js +├── UserMenu.js (modified — dark theme, "My Profile" option) +│ └── UserProfilePanel.js (new — modal component) +│ ├── Profile Info Section +│ └── Password Change Form +``` + +### Backend Route Structure + +``` +/api/auth/ +├── POST /login (existing) +├── POST /logout (existing) +├── GET /me (existing) +├── GET /profile (new — full profile data) +├── POST /change-password (new — self-service password change) +└── POST /cleanup-sessions (existing) +``` + +--- + +## Components and Interfaces + +### Backend: New Routes in `routes/auth.js` + +#### `GET /api/auth/profile` + +Returns the full profile for the authenticated user. + +| Aspect | Detail | +|--------|--------| +| Auth | `requireAuth(db)` | +| Query | `SELECT id, username, email, user_group, created_at, last_login FROM users WHERE id = ? AND is_active = 1` | +| Success | `200 { id, username, email, group, created_at, last_login }` | +| Inactive account | `401 { error }` + clear session cookie | +| No session | `401 { error: 'Authentication required' }` | + +#### `POST /api/auth/change-password` + +Allows the authenticated user to change their own password. + +| Aspect | Detail | +|--------|--------| +| Auth | `requireAuth(db)` | +| Rate limit | 5 requests per 15 minutes, keyed by `req.cookies.session_id` | +| Body | `{ currentPassword: string, newPassword: string }` | +| Validation | `newPassword.length >= 8` (server-side) | +| Flow | 1. Verify account active 2. `bcrypt.compare` current password 3. `bcrypt.hash` new password (cost 10) 4. `UPDATE users SET password_hash` 5. `logAudit` with action `password_change` | +| Success | `200 { message: 'Password changed successfully' }` | +| Wrong password | `401 { error: 'Current password is incorrect' }` | +| Too short | `400 { error: 'New password must be at least 8 characters' }` | +| Rate limited | `429 { error: 'Too many password change attempts. Please try again later.' }` | +| Inactive | `401 { error: 'Account is disabled' }` | + +### Frontend: New Component `UserProfilePanel.js` + +A modal component rendered when the user clicks "My Profile" in the UserMenu dropdown. + +**Props:** + +| Prop | Type | Description | +|------|------|-------------| +| `isOpen` | `boolean` | Controls modal visibility | +| `onClose` | `function` | Callback to close the modal | + +**Internal State:** + +| State | Type | Purpose | +|-------|------|---------| +| `profile` | `object \| null` | Profile data from API | +| `loading` | `boolean` | Loading state for profile fetch | +| `error` | `string \| null` | Error message from profile fetch | +| `currentPassword` | `string` | Current password field | +| `newPassword` | `string` | New password field | +| `confirmPassword` | `string` | Confirm password field | +| `changeLoading` | `boolean` | Loading state for password change | +| `changeError` | `string \| null` | Error from password change API | +| `changeSuccess` | `string \| null` | Success message after password change | + +**Behavior:** +- On open (`isOpen` transitions to `true`), fetches `GET /api/auth/profile` +- Displays profile fields in a read-only info section +- Password change form validates client-side before submitting: + - New password and confirm must match + - New password must be >= 8 characters +- On successful password change, shows success message and clears form fields +- Click-outside or X button closes the modal +- Uses design system dark theme styling (intel-card background, accent borders, light text) + +### Frontend: Modified Component `UserMenu.js` + +**Changes:** +1. Add "My Profile" menu item with `User` icon between the dropdown header and admin actions +2. Convert all Tailwind light-theme classes to inline dark-theme styles: + - Button: `hover:bg-gray-100` → `hover: rgba(14, 165, 233, 0.1)` + - Username text: `text-gray-900` → `color: var(--text-primary)` / `#F8FAFC` + - Group text: `text-gray-500` → `color: var(--text-secondary)` / `#E2E8F0` + - Chevron: `text-gray-500` → `color: var(--text-secondary)` / `#E2E8F0` + - Dropdown panel: `bg-white` → intel-card gradient background + - Dropdown border: `border-gray-200` → `rgba(14, 165, 233, 0.3)` + - Menu items: `text-gray-700` → `color: var(--text-primary)` / `#F8FAFC` + - Menu hover: `hover:bg-gray-50` → `rgba(14, 165, 233, 0.1)` + - Sign out: `text-red-600` → `#F87171` (design system danger text) +3. Add state and handler for profile panel visibility +4. Render `UserProfilePanel` component + +--- + +## Data Models + +No new database tables or migrations are required. The feature reads from the existing `users` table: + +```sql +-- Existing users table (relevant columns) +CREATE TABLE users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username VARCHAR(50) UNIQUE NOT NULL, + email VARCHAR(255) UNIQUE NOT NULL, + password_hash VARCHAR(255) NOT NULL, + role VARCHAR(20) NOT NULL DEFAULT 'viewer', + user_group VARCHAR(20) NOT NULL DEFAULT 'Read_Only', + is_active BOOLEAN DEFAULT 1, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + last_login TIMESTAMP +); +``` + +### API Response Shapes + +**GET /api/auth/profile response:** +```json +{ + "id": 1, + "username": "admin", + "email": "admin@localhost", + "group": "Admin", + "created_at": "2026-01-15 10:30:00", + "last_login": "2026-07-20 14:22:00" +} +``` + +**POST /api/auth/change-password request:** +```json +{ + "currentPassword": "oldpass123", + "newPassword": "newpass456" +} +``` + +**POST /api/auth/change-password success response:** +```json +{ + "message": "Password changed successfully" +} +``` + +**Audit log entry for password change:** +```json +{ + "userId": 1, + "username": "admin", + "action": "password_change", + "entityType": "auth", + "entityId": null, + "details": null, + "ipAddress": "::1" +} +``` + + +--- + +## 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: Profile panel displays all required fields + +*For any* valid profile object (with arbitrary username, email, group, created_at, and last_login values), rendering the UserProfilePanel with that data SHALL result in all five field values being present in the rendered output. + +**Validates: Requirements 1.2** + +### Property 2: Profile API returns complete user data matching database + +*For any* active user record in the database, a GET request to `/api/auth/profile` with that user's valid session SHALL return an object containing `id`, `username`, `email`, `group`, `created_at`, and `last_login` fields, where each value matches the corresponding column in the `users` table. + +**Validates: Requirements 4.1** + +### Property 3: Password change round-trip + +*For any* valid current password and *any* new password of 8 or more characters, after a successful `POST /api/auth/change-password`, the stored `password_hash` in the database SHALL be a valid bcrypt hash and `bcrypt.compare(newPassword, storedHash)` SHALL return `true`. + +**Validates: Requirements 2.2, 2.7** + +### Property 4: Incorrect current password is always rejected + +*For any* password string that does not match the user's current password, submitting it as `currentPassword` to `POST /api/auth/change-password` SHALL return HTTP 401 and the user's stored `password_hash` SHALL remain unchanged. + +**Validates: Requirements 2.3** + +### Property 5: Mismatched password confirmation is rejected client-side + +*For any* two distinct strings used as `newPassword` and `confirmPassword` in the Password_Change_Form, the form SHALL display a validation error and SHALL NOT submit a request to the Auth_API. + +**Validates: Requirements 2.4** + +### Property 6: Short passwords are rejected at both client and server + +*For any* string of length 0 through 7, the Password_Change_Form SHALL display a minimum-length validation error (client-side), and `POST /api/auth/change-password` SHALL return HTTP 400 (server-side). In both cases, the user's stored `password_hash` SHALL remain unchanged. + +**Validates: Requirements 2.5, 5.4** + +--- + +## Error Handling + +### Backend Errors + +| Scenario | HTTP Status | Response | Behavior | +|----------|-------------|----------|----------| +| No session cookie on `/profile` | 401 | `{ error: 'Authentication required' }` | Handled by `requireAuth` middleware | +| Expired session on `/profile` | 401 | `{ error: 'Session expired or invalid' }` | Handled by `requireAuth` middleware | +| Deactivated account on `/profile` | 401 | `{ error: 'Account is disabled' }` | Clear session cookie, return 401 | +| No session on `/change-password` | 401 | `{ error: 'Authentication required' }` | Handled by `requireAuth` middleware | +| Rate limit exceeded on `/change-password` | 429 | `{ error: 'Too many password change attempts. Please try again later.' }` | `express-rate-limit` middleware | +| Missing `currentPassword` or `newPassword` | 400 | `{ error: 'Current password and new password are required' }` | Early return validation | +| New password < 8 characters | 400 | `{ error: 'New password must be at least 8 characters' }` | Early return validation | +| Incorrect current password | 401 | `{ error: 'Current password is incorrect' }` | After `bcrypt.compare` fails | +| Deactivated account on `/change-password` | 401 | `{ error: 'Account is disabled' }` | Check `is_active` before processing | +| Database error during profile fetch | 500 | `{ error: 'Failed to fetch profile' }` | Catch block, log to console | +| Database error during password update | 500 | `{ error: 'Failed to change password' }` | Catch block, log to console | + +### Frontend Error Handling + +| Scenario | Behavior | +|----------|----------| +| Profile fetch fails (network error) | Display error message in panel, offer retry | +| Profile fetch returns 401 | Redirect to login (session expired) | +| Password change returns 401 (wrong password) | Display "Current password is incorrect" in form | +| Password change returns 429 | Display "Too many attempts. Please try again later." in form | +| Password change returns 400 | Display server validation error message in form | +| Password change returns 500 | Display "An error occurred. Please try again." in form | +| Client-side validation failure (mismatch) | Display "Passwords do not match" below confirm field | +| Client-side validation failure (too short) | Display "Password must be at least 8 characters" below new password field | + +--- + +## Testing Strategy + +### Property-Based Tests + +This feature is suitable for property-based testing. The password change logic involves pure input/output behavior (password validation, hashing, comparison) with a large input space (arbitrary strings). The profile data retrieval has clear invariants (all fields present, values match database). + +**Library:** [fast-check](https://github.com/dubzzz/fast-check) — the standard PBT library for JavaScript. + +**Configuration:** +- Minimum 100 iterations per property test +- Each test tagged with: `Feature: user-profile, Property {number}: {property_text}` + +**Properties to implement:** +1. Profile panel renders all fields (Property 1) — generate random profile objects, render component, assert all values present +2. Profile API returns complete data (Property 2) — generate random user records, insert into test DB, fetch profile, assert field match +3. Password change round-trip (Property 3) — generate random valid passwords, change password, verify bcrypt.compare succeeds +4. Wrong password rejection (Property 4) — generate random wrong passwords, attempt change, verify 401 and hash unchanged +5. Mismatched confirmation rejection (Property 5) — generate pairs of distinct strings, verify client validation rejects +6. Short password rejection (Property 6) — generate strings of length 0-7, verify rejection at both client and server + +### Unit Tests (Example-Based) + +| Test | What it verifies | +|------|-----------------| +| "My Profile" click opens panel | Requirement 1.1 — UI interaction | +| Close button dismisses panel | Requirement 1.3 — close mechanism | +| Click-outside dismisses panel | Requirement 1.3 — close mechanism | +| Password form has three fields | Requirement 2.1 — form structure | +| Success message shown after change | Requirement 2.6 — success feedback | +| Form fields cleared after success | Requirement 2.6 — form reset | +| Unauthenticated profile request returns 401 | Requirement 4.2 — auth guard | +| Deactivated account profile request returns 401 | Requirement 4.3 — account check | +| Deactivated account password change rejected | Requirement 5.3 — account check | +| Rate limit triggers after 5 attempts | Requirements 5.1, 5.2 — rate limiting | + +### Integration Tests + +| Test | What it verifies | +|------|-----------------| +| Audit log entry created on password change | Requirement 2.8 — audit logging | +| Rate limiter resets after 15-minute window | Requirement 5.1 — rate limit window | + +### Manual Testing + +| Test | What it verifies | +|------|-----------------| +| Username visible on dark header without hover | Requirement 3.1 — WCAG AA contrast | +| Group label visible on dark header | Requirement 3.2 — contrast | +| Hover state uses dark-themed highlight | Requirement 3.3 — theming | +| Chevron icon uses light color | Requirement 3.4 — theming | +| Dropdown uses dark background | Requirement 6.1 — theming | +| Dropdown text uses light colors | Requirement 6.2 — theming | +| Dropdown hover uses accent highlight | Requirement 6.3 — theming | +| Dropdown border uses accent style | Requirement 6.4 — theming | +| Group badge retains color coding | Requirement 6.5 — theming | diff --git a/.kiro/specs/user-profile/requirements.md b/.kiro/specs/user-profile/requirements.md new file mode 100644 index 0000000..db99c5a --- /dev/null +++ b/.kiro/specs/user-profile/requirements.md @@ -0,0 +1,87 @@ +# Requirements Document + +## Introduction + +The STEAM Security Dashboard currently lacks a self-service user profile. Users cannot view their own account details or change their own password — only admins can reset passwords through the User Management panel. Additionally, the username text in the top-right UserMenu is rendered in black (`text-gray-900`) against the dark dashboard background, making it invisible until hovered. This feature adds a user profile panel accessible from the UserMenu, enables self-service password changes for all authenticated users, and fixes the username visibility issue in the header. + +## Glossary + +- **Dashboard**: The STEAM Security Dashboard application, consisting of a React 19 SPA frontend and a Node.js/Express backend with SQLite3 storage. +- **User_Profile_Panel**: A modal or slide-over panel that displays the authenticated user's account information and provides a password change form. +- **UserMenu**: The existing dropdown component (`UserMenu.js`) in the top-right corner of the header that shows the user icon, username, group badge, and navigation actions (Manage Users, Audit Log, Sign Out). +- **Password_Change_Form**: A form within the User_Profile_Panel that accepts the current password and a new password (with confirmation) to allow users to change their own credentials. +- **Auth_API**: The backend Express routes under `/api/auth` that handle login, logout, session validation, and (with this feature) self-service password changes. +- **Authenticated_User**: Any user with a valid, non-expired session cookie and an active account. +- **Header_Username_Display**: The text element in the UserMenu button that shows the current user's username and group label. + +## Requirements + +### Requirement 1: User Profile Panel Access + +**User Story:** As an authenticated user, I want to access a profile panel from the UserMenu dropdown, so that I can view my account details without needing admin assistance. + +#### Acceptance Criteria + +1. WHEN the Authenticated_User clicks the "My Profile" option in the UserMenu dropdown, THE Dashboard SHALL display the User_Profile_Panel. +2. THE User_Profile_Panel SHALL display the following account fields: username, email address, user group, account creation date, and last login timestamp. +3. WHEN the User_Profile_Panel is open, THE Dashboard SHALL provide a visible close mechanism (close button or click-outside) to dismiss the panel. +4. THE User_Profile_Panel SHALL use the dark theme styling defined in the Dashboard design system (intel-card backgrounds, accent borders, light text colors). + +### Requirement 2: Self-Service Password Change + +**User Story:** As an authenticated user, I want to change my own password from my profile, so that I can maintain my account security without requesting an admin to reset it. + +#### Acceptance Criteria + +1. THE User_Profile_Panel SHALL include a Password_Change_Form with three fields: current password, new password, and confirm new password. +2. WHEN the Authenticated_User submits the Password_Change_Form with a valid current password and matching new password fields, THE Auth_API SHALL update the password hash for that user in the database. +3. WHEN the Authenticated_User submits the Password_Change_Form with an incorrect current password, THE Auth_API SHALL return an error and THE Password_Change_Form SHALL display a message stating the current password is incorrect. +4. WHEN the new password and confirm new password fields do not match, THE Password_Change_Form SHALL display a validation error before submitting to the Auth_API. +5. WHEN the new password is fewer than 8 characters, THE Password_Change_Form SHALL display a validation error stating the minimum length requirement. +6. WHEN a password change succeeds, THE Dashboard SHALL display a success confirmation message and clear the Password_Change_Form fields. +7. THE Auth_API SHALL hash the new password using bcryptjs before storing it in the database. +8. WHEN a password change succeeds, THE Auth_API SHALL log an audit entry with action `password_change` for the Authenticated_User. + +### Requirement 3: Header Username Visibility Fix + +**User Story:** As a user, I want to see my username in the top-right header area at all times, so that I can confirm which account is logged in without hovering. + +#### Acceptance Criteria + +1. THE Header_Username_Display SHALL render the username text using a light color (design system `--text-primary` or equivalent) that meets WCAG AA contrast ratio against the dark header background. +2. THE Header_Username_Display SHALL render the group label text using a secondary light color (design system `--text-secondary` or equivalent) that is visible against the dark header background. +3. THE UserMenu button hover state SHALL use a dark-themed highlight (e.g., `rgba(14, 165, 233, 0.1)`) instead of the current light-themed `hover:bg-gray-100`. +4. THE UserMenu dropdown chevron icon SHALL use a light color consistent with the header text colors. + +### Requirement 4: Profile API Endpoint + +**User Story:** As a frontend developer, I want a dedicated API endpoint that returns the full profile data for the currently authenticated user, so that the User_Profile_Panel can display account details not available in the session payload. + +#### Acceptance Criteria + +1. WHEN the Authenticated_User requests their profile, THE Auth_API SHALL return the user's id, username, email, user group, account creation date, and last login timestamp. +2. IF an unauthenticated request is made to the profile endpoint, THEN THE Auth_API SHALL return HTTP 401 with an error message. +3. IF the Authenticated_User's account has been deactivated, THEN THE Auth_API SHALL return HTTP 401 and clear the session cookie. + +### Requirement 5: Password Change Security + +**User Story:** As a security-conscious administrator, I want password changes to be rate-limited and validated, so that brute-force attempts against the current password field are mitigated. + +#### Acceptance Criteria + +1. THE Auth_API SHALL enforce a rate limit on the password change endpoint of no more than 5 attempts per 15-minute window per session. +2. IF the rate limit is exceeded, THEN THE Auth_API SHALL return HTTP 429 with a message indicating the user should try again later. +3. THE Auth_API SHALL verify that the Authenticated_User's account is active before processing a password change. +4. THE Auth_API SHALL validate that the new password is at least 8 characters long on the server side. + +### Requirement 6: UserMenu Dropdown Theming + +**User Story:** As a user, I want the UserMenu dropdown to match the dark dashboard theme, so that the interface is visually consistent. + +#### Acceptance Criteria + +1. THE UserMenu dropdown panel SHALL use a dark background consistent with the Dashboard design system (intel-card gradient or equivalent dark surface). +2. THE UserMenu dropdown text items SHALL use light text colors from the design system (`--text-primary` for labels, `--text-secondary` for metadata). +3. THE UserMenu dropdown hover states SHALL use a subtle accent highlight (e.g., `rgba(14, 165, 233, 0.1)`) instead of the current light-themed `hover:bg-gray-50`. +4. THE UserMenu dropdown border SHALL use the design system accent border style (`rgba(14, 165, 233, 0.3)` or equivalent). +5. THE UserMenu group badge in the dropdown header SHALL retain its existing color-coded styling for group identification. diff --git a/.kiro/specs/user-profile/tasks.md b/.kiro/specs/user-profile/tasks.md new file mode 100644 index 0000000..c0d904a --- /dev/null +++ b/.kiro/specs/user-profile/tasks.md @@ -0,0 +1,119 @@ +# Implementation Plan: User Profile + +## Overview + +This plan implements the user profile feature in three phases: backend API routes first (profile endpoint and password change endpoint on the existing auth router), then the frontend components (UserProfilePanel modal and UserMenu theming/integration), and finally wiring everything together. Each task builds incrementally on the previous one, and testing tasks are placed close to the code they validate. + +## Tasks + +- [ ] 1. Add backend profile and password change routes to `routes/auth.js` + - [x] 1.1 Add `GET /api/auth/profile` route + - Add a new route inside `createAuthRouter` that queries the `users` table for `id, username, email, user_group, created_at, last_login` using the session user's ID + - Return `{ id, username, email, group, created_at, last_login }` on success + - Return 401 if the account is inactive (with `is_active = 0`), clearing the session cookie + - Use the existing `requireAuth(db)` middleware for authentication + - _Requirements: 4.1, 4.2, 4.3_ + + - [x] 1.2 Add `POST /api/auth/change-password` route with rate limiting + - Add `express-rate-limit` middleware scoped to this route: 5 requests per 15-minute window, keyed by `req.cookies.session_id` + - Validate request body has `currentPassword` and `newPassword` fields; return 400 if missing + - Validate `newPassword` is at least 8 characters; return 400 if too short + - Query the user's `password_hash` and `is_active` from the database; return 401 if account is inactive + - Use `bcrypt.compare` to verify `currentPassword`; return 401 if incorrect + - Hash the new password with `bcrypt.hash(newPassword, 10)` and update the `password_hash` column + - Call `logAudit` with action `password_change`, entityType `auth` + - Return `{ message: 'Password changed successfully' }` on success + - Return 429 with appropriate message when rate limit is exceeded + - _Requirements: 2.2, 2.3, 2.7, 2.8, 5.1, 5.2, 5.3, 5.4_ + + - [x] 1.3 Write property tests for password change round-trip (backend) + - **Property 3: Password change round-trip** — For any valid current password and any new password of 8+ characters, after a successful change, `bcrypt.compare(newPassword, storedHash)` returns true + - **Validates: Requirements 2.2, 2.7** + + - [x] 1.4 Write property tests for incorrect password rejection (backend) + - **Property 4: Incorrect current password is always rejected** — For any password string that does not match the user's current password, the endpoint returns 401 and the stored hash remains unchanged + - **Validates: Requirements 2.3** + + - [x] 1.5 Write property tests for short password rejection (backend) + - **Property 6 (server-side): Short passwords are rejected** — For any string of length 0–7, `POST /api/auth/change-password` returns 400 and the stored hash remains unchanged + - **Validates: Requirements 2.5, 5.4** + +- [x] 2. Checkpoint — Verify backend routes + - Ensure all tests pass, ask the user if questions arise. + +- [x] 3. Create `UserProfilePanel.js` frontend component + - [x] 3.1 Create the `UserProfilePanel` modal component + - Create `frontend/src/components/UserProfilePanel.js` + - Accept `isOpen` and `onClose` props + - On open, fetch `GET /api/auth/profile` with `credentials: 'include'` and display loading state + - Render profile info section showing: username, email, group, created_at (formatted), last_login (formatted) + - Use dark theme inline styles matching the design system (intel-card gradient background, accent borders, light text colors from `DESIGN_SYSTEM.md`) + - Include a close button (X icon from lucide-react) and click-outside-to-close behavior + - Display error state with retry option if profile fetch fails + - Use icons from lucide-react (User, Mail, Shield, Calendar, Clock) + - _Requirements: 1.1, 1.2, 1.3, 1.4_ + + - [x] 3.2 Add password change form to `UserProfilePanel` + - Add a password change section below the profile info with three fields: current password, new password, confirm new password + - Implement client-side validation: new password must match confirm password; new password must be >= 8 characters + - Display inline validation errors below the relevant fields + - On submit, call `POST /api/auth/change-password` with `{ currentPassword, newPassword }` + - Handle API error responses (401 wrong password, 429 rate limited, 400 validation, 500 server error) and display appropriate messages + - On success, show success message and clear all form fields + - Style the form with dark theme inline styles (intel-input styling, intel-button-primary for submit) + - _Requirements: 2.1, 2.2, 2.3, 2.4, 2.5, 2.6_ + + - [x] 3.3 Write property test for profile panel field rendering + - **Property 1: Profile panel displays all required fields** — For any valid profile object with arbitrary username, email, group, created_at, and last_login values, rendering UserProfilePanel displays all five values in the output + - **Validates: Requirements 1.2** + + - [x] 3.4 Write property test for mismatched password confirmation + - **Property 5: Mismatched password confirmation is rejected client-side** — For any two distinct strings used as newPassword and confirmPassword, the form displays a validation error and does not submit a request + - **Validates: Requirements 2.4** + + - [x] 3.5 Write property test for short password client-side rejection + - **Property 6 (client-side): Short passwords are rejected** — For any string of length 0–7, the form displays a minimum-length validation error and does not submit a request + - **Validates: Requirements 2.5** + +- [x] 4. Checkpoint — Verify frontend component + - Ensure all tests pass, ask the user if questions arise. + +- [x] 5. Modify `UserMenu.js` for dark theming and profile integration + - [x] 5.1 Convert `UserMenu.js` from light theme to dark theme + - Replace Tailwind light-theme classes with inline dark-theme style objects + - Button: `hover:bg-gray-100` → `rgba(14, 165, 233, 0.1)` hover background + - Username text: `text-gray-900` → `#F8FAFC` (design system `--text-primary`) + - Group label text: `text-gray-500` → `#E2E8F0` (design system `--text-secondary`) + - Chevron icon: `text-gray-500` → `#E2E8F0` + - Dropdown panel: `bg-white` → intel-card gradient background; `border-gray-200` → `rgba(14, 165, 233, 0.3)` + - Dropdown items: `text-gray-700` → `#F8FAFC`; `hover:bg-gray-50` → `rgba(14, 165, 233, 0.1)` + - Sign out text: `text-red-600` → `#F87171`; `hover:bg-red-50` → `rgba(239, 68, 68, 0.1)` + - Dropdown header: `text-gray-900` → `#F8FAFC`; `text-gray-500` → `#94A3B8`; `border-gray-100` → `rgba(14, 165, 233, 0.2)` + - Retain existing group badge color-coding logic + - _Requirements: 3.1, 3.2, 3.3, 3.4, 6.1, 6.2, 6.3, 6.4, 6.5_ + + - [x] 5.2 Add "My Profile" menu item and wire `UserProfilePanel` + - Import `UserProfilePanel` component + - Add `showProfile` state variable + - Add a "My Profile" menu item with `User` icon between the dropdown header and the admin-only actions + - On click, close the dropdown and set `showProfile` to `true` + - Render ` setShowProfile(false)} />` in the component output + - _Requirements: 1.1, 1.3_ + + - [x] 5.3 Write property test for profile API data completeness + - **Property 2: Profile API returns complete user data matching database** — For any active user record, a GET request to `/api/auth/profile` with that user's valid session returns an object with `id`, `username`, `email`, `group`, `created_at`, and `last_login` fields matching the database + - **Validates: Requirements 4.1** + +- [x] 6. Final checkpoint — Ensure all tests pass + - 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` (already installed in `frontend/package.json` as a devDependency) +- Backend tests for properties 3, 4, and 6 (server-side) will need `fast-check` added to the root `package.json` devDependencies +- The existing `express-rate-limit` package (already in root `package.json`) is used for the password change rate limiter +- No database migrations are needed — the existing `users` table has all required columns +- All styling follows the dark theme design system documented in `DESIGN_SYSTEM.md` diff --git a/.kiro/steering/doc-standards.md b/.kiro/steering/doc-standards.md new file mode 100644 index 0000000..973e998 --- /dev/null +++ b/.kiro/steering/doc-standards.md @@ -0,0 +1,190 @@ +# Documentation Standards — CVE Dashboard + +These standards are reverse-engineered from the existing README.md and should be treated as the canonical style guide for all documentation in this repository. When updating docs, match these conventions exactly — consistency matters more than any individual stylistic preference. + +--- + +## Language and Style + +- Write in **present tense, active voice**. "The sync fetches findings" — not "Findings will be fetched by the sync." +- Do not use marketing language. No "powerful", "seamless", "robust", "cutting-edge", "unleash". Describe what the thing does, not how great it is. +- No emoji anywhere in documentation. None. +- Prefer precise technical vocabulary over casual phrasing. "Session tokens are stored in `httpOnly` cookies" — not "we keep the login thing in a secure cookie." +- **Spelling:** match whatever spelling variant already appears in the surrounding prose. Do not rewrite existing words to switch between US and British English — consistency within a section matters more than which variant is used. + +--- + +## Punctuation and Formatting + +- **Em-dashes (—)** for mid-sentence clarifications or appositives, surrounded by spaces: `sync requires Admin or Standard_User group — the sync then fetches findings`. +- **En-dashes (–)** for numeric ranges: `8.5–9.9 VRR`, `20 attempts per 15-minute window`. +- Use **inline backticks** for: file paths, environment variables, CLI flags, function names, field names, HTTP status codes, and any literal value the reader should type or recognise verbatim. +- Use **bold** (`**text**`) for UI element names (button labels, tab names, form fields) and for label-style emphasis at the start of a sub-point. +- Avoid italics except for rare genuine emphasis. Do not use italics decoratively. + +--- + +## Heading Hierarchy + +Use a strict three-tier hierarchy per document: + +``` +# Document Title (one per file, matches repo/feature name) +## Top-Level Section (Overview, Features, Architecture, etc.) +### Subsection (a named feature, page, or concept) +**Sub-subsection label** (bold paragraph labels — NOT an #### heading) +``` + +- Do **not** use `####` or deeper headings. If you need a fourth level, it should be a bold inline label followed by content, matching the existing README pattern. +- Separate top-level `##` sections with a horizontal rule (`---`) on its own line, with blank lines above and below. +- Do not place horizontal rules between `###` subsections unless visually necessary. + +--- + +## Table of Contents + +- Any document over ~200 lines should open with a Table of Contents using markdown anchor links. +- Nest TOC entries to match heading depth (`##` entries flush-left, `###` entries indented two spaces). +- Anchor slugs: lowercase, spaces become hyphens, em-dashes become `--` (two hyphens). Example: `## Home — CVE Management` becomes `#home--cve-management`. + +--- + +## Tables + +Use tables for any reference material with two or more parallel attributes. This includes: + +- Permission matrices (group → capabilities) +- Tech stack listings (layer → technology) +- Column descriptions (column → meaning) +- Environment variable references (name → purpose → default) +- Route references (method + path → description → auth requirement) + +Table format: + +```markdown +| Column | Description | +|---|---| +| `field_name` | What it does | +``` + +- Always use `---` separators (not `:---:` centred alignment) unless centring is specifically warranted. +- Keep cell content on a single line. If a value is long, prefer prose above or below the table. +- Use inline backticks for literal values inside cells. + +--- + +## Code Blocks + +- **Always include a language tag** on fenced code blocks: ` ```bash `, ` ```javascript `, ` ```json `, ` ```python `, ` ```sql `. Bare ` ``` ` is not acceptable. +- Shell commands use `bash` — even for single-line commands. +- Place code blocks on their own line, with blank lines before and after. +- Inside code blocks, do not use markdown formatting (no bold, no italics). Treat them as literal terminal/file content. +- Prefer showing the actual command over describing what to do. "Run `node setup.js` from `backend/`" with a code block — not "initialize the database." + +--- + +## Callouts and Warnings + +- Use a blockquote (`>`) for callouts, notes, and warnings. Do not use GitHub-flavoured admonition syntax (`> [!NOTE]`), Docusaurus admonitions, or custom HTML. +- Keep callouts to one or two sentences. If longer, promote to prose. +- Example: `> IVANTI_API_KEY must be set in backend/.env for sync to work.` + +Bold prefixes for emphasis are acceptable inside a blockquote: `> **Warning:** This deletes all audit records older than 90 days.` + +--- + +## Feature Documentation Pattern + +When documenting a feature or page, follow this structure: + +1. **One-sentence summary** of what the feature is and who uses it. +2. **Capability bullets** — what a user can do, grouped by role if access-controlled. +3. **Workflow or interaction details** — how the feature is used in practice (inline editing behaviour, filter semantics, persistence rules). +4. **Integration notes** — what external systems it touches, what credentials it needs, what it caches. +5. **Edge cases and restrictions** — delete rules, rate limits, role requirements. + +Bold paragraph labels (`**Inline editing:**`, `**CVE Tooltips:**`, `**Filtering:**`) are the preferred pattern for calling out sub-behaviours within a feature section. + +--- + +## Troubleshooting Entries + +Every troubleshooting entry uses the **Symptom → Cause → Fix** triad: + +```markdown +### Short description of the problem + +**Symptom:** What the user sees or experiences. Be specific about error messages, console output, and observed behaviour. + +**Cause:** The underlying reason, explained in one or two sentences. Include the technical mechanism (cookie flag, rate limiter, missing migration) — not just "it's broken." + +**Fix:** Either: +1. Concrete action with a command or config change, **or** +2. Alternative action. +``` + +- Short entries (fix is one command) may collapse Cause into Fix, but Symptom must always appear explicitly. +- Use **Fix:** as the label, not "Solution" or "Resolution." + +--- + +## CHANGELOG + +This project does not currently maintain a `CHANGELOG.md`. The doc-updater agent should **not** create one unless explicitly instructed. + +When a change ships, rely instead on: + +- **Git commit messages** as the primary change history. Write them as complete sentences describing user-observable behaviour, not implementation detail. "Add inline editing to CVE reporting table" — not "fix table.jsx." +- **README updates** as the user-facing record of what the app does now. The README is the source of truth for current behaviour; git log is the source of truth for when and why it changed. + +If a `CHANGELOG.md` is introduced later, this section should be rewritten to declare the chosen format. Until then, the agent should leave changelog concerns out of scope. + +--- + +## Migration Documentation + +When a feature requires a new database migration: + +1. Add the migration filename to the README's **Migrations** section in the order it must be run. +2. If the migration is required for an existing deployment (not just fresh installs), add a Troubleshooting entry using the Symptom → Cause → Fix pattern for the error users will see if they skip it. +3. Note any data-transforming migrations explicitly — users need to know whether the migration is additive (safe to re-run) or transformative (destructive if re-run). + +--- + +## API Reference Documentation + +- Document routes as: `METHOD /path/with/:params` +- Group routes by resource or page (CVE routes, Ivanti routes, Audit routes). +- For each route, specify: purpose, required group, request body or query params, response shape summary. Keep it to 2–4 lines per route. +- Do not duplicate route implementation details. Link to the source file if deeper reference is needed. +- When a route enforces group-based authorisation, state the required group explicitly — do not imply it from context. + +--- + +## What to Update When a Feature Ships + +A new feature or meaningful change to existing behaviour should touch: + +1. **README.md — Features section:** add or update the relevant `###` subsection, matching the Feature Documentation Pattern above. +2. **README.md — Table of Contents:** if a new feature added a new heading, update the TOC to match. +3. **README.md — Configuration:** if new env vars were introduced, add them to the Configuration table. +4. **README.md — API Reference:** if new routes were added, document them under the appropriate resource group. +5. **README.md — Database Schema:** if tables or columns changed, update the schema section. +6. **README.md — Migrations:** if a new migration was added, append it to the ordered list. +7. **docs/**: if the feature has its own standalone guide (setup guide, integration guide), create or update the relevant file in `docs/`. + +If a change does not alter user-observable behaviour, configuration, data model, or API surface, it does not require doc changes. Internal refactors, test additions, and dependency bumps are exempt unless they change how the app is run or deployed. + +--- + +## What NOT to Change + +The doc-updater agent and human contributors alike should **leave alone**: + +- Tone, voice, and spelling conventions of existing prose. Match, do not rewrite. +- Section ordering in the README — the current order is deliberate and reader-tested. +- Heading wording, unless the underlying feature has genuinely been renamed. +- Examples and command snippets that still work, even if they could be "more elegant." +- The overall shape of the Troubleshooting section — append new entries, do not reorganise existing ones. + +Documentation churn is a cost. Only change what the code change requires. \ No newline at end of file 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 |