Add Atlas metrics reporting, security audit tracker, and spec documents
This commit is contained in:
1
.kiro/specs/atlas-metrics-report/.config.kiro
Normal file
1
.kiro/specs/atlas-metrics-report/.config.kiro
Normal file
@@ -0,0 +1 @@
|
||||
{"specId": "a3e7c1d2-8f4b-4a91-b6e3-9d2f5c8a1b74", "workflowType": "requirements-first", "specType": "feature"}
|
||||
362
.kiro/specs/atlas-metrics-report/design.md
Normal file
362
.kiro/specs/atlas-metrics-report/design.md
Normal file
@@ -0,0 +1,362 @@
|
||||
# Design Document: Atlas Metrics Report
|
||||
|
||||
## Overview
|
||||
|
||||
This feature adds a tab system to the existing Metric Graphs panel on the ReportingPage, separating Ivanti donut charts from new Atlas coverage charts. A new `GET /api/atlas/metrics` endpoint aggregates cached Atlas action plan data into chart-ready metrics. The frontend renders three new donut charts — coverage, plan type distribution, and plan status distribution — under an "Atlas Coverage" tab, while the existing four Ivanti donuts move under an "Ivanti Findings" tab.
|
||||
|
||||
### Key Design Decisions
|
||||
|
||||
- **Server-side aggregation**: The metrics endpoint computes counts and distributions on the backend rather than shipping raw `plans_json` to the client. This keeps the frontend simple and avoids parsing potentially large JSON arrays in the browser.
|
||||
- **Reuse existing donut helpers**: The new Atlas donut charts reuse the `polarToCartesian` and `donutArcPath` SVG helper functions already defined in ReportingPage.js, along with the same dimensions (180px size, 72px outer radius, 48px inner radius). This keeps the visual language consistent.
|
||||
- **Fetch once, refresh on sync**: Atlas metrics are fetched once on page mount and re-fetched only after a successful Atlas sync. Tab switches do not trigger new API calls.
|
||||
- **No server.js changes**: The new endpoint is added inside the existing `createAtlasRouter(db, requireAuth)` factory function in `backend/routes/atlas.js`. The router is already mounted at `/api/atlas` in server.js.
|
||||
- **Tab state is local**: The active tab is stored in React component state — no URL params, no localStorage. The default is "Ivanti Findings" to preserve the existing experience.
|
||||
- **IvantiCountsChart conditional visibility**: The trend chart below the Metric Graphs panel is only shown when the Ivanti tab is active, since it has no relevance to Atlas data.
|
||||
|
||||
## Architecture
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
subgraph Frontend - ReportingPage
|
||||
TS[Tab System<br/>Ivanti Findings | Atlas Coverage]
|
||||
ID[Ivanti Donuts<br/>StatusDonut, ActionCoverageDonut,<br/>FPWorkflowDonut x2]
|
||||
AD[Atlas Donuts<br/>CoverageDonut, PlanTypeDonut,<br/>PlanStatusDonut]
|
||||
IC[IvantiCountsChart]
|
||||
end
|
||||
|
||||
subgraph Backend - Atlas Router
|
||||
ME[GET /api/atlas/metrics]
|
||||
AC[(atlas_action_plans_cache<br/>SQLite)]
|
||||
end
|
||||
|
||||
TS -->|tab = ivanti| ID
|
||||
TS -->|tab = ivanti| IC
|
||||
TS -->|tab = atlas| AD
|
||||
AD -->|fetch on mount + after sync| ME
|
||||
ME -->|SELECT + aggregate| AC
|
||||
```
|
||||
|
||||
### Data Flow: Metrics Fetch
|
||||
|
||||
1. ReportingPage mounts → calls `GET /api/atlas/metrics`
|
||||
2. Backend queries all rows from `atlas_action_plans_cache`, including `plans_json`
|
||||
3. Backend iterates rows, parses each `plans_json`, counts plans by type and status
|
||||
4. Backend returns aggregated JSON: `{ totalHosts, hostsWithPlans, hostsWithoutPlans, plansByType, plansByStatus, totalPlans }`
|
||||
5. Frontend stores result in `atlasMetrics` state
|
||||
6. When "Atlas Coverage" tab is active, three donut components render from this state
|
||||
|
||||
### Data Flow: Refresh After Sync
|
||||
|
||||
1. User clicks Atlas sync button → `POST /api/atlas/sync` (existing)
|
||||
2. On success, frontend calls `GET /api/atlas/metrics` again
|
||||
3. Atlas donut charts re-render with updated data
|
||||
|
||||
## Components and Interfaces
|
||||
|
||||
### Backend: GET /api/atlas/metrics Endpoint
|
||||
|
||||
Added inside the existing `createAtlasRouter(db, requireAuth)` factory function in `backend/routes/atlas.js`.
|
||||
|
||||
| Method | Path | Auth | Group | Description |
|
||||
|--------|------|------|-------|-------------|
|
||||
| `GET` | `/metrics` | requireAuth | any | Return aggregated Atlas metrics for chart rendering |
|
||||
|
||||
**Response shape**:
|
||||
|
||||
```json
|
||||
{
|
||||
"totalHosts": 42,
|
||||
"hostsWithPlans": 28,
|
||||
"hostsWithoutPlans": 14,
|
||||
"plansByType": {
|
||||
"decommission": 5,
|
||||
"remediation": 18,
|
||||
"false_positive": 3,
|
||||
"risk_acceptance": 8,
|
||||
"scan_exclusion": 2
|
||||
},
|
||||
"plansByStatus": {
|
||||
"active": 25,
|
||||
"expired": 7,
|
||||
"completed": 4
|
||||
},
|
||||
"totalPlans": 36
|
||||
}
|
||||
```
|
||||
|
||||
**Implementation approach**:
|
||||
|
||||
1. Query all rows: `SELECT has_action_plan, plans_json FROM atlas_action_plans_cache`
|
||||
2. Initialize counters: `totalHosts = rows.length`, `hostsWithPlans = 0`, `hostsWithoutPlans = 0`, `plansByType = {}`, `plansByStatus = {}`, `totalPlans = 0`
|
||||
3. For each row:
|
||||
- If `has_action_plan === 1`, increment `hostsWithPlans`; else increment `hostsWithoutPlans`
|
||||
- Try to parse `plans_json`; on failure, skip plan details for that row
|
||||
- For each plan in the parsed array, increment the corresponding `plansByType[plan.plan_type]` and `plansByStatus[plan.status]` counters, and increment `totalPlans`
|
||||
4. Return the aggregated object
|
||||
|
||||
Uses the existing `dbAll` promise wrapper already defined in `atlas.js`.
|
||||
|
||||
### Frontend: Tab System
|
||||
|
||||
A horizontal tab bar rendered inside the Metric Graphs panel header, to the right of the "Metric Graphs" title and `PieChart` icon.
|
||||
|
||||
**State**: `metricsTab` — `'ivanti'` (default) or `'atlas'`
|
||||
|
||||
**Tab bar structure**:
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ [PieChart icon] METRIC GRAPHS [Ivanti Findings] [Atlas Coverage] │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Styling**:
|
||||
- Tabs use `role="tab"` and `aria-selected`; the content area uses `role="tabpanel"`
|
||||
- Active tab: `color: #F59E0B`, `borderBottom: 2px solid #F59E0B`
|
||||
- Inactive tab: `color: #64748B`, no bottom border
|
||||
- Hover on inactive: `background: rgba(245, 158, 11, 0.06)`
|
||||
- Font: `'JetBrains Mono', monospace`, `0.7rem`, `uppercase`, `letterSpacing: 0.08em`
|
||||
- Tabs are keyboard navigable via Tab and Enter keys
|
||||
|
||||
### Frontend: Atlas Metrics State
|
||||
|
||||
New state variables in the ReportingPage component:
|
||||
|
||||
```javascript
|
||||
const [metricsTab, setMetricsTab] = useState('ivanti');
|
||||
const [atlasMetrics, setAtlasMetrics] = useState(null);
|
||||
const [atlasMetricsLoading, setAtlasMetricsLoading] = useState(false);
|
||||
const [atlasMetricsError, setAtlasMetricsError] = useState(null);
|
||||
```
|
||||
|
||||
New fetch function:
|
||||
|
||||
```javascript
|
||||
const fetchAtlasMetrics = useCallback(async () => {
|
||||
setAtlasMetricsLoading(true);
|
||||
setAtlasMetricsError(null);
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/atlas/metrics`, { credentials: 'include' });
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setAtlasMetrics(data);
|
||||
} else {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
setAtlasMetricsError(err.error || 'Failed to fetch Atlas metrics');
|
||||
}
|
||||
} catch (err) {
|
||||
setAtlasMetricsError(err.message);
|
||||
} finally {
|
||||
setAtlasMetricsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
```
|
||||
|
||||
Called on mount (in the existing `useEffect`) and after a successful Atlas sync.
|
||||
|
||||
### Frontend: Atlas Donut Charts
|
||||
|
||||
Three new inline components defined in ReportingPage.js, following the same pattern as `StatusDonut`, `ActionCoverageDonut`, and `FPWorkflowDonut`. All reuse the existing `polarToCartesian` and `donutArcPath` helper functions.
|
||||
|
||||
**AtlasCoverageDonut**
|
||||
- Props: `{ hostsWithPlans, hostsWithoutPlans, totalHosts }`
|
||||
- Segments: emerald (`#10B981`) for with plans, amber (`#F59E0B`) for without plans
|
||||
- Center text: `totalHosts` count, "HOSTS" label
|
||||
- Empty state: "No data — run Atlas Sync"
|
||||
|
||||
**AtlasPlanTypeDonut**
|
||||
- Props: `{ plansByType, totalPlans }`
|
||||
- Color map: `decommission: #EF4444`, `remediation: #0EA5E9`, `false_positive: #A855F7`, `risk_acceptance: #F59E0B`, `scan_exclusion: #64748B`
|
||||
- Center text: `totalPlans` count, "PLANS" label
|
||||
- Legend: only shows types with count > 0
|
||||
- Empty state: "No plans — run Atlas Sync"
|
||||
|
||||
**AtlasPlanStatusDonut**
|
||||
- Props: `{ plansByStatus, totalPlans }`
|
||||
- Color map: `active: #10B981`, `expired: #EF4444`, `completed: #0EA5E9`, fallback: `#64748B`
|
||||
- Center text: `totalPlans` count, "STATUS" label
|
||||
- Legend: only shows statuses with count > 0
|
||||
- Empty state: "No plans — run Atlas Sync"
|
||||
|
||||
All three follow the same SVG dimensions: 180px size, 72px outer radius, 48px inner radius.
|
||||
|
||||
### Frontend: Metric Graphs Panel Layout
|
||||
|
||||
When "Ivanti Findings" tab is active:
|
||||
- Existing four donuts in horizontal flex row with dividers (unchanged)
|
||||
- IvantiCountsChart rendered below the panel (unchanged)
|
||||
|
||||
When "Atlas Coverage" tab is active:
|
||||
- Three Atlas donuts in horizontal flex row with dividers (same layout pattern)
|
||||
- IvantiCountsChart hidden
|
||||
|
||||
## Data Models
|
||||
|
||||
### Metrics Endpoint Response
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `totalHosts` | integer | Count of all rows in `atlas_action_plans_cache` |
|
||||
| `hostsWithPlans` | integer | Count of rows where `has_action_plan = 1` |
|
||||
| `hostsWithoutPlans` | integer | Count of rows where `has_action_plan = 0` |
|
||||
| `plansByType` | object | Map of plan type string → integer count |
|
||||
| `plansByStatus` | object | Map of plan status string → integer count |
|
||||
| `totalPlans` | integer | Sum of all plans across all hosts |
|
||||
|
||||
### Existing Atlas Cache Table (no changes)
|
||||
|
||||
The `atlas_action_plans_cache` table is unchanged. The metrics endpoint reads from it:
|
||||
|
||||
| Column | Type | Used by metrics endpoint |
|
||||
|--------|------|--------------------------|
|
||||
| `has_action_plan` | INTEGER | Counting hosts with/without plans |
|
||||
| `plans_json` | TEXT | Parsed to count plans by type and status |
|
||||
|
||||
### Plan Object Shape (within `plans_json`)
|
||||
|
||||
The metrics endpoint reads `plan_type` and `status` from each plan object in the JSON array. The Atlas API returns these fields as strings. Example:
|
||||
|
||||
```json
|
||||
{
|
||||
"action_plan_id": "ap-123",
|
||||
"plan_type": "remediation",
|
||||
"status": "active",
|
||||
"commit_date": "2026-07-01"
|
||||
}
|
||||
```
|
||||
|
||||
Known `plan_type` values: `decommission`, `remediation`, `false_positive`, `risk_acceptance`, `scan_exclusion`
|
||||
|
||||
Known `status` values: `active`, `expired`, `completed` (the frontend handles unknown values with a neutral color)
|
||||
|
||||
|
||||
## Correctness Properties
|
||||
|
||||
*A property is a characteristic or behavior that should hold true across all valid executions of a system — essentially, a formal statement about what the system should do. Properties serve as the bridge between human-readable specifications and machine-verifiable correctness guarantees.*
|
||||
|
||||
### Property 1: Metrics aggregation correctness
|
||||
|
||||
*For any* array of cache rows — each with a `has_action_plan` flag (0 or 1) and a `plans_json` string that is either valid JSON containing an array of plan objects (each with `plan_type` and `status` fields) or invalid JSON — the metrics aggregation function SHALL produce:
|
||||
- `totalHosts` equal to the number of rows
|
||||
- `hostsWithPlans + hostsWithoutPlans` equal to `totalHosts`
|
||||
- `hostsWithPlans` equal to the count of rows where `has_action_plan === 1`
|
||||
- `totalPlans` equal to the sum of plan array lengths across all rows with valid JSON
|
||||
- Each key in `plansByType` maps to the count of plans with that `plan_type` across all valid rows
|
||||
- Each key in `plansByStatus` maps to the count of plans with that `status` across all valid rows
|
||||
- Rows with invalid `plans_json` are counted in `totalHosts` and `hostsWithPlans`/`hostsWithoutPlans` but their plans are excluded from `plansByType`, `plansByStatus`, and `totalPlans`
|
||||
|
||||
**Validates: Requirements 1.3, 1.4, 1.5**
|
||||
|
||||
### Property 2: Coverage donut data correctness
|
||||
|
||||
*For any* non-negative integer pair `(hostsWithPlans, hostsWithoutPlans)` where `totalHosts = hostsWithPlans + hostsWithoutPlans > 0`, the Coverage Donut SHALL display `totalHosts` as center text, and the legend SHALL show counts matching the input values with percentages that equal `(count / totalHosts) * 100` for each segment.
|
||||
|
||||
**Validates: Requirements 3.3, 3.4**
|
||||
|
||||
### Property 3: Plan type donut data correctness
|
||||
|
||||
*For any* `plansByType` object mapping plan type strings to positive integer counts, and `totalPlans` equal to the sum of those counts, the Plan Type Donut SHALL display `totalPlans` as center text, the legend SHALL include only types with count greater than zero, and each legend entry's percentage SHALL equal `(count / totalPlans) * 100`.
|
||||
|
||||
**Validates: Requirements 4.3, 4.4**
|
||||
|
||||
### Property 4: Plan status donut data correctness
|
||||
|
||||
*For any* `plansByStatus` object mapping status strings to positive integer counts, and `totalPlans` equal to the sum of those counts, the Plan Status Donut SHALL display `totalPlans` as center text, the legend SHALL include only statuses with count greater than zero, and each legend entry's percentage SHALL equal `(count / totalPlans) * 100`.
|
||||
|
||||
**Validates: Requirements 5.3, 5.4**
|
||||
|
||||
### Property 5: Plan status color assignment
|
||||
|
||||
*For any* status string, the Plan Status Donut color assignment function SHALL return `#10B981` if the status is `"active"`, `#EF4444` if `"expired"`, `#0EA5E9` if `"completed"`, and `#64748B` for any other string value.
|
||||
|
||||
**Validates: Requirements 5.2**
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Backend Errors
|
||||
|
||||
| Error Scenario | Handling | HTTP Status | Response |
|
||||
|----------------|----------|-------------|----------|
|
||||
| Atlas not configured (missing env vars) | Return 503 with descriptive message | 503 | `{ error: 'Atlas API is not configured...' }` |
|
||||
| Database query failure | Catch error, log, return 500 | 500 | `{ error: 'Failed to fetch Atlas metrics.' }` |
|
||||
| Invalid JSON in `plans_json` column | Skip that row's plan details, continue processing | N/A (handled internally) | Metrics still returned with correct counts for valid rows |
|
||||
| Empty cache table | Return metrics object with all zeros | 200 | `{ totalHosts: 0, hostsWithPlans: 0, hostsWithoutPlans: 0, plansByType: {}, plansByStatus: {}, totalPlans: 0 }` |
|
||||
| Unauthenticated request | Auth middleware rejects | 401 | `{ error: 'Authentication required' }` |
|
||||
|
||||
### Frontend Errors
|
||||
|
||||
| Error Scenario | Handling | User Impact |
|
||||
|----------------|----------|-------------|
|
||||
| Metrics fetch returns non-200 | Store error message in `atlasMetricsError` state | Error message displayed in Atlas Coverage tab content area |
|
||||
| Metrics fetch network failure | Catch error, store message | Error message displayed with failure reason |
|
||||
| Metrics fetch in progress | `atlasMetricsLoading = true` | Loading spinner shown in Atlas Coverage tab content area |
|
||||
| Atlas metrics data is null (not yet fetched) | Donut components check for null/undefined | Loading state or empty state shown |
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Unit Tests
|
||||
|
||||
Unit tests cover specific examples, edge cases, and integration points:
|
||||
|
||||
**Backend — Metrics Aggregation (`GET /api/atlas/metrics`)**:
|
||||
- Returns all-zero metrics when cache table is empty
|
||||
- Correctly counts hosts with and without plans from seeded data
|
||||
- Correctly aggregates plansByType from multiple hosts
|
||||
- Correctly aggregates plansByStatus from multiple hosts
|
||||
- Skips plan details for rows with invalid JSON but still counts the host
|
||||
- Returns 503 when Atlas is not configured
|
||||
- Requires authentication (returns 401 without session)
|
||||
|
||||
**Frontend — Tab System**:
|
||||
- Renders two tabs with correct labels
|
||||
- Defaults to "Ivanti Findings" tab on mount
|
||||
- Switches content when tab is clicked
|
||||
- Active tab has correct ARIA attributes (`aria-selected="true"`)
|
||||
- Tab panel has `role="tabpanel"` attribute
|
||||
- Keyboard navigation works (Tab + Enter)
|
||||
|
||||
**Frontend — Atlas Donut Charts**:
|
||||
- Coverage donut shows "No data — run Atlas Sync" when totalHosts is 0
|
||||
- Plan type donut shows "No plans — run Atlas Sync" when totalPlans is 0
|
||||
- Plan status donut shows "No plans — run Atlas Sync" when totalPlans is 0
|
||||
- SVG dimensions are 180px with 72px outer and 48px inner radius
|
||||
- Color assignments match specification for each plan type
|
||||
- Color assignments match specification for known statuses
|
||||
|
||||
**Frontend — Data Fetching**:
|
||||
- Fetches metrics on mount
|
||||
- Re-fetches metrics after successful Atlas sync
|
||||
- Does not re-fetch on tab switch
|
||||
- Shows loading indicator while fetch is in progress
|
||||
- Shows error message when fetch fails
|
||||
|
||||
**Frontend — IvantiCountsChart Visibility**:
|
||||
- Rendered when Ivanti tab is active
|
||||
- Not rendered when Atlas tab is active
|
||||
|
||||
### Property-Based Tests
|
||||
|
||||
Property-based tests verify universal properties across generated inputs. Each test runs a minimum of 100 iterations.
|
||||
|
||||
**Library**: [fast-check](https://github.com/dubzzz/fast-check) — the standard PBT library for JavaScript/Node.js.
|
||||
|
||||
**Configuration**: Each property test runs with `{ numRuns: 100 }` minimum.
|
||||
|
||||
**Tag format**: Each test includes a comment referencing its design property:
|
||||
```javascript
|
||||
// Feature: atlas-metrics-report, Property 1: Metrics aggregation correctness
|
||||
```
|
||||
|
||||
| Property | Test Description | Generator Strategy |
|
||||
|----------|-----------------|-------------------|
|
||||
| Property 1 | Metrics aggregation correctness | Generate arrays of objects with `has_action_plan` (0 or 1) and `plans_json` (either valid JSON array of `{plan_type, status}` objects or random invalid strings). Extract the aggregation logic into a pure function, call it with generated input, verify all invariants. |
|
||||
| Property 2 | Coverage donut data correctness | Generate random `(hostsWithPlans, hostsWithoutPlans)` pairs of non-negative integers (at least one > 0). Render `AtlasCoverageDonut`, verify center text equals sum and legend percentages are mathematically correct. |
|
||||
| Property 3 | Plan type donut data correctness | Generate random `plansByType` objects with 1–5 plan type keys mapped to positive integers. Render `AtlasPlanTypeDonut`, verify center text equals sum and legend entries match input. |
|
||||
| Property 4 | Plan status donut data correctness | Generate random `plansByStatus` objects with 1–4 status keys mapped to positive integers. Render `AtlasPlanStatusDonut`, verify center text equals sum and legend entries match input. |
|
||||
| Property 5 | Plan status color assignment | Generate random strings (mix of known statuses and arbitrary strings). Verify the color function returns the correct color for known statuses and the fallback for unknowns. |
|
||||
|
||||
### Integration Tests
|
||||
|
||||
- Full metrics flow: seed `atlas_action_plans_cache` with varied data → call `GET /api/atlas/metrics` → verify response matches expected aggregation
|
||||
- Empty cache flow: ensure empty cache returns all-zero metrics
|
||||
- Corrupt data flow: seed cache with mix of valid and invalid `plans_json` → verify metrics are correct for valid rows
|
||||
124
.kiro/specs/atlas-metrics-report/requirements.md
Normal file
124
.kiro/specs/atlas-metrics-report/requirements.md
Normal file
@@ -0,0 +1,124 @@
|
||||
# Requirements Document
|
||||
|
||||
## Introduction
|
||||
|
||||
Add a tab system to the existing Metric Graphs panel on the ReportingPage so that Atlas-specific coverage metrics live alongside the existing Ivanti donut charts without cluttering the current layout. The existing four donut charts (Open vs Closed, Action Coverage, FP Finding Status, FP Workflow Status) move under an "Ivanti Findings" tab. A new "Atlas Coverage" tab displays donut charts derived from the cached Atlas action plan data — plan coverage, plan type breakdown, and plan status distribution. A new backend endpoint on the existing Atlas router aggregates the cached data into chart-ready metrics, keeping server.js untouched.
|
||||
|
||||
## Glossary
|
||||
|
||||
- **Dashboard**: The STEAM Security Dashboard frontend React application
|
||||
- **ReportingPage**: The existing frontend page (`frontend/src/components/pages/ReportingPage.js`) displaying Ivanti host findings, metric charts, and the findings table
|
||||
- **Metric_Graphs_Panel**: The existing panel on the ReportingPage containing four SVG donut charts and the IvantiCountsChart trend line
|
||||
- **Tab_System**: A horizontal tab bar added to the Metric_Graphs_Panel header that switches between Ivanti and Atlas chart views
|
||||
- **Atlas_Cache**: The existing `atlas_action_plans_cache` SQLite table storing per-host action plan status, including `plans_json`
|
||||
- **Atlas_Router**: The existing Express route module (`backend/routes/atlas.js`) mounted at `/api/atlas`
|
||||
- **Atlas_Metrics_Endpoint**: A new `GET /api/atlas/metrics` endpoint on the Atlas_Router that returns aggregated chart data
|
||||
- **Coverage_Donut**: A donut chart showing hosts with action plans vs hosts without action plans
|
||||
- **Plan_Type_Donut**: A donut chart showing the distribution of action plans across the five plan types (decommission, remediation, false_positive, risk_acceptance, scan_exclusion)
|
||||
- **Plan_Status_Donut**: A donut chart showing the distribution of action plans across their status values (e.g. active, expired, completed)
|
||||
- **Action_Plan**: A compliance plan in Atlas with a type, commit date, and status — stored in the `plans_json` column of the Atlas_Cache
|
||||
- **Host_ID**: The shared numeric identifier linking an Ivanti host finding to an Atlas host
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement 1: Atlas Metrics Aggregation Endpoint
|
||||
|
||||
**User Story:** As a frontend developer, I want a single endpoint that returns pre-aggregated Atlas metrics, so that the frontend can render donut charts without parsing raw plan JSON on the client.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. THE Atlas_Router SHALL expose a `GET /api/atlas/metrics` endpoint that requires authentication
|
||||
2. WHEN the metrics endpoint is called, THE Atlas_Router SHALL query all rows from the Atlas_Cache table including the `plans_json` column
|
||||
3. WHEN rows are retrieved, THE Atlas_Router SHALL compute and return a JSON object containing: `totalHosts` (integer count of all cached hosts), `hostsWithPlans` (integer count of hosts where `has_action_plan` equals 1), `hostsWithoutPlans` (integer count of hosts where `has_action_plan` equals 0), `plansByType` (object mapping each plan type string to its integer count across all hosts), `plansByStatus` (object mapping each plan status string to its integer count across all hosts), and `totalPlans` (integer sum of all plans across all hosts)
|
||||
4. WHEN the Atlas_Cache table is empty, THE Atlas_Router SHALL return the metrics object with all counts set to zero and `plansByType` and `plansByStatus` as empty objects
|
||||
5. IF a row's `plans_json` column contains invalid JSON, THEN THE Atlas_Router SHALL skip that row's plan details and continue processing remaining rows
|
||||
6. THE Atlas_Metrics_Endpoint SHALL NOT modify server.js — the endpoint SHALL be added to the existing Atlas_Router module only
|
||||
|
||||
### Requirement 2: Tab System in Metric Graphs Panel
|
||||
|
||||
**User Story:** As a dashboard user, I want the Metric Graphs panel to have tabs, so that I can switch between Ivanti findings metrics and Atlas coverage metrics without the panel becoming overcrowded.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. THE Dashboard SHALL display a horizontal tab bar in the Metric_Graphs_Panel header area, to the right of the "Metric Graphs" title
|
||||
2. THE Tab_System SHALL contain exactly two tabs labeled "Ivanti Findings" and "Atlas Coverage"
|
||||
3. WHEN the ReportingPage loads, THE Tab_System SHALL default to the "Ivanti Findings" tab as the active tab
|
||||
4. WHEN the "Ivanti Findings" tab is active, THE Metric_Graphs_Panel SHALL display the existing four donut charts (Open vs Closed, Action Coverage, FP Finding Status, FP Workflow Status) in their current layout
|
||||
5. WHEN the "Atlas Coverage" tab is active, THE Metric_Graphs_Panel SHALL display the Atlas-specific donut charts (Coverage_Donut, Plan_Type_Donut, Plan_Status_Donut) in a horizontal flex row matching the existing chart layout pattern
|
||||
6. THE Tab_System SHALL visually indicate the active tab using the design system accent color and a bottom border highlight
|
||||
7. THE Tab_System SHALL use monospace font, uppercase text, and letter spacing consistent with the existing Metric_Graphs_Panel header style
|
||||
8. WHEN the user switches tabs, THE Metric_Graphs_Panel content SHALL update immediately without a full page reload
|
||||
|
||||
### Requirement 3: Atlas Coverage Donut Chart
|
||||
|
||||
**User Story:** As a dashboard user, I want to see what percentage of cached hosts have Atlas action plans, so that I can gauge overall compliance coverage at a glance.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. WHEN the "Atlas Coverage" tab is active, THE Dashboard SHALL display a Coverage_Donut chart showing hosts with plans vs hosts without plans
|
||||
2. THE Coverage_Donut SHALL use emerald (`#10B981`) for hosts with plans and amber (`#F59E0B`) for hosts without plans
|
||||
3. THE Coverage_Donut SHALL display the total host count as center text with a "HOSTS" label below it
|
||||
4. THE Coverage_Donut SHALL include a legend showing the count and percentage for each segment
|
||||
5. WHEN the Atlas_Cache contains no data, THE Coverage_Donut SHALL display a "No data — run Atlas Sync" message instead of an empty chart
|
||||
6. THE Coverage_Donut SHALL follow the same SVG donut dimensions and styling as the existing StatusDonut component (180px size, 72px outer radius, 48px inner radius)
|
||||
|
||||
### Requirement 4: Plan Type Distribution Donut Chart
|
||||
|
||||
**User Story:** As a dashboard user, I want to see how action plans are distributed across plan types, so that I can understand the remediation strategy mix.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. WHEN the "Atlas Coverage" tab is active, THE Dashboard SHALL display a Plan_Type_Donut chart showing the count of plans per type
|
||||
2. THE Plan_Type_Donut SHALL assign a distinct color to each plan type: decommission (`#EF4444`), remediation (`#0EA5E9`), false_positive (`#A855F7`), risk_acceptance (`#F59E0B`), scan_exclusion (`#64748B`)
|
||||
3. THE Plan_Type_Donut SHALL display the total plan count as center text with a "PLANS" label below it
|
||||
4. THE Plan_Type_Donut SHALL include a legend showing the label, count, and percentage for each plan type that has a count greater than zero
|
||||
5. WHEN no plans exist in the Atlas_Cache, THE Plan_Type_Donut SHALL display a "No plans — run Atlas Sync" message instead of an empty chart
|
||||
6. THE Plan_Type_Donut SHALL follow the same SVG donut dimensions and styling as the existing donut chart components
|
||||
|
||||
### Requirement 5: Plan Status Distribution Donut Chart
|
||||
|
||||
**User Story:** As a dashboard user, I want to see how action plans are distributed across statuses, so that I can identify how many plans are active vs expired or completed.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. WHEN the "Atlas Coverage" tab is active, THE Dashboard SHALL display a Plan_Status_Donut chart showing the count of plans per status value
|
||||
2. THE Plan_Status_Donut SHALL assign colors to known statuses: active (`#10B981`), expired (`#EF4444`), completed (`#0EA5E9`), and use a neutral color (`#64748B`) for any unrecognized status values
|
||||
3. THE Plan_Status_Donut SHALL display the total plan count as center text with a "STATUS" label below it
|
||||
4. THE Plan_Status_Donut SHALL include a legend showing the label, count, and percentage for each status that has a count greater than zero
|
||||
5. WHEN no plans exist in the Atlas_Cache, THE Plan_Status_Donut SHALL display a "No plans — run Atlas Sync" message instead of an empty chart
|
||||
6. THE Plan_Status_Donut SHALL follow the same SVG donut dimensions and styling as the existing donut chart components
|
||||
|
||||
### Requirement 6: Atlas Metrics Data Fetching
|
||||
|
||||
**User Story:** As a frontend developer, I want the Atlas metrics fetched efficiently and kept in sync with the Atlas cache, so that the charts reflect the latest synced data without unnecessary API calls.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. WHEN the ReportingPage mounts, THE Dashboard SHALL fetch Atlas metrics from `GET /api/atlas/metrics` and store the result in component state
|
||||
2. WHEN an Atlas sync completes successfully (via the existing Atlas sync button), THE Dashboard SHALL re-fetch Atlas metrics from `GET /api/atlas/metrics` to update the charts
|
||||
3. WHILE the Atlas metrics fetch is in progress, THE Dashboard SHALL display a loading indicator in the Atlas Coverage tab content area
|
||||
4. IF the Atlas metrics fetch fails, THEN THE Dashboard SHALL display an error message in the Atlas Coverage tab content area with the failure reason
|
||||
5. THE Dashboard SHALL NOT fetch Atlas metrics on every tab switch — the data SHALL be fetched once on mount and refreshed only after a sync operation
|
||||
|
||||
### Requirement 7: IvantiCountsChart Visibility with Tabs
|
||||
|
||||
**User Story:** As a dashboard user, I want the IvantiCountsChart trend line to remain visible when the Ivanti Findings tab is active, so that the existing reporting experience is preserved.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. WHEN the "Ivanti Findings" tab is active, THE Dashboard SHALL display the IvantiCountsChart trend line below the Metric_Graphs_Panel, matching its current position
|
||||
2. WHEN the "Atlas Coverage" tab is active, THE Dashboard SHALL hide the IvantiCountsChart trend line since it is not relevant to Atlas metrics
|
||||
3. THE IvantiCountsChart component SHALL continue to function identically to its current behavior when visible
|
||||
|
||||
### Requirement 8: Tab System Styling and Accessibility
|
||||
|
||||
**User Story:** As a dashboard user, I want the tab system to be visually consistent with the existing design system and keyboard accessible, so that the interface feels cohesive and usable.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. THE Tab_System tabs SHALL use inline styles consistent with the design system: dark background, monospace font at 0.7rem, uppercase text, 0.08em letter spacing
|
||||
2. THE active tab SHALL have a bottom border of 2px solid using the panel accent color (`#F59E0B`) and brighter text color (`#F59E0B`)
|
||||
3. THE inactive tab SHALL have muted text color (`#64748B`) and no bottom border highlight
|
||||
4. WHEN the user hovers over an inactive tab, THE tab SHALL display a subtle background color change to indicate interactivity
|
||||
5. THE Tab_System tabs SHALL use `role="tab"`, `aria-selected`, and `role="tabpanel"` attributes for screen reader accessibility
|
||||
6. THE Tab_System tabs SHALL be keyboard navigable using Tab and Enter keys
|
||||
163
.kiro/specs/atlas-metrics-report/tasks.md
Normal file
163
.kiro/specs/atlas-metrics-report/tasks.md
Normal file
@@ -0,0 +1,163 @@
|
||||
# Implementation Plan: Atlas Metrics Report
|
||||
|
||||
## Overview
|
||||
|
||||
Add a tab system to the Metric Graphs panel on the ReportingPage, with an "Ivanti Findings" tab (existing donuts) and an "Atlas Coverage" tab (three new donut charts). A new `GET /api/atlas/metrics` endpoint aggregates cached Atlas action plan data into chart-ready metrics. All backend changes stay within `backend/routes/atlas.js`. Frontend changes are in `frontend/src/components/pages/ReportingPage.js`.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] 1. Implement the Atlas metrics aggregation endpoint
|
||||
- [x] 1.1 Add `GET /metrics` route inside the existing `createAtlasRouter` factory function in `backend/routes/atlas.js`
|
||||
- Query all rows from `atlas_action_plans_cache` using the existing `dbAll` helper: `SELECT has_action_plan, plans_json FROM atlas_action_plans_cache`
|
||||
- Extract the aggregation logic into a pure function `aggregateAtlasMetrics(rows)` that takes an array of `{ has_action_plan, plans_json }` objects and returns `{ totalHosts, hostsWithPlans, hostsWithoutPlans, plansByType, plansByStatus, totalPlans }`
|
||||
- For each row: count hosts with/without plans based on `has_action_plan`; parse `plans_json` and count plans by `plan_type` and `status`; skip plan details for rows with invalid JSON
|
||||
- Return 503 if Atlas is not configured; return 500 on DB errors; require authentication via `requireAuth(db)`
|
||||
- Return all-zero metrics with empty objects when the cache table is empty
|
||||
- Do NOT modify `server.js` — the route is added inside the existing router factory
|
||||
- _Requirements: 1.1, 1.2, 1.3, 1.4, 1.5, 1.6_
|
||||
|
||||
- [x] 1.2 Write property test for metrics aggregation (Property 1)
|
||||
- **Property 1: Metrics aggregation correctness**
|
||||
- Extract `aggregateAtlasMetrics` as a pure exported function for testability
|
||||
- Use fast-check to generate arrays of objects with `has_action_plan` (0 or 1) and `plans_json` (valid JSON arrays of `{ plan_type, status }` objects or invalid strings)
|
||||
- Verify: `totalHosts === rows.length`, `hostsWithPlans + hostsWithoutPlans === totalHosts`, `hostsWithPlans` equals count of rows where `has_action_plan === 1`, `totalPlans` equals sum of valid plan array lengths, `plansByType` and `plansByStatus` counts match individual plan fields, rows with invalid JSON are counted in host totals but excluded from plan counts
|
||||
- **Validates: Requirements 1.3, 1.4, 1.5**
|
||||
|
||||
- [ ]* 1.3 Write unit tests for the metrics endpoint
|
||||
- Test empty cache returns all-zero metrics
|
||||
- Test correct host counting with seeded data
|
||||
- Test correct plansByType and plansByStatus aggregation
|
||||
- Test rows with invalid `plans_json` are handled gracefully
|
||||
- Test 503 response when Atlas is not configured
|
||||
- Test 401 response for unauthenticated requests
|
||||
- _Requirements: 1.1, 1.2, 1.3, 1.4, 1.5_
|
||||
|
||||
- [x] 2. Checkpoint — Verify backend endpoint
|
||||
- Ensure all tests pass, ask the user if questions arise.
|
||||
|
||||
- [x] 3. Add tab system to the Metric Graphs panel
|
||||
- [x] 3.1 Add tab state and tab bar UI to the Metric Graphs panel header in `ReportingPage.js`
|
||||
- Add `metricsTab` state initialized to `'ivanti'`
|
||||
- Render a horizontal tab bar to the right of the "Metric Graphs" title with two tabs: "Ivanti Findings" and "Atlas Coverage"
|
||||
- Active tab styling: `color: #F59E0B`, `borderBottom: 2px solid #F59E0B`
|
||||
- Inactive tab styling: `color: #64748B`, no bottom border
|
||||
- Hover on inactive: `background: rgba(245, 158, 11, 0.06)`
|
||||
- Font: `'JetBrains Mono', monospace`, `0.7rem`, `uppercase`, `letterSpacing: 0.08em`
|
||||
- Add `role="tab"`, `aria-selected` attributes on tabs; `role="tabpanel"` on content area
|
||||
- Tabs navigable via Tab and Enter keys
|
||||
- _Requirements: 2.1, 2.2, 2.3, 2.6, 2.7, 2.8, 8.1, 8.2, 8.3, 8.4, 8.5, 8.6_
|
||||
|
||||
- [x] 3.2 Conditionally render Ivanti donuts vs Atlas content based on active tab
|
||||
- When `metricsTab === 'ivanti'`: show existing four donut charts in their current layout (unchanged)
|
||||
- When `metricsTab === 'atlas'`: show placeholder for Atlas donut charts (to be implemented in task 5)
|
||||
- _Requirements: 2.4, 2.5_
|
||||
|
||||
- [x] 3.3 Conditionally render IvantiCountsChart based on active tab
|
||||
- Show `IvantiCountsChart` only when `metricsTab === 'ivanti'`
|
||||
- Hide it when `metricsTab === 'atlas'`
|
||||
- _Requirements: 7.1, 7.2, 7.3_
|
||||
|
||||
- [x] 4. Add Atlas metrics data fetching
|
||||
- [x] 4.1 Add Atlas metrics state and fetch function to `ReportingPage.js`
|
||||
- Add state: `atlasMetrics` (null), `atlasMetricsLoading` (false), `atlasMetricsError` (null)
|
||||
- Add `fetchAtlasMetrics` callback that calls `GET /api/atlas/metrics` with `credentials: 'include'`
|
||||
- On success: store data in `atlasMetrics`; on error: store message in `atlasMetricsError`
|
||||
- Set loading state during fetch
|
||||
- _Requirements: 6.1, 6.3, 6.4_
|
||||
|
||||
- [x] 4.2 Call `fetchAtlasMetrics` on mount and after successful Atlas sync
|
||||
- Add `fetchAtlasMetrics()` call in the existing mount `useEffect`
|
||||
- After a successful Atlas sync (existing sync handler), call `fetchAtlasMetrics()` to refresh
|
||||
- Do NOT re-fetch on tab switch
|
||||
- _Requirements: 6.1, 6.2, 6.5_
|
||||
|
||||
- [x] 5. Implement Atlas donut chart components
|
||||
- [x] 5.1 Implement `AtlasCoverageDonut` component in `ReportingPage.js`
|
||||
- Props: `{ hostsWithPlans, hostsWithoutPlans, totalHosts }`
|
||||
- Segments: emerald (`#10B981`) for with plans, amber (`#F59E0B`) for without plans
|
||||
- Center text: `totalHosts` count, "HOSTS" label
|
||||
- Legend: count and percentage for each segment
|
||||
- Empty state: "No data — run Atlas Sync" when `totalHosts === 0`
|
||||
- Reuse existing `polarToCartesian` and `donutArcPath` helpers; same dimensions (180px, 72px outer, 48px inner)
|
||||
- Follow the same SVG and styling pattern as `StatusDonut`
|
||||
- _Requirements: 3.1, 3.2, 3.3, 3.4, 3.5, 3.6_
|
||||
|
||||
- [x] 5.2 Implement `AtlasPlanTypeDonut` component in `ReportingPage.js`
|
||||
- Props: `{ plansByType, totalPlans }`
|
||||
- Color map: `decommission: #EF4444`, `remediation: #0EA5E9`, `false_positive: #A855F7`, `risk_acceptance: #F59E0B`, `scan_exclusion: #64748B`
|
||||
- Center text: `totalPlans` count, "PLANS" label
|
||||
- Legend: only show types with count > 0, with label, count, and percentage
|
||||
- Empty state: "No plans — run Atlas Sync" when `totalPlans === 0`
|
||||
- Same SVG dimensions and styling pattern
|
||||
- _Requirements: 4.1, 4.2, 4.3, 4.4, 4.5, 4.6_
|
||||
|
||||
- [x] 5.3 Implement `AtlasPlanStatusDonut` component in `ReportingPage.js`
|
||||
- Props: `{ plansByStatus, totalPlans }`
|
||||
- Color map: `active: #10B981`, `expired: #EF4444`, `completed: #0EA5E9`, fallback: `#64748B`
|
||||
- Extract a `getStatusColor(status)` helper function for color assignment
|
||||
- Center text: `totalPlans` count, "STATUS" label
|
||||
- Legend: only show statuses with count > 0, with label, count, and percentage
|
||||
- Empty state: "No plans — run Atlas Sync" when `totalPlans === 0`
|
||||
- Same SVG dimensions and styling pattern
|
||||
- _Requirements: 5.1, 5.2, 5.3, 5.4, 5.5, 5.6_
|
||||
|
||||
- [x] 5.4 Write property test for Coverage donut data correctness (Property 2)
|
||||
- **Property 2: Coverage donut data correctness**
|
||||
- Use fast-check to generate random `(hostsWithPlans, hostsWithoutPlans)` pairs of non-negative integers where at least one > 0
|
||||
- Render `AtlasCoverageDonut`, verify center text equals `totalHosts`, legend percentages equal `(count / totalHosts) * 100`
|
||||
- **Validates: Requirements 3.3, 3.4**
|
||||
|
||||
- [x] 5.5 Write property test for Plan type donut data correctness (Property 3)
|
||||
- **Property 3: Plan type donut data correctness**
|
||||
- Use fast-check to generate random `plansByType` objects with 1–5 plan type keys mapped to positive integers
|
||||
- Render `AtlasPlanTypeDonut`, verify center text equals sum of counts, legend entries match input, percentages are correct
|
||||
- **Validates: Requirements 4.3, 4.4**
|
||||
|
||||
- [x] 5.6 Write property test for Plan status donut data correctness (Property 4)
|
||||
- **Property 4: Plan status donut data correctness**
|
||||
- Use fast-check to generate random `plansByStatus` objects with 1–4 status keys mapped to positive integers
|
||||
- Render `AtlasPlanStatusDonut`, verify center text equals sum of counts, legend entries match input, percentages are correct
|
||||
- **Validates: Requirements 5.3, 5.4**
|
||||
|
||||
- [x] 5.7 Write property test for Plan status color assignment (Property 5)
|
||||
- **Property 5: Plan status color assignment**
|
||||
- Use fast-check to generate random strings (mix of known statuses and arbitrary strings)
|
||||
- Verify `getStatusColor` returns `#10B981` for "active", `#EF4444` for "expired", `#0EA5E9` for "completed", `#64748B` for any other string
|
||||
- **Validates: Requirements 5.2**
|
||||
|
||||
- [~]* 5.8 Write unit tests for Atlas donut components
|
||||
- Test Coverage donut empty state message when totalHosts is 0
|
||||
- Test Plan type donut empty state message when totalPlans is 0
|
||||
- Test Plan status donut empty state message when totalPlans is 0
|
||||
- Test SVG dimensions are 180px with correct outer/inner radius
|
||||
- Test color assignments for each plan type and status
|
||||
- _Requirements: 3.5, 4.5, 5.5, 3.6, 4.6, 5.6_
|
||||
|
||||
- [x] 6. Wire Atlas donuts into the Atlas Coverage tab
|
||||
- [x] 6.1 Render Atlas donut charts in the Atlas Coverage tab content area
|
||||
- When `metricsTab === 'atlas'`: render `AtlasCoverageDonut`, `AtlasPlanTypeDonut`, `AtlasPlanStatusDonut` in a horizontal flex row with dividers (same layout pattern as Ivanti donuts)
|
||||
- Pass data from `atlasMetrics` state to each donut component
|
||||
- Show loading indicator while `atlasMetricsLoading` is true
|
||||
- Show error message when `atlasMetricsError` is set
|
||||
- Add chart labels above each donut: "Host Coverage", "Plan Types", "Plan Status" — matching existing label style
|
||||
- _Requirements: 2.5, 3.1, 4.1, 5.1, 6.3, 6.4_
|
||||
|
||||
- [ ]* 6.2 Write integration tests for the full metrics flow
|
||||
- Test: fetch metrics on mount populates Atlas donut charts
|
||||
- Test: tab switch does not trigger re-fetch
|
||||
- Test: loading state shown during fetch
|
||||
- Test: error state shown on fetch failure
|
||||
- _Requirements: 6.1, 6.2, 6.3, 6.4, 6.5_
|
||||
|
||||
- [x] 7. Final checkpoint — Ensure all tests pass
|
||||
- Ensure all tests pass, ask the user if questions arise.
|
||||
|
||||
## Notes
|
||||
|
||||
- Tasks marked with `*` are optional and can be skipped for faster MVP
|
||||
- Each task references specific requirements for traceability
|
||||
- All backend changes are confined to `backend/routes/atlas.js` — server.js is NOT modified
|
||||
- The `aggregateAtlasMetrics` function is extracted as a pure function for testability and property-based testing
|
||||
- Property tests use fast-check with `{ numRuns: 100 }` minimum
|
||||
- Checkpoints ensure incremental validation after backend and full integration
|
||||
- The existing donut chart pattern (StatusDonut, ActionCoverageDonut, FPWorkflowDonut) serves as the template for all three Atlas donut components
|
||||
64
README.md
64
README.md
@@ -21,8 +21,7 @@ A self-hosted vulnerability management dashboard for the NTS-AEO-STEAM and NTS-A
|
||||
- [Knowledge Base](#knowledge-base)
|
||||
- [Exports](#exports)
|
||||
- [Archer Risk Acceptance Tickets](#archer-risk-acceptance-tickets)
|
||||
- [User Management (Admin)](#user-management-admin)
|
||||
- [Audit Log (Admin)](#audit-log-admin)
|
||||
- [Admin Panel](#admin-panel)
|
||||
- [Scripts](#scripts)
|
||||
- [API Reference](#api-reference)
|
||||
- [Architecture](#architecture)
|
||||
@@ -411,11 +410,18 @@ The Compliance page tracks NTS-AEO team posture against the AEO compliance frame
|
||||
Admin and Standard_User groups can upload a new compliance report via the **Upload Report** button:
|
||||
|
||||
1. Drop or browse for the `NTS_AEO_YYYY_MM_DD.xlsx` file
|
||||
2. The report is parsed server-side and a **diff preview** is shown — new violations, resolved items, and recurring items since the last upload
|
||||
3. Click **Confirm Upload** to commit. The upload is recorded and the device table updates immediately.
|
||||
2. The backend extracts the xlsx schema and runs a **drift check** against the parser configuration (`compliance_config.json`). If structural drift is detected, a drift review phase is shown before the diff preview:
|
||||
- **Breaking** findings (red) — missing core columns or detail sheets — block the upload until the config is updated
|
||||
- **Silent-miss** findings (amber) — unknown metrics or sheets that will be miscategorised — warn but allow proceeding
|
||||
- **Cosmetic** findings (muted) — new columns or stale config entries — informational only
|
||||
- Admins can click **Reconcile Config** to auto-patch the parser configuration and re-run the check
|
||||
3. If no breaking drift exists, the **diff preview** is shown — new violations, resolved items, and recurring items since the last upload
|
||||
4. Click **Confirm Upload** to commit. The upload is recorded and the device table updates immediately.
|
||||
|
||||
The report date is extracted automatically from the filename.
|
||||
|
||||
**Upload rollback:** Admins can roll back the most recent upload via `POST /api/compliance/rollback/:uploadId`. Rolling back deletes new items introduced by that upload, re-activates items it resolved, and decrements seen counts on recurring items.
|
||||
|
||||
#### Metric Health Cards
|
||||
|
||||
Each AEO metric (e.g., `2.3.4i`, `5.2.4`) is shown as a health card displaying:
|
||||
@@ -435,7 +441,7 @@ A slide-out panel for a selected device showing:
|
||||
- For **2.3.x vulnerability metrics**: the `Ivanti_Vulnerability_ID` is displayed with a **View in Reporting →** button that navigates directly to the Reporting page
|
||||
- **Resolved Metrics** — previously failing metrics now back in compliance
|
||||
- **History** — how many times the device has appeared on the report and since when
|
||||
- **Notes** — timestamped notes per metric with a multi-metric selector if multiple metrics are failing. Requires Admin or Standard_User group.
|
||||
- **Notes** — timestamped notes per metric with a multi-metric selector if multiple metrics are failing. Notes can be deleted by the author or an Admin — deleting a multi-metric note removes it from all linked metrics. Requires Admin or Standard_User group.
|
||||
|
||||
Notes persist across uploads and are keyed to the device hostname and metric ID.
|
||||
|
||||
@@ -479,20 +485,17 @@ Track Archer exception tickets (EXC numbers) linked to specific CVE/vendor pairs
|
||||
|
||||
---
|
||||
|
||||
### User Management (Admin)
|
||||
### Admin Panel
|
||||
|
||||
- Create users with a group assignment (Admin, Standard_User, Leadership, Read_Only)
|
||||
- Change username, email, password, group, or active status
|
||||
- Group changes require confirmation; downgrading an Admin shows an additional warning
|
||||
- Deactivating a user immediately invalidates all their active sessions
|
||||
- Admins cannot demote themselves or deactivate their own account
|
||||
- All group changes are audit-logged with previous and new group values
|
||||
The Admin Panel is a full-page, tabbed interface accessible only to Admin-group users. It replaces the previous inline modal rendering and follows the dashboard's dark tactical intelligence theme. Three tabs provide consolidated access to administrative functions:
|
||||
|
||||
---
|
||||
**User Management** — the default tab. Displays a themed user table with group badges (Admin in red, Standard_User in accent blue, Leadership in amber, Read_Only in muted grey). Admins can create, edit, and delete users, change group assignments, and toggle active status — all through inline forms styled to match the dashboard. Admins cannot demote themselves or deactivate their own account. Deactivating a user immediately invalidates all their active sessions. All group changes are audit-logged with previous and new group values.
|
||||
|
||||
### Audit Log (Admin)
|
||||
**Audit Log** — a paginated, filterable log table showing every state-changing action with timestamp, username, action type, entity type, entity ID, details, and IP address. Action types are colour-coded: login in green, delete in red, create in accent blue, update in amber. Filter by username, action type, entity type, and date range. Results are paginated at 25 per page.
|
||||
|
||||
Every state-changing action is recorded with the user identity, IP address, action type, target entity, and a before/after payload. Admins can view the log filtered by user, action type, entity type, and date range. Results are paginated (25 per page).
|
||||
**System Info** — stat cards showing total user count, active user count, total audit log entries, and users who logged in within the last 7 days. A "Recent Activity" section lists the 10 most recent audit log entries.
|
||||
|
||||
The `UserMenu` quick-access links ("Manage Users", "Audit Log") continue to open the existing modal components for fast access without navigating to the admin page.
|
||||
|
||||
---
|
||||
|
||||
@@ -500,9 +503,9 @@ Every state-changing action is recorded with the user identity, IP address, acti
|
||||
|
||||
### `backend/scripts/parse_compliance_xlsx.py`
|
||||
|
||||
Called automatically by the compliance upload flow. Parses the NTS_AEO xlsx report and outputs structured JSON to stdout for consumption by the Node compliance route.
|
||||
Called automatically by the compliance upload flow. Parses the NTS_AEO xlsx report and outputs structured JSON to stdout for consumption by the Node compliance route. Reads metric categories, core columns, and skip sheets from `compliance_config.json` (shared with the drift checker).
|
||||
|
||||
- Reads all detail sheets; skips `Summary` and `CMDB_9box`
|
||||
- Reads all detail sheets; skips sheets listed in `skip_sheets`
|
||||
- Filters to rows where `Compliant == False`
|
||||
- Extracts hostname, IP, device type, team, and metric ID per row
|
||||
- Captures all non-core columns in `extra_json` (CVEs, SLA status, OS, EoL, Splunk, MFA, Ivanti_Vulnerability_ID, etc.)
|
||||
@@ -511,6 +514,16 @@ Called automatically by the compliance upload flow. Parses the NTS_AEO xlsx repo
|
||||
|
||||
**Dependencies:** `pandas>=2.0.0`, `openpyxl>=3.0.0`
|
||||
|
||||
### `backend/scripts/extract_xlsx_schema.py`
|
||||
|
||||
Called by the preview endpoint before parsing. Extracts the structural schema of an xlsx file as JSON — sheet names, first-row column headers per sheet, and unique metric values from the Summary sheet. The Node.js drift checker compares this schema against `compliance_config.json` to detect breaking, silent-miss, and cosmetic drift.
|
||||
|
||||
**Dependencies:** `openpyxl>=3.0.0`
|
||||
|
||||
### `backend/scripts/compliance_config.json`
|
||||
|
||||
Shared parser configuration file — the single source of truth for `metric_categories` (metric ID → category mapping), `core_cols` (columns that become main item fields), and `skip_sheets` (sheets excluded from parsing). Read by both `parse_compliance_xlsx.py` and the Node.js `driftChecker.js` module. Admins can auto-patch this file via the **Reconcile Config** button in the upload modal.
|
||||
|
||||
---
|
||||
|
||||
## API Reference
|
||||
@@ -618,14 +631,17 @@ All endpoints are prefixed with `/api`. All endpoints except `/api/auth/login` a
|
||||
|
||||
| Method | Path | Group | Description |
|
||||
|---|---|---|---|
|
||||
| POST | `/api/compliance/preview` | Admin, Standard_User | Parse an xlsx upload and return diff + temp file path |
|
||||
| POST | `/api/compliance/preview` | Admin, Standard_User | Parse an xlsx upload, run drift check, and return drift report + diff + temp file path |
|
||||
| POST | `/api/compliance/commit` | Admin, Standard_User | Commit a previewed upload to the database |
|
||||
| POST | `/api/compliance/reconcile-config` | Admin | Auto-patch `compliance_config.json` to resolve breaking and silent-miss drift findings |
|
||||
| POST | `/api/compliance/rollback/:uploadId` | Admin | Roll back the most recent upload (deletes new items, re-activates resolved items) |
|
||||
| GET | `/api/compliance/uploads` | Any | List all compliance upload records |
|
||||
| GET | `/api/compliance/summary` | Any | Metric health summary; `?team=STEAM` |
|
||||
| GET | `/api/compliance/items` | Any | Device list; `?team=STEAM&status=active` |
|
||||
| GET | `/api/compliance/items/:hostname` | Any | Full detail for a device (metrics + notes) |
|
||||
| GET | `/api/compliance/notes/:hostname/:metricId` | Any | Notes for a specific hostname/metric |
|
||||
| POST | `/api/compliance/notes` | Admin, Standard_User | Add a note for a hostname/metric; accepts `metric_ids` array for multi-metric notes |
|
||||
| DELETE | `/api/compliance/notes/:id` | Admin, Standard_User | Delete a note by ID; `?group=true` deletes all notes sharing the same `group_id`. Author or Admin only. |
|
||||
|
||||
### Knowledge Base
|
||||
|
||||
@@ -705,9 +721,13 @@ cve-dashboard/
|
||||
│ ├── middleware/
|
||||
│ │ └── auth.js # requireAuth and requireGroup middleware
|
||||
│ ├── helpers/
|
||||
│ │ └── auditLog.js # logAudit helper (fire-and-forget)
|
||||
│ │ ├── auditLog.js # logAudit helper (fire-and-forget)
|
||||
│ │ ├── driftChecker.js # Schema drift detection: compareSchemaToDrift(), loadConfig(), reconcileConfig()
|
||||
│ │ └── ivantiApi.js # Ivanti API HTTP helpers (multipart, JSON, form POST)
|
||||
│ ├── migrations/ # Sequential migration scripts (run manually with node)
|
||||
│ └── scripts/
|
||||
│ ├── compliance_config.json # Shared parser config (metric_categories, core_cols, skip_sheets)
|
||||
│ ├── extract_xlsx_schema.py # Extracts xlsx structure as JSON for drift checking
|
||||
│ ├── parse_compliance_xlsx.py # Parses NTS_AEO xlsx compliance reports
|
||||
│ ├── import_notes_from_csv.py # Bulk-import finding notes from CSV
|
||||
│ └── requirements.txt # pandas, openpyxl
|
||||
@@ -723,14 +743,16 @@ cve-dashboard/
|
||||
├── NavDrawer.js # Side navigation drawer (Admin Panel link for Admin group)
|
||||
├── UserMenu.js # User dropdown in header (shows group badge)
|
||||
├── CalendarWidget.js # Due-date calendar with Ivanti finding indicators
|
||||
├── UserManagement.js # Admin user management panel (group assignment)
|
||||
├── AuditLog.js # Admin audit log viewer
|
||||
├── UserManagement.js # Admin user management modal (quick-access from UserMenu)
|
||||
├── AuditLog.js # Admin audit log modal (quick-access from UserMenu)
|
||||
├── NvdSyncModal.js # Bulk NVD sync dialog
|
||||
├── KnowledgeBaseModal.js # Knowledge base upload/list modal
|
||||
├── KnowledgeBaseViewer.js # Inline document viewer (sandboxed iframe, sanitized markdown)
|
||||
├── ConfirmModal.js # Themed confirmation dialog (replaces window.confirm)
|
||||
├── CveTooltip.js # Hover tooltip for CVE badges (portal-rendered, cached)
|
||||
├── RedirectModal.js # Queue item redirect modal (workflow type + vendor selection)
|
||||
└── pages/
|
||||
├── AdminPage.js # Admin panel: user management, audit log, system info
|
||||
├── ReportingPage.js # Host findings: charts, table, queue, export
|
||||
├── CompliancePage.js # AEO compliance: metric cards, device table
|
||||
├── ComplianceUploadModal.js # xlsx upload with diff preview
|
||||
|
||||
@@ -31,16 +31,96 @@ function dbAll(db, sql, params = []) {
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pure aggregation function — exported for testability
|
||||
// ---------------------------------------------------------------------------
|
||||
function aggregateAtlasMetrics(rows) {
|
||||
const result = {
|
||||
totalHosts: rows.length,
|
||||
hostsWithPlans: 0,
|
||||
hostsWithoutPlans: 0,
|
||||
plansByType: {},
|
||||
plansByStatus: {},
|
||||
totalPlans: 0
|
||||
};
|
||||
|
||||
for (const row of rows) {
|
||||
if (row.has_action_plan === 1) {
|
||||
result.hostsWithPlans++;
|
||||
} else {
|
||||
result.hostsWithoutPlans++;
|
||||
}
|
||||
|
||||
let plans;
|
||||
try {
|
||||
plans = JSON.parse(row.plans_json);
|
||||
} catch (e) {
|
||||
// Invalid JSON — skip plan details for this row
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!Array.isArray(plans)) continue;
|
||||
|
||||
for (const plan of plans) {
|
||||
result.totalPlans++;
|
||||
|
||||
if (plan.plan_type) {
|
||||
result.plansByType[plan.plan_type] = (result.plansByType[plan.plan_type] || 0) + 1;
|
||||
}
|
||||
|
||||
if (plan.status) {
|
||||
result.plansByStatus[plan.status] = (result.plansByStatus[plan.status] || 0) + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Router factory
|
||||
// ---------------------------------------------------------------------------
|
||||
function createAtlasRouter(db, requireAuth) {
|
||||
const router = express.Router();
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// GET /metrics
|
||||
// Return aggregated Atlas metrics for chart rendering.
|
||||
// Auth: any authenticated user
|
||||
//
|
||||
// Response 200:
|
||||
// { totalHosts: number, hostsWithPlans: number, hostsWithoutPlans: number,
|
||||
// plansByType: { [type: string]: number }, plansByStatus: { [status: string]: number },
|
||||
// totalPlans: number }
|
||||
// Response 503: { error: string } — Atlas not configured
|
||||
// Response 500: { error: string } — DB query failure
|
||||
// -----------------------------------------------------------------------
|
||||
router.get('/metrics', 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 has_action_plan, plans_json FROM atlas_action_plans_cache`
|
||||
);
|
||||
const metrics = aggregateAtlasMetrics(rows);
|
||||
res.json(metrics);
|
||||
} catch (err) {
|
||||
console.error('[Atlas] Error fetching metrics:', err.message);
|
||||
res.status(500).json({ error: 'Failed to fetch Atlas metrics.' });
|
||||
}
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// GET /status
|
||||
// Return all cached Atlas rows for badge rendering.
|
||||
// Auth: any authenticated user
|
||||
//
|
||||
// Response 200:
|
||||
// [ { host_id: number, has_action_plan: 0|1, plan_count: number, synced_at: string }, ... ]
|
||||
// Response 503: { error: string } — Atlas not configured
|
||||
// Response 500: { error: string } — DB query failure
|
||||
// -----------------------------------------------------------------------
|
||||
router.get('/status', requireAuth(db), async (req, res) => {
|
||||
if (!isConfigured) {
|
||||
@@ -62,6 +142,12 @@ function createAtlasRouter(db, requireAuth) {
|
||||
// POST /sync
|
||||
// Sync Atlas action plan data for all hosts found in the Ivanti cache.
|
||||
// Auth: Admin or Standard_User
|
||||
//
|
||||
// Request body: none
|
||||
// Response 200:
|
||||
// { synced: number, withPlans: number, failed: number }
|
||||
// Response 503: { error: string } — Atlas not configured
|
||||
// Response 500: { error: string } — sync failure or Ivanti cache parse error
|
||||
// -----------------------------------------------------------------------
|
||||
router.post('/sync', requireAuth(db), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||
if (!isConfigured) {
|
||||
@@ -187,6 +273,12 @@ function createAtlasRouter(db, requireAuth) {
|
||||
// GET /hosts/:hostId/action-plans
|
||||
// Proxy to Atlas API — returns live action plan data for a single host.
|
||||
// Auth: any authenticated user
|
||||
//
|
||||
// Params: hostId (positive integer)
|
||||
// Response 2xx: proxied Atlas response body (parsed JSON or raw)
|
||||
// Response 400: { error: string } — invalid hostId
|
||||
// Response 503: { error: string } — Atlas not configured
|
||||
// Response 502: { error: string } — Atlas API unreachable
|
||||
// -----------------------------------------------------------------------
|
||||
router.get('/hosts/:hostId/action-plans', requireAuth(db), async (req, res) => {
|
||||
if (!isConfigured) {
|
||||
@@ -229,6 +321,16 @@ function createAtlasRouter(db, requireAuth) {
|
||||
// PUT /hosts/:hostId/action-plans
|
||||
// Create a new action plan for a host.
|
||||
// Auth: Admin or Standard_User
|
||||
//
|
||||
// Params: hostId (positive integer)
|
||||
// Request body:
|
||||
// { plan_type: string (one of VALID_PLAN_TYPES), commit_date: string (YYYY-MM-DD),
|
||||
// qualys_id?: string, active_host_findings_id?: string,
|
||||
// jira_vnr?: string, archer_exc?: string }
|
||||
// Response 2xx: proxied Atlas response body
|
||||
// Response 400: { error: string } — invalid hostId, plan_type, or commit_date
|
||||
// Response 503: { error: string } — Atlas not configured
|
||||
// Response 502: { error: string } — Atlas API unreachable
|
||||
// -----------------------------------------------------------------------
|
||||
router.put('/hosts/:hostId/action-plans', requireAuth(db), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||
if (!isConfigured) {
|
||||
@@ -290,6 +392,14 @@ function createAtlasRouter(db, requireAuth) {
|
||||
// PATCH /hosts/:hostId/action-plans
|
||||
// Update an existing action plan for a host.
|
||||
// Auth: Admin or Standard_User
|
||||
//
|
||||
// Params: hostId (positive integer)
|
||||
// Request body:
|
||||
// { action_plan_id: string (non-empty), updates: object (non-null, non-array) }
|
||||
// Response 2xx: proxied Atlas response body
|
||||
// Response 400: { error: string } — invalid hostId, action_plan_id, or updates
|
||||
// Response 503: { error: string } — Atlas not configured
|
||||
// Response 502: { error: string } — Atlas API unreachable
|
||||
// -----------------------------------------------------------------------
|
||||
router.patch('/hosts/:hostId/action-plans', requireAuth(db), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||
if (!isConfigured) {
|
||||
@@ -351,6 +461,15 @@ function createAtlasRouter(db, requireAuth) {
|
||||
// POST /hosts/bulk-action-plans
|
||||
// Create action plans for multiple hosts at once.
|
||||
// Auth: Admin or Standard_User
|
||||
//
|
||||
// Request body:
|
||||
// { host_ids: number[] (non-empty, positive integers),
|
||||
// plan_type: string (one of VALID_PLAN_TYPES),
|
||||
// commit_date: string (YYYY-MM-DD) }
|
||||
// Response 2xx: proxied Atlas response body
|
||||
// Response 400: { error: string } — invalid host_ids, plan_type, or commit_date
|
||||
// Response 503: { error: string } — Atlas not configured
|
||||
// Response 502: { error: string } — Atlas API unreachable
|
||||
// -----------------------------------------------------------------------
|
||||
router.post('/hosts/bulk-action-plans', requireAuth(db), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||
if (!isConfigured) {
|
||||
@@ -407,3 +526,4 @@ function createAtlasRouter(db, requireAuth) {
|
||||
}
|
||||
|
||||
module.exports = createAtlasRouter;
|
||||
module.exports.aggregateAtlasMetrics = aggregateAtlasMetrics;
|
||||
|
||||
337
docs/security-audit-tracker.md
Normal file
337
docs/security-audit-tracker.md
Normal file
@@ -0,0 +1,337 @@
|
||||
# Security Audit Tracker — STEAM Security Dashboard
|
||||
|
||||
**Last scan:** 2026-04-20
|
||||
**Scope:** Full repository — backend routes, middleware, helpers, scripts, frontend components
|
||||
**Baseline:** `docs/security-audit-2026-04-01.md` (31 findings), `docs/security-remediation-plan.md` (17 prioritised items)
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Remediation Status — April 1 Audit](#remediation-status--april-1-audit)
|
||||
- [New Findings — April 20 Scan](#new-findings--april-20-scan)
|
||||
- [Open Finding Summary](#open-finding-summary)
|
||||
- [Positive Security Observations](#positive-security-observations)
|
||||
- [Scan Metadata](#scan-metadata)
|
||||
|
||||
---
|
||||
|
||||
## Remediation Status — April 1 Audit
|
||||
|
||||
Cross-reference of the 31 original findings against the current codebase. Status: **Fixed**, **Partial**, or **Open**.
|
||||
|
||||
### Critical Findings
|
||||
|
||||
| ID | Title | Status | Evidence |
|
||||
|---|---|---|---|
|
||||
| C-1 | Missing auth on Ivanti findings endpoints | **Fixed** | `ivantiFindings.js` — router uses `requireAuth(db)` at router level, `requireGroup` on sync |
|
||||
| C-2 | `requireRole(db)` bypasses role check in KB routes | **Fixed** | `knowledgeBase.js` — uses `requireGroup('Admin', 'Standard_User')` correctly |
|
||||
| C-3 | Unauthenticated finding note writes | **Fixed** | `ivantiFindings.js` — note routes behind `requireAuth(db)` |
|
||||
| C-4 | No brute force protection on login | **Fixed** | `auth.js` — `loginLimiter` (20 attempts / 15 min) applied to POST /login |
|
||||
| C-5 | Default credentials displayed in login UI | **Fixed** | `LoginForm.js` — no hardcoded credentials in the component |
|
||||
| C-6 | Missing sandbox on KB document iframe | **Fixed** | `KnowledgeBaseViewer.js:282` — `sandbox="allow-same-origin"` applied |
|
||||
|
||||
### High Findings
|
||||
|
||||
| ID | Title | Status | Evidence |
|
||||
|---|---|---|---|
|
||||
| H-1 | `/cleanup-sessions` missing role check | **Fixed** | `auth.js` — `requireAuth(db), requireGroup('Admin')` applied |
|
||||
| H-2 | Hardcoded fallback SESSION_SECRET | **Fixed** | `server.js:34-37` — hard-fails with `process.exit(1)` if unset |
|
||||
| H-3 | Audit log parameter mismatch — silent trail gaps | **Partial** | `knowledgeBase.js` — fixed. `archerTickets.js` — `logAudit` calls missing `username` field (see N-1 below) |
|
||||
| H-4 | Viewers can write compliance notes | **Fixed** | `compliance.js` — `requireGroup('Admin', 'Standard_User')` on POST /notes |
|
||||
| H-5 | Sync endpoints accessible to all authenticated users | **Fixed** | Both `ivantiFindings.js` and `ivantiWorkflows.js` — `requireGroup('Admin', 'Standard_User')` on POST /sync |
|
||||
| H-6 | HTTP header injection via Content-Disposition filename | **Fixed** | `knowledgeBase.js` — filename sanitized with `.replace(/["\r\n\\]/g, '')` |
|
||||
| H-7 | Race condition in KB file upload | **Fixed** | `knowledgeBase.js` — file moved after DB insert succeeds |
|
||||
| H-8 | Hardcoded default admin password in setup.js | **Fixed** | `setup.js` — generates random password via `crypto.randomBytes(12)` |
|
||||
| H-9 | ReactMarkdown renders HTML without sanitization | **Fixed** | `KnowledgeBaseViewer.js` — `rehypeSanitize` plugin applied |
|
||||
|
||||
### Medium Findings
|
||||
|
||||
| ID | Title | Status | Evidence |
|
||||
|---|---|---|---|
|
||||
| M-1 | No CSRF token protection | **Open** | Cookies use `SameSite: lax` — no CSRF token implemented |
|
||||
| M-2 | CORS credentials with explicit origin list | **Open** | Acceptable for this deployment model — monitor |
|
||||
| M-3 | No rate limiting on NVD API proxy | **Open** | No server-side cache or per-user rate limit on `/api/nvd/lookup` |
|
||||
| M-4 | Admin self-demotion check uses loose equality | **Fixed** | `users.js` — uses `String(userId) === String(req.user.id)` |
|
||||
| M-5 | Missing hostname format validation | **Fixed** | `compliance.js` POST /notes — regex validation `^[a-zA-Z0-9._-]+$` |
|
||||
| M-6 | Vendor field validated before trim | **Open** | `ivantiTodoQueue.js:8` — `isValidVendor()` checks length before trim |
|
||||
| M-7 | Unsanitized original filename in temp JSON | **Open** | `compliance.js:344` — `req.file.originalname` passed directly |
|
||||
| M-8 | Hardcoded frontend IP in CSP header | **Fixed** | `knowledgeBase.js:302` — reads from `CORS_ORIGINS` env var |
|
||||
| M-9 | API error messages forwarded to UI | **Open** | Frontend still uses `alert(err.message)` in several places |
|
||||
| M-10 | User data in window.confirm dialogs | **Open** | Frontend still uses `window.confirm` with user-supplied data |
|
||||
|
||||
### Low / Info Findings
|
||||
|
||||
| ID | Title | Status | Evidence |
|
||||
|---|---|---|---|
|
||||
| L-1 | Silent ROLLBACK on transaction failure | **Open** | `compliance.js:167` — `.catch(() => {})` still swallows errors |
|
||||
| L-2 | Fire-and-forget audit logging | **Partial** | `auditLog.js` — now logs to `console.error` on failure, but no alerting |
|
||||
| L-3 | Async temp file cleanup with no error handling | **Open** | `compliance.js` — `fs.unlink(path, () => {})` still used |
|
||||
| L-4 | IVANTI_SKIP_TLS with no startup warning | **Open** | No startup warning when `IVANTI_SKIP_TLS=true` |
|
||||
| L-5 | console.error in production frontend | **Open** | No environment guard on console.error calls |
|
||||
| L-6 | localStorage column config lacks structural validation | **Open** | No change observed |
|
||||
|
||||
### Remediation Plan Items (not in original 31)
|
||||
|
||||
| ID | Title | Status | Evidence |
|
||||
|---|---|---|---|
|
||||
| RP-1 | Authenticate /uploads static file access | **Open** | `server.js:127` — `express.static('uploads')` still unauthenticated |
|
||||
| RP-2 | Sanitize Mermaid SVG output with DOMPurify | **Open** | `KnowledgeBaseViewer.js:38` — `innerHTML = svg` without DOMPurify |
|
||||
| RP-3 | Strip server file paths from compliance preview response | **Open** | `compliance.js:342` — full `tempFilePath` returned to client |
|
||||
| RP-4 | Add SESSION_SECRET to .env.example | **Open** | `.env.example` — no `SESSION_SECRET` entry |
|
||||
|
||||
---
|
||||
|
||||
## New Findings — April 20 Scan
|
||||
|
||||
Findings discovered in this scan that were not present in the April 1 audit.
|
||||
|
||||
---
|
||||
|
||||
### N-1 — Archer Ticket Audit Logs Missing `username` Field (Medium)
|
||||
|
||||
**File:** `backend/routes/archerTickets.js:89, 172, 195`
|
||||
|
||||
All three `logAudit` calls in the Archer tickets router omit the `username` field:
|
||||
|
||||
```js
|
||||
logAudit(db, {
|
||||
userId: req.user.id,
|
||||
action: 'CREATE_ARCHER_TICKET',
|
||||
// username: req.user.username ← missing
|
||||
...
|
||||
});
|
||||
```
|
||||
|
||||
The `auditLog.js` helper defaults missing username to `'unknown'`, so all Archer ticket audit entries show `username = 'unknown'` instead of the actual user.
|
||||
|
||||
**Impact:** Audit trail for Archer ticket operations cannot identify which user performed the action. Compliance reviews and incident investigations are degraded.
|
||||
|
||||
**Fix:** Add `username: req.user.username` to all three `logAudit` calls.
|
||||
|
||||
---
|
||||
|
||||
### N-2 — `migrate-to-1.1.js` Contains Hardcoded Admin Password (Medium)
|
||||
|
||||
**File:** `backend/migrate-to-1.1.js:246`
|
||||
|
||||
```js
|
||||
const passwordHash = await bcrypt.hash('admin123', 10);
|
||||
```
|
||||
|
||||
While `setup.js` was fixed to generate random passwords (H-8), the migration script still hardcodes `admin123`. If this migration is run on an existing deployment, it resets the admin password to a known value.
|
||||
|
||||
**Impact:** Running the migration on a production system resets the admin account to a publicly known password.
|
||||
|
||||
**Fix:** Either generate a random password (matching `setup.js` pattern) or skip admin creation if the user already exists.
|
||||
|
||||
---
|
||||
|
||||
### N-3 — Compliance Preview Returns Full Server Filesystem Path (Medium)
|
||||
|
||||
**File:** `backend/routes/compliance.js:342`
|
||||
|
||||
```js
|
||||
tempFile: tempFilePath,
|
||||
```
|
||||
|
||||
The preview endpoint returns the full server-side path (e.g. `/home/cve-dashboard/backend/uploads/temp/compliance_preview_...json`) to the frontend. The commit endpoint then receives this path back and reads the file. This exposes the server's directory structure to any authenticated user.
|
||||
|
||||
**Impact:** Information disclosure — authenticated users learn the server's absolute filesystem layout, which aids further exploitation.
|
||||
|
||||
**Fix:** Return only the filename. Reconstruct the full path server-side in the commit handler:
|
||||
```js
|
||||
tempFile: tempFilename, // just the basename
|
||||
// In commit handler:
|
||||
const tempFile = path.join(TEMP_DIR, path.basename(req.body.tempFile));
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### N-4 — `/uploads` Static Directory Served Without Authentication (High)
|
||||
|
||||
**File:** `backend/server.js:127`
|
||||
|
||||
```js
|
||||
app.use('/uploads', express.static('uploads', {
|
||||
dotfiles: 'deny',
|
||||
index: false
|
||||
}));
|
||||
```
|
||||
|
||||
All uploaded files (CVE documents, compliance data, knowledge base articles) are served as static files without any authentication check. Anyone who knows or guesses a file URL can access sensitive vulnerability documentation, compliance reports, and internal knowledge base content.
|
||||
|
||||
**Impact:** Unauthenticated access to all uploaded documents. File paths are predictable (CVE ID + vendor + timestamp-filename pattern).
|
||||
|
||||
**Fix:** Replace with an authenticated route handler:
|
||||
```js
|
||||
app.use('/uploads', requireAuth(db), express.static('uploads', { ... }));
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### N-5 — Mermaid SVG Rendered via `innerHTML` Without Sanitization (Medium)
|
||||
|
||||
**File:** `frontend/src/components/KnowledgeBaseViewer.js:38`
|
||||
|
||||
```js
|
||||
ref.current.innerHTML = svg;
|
||||
```
|
||||
|
||||
Mermaid-generated SVG is injected directly into the DOM via `innerHTML`. While Mermaid itself sanitizes most input, a crafted diagram definition in a knowledge base article could potentially produce SVG with embedded event handlers or script elements.
|
||||
|
||||
**Impact:** Stored XSS vector if Mermaid's internal sanitization is bypassed. Any user viewing the article would execute the payload.
|
||||
|
||||
**Fix:** Sanitize the SVG string before injection:
|
||||
```js
|
||||
import DOMPurify from 'dompurify';
|
||||
ref.current.innerHTML = DOMPurify.sanitize(svg, { USE_PROFILES: { svg: true } });
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### N-6 — `SESSION_SECRET` Not Documented in `.env.example` (Low)
|
||||
|
||||
**File:** `backend/.env.example`
|
||||
|
||||
The `SESSION_SECRET` environment variable is required for the server to start (hard-fail added per H-2 fix), but it is not listed in `.env.example`. Fresh deployments will fail with no guidance on what to set.
|
||||
|
||||
**Fix:** Add to `.env.example`:
|
||||
```
|
||||
# Session signing secret — generate with: openssl rand -hex 32
|
||||
SESSION_SECRET=
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### N-7 — `requireGroup` Error Response Leaks Current User Group (Low)
|
||||
|
||||
**File:** `backend/middleware/auth.js:55-60`
|
||||
|
||||
```js
|
||||
return res.status(403).json({
|
||||
error: 'Insufficient permissions',
|
||||
required: allowedGroups,
|
||||
current: req.user.group
|
||||
});
|
||||
```
|
||||
|
||||
The 403 response includes both the required groups and the user's current group. This is minor information disclosure — an attacker probing endpoints learns the exact group membership of the compromised account and which groups are needed.
|
||||
|
||||
**Fix:** Remove `required` and `current` from the response:
|
||||
```js
|
||||
return res.status(403).json({ error: 'Insufficient permissions' });
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### N-8 — No Content-Security-Policy Header on Main Application (Medium)
|
||||
|
||||
**File:** `backend/server.js:107-113`
|
||||
|
||||
Security headers include `X-Content-Type-Options`, `X-Frame-Options`, `X-XSS-Protection`, `Referrer-Policy`, and `Permissions-Policy`, but no `Content-Security-Policy` header. CSP is the primary browser-side defense against XSS.
|
||||
|
||||
**Impact:** No browser-enforced restriction on script sources. If an XSS vulnerability exists (e.g. N-5), there is no CSP to mitigate it.
|
||||
|
||||
**Fix:** Add a baseline CSP header:
|
||||
```js
|
||||
res.setHeader('Content-Security-Policy',
|
||||
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; " +
|
||||
"img-src 'self' data:; font-src 'self'; connect-src 'self'");
|
||||
```
|
||||
Start with `Content-Security-Policy-Report-Only` to avoid breaking existing functionality.
|
||||
|
||||
---
|
||||
|
||||
### N-9 — Expired Sessions Not Cleaned Up Automatically (Low)
|
||||
|
||||
**File:** `backend/server.js`, `backend/routes/auth.js`
|
||||
|
||||
The `sessions` table has no automatic cleanup. Expired sessions accumulate indefinitely. The `/cleanup-sessions` endpoint exists but must be triggered manually by an admin.
|
||||
|
||||
**Impact:** Performance degradation over time as the sessions table grows. Not directly exploitable, but expired session rows increase the surface for timing attacks on session lookups.
|
||||
|
||||
**Fix:** Add a cleanup interval on server startup:
|
||||
```js
|
||||
setInterval(() => {
|
||||
db.run("DELETE FROM sessions WHERE expires_at < datetime('now')");
|
||||
}, 6 * 60 * 60 * 1000); // every 6 hours
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Open Finding Summary
|
||||
|
||||
Prioritised list of all open findings requiring action.
|
||||
|
||||
### High Priority
|
||||
|
||||
| ID | Severity | Title | Source |
|
||||
|---|---|---|---|
|
||||
| N-4 | High | `/uploads` static directory served without authentication | New |
|
||||
|
||||
### Medium Priority
|
||||
|
||||
| ID | Severity | Title | Source |
|
||||
|---|---|---|---|
|
||||
| M-1 | Medium | No CSRF token protection | April 1 |
|
||||
| M-3 | Medium | No rate limiting on NVD API proxy | April 1 |
|
||||
| N-1 | Medium | Archer ticket audit logs missing `username` field | New |
|
||||
| N-2 | Medium | `migrate-to-1.1.js` contains hardcoded admin password | New |
|
||||
| N-3 | Medium | Compliance preview returns full server filesystem path | New |
|
||||
| N-5 | Medium | Mermaid SVG rendered via `innerHTML` without sanitization | New |
|
||||
| N-8 | Medium | No Content-Security-Policy header on main application | New |
|
||||
| M-6 | Medium | Vendor field validated before trim | April 1 |
|
||||
| M-7 | Medium | Unsanitized original filename in temp JSON | April 1 |
|
||||
| M-9 | Medium | API error messages forwarded to UI | April 1 |
|
||||
| M-10 | Medium | User data in `window.confirm` dialogs | April 1 |
|
||||
|
||||
### Low Priority
|
||||
|
||||
| ID | Severity | Title | Source |
|
||||
|---|---|---|---|
|
||||
| N-6 | Low | `SESSION_SECRET` not documented in `.env.example` | New |
|
||||
| N-7 | Low | `requireGroup` error response leaks current user group | New |
|
||||
| N-9 | Low | Expired sessions not cleaned up automatically | New |
|
||||
| L-1 | Low | Silent ROLLBACK on transaction failure | April 1 |
|
||||
| L-3 | Low | Async temp file cleanup with no error handling | April 1 |
|
||||
| L-4 | Low | IVANTI_SKIP_TLS with no startup warning | April 1 |
|
||||
| L-5 | Low | console.error in production frontend | April 1 |
|
||||
| L-6 | Low | localStorage column config lacks structural validation | April 1 |
|
||||
|
||||
---
|
||||
|
||||
## Positive Security Observations
|
||||
|
||||
Verified secure patterns that should be preserved:
|
||||
|
||||
- **SQL injection prevention** — all queries use parameterized statements throughout the entire codebase
|
||||
- **Path traversal prevention** — `sanitizePathSegment()` and `isPathWithinUploads()` consistently applied in `server.js`, `compliance.js`, and `knowledgeBase.js`
|
||||
- **Python script execution** — `spawn('python3', [SCRIPT, filePath])` with argument arrays — no shell injection
|
||||
- **File upload security** — extension allowlist + MIME prefix validation + 10 MB size limit via multer
|
||||
- **Password hashing** — bcrypt with cost factor 10 used for all password storage
|
||||
- **Session management** — 32-byte random session IDs via `crypto.randomBytes`, httpOnly cookies, 24h expiry
|
||||
- **Rate limiting** — login endpoint protected with 20 attempts per 15-minute window
|
||||
- **Audit trail** — comprehensive audit logging on all state-changing operations (with noted exceptions above)
|
||||
- **Self-modification prevention** — admin cannot demote or deactivate their own account
|
||||
- **Ownership-scoped deletion** — Standard_User can only delete resources they created
|
||||
- **Compliance linkage protection** — deletion blocked when tickets are linked to active compliance reports
|
||||
- **Temp file path validation** — `isSafeTempPath()` enforces `.json` extension and `uploads/temp/` directory
|
||||
- **Static file serving** — `dotfiles: 'deny'` and `index: false` prevent directory listing
|
||||
|
||||
---
|
||||
|
||||
## Scan Metadata
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Scan date | 2026-04-20 |
|
||||
| Scan type | Full repository static analysis |
|
||||
| Scope | `backend/`, `frontend/src/`, config files |
|
||||
| Baseline | `docs/security-audit-2026-04-01.md` |
|
||||
| Previous findings | 31 (6 Critical, 9 High, 10 Medium, 6 Low/Info) |
|
||||
| Remediated | 20 fully fixed, 2 partially fixed |
|
||||
| Still open (from baseline) | 13 |
|
||||
| New findings | 9 |
|
||||
| Total open | 22 (1 High, 11 Medium, 10 Low) |
|
||||
| Methodology | Static analysis — code review of all route handlers, middleware, helpers, and frontend components |
|
||||
@@ -507,6 +507,229 @@ function FPWorkflowDonut({ counts, total, centerLabel = 'FP TOTAL' }) {
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Atlas Donut Charts — Coverage, Plan Type, Plan Status
|
||||
// ---------------------------------------------------------------------------
|
||||
const PLAN_TYPE_DEFS = [
|
||||
{ key: 'decommission', label: 'Decommission', color: '#EF4444' },
|
||||
{ key: 'remediation', label: 'Remediation', color: '#0EA5E9' },
|
||||
{ key: 'false_positive', label: 'False Positive', color: '#A855F7' },
|
||||
{ key: 'risk_acceptance', label: 'Risk Acceptance', color: '#F59E0B' },
|
||||
{ key: 'scan_exclusion', label: 'Scan Exclusion', color: '#64748B' },
|
||||
];
|
||||
|
||||
function getStatusColor(status) {
|
||||
if (status === 'active') return '#10B981';
|
||||
if (status === 'expired') return '#EF4444';
|
||||
if (status === 'completed') return '#0EA5E9';
|
||||
return '#64748B';
|
||||
}
|
||||
|
||||
function AtlasCoverageDonut({ hostsWithPlans, hostsWithoutPlans, totalHosts }) {
|
||||
const SIZE = 180;
|
||||
const CX = SIZE / 2;
|
||||
const CY = SIZE / 2;
|
||||
const OUTER = 72;
|
||||
const INNER = 48;
|
||||
|
||||
if (totalHosts === 0) {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: `${SIZE}px` }}>
|
||||
<p style={{ fontFamily: 'monospace', fontSize: '0.75rem', color: '#475569' }}>No data — run Atlas Sync</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const segments = [
|
||||
{ label: 'With Plans', count: hostsWithPlans, color: '#10B981', start: 0, end: (hostsWithPlans / totalHosts) * 360 },
|
||||
{ label: 'Without Plans', count: hostsWithoutPlans, color: '#F59E0B', start: (hostsWithPlans / totalHosts) * 360, end: 360 },
|
||||
].filter((s) => s.count > 0);
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '2rem' }}>
|
||||
<svg width={SIZE} height={SIZE} style={{ flexShrink: 0 }}>
|
||||
<circle cx={CX} cy={CY} r={OUTER + 1} fill="none" stroke="rgba(10,18,32,0.8)" strokeWidth="2" />
|
||||
{segments.map((seg) => (
|
||||
<path
|
||||
key={seg.label}
|
||||
d={donutArcPath(CX, CY, OUTER, INNER, seg.start, seg.end)}
|
||||
fill={seg.color}
|
||||
opacity={0.88}
|
||||
/>
|
||||
))}
|
||||
<text x={CX} y={CY - 10} textAnchor="middle" style={{ fontFamily: 'monospace', fontSize: '22px', fontWeight: '700', fill: '#E2E8F0' }}>
|
||||
{totalHosts.toLocaleString()}
|
||||
</text>
|
||||
<text x={CX} y={CY + 10} textAnchor="middle" style={{ fontFamily: 'monospace', fontSize: '8.5px', fontWeight: '600', fill: '#475569', letterSpacing: '0.12em' }}>
|
||||
HOSTS
|
||||
</text>
|
||||
</svg>
|
||||
|
||||
{/* Legend */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
||||
{segments.map((seg) => (
|
||||
<div key={seg.label} style={{ display: 'flex', alignItems: 'center', gap: '0.625rem' }}>
|
||||
<div style={{ width: '10px', height: '10px', borderRadius: '2px', background: seg.color, flexShrink: 0 }} />
|
||||
<div>
|
||||
<div style={{ fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '600', color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.08em' }}>
|
||||
{seg.label}
|
||||
</div>
|
||||
<div style={{ fontFamily: 'monospace', fontSize: '0.9rem', fontWeight: '700', color: '#E2E8F0', lineHeight: 1.2 }}>
|
||||
{seg.count.toLocaleString()}
|
||||
<span style={{ fontSize: '0.68rem', fontWeight: '400', color: '#64748B', marginLeft: '0.4rem' }}>
|
||||
({((seg.count / totalHosts) * 100).toFixed(1)}%)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AtlasPlanTypeDonut({ plansByType, totalPlans }) {
|
||||
const SIZE = 180;
|
||||
const CX = SIZE / 2;
|
||||
const CY = SIZE / 2;
|
||||
const OUTER = 72;
|
||||
const INNER = 48;
|
||||
|
||||
if (totalPlans === 0) {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: `${SIZE}px` }}>
|
||||
<p style={{ fontFamily: 'monospace', fontSize: '0.75rem', color: '#475569' }}>No plans — run Atlas Sync</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
let cursor = 0;
|
||||
const segments = PLAN_TYPE_DEFS.map((def) => {
|
||||
const count = plansByType[def.key] || 0;
|
||||
const start = cursor;
|
||||
const end = count > 0 ? cursor + (count / totalPlans) * 360 : cursor;
|
||||
if (count > 0) cursor = end;
|
||||
return { ...def, count, start, end };
|
||||
}).filter(s => s.count > 0);
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '2rem' }}>
|
||||
<svg width={SIZE} height={SIZE} style={{ flexShrink: 0 }}>
|
||||
<circle cx={CX} cy={CY} r={OUTER + 1} fill="none" stroke="rgba(10,18,32,0.8)" strokeWidth="2" />
|
||||
{segments.map((seg) => (
|
||||
<path
|
||||
key={seg.key}
|
||||
d={donutArcPath(CX, CY, OUTER, INNER, seg.start, seg.end)}
|
||||
fill={seg.color}
|
||||
opacity={0.88}
|
||||
/>
|
||||
))}
|
||||
<text x={CX} y={CY - 10} textAnchor="middle" style={{ fontFamily: 'monospace', fontSize: '22px', fontWeight: '700', fill: '#E2E8F0' }}>
|
||||
{totalPlans.toLocaleString()}
|
||||
</text>
|
||||
<text x={CX} y={CY + 10} textAnchor="middle" style={{ fontFamily: 'monospace', fontSize: '8.5px', fontWeight: '600', fill: '#475569', letterSpacing: '0.12em' }}>
|
||||
PLANS
|
||||
</text>
|
||||
</svg>
|
||||
|
||||
{/* Legend */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.6rem' }}>
|
||||
{segments.map((seg) => (
|
||||
<div key={seg.key} style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<div style={{ width: '8px', height: '8px', borderRadius: '2px', background: seg.color, flexShrink: 0 }} />
|
||||
<div>
|
||||
<span style={{ fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '600', color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.06em' }}>
|
||||
{seg.label}
|
||||
</span>
|
||||
<span style={{ fontFamily: 'monospace', fontSize: '0.8rem', fontWeight: '700', color: '#E2E8F0', marginLeft: '0.4rem' }}>
|
||||
{seg.count}
|
||||
</span>
|
||||
<span style={{ fontFamily: 'monospace', fontSize: '0.65rem', color: '#475569', marginLeft: '0.25rem' }}>
|
||||
({((seg.count / totalPlans) * 100).toFixed(0)}%)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AtlasPlanStatusDonut({ plansByStatus, totalPlans }) {
|
||||
const SIZE = 180;
|
||||
const CX = SIZE / 2;
|
||||
const CY = SIZE / 2;
|
||||
const OUTER = 72;
|
||||
const INNER = 48;
|
||||
|
||||
if (totalPlans === 0) {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: `${SIZE}px` }}>
|
||||
<p style={{ fontFamily: 'monospace', fontSize: '0.75rem', color: '#475569' }}>No plans — run Atlas Sync</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const entries = Object.entries(plansByStatus).filter(([, count]) => count > 0);
|
||||
|
||||
let cursor = 0;
|
||||
const segments = entries.map(([status, count]) => {
|
||||
const start = cursor;
|
||||
const end = cursor + (count / totalPlans) * 360;
|
||||
cursor = end;
|
||||
return {
|
||||
key: status,
|
||||
label: status.charAt(0).toUpperCase() + status.slice(1),
|
||||
color: getStatusColor(status),
|
||||
count,
|
||||
start,
|
||||
end,
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '2rem' }}>
|
||||
<svg width={SIZE} height={SIZE} style={{ flexShrink: 0 }}>
|
||||
<circle cx={CX} cy={CY} r={OUTER + 1} fill="none" stroke="rgba(10,18,32,0.8)" strokeWidth="2" />
|
||||
{segments.map((seg) => (
|
||||
<path
|
||||
key={seg.key}
|
||||
d={donutArcPath(CX, CY, OUTER, INNER, seg.start, seg.end)}
|
||||
fill={seg.color}
|
||||
opacity={0.88}
|
||||
/>
|
||||
))}
|
||||
<text x={CX} y={CY - 10} textAnchor="middle" style={{ fontFamily: 'monospace', fontSize: '22px', fontWeight: '700', fill: '#E2E8F0' }}>
|
||||
{totalPlans.toLocaleString()}
|
||||
</text>
|
||||
<text x={CX} y={CY + 10} textAnchor="middle" style={{ fontFamily: 'monospace', fontSize: '8.5px', fontWeight: '600', fill: '#475569', letterSpacing: '0.12em' }}>
|
||||
STATUS
|
||||
</text>
|
||||
</svg>
|
||||
|
||||
{/* Legend */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.6rem' }}>
|
||||
{segments.map((seg) => (
|
||||
<div key={seg.key} style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<div style={{ width: '8px', height: '8px', borderRadius: '2px', background: seg.color, flexShrink: 0 }} />
|
||||
<div>
|
||||
<span style={{ fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '600', color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.06em' }}>
|
||||
{seg.label}
|
||||
</span>
|
||||
<span style={{ fontFamily: 'monospace', fontSize: '0.8rem', fontWeight: '700', color: '#E2E8F0', marginLeft: '0.4rem' }}>
|
||||
{seg.count}
|
||||
</span>
|
||||
<span style={{ fontFamily: 'monospace', fontSize: '0.65rem', color: '#475569', marginLeft: '0.25rem' }}>
|
||||
({((seg.count / totalPlans) * 100).toFixed(0)}%)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SortIcon({ colKey, sort }) {
|
||||
if (sort.field !== colKey) return <ChevronsUpDown style={{ width: '11px', height: '11px', opacity: 0.3, marginLeft: '3px', flexShrink: 0 }} />;
|
||||
return sort.dir === 'asc'
|
||||
@@ -3632,6 +3855,7 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
|
||||
const hoverTimerRef = useRef(null);
|
||||
|
||||
// Atlas action plan state
|
||||
const [metricsTab, setMetricsTab] = useState('ivanti');
|
||||
const [atlasStatusMap, setAtlasStatusMap] = useState(new Map());
|
||||
const [atlasSyncing, setAtlasSyncing] = useState(false);
|
||||
const [atlasError, setAtlasError] = useState(null);
|
||||
@@ -3640,6 +3864,11 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
|
||||
const [atlasSelectedHostName, setAtlasSelectedHostName] = useState(null);
|
||||
const [atlasSelectedFindingId, setAtlasSelectedFindingId] = useState(null);
|
||||
|
||||
// Atlas metrics state (for Atlas Coverage tab donut charts)
|
||||
const [atlasMetrics, setAtlasMetrics] = useState(null);
|
||||
const [atlasMetricsLoading, setAtlasMetricsLoading] = useState(false);
|
||||
const [atlasMetricsError, setAtlasMetricsError] = useState(null);
|
||||
|
||||
const updateColumns = useCallback((newOrder) => {
|
||||
setColumnOrder(newOrder);
|
||||
saveColumnOrder(newOrder);
|
||||
@@ -3758,6 +3987,25 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchAtlasMetrics = useCallback(async () => {
|
||||
setAtlasMetricsLoading(true);
|
||||
setAtlasMetricsError(null);
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/atlas/metrics`, { credentials: 'include' });
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setAtlasMetrics(data);
|
||||
} else {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
setAtlasMetricsError(err.error || 'Failed to fetch Atlas metrics');
|
||||
}
|
||||
} catch (err) {
|
||||
setAtlasMetricsError(err.message);
|
||||
} finally {
|
||||
setAtlasMetricsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchFindings = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
@@ -3799,6 +4047,7 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
|
||||
fetchQueue();
|
||||
fetchFpSubmissions();
|
||||
fetchAtlasStatus();
|
||||
fetchAtlasMetrics();
|
||||
}, []); // eslint-disable-line
|
||||
|
||||
// Set/clear a single column filter
|
||||
@@ -4234,7 +4483,41 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
|
||||
<h2 style={{ fontFamily: 'monospace', fontSize: '1rem', fontWeight: '600', color: '#F59E0B', textTransform: 'uppercase', letterSpacing: '0.1em', textShadow: '0 0 12px rgba(245,158,11,0.4)', margin: 0 }}>
|
||||
Metric Graphs
|
||||
</h2>
|
||||
<div style={{ marginLeft: 'auto', display: 'flex', gap: '0.25rem' }} role="tablist">
|
||||
{[{ key: 'ivanti', label: 'Ivanti Findings' }, { key: 'atlas', label: 'Atlas Coverage' }].map(tab => {
|
||||
const isActive = metricsTab === tab.key;
|
||||
return (
|
||||
<button
|
||||
key={tab.key}
|
||||
role="tab"
|
||||
aria-selected={isActive}
|
||||
tabIndex={0}
|
||||
onClick={() => setMetricsTab(tab.key)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') setMetricsTab(tab.key); }}
|
||||
style={{
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
borderBottom: isActive ? '2px solid #F59E0B' : '2px solid transparent',
|
||||
color: isActive ? '#F59E0B' : '#64748B',
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.7rem',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.08em',
|
||||
padding: '0.375rem 0.75rem',
|
||||
cursor: 'pointer',
|
||||
transition: 'background 0.15s, color 0.15s'
|
||||
}}
|
||||
onMouseEnter={(e) => { if (!isActive) e.currentTarget.style.background = 'rgba(245, 158, 11, 0.06)'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; }}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div role="tabpanel">
|
||||
{metricsTab === 'ivanti' && (
|
||||
<div style={{ display: 'flex', gap: '3rem', flexWrap: 'wrap', alignItems: 'flex-start' }}>
|
||||
{/* Open vs Closed donut */}
|
||||
<div style={{ flex: '0 0 auto' }}>
|
||||
@@ -4289,12 +4572,68 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
|
||||
<FPWorkflowDonut counts={fpCounts.idCounts} total={fpCounts.idTotal} centerLabel="FP TICKETS" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{metricsTab === 'atlas' && (
|
||||
(atlasMetricsLoading || (!atlasMetrics && !atlasMetricsError)) ? (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '3rem 0', gap: '0.5rem' }}>
|
||||
<Loader style={{ width: '24px', height: '24px', color: '#0EA5E9', animation: 'spin 1s linear infinite' }} />
|
||||
</div>
|
||||
) : atlasMetricsError ? (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '2rem', gap: '0.375rem' }}>
|
||||
<AlertCircle style={{ width: '14px', height: '14px', color: '#EF4444', flexShrink: 0 }} />
|
||||
<span style={{ fontFamily: 'monospace', fontSize: '0.75rem', color: '#FCA5A5' }}>{atlasMetricsError}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', gap: '3rem', flexWrap: 'wrap', alignItems: 'flex-start' }}>
|
||||
{/* Host Coverage donut */}
|
||||
<div style={{ flex: '0 0 auto' }}>
|
||||
<div style={{ fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '600', color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: '0.75rem' }}>
|
||||
Host Coverage
|
||||
</div>
|
||||
<AtlasCoverageDonut
|
||||
hostsWithPlans={atlasMetrics.hostsWithPlans}
|
||||
hostsWithoutPlans={atlasMetrics.hostsWithoutPlans}
|
||||
totalHosts={atlasMetrics.totalHosts}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div style={{ width: '1px', background: 'rgba(255,255,255,0.06)', alignSelf: 'stretch', flexShrink: 0 }} />
|
||||
|
||||
{/* Plan Types donut */}
|
||||
<div style={{ flex: '0 0 auto' }}>
|
||||
<div style={{ fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '600', color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: '0.75rem' }}>
|
||||
Plan Types
|
||||
</div>
|
||||
<AtlasPlanTypeDonut
|
||||
plansByType={atlasMetrics.plansByType}
|
||||
totalPlans={atlasMetrics.totalPlans}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div style={{ width: '1px', background: 'rgba(255,255,255,0.06)', alignSelf: 'stretch', flexShrink: 0 }} />
|
||||
|
||||
{/* Plan Status donut */}
|
||||
<div style={{ flex: '0 0 auto' }}>
|
||||
<div style={{ fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '600', color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: '0.75rem' }}>
|
||||
Plan Status
|
||||
</div>
|
||||
<AtlasPlanStatusDonut
|
||||
plansByStatus={atlasMetrics.plansByStatus}
|
||||
totalPlans={atlasMetrics.totalPlans}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ----------------------------------------------------------------
|
||||
Panel 1.5 — Open vs Closed trend over time
|
||||
---------------------------------------------------------------- */}
|
||||
<IvantiCountsChart />
|
||||
{metricsTab === 'ivanti' && <IvantiCountsChart />}
|
||||
|
||||
{/* ----------------------------------------------------------------
|
||||
Panel 2 — Findings table
|
||||
@@ -4483,6 +4822,7 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
|
||||
throw new Error(data.error || 'Atlas sync failed');
|
||||
}
|
||||
await fetchAtlasStatus();
|
||||
await fetchAtlasMetrics();
|
||||
} catch (err) {
|
||||
setAtlasError(err.message);
|
||||
} finally {
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
// Feature: atlas-metrics-report, Property 1: Metrics aggregation correctness
|
||||
|
||||
import fc from 'fast-check';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock backend dependencies so we can import the pure function
|
||||
// without pulling in Express, SQLite, etc.
|
||||
// ---------------------------------------------------------------------------
|
||||
jest.mock('express', () => ({ Router: jest.fn(() => ({ get: jest.fn(), post: jest.fn(), put: jest.fn(), patch: jest.fn() })) }));
|
||||
jest.mock('../../../../../backend/middleware/auth', () => ({ requireGroup: jest.fn() }), { virtual: true });
|
||||
jest.mock('../../../../../backend/helpers/auditLog', () => jest.fn(), { virtual: true });
|
||||
jest.mock('../../../../../backend/helpers/atlasApi', () => ({
|
||||
isConfigured: false,
|
||||
atlasGet: jest.fn(),
|
||||
atlasPut: jest.fn(),
|
||||
atlasPatch: jest.fn(),
|
||||
atlasPost: jest.fn(),
|
||||
}), { virtual: true });
|
||||
|
||||
// Now import the pure function
|
||||
const { aggregateAtlasMetrics } = require('../../../../../backend/routes/atlas');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Generators
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const PLAN_TYPES = ['decommission', 'remediation', 'false_positive', 'risk_acceptance', 'scan_exclusion'];
|
||||
const PLAN_STATUSES = ['active', 'expired', 'completed'];
|
||||
|
||||
/** Generate a single plan object with plan_type and status */
|
||||
const planArb = fc.record({
|
||||
plan_type: fc.constantFrom(...PLAN_TYPES),
|
||||
status: fc.constantFrom(...PLAN_STATUSES),
|
||||
});
|
||||
|
||||
/** Generate a valid plans_json string (JSON array of plan objects) */
|
||||
const validPlansJsonArb = fc
|
||||
.array(planArb, { minLength: 0, maxLength: 10 })
|
||||
.map((plans) => JSON.stringify(plans));
|
||||
|
||||
/** Generate an invalid JSON string that will fail JSON.parse */
|
||||
const invalidPlansJsonArb = fc.constantFrom(
|
||||
'{bad json',
|
||||
'not json at all',
|
||||
'{{[',
|
||||
'',
|
||||
'undefined',
|
||||
);
|
||||
|
||||
/** Generate a plans_json value — either valid JSON or invalid */
|
||||
const plansJsonArb = fc.oneof(
|
||||
{ weight: 3, arbitrary: validPlansJsonArb },
|
||||
{ weight: 1, arbitrary: invalidPlansJsonArb },
|
||||
);
|
||||
|
||||
/** Generate a single cache row */
|
||||
const cacheRowArb = fc.record({
|
||||
has_action_plan: fc.constantFrom(0, 1),
|
||||
plans_json: plansJsonArb,
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper: manually compute expected metrics for comparison
|
||||
// ---------------------------------------------------------------------------
|
||||
function computeExpected(rows) {
|
||||
const expected = {
|
||||
totalHosts: rows.length,
|
||||
hostsWithPlans: 0,
|
||||
hostsWithoutPlans: 0,
|
||||
plansByType: {},
|
||||
plansByStatus: {},
|
||||
totalPlans: 0,
|
||||
};
|
||||
|
||||
for (const row of rows) {
|
||||
if (row.has_action_plan === 1) {
|
||||
expected.hostsWithPlans++;
|
||||
} else {
|
||||
expected.hostsWithoutPlans++;
|
||||
}
|
||||
|
||||
let plans;
|
||||
try {
|
||||
plans = JSON.parse(row.plans_json);
|
||||
} catch (e) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!Array.isArray(plans)) continue;
|
||||
|
||||
for (const plan of plans) {
|
||||
expected.totalPlans++;
|
||||
if (plan.plan_type) {
|
||||
expected.plansByType[plan.plan_type] = (expected.plansByType[plan.plan_type] || 0) + 1;
|
||||
}
|
||||
if (plan.status) {
|
||||
expected.plansByStatus[plan.status] = (expected.plansByStatus[plan.status] || 0) + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return expected;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Property 1: Metrics aggregation correctness
|
||||
// Validates: Requirements 1.3, 1.4, 1.5
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Property 1: Metrics aggregation correctness', () => {
|
||||
test('totalHosts equals rows.length', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.array(cacheRowArb, { minLength: 0, maxLength: 30 }),
|
||||
(rows) => {
|
||||
const result = aggregateAtlasMetrics(rows);
|
||||
expect(result.totalHosts).toBe(rows.length);
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
test('hostsWithPlans + hostsWithoutPlans equals totalHosts', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.array(cacheRowArb, { minLength: 0, maxLength: 30 }),
|
||||
(rows) => {
|
||||
const result = aggregateAtlasMetrics(rows);
|
||||
expect(result.hostsWithPlans + result.hostsWithoutPlans).toBe(result.totalHosts);
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
test('hostsWithPlans equals count of rows where has_action_plan === 1', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.array(cacheRowArb, { minLength: 0, maxLength: 30 }),
|
||||
(rows) => {
|
||||
const result = aggregateAtlasMetrics(rows);
|
||||
const expectedWithPlans = rows.filter((r) => r.has_action_plan === 1).length;
|
||||
expect(result.hostsWithPlans).toBe(expectedWithPlans);
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
test('totalPlans equals sum of valid plan array lengths', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.array(cacheRowArb, { minLength: 0, maxLength: 30 }),
|
||||
(rows) => {
|
||||
const result = aggregateAtlasMetrics(rows);
|
||||
const expected = computeExpected(rows);
|
||||
expect(result.totalPlans).toBe(expected.totalPlans);
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
test('plansByType and plansByStatus counts match individual plan fields', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.array(cacheRowArb, { minLength: 0, maxLength: 30 }),
|
||||
(rows) => {
|
||||
const result = aggregateAtlasMetrics(rows);
|
||||
const expected = computeExpected(rows);
|
||||
expect(result.plansByType).toEqual(expected.plansByType);
|
||||
expect(result.plansByStatus).toEqual(expected.plansByStatus);
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
test('rows with invalid JSON are counted in host totals but excluded from plan counts', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.array(
|
||||
fc.record({
|
||||
has_action_plan: fc.constantFrom(0, 1),
|
||||
plans_json: invalidPlansJsonArb,
|
||||
}),
|
||||
{ minLength: 1, maxLength: 20 },
|
||||
),
|
||||
(rows) => {
|
||||
const result = aggregateAtlasMetrics(rows);
|
||||
|
||||
// Host totals should still be correct
|
||||
expect(result.totalHosts).toBe(rows.length);
|
||||
expect(result.hostsWithPlans + result.hostsWithoutPlans).toBe(rows.length);
|
||||
|
||||
// No plans should be counted since all JSON is invalid
|
||||
expect(result.totalPlans).toBe(0);
|
||||
expect(result.plansByType).toEqual({});
|
||||
expect(result.plansByStatus).toEqual({});
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,400 @@
|
||||
// Feature: atlas-metrics-report
|
||||
// Property tests for Atlas donut chart data correctness and color assignment
|
||||
|
||||
import fc from 'fast-check';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Since the donut components and getStatusColor are defined inline in
|
||||
// ReportingPage.js and not exported, we replicate the exact data
|
||||
// transformation logic here and test the mathematical properties directly.
|
||||
// This validates that the formulas used in the components are correct
|
||||
// for all valid inputs.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Replicated logic from ReportingPage.js
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Coverage donut segment computation — mirrors AtlasCoverageDonut logic.
|
||||
*/
|
||||
function computeCoverageDonutData(hostsWithPlans, hostsWithoutPlans) {
|
||||
const totalHosts = hostsWithPlans + hostsWithoutPlans;
|
||||
if (totalHosts === 0) return { totalHosts, segments: [] };
|
||||
|
||||
const segments = [
|
||||
{ label: 'With Plans', count: hostsWithPlans, color: '#10B981', percentage: ((hostsWithPlans / totalHosts) * 100).toFixed(1) },
|
||||
{ label: 'Without Plans', count: hostsWithoutPlans, color: '#F59E0B', percentage: ((hostsWithoutPlans / totalHosts) * 100).toFixed(1) },
|
||||
].filter((s) => s.count > 0);
|
||||
|
||||
return { totalHosts, segments };
|
||||
}
|
||||
|
||||
/**
|
||||
* Plan type definitions — mirrors PLAN_TYPE_DEFS in ReportingPage.js.
|
||||
*/
|
||||
const PLAN_TYPE_DEFS = [
|
||||
{ key: 'decommission', label: 'Decommission', color: '#EF4444' },
|
||||
{ key: 'remediation', label: 'Remediation', color: '#0EA5E9' },
|
||||
{ key: 'false_positive', label: 'False Positive', color: '#A855F7' },
|
||||
{ key: 'risk_acceptance', label: 'Risk Acceptance', color: '#F59E0B' },
|
||||
{ key: 'scan_exclusion', label: 'Scan Exclusion', color: '#64748B' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Plan type donut segment computation — mirrors AtlasPlanTypeDonut logic.
|
||||
*/
|
||||
function computePlanTypeDonutData(plansByType) {
|
||||
const totalPlans = Object.values(plansByType).reduce((sum, c) => sum + c, 0);
|
||||
if (totalPlans === 0) return { totalPlans, segments: [] };
|
||||
|
||||
const segments = PLAN_TYPE_DEFS.map((def) => {
|
||||
const count = plansByType[def.key] || 0;
|
||||
return { ...def, count, percentage: ((count / totalPlans) * 100).toFixed(0) };
|
||||
}).filter((s) => s.count > 0);
|
||||
|
||||
return { totalPlans, segments };
|
||||
}
|
||||
|
||||
/**
|
||||
* Plan status color assignment — mirrors getStatusColor in ReportingPage.js.
|
||||
*/
|
||||
function getStatusColor(status) {
|
||||
if (status === 'active') return '#10B981';
|
||||
if (status === 'expired') return '#EF4444';
|
||||
if (status === 'completed') return '#0EA5E9';
|
||||
return '#64748B';
|
||||
}
|
||||
|
||||
/**
|
||||
* Plan status donut segment computation — mirrors AtlasPlanStatusDonut logic.
|
||||
*/
|
||||
function computePlanStatusDonutData(plansByStatus) {
|
||||
const totalPlans = Object.values(plansByStatus).reduce((sum, c) => sum + c, 0);
|
||||
if (totalPlans === 0) return { totalPlans, segments: [] };
|
||||
|
||||
const entries = Object.entries(plansByStatus).filter(([, count]) => count > 0);
|
||||
const segments = entries.map(([status, count]) => ({
|
||||
key: status,
|
||||
label: status.charAt(0).toUpperCase() + status.slice(1),
|
||||
color: getStatusColor(status),
|
||||
count,
|
||||
percentage: ((count / totalPlans) * 100).toFixed(0),
|
||||
}));
|
||||
|
||||
return { totalPlans, segments };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Generators
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const KNOWN_PLAN_TYPES = ['decommission', 'remediation', 'false_positive', 'risk_acceptance', 'scan_exclusion'];
|
||||
const KNOWN_STATUSES = ['active', 'expired', 'completed'];
|
||||
|
||||
/**
|
||||
* Generate a pair of non-negative integers where at least one is > 0.
|
||||
*/
|
||||
const coveragePairArb = fc
|
||||
.tuple(
|
||||
fc.nat({ max: 10000 }),
|
||||
fc.nat({ max: 10000 }),
|
||||
)
|
||||
.filter(([a, b]) => a + b > 0);
|
||||
|
||||
/**
|
||||
* Generate a plansByType object with 1–5 known plan type keys mapped to positive integers.
|
||||
*/
|
||||
const plansByTypeArb = fc
|
||||
.subarray(KNOWN_PLAN_TYPES, { minLength: 1, maxLength: 5 })
|
||||
.chain((keys) =>
|
||||
fc.tuple(...keys.map(() => fc.integer({ min: 1, max: 5000 }))).map((counts) => {
|
||||
const obj = {};
|
||||
keys.forEach((key, i) => { obj[key] = counts[i]; });
|
||||
return obj;
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* Generate a plansByStatus object with 1–3 known status keys mapped to positive integers.
|
||||
* Also allows arbitrary unknown status strings.
|
||||
*/
|
||||
const statusKeyArb = fc.oneof(
|
||||
{ weight: 3, arbitrary: fc.constantFrom(...KNOWN_STATUSES) },
|
||||
{ weight: 1, arbitrary: fc.stringMatching(/^[a-z_]{2,15}$/).filter((s) => !KNOWN_STATUSES.includes(s)) },
|
||||
);
|
||||
|
||||
const plansByStatusArb = fc
|
||||
.array(
|
||||
fc.tuple(statusKeyArb, fc.integer({ min: 1, max: 5000 })),
|
||||
{ minLength: 1, maxLength: 4 },
|
||||
)
|
||||
.map((pairs) => {
|
||||
const obj = {};
|
||||
for (const [key, count] of pairs) {
|
||||
// Use first occurrence if duplicate keys generated
|
||||
if (!(key in obj)) obj[key] = count;
|
||||
}
|
||||
return obj;
|
||||
})
|
||||
.filter((obj) => Object.keys(obj).length >= 1);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Property 2: Coverage donut data correctness
|
||||
// Validates: Requirements 3.3, 3.4
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Property 2: Coverage donut data correctness', () => {
|
||||
test('center text (totalHosts) equals hostsWithPlans + hostsWithoutPlans', () => {
|
||||
fc.assert(
|
||||
fc.property(coveragePairArb, ([hostsWithPlans, hostsWithoutPlans]) => {
|
||||
const { totalHosts } = computeCoverageDonutData(hostsWithPlans, hostsWithoutPlans);
|
||||
expect(totalHosts).toBe(hostsWithPlans + hostsWithoutPlans);
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
test('legend percentages equal (count / totalHosts) * 100 for each segment', () => {
|
||||
fc.assert(
|
||||
fc.property(coveragePairArb, ([hostsWithPlans, hostsWithoutPlans]) => {
|
||||
const totalHosts = hostsWithPlans + hostsWithoutPlans;
|
||||
const { segments } = computeCoverageDonutData(hostsWithPlans, hostsWithoutPlans);
|
||||
|
||||
for (const seg of segments) {
|
||||
const expectedPct = ((seg.count / totalHosts) * 100).toFixed(1);
|
||||
expect(seg.percentage).toBe(expectedPct);
|
||||
}
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
test('segments only include entries with count > 0', () => {
|
||||
fc.assert(
|
||||
fc.property(coveragePairArb, ([hostsWithPlans, hostsWithoutPlans]) => {
|
||||
const { segments } = computeCoverageDonutData(hostsWithPlans, hostsWithoutPlans);
|
||||
|
||||
for (const seg of segments) {
|
||||
expect(seg.count).toBeGreaterThan(0);
|
||||
}
|
||||
|
||||
// If one value is 0, only one segment should appear
|
||||
if (hostsWithPlans === 0) {
|
||||
expect(segments.length).toBe(1);
|
||||
expect(segments[0].label).toBe('Without Plans');
|
||||
} else if (hostsWithoutPlans === 0) {
|
||||
expect(segments.length).toBe(1);
|
||||
expect(segments[0].label).toBe('With Plans');
|
||||
} else {
|
||||
expect(segments.length).toBe(2);
|
||||
}
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
test('segment percentages sum to approximately 100', () => {
|
||||
fc.assert(
|
||||
fc.property(coveragePairArb, ([hostsWithPlans, hostsWithoutPlans]) => {
|
||||
const { segments } = computeCoverageDonutData(hostsWithPlans, hostsWithoutPlans);
|
||||
const totalPct = segments.reduce((sum, s) => sum + parseFloat(s.percentage), 0);
|
||||
// Allow small rounding tolerance due to toFixed(1)
|
||||
expect(totalPct).toBeCloseTo(100, 0);
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Property 3: Plan type donut data correctness
|
||||
// Validates: Requirements 4.3, 4.4
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Property 3: Plan type donut data correctness', () => {
|
||||
test('center text (totalPlans) equals sum of all plan type counts', () => {
|
||||
fc.assert(
|
||||
fc.property(plansByTypeArb, (plansByType) => {
|
||||
const expectedTotal = Object.values(plansByType).reduce((s, c) => s + c, 0);
|
||||
const { totalPlans } = computePlanTypeDonutData(plansByType);
|
||||
expect(totalPlans).toBe(expectedTotal);
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
test('legend entries match input — only types with count > 0 appear', () => {
|
||||
fc.assert(
|
||||
fc.property(plansByTypeArb, (plansByType) => {
|
||||
const { segments } = computePlanTypeDonutData(plansByType);
|
||||
|
||||
// Every segment should have count > 0
|
||||
for (const seg of segments) {
|
||||
expect(seg.count).toBeGreaterThan(0);
|
||||
}
|
||||
|
||||
// Every key in plansByType with count > 0 should appear in segments
|
||||
const segmentKeys = new Set(segments.map((s) => s.key));
|
||||
for (const [key, count] of Object.entries(plansByType)) {
|
||||
if (count > 0 && KNOWN_PLAN_TYPES.includes(key)) {
|
||||
expect(segmentKeys.has(key)).toBe(true);
|
||||
}
|
||||
}
|
||||
|
||||
// Every segment key should be in the input
|
||||
for (const seg of segments) {
|
||||
expect(plansByType[seg.key]).toBeDefined();
|
||||
expect(plansByType[seg.key]).toBe(seg.count);
|
||||
}
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
test('legend percentages equal (count / totalPlans) * 100 for each entry', () => {
|
||||
fc.assert(
|
||||
fc.property(plansByTypeArb, (plansByType) => {
|
||||
const { totalPlans, segments } = computePlanTypeDonutData(plansByType);
|
||||
|
||||
for (const seg of segments) {
|
||||
const expectedPct = ((seg.count / totalPlans) * 100).toFixed(0);
|
||||
expect(seg.percentage).toBe(expectedPct);
|
||||
}
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Property 4: Plan status donut data correctness
|
||||
// Validates: Requirements 5.3, 5.4
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Property 4: Plan status donut data correctness', () => {
|
||||
test('center text (totalPlans) equals sum of all status counts', () => {
|
||||
fc.assert(
|
||||
fc.property(plansByStatusArb, (plansByStatus) => {
|
||||
const expectedTotal = Object.values(plansByStatus).reduce((s, c) => s + c, 0);
|
||||
const { totalPlans } = computePlanStatusDonutData(plansByStatus);
|
||||
expect(totalPlans).toBe(expectedTotal);
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
test('legend entries match input — only statuses with count > 0 appear', () => {
|
||||
fc.assert(
|
||||
fc.property(plansByStatusArb, (plansByStatus) => {
|
||||
const { segments } = computePlanStatusDonutData(plansByStatus);
|
||||
|
||||
// Every segment should have count > 0
|
||||
for (const seg of segments) {
|
||||
expect(seg.count).toBeGreaterThan(0);
|
||||
}
|
||||
|
||||
// Every key in plansByStatus with count > 0 should appear in segments
|
||||
const segmentKeys = new Set(segments.map((s) => s.key));
|
||||
for (const [key, count] of Object.entries(plansByStatus)) {
|
||||
if (count > 0) {
|
||||
expect(segmentKeys.has(key)).toBe(true);
|
||||
}
|
||||
}
|
||||
|
||||
// Every segment key should be in the input with matching count
|
||||
for (const seg of segments) {
|
||||
expect(plansByStatus[seg.key]).toBeDefined();
|
||||
expect(plansByStatus[seg.key]).toBe(seg.count);
|
||||
}
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
test('legend percentages equal (count / totalPlans) * 100 for each entry', () => {
|
||||
fc.assert(
|
||||
fc.property(plansByStatusArb, (plansByStatus) => {
|
||||
const { totalPlans, segments } = computePlanStatusDonutData(plansByStatus);
|
||||
|
||||
for (const seg of segments) {
|
||||
const expectedPct = ((seg.count / totalPlans) * 100).toFixed(0);
|
||||
expect(seg.percentage).toBe(expectedPct);
|
||||
}
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
test('segment labels are capitalized versions of status keys', () => {
|
||||
fc.assert(
|
||||
fc.property(plansByStatusArb, (plansByStatus) => {
|
||||
const { segments } = computePlanStatusDonutData(plansByStatus);
|
||||
|
||||
for (const seg of segments) {
|
||||
const expectedLabel = seg.key.charAt(0).toUpperCase() + seg.key.slice(1);
|
||||
expect(seg.label).toBe(expectedLabel);
|
||||
}
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Property 5: Plan status color assignment
|
||||
// Validates: Requirements 5.2
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Property 5: Plan status color assignment', () => {
|
||||
test('known statuses return their specified colors', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.constantFrom('active', 'expired', 'completed'),
|
||||
(status) => {
|
||||
const color = getStatusColor(status);
|
||||
if (status === 'active') expect(color).toBe('#10B981');
|
||||
if (status === 'expired') expect(color).toBe('#EF4444');
|
||||
if (status === 'completed') expect(color).toBe('#0EA5E9');
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
test('arbitrary unknown strings return the fallback color #64748B', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.string({ minLength: 0, maxLength: 50 }).filter(
|
||||
(s) => s !== 'active' && s !== 'expired' && s !== 'completed',
|
||||
),
|
||||
(status) => {
|
||||
const color = getStatusColor(status);
|
||||
expect(color).toBe('#64748B');
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
test('mixed known and unknown statuses all return correct colors', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.oneof(
|
||||
fc.constantFrom('active', 'expired', 'completed'),
|
||||
fc.string({ minLength: 0, maxLength: 30 }),
|
||||
),
|
||||
(status) => {
|
||||
const color = getStatusColor(status);
|
||||
const expected =
|
||||
status === 'active' ? '#10B981' :
|
||||
status === 'expired' ? '#EF4444' :
|
||||
status === 'completed' ? '#0EA5E9' :
|
||||
'#64748B';
|
||||
expect(color).toBe(expected);
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -18,5 +18,9 @@
|
||||
"express-rate-limit": "^7.5.0",
|
||||
"multer": "^2.0.2",
|
||||
"sqlite3": "^5.1.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"fast-check": "^4.7.0",
|
||||
"jest": "^30.3.0"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user