12 Commits

Author SHA1 Message Date
Jordan Ramos
7f7d3a2977 release: v1.0.0 — clean README, changelog, full reference manual, dead code removal, package metadata 2026-05-01 21:18:31 +00:00
Jordan Ramos
034d3963b9 chore: reorganize docs, document migrations, gitignore operational files for v1.0.0 release 2026-05-01 20:53:39 +00:00
Jordan Ramos
c8b3626ac5 feat: consolidate setup.js with complete v1.0.0 schema — all tables, indexes, triggers for fresh deployments 2026-05-01 20:13:52 +00:00
Jordan Ramos
8e377bb85f chore: enable GPG-signed commits for code provenance 2026-05-01 19:50:31 +00:00
root
5a9df2103f fix: aggregate anomaly data per day instead of taking latest — fixes missing returned bars when multiple syncs per day 2026-05-01 19:29:11 +00:00
root
bfa52c7f8f fix: reclassify BU reassignment round-trips and fix backfill date-ordering bug 2026-05-01 17:36:28 +00:00
root
3202b0707c feat: add backfill script for return classification on existing anomaly log rows 2026-05-01 17:27:49 +00:00
root
15abf8bae4 feat: add return classification for archive chart, CARD API integration, compliance charts, systemd services 2026-05-01 17:15:41 +00:00
8df961cce8 Merge pull request 'Switch Jira API calls to GET-based JQL search with project scoping' (#9) from fix/jira-api-compliance into master
Reviewed-on: #9
2026-04-29 08:16:44 -06:00
root
7a179f19a1 Switch Jira API calls to GET-based JQL search with project scoping
- getIssue now uses GET /rest/api/2/search with JQL instead of
  GET /rest/api/2/issue/{key} for Charter compliance
- searchIssues switched from POST to GET with URL-encoded query params
- searchIssuesByKeys adds project scoping to JQL clause
- Updated UAT tests and API use-case docs to match
2026-04-29 14:12:04 +00:00
root
4f960d0866 Update README and Jira UAT test script 2026-04-28 18:44:14 +00:00
root
caa1d539cc Add CARD API integration spec, Atlas metrics updates, NavDrawer and server.js cleanup, reference docs 2026-04-28 16:38:18 +00:00
72 changed files with 6319 additions and 3280 deletions

27
.gitignore vendored
View File

@@ -39,10 +39,6 @@ frontend.pid
backend/uploads/temp/
feature_request*.md
# Planning docs
docs/aeo-compliance-ui-plan.md
docs/aeo-compliance-wireframe.md
# AI tooling config
.claude/
ai_notes.md
@@ -59,13 +55,20 @@ backend/setup.js-backup
# Kiro agents (local only)
.kiro/agents/
# Kiro implementation summary (internal only)
docs/kiro-implementation-summary.md
# Zip files
*.zip
# Diagnostic scripts (troubleshooting only)
backend/scripts/drift-check.js
backend/scripts/bu-reassignment-check.js
backend/scripts/export-reassigned-findings.js
# Production DB copies
cve_database_prod.db
cve_database.db.prod
cve_database.db.backup
database.db
# Investigation exports
docs/reassigned-findings-*.xlsx
# Operations — local admin records, UAT logs, firewall requests, data exports
docs/operations/
# Data exports — local spreadsheets
docs/data-exports/
# Python cache
__pycache__/

View File

@@ -125,7 +125,7 @@ Add a tab system to the Metric Graphs panel on the ReportingPage, with an "Ivant
- Verify `getStatusColor` returns `#10B981` for "active", `#EF4444` for "expired", `#0EA5E9` for "completed", `#64748B` for any other string
- **Validates: Requirements 5.2**
- [~]* 5.8 Write unit tests for Atlas donut components
- [ ]* 5.8 Write unit tests for Atlas donut components
- Test Coverage donut empty state message when totalHosts is 0
- Test Plan type donut empty state message when totalPlans is 0
- Test Plan status donut empty state message when totalPlans is 0

View File

@@ -0,0 +1 @@
{"specId": "0334e0b6-7ae7-4284-95a0-caed55c59af1", "workflowType": "requirements-first", "specType": "feature"}

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,163 @@
# Requirements Document
## Introduction
This feature integrates the CARD API into the STEAM Security Dashboard so that CARD workflow items in the Ivanti Queue can trigger real actions — confirm, decline, redirect, and search — via the CARD API. The integration covers OAuth token management, a backend helper module with automatic update_token handling, specific proxy routes for each CARD operation, and frontend UI updates that let users execute CARD actions directly from the queue. A standalone asset search capability supports Granite ID lookups when assets are reassigned.
## Glossary
- **Dashboard**: The STEAM Security Dashboard — the self-hosted vulnerability management application this feature extends.
- **CARD_API**: The external CARD REST API hosted at `card.charter.com` (production) or `card.caas.stage.charterlab.com` (UAT), authenticated via OAuth Bearer tokens. Read endpoints use the `/api/v1/` path prefix; mutation endpoints use the `/api/v2/` path prefix.
- **CARD_Helper**: The new `backend/helpers/cardApi.js` module responsible for CARD API authentication, token management, and HTTP request execution.
- **Token_Manager**: The component within CARD_Helper that handles OAuth token acquisition via Basic Auth, in-memory caching, and automatic refresh before expiry. Tokens have a one-hour TTL.
- **Queue_Item**: A row in the `ivanti_todo_queue` table with `workflow_type = 'CARD'`, representing a finding staged for CARD action.
- **CARD_Route**: The new Express route module at `backend/routes/cardApi.js` that exposes CARD API operations to the frontend through the backend.
- **Audit_Logger**: The existing `logAudit(db, {...})` helper that records state-changing actions to the `audit_logs` table.
- **Auth_Middleware**: The existing `requireAuth(db)` and `requireGroup(...)` middleware that enforces session validation and role-based access.
- **Asset_ID**: A CARD asset identifier in IPN format (e.g., `98.8.142.56-NATL`). Used as the path parameter in owner lookup and mutation endpoints.
- **Update_Token**: A server-generated token returned by the GET owner endpoint. The update_token is mandatory for all mutation calls (confirm, decline, redirect) and ensures optimistic concurrency control.
- **Disposition**: The ownership state of an asset in CARD. Valid values are `confirmed`, `unconfirmed`, `declined`, and `candidate`.
- **Team**: A CARD team name (e.g., `NTS-AEO-STEAM`). Teams are the organizational unit for asset ownership in CARD.
- **Owner_Record**: The JSON object returned by the GET owner endpoint, containing the asset ownership details, disposition states with team names, scores, timestamps, and the update_token field.
## Requirements
### Requirement 1: CARD API Helper Module
**User Story:** As a backend developer, I want a dedicated CARD API helper module that follows the existing atlasApi.js pattern, so that all CARD API communication is centralized and consistent with the codebase.
#### Acceptance Criteria
1. THE CARD_Helper SHALL export an `isConfigured` boolean that is `true` only when all required environment variables (`CARD_API_URL`, `CARD_API_USER`, `CARD_API_PASS`) are present and non-empty.
2. WHEN `isConfigured` is `false`, THE CARD_Helper SHALL log a warning at module load listing the missing environment variables with the prefix `[card-api]`.
3. THE CARD_Helper SHALL use the Node.js built-in `https` module for all HTTP requests to the CARD_API.
4. THE CARD_Helper SHALL export convenience wrapper functions for GET and POST HTTP methods, each accepting a URL path, optional request body, and optional options object.
5. THE CARD_Helper SHALL set `rejectUnauthorized` to `false` on HTTPS requests when the `CARD_SKIP_TLS` environment variable is set to `'true'`.
6. THE CARD_Helper SHALL apply a configurable request timeout defaulting to 15000 milliseconds.
7. THE CARD_Helper SHALL return a Promise that resolves with an object containing `status` (HTTP status code) and `body` (response body string) for each request.
8. THE CARD_Helper SHALL route read requests (GET) through the `/api/v1/` path prefix and mutation requests (POST) through the `/api/v2/` path prefix, matching the CARD_API versioning scheme.
### Requirement 2: OAuth Token Management
**User Story:** As a backend developer, I want the CARD helper to manage OAuth Bearer tokens automatically, so that downstream code does not need to handle authentication directly.
#### Acceptance Criteria
1. WHEN a CARD API request is made and no cached token exists, THE Token_Manager SHALL acquire a new token by sending a request to the CARD_API `/api/v1/auth/get_token` endpoint with a Basic Auth header containing the base64-encoded `CARD_API_USER:CARD_API_PASS` credentials.
2. WHEN a valid token is received, THE Token_Manager SHALL cache the token in memory along with its expiry timestamp (one-hour TTL from acquisition time).
3. WHEN a cached token exists and its expiry timestamp is more than 60 seconds in the future, THE Token_Manager SHALL reuse the cached token for subsequent requests.
4. WHEN a cached token exists and its expiry timestamp is 60 seconds or less in the future, THE Token_Manager SHALL acquire a new token before making the API request.
5. THE Token_Manager SHALL include the cached Bearer token in the `Authorization` header of all non-authentication CARD API requests.
6. IF the CARD_API returns an HTTP 401 response on a non-authentication request, THEN THE Token_Manager SHALL invalidate the cached token, acquire a new token, and retry the original request exactly once.
7. IF the token acquisition request fails or returns a non-success HTTP status, THEN THE Token_Manager SHALL reject the Promise with a descriptive error message including the HTTP status code and the response body.
### Requirement 3: Environment Variable Configuration
**User Story:** As a system administrator, I want CARD API credentials and settings stored in environment variables following the existing pattern, so that configuration is consistent and secrets are not committed to source control.
#### Acceptance Criteria
1. THE Dashboard SHALL read the following environment variables for CARD API configuration: `CARD_API_URL` (base URL), `CARD_API_USER` (service account username), `CARD_API_PASS` (service account password), and `CARD_SKIP_TLS` (TLS verification toggle).
2. THE Dashboard SHALL document all CARD environment variables in `backend/.env.example` with descriptive comments matching the existing documentation style.
3. WHEN any of `CARD_API_URL`, `CARD_API_USER`, or `CARD_API_PASS` is missing or empty, THE CARD_Helper SHALL treat the integration as unconfigured and report `isConfigured` as `false`.
4. THE Dashboard SHALL treat `CARD_SKIP_TLS` as optional, defaulting to `false` when not set.
### Requirement 4: CARD API Proxy Routes
**User Story:** As a dashboard user, I want backend routes that proxy specific CARD API operations, so that the frontend can trigger CARD actions without exposing API credentials to the browser.
#### Acceptance Criteria
1. THE CARD_Route SHALL export a factory function `createCardApiRouter(db, requireAuth)` that returns an Express Router, following the existing route module pattern.
2. THE CARD_Route SHALL protect all endpoints with `requireAuth(db)` for session validation and `requireGroup('Admin', 'Standard_User')` for role-based access.
3. THE CARD_Route SHALL expose a `GET /api/card/status` endpoint that returns `{ configured: boolean }` indicating whether the CARD API integration is configured.
4. THE CARD_Route SHALL expose a `GET /api/card/teams` endpoint that proxies the CARD_API `GET /api/v1/teams` endpoint and returns the list of CARD teams to the client.
5. THE CARD_Route SHALL expose a `GET /api/card/teams/:teamName/assets` endpoint that proxies the CARD_API `GET /api/v1/team/{teamName}/assets` endpoint, accepting `disposition`, `page`, and `page_size` query parameters.
6. WHEN the `page_size` query parameter is not provided on the assets endpoint, THE CARD_Route SHALL default to a page size of 50.
7. THE CARD_Route SHALL expose a `GET /api/card/owner/:assetId` endpoint that proxies the CARD_API `GET /api/v1/owner/{assetId}` endpoint and returns the Owner_Record including disposition states and the update_token.
8. IF `isConfigured` is `false` when a CARD API proxy endpoint is called, THEN THE CARD_Route SHALL return HTTP 503 with `{ error: 'CARD API is not configured.' }`.
9. IF the CARD_API returns an error response, THEN THE CARD_Route SHALL return the CARD_API HTTP status code and a JSON error body containing the upstream error message.
10. THE CARD_Route SHALL be mounted at the `/api/card` path prefix in `server.js`.
### Requirement 5: CARD Asset Mutation Actions
**User Story:** As a dashboard user, I want to confirm, decline, or redirect CARD assets directly from the queue, so that I can process CARD workflow findings without leaving the dashboard.
#### Acceptance Criteria
1. THE CARD_Route SHALL expose a `POST /api/card/queue/:queueItemId/confirm` endpoint that confirms an asset to a specified team via the CARD_API.
2. THE CARD_Route SHALL expose a `POST /api/card/queue/:queueItemId/decline` endpoint that declines an asset from a specified team via the CARD_API.
3. THE CARD_Route SHALL expose a `POST /api/card/queue/:queueItemId/redirect` endpoint that redirects an asset from one team to another team via the CARD_API.
4. WHEN any mutation endpoint is called, THE CARD_Route SHALL verify that the queue item exists, belongs to the requesting user, has `workflow_type = 'CARD'`, and has `status = 'pending'`.
5. IF the queue item does not exist, does not belong to the user, or is not a CARD workflow item, THEN THE CARD_Route SHALL return HTTP 404 with `{ error: 'Queue item not found.' }`.
6. IF the queue item status is not `'pending'`, THEN THE CARD_Route SHALL return HTTP 400 with `{ error: 'Only pending queue items can be executed.' }`.
7. WHEN a mutation endpoint is called, THE CARD_Route SHALL first call `GET /api/v1/owner/{assetId}` to retrieve the current update_token, then use that update_token in the subsequent mutation call, making the two-step flow transparent to the frontend.
8. WHEN the confirm endpoint is called, THE CARD_Route SHALL send `POST /api/v2/owner/{assetId}/confirm?update_token={token}&comment={comment}` with body `{ "name": "TEAM-NAME" }` to the CARD_API.
9. WHEN the decline endpoint is called, THE CARD_Route SHALL send `POST /api/v2/owner/{assetId}/decline?update_token={token}&comment={comment}` with body `{ "name": "TEAM-NAME" }` to the CARD_API.
10. WHEN the redirect endpoint is called, THE CARD_Route SHALL send `POST /api/v2/owner/{assetId}/{fromTeam}/redirect?update_token={token}` with body `{ "name": "TO-TEAM-NAME" }` to the CARD_API, where `fromTeam` is a path parameter and the destination team is in the request body.
11. THE confirm endpoint SHALL accept a request body containing `teamName` (string, required), `comment` (string, optional), and `assetId` (string, required).
12. THE decline endpoint SHALL accept a request body containing `teamName` (string, required), `comment` (string, optional), and `assetId` (string, required).
13. THE redirect endpoint SHALL accept a request body containing `fromTeam` (string, required), `toTeam` (string, required), and `assetId` (string, required).
14. WHEN the CARD_API mutation call succeeds, THE CARD_Route SHALL update the queue item status to `'complete'` and return the CARD_API response to the client.
15. IF the CARD_API mutation call fails, THEN THE CARD_Route SHALL leave the queue item status as `'pending'` and return the error to the client.
### Requirement 6: Frontend CARD Action UI
**User Story:** As a dashboard user, I want specific Confirm, Decline, and Redirect action buttons on CARD queue items, so that I can perform the correct CARD operation for each finding.
#### Acceptance Criteria
1. WHEN a CARD Queue_Item is displayed in the Ivanti Queue panel, THE Dashboard SHALL render three action buttons labeled "Confirm", "Decline", and "Redirect" on pending CARD items.
2. WHEN the user clicks the "Confirm" button, THE Dashboard SHALL display a form with a team selection dropdown (populated from the `/api/card/teams` endpoint) and an optional comment text field, then send a `POST` request to `/api/card/queue/:queueItemId/confirm` with the selected team name, comment, and asset ID.
3. WHEN the user clicks the "Decline" button, THE Dashboard SHALL display a form with a team selection dropdown and an optional comment text field, then send a `POST` request to `/api/card/queue/:queueItemId/decline` with the selected team name, comment, and asset ID.
4. WHEN the user clicks the "Redirect" button, THE Dashboard SHALL display a form with a "From Team" dropdown and a "To Team" dropdown (both populated from the `/api/card/teams` endpoint), then send a `POST` request to `/api/card/queue/:queueItemId/redirect` with the from team, to team, and asset ID.
5. WHILE a CARD action request is in flight, THE Dashboard SHALL disable the action buttons and display a loading indicator on the affected queue item.
6. WHEN the CARD action request succeeds, THE Dashboard SHALL update the queue item status to `'complete'` in the local UI state without requiring a full queue refresh.
7. IF the CARD action request fails, THEN THE Dashboard SHALL display the error message returned by the backend in an inline error indicator on the affected queue item.
8. WHEN the CARD API is not configured (status endpoint returns `configured: false`), THE Dashboard SHALL disable CARD action buttons and display a tooltip indicating the integration is not configured.
9. THE Dashboard SHALL cache the teams list from `/api/card/teams` for the duration of the browser session to avoid redundant API calls.
### Requirement 7: Asset Search UI
**User Story:** As a dashboard user, I want to search CARD for assets by team and disposition, so that I can find Granite IDs when assets get reassigned.
#### Acceptance Criteria
1. THE Dashboard SHALL provide an asset search interface accessible from the Ivanti Queue page.
2. THE asset search interface SHALL include a team selection dropdown (populated from the `/api/card/teams` endpoint) and a disposition filter dropdown with options: `confirmed`, `unconfirmed`, `declined`, `candidate`.
3. WHEN the user initiates a search, THE Dashboard SHALL send a `GET` request to `/api/card/teams/:teamName/assets` with the selected disposition and `page_size=50`.
4. WHEN the first page of results is returned, THE Dashboard SHALL display the total asset count and render the first page of results in a table.
5. WHEN the total asset count exceeds the page size, THE Dashboard SHALL provide pagination controls to navigate through additional pages by sending subsequent requests with incremented `page` parameters.
6. THE asset search results table SHALL display the Asset_ID and any other identifying fields returned by the CARD_API that help the user locate the correct Granite ID.
7. IF the asset search request fails, THEN THE Dashboard SHALL display the error message returned by the backend in the search results area.
### Requirement 8: Error Handling and Resilience
**User Story:** As a dashboard user, I want clear error feedback when CARD API operations fail, so that I can understand what went wrong and take corrective action.
#### Acceptance Criteria
1. IF the CARD_API is unreachable or the request times out, THEN THE CARD_Helper SHALL reject the Promise with an error message that includes the HTTP method, URL path, and failure reason.
2. IF the token acquisition endpoint returns invalid or unparseable JSON, THEN THE Token_Manager SHALL reject the Promise with a descriptive error message indicating a token parse failure.
3. IF the token acquisition endpoint returns HTTP 403, THEN THE CARD_Route SHALL return HTTP 403 with `{ error: 'CARD access denied. The service account may not be onboarded with the CARD team.' }`.
4. IF the token acquisition endpoint returns HTTP 401, THEN THE CARD_Route SHALL return HTTP 401 with `{ error: 'CARD authorization failed. Check service account credentials.' }`.
5. IF the token acquisition endpoint returns HTTP 525, THEN THE CARD_Route SHALL return HTTP 502 with `{ error: 'CARD LDAP error. The service account may not be provisioned correctly.' }`.
6. IF a CARD_API call returns HTTP 401, THEN THE CARD_Route SHALL return HTTP 401 with `{ error: 'CARD token expired or invalid. The request has been retried once automatically.' }`.
7. IF a CARD_API call returns HTTP 403, THEN THE CARD_Route SHALL return HTTP 403 with `{ error: 'Insufficient CARD permissions for this operation.' }`.
8. THE CARD_Route SHALL catch all unhandled errors from CARD_Helper calls and return HTTP 502 with `{ error: 'CARD API request failed.', details: <error message> }`.
9. THE CARD_Route SHALL log all CARD API errors to the server console with the prefix `[card-api]` for consistent log filtering.
10. IF the CARD_Helper is not configured and a proxy endpoint is called, THEN THE CARD_Route SHALL return HTTP 503 with a message indicating which environment variables are missing.
### Requirement 9: Audit Logging for CARD Actions
**User Story:** As an administrator, I want all CARD API actions logged in the audit trail, so that I can review what CARD operations were performed and by whom.
#### Acceptance Criteria
1. WHEN a CARD confirm action is executed through the dashboard, THE Audit_Logger SHALL record an entry with `action: 'card_confirm'`, `entityType: 'ivanti_todo_queue'`, the queue item ID as `entityId`, and `details` containing the asset ID, team name, comment, and CARD_API response status.
2. WHEN a CARD decline action is executed through the dashboard, THE Audit_Logger SHALL record an entry with `action: 'card_decline'`, `entityType: 'ivanti_todo_queue'`, the queue item ID as `entityId`, and `details` containing the asset ID, team name, comment, and CARD_API response status.
3. WHEN a CARD redirect action is executed through the dashboard, THE Audit_Logger SHALL record an entry with `action: 'card_redirect'`, `entityType: 'ivanti_todo_queue'`, the queue item ID as `entityId`, and `details` containing the asset ID, from team, to team, and CARD_API response status.
4. WHEN a CARD asset search is executed through the dashboard, THE Audit_Logger SHALL record an entry with `action: 'card_search'`, `entityType: 'card_asset'`, `entityId` set to the team name, and `details` containing the disposition filter and result count.
5. WHEN a CARD API action fails, THE Audit_Logger SHALL record an entry with `action: 'card_action_failed'`, `entityType: 'ivanti_todo_queue'`, the queue item ID as `entityId`, and `details` containing the action type, asset ID, error message, and CARD_API response status.
6. THE Audit_Logger SHALL record the requesting user's `userId`, `username`, and `ipAddress` on all CARD audit entries.
7. THE Audit_Logger SHALL use fire-and-forget semantics for CARD audit entries, matching the existing audit logging pattern.

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__/`

59
CHANGELOG.md Normal file
View File

@@ -0,0 +1,59 @@
# Changelog
## v1.0.0 — 2026-05-01
First official release. Consolidates all features developed since initial commit into a stable, documented, deployment-ready package.
### Core Platform
- CVE tracking with multi-vendor support, document storage, and NVD API auto-fill
- Session-based authentication with four user groups (Admin, Standard_User, Leadership, Read_Only)
- Full audit logging of all state-changing actions
- Dark tactical intelligence UI theme with monospace typography
### Ivanti Integration
- Live sync of open host findings from Ivanti/RiskSense API (auto-sync every 24h)
- Reporting page with donut metric charts, advanced per-column filtering, inline editing
- FP workflow submission directly to Ivanti API with file attachments
- Ivanti Queue — personal staging list for batch FP, Archer, CARD, and Granite workflows
- Queue item redirect between workflow types after completion
- Row visibility controls with localStorage persistence
### Archive and Anomaly Tracking
- Automatic detection of disappeared and returned findings across syncs
- BU drift checker — classifies archived findings by reason (BU reassignment, severity drift, closed on platform, decommissioned)
- Return classification — explains why findings came back (BU reassigned back, severity re-escalated, etc.)
- Findings Trend chart with archive activity sparkline and shift reason tooltips
- Anomaly banner for significant archive events
### Compliance (AEO Posture)
- Weekly NTS_AEO xlsx upload with diff preview (new, resolved, recurring)
- Schema drift detection with breaking/silent-miss/cosmetic classification
- Admin config reconciliation for parser updates
- Per-team metric health cards with grouped categories and variant pills
- Device-level violation tracking with timestamped notes history
- Multi-metric note grouping
- Upload rollback support
### Integrations
- Jira Data Center — create, sync, and track tickets linked to CVE/vendor pairs
- Archer — risk acceptance exception tracking (EXC numbers)
- Atlas InfoSec — action plan cache, bulk creation from row selection, metrics reporting
- CARD API — Granite/CARD asset lookup for network device workflows
- NVD API — auto-fill CVE metadata with bulk sync support
### Knowledge Base
- Internal document library with inline PDF and Markdown rendering
- Category-based browsing and search
### Admin
- Full-page admin panel with user management, audit log, and system info tabs
- Themed confirm modals replacing browser dialogs
- User profile panel with self-service password change
### Infrastructure
- Consolidated `setup.js` with complete database schema (27 tables, all indexes and triggers)
- systemd service files for persistent deployment
- GitLab CI/CD pipeline (install, lint, test, build, deploy)
- GPG-signed commits for code provenance
- Organized documentation structure (api, design, guides, security, testing, troubleshooting)
- Migration scripts documented and retained for existing deployment upgrades

1069
README.md

File diff suppressed because it is too large Load Diff

View File

@@ -3,6 +3,10 @@ PORT=3001
API_HOST=localhost
CORS_ORIGINS=http://localhost:3000
# Session secret — REQUIRED. Server will not start without this.
# Generate with: openssl rand -base64 32
SESSION_SECRET=
# NVD API Key (optional - increases rate limit from 5 to 50 requests per 30s)
# Request one at https://nvd.nist.gov/developers/request-an-api-key
NVD_API_KEY=
@@ -41,3 +45,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

@@ -276,14 +276,15 @@ function jiraDelete(urlPath, options) {
* @param {string[]} [fields] - Jira field names to return
*/
async function getIssue(issueKey, fields) {
const fieldList = (fields || DEFAULT_FIELDS).join(',');
const res = await jiraGet(
`/rest/api/2/issue/${encodeURIComponent(issueKey)}?fields=${encodeURIComponent(fieldList)}`
);
if (res.status === 200) {
return { ok: true, data: JSON.parse(res.body) };
const jql = `key = "${issueKey}" AND project = ${JIRA_PROJECT_KEY}`;
const result = await searchIssues(jql, { fields: fields || DEFAULT_FIELDS, maxResults: 1, startAt: 0 });
if (result.ok && result.data.issues && result.data.issues.length > 0) {
return { ok: true, data: result.data.issues[0] };
}
return { ok: false, status: res.status, body: res.body, rateLimited: res.rateLimited };
if (result.ok && (!result.data.issues || result.data.issues.length === 0)) {
return { ok: false, status: 404, body: 'Issue not found' };
}
return result;
}
/**
@@ -303,7 +304,7 @@ async function searchIssuesByKeys(issueKeys, opts) {
// or similar, but key-based search is inherently scoped. We add updated
// clause for compliance.
const keyList = issueKeys.map(k => `"${k}"`).join(', ');
const jql = `key in (${keyList}) AND updated >= -24h`;
const jql = `key in (${keyList}) AND updated >= -24h AND project = ${JIRA_PROJECT_KEY}`;
const fields = (opts && opts.fields) || DEFAULT_FIELDS;
const maxResults = Math.min((opts && opts.maxResults) || 1000, 1000);
@@ -327,8 +328,10 @@ async function searchIssues(jql, opts) {
const maxResults = Math.min((opts && opts.maxResults) || 1000, 1000);
const fields = (opts && opts.fields) || DEFAULT_FIELDS;
const body = { jql, startAt, maxResults, fields };
const res = await jiraPost('/rest/api/2/search', body);
const fieldList = encodeURIComponent(fields.join(','));
const encodedJql = encodeURIComponent(jql);
const queryString = `?jql=${encodedJql}&fields=${fieldList}&maxResults=${maxResults}&startAt=${startAt}`;
const res = await jiraGet('/rest/api/2/search' + queryString);
if (res.status === 200) {
return { ok: true, data: JSON.parse(res.body) };
}

View File

@@ -1,96 +0,0 @@
#!/usr/bin/env node
// Migration script: Add audit_logs table
// Run: node migrate-audit-log.js
const sqlite3 = require('sqlite3').verbose();
const fs = require('fs');
const DB_FILE = './cve_database.db';
const BACKUP_FILE = `./cve_database_backup_${Date.now()}.db`;
function run(db, sql, params = []) {
return new Promise((resolve, reject) => {
db.run(sql, params, function(err) {
if (err) reject(err);
else resolve(this);
});
});
}
function get(db, sql, params = []) {
return new Promise((resolve, reject) => {
db.get(sql, params, (err, row) => {
if (err) reject(err);
else resolve(row);
});
});
}
async function migrate() {
console.log('╔════════════════════════════════════════════════════════╗');
console.log('║ CVE Database Migration: Add Audit Logs ║');
console.log('╚════════════════════════════════════════════════════════╝\n');
if (!fs.existsSync(DB_FILE)) {
console.log('❌ Database not found. Run setup.js for fresh install.');
process.exit(1);
}
// Backup database
console.log('📦 Creating backup...');
fs.copyFileSync(DB_FILE, BACKUP_FILE);
console.log(` ✓ Backup saved to: ${BACKUP_FILE}\n`);
const db = new sqlite3.Database(DB_FILE);
try {
// Check if table already exists
const exists = await get(db,
"SELECT name FROM sqlite_master WHERE type='table' AND name='audit_logs'"
);
if (exists) {
console.log('⏭️ audit_logs table already exists, nothing to do.');
} else {
console.log('1⃣ Creating audit_logs table...');
await run(db, `
CREATE TABLE audit_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER,
username VARCHAR(50) NOT NULL,
action VARCHAR(50) NOT NULL,
entity_type VARCHAR(50) NOT NULL,
entity_id VARCHAR(100),
details TEXT,
ip_address VARCHAR(45),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
`);
console.log(' ✓ Table created');
console.log('2⃣ Creating indexes...');
await run(db, 'CREATE INDEX IF NOT EXISTS idx_audit_user_id ON audit_logs(user_id)');
await run(db, 'CREATE INDEX IF NOT EXISTS idx_audit_action ON audit_logs(action)');
await run(db, 'CREATE INDEX IF NOT EXISTS idx_audit_entity_type ON audit_logs(entity_type)');
await run(db, 'CREATE INDEX IF NOT EXISTS idx_audit_created_at ON audit_logs(created_at)');
console.log(' ✓ Indexes created');
}
console.log('\n╔════════════════════════════════════════════════════════╗');
console.log('║ MIGRATION COMPLETE! ║');
console.log('╚════════════════════════════════════════════════════════╝');
console.log('\n📋 Summary:');
console.log(' ✓ audit_logs table ready');
console.log(`\n💾 Backup saved: ${BACKUP_FILE}`);
console.log('\n🚀 Restart your server to apply changes.\n');
} catch (error) {
console.error('\n❌ Migration failed:', error.message);
console.log(`\n🔄 To restore from backup: cp ${BACKUP_FILE} ${DB_FILE}`);
process.exit(1);
} finally {
db.close();
}
}
migrate();

View File

@@ -1,289 +0,0 @@
#!/usr/bin/env node
// Migration script: v1.0.0 -> v1.1.0
// Adds: users, sessions tables, multi-vendor support, vendor column in documents
// Run: node migrate-to-1.1.js
const sqlite3 = require('sqlite3').verbose();
const bcrypt = require('bcryptjs');
const fs = require('fs');
const path = require('path');
const DB_FILE = './cve_database.db';
const BACKUP_FILE = `./cve_database_backup_${Date.now()}.db`;
async function migrate() {
console.log('╔════════════════════════════════════════════════════════╗');
console.log('║ CVE Database Migration: v1.0.0 → v1.1.0 ║');
console.log('╚════════════════════════════════════════════════════════╝\n');
// Check if database exists
if (!fs.existsSync(DB_FILE)) {
console.log('❌ Database not found. Run setup.js for fresh install.');
process.exit(1);
}
// Backup database
console.log('📦 Creating backup...');
fs.copyFileSync(DB_FILE, BACKUP_FILE);
console.log(` ✓ Backup saved to: ${BACKUP_FILE}\n`);
const db = new sqlite3.Database(DB_FILE);
try {
// Run migrations in sequence
await addUsersTable(db);
await addSessionsTable(db);
await addVendorToDocuments(db);
await updateCvesConstraint(db);
await createDefaultAdmin(db);
await updateView(db);
console.log('\n╔════════════════════════════════════════════════════════╗');
console.log('║ MIGRATION COMPLETE! ║');
console.log('╚════════════════════════════════════════════════════════╝');
console.log('\n📋 Summary:');
console.log(' ✓ Users table added');
console.log(' ✓ Sessions table added');
console.log(' ✓ Vendor column added to documents');
console.log(' ✓ Multi-vendor constraint applied to cves');
console.log(' ✓ Default admin user created (admin/admin123)');
console.log(`\n💾 Backup saved: ${BACKUP_FILE}`);
console.log('\n🚀 Restart your server to apply changes.\n');
} catch (error) {
console.error('\n❌ Migration failed:', error.message);
console.log(`\n🔄 To restore from backup: cp ${BACKUP_FILE} ${DB_FILE}`);
process.exit(1);
} finally {
db.close();
}
}
function run(db, sql, params = []) {
return new Promise((resolve, reject) => {
db.run(sql, params, function(err) {
if (err) reject(err);
else resolve(this);
});
});
}
function get(db, sql, params = []) {
return new Promise((resolve, reject) => {
db.get(sql, params, (err, row) => {
if (err) reject(err);
else resolve(row);
});
});
}
function all(db, sql, params = []) {
return new Promise((resolve, reject) => {
db.all(sql, params, (err, rows) => {
if (err) reject(err);
else resolve(rows);
});
});
}
async function addUsersTable(db) {
console.log('1⃣ Adding users table...');
const exists = await get(db,
"SELECT name FROM sqlite_master WHERE type='table' AND name='users'"
);
if (exists) {
console.log(' ⏭️ Users table already exists, skipping');
return;
}
await run(db, `
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username VARCHAR(50) UNIQUE NOT NULL,
email VARCHAR(255) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL,
role VARCHAR(20) NOT NULL DEFAULT 'viewer',
is_active BOOLEAN DEFAULT 1,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_login TIMESTAMP,
CHECK (role IN ('admin', 'editor', 'viewer'))
)
`);
await run(db, 'CREATE INDEX IF NOT EXISTS idx_users_username ON users(username)');
console.log(' ✓ Users table created');
}
async function addSessionsTable(db) {
console.log('2⃣ Adding sessions table...');
const exists = await get(db,
"SELECT name FROM sqlite_master WHERE type='table' AND name='sessions'"
);
if (exists) {
console.log(' ⏭️ Sessions table already exists, skipping');
return;
}
await run(db, `
CREATE TABLE sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id VARCHAR(255) UNIQUE NOT NULL,
user_id INTEGER NOT NULL,
expires_at TIMESTAMP NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)
`);
await run(db, 'CREATE INDEX IF NOT EXISTS idx_sessions_session_id ON sessions(session_id)');
await run(db, 'CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id)');
await run(db, 'CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions(expires_at)');
console.log(' ✓ Sessions table created');
}
async function addVendorToDocuments(db) {
console.log('3⃣ Adding vendor column to documents...');
// Check if vendor column exists
const columns = await all(db, "PRAGMA table_info(documents)");
const hasVendor = columns.some(col => col.name === 'vendor');
if (hasVendor) {
console.log(' ⏭️ Vendor column already exists, skipping');
return;
}
// Add vendor column
await run(db, "ALTER TABLE documents ADD COLUMN vendor VARCHAR(100)");
// Populate vendor from the cves table based on cve_id
await run(db, `
UPDATE documents
SET vendor = (
SELECT c.vendor
FROM cves c
WHERE c.cve_id = documents.cve_id
LIMIT 1
)
WHERE vendor IS NULL
`);
// Set default for any remaining nulls
await run(db, "UPDATE documents SET vendor = 'Unknown' WHERE vendor IS NULL");
await run(db, 'CREATE INDEX IF NOT EXISTS idx_doc_vendor ON documents(vendor)');
console.log(' ✓ Vendor column added and populated');
}
async function updateCvesConstraint(db) {
console.log('4⃣ Updating CVEs table for multi-vendor support...');
// Check current schema
const tableInfo = await get(db,
"SELECT sql FROM sqlite_master WHERE type='table' AND name='cves'"
);
if (tableInfo.sql.includes('UNIQUE(cve_id, vendor)')) {
console.log(' ⏭️ Multi-vendor constraint already exists, skipping');
return;
}
// SQLite doesn't support ALTER CONSTRAINT, so we need to rebuild the table
console.log(' 📋 Rebuilding table with new constraint...');
// Create new table with correct schema
await run(db, `
CREATE TABLE cves_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
cve_id VARCHAR(20) NOT NULL,
vendor VARCHAR(100) NOT NULL,
severity VARCHAR(20) NOT NULL,
description TEXT,
published_date DATE,
status VARCHAR(50) DEFAULT 'Open',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(cve_id, vendor)
)
`);
// Copy data
await run(db, `
INSERT INTO cves_new (id, cve_id, vendor, severity, description, published_date, status, created_at, updated_at)
SELECT id, cve_id, vendor, severity, description, published_date, status, created_at, updated_at
FROM cves
`);
// Drop old table
await run(db, 'DROP TABLE cves');
// Rename new table
await run(db, 'ALTER TABLE cves_new RENAME TO cves');
// Recreate indexes
await run(db, 'CREATE INDEX IF NOT EXISTS idx_cve_id ON cves(cve_id)');
await run(db, 'CREATE INDEX IF NOT EXISTS idx_vendor ON cves(vendor)');
await run(db, 'CREATE INDEX IF NOT EXISTS idx_severity ON cves(severity)');
await run(db, 'CREATE INDEX IF NOT EXISTS idx_status ON cves(status)');
console.log(' ✓ Multi-vendor constraint applied');
}
async function createDefaultAdmin(db) {
console.log('5⃣ Creating default admin user...');
const exists = await get(db, "SELECT id FROM users WHERE username = 'admin'");
if (exists) {
console.log(' ⏭️ Admin user already exists, skipping');
return;
}
const passwordHash = await bcrypt.hash('admin123', 10);
await run(db, `
INSERT INTO users (username, email, password_hash, role, is_active)
VALUES (?, ?, ?, ?, ?)
`, ['admin', 'admin@localhost', passwordHash, 'admin', 1]);
console.log(' ✓ Admin user created (admin/admin123)');
}
async function updateView(db) {
console.log('6⃣ Updating document status view...');
// Drop old view if exists
await run(db, 'DROP VIEW IF EXISTS cve_document_status');
// Create updated view with multi-vendor support
await run(db, `
CREATE VIEW cve_document_status AS
SELECT
c.id as record_id,
c.cve_id,
c.vendor,
c.severity,
c.status,
COUNT(DISTINCT d.id) as total_documents,
COUNT(DISTINCT CASE WHEN d.type = 'advisory' THEN d.id END) as advisory_count,
COUNT(DISTINCT CASE WHEN d.type = 'email' THEN d.id END) as email_count,
COUNT(DISTINCT CASE WHEN d.type = 'screenshot' THEN d.id END) as screenshot_count,
CASE
WHEN COUNT(DISTINCT CASE WHEN d.type = 'advisory' THEN d.id END) > 0
THEN 'Complete'
ELSE 'Missing Required Docs'
END as compliance_status
FROM cves c
LEFT JOIN documents d ON c.cve_id = d.cve_id AND c.vendor = d.vendor
GROUP BY c.id, c.cve_id, c.vendor, c.severity, c.status
`);
console.log(' ✓ View updated');
}
// Run migration
migrate();

View File

@@ -1,39 +0,0 @@
// Migration: Add jira_tickets table
const sqlite3 = require('sqlite3').verbose();
const path = require('path');
const dbPath = path.join(__dirname, 'cve_database.db');
const db = new sqlite3.Database(dbPath);
console.log('Starting JIRA tickets migration...');
db.serialize(() => {
// Create jira_tickets table
db.run(`
CREATE TABLE IF NOT EXISTS jira_tickets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
cve_id TEXT NOT NULL,
vendor TEXT NOT NULL,
ticket_key TEXT NOT NULL,
url TEXT,
summary TEXT,
status TEXT DEFAULT 'Open' CHECK(status IN ('Open', 'In Progress', 'Closed')),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (cve_id, vendor) REFERENCES cves(cve_id, vendor) ON DELETE CASCADE
)
`, (err) => {
if (err) console.error('Error creating table:', err);
else console.log('✓ jira_tickets table created');
});
// Create indexes
db.run('CREATE INDEX IF NOT EXISTS idx_jira_tickets_cve ON jira_tickets(cve_id, vendor)');
db.run('CREATE INDEX IF NOT EXISTS idx_jira_tickets_status ON jira_tickets(status)');
console.log('✓ Indexes created');
});
db.close(() => {
console.log('Migration complete!');
});

View File

@@ -1,128 +0,0 @@
const sqlite3 = require('sqlite3').verbose();
const db = new sqlite3.Database('./cve_database.db');
console.log('🔄 Starting database migration for multi-vendor support...\n');
db.serialize(() => {
// Backup existing data
console.log('📦 Creating backup tables...');
db.run(`CREATE TABLE IF NOT EXISTS cves_backup AS SELECT * FROM cves`, (err) => {
if (err) console.error('Backup error:', err);
else console.log('✓ CVEs backed up');
});
db.run(`CREATE TABLE IF NOT EXISTS documents_backup AS SELECT * FROM documents`, (err) => {
if (err) console.error('Backup error:', err);
else console.log('✓ Documents backed up');
});
// Drop old table
console.log('\n🗑 Dropping old cves table...');
db.run(`DROP TABLE IF EXISTS cves`, (err) => {
if (err) {
console.error('Drop error:', err);
return;
}
console.log('✓ Old table dropped');
// Create new table with UNIQUE(cve_id, vendor) instead of UNIQUE(cve_id)
console.log('\n🏗 Creating new cves table with multi-vendor support...');
db.run(`
CREATE TABLE cves (
id INTEGER PRIMARY KEY AUTOINCREMENT,
cve_id VARCHAR(20) NOT NULL,
vendor VARCHAR(100) NOT NULL,
severity VARCHAR(20) NOT NULL,
description TEXT,
published_date DATE,
status VARCHAR(50) DEFAULT 'Open',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(cve_id, vendor)
)
`, (err) => {
if (err) {
console.error('Create error:', err);
return;
}
console.log('✓ New table created with UNIQUE(cve_id, vendor)');
// Restore data
console.log('\n📥 Restoring data...');
db.run(`INSERT INTO cves SELECT * FROM cves_backup`, (err) => {
if (err) {
console.error('Restore error:', err);
return;
}
console.log('✓ Data restored');
// Recreate indexes
console.log('\n🔍 Creating indexes...');
db.run(`CREATE INDEX idx_cve_id ON cves(cve_id)`, () => {
console.log('✓ Index: idx_cve_id');
});
db.run(`CREATE INDEX idx_vendor ON cves(vendor)`, () => {
console.log('✓ Index: idx_vendor');
});
db.run(`CREATE INDEX idx_severity ON cves(severity)`, () => {
console.log('✓ Index: idx_severity');
});
db.run(`CREATE INDEX idx_status ON cves(status)`, () => {
console.log('✓ Index: idx_status');
});
// Update view
console.log('\n👁 Updating cve_document_status view...');
db.run(`DROP VIEW IF EXISTS cve_document_status`, (err) => {
if (err) console.error('Drop view error:', err);
db.run(`
CREATE VIEW cve_document_status AS
SELECT
c.id as record_id,
c.cve_id,
c.vendor,
c.severity,
c.status,
COUNT(DISTINCT d.id) as total_documents,
COUNT(DISTINCT CASE WHEN d.type = 'advisory' THEN d.id END) as advisory_count,
COUNT(DISTINCT CASE WHEN d.type = 'email' THEN d.id END) as email_count,
COUNT(DISTINCT CASE WHEN d.type = 'screenshot' THEN d.id END) as screenshot_count,
CASE
WHEN COUNT(DISTINCT CASE WHEN d.type = 'advisory' THEN d.id END) > 0
THEN 'Complete'
ELSE 'Missing Required Docs'
END as compliance_status
FROM cves c
LEFT JOIN documents d ON c.cve_id = d.cve_id AND c.vendor = d.vendor
GROUP BY c.id, c.cve_id, c.vendor, c.severity, c.status
`, (err) => {
if (err) {
console.error('Create view error:', err);
} else {
console.log('✓ View recreated');
}
console.log('\n✅ Migration complete!');
console.log('\n📊 Summary:');
db.get('SELECT COUNT(*) as count FROM cves', (err, row) => {
if (!err) console.log(` Total CVE entries: ${row.count}`);
db.get('SELECT COUNT(DISTINCT cve_id) as count FROM cves', (err, row) => {
if (!err) console.log(` Unique CVE IDs: ${row.count}`);
console.log('\n💡 Next steps:');
console.log(' 1. Restart backend: pkill -f "node server.js" && node server.js &');
console.log(' 2. Replace frontend/src/App.js with multi-vendor version');
console.log(' 3. Test by adding same CVE with multiple vendors\n');
db.close();
});
});
});
});
});
});
});
});

View File

@@ -0,0 +1,41 @@
# Database Migrations
These migration scripts were used to evolve the database schema during development. **They are NOT needed for fresh deployments**`setup.js` contains the complete v1.0.0 schema.
These are retained for reference and for upgrading existing deployments that were set up before v1.0.0.
## Schema Migrations (run in order for existing deployments)
| Script | Purpose |
|--------|---------|
| `add_ivanti_sync_table.js` | Creates `ivanti_sync_state` table for tracking Ivanti sync status |
| `add_ivanti_findings_tables.js` | Creates `ivanti_findings_cache`, `ivanti_finding_notes`, `ivanti_counts_cache`, `ivanti_finding_overrides` tables |
| `add_ivanti_counts_history_table.js` | Creates `ivanti_counts_history` table for trend chart data |
| `add_ivanti_todo_queue_table.js` | Creates `ivanti_todo_queue` table for FP/Archer workflow queuing |
| `add_todo_queue_hostname.js` | Adds `hostname` column to `ivanti_todo_queue` |
| `add_todo_queue_ip_address.js` | Adds `ip_address` column to `ivanti_todo_queue` |
| `add_fp_submissions_table.js` | Creates `ivanti_fp_submissions` table for false positive workflow tracking |
| `add_fp_submission_editing.js` | Adds `lifecycle_status`, `ivanti_workflow_batch_uuid`, `updated_at` columns and `ivanti_fp_submission_history` table |
| `add_knowledge_base_table.js` | Creates `knowledge_base` table for KB article storage |
| `add_user_groups.js` | Adds `user_group` column to `users` table with validation triggers |
| `add_created_by_columns.js` | Adds `created_by` column to `compliance_notes` and `knowledge_base` tables |
| `add_compliance_tables.js` | Creates `compliance_uploads`, `compliance_items`, `compliance_notes` tables |
| `add_compliance_notes_group_id.js` | Adds `group_id` column to `compliance_notes` for multi-metric note grouping |
| `add_archer_tickets_table.js` | Creates `archer_tickets` table for Archer exception tracking |
| `add_archer_tickets_timestamps.js` | Adds `created_at` and `updated_at` columns to `archer_tickets` |
| `add_jira_sync_columns.js` | Adds Jira sync-related columns to `jira_tickets` |
| `add_card_workflow_type.js` | Adds `CARD` to `workflow_type` CHECK constraint on `ivanti_todo_queue` |
| `add_granite_workflow_type.js` | Adds `GRANITE` to `workflow_type` CHECK constraint on `ivanti_todo_queue` |
| `add_finding_archive_tables.js` | Creates `ivanti_finding_archives` and `ivanti_archive_transitions` tables |
| `add_closed_gone_state.js` | Adds `CLOSED_GONE` to `current_state` CHECK constraint on `ivanti_finding_archives` |
| `add_sync_anomaly_tables.js` | Creates `ivanti_sync_anomaly_log` and `ivanti_finding_bu_history` tables |
| `add_atlas_action_plans_cache.js` | Creates `atlas_action_plans_cache` table for Atlas API caching |
| `add_return_classification.js` | Adds `return_classification_json` column to `ivanti_sync_anomaly_log` |
## Data Migrations (one-time backfills)
| Script | Purpose |
|--------|---------|
| `backfill_anomaly_log.js` | Synthesizes anomaly log entries from existing archive transitions for historical chart data |
| `backfill_return_classification.js` | Populates `return_classification_json` for existing anomaly rows with returned findings. Supports `--force` flag to re-run. |
| `reclassify_bu_roundtrips.js` | Reclassifies archive transitions that were BU reassignment round-trips (archived then returned within 14 days) from the default `severity_score_drift` to `bu_reassignment` |

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);
});

View File

@@ -0,0 +1,165 @@
#!/usr/bin/env node
// backfill_return_classification.js
//
// Retroactively populates return_classification_json for existing anomaly log
// rows that have returned_count > 0 but an empty return classification.
//
// For each such row, looks at archive transitions that went ARCHIVED → RETURNED
// on that date, then finds the *prior* archive reason (the most recent
// transition to ARCHIVED for that same archive record) to determine why the
// finding originally left — which tells us why it came back.
//
// Safe to run multiple times — only updates rows with empty classification.
//
// Usage: node backend/migrations/backfill_return_classification.js
const path = require('path');
const sqlite3 = require('sqlite3').verbose();
const DB_PATH = path.join(__dirname, '..', 'cve_database.db');
function dbAll(db, sql, params = []) {
return new Promise((resolve, reject) => {
db.all(sql, params, (err, rows) => {
if (err) reject(err);
else resolve(rows || []);
});
});
}
function dbGet(db, sql, params = []) {
return new Promise((resolve, reject) => {
db.get(sql, params, (err, row) => {
if (err) reject(err);
else resolve(row);
});
});
}
function dbRun(db, sql, params = []) {
return new Promise((resolve, reject) => {
db.run(sql, params, function (err) {
if (err) reject(err);
else resolve(this);
});
});
}
async function main() {
const db = new sqlite3.Database(DB_PATH);
// Find anomaly log rows that have returned findings but no return classification
const rows = await dbAll(db,
`SELECT id, sync_timestamp, returned_count, return_classification_json
FROM ivanti_sync_anomaly_log
WHERE returned_count > 0
ORDER BY sync_timestamp ASC`
);
if (rows.length === 0) {
console.log('No anomaly log rows with returned findings found — nothing to backfill.');
db.close();
return;
}
const force = process.argv.includes('--force');
let updated = 0;
let skipped = 0;
for (const row of rows) {
// Skip if already has a non-empty classification (unless --force)
if (!force) {
let existing = {};
try { existing = JSON.parse(row.return_classification_json || '{}'); } catch (_) {}
const hasData = Object.values(existing).some(v => v > 0);
if (hasData) {
skipped++;
continue;
}
}
// Find the date of this anomaly row
const date = row.sync_timestamp.split('T')[0].split(' ')[0];
// Find all ARCHIVED → RETURNED transitions on this date
const returnTransitions = await dbAll(db,
`SELECT archive_id
FROM ivanti_archive_transitions
WHERE to_state = 'RETURNED'
AND DATE(transitioned_at) = ?`,
[date]
);
if (returnTransitions.length === 0) {
// No transitions found for this date — try a wider window (±1 day)
// since sync_timestamp and transitioned_at might not align exactly
const wider = await dbAll(db,
`SELECT archive_id
FROM ivanti_archive_transitions
WHERE to_state = 'RETURNED'
AND DATE(transitioned_at) BETWEEN DATE(?, '-1 day') AND DATE(?, '+1 day')`,
[date, date]
);
if (wider.length === 0) {
console.log(` ${date}: ${row.returned_count} returned but no matching transitions found — skipping`);
continue;
}
returnTransitions.push(...wider);
}
// For each returned finding, look up the prior archive reason
const classification = { bu_reassignment: 0, severity_drift: 0, closed_on_platform: 0, decommissioned: 0 };
const seen = new Set();
for (const rt of returnTransitions) {
if (seen.has(rt.archive_id)) continue;
seen.add(rt.archive_id);
// Find the most recent ARCHIVED transition *before* this return
// (the reason it was archived before it came back)
const archiveTransition = await dbGet(db,
`SELECT reason FROM ivanti_archive_transitions
WHERE archive_id = ? AND to_state = 'ARCHIVED'
AND transitioned_at <= (
SELECT transitioned_at FROM ivanti_archive_transitions
WHERE archive_id = ? AND to_state = 'RETURNED'
AND DATE(transitioned_at) BETWEEN DATE(?, '-1 day') AND DATE(?, '+1 day')
ORDER BY transitioned_at DESC LIMIT 1
)
ORDER BY transitioned_at DESC LIMIT 1`,
[rt.archive_id, rt.archive_id, date, date]
);
if (archiveTransition && archiveTransition.reason) {
const reasonKey = archiveTransition.reason.split(':')[0];
if (reasonKey in classification) {
classification[reasonKey]++;
}
}
}
const classificationJson = JSON.stringify(classification);
await dbRun(db,
`UPDATE ivanti_sync_anomaly_log
SET return_classification_json = ?
WHERE id = ?`,
[classificationJson, row.id]
);
const parts = Object.entries(classification)
.filter(([, v]) => v > 0)
.map(([k, v]) => `${v} ${k}`);
const breakdown = parts.length > 0 ? parts.join(', ') : 'unclassified';
console.log(` ${date}: ${row.returned_count} returned — ${breakdown}`);
updated++;
}
console.log(`\nBackfill complete: ${updated} rows updated, ${skipped} already had data.`);
db.close();
}
main().catch(err => {
console.error('Fatal error:', err);
process.exit(1);
});

View File

@@ -0,0 +1,102 @@
#!/usr/bin/env node
// reclassify_bu_roundtrips.js
//
// Reclassifies archive transitions that were part of a BU reassignment
// round-trip. These are findings that were archived (disappeared from sync)
// and then returned within a short window — indicating they were temporarily
// reassigned to a different BU and then reassigned back.
//
// The original drift checker couldn't classify these correctly because by the
// time it queried Ivanti, the findings had already been reassigned back to
// the expected BUs.
//
// After running this, re-run backfill_return_classification.js to update
// the anomaly log with the corrected reasons.
//
// Usage: node backend/migrations/reclassify_bu_roundtrips.js
const path = require('path');
const sqlite3 = require('sqlite3').verbose();
const DB_PATH = path.join(__dirname, '..', 'cve_database.db');
// Findings that were archived and returned within this many days are
// considered BU reassignment round-trips
const ROUNDTRIP_WINDOW_DAYS = 14;
function dbAll(db, sql, params = []) {
return new Promise((resolve, reject) => {
db.all(sql, params, (err, rows) => {
if (err) reject(err);
else resolve(rows || []);
});
});
}
function dbRun(db, sql, params = []) {
return new Promise((resolve, reject) => {
db.run(sql, params, function (err) {
if (err) reject(err);
else resolve(this);
});
});
}
async function main() {
const db = new sqlite3.Database(DB_PATH);
// Find archive transitions where the finding was archived and then returned
// within the roundtrip window, and the archive reason is still the default
// severity_score_drift placeholder
const roundtrips = await dbAll(db, `
SELECT
t_arch.id AS archive_transition_id,
t_arch.archive_id,
a.finding_id,
a.finding_title,
t_arch.reason AS current_reason,
DATE(t_arch.transitioned_at) AS archived_date,
DATE(t_ret.transitioned_at) AS returned_date,
JULIANDAY(t_ret.transitioned_at) - JULIANDAY(t_arch.transitioned_at) AS days_between
FROM ivanti_archive_transitions t_arch
JOIN ivanti_finding_archives a ON a.id = t_arch.archive_id
JOIN ivanti_archive_transitions t_ret
ON t_ret.archive_id = t_arch.archive_id
AND t_ret.to_state = 'RETURNED'
AND t_ret.transitioned_at > t_arch.transitioned_at
WHERE t_arch.to_state = 'ARCHIVED'
AND t_arch.reason = 'severity_score_drift'
AND (JULIANDAY(t_ret.transitioned_at) - JULIANDAY(t_arch.transitioned_at)) BETWEEN 0 AND ?
ORDER BY t_arch.transitioned_at DESC
`, [ROUNDTRIP_WINDOW_DAYS]);
if (roundtrips.length === 0) {
console.log('No BU reassignment round-trips found to reclassify.');
db.close();
return;
}
console.log(`Found ${roundtrips.length} archive transitions to reclassify as bu_reassignment:\n`);
let updated = 0;
for (const rt of roundtrips) {
console.log(` Finding ${rt.finding_id}: archived ${rt.archived_date}, returned ${rt.returned_date} (${Math.round(rt.days_between)}d) — ${rt.current_reason} → bu_reassignment`);
await dbRun(db,
`UPDATE ivanti_archive_transitions SET reason = 'bu_reassignment' WHERE id = ?`,
[rt.archive_transition_id]
);
updated++;
}
console.log(`\nReclassified ${updated} transitions.`);
console.log('\nNow run the return classification backfill to update anomaly log rows:');
console.log(' node backend/migrations/backfill_return_classification.js');
db.close();
}
main().catch(err => {
console.error('Fatal error:', err);
process.exit(1);
});

View File

@@ -554,6 +554,9 @@ function createAtlasRouter(db, requireAuth) {
try {
const result = await atlasPost('/ivanti-vulnerabilities-by-host', { host_ids }, { timeout: 30000 });
console.log('[Atlas] POST /ivanti-vulnerabilities-by-host status:', result.status, 'body length:', result.body?.length);
console.log('[Atlas] Response preview:', result.body?.substring(0, 500));
if (result.status >= 200 && result.status < 300) {
let body;
try {

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,39 @@ 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 *before* this return
const transition = await dbGet(db,
`SELECT reason FROM ivanti_archive_transitions
WHERE archive_id = ? AND to_state = 'ARCHIVED'
AND transitioned_at <= datetime('now')
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 +781,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 +830,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 +1079,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 +1242,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 +1260,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 +1291,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 +1300,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 +1309,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 +1318,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

