Add Atlas InfoSec action plans integration

Integrate Atlas InfoSec API to manage compliance action plans directly from
the ReportingPage. Users can view, create, and update action plans for host
findings without switching to the Atlas web tool.

Backend:
- Add atlasApi.js helper with Basic Auth, TLS skip, GET/PUT/PATCH/POST
- Add atlas_action_plans_cache migration for SQLite cache table
- Add atlas.js router with sync, status, and proxy CRUD endpoints
- Mount Atlas router at /api/atlas in server.js
- Extract hostId from Ivanti host findings during sync

Frontend:
- Add AtlasBadge component (amber=needs plan, green=has plan)
- Add AtlasSlideOutPanel with plan list, create form, edit capability
- Separate active plans from inactive history in collapsible section
- Custom dark-themed plan type dropdown
- Optimistic local state shows pending plans immediately after creation
- Atlas sync button on ReportingPage toolbar
- Prepopulate finding ID in create form from clicked row

Environment:
- Add ATLAS_API_URL, ATLAS_API_USER, ATLAS_API_PASS, ATLAS_SKIP_TLS to .env.example
This commit is contained in:
root
2026-04-23 21:52:53 +00:00
parent e1b000870c
commit 4c04c9870a
14 changed files with 3914 additions and 11 deletions

View File

@@ -0,0 +1 @@
{"specId": "aa138cae-9fbf-47bf-9dc3-1169456f5706", "workflowType": "requirements-first", "specType": "feature"}

View File

