Compare commits
17 Commits
a2a43a8685
...
feature/cv
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9b36a58959 | ||
|
|
690c30aac0 | ||
|
|
fc68097821 | ||
|
|
d9fdaf5cbb | ||
|
|
cb3da6980c | ||
|
|
ccc3576706 | ||
|
|
5405926550 | ||
|
|
328e48ea8c | ||
|
|
41f9c35586 | ||
|
|
729dada05c | ||
|
|
5d417edf82 | ||
|
|
03e60c9daf | ||
|
|
ee9403ab47 | ||
|
|
3d04cd393f | ||
|
|
382bc81a7e | ||
|
|
7302ece958 | ||
|
|
80d80c099f |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -51,3 +51,6 @@ backend/add_vendor_to_documents.js
|
|||||||
backend/fix_multivendor_constraint.js
|
backend/fix_multivendor_constraint.js
|
||||||
backend/server.js-backup
|
backend/server.js-backup
|
||||||
backend/setup.js-backup
|
backend/setup.js-backup
|
||||||
|
|
||||||
|
# Kiro implementation summary (internal only)
|
||||||
|
docs/kiro-implementation-summary.md
|
||||||
|
|||||||
1
.kiro/specs/batch-finding-disposition/.config.kiro
Normal file
1
.kiro/specs/batch-finding-disposition/.config.kiro
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"specId": "9f5c16d4-43ea-4d7a-beb1-9329d79a5acc", "workflowType": "requirements-first", "specType": "feature"}
|
||||||
331
.kiro/specs/batch-finding-disposition/design.md
Normal file
331
.kiro/specs/batch-finding-disposition/design.md
Normal file
@@ -0,0 +1,331 @@
|
|||||||
|
# Design Document: Batch Finding Disposition
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This feature adds multi-select capability to the Vulnerability Triage page's findings table, enabling engineers to select multiple findings and add them all to the Ivanti Queue in a single operation. The current flow requires clicking each finding individually, configuring a popover, and submitting one at a time — this design replaces that with a batch selection toolbar and a bulk-add API endpoint while preserving the existing single-select popover for one-off additions.
|
||||||
|
|
||||||
|
The design touches three layers:
|
||||||
|
1. A new `POST /api/ivanti/todo-queue/batch` backend endpoint that accepts an array of findings in a single transactional insert
|
||||||
|
2. Frontend multi-select state management (selection set, shift-click range select, select-all)
|
||||||
|
3. A sticky Selection Toolbar component with workflow type toggles, vendor input, and batch submit
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
The feature extends the existing Ivanti Queue subsystem without introducing new services or tables. The `ivanti_todo_queue` table schema is unchanged — batch add simply inserts multiple rows in a single SQLite transaction.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
subgraph Frontend ["Frontend (ReportingPage.js)"]
|
||||||
|
CB[Row Checkboxes] --> SS[Selection State<br/>Set of finding IDs]
|
||||||
|
SS --> ST[Selection Toolbar]
|
||||||
|
ST -->|"Add to Queue"| BA[Batch API Call]
|
||||||
|
CB -->|"No selection + click"| PO[AddToQueuePopover<br/>existing single-add]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Backend ["Backend (ivantiTodoQueue.js)"]
|
||||||
|
BA -->|"POST /batch"| BH[Batch Handler]
|
||||||
|
BH -->|"BEGIN TRANSACTION"| DB[(ivanti_todo_queue)]
|
||||||
|
BH -->|"logAudit()"| AL[(audit_logs)]
|
||||||
|
PO -->|"POST /"| SH[Single Handler<br/>existing]
|
||||||
|
SH --> DB
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key Design Decisions
|
||||||
|
|
||||||
|
1. **No new database table or migration** — batch insert reuses the existing `ivanti_todo_queue` schema. Each finding becomes its own row, identical to what the single-add endpoint creates.
|
||||||
|
|
||||||
|
2. **SQLite transaction for atomicity** — all findings in a batch are inserted inside `db.serialize()` with `BEGIN TRANSACTION` / `COMMIT`. If any insert fails, the entire batch is rolled back. This satisfies the all-or-nothing requirement (Req 3.7, 3.8, 3.11).
|
||||||
|
|
||||||
|
3. **Selection state lives in the VulnerabilityTriagePage component** — a `Set<string>` of finding IDs managed via `useState`. This keeps the selection co-located with the existing `findings`, `sorted`, `filtered`, and `queueItems` state. No new context or global store needed.
|
||||||
|
|
||||||
|
4. **Dual-mode checkbox behavior** — when no findings are selected, clicking a checkbox opens the existing `AddToQueuePopover` (preserving the single-select flow per Req 5). Once one or more findings are selected, subsequent checkbox clicks toggle selection instead. This is the simplest UX that satisfies both Req 1 and Req 5.
|
||||||
|
|
||||||
|
5. **Selection Toolbar as inline sticky bar** — rendered between the table header controls and the `<table>` element, using `position: sticky` to stay visible during scroll. This avoids portal complexity and keeps the toolbar visually anchored to the table.
|
||||||
|
|
||||||
|
6. **200-item batch limit** — prevents oversized payloads and keeps SQLite transaction time reasonable. The findings table typically has 200-800 rows, so this covers most realistic batch sizes.
|
||||||
|
|
||||||
|
## Components and Interfaces
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
|
||||||
|
#### `POST /api/ivanti/todo-queue/batch`
|
||||||
|
|
||||||
|
Added to the existing `createIvantiTodoQueueRouter` factory in `backend/routes/ivantiTodoQueue.js`.
|
||||||
|
|
||||||
|
**Request body:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"findings": [
|
||||||
|
{
|
||||||
|
"finding_id": "FID-12345",
|
||||||
|
"finding_title": "OpenSSL vulnerability",
|
||||||
|
"cves": ["CVE-2024-0001"],
|
||||||
|
"ip_address": "10.0.1.50"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"workflow_type": "FP",
|
||||||
|
"vendor": "Juniper"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Validation rules:**
|
||||||
|
- `findings` — array, 1–200 items
|
||||||
|
- Each item: `finding_id` required, non-empty string; `finding_title`, `cves`, `ip_address` optional
|
||||||
|
- `workflow_type` — must be `FP`, `Archer`, or `CARD`
|
||||||
|
- `vendor` — required non-empty string (≤200 chars) for FP/Archer; ignored for CARD
|
||||||
|
- If any finding fails validation, reject entire batch with 400
|
||||||
|
|
||||||
|
**Auth:** `requireAuth(db)`, `requireGroup('Admin', 'Standard_User')`
|
||||||
|
|
||||||
|
**Response (201):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"id": 42,
|
||||||
|
"user_id": 1,
|
||||||
|
"finding_id": "FID-12345",
|
||||||
|
"finding_title": "OpenSSL vulnerability",
|
||||||
|
"cves_json": "[\"CVE-2024-0001\"]",
|
||||||
|
"ip_address": "10.0.1.50",
|
||||||
|
"vendor": "Juniper",
|
||||||
|
"workflow_type": "FP",
|
||||||
|
"status": "pending",
|
||||||
|
"created_at": "2025-01-15 12:00:00",
|
||||||
|
"updated_at": "2025-01-15 12:00:00",
|
||||||
|
"cves": ["CVE-2024-0001"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Error responses:**
|
||||||
|
- `400` — validation failure (descriptive message)
|
||||||
|
- `401` — not authenticated
|
||||||
|
- `403` — insufficient permissions
|
||||||
|
- `500` — database transaction failure (all inserts rolled back)
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
|
#### Selection State (in VulnerabilityTriagePage)
|
||||||
|
|
||||||
|
New state variables added to the main component:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const [selectedIds, setSelectedIds] = useState(new Set()); // Set<string> of finding IDs
|
||||||
|
const [lastClickedId, setLastClickedId] = useState(null); // for shift-click range select
|
||||||
|
const [batchSubmitting, setBatchSubmitting] = useState(false); // loading state
|
||||||
|
const [batchError, setBatchError] = useState(null); // error message from failed batch
|
||||||
|
const [batchWorkflowType, setBatchWorkflowType] = useState('FP');
|
||||||
|
const [batchVendor, setBatchVendor] = useState('');
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Checkbox Click Logic
|
||||||
|
|
||||||
|
```
|
||||||
|
onClick(finding, event):
|
||||||
|
if finding is already queued → return (no-op)
|
||||||
|
if selectedIds.size === 0 AND not shift-click:
|
||||||
|
→ open AddToQueuePopover (existing single-select flow)
|
||||||
|
else:
|
||||||
|
if shift-click AND lastClickedId exists:
|
||||||
|
→ range-select all visible findings between lastClickedId and finding.id
|
||||||
|
else:
|
||||||
|
→ toggle finding.id in selectedIds
|
||||||
|
set lastClickedId = finding.id
|
||||||
|
```
|
||||||
|
|
||||||
|
#### SelectionToolbar Component
|
||||||
|
|
||||||
|
Rendered inline above the table when `selectedIds.size > 0`. Contains:
|
||||||
|
- Selected count badge
|
||||||
|
- "Clear Selection" button
|
||||||
|
- Workflow type toggle buttons (FP / Archer / CARD) with existing color scheme
|
||||||
|
- Vendor text input (hidden when CARD selected)
|
||||||
|
- "Add to Queue" submit button (disabled until valid)
|
||||||
|
- Error message display area
|
||||||
|
|
||||||
|
#### Selection Persistence Across Filters
|
||||||
|
|
||||||
|
When `columnFilters`, `actionFilter`, or `excFilter` change, the selection set is pruned to only include IDs that remain in the `filtered` array. This is done via a `useEffect` that intersects `selectedIds` with the current filtered finding IDs.
|
||||||
|
|
||||||
|
#### Select All / Deselect All
|
||||||
|
|
||||||
|
The checkbox column header renders a "Select All" control when `selectedIds.size > 0` or as a standard header otherwise. Clicking it:
|
||||||
|
- If not all visible non-queued findings are selected → selects all visible non-queued findings
|
||||||
|
- If all are already selected → deselects all
|
||||||
|
|
||||||
|
## Data Models
|
||||||
|
|
||||||
|
### Database Schema (unchanged)
|
||||||
|
|
||||||
|
The `ivanti_todo_queue` table is reused as-is:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE ivanti_todo_queue (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
finding_id TEXT NOT NULL,
|
||||||
|
finding_title TEXT,
|
||||||
|
cves_json TEXT,
|
||||||
|
ip_address TEXT,
|
||||||
|
vendor TEXT NOT NULL,
|
||||||
|
workflow_type TEXT NOT NULL CHECK(workflow_type IN ('FP', 'Archer', 'CARD')),
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending', 'complete')),
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
Each batch-added finding creates one row, identical to single-add. The `vendor` and `workflow_type` are shared across all findings in a batch (set once in the toolbar).
|
||||||
|
|
||||||
|
### API Request Schema
|
||||||
|
|
||||||
|
```
|
||||||
|
BatchAddRequest {
|
||||||
|
findings: Array<{
|
||||||
|
finding_id: string (required, non-empty, trimmed)
|
||||||
|
finding_title: string | null (max 500 chars)
|
||||||
|
cves: string[] | null
|
||||||
|
ip_address: string | null (max 64 chars)
|
||||||
|
}> (1–200 items)
|
||||||
|
workflow_type: "FP" | "Archer" | "CARD"
|
||||||
|
vendor: string (required for FP/Archer, ≤200 chars; empty/absent for CARD)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend State Shape
|
||||||
|
|
||||||
|
```
|
||||||
|
Selection State:
|
||||||
|
selectedIds: Set<string> — finding IDs currently selected
|
||||||
|
lastClickedId: string | null — last checkbox clicked (for shift-range)
|
||||||
|
batchSubmitting: boolean — true while POST /batch in flight
|
||||||
|
batchError: string | null — error message from last failed batch
|
||||||
|
batchWorkflowType: "FP" | "Archer" | "CARD"
|
||||||
|
batchVendor: string
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
## Correctness Properties
|
||||||
|
|
||||||
|
*A property is a characteristic or behavior that should hold true across all valid executions of a system — essentially, a formal statement about what the system should do. Properties serve as the bridge between human-readable specifications and machine-verifiable correctness guarantees.*
|
||||||
|
|
||||||
|
### Property 1: Selection pruning preserves only visible findings
|
||||||
|
|
||||||
|
*For any* set of selected finding IDs and any set of currently visible (filtered) finding IDs, pruning the selection after a filter change should produce exactly the intersection of the two sets — every ID in the result is both selected and visible, and no visible selected ID is lost.
|
||||||
|
|
||||||
|
**Validates: Requirements 1.4**
|
||||||
|
|
||||||
|
### Property 2: Select-all produces the complete visible non-queued set
|
||||||
|
|
||||||
|
*For any* list of visible findings and any set of queued finding IDs, the select-all operation should produce a set containing exactly the IDs of visible findings that are not in the queued set — no queued findings included, no non-queued visible findings omitted.
|
||||||
|
|
||||||
|
**Validates: Requirements 1.6**
|
||||||
|
|
||||||
|
### Property 3: Submit button enabled state matches validation rule
|
||||||
|
|
||||||
|
*For any* workflow type (FP, Archer, CARD) and any vendor string, the "Add to Queue" button should be enabled if and only if the workflow type is CARD, or the vendor string trimmed is non-empty. No other combination should enable the button.
|
||||||
|
|
||||||
|
**Validates: Requirements 2.7**
|
||||||
|
|
||||||
|
### Property 4: Batch size validation accepts only 1–200 items
|
||||||
|
|
||||||
|
*For any* integer N representing the number of findings in a batch request, the endpoint should accept the request (assuming all other fields are valid) if and only if 1 ≤ N ≤ 200. Arrays of size 0 or greater than 200 should be rejected with a 400 response.
|
||||||
|
|
||||||
|
**Validates: Requirements 3.2**
|
||||||
|
|
||||||
|
### Property 5: Vendor validation is conditional on workflow type
|
||||||
|
|
||||||
|
*For any* workflow type and any vendor string, the batch endpoint should require a non-empty vendor of 200 characters or fewer when workflow_type is FP or Archer, and should accept any vendor value (including empty or absent) when workflow_type is CARD.
|
||||||
|
|
||||||
|
**Validates: Requirements 3.5, 3.6**
|
||||||
|
|
||||||
|
### Property 6: One invalid finding rejects the entire batch
|
||||||
|
|
||||||
|
*For any* valid batch of findings, if exactly one finding is replaced with an invalid finding (empty finding_id, missing finding_id, or non-string finding_id) at any position in the array, the entire batch should be rejected with a 400 response and zero rows should be inserted.
|
||||||
|
|
||||||
|
**Validates: Requirements 3.3, 3.8**
|
||||||
|
|
||||||
|
### Property 7: Successful batch response matches request
|
||||||
|
|
||||||
|
*For any* valid batch request of N findings, the 201 response should contain exactly N items, each with a unique numeric `id`, and the set of `finding_id` values in the response should equal the set of `finding_id` values in the request.
|
||||||
|
|
||||||
|
**Validates: Requirements 3.9**
|
||||||
|
|
||||||
|
### Property 8: Shift-click range select covers exactly the between range
|
||||||
|
|
||||||
|
*For any* sorted list of visible findings, any last-clicked index, and any current-click index, the shift-click range select should produce a set containing exactly the non-queued findings between those two indices (inclusive), regardless of which index is larger.
|
||||||
|
|
||||||
|
**Validates: Requirements 6.1**
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
### Backend Errors
|
||||||
|
|
||||||
|
| Scenario | Response | Behavior |
|
||||||
|
|----------|----------|----------|
|
||||||
|
| Empty findings array or > 200 items | 400 | `{ error: "findings array must contain 1-200 items." }` |
|
||||||
|
| Any finding missing/empty finding_id | 400 | `{ error: "Each finding must have a non-empty finding_id string." }` |
|
||||||
|
| Invalid workflow_type | 400 | `{ error: "workflow_type must be FP, Archer, or CARD." }` |
|
||||||
|
| Missing vendor for FP/Archer | 400 | `{ error: "vendor is required for FP and Archer workflows." }` |
|
||||||
|
| Vendor exceeds 200 chars | 400 | `{ error: "vendor must be under 200 chars." }` |
|
||||||
|
| Not authenticated | 401 | Standard auth middleware response |
|
||||||
|
| Insufficient permissions (Read_Only) | 403 | Standard group middleware response |
|
||||||
|
| SQLite transaction failure | 500 | Transaction rolled back, `{ error: "Internal server error." }` |
|
||||||
|
|
||||||
|
### Frontend Errors
|
||||||
|
|
||||||
|
| Scenario | Behavior |
|
||||||
|
|----------|----------|
|
||||||
|
| Batch POST returns 4xx/5xx | Display error message in Selection Toolbar, keep selection intact |
|
||||||
|
| Network failure during batch POST | Display "Network error — please try again" in toolbar, keep selection |
|
||||||
|
| Batch POST timeout | Same as network failure handling |
|
||||||
|
|
||||||
|
### Edge Cases
|
||||||
|
|
||||||
|
- **Duplicate finding_ids in batch**: Allowed — the same finding could appear on multiple hosts. The backend does not enforce uniqueness on finding_id within a batch.
|
||||||
|
- **Finding already in queue**: The frontend prevents selecting already-queued findings (checkbox is disabled), so duplicates should not reach the API. No server-side duplicate check is added to keep the endpoint simple.
|
||||||
|
- **Concurrent batch submissions**: The SQLite transaction serializes writes. If two users submit overlapping batches, both succeed independently (each user has their own queue scoped by user_id).
|
||||||
|
- **Selection of 0 findings**: The "Add to Queue" button is only rendered when selectedIds.size > 0, so this state cannot be reached through the UI. The backend still validates for it.
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
|
||||||
|
### Unit Tests
|
||||||
|
|
||||||
|
Focus on specific examples and edge cases:
|
||||||
|
|
||||||
|
- **Backend validation**: Test each validation rule with concrete valid/invalid inputs (empty array, 201 items, missing finding_id, invalid workflow_type, vendor edge cases)
|
||||||
|
- **Transaction rollback**: Mock a database error mid-insert, verify no rows are committed
|
||||||
|
- **Frontend checkbox dual-mode**: Test that clicking with empty selection opens popover, clicking with existing selection toggles selection
|
||||||
|
- **Toolbar visibility**: Test toolbar appears/disappears based on selection state
|
||||||
|
- **Clear selection**: Test that clear button empties selection
|
||||||
|
- **Escape key**: Test that Escape clears selection
|
||||||
|
- **Select-all toggle**: Test select-all and deselect-all behavior
|
||||||
|
- **Queue panel update**: Test that successful batch updates queueItems state
|
||||||
|
|
||||||
|
### Property-Based Tests
|
||||||
|
|
||||||
|
Using [fast-check](https://github.com/dubzzz/fast-check) for JavaScript property-based testing.
|
||||||
|
|
||||||
|
Each property test runs a minimum of 100 iterations with randomly generated inputs. Tests are tagged with their corresponding design property.
|
||||||
|
|
||||||
|
| Property | What's Generated | What's Verified |
|
||||||
|
|----------|-----------------|-----------------|
|
||||||
|
| Property 1: Selection pruning | Random sets of selected IDs and filtered IDs | Result = intersection of both sets |
|
||||||
|
| Property 2: Select-all | Random finding lists and queued ID sets | Result = visible IDs minus queued IDs |
|
||||||
|
| Property 3: Submit enabled | Random workflow types and vendor strings | Enabled iff CARD or non-empty vendor |
|
||||||
|
| Property 4: Batch size | Random integers 0–300 | Accepted iff 1 ≤ N ≤ 200 |
|
||||||
|
| Property 5: Vendor validation | Random workflow types and vendor strings (0–300 chars) | Conditional acceptance rule |
|
||||||
|
| Property 6: Invalid finding rejection | Valid batches with one injected invalid item | Entire batch rejected, 0 rows inserted |
|
||||||
|
| Property 7: Response shape | Valid batches of 1–50 findings | Response count matches, IDs match |
|
||||||
|
| Property 8: Range select | Random sorted lists and two index positions | Correct range of non-queued findings |
|
||||||
|
|
||||||
|
### Integration Tests
|
||||||
|
|
||||||
|
- End-to-end batch submission: POST valid batch, verify rows in database, verify response shape
|
||||||
|
- Auth enforcement: Verify 401 for unauthenticated, 403 for Read_Only users
|
||||||
|
- Transaction atomicity: Verify rollback on database error
|
||||||
|
- Frontend → Backend: Mock API, verify correct request payload from toolbar submit
|
||||||
97
.kiro/specs/batch-finding-disposition/requirements.md
Normal file
97
.kiro/specs/batch-finding-disposition/requirements.md
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
# Requirements Document
|
||||||
|
|
||||||
|
## Introduction
|
||||||
|
|
||||||
|
The Batch Finding Disposition feature adds multi-select capability to the Vulnerability Triage page's findings table, allowing engineers to select multiple findings at once and add them all to the Ivanti Queue with a shared workflow type and vendor in a single operation. Currently, each finding must be individually clicked, configured via a popover, and submitted — a repetitive process that slows down triage when working through many findings. This feature replaces that one-at-a-time flow with a batch selection toolbar and a bulk-add API endpoint.
|
||||||
|
|
||||||
|
## Glossary
|
||||||
|
|
||||||
|
- **Findings_Table**: The sortable, filterable table of Ivanti host findings rendered in the VulnerabilityTriagePage component (`ReportingPage.js`), where each row represents one finding.
|
||||||
|
- **Selection_Toolbar**: A floating toolbar that appears above the Findings_Table when one or more findings are selected via their row checkboxes, displaying the count of selected findings and batch action controls.
|
||||||
|
- **Batch_Add_Panel**: The inline panel within the Selection_Toolbar that provides workflow type selection (FP, Archer, CARD), an optional vendor input, and a submit button for adding all selected findings to the queue in one operation.
|
||||||
|
- **Todo_Queue_API**: The backend Express router at `/api/ivanti/todo-queue` that manages CRUD operations on the `ivanti_todo_queue` table.
|
||||||
|
- **Queue_Panel**: The existing right-side slide-out panel (`QueuePanel` component) that displays the user's current queue items grouped by vendor.
|
||||||
|
- **Workflow_Type**: One of three disposition categories: FP (false positive), Archer (risk acceptance), or CARD (remediation card). Each finding added to the queue is assigned exactly one Workflow_Type.
|
||||||
|
- **Finding**: A single Ivanti host vulnerability record containing an ID, title, CVEs, IP address, severity, and other metadata.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### Requirement 1: Multi-Select Findings via Row Checkboxes
|
||||||
|
|
||||||
|
**User Story:** As an engineer, I want to select multiple findings using checkboxes so that I can batch-process them instead of handling each one individually.
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. THE Findings_Table SHALL render a checkbox in the first column of each finding row that is not already in the queue.
|
||||||
|
2. WHEN a user clicks a finding row's checkbox, THE Findings_Table SHALL toggle that Finding's selected state without opening the AddToQueuePopover.
|
||||||
|
3. WHEN one or more findings are selected, THE Findings_Table SHALL visually distinguish selected rows from unselected rows using a highlighted background.
|
||||||
|
4. THE Findings_Table SHALL maintain the selected findings set across sort and filter changes, removing only findings that are no longer visible after filtering.
|
||||||
|
5. WHEN a finding is already in the queue, THE Findings_Table SHALL display that row's checkbox as checked and disabled, preventing re-selection.
|
||||||
|
6. WHILE findings are selected, THE Findings_Table SHALL display a "Select All (visible)" control in the checkbox column header that selects all visible, non-queued findings.
|
||||||
|
7. WHEN the "Select All" control is clicked while all visible non-queued findings are already selected, THE Findings_Table SHALL deselect all findings.
|
||||||
|
|
||||||
|
### Requirement 2: Selection Toolbar with Batch Actions
|
||||||
|
|
||||||
|
**User Story:** As an engineer, I want a toolbar that appears when I have findings selected so that I can see how many are selected and take batch actions on them.
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. WHEN one or more findings are selected, THE Selection_Toolbar SHALL appear as a sticky bar above the Findings_Table header row.
|
||||||
|
2. THE Selection_Toolbar SHALL display the count of currently selected findings.
|
||||||
|
3. THE Selection_Toolbar SHALL provide a "Clear Selection" button that deselects all findings and hides the Selection_Toolbar.
|
||||||
|
4. THE Selection_Toolbar SHALL provide workflow type toggle buttons for FP, Archer, and CARD, matching the existing color scheme (FP: amber, Archer: blue, CARD: green).
|
||||||
|
5. WHEN the selected Workflow_Type is FP or Archer, THE Selection_Toolbar SHALL display a vendor text input field.
|
||||||
|
6. WHEN the selected Workflow_Type is CARD, THE Selection_Toolbar SHALL hide the vendor input field and display a "No vendor required" indicator.
|
||||||
|
7. THE Selection_Toolbar SHALL provide an "Add to Queue" submit button that is enabled only when a Workflow_Type is selected and vendor is provided (for FP/Archer) or Workflow_Type is CARD.
|
||||||
|
8. THE Selection_Toolbar SHALL follow the existing dark theme design system (monospace fonts, dark gradient backgrounds, accent-colored borders).
|
||||||
|
|
||||||
|
### Requirement 3: Bulk Add to Queue API Endpoint
|
||||||
|
|
||||||
|
**User Story:** As an engineer, I want the backend to accept multiple findings in a single request so that batch additions are processed efficiently.
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. THE Todo_Queue_API SHALL expose a `POST /api/ivanti/todo-queue/batch` endpoint that accepts an array of finding objects with a shared workflow_type and vendor.
|
||||||
|
2. THE Todo_Queue_API SHALL validate that the findings array contains between 1 and 200 items.
|
||||||
|
3. THE Todo_Queue_API SHALL validate that each finding object contains a non-empty finding_id string.
|
||||||
|
4. THE Todo_Queue_API SHALL validate that workflow_type is one of FP, Archer, or CARD.
|
||||||
|
5. WHEN workflow_type is FP or Archer, THE Todo_Queue_API SHALL validate that vendor is a non-empty string of 200 characters or fewer.
|
||||||
|
6. WHEN workflow_type is CARD, THE Todo_Queue_API SHALL accept an empty or absent vendor field.
|
||||||
|
7. THE Todo_Queue_API SHALL insert all valid findings into the `ivanti_todo_queue` table within a single database transaction.
|
||||||
|
8. IF any finding in the batch fails validation, THEN THE Todo_Queue_API SHALL reject the entire batch and return a 400 response with a descriptive error message.
|
||||||
|
9. THE Todo_Queue_API SHALL return a 201 response containing the array of newly created queue items with their assigned IDs.
|
||||||
|
10. THE Todo_Queue_API SHALL require authentication and the Admin or Standard_User group.
|
||||||
|
11. IF a database error occurs during the transaction, THEN THE Todo_Queue_API SHALL roll back all inserts and return a 500 response.
|
||||||
|
|
||||||
|
### Requirement 4: Frontend Batch Submission Flow
|
||||||
|
|
||||||
|
**User Story:** As an engineer, I want clicking "Add to Queue" on the toolbar to submit all selected findings at once so that I save time during triage.
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. WHEN the user clicks "Add to Queue" on the Selection_Toolbar, THE Findings_Table SHALL send a single POST request to `POST /api/ivanti/todo-queue/batch` containing all selected findings with the chosen workflow_type and vendor.
|
||||||
|
2. WHILE the batch request is in progress, THE Selection_Toolbar SHALL disable the "Add to Queue" button and display a loading indicator.
|
||||||
|
3. WHEN the batch request succeeds, THE Findings_Table SHALL add all returned queue items to the local queue state, clear the selection, and hide the Selection_Toolbar.
|
||||||
|
4. WHEN the batch request succeeds, THE Findings_Table SHALL update each newly queued finding's row checkbox to show the checked-and-disabled (already queued) state.
|
||||||
|
5. IF the batch request fails, THEN THE Selection_Toolbar SHALL display the error message returned by the API and keep the current selection intact.
|
||||||
|
6. WHEN the batch request succeeds and the Queue_Panel is open, THE Queue_Panel SHALL reflect the newly added items immediately without requiring a manual refresh.
|
||||||
|
|
||||||
|
### Requirement 5: Preserve Single-Select Popover Flow
|
||||||
|
|
||||||
|
**User Story:** As an engineer, I want to still be able to add a single finding to the queue quickly without going through the batch flow, so that simple one-off additions remain fast.
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. WHEN no findings are currently selected and a user clicks a finding row's checkbox, THE Findings_Table SHALL open the existing AddToQueuePopover for that single finding.
|
||||||
|
2. WHEN one or more findings are already selected and a user clicks another finding row's checkbox, THE Findings_Table SHALL add that finding to the selection set instead of opening the AddToQueuePopover.
|
||||||
|
3. THE AddToQueuePopover SHALL continue to use the existing single-item `POST /api/ivanti/todo-queue` endpoint for individual additions.
|
||||||
|
|
||||||
|
### Requirement 6: Keyboard Accessibility for Multi-Select
|
||||||
|
|
||||||
|
**User Story:** As an engineer, I want to use keyboard shortcuts to speed up multi-select so that I can triage even faster.
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. WHEN a user holds Shift and clicks a finding row's checkbox, THE Findings_Table SHALL select all visible findings between the last clicked checkbox and the current checkbox (range select).
|
||||||
|
2. THE Selection_Toolbar SHALL be navigable via keyboard Tab order, with all interactive elements (workflow buttons, vendor input, submit button) reachable by Tab key.
|
||||||
|
3. WHEN the Escape key is pressed while the Selection_Toolbar is visible, THE Findings_Table SHALL clear the selection and hide the Selection_Toolbar.
|
||||||
116
.kiro/specs/batch-finding-disposition/tasks.md
Normal file
116
.kiro/specs/batch-finding-disposition/tasks.md
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
# Implementation Plan: Batch Finding Disposition
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Add multi-select capability to the Vulnerability Triage findings table with a batch-add-to-queue API endpoint. The backend gets a new `POST /api/ivanti/todo-queue/batch` route in `ivantiTodoQueue.js`. The frontend gets selection state, checkbox dual-mode logic, a SelectionToolbar component, shift-click range select, select-all, and Escape-to-clear — all within `ReportingPage.js`.
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
|
||||||
|
- [x] 1. Add `POST /api/ivanti/todo-queue/batch` endpoint
|
||||||
|
- [x] 1.1 Add batch route handler to `backend/routes/ivantiTodoQueue.js`
|
||||||
|
- Add `POST /batch` route inside `createIvantiTodoQueueRouter`, before the `POST /` route
|
||||||
|
- Apply `requireAuth(db)` and `requireGroup('Admin', 'Standard_User')` middleware
|
||||||
|
- Validate request body: `findings` array (1–200 items), each with non-empty `finding_id` string
|
||||||
|
- Validate `workflow_type` is one of `FP`, `Archer`, `CARD`
|
||||||
|
- Validate `vendor`: required non-empty string ≤200 chars for FP/Archer; ignored for CARD
|
||||||
|
- If any validation fails, return 400 with descriptive error message and reject entire batch
|
||||||
|
- _Requirements: 3.1, 3.2, 3.3, 3.4, 3.5, 3.6, 3.8, 3.10_
|
||||||
|
- [x] 1.2 Implement transactional batch insert with SQLite
|
||||||
|
- Use `db.serialize()` with `BEGIN TRANSACTION` / `COMMIT` to insert all findings atomically
|
||||||
|
- For each finding: insert row into `ivanti_todo_queue` with `user_id`, `finding_id`, `finding_title`, `cves_json`, `ip_address`, `vendor`, `workflow_type`
|
||||||
|
- On success: fetch all inserted rows, parse `cves_json` back to arrays, return 201 with `{ items: [...] }`
|
||||||
|
- On any DB error: `ROLLBACK` the transaction and return 500
|
||||||
|
- _Requirements: 3.7, 3.8, 3.9, 3.11_
|
||||||
|
- [x] 1.3 Add audit logging for batch additions
|
||||||
|
- After successful commit, call `logAudit(db, { ... })` with action `'batch_add_to_queue'`, entityType `'ivanti_todo_queue'`, and details including the count and workflow_type
|
||||||
|
- Import `logAudit` from `../helpers/auditLog`
|
||||||
|
- _Requirements: 3.7_
|
||||||
|
|
||||||
|
- [x] 2. Checkpoint — Verify backend endpoint
|
||||||
|
- Ensure the batch endpoint is syntactically correct and the route file has no errors. Ask the user if questions arise.
|
||||||
|
|
||||||
|
- [x] 3. Add multi-select state and checkbox dual-mode logic to `ReportingPage.js`
|
||||||
|
- [x] 3.1 Add selection state variables to `VulnerabilityTriagePage`
|
||||||
|
- Add `selectedIds` (`new Set()`), `lastClickedId` (null), `batchSubmitting` (false), `batchError` (null), `batchWorkflowType` ('FP'), `batchVendor` ('') as new `useState` hooks
|
||||||
|
- _Requirements: 1.1, 2.1_
|
||||||
|
- [x] 3.2 Implement checkbox dual-mode click handler
|
||||||
|
- Replace the existing `<td>` onClick in the checkbox cell with new logic:
|
||||||
|
- If finding is already queued → no-op (existing behavior)
|
||||||
|
- If `selectedIds.size === 0` AND not shift-click → open `AddToQueuePopover` (preserves single-select flow)
|
||||||
|
- If shift-click AND `lastClickedId` exists → range-select all visible non-queued findings between `lastClickedId` and current finding in the `sorted` array
|
||||||
|
- Otherwise → toggle finding.id in `selectedIds`
|
||||||
|
- Always update `lastClickedId` when toggling selection
|
||||||
|
- _Requirements: 1.1, 1.2, 5.1, 5.2, 6.1_
|
||||||
|
- [x] 3.3 Add visual highlighting for selected rows
|
||||||
|
- When a finding's ID is in `selectedIds`, apply a highlighted background (e.g. `rgba(14,165,233,0.12)`) to the row
|
||||||
|
- Override the existing alternating row background and hover for selected rows
|
||||||
|
- _Requirements: 1.3_
|
||||||
|
- [x] 3.4 Disable checkbox for already-queued findings
|
||||||
|
- Keep existing behavior: queued findings show checked + disabled checkbox, preventing re-selection
|
||||||
|
- Ensure queued findings are excluded from shift-click range select and select-all
|
||||||
|
- _Requirements: 1.5_
|
||||||
|
|
||||||
|
- [x] 4. Implement Select All / Deselect All in column header
|
||||||
|
- Modify the checkbox column `<th>` to render a clickable "Select All" checkbox when `selectedIds.size > 0` or when the user interacts with it
|
||||||
|
- Click behavior: if not all visible non-queued findings are selected → select all visible non-queued; if all are selected → deselect all
|
||||||
|
- _Requirements: 1.6, 1.7_
|
||||||
|
|
||||||
|
- [x] 5. Add selection pruning on filter changes
|
||||||
|
- Add a `useEffect` that watches `filtered` (the filtered findings array) and prunes `selectedIds` to only include IDs still present in the filtered set
|
||||||
|
- This ensures selection stays consistent when `columnFilters`, `actionFilter`, or `excFilter` change
|
||||||
|
- _Requirements: 1.4_
|
||||||
|
|
||||||
|
- [x] 6. Implement SelectionToolbar component
|
||||||
|
- [x] 6.1 Create the `SelectionToolbar` inline component in `ReportingPage.js`
|
||||||
|
- Render between the panel header controls and the `<table>` element, only when `selectedIds.size > 0`
|
||||||
|
- Use `position: sticky` with appropriate `top` value to stay visible during scroll
|
||||||
|
- Follow the dark theme design system: monospace fonts, dark gradient background, accent-colored borders
|
||||||
|
- _Requirements: 2.1, 2.8_
|
||||||
|
- [x] 6.2 Add toolbar controls: count badge, Clear Selection, workflow toggles, vendor input, submit button
|
||||||
|
- Display selected count badge (e.g. "12 selected")
|
||||||
|
- "Clear Selection" button that empties `selectedIds` and hides toolbar
|
||||||
|
- Workflow type toggle buttons (FP / Archer / CARD) using existing color scheme: FP = amber (`#F59E0B`), Archer = blue (`#0EA5E9`), CARD = green (`#10B981`)
|
||||||
|
- Vendor text input (hidden when CARD is selected, show "No vendor required" indicator for CARD)
|
||||||
|
- "Add to Queue" submit button — enabled only when workflow_type is CARD, or vendor is non-empty
|
||||||
|
- _Requirements: 2.2, 2.3, 2.4, 2.5, 2.6, 2.7_
|
||||||
|
|
||||||
|
- [x] 7. Implement batch submission flow
|
||||||
|
- [x] 7.1 Add `submitBatch` async function to `VulnerabilityTriagePage`
|
||||||
|
- Build request payload from `selectedIds` (map each ID to its finding object from `sorted`/`filtered` for `finding_id`, `finding_title`, `cves`, `ip_address`), plus `batchWorkflowType` and `batchVendor`
|
||||||
|
- POST to `${API_BASE}/ivanti/todo-queue/batch` with `credentials: 'include'`
|
||||||
|
- Set `batchSubmitting = true` before request, `false` after
|
||||||
|
- _Requirements: 4.1, 4.2_
|
||||||
|
- [x] 7.2 Handle batch success response
|
||||||
|
- On 201: merge returned items into `queueItems` state (sorted by vendor then id, matching existing pattern)
|
||||||
|
- Clear `selectedIds`, reset `batchWorkflowType` to 'FP', reset `batchVendor` to '', clear `batchError`
|
||||||
|
- The newly queued findings will automatically show as checked+disabled via the existing `isQueued()` helper
|
||||||
|
- _Requirements: 4.3, 4.4, 4.6_
|
||||||
|
- [x] 7.3 Handle batch error response
|
||||||
|
- On 4xx/5xx: parse error message from response JSON, set `batchError` to display in toolbar
|
||||||
|
- On network failure: set `batchError` to "Network error — please try again"
|
||||||
|
- Keep selection intact on error so user can retry
|
||||||
|
- _Requirements: 4.5_
|
||||||
|
|
||||||
|
- [x] 8. Add Escape key handler to clear selection
|
||||||
|
- Add a `useEffect` with a `keydown` listener for Escape that clears `selectedIds` when the SelectionToolbar is visible (i.e. `selectedIds.size > 0`)
|
||||||
|
- Ensure it doesn't conflict with the existing Escape handler on `AddToQueuePopover`
|
||||||
|
- _Requirements: 6.3_
|
||||||
|
|
||||||
|
- [x] 9. Ensure keyboard Tab accessibility for SelectionToolbar
|
||||||
|
- Verify all interactive elements in the toolbar (workflow buttons, vendor input, submit button, clear button) are focusable via Tab key
|
||||||
|
- Use native `<button>` and `<input>` elements (which are inherently tabbable) rather than `<div>` with onClick
|
||||||
|
- _Requirements: 6.2_
|
||||||
|
|
||||||
|
- [x] 10. Final checkpoint — Full integration verification
|
||||||
|
- Ensure all files have no syntax errors or diagnostic issues
|
||||||
|
- Verify the checkbox dual-mode logic: no selection → popover, existing selection → toggle
|
||||||
|
- Verify the SelectionToolbar renders/hides correctly based on selection state
|
||||||
|
- Verify batch submit wires through to the backend endpoint and updates queue state
|
||||||
|
- Ensure all tests pass, ask the user if questions arise.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- No new database migration needed — batch insert reuses the existing `ivanti_todo_queue` schema
|
||||||
|
- The batch endpoint must be registered before `POST /` in the router to avoid Express route conflicts
|
||||||
|
- All testing is done on the dev server after push — no local test tasks included
|
||||||
|
- Each task references specific acceptance criteria from the requirements document for traceability
|
||||||
1
.kiro/specs/cve-tooltip-hover/.config.kiro
Normal file
1
.kiro/specs/cve-tooltip-hover/.config.kiro
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"specId": "b8855eb4-3949-426e-86ac-36fe069a6bb1", "workflowType": "requirements-first", "specType": "feature"}
|
||||||
229
.kiro/specs/cve-tooltip-hover/design.md
Normal file
229
.kiro/specs/cve-tooltip-hover/design.md
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
# Design Document: CVE Tooltip Hover
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This feature adds a hover tooltip to CVE badges in the Reporting Page findings table. When a user pauses their cursor over a CVE identifier badge, the system fetches a brief description and severity from the backend and displays it in a styled floating tooltip. Responses are cached in-memory to avoid redundant API calls, and a 300ms hover delay prevents tooltip flicker during fast mouse movement.
|
||||||
|
|
||||||
|
The implementation spans two layers:
|
||||||
|
1. A new lightweight backend endpoint (`/api/cves/:cveId/tooltip`) that queries the existing `cves` SQLite table and returns a trimmed response.
|
||||||
|
2. A frontend `CveTooltip` component rendered via a React portal, with an in-memory cache (React ref), hover delay timer, and viewport-aware positioning.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant User
|
||||||
|
participant CVEBadge as CVE Badge (ReportingPage)
|
||||||
|
participant Tooltip as CveTooltip Component
|
||||||
|
participant Cache as Tooltip Cache (useRef)
|
||||||
|
participant API as /api/cves/:cveId/tooltip
|
||||||
|
participant DB as SQLite (cves table)
|
||||||
|
|
||||||
|
User->>CVEBadge: mouseenter
|
||||||
|
CVEBadge->>Tooltip: start 300ms delay timer
|
||||||
|
Note over Tooltip: If mouseout before 300ms, cancel
|
||||||
|
|
||||||
|
alt Cache hit
|
||||||
|
Tooltip->>Cache: lookup(cveId)
|
||||||
|
Cache-->>Tooltip: cached data
|
||||||
|
Tooltip->>User: show tooltip (or skip if exists:false)
|
||||||
|
else Cache miss
|
||||||
|
Tooltip->>API: GET /api/cves/:cveId/tooltip
|
||||||
|
API->>DB: SELECT cve_id, description, severity FROM cves WHERE cve_id = ?
|
||||||
|
DB-->>API: row or null
|
||||||
|
API-->>Tooltip: { exists, cve_id, description, severity }
|
||||||
|
Tooltip->>Cache: store response
|
||||||
|
Tooltip->>User: show tooltip (or skip if exists:false)
|
||||||
|
end
|
||||||
|
|
||||||
|
User->>CVEBadge: mouseleave
|
||||||
|
CVEBadge->>Tooltip: hide + clear timer
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key Design Decisions
|
||||||
|
|
||||||
|
1. **Inline endpoint in server.js** — The tooltip endpoint is a single GET route on the existing `/api/cves` path prefix. It follows the pattern of other simple CVE endpoints already defined inline in `server.js` (e.g., `/api/cves/check/:cveId`, `/api/cves/:cveId/vendors`). No separate route module needed.
|
||||||
|
|
||||||
|
2. **React portal for tooltip rendering** — The tooltip is rendered via `ReactDOM.createPortal` to `document.body`, avoiding overflow/clipping issues from the table's scroll container. The ReportingPage already imports `ReactDOM` for other portal usage.
|
||||||
|
|
||||||
|
3. **useRef for cache instead of useState** — The cache is a plain `Map` stored in a `useRef`. This avoids re-renders when cache entries are added and persists across renders without triggering updates. The cache is cleared when the findings data is re-synced.
|
||||||
|
|
||||||
|
4. **Single shared tooltip instance** — Only one tooltip is visible at a time. The parent component tracks which CVE badge is hovered and passes the active CVE ID + badge position to the tooltip component.
|
||||||
|
|
||||||
|
## Components and Interfaces
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
|
||||||
|
#### `GET /api/cves/:cveId/tooltip`
|
||||||
|
|
||||||
|
Added inline in `server.js` alongside existing CVE endpoints.
|
||||||
|
|
||||||
|
- **Auth**: `requireAuth(db)` — session cookie required
|
||||||
|
- **Params**: `:cveId` — validated against `CVE_ID_PATTERN` (`/^CVE-\d{4}-\d{4,}$/`)
|
||||||
|
- **Query**: `SELECT cve_id, description, severity FROM cves WHERE cve_id = ? LIMIT 1`
|
||||||
|
- **Response (found)**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"exists": true,
|
||||||
|
"cve_id": "CVE-2024-12345",
|
||||||
|
"description": "A vulnerability in...",
|
||||||
|
"severity": "High"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- **Response (not found)**:
|
||||||
|
```json
|
||||||
|
{ "exists": false }
|
||||||
|
```
|
||||||
|
- **Description truncation**: If `description.length > 300`, return `description.substring(0, 300) + '…'`
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
|
#### `CveTooltip` Component (new file: `frontend/src/components/CveTooltip.js`)
|
||||||
|
|
||||||
|
A portal-rendered tooltip that receives positioning data and CVE info.
|
||||||
|
|
||||||
|
**Props:**
|
||||||
|
| Prop | Type | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| `cveId` | `string \| null` | The CVE ID to display. `null` hides the tooltip. |
|
||||||
|
| `anchorRect` | `DOMRect \| null` | Bounding rect of the hovered badge for positioning. |
|
||||||
|
| `cache` | `React.MutableRefObject<Map>` | Shared cache ref from parent. |
|
||||||
|
|
||||||
|
**Internal state:**
|
||||||
|
- `data` — fetched tooltip payload (`{ exists, cve_id, description, severity }` or `null`)
|
||||||
|
- `loading` — boolean, true while fetch is in-flight
|
||||||
|
|
||||||
|
**Behavior:**
|
||||||
|
1. When `cveId` changes to a non-null value, check `cache.current` for the CVE ID.
|
||||||
|
2. If cached and `exists: false`, render nothing.
|
||||||
|
3. If cached and `exists: true`, display immediately.
|
||||||
|
4. If not cached, set `loading = true`, fetch from API, store result in cache, set `loading = false`.
|
||||||
|
5. Position the tooltip above the badge by default. If the tooltip would overflow the top of the viewport, position it below instead.
|
||||||
|
6. Render via `ReactDOM.createPortal` to `document.body`.
|
||||||
|
|
||||||
|
#### ReportingPage Integration
|
||||||
|
|
||||||
|
Modifications to the existing `renderCell` function for the `'cves'` case:
|
||||||
|
|
||||||
|
- Add `onMouseEnter` / `onMouseLeave` handlers to each CVE badge `<span>`.
|
||||||
|
- `onMouseEnter`: Start a 300ms `setTimeout`. On fire, set active CVE ID + badge `getBoundingClientRect()` into state.
|
||||||
|
- `onMouseLeave`: Clear the timeout. Set active CVE ID to `null`.
|
||||||
|
- Render a single `<CveTooltip>` instance at the bottom of the component, passing the active CVE ID, anchor rect, and cache ref.
|
||||||
|
- On data sync (when findings are refreshed), call `cache.current.clear()`.
|
||||||
|
|
||||||
|
## Data Models
|
||||||
|
|
||||||
|
### Existing: `cves` Table (SQLite)
|
||||||
|
|
||||||
|
The tooltip endpoint queries the existing table. No schema changes required.
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE cves (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
cve_id TEXT NOT NULL,
|
||||||
|
vendor TEXT NOT NULL,
|
||||||
|
severity TEXT CHECK(severity IN ('Critical', 'High', 'Medium', 'Low')),
|
||||||
|
description TEXT,
|
||||||
|
published_date TEXT,
|
||||||
|
status TEXT DEFAULT 'Open',
|
||||||
|
created_by INTEGER,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(cve_id, vendor)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
The query uses `LIMIT 1` since a CVE may have multiple vendor rows — the description and severity from any row suffice for the tooltip blurb.
|
||||||
|
|
||||||
|
### Frontend Cache Structure
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// cache.current is a Map<string, object>
|
||||||
|
// Key: CVE ID string (e.g. "CVE-2024-12345")
|
||||||
|
// Value: API response object
|
||||||
|
// { exists: false }
|
||||||
|
// OR
|
||||||
|
// { exists: true, cve_id: string, description: string, severity: string }
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
## Correctness Properties
|
||||||
|
|
||||||
|
*A property is a characteristic or behavior that should hold true across all valid executions of a system — essentially, a formal statement about what the system should do. Properties serve as the bridge between human-readable specifications and machine-verifiable correctness guarantees.*
|
||||||
|
|
||||||
|
### Property 1: Tooltip endpoint returns correct data for existing CVEs
|
||||||
|
|
||||||
|
*For any* CVE record inserted into the `cves` table with a valid `cve_id`, `description`, and `severity`, a GET request to `/api/cves/:cveId/tooltip` SHALL return `{ exists: true }` with the matching `cve_id` and `severity`, and a `description` that is either the original (if ≤ 300 chars) or truncated to 300 chars + ellipsis.
|
||||||
|
|
||||||
|
**Validates: Requirements 1.1, 1.3, 1.5**
|
||||||
|
|
||||||
|
### Property 2: Description truncation preserves content and enforces length
|
||||||
|
|
||||||
|
*For any* string of arbitrary length, the truncation function SHALL return the original string unchanged if its length is ≤ 300, or return exactly the first 300 characters followed by "…" if its length exceeds 300. In both cases, the output starts with the same characters as the input.
|
||||||
|
|
||||||
|
**Validates: Requirements 1.5**
|
||||||
|
|
||||||
|
### Property 3: Tooltip positioning flips based on available viewport space
|
||||||
|
|
||||||
|
*For any* anchor rectangle position and viewport height, the tooltip SHALL be positioned above the anchor when `anchorRect.top` provides sufficient space for the tooltip height, and below the anchor otherwise. The tooltip SHALL never overflow the top or bottom of the viewport.
|
||||||
|
|
||||||
|
**Validates: Requirements 3.1, 3.2**
|
||||||
|
|
||||||
|
### Property 4: Cache round-trip — fetch then cache-hit avoids network call
|
||||||
|
|
||||||
|
*For any* CVE ID, after the tooltip system fetches data from the API and stores it in the cache, a subsequent tooltip request for the same CVE ID SHALL return the identical cached data object without making an additional network request.
|
||||||
|
|
||||||
|
**Validates: Requirements 4.1, 4.2**
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
| Scenario | Layer | Behavior |
|
||||||
|
|----------|-------|----------|
|
||||||
|
| Invalid CVE ID format in URL param | Backend | Return `400 { error: 'Invalid CVE ID format.' }` |
|
||||||
|
| Database query error | Backend | Log error, return `500 { error: 'Internal server error.' }` |
|
||||||
|
| No session cookie / expired session | Backend | `requireAuth` middleware returns `401` |
|
||||||
|
| Network error during fetch | Frontend | Catch error, hide tooltip (do not cache failures), log to console |
|
||||||
|
| Fetch timeout / slow response | Frontend | Show loading state; if user moves away, cancel via AbortController |
|
||||||
|
| Component unmounts during fetch | Frontend | AbortController signal aborts in-flight request, no state update |
|
||||||
|
|
||||||
|
**Key principle**: Transient errors (network failures, timeouts) are NOT cached. Only successful API responses (both `exists: true` and `exists: false`) are stored in the cache. This ensures a retry on next hover for failed requests.
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
|
||||||
|
### Unit Tests (Example-Based)
|
||||||
|
|
||||||
|
| Test | Validates |
|
||||||
|
|------|-----------|
|
||||||
|
| Endpoint returns `{ exists: false }` for unknown CVE ID | Req 1.2 |
|
||||||
|
| Endpoint returns 401 without session cookie | Req 1.4 |
|
||||||
|
| Endpoint returns 400 for malformed CVE ID (e.g. "not-a-cve") | Req 1.1 (error path) |
|
||||||
|
| Tooltip appears after 300ms hover delay | Req 5.1 |
|
||||||
|
| Tooltip cancelled if mouseout before 300ms | Req 5.2 |
|
||||||
|
| Tooltip hidden on mouseleave | Req 2.2 |
|
||||||
|
| Loading indicator shown while fetching | Req 2.5 |
|
||||||
|
| No tooltip shown when API returns `exists: false` | Req 2.6 |
|
||||||
|
| Severity badge uses correct color per level | Req 2.4 |
|
||||||
|
| Tooltip has max-width of 320px | Req 3.3 |
|
||||||
|
| Tooltip includes directional arrow element | Req 3.5 |
|
||||||
|
| Cache cleared on data sync/refresh | Req 4.4 |
|
||||||
|
| Cached `exists: false` suppresses tooltip and API call | Req 4.3 |
|
||||||
|
|
||||||
|
### Property-Based Tests
|
||||||
|
|
||||||
|
Property-based tests use **fast-check** (JavaScript PBT library, already compatible with the Jest/react-scripts test runner).
|
||||||
|
|
||||||
|
Each property test runs a minimum of **100 iterations**.
|
||||||
|
|
||||||
|
| Property | Tag | Focus |
|
||||||
|
|----------|-----|-------|
|
||||||
|
| Property 1 | `Feature: cve-tooltip-hover, Property 1: Tooltip endpoint returns correct data for existing CVEs` | Generate random CVE records (varying description lengths 0–1000, all 4 severity levels), insert into test DB, call endpoint, verify response shape and truncation |
|
||||||
|
| Property 2 | `Feature: cve-tooltip-hover, Property 2: Description truncation preserves content and enforces length` | Generate random strings of length 0–2000, apply truncation function, verify length invariant and prefix preservation |
|
||||||
|
| Property 3 | `Feature: cve-tooltip-hover, Property 3: Tooltip positioning flips based on available viewport space` | Generate random anchorRect.top (0–2000), tooltip height (50–200), viewport height (400–1200), verify position is within viewport bounds |
|
||||||
|
| Property 4 | `Feature: cve-tooltip-hover, Property 4: Cache round-trip` | Generate random CVE IDs and response payloads, store in cache Map, verify subsequent lookups return identical objects and no fetch is triggered |
|
||||||
|
|
||||||
|
### Test Configuration
|
||||||
|
|
||||||
|
- Test runner: `react-scripts test` (Jest) — already configured in the project
|
||||||
|
- PBT library: `fast-check` — install via `npm install --save-dev fast-check` in the `frontend/` directory
|
||||||
|
- Backend endpoint tests: Use supertest or direct handler invocation with a test SQLite DB
|
||||||
|
- Frontend component tests: React Testing Library with mocked fetch
|
||||||
73
.kiro/specs/cve-tooltip-hover/requirements.md
Normal file
73
.kiro/specs/cve-tooltip-hover/requirements.md
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
# Requirements Document
|
||||||
|
|
||||||
|
## Introduction
|
||||||
|
|
||||||
|
Add a hover tooltip to CVE badges in the Reporting Page (vuln triage view). When a user hovers over a CVE identifier badge in the findings table, the system checks whether that CVE exists in the local SQLite database. If it does, a small tooltip appears showing a brief description/blurb about that CVE. CVEs not present in the database show no tooltip.
|
||||||
|
|
||||||
|
## Glossary
|
||||||
|
|
||||||
|
- **Reporting_Page**: The vulnerability triage view at `frontend/src/components/pages/ReportingPage.js` that displays Ivanti host findings in a sortable, filterable table.
|
||||||
|
- **CVE_Badge**: The styled `<span>` element in the CVEs column of the findings table that displays a CVE identifier (e.g. CVE-2024-12345) with a purple pill/box appearance.
|
||||||
|
- **CVE_Tooltip**: A small floating box that appears on mouse hover over a CVE_Badge, displaying a text blurb about the CVE.
|
||||||
|
- **CVE_Database**: The `cves` table in the SQLite database (`backend/cve_database.db`) that stores CVE records including descriptions, severity, and vendor information.
|
||||||
|
- **Tooltip_Cache**: An in-memory lookup (React state or ref) that stores previously fetched CVE descriptions to avoid redundant API calls during the same session.
|
||||||
|
- **API_Server**: The Express backend at `backend/server.js` that serves CVE data via `/api` endpoints.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### Requirement 1: CVE Tooltip Data Endpoint
|
||||||
|
|
||||||
|
**User Story:** As a frontend component, I want to fetch a brief description for a given CVE ID, so that the tooltip can display relevant information without loading unnecessary data.
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. WHEN a GET request is made to `/api/cves/:cveId/tooltip`, THE API_Server SHALL return a JSON object containing the `cve_id`, `description`, and `severity` fields for the matching CVE record.
|
||||||
|
2. WHEN a GET request is made to `/api/cves/:cveId/tooltip` for a CVE ID that does not exist in the CVE_Database, THE API_Server SHALL return a JSON object with `{ exists: false }` and HTTP status 200.
|
||||||
|
3. WHEN a GET request is made to `/api/cves/:cveId/tooltip` for a CVE ID that exists in the CVE_Database, THE API_Server SHALL return a JSON object with `{ exists: true, cve_id, description, severity }` and HTTP status 200.
|
||||||
|
4. THE API_Server SHALL require a valid session cookie for the `/api/cves/:cveId/tooltip` endpoint.
|
||||||
|
5. WHEN the `description` field exceeds 300 characters, THE API_Server SHALL truncate the description to 300 characters and append an ellipsis ("…").
|
||||||
|
|
||||||
|
### Requirement 2: Tooltip Display on CVE Badge Hover
|
||||||
|
|
||||||
|
**User Story:** As a security analyst triaging findings, I want to see a brief description of a CVE when I hover over its badge in the findings table, so that I can quickly understand the vulnerability without leaving the page.
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. WHEN the user hovers the mouse cursor over a CVE_Badge in the Reporting_Page findings table, THE Reporting_Page SHALL display a CVE_Tooltip near the hovered badge.
|
||||||
|
2. WHEN the user moves the mouse cursor away from the CVE_Badge, THE Reporting_Page SHALL hide the CVE_Tooltip.
|
||||||
|
3. THE CVE_Tooltip SHALL display the CVE description text returned by the API_Server.
|
||||||
|
4. THE CVE_Tooltip SHALL display the severity level of the CVE using the existing severity color scheme (Critical: red, High: amber, Medium: sky blue, Low: emerald).
|
||||||
|
5. WHILE the CVE data is being fetched from the API_Server, THE CVE_Tooltip SHALL display a loading indicator.
|
||||||
|
6. WHEN the API_Server returns `exists: false` for a CVE ID, THE Reporting_Page SHALL not display a CVE_Tooltip for that badge.
|
||||||
|
|
||||||
|
### Requirement 3: Tooltip Positioning and Styling
|
||||||
|
|
||||||
|
**User Story:** As a security analyst, I want the CVE tooltip to be readable and not obstruct other table content, so that I can continue triaging while viewing CVE details.
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. THE CVE_Tooltip SHALL appear above the hovered CVE_Badge by default.
|
||||||
|
2. WHEN there is insufficient viewport space above the CVE_Badge, THE CVE_Tooltip SHALL appear below the badge instead.
|
||||||
|
3. THE CVE_Tooltip SHALL have a maximum width of 320 pixels.
|
||||||
|
4. THE CVE_Tooltip SHALL use the design system dark theme styling: dark background gradient, accent border, monospace font for the CVE ID, and standard font for the description text.
|
||||||
|
5. THE CVE_Tooltip SHALL include a small directional arrow pointing toward the CVE_Badge.
|
||||||
|
|
||||||
|
### Requirement 4: Tooltip Response Caching
|
||||||
|
|
||||||
|
**User Story:** As a security analyst scrolling through many findings, I want CVE tooltip data to load instantly for CVEs I have already hovered over, so that repeated hovers do not cause redundant network requests.
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. WHEN the Reporting_Page fetches tooltip data for a CVE ID, THE Tooltip_Cache SHALL store the response for that CVE ID.
|
||||||
|
2. WHEN the user hovers over a CVE_Badge for a CVE ID that exists in the Tooltip_Cache, THE Reporting_Page SHALL display the cached data without making an API call.
|
||||||
|
3. WHEN the user hovers over a CVE_Badge for a CVE ID where the Tooltip_Cache stores `exists: false`, THE Reporting_Page SHALL not display a tooltip and SHALL not make an API call.
|
||||||
|
4. WHEN the Reporting_Page performs a full data sync (refresh), THE Tooltip_Cache SHALL be cleared.
|
||||||
|
|
||||||
|
### Requirement 5: Hover Delay
|
||||||
|
|
||||||
|
**User Story:** As a security analyst, I want the tooltip to only appear after a brief pause on a CVE badge, so that tooltips do not flash distractingly when I move the mouse across the table quickly.
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. WHEN the user hovers over a CVE_Badge, THE Reporting_Page SHALL wait 300 milliseconds before initiating the tooltip display sequence.
|
||||||
|
2. IF the user moves the mouse away from the CVE_Badge before 300 milliseconds have elapsed, THEN THE Reporting_Page SHALL cancel the tooltip display and not make an API call.
|
||||||
107
.kiro/specs/cve-tooltip-hover/tasks.md
Normal file
107
.kiro/specs/cve-tooltip-hover/tasks.md
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
# Implementation Plan: CVE Tooltip Hover
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Implement a hover tooltip for CVE badges in the Reporting Page findings table. The feature spans a backend endpoint (`GET /api/cves/:cveId/tooltip`) and a frontend `CveTooltip` portal component with in-memory caching and 300ms hover delay. Tasks are ordered backend-first, then frontend component, then integration, with property tests alongside each layer.
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
|
||||||
|
- [x] 1. Add backend tooltip endpoint
|
||||||
|
- [x] 1.1 Add `GET /api/cves/:cveId/tooltip` route inline in `backend/server.js`
|
||||||
|
- Place it alongside existing CVE endpoints (after `/api/cves/:cveId/vendors`)
|
||||||
|
- Validate `:cveId` against existing `CVE_ID_PATTERN`; return 400 for invalid format
|
||||||
|
- Query: `SELECT cve_id, description, severity FROM cves WHERE cve_id = ? LIMIT 1`
|
||||||
|
- If no row: return `{ exists: false }` with status 200
|
||||||
|
- If row found: truncate `description` to 300 chars + "…" if needed, return `{ exists: true, cve_id, description, severity }`
|
||||||
|
- Protect with `requireAuth(db)` middleware
|
||||||
|
- _Requirements: 1.1, 1.2, 1.3, 1.4, 1.5_
|
||||||
|
|
||||||
|
- [ ]* 1.2 Write property test for tooltip endpoint data correctness
|
||||||
|
- **Property 1: Tooltip endpoint returns correct data for existing CVEs**
|
||||||
|
- Install `fast-check` as dev dependency in `frontend/` (shared test runner)
|
||||||
|
- Generate random CVE records with description lengths 0–1000 and all 4 severity levels
|
||||||
|
- Verify response shape, truncation at 300 chars, and prefix preservation
|
||||||
|
- **Validates: Requirements 1.1, 1.3, 1.5**
|
||||||
|
|
||||||
|
- [ ]* 1.3 Write property test for description truncation
|
||||||
|
- **Property 2: Description truncation preserves content and enforces length**
|
||||||
|
- Extract truncation logic into a testable pure function
|
||||||
|
- Generate random strings of length 0–2000, verify length invariant and prefix match
|
||||||
|
- **Validates: Requirements 1.5**
|
||||||
|
|
||||||
|
- [x] 2. Checkpoint — Verify backend endpoint
|
||||||
|
- Ensure all tests pass, ask the user if questions arise.
|
||||||
|
|
||||||
|
- [x] 3. Create CveTooltip frontend component
|
||||||
|
- [x] 3.1 Create `frontend/src/components/CveTooltip.js`
|
||||||
|
- Portal-rendered component using `ReactDOM.createPortal` to `document.body`
|
||||||
|
- Props: `cveId` (string|null), `anchorRect` (DOMRect|null), `cache` (useRef Map)
|
||||||
|
- Internal state: `data`, `loading`
|
||||||
|
- On `cveId` change: check cache → if miss, fetch from `/api/cves/:cveId/tooltip` with AbortController
|
||||||
|
- If cached `exists: false` or fetch returns `exists: false`, render nothing
|
||||||
|
- Show loading spinner (Loader from lucide-react) while fetching
|
||||||
|
- Display: CVE ID in monospace, severity badge with design system colors, description text
|
||||||
|
- Max-width 320px, dark theme gradient background, accent border, directional arrow
|
||||||
|
- Position above anchor by default; flip below if insufficient viewport space above
|
||||||
|
- Do not cache transient errors (network failures)
|
||||||
|
- _Requirements: 2.1, 2.2, 2.3, 2.4, 2.5, 2.6, 3.1, 3.2, 3.3, 3.4, 3.5_
|
||||||
|
|
||||||
|
- [ ]* 3.2 Write property test for tooltip positioning logic
|
||||||
|
- **Property 3: Tooltip positioning flips based on available viewport space**
|
||||||
|
- Extract positioning calculation into a pure function
|
||||||
|
- Generate random anchorRect.top (0–2000), tooltip height (50–200), viewport height (400–1200)
|
||||||
|
- Verify tooltip never overflows top or bottom of viewport
|
||||||
|
- **Validates: Requirements 3.1, 3.2**
|
||||||
|
|
||||||
|
- [ ]* 3.3 Write unit tests for CveTooltip component
|
||||||
|
- Test loading state renders spinner
|
||||||
|
- Test `exists: false` renders nothing
|
||||||
|
- Test severity badge uses correct color per level
|
||||||
|
- Test max-width constraint
|
||||||
|
- Test directional arrow element is present
|
||||||
|
- _Requirements: 2.4, 2.5, 2.6, 3.3, 3.5_
|
||||||
|
|
||||||
|
- [x] 4. Checkpoint — Verify CveTooltip component
|
||||||
|
- Ensure all tests pass, ask the user if questions arise.
|
||||||
|
|
||||||
|
- [x] 5. Integrate tooltip into ReportingPage
|
||||||
|
- [x] 5.1 Add hover state and cache ref to ReportingPage
|
||||||
|
- Add state: `tooltipCveId` (string|null), `tooltipAnchorRect` (DOMRect|null)
|
||||||
|
- Add `useRef(new Map())` for tooltip cache
|
||||||
|
- Add `useRef` for hover delay timer
|
||||||
|
- Clear cache when findings data is re-synced (inside existing sync callback)
|
||||||
|
- _Requirements: 4.1, 4.4, 5.1_
|
||||||
|
|
||||||
|
- [x] 5.2 Add mouseenter/mouseleave handlers to CVE badge spans
|
||||||
|
- In the `renderCell` function for the `'cves'` column case, wrap each CVE badge `<span>` with `onMouseEnter` and `onMouseLeave`
|
||||||
|
- `onMouseEnter`: start 300ms setTimeout; on fire, set `tooltipCveId` and `tooltipAnchorRect` from `getBoundingClientRect()`
|
||||||
|
- `onMouseLeave`: clear timeout, set `tooltipCveId` to null
|
||||||
|
- _Requirements: 2.1, 2.2, 5.1, 5.2_
|
||||||
|
|
||||||
|
- [x] 5.3 Render CveTooltip instance in ReportingPage
|
||||||
|
- Add single `<CveTooltip>` at the bottom of the ReportingPage return, passing `tooltipCveId`, `tooltipAnchorRect`, and cache ref
|
||||||
|
- _Requirements: 2.1, 4.2, 4.3_
|
||||||
|
|
||||||
|
- [ ]* 5.4 Write property test for cache round-trip behavior
|
||||||
|
- **Property 4: Cache round-trip — fetch then cache-hit avoids network call**
|
||||||
|
- Generate random CVE IDs and response payloads, store in Map, verify lookups return identical objects
|
||||||
|
- **Validates: Requirements 4.1, 4.2**
|
||||||
|
|
||||||
|
- [ ]* 5.5 Write unit tests for hover delay and cache integration
|
||||||
|
- Test tooltip appears after 300ms delay (use fake timers)
|
||||||
|
- Test tooltip cancelled if mouseout before 300ms
|
||||||
|
- Test cached `exists: false` suppresses tooltip and API call
|
||||||
|
- Test cache cleared on data sync/refresh
|
||||||
|
- _Requirements: 4.3, 4.4, 5.1, 5.2_
|
||||||
|
|
||||||
|
- [x] 6. Final checkpoint — Ensure all tests pass
|
||||||
|
- Ensure all tests pass, ask the user if questions arise.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Tasks marked with `*` are optional and can be skipped for faster MVP
|
||||||
|
- Each task references specific requirements for traceability
|
||||||
|
- Checkpoints ensure incremental validation
|
||||||
|
- Property tests validate universal correctness properties from the design document
|
||||||
|
- Unit tests validate specific examples and edge cases
|
||||||
|
- The project uses plain JavaScript (no TypeScript), fast-check for PBT, and react-scripts test (Jest)
|
||||||
321
.kiro/specs/ivanti-fp-workflow-submission/design.md
Normal file
321
.kiro/specs/ivanti-fp-workflow-submission/design.md
Normal 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
|
||||||
99
.kiro/specs/ivanti-fp-workflow-submission/requirements.md
Normal file
99
.kiro/specs/ivanti-fp-workflow-submission/requirements.md
Normal 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
|
||||||
109
.kiro/specs/ivanti-fp-workflow-submission/tasks.md
Normal file
109
.kiro/specs/ivanti-fp-workflow-submission/tasks.md
Normal 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)
|
||||||
1
.kiro/specs/queue-hostname-ip-display/.config.kiro
Normal file
1
.kiro/specs/queue-hostname-ip-display/.config.kiro
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"specId": "b8855eb4-3949-426e-86ac-36fe069a6bb1", "workflowType": "requirements-first", "specType": "feature"}
|
||||||
175
.kiro/specs/queue-hostname-ip-display/design.md
Normal file
175
.kiro/specs/queue-hostname-ip-display/design.md
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
# Design Document: Queue Hostname & IP Display
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This feature adds hostname tracking to the Ivanti todo queue. Currently the queue stores `ip_address` but not `hostname`. The change spans three layers:
|
||||||
|
|
||||||
|
1. **Database** — A migration adds a `hostname TEXT` column to `ivanti_todo_queue`.
|
||||||
|
2. **Backend API** — The POST (single + batch) endpoints accept and store an optional `hostname` field. The GET endpoint already uses `SELECT *`, so hostname is returned automatically once the column exists.
|
||||||
|
3. **Frontend** — The `addToQueue` and `submitBatch` functions pass `finding.hostName` as `hostname`. The QueuePanel renders hostname and IP address for both CARD and vendor-grouped (FP/Archer) sections.
|
||||||
|
|
||||||
|
The change is additive and backward-compatible. Existing rows get `NULL` for hostname. No existing behavior changes unless both hostname and ip_address are present.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
The data flows through three layers in a straight pipeline:
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
A[Ivanti Finding<br/>hostName, ipAddress] -->|POST /todo-queue| B[Express Route<br/>ivantiTodoQueue.js]
|
||||||
|
B -->|INSERT hostname, ip_address| C[SQLite<br/>ivanti_todo_queue]
|
||||||
|
C -->|SELECT *| B
|
||||||
|
B -->|GET response| D[QueuePanel<br/>ReportingPage.js]
|
||||||
|
```
|
||||||
|
|
||||||
|
No new services, tables, or route modules are introduced. The migration script is a standalone Node.js file following the existing pattern in `backend/migrations/`.
|
||||||
|
|
||||||
|
## Components and Interfaces
|
||||||
|
|
||||||
|
### Migration Script: `backend/migrations/add_todo_queue_hostname.js`
|
||||||
|
|
||||||
|
Follows the exact pattern of `add_todo_queue_ip_address.js`:
|
||||||
|
|
||||||
|
- Opens `cve_database.db` via `sqlite3`
|
||||||
|
- Runs `ALTER TABLE ivanti_todo_queue ADD COLUMN hostname TEXT`
|
||||||
|
- Catches `duplicate column name` error to make it idempotent
|
||||||
|
- Closes the database connection
|
||||||
|
|
||||||
|
### Backend Route: `backend/routes/ivantiTodoQueue.js`
|
||||||
|
|
||||||
|
Changes to two endpoints:
|
||||||
|
|
||||||
|
**POST `/` (single-item)**
|
||||||
|
- Extract `hostname` from `req.body`
|
||||||
|
- Sanitize: if present and a string, trim and slice to 255 chars; otherwise `null`
|
||||||
|
- Add to the INSERT column list and parameter array
|
||||||
|
|
||||||
|
**POST `/batch`**
|
||||||
|
- For each finding in the `findings` array, extract `hostname` from `f.hostname`
|
||||||
|
- Same sanitization as single-item
|
||||||
|
- Add to the per-row INSERT column list and parameter array
|
||||||
|
|
||||||
|
**GET `/`** — No code change needed. `SELECT *` already returns all columns.
|
||||||
|
|
||||||
|
**PUT `/:id`** — No change. Hostname is set at insert time and not editable.
|
||||||
|
|
||||||
|
### Frontend: `ReportingPage.js`
|
||||||
|
|
||||||
|
**`addToQueue` function**
|
||||||
|
- Add `hostname: finding.hostName || null` to the POST body
|
||||||
|
|
||||||
|
**`submitBatch` function**
|
||||||
|
- Add `hostname: f.hostName || null` to each finding object in `findingsPayload`
|
||||||
|
|
||||||
|
**QueuePanel rendering (per item)**
|
||||||
|
|
||||||
|
For CARD items, the content `<div>` currently shows:
|
||||||
|
1. `finding_id`
|
||||||
|
2. `ip_address` (if present)
|
||||||
|
|
||||||
|
New rendering for CARD items:
|
||||||
|
1. `finding_id`
|
||||||
|
2. `hostname` (if present)
|
||||||
|
3. `ip_address` (if present)
|
||||||
|
|
||||||
|
For vendor-grouped items (FP/Archer), the content `<div>` currently shows:
|
||||||
|
1. `finding_id`
|
||||||
|
2. CVE list (if present)
|
||||||
|
|
||||||
|
New rendering for vendor-grouped items:
|
||||||
|
1. `finding_id`
|
||||||
|
2. CVE list (if present)
|
||||||
|
3. `hostname` (if present)
|
||||||
|
4. `ip_address` (if present)
|
||||||
|
|
||||||
|
Both hostname and IP use the same monospace styling at `0.68rem` / `0.62rem` with muted colors consistent with the existing design system.
|
||||||
|
|
||||||
|
## Data Models
|
||||||
|
|
||||||
|
### `ivanti_todo_queue` table (after migration)
|
||||||
|
|
||||||
|
| Column | Type | Nullable | Notes |
|
||||||
|
|--------|------|----------|-------|
|
||||||
|
| id | INTEGER | NO | PRIMARY KEY AUTOINCREMENT |
|
||||||
|
| user_id | INTEGER | NO | FK → users(id) |
|
||||||
|
| finding_id | TEXT | NO | |
|
||||||
|
| finding_title | TEXT | YES | max 500 chars |
|
||||||
|
| cves_json | TEXT | YES | JSON array string |
|
||||||
|
| ip_address | TEXT | YES | max 64 chars |
|
||||||
|
| **hostname** | **TEXT** | **YES** | **max 255 chars (new)** |
|
||||||
|
| vendor | TEXT | NO | |
|
||||||
|
| workflow_type | TEXT | NO | FP, Archer, or CARD |
|
||||||
|
| status | TEXT | NO | pending or complete |
|
||||||
|
| created_at | DATETIME | NO | DEFAULT CURRENT_TIMESTAMP |
|
||||||
|
| updated_at | DATETIME | NO | DEFAULT CURRENT_TIMESTAMP |
|
||||||
|
|
||||||
|
### API Request/Response Changes
|
||||||
|
|
||||||
|
**POST `/api/ivanti/todo-queue` body** — adds optional field:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"finding_id": "...",
|
||||||
|
"finding_title": "...",
|
||||||
|
"cves": [],
|
||||||
|
"ip_address": "...",
|
||||||
|
"hostname": "server01.example.com",
|
||||||
|
"vendor": "...",
|
||||||
|
"workflow_type": "CARD"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**POST `/api/ivanti/todo-queue/batch` body** — adds optional field per finding:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"findings": [
|
||||||
|
{ "finding_id": "...", "ip_address": "...", "hostname": "server01.example.com" }
|
||||||
|
],
|
||||||
|
"workflow_type": "FP",
|
||||||
|
"vendor": "VendorName"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**GET response** — `hostname` field included automatically via `SELECT *`:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"finding_id": "...",
|
||||||
|
"hostname": "server01.example.com",
|
||||||
|
"ip_address": "10.0.0.1",
|
||||||
|
"..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
## Correctness Properties
|
||||||
|
|
||||||
|
*A property is a characteristic or behavior that should hold true across all valid executions of a system — essentially, a formal statement about what the system should do. Properties serve as the bridge between human-readable specifications and machine-verifiable correctness guarantees.*
|
||||||
|
|
||||||
|
### Property 1: Hostname storage round-trip
|
||||||
|
|
||||||
|
*For any* valid hostname string (up to 255 characters), storing it via the queue API (single or batch endpoint) and then retrieving it via GET should return the exact same trimmed string. When the hostname is omitted, null, or empty, the stored and returned value should be null.
|
||||||
|
|
||||||
|
**Validates: Requirements 2.1, 2.2, 2.3, 2.4**
|
||||||
|
|
||||||
|
### Property 2: Hostname display presence
|
||||||
|
|
||||||
|
*For any* queue item with a non-null hostname value, the rendered QueuePanel output should contain the hostname text, regardless of whether the item is a CARD item or a vendor-grouped (FP/Archer) item.
|
||||||
|
|
||||||
|
**Validates: Requirements 4.1, 5.1**
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
| Scenario | Handling |
|
||||||
|
|----------|----------|
|
||||||
|
| Migration run when column already exists | Catch `duplicate column name` SQLite error, log skip message, exit cleanly |
|
||||||
|
| `hostname` field is not a string | Treat as null — store NULL in database |
|
||||||
|
| `hostname` exceeds 255 characters | Truncate to 255 characters via `.slice(0, 255)` |
|
||||||
|
| `hostname` is undefined/null/empty string | Store NULL in database |
|
||||||
|
| GET returns item with null hostname | Frontend conditionally renders — no hostname line shown |
|
||||||
|
| GET returns item with null ip_address and null hostname | CARD: show only finding_id. Vendor: show finding_id + CVEs only |
|
||||||
|
|
||||||
|
No new error codes or HTTP status changes are introduced. The hostname field is optional and its absence is a normal case, not an error.
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
|
||||||
|
Testing is out of scope for this feature. Manual verification will be performed after implementation.
|
||||||
70
.kiro/specs/queue-hostname-ip-display/requirements.md
Normal file
70
.kiro/specs/queue-hostname-ip-display/requirements.md
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
# Requirements Document
|
||||||
|
|
||||||
|
## Introduction
|
||||||
|
|
||||||
|
The Ivanti Queue (todo queue) in the STEAM Security Dashboard currently stores and displays `ip_address` for CARD workflow items but omits hostname entirely. Vendor-grouped sections (FP/Archer) display only `finding_id` and CVEs, hiding the `ip_address` that is already stored. This feature adds a `hostname` column to the database, passes hostname through the backend API, and displays both hostname and IP address across all queue sections (CARD, FP, Archer).
|
||||||
|
|
||||||
|
## Glossary
|
||||||
|
|
||||||
|
- **Queue_Panel**: The slide-out side panel (`QueuePanel` component) that displays the user's staged Ivanti findings grouped by workflow type and vendor.
|
||||||
|
- **Queue_API**: The Express route module (`ivantiTodoQueue.js`) that handles CRUD operations on the `ivanti_todo_queue` table.
|
||||||
|
- **Queue_Table**: The SQLite table `ivanti_todo_queue` that persists per-user queue items.
|
||||||
|
- **CARD_Section**: The top group in the Queue_Panel that displays items with `workflow_type = 'CARD'`.
|
||||||
|
- **Vendor_Section**: Groups in the Queue_Panel for FP and Archer workflow items, organized by vendor name.
|
||||||
|
- **Finding**: An Ivanti host finding record containing fields such as `id`, `title`, `hostName`, `ipAddress`, `cves`, and `severity`.
|
||||||
|
- **Migration_Script**: A standalone Node.js script in `backend/migrations/` that alters the SQLite schema.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### Requirement 1: Add hostname column to the queue database table
|
||||||
|
|
||||||
|
**User Story:** As a developer, I want the queue table to have a `hostname` column, so that hostname data can be persisted alongside each queued finding.
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. THE Migration_Script SHALL add a `hostname` TEXT column to the Queue_Table.
|
||||||
|
2. WHEN the `hostname` column already exists, THE Migration_Script SHALL skip the alteration and log a message indicating the column already exists.
|
||||||
|
3. THE Migration_Script SHALL preserve all existing rows and column data in the Queue_Table.
|
||||||
|
|
||||||
|
### Requirement 2: Accept and store hostname in queue API endpoints
|
||||||
|
|
||||||
|
**User Story:** As a developer, I want the queue API to accept a `hostname` field, so that hostname data is stored when findings are added to the queue.
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. WHEN a POST request is received at the single-item endpoint, THE Queue_API SHALL accept an optional `hostname` string field (max 255 characters) and store it in the Queue_Table.
|
||||||
|
2. WHEN a POST request is received at the batch endpoint, THE Queue_API SHALL accept an optional `hostname` string field on each finding object (max 255 characters) and store it in the Queue_Table.
|
||||||
|
3. WHEN the `hostname` field is omitted or empty, THE Queue_API SHALL store NULL for the `hostname` column.
|
||||||
|
4. WHEN a GET request is received, THE Queue_API SHALL return the `hostname` field for each queue item in the response.
|
||||||
|
|
||||||
|
### Requirement 3: Pass hostname from the frontend to the queue API
|
||||||
|
|
||||||
|
**User Story:** As a developer, I want the frontend to send hostname data when adding findings to the queue, so that hostname is captured from the Ivanti findings data.
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. WHEN a single finding is added to the queue, THE ReportingPage SHALL include the finding's `hostName` value in the `hostname` field of the POST request body.
|
||||||
|
2. WHEN findings are added via batch submission, THE ReportingPage SHALL include each finding's `hostName` value in the `hostname` field of the corresponding finding object in the POST request body.
|
||||||
|
|
||||||
|
### Requirement 4: Display hostname and IP address in the CARD section
|
||||||
|
|
||||||
|
**User Story:** As a security analyst, I want to see both hostname and IP address for CARD items in the queue, so that I can identify the affected host at a glance.
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. WHEN a CARD item has a `hostname` value, THE CARD_Section SHALL display the hostname below the finding ID.
|
||||||
|
2. WHEN a CARD item has an `ip_address` value, THE CARD_Section SHALL display the IP address below the hostname.
|
||||||
|
3. WHEN a CARD item has both `hostname` and `ip_address`, THE CARD_Section SHALL display hostname on one line and IP address on the next line.
|
||||||
|
4. WHEN a CARD item has only `ip_address` and no `hostname`, THE CARD_Section SHALL display the IP address (preserving current behavior).
|
||||||
|
5. WHEN a CARD item has only `hostname` and no `ip_address`, THE CARD_Section SHALL display the hostname.
|
||||||
|
|
||||||
|
### Requirement 5: Display hostname and IP address in vendor sections (FP/Archer)
|
||||||
|
|
||||||
|
**User Story:** As a security analyst, I want to see hostname and IP address for FP and Archer items in the queue, so that I can identify affected hosts without leaving the queue panel.
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. WHEN a vendor-grouped item has a `hostname` value, THE Vendor_Section SHALL display the hostname below the CVE list.
|
||||||
|
2. WHEN a vendor-grouped item has an `ip_address` value, THE Vendor_Section SHALL display the IP address below the hostname (or below the CVE list if no hostname exists).
|
||||||
|
3. WHEN a vendor-grouped item has both `hostname` and `ip_address`, THE Vendor_Section SHALL display hostname on one line and IP address on the next line, both below the CVE list.
|
||||||
|
4. WHEN a vendor-grouped item has neither `hostname` nor `ip_address`, THE Vendor_Section SHALL display only the finding ID and CVE list (preserving current behavior).
|
||||||
56
.kiro/specs/queue-hostname-ip-display/tasks.md
Normal file
56
.kiro/specs/queue-hostname-ip-display/tasks.md
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
# Implementation Plan: Queue Hostname & IP Display
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Add hostname tracking to the Ivanti todo queue across database, backend API, and frontend display layers. All changes are additive and backward-compatible.
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
|
||||||
|
- [x] 1. Create database migration to add hostname column
|
||||||
|
- Create `backend/migrations/add_todo_queue_hostname.js` following the exact pattern of `add_todo_queue_ip_address.js`
|
||||||
|
- Use `ALTER TABLE ivanti_todo_queue ADD COLUMN hostname TEXT`
|
||||||
|
- Handle `duplicate column name` error for idempotency
|
||||||
|
- Log appropriate messages for success and skip scenarios
|
||||||
|
- _Requirements: 1.1, 1.2, 1.3_
|
||||||
|
|
||||||
|
- [x] 2. Update backend API endpoints to accept and store hostname
|
||||||
|
- [x] 2.1 Update POST `/` (single-item) endpoint in `backend/routes/ivantiTodoQueue.js`
|
||||||
|
- Extract `hostname` from `req.body`
|
||||||
|
- Sanitize: if present and a string, trim and slice to 255 chars; otherwise `null`
|
||||||
|
- Add `hostname` to the INSERT column list and parameter array
|
||||||
|
- _Requirements: 2.1, 2.3_
|
||||||
|
|
||||||
|
- [x] 2.2 Update POST `/batch` endpoint in `backend/routes/ivantiTodoQueue.js`
|
||||||
|
- For each finding, extract `hostname` from `f.hostname`
|
||||||
|
- Apply same sanitization as single-item (trim, slice to 255, or null)
|
||||||
|
- Add `hostname` to the per-row INSERT column list and parameter array
|
||||||
|
- _Requirements: 2.2, 2.3_
|
||||||
|
|
||||||
|
- [x] 3. Checkpoint
|
||||||
|
- Ensure all backend changes are consistent, ask the user if questions arise.
|
||||||
|
|
||||||
|
- [x] 4. Update frontend to pass hostname and display it in the queue panel
|
||||||
|
- [x] 4.1 Update `addToQueue` function in `ReportingPage.js`
|
||||||
|
- Add `hostname: finding.hostName || null` to the POST request body
|
||||||
|
- _Requirements: 3.1_
|
||||||
|
|
||||||
|
- [x] 4.2 Update `submitBatch` function in `ReportingPage.js`
|
||||||
|
- Add `hostname: f.hostName || null` to each finding object in the payload
|
||||||
|
- _Requirements: 3.2_
|
||||||
|
|
||||||
|
- [x] 4.3 Update CARD section rendering in QueuePanel (`ReportingPage.js`)
|
||||||
|
- Display `hostname` below finding_id (when present)
|
||||||
|
- Display `ip_address` below hostname (when present)
|
||||||
|
- Handle all combinations: both present, only hostname, only ip_address, neither
|
||||||
|
- Use monospace styling at `0.68rem` consistent with existing ip_address display
|
||||||
|
- _Requirements: 4.1, 4.2, 4.3, 4.4, 4.5_
|
||||||
|
|
||||||
|
- [x] 4.4 Update vendor section (FP/Archer) rendering in QueuePanel (`ReportingPage.js`)
|
||||||
|
- Display `hostname` below the CVE list (when present)
|
||||||
|
- Display `ip_address` below hostname or below CVE list if no hostname
|
||||||
|
- Handle all combinations: both present, only one, neither
|
||||||
|
- Use monospace styling at `0.62rem` / `0.68rem` with muted colors matching existing design
|
||||||
|
- _Requirements: 5.1, 5.2, 5.3, 5.4_
|
||||||
|
|
||||||
|
- [x] 5. Final checkpoint
|
||||||
|
- Ensure all changes are wired together end-to-end, ask the user if questions arise.
|
||||||
99
README.md
99
README.md
@@ -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)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -173,7 +175,7 @@ PORT=3001
|
|||||||
API_HOST=localhost
|
API_HOST=localhost
|
||||||
CORS_ORIGINS=http://YOUR_IP:3000
|
CORS_ORIGINS=http://YOUR_IP:3000
|
||||||
SESSION_SECRET=<generate with: openssl rand -base64 32>
|
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
|
||||||
@@ -191,6 +193,8 @@ 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`.
|
**`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
|
||||||
@@ -860,6 +864,64 @@ Applied to all responses:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 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 are idempotent and 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.
|
||||||
@@ -888,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).
|
||||||
|
|||||||
154
backend/helpers/ivantiApi.js
Normal file
154
backend/helpers/ivantiApi.js
Normal 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 };
|
||||||
57
backend/migrations/add_fp_submissions_table.js
Normal file
57
backend/migrations/add_fp_submissions_table.js
Normal 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!');
|
||||||
|
});
|
||||||
25
backend/migrations/add_todo_queue_hostname.js
Normal file
25
backend/migrations/add_todo_queue_hostname.js
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
// Migration: Add hostname column to ivanti_todo_queue
|
||||||
|
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 add_todo_queue_hostname migration...');
|
||||||
|
|
||||||
|
db.run(
|
||||||
|
'ALTER TABLE ivanti_todo_queue ADD COLUMN hostname TEXT',
|
||||||
|
(err) => {
|
||||||
|
if (err) {
|
||||||
|
// Column may already exist if migration was run before
|
||||||
|
if (err.message.includes('duplicate column name')) {
|
||||||
|
console.log('✓ hostname column already exists, skipping');
|
||||||
|
} else {
|
||||||
|
console.error('Error adding column:', err);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('✓ hostname column added');
|
||||||
|
}
|
||||||
|
db.close(() => console.log('Migration complete!'));
|
||||||
|
}
|
||||||
|
);
|
||||||
@@ -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 { 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;
|
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
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
395
backend/routes/ivantiFpWorkflow.js
Normal file
395
backend/routes/ivantiFpWorkflow.js
Normal 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;
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
// routes/ivantiTodoQueue.js
|
// routes/ivantiTodoQueue.js
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const { requireGroup } = require('../middleware/auth');
|
const { requireGroup } = require('../middleware/auth');
|
||||||
|
const logAudit = require('../helpers/auditLog');
|
||||||
|
|
||||||
const VALID_WORKFLOW_TYPES = ['FP', 'Archer', 'CARD'];
|
const VALID_WORKFLOW_TYPES = ['FP', 'Archer', 'CARD'];
|
||||||
const VALID_STATUSES = ['pending', 'complete'];
|
const VALID_STATUSES = ['pending', 'complete'];
|
||||||
@@ -14,8 +15,16 @@ function isValidVendor(vendor) {
|
|||||||
function createIvantiTodoQueueRouter(db, requireAuth) {
|
function createIvantiTodoQueueRouter(db, requireAuth) {
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
// GET /api/ivanti/todo-queue
|
/**
|
||||||
// Fetch current user's queue items, ordered by vendor then created_at
|
* GET /api/ivanti/todo-queue
|
||||||
|
*
|
||||||
|
* Fetch the current user's queue items, ordered by vendor then created_at.
|
||||||
|
*
|
||||||
|
* @returns {Array<Object>} 200 - Array of queue items, each with:
|
||||||
|
* id, user_id, finding_id, finding_title, cves_json, ip_address,
|
||||||
|
* vendor, workflow_type, status, created_at, updated_at, cves (parsed array)
|
||||||
|
* @returns {Object} 500 - { error: string } on database error
|
||||||
|
*/
|
||||||
router.get('/', requireAuth(db), (req, res) => {
|
router.get('/', requireAuth(db), (req, res) => {
|
||||||
db.all(
|
db.all(
|
||||||
`SELECT * FROM ivanti_todo_queue
|
`SELECT * FROM ivanti_todo_queue
|
||||||
@@ -37,10 +46,174 @@ function createIvantiTodoQueueRouter(db, requireAuth) {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST /api/ivanti/todo-queue
|
/**
|
||||||
// Add a finding to the queue
|
* POST /api/ivanti/todo-queue/batch
|
||||||
|
*
|
||||||
|
* Add multiple findings to the current user's queue in a single transaction.
|
||||||
|
*
|
||||||
|
* @body {Object[]} findings - Required array of 1–200 finding objects
|
||||||
|
* @body {string} findings[].finding_id - Required, non-empty finding identifier
|
||||||
|
* @body {string} [findings[].finding_title] - Optional finding title (max 500 chars)
|
||||||
|
* @body {string[]} [findings[].cves] - Optional array of CVE identifiers
|
||||||
|
* @body {string} [findings[].ip_address] - Optional IP address (max 64 chars)
|
||||||
|
* @body {string} [findings[].hostname] - Optional hostname (max 255 chars)
|
||||||
|
* @body {string} workflow_type - One of 'FP', 'Archer', 'CARD'
|
||||||
|
* @body {string} vendor - Required for FP/Archer (max 200 chars); optional for CARD
|
||||||
|
*
|
||||||
|
* @returns {Object} 201 - { items: Array<Object> } array of created queue items,
|
||||||
|
* each with: id, user_id, finding_id, finding_title, cves_json, ip_address,
|
||||||
|
* vendor, workflow_type, status, created_at, updated_at, cves (parsed array)
|
||||||
|
* @returns {Object} 400 - { error: string } on validation failure
|
||||||
|
* @returns {Object} 500 - { error: string } on database/transaction error (all inserts rolled back)
|
||||||
|
*/
|
||||||
|
router.post('/batch', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
|
||||||
|
const { findings, workflow_type, vendor } = req.body;
|
||||||
|
|
||||||
|
// --- Validation ---
|
||||||
|
if (!Array.isArray(findings) || findings.length < 1 || findings.length > 200) {
|
||||||
|
return res.status(400).json({ error: 'findings array must contain 1-200 items.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < findings.length; i++) {
|
||||||
|
const f = findings[i];
|
||||||
|
if (!f || typeof f.finding_id !== 'string' || f.finding_id.trim().length === 0) {
|
||||||
|
return res.status(400).json({ error: 'Each finding must have a non-empty finding_id string.' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!VALID_WORKFLOW_TYPES.includes(workflow_type)) {
|
||||||
|
return res.status(400).json({ error: 'workflow_type must be FP, Archer, or CARD.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (workflow_type !== 'CARD') {
|
||||||
|
if (!isValidVendor(vendor)) {
|
||||||
|
return res.status(400).json({ error: 'vendor is required for FP and Archer workflows.' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (vendor !== undefined && vendor !== '' && typeof vendor === 'string' && vendor.trim().length > 200) {
|
||||||
|
return res.status(400).json({ error: 'vendor must be under 200 chars.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const vendorVal = workflow_type === 'CARD' ? '' : vendor.trim();
|
||||||
|
const userId = req.user.id;
|
||||||
|
|
||||||
|
// --- Transactional batch insert ---
|
||||||
|
// Prepare all row values upfront
|
||||||
|
const rows = findings.map((f) => {
|
||||||
|
const findingId = f.finding_id.trim();
|
||||||
|
const title = f.finding_title && typeof f.finding_title === 'string'
|
||||||
|
? f.finding_title.slice(0, 500)
|
||||||
|
: null;
|
||||||
|
const cvesJson = Array.isArray(f.cves) ? JSON.stringify(f.cves) : null;
|
||||||
|
const ipVal = f.ip_address && typeof f.ip_address === 'string'
|
||||||
|
? f.ip_address.trim().slice(0, 64)
|
||||||
|
: null;
|
||||||
|
const hostVal = f.hostname && typeof f.hostname === 'string'
|
||||||
|
? f.hostname.trim().slice(0, 255)
|
||||||
|
: null;
|
||||||
|
return [userId, findingId, title, cvesJson, ipVal, hostVal, vendorVal, workflow_type];
|
||||||
|
});
|
||||||
|
|
||||||
|
const insertedIds = [];
|
||||||
|
let insertError = null;
|
||||||
|
let remaining = rows.length;
|
||||||
|
|
||||||
|
db.serialize(() => {
|
||||||
|
db.run('BEGIN TRANSACTION');
|
||||||
|
|
||||||
|
rows.forEach((params) => {
|
||||||
|
db.run(
|
||||||
|
`INSERT INTO ivanti_todo_queue
|
||||||
|
(user_id, finding_id, finding_title, cves_json, ip_address, hostname, vendor, workflow_type)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
params,
|
||||||
|
function (err) {
|
||||||
|
if (err && !insertError) {
|
||||||
|
insertError = err;
|
||||||
|
} else if (!err) {
|
||||||
|
insertedIds.push(this.lastID);
|
||||||
|
}
|
||||||
|
remaining--;
|
||||||
|
|
||||||
|
// After all insert callbacks have fired, commit or rollback
|
||||||
|
if (remaining === 0) {
|
||||||
|
if (insertError) {
|
||||||
|
db.run('ROLLBACK', () => {
|
||||||
|
console.error('Batch insert error:', insertError);
|
||||||
|
return res.status(500).json({ error: 'Internal server error.' });
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
db.run('COMMIT', (commitErr) => {
|
||||||
|
if (commitErr) {
|
||||||
|
console.error('Batch commit error:', commitErr);
|
||||||
|
db.run('ROLLBACK', () => {});
|
||||||
|
return res.status(500).json({ error: 'Internal server error.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch all inserted rows
|
||||||
|
const placeholders = insertedIds.map(() => '?').join(',');
|
||||||
|
db.all(
|
||||||
|
`SELECT * FROM ivanti_todo_queue WHERE id IN (${placeholders})`,
|
||||||
|
insertedIds,
|
||||||
|
(fetchErr, fetchedRows) => {
|
||||||
|
if (fetchErr) {
|
||||||
|
console.error('Error fetching inserted batch rows:', fetchErr);
|
||||||
|
return res.status(500).json({ error: 'Internal server error.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = (fetchedRows || []).map((r) => ({
|
||||||
|
...r,
|
||||||
|
cves: r.cves_json ? JSON.parse(r.cves_json) : [],
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Audit log (fire-and-forget)
|
||||||
|
logAudit(db, {
|
||||||
|
userId: req.user.id,
|
||||||
|
username: req.user.username,
|
||||||
|
action: 'batch_add_to_queue',
|
||||||
|
entityType: 'ivanti_todo_queue',
|
||||||
|
entityId: null,
|
||||||
|
details: {
|
||||||
|
count: insertedIds.length,
|
||||||
|
workflow_type: workflow_type,
|
||||||
|
finding_ids: findings.map((f) => f.finding_id.trim()),
|
||||||
|
},
|
||||||
|
ipAddress: req.ip,
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.status(201).json({ items });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/ivanti/todo-queue
|
||||||
|
*
|
||||||
|
* Add a single finding to the current user's queue.
|
||||||
|
*
|
||||||
|
* @body {string} finding_id - Required, non-empty finding identifier
|
||||||
|
* @body {string} [finding_title] - Optional finding title (max 500 chars)
|
||||||
|
* @body {string[]} [cves] - Optional array of CVE identifiers
|
||||||
|
* @body {string} [ip_address] - Optional IP address (max 64 chars)
|
||||||
|
* @body {string} [hostname] - Optional hostname (max 255 chars) * @body {string} vendor - Required for FP/Archer (max 200 chars); optional for CARD
|
||||||
|
* @body {string} workflow_type - One of 'FP', 'Archer', 'CARD'
|
||||||
|
*
|
||||||
|
* @returns {Object} 201 - Created queue item with parsed cves array:
|
||||||
|
* id, user_id, finding_id, finding_title, cves_json, ip_address,
|
||||||
|
* vendor, workflow_type, status, created_at, updated_at, cves
|
||||||
|
* @returns {Object} 400 - { error: string } on validation failure
|
||||||
|
* @returns {Object} 500 - { error: string } on database error
|
||||||
|
*/
|
||||||
router.post('/', requireAuth(db), requireGroup('Admin', 'Standard_User'), (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, hostname, 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) {
|
||||||
return res.status(400).json({ error: 'finding_id is required.' });
|
return res.status(400).json({ error: 'finding_id is required.' });
|
||||||
@@ -59,15 +232,16 @@ function createIvantiTodoQueueRouter(db, requireAuth) {
|
|||||||
const vendorVal = workflow_type === 'CARD' ? '' : vendor.trim();
|
const vendorVal = workflow_type === 'CARD' ? '' : vendor.trim();
|
||||||
const cvesJson = Array.isArray(cves) ? JSON.stringify(cves) : null;
|
const cvesJson = Array.isArray(cves) ? JSON.stringify(cves) : null;
|
||||||
const ipVal = ip_address && typeof ip_address === 'string' ? ip_address.trim().slice(0, 64) : null;
|
const ipVal = ip_address && typeof ip_address === 'string' ? ip_address.trim().slice(0, 64) : null;
|
||||||
|
const hostVal = hostname && typeof hostname === 'string' ? hostname.trim().slice(0, 255) : null;
|
||||||
const title = finding_title && typeof finding_title === 'string'
|
const title = finding_title && typeof finding_title === 'string'
|
||||||
? finding_title.slice(0, 500)
|
? finding_title.slice(0, 500)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
db.run(
|
db.run(
|
||||||
`INSERT INTO ivanti_todo_queue
|
`INSERT INTO ivanti_todo_queue
|
||||||
(user_id, finding_id, finding_title, cves_json, ip_address, vendor, workflow_type)
|
(user_id, finding_id, finding_title, cves_json, ip_address, hostname, vendor, workflow_type)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
[req.user.id, finding_id.trim(), title, cvesJson, ipVal, vendorVal, workflow_type],
|
[req.user.id, finding_id.trim(), title, cvesJson, ipVal, hostVal, vendorVal, workflow_type],
|
||||||
function (err) {
|
function (err) {
|
||||||
if (err) {
|
if (err) {
|
||||||
console.error('Error adding to queue:', err);
|
console.error('Error adding to queue:', err);
|
||||||
@@ -87,8 +261,23 @@ function createIvantiTodoQueueRouter(db, requireAuth) {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// PUT /api/ivanti/todo-queue/:id
|
/**
|
||||||
// Update vendor, workflow_type, or status — scoped to current user
|
* PUT /api/ivanti/todo-queue/:id
|
||||||
|
*
|
||||||
|
* Update vendor, workflow_type, or status on a queue item — scoped to current user.
|
||||||
|
*
|
||||||
|
* @param {string} id - Queue item ID (URL parameter)
|
||||||
|
* @body {string} [vendor] - New vendor string (max 200 chars)
|
||||||
|
* @body {string} [workflow_type] - One of 'FP', 'Archer', 'CARD'
|
||||||
|
* @body {string} [status] - One of 'pending', 'complete'
|
||||||
|
*
|
||||||
|
* @returns {Object} 200 - Updated queue item with parsed cves array:
|
||||||
|
* id, user_id, finding_id, finding_title, cves_json, ip_address,
|
||||||
|
* vendor, workflow_type, status, created_at, updated_at, cves
|
||||||
|
* @returns {Object} 400 - { error: string } on validation failure or no fields to update
|
||||||
|
* @returns {Object} 404 - { error: string } if item not found for current user
|
||||||
|
* @returns {Object} 500 - { error: string } on database error
|
||||||
|
*/
|
||||||
router.put('/:id', requireAuth(db), requireGroup('Admin', 'Standard_User'), (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,9 +351,15 @@ function createIvantiTodoQueueRouter(db, requireAuth) {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// DELETE /api/ivanti/todo-queue/completed
|
/**
|
||||||
// Bulk-delete all completed items for the current user
|
* DELETE /api/ivanti/todo-queue/completed
|
||||||
// IMPORTANT: This route must be registered BEFORE DELETE /:id
|
*
|
||||||
|
* Bulk-delete all completed items for the current user.
|
||||||
|
* IMPORTANT: This route must be registered BEFORE DELETE /:id.
|
||||||
|
*
|
||||||
|
* @returns {Object} 200 - { message: string, deleted: number }
|
||||||
|
* @returns {Object} 500 - { error: string } on database error
|
||||||
|
*/
|
||||||
router.delete('/completed', requireAuth(db), requireGroup('Admin', 'Standard_User'), (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'",
|
||||||
@@ -179,8 +374,17 @@ function createIvantiTodoQueueRouter(db, requireAuth) {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// DELETE /api/ivanti/todo-queue/:id
|
/**
|
||||||
// Delete a single item — scoped to current user
|
* DELETE /api/ivanti/todo-queue/:id
|
||||||
|
*
|
||||||
|
* Delete a single queue item — scoped to current user.
|
||||||
|
*
|
||||||
|
* @param {string} id - Queue item ID (URL parameter)
|
||||||
|
*
|
||||||
|
* @returns {Object} 200 - { message: string }
|
||||||
|
* @returns {Object} 404 - { error: string } if item not found for current user
|
||||||
|
* @returns {Object} 500 - { error: string } on database error
|
||||||
|
*/
|
||||||
router.delete('/:id', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
|
router.delete('/:id', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
|
|
||||||
|
|||||||
@@ -4,49 +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 { 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)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ const createIvantiWorkflowsRouter = require('./routes/ivantiWorkflows');
|
|||||||
const createIvantiFindingsRouter = require('./routes/ivantiFindings');
|
const createIvantiFindingsRouter = require('./routes/ivantiFindings');
|
||||||
const createIvantiTodoQueueRouter = require('./routes/ivantiTodoQueue');
|
const createIvantiTodoQueueRouter = require('./routes/ivantiTodoQueue');
|
||||||
const createIvantiArchiveRouter = require('./routes/ivantiArchive');
|
const createIvantiArchiveRouter = require('./routes/ivantiArchive');
|
||||||
|
const createIvantiFpWorkflowRouter = require('./routes/ivantiFpWorkflow');
|
||||||
const createComplianceRouter = require('./routes/compliance');
|
const createComplianceRouter = require('./routes/compliance');
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
@@ -227,6 +228,9 @@ app.use('/api/ivanti/todo-queue', createIvantiTodoQueueRouter(db, requireAuth));
|
|||||||
// Ivanti archive routes — finding archive tracking for severity score drift
|
// Ivanti archive routes — finding archive tracking for severity score drift
|
||||||
app.use('/api/ivanti/archive', createIvantiArchiveRouter(db, requireAuth));
|
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, requireGroup));
|
app.use('/api/compliance', createComplianceRouter(db, upload, requireAuth, requireGroup));
|
||||||
|
|
||||||
@@ -344,6 +348,29 @@ app.get('/api/cves/:cveId/vendors', requireAuth(db), (req, res) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Get tooltip data for a specific CVE (authenticated users)
|
||||||
|
app.get('/api/cves/:cveId/tooltip', requireAuth(db), (req, res) => {
|
||||||
|
const { cveId } = req.params;
|
||||||
|
|
||||||
|
if (!CVE_ID_PATTERN.test(cveId)) {
|
||||||
|
return res.status(400).json({ error: 'Invalid CVE ID format.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
db.get('SELECT cve_id, description, severity FROM cves WHERE cve_id = ? LIMIT 1', [cveId], (err, row) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('Error fetching CVE tooltip:', err);
|
||||||
|
return res.status(500).json({ error: 'Internal server error.' });
|
||||||
|
}
|
||||||
|
if (!row) {
|
||||||
|
return res.json({ exists: false });
|
||||||
|
}
|
||||||
|
let description = row.description || '';
|
||||||
|
if (description.length > 300) {
|
||||||
|
description = description.substring(0, 300) + '\u2026';
|
||||||
|
}
|
||||||
|
res.json({ exists: true, cve_id: row.cve_id, description, severity: row.severity });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// Compliance export — reads from cve_document_status view
|
// Compliance export — reads from cve_document_status view
|
||||||
app.get('/api/cves/compliance', requireAuth(db), (req, res) => {
|
app.get('/api/cves/compliance', requireAuth(db), (req, res) => {
|
||||||
|
|||||||
106
docs/ivanti-api-reference.md
Normal file
106
docs/ivanti-api-reference.md
Normal 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/202)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 33418832,
|
||||||
|
"created": "2026-04-08T18:16:08"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns HTTP 200 or 202 (Accepted — async job creation). Response contains 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) |
|
||||||
243
frontend/src/components/CveTooltip.js
Normal file
243
frontend/src/components/CveTooltip.js
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import ReactDOM from 'react-dom';
|
||||||
|
import { Loader } from 'lucide-react';
|
||||||
|
|
||||||
|
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Severity color mapping — matches DESIGN_SYSTEM.md badge colors
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const SEVERITY_COLORS = {
|
||||||
|
Critical: { border: '#EF4444', bg: 'rgba(239, 68, 68, 0.25)', text: '#FCA5A5', dot: '#EF4444' },
|
||||||
|
High: { border: '#F59E0B', bg: 'rgba(245, 158, 11, 0.25)', text: '#FCD34D', dot: '#F59E0B' },
|
||||||
|
Medium: { border: '#0EA5E9', bg: 'rgba(14, 165, 233, 0.25)', text: '#7DD3FC', dot: '#0EA5E9' },
|
||||||
|
Low: { border: '#10B981', bg: 'rgba(16, 185, 129, 0.25)', text: '#6EE7B7', dot: '#10B981' },
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Pure positioning function — exported for testability
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const TOOLTIP_GAP = 8;
|
||||||
|
const ARROW_SIZE = 6;
|
||||||
|
|
||||||
|
export function calcTooltipPosition(anchorRect, tooltipHeight, viewportHeight) {
|
||||||
|
const spaceAbove = anchorRect.top;
|
||||||
|
const spaceBelow = viewportHeight - anchorRect.bottom;
|
||||||
|
const needed = tooltipHeight + TOOLTIP_GAP + ARROW_SIZE;
|
||||||
|
|
||||||
|
const placeAbove = spaceAbove >= needed || spaceAbove >= spaceBelow;
|
||||||
|
|
||||||
|
let top;
|
||||||
|
if (placeAbove) {
|
||||||
|
top = anchorRect.top - tooltipHeight - TOOLTIP_GAP - ARROW_SIZE;
|
||||||
|
if (top < 0) top = 0;
|
||||||
|
} else {
|
||||||
|
top = anchorRect.bottom + TOOLTIP_GAP + ARROW_SIZE;
|
||||||
|
if (top + tooltipHeight > viewportHeight) top = viewportHeight - tooltipHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
const left = anchorRect.left + anchorRect.width / 2;
|
||||||
|
|
||||||
|
return { top, left, placeAbove };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// CveTooltip component
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
export default function CveTooltip({ cveId, anchorRect, cache }) {
|
||||||
|
const [data, setData] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!cveId) {
|
||||||
|
setData(null);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check cache first
|
||||||
|
if (cache.current.has(cveId)) {
|
||||||
|
setData(cache.current.get(cveId));
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache miss — fetch from API
|
||||||
|
const controller = new AbortController();
|
||||||
|
setLoading(true);
|
||||||
|
setData(null);
|
||||||
|
|
||||||
|
fetch(`${API_BASE}/cves/${encodeURIComponent(cveId)}/tooltip`, {
|
||||||
|
credentials: 'include',
|
||||||
|
signal: controller.signal,
|
||||||
|
})
|
||||||
|
.then((res) => {
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
return res.json();
|
||||||
|
})
|
||||||
|
.then((payload) => {
|
||||||
|
cache.current.set(cveId, payload);
|
||||||
|
setData(payload);
|
||||||
|
setLoading(false);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
if (err.name === 'AbortError') return;
|
||||||
|
// Do not cache transient errors
|
||||||
|
console.error('CveTooltip fetch error:', err);
|
||||||
|
setData(null);
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => controller.abort();
|
||||||
|
}, [cveId, cache]);
|
||||||
|
|
||||||
|
// Nothing to show
|
||||||
|
if (!cveId || !anchorRect) return null;
|
||||||
|
if (!loading && !data) return null;
|
||||||
|
if (data && data.exists === false) return null;
|
||||||
|
|
||||||
|
const severity = data?.severity || '';
|
||||||
|
const colors = SEVERITY_COLORS[severity] || SEVERITY_COLORS.Medium;
|
||||||
|
|
||||||
|
return ReactDOM.createPortal(
|
||||||
|
<TooltipBody
|
||||||
|
data={data}
|
||||||
|
loading={loading}
|
||||||
|
anchorRect={anchorRect}
|
||||||
|
colors={colors}
|
||||||
|
severity={severity}
|
||||||
|
/>,
|
||||||
|
document.body,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// TooltipBody — inner component that measures itself for positioning
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function TooltipBody({ data, loading, anchorRect, colors, severity }) {
|
||||||
|
const tooltipRef = React.useRef(null);
|
||||||
|
const [pos, setPos] = React.useState({ top: 0, left: 0, placeAbove: true });
|
||||||
|
|
||||||
|
React.useLayoutEffect(() => {
|
||||||
|
if (!tooltipRef.current || !anchorRect) return;
|
||||||
|
const rect = tooltipRef.current.getBoundingClientRect();
|
||||||
|
const vp = window.innerHeight;
|
||||||
|
setPos(calcTooltipPosition(anchorRect, rect.height, vp));
|
||||||
|
}, [anchorRect, data, loading]);
|
||||||
|
|
||||||
|
const tooltipStyle = {
|
||||||
|
position: 'fixed',
|
||||||
|
zIndex: 99999,
|
||||||
|
top: pos.top,
|
||||||
|
left: pos.left,
|
||||||
|
transform: 'translateX(-50%)',
|
||||||
|
maxWidth: 320,
|
||||||
|
minWidth: 200,
|
||||||
|
background: 'linear-gradient(135deg, rgba(30, 41, 59, 0.98), rgba(51, 65, 85, 0.95))',
|
||||||
|
border: `1.5px solid ${colors.border}`,
|
||||||
|
borderRadius: '0.5rem',
|
||||||
|
padding: '0.75rem',
|
||||||
|
boxShadow: `0 8px 24px rgba(0, 0, 0, 0.6), 0 0 16px ${colors.border}33`,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
transition: 'opacity 0.15s ease',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Directional arrow
|
||||||
|
const arrowStyle = {
|
||||||
|
position: 'absolute',
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translateX(-50%)',
|
||||||
|
width: 0,
|
||||||
|
height: 0,
|
||||||
|
borderLeft: `${ARROW_SIZE}px solid transparent`,
|
||||||
|
borderRight: `${ARROW_SIZE}px solid transparent`,
|
||||||
|
...(pos.placeAbove
|
||||||
|
? {
|
||||||
|
bottom: -ARROW_SIZE,
|
||||||
|
borderTop: `${ARROW_SIZE}px solid ${colors.border}`,
|
||||||
|
borderBottom: 'none',
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
top: -ARROW_SIZE,
|
||||||
|
borderBottom: `${ARROW_SIZE}px solid ${colors.border}`,
|
||||||
|
borderTop: 'none',
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={tooltipRef} style={tooltipStyle} data-testid="cve-tooltip">
|
||||||
|
{/* Arrow */}
|
||||||
|
<div style={arrowStyle} data-testid="cve-tooltip-arrow" />
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '0.5rem 0' }}>
|
||||||
|
<Loader
|
||||||
|
style={{ width: 18, height: 18, color: '#0EA5E9', animation: 'spin 1s linear infinite' }}
|
||||||
|
data-testid="cve-tooltip-loader"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : data && data.exists ? (
|
||||||
|
<>
|
||||||
|
{/* CVE ID header */}
|
||||||
|
<div style={{
|
||||||
|
fontFamily: "'JetBrains Mono', monospace",
|
||||||
|
fontSize: '0.8rem',
|
||||||
|
fontWeight: 700,
|
||||||
|
color: '#E2E8F0',
|
||||||
|
marginBottom: '0.4rem',
|
||||||
|
letterSpacing: '0.02em',
|
||||||
|
}}>
|
||||||
|
{data.cve_id}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Severity badge */}
|
||||||
|
{severity && (
|
||||||
|
<div style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '0.35rem',
|
||||||
|
padding: '0.2rem 0.5rem',
|
||||||
|
borderRadius: '0.25rem',
|
||||||
|
border: `1.5px solid ${colors.border}`,
|
||||||
|
background: colors.bg,
|
||||||
|
marginBottom: '0.5rem',
|
||||||
|
}}>
|
||||||
|
{/* Glow dot */}
|
||||||
|
<span style={{
|
||||||
|
width: 7,
|
||||||
|
height: 7,
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: colors.dot,
|
||||||
|
boxShadow: `0 0 6px ${colors.dot}`,
|
||||||
|
flexShrink: 0,
|
||||||
|
}} />
|
||||||
|
<span style={{
|
||||||
|
fontFamily: "'JetBrains Mono', monospace",
|
||||||
|
fontSize: '0.65rem',
|
||||||
|
fontWeight: 700,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: '0.04em',
|
||||||
|
color: colors.text,
|
||||||
|
}}>
|
||||||
|
{severity}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
{data.description && (
|
||||||
|
<div style={{
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
lineHeight: 1.5,
|
||||||
|
color: '#CBD5E1',
|
||||||
|
wordBreak: 'break-word',
|
||||||
|
}}>
|
||||||
|
{data.description}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user