@@ -1,343 +0,0 @@
#!/usr/bin/env node
// ==========================================================================
// Jira UAT Test Script
// ==========================================================================
// Exercises every Jira REST API use case the STEAM Dashboard will run in
// production. Run this against the UAT instance before submitting the
// ATLSUP Rest API Approval ticket.
//
// Usage:
// cd backend
// node scripts/jira-uat-test.js
//
// Prerequisites:
// - backend/.env has JIRA_BASE_URL pointing to UAT
// - JIRA_API_USER / JIRA_API_TOKEN set to service account credentials
// - JIRA_PROJECT_KEY set to a UAT project your service account can access
// - Service account has been granted access to the target space by space owners
//
// The script logs every API call, response status, and timing to both
// console and a log file at backend/scripts/jira-uat-test.log for the
// ATLSUP reviewers.
// ==========================================================================
require('dotenv').config({ path: require('path').join(__dirname, '..', '.env') });
const fs = require('fs');
const path = require('path');
const jiraApi = require('../helpers/jiraApi');
const LOG_FILE = path.join(__dirname, 'jira-uat-test.log');
const results = [];
let createdIssueKey = 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);
console.log(' ' + dataStr.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: Connection Test (GET /rest/api/2/myself)
// Production use: Admin clicks "Test Connection" button on Jira settings panel
// ---------------------------------------------------------------------------
async function testConnection() {
const result = await jiraApi.testConnection();
assert(result.ok, 'Connection test should succeed. Got: ' + JSON.stringify(result));
assert(result.user && result.user.name, 'Should return authenticated user name');
logInfo('Authenticated as:', result.user);
}
// ---------------------------------------------------------------------------
// Use Case 2: Create Issue (POST /rest/api/2/issue)
// Production use: User clicks "Create in Jira" from CVE detail panel
// ---------------------------------------------------------------------------
async function testCreateIssue() {
const projectKey = jiraApi.JIRA_PROJECT_KEY;
assert(projectKey, 'JIRA_PROJECT_KEY must be set in .env');
const fields = {
project: { key: projectKey },
summary: `[UAT TEST] STEAM Dashboard - CVE-2025-0001 - TestVendor - ${new Date().toISOString()}`,
issuetype: { name: jiraApi.JIRA_ISSUE_TYPE || 'Task' },
description: 'Automated UAT test issue created by STEAM Security Dashboard Jira integration test script. This issue can be safely deleted after review.'
};
logInfo('Creating issue with fields:', { project: fields.project, summary: fields.summary, issuetype: fields.issuetype });
const result = await jiraApi.createIssue(fields);
assert(result.ok, 'Create issue should succeed. Got: ' + JSON.stringify(result));
assert(result.data && result.data.key, 'Should return issue key');
createdIssueKey = result.data.key;
logInfo('Created issue:', { key: createdIssueKey, id: result.data.id, self: result.data.self });
}
// ---------------------------------------------------------------------------
// Use Case 3: Get Single Issue (GET /rest/api/2/issue/{key}?fields=...)
// Production use: User clicks "Sync" on a single Jira ticket row
// ---------------------------------------------------------------------------
async function testGetIssue() {
assert(createdIssueKey, 'Need a created issue key from previous test');
const result = await jiraApi.getIssue(createdIssueKey);
assert(result.ok, 'Get issue should succeed. Got: ' + JSON.stringify(result));
const issue = result.data;
assert(issue.key === createdIssueKey, 'Returned key should match');
assert(issue.fields && issue.fields.summary, 'Should have summary field');
assert(issue.fields.status, 'Should have status field');
logInfo('Fetched issue:', {
key: issue.key,
summary: issue.fields.summary,
status: issue.fields.status.name,
issuetype: issue.fields.issuetype ? issue.fields.issuetype.name : null
});
}
// ---------------------------------------------------------------------------
// Use Case 4: Update Issue (PUT /rest/api/2/issue/{key})
// Production use: Local ticket edits synced back to Jira (future feature)
// ---------------------------------------------------------------------------
async function testUpdateIssue() {
assert(createdIssueKey, 'Need a created issue key from previous test');
const result = await jiraApi.updateIssue(createdIssueKey, {
summary: `[UAT TEST] STEAM Dashboard - UPDATED - ${new Date().toISOString()}`
});
assert(result.ok, 'Update issue should succeed (204). Got: ' + JSON.stringify(result));
logInfo('Updated issue summary successfully');
}
// ---------------------------------------------------------------------------
// Use Case 5: Add Comment (POST /rest/api/2/issue/{key}/comment)
// Production use: Dashboard adds audit trail comments to linked Jira tickets
// ---------------------------------------------------------------------------
async function testAddComment() {
assert(createdIssueKey, 'Need a created issue key from previous test');
const commentBody = `STEAM Dashboard UAT test comment.\nTimestamp: ${new Date().toISOString()}\nThis comment was created by the automated test script.`;
const result = await jiraApi.addComment(createdIssueKey, commentBody);
assert(result.ok, 'Add comment should succeed. Got: ' + JSON.stringify(result));
assert(result.data && result.data.id, 'Should return comment ID');
logInfo('Added comment:', { commentId: result.data.id });
}
// ---------------------------------------------------------------------------
// Use Case 6: Get Transitions (GET /rest/api/2/issue/{key}/transitions)
// Production use: Dashboard checks available workflow transitions before
// attempting to move a ticket to a new status
// ---------------------------------------------------------------------------
async function testGetTransitions() {
assert(createdIssueKey, 'Need a created issue key from previous test');
const result = await jiraApi.getTransitions(createdIssueKey);
assert(result.ok, 'Get transitions should succeed. Got: ' + JSON.stringify(result));
const transitions = result.data.transitions || [];
logInfo('Available transitions:', transitions.map(t => ({ id: t.id, name: t.name, to: t.to ? t.to.name : null })));
// Store for the transition test
return transitions;
}
// ---------------------------------------------------------------------------
// Use Case 7: Transition Issue (POST /rest/api/2/issue/{key}/transitions)
// Production use: Dashboard moves ticket status (e.g., Open → In Progress)
// ---------------------------------------------------------------------------
async function testTransitionIssue(transitions) {
assert(createdIssueKey, 'Need a created issue key from previous test');
if (!transitions || transitions.length === 0) {
logWarn('No transitions available — skipping transition test. This may be expected depending on the project workflow.');
return;
}
// Pick the first available transition
const transition = transitions[0];
logInfo(`Transitioning to: ${transition.name} (id: ${transition.id})`);
const result = await jiraApi.transitionIssue(createdIssueKey, transition.id);
assert(result.ok, 'Transition should succeed (204). Got: ' + JSON.stringify(result));
logInfo('Transition successful');
}
// ---------------------------------------------------------------------------
// Use Case 8: JQL Search (POST /rest/api/2/search)
// Production use: Bulk sync — fetches all tracked tickets in one request
// instead of one GET per ticket (Charter-compliant)
// ---------------------------------------------------------------------------
async function testJqlSearch() {
const projectKey = jiraApi.JIRA_PROJECT_KEY;
assert(projectKey, 'JIRA_PROJECT_KEY must be set');
const jql = `project = ${projectKey} AND updated >= -1h ORDER BY updated DESC`;
logInfo('Searching with JQL:', jql);
const result = await jiraApi.searchIssues(jql, { maxResults: 10 });
assert(result.ok, 'Search should succeed. Got: ' + JSON.stringify(result));
const data = result.data;
logInfo('Search results:', {
total: data.total,
returned: (data.issues || []).length,
issues: (data.issues || []).slice(0, 5).map(i => ({
key: i.key,
summary: i.fields.summary,
status: i.fields.status ? i.fields.status.name : null
}))
});
}
// ---------------------------------------------------------------------------
// Use Case 9: Bulk Key Search (searchIssuesByKeys)
// Production use: sync-all endpoint — fetches multiple tickets by key
// in a single JQL query
// ---------------------------------------------------------------------------
async function testBulkKeySearch() {
assert(createdIssueKey, 'Need a created issue key from previous test');
// Search for the issue we created plus a fake key to test partial results
const keys = [createdIssueKey, 'FAKE-99999'];
logInfo('Bulk searching keys:', keys);
const result = await jiraApi.searchIssuesByKeys(keys);
assert(result.ok, 'Bulk key search should succeed. Got: ' + JSON.stringify(result));
const found = (result.data.issues || []).map(i => i.key);
logInfo('Found issues:', found);
assert(found.includes(createdIssueKey), 'Should find the created issue');
}
// ---------------------------------------------------------------------------
// Use Case 10: Rate Limit Status Check
// Production use: Admin views rate limit usage on the Jira settings panel
// ---------------------------------------------------------------------------
async function testRateLimitStatus() {
const status = jiraApi.getRateLimitStatus();
assert(status.daily && typeof status.daily.used === 'number', 'Should have daily usage');
assert(status.burst && typeof status.burst.used === 'number', 'Should have burst usage');
logInfo('Rate limit status after all tests:', status);
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
async function main() {
logInfo('=== STEAM Dashboard — Jira UAT Test Run ===');
logInfo('Timestamp: ' + new Date().toISOString());
logInfo('JIRA_BASE_URL: ' + (process.env.JIRA_BASE_URL || '(not set)'));
logInfo('JIRA_AUTH_METHOD: ' + (process.env.JIRA_AUTH_METHOD || 'basic'));
logInfo('JIRA_API_USER: ' + (process.env.JIRA_API_USER || '(not set)'));
logInfo('JIRA_PROJECT_KEY: ' + (jiraApi.JIRA_PROJECT_KEY || '(not set)'));
logInfo('JIRA_ISSUE_TYPE: ' + (jiraApi.JIRA_ISSUE_TYPE || 'Task'));
logInfo('JIRA_SKIP_TLS: ' + (process.env.JIRA_SKIP_TLS || 'false'));
logInfo('isConfigured: ' + jiraApi.isConfigured);
logInfo('');
if (!jiraApi.isConfigured) {
logFail('Pre-flight check', 'Jira API is not configured. Set JIRA_BASE_URL, JIRA_API_USER, and JIRA_API_TOKEN in backend/.env');
writeLog();
process.exit(1);
}
let passed = 0;
let failed = 0;
let transitions = [];
// Run tests in order — later tests depend on the created issue
if (await runTest('1. Connection Test (GET /myself)', testConnection)) passed++; else failed++;
if (await runTest('2. Create Issue (POST /issue)', testCreateIssue)) passed++; else failed++;
if (await runTest('3. Get Single Issue (GET /issue/{key})', testGetIssue)) passed++; else failed++;
if (await runTest('4. Update Issue (PUT /issue/{key})', testUpdateIssue)) passed++; else failed++;
if (await runTest('5. Add Comment (POST /issue/{key}/comment)', testAddComment)) passed++; else failed++;
if (await runTest('6. Get Transitions (GET /issue/{key}/transitions)', async () => {
transitions = await testGetTransitions();
})) passed++; else failed++;
if (await runTest('7. Transition Issue (POST /issue/{key}/transitions)', async () => {
await testTransitionIssue(transitions);
})) passed++; else failed++;
if (await runTest('8. JQL Search (POST /search)', testJqlSearch)) passed++; else failed++;
if (await runTest('9. Bulk Key Search (searchIssuesByKeys)', testBulkKeySearch)) passed++; else failed++;
if (await runTest('10. Rate Limit Status', testRateLimitStatus)) passed++; else failed++;
logInfo('');
logInfo('=== Summary ===');
logInfo(`Passed: ${passed} | Failed: ${failed} | Total: ${passed + failed}`);
if (createdIssueKey) {
logInfo(`Test issue created: ${createdIssueKey} — delete manually after ATLSUP review if desired.`);
}
logInfo('Rate limit usage:', jiraApi.getRateLimitStatus());
writeLog();
if (failed > 0) {
console.log('\nSome tests failed. Review the log above and jira-uat-test.log for details.');
process.exit(1);
} else {
console.log('\nAll tests passed. Log saved to backend/scripts/jira-uat-test.log');
console.log('Next steps:');
console.log(' 1. Submit an ATLSUP Rest API Approval ticket');
console.log(' 2. Attach or reference jira-uat-test.log in the ticket');
console.log(' 3. Click "Script ran - Review Logs" on the ATLSUP ticket');
process.exit(0);
}
}
function writeLog() {
const lines = results.map(r => {
let line = `[${r.timestamp}] ${r.level.toUpperCase().padEnd(5)} ${r.message}`;
if (r.data) {
line += '\n ' + (typeof r.data === 'string' ? r.data : JSON.stringify(r.data, null, 2)).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,8 +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;
@@ -238,6 +240,12 @@ app.use('/api/compliance', createComplianceRouter(db, upload, requireAuth, requi
// Atlas InfoSec action plan routes — proxy CRUD to Atlas API, local cache for badges
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)
@@ -1185,234 +1193,6 @@ app.get('/api/stats', requireAuth(db), (req, res) => {
});
});
// ========== JIRA TICKET ENDPOINTS ==========
// Get all JIRA tickets (with optional filters)
app.get('/api/jira-tickets', requireAuth(db), (req, res) => {
const { cve_id, vendor, status } = req.query;
let query = 'SELECT * FROM jira_tickets WHERE 1=1';
const params = [];
if (cve_id) {
query += ' AND cve_id = ?';
params.push(cve_id);
}
if (vendor) {
query += ' AND vendor = ?';
params.push(vendor);
}
if (status) {
query += ' AND status = ?';
params.push(status);
}
query += ' ORDER BY created_at DESC';
db.all(query, params, (err, rows) => {
if (err) {
console.error('Error fetching JIRA tickets:', err);
return res.status(500).json({ error: 'Internal server error.' });
}
res.json(rows);
});
});
// Create JIRA ticket
app.post('/api/jira-tickets', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
const { cve_id, vendor, ticket_key, url, summary, status } = req.body;
// Validation
if (!cve_id || !isValidCveId(cve_id)) {
return res.status(400).json({ error: 'Valid CVE ID is required.' });
}
if (!vendor || !isValidVendor(vendor)) {
return res.status(400).json({ error: 'Valid vendor is required.' });
}
if (!ticket_key || typeof ticket_key !== 'string' || ticket_key.trim().length === 0 || ticket_key.length > 50) {
return res.status(400).json({ error: 'Ticket key is required (max 50 chars).' });
}
if (url && (typeof url !== 'string' || url.length > 500)) {
return res.status(400).json({ error: 'URL must be under 500 characters.' });
}
if (summary && (typeof summary !== 'string' || summary.length > 500)) {
return res.status(400).json({ error: 'Summary must be under 500 characters.' });
}
if (status && !VALID_TICKET_STATUSES.includes(status)) {
return res.status(400).json({ error: `Status must be one of: ${VALID_TICKET_STATUSES.join(', ')}` });
}
const ticketStatus = status || 'Open';
const query = `
INSERT INTO jira_tickets (cve_id, vendor, ticket_key, url, summary, status, created_by)
VALUES (?, ?, ?, ?, ?, ?, ?)
`;
db.run(query, [cve_id, vendor, ticket_key.trim(), url || null, summary || null, ticketStatus, req.user.id], function(err) {
if (err) {
console.error('Error creating JIRA ticket:', err);
return res.status(500).json({ error: 'Internal server error.' });
}
logAudit(db, {
userId: req.user.id,
username: req.user.username,
action: 'jira_ticket_create',
entityType: 'jira_ticket',
entityId: this.lastID.toString(),
details: { cve_id, vendor, ticket_key, status: ticketStatus },
ipAddress: req.ip
});
res.status(201).json({
id: this.lastID,
message: 'JIRA ticket created successfully'
});
});
});
// Update JIRA ticket
app.put('/api/jira-tickets/:id', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
const { id } = req.params;
const { ticket_key, url, summary, status } = req.body;
// Validation
if (ticket_key !== undefined && (typeof ticket_key !== 'string' || ticket_key.trim().length === 0 || ticket_key.length > 50)) {
return res.status(400).json({ error: 'Ticket key must be under 50 chars.' });
}
if (url !== undefined && url !== null && (typeof url !== 'string' || url.length > 500)) {
return res.status(400).json({ error: 'URL must be under 500 characters.' });
}
if (summary !== undefined && summary !== null && (typeof summary !== 'string' || summary.length > 500)) {
return res.status(400).json({ error: 'Summary must be under 500 characters.' });
}
if (status !== undefined && !VALID_TICKET_STATUSES.includes(status)) {
return res.status(400).json({ error: `Status must be one of: ${VALID_TICKET_STATUSES.join(', ')}` });
}
// Build dynamic update
const fields = [];
const values = [];
if (ticket_key !== undefined) { fields.push('ticket_key = ?'); values.push(ticket_key.trim()); }
if (url !== undefined) { fields.push('url = ?'); values.push(url); }
if (summary !== undefined) { fields.push('summary = ?'); values.push(summary); }
if (status !== undefined) { fields.push('status = ?'); values.push(status); }
if (fields.length === 0) {
return res.status(400).json({ error: 'No fields to update.' });
}
fields.push('updated_at = CURRENT_TIMESTAMP');
values.push(id);
db.get('SELECT * FROM jira_tickets WHERE id = ?', [id], (err, existing) => {
if (err) {
console.error(err);
return res.status(500).json({ error: 'Internal server error.' });
}
if (!existing) {
return res.status(404).json({ error: 'JIRA ticket not found.' });
}
db.run(`UPDATE jira_tickets SET ${fields.join(', ')} WHERE id = ?`, values, function(updateErr) {
if (updateErr) {
console.error('Error updating JIRA ticket:', updateErr);
return res.status(500).json({ error: 'Internal server error.' });
}
logAudit(db, {
userId: req.user.id,
username: req.user.username,
action: 'jira_ticket_update',
entityType: 'jira_ticket',
entityId: id,
details: { before: existing, changes: req.body },
ipAddress: req.ip
});
res.json({ message: 'JIRA ticket updated successfully', changes: this.changes });
});
});
});
// Delete JIRA ticket
app.delete('/api/jira-tickets/:id', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
const { id } = req.params;
db.get('SELECT * FROM jira_tickets WHERE id = ?', [id], (err, ticket) => {
if (err) {
console.error(err);
return res.status(500).json({ error: 'Internal server error.' });
}
if (!ticket) {
return res.status(404).json({ error: 'JIRA ticket not found.' });
}
// Admin bypasses all delete restrictions
if (req.user.group === 'Admin') {
return performJiraDelete();
}
// Standard_User: ownership check
if (ticket.created_by && ticket.created_by !== req.user.id) {
return res.status(403).json({ error: 'You can only delete resources you created' });
}
// Standard_User: compliance linkage check
const ticketKey = ticket.ticket_key;
db.all(
`SELECT ci.id, ci.extra_json
FROM compliance_items ci
JOIN compliance_uploads cu ON ci.upload_id = cu.id
WHERE ci.status = 'active' AND ci.extra_json LIKE ?`,
[`%${ticketKey}%`],
(compErr, compLinks) => {
// If compliance_items table doesn't exist yet, treat as no linkage
if (compErr && compErr.message && compErr.message.includes('no such table')) {
compLinks = [];
} else if (compErr) {
console.error(compErr);
return res.status(500).json({ error: 'Internal server error.' });
}
const isLinked = (compLinks || []).some(cl => {
const json = cl.extra_json || '';
return json.includes(ticketKey);
});
if (isLinked) {
return res.status(403).json({ error: 'Cannot delete ticket linked to compliance report. Contact an admin.' });
}
return performJiraDelete();
}
);
function performJiraDelete() {
db.run('DELETE FROM jira_tickets WHERE id = ?', [id], function(deleteErr) {
if (deleteErr) {
console.error('Error deleting JIRA ticket:', deleteErr);
return res.status(500).json({ error: 'Internal server error.' });
}
logAudit(db, {
userId: req.user.id,
username: req.user.username,
action: 'jira_ticket_delete',
entityType: 'jira_ticket',
entityId: id,
details: { ticket_key: ticket.ticket_key, cve_id: ticket.cve_id, vendor: ticket.vendor },
ipAddress: req.ip
});
res.json({ message: 'JIRA ticket deleted successfully' });
});
}
});
});
// Start server
app.listen(PORT, () => {
console.log(`CVE API server running on http://${API_HOST}:${PORT}`);

View File

@@ -1,5 +1,12 @@
// Setup Script for CVE Database
// This creates a fresh database with multi-vendor support built-in
// Setup Script for CVE Dashboard v1.0.0
// Creates a fresh database with the complete schema including all tables,
// indexes, triggers, and views needed for a new deployment.
//
// Usage: node backend/setup.js
//
// This consolidates the original schema plus all migration scripts into a
// single idempotent setup. Migration scripts in backend/migrations/ are
// retained for reference but are NOT needed on fresh deployments.
const sqlite3 = require('sqlite3').verbose();
const bcrypt = require('bcryptjs');
@@ -7,334 +14,628 @@ const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
const DB_FILE = './cve_database.db';
const UPLOADS_DIR = './uploads';
const DB_FILE = path.join(__dirname, 'cve_database.db');
const UPLOADS_DIR = path.join(__dirname, 'uploads');
// Initialize database with schema
function initializeDatabase() {
// ---------------------------------------------------------------------------
// Database helpers
// ---------------------------------------------------------------------------
function dbRun(db, sql, params = []) {
return new Promise((resolve, reject) => {
const db = new sqlite3.Database(DB_FILE, (err) => {
db.run(sql, params, function (err) {
if (err) reject(err);
});
const schema = `
CREATE TABLE IF NOT EXISTS cves (
id INTEGER PRIMARY KEY AUTOINCREMENT,
cve_id VARCHAR(20) NOT NULL,
vendor VARCHAR(100) NOT NULL,
severity VARCHAR(20) NOT NULL,
description TEXT,
published_date DATE,
status VARCHAR(50) DEFAULT 'Open',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(cve_id, vendor)
);
CREATE TABLE IF NOT EXISTS documents (
id INTEGER PRIMARY KEY AUTOINCREMENT,
cve_id VARCHAR(20) NOT NULL,
vendor VARCHAR(100) NOT NULL,
name VARCHAR(255) NOT NULL,
type VARCHAR(50) NOT NULL,
file_path VARCHAR(500) NOT NULL,
file_size VARCHAR(20),
mime_type VARCHAR(100),
uploaded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
notes TEXT,
FOREIGN KEY (cve_id) REFERENCES cves(cve_id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS required_documents (
id INTEGER PRIMARY KEY AUTOINCREMENT,
vendor VARCHAR(100) NOT NULL,
document_type VARCHAR(50) NOT NULL,
is_mandatory BOOLEAN DEFAULT 1,
description TEXT
);
CREATE INDEX IF NOT EXISTS idx_cve_id ON cves(cve_id);
CREATE INDEX IF NOT EXISTS idx_vendor ON cves(vendor);
CREATE INDEX IF NOT EXISTS idx_severity ON cves(severity);
CREATE INDEX IF NOT EXISTS idx_status ON cves(status);
CREATE INDEX IF NOT EXISTS idx_doc_cve_id ON documents(cve_id);
CREATE INDEX IF NOT EXISTS idx_doc_vendor ON documents(vendor);
CREATE INDEX IF NOT EXISTS idx_doc_type ON documents(type);
-- Users table for authentication
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username VARCHAR(50) UNIQUE NOT NULL,
email VARCHAR(255) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL,
role VARCHAR(20) NOT NULL DEFAULT 'viewer',
is_active BOOLEAN DEFAULT 1,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_login TIMESTAMP,
CHECK (role IN ('admin', 'editor', 'viewer'))
);
-- Sessions table for session management
CREATE TABLE IF NOT EXISTS sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id VARCHAR(255) UNIQUE NOT NULL,
user_id INTEGER NOT NULL,
expires_at TIMESTAMP NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_sessions_session_id ON sessions(session_id);
CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id);
CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions(expires_at);
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
-- Audit log table for tracking user actions
CREATE TABLE IF NOT EXISTS audit_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER,
username VARCHAR(50) NOT NULL,
action VARCHAR(50) NOT NULL,
entity_type VARCHAR(50) NOT NULL,
entity_id VARCHAR(100),
details TEXT,
ip_address VARCHAR(45),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_audit_user_id ON audit_logs(user_id);
CREATE INDEX IF NOT EXISTS idx_audit_action ON audit_logs(action);
CREATE INDEX IF NOT EXISTS idx_audit_entity_type ON audit_logs(entity_type);
CREATE INDEX IF NOT EXISTS idx_audit_created_at ON audit_logs(created_at);
INSERT OR IGNORE INTO required_documents (vendor, document_type, is_mandatory, description) VALUES
('Microsoft', 'advisory', 1, 'Official Microsoft Security Advisory'),
('Microsoft', 'screenshot', 0, 'Proof of patch application'),
('Cisco', 'advisory', 1, 'Cisco Security Advisory'),
('Oracle', 'advisory', 1, 'Oracle Security Alert'),
('VMware', 'advisory', 1, 'VMware Security Advisory'),
('Adobe', 'advisory', 1, 'Adobe Security Bulletin');
CREATE VIEW IF NOT EXISTS cve_document_status AS
SELECT
c.id as record_id,
c.cve_id,
c.vendor,
c.severity,
c.status,
COUNT(DISTINCT d.id) as total_documents,
COUNT(DISTINCT CASE WHEN d.type = 'advisory' THEN d.id END) as advisory_count,
COUNT(DISTINCT CASE WHEN d.type = 'email' THEN d.id END) as email_count,
COUNT(DISTINCT CASE WHEN d.type = 'screenshot' THEN d.id END) as screenshot_count,
CASE
WHEN COUNT(DISTINCT CASE WHEN d.type = 'advisory' THEN d.id END) > 0
THEN 'Complete'
ELSE 'Missing Required Docs'
END as compliance_status
FROM cves c
LEFT JOIN documents d ON c.cve_id = d.cve_id AND c.vendor = d.vendor
GROUP BY c.id, c.cve_id, c.vendor, c.severity, c.status;
`;
db.exec(schema, (err) => {
if (err) {
reject(err);
} else {
console.log('✓ Database initialized successfully');
resolve(db);
}
else resolve(this);
});
});
}
// Create uploads directory structure
function createUploadsDirectory() {
if (!fs.existsSync(UPLOADS_DIR)) {
fs.mkdirSync(UPLOADS_DIR, { recursive: true });
console.log('✓ Created uploads directory');
} else {
console.log('✓ Uploads directory already exists');
}
}
// Create default admin user
async function createDefaultAdmin(db) {
function dbGet(db, sql, params = []) {
return new Promise((resolve, reject) => {
// Check if admin already exists
db.get('SELECT id FROM users WHERE username = ?', ['admin'], async (err, row) => {
if (err) {
reject(err);
return;
}
if (row) {
console.log('✓ Default admin user already exists');
resolve();
return;
}
// Generate a random admin password on first run
const generatedPassword = crypto.randomBytes(12).toString('base64url');
const passwordHash = await bcrypt.hash(generatedPassword, 10);
db.run(
`INSERT INTO users (username, email, password_hash, role, is_active)
VALUES (?, ?, ?, ?, ?)`,
['admin', 'admin@localhost', passwordHash, 'admin', 1],
(err) => {
if (err) {
reject(err);
} else {
console.log('✓ Created default admin user');
console.log(`\n ╔══════════════════════════════════════════╗`);
console.log(` ║ Admin credentials (save these now!) ║`);
console.log(` ║ Username: admin ║`);
console.log(` ║ Password: ${generatedPassword.padEnd(29)}`);
console.log(` ╚══════════════════════════════════════════╝\n`);
resolve();
}
}
);
db.get(sql, params, (err, row) => {
if (err) reject(err);
else resolve(row);
});
});
}
// Add sample CVE data (optional - for testing)
async function addSampleData(db) {
console.log('\n📝 Adding sample CVE data for testing...');
const sampleCVEs = [
{
cve_id: 'CVE-2024-SAMPLE-1',
vendor: 'Microsoft',
severity: 'Critical',
description: 'Sample remote code execution vulnerability',
published_date: '2024-01-15'
},
{
cve_id: 'CVE-2024-SAMPLE-1',
vendor: 'Cisco',
severity: 'High',
description: 'Sample remote code execution vulnerability',
published_date: '2024-01-15'
}
function dbExec(db, sql) {
return new Promise((resolve, reject) => {
db.exec(sql, (err) => {
if (err) reject(err);
else resolve();
});
});
}
// ---------------------------------------------------------------------------
// Schema — complete v1.0.0 database structure
// ---------------------------------------------------------------------------
async function initializeDatabase(db) {
await dbExec(db, `
-- =================================================================
-- Core CVE tracking tables
-- =================================================================
CREATE TABLE IF NOT EXISTS cves (
id INTEGER PRIMARY KEY AUTOINCREMENT,
cve_id VARCHAR(20) NOT NULL,
vendor VARCHAR(100) NOT NULL,
severity VARCHAR(20) NOT NULL,
description TEXT,
published_date DATE,
status VARCHAR(50) DEFAULT 'Open',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(cve_id, vendor)
);
CREATE TABLE IF NOT EXISTS documents (
id INTEGER PRIMARY KEY AUTOINCREMENT,
cve_id VARCHAR(20) NOT NULL,
vendor VARCHAR(100) NOT NULL,
name VARCHAR(255) NOT NULL,
type VARCHAR(50) NOT NULL,
file_path VARCHAR(500) NOT NULL,
file_size VARCHAR(20),
mime_type VARCHAR(100),
uploaded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
notes TEXT,
FOREIGN KEY (cve_id) REFERENCES cves(cve_id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS required_documents (
id INTEGER PRIMARY KEY AUTOINCREMENT,
vendor VARCHAR(100) NOT NULL,
document_type VARCHAR(50) NOT NULL,
is_mandatory BOOLEAN DEFAULT 1,
description TEXT
);
CREATE INDEX IF NOT EXISTS idx_cve_id ON cves(cve_id);
CREATE INDEX IF NOT EXISTS idx_vendor ON cves(vendor);
CREATE INDEX IF NOT EXISTS idx_severity ON cves(severity);
CREATE INDEX IF NOT EXISTS idx_status ON cves(status);
CREATE INDEX IF NOT EXISTS idx_doc_cve_id ON documents(cve_id);
CREATE INDEX IF NOT EXISTS idx_doc_vendor ON documents(vendor);
CREATE INDEX IF NOT EXISTS idx_doc_type ON documents(type);
-- =================================================================
-- Authentication and session management
-- =================================================================
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username VARCHAR(50) UNIQUE NOT NULL,
email VARCHAR(255) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL,
role VARCHAR(20) NOT NULL DEFAULT 'viewer',
is_active BOOLEAN DEFAULT 1,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_login TIMESTAMP,
user_group VARCHAR(20) NOT NULL DEFAULT 'Read_Only',
CHECK (role IN ('admin', 'editor', 'viewer'))
);
CREATE TABLE IF NOT EXISTS sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id VARCHAR(255) UNIQUE NOT NULL,
user_id INTEGER NOT NULL,
expires_at TIMESTAMP NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_sessions_session_id ON sessions(session_id);
CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id);
CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions(expires_at);
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
CREATE INDEX IF NOT EXISTS idx_users_user_group ON users(user_group);
-- =================================================================
-- Audit logging
-- =================================================================
CREATE TABLE IF NOT EXISTS audit_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER,
username VARCHAR(50) NOT NULL,
action VARCHAR(50) NOT NULL,
entity_type VARCHAR(50) NOT NULL,
entity_id VARCHAR(100),
details TEXT,
ip_address VARCHAR(45),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_audit_user_id ON audit_logs(user_id);
CREATE INDEX IF NOT EXISTS idx_audit_action ON audit_logs(action);
CREATE INDEX IF NOT EXISTS idx_audit_entity_type ON audit_logs(entity_type);
CREATE INDEX IF NOT EXISTS idx_audit_created_at ON audit_logs(created_at);
-- =================================================================
-- Jira integration
-- =================================================================
CREATE TABLE IF NOT EXISTS jira_tickets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
cve_id TEXT NOT NULL,
vendor TEXT NOT NULL,
ticket_key TEXT NOT NULL,
url TEXT,
summary TEXT,
status TEXT DEFAULT 'Open' CHECK(status IN ('Open', 'In Progress', 'Closed')),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (cve_id, vendor) REFERENCES cves(cve_id, vendor) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_jira_tickets_cve ON jira_tickets(cve_id, vendor);
CREATE INDEX IF NOT EXISTS idx_jira_tickets_status ON jira_tickets(status);
-- =================================================================
-- Archer integration
-- =================================================================
CREATE TABLE IF NOT EXISTS archer_tickets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
exc_number TEXT NOT NULL UNIQUE,
archer_url TEXT,
status TEXT DEFAULT 'Draft' CHECK(status IN ('Draft', 'Open', 'Under Review', 'Accepted')),
cve_id TEXT NOT NULL,
vendor TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (cve_id, vendor) REFERENCES cves(cve_id, vendor) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_archer_tickets_cve ON archer_tickets(cve_id, vendor);
CREATE INDEX IF NOT EXISTS idx_archer_tickets_status ON archer_tickets(status);
CREATE INDEX IF NOT EXISTS idx_archer_tickets_exc ON archer_tickets(exc_number);
-- =================================================================
-- Knowledge base
-- =================================================================
CREATE TABLE IF NOT EXISTS knowledge_base (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title VARCHAR(255) NOT NULL,
slug VARCHAR(255) UNIQUE NOT NULL,
description TEXT,
category VARCHAR(100),
file_path VARCHAR(500),
file_name VARCHAR(255),
file_type VARCHAR(50),
file_size INTEGER,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
created_by INTEGER,
FOREIGN KEY (created_by) REFERENCES users(id)
);
CREATE INDEX IF NOT EXISTS idx_knowledge_base_slug ON knowledge_base(slug);
CREATE INDEX IF NOT EXISTS idx_knowledge_base_category ON knowledge_base(category);
CREATE INDEX IF NOT EXISTS idx_knowledge_base_created_at ON knowledge_base(created_at DESC);
-- =================================================================
-- Ivanti findings sync and cache
-- =================================================================
CREATE TABLE IF NOT EXISTS ivanti_sync_state (
id INTEGER PRIMARY KEY CHECK (id = 1),
total INTEGER DEFAULT 0,
workflows_json TEXT DEFAULT '[]',
synced_at DATETIME,
sync_status TEXT DEFAULT 'never',
error_message TEXT
);
CREATE TABLE IF NOT EXISTS ivanti_findings_cache (
id INTEGER PRIMARY KEY CHECK (id = 1),
total INTEGER DEFAULT 0,
findings_json TEXT DEFAULT '[]',
synced_at DATETIME,
sync_status TEXT DEFAULT 'never',
error_message TEXT
);
CREATE TABLE IF NOT EXISTS ivanti_finding_notes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
finding_id TEXT NOT NULL UNIQUE,
note TEXT NOT NULL DEFAULT '',
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_finding_notes_finding_id ON ivanti_finding_notes(finding_id);
CREATE TABLE IF NOT EXISTS ivanti_counts_cache (
id INTEGER PRIMARY KEY CHECK (id = 1),
open_count INTEGER DEFAULT 0,
closed_count INTEGER DEFAULT 0,
synced_at DATETIME,
fp_workflow_counts_json TEXT DEFAULT '{}',
fp_id_counts_json TEXT DEFAULT '{}'
);
CREATE TABLE IF NOT EXISTS ivanti_finding_overrides (
id INTEGER PRIMARY KEY AUTOINCREMENT,
finding_id TEXT NOT NULL,
field TEXT NOT NULL,
value TEXT NOT NULL DEFAULT '',
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(finding_id, field)
);
CREATE INDEX IF NOT EXISTS idx_finding_overrides_finding_id ON ivanti_finding_overrides(finding_id);
CREATE TABLE IF NOT EXISTS ivanti_counts_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
open_count INTEGER NOT NULL,
closed_count INTEGER NOT NULL,
recorded_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- =================================================================
-- Ivanti FP (False Positive) submissions
-- =================================================================
CREATE TABLE IF NOT EXISTS ivanti_fp_submissions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
username TEXT NOT NULL,
ivanti_workflow_batch_id INTEGER,
ivanti_generated_id TEXT,
ivanti_workflow_batch_uuid TEXT,
workflow_name TEXT NOT NULL,
reason TEXT NOT NULL,
description TEXT,
expiration_date TEXT NOT NULL,
scope_override TEXT NOT NULL DEFAULT 'Authorized',
finding_ids_json TEXT NOT NULL,
queue_item_ids_json TEXT NOT NULL,
attachment_count INTEGER DEFAULT 0,
attachment_results_json TEXT,
status TEXT NOT NULL DEFAULT 'success' CHECK(status IN ('success', 'partial', 'failed')),
lifecycle_status TEXT NOT NULL DEFAULT 'submitted' CHECK(lifecycle_status IN ('submitted', 'approved', 'rejected', 'rework', 'resubmitted')),
error_message TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT NULL
);
CREATE INDEX IF NOT EXISTS idx_fp_submissions_user ON ivanti_fp_submissions(user_id);
CREATE INDEX IF NOT EXISTS idx_fp_submissions_ivanti_id ON ivanti_fp_submissions(ivanti_generated_id);
CREATE TABLE IF NOT EXISTS ivanti_fp_submission_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
submission_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
username TEXT NOT NULL,
change_type TEXT NOT NULL CHECK(change_type IN (
'created', 'fields_updated', 'findings_added',
'attachments_added', 'status_changed'
)),
change_details_json TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (submission_id) REFERENCES ivanti_fp_submissions(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_fp_history_submission ON ivanti_fp_submission_history(submission_id);
-- =================================================================
-- Ivanti todo queue (FP, Archer, CARD, GRANITE workflows)
-- =================================================================
CREATE TABLE IF NOT EXISTS ivanti_todo_queue (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
finding_id TEXT NOT NULL,
finding_title TEXT,
cves_json TEXT,
ip_address TEXT,
hostname TEXT,
vendor TEXT NOT NULL,
workflow_type TEXT NOT NULL CHECK(workflow_type IN ('FP', 'Archer', 'CARD', 'GRANITE')),
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending', 'complete')),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_todo_queue_user ON ivanti_todo_queue(user_id, status);
-- =================================================================
-- Ivanti archive detection and anomaly tracking
-- =================================================================
CREATE TABLE IF NOT EXISTS ivanti_finding_archives (
id INTEGER PRIMARY KEY AUTOINCREMENT,
finding_id TEXT NOT NULL UNIQUE,
finding_title TEXT NOT NULL DEFAULT '',
host_name TEXT NOT NULL DEFAULT '',
ip_address TEXT NOT NULL DEFAULT '',
current_state TEXT NOT NULL CHECK(current_state IN ('ARCHIVED','RETURNED','CLOSED','CLOSED_GONE')),
last_severity REAL NOT NULL DEFAULT 0,
first_archived_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_transition_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_archive_finding_id ON ivanti_finding_archives(finding_id);
CREATE INDEX IF NOT EXISTS idx_archive_current_state ON ivanti_finding_archives(current_state);
CREATE TABLE IF NOT EXISTS ivanti_archive_transitions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
archive_id INTEGER NOT NULL,
from_state TEXT NOT NULL,
to_state TEXT NOT NULL,
severity_at_transition REAL NOT NULL DEFAULT 0,
reason TEXT NOT NULL DEFAULT '',
transitioned_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (archive_id) REFERENCES ivanti_finding_archives(id)
);
CREATE INDEX IF NOT EXISTS idx_transition_archive_id ON ivanti_archive_transitions(archive_id);
CREATE TABLE IF NOT EXISTS ivanti_sync_anomaly_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sync_timestamp DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
open_count_delta INTEGER NOT NULL DEFAULT 0,
closed_count_delta INTEGER NOT NULL DEFAULT 0,
newly_archived_count INTEGER NOT NULL DEFAULT 0,
returned_count INTEGER NOT NULL DEFAULT 0,
classification_json TEXT NOT NULL DEFAULT '{}',
return_classification_json TEXT NOT NULL DEFAULT '{}',
is_significant INTEGER NOT NULL DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_anomaly_sync_timestamp ON ivanti_sync_anomaly_log(sync_timestamp);
CREATE TABLE IF NOT EXISTS ivanti_finding_bu_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
finding_id TEXT NOT NULL,
finding_title TEXT NOT NULL DEFAULT '',
host_name TEXT NOT NULL DEFAULT '',
previous_bu TEXT NOT NULL,
new_bu TEXT NOT NULL,
detected_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_bu_history_finding_id ON ivanti_finding_bu_history(finding_id);
CREATE INDEX IF NOT EXISTS idx_bu_history_detected_at ON ivanti_finding_bu_history(detected_at);
-- =================================================================
-- Atlas action plans cache
-- =================================================================
CREATE TABLE IF NOT EXISTS atlas_action_plans_cache (
id INTEGER PRIMARY KEY AUTOINCREMENT,
host_id INTEGER NOT NULL UNIQUE,
has_action_plan INTEGER NOT NULL DEFAULT 0,
plan_count INTEGER NOT NULL DEFAULT 0,
plans_json TEXT NOT NULL DEFAULT '[]',
synced_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_atlas_cache_host_id ON atlas_action_plans_cache(host_id);
-- =================================================================
-- Compliance (NTS AEO) tracking
-- =================================================================
CREATE TABLE IF NOT EXISTS compliance_uploads (
id INTEGER PRIMARY KEY AUTOINCREMENT,
filename TEXT NOT NULL,
report_date TEXT,
uploaded_by INTEGER,
uploaded_at DATETIME DEFAULT CURRENT_TIMESTAMP,
new_count INTEGER DEFAULT 0,
resolved_count INTEGER DEFAULT 0,
recurring_count INTEGER DEFAULT 0,
summary_json TEXT,
FOREIGN KEY (uploaded_by) REFERENCES users(id) ON DELETE SET NULL
);
CREATE TABLE IF NOT EXISTS compliance_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
upload_id INTEGER NOT NULL,
hostname TEXT NOT NULL,
ip_address TEXT,
device_type TEXT,
team TEXT,
metric_id TEXT NOT NULL,
metric_desc TEXT,
category TEXT,
extra_json TEXT,
status TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active', 'resolved')),
first_seen_upload_id INTEGER,
resolved_upload_id INTEGER,
seen_count INTEGER DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (upload_id) REFERENCES compliance_uploads(id) ON DELETE CASCADE,
FOREIGN KEY (first_seen_upload_id) REFERENCES compliance_uploads(id) ON DELETE SET NULL,
FOREIGN KEY (resolved_upload_id) REFERENCES compliance_uploads(id) ON DELETE SET NULL
);
CREATE INDEX IF NOT EXISTS idx_compliance_items_upload ON compliance_items(upload_id);
CREATE INDEX IF NOT EXISTS idx_compliance_items_identity ON compliance_items(hostname, metric_id);
CREATE INDEX IF NOT EXISTS idx_compliance_items_team_status ON compliance_items(team, status);
CREATE TABLE IF NOT EXISTS compliance_notes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
hostname TEXT NOT NULL,
metric_id TEXT NOT NULL,
note TEXT NOT NULL,
group_id TEXT,
created_by INTEGER,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL
);
CREATE INDEX IF NOT EXISTS idx_compliance_notes_identity ON compliance_notes(hostname, metric_id);
CREATE INDEX IF NOT EXISTS idx_compliance_notes_group ON compliance_notes(group_id);
-- =================================================================
-- Document compliance view
-- =================================================================
CREATE VIEW IF NOT EXISTS cve_document_status AS
SELECT
c.id as record_id,
c.cve_id,
c.vendor,
c.severity,
c.status,
COUNT(DISTINCT d.id) as total_documents,
COUNT(DISTINCT CASE WHEN d.type = 'advisory' THEN d.id END) as advisory_count,
COUNT(DISTINCT CASE WHEN d.type = 'email' THEN d.id END) as email_count,
COUNT(DISTINCT CASE WHEN d.type = 'screenshot' THEN d.id END) as screenshot_count,
CASE
WHEN COUNT(DISTINCT CASE WHEN d.type = 'advisory' THEN d.id END) > 0
THEN 'Complete'
ELSE 'Missing Required Docs'
END as compliance_status
FROM cves c
LEFT JOIN documents d ON c.cve_id = d.cve_id AND c.vendor = d.vendor
GROUP BY c.id, c.cve_id, c.vendor, c.severity, c.status;
-- =================================================================
-- Seed data
-- =================================================================
INSERT OR IGNORE INTO required_documents (vendor, document_type, is_mandatory, description) VALUES
('Microsoft', 'advisory', 1, 'Official Microsoft Security Advisory'),
('Microsoft', 'screenshot', 0, 'Proof of patch application'),
('Cisco', 'advisory', 1, 'Cisco Security Advisory'),
('Oracle', 'advisory', 1, 'Oracle Security Alert'),
('VMware', 'advisory', 1, 'VMware Security Advisory'),
('Adobe', 'advisory', 1, 'Adobe Security Bulletin');
`);
console.log('✓ Database schema initialized');
// User group validation triggers (cannot be in db.exec multi-statement)
await dbRun(db, `
CREATE TRIGGER IF NOT EXISTS check_user_group_insert
BEFORE INSERT ON users
FOR EACH ROW
WHEN NEW.user_group NOT IN ('Admin', 'Standard_User', 'Leadership', 'Read_Only')
BEGIN
SELECT RAISE(ABORT, 'Invalid user_group value. Must be Admin, Standard_User, Leadership, or Read_Only');
END
`);
await dbRun(db, `
CREATE TRIGGER IF NOT EXISTS check_user_group_update
BEFORE UPDATE OF user_group ON users
FOR EACH ROW
WHEN NEW.user_group NOT IN ('Admin', 'Standard_User', 'Leadership', 'Read_Only')
BEGIN
SELECT RAISE(ABORT, 'Invalid user_group value. Must be Admin, Standard_User, Leadership, or Read_Only');
END
`);
console.log('✓ Triggers created');
}
// ---------------------------------------------------------------------------
// Directory setup
// ---------------------------------------------------------------------------
function createDirectories() {
const dirs = [
UPLOADS_DIR,
path.join(UPLOADS_DIR, 'temp'),
path.join(UPLOADS_DIR, 'knowledge_base'),
];
for (const cve of sampleCVEs) {
await new Promise((resolve, reject) => {
db.run(
`INSERT OR IGNORE INTO cves (cve_id, vendor, severity, description, published_date)
VALUES (?, ?, ?, ?, ?)`,
[cve.cve_id, cve.vendor, cve.severity, cve.description, cve.published_date],
(err) => {
if (err) reject(err);
else {
console.log(` ✓ Added sample: ${cve.cve_id} / ${cve.vendor}`);
resolve();
}
}
);
});
for (const dir of dirs) {
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
console.log(`✓ Created directory: ${path.relative(__dirname, dir)}`);
}
}
console.log(' Sample data added - demonstrates multi-vendor support');
}
// Verify database structure
async function verifySetup(db) {
return new Promise((resolve) => {
db.get('SELECT sql FROM sqlite_master WHERE type="table" AND name="cves"', (err, row) => {
if (err) {
console.error('Warning: Could not verify setup:', err);
} else {
console.log('\n📋 CVEs table structure:');
console.log(row.sql);
// Check if UNIQUE constraint is correct
if (row.sql.includes('UNIQUE(cve_id, vendor)')) {
console.log('\n✅ Multi-vendor support: ENABLED');
} else {
console.log('\n⚠ Warning: Multi-vendor constraint may not be set correctly');
}
}
resolve();
});
});
// ---------------------------------------------------------------------------
// Default admin user
// ---------------------------------------------------------------------------
async function createDefaultAdmin(db) {
const existing = await dbGet(db, 'SELECT id FROM users WHERE username = ?', ['admin']);
if (existing) {
console.log('✓ Default admin user already exists');
return;
}
const generatedPassword = crypto.randomBytes(12).toString('base64url');
const passwordHash = await bcrypt.hash(generatedPassword, 10);
await dbRun(db,
`INSERT INTO users (username, email, password_hash, role, user_group, is_active)
VALUES (?, ?, ?, ?, ?, ?)`,
['admin', 'admin@localhost', passwordHash, 'admin', 'Admin', 1]
);
console.log('✓ Created default admin user');
console.log(`\n ╔══════════════════════════════════════════╗`);
console.log(` ║ Admin credentials (save these now!) ║`);
console.log(` ║ Username: admin ║`);
console.log(` ║ Password: ${generatedPassword.padEnd(29)}`);
console.log(` ╚══════════════════════════════════════════╝\n`);
}
// Display setup summary
// ---------------------------------------------------------------------------
// Setup summary
// ---------------------------------------------------------------------------
function displaySummary() {
console.log('\n╔════════════════════════════════════════════════════════╗');
console.log('║ CVE DATABASE SETUP COMPLETE! ║');
console.log('║ CVE DASHBOARD v1.0.0 — SETUP COMPLETE ║');
console.log('╚════════════════════════════════════════════════════════╝');
console.log('\n📊 What was created:');
console.log(' ✓ SQLite database (cve_database.db)');
console.log(' ✓ Tables: cves, documents, required_documents, users, sessions, audit_logs');
console.log(' ✓ Multi-vendor support with UNIQUE(cve_id, vendor)');
console.log(' ✓ Vendor column in documents table');
console.log(' ✓ User authentication with session-based auth');
console.log(' ✓ Indexes for fast queries');
console.log(' ✓ Document compliance view');
console.log(' ✓ Uploads directory for file storage');
console.log(' ✓ Default admin user (see credentials above)');
console.log('\n📁 File structure will be:');
console.log(' uploads/');
console.log(' └── CVE-XXXX-XXXX/');
console.log(' ├── Vendor1/');
console.log(' │ ├── advisory.pdf');
console.log(' │ └── screenshot.png');
console.log(' └── Vendor2/');
console.log(' └── advisory.pdf');
console.log('\n📊 Tables created:');
console.log(' Core: cves, documents, required_documents');
console.log(' Auth: users, sessions');
console.log(' Audit: audit_logs');
console.log(' Jira: jira_tickets');
console.log(' Archer: archer_tickets');
console.log(' KB: knowledge_base');
console.log(' Ivanti: ivanti_sync_state, ivanti_findings_cache,');
console.log(' ivanti_finding_notes, ivanti_counts_cache,');
console.log(' ivanti_finding_overrides, ivanti_counts_history,');
console.log(' ivanti_fp_submissions, ivanti_fp_submission_history,');
console.log(' ivanti_todo_queue');
console.log(' Archives: ivanti_finding_archives, ivanti_archive_transitions,');
console.log(' ivanti_sync_anomaly_log, ivanti_finding_bu_history');
console.log(' Atlas: atlas_action_plans_cache');
console.log(' Compliance: compliance_uploads, compliance_items, compliance_notes');
console.log('\n🚀 Next steps:');
console.log(' 1. Start the backend API:');
console.log(' → cd backend && node server.js');
console.log(' 2. Start the frontend:');
console.log(' → cd frontend && npm start');
console.log(' 3. Open http://localhost:3000');
console.log(' 4. Start adding CVEs with multiple vendors!');
console.log('\n💡 Key Features:');
console.log(' • Add same CVE-ID with different vendors');
console.log(' • Each vendor has separate document storage');
console.log(' • Quick Check shows all vendors for a CVE');
console.log(' • Document compliance tracking per vendor');
console.log(' • Required docs: Advisory (mandatory for most vendors)\n');
console.log(' 1. Copy .env.example to .env and configure API keys');
console.log(' 2. Start the backend: node backend/server.js');
console.log(' 3. Build the frontend: cd frontend && npm run build');
console.log(' 4. Open the dashboard and log in with the admin credentials above\n');
}
// Main execution
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
async function main() {
console.log('🚀 CVE Database Setup (Multi-Vendor Support)\n');
console.log('🚀 CVE Dashboard v1.0.0 — Database Setup\n');
console.log('════════════════════════════════════════\n');
try {
// Create uploads directory
createUploadsDirectory();
// Initialize database
const db = await initializeDatabase();
// Create default admin user
try {
createDirectories();
const db = new sqlite3.Database(DB_FILE);
await initializeDatabase(db);
await createDefaultAdmin(db);
// Add sample data
await addSampleData(db);
// Verify setup
await verifySetup(db);
// Close database connection
db.close((err) => {
if (err) console.error('Error closing database:', err);
else console.log('\n✓ Database connection closed');
// Display summary
else console.log('✓ Database connection closed');
displaySummary();
});
} catch (error) {
console.error('❌ Setup Error:', error);
process.exit(1);
}
}
// Run the setup
main();

View File

@@ -19,8 +19,9 @@ All API calls are made from a single Node.js backend process. The integration us
| Inter-request delay — writes | 2 seconds minimum between PUT/POST/DELETE requests |
| Explicit field lists | Every GET includes `?fields=` parameter; `/rest/api/2/field` is blocked |
| No bulk updates | Issues are updated one at a time; `/rest/api/2/issue/bulk` is blocked |
| Bulk reads via JQL | Multi-ticket sync uses a single `POST /rest/api/2/search` with JQL, not per-issue GETs |
| JQL scoping | All recurring JQL queries include `updated >= -Xh` clause |
| Bulk reads via JQL | Multi-ticket sync uses a single `GET /rest/api/2/search` with JQL query parameters, not per-issue GETs |
| Single-issue fetch via JQL | `GET /rest/api/2/search?jql=key="KEY" AND project=<KEY>&fields=...&maxResults=1` |
| JQL scoping | All recurring JQL queries include `updated >= -Xh` clause and `project = <KEY>` scoping |
| `maxResults` cap | Search queries capped at 1 000 results per page |
---
@@ -52,11 +53,11 @@ All API calls are made from a single Node.js backend process. The integration us
| | |
|---|---|
| **Endpoint** | `GET /rest/api/2/issue/{issueKey}?fields=summary,status,assignee,created,updated,priority,issuetype,project,resolution` |
| **Endpoint** | `GET /rest/api/2/search?jql=key="ISSUE-KEY" AND project=<KEY>&fields=summary,status,assignee,created,updated,priority,issuetype,project,resolution&maxResults=1` |
| **Trigger** | User clicks "Sync" on a single Jira ticket row |
| **Frequency** | Manual, estimated 1030 per day |
| **Purpose** | Refresh a single ticket's status and summary from Jira |
| **Notes** | Fields are always specified explicitly per Charter requirement |
| **Purpose** | Refresh a single ticket's status and summary from Jira via JQL search |
| **Notes** | Uses JQL-based lookup instead of single-issue GET per Charter compliance. Fields are always specified explicitly. |
### 4. Update Issue
@@ -99,23 +100,23 @@ All API calls are made from a single Node.js backend process. The integration us
| | |
|---|---|
| **Endpoint** | `POST /rest/api/2/search` |
| **Endpoint** | `GET /rest/api/2/search?jql=...&fields=...&maxResults=...&startAt=...` |
| **Trigger** | Admin clicks "Sync All" on the Jira tickets panel |
| **Frequency** | Manual, estimated 13 times per day |
| **Purpose** | Bulk-refresh all tracked tickets in a single request instead of per-issue GETs |
| **JQL pattern** | `key in ("KEY-1", "KEY-2", ...) AND updated >= -24h` |
| **JQL pattern** | `key in ("KEY-1", "KEY-2", ...) AND updated >= -24h AND project = <KEY>` |
| **Fields requested** | `summary, status, assignee, created, updated, priority, issuetype, project, resolution` |
| **Batch size** | 100 keys per JQL query; multiple batches if needed |
| **Notes** | Stops early if rate limit budget is running low (burst remaining <= 5 or daily remaining <= 10) |
| **Notes** | Uses GET with URL-encoded query parameters per Charter compliance. Stops early if rate limit budget is running low (burst remaining <= 5 or daily remaining <= 10) |
### 9. Issue Lookup
| | |
|---|---|
| **Endpoint** | `GET /rest/api/2/issue/{issueKey}?fields=...` |
| **Endpoint** | `GET /rest/api/2/search?jql=key="ISSUE-KEY" AND project=<KEY>&fields=...&maxResults=1` |
| **Trigger** | User looks up a Jira issue by key from the dashboard search |
| **Frequency** | Manual, estimated 515 per day |
| **Purpose** | Quick lookup of any Jira issue to view its current state |
| **Purpose** | Quick lookup of any Jira issue to view its current state via JQL search |
---
@@ -130,7 +131,7 @@ All API calls are made from a single Node.js backend process. The integration us
| Add comment | 515 | POST | 2s |
| Get transitions | 510 | GET | 1s |
| Transition issue | 510 | POST | 2s |
| JQL search (sync) | 15 | POST | 2s |
| JQL search (sync) | 15 | GET | 1s |
| Issue lookup | 515 | GET | 1s |
| **Total estimated** | **43120** | | |

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,270 @@
#!/usr/bin/env node
// bu-reassignment-check.js — Check if disappeared findings were reassigned to a different BU
//
// Queries Ivanti for the specific finding IDs that are completely gone from our
// BU-filtered results, using NO filters at all (just the finding IDs).
// If they come back with a different BU, that confirms BU reassignment.
//
// Usage: node backend/scripts/bu-reassignment-check.js
require('dotenv').config({ path: require('path').join(__dirname, '..', '.env') });
const path = require('path');
const sqlite3 = require('sqlite3').verbose();
const { ivantiPost } = require('../helpers/ivantiApi');
const DB_PATH = path.join(__dirname, '..', 'cve_database.db');
function dbAll(db, sql, params = []) {
return new Promise((resolve, reject) => {
db.all(sql, params, (err, rows) => {
if (err) reject(err);
else resolve(rows || []);
});
});
}
async function queryByFindingIds(findingIds, apiKey, clientId, skipTls) {
const urlPath = `/client/${encodeURIComponent(clientId)}/hostFinding/search`;
const allResults = [];
// Ivanti's IN filter can handle batches — but let's chunk to be safe
const chunkSize = 50;
for (let i = 0; i < findingIds.length; i += chunkSize) {
const chunk = findingIds.slice(i, i + chunkSize);
const idList = chunk.join(',');
// Query with ONLY the finding ID filter — no BU, no severity, no state
const filters = [
{
field: 'id',
exclusive: false,
operator: 'IN',
orWithPrevious: false,
implicitFilters: [],
value: idList,
caseSensitive: false
}
];
let page = 0;
let totalPages = 1;
do {
const body = {
filters,
projection: 'internal',
sort: [{ field: 'severity', direction: 'ASC' }],
page,
size: 100
};
try {
const result = await ivantiPost(urlPath, body, apiKey, skipTls);
if (result.status !== 200) {
console.error(` API returned status ${result.status} for chunk starting at ${i}`);
break;
}
const data = JSON.parse(result.body);
totalPages = data.page?.totalPages || 1;
const findings = data._embedded?.hostFindings || [];
for (const f of findings) {
const bu = f.assetCustomAttributes?.['1550_host_1']?.[0] || 'UNKNOWN';
allResults.push({
id: String(f.id),
severity: typeof f.severity === 'number' ? f.severity : parseFloat(f.severity) || 0,
title: f.title || '',
hostName: f.host?.hostName || '',
ipAddress: f.host?.ipAddress || '',
state: f.status || f.generic_state || '',
bu,
// Check for FP workflow
fpWorkflow: extractFP(f)
});
}
console.error(` Chunk ${Math.floor(i/chunkSize)+1}: page ${page+1}/${totalPages}, ${findings.length} results`);
page++;
} catch (err) {
console.error(` Error querying chunk at ${i}:`, err.message);
break;
}
} while (page < totalPages);
}
return allResults;
}
function extractFP(f) {
const wfDist = f.workflowDistribution || {};
const fpBuckets = [
...(wfDist.approvedWorkflows || []),
...(wfDist.actionableWorkflows || []),
...(wfDist.requestedWorkflows || []),
...(wfDist.reworkedWorkflows || []),
...(wfDist.rejectedWorkflows || []),
...(wfDist.expiredWorkflows || []),
].filter(w => (w.generatedId || '').startsWith('FP#'));
const entry = fpBuckets[0];
if (!entry) return null;
return { id: entry.generatedId, state: entry.state };
}
async function main() {
const apiKey = process.env.IVANTI_API_KEY;
const clientId = process.env.IVANTI_CLIENT_ID || '1550';
const skipTls = process.env.IVANTI_SKIP_TLS === 'true';
if (!apiKey) {
console.error('IVANTI_API_KEY not set');
process.exit(1);
}
const db = new sqlite3.Database(DB_PATH);
// Get the 124 finding IDs that were completely gone from BU-filtered results
const goneFindings = await dbAll(db,
`SELECT finding_id, last_severity, finding_title, current_state
FROM ivanti_finding_archives
WHERE current_state IN ('ARCHIVED', 'CLOSED')`
);
const goneIds = goneFindings.map(f => f.finding_id);
console.error(`\n=== BU Reassignment Check ===`);
console.error(`Querying Ivanti for ${goneIds.length} disappeared finding IDs (no BU/severity/state filter)...\n`);
const results = await queryByFindingIds(goneIds, apiKey, clientId, skipTls);
const foundMap = new Map(results.map(r => [r.id, r]));
// Categorize
const reassigned = []; // Found with different BU
const sameBU = []; // Found with same BU (STEAM or ACCESS-ENG)
const notFound = []; // Still not found even without filters
const withFP = []; // Has an FP workflow (any state)
for (const arch of goneFindings) {
const found = foundMap.get(arch.finding_id);
if (!found) {
notFound.push(arch);
} else if (found.bu !== 'NTS-AEO-ACCESS-ENG' && found.bu !== 'NTS-AEO-STEAM') {
reassigned.push({ ...arch, currentBU: found.bu, currentSeverity: found.severity, currentState: found.state, fp: found.fpWorkflow });
if (found.fpWorkflow) withFP.push({ ...arch, ...found });
} else {
sameBU.push({ ...arch, currentBU: found.bu, currentSeverity: found.severity, currentState: found.state, fp: found.fpWorkflow });
if (found.fpWorkflow) withFP.push({ ...arch, ...found });
}
}
console.log('');
console.log('='.repeat(130));
console.log('BU REASSIGNMENT CHECK RESULTS');
console.log('='.repeat(130));
console.log(`\nREASSIGNED TO DIFFERENT BU: ${reassigned.length} findings`);
console.log('-'.repeat(130));
if (reassigned.length > 0) {
console.log(
'Finding ID'.padEnd(14) +
'Archive State'.padEnd(15) +
'Last Sev'.padEnd(10) +
'Current Sev'.padEnd(13) +
'Current BU'.padEnd(30) +
'FP Workflow'.padEnd(25) +
'Title'
);
console.log('-'.repeat(130));
for (const f of reassigned) {
const fpStr = f.fp ? `${f.fp.id} (${f.fp.state})` : '-';
console.log(
f.finding_id.padEnd(14) +
f.current_state.padEnd(15) +
f.last_severity.toFixed(2).padEnd(10) +
f.currentSeverity.toFixed(2).padEnd(13) +
f.currentBU.padEnd(30) +
fpStr.padEnd(25) +
f.finding_title.substring(0, 40)
);
}
}
console.log(`\nSTILL SAME BU (but missing from filtered results): ${sameBU.length} findings`);
console.log('-'.repeat(130));
if (sameBU.length > 0) {
console.log(
'Finding ID'.padEnd(14) +
'Archive State'.padEnd(15) +
'Last Sev'.padEnd(10) +
'Current Sev'.padEnd(13) +
'Current BU'.padEnd(30) +
'Current State'.padEnd(15) +
'Title'
);
console.log('-'.repeat(130));
for (const f of sameBU) {
console.log(
f.finding_id.padEnd(14) +
f.current_state.padEnd(15) +
f.last_severity.toFixed(2).padEnd(10) +
f.currentSeverity.toFixed(2).padEnd(13) +
f.currentBU.padEnd(30) +
f.currentState.padEnd(15) +
f.finding_title.substring(0, 40)
);
}
}
console.log(`\nCOMPLETELY GONE (not found even without any filters): ${notFound.length} findings`);
if (notFound.length > 0 && notFound.length <= 20) {
console.log('-'.repeat(130));
for (const f of notFound) {
console.log(` ${f.finding_id} ${f.last_severity.toFixed(2)} ${f.finding_title.substring(0, 60)}`);
}
}
if (withFP.length > 0) {
console.log(`\nFINDINGS WITH FP WORKFLOWS: ${withFP.length}`);
console.log('-'.repeat(130));
for (const f of withFP) {
const fpStr = f.fpWorkflow ? `${f.fpWorkflow.id} (${f.fpWorkflow.state})` : f.fp ? `${f.fp.id} (${f.fp.state})` : '-';
console.log(` ${f.finding_id || f.id} ${fpStr} ${f.bu || f.currentBU} ${(f.finding_title || f.title || '').substring(0, 50)}`);
}
}
// Summary
console.log('');
console.log('='.repeat(130));
console.log('SUMMARY');
console.log('='.repeat(130));
console.log(` Total disappeared findings checked: ${goneFindings.length}`);
console.log(` Reassigned to different BU: ${reassigned.length}`);
console.log(` Still same BU (unexpected): ${sameBU.length}`);
console.log(` Completely gone from platform: ${notFound.length}`);
console.log(` Have FP workflows: ${withFP.length}`);
if (reassigned.length > 0) {
const buCounts = {};
reassigned.forEach(f => { buCounts[f.currentBU] = (buCounts[f.currentBU] || 0) + 1; });
console.log('\n BU reassignment breakdown:');
for (const [bu, cnt] of Object.entries(buCounts).sort((a, b) => b[1] - a[1])) {
console.log(` ${bu}: ${cnt} findings`);
}
}
if (reassigned.length > goneFindings.length * 0.5) {
console.log('\n VERDICT: BU REASSIGNMENT CONFIRMED.');
} else if (notFound.length > goneFindings.length * 0.5) {
console.log('\n VERDICT: Findings removed from platform entirely (decommission or data purge).');
} else {
console.log('\n VERDICT: Mixed causes — review individual categories above.');
}
db.close();
}
main().catch(err => {
console.error('Fatal error:', err);
process.exit(1);
});

View File

@@ -0,0 +1,83 @@
#!/usr/bin/env node
// Diagnostic: check alignment between counts history dates and anomaly log dates
// Usage: node backend/scripts/diagnose-chart-alignment.js
const path = require('path');
const sqlite3 = require('sqlite3').verbose();
const DB_PATH = path.join(__dirname, '..', 'cve_database.db');
function dbAll(db, sql, params = []) {
return new Promise((resolve, reject) => {
db.all(sql, params, (err, rows) => {
if (err) reject(err);
else resolve(rows || []);
});
});
}
function fmtDate(d) {
if (!d) return '';
const p = d.split('-');
if (p.length === 3) return `${p[1]}/${p[2]}/${p[0].slice(2)}`;
return d;
}
function extractDate(ts) {
if (!ts) return '';
return ts.split('T')[0].split(' ')[0];
}
async function main() {
const db = new sqlite3.Database(DB_PATH);
// Get counts history dates (same query as the API)
const countsRows = await dbAll(db,
`SELECT date FROM (
SELECT DATE(recorded_at) AS date,
ROW_NUMBER() OVER (PARTITION BY DATE(recorded_at) ORDER BY recorded_at DESC) AS rn
FROM ivanti_counts_history
) WHERE rn = 1 ORDER BY date ASC`
);
const countsDates = new Set(countsRows.map(r => fmtDate(r.date)));
// Get anomaly history (same query as the API)
const anomalyRows = await dbAll(db,
`SELECT sync_timestamp, newly_archived_count, returned_count, return_classification_json
FROM ivanti_sync_anomaly_log
ORDER BY sync_timestamp DESC LIMIT 30`
);
console.log('=== Counts History Dates (last 10) ===');
const lastTen = countsRows.slice(-10);
for (const r of lastTen) {
console.log(` ${r.date}${fmtDate(r.date)}`);
}
console.log('\n=== Anomaly Log Entries with Activity ===');
for (const a of anomalyRows) {
if (a.newly_archived_count === 0 && a.returned_count === 0) continue;
const rawDate = extractDate(a.sync_timestamp);
const dateKey = fmtDate(rawDate);
const inCounts = countsDates.has(dateKey);
console.log(` ${a.sync_timestamp} → raw="${rawDate}" → key="${dateKey}" | archived=${a.newly_archived_count} returned=${a.returned_count} | in counts: ${inCounts ? 'YES' : '*** NO ***'}`);
}
console.log('\n=== All Anomaly Dates NOT in Counts History ===');
let missingCount = 0;
for (const a of anomalyRows) {
const rawDate = extractDate(a.sync_timestamp);
const dateKey = fmtDate(rawDate);
if (!countsDates.has(dateKey)) {
console.log(` MISSING: ${a.sync_timestamp} → "${dateKey}" (archived=${a.newly_archived_count}, returned=${a.returned_count})`);
missingCount++;
}
}
if (missingCount === 0) console.log(' (none — all anomaly dates have matching counts history)');
db.close();
}
main().catch(err => {
console.error('Fatal error:', err);
process.exit(1);
});

View File

@@ -0,0 +1,275 @@
#!/usr/bin/env node
// drift-check.js — One-time diagnostic to confirm host-level VRR score drift
//
// Queries Ivanti WITHOUT the severity filter for the same BUs, then cross-
// references the results against our archived finding IDs to see if they
// still exist at lower severity scores.
//
// Usage: node backend/scripts/drift-check.js
//
// Output: prints a comparison table and summary. Does NOT modify cve_database.db
// permanently — uses a temporary in-memory table for the comparison.
require('dotenv').config({ path: require('path').join(__dirname, '..', '.env') });
const path = require('path');
const sqlite3 = require('sqlite3').verbose();
const { ivantiPost } = require('../helpers/ivantiApi');
const DB_PATH = path.join(__dirname, '..', 'cve_database.db');
// Same BU filter, NO severity filter, NO state filter — get everything
const ALL_FINDINGS_FILTERS = [
{
field: 'assetCustomAttributes.1550_host_1.value',
exclusive: false,
operator: 'IN',
orWithPrevious: false,
implicitFilters: [],
value: 'NTS-AEO-ACCESS-ENG,NTS-AEO-STEAM',
caseSensitive: false
}
];
function dbAll(db, sql, params = []) {
return new Promise((resolve, reject) => {
db.all(sql, params, (err, rows) => {
if (err) reject(err);
else resolve(rows || []);
});
});
}
function dbRun(db, sql, params = []) {
return new Promise((resolve, reject) => {
db.run(sql, params, function (err) {
if (err) reject(err);
else resolve(this);
});
});
}
async function fetchAllFindings(apiKey, clientId, skipTls, state) {
const urlPath = `/client/${encodeURIComponent(clientId)}/hostFinding/search`;
const filters = [
...ALL_FINDINGS_FILTERS,
{
field: 'generic_state',
exclusive: false,
operator: 'EXACT',
orWithPrevious: false,
implicitFilters: [],
value: state,
caseSensitive: false
}
];
let allFindings = [];
let page = 0;
let totalPages = 1;
do {
const body = {
filters,
projection: 'internal',
sort: [{ field: 'severity', direction: 'ASC' }],
page,
size: 100
};
const result = await ivantiPost(urlPath, body, apiKey, skipTls);
if (result.status !== 200) {
console.error(` API returned status ${result.status} on page ${page}`);
break;
}
const data = JSON.parse(result.body);
totalPages = data.page?.totalPages || 1;
const findings = data._embedded?.hostFindings || [];
for (const f of findings) {
allFindings.push({
id: String(f.id),
severity: typeof f.severity === 'number' ? f.severity : parseFloat(f.severity) || 0,
title: f.title || '',
hostName: f.host?.hostName || '',
state
});
}
console.error(` ${state} page ${page + 1}/${totalPages}${allFindings.length} findings so far`);
page++;
} while (page < totalPages);
return allFindings;
}
async function main() {
const apiKey = process.env.IVANTI_API_KEY;
const clientId = process.env.IVANTI_CLIENT_ID || '1550';
const skipTls = process.env.IVANTI_SKIP_TLS === 'true';
if (!apiKey) {
console.error('IVANTI_API_KEY not set in backend/.env');
process.exit(1);
}
console.error('=== Drift Check: Querying Ivanti WITHOUT severity filter ===\n');
// Fetch all Open findings (no severity filter)
console.error('Fetching ALL Open findings (no severity filter)...');
const openFindings = await fetchAllFindings(apiKey, clientId, skipTls, 'Open');
console.error(` Total Open (all severities): ${openFindings.length}\n`);
// Fetch all Closed findings (no severity filter)
console.error('Fetching ALL Closed findings (no severity filter)...');
const closedFindings = await fetchAllFindings(apiKey, clientId, skipTls, 'Closed');
console.error(` Total Closed (all severities): ${closedFindings.length}\n`);
const allFindings = [...openFindings, ...closedFindings];
const findingMap = new Map(allFindings.map(f => [f.id, f]));
console.error(`Total findings across both states: ${allFindings.length}\n`);
// Open the database and get archived finding IDs
const db = new sqlite3.Database(DB_PATH);
const archived = await dbAll(db,
`SELECT finding_id, last_severity, finding_title, host_name, current_state
FROM ivanti_finding_archives
WHERE current_state IN ('ARCHIVED', 'CLOSED')
ORDER BY current_state, last_severity DESC`
);
console.log('');
console.log('='.repeat(120));
console.log('DRIFT CHECK RESULTS');
console.log('='.repeat(120));
console.log('');
// Categorize results
const drifted = []; // Found in API at lower severity (below 8.5)
const stillHigh = []; // Found in API, severity still >= 8.5
const gone = []; // Not found in API at all (any severity)
const stateChanged = []; // Found but in different state
for (const arch of archived) {
const current = findingMap.get(arch.finding_id);
if (!current) {
gone.push(arch);
} else if (current.severity < 8.5) {
drifted.push({ ...arch, currentSeverity: current.severity, currentState: current.state });
} else {
stillHigh.push({ ...arch, currentSeverity: current.severity, currentState: current.state });
}
}
// Print drifted findings
console.log(`CONFIRMED SCORE DRIFT (now below 8.5): ${drifted.length} findings`);
console.log('-'.repeat(120));
if (drifted.length > 0) {
console.log(
'Finding ID'.padEnd(14) +
'Archive State'.padEnd(15) +
'Last Severity'.padEnd(15) +
'Current Severity'.padEnd(18) +
'Delta'.padEnd(10) +
'Current State'.padEnd(15) +
'Title'
);
console.log('-'.repeat(120));
for (const f of drifted) {
const delta = (f.currentSeverity - f.last_severity).toFixed(2);
console.log(
f.finding_id.padEnd(14) +
f.current_state.padEnd(15) +
f.last_severity.toFixed(2).padEnd(15) +
f.currentSeverity.toFixed(2).padEnd(18) +
delta.padEnd(10) +
f.currentState.padEnd(15) +
f.finding_title.substring(0, 50)
);
}
}
console.log('');
console.log(`STILL HIGH SEVERITY (>= 8.5, should be in filtered results): ${stillHigh.length} findings`);
console.log('-'.repeat(120));
if (stillHigh.length > 0) {
console.log(
'Finding ID'.padEnd(14) +
'Archive State'.padEnd(15) +
'Last Severity'.padEnd(15) +
'Current Severity'.padEnd(18) +
'Current State'.padEnd(15) +
'Title'
);
console.log('-'.repeat(120));
for (const f of stillHigh) {
console.log(
f.finding_id.padEnd(14) +
f.current_state.padEnd(15) +
f.last_severity.toFixed(2).padEnd(15) +
f.currentSeverity.toFixed(2).padEnd(18) +
f.currentState.padEnd(15) +
f.finding_title.substring(0, 50)
);
}
}
console.log('');
console.log(`COMPLETELY GONE (not in API at any severity): ${gone.length} findings`);
console.log('-'.repeat(120));
if (gone.length > 0) {
console.log(
'Finding ID'.padEnd(14) +
'Archive State'.padEnd(15) +
'Last Severity'.padEnd(15) +
'Title'
);
console.log('-'.repeat(120));
for (const f of gone) {
console.log(
f.finding_id.padEnd(14) +
f.current_state.padEnd(15) +
f.last_severity.toFixed(2).padEnd(15) +
f.finding_title.substring(0, 50)
);
}
}
// Summary
console.log('');
console.log('='.repeat(120));
console.log('SUMMARY');
console.log('='.repeat(120));
console.log(` Archived/Closed findings checked: ${archived.length}`);
console.log(` Confirmed score drift (< 8.5): ${drifted.length}`);
console.log(` Still high severity (>= 8.5): ${stillHigh.length}`);
console.log(` Completely gone from API: ${gone.length}`);
console.log('');
if (drifted.length > 0) {
const avgDelta = drifted.reduce((sum, f) => sum + (f.currentSeverity - f.last_severity), 0) / drifted.length;
const minNew = Math.min(...drifted.map(f => f.currentSeverity));
const maxNew = Math.max(...drifted.map(f => f.currentSeverity));
console.log(` Score drift range: ${minNew.toFixed(2)} ${maxNew.toFixed(2)} (avg delta: ${avgDelta.toFixed(2)})`);
}
if (drifted.length > archived.length * 0.5) {
console.log('\n VERDICT: Host-level VRR score drift CONFIRMED.');
console.log(' The majority of disappeared findings still exist in Ivanti but at lower severity scores.');
} else if (drifted.length > 0) {
console.log('\n VERDICT: Partial score drift detected. Some findings drifted, others may have been removed.');
} else if (gone.length > archived.length * 0.5) {
console.log('\n VERDICT: Score drift NOT confirmed. Most findings are completely gone from the API.');
console.log(' This suggests BU reassignment, host decommission, or a platform-side data issue.');
}
db.close();
}
main().catch(err => {
console.error('Fatal error:', err);
process.exit(1);
});

View File

@@ -0,0 +1,197 @@
#!/usr/bin/env node
// export-reassigned-findings.js — Generate an xlsx with findings reassigned to SDIT-CSD-ITLS-PIES
//
// Pulls data from the archive database and the BU reassignment check results.
// Outputs to docs/reassigned-findings-2026-04-24.xlsx
//
// Usage: node backend/scripts/export-reassigned-findings.js
require('dotenv').config({ path: require('path').join(__dirname, '..', '.env') });
const path = require('path');
const sqlite3 = require('sqlite3').verbose();
const XLSX = require('xlsx');
const { ivantiPost } = require('../helpers/ivantiApi');
const DB_PATH = path.join(__dirname, '..', 'cve_database.db');
const OUTPUT_PATH = path.join(__dirname, '..', '..', 'docs', 'reassigned-findings-2026-04-24.xlsx');
function dbAll(db, sql, params = []) {
return new Promise((resolve, reject) => {
db.all(sql, params, (err, rows) => {
if (err) reject(err);
else resolve(rows || []);
});
});
}
async function queryByFindingIds(findingIds, apiKey, clientId, skipTls) {
const urlPath = `/client/${encodeURIComponent(clientId)}/hostFinding/search`;
const results = new Map();
const chunkSize = 50;
for (let i = 0; i < findingIds.length; i += chunkSize) {
const chunk = findingIds.slice(i, i + chunkSize);
const idList = chunk.join(',');
const filters = [{
field: 'id', exclusive: false, operator: 'IN',
orWithPrevious: false, implicitFilters: [],
value: idList, caseSensitive: false
}];
let page = 0;
let totalPages = 1;
do {
try {
const body = { filters, projection: 'internal', sort: [{ field: 'severity', direction: 'ASC' }], page, size: 100 };
const result = await ivantiPost(urlPath, body, apiKey, skipTls);
if (result.status !== 200) break;
const data = JSON.parse(result.body);
totalPages = data.page?.totalPages || 1;
const findings = data._embedded?.hostFindings || [];
for (const f of findings) {
const bu = f.assetCustomAttributes?.['1550_host_1']?.[0] || 'UNKNOWN';
const wfDist = f.workflowDistribution || {};
const fpBuckets = [
...(wfDist.approvedWorkflows || []), ...(wfDist.actionableWorkflows || []),
...(wfDist.requestedWorkflows || []), ...(wfDist.reworkedWorkflows || []),
...(wfDist.rejectedWorkflows || []), ...(wfDist.expiredWorkflows || []),
].filter(w => (w.generatedId || '').startsWith('FP#'));
const fp = fpBuckets[0] || null;
results.set(String(f.id), {
bu,
severity: typeof f.severity === 'number' ? f.severity : parseFloat(f.severity) || 0,
state: f.status || '',
fpId: fp ? fp.generatedId : '',
fpState: fp ? fp.state : '',
hostName: f.host?.hostName || '',
ipAddress: f.host?.ipAddress || '',
title: f.title || '',
});
}
page++;
} catch (err) {
console.error(` Error on batch at ${i}:`, err.message);
break;
}
} while (page < totalPages);
}
return results;
}
async function main() {
const apiKey = process.env.IVANTI_API_KEY;
const clientId = process.env.IVANTI_CLIENT_ID || '1550';
const skipTls = process.env.IVANTI_SKIP_TLS === 'true';
const db = new sqlite3.Database(DB_PATH);
// Get all archived/closed findings from the archive
const archived = await dbAll(db,
`SELECT finding_id, last_severity, finding_title, host_name, ip_address, current_state,
DATE(first_archived_at) as archived_date, DATE(last_transition_at) as last_transition_date
FROM ivanti_finding_archives
WHERE current_state IN ('ARCHIVED', 'CLOSED')
ORDER BY current_state, last_severity DESC`
);
const ids = archived.map(a => a.finding_id);
console.log(`Querying Ivanti for ${ids.length} findings...`);
const currentData = await queryByFindingIds(ids, apiKey, clientId, skipTls);
// Build rows for each sheet
const reassignedRows = [];
const goneRows = [];
const sameBuRows = [];
for (const arch of archived) {
const current = currentData.get(arch.finding_id);
if (!current) {
goneRows.push({
'Finding ID': arch.finding_id,
'Title': arch.finding_title,
'Last Severity': arch.last_severity,
'Host': arch.host_name,
'IP Address': arch.ip_address,
'Archive State': arch.current_state,
'Archived Date': arch.archived_date,
'Status': 'Gone from platform',
});
} else if (current.bu !== 'NTS-AEO-ACCESS-ENG' && current.bu !== 'NTS-AEO-STEAM') {
reassignedRows.push({
'Finding ID': arch.finding_id,
'Title': current.title || arch.finding_title,
'Last Severity (STEAM)': arch.last_severity,
'Current Severity': current.severity,
'Host': current.hostName || arch.host_name,
'IP Address': current.ipAddress || arch.ip_address,
'Previous BU': 'NTS-AEO-STEAM / ACCESS-ENG',
'Current BU': current.bu,
'Current State': current.state,
'FP Workflow': current.fpId ? `${current.fpId} (${current.fpState})` : '',
'Archive State': arch.current_state,
'Archived Date': arch.archived_date,
});
} else {
sameBuRows.push({
'Finding ID': arch.finding_id,
'Title': current.title || arch.finding_title,
'Severity': current.severity,
'Host': current.hostName || arch.host_name,
'IP Address': current.ipAddress || arch.ip_address,
'BU': current.bu,
'Current State': current.state,
'FP Workflow': current.fpId ? `${current.fpId} (${current.fpState})` : '',
'Archive State': arch.current_state,
});
}
}
// Create workbook
const wb = XLSX.utils.book_new();
// Sheet 1: Reassigned findings
const ws1 = XLSX.utils.json_to_sheet(reassignedRows);
// Set column widths
ws1['!cols'] = [
{ wch: 14 }, { wch: 55 }, { wch: 18 }, { wch: 16 },
{ wch: 30 }, { wch: 16 }, { wch: 28 }, { wch: 24 },
{ wch: 14 }, { wch: 24 }, { wch: 14 }, { wch: 14 },
];
XLSX.utils.book_append_sheet(wb, ws1, 'Reassigned to SDIT-PIES');
// Sheet 2: Gone from platform
if (goneRows.length > 0) {
const ws2 = XLSX.utils.json_to_sheet(goneRows);
ws2['!cols'] = [
{ wch: 14 }, { wch: 55 }, { wch: 14 }, { wch: 30 },
{ wch: 16 }, { wch: 14 }, { wch: 14 }, { wch: 20 },
];
XLSX.utils.book_append_sheet(wb, ws2, 'Gone from Platform');
}
// Sheet 3: Still same BU
if (sameBuRows.length > 0) {
const ws3 = XLSX.utils.json_to_sheet(sameBuRows);
ws3['!cols'] = [
{ wch: 14 }, { wch: 55 }, { wch: 10 }, { wch: 30 },
{ wch: 16 }, { wch: 24 }, { wch: 14 }, { wch: 24 }, { wch: 14 },
];
XLSX.utils.book_append_sheet(wb, ws3, 'Still Same BU');
}
// Write file
XLSX.writeFile(wb, OUTPUT_PATH);
console.log(`\nExported to: ${OUTPUT_PATH}`);
console.log(` Reassigned: ${reassignedRows.length}`);
console.log(` Gone: ${goneRows.length}`);
console.log(` Same BU: ${sameBuRows.length}`);
db.close();
}
main().catch(err => {
console.error('Fatal error:', err);
process.exit(1);
});

View File

@@ -13,6 +13,7 @@ import VulnerabilityTriagePage from './components/pages/ReportingPage';
import KnowledgeBasePage from './components/pages/KnowledgeBasePage';
import ExportsPage from './components/pages/ExportsPage';
import CompliancePage from './components/pages/CompliancePage';
import JiraPage from './components/pages/JiraPage';
import AdminPage from './components/pages/AdminPage';
import ArchiveSummaryBar from './components/pages/ArchiveSummaryBar';
import './App.css';
@@ -178,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);
@@ -1043,6 +1054,7 @@ export default function App() {
{currentPage === 'compliance' && <CompliancePage onNavigate={setCurrentPage} />}
{currentPage === 'knowledge-base' && <KnowledgeBasePage />}
{currentPage === 'exports' && <ExportsPage />}
{currentPage === 'jira' && <JiraPage />}
{currentPage === 'admin' && isAdmin() && <AdminPage />}
{currentPage === 'admin' && !isAdmin() && (() => { setCurrentPage('home'); return null; })()}

View File

@@ -1,598 +0,0 @@
import React, { useState, useEffect } from 'react';
import { Search, FileText, AlertCircle, Download, Upload, Eye, Filter, CheckCircle, XCircle, Loader } from 'lucide-react';
const API_BASE = 'http://192.168.2.117:3001/api';
const severityLevels = ['All Severities', 'Critical', 'High', 'Medium', 'Low'];
export default function App() {
const [searchQuery, setSearchQuery] = useState('');
const [selectedVendor, setSelectedVendor] = useState('All Vendors');
const [selectedSeverity, setSelectedSeverity] = useState('All Severities');
const [selectedCVE, setSelectedCVE] = useState(null);
const [selectedDocuments, setSelectedDocuments] = useState([]);
const [cves, setCves] = useState([]);
const [vendors, setVendors] = useState(['All Vendors']);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [cveDocuments, setCveDocuments] = useState({});
const [quickCheckCVE, setQuickCheckCVE] = useState('');
const [quickCheckResult, setQuickCheckResult] = useState(null);
const [showAddCVE, setShowAddCVE] = useState(false);
const [newCVE, setNewCVE] = useState({
cve_id: '',
vendor: '',
severity: 'Medium',
description: '',
published_date: new Date().toISOString().split('T')[0]
});
const [uploadingFile, setUploadingFile] = useState(false);
// Fetch CVEs from API
useEffect(() => {
fetchCVEs();
fetchVendors();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Refetch when filters change
useEffect(() => {
fetchCVEs();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchQuery, selectedVendor, selectedSeverity]);
const fetchCVEs = async () => {
setLoading(true);
setError(null);
try {
const params = new URLSearchParams();
if (searchQuery) params.append('search', searchQuery);
if (selectedVendor !== 'All Vendors') params.append('vendor', selectedVendor);
if (selectedSeverity !== 'All Severities') params.append('severity', selectedSeverity);
const response = await fetch(`${API_BASE}/cves?${params}`);
if (!response.ok) throw new Error('Failed to fetch CVEs');
const data = await response.json();
setCves(data);
} catch (err) {
setError(err.message);
console.error('Error fetching CVEs:', err);
} finally {
setLoading(false);
}
};
const fetchVendors = async () => {
try {
const response = await fetch(`${API_BASE}/vendors`);
if (!response.ok) throw new Error('Failed to fetch vendors');
const data = await response.json();
setVendors(['All Vendors', ...data]);
} catch (err) {
console.error('Error fetching vendors:', err);
}
};
const fetchDocuments = async (cveId) => {
if (cveDocuments[cveId]) return;
try {
const response = await fetch(`${API_BASE}/cves/${cveId}/documents`);
if (!response.ok) throw new Error('Failed to fetch documents');
const data = await response.json();
setCveDocuments(prev => ({ ...prev, [cveId]: data }));
} catch (err) {
console.error('Error fetching documents:', err);
}
};
const quickCheckCVEStatus = async () => {
if (!quickCheckCVE.trim()) return;
try {
const response = await fetch(`${API_BASE}/cves/check/${quickCheckCVE.trim()}`);
if (!response.ok) throw new Error('Failed to check CVE');
const data = await response.json();
setQuickCheckResult(data);
} catch (err) {
console.error('Error checking CVE:', err);
setQuickCheckResult({ error: err.message });
}
};
const handleViewDocuments = async (cveId) => {
if (selectedCVE === cveId) {
setSelectedCVE(null);
} else {
setSelectedCVE(cveId);
await fetchDocuments(cveId);
}
};
const getSeverityColor = (severity) => {
const colors = {
'Critical': 'bg-red-100 text-red-800',
'High': 'bg-orange-100 text-orange-800',
'Medium': 'bg-yellow-100 text-yellow-800',
'Low': 'bg-blue-100 text-blue-800'
};
return colors[severity] || 'bg-gray-100 text-gray-800';
};
const toggleDocumentSelection = (docId) => {
setSelectedDocuments(prev =>
prev.includes(docId)
? prev.filter(id => id !== docId)
: [...prev, docId]
);
};
const exportSelectedDocuments = () => {
alert(`Exporting ${selectedDocuments.length} documents for report attachment`);
};
const handleAddCVE = async (e) => {
e.preventDefault();
try {
const response = await fetch(`${API_BASE}/cves`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newCVE)
});
if (!response.ok) throw new Error('Failed to add CVE');
alert(`CVE ${newCVE.cve_id} added successfully!`);
setShowAddCVE(false);
setNewCVE({
cve_id: '',
vendor: '',
severity: 'Medium',
description: '',
published_date: new Date().toISOString().split('T')[0]
});
fetchCVEs();
} catch (err) {
alert(`Error: ${err.message}`);
}
};
const handleFileUpload = async (cveId, vendor) => {
const fileInput = document.createElement('input');
fileInput.type = 'file';
fileInput.accept = '.pdf,.png,.jpg,.jpeg,.txt,.doc,.docx';
fileInput.onchange = async (e) => {
const file = e.target.files[0];
if (!file) return;
const docType = prompt(
'Document type (advisory, email, screenshot, patch, other):',
'advisory'
);
if (!docType) return;
const notes = prompt('Notes (optional):');
setUploadingFile(true);
const formData = new FormData();
formData.append('file', file);
formData.append('cveId', cveId);
formData.append('vendor', vendor);
formData.append('type', docType);
if (notes) formData.append('notes', notes);
try {
const response = await fetch(`${API_BASE}/cves/${cveId}/documents`, {
method: 'POST',
body: formData
});
if (!response.ok) throw new Error('Failed to upload document');
alert(`Document uploaded successfully!`);
delete cveDocuments[cveId];
await fetchDocuments(cveId);
fetchCVEs();
} catch (err) {
alert(`Error: ${err.message}`);
} finally {
setUploadingFile(false);
}
};
fileInput.click();
};
const filteredCVEs = cves;
return (
<div className="min-h-screen bg-gray-50 p-6">
<div className="max-w-7xl mx-auto">
{/* Header */}
<div className="mb-8 flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold text-gray-900 mb-2">CVE Dashboard</h1>
<p className="text-gray-600">Query vulnerabilities, manage vendors, and attach documentation</p>
</div>
<button
onClick={() => setShowAddCVE(true)}
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors flex items-center gap-2"
>
<span className="text-xl">+</span>
Add New CVE
</button>
</div>
{/* Add CVE Modal */}
{showAddCVE && (
<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-2xl w-full max-h-[90vh] overflow-y-auto">
<div className="p-6">
<div className="flex justify-between items-center mb-4">
<h2 className="text-2xl font-bold text-gray-900">Add New CVE</h2>
<button
onClick={() => setShowAddCVE(false)}
className="text-gray-400 hover:text-gray-600"
>
<XCircle className="w-6 h-6" />
</button>
</div>
<form onSubmit={handleAddCVE} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
CVE ID *
</label>
<input
type="text"
required
placeholder="CVE-2024-1234"
value={newCVE.cve_id}
onChange={(e) => setNewCVE({...newCVE, cve_id: e.target.value.toUpperCase()})}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Vendor *
</label>
<input
type="text"
required
placeholder="Microsoft, Cisco, Oracle, etc."
value={newCVE.vendor}
onChange={(e) => setNewCVE({...newCVE, vendor: e.target.value})}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Severity *
</label>
<select
value={newCVE.severity}
onChange={(e) => setNewCVE({...newCVE, severity: e.target.value})}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
>
<option value="Critical">Critical</option>
<option value="High">High</option>
<option value="Medium">Medium</option>
<option value="Low">Low</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Description *
</label>
<textarea
required
placeholder="Brief description of the vulnerability"
value={newCVE.description}
onChange={(e) => setNewCVE({...newCVE, description: e.target.value})}
rows={3}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Published Date *
</label>
<input
type="date"
required
value={newCVE.published_date}
onChange={(e) => setNewCVE({...newCVE, published_date: e.target.value})}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
/>
</div>
<div className="flex gap-3 pt-4">
<button
type="submit"
className="flex-1 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors font-medium"
>
Add CVE
</button>
<button
type="button"
onClick={() => setShowAddCVE(false)}
className="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 transition-colors"
>
Cancel
</button>
</div>
</form>
</div>
</div>
</div>
)}
{/* Quick Check */}
<div className="bg-gradient-to-r from-blue-50 to-indigo-50 rounded-lg shadow-sm p-6 mb-6 border border-blue-200">
<h2 className="text-lg font-semibold text-gray-900 mb-3">Quick CVE Status Check</h2>
<div className="flex gap-3">
<input
type="text"
placeholder="Enter CVE ID (e.g., CVE-2024-1234)"
value={quickCheckCVE}
onChange={(e) => setQuickCheckCVE(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && quickCheckCVEStatus()}
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
<button
onClick={quickCheckCVEStatus}
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors font-medium"
>
Check Status
</button>
</div>
{quickCheckResult && (
<div className={`mt-4 p-4 rounded-lg ${quickCheckResult.exists ? 'bg-green-50 border border-green-200' : 'bg-yellow-50 border border-yellow-200'}`}>
{quickCheckResult.error ? (
<div className="flex items-start gap-3">
<XCircle className="w-5 h-5 text-red-600 mt-0.5" />
<div>
<p className="font-medium text-red-900">Error</p>
<p className="text-sm text-red-700">{quickCheckResult.error}</p>
</div>
</div>
) : quickCheckResult.exists ? (
<div className="flex items-start gap-3">
<CheckCircle className="w-5 h-5 text-green-600 mt-0.5" />
<div className="flex-1">
<p className="font-medium text-green-900">✓ CVE Addressed</p>
<div className="mt-2 space-y-1 text-sm text-gray-700">
<p><strong>Vendor:</strong> {quickCheckResult.cve.vendor}</p>
<p><strong>Severity:</strong> {quickCheckResult.cve.severity}</p>
<p><strong>Status:</strong> {quickCheckResult.cve.status}</p>
<p><strong>Documents:</strong> {quickCheckResult.cve.total_documents} attached</p>
<div className="mt-2 flex gap-3">
<span className={`px-2 py-1 rounded text-xs font-medium ${quickCheckResult.compliance.advisory ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'}`}>
{quickCheckResult.compliance.advisory ? '✓' : '✗'} Advisory
</span>
<span className={`px-2 py-1 rounded text-xs font-medium ${quickCheckResult.compliance.email ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-600'}`}>
{quickCheckResult.compliance.email ? '✓' : '○'} Email
</span>
<span className={`px-2 py-1 rounded text-xs font-medium ${quickCheckResult.compliance.screenshot ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-600'}`}>
{quickCheckResult.compliance.screenshot ? '✓' : '○'} Screenshot
</span>
</div>
</div>
</div>
</div>
) : (
<div className="flex items-start gap-3">
<AlertCircle className="w-5 h-5 text-yellow-600 mt-0.5" />
<div>
<p className="font-medium text-yellow-900">Not Found</p>
<p className="text-sm text-yellow-700">This CVE has not been addressed yet. No entry exists in the database.</p>
</div>
</div>
)}
</div>
)}
</div>
{/* Search and Filters */}
<div className="bg-white rounded-lg shadow-sm p-6 mb-6">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="md:col-span-1">
<label className="block text-sm font-medium text-gray-700 mb-2">
<Search className="inline w-4 h-4 mr-1" />
Search CVEs
</label>
<input
type="text"
placeholder="CVE ID or description..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
<Filter className="inline w-4 h-4 mr-1" />
Vendor
</label>
<select
value={selectedVendor}
onChange={(e) => setSelectedVendor(e.target.value)}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
>
{vendors.map(vendor => (
<option key={vendor} value={vendor}>{vendor}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
<AlertCircle className="inline w-4 h-4 mr-1" />
Severity
</label>
<select
value={selectedSeverity}
onChange={(e) => setSelectedSeverity(e.target.value)}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
>
{severityLevels.map(level => (
<option key={level} value={level}>{level}</option>
))}
</select>
</div>
</div>
</div>
{/* Results Summary */}
<div className="mb-4 flex justify-between items-center">
<p className="text-gray-600">
Found {filteredCVEs.length} CVE{filteredCVEs.length !== 1 ? 's' : ''}
</p>
{selectedDocuments.length > 0 && (
<button
onClick={exportSelectedDocuments}
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
<Download className="w-4 h-4" />
Export {selectedDocuments.length} Document{selectedDocuments.length !== 1 ? 's' : ''} for Report
</button>
)}
</div>
{/* CVE List */}
{loading ? (
<div className="bg-white rounded-lg shadow-sm p-12 text-center">
<Loader className="w-12 h-12 text-blue-600 mx-auto mb-4 animate-spin" />
<p className="text-gray-600">Loading CVEs...</p>
</div>
) : error ? (
<div className="bg-white rounded-lg shadow-sm p-12 text-center">
<XCircle className="w-12 h-12 text-red-500 mx-auto mb-4" />
<h3 className="text-lg font-medium text-gray-900 mb-2">Error Loading CVEs</h3>
<p className="text-gray-600 mb-4">{error}</p>
<button
onClick={fetchCVEs}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
Retry
</button>
</div>
) : (
<div className="space-y-4">
{filteredCVEs.map(cve => {
const documents = cveDocuments[cve.cve_id] || [];
return (
<div key={cve.cve_id} className="bg-white rounded-lg shadow-sm border border-gray-200">
<div className="p-6">
<div className="flex justify-between items-start mb-4">
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<h3 className="text-xl font-semibold text-gray-900">{cve.cve_id}</h3>
<span className={`px-3 py-1 rounded-full text-sm font-medium ${getSeverityColor(cve.severity)}`}>
{cve.severity}
</span>
<span className={`px-3 py-1 rounded-full text-xs font-medium ${cve.doc_status === 'Complete' ? 'bg-green-100 text-green-800' : 'bg-yellow-100 text-yellow-800'}`}>
{cve.doc_status === 'Complete' ? '✓ Docs Complete' : '⚠ Incomplete'}
</span>
</div>
<p className="text-gray-700 mb-2">{cve.description}</p>
<div className="flex items-center gap-4 text-sm text-gray-500">
<span>Vendor: <span className="font-medium text-gray-700">{cve.vendor}</span></span>
<span>Published: {cve.published_date}</span>
<span>Status: <span className="font-medium text-gray-700">{cve.status}</span></span>
<span className="flex items-center gap-1">
<FileText className="w-4 h-4" />
{cve.document_count} document{cve.document_count !== 1 ? 's' : ''}
</span>
</div>
</div>
<button
onClick={() => handleViewDocuments(cve.cve_id)}
className="px-4 py-2 text-blue-600 hover:bg-blue-50 rounded-lg transition-colors flex items-center gap-2"
>
<Eye className="w-4 h-4" />
{selectedCVE === cve.cve_id ? 'Hide' : 'View'} Documents
</button>
</div>
{/* Documents Section */}
{selectedCVE === cve.cve_id && (
<div className="mt-4 pt-4 border-t border-gray-200">
<h4 className="text-sm font-semibold text-gray-700 mb-3 flex items-center gap-2">
<FileText className="w-4 h-4" />
Attached Documents ({documents.length})
</h4>
{documents.length > 0 ? (
<div className="space-y-2">
{documents.map(doc => (
<div
key={doc.id}
className="flex items-center justify-between p-3 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors"
>
<div className="flex items-center gap-3 flex-1">
<input
type="checkbox"
checked={selectedDocuments.includes(doc.id)}
onChange={() => toggleDocumentSelection(doc.id)}
className="w-4 h-4 text-blue-600 rounded focus:ring-2 focus:ring-blue-500"
/>
<FileText className="w-5 h-5 text-gray-400" />
<div className="flex-1">
<p className="text-sm font-medium text-gray-900">{doc.name}</p>
<p className="text-xs text-gray-500 capitalize">
{doc.type} • {doc.file_size}
{doc.notes && ` • ${doc.notes}`}
</p>
</div>
</div>
<a
href={`http://localhost:3001/${doc.file_path}`}
target="_blank"
rel="noopener noreferrer"
className="px-3 py-1 text-sm text-blue-600 hover:bg-blue-50 rounded transition-colors"
>
View
</a>
</div>
))}
</div>
) : (
<p className="text-sm text-gray-500 italic">No documents attached yet</p>
)}
<button
onClick={() => handleFileUpload(cve.cve_id, cve.vendor)}
disabled={uploadingFile}
className="mt-3 px-4 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded-lg transition-colors flex items-center gap-2 disabled:opacity-50"
>
<Upload className="w-4 h-4" />
{uploadingFile ? 'Uploading...' : 'Upload New Document'}
</button>
</div>
)}
</div>
</div>
);
})}
</div>
)}
{filteredCVEs.length === 0 && !loading && (
<div className="bg-white rounded-lg shadow-sm p-12 text-center">
<AlertCircle className="w-12 h-12 text-gray-400 mx-auto mb-4" />
<h3 className="text-lg font-medium text-gray-900 mb-2">No CVEs Found</h3>
<p className="text-gray-600">Try adjusting your search criteria or filters</p>
</div>
)}
</div>
</div>
);
}

View File

@@ -1,25 +0,0 @@
import logo from './logo.svg';
import './App.css';
function App() {
return (
<div className="App">
<header className="App-header">
<img src={logo} className="App-logo" alt="logo" />
<p>
Edit <code>src/App.js</code> and save to reload.
</p>
<a
className="App-link"
href="https://reactjs.org"
target="_blank"
rel="noopener noreferrer"
>
Learn React
</a>
</header>
</div>
);
}
export default App;

View File

@@ -1,5 +1,5 @@
import React from 'react';
import { X, Home, BarChart2, BookOpen, Download, ShieldCheck, Settings } from 'lucide-react';
import { X, Home, BarChart2, BookOpen, Download, ShieldCheck, Settings, Ticket } from 'lucide-react';
import { useAuth } from '../contexts/AuthContext';
const NAV_ITEMS = [
@@ -8,6 +8,7 @@ const NAV_ITEMS = [
{ id: 'compliance', label: 'Compliance', icon: ShieldCheck, color: '#14B8A6', description: 'AEO posture & metrics' },
{ id: 'knowledge-base', label: 'Knowledge Base', icon: BookOpen, color: '#10B981', description: 'Articles & documentation' },
{ id: 'exports', label: 'Exports', icon: Download, color: '#8B5CF6', description: 'Export data & reports' },
{ id: 'jira', label: 'Jira Tickets', icon: Ticket, color: '#6366F1', description: 'Jira issue tracking & sync' },
];
const ADMIN_ITEM = { id: 'admin', label: 'Admin Panel', icon: Settings, color: '#EF4444', description: 'User management & audit' };

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>
);
}
@@ -194,35 +226,46 @@ export default function IvantiCountsChart() {
);
// Build archive activity data aligned to the same date axis as the main chart.
// Aggregate anomaly rows by date (take the last sync per day, matching the
// counts history pattern), then merge onto the chartData date set.
// Aggregate anomaly rows by date — sum archived/returned counts and merge
// classifications across all syncs that day, then align to the chartData dates.
const archiveData = useMemo(() => {
if (!anomalies.length || !chartData.length) return [];
// Group anomalies by date, keep the latest per day
// Aggregate all anomaly rows per date (sum counts, merge classifications)
const byDate = {};
for (const a of anomalies) {
const rawDate = extractDate(a.sync_timestamp);
const dateKey = fmtDate(rawDate);
// anomaly/history returns newest first, so first seen per date is the latest
if (!byDate[dateKey]) {
byDate[dateKey] = a;
byDate[dateKey] = {
archived: 0,
returned: 0,
classification: {},
return_classification: {},
is_significant: false,
};
}
const entry = byDate[dateKey];
entry.archived += (a.newly_archived_count || 0);
entry.returned += (a.returned_count || 0);
if (a.is_significant) entry.is_significant = true;
// Merge classification counts
for (const [key, val] of Object.entries(a.classification || {})) {
entry.classification[key] = (entry.classification[key] || 0) + (val || 0);
}
for (const [key, val] of Object.entries(a.return_classification || {})) {
entry.return_classification[key] = (entry.return_classification[key] || 0) + (val || 0);
}
}
// Map onto the chart date axis so both charts share the same X positions
return chartData.map(point => {
const anomaly = byDate[point.date];
if (anomaly) {
return {
date: point.date,
archived: anomaly.newly_archived_count || 0,
returned: anomaly.returned_count || 0,
classification: anomaly.classification || {},
is_significant: anomaly.is_significant,
};
const agg = byDate[point.date];
if (agg) {
return { date: point.date, ...agg };
}
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]);
@@ -344,13 +387,13 @@ export default function IvantiCountsChart() {
}}>
Archive Activity
</div>
<ResponsiveContainer width="100%" height={64}>
<ResponsiveContainer width="100%" height={80}>
<BarChart data={archiveData} margin={{ top: 2, right: 12, bottom: 0, left: -12 }}>
<CartesianGrid {...GRID_STYLE} />
<XAxis dataKey="date" tick={false} axisLine={false} />
<YAxis tick={AXIS_STYLE} allowDecimals={false} width={30} />
<Tooltip content={<ArchiveTooltip />} />
<Bar dataKey="archived" name="Archived" stackId="a" maxBarSize={12}>
<Bar dataKey="archived" name="Archived" stackId="a" maxBarSize={14}>
{archiveData.map((entry, idx) => (
<Cell
key={`arch-${idx}`}
@@ -358,7 +401,14 @@ export default function IvantiCountsChart() {
/>
))}
</Bar>
<Bar dataKey="returned" name="Returned" stackId="a" fill={TEAL} maxBarSize={12} />
<Bar dataKey="returned" name="Returned" stackId="a" maxBarSize={14}>
{archiveData.map((entry, idx) => (
<Cell
key={`ret-${idx}`}
fill={TEAL}
/>
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>

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

@@ -1,8 +1,10 @@
{
"name": "cve-dashboard",
"version": "1.0.0",
"description": "",
"main": "index.js",
"description": "STEAM Security Dashboard — vulnerability management for NTS-AEO",
"author": "Jordan Ramos <jordan.ramos@spectrum.com>",
"license": "UNLICENSED",
"private": true,
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},

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