@@ -0,0 +1,497 @@
# Design Document: Atlas Action Plans Integration
## Overview
This feature integrates the Atlas InfoSec action plans API into the STEAM Security Dashboard, allowing users to view and manage compliance action plans for host findings directly from the ReportingPage. The integration follows the existing proxy-and-cache pattern used by the Ivanti integration — a backend helper handles HTTP communication with the external API, a SQLite cache stores host-level status for fast page loads, and Express routes expose both cached status and proxied CRUD operations to the React frontend.
The frontend adds two visual elements to the ReportingPage: a small badge in the Host column indicating action plan coverage, and a slide-out panel for viewing, creating, and updating plans. A manual sync button — matching the existing Ivanti sync button pattern — lets users refresh cached Atlas data on demand.
### Key Design Decisions
- **Proxy pattern over direct frontend calls**: The frontend never talks to Atlas directly. All Atlas API calls go through the STEAM backend, which handles authentication, TLS configuration, and audit logging. This keeps Atlas credentials server-side and provides a single audit trail.
- **Cache-then-fetch**: The ReportingPage loads cached badge data from SQLite on mount (fast), and users trigger a manual sync to refresh from Atlas (slow, one API call per host). This matches the existing Ivanti sync UX.
- **Sequential host sync (not bulk GET)**: The Atlas API only exposes per-host `GET /hosts/{host_id}/action-plans`. There is no bulk status endpoint, so the sync iterates over unique host IDs from the Ivanti cache. Concurrency is limited to avoid overwhelming Atlas.
- **Basic Auth with runtime base64 encoding**: Atlas uses `Authorization: Basic <base64(user:pass)>` rather than the API key pattern used by Ivanti. The helper computes this at request time from environment variables.
- **ID mapping**: Ivanti `host.hostId` maps directly to Atlas `host_id` in URL paths. Ivanti `f.id` maps to Atlas `active_host_findings_id` in request bodies. No translation layer is needed.
## Architecture
```mermaid
graph TD
subgraph Frontend
RP[ReportingPage]
AB[AtlasBadge]
SP[AtlasSlideOutPanel]
end
subgraph Backend
AR[Atlas Router<br/>/api/atlas/*]
AH[Atlas Helper<br/>atlasApi.js]
AC[(Atlas Cache<br/>SQLite)]
IC[(Ivanti Cache<br/>SQLite)]
AL[Audit Log]
end
subgraph External
ATLAS[Atlas InfoSec API<br/>https://atlas-infosec.caas.charterlab.com]
end
RP -->|GET /api/atlas/status| AR
RP -->|POST /api/atlas/sync| AR
AB -->|click| SP
SP -->|GET /api/atlas/hosts/:id/action-plans| AR
SP -->|PUT /api/atlas/hosts/:id/action-plans| AR
SP -->|PATCH /api/atlas/hosts/:id/action-plans| AR
AR -->|read cached status| AC
AR -->|read host IDs| IC
AR -->|GET, PUT, PATCH, POST| AH
AR -->|logAudit| AL
AH -->|HTTPS + Basic Auth| ATLAS
AR -->|upsert cache| AC
```
### Data Flow: Page Load
1. ReportingPage mounts, fetches Ivanti findings from existing cache (existing behavior)
2. ReportingPage fetches `GET /api/atlas/status` — returns all cached Atlas rows
3. Frontend builds a `Map<hostId, atlasStatus>` and passes it to table rendering
4. Each Host column cell checks the map — if a match exists, renders an AtlasBadge
### Data Flow: Manual Sync
1. User clicks Atlas sync button
2. Frontend sends `POST /api/atlas/sync`
3. Backend extracts unique `hostId` values from `ivanti_findings_cache.findings_json`
4. Backend calls `GET /hosts/{host_id}/action-plans` for each host (with concurrency limit of 5)
5. Backend upserts each result into `atlas_action_plans_cache`
6. Backend returns summary `{ synced, withPlans, failed }`
7. Frontend re-fetches `GET /api/atlas/status` and updates badges
### Data Flow: Create/Update Plan
1. User clicks AtlasBadge → slide-out panel opens
2. Panel fetches `GET /api/atlas/hosts/:hostId/action-plans` for live data
3. User fills create form or edits existing plan
4. Frontend sends `PUT` (create) or `PATCH` (update) to `/api/atlas/hosts/:hostId/action-plans`
5. Backend validates request body, proxies to Atlas API, logs audit entry
6. On success, panel refreshes plan list and frontend re-fetches cached status
## Components and Interfaces
### Backend: Atlas API Helper (`backend/helpers/atlasApi.js`)
A new helper module following the same pattern as `ivantiApi.js` — promise-based HTTP using Node's `https` module, with TLS skip support.
```javascript
// Exported functions
function atlasRequest(method, urlPath, body, options)
// method: 'GET' | 'PUT' | 'PATCH' | 'POST'
// urlPath: e.g. '/hosts/29329662/action-plans'
// body: object | null (null for GET)
// options: { timeout?: number }
// Returns: Promise<{ status: number, body: string }>
// Convenience wrappers
function atlasGet(urlPath, options)
function atlasPut(urlPath, body, options)
function atlasPatch(urlPath, body, options)
function atlasPost(urlPath, body, options)
```
**Configuration** (read from `process.env` at module load):
- `ATLAS_API_URL` — base URL (e.g. `https://atlas-infosec.caas.charterlab.com`)
- `ATLAS_API_USER` — service account username
- `ATLAS_API_PASS` — service account password
- `ATLAS_SKIP_TLS``'true'` to disable certificate verification
**Auth header**: `Authorization: Basic ${Buffer.from(user + ':' + pass).toString('base64')}`
**Timeouts**: 15s default for single-host endpoints, 60s for bulk. Passed via `options.timeout`.
**Error handling**: Network errors and timeouts reject the promise. Non-2xx responses resolve normally with `{ status, body }` — the caller decides how to handle them.
### Backend: Migration (`backend/migrations/add_atlas_action_plans_cache.js`)
Creates the `atlas_action_plans_cache` table following the existing migration pattern.
```javascript
// Table schema
db.run(`
CREATE TABLE IF NOT EXISTS atlas_action_plans_cache (
id INTEGER PRIMARY KEY AUTOINCREMENT,
host_id INTEGER NOT NULL UNIQUE,
has_action_plan INTEGER NOT NULL DEFAULT 0,
plan_count INTEGER NOT NULL DEFAULT 0,
plans_json TEXT NOT NULL DEFAULT '[]',
synced_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
)
`);
db.run(`
CREATE INDEX IF NOT EXISTS idx_atlas_cache_host_id
ON atlas_action_plans_cache(host_id)
`);
```
### Backend: Atlas Router (`backend/routes/atlas.js`)
Factory function pattern: `createAtlasRouter(db, requireAuth)` returns an Express Router mounted at `/api/atlas`.
| Method | Path | Auth | Group | Description |
|--------|------|------|-------|-------------|
| `GET` | `/status` | requireAuth | any | Return all cached Atlas rows |
| `POST` | `/sync` | requireAuth | Admin, Standard_User | Sync Atlas data for all Ivanti hosts |
| `GET` | `/hosts/:hostId/action-plans` | requireAuth | any | Proxy to Atlas GET plans |
| `PUT` | `/hosts/:hostId/action-plans` | requireAuth | Admin, Standard_User | Proxy to Atlas create plan |
| `PATCH` | `/hosts/:hostId/action-plans` | requireAuth | Admin, Standard_User | Proxy to Atlas update plan |
| `POST` | `/hosts/bulk-action-plans` | requireAuth | Admin, Standard_User | Proxy to Atlas bulk create |
**Sync implementation**:
1. Parse `ivanti_findings_cache.findings_json` to extract unique `hostId` values (skip nulls)
2. Process hosts in batches of 5 concurrent requests using `Promise.allSettled`
3. For each host, call `atlasGet('/hosts/' + hostId + '/action-plans')`
4. On 2xx: upsert cache row with plan count and summary JSON
5. On non-2xx: increment failure counter, log warning, continue
6. Return `{ synced: N, withPlans: N, failed: N }`
**Validation (PUT create)**:
- `plan_type` must be one of: `decommission`, `remediation`, `false_positive`, `risk_acceptance`, `scan_exclusion`
- `commit_date` must match `/^\d{4}-\d{2}-\d{2}$/`
- `hostId` param must be a positive integer
**Validation (PATCH update)**:
- `action_plan_id` must be a non-empty string
- `updates` must be a non-null object
**Validation (POST bulk)**:
- `host_ids` must be a non-empty array of positive integers
- `plan_type` and `commit_date` validated same as PUT
### Frontend: AtlasBadge Component
A small inline badge rendered inside the Host column cell, next to the hostname. Clicking it opens the slide-out panel.
**Props**: `{ hostId, atlasStatus, onClick }`
**Rendering logic**:
- If `atlasStatus` is `undefined` (host not in Atlas cache): render nothing
- If `atlasStatus.has_action_plan === 0`: render warning badge (amber border, "0" text)
- If `atlasStatus.plan_count > 0`: render success badge (emerald border, count text)
**Style**: Small pill badge using the design system's badge pattern — monospace font, 0.58rem, inline-flex, with border color indicating status. Positioned after the hostname text in the OverrideCell wrapper.
### Frontend: AtlasSlideOutPanel Component
A right-side drawer panel, similar in concept to the existing FP submission detail panels. Renders over the table content with a semi-transparent backdrop.
**Props**: `{ hostId, hostName, onClose, canWrite }`
**Sections**:
1. **Header**: hostname, host ID, close button
2. **Plan list**: fetched from `GET /api/atlas/hosts/:hostId/action-plans` on open. Each plan shows type, commit date, status, and optional VNR/EXC references
3. **Create form** (if `canWrite`): plan type dropdown, commit date picker, optional fields (qualys_id, active_host_findings_id, jira_vnr, archer_exc)
4. **Edit capability** (if `canWrite`): inline edit on existing plans, submits via PATCH
**State management**: Local component state — plan list, loading, error, form values. No global state needed since the panel is ephemeral.
### Frontend: Atlas Sync Button
A new button in the ReportingPage toolbar, placed adjacent to the existing Ivanti sync button. Uses the same styling pattern — `RefreshCw` icon, monospace uppercase text, sky blue accent color. Differentiated by a `Database` icon prefix and "Atlas" label.
**State**: `atlasSyncing` boolean, `atlasStatus` map (keyed by hostId), `atlasError` string.
### Server.js Integration
```javascript
const createAtlasRouter = require('./routes/atlas');
// ...
app.use('/api/atlas', createAtlasRouter(db, requireAuth));
```
## Data Models
### Atlas Cache Table (`atlas_action_plans_cache`)
| Column | Type | Constraints | Description |
|--------|------|-------------|-------------|
| `id` | INTEGER | PRIMARY KEY AUTOINCREMENT | Row ID |
| `host_id` | INTEGER | NOT NULL UNIQUE | Ivanti host ID (= Atlas host_id) |
| `has_action_plan` | INTEGER | NOT NULL DEFAULT 0 | 1 if any plans exist, 0 otherwise |
| `plan_count` | INTEGER | NOT NULL DEFAULT 0 | Number of action plans |
| `plans_json` | TEXT | NOT NULL DEFAULT '[]' | JSON array of plan summaries |
| `synced_at` | DATETIME | NOT NULL DEFAULT CURRENT_TIMESTAMP | Last sync timestamp |
**Index**: `idx_atlas_cache_host_id` on `host_id`
### Plan Summary JSON Shape (stored in `plans_json`)
```json
[
{
"action_plan_id": "ap-123",
"plan_type": "remediation",
"commit_date": "2026-07-01",
"status": "active",
"qualys_id": "QID-12345",
"active_host_findings_id": 2281281250,
"jira_vnr": null,
"archer_exc": null
}
]
```
The exact shape depends on what the Atlas API returns. The backend stores the raw response array as-is, extracting only `plan_count` and `has_action_plan` for the cache columns.
### Atlas API Request/Response Shapes
**Create (PUT `/hosts/{host_id}/action-plans`)**:
```json
{
"plan_type": "remediation",
"commit_date": "2026-07-01",
"active_host_findings_id": 2281281250
}
```
**Update (PATCH `/hosts/{host_id}/action-plans`)**:
```json
{
"action_plan_id": "ap-123",
"updates": {
"commit_date": "2026-08-01"
}
}
```
**Bulk Create (POST `/hosts/create-bulk-action-plans`)**:
```json
{
"host_ids": [29329662, 29329663],
"plan_type": "decommission",
"commit_date": "2026-07-01"
}
```
### Environment Variables
| Variable | Required | Description |
|----------|----------|-------------|
| `ATLAS_API_URL` | Yes | Atlas InfoSec API base URL |
| `ATLAS_API_USER` | Yes | Service account username for Basic Auth |
| `ATLAS_API_PASS` | Yes | Service account password for Basic Auth |
| `ATLAS_SKIP_TLS` | No | Set to `true` to skip TLS cert verification (default: `false`) |
### Frontend State Shape
```javascript
// Atlas status map — keyed by hostId (number)
const atlasStatusMap = new Map([
[29329662, { host_id: 29329662, has_action_plan: 1, plan_count: 2, synced_at: '2026-07-01 12:00:00' }],
[29329663, { host_id: 29329663, has_action_plan: 0, plan_count: 0, synced_at: '2026-07-01 12:00:00' }],
]);
```
## Correctness Properties
*A property is a characteristic or behavior that should hold true across all valid executions of a system — essentially, a formal statement about what the system should do. Properties serve as the bridge between human-readable specifications and machine-verifiable correctness guarantees.*
### Property 1: Basic Auth header round-trip
*For any* pair of (username, password) strings, the Authorization header produced by the Atlas helper should decode (via base64) to exactly `username:password`.
**Validates: Requirements 1.2**
### Property 2: Non-2xx responses resolve with status and body
*For any* HTTP status code in the range 400599 and any response body string, the Atlas helper should resolve the promise with an object containing that exact status code and body, rather than rejecting.
**Validates: Requirements 1.7**
### Property 3: Error messages contain method and path
*For any* HTTP method string and URL path string, when a network error or timeout occurs, the Atlas helper's rejection error message should contain both the method and the path.
**Validates: Requirements 1.8**
### Property 4: Unique host ID extraction
*For any* array of finding objects (each with an optional `hostId` field), the sync operation should extract exactly the set of unique, non-null `hostId` values — no duplicates, no nulls.
**Validates: Requirements 3.2**
### Property 5: Cache upsert derives correct plan_count and has_action_plan
*For any* host ID and any array of action plan objects returned by the Atlas API, after upserting into the cache, the stored `plan_count` should equal the array length and `has_action_plan` should equal 1 if the array is non-empty, 0 otherwise.
**Validates: Requirements 3.4**
### Property 6: Sync response count invariant
*For any* sync operation over N unique hosts where M hosts fail, the response should satisfy: `synced + failed = N` and `withPlans <= synced`.
**Validates: Requirements 3.6**
### Property 7: Status endpoint returns all cached rows with required fields
*For any* set of rows inserted into the Atlas cache table, the `GET /api/atlas/status` endpoint should return exactly that many rows, and each row should contain `host_id`, `has_action_plan`, `plan_count`, and `synced_at` fields.
**Validates: Requirements 4.2, 4.3**
### Property 8: plan_type validation
*For any* string, the PUT endpoint's plan_type validation should accept the string if and only if it is one of: `decommission`, `remediation`, `false_positive`, `risk_acceptance`, `scan_exclusion`.
**Validates: Requirements 5.3**
### Property 9: commit_date validation
*For any* string, the PUT endpoint's commit_date validation should accept the string if and only if it matches the pattern `YYYY-MM-DD` (four digits, hyphen, two digits, hyphen, two digits).
**Validates: Requirements 5.4**
### Property 10: PATCH body validation
*For any* request body object, the PATCH endpoint should accept it if and only if `action_plan_id` is a non-empty string and `updates` is a non-null object.
**Validates: Requirements 5.7**
### Property 11: Bulk request validation
*For any* request body object, the bulk POST endpoint should accept it if and only if `host_ids` is a non-empty array of positive integers, `plan_type` is one of the five valid types, and `commit_date` matches `YYYY-MM-DD`.
**Validates: Requirements 5.10**
### Property 12: Non-2xx Atlas response passthrough
*For any* non-2xx status code and error body returned by the Atlas API, the proxy route should return that same status code and body to the frontend client.
**Validates: Requirements 5.12**
### Property 13: Badge visibility and content
*For any* finding with a `hostId` and any atlas status map, the AtlasBadge should render if and only if the `hostId` exists as a key in the map. When rendered with `plan_count > 0`, the badge text should contain the plan count value.
**Validates: Requirements 6.2, 6.5, 6.6**
### Property 14: Panel displays all plan fields
*For any* action plan object containing `plan_type`, `commit_date`, `status`, and optional reference fields (`jira_vnr`, `archer_exc`), the rendered slide-out panel should include all non-null field values in its output.
**Validates: Requirements 7.3**
## Error Handling
### Atlas API Communication Errors
| Error Scenario | Handling | User Impact |
|----------------|----------|-------------|
| Network timeout (15s/60s) | Helper rejects promise with descriptive error | Sync: host skipped, counted as failed. Proxy: 502 returned to frontend with error message |
| DNS resolution failure | Helper rejects promise | Same as timeout |
| TLS certificate error (when `ATLAS_SKIP_TLS` is false) | Helper rejects promise | Same as timeout |
| Atlas API returns 401 (bad credentials) | Helper resolves with `{ status: 401, body }` | Sync: all hosts fail. Proxy: 401 forwarded to frontend. Error banner shown |
| Atlas API returns 404 (host not found) | Helper resolves with `{ status: 404, body }` | Sync: host skipped. Proxy: 404 forwarded to frontend |
| Atlas API returns 422 (validation error) | Helper resolves with `{ status: 422, body }` | Proxy: 422 forwarded to frontend. Panel shows validation error |
| Atlas API returns 500 (server error) | Helper resolves with `{ status: 500, body }` | Sync: host skipped. Proxy: 500 forwarded to frontend |
### Backend Validation Errors
| Error Scenario | HTTP Status | Response |
|----------------|-------------|----------|
| Missing or invalid `plan_type` | 400 | `{ error: 'plan_type must be one of: decommission, remediation, ...' }` |
| Missing or invalid `commit_date` | 400 | `{ error: 'commit_date must be a valid YYYY-MM-DD date string' }` |
| Missing `action_plan_id` on PATCH | 400 | `{ error: 'action_plan_id is required and must be a non-empty string' }` |
| Missing `updates` on PATCH | 400 | `{ error: 'updates is required and must be an object' }` |
| Empty or invalid `host_ids` on bulk | 400 | `{ error: 'host_ids must be a non-empty array of positive integers' }` |
| Non-integer `hostId` URL param | 400 | `{ error: 'hostId must be a positive integer' }` |
| Unauthenticated request | 401 | `{ error: 'Authentication required' }` |
| Viewer group on restricted endpoint | 403 | `{ error: 'Insufficient permissions', required: [...], current: 'Viewer' }` |
### Frontend Error Handling
- **Sync failure**: Error banner displayed below the Atlas sync button (matching existing Ivanti sync error pattern). Button re-enabled.
- **Panel fetch failure**: "Failed to load action plans" message inside the panel with a retry button.
- **Create/update failure**: Error message displayed inline in the form, preserving user input for correction.
- **Network error**: Generic "Unable to reach server" message with retry option.
### Environment Configuration Errors
- If `ATLAS_API_URL`, `ATLAS_API_USER`, or `ATLAS_API_PASS` are not set, the Atlas helper logs a warning at module load time. All Atlas API calls will fail with a descriptive error rather than crashing the server.
- The Atlas router checks for helper availability and returns 503 if Atlas is not configured.
## Testing Strategy
### Unit Tests
Unit tests cover specific examples, edge cases, and integration points:
**Atlas Helper (`atlasApi.js`)**:
- Correct URL construction from base URL + path
- Basic Auth header format for known credentials
- TLS skip flag respected (rejectUnauthorized option)
- Timeout values for single vs bulk endpoints
- GET, PUT, PATCH, POST methods set correctly
**Atlas Router validation**:
- Valid plan_type values accepted, invalid rejected
- Valid commit_date formats accepted, invalid rejected
- PATCH body with missing action_plan_id rejected
- Bulk request with empty host_ids rejected
- Non-integer hostId param rejected
- Auth middleware applied to correct endpoints (401/403 responses)
**Atlas Cache operations**:
- Upsert creates new row when host_id doesn't exist
- Upsert updates existing row when host_id exists
- Status endpoint returns empty array when cache is empty
- Migration is idempotent (runs twice without error)
**Frontend components**:
- AtlasBadge renders nothing when host not in status map
- AtlasBadge renders warning style when plan_count is 0
- AtlasBadge renders success style when plan_count > 0
- AtlasSlideOutPanel shows create form for Admin/Standard_User
- AtlasSlideOutPanel hides create form for Viewer
- Sync button disabled during sync, re-enabled after
### Property-Based Tests
Property-based tests verify universal properties across generated inputs. Each test runs a minimum of 100 iterations.
**Library**: [fast-check](https://github.com/dubzzz/fast-check) — the standard PBT library for JavaScript/Node.js.
**Configuration**: Each property test runs with `{ numRuns: 100 }` minimum.
**Tag format**: Each test includes a comment referencing its design property:
```javascript
// Feature: atlas-action-plans, Property 1: Basic Auth header round-trip
```
| Property | Test Description | Generator Strategy |
|----------|-----------------|-------------------|
| Property 1 | Encode then decode Basic Auth header | Generate random (user, pass) string pairs, verify round-trip |
| Property 2 | Non-2xx status codes resolve | Generate integers 400599 and random body strings |
| Property 3 | Error messages contain method and path | Generate random method names and URL path strings |
| Property 4 | Unique host ID extraction | Generate arrays of objects with optional numeric hostId fields |
| Property 5 | Cache upsert correctness | Generate (hostId, planArray) pairs, verify derived fields |
| Property 6 | Sync count invariant | Generate (totalHosts, failureCount) pairs, verify arithmetic |
| Property 7 | Status returns all cached rows | Generate N cache rows, verify response count and fields |
| Property 8 | plan_type validation | Generate random strings, verify acceptance matches valid set |
| Property 9 | commit_date validation | Generate random strings, verify acceptance matches date pattern |
| Property 10 | PATCH body validation | Generate random objects with varying field presence |
| Property 11 | Bulk validation | Generate objects with varying host_ids, plan_type, commit_date |
| Property 12 | Error passthrough | Generate non-2xx codes and body strings, verify forwarding |
| Property 13 | Badge visibility and content | Generate findings and status maps, verify render logic |
| Property 14 | Panel plan field display | Generate plan objects, verify all non-null fields appear |
### Integration Tests
Integration tests verify end-to-end behavior with mocked Atlas API:
- Full sync flow: populate Ivanti cache → trigger sync → verify Atlas cache populated
- Create plan flow: send PUT → verify Atlas API called → verify audit logged
- Update plan flow: send PATCH → verify Atlas API called → verify audit logged
- Bulk create flow: send POST → verify Atlas API called with correct body
- Error resilience: mix of successful and failing hosts during sync
- Auth enforcement: verify 401/403 for each endpoint with wrong credentials/group

View File

@@ -0,0 +1,164 @@
# Requirements Document
## Introduction
Integrate the Atlas InfoSec action plans API into the STEAM Security Dashboard so that users can view and manage compliance action plans for host findings directly from the ReportingPage. This eliminates the need to context-switch to the separate Atlas InfoSec web tool. The integration uses STEAM's Ivanti findings as the source of truth and checks which hosts also exist in Atlas, displaying action plan status badges and providing a slide-out panel for plan creation and management.
## Glossary
- **Dashboard**: The STEAM Security Dashboard frontend React application
- **Backend**: The STEAM Security Dashboard Express.js API server
- **Atlas_API**: The Atlas InfoSec REST API at `https://atlas-infosec.caas.charterlab.com`, documented in `docs/atlasinfosec-api-spec.json`
- **Atlas_Helper**: The backend helper module (`backend/helpers/atlasApi.js`) responsible for HTTP communication with the Atlas_API
- **Atlas_Cache**: A SQLite table storing host-level action plan status per `hostId`, refreshed on-demand via manual sync
- **Atlas_Router**: The backend Express route module (`backend/routes/atlas.js`) exposing Atlas-related endpoints under `/api/atlas`
- **ReportingPage**: The existing frontend page (`frontend/src/components/pages/ReportingPage.js`) displaying Ivanti host findings
- **Action_Plan**: A compliance plan created in Atlas InfoSec for a host finding, with a type (decommission, remediation, false_positive, risk_acceptance, scan_exclusion), a commit date, and optional reference fields
- **Host_ID**: The shared numeric identifier linking an Ivanti host finding (`host.hostId`) to an Atlas host (`host_id` URL parameter)
- **Finding_ID**: The Ivanti finding-level identifier (`f.id`) that maps to Atlas's `active_host_findings_id`
- **Slide_Out_Panel**: A right-side drawer component on the ReportingPage for viewing and managing action plans for a specific host
- **Atlas_Badge**: A visual indicator on the ReportingPage Host column showing whether a host exists in Atlas and its action plan coverage status
- **Ivanti_Cache**: The existing `ivanti_findings_cache` SQLite table holding synced Ivanti host findings
## Requirements
### Requirement 1: Atlas API Helper Module
**User Story:** As a backend developer, I want a centralized helper module for Atlas InfoSec API communication, so that all Atlas HTTP calls use consistent authentication, TLS handling, and error management.
#### Acceptance Criteria
1. THE Atlas_Helper SHALL send all requests to the Atlas_API base URL configured via the `ATLAS_API_URL` environment variable
2. THE Atlas_Helper SHALL include a Basic Auth `Authorization` header computed by base64-encoding the `ATLAS_API_USER` and `ATLAS_API_PASS` environment variable values at runtime
3. WHEN the `ATLAS_SKIP_TLS` environment variable is set to `true`, THE Atlas_Helper SHALL disable TLS certificate verification for Atlas_API requests
4. WHEN the `ATLAS_SKIP_TLS` environment variable is not set or set to `false`, THE Atlas_Helper SHALL enforce TLS certificate verification for Atlas_API requests
5. THE Atlas_Helper SHALL support GET, PUT, PATCH, and POST HTTP methods for communicating with the Atlas_API
6. THE Atlas_Helper SHALL set a request timeout of 15 seconds for single-host endpoints and 60 seconds for bulk endpoints
7. WHEN the Atlas_API returns a non-2xx status code, THE Atlas_Helper SHALL resolve the promise with the status code and response body without throwing an exception
8. WHEN a network error or timeout occurs, THE Atlas_Helper SHALL reject the promise with a descriptive error message including the HTTP method and URL path
### Requirement 2: Atlas Cache Table and Migration
**User Story:** As a system administrator, I want Atlas action plan status cached locally in SQLite, so that the ReportingPage can render badges without calling the Atlas_API on every page load.
#### Acceptance Criteria
1. THE Backend SHALL provide a migration script (`backend/migrations/add_atlas_action_plans_cache.js`) that creates the Atlas_Cache table
2. THE Atlas_Cache table SHALL store one row per Host_ID with columns for: `host_id` (integer, unique), `has_action_plan` (integer, 0 or 1), `plan_count` (integer), `plans_json` (text, JSON array of plan summaries), and `synced_at` (datetime)
3. THE migration script SHALL create an index on the `host_id` column of the Atlas_Cache table
4. THE migration script SHALL follow the existing migration pattern: open the database at `backend/cve_database.db`, use `db.serialize()`, log progress to the console, and close the database on completion
5. WHEN the migration script is run multiple times, THE migration script SHALL complete without errors by using `CREATE TABLE IF NOT EXISTS` and `CREATE INDEX IF NOT EXISTS`
### Requirement 3: Atlas Sync Route
**User Story:** As a dashboard user, I want to trigger a manual sync of Atlas action plan data, so that the badge indicators on the ReportingPage reflect the current state of action plans in Atlas.
#### Acceptance Criteria
1. THE Atlas_Router SHALL expose a `POST /api/atlas/sync` endpoint that requires authentication and membership in the Admin or Standard_User group
2. WHEN the sync endpoint is called, THE Atlas_Router SHALL extract unique Host_ID values from the Ivanti_Cache findings
3. WHEN unique Host_ID values are extracted, THE Atlas_Router SHALL call the Atlas_API `GET /hosts/{host_id}/action-plans` endpoint for each Host_ID to retrieve action plan data
4. WHEN action plan data is retrieved for a Host_ID, THE Atlas_Router SHALL upsert the Atlas_Cache row for that Host_ID with the plan count, plan summary JSON, and current timestamp
5. WHEN the Atlas_API returns a non-2xx response for a specific Host_ID, THE Atlas_Router SHALL skip that host and continue processing remaining hosts
6. WHEN the sync completes, THE Atlas_Router SHALL return a JSON response containing the count of hosts synced, the count of hosts with action plans, and the count of hosts that failed
7. THE Atlas_Router SHALL log an audit entry for each sync operation with the initiating user and result summary
### Requirement 4: Atlas Status Route
**User Story:** As a frontend developer, I want a single endpoint that returns cached Atlas status for all hosts, so that the ReportingPage can render badges without individual API calls per row.
#### Acceptance Criteria
1. THE Atlas_Router SHALL expose a `GET /api/atlas/status` endpoint that requires authentication
2. WHEN the status endpoint is called, THE Atlas_Router SHALL return all rows from the Atlas_Cache table as a JSON array
3. THE status response SHALL include for each host: `host_id`, `has_action_plan`, `plan_count`, and `synced_at`
### Requirement 5: Atlas Action Plan Proxy Routes
**User Story:** As a dashboard user, I want to create, view, and update Atlas action plans from the STEAM Dashboard, so that I do not need to switch to the Atlas InfoSec web tool.
#### Acceptance Criteria
1. THE Atlas_Router SHALL expose a `GET /api/atlas/hosts/:hostId/action-plans` endpoint that requires authentication and proxies to the Atlas_API `GET /hosts/{host_id}/action-plans` endpoint
2. THE Atlas_Router SHALL expose a `PUT /api/atlas/hosts/:hostId/action-plans` endpoint that requires authentication and membership in the Admin or Standard_User group
3. WHEN the PUT endpoint receives a request body, THE Atlas_Router SHALL validate that `plan_type` is one of: decommission, remediation, false_positive, risk_acceptance, scan_exclusion
4. WHEN the PUT endpoint receives a request body, THE Atlas_Router SHALL validate that `commit_date` is present and is a valid date string in YYYY-MM-DD format
5. WHEN validation passes, THE Atlas_Router SHALL proxy the request body to the Atlas_API `PUT /hosts/{host_id}/action-plans` endpoint and return the Atlas_API response
6. THE Atlas_Router SHALL expose a `PATCH /api/atlas/hosts/:hostId/action-plans` endpoint that requires authentication and membership in the Admin or Standard_User group
7. WHEN the PATCH endpoint receives a request body, THE Atlas_Router SHALL validate that `action_plan_id` (string) and `updates` (object) are present
8. WHEN validation passes, THE Atlas_Router SHALL proxy the request body to the Atlas_API `PATCH /hosts/{host_id}/action-plans` endpoint and return the Atlas_API response
9. THE Atlas_Router SHALL expose a `POST /api/atlas/hosts/bulk-action-plans` endpoint that requires authentication and membership in the Admin or Standard_User group
10. WHEN the bulk endpoint receives a request body, THE Atlas_Router SHALL validate that `host_ids` is a non-empty array of integers, `plan_type` is valid, and `commit_date` is a valid YYYY-MM-DD date string
11. WHEN validation passes, THE Atlas_Router SHALL proxy the request body to the Atlas_API `POST /hosts/create-bulk-action-plans` endpoint and return the Atlas_API response
12. WHEN any proxy endpoint receives a non-2xx response from the Atlas_API, THE Atlas_Router SHALL return the Atlas_API status code and error body to the frontend
13. THE Atlas_Router SHALL log an audit entry for each create (PUT) and update (PATCH) action plan operation with the user, Host_ID, and plan type
### Requirement 6: Atlas Badge on ReportingPage
**User Story:** As a dashboard user, I want to see at a glance which hosts in the findings table have Atlas action plans, so that I can prioritize hosts that still need compliance attention.
#### Acceptance Criteria
1. WHEN the ReportingPage loads, THE Dashboard SHALL fetch cached Atlas status from `GET /api/atlas/status` and store the result in component state
2. WHEN a finding row's Host_ID matches an entry in the Atlas status data, THE Dashboard SHALL display an Atlas_Badge in the Host column next to the hostname
3. WHEN a host exists in Atlas but has zero action plans, THE Atlas_Badge SHALL display with a warning style indicating the host needs attention
4. WHEN a host exists in Atlas and has one or more active action plans, THE Atlas_Badge SHALL display with a success style indicating the host is covered
5. WHEN a finding row's Host_ID does not match any entry in the Atlas status data, THE Dashboard SHALL display no Atlas_Badge for that row
6. THE Atlas_Badge SHALL display the action plan count as text within the badge when the host has one or more plans
### Requirement 7: Atlas Slide-Out Panel
**User Story:** As a dashboard user, I want to click an Atlas badge to see full action plan details and create or update plans, so that I can manage compliance without leaving the ReportingPage.
#### Acceptance Criteria
1. WHEN a user clicks an Atlas_Badge, THE Dashboard SHALL open the Slide_Out_Panel on the right side of the ReportingPage
2. WHEN the Slide_Out_Panel opens, THE Dashboard SHALL fetch full action plan details from `GET /api/atlas/hosts/:hostId/action-plans` and display them in the panel
3. THE Slide_Out_Panel SHALL display each existing action plan with: plan type, commit date, status, and any associated VNR or EXC reference numbers
4. WHEN the user is in the Admin or Standard_User group, THE Slide_Out_Panel SHALL display a form to create a new action plan with fields for: plan type (dropdown selector), commit date (date picker), qualys_id (optional text input), active_host_findings_id (optional numeric input), jira_vnr (optional text input), and archer_exc (optional text input)
5. WHEN the user submits the create form with valid data, THE Dashboard SHALL send a PUT request to `/api/atlas/hosts/:hostId/action-plans` and refresh the plan list on success
6. WHEN the user is in the Admin or Standard_User group, THE Slide_Out_Panel SHALL provide an edit capability for existing action plans
7. WHEN the user submits an update with valid data, THE Dashboard SHALL send a PATCH request to `/api/atlas/hosts/:hostId/action-plans` and refresh the plan list on success
8. WHEN the Atlas_API returns an error for a create or update operation, THE Slide_Out_Panel SHALL display the error message to the user
9. WHEN the user clicks outside the Slide_Out_Panel or clicks a close button, THE Dashboard SHALL close the panel
10. WHEN the user is in the Viewer group, THE Slide_Out_Panel SHALL display existing plans in read-only mode without the create or edit forms
### Requirement 8: Atlas Sync Button on ReportingPage
**User Story:** As a dashboard user, I want a manual sync button for Atlas data on the ReportingPage, so that I can refresh action plan status on demand.
#### Acceptance Criteria
1. THE Dashboard SHALL display an Atlas sync button on the ReportingPage near the existing Ivanti sync button
2. WHEN the user is in the Admin or Standard_User group, THE Atlas sync button SHALL be enabled
3. WHEN the user is in the Viewer group, THE Atlas sync button SHALL be disabled with a tooltip indicating insufficient permissions
4. WHEN the user clicks the Atlas sync button, THE Dashboard SHALL send a POST request to `/api/atlas/sync`
5. WHILE the Atlas sync is in progress, THE Atlas sync button SHALL display a loading indicator and be disabled to prevent duplicate requests
6. WHEN the Atlas sync completes successfully, THE Dashboard SHALL refresh the Atlas status data and update all Atlas_Badge indicators on the page
7. WHEN the Atlas sync fails, THE Dashboard SHALL display an error notification with the failure reason
### Requirement 9: Environment Configuration
**User Story:** As a system administrator, I want Atlas API credentials and configuration documented alongside existing environment variables, so that deployment setup is straightforward.
#### Acceptance Criteria
1. THE Backend SHALL read the Atlas_API base URL from the `ATLAS_API_URL` environment variable
2. THE Backend SHALL read the Atlas service account username from the `ATLAS_API_USER` environment variable
3. THE Backend SHALL read the Atlas service account password from the `ATLAS_API_PASS` environment variable
4. THE Backend SHALL read the TLS verification skip flag from the `ATLAS_SKIP_TLS` environment variable
5. THE Backend SHALL document all four Atlas environment variables in `backend/.env.example` with descriptive comments
### Requirement 10: Access Control
**User Story:** As a security administrator, I want Atlas operations restricted by user group, so that only authorized users can modify action plans or trigger syncs.
#### Acceptance Criteria
1. THE Atlas_Router SHALL allow all authenticated users to access the `GET /api/atlas/status` endpoint
2. THE Atlas_Router SHALL allow all authenticated users to access the `GET /api/atlas/hosts/:hostId/action-plans` endpoint
3. THE Atlas_Router SHALL restrict the `POST /api/atlas/sync` endpoint to users in the Admin or Standard_User group
4. THE Atlas_Router SHALL restrict the `PUT /api/atlas/hosts/:hostId/action-plans` endpoint to users in the Admin or Standard_User group
5. THE Atlas_Router SHALL restrict the `PATCH /api/atlas/hosts/:hostId/action-plans` endpoint to users in the Admin or Standard_User group
6. THE Atlas_Router SHALL restrict the `POST /api/atlas/hosts/bulk-action-plans` endpoint to users in the Admin or Standard_User group
7. WHEN an unauthorized user attempts a restricted operation, THE Atlas_Router SHALL return HTTP 403 with an error message indicating insufficient permissions

View File

@@ -0,0 +1,262 @@
# Implementation Plan: Atlas Action Plans Integration
## Overview
Integrate the Atlas InfoSec action plans API into the STEAM Security Dashboard. The implementation follows the existing proxy-and-cache pattern — backend helper for HTTP communication, SQLite cache for fast page loads, Express routes for proxied CRUD, and React frontend components for badge display and plan management. Tasks are ordered for incremental progress: environment config, backend helper, migration, routes, server wiring, then frontend components.
## Tasks
- [x] 1. Add Atlas environment variables to `.env.example`
- Append `ATLAS_API_URL`, `ATLAS_API_USER`, `ATLAS_API_PASS`, and `ATLAS_SKIP_TLS` to `backend/.env.example` with descriptive comments, following the existing Ivanti variable block pattern
- _Requirements: 9.1, 9.2, 9.3, 9.4, 9.5_
- [ ] 2. Implement Atlas API helper module
- [x] 2.1 Create `backend/helpers/atlasApi.js` with `atlasRequest`, `atlasGet`, `atlasPut`, `atlasPatch`, `atlasPost` functions
- Read `ATLAS_API_URL`, `ATLAS_API_USER`, `ATLAS_API_PASS`, `ATLAS_SKIP_TLS` from `process.env` at module load
- Compute `Authorization: Basic <base64(user:pass)>` header at request time
- Use Node.js `https` module following the `ivantiApi.js` pattern
- Support GET, PUT, PATCH, POST methods with `rejectUnauthorized` controlled by `ATLAS_SKIP_TLS`
- Default timeout 15s for single-host endpoints, 60s via `options.timeout` for bulk
- Resolve non-2xx responses with `{ status, body }` without throwing
- Reject on network errors/timeouts with a message containing the HTTP method and URL path
- Log a warning at module load if required env vars are missing
- _Requirements: 1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 1.7, 1.8_
- [ ] 2.2 Write property test: Basic Auth header round-trip
- **Property 1: Basic Auth header round-trip**
- Generate random (username, password) string pairs, verify base64 decode yields `username:password`
- **Validates: Requirements 1.2**
- [ ] 2.3 Write property test: Non-2xx responses resolve with status and body
- **Property 2: Non-2xx responses resolve with status and body**
- Generate integers 400599 and random body strings, verify promise resolves with `{ status, body }`
- **Validates: Requirements 1.7**
- [ ] 2.4 Write property test: Error messages contain method and path
- **Property 3: Error messages contain method and path**
- Generate random method names and URL path strings, verify rejection message includes both
- **Validates: Requirements 1.8**
- [x] 3. Checkpoint — Verify Atlas helper module
- Ensure all tests pass, ask the user if questions arise.
- [ ] 4. Create Atlas cache migration
- [x] 4.1 Create `backend/migrations/add_atlas_action_plans_cache.js`
- Follow the existing migration pattern from `add_ivanti_findings_tables.js`: open `backend/cve_database.db`, use `db.serialize()`, log progress, close on completion
- Create `atlas_action_plans_cache` table with columns: `id` (INTEGER PRIMARY KEY AUTOINCREMENT), `host_id` (INTEGER NOT NULL UNIQUE), `has_action_plan` (INTEGER NOT NULL DEFAULT 0), `plan_count` (INTEGER NOT NULL DEFAULT 0), `plans_json` (TEXT NOT NULL DEFAULT '[]'), `synced_at` (DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP)
- Create index `idx_atlas_cache_host_id` on `host_id`
- Use `CREATE TABLE IF NOT EXISTS` and `CREATE INDEX IF NOT EXISTS` for idempotency
- _Requirements: 2.1, 2.2, 2.3, 2.4, 2.5_
- [ ]* 4.2 Write unit tests for migration idempotency
- Verify migration runs twice without errors
- Verify table and index exist after migration
- _Requirements: 2.5_
- [ ] 5. Implement Atlas router with all endpoints
- [x] 5.1 Create `backend/routes/atlas.js` with `createAtlasRouter(db, requireAuth)` factory function
- Import `requireGroup` from `../middleware/auth`, `logAudit` from `../helpers/auditLog`, and Atlas helper functions from `../helpers/atlasApi`
- Check Atlas helper availability; return 503 if Atlas is not configured
- _Requirements: 10.1, 10.2, 10.3, 10.4, 10.5, 10.6, 10.7_
- [x] 5.2 Implement `GET /status` endpoint
- Require authentication (any group)
- Return all rows from `atlas_action_plans_cache` as JSON array with `host_id`, `has_action_plan`, `plan_count`, `synced_at`
- _Requirements: 4.1, 4.2, 4.3, 10.1_
- [x] 5.3 Implement `POST /sync` endpoint
- Require authentication and Admin or Standard_User group
- Extract unique non-null `hostId` values from `ivanti_findings_cache.findings_json`
- Call `atlasGet('/hosts/' + hostId + '/action-plans')` for each host with concurrency limit of 5 using `Promise.allSettled`
- On 2xx: upsert cache row with `plan_count`, `has_action_plan`, `plans_json`, and current timestamp
- On non-2xx: increment failure counter, log warning, continue
- Return `{ synced, withPlans, failed }` summary
- Log audit entry with initiating user and result summary
- _Requirements: 3.1, 3.2, 3.3, 3.4, 3.5, 3.6, 3.7, 10.3_
- [x] 5.4 Implement `GET /hosts/:hostId/action-plans` proxy endpoint
- Require authentication (any group)
- Validate `hostId` is a positive integer
- Proxy to Atlas API `GET /hosts/{host_id}/action-plans` and return the response
- Forward non-2xx Atlas responses to the client
- _Requirements: 5.1, 5.12, 10.2_
- [x] 5.5 Implement `PUT /hosts/:hostId/action-plans` proxy endpoint
- Require authentication and Admin or Standard_User group
- Validate `hostId` is a positive integer
- Validate `plan_type` is one of: decommission, remediation, false_positive, risk_acceptance, scan_exclusion
- Validate `commit_date` is present and matches `YYYY-MM-DD` format
- Proxy validated body to Atlas API `PUT /hosts/{host_id}/action-plans`
- Log audit entry with user, hostId, and plan type
- _Requirements: 5.2, 5.3, 5.4, 5.5, 5.12, 5.13, 10.4_
- [x] 5.6 Implement `PATCH /hosts/:hostId/action-plans` proxy endpoint
- Require authentication and Admin or Standard_User group
- Validate `action_plan_id` is a non-empty string and `updates` is a non-null object
- Proxy validated body to Atlas API `PATCH /hosts/{host_id}/action-plans`
- Log audit entry with user, hostId, and plan type
- _Requirements: 5.6, 5.7, 5.8, 5.12, 5.13, 10.5_
- [x] 5.7 Implement `POST /hosts/bulk-action-plans` proxy endpoint
- Require authentication and Admin or Standard_User group
- Validate `host_ids` is a non-empty array of positive integers, `plan_type` is valid, `commit_date` matches `YYYY-MM-DD`
- Proxy validated body to Atlas API `POST /hosts/create-bulk-action-plans`
- _Requirements: 5.9, 5.10, 5.11, 5.12, 10.6_
- [ ] 5.8 Write property test: Unique host ID extraction
- **Property 4: Unique host ID extraction**
- Generate arrays of finding objects with optional numeric `hostId` fields, verify extracted set has no duplicates and no nulls
- **Validates: Requirements 3.2**
- [ ] 5.9 Write property test: Cache upsert derives correct plan_count and has_action_plan
- **Property 5: Cache upsert derives correct plan_count and has_action_plan**
- Generate (hostId, planArray) pairs, verify `plan_count` equals array length and `has_action_plan` equals 1 if non-empty, 0 otherwise
- **Validates: Requirements 3.4**
- [ ] 5.10 Write property test: Sync response count invariant
- **Property 6: Sync response count invariant**
- Generate (totalHosts, failureCount) pairs, verify `synced + failed = totalHosts` and `withPlans <= synced`
- **Validates: Requirements 3.6**
- [ ] 5.11 Write property test: Status endpoint returns all cached rows with required fields
- **Property 7: Status endpoint returns all cached rows with required fields**
- Generate N cache rows, insert into DB, verify response count and field presence
- **Validates: Requirements 4.2, 4.3**
- [ ] 5.12 Write property test: plan_type validation
- **Property 8: plan_type validation**
- Generate random strings, verify acceptance if and only if string is one of the five valid types
- **Validates: Requirements 5.3**
- [ ] 5.13 Write property test: commit_date validation
- **Property 9: commit_date validation**
- Generate random strings, verify acceptance if and only if string matches `YYYY-MM-DD` pattern
- **Validates: Requirements 5.4**
- [ ] 5.14 Write property test: PATCH body validation
- **Property 10: PATCH body validation**
- Generate random objects with varying field presence, verify acceptance if and only if `action_plan_id` is a non-empty string and `updates` is a non-null object
- **Validates: Requirements 5.7**
- [ ] 5.15 Write property test: Bulk request validation
- **Property 11: Bulk request validation**
- Generate objects with varying `host_ids`, `plan_type`, `commit_date`, verify acceptance matches combined validation rules
- **Validates: Requirements 5.10**
- [ ]* 5.16 Write property test: Non-2xx Atlas response passthrough
- **Property 12: Non-2xx Atlas response passthrough**
- Generate non-2xx status codes and body strings, verify proxy route returns same status and body
- **Validates: Requirements 5.12**
- [x] 6. Checkpoint — Verify backend routes and properties
- Ensure all tests pass, ask the user if questions arise.
- [x] 7. Mount Atlas router in server.js
- Add `const createAtlasRouter = require('./routes/atlas');` import alongside existing route imports
- Add `app.use('/api/atlas', createAtlasRouter(db, requireAuth));` mount alongside existing route mounts
- _Requirements: 3.1, 4.1, 5.1, 5.2, 5.6, 5.9_
- [ ] 8. Implement frontend Atlas components
- [x] 8.1 Create AtlasBadge component in `frontend/src/components/AtlasBadge.js`
- Accept props: `{ hostId, atlasStatus, onClick }`
- Render nothing if `atlasStatus` is undefined (host not in cache)
- Render warning badge (amber border, "0" text) if `has_action_plan === 0`
- Render success badge (emerald border, plan count text) if `plan_count > 0`
- Use design system badge pattern: monospace font, 0.58rem, inline-flex, pill shape
- _Requirements: 6.2, 6.3, 6.4, 6.5, 6.6_
- [ ]* 8.2 Write property test: Badge visibility and content
- **Property 13: Badge visibility and content**
- Generate findings with `hostId` and atlas status maps, verify badge renders if and only if `hostId` exists in map, and badge text contains plan count when `plan_count > 0`
- **Validates: Requirements 6.2, 6.5, 6.6**
- [x] 8.3 Create AtlasSlideOutPanel component in `frontend/src/components/AtlasSlideOutPanel.js`
- Accept props: `{ hostId, hostName, onClose, canWrite }`
- Fetch action plans from `GET /api/atlas/hosts/:hostId/action-plans` on open
- Display header with hostname, host ID, and close button
- Display each plan with: plan type, commit date, status, VNR/EXC references
- Show create form (plan type dropdown, commit date picker, optional fields: qualys_id, active_host_findings_id, jira_vnr, archer_exc) when `canWrite` is true
- Submit create via `PUT /api/atlas/hosts/:hostId/action-plans`, refresh plan list on success
- Show inline edit capability for existing plans when `canWrite` is true
- Submit updates via `PATCH /api/atlas/hosts/:hostId/action-plans`, refresh plan list on success
- Display error messages from Atlas API inline in the panel
- Close on backdrop click or close button
- Hide create/edit forms for Viewer group (read-only mode)
- _Requirements: 7.1, 7.2, 7.3, 7.4, 7.5, 7.6, 7.7, 7.8, 7.9, 7.10_
- [ ]* 8.4 Write property test: Panel displays all plan fields
- **Property 14: Panel displays all plan fields**
- Generate plan objects with `plan_type`, `commit_date`, `status`, and optional reference fields, verify all non-null field values appear in rendered output
- **Validates: Requirements 7.3**
- [ ] 8.5 Write unit tests for AtlasBadge and AtlasSlideOutPanel
- Test AtlasBadge renders nothing when host not in status map
- Test AtlasBadge renders warning style when `plan_count` is 0
- Test AtlasBadge renders success style when `plan_count > 0`
- Test AtlasSlideOutPanel shows create form for Admin/Standard_User
- Test AtlasSlideOutPanel hides create form for Viewer
- _Requirements: 6.2, 6.3, 6.4, 6.5, 7.4, 7.10_
- [ ] 9. Integrate Atlas badge and sync button into ReportingPage
- [x] 9.1 Add Atlas status state and fetch to ReportingPage
- Add `atlasStatus` state (Map keyed by hostId), `atlasSyncing` boolean, `atlasError` string
- Fetch `GET /api/atlas/status` on mount and build the status map
- _Requirements: 6.1_
- [x] 9.2 Render AtlasBadge in Host column cells
- In the Host column cell renderer, check `atlasStatus` map for the finding's `hostId`
- Render AtlasBadge inline after the hostname text when a match exists
- Wire badge `onClick` to open the AtlasSlideOutPanel with the host's ID and name
- _Requirements: 6.2, 6.3, 6.4, 6.5, 6.6, 7.1_
- [x] 9.3 Add Atlas sync button to ReportingPage toolbar
- Place adjacent to existing Ivanti sync button, using same styling pattern (RefreshCw icon, monospace uppercase text, sky blue accent)
- Differentiate with Database icon prefix and "Atlas" label
- Enable for Admin and Standard_User groups, disable for Viewer with tooltip
- On click: send `POST /api/atlas/sync`, show loading indicator, disable button
- On success: re-fetch `GET /api/atlas/status` and update all badges
- On failure: display error notification
- _Requirements: 8.1, 8.2, 8.3, 8.4, 8.5, 8.6, 8.7_
- [x] 9.4 Wire AtlasSlideOutPanel into ReportingPage
- Add state for selected host (`atlasSelectedHostId`, `atlasSelectedHostName`, `atlasPanelOpen`)
- Render AtlasSlideOutPanel conditionally when `atlasPanelOpen` is true
- Pass `canWrite` based on user group (Admin or Standard_User)
- On panel close: clear selected host state
- On plan create/update success: re-fetch atlas status to update badges
- _Requirements: 7.1, 7.2, 7.9, 7.10_
- [x] 10. Checkpoint — Verify full integration
- Ensure all tests pass, ask the user if questions arise.
- [ ] 11. Write integration tests
- [ ]* 11.1 Write integration tests for Atlas sync flow
- Populate Ivanti cache with test findings, trigger sync with mocked Atlas API, verify Atlas cache populated correctly
- Test error resilience: mix of successful and failing hosts during sync
- _Requirements: 3.2, 3.3, 3.4, 3.5, 3.6_
- [ ]* 11.2 Write integration tests for Atlas proxy routes
- Test create plan flow: send PUT, verify Atlas API called, verify audit logged
- Test update plan flow: send PATCH, verify Atlas API called, verify audit logged
- Test bulk create flow: send POST, verify Atlas API called with correct body
- _Requirements: 5.1, 5.2, 5.5, 5.6, 5.8, 5.11, 5.13_
- [ ] 11.3 Write integration tests for access control
- Verify 401 for unauthenticated requests on all endpoints
- Verify 403 for Viewer group on restricted endpoints (sync, PUT, PATCH, bulk POST)
- Verify 200 for Viewer group on read endpoints (status, GET plans)
- _Requirements: 10.1, 10.2, 10.3, 10.4, 10.5, 10.6, 10.7_
- [x] 12. Final checkpoint — Ensure all tests pass
- Ensure all tests pass, ask the user if questions arise.
## Notes
- Tasks marked with `*` are optional and can be skipped for faster MVP
- Each task references specific requirements for traceability
- Checkpoints ensure incremental validation
- Property tests validate universal correctness properties from the design document using fast-check
- Unit tests validate specific examples and edge cases
- The backend follows the existing factory function pattern (`createAtlasRouter(db, requireAuth)`)
- The Atlas helper follows the existing `ivantiApi.js` pattern (promise-based HTTP with Node.js `https` module)
- The migration follows the existing pattern from `add_ivanti_findings_tables.js`

View File

@@ -15,3 +15,11 @@ IVANTI_FIRST_NAME=
IVANTI_LAST_NAME= IVANTI_LAST_NAME=
# Set to true if behind Charter's SSL inspection proxy (replicates Python verify=False) # Set to true if behind Charter's SSL inspection proxy (replicates Python verify=False)
IVANTI_SKIP_TLS=false IVANTI_SKIP_TLS=false
# Atlas InfoSec API (atlas-infosec.caas.charterlab.com)
# Service account credentials for Basic Auth — used to sync and manage action plans
ATLAS_API_URL=
ATLAS_API_USER=
ATLAS_API_PASS=
# Set to true if behind Charter's SSL inspection proxy (disables TLS cert verification)
ATLAS_SKIP_TLS=false

104
backend/helpers/atlasApi.js Normal file
View File

@@ -0,0 +1,104 @@
// Shared Atlas InfoSec API helpers
// Centralizes HTTP calls so the atlas router uses a single implementation.
// Follows the same promise-based pattern as ivantiApi.js.
const https = require('https');
const http = require('http');
// ---------------------------------------------------------------------------
// Configuration — read from process.env at module load
// ---------------------------------------------------------------------------
const ATLAS_API_URL = process.env.ATLAS_API_URL || '';
const ATLAS_API_USER = process.env.ATLAS_API_USER || '';
const ATLAS_API_PASS = process.env.ATLAS_API_PASS || '';
const ATLAS_SKIP_TLS = process.env.ATLAS_SKIP_TLS === 'true';
const requiredVars = ['ATLAS_API_URL', 'ATLAS_API_USER', 'ATLAS_API_PASS'];
const missingVars = requiredVars.filter((v) => !process.env[v]);
if (missingVars.length > 0) {
console.warn(`[atlas-api] WARNING: Missing required environment variables: ${missingVars.join(', ')}. Atlas API calls will fail.`);
}
const isConfigured = missingVars.length === 0;
// ---------------------------------------------------------------------------
// Generic request — supports GET, PUT, PATCH, POST
// ---------------------------------------------------------------------------
function atlasRequest(method, urlPath, body, options) {
const timeout = (options && options.timeout) || 15000;
const authString = Buffer.from(ATLAS_API_USER + ':' + ATLAS_API_PASS).toString('base64');
const fullUrl = new URL(ATLAS_API_URL + urlPath);
const isHttps = fullUrl.protocol === 'https:';
const transport = isHttps ? https : http;
const headers = {
'accept': 'application/json',
'authorization': 'Basic ' + authString
};
let bodyStr = null;
if (body !== null && body !== undefined) {
bodyStr = JSON.stringify(body);
headers['content-type'] = 'application/json';
headers['content-length'] = Buffer.byteLength(bodyStr);
}
return new Promise((resolve, reject) => {
const reqOptions = {
hostname: fullUrl.hostname,
port: fullUrl.port || (isHttps ? 443 : 80),
path: fullUrl.pathname + fullUrl.search,
method: method,
headers: headers,
timeout: timeout
};
if (isHttps) {
reqOptions.rejectUnauthorized = !ATLAS_SKIP_TLS;
}
const req = transport.request(reqOptions, (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(method + ' ' + urlPath + ' timed out')));
req.on('error', (err) => {
reject(new Error(method + ' ' + urlPath + ' failed: ' + err.message));
});
if (bodyStr) {
req.write(bodyStr);
}
req.end();
});
}
// ---------------------------------------------------------------------------
// Convenience wrappers
// ---------------------------------------------------------------------------
function atlasGet(urlPath, options) {
return atlasRequest('GET', urlPath, null, options);
}
function atlasPut(urlPath, body, options) {
return atlasRequest('PUT', urlPath, body, options);
}
function atlasPatch(urlPath, body, options) {
return atlasRequest('PATCH', urlPath, body, options);
}
function atlasPost(urlPath, body, options) {
return atlasRequest('POST', urlPath, body, options);
}
module.exports = {
isConfigured,
atlasRequest,
atlasGet,
atlasPut,
atlasPatch,
atlasPost
};

View File

@@ -0,0 +1,37 @@
// Migration: Add atlas_action_plans_cache 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 Atlas action plans cache migration...');
db.serialize(() => {
// Cache table — one row per host, holding cached Atlas action plan status
db.run(`
CREATE TABLE IF NOT EXISTS atlas_action_plans_cache (
id INTEGER PRIMARY KEY AUTOINCREMENT,
host_id INTEGER NOT NULL UNIQUE,
has_action_plan INTEGER NOT NULL DEFAULT 0,
plan_count INTEGER NOT NULL DEFAULT 0,
plans_json TEXT NOT NULL DEFAULT '[]',
synced_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
)
`, (err) => {
if (err) console.error('Error creating atlas_action_plans_cache table:', err);
else console.log('✓ atlas_action_plans_cache table created');
});
db.run(`
CREATE INDEX IF NOT EXISTS idx_atlas_cache_host_id
ON atlas_action_plans_cache(host_id)
`, (err) => {
if (err) console.error('Error creating host_id index:', err);
else console.log('✓ idx_atlas_cache_host_id index created');
});
});
db.close(() => {
console.log('Migration complete!');
});

409
backend/routes/atlas.js Normal file
View File

@@ -0,0 +1,409 @@
// Atlas InfoSec Action Plans Routes
// Proxies CRUD operations to the Atlas API and maintains a local SQLite cache
// for fast badge rendering on the ReportingPage.
const express = require('express');
const { requireGroup } = require('../middleware/auth');
const logAudit = require('../helpers/auditLog');
const { isConfigured, atlasGet, atlasPut, atlasPatch, atlasPost } = require('../helpers/atlasApi');
const VALID_PLAN_TYPES = ['decommission', 'remediation', 'false_positive', 'risk_acceptance', 'scan_exclusion'];
const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
// ---------------------------------------------------------------------------
// DB helpers — promise wrappers for callback-based SQLite API
// ---------------------------------------------------------------------------
function dbRun(db, sql, params = []) {
return new Promise((resolve, reject) => {
db.run(sql, params, function (err) { if (err) reject(err); else resolve(this); });
});
}
function dbGet(db, sql, params = []) {
return new Promise((resolve, reject) => {
db.get(sql, params, (err, row) => { if (err) reject(err); else resolve(row); });
});
}
function dbAll(db, sql, params = []) {
return new Promise((resolve, reject) => {
db.all(sql, params, (err, rows) => { if (err) reject(err); else resolve(rows || []); });
});
}
// ---------------------------------------------------------------------------
// Router factory
// ---------------------------------------------------------------------------
function createAtlasRouter(db, requireAuth) {
const router = express.Router();
// -----------------------------------------------------------------------
// GET /status
// Return all cached Atlas rows for badge rendering.
// Auth: any authenticated user
// -----------------------------------------------------------------------
router.get('/status', requireAuth(db), async (req, res) => {
if (!isConfigured) {
return res.status(503).json({ error: 'Atlas API is not configured. Check ATLAS_API_URL, ATLAS_API_USER, and ATLAS_API_PASS environment variables.' });
}
try {
const rows = await dbAll(db,
`SELECT host_id, has_action_plan, plan_count, synced_at FROM atlas_action_plans_cache`
);
res.json(rows);
} catch (err) {
console.error('[Atlas] Error fetching status:', err.message);
res.status(500).json({ error: 'Failed to fetch Atlas status.' });
}
});
// -----------------------------------------------------------------------
// POST /sync
// Sync Atlas action plan data for all hosts found in the Ivanti cache.
// Auth: Admin or Standard_User
// -----------------------------------------------------------------------
router.post('/sync', requireAuth(db), requireGroup('Admin', 'Standard_User'), async (req, res) => {
if (!isConfigured) {
return res.status(503).json({ error: 'Atlas API is not configured. Check ATLAS_API_URL, ATLAS_API_USER, and ATLAS_API_PASS environment variables.' });
}
try {
// 1. Read Ivanti findings cache and extract unique non-null hostIds
const cacheRow = await dbGet(db, `SELECT findings_json FROM ivanti_findings_cache WHERE id = 1`);
if (!cacheRow || !cacheRow.findings_json) {
return res.json({ synced: 0, withPlans: 0, failed: 0 });
}
let findings;
try {
findings = JSON.parse(cacheRow.findings_json);
} catch (parseErr) {
return res.status(500).json({ error: 'Failed to parse Ivanti findings cache.' });
}
const hostIdSet = new Set();
for (const f of findings) {
if (f.hostId != null && typeof f.hostId === 'number' && f.hostId > 0) {
hostIdSet.add(f.hostId);
}
}
const hostIds = [...hostIdSet];
if (hostIds.length === 0) {
return res.json({ synced: 0, withPlans: 0, failed: 0 });
}
// 2. Process hosts in batches of 5 concurrent requests
let synced = 0;
let withPlans = 0;
let failed = 0;
const BATCH_SIZE = 5;
for (let i = 0; i < hostIds.length; i += BATCH_SIZE) {
const batch = hostIds.slice(i, i + BATCH_SIZE);
const results = await Promise.allSettled(
batch.map(async (hostId) => {
const result = await atlasGet('/hosts/' + hostId + '/action-plans');
return { hostId, result };
})
);
for (const settled of results) {
if (settled.status === 'rejected') {
failed++;
console.warn('[Atlas Sync] Request failed for host:', settled.reason?.message || settled.reason);
continue;
}
const { hostId, result } = settled.value;
if (result.status >= 200 && result.status < 300) {
let allPlans = [];
let activePlans = [];
try {
const parsed = JSON.parse(result.body);
// Atlas returns { active: [...], inactive: [...] }
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
activePlans = Array.isArray(parsed.active) ? parsed.active : [];
const inactive = Array.isArray(parsed.inactive) ? parsed.inactive : [];
allPlans = [...activePlans, ...inactive];
} else if (Array.isArray(parsed)) {
allPlans = parsed;
activePlans = parsed;
}
} catch (e) {
allPlans = [];
activePlans = [];
}
// Badge counts only active plans — inactive are historical
const planCount = activePlans.length;
const hasActionPlan = planCount > 0 ? 1 : 0;
try {
await dbRun(db,
`INSERT INTO atlas_action_plans_cache (host_id, has_action_plan, plan_count, plans_json, synced_at)
VALUES (?, ?, ?, ?, datetime('now'))
ON CONFLICT(host_id) DO UPDATE SET
has_action_plan = excluded.has_action_plan,
plan_count = excluded.plan_count,
plans_json = excluded.plans_json,
synced_at = excluded.synced_at`,
[hostId, hasActionPlan, planCount, JSON.stringify(allPlans)]
);
} catch (dbErr) {
console.error('[Atlas Sync] DB upsert failed for host', hostId, ':', dbErr.message);
}
synced++;
if (hasActionPlan) withPlans++;
} else {
failed++;
console.warn(`[Atlas Sync] Non-2xx response for host ${hostId}: status ${result.status}`);
}
}
}
// 3. Log audit entry
logAudit(db, {
userId: req.user.id,
username: req.user.username,
action: 'ATLAS_SYNC',
entityType: 'atlas_action_plans',
entityId: null,
details: { synced, withPlans, failed, totalHosts: hostIds.length },
ipAddress: req.ip
});
res.json({ synced, withPlans, failed });
} catch (err) {
console.error('[Atlas Sync] Unexpected error:', err.message);
res.status(500).json({ error: 'Atlas sync failed: ' + err.message });
}
});
// -----------------------------------------------------------------------
// GET /hosts/:hostId/action-plans
// Proxy to Atlas API — returns live action plan data for a single host.
// Auth: any authenticated user
// -----------------------------------------------------------------------
router.get('/hosts/:hostId/action-plans', requireAuth(db), async (req, res) => {
if (!isConfigured) {
return res.status(503).json({ error: 'Atlas API is not configured. Check ATLAS_API_URL, ATLAS_API_USER, and ATLAS_API_PASS environment variables.' });
}
const hostId = parseInt(req.params.hostId, 10);
if (!Number.isInteger(hostId) || hostId <= 0) {
return res.status(400).json({ error: 'hostId must be a positive integer' });
}
try {
const result = await atlasGet('/hosts/' + hostId + '/action-plans');
if (result.status >= 200 && result.status < 300) {
let body;
try {
body = JSON.parse(result.body);
} catch (e) {
body = result.body;
}
res.status(result.status).json(body);
} else {
// Forward non-2xx Atlas responses to the client
let errorBody;
try {
errorBody = JSON.parse(result.body);
} catch (e) {
errorBody = { error: result.body };
}
res.status(result.status).json(errorBody);
}
} catch (err) {
console.error('[Atlas] GET action-plans failed for host', hostId, ':', err.message);
res.status(502).json({ error: 'Failed to reach Atlas API: ' + err.message });
}
});
// -----------------------------------------------------------------------
// PUT /hosts/:hostId/action-plans
// Create a new action plan for a host.
// Auth: Admin or Standard_User
// -----------------------------------------------------------------------
router.put('/hosts/:hostId/action-plans', requireAuth(db), requireGroup('Admin', 'Standard_User'), async (req, res) => {
if (!isConfigured) {
return res.status(503).json({ error: 'Atlas API is not configured. Check ATLAS_API_URL, ATLAS_API_USER, and ATLAS_API_PASS environment variables.' });
}
const hostId = parseInt(req.params.hostId, 10);
if (!Number.isInteger(hostId) || hostId <= 0) {
return res.status(400).json({ error: 'hostId must be a positive integer' });
}
const { plan_type, commit_date } = req.body || {};
if (!plan_type || !VALID_PLAN_TYPES.includes(plan_type)) {
return res.status(400).json({ error: 'plan_type must be one of: ' + VALID_PLAN_TYPES.join(', ') });
}
if (!commit_date || !DATE_PATTERN.test(commit_date)) {
return res.status(400).json({ error: 'commit_date must be a valid YYYY-MM-DD date string' });
}
try {
const result = await atlasPut('/hosts/' + hostId + '/action-plans', req.body);
logAudit(db, {
userId: req.user.id,
username: req.user.username,
action: 'ATLAS_CREATE_PLAN',
entityType: 'atlas_action_plan',
entityId: String(hostId),
details: { hostId, plan_type, commit_date },
ipAddress: req.ip
});
if (result.status >= 200 && result.status < 300) {
let body;
try {
body = JSON.parse(result.body);
} catch (e) {
body = result.body;
}
res.status(result.status).json(body);
} else {
let errorBody;
try {
errorBody = JSON.parse(result.body);
} catch (e) {
errorBody = { error: result.body };
}
res.status(result.status).json(errorBody);
}
} catch (err) {
console.error('[Atlas] PUT action-plans failed for host', hostId, ':', err.message);
res.status(502).json({ error: 'Failed to reach Atlas API: ' + err.message });
}
});
// -----------------------------------------------------------------------
// PATCH /hosts/:hostId/action-plans
// Update an existing action plan for a host.
// Auth: Admin or Standard_User
// -----------------------------------------------------------------------
router.patch('/hosts/:hostId/action-plans', requireAuth(db), requireGroup('Admin', 'Standard_User'), async (req, res) => {
if (!isConfigured) {
return res.status(503).json({ error: 'Atlas API is not configured. Check ATLAS_API_URL, ATLAS_API_USER, and ATLAS_API_PASS environment variables.' });
}
const hostId = parseInt(req.params.hostId, 10);
if (!Number.isInteger(hostId) || hostId <= 0) {
return res.status(400).json({ error: 'hostId must be a positive integer' });
}
const { action_plan_id, updates } = req.body || {};
if (!action_plan_id || typeof action_plan_id !== 'string' || action_plan_id.trim() === '') {
return res.status(400).json({ error: 'action_plan_id is required and must be a non-empty string' });
}
if (!updates || typeof updates !== 'object' || Array.isArray(updates)) {
return res.status(400).json({ error: 'updates is required and must be an object' });
}
try {
const result = await atlasPatch('/hosts/' + hostId + '/action-plans', req.body);
logAudit(db, {
userId: req.user.id,
username: req.user.username,
action: 'ATLAS_UPDATE_PLAN',
entityType: 'atlas_action_plan',
entityId: String(hostId),
details: { hostId, action_plan_id },
ipAddress: req.ip
});
if (result.status >= 200 && result.status < 300) {
let body;
try {
body = JSON.parse(result.body);
} catch (e) {
body = result.body;
}
res.status(result.status).json(body);
} else {
let errorBody;
try {
errorBody = JSON.parse(result.body);
} catch (e) {
errorBody = { error: result.body };
}
res.status(result.status).json(errorBody);
}
} catch (err) {
console.error('[Atlas] PATCH action-plans failed for host', hostId, ':', err.message);
res.status(502).json({ error: 'Failed to reach Atlas API: ' + err.message });
}
});
// -----------------------------------------------------------------------
// POST /hosts/bulk-action-plans
// Create action plans for multiple hosts at once.
// Auth: Admin or Standard_User
// -----------------------------------------------------------------------
router.post('/hosts/bulk-action-plans', requireAuth(db), requireGroup('Admin', 'Standard_User'), async (req, res) => {
if (!isConfigured) {
return res.status(503).json({ error: 'Atlas API is not configured. Check ATLAS_API_URL, ATLAS_API_USER, and ATLAS_API_PASS environment variables.' });
}
const { host_ids, plan_type, commit_date } = req.body || {};
if (!Array.isArray(host_ids) || host_ids.length === 0) {
return res.status(400).json({ error: 'host_ids must be a non-empty array of positive integers' });
}
for (const id of host_ids) {
if (!Number.isInteger(id) || id <= 0) {
return res.status(400).json({ error: 'host_ids must be a non-empty array of positive integers' });
}
}
if (!plan_type || !VALID_PLAN_TYPES.includes(plan_type)) {
return res.status(400).json({ error: 'plan_type must be one of: ' + VALID_PLAN_TYPES.join(', ') });
}
if (!commit_date || !DATE_PATTERN.test(commit_date)) {
return res.status(400).json({ error: 'commit_date must be a valid YYYY-MM-DD date string' });
}
try {
const result = await atlasPost('/hosts/create-bulk-action-plans', req.body);
if (result.status >= 200 && result.status < 300) {
let body;
try {
body = JSON.parse(result.body);
} catch (e) {
body = result.body;
}
res.status(result.status).json(body);
} else {
let errorBody;
try {
errorBody = JSON.parse(result.body);
} catch (e) {
errorBody = { error: result.body };
}
res.status(result.status).json(errorBody);
}
} catch (err) {
console.error('[Atlas] POST bulk-action-plans failed:', err.message);
res.status(502).json({ error: 'Failed to reach Atlas API: ' + err.message });
}
});
return router;
}
module.exports = createAtlasRouter;

