28 Commits

Author SHA1 Message Date
jramos
5405926550 Merge feature/submit-workflow into master — Ivanti FP workflow submission 2026-04-08 12:45:28 -06:00
jramos
328e48ea8c fix: accept HTTP 202 as success from Ivanti workflow creation
Ivanti returns 202 (Accepted) for async job creation, not just 200/201.
2026-04-08 12:26:35 -06:00
jramos
41f9c35586 fix: correct subjectFilterRequest format and add Ivanti API docs
The subjectFilterRequest field requires a nested structure:
{ subject: 'hostFinding', filterRequest: { filters: [...] } }

Previous attempts with flat { filters: [] } or { subject, filters }
caused Ivanti to return 500. The filterRequest wrapper is required.

Also adds docs/ivanti-api-reference.md documenting all known endpoints,
field formats, and the subjectFilterRequest structure so we don't have
to reverse-engineer the Swagger again.
2026-04-08 12:20:09 -06:00
jramos
729dada05c fix: correct subjectFilterRequest format for Ivanti FP workflow API
The API expects { subject: 'hostFinding', filterRequest: { filters } }
not a flat filter object. Confirmed working via direct curl test —
workflow ID 33418832 created successfully.
2026-04-08 12:18:41 -06:00
jramos
5d417edf82 fix: align subjectFilterRequest with Ivanti search filter schema
Remove extra fields (orWithPrevious, implicitFilters, subject) that
aren't in the Swagger filter schema. Add projection and sort fields
to match the search endpoint format.
2026-04-08 12:08:08 -06:00
jramos
03e60c9daf fix: rewrite FP workflow to use Ivanti multipart/form-data API
The /workflowBatch/falsePositive/request endpoint expects
multipart/form-data with text fields (name, reason, description,
expirationDate, overrideControl, subjectFilterRequest, isEmptyWorkflow)
and inline file uploads — not a JSON body with separate attachment calls.

- Add ivantiFormPost() helper for mixed form fields + files
- Replace buildIvantiPayload with buildIvantiFormFields + buildSubjectFilterRequest
- Remove separate attachment upload loop (files sent inline)
- Update response handling for { id, created } shape
2026-04-08 10:18:45 -06:00
jramos
ee9403ab47 fix: correct Ivanti API endpoint paths for FP workflow creation and attachment
- Creation: /workflowBatch -> /workflowBatch/falsePositive/request
- Attachment: /workflowBatch/{id}/attachment -> /workflowBatch/falsePositive/{uuid}/attach
- Paths confirmed against platform4.risksense.com swagger spec
2026-04-08 10:08:14 -06:00
jramos
3d04cd393f fix: remove no-op status ternary, dead code, and redundant calls
- Fix copy-paste bug in ivantiFpWorkflow.js where both ternary branches
  returned 'partial'; simplified to direct assignment
- Remove unused shouldShowFpButton() from ReportingPage.js (canWrite
  from useAuth() is used instead)
- Hoist repeated isCreateFpButtonEnabled() calls into a single variable
  in QueuePanel render
2026-04-08 09:38:39 -06:00
jramos
382bc81a7e feat: add Ivanti FP workflow submission from Queue
- Add shared ivantiApi.js helper (ivantiPost + ivantiMultipartPost)
- Add ivantiFpWorkflow.js backend route with validation, Ivanti API
  workflow creation, attachment uploads, submission tracking, and audit
- Add add_fp_submissions_table.js migration
- Wire route into server.js at /api/ivanti/fp-workflow
- Add FpWorkflowModal component in ReportingPage.js with form fields,
  drag-and-drop file upload, progress indicator, and result views
- Add Create FP Workflow button to QueuePanel footer (editor/admin only)
- Refactor ivantiWorkflows.js and ivantiFindings.js to use shared helper
2026-04-07 16:20:24 -06:00
jramos
7302ece958 docs: add Upgrade section and Troubleshooting TOC link to README 2026-04-07 13:43:50 -06:00
jramos
80d80c099f docs: add NODE_ENV/Secure cookie warning and troubleshooting section to README 2026-04-07 12:09:27 -06:00
jramos
a2a43a8685 Merge maintenance/security-audit1: security audit remediation and README update 2026-04-07 11:31:41 -06:00
jramos
a711972054 docs: update README for group-based access control, security hardening, and current architecture
- Replace role-based docs with group-based (Admin, Standard_User, Leadership, Read_Only)
- Update API reference with correct group requirements and new endpoints (JIRA tickets, archive, todo-queue)
- Remove hardcoded default credentials from installation instructions
- Document SESSION_SECRET as required with generation instructions
- Add new migrations to install sequence (archive, timestamps, counts history, user_groups, created_by)
- Update architecture tree with new files (ivantiArchive, ComplianceChartsPanel, etc.)
- Update security model with rate limiting, sandbox iframe, rehype-sanitize, Content-Disposition sanitization
- Update database schema docs with created_by columns, user_group triggers, cascade deletes
- Fix middleware reference from requireRole to requireGroup
- Remove stale admin123 references throughout
2026-04-07 11:29:33 -06:00
jramos
8a6a3485e9 security: address audit findings C-4 through M-8
Critical:
- C-4: Add express-rate-limit to login (20 attempts/15min)
- C-5: Remove default credentials from LoginForm.js
- C-6: Add sandbox attribute to KB document iframe

High:
- H-2: Hard-fail on startup if SESSION_SECRET env var is missing
- H-6: Sanitize filenames in Content-Disposition headers
- H-7: Fix KB upload race condition — move file after DB insert succeeds
- H-8: Generate random admin password in setup.js instead of hardcoded
- H-9: Add rehype-sanitize to ReactMarkdown (requires npm install)

Medium:
- M-4: Fix loose equality (==) to strict (===) in users.js self-checks
- M-5: Add hostname format regex validation in compliance notes
- M-6: Fix vendor trim-before-validate in ivantiTodoQueue.js
- M-7: Sanitize original filename in compliance temp JSON
- M-8: Pull CSP frame-ancestors from CORS_ORIGINS env var

New dependencies needed:
- backend: express-rate-limit (npm install in root)
- frontend: rehype-sanitize (npm install in frontend/)
2026-04-07 10:23:10 -06:00
jramos
169a0d2337 Merge feature/usergroups: group-based access control (Admin, Standard_User, Leadership, Read_Only) 2026-04-07 10:11:21 -06:00
jramos
c50fc5d8a8 fix: address all 11 review items for group-based access control
Bugs fixed:
- knowledgeBase.js: logAudit calls converted from positional args to object signature
- archerTickets.js: targetType/targetId renamed to entityType/entityId
- server.js: single CVE delete now has cascade/compliance check for Standard_User

Unprotected endpoints secured:
- ivantiTodoQueue.js: POST/PUT/DELETE now require Admin or Standard_User
- ivantiFindings.js: PUT note and POST sync now require Admin or Standard_User
- compliance.js: POST notes now requires Admin or Standard_User
- ivantiWorkflows.js: POST sync now requires Admin or Standard_User
- auth.js: cleanup-sessions now requires Admin via requireAuth + requireGroup

Additional fixes:
- ExportsPage.js: canExport() guard blocks Read_Only users
- knowledgeBase.js: Standard_User delete checks created_by ownership
- Migration: added INSERT/UPDATE triggers to enforce valid user_group values
2026-04-07 10:09:18 -06:00
jramos
e9e2c0961d fix: address all 11 review items for group-based access control
Bugs fixed:
- knowledgeBase.js: logAudit calls converted from positional args to object signature
- archerTickets.js: targetType/targetId renamed to entityType/entityId
- server.js: single CVE delete now has cascade/compliance check for Standard_User

Unprotected endpoints secured:
- ivantiTodoQueue.js: POST/PUT/DELETE now require Admin or Standard_User
- ivantiFindings.js: PUT note and POST sync now require Admin or Standard_User
- compliance.js: POST notes now requires Admin or Standard_User
- ivantiWorkflows.js: POST sync now requires Admin or Standard_User
- auth.js: cleanup-sessions now requires Admin via requireAuth + requireGroup

Additional fixes:
- ExportsPage.js: canExport() guard blocks Read_Only users
- knowledgeBase.js: Standard_User delete checks created_by ownership
- Migration: added INSERT/UPDATE triggers to enforce valid user_group values
2026-04-07 09:52:26 -06:00
jramos
d910af847e fix: wire up admin page route to render UserManagement component 2026-04-06 16:25:59 -06:00
jramos
73fd747576 feat: implement group-based access control (Admin, Standard_User, Leadership, Read_Only)
- Add user_group migration and created_by column migration
- Replace requireRole middleware with requireGroup
- Update all backend routes to use group-based authorization
- Add Standard_User conditional delete with ownership, state, and compliance checks
- Add cascade impact check for CVE deletes
- Update AuthContext with group-based permission helpers
- Update all frontend components for group-based rendering
- Update UserManagement UI with group dropdown, confirmation dialogs, self-demotion prevention
2026-04-06 16:18:07 -06:00
1ef57b0504 feat(archive): add finding archive tracking to Ivanti sync pipeline
Adds a four-state lifecycle tracker (ACTIVE → ARCHIVED → RETURNED → CLOSED)
to detect and monitor findings that disappear from Ivanti sync results due to
severity score drift rather than actual remediation.

- Archive detection runs automatically after each sync, comparing previous
  and current finding sets to identify disappearances and reappearances
- Full transition history stored in ivanti_finding_archives and
  ivanti_archive_transitions tables with timestamps and severity scores
- Three new API endpoints: /api/ivanti/archive, /api/ivanti/archive/stats,
  /api/ivanti/archive/:findingId/history
- Archive Summary Bar UI on the home page shows counts for each state
  (Active, Archived, Returned, Closed) with click-through finding lists
- Two new migrations: add_finding_archive_tables, add_archer_tickets_timestamps
- Mermaid diagram support added to Knowledge Base viewer
2026-04-06 09:51:56 -06:00
jramos
d1fe0bf455 fix: resolve 5 pre-merge issues in finding archive tracking
1. ACTIVE state never populated — stats endpoint now computes ACTIVE from live findings cache count instead of querying archive table

2. CHECK constraint mismatch — migration now uses 3-state constraint (ARCHIVED, RETURNED, CLOSED) matching runtime initArchiveTables()

3. Archive filter click non-functional — handleArchiveStateClick now fetches and renders filtered archive list below summary bar

4. Hook glob pattern mismatch — changed **/migrate*.js to **/migrations/*.js so hook fires for actual migration filenames

5. Stale stats after sync — ArchiveSummaryBar polls every 60s and refreshes immediately after workflow sync via refreshKey prop
2026-04-03 15:51:18 -06:00
jramos
3f7887eba6 added hooks 2026-04-03 15:29:05 -06:00
jramos
9bd5a52661 feat: implement finding archive tracking system
- Add migration script for ivanti_finding_archives and ivanti_archive_transitions tables
- Add archive detection logic (detectArchiveChanges, detectClosedFindings) in sync pipeline
- Add archive API router with list, stats, and history endpoints at /api/ivanti/archive
- Add ArchiveSummaryBar UI component with four state cards (ACTIVE, ARCHIVED, RETURNED, CLOSED)
- Integrate ArchiveSummaryBar into Ivanti findings page in App.js
- Register archive router in server.js
2026-04-03 15:20:04 -06:00
jramos
2b4ec5d8e2 added kiro specs 2026-04-03 13:48:04 -06:00
jramos
62592e9821 add kiro steering files 2026-04-03 09:27:12 -06:00
2fead2cfef feat(kb): render Mermaid diagrams in Knowledge Base viewer
Installs mermaid v11 and adds a custom ReactMarkdown code renderer
that intercepts fenced mermaid blocks and renders them as SVG diagrams
using the dark theme. SVGs are made responsive (width: 100%).
Non-mermaid code blocks are unchanged.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 15:37:00 -06:00
7c0ba41514 fix(migrations): add created_at/updated_at to archer_tickets if missing
Production instances where the table was created before these columns
were added to the schema will see 500 errors on all /api/archer-tickets
endpoints. This migration safely checks PRAGMA table_info before each
ALTER TABLE so it is idempotent and safe to run multiple times.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 15:23:38 -06:00
9c6c03a518 feat: time-based charts, Vulnerability Triage rename, Knowledge Base page
Merges feature/compliance-time-charts into master.

Changes included:
- Compliance page: 6 Recharts trend charts (active totals, deltas, per-team,
  MTTR, recurring items, Archer pipeline)
- Ivanti findings trend chart on Vulnerability Triage page: open/closed
  counts history stored on every sync, aggregated to end-of-day snapshots
- Rename 'Reporting' page to 'Vulnerability Triage' throughout (nav, routes,
  docs, all cross-page navigation references)
- Knowledge Base page: full article library with category filter, search,
  inline viewer, upload/delete for editor+ roles
- Remove Knowledge Base sidebar panel from home page (now lives on KB page);
  home layout adjusts to 2-column (9+3)
- Add ivanti_counts_history migration script for documentation consistency
- Update security-posture-workflow-diagrams.md and team-training-agenda.md
  to reflect Vulnerability Triage page name

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 14:53:13 -06:00
52 changed files with 5971 additions and 479 deletions

View File

@@ -0,0 +1,16 @@
{
"enabled": true,
"name": "Check Component Conventions",
"description": "On save of files in frontend/src/components/, verifies the component follows project conventions and flags deviations as inline comments.",
"version": "1",
"when": {
"type": "fileEdited",
"patterns": [
"frontend/src/components/**/*.js"
]
},
"then": {
"type": "askAgent",
"prompt": "Review the saved component file and verify it follows these project conventions:\n\n1. Functional component with hooks (no class components)\n2. Uses Lucide icons for iconography (not raw SVGs or other icon libraries)\n3. Uses inline styles or existing CSS classes from App.css (no CSS modules, no styled-components)\n4. Fetches data with fetch() using relative API paths and credentials: 'include' (no axios, no absolute URLs)\n5. Handles loading and error states when fetching data\n\nFor any deviations found, add inline comments in the code flagging the issue, e.g. // ⚠️ CONVENTION: Use lucide-react icons instead of raw SVGs\n\nOnly flag actual deviations. Do not modify working logic or refactor the component."
}
}

View File

@@ -0,0 +1,16 @@
{
"enabled": true,
"name": "JSDoc Route Documentation",
"description": "On save of files in backend/routes/, ensures every exported route handler has a JSDoc comment documenting the HTTP method, path, query parameters, request body shape, and response shape. Uses the existing documentation style in the file. Does not add comments to internal helper functions.",
"version": "1",
"when": {
"type": "fileEdited",
"patterns": [
"backend/routes/*.js"
]
},
"then": {
"type": "askAgent",
"prompt": "Review the saved route file and ensure every exported route handler (e.g., router.get, router.post, router.put, router.patch, router.delete) has a JSDoc comment directly above it documenting: the HTTP method, the route path, any query parameters, the request body shape (if applicable), and the response shape. Match the existing documentation style already used in the file. Do NOT add JSDoc comments to internal helper functions that are not route handlers. Only add missing documentation — do not modify or remove existing JSDoc comments that are already correct."
}
}

View File

@@ -0,0 +1,16 @@
{
"enabled": true,
"name": "SQLite3 Safety Check",
"description": "On save of files containing db.run, db.get, or db.all, verifies all sqlite3 calls use parameterized queries (? placeholders) instead of string concatenation, handle the error parameter first in every callback, and use hardcoded table/column names. Flags violations as inline comments prefixed with \"// FIXME:\".",
"version": "1",
"when": {
"type": "fileEdited",
"patterns": [
"backend/**/*.js"
]
},
"then": {
"type": "askAgent",
"prompt": "The saved file may contain sqlite3 calls (db.run, db.get, or db.all). Scan the file and verify all sqlite3 calls follow these rules:\n\n1. Parameterized queries only: All SQL queries must use ? placeholders for dynamic values. Never use string concatenation or template literals to inject values into SQL strings.\n2. Error-first callbacks: Every callback passed to db.run, db.get, or db.all must handle the error parameter first (e.g., `if (err) { ... }`).\n3. Hardcoded table/column names: All table and column names in SQL strings must be hardcoded string literals, never sourced from variables or parameters.\n\nIf the file does not contain any db.run, db.get, or db.all calls, skip the check silently.\n\nFor any violations found, add an inline comment on the offending line prefixed with \"// FIXME:\" describing the specific issue. Do not modify any other code."
}
}

View File

@@ -0,0 +1,16 @@
{
"enabled": true,
"name": "Verify Migration Pattern",
"description": "On save or create of migration files (migrate*.js), verifies the migration follows existing project patterns: uses CREATE TABLE IF NOT EXISTS, includes explicit column types, adds appropriate indexes, and wraps multiple statements in transactions. Compares against existing migrations for style consistency.",
"version": "1",
"when": {
"type": "fileEdited",
"patterns": [
"**/migrate*.js"
]
},
"then": {
"type": "askAgent",
"prompt": "A migration file was just saved. Review the edited file and verify it follows the existing migration pattern used in this project. Check the existing migrations in backend/migrations/ for reference, then verify the edited file:\n\n1. Uses CREATE TABLE IF NOT EXISTS (not just CREATE TABLE)\n2. Includes all columns with explicit SQLite types (TEXT, INTEGER, REAL, etc.)\n3. Adds appropriate indexes for foreign keys and frequently queried columns\n4. Wraps operations in a serialized transaction (db.serialize + db.run(\"BEGIN TRANSACTION\") / COMMIT) if there are multiple statements\n5. Follows the same callback-based db.run() style as existing migrations\n6. Includes proper error handling\n\nCompare the file against the existing migrations in backend/migrations/ for style consistency. Report any deviations or issues found."
}
}

View File

@@ -0,0 +1,16 @@
{
"enabled": true,
"name": "Verify New Migration",
"description": "On creation of new migration files in backend/migrations/, verifies the migration follows existing project patterns: uses CREATE TABLE IF NOT EXISTS, includes explicit column types, adds appropriate indexes, and wraps multiple statements in transactions.",
"version": "1",
"when": {
"type": "fileCreated",
"patterns": [
"**/migrations/*.js"
]
},
"then": {
"type": "askAgent",
"prompt": "A new migration file was just created. Review the file and verify it follows the existing migration pattern used in this project. Check the existing migrations in backend/migrations/ for reference, then verify the new file:\n\n1. Uses CREATE TABLE IF NOT EXISTS (not just CREATE TABLE)\n2. Includes all columns with explicit SQLite types (TEXT, INTEGER, REAL, etc.)\n3. Adds appropriate indexes for foreign keys and frequently queried columns\n4. Wraps operations in a serialized transaction (db.serialize + db.run(\"BEGIN TRANSACTION\") / COMMIT) if there are multiple statements\n5. Follows the same callback-based db.run() style as existing migrations\n6. Includes proper error handling\n\nCompare the file against the existing migrations in backend/migrations/ for style consistency. Report any deviations or issues found."
}
}

View File

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

View File

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

View File

@@ -0,0 +1,134 @@
# Implementation Plan: Finding Archive Tracking
## Overview
Implement the Finding Archive Tracking system by creating the database migration, archive detection logic within the existing sync pipeline, three API endpoints via a new route module, and an Archive Summary Bar UI component. Each task builds incrementally — schema first, then detection logic, then API, then frontend.
## Tasks
- [x] 1. Create database migration and archive tables
- [x] 1.1 Create `backend/migrations/add_finding_archive_tables.js` migration script
- Create `ivanti_finding_archives` table with columns: id, finding_id (UNIQUE), finding_title, host_name, ip_address, current_state (CHECK constraint for ACTIVE/ARCHIVED/RETURNED/CLOSED), last_severity, first_archived_at, last_transition_at, created_at
- Create `ivanti_archive_transitions` table with columns: id, archive_id (FK), from_state, to_state, severity_at_transition, reason, transitioned_at
- Create indexes: idx_archive_finding_id, idx_archive_current_state, idx_transition_archive_id
- Use `CREATE TABLE IF NOT EXISTS` and `CREATE INDEX IF NOT EXISTS` for idempotency
- Follow existing migration pattern: open db, `db.serialize()`, log progress, close db
- _Requirements: 3.1, 3.2, 3.3, 3.4, 6.1, 6.2, 6.3_
- [ ]* 1.2 Write property test for migration idempotency
- **Property 9: Migration idempotency**
- Run migration logic multiple times against in-memory SQLite, verify no errors and schema is consistent
- **Validates: Requirements 6.2**
- [x] 2. Implement archive detection logic in sync pipeline
- [x] 2.1 Add `initArchiveTables(db)` function to `backend/routes/ivantiFindings.js`
- Create both archive tables inline (same pattern as existing `initTables`) so they exist on startup
- Call from `createIvantiFindingsRouter` during init alongside existing `initTables`
- _Requirements: 3.1, 3.2, 3.3, 3.4_
- [x] 2.2 Implement `detectArchiveChanges(db, previousFindings, currentFindings)` function
- Build ID sets from previous and current findings
- For disappeared findings (in previous, not in current): upsert archive record with state ARCHIVED, insert transition history
- For returned findings (in current, has ARCHIVED record): update to RETURNED, insert transition history
- For re-disappeared findings (has RETURNED record, not in current): update to ARCHIVED, insert transition history
- Use `db.run` with callbacks wrapped in promises (matching existing `dbRun` helper pattern)
- _Requirements: 1.1, 1.2, 1.3, 1.4, 2.1, 2.2_
- [x] 2.3 Implement `detectClosedFindings(db, closedFindingIds)` function
- Query archive records with state ARCHIVED or RETURNED
- For any that appear in the closed findings set, update to CLOSED with reason "remediated_in_ivanti"
- Insert transition history for each state change
- _Requirements: 2.3_
- [x] 2.4 Integrate archive detection into `syncFindings()` flow
- Before updating the cache, read the current findings from `ivanti_findings_cache` as `previousFindings`
- After successful cache update, call `detectArchiveChanges(db, previousFindings, currentFindings)`
- Skip archive detection if sync encountered an error (requirement 1.5)
- Call `detectClosedFindings` during `syncClosedCount` with closed finding IDs
- _Requirements: 1.1, 1.5, 2.3_
- [ ]* 2.5 Write property test for archive detection — disappeared findings
- **Property 1: Disappeared findings are archived with complete metadata**
- Generate random previous/current finding sets using fast-check, run detectArchiveChanges against in-memory SQLite, verify all disappeared findings have ARCHIVED records with correct metadata
- **Validates: Requirements 1.1, 1.2, 2.2**
- [ ]* 2.6 Write property test for archive detection — returned findings
- **Property 2: Returned findings transition from ARCHIVED to RETURNED**
- Generate archived findings, add some back to current set, verify RETURNED state and updated severity
- **Validates: Requirements 1.3**
- [ ]* 2.7 Write property test for archive detection — re-disappeared findings
- **Property 3: Re-disappeared findings transition from RETURNED to ARCHIVED**
- Generate returned findings, remove some from current set, verify ARCHIVED state
- **Validates: Requirements 1.4**
- [ ]* 2.8 Write property test for transition history completeness
- **Property 4: Every state transition produces a history record with all required fields**
- Generate random state transitions, verify each produces a complete history row with archive_id, from_state, to_state, severity_at_transition, reason, transitioned_at
- **Validates: Requirements 2.1**
- [ ]* 2.9 Write property test for closed finding detection
- **Property 5: Closed findings transition to CLOSED state**
- Generate archived/returned findings, mark some as closed, verify CLOSED state and reason "remediated_in_ivanti"
- **Validates: Requirements 2.3**
- [x] 3. Checkpoint — Verify archive detection logic
- Ensure all tests pass, ask the user if questions arise.
- [x] 4. Implement Archive API endpoints
- [x] 4.1 Create `backend/routes/ivantiArchive.js` route module
- Export factory function `createIvantiArchiveRouter(db, requireAuth)` returning Express Router
- Apply `requireAuth(db)` middleware to all routes
- Implement GET `/` — list archive records with optional `?state=` filter, return `{ archives: [...], total: N }`. Return 400 with message "Invalid state parameter. Valid values: ACTIVE, ARCHIVED, RETURNED, CLOSED" if an unrecognized state value is provided.
- Implement GET `/stats` — return `{ ACTIVE: N, ARCHIVED: N, RETURNED: N, CLOSED: N, total: N }`
- Implement GET `/:findingId/history` — return `{ finding_id, transitions: [...] }` ordered by transitioned_at DESC, return empty array for unknown finding_id
- _Requirements: 4.1, 4.2, 4.3, 4.4, 4.5_
- [x] 4.2 Register archive router in `backend/server.js`
- Import `createIvantiArchiveRouter` from `./routes/ivantiArchive`
- Mount at `/api/ivanti/archive` with `requireAuth` middleware
- _Requirements: 4.1_
- [ ]* 4.3 Write property test for state filtering
- **Property 6: State filter returns only matching records**
- Generate archive records with random states, query with filter, verify only matching records returned
- **Validates: Requirements 4.1**
- [ ]* 4.4 Write property test for history ordering
- **Property 7: Transition history is ordered by timestamp descending**
- Generate multiple transitions for a finding, query history, verify descending timestamp order
- **Validates: Requirements 4.2**
- [ ]* 4.5 Write property test for stats accuracy
- **Property 8: Stats counts match actual record distribution**
- Generate archive records with random states, query stats, verify counts match actual distribution
- **Validates: Requirements 4.3**
- [x] 5. Checkpoint — Verify API endpoints
- Ensure all tests pass, ask the user if questions arise.
- [x] 6. Implement Archive Summary Bar UI component
- [x] 6.1 Create `frontend/src/components/pages/ArchiveSummaryBar.js`
- Fetch stats from `/api/ivanti/archive/stats` on mount
- Render four stat cards: ACTIVE (sky blue #0EA5E9), ARCHIVED (amber #F59E0B), RETURNED (emerald #10B981), CLOSED (red #EF4444)
- Each card shows the count and state label with Lucide icons and monospace typography
- Accept `onStateClick` callback prop and `activeFilter` prop for highlighting the selected state
- Use inline style objects matching the existing design system (dark gradients, glows, hover effects)
- _Requirements: 5.1, 5.2, 5.3, 5.4, 5.5_
- [x] 6.2 Integrate Archive Summary Bar into the Ivanti findings page
- Import and render `ArchiveSummaryBar` in the Ivanti findings section of `App.js` (or the relevant page component)
- Wire `onStateClick` to manage a state filter for the archive list display
- _Requirements: 5.3_
- [x] 7. Final checkpoint — Verify full integration
- Ensure all tests pass, ask the user if questions arise.
## Notes
- Tasks marked with `*` are optional and can be skipped for faster MVP
- Each task references specific requirements for traceability
- Checkpoints ensure incremental validation
- Property tests use `fast-check` library with minimum 100 iterations per test
- All backend code uses callback-based SQLite API wrapped in promises (matching existing patterns)
- All frontend code uses plain JavaScript (no TypeScript)

View File

@@ -0,0 +1,143 @@
# Requirements Document
## Introduction
Replace the existing simple role-based access control system (admin/editor/viewer) with a group-based access control model. The system supports exactly four user groups (Admin, Standard User, Leadership, Read Only) with distinct permission boundaries. This change affects the database schema, backend middleware, API endpoint authorization, frontend conditional rendering, and the admin panel user management interface.
## Glossary
- **Dashboard**: The STEAM Security Dashboard application comprising a React frontend and Express backend
- **Group**: One of four access control categories (Admin, Standard_User, Leadership, Read_Only) that determines a user's permissions
- **Admin_Group**: The group with full CRUD access to all resources, user management, and admin panel access
- **Standard_User_Group**: The working group with view-all, create, edit, and conditional delete permissions plus basic export
- **Leadership_Group**: The read-only group with additional export capabilities for reports, compliance documents, and visualizations
- **Read_Only_Group**: The view-only group with no create, edit, delete, or export capabilities
- **Permission_Middleware**: Backend Express middleware that validates a user's group membership before allowing an API action
- **Cascade_Impact**: The set of associated Archer tickets, JIRA tickets, and documents that would be deleted when a CVE is deleted
- **Compliance_Link**: An association between a ticket (Archer or JIRA) and a compliance report that blocks Standard_User deletion
- **Group_Migration**: The database migration that replaces the role field with a group field and maps existing users
## Requirements
### Requirement 1: Group Data Model
**User Story:** As a system administrator, I want the user model to reference one of four defined groups instead of the legacy role field, so that permissions are enforced through a well-defined group structure.
#### Acceptance Criteria
1. THE Dashboard SHALL store exactly four groups: Admin, Standard_User, Leadership, and Read_Only
2. THE Dashboard SHALL assign each user to exactly one group via a group field on the user record
3. WHEN a user record is created, THE Dashboard SHALL default the group to Read_Only
4. THE Dashboard SHALL enforce a foreign key or CHECK constraint so that the group field only accepts valid group values
### Requirement 2: Group Migration
**User Story:** As a system administrator, I want existing users to be automatically mapped from the old role system to the new group system, so that no manual re-assignment is needed after the upgrade.
#### Acceptance Criteria
1. WHEN the migration runs, THE Group_Migration SHALL map users with role "admin" to Admin_Group
2. WHEN the migration runs, THE Group_Migration SHALL map users with role "editor" to Standard_User_Group
3. WHEN the migration runs, THE Group_Migration SHALL map users with role "viewer" to Read_Only_Group
4. WHEN the migration runs, THE Group_Migration SHALL remove the CHECK constraint on the old role column and replace it with the new group field
5. IF a user record has no role value or an unrecognized role value, THEN THE Group_Migration SHALL assign that user to Read_Only_Group
### Requirement 3: Backend Permission Enforcement
**User Story:** As a security-conscious developer, I want every API endpoint to check the requesting user's group before allowing the action, so that permissions are enforced server-side and cannot be bypassed through direct API calls.
#### Acceptance Criteria
1. THE Permission_Middleware SHALL replace the existing requireRole middleware with a requireGroup middleware that accepts one or more group names
2. WHEN an unauthenticated request reaches a protected endpoint, THE Permission_Middleware SHALL return HTTP 401
3. WHEN an authenticated user's group is not in the allowed groups for an endpoint, THE Permission_Middleware SHALL return HTTP 403
4. THE Permission_Middleware SHALL attach the user's group to the request object for downstream route handlers to use
5. WHEN a Standard_User_Group user attempts to delete a resource they did not create, THE Dashboard SHALL return HTTP 403
6. WHEN a Standard_User_Group user attempts to delete a finding that is marked as resolved or closed, THE Dashboard SHALL return HTTP 403
7. WHEN a Standard_User_Group user attempts to delete a ticket that is linked to a compliance report, THE Dashboard SHALL return HTTP 403
8. WHEN a Standard_User_Group user attempts to delete a CVE they created, THE Dashboard SHALL check for Cascade_Impact and return the list of associated Archer tickets, JIRA tickets, and documents
9. IF any ticket in the Cascade_Impact is linked to a compliance report, THEN THE Dashboard SHALL block the CVE deletion and return HTTP 403 with a message indicating Admin-only deletion is required
10. WHEN an Admin_Group user performs any CRUD operation, THE Dashboard SHALL allow the operation without ownership or state restrictions
### Requirement 4: Admin Group Permissions
**User Story:** As an admin, I want full unrestricted access to all resources and management functions, so that I can manage the entire system without limitations.
#### Acceptance Criteria
1. THE Dashboard SHALL allow Admin_Group users to create, read, update, and delete all resources (CVEs, findings, tickets, comments, compliance reports)
2. THE Dashboard SHALL allow Admin_Group users to access the admin panel
3. THE Dashboard SHALL allow Admin_Group users to manage users and assign users to groups
4. THE Dashboard SHALL allow Admin_Group users to export all data
5. THE Dashboard SHALL allow Admin_Group users to delete any resource regardless of ownership, state, or compliance linkage
### Requirement 5: Standard User Group Permissions
**User Story:** As a standard user, I want to view all data and create/edit resources while having controlled delete access, so that I can do my daily work without accidentally removing critical linked data.
#### Acceptance Criteria
1. THE Dashboard SHALL allow Standard_User_Group users to view all data across the dashboard
2. THE Dashboard SHALL allow Standard_User_Group users to create and edit CVEs, findings, tickets, and comments
3. THE Dashboard SHALL allow Standard_User_Group users to delete their own findings, tickets, and comments subject to state and linkage restrictions
4. WHEN a Standard_User_Group user attempts to delete a finding that is resolved or closed, THE Dashboard SHALL reject the deletion
5. WHEN a Standard_User_Group user attempts to delete a ticket linked to a compliance report, THE Dashboard SHALL reject the deletion
6. WHEN a Standard_User_Group user attempts to delete a CVE they created, THE Dashboard SHALL display a warning listing associated Archer tickets, JIRA tickets, and documents that will be cascade-deleted
7. IF any associated ticket in the cascade is linked to a compliance report, THEN THE Dashboard SHALL block the CVE deletion entirely
8. THE Dashboard SHALL allow Standard_User_Group users to perform basic exports (CSV and XLSX of CVEs and findings)
### Requirement 6: Leadership Group Permissions
**User Story:** As a leadership user, I want read-only access with export capabilities, so that I can review data and generate reports without risk of modifying records.
#### Acceptance Criteria
1. THE Dashboard SHALL allow Leadership_Group users to view all data across the dashboard
2. THE Dashboard SHALL allow Leadership_Group users to export reports, compliance documents, and graph visualizations
3. THE Dashboard SHALL prevent Leadership_Group users from creating, editing, or deleting any records
4. THE Dashboard SHALL prevent Leadership_Group users from accessing the admin panel
### Requirement 7: Read Only Group Permissions
**User Story:** As a read-only user, I want view-only access to the dashboard, so that I can see data without any ability to modify or export it.
#### Acceptance Criteria
1. THE Dashboard SHALL allow Read_Only_Group users to view all data across the dashboard
2. THE Dashboard SHALL prevent Read_Only_Group users from creating, editing, or deleting any records
3. THE Dashboard SHALL prevent Read_Only_Group users from exporting any data
4. THE Dashboard SHALL prevent Read_Only_Group users from accessing the admin panel
### Requirement 8: Admin Panel Group Management
**User Story:** As an admin, I want to view all users with their current group and reassign groups through the admin panel, so that I can manage access control centrally.
#### Acceptance Criteria
1. WHEN an Admin_Group user opens the user management section, THE Dashboard SHALL display all users with their current group assignment
2. WHEN an Admin_Group user changes a user's group, THE Dashboard SHALL update the group assignment and persist it to the database
3. WHEN an Admin_Group user changes a user's group, THE Dashboard SHALL display a confirmation dialog before applying the change
4. WHEN an Admin_Group user downgrades another Admin_Group user, THE Dashboard SHALL display an additional warning in the confirmation dialog
5. THE Dashboard SHALL prevent an Admin_Group user from changing their own group to a non-Admin group
### Requirement 9: Audit Logging for Group Changes
**User Story:** As a system administrator, I want all group assignment changes to be logged with full context, so that I can audit who changed access for whom and when.
#### Acceptance Criteria
1. WHEN a user's group is changed, THE Dashboard SHALL log the change with the acting user's ID, the target user's ID, the previous group, the new group, and a timestamp
2. THE Dashboard SHALL preserve existing audit trail behavior for all CRUD operations performed under the new group system
3. WHEN a group change is logged, THE Dashboard SHALL record the IP address of the acting user
### Requirement 10: Frontend Conditional Rendering
**User Story:** As a user, I want the UI to show only the actions available to my group, so that I have a clear and uncluttered interface matching my permissions.
#### Acceptance Criteria
1. THE Dashboard SHALL conditionally render create, edit, and delete buttons based on the current user's group
2. THE Dashboard SHALL conditionally render export options based on the current user's group
3. THE Dashboard SHALL conditionally render the admin panel link based on the current user's group
4. WHEN a Standard_User_Group user views a resource they did not create, THE Dashboard SHALL hide the delete button for that resource
5. THE Dashboard SHALL replace the existing role-based helper functions (hasRole, canWrite, isAdmin) with group-based equivalents (isInGroup, canWrite, canDelete, canExport, isAdmin)

View File

@@ -0,0 +1,279 @@
# Implementation Plan: Group-Based Access Control
## Overview
Replace the existing role-based access control (admin/editor/viewer) with a four-group model (Admin, Standard_User, Leadership, Read_Only). This touches the database schema, backend middleware, all route authorization, frontend permission helpers, and the admin panel UI. Tasks build incrementally: migration first, then middleware, then routes, then frontend.
## Tasks
- [ ] 1. Create database migration for user groups
- [x] 1.1 Create `backend/migrations/add_user_groups.js` migration script
- Add `user_group` column (VARCHAR(20), NOT NULL, DEFAULT 'Read_Only') to users table
- Map existing role values: admin to Admin, editor to Standard_User, viewer to Read_Only
- Map NULL or unrecognized role values to Read_Only
- Add CHECK constraint: user_group IN ('Admin', 'Standard_User', 'Leadership', 'Read_Only')
- Add index `idx_users_user_group` on user_group column
- Use idempotent checks so migration is safe to run multiple times
- Follow existing migration pattern: open db, db.serialize(), log progress, close db
- _Requirements: 1.1, 1.2, 1.3, 1.4, 2.1, 2.2, 2.3, 2.4, 2.5_
- [ ]* 1.2 Write property test for migration role mapping
- **Property 8: Migration maps all role values correctly**
- Generate users with random roles from {admin, editor, viewer, NULL, arbitrary}, run migration against in-memory SQLite, verify mapping
- **Validates: Requirements 2.1, 2.2, 2.3, 2.5**
- [ ]* 1.3 Write property test for migration idempotency
- **Property 9: Migration is idempotent**
- Run migration N times (N in 1-5) against in-memory SQLite, verify schema and data identical each time
- **Validates: Requirements 2.4**
- [ ] 1.4 Write unit tests for migration
- Test column creation with correct CHECK constraint
- Test role mapping: admin to Admin, editor to Standard_User, viewer to Read_Only
- Test NULL and unrecognized role handling defaults to Read_Only
- Test new user defaults to Read_Only group
- _Requirements: 1.3, 1.4, 2.1, 2.2, 2.3, 2.5_
- [ ] 2. Update auth middleware to use groups
- [x] 2.1 Update `requireAuth` in `backend/middleware/auth.js`
- Modify session join query to SELECT user_group and attach as req.user.group
- _Requirements: 3.4_
- [x] 2.2 Add `requireGroup` middleware function
- Accept spread of allowed group names
- Return 401 if req.user is missing
- Return 403 with error details if user group not in allowed set
- Call next() if group is allowed
- _Requirements: 3.1, 3.2, 3.3_
- [x] 2.3 Remove `requireRole` and export `requireGroup`
- Remove requireRole function and its export
- Export requireGroup in its place
- _Requirements: 3.1_
- [ ]* 2.4 Write property test for group constraint
- **Property 1: Group constraint rejects invalid values**
- Generate random strings not in valid group set, attempt DB insert, verify constraint error
- **Validates: Requirements 1.1, 1.4**
- [ ]* 2.5 Write property test for requireGroup
- **Property 3: requireGroup rejects unauthorized groups**
- Generate random group and allowedGroups pairs where group is not in allowed set, verify 403
- **Validates: Requirements 3.3**
- [ ] 2.6 Write unit tests for requireGroup middleware
- Test 401 for unauthenticated requests
- Test 403 for wrong group
- Test group attached to req.user
- Test next() called for allowed group
- _Requirements: 3.2, 3.3, 3.4_
- [x] 3. Checkpoint: Verify migration and middleware
- Ensure all tests pass, ask the user if questions arise.
- [ ] 4. Update auth routes to return group
- [x] 4.1 Update login endpoint in `backend/routes/auth.js`
- Return group (from user_group) instead of role in user response object
- Update audit log details to log group instead of role
- _Requirements: 3.4, 9.2_
- [x] 4.2 Update me endpoint in `backend/routes/auth.js`
- Return group instead of role in user response object
- _Requirements: 3.4_
- [ ] 5. Update user management routes
- [x] 5.1 Switch `backend/routes/users.js` to use requireGroup
- Replace requireRole('admin') with requireGroup('Admin')
- _Requirements: 4.2, 4.3_
- [x] 5.2 Update GET endpoints to return user_group
- Return user_group instead of role in user records
- _Requirements: 8.1_
- [x] 5.3 Update POST create user to accept group param
- Validate group against valid values
- Default to Read_Only if not provided
- Return 400 for invalid group values
- _Requirements: 1.3, 8.2_
- [x] 5.4 Update PATCH update user to accept group param
- Validate group against valid values
- Prevent admin self-demotion (return 400)
- _Requirements: 8.2, 8.5_
- [x] 5.5 Add audit logging for group changes
- Log acting user ID, target user ID, previous group, new group, IP address, timestamp
- _Requirements: 9.1, 9.3_
- [ ]* 5.6 Write property test for user group validity
- **Property 2: Every user has exactly one valid group**
- Generate random user sets, query all users, verify each has exactly one valid group
- **Validates: Requirements 1.2**
- [ ] 5.7 Write unit tests for user management group logic
- Test group validation rejects invalid values
- Test self-demotion prevention
- Test audit logging includes all required fields
- _Requirements: 8.2, 8.5, 9.1, 9.3_
- [ ] 6. Update backend route authorization across all routes
- [x] 6.1 Update `backend/routes/auditLog.js`
- Replace requireRole('admin') with requireGroup('Admin')
- _Requirements: 4.2_
- [x] 6.2 Update `backend/routes/archerTickets.js`
- Use requireGroup('Admin', 'Standard_User') for create, update, delete
- _Requirements: 5.2_
- [x] 6.3 Update `backend/routes/knowledgeBase.js`
- Use requireGroup('Admin', 'Standard_User') for upload and delete
- _Requirements: 5.2_
- [x] 6.4 Update `backend/routes/ivantiFindings.js`
- Use requireGroup('Admin', 'Standard_User') for override endpoint
- _Requirements: 5.2_
- [x] 6.5 Update `backend/routes/compliance.js`
- Use requireGroup('Admin', 'Standard_User') for preview and commit
- _Requirements: 5.2_
- [x] 6.6 Update `backend/server.js` inline CVE routes
- Use requireGroup('Admin', 'Standard_User') for POST, PUT, PATCH, DELETE
- _Requirements: 5.2_
- [x] 6.7 Update `backend/server.js` route mounting
- Pass requireGroup instead of requireRole to route factories
- _Requirements: 3.1_
- [ ]* 6.8 Write property test for Leadership restrictions
- **Property 5: Leadership cannot mutate any resource**
- Generate random mutation requests as Leadership, verify 403
- **Validates: Requirements 6.3**
- [ ]* 6.9 Write property test for Read_Only restrictions
- **Property 6: Read_Only cannot mutate or export**
- Generate random mutation and export requests as Read_Only, verify 403
- **Validates: Requirements 7.2, 7.3**
- [x] 7. Checkpoint: Verify backend route authorization
- Ensure all tests pass, ask the user if questions arise.
- [ ] 8. Implement Standard User conditional delete logic
- [x] 8.1 Add created_by column tracking
- Add created_by to CVE, finding, and ticket creation endpoints storing req.user.id on insert
- _Requirements: 3.5_
- [x] 8.2 Implement ownership check for CVE delete
- Standard_User can only delete CVEs they created
- Return 403 if not owner
- _Requirements: 3.5_
- [x] 8.3 Implement cascade impact check for CVE delete
- Query associated Archer tickets and documents
- Check compliance linkage on cascaded tickets
- Return cascade_impact response schema
- Block deletion if any cascaded ticket is compliance-linked
- _Requirements: 3.8, 3.9_
- [x] 8.4 Implement state check for finding delete
- Standard_User cannot delete resolved or closed findings
- Return 403 with appropriate error message
- _Requirements: 3.6_
- [x] 8.5 Implement compliance linkage check for ticket delete
- Standard_User cannot delete tickets linked to compliance reports
- Return 403 with appropriate error message
- _Requirements: 3.7_
- [x] 8.6 Ensure Admin bypasses all delete restrictions
- Admin group skips ownership, state, and compliance checks
- _Requirements: 3.10, 4.5_
- [ ]* 8.7 Write property test for Admin delete bypass
- **Property 4: Admin bypasses all delete restrictions**
- Generate resources with random ownership, state, compliance linkage, delete as Admin, verify success
- **Validates: Requirements 3.10, 4.1, 4.5**
- [ ] 8.8 Write unit tests for conditional delete logic
- Test ownership rejection for non-owner
- Test state rejection for resolved/closed findings
- Test compliance linkage rejection
- Test cascade impact response format
- Test Admin bypass of all restrictions
- _Requirements: 3.5, 3.6, 3.7, 3.8, 3.9, 3.10_
- [x] 9. Checkpoint: Verify conditional delete logic
- Ensure all tests pass, ask the user if questions arise.
- [ ] 10. Update frontend AuthContext with group helpers
- [x] 10.1 Update `frontend/src/contexts/AuthContext.js`
- Read group from user object instead of role
- Replace hasRole with isInGroup(...groups) helper
- Update canWrite to check isInGroup('Admin', 'Standard_User')
- Add canDelete(resource) helper: Admin always true, Standard_User only if owns resource, others false
- Add canExport() helper: true for Admin, Standard_User, Leadership
- Update isAdmin() to check isInGroup('Admin')
- _Requirements: 10.1, 10.2, 10.3, 10.4, 10.5_
- [ ]* 10.2 Write property test for permission helpers
- **Property 7: Group permission helpers are consistent with group matrix**
- Generate all valid group values, call each helper, verify against permission matrix
- **Validates: Requirements 10.5**
- [ ] 11. Update frontend UI for group-based rendering
- [x] 11.1 Update `App.js` conditional rendering
- Use canWrite, canDelete, canExport, isAdmin for button and link visibility
- _Requirements: 10.1, 10.2, 10.3_
- [x] 11.2 Update `NavDrawer.js`
- Show admin panel link only when isAdmin() is true
- _Requirements: 10.3_
- [x] 11.3 Update `UserMenu.js`
- Display user group instead of role
- _Requirements: 10.1_
- [x] 11.4 Update all components using hasRole or canWrite
- Replace with new group-based helpers throughout components
- _Requirements: 10.5_
- [x] 11.5 Hide delete buttons for non-owned resources
- Standard_User sees delete only on resources they created
- _Requirements: 10.4_
- [ ] 12. Update User Management UI
- [x] 12.1 Replace role dropdown with group dropdown in `UserManagement.js`
- Options: Admin, Standard_User, Leadership, Read_Only
- _Requirements: 8.1, 8.2_
- [x] 12.2 Update form data and API calls to use group field
- Send group instead of role in create and update requests
- _Requirements: 8.2_
- [x] 12.3 Add confirmation dialog for group changes
- Show confirmation before applying any group change
- _Requirements: 8.3_
- [x] 12.4 Add extra warning when downgrading Admin
- Show additional warning in confirmation dialog
- _Requirements: 8.4_
- [x] 12.5 Prevent admin self-demotion in UI
- Disable group change dropdown for current user if Admin
- _Requirements: 8.5_
- [x] 12.6 Update user table to show group badges
- Display group badge with appropriate colors instead of role badge
- _Requirements: 8.1_
- [x] 13. Final checkpoint: Verify full integration
- Ensure all tests pass, ask the user if questions arise.
## Notes
- Tasks marked with `*` are optional and can be skipped for faster MVP
- Each task references specific requirements for traceability
- Checkpoints ensure incremental validation
- Property tests use `fast-check` library with minimum 100 iterations per test
- All backend code uses callback-based SQLite API wrapped in promises (matching existing patterns)
- All frontend code uses plain JavaScript (no TypeScript)

View File

@@ -0,0 +1,321 @@
# Design Document: Ivanti FP Workflow Submission
## Overview
This feature extends the existing Ivanti Queue (QueuePanel) in the Reporting Page to allow users to submit False Positive (FP) workflows directly to the Ivanti/RiskSense API. The implementation adds a submission modal triggered from the queue panel, a backend API endpoint that proxies the workflow creation and attachment upload to Ivanti, and local tracking of submissions in SQLite.
The design follows existing codebase conventions: factory-pattern Express routes, inline React styles with the dark tactical theme, Multer for file uploads, and the `ivantiPost()` HTTP helper for Ivanti API calls.
## Architecture
```mermaid
sequenceDiagram
participant U as User (Browser)
participant FE as React Frontend
participant BE as Express Backend
participant IV as Ivanti API
participant DB as SQLite
U->>FE: Select FP queue items, click "Create FP Workflow"
FE->>FE: Open FpWorkflowModal with selected items
U->>FE: Fill form, attach files, click Submit
FE->>BE: POST /api/ivanti/fp-workflow (multipart/form-data)
BE->>BE: Validate input, check auth
BE->>IV: POST /client/{clientId}/workflowBatch (create FP workflow)
IV-->>BE: 200 + workflow batch response (id, generatedId)
alt Attachments present
loop For each attachment
BE->>IV: POST /client/{clientId}/workflowBatch/{id}/attachment
IV-->>BE: 200 OK
end
end
BE->>DB: INSERT into ivanti_fp_submissions
BE->>DB: INSERT audit log entry
BE->>DB: UPDATE ivanti_todo_queue SET status='complete'
BE-->>FE: 200 + { workflowBatchId, generatedId, status }
FE->>FE: Show success, refresh queue panel
```
## Components and Interfaces
### Backend
#### New Route Module: `backend/routes/ivantiFpWorkflow.js`
Exports `createIvantiFpWorkflowRouter(db, requireAuth)` following the existing factory pattern.
**Endpoint: `POST /api/ivanti/fp-workflow`**
- Auth: `requireAuth(db)`, `requireGroup('Admin', 'Standard_User')`
- Content-Type: `multipart/form-data` (handled by Multer)
- Request fields:
- `name` (string, required) — workflow name, max 255 chars
- `reason` (string, required) — justification text
- `description` (string, optional) — additional details, max 2000 chars
- `expirationDate` (string, required) — ISO date string, must be future
- `scopeOverride` (string, optional) — "Authorized" (default) or "None"
- `findingIds` (string, required) — JSON-encoded array of finding ID strings
- `queueItemIds` (string, required) — JSON-encoded array of local queue item IDs
- `attachments` (files, optional) — up to 10 files, 10MB each
- Response (success):
```json
{
"success": true,
"workflowBatchId": 12345,
"generatedId": "FP#12345",
"attachmentResults": [
{ "filename": "evidence.pdf", "success": true },
{ "filename": "screenshot.png", "success": true }
],
"queueItemsUpdated": 3
}
```
- Response (error):
```json
{
"success": false,
"error": "Ivanti API returned status 401",
"step": "create_workflow",
"details": "..."
}
```
**Internal flow:**
1. Parse and validate all form fields
2. Verify all `queueItemIds` belong to the requesting user and are FP-type with pending status
3. Call Ivanti API to create the workflow batch
4. If attachments exist, upload each to the created workflow batch
5. Insert a submission record into `ivanti_fp_submissions`
6. Log audit entry via `logAudit()`
7. Mark queue items as complete
8. Return combined result
#### Ivanti API Calls
Reuses the existing `ivantiPost()` helper pattern from `ivantiWorkflows.js`. Adds a new `ivantiMultipartPost()` helper for attachment uploads that sends `multipart/form-data` instead of JSON.
**Create Workflow Batch:**
```
POST /client/{clientId}/workflowBatch
```
```json
{
"name": "FP - CVE-2024-1234 - Vendor X",
"type": "FALSE_POSITIVE",
"reason": "Scanner false positive confirmed by manual investigation",
"description": "Additional context...",
"expirationDate": "2025-12-31",
"scopeOverrideAuthorization": "AUTHORIZED",
"hostFindingIds": [123456, 789012],
"subType": "FALSE_POSITIVE"
}
```
**Upload Attachment:**
```
POST /client/{clientId}/workflowBatch/{workflowBatchId}/attachment
Content-Type: multipart/form-data
```
Form field: `file` — the binary file content.
#### Shared HTTP Helpers
The existing `ivantiPost()` function is duplicated across `ivantiWorkflows.js` and `ivantiFindings.js`. This design extracts it into a shared helper at `backend/helpers/ivantiApi.js` alongside the new multipart helper:
- `ivantiPost(urlPath, body, apiKey, skipTls)` — JSON POST (existing logic)
- `ivantiMultipartPost(urlPath, fileBuffer, fileName, apiKey, skipTls)` — multipart file upload
### Frontend
#### New Component: `FpWorkflowModal`
Located in `frontend/src/components/pages/ReportingPage.js` (inline, following the existing pattern where QueuePanel and AddToQueuePopover are defined in the same file).
**Props:**
- `open` (boolean) — controls visibility
- `onClose` (function) — close handler
- `selectedItems` (array) — FP queue items selected for submission
- `onSuccess` (function) — callback after successful submission, triggers queue refresh
**State:**
- `name`, `reason`, `description`, `expirationDate`, `scopeOverride` — form fields
- `files` — array of File objects for upload
- `submitting` — boolean, disables form during submission
- `progress` — object tracking current step and attachment progress
- `errors` — validation error map
- `result` — submission result (success/failure details)
**UI Layout:**
- Modal overlay with dark backdrop (matching existing modal patterns)
- Header: "Create FP Workflow" with close button
- Body sections:
1. Selected findings summary (read-only list with finding_id, title, CVEs)
2. Workflow configuration form (name, reason, description, expiration, scope override toggle)
3. File upload area (drag-and-drop zone + file list)
- Footer: Cancel and Submit buttons, progress indicator when submitting
#### QueuePanel Modifications
- Add a "Create FP Workflow" button in the footer, next to existing "Delete Selected" and "Clear Completed" buttons
- Button enabled only when `selectedIds` contains at least one pending FP-type item
- Clicking opens `FpWorkflowModal` with the filtered FP items
- After successful submission, the `onSuccess` callback triggers queue refresh
## Data Models
### New Table: `ivanti_fp_submissions`
```sql
CREATE TABLE IF NOT EXISTS ivanti_fp_submissions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
username TEXT NOT NULL,
ivanti_workflow_batch_id INTEGER,
ivanti_generated_id TEXT,
workflow_name TEXT NOT NULL,
reason TEXT NOT NULL,
description TEXT,
expiration_date TEXT NOT NULL,
scope_override TEXT NOT NULL DEFAULT 'Authorized',
finding_ids_json TEXT NOT NULL,
queue_item_ids_json TEXT NOT NULL,
attachment_count INTEGER DEFAULT 0,
attachment_results_json TEXT,
status TEXT NOT NULL DEFAULT 'success' CHECK(status IN ('success', 'partial', 'failed')),
error_message TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_fp_submissions_user ON ivanti_fp_submissions(user_id);
CREATE INDEX IF NOT EXISTS idx_fp_submissions_ivanti_id ON ivanti_fp_submissions(ivanti_generated_id);
```
**Status values:**
- `success` — workflow created and all attachments uploaded
- `partial` — workflow created but one or more attachments failed
- `failed` — workflow creation itself failed (record kept for audit)
### Migration Script: `backend/migrations/add_fp_submissions_table.js`
Standard migration script following the existing pattern (e.g., `add_ivanti_todo_queue_table.js`).
## 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: FP Workflow Button Enabled State
*For any* set of queue items and any selection of item IDs, the "Create FP Workflow" button should be enabled if and only if the selection contains at least one queue item that has `workflow_type === 'FP'` and `status === 'pending'`.
**Validates: Requirements 1.1**
### Property 2: FP-Only Item Filtering
*For any* set of selected queue items containing a mix of workflow types (FP, Archer, CARD), the items passed to the FP workflow submission modal should contain only items where `workflow_type === 'FP'`, and the count of filtered items should be less than or equal to the count of selected items.
**Validates: Requirements 1.2**
### Property 3: Form Validation Correctness
*For any* form state (name, reason, description, expirationDate, scopeOverride), validation should pass if and only if: name is a non-empty string of at most 255 characters, reason is a non-empty string, description (if provided) is at most 2000 characters, and expirationDate is a valid date strictly after today. When validation fails, the returned error map should contain a key for each invalid field and no keys for valid fields.
**Validates: Requirements 2.4, 2.5**
### Property 4: File Extension Validation
*For any* filename string, the file acceptance function should return true if and only if the file's extension (case-insensitive) is one of: .pdf, .png, .jpg, .jpeg, .gif, .doc, .docx, .xlsx, .csv, .txt, .zip. Files with disallowed extensions should be rejected.
**Validates: Requirements 3.3**
### Property 5: API Payload Construction
*For any* valid form input (name, reason, description, expirationDate, scopeOverride, findingIds), the constructed Ivanti API request body should contain: `type` equal to "FALSE_POSITIVE", `name` equal to the input name, `reason` equal to the input reason, `expirationDate` equal to the input date, `scopeOverrideAuthorization` mapped from the input scopeOverride value, and `hostFindingIds` equal to the input finding IDs parsed as integers.
**Validates: Requirements 4.1**
### Property 6: Queue Items Marked Complete on Success
*For any* set of queue item IDs associated with a successful FP workflow submission, after the post-submission handler runs, all those queue items should have `status === 'complete'`.
**Validates: Requirements 5.1**
### Property 7: Post-Submission Persistence Completeness
*For any* successful FP workflow submission with a given workflow batch ID, name, user ID, and finding IDs, the resulting submission record should contain all of: ivanti_workflow_batch_id, workflow_name, user_id, finding_ids_json (parseable to the original finding IDs array), and a non-null created_at timestamp. Additionally, the audit log entry should have action "ivanti_fp_workflow_created", entity_type "ivanti_workflow", and details containing the workflow name and finding IDs.
**Validates: Requirements 6.1, 6.2**
### Property 8: Role-Based UI Visibility
*For any* user role, the "Create FP Workflow" button should be visible if and only if the user's role is "editor" or "admin". Users with the "viewer" role should not see the button.
**Validates: Requirements 7.2**
## Error Handling
### Ivanti API Errors
| HTTP Status | Error Type | User-Facing Message | System Behavior |
|-------------|-----------|---------------------|-----------------|
| 401 | Auth failure | "Ivanti API key is invalid or missing. Contact your administrator." | Log error, preserve form state |
| 419 | Insufficient privileges | "API key lacks workflow creation permissions." | Log error, preserve form state |
| 429 | Rate limited | "Ivanti API rate limit reached. Please try again in a few minutes." | Log error, preserve form state |
| 5xx | Server error | "Ivanti API is temporarily unavailable. Please try again later." | Log error, preserve form state |
| Other | Unknown | "Workflow creation failed: {status} — {message}" | Log error with full response, preserve form state |
### Partial Failure (Attachment Upload)
When the workflow batch is created successfully but one or more attachment uploads fail:
- The submission record is saved with `status = 'partial'`
- The response includes the workflow batch ID and per-attachment success/failure details
- The UI shows which attachments failed and allows retry
- The queue items are still marked complete (the workflow itself was created)
### Local Database Errors
- If the submission record INSERT fails: log error, still return success to user (Ivanti workflow was created)
- If queue item status UPDATE fails: return success with a warning that local queue state may be stale
- If audit log INSERT fails: fire-and-forget (existing pattern from `logAudit()`)
### Input Validation Errors
- All validation errors return 400 with a structured error object mapping field names to error messages
- Frontend validates before sending to prevent unnecessary API calls
- Backend re-validates all inputs as a security measure
## Testing Strategy
### Property-Based Testing
Use `fast-check` as the property-based testing library for JavaScript.
Each correctness property maps to a single property-based test with a minimum of 100 iterations. Tests are tagged with the format: **Feature: ivanti-fp-workflow-submission, Property {number}: {title}**.
Property tests focus on pure functions extracted from the implementation:
- `isCreateFpButtonEnabled(items, selectedIds)` — Property 1
- `filterFpItems(items)` — Property 2
- `validateFpWorkflowForm(formData)` — Property 3
- `isAllowedFileExtension(filename)` — Property 4
- `buildIvantiPayload(formData, findingIds)` — Property 5
- Queue item status update logic — Property 6
- Submission record creation — Property 7
- Role-based visibility check — Property 8
### Unit Testing
Unit tests complement property tests by covering:
- Specific examples: known-good form submissions, known-bad inputs
- Edge cases: empty finding lists, maximum file size boundary, expiration date exactly tomorrow
- Error code mapping: verify each Ivanti HTTP status maps to the correct error message
- Integration points: Multer file handling, multipart form construction
- API response parsing: various Ivanti response formats
### Test File Locations
- `backend/__tests__/ivantiFpWorkflow.test.js` — backend route handler tests, validation, payload construction
- `backend/__tests__/ivantiFpWorkflow.property.test.js` — property-based tests for backend logic
- `frontend/src/__tests__/fpWorkflowModal.test.js` — frontend component and validation tests

View File

@@ -0,0 +1,99 @@
# Requirements Document
## Introduction
This feature adds the ability for users to select items from the Ivanti Queue (QueuePanel) and submit False Positive (FP) workflows directly to the Ivanti/RiskSense API. Users can configure the FP workflow with a name, reason, description, expiration date, and the "Authorized" scope override option. Supporting documentation and artifacts can be uploaded and attached to the workflow via the API. Successful submissions mark the corresponding queue items as complete and are tracked locally with full audit logging.
## Glossary
- **Dashboard**: The STEAM Security Dashboard application
- **Queue_Panel**: The slide-out panel in the Reporting Page that displays the user's Ivanti todo queue items grouped by vendor/CARD
- **Queue_Item**: A single entry in the ivanti_todo_queue table representing a host finding staged for workflow processing, with fields including finding_id, finding_title, cves_json, ip_address, vendor, workflow_type, and status
- **FP_Workflow**: A False Positive workflow batch created in the Ivanti/RiskSense platform to mark host findings as false positives, removing them from risk calculations
- **Ivanti_API**: The Ivanti/RiskSense REST API at https://platform4.risksense.com/api/v1, authenticated via x-api-key header
- **Workflow_Batch**: An Ivanti API resource representing a group of findings submitted together under a single workflow request
- **Scope_Override_Authorization**: An Ivanti workflow property that controls whether additional findings can be added to or removed from the workflow after creation; values are "None" or "Authorized"
- **Submission_Record**: A local database record tracking the details and outcome of an FP workflow submission made through the Dashboard
- **Attachment**: A supporting document or artifact (PDF, screenshot, etc.) uploaded alongside an FP workflow submission as evidence or justification
## Requirements
### Requirement 1: Select FP Queue Items for Workflow Submission
**User Story:** As an editor or admin, I want to select one or more FP-type items from the Ivanti Queue, so that I can batch them into a single False Positive workflow submission.
#### Acceptance Criteria
1. WHEN the Queue_Panel is open and contains FP-type Queue_Items, THE Dashboard SHALL display a "Create FP Workflow" action button that is enabled only when at least one pending FP-type Queue_Item is selected
2. WHEN a user selects Queue_Items of mixed workflow_type (FP and non-FP), THE Dashboard SHALL only include FP-type Queue_Items in the FP workflow submission and SHALL visually indicate which items are eligible
3. IF no pending FP-type Queue_Items are selected, THEN THE Dashboard SHALL disable the "Create FP Workflow" action button and display a tooltip explaining the requirement
4. WHEN the "Create FP Workflow" button is clicked, THE Dashboard SHALL open the FP Workflow Submission modal pre-populated with the selected finding IDs
### Requirement 2: Configure FP Workflow Details
**User Story:** As an editor or admin, I want to configure the FP workflow properties before submission, so that I can provide the required justification and metadata for the false positive request.
#### Acceptance Criteria
1. THE FP_Workflow submission modal SHALL present input fields for: workflow name (required, max 255 characters), reason/justification (required), description (optional, max 2000 characters), and expiration date (required, must be a future date)
2. THE FP_Workflow submission modal SHALL include a Scope_Override_Authorization toggle defaulting to "Authorized"
3. THE FP_Workflow submission modal SHALL display a summary list of the selected Queue_Items including finding_id, finding_title, and associated CVEs
4. WHEN a user attempts to submit with missing required fields, THE Dashboard SHALL display inline validation errors for each invalid field and prevent submission
5. IF the expiration date is set to a date in the past or today, THEN THE Dashboard SHALL reject the value and display a validation message indicating the date must be in the future
### Requirement 3: Upload Supporting Documentation
**User Story:** As an editor or admin, I want to upload supporting documents and artifacts with my FP workflow submission, so that reviewers have the evidence needed to approve the false positive request.
#### Acceptance Criteria
1. THE FP_Workflow submission modal SHALL include a file upload area that accepts multiple files with a maximum size of 10 MB per file
2. WHEN files are added to the upload area, THE Dashboard SHALL display each file name, size, and a remove button
3. THE Dashboard SHALL accept files with extensions: .pdf, .png, .jpg, .jpeg, .gif, .doc, .docx, .xlsx, .csv, .txt, .zip
4. IF a user attempts to upload a file exceeding 10 MB, THEN THE Dashboard SHALL reject the file and display an error message stating the size limit
5. IF a user attempts to upload a file with a disallowed extension, THEN THE Dashboard SHALL reject the file and display an error message listing the allowed file types
### Requirement 4: Submit FP Workflow to Ivanti API
**User Story:** As an editor or admin, I want to submit the configured FP workflow to the Ivanti API, so that the false positive request is created in the Ivanti/RiskSense platform with all associated findings and attachments.
#### Acceptance Criteria
1. WHEN the user clicks Submit, THE Dashboard SHALL send a POST request to the Ivanti_API to create a Workflow_Batch of type "False Positive" with the configured name, reason, description, expiration date, Scope_Override_Authorization setting, and the list of host finding IDs
2. WHEN the Workflow_Batch is created successfully and attachments are present, THE Dashboard SHALL upload each Attachment to the Ivanti_API associated with the created Workflow_Batch
3. WHEN the submission is in progress, THE Dashboard SHALL display a progress indicator showing the current step (creating workflow, uploading attachment 1 of N, etc.) and disable the Submit button to prevent duplicate submissions
4. WHEN the entire submission completes successfully, THE Dashboard SHALL display a success message including the Ivanti-generated workflow batch ID (e.g., "FP#12345")
5. IF the Ivanti_API returns a 401 status, THEN THE Dashboard SHALL display an error message indicating the API key is invalid or missing
6. IF the Ivanti_API returns a 429 status, THEN THE Dashboard SHALL display an error message indicating rate limiting and suggest retrying later
7. IF the Ivanti_API returns any other error status during workflow creation, THEN THE Dashboard SHALL display the error details and preserve the user's form input so they can retry without re-entering data
8. IF an attachment upload fails after the workflow is created, THEN THE Dashboard SHALL report which attachments failed, display the workflow batch ID for the successfully created workflow, and allow the user to retry the failed uploads
### Requirement 5: Post-Submission Queue Item Updates
**User Story:** As an editor or admin, I want queue items to be automatically marked complete after a successful FP workflow submission, so that my queue reflects the current processing state.
#### Acceptance Criteria
1. WHEN an FP workflow submission completes successfully, THE Dashboard SHALL mark all associated Queue_Items as "complete" status
2. WHEN Queue_Items are marked complete after submission, THE Dashboard SHALL refresh the Queue_Panel to reflect the updated statuses
3. IF marking a Queue_Item as complete fails locally, THEN THE Dashboard SHALL display a warning that the workflow was submitted successfully but the local queue status could not be updated
### Requirement 6: Local Submission Tracking
**User Story:** As an editor or admin, I want FP workflow submissions to be tracked locally, so that I can review submission history and audit past actions.
#### Acceptance Criteria
1. WHEN an FP workflow submission completes successfully, THE Dashboard SHALL create a Submission_Record in the local database containing: the Ivanti workflow batch ID, workflow name, submitting user ID, list of finding IDs, submission timestamp, and status
2. WHEN an FP workflow submission completes successfully, THE Dashboard SHALL log an audit entry with action "ivanti_fp_workflow_created", entity type "ivanti_workflow", the workflow batch ID as entity ID, and details including the finding IDs and workflow name
3. IF an FP workflow submission fails, THEN THE Dashboard SHALL log an audit entry with action "ivanti_fp_workflow_failed" including the error details
### Requirement 7: Authorization and Access Control
**User Story:** As a system administrator, I want FP workflow submission restricted to authorized users, so that only editors and admins can create workflows in the Ivanti platform.
#### Acceptance Criteria
1. THE Dashboard SHALL restrict the FP workflow submission API endpoint to users with the "Admin" or "Standard_User" group membership
2. THE Dashboard SHALL restrict the FP workflow submission UI controls to users with editor or admin roles
3. WHILE a user has the viewer role, THE Dashboard SHALL hide the "Create FP Workflow" button from the Queue_Panel

View File

@@ -0,0 +1,109 @@
# Implementation Plan: Ivanti FP Workflow Submission
## Overview
Implement the ability to select FP-type items from the Ivanti Queue and submit False Positive workflows to the Ivanti/RiskSense API, with file attachment support, local submission tracking, and audit logging. The implementation follows existing codebase conventions: factory-pattern Express routes, Multer for file uploads, inline React component styles with the dark tactical theme, and the `ivantiPost()` HTTP helper for Ivanti API calls.
## Tasks
- [x] 1. Database migration and shared helpers
- [x] 1.1 Create migration script `backend/migrations/add_fp_submissions_table.js`
- Create `ivanti_fp_submissions` table with columns: id, user_id, username, ivanti_workflow_batch_id, ivanti_generated_id, workflow_name, reason, description, expiration_date, scope_override, finding_ids_json, queue_item_ids_json, attachment_count, attachment_results_json, status (success/partial/failed), error_message, created_at
- Add indexes on user_id and ivanti_generated_id
- Follow existing migration pattern from `add_ivanti_todo_queue_table.js`
- _Requirements: 6.1_
- [x] 1.2 Extract shared Ivanti API helpers into `backend/helpers/ivantiApi.js`
- Move the `ivantiPost()` function from `ivantiWorkflows.js` into a shared module
- Add `ivantiMultipartPost(urlPath, fileBuffer, fileName, apiKey, skipTls)` for attachment uploads using Node.js `https` module with multipart/form-data boundary construction
- Export both functions; update `ivantiWorkflows.js` and `ivantiFindings.js` to import from the shared module
- _Requirements: 4.1, 4.2_
- [x] 2. Backend route — validation and payload construction
- [x] 2.1 Create `backend/routes/ivantiFpWorkflow.js` with validation and payload builder
- Export `createIvantiFpWorkflowRouter(db, requireAuth)` factory function
- Implement `POST /` route with `requireAuth(db)` and `requireGroup('Admin', 'Standard_User')` middleware
- Configure Multer for up to 10 file uploads, 10MB each, with allowed extensions: .pdf, .png, .jpg, .jpeg, .gif, .doc, .docx, .xlsx, .csv, .txt, .zip
- Implement `validateFpWorkflowForm(body)` — returns error map for invalid fields (name required max 255, reason required, description max 2000, expirationDate required and must be future date)
- Implement `buildIvantiPayload(formData, findingIds)` — constructs the Ivanti API request body with type "FALSE_POSITIVE", scopeOverrideAuthorization mapping, and hostFindingIds as integers
- Implement `isAllowedFileExtension(filename)` — checks against the allowed extensions list (case-insensitive)
- Verify all queueItemIds belong to the requesting user, are FP-type, and have pending status
- _Requirements: 2.4, 2.5, 3.3, 3.4, 3.5, 4.1, 7.1_
- [ ]* 2.2 Write property tests for validation and payload construction
- **Property 3: Form Validation Correctness** — For any form state, validation passes iff all required fields present and expiration date is future; error map keys match invalid fields only
- **Property 4: File Extension Validation** — For any filename, acceptance returns true iff extension is in the allowed set (case-insensitive)
- **Property 5: API Payload Construction** — For any valid form input, the constructed payload contains correct type, name, reason, expirationDate, scopeOverrideAuthorization, and hostFindingIds as integers
- Use `fast-check` library with minimum 100 iterations per property
- **Validates: Requirements 2.4, 2.5, 3.3, 4.1**
- [x] 3. Backend route — Ivanti API submission and local persistence
- [x] 3.1 Implement the submission flow in `ivantiFpWorkflow.js`
- Call Ivanti API `POST /client/{clientId}/workflowBatch` to create the FP workflow batch
- If attachments present, upload each via `ivantiMultipartPost()` to `/client/{clientId}/workflowBatch/{id}/attachment`
- Handle Ivanti API error responses: 401 (invalid key), 419 (insufficient privileges), 429 (rate limited), other errors
- On success: insert submission record into `ivanti_fp_submissions`, call `logAudit()` with action "ivanti_fp_workflow_created"
- On failure: call `logAudit()` with action "ivanti_fp_workflow_failed"
- Mark associated queue items as complete via `UPDATE ivanti_todo_queue SET status='complete'`
- Handle partial failures (workflow created but attachment upload failed) — save with status "partial"
- Return structured response with workflowBatchId, generatedId, attachmentResults, queueItemsUpdated
- _Requirements: 4.1, 4.2, 4.5, 4.6, 4.7, 4.8, 5.1, 6.1, 6.2, 6.3_
- [ ]* 3.2 Write property tests for queue item completion and submission persistence
- **Property 6: Queue Items Marked Complete on Success** — For any set of queue item IDs after successful submission, all items have status "complete"
- **Property 7: Post-Submission Persistence Completeness** — For any successful submission, the record contains all required fields (ivanti_workflow_batch_id, workflow_name, user_id, finding_ids_json, created_at) and audit entry has correct action/entity_type/details
- Use in-memory SQLite for test isolation
- **Validates: Requirements 5.1, 6.1, 6.2**
- [x] 4. Wire backend route into server.js
- [x] 4.1 Register the new route in `backend/server.js`
- Add `const createIvantiFpWorkflowRouter = require('./routes/ivantiFpWorkflow');`
- Mount at `app.use('/api/ivanti/fp-workflow', createIvantiFpWorkflowRouter(db, requireAuth));`
- Place near the existing Ivanti route registrations
- _Requirements: 7.1_
- [x] 5. Checkpoint — Backend complete
- Ensure all tests pass, ask the user if questions arise.
- [x] 6. Frontend — FP Workflow Modal component
- [x] 6.1 Implement `FpWorkflowModal` in `frontend/src/components/pages/ReportingPage.js`
- Add the modal component inline in ReportingPage.js following the existing pattern (QueuePanel, AddToQueuePopover are in the same file)
- Props: open, onClose, selectedItems (FP queue items), onSuccess
- Form fields: workflow name (text input, required), reason (textarea, required), description (textarea, optional), expiration date (date input, required), scope override toggle (Authorized/None, default Authorized)
- Display selected findings summary: finding_id, finding_title, CVEs for each item
- File upload area: drag-and-drop zone, file list with name/size/remove button, validate extensions and 10MB limit client-side
- Submit button with progress indicator (creating workflow → uploading attachment N of M)
- Error display: inline validation errors, API error messages with form state preservation
- Success display: workflow batch ID (e.g., "FP#12345") with close/done action
- Style with inline style objects matching the dark tactical theme from DESIGN_SYSTEM.md
- Icons from lucide-react (Upload, FileText, X, Check, AlertTriangle, Loader)
- _Requirements: 2.1, 2.2, 2.3, 2.4, 2.5, 3.1, 3.2, 3.3, 3.4, 3.5, 4.3, 4.4, 4.7, 4.8_
- [ ]* 6.2 Write property tests for frontend validation helpers
- **Property 1: FP Workflow Button Enabled State** — For any set of queue items and selection, button enabled iff selection contains at least one pending FP item
- **Property 2: FP-Only Item Filtering** — For any mixed-type selection, filtered result contains only FP items
- **Property 8: Role-Based UI Visibility** — For any user role, button visible iff role is editor or admin
- Extract `isCreateFpButtonEnabled`, `filterFpItems`, `shouldShowFpButton` as testable pure functions
- Use `fast-check` with minimum 100 iterations
- **Validates: Requirements 1.1, 1.2, 7.2**
- [x] 7. Frontend — QueuePanel integration
- [x] 7.1 Add "Create FP Workflow" button and modal wiring in QueuePanel
- Add "Create FP Workflow" button in QueuePanel footer, styled with amber/FP accent color
- Button enabled only when selectedIds contains at least one pending FP-type item
- Disabled state shows tooltip: "Select pending FP items to create a workflow"
- Hide button entirely for viewer role users (check via useAuth context)
- On click: filter selected items to FP-only, open FpWorkflowModal with filtered items
- Wire onSuccess callback to trigger queue refresh (call existing fetch function from parent)
- _Requirements: 1.1, 1.2, 1.3, 1.4, 5.2, 7.2, 7.3_
- [x] 8. Final checkpoint — Full integration
- Ensure all tests pass, ask the user if questions arise.
## Notes
- Tasks marked with `*` are optional and can be skipped for faster MVP
- Each task references specific requirements for traceability
- Property tests use `fast-check` library — install via `npm install --save-dev fast-check` in both backend and frontend
- The shared Ivanti API helper (task 1.2) updates existing imports in ivantiWorkflows.js and ivantiFindings.js — test those routes still work after the refactor
- Multer is already a project dependency (used for document uploads in server.js)

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

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

View File

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

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

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

451
README.md
View File

@@ -13,7 +13,7 @@ A self-hosted vulnerability management dashboard for the NTS-AEO-STEAM and NTS-A
- [Configuration](#configuration) - [Configuration](#configuration)
- [Running the Application](#running-the-application) - [Running the Application](#running-the-application)
- [Features](#features) - [Features](#features)
- [Authentication and User Roles](#authentication-and-user-roles) - [Authentication and User Groups](#authentication-and-user-groups)
- [Home — CVE Management](#home--cve-management) - [Home — CVE Management](#home--cve-management)
- [Reporting — Host Findings](#reporting--host-findings) - [Reporting — Host Findings](#reporting--host-findings)
- [Ivanti Queue](#ivanti-queue) - [Ivanti Queue](#ivanti-queue)
@@ -28,7 +28,9 @@ A self-hosted vulnerability management dashboard for the NTS-AEO-STEAM and NTS-A
- [Architecture](#architecture) - [Architecture](#architecture)
- [Database Schema](#database-schema) - [Database Schema](#database-schema)
- [Security Model](#security-model) - [Security Model](#security-model)
- [Upgrading an Existing Deployment](#upgrading-an-existing-deployment)
- [Migrations](#migrations) - [Migrations](#migrations)
- [Troubleshooting](#troubleshooting)
--- ---
@@ -46,7 +48,7 @@ The application provides:
- **AEO Compliance page** — weekly xlsx upload, diff preview, per-team metric health cards, device-level violation tracking with notes history - **AEO Compliance page** — weekly xlsx upload, diff preview, per-team metric health cards, device-level violation tracking with notes history
- Archer risk acceptance ticket tracking (EXC numbers) linked to CVE/vendor pairs - Archer risk acceptance ticket tracking (EXC numbers) linked to CVE/vendor pairs
- A knowledge base for internal documentation and policies - A knowledge base for internal documentation and policies
- Role-based access control with a full audit trail - Group-based access control (Admin, Standard_User, Leadership, Read_Only) with a full audit trail
--- ---
@@ -54,11 +56,11 @@ The application provides:
| Layer | Technology | | Layer | Technology |
|---|---| |---|---|
| Backend | Node.js, Express 5 | | Backend | Node.js 18+, Express 5 |
| Database | SQLite3 | | Database | SQLite3 |
| File uploads | Multer 2 | | File uploads | Multer 2 |
| Auth | bcryptjs, cookie-based sessions | | Auth | bcryptjs, cookie-based sessions, express-rate-limit |
| Frontend | React 19, lucide-react, xlsx | | Frontend | React 19, lucide-react, xlsx, rehype-sanitize |
| Compliance xlsx parsing | Python 3, pandas, openpyxl | | Compliance xlsx parsing | Python 3, pandas, openpyxl |
| Bulk notes import | Python 3 (stdlib only) | | Bulk notes import | Python 3 (stdlib only) |
@@ -84,7 +86,6 @@ cd cve-dashboard
### 2. Install backend dependencies ### 2. Install backend dependencies
```bash ```bash
cd backend
npm install npm install
``` ```
@@ -107,7 +108,20 @@ apt install -y python3-pandas python3-openpyxl
> The bulk notes import script (`import_notes_from_csv.py`) uses only Python stdlib and does **not** require these packages. > The bulk notes import script (`import_notes_from_csv.py`) uses only Python stdlib and does **not** require these packages.
### 5. Initialize the database ### 5. Configure environment variables
Create `backend/.env` — the server will refuse to start without `SESSION_SECRET`:
```bash
cd backend
cp .env.example .env
# Edit .env and set SESSION_SECRET to a random string:
# openssl rand -base64 32
```
See [Configuration](#configuration) for all available options.
### 6. Initialize the database
Run once from the `backend/` directory to create the SQLite database, all tables, indexes, and a default admin user: Run once from the `backend/` directory to create the SQLite database, all tables, indexes, and a default admin user:
@@ -116,13 +130,9 @@ cd backend
node setup.js node setup.js
``` ```
This creates `backend/cve_database.db` and a default admin account: This creates `backend/cve_database.db` and generates a random admin password printed to stdout. **Save the password — it is only shown once.**
- Username: `admin`
- Password: `admin123`
**Change the admin password immediately after first login.** ### 7. Run database migrations
### 6. Run database migrations
Apply all feature migrations in order: Apply all feature migrations in order:
@@ -136,9 +146,14 @@ node migrations/add_ivanti_todo_queue_table.js
node migrations/add_card_workflow_type.js node migrations/add_card_workflow_type.js
node migrations/add_todo_queue_ip_address.js node migrations/add_todo_queue_ip_address.js
node migrations/add_compliance_tables.js node migrations/add_compliance_tables.js
node migrations/add_finding_archive_tables.js
node migrations/add_archer_tickets_timestamps.js
node migrations/add_ivanti_counts_history_table.js
node migrations/add_user_groups.js
node migrations/add_created_by_columns.js
``` ```
### 7. Build the frontend ### 8. Build the frontend
```bash ```bash
cd frontend cd frontend
@@ -159,8 +174,8 @@ The application is configured via `.env` files. These files are gitignored and m
PORT=3001 PORT=3001
API_HOST=localhost API_HOST=localhost
CORS_ORIGINS=http://YOUR_IP:3000 CORS_ORIGINS=http://YOUR_IP:3000
SESSION_SECRET=change-this-to-a-long-random-string SESSION_SECRET=<generate with: openssl rand -base64 32>
NODE_ENV=production # NODE_ENV=production — see note below
# Optional: NVD API key for higher rate limits (50 req/30s vs 5 req/30s) # Optional: NVD API key for higher rate limits (50 req/30s vs 5 req/30s)
# Register at https://nvd.nist.gov/developers/request-an-api-key # Register at https://nvd.nist.gov/developers/request-an-api-key
@@ -176,6 +191,10 @@ IVANTI_LAST_NAME=
IVANTI_SKIP_TLS=false IVANTI_SKIP_TLS=false
``` ```
**`SESSION_SECRET` is required.** The server will exit on startup if it is not set. Generate one with `openssl rand -base64 32`.
**`NODE_ENV` and the Secure cookie flag:** When `NODE_ENV=production`, session cookies are set with the `Secure` flag, which means the browser will only send them over HTTPS connections. If you are running the application over plain HTTP (no TLS/SSL), you **must** leave `NODE_ENV` unset or set it to `development` — otherwise login will succeed but every subsequent API request will return 401 because the browser silently drops the cookie. Only set `NODE_ENV=production` when the application is served behind HTTPS (e.g., via a reverse proxy with TLS termination).
### Frontend: `frontend/.env` ### Frontend: `frontend/.env`
```env ```env
@@ -225,17 +244,26 @@ npm start
## Features ## Features
### Authentication and User Roles ### Authentication and User Groups
All routes require authentication. Three roles are supported: All routes require authentication. Four user groups are supported:
| Role | Permissions | | Group | Permissions |
|---|---| |---|---|
| `viewer` | Read-only: CVEs, documents, findings, reports, knowledge base, Archer tickets, compliance data | | `Admin` | Full CRUD on all resources, user management, audit log access, export all data, delete any resource regardless of ownership |
| `editor` | All viewer permissions plus: create/update CVEs, upload documents, sync Ivanti findings, save notes and overrides, manage knowledge base, manage Archer tickets, upload compliance reports, manage Ivanti Queue | | `Standard_User` | View all data, create and edit resources, delete own resources (with state and compliance restrictions), basic export (CSV/XLSX) |
| `admin` | All editor permissions plus: delete documents, delete reports, manage users, view audit logs | | `Leadership` | View all data, export reports/compliance/visualizations, no create/edit/delete |
| `Read_Only` | View all data only — no create, edit, delete, or export |
Sessions expire after 24 hours. Session tokens are stored in `httpOnly` cookies. **Standard User delete restrictions:**
- Can only delete resources they created (`created_by` ownership check)
- Cannot delete findings marked as resolved or closed
- Cannot delete tickets linked to compliance reports
- CVE deletion triggers a cascade impact check — if any associated Archer or JIRA ticket is compliance-linked, deletion is blocked and requires Admin intervention
Sessions expire after 24 hours. Session tokens are stored in `httpOnly` cookies. Login is rate-limited to 20 attempts per 15-minute window.
**Migration from legacy roles:** The `add_user_groups.js` migration automatically maps existing users: `admin``Admin`, `editor``Standard_User`, `viewer``Read_Only`. Unrecognized or NULL roles default to `Read_Only`.
--- ---
@@ -249,11 +277,11 @@ The home page is the primary CVE research and tracking tool.
- Color-coded severity badges: Critical (red), High (amber), Medium (sky blue), Low (green) - Color-coded severity badges: Critical (red), High (amber), Medium (sky blue), Low (green)
- Paginated list view - Paginated list view
**CVE Operations (editor/admin)** **CVE Operations (Admin/Standard_User)**
- Add a new CVE entry — NVD auto-fill populates description, severity, and published date automatically - Add a new CVE entry — NVD auto-fill populates description, severity, and published date automatically
- Edit any field on an existing CVE entry - Edit any field on an existing CVE entry
- Update status for all vendor rows matching a CVE ID in one click - Update status for all vendor rows matching a CVE ID in one click
- Delete a single vendor entry or all vendor entries for a CVE ID - Delete a single vendor entry or all vendor entries for a CVE ID (ownership restrictions apply for Standard_User)
- The same CVE ID can be tracked across multiple vendors independently - The same CVE ID can be tracked across multiple vendors independently
**Document Management** **Document Management**
@@ -265,7 +293,7 @@ The home page is the primary CVE research and tracking tool.
**NVD Integration** **NVD Integration**
- Auto-fill CVE description, severity, and published date from the NIST NVD API 2.0 when adding a new CVE - Auto-fill CVE description, severity, and published date from the NIST NVD API 2.0 when adding a new CVE
- Bulk NVD Sync (editor/admin): fetch updated metadata for all CVEs in the database in one operation - Bulk NVD Sync (Admin/Standard_User): fetch updated metadata for all CVEs in the database in one operation
- CVSS severity cascade: v3.1 preferred, then v3.0, then v2.0 - CVSS severity cascade: v3.1 preferred, then v3.0, then v2.0
- Rate-limit aware: respects NVD's 5 req/30s unauthenticated limit; with `NVD_API_KEY` the limit increases to 50 req/30s - Rate-limit aware: respects NVD's 5 req/30s unauthenticated limit; with `NVD_API_KEY` the limit increases to 50 req/30s
@@ -285,7 +313,7 @@ The Reporting page is the core operational view for remediation tracking. It int
#### Syncing Data #### Syncing Data
Click **Sync** (top right) to pull the latest findings from Ivanti. The sync: Click **Sync** (top right) to pull the latest findings from Ivanti. Sync requires Admin or Standard_User group. The sync:
1. Fetches all open host findings matching your BU filters and severity range (8.59.9 VRR) 1. Fetches all open host findings matching your BU filters and severity range (8.59.9 VRR)
2. Fetches the closed finding count separately 2. Fetches the closed finding count separately
3. Sweeps closed findings to capture FP workflow states (including Approved FPs now closed) 3. Sweeps closed findings to capture FP workflow states (including Approved FPs now closed)
@@ -324,19 +352,19 @@ Each row represents a single Ivanti host finding.
| Last Found | Last detection date from Ivanti | | Last Found | Last detection date from Ivanti |
| Notes | Free-form notes — inline editable, persists across syncs | | Notes | Free-form notes — inline editable, persists across syncs |
**Inline editing:** Click a Host or DNS cell to override the Ivanti value. An amber dot (●) marks overridden cells; use the revert button (↻) to restore the original. Overrides survive re-syncs. **Inline editing:** Click a Host or DNS cell to override the Ivanti value. An amber dot (●) marks overridden cells; use the revert button (↻) to restore the original. Overrides survive re-syncs. Requires Admin or Standard_User group.
**Filtering:** Click ⊙ on any column header for multi-select filtering. The `— empty —` option filters to findings with no value in that column. Multiple filters are ANDed. The Action Coverage chart also acts as a filter. **Filtering:** Click ⊙ on any column header for multi-select filtering. The `— empty —` option filters to findings with no value in that column. Multiple filters are ANDed. The Action Coverage chart also acts as a filter.
**Column management:** Toggle visibility and drag to reorder via the **Columns** button. Order and visibility persist to `localStorage`. **Column management:** Toggle visibility and drag to reorder via the **Columns** button. Order and visibility persist to `localStorage`.
**Export:** Click **Export** to download the current filtered view as CSV or XLSX. **Export:** Click **Export** to download the current filtered view as CSV or XLSX. Requires Admin, Standard_User, or Leadership group.
--- ---
### Ivanti Queue ### Ivanti Queue
A personal staging list for batch-processing FP, Archer, and CARD workflows without context-switching into Ivanti mid-review. A personal staging list for batch-processing FP, Archer, and CARD workflows without context-switching into Ivanti mid-review. Requires Admin or Standard_User group.
**Adding items:** Check the checkbox at the far left of any finding row. A popover appears: **Adding items:** Check the checkbox at the far left of any finding row. A popover appears:
- For **FP** and **Archer** items: enter the Vendor / Platform (e.g., "Juniper MX", "Cisco IOS-XE") - For **FP** and **Archer** items: enter the Vendor / Platform (e.g., "Juniper MX", "Cisco IOS-XE")
@@ -364,7 +392,7 @@ The Compliance page tracks NTS-AEO team posture against the AEO compliance frame
#### Upload Workflow #### Upload Workflow
Editors and admins can upload a new compliance report via the **Upload Report** button: Admin and Standard_User groups can upload a new compliance report via the **Upload Report** button:
1. Drop or browse for the `NTS_AEO_YYYY_MM_DD.xlsx` file 1. Drop or browse for the `NTS_AEO_YYYY_MM_DD.xlsx` file
2. The report is parsed server-side and a **diff preview** is shown — new violations, resolved items, and recurring items since the last upload 2. The report is parsed server-side and a **diff preview** is shown — new violations, resolved items, and recurring items since the last upload
@@ -391,7 +419,7 @@ A slide-out panel for a selected device showing:
- For **2.3.x vulnerability metrics**: the `Ivanti_Vulnerability_ID` is displayed with a **View in Reporting →** button that navigates directly to the Reporting page - For **2.3.x vulnerability metrics**: the `Ivanti_Vulnerability_ID` is displayed with a **View in Reporting →** button that navigates directly to the Reporting page
- **Resolved Metrics** — previously failing metrics now back in compliance - **Resolved Metrics** — previously failing metrics now back in compliance
- **History** — how many times the device has appeared on the report and since when - **History** — how many times the device has appeared on the report and since when
- **Notes** — timestamped notes per metric with a multi-metric selector if multiple metrics are failing - **Notes** — timestamped notes per metric with a multi-metric selector if multiple metrics are failing. Requires Admin or Standard_User group.
Notes persist across uploads and are keyed to the device hostname and metric ID. Notes persist across uploads and are keyed to the device hostname and metric ID.
@@ -405,11 +433,12 @@ Only **STEAM** and **ACCESS-ENG** teams are tracked. The team selector at the to
A document library for internal reference material — policies, runbooks, vendor advisories, and process guides. A document library for internal reference material — policies, runbooks, vendor advisories, and process guides.
- Upload documents with a title, optional description, and category - Upload documents with a title, optional description, and category (Admin/Standard_User)
- View documents inline in the browser (PDFs render in an iframe; Markdown files render as HTML) - View documents inline in the browser (PDFs render in a sandboxed iframe; Markdown files render as sanitized HTML)
- Download any document - Download any document
- Filter and browse by category - Filter and browse by category
- Editors and admins can upload and delete; all authenticated users can view - Admin can delete any article; Standard_User can delete articles they created
- All authenticated users can view
Allowed file types: PDF, Markdown, TXT, Office documents (DOC, DOCX, XLS, XLSX, PPT, PPTX), HTML, JSON, YAML, and images (PNG, JPG, GIF). Allowed file types: PDF, Markdown, TXT, Office documents (DOC, DOCX, XLS, XLSX, PPT, PPTX), HTML, JSON, YAML, and images (PNG, JPG, GIF).
@@ -417,7 +446,7 @@ Allowed file types: PDF, Markdown, TXT, Office documents (DOC, DOCX, XLS, XLSX,
### Exports ### Exports
Bulk export tools for reports and data extracts. Bulk export tools for reports and data extracts. Available to Admin, Standard_User, and Leadership groups. Read_Only users cannot access the Exports page.
--- ---
@@ -430,15 +459,18 @@ Track Archer exception tickets (EXC numbers) linked to specific CVE/vendor pairs
- Optional Archer URL field for deep-linking to the Archer record - Optional Archer URL field for deep-linking to the Archer record
- Filter tickets by CVE ID, vendor, or status - Filter tickets by CVE ID, vendor, or status
- Clicking an EXC badge on the Home page navigates to the Reporting page pre-filtered to findings with that EXC number in their notes - Clicking an EXC badge on the Home page navigates to the Reporting page pre-filtered to findings with that EXC number in their notes
- Admin/Standard_User can create, edit, and delete tickets (Standard_User delete subject to ownership and compliance linkage checks)
--- ---
### User Management (Admin) ### User Management (Admin)
- Create users with a role assignment - Create users with a group assignment (Admin, Standard_User, Leadership, Read_Only)
- Change username, email, password, role, or active status - Change username, email, password, group, or active status
- Group changes require confirmation; downgrading an Admin shows an additional warning
- Deactivating a user immediately invalidates all their active sessions - Deactivating a user immediately invalidates all their active sessions
- Admins cannot demote themselves or deactivate their own account - Admins cannot demote themselves or deactivate their own account
- All group changes are audit-logged with previous and new group values
--- ---
@@ -506,130 +538,147 @@ python3 import_notes_from_csv.py input.csv --db /path/to/cve_database.db
## API Reference ## API Reference
All endpoints are prefixed with `/api`. All endpoints except `/api/auth/login` and `/api/auth/logout` require a valid session cookie. All endpoints are prefixed with `/api`. All endpoints except `/api/auth/login` and `/api/auth/logout` require a valid session cookie. Group requirements are listed per endpoint.
### Auth ### Auth
| Method | Path | Auth | Description | | Method | Path | Group | Description |
|---|---|---|---| |---|---|---|---|
| POST | `/api/auth/login` | Public | Log in, receive session cookie | | POST | `/api/auth/login` | Public | Log in, receive session cookie (rate-limited: 20/15min) |
| POST | `/api/auth/logout` | Public | Invalidate session | | POST | `/api/auth/logout` | Public | Invalidate session |
| GET | `/api/auth/me` | Session | Get current user info | | GET | `/api/auth/me` | Any | Get current user info (returns `group` field) |
| POST | `/api/auth/cleanup-sessions` | Session | Delete expired sessions | | POST | `/api/auth/cleanup-sessions` | Admin | Delete expired sessions |
### CVEs ### CVEs
| Method | Path | Role | Description | | Method | Path | Group | Description |
|---|---|---|---| |---|---|---|---|
| GET | `/api/cves` | viewer+ | List CVEs; query params: `search`, `vendor`, `severity`, `status` | | GET | `/api/cves` | Any | List CVEs; query params: `search`, `vendor`, `severity`, `status` |
| POST | `/api/cves` | editor+ | Create a new CVE entry | | POST | `/api/cves` | Admin, Standard_User | Create a new CVE entry |
| PUT | `/api/cves/:id` | editor+ | Update a CVE entry by row ID | | PUT | `/api/cves/:id` | Admin, Standard_User | Update a CVE entry by row ID |
| PATCH | `/api/cves/:cveId/status` | editor+ | Update status for all vendor rows matching a CVE ID | | PATCH | `/api/cves/:cveId/status` | Admin, Standard_User | Update status for all vendor rows matching a CVE ID |
| DELETE | `/api/cves/:id` | editor+ | Delete a single CVE vendor entry | | DELETE | `/api/cves/:id` | Admin, Standard_User | Delete a single CVE vendor entry (ownership + cascade check for Standard_User) |
| DELETE | `/api/cves/by-cve-id/:cveId` | editor+ | Delete all vendor entries for a CVE ID | | DELETE | `/api/cves/by-cve-id/:cveId` | Admin, Standard_User | Delete all vendor entries for a CVE ID (ownership + cascade check for Standard_User) |
| GET | `/api/cves/check/:cveId` | viewer+ | Quick check: existence and status of a CVE | | GET | `/api/cves/check/:cveId` | Any | Quick check: existence and status of a CVE |
| GET | `/api/cves/distinct-ids` | viewer+ | All distinct CVE IDs (used by NVD sync) | | GET | `/api/cves/distinct-ids` | Any | All distinct CVE IDs (used by NVD sync) |
| GET | `/api/cves/:cveId/vendors` | viewer+ | All vendor entries for a specific CVE ID | | GET | `/api/cves/:cveId/vendors` | Any | All vendor entries for a specific CVE ID |
| GET | `/api/cves/compliance` | Any | Document compliance status view |
### Documents ### Documents
| Method | Path | Role | Description | | Method | Path | Group | Description |
|---|---|---|---| |---|---|---|---|
| GET | `/api/cves/:cveId/documents` | viewer+ | List documents for a CVE; optional `?vendor=` filter | | GET | `/api/cves/:cveId/documents` | Any | List documents for a CVE; optional `?vendor=` filter |
| POST | `/api/cves/:cveId/documents` | editor+ | Upload a document for a CVE/vendor pair | | POST | `/api/cves/:cveId/documents` | Admin, Standard_User | Upload a document for a CVE/vendor pair |
| DELETE | `/api/documents/:id` | admin | Delete a document and its file from disk | | DELETE | `/api/documents/:id` | Admin | Delete a document and its file from disk |
### NVD ### NVD
| Method | Path | Role | Description | | Method | Path | Group | Description |
|---|---|---|---| |---|---|---|---|
| GET | `/api/nvd/lookup/:cveId` | viewer+ | Look up a single CVE in the NVD 2.0 API | | GET | `/api/nvd/lookup/:cveId` | Any | Look up a single CVE in the NVD 2.0 API |
| POST | `/api/cves/nvd-sync` | editor+ | Bulk update CVE metadata from NVD | | POST | `/api/cves/nvd-sync` | Admin, Standard_User | Bulk update CVE metadata from NVD |
### JIRA Tickets
| Method | Path | Group | Description |
|---|---|---|---|
| GET | `/api/jira-tickets` | Any | List tickets; optional filters: `cve_id`, `vendor`, `status` |
| POST | `/api/jira-tickets` | Admin, Standard_User | Create a JIRA ticket |
| PUT | `/api/jira-tickets/:id` | Admin, Standard_User | Update a JIRA ticket |
| DELETE | `/api/jira-tickets/:id` | Admin, Standard_User | Delete a JIRA ticket (ownership + compliance check for Standard_User) |
### Ivanti — Host Findings ### Ivanti — Host Findings
| Method | Path | Role | Description | | Method | Path | Group | Description |
|---|---|---|---| |---|---|---|---|
| GET | `/api/ivanti/findings` | viewer+ | Get cached findings with notes and overrides merged in | | GET | `/api/ivanti/findings` | Any | Get cached findings with notes and overrides merged in |
| POST | `/api/ivanti/findings/sync` | viewer+ | Trigger an immediate findings sync from Ivanti | | POST | `/api/ivanti/findings/sync` | Admin, Standard_User | Trigger an immediate findings sync from Ivanti |
| GET | `/api/ivanti/findings/counts` | viewer+ | Open vs closed finding totals | | GET | `/api/ivanti/findings/counts` | Any | Open vs closed finding totals |
| GET | `/api/ivanti/findings/fp-workflow-counts` | viewer+ | FP workflow state breakdown | | GET | `/api/ivanti/findings/fp-workflow-counts` | Any | FP workflow state breakdown |
| PUT | `/api/ivanti/findings/:findingId/override` | editor+ | Override `hostName` or `dns`; empty value clears the override | | PUT | `/api/ivanti/findings/:findingId/override` | Admin, Standard_User | Override `hostName` or `dns`; empty value clears the override |
| PUT | `/api/ivanti/findings/:findingId/note` | viewer+ | Save or update a finding note (max 255 chars) | | PUT | `/api/ivanti/findings/:findingId/note` | Admin, Standard_User | Save or update a finding note (max 255 chars) |
### Ivanti — Workflows ### Ivanti — Workflows
| Method | Path | Role | Description | | Method | Path | Group | Description |
|---|---|---|---| |---|---|---|---|
| GET | `/api/ivanti/workflows` | viewer+ | Get cached workflow data | | GET | `/api/ivanti/workflows` | Any | Get cached workflow data |
| POST | `/api/ivanti/workflows/sync` | viewer+ | Trigger an immediate workflow sync | | POST | `/api/ivanti/workflows/sync` | Admin, Standard_User | Trigger an immediate workflow sync |
### Ivanti Queue ### Ivanti — Todo Queue
| Method | Path | Role | Description | | Method | Path | Group | Description |
|---|---|---|---| |---|---|---|---|
| GET | `/api/ivanti/queue` | viewer+ | Get all queue items for the current user | | GET | `/api/ivanti/todo-queue` | Any | Get all queue items for the current user |
| POST | `/api/ivanti/queue` | editor+ | Add a finding to the queue | | POST | `/api/ivanti/todo-queue` | Admin, Standard_User | Add a finding to the queue |
| PATCH | `/api/ivanti/queue/:id` | editor+ | Update a queue item (mark complete, edit vendor/type) | | PUT | `/api/ivanti/todo-queue/:id` | Admin, Standard_User | Update a queue item (mark complete, edit vendor/type) |
| DELETE | `/api/ivanti/queue/:id` | editor+ | Delete a single queue item | | DELETE | `/api/ivanti/todo-queue/:id` | Admin, Standard_User | Delete a single queue item |
| DELETE | `/api/ivanti/queue` | editor+ | Delete multiple queue items (body: `{ ids: [...] }`) | | DELETE | `/api/ivanti/todo-queue/completed` | Admin, Standard_User | Delete all completed queue items |
### Ivanti — Archive
| Method | Path | Group | Description |
|---|---|---|---|
| GET | `/api/ivanti/archive` | Any | Get finding archive data for severity score drift tracking |
### Compliance ### Compliance
| Method | Path | Role | Description | | Method | Path | Group | Description |
|---|---|---|---| |---|---|---|---|
| POST | `/api/compliance/preview` | editor+ | Parse an xlsx upload and return diff + temp file path | | POST | `/api/compliance/preview` | Admin, Standard_User | Parse an xlsx upload and return diff + temp file path |
| POST | `/api/compliance/commit` | editor+ | Commit a previewed upload to the database | | POST | `/api/compliance/commit` | Admin, Standard_User | Commit a previewed upload to the database |
| GET | `/api/compliance/uploads` | viewer+ | List all compliance upload records | | GET | `/api/compliance/uploads` | Any | List all compliance upload records |
| GET | `/api/compliance/summary` | viewer+ | Metric health summary; `?team=STEAM` | | GET | `/api/compliance/summary` | Any | Metric health summary; `?team=STEAM` |
| GET | `/api/compliance/items` | viewer+ | Device list; `?team=STEAM&status=active` | | GET | `/api/compliance/items` | Any | Device list; `?team=STEAM&status=active` |
| GET | `/api/compliance/items/:hostname` | viewer+ | Full detail for a device (metrics + notes) | | GET | `/api/compliance/items/:hostname` | Any | Full detail for a device (metrics + notes) |
| GET | `/api/compliance/notes/:hostname/:metricId` | viewer+ | Notes for a specific hostname/metric | | GET | `/api/compliance/notes/:hostname/:metricId` | Any | Notes for a specific hostname/metric |
| POST | `/api/compliance/notes` | editor+ | Add a note for a hostname/metric | | POST | `/api/compliance/notes` | Admin, Standard_User | Add a note for a hostname/metric |
### Knowledge Base ### Knowledge Base
| Method | Path | Role | Description | | Method | Path | Group | Description |
|---|---|---|---| |---|---|---|---|
| POST | `/api/knowledge-base/upload` | editor+ | Upload a new knowledge base document | | POST | `/api/knowledge-base/upload` | Admin, Standard_User | Upload a new knowledge base document |
| GET | `/api/knowledge-base` | viewer+ | List all articles | | GET | `/api/knowledge-base` | Any | List all articles |
| GET | `/api/knowledge-base/:id` | viewer+ | Get article metadata | | GET | `/api/knowledge-base/:id` | Any | Get article metadata |
| GET | `/api/knowledge-base/:id/content` | viewer+ | Get file content for inline display | | GET | `/api/knowledge-base/:id/content` | Any | Get file content for inline display |
| GET | `/api/knowledge-base/:id/download` | viewer+ | Download the file | | GET | `/api/knowledge-base/:id/download` | Any | Download the file |
| DELETE | `/api/knowledge-base/:id` | editor+ | Delete article and file | | DELETE | `/api/knowledge-base/:id` | Admin, Standard_User | Delete article and file (Standard_User: own articles only) |
### Archer Tickets ### Archer Tickets
| Method | Path | Role | Description | | Method | Path | Group | Description |
|---|---|---|---| |---|---|---|---|
| GET | `/api/archer-tickets` | viewer+ | List tickets; optional filters: `cve_id`, `vendor`, `status` | | GET | `/api/archer-tickets` | Any | List tickets; optional filters: `cve_id`, `vendor`, `status` |
| POST | `/api/archer-tickets` | editor+ | Create a new Archer ticket | | GET | `/api/archer-tickets/status-trend` | Any | Ticket counts by date and status for pipeline chart |
| PUT | `/api/archer-tickets/:id` | editor+ | Update an Archer ticket | | POST | `/api/archer-tickets` | Admin, Standard_User | Create a new Archer ticket |
| DELETE | `/api/archer-tickets/:id` | editor+ | Delete an Archer ticket | | PUT | `/api/archer-tickets/:id` | Admin, Standard_User | Update an Archer ticket |
| DELETE | `/api/archer-tickets/:id` | Admin, Standard_User | Delete an Archer ticket (ownership + compliance check for Standard_User) |
### Users (Admin only) ### Users (Admin only)
| Method | Path | Role | Description | | Method | Path | Group | Description |
|---|---|---|---| |---|---|---|---|
| GET | `/api/users` | admin | List all users | | GET | `/api/users` | Admin | List all users |
| GET | `/api/users/:id` | admin | Get a single user | | GET | `/api/users/:id` | Admin | Get a single user |
| POST | `/api/users` | admin | Create a user | | POST | `/api/users` | Admin | Create a user |
| PATCH | `/api/users/:id` | admin | Update a user | | PATCH | `/api/users/:id` | Admin | Update a user |
| DELETE | `/api/users/:id` | admin | Delete a user | | DELETE | `/api/users/:id` | Admin | Delete a user |
### Audit Logs (Admin only) ### Audit Logs (Admin only)
| Method | Path | Role | Description | | Method | Path | Group | Description |
|---|---|---|---| |---|---|---|---|
| GET | `/api/audit-logs` | admin | Paginated audit log; filters: `user`, `action`, `entityType`, `startDate`, `endDate` | | GET | `/api/audit-logs` | Admin | Paginated audit log; filters: `user`, `action`, `entityType`, `startDate`, `endDate` |
| GET | `/api/audit-logs/actions` | admin | List distinct action types for filter dropdowns | | GET | `/api/audit-logs/actions` | Admin | List distinct action types for filter dropdowns |
### Utility ### Utility
| Method | Path | Role | Description | | Method | Path | Group | Description |
|---|---|---|---| |---|---|---|---|
| GET | `/api/vendors` | viewer+ | List all distinct vendor names | | GET | `/api/vendors` | Any | List all distinct vendor names |
| GET | `/api/stats` | viewer+ | Dashboard statistics | | GET | `/api/stats` | Any | Dashboard statistics |
--- ---
@@ -639,6 +688,7 @@ All endpoints are prefixed with `/api`. All endpoints except `/api/auth/login` a
cve-dashboard/ cve-dashboard/
├── start-servers.sh # Start backend + frontend in background ├── start-servers.sh # Start backend + frontend in background
├── stop-servers.sh # Stop all servers ├── stop-servers.sh # Stop all servers
├── package.json # Root package.json (backend dependencies)
├── backend/ ├── backend/
│ ├── server.js # Express app — routes, middleware, security headers │ ├── server.js # Express app — routes, middleware, security headers
@@ -649,7 +699,7 @@ cve-dashboard/
│ │ ├── knowledge_base/ # Knowledge base documents │ │ ├── knowledge_base/ # Knowledge base documents
│ │ └── temp/ # Temporary upload staging │ │ └── temp/ # Temporary upload staging
│ ├── routes/ │ ├── routes/
│ │ ├── auth.js # Login, logout, session check │ │ ├── auth.js # Login, logout, session check, rate limiting
│ │ ├── users.js # User CRUD (admin) │ │ ├── users.js # User CRUD (admin)
│ │ ├── auditLog.js # Audit log viewer (admin) │ │ ├── auditLog.js # Audit log viewer (admin)
│ │ ├── nvdLookup.js # NVD API proxy │ │ ├── nvdLookup.js # NVD API proxy
@@ -658,20 +708,13 @@ cve-dashboard/
│ │ ├── ivantiWorkflows.js # Ivanti workflow batch sync and cache │ │ ├── ivantiWorkflows.js # Ivanti workflow batch sync and cache
│ │ ├── ivantiFindings.js # Ivanti host findings sync, notes, overrides, FP counts │ │ ├── ivantiFindings.js # Ivanti host findings sync, notes, overrides, FP counts
│ │ ├── ivantiTodoQueue.js # Ivanti Queue — personal FP/Archer/CARD staging list │ │ ├── ivantiTodoQueue.js # Ivanti Queue — personal FP/Archer/CARD staging list
│ │ ├── ivantiArchive.js # Finding archive for severity score drift
│ │ └── compliance.js # AEO compliance upload, diff, device tracking, notes │ │ └── compliance.js # AEO compliance upload, diff, device tracking, notes
│ ├── middleware/ │ ├── middleware/
│ │ └── auth.js # requireAuth and requireRole middleware │ │ └── auth.js # requireAuth and requireGroup middleware
│ ├── helpers/ │ ├── helpers/
│ │ ── auditLog.js # logAudit helper (fire-and-forget) │ │ ── auditLog.js # logAudit helper (fire-and-forget)
│ ├── migrations/ │ ├── migrations/ # Sequential migration scripts (run manually with node)
│ │ ├── add_knowledge_base_table.js
│ │ ├── add_archer_tickets_table.js
│ │ ├── add_ivanti_sync_table.js
│ │ ├── add_ivanti_findings_tables.js
│ │ ├── add_ivanti_todo_queue_table.js # Ivanti Queue table
│ │ ├── add_card_workflow_type.js # CARD workflow type support
│ │ ├── add_todo_queue_ip_address.js # IP address column on queue items
│ │ └── add_compliance_tables.js # AEO compliance tables
│ └── scripts/ │ └── scripts/
│ ├── parse_compliance_xlsx.py # Parses NTS_AEO xlsx compliance reports │ ├── parse_compliance_xlsx.py # Parses NTS_AEO xlsx compliance reports
│ ├── import_notes_from_csv.py # Bulk-import finding notes from CSV │ ├── import_notes_from_csv.py # Bulk-import finding notes from CSV
@@ -682,24 +725,27 @@ cve-dashboard/
├── App.js # Home dashboard — CVE list, filters, modals, calendar ├── App.js # Home dashboard — CVE list, filters, modals, calendar
├── App.css # Global styles and CSS variables ├── App.css # Global styles and CSS variables
├── contexts/ ├── contexts/
│ └── AuthContext.js # Auth state provider (login, logout, role helpers) │ └── AuthContext.js # Auth state provider (login, logout, group helpers)
└── components/ └── components/
├── LoginForm.js # Login page ├── LoginForm.js # Login page
├── NavDrawer.js # Side navigation drawer ├── NavDrawer.js # Side navigation drawer (Admin Panel link for Admin group)
├── UserMenu.js # User dropdown in header ├── UserMenu.js # User dropdown in header (shows group badge)
├── CalendarWidget.js # Due-date calendar with Ivanti finding indicators ├── CalendarWidget.js # Due-date calendar with Ivanti finding indicators
├── UserManagement.js # Admin user management panel ├── UserManagement.js # Admin user management panel (group assignment)
├── AuditLog.js # Admin audit log viewer ├── AuditLog.js # Admin audit log viewer
├── NvdSyncModal.js # Bulk NVD sync dialog ├── NvdSyncModal.js # Bulk NVD sync dialog
├── KnowledgeBaseModal.js # Knowledge base upload/list modal ├── KnowledgeBaseModal.js # Knowledge base upload/list modal
├── KnowledgeBaseViewer.js # Inline document viewer ├── KnowledgeBaseViewer.js # Inline document viewer (sandboxed iframe, sanitized markdown)
└── pages/ └── pages/
├── ReportingPage.js # Host findings: charts, table, queue, export ├── ReportingPage.js # Host findings: charts, table, queue, export
├── CompliancePage.js # AEO compliance: metric cards, device table ├── CompliancePage.js # AEO compliance: metric cards, device table
├── ComplianceUploadModal.js # xlsx upload with diff preview ├── ComplianceUploadModal.js # xlsx upload with diff preview
├── ComplianceDetailPanel.js # Per-device metrics, history, notes ├── ComplianceDetailPanel.js # Per-device metrics, history, notes
├── ComplianceChartsPanel.js # Compliance trend charts
├── IvantiCountsChart.js # Ivanti counts history chart
├── ArchiveSummaryBar.js # Finding archive summary
├── KnowledgeBasePage.js # Knowledge base page ├── KnowledgeBasePage.js # Knowledge base page
└── ExportsPage.js # Exports page └── ExportsPage.js # Exports page (group-gated)
``` ```
--- ---
@@ -708,13 +754,13 @@ cve-dashboard/
### Core tables (created by `setup.js`) ### Core tables (created by `setup.js`)
**`cves`** — One row per CVE/vendor pair. `UNIQUE(cve_id, vendor)`. **`cves`** — One row per CVE/vendor pair. `UNIQUE(cve_id, vendor)`. Includes `created_by` column for ownership tracking.
**`documents`** — Files attached to a CVE/vendor pair. Foreign key to `cves(cve_id)`. **`documents`** — Files attached to a CVE/vendor pair. Foreign key to `cves(cve_id)` with `ON DELETE CASCADE`.
**`required_documents`** — Vendor-specific document requirements. **`required_documents`** — Vendor-specific document requirements.
**`users`** — Accounts with roles: `admin`, `editor`, `viewer`. **`users`** — Accounts with group-based access control. `user_group` column with values: `Admin`, `Standard_User`, `Leadership`, `Read_Only`. Enforced by INSERT/UPDATE triggers. Legacy `role` column retained for rollback safety.
**`sessions`** — Active sessions with 24-hour expiry. **`sessions`** — Active sessions with 24-hour expiry.
@@ -722,9 +768,11 @@ cve-dashboard/
### Feature tables (added by migrations) ### Feature tables (added by migrations)
**`knowledge_base`** — Document library entries with title, slug, category, description, and file metadata. **`knowledge_base`** — Document library entries with title, slug, category, description, file metadata, and `created_by`.
**`archer_tickets`** — Archer EXC exception tickets linked to CVE/vendor pairs. `UNIQUE(exc_number)`. **`archer_tickets`** — Archer EXC exception tickets linked to CVE/vendor pairs. `UNIQUE(exc_number)`. Includes `created_by` for ownership tracking. Foreign key to `cves(cve_id, vendor)` with `ON DELETE CASCADE`.
**`jira_tickets`** — JIRA tickets linked to CVE/vendor pairs. Includes `created_by`. Foreign key to `cves(cve_id, vendor)` with `ON DELETE CASCADE`.
**`ivanti_sync_state`** — Single-row cache for Ivanti workflow batch data. **`ivanti_sync_state`** — Single-row cache for Ivanti workflow batch data.
@@ -752,18 +800,47 @@ cve-dashboard/
## Security Model ## Security Model
### Authentication
- Cookie-based sessions with `httpOnly: true`, `sameSite: lax`, `secure: true` (in production)
- Sessions expire after 24 hours
- Login rate-limited to 20 attempts per 15-minute window via `express-rate-limit`
- `SESSION_SECRET` is required — server refuses to start without it
### Group-based access control
Four groups with distinct permission boundaries enforced server-side via `requireGroup` middleware:
| Capability | Admin | Standard_User | Leadership | Read_Only |
|---|---|---|---|---|
| View all data | ✓ | ✓ | ✓ | ✓ |
| Create/edit resources | ✓ | ✓ | ✗ | ✗ |
| Delete own resources | ✓ | ✓ (restricted) | ✗ | ✗ |
| Delete any resource | ✓ | ✗ | ✗ | ✗ |
| Export (CSV/XLSX) | ✓ | ✓ | ✓ | ✗ |
| Admin panel / user management | ✓ | ✗ | ✗ | ✗ |
Standard_User delete restrictions are enforced at the API level: ownership check, finding state check, compliance linkage check, and cascade impact check for CVEs.
### File upload security ### File upload security
- Extension allowlist enforced by Multer; executables (`.exe`, `.js`, `.sh`, `.py`, `.bat`, etc.) are blocked - Extension allowlist enforced by Multer; executables (`.exe`, `.js`, `.sh`, `.py`, `.bat`, etc.) are blocked
- MIME type prefix validation in addition to extension checking - MIME type prefix validation in addition to extension checking
- 10 MB per-file size limit - 10 MB per-file size limit
- Filenames are sanitized: path separators, `..` sequences, null bytes, and non-alphanumeric characters are removed - Filenames are sanitized: path separators, `..` sequences, null bytes, and non-alphanumeric characters are removed
- Content-Disposition headers sanitize filenames to prevent header injection
### Path traversal prevention ### Path traversal prevention
- `sanitizePathSegment()` strips `/`, `\`, `..`, and null bytes from any value used in `path.join()` - `sanitizePathSegment()` strips `/`, `\`, `..`, and null bytes from any value used in `path.join()`
- `isPathWithinUploads()` verifies resolved paths stay within the uploads root before any file operation - `isPathWithinUploads()` verifies resolved paths stay within the uploads root before any file operation
### Content security
- Knowledge base PDF iframe uses `sandbox="allow-same-origin"` to prevent script execution
- Markdown rendering uses `rehype-sanitize` to strip dangerous HTML
- CSP `frame-ancestors` header derived from `CORS_ORIGINS` environment variable
### Input validation ### Input validation
- CVE ID must match `/^CVE-\d{4}-\d{4,}$/` - CVE ID must match `/^CVE-\d{4}-\d{4,}$/`
@@ -771,14 +848,10 @@ cve-dashboard/
- Status must be one of: `Open`, `Addressed`, `In Progress`, `Resolved` - Status must be one of: `Open`, `Addressed`, `In Progress`, `Resolved`
- Archer EXC numbers must match `/^EXC-\d+$/` - Archer EXC numbers must match `/^EXC-\d+$/`
- Finding override field must be one of: `hostName`, `dns` - Finding override field must be one of: `hostName`, `dns`
- User group validated against: `Admin`, `Standard_User`, `Leadership`, `Read_Only` (enforced by DB triggers and app-level validation)
- Hostname format validated with `/^[a-zA-Z0-9._-]+$/` in compliance notes
- All database operations use prepared statements — no string interpolation in SQL - All database operations use prepared statements — no string interpolation in SQL
### Error handling
- 500 responses never expose internal error messages to the client
- Full errors are logged server-side only
- Descriptive 400/409 responses contain only application-authored validation messages
### Security headers ### Security headers
Applied to all responses: Applied to all responses:
@@ -789,15 +862,69 @@ Applied to all responses:
- `Referrer-Policy: strict-origin-when-cross-origin` - `Referrer-Policy: strict-origin-when-cross-origin`
- `Permissions-Policy: camera=(), microphone=(), geolocation=()` - `Permissions-Policy: camera=(), microphone=(), geolocation=()`
### Session cookies ---
`httpOnly: true`, `sameSite: lax`, `secure: true` in production (`NODE_ENV=production`). ## Upgrading an Existing Deployment
This procedure updates the application code and schema while preserving all existing data. The database file (`backend/cve_database.db`) is never overwritten by `git pull` — it is gitignored.
```bash
# 1. Stop the running servers
cd /home/cve-dashboard
./stop-servers.sh
# 2. Pull latest code
git pull origin master
# 3. Install backend dependencies (picks up any new packages)
npm install
# 4. Install frontend dependencies
cd frontend
npm install
cd ..
# 5. Ensure SESSION_SECRET is set in backend/.env
# If missing:
# echo "SESSION_SECRET=$(openssl rand -base64 32)" >> backend/.env
# 6. Run all migrations (idempotent — safe to re-run, skips already-applied changes)
cd backend
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
node migrations/add_finding_archive_tables.js
node migrations/add_archer_tickets_timestamps.js
node migrations/add_ivanti_counts_history_table.js
node migrations/add_user_groups.js
node migrations/add_created_by_columns.js
cd ..
# 7. Rebuild the frontend
cd frontend
npm run build
cd ..
# 8. Start servers
./start-servers.sh
```
After upgrading, clear your browser cookies and log in fresh — session format changes between versions will invalidate old sessions.
> **Do not re-run `node setup.js`** on an existing deployment. It is only for first-time initialization. Re-running it will not destroy data (it checks for existing tables/users), but it is unnecessary and may create a duplicate admin account.
> **NODE_ENV reminder:** If you are running over plain HTTP (no TLS), make sure `NODE_ENV` is **not** set to `production` in `backend/.env`. See [Troubleshooting](#troubleshooting) for details.
--- ---
## Migrations ## Migrations
Migrations are standalone Node.js scripts. Run them in the listed order on a fresh install. All use `CREATE TABLE IF NOT EXISTS` or `ALTER TABLE ... ADD COLUMN IF NOT EXISTS` and are safe to re-run. Migrations are standalone Node.js scripts. Run them in the listed order on a fresh install. All are idempotent and safe to re-run.
```bash ```bash
cd backend cd backend
@@ -809,6 +936,11 @@ node migrations/add_ivanti_todo_queue_table.js
node migrations/add_card_workflow_type.js node migrations/add_card_workflow_type.js
node migrations/add_todo_queue_ip_address.js node migrations/add_todo_queue_ip_address.js
node migrations/add_compliance_tables.js node migrations/add_compliance_tables.js
node migrations/add_finding_archive_tables.js
node migrations/add_archer_tickets_timestamps.js
node migrations/add_ivanti_counts_history_table.js
node migrations/add_user_groups.js
node migrations/add_created_by_columns.js
``` ```
For deployments upgrading from an older schema, the following legacy migration scripts are also available in `backend/`: For deployments upgrading from an older schema, the following legacy migration scripts are also available in `backend/`:
@@ -818,3 +950,38 @@ For deployments upgrading from an older schema, the following legacy migration s
- `migrate-to-1.1.js` — General 1.0 → 1.1 schema update - `migrate-to-1.1.js` — General 1.0 → 1.1 schema update
> Several columns (`fp_workflow_counts_json`, `fp_id_counts_json`, `seen_count`, `summary_json`) are added automatically via idempotent `ALTER TABLE` statements each time the server starts. No manual re-run is needed. > Several columns (`fp_workflow_counts_json`, `fp_id_counts_json`, `seen_count`, `summary_json`) are added automatically via idempotent `ALTER TABLE` statements each time the server starts. No manual re-run is needed.
---
## Troubleshooting
### Login succeeds but all pages show "Error Loading" / 401 Unauthorized
**Symptom:** You can log in successfully, but the dashboard shows "Error Loading CVEs", "Failed to fetch", and the browser console shows 401 on every API call.
**Cause:** The session cookie has the `Secure` flag set (because `NODE_ENV=production` in `backend/.env`), but the application is being accessed over plain HTTP. Browsers silently refuse to send `Secure` cookies over non-HTTPS connections, so every request after login arrives without a session cookie.
**Fix:** Either:
1. Remove `NODE_ENV=production` from `backend/.env` (or set it to `development`) and restart the backend, **or**
2. Set up HTTPS (e.g., via nginx reverse proxy with TLS termination) and access the app over `https://`
### Login fails with "Too many login attempts"
**Cause:** The login endpoint is rate-limited to 20 attempts per 15-minute window. Wait 15 minutes or restart the backend to reset the counter.
### Server refuses to start: "SESSION_SECRET environment variable must be set"
**Fix:** Add a `SESSION_SECRET` to `backend/.env`:
```bash
echo "SESSION_SECRET=$(openssl rand -base64 32)" >> backend/.env
```
### After upgrading: "user_group" errors or missing group data
**Fix:** Run the group migration:
```bash
cd backend
node migrations/add_user_groups.js
node migrations/add_created_by_columns.js
```
This maps existing roles to groups automatically (admin→Admin, editor→Standard_User, viewer→Read_Only).

View File

@@ -0,0 +1,154 @@
// Shared Ivanti / RiskSense API helpers
// Centralizes HTTP calls so ivantiWorkflows.js, ivantiFindings.js, and
// ivantiFpWorkflow.js all use the same implementation.
const https = require('https');
const IVANTI_URL_BASE = 'https://platform4.risksense.com/api/v1';
// ---------------------------------------------------------------------------
// JSON POST — used for search, workflow creation, etc.
// ---------------------------------------------------------------------------
function ivantiPost(urlPath, body, apiKey, skipTls) {
const bodyStr = JSON.stringify(body);
const fullUrl = new URL(IVANTI_URL_BASE + urlPath);
return new Promise((resolve, reject) => {
const options = {
hostname: fullUrl.hostname,
path: fullUrl.pathname + fullUrl.search,
method: 'POST',
headers: {
'accept': '*/*',
'content-type': 'application/json',
'x-api-key': apiKey,
'x-http-client-type': 'browser',
'content-length': Buffer.byteLength(bodyStr)
},
rejectUnauthorized: !skipTls,
timeout: 15000
};
const req = https.request(options, (res) => {
let data = '';
res.on('data', (chunk) => { data += chunk; });
res.on('end', () => resolve({ status: res.statusCode, body: data }));
});
req.on('timeout', () => req.destroy(new Error('Request timed out')));
req.on('error', reject);
req.write(bodyStr);
req.end();
});
}
// ---------------------------------------------------------------------------
// Multipart POST — used for file attachment uploads.
// Constructs multipart/form-data manually using Node's https module.
// ---------------------------------------------------------------------------
function ivantiMultipartPost(urlPath, fileBuffer, fileName, apiKey, skipTls) {
const boundary = '----IvantiUpload' + Date.now().toString(36) + Math.random().toString(36).slice(2);
const fullUrl = new URL(IVANTI_URL_BASE + urlPath);
// Build multipart body
const preamble = Buffer.from(
`--${boundary}\r\n` +
`Content-Disposition: form-data; name="file"; filename="${fileName}"\r\n` +
`Content-Type: application/octet-stream\r\n\r\n`
);
const epilogue = Buffer.from(`\r\n--${boundary}--\r\n`);
const bodyBuffer = Buffer.concat([preamble, fileBuffer, epilogue]);
return new Promise((resolve, reject) => {
const options = {
hostname: fullUrl.hostname,
path: fullUrl.pathname + fullUrl.search,
method: 'POST',
headers: {
'accept': '*/*',
'content-type': `multipart/form-data; boundary=${boundary}`,
'x-api-key': apiKey,
'x-http-client-type': 'browser',
'content-length': bodyBuffer.length
},
rejectUnauthorized: !skipTls,
timeout: 30000
};
const req = https.request(options, (res) => {
let data = '';
res.on('data', (chunk) => { data += chunk; });
res.on('end', () => resolve({ status: res.statusCode, body: data }));
});
req.on('timeout', () => req.destroy(new Error('Request timed out')));
req.on('error', reject);
req.write(bodyBuffer);
req.end();
});
}
// ---------------------------------------------------------------------------
// Multipart form POST — used for endpoints that accept mixed form fields + files.
// fields: array of { name, value } for text form fields
// files: array of { name, buffer, filename } for file uploads
// ---------------------------------------------------------------------------
function ivantiFormPost(urlPath, fields, files, apiKey, skipTls) {
const boundary = '----IvantiForm' + Date.now().toString(36) + Math.random().toString(36).slice(2);
const fullUrl = new URL(IVANTI_URL_BASE + urlPath);
const parts = [];
// Text fields
for (const { name, value } of fields) {
parts.push(Buffer.from(
`--${boundary}\r\n` +
`Content-Disposition: form-data; name="${name}"\r\n\r\n` +
`${value}\r\n`
));
}
// File fields
for (const { name, buffer, filename } of files) {
parts.push(Buffer.from(
`--${boundary}\r\n` +
`Content-Disposition: form-data; name="${name}"; filename="${filename}"\r\n` +
`Content-Type: application/octet-stream\r\n\r\n`
));
parts.push(buffer);
parts.push(Buffer.from('\r\n'));
}
parts.push(Buffer.from(`--${boundary}--\r\n`));
const bodyBuffer = Buffer.concat(parts);
return new Promise((resolve, reject) => {
const options = {
hostname: fullUrl.hostname,
path: fullUrl.pathname + fullUrl.search,
method: 'POST',
headers: {
'accept': '*/*',
'content-type': `multipart/form-data; boundary=${boundary}`,
'x-api-key': apiKey,
'x-http-client-type': 'browser',
'content-length': bodyBuffer.length
},
rejectUnauthorized: !skipTls,
timeout: 60000
};
const req = https.request(options, (res) => {
let data = '';
res.on('data', (chunk) => { data += chunk; });
res.on('end', () => resolve({ status: res.statusCode, body: data }));
});
req.on('timeout', () => req.destroy(new Error('Request timed out')));
req.on('error', reject);
req.write(bodyBuffer);
req.end();
});
}
module.exports = { IVANTI_URL_BASE, ivantiPost, ivantiMultipartPost, ivantiFormPost };

View File

@@ -12,7 +12,7 @@ function requireAuth(db) {
try { try {
const session = await new Promise((resolve, reject) => { const session = await new Promise((resolve, reject) => {
db.get( db.get(
`SELECT s.*, u.id as user_id, u.username, u.email, u.role, u.is_active `SELECT s.*, u.id as user_id, u.username, u.email, u.role, u.user_group, u.is_active
FROM sessions s FROM sessions s
JOIN users u ON s.user_id = u.id JOIN users u ON s.user_id = u.id
WHERE s.session_id = ? AND s.expires_at > datetime('now')`, WHERE s.session_id = ? AND s.expires_at > datetime('now')`,
@@ -37,7 +37,8 @@ function requireAuth(db) {
id: session.user_id, id: session.user_id,
username: session.username, username: session.username,
email: session.email, email: session.email,
role: session.role role: session.role,
group: session.user_group
}; };
next(); next();
@@ -48,18 +49,18 @@ function requireAuth(db) {
}; };
} }
// Require specific role(s) // Require specific group(s)
function requireRole(...allowedRoles) { function requireGroup(...allowedGroups) {
return (req, res, next) => { return (req, res, next) => {
if (!req.user) { if (!req.user) {
return res.status(401).json({ error: 'Authentication required' }); return res.status(401).json({ error: 'Authentication required' });
} }
if (!allowedRoles.includes(req.user.role)) { if (!allowedGroups.includes(req.user.group)) {
return res.status(403).json({ return res.status(403).json({
error: 'Insufficient permissions', error: 'Insufficient permissions',
required: allowedRoles, required: allowedGroups,
current: req.user.role current: req.user.group
}); });
} }
@@ -67,4 +68,4 @@ function requireRole(...allowedRoles) {
}; };
} }
module.exports = { requireAuth, requireRole }; module.exports = { requireAuth, requireGroup };

View File

@@ -0,0 +1,56 @@
// Migration: Add created_at / updated_at columns to archer_tickets
//
// SQLite does not support ALTER TABLE ADD COLUMN IF NOT EXISTS, so we check
// PRAGMA table_info first and only add the column when it is absent.
//
// Run on any instance where archer_tickets was created before these columns
// were added to the schema (symptoms: every /api/archer-tickets call → 500).
//
// Usage: node backend/migrations/add_archer_tickets_timestamps.js
const sqlite3 = require('sqlite3').verbose();
const path = require('path');
const dbPath = path.join(__dirname, '..', 'cve_database.db');
const db = new sqlite3.Database(dbPath);
console.log('Starting archer_tickets timestamp migration...');
db.all('PRAGMA table_info(archer_tickets)', [], (err, columns) => {
if (err) {
console.error('Error reading table info:', err);
return db.close();
}
const names = columns.map(c => c.name);
db.serialize(() => {
if (!names.includes('created_at')) {
db.run(
`ALTER TABLE archer_tickets ADD COLUMN created_at DATETIME DEFAULT CURRENT_TIMESTAMP`,
(err) => {
if (err) console.error('Error adding created_at:', err);
else console.log('✓ created_at column added');
}
);
} else {
console.log('✓ created_at already exists — skipping');
}
if (!names.includes('updated_at')) {
db.run(
`ALTER TABLE archer_tickets ADD COLUMN updated_at DATETIME DEFAULT CURRENT_TIMESTAMP`,
(err) => {
if (err) console.error('Error adding updated_at:', err);
else console.log('✓ updated_at column added');
}
);
} else {
console.log('✓ updated_at already exists — skipping');
}
});
db.close(() => {
console.log('Migration complete. Restart the backend server.');
});
});

View File

@@ -0,0 +1,76 @@
// Migration: Add created_by column to cves, archer_tickets, and jira_tickets tables
// Stores the user ID of the creator for ownership-based delete checks.
// Idempotent — safe to run multiple times.
const sqlite3 = require('sqlite3').verbose();
const path = require('path');
/**
* Run the migration against the given database instance.
* Exported for testing with in-memory databases.
* @param {sqlite3.Database} db
* @returns {Promise<void>}
*/
function runMigration(db) {
return new Promise((resolve, reject) => {
const tables = ['cves', 'archer_tickets', 'jira_tickets'];
let completed = 0;
db.serialize(() => {
tables.forEach((table) => {
db.all(`PRAGMA table_info(${table})`, (err, columns) => {
if (err) {
// Table may not exist yet — skip gracefully
console.log(`⚠ Could not inspect ${table}: ${err.message} — skipping`);
completed++;
if (completed === tables.length) resolve();
return;
}
const hasCreatedBy = columns.some(col => col.name === 'created_by');
if (hasCreatedBy) {
console.log(`${table}.created_by already exists — skipping`);
completed++;
if (completed === tables.length) resolve();
return;
}
db.run(
`ALTER TABLE ${table} ADD COLUMN created_by INTEGER REFERENCES users(id)`,
(err) => {
if (err) {
reject(err);
return;
}
console.log(`✓ Added created_by column to ${table}`);
completed++;
if (completed === tables.length) resolve();
}
);
});
});
});
});
}
// Run directly if executed as a script
if (require.main === module) {
const dbPath = path.join(__dirname, '..', 'cve_database.db');
const db = new sqlite3.Database(dbPath);
console.log('Starting add_created_by_columns migration...');
runMigration(db)
.then(() => {
console.log('Migration complete!');
db.close(() => {
console.log('Database connection closed.');
});
})
.catch((err) => {
console.error('Migration failed:', err);
db.close();
process.exit(1);
});
}
module.exports = { runMigration };

View File

@@ -0,0 +1,75 @@
// Migration: Add ivanti_finding_archives and ivanti_archive_transitions tables
const sqlite3 = require('sqlite3').verbose();
const path = require('path');
const dbPath = path.join(__dirname, '..', 'cve_database.db');
const db = new sqlite3.Database(dbPath);
console.log('Starting finding archive tables migration...');
db.serialize(() => {
// Archive records — one row per finding that has entered the archive lifecycle
db.run(`
CREATE TABLE IF NOT EXISTS ivanti_finding_archives (
id INTEGER PRIMARY KEY AUTOINCREMENT,
finding_id TEXT NOT NULL UNIQUE,
finding_title TEXT NOT NULL DEFAULT '',
host_name TEXT NOT NULL DEFAULT '',
ip_address TEXT NOT NULL DEFAULT '',
current_state TEXT NOT NULL CHECK(current_state IN ('ARCHIVED', 'RETURNED', 'CLOSED')),
last_severity REAL NOT NULL DEFAULT 0,
first_archived_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_transition_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`, (err) => {
if (err) console.error('Error creating ivanti_finding_archives table:', err);
else console.log('✓ ivanti_finding_archives table created');
});
// Transition history — one row per state change on an archive record
db.run(`
CREATE TABLE IF NOT EXISTS ivanti_archive_transitions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
archive_id INTEGER NOT NULL,
from_state TEXT NOT NULL,
to_state TEXT NOT NULL,
severity_at_transition REAL NOT NULL DEFAULT 0,
reason TEXT NOT NULL DEFAULT '',
transitioned_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (archive_id) REFERENCES ivanti_finding_archives(id)
)
`, (err) => {
if (err) console.error('Error creating ivanti_archive_transitions table:', err);
else console.log('✓ ivanti_archive_transitions table created');
});
// Indexes for query performance
db.run(`
CREATE INDEX IF NOT EXISTS idx_archive_finding_id
ON ivanti_finding_archives(finding_id)
`, (err) => {
if (err) console.error('Error creating idx_archive_finding_id:', err);
else console.log('✓ idx_archive_finding_id index created');
});
db.run(`
CREATE INDEX IF NOT EXISTS idx_archive_current_state
ON ivanti_finding_archives(current_state)
`, (err) => {
if (err) console.error('Error creating idx_archive_current_state:', err);
else console.log('✓ idx_archive_current_state index created');
});
db.run(`
CREATE INDEX IF NOT EXISTS idx_transition_archive_id
ON ivanti_archive_transitions(archive_id)
`, (err) => {
if (err) console.error('Error creating idx_transition_archive_id:', err);
else console.log('✓ idx_transition_archive_id index created');
});
});
db.close(() => {
console.log('Migration complete!');
});

View File

@@ -0,0 +1,57 @@
// Migration: Add ivanti_fp_submissions table
const sqlite3 = require('sqlite3').verbose();
const path = require('path');
const dbPath = path.join(__dirname, '..', 'cve_database.db');
const db = new sqlite3.Database(dbPath);
console.log('Starting ivanti_fp_submissions migration...');
db.serialize(() => {
db.run(`
CREATE TABLE IF NOT EXISTS ivanti_fp_submissions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
username TEXT NOT NULL,
ivanti_workflow_batch_id INTEGER,
ivanti_generated_id TEXT,
workflow_name TEXT NOT NULL,
reason TEXT NOT NULL,
description TEXT,
expiration_date TEXT NOT NULL,
scope_override TEXT NOT NULL DEFAULT 'Authorized',
finding_ids_json TEXT NOT NULL,
queue_item_ids_json TEXT NOT NULL,
attachment_count INTEGER DEFAULT 0,
attachment_results_json TEXT,
status TEXT NOT NULL DEFAULT 'success' CHECK(status IN ('success', 'partial', 'failed')),
error_message TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`, (err) => {
if (err) console.error('Error creating table:', err);
else console.log('✓ ivanti_fp_submissions table created');
});
db.run(
'CREATE INDEX IF NOT EXISTS idx_fp_submissions_user ON ivanti_fp_submissions(user_id)',
(err) => {
if (err) console.error('Error creating index:', err);
else console.log('✓ user_id index created');
}
);
db.run(
'CREATE INDEX IF NOT EXISTS idx_fp_submissions_ivanti_id ON ivanti_fp_submissions(ivanti_generated_id)',
(err) => {
if (err) console.error('Error creating index:', err);
else console.log('✓ ivanti_generated_id index created');
}
);
console.log('✓ Migration statements queued');
});
db.close(() => {
console.log('Migration complete!');
});

View File

@@ -0,0 +1,146 @@
// Migration: Add user_group column to users table and map legacy roles
// Mapping: admin→Admin, editor→Standard_User, viewer→Read_Only
// NULL/unrecognized roles default to Read_Only
// Idempotent — safe to run multiple times
const sqlite3 = require('sqlite3').verbose();
const path = require('path');
/**
* Run the migration against the given database instance.
* Exported for testing with in-memory databases.
* @param {sqlite3.Database} db
* @returns {Promise<void>}
*/
function runMigration(db) {
return new Promise((resolve, reject) => {
db.serialize(() => {
// Check if user_group column already exists
db.all("PRAGMA table_info(users)", (err, columns) => {
if (err) {
reject(err);
return;
}
const hasUserGroup = columns.some(col => col.name === 'user_group');
if (hasUserGroup) {
console.log('✓ user_group column already exists — skipping migration');
resolve();
return;
}
console.log('Adding user_group column to users table...');
// SQLite doesn't support ADD COLUMN with CHECK inline in all versions,
// so we add the column first, map values, then recreate with constraint.
// However, SQLite also doesn't support ALTER TABLE ADD CONSTRAINT.
// Strategy: add column, map values, create index.
// The CHECK constraint is enforced via table rebuild.
db.run(
`ALTER TABLE users ADD COLUMN user_group VARCHAR(20) NOT NULL DEFAULT 'Read_Only'`,
(err) => {
if (err) {
reject(err);
return;
}
console.log('✓ Added user_group column');
// Map existing roles to groups
db.run(
`UPDATE users SET user_group = 'Admin' WHERE role = 'admin'`,
function(err) {
if (err) { reject(err); return; }
console.log(` ✓ Mapped ${this.changes} admin(s) → Admin`);
db.run(
`UPDATE users SET user_group = 'Standard_User' WHERE role = 'editor'`,
function(err) {
if (err) { reject(err); return; }
console.log(` ✓ Mapped ${this.changes} editor(s) → Standard_User`);
db.run(
`UPDATE users SET user_group = 'Read_Only' WHERE role = 'viewer'`,
function(err) {
if (err) { reject(err); return; }
console.log(` ✓ Mapped ${this.changes} viewer(s) → Read_Only`);
// Map NULL or unrecognized roles to Read_Only
db.run(
`UPDATE users SET user_group = 'Read_Only' WHERE user_group = 'Read_Only' AND role NOT IN ('admin', 'editor', 'viewer')`,
function(err) {
if (err) { reject(err); return; }
console.log(` ✓ Mapped ${this.changes} unrecognized role(s) → Read_Only`);
// Create index on user_group
db.run(
`CREATE INDEX IF NOT EXISTS idx_users_user_group ON users(user_group)`,
(err) => {
if (err) { reject(err); return; }
console.log('✓ Created idx_users_user_group index');
// Add CHECK constraint via trigger (SQLite can't ALTER TABLE ADD CONSTRAINT)
db.run(
`CREATE TRIGGER IF NOT EXISTS check_user_group_insert
BEFORE INSERT ON users
FOR EACH ROW
WHEN NEW.user_group NOT IN ('Admin', 'Standard_User', 'Leadership', 'Read_Only')
BEGIN
SELECT RAISE(ABORT, 'Invalid user_group value. Must be Admin, Standard_User, Leadership, or Read_Only');
END`,
(err) => {
if (err) { reject(err); return; }
db.run(
`CREATE TRIGGER IF NOT EXISTS check_user_group_update
BEFORE UPDATE OF user_group ON users
FOR EACH ROW
WHEN NEW.user_group NOT IN ('Admin', 'Standard_User', 'Leadership', 'Read_Only')
BEGIN
SELECT RAISE(ABORT, 'Invalid user_group value. Must be Admin, Standard_User, Leadership, or Read_Only');
END`,
(err) => {
if (err) { reject(err); return; }
console.log('✓ Created user_group validation triggers');
console.log('Migration complete!');
resolve();
}
);
}
);
}
);
}
);
}
);
}
);
}
);
}
);
});
});
});
}
// Run directly if executed as a script
if (require.main === module) {
const dbPath = path.join(__dirname, '..', 'cve_database.db');
const db = new sqlite3.Database(dbPath);
console.log('Starting add_user_groups migration...');
runMigration(db)
.then(() => {
db.close(() => {
console.log('Database connection closed.');
});
})
.catch((err) => {
console.error('Migration failed:', err);
db.close();
process.exit(1);
});
}
module.exports = { runMigration };

View File

@@ -1,6 +1,6 @@
// routes/archerTickets.js // routes/archerTickets.js
const express = require('express'); const express = require('express');
const { requireAuth, requireRole } = require('../middleware/auth'); const { requireAuth, requireGroup } = require('../middleware/auth');
const logAudit = require('../helpers/auditLog'); const logAudit = require('../helpers/auditLog');
// Validation helpers // Validation helpers
@@ -48,7 +48,7 @@ function createArcherTicketsRouter(db) {
}); });
// Create Archer ticket // Create Archer ticket
router.post('/', requireAuth(db), requireRole('editor', 'admin'), (req, res) => { router.post('/', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
const { exc_number, archer_url, status, cve_id, vendor } = req.body; const { exc_number, archer_url, status, cve_id, vendor } = req.body;
// Validation // Validation
@@ -74,9 +74,9 @@ function createArcherTicketsRouter(db) {
const validatedStatus = status || 'Draft'; const validatedStatus = status || 'Draft';
db.run( db.run(
`INSERT INTO archer_tickets (exc_number, archer_url, status, cve_id, vendor) `INSERT INTO archer_tickets (exc_number, archer_url, status, cve_id, vendor, created_by)
VALUES (?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?)`,
[exc_number.trim(), archer_url || null, validatedStatus, cve_id, vendor], [exc_number.trim(), archer_url || null, validatedStatus, cve_id, vendor, req.user.id],
function(err) { function(err) {
if (err) { if (err) {
console.error('Error creating Archer ticket:', err); console.error('Error creating Archer ticket:', err);
@@ -89,8 +89,8 @@ function createArcherTicketsRouter(db) {
logAudit(db, { logAudit(db, {
userId: req.user.id, userId: req.user.id,
action: 'CREATE_ARCHER_TICKET', action: 'CREATE_ARCHER_TICKET',
targetType: 'archer_ticket', entityType: 'archer_ticket',
targetId: this.lastID, entityId: String(this.lastID),
details: { exc_number, archer_url, status: validatedStatus, cve_id, vendor }, details: { exc_number, archer_url, status: validatedStatus, cve_id, vendor },
ipAddress: req.ip ipAddress: req.ip
}); });
@@ -104,7 +104,7 @@ function createArcherTicketsRouter(db) {
}); });
// Update Archer ticket // Update Archer ticket
router.put('/:id', requireAuth(db), requireRole('editor', 'admin'), (req, res) => { router.put('/:id', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
const { id } = req.params; const { id } = req.params;
const { exc_number, archer_url, status } = req.body; const { exc_number, archer_url, status } = req.body;
@@ -172,8 +172,8 @@ function createArcherTicketsRouter(db) {
logAudit(db, { logAudit(db, {
userId: req.user.id, userId: req.user.id,
action: 'UPDATE_ARCHER_TICKET', action: 'UPDATE_ARCHER_TICKET',
targetType: 'archer_ticket', entityType: 'archer_ticket',
targetId: id, entityId: String(id),
details: { before: existing, changes: req.body }, details: { before: existing, changes: req.body },
ipAddress: req.ip ipAddress: req.ip
}); });
@@ -184,8 +184,29 @@ function createArcherTicketsRouter(db) {
}); });
}); });
// Helper: perform the actual Archer ticket deletion
function performArcherDelete(db, req, res, id, ticket) {
db.run('DELETE FROM archer_tickets WHERE id = ?', [id], function(err) {
if (err) {
console.error(err);
return res.status(500).json({ error: 'Internal server error.' });
}
logAudit(db, {
userId: req.user.id,
action: 'DELETE_ARCHER_TICKET',
entityType: 'archer_ticket',
entityId: String(id),
details: { deleted: ticket },
ipAddress: req.ip
});
res.json({ message: 'Archer ticket deleted successfully' });
});
}
// Delete Archer ticket // Delete Archer ticket
router.delete('/:id', requireAuth(db), requireRole('editor', 'admin'), (req, res) => { router.delete('/:id', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
const { id } = req.params; const { id } = req.params;
db.get('SELECT * FROM archer_tickets WHERE id = ?', [id], (err, ticket) => { db.get('SELECT * FROM archer_tickets WHERE id = ?', [id], (err, ticket) => {
@@ -197,23 +218,45 @@ function createArcherTicketsRouter(db) {
return res.status(404).json({ error: 'Archer ticket not found.' }); return res.status(404).json({ error: 'Archer ticket not found.' });
} }
db.run('DELETE FROM archer_tickets WHERE id = ?', [id], function(err) { // Admin bypasses all delete restrictions
if (err) { if (req.user.group === 'Admin') {
console.error(err); return performArcherDelete(db, req, res, id, ticket);
return res.status(500).json({ error: 'Internal server error.' }); }
// Standard_User: ownership check
if (ticket.created_by && ticket.created_by !== req.user.id) {
return res.status(403).json({ error: 'You can only delete resources you created' });
}
// Standard_User: compliance linkage check
const excNumber = ticket.exc_number;
db.all(
`SELECT ci.id, ci.extra_json
FROM compliance_items ci
JOIN compliance_uploads cu ON ci.upload_id = cu.id
WHERE ci.status = 'active' AND ci.extra_json LIKE ?`,
[`%${excNumber}%`],
(compErr, compLinks) => {
// If compliance_items table doesn't exist yet, treat as no linkage
if (compErr && compErr.message && compErr.message.includes('no such table')) {
compLinks = [];
} else if (compErr) {
console.error(compErr);
return res.status(500).json({ error: 'Internal server error.' });
}
const isLinked = (compLinks || []).some(cl => {
const json = cl.extra_json || '';
return json.includes(excNumber);
});
if (isLinked) {
return res.status(403).json({ error: 'Cannot delete ticket linked to compliance report. Contact an admin.' });
}
return performArcherDelete(db, req, res, id, ticket);
} }
);
logAudit(db, {
userId: req.user.id,
action: 'DELETE_ARCHER_TICKET',
targetType: 'archer_ticket',
targetId: id,
details: { deleted: ticket },
ipAddress: req.ip
});
res.json({ message: 'Archer ticket deleted successfully' });
});
}); });
}); });

View File

@@ -1,11 +1,11 @@
// Audit Log Routes (Admin only) // Audit Log Routes (Admin only)
const express = require('express'); const express = require('express');
function createAuditLogRouter(db, requireAuth, requireRole) { function createAuditLogRouter(db, requireAuth, requireGroup) {
const router = express.Router(); const router = express.Router();
// All routes require admin role // All routes require Admin group
router.use(requireAuth(db), requireRole('admin')); router.use(requireAuth(db), requireGroup('Admin'));
// Get paginated audit logs with filters // Get paginated audit logs with filters
router.get('/', async (req, res) => { router.get('/', async (req, res) => {

View File

@@ -2,12 +2,35 @@
const express = require('express'); const express = require('express');
const bcrypt = require('bcryptjs'); const bcrypt = require('bcryptjs');
const crypto = require('crypto'); const crypto = require('crypto');
const rateLimit = require('express-rate-limit');
const { requireAuth, requireGroup } = require('../middleware/auth');
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 20, // 20 attempts per window
standardHeaders: true,
legacyHeaders: false,
message: { error: 'Too many login attempts. Please try again in 15 minutes.' }
});
function createAuthRouter(db, logAudit) { function createAuthRouter(db, logAudit) {
const router = express.Router(); const router = express.Router();
// Login /**
router.post('/login', async (req, res) => { * POST /api/auth/login
*
* Authenticates a user with username and password, creates a session,
* and sets an httpOnly session cookie. Rate-limited to 20 attempts per 15 minutes.
*
* @body {string} username - The user's login username
* @body {string} password - The user's password
* @returns {object} 200 - { message: 'Login successful', user: { id, username, email, group } }
* @returns {object} 400 - { error: 'Username and password are required' }
* @returns {object} 401 - { error: 'Invalid username or password' } | { error: 'Account is disabled' }
* @returns {object} 429 - { error: 'Too many login attempts. Please try again in 15 minutes.' }
* @returns {object} 500 - { error: 'Login failed' }
*/
router.post('/login', loginLimiter, async (req, res) => {
const { username, password } = req.body; const { username, password } = req.body;
if (!username || !password) { if (!username || !password) {
@@ -110,7 +133,7 @@ function createAuthRouter(db, logAudit) {
action: 'login', action: 'login',
entityType: 'auth', entityType: 'auth',
entityId: null, entityId: null,
details: { role: user.role }, details: { group: user.user_group },
ipAddress: req.ip ipAddress: req.ip
}); });
@@ -120,7 +143,7 @@ function createAuthRouter(db, logAudit) {
id: user.id, id: user.id,
username: user.username, username: user.username,
email: user.email, email: user.email,
role: user.role group: user.user_group
} }
}); });
} catch (err) { } catch (err) {
@@ -129,7 +152,14 @@ function createAuthRouter(db, logAudit) {
} }
}); });
// Logout /**
* POST /api/auth/logout
*
* Ends the current user session by deleting it from the database
* and clearing the session cookie.
*
* @returns {object} 200 - { message: 'Logged out successfully' }
*/
router.post('/logout', async (req, res) => { router.post('/logout', async (req, res) => {
const sessionId = req.cookies?.session_id; const sessionId = req.cookies?.session_id;
@@ -172,7 +202,16 @@ function createAuthRouter(db, logAudit) {
res.json({ message: 'Logged out successfully' }); res.json({ message: 'Logged out successfully' });
}); });
// Get current user /**
* GET /api/auth/me
*
* Returns the currently authenticated user based on the session cookie.
* Clears the cookie and returns 401 if the session is expired or the account is disabled.
*
* @returns {object} 200 - { user: { id, username, email, group } }
* @returns {object} 401 - { error: 'Not authenticated' } | { error: 'Session expired' } | { error: 'Account is disabled' }
* @returns {object} 500 - { error: 'Failed to get user' }
*/
router.get('/me', async (req, res) => { router.get('/me', async (req, res) => {
const sessionId = req.cookies?.session_id; const sessionId = req.cookies?.session_id;
@@ -183,7 +222,7 @@ function createAuthRouter(db, logAudit) {
try { try {
const session = await new Promise((resolve, reject) => { const session = await new Promise((resolve, reject) => {
db.get( db.get(
`SELECT s.*, u.id as user_id, u.username, u.email, u.role, u.is_active `SELECT s.*, u.id as user_id, u.username, u.email, u.user_group, u.is_active
FROM sessions s FROM sessions s
JOIN users u ON s.user_id = u.id JOIN users u ON s.user_id = u.id
WHERE s.session_id = ? AND s.expires_at > datetime('now')`, WHERE s.session_id = ? AND s.expires_at > datetime('now')`,
@@ -210,7 +249,7 @@ function createAuthRouter(db, logAudit) {
id: session.user_id, id: session.user_id,
username: session.username, username: session.username,
email: session.email, email: session.email,
role: session.role group: session.user_group
} }
}); });
} catch (err) { } catch (err) {
@@ -219,13 +258,17 @@ function createAuthRouter(db, logAudit) {
} }
}); });
// Clean up expired sessions (admin only) /**
router.post('/cleanup-sessions', async (req, res) => { * POST /api/auth/cleanup-sessions
// Basic auth check - require a valid session to call this *
const sessionId = req.cookies?.session_id; * Deletes all expired sessions from the database. Requires Admin group.
if (!sessionId) { *
return res.status(401).json({ error: 'Authentication required' }); * @returns {object} 200 - { message: 'Expired sessions cleaned up' }
} * @returns {object} 401 - { error: 'Authentication required' }
* @returns {object} 403 - { error: 'Insufficient permissions', required: ['Admin'], current: '...' }
* @returns {object} 500 - { error: 'Cleanup failed' }
*/
router.post('/cleanup-sessions', requireAuth(db), requireGroup('Admin'), async (req, res) => {
try { try {
await new Promise((resolve, reject) => { await new Promise((resolve, reject) => {
db.run( db.run(

View File

@@ -213,7 +213,7 @@ function groupByHostname(rows, noteHostnames) {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Router factory // Router factory
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function createComplianceRouter(db, upload, requireAuth, requireRole) { function createComplianceRouter(db, upload, requireAuth, requireGroup) {
const router = express.Router(); const router = express.Router();
// Idempotent column additions — errors mean column already exists, which is fine // Idempotent column additions — errors mean column already exists, which is fine
@@ -228,7 +228,7 @@ function createComplianceRouter(db, upload, requireAuth, requireRole) {
// Parse the uploaded xlsx, compute diff, save parsed data to a temp JSON. // Parse the uploaded xlsx, compute diff, save parsed data to a temp JSON.
// Returns diff counts + tempFile path for the commit step. // Returns diff counts + tempFile path for the commit step.
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
router.post('/preview', requireRole('editor', 'admin'), (req, res) => { router.post('/preview', requireGroup('Admin', 'Standard_User'), (req, res) => {
upload.single('file')(req, res, async (uploadErr) => { upload.single('file')(req, res, async (uploadErr) => {
if (uploadErr) { if (uploadErr) {
return res.status(400).json({ error: uploadErr.message }); return res.status(400).json({ error: uploadErr.message });
@@ -260,7 +260,7 @@ function createComplianceRouter(db, upload, requireAuth, requireRole) {
items: parsed.items, items: parsed.items,
summary: parsed.summary, summary: parsed.summary,
report_date: parsed.report_date, report_date: parsed.report_date,
filename: req.file.originalname, filename: req.file.originalname.replace(/[^\w.\-() ]/g, '_'),
})); }));
// Delete the original xlsx from temp (we only need the JSON now) // Delete the original xlsx from temp (we only need the JSON now)
@@ -291,7 +291,7 @@ function createComplianceRouter(db, upload, requireAuth, requireRole) {
// Commit a previewed upload to the DB. // Commit a previewed upload to the DB.
// Body: { tempFile, filename, report_date } // Body: { tempFile, filename, report_date }
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
router.post('/commit', requireRole('editor', 'admin'), async (req, res) => { router.post('/commit', requireGroup('Admin', 'Standard_User'), async (req, res) => {
const { tempFile, filename, report_date } = req.body; const { tempFile, filename, report_date } = req.body;
if (!tempFile || typeof tempFile !== 'string') { if (!tempFile || typeof tempFile !== 'string') {
@@ -520,11 +520,11 @@ function createComplianceRouter(db, upload, requireAuth, requireRole) {
// Add a note to a (hostname, metric_id) pair. // Add a note to a (hostname, metric_id) pair.
// Body: { hostname, metric_id, note } // Body: { hostname, metric_id, note }
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
router.post('/notes', async (req, res) => { router.post('/notes', requireGroup('Admin', 'Standard_User'), async (req, res) => {
const { hostname, metric_id, note } = req.body; const { hostname, metric_id, note } = req.body;
if (!hostname || typeof hostname !== 'string' || hostname.length > 300) { if (!hostname || typeof hostname !== 'string' || hostname.length > 300 || !/^[a-zA-Z0-9._-]+$/.test(hostname)) {
return res.status(400).json({ error: 'Invalid hostname' }); return res.status(400).json({ error: 'Invalid hostname format' });
} }
if (!metric_id || typeof metric_id !== 'string' || metric_id.length > 50) { if (!metric_id || typeof metric_id !== 'string' || metric_id.length > 50) {
return res.status(400).json({ error: 'Invalid metric_id' }); return res.status(400).json({ error: 'Invalid metric_id' });

View File

@@ -0,0 +1,162 @@
// Ivanti Archive Routes — list, stats, and transition history for archived findings
const express = require('express');
const VALID_STATES = ['ACTIVE', 'ARCHIVED', 'RETURNED', 'CLOSED'];
function createIvantiArchiveRouter(db, requireAuth) {
const router = express.Router();
// All routes require authentication
router.use(requireAuth(db));
/**
* GET /
* List archive records with optional state filtering.
*
* @query {string} [state] - Filter by lifecycle state (ACTIVE, ARCHIVED, RETURNED, CLOSED)
* @returns {Object} 200 - { archives: Array<ArchiveRecord>, total: number }
* @returns {Object} 400 - { error: string } when state param is invalid
* @returns {Object} 500 - { error: string } on database failure
*/
router.get('/', async (req, res) => {
const { state } = req.query;
if (state && !VALID_STATES.includes(state)) {
return res.status(400).json({
error: 'Invalid state parameter. Valid values: ACTIVE, ARCHIVED, RETURNED, CLOSED'
});
}
try {
let query = 'SELECT * FROM ivanti_finding_archives';
const params = [];
if (state) {
query += ' WHERE current_state = ?';
params.push(state);
}
query += ' ORDER BY last_transition_at DESC';
const archives = await new Promise((resolve, reject) => {
db.all(query, params, (err, rows) => {
if (err) reject(err);
else resolve(rows || []);
});
});
res.json({ archives, total: archives.length });
} catch (err) {
console.error('Archive list error:', err);
res.status(500).json({ error: 'Failed to fetch archive records' });
}
});
/**
* GET /stats
* Summary counts of archive records by lifecycle state.
* ACTIVE is implicit: live findings in the cache that have no ARCHIVED/RETURNED archive record.
*
* @returns {Object} 200 - { ACTIVE: number, ARCHIVED: number, RETURNED: number, CLOSED: number, total: number }
* @returns {Object} 500 - { error: string } on database failure
*/
router.get('/stats', async (req, res) => {
try {
// Count archive records by state
const rows = await new Promise((resolve, reject) => {
db.all(
`SELECT current_state, COUNT(*) as count
FROM ivanti_finding_archives
GROUP BY current_state`,
(err, rows) => {
if (err) reject(err);
else resolve(rows || []);
}
);
});
const stats = { ACTIVE: 0, ARCHIVED: 0, RETURNED: 0, CLOSED: 0 };
for (const row of rows) {
if (stats.hasOwnProperty(row.current_state)) {
stats[row.current_state] = row.count;
}
}
// Compute ACTIVE: total live findings minus those with ARCHIVED or RETURNED records
const cacheRow = await new Promise((resolve, reject) => {
db.get(
'SELECT total FROM ivanti_findings_cache WHERE id = 1',
(err, row) => {
if (err) reject(err);
else resolve(row);
}
);
});
const liveFindingsCount = (cacheRow && cacheRow.total) || 0;
// Findings that are ARCHIVED or RETURNED are "missing" from the live set,
// so ACTIVE = live count (all findings currently present in sync results)
stats.ACTIVE = liveFindingsCount;
const total = stats.ACTIVE + stats.ARCHIVED + stats.RETURNED + stats.CLOSED;
res.json({ ...stats, total });
} catch (err) {
console.error('Archive stats error:', err);
res.status(500).json({ error: 'Failed to fetch archive stats' });
}
});
/**
* GET /:findingId/history
* Transition history for a specific archived finding, ordered by most recent first.
* Returns an empty transitions array if the finding has no archive record.
*
* @param {string} findingId - Ivanti finding identifier (route param)
* @returns {Object} 200 - { finding_id: string, transitions: Array<TransitionRecord> }
* @returns {Object} 500 - { error: string } on database failure
*/
router.get('/:findingId/history', async (req, res) => {
const { findingId } = req.params;
try {
const archive = await new Promise((resolve, reject) => {
db.get(
'SELECT id FROM ivanti_finding_archives WHERE finding_id = ?',
[findingId],
(err, row) => {
if (err) reject(err);
else resolve(row);
}
);
});
if (!archive) {
return res.json({ finding_id: findingId, transitions: [] });
}
const transitions = await new Promise((resolve, reject) => {
db.all(
`SELECT * FROM ivanti_archive_transitions
WHERE archive_id = ?
ORDER BY transitioned_at DESC`,
[archive.id],
(err, rows) => {
if (err) reject(err);
else resolve(rows || []);
}
);
});
res.json({ finding_id: findingId, transitions });
} catch (err) {
console.error('Archive history error:', err);
res.status(500).json({ error: 'Failed to fetch transition history' });
}
});
return router;
}
module.exports = createIvantiArchiveRouter;

View File

@@ -3,10 +3,9 @@
// Notes are stored separately so they survive cache refreshes. // Notes are stored separately so they survive cache refreshes.
const express = require('express'); const express = require('express');
const https = require('https'); const { requireGroup } = require('../middleware/auth');
const { requireRole } = require('../middleware/auth'); const { ivantiPost } = require('../helpers/ivantiApi');
const IVANTI_URL_BASE = 'https://platform4.risksense.com/api/v1';
const SYNC_INTERVAL_MS = 24 * 60 * 60 * 1000; const SYNC_INTERVAL_MS = 24 * 60 * 60 * 1000;
const FINDINGS_FILTERS = [ const FINDINGS_FILTERS = [
@@ -71,42 +70,6 @@ const CLOSED_COUNT_FILTERS = [
} }
]; ];
// ---------------------------------------------------------------------------
// HTTP helper — mirrors the one in ivantiWorkflows.js
// ---------------------------------------------------------------------------
function ivantiPost(urlPath, body, apiKey, skipTls) {
const bodyStr = JSON.stringify(body);
const fullUrl = new URL(IVANTI_URL_BASE + urlPath);
return new Promise((resolve, reject) => {
const options = {
hostname: fullUrl.hostname,
path: fullUrl.pathname + fullUrl.search,
method: 'POST',
headers: {
'accept': '*/*',
'content-type': 'application/json',
'x-api-key': apiKey,
'x-http-client-type': 'browser',
'content-length': Buffer.byteLength(bodyStr)
},
rejectUnauthorized: !skipTls,
timeout: 20000
};
const req = https.request(options, (res) => {
let data = '';
res.on('data', (chunk) => { data += chunk; });
res.on('end', () => resolve({ status: res.statusCode, body: data }));
});
req.on('timeout', () => req.destroy(new Error('Request timed out')));
req.on('error', reject);
req.write(bodyStr);
req.end();
});
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Table init // Table init
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -192,6 +155,201 @@ function initTables(db) {
}); });
} }
// ---------------------------------------------------------------------------
// Archive table init — creates archive tracking tables alongside the main cache
// ---------------------------------------------------------------------------
function initArchiveTables(db) {
return new Promise((resolve, reject) => {
db.serialize(() => {
db.run(`
CREATE TABLE IF NOT EXISTS ivanti_finding_archives (
id INTEGER PRIMARY KEY AUTOINCREMENT,
finding_id TEXT NOT NULL UNIQUE,
finding_title TEXT NOT NULL DEFAULT '',
host_name TEXT NOT NULL DEFAULT '',
ip_address TEXT NOT NULL DEFAULT '',
current_state TEXT NOT NULL CHECK(current_state IN ('ARCHIVED','RETURNED','CLOSED')),
last_severity REAL NOT NULL DEFAULT 0,
first_archived_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_transition_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`, (err) => { if (err) return reject(err); });
db.run(`
CREATE TABLE IF NOT EXISTS ivanti_archive_transitions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
archive_id INTEGER NOT NULL,
from_state TEXT NOT NULL,
to_state TEXT NOT NULL,
severity_at_transition REAL NOT NULL DEFAULT 0,
reason TEXT NOT NULL DEFAULT '',
transitioned_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (archive_id) REFERENCES ivanti_finding_archives(id)
)
`, (err) => { if (err) return reject(err); });
db.run(`
CREATE INDEX IF NOT EXISTS idx_archive_finding_id
ON ivanti_finding_archives(finding_id)
`, (err) => { if (err) return reject(err); });
db.run(`
CREATE INDEX IF NOT EXISTS idx_archive_current_state
ON ivanti_finding_archives(current_state)
`, (err) => { if (err) return reject(err); });
db.run(`
CREATE INDEX IF NOT EXISTS idx_transition_archive_id
ON ivanti_archive_transitions(archive_id)
`, (err) => {
if (err) reject(err);
else resolve();
});
});
});
}
// ---------------------------------------------------------------------------
// Archive detection — compare previous vs current findings to detect state changes
// ---------------------------------------------------------------------------
async function detectArchiveChanges(db, previousFindings, currentFindings) {
const previousIds = new Set(previousFindings.map(f => String(f.id)));
const currentIds = new Set(currentFindings.map(f => String(f.id)));
// Build lookup maps for metadata
const previousMap = new Map(previousFindings.map(f => [String(f.id), f]));
const currentMap = new Map(currentFindings.map(f => [String(f.id), f]));
// 1. Disappeared findings: in previous but not in current → ARCHIVED
const disappearedIds = [...previousIds].filter(id => !currentIds.has(id));
for (const id of disappearedIds) {
const finding = previousMap.get(id);
const title = finding.title || '';
const hostName = finding.hostName || '';
const ipAddress = finding.ipAddress || '';
const severity = typeof finding.severity === 'number' ? finding.severity : 0;
try {
// Check if this finding already has an archive record
const existing = await dbGet(db,
`SELECT id, current_state FROM ivanti_finding_archives WHERE finding_id = ?`,
[id]
);
if (existing && existing.current_state === 'RETURNED') {
// Re-disappeared: RETURNED → ARCHIVED
await dbRun(db,
`UPDATE ivanti_finding_archives
SET current_state = 'ARCHIVED', last_severity = ?, last_transition_at = datetime('now')
WHERE id = ?`,
[severity, existing.id]
);
await dbRun(db,
`INSERT INTO ivanti_archive_transitions (archive_id, from_state, to_state, severity_at_transition, reason, transitioned_at)
VALUES (?, 'RETURNED', 'ARCHIVED', ?, 'severity_score_drift', datetime('now'))`,
[existing.id, severity]
);
console.log(`[Archive Detection] Finding ${id} re-archived (RETURNED → ARCHIVED)`);
} else if (!existing) {
// First disappearance: NONE → ARCHIVED
const result = await dbRun(db,
`INSERT INTO ivanti_finding_archives (finding_id, finding_title, host_name, ip_address, current_state, last_severity, first_archived_at, last_transition_at)
VALUES (?, ?, ?, ?, 'ARCHIVED', ?, datetime('now'), datetime('now'))`,
[id, title, hostName, ipAddress, severity]
);
const archiveId = result.lastID;
await dbRun(db,
`INSERT INTO ivanti_archive_transitions (archive_id, from_state, to_state, severity_at_transition, reason, transitioned_at)
VALUES (?, 'NONE', 'ARCHIVED', ?, 'severity_score_drift', datetime('now'))`,
[archiveId, severity]
);
console.log(`[Archive Detection] Finding ${id} archived (NONE → ARCHIVED)`);
}
// If existing state is ARCHIVED or CLOSED, no action needed
} catch (err) {
console.error(`[Archive Detection] Error processing disappeared finding ${id}:`, err.message);
}
}
// 2. Returned findings: in current AND has existing ARCHIVED record → RETURNED
const currentIdsList = [...currentIds];
if (currentIdsList.length > 0) {
try {
const archivedRecords = await dbAll(db,
`SELECT id, finding_id FROM ivanti_finding_archives WHERE current_state = 'ARCHIVED'`
);
for (const record of archivedRecords) {
if (currentIds.has(record.finding_id)) {
const finding = currentMap.get(record.finding_id);
const severity = typeof finding.severity === 'number' ? finding.severity : 0;
await dbRun(db,
`UPDATE ivanti_finding_archives
SET current_state = 'RETURNED', last_severity = ?, last_transition_at = datetime('now')
WHERE id = ?`,
[severity, record.id]
);
await dbRun(db,
`INSERT INTO ivanti_archive_transitions (archive_id, from_state, to_state, severity_at_transition, reason, transitioned_at)
VALUES (?, 'ARCHIVED', 'RETURNED', ?, 'reappeared_in_sync', datetime('now'))`,
[record.id, severity]
);
console.log(`[Archive Detection] Finding ${record.finding_id} returned (ARCHIVED → RETURNED)`);
}
}
} catch (err) {
console.error('[Archive Detection] Error processing returned findings:', err.message);
}
}
console.log(`[Archive Detection] Processed ${disappearedIds.length} disappeared, checked ${currentIdsList.length} current findings against archive`);
}
// ---------------------------------------------------------------------------
// Closed finding detection — check archived/returned findings against Ivanti closed set
// ---------------------------------------------------------------------------
async function detectClosedFindings(db, closedFindingIds) {
if (!closedFindingIds || closedFindingIds.length === 0) return;
const closedSet = new Set(closedFindingIds.map(String));
try {
const records = await dbAll(db,
`SELECT id, finding_id, current_state, last_severity FROM ivanti_finding_archives WHERE current_state IN ('ARCHIVED', 'RETURNED')`
);
let closedCount = 0;
for (const record of records) {
if (!closedSet.has(record.finding_id)) continue;
try {
await dbRun(db,
`UPDATE ivanti_finding_archives
SET current_state = 'CLOSED', last_transition_at = datetime('now')
WHERE id = ?`,
[record.id]
);
await dbRun(db,
`INSERT INTO ivanti_archive_transitions (archive_id, from_state, to_state, severity_at_transition, reason, transitioned_at)
VALUES (?, ?, 'CLOSED', ?, 'remediated_in_ivanti', datetime('now'))`,
[record.id, record.current_state, record.last_severity || 0]
);
closedCount++;
console.log(`[Archive Detection] Finding ${record.finding_id} closed (${record.current_state} → CLOSED)`);
} catch (err) {
console.error(`[Archive Detection] Error closing finding ${record.finding_id}:`, err.message);
}
}
console.log(`[Archive Detection] Closed ${closedCount} findings as remediated`);
} catch (err) {
console.error('[Archive Detection] Error querying archive records for closed detection:', err.message);
}
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Extract only the fields we need from a raw finding object // Extract only the fields we need from a raw finding object
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -266,7 +424,7 @@ async function syncClosedCount(db, openCount, apiKey, clientId, skipTls) {
projection: 'internal', projection: 'internal',
sort: [{ field: 'severity', direction: 'ASC' }], sort: [{ field: 'severity', direction: 'ASC' }],
page: 0, page: 0,
size: 1 size: 100
}; };
const result = await ivantiPost(urlPath, body, apiKey, skipTls); const result = await ivantiPost(urlPath, body, apiKey, skipTls);
@@ -275,6 +433,27 @@ async function syncClosedCount(db, openCount, apiKey, clientId, skipTls) {
const data = JSON.parse(result.body); const data = JSON.parse(result.body);
// RiskSense returns total in page.totalElements or page.total // RiskSense returns total in page.totalElements or page.total
const closedCount = data.page?.totalElements ?? data.page?.total ?? 0; const closedCount = data.page?.totalElements ?? data.page?.total ?? 0;
const totalPages = data.page?.totalPages || 1;
// Collect closed finding IDs for archive detection
const closedFindingIds = [];
const firstPageFindings = data._embedded?.hostFindings || [];
firstPageFindings.forEach(f => { if (f.id) closedFindingIds.push(String(f.id)); });
// Fetch remaining pages to collect all closed finding IDs
for (let pg = 1; pg < totalPages; pg++) {
try {
const pageBody = { ...body, page: pg };
const pageResult = await ivantiPost(urlPath, pageBody, apiKey, skipTls);
if (pageResult.status !== 200) break;
const pageData = JSON.parse(pageResult.body);
const pageFindings = pageData._embedded?.hostFindings || [];
pageFindings.forEach(f => { if (f.id) closedFindingIds.push(String(f.id)); });
} catch (err) {
console.error(`[Ivanti Findings] Failed to fetch closed findings page ${pg}:`, err.message);
break;
}
}
await dbRun(db, await dbRun(db,
`UPDATE ivanti_counts_cache SET open_count=?, closed_count=?, synced_at=datetime('now') WHERE id=1`, `UPDATE ivanti_counts_cache SET open_count=?, closed_count=?, synced_at=datetime('now') WHERE id=1`,
@@ -289,6 +468,13 @@ async function syncClosedCount(db, openCount, apiKey, clientId, skipTls) {
); );
console.log(`[Ivanti Findings] Counts updated — open: ${openCount}, closed: ${closedCount}`); console.log(`[Ivanti Findings] Counts updated — open: ${openCount}, closed: ${closedCount}`);
// Detect closed findings in the archive — wrap in try/catch so errors don't break sync
try {
await detectClosedFindings(db, closedFindingIds);
} catch (err) {
console.error('[Ivanti Findings] Closed finding archive detection failed (non-fatal):', err.message);
}
} catch (err) { } catch (err) {
console.error('[Ivanti Findings] Failed to fetch closed count:', err.message); console.error('[Ivanti Findings] Failed to fetch closed count:', err.message);
// Still update open count so it stays in sync; leave closed_count as-is // Still update open count so it stays in sync; leave closed_count as-is
@@ -441,17 +627,36 @@ async function syncFindings(db) {
page++; page++;
} while (page < totalPages); } while (page < totalPages);
// Read previous findings BEFORE updating the cache (they'll be overwritten)
let previousFindings = [];
try {
const state = await readState(db);
previousFindings = state.findings || [];
} catch (err) {
console.error('[Ivanti Findings] Failed to read previous findings for archive detection:', err.message);
}
await dbRun(db, await dbRun(db,
`UPDATE ivanti_findings_cache SET total=?, findings_json=?, synced_at=datetime('now'), sync_status='success', error_message=NULL WHERE id=1`, `UPDATE ivanti_findings_cache SET total=?, findings_json=?, synced_at=datetime('now'), sync_status='success', error_message=NULL WHERE id=1`,
[allFindings.length, JSON.stringify(allFindings)] [allFindings.length, JSON.stringify(allFindings)]
); );
console.log(`[Ivanti Findings] Sync complete — ${allFindings.length} findings`); console.log(`[Ivanti Findings] Sync complete — ${allFindings.length} findings`);
// Archive detection — compare previous vs current to detect disappeared/returned findings
// Only runs after a successful sync (skipped on error per requirement 1.5)
try {
await detectArchiveChanges(db, previousFindings, allFindings);
} catch (err) {
console.error('[Ivanti Findings] Archive detection failed (non-fatal):', err.message);
}
await syncClosedCount(db, allFindings.length, apiKey, clientId, skipTls); await syncClosedCount(db, allFindings.length, apiKey, clientId, skipTls);
await syncFPWorkflowCounts(db, allFindings, apiKey, clientId, skipTls); await syncFPWorkflowCounts(db, allFindings, apiKey, clientId, skipTls);
} catch (err) { } catch (err) {
const msg = err.message || 'Unknown error'; const msg = err.message || 'Unknown error';
console.error('[Ivanti Findings] Sync failed:', msg); console.error('[Ivanti Findings] Sync failed:', msg);
// Archive detection is intentionally skipped on sync error (requirement 1.5)
await dbRun(db, `UPDATE ivanti_findings_cache SET sync_status='error', error_message=?, synced_at=datetime('now') WHERE id=1`, [msg]); await dbRun(db, `UPDATE ivanti_findings_cache SET sync_status='error', error_message=?, synced_at=datetime('now') WHERE id=1`, [msg]);
} }
} }
@@ -482,7 +687,19 @@ function scheduleSync(db) {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function dbRun(db, sql, params = []) { function dbRun(db, sql, params = []) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
db.run(sql, params, (err) => { if (err) reject(err); else resolve(); }); db.run(sql, params, function (err) { if (err) reject(err); else resolve(this); });
});
}
function dbGet(db, sql, params = []) {
return new Promise((resolve, reject) => {
db.get(sql, params, (err, row) => { if (err) reject(err); else resolve(row); });
});
}
function dbAll(db, sql, params = []) {
return new Promise((resolve, reject) => {
db.all(sql, params, (err, rows) => { if (err) reject(err); else resolve(rows || []); });
}); });
} }
@@ -559,7 +776,7 @@ async function readStateWithNotes(db) {
function createIvantiFindingsRouter(db, requireAuth) { function createIvantiFindingsRouter(db, requireAuth) {
const router = express.Router(); const router = express.Router();
initTables(db) Promise.all([initTables(db), initArchiveTables(db)])
.then(() => scheduleSync(db)) .then(() => scheduleSync(db))
.catch((err) => console.error('[Ivanti Findings] Init failed:', err)); .catch((err) => console.error('[Ivanti Findings] Init failed:', err));
@@ -575,7 +792,7 @@ function createIvantiFindingsRouter(db, requireAuth) {
}); });
// POST /sync — trigger immediate sync, return fresh state // POST /sync — trigger immediate sync, return fresh state
router.post('/sync', async (req, res) => { router.post('/sync', requireGroup('Admin', 'Standard_User'), async (req, res) => {
await syncFindings(db); await syncFindings(db);
try { try {
res.json(await readStateWithNotes(db)); res.json(await readStateWithNotes(db));
@@ -645,7 +862,7 @@ function createIvantiFindingsRouter(db, requireAuth) {
// PUT /:findingId/override — save or clear a field override (editor/admin only) // PUT /:findingId/override — save or clear a field override (editor/admin only)
const OVERRIDE_ALLOWED = ['hostName', 'dns']; const OVERRIDE_ALLOWED = ['hostName', 'dns'];
router.put('/:findingId/override', requireRole('editor', 'admin'), (req, res) => { router.put('/:findingId/override', requireGroup('Admin', 'Standard_User'), (req, res) => {
const { findingId } = req.params; const { findingId } = req.params;
const { field, value } = req.body; const { field, value } = req.body;
@@ -680,7 +897,7 @@ function createIvantiFindingsRouter(db, requireAuth) {
}); });
// PUT /:findingId/note — save or update a note (max 255 chars enforced here) // PUT /:findingId/note — save or update a note (max 255 chars enforced here)
router.put('/:findingId/note', (req, res) => { router.put('/:findingId/note', requireGroup('Admin', 'Standard_User'), (req, res) => {
const { findingId } = req.params; const { findingId } = req.params;
const note = String(req.body.note || '').slice(0, 255); const note = String(req.body.note || '').slice(0, 255);
@@ -700,3 +917,6 @@ function createIvantiFindingsRouter(db, requireAuth) {
} }
module.exports = createIvantiFindingsRouter; module.exports = createIvantiFindingsRouter;
module.exports.detectArchiveChanges = detectArchiveChanges;
module.exports.detectClosedFindings = detectClosedFindings;
module.exports.initArchiveTables = initArchiveTables;

View File

@@ -0,0 +1,395 @@
// routes/ivantiFpWorkflow.js
const express = require('express');
const multer = require('multer');
const path = require('path');
const { requireGroup } = require('../middleware/auth');
const { ivantiFormPost } = require('../helpers/ivantiApi');
const logAudit = require('../helpers/auditLog');
// ---------------------------------------------------------------------------
// Pure helpers (exported for testing)
// ---------------------------------------------------------------------------
const ALLOWED_EXTENSIONS = new Set([
'.pdf', '.png', '.jpg', '.jpeg', '.gif',
'.doc', '.docx', '.xlsx', '.csv', '.txt', '.zip'
]);
/**
* Returns true if the filename has an allowed extension (case-insensitive).
*/
function isAllowedFileExtension(filename) {
if (!filename || typeof filename !== 'string') return false;
const ext = path.extname(filename).toLowerCase();
return ALLOWED_EXTENSIONS.has(ext);
}
/**
* Validates the FP workflow form body.
* Returns {} if valid, or { fieldName: 'error message' } for each invalid field.
*/
function validateFpWorkflowForm(body) {
const errors = {};
// name: required, non-empty, max 255
if (!body.name || typeof body.name !== 'string' || body.name.trim().length === 0) {
errors.name = 'Workflow name is required.';
} else if (body.name.trim().length > 255) {
errors.name = 'Workflow name must be 255 characters or fewer.';
}
// reason: required, non-empty
if (!body.reason || typeof body.reason !== 'string' || body.reason.trim().length === 0) {
errors.reason = 'Reason is required.';
}
// description: optional, max 2000 if provided
if (body.description !== undefined && body.description !== null && body.description !== '') {
if (typeof body.description !== 'string') {
errors.description = 'Description must be a string.';
} else if (body.description.length > 2000) {
errors.description = 'Description must be 2000 characters or fewer.';
}
}
// expirationDate: required, valid date, strictly after today
if (!body.expirationDate || typeof body.expirationDate !== 'string' || body.expirationDate.trim().length === 0) {
errors.expirationDate = 'Expiration date is required.';
} else {
const parsed = new Date(body.expirationDate);
if (isNaN(parsed.getTime())) {
errors.expirationDate = 'Expiration date must be a valid date.';
} else {
const today = new Date();
today.setHours(0, 0, 0, 0);
const expDay = new Date(parsed);
expDay.setHours(0, 0, 0, 0);
if (expDay <= today) {
errors.expirationDate = 'Expiration date must be in the future.';
}
}
}
return errors;
}
/**
* Builds the subjectFilterRequest JSON for the Ivanti FP workflow endpoint.
* Format: { subject, filterRequest: { filters } }
*/
function buildSubjectFilterRequest(findingIds) {
return JSON.stringify({
subject: 'hostFinding',
filterRequest: {
filters: [{
field: 'id',
exclusive: false,
operator: 'IN',
value: findingIds.map(id => String(id)).join(',')
}]
}
});
}
/**
* Builds the multipart form fields array for the Ivanti FP workflow request.
*/
function buildIvantiFormFields(formData, findingIds) {
const scopeMap = {
'Authorized': 'AUTHORIZED',
'None': 'NONE',
'Automated': 'AUTOMATED'
};
return [
{ name: 'name', value: formData.name },
{ name: 'reason', value: formData.reason },
{ name: 'description', value: formData.description || '' },
{ name: 'expirationDate', value: formData.expirationDate },
{ name: 'overrideControl', value: scopeMap[formData.scopeOverride] || 'AUTHORIZED' },
{ name: 'subjectFilterRequest', value: buildSubjectFilterRequest(findingIds) },
{ name: 'isEmptyWorkflow', value: findingIds.length === 0 ? 'true' : 'false' }
];
}
// ---------------------------------------------------------------------------
// Multer configuration
// ---------------------------------------------------------------------------
const uploadStorage = multer.memoryStorage();
const fpUpload = multer({
storage: uploadStorage,
limits: { fileSize: 10 * 1024 * 1024 }, // 10 MB per file
fileFilter: (req, file, cb) => {
if (isAllowedFileExtension(file.originalname)) {
cb(null, true);
} else {
cb(new Error(`File type not allowed: ${path.extname(file.originalname)}`));
}
}
}).array('attachments', 10); // up to 10 files
// ---------------------------------------------------------------------------
// Router factory
// ---------------------------------------------------------------------------
function createIvantiFpWorkflowRouter(db, requireAuth) {
const router = express.Router();
/**
* POST /api/ivanti/fp-workflow
*
* Creates a False Positive workflow batch in the Ivanti/RiskSense API,
* optionally uploads file attachments, records the submission locally,
* and marks the associated queue items as complete.
*
* Content-Type: multipart/form-data
*
* @param {string} req.body.name - Workflow name (required, max 255 chars)
* @param {string} req.body.reason - Reason for the FP determination (required)
* @param {string} [req.body.description] - Additional description (optional, max 2000 chars)
* @param {string} req.body.expirationDate - ISO date string, must be a future date (required)
* @param {string} [req.body.scopeOverride] - "Authorized" (default) or "None"
* @param {string} req.body.findingIds - JSON-encoded array of Ivanti finding IDs
* @param {string} req.body.queueItemIds - JSON-encoded array of local queue item IDs
* @param {File[]} [req.files] - Up to 10 file attachments (max 10 MB each);
* allowed extensions: .pdf .png .jpg .jpeg .gif
* .doc .docx .xlsx .csv .txt .zip
*
* @returns {object} 200 - Success
* { success: true, workflowBatchId: number, generatedId: string,
* attachmentResults: Array<{ filename: string, success: boolean, error?: string }>,
* queueItemsUpdated: number, status: 'success' | 'partial' }
* @returns {object} 400 - Validation error
* { error: string } or { success: false, errors: { [field]: string } }
* @returns {object} 403 - Queue item ownership violation
* { error: string }
* @returns {object} 429 - Ivanti rate limit
* { success: false, error: string, step: 'create_workflow' }
* @returns {object} 500 - Server configuration error
* { success: false, error: string, step: 'create_workflow' }
* @returns {object} 502 - Ivanti API error
* { success: false, error: string, step: 'create_workflow', details?: string }
*/
router.post('/', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
fpUpload(req, res, (multerErr) => {
if (multerErr) {
if (multerErr.code === 'LIMIT_FILE_SIZE') {
return res.status(400).json({ error: 'File exceeds the 10 MB size limit.' });
}
return res.status(400).json({ error: multerErr.message });
}
// --- Parse JSON-encoded arrays from the multipart body ---
let findingIds, queueItemIds;
try {
findingIds = JSON.parse(req.body.findingIds || '[]');
queueItemIds = JSON.parse(req.body.queueItemIds || '[]');
} catch (e) {
return res.status(400).json({ error: 'findingIds and queueItemIds must be valid JSON arrays.' });
}
if (!Array.isArray(findingIds) || findingIds.length === 0) {
return res.status(400).json({ error: 'At least one finding ID is required.' });
}
if (!Array.isArray(queueItemIds) || queueItemIds.length === 0) {
return res.status(400).json({ error: 'At least one queue item ID is required.' });
}
// --- Validate form fields ---
const validationErrors = validateFpWorkflowForm(req.body);
if (Object.keys(validationErrors).length > 0) {
return res.status(400).json({ success: false, errors: validationErrors });
}
// --- Validate file extensions (belt-and-suspenders with Multer filter) ---
const files = req.files || [];
for (const file of files) {
if (!isAllowedFileExtension(file.originalname)) {
return res.status(400).json({
error: `File type not allowed: ${file.originalname}. Allowed: ${[...ALLOWED_EXTENSIONS].join(', ')}`
});
}
}
// --- Verify queue items belong to user, are FP type, and pending ---
const placeholders = queueItemIds.map(() => '?').join(',');
db.all(
`SELECT id, workflow_type, status, user_id
FROM ivanti_todo_queue
WHERE id IN (${placeholders})`,
queueItemIds,
(err, rows) => {
if (err) {
console.error('Error verifying queue items:', err);
return res.status(500).json({ error: 'Internal server error.' });
}
// Check all items were found
if (!rows || rows.length !== queueItemIds.length) {
return res.status(400).json({ error: 'One or more queue items not found.' });
}
// Check ownership, type, and status
for (const row of rows) {
if (row.user_id !== req.user.id) {
return res.status(403).json({ error: 'You can only submit your own queue items.' });
}
if (row.workflow_type !== 'FP') {
return res.status(400).json({ error: `Queue item ${row.id} is not an FP workflow type.` });
}
if (row.status !== 'pending') {
return res.status(400).json({ error: `Queue item ${row.id} is not in pending status.` });
}
}
// --- Validation passed — submit to Ivanti API ---
(async () => {
const apiKey = process.env.IVANTI_API_KEY;
const clientId = process.env.IVANTI_CLIENT_ID || '1550';
const skipTls = process.env.IVANTI_SKIP_TLS === 'true';
if (!apiKey) {
return res.status(500).json({ success: false, error: 'Ivanti API key is not configured.', step: 'create_workflow' });
}
// 1. Build form fields and call Ivanti API (multipart/form-data)
const formFields = buildIvantiFormFields(req.body, findingIds);
const formFiles = files.map(f => ({ name: 'files', buffer: f.buffer, filename: f.originalname }));
const createUrl = `/client/${encodeURIComponent(clientId)}/workflowBatch/falsePositive/request`;
let createResult;
try {
createResult = await ivantiFormPost(createUrl, formFields, formFiles, apiKey, skipTls);
} catch (networkErr) {
logAudit(db, {
userId: req.user.id, username: req.user.username,
action: 'ivanti_fp_workflow_failed', entityType: 'ivanti_workflow',
details: { error: networkErr.message, findingIds },
ipAddress: req.ip
});
return res.status(502).json({ success: false, error: 'Failed to connect to Ivanti API.', step: 'create_workflow', details: networkErr.message });
}
// Handle error responses from Ivanti
if (createResult.status !== 200 && createResult.status !== 201 && createResult.status !== 202) {
const errorMap = {
401: 'Ivanti API key is invalid or missing.',
419: 'API key lacks workflow creation permissions.',
429: 'Ivanti API rate limit reached. Please try again in a few minutes.'
};
const errorMsg = errorMap[createResult.status] || `Workflow creation failed: ${createResult.status}`;
const errorResponse = { success: false, error: errorMsg, step: 'create_workflow' };
if (!errorMap[createResult.status]) {
errorResponse.details = createResult.body;
}
logAudit(db, {
userId: req.user.id, username: req.user.username,
action: 'ivanti_fp_workflow_failed', entityType: 'ivanti_workflow',
details: { error: errorMsg, status: createResult.status, findingIds },
ipAddress: req.ip
});
return res.status(createResult.status === 429 ? 429 : 502).json(errorResponse);
}
// 2. Parse workflow batch response — API returns { id, created }
let workflowBatchId;
try {
const createData = JSON.parse(createResult.body);
workflowBatchId = createData.id;
} catch (parseErr) {
logAudit(db, {
userId: req.user.id, username: req.user.username,
action: 'ivanti_fp_workflow_failed', entityType: 'ivanti_workflow',
details: { error: 'Failed to parse Ivanti response', responseBody: createResult.body },
ipAddress: req.ip
});
return res.status(502).json({ success: false, error: 'Failed to parse Ivanti API response.', step: 'create_workflow' });
}
// 3. Determine submission status (files sent inline, so success if we got here)
const status = 'success';
// 4. Insert submission record
try {
await new Promise((resolve, reject) => {
db.run(
`INSERT INTO ivanti_fp_submissions (user_id, username, ivanti_workflow_batch_id, ivanti_generated_id, workflow_name, reason, description, expiration_date, scope_override, finding_ids_json, queue_item_ids_json, attachment_count, attachment_results_json, status, error_message)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
req.user.id,
req.user.username,
workflowBatchId,
null, // generatedId not returned by this endpoint
req.body.name,
req.body.reason,
req.body.description || null,
req.body.expirationDate,
req.body.scopeOverride || 'Authorized',
JSON.stringify(findingIds),
JSON.stringify(queueItemIds),
files.length,
JSON.stringify(files.map(f => ({ filename: f.originalname, success: true }))),
status,
null
],
(err) => { if (err) reject(err); else resolve(); }
);
});
} catch (dbErr) {
console.error('Failed to insert submission record:', dbErr);
// Don't fail the response — the Ivanti workflow was created
}
// 5. Log audit entry
logAudit(db, {
userId: req.user.id, username: req.user.username,
action: 'ivanti_fp_workflow_created', entityType: 'ivanti_workflow',
entityId: String(workflowBatchId),
details: { workflowName: req.body.name, findingIds, attachmentCount: files.length, status },
ipAddress: req.ip
});
// 6. Mark queue items as complete
let queueItemsUpdated = 0;
try {
const queuePlaceholders = queueItemIds.map(() => '?').join(',');
queueItemsUpdated = await new Promise((resolve, reject) => {
db.run(
`UPDATE ivanti_todo_queue SET status='complete', updated_at=CURRENT_TIMESTAMP WHERE id IN (${queuePlaceholders}) AND user_id=?`,
[...queueItemIds, req.user.id],
function (err) { if (err) reject(err); else resolve(this.changes); }
);
});
} catch (queueErr) {
console.error('Failed to update queue items:', queueErr);
// Don't fail — workflow was created
}
// 7. Return response
res.json({
success: true,
workflowBatchId,
queueItemsUpdated,
status
});
})().catch((unexpectedErr) => {
console.error('Unexpected error in FP workflow submission:', unexpectedErr);
res.status(500).json({ success: false, error: 'Internal server error.' });
});
}
);
});
});
return router;
}
module.exports = createIvantiFpWorkflowRouter;
module.exports.validateFpWorkflowForm = validateFpWorkflowForm;
module.exports.buildIvantiFormFields = buildIvantiFormFields;
module.exports.buildSubjectFilterRequest = buildSubjectFilterRequest;
module.exports.isAllowedFileExtension = isAllowedFileExtension;

View File

@@ -1,11 +1,14 @@
// routes/ivantiTodoQueue.js // routes/ivantiTodoQueue.js
const express = require('express'); const express = require('express');
const { requireGroup } = require('../middleware/auth');
const VALID_WORKFLOW_TYPES = ['FP', 'Archer', 'CARD']; const VALID_WORKFLOW_TYPES = ['FP', 'Archer', 'CARD'];
const VALID_STATUSES = ['pending', 'complete']; const VALID_STATUSES = ['pending', 'complete'];
function isValidVendor(vendor) { function isValidVendor(vendor) {
return typeof vendor === 'string' && vendor.trim().length > 0 && vendor.length <= 200; if (typeof vendor !== 'string') return false;
const trimmed = vendor.trim();
return trimmed.length > 0 && trimmed.length <= 200;
} }
function createIvantiTodoQueueRouter(db, requireAuth) { function createIvantiTodoQueueRouter(db, requireAuth) {
@@ -36,7 +39,7 @@ function createIvantiTodoQueueRouter(db, requireAuth) {
// POST /api/ivanti/todo-queue // POST /api/ivanti/todo-queue
// Add a finding to the queue // Add a finding to the queue
router.post('/', requireAuth(db), (req, res) => { router.post('/', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
const { finding_id, finding_title, cves, ip_address, vendor, workflow_type } = req.body; const { finding_id, finding_title, cves, ip_address, vendor, workflow_type } = req.body;
if (!finding_id || typeof finding_id !== 'string' || finding_id.trim().length === 0) { if (!finding_id || typeof finding_id !== 'string' || finding_id.trim().length === 0) {
@@ -86,7 +89,7 @@ function createIvantiTodoQueueRouter(db, requireAuth) {
// PUT /api/ivanti/todo-queue/:id // PUT /api/ivanti/todo-queue/:id
// Update vendor, workflow_type, or status — scoped to current user // Update vendor, workflow_type, or status — scoped to current user
router.put('/:id', requireAuth(db), (req, res) => { router.put('/:id', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
const { id } = req.params; const { id } = req.params;
const { vendor, workflow_type, status } = req.body; const { vendor, workflow_type, status } = req.body;
@@ -162,7 +165,7 @@ function createIvantiTodoQueueRouter(db, requireAuth) {
// DELETE /api/ivanti/todo-queue/completed // DELETE /api/ivanti/todo-queue/completed
// Bulk-delete all completed items for the current user // Bulk-delete all completed items for the current user
// IMPORTANT: This route must be registered BEFORE DELETE /:id // IMPORTANT: This route must be registered BEFORE DELETE /:id
router.delete('/completed', requireAuth(db), (req, res) => { router.delete('/completed', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
db.run( db.run(
"DELETE FROM ivanti_todo_queue WHERE user_id = ? AND status = 'complete'", "DELETE FROM ivanti_todo_queue WHERE user_id = ? AND status = 'complete'",
[req.user.id], [req.user.id],
@@ -178,7 +181,7 @@ function createIvantiTodoQueueRouter(db, requireAuth) {
// DELETE /api/ivanti/todo-queue/:id // DELETE /api/ivanti/todo-queue/:id
// Delete a single item — scoped to current user // Delete a single item — scoped to current user
router.delete('/:id', requireAuth(db), (req, res) => { router.delete('/:id', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
const { id } = req.params; const { id } = req.params;
db.get( db.get(

View File

@@ -4,48 +4,11 @@
// Error codes: 401 bad key, 419 insufficient privileges, 429 rate limited // Error codes: 401 bad key, 419 insufficient privileges, 429 rate limited
const express = require('express'); const express = require('express');
const https = require('https'); const { requireGroup } = require('../middleware/auth');
const { ivantiPost } = require('../helpers/ivantiApi');
const IVANTI_URL_BASE = 'https://platform4.risksense.com/api/v1';
const SYNC_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours const SYNC_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
// ---------------------------------------------------------------------------
// HTTP helper — uses Node's https module directly so we can toggle
// rejectUnauthorized for Charter's SSL inspection proxy (IVANTI_SKIP_TLS=true)
// ---------------------------------------------------------------------------
function ivantiPost(urlPath, body, apiKey, skipTls) {
const bodyStr = JSON.stringify(body);
const fullUrl = new URL(IVANTI_URL_BASE + urlPath);
return new Promise((resolve, reject) => {
const options = {
hostname: fullUrl.hostname,
path: fullUrl.pathname + fullUrl.search,
method: 'POST',
headers: {
'accept': '*/*',
'content-type': 'application/json',
'x-api-key': apiKey,
'x-http-client-type': 'browser',
'content-length': Buffer.byteLength(bodyStr)
},
rejectUnauthorized: !skipTls,
timeout: 15000
};
const req = https.request(options, (res) => {
let data = '';
res.on('data', (chunk) => { data += chunk; });
res.on('end', () => resolve({ status: res.statusCode, body: data }));
});
req.on('timeout', () => req.destroy(new Error('Request timed out')));
req.on('error', reject);
req.write(bodyStr);
req.end();
});
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Ensure the sync state table exists (idempotent — safe to call on every start) // Ensure the sync state table exists (idempotent — safe to call on every start)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -259,7 +222,7 @@ function createIvantiWorkflowsRouter(db, requireAuth) {
}); });
// POST /sync — trigger an immediate sync, await completion, return fresh state // POST /sync — trigger an immediate sync, await completion, return fresh state
router.post('/sync', async (req, res) => { router.post('/sync', requireGroup('Admin', 'Standard_User'), async (req, res) => {
await syncWorkflows(db); await syncWorkflows(db);
try { try {
res.json(await readState(db)); res.json(await readState(db));

View File

@@ -1,7 +1,7 @@
const express = require('express'); const express = require('express');
const path = require('path'); const path = require('path');
const fs = require('fs'); const fs = require('fs');
const { requireAuth, requireRole } = require('../middleware/auth'); const { requireAuth, requireGroup } = require('../middleware/auth');
const logAudit = require('../helpers/auditLog'); const logAudit = require('../helpers/auditLog');
function createKnowledgeBaseRouter(db, upload) { function createKnowledgeBaseRouter(db, upload) {
@@ -39,8 +39,20 @@ function createKnowledgeBaseRouter(db, upload) {
return ALLOWED_EXTENSIONS.has(ext); return ALLOWED_EXTENSIONS.has(ext);
} }
// POST /api/knowledge-base/upload - Upload new document /**
router.post('/upload', requireAuth(db), requireRole(db, 'editor', 'admin'), (req, res, next) => { * POST /api/knowledge-base/upload
* Upload a new knowledge base document.
*
* @body {string} title - Article title (required)
* @body {string} [description] - Article description
* @body {string} [category] - Article category (defaults to 'General')
* @body {File} file - The document file to upload (multipart/form-data)
*
* @response 200 - { success: true, id: number, title: string, slug: string, category: string }
* @response 400 - { error: string } - Missing title, no file, or invalid file type
* @response 500 - { error: string } - Database or filesystem error
*/
router.post('/upload', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res, next) => {
upload.single('file')(req, res, (err) => { upload.single('file')(req, res, (err) => {
if (err) { if (err) {
console.error('[KB Upload] Multer error:', err); console.error('[KB Upload] Multer error:', err);
@@ -80,22 +92,15 @@ function createKnowledgeBaseRouter(db, upload) {
const slug = generateSlug(title); const slug = generateSlug(title);
const kbDir = path.join(__dirname, '..', 'uploads', 'knowledge_base'); const kbDir = path.join(__dirname, '..', 'uploads', 'knowledge_base');
// Create directory if it doesn't exist
if (!fs.existsSync(kbDir)) {
fs.mkdirSync(kbDir, { recursive: true });
}
const filename = `${timestamp}_${sanitizedName}`; const filename = `${timestamp}_${sanitizedName}`;
const filePath = path.join(kbDir, filename); const filePath = path.join(kbDir, filename);
try { try {
// Move uploaded file to permanent location // Keep file in temp location until DB insert succeeds
fs.renameSync(uploadedFile.path, filePath);
// Check if slug already exists // Check if slug already exists
db.get('SELECT id FROM knowledge_base WHERE slug = ?', [slug], (err, row) => { db.get('SELECT id FROM knowledge_base WHERE slug = ?', [slug], (err, row) => {
if (err) { if (err) {
fs.unlinkSync(filePath); fs.unlinkSync(uploadedFile.path);
console.error('Error checking slug:', err); console.error('Error checking slug:', err);
return res.status(500).json({ error: 'Database error' }); return res.status(500).json({ error: 'Database error' });
} }
@@ -126,22 +131,32 @@ function createKnowledgeBaseRouter(db, upload) {
], ],
function (err) { function (err) {
if (err) { if (err) {
fs.unlinkSync(filePath); fs.unlinkSync(uploadedFile.path);
console.error('Error inserting knowledge base entry:', err); console.error('Error inserting knowledge base entry:', err);
return res.status(500).json({ error: 'Failed to save document metadata' }); return res.status(500).json({ error: 'Failed to save document metadata' });
} }
// DB insert succeeded — now move file to permanent location
try {
if (!fs.existsSync(kbDir)) {
fs.mkdirSync(kbDir, { recursive: true });
}
fs.renameSync(uploadedFile.path, filePath);
} catch (moveErr) {
console.error('Error moving file to permanent location:', moveErr);
// File is orphaned in temp but DB record exists — log and continue
}
// Log audit entry // Log audit entry
logAudit( logAudit(db, {
db, userId: req.user.id,
req.user.id, username: req.user.username,
req.user.username, action: 'CREATE_KB_ARTICLE',
'CREATE_KB_ARTICLE', entityType: 'knowledge_base',
'knowledge_base', entityId: String(this.lastID),
this.lastID, details: { title: title.trim(), filename: sanitizedName },
JSON.stringify({ title: title.trim(), filename: sanitizedName }), ipAddress: req.ip
req.ip });
);
res.json({ res.json({
success: true, success: true,
@@ -154,14 +169,20 @@ function createKnowledgeBaseRouter(db, upload) {
); );
}); });
} catch (error) { } catch (error) {
// Clean up file on error // Clean up temp file on error
if (fs.existsSync(filePath)) fs.unlinkSync(filePath); if (uploadedFile && fs.existsSync(uploadedFile.path)) fs.unlinkSync(uploadedFile.path);
console.error('Error uploading knowledge base document:', error); console.error('Error uploading knowledge base document:', error);
res.status(500).json({ error: error.message || 'Failed to upload document' }); res.status(500).json({ error: error.message || 'Failed to upload document' });
} }
}); });
// GET /api/knowledge-base - List all articles /**
* GET /api/knowledge-base
* List all knowledge base articles.
*
* @response 200 - Array of article objects: [{ id, title, slug, description, category, file_name, file_type, file_size, created_at, updated_at, created_by_username }]
* @response 500 - { error: string }
*/
router.get('/', requireAuth(db), (req, res) => { router.get('/', requireAuth(db), (req, res) => {
const sql = ` const sql = `
SELECT SELECT
@@ -183,7 +204,16 @@ function createKnowledgeBaseRouter(db, upload) {
}); });
}); });
// GET /api/knowledge-base/:id - Get single article details /**
* GET /api/knowledge-base/:id
* Get a single article's details by ID.
*
* @param {string} id - Article ID (route parameter)
*
* @response 200 - { id, title, slug, description, category, file_name, file_type, file_size, created_at, updated_at, created_by_username }
* @response 404 - { error: 'Article not found' }
* @response 500 - { error: string }
*/
router.get('/:id', requireAuth(db), (req, res) => { router.get('/:id', requireAuth(db), (req, res) => {
const { id } = req.params; const { id } = req.params;
@@ -211,7 +241,17 @@ function createKnowledgeBaseRouter(db, upload) {
}); });
}); });
// GET /api/knowledge-base/:id/content - Get document content for display /**
* GET /api/knowledge-base/:id/content
* Get document content for inline display. Returns the raw file with appropriate
* Content-Type headers. Markdown and text files are served as text/plain.
*
* @param {string} id - Article ID (route parameter)
*
* @response 200 - Raw file content with Content-Type and Content-Disposition headers
* @response 404 - { error: string } - Article or file not found
* @response 500 - { error: string }
*/
router.get('/:id/content', requireAuth(db), (req, res) => { router.get('/:id/content', requireAuth(db), (req, res) => {
const { id } = req.params; const { id } = req.params;
@@ -232,16 +272,15 @@ function createKnowledgeBaseRouter(db, upload) {
} }
// Log audit entry // Log audit entry
logAudit( logAudit(db, {
db, userId: req.user.id,
req.user.id, username: req.user.username,
req.user.username, action: 'VIEW_KB_ARTICLE',
'VIEW_KB_ARTICLE', entityType: 'knowledge_base',
'knowledge_base', entityId: String(id),
id, details: { filename: row.file_name },
JSON.stringify({ filename: row.file_name }), ipAddress: req.ip
req.ip });
);
// Determine content type for inline display // Determine content type for inline display
let contentType = row.file_type || 'application/octet-stream'; let contentType = row.file_type || 'application/octet-stream';
@@ -253,17 +292,28 @@ function createKnowledgeBaseRouter(db, upload) {
contentType = 'text/plain; charset=utf-8'; contentType = 'text/plain; charset=utf-8';
} }
const safeFileName = row.file_name.replace(/["\r\n\\]/g, '');
res.setHeader('Content-Type', contentType); res.setHeader('Content-Type', contentType);
// Use inline instead of attachment to allow browser to display // Use inline instead of attachment to allow browser to display
res.setHeader('Content-Disposition', `inline; filename="${row.file_name}"`); res.setHeader('Content-Disposition', `inline; filename="${safeFileName}"`);
// Allow iframe embedding from frontend origin // Allow iframe embedding from frontend origin
res.removeHeader('X-Frame-Options'); res.removeHeader('X-Frame-Options');
res.setHeader('Content-Security-Policy', "frame-ancestors 'self' http://71.85.90.9:3000 http://localhost:3000"); const corsOrigins = process.env.CORS_ORIGINS ? process.env.CORS_ORIGINS.split(',').join(' ') : 'http://localhost:3000';
res.setHeader('Content-Security-Policy', `frame-ancestors 'self' ${corsOrigins}`);
res.sendFile(row.file_path); res.sendFile(row.file_path);
}); });
}); });
// GET /api/knowledge-base/:id/download - Download document /**
* GET /api/knowledge-base/:id/download
* Download a knowledge base document as an attachment.
*
* @param {string} id - Article ID (route parameter)
*
* @response 200 - File download with Content-Disposition: attachment header
* @response 404 - { error: string } - Article or file not found
* @response 500 - { error: string }
*/
router.get('/:id/download', requireAuth(db), (req, res) => { router.get('/:id/download', requireAuth(db), (req, res) => {
const { id } = req.params; const { id } = req.params;
@@ -284,28 +334,39 @@ function createKnowledgeBaseRouter(db, upload) {
} }
// Log audit entry // Log audit entry
logAudit( logAudit(db, {
db, userId: req.user.id,
req.user.id, username: req.user.username,
req.user.username, action: 'DOWNLOAD_KB_ARTICLE',
'DOWNLOAD_KB_ARTICLE', entityType: 'knowledge_base',
'knowledge_base', entityId: String(id),
id, details: { filename: row.file_name },
JSON.stringify({ filename: row.file_name }), ipAddress: req.ip
req.ip });
);
const safeDownloadName = row.file_name.replace(/["\r\n\\]/g, '');
res.setHeader('Content-Type', row.file_type || 'application/octet-stream'); res.setHeader('Content-Type', row.file_type || 'application/octet-stream');
res.setHeader('Content-Disposition', `attachment; filename="${row.file_name}"`); res.setHeader('Content-Disposition', `attachment; filename="${safeDownloadName}"`);
res.sendFile(row.file_path); res.sendFile(row.file_path);
}); });
}); });
// DELETE /api/knowledge-base/:id - Delete article /**
router.delete('/:id', requireAuth(db), requireRole(db, 'editor', 'admin'), (req, res) => { * DELETE /api/knowledge-base/:id
* Delete a knowledge base article and its associated file.
* Standard_User can only delete articles they created. Admin can delete any article.
*
* @param {string} id - Article ID (route parameter)
*
* @response 200 - { success: true }
* @response 403 - { error: string } - Ownership check failed for Standard_User
* @response 404 - { error: 'Article not found' }
* @response 500 - { error: string }
*/
router.delete('/:id', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
const { id } = req.params; const { id } = req.params;
const sql = 'SELECT file_path, title FROM knowledge_base WHERE id = ?'; const sql = 'SELECT file_path, title, created_by FROM knowledge_base WHERE id = ?';
db.get(sql, [id], (err, row) => { db.get(sql, [id], (err, row) => {
if (err) { if (err) {
@@ -317,6 +378,11 @@ function createKnowledgeBaseRouter(db, upload) {
return res.status(404).json({ error: 'Article not found' }); return res.status(404).json({ error: 'Article not found' });
} }
// Ownership check: Standard_User can only delete articles they created
if (req.user.group === 'Standard_User' && row.created_by !== req.user.id) {
return res.status(403).json({ error: 'You can only delete resources you created' });
}
// Delete database record // Delete database record
db.run('DELETE FROM knowledge_base WHERE id = ?', [id], (err) => { db.run('DELETE FROM knowledge_base WHERE id = ?', [id], (err) => {
if (err) { if (err) {
@@ -330,16 +396,15 @@ function createKnowledgeBaseRouter(db, upload) {
} }
// Log audit entry // Log audit entry
logAudit( logAudit(db, {
db, userId: req.user.id,
req.user.id, username: req.user.username,
req.user.username, action: 'DELETE_KB_ARTICLE',
'DELETE_KB_ARTICLE', entityType: 'knowledge_base',
'knowledge_base', entityId: String(id),
id, details: { title: row.title },
JSON.stringify({ title: row.title }), ipAddress: req.ip
req.ip });
);
res.json({ success: true }); res.json({ success: true });
}); });

View File

@@ -2,18 +2,18 @@
const express = require('express'); const express = require('express');
const bcrypt = require('bcryptjs'); const bcrypt = require('bcryptjs');
function createUsersRouter(db, requireAuth, requireRole, logAudit) { function createUsersRouter(db, requireAuth, requireGroup, logAudit) {
const router = express.Router(); const router = express.Router();
// All routes require admin role // All routes require Admin group
router.use(requireAuth(db), requireRole('admin')); router.use(requireAuth(db), requireGroup('Admin'));
// Get all users // Get all users
router.get('/', async (req, res) => { router.get('/', async (req, res) => {
try { try {
const users = await new Promise((resolve, reject) => { const users = await new Promise((resolve, reject) => {
db.all( db.all(
`SELECT id, username, email, role, is_active, created_at, last_login `SELECT id, username, email, user_group AS 'group', is_active, created_at, last_login
FROM users ORDER BY created_at DESC`, FROM users ORDER BY created_at DESC`,
(err, rows) => { (err, rows) => {
if (err) reject(err); if (err) reject(err);
@@ -33,7 +33,7 @@ function createUsersRouter(db, requireAuth, requireRole, logAudit) {
try { try {
const user = await new Promise((resolve, reject) => { const user = await new Promise((resolve, reject) => {
db.get( db.get(
`SELECT id, username, email, role, is_active, created_at, last_login `SELECT id, username, email, user_group AS 'group', is_active, created_at, last_login
FROM users WHERE id = ?`, FROM users WHERE id = ?`,
[req.params.id], [req.params.id],
(err, row) => { (err, row) => {
@@ -56,14 +56,17 @@ function createUsersRouter(db, requireAuth, requireRole, logAudit) {
// Create new user // Create new user
router.post('/', async (req, res) => { router.post('/', async (req, res) => {
const { username, email, password, role } = req.body; const { username, email, password, group } = req.body;
const VALID_GROUPS = ['Admin', 'Standard_User', 'Leadership', 'Read_Only'];
if (!username || !email || !password) { if (!username || !email || !password) {
return res.status(400).json({ error: 'Username, email, and password are required' }); return res.status(400).json({ error: 'Username, email, and password are required' });
} }
if (role && !['admin', 'editor', 'viewer'].includes(role)) { const userGroup = group || 'Read_Only';
return res.status(400).json({ error: 'Invalid role. Must be admin, editor, or viewer' });
if (!VALID_GROUPS.includes(userGroup)) {
return res.status(400).json({ error: 'Invalid group. Must be one of: Admin, Standard_User, Leadership, Read_Only' });
} }
try { try {
@@ -71,9 +74,9 @@ function createUsersRouter(db, requireAuth, requireRole, logAudit) {
const result = await new Promise((resolve, reject) => { const result = await new Promise((resolve, reject) => {
db.run( db.run(
`INSERT INTO users (username, email, password_hash, role) `INSERT INTO users (username, email, password_hash, user_group)
VALUES (?, ?, ?, ?)`, VALUES (?, ?, ?, ?)`,
[username, email, passwordHash, role || 'viewer'], [username, email, passwordHash, userGroup],
function(err) { function(err) {
if (err) reject(err); if (err) reject(err);
else resolve({ id: this.lastID }); else resolve({ id: this.lastID });
@@ -87,7 +90,7 @@ function createUsersRouter(db, requireAuth, requireRole, logAudit) {
action: 'user_create', action: 'user_create',
entityType: 'user', entityType: 'user',
entityId: String(result.id), entityId: String(result.id),
details: { created_username: username, role: role || 'viewer' }, details: { created_username: username, group: userGroup },
ipAddress: req.ip ipAddress: req.ip
}); });
@@ -97,7 +100,7 @@ function createUsersRouter(db, requireAuth, requireRole, logAudit) {
id: result.id, id: result.id,
username, username,
email, email,
role: role || 'viewer' group: userGroup
} }
}); });
} catch (err) { } catch (err) {
@@ -111,20 +114,42 @@ function createUsersRouter(db, requireAuth, requireRole, logAudit) {
// Update user // Update user
router.patch('/:id', async (req, res) => { router.patch('/:id', async (req, res) => {
const { username, email, password, role, is_active } = req.body; const { username, email, password, group, is_active } = req.body;
const VALID_GROUPS = ['Admin', 'Standard_User', 'Leadership', 'Read_Only'];
const userId = req.params.id; const userId = req.params.id;
// Prevent self-demotion from admin // Validate group if provided
if (userId == req.user.id && role && role !== 'admin') { if (group && !VALID_GROUPS.includes(group)) {
return res.status(400).json({ error: 'Cannot remove your own admin role' }); return res.status(400).json({ error: 'Invalid group. Must be one of: Admin, Standard_User, Leadership, Read_Only' });
}
// Prevent admin self-demotion
if (String(userId) === String(req.user.id) && group && group !== 'Admin') {
return res.status(400).json({ error: 'Cannot remove your own admin group' });
} }
// Prevent self-deactivation // Prevent self-deactivation
if (userId == req.user.id && is_active === false) { if (String(userId) === String(req.user.id) && is_active === false) {
return res.status(400).json({ error: 'Cannot deactivate your own account' }); return res.status(400).json({ error: 'Cannot deactivate your own account' });
} }
try { try {
// Fetch current user record before update (needed for group change audit)
const currentUser = await new Promise((resolve, reject) => {
db.get(
'SELECT user_group FROM users WHERE id = ?',
[userId],
(err, row) => {
if (err) reject(err);
else resolve(row);
}
);
});
if (!currentUser) {
return res.status(404).json({ error: 'User not found' });
}
const updates = []; const updates = [];
const values = []; const values = [];
@@ -141,12 +166,9 @@ function createUsersRouter(db, requireAuth, requireRole, logAudit) {
updates.push('password_hash = ?'); updates.push('password_hash = ?');
values.push(passwordHash); values.push(passwordHash);
} }
if (role) { if (group) {
if (!['admin', 'editor', 'viewer'].includes(role)) { updates.push('user_group = ?');
return res.status(400).json({ error: 'Invalid role' }); values.push(group);
}
updates.push('role = ?');
values.push(role);
} }
if (typeof is_active === 'boolean') { if (typeof is_active === 'boolean') {
updates.push('is_active = ?'); updates.push('is_active = ?');
@@ -173,7 +195,7 @@ function createUsersRouter(db, requireAuth, requireRole, logAudit) {
const updatedFields = {}; const updatedFields = {};
if (username) updatedFields.username = username; if (username) updatedFields.username = username;
if (email) updatedFields.email = email; if (email) updatedFields.email = email;
if (role) updatedFields.role = role; if (group) updatedFields.group = group;
if (typeof is_active === 'boolean') updatedFields.is_active = is_active; if (typeof is_active === 'boolean') updatedFields.is_active = is_active;
if (password) updatedFields.password_changed = true; if (password) updatedFields.password_changed = true;
@@ -187,6 +209,22 @@ function createUsersRouter(db, requireAuth, requireRole, logAudit) {
ipAddress: req.ip ipAddress: req.ip
}); });
// Log specific audit entry for group changes
if (group && group !== currentUser.user_group) {
logAudit(db, {
userId: req.user.id,
username: req.user.username,
action: 'user_group_change',
entityType: 'user',
entityId: String(userId),
details: {
previous_group: currentUser.user_group,
new_group: group
},
ipAddress: req.ip
});
}
// If user was deactivated, delete their sessions // If user was deactivated, delete their sessions
if (is_active === false) { if (is_active === false) {
await new Promise((resolve) => { await new Promise((resolve) => {
@@ -209,7 +247,7 @@ function createUsersRouter(db, requireAuth, requireRole, logAudit) {
const userId = req.params.id; const userId = req.params.id;
// Prevent self-deletion // Prevent self-deletion
if (userId == req.user.id) { if (String(userId) === String(req.user.id)) {
return res.status(400).json({ error: 'Cannot delete your own account' }); return res.status(400).json({ error: 'Cannot delete your own account' });
} }

View File

@@ -12,7 +12,7 @@ const path = require('path');
const fs = require('fs'); const fs = require('fs');
// Auth imports // Auth imports
const { requireAuth, requireRole } = require('./middleware/auth'); const { requireAuth, requireGroup } = require('./middleware/auth');
const createAuthRouter = require('./routes/auth'); const createAuthRouter = require('./routes/auth');
const createUsersRouter = require('./routes/users'); const createUsersRouter = require('./routes/users');
const createAuditLogRouter = require('./routes/auditLog'); const createAuditLogRouter = require('./routes/auditLog');
@@ -23,12 +23,18 @@ const createArcherTicketsRouter = require('./routes/archerTickets');
const createIvantiWorkflowsRouter = require('./routes/ivantiWorkflows'); const createIvantiWorkflowsRouter = require('./routes/ivantiWorkflows');
const createIvantiFindingsRouter = require('./routes/ivantiFindings'); const createIvantiFindingsRouter = require('./routes/ivantiFindings');
const createIvantiTodoQueueRouter = require('./routes/ivantiTodoQueue'); const createIvantiTodoQueueRouter = require('./routes/ivantiTodoQueue');
const createComplianceRouter = require('./routes/compliance'); const createIvantiArchiveRouter = require('./routes/ivantiArchive');
const createIvantiFpWorkflowRouter = require('./routes/ivantiFpWorkflow');
const createComplianceRouter = require('./routes/compliance');
const app = express(); const app = express();
const PORT = process.env.PORT || 3001; const PORT = process.env.PORT || 3001;
const API_HOST = process.env.API_HOST || 'localhost'; const API_HOST = process.env.API_HOST || 'localhost';
const SESSION_SECRET = process.env.SESSION_SECRET || 'default-secret-change-me'; const SESSION_SECRET = process.env.SESSION_SECRET;
if (!SESSION_SECRET) {
console.error('FATAL: SESSION_SECRET environment variable must be set');
process.exit(1);
}
const CORS_ORIGINS = process.env.CORS_ORIGINS const CORS_ORIGINS = process.env.CORS_ORIGINS
? process.env.CORS_ORIGINS.split(',') ? process.env.CORS_ORIGINS.split(',')
: ['http://localhost:3000']; : ['http://localhost:3000'];
@@ -160,10 +166,10 @@ const db = new sqlite3.Database('./cve_database.db', (err) => {
app.use('/api/auth', createAuthRouter(db, logAudit)); app.use('/api/auth', createAuthRouter(db, logAudit));
// User management routes (admin only) // User management routes (admin only)
app.use('/api/users', createUsersRouter(db, requireAuth, requireRole, logAudit)); app.use('/api/users', createUsersRouter(db, requireAuth, requireGroup, logAudit));
// Audit log routes (admin only) // Audit log routes (admin only)
app.use('/api/audit-logs', createAuditLogRouter(db, requireAuth, requireRole)); app.use('/api/audit-logs', createAuditLogRouter(db, requireAuth, requireGroup));
// NVD lookup routes (authenticated users) // NVD lookup routes (authenticated users)
app.use('/api/nvd', createNvdLookupRouter(db, requireAuth)); app.use('/api/nvd', createNvdLookupRouter(db, requireAuth));
@@ -219,8 +225,14 @@ app.use('/api/ivanti/findings', createIvantiFindingsRouter(db, requireAuth));
// Ivanti queue routes — per-user staging queue for FP / Archer workflows // Ivanti queue routes — per-user staging queue for FP / Archer workflows
app.use('/api/ivanti/todo-queue', createIvantiTodoQueueRouter(db, requireAuth)); app.use('/api/ivanti/todo-queue', createIvantiTodoQueueRouter(db, requireAuth));
// Ivanti archive routes — finding archive tracking for severity score drift
app.use('/api/ivanti/archive', createIvantiArchiveRouter(db, requireAuth));
// Ivanti FP workflow routes — submit False Positive workflows to Ivanti API
app.use('/api/ivanti/fp-workflow', createIvantiFpWorkflowRouter(db, requireAuth));
// AEO compliance routes — xlsx upload, non-compliant item tracking, notes // AEO compliance routes — xlsx upload, non-compliant item tracking, notes
app.use('/api/compliance', createComplianceRouter(db, upload, requireAuth, requireRole)); app.use('/api/compliance', createComplianceRouter(db, upload, requireAuth, requireGroup));
// ========== CVE ENDPOINTS ========== // ========== CVE ENDPOINTS ==========
@@ -349,7 +361,7 @@ app.get('/api/cves/compliance', requireAuth(db), (req, res) => {
}); });
// Create new CVE entry - ALLOW MULTIPLE VENDORS (editor or admin) // Create new CVE entry - ALLOW MULTIPLE VENDORS (editor or admin)
app.post('/api/cves', requireAuth(db), requireRole('editor', 'admin'), (req, res) => { app.post('/api/cves', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
const { cve_id, vendor, severity, description, published_date } = req.body; const { cve_id, vendor, severity, description, published_date } = req.body;
// Input validation // Input validation
@@ -370,11 +382,11 @@ app.post('/api/cves', requireAuth(db), requireRole('editor', 'admin'), (req, res
} }
const query = ` const query = `
INSERT INTO cves (cve_id, vendor, severity, description, published_date) INSERT INTO cves (cve_id, vendor, severity, description, published_date, created_by)
VALUES (?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?)
`; `;
db.run(query, [cve_id, vendor, severity, description, published_date], function(err) { db.run(query, [cve_id, vendor, severity, description, published_date, req.user.id], function(err) {
if (err) { if (err) {
console.error('DATABASE ERROR:', err); console.error('DATABASE ERROR:', err);
if (err.message.includes('UNIQUE constraint failed')) { if (err.message.includes('UNIQUE constraint failed')) {
@@ -403,7 +415,7 @@ app.post('/api/cves', requireAuth(db), requireRole('editor', 'admin'), (req, res
// Update CVE status (editor or admin) // Update CVE status (editor or admin)
app.patch('/api/cves/:cveId/status', requireAuth(db), requireRole('editor', 'admin'), (req, res) => { app.patch('/api/cves/:cveId/status', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
const { cveId } = req.params; const { cveId } = req.params;
const { status } = req.body; const { status } = req.body;
@@ -431,7 +443,7 @@ app.patch('/api/cves/:cveId/status', requireAuth(db), requireRole('editor', 'adm
}); });
// Bulk sync CVE data from NVD (editor or admin) // Bulk sync CVE data from NVD (editor or admin)
app.post('/api/cves/nvd-sync', requireAuth(db), requireRole('editor', 'admin'), (req, res) => { app.post('/api/cves/nvd-sync', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
const { updates } = req.body; const { updates } = req.body;
if (!Array.isArray(updates) || updates.length === 0) { if (!Array.isArray(updates) || updates.length === 0) {
return res.status(400).json({ error: 'No updates provided' }); return res.status(400).json({ error: 'No updates provided' });
@@ -501,7 +513,7 @@ app.post('/api/cves/nvd-sync', requireAuth(db), requireRole('editor', 'admin'),
// ========== CVE EDIT & DELETE ENDPOINTS ========== // ========== CVE EDIT & DELETE ENDPOINTS ==========
// Edit single CVE entry (editor or admin) // Edit single CVE entry (editor or admin)
app.put('/api/cves/:id', requireAuth(db), requireRole('editor', 'admin'), (req, res) => { app.put('/api/cves/:id', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
const { id } = req.params; const { id } = req.params;
const { cve_id, vendor, severity, description, published_date, status } = req.body; const { cve_id, vendor, severity, description, published_date, status } = req.body;
@@ -645,7 +657,7 @@ app.put('/api/cves/:id', requireAuth(db), requireRole('editor', 'admin'), (req,
}); });
// Delete entire CVE - all vendors (editor or admin) - MUST be before /:id route // Delete entire CVE - all vendors (editor or admin) - MUST be before /:id route
app.delete('/api/cves/by-cve-id/:cveId', requireAuth(db), requireRole('editor', 'admin'), (req, res) => { app.delete('/api/cves/by-cve-id/:cveId', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
const { cveId } = req.params; const { cveId } = req.params;
// Get all rows for this CVE ID to know what we're deleting // Get all rows for this CVE ID to know what we're deleting
@@ -653,6 +665,151 @@ app.delete('/api/cves/by-cve-id/:cveId', requireAuth(db), requireRole('editor',
if (err) { console.error(err); return res.status(500).json({ error: 'Internal server error.' }); } if (err) { console.error(err); return res.status(500).json({ error: 'Internal server error.' }); }
if (!rows || rows.length === 0) return res.status(404).json({ error: 'No CVE entries found for this CVE ID' }); if (!rows || rows.length === 0) return res.status(404).json({ error: 'No CVE entries found for this CVE ID' });
// Ownership check: Standard_User can only delete CVEs they created
if (req.user.group === 'Standard_User') {
const notOwned = rows.some(row => row.created_by !== req.user.id);
if (notOwned) {
return res.status(403).json({ error: 'You can only delete resources you created' });
}
// Cascade impact check for Standard_User
// Query all three cascade-deleted resource types in parallel
db.all('SELECT id, exc_number, cve_id, vendor FROM archer_tickets WHERE cve_id = ?', [cveId], (archerErr, archerTickets) => {
if (archerErr) { console.error(archerErr); return res.status(500).json({ error: 'Internal server error.' }); }
db.all('SELECT id, cve_id, vendor, ticket_key, status FROM jira_tickets WHERE cve_id = ?', [cveId], (jiraErr, jiraTickets) => {
// If jira_tickets table doesn't exist yet, treat as empty
if (jiraErr && jiraErr.message && jiraErr.message.includes('no such table')) {
jiraTickets = [];
} else if (jiraErr) {
console.error(jiraErr);
return res.status(500).json({ error: 'Internal server error.' });
}
db.all('SELECT id, name, type FROM documents WHERE cve_id = ?', [cveId], (docErr, docs) => {
if (docErr) { console.error(docErr); return res.status(500).json({ error: 'Internal server error.' }); }
const allTickets = [
...(archerTickets || []).map(t => ({ ...t, source: 'archer', key: t.exc_number })),
...(jiraTickets || []).map(t => ({ ...t, source: 'jira', key: t.ticket_key }))
];
// If no tickets at all, no compliance linkage possible — return cascade info
if (allTickets.length === 0) {
return res.json({
cascade_impact: {
archer_tickets: [],
jira_tickets: [],
documents: (docs || []).map(d => ({ id: d.id, name: d.name, type: d.type })),
blocked: false,
blocked_reason: null
}
});
}
// Check compliance linkage for each ticket
// A ticket is compliance-linked if its key (exc_number or ticket_key) or cve_id
// appears in active compliance_items extra_json
const likeConditions = [];
const likeParams = [];
for (const t of allTickets) {
likeConditions.push('ci.extra_json LIKE ?');
likeParams.push(`%${t.key}%`);
}
// Also check if the CVE ID itself appears in compliance extra_json
likeConditions.push('ci.extra_json LIKE ?');
likeParams.push(`%${cveId}%`);
db.all(
`SELECT ci.id, ci.extra_json, cu.report_date
FROM compliance_items ci
JOIN compliance_uploads cu ON ci.upload_id = cu.id
WHERE ci.status = 'active' AND (${likeConditions.join(' OR ')})`,
likeParams,
(compErr, compLinks) => {
// If compliance_items table doesn't exist yet, treat as no linkage
if (compErr && compErr.message && compErr.message.includes('no such table')) {
compLinks = [];
} else if (compErr) {
console.error(compErr);
return res.status(500).json({ error: 'Internal server error.' });
}
// Determine which tickets are compliance-linked by checking extra_json matches
const linkedTicketKeys = new Set();
for (const cl of (compLinks || [])) {
const json = cl.extra_json || '';
for (const t of allTickets) {
if (json.includes(t.key)) {
linkedTicketKeys.add(`${t.source}:${t.id}`);
}
}
// If CVE ID itself is in compliance data, all tickets are considered linked
if (json.includes(cveId)) {
for (const t of allTickets) {
linkedTicketKeys.add(`${t.source}:${t.id}`);
}
}
}
const archerTicketsResult = (archerTickets || []).map(t => ({
id: t.id,
exc_number: t.exc_number,
compliance_linked: linkedTicketKeys.has(`archer:${t.id}`)
}));
const jiraTicketsResult = (jiraTickets || []).map(t => ({
id: t.id,
ticket_key: t.ticket_key,
compliance_linked: linkedTicketKeys.has(`jira:${t.id}`)
}));
const documentsResult = (docs || []).map(d => ({
id: d.id,
name: d.name,
type: d.type
}));
const hasComplianceLink = archerTicketsResult.some(t => t.compliance_linked)
|| jiraTicketsResult.some(t => t.compliance_linked);
if (hasComplianceLink) {
const blockedArcher = archerTicketsResult.find(t => t.compliance_linked);
const blockedJira = jiraTicketsResult.find(t => t.compliance_linked);
const blockedLabel = blockedArcher
? `Archer ticket ${blockedArcher.exc_number}`
: `JIRA ticket ${blockedJira.ticket_key}`;
return res.status(403).json({
error: 'CVE deletion blocked: associated ticket linked to compliance report',
cascade_impact: {
archer_tickets: archerTicketsResult,
jira_tickets: jiraTicketsResult,
documents: documentsResult,
blocked: true,
blocked_reason: `${blockedLabel} is linked to a compliance report`
}
});
}
// Not blocked — return cascade impact for frontend warning
return res.json({
cascade_impact: {
archer_tickets: archerTicketsResult,
jira_tickets: jiraTicketsResult,
documents: documentsResult,
blocked: false,
blocked_reason: null
}
});
}
);
});
});
});
return; // Exit early — Standard_User flow handled above
}
// Admin flow: proceed directly with deletion (no cascade check)
// Delete all documents from DB // Delete all documents from DB
db.run('DELETE FROM documents WHERE cve_id = ?', [cveId], (docErr) => { db.run('DELETE FROM documents WHERE cve_id = ?', [cveId], (docErr) => {
if (docErr) console.error('Error deleting documents:', docErr); if (docErr) console.error('Error deleting documents:', docErr);
@@ -685,13 +842,71 @@ app.delete('/api/cves/by-cve-id/:cveId', requireAuth(db), requireRole('editor',
}); });
// Delete single CVE vendor entry (editor or admin) // Delete single CVE vendor entry (editor or admin)
app.delete('/api/cves/:id', requireAuth(db), requireRole('editor', 'admin'), (req, res) => { app.delete('/api/cves/:id', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
const { id } = req.params; const { id } = req.params;
db.get('SELECT * FROM cves WHERE id = ?', [id], (err, cve) => { db.get('SELECT * FROM cves WHERE id = ?', [id], (err, cve) => {
if (err) { console.error(err); return res.status(500).json({ error: 'Internal server error.' }); } if (err) { console.error(err); return res.status(500).json({ error: 'Internal server error.' }); }
if (!cve) return res.status(404).json({ error: 'CVE entry not found' }); if (!cve) return res.status(404).json({ error: 'CVE entry not found' });
// Ownership check: Standard_User can only delete CVEs they created
if (req.user.group === 'Standard_User' && cve.created_by !== req.user.id) {
return res.status(403).json({ error: 'You can only delete resources you created' });
}
// Cascade/compliance check for Standard_User
if (req.user.group === 'Standard_User') {
return db.all('SELECT id, exc_number FROM archer_tickets WHERE cve_id = ? AND vendor = ?', [cve.cve_id, cve.vendor], (archerErr, archerTickets) => {
if (archerErr) { console.error(archerErr); return res.status(500).json({ error: 'Internal server error.' }); }
db.all('SELECT id, ticket_key FROM jira_tickets WHERE cve_id = ? AND vendor = ?', [cve.cve_id, cve.vendor], (jiraErr, jiraTickets) => {
if (jiraErr && jiraErr.message && jiraErr.message.includes('no such table')) { jiraTickets = []; }
else if (jiraErr) { console.error(jiraErr); return res.status(500).json({ error: 'Internal server error.' }); }
const allTickets = [
...(archerTickets || []).map(t => ({ ...t, source: 'archer', key: t.exc_number })),
...(jiraTickets || []).map(t => ({ ...t, source: 'jira', key: t.ticket_key }))
];
if (allTickets.length === 0) {
return doSingleCveDelete(req, res, id, cve);
}
const likeConditions = allTickets.map(() => 'ci.extra_json LIKE ?');
const likeParams = allTickets.map(t => `%${t.key}%`);
db.all(
`SELECT ci.id, ci.extra_json FROM compliance_items ci
JOIN compliance_uploads cu ON ci.upload_id = cu.id
WHERE ci.status = 'active' AND (${likeConditions.join(' OR ')})`,
likeParams,
(compErr, compLinks) => {
if (compErr && compErr.message && compErr.message.includes('no such table')) { compLinks = []; }
else if (compErr) { console.error(compErr); return res.status(500).json({ error: 'Internal server error.' }); }
const hasLink = (compLinks || []).some(cl => {
const json = cl.extra_json || '';
return allTickets.some(t => json.includes(t.key));
});
if (hasLink) {
return res.status(403).json({
error: 'CVE deletion blocked: associated ticket linked to compliance report',
cascade_impact: { blocked: true, blocked_reason: 'Associated ticket is linked to a compliance report' }
});
}
return doSingleCveDelete(req, res, id, cve);
}
);
});
});
}
doSingleCveDelete(req, res, id, cve);
});
function doSingleCveDelete(req, res, id, cve) {
// Delete associated documents from DB // Delete associated documents from DB
db.all('SELECT id, file_path FROM documents WHERE cve_id = ? AND vendor = ?', [cve.cve_id, cve.vendor], (docErr, docs) => { db.all('SELECT id, file_path FROM documents WHERE cve_id = ? AND vendor = ?', [cve.cve_id, cve.vendor], (docErr, docs) => {
if (docErr) console.error('Error fetching documents:', docErr); if (docErr) console.error('Error fetching documents:', docErr);
@@ -738,7 +953,7 @@ app.delete('/api/cves/:id', requireAuth(db), requireRole('editor', 'admin'), (re
}); });
}); });
}); });
}); }
}); });
// ========== DOCUMENT ENDPOINTS ========== // ========== DOCUMENT ENDPOINTS ==========
@@ -767,7 +982,7 @@ app.get('/api/cves/:cveId/documents', requireAuth(db), (req, res) => {
}); });
// Upload document - ADD ERROR HANDLING FOR MULTER (editor or admin) // Upload document - ADD ERROR HANDLING FOR MULTER (editor or admin)
app.post('/api/cves/:cveId/documents', requireAuth(db), requireRole('editor', 'admin'), (req, res, next) => { app.post('/api/cves/:cveId/documents', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res, next) => {
upload.single('file')(req, res, (err) => { upload.single('file')(req, res, (err) => {
if (err) { if (err) {
console.error('Upload error:', err.message); console.error('Upload error:', err.message);
@@ -875,7 +1090,7 @@ app.post('/api/cves/:cveId/documents', requireAuth(db), requireRole('editor', 'a
}); });
}); });
// Delete document (admin only) // Delete document (admin only)
app.delete('/api/documents/:id', requireAuth(db), requireRole('admin'), (req, res) => { app.delete('/api/documents/:id', requireAuth(db), requireGroup('Admin'), (req, res) => {
const { id } = req.params; const { id } = req.params;
// First get the file path to delete the actual file // First get the file path to delete the actual file
@@ -977,7 +1192,7 @@ app.get('/api/jira-tickets', requireAuth(db), (req, res) => {
}); });
// Create JIRA ticket // Create JIRA ticket
app.post('/api/jira-tickets', requireAuth(db), requireRole('editor', 'admin'), (req, res) => { app.post('/api/jira-tickets', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
const { cve_id, vendor, ticket_key, url, summary, status } = req.body; const { cve_id, vendor, ticket_key, url, summary, status } = req.body;
// Validation // Validation
@@ -1003,11 +1218,11 @@ app.post('/api/jira-tickets', requireAuth(db), requireRole('editor', 'admin'), (
const ticketStatus = status || 'Open'; const ticketStatus = status || 'Open';
const query = ` const query = `
INSERT INTO jira_tickets (cve_id, vendor, ticket_key, url, summary, status) INSERT INTO jira_tickets (cve_id, vendor, ticket_key, url, summary, status, created_by)
VALUES (?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?)
`; `;
db.run(query, [cve_id, vendor, ticket_key.trim(), url || null, summary || null, ticketStatus], function(err) { db.run(query, [cve_id, vendor, ticket_key.trim(), url || null, summary || null, ticketStatus, req.user.id], function(err) {
if (err) { if (err) {
console.error('Error creating JIRA ticket:', err); console.error('Error creating JIRA ticket:', err);
return res.status(500).json({ error: 'Internal server error.' }); return res.status(500).json({ error: 'Internal server error.' });
@@ -1031,7 +1246,7 @@ app.post('/api/jira-tickets', requireAuth(db), requireRole('editor', 'admin'), (
}); });
// Update JIRA ticket // Update JIRA ticket
app.put('/api/jira-tickets/:id', requireAuth(db), requireRole('editor', 'admin'), (req, res) => { app.put('/api/jira-tickets/:id', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
const { id } = req.params; const { id } = req.params;
const { ticket_key, url, summary, status } = req.body; const { ticket_key, url, summary, status } = req.body;
@@ -1096,7 +1311,7 @@ app.put('/api/jira-tickets/:id', requireAuth(db), requireRole('editor', 'admin')
}); });
// Delete JIRA ticket // Delete JIRA ticket
app.delete('/api/jira-tickets/:id', requireAuth(db), requireRole('editor', 'admin'), (req, res) => { app.delete('/api/jira-tickets/:id', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
const { id } = req.params; const { id } = req.params;
db.get('SELECT * FROM jira_tickets WHERE id = ?', [id], (err, ticket) => { db.get('SELECT * FROM jira_tickets WHERE id = ?', [id], (err, ticket) => {
@@ -1108,24 +1323,66 @@ app.delete('/api/jira-tickets/:id', requireAuth(db), requireRole('editor', 'admi
return res.status(404).json({ error: 'JIRA ticket not found.' }); return res.status(404).json({ error: 'JIRA ticket not found.' });
} }
db.run('DELETE FROM jira_tickets WHERE id = ?', [id], function(deleteErr) { // Admin bypasses all delete restrictions
if (deleteErr) { if (req.user.group === 'Admin') {
console.error('Error deleting JIRA ticket:', deleteErr); return performJiraDelete();
return res.status(500).json({ error: 'Internal server error.' }); }
// Standard_User: ownership check
if (ticket.created_by && ticket.created_by !== req.user.id) {
return res.status(403).json({ error: 'You can only delete resources you created' });
}
// Standard_User: compliance linkage check
const ticketKey = ticket.ticket_key;
db.all(
`SELECT ci.id, ci.extra_json
FROM compliance_items ci
JOIN compliance_uploads cu ON ci.upload_id = cu.id
WHERE ci.status = 'active' AND ci.extra_json LIKE ?`,
[`%${ticketKey}%`],
(compErr, compLinks) => {
// If compliance_items table doesn't exist yet, treat as no linkage
if (compErr && compErr.message && compErr.message.includes('no such table')) {
compLinks = [];
} else if (compErr) {
console.error(compErr);
return res.status(500).json({ error: 'Internal server error.' });
}
const isLinked = (compLinks || []).some(cl => {
const json = cl.extra_json || '';
return json.includes(ticketKey);
});
if (isLinked) {
return res.status(403).json({ error: 'Cannot delete ticket linked to compliance report. Contact an admin.' });
}
return performJiraDelete();
} }
);
logAudit(db, { function performJiraDelete() {
userId: req.user.id, db.run('DELETE FROM jira_tickets WHERE id = ?', [id], function(deleteErr) {
username: req.user.username, if (deleteErr) {
action: 'jira_ticket_delete', console.error('Error deleting JIRA ticket:', deleteErr);
entityType: 'jira_ticket', return res.status(500).json({ error: 'Internal server error.' });
entityId: id, }
details: { ticket_key: ticket.ticket_key, cve_id: ticket.cve_id, vendor: ticket.vendor },
ipAddress: req.ip logAudit(db, {
userId: req.user.id,
username: req.user.username,
action: 'jira_ticket_delete',
entityType: 'jira_ticket',
entityId: id,
details: { ticket_key: ticket.ticket_key, cve_id: ticket.cve_id, vendor: ticket.vendor },
ipAddress: req.ip
});
res.json({ message: 'JIRA ticket deleted successfully' });
}); });
}
res.json({ message: 'JIRA ticket deleted successfully' });
});
}); });
}); });

View File

@@ -3,6 +3,7 @@
const sqlite3 = require('sqlite3').verbose(); const sqlite3 = require('sqlite3').verbose();
const bcrypt = require('bcryptjs'); const bcrypt = require('bcryptjs');
const crypto = require('crypto');
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
@@ -172,8 +173,9 @@ async function createDefaultAdmin(db) {
return; return;
} }
// Create admin user with password 'admin123' // Generate a random admin password on first run
const passwordHash = await bcrypt.hash('admin123', 10); const generatedPassword = crypto.randomBytes(12).toString('base64url');
const passwordHash = await bcrypt.hash(generatedPassword, 10);
db.run( db.run(
`INSERT INTO users (username, email, password_hash, role, is_active) `INSERT INTO users (username, email, password_hash, role, is_active)
@@ -183,7 +185,12 @@ async function createDefaultAdmin(db) {
if (err) { if (err) {
reject(err); reject(err);
} else { } else {
console.log('✓ Created default admin user (admin/admin123)'); console.log('✓ Created default admin user');
console.log(`\n ╔══════════════════════════════════════════╗`);
console.log(` ║ Admin credentials (save these now!) ║`);
console.log(` ║ Username: admin ║`);
console.log(` ║ Password: ${generatedPassword.padEnd(29)}`);
console.log(` ╚══════════════════════════════════════════╝\n`);
resolve(); resolve();
} }
} }
@@ -269,7 +276,7 @@ function displaySummary() {
console.log(' ✓ Indexes for fast queries'); console.log(' ✓ Indexes for fast queries');
console.log(' ✓ Document compliance view'); console.log(' ✓ Document compliance view');
console.log(' ✓ Uploads directory for file storage'); console.log(' ✓ Uploads directory for file storage');
console.log(' ✓ Default admin user (admin/admin123)'); console.log(' ✓ Default admin user (see credentials above)');
console.log('\n📁 File structure will be:'); console.log('\n📁 File structure will be:');
console.log(' uploads/'); console.log(' uploads/');
console.log(' └── CVE-XXXX-XXXX/'); console.log(' └── CVE-XXXX-XXXX/');

View File

@@ -0,0 +1,106 @@
# Ivanti / RiskSense API Reference
Base URL: `https://platform4.risksense.com/api/v1`
Swagger: `https://platform4.risksense.com/doc/swagger.json`
Auth: `x-api-key` header. Error codes: 401 bad key, 419 insufficient privileges, 429 rate limited.
## Endpoints Used
### Search Workflow Batches
```
POST /client/{clientId}/workflowBatch/search
Content-Type: application/json
```
Standard JSON body with filters, projection, sort, page, size. Used by `ivantiWorkflows.js` for the daily sync.
### Create False Positive Workflow
```
POST /client/{clientId}/workflowBatch/falsePositive/request
Content-Type: multipart/form-data
```
This endpoint does NOT accept JSON. It requires `multipart/form-data` with the following fields:
| Field | Type | Required | Notes |
|-------|------|----------|-------|
| `name` | string | yes | Workflow batch name (max 255) |
| `reason` | string | yes | Reason for the FP determination |
| `description` | string | yes | Description (can be empty string but field must be present) |
| `expirationDate` | string | yes | ISO-8601 date, e.g. `2026-06-01` |
| `overrideControl` | string | yes | `AUTHORIZED`, `NONE`, or `AUTOMATED`. Use `AUTHORIZED` for standard FP workflows. `NONE` with `isEmptyWorkflow=true` is rejected (400). |
| `isEmptyWorkflow` | boolean | yes | `true` if no findings attached, `false` otherwise |
| `subjectFilterRequest` | string | yes | Stringified JSON (see format below) |
| `files` | file | no | Attachments sent inline in the same request |
#### subjectFilterRequest format
This is the critical field. It must be a stringified JSON object with this exact structure:
```json
{
"subject": "hostFinding",
"filterRequest": {
"filters": [
{
"field": "id",
"exclusive": false,
"operator": "IN",
"value": "2283734550,2283734551"
}
]
}
}
```
Key details:
- `subject` must be `"hostFinding"` — without this, the API returns 500
- `filters` is nested inside `filterRequest`, NOT at the top level — `{"filters":[]}` at the top level returns 500
- `value` for multiple IDs is comma-separated as a single string, not an array
- `operator` values: `EXACT`, `IN`, `LIKE`, `WILDCARD`, `RANGE`, `CIDR`
- For empty workflows, use `{"subject":"hostFinding","filterRequest":{"filters":[]}}` with `isEmptyWorkflow=true`
#### Response (200)
```json
{
"id": 33418832,
"created": "2026-04-08T18:16:08"
}
```
Returns a numeric `id` (the workflow batch job ID) and `created` timestamp. No `generatedId` or `uuid` in this response.
### Other Workflow Endpoints (from Swagger)
These are available but not currently used by the dashboard:
| Endpoint | Purpose |
|----------|---------|
| `/workflowBatch/acceptance/request` | Risk acceptance workflow |
| `/workflowBatch/remediation/request` | Remediation workflow |
| `/workflowBatch/severityChange/request` | Severity change workflow |
| `/workflowBatch/{workflowType}/approve` | Approve a workflow (needs `workflowBatchUuid`) |
| `/workflowBatch/{workflowType}/reject` | Reject a workflow |
| `/workflowBatch/{workflowType}/rework` | Send back for rework |
| `/workflowBatch/{workflowType}/update` | Update a workflow |
| `/workflowBatch/{workflowType}/{workflowBatchUuid}/map` | Map findings to workflow |
| `/workflowBatch/{workflowType}/{workflowBatchUuid}/unmap` | Unmap findings |
| `/workflowBatch/{workflowType}/{workflowBatchUuid}/attach` | Attach file to existing workflow |
| `/workflowBatch/{workflowType}/{workflowBatchUuid}/detach` | Detach file |
| `/workflowBatch/model` | Get model/schema |
| `/workflowBatch/filter` | Get available filter fields |
| `/workflowBatch/suggest` | Get suggested values for a filter field |
## Environment Variables
| Variable | Default | Description |
|----------|---------|-------------|
| `IVANTI_API_KEY` | — | Required. API key for authentication |
| `IVANTI_CLIENT_ID` | `1550` | Client ID in the Ivanti platform |
| `IVANTI_SKIP_TLS` | `false` | Set `true` to skip TLS verification |
| `IVANTI_FIRST_NAME` | — | Used for workflow search filter (sync) |
| `IVANTI_LAST_NAME` | — | Used for workflow search filter (sync) |

View File

@@ -0,0 +1,617 @@
# Security Audit Report — STEAM Security Dashboard
**Date:** 2026-04-01
**Scope:** Full codebase — backend routes, authentication, file handling, Python scripts, React frontend
**Methodology:** Static analysis across four parallel audit tracks
---
## Executive Summary
The audit identified **31 findings** across four severity levels. The most serious issues are concentrated in the **authentication and authorization layer** — several endpoints are either completely unauthenticated or have role-checking middleware called with the wrong arguments, silently bypassing access control. These require immediate remediation before the application is exposed to a broader user base.
| Severity | Count |
|----------|-------|
| Critical | 6 |
| High | 9 |
| Medium | 10 |
| Low / Info | 6 |
| **Total** | **31** |
The application has strong foundational security in several areas: all database queries use parameterized statements (no SQL injection risk), path traversal prevention is comprehensive, Python script execution uses `spawn` with argument arrays (no shell injection), and file type allowlisting is in place. The vulnerabilities are largely in middleware wiring and missing access controls rather than fundamental design flaws.
---
## Critical Findings
---
### C-1 — Missing Authentication on Ivanti Findings Endpoints
**File:** `backend/routes/ivantiFindings.js:552600`
The findings router imports `requireRole` but **not** `requireAuth`. No authentication middleware is applied at the router level or on individual routes. Four endpoints are fully unauthenticated:
```js
const { requireRole } = require('../middleware/auth'); // requireAuth never imported
router.get('/', async (req, res) => { // line 552 — no auth
router.post('/sync', async (req, res) => { // line 561 — no auth
router.get('/counts', async (req, res) => { // line 571 — no auth
router.get('/fp-workflow-counts', ...) // line 580 — no auth
```
**Impact:** Any unauthenticated attacker on the network can read the full list of Ivanti host findings (hostnames, IPs, CVEs, severity, SLA status), trigger a sync operation, and enumerate all finding metrics.
**Fix:** Import `requireAuth` and apply it to the router or each route:
```js
const { requireAuth, requireRole } = require('../middleware/auth');
router.use(requireAuth(db));
```
---
### C-2 — Broken requireRole Call — Privilege Escalation in Knowledge Base
**File:** `backend/routes/knowledgeBase.js:43, 305`
`requireRole` is called with `db` as the first argument:
```js
router.post('/upload', requireAuth(db), requireRole(db, 'editor', 'admin'), ...)
router.delete('/:id', requireAuth(db), requireRole(db, 'editor', 'admin'), ...)
```
The function signature is `function requireRole(...allowedRoles)`. It does not accept `db`. The database object is treated as the first "allowed role", so the check becomes `req.user.role === db` — an object comparison that always evaluates false, meaning **the check never blocks anyone**. Any authenticated viewer can upload and delete knowledge base documents.
**Fix:** Remove `db` from all `requireRole` calls:
```js
requireRole('editor', 'admin')
```
---
### C-3 — Unauthenticated Ivanti Finding Note Writes
**File:** `backend/routes/ivantiFindings.js:639`
The PUT endpoint for saving finding notes has no authentication middleware:
```js
router.put('/:findingId/note', (req, res) => {
const note = String(req.body.note || '').slice(0, 255);
db.run(`INSERT INTO ivanti_finding_notes ...`);
});
```
**Impact:** Any unauthenticated request can write notes to any finding. Notes are visible to all users and used during remediation triage. An attacker could inject false status information (e.g. "EXC-12345 — patched") to mislead the team or cover tracks.
**Fix:** Add `requireAuth(db)` to this route.
---
### C-4 — No Brute Force Protection on Login Endpoint
**File:** `backend/routes/auth.js:10`
The login endpoint has no rate limiting, attempt counting, or lockout:
```js
router.post('/login', async (req, res) => {
const { username, password } = req.body;
// Direct DB lookup, unlimited attempts
```
**Impact:** An attacker can run unlimited password guesses against any account at full network speed. With the default credentials documented in the README and displayed in the UI (see F-2), admin accounts are a trivial target.
**Fix:** Apply `express-rate-limit` to the login route:
```js
const rateLimit = require('express-rate-limit');
const loginLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 20 });
router.post('/login', loginLimiter, async (req, res) => { ... });
```
---
### C-5 — Default Credentials Displayed in Login UI
**File:** `frontend/src/components/LoginForm.js:104`
The login form renders hardcoded credentials in plain text:
```jsx
<p className="text-sm text-gray-500 text-center font-mono">
Default: <span className="text-intel-accent">admin</span> /
<span className="text-intel-accent">admin123</span>
</p>
```
**Impact:** Anyone who opens the login page — including unauthenticated users — sees the default admin credentials. Combined with C-4 (no rate limiting), this is a direct path to admin compromise if the password has not been changed.
**Fix:** Remove this block entirely. Document default credentials only in the deployment guide. Enforce password change on first login server-side.
---
### C-6 — Missing Sandbox Attribute on Knowledge Base PDF Iframe
**File:** `frontend/src/components/KnowledgeBaseViewer.js:195`
The inline document viewer renders uploaded files in an unsandboxed iframe:
```jsx
<iframe
src={`${API_BASE}/knowledge-base/${article.id}/content`}
title={article.title}
className="w-full h-full rounded"
>
```
**Impact:** A malicious PDF or HTML file uploaded by an editor could execute JavaScript within the application's origin, accessing `localStorage`, `sessionStorage`, and DOM of the parent page. An attacker with editor access could upload a file that steals session data from any user who views it.
**Fix:** Add a restrictive `sandbox` attribute:
```jsx
<iframe
sandbox="allow-same-origin allow-scripts"
src={...}
title={article.title}
/>
```
---
## High Findings
---
### H-1 — /cleanup-sessions Missing Role Check
**File:** `backend/routes/auth.js:223`
The comment says "admin only" but the endpoint only checks for any valid session:
```js
router.post('/cleanup-sessions', async (req, res) => {
const sessionId = req.cookies?.session_id;
if (!sessionId) return res.status(401).json({ error: '...' });
// No role check
```
**Fix:** Apply `requireAuth(db)` and `requireRole('admin')`.
---
### H-2 — Hardcoded Fallback SESSION_SECRET
**File:** `backend/server.js:31`
```js
const SESSION_SECRET = process.env.SESSION_SECRET || 'default-secret-change-me';
```
If the `.env` file is missing or the variable is unset, all sessions are signed with a publicly known string. An attacker who knows the secret can forge valid session cookies.
**Fix:** Fail hard on startup if the secret is not set:
```js
const SESSION_SECRET = process.env.SESSION_SECRET;
if (!SESSION_SECRET) throw new Error('SESSION_SECRET environment variable must be set');
```
---
### H-3 — Audit Log Parameter Mismatch — Silent Audit Trail Gaps
**Files:** `backend/routes/archerTickets.js:8995, 172, 206` and `backend/routes/knowledgeBase.js:235244, 287296`
The `logAudit` helper expects an object with `entityType` and `entityId`. These callers use the wrong keys (`targetType`, `targetId`) or pass positional arguments instead of an object:
```js
// archerTickets.js — wrong keys
logAudit(db, { ..., targetType: 'archer_ticket', targetId: this.lastID, ... });
// knowledgeBase.js — positional (wrong pattern)
logAudit(db, req.user.id, req.user.username, 'VIEW_KB_ARTICLE', 'knowledge_base', id, ...);
```
**Impact:** All Archer ticket and Knowledge Base operations produce audit log rows with `NULL` entity type and entity ID. Security investigations and compliance reviews will show these actions occurred but not what was affected.
**Fix:** Align all callers to the object format expected by `auditLog.js`:
```js
logAudit(db, { userId, username, action, entityType, entityId, details, ipAddress });
```
---
### H-4 — Viewers Can Write Compliance Notes
**Files:** `backend/routes/compliance.js:522` (also flagged by file-upload audit)
The POST /notes endpoint is protected by authentication but not by role:
```js
router.post('/notes', async (req, res) => { // no requireRole()
```
**Impact:** Any viewer can add notes to any compliance item. Notes surface in the detail panel and influence remediation decisions. False notes cannot be deleted via the API.
**Fix:** `requireRole('editor', 'admin')` on this route.
---
### H-5 — Sync Endpoints Accessible to All Authenticated Users
**Files:** `backend/routes/ivantiFindings.js:561`, `backend/routes/ivantiWorkflows.js:262`
POST /sync on both routers requires only authentication, not editor/admin role. Any viewer can trigger expensive Ivanti API calls repeatedly.
**Impact:** Viewer-role users can cause repeated large API fetches, potentially hitting Ivanti rate limits and blocking legitimate syncs for the team.
**Fix:** Add `requireRole('editor', 'admin')` to both POST /sync routes.
---
### H-6 — HTTP Header Injection via Unsanitized Filename in Content-Disposition
**File:** `backend/routes/knowledgeBase.js:258, 299`
The original uploaded filename (user-controlled) is written directly into the `Content-Disposition` response header:
```js
res.setHeader('Content-Disposition', `inline; filename="${row.file_name}"`);
res.setHeader('Content-Disposition', `attachment; filename="${row.file_name}"`);
```
`row.file_name` stores `uploadedFile.originalname` which is not sanitized for use in HTTP headers. A filename containing `"\r\n` characters can split the response and inject arbitrary headers.
**Fix:**
```js
const safeFilename = row.file_name.replace(/["\r\n\\]/g, '');
res.setHeader('Content-Disposition', `attachment; filename="${safeFilename}"`);
```
---
### H-7 — Race Condition in Knowledge Base File Upload
**File:** `backend/routes/knowledgeBase.js:91155`
The file is moved to its permanent location (line 93) before the database record is created (line 114). If the DB insert fails, the file is orphaned on disk. Two concurrent uploads with the same slug can also bypass the uniqueness check due to the async gap between the slug check query and the insert.
**Fix:** Keep the file in the temp directory until the DB insert succeeds, then move it:
```js
db.run(insertSql, [...], function(err) {
if (err) { fs.unlinkSync(uploadedFile.path); return res.status(500)...; }
fs.renameSync(uploadedFile.path, filePath);
res.json({ success: true });
});
```
---
### H-8 — Hardcoded Default Admin Password in setup.js
**File:** `backend/setup.js:175`
```js
const passwordHash = await bcrypt.hash('admin123', 10);
```
If `setup.js` is re-run on an existing deployment (e.g. during a restore), the admin password resets to a known value. The password is also documented in the README and displayed in the login UI (C-5).
**Fix:** Generate a random password on first run and print it once to stdout, or require it as a CLI argument. Never hardcode credentials in source.
---
### H-9 — ReactMarkdown Renders HTML Without Sanitization
**File:** `frontend/src/components/KnowledgeBaseViewer.js:169171`
```jsx
<ReactMarkdown>{content}</ReactMarkdown>
```
`ReactMarkdown` by default allows raw HTML in markdown (via `rehype-raw`). A knowledge base article containing `<img src=x onerror="...">` or `<script>` tags would execute JavaScript in the viewer's browser.
**Fix:** Add `rehype-sanitize`:
```jsx
import rehypeSanitize from 'rehype-sanitize';
<ReactMarkdown rehypePlugins={[rehypeSanitize]}>{content}</ReactMarkdown>
```
---
## Medium Findings
---
### M-1 — No CSRF Token Protection on State-Changing Requests
**Files:** All POST / PUT / DELETE routes
Cookies are `SameSite: lax` which provides partial protection, but `lax` still allows top-level cross-site navigations to carry cookies. No CSRF token is validated server-side. Combined with the permissive CORS configuration, cross-site request forgery is possible against editors and admins.
**Fix:** Either upgrade session cookie to `SameSite: strict`, or implement a CSRF token (double-submit cookie pattern or `csurf` middleware).
---
### M-2 — CORS Allows Credentials with Explicit Origin List
**File:** `backend/server.js:111114`
```js
app.use(cors({ origin: CORS_ORIGINS, credentials: true }));
```
`credentials: true` with explicit origins means any subdomain compromise or DNS hijacking of a listed origin could allow cross-origin authenticated requests. This is the correct pattern for this use case, but worth hardening.
**Fix:** Ensure `CORS_ORIGINS` is reviewed whenever the deployment changes. Consider `SameSite: strict` on cookies to reduce reliance on CORS for CSRF protection.
---
### M-3 — No Rate Limiting on NVD API Proxy
**File:** `backend/routes/nvdLookup.js:13`
Any authenticated user can trigger NVD API calls in rapid succession. NVD enforces a 5 req/30s unauthenticated limit, which can be exhausted by a single user making 5 lookups.
**Fix:** Add a server-side 1-hour cache keyed by CVE ID to avoid repeated external lookups, plus a per-user rate limit.
---
### M-4 — Admin Self-Demotion Check Uses Loose Equality
**File:** `backend/routes/users.js:118`
```js
if (userId == req.user.id && role && role !== 'admin') {
```
Using `==` allows type coercion. If `userId` is passed as a different type than `req.user.id`, the comparison may not match correctly.
**Fix:** `String(userId) === String(req.user.id)`.
---
### M-5 — Missing Hostname Format Validation
**File:** `backend/routes/compliance.js:451`
The hostname route parameter is used in SQL queries and responses. Only length is checked (>300). No format validation rejects characters outside a valid hostname range.
**Fix:**
```js
if (!/^[a-zA-Z0-9._-]+$/.test(hostname)) {
return res.status(400).json({ error: 'Invalid hostname format' });
}
```
---
### M-6 — Vendor Field Validated Before Trim
**File:** `backend/routes/ivantiTodoQueue.js:8, 56`
Vendor length is validated before `.trim()` is called. A string of 200 spaces passes validation but becomes an empty string after trimming, which then passes without a vendor value for FP/Archer items that require one.
**Fix:** Trim first, then validate length and presence.
---
### M-7 — Unsanitized Original Filename Stored in Compliance Temp JSON
**File:** `backend/routes/compliance.js:262`
```js
filename: req.file.originalname, // user-controlled, unsanitized
```
The original filename is stored in the temp JSON and later echoed back to the frontend. Special characters could cause log injection or unexpected display issues.
**Fix:** `filename: sanitizePathSegment(req.file.originalname)`.
---
### M-8 — Hardcoded Frontend Origin in CSP Header
**File:** `backend/routes/knowledgeBase.js:261`
```js
res.setHeader('Content-Security-Policy',
"frame-ancestors 'self' http://71.85.90.9:3000 http://localhost:3000");
```
IP address is hardcoded. If the deployment IP changes, the CSP header will block inline document viewing without an obvious error and require a code change.
**Fix:** Use `CORS_ORIGINS` from the environment variable.
---
### M-9 — Sensitive API Error Messages Forwarded to UI
**Files:** `frontend/src/App.js:801, 816, 847, 886`
```js
} catch (err) {
alert(`Error: ${err.message}`);
}
```
Raw API error messages are displayed in browser alerts. If the backend leaks stack traces or query information in error responses, this information reaches the user directly.
**Fix:** Show generic user-facing messages; log details to the console in development only.
---
### M-10 — User-Supplied Data in window.confirm Dialogs
**File:** `frontend/src/App.js:806, 891`
```js
if (!window.confirm(`Delete ticket ${ticket.ticket_key}?`)) return;
```
A ticket with a crafted `ticket_key` value (e.g. containing newlines or misleading text) could produce a deceptive confirmation dialog used to social-engineer users.
**Fix:** Use a React modal component with escaped, controlled text instead of `window.confirm`.
---
## Low / Info Findings
---
### L-1 — Silent ROLLBACK on Compliance Transaction Failure
**File:** `backend/routes/compliance.js:167`
```js
await dbRun(db, 'ROLLBACK').catch(() => {});
```
If the rollback itself fails, the error is swallowed entirely. A failed rollback leaves an open transaction that can cause subsequent operations to block.
**Fix:** Log rollback failures even if execution continues.
---
### L-2 — Fire-and-Forget Audit Logging
**File:** `backend/helpers/auditLog.js:9`
Audit log writes fail silently. If the database is under load or unavailable, audit records are dropped with no alert.
**Fix:** Log audit write failures to stderr so they surface in server logs.
---
### L-3 — Async Temp File Cleanup With No Error Handling
**File:** `backend/routes/compliance.js:239, 247, 266, 281, 322`
```js
fs.unlink(req.file.path, () => {});
```
Cleanup failures accumulate silently, potentially causing disk exhaustion over time.
**Fix:** Log errors on unlink failure (excluding ENOENT which is expected).
---
### L-4 — IVANTI_SKIP_TLS Disables Certificate Validation
**File:** `backend/routes/ivantiFindings.js:385`
`IVANTI_SKIP_TLS=true` disables TLS verification for all Ivanti API calls, enabling man-in-the-middle attacks against the sync. It is controlled purely by environment variable with no warning.
**Fix:** Log a prominent warning on startup when this flag is active, and ensure it is never set in production.
---
### L-5 — console.error in Production Frontend Code
**Files:** `frontend/src/contexts/AuthContext.js:26`, `KnowledgeBaseViewer.js:31, 56`
Full error objects are logged to the browser console in production builds. In a monitored environment, these could expose internal details to anyone with DevTools open.
**Fix:** Guard with `if (process.env.NODE_ENV === 'development')` or use a structured logging library.
---
### L-6 — localStorage Column Config Lacks Structural Validation
**File:** `frontend/src/components/pages/ReportingPage.js:5168`
Column order/visibility is loaded from `localStorage` and merged with defaults. If the stored data is tampered with (via XSS or DevTools), the parsed structure is used with only partial validation.
**Fix:** Validate each loaded item against the known `COLUMN_DEFS` whitelist before use (a `hasOwnProperty` check is already present; ensure it runs on every item before the merge).
---
## Summary Table
| ID | Severity | Title | File |
|----|----------|-------|------|
| C-1 | Critical | Missing auth on Ivanti findings endpoints | ivantiFindings.js:552 |
| C-2 | Critical | requireRole(db) call bypasses role check in KB routes | knowledgeBase.js:43,305 |
| C-3 | Critical | Unauthenticated finding note writes | ivantiFindings.js:639 |
| C-4 | Critical | No brute force protection on login | auth.js:10 |
| C-5 | Critical | Default credentials displayed in login UI | LoginForm.js:104 |
| C-6 | Critical | Missing sandbox on PDF/document iframe | KnowledgeBaseViewer.js:195 |
| H-1 | High | /cleanup-sessions missing role check | auth.js:223 |
| H-2 | High | Hardcoded fallback SESSION_SECRET | server.js:31 |
| H-3 | High | Audit log parameter mismatch — silent trail gaps | archerTickets.js, knowledgeBase.js |
| H-4 | High | Viewers can write compliance notes | compliance.js:522 |
| H-5 | High | Sync endpoints accessible to all authenticated users | ivantiFindings.js:561, ivantiWorkflows.js:262 |
| H-6 | High | HTTP header injection via Content-Disposition filename | knowledgeBase.js:258,299 |
| H-7 | High | Race condition in KB file upload | knowledgeBase.js:91 |
| H-8 | High | Hardcoded default admin password in setup.js | setup.js:175 |
| H-9 | High | ReactMarkdown renders HTML without sanitization | KnowledgeBaseViewer.js:169 |
| M-1 | Medium | No CSRF token protection | All state-changing routes |
| M-2 | Medium | CORS credentials with explicit origin list | server.js:111 |
| M-3 | Medium | No rate limiting on NVD API proxy | nvdLookup.js:13 |
| M-4 | Medium | Admin self-demotion check uses loose equality | users.js:118 |
| M-5 | Medium | Missing hostname format validation | compliance.js:451 |
| M-6 | Medium | Vendor field validated before trim | ivantiTodoQueue.js:8,56 |
| M-7 | Medium | Unsanitized original filename in temp JSON | compliance.js:262 |
| M-8 | Medium | Hardcoded frontend IP in CSP header | knowledgeBase.js:261 |
| M-9 | Medium | API error messages forwarded to UI | App.js:801,816,847,886 |
| M-10 | Medium | User data in window.confirm dialogs | App.js:806,891 |
| L-1 | Low | Silent ROLLBACK on transaction failure | compliance.js:167 |
| L-2 | Low | Fire-and-forget audit logging | auditLog.js:9 |
| L-3 | Low | Async temp file cleanup with no error handling | compliance.js:239+ |
| L-4 | Low | IVANTI_SKIP_TLS with no startup warning | ivantiFindings.js:385 |
| L-5 | Low | console.error exposed in production frontend | AuthContext.js, KnowledgeBaseViewer.js |
| L-6 | Low | localStorage column config lacks structural validation | ReportingPage.js:51 |
---
## Remediation Priority
### Immediate — fix before adding users
1. **C-1** — Add `requireAuth` import and router-level middleware to `ivantiFindings.js`
2. **C-2** — Remove `db` from all `requireRole(db, ...)` calls in `knowledgeBase.js`
3. **C-3** — Add `requireAuth(db)` to the finding note PUT route
4. **C-4** — Add `express-rate-limit` to the login route (20 attempts / 15 min)
5. **C-5** — Remove default credentials from `LoginForm.js`
6. **H-2** — Hard-fail on startup if `SESSION_SECRET` is not set in env
### Short-term — next maintenance window
7. **C-6** — Add `sandbox` attribute to the KB iframe
8. **H-3** — Fix `logAudit` call signatures in `archerTickets.js` and `knowledgeBase.js`
9. **H-4** — Add `requireRole('editor', 'admin')` to POST /compliance/notes
10. **H-5** — Add `requireRole('editor', 'admin')` to both POST /sync routes
11. **H-6** — Sanitize filename for `Content-Disposition` header
12. **H-7** — Move file after DB insert succeeds in KB upload
13. **H-8** — Remove hardcoded password from `setup.js`; generate random on first run
14. **H-9** — Add `rehype-sanitize` to `ReactMarkdown` usage
### Medium-term
15. **M-1** — Implement CSRF token or upgrade cookie to `SameSite: strict`
16. **M-3** — Add server-side CVE lookup cache
17. **M-5** — Add hostname format regex validation
18. **M-8** — Pull frontend origin from `CORS_ORIGINS` env var for CSP header
19. **M-9** — Replace `alert(err.message)` with user-friendly error messages
20. Remaining medium and low findings
---
## Positive Security Observations
The following were explicitly verified as secure and should be preserved:
- **SQL injection prevention** — all queries use SQLite3 parameterized statements throughout
- **Path traversal prevention** — `sanitizePathSegment()` and `isPathWithinUploads()` are comprehensive and consistently applied
- **Python script execution** — `spawn('python3', [SCRIPT, filePath])` passes arguments as an array, not a shell string — no command injection possible
- **Python scripts** — no `eval()`, `exec()`, `pickle.load()`, or shell calls in any script
- **File size enforcement** — 10 MB limit applied via multer before route handlers execute
- **File type allowlisting** — extension + MIME prefix validation applied at upload
- **Static file serving** — `express.static` with `{ dotfiles: 'deny', index: false }` prevents directory listing
- **Temp file path validation** — `isSafeTempPath()` enforces `.json` extension on compliance temp files
- **Password hashing** — bcrypt with cost factor 10 used throughout
---
*Audit scope: static analysis only. Dynamic testing (active exploitation, fuzzing, dependency CVE scan) not performed.*

View File

@@ -8,11 +8,13 @@
"@testing-library/react": "^16.3.2", "@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^13.5.0", "@testing-library/user-event": "^13.5.0",
"lucide-react": "^0.563.0", "lucide-react": "^0.563.0",
"mermaid": "^11.14.0",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"react-scripts": "5.0.1", "react-scripts": "5.0.1",
"recharts": "^3.8.1", "recharts": "^3.8.1",
"rehype-sanitize": "^6.0.0",
"web-vitals": "^2.1.4", "web-vitals": "^2.1.4",
"xlsx": "^0.18.5" "xlsx": "^0.18.5"
}, },

View File

@@ -12,6 +12,7 @@ import VulnerabilityTriagePage from './components/pages/ReportingPage';
import KnowledgeBasePage from './components/pages/KnowledgeBasePage'; import KnowledgeBasePage from './components/pages/KnowledgeBasePage';
import ExportsPage from './components/pages/ExportsPage'; import ExportsPage from './components/pages/ExportsPage';
import CompliancePage from './components/pages/CompliancePage'; import CompliancePage from './components/pages/CompliancePage';
import ArchiveSummaryBar from './components/pages/ArchiveSummaryBar';
import './App.css'; import './App.css';
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api'; const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
@@ -161,7 +162,7 @@ const API_HOST = process.env.REACT_APP_API_HOST || 'http://localhost:3001';
const severityLevels = ['All Severities', 'Critical', 'High', 'Medium', 'Low']; const severityLevels = ['All Severities', 'Critical', 'High', 'Medium', 'Low'];
export default function App() { export default function App() {
const { isAuthenticated, loading: authLoading, canWrite, isAdmin, user } = useAuth(); const { isAuthenticated, loading: authLoading, canWrite, canDelete, canExport, isAdmin, user } = useAuth();
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [selectedVendor, setSelectedVendor] = useState('All Vendors'); const [selectedVendor, setSelectedVendor] = useState('All Vendors');
const [selectedSeverity, setSelectedSeverity] = useState('All Severities'); const [selectedSeverity, setSelectedSeverity] = useState('All Severities');
@@ -233,6 +234,12 @@ export default function App() {
const [ivantiLoading, setIvantiLoading] = useState(false); const [ivantiLoading, setIvantiLoading] = useState(false);
const [ivantiSyncing, setIvantiSyncing] = useState(false); const [ivantiSyncing, setIvantiSyncing] = useState(false);
// Archive filter state
const [archiveFilter, setArchiveFilter] = useState(null);
const [archiveRefreshKey, setArchiveRefreshKey] = useState(0);
const [archiveList, setArchiveList] = useState([]);
const [archiveListLoading, setArchiveListLoading] = useState(false);
const toggleCVEExpand = (cveId) => { const toggleCVEExpand = (cveId) => {
setExpandedCVEs(prev => ({ ...prev, [cveId]: !prev[cveId] })); setExpandedCVEs(prev => ({ ...prev, [cveId]: !prev[cveId] }));
}; };
@@ -366,6 +373,22 @@ export default function App() {
console.error('Error syncing Ivanti workflows:', err); console.error('Error syncing Ivanti workflows:', err);
} finally { } finally {
setIvantiSyncing(false); setIvantiSyncing(false);
setArchiveRefreshKey(k => k + 1);
}
};
const handleArchiveStateClick = (state) => {
const newFilter = archiveFilter === state ? null : state;
setArchiveFilter(newFilter);
if (newFilter) {
setArchiveListLoading(true);
fetch(`${API_BASE}/ivanti/archive?state=${newFilter}`, { credentials: 'include' })
.then(res => res.ok ? res.json() : Promise.reject())
.then(data => setArchiveList(data.archives || []))
.catch(() => setArchiveList([]))
.finally(() => setArchiveListLoading(false));
} else {
setArchiveList([]);
} }
}; };
@@ -989,6 +1012,11 @@ export default function App() {
{currentPage === 'compliance' && <CompliancePage onNavigate={setCurrentPage} />} {currentPage === 'compliance' && <CompliancePage onNavigate={setCurrentPage} />}
{currentPage === 'knowledge-base' && <KnowledgeBasePage />} {currentPage === 'knowledge-base' && <KnowledgeBasePage />}
{currentPage === 'exports' && <ExportsPage />} {currentPage === 'exports' && <ExportsPage />}
{currentPage === 'admin' && isAdmin() && (
<div className="space-y-6">
<UserManagement onClose={() => setCurrentPage('home')} />
</div>
)}
{/* User Management Modal */} {/* User Management Modal */}
{showUserManagement && ( {showUserManagement && (
@@ -1723,7 +1751,7 @@ export default function App() {
<span className="text-gray-500 mx-2"></span> <span className="text-gray-500 mx-2"></span>
<span className="text-gray-300">{cves.length}</span> vendor entr{cves.length !== 1 ? 'ies' : 'y'} <span className="text-gray-300">{cves.length}</span> vendor entr{cves.length !== 1 ? 'ies' : 'y'}
</p> </p>
{selectedDocuments.length > 0 && ( {selectedDocuments.length > 0 && canExport() && (
<button <button
onClick={exportSelectedDocuments} onClick={exportSelectedDocuments}
className="intel-button intel-button-primary flex items-center gap-2" className="intel-button intel-button-primary flex items-center gap-2"
@@ -1810,7 +1838,7 @@ export default function App() {
<span>Published: {vendorEntries[0].published_date}</span> <span>Published: {vendorEntries[0].published_date}</span>
<span className="text-intel-accent"></span> <span className="text-intel-accent"></span>
<span>{vendorEntries.length} affected vendor{vendorEntries.length > 1 ? 's' : ''}</span> <span>{vendorEntries.length} affected vendor{vendorEntries.length > 1 ? 's' : ''}</span>
{canWrite() && vendorEntries.length >= 2 && ( {isAdmin() && vendorEntries.length >= 2 && (
<button <button
onClick={(e) => { e.stopPropagation(); handleDeleteEntireCVE(cveId, vendorEntries.length); }} onClick={(e) => { e.stopPropagation(); handleDeleteEntireCVE(cveId, vendorEntries.length); }}
className="ml-2 px-3 py-1 text-xs intel-button intel-button-danger flex items-center gap-1" className="ml-2 px-3 py-1 text-xs intel-button intel-button-danger flex items-center gap-1"
@@ -1871,7 +1899,7 @@ export default function App() {
<Edit2 className="w-4 h-4" /> <Edit2 className="w-4 h-4" />
</button> </button>
)} )}
{canWrite() && ( {canDelete(cve) && (
<button <button
onClick={() => handleDeleteCVEEntry(cve)} onClick={() => handleDeleteCVEEntry(cve)}
className="px-3 py-2 text-intel-danger hover:bg-intel-medium rounded border border-intel-danger/50 transition-all flex items-center gap-1" className="px-3 py-2 text-intel-danger hover:bg-intel-medium rounded border border-intel-danger/50 transition-all flex items-center gap-1"
@@ -2003,9 +2031,11 @@ export default function App() {
<button onClick={() => handleEditTicket(ticket)} className="p-1 text-gray-400 hover:text-intel-warning transition-colors"> <button onClick={() => handleEditTicket(ticket)} className="p-1 text-gray-400 hover:text-intel-warning transition-colors">
<Edit2 className="w-4 h-4" /> <Edit2 className="w-4 h-4" />
</button> </button>
{canDelete(ticket) && (
<button onClick={() => handleDeleteTicket(ticket)} className="p-1 text-gray-400 hover:text-intel-danger transition-colors"> <button onClick={() => handleDeleteTicket(ticket)} className="p-1 text-gray-400 hover:text-intel-danger transition-colors">
<Trash2 className="w-4 h-4" /> <Trash2 className="w-4 h-4" />
</button> </button>
)}
</div> </div>
)} )}
</div> </div>
@@ -2129,9 +2159,11 @@ export default function App() {
<button onClick={() => handleEditTicket(ticket)} className="text-gray-400 hover:text-intel-warning transition-colors"> <button onClick={() => handleEditTicket(ticket)} className="text-gray-400 hover:text-intel-warning transition-colors">
<Edit2 className="w-3 h-3" /> <Edit2 className="w-3 h-3" />
</button> </button>
{canDelete(ticket) && (
<button onClick={() => handleDeleteTicket(ticket)} className="text-gray-400 hover:text-intel-danger transition-colors"> <button onClick={() => handleDeleteTicket(ticket)} className="text-gray-400 hover:text-intel-danger transition-colors">
<Trash2 className="w-3 h-3" /> <Trash2 className="w-3 h-3" />
</button> </button>
)}
</div> </div>
)} )}
</div> </div>
@@ -2197,14 +2229,16 @@ export default function App() {
> >
<Filter className="w-3 h-3" /> <Filter className="w-3 h-3" />
</button> </button>
{canWrite() && (<> {canWrite() && (
<button onClick={() => handleEditArcherTicket(ticket)} className="text-gray-400 hover:text-purple-400 transition-colors"> <button onClick={() => handleEditArcherTicket(ticket)} className="text-gray-400 hover:text-purple-400 transition-colors">
<Edit2 className="w-3 h-3" /> <Edit2 className="w-3 h-3" />
</button> </button>
)}
{canDelete(ticket) && (
<button onClick={() => handleDeleteArcherTicket(ticket)} className="text-gray-400 hover:text-intel-danger transition-colors"> <button onClick={() => handleDeleteArcherTicket(ticket)} className="text-gray-400 hover:text-intel-danger transition-colors">
<Trash2 className="w-3 h-3" /> <Trash2 className="w-3 h-3" />
</button> </button>
</>)} )}
</div> </div>
</div> </div>
<div className="text-xs text-white font-mono mb-1">{ticket.cve_id}</div> <div className="text-xs text-white font-mono mb-1">{ticket.cve_id}</div>
@@ -2233,6 +2267,7 @@ export default function App() {
<Activity className="w-5 h-5" /> <Activity className="w-5 h-5" />
Ivanti Workflows Ivanti Workflows
</h2> </h2>
{canWrite() && (
<button <button
onClick={syncIvantiWorkflows} onClick={syncIvantiWorkflows}
disabled={ivantiSyncing || ivantiLoading} disabled={ivantiSyncing || ivantiLoading}
@@ -2242,6 +2277,7 @@ export default function App() {
<RefreshCw className={`w-3 h-3 ${ivantiSyncing ? 'animate-spin' : ''}`} /> <RefreshCw className={`w-3 h-3 ${ivantiSyncing ? 'animate-spin' : ''}`} />
{ivantiSyncing ? 'Syncing…' : 'Sync'} {ivantiSyncing ? 'Syncing…' : 'Sync'}
</button> </button>
)}
</div> </div>
{/* Last synced line */} {/* Last synced line */}
@@ -2251,6 +2287,49 @@ export default function App() {
: 'Never synced'} : 'Never synced'}
</div> </div>
{/* Archive Summary Bar */}
<ArchiveSummaryBar onStateClick={handleArchiveStateClick} activeFilter={archiveFilter} refreshKey={archiveRefreshKey} />
{/* Archive list — shown when a state card is clicked */}
{archiveFilter && (
<div style={{ marginBottom: '1rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.5rem' }}>
<span style={{ fontFamily: 'monospace', fontSize: '0.75rem', color: '#94A3B8', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
{archiveFilter} findings
</span>
<button
onClick={() => { setArchiveFilter(null); setArchiveList([]); }}
style={{ background: 'none', border: 'none', color: '#94A3B8', cursor: 'pointer', fontFamily: 'monospace', fontSize: '0.7rem' }}
>
Clear
</button>
</div>
{archiveListLoading ? (
<div style={{ textAlign: 'center', padding: '1rem', color: '#94A3B8', fontFamily: 'monospace', fontSize: '0.75rem' }}>Loading</div>
) : archiveList.length === 0 ? (
<div style={{ textAlign: 'center', padding: '1rem', color: '#64748B', fontFamily: 'monospace', fontSize: '0.72rem', border: '1px dashed rgba(100, 116, 139, 0.3)', borderRadius: '0.375rem' }}>
No {archiveFilter.toLowerCase()} findings
</div>
) : (
<div style={{ maxHeight: '240px', overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: '0.375rem' }}>
{archiveList.map((a) => (
<div key={a.id} style={{ background: 'linear-gradient(135deg, rgba(30, 41, 59, 0.85), rgba(51, 65, 85, 0.75))', border: '1px solid rgba(100, 116, 139, 0.25)', borderRadius: '0.375rem', padding: '0.5rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'start', gap: '0.5rem', marginBottom: '0.25rem' }}>
<span style={{ fontFamily: 'monospace', fontSize: '0.7rem', fontWeight: '600', color: '#E2E8F0' }}>{a.finding_title || a.finding_id}</span>
<span style={{ fontFamily: 'monospace', fontSize: '0.6rem', padding: '0.15rem 0.35rem', borderRadius: '0.25rem', background: 'rgba(100, 116, 139, 0.2)', border: '1px solid rgba(100, 116, 139, 0.4)', color: '#94A3B8', whiteSpace: 'nowrap' }}>
{a.last_severity?.toFixed(1) ?? '—'}
</span>
</div>
<div style={{ fontFamily: 'monospace', fontSize: '0.65rem', color: '#64748B' }}>
{a.host_name}{a.ip_address ? ` (${a.ip_address})` : ''}
</div>
</div>
))}
</div>
)}
</div>
)}
{ivantiLoading ? ( {ivantiLoading ? (
<div className="text-center py-8"> <div className="text-center py-8">
<Loader className="w-6 h-6 text-teal-400 animate-spin mx-auto mb-2" /> <Loader className="w-6 h-6 text-teal-400 animate-spin mx-auto mb-2" />

View File

@@ -1,7 +1,73 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect, useRef } from 'react';
import ReactMarkdown from 'react-markdown'; import ReactMarkdown from 'react-markdown';
import rehypeSanitize from 'rehype-sanitize';
import mermaid from 'mermaid';
import { X, Download, Loader, AlertCircle, FileText, File } from 'lucide-react'; import { X, Download, Loader, AlertCircle, FileText, File } from 'lucide-react';
mermaid.initialize({
startOnLoad: false,
theme: 'dark',
darkMode: true,
themeVariables: {
background: '#0f172a',
primaryColor: '#1e3a5f',
primaryTextColor: '#e2e8f0',
primaryBorderColor: '#0ea5e9',
lineColor: '#475569',
secondaryColor: '#1a2e1a',
tertiaryColor: '#2d1f14',
edgeLabelBackground: '#1e293b',
clusterBkg: '#1e293b',
titleColor: '#e2e8f0',
fontFamily: 'monospace'
}
});
let mermaidCounter = 0;
function MermaidDiagram({ code }) {
const ref = useRef(null);
const [svgError, setSvgError] = useState('');
useEffect(() => {
let cancelled = false;
const id = `mermaid-kb-${++mermaidCounter}`;
mermaid.render(id, code)
.then(({ svg }) => {
if (!cancelled && ref.current) {
ref.current.innerHTML = svg;
// Make SVG responsive
const svgEl = ref.current.querySelector('svg');
if (svgEl) {
svgEl.removeAttribute('width');
svgEl.removeAttribute('height');
svgEl.style.width = '100%';
svgEl.style.maxWidth = '100%';
}
}
})
.catch((err) => {
if (!cancelled) setSvgError(err.message || 'Failed to render diagram');
});
return () => { cancelled = true; };
}, [code]);
if (svgError) {
return (
<pre style={{ color: '#EF4444', fontSize: '0.75rem', padding: '0.75rem', background: 'rgba(239,68,68,0.1)', borderRadius: '0.375rem', overflowX: 'auto' }}>
Mermaid render error: {svgError}
</pre>
);
}
return (
<div
ref={ref}
style={{ background: 'rgba(15,23,42,0.6)', border: '1px solid rgba(14,165,233,0.2)', borderRadius: '0.5rem', padding: '1rem', margin: '1rem 0', overflowX: 'auto' }}
/>
);
}
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api'; const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
export default function KnowledgeBaseViewer({ article, onClose }) { export default function KnowledgeBaseViewer({ article, onClose }) {
@@ -167,7 +233,27 @@ export default function KnowledgeBaseViewer({ article, onClose }) {
{/* Markdown Rendering */} {/* Markdown Rendering */}
{isMarkdown && ( {isMarkdown && (
<div className="markdown-content"> <div className="markdown-content">
<ReactMarkdown>{content}</ReactMarkdown> <ReactMarkdown
rehypePlugins={[rehypeSanitize]}
components={{
code({ inline, className, children }) {
const lang = /language-(\w+)/.exec(className || '')?.[1];
if (!inline && lang === 'mermaid') {
return <MermaidDiagram code={String(children).replace(/\n$/, '')} />;
}
return (
<code
className={className}
style={inline ? { background: 'rgba(14,165,233,0.15)', padding: '0.1rem 0.3rem', borderRadius: '0.25rem', fontFamily: 'monospace', fontSize: '0.85em' } : {}}
>
{children}
</code>
);
}
}}
>
{content}
</ReactMarkdown>
</div> </div>
)} )}
@@ -193,6 +279,7 @@ export default function KnowledgeBaseViewer({ article, onClose }) {
{isPDF && ( {isPDF && (
<div className="w-full" style={{ height: '700px' }}> <div className="w-full" style={{ height: '700px' }}>
<iframe <iframe
sandbox="allow-same-origin"
src={`${API_BASE}/knowledge-base/${article.id}/content`} src={`${API_BASE}/knowledge-base/${article.id}/content`}
title={article.title} title={article.title}
className="w-full h-full rounded" className="w-full h-full rounded"

View File

@@ -98,12 +98,6 @@ export default function LoginForm() {
)} )}
</button> </button>
</form> </form>
<div className="mt-6 pt-6 border-t border-intel-grid">
<p className="text-sm text-gray-500 text-center font-mono">
Default: <span className="text-intel-accent">admin</span> / <span className="text-intel-accent">admin123</span>
</p>
</div>
</div> </div>
</div> </div>
); );

View File

@@ -1,5 +1,6 @@
import React from 'react'; import React from 'react';
import { X, Home, BarChart2, BookOpen, Download, ShieldCheck } from 'lucide-react'; import { X, Home, BarChart2, BookOpen, Download, ShieldCheck, Settings } from 'lucide-react';
import { useAuth } from '../contexts/AuthContext';
const NAV_ITEMS = [ const NAV_ITEMS = [
{ id: 'home', label: 'Home', icon: Home, color: '#0EA5E9', description: 'Main dashboard' }, { id: 'home', label: 'Home', icon: Home, color: '#0EA5E9', description: 'Main dashboard' },
@@ -9,7 +10,11 @@ const NAV_ITEMS = [
{ id: 'exports', label: 'Exports', icon: Download, color: '#8B5CF6', description: 'Export data & reports' }, { id: 'exports', label: 'Exports', icon: Download, color: '#8B5CF6', description: 'Export data & reports' },
]; ];
const ADMIN_ITEM = { id: 'admin', label: 'Admin Panel', icon: Settings, color: '#EF4444', description: 'User management & audit' };
export default function NavDrawer({ isOpen, onClose, currentPage, onNavigate }) { export default function NavDrawer({ isOpen, onClose, currentPage, onNavigate }) {
const { isAdmin } = useAuth();
if (!isOpen) return null; if (!isOpen) return null;
return ( return (
@@ -110,6 +115,60 @@ export default function NavDrawer({ isOpen, onClose, currentPage, onNavigate })
</button> </button>
); );
})} })}
{/* Admin panel link — visible only to Admin group */}
{isAdmin() && (() => {
const { id, label, icon: Icon, color, description } = ADMIN_ITEM;
const active = currentPage === id;
return (
<button
key={id}
onClick={() => { onNavigate(id); onClose(); }}
style={{
display: 'flex', alignItems: 'center', gap: '0.875rem',
padding: '0.75rem 0.875rem',
borderRadius: '0.5rem',
border: active ? `1px solid ${color}50` : '1px solid transparent',
background: active ? `${color}18` : 'transparent',
cursor: 'pointer', textAlign: 'left', width: '100%',
marginTop: '0.5rem',
borderTop: '1px solid rgba(255, 255, 255, 0.05)',
paddingTop: '1rem',
transition: 'background 0.15s, border-color 0.15s'
}}
onMouseEnter={e => { if (!active) e.currentTarget.style.background = 'rgba(255,255,255,0.04)'; }}
onMouseLeave={e => { if (!active) e.currentTarget.style.background = 'transparent'; }}
>
<div style={{
width: '36px', height: '36px', flexShrink: 0,
borderRadius: '0.375rem',
background: `${color}18`,
border: `1px solid ${color}40`,
display: 'flex', alignItems: 'center', justifyContent: 'center'
}}>
<Icon style={{ width: '17px', height: '17px', color }} />
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{
fontFamily: 'monospace', fontSize: '0.8rem', fontWeight: '600',
color: active ? color : '#CBD5E1',
textTransform: 'uppercase', letterSpacing: '0.06em'
}}>
{label}
</div>
<div style={{ fontSize: '0.68rem', color: '#475569', marginTop: '1px' }}>
{description}
</div>
</div>
{active && (
<div style={{
width: '6px', height: '6px', borderRadius: '50%',
background: color, boxShadow: `0 0 8px ${color}`, flexShrink: 0
}} />
)}
</button>
);
})()}
</nav> </nav>
{/* Footer */} {/* Footer */}

View File

@@ -4,6 +4,22 @@ import { useAuth } from '../contexts/AuthContext';
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api'; const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
const VALID_GROUPS = ['Admin', 'Standard_User', 'Leadership', 'Read_Only'];
const GROUP_LABELS = {
Admin: 'Admin (full access)',
Standard_User: 'Standard User (create, edit, limited delete)',
Leadership: 'Leadership (read-only + exports)',
Read_Only: 'Read Only (view only)'
};
const GROUP_BADGE_STYLES = {
Admin: { backgroundColor: '#FEE2E2', color: '#991B1B' },
Standard_User: { backgroundColor: '#DBEAFE', color: '#1E40AF' },
Leadership: { backgroundColor: '#F3E8FF', color: '#6B21A8' },
Read_Only: { backgroundColor: '#F3F4F6', color: '#374151' }
};
export default function UserManagement({ onClose }) { export default function UserManagement({ onClose }) {
const { user: currentUser } = useAuth(); const { user: currentUser } = useAuth();
const [users, setUsers] = useState([]); const [users, setUsers] = useState([]);
@@ -15,7 +31,7 @@ export default function UserManagement({ onClose }) {
username: '', username: '',
email: '', email: '',
password: '', password: '',
role: 'viewer' group: 'Read_Only'
}); });
const [formError, setFormError] = useState(''); const [formError, setFormError] = useState('');
const [formSuccess, setFormSuccess] = useState(''); const [formSuccess, setFormSuccess] = useState('');
@@ -39,11 +55,29 @@ export default function UserManagement({ onClose }) {
} }
}; };
const confirmGroupChange = (targetUser, newGroup) => {
let message = `Are you sure you want to change ${targetUser.username}'s group from ${targetUser.group} to ${newGroup}?`;
// Extra warning when downgrading an Admin user
if (targetUser.group === 'Admin' && newGroup !== 'Admin') {
message += `\n\n⚠️ WARNING: You are removing Admin privileges from ${targetUser.username}. They will lose full system access.`;
}
return window.confirm(message);
};
const handleSubmit = async (e) => { const handleSubmit = async (e) => {
e.preventDefault(); e.preventDefault();
setFormError(''); setFormError('');
setFormSuccess(''); setFormSuccess('');
// If editing and group changed, show confirmation dialog
if (editingUser && formData.group !== editingUser.group) {
if (!confirmGroupChange(editingUser, formData.group)) {
return;
}
}
try { try {
const url = editingUser const url = editingUser
? `${API_BASE}/users/${editingUser.id}` ? `${API_BASE}/users/${editingUser.id}`
@@ -75,7 +109,7 @@ export default function UserManagement({ onClose }) {
setTimeout(() => { setTimeout(() => {
setShowAddUser(false); setShowAddUser(false);
setEditingUser(null); setEditingUser(null);
setFormData({ username: '', email: '', password: '', role: 'viewer' }); setFormData({ username: '', email: '', password: '', group: 'Read_Only' });
setFormSuccess(''); setFormSuccess('');
}, 1500); }, 1500);
} catch (err) { } catch (err) {
@@ -89,7 +123,7 @@ export default function UserManagement({ onClose }) {
username: user.username, username: user.username,
email: user.email, email: user.email,
password: '', password: '',
role: user.role group: user.group
}); });
setShowAddUser(true); setShowAddUser(true);
setFormError(''); setFormError('');
@@ -140,15 +174,10 @@ export default function UserManagement({ onClose }) {
} }
}; };
const getRoleBadgeColor = (role) => { // Check if group dropdown should be disabled for self-demotion prevention
switch (role) { const isGroupDropdownDisabled = (targetUser) => {
case 'admin': if (!targetUser || !currentUser) return false;
return 'bg-red-100 text-red-800'; return targetUser.id === currentUser.id && currentUser.group === 'Admin';
case 'editor':
return 'bg-blue-100 text-blue-800';
default:
return 'bg-gray-100 text-gray-800';
}
}; };
return ( return (
@@ -173,7 +202,7 @@ export default function UserManagement({ onClose }) {
onClick={() => { onClick={() => {
setShowAddUser(true); setShowAddUser(true);
setEditingUser(null); setEditingUser(null);
setFormData({ username: '', email: '', password: '', role: 'viewer' }); setFormData({ username: '', email: '', password: '', group: 'Read_Only' });
setFormError(''); setFormError('');
setFormSuccess(''); setFormSuccess('');
}} }}
@@ -253,19 +282,24 @@ export default function UserManagement({ onClose }) {
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1"> <label className="block text-sm font-medium text-gray-700 mb-1">
Role * Group *
</label> </label>
<div className="relative"> <div className="relative">
<Shield className="w-5 h-5 text-gray-400 absolute left-3 top-1/2 transform -translate-y-1/2" /> <Shield className="w-5 h-5 text-gray-400 absolute left-3 top-1/2 transform -translate-y-1/2" />
<select <select
value={formData.role} value={formData.group}
onChange={(e) => setFormData({ ...formData, role: e.target.value })} onChange={(e) => setFormData({ ...formData, group: e.target.value })}
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#0476D9] focus:border-transparent" disabled={isGroupDropdownDisabled(editingUser)}
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#0476D9] focus:border-transparent disabled:opacity-50 disabled:cursor-not-allowed"
title={isGroupDropdownDisabled(editingUser) ? 'Cannot change your own Admin group' : ''}
> >
<option value="viewer">Viewer (read-only)</option> {VALID_GROUPS.map((g) => (
<option value="editor">Editor (can add CVEs, upload docs)</option> <option key={g} value={g}>{GROUP_LABELS[g]}</option>
<option value="admin">Admin (full access)</option> ))}
</select> </select>
{isGroupDropdownDisabled(editingUser) && (
<p className="text-xs text-amber-600 mt-1">You cannot change your own Admin group.</p>
)}
</div> </div>
</div> </div>
</div> </div>
@@ -308,7 +342,7 @@ export default function UserManagement({ onClose }) {
<thead> <thead>
<tr className="border-b border-gray-200"> <tr className="border-b border-gray-200">
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700">User</th> <th className="text-left py-3 px-4 text-sm font-medium text-gray-700">User</th>
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700">Role</th> <th className="text-left py-3 px-4 text-sm font-medium text-gray-700">Group</th>
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700">Status</th> <th className="text-left py-3 px-4 text-sm font-medium text-gray-700">Status</th>
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700">Last Login</th> <th className="text-left py-3 px-4 text-sm font-medium text-gray-700">Last Login</th>
<th className="text-right py-3 px-4 text-sm font-medium text-gray-700">Actions</th> <th className="text-right py-3 px-4 text-sm font-medium text-gray-700">Actions</th>
@@ -324,8 +358,17 @@ export default function UserManagement({ onClose }) {
</div> </div>
</td> </td>
<td className="py-3 px-4"> <td className="py-3 px-4">
<span className={`px-2 py-1 rounded text-xs font-medium ${getRoleBadgeColor(user.role)}`}> <span
{user.role.charAt(0).toUpperCase() + user.role.slice(1)} style={{
...GROUP_BADGE_STYLES[user.group] || GROUP_BADGE_STYLES.Read_Only,
padding: '2px 8px',
borderRadius: '4px',
fontSize: '12px',
fontWeight: '500',
display: 'inline-block'
}}
>
{user.group ? user.group.replace('_', ' ') : 'Read Only'}
</span> </span>
</td> </td>
<td className="py-3 px-4"> <td className="py-3 px-4">

View File

@@ -19,17 +19,26 @@ export default function UserMenu({ onManageUsers, onAuditLog }) {
return () => document.removeEventListener('mousedown', handleClickOutside); return () => document.removeEventListener('mousedown', handleClickOutside);
}, []); }, []);
const getRoleBadgeColor = (role) => { const getGroupBadgeColor = (group) => {
switch (role) { switch (group) {
case 'admin': case 'Admin':
return 'bg-red-100 text-red-800'; return 'bg-red-100 text-red-800';
case 'editor': case 'Standard_User':
return 'bg-blue-100 text-blue-800'; return 'bg-blue-100 text-blue-800';
case 'Leadership':
return 'bg-purple-100 text-purple-800';
case 'Read_Only':
return 'bg-gray-100 text-gray-800';
default: default:
return 'bg-gray-100 text-gray-800'; return 'bg-gray-100 text-gray-800';
} }
}; };
const formatGroupName = (group) => {
if (!group) return '';
return group.replace(/_/g, ' ');
};
const handleLogout = async () => { const handleLogout = async () => {
setIsOpen(false); setIsOpen(false);
await logout(); await logout();
@@ -62,7 +71,7 @@ export default function UserMenu({ onManageUsers, onAuditLog }) {
</div> </div>
<div className="text-left hidden sm:block"> <div className="text-left hidden sm:block">
<p className="text-sm font-medium text-gray-900">{user.username}</p> <p className="text-sm font-medium text-gray-900">{user.username}</p>
<p className="text-xs text-gray-500 capitalize">{user.role}</p> <p className="text-xs text-gray-500">{formatGroupName(user.group)}</p>
</div> </div>
<ChevronDown className={`w-4 h-4 text-gray-500 transition-transform ${isOpen ? 'rotate-180' : ''}`} /> <ChevronDown className={`w-4 h-4 text-gray-500 transition-transform ${isOpen ? 'rotate-180' : ''}`} />
</button> </button>
@@ -72,8 +81,8 @@ export default function UserMenu({ onManageUsers, onAuditLog }) {
<div className="px-4 py-3 border-b border-gray-100"> <div className="px-4 py-3 border-b border-gray-100">
<p className="text-sm font-medium text-gray-900">{user.username}</p> <p className="text-sm font-medium text-gray-900">{user.username}</p>
<p className="text-sm text-gray-500">{user.email}</p> <p className="text-sm text-gray-500">{user.email}</p>
<span className={`inline-block mt-2 px-2 py-1 rounded text-xs font-medium ${getRoleBadgeColor(user.role)}`}> <span className={`inline-block mt-2 px-2 py-1 rounded text-xs font-medium ${getGroupBadgeColor(user.group)}`}>
{user.role.charAt(0).toUpperCase() + user.role.slice(1)} {formatGroupName(user.group)}
</span> </span>
</div> </div>

View File

@@ -0,0 +1,205 @@
// ArchiveSummaryBar.js
// Displays four stat cards for archive lifecycle states: ACTIVE, ARCHIVED, RETURNED, CLOSED.
// Fetches counts from /api/ivanti/archive/stats on mount.
import React, { useState, useEffect } from 'react';
import { Activity, Archive, RotateCcw, XCircle, Loader } from 'lucide-react';
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
const STATE_CONFIG = [
{
key: 'ACTIVE',
label: 'Active',
color: '#0EA5E9',
Icon: Activity,
},
{
key: 'ARCHIVED',
label: 'Archived',
color: '#F59E0B',
Icon: Archive,
},
{
key: 'RETURNED',
label: 'Returned',
color: '#10B981',
Icon: RotateCcw,
},
{
key: 'CLOSED',
label: 'Closed',
color: '#EF4444',
Icon: XCircle,
},
];
function StatCard({ stateKey, label, color, Icon, count, active, onClick }) {
const [hovered, setHovered] = useState(false);
const isHighlighted = active || hovered;
const cardStyle = {
flex: '1 1 0',
minWidth: '140px',
background: 'linear-gradient(135deg, rgba(30, 41, 59, 0.95), rgba(51, 65, 85, 0.9))',
border: `2px solid ${isHighlighted ? color : `rgba(${hexToRgb(color)}, 0.3)`}`,
borderRadius: '0.5rem',
padding: '1rem',
cursor: 'pointer',
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
transform: isHighlighted ? 'translateY(-2px)' : 'translateY(0)',
boxShadow: isHighlighted
? `0 4px 16px rgba(0, 0, 0, 0.5), 0 0 20px rgba(${hexToRgb(color)}, 0.25)`
: '0 4px 16px rgba(0, 0, 0, 0.5)',
position: 'relative',
overflow: 'hidden',
};
const accentLineStyle = {
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: '2px',
background: `linear-gradient(90deg, transparent, ${color}, transparent)`,
boxShadow: `0 0 8px ${color}`,
};
return (
<div
style={cardStyle}
onClick={() => onClick(stateKey)}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
role="button"
tabIndex={0}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onClick(stateKey); } }}
aria-label={`${label}: ${count} findings. ${active ? 'Currently filtered.' : 'Click to filter.'}`}
>
<div style={accentLineStyle} />
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '0.625rem' }}>
<Icon
style={{
width: '16px',
height: '16px',
color: color,
filter: isHighlighted ? `drop-shadow(0 0 4px ${color})` : 'none',
}}
/>
<span style={{
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
fontSize: '0.7rem',
fontWeight: '600',
color: color,
textTransform: 'uppercase',
letterSpacing: '0.08em',
textShadow: isHighlighted ? `0 0 8px rgba(${hexToRgb(color)}, 0.5)` : 'none',
}}>
{label}
</span>
</div>
<div style={{
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
fontSize: '1.75rem',
fontWeight: '700',
color: '#F8FAFC',
lineHeight: 1,
textShadow: `0 0 16px rgba(${hexToRgb(color)}, 0.3)`,
}}>
{count != null ? count : '—'}
</div>
</div>
);
}
// Convert hex color to r, g, b string for use in rgba()
function hexToRgb(hex) {
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
return `${r}, ${g}, ${b}`;
}
export default function ArchiveSummaryBar({ onStateClick, activeFilter, refreshKey }) {
const [stats, setStats] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
useEffect(() => {
let cancelled = false;
const load = async () => {
setLoading(true);
setError(false);
try {
const res = await fetch(`${API_BASE}/ivanti/archive/stats`, { credentials: 'include' });
if (res.ok && !cancelled) {
const data = await res.json();
setStats(data);
} else if (!cancelled) {
setError(true);
}
} catch {
if (!cancelled) setError(true);
} finally {
if (!cancelled) setLoading(false);
}
};
load();
// Re-fetch every 60s so stats stay reasonably fresh after syncs
const interval = setInterval(load, 60000);
return () => { cancelled = true; clearInterval(interval); };
}, [refreshKey]);
if (loading) {
return (
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'center',
gap: '0.5rem', padding: '1.25rem',
color: '#94A3B8', fontFamily: 'monospace', fontSize: '0.75rem',
}}>
<Loader style={{ width: '14px', height: '14px', animation: 'spin 1s linear infinite' }} />
Loading archive stats
</div>
);
}
if (error) {
return (
<div style={{
padding: '1rem', textAlign: 'center',
color: '#94A3B8', fontFamily: 'monospace', fontSize: '0.72rem',
border: '1px dashed rgba(239, 68, 68, 0.2)', borderRadius: '0.375rem',
}}>
Unable to load archive statistics
</div>
);
}
const handleClick = (state) => {
if (onStateClick) onStateClick(state);
};
return (
<div style={{
display: 'flex',
gap: '0.75rem',
marginBottom: '1.25rem',
flexWrap: 'wrap',
}}>
{STATE_CONFIG.map(({ key, label, color, Icon }) => (
<StatCard
key={key}
stateKey={key}
label={label}
color={color}
Icon={Icon}
count={stats?.[key] ?? 0}
active={activeFilter === key}
onClick={handleClick}
/>
))}
</div>
);
}

View File

@@ -1,6 +1,7 @@
import React, { useState, useCallback } from 'react'; import React, { useState, useCallback } from 'react';
import * as XLSX from 'xlsx'; import * as XLSX from 'xlsx';
import { Download, Loader, AlertCircle, BarChart2, FileText, Shield, Tag, CheckCircle, X } from 'lucide-react'; import { Download, Loader, AlertCircle, BarChart2, FileText, Shield, Tag, CheckCircle, X } from 'lucide-react';
import { useAuth } from '../../contexts/AuthContext';
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api'; const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
const EXC_PATTERN = /EXC-\d+/i; const EXC_PATTERN = /EXC-\d+/i;
@@ -217,6 +218,7 @@ function Toggle({ label, checked, onChange, color, colorRgb }) {
// Main page // Main page
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export default function ExportsPage() { export default function ExportsPage() {
const { canExport } = useAuth();
const [loading, setLoading] = useState(null); const [loading, setLoading] = useState(null);
const [error, setError] = useState(null); const [error, setError] = useState(null);
const [cveStatus, setCveStatus] = useState(''); const [cveStatus, setCveStatus] = useState('');
@@ -333,6 +335,15 @@ export default function ExportsPage() {
// ---- Render ---- // ---- Render ----
if (!canExport()) {
return (
<div style={{ textAlign: 'center', padding: '4rem 1rem', color: '#94A3B8' }}>
<Shield style={{ width: '48px', height: '48px', margin: '0 auto 1rem', opacity: 0.5 }} />
<p style={{ fontFamily: 'monospace', fontSize: '0.9rem' }}>You do not have permission to export data.</p>
</div>
);
}
return ( return (
<div style={{ padding: '1.5rem', display: 'flex', flexDirection: 'column', gap: '1.5rem' }}> <div style={{ padding: '1.5rem', display: 'flex', flexDirection: 'column', gap: '1.5rem' }}>

View File

@@ -1,6 +1,6 @@
import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react'; import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import ReactDOM from 'react-dom'; import ReactDOM from 'react-dom';
import { RefreshCw, Loader, AlertCircle, PieChart, ChevronUp, ChevronDown, ChevronsUpDown, Settings2, GripVertical, Eye, EyeOff, Filter, Download, RotateCcw, Trash2, X, ListTodo } from 'lucide-react'; import { RefreshCw, Loader, AlertCircle, PieChart, ChevronUp, ChevronDown, ChevronsUpDown, Settings2, GripVertical, Eye, EyeOff, Filter, Download, RotateCcw, Trash2, X, ListTodo, Upload, FileText, Check, AlertTriangle } from 'lucide-react';
import * as XLSX from 'xlsx'; import * as XLSX from 'xlsx';
import { useAuth } from '../../contexts/AuthContext'; import { useAuth } from '../../contexts/AuthContext';
import IvantiCountsChart from './IvantiCountsChart'; import IvantiCountsChart from './IvantiCountsChart';
@@ -1242,7 +1242,7 @@ function AddToQueuePopover({ finding, anchorRect, queueForm, setQueueForm, onAdd
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// QueuePanel — fixed slide-out panel showing the user's Ivanti queue // QueuePanel — fixed slide-out panel showing the user's Ivanti queue
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function QueuePanel({ open, items, onClose, onUpdate, onDelete, onDeleteMany, onClearCompleted }) { function QueuePanel({ open, items, onClose, onUpdate, onDelete, onDeleteMany, onClearCompleted, onCreateFpWorkflow, canWrite }) {
const pendingCount = items.filter((i) => i.status === 'pending').length; const pendingCount = items.filter((i) => i.status === 'pending').length;
const completedCount = items.filter((i) => i.status === 'complete').length; const completedCount = items.filter((i) => i.status === 'complete').length;
@@ -1492,6 +1492,30 @@ function QueuePanel({ open, items, onClose, onUpdate, onDelete, onDeleteMany, on
flexShrink: 0, flexShrink: 0,
display: 'flex', gap: '0.5rem', display: 'flex', gap: '0.5rem',
}}> }}>
{/* Create FP Workflow — visible for editor/admin only */}
{canWrite && (() => {
const fpEnabled = isCreateFpButtonEnabled(items, selectedIds);
return (
<button
onClick={() => onCreateFpWorkflow([...selectedIds])}
disabled={!fpEnabled}
title={!fpEnabled ? 'Select pending FP items to create a workflow' : ''}
style={{
flex: 1, padding: '0.45rem',
background: fpEnabled ? 'rgba(245,158,11,0.12)' : 'transparent',
border: `1px solid ${fpEnabled ? 'rgba(245,158,11,0.35)' : 'rgba(255,255,255,0.05)'}`,
borderRadius: '0.375rem',
color: fpEnabled ? '#F59E0B' : '#334155',
fontFamily: 'monospace', fontSize: '0.72rem', fontWeight: '600',
cursor: fpEnabled ? 'pointer' : 'not-allowed',
textTransform: 'uppercase', letterSpacing: '0.05em',
transition: 'all 0.12s',
}}
>
Create FP Workflow
</button>
);
})()}
{/* Delete selected — only shown when items are selected */} {/* Delete selected — only shown when items are selected */}
{selectedIds.size > 0 && ( {selectedIds.size > 0 && (
<button <button
@@ -1534,6 +1558,561 @@ function QueuePanel({ open, items, onClose, onUpdate, onDelete, onDeleteMany, on
); );
} }
// ---------------------------------------------------------------------------
// FP Workflow helpers (pure functions, exported for testing)
// ---------------------------------------------------------------------------
function isCreateFpButtonEnabled(items, selectedIds) {
return items.some(item =>
selectedIds.has(item.id) &&
item.workflow_type === 'FP' &&
item.status === 'pending'
);
}
function filterFpItems(items) {
return items.filter(item => item.workflow_type === 'FP');
}
// ---------------------------------------------------------------------------
// FpWorkflowModal — submit FP workflows to Ivanti API
// ---------------------------------------------------------------------------
const ALLOWED_EXTENSIONS = ['.pdf', '.png', '.jpg', '.jpeg', '.gif', '.doc', '.docx', '.xlsx', '.csv', '.txt', '.zip'];
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB
function FpWorkflowModal({ open, onClose, selectedItems, onSuccess }) {
const [name, setName] = useState('');
const [reason, setReason] = useState('');
const [description, setDescription] = useState('');
const [expirationDate, setExpirationDate] = useState('');
const [scopeOverride, setScopeOverride] = useState('Authorized');
const [files, setFiles] = useState([]);
const [submitting, setSubmitting] = useState(false);
const [progress, setProgress] = useState({ step: '', current: 0, total: 0 });
const [errors, setErrors] = useState({});
const [result, setResult] = useState(null);
const fileInputRef = useRef(null);
const dropRef = useRef(null);
// Reset form when modal opens
useEffect(() => {
if (open) {
setName('');
setReason('');
setDescription('');
setExpirationDate('');
setScopeOverride('Authorized');
setFiles([]);
setSubmitting(false);
setProgress({ step: '', current: 0, total: 0 });
setErrors({});
setResult(null);
}
}, [open]);
// Close on Escape
useEffect(() => {
if (!open) return;
const handler = (e) => { if (e.key === 'Escape' && !submitting) onClose(); };
document.addEventListener('keydown', handler);
return () => document.removeEventListener('keydown', handler);
}, [open, submitting, onClose]);
const isAllowedExtension = (filename) => {
const ext = '.' + filename.split('.').pop().toLowerCase();
return ALLOWED_EXTENSIONS.includes(ext);
};
const addFiles = (newFiles) => {
const fileErrors = [];
const valid = [];
Array.from(newFiles).forEach(f => {
if (!isAllowedExtension(f.name)) {
fileErrors.push(`"${f.name}" — file type not allowed. Accepted: ${ALLOWED_EXTENSIONS.join(', ')}`);
} else if (f.size > MAX_FILE_SIZE) {
fileErrors.push(`"${f.name}" — exceeds 10 MB limit`);
} else {
valid.push(f);
}
});
if (fileErrors.length) {
setErrors(prev => ({ ...prev, files: fileErrors.join('; ') }));
} else {
setErrors(prev => { const next = { ...prev }; delete next.files; return next; });
}
if (valid.length) setFiles(prev => [...prev, ...valid]);
};
const removeFile = (idx) => {
setFiles(prev => prev.filter((_, i) => i !== idx));
setErrors(prev => { const next = { ...prev }; delete next.files; return next; });
};
const handleDrop = (e) => {
e.preventDefault();
e.stopPropagation();
if (e.dataTransfer.files?.length) addFiles(e.dataTransfer.files);
};
const handleDragOver = (e) => { e.preventDefault(); e.stopPropagation(); };
const validate = () => {
const errs = {};
if (!name.trim()) errs.name = 'Workflow name is required';
else if (name.trim().length > 255) errs.name = 'Name must be 255 characters or fewer';
if (!reason.trim()) errs.reason = 'Reason is required';
if (description.length > 2000) errs.description = 'Description must be 2000 characters or fewer';
if (!expirationDate) errs.expirationDate = 'Expiration date is required';
else {
const today = new Date();
today.setHours(0, 0, 0, 0);
const exp = new Date(expirationDate + 'T00:00:00');
if (exp <= today) errs.expirationDate = 'Expiration date must be in the future';
}
setErrors(errs);
return Object.keys(errs).length === 0;
};
const handleSubmit = async () => {
if (!validate()) return;
setSubmitting(true);
setProgress({ step: 'Creating workflow...', current: 0, total: 0 });
setResult(null);
try {
const formData = new FormData();
formData.append('name', name.trim());
formData.append('reason', reason.trim());
if (description.trim()) formData.append('description', description.trim());
formData.append('expirationDate', expirationDate);
formData.append('scopeOverride', scopeOverride);
formData.append('findingIds', JSON.stringify(selectedItems.map(i => i.finding_id)));
formData.append('queueItemIds', JSON.stringify(selectedItems.map(i => i.id)));
files.forEach(f => formData.append('attachments', f));
if (files.length > 0) {
setProgress({ step: 'Creating workflow and uploading attachments...', current: 0, total: files.length });
}
const res = await fetch(`${API_BASE}/ivanti/fp-workflow`, {
method: 'POST',
credentials: 'include',
body: formData,
});
const data = await res.json();
if (res.ok && data.success) {
setResult({
success: true,
workflowBatchId: data.workflowBatchId,
generatedId: data.generatedId,
attachmentResults: data.attachmentResults || [],
status: data.status || 'success',
});
onSuccess();
} else {
let errorMsg = data.error || 'Workflow creation failed';
if (res.status === 401) errorMsg = 'Ivanti API key is invalid or missing. Contact your administrator.';
else if (res.status === 429) errorMsg = 'Ivanti API rate limit reached. Please try again in a few minutes.';
setResult({
success: false,
error: errorMsg,
workflowBatchId: data.workflowBatchId || null,
generatedId: data.generatedId || null,
attachmentResults: data.attachmentResults || [],
status: data.status || 'failed',
});
}
} catch (err) {
setResult({
success: false,
error: err.message || 'Network error — could not reach the server',
status: 'failed',
});
} finally {
setSubmitting(false);
}
};
if (!open) return null;
const formatSize = (bytes) => {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
};
// ---- Styles ----
const overlayStyle = {
position: 'fixed', inset: 0, zIndex: 10000,
background: 'rgba(0,0,0,0.6)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
};
const modalStyle = {
width: '640px', maxHeight: '90vh', overflow: 'auto',
background: 'linear-gradient(180deg, #0D1929 0%, #080F1C 100%)',
border: '1px solid rgba(245,158,11,0.3)',
borderRadius: '0.75rem',
boxShadow: '0 12px 48px rgba(0,0,0,0.8)',
fontFamily: 'monospace',
};
const headerStyle = {
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '1rem 1.25rem',
borderBottom: '1px solid rgba(245,158,11,0.2)',
};
const sectionStyle = {
padding: '0.875rem 1.25rem',
borderBottom: '1px solid rgba(255,255,255,0.04)',
};
const labelStyle = {
display: 'block', fontSize: '0.68rem', fontWeight: '600',
color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.08em',
marginBottom: '0.35rem',
};
const inputStyle = {
width: '100%', boxSizing: 'border-box',
background: 'rgba(14,165,233,0.05)',
border: '1px solid rgba(14,165,233,0.2)',
borderRadius: '0.25rem', padding: '0.45rem 0.6rem',
color: '#CBD5E1', fontSize: '0.82rem', fontFamily: 'monospace',
outline: 'none',
};
const inputErrorStyle = { ...inputStyle, borderColor: '#EF4444' };
const textareaStyle = { ...inputStyle, minHeight: '60px', resize: 'vertical' };
const textareaErrorStyle = { ...textareaStyle, borderColor: '#EF4444' };
const errorTextStyle = { fontSize: '0.68rem', color: '#EF4444', marginTop: '0.2rem' };
const footerStyle = {
display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: '0.625rem',
padding: '0.875rem 1.25rem',
};
// ---- Result views ----
if (result) {
return ReactDOM.createPortal(
<div style={overlayStyle} onClick={() => { if (!submitting) onClose(); }}>
<div style={modalStyle} onClick={e => e.stopPropagation()}>
<div style={headerStyle}>
<span style={{ fontSize: '0.88rem', fontWeight: '700', color: result.success ? '#10B981' : '#EF4444' }}>
{result.success ? 'Workflow Created' : 'Submission Failed'}
</span>
<button onClick={onClose} style={{ background: 'none', border: 'none', color: '#64748B', cursor: 'pointer' }}>
<X size={16} />
</button>
</div>
<div style={{ padding: '1.5rem 1.25rem', textAlign: 'center' }}>
{result.success ? (
<>
<div style={{ marginBottom: '1rem' }}>
<Check size={36} style={{ color: '#10B981' }} />
</div>
<div style={{ fontSize: '1.1rem', fontWeight: '700', color: '#F59E0B', marginBottom: '0.5rem' }}>
{result.generatedId || `Batch #${result.workflowBatchId}`}
</div>
<div style={{ fontSize: '0.78rem', color: '#94A3B8', marginBottom: '1rem' }}>
FP workflow created successfully with {selectedItems.length} finding{selectedItems.length !== 1 ? 's' : ''}.
</div>
{result.attachmentResults.length > 0 && (
<div style={{ textAlign: 'left', marginBottom: '1rem' }}>
<div style={labelStyle}>Attachments</div>
{result.attachmentResults.map((a, i) => (
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', fontSize: '0.75rem', color: a.success ? '#10B981' : '#EF4444', marginBottom: '0.25rem' }}>
{a.success ? <Check size={12} /> : <AlertTriangle size={12} />}
<span>{a.filename}</span>
</div>
))}
</div>
)}
</>
) : (
<>
<div style={{ marginBottom: '1rem' }}>
<AlertTriangle size={36} style={{ color: '#EF4444' }} />
</div>
<div style={{ fontSize: '0.88rem', fontWeight: '600', color: '#E2E8F0', marginBottom: '0.5rem' }}>
{result.error}
</div>
{result.generatedId && (
<div style={{ fontSize: '0.78rem', color: '#F59E0B', marginBottom: '0.5rem' }}>
Workflow was created: {result.generatedId}
</div>
)}
{result.attachmentResults?.length > 0 && (
<div style={{ textAlign: 'left', marginBottom: '1rem' }}>
<div style={labelStyle}>Attachment Results</div>
{result.attachmentResults.map((a, i) => (
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', fontSize: '0.75rem', color: a.success ? '#10B981' : '#EF4444', marginBottom: '0.25rem' }}>
{a.success ? <Check size={12} /> : <AlertTriangle size={12} />}
<span>{a.filename}</span>
</div>
))}
</div>
)}
</>
)}
</div>
<div style={footerStyle}>
{!result.success && (
<button
onClick={() => setResult(null)}
style={{
padding: '0.45rem 1rem',
background: 'rgba(245,158,11,0.1)',
border: '1px solid rgba(245,158,11,0.3)',
borderRadius: '0.375rem',
color: '#F59E0B', fontSize: '0.78rem', fontWeight: '600',
cursor: 'pointer', fontFamily: 'monospace',
}}
>
Retry
</button>
)}
<button
onClick={onClose}
style={{
padding: '0.45rem 1rem',
background: result.success ? 'rgba(16,185,129,0.12)' : 'rgba(255,255,255,0.04)',
border: `1px solid ${result.success ? 'rgba(16,185,129,0.3)' : 'rgba(255,255,255,0.1)'}`,
borderRadius: '0.375rem',
color: result.success ? '#10B981' : '#94A3B8',
fontSize: '0.78rem', fontWeight: '600',
cursor: 'pointer', fontFamily: 'monospace',
}}
>
Done
</button>
</div>
</div>
</div>,
document.body
);
}
// ---- Form view ----
return ReactDOM.createPortal(
<div style={overlayStyle} onClick={() => { if (!submitting) onClose(); }}>
<div style={modalStyle} onClick={e => e.stopPropagation()}>
{/* Header */}
<div style={headerStyle}>
<span style={{ fontSize: '0.88rem', fontWeight: '700', color: '#F59E0B' }}>
Create FP Workflow
</span>
<button onClick={() => { if (!submitting) onClose(); }} style={{ background: 'none', border: 'none', color: '#64748B', cursor: 'pointer' }}>
<X size={16} />
</button>
</div>
{/* Selected findings summary */}
<div style={sectionStyle}>
<div style={labelStyle}>Selected Findings ({selectedItems.length})</div>
<div style={{ maxHeight: '120px', overflow: 'auto' }}>
{selectedItems.map((item, i) => (
<div key={item.id || i} style={{ display: 'flex', gap: '0.5rem', alignItems: 'baseline', fontSize: '0.75rem', color: '#94A3B8', marginBottom: '0.3rem' }}>
<span style={{ color: '#F59E0B', fontWeight: '600', flexShrink: 0 }}>{item.finding_id}</span>
<span style={{ color: '#CBD5E1', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>{item.finding_title || '—'}</span>
{item.cves_json && (() => {
try {
const cves = JSON.parse(item.cves_json);
return cves.length > 0 ? <span style={{ color: '#64748B', flexShrink: 0 }}>{cves.join(', ')}</span> : null;
} catch { return null; }
})()}
</div>
))}
</div>
</div>
{/* Form fields */}
<div style={sectionStyle}>
{/* Name */}
<label style={{ display: 'block', marginBottom: '0.75rem' }}>
<span style={labelStyle}>Workflow Name <span style={{ color: '#EF4444' }}>*</span></span>
<input
type="text"
value={name}
onChange={e => setName(e.target.value)}
placeholder="FP — CVE-2024-XXXX — Vendor"
disabled={submitting}
maxLength={255}
style={errors.name ? inputErrorStyle : inputStyle}
/>
{errors.name && <div style={errorTextStyle}>{errors.name}</div>}
</label>
{/* Reason */}
<label style={{ display: 'block', marginBottom: '0.75rem' }}>
<span style={labelStyle}>Reason / Justification <span style={{ color: '#EF4444' }}>*</span></span>
<textarea
value={reason}
onChange={e => setReason(e.target.value)}
placeholder="Explain why these findings are false positives..."
disabled={submitting}
style={errors.reason ? textareaErrorStyle : textareaStyle}
/>
{errors.reason && <div style={errorTextStyle}>{errors.reason}</div>}
</label>
{/* Description */}
<label style={{ display: 'block', marginBottom: '0.75rem' }}>
<span style={labelStyle}>Description (optional)</span>
<textarea
value={description}
onChange={e => setDescription(e.target.value)}
placeholder="Additional context or details..."
disabled={submitting}
maxLength={2000}
style={errors.description ? textareaErrorStyle : textareaStyle}
/>
{errors.description && <div style={errorTextStyle}>{errors.description}</div>}
<div style={{ fontSize: '0.62rem', color: '#475569', textAlign: 'right', marginTop: '0.15rem' }}>{description.length}/2000</div>
</label>
{/* Expiration date */}
<label style={{ display: 'block', marginBottom: '0.75rem' }}>
<span style={labelStyle}>Expiration Date <span style={{ color: '#EF4444' }}>*</span></span>
<input
type="date"
value={expirationDate}
onChange={e => setExpirationDate(e.target.value)}
disabled={submitting}
style={errors.expirationDate ? inputErrorStyle : inputStyle}
/>
{errors.expirationDate && <div style={errorTextStyle}>{errors.expirationDate}</div>}
</label>
{/* Scope override toggle */}
<div style={{ marginBottom: '0.25rem' }}>
<span style={labelStyle}>Scope Override Authorization</span>
<div style={{ display: 'flex', gap: '0.5rem' }}>
{['Authorized', 'None'].map(val => {
const active = scopeOverride === val;
return (
<button
key={val}
onClick={() => setScopeOverride(val)}
disabled={submitting}
style={{
flex: 1, padding: '0.35rem',
background: active ? 'rgba(245,158,11,0.12)' : 'transparent',
border: `1px solid ${active ? 'rgba(245,158,11,0.4)' : 'rgba(255,255,255,0.08)'}`,
borderRadius: '0.25rem',
color: active ? '#F59E0B' : '#475569',
fontFamily: 'monospace', fontSize: '0.75rem', fontWeight: '600',
cursor: submitting ? 'not-allowed' : 'pointer',
transition: 'all 0.12s',
}}
>
{val}
</button>
);
})}
</div>
</div>
</div>
{/* File upload */}
<div style={sectionStyle}>
<div style={labelStyle}>Attachments</div>
<div
ref={dropRef}
onDrop={handleDrop}
onDragOver={handleDragOver}
onClick={() => fileInputRef.current?.click()}
style={{
border: '1px dashed rgba(14,165,233,0.25)',
borderRadius: '0.375rem',
padding: '1rem',
textAlign: 'center',
cursor: submitting ? 'not-allowed' : 'pointer',
background: 'rgba(14,165,233,0.03)',
transition: 'border-color 0.15s',
}}
>
<Upload size={20} style={{ color: '#475569', marginBottom: '0.35rem' }} />
<div style={{ fontSize: '0.75rem', color: '#64748B' }}>
Drop files here or click to browse
</div>
<div style={{ fontSize: '0.62rem', color: '#475569', marginTop: '0.2rem' }}>
Max 10 MB per file · PDF, PNG, JPG, DOC, XLSX, CSV, TXT, ZIP
</div>
</div>
<input
ref={fileInputRef}
type="file"
multiple
style={{ display: 'none' }}
onChange={e => { if (e.target.files?.length) addFiles(e.target.files); e.target.value = ''; }}
accept={ALLOWED_EXTENSIONS.join(',')}
/>
{errors.files && <div style={errorTextStyle}>{errors.files}</div>}
{files.length > 0 && (
<div style={{ marginTop: '0.5rem' }}>
{files.map((f, i) => (
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', padding: '0.3rem 0', borderBottom: '1px solid rgba(255,255,255,0.03)' }}>
<FileText size={13} style={{ color: '#64748B', flexShrink: 0 }} />
<span style={{ flex: 1, fontSize: '0.75rem', color: '#CBD5E1', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.name}</span>
<span style={{ fontSize: '0.68rem', color: '#475569', flexShrink: 0 }}>{formatSize(f.size)}</span>
<button
onClick={(e) => { e.stopPropagation(); removeFile(i); }}
disabled={submitting}
style={{ background: 'none', border: 'none', color: '#64748B', cursor: 'pointer', padding: '0.15rem' }}
>
<Trash2 size={12} />
</button>
</div>
))}
</div>
)}
</div>
{/* Footer */}
<div style={footerStyle}>
{submitting && (
<div style={{ flex: 1, display: 'flex', alignItems: 'center', gap: '0.5rem', fontSize: '0.75rem', color: '#F59E0B' }}>
<Loader size={14} style={{ animation: 'spin 1s linear infinite' }} />
<span>{progress.step}</span>
</div>
)}
<button
onClick={() => { if (!submitting) onClose(); }}
disabled={submitting}
style={{
padding: '0.45rem 1rem',
background: 'none',
border: '1px solid rgba(255,255,255,0.08)',
borderRadius: '0.375rem',
color: '#64748B', fontSize: '0.78rem', fontWeight: '600',
cursor: submitting ? 'not-allowed' : 'pointer',
fontFamily: 'monospace',
}}
>
Cancel
</button>
<button
onClick={handleSubmit}
disabled={submitting}
style={{
padding: '0.45rem 1.25rem',
background: submitting ? 'rgba(245,158,11,0.08)' : 'rgba(245,158,11,0.15)',
border: `1px solid ${submitting ? 'rgba(245,158,11,0.15)' : 'rgba(245,158,11,0.4)'}`,
borderRadius: '0.375rem',
color: submitting ? '#92700C' : '#F59E0B',
fontSize: '0.78rem', fontWeight: '700',
cursor: submitting ? 'not-allowed' : 'pointer',
fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: '0.05em',
}}
>
{submitting ? 'Submitting...' : 'Submit'}
</button>
</div>
</div>
</div>,
document.body
);
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Main ReportingPage // Main ReportingPage
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -1718,6 +2297,10 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
const [addPopover, setAddPopover] = useState(null); // { finding, anchorRect } const [addPopover, setAddPopover] = useState(null); // { finding, anchorRect }
const [queueForm, setQueueForm] = useState({ vendor: '', workflowType: 'FP' }); const [queueForm, setQueueForm] = useState({ vendor: '', workflowType: 'FP' });
// FP Workflow modal state
const [fpModalOpen, setFpModalOpen] = useState(false);
const [fpModalItems, setFpModalItems] = useState([]);
// Queue API helpers // Queue API helpers
const fetchQueue = useCallback(async () => { const fetchQueue = useCallback(async () => {
setQueueLoading(true); setQueueLoading(true);
@@ -1732,6 +2315,22 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
} }
}, []); }, []);
// FP Workflow handlers
const handleCreateFpWorkflow = useCallback((selectedIds) => {
const selectedSet = new Set(selectedIds);
const fpItems = filterFpItems(
queueItems.filter(item => selectedSet.has(item.id) && item.status === 'pending')
);
if (fpItems.length > 0) {
setFpModalItems(fpItems);
setFpModalOpen(true);
}
}, [queueItems]);
const handleFpWorkflowSuccess = useCallback(() => {
fetchQueue();
}, [fetchQueue]);
const addToQueue = useCallback(async () => { const addToQueue = useCallback(async () => {
if (!addPopover) return; if (!addPopover) return;
const { finding } = addPopover; const { finding } = addPopover;
@@ -2336,6 +2935,14 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
onDelete={deleteQueueItem} onDelete={deleteQueueItem}
onDeleteMany={deleteQueueItems} onDeleteMany={deleteQueueItems}
onClearCompleted={clearCompleted} onClearCompleted={clearCompleted}
onCreateFpWorkflow={handleCreateFpWorkflow}
canWrite={canWrite}
/>
<FpWorkflowModal
open={fpModalOpen}
onClose={() => setFpModalOpen(false)}
selectedItems={fpModalItems}
onSuccess={handleFpWorkflowSuccess}
/> />
</div> </div>
); );

View File

@@ -72,16 +72,26 @@ export function AuthProvider({ children }) {
setUser(null); setUser(null);
}; };
// Check if user has a specific role // Check if user belongs to one of the specified groups
const hasRole = (...roles) => { const isInGroup = (...groups) => user && groups.includes(user.group);
return user && roles.includes(user.role);
// Check if user can perform write operations (Admin or Standard_User)
const canWrite = () => isInGroup('Admin', 'Standard_User');
// Check if user can delete a resource
// Admin: always true; Standard_User: only if they own the resource; others: false
const canDelete = (resource) => {
if (!user) return false;
if (isInGroup('Admin')) return true;
if (!isInGroup('Standard_User')) return false;
return resource?.created_by === user.id;
}; };
// Check if user can perform write operations (editor or admin) // Check if user can export data
const canWrite = () => hasRole('editor', 'admin'); const canExport = () => isInGroup('Admin', 'Standard_User', 'Leadership');
// Check if user is admin // Check if user is admin
const isAdmin = () => hasRole('admin'); const isAdmin = () => isInGroup('Admin');
const value = { const value = {
user, user,
@@ -90,8 +100,10 @@ export function AuthProvider({ children }) {
login, login,
logout, logout,
checkAuth, checkAuth,
hasRole, isInGroup,
canWrite, canWrite,
canDelete,
canExport,
isAdmin, isAdmin,
isAuthenticated: !!user isAuthenticated: !!user
}; };

View File

@@ -15,6 +15,7 @@
"cors": "^2.8.6", "cors": "^2.8.6",
"dotenv": "^16.6.1", "dotenv": "^16.6.1",
"express": "^5.2.1", "express": "^5.2.1",
"express-rate-limit": "^7.5.0",
"multer": "^2.0.2", "multer": "^2.0.2",
"sqlite3": "^5.1.7" "sqlite3": "^5.1.7"
} }