feat: add return classification for archive chart, CARD API integration, compliance charts, systemd services

This commit is contained in:
root
2026-05-01 17:15:41 +00:00
parent 8df961cce8
commit 15abf8bae4
21 changed files with 3639 additions and 210 deletions

9
.gitignore vendored
View File

@@ -69,3 +69,12 @@ backend/scripts/export-reassigned-findings.js
# Investigation exports
docs/reassigned-findings-*.xlsx
# Zip files
*.zip
# Docs — local/staging files
docs/card-lookup-results.csv
docs/card-prod-archer-firewall-request.md
docs/granite-reassignment-upload.csv
docs/granite-reassignment-upload.xlsx

View File

@@ -0,0 +1,339 @@
# Design Document: CARD API Integration
## Overview
This design integrates the CARD Asset Ownership API into the STEAM Security Dashboard, enabling users to confirm, decline, and redirect CARD assets directly from the Ivanti Queue. The integration follows the existing architectural patterns established by the Atlas API integration (`atlasApi.js` / `atlas.js` route), adding OAuth Bearer token management with automatic caching and refresh.
The implementation is split into three layers:
1. **Helper module** (`backend/helpers/cardApi.js`) — already built and UAT-tested. Handles HTTP transport, OAuth token lifecycle, and high-level CARD API wrappers.
2. **Route module** (`backend/routes/cardApi.js`) — new Express router that proxies CARD operations, validates queue items, orchestrates the two-step update_token flow, and logs audit entries.
3. **Frontend UI** — CARD action buttons (Confirm, Decline, Redirect) on queue items, team selection dropdowns, and an asset search panel.
### Key Findings from UAT Testing
- Token endpoint is `POST /api/v1/auth/get_token` (not GET)
- Team name field in API responses is `card_team_name` or `_id`
- The `update_token` is nested at `owner.update_token` in the owner record
- The assets endpoint **requires** a `disposition` query parameter (returns 500 without it)
- The helper module and UAT test script are already built and validated
## Architecture
```mermaid
graph TD
subgraph Frontend
QP[Ivanti Queue Panel] --> AB[CARD Action Buttons]
AB --> CF[Confirm Form]
AB --> DF[Decline Form]
AB --> RF[Redirect Form]
QP --> AS[Asset Search Panel]
end
subgraph Backend
CR[cardApi Route<br>/api/card/*] --> CM[cardApi Helper]
CR --> DB[(SQLite DB<br>ivanti_todo_queue)]
CR --> AL[Audit Logger]
CM --> TM[Token Manager]
end
subgraph External
CARD[CARD API<br>card.charter.com]
end
CF --> CR
DF --> CR
RF --> CR
AS --> CR
CM --> CARD
TM --> CARD
```
### Request Flow for Mutations (Confirm/Decline/Redirect)
```mermaid
sequenceDiagram
participant UI as Frontend
participant Route as cardApi Route
participant DB as SQLite
participant Helper as cardApi Helper
participant CARD as CARD API
UI->>Route: POST /api/card/queue/:id/confirm
Route->>DB: Validate queue item (exists, user, CARD, pending)
DB-->>Route: Queue item record
Route->>Helper: getOwner(assetId)
Helper->>CARD: GET /api/v1/owner/{assetId}
CARD-->>Helper: Owner record with update_token
Helper-->>Route: { owner: { update_token: "..." } }
Route->>Helper: confirmAsset(assetId, team, token, comment)
Helper->>CARD: POST /api/v2/owner/{assetId}/confirm?update_token=...
CARD-->>Helper: Success response
Helper-->>Route: { status: 200, body: "..." }
Route->>DB: UPDATE status = 'complete'
Route->>AL: logAudit(card_confirm, ...)
Route-->>UI: { success: true, cardResponse: ... }
```
## Components and Interfaces
### 1. CARD API Helper (`backend/helpers/cardApi.js`) — Already Built
The helper module is complete and UAT-tested. It exports:
| Export | Type | Description |
|--------|------|-------------|
| `isConfigured` | `boolean` | `true` when `CARD_API_URL`, `CARD_API_USER`, `CARD_API_PASS` are all set |
| `missingVars` | `string[]` | List of missing env var names |
| `cardGet(urlPath, options)` | `function` | GET request with Bearer auth, returns `{ status, body }` |
| `cardPost(urlPath, body, options)` | `function` | POST request with Bearer auth, returns `{ status, body }` |
| `getTeams()` | `function` | `GET /api/v1/teams` — returns `{ status, body, ok }` |
| `getTeamAssets(teamName, opts)` | `function` | `GET /api/v1/team/{name}/assets` with disposition, page, pageSize |
| `getOwner(assetId)` | `function` | `GET /api/v1/owner/{assetId}` — returns owner record with `update_token` |
| `confirmAsset(assetId, team, token, comment)` | `function` | `POST /api/v2/owner/{id}/confirm` |
| `declineAsset(assetId, team, token, comment)` | `function` | `POST /api/v2/owner/{id}/decline` |
| `redirectAsset(assetId, from, to, token)` | `function` | `POST /api/v2/owner/{id}/{from}/redirect` |
| `invalidateToken()` | `function` | Clears cached Bearer token |
| `testConnection()` | `function` | Acquires token and returns `{ ok, token }` or `{ ok, error }` |
**Token Manager** (internal to helper):
- Acquires tokens via `POST /api/v1/auth/get_token` with Basic Auth
- Caches in memory with 1-hour TTL, refreshes when within 60s of expiry
- Automatically retries once on HTTP 401 (invalidate → re-acquire → retry)
### 2. CARD API Route (`backend/routes/cardApi.js`) — New
Factory function: `createCardApiRouter(db, requireAuth)` → Express Router
**Endpoints:**
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/card/status` | Returns `{ configured: boolean }` |
| GET | `/api/card/teams` | Proxies CARD teams list |
| GET | `/api/card/teams/:teamName/assets` | Proxies team assets with `disposition` (required), `page`, `page_size` (default 50) |
| GET | `/api/card/owner/:assetId` | Proxies owner record lookup |
| POST | `/api/card/queue/:queueItemId/confirm` | Confirm asset — body: `{ teamName, assetId, comment? }` |
| POST | `/api/card/queue/:queueItemId/decline` | Decline asset — body: `{ teamName, assetId, comment? }` |
| POST | `/api/card/queue/:queueItemId/redirect` | Redirect asset — body: `{ fromTeam, toTeam, assetId }` |
**Middleware:** All endpoints use `requireAuth(db)` + `requireGroup('Admin', 'Standard_User')`.
**Mutation flow** (confirm/decline/redirect):
1. Validate queue item: exists, belongs to `req.user.id`, `workflow_type = 'CARD'`, `status = 'pending'`
2. Fetch owner record via `getOwner(assetId)` to get fresh `update_token`
3. Extract `update_token` from `owner.update_token` (nested path)
4. Execute CARD mutation with the `update_token`
5. On success: update queue item `status = 'complete'`, log audit, return response
6. On failure: leave queue item as `pending`, log audit failure, return error
### 3. Frontend Components
**Modified:** Ivanti Queue panel in the existing queue UI
**New UI elements:**
- **CARD Action Buttons**: Confirm, Decline, Redirect buttons rendered on pending CARD queue items
- **Confirm/Decline Form**: Team dropdown (from `/api/card/teams`) + optional comment field
- **Redirect Form**: From Team dropdown + To Team dropdown
- **Asset Search Panel**: Team dropdown + disposition filter + paginated results table
- **Loading/Error States**: Inline loading indicators and error messages per queue item
**Session-level caching:** Teams list fetched once per browser session and reused across all forms.
### 4. Server Integration (`backend/server.js`)
Mount the new route:
```javascript
const createCardApiRouter = require('./routes/cardApi');
// ...
app.use('/api/card', createCardApiRouter(db, requireAuth));
```
## Data Models
### Existing: `ivanti_todo_queue` Table
No schema changes required. CARD items use `workflow_type = 'CARD'`.
| Column | Type | Notes |
|--------|------|-------|
| `id` | INTEGER | Primary key |
| `user_id` | INTEGER | FK to users |
| `finding_id` | TEXT | Ivanti finding identifier |
| `finding_title` | TEXT | Finding description |
| `cves_json` | TEXT | JSON array of CVE IDs |
| `ip_address` | TEXT | Asset IP address (used as CARD Asset_ID with suffix) |
| `hostname` | TEXT | Asset hostname |
| `vendor` | TEXT | Empty string for CARD items |
| `workflow_type` | TEXT | `'CARD'` for this integration |
| `status` | TEXT | `'pending'` or `'complete'` |
| `created_at` | DATETIME | Auto-set |
| `updated_at` | DATETIME | Auto-updated |
### CARD API Response Shapes (from UAT testing)
**Teams response** (`GET /api/v1/teams`):
```json
[
{ "_id": "NTS-AEO-STEAM", "card_team_name": "NTS-AEO-STEAM", ... },
{ "_id": "NTS-ACCESS-ENG", "card_team_name": "NTS-ACCESS-ENG", ... }
]
```
Team name extraction: `t.card_team_name || t._id`
**Owner record** (`GET /api/v1/owner/{assetId}`):
```json
{
"owner": {
"update_token": "abc123...",
"dispositions": [
{ "team": "NTS-AEO-STEAM", "disposition": "confirmed", ... }
],
...
}
}
```
Update token path: `response.owner.update_token`
**Team assets** (`GET /api/v1/team/{name}/assets?disposition=confirmed&page_size=50`):
```json
{
"assets": [ { "asset_id": "98.8.142.56-NATL", ... } ],
"total": 150,
"page": 1,
"page_size": 50
}
```
### Audit Log Entries
| Action | entityType | entityId | Details |
|--------|-----------|----------|---------|
| `card_confirm` | `ivanti_todo_queue` | queue item ID | `{ assetId, teamName, comment, cardStatus }` |
| `card_decline` | `ivanti_todo_queue` | queue item ID | `{ assetId, teamName, comment, cardStatus }` |
| `card_redirect` | `ivanti_todo_queue` | queue item ID | `{ assetId, fromTeam, toTeam, cardStatus }` |
| `card_search` | `card_asset` | team name | `{ disposition, resultCount }` |
| `card_action_failed` | `ivanti_todo_queue` | queue item ID | `{ actionType, assetId, error, cardStatus }` |
## 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: isConfigured reflects environment variable presence
*For any* combination of the three required environment variables (`CARD_API_URL`, `CARD_API_USER`, `CARD_API_PASS`) being present/non-empty or absent/empty, `isConfigured` SHALL be `true` if and only if all three are present and non-empty.
**Validates: Requirements 1.1, 3.3**
### Property 2: All CARD API responses have consistent shape
*For any* successful CARD API call (any HTTP method and URL path), the resolved Promise SHALL contain an object with a numeric `status` field and a string `body` field.
**Validates: Requirements 1.7**
### Property 3: Token acquisition errors include status and body
*For any* non-success HTTP status code and response body returned by the token acquisition endpoint, the rejected Promise error message SHALL include both the HTTP status code and the response body text.
**Validates: Requirements 2.7**
### Property 4: CARD API error status codes are forwarded through proxy
*For any* HTTP error status code (4xx or 5xx) returned by the CARD API on a proxied request, the route SHALL return that same status code to the client along with a JSON error body containing the upstream error message.
**Validates: Requirements 4.9**
### Property 5: Queue item validation rejects invalid states for mutations
*For any* CARD mutation request (confirm, decline, or redirect), if the referenced queue item does not exist, does not belong to the requesting user, has `workflow_type` other than `'CARD'`, or has `status` other than `'pending'`, the endpoint SHALL reject the request with the appropriate HTTP error code (404 or 400) without calling the CARD API.
**Validates: Requirements 5.4, 5.5, 5.6**
### Property 6: Mutation input validation enforces required fields
*For any* CARD mutation request, the endpoint SHALL reject with HTTP 400 if any required field is missing or empty: `teamName` and `assetId` for confirm/decline; `fromTeam`, `toTeam`, and `assetId` for redirect. Optional fields (e.g., `comment`) SHALL be accepted when absent.
**Validates: Requirements 5.11, 5.12, 5.13**
### Property 7: CARD mutation audit entries contain required fields
*For any* CARD mutation action (confirm, decline, or redirect) executed through the dashboard, the audit log entry SHALL contain the correct `action` name (`card_confirm`, `card_decline`, or `card_redirect`), `entityType` of `'ivanti_todo_queue'`, the queue item ID as `entityId`, the requesting user's `userId`, `username`, and `ipAddress`, and a `details` object containing the `assetId` and CARD API response status.
**Validates: Requirements 9.1, 9.2, 9.3, 9.6**
## Error Handling
### CARD Helper Error Handling
| Scenario | Behavior |
|----------|----------|
| CARD API unreachable / timeout | Reject with `[card-api] {METHOD} {path} failed: {reason}` |
| Token endpoint returns non-2xx | Reject with `[card-api] Token acquisition failed with HTTP {status}: {body}` |
| Token endpoint returns unparseable JSON | Fall back to raw body as token string; reject if empty |
| Token endpoint returns empty token | Reject with `[card-api] Token parse failure: empty token in response body.` |
| Non-auth request returns 401 | Invalidate token, re-acquire, retry once. If retry also 401, return the 401 |
### Route Error Handling
| CARD API Status | Route Response | Error Message |
|----------------|----------------|---------------|
| 401 (token endpoint) | 401 | `CARD authorization failed. Check service account credentials.` |
| 403 (token endpoint) | 403 | `CARD access denied. The service account may not be onboarded with the CARD team.` |
| 525 (token endpoint) | 502 | `CARD LDAP error. The service account may not be provisioned correctly.` |
| 401 (API call, after retry) | 401 | `CARD token expired or invalid. The request has been retried once automatically.` |
| 403 (API call) | 403 | `Insufficient CARD permissions for this operation.` |
| Any unhandled error | 502 | `CARD API request failed.` + details |
| Not configured | 503 | `CARD API is not configured.` + missing vars |
All errors are logged to console with `[card-api]` prefix for consistent log filtering.
### Frontend Error Handling
- Inline error messages on the affected queue item (no modal popups)
- Loading state disables action buttons to prevent double-submission
- Network errors display a generic "Unable to reach server" message
- CARD-specific errors display the backend error message verbatim
## Testing Strategy
### Property-Based Tests (fast-check)
The project uses Jest as the test runner. Property-based tests will use [fast-check](https://github.com/dubzzz/fast-check) with a minimum of 100 iterations per property.
Each property test references its design document property:
| Property | Test File | What It Validates |
|----------|-----------|-------------------|
| Property 1: isConfigured | `backend/__tests__/card-isConfigured.property.test.js` | Env var combinations → isConfigured correctness |
| Property 2: Response shape | `backend/__tests__/card-response-shape.property.test.js` | All API responses have { status, body } |
| Property 3: Token error messages | `backend/__tests__/card-token-errors.property.test.js` | Error messages include status + body |
| Property 4: Error forwarding | `backend/__tests__/card-error-forwarding.property.test.js` | Proxy forwards CARD error status codes |
| Property 5: Queue validation | `backend/__tests__/card-queue-validation.property.test.js` | Invalid queue states rejected correctly |
| Property 6: Input validation | `backend/__tests__/card-input-validation.property.test.js` | Required fields enforced on mutations |
| Property 7: Audit entries | `backend/__tests__/card-audit-entries.property.test.js` | Mutation audit logs have correct shape |
Tag format: `Feature: card-api-integration, Property {N}: {title}`
### Unit Tests (example-based)
- Token acquisition flow (mock HTTP, verify Basic Auth header)
- Token caching and refresh timing
- 401 retry logic (mock 401 → 200 sequence)
- Two-step update_token flow (getOwner → mutation)
- Specific CARD API endpoint URL construction
- Default page_size=50 on assets endpoint
- TLS skip configuration
### Integration Tests
- Route mounting at `/api/card` prefix
- Auth middleware enforcement (401 without session)
- End-to-end confirm/decline/redirect with mocked CARD API
- Asset search with pagination
### Frontend Tests
- CARD action buttons render only on pending CARD items
- Form submission sends correct request body
- Loading state disables buttons
- Error messages display inline
- Teams list caching (single fetch per session)

View File

@@ -0,0 +1,165 @@
# Implementation Plan: CARD API Integration
## Overview
This plan covers the remaining implementation work for the CARD API integration into the STEAM Security Dashboard. The CARD API helper module (`backend/helpers/cardApi.js`), environment variable configuration, and UAT test script are already built and validated. The remaining work focuses on the backend route module, server mounting, frontend CARD action UI, asset search panel, and property-based tests.
## Tasks
- [x] 1. CARD API Helper Module (Already Complete)
- `backend/helpers/cardApi.js` is built and UAT-tested with all exports: `isConfigured`, `cardGet`, `cardPost`, `getTeams`, `getTeamAssets`, `getOwner`, `confirmAsset`, `declineAsset`, `redirectAsset`, `invalidateToken`, `testConnection`
- Token Manager handles OAuth Bearer token acquisition, 1-hour TTL caching, 60s refresh window, and automatic 401 retry
- _Requirements: 1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 1.7, 1.8, 2.1, 2.2, 2.3, 2.4, 2.5, 2.6, 2.7_
- [x] 2. Environment Variable Configuration (Already Complete)
- `backend/.env` and `backend/.env.example` have `CARD_API_URL`, `CARD_API_USER`, `CARD_API_PASS`, `CARD_SKIP_TLS` configured with descriptive comments
- _Requirements: 3.1, 3.2, 3.3, 3.4_
- [x] 3. UAT Test Script (Already Complete)
- `backend/scripts/card-uat-test.js` exercises all 9 CARD API use cases and passes
- _Requirements: 1.1, 1.7, 2.1, 2.3, 2.6_
- [x] 4. Backend CARD API Route Module
- [x] 4.1 Create `backend/routes/cardApi.js` with factory function `createCardApiRouter(db, requireAuth)`
- Follow the existing `atlas.js` route pattern: import `requireGroup` from middleware, import `logAudit` from helpers, import CARD helper functions from `helpers/cardApi.js`
- Add promise-based DB helpers (`dbRun`, `dbGet`) matching the atlas.js pattern
- Protect all endpoints with `requireAuth(db)` and `requireGroup('Admin', 'Standard_User')`
- _Requirements: 4.1, 4.2_
- [x] 4.2 Implement read-only proxy endpoints
- `GET /status` — return `{ configured: isConfigured }`; if not configured, return 503 with missing vars
- `GET /teams` — proxy `getTeams()`, parse JSON response, forward to client
- `GET /teams/:teamName/assets` — proxy `getTeamAssets()` with `disposition` (required), `page`, `page_size` (default 50) query params
- `GET /owner/:assetId` — proxy `getOwner()`, return owner record
- All proxy endpoints: return 503 if not configured, forward CARD API error status codes with JSON error body
- _Requirements: 4.3, 4.4, 4.5, 4.6, 4.7, 4.8, 4.9_
- [x] 4.3 Implement mutation endpoints (confirm, decline, redirect) with two-step update_token flow
- `POST /queue/:queueItemId/confirm` — body: `{ teamName, assetId, comment? }`
- `POST /queue/:queueItemId/decline` — body: `{ teamName, assetId, comment? }`
- `POST /queue/:queueItemId/redirect` — body: `{ fromTeam, toTeam, assetId }`
- Validate queue item: exists, belongs to `req.user.id`, `workflow_type = 'CARD'`, `status = 'pending'`; return 404 if not found/wrong user/wrong type, 400 if not pending
- Validate required fields: `teamName` + `assetId` for confirm/decline; `fromTeam` + `toTeam` + `assetId` for redirect; return 400 if missing
- Two-step flow: call `getOwner(assetId)` → extract `update_token` from `owner.update_token` → call mutation with token
- On success: update queue item `status = 'complete'`, return `{ success: true, cardResponse }`
- On failure: leave queue item as `pending`, return error
- _Requirements: 5.1, 5.2, 5.3, 5.4, 5.5, 5.6, 5.7, 5.8, 5.9, 5.10, 5.11, 5.12, 5.13, 5.14, 5.15_
- [x] 4.4 Implement error handling for CARD API responses
- Map token endpoint errors: 401 → 401 auth failed, 403 → 403 access denied, 525 → 502 LDAP error
- Map API call errors: 401 after retry → 401 token expired, 403 → 403 insufficient permissions
- Catch unhandled errors → 502 with `{ error: 'CARD API request failed.', details }`
- Log all errors with `[card-api]` prefix via `console.error`
- _Requirements: 8.1, 8.2, 8.3, 8.4, 8.5, 8.6, 8.7, 8.8, 8.9, 8.10_
- [x] 4.5 Implement audit logging for all CARD actions
- `card_confirm`: entityType `ivanti_todo_queue`, entityId = queue item ID, details = `{ assetId, teamName, comment, cardStatus }`
- `card_decline`: same pattern with decline details
- `card_redirect`: details = `{ assetId, fromTeam, toTeam, cardStatus }`
- `card_search`: entityType `card_asset`, entityId = team name, details = `{ disposition, resultCount }`
- `card_action_failed`: details = `{ actionType, assetId, error, cardStatus }`
- All entries include `userId`, `username`, `ipAddress`; use fire-and-forget semantics
- _Requirements: 9.1, 9.2, 9.3, 9.4, 9.5, 9.6, 9.7_
- [x] 5. Mount CARD route in server.js
- [x] 5.1 Add `const createCardApiRouter = require('./routes/cardApi');` import to server.js alongside existing route imports
- Mount with `app.use('/api/card', createCardApiRouter(db, requireAuth));` after the Atlas route mount
- _Requirements: 4.10_
- [x] 6. Checkpoint — Backend route verification
- Ensure all tests pass, ask the user if questions arise.
- [x] 7. Frontend CARD Action UI
- [x] 7.1 Add CARD teams fetch and session-level caching to ReportingPage
- Fetch `/api/card/status` on mount to check if CARD is configured
- Fetch `/api/card/teams` once per session and cache in component state
- Pass `cardConfigured`, `cardTeams` props down to `QueuePanel`
- _Requirements: 6.8, 6.9_
- [x] 7.2 Add CARD action buttons (Confirm, Decline, Redirect) to queue items in QueuePanel
- Render three action buttons on pending CARD queue items (`workflow_type === 'CARD'` and `status === 'pending'`)
- Disable buttons when CARD is not configured; show tooltip "CARD integration not configured"
- Style buttons to match existing queue item action patterns (compact, inline)
- _Requirements: 6.1, 6.8_
- [x] 7.3 Implement Confirm and Decline action forms
- On Confirm/Decline button click: show inline form with team selection dropdown (populated from cached teams list) and optional comment text field
- On form submit: POST to `/api/card/queue/:queueItemId/confirm` or `/decline` with `{ teamName, assetId: item.ip_address, comment }`
- While request is in flight: disable action buttons, show loading indicator on the queue item
- On success: update queue item status to `complete` in local state without full refresh
- On failure: display backend error message inline on the affected queue item
- _Requirements: 6.2, 6.3, 6.5, 6.6, 6.7_
- [x] 7.4 Implement Redirect action form
- On Redirect button click: show inline form with "From Team" dropdown and "To Team" dropdown (both from cached teams list)
- On form submit: POST to `/api/card/queue/:queueItemId/redirect` with `{ fromTeam, toTeam, assetId: item.ip_address }`
- Same loading/success/error handling as confirm/decline
- _Requirements: 6.4, 6.5, 6.6, 6.7_
- [x] 8. Frontend Asset Search Panel
- [x] 8.1 Create asset search interface accessible from the Ivanti Queue page
- Add a "CARD Asset Search" button/section in the queue panel or as a collapsible panel
- Include team selection dropdown (from cached teams) and disposition filter dropdown (`confirmed`, `unconfirmed`, `declined`, `candidate`)
- On search: GET `/api/card/teams/:teamName/assets?disposition=X&page_size=50`
- Display total asset count and results table with Asset_ID and identifying fields
- Add pagination controls when total exceeds page size (increment `page` param)
- Display error messages inline in the search results area on failure
- _Requirements: 7.1, 7.2, 7.3, 7.4, 7.5, 7.6, 7.7_
- [x] 9. Checkpoint — Full integration verification
- Ensure all tests pass, ask the user if questions arise.
- [ ] 10. Property-based tests for CARD API correctness properties
- [ ]* 10.1 Write property test for isConfigured environment variable logic
- **Property 1: isConfigured reflects environment variable presence**
- For any combination of the three required env vars being present/non-empty or absent/empty, `isConfigured` is `true` iff all three are present and non-empty
- Create `backend/__tests__/card-isConfigured.property.test.js` using Jest + fast-check with 100+ iterations
- **Validates: Requirements 1.1, 3.3**
- [ ]* 10.2 Write property test for CARD API response shape consistency
- **Property 2: All CARD API responses have consistent shape**
- For any successful CARD API call, the resolved Promise contains `{ status: number, body: string }`
- Create `backend/__tests__/card-response-shape.property.test.js`
- **Validates: Requirements 1.7**
- [ ]* 10.3 Write property test for token acquisition error messages
- **Property 3: Token acquisition errors include status and body**
- For any non-success HTTP status and response body from the token endpoint, the rejected error message includes both the status code and body text
- Create `backend/__tests__/card-token-errors.property.test.js`
- **Validates: Requirements 2.7**
- [ ]* 10.4 Write property test for CARD API error status code forwarding
- **Property 4: CARD API error status codes are forwarded through proxy**
- For any 4xx/5xx status from CARD API on a proxied request, the route returns that same status code with a JSON error body
- Create `backend/__tests__/card-error-forwarding.property.test.js`
- **Validates: Requirements 4.9**
- [ ]* 10.5 Write property test for queue item validation on mutations
- **Property 5: Queue item validation rejects invalid states for mutations**
- For any mutation request where the queue item doesn't exist, wrong user, wrong workflow_type, or wrong status, the endpoint rejects with 404 or 400 without calling CARD API
- Create `backend/__tests__/card-queue-validation.property.test.js`
- **Validates: Requirements 5.4, 5.5, 5.6**
- [ ]* 10.6 Write property test for mutation input validation
- **Property 6: Mutation input validation enforces required fields**
- For any mutation request missing required fields (teamName/assetId for confirm/decline; fromTeam/toTeam/assetId for redirect), the endpoint rejects with 400
- Create `backend/__tests__/card-input-validation.property.test.js`
- **Validates: Requirements 5.11, 5.12, 5.13**
- [ ]* 10.7 Write property test for CARD mutation audit log entries
- **Property 7: CARD mutation audit entries contain required fields**
- For any mutation action, the audit log entry contains correct `action` name, `entityType`, `entityId`, `userId`, `username`, `ipAddress`, and `details` with `assetId` and CARD response status
- Create `backend/__tests__/card-audit-entries.property.test.js`
- **Validates: Requirements 9.1, 9.2, 9.3, 9.6**
- [x] 11. Final checkpoint — Ensure all tests pass
- Ensure all tests pass, ask the user if questions arise.
## Notes
- Tasks 13 are marked complete because `backend/helpers/cardApi.js`, `.env`/`.env.example`, and `backend/scripts/card-uat-test.js` are already built and UAT-tested
- Tasks marked with `*` are optional property-based tests and can be skipped for faster MVP
- Each task references specific requirements for traceability
- The backend route module (Task 4) follows the existing `atlas.js` route pattern exactly
- The frontend UI (Tasks 78) extends the existing `QueuePanel` in `ReportingPage.js`
- Property tests use Jest + fast-check matching the existing test pattern in `backend/__tests__/`

View File

@@ -12,6 +12,7 @@ A self-hosted vulnerability management dashboard for the NTS-AEO-STEAM and NTS-A
- [Installation](#installation)
- [Configuration](#configuration)
- [Running the Application](#running-the-application)
- [Running as systemd services (auto-start on reboot)](#running-as-systemd-services-auto-start-on-reboot)
- [Features](#features)
- [Authentication and User Groups](#authentication-and-user-groups)
- [Home — CVE Management](#home--cve-management)
@@ -251,6 +252,42 @@ cd frontend
npm start
```
### Running as systemd services (auto-start on reboot)
Two systemd unit files are installed to `/etc/systemd/system/` so the dashboard starts automatically when the server boots:
| Unit | What it runs |
|---|---|
| `cve-backend.service` | `node server.js` from `backend/` |
| `cve-frontend.service` | `npm start` from `frontend/` (waits for backend) |
Both services load their respective `.env` files, restart on failure (5-second delay), and append output to `backend/backend.log` and `frontend/frontend.log`.
**First-time setup** (if the units are not yet installed):
```bash
# Copy the unit files into systemd
cp systemd/cve-backend.service /etc/systemd/system/
cp systemd/cve-frontend.service /etc/systemd/system/
# Reload systemd, enable on boot, and start
systemctl daemon-reload
systemctl enable --now cve-backend cve-frontend
```
**Common commands:**
```bash
systemctl status cve-backend cve-frontend # Check both services
systemctl restart cve-backend # Restart backend only
systemctl restart cve-frontend # Restart frontend only
systemctl stop cve-backend cve-frontend # Stop both
journalctl -u cve-backend -f # Follow backend journal
journalctl -u cve-frontend -f # Follow frontend journal
```
> The helper scripts (`start-servers.sh` / `stop-servers.sh`) still work for ad-hoc use, but systemd is the recommended approach for persistent deployments. If switching to systemd, stop any script-launched processes first with `./stop-servers.sh` to avoid port conflicts.
### Default ports
| Service | URL |
@@ -742,6 +779,9 @@ cve-dashboard/
├── start-servers.sh # Start backend + frontend in background
├── stop-servers.sh # Stop all servers
├── package.json # Root package.json (backend dependencies)
├── systemd/ # systemd unit files for auto-start on boot
│ ├── cve-backend.service
│ └── cve-frontend.service
├── backend/
│ ├── server.js # Express app — routes, middleware, security headers
@@ -983,6 +1023,8 @@ cd ..
# 8. Start servers
./start-servers.sh
# Or, if using systemd services:
# systemctl restart cve-backend cve-frontend
```
After upgrading, clear your browser cookies and log in fresh — session format changes between versions will invalidate old sessions.

View File

@@ -41,3 +41,12 @@ JIRA_PROJECT_KEY=
JIRA_ISSUE_TYPE=Task
# Set to true if behind Charter's SSL inspection proxy
JIRA_SKIP_TLS=false
# CARD Asset Ownership API (card.charter.com / card.caas.stage.charterlab.com)
# OAuth Bearer token auth — service account must be onboarded with the CARD team.
# Tokens are acquired automatically via Basic Auth and cached for 1 hour.
CARD_API_URL=
CARD_API_USER=
CARD_API_PASS=
# Set to true if behind Charter's SSL inspection proxy
CARD_SKIP_TLS=false

305
backend/helpers/cardApi.js Normal file
View File

@@ -0,0 +1,305 @@
// Shared CARD API helpers
// Centralizes HTTP calls for the CARD asset ownership API.
// Follows the same promise-based pattern as atlasApi.js, with the addition
// of OAuth Bearer token management (auto-acquire, cache, refresh, 401 retry).
//
// CARD API versioning:
// - Read endpoints (GET): /api/v1/...
// - Mutation endpoints (POST): /api/v2/...
const https = require('https');
const http = require('http');
// ---------------------------------------------------------------------------
// Configuration — read from process.env at module load
// ---------------------------------------------------------------------------
const CARD_API_URL = process.env.CARD_API_URL || '';
const CARD_API_USER = process.env.CARD_API_USER || '';
const CARD_API_PASS = process.env.CARD_API_PASS || '';
const CARD_SKIP_TLS = process.env.CARD_SKIP_TLS === 'true';
const requiredVars = ['CARD_API_URL', 'CARD_API_USER', 'CARD_API_PASS'];
const missingVars = requiredVars.filter((v) => !process.env[v]);
if (missingVars.length > 0) {
console.warn(`[card-api] WARNING: Missing required environment variables: ${missingVars.join(', ')}. CARD API calls will fail.`);
}
const isConfigured = missingVars.length === 0;
// ---------------------------------------------------------------------------
// Token Manager — OAuth Bearer token with 1-hour TTL
// ---------------------------------------------------------------------------
let cachedToken = null; // { token: string, expiresAt: number (epoch ms) }
function tokenIsValid() {
if (!cachedToken) return false;
// Refresh if within 60 seconds of expiry
return cachedToken.expiresAt - Date.now() > 60_000;
}
function invalidateToken() {
cachedToken = null;
}
/**
* Acquire a new Bearer token from CARD /api/v1/auth/get_token using Basic Auth.
* Caches the token in memory with a 1-hour TTL.
*/
function acquireToken(timeout) {
const authString = Buffer.from(CARD_API_USER + ':' + CARD_API_PASS).toString('base64');
const fullUrl = new URL(CARD_API_URL + '/api/v1/auth/get_token');
const isHttps = fullUrl.protocol === 'https:';
const transport = isHttps ? https : http;
return new Promise((resolve, reject) => {
const reqOptions = {
hostname: fullUrl.hostname,
port: fullUrl.port || (isHttps ? 443 : 80),
path: fullUrl.pathname + fullUrl.search,
method: 'POST',
headers: {
'accept': 'application/json',
'authorization': 'Basic ' + authString,
'content-length': '0',
},
timeout: timeout || 15000,
};
if (isHttps) {
reqOptions.rejectUnauthorized = !CARD_SKIP_TLS;
}
const req = transport.request(reqOptions, (res) => {
let data = '';
res.on('data', (chunk) => { data += chunk; });
res.on('end', () => {
if (res.statusCode < 200 || res.statusCode >= 300) {
return reject(new Error(
`[card-api] Token acquisition failed with HTTP ${res.statusCode}: ${data.substring(0, 500)}`
));
}
// The CARD API returns the token as a JSON string or object.
// Try to parse; fall back to raw body as the token string.
let token;
try {
const parsed = JSON.parse(data);
token = typeof parsed === 'string' ? parsed
: parsed.token || parsed.access_token || data.trim();
} catch (_) {
// Response may be a plain token string (unquoted)
token = data.trim();
}
if (!token) {
return reject(new Error('[card-api] Token parse failure: empty token in response body.'));
}
cachedToken = {
token,
expiresAt: Date.now() + 60 * 60 * 1000, // 1-hour TTL
};
resolve(cachedToken.token);
});
});
req.on('timeout', () => req.destroy(new Error('GET /api/v1/auth/get_token timed out')));
req.on('error', (err) => {
reject(new Error(`[card-api] GET /api/v1/auth/get_token failed: ${err.message}`));
});
req.end();
});
}
/**
* Ensure we have a valid Bearer token, acquiring or refreshing as needed.
*/
async function ensureToken(timeout) {
if (tokenIsValid()) return cachedToken.token;
return acquireToken(timeout);
}
// ---------------------------------------------------------------------------
// Generic request — supports GET and POST with Bearer auth + 401 retry
// ---------------------------------------------------------------------------
async function cardRequest(method, urlPath, body, options) {
const timeout = (options && options.timeout) || 15000;
const skipAuth = (options && options.skipAuth) || false;
async function doRequest(bearerToken) {
const fullUrl = new URL(CARD_API_URL + urlPath);
const isHttps = fullUrl.protocol === 'https:';
const transport = isHttps ? https : http;
const headers = { 'accept': 'application/json' };
if (bearerToken) {
headers['authorization'] = 'Bearer ' + bearerToken;
}
let bodyStr = null;
if (body !== null && body !== undefined) {
bodyStr = JSON.stringify(body);
headers['content-type'] = 'application/json';
headers['content-length'] = Buffer.byteLength(bodyStr);
}
return new Promise((resolve, reject) => {
const reqOptions = {
hostname: fullUrl.hostname,
port: fullUrl.port || (isHttps ? 443 : 80),
path: fullUrl.pathname + fullUrl.search,
method,
headers,
timeout,
};
if (isHttps) {
reqOptions.rejectUnauthorized = !CARD_SKIP_TLS;
}
const req = transport.request(reqOptions, (res) => {
let data = '';
res.on('data', (chunk) => { data += chunk; });
res.on('end', () => resolve({ status: res.statusCode, body: data }));
});
req.on('timeout', () => req.destroy(new Error(`${method} ${urlPath} timed out`)));
req.on('error', (err) => {
reject(new Error(`[card-api] ${method} ${urlPath} failed: ${err.message}`));
});
if (bodyStr) req.write(bodyStr);
req.end();
});
}
// Skip auth for the token endpoint itself
if (skipAuth) {
return doRequest(null);
}
// Normal flow: ensure token → request → retry once on 401
let token = await ensureToken(timeout);
let result = await doRequest(token);
if (result.status === 401) {
// Invalidate and retry exactly once
invalidateToken();
token = await ensureToken(timeout);
result = await doRequest(token);
}
return result;
}
// ---------------------------------------------------------------------------
// Convenience wrappers
// ---------------------------------------------------------------------------
function cardGet(urlPath, options) {
return cardRequest('GET', urlPath, null, options);
}
function cardPost(urlPath, body, options) {
return cardRequest('POST', urlPath, body, options);
}
// ---------------------------------------------------------------------------
// High-level helpers used by the UAT test and routes
// ---------------------------------------------------------------------------
/**
* Test connection by acquiring a token. Returns { ok, token } or { ok, error }.
*/
async function testConnection() {
try {
const token = await acquireToken();
return { ok: true, token: token.substring(0, 12) + '...' };
} catch (err) {
return { ok: false, error: err.message };
}
}
/**
* GET /api/v1/teams — list all CARD teams.
*/
async function getTeams() {
const res = await cardGet('/api/v1/teams');
return { status: res.status, body: res.body, ok: res.status >= 200 && res.status < 300 };
}
/**
* GET /api/v1/team/{teamName}/assets — list assets for a team.
*/
async function getTeamAssets(teamName, { disposition, page, pageSize } = {}) {
const params = new URLSearchParams();
if (disposition) params.set('disposition', disposition);
if (page) params.set('page', String(page));
params.set('page_size', String(pageSize || 50));
const qs = params.toString();
const res = await cardGet(`/api/v1/team/${encodeURIComponent(teamName)}/assets${qs ? '?' + qs : ''}`);
return { status: res.status, body: res.body, ok: res.status >= 200 && res.status < 300 };
}
/**
* GET /api/v1/owner/{assetId} — get owner record including update_token.
*/
async function getOwner(assetId) {
const res = await cardGet(`/api/v1/owner/${encodeURIComponent(assetId)}`);
return { status: res.status, body: res.body, ok: res.status >= 200 && res.status < 300 };
}
/**
* POST /api/v2/owner/{assetId}/confirm — confirm asset to a team.
*/
async function confirmAsset(assetId, teamName, updateToken, comment) {
const params = new URLSearchParams({ update_token: updateToken });
if (comment) params.set('comment', comment);
const res = await cardPost(
`/api/v2/owner/${encodeURIComponent(assetId)}/confirm?${params.toString()}`,
{ name: teamName }
);
return { status: res.status, body: res.body, ok: res.status >= 200 && res.status < 300 };
}
/**
* POST /api/v2/owner/{assetId}/decline — decline asset from a team.
*/
async function declineAsset(assetId, teamName, updateToken, comment) {
const params = new URLSearchParams({ update_token: updateToken });
if (comment) params.set('comment', comment);
const res = await cardPost(
`/api/v2/owner/${encodeURIComponent(assetId)}/decline?${params.toString()}`,
{ name: teamName }
);
return { status: res.status, body: res.body, ok: res.status >= 200 && res.status < 300 };
}
/**
* POST /api/v2/owner/{assetId}/{fromTeam}/redirect — redirect asset between teams.
*/
async function redirectAsset(assetId, fromTeam, toTeam, updateToken) {
const params = new URLSearchParams({ update_token: updateToken });
const res = await cardPost(
`/api/v2/owner/${encodeURIComponent(assetId)}/${encodeURIComponent(fromTeam)}/redirect?${params.toString()}`,
{ name: toTeam }
);
return { status: res.status, body: res.body, ok: res.status >= 200 && res.status < 300 };
}
module.exports = {
isConfigured,
missingVars,
cardRequest,
cardGet,
cardPost,
testConnection,
getTeams,
getTeamAssets,
getOwner,
confirmAsset,
declineAsset,
redirectAsset,
invalidateToken,
};

View File

@@ -0,0 +1,57 @@
// Migration: Add return_classification_json column to ivanti_sync_anomaly_log
//
// Stores the classification breakdown for returned findings (e.g., how many
// returned due to BU reassignment back to team, severity re-escalation, etc.)
//
// Safe to re-run — uses ALTER TABLE with IF NOT EXISTS pattern.
//
// Usage: node backend/migrations/add_return_classification.js
const path = require('path');
const sqlite3 = require('sqlite3').verbose();
const dbPath = path.join(__dirname, '..', 'cve_database.db');
const db = new sqlite3.Database(dbPath);
console.log('Starting return classification migration...');
function run(sql, params = []) {
return new Promise((resolve, reject) => {
db.run(sql, params, function (err) {
if (err) reject(err);
else resolve(this);
});
});
}
function all(sql, params = []) {
return new Promise((resolve, reject) => {
db.all(sql, params, (err, rows) => {
if (err) reject(err);
else resolve(rows || []);
});
});
}
async function migrate() {
// Check if column already exists
const columns = await all(`PRAGMA table_info(ivanti_sync_anomaly_log)`);
const hasColumn = columns.some(c => c.name === 'return_classification_json');
if (!hasColumn) {
await run(`ALTER TABLE ivanti_sync_anomaly_log ADD COLUMN return_classification_json TEXT NOT NULL DEFAULT '{}'`);
console.log('✓ Added return_classification_json column to ivanti_sync_anomaly_log');
} else {
console.log('✓ return_classification_json column already exists — skipping');
}
}
migrate()
.then(() => {
console.log('Migration complete.');
db.close();
})
.catch((err) => {
console.error('Migration failed:', err);
db.close();
process.exit(1);
});

615
backend/routes/cardApi.js Normal file
View File

@@ -0,0 +1,615 @@
// CARD Asset Ownership API Routes
// Proxies CARD operations (confirm, decline, redirect, search) and orchestrates
// the two-step update_token flow for mutations.
const express = require('express');
const { requireGroup } = require('../middleware/auth');
const logAudit = require('../helpers/auditLog');
const {
isConfigured,
missingVars,
getTeams,
getTeamAssets,
getOwner,
confirmAsset,
declineAsset,
redirectAsset,
} = require('../helpers/cardApi');
// ---------------------------------------------------------------------------
// DB helpers — promise wrappers for callback-based SQLite API
// ---------------------------------------------------------------------------
function dbRun(db, sql, params = []) {
return new Promise((resolve, reject) => {
db.run(sql, params, function (err) { if (err) reject(err); else resolve(this); });
});
}
function dbGet(db, sql, params = []) {
return new Promise((resolve, reject) => {
db.get(sql, params, (err, row) => { if (err) reject(err); else resolve(row); });
});
}
// ---------------------------------------------------------------------------
// Error classification — maps CARD API / token errors to client responses
// ---------------------------------------------------------------------------
function handleCardError(err, res) {
const msg = err.message || String(err);
console.error('[card-api]', msg);
// Token endpoint errors (from acquireToken rejections)
if (msg.includes('Token acquisition failed')) {
if (msg.includes('HTTP 401')) {
return res.status(401).json({ error: 'CARD authorization failed. Check service account credentials.' });
}
if (msg.includes('HTTP 403')) {
return res.status(403).json({ error: 'CARD access denied. The service account may not be onboarded with the CARD team.' });
}
if (msg.includes('HTTP 525')) {
return res.status(502).json({ error: 'CARD LDAP error. The service account may not be provisioned correctly.' });
}
}
// API call errors (after automatic 401 retry in helper)
if (msg.includes('401')) {
return res.status(401).json({ error: 'CARD token expired or invalid. The request has been retried once automatically.' });
}
if (msg.includes('403')) {
return res.status(403).json({ error: 'Insufficient CARD permissions for this operation.' });
}
// Catch-all
return res.status(502).json({ error: 'CARD API request failed.', details: msg });
}
// ---------------------------------------------------------------------------
// Router factory
// ---------------------------------------------------------------------------
function createCardApiRouter(db, requireAuth) {
const router = express.Router();
// -------------------------------------------------------------------
// GET /status
// Returns whether the CARD API integration is configured.
// -------------------------------------------------------------------
router.get('/status', requireAuth(db), (req, res) => {
if (!isConfigured) {
return res.status(503).json({
configured: false,
error: 'CARD API is not configured.',
missingVars,
});
}
res.json({ configured: true });
});
// -------------------------------------------------------------------
// GET /teams
// Proxy CARD teams list.
// -------------------------------------------------------------------
router.get('/teams', requireAuth(db), requireGroup('Admin', 'Standard_User'), async (req, res) => {
if (!isConfigured) {
return res.status(503).json({ error: 'CARD API is not configured.', missingVars });
}
try {
const result = await getTeams();
if (result.ok) {
let body;
try {
body = JSON.parse(result.body);
} catch (_) {
body = result.body;
}
// CARD API wraps teams in { teams: [...], response_time: ... }
const teams = Array.isArray(body) ? body : (body && body.teams) || [];
return res.json(teams);
}
// Forward CARD error status
let errorBody;
try {
errorBody = JSON.parse(result.body);
} catch (_) {
errorBody = { error: result.body };
}
return res.status(result.status).json(errorBody);
} catch (err) {
return handleCardError(err, res);
}
});
// -------------------------------------------------------------------
// GET /teams/:teamName/assets
// Proxy team assets with required disposition filter.
// -------------------------------------------------------------------
router.get('/teams/:teamName/assets', requireAuth(db), requireGroup('Admin', 'Standard_User'), async (req, res) => {
if (!isConfigured) {
return res.status(503).json({ error: 'CARD API is not configured.', missingVars });
}
const { teamName } = req.params;
const { disposition, page, page_size } = req.query;
if (!disposition) {
return res.status(400).json({ error: 'disposition query parameter is required.' });
}
try {
const result = await getTeamAssets(teamName, {
disposition,
page: page ? parseInt(page, 10) : undefined,
pageSize: page_size ? parseInt(page_size, 10) : 50,
});
if (result.ok) {
let body;
try {
body = JSON.parse(result.body);
} catch (_) {
body = result.body;
}
// Audit log for asset search (fire-and-forget)
let resultCount = 0;
if (body && typeof body === 'object' && typeof body.total === 'number') {
resultCount = body.total;
} else if (body && Array.isArray(body.assets)) {
resultCount = body.assets.length;
}
logAudit(db, {
userId: req.user.id,
username: req.user.username,
action: 'card_search',
entityType: 'card_asset',
entityId: teamName,
details: { disposition, resultCount },
ipAddress: req.ip,
});
return res.json(body);
}
let errorBody;
try {
errorBody = JSON.parse(result.body);
} catch (_) {
errorBody = { error: result.body };
}
return res.status(result.status).json(errorBody);
} catch (err) {
return handleCardError(err, res);
}
});
// -------------------------------------------------------------------
// GET /owner/:assetId
// Proxy owner record lookup.
// -------------------------------------------------------------------
router.get('/owner/:assetId', requireAuth(db), requireGroup('Admin', 'Standard_User'), async (req, res) => {
if (!isConfigured) {
return res.status(503).json({ error: 'CARD API is not configured.', missingVars });
}
const { assetId } = req.params;
try {
const result = await getOwner(assetId);
if (result.ok) {
let body;
try {
body = JSON.parse(result.body);
} catch (_) {
body = result.body;
}
return res.json(body);
}
let errorBody;
try {
errorBody = JSON.parse(result.body);
} catch (_) {
errorBody = { error: result.body };
}
return res.status(result.status).json(errorBody);
} catch (err) {
return handleCardError(err, res);
}
});
// -------------------------------------------------------------------
// POST /queue/:queueItemId/confirm
// Confirm asset to a team via CARD API.
// -------------------------------------------------------------------
router.post('/queue/:queueItemId/confirm', requireAuth(db), requireGroup('Admin', 'Standard_User'), async (req, res) => {
if (!isConfigured) {
return res.status(503).json({ error: 'CARD API is not configured.', missingVars });
}
const { queueItemId } = req.params;
const { teamName, assetId, comment } = req.body;
// Validate required fields
if (!teamName || typeof teamName !== 'string' || !teamName.trim()) {
return res.status(400).json({ error: 'teamName is required.' });
}
if (!assetId || typeof assetId !== 'string' || !assetId.trim()) {
return res.status(400).json({ error: 'assetId is required.' });
}
try {
// Validate queue item
const item = await dbGet(db,
'SELECT * FROM ivanti_todo_queue WHERE id = ? AND user_id = ? AND workflow_type = ?',
[queueItemId, req.user.id, 'CARD']
);
if (!item) {
return res.status(404).json({ error: 'Queue item not found.' });
}
if (item.status !== 'pending') {
return res.status(400).json({ error: 'Only pending queue items can be executed.' });
}
// Step 1: Get owner record for update_token
const ownerResult = await getOwner(assetId);
if (!ownerResult.ok) {
const errMsg = `Failed to fetch owner record: HTTP ${ownerResult.status}`;
console.error('[card-api]', errMsg);
logAudit(db, {
userId: req.user.id,
username: req.user.username,
action: 'card_action_failed',
entityType: 'ivanti_todo_queue',
entityId: String(queueItemId),
details: { actionType: 'confirm', assetId, error: errMsg, cardStatus: ownerResult.status },
ipAddress: req.ip,
});
let errorBody;
try { errorBody = JSON.parse(ownerResult.body); } catch (_) { errorBody = { error: ownerResult.body }; }
return res.status(ownerResult.status).json(errorBody);
}
let ownerData;
try { ownerData = JSON.parse(ownerResult.body); } catch (_) { ownerData = {}; }
const updateToken = ownerData.owner && ownerData.owner.update_token;
if (!updateToken) {
const errMsg = 'update_token not found in owner record.';
console.error('[card-api]', errMsg);
logAudit(db, {
userId: req.user.id,
username: req.user.username,
action: 'card_action_failed',
entityType: 'ivanti_todo_queue',
entityId: String(queueItemId),
details: { actionType: 'confirm', assetId, error: errMsg, cardStatus: null },
ipAddress: req.ip,
});
return res.status(502).json({ error: 'CARD API request failed.', details: errMsg });
}
// Step 2: Execute confirm mutation
const confirmResult = await confirmAsset(assetId, teamName.trim(), updateToken, comment || '');
if (confirmResult.ok) {
// Update queue item to complete
await dbRun(db,
"UPDATE ivanti_todo_queue SET status = 'complete', updated_at = CURRENT_TIMESTAMP WHERE id = ?",
[queueItemId]
);
let cardResponse;
try { cardResponse = JSON.parse(confirmResult.body); } catch (_) { cardResponse = confirmResult.body; }
// Audit log (fire-and-forget)
logAudit(db, {
userId: req.user.id,
username: req.user.username,
action: 'card_confirm',
entityType: 'ivanti_todo_queue',
entityId: String(queueItemId),
details: { assetId, teamName: teamName.trim(), comment: comment || '', cardStatus: confirmResult.status },
ipAddress: req.ip,
});
return res.json({ success: true, cardResponse });
}
// Mutation failed — leave queue item as pending
const errMsg = `Confirm failed: HTTP ${confirmResult.status}`;
console.error('[card-api]', errMsg);
logAudit(db, {
userId: req.user.id,
username: req.user.username,
action: 'card_action_failed',
entityType: 'ivanti_todo_queue',
entityId: String(queueItemId),
details: { actionType: 'confirm', assetId, error: errMsg, cardStatus: confirmResult.status },
ipAddress: req.ip,
});
let errorBody;
try { errorBody = JSON.parse(confirmResult.body); } catch (_) { errorBody = { error: confirmResult.body }; }
return res.status(confirmResult.status).json(errorBody);
} catch (err) {
console.error('[card-api] Confirm error:', err.message);
logAudit(db, {
userId: req.user.id,
username: req.user.username,
action: 'card_action_failed',
entityType: 'ivanti_todo_queue',
entityId: String(queueItemId),
details: { actionType: 'confirm', assetId, error: err.message, cardStatus: null },
ipAddress: req.ip,
});
return handleCardError(err, res);
}
});
// -------------------------------------------------------------------
// POST /queue/:queueItemId/decline
// Decline asset from a team via CARD API.
// -------------------------------------------------------------------
router.post('/queue/:queueItemId/decline', requireAuth(db), requireGroup('Admin', 'Standard_User'), async (req, res) => {
if (!isConfigured) {
return res.status(503).json({ error: 'CARD API is not configured.', missingVars });
}
const { queueItemId } = req.params;
const { teamName, assetId, comment } = req.body;
// Validate required fields
if (!teamName || typeof teamName !== 'string' || !teamName.trim()) {
return res.status(400).json({ error: 'teamName is required.' });
}
if (!assetId || typeof assetId !== 'string' || !assetId.trim()) {
return res.status(400).json({ error: 'assetId is required.' });
}
try {
// Validate queue item
const item = await dbGet(db,
'SELECT * FROM ivanti_todo_queue WHERE id = ? AND user_id = ? AND workflow_type = ?',
[queueItemId, req.user.id, 'CARD']
);
if (!item) {
return res.status(404).json({ error: 'Queue item not found.' });
}
if (item.status !== 'pending') {
return res.status(400).json({ error: 'Only pending queue items can be executed.' });
}
// Step 1: Get owner record for update_token
const ownerResult = await getOwner(assetId);
if (!ownerResult.ok) {
const errMsg = `Failed to fetch owner record: HTTP ${ownerResult.status}`;
console.error('[card-api]', errMsg);
logAudit(db, {
userId: req.user.id,
username: req.user.username,
action: 'card_action_failed',
entityType: 'ivanti_todo_queue',
entityId: String(queueItemId),
details: { actionType: 'decline', assetId, error: errMsg, cardStatus: ownerResult.status },
ipAddress: req.ip,
});
let errorBody;
try { errorBody = JSON.parse(ownerResult.body); } catch (_) { errorBody = { error: ownerResult.body }; }
return res.status(ownerResult.status).json(errorBody);
}
let ownerData;
try { ownerData = JSON.parse(ownerResult.body); } catch (_) { ownerData = {}; }
const updateToken = ownerData.owner && ownerData.owner.update_token;
if (!updateToken) {
const errMsg = 'update_token not found in owner record.';
console.error('[card-api]', errMsg);
logAudit(db, {
userId: req.user.id,
username: req.user.username,
action: 'card_action_failed',
entityType: 'ivanti_todo_queue',
entityId: String(queueItemId),
details: { actionType: 'decline', assetId, error: errMsg, cardStatus: null },
ipAddress: req.ip,
});
return res.status(502).json({ error: 'CARD API request failed.', details: errMsg });
}
// Step 2: Execute decline mutation
const declineResult = await declineAsset(assetId, teamName.trim(), updateToken, comment || '');
if (declineResult.ok) {
await dbRun(db,
"UPDATE ivanti_todo_queue SET status = 'complete', updated_at = CURRENT_TIMESTAMP WHERE id = ?",
[queueItemId]
);
let cardResponse;
try { cardResponse = JSON.parse(declineResult.body); } catch (_) { cardResponse = declineResult.body; }
logAudit(db, {
userId: req.user.id,
username: req.user.username,
action: 'card_decline',
entityType: 'ivanti_todo_queue',
entityId: String(queueItemId),
details: { assetId, teamName: teamName.trim(), comment: comment || '', cardStatus: declineResult.status },
ipAddress: req.ip,
});
return res.json({ success: true, cardResponse });
}
// Mutation failed
const errMsg = `Decline failed: HTTP ${declineResult.status}`;
console.error('[card-api]', errMsg);
logAudit(db, {
userId: req.user.id,
username: req.user.username,
action: 'card_action_failed',
entityType: 'ivanti_todo_queue',
entityId: String(queueItemId),
details: { actionType: 'decline', assetId, error: errMsg, cardStatus: declineResult.status },
ipAddress: req.ip,
});
let errorBody;
try { errorBody = JSON.parse(declineResult.body); } catch (_) { errorBody = { error: declineResult.body }; }
return res.status(declineResult.status).json(errorBody);
} catch (err) {
console.error('[card-api] Decline error:', err.message);
logAudit(db, {
userId: req.user.id,
username: req.user.username,
action: 'card_action_failed',
entityType: 'ivanti_todo_queue',
entityId: String(queueItemId),
details: { actionType: 'decline', assetId, error: err.message, cardStatus: null },
ipAddress: req.ip,
});
return handleCardError(err, res);
}
});
// -------------------------------------------------------------------
// POST /queue/:queueItemId/redirect
// Redirect asset from one team to another via CARD API.
// -------------------------------------------------------------------
router.post('/queue/:queueItemId/redirect', requireAuth(db), requireGroup('Admin', 'Standard_User'), async (req, res) => {
if (!isConfigured) {
return res.status(503).json({ error: 'CARD API is not configured.', missingVars });
}
const { queueItemId } = req.params;
const { fromTeam, toTeam, assetId } = req.body;
// Validate required fields
if (!fromTeam || typeof fromTeam !== 'string' || !fromTeam.trim()) {
return res.status(400).json({ error: 'fromTeam is required.' });
}
if (!toTeam || typeof toTeam !== 'string' || !toTeam.trim()) {
return res.status(400).json({ error: 'toTeam is required.' });
}
if (!assetId || typeof assetId !== 'string' || !assetId.trim()) {
return res.status(400).json({ error: 'assetId is required.' });
}
try {
// Validate queue item
const item = await dbGet(db,
'SELECT * FROM ivanti_todo_queue WHERE id = ? AND user_id = ? AND workflow_type = ?',
[queueItemId, req.user.id, 'CARD']
);
if (!item) {
return res.status(404).json({ error: 'Queue item not found.' });
}
if (item.status !== 'pending') {
return res.status(400).json({ error: 'Only pending queue items can be executed.' });
}
// Step 1: Get owner record for update_token
const ownerResult = await getOwner(assetId);
if (!ownerResult.ok) {
const errMsg = `Failed to fetch owner record: HTTP ${ownerResult.status}`;
console.error('[card-api]', errMsg);
logAudit(db, {
userId: req.user.id,
username: req.user.username,
action: 'card_action_failed',
entityType: 'ivanti_todo_queue',
entityId: String(queueItemId),
details: { actionType: 'redirect', assetId, error: errMsg, cardStatus: ownerResult.status },
ipAddress: req.ip,
});
let errorBody;
try { errorBody = JSON.parse(ownerResult.body); } catch (_) { errorBody = { error: ownerResult.body }; }
return res.status(ownerResult.status).json(errorBody);
}
let ownerData;
try { ownerData = JSON.parse(ownerResult.body); } catch (_) { ownerData = {}; }
const updateToken = ownerData.owner && ownerData.owner.update_token;
if (!updateToken) {
const errMsg = 'update_token not found in owner record.';
console.error('[card-api]', errMsg);
logAudit(db, {
userId: req.user.id,
username: req.user.username,
action: 'card_action_failed',
entityType: 'ivanti_todo_queue',
entityId: String(queueItemId),
details: { actionType: 'redirect', assetId, error: errMsg, cardStatus: null },
ipAddress: req.ip,
});
return res.status(502).json({ error: 'CARD API request failed.', details: errMsg });
}
// Step 2: Execute redirect mutation
const redirectResult = await redirectAsset(assetId, fromTeam.trim(), toTeam.trim(), updateToken);
if (redirectResult.ok) {
await dbRun(db,
"UPDATE ivanti_todo_queue SET status = 'complete', updated_at = CURRENT_TIMESTAMP WHERE id = ?",
[queueItemId]
);
let cardResponse;
try { cardResponse = JSON.parse(redirectResult.body); } catch (_) { cardResponse = redirectResult.body; }
logAudit(db, {
userId: req.user.id,
username: req.user.username,
action: 'card_redirect',
entityType: 'ivanti_todo_queue',
entityId: String(queueItemId),
details: { assetId, fromTeam: fromTeam.trim(), toTeam: toTeam.trim(), cardStatus: redirectResult.status },
ipAddress: req.ip,
});
return res.json({ success: true, cardResponse });
}
// Mutation failed
const errMsg = `Redirect failed: HTTP ${redirectResult.status}`;
console.error('[card-api]', errMsg);
logAudit(db, {
userId: req.user.id,
username: req.user.username,
action: 'card_action_failed',
entityType: 'ivanti_todo_queue',
entityId: String(queueItemId),
details: { actionType: 'redirect', assetId, error: errMsg, cardStatus: redirectResult.status },
ipAddress: req.ip,
});
let errorBody;
try { errorBody = JSON.parse(redirectResult.body); } catch (_) { errorBody = { error: redirectResult.body }; }
return res.status(redirectResult.status).json(errorBody);
} catch (err) {
console.error('[card-api] Redirect error:', err.message);
logAudit(db, {
userId: req.user.id,
username: req.user.username,
action: 'card_action_failed',
entityType: 'ivanti_todo_queue',
entityId: String(queueItemId),
details: { actionType: 'redirect', assetId, error: err.message, cardStatus: null },
ipAddress: req.ip,
});
return handleCardError(err, res);
}
});
return router;
}
module.exports = createCardApiRouter;

View File

@@ -13,8 +13,8 @@
// POST /notes — add a note to one or more (hostname, metric_id) pairs
// GET /notes/:hostname/:metricId — notes for a specific device+metric
// GET /trends — per-upload totals + per-team counts for time-series charts
// GET /mttr — mean time to resolution per team
// GET /top-recurring — chronic compliance gaps sorted by seen_count
// GET /mttr — aging findings distribution by seen_count bucket and team
// GET /top-recurring — net change waterfall (per-cycle start/new/recurring/resolved/end)
// GET /category-trend — active counts per category per upload for stacked area chart
const express = require('express');
@@ -240,6 +240,60 @@ function groupByHostname(rows, noteHostnames) {
return Object.values(deviceMap);
}
// ---------------------------------------------------------------------------
// Pure function: bucket active items by age group and pivot per-team counts
// ---------------------------------------------------------------------------
const BUCKET_ORDER = ['1 cycle', '23 cycles', '46 cycles', '7+ cycles'];
function bucketAgingItems(items) {
// Initialise empty buckets with all teams at zero
const teams = ['STEAM', 'ACCESS-ENG', 'ACCESS-OPS', 'INTELDEV'];
const buckets = {};
for (const b of BUCKET_ORDER) {
buckets[b] = { bucket: b, total: 0 };
for (const t of teams) buckets[b][t] = 0;
}
// Classify each item into a bucket
for (const item of items) {
const sc = item.seen_count;
let label;
if (sc === 1) label = '1 cycle';
else if (sc >= 2 && sc <= 3) label = '23 cycles';
else if (sc >= 4 && sc <= 6) label = '46 cycles';
else label = '7+ cycles';
const team = item.team;
buckets[label].total += 1;
if (team in buckets[label]) {
buckets[label][team] += 1;
}
}
// Return in ascending age order
return BUCKET_ORDER.map(b => buckets[b]);
}
// ---------------------------------------------------------------------------
// Pure function: compute waterfall chain from ordered upload records
// ---------------------------------------------------------------------------
function computeWaterfall(uploads) {
let start = 0;
return uploads.map((row) => {
const end = start + row.new_count + row.recurring_count - row.resolved_count;
const entry = {
date: row.report_date,
start,
new_count: row.new_count,
recurring_count: row.recurring_count,
resolved_count: row.resolved_count,
end,
};
start = end;
return entry;
});
}
// ---------------------------------------------------------------------------
// Router factory
// ---------------------------------------------------------------------------
@@ -1012,27 +1066,23 @@ function createComplianceRouter(db, upload, requireAuth, requireGroup) {
// -----------------------------------------------------------------------
// GET /mttr
// Mean time to resolution (calendar days) per team, for resolved items.
// Aging Findings Distribution — active findings bucketed by seen_count
// with per-team breakdown for stacked bar chart.
//
// Response: { mttr: [{ team, avg_days, resolved_count }] }
// Response: { aging: [{ bucket, total, STEAM, ACCESS-ENG, ACCESS-OPS, INTELDEV }] }
// -----------------------------------------------------------------------
router.get('/mttr', async (req, res) => {
try {
const rows = await dbAll(db,
`SELECT
ci.team,
ROUND(AVG(JULIANDAY(ru.report_date) - JULIANDAY(fu.report_date)), 1) AS avg_days,
COUNT(*) AS resolved_count
FROM compliance_items ci
JOIN compliance_uploads fu ON ci.first_seen_upload_id = fu.id
JOIN compliance_uploads ru ON ci.resolved_upload_id = ru.id
WHERE ci.resolved_upload_id IS NOT NULL
AND fu.report_date IS NOT NULL
AND ru.report_date IS NOT NULL
GROUP BY ci.team
ORDER BY avg_days DESC`
`SELECT COALESCE(seen_count, 1) AS seen_count, team
FROM compliance_items
WHERE status = 'active'`
);
res.json({ mttr: rows });
if (rows.length === 0) {
return res.json({ aging: [] });
}
const aging = bucketAgingItems(rows);
res.json({ aging });
} catch (err) {
console.error('[Compliance] GET /mttr error:', err.message);
res.status(500).json({ error: 'Database error' });
@@ -1041,23 +1091,24 @@ function createComplianceRouter(db, upload, requireAuth, requireGroup) {
// -----------------------------------------------------------------------
// GET /top-recurring
// Active findings grouped by team + metric_id, sorted by seen_count desc.
// Identifies chronic compliance gaps that keep reappearing.
// Net Change Waterfall — per-cycle net movement (start → +new →
// +recurring → resolved → end) computed from compliance_uploads.
//
// Response: { items: [{ team, metric_id, metric_desc, seen_count,
// host_count }] } — limited to top 20
// Response: { waterfall: [{ date, start, new_count, recurring_count,
// resolved_count, end }] }
// -----------------------------------------------------------------------
router.get('/top-recurring', async (req, res) => {
try {
const rows = await dbAll(db,
`SELECT team, metric_id, metric_desc, seen_count, COUNT(*) AS host_count
FROM compliance_items
WHERE status = 'active'
GROUP BY team, metric_id
ORDER BY seen_count DESC, host_count DESC
LIMIT 20`
`SELECT id, report_date,
COALESCE(new_count, 0) AS new_count,
COALESCE(recurring_count, 0) AS recurring_count,
COALESCE(resolved_count, 0) AS resolved_count
FROM compliance_uploads
ORDER BY report_date ASC`
);
res.json({ items: rows });
const waterfall = computeWaterfall(rows);
res.json({ waterfall });
} catch (err) {
console.error('[Compliance] GET /top-recurring error:', err.message);
res.status(500).json({ error: 'Database error' });
@@ -1089,4 +1140,4 @@ function createComplianceRouter(db, upload, requireAuth, requireGroup) {
return router;
}
module.exports = createComplianceRouter;
module.exports = { createComplianceRouter, bucketAgingItems, computeWaterfall };

View File

@@ -275,6 +275,7 @@ async function detectArchiveChanges(db, previousFindings, currentFindings) {
// 2. Returned findings: in current AND has existing ARCHIVED record → RETURNED
const currentIdsList = [...currentIds];
const returnedArchiveIds = []; // track archive IDs of returned findings for classification
if (currentIdsList.length > 0) {
try {
const archivedRecords = await dbAll(db,
@@ -297,6 +298,7 @@ async function detectArchiveChanges(db, previousFindings, currentFindings) {
VALUES (?, 'ARCHIVED', 'RETURNED', ?, 'reappeared_in_sync', datetime('now'))`,
[record.id, severity]
);
returnedArchiveIds.push(record.id);
console.log(`[Archive Detection] Finding ${record.finding_id} returned (ARCHIVED → RETURNED)`);
}
}
@@ -306,23 +308,38 @@ async function detectArchiveChanges(db, previousFindings, currentFindings) {
}
// Count returned findings for anomaly summary
let returnedCount = 0;
if (currentIdsList.length > 0) {
let returnedCount = returnedArchiveIds.length;
// Classify returned findings by looking up the reason they were originally archived.
// This tells us *why* they came back (e.g., BU reassignment back to team).
const returnClassification = { bu_reassignment: 0, severity_drift: 0, closed_on_platform: 0, decommissioned: 0 };
for (const archiveId of returnedArchiveIds) {
try {
// Count how many ARCHIVED records transitioned to RETURNED in this cycle
// (already handled above, just count them)
const archivedForCount = await dbAll(db,
`SELECT id, finding_id FROM ivanti_finding_archives WHERE current_state = 'RETURNED' AND last_transition_at >= datetime('now', '-1 minute')`
// Find the most recent ARCHIVED transition reason for this archive record
const transition = await dbGet(db,
`SELECT reason FROM ivanti_archive_transitions
WHERE archive_id = ? AND to_state = 'ARCHIVED'
ORDER BY transitioned_at DESC LIMIT 1`,
[archiveId]
);
returnedCount = archivedForCount.length;
if (transition && transition.reason) {
// Reason format is either a plain key or "key:detail" (e.g., "bu_reassignment:SOME-BU")
const reasonKey = transition.reason.split(':')[0];
if (reasonKey in returnClassification) {
returnClassification[reasonKey]++;
}
}
} catch (err) {
// Non-fatal — returnedCount stays 0
// Non-fatal — skip this finding's classification
}
}
console.log(`[Archive Detection] Processed ${disappearedIds.length} disappeared, ${returnedCount} returned, checked ${currentIdsList.length} current findings against archive`);
if (returnedCount > 0) {
console.log(`[Archive Detection] Return classification:`, returnClassification);
}
return { disappearedIds, returnedCount };
return { disappearedIds, returnedCount, returnClassification };
}
// ---------------------------------------------------------------------------
@@ -763,9 +780,9 @@ async function syncFindings(db) {
// Archive detection — compare previous vs current to detect disappeared/returned findings
// Only runs after a successful sync (skipped on error per requirement 1.5)
let archiveResult = { disappearedIds: [], returnedCount: 0 };
let archiveResult = { disappearedIds: [], returnedCount: 0, returnClassification: {} };
try {
archiveResult = await detectArchiveChanges(db, previousFindings, allFindings) || { disappearedIds: [], returnedCount: 0 };
archiveResult = await detectArchiveChanges(db, previousFindings, allFindings) || { disappearedIds: [], returnedCount: 0, returnClassification: {} };
} catch (err) {
console.error('[Ivanti Findings] Archive detection failed (non-fatal):', err.message);
}
@@ -812,7 +829,8 @@ async function syncFindings(db) {
closedCountDelta,
archiveResult.disappearedIds.length,
archiveResult.returnedCount,
classificationBreakdown
classificationBreakdown,
archiveResult.returnClassification || {}
);
} catch (err) {
console.error('[Ivanti Findings] Anomaly summary failed (non-fatal):', err.message);
@@ -1060,20 +1078,24 @@ async function runBUDriftChecker(db, newlyArchivedIds, apiKey, clientId, skipTls
// ---------------------------------------------------------------------------
// Anomaly Summary — compute and store post-sync anomaly report
// ---------------------------------------------------------------------------
async function computeAnomalySummary(db, openCountDelta, closedCountDelta, newlyArchivedCount, returnedCount, classificationBreakdown) {
async function computeAnomalySummary(db, openCountDelta, closedCountDelta, newlyArchivedCount, returnedCount, classificationBreakdown, returnClassificationBreakdown) {
try {
const isSignificant = newlyArchivedCount > 5 ? 1 : 0;
const classificationJson = JSON.stringify(classificationBreakdown || {});
const returnClassificationJson = JSON.stringify(returnClassificationBreakdown || {});
await dbRun(db,
`INSERT INTO ivanti_sync_anomaly_log
(sync_timestamp, open_count_delta, closed_count_delta, newly_archived_count, returned_count, classification_json, is_significant)
VALUES (datetime('now'), ?, ?, ?, ?, ?, ?)`,
[openCountDelta, closedCountDelta, newlyArchivedCount, returnedCount, classificationJson, isSignificant]
(sync_timestamp, open_count_delta, closed_count_delta, newly_archived_count, returned_count, classification_json, return_classification_json, is_significant)
VALUES (datetime('now'), ?, ?, ?, ?, ?, ?, ?)`,
[openCountDelta, closedCountDelta, newlyArchivedCount, returnedCount, classificationJson, returnClassificationJson, isSignificant]
);
console.log(`[Anomaly Summary] Sync anomaly logged — open_delta: ${openCountDelta}, closed_delta: ${closedCountDelta}, archived: ${newlyArchivedCount}, returned: ${returnedCount}, significant: ${!!isSignificant}`);
console.log(`[Anomaly Summary] Classification:`, classificationBreakdown);
if (returnedCount > 0) {
console.log(`[Anomaly Summary] Return classification:`, returnClassificationBreakdown);
}
} catch (err) {
console.error('[Anomaly Summary] Failed to write anomaly summary (non-fatal):', err.message);
}
@@ -1219,13 +1241,15 @@ function createIvantiFindingsRouter(db, requireAuth) {
try {
const row = await dbGet(db,
`SELECT id, sync_timestamp, open_count_delta, closed_count_delta,
newly_archived_count, returned_count, classification_json, is_significant
newly_archived_count, returned_count, classification_json, return_classification_json, is_significant
FROM ivanti_sync_anomaly_log
ORDER BY sync_timestamp DESC LIMIT 1`
);
if (!row) return res.json({ anomaly: null });
let classification = {};
try { classification = JSON.parse(row.classification_json || '{}'); } catch (_) {}
let return_classification = {};
try { return_classification = JSON.parse(row.return_classification_json || '{}'); } catch (_) {}
res.json({
anomaly: {
id: row.id,
@@ -1235,6 +1259,7 @@ function createIvantiFindingsRouter(db, requireAuth) {
newly_archived_count: row.newly_archived_count,
returned_count: row.returned_count,
classification,
return_classification,
is_significant: !!row.is_significant
}
});
@@ -1265,7 +1290,7 @@ function createIvantiFindingsRouter(db, requireAuth) {
if (from && to) {
rows = await dbAll(db,
`SELECT sync_timestamp, open_count_delta, closed_count_delta,
newly_archived_count, returned_count, classification_json, is_significant
newly_archived_count, returned_count, classification_json, return_classification_json, is_significant
FROM ivanti_sync_anomaly_log
WHERE sync_timestamp >= ? AND sync_timestamp <= ?
ORDER BY sync_timestamp DESC`,
@@ -1274,7 +1299,7 @@ function createIvantiFindingsRouter(db, requireAuth) {
} else {
rows = await dbAll(db,
`SELECT sync_timestamp, open_count_delta, closed_count_delta,
newly_archived_count, returned_count, classification_json, is_significant
newly_archived_count, returned_count, classification_json, return_classification_json, is_significant
FROM ivanti_sync_anomaly_log
ORDER BY sync_timestamp DESC LIMIT 30`
);
@@ -1283,6 +1308,8 @@ function createIvantiFindingsRouter(db, requireAuth) {
const history = rows.map(row => {
let classification = {};
try { classification = JSON.parse(row.classification_json || '{}'); } catch (_) {}
let return_classification = {};
try { return_classification = JSON.parse(row.return_classification_json || '{}'); } catch (_) {}
return {
sync_timestamp: row.sync_timestamp,
open_count_delta: row.open_count_delta,
@@ -1290,6 +1317,7 @@ function createIvantiFindingsRouter(db, requireAuth) {
newly_archived_count: row.newly_archived_count,
returned_count: row.returned_count,
classification,
return_classification,
is_significant: !!row.is_significant
};
});

View File

@@ -0,0 +1,388 @@
#!/usr/bin/env node
// ==========================================================================
// CARD → Granite Lookup Script (v2)
// ==========================================================================
// Queries CARD team assets endpoint (which returns full enriched records
// including ncim_discovery with EQUIP_INST_ID) for the 109 reassigned IPs
// from the findings-count investigation Appendix C.
//
// Generates:
// docs/card-lookup-results.csv — full CARD data for review
// docs/granite-reassignment-upload.csv — Team_Device Loader format
//
// Usage:
// cd backend
// node scripts/card-granite-lookup.js
// ==========================================================================
require('dotenv').config({ path: require('path').join(__dirname, '..', '.env') });
const cardApi = require('../helpers/cardApi');
const fs = require('fs');
const path = require('path');
// ---------------------------------------------------------------------------
// IP → hostname mapping from Appendix C
// ---------------------------------------------------------------------------
const REASSIGNED = {
// With approved FP workflows (58)
'98.120.0.78': 'syn-098-120-000-078', '98.120.32.185': 'syn-098-120-032-185',
'10.240.78.177': 'mon15-agg-sw', '10.240.78.176': 'mon16-agg-sw',
'10.240.78.133': 'mon15-sw14', '10.240.78.130': 'mon15-sw11',
'10.240.78.150': 'mon19-sw3', '10.240.78.107': 'mon16-sw2',
'10.240.78.110': 'mon16-sw5', '10.240.78.106': 'mon16-sw1',
'10.240.78.149': 'mon19-sw2', '10.240.78.154': 'mon19-sw7',
'10.240.78.111': 'mon16-sw6', '10.240.78.153': 'mon19-sw6',
'10.240.78.132': 'mon15-sw13', '10.240.78.115': 'mon16-sw10',
'10.240.78.109': 'mon16-sw4', '10.240.78.112': 'mon16-sw7',
'10.240.78.119': 'mon16-sw14', '10.240.78.114': 'mon16-sw9',
'10.240.78.118': 'mon16-sw13', '10.240.78.117': 'mon16-sw12',
'10.240.78.108': 'mon16-sw3', '10.240.78.155': 'mon19-sw8',
'10.240.78.157': 'mon19-sw10', '10.240.78.151': 'mon19-sw4',
'10.240.78.116': 'mon16-sw11', '10.240.78.152': 'mon19-sw5',
'10.240.78.161': 'mon19-sw14', '10.240.78.160': 'mon19-sw13',
'10.240.78.159': 'mon19-sw12', '10.240.78.158': 'mon19-sw11',
'10.240.78.123': 'mon15-sw4', '10.240.78.137': 'mon20-sw4',
'10.240.78.148': 'mon19-sw1', '10.240.78.125': 'mon15-sw6',
'10.240.78.156': 'mon19-sw9', '10.241.0.63': '',
'10.244.11.51': 'apc01se1shcc-n01-bmc', '172.27.72.1': '',
'96.37.185.145': '', '10.240.78.170': 'mon17-sw9',
'10.240.78.172': 'mon17-sw11', '10.240.78.169': 'mon17-sw8',
'10.240.78.166': 'mon17-sw5', '10.240.78.174': 'mon17-sw13',
'10.240.78.173': 'mon17-sw12', '10.240.78.167': 'mon17-sw6',
'10.240.78.175': 'mon17-sw14', '10.240.78.168': 'mon17-sw7',
'10.240.78.171': 'mon17-sw10', '66.61.128.10': 'syn-066-061-128-010',
'66.61.128.233': 'apa01se1shcc-bvi101-secondary',
'66.61.128.49': 'syn-066-061-128-049', '66.61.128.18': 'syn-066-061-128-018',
'10.244.4.26': '', '10.244.11.5': '', '10.244.11.6': '',
// With rejected FP workflows (8)
'10.244.4.55': 'apc15se1shcc-n03', '10.244.11.53': 'apc01se1shcc-n03-bmc',
'10.244.4.30': '', '10.244.11.63': 'apc04se1shcc-n01-cimc',
'24.28.208.125': '', '24.28.210.101': 'syn-024-028-210-101',
'10.244.11.27': '', '10.240.1.203': '',
// Without FP workflows (43)
'10.240.78.20': '', '172.16.1.229': '',
'10.244.11.96': '', '10.244.11.54': 'apc02se1shcc-n01-cimc',
'10.244.4.51': 'apc14se1shcc-n02', '10.244.11.86': '',
'10.244.11.55': 'apc02se1shcc-n02-cimc', '24.28.208.105': 'syn-024-028-208-105',
'10.244.4.50': 'apc14se1shcc-n01', '10.244.4.53': 'apc15se1shcc-n01',
'10.244.11.73': 'apc07se1shcc-n02-cimc', '10.244.11.64': 'apc04se1shcc-n02-cimc',
'10.244.4.54': 'apc15se1shcc-n02', '10.244.4.28': '',
'10.244.11.94': '', '10.241.0.43': 'c220-wzp27340ss5',
'10.244.11.56': 'apc02se1shcc-n03-cimc', '10.244.11.66': 'apc05se1shcc-n01-bmc',
'10.244.4.47': 'apc13se1shcc-n01', '10.244.4.49': 'apc13se1shcc-n03',
'10.244.4.52': 'apc14se1shcc-n03', '10.244.11.72': 'apc07se1shcc-n01-cimc',
'10.244.4.25': 'apc02ctsbcom7-n03-cimc', '10.244.4.29': '',
'10.244.11.74': 'apc07se1shcc-n03-cimc', '10.244.4.48': 'apc13se1shcc-n02',
'10.244.11.65': 'apc04se1shcc-n03-cimc', '10.244.4.24': 'apc02ctsbcom7-n02-cimc',
'10.244.11.87': '', '10.244.11.68': 'apc05se1shcc-n03-bmc',
'10.244.11.67': 'apc05se1shcc-n02-bmc', '10.244.4.23': 'apc02ctsbcom7-n01-cimc',
'10.244.11.57': '', '10.244.11.95': '',
'98.120.32.145': 'syn-098-120-032-145', '98.120.0.129': 'syn-098-120-000-129',
'68.114.184.84': 'rphy-runner-vecima',
};
const TARGET_IPS = new Set(Object.keys(REASSIGNED));
// ---------------------------------------------------------------------------
// Fetch all assets for both teams, then match against our IP list
// ---------------------------------------------------------------------------
async function fetchTeamAssets(teamName) {
const allAssets = [];
let page = 1;
const pageSize = 200;
while (true) {
// Fetch confirmed assets (these have the richest data)
const result = await cardApi.getTeamAssets(teamName, {
disposition: 'confirmed',
page,
pageSize,
});
if (!result.ok) {
console.error(` Failed to fetch ${teamName} page ${page}: HTTP ${result.status}`);
break;
}
let data;
try { data = JSON.parse(result.body); } catch (_) { break; }
const assets = Array.isArray(data) ? data : (data.assets || data.results || []);
allAssets.push(...assets);
const total = data.total || assets.length;
console.log(` ${teamName} page ${page}: ${assets.length} assets (total: ${total})`);
if (allAssets.length >= total || assets.length === 0) break;
page++;
}
return allAssets;
}
function extractIPFromAssetId(assetId) {
// Asset IDs are like "10.240.78.110-CTEC" — strip the suffix
if (!assetId) return null;
const parts = assetId.split('-');
// Rejoin all but the last part (the suffix like CTEC, NATL, etc.)
// But only if the last part looks like a suffix (not a number)
const last = parts[parts.length - 1];
if (/^\d+$/.test(last)) return assetId; // All numeric, probably just an IP
return parts.slice(0, -1).join('-');
}
function extractGraniteData(asset) {
const id = asset._id || '';
const ip = extractIPFromAssetId(id);
const flags = (asset.card_flags && asset.card_flags[0]) || {};
const ncim = asset.ncim_discovery || [];
const qualys = asset.qualys_hosts || [];
const ivanti = asset.ivanti_assets || [];
const granite = asset.netops_granite_allips || null;
const iseGranite = asset.ise_granite_equipment || null;
// Extract EQUIP_INST_ID from ncim_discovery (primary source)
let equipInstId = null;
let graniteTeam = null;
let entityId = null;
let sysLocation = null;
let ncimHostname = null;
if (ncim.length > 0) {
equipInstId = ncim[0].EQUIP_INST_ID || null;
graniteTeam = ncim[0].GRANITE_RESP_TEAM || ncim[0].RESPONSIBLE_TEAM || null;
entityId = ncim[0].ENTITYID || null;
sysLocation = ncim[0].SYSLOCATION || null;
ncimHostname = ncim[0].HOSTNAME || null;
}
// Fallback: check netops_granite_allips
if (!equipInstId && granite && Array.isArray(granite) && granite.length > 0) {
equipInstId = granite[0].EQUIP_INST_ID || null;
}
// Fallback: check ise_granite_equipment
if (!equipInstId && iseGranite && Array.isArray(iseGranite) && iseGranite.length > 0) {
equipInstId = iseGranite[0].EQUIP_INST_ID || null;
}
const hostname = ncimHostname
|| (flags.CARD_HOSTNAME && flags.CARD_HOSTNAME[0])
|| (qualys.length > 0 && qualys[0].HOSTNAME)
|| (ivanti.length > 0 && ivanti[0].hostName)
|| '';
const confirmedTeam = asset.owner && asset.owner.confirmed
? asset.owner.confirmed.name : null;
return {
ip,
assetId: id,
hostname,
equipInstId,
graniteTeam,
entityId,
sysLocation,
confirmedTeam,
deviceId: flags.CARD_DEVICE_ID || null,
asn: flags.CARD_ASN || null,
vendorModel: (flags.CARD_VENDOR_MODEL || []).map(v => v.vendor_model || v).join(', '),
status: flags.status || null,
};
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
async function main() {
console.log('=== CARD → Granite Lookup (v2 — team assets endpoint) ===');
console.log(`Target IPs: ${TARGET_IPS.size}`);
console.log(`CARD_API_URL: ${process.env.CARD_API_URL}`);
console.log('');
if (!cardApi.isConfigured) {
console.error('CARD API is not configured.');
process.exit(1);
}
// Fetch assets from both teams
const teams = ['NTS-AEO-STEAM', 'NTS-AEO-ACCESS-ENG'];
const allAssets = [];
for (const team of teams) {
console.log(`Fetching ${team}...`);
const assets = await fetchTeamAssets(team);
allAssets.push(...assets);
console.log(` Total: ${assets.length} assets\n`);
}
// Also fetch candidate/unconfirmed in case some were reassigned
for (const team of teams) {
for (const disp of ['candidate', 'unconfirmed']) {
console.log(`Fetching ${team} (${disp})...`);
try {
const result = await cardApi.getTeamAssets(team, { disposition: disp, pageSize: 200 });
if (result.ok) {
const data = JSON.parse(result.body);
const assets = Array.isArray(data) ? data : (data.assets || data.results || []);
allAssets.push(...assets);
console.log(` ${assets.length} assets`);
}
} catch (_) { /* skip */ }
}
}
console.log(`\nTotal assets fetched: ${allAssets.length}`);
// Build IP → asset map
const ipMap = new Map();
for (const asset of allAssets) {
const id = asset._id || '';
const ip = extractIPFromAssetId(id);
if (ip && !ipMap.has(ip)) {
ipMap.set(ip, asset);
}
}
console.log(`Unique IPs in CARD: ${ipMap.size}`);
// Match against our target IPs
const matched = [];
const notFound = [];
for (const ip of TARGET_IPS) {
const asset = ipMap.get(ip);
if (asset) {
matched.push(extractGraniteData(asset));
} else {
notFound.push(ip);
}
}
// For IPs not found in team assets, fall back to individual owner lookup
if (notFound.length > 0) {
console.log(`\n${notFound.length} IPs not in team assets — trying individual owner lookups...`);
const SUFFIXES = ['CTEC', 'NATL', 'TWC', 'BHN', 'CHTR'];
const stillNotFound = [];
for (const ip of notFound) {
let found = false;
for (const suffix of SUFFIXES) {
try {
const result = await cardApi.getOwner(`${ip}-${suffix}`);
if (result.ok) {
const data = JSON.parse(result.body);
// Owner endpoint is slim — extract what we can
const ncim = data.ncim_discovery || [];
matched.push({
ip,
assetId: data._id || `${ip}-${suffix}`,
hostname: REASSIGNED[ip] || '',
equipInstId: ncim.length > 0 ? (ncim[0].EQUIP_INST_ID || null) : null,
graniteTeam: ncim.length > 0 ? (ncim[0].GRANITE_RESP_TEAM || null) : null,
entityId: ncim.length > 0 ? (ncim[0].ENTITYID || null) : null,
sysLocation: ncim.length > 0 ? (ncim[0].SYSLOCATION || null) : null,
confirmedTeam: data.owner && data.owner.confirmed ? data.owner.confirmed.name : null,
deviceId: null,
asn: null,
vendorModel: '',
status: null,
});
found = true;
break;
}
} catch (_) { /* continue */ }
}
if (!found) stillNotFound.push(ip);
}
if (stillNotFound.length > 0) {
console.log(`\n${stillNotFound.length} IPs not found anywhere in CARD:`);
stillNotFound.forEach(ip => console.log(` ${ip} (${REASSIGNED[ip] || 'no hostname'})`));
}
}
// Sort by IP
matched.sort((a, b) => {
const aParts = a.ip.split('.').map(Number);
const bParts = b.ip.split('.').map(Number);
for (let i = 0; i < 4; i++) {
if (aParts[i] !== bParts[i]) return aParts[i] - bParts[i];
}
return 0;
});
// Summary
const withEquipId = matched.filter(r => r.equipInstId);
const withoutEquipId = matched.filter(r => !r.equipInstId);
console.log('\n=== Summary ===');
console.log(`Matched in CARD: ${matched.length}`);
console.log(`With EQUIP_INST_ID: ${withEquipId.length}`);
console.log(`Without EQUIP_INST_ID: ${withoutEquipId.length}`);
// Print results
console.log('\n=== Results with EQUIP_INST_ID ===');
console.log('IP Address | EQUIP_INST_ID | Hostname | Granite Team');
console.log('-'.repeat(100));
for (const r of withEquipId) {
console.log(`${r.ip.padEnd(20)} | ${String(r.equipInstId).padEnd(13)} | ${(r.hostname || '').padEnd(30)} | ${r.graniteTeam || '-'}`);
}
if (withoutEquipId.length > 0) {
console.log('\n=== Results WITHOUT EQUIP_INST_ID ===');
for (const r of withoutEquipId) {
console.log(` ${r.ip.padEnd(20)} ${(r.hostname || REASSIGNED[r.ip] || '').padEnd(30)} confirmed: ${r.confirmedTeam || '-'}`);
}
}
// Write full CSV
const csvPath = path.join(__dirname, '..', '..', 'docs', 'card-lookup-results.csv');
const csvHeader = 'IP Address,CARD Asset ID,Hostname,EQUIP_INST_ID,Granite Team,Entity ID,SysLocation,Confirmed Team,Device ID,ASN,Vendor Model,Status';
const csvRows = matched.map(r =>
[r.ip, r.assetId, r.hostname, r.equipInstId, r.graniteTeam, r.entityId, r.sysLocation, r.confirmedTeam, r.deviceId, r.asn, r.vendorModel, r.status]
.map(v => v === null || v === undefined ? '' : `"${String(v).replace(/"/g, '""')}"`)
.join(',')
);
fs.writeFileSync(csvPath, csvHeader + '\n' + csvRows.join('\n') + '\n', 'utf8');
console.log(`\nFull CSV: ${csvPath}`);
// Write Granite Team_Device Loader CSV
const graniteHeaders = [
'DELETE', 'SET_CONFIRMED', 'EQUIPMENT CLASS', 'EQUIP_INST_ID', 'SITE_NAME',
'EQUIP_NAME', 'EQUIP_TEMPLATE', 'EQUIP_STATUS',
'UDA#RESPONSIBLE ORGANIZATION#RESPONSIBLE TEAM',
'UDA#IP_ADDRESSING#IPV4_ADDRESS',
'UDA#IP_ADDRESSING#MAC ADDRESS', 'UDA#IP_ADDRESSING#MGMT_IP_ASN', 'SERIALNUMBER',
];
const graniteRows = withEquipId.map(r => [
'', // DELETE
'', // SET_CONFIRMED
'S', // EQUIPMENT CLASS (Shelf)
r.equipInstId, // EQUIP_INST_ID
'', // SITE_NAME
r.hostname || REASSIGNED[r.ip] || '', // EQUIP_NAME
'', // EQUIP_TEMPLATE
'', // EQUIP_STATUS
'NTS-AEO-STEAM', // RESPONSIBLE TEAM
r.ip, // IPV4_ADDRESS
'', // MAC ADDRESS
r.asn || '', // MGMT_IP_ASN
r.deviceId || '', // SERIALNUMBER
]);
const granitePath = path.join(__dirname, '..', '..', 'docs', 'granite-reassignment-upload.csv');
const graniteContent = [
graniteHeaders.join(','),
...graniteRows.map(r => r.map(v => `"${String(v).replace(/"/g, '""')}"`).join(','))
].join('\n');
fs.writeFileSync(granitePath, graniteContent + '\n', 'utf8');
console.log(`Granite upload CSV (${withEquipId.length} rows): ${granitePath}`);
}
main().catch(err => {
console.error('Unhandled error:', err);
process.exit(1);
});

View File

@@ -0,0 +1,486 @@
#!/usr/bin/env node
// ==========================================================================
// CARD API UAT Test Script
// ==========================================================================
// Exercises every CARD REST API use case the STEAM Dashboard will run in
// production. Run this against the UAT instance to verify the service
// account has been onboarded and all endpoints are accessible.
//
// Usage:
// cd backend
// node scripts/card-uat-test.js # auto-discovers NTS-AEO-STEAM
// node scripts/card-uat-test.js NTS-ACCESS-ENG # target a specific team
//
// Prerequisites:
// - backend/.env has CARD_API_URL pointing to UAT
// (https://card.caas.stage.charterlab.com)
// - CARD_API_USER / CARD_API_PASS set to service account credentials
// - CARD_SKIP_TLS=true if behind Charter's SSL inspection proxy
// - Service account has been onboarded with the CARD team
//
// The script logs every API call, response status, and timing to both
// console and a log file at backend/scripts/card-uat-test.log.
// ==========================================================================
require('dotenv').config({ path: require('path').join(__dirname, '..', '.env') });
const fs = require('fs');
const path = require('path');
const cardApi = require('../helpers/cardApi');
const LOG_FILE = path.join(__dirname, 'card-uat-test.log');
const results = [];
// CLI: optional team name override (e.g. node scripts/card-uat-test.js NTS-ACCESS-ENG)
const CLI_TEAM = process.argv[2] || null;
// State carried between tests
let discoveredTeam = null;
let discoveredAssetId = null;
let discoveredUpdateToken = null;
// ---------------------------------------------------------------------------
// Logging
// ---------------------------------------------------------------------------
function log(level, message, data) {
const timestamp = new Date().toISOString();
const entry = { timestamp, level, message };
if (data !== undefined) entry.data = data;
results.push(entry);
const line = `[${timestamp}] ${level.toUpperCase().padEnd(5)} ${message}`;
console.log(line);
if (data) {
const dataStr = typeof data === 'string' ? data : JSON.stringify(data, null, 2);
const truncated = dataStr.length > 2000
? dataStr.substring(0, 2000) + '\n ... [truncated — ' + dataStr.length + ' chars total]'
: dataStr;
console.log(' ' + truncated.split('\n').join('\n '));
}
}
function logPass(testName, data) { log('pass', `PASS: ${testName}`, data); }
function logFail(testName, data) { log('fail', `FAIL: ${testName}`, data); }
function logInfo(message, data) { log('info', message, data); }
function logWarn(message, data) { log('warn', message, data); }
// ---------------------------------------------------------------------------
// Test runner
// ---------------------------------------------------------------------------
async function runTest(name, fn) {
logInfo(`--- Running: ${name} ---`);
const start = Date.now();
try {
await fn();
logPass(name, { durationMs: Date.now() - start });
return true;
} catch (err) {
logFail(name, { error: err.message, durationMs: Date.now() - start });
return false;
}
}
function assert(condition, message) {
if (!condition) throw new Error('Assertion failed: ' + message);
}
// ---------------------------------------------------------------------------
// Use Case 1: Token Acquisition (GET /api/v1/auth/get_token)
// Production use: Automatic — every CARD API call acquires/reuses a token
// ---------------------------------------------------------------------------
async function testTokenAcquisition() {
const result = await cardApi.testConnection();
assert(result.ok, 'Token acquisition should succeed. Got: ' + JSON.stringify(result));
logInfo('Token acquired (truncated):', result.token);
}
// ---------------------------------------------------------------------------
// Use Case 2: List Teams (GET /api/v1/teams)
// Production use: Populate team dropdowns in Confirm/Decline/Redirect forms
// ---------------------------------------------------------------------------
async function testListTeams() {
const result = await cardApi.getTeams();
assert(result.ok, 'List teams should succeed. Got HTTP ' + result.status + ': ' + (result.body || '').substring(0, 500));
let teams;
try {
teams = JSON.parse(result.body);
} catch (_) {
teams = result.body;
}
const teamList = Array.isArray(teams) ? teams : (teams && teams.teams) || [];
logInfo('Teams returned:', { count: teamList.length, sample: teamList.slice(0, 10) });
// Extract team name — CARD API uses card_team_name or _id
function extractTeamName(t) {
if (typeof t === 'string') return t;
return t.card_team_name || t._id || t.name || t.teamName || '';
}
// If CLI specified a team, use it directly; otherwise auto-discover
if (CLI_TEAM && teamList.length > 0) {
const cliUpper = CLI_TEAM.toUpperCase();
const match = teamList.find(t => extractTeamName(t).toUpperCase() === cliUpper);
if (match) {
discoveredTeam = extractTeamName(match);
logInfo('Using CLI-specified team:', discoveredTeam);
} else {
// Fuzzy: check if any team contains the CLI string
const fuzzy = teamList.find(t => extractTeamName(t).toUpperCase().includes(cliUpper));
if (fuzzy) {
discoveredTeam = extractTeamName(fuzzy);
logInfo('CLI team "' + CLI_TEAM + '" not exact — fuzzy matched:', discoveredTeam);
} else {
logWarn('CLI team "' + CLI_TEAM + '" not found in ' + teamList.length + ' teams. Falling back to auto-discover.');
}
}
}
// Auto-discover if CLI didn't resolve
if (!discoveredTeam && teamList.length > 0) {
const steamTeam = teamList.find(t => {
const name = extractTeamName(t);
return name.includes('NTS-AEO-STEAM') || name.includes('STEAM');
});
discoveredTeam = steamTeam
? extractTeamName(steamTeam)
: extractTeamName(teamList[0]);
logInfo('Using team for subsequent tests:', discoveredTeam);
}
assert(teamList.length > 0, 'Should return at least one team');
}
// ---------------------------------------------------------------------------
// Use Case 3: List Team Assets (GET /api/v1/team/{teamName}/assets)
// Production use: Asset search UI — find Granite IDs for reassigned assets
// NOTE: CARD API requires a disposition filter — unfiltered calls return 500.
// ---------------------------------------------------------------------------
async function testListTeamAssets() {
assert(discoveredTeam, 'Need a team from previous test');
// CARD API requires disposition — use 'confirmed' as the default
const result = await cardApi.getTeamAssets(discoveredTeam, { disposition: 'confirmed', pageSize: 10 });
assert(result.ok, 'List team assets should succeed. Got HTTP ' + result.status + ': ' + (result.body || '').substring(0, 500));
let data;
try {
data = JSON.parse(result.body);
} catch (_) {
data = result.body;
}
const assets = Array.isArray(data) ? data : (data && data.assets) || (data && data.results) || [];
const total = data && data.total !== undefined ? data.total : assets.length;
logInfo('Team assets (confirmed):', { team: discoveredTeam, total, returned: assets.length, sample: assets.slice(0, 3) });
// Grab first asset ID for owner lookup test
if (assets.length > 0) {
const first = assets[0];
discoveredAssetId = first.asset_id || first.assetId || first.id || first.ipn || first._id || null;
if (typeof first === 'string') discoveredAssetId = first;
logInfo('Using asset for subsequent tests:', discoveredAssetId);
}
}
// ---------------------------------------------------------------------------
// Use Case 4: List Team Assets with Disposition Filter
// Production use: Filter assets by confirmed/unconfirmed/declined/candidate
// ---------------------------------------------------------------------------
async function testListTeamAssetsFiltered() {
assert(discoveredTeam, 'Need a team from previous test');
const dispositions = ['confirmed', 'unconfirmed', 'declined', 'candidate'];
for (const disposition of dispositions) {
const result = await cardApi.getTeamAssets(discoveredTeam, { disposition, pageSize: 5 });
let count = '?';
try {
const data = JSON.parse(result.body);
const assets = Array.isArray(data) ? data : (data && data.assets) || (data && data.results) || [];
count = data && data.total !== undefined ? data.total : assets.length;
} catch (_) { /* ignore parse errors */ }
logInfo(` ${disposition}: HTTP ${result.status}, count=${count}`);
// We don't assert success here — some dispositions may return 0 results
// but the endpoint should still respond with 200
assert(
result.status >= 200 && result.status < 500,
`${disposition} filter should not return server error. Got HTTP ${result.status}`
);
}
}
// ---------------------------------------------------------------------------
// Use Case 5: Get Owner Record (GET /api/v1/owner/{assetId})
// Production use: Retrieve update_token before confirm/decline/redirect
// ---------------------------------------------------------------------------
async function testGetOwner() {
assert(discoveredAssetId, 'Need an asset ID from previous test');
const result = await cardApi.getOwner(discoveredAssetId);
assert(result.ok, 'Get owner should succeed. Got HTTP ' + result.status + ': ' + (result.body || '').substring(0, 500));
let ownerData;
try {
ownerData = JSON.parse(result.body);
} catch (_) {
ownerData = result.body;
}
logInfo('Owner record:', ownerData);
// Extract update_token — CARD nests it inside owner object
const updateToken = (ownerData && ownerData.owner && ownerData.owner.update_token)
|| (ownerData && ownerData.update_token)
|| null;
if (updateToken) {
discoveredUpdateToken = updateToken;
logInfo('update_token acquired:', discoveredUpdateToken);
} else {
logWarn('No update_token in owner response — mutation tests will be skipped');
}
}
// ---------------------------------------------------------------------------
// Use Case 6: Token Reuse (verify caching works)
// Production use: Consecutive API calls should reuse the cached token
// ---------------------------------------------------------------------------
async function testTokenReuse() {
// Make two rapid calls — second should reuse the cached token
const start1 = Date.now();
const r1 = await cardApi.getTeams();
const dur1 = Date.now() - start1;
const start2 = Date.now();
const r2 = await cardApi.getTeams();
const dur2 = Date.now() - start2;
assert(r1.ok, 'First call should succeed');
assert(r2.ok, 'Second call should succeed');
logInfo('Token reuse timing:', { firstCallMs: dur1, secondCallMs: dur2 });
// Second call should generally be faster (no token acquisition), but we
// don't assert timing — just log it for review
}
// ---------------------------------------------------------------------------
// Use Case 7: Confirm Asset (POST /api/v2/owner/{assetId}/confirm)
// Production use: User clicks "Confirm" on a CARD queue item
// NOTE: This is a MUTATION — only runs if we have a valid update_token
// and the asset is in a confirmable state. May fail in UAT if the
// asset state doesn't allow confirmation. That's expected.
// ---------------------------------------------------------------------------
async function testConfirmAsset() {
assert(discoveredAssetId, 'Need an asset ID');
assert(discoveredTeam, 'Need a team name');
if (!discoveredUpdateToken) {
logWarn('Skipping confirm test — no update_token available');
return;
}
// Re-fetch update_token to ensure it's current
const ownerRes = await cardApi.getOwner(discoveredAssetId);
assert(ownerRes.ok, 'Owner re-fetch should succeed');
const ownerData = JSON.parse(ownerRes.body);
const token = (ownerData.owner && ownerData.owner.update_token) || ownerData.update_token;
assert(token, 'Should have update_token for confirm');
const result = await cardApi.confirmAsset(
discoveredAssetId,
discoveredTeam,
token,
'STEAM Dashboard UAT test — confirm'
);
logInfo('Confirm result:', { status: result.status, body: (result.body || '').substring(0, 500) });
// Accept 200-299 as success, but also accept 400/409 (asset may already
// be confirmed or in a state that doesn't allow confirmation in UAT)
if (result.ok) {
logInfo('Confirm succeeded');
} else if (result.status === 400 || result.status === 409 || result.status === 422) {
logWarn('Confirm returned ' + result.status + ' — asset may already be in confirmed state (expected in UAT)');
} else {
assert(false, 'Confirm returned unexpected HTTP ' + result.status + ': ' + (result.body || '').substring(0, 500));
}
}
// ---------------------------------------------------------------------------
// Use Case 8: Decline Asset (POST /api/v2/owner/{assetId}/decline)
// Production use: User clicks "Decline" on a CARD queue item
// ---------------------------------------------------------------------------
async function testDeclineAsset() {
assert(discoveredAssetId, 'Need an asset ID');
assert(discoveredTeam, 'Need a team name');
if (!discoveredUpdateToken) {
logWarn('Skipping decline test — no update_token available');
return;
}
// Re-fetch update_token
const ownerRes = await cardApi.getOwner(discoveredAssetId);
assert(ownerRes.ok, 'Owner re-fetch should succeed');
const ownerData = JSON.parse(ownerRes.body);
const token = (ownerData.owner && ownerData.owner.update_token) || ownerData.update_token;
assert(token, 'Should have update_token for decline');
const result = await cardApi.declineAsset(
discoveredAssetId,
discoveredTeam,
token,
'STEAM Dashboard UAT test — decline'
);
logInfo('Decline result:', { status: result.status, body: (result.body || '').substring(0, 500) });
if (result.ok) {
logInfo('Decline succeeded');
} else if (result.status === 400 || result.status === 409 || result.status === 422) {
logWarn('Decline returned ' + result.status + ' — asset may not be in a declinable state (expected in UAT)');
} else {
assert(false, 'Decline returned unexpected HTTP ' + result.status + ': ' + (result.body || '').substring(0, 500));
}
}
// ---------------------------------------------------------------------------
// Use Case 9: Redirect Asset (POST /api/v2/owner/{assetId}/{from}/redirect)
// Production use: User clicks "Redirect" on a CARD queue item
// NOTE: Requires two different teams. We'll attempt it but expect it may
// fail in UAT if only one team is available.
// ---------------------------------------------------------------------------
async function testRedirectAsset() {
assert(discoveredAssetId, 'Need an asset ID');
assert(discoveredTeam, 'Need a team name');
if (!discoveredUpdateToken) {
logWarn('Skipping redirect test — no update_token available');
return;
}
// We need a second team for redirect. Try to find one from the teams list.
const teamsRes = await cardApi.getTeams();
let teams = [];
try {
const parsed = JSON.parse(teamsRes.body);
teams = Array.isArray(parsed) ? parsed : (parsed.teams || []);
} catch (_) { /* ignore */ }
const teamNames = teams.map(t => typeof t === 'string' ? t : (t.card_team_name || t._id || t.name || t.teamName || ''));
const otherTeam = teamNames.find(t => t && t !== discoveredTeam);
if (!otherTeam) {
logWarn('Only one team available — cannot test redirect (requires from and to teams)');
return;
}
logInfo('Redirect test:', { from: discoveredTeam, to: otherTeam });
// Re-fetch update_token
const ownerRes = await cardApi.getOwner(discoveredAssetId);
assert(ownerRes.ok, 'Owner re-fetch should succeed');
const ownerData = JSON.parse(ownerRes.body);
const token = (ownerData.owner && ownerData.owner.update_token) || ownerData.update_token;
assert(token, 'Should have update_token for redirect');
const result = await cardApi.redirectAsset(
discoveredAssetId,
discoveredTeam,
otherTeam,
token
);
logInfo('Redirect result:', { status: result.status, body: (result.body || '').substring(0, 500) });
if (result.ok) {
logInfo('Redirect succeeded');
} else if (result.status === 400 || result.status === 409 || result.status === 422) {
logWarn('Redirect returned ' + result.status + ' — asset may not be in a redirectable state (expected in UAT)');
} else {
assert(false, 'Redirect returned unexpected HTTP ' + result.status + ': ' + (result.body || '').substring(0, 500));
}
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
async function main() {
logInfo('=== STEAM Dashboard — CARD API UAT Test Run ===');
logInfo('Timestamp: ' + new Date().toISOString());
logInfo('CARD_API_URL: ' + (process.env.CARD_API_URL || '(not set)'));
logInfo('CARD_API_USER: ' + (process.env.CARD_API_USER || '(not set)'));
logInfo('CARD_SKIP_TLS: ' + (process.env.CARD_SKIP_TLS || 'false'));
logInfo('isConfigured: ' + cardApi.isConfigured);
logInfo('');
if (!cardApi.isConfigured) {
logFail('Pre-flight check', {
error: 'CARD API is not configured. Set CARD_API_URL, CARD_API_USER, and CARD_API_PASS in backend/.env',
missing: cardApi.missingVars,
});
writeLog();
process.exit(1);
}
let passed = 0;
let failed = 0;
// Read-only tests first (safe to run in any environment)
if (await runTest('1. Token Acquisition (GET /auth/get_token)', testTokenAcquisition)) passed++; else failed++;
if (await runTest('2. List Teams (GET /teams)', testListTeams)) passed++; else failed++;
if (await runTest('3. List Team Assets (GET /team/{name}/assets)', testListTeamAssets)) passed++; else failed++;
if (await runTest('4. List Team Assets — Disposition Filters', testListTeamAssetsFiltered)) passed++; else failed++;
if (await runTest('5. Get Owner Record (GET /owner/{assetId})', testGetOwner)) passed++; else failed++;
if (await runTest('6. Token Reuse (caching verification)', testTokenReuse)) passed++; else failed++;
// Mutation tests — these modify asset state in CARD
logInfo('');
logInfo('=== Mutation Tests (modify asset state) ===');
logInfo('These tests exercise confirm/decline/redirect. They may return');
logInfo('4xx if the asset is not in the correct state — that is expected.');
logInfo('');
if (await runTest('7. Confirm Asset (POST /owner/{id}/confirm)', testConfirmAsset)) passed++; else failed++;
if (await runTest('8. Decline Asset (POST /owner/{id}/decline)', testDeclineAsset)) passed++; else failed++;
if (await runTest('9. Redirect Asset (POST /owner/{id}/{from}/redirect)', testRedirectAsset)) passed++; else failed++;
logInfo('');
logInfo('=== Summary ===');
logInfo(`Passed: ${passed} | Failed: ${failed} | Total: ${passed + failed}`);
if (discoveredTeam) logInfo('Team used: ' + discoveredTeam);
if (discoveredAssetId) logInfo('Asset used: ' + discoveredAssetId);
writeLog();
if (failed > 0) {
console.log('\nSome tests failed. Review the log above and card-uat-test.log for details.');
process.exit(1);
} else {
console.log('\nAll tests passed. Log saved to backend/scripts/card-uat-test.log');
process.exit(0);
}
}
function writeLog() {
const lines = results.map(r => {
let line = `[${r.timestamp}] ${r.level.toUpperCase().padEnd(5)} ${r.message}`;
if (r.data) {
const dataStr = typeof r.data === 'string' ? r.data : JSON.stringify(r.data, null, 2);
const truncated = dataStr.length > 2000
? dataStr.substring(0, 2000) + '\n ... [truncated — ' + dataStr.length + ' chars total]'
: dataStr;
line += '\n ' + truncated.split('\n').join('\n ');
}
return line;
});
fs.writeFileSync(LOG_FILE, lines.join('\n') + '\n', 'utf8');
}
main().catch(err => {
console.error('Unhandled error:', err);
process.exit(1);
});

View File

@@ -25,9 +25,10 @@ const createIvantiFindingsRouter = require('./routes/ivantiFindings');
const createIvantiTodoQueueRouter = require('./routes/ivantiTodoQueue');
const createIvantiArchiveRouter = require('./routes/ivantiArchive');
const createIvantiFpWorkflowRouter = require('./routes/ivantiFpWorkflow');
const createComplianceRouter = require('./routes/compliance');
const { createComplianceRouter } = require('./routes/compliance');
const createAtlasRouter = require('./routes/atlas');
const createJiraTicketsRouter = require('./routes/jiraTickets');
const createCardApiRouter = require('./routes/cardApi');
const app = express();
const PORT = process.env.PORT || 3001;
@@ -242,6 +243,9 @@ app.use('/api/atlas', createAtlasRouter(db, requireAuth));
// Jira ticket routes — local CRUD + Jira REST API integration (lookup, sync, create)
app.use('/api/jira-tickets', createJiraTicketsRouter(db));
// CARD Asset Ownership API routes — proxy CARD operations, mutation flow, asset search
app.use('/api/card', createCardApiRouter(db, requireAuth));
// ========== CVE ENDPOINTS ==========
// Get all CVEs with optional filters (authenticated users)

View File

@@ -179,7 +179,17 @@ export default function App() {
const [cveDocuments, setCveDocuments] = useState({});
const [quickCheckCVE, setQuickCheckCVE] = useState('');
const [quickCheckResult, setQuickCheckResult] = useState(null);
const [currentPage, setCurrentPage] = useState('home');
const VALID_PAGES = new Set(['home', 'triage', 'compliance', 'knowledge-base', 'exports', 'jira', 'admin']);
const [currentPage, setCurrentPageRaw] = useState(() => {
try {
const saved = localStorage.getItem('cve-dashboard-page');
return saved && VALID_PAGES.has(saved) ? saved : 'home';
} catch { return 'home'; }
});
const setCurrentPage = (page) => {
setCurrentPageRaw(page);
try { localStorage.setItem('cve-dashboard-page', page); } catch {}
};
const [navOpen, setNavOpen] = useState(false);
const [calendarFilter, setCalendarFilter] = useState(null);
const [reportingExcFilter, setReportingExcFilter] = useState(null);

View File

@@ -1,6 +1,13 @@
// ⚠️ CONVENTION: This component uses Tailwind utility classes (e.g. bg-white, rounded-lg, hover:bg-gray-50)
// instead of inline styles or App.css global classes. This is the legacy modal kept for UserMenu quick-access;
// the themed replacement lives in AdminPage.js.
// ⚠️ CONVENTION: This component uses inline styles matching the dark "tactical intelligence"
// design system (DESIGN_SYSTEM.md). Colors use the --intel-* and --text-* palette.
//
// ⚠️ CONVENTION: This file is INCOMPLETE — the exported functional component (UserManagement)
// was removed during the style refactor. Only style constants remain. The file must include:
// - A default-exported functional component using hooks (useState, useEffect)
// - Data fetching via fetch() with credentials: 'include' and relative API paths
// - Loading and error state handling in the rendered output
// - JSX that uses the imported lucide-react icons (X, Plus, Edit2, Trash2, etc.)
// - The ConfirmModal integration for delete/group-change confirmations
import React, { useState, useEffect } from 'react';
import { X, Plus, Edit2, Trash2, Loader, AlertCircle, CheckCircle, User, Mail, Shield } from 'lucide-react';
import { useAuth } from '../contexts/AuthContext';
@@ -18,12 +25,150 @@ const GROUP_LABELS = {
};
const GROUP_BADGE_STYLES = {
Admin: { backgroundColor: '#FEE2E2', color: '#991B1B' },
Standard_User: { backgroundColor: '#DBEAFE', color: '#1E40AF' },
Leadership: { backgroundColor: '#F3E8FF', color: '#6B21A8' },
Read_Only: { backgroundColor: '#F3F4F6', color: '#374151' }
Admin: { backgroundColor: 'rgba(239, 68, 68, 0.25)', color: '#FCA5A5', border: '1px solid rgba(239, 68, 68, 0.4)' },
Standard_User: { backgroundColor: 'rgba(14, 165, 233, 0.25)', color: '#7DD3FC', border: '1px solid rgba(14, 165, 233, 0.4)' },
Leadership: { backgroundColor: 'rgba(168, 85, 247, 0.25)', color: '#C4B5FD', border: '1px solid rgba(168, 85, 247, 0.4)' },
Read_Only: { backgroundColor: 'rgba(148, 163, 184, 0.2)', color: '#CBD5E1', border: '1px solid rgba(148, 163, 184, 0.3)' }
};
/* ── Shared style constants ── */
const styles = {
overlay: {
position: 'fixed', inset: 0, backgroundColor: 'rgba(0,0,0,0.6)',
display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 50, padding: '1rem',
},
modal: {
background: 'linear-gradient(135deg, #1E293B 0%, #0F172A 100%)',
borderRadius: '0.75rem', border: '1.5px solid rgba(14,165,233,0.3)',
boxShadow: '0 8px 24px rgba(0,0,0,0.6), 0 0 28px rgba(14,165,233,0.08)',
maxWidth: '56rem', width: '100%', maxHeight: '90vh', overflow: 'hidden',
display: 'flex', flexDirection: 'column', color: '#F8FAFC',
},
header: {
padding: '1.5rem', borderBottom: '1px solid rgba(14,165,233,0.2)',
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
},
title: {
fontSize: '1.5rem', fontWeight: 700, color: '#F8FAFC', margin: 0,
fontFamily: "'JetBrains Mono', monospace",
},
subtitle: { color: '#94A3B8', fontSize: '0.875rem', margin: '0.25rem 0 0' },
closeBtn: {
background: 'none', border: 'none', color: '#94A3B8', cursor: 'pointer', padding: '0.5rem',
borderRadius: '0.375rem', transition: 'color 0.2s',
},
body: { padding: '1.5rem', overflowY: 'auto', flex: 1 },
addBtn: {
marginBottom: '1.5rem', padding: '0.5rem 1rem',
background: 'linear-gradient(135deg, rgba(14,165,233,0.15), rgba(14,165,233,0.1))',
border: '1px solid #0EA5E9', borderRadius: '0.5rem', color: '#38BDF8',
cursor: 'pointer', display: 'inline-flex', alignItems: 'center', gap: '0.5rem',
fontSize: '0.875rem', fontWeight: 500, transition: 'all 0.2s',
textShadow: '0 0 6px rgba(14,165,233,0.2)',
},
formCard: {
marginBottom: '1.5rem', padding: '1.5rem',
background: 'linear-gradient(135deg, rgba(15,23,42,0.95), rgba(30,41,59,0.9))',
borderRadius: '0.5rem', border: '1px solid rgba(14,165,233,0.25)',
},
formTitle: {
fontSize: '1.125rem', fontWeight: 600, color: '#0EA5E9', margin: '0 0 1rem',
fontFamily: "'JetBrains Mono', monospace",
},
label: {
display: 'block', fontSize: '0.75rem', fontWeight: 500, color: '#CBD5E1',
marginBottom: '0.375rem', textTransform: 'uppercase', letterSpacing: '0.5px',
},
inputWrap: { position: 'relative' },
inputIcon: {
position: 'absolute', left: '0.75rem', top: '50%', transform: 'translateY(-50%)',
color: '#94A3B8', width: '1.125rem', height: '1.125rem', pointerEvents: 'none',
},
input: {
width: '100%', padding: '0.5rem 0.75rem 0.5rem 2.5rem',
background: 'rgba(30,41,59,0.6)', border: '1px solid rgba(14,165,233,0.25)',
borderRadius: '0.5rem', color: '#F8FAFC', fontSize: '0.875rem',
fontFamily: "'JetBrains Mono', monospace", outline: 'none', transition: 'border-color 0.2s',
boxSizing: 'border-box',
},
inputNoIcon: {
width: '100%', padding: '0.5rem 0.75rem',
background: 'rgba(30,41,59,0.6)', border: '1px solid rgba(14,165,233,0.25)',
borderRadius: '0.5rem', color: '#F8FAFC', fontSize: '0.875rem',
fontFamily: "'JetBrains Mono', monospace", outline: 'none', transition: 'border-color 0.2s',
boxSizing: 'border-box',
},
select: {
width: '100%', padding: '0.5rem 0.75rem 0.5rem 2.5rem',
background: 'rgba(30,41,59,0.6)', border: '1px solid rgba(14,165,233,0.25)',
borderRadius: '0.5rem', color: '#F8FAFC', fontSize: '0.875rem',
fontFamily: "'JetBrains Mono', monospace", outline: 'none', cursor: 'pointer',
appearance: 'none', boxSizing: 'border-box',
},
primaryBtn: {
padding: '0.5rem 1rem',
background: 'linear-gradient(135deg, rgba(14,165,233,0.15), rgba(14,165,233,0.1))',
border: '1px solid #0EA5E9', borderRadius: '0.5rem', color: '#38BDF8',
cursor: 'pointer', fontSize: '0.875rem', fontWeight: 500, transition: 'all 0.2s',
textShadow: '0 0 6px rgba(14,165,233,0.2)',
},
cancelBtn: {
padding: '0.5rem 1rem',
background: 'rgba(51,65,85,0.5)', border: '1px solid rgba(148,163,184,0.3)',
borderRadius: '0.5rem', color: '#CBD5E1', cursor: 'pointer',
fontSize: '0.875rem', fontWeight: 500, transition: 'all 0.2s',
},
alertError: {
marginBottom: '1rem', padding: '0.75rem',
background: 'rgba(239,68,68,0.15)', border: '1px solid rgba(239,68,68,0.3)',
borderRadius: '0.5rem', display: 'flex', alignItems: 'center', gap: '0.5rem',
},
alertSuccess: {
marginBottom: '1rem', padding: '0.75rem',
background: 'rgba(16,185,129,0.15)', border: '1px solid rgba(16,185,129,0.3)',
borderRadius: '0.5rem', display: 'flex', alignItems: 'center', gap: '0.5rem',
},
th: {
textAlign: 'left', padding: '0.75rem 1rem', fontSize: '0.75rem', fontWeight: 600,
color: '#94A3B8', textTransform: 'uppercase', letterSpacing: '0.5px',
borderBottom: '1px solid rgba(14,165,233,0.2)',
},
thRight: {
textAlign: 'right', padding: '0.75rem 1rem', fontSize: '0.75rem', fontWeight: 600,
color: '#94A3B8', textTransform: 'uppercase', letterSpacing: '0.5px',
borderBottom: '1px solid rgba(14,165,233,0.2)',
},
td: { padding: '0.75rem 1rem', borderBottom: '1px solid rgba(51,65,85,0.5)' },
tdRight: { padding: '0.75rem 1rem', borderBottom: '1px solid rgba(51,65,85,0.5)', textAlign: 'right' },
username: { fontWeight: 500, color: '#F8FAFC', fontSize: '0.875rem' },
email: { fontSize: '0.8rem', color: '#94A3B8' },
lastLogin: { fontSize: '0.8rem', color: '#94A3B8' },
badge: {
padding: '0.25rem 0.625rem', borderRadius: '0.375rem',
fontSize: '0.7rem', fontWeight: 600, display: 'inline-block',
fontFamily: "'JetBrains Mono', monospace", letterSpacing: '0.3px',
},
statusActive: {
padding: '0.2rem 0.5rem', borderRadius: '0.25rem', fontSize: '0.7rem', fontWeight: 600,
background: 'rgba(16,185,129,0.2)', color: '#6EE7B7', border: '1px solid rgba(16,185,129,0.3)',
cursor: 'pointer', transition: 'opacity 0.2s',
},
statusInactive: {
padding: '0.2rem 0.5rem', borderRadius: '0.25rem', fontSize: '0.7rem', fontWeight: 600,
background: 'rgba(239,68,68,0.2)', color: '#FCA5A5', border: '1px solid rgba(239,68,68,0.3)',
cursor: 'pointer', transition: 'opacity 0.2s',
},
actionBtn: {
background: 'none', border: 'none', padding: '0.375rem', borderRadius: '0.375rem',
cursor: 'pointer', color: '#94A3B8', transition: 'all 0.2s',
},
deleteBtn: {
background: 'none', border: 'none', padding: '0.375rem', borderRadius: '0.375rem',
cursor: 'pointer', color: '#EF4444', transition: 'all 0.2s',
},
};
export default function UserManagement({ onClose }) {
const { user: currentUser } = useAuth();
const [users, setUsers] = useState([]);
@@ -106,7 +251,6 @@ export default function UserManagement({ onClose }) {
const handleSubmit = (e) => {
e.preventDefault();
// If editing and group changed, show confirmation modal
if (editingUser && formData.group !== editingUser.group) {
let message = `Are you sure you want to change ${editingUser.username}'s group from ${editingUser.group} to ${formData.group}?`;
if (editingUser.group === 'Admin' && formData.group !== 'Admin') {
@@ -189,29 +333,29 @@ export default function UserManagement({ onClose }) {
}
};
// Check if group dropdown should be disabled for self-demotion prevention
const isGroupDropdownDisabled = (targetUser) => {
if (!targetUser || !currentUser) return false;
return targetUser.id === currentUser.id && currentUser.group === 'Admin';
};
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-lg shadow-xl max-w-4xl w-full max-h-[90vh] overflow-hidden flex flex-col">
<div className="p-6 border-b border-gray-200 flex justify-between items-center">
<div style={styles.overlay}>
<div style={styles.modal}>
{/* Header */}
<div style={styles.header}>
<div>
<h2 className="text-2xl font-bold text-gray-900">User Management</h2>
<p className="text-gray-600">Manage user accounts and permissions</p>
<h2 style={styles.title}>User Management</h2>
<p style={styles.subtitle}>Manage user accounts and permissions</p>
</div>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 p-2"
>
<X className="w-6 h-6" />
<button onClick={onClose} style={styles.closeBtn}
onMouseEnter={e => e.currentTarget.style.color = '#F8FAFC'}
onMouseLeave={e => e.currentTarget.style.color = '#94A3B8'}>
<X style={{ width: '1.5rem', height: '1.5rem' }} />
</button>
</div>
<div className="p-6 overflow-y-auto flex-1">
{/* Body */}
<div style={styles.body}>
{!showAddUser && (
<button
onClick={() => {
@@ -221,69 +365,80 @@ export default function UserManagement({ onClose }) {
setFormError('');
setFormSuccess('');
}}
className="mb-6 px-4 py-2 bg-[#0476D9] text-white rounded-lg hover:bg-[#0360B8] transition-colors flex items-center gap-2 shadow-md"
style={styles.addBtn}
onMouseEnter={e => {
e.currentTarget.style.background = 'linear-gradient(135deg, rgba(14,165,233,0.25), rgba(14,165,233,0.2))';
e.currentTarget.style.boxShadow = '0 0 20px rgba(14,165,233,0.25)';
}}
onMouseLeave={e => {
e.currentTarget.style.background = 'linear-gradient(135deg, rgba(14,165,233,0.15), rgba(14,165,233,0.1))';
e.currentTarget.style.boxShadow = 'none';
}}
>
<Plus className="w-5 h-5" />
<Plus style={{ width: '1.125rem', height: '1.125rem' }} />
Add User
</button>
)}
{/* Add / Edit Form */}
{showAddUser && (
<div className="mb-6 p-6 bg-gray-50 rounded-lg border border-gray-200">
<h3 className="text-lg font-semibold mb-4">
<div style={styles.formCard}>
<h3 style={styles.formTitle}>
{editingUser ? 'Edit User' : 'Add New User'}
</h3>
{formError && (
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg flex items-center gap-2">
<AlertCircle className="w-5 h-5 text-red-600" />
<span className="text-sm text-red-700">{formError}</span>
<div style={styles.alertError}>
<AlertCircle style={{ width: '1.125rem', height: '1.125rem', color: '#FCA5A5', flexShrink: 0 }} />
<span style={{ fontSize: '0.8rem', color: '#FCA5A5' }}>{formError}</span>
</div>
)}
{formSuccess && (
<div className="mb-4 p-3 bg-green-50 border border-green-200 rounded-lg flex items-center gap-2">
<CheckCircle className="w-5 h-5 text-green-600" />
<span className="text-sm text-green-700">{formSuccess}</span>
<div style={styles.alertSuccess}>
<CheckCircle style={{ width: '1.125rem', height: '1.125rem', color: '#6EE7B7', flexShrink: 0 }} />
<span style={{ fontSize: '0.8rem', color: '#6EE7B7' }}>{formSuccess}</span>
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<form onSubmit={handleSubmit}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem' }}>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Username *
</label>
<div className="relative">
<User className="w-5 h-5 text-gray-400 absolute left-3 top-1/2 transform -translate-y-1/2" />
<label style={styles.label}>Username *</label>
<div style={styles.inputWrap}>
<User style={styles.inputIcon} />
<input
type="text"
required
value={formData.username}
onChange={(e) => setFormData({ ...formData, username: e.target.value })}
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#0476D9] focus:border-transparent"
style={styles.input}
placeholder="Enter username"
onFocus={e => e.target.style.borderColor = '#0EA5E9'}
onBlur={e => e.target.style.borderColor = 'rgba(14,165,233,0.25)'}
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Email *
</label>
<div className="relative">
<Mail className="w-5 h-5 text-gray-400 absolute left-3 top-1/2 transform -translate-y-1/2" />
<label style={styles.label}>Email *</label>
<div style={styles.inputWrap}>
<Mail style={styles.inputIcon} />
<input
type="email"
required
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#0476D9] focus:border-transparent"
style={styles.input}
placeholder="user@example.com"
onFocus={e => e.target.style.borderColor = '#0EA5E9'}
onBlur={e => e.target.style.borderColor = 'rgba(14,165,233,0.25)'}
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
<label style={styles.label}>
Password {editingUser ? '(leave blank to keep current)' : '*'}
</label>
<input
@@ -291,49 +446,58 @@ export default function UserManagement({ onClose }) {
required={!editingUser}
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#0476D9] focus:border-transparent"
style={styles.inputNoIcon}
onFocus={e => e.target.style.borderColor = '#0EA5E9'}
onBlur={e => e.target.style.borderColor = 'rgba(14,165,233,0.25)'}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Group *
</label>
<div className="relative">
<Shield className="w-5 h-5 text-gray-400 absolute left-3 top-1/2 transform -translate-y-1/2" />
<label style={styles.label}>Group *</label>
<div style={styles.inputWrap}>
<Shield style={styles.inputIcon} />
<select
value={formData.group}
onChange={(e) => setFormData({ ...formData, group: e.target.value })}
disabled={isGroupDropdownDisabled(editingUser)}
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#0476D9] focus:border-transparent disabled:opacity-50 disabled:cursor-not-allowed"
style={{
...styles.select,
opacity: isGroupDropdownDisabled(editingUser) ? 0.5 : 1,
cursor: isGroupDropdownDisabled(editingUser) ? 'not-allowed' : 'pointer',
}}
title={isGroupDropdownDisabled(editingUser) ? 'Cannot change your own Admin group' : ''}
>
{VALID_GROUPS.map((g) => (
<option key={g} value={g}>{GROUP_LABELS[g]}</option>
<option key={g} value={g} style={{ background: '#1E293B', color: '#F8FAFC' }}>
{GROUP_LABELS[g]}
</option>
))}
</select>
{isGroupDropdownDisabled(editingUser) && (
<p className="text-xs text-amber-600 mt-1">You cannot change your own Admin group.</p>
<p style={{ fontSize: '0.7rem', color: '#F59E0B', marginTop: '0.375rem' }}>
You cannot change your own Admin group.
</p>
)}
</div>
</div>
</div>
<div className="flex gap-3 pt-2">
<button
type="submit"
className="px-4 py-2 bg-[#0476D9] text-white rounded-lg hover:bg-[#0360B8] transition-colors"
>
<div style={{ display: 'flex', gap: '0.75rem', paddingTop: '1rem' }}>
<button type="submit" style={styles.primaryBtn}
onMouseEnter={e => {
e.currentTarget.style.background = 'linear-gradient(135deg, rgba(14,165,233,0.25), rgba(14,165,233,0.2))';
e.currentTarget.style.boxShadow = '0 0 20px rgba(14,165,233,0.25)';
}}
onMouseLeave={e => {
e.currentTarget.style.background = 'linear-gradient(135deg, rgba(14,165,233,0.15), rgba(14,165,233,0.1))';
e.currentTarget.style.boxShadow = 'none';
}}>
{editingUser ? 'Update User' : 'Create User'}
</button>
<button
type="button"
onClick={() => {
setShowAddUser(false);
setEditingUser(null);
}}
className="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 transition-colors"
>
<button type="button" style={styles.cancelBtn}
onClick={() => { setShowAddUser(false); setEditingUser(null); }}
onMouseEnter={e => e.currentTarget.style.background = 'rgba(51,65,85,0.8)'}
onMouseLeave={e => e.currentTarget.style.background = 'rgba(51,65,85,0.5)'}>
Cancel
</button>
</div>
@@ -341,87 +505,92 @@ export default function UserManagement({ onClose }) {
</div>
)}
{/* User Table */}
{loading ? (
<div className="text-center py-12">
<Loader className="w-8 h-8 text-[#0476D9] mx-auto animate-spin" />
<p className="text-gray-600 mt-2">Loading users...</p>
<div style={{ textAlign: 'center', padding: '3rem 0' }}>
<Loader style={{ width: '2rem', height: '2rem', color: '#0EA5E9', margin: '0 auto', animation: 'spin 1s linear infinite' }} />
<p style={{ color: '#94A3B8', marginTop: '0.5rem', fontSize: '0.875rem' }}>Loading users...</p>
</div>
) : error ? (
<div className="text-center py-12">
<AlertCircle className="w-8 h-8 text-red-500 mx-auto" />
<p className="text-red-600 mt-2">{error}</p>
<div style={{ textAlign: 'center', padding: '3rem 0' }}>
<AlertCircle style={{ width: '2rem', height: '2rem', color: '#EF4444', margin: '0 auto' }} />
<p style={{ color: '#FCA5A5', marginTop: '0.5rem', fontSize: '0.875rem' }}>{error}</p>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full">
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr className="border-b border-gray-200">
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700">User</th>
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700">Group</th>
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700">Status</th>
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700">Last Login</th>
<th className="text-right py-3 px-4 text-sm font-medium text-gray-700">Actions</th>
<tr>
<th style={styles.th}>User</th>
<th style={styles.th}>Group</th>
<th style={styles.th}>Status</th>
<th style={styles.th}>Last Login</th>
<th style={styles.thRight}>Actions</th>
</tr>
</thead>
<tbody>
{users.map((user) => (
<tr key={user.id} className="border-b border-gray-100 hover:bg-gray-50">
<td className="py-3 px-4">
<tr key={user.id}
onMouseEnter={e => e.currentTarget.style.background = 'rgba(14,165,233,0.05)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
<td style={styles.td}>
<div>
<p className="font-medium text-gray-900">{user.username}</p>
<p className="text-sm text-gray-500">{user.email}</p>
<p style={styles.username}>{user.username}</p>
<p style={styles.email}>{user.email}</p>
</div>
</td>
<td className="py-3 px-4">
<span
style={{
...GROUP_BADGE_STYLES[user.group] || GROUP_BADGE_STYLES.Read_Only,
padding: '2px 8px',
borderRadius: '4px',
fontSize: '12px',
fontWeight: '500',
display: 'inline-block'
}}
>
<td style={styles.td}>
<span style={{
...styles.badge,
...(GROUP_BADGE_STYLES[user.group] || GROUP_BADGE_STYLES.Read_Only),
}}>
{user.group ? user.group.replace('_', ' ') : 'Read Only'}
</span>
</td>
<td className="py-3 px-4">
<td style={styles.td}>
<button
onClick={() => handleToggleActive(user)}
disabled={user.id === currentUser.id}
className={`px-2 py-1 rounded text-xs font-medium ${
user.is_active
? 'bg-green-100 text-green-800'
: 'bg-red-100 text-red-800'
} ${user.id === currentUser.id ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer hover:opacity-80'}`}
style={{
...(user.is_active ? styles.statusActive : styles.statusInactive),
opacity: user.id === currentUser.id ? 0.5 : 1,
cursor: user.id === currentUser.id ? 'not-allowed' : 'pointer',
}}
>
{user.is_active ? 'Active' : 'Inactive'}
</button>
</td>
<td className="py-3 px-4 text-sm text-gray-500">
{user.last_login
? new Date(user.last_login).toLocaleString()
: 'Never'}
<td style={styles.td}>
<span style={styles.lastLogin}>
{user.last_login
? new Date(user.last_login).toLocaleString()
: 'Never'}
</span>
</td>
<td className="py-3 px-4">
<div className="flex justify-end gap-2">
<td style={styles.tdRight}>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '0.25rem' }}>
<button
onClick={() => handleEdit(user)}
className="p-2 text-gray-600 hover:bg-gray-100 rounded"
style={styles.actionBtn}
title="Edit user"
onMouseEnter={e => { e.currentTarget.style.color = '#0EA5E9'; e.currentTarget.style.background = 'rgba(14,165,233,0.1)'; }}
onMouseLeave={e => { e.currentTarget.style.color = '#94A3B8'; e.currentTarget.style.background = 'none'; }}
>
<Edit2 className="w-4 h-4" />
<Edit2 style={{ width: '1rem', height: '1rem' }} />
</button>
<button
onClick={() => handleDelete(user.id)}
disabled={user.id === currentUser.id}
className={`p-2 text-red-600 hover:bg-red-50 rounded ${
user.id === currentUser.id ? 'opacity-50 cursor-not-allowed' : ''
}`}
style={{
...styles.deleteBtn,
opacity: user.id === currentUser.id ? 0.3 : 1,
cursor: user.id === currentUser.id ? 'not-allowed' : 'pointer',
}}
title="Delete user"
onMouseEnter={e => { if (user.id !== currentUser.id) { e.currentTarget.style.background = 'rgba(239,68,68,0.1)'; } }}
onMouseLeave={e => { e.currentTarget.style.background = 'none'; }}
>
<Trash2 className="w-4 h-4" />
<Trash2 style={{ width: '1rem', height: '1rem' }} />
</button>
</div>
</td>
@@ -446,4 +615,4 @@ export default function UserManagement({ onClose }) {
/>
</div>
);
}
}

View File

@@ -102,6 +102,13 @@ const CLASSIFICATION_LABELS = {
decommissioned: 'decommissioned',
};
const RETURN_CLASSIFICATION_LABELS = {
bu_reassignment: 'BU reassigned back',
severity_drift: 'severity re-escalated',
closed_on_platform: 'reopened on platform',
decommissioned: 're-provisioned',
};
// ---------------------------------------------------------------------------
// Build the summary text from anomaly data
// ---------------------------------------------------------------------------
@@ -220,6 +227,20 @@ export default function AnomalyBanner() {
<span style={DETAIL_COUNT}>{anomaly.returned_count}</span>
</div>
)}
{anomaly.returned_count > 0 && anomaly.return_classification && (
<>
{Object.entries(RETURN_CLASSIFICATION_LABELS).map(([key, label]) => {
const val = (anomaly.return_classification || {})[key] || 0;
if (val === 0) return null;
return (
<div key={`ret-${key}`} style={{ ...DETAIL_ROW, paddingLeft: '0.75rem', fontSize: '0.6rem', color: '#94A3B8' }}>
<span> {label}</span>
<span style={{ fontWeight: '600', color: '#14B8A6' }}>{val}</span>
</div>
);
})}
</>
)}
</div>
)}
</div>

View File

@@ -1,7 +1,7 @@
// ComplianceChartsPanel.js
// Tier-1 time-based compliance charts using Recharts.
// Charts rendered: Active Findings Over Time, Change per Cycle,
// Team Health, MTTR by Team, Persistent Findings, Archer Pipeline.
// Team Health, Aging Findings Distribution, Resolution Rate, Archer Pipeline.
import React, { useState, useEffect, useMemo } from 'react';
import {
@@ -208,47 +208,68 @@ function TeamTrendChart({ data }) {
}
// ---------------------------------------------------------------------------
// Chart 4 — MTTR by Team (horizontal bar)
// Chart 4 — Aging Findings Distribution (vertical stacked bar by age bucket)
// ---------------------------------------------------------------------------
function MttrChart({ data }) {
if (data.length === 0) return <NoData msg="No resolved findings yet — MTTR will appear after items are remediated" />;
function AgingChart({ data }) {
if (data.length === 0) return <NoData />;
const teamKeys = Object.keys(TEAM_COLORS);
return (
<ResponsiveContainer width="100%" height={Math.max(160, data.length * 44 + 40)}>
<BarChart data={data} layout="vertical" margin={{ top: 4, right: 48, bottom: 0, left: 4 }}>
<ResponsiveContainer width="100%" height={210}>
<BarChart data={data} margin={{ top: 4, right: 12, bottom: 0, left: -12 }}>
<CartesianGrid {...GRID_STYLE} />
<XAxis type="number" tick={AXIS_STYLE} unit=" d" />
<YAxis type="category" dataKey="team" tick={AXIS_STYLE} width={86} />
<XAxis dataKey="bucket" tick={AXIS_STYLE} />
<YAxis tick={AXIS_STYLE} allowDecimals={false} />
<Tooltip content={<DarkTooltip />} />
<Bar dataKey="avg_days" name="Avg Days" fill={TEAL} fillOpacity={0.8} radius={[0, 3, 3, 0]}
label={{ position: 'right', style: { ...AXIS_STYLE, fill: '#64748B' }, formatter: v => `${v}d` }}
/>
<Legend wrapperStyle={LEGEND_STYLE} iconSize={8} />
{teamKeys.map((team, i) => (
<Bar
key={team}
dataKey={team}
name={team}
stackId="aging"
fill={TEAM_COLORS[team]}
fillOpacity={0.85}
radius={i === teamKeys.length - 1 ? [2, 2, 0, 0] : [0, 0, 0, 0]}
/>
))}
</BarChart>
</ResponsiveContainer>
);
}
// ---------------------------------------------------------------------------
// Chart 5 — Most Persistent Findings (horizontal bar by seen_count)
// Chart 5 — Resolution Rate per Cycle (line chart, % of findings resolved)
// ---------------------------------------------------------------------------
function RecurringChart({ data }) {
if (data.length === 0) return <NoData />;
const top10 = data.slice(0, 10).map(r => ({
...r,
label: r.metric_id.length > 18 ? r.metric_id.slice(0, 18) + '…' : r.metric_id,
}));
function ResolutionRateChart({ data }) {
const rateData = useMemo(() => {
return data
.filter(t => t.total_active > 0 || t.resolved_count > 0)
.map(t => {
const pool = t.total_active + t.resolved_count;
return {
date: t.date,
rate: pool > 0 ? Math.round((t.resolved_count / pool) * 1000) / 10 : 0,
resolved: t.resolved_count,
active: t.total_active,
};
});
}, [data]);
if (rateData.length < 2) return <NoData />;
return (
<ResponsiveContainer width="100%" height={Math.max(160, top10.length * 28 + 40)}>
<BarChart data={top10} layout="vertical" margin={{ top: 4, right: 48, bottom: 0, left: 4 }}>
<ResponsiveContainer width="100%" height={210}>
<LineChart data={rateData} margin={{ top: 4, right: 12, bottom: 0, left: -12 }}>
<CartesianGrid {...GRID_STYLE} />
<XAxis type="number" tick={AXIS_STYLE} unit=" cycles" allowDecimals={false} />
<YAxis type="category" dataKey="label" tick={AXIS_STYLE} width={110} />
<Tooltip content={<DarkTooltip />} formatter={(val, name, props) => [
`${val} cycles (${props.payload.host_count} host${props.payload.host_count !== 1 ? 's' : ''})`, props.payload.team
]} />
<Bar dataKey="seen_count" name="Cycles Seen" fill="#F59E0B" fillOpacity={0.85} radius={[0, 3, 3, 0]}
label={{ position: 'right', style: { ...AXIS_STYLE, fill: '#64748B' }, formatter: v => `${v}×` }}
<XAxis dataKey="date" tick={AXIS_STYLE} />
<YAxis tick={AXIS_STYLE} unit="%" domain={[0, 'auto']} />
<Tooltip content={<DarkTooltip />} />
<Line
type="monotone" dataKey="rate" name="Resolution %"
stroke="#10B981" strokeWidth={2}
dot={{ r: 4, fill: '#10B981', strokeWidth: 0 }}
activeDot={{ r: 6 }}
/>
</BarChart>
</LineChart>
</ResponsiveContainer>
);
}
@@ -286,8 +307,7 @@ export default function ComplianceChartsPanel() {
const [collapsed, setCollapsed] = useState(false);
const [loading, setLoading] = useState(true);
const [trends, setTrends] = useState([]);
const [mttr, setMttr] = useState([]);
const [recurring, setRecurring] = useState([]);
const [aging, setAging] = useState([]);
const [archerRaw, setArcherRaw] = useState([]);
useEffect(() => {
@@ -295,16 +315,14 @@ export default function ComplianceChartsPanel() {
const load = async () => {
setLoading(true);
try {
const [tRes, mRes, rRes, aRes] = await Promise.all([
const [tRes, mRes, aRes] = await Promise.all([
fetch(`${API_BASE}/compliance/trends`, { credentials: 'include' }),
fetch(`${API_BASE}/compliance/mttr`, { credentials: 'include' }),
fetch(`${API_BASE}/compliance/top-recurring`, { credentials: 'include' }),
fetch(`${API_BASE}/archer-tickets/status-trend`, { credentials: 'include' }),
]);
if (cancelled) return;
if (tRes.ok) { const d = await tRes.json(); setTrends(d.trends || []); }
if (mRes.ok) { const d = await mRes.json(); setMttr(d.mttr || []); }
if (rRes.ok) { const d = await rRes.json(); setRecurring(d.items || []); }
if (mRes.ok) { const d = await mRes.json(); setAging(d.aging || []); }
if (aRes.ok) { const d = await aRes.json(); setArcherRaw(d.statusTrend || []); }
} catch { /* silent — charts will show no-data state */ }
finally { if (!cancelled) setLoading(false); }
@@ -393,20 +411,20 @@ export default function ComplianceChartsPanel() {
<TeamTrendChart data={formattedTrends} />
</ChartCard>
{/* 4. MTTR per team */}
{/* 4. Aging Findings Distribution */}
<ChartCard
title="Mean Time to Resolution"
subtitle="Average calendar days between first-seen and resolved, by team"
title="Aging Findings Distribution"
subtitle="Active findings by age bucket — stacked by team"
>
<MttrChart data={mttr} />
<AgingChart data={aging} />
</ChartCard>
{/* 5. Most persistent / recurring findings */}
{/* 5. Resolution Rate per Cycle */}
<ChartCard
title="Most Persistent Findings"
subtitle="Active items with the highest recurrence count (top 10)"
title="Resolution Rate"
subtitle="Percentage of known findings resolved each cycle — higher is better"
>
<RecurringChart data={recurring} />
<ResolutionRateChart data={formattedTrends} />
</ChartCard>
{/* 6. Archer ticket pipeline */}

View File

@@ -76,6 +76,7 @@ function ArchiveTooltip({ active, payload, label }) {
// Parse classification if present
const dataPoint = payload[0]?.payload;
const classification = dataPoint?.classification;
const returnClassification = dataPoint?.return_classification;
return (
<div style={{
@@ -133,6 +134,37 @@ function ArchiveTooltip({ active, payload, label }) {
)}
</div>
)}
{returnClassification && returned > 0 && (returnClassification.bu_reassignment > 0 || returnClassification.severity_drift > 0 || returnClassification.closed_on_platform > 0 || returnClassification.decommissioned > 0) && (
<div style={{ borderTop: '1px solid rgba(255,255,255,0.06)', marginTop: '0.35rem', paddingTop: '0.3rem' }}>
<div style={{ color: '#475569', fontSize: '0.58rem', textTransform: 'uppercase', letterSpacing: '0.06em', marginBottom: '0.2rem' }}>
Returned because
</div>
{returnClassification.bu_reassignment > 0 && (
<div style={{ color: '#FB923C', fontSize: '0.62rem', display: 'flex', justifyContent: 'space-between', gap: '1rem' }}>
<span style={{ opacity: 0.8 }}>BU reassigned back</span>
<span>{returnClassification.bu_reassignment}</span>
</div>
)}
{returnClassification.severity_drift > 0 && (
<div style={{ color: '#A78BFA', fontSize: '0.62rem', display: 'flex', justifyContent: 'space-between', gap: '1rem' }}>
<span style={{ opacity: 0.8 }}>Severity re-escalated</span>
<span>{returnClassification.severity_drift}</span>
</div>
)}
{returnClassification.closed_on_platform > 0 && (
<div style={{ color: SKY, fontSize: '0.62rem', display: 'flex', justifyContent: 'space-between', gap: '1rem' }}>
<span style={{ opacity: 0.8 }}>Reopened on platform</span>
<span>{returnClassification.closed_on_platform}</span>
</div>
)}
{returnClassification.decommissioned > 0 && (
<div style={{ color: '#94A3B8', fontSize: '0.62rem', display: 'flex', justifyContent: 'space-between', gap: '1rem' }}>
<span style={{ opacity: 0.8 }}>Re-provisioned</span>
<span>{returnClassification.decommissioned}</span>
</div>
)}
</div>
)}
</div>
);
}
@@ -219,10 +251,11 @@ export default function IvantiCountsChart() {
archived: anomaly.newly_archived_count || 0,
returned: anomaly.returned_count || 0,
classification: anomaly.classification || {},
return_classification: anomaly.return_classification || {},
is_significant: anomaly.is_significant,
};
}
return { date: point.date, archived: 0, returned: 0, classification: {}, is_significant: false };
return { date: point.date, archived: 0, returned: 0, classification: {}, return_classification: {}, is_significant: false };
});
}, [anomalies, chartData]);

View File

@@ -1518,7 +1518,7 @@ function AddToQueuePopover({ finding, anchorRect, queueForm, setQueueForm, onAdd
// ---------------------------------------------------------------------------
// QueuePanel — fixed slide-out panel showing the user's Ivanti queue
// ---------------------------------------------------------------------------
function QueuePanel({ open, items, onClose, onUpdate, onDelete, onDeleteMany, onClearCompleted, onCreateFpWorkflow, onRedirectComplete, canWrite, fpSubmissions, onEditSubmission }) {
function QueuePanel({ open, items, onClose, onUpdate, onDelete, onDeleteMany, onClearCompleted, onCreateFpWorkflow, onRedirectComplete, canWrite, fpSubmissions, onEditSubmission, cardConfigured, cardTeams, onQueueRefresh }) {
const pendingCount = items.filter((i) => i.status === 'pending').length;
const completedCount = items.filter((i) => i.status === 'complete').length;
@@ -1526,6 +1526,24 @@ function QueuePanel({ open, items, onClose, onUpdate, onDelete, onDeleteMany, on
const [redirectItem, setRedirectItem] = useState(null);
const [redirectSuccess, setRedirectSuccess] = useState(null);
// CARD action state — tracks which item has an active action form
const [cardAction, setCardAction] = useState(null); // { itemId, type: 'confirm'|'decline'|'redirect' }
const [cardFormTeam, setCardFormTeam] = useState('');
const [cardFormComment, setCardFormComment] = useState('');
const [cardFormFromTeam, setCardFormFromTeam] = useState('');
const [cardFormToTeam, setCardFormToTeam] = useState('');
const [cardActionLoading, setCardActionLoading] = useState(false);
const [cardActionError, setCardActionError] = useState(null);
// CARD Asset Search state
const [assetSearchOpen, setAssetSearchOpen] = useState(false);
const [assetSearchTeam, setAssetSearchTeam] = useState('');
const [assetSearchDisposition, setAssetSearchDisposition] = useState('confirmed');
const [assetSearchResults, setAssetSearchResults] = useState(null);
const [assetSearchLoading, setAssetSearchLoading] = useState(false);
const [assetSearchError, setAssetSearchError] = useState(null);
const [assetSearchPage, setAssetSearchPage] = useState(1);
// Drop any selected IDs that no longer exist in items
useEffect(() => {
setSelectedIds((prev) => {
@@ -1556,6 +1574,110 @@ function QueuePanel({ open, items, onClose, onUpdate, onDelete, onDeleteMany, on
setTimeout(() => setRedirectSuccess(null), 3000);
};
// CARD action handlers
const openCardAction = (itemId, type) => {
setCardAction({ itemId, type });
setCardFormTeam('');
setCardFormComment('');
setCardFormFromTeam('');
setCardFormToTeam('');
setCardActionError(null);
};
const closeCardAction = () => {
setCardAction(null);
setCardFormTeam('');
setCardFormComment('');
setCardFormFromTeam('');
setCardFormToTeam('');
setCardActionError(null);
setCardActionLoading(false);
};
const handleCardConfirmDecline = async (item, actionType) => {
if (!cardFormTeam) return;
setCardActionLoading(true);
setCardActionError(null);
try {
const res = await fetch(`${API_BASE}/card/queue/${item.id}/${actionType}`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
teamName: cardFormTeam,
assetId: item.ip_address,
comment: cardFormComment || '',
}),
});
const data = await res.json();
if (!res.ok) {
setCardActionError(data.error || `${actionType} failed.`);
setCardActionLoading(false);
return;
}
// Update local state to complete without full refresh
onUpdate(item.id, { status: 'complete' });
closeCardAction();
} catch (err) {
setCardActionError(err.message || 'Network error.');
setCardActionLoading(false);
}
};
const handleCardRedirect = async (item) => {
if (!cardFormFromTeam || !cardFormToTeam) return;
setCardActionLoading(true);
setCardActionError(null);
try {
const res = await fetch(`${API_BASE}/card/queue/${item.id}/redirect`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
fromTeam: cardFormFromTeam,
toTeam: cardFormToTeam,
assetId: item.ip_address,
}),
});
const data = await res.json();
if (!res.ok) {
setCardActionError(data.error || 'Redirect failed.');
setCardActionLoading(false);
return;
}
onUpdate(item.id, { status: 'complete' });
closeCardAction();
} catch (err) {
setCardActionError(err.message || 'Network error.');
setCardActionLoading(false);
}
};
// CARD Asset Search handler
const handleAssetSearch = async (page = 1) => {
if (!assetSearchTeam || !assetSearchDisposition) return;
setAssetSearchLoading(true);
setAssetSearchError(null);
setAssetSearchPage(page);
try {
const res = await fetch(
`${API_BASE}/card/teams/${encodeURIComponent(assetSearchTeam)}/assets?disposition=${encodeURIComponent(assetSearchDisposition)}&page_size=50&page=${page}`,
{ credentials: 'include' }
);
const data = await res.json();
if (!res.ok) {
setAssetSearchError(data.error || 'Search failed.');
setAssetSearchLoading(false);
return;
}
setAssetSearchResults(data);
} catch (err) {
setAssetSearchError(err.message || 'Network error.');
} finally {
setAssetSearchLoading(false);
}
};
// Render a single queue item row
const renderQueueItem = (item, { done, selectedIds, toggleSelect, onUpdate, onDelete, setRedirectItem, canWrite }) => {
const wfColor = item.workflow_type === 'FP' ? { col: '#F59E0B', rgb: '245,158,11' }
@@ -1681,6 +1803,66 @@ function QueuePanel({ open, items, onClose, onUpdate, onDelete, onDeleteMany, on
{item.workflow_type}
</span>
{/* CARD action buttons — pending CARD items only */}
{item.workflow_type === 'CARD' && item.status === 'pending' && canWrite && (
<div style={{ display: 'flex', gap: '0.25rem', flexShrink: 0 }}>
<button
onClick={() => openCardAction(item.id, 'confirm')}
disabled={!cardConfigured || cardActionLoading}
title={!cardConfigured ? 'CARD integration not configured' : 'Confirm asset'}
style={{
background: cardConfigured ? 'rgba(16,185,129,0.1)' : 'transparent',
border: `1px solid ${cardConfigured ? 'rgba(16,185,129,0.3)' : 'rgba(255,255,255,0.05)'}`,
borderRadius: '0.2rem',
padding: '0.15rem 0.3rem',
fontFamily: 'monospace', fontSize: '0.55rem', fontWeight: '700',
color: cardConfigured ? '#10B981' : '#334155',
cursor: cardConfigured ? 'pointer' : 'not-allowed',
textTransform: 'uppercase', letterSpacing: '0.04em',
transition: 'all 0.12s',
}}
>
Confirm
</button>
<button
onClick={() => openCardAction(item.id, 'decline')}
disabled={!cardConfigured || cardActionLoading}
title={!cardConfigured ? 'CARD integration not configured' : 'Decline asset'}
style={{
background: cardConfigured ? 'rgba(239,68,68,0.1)' : 'transparent',
border: `1px solid ${cardConfigured ? 'rgba(239,68,68,0.3)' : 'rgba(255,255,255,0.05)'}`,
borderRadius: '0.2rem',
padding: '0.15rem 0.3rem',
fontFamily: 'monospace', fontSize: '0.55rem', fontWeight: '700',
color: cardConfigured ? '#EF4444' : '#334155',
cursor: cardConfigured ? 'pointer' : 'not-allowed',
textTransform: 'uppercase', letterSpacing: '0.04em',
transition: 'all 0.12s',
}}
>
Decline
</button>
<button
onClick={() => openCardAction(item.id, 'redirect')}
disabled={!cardConfigured || cardActionLoading}
title={!cardConfigured ? 'CARD integration not configured' : 'Redirect asset'}
style={{
background: cardConfigured ? 'rgba(14,165,233,0.1)' : 'transparent',
border: `1px solid ${cardConfigured ? 'rgba(14,165,233,0.3)' : 'rgba(255,255,255,0.05)'}`,
borderRadius: '0.2rem',
padding: '0.15rem 0.3rem',
fontFamily: 'monospace', fontSize: '0.55rem', fontWeight: '700',
color: cardConfigured ? '#0EA5E9' : '#334155',
cursor: cardConfigured ? 'pointer' : 'not-allowed',
textTransform: 'uppercase', letterSpacing: '0.04em',
transition: 'all 0.12s',
}}
>
Redirect
</button>
</div>
)}
{/* Redirect button — completed items only */}
{canWrite && done && (
<button
@@ -1708,6 +1890,228 @@ function QueuePanel({ open, items, onClose, onUpdate, onDelete, onDeleteMany, on
);
};
// Render CARD action inline form below a queue item
const renderCardActionForm = (item) => {
if (!cardAction || cardAction.itemId !== item.id) return null;
const { type } = cardAction;
if (type === 'confirm' || type === 'decline') {
const accentColor = type === 'confirm' ? '#10B981' : '#EF4444';
const accentRgb = type === 'confirm' ? '16,185,129' : '239,68,68';
const canSubmit = !cardActionLoading && cardFormTeam.length > 0;
return (
<div style={{
padding: '0.5rem 0.625rem',
marginBottom: '0.25rem',
borderRadius: '0.375rem',
background: `rgba(${accentRgb},0.04)`,
border: `1px solid rgba(${accentRgb},0.15)`,
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.375rem', marginBottom: '0.375rem' }}>
<span style={{ fontFamily: 'monospace', fontSize: '0.62rem', fontWeight: '700', color: accentColor, textTransform: 'uppercase', letterSpacing: '0.08em' }}>
{type === 'confirm' ? 'Confirm Asset' : 'Decline Asset'}
</span>
</div>
<select
value={cardFormTeam}
onChange={(e) => setCardFormTeam(e.target.value)}
disabled={cardActionLoading}
style={{
width: '100%', boxSizing: 'border-box',
background: 'rgba(15, 23, 42, 0.6)',
border: `1px solid rgba(${accentRgb}, 0.25)`,
borderRadius: '0.25rem',
padding: '0.35rem 0.5rem',
fontFamily: "'JetBrains Mono', monospace", fontSize: '0.7rem',
color: '#E2E8F0',
outline: 'none',
marginBottom: '0.375rem',
}}
>
<option value="">Select team</option>
{cardTeams.map(t => <option key={t} value={t}>{t}</option>)}
</select>
<input
type="text"
value={cardFormComment}
onChange={(e) => setCardFormComment(e.target.value)}
disabled={cardActionLoading}
placeholder="Comment (optional)"
maxLength={500}
style={{
width: '100%', boxSizing: 'border-box',
background: 'rgba(15, 23, 42, 0.6)',
border: `1px solid rgba(${accentRgb}, 0.15)`,
borderRadius: '0.25rem',
padding: '0.35rem 0.5rem',
fontFamily: "'JetBrains Mono', monospace", fontSize: '0.7rem',
color: '#E2E8F0',
outline: 'none',
marginBottom: '0.375rem',
}}
/>
{cardActionError && (
<div style={{
display: 'flex', alignItems: 'center', gap: '0.375rem',
padding: '0.3rem 0.5rem',
background: 'rgba(239, 68, 68, 0.1)',
border: '1px solid rgba(239, 68, 68, 0.25)',
borderRadius: '0.25rem',
marginBottom: '0.375rem',
}}>
<AlertCircle style={{ width: '12px', height: '12px', color: '#EF4444', flexShrink: 0 }} />
<span style={{ fontFamily: 'monospace', fontSize: '0.62rem', color: '#FCA5A5' }}>{cardActionError}</span>
</div>
)}
<div style={{ display: 'flex', gap: '0.375rem' }}>
<button
onClick={() => handleCardConfirmDecline(item, type)}
disabled={!canSubmit}
style={{
flex: 1, padding: '0.3rem',
background: canSubmit ? `rgba(${accentRgb},0.12)` : 'transparent',
border: `1px solid ${canSubmit ? `rgba(${accentRgb},0.35)` : 'rgba(255,255,255,0.05)'}`,
borderRadius: '0.25rem',
color: canSubmit ? accentColor : '#334155',
fontFamily: 'monospace', fontSize: '0.65rem', fontWeight: '600',
cursor: canSubmit ? 'pointer' : 'not-allowed',
textTransform: 'uppercase', letterSpacing: '0.04em',
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '0.25rem',
}}
>
{cardActionLoading && <Loader style={{ width: '10px', height: '10px', animation: 'spin 1s linear infinite' }} />}
{cardActionLoading ? 'Submitting…' : type === 'confirm' ? 'Confirm' : 'Decline'}
</button>
<button
onClick={closeCardAction}
disabled={cardActionLoading}
style={{
padding: '0.3rem 0.5rem',
background: 'transparent',
border: '1px solid rgba(255,255,255,0.08)',
borderRadius: '0.25rem',
color: '#64748B',
fontFamily: 'monospace', fontSize: '0.65rem', fontWeight: '600',
cursor: 'pointer',
textTransform: 'uppercase', letterSpacing: '0.04em',
}}
>
Cancel
</button>
</div>
</div>
);
}
if (type === 'redirect') {
const canSubmit = !cardActionLoading && cardFormFromTeam.length > 0 && cardFormToTeam.length > 0;
return (
<div style={{
padding: '0.5rem 0.625rem',
marginBottom: '0.25rem',
borderRadius: '0.375rem',
background: 'rgba(14,165,233,0.04)',
border: '1px solid rgba(14,165,233,0.15)',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.375rem', marginBottom: '0.375rem' }}>
<span style={{ fontFamily: 'monospace', fontSize: '0.62rem', fontWeight: '700', color: '#0EA5E9', textTransform: 'uppercase', letterSpacing: '0.08em' }}>
Redirect Asset
</span>
</div>
<select
value={cardFormFromTeam}
onChange={(e) => setCardFormFromTeam(e.target.value)}
disabled={cardActionLoading}
style={{
width: '100%', boxSizing: 'border-box',
background: 'rgba(15, 23, 42, 0.6)',
border: '1px solid rgba(14, 165, 233, 0.25)',
borderRadius: '0.25rem',
padding: '0.35rem 0.5rem',
fontFamily: "'JetBrains Mono', monospace", fontSize: '0.7rem',
color: '#E2E8F0',
outline: 'none',
marginBottom: '0.375rem',
}}
>
<option value="">From team</option>
{cardTeams.map(t => <option key={t} value={t}>{t}</option>)}
</select>
<select
value={cardFormToTeam}
onChange={(e) => setCardFormToTeam(e.target.value)}
disabled={cardActionLoading}
style={{
width: '100%', boxSizing: 'border-box',
background: 'rgba(15, 23, 42, 0.6)',
border: '1px solid rgba(14, 165, 233, 0.25)',
borderRadius: '0.25rem',
padding: '0.35rem 0.5rem',
fontFamily: "'JetBrains Mono', monospace", fontSize: '0.7rem',
color: '#E2E8F0',
outline: 'none',
marginBottom: '0.375rem',
}}
>
<option value="">To team</option>
{cardTeams.map(t => <option key={t} value={t}>{t}</option>)}
</select>
{cardActionError && (
<div style={{
display: 'flex', alignItems: 'center', gap: '0.375rem',
padding: '0.3rem 0.5rem',
background: 'rgba(239, 68, 68, 0.1)',
border: '1px solid rgba(239, 68, 68, 0.25)',
borderRadius: '0.25rem',
marginBottom: '0.375rem',
}}>
<AlertCircle style={{ width: '12px', height: '12px', color: '#EF4444', flexShrink: 0 }} />
<span style={{ fontFamily: 'monospace', fontSize: '0.62rem', color: '#FCA5A5' }}>{cardActionError}</span>
</div>
)}
<div style={{ display: 'flex', gap: '0.375rem' }}>
<button
onClick={() => handleCardRedirect(item)}
disabled={!canSubmit}
style={{
flex: 1, padding: '0.3rem',
background: canSubmit ? 'rgba(14,165,233,0.12)' : 'transparent',
border: `1px solid ${canSubmit ? 'rgba(14,165,233,0.35)' : 'rgba(255,255,255,0.05)'}`,
borderRadius: '0.25rem',
color: canSubmit ? '#0EA5E9' : '#334155',
fontFamily: 'monospace', fontSize: '0.65rem', fontWeight: '600',
cursor: canSubmit ? 'pointer' : 'not-allowed',
textTransform: 'uppercase', letterSpacing: '0.04em',
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '0.25rem',
}}
>
{cardActionLoading && <Loader style={{ width: '10px', height: '10px', animation: 'spin 1s linear infinite' }} />}
{cardActionLoading ? 'Redirecting…' : 'Redirect'}
</button>
<button
onClick={closeCardAction}
disabled={cardActionLoading}
style={{
padding: '0.3rem 0.5rem',
background: 'transparent',
border: '1px solid rgba(255,255,255,0.08)',
borderRadius: '0.25rem',
color: '#64748B',
fontFamily: 'monospace', fontSize: '0.65rem', fontWeight: '600',
cursor: 'pointer',
textTransform: 'uppercase', letterSpacing: '0.04em',
}}
>
Cancel
</button>
</div>
</div>
);
}
return null;
};
// Inventory items (CARD + GRANITE) are their own top section; everything else groups by vendor
const grouped = useMemo(() => {
const inventoryItems = items.filter((i) => i.workflow_type === 'CARD' || i.workflow_type === 'GRANITE');
@@ -1819,7 +2223,12 @@ function QueuePanel({ open, items, onClose, onUpdate, onDelete, onDeleteMany, on
{/* Items — Inventory section renders CARD then GRANITE with optional sub-divider */}
{isInventory ? (
<>
{cardItems.map((item) => renderQueueItem(item, { done: item.status === 'complete', selectedIds, toggleSelect, onUpdate, onDelete, setRedirectItem, canWrite }))}
{cardItems.map((item) => (
<React.Fragment key={item.id}>
{renderQueueItem(item, { done: item.status === 'complete', selectedIds, toggleSelect, onUpdate, onDelete, setRedirectItem, canWrite })}
{renderCardActionForm(item)}
</React.Fragment>
))}
{cardItems.length > 0 && graniteItems.length > 0 && (
<div style={{
height: '1px',
@@ -1836,6 +2245,211 @@ function QueuePanel({ open, items, onClose, onUpdate, onDelete, onDeleteMany, on
))}
</div>
{/* CARD Asset Search section */}
{cardConfigured && (
<div style={{ padding: '0 1.25rem 0.75rem' }}>
<div
onClick={() => setAssetSearchOpen(!assetSearchOpen)}
style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '0.3rem 0', marginBottom: '0.375rem',
borderBottom: '1px solid rgba(14,165,233,0.2)',
cursor: 'pointer',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.375rem' }}>
<Database style={{ width: '12px', height: '12px', color: '#0EA5E9' }} />
<span style={{ fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '700', color: '#0EA5E9', textTransform: 'uppercase', letterSpacing: '0.1em' }}>
CARD Asset Search
</span>
</div>
{assetSearchOpen
? <ChevronUp style={{ width: '14px', height: '14px', color: '#475569' }} />
: <ChevronDown style={{ width: '14px', height: '14px', color: '#475569' }} />
}
</div>
{assetSearchOpen && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.375rem' }}>
<select
value={assetSearchTeam}
onChange={(e) => { setAssetSearchTeam(e.target.value); setAssetSearchResults(null); setAssetSearchError(null); }}
style={{
width: '100%', boxSizing: 'border-box',
background: 'rgba(15, 23, 42, 0.6)',
border: '1px solid rgba(14, 165, 233, 0.25)',
borderRadius: '0.25rem',
padding: '0.35rem 0.5rem',
fontFamily: "'JetBrains Mono', monospace", fontSize: '0.7rem',
color: '#E2E8F0',
outline: 'none',
}}
>
<option value="">Select team</option>
{cardTeams.map(t => <option key={t} value={t}>{t}</option>)}
</select>
<select
value={assetSearchDisposition}
onChange={(e) => { setAssetSearchDisposition(e.target.value); setAssetSearchResults(null); setAssetSearchError(null); }}
style={{
width: '100%', boxSizing: 'border-box',
background: 'rgba(15, 23, 42, 0.6)',
border: '1px solid rgba(14, 165, 233, 0.25)',
borderRadius: '0.25rem',
padding: '0.35rem 0.5rem',
fontFamily: "'JetBrains Mono', monospace", fontSize: '0.7rem',
color: '#E2E8F0',
outline: 'none',
}}
>
<option value="confirmed">Confirmed</option>
<option value="unconfirmed">Unconfirmed</option>
<option value="declined">Declined</option>
<option value="candidate">Candidate</option>
</select>
<button
onClick={() => handleAssetSearch(1)}
disabled={!assetSearchTeam || assetSearchLoading}
style={{
padding: '0.35rem',
background: assetSearchTeam ? 'rgba(14,165,233,0.12)' : 'transparent',
border: `1px solid ${assetSearchTeam ? 'rgba(14,165,233,0.35)' : 'rgba(255,255,255,0.05)'}`,
borderRadius: '0.25rem',
color: assetSearchTeam ? '#0EA5E9' : '#334155',
fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '600',
cursor: assetSearchTeam ? 'pointer' : 'not-allowed',
textTransform: 'uppercase', letterSpacing: '0.05em',
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '0.375rem',
}}
>
{assetSearchLoading
? <Loader style={{ width: '12px', height: '12px', animation: 'spin 1s linear infinite' }} />
: <Search style={{ width: '12px', height: '12px' }} />
}
{assetSearchLoading ? 'Searching…' : 'Search'}
</button>
{/* Error */}
{assetSearchError && (
<div style={{
display: 'flex', alignItems: 'center', gap: '0.375rem',
padding: '0.3rem 0.5rem',
background: 'rgba(239, 68, 68, 0.1)',
border: '1px solid rgba(239, 68, 68, 0.25)',
borderRadius: '0.25rem',
}}>
<AlertCircle style={{ width: '12px', height: '12px', color: '#EF4444', flexShrink: 0 }} />
<span style={{ fontFamily: 'monospace', fontSize: '0.62rem', color: '#FCA5A5' }}>{assetSearchError}</span>
</div>
)}
{/* Results */}
{assetSearchResults && (
<div>
<div style={{
fontFamily: 'monospace', fontSize: '0.62rem', fontWeight: '600',
color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.08em',
marginBottom: '0.25rem',
}}>
{assetSearchResults.total != null ? `${assetSearchResults.total} asset${assetSearchResults.total !== 1 ? 's' : ''} found` : 'Results'}
</div>
{/* Results table */}
{Array.isArray(assetSearchResults.assets) && assetSearchResults.assets.length > 0 ? (
<div style={{
maxHeight: '200px', overflowY: 'auto',
border: '1px solid rgba(14,165,233,0.12)',
borderRadius: '0.25rem',
}}>
<table style={{
width: '100%', borderCollapse: 'collapse',
fontFamily: "'JetBrains Mono', monospace", fontSize: '0.62rem',
}}>
<thead>
<tr style={{ background: 'rgba(14,165,233,0.06)' }}>
<th style={{ padding: '0.3rem 0.5rem', textAlign: 'left', color: '#94A3B8', fontWeight: '700', textTransform: 'uppercase', letterSpacing: '0.06em', borderBottom: '1px solid rgba(14,165,233,0.12)' }}>
Asset ID
</th>
{assetSearchResults.assets[0] && Object.keys(assetSearchResults.assets[0]).filter(k => k !== 'asset_id' && k !== '_id').slice(0, 3).map(k => (
<th key={k} style={{ padding: '0.3rem 0.5rem', textAlign: 'left', color: '#94A3B8', fontWeight: '700', textTransform: 'uppercase', letterSpacing: '0.06em', borderBottom: '1px solid rgba(14,165,233,0.12)' }}>
{k.replace(/_/g, ' ')}
</th>
))}
</tr>
</thead>
<tbody>
{assetSearchResults.assets.map((asset, idx) => {
const extraKeys = Object.keys(asset).filter(k => k !== 'asset_id' && k !== '_id').slice(0, 3);
return (
<tr key={asset.asset_id || asset._id || idx} style={{ borderBottom: '1px solid rgba(255,255,255,0.03)' }}>
<td style={{ padding: '0.25rem 0.5rem', color: '#CBD5E1', fontWeight: '600' }}>
{asset.asset_id || asset._id || '—'}
</td>
{extraKeys.map(k => (
<td key={k} style={{ padding: '0.25rem 0.5rem', color: '#94A3B8' }}>
{typeof asset[k] === 'object' ? JSON.stringify(asset[k]) : String(asset[k] ?? '—')}
</td>
))}
</tr>
);
})}
</tbody>
</table>
</div>
) : (
<div style={{ fontFamily: 'monospace', fontSize: '0.65rem', color: '#475569', padding: '0.5rem 0' }}>
No assets found.
</div>
)}
{/* Pagination */}
{assetSearchResults.total != null && assetSearchResults.total > (assetSearchResults.page_size || 50) && (
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '0.5rem',
marginTop: '0.375rem',
}}>
<button
onClick={() => handleAssetSearch(assetSearchPage - 1)}
disabled={assetSearchPage <= 1 || assetSearchLoading}
style={{
padding: '0.2rem 0.4rem',
background: assetSearchPage > 1 ? 'rgba(14,165,233,0.08)' : 'transparent',
border: `1px solid ${assetSearchPage > 1 ? 'rgba(14,165,233,0.25)' : 'rgba(255,255,255,0.05)'}`,
borderRadius: '0.2rem',
color: assetSearchPage > 1 ? '#0EA5E9' : '#334155',
fontFamily: 'monospace', fontSize: '0.6rem', fontWeight: '600',
cursor: assetSearchPage > 1 ? 'pointer' : 'not-allowed',
}}
>
Prev
</button>
<span style={{ fontFamily: 'monospace', fontSize: '0.6rem', color: '#64748B' }}>
Page {assetSearchPage} of {Math.ceil(assetSearchResults.total / (assetSearchResults.page_size || 50))}
</span>
<button
onClick={() => handleAssetSearch(assetSearchPage + 1)}
disabled={assetSearchPage >= Math.ceil(assetSearchResults.total / (assetSearchResults.page_size || 50)) || assetSearchLoading}
style={{
padding: '0.2rem 0.4rem',
background: assetSearchPage < Math.ceil(assetSearchResults.total / (assetSearchResults.page_size || 50)) ? 'rgba(14,165,233,0.08)' : 'transparent',
border: `1px solid ${assetSearchPage < Math.ceil(assetSearchResults.total / (assetSearchResults.page_size || 50)) ? 'rgba(14,165,233,0.25)' : 'rgba(255,255,255,0.05)'}`,
borderRadius: '0.2rem',
color: assetSearchPage < Math.ceil(assetSearchResults.total / (assetSearchResults.page_size || 50)) ? '#0EA5E9' : '#334155',
fontFamily: 'monospace', fontSize: '0.6rem', fontWeight: '600',
cursor: assetSearchPage < Math.ceil(assetSearchResults.total / (assetSearchResults.page_size || 50)) ? 'pointer' : 'not-allowed',
}}
>
Next
</button>
</div>
)}
</div>
)}
</div>
)}
</div>
)}
{/* Submissions section */}
{fpSubmissions && fpSubmissions.length > 0 && (
<div style={{ padding: '0 1.25rem 0.75rem' }}>
@@ -4344,6 +4958,10 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
const [atlasMetricsLoading, setAtlasMetricsLoading] = useState(false);
const [atlasMetricsError, setAtlasMetricsError] = useState(null);
// CARD API state — session-level caching for teams list
const [cardConfigured, setCardConfigured] = useState(false);
const [cardTeams, setCardTeams] = useState([]);
const updateColumns = useCallback((newOrder) => {
setColumnOrder(newOrder);
saveColumnOrder(newOrder);
@@ -4481,6 +5099,31 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
}
}, []);
// CARD API — fetch status and teams (session-level caching)
const cardTeamsFetchedRef = useRef(false);
const fetchCardStatus = useCallback(async () => {
try {
const res = await fetch(`${API_BASE}/card/status`, { credentials: 'include' });
if (res.ok) {
const data = await res.json();
setCardConfigured(data.configured === true);
if (data.configured && !cardTeamsFetchedRef.current) {
cardTeamsFetchedRef.current = true;
const teamsRes = await fetch(`${API_BASE}/card/teams`, { credentials: 'include' });
if (teamsRes.ok) {
const teamsData = await teamsRes.json();
const teams = Array.isArray(teamsData)
? teamsData.map(t => t.card_team_name || t._id).filter(Boolean).sort()
: [];
setCardTeams(teams);
}
}
}
} catch (err) {
console.error('[card-api] Failed to fetch CARD status:', err.message);
}
}, []);
const fetchFindings = async () => {
setLoading(true);
try {
@@ -4523,6 +5166,7 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
fetchFpSubmissions();
fetchAtlasStatus();
fetchAtlasMetrics();
fetchCardStatus();
}, []); // eslint-disable-line
// Set/clear a single column filter
@@ -5658,6 +6302,9 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
canWrite={canWrite}
fpSubmissions={fpSubmissions}
onEditSubmission={handleEditSubmission}
cardConfigured={cardConfigured}
cardTeams={cardTeams}
onQueueRefresh={fetchQueue}
/>
<FpWorkflowModal
open={fpModalOpen}

View File

@@ -0,0 +1,16 @@
[Unit]
Description=CVE Dashboard Backend (Express API)
After=network.target
[Service]
Type=simple
WorkingDirectory=/home/cve-dashboard/backend
ExecStart=/usr/bin/node server.js
Restart=on-failure
RestartSec=5
EnvironmentFile=/home/cve-dashboard/backend/.env
StandardOutput=append:/home/cve-dashboard/backend/backend.log
StandardError=append:/home/cve-dashboard/backend/backend.log
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,17 @@
[Unit]
Description=CVE Dashboard Frontend (React Dev Server)
After=network.target cve-backend.service
[Service]
Type=simple
WorkingDirectory=/home/cve-dashboard/frontend
ExecStart=/usr/bin/npm start
Restart=on-failure
RestartSec=5
EnvironmentFile=/home/cve-dashboard/frontend/.env
Environment=BROWSER=none
StandardOutput=append:/home/cve-dashboard/frontend/frontend.log
StandardError=append:/home/cve-dashboard/frontend/frontend.log
[Install]
WantedBy=multi-user.target