View File

@@ -397,6 +397,7 @@ function extractFinding(f) {
return { return {
id: String(f.id), id: String(f.id),
hostId: f.host?.hostId || null,
title: f.title || '', title: f.title || '',
severity: typeof f.severity === 'number' ? f.severity : parseFloat(f.severity) || 0, severity: typeof f.severity === 'number' ? f.severity : parseFloat(f.severity) || 0,
vrrGroup: f.vrrGroup || f.severityGroup || '', vrrGroup: f.vrrGroup || f.severityGroup || '',
@@ -782,7 +783,14 @@ function createIvantiFindingsRouter(db, requireAuth) {
router.use(requireAuth(db)); router.use(requireAuth(db));
// GET / — cached findings with notes merged in /**
* GET /api/ivanti/findings
*
* Return cached Ivanti findings with notes and overrides merged in.
*
* @returns {Object} 200 - { findings: Array<Object>, lastSync: string|null, overrides: Object }
* @returns {Object} 500 - { error: string } on database error
*/
router.get('/', async (req, res) => { router.get('/', async (req, res) => {
try { try {
res.json(await readStateWithNotes(db)); res.json(await readStateWithNotes(db));
@@ -791,7 +799,15 @@ function createIvantiFindingsRouter(db, requireAuth) {
} }
}); });
// POST /sync — trigger immediate sync, return fresh state /**
* POST /api/ivanti/findings/sync
*
* Trigger an immediate Ivanti findings sync and return the fresh state.
* Requires Admin or Standard_User group.
*
* @returns {Object} 200 - { findings: Array<Object>, lastSync: string|null, overrides: Object }
* @returns {Object} 500 - { error: string } if sync ran but state could not be read
*/
router.post('/sync', requireGroup('Admin', 'Standard_User'), async (req, res) => { router.post('/sync', requireGroup('Admin', 'Standard_User'), async (req, res) => {
await syncFindings(db); await syncFindings(db);
try { try {
@@ -801,7 +817,14 @@ function createIvantiFindingsRouter(db, requireAuth) {
} }
}); });
// GET /counts — open vs closed totals for pie chart /**
* GET /api/ivanti/findings/counts
*
* Return open vs closed finding totals for the pie chart.
*
* @returns {Object} 200 - { open: number, closed: number }
* @returns {Object} 500 - { error: string } on database error
*/
router.get('/counts', async (req, res) => { router.get('/counts', async (req, res) => {
try { try {
res.json(await readCounts(db)); res.json(await readCounts(db));
@@ -810,8 +833,15 @@ function createIvantiFindingsRouter(db, requireAuth) {
} }
}); });
// GET /counts/history — last snapshot per day, ascending, for the trend chart. /**
// Uses a window function (ROW_NUMBER) to pick the final sync of each calendar day. * GET /api/ivanti/findings/counts/history
*
* Return the last snapshot per day (ascending) for the trend chart.
* Uses a ROW_NUMBER window function to pick the final sync of each calendar day.
*
* @returns {Object} 200 - { history: Array<{ date: string, open_count: number, closed_count: number }> }
* @returns {Object} 500 - { error: string } on database error
*/
router.get('/counts/history', async (req, res) => { router.get('/counts/history', async (req, res) => {
try { try {
const rows = await new Promise((resolve, reject) => { const rows = await new Promise((resolve, reject) => {
@@ -837,7 +867,15 @@ function createIvantiFindingsRouter(db, requireAuth) {
} }
}); });
// GET /fp-workflow-counts — FP finding + unique workflow counts (open + closed) /**
* GET /api/ivanti/findings/fp-workflow-counts
*
* Return FP finding counts and unique workflow ID counts (open + closed),
* broken down by workflow status.
*
* @returns {Object} 200 - { findingCounts: Object, findingTotal: number, idCounts: Object, idTotal: number }
* @returns {Object} 500 - { error: string } on database error
*/
router.get('/fp-workflow-counts', async (req, res) => { router.get('/fp-workflow-counts', async (req, res) => {
try { try {
const row = await new Promise((resolve, reject) => { const row = await new Promise((resolve, reject) => {
@@ -860,7 +898,20 @@ function createIvantiFindingsRouter(db, requireAuth) {
} }
}); });
// PUT /:findingId/override — save or clear a field override (editor/admin only) /**
* PUT /api/ivanti/findings/:findingId/override
*
* Save or clear a field override for a finding. Requires Admin or Standard_User group.
* Sending an empty value clears the override (reverts to Ivanti-sourced data).
*
* @param {string} findingId - The finding identifier (URL param)
* @body {string} field - The field to override; must be one of 'hostName', 'dns'
* @body {string} [value] - The override value; empty or omitted to clear
*
* @returns {Object} 200 - { finding_id: string, field: string, value: string|null }
* @returns {Object} 400 - { error: string } when field is not in the allowed list
* @returns {Object} 500 - { error: string } on database error
*/
const OVERRIDE_ALLOWED = ['hostName', 'dns']; const OVERRIDE_ALLOWED = ['hostName', 'dns'];
router.put('/:findingId/override', requireGroup('Admin', 'Standard_User'), (req, res) => { router.put('/:findingId/override', requireGroup('Admin', 'Standard_User'), (req, res) => {
const { findingId } = req.params; const { findingId } = req.params;
@@ -896,7 +947,18 @@ function createIvantiFindingsRouter(db, requireAuth) {
} }
}); });
// PUT /:findingId/note — save or update a note (max 255 chars enforced here) /**
* PUT /api/ivanti/findings/:findingId/note
*
* Save or update a note for a finding (max 255 characters).
* Requires Admin or Standard_User group.
*
* @param {string} findingId - The finding identifier (URL param)
* @body {string} [note] - The note text (truncated to 255 chars)
*
* @returns {Object} 200 - { finding_id: string, note: string }
* @returns {Object} 500 - { error: string } on database error
*/
router.put('/:findingId/note', requireGroup('Admin', 'Standard_User'), (req, res) => { router.put('/:findingId/note', requireGroup('Admin', 'Standard_User'), (req, res) => {
const { findingId } = req.params; const { findingId } = req.params;
const note = String(req.body.note || '').slice(0, 255); const note = String(req.body.note || '').slice(0, 255);

View File

@@ -26,6 +26,7 @@ const createIvantiTodoQueueRouter = require('./routes/ivantiTodoQueue');
const createIvantiArchiveRouter = require('./routes/ivantiArchive'); const createIvantiArchiveRouter = require('./routes/ivantiArchive');
const createIvantiFpWorkflowRouter = require('./routes/ivantiFpWorkflow'); const createIvantiFpWorkflowRouter = require('./routes/ivantiFpWorkflow');
const createComplianceRouter = require('./routes/compliance'); const createComplianceRouter = require('./routes/compliance');
const createAtlasRouter = require('./routes/atlas');
const app = express(); const app = express();
const PORT = process.env.PORT || 3001; const PORT = process.env.PORT || 3001;
@@ -234,6 +235,9 @@ app.use('/api/ivanti/fp-workflow', createIvantiFpWorkflowRouter(db, requireAuth)
// AEO compliance routes — xlsx upload, non-compliant item tracking, notes // AEO compliance routes — xlsx upload, non-compliant item tracking, notes
app.use('/api/compliance', createComplianceRouter(db, upload, requireAuth, requireGroup)); app.use('/api/compliance', createComplianceRouter(db, upload, requireAuth, requireGroup));
// Atlas InfoSec action plan routes — proxy CRUD to Atlas API, local cache for badges
app.use('/api/atlas', createAtlasRouter(db, requireAuth));
// ========== CVE ENDPOINTS ========== // ========== CVE ENDPOINTS ==========
// Get all CVEs with optional filters (authenticated users) // Get all CVEs with optional filters (authenticated users)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,74 @@
import React from 'react';
import { Shield } from 'lucide-react';
// ---------------------------------------------------------------------------
// AtlasBadge — small inline pill badge for the Host column on ReportingPage.
// Shows Atlas action plan coverage status for a given host.
//
// Props:
// hostId — numeric host identifier
// atlasStatus — { host_id, has_action_plan, plan_count, synced_at } or undefined
// onClick — callback when badge is clicked (opens slide-out panel)
// ---------------------------------------------------------------------------
const warningStyle = {
display: 'inline-flex',
alignItems: 'center',
gap: '3px',
borderRadius: '9999px',
padding: '1px 6px',
fontFamily: "'JetBrains Mono', monospace",
fontSize: '0.58rem',
fontWeight: 700,
lineHeight: 1,
cursor: 'pointer',
marginLeft: '6px',
background: 'rgba(245,158,11,0.12)',
border: '1px solid rgba(245,158,11,0.4)',
color: '#F59E0B',
};
const successStyle = {
display: 'inline-flex',
alignItems: 'center',
gap: '3px',
borderRadius: '9999px',
padding: '1px 6px',
fontFamily: "'JetBrains Mono', monospace",
fontSize: '0.58rem',
fontWeight: 700,
lineHeight: 1,
cursor: 'pointer',
marginLeft: '6px',
background: 'rgba(16,185,129,0.12)',
border: '1px solid rgba(16,185,129,0.4)',
color: '#10B981',
};
export default function AtlasBadge({ hostId, atlasStatus, onClick }) {
// No status data — render nothing
if (!atlasStatus) return null;
const hasPlan = atlasStatus.plan_count > 0;
const style = hasPlan ? successStyle : warningStyle;
const label = hasPlan ? String(atlasStatus.plan_count) : '0';
return (
<span
style={style}
title={
hasPlan
? `${atlasStatus.plan_count} Atlas action plan${atlasStatus.plan_count !== 1 ? 's' : ''}`
: 'No Atlas action plans — needs attention'
}
onClick={(e) => {
e.stopPropagation();
if (onClick) onClick(hostId);
}}
data-testid="atlas-badge"
>
<Shield style={{ width: 12, height: 12, flexShrink: 0 }} />
{label}
</span>
);
}

View File

@@ -0,0 +1,870 @@
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { X, Loader, Shield, Plus, Edit3, Check, AlertCircle, ChevronDown } from 'lucide-react';
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
// ---------------------------------------------------------------------------
// Plan type badge colors
// ---------------------------------------------------------------------------
const PLAN_TYPE_COLORS = {
remediation: '#0EA5E9',
decommission: '#EF4444',
false_positive: '#F59E0B',
risk_acceptance: '#A855F7',
scan_exclusion: '#64748B',
};
const VALID_PLAN_TYPES = Object.keys(PLAN_TYPE_COLORS);
// ---------------------------------------------------------------------------
// Shared inline style constants
// ---------------------------------------------------------------------------
const ACCENT = '#0EA5E9';
const panelStyle = {
position: 'fixed', top: 0, right: 0, bottom: 0, width: '420px',
background: '#0A1220',
borderLeft: '1px solid rgba(14,165,233,0.15)',
boxShadow: '-8px 0 32px rgba(0,0,0,0.6)',
zIndex: 41,
display: 'flex', flexDirection: 'column',
overflowY: 'auto',
fontFamily: "'JetBrains Mono', monospace",
};
const backdropStyle = {
position: 'fixed', inset: 0,
background: 'rgba(0,0,0,0.4)',
zIndex: 40,
};
const headerStyle = {
padding: '1.25rem 1.25rem 1rem',
borderBottom: '1px solid rgba(255,255,255,0.06)',
flexShrink: 0,
};
const sectionTitleStyle = {
display: 'flex', alignItems: 'center', gap: '0.4rem',
fontSize: '0.68rem', fontFamily: "'JetBrains Mono', monospace",
textTransform: 'uppercase', letterSpacing: '0.1em',
color: '#475569', marginBottom: '0.75rem',
};
const inputStyle = {
width: '100%', boxSizing: 'border-box',
background: 'rgba(14,165,233,0.06)',
border: '1px solid rgba(14,165,233,0.2)',
borderRadius: '0.375rem',
color: '#E2E8F0',
padding: '0.5rem 0.625rem',
fontSize: '0.78rem',
fontFamily: "'JetBrains Mono', monospace",
outline: 'none',
transition: 'border-color 0.15s',
};
const labelStyle = {
display: 'block',
fontSize: '0.68rem',
fontFamily: "'JetBrains Mono', monospace",
color: '#94A3B8',
marginBottom: '0.3rem',
textTransform: 'uppercase',
letterSpacing: '0.05em',
};
const primaryBtnStyle = {
display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: '0.4rem',
padding: '0.5rem 1rem',
background: 'rgba(14,165,233,0.15)',
border: '1px solid #0EA5E9',
borderRadius: '0.375rem',
color: '#38BDF8',
fontSize: '0.75rem',
fontFamily: "'JetBrains Mono', monospace",
fontWeight: 600,
cursor: 'pointer',
transition: 'all 0.15s',
textTransform: 'uppercase',
letterSpacing: '0.05em',
};
// ---------------------------------------------------------------------------
// Custom dropdown — dark-themed replacement for native <select>
// ---------------------------------------------------------------------------
function PlanTypeDropdown({ value, onChange }) {
const [open, setOpen] = useState(false);
const ref = useRef(null);
useEffect(() => {
if (!open) return;
const handleClick = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); };
document.addEventListener('mousedown', handleClick);
return () => document.removeEventListener('mousedown', handleClick);
}, [open]);
const color = PLAN_TYPE_COLORS[value] || '#94A3B8';
return (
<div ref={ref} style={{ position: 'relative' }}>
<button
type="button"
onClick={() => setOpen(!open)}
style={{
...inputStyle,
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
cursor: 'pointer', textAlign: 'left',
borderColor: open ? 'rgba(14,165,233,0.5)' : 'rgba(14,165,233,0.2)',
}}
>
<span style={{ color, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.03em' }}>
{value.replace(/_/g, ' ')}
</span>
<ChevronDown style={{ width: 14, height: 14, color: '#475569', transform: open ? 'rotate(180deg)' : 'none', transition: 'transform 0.15s' }} />
</button>
{open && (
<div style={{
position: 'absolute', top: '100%', left: 0, right: 0, marginTop: '4px',
background: '#0F1A2E',
border: '1px solid rgba(14,165,233,0.25)',
borderRadius: '0.375rem',
boxShadow: '0 8px 24px rgba(0,0,0,0.5)',
zIndex: 50, overflow: 'hidden',
}}>
{VALID_PLAN_TYPES.map(t => {
const c = PLAN_TYPE_COLORS[t] || '#94A3B8';
const isSelected = t === value;
return (
<div
key={t}
onClick={() => { onChange(t); setOpen(false); }}
style={{
padding: '0.5rem 0.625rem',
cursor: 'pointer',
background: isSelected ? 'rgba(14,165,233,0.12)' : 'transparent',
color: c,
fontSize: '0.78rem',
fontFamily: "'JetBrains Mono', monospace",
fontWeight: 600,
textTransform: 'uppercase',
letterSpacing: '0.03em',
transition: 'background 0.1s',
}}
onMouseEnter={e => { if (!isSelected) e.currentTarget.style.background = 'rgba(14,165,233,0.06)'; }}
onMouseLeave={e => { if (!isSelected) e.currentTarget.style.background = 'transparent'; }}
>
{t.replace(/_/g, ' ')}
</div>
);
})}
</div>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// PlanTypeBadge — colored pill for plan type
// ---------------------------------------------------------------------------
function PlanTypeBadge({ type }) {
const color = PLAN_TYPE_COLORS[type] || '#94A3B8';
return (
<span style={{
display: 'inline-flex', alignItems: 'center',
padding: '0.2rem 0.5rem',
background: `${color}18`,
border: `1px solid ${color}50`,
borderRadius: '0.25rem',
color,
fontSize: '0.7rem',
fontFamily: "'JetBrains Mono', monospace",
fontWeight: 600,
textTransform: 'uppercase',
letterSpacing: '0.03em',
}}>
{type.replace(/_/g, ' ')}
</span>
);
}
// ---------------------------------------------------------------------------
// PlanCard — displays a single action plan
// ---------------------------------------------------------------------------
function PlanCard({ plan, canWrite, onSaveEdit, editingId, onStartEdit, onCancelEdit }) {
const isEditing = editingId === (plan.action_plan_id || plan.id);
const [editForm, setEditForm] = useState({
commit_date: plan.commit_date || '',
qualys_id: plan.qualys_id || '',
active_host_findings_id: plan.active_host_findings_id || '',
jira_vnr: plan.jira_vnr || '',
archer_exc: plan.archer_exc || '',
});
const [saving, setSaving] = useState(false);
const [editError, setEditError] = useState(null);
const handleSave = async () => {
setSaving(true);
setEditError(null);
try {
await onSaveEdit(plan.action_plan_id || plan.id, editForm);
} catch (err) {
setEditError(err.message);
} finally {
setSaving(false);
}
};
const isPending = !!plan._localPending;
return (
<div style={{
marginBottom: '0.625rem', padding: '0.625rem 0.75rem',
background: isPending ? 'rgba(245,158,11,0.06)' : 'rgba(14,165,233,0.04)',
border: isPending ? '1px solid rgba(245,158,11,0.25)' : '1px solid rgba(14,165,233,0.12)',
borderRadius: '0.375rem',
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.4rem' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem' }}>
<PlanTypeBadge type={plan.plan_type || 'unknown'} />
{isPending && (
<span style={{
display: 'inline-flex', alignItems: 'center', gap: '3px',
padding: '0.15rem 0.4rem',
background: 'rgba(245,158,11,0.12)',
border: '1px solid rgba(245,158,11,0.35)',
borderRadius: '0.25rem',
color: '#F59E0B',
fontSize: '0.6rem',
fontFamily: "'JetBrains Mono', monospace",
fontWeight: 600,
textTransform: 'uppercase',
letterSpacing: '0.05em',
}}>
pending
</span>
)}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem' }}>
{plan.status && !isPending && (
<span style={{
display: 'inline-flex', alignItems: 'center', gap: '3px',
padding: '0.15rem 0.4rem',
background: 'rgba(16,185,129,0.12)',
border: '1px solid rgba(16,185,129,0.35)',
borderRadius: '0.25rem',
color: '#10B981',
fontSize: '0.6rem',
fontFamily: "'JetBrains Mono', monospace",
fontWeight: 600,
textTransform: 'uppercase',
letterSpacing: '0.05em',
}}>
{plan.status}
</span>
)}
{canWrite && !isEditing && !isPending && (
<button
onClick={() => onStartEdit(plan.action_plan_id || plan.id)}
title="Edit plan"
style={{
background: 'none', border: '1px solid rgba(14,165,233,0.15)',
borderRadius: '0.25rem', padding: '0.2rem',
cursor: 'pointer', color: '#475569',
transition: 'all 0.15s', lineHeight: 1,
}}
onMouseEnter={e => { e.currentTarget.style.color = ACCENT; e.currentTarget.style.borderColor = 'rgba(14,165,233,0.5)'; }}
onMouseLeave={e => { e.currentTarget.style.color = '#475569'; e.currentTarget.style.borderColor = 'rgba(14,165,233,0.15)'; }}
>
<Edit3 style={{ width: 11, height: 11 }} />
</button>
)}
</div>
</div>
{!isEditing ? (
<>
<div style={{ display: 'flex', gap: '0.4rem', marginBottom: '0.25rem' }}>
<span style={{ fontSize: '0.68rem', color: '#475569', fontFamily: "'JetBrains Mono', monospace", minWidth: '72px' }}>Commit</span>
<span style={{ fontSize: '0.68rem', color: '#E2E8F0', fontFamily: "'JetBrains Mono', monospace" }}>{plan.commit_date || '—'}</span>
</div>
{plan.jira_vnr && (
<div style={{ display: 'flex', gap: '0.4rem', marginBottom: '0.25rem' }}>
<span style={{ fontSize: '0.68rem', color: '#475569', fontFamily: "'JetBrains Mono', monospace", minWidth: '72px' }}>VNR</span>
<span style={{ fontSize: '0.68rem', color: '#E2E8F0', fontFamily: "'JetBrains Mono', monospace" }}>{plan.jira_vnr}</span>
</div>
)}
{plan.archer_exc && (
<div style={{ display: 'flex', gap: '0.4rem', marginBottom: '0.25rem' }}>
<span style={{ fontSize: '0.68rem', color: '#475569', fontFamily: "'JetBrains Mono', monospace", minWidth: '72px' }}>EXC</span>
<span style={{ fontSize: '0.68rem', color: '#E2E8F0', fontFamily: "'JetBrains Mono', monospace" }}>{plan.archer_exc}</span>
</div>
)}
</>
) : (
<div style={{ marginTop: '0.5rem' }}>
<div style={{ marginBottom: '0.5rem' }}>
<label style={labelStyle}>Commit Date</label>
<input
type="date"
value={editForm.commit_date}
onChange={e => setEditForm({ ...editForm, commit_date: e.target.value })}
style={inputStyle}
onFocus={e => e.target.style.borderColor = 'rgba(14,165,233,0.5)'}
onBlur={e => e.target.style.borderColor = 'rgba(14,165,233,0.2)'}
/>
</div>
<div style={{ marginBottom: '0.5rem' }}>
<label style={labelStyle}>Qualys ID</label>
<input
type="text"
value={editForm.qualys_id}
onChange={e => setEditForm({ ...editForm, qualys_id: e.target.value })}
placeholder="Optional"
style={inputStyle}
onFocus={e => e.target.style.borderColor = 'rgba(14,165,233,0.5)'}
onBlur={e => e.target.style.borderColor = 'rgba(14,165,233,0.2)'}
/>
</div>
<div style={{ marginBottom: '0.5rem' }}>
<label style={labelStyle}>Findings ID</label>
<input
type="number"
value={editForm.active_host_findings_id}
onChange={e => setEditForm({ ...editForm, active_host_findings_id: e.target.value })}
placeholder="Optional"
style={inputStyle}
onFocus={e => e.target.style.borderColor = 'rgba(14,165,233,0.5)'}
onBlur={e => e.target.style.borderColor = 'rgba(14,165,233,0.2)'}
/>
</div>
<div style={{ marginBottom: '0.5rem' }}>
<label style={labelStyle}>Jira VNR</label>
<input
type="text"
value={editForm.jira_vnr}
onChange={e => setEditForm({ ...editForm, jira_vnr: e.target.value })}
placeholder="Optional"
style={inputStyle}
onFocus={e => e.target.style.borderColor = 'rgba(14,165,233,0.5)'}
onBlur={e => e.target.style.borderColor = 'rgba(14,165,233,0.2)'}
/>
</div>
<div style={{ marginBottom: '0.5rem' }}>
<label style={labelStyle}>Archer EXC</label>
<input
type="text"
value={editForm.archer_exc}
onChange={e => setEditForm({ ...editForm, archer_exc: e.target.value })}
placeholder="Optional"
style={inputStyle}
onFocus={e => e.target.style.borderColor = 'rgba(14,165,233,0.5)'}
onBlur={e => e.target.style.borderColor = 'rgba(14,165,233,0.2)'}
/>
</div>
{editError && (
<div style={{ display: 'flex', gap: '0.4rem', alignItems: 'center', color: '#F87171', fontSize: '0.72rem', marginBottom: '0.5rem' }}>
<AlertCircle style={{ width: 12, height: 12, flexShrink: 0 }} />{editError}
</div>
)}
<div style={{ display: 'flex', gap: '0.5rem' }}>
<button
onClick={handleSave}
disabled={saving}
style={{ ...primaryBtnStyle, fontSize: '0.68rem', padding: '0.35rem 0.75rem', opacity: saving ? 0.6 : 1 }}
>
{saving ? <Loader style={{ width: 12, height: 12, animation: 'spin 1s linear infinite' }} /> : <Check style={{ width: 12, height: 12 }} />}
Save
</button>
<button
onClick={onCancelEdit}
style={{
padding: '0.35rem 0.75rem',
background: 'transparent',
border: '1px solid rgba(255,255,255,0.1)',
borderRadius: '0.375rem',
color: '#94A3B8',
fontSize: '0.68rem',
fontFamily: "'JetBrains Mono', monospace",
cursor: 'pointer',
transition: 'all 0.15s',
}}
>
Cancel
</button>
</div>
</div>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// InactiveSection — collapsible history of overridden/inactive plans
// ---------------------------------------------------------------------------
function InactiveSection({ plans }) {
const [expanded, setExpanded] = useState(false);
return (
<div style={{ padding: '0.75rem 1.25rem', borderBottom: '1px solid rgba(255,255,255,0.04)' }}>
<button
onClick={() => setExpanded(!expanded)}
style={{
display: 'flex', alignItems: 'center', gap: '0.4rem',
background: 'none', border: 'none', cursor: 'pointer', padding: 0,
fontSize: '0.68rem', fontFamily: "'JetBrains Mono', monospace",
textTransform: 'uppercase', letterSpacing: '0.1em',
color: '#475569', width: '100%',
}}
>
<ChevronDown style={{ width: 12, height: 12, transform: expanded ? 'rotate(180deg)' : 'none', transition: 'transform 0.15s' }} />
History ({plans.length})
</button>
{expanded && (
<div style={{ marginTop: '0.625rem' }}>
{plans.map((plan, idx) => (
<div key={plan.action_plan_id || idx} style={{
marginBottom: '0.5rem', padding: '0.5rem 0.625rem',
background: 'rgba(100,116,139,0.04)',
border: '1px solid rgba(100,116,139,0.1)',
borderRadius: '0.375rem',
opacity: 0.7,
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', marginBottom: '0.25rem' }}>
<PlanTypeBadge type={plan.plan_type || 'unknown'} />
<span style={{
fontSize: '0.6rem', color: '#64748B',
fontFamily: "'JetBrains Mono', monospace",
textTransform: 'uppercase',
}}>
{plan.status || 'inactive'}
</span>
</div>
<div style={{ display: 'flex', gap: '0.4rem' }}>
<span style={{ fontSize: '0.65rem', color: '#475569', fontFamily: "'JetBrains Mono', monospace", minWidth: '60px' }}>Commit</span>
<span style={{ fontSize: '0.65rem', color: '#94A3B8', fontFamily: "'JetBrains Mono', monospace" }}>{plan.commit_date || '—'}</span>
</div>
{plan.created_at && (
<div style={{ display: 'flex', gap: '0.4rem' }}>
<span style={{ fontSize: '0.65rem', color: '#475569', fontFamily: "'JetBrains Mono', monospace", minWidth: '60px' }}>Created</span>
<span style={{ fontSize: '0.65rem', color: '#94A3B8', fontFamily: "'JetBrains Mono', monospace" }}>{plan.created_at.split('T')[0]}</span>
</div>
)}
</div>
))}
</div>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// AtlasSlideOutPanel — main exported component
// ---------------------------------------------------------------------------
export default function AtlasSlideOutPanel({ hostId, hostName, findingId, qualysId, onClose, canWrite, onPlanChange }) {
const [plans, setPlans] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [editingId, setEditingId] = useState(null);
// Create form state — prepopulate qualys_id and findings ID from the clicked finding
const [showCreate, setShowCreate] = useState(false);
const [createForm, setCreateForm] = useState({
plan_type: 'remediation',
commit_date: '',
qualys_id: qualysId || '',
active_host_findings_id: findingId || '',
jira_vnr: '',
archer_exc: '',
});
const [creating, setCreating] = useState(false);
const [createError, setCreateError] = useState(null);
const [successMsg, setSuccessMsg] = useState(null);
// -----------------------------------------------------------------------
// Parse Atlas response — handles { active: [...], inactive: [...] } format
// -----------------------------------------------------------------------
function parseAtlasPlans(data) {
if (Array.isArray(data)) return data;
if (data && typeof data === 'object') {
const active = Array.isArray(data.active) ? data.active : [];
const inactive = Array.isArray(data.inactive) ? data.inactive : [];
return [...active, ...inactive];
}
return [];
}
// -----------------------------------------------------------------------
// Fetch plans
// -----------------------------------------------------------------------
const fetchPlans = useCallback(async () => {
setLoading(true);
setError(null);
try {
const res = await fetch(`${API_BASE}/atlas/hosts/${hostId}/action-plans`, { credentials: 'include' });
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || `Failed to load plans (${res.status})`);
}
const data = await res.json();
const remotePlans = parseAtlasPlans(data);
// Merge: keep local pending plans that aren't yet confirmed by Atlas
setPlans(prev => {
const localPending = prev.filter(p => p._localPending);
const remoteIds = new Set(remotePlans.map(p => p.action_plan_id));
// Remove local pending plans that now appear in remote (confirmed)
const stillPending = localPending.filter(p => !remoteIds.has(p.action_plan_id));
return [...remotePlans, ...stillPending];
});
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}, [hostId]);
useEffect(() => { fetchPlans(); }, [fetchPlans]);
// Clear success message after 3s
useEffect(() => {
if (!successMsg) return;
const t = setTimeout(() => setSuccessMsg(null), 3000);
return () => clearTimeout(t);
}, [successMsg]);
// -----------------------------------------------------------------------
// Create plan
// -----------------------------------------------------------------------
const handleCreate = async () => {
if (!createForm.commit_date) {
setCreateError('Commit date is required');
return;
}
setCreating(true);
setCreateError(null);
try {
const body = {
plan_type: createForm.plan_type,
commit_date: createForm.commit_date,
};
if (createForm.qualys_id.trim()) body.qualys_id = createForm.qualys_id.trim();
if (createForm.active_host_findings_id) body.active_host_findings_id = Number(createForm.active_host_findings_id);
if (createForm.jira_vnr.trim()) body.jira_vnr = createForm.jira_vnr.trim();
if (createForm.archer_exc.trim()) body.archer_exc = createForm.archer_exc.trim();
const res = await fetch(`${API_BASE}/atlas/hosts/${hostId}/action-plans`, {
method: 'PUT',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || `Create failed (${res.status})`);
// Add optimistic local plan immediately — shown as "pending" until sync confirms
const localPlan = {
action_plan_id: data.action_plan_id || ('local-' + Date.now()),
plan_type: body.plan_type,
commit_date: body.commit_date,
qualys_id: body.qualys_id || null,
active_host_findings_id: body.active_host_findings_id || null,
jira_vnr: body.jira_vnr || null,
archer_exc: body.archer_exc || null,
status: 'pending',
_localPending: true,
created_at: new Date().toISOString(),
};
setPlans(prev => [localPlan, ...prev]);
// Reset form
setCreateForm({ plan_type: 'remediation', commit_date: '', qualys_id: qualysId || '', active_host_findings_id: findingId || '', jira_vnr: '', archer_exc: '' });
setShowCreate(false);
setSuccessMsg('Action plan created');
if (onPlanChange) onPlanChange();
} catch (err) {
setCreateError(err.message);
} finally {
setCreating(false);
}
};
// -----------------------------------------------------------------------
// Edit plan
// -----------------------------------------------------------------------
const handleSaveEdit = async (actionPlanId, updates) => {
const res = await fetch(`${API_BASE}/atlas/hosts/${hostId}/action-plans`, {
method: 'PATCH',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action_plan_id: actionPlanId, updates }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || `Update failed (${res.status})`);
setEditingId(null);
setSuccessMsg('Action plan updated');
await fetchPlans();
if (onPlanChange) onPlanChange();
};
// -----------------------------------------------------------------------
// Render
// -----------------------------------------------------------------------
return (
<>
{/* Backdrop */}
<div onClick={onClose} style={backdropStyle} data-testid="atlas-panel-backdrop" />
{/* Panel */}
<div style={panelStyle} data-testid="atlas-slide-out-panel">
{/* Header */}
<div style={headerStyle}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<div style={{ minWidth: 0, flex: 1, marginRight: '0.75rem' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', marginBottom: '0.3rem' }}>
<Shield style={{ width: 16, height: 16, color: ACCENT, flexShrink: 0 }} />
<span style={{ fontSize: '0.9rem', fontWeight: 700, color: '#E2E8F0', wordBreak: 'break-all', lineHeight: 1.3 }}>
{hostName || 'Unknown Host'}
</span>
</div>
<span style={{ fontSize: '0.72rem', color: '#475569', fontFamily: "'JetBrains Mono', monospace" }}>
Host ID: {hostId}
</span>
</div>
<button
onClick={onClose}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#475569', flexShrink: 0, padding: '0.25rem' }}
onMouseEnter={e => e.currentTarget.style.color = '#E2E8F0'}
onMouseLeave={e => e.currentTarget.style.color = '#475569'}
data-testid="atlas-panel-close"
>
<X style={{ width: 18, height: 18 }} />
</button>
</div>
</div>
{/* Success message */}
{successMsg && (
<div style={{
margin: '0.75rem 1.25rem 0', padding: '0.5rem 0.75rem',
background: 'rgba(16,185,129,0.1)',
border: '1px solid rgba(16,185,129,0.3)',
borderRadius: '0.375rem',
color: '#10B981', fontSize: '0.75rem',
display: 'flex', alignItems: 'center', gap: '0.4rem',
}}>
<Check style={{ width: 14, height: 14 }} />{successMsg}
</div>
)}
{/* Loading */}
{loading && (
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '3rem 0' }}>
<Loader style={{ width: 28, height: 28, color: ACCENT, animation: 'spin 1s linear infinite' }} />
</div>
)}
{/* Error */}
{error && !loading && (
<div style={{ padding: '1.25rem', display: 'flex', flexDirection: 'column', gap: '0.75rem', alignItems: 'center' }}>
<div style={{ display: 'flex', gap: '0.5rem', color: '#F87171', fontSize: '0.8rem', alignItems: 'center' }}>
<AlertCircle style={{ width: 16, height: 16, flexShrink: 0 }} />{error}
</div>
<button
onClick={fetchPlans}
style={{
...primaryBtnStyle,
fontSize: '0.68rem',
padding: '0.35rem 0.75rem',
}}
>
Retry
</button>
</div>
)}
{/* Plan list */}
{!loading && !error && (
<div style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
{/* Section: Active plans */}
<div style={{ padding: '1rem 1.25rem', borderBottom: '1px solid rgba(255,255,255,0.04)' }}>
<div style={sectionTitleStyle}>
<Shield style={{ width: 14, height: 14, color: ACCENT }} />
Active Plans ({plans.filter(p => p.status === 'active' || p._localPending).length})
</div>
{plans.filter(p => p.status === 'active' || p._localPending).length === 0 && (
<div style={{ color: '#475569', fontSize: '0.75rem', fontStyle: 'italic' }}>
No active action plans for this host.
</div>
)}
{plans.filter(p => p.status === 'active' || p._localPending).map((plan, idx) => (
<PlanCard
key={plan.action_plan_id || plan.id || idx}
plan={plan}
canWrite={canWrite}
editingId={editingId}
onStartEdit={setEditingId}
onCancelEdit={() => setEditingId(null)}
onSaveEdit={handleSaveEdit}
/>
))}
</div>
{/* Section: Inactive plans (history) — collapsible */}
{plans.filter(p => p.status !== 'active' && !p._localPending).length > 0 && (
<InactiveSection plans={plans.filter(p => p.status !== 'active' && !p._localPending)} />
)}
{/* Section: Create form */}
{canWrite && (
<div style={{ padding: '1rem 1.25rem' }}>
{!showCreate ? (
<button
onClick={() => setShowCreate(true)}
style={{
...primaryBtnStyle,
width: '100%',
}}
onMouseEnter={e => { e.currentTarget.style.background = 'rgba(14,165,233,0.25)'; e.currentTarget.style.boxShadow = '0 0 20px rgba(14,165,233,0.25)'; }}
onMouseLeave={e => { e.currentTarget.style.background = 'rgba(14,165,233,0.15)'; e.currentTarget.style.boxShadow = 'none'; }}
data-testid="atlas-create-plan-btn"
>
<Plus style={{ width: 14, height: 14 }} />
New Action Plan
</button>
) : (
<div data-testid="atlas-create-form">
<div style={sectionTitleStyle}>
<Plus style={{ width: 14, height: 14, color: ACCENT }} />
Create Action Plan
</div>
{/* Plan type */}
<div style={{ marginBottom: '0.625rem' }}>
<label style={labelStyle}>Plan Type</label>
<PlanTypeDropdown
value={createForm.plan_type}
onChange={val => setCreateForm({ ...createForm, plan_type: val })}
/>
</div>
{/* Commit date */}
<div style={{ marginBottom: '0.625rem' }}>
<label style={labelStyle}>Commit Date *</label>
<input
type="date"
value={createForm.commit_date}
onChange={e => setCreateForm({ ...createForm, commit_date: e.target.value })}
style={inputStyle}
onFocus={e => e.target.style.borderColor = 'rgba(14,165,233,0.5)'}
onBlur={e => e.target.style.borderColor = 'rgba(14,165,233,0.2)'}
/>
</div>
{/* Qualys ID */}
<div style={{ marginBottom: '0.625rem' }}>
<label style={labelStyle}>Qualys ID</label>
<input
type="text"
value={createForm.qualys_id}
onChange={e => setCreateForm({ ...createForm, qualys_id: e.target.value })}
placeholder="Optional"
style={inputStyle}
onFocus={e => e.target.style.borderColor = 'rgba(14,165,233,0.5)'}
onBlur={e => e.target.style.borderColor = 'rgba(14,165,233,0.2)'}
/>
</div>
{/* Active Host Findings ID */}
<div style={{ marginBottom: '0.625rem' }}>
<label style={labelStyle}>Findings ID</label>
<input
type="number"
value={createForm.active_host_findings_id}
onChange={e => setCreateForm({ ...createForm, active_host_findings_id: e.target.value })}
placeholder="Optional"
style={inputStyle}
onFocus={e => e.target.style.borderColor = 'rgba(14,165,233,0.5)'}
onBlur={e => e.target.style.borderColor = 'rgba(14,165,233,0.2)'}
/>
</div>
{/* Jira VNR */}
<div style={{ marginBottom: '0.625rem' }}>
<label style={labelStyle}>Jira VNR</label>
<input
type="text"
value={createForm.jira_vnr}
onChange={e => setCreateForm({ ...createForm, jira_vnr: e.target.value })}
placeholder="Optional"
style={inputStyle}
onFocus={e => e.target.style.borderColor = 'rgba(14,165,233,0.5)'}
onBlur={e => e.target.style.borderColor = 'rgba(14,165,233,0.2)'}
/>
</div>
{/* Archer EXC */}
<div style={{ marginBottom: '0.625rem' }}>
<label style={labelStyle}>Archer EXC</label>
<input
type="text"
value={createForm.archer_exc}
onChange={e => setCreateForm({ ...createForm, archer_exc: e.target.value })}
placeholder="Optional"
style={inputStyle}
onFocus={e => e.target.style.borderColor = 'rgba(14,165,233,0.5)'}
onBlur={e => e.target.style.borderColor = 'rgba(14,165,233,0.2)'}
/>
</div>
{/* Create error */}
{createError && (
<div style={{ display: 'flex', gap: '0.4rem', alignItems: 'center', color: '#F87171', fontSize: '0.72rem', marginBottom: '0.625rem' }}>
<AlertCircle style={{ width: 12, height: 12, flexShrink: 0 }} />{createError}
</div>
)}
{/* Buttons */}
<div style={{ display: 'flex', gap: '0.5rem' }}>
<button
onClick={handleCreate}
disabled={creating}
style={{ ...primaryBtnStyle, opacity: creating ? 0.6 : 1 }}
onMouseEnter={e => { if (!creating) { e.currentTarget.style.background = 'rgba(14,165,233,0.25)'; } }}
onMouseLeave={e => { e.currentTarget.style.background = 'rgba(14,165,233,0.15)'; }}
>
{creating
? <Loader style={{ width: 14, height: 14, animation: 'spin 1s linear infinite' }} />
: <Check style={{ width: 14, height: 14 }} />}
Create
</button>
<button
onClick={() => { setShowCreate(false); setCreateError(null); }}
style={{
padding: '0.5rem 1rem',
background: 'transparent',
border: '1px solid rgba(255,255,255,0.1)',
borderRadius: '0.375rem',
color: '#94A3B8',
fontSize: '0.75rem',
fontFamily: "'JetBrains Mono', monospace",
cursor: 'pointer',
transition: 'all 0.15s',
}}
>
Cancel
</button>
</div>
</div>
)}
</div>
)}
</div>
)}
</div>
</>
);
}

View File

@@ -6,6 +6,8 @@ import { useAuth } from '../../contexts/AuthContext';
import IvantiCountsChart from './IvantiCountsChart'; import IvantiCountsChart from './IvantiCountsChart';
import CveTooltip from '../CveTooltip'; import CveTooltip from '../CveTooltip';
import RedirectModal from '../RedirectModal'; import RedirectModal from '../RedirectModal';
import AtlasBadge from '../AtlasBadge';
import AtlasSlideOutPanel from '../AtlasSlideOutPanel';
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api'; const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
const STORAGE_KEY = 'steam_findings_columns_v2'; const STORAGE_KEY = 'steam_findings_columns_v2';
@@ -514,7 +516,7 @@ function SortIcon({ colKey, sort }) {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// OverrideCell — inline editable hostname/dns with amber dot when overridden // OverrideCell — inline editable hostname/dns with amber dot when overridden
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function OverrideCell({ findingId, field, originalValue, initialOverride, canWrite }) { function OverrideCell({ findingId, field, originalValue, initialOverride, canWrite, suffix }) {
const effective = initialOverride ?? originalValue ?? ''; const effective = initialOverride ?? originalValue ?? '';
const [value, setValue] = useState(effective); const [value, setValue] = useState(effective);
const [isOverridden, setOverridden] = useState(!!initialOverride); const [isOverridden, setOverridden] = useState(!!initialOverride);
@@ -620,6 +622,7 @@ function OverrideCell({ findingId, field, originalValue, initialOverride, canWri
</button> </button>
)} )}
</span> </span>
{suffix}
</td> </td>
); );
} }
@@ -955,7 +958,7 @@ function FilterDropdown({ anchorEl, colKey, findings, activeFilter, onFilterChan
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Render a single table cell by column key // Render a single table cell by column key
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function TableCell({ colKey, finding, canWrite, onCveMouseEnter, onCveMouseLeave, fpSubmissions, onEditSubmission }) { function TableCell({ colKey, finding, canWrite, onCveMouseEnter, onCveMouseLeave, fpSubmissions, onEditSubmission, atlasStatusMap, onAtlasBadgeClick }) {
switch (colKey) { switch (colKey) {
case 'findingId': case 'findingId':
return ( return (
@@ -1017,6 +1020,13 @@ function TableCell({ colKey, finding, canWrite, onCveMouseEnter, onCveMouseLeave
originalValue={finding.hostName} originalValue={finding.hostName}
initialOverride={finding.overrides?.hostName ?? null} initialOverride={finding.overrides?.hostName ?? null}
canWrite={canWrite} canWrite={canWrite}
suffix={
<AtlasBadge
hostId={finding.hostId}
atlasStatus={atlasStatusMap ? atlasStatusMap.get(finding.hostId) : undefined}
onClick={onAtlasBadgeClick}
/>
}
/> />
); );
case 'ipAddress': case 'ipAddress':
@@ -3620,6 +3630,15 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
const tooltipCacheRef = useRef(new Map()); const tooltipCacheRef = useRef(new Map());
const hoverTimerRef = useRef(null); const hoverTimerRef = useRef(null);
// Atlas action plan state
const [atlasStatusMap, setAtlasStatusMap] = useState(new Map());
const [atlasSyncing, setAtlasSyncing] = useState(false);
const [atlasError, setAtlasError] = useState(null);
const [atlasPanelOpen, setAtlasPanelOpen] = useState(false);
const [atlasSelectedHostId, setAtlasSelectedHostId] = useState(null);
const [atlasSelectedHostName, setAtlasSelectedHostName] = useState(null);
const [atlasSelectedFindingId, setAtlasSelectedFindingId] = useState(null);
const updateColumns = useCallback((newOrder) => { const updateColumns = useCallback((newOrder) => {
setColumnOrder(newOrder); setColumnOrder(newOrder);
saveColumnOrder(newOrder); saveColumnOrder(newOrder);
@@ -3724,6 +3743,20 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
} }
}; };
const fetchAtlasStatus = useCallback(async () => {
try {
const res = await fetch(`${API_BASE}/atlas/status`, { credentials: 'include' });
if (res.ok) {
const data = await res.json();
const map = new Map();
data.forEach(row => map.set(row.host_id, row));
setAtlasStatusMap(map);
}
} catch (err) {
console.error('[Atlas] Failed to fetch status:', err.message);
}
}, []);
const fetchFindings = async () => { const fetchFindings = async () => {
setLoading(true); setLoading(true);
try { try {
@@ -3764,6 +3797,7 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
fetchFPWorkflowCounts(); fetchFPWorkflowCounts();
fetchQueue(); fetchQueue();
fetchFpSubmissions(); fetchFpSubmissions();
fetchAtlasStatus();
}, []); // eslint-disable-line }, []); // eslint-disable-line
// Set/clear a single column filter // Set/clear a single column filter
@@ -4437,6 +4471,46 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
</button> </button>
<ColumnManager columnOrder={columnOrder} onChange={updateColumns} /> <ColumnManager columnOrder={columnOrder} onChange={updateColumns} />
<RowVisibilityManager hiddenRowIds={hiddenRowIds} findings={findings} onRestore={restoreRow} onRestoreAll={restoreAllRows} /> <RowVisibilityManager hiddenRowIds={hiddenRowIds} findings={findings} onRestore={restoreRow} onRestoreAll={restoreAllRows} />
<button
onClick={async () => {
setAtlasSyncing(true);
setAtlasError(null);
try {
const res = await fetch(`${API_BASE}/atlas/sync`, { method: 'POST', credentials: 'include' });
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || 'Atlas sync failed');
}
await fetchAtlasStatus();
} catch (err) {
setAtlasError(err.message);
} finally {
setAtlasSyncing(false);
}
}}
disabled={atlasSyncing || !canWrite()}
title={!canWrite() ? 'Insufficient permissions' : 'Sync Atlas action plan status'}
style={{
display: 'inline-flex', alignItems: 'center', gap: '0.4rem',
padding: '0.4rem 0.75rem',
background: atlasSyncing ? 'rgba(14,165,233,0.08)' : 'rgba(14,165,233,0.06)',
border: '1px solid rgba(14,165,233,0.2)',
borderRadius: '0.375rem',
color: atlasSyncing ? '#475569' : '#0EA5E9',
fontSize: '0.72rem',
fontFamily: "'JetBrains Mono', monospace",
fontWeight: 600,
cursor: atlasSyncing || !canWrite() ? 'not-allowed' : 'pointer',
opacity: !canWrite() ? 0.5 : 1,
textTransform: 'uppercase',
letterSpacing: '0.05em',
}}
>
{atlasSyncing
? <Loader style={{ width: 13, height: 13, animation: 'spin 1s linear infinite' }} />
: <Database style={{ width: 13, height: 13 }} />}
Atlas
</button>
<button <button
onClick={syncFindings} onClick={syncFindings}
disabled={syncing || loading} disabled={syncing || loading}
@@ -4465,6 +4539,12 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
<span style={{ fontSize: '0.75rem', color: '#FCA5A5', fontFamily: 'monospace' }}>{syncError}</span> <span style={{ fontSize: '0.75rem', color: '#FCA5A5', fontFamily: 'monospace' }}>{syncError}</span>
</div> </div>
)} )}
{atlasError && (
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '0.5rem', padding: '0.625rem 0.875rem', background: 'rgba(239,68,68,0.08)', border: '1px solid rgba(239,68,68,0.25)', borderRadius: '0.375rem', marginBottom: '1rem' }}>
<AlertCircle style={{ width: '15px', height: '15px', color: '#EF4444', flexShrink: 0, marginTop: '1px' }} />
<span style={{ fontSize: '0.75rem', color: '#FCA5A5', fontFamily: 'monospace' }}>Atlas: {atlasError}</span>
</div>
)}
{/* Content */} {/* Content */}
{loading ? ( {loading ? (
@@ -4696,7 +4776,7 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
/> />
</td> </td>
{visibleCols.map((col) => ( {visibleCols.map((col) => (
<TableCell key={col.key} colKey={col.key} finding={finding} canWrite={canWrite()} onCveMouseEnter={handleCveMouseEnter} onCveMouseLeave={handleCveMouseLeave} fpSubmissions={fpSubmissions} onEditSubmission={handleEditSubmission} /> <TableCell key={col.key} colKey={col.key} finding={finding} canWrite={canWrite()} onCveMouseEnter={handleCveMouseEnter} onCveMouseLeave={handleCveMouseLeave} fpSubmissions={fpSubmissions} onEditSubmission={handleEditSubmission} atlasStatusMap={atlasStatusMap} onAtlasBadgeClick={(hostId) => { setAtlasSelectedHostId(hostId); setAtlasSelectedHostName(finding.hostName || finding.ipAddress || ''); setAtlasSelectedFindingId(finding.id || null); setAtlasPanelOpen(true); }} />
))} ))}
</tr> </tr>
); );
@@ -4778,6 +4858,21 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
anchorRect={tooltipAnchorRect} anchorRect={tooltipAnchorRect}
cache={tooltipCacheRef} cache={tooltipCacheRef}
/> />
{atlasPanelOpen && atlasSelectedHostId && (
<AtlasSlideOutPanel
hostId={atlasSelectedHostId}
hostName={atlasSelectedHostName}
findingId={atlasSelectedFindingId}
onClose={() => {
setAtlasPanelOpen(false);
setAtlasSelectedHostId(null);
setAtlasSelectedHostName(null);
setAtlasSelectedFindingId(null);
}}
canWrite={canWrite()}
onPlanChange={fetchAtlasStatus}
/>
)}
</div> </div>
); );
} }