Compare commits
9 Commits
7302ece958
...
5405926550
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5405926550 | ||
|
|
328e48ea8c | ||
|
|
41f9c35586 | ||
|
|
729dada05c | ||
|
|
5d417edf82 | ||
|
|
03e60c9daf | ||
|
|
ee9403ab47 | ||
|
|
3d04cd393f | ||
|
|
382bc81a7e |
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)
|
||||
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!');
|
||||
});
|
||||
@@ -3,10 +3,9 @@
|
||||
// Notes are stored separately so they survive cache refreshes.
|
||||
|
||||
const express = require('express');
|
||||
const https = require('https');
|
||||
const { requireGroup } = require('../middleware/auth');
|
||||
const { ivantiPost } = require('../helpers/ivantiApi');
|
||||
|
||||
const IVANTI_URL_BASE = 'https://platform4.risksense.com/api/v1';
|
||||
const SYNC_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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;
|
||||
@@ -4,49 +4,11 @@
|
||||
// Error codes: 401 bad key, 419 insufficient privileges, 429 rate limited
|
||||
|
||||
const express = require('express');
|
||||
const https = require('https');
|
||||
const { requireGroup } = require('../middleware/auth');
|
||||
const { ivantiPost } = require('../helpers/ivantiApi');
|
||||
|
||||
const IVANTI_URL_BASE = 'https://platform4.risksense.com/api/v1';
|
||||
const SYNC_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -23,8 +23,9 @@ const createArcherTicketsRouter = require('./routes/archerTickets');
|
||||
const createIvantiWorkflowsRouter = require('./routes/ivantiWorkflows');
|
||||
const createIvantiFindingsRouter = require('./routes/ivantiFindings');
|
||||
const createIvantiTodoQueueRouter = require('./routes/ivantiTodoQueue');
|
||||
const createIvantiArchiveRouter = require('./routes/ivantiArchive');
|
||||
const createComplianceRouter = require('./routes/compliance');
|
||||
const createIvantiArchiveRouter = require('./routes/ivantiArchive');
|
||||
const createIvantiFpWorkflowRouter = require('./routes/ivantiFpWorkflow');
|
||||
const createComplianceRouter = require('./routes/compliance');
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3001;
|
||||
@@ -227,6 +228,9 @@ app.use('/api/ivanti/todo-queue', createIvantiTodoQueueRouter(db, requireAuth));
|
||||
// Ivanti archive routes — finding archive tracking for severity score drift
|
||||
app.use('/api/ivanti/archive', createIvantiArchiveRouter(db, requireAuth));
|
||||
|
||||
// Ivanti FP workflow routes — submit False Positive workflows to Ivanti API
|
||||
app.use('/api/ivanti/fp-workflow', createIvantiFpWorkflowRouter(db, requireAuth));
|
||||
|
||||
// AEO compliance routes — xlsx upload, non-compliant item tracking, notes
|
||||
app.use('/api/compliance', createComplianceRouter(db, upload, requireAuth, requireGroup));
|
||||
|
||||
|
||||
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)
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 33418832,
|
||||
"created": "2026-04-08T18:16:08"
|
||||
}
|
||||
```
|
||||
|
||||
Returns a numeric `id` (the workflow batch job ID) and `created` timestamp. No `generatedId` or `uuid` in this response.
|
||||
|
||||
### Other Workflow Endpoints (from Swagger)
|
||||
|
||||
These are available but not currently used by the dashboard:
|
||||
|
||||
| Endpoint | Purpose |
|
||||
|----------|---------|
|
||||
| `/workflowBatch/acceptance/request` | Risk acceptance workflow |
|
||||
| `/workflowBatch/remediation/request` | Remediation workflow |
|
||||
| `/workflowBatch/severityChange/request` | Severity change workflow |
|
||||
| `/workflowBatch/{workflowType}/approve` | Approve a workflow (needs `workflowBatchUuid`) |
|
||||
| `/workflowBatch/{workflowType}/reject` | Reject a workflow |
|
||||
| `/workflowBatch/{workflowType}/rework` | Send back for rework |
|
||||
| `/workflowBatch/{workflowType}/update` | Update a workflow |
|
||||
| `/workflowBatch/{workflowType}/{workflowBatchUuid}/map` | Map findings to workflow |
|
||||
| `/workflowBatch/{workflowType}/{workflowBatchUuid}/unmap` | Unmap findings |
|
||||
| `/workflowBatch/{workflowType}/{workflowBatchUuid}/attach` | Attach file to existing workflow |
|
||||
| `/workflowBatch/{workflowType}/{workflowBatchUuid}/detach` | Detach file |
|
||||
| `/workflowBatch/model` | Get model/schema |
|
||||
| `/workflowBatch/filter` | Get available filter fields |
|
||||
| `/workflowBatch/suggest` | Get suggested values for a filter field |
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `IVANTI_API_KEY` | — | Required. API key for authentication |
|
||||
| `IVANTI_CLIENT_ID` | `1550` | Client ID in the Ivanti platform |
|
||||
| `IVANTI_SKIP_TLS` | `false` | Set `true` to skip TLS verification |
|
||||
| `IVANTI_FIRST_NAME` | — | Used for workflow search filter (sync) |
|
||||
| `IVANTI_LAST_NAME` | — | Used for workflow search filter (sync) |
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { RefreshCw, Loader, AlertCircle, PieChart, ChevronUp, ChevronDown, ChevronsUpDown, Settings2, GripVertical, Eye, EyeOff, Filter, Download, RotateCcw, Trash2, X, ListTodo } from 'lucide-react';
|
||||
import { RefreshCw, Loader, AlertCircle, PieChart, ChevronUp, ChevronDown, ChevronsUpDown, Settings2, GripVertical, Eye, EyeOff, Filter, Download, RotateCcw, Trash2, X, ListTodo, Upload, FileText, Check, AlertTriangle } from 'lucide-react';
|
||||
import * as XLSX from 'xlsx';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import IvantiCountsChart from './IvantiCountsChart';
|
||||
@@ -1242,7 +1242,7 @@ function AddToQueuePopover({ finding, anchorRect, queueForm, setQueueForm, onAdd
|
||||
// ---------------------------------------------------------------------------
|
||||
// QueuePanel — fixed slide-out panel showing the user's Ivanti queue
|
||||
// ---------------------------------------------------------------------------
|
||||
function QueuePanel({ open, items, onClose, onUpdate, onDelete, onDeleteMany, onClearCompleted }) {
|
||||
function QueuePanel({ open, items, onClose, onUpdate, onDelete, onDeleteMany, onClearCompleted, onCreateFpWorkflow, canWrite }) {
|
||||
const pendingCount = items.filter((i) => i.status === 'pending').length;
|
||||
const completedCount = items.filter((i) => i.status === 'complete').length;
|
||||
|
||||
@@ -1492,6 +1492,30 @@ function QueuePanel({ open, items, onClose, onUpdate, onDelete, onDeleteMany, on
|
||||
flexShrink: 0,
|
||||
display: 'flex', gap: '0.5rem',
|
||||
}}>
|
||||
{/* Create FP Workflow — visible for editor/admin only */}
|
||||
{canWrite && (() => {
|
||||
const fpEnabled = isCreateFpButtonEnabled(items, selectedIds);
|
||||
return (
|
||||
<button
|
||||
onClick={() => onCreateFpWorkflow([...selectedIds])}
|
||||
disabled={!fpEnabled}
|
||||
title={!fpEnabled ? 'Select pending FP items to create a workflow' : ''}
|
||||
style={{
|
||||
flex: 1, padding: '0.45rem',
|
||||
background: fpEnabled ? 'rgba(245,158,11,0.12)' : 'transparent',
|
||||
border: `1px solid ${fpEnabled ? 'rgba(245,158,11,0.35)' : 'rgba(255,255,255,0.05)'}`,
|
||||
borderRadius: '0.375rem',
|
||||
color: fpEnabled ? '#F59E0B' : '#334155',
|
||||
fontFamily: 'monospace', fontSize: '0.72rem', fontWeight: '600',
|
||||
cursor: fpEnabled ? 'pointer' : 'not-allowed',
|
||||
textTransform: 'uppercase', letterSpacing: '0.05em',
|
||||
transition: 'all 0.12s',
|
||||
}}
|
||||
>
|
||||
Create FP Workflow
|
||||
</button>
|
||||
);
|
||||
})()}
|
||||
{/* Delete selected — only shown when items are selected */}
|
||||
{selectedIds.size > 0 && (
|
||||
<button
|
||||
@@ -1534,6 +1558,561 @@ function QueuePanel({ open, items, onClose, onUpdate, onDelete, onDeleteMany, on
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FP Workflow helpers (pure functions, exported for testing)
|
||||
// ---------------------------------------------------------------------------
|
||||
function isCreateFpButtonEnabled(items, selectedIds) {
|
||||
return items.some(item =>
|
||||
selectedIds.has(item.id) &&
|
||||
item.workflow_type === 'FP' &&
|
||||
item.status === 'pending'
|
||||
);
|
||||
}
|
||||
|
||||
function filterFpItems(items) {
|
||||
return items.filter(item => item.workflow_type === 'FP');
|
||||
}
|
||||
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FpWorkflowModal — submit FP workflows to Ivanti API
|
||||
// ---------------------------------------------------------------------------
|
||||
const ALLOWED_EXTENSIONS = ['.pdf', '.png', '.jpg', '.jpeg', '.gif', '.doc', '.docx', '.xlsx', '.csv', '.txt', '.zip'];
|
||||
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB
|
||||
|
||||
function FpWorkflowModal({ open, onClose, selectedItems, onSuccess }) {
|
||||
const [name, setName] = useState('');
|
||||
const [reason, setReason] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [expirationDate, setExpirationDate] = useState('');
|
||||
const [scopeOverride, setScopeOverride] = useState('Authorized');
|
||||
const [files, setFiles] = useState([]);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [progress, setProgress] = useState({ step: '', current: 0, total: 0 });
|
||||
const [errors, setErrors] = useState({});
|
||||
const [result, setResult] = useState(null);
|
||||
const fileInputRef = useRef(null);
|
||||
const dropRef = useRef(null);
|
||||
|
||||
// Reset form when modal opens
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setName('');
|
||||
setReason('');
|
||||
setDescription('');
|
||||
setExpirationDate('');
|
||||
setScopeOverride('Authorized');
|
||||
setFiles([]);
|
||||
setSubmitting(false);
|
||||
setProgress({ step: '', current: 0, total: 0 });
|
||||
setErrors({});
|
||||
setResult(null);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// Close on Escape
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handler = (e) => { if (e.key === 'Escape' && !submitting) onClose(); };
|
||||
document.addEventListener('keydown', handler);
|
||||
return () => document.removeEventListener('keydown', handler);
|
||||
}, [open, submitting, onClose]);
|
||||
|
||||
const isAllowedExtension = (filename) => {
|
||||
const ext = '.' + filename.split('.').pop().toLowerCase();
|
||||
return ALLOWED_EXTENSIONS.includes(ext);
|
||||
};
|
||||
|
||||
const addFiles = (newFiles) => {
|
||||
const fileErrors = [];
|
||||
const valid = [];
|
||||
Array.from(newFiles).forEach(f => {
|
||||
if (!isAllowedExtension(f.name)) {
|
||||
fileErrors.push(`"${f.name}" — file type not allowed. Accepted: ${ALLOWED_EXTENSIONS.join(', ')}`);
|
||||
} else if (f.size > MAX_FILE_SIZE) {
|
||||
fileErrors.push(`"${f.name}" — exceeds 10 MB limit`);
|
||||
} else {
|
||||
valid.push(f);
|
||||
}
|
||||
});
|
||||
if (fileErrors.length) {
|
||||
setErrors(prev => ({ ...prev, files: fileErrors.join('; ') }));
|
||||
} else {
|
||||
setErrors(prev => { const next = { ...prev }; delete next.files; return next; });
|
||||
}
|
||||
if (valid.length) setFiles(prev => [...prev, ...valid]);
|
||||
};
|
||||
|
||||
const removeFile = (idx) => {
|
||||
setFiles(prev => prev.filter((_, i) => i !== idx));
|
||||
setErrors(prev => { const next = { ...prev }; delete next.files; return next; });
|
||||
};
|
||||
|
||||
const handleDrop = (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (e.dataTransfer.files?.length) addFiles(e.dataTransfer.files);
|
||||
};
|
||||
|
||||
const handleDragOver = (e) => { e.preventDefault(); e.stopPropagation(); };
|
||||
|
||||
const validate = () => {
|
||||
const errs = {};
|
||||
if (!name.trim()) errs.name = 'Workflow name is required';
|
||||
else if (name.trim().length > 255) errs.name = 'Name must be 255 characters or fewer';
|
||||
if (!reason.trim()) errs.reason = 'Reason is required';
|
||||
if (description.length > 2000) errs.description = 'Description must be 2000 characters or fewer';
|
||||
if (!expirationDate) errs.expirationDate = 'Expiration date is required';
|
||||
else {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const exp = new Date(expirationDate + 'T00:00:00');
|
||||
if (exp <= today) errs.expirationDate = 'Expiration date must be in the future';
|
||||
}
|
||||
setErrors(errs);
|
||||
return Object.keys(errs).length === 0;
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!validate()) return;
|
||||
setSubmitting(true);
|
||||
setProgress({ step: 'Creating workflow...', current: 0, total: 0 });
|
||||
setResult(null);
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('name', name.trim());
|
||||
formData.append('reason', reason.trim());
|
||||
if (description.trim()) formData.append('description', description.trim());
|
||||
formData.append('expirationDate', expirationDate);
|
||||
formData.append('scopeOverride', scopeOverride);
|
||||
formData.append('findingIds', JSON.stringify(selectedItems.map(i => i.finding_id)));
|
||||
formData.append('queueItemIds', JSON.stringify(selectedItems.map(i => i.id)));
|
||||
files.forEach(f => formData.append('attachments', f));
|
||||
|
||||
if (files.length > 0) {
|
||||
setProgress({ step: 'Creating workflow and uploading attachments...', current: 0, total: files.length });
|
||||
}
|
||||
|
||||
const res = await fetch(`${API_BASE}/ivanti/fp-workflow`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (res.ok && data.success) {
|
||||
setResult({
|
||||
success: true,
|
||||
workflowBatchId: data.workflowBatchId,
|
||||
generatedId: data.generatedId,
|
||||
attachmentResults: data.attachmentResults || [],
|
||||
status: data.status || 'success',
|
||||
});
|
||||
onSuccess();
|
||||
} else {
|
||||
let errorMsg = data.error || 'Workflow creation failed';
|
||||
if (res.status === 401) errorMsg = 'Ivanti API key is invalid or missing. Contact your administrator.';
|
||||
else if (res.status === 429) errorMsg = 'Ivanti API rate limit reached. Please try again in a few minutes.';
|
||||
|
||||
setResult({
|
||||
success: false,
|
||||
error: errorMsg,
|
||||
workflowBatchId: data.workflowBatchId || null,
|
||||
generatedId: data.generatedId || null,
|
||||
attachmentResults: data.attachmentResults || [],
|
||||
status: data.status || 'failed',
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
setResult({
|
||||
success: false,
|
||||
error: err.message || 'Network error — could not reach the server',
|
||||
status: 'failed',
|
||||
});
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const formatSize = (bytes) => {
|
||||
if (bytes < 1024) return bytes + ' B';
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
||||
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
||||
};
|
||||
|
||||
// ---- Styles ----
|
||||
const overlayStyle = {
|
||||
position: 'fixed', inset: 0, zIndex: 10000,
|
||||
background: 'rgba(0,0,0,0.6)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
};
|
||||
const modalStyle = {
|
||||
width: '640px', maxHeight: '90vh', overflow: 'auto',
|
||||
background: 'linear-gradient(180deg, #0D1929 0%, #080F1C 100%)',
|
||||
border: '1px solid rgba(245,158,11,0.3)',
|
||||
borderRadius: '0.75rem',
|
||||
boxShadow: '0 12px 48px rgba(0,0,0,0.8)',
|
||||
fontFamily: 'monospace',
|
||||
};
|
||||
const headerStyle = {
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
padding: '1rem 1.25rem',
|
||||
borderBottom: '1px solid rgba(245,158,11,0.2)',
|
||||
};
|
||||
const sectionStyle = {
|
||||
padding: '0.875rem 1.25rem',
|
||||
borderBottom: '1px solid rgba(255,255,255,0.04)',
|
||||
};
|
||||
const labelStyle = {
|
||||
display: 'block', fontSize: '0.68rem', fontWeight: '600',
|
||||
color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.08em',
|
||||
marginBottom: '0.35rem',
|
||||
};
|
||||
const inputStyle = {
|
||||
width: '100%', boxSizing: 'border-box',
|
||||
background: 'rgba(14,165,233,0.05)',
|
||||
border: '1px solid rgba(14,165,233,0.2)',
|
||||
borderRadius: '0.25rem', padding: '0.45rem 0.6rem',
|
||||
color: '#CBD5E1', fontSize: '0.82rem', fontFamily: 'monospace',
|
||||
outline: 'none',
|
||||
};
|
||||
const inputErrorStyle = { ...inputStyle, borderColor: '#EF4444' };
|
||||
const textareaStyle = { ...inputStyle, minHeight: '60px', resize: 'vertical' };
|
||||
const textareaErrorStyle = { ...textareaStyle, borderColor: '#EF4444' };
|
||||
const errorTextStyle = { fontSize: '0.68rem', color: '#EF4444', marginTop: '0.2rem' };
|
||||
const footerStyle = {
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: '0.625rem',
|
||||
padding: '0.875rem 1.25rem',
|
||||
};
|
||||
|
||||
// ---- Result views ----
|
||||
if (result) {
|
||||
return ReactDOM.createPortal(
|
||||
<div style={overlayStyle} onClick={() => { if (!submitting) onClose(); }}>
|
||||
<div style={modalStyle} onClick={e => e.stopPropagation()}>
|
||||
<div style={headerStyle}>
|
||||
<span style={{ fontSize: '0.88rem', fontWeight: '700', color: result.success ? '#10B981' : '#EF4444' }}>
|
||||
{result.success ? 'Workflow Created' : 'Submission Failed'}
|
||||
</span>
|
||||
<button onClick={onClose} style={{ background: 'none', border: 'none', color: '#64748B', cursor: 'pointer' }}>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ padding: '1.5rem 1.25rem', textAlign: 'center' }}>
|
||||
{result.success ? (
|
||||
<>
|
||||
<div style={{ marginBottom: '1rem' }}>
|
||||
<Check size={36} style={{ color: '#10B981' }} />
|
||||
</div>
|
||||
<div style={{ fontSize: '1.1rem', fontWeight: '700', color: '#F59E0B', marginBottom: '0.5rem' }}>
|
||||
{result.generatedId || `Batch #${result.workflowBatchId}`}
|
||||
</div>
|
||||
<div style={{ fontSize: '0.78rem', color: '#94A3B8', marginBottom: '1rem' }}>
|
||||
FP workflow created successfully with {selectedItems.length} finding{selectedItems.length !== 1 ? 's' : ''}.
|
||||
</div>
|
||||
{result.attachmentResults.length > 0 && (
|
||||
<div style={{ textAlign: 'left', marginBottom: '1rem' }}>
|
||||
<div style={labelStyle}>Attachments</div>
|
||||
{result.attachmentResults.map((a, i) => (
|
||||
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', fontSize: '0.75rem', color: a.success ? '#10B981' : '#EF4444', marginBottom: '0.25rem' }}>
|
||||
{a.success ? <Check size={12} /> : <AlertTriangle size={12} />}
|
||||
<span>{a.filename}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div style={{ marginBottom: '1rem' }}>
|
||||
<AlertTriangle size={36} style={{ color: '#EF4444' }} />
|
||||
</div>
|
||||
<div style={{ fontSize: '0.88rem', fontWeight: '600', color: '#E2E8F0', marginBottom: '0.5rem' }}>
|
||||
{result.error}
|
||||
</div>
|
||||
{result.generatedId && (
|
||||
<div style={{ fontSize: '0.78rem', color: '#F59E0B', marginBottom: '0.5rem' }}>
|
||||
Workflow was created: {result.generatedId}
|
||||
</div>
|
||||
)}
|
||||
{result.attachmentResults?.length > 0 && (
|
||||
<div style={{ textAlign: 'left', marginBottom: '1rem' }}>
|
||||
<div style={labelStyle}>Attachment Results</div>
|
||||
{result.attachmentResults.map((a, i) => (
|
||||
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', fontSize: '0.75rem', color: a.success ? '#10B981' : '#EF4444', marginBottom: '0.25rem' }}>
|
||||
{a.success ? <Check size={12} /> : <AlertTriangle size={12} />}
|
||||
<span>{a.filename}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div style={footerStyle}>
|
||||
{!result.success && (
|
||||
<button
|
||||
onClick={() => setResult(null)}
|
||||
style={{
|
||||
padding: '0.45rem 1rem',
|
||||
background: 'rgba(245,158,11,0.1)',
|
||||
border: '1px solid rgba(245,158,11,0.3)',
|
||||
borderRadius: '0.375rem',
|
||||
color: '#F59E0B', fontSize: '0.78rem', fontWeight: '600',
|
||||
cursor: 'pointer', fontFamily: 'monospace',
|
||||
}}
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
padding: '0.45rem 1rem',
|
||||
background: result.success ? 'rgba(16,185,129,0.12)' : 'rgba(255,255,255,0.04)',
|
||||
border: `1px solid ${result.success ? 'rgba(16,185,129,0.3)' : 'rgba(255,255,255,0.1)'}`,
|
||||
borderRadius: '0.375rem',
|
||||
color: result.success ? '#10B981' : '#94A3B8',
|
||||
fontSize: '0.78rem', fontWeight: '600',
|
||||
cursor: 'pointer', fontFamily: 'monospace',
|
||||
}}
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Form view ----
|
||||
return ReactDOM.createPortal(
|
||||
<div style={overlayStyle} onClick={() => { if (!submitting) onClose(); }}>
|
||||
<div style={modalStyle} onClick={e => e.stopPropagation()}>
|
||||
{/* Header */}
|
||||
<div style={headerStyle}>
|
||||
<span style={{ fontSize: '0.88rem', fontWeight: '700', color: '#F59E0B' }}>
|
||||
Create FP Workflow
|
||||
</span>
|
||||
<button onClick={() => { if (!submitting) onClose(); }} style={{ background: 'none', border: 'none', color: '#64748B', cursor: 'pointer' }}>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Selected findings summary */}
|
||||
<div style={sectionStyle}>
|
||||
<div style={labelStyle}>Selected Findings ({selectedItems.length})</div>
|
||||
<div style={{ maxHeight: '120px', overflow: 'auto' }}>
|
||||
{selectedItems.map((item, i) => (
|
||||
<div key={item.id || i} style={{ display: 'flex', gap: '0.5rem', alignItems: 'baseline', fontSize: '0.75rem', color: '#94A3B8', marginBottom: '0.3rem' }}>
|
||||
<span style={{ color: '#F59E0B', fontWeight: '600', flexShrink: 0 }}>{item.finding_id}</span>
|
||||
<span style={{ color: '#CBD5E1', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>{item.finding_title || '—'}</span>
|
||||
{item.cves_json && (() => {
|
||||
try {
|
||||
const cves = JSON.parse(item.cves_json);
|
||||
return cves.length > 0 ? <span style={{ color: '#64748B', flexShrink: 0 }}>{cves.join(', ')}</span> : null;
|
||||
} catch { return null; }
|
||||
})()}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Form fields */}
|
||||
<div style={sectionStyle}>
|
||||
{/* Name */}
|
||||
<label style={{ display: 'block', marginBottom: '0.75rem' }}>
|
||||
<span style={labelStyle}>Workflow Name <span style={{ color: '#EF4444' }}>*</span></span>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
placeholder="FP — CVE-2024-XXXX — Vendor"
|
||||
disabled={submitting}
|
||||
maxLength={255}
|
||||
style={errors.name ? inputErrorStyle : inputStyle}
|
||||
/>
|
||||
{errors.name && <div style={errorTextStyle}>{errors.name}</div>}
|
||||
</label>
|
||||
|
||||
{/* Reason */}
|
||||
<label style={{ display: 'block', marginBottom: '0.75rem' }}>
|
||||
<span style={labelStyle}>Reason / Justification <span style={{ color: '#EF4444' }}>*</span></span>
|
||||
<textarea
|
||||
value={reason}
|
||||
onChange={e => setReason(e.target.value)}
|
||||
placeholder="Explain why these findings are false positives..."
|
||||
disabled={submitting}
|
||||
style={errors.reason ? textareaErrorStyle : textareaStyle}
|
||||
/>
|
||||
{errors.reason && <div style={errorTextStyle}>{errors.reason}</div>}
|
||||
</label>
|
||||
|
||||
{/* Description */}
|
||||
<label style={{ display: 'block', marginBottom: '0.75rem' }}>
|
||||
<span style={labelStyle}>Description (optional)</span>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={e => setDescription(e.target.value)}
|
||||
placeholder="Additional context or details..."
|
||||
disabled={submitting}
|
||||
maxLength={2000}
|
||||
style={errors.description ? textareaErrorStyle : textareaStyle}
|
||||
/>
|
||||
{errors.description && <div style={errorTextStyle}>{errors.description}</div>}
|
||||
<div style={{ fontSize: '0.62rem', color: '#475569', textAlign: 'right', marginTop: '0.15rem' }}>{description.length}/2000</div>
|
||||
</label>
|
||||
|
||||
{/* Expiration date */}
|
||||
<label style={{ display: 'block', marginBottom: '0.75rem' }}>
|
||||
<span style={labelStyle}>Expiration Date <span style={{ color: '#EF4444' }}>*</span></span>
|
||||
<input
|
||||
type="date"
|
||||
value={expirationDate}
|
||||
onChange={e => setExpirationDate(e.target.value)}
|
||||
disabled={submitting}
|
||||
style={errors.expirationDate ? inputErrorStyle : inputStyle}
|
||||
/>
|
||||
{errors.expirationDate && <div style={errorTextStyle}>{errors.expirationDate}</div>}
|
||||
</label>
|
||||
|
||||
{/* Scope override toggle */}
|
||||
<div style={{ marginBottom: '0.25rem' }}>
|
||||
<span style={labelStyle}>Scope Override Authorization</span>
|
||||
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
||||
{['Authorized', 'None'].map(val => {
|
||||
const active = scopeOverride === val;
|
||||
return (
|
||||
<button
|
||||
key={val}
|
||||
onClick={() => setScopeOverride(val)}
|
||||
disabled={submitting}
|
||||
style={{
|
||||
flex: 1, padding: '0.35rem',
|
||||
background: active ? 'rgba(245,158,11,0.12)' : 'transparent',
|
||||
border: `1px solid ${active ? 'rgba(245,158,11,0.4)' : 'rgba(255,255,255,0.08)'}`,
|
||||
borderRadius: '0.25rem',
|
||||
color: active ? '#F59E0B' : '#475569',
|
||||
fontFamily: 'monospace', fontSize: '0.75rem', fontWeight: '600',
|
||||
cursor: submitting ? 'not-allowed' : 'pointer',
|
||||
transition: 'all 0.12s',
|
||||
}}
|
||||
>
|
||||
{val}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* File upload */}
|
||||
<div style={sectionStyle}>
|
||||
<div style={labelStyle}>Attachments</div>
|
||||
<div
|
||||
ref={dropRef}
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
style={{
|
||||
border: '1px dashed rgba(14,165,233,0.25)',
|
||||
borderRadius: '0.375rem',
|
||||
padding: '1rem',
|
||||
textAlign: 'center',
|
||||
cursor: submitting ? 'not-allowed' : 'pointer',
|
||||
background: 'rgba(14,165,233,0.03)',
|
||||
transition: 'border-color 0.15s',
|
||||
}}
|
||||
>
|
||||
<Upload size={20} style={{ color: '#475569', marginBottom: '0.35rem' }} />
|
||||
<div style={{ fontSize: '0.75rem', color: '#64748B' }}>
|
||||
Drop files here or click to browse
|
||||
</div>
|
||||
<div style={{ fontSize: '0.62rem', color: '#475569', marginTop: '0.2rem' }}>
|
||||
Max 10 MB per file · PDF, PNG, JPG, DOC, XLSX, CSV, TXT, ZIP
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
style={{ display: 'none' }}
|
||||
onChange={e => { if (e.target.files?.length) addFiles(e.target.files); e.target.value = ''; }}
|
||||
accept={ALLOWED_EXTENSIONS.join(',')}
|
||||
/>
|
||||
{errors.files && <div style={errorTextStyle}>{errors.files}</div>}
|
||||
{files.length > 0 && (
|
||||
<div style={{ marginTop: '0.5rem' }}>
|
||||
{files.map((f, i) => (
|
||||
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', padding: '0.3rem 0', borderBottom: '1px solid rgba(255,255,255,0.03)' }}>
|
||||
<FileText size={13} style={{ color: '#64748B', flexShrink: 0 }} />
|
||||
<span style={{ flex: 1, fontSize: '0.75rem', color: '#CBD5E1', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.name}</span>
|
||||
<span style={{ fontSize: '0.68rem', color: '#475569', flexShrink: 0 }}>{formatSize(f.size)}</span>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); removeFile(i); }}
|
||||
disabled={submitting}
|
||||
style={{ background: 'none', border: 'none', color: '#64748B', cursor: 'pointer', padding: '0.15rem' }}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div style={footerStyle}>
|
||||
{submitting && (
|
||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', gap: '0.5rem', fontSize: '0.75rem', color: '#F59E0B' }}>
|
||||
<Loader size={14} style={{ animation: 'spin 1s linear infinite' }} />
|
||||
<span>{progress.step}</span>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={() => { if (!submitting) onClose(); }}
|
||||
disabled={submitting}
|
||||
style={{
|
||||
padding: '0.45rem 1rem',
|
||||
background: 'none',
|
||||
border: '1px solid rgba(255,255,255,0.08)',
|
||||
borderRadius: '0.375rem',
|
||||
color: '#64748B', fontSize: '0.78rem', fontWeight: '600',
|
||||
cursor: submitting ? 'not-allowed' : 'pointer',
|
||||
fontFamily: 'monospace',
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={submitting}
|
||||
style={{
|
||||
padding: '0.45rem 1.25rem',
|
||||
background: submitting ? 'rgba(245,158,11,0.08)' : 'rgba(245,158,11,0.15)',
|
||||
border: `1px solid ${submitting ? 'rgba(245,158,11,0.15)' : 'rgba(245,158,11,0.4)'}`,
|
||||
borderRadius: '0.375rem',
|
||||
color: submitting ? '#92700C' : '#F59E0B',
|
||||
fontSize: '0.78rem', fontWeight: '700',
|
||||
cursor: submitting ? 'not-allowed' : 'pointer',
|
||||
fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: '0.05em',
|
||||
}}
|
||||
>
|
||||
{submitting ? 'Submitting...' : 'Submit'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main ReportingPage
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1718,6 +2297,10 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
|
||||
const [addPopover, setAddPopover] = useState(null); // { finding, anchorRect }
|
||||
const [queueForm, setQueueForm] = useState({ vendor: '', workflowType: 'FP' });
|
||||
|
||||
// FP Workflow modal state
|
||||
const [fpModalOpen, setFpModalOpen] = useState(false);
|
||||
const [fpModalItems, setFpModalItems] = useState([]);
|
||||
|
||||
// Queue API helpers
|
||||
const fetchQueue = useCallback(async () => {
|
||||
setQueueLoading(true);
|
||||
@@ -1732,6 +2315,22 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// FP Workflow handlers
|
||||
const handleCreateFpWorkflow = useCallback((selectedIds) => {
|
||||
const selectedSet = new Set(selectedIds);
|
||||
const fpItems = filterFpItems(
|
||||
queueItems.filter(item => selectedSet.has(item.id) && item.status === 'pending')
|
||||
);
|
||||
if (fpItems.length > 0) {
|
||||
setFpModalItems(fpItems);
|
||||
setFpModalOpen(true);
|
||||
}
|
||||
}, [queueItems]);
|
||||
|
||||
const handleFpWorkflowSuccess = useCallback(() => {
|
||||
fetchQueue();
|
||||
}, [fetchQueue]);
|
||||
|
||||
const addToQueue = useCallback(async () => {
|
||||
if (!addPopover) return;
|
||||
const { finding } = addPopover;
|
||||
@@ -2336,6 +2935,14 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
|
||||
onDelete={deleteQueueItem}
|
||||
onDeleteMany={deleteQueueItems}
|
||||
onClearCompleted={clearCompleted}
|
||||
onCreateFpWorkflow={handleCreateFpWorkflow}
|
||||
canWrite={canWrite}
|
||||
/>
|
||||
<FpWorkflowModal
|
||||
open={fpModalOpen}
|
||||
onClose={() => setFpModalOpen(false)}
|
||||
selectedItems={fpModalItems}
|
||||
onSuccess={handleFpWorkflowSuccess}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user