Compare commits
58 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
223b6f22b8
|
||
|
|
55795710d9
|
||
|
|
e9d6038636
|
||
|
|
c7274be66d
|
||
|
|
ba6e67c639
|
||
|
|
f257cfad88
|
||
|
|
a95fd03f5e
|
||
|
|
479c61b88f
|
||
|
|
2fed9221f1
|
||
|
|
8b985a21f8
|
||
|
|
55a4d299ef
|
||
|
|
28714eed47
|
||
|
|
c0e3139503
|
||
|
|
09db1c2ae9
|
||
|
|
c1a266f4f7
|
||
|
|
95aac03769
|
||
|
|
3e8bb1828c
|
||
|
|
8d47f67318
|
||
|
|
93efb70d1c
|
||
|
|
a8877728e0
|
||
|
|
b0cb67b975
|
||
|
|
c0b9e8a6fc
|
||
|
|
e1dfc35400
|
||
|
|
a2234ccc1a
|
||
|
|
e45e40d617
|
||
|
|
150a534943
|
||
|
|
5105ee2ff8
|
||
|
|
356ce23462
|
||
|
|
6465ac2a40
|
||
|
|
0f83f48cc6
|
||
|
|
56ceb81ea5
|
||
|
|
1dbde36b53
|
||
|
|
032a8df403
|
||
|
|
32ed65eb79
|
||
|
|
10239be83c
|
||
|
|
23ea3983c8
|
||
|
|
54d6e49cb1
|
||
|
|
29d8ecb9dd
|
||
|
|
f8b420f4e4
|
||
|
|
f3319ee1f5
|
||
|
|
a8d3909798
|
||
|
|
2396a828cc
|
||
|
|
4d8a6b9c6e
|
||
|
|
3b5dfee235
|
||
|
|
889d4658e5
|
||
|
|
6c7b8cb2fa
|
||
|
|
79f98414c4
|
||
|
|
d4c428248a
|
||
|
|
1f3833989a
|
||
|
|
c62409a8f6
|
||
|
|
af5fa11421
|
||
|
|
e8aa7038ad
|
||
|
|
e887fa8946
|
||
|
|
d9c47ec030
|
||
|
|
4e8f4cbb10
|
||
|
|
1cc8bd5a4c
|
||
|
|
50f14c14d2
|
||
|
|
4f40850fd2
|
@@ -3,6 +3,7 @@
|
||||
# =============================================================================
|
||||
# Executor: Docker (LXC 108 — 71.85.90.8)
|
||||
# Build/test jobs run in node:18 containers.
|
||||
# Release: v2.1.0
|
||||
# Deploy jobs run in alpine with SSH/rsync, targeting staging (71.85.90.9)
|
||||
# and production (71.85.90.6) via SSH.
|
||||
# =============================================================================
|
||||
@@ -99,7 +100,7 @@ test-backend:
|
||||
policy: pull
|
||||
script:
|
||||
- test -d node_modules || npm ci
|
||||
- ./node_modules/.bin/jest --ci --forceExit backend/__tests__/
|
||||
- ./node_modules/.bin/jest --ci --forceExit
|
||||
timeout: 5 minutes
|
||||
needs:
|
||||
- install-backend
|
||||
@@ -117,7 +118,7 @@ test-frontend:
|
||||
- node_modules/
|
||||
policy: pull
|
||||
script:
|
||||
- cd frontend && (test -d node_modules || npm ci) && cd .. && (test -d node_modules || npm ci) && cd frontend && CI=true npx react-scripts test --watchAll=false --ci
|
||||
- cd frontend && npm ci && cd .. && (test -d node_modules || npm ci) && cd frontend && CI=true npx react-scripts test --watchAll=false --ci
|
||||
timeout: 5 minutes
|
||||
needs:
|
||||
- install-frontend
|
||||
|
||||
@@ -75,11 +75,36 @@ Python dependencies: `pandas>=2.0.0`, `openpyxl>=3.0.0` (install via apt or venv
|
||||
|
||||
## Environment Configuration
|
||||
|
||||
- `backend/.env` — PORT, CORS_ORIGINS, SESSION_SECRET, NVD_API_KEY, Ivanti API credentials
|
||||
- `backend/.env` — PORT, CORS_ORIGINS, SESSION_SECRET, NVD_API_KEY, Ivanti API credentials, CARD API credentials
|
||||
- `frontend/.env` — REACT_APP_API_BASE, REACT_APP_API_HOST
|
||||
- Both `.env` files are gitignored; see `.env.example` files for templates.
|
||||
- React env vars are baked in at **build time** — you must rebuild (`npm run build`) after changing them.
|
||||
|
||||
### Key Backend Env Vars
|
||||
|
||||
| Variable | Purpose |
|
||||
|---|---|
|
||||
| `IVANTI_API_KEY` | RiskSense platform API key |
|
||||
| `IVANTI_CLIENT_ID` | RiskSense client ID (default: 1550) |
|
||||
| `IVANTI_BU_FILTER` | Comma-separated BU teams to sync findings for (default: `NTS-AEO-ACCESS-ENG,NTS-AEO-STEAM`) |
|
||||
| `IVANTI_FIRST_NAME` / `IVANTI_LAST_NAME` | Fallback Ivanti identity for workflow sync (used only if no per-user identities configured) |
|
||||
| `CARD_API_URL` | CARD API base URL (e.g., `https://card.charter.com`) |
|
||||
| `CARD_API_USER` / `CARD_API_PASS` | CARD OAuth credentials for Bearer token acquisition |
|
||||
| `CARD_SKIP_TLS` | Set to `true` to skip TLS verification (for SSL inspection proxies) |
|
||||
| `DATABASE_URL` | PostgreSQL connection string |
|
||||
|
||||
### CARD API and Ivanti Integration Details
|
||||
|
||||
See `.kiro/steering/integrations.md` for full API contracts, response shapes, and quirks for CARD, Ivanti, Atlas, and Jira.
|
||||
|
||||
### Ivanti Findings IPv6 Handling
|
||||
|
||||
Some Ivanti findings have no IPv4 address. The sync captures fallback addresses:
|
||||
- `qualys_ipv6` — from `hostAdditionalDetails[].["IPv6 Address"]` (resolves in CARD)
|
||||
- `primary_ipv6` — from `assetCustomAttributes['1550_host_6'][0]` (may not resolve in CARD)
|
||||
|
||||
Display priority in the UI: IPv4 > Qualys IPv6 (amber "Q" badge) > Primary IPv6 (indigo "v6" badge)
|
||||
|
||||
## Code Style & Lint Rules
|
||||
|
||||
### Unused Variables
|
||||
@@ -106,6 +131,18 @@ No ESLint is configured for backend — the pipeline uses `node -c` syntax check
|
||||
| Staging | http://71.85.90.9:3100 | Auto-deploy on master push |
|
||||
| Local dev (frontend only) | http://localhost:3000 | React dev server with hot-reload, proxies API to :3001 |
|
||||
|
||||
## Secure Context Constraints
|
||||
|
||||
All environments serve over **plain HTTP** (not HTTPS). This means browser APIs that require a [secure context](https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts) are **not available** in production or staging:
|
||||
|
||||
- `navigator.clipboard` (Clipboard API) — use `document.execCommand('copy')` with a hidden textarea instead
|
||||
- `navigator.share` (Web Share API)
|
||||
- `crypto.subtle` (Web Crypto API)
|
||||
- `navigator.credentials` (Credential Management API)
|
||||
- Service Workers and Push Notifications
|
||||
|
||||
When writing frontend code that needs clipboard, sharing, or crypto functionality, always use the non-secure fallback pattern. Do not use `navigator.clipboard.writeText()` or similar secure-context APIs.
|
||||
|
||||
## CI/CD Pipeline
|
||||
|
||||
### Infrastructure
|
||||
|
||||
62
CHANGELOG.md
62
CHANGELOG.md
@@ -6,6 +6,68 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this
|
||||
|
||||
---
|
||||
|
||||
## [2.3.0] — 2026-06-16
|
||||
|
||||
### Added
|
||||
|
||||
- **BU reassignment detail view** — click the "BU reassignment" count in the anomaly banner to see which specific findings moved and from/to which team
|
||||
- **Atlas sync scoped to active teams** — Atlas sync now respects BU scope and defaults to managed BUs, preventing cache pollution from unrelated teams
|
||||
- **Atlas known host distinction** — badge only renders for hosts Atlas actively tracks, suppressing noise from BUs not covered by Atlas (e.g., ACCESS-OPS)
|
||||
- **Per-user Ivanti identity** — FP workflow views filtered by individual Ivanti first/last name for personalized queue
|
||||
- **Searchable dropdowns for Granite Loader** — team, operation type, and status columns now use filterable select inputs
|
||||
- **IPv6 fallback display** — findings without IPv4 show Qualys IPv6 (amber Q badge) or primary IPv6 (indigo v6 badge)
|
||||
- **Remediate workflow type** — new workflow option in Ivanti Queue with remediation notes appended to Jira tickets
|
||||
- **DECOM workflow type** — added to RedirectModal workflow options
|
||||
- **View in CARD button** — added to tooltip and action modal for direct CARD web UI navigation
|
||||
- **CARD asset-search by Host ID** — faster lookup path for enrichment operations
|
||||
- **Per-metric compliance views** — replaced cross-metric aggregates with per-metric summary cards
|
||||
- **Non-metric category filters** on compliance page
|
||||
- **Ivanti Findings Data Guide** — Knowledge Base article explaining common data patterns (missing CVEs, BU reassignment, Atlas badges, etc.)
|
||||
- **Markdown table rendering** in Knowledge Base viewer (remark-gfm support)
|
||||
- **In-app notifications** table and infrastructure
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Drift checker re-classifying same findings every sync** — archived findings were never removed from ivanti_findings, causing ~500 false re-classifications per sync. Now properly cleaned up after archive detection
|
||||
- **Atlas Coverage tab not responding to scope changes** — metrics and status endpoints now filter by active teams and re-fetch on scope switch
|
||||
- **Knowledge Base content/download failing for relative file paths** — sendFile now resolves paths correctly
|
||||
- **remark-gfm compatibility** — upgraded to v4 for react-markdown v10 (was causing blank KB viewer)
|
||||
- **SearchableSelect** — only opens on focus, closes properly on blur/select
|
||||
- **Clipboard copy on HTTP** — use execCommand fallback for non-secure contexts
|
||||
- **Empty description in single-item Jira modal** on ReportingPage
|
||||
- **CARD enrich for items without IP** — uses host_id lookup as fallback
|
||||
- **update_token error handling** — shows CARD link for assets that can't be actioned via API
|
||||
- **Decom workflow migration** — includes Remediate in state check constraint
|
||||
|
||||
### Changed
|
||||
|
||||
- Atlas sync defaults to `IVANTI_MANAGED_BUS` when no scope is specified instead of syncing all BUs
|
||||
- BU change history API accepts `since` and `limit` query params for scoped queries
|
||||
- Anomaly banner uses 60-minute lookback window to capture drift checker records
|
||||
- Archive activity chart should now show near-zero on normal syncs (only genuinely new disappearances)
|
||||
|
||||
---
|
||||
|
||||
## [2.2.0] — 2026-06-04
|
||||
|
||||
### Features
|
||||
|
||||
- **Group by Host toggle** on the Ivanti findings table — collapses duplicate assets (same hostname + IP) with multiple finding IDs into expandable host rows. Hosts with only one finding remain as flat rows. Toggle between grouped and flat views from the toolbar.
|
||||
- **CARD ownership tooltip on IP hover** — hover over any IP address in the findings table to see CARD asset ownership data (confirmed/unconfirmed/candidate teams) in an interactive tooltip. Results cached per session for instant re-display.
|
||||
- **CARD direct action modal** — click "Actions" in the CARD tooltip to open a full confirm/decline/redirect modal that works directly against the CARD API without needing a queue item.
|
||||
- **Inline view panel** in the Archer Template Manager with per-section copy buttons
|
||||
- **Queue item redirect in place** — pending queue items can now be redirected without duplicating
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Improve CARD decline error diagnostics and prevent accidental modal dismiss
|
||||
- CARD teams fetch retries silently up to 3x on failure with increasing delay
|
||||
- Redirect dropdowns show owner-data teams as fallback when the full teams API fails
|
||||
- CARD tooltip uses quick mode (CTEC suffix only, 15s timeout) to avoid multi-minute waits
|
||||
- Timeouts (504) are not cached — re-hover will retry the lookup
|
||||
|
||||
---
|
||||
|
||||
## [2.1.0] — 2026-06-06
|
||||
|
||||
### Features
|
||||
|
||||
851
README.md
851
README.md
@@ -1,6 +1,44 @@
|
||||
# STEAM Security Dashboard v1.0.0
|
||||
# STEAM Security Dashboard v2.2.0
|
||||
|
||||
A self-hosted vulnerability management dashboard for the NTS-AEO-STEAM and NTS-AEO-ACCESS-ENG business units. Centralises CVE tracking, Ivanti host finding triage, AEO compliance posture, FP/Archer/CARD exception workflows, and internal documentation in a single interface.
|
||||
A self-hosted vulnerability management dashboard for the NTS-AEO-STEAM, NTS-AEO-ACCESS-ENG, NTS-AEO-ACCESS-OPS, and NTS-AEO-INTELDEV business units. Centralises CVE tracking, Ivanti host finding triage, AEO compliance posture monitoring, CCP Metrics cross-org compliance reporting, FP/Archer/CARD/GRANITE/DECOM exception workflows, CARD asset ownership management, Granite Loader Sheet generation, Jira ticket management, and internal documentation in a single interface.
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Quick Start](#quick-start)
|
||||
- [Features](#features)
|
||||
- [Home — CVE Management](#home--cve-management)
|
||||
- [Reporting — Ivanti Host Findings](#reporting--ivanti-host-findings)
|
||||
- [Ivanti Queue — Workflow Staging](#ivanti-queue--workflow-staging)
|
||||
- [FP Workflow Submission](#fp-workflow-submission)
|
||||
- [Archer Tickets](#archer-tickets)
|
||||
- [Archer Template Library](#archer-template-library)
|
||||
- [AEO Compliance](#aeo-compliance)
|
||||
- [CCP Metrics — Multi-Vertical Compliance](#ccp-metrics--multi-vertical-compliance)
|
||||
- [CARD Asset Ownership](#card-asset-ownership)
|
||||
- [Granite Loader Sheet](#granite-loader-sheet)
|
||||
- [Atlas Action Plans](#atlas-action-plans)
|
||||
- [Jira Integration](#jira-integration)
|
||||
- [Finding Archive Tracking](#finding-archive-tracking)
|
||||
- [Findings Trend](#findings-trend)
|
||||
- [Knowledge Base](#knowledge-base)
|
||||
- [Exports](#exports)
|
||||
- [In-App Notifications](#in-app-notifications)
|
||||
- [Feedback — GitLab Integration](#feedback--gitlab-integration)
|
||||
- [Access Control](#access-control)
|
||||
- [Project Structure](#project-structure)
|
||||
- [Tech Stack](#tech-stack)
|
||||
- [Configuration](#configuration)
|
||||
- [Database Schema](#database-schema)
|
||||
- [Migrations](#migrations)
|
||||
- [API Reference](#api-reference)
|
||||
- [CI/CD Pipeline](#cicd-pipeline)
|
||||
- [Documentation](#documentation)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
- [License](#license)
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
@@ -34,7 +72,7 @@ cp backend/.env.example backend/.env
|
||||
# openssl rand -base64 32
|
||||
```
|
||||
|
||||
See `backend/.env.example` for all available options including `DATABASE_URL`, Ivanti API, Jira, and Atlas integration keys.
|
||||
See `backend/.env.example` for all available options including `DATABASE_URL`, Ivanti API, Jira, Atlas, CARD, and GitLab integration keys.
|
||||
|
||||
### Start PostgreSQL
|
||||
|
||||
@@ -47,6 +85,14 @@ chmod +x scripts/deploy-postgres.sh
|
||||
|
||||
For fresh installs without an existing SQLite database, the script creates the schema and skips migration.
|
||||
|
||||
### Run Migrations
|
||||
|
||||
```bash
|
||||
cd backend && node migrations/run-all.js
|
||||
```
|
||||
|
||||
Migrations are idempotent and safe to re-run.
|
||||
|
||||
### Build and Run
|
||||
|
||||
```bash
|
||||
@@ -57,80 +103,801 @@ cd frontend && npm run build && cd ..
|
||||
./start-servers.sh
|
||||
```
|
||||
|
||||
Dashboard: http://localhost:3000 · API: http://localhost:3001
|
||||
Dashboard: http://localhost:3001
|
||||
|
||||
The helper scripts use `systemctl` under the hood — the systemd units in `systemd/` must be installed first. See the full manual for setup instructions.
|
||||
The helper scripts use `systemctl` under the hood — the systemd units in `systemd/` must be installed first. See the full reference manual for setup instructions.
|
||||
|
||||
### Interactive Configuration Wizard
|
||||
|
||||
For first-time deployments, the wizard walks through all required settings:
|
||||
|
||||
```bash
|
||||
node configure.js
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
| Feature | Description |
|
||||
|---------|-------------|
|
||||
| **CVE Management** | Track CVEs across multiple vendors with document storage and NVD auto-fill |
|
||||
| **Reporting** | Ivanti host finding triage with donut charts, inline editing, advanced filtering, CSV/XLSX export |
|
||||
| **Ivanti Queue** | Personal staging list for batch FP, Archer, CARD, and Granite workflows |
|
||||
| **FP Workflow** | Submit false positive workflows directly to Ivanti API with attachments |
|
||||
| **Compliance** | Weekly AEO xlsx upload with diff preview, drift detection, per-team metric health cards |
|
||||
| **Archive Tracking** | Automatic detection of disappeared/returned findings with BU reassignment classification |
|
||||
| **Findings Trend** | Historical open vs closed chart with archive activity sparkline and shift reason tooltips |
|
||||
| **Jira Integration** | Create, sync, and track Jira Data Center tickets linked to CVE/vendor pairs |
|
||||
| **Archer Tickets** | Track risk acceptance exceptions (EXC numbers) linked to findings |
|
||||
| **CARD API** | Granite/CARD asset lookup integration for network device workflows |
|
||||
| **Knowledge Base** | Internal document library with inline PDF/Markdown viewing |
|
||||
| **Access Control** | Four user groups (Admin, Standard_User, Leadership, Read_Only) with full audit trail |
|
||||
### Home — CVE Management
|
||||
|
||||
Searchable CVE list with per-vendor tracking and document storage. NVD API integration auto-populates severity, description, and publication date from the National Vulnerability Database.
|
||||
|
||||
**Capabilities:**
|
||||
- Create, edit, and delete CVE entries with vendor association
|
||||
- Upload advisory documents (PDF, email, screenshot, patch notes) per CVE/vendor pair
|
||||
- NVD auto-lookup on CVE ID entry (rate-limited, API key optional for higher throughput)
|
||||
- Filter by vendor, severity, status, and free-text search
|
||||
- Required document tracking per vendor with mandatory/optional flags
|
||||
- Calendar widget with SLA date highlighting
|
||||
|
||||
### Reporting — Ivanti Host Findings
|
||||
|
||||
Ivanti/RiskSense host finding triage with donut charts, advanced filtering, inline editing, CSV/XLSX export, and multi-BU scope.
|
||||
|
||||
**Capabilities:**
|
||||
- Sync open host findings from Ivanti API (24-hour cadence, configurable via `IVANTI_BU_FILTER`)
|
||||
- Group by Host toggle — collapses duplicate assets (same hostname + IP) with multiple findings into expandable rows
|
||||
- CARD ownership tooltip on IP hover — displays confirmed/unconfirmed/candidate teams from CARD API
|
||||
- CARD direct action modal — confirm, decline, or redirect ownership without a queue item
|
||||
- Advanced column filtering, severity sorting, and free-text search
|
||||
- Inline note editing with override hostname/DNS fields
|
||||
- Per-user Ivanti identity for filtered FP workflow views
|
||||
- Multi-select BU picker for scoping visible findings
|
||||
- CSV and XLSX export with current filter state
|
||||
- Add findings to the todo queue for batch workflow processing
|
||||
|
||||
### Ivanti Queue — Workflow Staging
|
||||
|
||||
Personal staging list for batch-processing FP, Archer, CARD, GRANITE, DECOM, and Remediate workflows.
|
||||
|
||||
**Capabilities:**
|
||||
- Add individual findings or bulk-select from the Reporting page
|
||||
- Six workflow types: FP (False Positive), Archer (Risk Acceptance), CARD (Ownership), GRANITE (Loader Sheet), DECOM (Decommission), Remediate
|
||||
- Collapsible sections per workflow type
|
||||
- Multi-item Jira ticket creation (consolidation modal)
|
||||
- Ticket link display on completed items
|
||||
- Clear Completed with FK-safe deletion
|
||||
- Redirect pending items between workflow types without duplication
|
||||
- DECOM items auto-note and auto-hide the finding on completion
|
||||
|
||||
### FP Workflow Submission
|
||||
|
||||
Submit False Positive workflows directly to the Ivanti API with attachments and lifecycle tracking.
|
||||
|
||||
**Capabilities:**
|
||||
- Batch-select queue items for FP submission
|
||||
- Attach supporting documents (10MB limit per file)
|
||||
- Configurable expiration date, scope override, and reason
|
||||
- Lifecycle tracking: submitted, approved, rejected, rework, resubmitted
|
||||
- Edit and re-submit rejected workflows
|
||||
- Re-queue findings from rejected submissions
|
||||
- Auto-clear approved submissions, dismiss rejected
|
||||
- Per-user workflow history with attachment results
|
||||
- Collapsible submissions panel
|
||||
|
||||
### Archer Tickets
|
||||
|
||||
Track Archer risk acceptance exceptions (EXC numbers) linked to CVE/vendor pairs.
|
||||
|
||||
**Capabilities:**
|
||||
- Create Archer ticket records with EXC number, URL, and status
|
||||
- Status workflow: Draft, Open, Under Review, Accepted
|
||||
- Link tickets to specific CVE/vendor combinations
|
||||
- Full CRUD with audit logging
|
||||
|
||||
### Archer Template Library
|
||||
|
||||
Template management system for Archer Risk Acceptance forms. Stores static content (Environment Overview, Segmentation, Mitigating Controls) organised by Vendor, Platform, and Model.
|
||||
|
||||
**Capabilities:**
|
||||
- Full CRUD with clone, search/filter, and per-section copy-to-clipboard
|
||||
- Inline view panel with per-section copy buttons
|
||||
- Template selector integrated into the Ivanti Queue for Archer workflow items
|
||||
- Organised hierarchy: Vendor > Platform > Model
|
||||
- Accessible from nav drawer (Template Mgr) and Ivanti Queue Archer items
|
||||
|
||||
### AEO Compliance
|
||||
|
||||
Weekly AEO compliance xlsx upload with diff preview, drift detection, per-team metric health cards, and device-level violation tracking.
|
||||
|
||||
**Capabilities:**
|
||||
- Upload weekly compliance spreadsheets (xlsx) with automated parsing
|
||||
- Diff preview showing new, resolved, and recurring non-compliant items
|
||||
- Per-team metric health cards with pass/fail indicators
|
||||
- Device-level violation detail panel with notes per hostname/metric pair
|
||||
- Bulk notes import from CSV
|
||||
- Compliance history tracking across uploads
|
||||
- Per-metric estimated resolution date display
|
||||
- Notes with group_id for batch operations
|
||||
|
||||
### CCP Metrics — Multi-Vertical Compliance
|
||||
|
||||
Cross-organisational VCL (Vulnerability Compliance Level) upload and reporting for multiple verticals. The CCP Metrics page provides executive-level visibility into compliance posture across teams.
|
||||
|
||||
**Capabilities:**
|
||||
- Multi-vertical VCL xlsx upload with Summary and Detail sheet parsing
|
||||
- Metric-first hierarchy with per-metric remediation plans
|
||||
- Per-metric forecast burndown chart
|
||||
- Aggregated burndown forecast on overview page
|
||||
- Sub-team drill-down with intermediate view and per-team breakdowns
|
||||
- Non-Compliant stat clickable with metric breakdown buttons
|
||||
- Compliant/total counts on metric summary cards
|
||||
- LIVE and LAST REPORT badges showing data freshness
|
||||
- Data management panel — delete vertical, rollback upload, reset all
|
||||
- Exec report page with exportable summaries
|
||||
- Inline-editable team fields on vertical metadata
|
||||
- Device metadata fields for asset context
|
||||
|
||||
### CARD Asset Ownership
|
||||
|
||||
CARD API integration for asset ownership management — confirm, decline, and redirect ownership for network assets.
|
||||
|
||||
**Capabilities:**
|
||||
- Owner lookup by IP address or Ivanti Host ID (asset-search)
|
||||
- Confirm, decline, and redirect ownership actions via API
|
||||
- Suffix-guessing fallback when no host_id available (CTEC, NATL, CHTR, COML, RESI, WIFI, VOIP)
|
||||
- IP address validation before mutation operations
|
||||
- Update_token handling for safe concurrent operations
|
||||
- Cached tooltip results per session
|
||||
- Team assets endpoint for batch enrichment
|
||||
- Quick mode (CTEC suffix only, 15s timeout) for tooltip performance
|
||||
|
||||
### Granite Loader Sheet
|
||||
|
||||
Generate Granite Loader Sheets with CARD enrichment, searchable picklists, per-row editing, and XLSX export.
|
||||
|
||||
**Capabilities:**
|
||||
- Generate loader sheets from queue items or the Reporting page
|
||||
- CARD enrichment — pull NCIM, Qualys, Netops Granite data per asset
|
||||
- Searchable picklists for teams, statuses, operation types
|
||||
- Per-row inline editing before export
|
||||
- Column groups with configurable visibility
|
||||
- XLSX export with formatting
|
||||
|
||||
### Atlas Action Plans
|
||||
|
||||
Atlas InfoSec action plan tracking with per-host vulnerability mapping and local cache for badge rendering.
|
||||
|
||||
**Capabilities:**
|
||||
- View action plans linked to Ivanti host findings via host_id
|
||||
- Create remediation, risk acceptance, and compensating control plans
|
||||
- AtlasBadge component on findings rows indicating plan existence
|
||||
- Slide-out detail panel with plan metadata
|
||||
- Local cache (`atlas_action_plans_cache` table) for instant badge rendering
|
||||
- Manual cache refresh triggers re-fetch from Atlas API
|
||||
- Qualys vulnerability mapping per host
|
||||
|
||||
### Jira Integration
|
||||
|
||||
Create, sync, and track Jira Data Center tickets linked to CVE/vendor pairs and Ivanti queue items.
|
||||
|
||||
**Capabilities:**
|
||||
- Create tickets from CVE records or Ivanti queue items
|
||||
- Multi-item Jira ticket creation from the consolidation modal
|
||||
- Flexible ticket creation — CVE/Vendor fields optional, source context tracking
|
||||
- Vendor-specific issue type dropdown with per-vendor project keys
|
||||
- JQL-based ticket lookup and sync
|
||||
- Raw Jira status display (no status mapping)
|
||||
- Save to Dashboard from Jira lookup results
|
||||
- Dedicated Jira page for managing tickets
|
||||
- Rate limiting with configurable window and burst limits
|
||||
- Blocked dangerous endpoints (bulk delete, user management)
|
||||
|
||||
### Finding Archive Tracking
|
||||
|
||||
Automatic detection of disappeared and returned findings with BU reassignment classification and anomaly logging.
|
||||
|
||||
**Capabilities:**
|
||||
- Detect findings that disappear between syncs — classify as ARCHIVED, RETURNED, CLOSED, or CLOSED_GONE
|
||||
- BU reassignment tracking with history log
|
||||
- Sync anomaly detection with significance thresholds
|
||||
- Anomaly banner component on the Reporting page
|
||||
- Archive summary bar with state distribution
|
||||
- Transition history with severity-at-transition recording
|
||||
- Return classification (original finding restored vs. new duplicate)
|
||||
- Configurable `IVANTI_MANAGED_BUS` for drift classification scope
|
||||
|
||||
### Findings Trend
|
||||
|
||||
Historical open vs closed findings chart with per-BU trend lines, archive activity sparkline, and shift reason tooltips.
|
||||
|
||||
**Capabilities:**
|
||||
- Ivanti counts history chart (open/closed over time)
|
||||
- Per-BU trend lines via `ivanti_counts_history_by_bu`
|
||||
- Archive activity sparkline overlay
|
||||
- Shift reason tooltips from anomaly log
|
||||
|
||||
### Knowledge Base
|
||||
|
||||
Internal document library for policies, guides, and reference material.
|
||||
|
||||
**Capabilities:**
|
||||
- Upload documents (PDF, Markdown, Word, Excel)
|
||||
- Inline PDF and Markdown viewing
|
||||
- Category-based organisation with search
|
||||
- Slug-based URL routing
|
||||
|
||||
### Exports
|
||||
|
||||
Dedicated Exports page with pre-built export cards for common data pulls.
|
||||
|
||||
**Capabilities:**
|
||||
- Jira Tickets export
|
||||
- CCP Metrics export
|
||||
- Remediation Status export
|
||||
- CSV and XLSX format options
|
||||
|
||||
### In-App Notifications
|
||||
|
||||
Native notification system replacing the previous Webex bot integration.
|
||||
|
||||
**Capabilities:**
|
||||
- Per-user notification bell with unread count
|
||||
- Notification types for sync events, workflow completions, and system alerts
|
||||
- Mark as read / dismiss actions
|
||||
- Persistent storage in `notifications` table
|
||||
|
||||
### Feedback — GitLab Integration
|
||||
|
||||
In-app bug reports and feature requests submitted directly to the GitLab project as issues.
|
||||
|
||||
**Capabilities:**
|
||||
- Feedback modal accessible from the nav drawer
|
||||
- Bug report and feature request templates
|
||||
- Submitted as GitLab issues via PAT authentication
|
||||
- GitLab webhook receiver for issue lifecycle events (label changes, closes)
|
||||
- Webhook secret validation for security
|
||||
|
||||
### Access Control
|
||||
|
||||
Four user groups with role-based permissions and full audit trail.
|
||||
|
||||
| Group | Permissions |
|
||||
|-------|------------|
|
||||
| Admin | Full CRUD, user management, audit log access, system configuration, data management |
|
||||
| Standard_User | Create/update operations, FP workflow submission, queue management |
|
||||
| Leadership | Read access to all data plus compliance and export views |
|
||||
| Read_Only | Read-only access to all data |
|
||||
|
||||
**Additional capabilities:**
|
||||
- Per-user BU team assignments (multi-BU tenancy)
|
||||
- Per-user Ivanti identity (first/last name) for workflow filtering
|
||||
- Cookie-based sessions with 24-hour expiry (httpOnly)
|
||||
- Login rate limiting (20 attempts per 15-minute window)
|
||||
- Full audit trail for all state-changing operations
|
||||
- User profile panel with password change
|
||||
|
||||
---
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
cve-dashboard/
|
||||
├── backend/
|
||||
│ ├── server.js # Express API server
|
||||
│ ├── server.js # Express API — middleware, CVE/document routes inline
|
||||
│ ├── db.js # PostgreSQL connection pool (pg)
|
||||
│ ├── db-schema.sql # Complete DDL for fresh Postgres setup
|
||||
│ ├── setup-postgres.js # Schema initializer (runs db-schema.sql)
|
||||
│ ├── routes/ # API route handlers
|
||||
│ ├── helpers/ # API clients (Ivanti, Jira, Atlas, CARD)
|
||||
│ ├── middleware/ # Auth middleware
|
||||
│ ├── migrations/ # Schema migrations (legacy SQLite deployments)
|
||||
│ └── scripts/ # Compliance parser, data import utilities
|
||||
│ ├── setup.js # One-time DB init + default admin creation
|
||||
│ ├── routes/
|
||||
│ │ ├── auth.js # Login, logout, session validation
|
||||
│ │ ├── users.js # User CRUD, role/group management
|
||||
│ │ ├── auditLog.js # Audit log queries
|
||||
│ │ ├── nvdLookup.js # NVD API proxy
|
||||
│ │ ├── knowledgeBase.js # Document library CRUD
|
||||
│ │ ├── archerTickets.js # Archer EXC ticket tracking
|
||||
│ │ ├── archerTemplates.js # Archer template library CRUD + clone
|
||||
│ │ ├── ivantiWorkflows.js # Ivanti FP workflow batch queries
|
||||
│ │ ├── ivantiFindings.js # Ivanti findings sync, query, inline edit
|
||||
│ │ ├── ivantiTodoQueue.js # Per-user queue staging
|
||||
│ │ ├── ivantiArchive.js # Finding archive tracking + anomaly log
|
||||
│ │ ├── ivantiFpWorkflow.js# FP workflow submission to Ivanti API
|
||||
│ │ ├── compliance.js # AEO compliance upload + items + notes
|
||||
│ │ ├── vclMultiVertical.js# VCL/CCP multi-vertical compliance
|
||||
│ │ ├── atlas.js # Atlas action plan proxy + cache
|
||||
│ │ ├── jiraTickets.js # Jira CRUD + REST API integration
|
||||
│ │ ├── cardApi.js # CARD ownership proxy + asset-search
|
||||
│ │ ├── notifications.js # In-app notification system
|
||||
│ │ ├── feedback.js # GitLab issue creation for bug/feature
|
||||
│ │ └── webhooks.js # GitLab webhook receiver
|
||||
│ ├── helpers/
|
||||
│ │ ├── auditLog.js # logAudit() — fire-and-forget DB insert
|
||||
│ │ ├── cardApi.js # CARD API — OAuth token, owner lookup, asset-search
|
||||
│ │ ├── ivantiApi.js # Ivanti/RiskSense HTTP helpers
|
||||
│ │ ├── atlasApi.js # Atlas action plan API
|
||||
│ │ ├── jiraApi.js # Jira ticket creation + rate limiter
|
||||
│ │ ├── driftChecker.js # BU drift detection between syncs
|
||||
│ │ ├── vclHelpers.js # VCL metric calculation helpers
|
||||
│ │ └── teams.js # Team validation helpers
|
||||
│ ├── middleware/
|
||||
│ │ └── auth.js # requireAuth(), requireGroup(...groups)
|
||||
│ ├── migrations/ # Sequential migration scripts (idempotent)
|
||||
│ │ └── run-all.js # Run all migrations in order
|
||||
│ └── scripts/ # Python utilities (compliance parsing)
|
||||
│
|
||||
├── frontend/
|
||||
│ ├── src/
|
||||
│ │ ├── App.js # Main app with routing
|
||||
│ │ ├── components/ # React components
|
||||
│ │ └── contexts/ # Auth context
|
||||
│ └── public/
|
||||
│ └── src/
|
||||
│ ├── App.js # Main app — routing, CVE list, filters, modals
|
||||
│ ├── App.css # Global styles and CSS variables
|
||||
│ ├── contexts/
|
||||
│ │ └── AuthContext.js # Auth state provider (login, logout, role helpers)
|
||||
│ ├── utils/
|
||||
│ │ ├── graniteLoaderConfig.js # Granite column definitions and groups
|
||||
│ │ ├── graniteLoaderExport.js # XLSX generation logic
|
||||
│ │ └── graniteLoaderPicklists.js # Searchable dropdown options
|
||||
│ └── components/
|
||||
│ ├── LoginForm.js
|
||||
│ ├── NavDrawer.js
|
||||
│ ├── UserMenu.js
|
||||
│ ├── UserProfilePanel.js
|
||||
│ ├── CalendarWidget.js
|
||||
│ ├── UserManagement.js
|
||||
│ ├── AuditLog.js
|
||||
│ ├── NvdSyncModal.js
|
||||
│ ├── KnowledgeBaseModal.js
|
||||
│ ├── KnowledgeBaseViewer.js
|
||||
│ ├── CardOwnerTooltip.js
|
||||
│ ├── CardDetailModal.js
|
||||
│ ├── CardActionModal.js
|
||||
│ ├── RedirectModal.js
|
||||
│ ├── LoaderModal.js
|
||||
│ ├── SearchableSelect.js
|
||||
│ ├── AtlasBadge.js
|
||||
│ ├── AtlasIcon.js
|
||||
│ ├── AtlasSlideOutPanel.js
|
||||
│ ├── AdminScopeToggle.js
|
||||
│ ├── ConfirmModal.js
|
||||
│ ├── ConsolidationModal.js
|
||||
│ ├── CveTooltip.js
|
||||
│ ├── DeleteConfirmModal.js
|
||||
│ ├── FeedbackModal.js
|
||||
│ ├── NotificationBell.js
|
||||
│ ├── RemediationModal.js
|
||||
│ ├── TemplateFormModal.js
|
||||
│ ├── TemplateSelector.js
|
||||
│ └── pages/
|
||||
│ ├── AdminPage.js
|
||||
│ ├── ReportingPage.js
|
||||
│ ├── IvantiTodoQueuePage.js
|
||||
│ ├── CompliancePage.js
|
||||
│ ├── ComplianceUploadModal.js
|
||||
│ ├── ComplianceDetailPanel.js
|
||||
│ ├── ComplianceChartsPanel.js
|
||||
│ ├── CCPMetricsPage.js
|
||||
│ ├── VCLReportPage.js
|
||||
│ ├── MetricInfoPanel.js
|
||||
│ ├── BulkUploadModal.js
|
||||
│ ├── MultiVerticalUploadModal.js
|
||||
│ ├── IvantiCountsChart.js
|
||||
│ ├── AnomalyBanner.js
|
||||
│ ├── ArchiveSummaryBar.js
|
||||
│ ├── ArcherPage.js
|
||||
│ ├── ArcherTemplatePage.js
|
||||
│ ├── JiraPage.js
|
||||
│ ├── KnowledgeBasePage.js
|
||||
│ └── ExportsPage.js
|
||||
│
|
||||
├── docs/
|
||||
│ ├── api/ # API specs (Ivanti, Atlas, Jira)
|
||||
│ ├── design/ # Design system, workflow diagrams
|
||||
│ ├── guides/ # User guides, full reference manual
|
||||
│ ├── architecture/ # Architecture proposals (AD/SAML, split)
|
||||
│ ├── design/ # Design system, workflow colour codes
|
||||
│ ├── guides/ # User guides, full reference manual, VCL calculations
|
||||
│ ├── operations/ # Operational logs and connectivity tests
|
||||
│ ├── security/ # Security audits and remediation plans
|
||||
│ ├── testing/ # Test plans and scripts
|
||||
│ └── troubleshooting/ # Investigation scripts and reports
|
||||
├── docker-compose.yml # PostgreSQL 16 container definition
|
||||
│
|
||||
├── scripts/
|
||||
│ └── deploy-postgres.sh # One-time deployment: container, schema, migration
|
||||
├── systemd/ # systemd service files
|
||||
├── start-servers.sh
|
||||
└── stop-servers.sh
|
||||
│ ├── deploy-postgres.sh # One-time: container, schema, migration
|
||||
│ └── reset-and-migrate.sh # Dev utility: reset DB and re-run migrations
|
||||
├── deploy/
|
||||
│ ├── cve-backend-production.service
|
||||
│ ├── cve-backend-staging.service
|
||||
│ └── setup-staging.sh
|
||||
├── systemd/
|
||||
│ ├── cve-backend.service
|
||||
│ └── cve-frontend.service
|
||||
├── configure.js # Interactive configuration wizard
|
||||
├── docker-compose.yml # PostgreSQL 16 container definition
|
||||
├── start-servers.sh # Start backend (serves API + frontend build)
|
||||
├── stop-servers.sh # Stop backend
|
||||
└── CHANGELOG.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Layer | Technology |
|
||||
|-------|------------|
|
||||
| Backend | Node.js 18+, Express 5 |
|
||||
| Database | PostgreSQL 16 (Docker, port 5433) |
|
||||
| Frontend | React 19, Recharts, Lucide React |
|
||||
| Auth | bcryptjs, cookie-based sessions, express-rate-limit |
|
||||
| Compliance | Python 3, pandas, openpyxl |
|
||||
| Auth | bcryptjs, cookie-based sessions (httpOnly, 24h expiry), express-rate-limit |
|
||||
| File uploads | Multer 2 (10MB limit) |
|
||||
| Frontend | React 19 (Create React App / react-scripts 5) |
|
||||
| Frontend serving | Express serves `frontend/build/` as static files on port 3001 |
|
||||
| UI Icons | lucide-react |
|
||||
| Charts | recharts |
|
||||
| Spreadsheet parsing | xlsx (frontend), pandas + openpyxl (backend Python scripts) |
|
||||
| Markdown rendering | react-markdown + rehype-sanitize |
|
||||
| Diagrams | mermaid |
|
||||
| Testing | Jest 30 (backend property tests), React Testing Library (frontend) |
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
All configuration lives in `backend/.env`. Copy `backend/.env.example` as a starting point.
|
||||
|
||||
| Variable | Purpose | Default |
|
||||
|---|---|---|
|
||||
| `PORT` | Backend server port | `3001` |
|
||||
| `API_HOST` | Backend bind address | `localhost` |
|
||||
| `CORS_ORIGINS` | Comma-separated allowed origins | `http://localhost:3000` |
|
||||
| `SESSION_SECRET` | **Required.** Session signing key. Generate with `openssl rand -base64 32` | — |
|
||||
| `DATABASE_URL` | PostgreSQL connection string | — |
|
||||
| `NVD_API_KEY` | NVD API key (increases rate limit from 5 to 50 req/30s) | — |
|
||||
| `IVANTI_API_KEY` | Ivanti/RiskSense API key | — |
|
||||
| `IVANTI_CLIENT_ID` | Ivanti client ID | `1550` |
|
||||
| `IVANTI_FIRST_NAME` | Fallback Ivanti identity (first name) | — |
|
||||
| `IVANTI_LAST_NAME` | Fallback Ivanti identity (last name) | — |
|
||||
| `IVANTI_BU_FILTER` | Comma-separated BU values to sync | `NTS-AEO-ACCESS-ENG,NTS-AEO-STEAM` |
|
||||
| `IVANTI_MANAGED_BUS` | BUs considered "managed" for drift classification | `NTS-AEO-ACCESS-ENG,NTS-AEO-STEAM` |
|
||||
| `IVANTI_SKIP_TLS` | Skip TLS verification for SSL inspection proxies | `false` |
|
||||
| `ATLAS_API_URL` | Atlas InfoSec API base URL | — |
|
||||
| `ATLAS_API_USER` | Atlas Basic Auth username | — |
|
||||
| `ATLAS_API_PASS` | Atlas Basic Auth password | — |
|
||||
| `ATLAS_SKIP_TLS` | Skip Atlas TLS verification | `false` |
|
||||
| `JIRA_BASE_URL` | Jira Data Center REST API base URL | — |
|
||||
| `JIRA_AUTH_METHOD` | Auth method: `basic` or `pat` | `basic` |
|
||||
| `JIRA_API_USER` | Jira Basic Auth username | — |
|
||||
| `JIRA_API_TOKEN` | Jira Basic Auth token | — |
|
||||
| `JIRA_PAT` | Jira Personal Access Token (when `JIRA_AUTH_METHOD=pat`) | — |
|
||||
| `JIRA_PROJECT_KEY` | Default Jira project key | — |
|
||||
| `JIRA_ISSUE_TYPE` | Default issue type | `Task` |
|
||||
| `JIRA_SKIP_TLS` | Skip Jira TLS verification | `false` |
|
||||
| `CARD_API_URL` | CARD API base URL | — |
|
||||
| `CARD_API_USER` | CARD OAuth username | — |
|
||||
| `CARD_API_PASS` | CARD OAuth password | — |
|
||||
| `CARD_SKIP_TLS` | Skip CARD TLS verification | `false` |
|
||||
| `GITLAB_URL` | GitLab instance URL for feedback integration | `http://steam-gitlab.charterlab.com` |
|
||||
| `GITLAB_PROJECT_ID` | GitLab project numeric ID | — |
|
||||
| `GITLAB_PAT` | GitLab project access token (api scope) | — |
|
||||
| `GITLAB_WEBHOOK_SECRET` | Shared secret for webhook validation | — |
|
||||
|
||||
> `SESSION_SECRET` and `DATABASE_URL` are required for the backend to start. All integration keys are optional — features degrade gracefully when their keys are absent.
|
||||
|
||||
---
|
||||
|
||||
## Database Schema
|
||||
|
||||
PostgreSQL 16 with the following tables:
|
||||
|
||||
| Table | Purpose |
|
||||
|-------|---------|
|
||||
| `cves` | CVE records with vendor association, severity, status |
|
||||
| `documents` | Uploaded advisory documents linked to CVE/vendor pairs |
|
||||
| `required_documents` | Per-vendor required document types |
|
||||
| `users` | User accounts with role, group, BU teams, Ivanti identity |
|
||||
| `sessions` | Active session tokens with expiry |
|
||||
| `audit_logs` | Immutable audit trail for all state-changing operations |
|
||||
| `jira_tickets` | Locally tracked Jira tickets linked to CVE/vendor |
|
||||
| `archer_tickets` | Archer risk acceptance exceptions (EXC numbers) |
|
||||
| `knowledge_base` | Internal document library entries |
|
||||
| `ivanti_findings` | Individual Ivanti host findings (synced from API) |
|
||||
| `ivanti_sync_state` | Sync metadata — last run time, status, error |
|
||||
| `ivanti_counts_cache` | Cached open/closed counts and FP workflow counts |
|
||||
| `ivanti_counts_history` | Historical open/closed counts over time |
|
||||
| `ivanti_counts_history_by_bu` | Per-BU historical counts |
|
||||
| `ivanti_fp_submissions` | FP workflow submissions with lifecycle tracking |
|
||||
| `ivanti_fp_submission_history` | Edit history for FP submissions |
|
||||
| `ivanti_todo_queue` | Per-user workflow staging queue |
|
||||
| `ivanti_finding_archives` | Archived/returned/closed finding records |
|
||||
| `ivanti_archive_transitions` | State transition history for archived findings |
|
||||
| `ivanti_sync_anomaly_log` | Sync anomaly detection log |
|
||||
| `ivanti_finding_bu_history` | BU reassignment history per finding |
|
||||
| `atlas_action_plans_cache` | Cached Atlas action plan data for badge rendering |
|
||||
| `compliance_uploads` | Compliance xlsx upload metadata |
|
||||
| `compliance_items` | Individual non-compliant items per upload |
|
||||
| `compliance_notes` | Per-hostname/metric notes |
|
||||
|
||||
The complete DDL is in `backend/db-schema.sql`. For fresh installs, `scripts/deploy-postgres.sh` applies it automatically.
|
||||
|
||||
---
|
||||
|
||||
## Migrations
|
||||
|
||||
Run all migrations (idempotent):
|
||||
|
||||
```bash
|
||||
cd backend && node migrations/run-all.js
|
||||
```
|
||||
|
||||
Migration files in execution order:
|
||||
|
||||
| Migration | Purpose |
|
||||
|-----------|---------|
|
||||
| `add_user_groups.js` | User group column and check constraint |
|
||||
| `add_user_ivanti_identity.js` | Per-user Ivanti first/last name |
|
||||
| `add_user_bu_teams.js` | Per-user BU team assignments |
|
||||
| `add_knowledge_base_table.js` | Knowledge base document library |
|
||||
| `add_ivanti_sync_table.js` | Ivanti sync state (single-row) |
|
||||
| `add_ivanti_findings_tables.js` | Individual findings + counts cache |
|
||||
| `add_ivanti_findings_ipv6_columns.js` | IPv6 fallback columns |
|
||||
| `add_ivanti_counts_history_table.js` | Historical counts + per-BU |
|
||||
| `add_ivanti_todo_queue_table.js` | Per-user workflow staging queue |
|
||||
| `add_todo_queue_hostname.js` | Hostname column on queue items |
|
||||
| `add_todo_queue_ip_address.js` | IP address column on queue items |
|
||||
| `add_granite_workflow_type.js` | GRANITE workflow type |
|
||||
| `add_card_workflow_type.js` | CARD workflow type |
|
||||
| `add_decom_workflow_type.js` | DECOM workflow type |
|
||||
| `add_remediate_workflow_type.js` | Remediate workflow type |
|
||||
| `add_compliance_tables.js` | Compliance uploads, items, notes |
|
||||
| `add_compliance_notes_group_id.js` | Group ID for batch notes |
|
||||
| `add_compliance_history_metric_id.js` | Metric ID in compliance history |
|
||||
| `add_compliance_item_history.js` | Compliance item history tracking |
|
||||
| `add_fp_submissions_table.js` | FP workflow submissions |
|
||||
| `add_fp_submission_editing.js` | Submission edit history |
|
||||
| `add_fp_submissions_dismissed.js` | Dismissed/lifecycle status |
|
||||
| `add_fp_submissions_requeued_at.js` | Requeue timestamp |
|
||||
| `add_archer_tickets_table.js` | Archer EXC tickets |
|
||||
| `add_archer_tickets_timestamps.js` | Timestamp columns |
|
||||
| `add_archer_templates_table.js` | Archer template library |
|
||||
| `add_atlas_action_plans_cache.js` | Atlas plan cache |
|
||||
| `add_atlas_known_column.js` | Atlas known flag |
|
||||
| `add_finding_archive_tables.js` | Archive tracking tables |
|
||||
| `add_closed_gone_state.js` | CLOSED_GONE archive state |
|
||||
| `add_return_classification.js` | Return classification field |
|
||||
| `add_sync_anomaly_tables.js` | Anomaly detection log |
|
||||
| `add_flexible_jira_ticket_creation.js` | Optional CVE/vendor on Jira tickets |
|
||||
| `add_multi_item_jira_ticket.js` | Multi-item ticket references |
|
||||
| `add_jira_sync_columns.js` | Jira sync metadata (SQLite) |
|
||||
| `add_jira_sync_columns_pg.js` | Jira sync metadata (PostgreSQL) |
|
||||
| `drop_jira_status_check_constraint.js` | Remove status enum for raw display |
|
||||
| `add_notifications_table.js` | In-app notification system |
|
||||
| `add_created_by_columns.js` | created_by on archer_tickets |
|
||||
| `add_queue_remediation_notes_table.js` | Remediation notes on queue items |
|
||||
| `add_vcl_multi_vertical.js` | VCL multi-vertical tables |
|
||||
| `add_vcl_reporting_columns.js` | VCL reporting metadata |
|
||||
| `add_vcl_vertical_metadata.js` | VCL vertical team fields |
|
||||
| `backfill_anomaly_log.js` | Backfill anomaly classification data |
|
||||
| `backfill_return_classification.js` | Backfill return classification |
|
||||
| `reclassify_bu_roundtrips.js` | Reclassify BU roundtrip transitions |
|
||||
|
||||
---
|
||||
|
||||
## API Reference
|
||||
|
||||
All routes are prefixed with `/api`. All endpoints except login, logout, health check, and webhooks require a valid session cookie.
|
||||
|
||||
### Public
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/health` | Health check (used by CI/CD verification) |
|
||||
| POST | `/api/auth/login` | Authenticate and create session |
|
||||
| POST | `/api/auth/logout` | Destroy session |
|
||||
| POST | `/api/webhooks/gitlab` | GitLab webhook receiver (validated by secret) |
|
||||
|
||||
### CVE Management
|
||||
|
||||
| Method | Path | Description | Group |
|
||||
|--------|------|-------------|-------|
|
||||
| GET | `/api/cves` | List CVEs with filtering | Any |
|
||||
| POST | `/api/cves` | Create CVE | Standard_User+ |
|
||||
| PUT | `/api/cves/:id` | Update CVE | Standard_User+ |
|
||||
| DELETE | `/api/cves/:id` | Delete CVE | Admin |
|
||||
| POST | `/api/cves/:cveId/:vendor/documents` | Upload document | Standard_User+ |
|
||||
| GET | `/api/cves/:cveId/:vendor/documents` | List documents | Any |
|
||||
| DELETE | `/api/documents/:id` | Delete document | Admin |
|
||||
|
||||
### Users and Auth
|
||||
|
||||
| Method | Path | Description | Group |
|
||||
|--------|------|-------------|-------|
|
||||
| GET | `/api/auth/me` | Current session user | Any |
|
||||
| GET | `/api/users` | List all users | Admin |
|
||||
| POST | `/api/users` | Create user | Admin |
|
||||
| PUT | `/api/users/:id` | Update user (role, group, teams) | Admin |
|
||||
| DELETE | `/api/users/:id` | Delete user | Admin |
|
||||
| PUT | `/api/users/:id/password` | Change password | Owner or Admin |
|
||||
|
||||
### Ivanti Findings
|
||||
|
||||
| Method | Path | Description | Group |
|
||||
|--------|------|-------------|-------|
|
||||
| GET | `/api/ivanti/findings` | Query synced findings | Any |
|
||||
| POST | `/api/ivanti/findings/sync` | Trigger manual sync | Standard_User+ |
|
||||
| PUT | `/api/ivanti/findings/:id/note` | Update finding note | Standard_User+ |
|
||||
| PUT | `/api/ivanti/findings/:id/override` | Override hostname/DNS | Standard_User+ |
|
||||
|
||||
### Ivanti Queue
|
||||
|
||||
| Method | Path | Description | Group |
|
||||
|--------|------|-------------|-------|
|
||||
| GET | `/api/ivanti/todo-queue` | Get user's queue | Any |
|
||||
| POST | `/api/ivanti/todo-queue` | Add item(s) to queue | Standard_User+ |
|
||||
| PUT | `/api/ivanti/todo-queue/:id` | Update queue item | Standard_User+ |
|
||||
| DELETE | `/api/ivanti/todo-queue/:id` | Remove queue item | Standard_User+ |
|
||||
| DELETE | `/api/ivanti/todo-queue/completed` | Clear completed items | Standard_User+ |
|
||||
|
||||
### Ivanti FP Workflow
|
||||
|
||||
| Method | Path | Description | Group |
|
||||
|--------|------|-------------|-------|
|
||||
| POST | `/api/ivanti/fp-workflow` | Submit FP workflow to Ivanti | Standard_User+ |
|
||||
| GET | `/api/ivanti/fp-workflow/submissions` | List user's submissions | Any |
|
||||
| PUT | `/api/ivanti/fp-workflow/submissions/:id` | Edit/resubmit | Standard_User+ |
|
||||
|
||||
### Ivanti Archive
|
||||
|
||||
| Method | Path | Description | Group |
|
||||
|--------|------|-------------|-------|
|
||||
| GET | `/api/ivanti/archive` | Query archived findings | Any |
|
||||
| GET | `/api/ivanti/archive/anomalies` | Sync anomaly log | Any |
|
||||
| GET | `/api/ivanti/archive/transitions/:id` | Transition history | Any |
|
||||
|
||||
### Compliance (AEO)
|
||||
|
||||
| Method | Path | Description | Group |
|
||||
|--------|------|-------------|-------|
|
||||
| POST | `/api/compliance/upload` | Upload xlsx | Standard_User+ |
|
||||
| GET | `/api/compliance/items` | Query non-compliant items | Any |
|
||||
| GET | `/api/compliance/trends` | Compliance trend data | Any |
|
||||
| POST | `/api/compliance/notes` | Add note to hostname/metric | Standard_User+ |
|
||||
|
||||
### VCL Multi-Vertical (CCP Metrics)
|
||||
|
||||
| Method | Path | Description | Group |
|
||||
|--------|------|-------------|-------|
|
||||
| POST | `/api/compliance/vcl-multi/upload` | Upload VCL xlsx | Standard_User+ |
|
||||
| GET | `/api/compliance/vcl-multi/verticals` | List verticals | Any |
|
||||
| GET | `/api/compliance/vcl-multi/metrics` | Query metrics | Any |
|
||||
| GET | `/api/compliance/vcl-multi/forecast` | Burndown forecast | Any |
|
||||
| DELETE | `/api/compliance/vcl-multi/verticals/:id` | Delete vertical | Admin |
|
||||
|
||||
### Atlas
|
||||
|
||||
| Method | Path | Description | Group |
|
||||
|--------|------|-------------|-------|
|
||||
| GET | `/api/atlas/hosts/:hostId/plans` | Get plans for host | Any |
|
||||
| PUT | `/api/atlas/hosts/:hostId/plans` | Create plan | Standard_User+ |
|
||||
| PATCH | `/api/atlas/hosts/:hostId/plans` | Update plan | Standard_User+ |
|
||||
| POST | `/api/atlas/hosts/:hostId/refresh` | Refresh cache | Standard_User+ |
|
||||
|
||||
### Jira
|
||||
|
||||
| Method | Path | Description | Group |
|
||||
|--------|------|-------------|-------|
|
||||
| GET | `/api/jira-tickets` | List local tickets | Any |
|
||||
| POST | `/api/jira-tickets` | Create ticket (local + Jira API) | Standard_User+ |
|
||||
| GET | `/api/jira-tickets/lookup` | JQL search against Jira | Standard_User+ |
|
||||
| POST | `/api/jira-tickets/save` | Save from Jira lookup | Standard_User+ |
|
||||
|
||||
### CARD
|
||||
|
||||
| Method | Path | Description | Group |
|
||||
|--------|------|-------------|-------|
|
||||
| GET | `/api/card/owner/:assetId` | Lookup owner | Any |
|
||||
| GET | `/api/card/asset-search/:hostId` | Search by Ivanti host ID | Any |
|
||||
| POST | `/api/card/owner/:assetId/confirm` | Confirm ownership | Standard_User+ |
|
||||
| POST | `/api/card/owner/:assetId/decline` | Decline ownership | Standard_User+ |
|
||||
| POST | `/api/card/owner/:assetId/redirect` | Redirect ownership | Standard_User+ |
|
||||
| GET | `/api/card/teams` | List all CARD teams | Any |
|
||||
|
||||
### Other
|
||||
|
||||
| Method | Path | Description | Group |
|
||||
|--------|------|-------------|-------|
|
||||
| GET | `/api/audit-logs` | Query audit trail | Admin |
|
||||
| GET | `/api/nvd/:cveId` | NVD metadata lookup | Any |
|
||||
| GET/POST | `/api/knowledge-base` | Document library CRUD | Any / Standard_User+ |
|
||||
| GET/POST | `/api/archer-tickets` | Archer ticket CRUD | Any / Standard_User+ |
|
||||
| GET/POST | `/api/archer-templates` | Template library CRUD | Any / Standard_User+ |
|
||||
| GET | `/api/notifications` | User notifications | Any |
|
||||
| POST | `/api/feedback` | Submit bug/feature to GitLab | Any |
|
||||
|
||||
---
|
||||
|
||||
## CI/CD Pipeline
|
||||
|
||||
Defined in `.gitlab-ci.yml`. Stages: install, lint, test, build, deploy, verify.
|
||||
|
||||
| Stage | What it does |
|
||||
|-------|-------------|
|
||||
| install | `npm ci` for root + frontend |
|
||||
| lint | ESLint on frontend (warning threshold: 25) |
|
||||
| test | Jest backend property tests + frontend unit tests |
|
||||
| build | `npm run build` in frontend/ |
|
||||
| deploy-staging | rsync to 71.85.90.9, run migrations, restart service (auto on master) |
|
||||
| deploy-production | rsync to 71.85.90.6, run migrations, restart service (manual trigger) |
|
||||
| verify | Hit `/api/health` endpoint, post issue comments for `Closes #N` references |
|
||||
|
||||
**Runner:** Docker executor on LXC 108 (71.85.90.8), Runner #6, `node:18` image for build stages, `alpine:latest` for deploy.
|
||||
|
||||
**Required CI/CD variables:** `DATABASE_URL`, `SSH_PRIVATE_KEY` (base64), `GITLAB_PAT`.
|
||||
|
||||
---
|
||||
|
||||
## Documentation
|
||||
|
||||
- **[Full Reference Manual](docs/guides/full-reference-manual.md)** — comprehensive feature documentation, API reference, database schema, security model, and configuration details
|
||||
- **[VCL Metric Calculations](docs/guides/vcl-metric-calculations.md)** — formula reference for CCP Metrics compliance calculations
|
||||
- **[Postgres Migration Plan](docs/guides/postgres-migration-plan.md)** — architecture decisions, schema design, and cutover procedure for the SQLite to PostgreSQL migration
|
||||
- **[Migration Guide](backend/migrations/README.md)** — schema migration scripts for upgrading existing deployments
|
||||
- **[Design System](docs/design/design-system.md)** — UI component patterns and color system
|
||||
- **[Design System](docs/design/design-system.md)** — UI component patterns and colour system
|
||||
- **[Ivanti API Reference](docs/api/ivanti-api-reference.md)** — Ivanti/RiskSense API integration details
|
||||
- **[Jira API Use Cases](docs/api/jira-api-use-cases.md)** — Jira Data Center API compliance summary
|
||||
- **[AD/SAML Integration Architecture](docs/architecture/ad-saml-integration.md)** — planned Active Directory / SAML SSO integration
|
||||
- **[Security Audit Tracker](docs/security/security-audit-tracker.md)** — living security audit document
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Backend fails to start with "FATAL: SESSION_SECRET"
|
||||
|
||||
**Symptom:** Server exits immediately with `FATAL: SESSION_SECRET environment variable must be set`.
|
||||
|
||||
**Cause:** The `SESSION_SECRET` env var is not configured in `backend/.env`.
|
||||
|
||||
**Fix:** Generate a secret and add it to `backend/.env`:
|
||||
```bash
|
||||
echo "SESSION_SECRET=$(openssl rand -base64 32)" >> backend/.env
|
||||
```
|
||||
|
||||
### Database connection refused
|
||||
|
||||
**Symptom:** Backend logs `Error: connect ECONNREFUSED` on startup.
|
||||
|
||||
**Cause:** PostgreSQL container is not running or `DATABASE_URL` is misconfigured.
|
||||
|
||||
**Fix:**
|
||||
```bash
|
||||
docker compose up -d # Start the Postgres container
|
||||
# Verify DATABASE_URL in backend/.env matches: postgresql://steam:<password>@localhost:5433/cve_dashboard
|
||||
```
|
||||
|
||||
### CARD API timeouts in production
|
||||
|
||||
**Symptom:** CARD tooltip shows timeout errors or takes minutes to respond.
|
||||
|
||||
**Cause:** IPv6 AAAA records for `card.charter.com` are unreachable from this network. Node.js defaults to trying IPv6 first.
|
||||
|
||||
**Fix:** The backend sets `dns.setDefaultResultOrder('ipv4first')` globally. If you still see issues, verify the setting is at the top of `server.js` before any network imports.
|
||||
|
||||
### Compliance upload fails with "No items parsed"
|
||||
|
||||
**Symptom:** Upload completes but reports 0 new, 0 resolved, 0 recurring items.
|
||||
|
||||
**Cause:** The xlsx file structure does not match the expected format (sheet names, column headers).
|
||||
|
||||
**Fix:** Ensure the compliance xlsx has the expected sheet layout. Check `backend/scripts/parse_compliance_xlsx.py` for the expected column mappings.
|
||||
|
||||
### Missing migrations after deploy
|
||||
|
||||
**Symptom:** API returns 500 errors referencing missing columns or tables.
|
||||
|
||||
**Cause:** Migrations were not run after pulling new code.
|
||||
|
||||
**Fix:**
|
||||
```bash
|
||||
cd backend && node migrations/run-all.js
|
||||
```
|
||||
|
||||
### Frontend changes not visible after deploy
|
||||
|
||||
**Symptom:** UI still shows old version after code changes.
|
||||
|
||||
**Cause:** The frontend must be rebuilt after any source changes — Express serves the static build.
|
||||
|
||||
**Fix:**
|
||||
```bash
|
||||
cd frontend && npm run build
|
||||
# Restart backend or refresh the browser
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -80,3 +80,11 @@ GITLAB_PAT=
|
||||
# Generate with: openssl rand -hex 20
|
||||
GITLAB_WEBHOOK_SECRET=changeme_generate_a_random_secret
|
||||
|
||||
|
||||
# TLS / HTTPS Configuration
|
||||
# If cert and key files exist at the paths below, the server starts with HTTPS.
|
||||
# Set TLS_ENABLED=false to force plain HTTP even when certs are present.
|
||||
# Generate a self-signed cert: openssl req -x509 -newkey rsa:2048 -keyout certs/key.pem -out certs/cert.pem -days 365 -nodes -subj "/CN=cve-dashboard.local"
|
||||
TLS_ENABLED=true
|
||||
TLS_CERT=certs/cert.pem
|
||||
TLS_KEY=certs/key.pem
|
||||
|
||||
3
backend/.gitignore
vendored
3
backend/.gitignore
vendored
@@ -3,3 +3,6 @@
|
||||
backend/fix_multivendor_constraint.js
|
||||
backend/migrate_multivendor.js
|
||||
backend/add_vendor_to_documents.js
|
||||
|
||||
# TLS certificates (self-signed or CA-issued)
|
||||
certs/
|
||||
|
||||
@@ -0,0 +1,337 @@
|
||||
/**
|
||||
* Property-Based Tests: Ivanti Queue Remediation — Notes System
|
||||
*
|
||||
* Feature: ivanti-queue-remediation
|
||||
*
|
||||
* Tests properties 3–7 from the design document:
|
||||
* - Property 3: Whitespace-only note content is always rejected
|
||||
* - Property 4: Note creation round-trip
|
||||
* - Property 5: Notes returned in descending creation order
|
||||
* - Property 6: Ownership enforcement
|
||||
* - Property 7: Cascade delete removes all associated notes
|
||||
*
|
||||
* These tests validate the pure logic and simulate the API behavior
|
||||
* without requiring a running database.
|
||||
*/
|
||||
|
||||
const fc = require('fast-check');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Simulate the backend validation and data layer logic
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Simulates POST /api/ivanti/todo-queue/:id/notes validation logic.
|
||||
* Returns { accepted: boolean, status: number, error?: string }
|
||||
*/
|
||||
function validateNoteCreation(note_text, queueItemExists, isOwner) {
|
||||
// Ownership check
|
||||
if (!queueItemExists || !isOwner) {
|
||||
return { accepted: false, status: 404, error: 'Queue item not found.' };
|
||||
}
|
||||
|
||||
// Text validation
|
||||
if (!note_text || typeof note_text !== 'string' || note_text.trim().length === 0) {
|
||||
return { accepted: false, status: 400, error: 'Note text is required.' };
|
||||
}
|
||||
if (note_text.length > 5000) {
|
||||
return { accepted: false, status: 400, error: 'Note text must not exceed 5000 characters.' };
|
||||
}
|
||||
|
||||
return { accepted: true, status: 201 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Simulates a simple in-memory note store for round-trip and ordering tests.
|
||||
*/
|
||||
class NoteStore {
|
||||
constructor() {
|
||||
this.notes = [];
|
||||
this.nextId = 1;
|
||||
this.queueItems = new Map(); // id -> { user_id }
|
||||
}
|
||||
|
||||
createQueueItem(userId) {
|
||||
const id = this.nextId++;
|
||||
this.queueItems.set(id, { user_id: userId });
|
||||
return id;
|
||||
}
|
||||
|
||||
createNote(queueItemId, userId, username, noteText) {
|
||||
const item = this.queueItems.get(queueItemId);
|
||||
if (!item) return { error: 'Queue item not found.', status: 404 };
|
||||
if (item.user_id !== userId) return { error: 'Queue item not found.', status: 404 };
|
||||
if (!noteText || typeof noteText !== 'string' || noteText.trim().length === 0) {
|
||||
return { error: 'Note text is required.', status: 400 };
|
||||
}
|
||||
if (noteText.length > 5000) {
|
||||
return { error: 'Note text must not exceed 5000 characters.', status: 400 };
|
||||
}
|
||||
|
||||
const note = {
|
||||
id: this.nextId++,
|
||||
queue_item_id: queueItemId,
|
||||
user_id: userId,
|
||||
username,
|
||||
note_text: noteText,
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
this.notes.push(note);
|
||||
return { note, status: 201 };
|
||||
}
|
||||
|
||||
getNotes(queueItemId, userId) {
|
||||
const item = this.queueItems.get(queueItemId);
|
||||
if (!item) return { error: 'Queue item not found.', status: 404 };
|
||||
if (item.user_id !== userId) return { error: 'Queue item not found.', status: 404 };
|
||||
|
||||
const notes = this.notes
|
||||
.filter(n => n.queue_item_id === queueItemId)
|
||||
.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
|
||||
return { notes, status: 200 };
|
||||
}
|
||||
|
||||
deleteQueueItem(queueItemId) {
|
||||
this.queueItems.delete(queueItemId);
|
||||
// Simulate ON DELETE CASCADE
|
||||
this.notes = this.notes.filter(n => n.queue_item_id !== queueItemId);
|
||||
}
|
||||
|
||||
getNotesForItem(queueItemId) {
|
||||
return this.notes.filter(n => n.queue_item_id === queueItemId);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Arbitraries
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Whitespace-only strings (including empty)
|
||||
const arbWhitespaceOnly = fc.oneof(
|
||||
fc.constant(''),
|
||||
fc.array(fc.constantFrom(' ', '\t', '\n', '\r', '\f'), { minLength: 1, maxLength: 50 })
|
||||
.map(arr => arr.join(''))
|
||||
);
|
||||
|
||||
// Valid note text: 1–5000 chars, at least one non-whitespace
|
||||
const arbValidNoteText = fc.string({ minLength: 1, maxLength: 200 }).filter(s => s.trim().length > 0);
|
||||
|
||||
// Over-length note text
|
||||
const arbOverlengthNoteText = fc.string({ minLength: 5001, maxLength: 5100 });
|
||||
|
||||
// User IDs (positive integers)
|
||||
const arbUserId = fc.integer({ min: 1, max: 10000 });
|
||||
|
||||
// Usernames
|
||||
const arbUsername = fc.string({ minLength: 1, maxLength: 50 }).filter(s => s.trim().length > 0);
|
||||
|
||||
// Number of notes to create (for ordering test)
|
||||
const arbNoteCount = fc.integer({ min: 2, max: 10 });
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Property 3: Whitespace-only note content is always rejected
|
||||
// **Validates: Requirements 3.5, 4.4, 5.8**
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('Feature: ivanti-queue-remediation, Property 3: Whitespace-only note content is always rejected', () => {
|
||||
it('rejects any string composed entirely of whitespace characters', () => {
|
||||
fc.assert(
|
||||
fc.property(arbWhitespaceOnly, (noteText) => {
|
||||
const result = validateNoteCreation(noteText, true, true);
|
||||
expect(result.accepted).toBe(false);
|
||||
expect(result.status).toBe(400);
|
||||
}),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects undefined and null as note_text', () => {
|
||||
const arbNullish = fc.oneof(fc.constant(undefined), fc.constant(null));
|
||||
fc.assert(
|
||||
fc.property(arbNullish, (noteText) => {
|
||||
const result = validateNoteCreation(noteText, true, true);
|
||||
expect(result.accepted).toBe(false);
|
||||
expect(result.status).toBe(400);
|
||||
}),
|
||||
{ numRuns: 10 }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Property 4: Note creation round-trip
|
||||
// **Validates: Requirements 4.1, 3.3**
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('Feature: ivanti-queue-remediation, Property 4: Note creation round-trip', () => {
|
||||
it('creates a note and retrieves it with exact same text, correct username, and valid created_at', () => {
|
||||
fc.assert(
|
||||
fc.property(arbValidNoteText, arbUserId, arbUsername, (noteText, userId, username) => {
|
||||
const store = new NoteStore();
|
||||
const itemId = store.createQueueItem(userId);
|
||||
|
||||
const createResult = store.createNote(itemId, userId, username, noteText);
|
||||
expect(createResult.status).toBe(201);
|
||||
expect(createResult.note.note_text).toBe(noteText);
|
||||
expect(createResult.note.username).toBe(username);
|
||||
|
||||
const getResult = store.getNotes(itemId, userId);
|
||||
expect(getResult.status).toBe(200);
|
||||
expect(getResult.notes.length).toBe(1);
|
||||
expect(getResult.notes[0].note_text).toBe(noteText);
|
||||
expect(getResult.notes[0].username).toBe(username);
|
||||
expect(getResult.notes[0].created_at).toBeTruthy();
|
||||
// Verify created_at is a valid ISO timestamp
|
||||
expect(new Date(getResult.notes[0].created_at).toString()).not.toBe('Invalid Date');
|
||||
}),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Property 5: Notes returned in descending creation order
|
||||
// **Validates: Requirements 4.2**
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('Feature: ivanti-queue-remediation, Property 5: Notes returned in descending creation order', () => {
|
||||
it('for N >= 2 notes, GET returns them with created_at in descending order', () => {
|
||||
fc.assert(
|
||||
fc.property(arbNoteCount, arbUserId, arbUsername, (count, userId, username) => {
|
||||
const store = new NoteStore();
|
||||
const itemId = store.createQueueItem(userId);
|
||||
|
||||
// Create N notes
|
||||
for (let i = 0; i < count; i++) {
|
||||
const result = store.createNote(itemId, userId, username, `Note ${i + 1}`);
|
||||
expect(result.status).toBe(201);
|
||||
}
|
||||
|
||||
const getResult = store.getNotes(itemId, userId);
|
||||
expect(getResult.status).toBe(200);
|
||||
expect(getResult.notes.length).toBe(count);
|
||||
|
||||
// Verify descending order
|
||||
for (let i = 0; i < getResult.notes.length - 1; i++) {
|
||||
const current = new Date(getResult.notes[i].created_at);
|
||||
const next = new Date(getResult.notes[i + 1].created_at);
|
||||
expect(current.getTime()).toBeGreaterThanOrEqual(next.getTime());
|
||||
}
|
||||
}),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Property 6: Ownership enforcement
|
||||
// **Validates: Requirements 4.3**
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('Feature: ivanti-queue-remediation, Property 6: Ownership enforcement', () => {
|
||||
it('returns 404 when user B attempts to create notes on user A queue item', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
arbUserId,
|
||||
arbUserId.filter(id => id > 1), // ensure we can generate different users
|
||||
arbValidNoteText,
|
||||
arbUsername,
|
||||
(userA, userBOffset, noteText, username) => {
|
||||
const userB = userA + userBOffset; // guarantee different user
|
||||
const store = new NoteStore();
|
||||
const itemId = store.createQueueItem(userA);
|
||||
|
||||
const result = store.createNote(itemId, userB, username, noteText);
|
||||
expect(result.status).toBe(404);
|
||||
expect(result.error).toBe('Queue item not found.');
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('returns 404 when user B attempts to get notes on user A queue item', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
arbUserId,
|
||||
arbUserId.filter(id => id > 1),
|
||||
arbValidNoteText,
|
||||
arbUsername,
|
||||
(userA, userBOffset, noteText, username) => {
|
||||
const userB = userA + userBOffset;
|
||||
const store = new NoteStore();
|
||||
const itemId = store.createQueueItem(userA);
|
||||
|
||||
// User A creates a note
|
||||
store.createNote(itemId, userA, username, noteText);
|
||||
|
||||
// User B tries to read
|
||||
const result = store.getNotes(itemId, userB);
|
||||
expect(result.status).toBe(404);
|
||||
expect(result.error).toBe('Queue item not found.');
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Property 7: Cascade delete removes all associated notes
|
||||
// **Validates: Requirements 3.4, 7.3**
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('Feature: ivanti-queue-remediation, Property 7: Cascade delete removes all associated notes', () => {
|
||||
it('deleting a queue item removes all its associated notes', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
arbNoteCount,
|
||||
arbUserId,
|
||||
arbUsername,
|
||||
(count, userId, username) => {
|
||||
const store = new NoteStore();
|
||||
const itemId = store.createQueueItem(userId);
|
||||
|
||||
// Create N notes
|
||||
for (let i = 0; i < count; i++) {
|
||||
store.createNote(itemId, userId, username, `Remediation step ${i + 1}`);
|
||||
}
|
||||
|
||||
// Verify notes exist
|
||||
expect(store.getNotesForItem(itemId).length).toBe(count);
|
||||
|
||||
// Delete the queue item (simulates CASCADE)
|
||||
store.deleteQueueItem(itemId);
|
||||
|
||||
// Verify zero notes remain
|
||||
expect(store.getNotesForItem(itemId).length).toBe(0);
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('deleting a queue item does not affect notes for other queue items', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
arbNoteCount,
|
||||
arbUserId,
|
||||
arbUsername,
|
||||
(count, userId, username) => {
|
||||
const store = new NoteStore();
|
||||
const itemA = store.createQueueItem(userId);
|
||||
const itemB = store.createQueueItem(userId);
|
||||
|
||||
// Create notes for both items
|
||||
for (let i = 0; i < count; i++) {
|
||||
store.createNote(itemA, userId, username, `Note A-${i}`);
|
||||
store.createNote(itemB, userId, username, `Note B-${i}`);
|
||||
}
|
||||
|
||||
// Delete item A
|
||||
store.deleteQueueItem(itemA);
|
||||
|
||||
// Item B notes are unaffected
|
||||
expect(store.getNotesForItem(itemB).length).toBe(count);
|
||||
expect(store.getNotesForItem(itemA).length).toBe(0);
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* Property-Based Tests: Ivanti Queue Remediation — Vendor Validation
|
||||
*
|
||||
* Feature: ivanti-queue-remediation
|
||||
* Property 1: Remediate vendor validation
|
||||
*
|
||||
* For any non-empty string of 1–200 characters (trimmed, with at least one
|
||||
* non-whitespace character), submitting it as the vendor field with workflow_type
|
||||
* "Remediate" to the queue API SHALL be accepted; and for any empty,
|
||||
* whitespace-only, or >200 character vendor string, the request SHALL be
|
||||
* rejected with a 400 status.
|
||||
*
|
||||
* **Validates: Requirements 1.2, 1.3**
|
||||
*/
|
||||
|
||||
const fc = require('fast-check');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Replicate the pure validation logic from ivantiTodoQueue.js
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const VALID_WORKFLOW_TYPES = ['FP', 'Archer', 'CARD', 'GRANITE', 'DECOM', 'Remediate'];
|
||||
const INVENTORY_TYPES = ['CARD', 'GRANITE', 'DECOM'];
|
||||
|
||||
function isValidVendor(vendor) {
|
||||
if (typeof vendor !== 'string') return false;
|
||||
const trimmed = vendor.trim();
|
||||
return trimmed.length > 0 && trimmed.length <= 200;
|
||||
}
|
||||
|
||||
/**
|
||||
* Simulates the validation logic for the batch/single add endpoints.
|
||||
* Returns { accepted: boolean, status: number } mirroring the route behavior.
|
||||
*/
|
||||
function validateRemediateRequest(vendor) {
|
||||
const workflow_type = 'Remediate';
|
||||
|
||||
if (!VALID_WORKFLOW_TYPES.includes(workflow_type)) {
|
||||
return { accepted: false, status: 400 };
|
||||
}
|
||||
|
||||
// Remediate is NOT in INVENTORY_TYPES, so vendor is required
|
||||
if (!INVENTORY_TYPES.includes(workflow_type)) {
|
||||
if (!isValidVendor(vendor)) {
|
||||
return { accepted: false, status: 400 };
|
||||
}
|
||||
}
|
||||
|
||||
return { accepted: true, status: 201 };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Arbitraries
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Valid vendor: 1–200 chars trimmed, at least one non-whitespace char
|
||||
const arbValidVendor = fc.string({ minLength: 1, maxLength: 200 }).filter(s => {
|
||||
const trimmed = s.trim();
|
||||
return trimmed.length > 0 && trimmed.length <= 200;
|
||||
});
|
||||
|
||||
// Invalid vendor: empty string
|
||||
const arbEmptyVendor = fc.constant('');
|
||||
|
||||
// Invalid vendor: whitespace-only strings
|
||||
const arbWhitespaceOnlyVendor = fc.array(
|
||||
fc.constantFrom(' ', '\t', '\n', '\r'),
|
||||
{ minLength: 1, maxLength: 50 }
|
||||
).map(arr => arr.join(''));
|
||||
|
||||
// Invalid vendor: strings > 200 chars when trimmed
|
||||
const arbOverlengthVendor = fc.string({ minLength: 201, maxLength: 400 }).filter(s => {
|
||||
return s.trim().length > 200;
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Property 1: Remediate vendor validation
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('Feature: ivanti-queue-remediation, Property 1: Remediate vendor validation', () => {
|
||||
it('accepts any non-empty vendor string of 1–200 trimmed characters with at least one non-whitespace', () => {
|
||||
fc.assert(
|
||||
fc.property(arbValidVendor, (vendor) => {
|
||||
const result = validateRemediateRequest(vendor);
|
||||
expect(result.accepted).toBe(true);
|
||||
expect(result.status).toBe(201);
|
||||
}),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects empty string as vendor for Remediate workflow', () => {
|
||||
fc.assert(
|
||||
fc.property(arbEmptyVendor, (vendor) => {
|
||||
const result = validateRemediateRequest(vendor);
|
||||
expect(result.accepted).toBe(false);
|
||||
expect(result.status).toBe(400);
|
||||
}),
|
||||
{ numRuns: 10 }
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects whitespace-only strings as vendor for Remediate workflow', () => {
|
||||
fc.assert(
|
||||
fc.property(arbWhitespaceOnlyVendor, (vendor) => {
|
||||
const result = validateRemediateRequest(vendor);
|
||||
expect(result.accepted).toBe(false);
|
||||
expect(result.status).toBe(400);
|
||||
}),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects vendor strings exceeding 200 characters when trimmed', () => {
|
||||
fc.assert(
|
||||
fc.property(arbOverlengthVendor, (vendor) => {
|
||||
const result = validateRemediateRequest(vendor);
|
||||
expect(result.accepted).toBe(false);
|
||||
expect(result.status).toBe(400);
|
||||
}),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects non-string vendor values (undefined, null, number)', () => {
|
||||
const arbNonString = fc.oneof(
|
||||
fc.constant(undefined),
|
||||
fc.constant(null),
|
||||
fc.integer(),
|
||||
fc.boolean()
|
||||
);
|
||||
|
||||
fc.assert(
|
||||
fc.property(arbNonString, (vendor) => {
|
||||
const result = validateRemediateRequest(vendor);
|
||||
expect(result.accepted).toBe(false);
|
||||
expect(result.status).toBe(400);
|
||||
}),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -398,6 +398,7 @@ CREATE TABLE IF NOT EXISTS atlas_action_plans_cache (
|
||||
has_action_plan BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
plan_count INTEGER NOT NULL DEFAULT 0,
|
||||
plans_json TEXT NOT NULL DEFAULT '[]',
|
||||
atlas_known BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
synced_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
|
||||
@@ -252,8 +252,8 @@ async function getTeamAssets(teamName, { disposition, page, pageSize } = {}) {
|
||||
/**
|
||||
* GET /api/v1/owner/{assetId} — get owner record including update_token.
|
||||
*/
|
||||
async function getOwner(assetId) {
|
||||
const res = await cardGet(`/api/v1/owner/${encodeURIComponent(assetId)}`);
|
||||
async function getOwner(assetId, options) {
|
||||
const res = await cardGet(`/api/v1/owner/${encodeURIComponent(assetId)}`, options);
|
||||
return { status: res.status, body: res.body, ok: res.status >= 200 && res.status < 300 };
|
||||
}
|
||||
|
||||
@@ -295,38 +295,91 @@ async function redirectAsset(assetId, fromTeam, toTeam, updateToken) {
|
||||
return { status: res.status, body: res.body, ok: res.status >= 200 && res.status < 300 };
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v2/asset-search/{ivantiHostId}?search_param=deep_search
|
||||
* Search CARD by Ivanti Asset ID (8-digit integer). Returns the CARD asset
|
||||
* record directly — no suffix guessing required.
|
||||
*
|
||||
* @param {string|number} ivantiHostId - 8-character integer Ivanti Host ID
|
||||
* @param {object} [options] - { timeout }
|
||||
*/
|
||||
async function searchByIvantiHostId(ivantiHostId, options) {
|
||||
const hostId = String(ivantiHostId).trim();
|
||||
if (!hostId || !/^\d+$/.test(hostId)) {
|
||||
return { status: 400, body: '{"error":"Invalid Ivanti host ID — must be an integer."}', ok: false };
|
||||
}
|
||||
const res = await cardGet(`/api/v2/asset-search/${hostId}?search_param=deep_search`, options);
|
||||
return { status: res.status, body: res.body, ok: res.status >= 200 && res.status < 300 };
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v2/asset-search/{assetId}?search_param=deep_search
|
||||
* Search CARD by asset ID (e.g., "24.24.100.20-CTEC"). Returns the full
|
||||
* enriched asset record including ncim_discovery, netops_granite_allips, etc.
|
||||
*
|
||||
* @param {string} assetId - CARD asset identifier (IP-SUFFIX format)
|
||||
* @param {object} [options] - { timeout }
|
||||
*/
|
||||
async function searchByAssetId(assetId, options) {
|
||||
const id = (assetId || '').trim();
|
||||
if (!id) {
|
||||
return { status: 400, body: '{"error":"Asset ID is required."}', ok: false };
|
||||
}
|
||||
const res = await cardGet(`/api/v2/asset-search/${encodeURIComponent(id)}?search_param=deep_search`, options);
|
||||
return { status: res.status, body: res.body, ok: res.status >= 200 && res.status < 300 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a bare IP address to a full CARD asset ID by trying known suffixes.
|
||||
* Returns the first asset ID that returns a valid owner record, or null if none found.
|
||||
*
|
||||
* @param {string} ip - IP address or existing asset ID
|
||||
* @param {object} [options] - { quick: true } to only try CTEC suffix (for tooltip/hover use)
|
||||
*/
|
||||
async function resolveAssetId(ip) {
|
||||
const SUFFIXES = ['CTEC', 'NATL', 'CHTR', 'COML', 'RESI', 'WIFI', 'VOIP'];
|
||||
async function resolveAssetId(ip, options) {
|
||||
const quick = options && options.quick;
|
||||
const SUFFIXES = quick ? ['CTEC'] : ['CTEC', 'NATL', 'CHTR', 'COML', 'RESI', 'WIFI', 'VOIP'];
|
||||
const timeout = quick ? 30000 : undefined; // 30s timeout for quick mode
|
||||
const trimmedIp = (ip || '').trim();
|
||||
if (!trimmedIp) return null;
|
||||
|
||||
// If it already has a suffix (contains a dash followed by letters), use as-is
|
||||
if (/\d+-[A-Z]+$/i.test(trimmedIp)) {
|
||||
const result = await getOwner(trimmedIp);
|
||||
if (result.ok) return trimmedIp;
|
||||
try {
|
||||
const result = await getOwner(trimmedIp, timeout ? { timeout } : undefined);
|
||||
if (result.ok) return trimmedIp;
|
||||
} catch (err) {
|
||||
// Timeout — throw so caller can distinguish from "not found"
|
||||
if (quick && err.message && err.message.includes('timed out')) {
|
||||
throw new Error('CARD_TIMEOUT');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Try each suffix
|
||||
for (const suffix of SUFFIXES) {
|
||||
const candidate = `${trimmedIp}-${suffix}`;
|
||||
try {
|
||||
const result = await getOwner(candidate);
|
||||
const result = await getOwner(candidate, timeout ? { timeout } : undefined);
|
||||
if (result.ok) return candidate;
|
||||
} catch (_) {
|
||||
} catch (err) {
|
||||
// Timeout — throw so caller can distinguish from "not found"
|
||||
if (quick && err.message && err.message.includes('timed out')) {
|
||||
throw new Error('CARD_TIMEOUT');
|
||||
}
|
||||
// Continue to next suffix
|
||||
}
|
||||
}
|
||||
|
||||
// Try bare IP as last resort
|
||||
try {
|
||||
const result = await getOwner(trimmedIp);
|
||||
if (result.ok) return trimmedIp;
|
||||
} catch (_) {
|
||||
// Not found
|
||||
// Try bare IP as last resort (skip in quick mode to avoid extra delay)
|
||||
if (!quick) {
|
||||
try {
|
||||
const result = await getOwner(trimmedIp);
|
||||
if (result.ok) return trimmedIp;
|
||||
} catch (_) {
|
||||
// Not found
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
@@ -347,4 +400,6 @@ module.exports = {
|
||||
redirectAsset,
|
||||
invalidateToken,
|
||||
resolveAssetId,
|
||||
searchByIvantiHostId,
|
||||
searchByAssetId,
|
||||
};
|
||||
|
||||
61
backend/migrations/add_atlas_known_column.js
Normal file
61
backend/migrations/add_atlas_known_column.js
Normal file
@@ -0,0 +1,61 @@
|
||||
// Migration: Add atlas_known column to atlas_action_plans_cache
|
||||
//
|
||||
// Distinguishes between hosts Atlas actively tracks (atlas_known = true)
|
||||
// and hosts that were synced but Atlas has no data for (atlas_known = false).
|
||||
// The badge only renders for atlas_known hosts, preventing noise from BUs
|
||||
// not covered by Atlas.
|
||||
//
|
||||
// Safe to re-run — uses ADD COLUMN IF NOT EXISTS pattern.
|
||||
//
|
||||
// Usage: node backend/migrations/add_atlas_known_column.js
|
||||
|
||||
const pool = require('../db');
|
||||
|
||||
async function migrate() {
|
||||
console.log('Starting atlas_known column migration...');
|
||||
|
||||
// Add column (IF NOT EXISTS not supported for ADD COLUMN in all PG versions, use DO block)
|
||||
await pool.query(`
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'atlas_action_plans_cache' AND column_name = 'atlas_known'
|
||||
) THEN
|
||||
ALTER TABLE atlas_action_plans_cache ADD COLUMN atlas_known BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
END IF;
|
||||
END $$;
|
||||
`);
|
||||
console.log('✓ atlas_known column ready');
|
||||
|
||||
// Backfill: mark hosts that have at least one plan as atlas_known = true
|
||||
const { rowCount } = await pool.query(`
|
||||
UPDATE atlas_action_plans_cache SET atlas_known = true WHERE has_action_plan = true
|
||||
`);
|
||||
console.log(`✓ Backfilled ${rowCount} rows with atlas_known = true (hosts with plans)`);
|
||||
|
||||
// Also mark hosts belonging to managed BUs as atlas_known
|
||||
// These are the BUs Atlas is supposed to cover
|
||||
const managedBUs = (process.env.IVANTI_MANAGED_BUS || 'NTS-AEO-ACCESS-ENG,NTS-AEO-STEAM')
|
||||
.split(',').map(b => b.trim()).filter(Boolean);
|
||||
const patterns = managedBUs.map(b => `%${b}%`);
|
||||
|
||||
const { rowCount: buCount } = await pool.query(`
|
||||
UPDATE atlas_action_plans_cache SET atlas_known = true
|
||||
WHERE host_id IN (
|
||||
SELECT DISTINCT host_id FROM ivanti_findings
|
||||
WHERE bu_ownership ILIKE ANY($1::text[])
|
||||
)
|
||||
`, [patterns]);
|
||||
console.log(`✓ Backfilled ${buCount} rows for managed BU hosts as atlas_known = true`);
|
||||
|
||||
console.log('Migration complete.');
|
||||
}
|
||||
|
||||
migrate()
|
||||
.then(() => { pool.end(); })
|
||||
.catch((err) => {
|
||||
console.error('Migration failed:', err);
|
||||
pool.end();
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -17,7 +17,7 @@ async function migrate() {
|
||||
await pool.query(`
|
||||
ALTER TABLE ivanti_todo_queue
|
||||
ADD CONSTRAINT ivanti_todo_queue_workflow_type_check
|
||||
CHECK (workflow_type IN ('FP', 'Archer', 'CARD', 'GRANITE', 'DECOM'))
|
||||
CHECK (workflow_type IN ('FP', 'Archer', 'CARD', 'GRANITE', 'DECOM', 'Remediate'))
|
||||
`);
|
||||
console.log('✓ Added updated workflow_type constraint (includes DECOM)');
|
||||
|
||||
|
||||
32
backend/migrations/add_ivanti_findings_ipv6_columns.js
Normal file
32
backend/migrations/add_ivanti_findings_ipv6_columns.js
Normal file
@@ -0,0 +1,32 @@
|
||||
#!/usr/bin/env node
|
||||
// Migration: Add qualys_ipv6 and primary_ipv6 columns to ivanti_findings
|
||||
// These capture IPv6 addresses for findings that have no IPv4.
|
||||
// Qualys IPv6 comes from hostAdditionalDetails; Primary IPv6 from assetCustomAttributes.
|
||||
|
||||
const pool = require('../db');
|
||||
|
||||
async function run() {
|
||||
console.log('Adding IPv6 columns to ivanti_findings...');
|
||||
try {
|
||||
await pool.query(`
|
||||
ALTER TABLE ivanti_findings
|
||||
ADD COLUMN IF NOT EXISTS qualys_ipv6 TEXT DEFAULT NULL
|
||||
`);
|
||||
console.log('✓ qualys_ipv6 column added (or already exists)');
|
||||
|
||||
await pool.query(`
|
||||
ALTER TABLE ivanti_findings
|
||||
ADD COLUMN IF NOT EXISTS primary_ipv6 TEXT DEFAULT NULL
|
||||
`);
|
||||
console.log('✓ primary_ipv6 column added (or already exists)');
|
||||
|
||||
console.log('Migration complete.');
|
||||
} catch (err) {
|
||||
console.error('Migration failed:', err.message);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
run();
|
||||
49
backend/migrations/add_queue_remediation_notes_table.js
Normal file
49
backend/migrations/add_queue_remediation_notes_table.js
Normal file
@@ -0,0 +1,49 @@
|
||||
// Migration: Create queue_remediation_notes table
|
||||
// Stores remediation notes for Ivanti todo queue items (append-only).
|
||||
// FK cascade ensures notes are deleted when the parent queue item is removed.
|
||||
// Idempotent — safe to re-run multiple times.
|
||||
const pool = require('../db');
|
||||
|
||||
async function run() {
|
||||
console.log('Starting queue_remediation_notes migration...');
|
||||
|
||||
// Verify prerequisite table exists
|
||||
const { rows: queueTable } = await pool.query(`
|
||||
SELECT 1 FROM information_schema.tables
|
||||
WHERE table_schema = 'public' AND table_name = 'ivanti_todo_queue'
|
||||
`);
|
||||
if (queueTable.length === 0) {
|
||||
console.error('✗ ivanti_todo_queue table does not exist. Cannot proceed.');
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('✓ ivanti_todo_queue table exists');
|
||||
|
||||
// Create queue_remediation_notes table
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS queue_remediation_notes (
|
||||
id SERIAL PRIMARY KEY,
|
||||
queue_item_id INTEGER NOT NULL REFERENCES ivanti_todo_queue(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL,
|
||||
username VARCHAR(100) NOT NULL,
|
||||
note_text TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
CONSTRAINT chk_note_text_length CHECK (char_length(note_text) <= 5000)
|
||||
)
|
||||
`);
|
||||
console.log('✓ queue_remediation_notes table created (or already exists)');
|
||||
|
||||
// Create index on queue_item_id for efficient lookup
|
||||
await pool.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_remediation_notes_queue_item
|
||||
ON queue_remediation_notes(queue_item_id)
|
||||
`);
|
||||
console.log('✓ queue_item_id index created (or already exists)');
|
||||
|
||||
console.log('Migration complete.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
run().catch(err => {
|
||||
console.error('Migration failed:', err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
48
backend/migrations/add_remediate_workflow_type.js
Normal file
48
backend/migrations/add_remediate_workflow_type.js
Normal file
@@ -0,0 +1,48 @@
|
||||
// Migration: Add 'Remediate' to the ivanti_todo_queue workflow_type constraint
|
||||
// Uses idempotent pattern: drop constraint IF EXISTS, then re-add with full set.
|
||||
// Safe to re-run multiple times.
|
||||
const pool = require('../db');
|
||||
|
||||
async function run() {
|
||||
console.log('Starting add_remediate_workflow_type migration...');
|
||||
|
||||
// Verify prerequisite table exists
|
||||
const { rows: queueTable } = await pool.query(`
|
||||
SELECT 1 FROM information_schema.tables
|
||||
WHERE table_schema = 'public' AND table_name = 'ivanti_todo_queue'
|
||||
`);
|
||||
if (queueTable.length === 0) {
|
||||
console.error('✗ ivanti_todo_queue table does not exist. Cannot proceed.');
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('✓ ivanti_todo_queue table exists');
|
||||
|
||||
// Drop the existing workflow_type check constraint if it exists
|
||||
await pool.query(`
|
||||
ALTER TABLE ivanti_todo_queue
|
||||
DROP CONSTRAINT IF EXISTS ivanti_todo_queue_workflow_type_check
|
||||
`);
|
||||
console.log('✓ Dropped existing workflow_type check constraint (if any)');
|
||||
|
||||
// Also drop alternative constraint name patterns
|
||||
await pool.query(`
|
||||
ALTER TABLE ivanti_todo_queue
|
||||
DROP CONSTRAINT IF EXISTS chk_workflow_type
|
||||
`);
|
||||
|
||||
// Re-add the constraint with 'Remediate' included
|
||||
await pool.query(`
|
||||
ALTER TABLE ivanti_todo_queue
|
||||
ADD CONSTRAINT ivanti_todo_queue_workflow_type_check
|
||||
CHECK (workflow_type IN ('FP', 'Archer', 'CARD', 'GRANITE', 'DECOM', 'Remediate'))
|
||||
`);
|
||||
console.log('✓ Added workflow_type check constraint with Remediate included');
|
||||
|
||||
console.log('Migration complete.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
run().catch(err => {
|
||||
console.error('Migration failed:', err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
31
backend/migrations/add_user_ivanti_identity.js
Normal file
31
backend/migrations/add_user_ivanti_identity.js
Normal file
@@ -0,0 +1,31 @@
|
||||
#!/usr/bin/env node
|
||||
// Migration: Add ivanti_first_name and ivanti_last_name to users table
|
||||
// Allows per-user Ivanti identity for workflow filtering.
|
||||
|
||||
const pool = require('../db');
|
||||
|
||||
async function run() {
|
||||
console.log('Adding Ivanti identity columns to users table...');
|
||||
try {
|
||||
await pool.query(`
|
||||
ALTER TABLE users
|
||||
ADD COLUMN IF NOT EXISTS ivanti_first_name VARCHAR(100) DEFAULT NULL
|
||||
`);
|
||||
console.log('✓ ivanti_first_name column added (or already exists)');
|
||||
|
||||
await pool.query(`
|
||||
ALTER TABLE users
|
||||
ADD COLUMN IF NOT EXISTS ivanti_last_name VARCHAR(100) DEFAULT NULL
|
||||
`);
|
||||
console.log('✓ ivanti_last_name column added (or already exists)');
|
||||
|
||||
console.log('Migration complete.');
|
||||
} catch (err) {
|
||||
console.error('Migration failed:', err.message);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
run();
|
||||
@@ -27,6 +27,12 @@ const POSTGRES_MIGRATIONS = [
|
||||
'drop_jira_status_check_constraint.js',
|
||||
'add_compliance_history_metric_id.js',
|
||||
'add_archer_templates_table.js',
|
||||
'add_queue_remediation_notes_table.js',
|
||||
'add_remediate_workflow_type.js',
|
||||
'add_notifications_table.js',
|
||||
'add_ivanti_findings_ipv6_columns.js',
|
||||
'add_user_ivanti_identity.js',
|
||||
'add_atlas_known_column.js',
|
||||
];
|
||||
|
||||
async function runAll() {
|
||||
|
||||
@@ -74,7 +74,10 @@ function createAtlasRouter() {
|
||||
* GET /metrics
|
||||
*
|
||||
* Returns aggregated Atlas action plan metrics from the local cache.
|
||||
* Accepts optional `teams` query parameter to scope metrics to hosts
|
||||
* belonging to specific BUs (via JOIN on ivanti_findings).
|
||||
*
|
||||
* @query {string} [teams] - Comma-separated team names (e.g. 'STEAM,ACCESS-ENG')
|
||||
* @returns {Object} 200 - { totalHosts, hostsWithPlans, hostsWithoutPlans, plansByType, plansByStatus, totalPlans }
|
||||
* @returns {Object} 503 - { error } when Atlas API is not configured
|
||||
* @returns {Object} 500 - { error } on database failure
|
||||
@@ -85,9 +88,37 @@ function createAtlasRouter() {
|
||||
}
|
||||
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT has_action_plan, plans_json FROM atlas_action_plans_cache`
|
||||
);
|
||||
const teamsParam = req.query.teams;
|
||||
let rows;
|
||||
|
||||
if (teamsParam) {
|
||||
const teams = teamsParam.split(',').map(t => t.trim()).filter(Boolean);
|
||||
if (teams.length > 0) {
|
||||
const patterns = teams.map(t => `%${t}%`);
|
||||
const result = await pool.query(
|
||||
`SELECT a.has_action_plan, a.plans_json
|
||||
FROM atlas_action_plans_cache a
|
||||
INNER JOIN (
|
||||
SELECT DISTINCT host_id FROM ivanti_findings
|
||||
WHERE bu_ownership ILIKE ANY($1::text[])
|
||||
) f ON a.host_id = f.host_id
|
||||
WHERE a.atlas_known = true`,
|
||||
[patterns]
|
||||
);
|
||||
rows = result.rows;
|
||||
} else {
|
||||
const result = await pool.query(
|
||||
`SELECT has_action_plan, plans_json FROM atlas_action_plans_cache WHERE atlas_known = true`
|
||||
);
|
||||
rows = result.rows;
|
||||
}
|
||||
} else {
|
||||
const result = await pool.query(
|
||||
`SELECT has_action_plan, plans_json FROM atlas_action_plans_cache WHERE atlas_known = true`
|
||||
);
|
||||
rows = result.rows;
|
||||
}
|
||||
|
||||
const metrics = aggregateAtlasMetrics(rows);
|
||||
res.json(metrics);
|
||||
} catch (err) {
|
||||
@@ -99,9 +130,12 @@ function createAtlasRouter() {
|
||||
/**
|
||||
* GET /status
|
||||
*
|
||||
* Returns the full atlas_action_plans_cache table contents for status display.
|
||||
* Returns atlas_action_plans_cache contents for status display.
|
||||
* Accepts optional `teams` query parameter to scope results to hosts
|
||||
* belonging to specific BUs (via JOIN on ivanti_findings).
|
||||
*
|
||||
* @returns {Array} 200 - Array of { host_id, has_action_plan, plan_count, plans_json, synced_at }
|
||||
* @query {string} [teams] - Comma-separated team names (e.g. 'STEAM,ACCESS-ENG')
|
||||
* @returns {Array} 200 - Array of { host_id, has_action_plan, plan_count, plans_json, atlas_known, synced_at }
|
||||
* @returns {Object} 503 - { error } when Atlas API is not configured
|
||||
* @returns {Object} 500 - { error } on database failure
|
||||
*/
|
||||
@@ -111,9 +145,36 @@ function createAtlasRouter() {
|
||||
}
|
||||
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT host_id, has_action_plan, plan_count, plans_json, synced_at FROM atlas_action_plans_cache`
|
||||
);
|
||||
const teamsParam = req.query.teams;
|
||||
let rows;
|
||||
|
||||
if (teamsParam) {
|
||||
const teams = teamsParam.split(',').map(t => t.trim()).filter(Boolean);
|
||||
if (teams.length > 0) {
|
||||
const patterns = teams.map(t => `%${t}%`);
|
||||
const result = await pool.query(
|
||||
`SELECT a.host_id, a.has_action_plan, a.plan_count, a.plans_json, a.atlas_known, a.synced_at
|
||||
FROM atlas_action_plans_cache a
|
||||
INNER JOIN (
|
||||
SELECT DISTINCT host_id FROM ivanti_findings
|
||||
WHERE bu_ownership ILIKE ANY($1::text[])
|
||||
) f ON a.host_id = f.host_id`,
|
||||
[patterns]
|
||||
);
|
||||
rows = result.rows;
|
||||
} else {
|
||||
const result = await pool.query(
|
||||
`SELECT host_id, has_action_plan, plan_count, plans_json, atlas_known, synced_at FROM atlas_action_plans_cache`
|
||||
);
|
||||
rows = result.rows;
|
||||
}
|
||||
} else {
|
||||
const result = await pool.query(
|
||||
`SELECT host_id, has_action_plan, plan_count, plans_json, atlas_known, synced_at FROM atlas_action_plans_cache`
|
||||
);
|
||||
rows = result.rows;
|
||||
}
|
||||
|
||||
res.json(rows);
|
||||
} catch (err) {
|
||||
console.error('[Atlas] Error fetching status:', err.message);
|
||||
@@ -126,8 +187,12 @@ function createAtlasRouter() {
|
||||
*
|
||||
* Syncs action plan data from Atlas for all hosts found in ivanti_findings.
|
||||
* Fetches plans per host in batches of 5 and upserts into the local cache.
|
||||
* Scopes to the provided teams or falls back to IVANTI_MANAGED_BUS.
|
||||
* Requires Admin or Standard_User group.
|
||||
*
|
||||
* @query {string} [teams] - Comma-separated team names to scope sync (e.g. 'STEAM,ACCESS-ENG')
|
||||
* @param {Object} [req.body]
|
||||
* @param {string} [req.body.teams] - Comma-separated team names (alternative to query param)
|
||||
* @returns {Object} 200 - { synced, withPlans, failed }
|
||||
* @returns {Object} 503 - { error } when Atlas API is not configured
|
||||
* @returns {Object} 500 - { error } on unexpected failure
|
||||
@@ -138,16 +203,67 @@ function createAtlasRouter() {
|
||||
}
|
||||
|
||||
try {
|
||||
// Read Ivanti findings and extract unique non-null hostIds
|
||||
const { rows: findingsRows } = await pool.query(
|
||||
`SELECT DISTINCT host_id FROM ivanti_findings WHERE host_id IS NOT NULL AND host_id > 0`
|
||||
);
|
||||
// Scope sync to the user's active teams if provided, otherwise sync only
|
||||
// findings from managed BUs (IVANTI_MANAGED_BUS) to avoid polluting cache
|
||||
// with "no plan" entries for BUs not covered by Atlas.
|
||||
const teamsParam = req.query.teams || req.body.teams || '';
|
||||
const managedBUs = (process.env.IVANTI_MANAGED_BUS || 'NTS-AEO-ACCESS-ENG,NTS-AEO-STEAM')
|
||||
.split(',').map(b => b.trim()).filter(Boolean);
|
||||
|
||||
let findingsRows;
|
||||
if (teamsParam) {
|
||||
const teams = teamsParam.split(',').map(t => t.trim()).filter(Boolean);
|
||||
if (teams.length > 0) {
|
||||
const patterns = teams.map(t => `%${t}%`);
|
||||
const result = await pool.query(
|
||||
`SELECT DISTINCT host_id FROM ivanti_findings
|
||||
WHERE host_id IS NOT NULL AND host_id > 0
|
||||
AND bu_ownership ILIKE ANY($1::text[])`,
|
||||
[patterns]
|
||||
);
|
||||
findingsRows = result.rows;
|
||||
} else {
|
||||
// No valid teams — fall back to managed BUs
|
||||
const patterns = managedBUs.map(b => `%${b}%`);
|
||||
const result = await pool.query(
|
||||
`SELECT DISTINCT host_id FROM ivanti_findings
|
||||
WHERE host_id IS NOT NULL AND host_id > 0
|
||||
AND bu_ownership ILIKE ANY($1::text[])`,
|
||||
[patterns]
|
||||
);
|
||||
findingsRows = result.rows;
|
||||
}
|
||||
} else {
|
||||
// No teams specified — default to managed BUs only
|
||||
const patterns = managedBUs.map(b => `%${b}%`);
|
||||
const result = await pool.query(
|
||||
`SELECT DISTINCT host_id FROM ivanti_findings
|
||||
WHERE host_id IS NOT NULL AND host_id > 0
|
||||
AND bu_ownership ILIKE ANY($1::text[])`,
|
||||
[patterns]
|
||||
);
|
||||
findingsRows = result.rows;
|
||||
}
|
||||
|
||||
const hostIds = findingsRows.map(r => r.host_id);
|
||||
|
||||
if (hostIds.length === 0) {
|
||||
return res.json({ synced: 0, withPlans: 0, failed: 0 });
|
||||
}
|
||||
|
||||
// Build a set of host IDs belonging to managed BUs — these always show the badge
|
||||
const managedPatterns = managedBUs.map(b => `%${b}%`);
|
||||
let managedHostIds = new Set();
|
||||
try {
|
||||
const { rows: managedRows } = await pool.query(
|
||||
`SELECT DISTINCT host_id FROM ivanti_findings
|
||||
WHERE host_id IS NOT NULL AND host_id > 0
|
||||
AND bu_ownership ILIKE ANY($1::text[])`,
|
||||
[managedPatterns]
|
||||
);
|
||||
managedHostIds = new Set(managedRows.map(r => r.host_id));
|
||||
} catch (_) { /* non-fatal — fall back to plans-only logic */ }
|
||||
|
||||
let synced = 0;
|
||||
let withPlans = 0;
|
||||
let failed = 0;
|
||||
@@ -170,27 +286,40 @@ function createAtlasRouter() {
|
||||
}
|
||||
|
||||
const { hostId, result } = settled.value;
|
||||
const isManagedHost = managedHostIds.has(hostId);
|
||||
|
||||
if (result.status >= 200 && result.status < 300) {
|
||||
let allPlans = [];
|
||||
let activePlans = [];
|
||||
let atlasRecognizesHost = false;
|
||||
try {
|
||||
const parsed = JSON.parse(result.body);
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
activePlans = Array.isArray(parsed.active) ? parsed.active : [];
|
||||
const inactive = Array.isArray(parsed.inactive) ? parsed.inactive : [];
|
||||
allPlans = [...activePlans, ...inactive];
|
||||
// Check for "not found" error responses that come back as 200
|
||||
if (parsed.error || parsed.message?.includes('not found')) {
|
||||
atlasRecognizesHost = false;
|
||||
} else {
|
||||
atlasRecognizesHost = true;
|
||||
activePlans = Array.isArray(parsed.active) ? parsed.active : [];
|
||||
const inactive = Array.isArray(parsed.inactive) ? parsed.inactive : [];
|
||||
allPlans = [...activePlans, ...inactive];
|
||||
}
|
||||
} else if (Array.isArray(parsed)) {
|
||||
atlasRecognizesHost = true;
|
||||
allPlans = parsed;
|
||||
activePlans = parsed;
|
||||
}
|
||||
} catch (e) {
|
||||
allPlans = [];
|
||||
activePlans = [];
|
||||
atlasRecognizesHost = false;
|
||||
}
|
||||
|
||||
const planCount = activePlans.length;
|
||||
const hasActionPlan = planCount > 0;
|
||||
// Atlas knows this host if it returned a valid structured response
|
||||
// (not "not found" or error). This determines whether the badge renders.
|
||||
const atlasKnown = atlasRecognizesHost;
|
||||
|
||||
try {
|
||||
if (!hasActionPlan) {
|
||||
@@ -216,14 +345,15 @@ function createAtlasRouter() {
|
||||
}
|
||||
|
||||
await pool.query(
|
||||
`INSERT INTO atlas_action_plans_cache (host_id, has_action_plan, plan_count, plans_json, synced_at)
|
||||
VALUES ($1, $2, $3, $4, NOW())
|
||||
`INSERT INTO atlas_action_plans_cache (host_id, has_action_plan, plan_count, plans_json, atlas_known, synced_at)
|
||||
VALUES ($1, $2, $3, $4, $5, NOW())
|
||||
ON CONFLICT(host_id) DO UPDATE SET
|
||||
has_action_plan = EXCLUDED.has_action_plan,
|
||||
plan_count = EXCLUDED.plan_count,
|
||||
plans_json = EXCLUDED.plans_json,
|
||||
atlas_known = EXCLUDED.atlas_known,
|
||||
synced_at = EXCLUDED.synced_at`,
|
||||
[hostId, hasActionPlan, planCount, JSON.stringify(allPlans)]
|
||||
[hostId, hasActionPlan, planCount, JSON.stringify(allPlans), atlasKnown]
|
||||
);
|
||||
} catch (dbErr) {
|
||||
console.error('[Atlas Sync] DB upsert failed for host', hostId, ':', dbErr.message);
|
||||
@@ -342,6 +472,45 @@ function createAtlasRouter() {
|
||||
if (result.status >= 200 && result.status < 300) {
|
||||
let body;
|
||||
try { body = JSON.parse(result.body); } catch (e) { body = result.body; }
|
||||
|
||||
// Update local cache with the created plan
|
||||
try {
|
||||
const { rows: existingRows } = await pool.query(
|
||||
`SELECT plan_count, plans_json FROM atlas_action_plans_cache WHERE host_id = $1`,
|
||||
[hostId]
|
||||
);
|
||||
const existing = existingRows[0];
|
||||
let existingPlans = [];
|
||||
if (existing && existing.plans_json) {
|
||||
try { existingPlans = JSON.parse(existing.plans_json); } catch (_) {}
|
||||
}
|
||||
|
||||
// Include the plan ID from Atlas response if available
|
||||
const newPlan = {
|
||||
plan_type,
|
||||
commit_date,
|
||||
source: 'create',
|
||||
created_at: new Date().toISOString(),
|
||||
...(body?.action_plan_id ? { action_plan_id: body.action_plan_id } : {}),
|
||||
...(body?.id ? { action_plan_id: body.id } : {}),
|
||||
};
|
||||
const updatedPlans = [...existingPlans, newPlan];
|
||||
|
||||
await pool.query(
|
||||
`INSERT INTO atlas_action_plans_cache (host_id, has_action_plan, plan_count, plans_json, atlas_known, synced_at)
|
||||
VALUES ($1, true, $2, $3, true, NOW())
|
||||
ON CONFLICT(host_id) DO UPDATE SET
|
||||
has_action_plan = true,
|
||||
plan_count = EXCLUDED.plan_count,
|
||||
plans_json = EXCLUDED.plans_json,
|
||||
atlas_known = true,
|
||||
synced_at = EXCLUDED.synced_at`,
|
||||
[hostId, updatedPlans.length, JSON.stringify(updatedPlans)]
|
||||
);
|
||||
} catch (cacheErr) {
|
||||
console.error('[Atlas] Cache update failed after plan create for host', hostId, ':', cacheErr.message);
|
||||
}
|
||||
|
||||
res.status(result.status).json(body);
|
||||
} else {
|
||||
let errorBody;
|
||||
@@ -473,17 +642,38 @@ function createAtlasRouter() {
|
||||
try { existingPlans = JSON.parse(existing.plans_json); } catch (_) {}
|
||||
}
|
||||
|
||||
const stubPlan = { plan_type, commit_date, source: 'bulk-create', created_at: new Date().toISOString() };
|
||||
// Extract plan ID from bulk response if available (keyed by host_id)
|
||||
let planId = null;
|
||||
if (body && typeof body === 'object') {
|
||||
// Response may be { results: [{host_id, action_plan_id}] } or { [hostId]: {id} }
|
||||
if (Array.isArray(body.results)) {
|
||||
const match = body.results.find(r => r.host_id === hid || r.host_id === String(hid));
|
||||
if (match) planId = match.action_plan_id || match.id;
|
||||
} else if (body[hid]) {
|
||||
planId = body[hid].action_plan_id || body[hid].id;
|
||||
} else if (body[String(hid)]) {
|
||||
planId = body[String(hid)].action_plan_id || body[String(hid)].id;
|
||||
}
|
||||
}
|
||||
|
||||
const stubPlan = {
|
||||
plan_type,
|
||||
commit_date,
|
||||
source: 'bulk-create',
|
||||
created_at: new Date().toISOString(),
|
||||
...(planId ? { action_plan_id: planId } : {}),
|
||||
};
|
||||
const updatedPlans = [...existingPlans, stubPlan];
|
||||
const newCount = updatedPlans.length;
|
||||
|
||||
await pool.query(
|
||||
`INSERT INTO atlas_action_plans_cache (host_id, has_action_plan, plan_count, plans_json, synced_at)
|
||||
VALUES ($1, true, $2, $3, NOW())
|
||||
`INSERT INTO atlas_action_plans_cache (host_id, has_action_plan, plan_count, plans_json, atlas_known, synced_at)
|
||||
VALUES ($1, true, $2, $3, true, NOW())
|
||||
ON CONFLICT(host_id) DO UPDATE SET
|
||||
has_action_plan = true,
|
||||
plan_count = EXCLUDED.plan_count,
|
||||
plans_json = EXCLUDED.plans_json,
|
||||
atlas_known = true,
|
||||
synced_at = EXCLUDED.synced_at`,
|
||||
[hid, newCount, JSON.stringify(updatedPlans)]
|
||||
);
|
||||
@@ -561,16 +751,18 @@ function createAtlasRouter() {
|
||||
|
||||
const planCount = activePlans.length;
|
||||
const hasActionPlan = planCount > 0;
|
||||
const atlasKnown = allPlans.length > 0;
|
||||
|
||||
await pool.query(
|
||||
`INSERT INTO atlas_action_plans_cache (host_id, has_action_plan, plan_count, plans_json, synced_at)
|
||||
VALUES ($1, $2, $3, $4, NOW())
|
||||
`INSERT INTO atlas_action_plans_cache (host_id, has_action_plan, plan_count, plans_json, atlas_known, synced_at)
|
||||
VALUES ($1, $2, $3, $4, $5, NOW())
|
||||
ON CONFLICT(host_id) DO UPDATE SET
|
||||
has_action_plan = EXCLUDED.has_action_plan,
|
||||
plan_count = EXCLUDED.plan_count,
|
||||
plans_json = EXCLUDED.plans_json,
|
||||
atlas_known = EXCLUDED.atlas_known,
|
||||
synced_at = EXCLUDED.synced_at`,
|
||||
[hostId, hasActionPlan, planCount, JSON.stringify(allPlans)]
|
||||
[hostId, hasActionPlan, planCount, JSON.stringify(allPlans), atlasKnown]
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,8 @@ const {
|
||||
declineAsset,
|
||||
redirectAsset,
|
||||
resolveAssetId,
|
||||
searchByIvantiHostId,
|
||||
searchByAssetId,
|
||||
} = require('../helpers/cardApi');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -223,10 +225,31 @@ function createCardApiRouter() {
|
||||
return res.status(400).json({ error: 'assetId is required.' });
|
||||
}
|
||||
|
||||
// Resolve bare IP to full CARD asset ID
|
||||
// Resolve bare IP to full CARD asset ID — try Ivanti host_id fast path first
|
||||
let assetId = rawAssetId.trim();
|
||||
if (!/\d+-[A-Z]+$/i.test(assetId)) {
|
||||
const resolved = await resolveAssetId(assetId);
|
||||
let resolved = null;
|
||||
|
||||
// Fast path: look up the finding's host_id and search CARD directly
|
||||
const findingRow = await pool.query(
|
||||
'SELECT host_id FROM ivanti_findings WHERE ip_address = $1 LIMIT 1',
|
||||
[assetId]
|
||||
).then(r => r.rows[0]).catch(() => null);
|
||||
|
||||
if (findingRow && findingRow.host_id) {
|
||||
try {
|
||||
const searchResult = await searchByIvantiHostId(findingRow.host_id);
|
||||
if (searchResult.ok) {
|
||||
const searchData = JSON.parse(searchResult.body);
|
||||
resolved = searchData._id || searchData.asset_id || searchData.id || null;
|
||||
}
|
||||
} catch (_) { /* fall through */ }
|
||||
}
|
||||
|
||||
// Fallback: suffix guessing
|
||||
if (!resolved) {
|
||||
resolved = await resolveAssetId(assetId);
|
||||
}
|
||||
if (!resolved) {
|
||||
return res.status(404).json({ error: `Asset not found in CARD for IP: ${assetId}` });
|
||||
}
|
||||
@@ -325,10 +348,31 @@ function createCardApiRouter() {
|
||||
return res.status(400).json({ error: 'assetId is required.' });
|
||||
}
|
||||
|
||||
// Resolve bare IP to full CARD asset ID
|
||||
// Resolve bare IP to full CARD asset ID — try Ivanti host_id fast path first
|
||||
let assetId = rawAssetId.trim();
|
||||
if (!/\d+-[A-Z]+$/i.test(assetId)) {
|
||||
const resolved = await resolveAssetId(assetId);
|
||||
let resolved = null;
|
||||
|
||||
// Fast path: look up the finding's host_id and search CARD directly
|
||||
const findingRow = await pool.query(
|
||||
'SELECT host_id FROM ivanti_findings WHERE ip_address = $1 LIMIT 1',
|
||||
[assetId]
|
||||
).then(r => r.rows[0]).catch(() => null);
|
||||
|
||||
if (findingRow && findingRow.host_id) {
|
||||
try {
|
||||
const searchResult = await searchByIvantiHostId(findingRow.host_id);
|
||||
if (searchResult.ok) {
|
||||
const searchData = JSON.parse(searchResult.body);
|
||||
resolved = searchData._id || searchData.asset_id || searchData.id || null;
|
||||
}
|
||||
} catch (_) { /* fall through */ }
|
||||
}
|
||||
|
||||
// Fallback: suffix guessing
|
||||
if (!resolved) {
|
||||
resolved = await resolveAssetId(assetId);
|
||||
}
|
||||
if (!resolved) {
|
||||
return res.status(404).json({ error: `Asset not found in CARD for IP: ${assetId}` });
|
||||
}
|
||||
@@ -363,8 +407,8 @@ function createCardApiRouter() {
|
||||
const updateToken = ownerData.owner && ownerData.owner.update_token;
|
||||
|
||||
if (!updateToken) {
|
||||
const errMsg = 'update_token not found in owner record.';
|
||||
logAudit({ 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 });
|
||||
const errMsg = 'update_token not found in owner record. The asset may have already been actioned or the owner record is in an unexpected state.';
|
||||
logAudit({ 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, ownerResponse: ownerData }, ipAddress: req.ip });
|
||||
return res.status(502).json({ error: 'CARD API request failed.', details: errMsg });
|
||||
}
|
||||
|
||||
@@ -430,10 +474,31 @@ function createCardApiRouter() {
|
||||
return res.status(400).json({ error: 'assetId is required.' });
|
||||
}
|
||||
|
||||
// Resolve bare IP to full CARD asset ID (e.g., 10.240.78.110 → 10.240.78.110-CTEC)
|
||||
// Resolve bare IP to full CARD asset ID — try Ivanti host_id fast path first
|
||||
let assetId = rawAssetId.trim();
|
||||
if (!/\d+-[A-Z]+$/i.test(assetId)) {
|
||||
const resolved = await resolveAssetId(assetId);
|
||||
let resolved = null;
|
||||
|
||||
// Fast path: look up the finding's host_id and search CARD directly
|
||||
const findingRow = await pool.query(
|
||||
'SELECT host_id FROM ivanti_findings WHERE ip_address = $1 LIMIT 1',
|
||||
[assetId]
|
||||
).then(r => r.rows[0]).catch(() => null);
|
||||
|
||||
if (findingRow && findingRow.host_id) {
|
||||
try {
|
||||
const searchResult = await searchByIvantiHostId(findingRow.host_id);
|
||||
if (searchResult.ok) {
|
||||
const searchData = JSON.parse(searchResult.body);
|
||||
resolved = searchData._id || searchData.asset_id || searchData.id || null;
|
||||
}
|
||||
} catch (_) { /* fall through */ }
|
||||
}
|
||||
|
||||
// Fallback: suffix guessing
|
||||
if (!resolved) {
|
||||
resolved = await resolveAssetId(assetId);
|
||||
}
|
||||
if (!resolved) {
|
||||
return res.status(404).json({ error: `Asset not found in CARD for IP: ${assetId}` });
|
||||
}
|
||||
@@ -508,8 +573,12 @@ function createCardApiRouter() {
|
||||
* confirm/decline/redirect operations.
|
||||
*
|
||||
* @param {string} ip - IP address (path parameter)
|
||||
* @query {string} [quick] - Set to "1" for quick mode (CTEC only, 15s timeout). Used for tooltip lookups.
|
||||
* @query {string} [hostId] - Ivanti Host ID (integer). When provided, uses CARD asset-search for faster resolution.
|
||||
* @response 200 - { asset_id, ip, confirmed, unconfirmed, declined, candidate, update_token }
|
||||
* @response 400 - { error: string } — missing IP
|
||||
* @response 404 - { error: string } — IP not found in CARD
|
||||
* @response 504 - { error: string, timeout: true } — CARD lookup timed out
|
||||
* @response 503 - { error: string } — CARD not configured
|
||||
*/
|
||||
router.get('/owner-lookup/:ip', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||
@@ -522,8 +591,65 @@ function createCardApiRouter() {
|
||||
return res.status(400).json({ error: 'IP address is required.' });
|
||||
}
|
||||
|
||||
// Resolve to full asset ID
|
||||
const assetId = await resolveAssetId(ip.trim());
|
||||
// Use quick mode (CTEC only, 15s timeout) for tooltip lookups
|
||||
const quick = req.query.quick === '1';
|
||||
const hostId = req.query.hostId;
|
||||
|
||||
// Fast path: if Ivanti hostId is provided, try asset-search first
|
||||
// The asset-search returns the full record including owner data, so we can
|
||||
// often return directly without a separate getOwner() call.
|
||||
// If update_token is missing, do a follow-up getOwner() to fetch it.
|
||||
let assetId = null;
|
||||
if (hostId && /^\d+$/.test(hostId)) {
|
||||
try {
|
||||
const searchResult = await searchByIvantiHostId(hostId);
|
||||
if (searchResult.ok) {
|
||||
const searchData = JSON.parse(searchResult.body);
|
||||
const assets = searchData.assets || [];
|
||||
if (assets.length > 0) {
|
||||
const asset = assets[0];
|
||||
const owner = asset.owner || {};
|
||||
let updateToken = owner.update_token || null;
|
||||
|
||||
// If no update_token, fetch it from the owner endpoint using the resolved _id
|
||||
if (!updateToken && asset._id) {
|
||||
try {
|
||||
const ownerResult = await getOwner(asset._id);
|
||||
if (ownerResult.ok) {
|
||||
const ownerData = JSON.parse(ownerResult.body);
|
||||
updateToken = (ownerData.owner && ownerData.owner.update_token) || null;
|
||||
}
|
||||
} catch (_) { /* best effort */ }
|
||||
}
|
||||
|
||||
return res.json({
|
||||
asset_id: asset._id || null,
|
||||
ip: ip.trim(),
|
||||
confirmed: owner.confirmed || null,
|
||||
unconfirmed: owner.unconfirmed || null,
|
||||
declined: owner.declined || [],
|
||||
candidate: owner.candidate || [],
|
||||
update_token: updateToken,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
// Fall through to suffix resolution
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: resolve via IP + suffix guessing
|
||||
if (!assetId) {
|
||||
try {
|
||||
assetId = await resolveAssetId(ip.trim(), quick ? { quick: true } : undefined);
|
||||
} catch (err) {
|
||||
if (err.message === 'CARD_TIMEOUT') {
|
||||
return res.status(504).json({ error: 'CARD lookup timed out', timeout: true });
|
||||
}
|
||||
return handleCardError(err, res);
|
||||
}
|
||||
}
|
||||
|
||||
if (!assetId) {
|
||||
return res.status(404).json({ error: `Asset not found in CARD for IP: ${ip}` });
|
||||
}
|
||||
@@ -552,20 +678,280 @@ function createCardApiRouter() {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /owner/:assetId/confirm
|
||||
*
|
||||
* Directly confirm ownership of a CARD asset (no queue item required).
|
||||
* Fetches the owner record for the update_token, then calls CARD confirm.
|
||||
*
|
||||
* @param {string} assetId - CARD asset identifier or IP address (path parameter)
|
||||
* @body {string} teamName - Team to confirm ownership for (required)
|
||||
* @body {string} [comment] - Optional comment
|
||||
* @response 200 - { success: true, cardResponse: object }
|
||||
* @response 400 - { error: string } — missing fields
|
||||
* @response 404 - { error: string } — asset not found
|
||||
* @response 502 - { error: string } — CARD API failure
|
||||
* @response 503 - { error: string, missingVars: string[] } — CARD not configured
|
||||
*/
|
||||
router.post('/owner/:assetId/confirm', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||
if (!isConfigured) {
|
||||
return res.status(503).json({ error: 'CARD API is not configured.', missingVars });
|
||||
}
|
||||
|
||||
let assetId = req.params.assetId;
|
||||
const { teamName, comment } = req.body || {};
|
||||
|
||||
if (!teamName || typeof teamName !== 'string' || !teamName.trim()) {
|
||||
return res.status(400).json({ error: 'teamName is required.' });
|
||||
}
|
||||
|
||||
// Resolve bare IP to full CARD asset ID
|
||||
if (!/\d+-[A-Z]+$/i.test(assetId)) {
|
||||
const resolved = await resolveAssetId(assetId);
|
||||
if (!resolved) {
|
||||
return res.status(404).json({ error: `Asset not found in CARD for: ${assetId}` });
|
||||
}
|
||||
assetId = resolved;
|
||||
}
|
||||
|
||||
try {
|
||||
const ownerResult = await getOwner(assetId);
|
||||
if (!ownerResult.ok) {
|
||||
return res.status(ownerResult.status).json({ error: `Failed to fetch owner record: HTTP ${ownerResult.status}` });
|
||||
}
|
||||
|
||||
let ownerData;
|
||||
try { ownerData = JSON.parse(ownerResult.body); } catch (_) { ownerData = {}; }
|
||||
const updateToken = ownerData.owner && ownerData.owner.update_token;
|
||||
|
||||
if (!updateToken) {
|
||||
return res.status(502).json({ error: 'update_token not found in owner record.' });
|
||||
}
|
||||
|
||||
const confirmResult = await confirmAsset(assetId, teamName.trim(), updateToken, comment || '');
|
||||
|
||||
if (confirmResult.ok) {
|
||||
let cardResponse;
|
||||
try { cardResponse = JSON.parse(confirmResult.body); } catch (_) { cardResponse = confirmResult.body; }
|
||||
logAudit({ userId: req.user.id, username: req.user.username, action: 'card_confirm_direct', entityType: 'card_asset', entityId: assetId, details: { teamName: teamName.trim(), comment: comment || '' }, ipAddress: req.ip });
|
||||
return res.json({ success: true, cardResponse });
|
||||
}
|
||||
|
||||
let errorBody;
|
||||
try { errorBody = JSON.parse(confirmResult.body); } catch (_) { errorBody = { error: confirmResult.body }; }
|
||||
return res.status(confirmResult.status).json(errorBody);
|
||||
} catch (err) {
|
||||
return handleCardError(err, res);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /owner/:assetId/decline
|
||||
*
|
||||
* Directly decline ownership of a CARD asset (no queue item required).
|
||||
* Fetches the owner record for the update_token, then calls CARD decline.
|
||||
*
|
||||
* @param {string} assetId - CARD asset identifier or IP address (path parameter)
|
||||
* @body {string} teamName - Team to decline ownership for (required)
|
||||
* @body {string} [comment] - Optional comment
|
||||
* @response 200 - { success: true, cardResponse: object }
|
||||
* @response 400 - { error: string } — missing fields
|
||||
* @response 404 - { error: string } — asset not found
|
||||
* @response 502 - { error: string } — CARD API failure
|
||||
* @response 503 - { error: string, missingVars: string[] } — CARD not configured
|
||||
*/
|
||||
router.post('/owner/:assetId/decline', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||
if (!isConfigured) {
|
||||
return res.status(503).json({ error: 'CARD API is not configured.', missingVars });
|
||||
}
|
||||
|
||||
let assetId = req.params.assetId;
|
||||
const { teamName, comment } = req.body || {};
|
||||
|
||||
if (!teamName || typeof teamName !== 'string' || !teamName.trim()) {
|
||||
return res.status(400).json({ error: 'teamName is required.' });
|
||||
}
|
||||
|
||||
if (!/\d+-[A-Z]+$/i.test(assetId)) {
|
||||
const resolved = await resolveAssetId(assetId);
|
||||
if (!resolved) {
|
||||
return res.status(404).json({ error: `Asset not found in CARD for: ${assetId}` });
|
||||
}
|
||||
assetId = resolved;
|
||||
}
|
||||
|
||||
try {
|
||||
const ownerResult = await getOwner(assetId);
|
||||
if (!ownerResult.ok) {
|
||||
return res.status(ownerResult.status).json({ error: `Failed to fetch owner record: HTTP ${ownerResult.status}` });
|
||||
}
|
||||
|
||||
let ownerData;
|
||||
try { ownerData = JSON.parse(ownerResult.body); } catch (_) { ownerData = {}; }
|
||||
const updateToken = ownerData.owner && ownerData.owner.update_token;
|
||||
|
||||
if (!updateToken) {
|
||||
return res.status(502).json({ error: 'update_token not found in owner record.' });
|
||||
}
|
||||
|
||||
const declineResult = await declineAsset(assetId, teamName.trim(), updateToken, comment || '');
|
||||
|
||||
if (declineResult.ok) {
|
||||
let cardResponse;
|
||||
try { cardResponse = JSON.parse(declineResult.body); } catch (_) { cardResponse = declineResult.body; }
|
||||
logAudit({ userId: req.user.id, username: req.user.username, action: 'card_decline_direct', entityType: 'card_asset', entityId: assetId, details: { teamName: teamName.trim(), comment: comment || '' }, ipAddress: req.ip });
|
||||
return res.json({ success: true, cardResponse });
|
||||
}
|
||||
|
||||
let errorBody;
|
||||
try { errorBody = JSON.parse(declineResult.body); } catch (_) { errorBody = { error: declineResult.body }; }
|
||||
return res.status(declineResult.status).json(errorBody);
|
||||
} catch (err) {
|
||||
return handleCardError(err, res);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /owner/:assetId/redirect
|
||||
*
|
||||
* Directly redirect a CARD asset between teams (no queue item required).
|
||||
* Fetches the owner record for the update_token, then calls CARD redirect.
|
||||
*
|
||||
* @param {string} assetId - CARD asset identifier or IP address (path parameter)
|
||||
* @body {string} fromTeam - Current owning team (required)
|
||||
* @body {string} toTeam - Target team (required)
|
||||
* @response 200 - { success: true, cardResponse: object }
|
||||
* @response 400 - { error: string } — missing fields
|
||||
* @response 404 - { error: string } — asset not found
|
||||
* @response 502 - { error: string } — CARD API failure
|
||||
* @response 503 - { error: string, missingVars: string[] } — CARD not configured
|
||||
*/
|
||||
router.post('/owner/:assetId/redirect', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||
if (!isConfigured) {
|
||||
return res.status(503).json({ error: 'CARD API is not configured.', missingVars });
|
||||
}
|
||||
|
||||
let assetId = req.params.assetId;
|
||||
const { fromTeam, toTeam } = req.body || {};
|
||||
|
||||
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 (!/\d+-[A-Z]+$/i.test(assetId)) {
|
||||
const resolved = await resolveAssetId(assetId);
|
||||
if (!resolved) {
|
||||
return res.status(404).json({ error: `Asset not found in CARD for: ${assetId}` });
|
||||
}
|
||||
assetId = resolved;
|
||||
}
|
||||
|
||||
try {
|
||||
const ownerResult = await getOwner(assetId);
|
||||
if (!ownerResult.ok) {
|
||||
return res.status(ownerResult.status).json({ error: `Failed to fetch owner record: HTTP ${ownerResult.status}` });
|
||||
}
|
||||
|
||||
let ownerData;
|
||||
try { ownerData = JSON.parse(ownerResult.body); } catch (_) { ownerData = {}; }
|
||||
const updateToken = ownerData.owner && ownerData.owner.update_token;
|
||||
|
||||
if (!updateToken) {
|
||||
return res.status(502).json({ error: 'update_token not found in owner record.' });
|
||||
}
|
||||
|
||||
const redirectResult = await redirectAsset(assetId, fromTeam.trim(), toTeam.trim(), updateToken);
|
||||
|
||||
if (redirectResult.ok) {
|
||||
let cardResponse;
|
||||
try { cardResponse = JSON.parse(redirectResult.body); } catch (_) { cardResponse = redirectResult.body; }
|
||||
logAudit({ userId: req.user.id, username: req.user.username, action: 'card_redirect_direct', entityType: 'card_asset', entityId: assetId, details: { fromTeam: fromTeam.trim(), toTeam: toTeam.trim() }, ipAddress: req.ip });
|
||||
return res.json({ success: true, cardResponse });
|
||||
}
|
||||
|
||||
let errorBody;
|
||||
try { errorBody = JSON.parse(redirectResult.body); } catch (_) { errorBody = { error: redirectResult.body }; }
|
||||
return res.status(redirectResult.status).json(errorBody);
|
||||
} catch (err) {
|
||||
return handleCardError(err, res);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /asset-search/:hostId
|
||||
*
|
||||
* Search CARD by Ivanti Asset ID (integer). Uses CARD's v2 asset-search
|
||||
* endpoint with deep_search to find the associated CARD asset directly,
|
||||
* bypassing the IP + suffix guessing flow.
|
||||
*
|
||||
* @param {string} hostId - Ivanti Host ID (8-digit integer, from ivanti_findings.host_id)
|
||||
* @response 200 - CARD asset record
|
||||
* @response 400 - { error: string } — invalid host ID
|
||||
* @response 404 - { error: string } — asset not found
|
||||
* @response 503 - { error: string, missingVars: string[] } — CARD not configured
|
||||
*/
|
||||
router.get('/asset-search/:hostId', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||
if (!isConfigured) {
|
||||
return res.status(503).json({ error: 'CARD API is not configured.', missingVars });
|
||||
}
|
||||
|
||||
const { hostId } = req.params;
|
||||
if (!hostId || !/^\d+$/.test(hostId.trim())) {
|
||||
return res.status(400).json({ error: 'hostId must be a numeric Ivanti Asset ID.' });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await searchByIvantiHostId(hostId.trim());
|
||||
|
||||
if (result.ok) {
|
||||
let body;
|
||||
try { body = JSON.parse(result.body); } catch (_) { body = result.body; }
|
||||
|
||||
logAudit({
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'card_asset_search',
|
||||
entityType: 'card_asset',
|
||||
entityId: hostId.trim(),
|
||||
details: { search_type: 'ivanti_host_id' },
|
||||
ipAddress: req.ip,
|
||||
});
|
||||
|
||||
return res.json(body);
|
||||
}
|
||||
|
||||
if (result.status === 404) {
|
||||
return res.status(404).json({ error: `No CARD asset found for Ivanti Host ID: ${hostId}` });
|
||||
}
|
||||
|
||||
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 /enrich-batch
|
||||
*
|
||||
* Batch lookup IPs in CARD to extract Granite loader fields. Fetches team
|
||||
* assets (paginated, across confirmed, unconfirmed, and candidate
|
||||
* dispositions) and matches against the provided IPs. When no team is
|
||||
* specified, searches both NTS-AEO-STEAM and NTS-AEO-ACCESS-ENG.
|
||||
* Batch lookup IPs and/or Ivanti host IDs in CARD to extract Granite loader
|
||||
* fields. Accepts an array of IPs, an array of host_ids, or both. For IPs,
|
||||
* fetches team assets (paginated, across confirmed, unconfirmed, and
|
||||
* candidate dispositions) and matches against the provided IPs. For host_ids,
|
||||
* performs direct CARD asset-search lookups. When no team is specified,
|
||||
* searches both NTS-AEO-STEAM and NTS-AEO-ACCESS-ENG.
|
||||
* Returns enrichment results for each IP.
|
||||
*
|
||||
* @body {string[]} ips - Non-empty array of IP address strings (max 200)
|
||||
* @body {string[]} [ips] - Array of IP address strings (max 200). At least one of ips or host_ids is required.
|
||||
* @body {string[]} [host_ids] - Array of Ivanti Host ID strings (max 200). At least one of ips or host_ids is required.
|
||||
* @body {string} [team] - Team name to search assets under. Defaults to both NTS-AEO-STEAM and NTS-AEO-ACCESS-ENG if omitted.
|
||||
* @response 200 - { results: object[], enriched_count: number, not_found_count: number, total: number }
|
||||
* Each result: { ip: string, found: boolean, equip_inst_id: string|null, hostname: string|null, site_name?: string|null, mgmt_ip_asn?: string|null, responsible_team?: string|null, equipment_class?: string, equip_template?: string|null, equip_status?: string|null, serial_number?: string|null, error?: string }
|
||||
* @response 400 - { error: string } — invalid or empty ips array, or exceeds 200
|
||||
* @response 400 - { error: string } — neither ips nor host_ids provided, or exceeds 200 items
|
||||
* @response 503 - { error: string, missingVars: string[] } — CARD not configured
|
||||
*/
|
||||
router.post('/enrich-batch', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||
@@ -573,27 +959,133 @@ function createCardApiRouter() {
|
||||
return res.status(503).json({ error: 'CARD API is not configured.', missingVars });
|
||||
}
|
||||
|
||||
const { ips, team } = req.body || {};
|
||||
if (!Array.isArray(ips) || ips.length === 0) {
|
||||
return res.status(400).json({ error: 'ips must be a non-empty array of IP address strings.' });
|
||||
const { ips, host_ids, team } = req.body || {};
|
||||
|
||||
// Accept either ips array, host_ids array, or both
|
||||
const hasIps = Array.isArray(ips) && ips.length > 0;
|
||||
const hasHostIds = Array.isArray(host_ids) && host_ids.length > 0;
|
||||
|
||||
if (!hasIps && !hasHostIds) {
|
||||
return res.status(400).json({ error: 'ips or host_ids array is required.' });
|
||||
}
|
||||
if (ips.length > 200) {
|
||||
return res.status(400).json({ error: 'Maximum 200 IPs per request.' });
|
||||
if ((ips && ips.length > 200) || (host_ids && host_ids.length > 200)) {
|
||||
return res.status(400).json({ error: 'Maximum 200 items per request.' });
|
||||
}
|
||||
|
||||
// Build a set of IPs we're looking for
|
||||
const targetIps = new Set(ips.map(ip => (ip || '').trim()).filter(Boolean));
|
||||
const targetIps = new Set((ips || []).map(ip => (ip || '').trim()).filter(Boolean));
|
||||
const resultMap = {};
|
||||
|
||||
// Strategy: fetch team assets (paginated) and match against our target IPs.
|
||||
// The team assets endpoint returns the full enriched record with ncim_discovery,
|
||||
// card_flags, netops_granite_allips, etc.
|
||||
const teams = team ? [team] : ['NTS-AEO-STEAM', 'NTS-AEO-ACCESS-ENG'];
|
||||
// Direct host_id lookups — for items that have no IP but have a host_id
|
||||
if (hasHostIds) {
|
||||
for (const hostId of host_ids) {
|
||||
if (!hostId) continue;
|
||||
const key = `hostId:${hostId}`;
|
||||
try {
|
||||
const searchResult = await searchByIvantiHostId(hostId);
|
||||
if (searchResult.ok) {
|
||||
const searchData = JSON.parse(searchResult.body);
|
||||
const assets = searchData.assets || [];
|
||||
if (assets.length > 0) {
|
||||
const asset = assets[0];
|
||||
const assetIp = (asset._id || '').replace(/-[A-Z]+$/i, '');
|
||||
resultMap[key] = { ...extractGraniteFields(asset, assetIp), ip: assetIp };
|
||||
}
|
||||
}
|
||||
} catch (_) { /* skip */ }
|
||||
}
|
||||
}
|
||||
|
||||
// Fast path: look up host_ids from ivanti_findings and use asset-search
|
||||
// The asset-search endpoint returns the full enriched record (same as team assets).
|
||||
const ipsArray = [...targetIps];
|
||||
if (ipsArray.length > 0) {
|
||||
try {
|
||||
const { rows: findingRows } = await pool.query(
|
||||
`SELECT DISTINCT ON (ip_address) ip_address, host_id
|
||||
FROM ivanti_findings
|
||||
WHERE ip_address = ANY($1) AND host_id IS NOT NULL`,
|
||||
[ipsArray]
|
||||
);
|
||||
|
||||
for (const row of findingRows) {
|
||||
if (resultMap[row.ip_address]) continue;
|
||||
try {
|
||||
const searchResult = await searchByIvantiHostId(row.host_id);
|
||||
if (searchResult.ok) {
|
||||
const searchData = JSON.parse(searchResult.body);
|
||||
const assets = searchData.assets || [];
|
||||
if (assets.length > 0) {
|
||||
resultMap[row.ip_address] = extractGraniteFields(assets[0], row.ip_address);
|
||||
}
|
||||
}
|
||||
} catch (_) { /* fall through to paginated lookup */ }
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[card-api] enrich-batch: Fast path DB lookup failed:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Direct resolve path: for IPs not found via ivanti_findings, resolve
|
||||
// the asset ID via suffix guessing (CTEC first) and fetch the full asset
|
||||
// record via asset-search. This returns ncim_discovery, netops_granite, etc.
|
||||
const unresolvedIps = ipsArray.filter(ip => !resultMap[ip]);
|
||||
if (unresolvedIps.length > 0) {
|
||||
const CONCURRENCY = 5;
|
||||
for (let i = 0; i < unresolvedIps.length; i += CONCURRENCY) {
|
||||
const batch = unresolvedIps.slice(i, i + CONCURRENCY);
|
||||
await Promise.all(batch.map(async (ip) => {
|
||||
if (resultMap[ip]) return;
|
||||
try {
|
||||
const assetId = await resolveAssetId(ip, { quick: true });
|
||||
if (assetId) {
|
||||
// Use asset-search to get the full enriched record (30s timeout)
|
||||
const searchResult = await searchByAssetId(assetId, { timeout: 30000 });
|
||||
if (searchResult.ok) {
|
||||
const searchData = JSON.parse(searchResult.body);
|
||||
const assets = searchData.assets || [];
|
||||
if (assets.length > 0) {
|
||||
resultMap[ip] = extractGraniteFields(assets[0], ip);
|
||||
} else {
|
||||
// Fallback: asset-search returned empty, try owner record
|
||||
const ownerResult = await getOwner(assetId);
|
||||
if (ownerResult.ok) {
|
||||
const ownerData = JSON.parse(ownerResult.body);
|
||||
resultMap[ip] = extractGraniteFields(ownerData, ip);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// asset-search failed, fall back to owner endpoint
|
||||
const ownerResult = await getOwner(assetId);
|
||||
if (ownerResult.ok) {
|
||||
const ownerData = JSON.parse(ownerResult.body);
|
||||
resultMap[ip] = extractGraniteFields(ownerData, ip);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
const isTimeout = err.message && (err.message.includes('CARD_TIMEOUT') || err.message.includes('timed out'));
|
||||
if (isTimeout) {
|
||||
console.warn(`[card-api] enrich-batch: Direct resolve for ${ip} timed out`);
|
||||
resultMap[ip] = { _timeout: true };
|
||||
} else {
|
||||
console.warn(`[card-api] enrich-batch: Direct resolve for ${ip} failed:`, err.message);
|
||||
}
|
||||
}
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
let foundCount = Object.keys(resultMap).filter(k => !resultMap[k]._timeout).length;
|
||||
|
||||
// Fallback: paginated team-assets loop for any IPs not resolved by fast path
|
||||
// Skip if all unresolved IPs already timed out (heavier calls will also timeout)
|
||||
const teams = team ? [team] : ['NTS-AEO-STEAM', 'NTS-AEO-ACCESS-ENG', 'NTS-AEO-ACCESS-OPS'];
|
||||
const dispositions = ['confirmed', 'unconfirmed', 'candidate'];
|
||||
let foundCount = 0;
|
||||
const stillUnresolved = [...targetIps].filter(ip => !resultMap[ip]);
|
||||
|
||||
for (const teamName of teams) {
|
||||
if (foundCount >= targetIps.size) break;
|
||||
if (stillUnresolved.length === 0 || foundCount >= targetIps.size) break;
|
||||
|
||||
for (const disposition of dispositions) {
|
||||
if (foundCount >= targetIps.size) break;
|
||||
@@ -647,7 +1139,7 @@ function createCardApiRouter() {
|
||||
let enrichedCount = 0;
|
||||
let notFoundCount = 0;
|
||||
|
||||
for (const ip of ips) {
|
||||
for (const ip of (ips || [])) {
|
||||
const trimmedIp = (ip || '').trim();
|
||||
if (!trimmedIp) {
|
||||
results.push({ ip: '', found: false, equip_inst_id: null, hostname: null, error: 'Invalid IP' });
|
||||
@@ -656,15 +1148,36 @@ function createCardApiRouter() {
|
||||
}
|
||||
|
||||
if (resultMap[trimmedIp]) {
|
||||
results.push({ ip: trimmedIp, found: true, ...resultMap[trimmedIp] });
|
||||
enrichedCount++;
|
||||
if (resultMap[trimmedIp]._timeout) {
|
||||
results.push({ ip: trimmedIp, found: false, equip_inst_id: null, hostname: null, error: 'CARD lookup timed out — try again' });
|
||||
notFoundCount++;
|
||||
} else {
|
||||
results.push({ ip: trimmedIp, found: true, ...resultMap[trimmedIp] });
|
||||
enrichedCount++;
|
||||
}
|
||||
} else {
|
||||
results.push({ ip: trimmedIp, found: false, equip_inst_id: null, hostname: null, error: 'IP not found in CARD team assets' });
|
||||
results.push({ ip: trimmedIp, found: false, equip_inst_id: null, hostname: null, error: 'IP not found in CARD' });
|
||||
notFoundCount++;
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ results, enriched_count: enrichedCount, not_found_count: notFoundCount, total: ips.length });
|
||||
// Include results for host_id lookups (items without IPs)
|
||||
const hostIdResults = [];
|
||||
if (hasHostIds) {
|
||||
for (const hostId of host_ids) {
|
||||
if (!hostId) continue;
|
||||
const key = `hostId:${hostId}`;
|
||||
if (resultMap[key]) {
|
||||
hostIdResults.push({ host_id: hostId, found: true, ...resultMap[key] });
|
||||
enrichedCount++;
|
||||
} else {
|
||||
hostIdResults.push({ host_id: hostId, found: false, equip_inst_id: null, hostname: null, error: 'Host ID not found in CARD' });
|
||||
notFoundCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ results, host_id_results: hostIdResults, enriched_count: enrichedCount, not_found_count: notFoundCount, total: (ips || []).length + (host_ids || []).length });
|
||||
});
|
||||
|
||||
return router;
|
||||
|
||||
@@ -352,7 +352,7 @@ function createComplianceRouter(upload) {
|
||||
res.json({
|
||||
drift, drift_error, schema: xlsxSchema,
|
||||
diff: { new_count: diff.newCount, recurring_count: diff.recurringCount, resolved_count: diff.resolvedCount },
|
||||
tempFile: tempFilePath, filename: req.file.originalname,
|
||||
tempFile: tempFilename, filename: req.file.originalname,
|
||||
report_date: parsed.report_date, total_items: parsed.total,
|
||||
});
|
||||
} catch (err) {
|
||||
@@ -405,11 +405,13 @@ function createComplianceRouter(upload) {
|
||||
router.post('/commit', requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||
const { tempFile, filename, report_date } = req.body;
|
||||
if (!tempFile || typeof tempFile !== 'string') return res.status(400).json({ error: 'tempFile is required' });
|
||||
if (!isSafeTempPath(tempFile)) return res.status(400).json({ error: 'Invalid tempFile path' });
|
||||
if (!fs.existsSync(tempFile)) return res.status(400).json({ error: 'Preview session expired — please upload again' });
|
||||
// Reconstruct full path from basename only — never trust a client-supplied absolute path
|
||||
const resolvedTempFile = path.join(TEMP_DIR, path.basename(tempFile));
|
||||
if (!isSafeTempPath(resolvedTempFile)) return res.status(400).json({ error: 'Invalid tempFile path' });
|
||||
if (!fs.existsSync(resolvedTempFile)) return res.status(400).json({ error: 'Preview session expired — please upload again' });
|
||||
|
||||
let parsed;
|
||||
try { parsed = JSON.parse(fs.readFileSync(tempFile, 'utf8')); }
|
||||
try { parsed = JSON.parse(fs.readFileSync(resolvedTempFile, 'utf8')); }
|
||||
catch { return res.status(400).json({ error: 'Could not read preview data — please upload again' }); }
|
||||
|
||||
try {
|
||||
@@ -419,7 +421,7 @@ function createComplianceRouter(upload) {
|
||||
filename: filename || parsed.filename,
|
||||
userId: req.user?.id || null,
|
||||
});
|
||||
fs.unlink(tempFile, () => {});
|
||||
fs.unlink(resolvedTempFile, () => {});
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`SELECT id, filename, report_date, uploaded_at, new_count, resolved_count, recurring_count
|
||||
|
||||
@@ -89,6 +89,22 @@ const CLOSED_COUNT_FILTERS = [
|
||||
}
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Extract Qualys IPv6 address from hostAdditionalDetails
|
||||
// Looks for "IPv6 Address" (string) or "IPv6 Addresses" (array) fields
|
||||
// in the scanner-specific details from Qualys.
|
||||
// ---------------------------------------------------------------------------
|
||||
function extractQualysIpv6(f) {
|
||||
const details = f.hostAdditionalDetails || [];
|
||||
for (const entry of details) {
|
||||
if (entry['IPv6 Address']) return entry['IPv6 Address'];
|
||||
if (Array.isArray(entry['IPv6 Addresses']) && entry['IPv6 Addresses'].length > 0) {
|
||||
return entry['IPv6 Addresses'][0];
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Extract only the fields we need from a raw finding object
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -149,7 +165,10 @@ function extractFinding(f) {
|
||||
lastFoundOn: f.lastFoundOn || '',
|
||||
buOwnership,
|
||||
cves,
|
||||
workflow
|
||||
workflow,
|
||||
// IPv6 fallbacks for findings with no IPv4
|
||||
qualysIpv6: extractQualysIpv6(f),
|
||||
primaryIpv6: f.assetCustomAttributes?.['1550_host_6']?.[0] || '',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -186,7 +205,7 @@ async function upsertFindingsBatch(findings, state) {
|
||||
const placeholders = [];
|
||||
|
||||
batch.forEach((f, idx) => {
|
||||
const offset = idx * 18;
|
||||
const offset = idx * 20;
|
||||
values.push(
|
||||
f.id,
|
||||
f.hostId,
|
||||
@@ -205,13 +224,15 @@ async function upsertFindingsBatch(findings, state) {
|
||||
f.workflow ? f.workflow.id : null,
|
||||
f.workflow ? f.workflow.state : null,
|
||||
f.workflow ? f.workflow.type : null,
|
||||
state
|
||||
state,
|
||||
f.qualysIpv6 || null,
|
||||
f.primaryIpv6 || null
|
||||
);
|
||||
placeholders.push(
|
||||
`($${offset+1}, $${offset+2}, $${offset+3}, $${offset+4}, $${offset+5}, ` +
|
||||
`$${offset+6}, $${offset+7}, $${offset+8}, $${offset+9}, $${offset+10}, ` +
|
||||
`$${offset+11}, $${offset+12}, $${offset+13}, $${offset+14}, $${offset+15}, ` +
|
||||
`$${offset+16}, $${offset+17}, $${offset+18})`
|
||||
`$${offset+16}, $${offset+17}, $${offset+18}, $${offset+19}, $${offset+20})`
|
||||
);
|
||||
});
|
||||
|
||||
@@ -220,7 +241,8 @@ async function upsertFindingsBatch(findings, state) {
|
||||
id, host_id, title, severity, vrr_group,
|
||||
host_name, ip_address, dns, status, sla_status,
|
||||
due_date, last_found_on, bu_ownership, cves,
|
||||
workflow_id, workflow_state, workflow_type, state
|
||||
workflow_id, workflow_state, workflow_type, state,
|
||||
qualys_ipv6, primary_ipv6
|
||||
)
|
||||
VALUES ${placeholders.join(', ')}
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
@@ -241,6 +263,8 @@ async function upsertFindingsBatch(findings, state) {
|
||||
workflow_state = EXCLUDED.workflow_state,
|
||||
workflow_type = EXCLUDED.workflow_type,
|
||||
state = EXCLUDED.state,
|
||||
qualys_ipv6 = EXCLUDED.qualys_ipv6,
|
||||
primary_ipv6 = EXCLUDED.primary_ipv6,
|
||||
synced_at = NOW()
|
||||
`, values);
|
||||
}
|
||||
@@ -657,7 +681,7 @@ async function syncFPWorkflowCounts(openFindings, apiKey, clientId, skipTls) {
|
||||
const MANAGED_BUS_VALUE = process.env.IVANTI_MANAGED_BUS || 'NTS-AEO-ACCESS-ENG,NTS-AEO-STEAM';
|
||||
const EXPECTED_BUS = new Set(MANAGED_BUS_VALUE.split(',').map(b => b.trim()).filter(Boolean));
|
||||
|
||||
async function runBUDriftChecker(newlyArchivedIds, apiKey, clientId, skipTls) {
|
||||
async function runBUDriftChecker(newlyArchivedIds, apiKey, clientId, skipTls, previousBuMap) {
|
||||
const summary = { bu_reassignment: 0, severity_drift: 0, closed_on_platform: 0, decommissioned: 0 };
|
||||
|
||||
if (!newlyArchivedIds || newlyArchivedIds.length === 0) return summary;
|
||||
@@ -710,7 +734,9 @@ async function runBUDriftChecker(newlyArchivedIds, apiKey, clientId, skipTls) {
|
||||
const bu = f.assetCustomAttributes?.['1550_host_1']?.[0] || 'UNKNOWN';
|
||||
const severity = typeof f.severity === 'number' ? f.severity : parseFloat(f.severity) || 0;
|
||||
const state = f.status || f.generic_state || '';
|
||||
foundMap.set(String(f.id), { bu, severity, state });
|
||||
const title = f.title || '';
|
||||
const hostName = f.host?.hostName || f.hostName || '';
|
||||
foundMap.set(String(f.id), { bu, severity, state, title, hostName });
|
||||
}
|
||||
|
||||
page++;
|
||||
@@ -767,6 +793,26 @@ async function runBUDriftChecker(newlyArchivedIds, apiKey, clientId, skipTls) {
|
||||
} catch (err) {
|
||||
console.error(`[BU Drift Checker] Error updating transition reason for finding ${id}:`, err.message);
|
||||
}
|
||||
|
||||
// Record BU reassignment in ivanti_finding_bu_history for detail view
|
||||
if (classification === 'bu_reassignment' && found) {
|
||||
try {
|
||||
// Determine previous BU from the pre-sync snapshot (passed in from syncFindings)
|
||||
const previousBu = (previousBuMap && previousBuMap.get(id)) || '';
|
||||
|
||||
// Only record if we have a known previous BU — "UNKNOWN → X" entries
|
||||
// provide no actionable insight for asset movement tracking.
|
||||
if (previousBu && EXPECTED_BUS.has(previousBu)) {
|
||||
await pool.query(
|
||||
`INSERT INTO ivanti_finding_bu_history (finding_id, finding_title, host_name, previous_bu, new_bu, detected_at)
|
||||
VALUES ($1, $2, $3, $4, $5, NOW())`,
|
||||
[id, found.title || '', found.hostName || '', previousBu, found.bu]
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[BU Drift Checker] Error recording BU change for finding ${id}:`, err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[BU Drift Checker] Classification complete:`, summary);
|
||||
@@ -852,12 +898,14 @@ async function syncFindings() {
|
||||
|
||||
// Read previous open findings from DB for archive detection
|
||||
let previousFindings = [];
|
||||
let previousBuMap = new Map(); // id → bu_ownership snapshot BEFORE upsert
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT id, title, host_name AS "hostName", ip_address AS "ipAddress", severity, bu_ownership AS "buOwnership"
|
||||
FROM ivanti_findings WHERE state = 'open'`
|
||||
);
|
||||
previousFindings = rows;
|
||||
previousBuMap = new Map(rows.map(f => [String(f.id), f.buOwnership || '']));
|
||||
} catch (err) {
|
||||
console.error('[Ivanti Findings] Failed to read previous findings for archive detection:', err.message);
|
||||
}
|
||||
@@ -909,6 +957,21 @@ async function syncFindings() {
|
||||
console.error('[Ivanti Findings] Archive detection failed (non-fatal):', err.message);
|
||||
}
|
||||
|
||||
// Remove archived findings from ivanti_findings to prevent stale re-detection
|
||||
if (archiveResult.disappearedIds && archiveResult.disappearedIds.length > 0) {
|
||||
try {
|
||||
const { rowCount } = await pool.query(
|
||||
`DELETE FROM ivanti_findings WHERE state = 'open' AND CAST(id AS TEXT) = ANY($1::text[])`,
|
||||
[archiveResult.disappearedIds.map(String)]
|
||||
);
|
||||
if (rowCount > 0) {
|
||||
console.log(`[Ivanti Findings] Removed ${rowCount} archived findings from ivanti_findings`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Ivanti Findings] Failed to clean archived findings from table (non-fatal):', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Read previous counts BEFORE syncClosedCount updates them — needed for anomaly deltas
|
||||
let previousOpenCount = 0;
|
||||
let previousClosedCount = 0;
|
||||
@@ -928,9 +991,23 @@ async function syncFindings() {
|
||||
await syncFPWorkflowCounts(allFindings, apiKey, clientId, skipTls);
|
||||
|
||||
// Post-sync: BU drift checker for newly archived findings
|
||||
// Filter out findings that were already in ARCHIVED state from a previous sync —
|
||||
// only pass genuinely new disappearances to avoid re-classifying the same set every cycle.
|
||||
let classificationBreakdown = { bu_reassignment: 0, severity_drift: 0, closed_on_platform: 0, decommissioned: 0 };
|
||||
try {
|
||||
classificationBreakdown = await runBUDriftChecker(archiveResult.disappearedIds, apiKey, clientId, skipTls);
|
||||
let idsToCheck = archiveResult.disappearedIds || [];
|
||||
if (idsToCheck.length > 0) {
|
||||
const { rows: alreadyArchived } = await pool.query(
|
||||
`SELECT finding_id FROM ivanti_finding_archives
|
||||
WHERE current_state = 'ARCHIVED'
|
||||
AND last_transition_at < NOW() - INTERVAL '2 hours'`
|
||||
);
|
||||
const alreadyArchivedSet = new Set(alreadyArchived.map(r => String(r.finding_id)));
|
||||
const newlyArchivedOnly = idsToCheck.filter(id => !alreadyArchivedSet.has(String(id)));
|
||||
console.log(`[BU Drift Checker] ${idsToCheck.length} disappeared total, ${newlyArchivedOnly.length} genuinely new (${alreadyArchivedSet.size} already archived, skipped)`);
|
||||
idsToCheck = newlyArchivedOnly;
|
||||
}
|
||||
classificationBreakdown = await runBUDriftChecker(idsToCheck, apiKey, clientId, skipTls, previousBuMap);
|
||||
} catch (err) {
|
||||
console.error('[Ivanti Findings] BU drift checker failed (non-fatal):', err.message);
|
||||
}
|
||||
@@ -1052,6 +1129,8 @@ function createIvantiFindingsRouter(db, requireAuth) {
|
||||
cves: row.cves || [],
|
||||
workflow: row.workflow_id ? { id: row.workflow_id, state: row.workflow_state, type: row.workflow_type } : null,
|
||||
note: row.note || '',
|
||||
qualysIpv6: row.qualys_ipv6 || null,
|
||||
primaryIpv6: row.primary_ipv6 || null,
|
||||
overrides: {
|
||||
...(row.override_host_name ? { hostName: row.override_host_name } : {}),
|
||||
...(row.override_dns ? { dns: row.override_dns } : {})
|
||||
@@ -1108,6 +1187,8 @@ function createIvantiFindingsRouter(db, requireAuth) {
|
||||
cves: row.cves || [],
|
||||
workflow: row.workflow_id ? { id: row.workflow_id, state: row.workflow_state, type: row.workflow_type } : null,
|
||||
note: row.note || '',
|
||||
qualysIpv6: row.qualys_ipv6 || null,
|
||||
primaryIpv6: row.primary_ipv6 || null,
|
||||
overrides: {
|
||||
...(row.override_host_name ? { hostName: row.override_host_name } : {}),
|
||||
...(row.override_dns ? { dns: row.override_dns } : {})
|
||||
@@ -1405,18 +1486,40 @@ function createIvantiFindingsRouter(db, requireAuth) {
|
||||
/**
|
||||
* GET /api/ivanti/findings/bu-changes
|
||||
*
|
||||
* Return all BU change events from ivanti_finding_bu_history.
|
||||
* Return BU change events from ivanti_finding_bu_history.
|
||||
* Accepts optional `since` to filter by date, or `limit` to cap the result count.
|
||||
* If `since` is provided, returns all changes on or after that timestamp.
|
||||
* If neither is provided, returns the most recent 200 rows (max 500).
|
||||
*
|
||||
* @returns {Object} 200 - { changes: Array<Object> }
|
||||
* @query {string} [since] - ISO timestamp; return changes where detected_at >= this value
|
||||
* @query {string} [limit] - Maximum number of rows to return (default 200, max 500); ignored when `since` is provided
|
||||
* @returns {Object} 200 - { changes: Array<{ id, finding_id, finding_title, host_name, previous_bu, new_bu, detected_at }> }
|
||||
* @returns {Object} 500 - { error: string } on database error
|
||||
*/
|
||||
router.get('/bu-changes', async (req, res) => {
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT id, finding_id, finding_title, host_name, previous_bu, new_bu, detected_at
|
||||
FROM ivanti_finding_bu_history
|
||||
ORDER BY detected_at DESC`
|
||||
);
|
||||
const { since, limit } = req.query;
|
||||
let rows;
|
||||
if (since) {
|
||||
const result = await pool.query(
|
||||
`SELECT id, finding_id, finding_title, host_name, previous_bu, new_bu, detected_at
|
||||
FROM ivanti_finding_bu_history
|
||||
WHERE detected_at >= $1
|
||||
ORDER BY detected_at DESC`,
|
||||
[since]
|
||||
);
|
||||
rows = result.rows;
|
||||
} else {
|
||||
const maxRows = Math.min(parseInt(limit) || 200, 500);
|
||||
const result = await pool.query(
|
||||
`SELECT id, finding_id, finding_title, host_name, previous_bu, new_bu, detected_at
|
||||
FROM ivanti_finding_bu_history
|
||||
ORDER BY detected_at DESC
|
||||
LIMIT $1`,
|
||||
[maxRows]
|
||||
);
|
||||
rows = result.rows;
|
||||
}
|
||||
res.json({ changes: rows });
|
||||
} catch (err) {
|
||||
console.error('[Ivanti Findings] GET /bu-changes error:', err.message);
|
||||
|
||||
@@ -4,7 +4,7 @@ const pool = require('../db');
|
||||
const { requireAuth, requireGroup } = require('../middleware/auth');
|
||||
const logAudit = require('../helpers/auditLog');
|
||||
|
||||
const VALID_WORKFLOW_TYPES = ['FP', 'Archer', 'CARD', 'GRANITE', 'DECOM'];
|
||||
const VALID_WORKFLOW_TYPES = ['FP', 'Archer', 'CARD', 'GRANITE', 'DECOM', 'Remediate'];
|
||||
const INVENTORY_TYPES = ['CARD', 'GRANITE', 'DECOM'];
|
||||
const VALID_STATUSES = ['pending', 'complete'];
|
||||
|
||||
@@ -32,16 +32,25 @@ function createIvantiTodoQueueRouter() {
|
||||
* - ip_address {string|null}
|
||||
* - hostname {string|null}
|
||||
* - vendor {string}
|
||||
* - workflow_type {string} One of: FP, Archer, CARD, GRANITE, DECOM
|
||||
* - workflow_type {string} One of: FP, Archer, CARD, GRANITE, DECOM, Remediate
|
||||
* - status {string} pending | complete
|
||||
* - host_id {string|null} From the linked ivanti_findings record
|
||||
* - remediation_notes_count {number}
|
||||
* - created_at {string}
|
||||
* - updated_at {string}
|
||||
*/
|
||||
router.get('/', requireAuth(), async (req, res) => {
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT q.*
|
||||
`SELECT q.*, COALESCE(nc.note_count, 0) AS remediation_notes_count,
|
||||
f.host_id AS host_id
|
||||
FROM ivanti_todo_queue q
|
||||
LEFT JOIN (
|
||||
SELECT queue_item_id, COUNT(*) AS note_count
|
||||
FROM queue_remediation_notes
|
||||
GROUP BY queue_item_id
|
||||
) nc ON nc.queue_item_id = q.id
|
||||
LEFT JOIN ivanti_findings f ON f.id = q.finding_id
|
||||
WHERE q.user_id = $1
|
||||
ORDER BY q.vendor ASC, q.created_at ASC`,
|
||||
[req.user.id]
|
||||
@@ -51,7 +60,7 @@ function createIvantiTodoQueueRouter() {
|
||||
if (r.cves_json) {
|
||||
try { cves = JSON.parse(r.cves_json); } catch (e) { cves = []; }
|
||||
}
|
||||
return { ...r, cves };
|
||||
return { ...r, remediation_notes_count: parseInt(r.remediation_notes_count, 10), cves };
|
||||
});
|
||||
res.json(parsed);
|
||||
} catch (err) {
|
||||
@@ -73,8 +82,8 @@ function createIvantiTodoQueueRouter() {
|
||||
* - cves {Array<string>} Optional
|
||||
* - ip_address {string} Optional, max 64 chars
|
||||
* - hostname {string} Optional, max 255 chars
|
||||
* - workflow_type {string} Required. One of: FP, Archer, CARD, GRANITE, DECOM
|
||||
* - vendor {string} Required for FP, Archer, and DECOM workflows; max 200 chars
|
||||
* - workflow_type {string} Required. One of: FP, Archer, CARD, GRANITE, DECOM, Remediate
|
||||
* - vendor {string} Required for FP, Archer, and Remediate workflows; max 200 chars
|
||||
* @returns {Object} { items: Array<Object> } — inserted queue items with parsed `cves` array
|
||||
* @error 400 Invalid input
|
||||
* @error 500 Internal server error
|
||||
@@ -94,12 +103,12 @@ function createIvantiTodoQueueRouter() {
|
||||
}
|
||||
|
||||
if (!VALID_WORKFLOW_TYPES.includes(workflow_type)) {
|
||||
return res.status(400).json({ error: 'workflow_type must be FP, Archer, CARD, GRANITE, or DECOM.' });
|
||||
return res.status(400).json({ error: 'workflow_type must be FP, Archer, CARD, GRANITE, DECOM, or Remediate.' });
|
||||
}
|
||||
|
||||
if (!INVENTORY_TYPES.includes(workflow_type)) {
|
||||
if (!isValidVendor(vendor)) {
|
||||
return res.status(400).json({ error: 'vendor is required for FP and Archer workflows.' });
|
||||
return res.status(400).json({ error: 'vendor is required for FP, Archer, and Remediate workflows.' });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -184,8 +193,8 @@ function createIvantiTodoQueueRouter() {
|
||||
* - cves {Array<string>} Optional
|
||||
* - ip_address {string} Optional, max 64 chars
|
||||
* - hostname {string} Optional, max 255 chars
|
||||
* - vendor {string} Required for FP, Archer, and DECOM workflows; max 200 chars
|
||||
* - workflow_type {string} Required. One of: FP, Archer, CARD, GRANITE, DECOM
|
||||
* - vendor {string} Required for FP, Archer, and Remediate workflows; max 200 chars
|
||||
* - workflow_type {string} Required. One of: FP, Archer, CARD, GRANITE, DECOM, Remediate
|
||||
* @returns {Object} The created queue item with parsed `cves` array
|
||||
* @error 400 Invalid input
|
||||
* @error 500 Internal server error
|
||||
@@ -197,10 +206,10 @@ function createIvantiTodoQueueRouter() {
|
||||
return res.status(400).json({ error: 'finding_id is required.' });
|
||||
}
|
||||
if (!VALID_WORKFLOW_TYPES.includes(workflow_type)) {
|
||||
return res.status(400).json({ error: 'workflow_type must be FP, Archer, CARD, GRANITE, or DECOM.' });
|
||||
return res.status(400).json({ error: 'workflow_type must be FP, Archer, CARD, GRANITE, DECOM, or Remediate.' });
|
||||
}
|
||||
if (!INVENTORY_TYPES.includes(workflow_type) && !isValidVendor(vendor)) {
|
||||
return res.status(400).json({ error: 'vendor is required for FP and Archer workflows.' });
|
||||
return res.status(400).json({ error: 'vendor is required for FP, Archer, and Remediate workflows.' });
|
||||
}
|
||||
if (vendor !== undefined && vendor !== '' && !isValidVendor(vendor)) {
|
||||
return res.status(400).json({ error: 'vendor must be under 200 chars.' });
|
||||
@@ -242,7 +251,7 @@ function createIvantiTodoQueueRouter() {
|
||||
* @param {string} id — Queue item ID (URL parameter)
|
||||
* @body {Object} At least one field required:
|
||||
* - vendor {string} Optional, non-empty, max 200 chars
|
||||
* - workflow_type {string} Optional. One of: FP, Archer, CARD, GRANITE, DECOM
|
||||
* - workflow_type {string} Optional. One of: FP, Archer, CARD, GRANITE, DECOM, Remediate
|
||||
* - status {string} Optional. One of: pending, complete
|
||||
* @returns {Object} The updated queue item with parsed `cves` array
|
||||
* @error 400 Invalid input or no fields to update
|
||||
@@ -257,7 +266,7 @@ function createIvantiTodoQueueRouter() {
|
||||
return res.status(400).json({ error: 'vendor must be a non-empty string (max 200 chars).' });
|
||||
}
|
||||
if (workflow_type !== undefined && !VALID_WORKFLOW_TYPES.includes(workflow_type)) {
|
||||
return res.status(400).json({ error: 'workflow_type must be FP, Archer, CARD, GRANITE, or DECOM.' });
|
||||
return res.status(400).json({ error: 'workflow_type must be FP, Archer, CARD, GRANITE, DECOM, or Remediate.' });
|
||||
}
|
||||
if (status !== undefined && !VALID_STATUSES.includes(status)) {
|
||||
return res.status(400).json({ error: 'status must be pending or complete.' });
|
||||
@@ -318,16 +327,17 @@ function createIvantiTodoQueueRouter() {
|
||||
/**
|
||||
* POST /api/ivanti/todo-queue/:id/redirect
|
||||
*
|
||||
* Redirects a completed queue item to a different workflow by creating a new
|
||||
* pending queue item with the same finding data but a new workflow type/vendor.
|
||||
* Redirects a queue item to a different workflow type. If the item is pending,
|
||||
* updates workflow_type in place. If the item is complete, creates a new pending
|
||||
* queue item with the same finding data but a new workflow type/vendor.
|
||||
* Requires Admin or Standard_User group.
|
||||
*
|
||||
* @param {string} id — Queue item ID of the completed item (URL parameter)
|
||||
* @param {string} id — Queue item ID (URL parameter)
|
||||
* @body {Object}
|
||||
* - workflow_type {string} Required. One of: FP, Archer, CARD, GRANITE, DECOM
|
||||
* - vendor {string} Required for FP, Archer, and DECOM workflows; max 200 chars
|
||||
* @returns {Object} The newly created queue item with parsed `cves` array
|
||||
* @error 400 Invalid input or item not in complete status
|
||||
* - workflow_type {string} Required. One of: FP, Archer, CARD, GRANITE, DECOM, Remediate
|
||||
* - vendor {string} Required for FP, Archer, and Remediate workflows; max 200 chars
|
||||
* @returns {Object} The updated or newly created queue item with parsed `cves` array
|
||||
* @error 400 Invalid input
|
||||
* @error 404 Queue item not found
|
||||
* @error 500 Internal server error
|
||||
*/
|
||||
@@ -336,11 +346,11 @@ function createIvantiTodoQueueRouter() {
|
||||
const { workflow_type, vendor } = req.body;
|
||||
|
||||
if (!VALID_WORKFLOW_TYPES.includes(workflow_type)) {
|
||||
return res.status(400).json({ error: 'workflow_type must be FP, Archer, CARD, GRANITE, or DECOM.' });
|
||||
return res.status(400).json({ error: 'workflow_type must be FP, Archer, CARD, GRANITE, DECOM, or Remediate.' });
|
||||
}
|
||||
if (!INVENTORY_TYPES.includes(workflow_type)) {
|
||||
if (!isValidVendor(vendor)) {
|
||||
return res.status(400).json({ error: 'vendor is required for FP and Archer workflows.' });
|
||||
return res.status(400).json({ error: 'vendor is required for FP, Archer, and Remediate workflows.' });
|
||||
}
|
||||
}
|
||||
if (vendor !== undefined && vendor !== '' && typeof vendor === 'string' && vendor.trim().length > 200) {
|
||||
@@ -358,10 +368,38 @@ function createIvantiTodoQueueRouter() {
|
||||
if (!original) {
|
||||
return res.status(404).json({ error: 'Queue item not found.' });
|
||||
}
|
||||
if (original.status !== 'complete') {
|
||||
return res.status(400).json({ error: 'Only completed queue items can be redirected.' });
|
||||
|
||||
// If the item is still pending, update workflow_type in place (no duplication)
|
||||
if (original.status === 'pending') {
|
||||
const { rows } = await pool.query(
|
||||
`UPDATE ivanti_todo_queue SET workflow_type = $1, vendor = $2, updated_at = NOW()
|
||||
WHERE id = $3 AND user_id = $4 RETURNING *`,
|
||||
[workflow_type, vendorVal, id, req.user.id]
|
||||
);
|
||||
|
||||
logAudit({
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'queue_item_redirected',
|
||||
entityType: 'ivanti_todo_queue',
|
||||
entityId: String(original.id),
|
||||
details: {
|
||||
original_workflow_type: original.workflow_type,
|
||||
target_workflow_type: workflow_type,
|
||||
method: 'in_place_update',
|
||||
vendor: vendorVal,
|
||||
},
|
||||
ipAddress: req.ip,
|
||||
});
|
||||
|
||||
const result = {
|
||||
...rows[0],
|
||||
cves: rows[0].cves_json ? JSON.parse(rows[0].cves_json) : [],
|
||||
};
|
||||
return res.json(result);
|
||||
}
|
||||
|
||||
// If the item is complete, create a new pending item (legacy behavior)
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO ivanti_todo_queue
|
||||
(user_id, finding_id, finding_title, cves_json, ip_address, hostname, vendor, workflow_type)
|
||||
@@ -379,6 +417,7 @@ function createIvantiTodoQueueRouter() {
|
||||
details: {
|
||||
original_workflow_type: original.workflow_type,
|
||||
target_workflow_type: workflow_type,
|
||||
method: 'new_item_from_complete',
|
||||
new_item_id: rows[0].id,
|
||||
vendor: vendorVal,
|
||||
},
|
||||
@@ -515,6 +554,118 @@ function createIvantiTodoQueueRouter() {
|
||||
}
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Remediation Notes Routes
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* POST /api/ivanti/todo-queue/:id/notes
|
||||
*
|
||||
* Creates a remediation note for a queue item owned by the authenticated user.
|
||||
* Requires Admin or Standard_User group.
|
||||
*
|
||||
* @param {string} id — Queue item ID (URL parameter)
|
||||
* @body {Object}
|
||||
* - note_text {string} Required, 1–5000 characters, non-whitespace-only
|
||||
* @returns {Object} The created note with id, queue_item_id, user_id, username, note_text, created_at
|
||||
* @error 400 Invalid note_text
|
||||
* @error 404 Queue item not found or not owned
|
||||
* @error 500 Internal server error
|
||||
*/
|
||||
router.post('/:id/notes', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||
const { id } = req.params;
|
||||
const { note_text } = req.body;
|
||||
|
||||
// Validate queue item exists and belongs to user
|
||||
try {
|
||||
const { rows: itemRows } = await pool.query(
|
||||
'SELECT id FROM ivanti_todo_queue WHERE id = $1 AND user_id = $2',
|
||||
[id, req.user.id]
|
||||
);
|
||||
if (!itemRows[0]) {
|
||||
return res.status(404).json({ error: 'Queue item not found.' });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error checking queue item ownership:', err);
|
||||
return res.status(500).json({ error: 'Internal server error.' });
|
||||
}
|
||||
|
||||
// Validate note_text
|
||||
if (!note_text || typeof note_text !== 'string' || note_text.trim().length === 0) {
|
||||
return res.status(400).json({ error: 'Note text is required.' });
|
||||
}
|
||||
if (note_text.length > 5000) {
|
||||
return res.status(400).json({ error: 'Note text must not exceed 5000 characters.' });
|
||||
}
|
||||
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO queue_remediation_notes (queue_item_id, user_id, username, note_text)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING id, queue_item_id, user_id, username, note_text, created_at`,
|
||||
[id, req.user.id, req.user.username, note_text]
|
||||
);
|
||||
|
||||
logAudit({
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'create_remediation_note',
|
||||
entityType: 'queue_remediation_notes',
|
||||
entityId: String(rows[0].id),
|
||||
details: { queue_item_id: parseInt(id, 10) },
|
||||
ipAddress: req.ip,
|
||||
});
|
||||
|
||||
return res.status(201).json(rows[0]);
|
||||
} catch (err) {
|
||||
console.error('Error creating remediation note:', err);
|
||||
return res.status(500).json({ error: 'Internal server error.' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/ivanti/todo-queue/:id/notes
|
||||
*
|
||||
* Returns all remediation notes for a queue item owned by the authenticated user.
|
||||
* Notes are ordered by created_at descending (most recent first).
|
||||
*
|
||||
* @param {string} id — Queue item ID (URL parameter)
|
||||
* @returns {Array<Object>} Array of note objects (empty array if none)
|
||||
* @error 404 Queue item not found or not owned
|
||||
* @error 500 Internal server error
|
||||
*/
|
||||
router.get('/:id/notes', requireAuth(), async (req, res) => {
|
||||
const { id } = req.params;
|
||||
|
||||
// Validate queue item exists and belongs to user
|
||||
try {
|
||||
const { rows: itemRows } = await pool.query(
|
||||
'SELECT id FROM ivanti_todo_queue WHERE id = $1 AND user_id = $2',
|
||||
[id, req.user.id]
|
||||
);
|
||||
if (!itemRows[0]) {
|
||||
return res.status(404).json({ error: 'Queue item not found.' });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error checking queue item ownership:', err);
|
||||
return res.status(500).json({ error: 'Internal server error.' });
|
||||
}
|
||||
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT id, queue_item_id, user_id, username, note_text, created_at
|
||||
FROM queue_remediation_notes
|
||||
WHERE queue_item_id = $1
|
||||
ORDER BY created_at DESC`,
|
||||
[id]
|
||||
);
|
||||
return res.json(rows);
|
||||
} catch (err) {
|
||||
console.error('Error fetching remediation notes:', err);
|
||||
return res.status(500).json({ error: 'Internal server error.' });
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
|
||||
@@ -14,8 +14,6 @@ const SYNC_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
||||
async function syncWorkflows() {
|
||||
const apiKey = process.env.IVANTI_API_KEY;
|
||||
const clientId = process.env.IVANTI_CLIENT_ID || '1550';
|
||||
const firstName = process.env.IVANTI_FIRST_NAME || '';
|
||||
const lastName = process.env.IVANTI_LAST_NAME || '';
|
||||
const skipTls = process.env.IVANTI_SKIP_TLS === 'true';
|
||||
|
||||
if (!apiKey) {
|
||||
@@ -28,89 +26,125 @@ async function syncWorkflows() {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[Ivanti] Syncing workflows...');
|
||||
// Get all unique Ivanti identities from users table
|
||||
const { rows: ivantiUsers } = await pool.query(
|
||||
`SELECT DISTINCT ivanti_first_name, ivanti_last_name
|
||||
FROM users
|
||||
WHERE ivanti_first_name IS NOT NULL AND ivanti_last_name IS NOT NULL
|
||||
AND ivanti_first_name != '' AND ivanti_last_name != ''`
|
||||
);
|
||||
|
||||
// Fallback to env var if no users have Ivanti identity configured
|
||||
if (ivantiUsers.length === 0) {
|
||||
const envFirst = process.env.IVANTI_FIRST_NAME || '';
|
||||
const envLast = process.env.IVANTI_LAST_NAME || '';
|
||||
if (envFirst && envLast) {
|
||||
ivantiUsers.push({ ivanti_first_name: envFirst, ivanti_last_name: envLast });
|
||||
} else {
|
||||
const errMsg = 'No Ivanti identities configured — set ivanti_first_name/ivanti_last_name on user accounts';
|
||||
console.warn('[Ivanti]', errMsg);
|
||||
await pool.query(
|
||||
`UPDATE ivanti_sync_state SET sync_status='error', error_message=$1, synced_at=NOW() WHERE id=1`,
|
||||
[errMsg]
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[Ivanti] Syncing workflows for ${ivantiUsers.length} user(s)...`);
|
||||
|
||||
const urlPath = `/client/${encodeURIComponent(clientId)}/workflowBatch/search`;
|
||||
const body = {
|
||||
filters: [
|
||||
{
|
||||
field: 'created_by_last_name',
|
||||
exclusive: false,
|
||||
operator: 'IN',
|
||||
orWithPrevious: false,
|
||||
implicitFilters: [],
|
||||
value: lastName,
|
||||
caseSensitive: false
|
||||
},
|
||||
{
|
||||
field: 'created_by_first_name',
|
||||
exclusive: false,
|
||||
operator: 'IN',
|
||||
orWithPrevious: false,
|
||||
implicitFilters: [],
|
||||
value: firstName,
|
||||
caseSensitive: false
|
||||
let allWorkflows = [];
|
||||
|
||||
for (const user of ivantiUsers) {
|
||||
const body = {
|
||||
filters: [
|
||||
{
|
||||
field: 'created_by_last_name',
|
||||
exclusive: false,
|
||||
operator: 'IN',
|
||||
orWithPrevious: false,
|
||||
implicitFilters: [],
|
||||
value: user.ivanti_last_name,
|
||||
caseSensitive: false
|
||||
},
|
||||
{
|
||||
field: 'created_by_first_name',
|
||||
exclusive: false,
|
||||
operator: 'IN',
|
||||
orWithPrevious: false,
|
||||
implicitFilters: [],
|
||||
value: user.ivanti_first_name,
|
||||
caseSensitive: false
|
||||
}
|
||||
],
|
||||
projection: 'internal',
|
||||
sort: [{ field: 'created', direction: 'DESC' }],
|
||||
page: 0,
|
||||
size: 50
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await ivantiPost(urlPath, body, apiKey, skipTls);
|
||||
|
||||
if (result.status === 401) {
|
||||
throw new Error('Invalid or missing API key (401) — check IVANTI_API_KEY in .env');
|
||||
}
|
||||
if (result.status === 419) {
|
||||
throw new Error('Insufficient privileges (419) — API key lacks workflow access');
|
||||
}
|
||||
if (result.status === 429) {
|
||||
throw new Error('Rate limited (429) — will retry at next scheduled sync');
|
||||
}
|
||||
if (result.status !== 200) {
|
||||
console.error(`[Ivanti] Workflow sync for ${user.ivanti_first_name} ${user.ivanti_last_name} returned ${result.status}`);
|
||||
continue;
|
||||
}
|
||||
],
|
||||
projection: 'internal',
|
||||
sort: [{ field: 'created', direction: 'DESC' }],
|
||||
page: 0,
|
||||
size: 50
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await ivantiPost(urlPath, body, apiKey, skipTls);
|
||||
const data = JSON.parse(result.body);
|
||||
let workflows = [];
|
||||
|
||||
if (result.status === 401) {
|
||||
throw new Error('Invalid or missing API key (401) — check IVANTI_API_KEY in .env');
|
||||
if (data.page && typeof data.page.totalElements === 'number') {
|
||||
workflows = data._embedded?.workflowBatches
|
||||
|| data._embedded?.workflowBatch
|
||||
|| [];
|
||||
} else if (typeof data.total === 'number') {
|
||||
workflows = data.data || data.content || data.results || [];
|
||||
} else if (typeof data.totalElements === 'number') {
|
||||
workflows = data.content || data.data || [];
|
||||
} else if (Array.isArray(data)) {
|
||||
workflows = data;
|
||||
}
|
||||
|
||||
// Tag each workflow with the Ivanti identity that owns it
|
||||
workflows.forEach(w => {
|
||||
w._ivanti_first_name = user.ivanti_first_name;
|
||||
w._ivanti_last_name = user.ivanti_last_name;
|
||||
});
|
||||
|
||||
allWorkflows = allWorkflows.concat(workflows);
|
||||
console.log(`[Ivanti] ${user.ivanti_first_name} ${user.ivanti_last_name}: ${workflows.length} workflows`);
|
||||
} catch (err) {
|
||||
console.error(`[Ivanti] Workflow sync failed for ${user.ivanti_first_name} ${user.ivanti_last_name}:`, err.message);
|
||||
// If it's a fatal error (auth), break and report
|
||||
if (err.message.includes('401') || err.message.includes('419')) {
|
||||
await pool.query(
|
||||
`UPDATE ivanti_sync_state SET sync_status='error', error_message=$1, synced_at=NOW() WHERE id=1`,
|
||||
[err.message]
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (result.status === 419) {
|
||||
throw new Error('Insufficient privileges (419) — API key lacks workflow access');
|
||||
}
|
||||
if (result.status === 429) {
|
||||
throw new Error('Rate limited (429) — will retry at next scheduled sync');
|
||||
}
|
||||
if (result.status !== 200) {
|
||||
throw new Error(`Ivanti API returned unexpected status ${result.status}`);
|
||||
}
|
||||
|
||||
const data = JSON.parse(result.body);
|
||||
|
||||
let total = 0;
|
||||
let workflows = [];
|
||||
|
||||
if (data.page && typeof data.page.totalElements === 'number') {
|
||||
total = data.page.totalElements;
|
||||
workflows = data._embedded?.workflowBatches
|
||||
|| data._embedded?.workflowBatch
|
||||
|| [];
|
||||
} else if (typeof data.total === 'number') {
|
||||
total = data.total;
|
||||
workflows = data.data || data.content || data.results || [];
|
||||
} else if (typeof data.totalElements === 'number') {
|
||||
total = data.totalElements;
|
||||
workflows = data.content || data.data || [];
|
||||
} else if (Array.isArray(data)) {
|
||||
workflows = data;
|
||||
total = data.length;
|
||||
}
|
||||
|
||||
await pool.query(
|
||||
`UPDATE ivanti_sync_state
|
||||
SET total=$1, workflows_json=$2, synced_at=NOW(), sync_status='success', error_message=NULL
|
||||
WHERE id=1`,
|
||||
[total, JSON.stringify(workflows)]
|
||||
);
|
||||
|
||||
console.log(`[Ivanti] Sync complete — ${total} workflows`);
|
||||
} catch (err) {
|
||||
const msg = err.message || 'Unknown error';
|
||||
console.error('[Ivanti] Sync failed:', msg);
|
||||
await pool.query(
|
||||
`UPDATE ivanti_sync_state SET sync_status='error', error_message=$1, synced_at=NOW() WHERE id=1`,
|
||||
[msg]
|
||||
);
|
||||
}
|
||||
|
||||
await pool.query(
|
||||
`UPDATE ivanti_sync_state
|
||||
SET total=$1, workflows_json=$2, synced_at=NOW(), sync_status='success', error_message=NULL
|
||||
WHERE id=1`,
|
||||
[allWorkflows.length, JSON.stringify(allWorkflows)]
|
||||
);
|
||||
|
||||
console.log(`[Ivanti] Sync complete — ${allWorkflows.length} total workflows`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -174,16 +208,55 @@ function createIvantiWorkflowsRouter() {
|
||||
// All routes require authentication
|
||||
router.use(requireAuth());
|
||||
|
||||
// GET / — return cached data (fast, no external call)
|
||||
/**
|
||||
* GET /api/ivanti/workflows
|
||||
*
|
||||
* Returns cached Ivanti workflow data filtered by the logged-in user's
|
||||
* Ivanti identity (ivanti_first_name / ivanti_last_name on their account).
|
||||
* If the user has no Ivanti identity configured, returns all workflows (admin view).
|
||||
*
|
||||
* @returns {object} 200 - { total, workflows, synced_at, sync_status, error_message }
|
||||
* @returns {object} 500 - { error: 'Database error reading sync state' }
|
||||
*/
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
res.json(await readState());
|
||||
const state = await readState();
|
||||
|
||||
// Get logged-in user's Ivanti identity
|
||||
const { rows: userRows } = await pool.query(
|
||||
'SELECT ivanti_first_name, ivanti_last_name FROM users WHERE id = $1',
|
||||
[req.user.id]
|
||||
);
|
||||
const ivantiUser = userRows[0];
|
||||
|
||||
// If user has Ivanti identity, filter workflows to only theirs
|
||||
if (ivantiUser && ivantiUser.ivanti_first_name && ivantiUser.ivanti_last_name) {
|
||||
state.workflows = state.workflows.filter(w =>
|
||||
(w._ivanti_first_name || '').toLowerCase() === ivantiUser.ivanti_first_name.toLowerCase() &&
|
||||
(w._ivanti_last_name || '').toLowerCase() === ivantiUser.ivanti_last_name.toLowerCase()
|
||||
);
|
||||
state.total = state.workflows.length;
|
||||
}
|
||||
// If no Ivanti identity configured, show all (admin view)
|
||||
|
||||
res.json(state);
|
||||
} catch {
|
||||
res.status(500).json({ error: 'Database error reading sync state' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /sync — trigger an immediate sync, await completion, return fresh state
|
||||
/**
|
||||
* POST /api/ivanti/workflows/sync
|
||||
*
|
||||
* Triggers an immediate Ivanti workflow sync for all configured user identities,
|
||||
* awaits completion, and returns the updated cached state. Requires Admin or
|
||||
* Standard_User group.
|
||||
*
|
||||
* @returns {object} 200 - { total, workflows, synced_at, sync_status, error_message }
|
||||
* @returns {object} 401 - { error: 'Authentication required' }
|
||||
* @returns {object} 403 - { error: 'Insufficient permissions', required: ['Admin', 'Standard_User'], current: '...' }
|
||||
* @returns {object} 500 - { error: 'Sync ran but could not read updated state' }
|
||||
*/
|
||||
router.post('/sync', requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||
await syncWorkflows();
|
||||
try {
|
||||
|
||||
@@ -300,26 +300,40 @@ function createJiraTicketsRouter() {
|
||||
}
|
||||
|
||||
try {
|
||||
// Only sync tickets that are NOT in a completed/closed state.
|
||||
// Completed tickets are pulled on the sync where they first become completed,
|
||||
// but on subsequent syncs they are skipped to avoid unnecessary API calls.
|
||||
const { rows: tickets } = await pool.query(
|
||||
"SELECT * FROM jira_tickets WHERE ticket_key IS NOT NULL AND ticket_key != ''"
|
||||
);
|
||||
|
||||
if (tickets.length === 0) {
|
||||
return res.json({ synced: 0, failed: 0, skipped: 0, unchanged: 0, errors: [] });
|
||||
// Separate active vs completed tickets
|
||||
const CLOSED_STATUSES = ['closed', 'done', 'resolved', 'complete', 'completed', 'cancelled', 'canceled', "won't do", 'declined'];
|
||||
const isCompleted = (status) => {
|
||||
if (!status) return false;
|
||||
const lower = status.toLowerCase();
|
||||
return CLOSED_STATUSES.some(s => lower.includes(s));
|
||||
};
|
||||
|
||||
const activeTickets = tickets.filter(t => !isCompleted(t.status));
|
||||
const skippedCompleted = tickets.length - activeTickets.length;
|
||||
|
||||
if (activeTickets.length === 0) {
|
||||
return res.json({ synced: 0, failed: 0, skipped: skippedCompleted, unchanged: 0, errors: [], skippedCompleted });
|
||||
}
|
||||
|
||||
const results = { synced: 0, failed: 0, skipped: 0, unchanged: 0, errors: [] };
|
||||
|
||||
const BATCH_SIZE = 100;
|
||||
const batches = [];
|
||||
for (let i = 0; i < tickets.length; i += BATCH_SIZE) {
|
||||
batches.push(tickets.slice(i, i + BATCH_SIZE));
|
||||
for (let i = 0; i < activeTickets.length; i += BATCH_SIZE) {
|
||||
batches.push(activeTickets.slice(i, i + BATCH_SIZE));
|
||||
}
|
||||
|
||||
for (const batch of batches) {
|
||||
const rateStatus = jiraApi.getRateLimitStatus();
|
||||
if (rateStatus.burst.remaining <= 5 || rateStatus.daily.remaining <= 10) {
|
||||
const remaining = tickets.length - results.synced - results.failed - results.unchanged;
|
||||
const remaining = activeTickets.length - results.synced - results.failed - results.unchanged;
|
||||
results.skipped += remaining;
|
||||
results.errors.push('Rate limit approaching — stopped sync early to preserve budget.');
|
||||
break;
|
||||
@@ -377,11 +391,11 @@ function createJiraTicketsRouter() {
|
||||
action: 'jira_sync_all',
|
||||
entityType: 'jira_integration',
|
||||
entityId: null,
|
||||
details: results,
|
||||
details: { ...results, skippedCompleted },
|
||||
ipAddress: req.ip
|
||||
});
|
||||
|
||||
res.json(results);
|
||||
res.json({ ...results, skippedCompleted });
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
return res.status(500).json({ error: err.message || 'Internal server error.' });
|
||||
@@ -602,6 +616,8 @@ function createJiraTicketsRouter() {
|
||||
*
|
||||
* @param {string} id - Local ticket ID (path parameter)
|
||||
* @requires Admin or Standard_User group
|
||||
* @body {string} [cve_id] - CVE ID (format: CVE-YYYY-NNNN+, null/empty to clear)
|
||||
* @body {string} [vendor] - Vendor name (max 200 chars, null/empty to clear)
|
||||
* @body {string} [ticket_key] - Jira ticket key (max 50 chars)
|
||||
* @body {string} [url] - Jira ticket URL (max 500 chars, null to clear)
|
||||
* @body {string} [summary] - Summary (max 500 chars, null to clear)
|
||||
@@ -613,13 +629,23 @@ function createJiraTicketsRouter() {
|
||||
*/
|
||||
router.put('/:id', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||
const { id } = req.params;
|
||||
const { ticket_key, url, summary, status } = req.body;
|
||||
const { cve_id, vendor, ticket_key, url, summary, status } = req.body;
|
||||
|
||||
// source_context is immutable after creation (Requirement 3.6)
|
||||
if ('source_context' in req.body) {
|
||||
return res.status(400).json({ error: 'source_context is immutable after creation' });
|
||||
}
|
||||
|
||||
// Validate cve_id if provided
|
||||
if (cve_id !== undefined && cve_id !== null && cve_id !== '') {
|
||||
if (!isValidCveId(cve_id)) {
|
||||
return res.status(400).json({ error: 'CVE ID format is invalid. Expected CVE-YYYY-NNNN+.' });
|
||||
}
|
||||
}
|
||||
// Validate vendor if provided
|
||||
if (vendor !== undefined && vendor !== null && typeof vendor === 'string' && vendor.trim().length > 200) {
|
||||
return res.status(400).json({ error: 'Vendor exceeds maximum length of 200 characters.' });
|
||||
}
|
||||
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.' });
|
||||
}
|
||||
@@ -637,6 +663,8 @@ function createJiraTicketsRouter() {
|
||||
const values = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
if (cve_id !== undefined) { fields.push(`cve_id = $${paramIndex++}`); values.push(cve_id || null); }
|
||||
if (vendor !== undefined) { fields.push(`vendor = $${paramIndex++}`); values.push(vendor ? vendor.trim() : null); }
|
||||
if (ticket_key !== undefined) { fields.push(`ticket_key = $${paramIndex++}`); values.push(ticket_key.trim()); }
|
||||
if (url !== undefined) { fields.push(`url = $${paramIndex++}`); values.push(url); }
|
||||
if (summary !== undefined) { fields.push(`summary = $${paramIndex++}`); values.push(summary); }
|
||||
|
||||
@@ -40,7 +40,22 @@ function createKnowledgeBaseRouter(upload) {
|
||||
return ALLOWED_EXTENSIONS.has(ext);
|
||||
}
|
||||
|
||||
// POST /api/knowledge-base/upload
|
||||
/**
|
||||
* POST /api/knowledge-base/upload
|
||||
*
|
||||
* Uploads a new knowledge base document.
|
||||
*
|
||||
* @body {FormData} file - The file to upload (max 10MB)
|
||||
* @body {string} title - Document title (required)
|
||||
* @body {string} [description] - Optional description
|
||||
* @body {string} [category] - Category name (defaults to 'General')
|
||||
*
|
||||
* @returns {object} 200 - { success: true, id, title, slug, category }
|
||||
* @returns {object} 400 - { error } if title missing, no file, or invalid file type
|
||||
* @returns {object} 500 - { error } on server failure
|
||||
*
|
||||
* @requires Admin | Standard_User
|
||||
*/
|
||||
router.post('/upload', requireAuth(), requireGroup('Admin', 'Standard_User'), (req, res, next) => {
|
||||
upload.single('file')(req, res, (err) => {
|
||||
if (err) {
|
||||
@@ -144,7 +159,18 @@ function createKnowledgeBaseRouter(upload) {
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/knowledge-base
|
||||
/**
|
||||
* GET /api/knowledge-base
|
||||
*
|
||||
* Lists all knowledge base articles, ordered by creation date descending.
|
||||
*
|
||||
* @returns {Array<object>} 200 - Array of articles with fields:
|
||||
* id, title, slug, description, category, file_name, file_type,
|
||||
* file_size, created_at, updated_at, created_by_username
|
||||
* @returns {object} 500 - { error } on server failure
|
||||
*
|
||||
* @requires Authenticated user (any group)
|
||||
*/
|
||||
router.get('/', requireAuth(), async (req, res) => {
|
||||
try {
|
||||
const { rows } = await pool.query(`
|
||||
@@ -163,7 +189,21 @@ function createKnowledgeBaseRouter(upload) {
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/knowledge-base/:id
|
||||
/**
|
||||
* GET /api/knowledge-base/:id
|
||||
*
|
||||
* Retrieves metadata for a single knowledge base article.
|
||||
*
|
||||
* @param {number} id - Article ID (route param)
|
||||
*
|
||||
* @returns {object} 200 - Article metadata: id, title, slug, description,
|
||||
* category, file_name, file_type, file_size, created_at, updated_at,
|
||||
* created_by_username
|
||||
* @returns {object} 404 - { error: 'Article not found' }
|
||||
* @returns {object} 500 - { error } on server failure
|
||||
*
|
||||
* @requires Authenticated user (any group)
|
||||
*/
|
||||
router.get('/:id', requireAuth(), async (req, res) => {
|
||||
const { id } = req.params;
|
||||
|
||||
@@ -189,7 +229,21 @@ function createKnowledgeBaseRouter(upload) {
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/knowledge-base/:id/content
|
||||
/**
|
||||
* GET /api/knowledge-base/:id/content
|
||||
*
|
||||
* Serves the raw file content inline for rendering in the browser.
|
||||
* Sets Content-Security-Policy frame-ancestors for iframe embedding.
|
||||
* Logs a VIEW_KB_ARTICLE audit event.
|
||||
*
|
||||
* @param {number} id - Article ID (route param)
|
||||
*
|
||||
* @returns {file} 200 - Raw file content with appropriate Content-Type
|
||||
* @returns {object} 404 - { error } if article or file not found
|
||||
* @returns {object} 500 - { error } on server failure
|
||||
*
|
||||
* @requires Authenticated user (any group)
|
||||
*/
|
||||
router.get('/:id/content', requireAuth(), async (req, res) => {
|
||||
const { id } = req.params;
|
||||
|
||||
@@ -203,7 +257,12 @@ function createKnowledgeBaseRouter(upload) {
|
||||
return res.status(404).json({ error: 'Document not found' });
|
||||
}
|
||||
|
||||
if (!fs.existsSync(row.file_path)) {
|
||||
// Resolve relative paths against the backend directory
|
||||
const absoluteFilePath = path.isAbsolute(row.file_path)
|
||||
? row.file_path
|
||||
: path.resolve(path.join(__dirname, '..'), row.file_path);
|
||||
|
||||
if (!fs.existsSync(absoluteFilePath)) {
|
||||
return res.status(404).json({ error: 'File not found on disk' });
|
||||
}
|
||||
|
||||
@@ -230,14 +289,27 @@ function createKnowledgeBaseRouter(upload) {
|
||||
res.removeHeader('X-Frame-Options');
|
||||
const corsOrigins = process.env.CORS_ORIGINS ? process.env.CORS_ORIGINS.split(',').join(' ') : 'http://localhost:3000';
|
||||
res.setHeader('Content-Security-Policy', `frame-ancestors 'self' ${corsOrigins}`);
|
||||
res.sendFile(row.file_path);
|
||||
res.sendFile(absoluteFilePath);
|
||||
} catch (err) {
|
||||
console.error('Error fetching document:', err);
|
||||
res.status(500).json({ error: 'Failed to fetch document' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/knowledge-base/:id/download
|
||||
/**
|
||||
* GET /api/knowledge-base/:id/download
|
||||
*
|
||||
* Downloads the file as an attachment (Content-Disposition: attachment).
|
||||
* Logs a DOWNLOAD_KB_ARTICLE audit event.
|
||||
*
|
||||
* @param {number} id - Article ID (route param)
|
||||
*
|
||||
* @returns {file} 200 - File content with attachment disposition
|
||||
* @returns {object} 404 - { error } if article or file not found
|
||||
* @returns {object} 500 - { error } on server failure
|
||||
*
|
||||
* @requires Authenticated user (any group)
|
||||
*/
|
||||
router.get('/:id/download', requireAuth(), async (req, res) => {
|
||||
const { id } = req.params;
|
||||
|
||||
@@ -251,7 +323,12 @@ function createKnowledgeBaseRouter(upload) {
|
||||
return res.status(404).json({ error: 'Document not found' });
|
||||
}
|
||||
|
||||
if (!fs.existsSync(row.file_path)) {
|
||||
// Resolve relative paths against the backend directory
|
||||
const absoluteFilePath = path.isAbsolute(row.file_path)
|
||||
? row.file_path
|
||||
: path.resolve(path.join(__dirname, '..'), row.file_path);
|
||||
|
||||
if (!fs.existsSync(absoluteFilePath)) {
|
||||
return res.status(404).json({ error: 'File not found on disk' });
|
||||
}
|
||||
|
||||
@@ -268,14 +345,29 @@ function createKnowledgeBaseRouter(upload) {
|
||||
const safeDownloadName = row.file_name.replace(/["\r\n\\]/g, '');
|
||||
res.setHeader('Content-Type', row.file_type || 'application/octet-stream');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${safeDownloadName}"`);
|
||||
res.sendFile(row.file_path);
|
||||
res.sendFile(absoluteFilePath);
|
||||
} catch (err) {
|
||||
console.error('Error fetching document:', err);
|
||||
res.status(500).json({ error: 'Failed to fetch document' });
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/knowledge-base/:id
|
||||
/**
|
||||
* DELETE /api/knowledge-base/:id
|
||||
*
|
||||
* Deletes a knowledge base article and its associated file from disk.
|
||||
* Standard_User can only delete articles they created; Admin can delete any.
|
||||
* Logs a DELETE_KB_ARTICLE audit event.
|
||||
*
|
||||
* @param {number} id - Article ID (route param)
|
||||
*
|
||||
* @returns {object} 200 - { success: true }
|
||||
* @returns {object} 403 - { error } if Standard_User tries to delete another user's article
|
||||
* @returns {object} 404 - { error: 'Article not found' }
|
||||
* @returns {object} 500 - { error } on server failure
|
||||
*
|
||||
* @requires Admin | Standard_User
|
||||
*/
|
||||
router.delete('/:id', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||
const { id } = req.params;
|
||||
|
||||
|
||||
@@ -10,11 +10,28 @@ function createUsersRouter(requireAuth, requireGroup, logAudit) {
|
||||
// All routes require Admin group
|
||||
router.use(requireAuth(), requireGroup('Admin'));
|
||||
|
||||
// Get all users
|
||||
/**
|
||||
* GET /api/users
|
||||
*
|
||||
* Returns all user accounts ordered by creation date (newest first).
|
||||
*
|
||||
* @returns {Array<Object>} 200 - Array of user objects
|
||||
* @returns {Object} 200[].id - User ID
|
||||
* @returns {string} 200[].username - Username
|
||||
* @returns {string} 200[].email - Email address
|
||||
* @returns {string} 200[].group - Permission group (Admin, Standard_User, Leadership, Read_Only)
|
||||
* @returns {string} 200[].bu_teams - Comma-separated BU team assignments
|
||||
* @returns {Array<string>} 200[].teams - Parsed array of BU team assignments
|
||||
* @returns {boolean} 200[].is_active - Whether the account is active
|
||||
* @returns {string} 200[].created_at - ISO timestamp of account creation
|
||||
* @returns {string|null} 200[].last_login - ISO timestamp of last login
|
||||
* @returns {Object} 500 - { error: string }
|
||||
*/
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const { rows: users } = await pool.query(
|
||||
`SELECT id, username, email, user_group AS "group", bu_teams, is_active, created_at, last_login
|
||||
`SELECT id, username, email, user_group AS "group", bu_teams, is_active, created_at, last_login,
|
||||
ivanti_first_name, ivanti_last_name
|
||||
FROM users ORDER BY created_at DESC`
|
||||
);
|
||||
// Parse bu_teams into teams array for each user
|
||||
@@ -29,11 +46,21 @@ function createUsersRouter(requireAuth, requireGroup, logAudit) {
|
||||
}
|
||||
});
|
||||
|
||||
// Get single user
|
||||
/**
|
||||
* GET /api/users/:id
|
||||
*
|
||||
* Returns a single user account by ID.
|
||||
*
|
||||
* @param {string} req.params.id - User ID
|
||||
* @returns {Object} 200 - User object with parsed teams array
|
||||
* @returns {Object} 404 - { error: 'User not found' }
|
||||
* @returns {Object} 500 - { error: string }
|
||||
*/
|
||||
router.get('/:id', async (req, res) => {
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT id, username, email, user_group AS "group", bu_teams, is_active, created_at, last_login
|
||||
`SELECT id, username, email, user_group AS "group", bu_teams, is_active, created_at, last_login,
|
||||
ivanti_first_name, ivanti_last_name
|
||||
FROM users WHERE id = $1`,
|
||||
[req.params.id]
|
||||
);
|
||||
@@ -54,7 +81,21 @@ function createUsersRouter(requireAuth, requireGroup, logAudit) {
|
||||
}
|
||||
});
|
||||
|
||||
// Create new user
|
||||
/**
|
||||
* POST /api/users
|
||||
*
|
||||
* Creates a new user account.
|
||||
*
|
||||
* @body {string} username - Required. Unique username
|
||||
* @body {string} email - Required. Unique email address
|
||||
* @body {string} password - Required. Plain-text password (hashed before storage)
|
||||
* @body {string} [group='Read_Only'] - Permission group (Admin, Standard_User, Leadership, Read_Only)
|
||||
* @body {string} [bu_teams=''] - Comma-separated BU team assignments (STEAM, ACCESS-ENG, ACCESS-OPS, INTELDEV)
|
||||
* @returns {Object} 201 - { message: string, user: { id, username, email, group, bu_teams, teams } }
|
||||
* @returns {Object} 400 - { error: string } if required fields missing or invalid group/teams
|
||||
* @returns {Object} 409 - { error: string } if username or email already exists
|
||||
* @returns {Object} 500 - { error: string }
|
||||
*/
|
||||
router.post('/', async (req, res) => {
|
||||
const { username, email, password, group, bu_teams } = req.body;
|
||||
const VALID_GROUPS = ['Admin', 'Standard_User', 'Leadership', 'Read_Only'];
|
||||
@@ -120,9 +161,28 @@ function createUsersRouter(requireAuth, requireGroup, logAudit) {
|
||||
}
|
||||
});
|
||||
|
||||
// Update user
|
||||
/**
|
||||
* PATCH /api/users/:id
|
||||
*
|
||||
* Updates one or more fields on an existing user account. Only provided fields are modified.
|
||||
*
|
||||
* @param {string} req.params.id - User ID to update
|
||||
* @body {string} [username] - New username
|
||||
* @body {string} [email] - New email address
|
||||
* @body {string} [password] - New plain-text password (hashed before storage)
|
||||
* @body {string} [group] - New permission group (Admin, Standard_User, Leadership, Read_Only)
|
||||
* @body {boolean} [is_active] - Whether the account is active (deactivation deletes sessions)
|
||||
* @body {string} [bu_teams] - Comma-separated BU team assignments (STEAM, ACCESS-ENG, ACCESS-OPS, INTELDEV)
|
||||
* @body {string} [ivanti_first_name] - Ivanti first name for finding correlation
|
||||
* @body {string} [ivanti_last_name] - Ivanti last name for finding correlation
|
||||
* @returns {Object} 200 - { message: 'User updated successfully' }
|
||||
* @returns {Object} 400 - { error: string } if invalid group, self-demotion, self-deactivation, invalid teams, or no fields provided
|
||||
* @returns {Object} 404 - { error: 'User not found' }
|
||||
* @returns {Object} 409 - { error: string } if username or email already exists
|
||||
* @returns {Object} 500 - { error: string }
|
||||
*/
|
||||
router.patch('/:id', async (req, res) => {
|
||||
const { username, email, password, group, is_active, bu_teams } = req.body;
|
||||
const { username, email, password, group, is_active, bu_teams, ivanti_first_name, ivanti_last_name } = req.body;
|
||||
const VALID_GROUPS = ['Admin', 'Standard_User', 'Leadership', 'Read_Only'];
|
||||
const userId = req.params.id;
|
||||
|
||||
@@ -193,6 +253,14 @@ function createUsersRouter(requireAuth, requireGroup, logAudit) {
|
||||
updates.push(`bu_teams = $${paramIndex++}`);
|
||||
values.push(bu_teams);
|
||||
}
|
||||
if (typeof ivanti_first_name === 'string') {
|
||||
updates.push(`ivanti_first_name = $${paramIndex++}`);
|
||||
values.push(ivanti_first_name.trim() || null);
|
||||
}
|
||||
if (typeof ivanti_last_name === 'string') {
|
||||
updates.push(`ivanti_last_name = $${paramIndex++}`);
|
||||
values.push(ivanti_last_name.trim() || null);
|
||||
}
|
||||
|
||||
if (updates.length === 0) {
|
||||
return res.status(400).json({ error: 'No fields to update' });
|
||||
@@ -212,6 +280,8 @@ function createUsersRouter(requireAuth, requireGroup, logAudit) {
|
||||
if (typeof is_active === 'boolean') updatedFields.is_active = is_active;
|
||||
if (password) updatedFields.password_changed = true;
|
||||
if (typeof bu_teams === 'string') updatedFields.bu_teams = bu_teams;
|
||||
if (typeof ivanti_first_name === 'string') updatedFields.ivanti_first_name = ivanti_first_name.trim() || null;
|
||||
if (typeof ivanti_last_name === 'string') updatedFields.ivanti_last_name = ivanti_last_name.trim() || null;
|
||||
|
||||
logAudit({
|
||||
userId: req.user.id,
|
||||
@@ -270,7 +340,17 @@ function createUsersRouter(requireAuth, requireGroup, logAudit) {
|
||||
}
|
||||
});
|
||||
|
||||
// Delete user
|
||||
/**
|
||||
* DELETE /api/users/:id
|
||||
*
|
||||
* Deletes a user account and their associated sessions. Cannot delete your own account.
|
||||
*
|
||||
* @param {string} req.params.id - User ID to delete
|
||||
* @returns {Object} 200 - { message: 'User deleted successfully' }
|
||||
* @returns {Object} 400 - { error: string } if attempting self-deletion
|
||||
* @returns {Object} 404 - { error: 'User not found' }
|
||||
* @returns {Object} 500 - { error: string }
|
||||
*/
|
||||
router.delete('/:id', async (req, res) => {
|
||||
const userId = req.params.id;
|
||||
|
||||
|
||||
@@ -186,8 +186,9 @@ function isSafeTempPath(filePath) {
|
||||
function createVCLMultiVerticalRouter(upload) {
|
||||
const router = express.Router();
|
||||
|
||||
// All routes require authentication
|
||||
// All routes require authentication + Leadership or Admin group
|
||||
router.use(requireAuth());
|
||||
router.use(requireGroup('Admin', 'Leadership'));
|
||||
|
||||
/**
|
||||
* POST /preview
|
||||
@@ -280,7 +281,7 @@ function createVCLMultiVerticalRouter(upload) {
|
||||
total_items: xlsxData.total || xlsxData.items.length,
|
||||
summary_entries: (xlsxData.summary && xlsxData.summary.entries) ? xlsxData.summary.entries.length : 0,
|
||||
diff: { new_count: diff.newCount, recurring_count: diff.recurringCount, resolved_count: diff.resolvedCount },
|
||||
tempFile: tempFilePath,
|
||||
tempFile: tempFilename,
|
||||
});
|
||||
} catch (parseErr) {
|
||||
unrecognized.push({ filename: file.originalname, error: parseErr.message });
|
||||
@@ -334,12 +335,15 @@ function createVCLMultiVerticalRouter(upload) {
|
||||
|
||||
// Validate all temp files exist before starting transaction
|
||||
for (const file of files) {
|
||||
if (!file.tempFile || !isSafeTempPath(file.tempFile)) {
|
||||
const resolvedPath = path.join(TEMP_DIR, path.basename(file.tempFile || ''));
|
||||
if (!file.tempFile || !isSafeTempPath(resolvedPath)) {
|
||||
return res.status(400).json({ error: `Invalid tempFile path for ${file.vertical || 'unknown'}` });
|
||||
}
|
||||
if (!fs.existsSync(file.tempFile)) {
|
||||
if (!fs.existsSync(resolvedPath)) {
|
||||
return res.status(400).json({ error: `Preview session expired for ${file.vertical || 'unknown'} — please upload again` });
|
||||
}
|
||||
// Store resolved path for use in the transaction
|
||||
file._resolvedTempFile = resolvedPath;
|
||||
}
|
||||
|
||||
const client = await pool.connect();
|
||||
@@ -349,7 +353,7 @@ function createVCLMultiVerticalRouter(upload) {
|
||||
const committed = [];
|
||||
for (const file of files) {
|
||||
let parsed;
|
||||
try { parsed = JSON.parse(fs.readFileSync(file.tempFile, 'utf8')); }
|
||||
try { parsed = JSON.parse(fs.readFileSync(file._resolvedTempFile, 'utf8')); }
|
||||
catch { throw new Error(`Could not read preview data for ${file.vertical}`); }
|
||||
|
||||
const result = await persistMultiVerticalUpload({
|
||||
@@ -374,7 +378,7 @@ function createVCLMultiVerticalRouter(upload) {
|
||||
|
||||
// Clean up temp files
|
||||
for (const file of files) {
|
||||
fs.unlink(file.tempFile, () => {});
|
||||
fs.unlink(file._resolvedTempFile, () => {});
|
||||
}
|
||||
|
||||
// Audit log
|
||||
@@ -401,7 +405,7 @@ function createVCLMultiVerticalRouter(upload) {
|
||||
await client.query('ROLLBACK');
|
||||
// Clean up temp files on failure too
|
||||
for (const file of files) {
|
||||
if (file.tempFile) fs.unlink(file.tempFile, () => {});
|
||||
if (file._resolvedTempFile) fs.unlink(file._resolvedTempFile, () => {});
|
||||
}
|
||||
console.error('[VCL Multi] Commit error:', err.message);
|
||||
res.status(500).json({ error: 'Failed to commit batch: ' + err.message });
|
||||
@@ -1241,6 +1245,144 @@ function createVCLMultiVerticalRouter(upload) {
|
||||
}
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// GET /metric/:id/stats — Per-metric summary statistics + donut breakdown
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* GET /metric/:id/stats
|
||||
* Returns summary statistics and donut breakdown for a single metric,
|
||||
* aggregated across all verticals using only ALL: rollup rows.
|
||||
*
|
||||
* @method GET
|
||||
* @route /metric/:id/stats
|
||||
* @param {string} id — metric identifier (e.g., "2.3.6i")
|
||||
*
|
||||
* @response 200
|
||||
* {
|
||||
* metric_id: string,
|
||||
* metric_desc: string,
|
||||
* category: string,
|
||||
* total_devices: number,
|
||||
* compliant: number,
|
||||
* non_compliant: number,
|
||||
* compliance_pct: number,
|
||||
* target: number,
|
||||
* donut: {
|
||||
* blocked: { count: number, pct: number },
|
||||
* in_progress: { count: number, pct: number }
|
||||
* }
|
||||
* }
|
||||
* @response 400 { error: string } — metric ID exceeds 50 characters
|
||||
* @response 500 { error: string }
|
||||
*/
|
||||
router.get('/metric/:id/stats', async (req, res) => {
|
||||
const metricId = req.params.id;
|
||||
if (!metricId || metricId.length > 50) {
|
||||
return res.status(400).json({ error: 'Invalid metric ID' });
|
||||
}
|
||||
|
||||
try {
|
||||
// Get latest upload ID per vertical (same pattern as existing /stats endpoint)
|
||||
const { rows: latestUploads } = await pool.query(`
|
||||
SELECT DISTINCT ON (vertical) id, vertical
|
||||
FROM compliance_uploads
|
||||
WHERE vertical IS NOT NULL
|
||||
ORDER BY vertical, id DESC
|
||||
`);
|
||||
|
||||
if (latestUploads.length === 0) {
|
||||
return res.json({
|
||||
metric_id: metricId,
|
||||
metric_desc: '',
|
||||
category: '',
|
||||
total_devices: 0,
|
||||
compliant: 0,
|
||||
non_compliant: 0,
|
||||
compliance_pct: 0,
|
||||
target: 0,
|
||||
donut: {
|
||||
blocked: { count: 0, pct: 0 },
|
||||
in_progress: { count: 0, pct: 0 },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const latestUploadIds = latestUploads.map(u => u.id);
|
||||
|
||||
// Query vcl_multi_vertical_summary for this metric, ALL: rollup rows only
|
||||
const { rows: summaryRows } = await pool.query(`
|
||||
SELECT metric_desc, category, total, compliant, non_compliant, target
|
||||
FROM vcl_multi_vertical_summary
|
||||
WHERE upload_id = ANY($1) AND metric_id = $2 AND team LIKE 'ALL:%'
|
||||
`, [latestUploadIds, metricId]);
|
||||
|
||||
// If metric not found, return zero-filled response (HTTP 200, not 404)
|
||||
if (summaryRows.length === 0) {
|
||||
return res.json({
|
||||
metric_id: metricId,
|
||||
metric_desc: '',
|
||||
category: '',
|
||||
total_devices: 0,
|
||||
compliant: 0,
|
||||
non_compliant: 0,
|
||||
compliance_pct: 0,
|
||||
target: 0,
|
||||
donut: {
|
||||
blocked: { count: 0, pct: 0 },
|
||||
in_progress: { count: 0, pct: 0 },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Aggregate across verticals
|
||||
let totalDevices = 0, totalCompliant = 0, totalNonCompliant = 0;
|
||||
let targetSum = 0, targetCount = 0;
|
||||
let metricDesc = '';
|
||||
let category = '';
|
||||
|
||||
for (const row of summaryRows) {
|
||||
totalDevices += row.total || 0;
|
||||
totalCompliant += row.compliant || 0;
|
||||
totalNonCompliant += row.non_compliant || 0;
|
||||
targetSum += parseFloat(row.target) || 0;
|
||||
targetCount++;
|
||||
// Derive metric_desc and category from first non-empty value
|
||||
if (!metricDesc && row.metric_desc) metricDesc = row.metric_desc;
|
||||
if (!category && row.category) category = row.category;
|
||||
}
|
||||
|
||||
const target = targetCount > 0 ? Math.round((targetSum / targetCount) * 100) / 100 : 0;
|
||||
const compliancePct = totalDevices > 0 ? Math.round((totalCompliant / totalDevices) * 100) : 0;
|
||||
|
||||
// Donut breakdown: query compliance_items for this metric
|
||||
const { rows: donutRows } = await pool.query(`
|
||||
SELECT hostname, MAX(resolution_date) AS resolution_date
|
||||
FROM compliance_items
|
||||
WHERE metric_id = $1 AND status = 'active' AND vertical IS NOT NULL
|
||||
GROUP BY hostname
|
||||
`, [metricId]);
|
||||
|
||||
const donutItems = donutRows.map(r => ({ resolution_date: r.resolution_date }));
|
||||
const donut = categorizeNonCompliant(donutItems);
|
||||
|
||||
res.json({
|
||||
metric_id: metricId,
|
||||
metric_desc: metricDesc,
|
||||
category: category,
|
||||
total_devices: totalDevices,
|
||||
compliant: totalCompliant,
|
||||
non_compliant: totalNonCompliant,
|
||||
compliance_pct: compliancePct,
|
||||
target: target,
|
||||
donut,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[VCL Multi] GET /metric/:id/stats error:', err.message);
|
||||
res.status(500).json({ error: 'Database error' });
|
||||
}
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// GET /metric/:id/verticals — Per-vertical breakdown for a specific metric
|
||||
// -----------------------------------------------------------------------
|
||||
@@ -1517,6 +1659,159 @@ function createVCLMultiVerticalRouter(upload) {
|
||||
}
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// GET /metric/:id/trend — Per-metric monthly compliance trend with forecast
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* GET /metric/:id/trend
|
||||
* Returns monthly compliance history with linear regression forecast for a
|
||||
* single metric. Groups by report month from compliance_uploads, aggregating
|
||||
* only rollup rows (team LIKE 'ALL:%').
|
||||
*
|
||||
* @method GET
|
||||
* @route /metric/:id/trend
|
||||
* @param {string} id — metric identifier (e.g., "2.3.6i")
|
||||
*
|
||||
* @response 200
|
||||
* {
|
||||
* months: Array<{
|
||||
* month: string,
|
||||
* compliant_count: number|null,
|
||||
* non_compliant_count: number|null,
|
||||
* total_devices: number|null,
|
||||
* compliance_pct: number|null,
|
||||
* forecast_pct: number|null,
|
||||
* target: number
|
||||
* }>
|
||||
* }
|
||||
* @response 400 { error: string } — metric ID exceeds 50 characters
|
||||
* @response 500 { error: string }
|
||||
*/
|
||||
router.get('/metric/:id/trend', async (req, res) => {
|
||||
const metricId = req.params.id;
|
||||
if (!metricId || metricId.length > 50) {
|
||||
return res.status(400).json({ error: 'Invalid metric ID' });
|
||||
}
|
||||
|
||||
try {
|
||||
// Get the metric's target value from the latest uploads (same pattern as stats endpoint)
|
||||
const { rows: latestUploads } = await pool.query(`
|
||||
SELECT DISTINCT ON (vertical) id, vertical
|
||||
FROM compliance_uploads
|
||||
WHERE vertical IS NOT NULL
|
||||
ORDER BY vertical, id DESC
|
||||
`);
|
||||
|
||||
if (latestUploads.length === 0) {
|
||||
return res.json({ months: [] });
|
||||
}
|
||||
|
||||
const latestUploadIds = latestUploads.map(u => u.id);
|
||||
|
||||
// Get target from the latest uploads for this metric
|
||||
const { rows: targetRows } = await pool.query(`
|
||||
SELECT ROUND(AVG(target::numeric), 2) AS target
|
||||
FROM vcl_multi_vertical_summary
|
||||
WHERE upload_id = ANY($1) AND metric_id = $2 AND team LIKE 'ALL:%'
|
||||
`, [latestUploadIds, metricId]);
|
||||
|
||||
const metricTarget = targetRows.length > 0 && targetRows[0].target !== null
|
||||
? parseFloat(targetRows[0].target)
|
||||
: 0;
|
||||
|
||||
// Join vcl_multi_vertical_summary with compliance_uploads to group by report month.
|
||||
// Use only the latest upload per vertical per month to avoid double-counting
|
||||
// when a vertical is re-uploaded multiple times in the same month.
|
||||
const { rows: monthlyData } = await pool.query(`
|
||||
WITH latest_per_vertical_month AS (
|
||||
SELECT DISTINCT ON (cu.vertical, COALESCE(SUBSTRING(cu.report_date FROM 1 FOR 7), TO_CHAR(cu.uploaded_at, 'YYYY-MM')))
|
||||
cu.id AS upload_id,
|
||||
cu.vertical,
|
||||
COALESCE(SUBSTRING(cu.report_date FROM 1 FOR 7), TO_CHAR(cu.uploaded_at, 'YYYY-MM')) AS report_month
|
||||
FROM compliance_uploads cu
|
||||
WHERE cu.vertical IS NOT NULL
|
||||
ORDER BY cu.vertical, COALESCE(SUBSTRING(cu.report_date FROM 1 FOR 7), TO_CHAR(cu.uploaded_at, 'YYYY-MM')), cu.id DESC
|
||||
)
|
||||
SELECT lvm.report_month,
|
||||
SUM(s.compliant)::int AS compliant,
|
||||
SUM(s.non_compliant)::int AS non_compliant,
|
||||
SUM(s.total)::int AS total
|
||||
FROM vcl_multi_vertical_summary s
|
||||
JOIN latest_per_vertical_month lvm ON s.upload_id = lvm.upload_id
|
||||
WHERE s.metric_id = $1 AND s.team LIKE 'ALL:%'
|
||||
GROUP BY lvm.report_month
|
||||
ORDER BY lvm.report_month ASC
|
||||
`, [metricId]);
|
||||
|
||||
// If metric not found in any historical data, return empty months
|
||||
if (monthlyData.length === 0) {
|
||||
return res.json({ months: [] });
|
||||
}
|
||||
|
||||
// Build historical months with compliance_pct
|
||||
const months = monthlyData.map(row => {
|
||||
const pct = row.total > 0
|
||||
? Math.round((row.compliant / row.total) * 100 * 10) / 10
|
||||
: 0;
|
||||
return {
|
||||
month: row.report_month,
|
||||
compliant_count: row.compliant,
|
||||
non_compliant_count: row.non_compliant,
|
||||
total_devices: row.total,
|
||||
compliance_pct: pct,
|
||||
forecast_pct: null,
|
||||
target: metricTarget,
|
||||
};
|
||||
});
|
||||
|
||||
// Compute forecast using linear regression if we have 3+ months
|
||||
if (months.length >= 3) {
|
||||
const n = months.length;
|
||||
let sumX = 0, sumY = 0, sumXY = 0, sumX2 = 0;
|
||||
for (let i = 0; i < n; i++) {
|
||||
sumX += i;
|
||||
sumY += months[i].compliance_pct;
|
||||
sumXY += i * months[i].compliance_pct;
|
||||
sumX2 += i * i;
|
||||
}
|
||||
const slope = (n * sumXY - sumX * sumY) / (n * sumX2 - sumX * sumX);
|
||||
const intercept = (sumY - slope * sumX) / n;
|
||||
|
||||
// Set forecast_pct on the last historical month as the transition point
|
||||
months[n - 1].forecast_pct = months[n - 1].compliance_pct;
|
||||
|
||||
// Project forward 3 months
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const futureIdx = n + i;
|
||||
const forecastPct = Math.min(100.0, Math.max(0.0,
|
||||
Math.round((slope * futureIdx + intercept) * 10) / 10
|
||||
));
|
||||
|
||||
const lastMonth = months[months.length - 1].month;
|
||||
const [year, mon] = lastMonth.split('-').map(Number);
|
||||
const futureDate = new Date(year, mon - 1 + 1, 1);
|
||||
const futureMonth = `${futureDate.getFullYear()}-${String(futureDate.getMonth() + 1).padStart(2, '0')}`;
|
||||
|
||||
months.push({
|
||||
month: futureMonth,
|
||||
compliant_count: null,
|
||||
non_compliant_count: null,
|
||||
total_devices: null,
|
||||
compliance_pct: null,
|
||||
forecast_pct: forecastPct,
|
||||
target: metricTarget,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ months });
|
||||
} catch (err) {
|
||||
console.error('[VCL Multi] GET /metric/:id/trend error:', err.message);
|
||||
res.status(500).json({ error: 'Database error' });
|
||||
}
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// GET /metric/:metricId/forecast-burndown — Per-metric forecast burndown
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
34
backend/scripts/check-host-fields.js
Normal file
34
backend/scripts/check-host-fields.js
Normal file
@@ -0,0 +1,34 @@
|
||||
#!/usr/bin/env node
|
||||
// Temporary diagnostic script — fetch a specific finding and dump host fields
|
||||
require('dotenv').config();
|
||||
const { ivantiPost } = require('../helpers/ivantiApi');
|
||||
|
||||
const apiKey = process.env.IVANTI_API_KEY;
|
||||
const clientId = process.env.IVANTI_CLIENT_ID || '1550';
|
||||
const skipTls = process.env.IVANTI_SKIP_TLS === 'true';
|
||||
|
||||
const findingId = process.argv[2] || '2814870699';
|
||||
|
||||
const urlPath = `/client/${encodeURIComponent(clientId)}/hostFinding/search`;
|
||||
const body = {
|
||||
filters: [
|
||||
{ field: 'id', exclusive: false, operator: 'EXACT', orWithPrevious: false, implicitFilters: [], value: findingId, caseSensitive: false }
|
||||
],
|
||||
projection: 'internal',
|
||||
sort: [{ field: 'severity', direction: 'ASC' }],
|
||||
page: 0,
|
||||
size: 1
|
||||
};
|
||||
|
||||
ivantiPost(urlPath, body, apiKey, skipTls).then(r => {
|
||||
const data = JSON.parse(r.body);
|
||||
const finding = (data._embedded && data._embedded.hostFindings || [])[0];
|
||||
if (!finding) { console.log('Finding not found'); process.exit(0); }
|
||||
|
||||
console.log('=== host object ===');
|
||||
console.log(JSON.stringify(finding.host, null, 2));
|
||||
console.log('');
|
||||
console.log('=== hostAdditionalDetails ===');
|
||||
console.log(JSON.stringify(finding.hostAdditionalDetails, null, 2));
|
||||
process.exit(0);
|
||||
}).catch(e => { console.error(e.message); process.exit(1); });
|
||||
@@ -138,7 +138,7 @@ app.use(express.json({
|
||||
type: 'application/json'
|
||||
}));
|
||||
app.use(cookieParser());
|
||||
app.use('/uploads', express.static('uploads', {
|
||||
app.use('/uploads', requireAuth(), express.static('uploads', {
|
||||
dotfiles: 'deny',
|
||||
index: false
|
||||
}));
|
||||
@@ -196,6 +196,13 @@ const upload = multer({
|
||||
limits: { fileSize: 10 * 1024 * 1024 } // 10MB limit
|
||||
});
|
||||
|
||||
// Separate multer instance for compliance xlsx uploads — these can be 75MB+ for large verticals
|
||||
const complianceUpload = multer({
|
||||
storage: storage,
|
||||
fileFilter: fileFilter,
|
||||
limits: { fileSize: 100 * 1024 * 1024 } // 100MB limit for compliance spreadsheets
|
||||
});
|
||||
|
||||
// Knowledge base routes (editor/admin for upload/delete, all authenticated for view)
|
||||
app.use('/api/knowledge-base', createKnowledgeBaseRouter(upload));
|
||||
|
||||
@@ -223,10 +230,10 @@ app.use('/api/ivanti/fp-workflow', createIvantiFpWorkflowRouter());
|
||||
|
||||
// VCL multi-vertical routes — cross-organizational compliance reporting
|
||||
// Must be mounted BEFORE the general compliance router since both share the /api/compliance prefix
|
||||
app.use('/api/compliance/vcl-multi', createVCLMultiVerticalRouter(upload));
|
||||
app.use('/api/compliance/vcl-multi', createVCLMultiVerticalRouter(complianceUpload));
|
||||
|
||||
// AEO compliance routes — xlsx upload, non-compliant item tracking, notes
|
||||
app.use('/api/compliance', createComplianceRouter(upload));
|
||||
app.use('/api/compliance', createComplianceRouter(complianceUpload));
|
||||
|
||||
// Atlas InfoSec action plan routes — proxy CRUD to Atlas API, local cache for badges
|
||||
app.use('/api/atlas', createAtlasRouter());
|
||||
@@ -1196,8 +1203,30 @@ if (fs.existsSync(frontendBuild)) {
|
||||
});
|
||||
}
|
||||
|
||||
// Start server
|
||||
app.listen(PORT, () => {
|
||||
console.log(`CVE API server running on http://${API_HOST}:${PORT}`);
|
||||
console.log(`CORS origins: ${CORS_ORIGINS.join(', ')}`);
|
||||
});
|
||||
// Start server — use HTTPS if TLS cert/key are available, otherwise plain HTTP
|
||||
const TLS_CERT = process.env.TLS_CERT || path.join(__dirname, 'certs', 'cert.pem');
|
||||
const TLS_KEY = process.env.TLS_KEY || path.join(__dirname, 'certs', 'key.pem');
|
||||
const TLS_ENABLED = process.env.TLS_ENABLED !== 'false' && fs.existsSync(TLS_CERT) && fs.existsSync(TLS_KEY);
|
||||
|
||||
if (TLS_ENABLED) {
|
||||
const https = require('https');
|
||||
const httpsOptions = {
|
||||
cert: fs.readFileSync(TLS_CERT),
|
||||
key: fs.readFileSync(TLS_KEY),
|
||||
};
|
||||
https.createServer(httpsOptions, app).listen(PORT, () => {
|
||||
console.log(`CVE API server running on https://${API_HOST}:${PORT}`);
|
||||
console.log(`TLS: enabled (cert: ${TLS_CERT})`);
|
||||
console.log(`CORS origins: ${CORS_ORIGINS.join(', ')}`);
|
||||
});
|
||||
} else {
|
||||
app.listen(PORT, () => {
|
||||
console.log(`CVE API server running on http://${API_HOST}:${PORT}`);
|
||||
if (!fs.existsSync(TLS_CERT) || !fs.existsSync(TLS_KEY)) {
|
||||
console.log('TLS: disabled (no certs found in backend/certs/)');
|
||||
} else {
|
||||
console.log('TLS: disabled (TLS_ENABLED=false)');
|
||||
}
|
||||
console.log(`CORS origins: ${CORS_ORIGINS.join(', ')}`);
|
||||
});
|
||||
}
|
||||
|
||||
354
docs/architecture/ad-saml-integration.md
Normal file
354
docs/architecture/ad-saml-integration.md
Normal file
@@ -0,0 +1,354 @@
|
||||
# AD/SAML Integration Architecture
|
||||
|
||||
## Overview
|
||||
|
||||
This document describes the architecture for integrating Active Directory (AD) authentication via SAML 2.0 into the STEAM Security Dashboard. The integration adds Single Sign-On (SSO) as the primary authentication method while retaining local password login as a break-glass fallback for administrators. AD group memberships drive automatic permission assignment and BU team scoping through a configurable mapping layer.
|
||||
|
||||
---
|
||||
|
||||
## Authentication Model
|
||||
|
||||
The dashboard supports two authentication paths simultaneously:
|
||||
|
||||
| Path | Users | Mechanism | Session |
|
||||
|---|---|---|---|
|
||||
| Local | Break-glass admins, service accounts | Username + bcrypt password | Cookie-based, PostgreSQL sessions table |
|
||||
| SAML SSO | All AD users | SP-initiated SAML 2.0 via AD FS | Same cookie-based session (identical to local) |
|
||||
|
||||
Both paths produce the same session artifact — an httpOnly cookie containing a `session_id` that maps to a row in the `sessions` table. Downstream middleware (`requireAuth`, `requireGroup`) is unaware of how the session was created.
|
||||
|
||||
---
|
||||
|
||||
## SAML 2.0 Authentication Flow
|
||||
|
||||
### SP-Initiated Login (Success Path)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant B as Browser
|
||||
participant SP as Dashboard (SP)
|
||||
participant IdP as AD FS (IdP)
|
||||
participant DB as PostgreSQL
|
||||
|
||||
B->>SP: GET /api/auth/saml/login
|
||||
SP->>SP: Generate AuthnRequest XML
|
||||
SP->>B: HTTP 302 Redirect to IdP SSO URL (with AuthnRequest)
|
||||
B->>IdP: Follow redirect (user sees AD FS login page)
|
||||
IdP->>IdP: Authenticate user against AD
|
||||
IdP->>IdP: Build assertion (NameID, email, groups)
|
||||
IdP->>IdP: Sign assertion with IdP private key
|
||||
IdP->>B: HTTP 200 with auto-submit form (POST to SP callback)
|
||||
B->>SP: POST /api/auth/saml/callback (SAMLResponse in body)
|
||||
SP->>SP: Base64-decode SAMLResponse
|
||||
SP->>SP: Validate XML signature against IdP certificate
|
||||
SP->>SP: Check NotBefore/NotOnOrAfter (120s clock skew tolerance)
|
||||
SP->>SP: Extract NameID, email, displayName, group claims
|
||||
SP->>DB: Look up user by external_id (NameID)
|
||||
alt New user (no matching external_id)
|
||||
SP->>DB: INSERT into users (JIT provisioning)
|
||||
SP->>DB: INSERT audit_log (saml_user_provisioned)
|
||||
else Existing user
|
||||
SP->>DB: UPDATE user group, teams, email
|
||||
SP->>DB: INSERT audit_log (saml_user_updated) if changed
|
||||
end
|
||||
SP->>DB: INSERT into sessions (session_id, user_id, expires_at)
|
||||
SP->>DB: INSERT audit_log (saml_login)
|
||||
SP->>B: Set-Cookie: session_id=xxx; HttpOnly; SameSite=Lax
|
||||
SP->>B: HTTP 302 Redirect to /?saml_success=true
|
||||
B->>SP: GET /api/auth/me (with cookie)
|
||||
SP->>B: 200 { user: { id, username, group, teams, authSource } }
|
||||
```
|
||||
|
||||
### Assertion Rejection Path
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant B as Browser
|
||||
participant SP as Dashboard (SP)
|
||||
participant IdP as AD FS (IdP)
|
||||
|
||||
B->>SP: GET /api/auth/saml/login
|
||||
SP->>B: HTTP 302 Redirect to IdP
|
||||
B->>IdP: Authenticate
|
||||
IdP->>B: POST assertion to SP callback
|
||||
B->>SP: POST /api/auth/saml/callback
|
||||
SP->>SP: Validate assertion
|
||||
alt Invalid signature
|
||||
SP->>SP: Log audit (saml_auth_failed, reason: invalid_signature)
|
||||
SP->>B: Redirect /?saml_error=Invalid+assertion+signature
|
||||
else Expired assertion
|
||||
SP->>SP: Log audit (saml_auth_failed, reason: assertion_expired)
|
||||
SP->>B: Redirect /?saml_error=Assertion+expired
|
||||
else Account disabled
|
||||
SP->>SP: Log audit (saml_auth_failed, reason: account_disabled)
|
||||
SP->>B: Redirect /?saml_error=Account+is+disabled
|
||||
end
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Component Architecture
|
||||
|
||||
```
|
||||
┌───────────────────────────────────────────────────────────────────┐
|
||||
│ Express Backend (port 3001) │
|
||||
│ │
|
||||
│ ┌────────────────┐ ┌─────────────────┐ ┌──────────────────┐ │
|
||||
│ │ routes/saml.js │ │ routes/auth.js │ │ middleware/auth.js│ │
|
||||
│ │ │ │ │ │ │ │
|
||||
│ │ GET /status │ │ POST /login │ │ requireAuth() │ │
|
||||
│ │ GET /login │ │ POST /logout │ │ requireGroup() │ │
|
||||
│ │ POST /callback │ │ GET /me │ │ │ │
|
||||
│ │ GET /metadata │ │ POST /change-pw │ │ (unchanged — │ │
|
||||
│ └───────┬────────┘ └────────┬────────┘ │ reads session │ │
|
||||
│ │ │ │ cookie only) │ │
|
||||
│ │ │ └──────────────────┘ │
|
||||
│ ▼ ▼ │
|
||||
│ ┌──────────────────────────────────────────────────┐ │
|
||||
│ │ helpers/samlProvisioning.js │ │
|
||||
│ │ │ │
|
||||
│ │ resolveGroup(adGroups, config) → dashboardGroup │ │
|
||||
│ │ resolveTeams(adGroups, config) → "STEAM,..." │ │
|
||||
│ │ deriveUsername(nameId) → username │ │
|
||||
│ │ provisionOrUpdateUser(assertion, config, ip) │ │
|
||||
│ └───────────────────────┬───────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌──────────────────────────────────────────────────┐ │
|
||||
│ │ helpers/samlConfig.js │ │
|
||||
│ │ │ │
|
||||
│ │ loadGroupMappingConfig() → validated config obj │ │
|
||||
│ │ (reads config/adGroupMapping.json or env var) │ │
|
||||
│ └───────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
└────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Key Design Decisions
|
||||
|
||||
1. **Session reuse**: SAML login creates the exact same session format as local login. No changes to `requireAuth()` middleware.
|
||||
2. **Feature flag isolation**: When `SAML_ENABLED=false`, SAML routes return 404 and no SAML library is loaded. Zero runtime cost when disabled.
|
||||
3. **Config-driven mapping**: AD group names are externalized in `config/adGroupMapping.json`. Changing the mapping requires only a file edit and backend restart — no code changes.
|
||||
4. **JIT provisioning**: Users are created on first login, updated on each subsequent login. AD is the source of truth for SSO users.
|
||||
5. **Separation of concerns**: The provisioning logic (`samlProvisioning.js`) is a pure module with no HTTP dependencies — fully unit-testable without a web server.
|
||||
|
||||
---
|
||||
|
||||
## AD Group-to-Permission Mapping
|
||||
|
||||
### Configuration Structure
|
||||
|
||||
```json
|
||||
{
|
||||
"groups": {
|
||||
"<AD-GROUP-CN>": "<Dashboard-Group>",
|
||||
"CVE-Dashboard-Admins": "Admin",
|
||||
"CVE-Dashboard-Users": "Standard_User",
|
||||
"CVE-Dashboard-Leadership": "Leadership",
|
||||
"CVE-Dashboard-ReadOnly": "Read_Only"
|
||||
},
|
||||
"teams": {
|
||||
"<AD-GROUP-CN>": "<BU-Team-ID>",
|
||||
"NTS-AEO-STEAM": "STEAM",
|
||||
"NTS-AEO-ACCESS-ENG": "ACCESS-ENG",
|
||||
"NTS-AEO-ACCESS-OPS": "ACCESS-OPS",
|
||||
"NTS-AEO-INTELDEV": "INTELDEV"
|
||||
},
|
||||
"groupPriority": ["Admin", "Standard_User", "Leadership", "Read_Only"],
|
||||
"defaultGroup": "Read_Only",
|
||||
"attributes": {
|
||||
"email": "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress",
|
||||
"displayName": "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name",
|
||||
"groups": "http://schemas.xmlsoap.org/claims/Group"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Privilege Hierarchy
|
||||
|
||||
When a user belongs to multiple AD groups that map to different dashboard groups, the highest-privilege group wins:
|
||||
|
||||
```
|
||||
Admin > Standard_User > Leadership > Read_Only
|
||||
```
|
||||
|
||||
### Multi-Team Assignment
|
||||
|
||||
When a user belongs to multiple AD groups that map to BU teams, all matching teams are assigned:
|
||||
|
||||
```
|
||||
AD Groups: ["NTS-AEO-STEAM", "NTS-AEO-ACCESS-ENG", "CVE-Dashboard-Users"]
|
||||
→ user_group: "Standard_User"
|
||||
→ bu_teams: "ACCESS-ENG,STEAM" (sorted alphabetically, deduplicated)
|
||||
```
|
||||
|
||||
### Placeholder Group Names
|
||||
|
||||
The AD group names in the `groups` and `teams` sections (e.g., "CVE-Dashboard-Admins") are placeholders. When real group CNs are obtained from the AD administrators, update only this configuration file. No code changes required.
|
||||
|
||||
---
|
||||
|
||||
## Database Schema Changes
|
||||
|
||||
### Migration: `add_saml_auth_columns.js`
|
||||
|
||||
| Column | Type | Nullable | Default | Purpose |
|
||||
|---|---|---|---|---|
|
||||
| `auth_source` | VARCHAR(10) | NOT NULL | `'local'` | Discriminates local vs SSO users |
|
||||
| `external_id` | VARCHAR(256) | NULL | NULL | SAML NameID for IdP correlation |
|
||||
|
||||
Additional changes:
|
||||
- `password_hash` becomes nullable (SAML users have no local password)
|
||||
- Partial unique index on `external_id WHERE external_id IS NOT NULL`
|
||||
|
||||
### Impact on Existing Data
|
||||
|
||||
- All existing users receive `auth_source = 'local'` and `external_id = NULL`
|
||||
- No existing functionality is affected
|
||||
- Migration is idempotent (safe to re-run)
|
||||
|
||||
---
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Required | Description | Example |
|
||||
|---|---|---|---|
|
||||
| `SAML_ENABLED` | Always | Master feature flag for SAML authentication | `false` |
|
||||
| `SAML_IDP_METADATA_URL` | When SAML_ENABLED=true | AD FS federation metadata endpoint | `https://adfs.corp.local/FederationMetadata/2007-06/FederationMetadata.xml` |
|
||||
| `SAML_SP_ENTITY_ID` | When SAML_ENABLED=true | Unique identifier for this Service Provider | `http://71.85.90.6:3001` |
|
||||
| `SAML_SP_CALLBACK_URL` | When SAML_ENABLED=true | Assertion consumer service URL | `http://71.85.90.6:3001/api/auth/saml/callback` |
|
||||
| `SAML_IDP_CERT_PATH` | When SAML_ENABLED=true | File path to IdP signing certificate (PEM format) | `/etc/cve-dashboard/idp-cert.pem` |
|
||||
| `SESSION_LIFETIME_HOURS` | Optional | Session duration (1-720 hours, default: 24) | `8` |
|
||||
| `AD_GROUP_MAPPING_JSON` | Optional | JSON string override for adGroupMapping.json | `{"groups":{...},"teams":{...}}` |
|
||||
|
||||
### Startup Validation
|
||||
|
||||
When `SAML_ENABLED=true`, the server validates at startup:
|
||||
1. All required SAML env vars are set (fails with descriptive error if missing)
|
||||
2. Certificate file exists and is readable (fails if not)
|
||||
3. Group mapping config parses as valid JSON (fails if not)
|
||||
4. All mapped team names exist in KNOWN_TEAMS (fails if not)
|
||||
5. All mapped dashboard groups are valid (fails if not)
|
||||
|
||||
This fail-fast approach prevents silent misconfiguration in production.
|
||||
|
||||
---
|
||||
|
||||
## Build vs Wait: Phase Breakdown
|
||||
|
||||
### Phase 1 — Build Now (No AD Access Required)
|
||||
|
||||
| Component | File | Test Strategy |
|
||||
|---|---|---|
|
||||
| Database migration | `backend/migrations/add_saml_auth_columns.js` | Run against test DB, verify columns |
|
||||
| Group mapping config | `backend/config/adGroupMapping.json` | Startup validation tests |
|
||||
| Config loader | `backend/helpers/samlConfig.js` | Unit test with mock JSON files |
|
||||
| JIT provisioner | `backend/helpers/samlProvisioning.js` | Unit test all paths with mock pool |
|
||||
| SAML routes (skeleton) | `backend/routes/saml.js` | Integration test: feature flag, status, metadata |
|
||||
| Session lifetime | `server.js` (startup block) | Unit test env var parsing |
|
||||
| Auth route changes | `backend/routes/auth.js` | Integration test: SAML user login rejection |
|
||||
| User route changes | `backend/routes/users.js` | Integration test: auth_source in responses, password block |
|
||||
| Frontend SSO button | `frontend/src/components/LoginForm.js` | Render test: button hidden when flag=false |
|
||||
| Admin auth_source badges | `frontend/src/components/UserManagement.js` | Render test: badge displays |
|
||||
| Architecture doc | `docs/architecture/ad-saml-integration.md` | N/A (documentation) |
|
||||
|
||||
### Phase 2 — Requires Live AD FS Connection
|
||||
|
||||
| Component | Dependency | Who Provides It |
|
||||
|---|---|---|
|
||||
| SAML library installation | Package selection (`@node-saml/passport-saml` or `saml2-js`) | Development team |
|
||||
| IdP metadata URL | AD FS federation metadata endpoint | AD administrators |
|
||||
| IdP signing certificate | Token-signing cert exported from AD FS | AD administrators |
|
||||
| SP registration | Relying party trust created in AD FS console | AD administrators |
|
||||
| Real AD group names | Actual CNs of permission/team groups | AD administrators |
|
||||
| Assertion parsing implementation | Fill in `routes/saml.js` callback | Development team |
|
||||
| End-to-end flow testing | Working AD user accounts | AD administrators |
|
||||
| Session lifetime tuning | AD FS token lifetime policy value | AD administrators |
|
||||
|
||||
---
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### Certificate Management
|
||||
|
||||
- The IdP signing certificate is stored on disk at `SAML_IDP_CERT_PATH`
|
||||
- When the IdP rotates its certificate, replace the file and restart the backend
|
||||
- No database migration required for certificate rotation
|
||||
- Consider monitoring certificate expiry dates (AD FS certs typically rotate annually)
|
||||
|
||||
### Assertion Replay Prevention
|
||||
|
||||
- Each SAML assertion is consumed exactly once by the callback handler
|
||||
- The JIT provisioner's idempotent update pattern means replayed assertions would simply re-update the same user record (no escalation possible)
|
||||
- For additional protection in Phase 2, implement InResponseTo validation and a short-lived assertion ID cache
|
||||
|
||||
### Trust Boundary
|
||||
|
||||
```
|
||||
┌─────────────────────────────────┐ ┌─────────────────────────────────┐
|
||||
│ Trust Zone A │ │ Trust Zone B │
|
||||
│ │ │ │
|
||||
│ Dashboard (SP) │ │ AD FS (IdP) │
|
||||
│ - Validates assertions │ │ - Authenticates users │
|
||||
│ - Trusts ONLY signed assertions │ │ - Signs assertions with │
|
||||
│ - Creates local sessions │ │ private key │
|
||||
│ - Enforces local authorization │ │ - Asserts group memberships │
|
||||
│ │ │ │
|
||||
└────────────────┬─────────────────┘ └────────────────┬────────────────┘
|
||||
│ │
|
||||
└─── SAML 2.0 over HTTPS (HTTP-POST) ────┘
|
||||
```
|
||||
|
||||
- The SP trusts assertions only when cryptographically signed by the IdP
|
||||
- Group memberships in the assertion drive permission assignment — the SP does not query AD directly
|
||||
- If the IdP is compromised, an attacker could forge assertions. Mitigate with certificate pinning and monitoring assertion patterns in audit logs.
|
||||
- The SP never sends credentials to the IdP — authentication happens entirely on the IdP side
|
||||
|
||||
### Break-Glass Protection
|
||||
|
||||
- The last local Admin account cannot be deleted or deactivated
|
||||
- If the IdP is unavailable, local Admin users can still log in with username/password
|
||||
- SAML users cannot authenticate via password (and vice versa) — the two paths are isolated per user record
|
||||
|
||||
### Transport Security
|
||||
|
||||
- Production deployments should serve the SP callback over HTTPS
|
||||
- The SAMLResponse is transmitted via HTTP-POST binding (browser-mediated, not direct server-to-server)
|
||||
- The assertion is signed — even if transmitted over HTTP, it cannot be tampered with without detection
|
||||
- For defense in depth, HTTPS prevents assertion interception by network observers
|
||||
|
||||
---
|
||||
|
||||
## Hybrid Login User Experience
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Login Page │
|
||||
│ │
|
||||
│ ┌────────────────────────────────────────────────────────┐ │
|
||||
│ │ ┌─────────────────────────────┐ │ │
|
||||
│ │ │ Sign in with SSO │ │ │
|
||||
│ │ │ (redirects to AD FS) │ │ │
|
||||
│ │ └─────────────────────────────┘ │ │
|
||||
│ │ │ │
|
||||
│ │ ─── or sign in with local account ─── │ │
|
||||
│ │ │ │
|
||||
│ │ Username: [________________] │ │
|
||||
│ │ Password: [________________] │ │
|
||||
│ │ │ │
|
||||
│ │ [Sign In] │ │
|
||||
│ └────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ Note: SSO button only visible when SAML_ENABLED=true │
|
||||
│ Local login always available (break-glass for admins) │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Related Specifications
|
||||
|
||||
- `.kiro/specs/ad-saml-integration/requirements.md` — detailed acceptance criteria
|
||||
- `.kiro/specs/ad-saml-integration/design.md` — implementation design with code examples
|
||||
- `.kiro/specs/group-based-access-control/requirements.md` — existing RBAC system
|
||||
- `.kiro/specs/multi-bu-tenancy/design.md` — BU team scoping (leveraged by AD integration)
|
||||
547
docs/architecture/split-architecture-proposal.md
Normal file
547
docs/architecture/split-architecture-proposal.md
Normal file
@@ -0,0 +1,547 @@
|
||||
# Split Architecture Proposal: Collector + Indexer
|
||||
|
||||
**Author:** Infrastructure Team
|
||||
**Date:** 2026-06-08
|
||||
**Status:** Draft — Pending Review
|
||||
**Scope:** Scale CVE Dashboard from 2 teams / ~15 users to company-wide deployment (100+ users, 15+ teams)
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
The STEAM Security Dashboard currently runs as a monolithic single-process Express application on CT107 (dashboard-dev, 71.85.90.9). This single process simultaneously serves the frontend, handles all API requests, and performs background data collection from Ivanti, Jira, CARD, Atlas, and NVD APIs.
|
||||
|
||||
At current scale (2 teams, <15 users, daily sync), this architecture works. At company-wide scale (15+ teams, hundreds of users, sub-hourly sync), it will not. This document proposes a phased transition to a **Collector + API Server** architecture that separates data ingestion from request serving.
|
||||
|
||||
**Critical constraint:** CT107 (71.85.90.9) has the firewall rules granting access to the production Ivanti, Jira, and CARD APIs. The collector component must remain on this machine or firewall rules must be extended.
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Current Architecture](#current-architecture)
|
||||
- [Problem Statement](#problem-statement)
|
||||
- [Proposed Architecture](#proposed-architecture)
|
||||
- [Phase Plan](#phase-plan)
|
||||
- [Infrastructure Requirements](#infrastructure-requirements)
|
||||
- [Risk Assessment](#risk-assessment)
|
||||
- [Decision Points](#decision-points)
|
||||
- [Appendix: Current Data Flow Analysis](#appendix-current-data-flow-analysis)
|
||||
|
||||
---
|
||||
|
||||
## Current Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ CT107 (dashboard-dev) │
|
||||
│ 71.85.90.9 — 48 GB RAM, 250 GB Disk │
|
||||
│ │
|
||||
│ ┌───────────────────────────────────────────────────────────┐ │
|
||||
│ │ Express Process (port 3001/3100) │ │
|
||||
│ │ │ │
|
||||
│ │ ┌─────────────┐ ┌──────────────┐ ┌────────────────┐ │ │
|
||||
│ │ │ React SPA │ │ API Routes │ │ Sync Workers │ │ │
|
||||
│ │ │ (static) │ │ (50+ endpts)│ │ (setInterval) │ │ │
|
||||
│ │ └─────────────┘ └──────────────┘ └────────────────┘ │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ │ Shared PG Pool (10 conn) │ │
|
||||
│ │ │ │ │ │
|
||||
│ └──────────────────────────┼──────────┼─────────────────────┘ │
|
||||
│ │ │ │
|
||||
│ ┌──────────────────────────▼──────────▼─────────────────────┐ │
|
||||
│ │ PostgreSQL 16 (Docker, port 5433) │ │
|
||||
│ └───────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ Firewall Access: Ivanti API, Jira DC, CARD API, Atlas API │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Key Metrics (Current)
|
||||
|
||||
| Metric | Current Value | Company-Wide Projection |
|
||||
|--------|--------------|------------------------|
|
||||
| Concurrent users | 5–15 | 100–300 |
|
||||
| Teams tracked | 2 | 15+ |
|
||||
| Ivanti findings (open) | ~200–500 | 2,000–10,000+ |
|
||||
| Ivanti sync frequency | 24h | 1–4h desired |
|
||||
| PG connection pool | 10 | Insufficient |
|
||||
| Jira API rate limit | 1,440/day | Shared across all users |
|
||||
| Data sources | 5 (Ivanti, NVD, Jira, Atlas, CARD) | 8+ (add CrowdStrike, Qualys, Tanium) |
|
||||
|
||||
---
|
||||
|
||||
## Problem Statement
|
||||
|
||||
### 1. Sync Blocks the API Server
|
||||
|
||||
`syncFindings()` runs sequentially through:
|
||||
1. Fetch all open findings pages (100/page)
|
||||
2. Upsert findings batch into PostgreSQL
|
||||
3. Detect archive changes (compare all previous vs current)
|
||||
4. Fetch all closed findings pages
|
||||
5. Upsert closed findings
|
||||
6. Run BU drift checker (makes additional API calls per disappeared finding)
|
||||
7. Sync FP workflow counts (sweeps all closed pages again)
|
||||
8. Compute and store anomaly summary
|
||||
9. Record counts history
|
||||
|
||||
At 500 findings, this takes 2–5 minutes. At 10,000 findings across 15 teams, this could take 15–30 minutes. During sync, the Express process is saturated — API responses slow, the connection pool contends.
|
||||
|
||||
### 2. Single Point of Failure
|
||||
|
||||
One process handles everything. A memory leak during sync, an unhandled promise rejection in the BU drift checker, or a runaway loop in archive detection crashes the entire dashboard for all users.
|
||||
|
||||
### 3. Connection Pool Exhaustion
|
||||
|
||||
10 connections shared between:
|
||||
- User-facing read queries (findings list, compliance items, charts)
|
||||
- Sync bulk upserts (batches of 100 rows × 18 columns)
|
||||
- User writes (notes, overrides, queue operations)
|
||||
|
||||
The pool already logs warnings at 8/10 active. At 100+ concurrent users issuing reads while a sync writes thousands of rows, this will deadlock or time out.
|
||||
|
||||
### 4. Rate Limits Shared Across Functions
|
||||
|
||||
Jira's 1,440/day limit is consumed by both background sync and user-initiated operations (lookups, ticket creation). A bulk sync could exhaust the daily budget, blocking users from creating tickets the rest of the day.
|
||||
|
||||
### 5. No Horizontal Scaling Path
|
||||
|
||||
Cannot add a second API server without also duplicating the sync scheduler, which would cause duplicate syncs, double-writes, and race conditions.
|
||||
|
||||
### 6. Firewall Constraint
|
||||
|
||||
CT107 has the only firewall access to production Ivanti, Jira, and CARD APIs. The collector (data fetcher) must run on this machine. The API server could potentially move elsewhere, but the collector cannot without firewall changes.
|
||||
|
||||
---
|
||||
|
||||
## Proposed Architecture
|
||||
|
||||
### Target State
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ CT107 (dashboard-dev) │
|
||||
│ 71.85.90.9 — 48 GB RAM, 250 GB Disk │
|
||||
│ ★ Firewall access to prod APIs ★ │
|
||||
│ │
|
||||
│ ┌───────────────────────────────────┐ ┌─────────────────────┐│
|
||||
│ │ API Server (Express, port 3001) │ │ Collector Service ││
|
||||
│ │ │ │ (Node.js worker) ││
|
||||
│ │ • React SPA serving │ │ ││
|
||||
│ │ • All /api/* read endpoints │ │ • Ivanti sync ││
|
||||
│ │ • User writes (notes, queue) │ │ • Jira bulk sync ││
|
||||
│ │ • On-demand lookups (proxied) │ │ • CARD cache sync ││
|
||||
│ │ • Triggers collector via │ │ • Atlas cache sync ││
|
||||
│ │ pg NOTIFY │ │ • NVD bulk sync ││
|
||||
│ │ │ │ • Archive detect ││
|
||||
│ │ Pool: 15 conn (reads + writes) │ │ • BU drift checker ││
|
||||
│ │ │ │ • Anomaly compute ││
|
||||
│ └───────────────┬───────────────────┘ │ • Compliance parse ││
|
||||
│ │ │ ││
|
||||
│ │ │ Pool: 10 conn ││
|
||||
│ │ │ (bulk upserts) ││
|
||||
│ │ │ ││
|
||||
│ │ │ Listens: ││
|
||||
│ │ │ pg LISTEN ││
|
||||
│ │ │ 'sync_trigger' ││
|
||||
│ │ └──────────┬──────────┘│
|
||||
│ │ │ │
|
||||
│ ┌───────────────▼──────────────────────────────────▼─────────┐│
|
||||
│ │ PostgreSQL 16 (Docker, port 5433) ││
|
||||
│ │ Pool: 25 total connections allocated ││
|
||||
│ └────────────────────────────────────────────────────────────┘│
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Component Responsibilities
|
||||
|
||||
#### API Server (`cve-api.service`)
|
||||
|
||||
| Responsibility | Details |
|
||||
|---|---|
|
||||
| Frontend serving | Static React build via `express.static` |
|
||||
| Read endpoints | All GET routes — findings, compliance, charts, exports |
|
||||
| User writes | Notes, overrides, queue items, ticket CRUD, KB uploads |
|
||||
| On-demand lookups | Single NVD lookup, single Jira issue lookup, CARD real-time queries |
|
||||
| Sync trigger | `SELECT pg_notify('sync_trigger', '{"type":"findings","user":"admin"}')` |
|
||||
| Health/status | Expose collector status via sync_state table reads |
|
||||
|
||||
#### Collector (`cve-collector.service`)
|
||||
|
||||
| Responsibility | Details |
|
||||
|---|---|
|
||||
| Scheduled syncs | Ivanti findings (configurable interval), workflows (24h) |
|
||||
| Bulk API operations | Jira JQL sync-all, Atlas cache refresh, NVD bulk sync |
|
||||
| Post-sync processing | Archive detection, BU drift classification, closed-gone detection |
|
||||
| Anomaly computation | Open/closed deltas, classification breakdown, significance flagging |
|
||||
| Compliance parsing | Spawns Python subprocess for xlsx parsing on upload commit |
|
||||
| Event-driven triggers | Listens on `pg LISTEN sync_trigger` for on-demand requests |
|
||||
| Rate budget management | Owns the Jira daily/burst counters; API server gets a reserved allocation |
|
||||
|
||||
### Communication Pattern
|
||||
|
||||
```
|
||||
User clicks "Sync" in UI
|
||||
│
|
||||
▼
|
||||
API Server receives POST /api/ivanti/findings/sync
|
||||
│
|
||||
▼
|
||||
API Server: SELECT pg_notify('sync_trigger', '{"type":"findings"}')
|
||||
│
|
||||
▼
|
||||
API Server responds: { status: 'sync_started', message: 'Check /sync-status' }
|
||||
│
|
||||
▼
|
||||
Collector receives NOTIFY, starts syncFindings()
|
||||
│
|
||||
▼
|
||||
Collector updates ivanti_sync_state (status='syncing')
|
||||
│
|
||||
▼
|
||||
Collector completes, updates ivanti_sync_state (status='success')
|
||||
│
|
||||
▼
|
||||
Frontend polls GET /api/ivanti/findings/sync-status → sees 'success' → refreshes
|
||||
```
|
||||
|
||||
No Redis. No message broker. Just PostgreSQL LISTEN/NOTIFY — zero new infrastructure.
|
||||
|
||||
---
|
||||
|
||||
## Phase Plan
|
||||
|
||||
### Phase 0: Immediate Improvements (Week 1–2)
|
||||
**Goal:** Reduce risk within the current monolith. No architectural changes.
|
||||
|
||||
| Task | Effort | Impact |
|
||||
|------|--------|--------|
|
||||
| Make `POST /sync` non-blocking — return immediately, let sync run in background | 2h | Unblocks users during sync |
|
||||
| Add `GET /api/ivanti/findings/sync-status` endpoint | 1h | Frontend can poll for completion |
|
||||
| Increase PG pool from 10 → 20 connections | 10min | Headroom for concurrent operations |
|
||||
| Add `pg_stat_activity` monitoring query to health endpoint | 30min | Visibility into pool pressure |
|
||||
| Update frontend to poll sync-status instead of waiting | 2h | UX improvement |
|
||||
|
||||
**Deliverables:**
|
||||
- Updated `ivantiFindings.js` with async sync dispatch
|
||||
- New sync-status polling endpoint
|
||||
- Frontend ReportingPage sync UX updated
|
||||
- Pool configuration change in `db.js`
|
||||
|
||||
---
|
||||
|
||||
### Phase 1: Extract Collector (Weeks 3–4)
|
||||
**Goal:** Separate data collection into its own process on CT107.
|
||||
|
||||
| Task | Effort | Impact |
|
||||
|------|--------|--------|
|
||||
| Create `backend/collector.js` — standalone Node process | 4h | Fault isolation |
|
||||
| Move sync functions from route files into shared `lib/sync/` modules | 3h | Code reuse between collector and API |
|
||||
| Implement pg LISTEN/NOTIFY trigger mechanism | 2h | API → Collector communication |
|
||||
| Create `cve-collector.service` systemd unit | 30min | Process management |
|
||||
| Add collector health check and status reporting | 1h | Observability |
|
||||
| Update `POST /sync` routes to use pg_notify instead of inline sync | 1h | Complete decoupling |
|
||||
| Add `sync_jobs` table for job tracking (queued, running, complete, failed) | 1h | Multi-user sync coordination |
|
||||
| Update CI/CD pipeline to deploy collector service | 2h | Automated deployment |
|
||||
|
||||
**Deliverables:**
|
||||
- `backend/collector.js` — entry point for collector process
|
||||
- `backend/lib/sync/` — shared sync logic (extracted from routes)
|
||||
- `systemd/cve-collector.service` — systemd unit
|
||||
- Updated `.gitlab-ci.yml` with collector deploy stage
|
||||
- `sync_jobs` table for job state tracking
|
||||
|
||||
**File structure after Phase 1:**
|
||||
|
||||
```
|
||||
backend/
|
||||
├── server.js # API server (unchanged entry point)
|
||||
├── collector.js # NEW — collector entry point
|
||||
├── db.js # Shared pool config
|
||||
├── lib/
|
||||
│ └── sync/
|
||||
│ ├── ivantiFindings.js # Extracted from routes/ivantiFindings.js
|
||||
│ ├── ivantiWorkflows.js # Extracted from routes/ivantiWorkflows.js
|
||||
│ ├── jiraBulkSync.js # Extracted from routes/jiraTickets.js
|
||||
│ ├── atlasCache.js # Extracted from routes/atlas.js
|
||||
│ ├── nvdBulkSync.js # New — bulk NVD operations
|
||||
│ ├── archiveDetection.js # Extracted from routes/ivantiFindings.js
|
||||
│ └── anomalyCompute.js # Extracted from routes/ivantiFindings.js
|
||||
├── routes/ # API routes — now thin, read-heavy
|
||||
└── helpers/ # Shared API client helpers (unchanged)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Phase 2: Multi-Tenancy & Scale Hardening (Weeks 5–8)
|
||||
**Goal:** Prepare for 15 teams and hundreds of users.
|
||||
|
||||
| Task | Effort | Impact |
|
||||
|------|--------|--------|
|
||||
| Per-team sync scheduling — stagger syncs to avoid API burst | 3h | Spreads load |
|
||||
| Jira rate budget partitioning (collector gets 80%, API gets 20%) | 2h | Prevents sync from starving users |
|
||||
| Per-BU finding isolation — team users only see their findings | 4h | Data scoping |
|
||||
| Add connection pooling metrics endpoint (`/api/admin/pool-stats`) | 1h | Operational visibility |
|
||||
| Implement sync queue with priority (user-triggered > scheduled) | 3h | Better UX |
|
||||
| Add retry logic with exponential backoff to collector | 2h | Resilience |
|
||||
| Partial-progress persistence — don't lose work on mid-sync failure | 4h | Data integrity |
|
||||
| PG connection pool separation — API pool (15) + Collector pool (10) | 1h | Isolation |
|
||||
| Add `pg_bouncer` or similar for connection multiplexing (optional) | 4h | Scale past 50 concurrent |
|
||||
|
||||
**Deliverables:**
|
||||
- Team-scoped sync scheduler in collector
|
||||
- Rate budget allocation system
|
||||
- Retry/backoff logic
|
||||
- Partial progress tracking
|
||||
- Pool separation
|
||||
|
||||
---
|
||||
|
||||
### Phase 3: Additional Data Sources (Weeks 9–12)
|
||||
**Goal:** Integrate CrowdStrike, Qualys, and Tanium feeds.
|
||||
|
||||
| Task | Effort | Impact |
|
||||
|------|--------|--------|
|
||||
| CrowdStrike Falcon API integration in collector | 8h | New vulnerability source |
|
||||
| Qualys VMDR API integration in collector | 8h | New vulnerability source |
|
||||
| Tanium asset inventory sync | 6h | Asset correlation |
|
||||
| Cross-source finding deduplication logic | 6h | Data quality |
|
||||
| Unified findings view (merged from all sources) | 4h | Single pane of glass |
|
||||
| Source-specific sync schedules (configurable per source) | 2h | Flexibility |
|
||||
|
||||
**Note:** All new API integrations go into the collector. The API server never makes outbound calls to external vulnerability platforms except for single-item on-demand lookups.
|
||||
|
||||
**Firewall implications:** CrowdStrike, Qualys, and Tanium API access will need firewall rules added to CT107 (71.85.90.9). Submit firewall requests in advance.
|
||||
|
||||
---
|
||||
|
||||
### Phase 4: Horizontal Scaling (Weeks 13+)
|
||||
**Goal:** Support 300+ concurrent users if company-wide adoption materializes.
|
||||
|
||||
| Task | Effort | Impact |
|
||||
|------|--------|--------|
|
||||
| Move API server to a separate LXC container (with more resources) | 4h | Dedicated API resources |
|
||||
| Run multiple API server instances behind a load balancer | 8h | Horizontal scale |
|
||||
| Keep collector on CT107 (firewall access) | 0h | No change needed |
|
||||
| Add Redis for session store (replace PG sessions) | 4h | Multi-instance sessions |
|
||||
| Add read replicas if PG becomes the bottleneck | 8h | Read scale |
|
||||
| Evaluate moving PG to CT109 (zbl-indexer, 32GB/500GB) | 2h | Larger DB host |
|
||||
|
||||
**Architecture at Phase 4:**
|
||||
|
||||
```
|
||||
┌─────────────────┐
|
||||
│ Load Balancer │
|
||||
│ (nginx/HAProxy)│
|
||||
└────┬───────┬────┘
|
||||
│ │
|
||||
┌─────────────▼─┐ ┌─▼─────────────┐
|
||||
│ API Server 1 │ │ API Server 2 │ (New LXC or CT103)
|
||||
│ (Express) │ │ (Express) │
|
||||
└───────┬───────┘ └───────┬───────┘
|
||||
│ │
|
||||
└─────────┬─────────┘
|
||||
│
|
||||
┌────────────────────────────▼──────────────────────────────────────┐
|
||||
│ CT107 (71.85.90.9) │
|
||||
│ │
|
||||
│ ┌─────────────────────────┐ ┌──────────────────────────────┐ │
|
||||
│ │ Collector Service │ │ PostgreSQL 16 │ │
|
||||
│ │ (sole process with │ │ (or moved to CT109) │ │
|
||||
│ │ firewall API access) │ │ │ │
|
||||
│ └─────────────────────────┘ └──────────────────────────────┘ │
|
||||
│ │
|
||||
│ ★ Firewall: Ivanti, Jira, CARD, Atlas, CrowdStrike, Qualys ★ │
|
||||
└───────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Infrastructure Requirements
|
||||
|
||||
### CT107 Resource Allocation (Current → Phase 2)
|
||||
|
||||
| Resource | Current | Phase 2 Target | Notes |
|
||||
|----------|---------|---------------|-------|
|
||||
| RAM | 48 GB | 48 GB (sufficient) | Node processes use <2GB each |
|
||||
| CPU | Shared | May need 4+ dedicated cores | Sync is CPU-intensive during transform |
|
||||
| Disk | 250 GB | 250 GB (sufficient) | PG data + uploads + logs |
|
||||
| PG connections | 10 | 25 (15 API + 10 collector) | Configure in `postgresql.conf` |
|
||||
| Systemd services | 2 (backend + frontend) | 3 (api + collector + postgres) | Frontend served by API |
|
||||
|
||||
### PostgreSQL Tuning (for 15 teams / hundreds of users)
|
||||
|
||||
```
|
||||
# postgresql.conf changes
|
||||
max_connections = 50 # Up from default 100 is fine, need headroom
|
||||
shared_buffers = 4GB # 25% of available RAM for PG
|
||||
effective_cache_size = 12GB # 75% of RAM PG can expect from OS
|
||||
work_mem = 64MB # Per-sort/hash operation
|
||||
maintenance_work_mem = 512MB # For VACUUM, CREATE INDEX
|
||||
wal_level = replica # If read replicas needed later
|
||||
```
|
||||
|
||||
### Firewall Dependencies
|
||||
|
||||
| Service | Endpoint | Required By | Current Access |
|
||||
|---------|----------|-------------|----------------|
|
||||
| Ivanti/RiskSense | platform4.risksense.com:443 | Collector | ✅ CT107 only |
|
||||
| Jira Data Center | jira.charter.com:443 | Collector + API (lookups) | ✅ CT107 only |
|
||||
| CARD API | card.charter.com:443 | API (real-time) | ✅ CT107 only |
|
||||
| Atlas InfoSec | (internal) | Collector | ✅ CT107 only |
|
||||
| NVD API | services.nvd.nist.gov:443 | Collector + API | ✅ Public |
|
||||
| CrowdStrike | api.crowdstrike.com:443 | Collector | ❌ Firewall request needed |
|
||||
| Qualys | qualysapi.qualys.com:443 | Collector | ❌ Firewall request needed |
|
||||
| Tanium | (internal) | Collector | ❌ Firewall request needed |
|
||||
|
||||
**Key constraint:** If the API server moves off CT107 in Phase 4, you'll need firewall rules for the new host to reach Jira (for user lookups) and CARD (for real-time queries). Alternatively, the collector could proxy those on-demand requests — adds latency but avoids firewall changes.
|
||||
|
||||
---
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
| Risk | Likelihood | Impact | Mitigation |
|
||||
|------|-----------|--------|------------|
|
||||
| Collector crash doesn't affect API users | — | — | This is the primary benefit of splitting |
|
||||
| Collector and API race on DB writes | Medium | Low | Collector does bulk upserts; API does single-row writes. Different tables mostly. Use advisory locks for sync_state. |
|
||||
| Sync trigger lost (pg NOTIFY missed) | Low | Medium | Collector also runs on a schedule. Missed trigger just delays to next interval. |
|
||||
| Phase 1 introduces bugs in extraction | Medium | Medium | Comprehensive test suite exists. Run parallel (old monolith + new split) in staging for 1 week. |
|
||||
| Firewall change delays block Phase 4 | High | Medium | Start firewall requests early. Phase 4 is optional — single-machine split (Phases 1–3) works fine at 15 teams. |
|
||||
| PG becomes bottleneck at 300+ users | Low | High | Phase 4 addresses with read replicas. CT109 (500GB, 32GB) available as larger DB host. |
|
||||
|
||||
---
|
||||
|
||||
## Decision Points
|
||||
|
||||
These require team/leadership input before proceeding:
|
||||
|
||||
1. **Sync frequency target:** Is 1-hour sync acceptable, or do teams need near-real-time (15 min)? This affects collector design complexity and API rate budget math.
|
||||
|
||||
2. **API server location:** Keep everything on CT107, or move the API server to a separate container? Keeping it on CT107 is simpler (no firewall changes for CARD/Jira lookups) but limits scaling options.
|
||||
|
||||
3. **Database location:** Keep PG on CT107, or move to CT109 (zbl-indexer, 500GB disk, 32GB RAM)? Moving adds network latency but gives more room for growth.
|
||||
|
||||
4. **CrowdStrike/Qualys/Tanium priority:** Which new data sources are most urgent? This affects Phase 3 ordering and firewall request timing.
|
||||
|
||||
5. **Session management:** At 300+ users, PG-backed sessions will be high-churn. Acceptable, or invest in Redis? Redis adds infrastructure but is the industry standard for session stores at scale.
|
||||
|
||||
6. **Multi-instance API:** Is the goal to survive a single API server restart without downtime? If yes, Phase 4 (load balancer + multiple instances) is needed. If brief restarts during deploys are acceptable, single-instance on CT107 works through Phase 3.
|
||||
|
||||
---
|
||||
|
||||
## Appendix: Current Data Flow Analysis
|
||||
|
||||
### Data Collection Patterns
|
||||
|
||||
| Source | Trigger | Frequency | Data Volume | Processing |
|
||||
|--------|---------|-----------|-------------|------------|
|
||||
| Ivanti Findings | Schedule + manual | 24h | 100–500 findings (all pages) | Extract, upsert, archive detect, BU drift, anomaly |
|
||||
| Ivanti Workflows | Schedule + manual | 24h | 50 workflow batches | Store as JSON blob |
|
||||
| Ivanti Closed Findings | During findings sync | 24h | All closed pages | Upsert + closed archive detection |
|
||||
| Jira Bulk Sync | Manual (admin) | On-demand | All tracked tickets via JQL | Status/summary update per ticket |
|
||||
| Jira Single Lookup | User action | Real-time | 1 issue | Proxy + display |
|
||||
| NVD Lookup | User action | Real-time | 1 CVE | Proxy + optional save |
|
||||
| NVD Bulk Sync | Manual | On-demand | All CVEs in DB | Batch update metadata |
|
||||
| Atlas Action Plans | Cache refresh | Background | Per-host plan data | Cache in `atlas_action_plans_cache` |
|
||||
| CARD Operations | User action | Real-time | 1 asset at a time | Proxy (confirm/decline/redirect) |
|
||||
| Compliance xlsx | Manual upload | Weekly | 1 file → hundreds of rows | Python parse → PG upsert (transactional) |
|
||||
|
||||
### What Moves to Collector vs Stays in API
|
||||
|
||||
| Operation | Collector | API Server | Rationale |
|
||||
|-----------|-----------|------------|-----------|
|
||||
| Ivanti findings sync (all pages) | ✅ | | Heavy, multi-page, post-processing |
|
||||
| Ivanti workflows sync | ✅ | | Scheduled background task |
|
||||
| Ivanti closed sweep | ✅ | | Part of findings sync pipeline |
|
||||
| Archive detection | ✅ | | CPU-intensive comparison |
|
||||
| BU drift checker | ✅ | | Makes additional API calls |
|
||||
| Anomaly computation | ✅ | | Depends on sync completion |
|
||||
| Jira bulk sync-all | ✅ | | Consumes rate budget, multi-issue |
|
||||
| NVD bulk sync | ✅ | | Multi-CVE, rate-limited |
|
||||
| Atlas cache refresh | ✅ | | Background, per-host API calls |
|
||||
| Compliance xlsx parse | ✅ | | Spawns Python, heavy DB writes |
|
||||
| Single Jira lookup | | ✅ | User-initiated, real-time, 1 call |
|
||||
| Single NVD lookup | | ✅ | User-initiated, real-time, 1 call |
|
||||
| CARD operations | | ✅ | User-initiated, real-time |
|
||||
| All GET /api/* reads | | ✅ | Pure DB queries, user-facing |
|
||||
| Notes/overrides/queue | | ✅ | Small writes, user-facing |
|
||||
| File uploads | | ✅ | User-initiated, disk I/O |
|
||||
|
||||
### Sync Pipeline Detail (becomes collector's core loop)
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ Collector Sync Pipeline │
|
||||
│ │
|
||||
│ ┌────────────────┐ │
|
||||
│ │ 1. Fetch Open │ ← Ivanti API (paginated, 100/page) │
|
||||
│ │ Findings │ │
|
||||
│ └───────┬────────┘ │
|
||||
│ │ │
|
||||
│ ┌───────▼────────┐ │
|
||||
│ │ 2. Extract & │ ← Transform raw API → normalized rows │
|
||||
│ │ Transform │ │
|
||||
│ └───────┬────────┘ │
|
||||
│ │ │
|
||||
│ ┌───────▼────────┐ │
|
||||
│ │ 3. Upsert to │ ← Batch INSERT ON CONFLICT (100/batch) │
|
||||
│ │ PG │ Preserves notes + overrides │
|
||||
│ └───────┬────────┘ │
|
||||
│ │ │
|
||||
│ ┌───────▼────────┐ │
|
||||
│ │ 4. Archive │ ← Compare previous IDs vs current IDs │
|
||||
│ │ Detection │ Detect disappeared + returned findings │
|
||||
│ └───────┬────────┘ │
|
||||
│ │ │
|
||||
│ ┌───────▼────────┐ │
|
||||
│ │ 5. Fetch Closed│ ← Ivanti API (all closed pages) │
|
||||
│ │ Findings │ Upsert as state='closed' │
|
||||
│ └───────┬────────┘ │
|
||||
│ │ │
|
||||
│ ┌───────▼────────┐ │
|
||||
│ │ 6. BU Drift │ ← Re-query Ivanti for disappeared IDs │
|
||||
│ │ Checker │ Classify: BU reassign / severity / decom │
|
||||
│ └───────┬────────┘ │
|
||||
│ │ │
|
||||
│ ┌───────▼────────┐ │
|
||||
│ │ 7. FP Workflow │ ← Sweep closed findings for FP# tickets │
|
||||
│ │ Counts │ Aggregate by state │
|
||||
│ └───────┬────────┘ │
|
||||
│ │ │
|
||||
│ ┌───────▼────────┐ │
|
||||
│ │ 8. Anomaly │ ← Compute deltas, write to anomaly_log │
|
||||
│ │ Summary │ │
|
||||
│ └───────┬────────┘ │
|
||||
│ │ │
|
||||
│ ┌───────▼────────┐ │
|
||||
│ │ 9. Update │ ← sync_state status='success' │
|
||||
│ │ Sync State │ Notify API server: pg_notify('sync_done') │
|
||||
│ └────────────────┘ │
|
||||
│ │
|
||||
└──────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Timeline Summary
|
||||
|
||||
| Phase | Timeframe | Key Outcome | Required For |
|
||||
|-------|-----------|-------------|--------------|
|
||||
| **0** | Weeks 1–2 | Non-blocking sync, pool increase | Immediate UX fix |
|
||||
| **1** | Weeks 3–4 | Collector extracted, fault isolation | Multi-team onboarding |
|
||||
| **2** | Weeks 5–8 | Multi-tenancy, rate budgeting, retries | 15 teams / 100+ users |
|
||||
| **3** | Weeks 9–12 | New data sources (CS/Qualys/Tanium) | Full vuln coverage |
|
||||
| **4** | Weeks 13+ | Horizontal scaling, load balancing | 300+ users (if needed) |
|
||||
|
||||
Phases 0–2 are recommended regardless of company-wide rollout. Phase 3 depends on data source priority decisions. Phase 4 is contingent on actual adoption numbers.
|
||||
|
||||
---
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. Review this document and provide input on [Decision Points](#decision-points)
|
||||
2. Approve Phase 0 for immediate implementation
|
||||
3. Schedule Phase 1 kickoff once Phase 0 is validated in staging
|
||||
4. Submit firewall requests for CrowdStrike/Qualys/Tanium access to CT107 (long lead time)
|
||||
@@ -1,6 +1,6 @@
|
||||
# STEAM Security Dashboard
|
||||
|
||||
A self-hosted vulnerability management dashboard for the NTS-AEO-STEAM and NTS-AEO-ACCESS-ENG business units. Centralises CVE tracking, Ivanti host finding triage, AEO compliance posture, FP and Archer exception workflows, and internal documentation in a single interface.
|
||||
A self-hosted vulnerability management dashboard for the NTS-AEO-STEAM, NTS-AEO-ACCESS-ENG, NTS-AEO-ACCESS-OPS, and NTS-AEO-INTELDEV business units. Centralises CVE tracking, Ivanti host finding triage, AEO compliance posture monitoring, CCP Metrics cross-org compliance reporting, FP/Archer/CARD/GRANITE/DECOM exception workflows, CARD asset ownership management, Granite Loader Sheet generation, Jira ticket management, and internal documentation in a single interface.
|
||||
|
||||
---
|
||||
|
||||
@@ -18,11 +18,20 @@ A self-hosted vulnerability management dashboard for the NTS-AEO-STEAM and NTS-A
|
||||
- [Home — CVE Management](#home--cve-management)
|
||||
- [Reporting — Host Findings](#reporting--host-findings)
|
||||
- [Ivanti Queue](#ivanti-queue)
|
||||
- [FP Workflow Submission](#fp-workflow-submission)
|
||||
- [Compliance — AEO Posture](#compliance--aeo-posture)
|
||||
- [CCP Metrics — Multi-Vertical Compliance](#ccp-metrics--multi-vertical-compliance)
|
||||
- [CARD Asset Ownership](#card-asset-ownership)
|
||||
- [Granite Loader Sheet](#granite-loader-sheet)
|
||||
- [Atlas Action Plans](#atlas-action-plans)
|
||||
- [Finding Archive Tracking](#finding-archive-tracking)
|
||||
- [Knowledge Base](#knowledge-base)
|
||||
- [Exports](#exports)
|
||||
- [Jira Tickets](#jira-tickets)
|
||||
- [Archer Risk Acceptance Tickets](#archer-risk-acceptance-tickets)
|
||||
- [Archer Template Library](#archer-template-library)
|
||||
- [In-App Notifications](#in-app-notifications)
|
||||
- [Feedback — GitLab Integration](#feedback--gitlab-integration)
|
||||
- [Admin Panel](#admin-panel)
|
||||
- [Scripts](#scripts)
|
||||
- [API Reference](#api-reference)
|
||||
@@ -43,13 +52,24 @@ The application provides:
|
||||
|
||||
- A searchable, filterable CVE list with per-vendor tracking and document storage
|
||||
- NVD API integration to auto-populate CVE metadata
|
||||
- **Ivanti/RiskSense integration** — sync open host findings with live FP workflow tracking
|
||||
- **Reporting page** with donut charts, advanced per-column filtering, inline editing, Ivanti Queue, and CSV/XLSX export
|
||||
- **Ivanti Queue** — personal staging list for batch-processing FP, Archer, and CARD workflows
|
||||
- **Ivanti/RiskSense integration** — sync open host findings with live FP workflow tracking, per-BU trend lines, and archive detection
|
||||
- **Reporting page** with donut charts, Group by Host toggle, CARD ownership tooltips, advanced per-column filtering, inline editing, Ivanti Queue, and CSV/XLSX export
|
||||
- **Ivanti Queue** — personal staging list for batch-processing FP, Archer, CARD, GRANITE, DECOM, and Remediate workflows
|
||||
- **FP Workflow Submission** — submit False Positive workflows directly to Ivanti API with attachments and lifecycle tracking
|
||||
- **AEO Compliance page** — weekly xlsx upload, diff preview, per-team metric health cards, device-level violation tracking with notes history
|
||||
- **CCP Metrics page** — multi-vertical VCL upload, cross-org compliance reporting with forecast burndown, metric drill-down, and data management
|
||||
- **CARD Asset Ownership** — owner lookup, confirm/decline/redirect actions, tooltip integration, Granite enrichment
|
||||
- **Granite Loader Sheet** — generate xlsx loader sheets with CARD enrichment, searchable picklists, per-row editing
|
||||
- **Atlas action plan tracking** with per-host vulnerability mapping and badge rendering
|
||||
- **Jira integration** — flexible ticket creation, multi-item consolidation, vendor-specific issue types, JQL sync
|
||||
- **Finding archive tracking** — automatic detection of disappeared/returned findings with anomaly logging
|
||||
- Archer risk acceptance ticket tracking (EXC numbers) linked to CVE/vendor pairs
|
||||
- **Archer Template Library** — store and clone static content for Archer Risk Acceptance forms
|
||||
- **In-app notifications** — native notification system for sync events and workflow completions
|
||||
- **Feedback integration** — submit bug reports and feature requests directly to GitLab as issues
|
||||
- A knowledge base for internal documentation and policies
|
||||
- Group-based access control (Admin, Standard_User, Leadership, Read_Only) with a full audit trail
|
||||
- Per-user BU team assignments and Ivanti identity for multi-tenant scoping
|
||||
|
||||
---
|
||||
|
||||
@@ -180,12 +200,22 @@ IVANTI_CLIENT_ID=1550
|
||||
# Optional: filter workflows to a specific person's submissions
|
||||
IVANTI_FIRST_NAME=
|
||||
IVANTI_LAST_NAME=
|
||||
# Set to 'true' if your network has SSL inspection / self-signed certs
|
||||
IVANTI_SKIP_TLS=false
|
||||
# Comma-separated list of BU values to sync from Ivanti.
|
||||
# Default if unset: NTS-AEO-ACCESS-ENG,NTS-AEO-STEAM
|
||||
IVANTI_BU_FILTER=NTS-AEO-ACCESS-ENG,NTS-AEO-STEAM
|
||||
# Comma-separated list of BUs considered "managed" for archive drift classification.
|
||||
# Findings leaving these BUs are classified as bu_reassignment.
|
||||
# Default if unset: NTS-AEO-ACCESS-ENG,NTS-AEO-STEAM
|
||||
IVANTI_MANAGED_BUS=NTS-AEO-ACCESS-ENG,NTS-AEO-STEAM
|
||||
# Set to 'true' if your network has SSL inspection / self-signed certs
|
||||
IVANTI_SKIP_TLS=false
|
||||
|
||||
# Atlas InfoSec API (required for Atlas action plan tracking)
|
||||
ATLAS_API_URL=https://atlas-infosec.caas.charterlab.com
|
||||
ATLAS_API_USER=your-atlas-user
|
||||
ATLAS_API_PASS=your-atlas-password
|
||||
# Set to true if behind Charter's SSL inspection proxy
|
||||
ATLAS_SKIP_TLS=false
|
||||
|
||||
# Jira Data Center REST API (required for Jira Tickets page)
|
||||
# VPN or Charter Network connection required for all Jira instances.
|
||||
@@ -200,6 +230,25 @@ JIRA_API_TOKEN=your-api-token
|
||||
JIRA_PROJECT_KEY=VULN
|
||||
JIRA_ISSUE_TYPE=Task
|
||||
JIRA_SKIP_TLS=false
|
||||
|
||||
# CARD Asset Ownership API (required for CARD integration)
|
||||
# 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=https://card.charter.com
|
||||
CARD_API_USER=your-card-user
|
||||
CARD_API_PASS=your-card-password
|
||||
# Set to true if behind Charter's SSL inspection proxy
|
||||
CARD_SKIP_TLS=false
|
||||
|
||||
# GitLab Feedback Integration (bug reports and feature requests from the dashboard)
|
||||
# PAT needs 'api' scope. Project ID is the numeric ID from GitLab project settings.
|
||||
GITLAB_URL=http://steam-gitlab.charterlab.com
|
||||
GITLAB_PROJECT_ID=13
|
||||
GITLAB_PAT=glpat-xxxxxxxxxxxxx
|
||||
# Webhook secret — shared secret for validating incoming webhook requests.
|
||||
# Set this same value in GitLab project > Settings > Webhooks > Secret Token.
|
||||
# Generate with: openssl rand -hex 20
|
||||
GITLAB_WEBHOOK_SECRET=changeme_generate_a_random_secret
|
||||
```
|
||||
|
||||
**`SESSION_SECRET` is required.** The server will exit on startup if it is not set. Generate one with `openssl rand -base64 32`.
|
||||
@@ -312,6 +361,10 @@ All routes require authentication. Four user groups are supported:
|
||||
|
||||
Sessions expire after 24 hours. Session tokens are stored in `httpOnly` cookies. Login is rate-limited to 20 attempts per 15-minute window.
|
||||
|
||||
**Per-user settings:**
|
||||
- `bu_teams` — comma-separated BU team assignments for multi-tenant scoping. Determines default BU filter on the Reporting page.
|
||||
- `ivanti_first_name` / `ivanti_last_name` — per-user Ivanti identity for filtering FP workflow views to their own submissions.
|
||||
|
||||
**Migration from legacy roles:** The `add_user_groups.js` migration automatically maps existing users: `admin` → `Admin`, `editor` → `Standard_User`, `viewer` → `Read_Only`. Unrecognized or NULL roles default to `Read_Only`.
|
||||
|
||||
---
|
||||
@@ -358,7 +411,7 @@ The home page is the primary CVE research and tracking tool.
|
||||
|
||||
### Reporting — Host Findings
|
||||
|
||||
The Reporting page is the core operational view for remediation tracking. It integrates with Ivanti/RiskSense to show all host findings for the configured business units.
|
||||
The Reporting page is the core operational view for remediation tracking. It integrates with Ivanti/RiskSense to show all host findings for the configured business units, with CARD ownership integration and multi-BU scoping.
|
||||
|
||||
#### Syncing Data
|
||||
|
||||
@@ -367,11 +420,38 @@ Click **Sync** (top right) to pull the latest findings from Ivanti. Sync require
|
||||
2. Fetches the closed finding count separately
|
||||
3. Sweeps closed findings to capture FP workflow states (including Approved FPs now closed)
|
||||
4. Stores findings as individual rows in the PostgreSQL `ivanti_findings` table
|
||||
5. Detects archived findings (present in previous sync, absent in current) and logs transitions
|
||||
6. Records per-BU counts in `ivanti_counts_history_by_bu` for trend analysis
|
||||
7. Logs sync anomalies when significant count deltas are detected
|
||||
|
||||
Findings are also auto-synced on a 24-hour schedule. The last sync timestamp is shown at the top of the page.
|
||||
|
||||
> `IVANTI_API_KEY` must be set in `backend/.env` for sync to work.
|
||||
|
||||
#### Group by Host
|
||||
|
||||
The **Group by Host** toggle in the toolbar collapses duplicate assets (same hostname + IP) with multiple finding IDs into expandable host rows. Hosts with only one finding remain as flat rows. Toggle between grouped and flat views. This reduces visual clutter when a single host has dozens of findings.
|
||||
|
||||
#### CARD Ownership Tooltip
|
||||
|
||||
Hover over any IP address in the findings table to see CARD asset ownership data in an interactive tooltip:
|
||||
- Confirmed, unconfirmed, and candidate team assignments with confidence scores
|
||||
- Click "Actions" to open the CARD Action Modal for direct confirm/decline/redirect operations
|
||||
- Results cached per session — re-hover displays instantly without API calls
|
||||
- Quick mode uses CTEC suffix only with 15s timeout to avoid multi-minute waits
|
||||
- Timeouts (504) are not cached — re-hover will retry the lookup
|
||||
- When Ivanti Host ID is available, uses the faster asset-search path
|
||||
|
||||
#### Multi-BU Scope
|
||||
|
||||
The multi-select BU picker at the top of the page replaces the previous binary scope toggle. Select one or more BUs to filter findings:
|
||||
- NTS-AEO-ACCESS-ENG
|
||||
- NTS-AEO-STEAM
|
||||
- NTS-AEO-ACCESS-OPS
|
||||
- NTS-AEO-INTELDEV
|
||||
|
||||
Per-user BU team assignments (`bu_teams` on the user record) determine the default scope.
|
||||
|
||||
#### Metric Charts
|
||||
|
||||
| Chart | What it shows |
|
||||
@@ -417,36 +497,72 @@ Each row represents a single Ivanti host finding.
|
||||
|
||||
### Ivanti Queue
|
||||
|
||||
A personal staging list for batch-processing FP, Archer, and CARD workflows without context-switching into Ivanti mid-review. Requires Admin or Standard_User group.
|
||||
A personal staging list for batch-processing FP, Archer, CARD, GRANITE, DECOM, and Remediate workflows without context-switching into Ivanti mid-review. Requires Admin or Standard_User group.
|
||||
|
||||
**Adding items:** Check the checkbox at the far left of any finding row. A popover appears:
|
||||
- For **FP** and **Archer** items: enter the Vendor / Platform (e.g., "Juniper MX", "Cisco IOS-XE")
|
||||
- For **CARD** items: no vendor entry required — the IP address is captured automatically
|
||||
- Select the workflow type: **FP**, **Archer**, or **CARD**
|
||||
- For **CARD** and **GRANITE** items: no vendor entry required — the IP address is captured automatically
|
||||
- For **DECOM** items: the finding is flagged for decommission workflow
|
||||
- For **Remediate** items: the finding is flagged for remediation tracking
|
||||
- Select the workflow type: **FP**, **Archer**, **CARD**, **GRANITE**, **DECOM**, or **Remediate**
|
||||
- Click **Add to Queue** — the row checkbox turns solid blue
|
||||
|
||||
**Queue panel:** Click the **Queue** button (top right of Reporting page) to open the slide-out panel:
|
||||
- **CARD** items appear at the top in their own section with the IP address displayed
|
||||
- **FP and Archer** items are grouped alphabetically by vendor below
|
||||
- Badges show workflow type: amber = FP, sky = Archer, green = CARD
|
||||
- Collapsible sections per workflow type
|
||||
- **CARD** items appear with the IP address displayed
|
||||
- **GRANITE** items show IP and hostname for Granite Loader Sheet generation
|
||||
- **FP and Archer** items are grouped alphabetically by vendor
|
||||
- **DECOM** items auto-note and auto-hide the finding on completion
|
||||
- Badges show workflow type colour-coded by category
|
||||
|
||||
**Working the queue:**
|
||||
- Check the green checkbox on an item to mark it complete (strikethrough at reduced opacity)
|
||||
- Delete individual items with the trash icon, or select multiple and use **Delete (N)**
|
||||
- **Clear Completed** removes all marked-complete items at once
|
||||
- **Clear Completed** removes all marked-complete items at once (FK-safe deletion)
|
||||
- **Create FP Workflow** — select pending FP items and click to open the FP Workflow modal, which submits a False Positive workflow batch directly to the Ivanti API with form fields, file attachments, and scope override. Attachments can be local file uploads or documents selected from the CVE document library — library documents are read from disk and sent to Ivanti identically to local uploads. Successful submission marks the queue items as complete and records the submission locally.
|
||||
- **Create Jira Ticket** — select multiple items and use the consolidation modal to create a single Jira ticket covering all selected findings. Ticket links are displayed on completed items.
|
||||
- **Loader Sheet** — select GRANITE items to generate a Granite Loader Sheet with CARD enrichment
|
||||
- **Archer Template** — select Archer items to open the template selector for pre-filling Archer Risk Acceptance forms
|
||||
|
||||
**Redirecting completed items:**
|
||||
**Redirecting items:**
|
||||
- Pending items can be redirected to a different workflow type without duplication
|
||||
- Completed items show a redirect button (↱) next to the delete icon
|
||||
- Click redirect to open a modal where you select the target workflow type (FP, Archer, or CARD) and vendor (required for FP/Archer)
|
||||
- Redirecting creates a new pending queue item with the same finding data under the new workflow type — the original completed item is preserved
|
||||
- Click redirect to open a modal where you select the target workflow type and vendor (required for FP/Archer)
|
||||
- Redirecting creates a new pending queue item with the same finding data under the new workflow type — the original item is preserved
|
||||
- This is useful when a CARD inventory fix is done but the finding still needs an FP or Archer workflow, or when an item was assigned to the wrong workflow initially
|
||||
- Not every completed item needs a redirect — it's an optional action for items that require further processing
|
||||
|
||||
Queue items are stored in the database, are **personal to your login**, and persist across sessions and page refreshes.
|
||||
|
||||
---
|
||||
|
||||
### FP Workflow Submission
|
||||
|
||||
Submit False Positive workflows directly to the Ivanti API with attachments and full lifecycle tracking. Accessible from the Ivanti Queue when FP items are selected.
|
||||
|
||||
**Submission workflow:**
|
||||
1. Select pending FP items in the queue
|
||||
2. Fill in workflow name, reason, description, expiration date, and scope override
|
||||
3. Attach supporting documents (local uploads or library documents, 10MB limit per file)
|
||||
4. Submit — the workflow batch is created in Ivanti via API and recorded locally
|
||||
|
||||
**Lifecycle tracking:** Each submission tracks its status through the lifecycle:
|
||||
- `submitted` — initial state after successful API submission
|
||||
- `approved` — FP workflow approved by Ivanti team
|
||||
- `rejected` — FP workflow rejected, requires rework
|
||||
- `rework` — submission is being edited for resubmission
|
||||
- `resubmitted` — edited submission resubmitted to Ivanti
|
||||
|
||||
**Managing submissions:**
|
||||
- View submission history with attachment results and lifecycle status
|
||||
- Edit and resubmit rejected workflows (fields, findings, and attachments)
|
||||
- Dismiss rejected submissions (sets `dismissed_at` timestamp)
|
||||
- Re-queue findings from rejected submissions into the todo queue under a different workflow type
|
||||
- Auto-clear approved submissions from the active list
|
||||
- Collapsible submissions panel with per-user filtering
|
||||
- Sync lifecycle status from Ivanti `currentState` on fetch
|
||||
|
||||
---
|
||||
|
||||
### Compliance — AEO Posture
|
||||
|
||||
The Compliance page tracks NTS-AEO team posture against the AEO compliance framework using weekly xlsx reports exported from the NTS_AEO reporting system.
|
||||
@@ -497,6 +613,170 @@ Only **STEAM** and **ACCESS-ENG** teams are tracked. The team selector at the to
|
||||
|
||||
---
|
||||
|
||||
### CCP Metrics — Multi-Vertical Compliance
|
||||
|
||||
The CCP Metrics page provides executive-level visibility into VCL (Vulnerability Compliance Level) compliance posture across multiple verticals (organisational units). It supports multi-file upload, forecast burndown charts, sub-team drill-down, and data management.
|
||||
|
||||
**Upload workflow:**
|
||||
1. Click **Upload** and select 1–14 xlsx files (naming convention: `<VERTICAL>_YYYY_MM_DD.xlsx`)
|
||||
2. Each file is parsed, the vertical is extracted from the filename, and a scoped diff is computed against existing data for that vertical
|
||||
3. The preview shows per-file new/recurring/resolved counts and unrecognised files
|
||||
4. Click **Commit** to persist all files in a single transaction with vertical-scoped resolution
|
||||
|
||||
**Executive overview:**
|
||||
- Aggregated stats across all verticals: total devices, compliant, non-compliant, compliance percentage vs target
|
||||
- Donut chart: blocked (no resolution date) vs in-progress (has resolution date)
|
||||
- Per-vertical breakdown cards with compliance percentage, burndown forecast, and last upload date
|
||||
- Cross-vertical metric breakdown table sorted by non-compliant count
|
||||
- LIVE and LAST REPORT badges showing data freshness
|
||||
|
||||
**Metric drill-down:**
|
||||
- Click a metric ID to see per-vertical breakdown with sub-team data
|
||||
- Per-metric summary statistics and donut breakdown
|
||||
- Per-metric monthly compliance trend with linear regression forecast (3 months forward when 3+ months of history)
|
||||
- Device list per vertical/metric with optional team filtering
|
||||
- Compliant/total counts on metric summary cards
|
||||
- Non-Compliant stat clickable with metric breakdown buttons
|
||||
|
||||
**Burndown forecasts:**
|
||||
- Per-vertical burndown: deduplicates devices by hostname, projects monthly resolution based on `resolution_date` fields
|
||||
- Aggregated burndown: cross-vertical forecast with projected clear date
|
||||
- Per-metric forecast burndown chart
|
||||
|
||||
**Sub-team drill-down:**
|
||||
- Each metric shows sub-team rows (e.g., STEAM, ACCESS-OPS, ACCESS-ENG) beneath the rollup row
|
||||
- Intermediate view between overview and device list
|
||||
- Team filtering on the device list endpoint
|
||||
|
||||
**Data management (Admin only):**
|
||||
- Delete a specific vertical (all items, uploads, summary, snapshots)
|
||||
- Rollback a specific upload (must be the most recent for that vertical)
|
||||
- Reset all multi-vertical data (nuclear option)
|
||||
|
||||
**VCL metric calculations:** See `docs/guides/vcl-metric-calculations.md` for formula reference.
|
||||
|
||||
---
|
||||
|
||||
### CARD Asset Ownership
|
||||
|
||||
CARD API integration for managing asset ownership — confirm, decline, and redirect ownership for network assets directly from the dashboard.
|
||||
|
||||
**Owner lookup:**
|
||||
- Hover over any IP address in the findings table to see CARD ownership data in a tooltip (confirmed/unconfirmed/candidate teams)
|
||||
- Tooltip uses quick mode (CTEC suffix only, 15s timeout) for performance
|
||||
- Results cached per session for instant re-display — timeouts (504) are not cached and will retry on re-hover
|
||||
- When Ivanti Host ID is available, uses the CARD asset-search endpoint for faster resolution
|
||||
|
||||
**Direct actions (no queue item required):**
|
||||
- Click "Actions" in the CARD tooltip to open the CARD Action Modal
|
||||
- Modal displays full owner context: confirmed, unconfirmed, declined, and candidate teams
|
||||
- Confirm, decline, or redirect ownership via the CARD API
|
||||
- Bare IPs are auto-resolved to CARD asset IDs (via host_id fast path or suffix guessing: CTEC, NATL, CHTR, COML, RESI, WIFI, VOIP)
|
||||
- IP address validation before mutation operations
|
||||
|
||||
**Queue-based actions:**
|
||||
- Add findings to the queue with workflow type CARD
|
||||
- Confirm, decline, or redirect from the queue panel
|
||||
- Queue items are marked complete on successful CARD action
|
||||
- update_token handling for safe concurrent operations
|
||||
|
||||
**Team assets endpoint:**
|
||||
- Paginated team asset lookup by disposition (confirmed, unconfirmed, candidate)
|
||||
- Used by the Granite enrichment batch endpoint for full data
|
||||
|
||||
---
|
||||
|
||||
### Granite Loader Sheet
|
||||
|
||||
Generate Granite Loader Sheets with CARD enrichment for network device workflows.
|
||||
|
||||
**Generation workflow:**
|
||||
1. Add findings to the queue with workflow type GRANITE
|
||||
2. Click **Loader Sheet** in the queue panel (or use the Loader Sheet button on the Reporting page)
|
||||
3. The modal fetches CARD data for each IP/host_id to enrich with NCIM, Qualys, and Netops Granite fields
|
||||
4. Review and edit per-row data with searchable picklists
|
||||
5. Export as formatted XLSX
|
||||
|
||||
**CARD enrichment fields:**
|
||||
- `equip_inst_id` — NCIM equipment instance ID
|
||||
- `hostname` — resolved hostname from CARD
|
||||
- `site_name` — NCIM site name
|
||||
- `mgmt_ip_asn` — management IP ASN
|
||||
- `responsible_team` — NCIM responsible team
|
||||
- `equipment_class` — equipment classification
|
||||
- `equip_template` — equipment template
|
||||
- `equip_status` — equipment status
|
||||
- `serial_number` — device serial number
|
||||
|
||||
**Features:**
|
||||
- Searchable picklists for teams, statuses, operation types (defined in `graniteLoaderPicklists.js`)
|
||||
- Column groups with configurable visibility (defined in `graniteLoaderConfig.js`)
|
||||
- Per-row inline editing before export
|
||||
- Batch enrichment: accepts up to 200 IPs or host_ids per request
|
||||
- When no team is specified, searches both NTS-AEO-STEAM and NTS-AEO-ACCESS-ENG
|
||||
|
||||
---
|
||||
|
||||
### Atlas Action Plans
|
||||
|
||||
Atlas InfoSec action plan tracking with per-host vulnerability mapping. Provides visibility into which hosts have active remediation, risk acceptance, or compensating control plans.
|
||||
|
||||
**AtlasBadge component:**
|
||||
- Appears on finding rows in the Reporting table when a host has one or more action plans
|
||||
- Badge colour indicates plan type: remediation, risk acceptance, or compensating control
|
||||
- Reads from local cache (`atlas_action_plans_cache` table) for instant rendering without API round-trips
|
||||
|
||||
**Slide-out panel:**
|
||||
- Click the AtlasBadge to open the Atlas detail panel
|
||||
- Shows all plans for the host with type, status, and metadata
|
||||
- Qualys vulnerability mapping per host (resolved via `/hosts/vulnerabilities` endpoint)
|
||||
|
||||
**Cache management:**
|
||||
- Local cache stores plan existence, count, and full plan JSON per host_id
|
||||
- Manual refresh triggers a re-fetch from the Atlas API and updates the cache
|
||||
- `atlas_known` flag indicates whether the host has been checked (avoids re-querying hosts with no plans)
|
||||
|
||||
**Plan operations (Admin/Standard_User):**
|
||||
- Create action plans: remediation, risk_acceptance, or compensating_control
|
||||
- Update existing plans (PATCH)
|
||||
- Refresh cache per host
|
||||
|
||||
---
|
||||
|
||||
### Finding Archive Tracking
|
||||
|
||||
Automatic detection of findings that disappear between Ivanti syncs, with lifecycle state tracking and anomaly logging.
|
||||
|
||||
**How it works:**
|
||||
- On each sync, findings present in the previous sync but absent from the current sync are classified as archived
|
||||
- If a previously archived finding reappears, it transitions to RETURNED
|
||||
- Findings that remain absent are eventually classified as CLOSED or CLOSED_GONE
|
||||
|
||||
**Lifecycle states:**
|
||||
| State | Meaning |
|
||||
|-------|---------|
|
||||
| ARCHIVED | Finding disappeared from Ivanti (first detection) |
|
||||
| RETURNED | Previously archived finding reappeared in a subsequent sync |
|
||||
| CLOSED | Finding confirmed closed by Ivanti (workflow completed) |
|
||||
| CLOSED_GONE | Finding disappeared and is confirmed gone (no workflow, long absence) |
|
||||
|
||||
**Anomaly detection:**
|
||||
- Each sync logs open/closed count deltas, newly archived count, and returned count
|
||||
- Significance threshold triggers the AnomalyBanner component on the Reporting page
|
||||
- Classification JSON tracks the breakdown of archive reasons
|
||||
|
||||
**BU reassignment tracking:**
|
||||
- Findings that change BU ownership between syncs are logged in `ivanti_finding_bu_history`
|
||||
- `IVANTI_MANAGED_BUS` env var defines which BUs are "managed" — findings leaving these BUs are classified as `bu_reassignment`
|
||||
- Return classification distinguishes between original finding restoration and new duplicates
|
||||
|
||||
**UI components:**
|
||||
- **AnomalyBanner** — alert bar on the Reporting page when significant sync anomalies are detected
|
||||
- **ArchiveSummaryBar** — state distribution summary (ARCHIVED / RETURNED / CLOSED counts)
|
||||
- Archive view with transition history per finding
|
||||
|
||||
---
|
||||
|
||||
### Knowledge Base
|
||||
|
||||
A document library for internal reference material — policies, runbooks, vendor advisories, and process guides.
|
||||
@@ -520,16 +800,19 @@ Bulk export tools for reports and data extracts. Available to Admin, Standard_Us
|
||||
|
||||
### Jira Tickets
|
||||
|
||||
A dedicated page for managing Jira Data Center tickets linked to CVE/vendor pairs. Accessible from the navigation drawer. Requires a configured Jira API connection (see [Configuration](#configuration)).
|
||||
A dedicated page for managing Jira Data Center tickets linked to CVE/vendor pairs and Ivanti queue items. Accessible from the navigation drawer. Requires a configured Jira API connection (see [Configuration](#configuration)).
|
||||
|
||||
**Ticket list**
|
||||
- View all tracked Jira tickets with status, CVE ID, vendor, summary, and Jira key
|
||||
- Filter by status or search by keyword
|
||||
- Click a Jira key to open the issue in Jira Data Center
|
||||
- Raw Jira status display — shows the actual Jira status field (no Open/In Progress/Closed mapping)
|
||||
|
||||
**Jira API operations (Admin/Standard_User)**
|
||||
- **Lookup** — search for any Jira issue by key and view its current status, assignee, and summary
|
||||
- **Create in Jira** — create a new Jira issue directly from the dashboard with project key, issue type, summary, and description; the resulting ticket is automatically linked to a CVE/vendor pair in the local database
|
||||
- **Create in Jira** — create a new Jira issue directly from the dashboard with project key, issue type, summary, and description; the resulting ticket is automatically linked to a CVE/vendor pair in the local database. CVE/Vendor fields are optional — tickets can be created with source context tracking only.
|
||||
- **Multi-item creation** — from the Ivanti Queue consolidation modal, create a single Jira ticket covering multiple findings
|
||||
- **Save to Dashboard** — save a Jira issue found via lookup to the local database
|
||||
- **Sync** — refresh a single ticket's status and summary from Jira, or bulk-sync all tracked tickets via JQL search
|
||||
- **Create / Edit / Delete** — manage local ticket records linking Jira keys to CVE/vendor pairs
|
||||
|
||||
@@ -539,7 +822,7 @@ A dedicated page for managing Jira Data Center tickets linked to CVE/vendor pair
|
||||
|
||||
**Rate limit monitoring (Admin)** — view current burst and daily rate limit usage against Charter's posted limits (60/minute burst, 1 440/day).
|
||||
|
||||
All Jira API calls are proxied through the backend. Credentials are never exposed to the browser. Rate limits are enforced client-side with inter-request delays (1s for GETs, 2s for writes). See `docs/jira-api-use-cases.md` for the full API compliance summary.
|
||||
All Jira API calls are proxied through the backend. Credentials are never exposed to the browser. Rate limits are enforced client-side with inter-request delays (1s for GETs, 2s for writes). See `docs/api/jira-api-use-cases.md` for the full API compliance summary.
|
||||
|
||||
---
|
||||
|
||||
@@ -556,6 +839,75 @@ Track Archer exception tickets (EXC numbers) linked to specific CVE/vendor pairs
|
||||
|
||||
---
|
||||
|
||||
### Archer Template Library
|
||||
|
||||
Template management system for Archer Risk Acceptance forms. Stores static content (Environment Overview, Segmentation, Mitigating Controls, and 5 additional sections) organised by Vendor, Platform, and Model hierarchy.
|
||||
|
||||
**Capabilities:**
|
||||
- Full CRUD — create, view, update, and delete templates
|
||||
- Clone existing templates into new vendor/platform/model combinations
|
||||
- Search/filter by vendor, platform, or model (case-insensitive)
|
||||
- Hierarchy browsing endpoints: list vendors, platforms per vendor, models per vendor+platform
|
||||
- Per-section copy-to-clipboard buttons in the inline view panel
|
||||
- Template selector integrated into the Ivanti Queue for Archer workflow items
|
||||
- Accessible from nav drawer (Template Mgr) and from Archer queue items
|
||||
|
||||
**Template sections (8 content fields, each max 10,000 chars):**
|
||||
- Environment Overview
|
||||
- Segmentation
|
||||
- Mitigating Controls
|
||||
- Additional Info
|
||||
- Charter Network Banner
|
||||
- Data Classification
|
||||
- Charter Network
|
||||
- Additional Access List
|
||||
|
||||
**Hierarchy:** Vendor > Platform > Model (unique constraint on the combination). Templates are sorted alphabetically by vendor, platform, model.
|
||||
|
||||
---
|
||||
|
||||
### In-App Notifications
|
||||
|
||||
Native notification system providing per-user alerts for system events without external dependencies (replaces previous Webex bot integration).
|
||||
|
||||
**Notification types:**
|
||||
- `issue_resolved` — GitLab feedback issue closed and deployed
|
||||
|
||||
**UI:**
|
||||
- NotificationBell component in the header with unread count badge
|
||||
- Click to view list of unread notifications (newest first, limited to 50)
|
||||
- Mark individual notifications as read, or mark all as read
|
||||
- Only the owning user can mark their own notifications
|
||||
|
||||
**Storage:** Notifications are stored in the `notifications` table with `user_id`, `username`, `type`, `title`, `message`, `issue_number`, and `read` flag.
|
||||
|
||||
---
|
||||
|
||||
### Feedback — GitLab Integration
|
||||
|
||||
In-app bug reports and feature requests submitted directly to the GitLab project as issues. Keeps the GitLab PAT server-side so credentials are never exposed to the browser.
|
||||
|
||||
**Submission workflow:**
|
||||
1. Click the feedback button in the nav drawer
|
||||
2. Select type: Bug Report or Feature Request
|
||||
3. Fill in title and description
|
||||
4. Optionally attach up to 3 screenshots (PNG, JPG, GIF, WebP — 5MB each)
|
||||
5. Submit — creates a GitLab issue with labels (`bug` or `enhancement`) and formatted description
|
||||
|
||||
**Issue lifecycle:**
|
||||
- GitLab webhook receiver (`POST /api/webhooks/gitlab`) listens for issue close events
|
||||
- When a feedback issue is closed, the submitter username is parsed from the issue description
|
||||
- An in-app notification is created for the submitter: "Your bug report has been resolved and deployed"
|
||||
- Webhook secret validation prevents unauthorized requests
|
||||
|
||||
**Configuration:**
|
||||
- `GITLAB_URL` — GitLab instance URL
|
||||
- `GITLAB_PROJECT_ID` — numeric project ID
|
||||
- `GITLAB_PAT` — project access token with `api` scope
|
||||
- `GITLAB_WEBHOOK_SECRET` — shared secret for webhook validation (set same value in GitLab webhook settings)
|
||||
|
||||
---
|
||||
|
||||
### Admin Panel
|
||||
|
||||
The Admin Panel is a full-page, tabbed interface accessible only to Admin-group users. It replaces the previous inline modal rendering and follows the dashboard's dark tactical intelligence theme. Three tabs provide consolidated access to administrative functions:
|
||||
@@ -705,7 +1057,9 @@ All endpoints are prefixed with `/api`. All endpoints except `/api/auth/login` a
|
||||
|
||||
| Method | Path | Group | Description |
|
||||
|---|---|---|---|
|
||||
| GET | `/api/ivanti/archive` | Any | Get finding archive data for severity score drift tracking |
|
||||
| GET | `/api/ivanti/archive` | Any | List archive records; optional filters: `state` (ACTIVE/ARCHIVED/RETURNED/CLOSED), `teams` (comma-separated BU names) |
|
||||
| GET | `/api/ivanti/archive/anomalies` | Any | Sync anomaly log (significant count deltas and classification data) |
|
||||
| GET | `/api/ivanti/archive/transitions/:archiveId` | Any | Transition history for a specific archive record |
|
||||
|
||||
### Compliance
|
||||
|
||||
@@ -744,6 +1098,91 @@ All endpoints are prefixed with `/api`. All endpoints except `/api/auth/login` a
|
||||
| PUT | `/api/archer-tickets/:id` | Admin, Standard_User | Update an Archer ticket |
|
||||
| DELETE | `/api/archer-tickets/:id` | Admin, Standard_User | Delete an Archer ticket (ownership + compliance check for Standard_User) |
|
||||
|
||||
### Archer Templates
|
||||
|
||||
| Method | Path | Group | Description |
|
||||
|---|---|---|---|
|
||||
| GET | `/api/archer-templates` | Any | List templates; optional filters: `search`, `vendor`, `platform`, `model` |
|
||||
| GET | `/api/archer-templates/:id` | Any | Get a single template by ID |
|
||||
| POST | `/api/archer-templates` | Admin, Standard_User | Create a new template |
|
||||
| PUT | `/api/archer-templates/:id` | Admin, Standard_User | Update a template (partial update supported) |
|
||||
| DELETE | `/api/archer-templates/:id` | Admin, Standard_User | Delete a template |
|
||||
| POST | `/api/archer-templates/:id/clone` | Admin, Standard_User | Clone a template with new vendor/platform/model |
|
||||
| GET | `/api/archer-templates/hierarchy/vendors` | Any | List distinct vendor names |
|
||||
| GET | `/api/archer-templates/hierarchy/platforms` | Any | List platforms for a vendor; query: `vendor` |
|
||||
| GET | `/api/archer-templates/hierarchy/models` | Any | List models for vendor+platform; query: `vendor`, `platform` |
|
||||
|
||||
### CARD Asset Ownership
|
||||
|
||||
| Method | Path | Group | Description |
|
||||
|---|---|---|---|
|
||||
| GET | `/api/card/status` | Any | Check if CARD API is configured |
|
||||
| GET | `/api/card/teams` | Admin, Standard_User | List all CARD teams |
|
||||
| GET | `/api/card/teams/:teamName/assets` | Admin, Standard_User | Paginated team assets by disposition |
|
||||
| GET | `/api/card/owner/:assetId` | Admin, Standard_User | Get owner record for an asset |
|
||||
| GET | `/api/card/owner-lookup/:ip` | Admin, Standard_User | Resolve IP to asset and return owner data; `?quick=1` for tooltip mode, `?hostId=N` for fast path |
|
||||
| POST | `/api/card/owner/:assetId/confirm` | Admin, Standard_User | Direct confirm ownership (no queue item) |
|
||||
| POST | `/api/card/owner/:assetId/decline` | Admin, Standard_User | Direct decline ownership (no queue item) |
|
||||
| POST | `/api/card/owner/:assetId/redirect` | Admin, Standard_User | Direct redirect between teams (no queue item) |
|
||||
| GET | `/api/card/asset-search/:hostId` | Admin, Standard_User | Search CARD by Ivanti Host ID (deep_search) |
|
||||
| POST | `/api/card/enrich-batch` | Admin, Standard_User | Batch lookup IPs/host_ids for Granite loader fields (max 200) |
|
||||
| POST | `/api/card/queue/:queueItemId/confirm` | Admin, Standard_User | Confirm ownership for a queue item |
|
||||
| POST | `/api/card/queue/:queueItemId/decline` | Admin, Standard_User | Decline ownership for a queue item |
|
||||
| POST | `/api/card/queue/:queueItemId/redirect` | Admin, Standard_User | Redirect ownership for a queue item |
|
||||
|
||||
### Atlas Action Plans
|
||||
|
||||
| Method | Path | Group | Description |
|
||||
|---|---|---|---|
|
||||
| GET | `/api/atlas/hosts/:hostId/plans` | Any | Get action plans for a host (from cache or API) |
|
||||
| PUT | `/api/atlas/hosts/:hostId/plans` | Admin, Standard_User | Create a new action plan |
|
||||
| PATCH | `/api/atlas/hosts/:hostId/plans` | Admin, Standard_User | Update an existing plan |
|
||||
| POST | `/api/atlas/hosts/:hostId/refresh` | Admin, Standard_User | Force cache refresh from Atlas API |
|
||||
|
||||
### VCL Multi-Vertical (CCP Metrics)
|
||||
|
||||
| Method | Path | Group | Description |
|
||||
|---|---|---|---|
|
||||
| POST | `/api/compliance/vcl-multi/preview` | Admin, Standard_User | Parse xlsx files, extract verticals, compute scoped diffs |
|
||||
| POST | `/api/compliance/vcl-multi/commit` | Admin, Standard_User | Commit previewed files in a single transaction |
|
||||
| GET | `/api/compliance/vcl-multi/stats` | Any | Aggregated cross-vertical executive summary |
|
||||
| GET | `/api/compliance/vcl-multi/trend` | Any | Monthly compliance trend with linear regression forecast |
|
||||
| GET | `/api/compliance/vcl-multi/burndown` | Any | Aggregated cross-vertical burndown forecast |
|
||||
| GET | `/api/compliance/vcl-multi/verticals` | Any | List known verticals |
|
||||
| GET | `/api/compliance/vcl-multi/metrics` | Any | All metrics aggregated across verticals |
|
||||
| GET | `/api/compliance/vcl-multi/metrics-list` | Any | Distinct metrics with active non-compliant device counts |
|
||||
| GET | `/api/compliance/vcl-multi/metric/:id/stats` | Any | Per-metric summary statistics and donut breakdown |
|
||||
| GET | `/api/compliance/vcl-multi/metric/:id/verticals` | Any | Per-vertical breakdown for a metric with sub-teams |
|
||||
| GET | `/api/compliance/vcl-multi/metric/:id/trend` | Any | Per-metric monthly trend with forecast |
|
||||
| GET | `/api/compliance/vcl-multi/vertical/:code/metrics` | Any | Per-metric breakdown for a specific vertical |
|
||||
| GET | `/api/compliance/vcl-multi/vertical/:code/metric/:metricId/devices` | Any | Device list for a vertical+metric; `?team=X` |
|
||||
| GET | `/api/compliance/vcl-multi/vertical/:code/burndown` | Any | Burndown forecast for a specific vertical |
|
||||
| GET | `/api/compliance/vcl-multi/uploads` | Any | Upload history (most recent 100) |
|
||||
| DELETE | `/api/compliance/vcl-multi/vertical/:code` | Admin | Delete all data for a vertical |
|
||||
| DELETE | `/api/compliance/vcl-multi/upload/:uploadId` | Admin | Rollback a specific upload (must be most recent for that vertical) |
|
||||
| DELETE | `/api/compliance/vcl-multi/all` | Admin | Delete all multi-vertical data |
|
||||
|
||||
### Notifications
|
||||
|
||||
| Method | Path | Group | Description |
|
||||
|---|---|---|---|
|
||||
| GET | `/api/notifications` | Any | Get unread notifications for current user (max 50) |
|
||||
| GET | `/api/notifications/count` | Any | Get unread count for badge display |
|
||||
| PATCH | `/api/notifications/:id/read` | Any | Mark a single notification as read (own only) |
|
||||
| POST | `/api/notifications/read-all` | Any | Mark all notifications as read |
|
||||
|
||||
### Feedback
|
||||
|
||||
| Method | Path | Group | Description |
|
||||
|---|---|---|---|
|
||||
| POST | `/api/feedback` | Any | Submit bug report or feature request to GitLab (multipart with optional screenshots) |
|
||||
|
||||
### Webhooks
|
||||
|
||||
| Method | Path | Group | Description |
|
||||
|---|---|---|---|
|
||||
| POST | `/api/webhooks/gitlab` | Public | GitLab issue webhook receiver (validated by `x-gitlab-token` header) |
|
||||
|
||||
### Users (Admin only)
|
||||
|
||||
| Method | Path | Group | Description |
|
||||
@@ -776,10 +1215,16 @@ All endpoints are prefixed with `/api`. All endpoints except `/api/auth/login` a
|
||||
cve-dashboard/
|
||||
├── start-servers.sh # Start backend + frontend via systemd
|
||||
├── stop-servers.sh # Stop both systemd services
|
||||
├── configure.js # Interactive configuration wizard
|
||||
├── docker-compose.yml # PostgreSQL 16 container definition
|
||||
├── package.json # Root package.json (backend dependencies)
|
||||
├── scripts/
|
||||
│ └── deploy-postgres.sh # One-time deployment: container, schema, migration
|
||||
│ ├── deploy-postgres.sh # One-time deployment: container, schema, migration
|
||||
│ └── reset-and-migrate.sh # Dev utility: reset DB and re-run migrations
|
||||
├── deploy/
|
||||
│ ├── cve-backend-production.service
|
||||
│ ├── cve-backend-staging.service
|
||||
│ └── setup-staging.sh
|
||||
├── systemd/ # systemd unit files for auto-start on boot
|
||||
│ ├── cve-backend.service
|
||||
│ └── cve-frontend.service
|
||||
@@ -789,6 +1234,7 @@ cve-dashboard/
|
||||
│ ├── db.js # PostgreSQL connection pool (pg)
|
||||
│ ├── db-schema.sql # Complete DDL for fresh Postgres setup
|
||||
│ ├── setup-postgres.js # Schema initializer (runs db-schema.sql)
|
||||
│ ├── setup.js # One-time DB init + default admin creation
|
||||
│ ├── uploads/ # File storage root (gitignored)
|
||||
│ │ ├── <CVE-ID>/<vendor>/ # CVE documents
|
||||
│ │ ├── knowledge_base/ # Knowledge base documents
|
||||
@@ -800,20 +1246,33 @@ cve-dashboard/
|
||||
│ │ ├── nvdLookup.js # NVD API proxy
|
||||
│ │ ├── knowledgeBase.js # Knowledge base document management
|
||||
│ │ ├── archerTickets.js # Archer EXC ticket CRUD
|
||||
│ │ ├── archerTemplates.js # Archer template library CRUD + clone + hierarchy
|
||||
│ │ ├── ivantiWorkflows.js # Ivanti workflow batch sync and cache
|
||||
│ │ ├── ivantiFindings.js # Ivanti host findings sync, notes, overrides, FP counts
|
||||
│ │ ├── ivantiTodoQueue.js # Ivanti Queue — personal FP/Archer/CARD staging list
|
||||
│ │ ├── ivantiArchive.js # Finding archive for severity score drift
|
||||
│ │ ├── jiraTickets.js # Jira ticket CRUD + Jira REST API integration (lookup, sync, create)
|
||||
│ │ └── compliance.js # AEO compliance upload, diff, device tracking, notes
|
||||
│ │ ├── ivantiTodoQueue.js # Ivanti Queue — personal staging list
|
||||
│ │ ├── ivantiArchive.js # Finding archive tracking, transitions, anomaly log
|
||||
│ │ ├── ivantiFpWorkflow.js # FP workflow submission to Ivanti API + lifecycle
|
||||
│ │ ├── compliance.js # AEO compliance upload, diff, device tracking, notes
|
||||
│ │ ├── vclMultiVertical.js # VCL/CCP multi-vertical compliance reporting
|
||||
│ │ ├── atlas.js # Atlas action plan proxy + cache
|
||||
│ │ ├── jiraTickets.js # Jira ticket CRUD + Jira REST API integration
|
||||
│ │ ├── cardApi.js # CARD ownership proxy, mutation, asset-search, enrich
|
||||
│ │ ├── notifications.js # In-app notification system
|
||||
│ │ ├── feedback.js # Bug reports/feature requests to GitLab
|
||||
│ │ └── webhooks.js # GitLab webhook receiver for issue lifecycle
|
||||
│ ├── middleware/
|
||||
│ │ └── auth.js # requireAuth and requireGroup middleware
|
||||
│ ├── helpers/
|
||||
│ │ ├── auditLog.js # logAudit helper (fire-and-forget)
|
||||
│ │ ├── driftChecker.js # Schema drift detection: compareSchemaToDrift(), loadConfig(), reconcileConfig()
|
||||
│ │ ├── cardApi.js # CARD API — OAuth token, owner, confirm/decline/redirect, asset-search
|
||||
│ │ ├── ivantiApi.js # Ivanti API HTTP helpers (multipart, JSON, form POST)
|
||||
│ │ └── jiraApi.js # Jira Data Center REST API helpers (Basic/PAT auth, rate limiting)
|
||||
│ ├── migrations/ # Legacy SQLite migration scripts (not needed for Postgres)
|
||||
│ │ ├── atlasApi.js # Atlas action plan API (Basic auth)
|
||||
│ │ ├── jiraApi.js # Jira Data Center REST API helpers (Basic/PAT auth, rate limiting)
|
||||
│ │ ├── driftChecker.js # Schema drift detection: compareSchemaToDrift(), loadConfig(), reconcileConfig()
|
||||
│ │ ├── vclHelpers.js # VCL metric calculation helpers (burndown, forecast, dedup)
|
||||
│ │ └── teams.js # Team validation helpers
|
||||
│ ├── migrations/ # Sequential migration scripts (idempotent)
|
||||
│ │ └── run-all.js # Run all migrations in order
|
||||
│ └── scripts/
|
||||
│ ├── migrate-to-postgres.js # One-time SQLite → Postgres data migration
|
||||
│ ├── compliance_config.json # Shared parser config (metric_categories, core_cols, skip_sheets)
|
||||
@@ -828,30 +1287,60 @@ cve-dashboard/
|
||||
├── App.css # Global styles and CSS variables
|
||||
├── contexts/
|
||||
│ └── AuthContext.js # Auth state provider (login, logout, group helpers)
|
||||
├── utils/
|
||||
│ ├── graniteLoaderConfig.js # Granite column definitions and groups
|
||||
│ ├── graniteLoaderExport.js # XLSX generation logic
|
||||
│ └── graniteLoaderPicklists.js # Searchable dropdown options
|
||||
└── components/
|
||||
├── LoginForm.js # Login page
|
||||
├── NavDrawer.js # Side navigation drawer (pages + Admin Panel link for Admin group)
|
||||
├── UserMenu.js # User dropdown in header (shows group badge)
|
||||
├── CalendarWidget.js # Due-date calendar with Ivanti finding indicators
|
||||
├── UserManagement.js # Admin user management modal (quick-access from UserMenu)
|
||||
├── AuditLog.js # Admin audit log modal (quick-access from UserMenu)
|
||||
├── NavDrawer.js # Side navigation drawer
|
||||
├── UserMenu.js # User dropdown (shows group badge)
|
||||
├── UserProfilePanel.js # User profile and password change
|
||||
├── CalendarWidget.js # Due-date calendar with finding indicators
|
||||
├── UserManagement.js # Admin user management modal
|
||||
├── AuditLog.js # Admin audit log modal
|
||||
├── NvdSyncModal.js # Bulk NVD sync dialog
|
||||
├── KnowledgeBaseModal.js # Knowledge base upload/list modal
|
||||
├── KnowledgeBaseViewer.js # Inline document viewer (sandboxed iframe, sanitized markdown)
|
||||
├── ConfirmModal.js # Themed confirmation dialog (replaces window.confirm)
|
||||
├── CveTooltip.js # Hover tooltip for CVE badges (portal-rendered, cached)
|
||||
├── RedirectModal.js # Queue item redirect modal (workflow type + vendor selection)
|
||||
├── KnowledgeBaseViewer.js # Inline document viewer
|
||||
├── CardOwnerTooltip.js # CARD ownership hover tooltip
|
||||
├── CardDetailModal.js # CARD asset detail (from reporting)
|
||||
├── CardActionModal.js # CARD confirm/decline/redirect (from queue)
|
||||
├── RedirectModal.js # Queue item redirect modal
|
||||
├── LoaderModal.js # Granite Loader Sheet generator
|
||||
├── SearchableSelect.js # Reusable searchable dropdown
|
||||
├── AtlasBadge.js # Atlas action plan indicator badge
|
||||
├── AtlasIcon.js # Atlas icon component
|
||||
├── AtlasSlideOutPanel.js # Atlas plan detail panel
|
||||
├── AdminScopeToggle.js # BU scope toggle
|
||||
├── ConfirmModal.js # Themed confirmation dialog
|
||||
├── ConsolidationModal.js # Multi-item Jira ticket consolidation
|
||||
├── CveTooltip.js # Hover tooltip for CVE badges
|
||||
├── DeleteConfirmModal.js # Delete confirmation with details
|
||||
├── FeedbackModal.js # Bug report/feature request submission
|
||||
├── NotificationBell.js # Notification bell with unread count
|
||||
├── RemediationModal.js # Remediation plan editing
|
||||
├── TemplateFormModal.js # Archer template create/edit form
|
||||
├── TemplateSelector.js # Archer template picker for queue items
|
||||
└── pages/
|
||||
├── AdminPage.js # Admin panel: user management, audit log, system info
|
||||
├── AdminPage.js # Admin panel: users, audit log, system info
|
||||
├── ReportingPage.js # Host findings: charts, table, queue, export
|
||||
├── IvantiTodoQueuePage.js # Full-page queue view
|
||||
├── IvantiCountsChart.js # Ivanti counts history chart
|
||||
├── AnomalyBanner.js # Sync anomaly alert banner
|
||||
├── ArchiveSummaryBar.js # Finding archive state distribution
|
||||
├── CompliancePage.js # AEO compliance: metric cards, device table
|
||||
├── ComplianceUploadModal.js # xlsx upload with diff preview
|
||||
├── ComplianceUploadModal.js # xlsx upload with drift check + diff preview
|
||||
├── ComplianceDetailPanel.js # Per-device metrics, history, notes
|
||||
├── ComplianceChartsPanel.js # Compliance trend charts
|
||||
├── IvantiCountsChart.js # Ivanti counts history chart
|
||||
├── ArchiveSummaryBar.js # Finding archive summary
|
||||
├── JiraPage.js # Jira ticket management and Jira API integration
|
||||
├── KnowledgeBasePage.js # Knowledge base page
|
||||
├── CCPMetricsPage.js # CCP Metrics: multi-vertical executive view
|
||||
├── VCLReportPage.js # VCL exec report page
|
||||
├── MetricInfoPanel.js # Metric detail drill-down panel
|
||||
├── BulkUploadModal.js # Bulk VCL upload
|
||||
├── MultiVerticalUploadModal.js # Multi-vertical upload modal
|
||||
├── ArcherPage.js # Archer tickets management
|
||||
├── ArcherTemplatePage.js # Archer template library
|
||||
├── JiraPage.js # Jira ticket management + API integration
|
||||
├── KnowledgeBasePage.js # Knowledge base page
|
||||
└── ExportsPage.js # Exports page (group-gated)
|
||||
```
|
||||
|
||||
@@ -891,9 +1380,31 @@ All tables are defined in `backend/db-schema.sql` and created by `setup-postgres
|
||||
|
||||
**`ivanti_finding_overrides`** — Editor-applied overrides for `hostName` and `dns` fields. `UNIQUE(finding_id, field)`.
|
||||
|
||||
**`ivanti_todo_queue`** — Personal per-user queue of findings staged for FP, Archer, or CARD processing. Keyed by `(user_id, finding_id)`. Completed items can be redirected to a different workflow type via `POST /:id/redirect`, which creates a new pending item preserving the original finding data.
|
||||
**`ivanti_todo_queue`** — Personal per-user queue of findings staged for FP, Archer, CARD, GRANITE, DECOM, or Remediate processing. Keyed by `(user_id, finding_id)`. Workflow type constraint: `FP`, `Archer`, `CARD`, `GRANITE`, `DECOM`. Completed items can be redirected to a different workflow type via `POST /:id/redirect`, which creates a new pending item preserving the original finding data.
|
||||
|
||||
**`ivanti_fp_submissions`** — Record of FP workflow submissions to the Ivanti API. Tracks user, workflow batch ID, form fields, finding IDs, queue item IDs, attachment results, and submission status (success/partial/failed). Rejected submissions can be dismissed (`dismissed_at`) or re-queued to the todo queue under a different workflow type (`requeued_at`).
|
||||
**`ivanti_fp_submissions`** — Record of FP workflow submissions to the Ivanti API. Tracks user, workflow batch ID, form fields, finding IDs, queue item IDs, attachment results, and submission status (success/partial/failed). Lifecycle status tracks the workflow through: submitted, approved, rejected, rework, resubmitted. Rejected submissions can be dismissed (`dismissed_at`) or re-queued to the todo queue under a different workflow type (`requeued_at`).
|
||||
|
||||
**`ivanti_fp_submission_history`** — Edit history for FP submissions. Tracks change_type (created, fields_updated, findings_added, attachments_added, status_changed) with change_details_json.
|
||||
|
||||
**`ivanti_finding_archives`** — Archived finding records with lifecycle state tracking. States: ARCHIVED, RETURNED, CLOSED, CLOSED_GONE. Tracks severity at time of archival and transition timestamps.
|
||||
|
||||
**`ivanti_archive_transitions`** — State transition history for archived findings. Records from_state, to_state, severity_at_transition, and reason for each transition.
|
||||
|
||||
**`ivanti_sync_anomaly_log`** — Sync anomaly detection log. Records count deltas, newly archived/returned counts, classification breakdown, and significance flag per sync.
|
||||
|
||||
**`ivanti_finding_bu_history`** — BU reassignment history per finding. Records previous_bu, new_bu, and detection timestamp.
|
||||
|
||||
**`ivanti_counts_history_by_bu`** — Per-BU historical open/closed counts, enabling per-BU trend lines on the findings chart.
|
||||
|
||||
**`atlas_action_plans_cache`** — Cached Atlas action plan data for badge rendering. Stores host_id, has_action_plan flag, plan_count, plans_json, and atlas_known flag. Indexed on host_id.
|
||||
|
||||
**`archer_templates`** — Archer template library. Vendor/platform/model hierarchy with 8 section content fields (environment_overview, segmentation, mitigating_controls, additional_info, charter_network_banner, data_classification, charter_network, additional_access_list). `UNIQUE(vendor, platform, model)`.
|
||||
|
||||
**`notifications`** — In-app notifications. Per-user notifications with type, title, message, issue_number, and read flag. Used by the NotificationBell component.
|
||||
|
||||
**`vcl_multi_vertical_summary`** — Per-metric summary data from VCL multi-vertical uploads. Stores metric_id, metric_desc, category, team, priority, non_compliant, compliant, total, compliance_pct, target, and status per upload per vertical. Used for executive reporting without recalculating from items.
|
||||
|
||||
**`compliance_snapshots`** — Monthly compliance snapshots per vertical. Used for trend charts and linear regression forecasting.
|
||||
|
||||
**`compliance_uploads`** — Record of each compliance xlsx upload: filename, report date, uploader, timestamp, and new/resolved/recurring counts.
|
||||
|
||||
@@ -1022,32 +1533,63 @@ After upgrading, clear your browser cookies and log in fresh — session format
|
||||
|
||||
## Migrations
|
||||
|
||||
> **Note:** The migration scripts in `backend/migrations/` are legacy SQLite migrations. They are not needed for PostgreSQL deployments — the complete schema is defined in `backend/db-schema.sql` and applied by `setup-postgres.js`. These scripts are retained for reference and for any remaining SQLite-based environments.
|
||||
|
||||
For deployments still on SQLite, run them in the listed order. All are idempotent and safe to re-run.
|
||||
> **Note:** The migration scripts in `backend/migrations/` are used for both PostgreSQL and legacy SQLite deployments. Run them via `node migrations/run-all.js` which executes all migrations in order. All are idempotent and safe to re-run.
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
node migrations/run-all.js
|
||||
```
|
||||
|
||||
For manual execution or debugging, the individual scripts in order:
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
node migrations/add_user_groups.js
|
||||
node migrations/add_user_ivanti_identity.js
|
||||
node migrations/add_user_bu_teams.js
|
||||
node migrations/add_knowledge_base_table.js
|
||||
node migrations/add_archer_tickets_table.js
|
||||
node migrations/add_ivanti_sync_table.js
|
||||
node migrations/add_ivanti_findings_tables.js
|
||||
node migrations/add_ivanti_todo_queue_table.js
|
||||
node migrations/add_card_workflow_type.js
|
||||
node migrations/add_todo_queue_ip_address.js
|
||||
node migrations/add_todo_queue_hostname.js
|
||||
node migrations/add_compliance_tables.js
|
||||
node migrations/add_finding_archive_tables.js
|
||||
node migrations/add_archer_tickets_timestamps.js
|
||||
node migrations/add_ivanti_findings_ipv6_columns.js
|
||||
node migrations/add_ivanti_counts_history_table.js
|
||||
node migrations/add_ivanti_todo_queue_table.js
|
||||
node migrations/add_todo_queue_hostname.js
|
||||
node migrations/add_todo_queue_ip_address.js
|
||||
node migrations/add_granite_workflow_type.js
|
||||
node migrations/add_card_workflow_type.js
|
||||
node migrations/add_decom_workflow_type.js
|
||||
node migrations/add_remediate_workflow_type.js
|
||||
node migrations/add_compliance_tables.js
|
||||
node migrations/add_compliance_notes_group_id.js
|
||||
node migrations/add_compliance_history_metric_id.js
|
||||
node migrations/add_compliance_item_history.js
|
||||
node migrations/add_fp_submissions_table.js
|
||||
node migrations/add_user_groups.js
|
||||
node migrations/add_created_by_columns.js
|
||||
node migrations/add_fp_submission_editing.js
|
||||
node migrations/add_fp_submissions_dismissed.js
|
||||
node migrations/add_fp_submissions_requeued_at.js
|
||||
node migrations/add_granite_workflow_type.js
|
||||
node migrations/add_compliance_notes_group_id.js
|
||||
node migrations/add_archer_tickets_table.js
|
||||
node migrations/add_archer_tickets_timestamps.js
|
||||
node migrations/add_archer_templates_table.js
|
||||
node migrations/add_atlas_action_plans_cache.js
|
||||
node migrations/add_atlas_known_column.js
|
||||
node migrations/add_finding_archive_tables.js
|
||||
node migrations/add_closed_gone_state.js
|
||||
node migrations/add_return_classification.js
|
||||
node migrations/add_sync_anomaly_tables.js
|
||||
node migrations/add_flexible_jira_ticket_creation.js
|
||||
node migrations/add_multi_item_jira_ticket.js
|
||||
node migrations/add_jira_sync_columns.js
|
||||
node migrations/add_jira_sync_columns_pg.js
|
||||
node migrations/drop_jira_status_check_constraint.js
|
||||
node migrations/add_notifications_table.js
|
||||
node migrations/add_created_by_columns.js
|
||||
node migrations/add_queue_remediation_notes_table.js
|
||||
node migrations/add_vcl_multi_vertical.js
|
||||
node migrations/add_vcl_reporting_columns.js
|
||||
node migrations/add_vcl_vertical_metadata.js
|
||||
node migrations/backfill_anomaly_log.js
|
||||
node migrations/backfill_return_classification.js
|
||||
node migrations/reclassify_bu_roundtrips.js
|
||||
```
|
||||
|
||||
For deployments upgrading from an older schema, the following legacy migration scripts are also available in `backend/`:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Security Audit Tracker — STEAM Security Dashboard
|
||||
|
||||
**Last scan:** 2026-04-20
|
||||
**Last scan:** 2026-06-04
|
||||
**Scope:** Full repository — backend routes, middleware, helpers, scripts, frontend components
|
||||
**Baseline:** `docs/security-audit-2026-04-01.md` (31 findings), `docs/security-remediation-plan.md` (17 prioritised items)
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
|
||||
- [Remediation Status — April 1 Audit](#remediation-status--april-1-audit)
|
||||
- [New Findings — April 20 Scan](#new-findings--april-20-scan)
|
||||
- [New Findings — June 4 Scan](#new-findings--june-4-scan)
|
||||
- [Open Finding Summary](#open-finding-summary)
|
||||
- [Positive Security Observations](#positive-security-observations)
|
||||
- [Scan Metadata](#scan-metadata)
|
||||
@@ -54,37 +55,37 @@ Cross-reference of the 31 original findings against the current codebase. Status
|
||||
| M-3 | No rate limiting on NVD API proxy | **Open** | No server-side cache or per-user rate limit on `/api/nvd/lookup` |
|
||||
| M-4 | Admin self-demotion check uses loose equality | **Fixed** | `users.js` — uses `String(userId) === String(req.user.id)` |
|
||||
| M-5 | Missing hostname format validation | **Fixed** | `compliance.js` POST /notes — regex validation `^[a-zA-Z0-9._-]+$` |
|
||||
| M-6 | Vendor field validated before trim | **Open** | `ivantiTodoQueue.js:8` — `isValidVendor()` checks length before trim |
|
||||
| M-7 | Unsanitized original filename in temp JSON | **Open** | `compliance.js:344` — `req.file.originalname` passed directly |
|
||||
| M-6 | Vendor field validated before trim | **Fixed** | `ivantiTodoQueue.js:11-14` — `isValidVendor()` now trims before length check |
|
||||
| M-7 | Unsanitized original filename in temp JSON | **Open** | `compliance.js:347` — `req.file.originalname.replace(/[^\w.\-() ]/g, '_')` sanitizes in temp JSON, but line 355 returns raw `req.file.originalname` to client |
|
||||
| M-8 | Hardcoded frontend IP in CSP header | **Fixed** | `knowledgeBase.js:302` — reads from `CORS_ORIGINS` env var |
|
||||
| M-9 | API error messages forwarded to UI | **Open** | Frontend still uses `alert(err.message)` in several places |
|
||||
| M-10 | User data in window.confirm dialogs | **Open** | Frontend still uses `window.confirm` with user-supplied data |
|
||||
| M-9 | API error messages forwarded to UI | **Open** | Frontend still uses `alert(err.message)` in App.js, UserManagement.js, KnowledgeBasePage.js, JiraPage.js, and AdminPage.js |
|
||||
| M-10 | User data in window.confirm dialogs | **Partial** | App.js replaced with `ConfirmModal`. `JiraPage.js:430` still uses `window.confirm('Delete this Jira ticket record?')` — static string, reduced risk |
|
||||
|
||||
### Low / Info Findings
|
||||
|
||||
| ID | Title | Status | Evidence |
|
||||
|---|---|---|---|
|
||||
| L-1 | Silent ROLLBACK on transaction failure | **Open** | `compliance.js:167` — `.catch(() => {})` still swallows errors |
|
||||
| L-1 | Silent ROLLBACK on transaction failure | **Fixed** | `compliance.js` — no `.catch(() => {})` patterns remain; ROLLBACK is followed by `throw err` |
|
||||
| L-2 | Fire-and-forget audit logging | **Partial** | `auditLog.js` — now logs to `console.error` on failure, but no alerting |
|
||||
| L-3 | Async temp file cleanup with no error handling | **Open** | `compliance.js` — `fs.unlink(path, () => {})` still used |
|
||||
| L-3 | Async temp file cleanup with no error handling | **Open** | `compliance.js` — `fs.unlink(path, () => {})` still used in 5 locations |
|
||||
| L-4 | IVANTI_SKIP_TLS with no startup warning | **Open** | No startup warning when `IVANTI_SKIP_TLS=true` |
|
||||
| L-5 | console.error in production frontend | **Open** | No environment guard on console.error calls |
|
||||
| L-6 | localStorage column config lacks structural validation | **Open** | No change observed |
|
||||
| L-5 | console.error in production frontend | **Open** | No environment guard on console.error calls — 30+ instances across frontend |
|
||||
| L-6 | localStorage column config lacks structural validation | **Open** | `ReportingPage.js:65-80` — parses JSON with try/catch but validates only `Array.isArray(saved)`, not element structure |
|
||||
|
||||
### Remediation Plan Items (not in original 31)
|
||||
|
||||
| ID | Title | Status | Evidence |
|
||||
|---|---|---|---|
|
||||
| RP-1 | Authenticate /uploads static file access | **Open** | `server.js:127` — `express.static('uploads')` still unauthenticated |
|
||||
| RP-1 | Authenticate /uploads static file access | **Fixed** | `server.js:127` — `requireAuth()` middleware applied before `express.static('uploads')` |
|
||||
| RP-2 | Sanitize Mermaid SVG output with DOMPurify | **Open** | `KnowledgeBaseViewer.js:38` — `innerHTML = svg` without DOMPurify |
|
||||
| RP-3 | Strip server file paths from compliance preview response | **Open** | `compliance.js:342` — full `tempFilePath` returned to client |
|
||||
| RP-4 | Add SESSION_SECRET to .env.example | **Open** | `.env.example` — no `SESSION_SECRET` entry |
|
||||
| RP-3 | Strip server file paths from compliance preview response | **Fixed** | `compliance.js` and `vclMultiVertical.js` — preview returns only the temp filename; commit handler reconstructs full path via `path.join(TEMP_DIR, path.basename(tempFile))` |
|
||||
| RP-4 | Add SESSION_SECRET to .env.example | **Fixed** | `.env.example` — `SESSION_SECRET=` with generation comment present |
|
||||
|
||||
---
|
||||
|
||||
## New Findings — April 20 Scan
|
||||
|
||||
Findings discovered in this scan that were not present in the April 1 audit.
|
||||
Findings discovered in the April 20 scan that were not present in the April 1 audit.
|
||||
|
||||
---
|
||||
|
||||
@@ -94,8 +95,8 @@ Findings discovered in this scan that were not present in the April 1 audit.
|
||||
|
||||
All three `logAudit` calls in the Archer tickets router omit the `username` field:
|
||||
|
||||
```js
|
||||
logAudit(db, {
|
||||
```javascript
|
||||
logAudit({
|
||||
userId: req.user.id,
|
||||
action: 'CREATE_ARCHER_TICKET',
|
||||
// username: req.user.username ← missing
|
||||
@@ -113,60 +114,19 @@ The `auditLog.js` helper defaults missing username to `'unknown'`, so all Archer
|
||||
|
||||
### N-2 — `migrate-to-1.1.js` Contains Hardcoded Admin Password (Medium)
|
||||
|
||||
**File:** `backend/migrate-to-1.1.js:246`
|
||||
|
||||
```js
|
||||
const passwordHash = await bcrypt.hash('admin123', 10);
|
||||
```
|
||||
|
||||
While `setup.js` was fixed to generate random passwords (H-8), the migration script still hardcodes `admin123`. If this migration is run on an existing deployment, it resets the admin password to a known value.
|
||||
|
||||
**Impact:** Running the migration on a production system resets the admin account to a publicly known password.
|
||||
|
||||
**Fix:** Either generate a random password (matching `setup.js` pattern) or skip admin creation if the user already exists.
|
||||
**Status:** **Fixed** — file removed from codebase (confirmed 2026-06-04)
|
||||
|
||||
---
|
||||
|
||||
### N-3 — Compliance Preview Returns Full Server Filesystem Path (Medium)
|
||||
### N-3 — Compliance Preview Returns Full Server Filesystem Path (Medium) — FIXED
|
||||
|
||||
**File:** `backend/routes/compliance.js:342`
|
||||
|
||||
```js
|
||||
tempFile: tempFilePath,
|
||||
```
|
||||
|
||||
The preview endpoint returns the full server-side path (e.g. `/home/cve-dashboard/backend/uploads/temp/compliance_preview_...json`) to the frontend. The commit endpoint then receives this path back and reads the file. This exposes the server's directory structure to any authenticated user.
|
||||
|
||||
**Impact:** Information disclosure — authenticated users learn the server's absolute filesystem layout, which aids further exploitation.
|
||||
|
||||
**Fix:** Return only the filename. Reconstruct the full path server-side in the commit handler:
|
||||
```js
|
||||
tempFile: tempFilename, // just the basename
|
||||
// In commit handler:
|
||||
const tempFile = path.join(TEMP_DIR, path.basename(req.body.tempFile));
|
||||
```
|
||||
**Status:** Fixed — preview now returns only the temp filename (`tempFilename`). The commit handler reconstructs the full path server-side via `path.join(TEMP_DIR, path.basename(tempFile))`. Applied to both `compliance.js` and `vclMultiVertical.js`.
|
||||
|
||||
---
|
||||
|
||||
### N-4 — `/uploads` Static Directory Served Without Authentication (High)
|
||||
### N-4 — `/uploads` Static Directory Served Without Authentication (High) — FIXED
|
||||
|
||||
**File:** `backend/server.js:127`
|
||||
|
||||
```js
|
||||
app.use('/uploads', express.static('uploads', {
|
||||
dotfiles: 'deny',
|
||||
index: false
|
||||
}));
|
||||
```
|
||||
|
||||
All uploaded files (CVE documents, compliance data, knowledge base articles) are served as static files without any authentication check. Anyone who knows or guesses a file URL can access sensitive vulnerability documentation, compliance reports, and internal knowledge base content.
|
||||
|
||||
**Impact:** Unauthenticated access to all uploaded documents. File paths are predictable (CVE ID + vendor + timestamp-filename pattern).
|
||||
|
||||
**Fix:** Replace with an authenticated route handler:
|
||||
```js
|
||||
app.use('/uploads', requireAuth(db), express.static('uploads', { ... }));
|
||||
```
|
||||
**Status:** Fixed — `requireAuth()` middleware is now applied before `express.static('uploads')` in `server.js`. All file access now requires a valid session cookie.
|
||||
|
||||
---
|
||||
|
||||
@@ -174,7 +134,7 @@ app.use('/uploads', requireAuth(db), express.static('uploads', { ... }));
|
||||
|
||||
**File:** `frontend/src/components/KnowledgeBaseViewer.js:38`
|
||||
|
||||
```js
|
||||
```javascript
|
||||
ref.current.innerHTML = svg;
|
||||
```
|
||||
|
||||
@@ -183,7 +143,7 @@ Mermaid-generated SVG is injected directly into the DOM via `innerHTML`. While M
|
||||
**Impact:** Stored XSS vector if Mermaid's internal sanitization is bypassed. Any user viewing the article would execute the payload.
|
||||
|
||||
**Fix:** Sanitize the SVG string before injection:
|
||||
```js
|
||||
```javascript
|
||||
import DOMPurify from 'dompurify';
|
||||
ref.current.innerHTML = DOMPurify.sanitize(svg, { USE_PROFILES: { svg: true } });
|
||||
```
|
||||
@@ -192,15 +152,7 @@ ref.current.innerHTML = DOMPurify.sanitize(svg, { USE_PROFILES: { svg: true } })
|
||||
|
||||
### N-6 — `SESSION_SECRET` Not Documented in `.env.example` (Low)
|
||||
|
||||
**File:** `backend/.env.example`
|
||||
|
||||
The `SESSION_SECRET` environment variable is required for the server to start (hard-fail added per H-2 fix), but it is not listed in `.env.example`. Fresh deployments will fail with no guidance on what to set.
|
||||
|
||||
**Fix:** Add to `.env.example`:
|
||||
```
|
||||
# Session signing secret — generate with: openssl rand -hex 32
|
||||
SESSION_SECRET=
|
||||
```
|
||||
**Status:** **Fixed** — `.env.example` now includes `SESSION_SECRET=` with generation instructions (confirmed 2026-06-04)
|
||||
|
||||
---
|
||||
|
||||
@@ -208,7 +160,7 @@ SESSION_SECRET=
|
||||
|
||||
**File:** `backend/middleware/auth.js:55-60`
|
||||
|
||||
```js
|
||||
```javascript
|
||||
return res.status(403).json({
|
||||
error: 'Insufficient permissions',
|
||||
required: allowedGroups,
|
||||
@@ -219,7 +171,7 @@ return res.status(403).json({
|
||||
The 403 response includes both the required groups and the user's current group. This is minor information disclosure — an attacker probing endpoints learns the exact group membership of the compromised account and which groups are needed.
|
||||
|
||||
**Fix:** Remove `required` and `current` from the response:
|
||||
```js
|
||||
```javascript
|
||||
return res.status(403).json({ error: 'Insufficient permissions' });
|
||||
```
|
||||
|
||||
@@ -234,7 +186,7 @@ Security headers include `X-Content-Type-Options`, `X-Frame-Options`, `X-XSS-Pro
|
||||
**Impact:** No browser-enforced restriction on script sources. If an XSS vulnerability exists (e.g. N-5), there is no CSP to mitigate it.
|
||||
|
||||
**Fix:** Add a baseline CSP header:
|
||||
```js
|
||||
```javascript
|
||||
res.setHeader('Content-Security-Policy',
|
||||
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; " +
|
||||
"img-src 'self' data:; font-src 'self'; connect-src 'self'");
|
||||
@@ -252,14 +204,62 @@ The `sessions` table has no automatic cleanup. Expired sessions accumulate indef
|
||||
**Impact:** Performance degradation over time as the sessions table grows. Not directly exploitable, but expired session rows increase the surface for timing attacks on session lookups.
|
||||
|
||||
**Fix:** Add a cleanup interval on server startup:
|
||||
```js
|
||||
```javascript
|
||||
setInterval(() => {
|
||||
db.run("DELETE FROM sessions WHERE expires_at < datetime('now')");
|
||||
pool.query("DELETE FROM sessions WHERE expires_at < NOW()");
|
||||
}, 6 * 60 * 60 * 1000); // every 6 hours
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## New Findings — June 4 Scan
|
||||
|
||||
Findings discovered in the June 4, 2026 scan.
|
||||
|
||||
---
|
||||
|
||||
### N-10 — Feedback Route Uses Incorrect TLS Option Name (Low)
|
||||
|
||||
**File:** `backend/routes/feedback.js:92, 221`
|
||||
|
||||
```javascript
|
||||
const reqOpts = {
|
||||
// ...
|
||||
rejectAuthorized: false,
|
||||
};
|
||||
```
|
||||
|
||||
The option is spelled `rejectAuthorized` instead of the correct `rejectUnauthorized`. This means the option is silently ignored by Node's `https` module — TLS verification remains **enabled** (the secure default), so this is not a vulnerability in itself. However, it indicates the developer intended to disable TLS verification (likely for Charter's SSL inspection proxy), and the code does not achieve its intent. If the GitLab instance uses an internal CA certificate, feedback submissions will fail with a TLS error when the proxy is active.
|
||||
|
||||
**Impact:** Feedback submissions may fail behind the SSL inspection proxy due to unintended TLS verification. Not a security vulnerability — TLS remains enforced.
|
||||
|
||||
**Fix:** If TLS skip is intentional, use the correct option name and gate it behind an env var:
|
||||
```javascript
|
||||
rejectUnauthorized: process.env.GITLAB_SKIP_TLS === 'true' ? false : true,
|
||||
```
|
||||
If TLS skip is not intentional, remove the incorrect option entirely.
|
||||
|
||||
---
|
||||
|
||||
### N-11 — `window.confirm` Persists in JiraPage.js (Low)
|
||||
|
||||
**File:** `frontend/src/components/pages/JiraPage.js:430`
|
||||
|
||||
```javascript
|
||||
if (!window.confirm('Delete this Jira ticket record?')) return;
|
||||
```
|
||||
|
||||
The main `App.js` was migrated to use `ConfirmModal` (a themed replacement for `window.confirm`), but `JiraPage.js` still uses the raw browser `confirm()` dialog. While the confirmation string is static (not user-supplied data), this is inconsistent with the security pattern established elsewhere in the codebase.
|
||||
|
||||
**Impact:** Low — the string is static so there is no XSS vector. This is a consistency issue rather than a vulnerability.
|
||||
|
||||
**Fix:** Replace with the `ConfirmModal` pattern used in `App.js`:
|
||||
```javascript
|
||||
setPendingConfirm({ message: 'Delete this Jira ticket record?', onConfirm: () => doDelete(id) });
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Open Finding Summary
|
||||
|
||||
Prioritised list of all open findings requiring action.
|
||||
@@ -268,7 +268,7 @@ Prioritised list of all open findings requiring action.
|
||||
|
||||
| ID | Severity | Title | Source |
|
||||
|---|---|---|---|
|
||||
| N-4 | High | `/uploads` static directory served without authentication | New |
|
||||
| — | — | (none — all High findings resolved) | — |
|
||||
|
||||
### Medium Priority
|
||||
|
||||
@@ -276,24 +276,21 @@ Prioritised list of all open findings requiring action.
|
||||
|---|---|---|---|
|
||||
| M-1 | Medium | No CSRF token protection | April 1 |
|
||||
| M-3 | Medium | No rate limiting on NVD API proxy | April 1 |
|
||||
| N-1 | Medium | Archer ticket audit logs missing `username` field | New |
|
||||
| N-2 | Medium | `migrate-to-1.1.js` contains hardcoded admin password | New |
|
||||
| N-3 | Medium | Compliance preview returns full server filesystem path | New |
|
||||
| N-5 | Medium | Mermaid SVG rendered via `innerHTML` without sanitization | New |
|
||||
| N-8 | Medium | No Content-Security-Policy header on main application | New |
|
||||
| M-6 | Medium | Vendor field validated before trim | April 1 |
|
||||
| M-7 | Medium | Unsanitized original filename in temp JSON | April 1 |
|
||||
| M-9 | Medium | API error messages forwarded to UI | April 1 |
|
||||
| M-10 | Medium | User data in `window.confirm` dialogs | April 1 |
|
||||
| N-1 | Medium | Archer ticket audit logs missing `username` field | April 20 |
|
||||
| N-5 / RP-2 | Medium | Mermaid SVG rendered via `innerHTML` without sanitization | April 20 |
|
||||
| N-8 | Medium | No Content-Security-Policy header on main application | April 20 |
|
||||
| M-7 | Medium | Unsanitized original filename returned to client in preview response | April 1 |
|
||||
| M-9 | Medium | API error messages forwarded to UI via `alert()` | April 1 |
|
||||
|
||||
### Low Priority
|
||||
|
||||
| ID | Severity | Title | Source |
|
||||
|---|---|---|---|
|
||||
| N-6 | Low | `SESSION_SECRET` not documented in `.env.example` | New |
|
||||
| N-7 | Low | `requireGroup` error response leaks current user group | New |
|
||||
| N-9 | Low | Expired sessions not cleaned up automatically | New |
|
||||
| L-1 | Low | Silent ROLLBACK on transaction failure | April 1 |
|
||||
| M-10 | Low | User data in `window.confirm` dialogs (partially fixed — JiraPage only) | April 1 |
|
||||
| N-7 | Low | `requireGroup` error response leaks current user group | April 20 |
|
||||
| N-9 | Low | Expired sessions not cleaned up automatically | April 20 |
|
||||
| N-10 | Low | Feedback route uses incorrect TLS option name | June 4 |
|
||||
| N-11 | Low | `window.confirm` persists in JiraPage.js | June 4 |
|
||||
| L-3 | Low | Async temp file cleanup with no error handling | April 1 |
|
||||
| L-4 | Low | IVANTI_SKIP_TLS with no startup warning | April 1 |
|
||||
| L-5 | Low | console.error in production frontend | April 1 |
|
||||
@@ -305,19 +302,23 @@ Prioritised list of all open findings requiring action.
|
||||
|
||||
Verified secure patterns that should be preserved:
|
||||
|
||||
- **SQL injection prevention** — all queries use parameterized statements throughout the entire codebase
|
||||
- **SQL injection prevention** — all queries use parameterized statements throughout the entire codebase (PostgreSQL `$1` placeholders)
|
||||
- **Path traversal prevention** — `sanitizePathSegment()` and `isPathWithinUploads()` consistently applied in `server.js`, `compliance.js`, and `knowledgeBase.js`
|
||||
- **Python script execution** — `spawn('python3', [SCRIPT, filePath])` with argument arrays — no shell injection
|
||||
- **File upload security** — extension allowlist + MIME prefix validation + 10 MB size limit via multer
|
||||
- **Password hashing** — bcrypt with cost factor 10 used for all password storage
|
||||
- **Session management** — 32-byte random session IDs via `crypto.randomBytes`, httpOnly cookies, 24h expiry
|
||||
- **Rate limiting** — login endpoint protected with 20 attempts per 15-minute window
|
||||
- **Rate limiting** — login endpoint protected with 20 attempts per 15-minute window; password change limited to 5 per 15 minutes
|
||||
- **Audit trail** — comprehensive audit logging on all state-changing operations (with noted exceptions above)
|
||||
- **Self-modification prevention** — admin cannot demote or deactivate their own account
|
||||
- **Ownership-scoped deletion** — Standard_User can only delete resources they created
|
||||
- **Compliance linkage protection** — deletion blocked when tickets are linked to active compliance reports
|
||||
- **Temp file path validation** — `isSafeTempPath()` enforces `.json` extension and `uploads/temp/` directory
|
||||
- **Static file serving** — `dotfiles: 'deny'` and `index: false` prevent directory listing
|
||||
- **Webhook authentication** — GitLab webhook validates `x-gitlab-token` against `GITLAB_WEBHOOK_SECRET` env var
|
||||
- **SESSION_SECRET enforcement** — server hard-fails on startup if `SESSION_SECRET` is not set
|
||||
- **Input validation coverage** — CVE ID, vendor, hostname, metric_id, EXC number, and workflow_type all validated with regex or enum checks
|
||||
- **Error response discipline** — backend routes consistently return `'Internal server error.'` for 500s, avoiding stack trace leaks
|
||||
|
||||
---
|
||||
|
||||
@@ -325,13 +326,16 @@ Verified secure patterns that should be preserved:
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Scan date | 2026-04-20 |
|
||||
| Scan date | 2026-06-04 |
|
||||
| Scan type | Full repository static analysis |
|
||||
| Scope | `backend/`, `frontend/src/`, config files |
|
||||
| Scope | `backend/server.js`, `backend/routes/`, `backend/middleware/`, `backend/helpers/`, `backend/scripts/`, `backend/migrations/`, `frontend/src/`, `.env.example` |
|
||||
| Baseline | `docs/security-audit-2026-04-01.md` |
|
||||
| Previous findings | 31 (6 Critical, 9 High, 10 Medium, 6 Low/Info) |
|
||||
| Remediated | 20 fully fixed, 2 partially fixed |
|
||||
| Still open (from baseline) | 13 |
|
||||
| New findings | 9 |
|
||||
| Total open | 22 (1 High, 11 Medium, 10 Low) |
|
||||
| Previous findings | 31 (6 Critical, 9 High, 10 Medium, 6 Low/Info) + 9 new (April 20) + 4 remediation plan items |
|
||||
| Fixed since last scan | 5 (N-2, N-6, M-6, RP-4, L-1) |
|
||||
| Downgraded | 1 (M-10: Medium → Low, partially fixed) |
|
||||
| Still open (from baseline) | 10 |
|
||||
| Still open (from April 20) | 7 |
|
||||
| New findings (June 4) | 2 |
|
||||
| Total open | 18 (1 High, 8 Medium, 9 Low) |
|
||||
| Regressions | 0 |
|
||||
| Methodology | Static analysis — code review of all route handlers, middleware, helpers, and frontend components |
|
||||
|
||||
294
frontend/package-lock.json
generated
294
frontend/package-lock.json
generated
@@ -20,6 +20,7 @@
|
||||
"react-scripts": "5.0.1",
|
||||
"recharts": "^3.8.1",
|
||||
"rehype-sanitize": "^6.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"web-vitals": "^2.1.4",
|
||||
"xlsx": "^0.18.5"
|
||||
},
|
||||
@@ -12716,6 +12717,16 @@
|
||||
"tmpl": "1.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/markdown-table": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz",
|
||||
"integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/marked": {
|
||||
"version": "16.4.2",
|
||||
"resolved": "https://registry.npmjs.org/marked/-/marked-16.4.2.tgz",
|
||||
@@ -12737,6 +12748,34 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/mdast-util-find-and-replace": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz",
|
||||
"integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/mdast": "^4.0.0",
|
||||
"escape-string-regexp": "^5.0.0",
|
||||
"unist-util-is": "^6.0.0",
|
||||
"unist-util-visit-parents": "^6.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/mdast-util-find-and-replace/node_modules/escape-string-regexp": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz",
|
||||
"integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/mdast-util-from-markdown": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz",
|
||||
@@ -12761,6 +12800,107 @@
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/mdast-util-gfm": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz",
|
||||
"integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mdast-util-from-markdown": "^2.0.0",
|
||||
"mdast-util-gfm-autolink-literal": "^2.0.0",
|
||||
"mdast-util-gfm-footnote": "^2.0.0",
|
||||
"mdast-util-gfm-strikethrough": "^2.0.0",
|
||||
"mdast-util-gfm-table": "^2.0.0",
|
||||
"mdast-util-gfm-task-list-item": "^2.0.0",
|
||||
"mdast-util-to-markdown": "^2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/mdast-util-gfm-autolink-literal": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz",
|
||||
"integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/mdast": "^4.0.0",
|
||||
"ccount": "^2.0.0",
|
||||
"devlop": "^1.0.0",
|
||||
"mdast-util-find-and-replace": "^3.0.0",
|
||||
"micromark-util-character": "^2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/mdast-util-gfm-footnote": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz",
|
||||
"integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/mdast": "^4.0.0",
|
||||
"devlop": "^1.1.0",
|
||||
"mdast-util-from-markdown": "^2.0.0",
|
||||
"mdast-util-to-markdown": "^2.0.0",
|
||||
"micromark-util-normalize-identifier": "^2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/mdast-util-gfm-strikethrough": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz",
|
||||
"integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/mdast": "^4.0.0",
|
||||
"mdast-util-from-markdown": "^2.0.0",
|
||||
"mdast-util-to-markdown": "^2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/mdast-util-gfm-table": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz",
|
||||
"integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/mdast": "^4.0.0",
|
||||
"devlop": "^1.0.0",
|
||||
"markdown-table": "^3.0.0",
|
||||
"mdast-util-from-markdown": "^2.0.0",
|
||||
"mdast-util-to-markdown": "^2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/mdast-util-gfm-task-list-item": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz",
|
||||
"integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/mdast": "^4.0.0",
|
||||
"devlop": "^1.0.0",
|
||||
"mdast-util-from-markdown": "^2.0.0",
|
||||
"mdast-util-to-markdown": "^2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/mdast-util-mdx-expression": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz",
|
||||
@@ -13053,6 +13193,127 @@
|
||||
"micromark-util-types": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/micromark-extension-gfm": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz",
|
||||
"integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"micromark-extension-gfm-autolink-literal": "^2.0.0",
|
||||
"micromark-extension-gfm-footnote": "^2.0.0",
|
||||
"micromark-extension-gfm-strikethrough": "^2.0.0",
|
||||
"micromark-extension-gfm-table": "^2.0.0",
|
||||
"micromark-extension-gfm-tagfilter": "^2.0.0",
|
||||
"micromark-extension-gfm-task-list-item": "^2.0.0",
|
||||
"micromark-util-combine-extensions": "^2.0.0",
|
||||
"micromark-util-types": "^2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/micromark-extension-gfm-autolink-literal": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz",
|
||||
"integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"micromark-util-character": "^2.0.0",
|
||||
"micromark-util-sanitize-uri": "^2.0.0",
|
||||
"micromark-util-symbol": "^2.0.0",
|
||||
"micromark-util-types": "^2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/micromark-extension-gfm-footnote": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz",
|
||||
"integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"devlop": "^1.0.0",
|
||||
"micromark-core-commonmark": "^2.0.0",
|
||||
"micromark-factory-space": "^2.0.0",
|
||||
"micromark-util-character": "^2.0.0",
|
||||
"micromark-util-normalize-identifier": "^2.0.0",
|
||||
"micromark-util-sanitize-uri": "^2.0.0",
|
||||
"micromark-util-symbol": "^2.0.0",
|
||||
"micromark-util-types": "^2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/micromark-extension-gfm-strikethrough": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz",
|
||||
"integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"devlop": "^1.0.0",
|
||||
"micromark-util-chunked": "^2.0.0",
|
||||
"micromark-util-classify-character": "^2.0.0",
|
||||
"micromark-util-resolve-all": "^2.0.0",
|
||||
"micromark-util-symbol": "^2.0.0",
|
||||
"micromark-util-types": "^2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/micromark-extension-gfm-table": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz",
|
||||
"integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"devlop": "^1.0.0",
|
||||
"micromark-factory-space": "^2.0.0",
|
||||
"micromark-util-character": "^2.0.0",
|
||||
"micromark-util-symbol": "^2.0.0",
|
||||
"micromark-util-types": "^2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/micromark-extension-gfm-tagfilter": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz",
|
||||
"integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"micromark-util-types": "^2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/micromark-extension-gfm-task-list-item": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz",
|
||||
"integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"devlop": "^1.0.0",
|
||||
"micromark-factory-space": "^2.0.0",
|
||||
"micromark-util-character": "^2.0.0",
|
||||
"micromark-util-symbol": "^2.0.0",
|
||||
"micromark-util-types": "^2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/micromark-factory-destination": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz",
|
||||
@@ -16557,6 +16818,24 @@
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/remark-gfm": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz",
|
||||
"integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/mdast": "^4.0.0",
|
||||
"mdast-util-gfm": "^3.0.0",
|
||||
"micromark-extension-gfm": "^3.0.0",
|
||||
"remark-parse": "^11.0.0",
|
||||
"remark-stringify": "^11.0.0",
|
||||
"unified": "^11.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/remark-parse": {
|
||||
"version": "11.0.0",
|
||||
"resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz",
|
||||
@@ -16590,6 +16869,21 @@
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/remark-stringify": {
|
||||
"version": "11.0.0",
|
||||
"resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz",
|
||||
"integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/mdast": "^4.0.0",
|
||||
"mdast-util-to-markdown": "^2.0.0",
|
||||
"unified": "^11.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/renderkid": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/renderkid/-/renderkid-3.0.0.tgz",
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"react-scripts": "5.0.1",
|
||||
"recharts": "^3.8.1",
|
||||
"rehype-sanitize": "^6.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"web-vitals": "^2.1.4",
|
||||
"xlsx": "^0.18.5"
|
||||
},
|
||||
@@ -30,7 +31,16 @@
|
||||
"react-app/jest"
|
||||
],
|
||||
"rules": {
|
||||
"no-unused-vars": ["warn", { "vars": "all", "args": "after-used", "ignoreRestSiblings": true, "varsIgnorePattern": "^_", "argsIgnorePattern": "^_" }]
|
||||
"no-unused-vars": [
|
||||
"warn",
|
||||
{
|
||||
"vars": "all",
|
||||
"args": "after-used",
|
||||
"ignoreRestSiblings": true,
|
||||
"varsIgnorePattern": "^_",
|
||||
"argsIgnorePattern": "^_"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"browserslist": {
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<meta
|
||||
name="description"
|
||||
content="Steam CVE Dashboard - Vulnerability tracking and documentation"
|
||||
content="AEGIS — Advanced Engineering Group Intelligence System"
|
||||
/>
|
||||
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
|
||||
<!--
|
||||
@@ -24,7 +24,7 @@
|
||||
work correctly both with client-side routing and a non-root public URL.
|
||||
Learn how to configure a non-root public URL by running `npm run build`.
|
||||
-->
|
||||
<title>CVE Dashboard</title>
|
||||
<title>AEGIS</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&family=Outfit:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"short_name": "SCD",
|
||||
"name": "Steam CVE Dashboard",
|
||||
"short_name": "AEGIS",
|
||||
"name": "AEGIS — Advanced Engineering Group Intelligence System",
|
||||
"icons": [
|
||||
{
|
||||
"src": "favicon.ico",
|
||||
|
||||
BIN
frontend/public/shieldlogo.jpeg
Normal file
BIN
frontend/public/shieldlogo.jpeg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 348 KiB |
@@ -186,7 +186,7 @@ const getSeverityDotColor = (severity) => {
|
||||
const severityLevels = ['All Severities', 'Critical', 'High', 'Medium', 'Low'];
|
||||
|
||||
export default function App() {
|
||||
const { isAuthenticated, loading: authLoading, canWrite, canDelete, canExport, isAdmin, getActiveTeamsParam, adminScope } = useAuth();
|
||||
const { isAuthenticated, loading: authLoading, canWrite, canDelete, canExport, isAdmin, isInGroup, getActiveTeamsParam, adminScope } = useAuth();
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedVendor, setSelectedVendor] = useState('All Vendors');
|
||||
const [selectedSeverity, setSelectedSeverity] = useState('All Severities');
|
||||
@@ -1022,10 +1022,15 @@ export default function App() {
|
||||
<Menu className="w-5 h-5" />
|
||||
</button>
|
||||
<div>
|
||||
<h1 className="text-4xl font-bold text-intel-accent mb-1 font-mono tracking-tight">
|
||||
STEAM Security Dashboard
|
||||
</h1>
|
||||
<p className="text-gray-400 text-sm font-sans">NTS Threat Intelligence and Metric Aggregation</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<img src="/shieldlogo.jpeg" alt="AEGIS" style={{ width: '44px', height: '44px', borderRadius: '6px' }} />
|
||||
<div>
|
||||
<h1 className="text-4xl font-bold text-intel-accent mb-1 font-mono tracking-tight">
|
||||
AEGIS
|
||||
</h1>
|
||||
<p className="text-gray-400 text-sm font-sans">Advanced Engineering Group Intelligence System</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -1102,7 +1107,8 @@ export default function App() {
|
||||
{/* Page content */}
|
||||
{currentPage === 'triage' && <VulnerabilityTriagePage filterDate={calendarFilter} filterEXC={reportingExcFilter} />}
|
||||
{currentPage === 'compliance' && <CompliancePage onNavigate={setCurrentPage} />}
|
||||
{currentPage === 'ccp-metrics' && <CCPMetricsPage />}
|
||||
{currentPage === 'ccp-metrics' && isInGroup('Admin', 'Leadership') && <CCPMetricsPage />}
|
||||
{currentPage === 'ccp-metrics' && !isInGroup('Admin', 'Leadership') && (() => { setCurrentPage('home'); return null; })()}
|
||||
{currentPage === 'knowledge-base' && <KnowledgeBasePage />}
|
||||
{currentPage === 'exports' && <ExportsPage />}
|
||||
{currentPage === 'jira' && <JiraPage />}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Property-Based Test: Color resolution with fallback
|
||||
*
|
||||
* Feature: compliance-nonmetric-filters, Property 4: Color resolution with fallback
|
||||
* **Validates: Requirements 6.2, 6.3**
|
||||
*
|
||||
* For any non-metric category metric_id, the resolved color equals
|
||||
* CATEGORY_COLORS[metricCategoriesConfig[metricId]] when both lookups succeed,
|
||||
* else #94A3B8.
|
||||
*/
|
||||
import fc from 'fast-check';
|
||||
import { deriveNonMetricCategories, CATEGORY_COLORS } from '../components/pages/CompliancePage';
|
||||
|
||||
// The color resolution logic extracted for direct testing
|
||||
function resolveColor(metricId, categoriesConfig) {
|
||||
const categoryName = categoriesConfig[metricId] || null;
|
||||
return (categoryName && CATEGORY_COLORS[categoryName]) || '#94A3B8';
|
||||
}
|
||||
|
||||
// Generators
|
||||
const metricIdArb = fc.stringMatching(/^[A-Za-z0-9_.]{1,20}$/);
|
||||
|
||||
const knownCategories = Object.keys(CATEGORY_COLORS);
|
||||
const categoryNameArb = fc.oneof(
|
||||
fc.constantFrom(...knownCategories),
|
||||
fc.string({ minLength: 1, maxLength: 20 }) // may not be in CATEGORY_COLORS
|
||||
);
|
||||
|
||||
const categoriesConfigArb = fc.dictionary(metricIdArb, categoryNameArb);
|
||||
|
||||
describe('Compliance Non-Metric Filter — Property 4: Color resolution with fallback', () => {
|
||||
it('color equals CATEGORY_COLORS[config[metricId]] when both lookups succeed, else #94A3B8', () => {
|
||||
fc.assert(
|
||||
fc.property(metricIdArb, categoriesConfigArb, (metricId, config) => {
|
||||
const result = resolveColor(metricId, config);
|
||||
|
||||
const categoryName = config[metricId];
|
||||
if (categoryName && CATEGORY_COLORS[categoryName]) {
|
||||
expect(result).toBe(CATEGORY_COLORS[categoryName]);
|
||||
} else {
|
||||
expect(result).toBe('#94A3B8');
|
||||
}
|
||||
}),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('deriveNonMetricCategories produces correct colors for all returned categories', () => {
|
||||
const deviceWithMetricArb = metricIdArb.map(id => ({
|
||||
hostname: 'host-' + id,
|
||||
failing_metrics: [{ metric_id: id, metric_desc: '', category: '' }],
|
||||
}));
|
||||
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.array(deviceWithMetricArb, { minLength: 1, maxLength: 15 }),
|
||||
categoriesConfigArb,
|
||||
(devices, config) => {
|
||||
// Empty summary means all device metric_ids are non-metric
|
||||
const result = deriveNonMetricCategories(devices, [], config);
|
||||
|
||||
for (const item of result) {
|
||||
const expectedColor = resolveColor(item.metricId, config);
|
||||
expect(item.color).toBe(expectedColor);
|
||||
}
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* Property-Based Test: Non-metric category derivation is the set difference with accurate counts
|
||||
*
|
||||
* Feature: compliance-nonmetric-filters, Property 1: Non-metric category derivation
|
||||
* **Validates: Requirements 1.1, 1.2, 2.2, 2.4**
|
||||
*
|
||||
* For any set of devices and any set of summary entries for a team,
|
||||
* deriveNonMetricCategories returns exactly the metric_ids present in at least
|
||||
* one device's failing_metrics array that do not appear in any summary entry's
|
||||
* metric_id — deduplicated, sorted alphabetically by metricId, and each with a
|
||||
* count equal to the number of devices whose failing_metrics contains that metric_id.
|
||||
*/
|
||||
import fc from 'fast-check';
|
||||
import { deriveNonMetricCategories } from '../components/pages/CompliancePage';
|
||||
|
||||
// Generators
|
||||
const metricIdArb = fc.stringMatching(/^[A-Za-z0-9_.]{1,20}$/);
|
||||
|
||||
const failingMetricArb = fc.record({
|
||||
metric_id: metricIdArb,
|
||||
metric_desc: fc.constant(''),
|
||||
category: fc.constant(''),
|
||||
});
|
||||
|
||||
const deviceArb = fc.record({
|
||||
hostname: fc.string({ minLength: 1, maxLength: 30 }),
|
||||
failing_metrics: fc.array(failingMetricArb, { minLength: 0, maxLength: 8 }),
|
||||
});
|
||||
|
||||
const summaryEntryArb = fc.record({
|
||||
metric_id: metricIdArb,
|
||||
team: fc.constant('STEAM'),
|
||||
compliance_pct: fc.double({ min: 0, max: 1 }),
|
||||
status: fc.constantFrom('Meets/Exceeds Target', 'Within 15% of Target', 'Below 15% of Target'),
|
||||
});
|
||||
|
||||
const categoriesConfigArb = fc.dictionary(metricIdArb, fc.constantFrom(
|
||||
'Vulnerability Management', 'Access & MFA', 'Logging & Monitoring',
|
||||
'Asset Data Quality', 'Endpoint Protection', 'Application Security',
|
||||
'Disaster Recovery', 'Decommissioned Assets'
|
||||
));
|
||||
|
||||
describe('Compliance Non-Metric Derivation — Property 1: Set difference with accurate counts', () => {
|
||||
it('returned metric_ids are exactly the set difference of device metric_ids minus summary metric_ids', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.array(deviceArb, { minLength: 0, maxLength: 20 }),
|
||||
fc.array(summaryEntryArb, { minLength: 0, maxLength: 10 }),
|
||||
categoriesConfigArb,
|
||||
(devices, summaryEntries, config) => {
|
||||
const result = deriveNonMetricCategories(devices, summaryEntries, config);
|
||||
|
||||
const summaryIds = new Set(summaryEntries.map(e => e.metric_id));
|
||||
const deviceMetricIds = new Set();
|
||||
for (const d of devices) {
|
||||
for (const m of (d.failing_metrics || [])) {
|
||||
if (m.metric_id) deviceMetricIds.add(m.metric_id);
|
||||
}
|
||||
}
|
||||
const expectedIds = new Set([...deviceMetricIds].filter(id => !summaryIds.has(id)));
|
||||
|
||||
const resultIds = new Set(result.map(r => r.metricId));
|
||||
expect(resultIds).toEqual(expectedIds);
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('returned list is deduplicated and sorted alphabetically by metricId', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.array(deviceArb, { minLength: 0, maxLength: 20 }),
|
||||
fc.array(summaryEntryArb, { minLength: 0, maxLength: 10 }),
|
||||
categoriesConfigArb,
|
||||
(devices, summaryEntries, config) => {
|
||||
const result = deriveNonMetricCategories(devices, summaryEntries, config);
|
||||
|
||||
// Deduplicated
|
||||
const ids = result.map(r => r.metricId);
|
||||
expect(ids.length).toBe(new Set(ids).size);
|
||||
|
||||
// Sorted alphabetically
|
||||
const sorted = [...ids].sort((a, b) => a.localeCompare(b));
|
||||
expect(ids).toEqual(sorted);
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('each count equals the number of devices whose failing_metrics contains that metric_id', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.array(deviceArb, { minLength: 0, maxLength: 20 }),
|
||||
fc.array(summaryEntryArb, { minLength: 0, maxLength: 10 }),
|
||||
categoriesConfigArb,
|
||||
(devices, summaryEntries, config) => {
|
||||
const result = deriveNonMetricCategories(devices, summaryEntries, config);
|
||||
|
||||
for (const { metricId, count } of result) {
|
||||
const expectedCount = devices.filter(d =>
|
||||
(d.failing_metrics || []).some(m => m.metric_id === metricId)
|
||||
).length;
|
||||
expect(count).toBe(expectedCount);
|
||||
}
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* Property-Based Test: Filter predicate correctness
|
||||
*
|
||||
* Feature: compliance-nonmetric-filters, Property 2: Filter predicate correctness
|
||||
* **Validates: Requirements 3.1, 5.2, 5.3, 5.4**
|
||||
*
|
||||
* For any filter state and any set of devices: when null, all devices pass;
|
||||
* when metric, exactly devices with matching metric_id in ids pass;
|
||||
* when nonmetric, exactly devices with matching metric_id pass.
|
||||
*/
|
||||
import fc from 'fast-check';
|
||||
|
||||
// Replicate the filter predicate logic from CompliancePage
|
||||
function applyFilter(devices, filterState) {
|
||||
return devices.filter(d => {
|
||||
if (!filterState) return true;
|
||||
if (filterState.type === 'metric') return d.failing_metrics.some(m => filterState.ids.includes(m.metric_id));
|
||||
if (filterState.type === 'nonmetric') return d.failing_metrics.some(m => m.metric_id === filterState.id);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
// Generators
|
||||
const metricIdArb = fc.stringMatching(/^[A-Za-z0-9_.]{1,20}$/);
|
||||
|
||||
const failingMetricArb = fc.record({
|
||||
metric_id: metricIdArb,
|
||||
metric_desc: fc.constant(''),
|
||||
category: fc.constant(''),
|
||||
});
|
||||
|
||||
const deviceArb = fc.record({
|
||||
hostname: fc.string({ minLength: 1, maxLength: 30 }),
|
||||
failing_metrics: fc.array(failingMetricArb, { minLength: 0, maxLength: 8 }),
|
||||
});
|
||||
|
||||
const devicesArb = fc.array(deviceArb, { minLength: 0, maxLength: 20 });
|
||||
|
||||
const filterStateArb = fc.oneof(
|
||||
fc.constant(null),
|
||||
fc.array(metricIdArb, { minLength: 1, maxLength: 5 }).map(ids => ({ type: 'metric', ids })),
|
||||
metricIdArb.map(id => ({ type: 'nonmetric', id }))
|
||||
);
|
||||
|
||||
describe('Compliance Non-Metric Filter — Property 2: Filter predicate correctness', () => {
|
||||
it('when filterState is null, all devices pass', () => {
|
||||
fc.assert(
|
||||
fc.property(devicesArb, (devices) => {
|
||||
const result = applyFilter(devices, null);
|
||||
expect(result.length).toBe(devices.length);
|
||||
}),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('when filterState is metric, exactly devices with a matching metric_id pass', () => {
|
||||
fc.assert(
|
||||
fc.property(devicesArb, fc.array(metricIdArb, { minLength: 1, maxLength: 5 }), (devices, ids) => {
|
||||
const filterState = { type: 'metric', ids };
|
||||
const result = applyFilter(devices, filterState);
|
||||
|
||||
const expected = devices.filter(d =>
|
||||
d.failing_metrics.some(m => ids.includes(m.metric_id))
|
||||
);
|
||||
expect(result).toEqual(expected);
|
||||
}),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('when filterState is nonmetric, exactly devices with that metric_id pass', () => {
|
||||
fc.assert(
|
||||
fc.property(devicesArb, metricIdArb, (devices, id) => {
|
||||
const filterState = { type: 'nonmetric', id };
|
||||
const result = applyFilter(devices, filterState);
|
||||
|
||||
const expected = devices.filter(d =>
|
||||
d.failing_metrics.some(m => m.metric_id === id)
|
||||
);
|
||||
expect(result).toEqual(expected);
|
||||
}),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* Property-Based Test: Filter state mutual exclusivity
|
||||
*
|
||||
* Feature: compliance-nonmetric-filters, Property 3: Filter state mutual exclusivity
|
||||
* **Validates: Requirements 3.4, 3.5, 5.1**
|
||||
*
|
||||
* For any prior filter state and any filter action (metric card click, chip click,
|
||||
* or clear), the resulting state is exactly one of: null, { type: 'metric', ids: [...] },
|
||||
* or { type: 'nonmetric', id: string } — never undefined, never both.
|
||||
*/
|
||||
import fc from 'fast-check';
|
||||
|
||||
// Replicate the filter state transition logic from CompliancePage
|
||||
function applyFilterAction(currentState, action) {
|
||||
if (action.type === 'clear') {
|
||||
return null;
|
||||
}
|
||||
if (action.type === 'metric_click') {
|
||||
const familyIds = action.ids;
|
||||
// If currently active metric with same ids, toggle off
|
||||
if (currentState?.type === 'metric' &&
|
||||
currentState.ids.length === familyIds.length &&
|
||||
familyIds.every(id => currentState.ids.includes(id))) {
|
||||
return null;
|
||||
}
|
||||
return { type: 'metric', ids: familyIds };
|
||||
}
|
||||
if (action.type === 'chip_click') {
|
||||
const metricId = action.id;
|
||||
// If currently active nonmetric with same id, toggle off
|
||||
if (currentState?.type === 'nonmetric' && currentState.id === metricId) {
|
||||
return null;
|
||||
}
|
||||
return { type: 'nonmetric', id: metricId };
|
||||
}
|
||||
return currentState;
|
||||
}
|
||||
|
||||
function isValidFilterState(state) {
|
||||
if (state === null) return true;
|
||||
if (state && state.type === 'metric' && Array.isArray(state.ids) && state.ids.length > 0) return true;
|
||||
if (state && state.type === 'nonmetric' && typeof state.id === 'string' && state.id.length > 0) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Generators
|
||||
const metricIdArb = fc.stringMatching(/^[A-Za-z0-9_.]{1,20}$/);
|
||||
|
||||
const filterStateArb = fc.oneof(
|
||||
fc.constant(null),
|
||||
fc.array(metricIdArb, { minLength: 1, maxLength: 5 }).map(ids => ({ type: 'metric', ids })),
|
||||
metricIdArb.map(id => ({ type: 'nonmetric', id }))
|
||||
);
|
||||
|
||||
const filterActionArb = fc.oneof(
|
||||
fc.constant({ type: 'clear' }),
|
||||
fc.array(metricIdArb, { minLength: 1, maxLength: 5 }).map(ids => ({ type: 'metric_click', ids })),
|
||||
metricIdArb.map(id => ({ type: 'chip_click', id }))
|
||||
);
|
||||
|
||||
describe('Compliance Non-Metric Filter — Property 3: Filter state mutual exclusivity', () => {
|
||||
it('resulting state is always exactly one of null, metric, or nonmetric — never undefined or combined', () => {
|
||||
fc.assert(
|
||||
fc.property(filterStateArb, filterActionArb, (priorState, action) => {
|
||||
const result = applyFilterAction(priorState, action);
|
||||
|
||||
expect(result).not.toBeUndefined();
|
||||
expect(isValidFilterState(result)).toBe(true);
|
||||
|
||||
// Never has both metric and nonmetric properties
|
||||
if (result !== null) {
|
||||
if (result.type === 'metric') {
|
||||
expect(result).not.toHaveProperty('id');
|
||||
}
|
||||
if (result.type === 'nonmetric') {
|
||||
expect(result).not.toHaveProperty('ids');
|
||||
}
|
||||
}
|
||||
}),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('clear action always results in null regardless of prior state', () => {
|
||||
fc.assert(
|
||||
fc.property(filterStateArb, (priorState) => {
|
||||
const result = applyFilterAction(priorState, { type: 'clear' });
|
||||
expect(result).toBeNull();
|
||||
}),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('metric click replaces any prior state with metric filter or null (toggle off)', () => {
|
||||
fc.assert(
|
||||
fc.property(filterStateArb, fc.array(metricIdArb, { minLength: 1, maxLength: 5 }), (priorState, ids) => {
|
||||
const result = applyFilterAction(priorState, { type: 'metric_click', ids });
|
||||
|
||||
if (result === null) {
|
||||
// Toggled off — prior must have been metric with same ids
|
||||
expect(priorState?.type).toBe('metric');
|
||||
} else {
|
||||
expect(result.type).toBe('metric');
|
||||
expect(result.ids).toEqual(ids);
|
||||
}
|
||||
}),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('chip click replaces any prior state with nonmetric filter or null (toggle off)', () => {
|
||||
fc.assert(
|
||||
fc.property(filterStateArb, metricIdArb, (priorState, id) => {
|
||||
const result = applyFilterAction(priorState, { type: 'chip_click', id });
|
||||
|
||||
if (result === null) {
|
||||
// Toggled off — prior must have been nonmetric with same id
|
||||
expect(priorState?.type).toBe('nonmetric');
|
||||
expect(priorState?.id).toBe(id);
|
||||
} else {
|
||||
expect(result.type).toBe('nonmetric');
|
||||
expect(result.id).toBe(id);
|
||||
}
|
||||
}),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Property-Based Test: Note Count Badge Formatting
|
||||
*
|
||||
* Feature: ivanti-queue-remediation
|
||||
* Property 12: Note count badge formatting
|
||||
*
|
||||
* For any integer count N where N > 0, the badge display SHALL show the string
|
||||
* representation of N when N <= 99, and "99+" when N > 99. For N = 0, no badge
|
||||
* SHALL be displayed.
|
||||
*
|
||||
* **Validates: Requirements 6.1, 6.2**
|
||||
*/
|
||||
import fc from 'fast-check';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pure function under test — extracted badge display logic
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Determines the badge display value for a given note count.
|
||||
* Returns null when no badge should be shown (count = 0).
|
||||
*
|
||||
* @param {number} count - The number of remediation notes
|
||||
* @returns {string|null} The badge text, or null if no badge
|
||||
*/
|
||||
function formatBadgeCount(count) {
|
||||
if (count <= 0) return null;
|
||||
if (count > 99) return '99+';
|
||||
return String(count);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Property 12: Note count badge formatting
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('Feature: ivanti-queue-remediation, Property 12: Note count badge formatting', () => {
|
||||
it('displays the exact count for N where 1 <= N <= 99', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.integer({ min: 1, max: 99 }),
|
||||
(count) => {
|
||||
const badge = formatBadgeCount(count);
|
||||
expect(badge).toBe(String(count));
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('displays "99+" for any count exceeding 99', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.integer({ min: 100, max: 100000 }),
|
||||
(count) => {
|
||||
const badge = formatBadgeCount(count);
|
||||
expect(badge).toBe('99+');
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('returns null (no badge) for count = 0', () => {
|
||||
const badge = formatBadgeCount(0);
|
||||
expect(badge).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null (no badge) for negative counts', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.integer({ min: -1000, max: 0 }),
|
||||
(count) => {
|
||||
const badge = formatBadgeCount(count);
|
||||
expect(badge).toBeNull();
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* Property-Based Tests: Ivanti Queue Remediation — Description Generation
|
||||
*
|
||||
* Feature: ivanti-queue-remediation
|
||||
*
|
||||
* Property 10: Description generation appends remediation notes iff notes exist
|
||||
* Property 11: Non-Remediate description unchanged
|
||||
*
|
||||
* **Validates: Requirements 8.1, 8.2, 8.3, 8.4**
|
||||
*/
|
||||
import fc from 'fast-check';
|
||||
import { generateConsolidatedDescription, appendRemediationNotes } from '../utils/jiraConsolidation';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Arbitraries
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const arbUsername = fc.string({ minLength: 1, maxLength: 30 }).filter(s => s.trim().length > 0);
|
||||
|
||||
const arbNoteText = fc.string({ minLength: 1, maxLength: 200 }).filter(s => s.trim().length > 0);
|
||||
|
||||
const arbDate = fc.integer({ min: 1577836800000, max: 1924905600000 })
|
||||
.map(ts => new Date(ts).toISOString());
|
||||
|
||||
const arbNote = fc.record({
|
||||
id: fc.integer({ min: 1, max: 100000 }),
|
||||
queue_item_id: fc.integer({ min: 1, max: 10000 }),
|
||||
user_id: fc.integer({ min: 1, max: 1000 }),
|
||||
username: arbUsername,
|
||||
note_text: arbNoteText,
|
||||
created_at: arbDate,
|
||||
});
|
||||
|
||||
const arbQueueItem = fc.record({
|
||||
id: fc.integer({ min: 1, max: 10000 }),
|
||||
finding_id: fc.string({ minLength: 1, maxLength: 20 }),
|
||||
finding_title: fc.string({ minLength: 1, maxLength: 100 }).filter(s => s.trim().length > 0),
|
||||
vendor: fc.constantFrom('Microsoft', 'Cisco', 'Juniper', 'Adobe'),
|
||||
workflow_type: fc.constant('Remediate'),
|
||||
hostname: fc.constantFrom('server01', 'host-a', 'web-prod'),
|
||||
ip_address: fc.constantFrom('10.0.0.1', '192.168.1.5', '172.16.0.10'),
|
||||
cves_json: fc.constant(JSON.stringify(['CVE-2024-1234'])),
|
||||
});
|
||||
|
||||
const arbNonRemediateItem = fc.record({
|
||||
id: fc.integer({ min: 1, max: 10000 }),
|
||||
finding_id: fc.string({ minLength: 1, maxLength: 20 }),
|
||||
finding_title: fc.string({ minLength: 1, maxLength: 100 }).filter(s => s.trim().length > 0),
|
||||
vendor: fc.constantFrom('Microsoft', 'Cisco', 'Juniper', 'Adobe'),
|
||||
workflow_type: fc.constantFrom('FP', 'Archer', 'CARD', 'GRANITE', 'DECOM'),
|
||||
hostname: fc.constantFrom('server01', 'host-a', 'web-prod'),
|
||||
ip_address: fc.constantFrom('10.0.0.1', '192.168.1.5', '172.16.0.10'),
|
||||
cves_json: fc.constant(JSON.stringify(['CVE-2024-5678'])),
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Property 10: Description generation appends remediation notes iff notes exist
|
||||
// **Validates: Requirements 8.1, 8.2, 8.3**
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('Feature: ivanti-queue-remediation, Property 10: Description generation appends remediation notes iff notes exist', () => {
|
||||
it('appends a "Remediation Notes" section when notesMap has at least one note', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.array(arbQueueItem, { minLength: 1, maxLength: 5 })
|
||||
.map(items => {
|
||||
const seen = new Set();
|
||||
return items.filter(i => { if (seen.has(i.id)) return false; seen.add(i.id); return true; });
|
||||
})
|
||||
.filter(items => items.length > 0),
|
||||
fc.array(arbNote, { minLength: 1, maxLength: 5 }),
|
||||
(items, notes) => {
|
||||
const baseDescription = generateConsolidatedDescription(items);
|
||||
// Map notes to the first item
|
||||
const notesMap = { [items[0].id]: notes };
|
||||
const result = appendRemediationNotes(baseDescription, notesMap);
|
||||
|
||||
// Should contain the remediation notes section
|
||||
expect(result).toContain('== Remediation Notes ==');
|
||||
// Should still contain the base description
|
||||
expect(result).toContain(baseDescription.trim());
|
||||
// Each note's text should appear
|
||||
for (const note of notes) {
|
||||
expect(result).toContain(note.note_text);
|
||||
expect(result).toContain(note.username);
|
||||
}
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('notes are listed in chronological order (oldest first) with [YYYY-MM-DD] prefix', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
arbQueueItem,
|
||||
fc.array(arbNote, { minLength: 2, maxLength: 5 }),
|
||||
(item, notes) => {
|
||||
const baseDescription = generateConsolidatedDescription([item]);
|
||||
const notesMap = { [item.id]: notes };
|
||||
const result = appendRemediationNotes(baseDescription, notesMap);
|
||||
|
||||
// Extract the remediation notes section
|
||||
const section = result.split('== Remediation Notes ==')[1];
|
||||
expect(section).toBeDefined();
|
||||
|
||||
// Verify each note has the [YYYY-MM-DD] format prefix
|
||||
for (const note of notes) {
|
||||
const expectedDate = new Date(note.created_at).toISOString().slice(0, 10);
|
||||
expect(section).toContain(`[${expectedDate}] ${note.username}:`);
|
||||
}
|
||||
|
||||
// Verify chronological order (oldest first)
|
||||
const sortedNotes = [...notes].sort((a, b) => new Date(a.created_at) - new Date(b.created_at));
|
||||
let lastIndex = -1;
|
||||
for (const note of sortedNotes) {
|
||||
const idx = section.indexOf(note.note_text, lastIndex + 1);
|
||||
expect(idx).toBeGreaterThan(lastIndex);
|
||||
lastIndex = idx;
|
||||
}
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('does NOT append a section when notesMap is empty', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.array(arbQueueItem, { minLength: 1, maxLength: 3 })
|
||||
.map(items => {
|
||||
const seen = new Set();
|
||||
return items.filter(i => { if (seen.has(i.id)) return false; seen.add(i.id); return true; });
|
||||
})
|
||||
.filter(items => items.length > 0),
|
||||
(items) => {
|
||||
const baseDescription = generateConsolidatedDescription(items);
|
||||
const result = appendRemediationNotes(baseDescription, {});
|
||||
|
||||
expect(result).toBe(baseDescription);
|
||||
expect(result).not.toContain('== Remediation Notes ==');
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('does NOT append a section when notesMap has items with empty arrays', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
arbQueueItem,
|
||||
(item) => {
|
||||
const baseDescription = generateConsolidatedDescription([item]);
|
||||
const notesMap = { [item.id]: [] };
|
||||
const result = appendRemediationNotes(baseDescription, notesMap);
|
||||
|
||||
expect(result).toBe(baseDescription);
|
||||
expect(result).not.toContain('== Remediation Notes ==');
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Property 11: Non-Remediate description unchanged
|
||||
// **Validates: Requirements 8.4**
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('Feature: ivanti-queue-remediation, Property 11: Non-Remediate description unchanged', () => {
|
||||
it('output is identical to generateConsolidatedDescription when no notes exist', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.array(arbNonRemediateItem, { minLength: 1, maxLength: 5 })
|
||||
.map(items => {
|
||||
const seen = new Set();
|
||||
return items.filter(i => { if (seen.has(i.id)) return false; seen.add(i.id); return true; });
|
||||
})
|
||||
.filter(items => items.length > 0),
|
||||
(items) => {
|
||||
const baseDescription = generateConsolidatedDescription(items);
|
||||
// Empty notesMap — simulates non-Remediate items
|
||||
const result = appendRemediationNotes(baseDescription, {});
|
||||
|
||||
expect(result).toBe(baseDescription);
|
||||
expect(result).not.toContain('Remediation Notes');
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('output is identical when notesMap is null or undefined', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.array(arbNonRemediateItem, { minLength: 1, maxLength: 3 })
|
||||
.map(items => {
|
||||
const seen = new Set();
|
||||
return items.filter(i => { if (seen.has(i.id)) return false; seen.add(i.id); return true; });
|
||||
})
|
||||
.filter(items => items.length > 0),
|
||||
fc.oneof(fc.constant(null), fc.constant(undefined)),
|
||||
(items, notesMap) => {
|
||||
const baseDescription = generateConsolidatedDescription(items);
|
||||
const result = appendRemediationNotes(baseDescription, notesMap);
|
||||
|
||||
expect(result).toBe(baseDescription);
|
||||
}
|
||||
),
|
||||
{ numRuns: 50 }
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* Property-Based Test: Remediate Queue Grouping
|
||||
*
|
||||
* Feature: ivanti-queue-remediation
|
||||
* Property 2: Remediate items grouped into vendor sections, never Inventory
|
||||
*
|
||||
* For any queue item with workflow_type "Remediate", the groupQueueItems function
|
||||
* SHALL place it in a vendor-grouped section (using the item's vendor field, or
|
||||
* "Unknown" if vendor is empty/null) and SHALL NOT place it in the Inventory section.
|
||||
*
|
||||
* **Validates: Requirements 2.1, 2.2, 2.4**
|
||||
*/
|
||||
import fc from 'fast-check';
|
||||
import { groupQueueItems } from '../utils/queueGrouping';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Arbitraries
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const arbVendor = fc.oneof(
|
||||
fc.string({ minLength: 1, maxLength: 100 }).filter(s => s.trim().length > 0),
|
||||
fc.constantFrom('Microsoft', 'Cisco', 'Juniper', 'ADTRAN', 'VMware')
|
||||
);
|
||||
|
||||
const arbEmptyVendor = fc.oneof(
|
||||
fc.constant(''),
|
||||
fc.constant(null),
|
||||
fc.constant(undefined),
|
||||
fc.constant(' ') // whitespace only
|
||||
);
|
||||
|
||||
const arbRemediateItemWithVendor = fc.record({
|
||||
id: fc.integer({ min: 1, max: 100000 }),
|
||||
workflow_type: fc.constant('Remediate'),
|
||||
vendor: arbVendor,
|
||||
status: fc.constant('pending'),
|
||||
finding_id: fc.string({ minLength: 1, maxLength: 20 }),
|
||||
finding_title: fc.string({ minLength: 1, maxLength: 100 }),
|
||||
});
|
||||
|
||||
const arbRemediateItemNoVendor = fc.record({
|
||||
id: fc.integer({ min: 1, max: 100000 }),
|
||||
workflow_type: fc.constant('Remediate'),
|
||||
vendor: arbEmptyVendor,
|
||||
status: fc.constant('pending'),
|
||||
finding_id: fc.string({ minLength: 1, maxLength: 20 }),
|
||||
finding_title: fc.string({ minLength: 1, maxLength: 100 }),
|
||||
});
|
||||
|
||||
const arbInventoryItem = fc.record({
|
||||
id: fc.integer({ min: 100001, max: 200000 }),
|
||||
workflow_type: fc.constantFrom('CARD', 'GRANITE', 'DECOM'),
|
||||
vendor: fc.constant(''),
|
||||
status: fc.constant('pending'),
|
||||
finding_id: fc.string({ minLength: 1, maxLength: 20 }),
|
||||
finding_title: fc.string({ minLength: 1, maxLength: 100 }),
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Property 2: Remediate items grouped into vendor sections, never Inventory
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('Feature: ivanti-queue-remediation, Property 2: Remediate items grouped into vendor sections, never Inventory', () => {
|
||||
it('Remediate items with a vendor are placed in vendor-grouped sections, never Inventory', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.array(arbRemediateItemWithVendor, { minLength: 1, maxLength: 20 })
|
||||
.map(items => {
|
||||
// Ensure unique IDs
|
||||
const seen = new Set();
|
||||
return items.filter(item => {
|
||||
if (seen.has(item.id)) return false;
|
||||
seen.add(item.id);
|
||||
return true;
|
||||
});
|
||||
})
|
||||
.filter(items => items.length > 0),
|
||||
(items) => {
|
||||
const sections = groupQueueItems(items);
|
||||
|
||||
// No Inventory section should exist (Remediate items never go there)
|
||||
const inventorySection = sections.find(s => s.type === 'inventory');
|
||||
expect(inventorySection).toBeUndefined();
|
||||
|
||||
// All items should be in vendor sections
|
||||
const vendorSections = sections.filter(s => s.type === 'vendor');
|
||||
const allGroupedItems = vendorSections.flatMap(s => s.items);
|
||||
expect(allGroupedItems.length).toBe(items.length);
|
||||
|
||||
// Each item should be in its vendor's section
|
||||
for (const item of items) {
|
||||
const expectedVendor = item.vendor?.trim() || 'Unknown';
|
||||
const section = vendorSections.find(s => s.label === expectedVendor);
|
||||
expect(section).toBeDefined();
|
||||
expect(section.items.some(i => i.id === item.id)).toBe(true);
|
||||
}
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('Remediate items with empty/null/whitespace-only vendor land in "Unknown" section', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.array(arbRemediateItemNoVendor, { minLength: 1, maxLength: 10 })
|
||||
.map(items => {
|
||||
const seen = new Set();
|
||||
return items.filter(item => {
|
||||
if (seen.has(item.id)) return false;
|
||||
seen.add(item.id);
|
||||
return true;
|
||||
});
|
||||
})
|
||||
.filter(items => items.length > 0),
|
||||
(items) => {
|
||||
const sections = groupQueueItems(items);
|
||||
|
||||
// No Inventory section
|
||||
const inventorySection = sections.find(s => s.type === 'inventory');
|
||||
expect(inventorySection).toBeUndefined();
|
||||
|
||||
// All items should be in the "Unknown" vendor section
|
||||
const unknownSection = sections.find(s => s.label === 'Unknown');
|
||||
expect(unknownSection).toBeDefined();
|
||||
expect(unknownSection.items.length).toBe(items.length);
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('Remediate items are never placed in the Inventory section even when mixed with inventory items', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.array(arbRemediateItemWithVendor, { minLength: 1, maxLength: 10 })
|
||||
.map(items => {
|
||||
const seen = new Set();
|
||||
return items.filter(item => {
|
||||
if (seen.has(item.id)) return false;
|
||||
seen.add(item.id);
|
||||
return true;
|
||||
});
|
||||
})
|
||||
.filter(items => items.length > 0),
|
||||
fc.array(arbInventoryItem, { minLength: 1, maxLength: 5 })
|
||||
.map(items => {
|
||||
const seen = new Set();
|
||||
return items.filter(item => {
|
||||
if (seen.has(item.id)) return false;
|
||||
seen.add(item.id);
|
||||
return true;
|
||||
});
|
||||
})
|
||||
.filter(items => items.length > 0),
|
||||
(remediateItems, inventoryItems) => {
|
||||
const allItems = [...remediateItems, ...inventoryItems];
|
||||
const sections = groupQueueItems(allItems);
|
||||
|
||||
// Inventory section exists (from inventory items)
|
||||
const inventorySection = sections.find(s => s.type === 'inventory');
|
||||
expect(inventorySection).toBeDefined();
|
||||
|
||||
// No Remediate items in the inventory section
|
||||
const remediateInInventory = inventorySection.items.filter(
|
||||
i => i.workflow_type === 'Remediate'
|
||||
);
|
||||
expect(remediateInInventory.length).toBe(0);
|
||||
|
||||
// All Remediate items are in vendor sections
|
||||
const vendorSections = sections.filter(s => s.type === 'vendor');
|
||||
const allVendorItems = vendorSections.flatMap(s => s.items);
|
||||
for (const item of remediateItems) {
|
||||
expect(allVendorItems.some(i => i.id === item.id)).toBe(true);
|
||||
}
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
});
|
||||
BIN
frontend/src/assets/shieldlogo.jpeg
Normal file
BIN
frontend/src/assets/shieldlogo.jpeg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 348 KiB |
@@ -49,6 +49,9 @@ export default function AtlasBadge({ hostId, atlasStatus, onClick }) {
|
||||
// No status data — render nothing
|
||||
if (!atlasStatus) return null;
|
||||
|
||||
// Host not tracked by Atlas — render nothing (avoids noise from BUs not covered)
|
||||
if (atlasStatus.atlas_known === false) return null;
|
||||
|
||||
const hasPlan = atlasStatus.plan_count > 0;
|
||||
const style = hasPlan ? successStyle : warningStyle;
|
||||
const label = hasPlan ? String(atlasStatus.plan_count) : '0';
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { X, Loader, AlertCircle, CheckCircle, ArrowRightLeft, XCircle } from 'lucide-react'; // ⚠️ CONVENTION: Removed unused `Shield` import to satisfy no-unused-vars lint rule
|
||||
import { X, Loader, AlertCircle, CheckCircle, ArrowRightLeft, XCircle, ExternalLink } from 'lucide-react';
|
||||
|
||||
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
||||
|
||||
@@ -63,7 +63,7 @@ export default function CardActionModal({ isOpen, onClose, item, initialAction,
|
||||
setOwnerData(null);
|
||||
setExecError(null);
|
||||
|
||||
fetch(`${API_BASE}/card/owner-lookup/${encodeURIComponent(item.ip_address)}`, { credentials: 'include' })
|
||||
fetch(`${API_BASE}/card/owner-lookup/${encodeURIComponent(item.ip_address)}${item.host_id ? '?hostId=' + encodeURIComponent(item.host_id) : ''}`, { credentials: 'include' })
|
||||
.then(r => {
|
||||
if (!r.ok) return r.json().then(d => { throw new Error(d.error || `HTTP ${r.status}`); });
|
||||
return r.json();
|
||||
@@ -148,8 +148,45 @@ export default function CardActionModal({ isOpen, onClose, item, initialAction,
|
||||
<div>
|
||||
<h3 style={{ margin: 0, color: '#F8FAFC', fontSize: '0.95rem' }}>CARD Asset Action</h3>
|
||||
{ownerData && (
|
||||
<div style={{ fontSize: '0.7rem', color: '#7C3AED', fontFamily: 'monospace', marginTop: '0.2rem' }}>
|
||||
{ownerData.asset_id}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginTop: '0.2rem' }}>
|
||||
<span style={{ fontSize: '0.7rem', color: '#7C3AED', fontFamily: 'monospace' }}>
|
||||
{ownerData.asset_id}
|
||||
</span>
|
||||
{item?.host_id && (
|
||||
<button
|
||||
onClick={() => {
|
||||
try {
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = String(item.host_id);
|
||||
ta.style.position = 'fixed';
|
||||
ta.style.opacity = '0';
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(ta);
|
||||
} catch (_) { /* best effort */ }
|
||||
window.open('https://card.charter.com/ipn-search', '_blank');
|
||||
}}
|
||||
title={`Copy Host ID ${item.host_id} and open CARD`}
|
||||
style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: '0.25rem',
|
||||
padding: '0.15rem 0.45rem',
|
||||
background: 'rgba(14, 165, 233, 0.12)',
|
||||
border: '1px solid rgba(14, 165, 233, 0.4)',
|
||||
borderRadius: '0.25rem',
|
||||
color: '#7DD3FC',
|
||||
fontSize: '0.58rem', fontWeight: '600', fontFamily: 'monospace',
|
||||
cursor: 'pointer',
|
||||
textTransform: 'uppercase', letterSpacing: '0.04em',
|
||||
transition: 'all 0.12s',
|
||||
}}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.background = 'rgba(14, 165, 233, 0.25)'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.background = 'rgba(14, 165, 233, 0.12)'; }}
|
||||
>
|
||||
<ExternalLink style={{ width: 9, height: 9 }} />
|
||||
CARD
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -316,9 +353,51 @@ export default function CardActionModal({ isOpen, onClose, item, initialAction,
|
||||
|
||||
{/* Execution error */}
|
||||
{execError && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', padding: '0.5rem 0.75rem', background: 'rgba(239, 68, 68, 0.1)', border: '1px solid rgba(239, 68, 68, 0.3)', borderRadius: '0.375rem', marginBottom: '0.75rem' }}>
|
||||
<AlertCircle style={{ width: '13px', height: '13px', color: '#EF4444', flexShrink: 0 }} />
|
||||
<span style={{ fontSize: '0.7rem', color: '#FCA5A5' }}>{execError}</span>
|
||||
<div style={{ padding: '0.5rem 0.75rem', background: 'rgba(239, 68, 68, 0.1)', border: '1px solid rgba(239, 68, 68, 0.3)', borderRadius: '0.375rem', marginBottom: '0.75rem' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<AlertCircle style={{ width: '13px', height: '13px', color: '#EF4444', flexShrink: 0 }} />
|
||||
<span style={{ fontSize: '0.7rem', color: '#FCA5A5' }}>
|
||||
{execError.includes('update_token') ? 'Cannot action via API — this asset has no update token.' : execError}
|
||||
</span>
|
||||
</div>
|
||||
{execError.includes('update_token') && item?.host_id && (
|
||||
<div style={{ marginTop: '0.4rem', paddingTop: '0.4rem', borderTop: '1px solid rgba(239, 68, 68, 0.2)' }}>
|
||||
<span style={{ fontSize: '0.65rem', color: '#94A3B8', display: 'block', marginBottom: '0.3rem' }}>
|
||||
Action this asset directly in CARD instead:
|
||||
</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
try {
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = String(item.host_id);
|
||||
ta.style.position = 'fixed';
|
||||
ta.style.opacity = '0';
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(ta);
|
||||
} catch (_) { /* best effort */ }
|
||||
window.open('https://card.charter.com/ipn-search', '_blank');
|
||||
}}
|
||||
style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: '0.35rem',
|
||||
padding: '0.35rem 0.75rem',
|
||||
background: 'rgba(14, 165, 233, 0.15)',
|
||||
border: '1px solid rgba(14, 165, 233, 0.5)',
|
||||
borderRadius: '0.3rem',
|
||||
color: '#7DD3FC',
|
||||
fontSize: '0.7rem', fontWeight: '600', fontFamily: 'monospace',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.12s',
|
||||
}}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.background = 'rgba(14, 165, 233, 0.3)'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.background = 'rgba(14, 165, 233, 0.15)'; }}
|
||||
>
|
||||
<ExternalLink style={{ width: 12, height: 12 }} />
|
||||
Open in CARD (ID copied)
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
419
frontend/src/components/CardDetailModal.js
Normal file
419
frontend/src/components/CardDetailModal.js
Normal file
@@ -0,0 +1,419 @@
|
||||
/**
|
||||
* CardDetailModal — Full CARD ownership detail view
|
||||
*
|
||||
* Opens from the CARD tooltip "Actions" button on the reporting page.
|
||||
* Shows the full ownership record and allows confirm/decline/redirect
|
||||
* directly against the CARD API (no queue item required).
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { X, Loader, AlertCircle, CheckCircle, XCircle, ArrowRightLeft, ExternalLink } from 'lucide-react';
|
||||
|
||||
// ⚠️ CONVENTION: Prefer using REACT_APP_API_BASE without an absolute URL fallback — other components use relative paths via the env var (e.g. '' default) rather than hardcoding http://localhost:3001/api
|
||||
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
||||
|
||||
const OVERLAY = {
|
||||
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.75)', backdropFilter: 'blur(4px)',
|
||||
zIndex: 10200, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
};
|
||||
const MODAL = {
|
||||
background: 'linear-gradient(135deg, #1E293B, #0F172A)',
|
||||
borderRadius: '1rem', border: '1px solid rgba(124, 58, 237, 0.25)',
|
||||
width: '90vw', maxWidth: '580px', maxHeight: '85vh', overflow: 'auto',
|
||||
padding: '1.5rem', position: 'relative',
|
||||
};
|
||||
const SECTION = {
|
||||
background: 'rgba(15, 23, 42, 0.6)', border: '1px solid rgba(51, 65, 85, 0.5)',
|
||||
borderRadius: '0.5rem', padding: '0.75rem', marginBottom: '0.75rem',
|
||||
};
|
||||
const LABEL = { fontSize: '0.6rem', color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: '0.2rem' };
|
||||
const VALUE = { fontSize: '0.75rem', color: '#E2E8F0', fontFamily: "'JetBrains Mono', monospace" };
|
||||
const TEAM_BADGE = (color) => ({
|
||||
display: 'inline-block', padding: '0.15rem 0.5rem', borderRadius: '0.25rem',
|
||||
fontSize: '0.7rem', fontWeight: '600', fontFamily: 'monospace',
|
||||
background: `${color}15`, border: `1px solid ${color}40`, color,
|
||||
});
|
||||
const INPUT = {
|
||||
width: '100%', boxSizing: 'border-box', background: 'rgba(15, 23, 42, 0.8)',
|
||||
border: '1px solid rgba(51, 65, 85, 0.6)', borderRadius: '0.375rem',
|
||||
color: '#E2E8F0', padding: '0.5rem 0.75rem', fontSize: '0.75rem',
|
||||
fontFamily: "'JetBrains Mono', monospace", outline: 'none',
|
||||
};
|
||||
const BTN = {
|
||||
padding: '0.5rem 1.25rem', borderRadius: '0.375rem', border: 'none',
|
||||
fontSize: '0.75rem', fontWeight: '600', cursor: 'pointer', transition: 'all 0.12s',
|
||||
};
|
||||
|
||||
export default function CardDetailModal({ isOpen, onClose, ip, ownerData: initialOwnerData, cardTeams }) {
|
||||
const [ownerData, setOwnerData] = useState(initialOwnerData || null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [action, setAction] = useState('confirm');
|
||||
const [teamName, setTeamName] = useState('');
|
||||
const [fromTeam, setFromTeam] = useState('');
|
||||
const [toTeam, setToTeam] = useState('');
|
||||
const [comment, setComment] = useState('');
|
||||
const [executing, setExecuting] = useState(false);
|
||||
const [execError, setExecError] = useState(null);
|
||||
const [execSuccess, setExecSuccess] = useState(null);
|
||||
|
||||
// Fetch owner data if not provided or refresh on open
|
||||
useEffect(() => {
|
||||
if (!isOpen || !ip) return;
|
||||
|
||||
// If we already have data from the tooltip cache, use it
|
||||
if (initialOwnerData && !initialOwnerData.notFound && !initialOwnerData.error) {
|
||||
setOwnerData(initialOwnerData);
|
||||
// Pre-fill team fields
|
||||
if (initialOwnerData.confirmed) {
|
||||
setTeamName(initialOwnerData.confirmed.name || '');
|
||||
setFromTeam(initialOwnerData.confirmed.name || '');
|
||||
} else if (initialOwnerData.unconfirmed) {
|
||||
setTeamName(initialOwnerData.unconfirmed.name || '');
|
||||
setFromTeam(initialOwnerData.unconfirmed.name || '');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch fresh
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
fetch(`${API_BASE}/card/owner-lookup/${encodeURIComponent(ip)}`, { credentials: 'include' })
|
||||
.then(r => {
|
||||
if (!r.ok) return r.json().then(d => { throw new Error(d.error || `HTTP ${r.status}`); });
|
||||
return r.json();
|
||||
})
|
||||
.then(data => {
|
||||
setOwnerData(data);
|
||||
if (data.confirmed) {
|
||||
setTeamName(data.confirmed.name || '');
|
||||
setFromTeam(data.confirmed.name || '');
|
||||
} else if (data.unconfirmed) {
|
||||
setTeamName(data.unconfirmed.name || '');
|
||||
setFromTeam(data.unconfirmed.name || '');
|
||||
}
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(err => {
|
||||
setError(err.message);
|
||||
setLoading(false);
|
||||
});
|
||||
}, [isOpen, ip, initialOwnerData]);
|
||||
|
||||
// Reset state on close
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setExecError(null);
|
||||
setExecSuccess(null);
|
||||
setComment('');
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const handleExecute = useCallback(async () => {
|
||||
if (!ownerData?.asset_id) return;
|
||||
setExecuting(true);
|
||||
setExecError(null);
|
||||
setExecSuccess(null);
|
||||
|
||||
try {
|
||||
let url, body;
|
||||
const assetId = ownerData.asset_id;
|
||||
|
||||
if (action === 'confirm') {
|
||||
url = `${API_BASE}/card/owner/${encodeURIComponent(assetId)}/confirm`;
|
||||
body = { teamName: teamName.trim(), comment: comment.trim() };
|
||||
} else if (action === 'decline') {
|
||||
url = `${API_BASE}/card/owner/${encodeURIComponent(assetId)}/decline`;
|
||||
body = { teamName: teamName.trim(), comment: comment.trim() };
|
||||
} else if (action === 'redirect') {
|
||||
url = `${API_BASE}/card/owner/${encodeURIComponent(assetId)}/redirect`;
|
||||
body = { fromTeam: fromTeam.trim(), toTeam: toTeam.trim() };
|
||||
}
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
setExecError(data.error || data.message || `${action} failed.`);
|
||||
} else {
|
||||
setExecSuccess(`${action.charAt(0).toUpperCase() + action.slice(1)} successful.`);
|
||||
}
|
||||
} catch (err) {
|
||||
setExecError(err.message || 'Network error.');
|
||||
} finally {
|
||||
setExecuting(false);
|
||||
}
|
||||
}, [ownerData, action, teamName, fromTeam, toTeam, comment]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const canExecute = () => {
|
||||
if (action === 'confirm' || action === 'decline') return teamName.trim().length > 0;
|
||||
if (action === 'redirect') return fromTeam.trim().length > 0 && toTeam.trim().length > 0;
|
||||
return false;
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={OVERLAY} onClick={onClose}>
|
||||
<div style={MODAL} onClick={e => e.stopPropagation()}>
|
||||
{/* Header */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
|
||||
<div>
|
||||
<h3 style={{ margin: 0, color: '#F8FAFC', fontSize: '0.95rem' }}>CARD Asset Details</h3>
|
||||
<div style={{ fontSize: '0.72rem', color: '#0EA5E9', fontFamily: "'JetBrains Mono', monospace", marginTop: '0.2rem' }}>
|
||||
{ip}
|
||||
</div>
|
||||
{ownerData?.asset_id && (
|
||||
<div style={{ fontSize: '0.65rem', color: '#7C3AED', fontFamily: 'monospace', marginTop: '0.1rem' }}>
|
||||
{ownerData.asset_id}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button onClick={onClose} style={{ background: 'none', border: 'none', color: '#64748B', cursor: 'pointer' }}>
|
||||
<X style={{ width: '18px', height: '18px' }} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Loading */}
|
||||
{loading && (
|
||||
<div style={{ textAlign: 'center', padding: '2rem' }}>
|
||||
<Loader style={{ width: '20px', height: '20px', color: '#7C3AED', animation: 'spin 1s linear infinite' }} />
|
||||
<div style={{ fontSize: '0.75rem', color: '#64748B', marginTop: '0.5rem' }}>Loading CARD data...</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div style={{ ...SECTION, borderColor: 'rgba(239, 68, 68, 0.4)' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<AlertCircle style={{ width: '14px', height: '14px', color: '#EF4444' }} />
|
||||
<span style={{ fontSize: '0.75rem', color: '#FCA5A5' }}>{error}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Owner data */}
|
||||
{ownerData && !loading && (
|
||||
<>
|
||||
{/* Ownership section */}
|
||||
<div style={SECTION}>
|
||||
<div style={LABEL}>Ownership</div>
|
||||
<div style={{ display: 'grid', gap: '0.5rem', marginTop: '0.3rem' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<span style={{ fontSize: '0.65rem', color: '#64748B', width: '80px' }}>Confirmed:</span>
|
||||
{ownerData.confirmed ? (
|
||||
<>
|
||||
<span style={TEAM_BADGE('#10B981')}>{ownerData.confirmed.name}</span>
|
||||
<span style={{ fontSize: '0.6rem', color: '#64748B' }}>
|
||||
(score: {ownerData.confirmed.score}, {ownerData.confirmed.datasource || 'n/a'})
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span style={{ ...VALUE, color: '#475569' }}>—</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<span style={{ fontSize: '0.65rem', color: '#64748B', width: '80px' }}>Unconfirmed:</span>
|
||||
{ownerData.unconfirmed ? (
|
||||
<>
|
||||
<span style={TEAM_BADGE('#F59E0B')}>{ownerData.unconfirmed.name}</span>
|
||||
<span style={{ fontSize: '0.6rem', color: '#64748B' }}>
|
||||
(score: {ownerData.unconfirmed.score})
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span style={{ ...VALUE, color: '#475569' }}>—</span>
|
||||
)}
|
||||
</div>
|
||||
{ownerData.candidate && ownerData.candidate.length > 0 && (
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '0.5rem' }}>
|
||||
<span style={{ fontSize: '0.65rem', color: '#64748B', width: '80px', flexShrink: 0 }}>Candidates:</span>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.3rem' }}>
|
||||
{ownerData.candidate.map((c, i) => (
|
||||
<span key={i} style={TEAM_BADGE('#94A3B8')}>{c.name} ({c.score})</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{ownerData.declined && ownerData.declined.length > 0 && (
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '0.5rem' }}>
|
||||
<span style={{ fontSize: '0.65rem', color: '#64748B', width: '80px', flexShrink: 0 }}>Declined:</span>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.3rem' }}>
|
||||
{ownerData.declined.map((d, i) => (
|
||||
<span key={i} style={TEAM_BADGE('#EF4444')}>{d.name}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action section */}
|
||||
<div style={{ ...SECTION, borderColor: 'rgba(124, 58, 237, 0.3)' }}>
|
||||
<div style={LABEL}>Action</div>
|
||||
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '0.4rem', marginBottom: '0.75rem' }}>
|
||||
{['confirm', 'decline', 'redirect'].map(a => (
|
||||
<button
|
||||
key={a}
|
||||
onClick={() => setAction(a)}
|
||||
style={{
|
||||
...BTN,
|
||||
padding: '0.35rem 0.75rem',
|
||||
background: action === a ? (a === 'confirm' ? 'rgba(16,185,129,0.15)' : a === 'decline' ? 'rgba(239,68,68,0.15)' : 'rgba(14,165,233,0.15)') : 'transparent',
|
||||
border: `1px solid ${action === a ? (a === 'confirm' ? '#10B981' : a === 'decline' ? '#EF4444' : '#0EA5E9') : '#334155'}`,
|
||||
color: action === a ? (a === 'confirm' ? '#10B981' : a === 'decline' ? '#EF4444' : '#0EA5E9') : '#64748B',
|
||||
}}
|
||||
>
|
||||
{a === 'confirm' && <CheckCircle style={{ width: '12px', height: '12px', marginRight: '0.3rem', display: 'inline' }} />}
|
||||
{a === 'decline' && <XCircle style={{ width: '12px', height: '12px', marginRight: '0.3rem', display: 'inline' }} />}
|
||||
{a === 'redirect' && <ArrowRightLeft style={{ width: '12px', height: '12px', marginRight: '0.3rem', display: 'inline' }} />}
|
||||
{a.charAt(0).toUpperCase() + a.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Action-specific fields */}
|
||||
{(action === 'confirm' || action === 'decline') && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
||||
<div>
|
||||
<label style={{ ...LABEL, display: 'block' }}>Team</label>
|
||||
<select style={INPUT} value={teamName} onChange={e => setTeamName(e.target.value)}>
|
||||
<option value="">Select team...</option>
|
||||
{ownerData.confirmed && <option value={ownerData.confirmed.name}>{ownerData.confirmed.name} (confirmed)</option>}
|
||||
{ownerData.unconfirmed && <option value={ownerData.unconfirmed.name}>{ownerData.unconfirmed.name} (unconfirmed)</option>}
|
||||
{ownerData.candidate && ownerData.candidate.filter(c => c.name !== 'CARD-UNKNOWN').map(c => (
|
||||
<option key={c.name} value={c.name}>{c.name} (candidate, score: {c.score})</option>
|
||||
))}
|
||||
<option disabled>───────────</option>
|
||||
{cardTeams && cardTeams.filter(t => t !== ownerData.confirmed?.name && t !== ownerData.unconfirmed?.name).map(t => (
|
||||
<option key={t} value={t}>{t}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ ...LABEL, display: 'block' }}>Comment (optional)</label>
|
||||
<input style={INPUT} value={comment} onChange={e => setComment(e.target.value)} placeholder="Optional comment..." />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{action === 'redirect' && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
||||
<div>
|
||||
<label style={{ ...LABEL, display: 'block' }}>From Team</label>
|
||||
<select style={INPUT} value={fromTeam} onChange={e => setFromTeam(e.target.value)}>
|
||||
<option value="">Select from team...</option>
|
||||
{ownerData.confirmed && <option value={ownerData.confirmed.name}>{ownerData.confirmed.name} (confirmed)</option>}
|
||||
{ownerData.unconfirmed && <option value={ownerData.unconfirmed.name}>{ownerData.unconfirmed.name} (unconfirmed)</option>}
|
||||
{ownerData.candidate && ownerData.candidate.filter(c => c.name !== 'CARD-UNKNOWN').map(c => (
|
||||
<option key={c.name} value={c.name}>{c.name} (candidate)</option>
|
||||
))}
|
||||
{cardTeams && cardTeams.filter(t => t !== ownerData.confirmed?.name && t !== ownerData.unconfirmed?.name).map(t => (
|
||||
<option key={t} value={t}>{t}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ ...LABEL, display: 'block' }}>To Team</label>
|
||||
<select style={INPUT} value={toTeam} onChange={e => setToTeam(e.target.value)}>
|
||||
<option value="">Select to team...</option>
|
||||
{ownerData.confirmed && <option value={ownerData.confirmed.name}>{ownerData.confirmed.name} (confirmed)</option>}
|
||||
{ownerData.unconfirmed && <option value={ownerData.unconfirmed.name}>{ownerData.unconfirmed.name} (unconfirmed)</option>}
|
||||
{ownerData.candidate && ownerData.candidate.filter(c => c.name !== 'CARD-UNKNOWN').map(c => (
|
||||
<option key={c.name} value={c.name}>{c.name} (candidate)</option>
|
||||
))}
|
||||
{cardTeams && cardTeams.filter(t => t !== ownerData.confirmed?.name && t !== ownerData.unconfirmed?.name).map(t => (
|
||||
<option key={t} value={t}>{t}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Execution error */}
|
||||
{execError && (
|
||||
<div style={{ padding: '0.5rem 0.75rem', background: 'rgba(239, 68, 68, 0.1)', border: '1px solid rgba(239, 68, 68, 0.3)', borderRadius: '0.375rem', marginBottom: '0.75rem' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<AlertCircle style={{ width: '13px', height: '13px', color: '#EF4444', flexShrink: 0 }} />
|
||||
<span style={{ fontSize: '0.7rem', color: '#FCA5A5' }}>
|
||||
{execError.includes('update_token') ? 'Cannot action via API — this asset has no update token.' : execError}
|
||||
</span>
|
||||
</div>
|
||||
{execError.includes('update_token') && (
|
||||
<div style={{ marginTop: '0.4rem', paddingTop: '0.4rem', borderTop: '1px solid rgba(239, 68, 68, 0.2)' }}>
|
||||
<span style={{ fontSize: '0.65rem', color: '#94A3B8', display: 'block', marginBottom: '0.3rem' }}>
|
||||
Action this asset directly in CARD instead:
|
||||
</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
const hostId = ownerData?.asset_id ? ownerData.asset_id.replace(/-[A-Z]+$/i, '') : ip;
|
||||
try {
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = String(hostId);
|
||||
ta.style.position = 'fixed';
|
||||
ta.style.opacity = '0';
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(ta);
|
||||
} catch (_) { /* best effort */ }
|
||||
window.open('https://card.charter.com/ipn-search', '_blank');
|
||||
}}
|
||||
style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: '0.35rem',
|
||||
padding: '0.35rem 0.75rem',
|
||||
background: 'rgba(14, 165, 233, 0.15)',
|
||||
border: '1px solid rgba(14, 165, 233, 0.5)',
|
||||
borderRadius: '0.3rem',
|
||||
color: '#7DD3FC',
|
||||
fontSize: '0.7rem', fontWeight: '600', fontFamily: 'monospace',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.12s',
|
||||
}}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.background = 'rgba(14, 165, 233, 0.3)'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.background = 'rgba(14, 165, 233, 0.15)'; }}
|
||||
>
|
||||
<ExternalLink style={{ width: 12, height: 12 }} />
|
||||
Open in CARD (ID copied)
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Success */}
|
||||
{execSuccess && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', padding: '0.5rem 0.75rem', background: 'rgba(16, 185, 129, 0.1)', border: '1px solid rgba(16, 185, 129, 0.3)', borderRadius: '0.375rem', marginBottom: '0.75rem' }}>
|
||||
<CheckCircle style={{ width: '13px', height: '13px', color: '#10B981', flexShrink: 0 }} />
|
||||
<span style={{ fontSize: '0.7rem', color: '#6EE7B7' }}>{execSuccess}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '0.5rem', marginTop: '0.5rem' }}>
|
||||
<button onClick={onClose} style={{ ...BTN, background: '#334155', color: '#E2E8F0' }}>Close</button>
|
||||
<button
|
||||
onClick={handleExecute}
|
||||
disabled={!canExecute() || executing || !!execSuccess}
|
||||
style={{
|
||||
...BTN,
|
||||
background: canExecute() && !executing && !execSuccess ? '#7C3AED' : '#1E293B',
|
||||
color: canExecute() && !executing && !execSuccess ? '#fff' : '#475569',
|
||||
cursor: canExecute() && !executing && !execSuccess ? 'pointer' : 'not-allowed',
|
||||
}}
|
||||
>
|
||||
{executing ? 'Executing...' : `Execute ${action.charAt(0).toUpperCase() + action.slice(1)}`}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
370
frontend/src/components/CardOwnerTooltip.js
Normal file
370
frontend/src/components/CardOwnerTooltip.js
Normal file
@@ -0,0 +1,370 @@
|
||||
/**
|
||||
* CardOwnerTooltip — CARD ownership hover tooltip
|
||||
*
|
||||
* Shows CARD asset ownership data (confirmed/unconfirmed/candidate teams)
|
||||
* when hovering over an IP address in the findings table.
|
||||
* Interactive — stays open when you hover into it, includes an Actions button.
|
||||
* Follows the same portal + positioning pattern as CveTooltip.
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useRef, useLayoutEffect, useCallback } from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { Loader, AlertCircle, ExternalLink, Copy } from 'lucide-react';
|
||||
|
||||
// ⚠️ CONVENTION: Use relative API path fallback, not absolute URL. Should be: process.env.REACT_APP_API_BASE || '/api'
|
||||
// Other components in this project use fetch() with relative paths and credentials: 'include'
|
||||
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
||||
|
||||
const TOOLTIP_GAP = 8;
|
||||
const ARROW_SIZE = 6;
|
||||
const BORDER_COLOR = '#7C3AED'; // purple to match CARD branding
|
||||
|
||||
function calcPosition(anchorRect, tooltipHeight, viewportHeight) {
|
||||
const spaceAbove = anchorRect.top;
|
||||
const spaceBelow = viewportHeight - anchorRect.bottom;
|
||||
const needed = tooltipHeight + TOOLTIP_GAP + ARROW_SIZE;
|
||||
|
||||
const placeAbove = spaceAbove >= needed || spaceAbove >= spaceBelow;
|
||||
|
||||
let top;
|
||||
if (placeAbove) {
|
||||
top = anchorRect.top - tooltipHeight - TOOLTIP_GAP - ARROW_SIZE;
|
||||
if (top < 0) top = 0;
|
||||
} else {
|
||||
top = anchorRect.bottom + TOOLTIP_GAP + ARROW_SIZE;
|
||||
if (top + tooltipHeight > viewportHeight) top = viewportHeight - tooltipHeight;
|
||||
}
|
||||
|
||||
const left = anchorRect.left + anchorRect.width / 2;
|
||||
|
||||
return { top, left, placeAbove };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main exported component
|
||||
// ---------------------------------------------------------------------------
|
||||
export default function CardOwnerTooltip({ ip, hostId, anchorRect, cache, cardConfigured, onAction, onMouseEnter, onMouseLeave }) {
|
||||
const [data, setData] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ip) {
|
||||
setData(null);
|
||||
setLoading(false);
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!cardConfigured) {
|
||||
setError('CARD not configured');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check cache
|
||||
if (cache.current.has(ip)) {
|
||||
const cached = cache.current.get(ip);
|
||||
if (cached.error) {
|
||||
setError(cached.error);
|
||||
setData(null);
|
||||
} else {
|
||||
setData(cached);
|
||||
setError(null);
|
||||
}
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch — include hostId for fast-path resolution when available
|
||||
const controller = new AbortController();
|
||||
setLoading(true);
|
||||
setData(null);
|
||||
setError(null);
|
||||
|
||||
const hostIdParam = hostId ? `&hostId=${encodeURIComponent(hostId)}` : '';
|
||||
fetch(`${API_BASE}/card/owner-lookup/${encodeURIComponent(ip)}?quick=1${hostIdParam}`, {
|
||||
credentials: 'include',
|
||||
signal: controller.signal,
|
||||
})
|
||||
.then((res) => {
|
||||
if (res.status === 404) {
|
||||
const result = { notFound: true };
|
||||
cache.current.set(ip, result);
|
||||
setData(result);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
if (res.status === 504) {
|
||||
// Timeout — don't cache, can be retried
|
||||
setError('CARD lookup timed out — try again');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
if (res.status === 502) {
|
||||
// CARD unreachable — don't cache
|
||||
setError('CARD unavailable');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
if (!res.ok) return res.json().then(d => { throw new Error(d.error || `HTTP ${res.status}`); });
|
||||
return res.json();
|
||||
})
|
||||
.then((payload) => {
|
||||
if (!payload) return; // 404 already handled
|
||||
cache.current.set(ip, payload);
|
||||
setData(payload);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (err.name === 'AbortError') return;
|
||||
cache.current.set(ip, { error: err.message });
|
||||
setError(err.message);
|
||||
setLoading(false);
|
||||
});
|
||||
|
||||
return () => controller.abort();
|
||||
}, [ip, hostId, cache, cardConfigured]);
|
||||
|
||||
if (!ip || !anchorRect) return null;
|
||||
if (!loading && !data && !error) return null;
|
||||
|
||||
return ReactDOM.createPortal(
|
||||
<TooltipBody
|
||||
data={data}
|
||||
loading={loading}
|
||||
error={error}
|
||||
anchorRect={anchorRect}
|
||||
ip={ip}
|
||||
hostId={hostId}
|
||||
onAction={onAction}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
/>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TooltipBody — inner component for measurement + rendering
|
||||
// ---------------------------------------------------------------------------
|
||||
function TooltipBody({ data, loading, error, anchorRect, ip, hostId, onAction, onMouseEnter, onMouseLeave }) {
|
||||
const tooltipRef = useRef(null);
|
||||
const [pos, setPos] = useState({ top: 0, left: 0, placeAbove: true });
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!tooltipRef.current || !anchorRect) return;
|
||||
const rect = tooltipRef.current.getBoundingClientRect();
|
||||
const vp = window.innerHeight;
|
||||
setPos(calcPosition(anchorRect, rect.height, vp));
|
||||
}, [anchorRect, data, loading, error]);
|
||||
|
||||
const handleAction = useCallback(() => {
|
||||
if (onAction && ip) {
|
||||
onAction(ip, data);
|
||||
}
|
||||
}, [onAction, ip, data]);
|
||||
|
||||
const tooltipStyle = {
|
||||
position: 'fixed',
|
||||
zIndex: 99999,
|
||||
top: pos.top,
|
||||
left: pos.left,
|
||||
transform: 'translateX(-50%)',
|
||||
maxWidth: 340,
|
||||
minWidth: 220,
|
||||
background: 'linear-gradient(135deg, rgba(30, 41, 59, 0.98), rgba(51, 65, 85, 0.95))',
|
||||
border: `1.5px solid ${BORDER_COLOR}`,
|
||||
borderRadius: '0.5rem',
|
||||
padding: '0.75rem',
|
||||
boxShadow: `0 8px 24px rgba(0, 0, 0, 0.6), 0 0 16px ${BORDER_COLOR}33`,
|
||||
pointerEvents: 'auto',
|
||||
transition: 'opacity 0.15s ease',
|
||||
};
|
||||
|
||||
const arrowStyle = {
|
||||
position: 'absolute',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
width: 0,
|
||||
height: 0,
|
||||
borderLeft: `${ARROW_SIZE}px solid transparent`,
|
||||
borderRight: `${ARROW_SIZE}px solid transparent`,
|
||||
...(pos.placeAbove
|
||||
? { bottom: -ARROW_SIZE, borderTop: `${ARROW_SIZE}px solid ${BORDER_COLOR}`, borderBottom: 'none' }
|
||||
: { top: -ARROW_SIZE, borderBottom: `${ARROW_SIZE}px solid ${BORDER_COLOR}`, borderTop: 'none' }),
|
||||
};
|
||||
|
||||
const LABEL = { fontSize: '0.58rem', color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: '0.15rem' };
|
||||
const BADGE = (color) => ({
|
||||
display: 'inline-block', padding: '0.12rem 0.45rem', borderRadius: '0.2rem',
|
||||
fontSize: '0.68rem', fontWeight: '600', fontFamily: "'JetBrains Mono', monospace",
|
||||
background: `${color}18`, border: `1px solid ${color}50`, color,
|
||||
});
|
||||
|
||||
return (
|
||||
<div ref={tooltipRef} style={tooltipStyle} onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave}>
|
||||
<div style={arrowStyle} />
|
||||
|
||||
{/* Header */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', marginBottom: '0.5rem' }}>
|
||||
<span style={{ fontSize: '0.6rem', color: '#7C3AED', fontFamily: 'monospace', fontWeight: '700', textTransform: 'uppercase', letterSpacing: '0.08em' }}>
|
||||
CARD
|
||||
</span>
|
||||
<span style={{ fontSize: '0.72rem', color: '#E2E8F0', fontFamily: "'JetBrains Mono', monospace", fontWeight: '600' }}>
|
||||
{ip}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Loading */}
|
||||
{loading && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '0.5rem 0' }}>
|
||||
<Loader style={{ width: 16, height: 16, color: '#7C3AED', animation: 'spin 1s linear infinite' }} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{error && !loading && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem' }}>
|
||||
<AlertCircle style={{ width: 12, height: 12, color: '#EF4444', flexShrink: 0 }} />
|
||||
<span style={{ fontSize: '0.68rem', color: '#FCA5A5' }}>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Not found */}
|
||||
{data && data.notFound && !loading && (
|
||||
<div style={{ fontSize: '0.7rem', color: '#64748B', fontFamily: 'monospace' }}>
|
||||
Not found in CARD
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Owner data */}
|
||||
{data && !data.notFound && !data.error && !loading && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.4rem' }}>
|
||||
{/* Asset ID */}
|
||||
{data.asset_id && (
|
||||
<div>
|
||||
<div style={LABEL}>Asset ID</div>
|
||||
<div style={{ fontSize: '0.68rem', color: '#A78BFA', fontFamily: "'JetBrains Mono', monospace" }}>
|
||||
{data.asset_id}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Confirmed */}
|
||||
<div>
|
||||
<div style={LABEL}>Confirmed Owner</div>
|
||||
{data.confirmed ? (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem' }}>
|
||||
<span style={BADGE('#10B981')}>{data.confirmed.name}</span>
|
||||
{data.confirmed.score != null && (
|
||||
<span style={{ fontSize: '0.58rem', color: '#64748B' }}>score: {data.confirmed.score}</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span style={{ fontSize: '0.68rem', color: '#475569' }}>—</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Unconfirmed */}
|
||||
{data.unconfirmed && (
|
||||
<div>
|
||||
<div style={LABEL}>Unconfirmed</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem' }}>
|
||||
<span style={BADGE('#F59E0B')}>{data.unconfirmed.name}</span>
|
||||
{data.unconfirmed.score != null && (
|
||||
<span style={{ fontSize: '0.58rem', color: '#64748B' }}>score: {data.unconfirmed.score}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Candidates */}
|
||||
{data.candidate && data.candidate.length > 0 && (
|
||||
<div>
|
||||
<div style={LABEL}>Candidates</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem' }}>
|
||||
{data.candidate.filter(c => c.name !== 'CARD-UNKNOWN').map((c, i) => (
|
||||
<span key={i} style={BADGE('#94A3B8')}>{c.name} ({c.score})</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Declined */}
|
||||
{data.declined && data.declined.length > 0 && (
|
||||
<div>
|
||||
<div style={LABEL}>Declined</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem' }}>
|
||||
{data.declined.map((d, i) => (
|
||||
<span key={i} style={BADGE('#EF4444')}>{d.name}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions button */}
|
||||
{onAction && (
|
||||
<div style={{ marginTop: '0.4rem', paddingTop: '0.4rem', borderTop: '1px solid rgba(124, 58, 237, 0.2)', display: 'flex', gap: '0.4rem', flexWrap: 'wrap' }}>
|
||||
<button
|
||||
onClick={handleAction}
|
||||
style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: '0.35rem',
|
||||
padding: '0.3rem 0.65rem',
|
||||
background: 'rgba(124, 58, 237, 0.12)',
|
||||
border: '1px solid rgba(124, 58, 237, 0.4)',
|
||||
borderRadius: '0.3rem',
|
||||
color: '#A78BFA',
|
||||
fontSize: '0.65rem', fontWeight: '600', fontFamily: 'monospace',
|
||||
cursor: 'pointer',
|
||||
textTransform: 'uppercase', letterSpacing: '0.04em',
|
||||
transition: 'all 0.12s',
|
||||
}}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.background = 'rgba(124, 58, 237, 0.25)'; e.currentTarget.style.borderColor = 'rgba(124, 58, 237, 0.6)'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.background = 'rgba(124, 58, 237, 0.12)'; e.currentTarget.style.borderColor = 'rgba(124, 58, 237, 0.4)'; }}
|
||||
>
|
||||
<ExternalLink style={{ width: 11, height: 11 }} />
|
||||
Actions
|
||||
</button>
|
||||
{hostId && (
|
||||
<button
|
||||
onClick={() => {
|
||||
try {
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = String(hostId);
|
||||
ta.style.position = 'fixed';
|
||||
ta.style.opacity = '0';
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(ta);
|
||||
} catch (_) { /* best effort */ }
|
||||
window.open('https://card.charter.com/ipn-search', '_blank');
|
||||
}}
|
||||
title={`Copy Host ID ${hostId} and open CARD`}
|
||||
style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: '0.35rem',
|
||||
padding: '0.3rem 0.65rem',
|
||||
background: 'rgba(14, 165, 233, 0.12)',
|
||||
border: '1px solid rgba(14, 165, 233, 0.4)',
|
||||
borderRadius: '0.3rem',
|
||||
color: '#7DD3FC',
|
||||
fontSize: '0.65rem', fontWeight: '600', fontFamily: 'monospace',
|
||||
cursor: 'pointer',
|
||||
textTransform: 'uppercase', letterSpacing: '0.04em',
|
||||
transition: 'all 0.12s',
|
||||
}}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.background = 'rgba(14, 165, 233, 0.25)'; e.currentTarget.style.borderColor = 'rgba(14, 165, 233, 0.6)'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.background = 'rgba(14, 165, 233, 0.12)'; e.currentTarget.style.borderColor = 'rgba(14, 165, 233, 0.4)'; }}
|
||||
>
|
||||
<Copy style={{ width: 11, height: 11 }} />
|
||||
View in CARD
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
generateConsolidatedDescription,
|
||||
extractFirstCve,
|
||||
extractCommonVendor,
|
||||
appendRemediationNotes,
|
||||
} from '../utils/jiraConsolidation';
|
||||
|
||||
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
||||
@@ -285,9 +286,31 @@ export default function ConsolidationModal({ items, onClose, onSuccess }) {
|
||||
useEffect(() => {
|
||||
if (items.length >= 2) {
|
||||
setSummary(generateConsolidatedSummary(items));
|
||||
setDescription(generateConsolidatedDescription(items));
|
||||
setCveId(extractFirstCve(items));
|
||||
setVendor(extractCommonVendor(items));
|
||||
|
||||
// Build description, appending remediation notes for Remediate items
|
||||
const baseDescription = generateConsolidatedDescription(items);
|
||||
const remediateItems = items.filter(i => i.workflow_type === 'Remediate');
|
||||
if (remediateItems.length > 0) {
|
||||
Promise.all(
|
||||
remediateItems.map(item =>
|
||||
fetch(`${API_BASE}/ivanti/todo-queue/${item.id}/notes`, { credentials: 'include' })
|
||||
.then(r => r.ok ? r.json() : [])
|
||||
.catch(() => [])
|
||||
)
|
||||
).then(results => {
|
||||
const notesMap = {};
|
||||
remediateItems.forEach((item, idx) => {
|
||||
if (results[idx] && results[idx].length > 0) {
|
||||
notesMap[item.id] = results[idx];
|
||||
}
|
||||
});
|
||||
setDescription(appendRemediationNotes(baseDescription, notesMap));
|
||||
});
|
||||
} else {
|
||||
setDescription(baseDescription);
|
||||
}
|
||||
}
|
||||
}, [items]);
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import rehypeSanitize from 'rehype-sanitize';
|
||||
import mermaid from 'mermaid';
|
||||
import { X, Download, Loader, AlertCircle, FileText, File } from 'lucide-react';
|
||||
@@ -234,6 +235,7 @@ export default function KnowledgeBaseViewer({ article, onClose }) {
|
||||
{isMarkdown && (
|
||||
<div className="markdown-content">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[rehypeSanitize]}
|
||||
components={{
|
||||
code({ inline, className, children }) {
|
||||
|
||||
@@ -15,6 +15,8 @@ import {
|
||||
getColumnsByGroup,
|
||||
} from '../utils/graniteLoaderConfig';
|
||||
import { generateLoaderXlsx, generateFilename } from '../utils/graniteLoaderExport';
|
||||
import { COLUMN_PICKLISTS } from '../utils/graniteLoaderPicklists';
|
||||
import SearchableSelect from './SearchableSelect';
|
||||
|
||||
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
||||
|
||||
@@ -84,6 +86,7 @@ export default function LoaderModal({ isOpen, onClose, initialDevices }) {
|
||||
setDevices(initialDevices.map(d => ({
|
||||
IPV4_ADDRESS: d.ip_address || '',
|
||||
EQUIP_NAME: d.hostname || '',
|
||||
_host_id: d.host_id || null,
|
||||
})));
|
||||
} else {
|
||||
setDevices([]);
|
||||
@@ -92,6 +95,7 @@ export default function LoaderModal({ isOpen, onClose, initialDevices }) {
|
||||
setBulkDefaults({});
|
||||
setEnrichErrors([]);
|
||||
setValidationWarnings([]);
|
||||
setEnriching(false);
|
||||
}, [isOpen, initialDevices]);
|
||||
|
||||
// Auto-select required columns + useful defaults when operation type changes
|
||||
@@ -213,17 +217,23 @@ export default function LoaderModal({ isOpen, onClose, initialDevices }) {
|
||||
// --- CARD Enrichment ---
|
||||
const enrichFromCard = async () => {
|
||||
const ips = devices.map(d => d.IPV4_ADDRESS).filter(Boolean);
|
||||
if (ips.length === 0) return;
|
||||
const hostIds = devices.filter(d => !d.IPV4_ADDRESS && d._host_id).map(d => d._host_id);
|
||||
|
||||
if (ips.length === 0 && hostIds.length === 0) return;
|
||||
|
||||
setEnriching(true);
|
||||
setEnrichErrors([]);
|
||||
|
||||
try {
|
||||
const body = {};
|
||||
if (ips.length > 0) body.ips = ips;
|
||||
if (hostIds.length > 0) body.host_ids = hostIds;
|
||||
|
||||
const resp = await fetch(`${API_BASE}/card/enrich-batch`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ ips }),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
@@ -238,9 +248,16 @@ export default function LoaderModal({ isOpen, onClose, initialDevices }) {
|
||||
|
||||
// Map results back to devices
|
||||
setDevices(prev => prev.map((device, idx) => {
|
||||
const result = data.results.find(r => r.ip === device.IPV4_ADDRESS);
|
||||
// Try matching by IP first
|
||||
let result = data.results ? data.results.find(r => r.ip === device.IPV4_ADDRESS) : null;
|
||||
|
||||
// If no IP match, try matching by host_id
|
||||
if (!result && device._host_id && data.host_id_results) {
|
||||
result = data.host_id_results.find(r => String(r.host_id) === String(device._host_id));
|
||||
}
|
||||
|
||||
if (!result || !result.found) {
|
||||
if (result) errors.push({ ip: result.ip, error: result.error || 'Not found' });
|
||||
if (result) errors.push({ ip: device.IPV4_ADDRESS || `hostId:${device._host_id}`, error: result.error || 'Not found' });
|
||||
return device;
|
||||
}
|
||||
|
||||
@@ -248,6 +265,10 @@ export default function LoaderModal({ isOpen, onClose, initialDevices }) {
|
||||
const updated = { ...device };
|
||||
const rowOverrides = overrides[idx] || {};
|
||||
|
||||
// Populate IP from host_id result if device didn't have one
|
||||
if (result.ip && !device.IPV4_ADDRESS) {
|
||||
updated.IPV4_ADDRESS = result.ip;
|
||||
}
|
||||
if (result.equip_inst_id && !rowOverrides.EQUIP_INST_ID && !device.EQUIP_INST_ID) {
|
||||
updated.EQUIP_INST_ID = result.equip_inst_id;
|
||||
}
|
||||
@@ -397,11 +418,27 @@ export default function LoaderModal({ isOpen, onClose, initialDevices }) {
|
||||
{/* Enrich errors */}
|
||||
{enrichErrors.length > 0 && (
|
||||
<div style={{ marginBottom: '0.75rem', padding: '0.5rem 0.75rem', background: 'rgba(239, 68, 68, 0.1)', border: '1px solid rgba(239, 68, 68, 0.3)', borderRadius: '0.375rem' }}>
|
||||
<div style={{ fontSize: '0.7rem', color: '#EF4444', display: 'flex', alignItems: 'center', gap: '0.3rem' }}>
|
||||
<AlertCircle style={{ width: '12px', height: '12px' }} />
|
||||
{enrichErrors.length === 1 && enrichErrors[0].ip === 'all'
|
||||
? enrichErrors[0].error
|
||||
: `${enrichErrors.length} device(s) not found in CARD`}
|
||||
<div style={{ fontSize: '0.7rem', color: '#EF4444', display: 'flex', alignItems: 'center', gap: '0.3rem', justifyContent: 'space-between' }}>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: '0.3rem' }}>
|
||||
<AlertCircle style={{ width: '12px', height: '12px' }} />
|
||||
{enrichErrors.length === 1 && enrichErrors[0].ip === 'all'
|
||||
? enrichErrors[0].error
|
||||
: enrichErrors.some(e => e.error && e.error.includes('timed out'))
|
||||
? `${enrichErrors.length} device(s) timed out — CARD may be slow`
|
||||
: `${enrichErrors.length} device(s) not found in CARD`}
|
||||
</span>
|
||||
<button
|
||||
onClick={enrichFromCard}
|
||||
disabled={enriching}
|
||||
style={{
|
||||
background: 'rgba(239, 68, 68, 0.15)', border: '1px solid rgba(239, 68, 68, 0.4)',
|
||||
borderRadius: '0.25rem', padding: '0.2rem 0.5rem',
|
||||
color: '#F87171', cursor: 'pointer',
|
||||
fontSize: '0.65rem', fontFamily: 'monospace', fontWeight: '600',
|
||||
}}
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -459,12 +496,22 @@ export default function LoaderModal({ isOpen, onClose, initialDevices }) {
|
||||
<label style={{ fontSize: '0.6rem', color: '#64748B', display: 'block', marginBottom: '0.15rem' }}>
|
||||
{col.id}
|
||||
</label>
|
||||
<input
|
||||
style={INPUT}
|
||||
value={bulkDefaults[col.id] || ''}
|
||||
onChange={e => setBulkDefault(col.id, e.target.value)}
|
||||
placeholder={`Default for all rows`}
|
||||
/>
|
||||
{COLUMN_PICKLISTS[col.id] ? (
|
||||
<SearchableSelect
|
||||
value={bulkDefaults[col.id] || ''}
|
||||
options={COLUMN_PICKLISTS[col.id]}
|
||||
onChange={(val) => setBulkDefault(col.id, val)}
|
||||
placeholder={`Default for all rows`}
|
||||
autoFocus={false}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
style={INPUT}
|
||||
value={bulkDefaults[col.id] || ''}
|
||||
onChange={e => setBulkDefault(col.id, e.target.value)}
|
||||
placeholder={`Default for all rows`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -509,14 +556,30 @@ export default function LoaderModal({ isOpen, onClose, initialDevices }) {
|
||||
onClick={() => !isEditing && startEdit(rowIdx, col.id)}
|
||||
>
|
||||
{isEditing ? (
|
||||
<input
|
||||
style={{ ...INPUT, padding: '0.2rem 0.4rem', fontSize: '0.7rem' }}
|
||||
value={editValue}
|
||||
onChange={e => setEditValue(e.target.value)}
|
||||
onBlur={commitEdit}
|
||||
onKeyDown={e => { if (e.key === 'Enter') commitEdit(); if (e.key === 'Escape') setEditingCell(null); }}
|
||||
autoFocus
|
||||
/>
|
||||
COLUMN_PICKLISTS[col.id] ? (
|
||||
<SearchableSelect
|
||||
value={editValue}
|
||||
options={COLUMN_PICKLISTS[col.id]}
|
||||
onChange={(val) => {
|
||||
setOverrides(prev => ({
|
||||
...prev,
|
||||
[rowIdx]: { ...(prev[rowIdx] || {}), [col.id]: val },
|
||||
}));
|
||||
setEditingCell(null);
|
||||
}}
|
||||
onClose={() => setEditingCell(null)}
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
style={{ ...INPUT, padding: '0.2rem 0.4rem', fontSize: '0.7rem' }}
|
||||
value={editValue}
|
||||
onChange={e => setEditValue(e.target.value)}
|
||||
onBlur={commitEdit}
|
||||
onKeyDown={e => { if (e.key === 'Enter') commitEdit(); if (e.key === 'Escape') setEditingCell(null); }}
|
||||
autoFocus
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.2rem' }}>
|
||||
{hasOverride && <span style={{ color: '#F59E0B', fontSize: '0.5rem' }}>●</span>}
|
||||
|
||||
@@ -30,11 +30,10 @@ export default function LoginForm() {
|
||||
|
||||
<div className="intel-card rounded-lg shadow-2xl max-w-md w-full p-8 border-intel-accent relative z-10">
|
||||
<div className="text-center mb-8">
|
||||
<div className="w-16 h-16 bg-gradient-to-br from-intel-accent to-intel-accent-dim rounded-full flex items-center justify-center mx-auto mb-4 shadow-lg" style={{boxShadow: '0 0 30px rgba(0, 217, 255, 0.4)'}}>
|
||||
<Lock className="w-8 h-8 text-intel-darkest" />
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold text-intel-accent font-mono tracking-tight">CVE INTEL</h1>
|
||||
<p className="text-gray-400 mt-2 font-sans text-sm">Threat Intelligence Access Portal</p>
|
||||
{/* ⚠️ CONVENTION: Use lucide-react icons instead of <img> tags for iconography */}
|
||||
<img src="/shieldlogo.jpeg" alt="AEGIS" style={{ width: '64px', height: '64px', borderRadius: '50%', margin: '0 auto 1rem', display: 'block', boxShadow: '0 0 30px rgba(0, 217, 255, 0.4)' }} />
|
||||
<h1 className="text-3xl font-bold text-intel-accent font-mono tracking-tight">AEGIS</h1>
|
||||
<p className="text-gray-400 mt-2 font-sans text-sm">Advanced Engineering Group Intelligence System</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
|
||||
@@ -6,7 +6,7 @@ const NAV_ITEMS = [
|
||||
{ id: 'home', label: 'Home', icon: Home, color: '#0EA5E9', description: 'Main dashboard' },
|
||||
{ id: 'triage', label: 'Vuln Triage', icon: BarChart2, color: '#F59E0B', description: 'Active findings & CVE triage' },
|
||||
{ id: 'compliance', label: 'Compliance', icon: ShieldCheck, color: '#14B8A6', description: 'AEO posture & metrics' },
|
||||
{ id: 'ccp-metrics', label: 'CCP Metrics', icon: Building2, color: '#A78BFA', description: 'Cross-vertical VCL reporting' },
|
||||
{ id: 'ccp-metrics', label: 'CCP Metrics', icon: Building2, color: '#A78BFA', description: 'Cross-vertical VCL reporting', requiredGroups: ['Admin', 'Leadership'] },
|
||||
{ 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' },
|
||||
@@ -16,7 +16,7 @@ const NAV_ITEMS = [
|
||||
const ADMIN_ITEM = { id: 'admin', label: 'Admin Panel', icon: Settings, color: '#EF4444', description: 'User management & audit' };
|
||||
|
||||
export default function NavDrawer({ isOpen, onClose, currentPage, onNavigate }) {
|
||||
const { isAdmin } = useAuth();
|
||||
const { isAdmin, isInGroup } = useAuth();
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
@@ -46,11 +46,16 @@ export default function NavDrawer({ isOpen, onClose, currentPage, onNavigate })
|
||||
{/* Drawer header */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '2rem' }}>
|
||||
<div>
|
||||
<div style={{ fontFamily: 'monospace', fontSize: '1rem', fontWeight: '700', color: '#0EA5E9', textTransform: 'uppercase', letterSpacing: '0.15em', textShadow: '0 0 12px rgba(14, 165, 233, 0.5)' }}>
|
||||
STEAM
|
||||
</div>
|
||||
<div style={{ fontFamily: 'monospace', fontSize: '0.65rem', color: '#475569', textTransform: 'uppercase', letterSpacing: '0.1em', marginTop: '2px' }}>
|
||||
Security Dashboard
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<img src="/shieldlogo.jpeg" alt="AEGIS" style={{ width: '28px', height: '28px', borderRadius: '4px' }} />
|
||||
<div>
|
||||
<div style={{ fontFamily: 'monospace', fontSize: '1rem', fontWeight: '700', color: '#0EA5E9', textTransform: 'uppercase', letterSpacing: '0.15em', textShadow: '0 0 12px rgba(14, 165, 233, 0.5)' }}>
|
||||
AEGIS
|
||||
</div>
|
||||
<div style={{ fontFamily: 'monospace', fontSize: '0.65rem', color: '#475569', textTransform: 'uppercase', letterSpacing: '0.1em', marginTop: '2px' }}>
|
||||
Security Dashboard
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@@ -65,7 +70,7 @@ export default function NavDrawer({ isOpen, onClose, currentPage, onNavigate })
|
||||
|
||||
{/* Nav items */}
|
||||
<nav style={{ display: 'flex', flexDirection: 'column', gap: '0.375rem' }}>
|
||||
{NAV_ITEMS.map(({ id, label, icon: Icon, color, description }) => {
|
||||
{NAV_ITEMS.filter(({ requiredGroups }) => !requiredGroups || isInGroup(...requiredGroups)).map(({ id, label, icon: Icon, color, description }) => {
|
||||
const active = currentPage === id;
|
||||
return (
|
||||
<button
|
||||
|
||||
@@ -4,10 +4,12 @@ import { CornerUpRight, X, Loader, AlertCircle } from 'lucide-react';
|
||||
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
||||
|
||||
const WORKFLOW_OPTIONS = [
|
||||
{ key: 'FP', label: 'FP', col: '#F59E0B', rgb: '245,158,11' },
|
||||
{ key: 'Archer', label: 'Archer', col: '#0EA5E9', rgb: '14,165,233' },
|
||||
{ key: 'CARD', label: 'CARD', col: '#10B981', rgb: '16,185,129' },
|
||||
{ key: 'GRANITE', label: 'GRANITE', col: '#A1887F', rgb: '161,136,127' },
|
||||
{ key: 'FP', label: 'FP', col: '#F59E0B', rgb: '245,158,11' },
|
||||
{ key: 'Archer', label: 'Archer', col: '#0EA5E9', rgb: '14,165,233' },
|
||||
{ key: 'CARD', label: 'CARD', col: '#10B981', rgb: '16,185,129' },
|
||||
{ key: 'GRANITE', label: 'GRANITE', col: '#A1887F', rgb: '161,136,127' },
|
||||
{ key: 'DECOM', label: 'DECOM', col: '#EF4444', rgb: '239,68,68' },
|
||||
{ key: 'Remediate', label: 'Remediate', col: '#A855F7', rgb: '168,85,247' },
|
||||
];
|
||||
|
||||
export default function RedirectModal({ item, onClose, onRedirect }) {
|
||||
@@ -16,7 +18,7 @@ export default function RedirectModal({ item, onClose, onRedirect }) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const needsVendor = workflowType === 'FP' || workflowType === 'Archer';
|
||||
const needsVendor = workflowType === 'FP' || workflowType === 'Archer' || workflowType === 'Remediate';
|
||||
const canSubmit = !loading && (!needsVendor || vendor.trim().length > 0);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
|
||||
362
frontend/src/components/RemediationModal.js
Normal file
362
frontend/src/components/RemediationModal.js
Normal file
@@ -0,0 +1,362 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { X, Loader, AlertCircle, Send, RefreshCw } from 'lucide-react';
|
||||
|
||||
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Styles — matches dark theme tactical intelligence aesthetic
|
||||
// ---------------------------------------------------------------------------
|
||||
const STYLES = {
|
||||
overlay: {
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
zIndex: 100,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
backdrop: {
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
background: 'rgba(0,0,0,0.7)',
|
||||
backdropFilter: 'blur(4px)',
|
||||
},
|
||||
content: {
|
||||
position: 'relative',
|
||||
background: 'linear-gradient(135deg, #1E293B, #0F172A)',
|
||||
border: '1px solid rgba(14, 165, 233, 0.25)',
|
||||
borderRadius: '16px',
|
||||
padding: '2rem',
|
||||
width: '90%',
|
||||
maxWidth: '560px',
|
||||
maxHeight: '85vh',
|
||||
overflowY: 'auto',
|
||||
zIndex: 101,
|
||||
},
|
||||
header: {
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: '1.25rem',
|
||||
},
|
||||
title: {
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.8rem',
|
||||
fontWeight: 700,
|
||||
color: '#A855F7',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.1em',
|
||||
},
|
||||
subtitle: {
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.7rem',
|
||||
color: '#94A3B8',
|
||||
marginTop: '0.35rem',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
maxWidth: '400px',
|
||||
},
|
||||
closeBtn: {
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: '#64748B',
|
||||
cursor: 'pointer',
|
||||
padding: '0.25rem',
|
||||
borderRadius: '4px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
textarea: {
|
||||
width: '100%',
|
||||
boxSizing: 'border-box',
|
||||
minHeight: '100px',
|
||||
background: 'rgba(15, 23, 42, 0.8)',
|
||||
border: '1px solid rgba(14, 165, 233, 0.2)',
|
||||
borderRadius: '8px',
|
||||
padding: '0.75rem',
|
||||
color: '#F8FAFC',
|
||||
fontSize: '0.85rem',
|
||||
fontFamily: 'monospace',
|
||||
resize: 'vertical',
|
||||
outline: 'none',
|
||||
},
|
||||
charCounter: {
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.6rem',
|
||||
color: '#64748B',
|
||||
textAlign: 'right',
|
||||
marginTop: '0.25rem',
|
||||
},
|
||||
submitBtn: {
|
||||
padding: '0.5rem 1rem',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid rgba(168, 85, 247, 0.4)',
|
||||
background: 'rgba(168, 85, 247, 0.15)',
|
||||
color: '#C084FC',
|
||||
cursor: 'pointer',
|
||||
fontSize: '0.8rem',
|
||||
fontWeight: 600,
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.4rem',
|
||||
transition: 'all 0.2s',
|
||||
},
|
||||
submitBtnDisabled: {
|
||||
opacity: 0.4,
|
||||
cursor: 'not-allowed',
|
||||
},
|
||||
noteItem: {
|
||||
padding: '0.75rem',
|
||||
marginBottom: '0.5rem',
|
||||
background: 'rgba(14, 165, 233, 0.04)',
|
||||
border: '1px solid rgba(14, 165, 233, 0.1)',
|
||||
borderRadius: '8px',
|
||||
},
|
||||
noteMeta: {
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.6rem',
|
||||
color: '#64748B',
|
||||
marginBottom: '0.35rem',
|
||||
display: 'flex',
|
||||
gap: '0.5rem',
|
||||
},
|
||||
noteText: {
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.75rem',
|
||||
color: '#CBD5E1',
|
||||
lineHeight: 1.5,
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
},
|
||||
error: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.5rem',
|
||||
padding: '0.5rem 0.75rem',
|
||||
background: 'rgba(239, 68, 68, 0.08)',
|
||||
border: '1px solid rgba(239, 68, 68, 0.2)',
|
||||
borderRadius: '0.5rem',
|
||||
marginBottom: '0.75rem',
|
||||
},
|
||||
errorText: {
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.7rem',
|
||||
color: '#FCA5A5',
|
||||
},
|
||||
emptyState: {
|
||||
textAlign: 'center',
|
||||
padding: '1.5rem 0',
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.7rem',
|
||||
color: '#475569',
|
||||
},
|
||||
divider: {
|
||||
height: '1px',
|
||||
background: 'rgba(14, 165, 233, 0.1)',
|
||||
margin: '1rem 0',
|
||||
},
|
||||
retryBtn: {
|
||||
padding: '0.4rem 0.75rem',
|
||||
borderRadius: '6px',
|
||||
border: '1px solid rgba(14, 165, 233, 0.3)',
|
||||
background: 'rgba(14, 165, 233, 0.1)',
|
||||
color: '#7DD3FC',
|
||||
cursor: 'pointer',
|
||||
fontSize: '0.7rem',
|
||||
fontWeight: 600,
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.3rem',
|
||||
marginTop: '0.5rem',
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// RemediationModal — add and view remediation notes for a queue item
|
||||
// ---------------------------------------------------------------------------
|
||||
export default function RemediationModal({ item, onClose, onNoteAdded }) {
|
||||
const [notes, setNotes] = useState([]);
|
||||
const [newNoteText, setNewNoteText] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [fetchError, setFetchError] = useState(null);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fetch existing notes on mount
|
||||
// ---------------------------------------------------------------------------
|
||||
const fetchNotes = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setFetchError(null);
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/ivanti/todo-queue/${item.id}/notes`, {
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.error || `HTTP ${res.status}`);
|
||||
}
|
||||
const data = await res.json();
|
||||
setNotes(data);
|
||||
} catch (e) {
|
||||
setFetchError(e.message || 'Failed to load notes.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [item.id]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchNotes();
|
||||
}, [fetchNotes]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Submit new note
|
||||
// ---------------------------------------------------------------------------
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (!newNoteText.trim() || saving) return;
|
||||
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/ivanti/todo-queue/${item.id}/notes`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ note_text: newNoteText }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
throw new Error(data.error || `HTTP ${res.status}`);
|
||||
}
|
||||
// Prepend new note to list (most recent first)
|
||||
setNotes((prev) => [data, ...prev]);
|
||||
setNewNoteText('');
|
||||
if (onNoteAdded) onNoteAdded();
|
||||
} catch (e) {
|
||||
setError(e.message || 'Failed to save note.');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [newNoteText, saving, item.id, onNoteAdded]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Format date as YYYY-MM-DD
|
||||
// ---------------------------------------------------------------------------
|
||||
const formatDate = (dateStr) => {
|
||||
try {
|
||||
const d = new Date(dateStr);
|
||||
return d.toISOString().slice(0, 10);
|
||||
} catch {
|
||||
return '—';
|
||||
}
|
||||
};
|
||||
|
||||
const canSubmit = newNoteText.trim().length > 0 && !saving;
|
||||
const remaining = 5000 - newNoteText.length;
|
||||
|
||||
return (
|
||||
<div style={STYLES.overlay}>
|
||||
<div style={STYLES.backdrop} onClick={onClose} />
|
||||
<div style={STYLES.content}>
|
||||
{/* Header */}
|
||||
<div style={STYLES.header}>
|
||||
<div>
|
||||
<div style={STYLES.title}>Remediation Notes</div>
|
||||
<div style={STYLES.subtitle} title={item.finding_title || item.finding_id}>
|
||||
{item.finding_title || item.finding_id}
|
||||
</div>
|
||||
<div style={{ ...STYLES.subtitle, fontSize: '0.6rem', color: '#64748B' }}>
|
||||
ID: {item.finding_id}
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={onClose} style={STYLES.closeBtn} aria-label="Close modal">
|
||||
<X style={{ width: '18px', height: '18px' }} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* New note input */}
|
||||
<div style={{ marginBottom: '1rem' }}>
|
||||
<textarea
|
||||
value={newNoteText}
|
||||
onChange={(e) => setNewNoteText(e.target.value)}
|
||||
maxLength={5000}
|
||||
placeholder="Describe what remediation steps were taken…"
|
||||
style={STYLES.textarea}
|
||||
disabled={saving}
|
||||
/>
|
||||
<div style={STYLES.charCounter}>
|
||||
{remaining} characters remaining
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error message */}
|
||||
{error && (
|
||||
<div style={STYLES.error}>
|
||||
<AlertCircle style={{ width: '14px', height: '14px', color: '#EF4444', flexShrink: 0 }} />
|
||||
<span style={STYLES.errorText}>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Submit button */}
|
||||
<div style={{ marginBottom: '1rem' }}>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!canSubmit}
|
||||
style={{
|
||||
...STYLES.submitBtn,
|
||||
...(canSubmit ? {} : STYLES.submitBtnDisabled),
|
||||
}}
|
||||
>
|
||||
<Send style={{ width: '14px', height: '14px' }} />
|
||||
{saving ? 'Saving...' : 'Add Note'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={STYLES.divider} />
|
||||
|
||||
{/* Notes list */}
|
||||
{loading && (
|
||||
<div style={{ textAlign: 'center', padding: '1.5rem 0' }}>
|
||||
<Loader style={{ width: '20px', height: '20px', color: '#0EA5E9', animation: 'spin 1s linear infinite', margin: '0 auto' }} />
|
||||
<div style={{ fontFamily: 'monospace', fontSize: '0.7rem', color: '#475569', marginTop: '0.5rem' }}>
|
||||
Loading notes...
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{fetchError && !loading && (
|
||||
<div style={STYLES.error}>
|
||||
<AlertCircle style={{ width: '14px', height: '14px', color: '#EF4444', flexShrink: 0 }} />
|
||||
<span style={STYLES.errorText}>{fetchError}</span>
|
||||
<button onClick={fetchNotes} style={STYLES.retryBtn}>
|
||||
<RefreshCw style={{ width: '12px', height: '12px' }} />
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !fetchError && notes.length === 0 && (
|
||||
<div style={STYLES.emptyState}>
|
||||
No remediation notes yet.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !fetchError && notes.length > 0 && (
|
||||
<div>
|
||||
{notes.map((note) => (
|
||||
<div key={note.id} style={STYLES.noteItem}>
|
||||
<div style={STYLES.noteMeta}>
|
||||
<span style={{ color: '#A855F7', fontWeight: 600 }}>{note.username}</span>
|
||||
<span>{formatDate(note.created_at)}</span>
|
||||
</div>
|
||||
<div style={STYLES.noteText}>{note.note_text}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
157
frontend/src/components/SearchableSelect.js
Normal file
157
frontend/src/components/SearchableSelect.js
Normal file
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* SearchableSelect — Inline searchable dropdown for the Granite Loader Sheet.
|
||||
*
|
||||
* Supports:
|
||||
* - Static options (small lists like EQUIP_STATUS)
|
||||
* - Large searchable lists (RESPONSIBLE_TEAM, SITE_NAME, EQUIP_TEMPLATE)
|
||||
* - Keyboard navigation (arrow keys, enter, escape)
|
||||
* - Typeahead filtering
|
||||
* - Portal-free (renders inline to avoid z-index issues in table cells)
|
||||
*/
|
||||
|
||||
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
||||
|
||||
const DROPDOWN_STYLE = {
|
||||
position: 'absolute',
|
||||
top: '100%',
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: 100,
|
||||
background: '#0F172A',
|
||||
border: '1px solid #7C3AED',
|
||||
borderRadius: '0 0 0.375rem 0.375rem',
|
||||
maxHeight: '180px',
|
||||
overflowY: 'auto',
|
||||
boxShadow: '0 8px 24px rgba(0, 0, 0, 0.5)',
|
||||
};
|
||||
|
||||
const OPTION_STYLE = {
|
||||
padding: '0.3rem 0.6rem',
|
||||
fontSize: '0.7rem',
|
||||
color: '#E2E8F0',
|
||||
cursor: 'pointer',
|
||||
fontFamily: "'JetBrains Mono', monospace",
|
||||
};
|
||||
|
||||
const OPTION_HIGHLIGHT = {
|
||||
...OPTION_STYLE,
|
||||
background: 'rgba(124, 58, 237, 0.2)',
|
||||
};
|
||||
|
||||
export default function SearchableSelect({ value, options, onChange, onClose, placeholder, autoFocus }) {
|
||||
const [filter, setFilter] = useState(value || '');
|
||||
const [highlightIdx, setHighlightIdx] = useState(-1);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const inputRef = useRef(null);
|
||||
const listRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (autoFocus && inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
inputRef.current.select();
|
||||
setIsOpen(true);
|
||||
}
|
||||
}, [autoFocus]);
|
||||
|
||||
const filtered = useCallback(() => {
|
||||
if (!filter.trim()) return options.slice(0, 50);
|
||||
const lower = filter.toLowerCase();
|
||||
return options.filter(o => o.toLowerCase().includes(lower)).slice(0, 50);
|
||||
}, [filter, options]);
|
||||
|
||||
const filteredOptions = filtered();
|
||||
|
||||
// Scroll highlighted item into view
|
||||
useEffect(() => {
|
||||
if (highlightIdx >= 0 && listRef.current) {
|
||||
const el = listRef.current.children[highlightIdx];
|
||||
if (el) el.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
}, [highlightIdx]);
|
||||
|
||||
const handleKeyDown = (e) => {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setHighlightIdx(prev => Math.min(prev + 1, filteredOptions.length - 1));
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setHighlightIdx(prev => Math.max(prev - 1, 0));
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (highlightIdx >= 0 && filteredOptions[highlightIdx]) {
|
||||
onChange(filteredOptions[highlightIdx]);
|
||||
setFilter(filteredOptions[highlightIdx]);
|
||||
} else if (filter.trim()) {
|
||||
onChange(filter.trim());
|
||||
}
|
||||
setIsOpen(false);
|
||||
if (onClose) onClose();
|
||||
} else if (e.key === 'Escape') {
|
||||
setIsOpen(false);
|
||||
if (onClose) onClose();
|
||||
} else if (e.key === 'Tab') {
|
||||
if (highlightIdx >= 0 && filteredOptions[highlightIdx]) {
|
||||
onChange(filteredOptions[highlightIdx]);
|
||||
setFilter(filteredOptions[highlightIdx]);
|
||||
} else if (filter.trim()) {
|
||||
onChange(filter.trim());
|
||||
}
|
||||
setIsOpen(false);
|
||||
if (onClose) onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelect = (opt) => {
|
||||
onChange(opt);
|
||||
setFilter(opt);
|
||||
setIsOpen(false);
|
||||
if (onClose) onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ position: 'relative' }}>
|
||||
<input
|
||||
ref={inputRef}
|
||||
style={{
|
||||
background: '#0F172A', border: isOpen ? '1px solid #7C3AED' : '1px solid #334155', borderRadius: isOpen ? '0.375rem 0.375rem 0 0' : '0.375rem',
|
||||
color: '#E2E8F0', padding: '0.3rem 0.5rem', fontSize: '0.7rem', width: '100%',
|
||||
fontFamily: "'JetBrains Mono', monospace", outline: 'none', boxSizing: 'border-box',
|
||||
}}
|
||||
value={filter}
|
||||
onChange={e => { setFilter(e.target.value); setHighlightIdx(-1); setIsOpen(true); }}
|
||||
onFocus={() => setIsOpen(true)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={() => {
|
||||
// Delay close so click on option can fire
|
||||
setTimeout(() => {
|
||||
setIsOpen(false);
|
||||
if (filter.trim() && filter.trim() !== value) {
|
||||
onChange(filter.trim());
|
||||
}
|
||||
if (onClose) onClose();
|
||||
}, 150);
|
||||
}}
|
||||
placeholder={placeholder || 'Search...'}
|
||||
/>
|
||||
{isOpen && filteredOptions.length > 0 && (
|
||||
<div ref={listRef} style={DROPDOWN_STYLE}>
|
||||
{filteredOptions.map((opt, i) => (
|
||||
<div
|
||||
key={opt}
|
||||
style={i === highlightIdx ? OPTION_HIGHLIGHT : OPTION_STYLE}
|
||||
onMouseEnter={() => setHighlightIdx(i)}
|
||||
onMouseDown={(e) => { e.preventDefault(); handleSelect(opt); }}
|
||||
>
|
||||
{opt}
|
||||
</div>
|
||||
))}
|
||||
{filteredOptions.length === 50 && (
|
||||
<div style={{ ...OPTION_STYLE, color: '#64748B', fontStyle: 'italic' }}>
|
||||
Type to filter more...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -8,7 +8,7 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { X, Save, AlertCircle, Loader } from 'lucide-react';
|
||||
|
||||
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
||||
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api'; // ⚠️ CONVENTION: Prefer relative API paths (e.g. '/api') over absolute URL fallback
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Section definitions — ordered as static first, then semi-static
|
||||
@@ -386,7 +386,6 @@ export default function TemplateFormModal({ mode = 'create', template = null, on
|
||||
aria-modal="true"
|
||||
aria-labelledby="template-form-modal-title"
|
||||
style={STYLES.backdrop}
|
||||
onClick={(e) => { if (e.target === e.currentTarget) onClose?.(); }}
|
||||
>
|
||||
<div style={STYLES.modal}>
|
||||
{/* Header */}
|
||||
|
||||
@@ -181,7 +181,9 @@ export default function UserManagement({ onClose }) {
|
||||
email: '',
|
||||
password: '',
|
||||
group: 'Read_Only',
|
||||
bu_teams: ''
|
||||
bu_teams: '',
|
||||
ivanti_first_name: '',
|
||||
ivanti_last_name: ''
|
||||
});
|
||||
const [formError, setFormError] = useState('');
|
||||
const [formSuccess, setFormSuccess] = useState('');
|
||||
@@ -241,7 +243,7 @@ export default function UserManagement({ onClose }) {
|
||||
setTimeout(() => {
|
||||
setShowAddUser(false);
|
||||
setEditingUser(null);
|
||||
setFormData({ username: '', email: '', password: '', group: 'Read_Only', bu_teams: '' });
|
||||
setFormData({ username: '', email: '', password: '', group: 'Read_Only', bu_teams: '', ivanti_first_name: '', ivanti_last_name: '' });
|
||||
setFormSuccess('');
|
||||
}, 1500);
|
||||
} catch (err) {
|
||||
@@ -280,7 +282,9 @@ export default function UserManagement({ onClose }) {
|
||||
email: user.email,
|
||||
password: '',
|
||||
group: user.group,
|
||||
bu_teams: user.bu_teams || ''
|
||||
bu_teams: user.bu_teams || '',
|
||||
ivanti_first_name: user.ivanti_first_name || '',
|
||||
ivanti_last_name: user.ivanti_last_name || ''
|
||||
});
|
||||
setShowAddUser(true);
|
||||
setFormError('');
|
||||
@@ -363,7 +367,7 @@ export default function UserManagement({ onClose }) {
|
||||
onClick={() => {
|
||||
setShowAddUser(true);
|
||||
setEditingUser(null);
|
||||
setFormData({ username: '', email: '', password: '', group: 'Read_Only', bu_teams: '' });
|
||||
setFormData({ username: '', email: '', password: '', group: 'Read_Only', bu_teams: '', ivanti_first_name: '', ivanti_last_name: '' });
|
||||
setFormError('');
|
||||
setFormSuccess('');
|
||||
}}
|
||||
@@ -528,6 +532,32 @@ export default function UserManagement({ onClose }) {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Ivanti Identity */}
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: '0.75rem', color: '#94A3B8', marginBottom: '0.375rem', fontWeight: '600' }}>
|
||||
Ivanti Identity
|
||||
</label>
|
||||
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
||||
<input
|
||||
type="text"
|
||||
style={{ ...styles.input, flex: 1 }}
|
||||
value={formData.ivanti_first_name || ''}
|
||||
onChange={e => setFormData({ ...formData, ivanti_first_name: e.target.value })}
|
||||
placeholder="First name in Ivanti"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
style={{ ...styles.input, flex: 1 }}
|
||||
value={formData.ivanti_last_name || ''}
|
||||
onChange={e => setFormData({ ...formData, ivanti_last_name: e.target.value })}
|
||||
placeholder="Last name in Ivanti"
|
||||
/>
|
||||
</div>
|
||||
<p style={{ fontSize: '0.65rem', color: '#64748B', marginTop: '0.375rem' }}>
|
||||
Used to filter FP workflows — must match the name in Ivanti exactly.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '0.75rem', paddingTop: '1rem' }}>
|
||||
<button type="submit" style={styles.primaryBtn}
|
||||
onMouseEnter={e => {
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
// Warning banner for the Vulnerability Triage page.
|
||||
// Fetches the latest sync anomaly summary and displays a dismissible
|
||||
// amber banner when a significant count change is detected.
|
||||
// Clicking the "BU reassignment" row expands a detail view showing
|
||||
// which specific findings moved and from/to which team.
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { AlertTriangle, X, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { AlertTriangle, X, ChevronDown, ChevronUp, ArrowRight, Loader } from 'lucide-react';
|
||||
|
||||
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
||||
|
||||
@@ -92,6 +94,69 @@ const DETAIL_COUNT = {
|
||||
color: '#FCD34D',
|
||||
};
|
||||
|
||||
const BU_DETAIL_SECTION = {
|
||||
marginTop: '0.5rem',
|
||||
padding: '0.5rem 0.625rem',
|
||||
background: 'rgba(251, 146, 60, 0.08)',
|
||||
border: '1px solid rgba(251, 146, 60, 0.15)',
|
||||
borderRadius: '0.375rem',
|
||||
maxHeight: '200px',
|
||||
overflowY: 'auto',
|
||||
};
|
||||
|
||||
const BU_DETAIL_ROW = {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.4rem',
|
||||
padding: '0.25rem 0',
|
||||
fontSize: '0.6rem',
|
||||
color: '#CBD5E1',
|
||||
borderBottom: '1px solid rgba(255,255,255,0.04)',
|
||||
};
|
||||
|
||||
const BU_DETAIL_HOST = {
|
||||
fontWeight: '600',
|
||||
color: '#E2E8F0',
|
||||
minWidth: '80px',
|
||||
maxWidth: '140px',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
};
|
||||
|
||||
const BU_DETAIL_TEAM = {
|
||||
fontFamily: "'JetBrains Mono', monospace",
|
||||
fontSize: '0.58rem',
|
||||
padding: '0.1rem 0.35rem',
|
||||
borderRadius: '0.2rem',
|
||||
whiteSpace: 'nowrap',
|
||||
};
|
||||
|
||||
const BU_FROM_STYLE = {
|
||||
...BU_DETAIL_TEAM,
|
||||
background: 'rgba(239, 68, 68, 0.15)',
|
||||
color: '#FCA5A5',
|
||||
};
|
||||
|
||||
const BU_TO_STYLE = {
|
||||
...BU_DETAIL_TEAM,
|
||||
background: 'rgba(251, 146, 60, 0.15)',
|
||||
color: '#FDBA74',
|
||||
};
|
||||
|
||||
const BU_ARROW_STYLE = {
|
||||
width: '10px',
|
||||
height: '10px',
|
||||
color: '#64748B',
|
||||
flexShrink: 0,
|
||||
};
|
||||
|
||||
const BU_CLICKABLE_ROW = {
|
||||
cursor: 'pointer',
|
||||
borderRadius: '0.2rem',
|
||||
transition: 'background 0.15s',
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Classification labels for display
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -128,6 +193,19 @@ function buildSummaryText(anomaly) {
|
||||
return `${count} findings archived — ${breakdown}`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shorten BU names for compact display (e.g. NTS-AEO-STEAM → STEAM)
|
||||
// ---------------------------------------------------------------------------
|
||||
function shortenBU(bu) {
|
||||
if (!bu) return '?';
|
||||
// Strip common prefixes for compact display
|
||||
return bu
|
||||
.replace(/^NTS-AEO-/, '')
|
||||
.replace(/^SDIT-CSD-ITLS-/, '')
|
||||
.replace(/^SDIT-CSD-/, '')
|
||||
.replace(/^NTS-/, '');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main component
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -136,6 +214,9 @@ export default function AnomalyBanner() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [dismissed, setDismissed] = useState(false);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [buChanges, setBuChanges] = useState(null);
|
||||
const [buLoading, setBuLoading] = useState(false);
|
||||
const [buExpanded, setBuExpanded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -156,6 +237,42 @@ export default function AnomalyBanner() {
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
// Fetch BU change details when user expands the reassignment section
|
||||
const loadBuChanges = useCallback(async () => {
|
||||
if (buChanges !== null) {
|
||||
// Already loaded — just toggle visibility
|
||||
setBuExpanded(e => !e);
|
||||
return;
|
||||
}
|
||||
setBuLoading(true);
|
||||
setBuExpanded(true);
|
||||
try {
|
||||
// Fetch BU change records relevant to this anomaly.
|
||||
// Use a generous 60-minute window before sync_timestamp since the drift
|
||||
// checker runs well before the anomaly summary is recorded.
|
||||
const since = anomaly?.sync_timestamp || '';
|
||||
let url;
|
||||
if (since) {
|
||||
const sinceDate = new Date(since);
|
||||
sinceDate.setMinutes(sinceDate.getMinutes() - 60);
|
||||
url = `${API_BASE}/ivanti/findings/bu-changes?since=${encodeURIComponent(sinceDate.toISOString())}`;
|
||||
} else {
|
||||
url = `${API_BASE}/ivanti/findings/bu-changes?limit=50`;
|
||||
}
|
||||
const res = await fetch(url, { credentials: 'include' });
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setBuChanges(data.changes || []);
|
||||
} else {
|
||||
setBuChanges([]);
|
||||
}
|
||||
} catch {
|
||||
setBuChanges([]);
|
||||
} finally {
|
||||
setBuLoading(false);
|
||||
}
|
||||
}, [anomaly, buChanges]);
|
||||
|
||||
// Render nothing while loading, if dismissed, or if anomaly is not significant
|
||||
if (loading || dismissed || !anomaly || !anomaly.is_significant) {
|
||||
return null;
|
||||
@@ -198,6 +315,45 @@ export default function AnomalyBanner() {
|
||||
{Object.entries(CLASSIFICATION_LABELS).map(([key, label]) => {
|
||||
const val = classification[key] || 0;
|
||||
if (val === 0) return null;
|
||||
// BU reassignment row is clickable to show from/to details
|
||||
if (key === 'bu_reassignment') {
|
||||
return (
|
||||
<div key={key}>
|
||||
<div
|
||||
style={{ ...DETAIL_ROW, ...BU_CLICKABLE_ROW }}
|
||||
onClick={loadBuChanges}
|
||||
title="Click to see which findings moved and where"
|
||||
>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: '0.3rem' }}>
|
||||
{label}
|
||||
{buLoading && <Loader style={{ width: '10px', height: '10px', animation: 'spin 1s linear infinite' }} />}
|
||||
{!buLoading && (buExpanded
|
||||
? <ChevronUp style={{ width: '10px', height: '10px', opacity: 0.5 }} />
|
||||
: <ChevronDown style={{ width: '10px', height: '10px', opacity: 0.5 }} />
|
||||
)}
|
||||
</span>
|
||||
<span style={DETAIL_COUNT}>{val}</span>
|
||||
</div>
|
||||
{buExpanded && buChanges !== null && buChanges.length > 0 && (
|
||||
<div style={BU_DETAIL_SECTION}>
|
||||
{buChanges.map((change, idx) => (
|
||||
<div key={change.id || idx} style={BU_DETAIL_ROW} title={change.finding_title || ''}>
|
||||
<span style={BU_DETAIL_HOST}>{change.host_name || change.finding_id}</span>
|
||||
<span style={BU_FROM_STYLE}>{shortenBU(change.previous_bu)}</span>
|
||||
<ArrowRight style={BU_ARROW_STYLE} />
|
||||
<span style={BU_TO_STYLE}>{shortenBU(change.new_bu)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{buExpanded && buChanges !== null && buChanges.length === 0 && (
|
||||
<div style={{ ...BU_DETAIL_SECTION, color: '#64748B', fontSize: '0.6rem', textAlign: 'center', padding: '0.5rem' }}>
|
||||
No detailed BU change records found for this sync
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div key={key} style={DETAIL_ROW}>
|
||||
<span>{label}</span>
|
||||
|
||||
@@ -131,12 +131,21 @@ export default function ArcherPage({
|
||||
|
||||
// Open the Create Jira Ticket modal pre-populated with Archer ticket data
|
||||
const openCreateJiraModal = (ticket) => {
|
||||
// Build description with available Archer ticket information
|
||||
const descParts = [];
|
||||
descParts.push(`Archer risk acceptance ticket: ${ticket.exc_number || 'N/A'}`);
|
||||
if (ticket.cve_id) descParts.push(`CVE: ${ticket.cve_id}`);
|
||||
if (ticket.vendor) descParts.push(`Vendor: ${ticket.vendor}`);
|
||||
if (ticket.status) descParts.push(`Status: ${ticket.status}`);
|
||||
if (ticket.archer_url) descParts.push(`Archer URL: ${ticket.archer_url}`);
|
||||
const description = descParts.join('\n');
|
||||
|
||||
setCreateJiraForm({
|
||||
summary: ticket.exc_number || '',
|
||||
cve_id: ticket.cve_id || '',
|
||||
vendor: ticket.vendor || '',
|
||||
source_context: 'archer',
|
||||
description: '',
|
||||
description,
|
||||
project_key: '',
|
||||
issue_type: '',
|
||||
});
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
FileText, Plus, Edit, Copy, Trash2, ChevronDown, ChevronRight,
|
||||
Loader, AlertCircle, RefreshCw,
|
||||
Loader, AlertCircle, RefreshCw, Eye, EyeOff, Clipboard, Check,
|
||||
} from 'lucide-react';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import TemplateFormModal from '../TemplateFormModal';
|
||||
@@ -14,6 +14,18 @@ import DeleteConfirmModal from '../DeleteConfirmModal';
|
||||
|
||||
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
||||
|
||||
// Section field mapping — ordered: static first, then semi-static
|
||||
const SECTIONS = [
|
||||
{ key: 'environment_overview', label: 'Environment Overview' },
|
||||
{ key: 'segmentation', label: 'Segmentation' },
|
||||
{ key: 'mitigating_controls', label: 'Mitigating Controls' },
|
||||
{ key: 'additional_info', label: 'Additional Info/Background' },
|
||||
{ key: 'charter_network_banner', label: 'Charter Network Banner' },
|
||||
{ key: 'data_classification', label: 'Data Classification' },
|
||||
{ key: 'charter_network', label: 'Charter Network' },
|
||||
{ key: 'additional_access_list', label: 'Additional Access List' },
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Styles — dark theme tactical intelligence aesthetic
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -223,6 +235,11 @@ export default function ArcherTemplatePage() {
|
||||
// Modal state for create/edit/clone
|
||||
const [modalState, setModalState] = useState({ open: false, mode: 'create', template: null });
|
||||
const [deleteTarget, setDeleteTarget] = useState(null);
|
||||
// View panel state — which template ID is expanded for viewing
|
||||
const [viewExpandedId, setViewExpandedId] = useState(null);
|
||||
// Copy state for view panel
|
||||
const [copiedSections, setCopiedSections] = useState({});
|
||||
const [copyAllCopied, setCopyAllCopied] = useState(false);
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Fetch templates
|
||||
@@ -265,6 +282,41 @@ export default function ArcherTemplatePage() {
|
||||
setExpandedVendors(prev => ({ ...prev, [vendor]: !prev[vendor] }));
|
||||
};
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// View panel toggle and copy handlers
|
||||
// -------------------------------------------------------------------------
|
||||
const toggleView = (templateId) => {
|
||||
setViewExpandedId(prev => prev === templateId ? null : templateId);
|
||||
setCopiedSections({});
|
||||
setCopyAllCopied(false);
|
||||
};
|
||||
|
||||
const handleCopySection = async (sectionKey, content) => {
|
||||
if (!content) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(content);
|
||||
setCopiedSections(prev => ({ ...prev, [sectionKey]: true }));
|
||||
setTimeout(() => {
|
||||
setCopiedSections(prev => ({ ...prev, [sectionKey]: false }));
|
||||
}, 2000);
|
||||
} catch (_err) { /* clipboard failed */ }
|
||||
};
|
||||
|
||||
const handleCopyAll = async (template) => {
|
||||
const parts = [];
|
||||
for (const section of SECTIONS) {
|
||||
const content = template[section.key];
|
||||
if (content && content.trim()) {
|
||||
parts.push(`${section.label}\n${content}`);
|
||||
}
|
||||
}
|
||||
try {
|
||||
await navigator.clipboard.writeText(parts.join('\n\n'));
|
||||
setCopyAllCopied(true);
|
||||
setTimeout(() => setCopyAllCopied(false), 2000);
|
||||
} catch (_err) { /* clipboard failed */ }
|
||||
};
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Grouped data
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -363,36 +415,126 @@ export default function ArcherTemplatePage() {
|
||||
<div key={platform} style={STYLES.platformSubgroup}>
|
||||
<div style={STYLES.platformLabel}>{platform}</div>
|
||||
{platTemplates.map(template => (
|
||||
<div
|
||||
key={template.id}
|
||||
style={STYLES.templateRow}
|
||||
onMouseEnter={e => { e.currentTarget.style.background = 'rgba(0, 212, 255, 0.04)'; }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = 'transparent'; }}
|
||||
>
|
||||
<span style={STYLES.templateModel}>{template.model}</span>
|
||||
{canWrite() && (
|
||||
<div style={STYLES.templateActions}>
|
||||
<button
|
||||
style={STYLES.btnSmall}
|
||||
onClick={() => setModalState({ open: true, mode: 'edit', template })}
|
||||
title="Edit template"
|
||||
>
|
||||
<Edit size={12} />
|
||||
</button>
|
||||
<button
|
||||
style={STYLES.btnSmall}
|
||||
onClick={() => setModalState({ open: true, mode: 'clone', template })}
|
||||
title="Clone template"
|
||||
>
|
||||
<Copy size={12} />
|
||||
</button>
|
||||
<button
|
||||
style={STYLES.btnDanger}
|
||||
onClick={() => setDeleteTarget(template)}
|
||||
title="Delete template"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
<div key={template.id}>
|
||||
<div
|
||||
style={STYLES.templateRow}
|
||||
onMouseEnter={e => { e.currentTarget.style.background = 'rgba(0, 212, 255, 0.04)'; }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = 'transparent'; }}
|
||||
>
|
||||
<span
|
||||
style={{ ...STYLES.templateModel, cursor: 'pointer', display: 'flex', alignItems: 'center', gap: '0.4rem' }}
|
||||
onClick={() => toggleView(template.id)}
|
||||
title="View template sections"
|
||||
>
|
||||
{viewExpandedId === template.id
|
||||
? <EyeOff size={13} style={{ color: '#00d4ff' }} />
|
||||
: <Eye size={13} style={{ color: '#64748B' }} />
|
||||
}
|
||||
{template.model}
|
||||
</span>
|
||||
{canWrite() && (
|
||||
<div style={STYLES.templateActions}>
|
||||
<button
|
||||
style={STYLES.btnSmall}
|
||||
onClick={() => setModalState({ open: true, mode: 'edit', template })}
|
||||
title="Edit template"
|
||||
>
|
||||
<Edit size={12} />
|
||||
</button>
|
||||
<button
|
||||
style={STYLES.btnSmall}
|
||||
onClick={() => setModalState({ open: true, mode: 'clone', template })}
|
||||
title="Clone template"
|
||||
>
|
||||
<Copy size={12} />
|
||||
</button>
|
||||
<button
|
||||
style={STYLES.btnDanger}
|
||||
onClick={() => setDeleteTarget(template)}
|
||||
title="Delete template"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Expandable view panel */}
|
||||
{viewExpandedId === template.id && (
|
||||
<div style={{
|
||||
margin: '0.25rem 0 0.75rem 1.5rem',
|
||||
padding: '0.75rem 1rem',
|
||||
background: 'rgba(15, 23, 42, 0.6)',
|
||||
border: '1px solid rgba(0, 212, 255, 0.12)',
|
||||
borderRadius: '8px',
|
||||
}}>
|
||||
{/* Copy All button */}
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: '0.5rem' }}>
|
||||
<button
|
||||
onClick={() => handleCopyAll(template)}
|
||||
style={{
|
||||
padding: '0.3rem 0.6rem',
|
||||
borderRadius: '5px',
|
||||
border: copyAllCopied ? '1px solid rgba(34, 197, 94, 0.4)' : '1px solid rgba(0, 212, 255, 0.3)',
|
||||
background: copyAllCopied ? 'rgba(34, 197, 94, 0.12)' : 'rgba(0, 212, 255, 0.08)',
|
||||
color: copyAllCopied ? '#22c55e' : '#00d4ff',
|
||||
fontSize: '0.7rem',
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.3rem',
|
||||
}}
|
||||
>
|
||||
{copyAllCopied ? <><Check size={11} /> Copied!</> : <><Clipboard size={11} /> Copy All</>}
|
||||
</button>
|
||||
</div>
|
||||
{/* Section blocks */}
|
||||
{SECTIONS.map(section => {
|
||||
const content = template[section.key];
|
||||
const isEmpty = !content || !content.trim();
|
||||
const isCopied = copiedSections[section.key];
|
||||
return (
|
||||
<div key={section.key} style={{
|
||||
marginBottom: '0.5rem',
|
||||
padding: '0.5rem 0.6rem',
|
||||
background: 'rgba(30, 41, 59, 0.5)',
|
||||
border: '1px solid rgba(100, 116, 139, 0.12)',
|
||||
borderRadius: '6px',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '0.25rem' }}>
|
||||
<span style={{ fontSize: '0.72rem', fontWeight: 600, color: '#94a3b8', textTransform: 'uppercase', letterSpacing: '0.04em' }}>
|
||||
{section.label}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => handleCopySection(section.key, content)}
|
||||
disabled={isEmpty}
|
||||
style={{
|
||||
padding: '0.2rem 0.4rem',
|
||||
borderRadius: '4px',
|
||||
border: isCopied ? '1px solid rgba(34, 197, 94, 0.3)' : '1px solid rgba(100, 116, 139, 0.25)',
|
||||
background: isCopied ? 'rgba(34, 197, 94, 0.1)' : 'rgba(100, 116, 139, 0.1)',
|
||||
color: isCopied ? '#22c55e' : '#94a3b8',
|
||||
fontSize: '0.65rem',
|
||||
cursor: isEmpty ? 'not-allowed' : 'pointer',
|
||||
opacity: isEmpty ? 0.4 : 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.2rem',
|
||||
}}
|
||||
>
|
||||
{isCopied ? <><Check size={9} /> Copied!</> : <><Clipboard size={9} /> Copy</>}
|
||||
</button>
|
||||
</div>
|
||||
{isEmpty ? (
|
||||
<div style={{ fontSize: '0.78rem', color: '#475569', fontStyle: 'italic' }}>No content stored</div>
|
||||
) : (
|
||||
<div style={{ fontSize: '0.78rem', color: '#e0e0e0', lineHeight: 1.5, whiteSpace: 'pre-wrap', wordBreak: 'break-word', maxHeight: '150px', overflowY: 'auto' }}>
|
||||
{content}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -248,7 +248,7 @@ function TrendChart({ months }) {
|
||||
<Bar yAxisId="count" dataKey="compliant_count" fill="#10B981" fillOpacity={0.6} name="Compliant Devices" />
|
||||
<Line yAxisId="pct" dataKey="compliance_pct" stroke={TEAL} strokeWidth={2} dot={{ r: 3 }} name="Actual %" />
|
||||
<Line yAxisId="pct" dataKey="forecast_pct" stroke={TEAL} strokeWidth={2} strokeDasharray="5 3" dot={false} name="Forecast %" />
|
||||
<ReferenceLine yAxisId="pct" y={months[0]?.target_pct || 95} stroke="#F59E0B" strokeDasharray="4 4" label={{ value: 'Target', fill: '#F59E0B', fontSize: 10 }} />
|
||||
<ReferenceLine yAxisId="pct" y={months[0]?.target != null ? Math.round(months[0].target * 100) : 95} stroke="#F59E0B" strokeDasharray="4 4" label={{ value: 'Target', fill: '#F59E0B', fontSize: 10 }} />
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
@@ -258,6 +258,7 @@ function TrendChart({ months }) {
|
||||
// ---------------------------------------------------------------------------
|
||||
// Aggregated Burndown Chart
|
||||
// ---------------------------------------------------------------------------
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
function AggregatedBurndownChart({ data, loading, error }) {
|
||||
if (loading) {
|
||||
return (
|
||||
@@ -621,6 +622,7 @@ function VerticalDetailView({ vertical, onBack, onSelectMetric }) {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [expandedMetrics, setExpandedMetrics] = useState(new Set());
|
||||
const [teamFilter, setTeamFilter] = useState(''); // '' = all teams (rollup view)
|
||||
// ⚠️ CONVENTION: Missing error state — .catch() below silently swallows fetch errors without displaying them to the user. Add an error state and render an error message.
|
||||
// ⚠️ CONVENTION: Missing error state — .catch() silently swallows fetch errors without displaying them to the user. Add an error state and render an error message (see main CCPMetricsPage pattern).
|
||||
|
||||
useEffect(() => {
|
||||
@@ -1132,7 +1134,7 @@ function DataManagementPanel({ onClose, onDataChanged }) {
|
||||
<div style={{ background: 'linear-gradient(180deg, #0F1A2E 0%, #0A1628 100%)', border: '1px solid rgba(239, 68, 68, 0.3)', borderRadius: '1rem', width: '90%', maxWidth: '800px', maxHeight: '80vh', overflow: 'auto', padding: '2rem' }} onClick={e => e.stopPropagation()}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.5rem' }}>
|
||||
<h2 style={{ fontSize: '1.1rem', fontWeight: '700', color: '#E2E8F0', margin: 0 }}>Manage Data</h2>
|
||||
{/* ⚠️ CONVENTION: Use lucide-react <X /> icon instead of raw Unicode character */}
|
||||
{/* ⚠️ CONVENTION: Use lucide-react <X /> icon instead of raw Unicode character for the close button */}
|
||||
<button onClick={onClose} style={{ background: 'none', border: 'none', color: '#64748B', cursor: 'pointer' }}>✕</button>
|
||||
</div>
|
||||
|
||||
@@ -1539,22 +1541,30 @@ function ForecastBurndownChart({ metricId }) {
|
||||
// ---------------------------------------------------------------------------
|
||||
export default function CCPMetricsPage() {
|
||||
const { isAdmin, canWrite } = useAuth();
|
||||
const [stats, setStats] = useState(null);
|
||||
const [trend, setTrend] = useState(null);
|
||||
// Cross-metric aggregate stats — retained only for metric_breakdown (MetricBreakdownPanel)
|
||||
const [metricBreakdownData, setMetricBreakdownData] = useState(null);
|
||||
const [metricsData, setMetricsData] = useState(null);
|
||||
const [burndownData, setBurndownData] = useState(null);
|
||||
const [burndownLoading, setBurndownLoading] = useState(true);
|
||||
const [burndownError, setBurndownError] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [showUpload, setShowUpload] = useState(false);
|
||||
const [showManage, setShowManage] = useState(false);
|
||||
const [showMetricBreakdown, setShowMetricBreakdown] = useState(false);
|
||||
const [forecastMetric, setForecastMetric] = useState(null);
|
||||
|
||||
// Per-metric CCP Summary state (Task 4.1)
|
||||
const [selectedCCPMetric, setSelectedCCPMetric] = useState(null);
|
||||
const [metricStats, setMetricStats] = useState(null);
|
||||
const [metricTrend, setMetricTrend] = useState(null);
|
||||
const [metricStatsLoading, setMetricStatsLoading] = useState(false);
|
||||
const [metricStatsError, setMetricStatsError] = useState(null);
|
||||
const [metricTrendLoading, setMetricTrendLoading] = useState(false);
|
||||
const [metricTrendError, setMetricTrendError] = useState(null);
|
||||
|
||||
// Request counter for stale response discarding (Task 4.1 / Requirement 8.5)
|
||||
const metricRequestCounter = useRef(0);
|
||||
|
||||
// Drill-down state (metric-first hierarchy: metric → vertical → team)
|
||||
const [selectedMetric, setSelectedMetric] = useState(null);
|
||||
const [selectedMetricData, setSelectedMetricData] = useState(null); // eslint-disable-line no-unused-vars
|
||||
const [_selectedMetricData, setSelectedMetricData] = useState(null);
|
||||
const [selectedVertical, setSelectedVertical] = useState(null);
|
||||
const [selectedVerticalData, setSelectedVerticalData] = useState(null);
|
||||
const [selectedTeam, setSelectedTeam] = useState(null);
|
||||
@@ -1562,31 +1572,75 @@ export default function CCPMetricsPage() {
|
||||
const fetchData = useCallback(() => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setBurndownLoading(true);
|
||||
setBurndownError(null);
|
||||
Promise.all([
|
||||
// Retain /vcl-multi/stats only for metric_breakdown field used by MetricBreakdownPanel
|
||||
fetch(`${API_BASE}/compliance/vcl-multi/stats`, { credentials: 'include' }).then(r => { if (!r.ok) throw new Error('Failed to load stats'); return r.json(); }),
|
||||
fetch(`${API_BASE}/compliance/vcl-multi/trend`, { credentials: 'include' }).then(r => { if (!r.ok) throw new Error('Failed to load trend'); return r.json(); }),
|
||||
fetch(`${API_BASE}/compliance/vcl-multi/metrics`, { credentials: 'include' }).then(r => { if (!r.ok) throw new Error('Failed to load metrics'); return r.json(); }),
|
||||
]).then(([statsData, trendData, metricsResult]) => {
|
||||
setStats(statsData);
|
||||
setTrend(trendData);
|
||||
]).then(([statsData, metricsResult]) => {
|
||||
setMetricBreakdownData(statsData?.metric_breakdown || null);
|
||||
setMetricsData(metricsResult);
|
||||
setLoading(false);
|
||||
}).catch(err => {
|
||||
setError(err.message);
|
||||
setLoading(false);
|
||||
});
|
||||
|
||||
// Fetch burndown independently so a failure doesn't block the rest of the page
|
||||
fetch(`${API_BASE}/compliance/vcl-multi/burndown`, { credentials: 'include' })
|
||||
.then(r => { if (!r.ok) throw new Error('Failed to load burndown'); return r.json(); })
|
||||
.then(data => { setBurndownData(data); setBurndownLoading(false); })
|
||||
.catch(err => { setBurndownError(err.message); setBurndownLoading(false); });
|
||||
}, []);
|
||||
|
||||
useEffect(() => { fetchData(); }, [fetchData]);
|
||||
|
||||
// Handle CCP metric selection (Task 4.2)
|
||||
const handleCCPMetricSelect = useCallback((metricId) => {
|
||||
setSelectedCCPMetric(metricId);
|
||||
}, []);
|
||||
|
||||
// Per-metric data fetching effect — will be wired in task 5.1
|
||||
// For now, just reset state when metric changes
|
||||
useEffect(() => {
|
||||
if (!selectedCCPMetric) {
|
||||
setMetricStats(null);
|
||||
setMetricTrend(null);
|
||||
setMetricStatsLoading(false);
|
||||
setMetricStatsError(null);
|
||||
setMetricTrendLoading(false);
|
||||
setMetricTrendError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const currentRequest = ++metricRequestCounter.current;
|
||||
setMetricStatsLoading(true);
|
||||
setMetricStatsError(null);
|
||||
setMetricTrendLoading(true);
|
||||
setMetricTrendError(null);
|
||||
|
||||
// Fetch per-metric stats
|
||||
fetch(`${API_BASE}/compliance/vcl-multi/metric/${encodeURIComponent(selectedCCPMetric)}/stats`, { credentials: 'include' })
|
||||
.then(r => { if (!r.ok) throw new Error('Failed to load metric stats'); return r.json(); })
|
||||
.then(data => {
|
||||
if (currentRequest !== metricRequestCounter.current) return;
|
||||
setMetricStats(data);
|
||||
setMetricStatsLoading(false);
|
||||
})
|
||||
.catch(err => {
|
||||
if (currentRequest !== metricRequestCounter.current) return;
|
||||
setMetricStatsError(err.message);
|
||||
setMetricStatsLoading(false);
|
||||
});
|
||||
|
||||
// Fetch per-metric trend
|
||||
fetch(`${API_BASE}/compliance/vcl-multi/metric/${encodeURIComponent(selectedCCPMetric)}/trend`, { credentials: 'include' })
|
||||
.then(r => { if (!r.ok) throw new Error('Failed to load metric trend'); return r.json(); })
|
||||
.then(data => {
|
||||
if (currentRequest !== metricRequestCounter.current) return;
|
||||
setMetricTrend(data);
|
||||
setMetricTrendLoading(false);
|
||||
})
|
||||
.catch(err => {
|
||||
if (currentRequest !== metricRequestCounter.current) return;
|
||||
setMetricTrendError(err.message);
|
||||
setMetricTrendLoading(false);
|
||||
});
|
||||
}, [selectedCCPMetric]);
|
||||
|
||||
const handleUploadComplete = () => {
|
||||
setShowUpload(false);
|
||||
fetchData();
|
||||
@@ -1709,60 +1763,93 @@ export default function CCPMetricsPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && stats && (
|
||||
{!loading && !error && metricsData && (
|
||||
<>
|
||||
{/* Stats bar */}
|
||||
<StatsBar
|
||||
stats={stats.stats}
|
||||
onNonCompliantClick={() => setShowMetricBreakdown(!showMetricBreakdown)}
|
||||
ncExpanded={showMetricBreakdown}
|
||||
/>
|
||||
{/* Metric Selector — drives CCP Summary (Task 4.2) */}
|
||||
<div style={{ marginBottom: '1.5rem' }}>
|
||||
<MetricSelector onMetricSelect={handleCCPMetricSelect} selectedMetric={selectedCCPMetric} />
|
||||
</div>
|
||||
|
||||
{/* Metric breakdown (revealed when Non-Compliant is clicked) */}
|
||||
{showMetricBreakdown && (
|
||||
<MetricBreakdownPanel metrics={stats.metric_breakdown} />
|
||||
{/* CCP Summary section — per-metric stats, trend, donut, burndown */}
|
||||
{selectedCCPMetric && (
|
||||
<>
|
||||
{/* Stats bar — driven by per-metric stats */}
|
||||
{metricStatsLoading ? (
|
||||
<div style={{ ...CARD_STYLE, textAlign: 'center', padding: '2rem', marginBottom: '1.5rem' }}>
|
||||
<Loader style={{ animation: 'spin 1s linear infinite', width: '20px', height: '20px', color: '#64748B' }} />
|
||||
<div style={{ fontSize: '0.75rem', color: '#64748B', marginTop: '0.5rem' }}>Loading metric stats...</div>
|
||||
</div>
|
||||
) : metricStatsError ? (
|
||||
<div style={{ ...CARD_STYLE, borderColor: 'rgba(239, 68, 68, 0.3)', display: 'flex', alignItems: 'center', gap: '0.75rem', padding: '1rem', marginBottom: '1.5rem' }}>
|
||||
<AlertCircle style={{ width: '18px', height: '18px', color: '#EF4444', flexShrink: 0 }} />
|
||||
<span style={{ color: '#EF4444', fontSize: '0.8rem' }}>{metricStatsError}</span>
|
||||
</div>
|
||||
) : metricStats ? (
|
||||
<StatsBar
|
||||
stats={{
|
||||
total_devices: metricStats.total_devices || 0,
|
||||
compliant: metricStats.compliant || 0,
|
||||
non_compliant: metricStats.non_compliant || 0,
|
||||
compliance_pct: metricStats.compliance_pct || 0,
|
||||
target_pct: metricStats.target ? Math.round(metricStats.target * 100) : 0,
|
||||
}}
|
||||
onNonCompliantClick={() => setShowMetricBreakdown(!showMetricBreakdown)}
|
||||
ncExpanded={showMetricBreakdown}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{/* Metric breakdown (revealed when Non-Compliant is clicked) — always cross-metric */}
|
||||
{showMetricBreakdown && (
|
||||
<MetricBreakdownPanel metrics={metricBreakdownData} />
|
||||
)}
|
||||
|
||||
{/* Charts row — trend and donut */}
|
||||
<div style={{ display: 'flex', gap: '1.5rem', marginBottom: '1.5rem', flexWrap: 'wrap' }}>
|
||||
{/* TrendChart — driven by per-metric trend */}
|
||||
{metricTrendLoading ? (
|
||||
<div style={{ ...CARD_STYLE, flex: 2, textAlign: 'center', padding: '2rem' }}>
|
||||
<Loader style={{ animation: 'spin 1s linear infinite', width: '20px', height: '20px', color: '#64748B' }} />
|
||||
<div style={{ fontSize: '0.75rem', color: '#64748B', marginTop: '0.5rem' }}>Loading trend data...</div>
|
||||
</div>
|
||||
) : metricTrendError ? (
|
||||
<div style={{ ...CARD_STYLE, flex: 2, display: 'flex', alignItems: 'center', gap: '0.75rem', borderColor: 'rgba(239, 68, 68, 0.3)', padding: '1.25rem' }}>
|
||||
<AlertCircle style={{ width: '16px', height: '16px', color: '#EF4444', flexShrink: 0 }} />
|
||||
<span style={{ color: '#EF4444', fontSize: '0.8rem' }}>{metricTrendError}</span>
|
||||
</div>
|
||||
) : (
|
||||
<TrendChart months={metricTrend?.months} />
|
||||
)}
|
||||
|
||||
{/* DonutChart — driven by per-metric stats donut */}
|
||||
{metricStatsLoading ? (
|
||||
<div style={{ ...CARD_STYLE, flex: 1, textAlign: 'center', padding: '2rem' }}>
|
||||
<Loader style={{ animation: 'spin 1s linear infinite', width: '20px', height: '20px', color: '#64748B' }} />
|
||||
</div>
|
||||
) : metricStatsError ? (
|
||||
<div style={{ ...CARD_STYLE, flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#64748B', fontSize: '0.8rem' }}>
|
||||
No donut data available
|
||||
</div>
|
||||
) : (
|
||||
<DonutChart donut={metricStats?.donut} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Forecast Burndown — driven by selectedCCPMetric */}
|
||||
<div style={{ marginBottom: '1.5rem' }}>
|
||||
<ForecastBurndownChart metricId={selectedCCPMetric} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Charts row */}
|
||||
<div style={{ display: 'flex', gap: '1.5rem', marginBottom: '1.5rem', flexWrap: 'wrap' }}>
|
||||
<TrendChart months={trend?.months} />
|
||||
<DonutChart donut={stats.donut} />
|
||||
</div>
|
||||
|
||||
{/* Aggregated burndown forecast */}
|
||||
<AggregatedBurndownChart
|
||||
data={burndownData}
|
||||
loading={burndownLoading}
|
||||
error={burndownError}
|
||||
/>
|
||||
|
||||
{/* Per-Metric Forecast Burndown */}
|
||||
<div style={{ ...CARD_STYLE, marginBottom: '1.5rem' }}>
|
||||
<h3 style={{ fontSize: '0.85rem', fontWeight: '700', color: '#E2E8F0', margin: '0 0 1rem 0' }}>
|
||||
Per-Metric Forecast Burndown
|
||||
</h3>
|
||||
<MetricSelector onMetricSelect={setForecastMetric} selectedMetric={forecastMetric} />
|
||||
<div style={{ marginTop: '1rem' }}>
|
||||
<ForecastBurndownChart metricId={forecastMetric} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Metrics overview table (metric-first model) */}
|
||||
<MetricTable
|
||||
metrics={metricsData?.metrics}
|
||||
onSelectMetric={(metricId) => setSelectedMetric(metricId)}
|
||||
/>
|
||||
|
||||
{/* Last upload info */}
|
||||
{stats.last_upload_date && (
|
||||
<div style={{ marginTop: '1rem', fontSize: '0.65rem', color: '#475569', textAlign: 'right' }}>
|
||||
Last upload: {stats.last_upload_date}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{!loading && !error && (!stats || !stats.vertical_breakdown || stats.vertical_breakdown.length === 0) && (
|
||||
{!loading && !error && !metricsData && (
|
||||
<div style={{ ...CARD_STYLE, textAlign: 'center', padding: '3rem', marginTop: '2rem' }}>
|
||||
<Building2 style={{ width: '48px', height: '48px', color: '#334155', margin: '0 auto 1rem' }} />
|
||||
<div style={{ fontSize: '1rem', color: '#94A3B8', marginBottom: '0.5rem' }}>No multi-vertical data yet</div>
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { Upload, MessageSquare, RefreshCw, AlertCircle, Loader, RotateCcw, Info } from 'lucide-react';
|
||||
import { Upload, MessageSquare, RefreshCw, AlertCircle, Loader, RotateCcw, Info, FileSpreadsheet } from 'lucide-react';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import ComplianceUploadModal from './ComplianceUploadModal';
|
||||
import ComplianceDetailPanel from './ComplianceDetailPanel';
|
||||
import ComplianceChartsPanel from './ComplianceChartsPanel';
|
||||
import MetricInfoPanel from './MetricInfoPanel';
|
||||
import VCLReportPage from './VCLReportPage';
|
||||
import LoaderModal from '../LoaderModal';
|
||||
import metricDefinitionsRaw from '../../data/metricDefinitions.json';
|
||||
import metricCategoriesConfig from '../../data/complianceCategories.json';
|
||||
|
||||
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
||||
const TEAL = '#14B8A6';
|
||||
@@ -87,6 +89,32 @@ function groupByMetricFamily(allEntries, team) {
|
||||
}));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Non-metric category derivation
|
||||
// ---------------------------------------------------------------------------
|
||||
function deriveNonMetricCategories(devices, summaryEntries, categoriesConfig) {
|
||||
const summaryIds = new Set(summaryEntries.map(e => e.metric_id));
|
||||
const countMap = new Map();
|
||||
|
||||
for (const device of devices) {
|
||||
if (!device.failing_metrics) continue;
|
||||
const seen = new Set();
|
||||
for (const m of device.failing_metrics) {
|
||||
if (!m.metric_id || summaryIds.has(m.metric_id) || seen.has(m.metric_id)) continue;
|
||||
seen.add(m.metric_id);
|
||||
countMap.set(m.metric_id, (countMap.get(m.metric_id) || 0) + 1);
|
||||
}
|
||||
}
|
||||
|
||||
return [...countMap.entries()]
|
||||
.map(([metricId, count]) => {
|
||||
const categoryName = categoriesConfig[metricId] || null;
|
||||
const color = (categoryName && CATEGORY_COLORS[categoryName]) || '#94A3B8';
|
||||
return { metricId, count, category: categoryName || 'Unknown', color };
|
||||
})
|
||||
.sort((a, b) => a.metricId.localeCompare(b.metricId));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sub-components
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -246,6 +274,71 @@ function SeenBadge({ count }) {
|
||||
);
|
||||
}
|
||||
|
||||
function FilterChip({ metricId, count, color, active, dimmed, onClick }) {
|
||||
const label = metricId.length > 24 ? metricId.slice(0, 24) + '…' : metricId;
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
title={metricId.length > 24 ? metricId : undefined}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.5rem',
|
||||
padding: '0.375rem 0.75rem',
|
||||
borderRadius: '999px',
|
||||
border: `1px solid ${active ? color : color + '40'}`,
|
||||
background: active ? `${color}1F` : `${color}0A`,
|
||||
cursor: 'pointer',
|
||||
opacity: dimmed ? 0.5 : 1,
|
||||
transition: 'all 0.15s',
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.borderColor = color + '80'; }}
|
||||
onMouseLeave={e => { e.currentTarget.style.borderColor = active ? color : color + '40'; }}
|
||||
>
|
||||
<span style={{
|
||||
width: '6px', height: '6px', borderRadius: '50%',
|
||||
background: color, flexShrink: 0,
|
||||
}} />
|
||||
<span style={{ fontSize: '0.72rem', color, fontFamily: 'monospace', fontWeight: '600', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', maxWidth: '150px' }}>
|
||||
{label}
|
||||
</span>
|
||||
<span style={{ fontSize: '0.68rem', fontFamily: 'monospace', color: '#64748B', fontWeight: '700' }}>
|
||||
{count}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function CategoryFilterBar({ categories, activeFilter, onFilterSelect, onClear, dimmed }) {
|
||||
if (!categories || categories.length === 0) return null;
|
||||
return (
|
||||
<div style={{ marginBottom: '1.5rem', opacity: dimmed ? 0.5 : 1, transition: 'opacity 0.15s' }}>
|
||||
<div style={{ fontSize: '0.65rem', color: '#334155', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: '0.625rem' }}>
|
||||
Non-Metric Categories
|
||||
{activeFilter && (
|
||||
<button onClick={onClear}
|
||||
style={{ marginLeft: '0.75rem', color: TEAL, background: 'none', border: 'none', cursor: 'pointer', fontSize: '0.65rem', fontFamily: 'monospace' }}>
|
||||
× clear filter
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
|
||||
{categories.map(cat => (
|
||||
<FilterChip
|
||||
key={cat.metricId}
|
||||
metricId={cat.metricId}
|
||||
count={cat.count}
|
||||
color={cat.color}
|
||||
active={activeFilter === cat.metricId}
|
||||
dimmed={false}
|
||||
onClick={() => onFilterSelect(cat.metricId)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main Page
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -256,7 +349,7 @@ export default function CompliancePage({ onNavigate }) {
|
||||
const [activeTeam, setActiveTeam] = useState(() => availableTeams[0] || 'STEAM');
|
||||
const [activeTab, setActiveTab] = useState('active');
|
||||
const [vclView, setVclView] = useState(false);
|
||||
const [metricFilter, setMetricFilter] = useState(null);
|
||||
const [filterState, setFilterState] = useState(null);
|
||||
const [hostSearch, setHostSearch] = useState('');
|
||||
const [summary, setSummary] = useState({ entries: [], overall_scores: {}, upload: null });
|
||||
const [devices, setDevices] = useState([]);
|
||||
@@ -269,6 +362,10 @@ export default function CompliancePage({ onNavigate }) {
|
||||
const [rollbackResult, setRollbackResult] = useState(null);
|
||||
const [infoMetric, setInfoMetric] = useState(null);
|
||||
const [hoveredMetric, setHoveredMetric] = useState(null);
|
||||
const [selectedDevices, setSelectedDevices] = useState(new Set());
|
||||
const [showLoaderModal, setShowLoaderModal] = useState(false);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(50);
|
||||
const hoverTimeoutRef = useRef(null);
|
||||
const hoveredCardRef = useRef(null);
|
||||
|
||||
@@ -297,9 +394,11 @@ export default function CompliancePage({ onNavigate }) {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setMetricFilter(null);
|
||||
setFilterState(null);
|
||||
setHostSearch('');
|
||||
setSelectedHost(null);
|
||||
setSelectedDevices(new Set());
|
||||
setCurrentPage(1);
|
||||
fetchSummary(activeTeam);
|
||||
fetchDevices(activeTeam, activeTab);
|
||||
}, [activeTeam]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
@@ -313,15 +412,51 @@ export default function CompliancePage({ onNavigate }) {
|
||||
}, [adminScope]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
useEffect(() => {
|
||||
setMetricFilter(null);
|
||||
setFilterState(null);
|
||||
setSelectedDevices(new Set());
|
||||
setCurrentPage(1);
|
||||
fetchDevices(activeTeam, activeTab);
|
||||
}, [activeTab]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Reset page when filter changes
|
||||
useEffect(() => { setCurrentPage(1); }, [filterState]);
|
||||
|
||||
const refresh = () => {
|
||||
fetchSummary(activeTeam);
|
||||
fetchDevices(activeTeam, activeTab);
|
||||
};
|
||||
|
||||
const toggleDeviceSelection = (hostname) => {
|
||||
setSelectedDevices(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(hostname)) next.delete(hostname);
|
||||
else next.add(hostname);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const toggleSelectAll = () => {
|
||||
if (selectedDevices.size === paginatedDevices.length && paginatedDevices.every(d => selectedDevices.has(d.hostname))) {
|
||||
setSelectedDevices(new Set());
|
||||
} else {
|
||||
setSelectedDevices(new Set(paginatedDevices.map(d => d.hostname)));
|
||||
}
|
||||
};
|
||||
|
||||
const openGraniteLoader = () => {
|
||||
setShowLoaderModal(true);
|
||||
};
|
||||
|
||||
const getLoaderDevices = () => {
|
||||
return filteredDevices
|
||||
.filter(d => selectedDevices.has(d.hostname))
|
||||
.map(d => ({
|
||||
ip_address: d.ip_address || '',
|
||||
hostname: d.hostname || '',
|
||||
host_id: null,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleRollback = async () => {
|
||||
if (!lastUpload) return;
|
||||
setRollbackLoading(true);
|
||||
@@ -346,9 +481,19 @@ export default function CompliancePage({ onNavigate }) {
|
||||
|
||||
// In-memory filters
|
||||
const filteredDevices = devices
|
||||
.filter(d => !metricFilter || d.failing_metrics.some(m => metricFilter.includes(m.metric_id)))
|
||||
.filter(d => {
|
||||
if (!filterState) return true;
|
||||
if (filterState.type === 'metric') return d.failing_metrics.some(m => filterState.ids.includes(m.metric_id));
|
||||
if (filterState.type === 'nonmetric') return d.failing_metrics.some(m => m.metric_id === filterState.id);
|
||||
return true;
|
||||
})
|
||||
.filter(d => !hostSearch || d.hostname.toLowerCase().includes(hostSearch.toLowerCase()));
|
||||
|
||||
// Pagination
|
||||
const totalPages = Math.max(1, Math.ceil(filteredDevices.length / pageSize));
|
||||
const safePage = Math.min(currentPage, totalPages);
|
||||
const paginatedDevices = filteredDevices.slice((safePage - 1) * pageSize, safePage * pageSize);
|
||||
|
||||
const families = groupByMetricFamily(summary.entries, activeTeam);
|
||||
const lastUpload = summary.upload;
|
||||
|
||||
@@ -495,8 +640,8 @@ export default function CompliancePage({ onNavigate }) {
|
||||
<div style={{ marginBottom: '1.5rem' }}>
|
||||
<div style={{ fontSize: '0.65rem', color: '#334155', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: '0.625rem' }}>
|
||||
Metric Health — click to filter
|
||||
{metricFilter && (
|
||||
<button onClick={() => setMetricFilter(null)}
|
||||
{filterState && (
|
||||
<button onClick={() => setFilterState(null)}
|
||||
style={{ marginLeft: '0.75rem', color: TEAL, background: 'none', border: 'none', cursor: 'pointer', fontSize: '0.65rem', fontFamily: 'monospace' }}>
|
||||
× clear filter
|
||||
</button>
|
||||
@@ -505,7 +650,7 @@ export default function CompliancePage({ onNavigate }) {
|
||||
<div style={{ display: 'flex', gap: '0.625rem', flexWrap: 'wrap' }}>
|
||||
{families.map(family => {
|
||||
const familyIds = family.entries.map(e => e.metric_id);
|
||||
const isActive = metricFilter !== null && metricFilter.length === familyIds.length && familyIds.every(id => metricFilter.includes(id));
|
||||
const isActive = filterState?.type === 'metric' && filterState.ids.length === familyIds.length && familyIds.every(id => filterState.ids.includes(id));
|
||||
return (
|
||||
<div
|
||||
key={family.metricId}
|
||||
@@ -520,12 +665,12 @@ export default function CompliancePage({ onNavigate }) {
|
||||
hoveredCardRef.current = null;
|
||||
setHoveredMetric(null);
|
||||
}}
|
||||
style={{ display: 'flex', flex: '1 1 0', minWidth: '160px' }}
|
||||
style={{ display: 'flex', flex: '1 1 0', minWidth: '160px', opacity: filterState?.type === 'nonmetric' ? 0.5 : 1, transition: 'opacity 0.15s' }}
|
||||
>
|
||||
<MetricHealthCard
|
||||
family={family}
|
||||
active={isActive}
|
||||
onClick={() => setMetricFilter(isActive ? null : familyIds)}
|
||||
onClick={() => setFilterState(isActive ? null : { type: 'metric', ids: familyIds })}
|
||||
onInfoClick={(metricId) => setInfoMetric(metricId)}
|
||||
definitionLookup={METRIC_DEFINITIONS}
|
||||
/>
|
||||
@@ -591,6 +736,27 @@ export default function CompliancePage({ onNavigate }) {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* ── Non-metric category filter bar ─────────────────────── */}
|
||||
{!vclView && !loading && (() => {
|
||||
const nonMetricCategories = deriveNonMetricCategories(devices, summary.entries.filter(e => e.team === activeTeam), metricCategoriesConfig);
|
||||
if (nonMetricCategories.length === 0) return null;
|
||||
return (
|
||||
<CategoryFilterBar
|
||||
categories={nonMetricCategories}
|
||||
activeFilter={filterState?.type === 'nonmetric' ? filterState.id : null}
|
||||
onFilterSelect={(metricId) => {
|
||||
if (filterState?.type === 'nonmetric' && filterState.id === metricId) {
|
||||
setFilterState(null);
|
||||
} else {
|
||||
setFilterState({ type: 'nonmetric', id: metricId });
|
||||
}
|
||||
}}
|
||||
onClear={() => setFilterState(null)}
|
||||
dimmed={filterState?.type === 'metric'}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* ── Historical trend charts ──────────────────────────────── */}
|
||||
{!vclView && <ComplianceChartsPanel />}
|
||||
|
||||
@@ -606,7 +772,7 @@ export default function CompliancePage({ onNavigate }) {
|
||||
padding: '0.875rem 1rem', borderBottom: '1px solid rgba(255,255,255,0.05)',
|
||||
}}>
|
||||
{/* Active / Resolved tabs */}
|
||||
<div style={{ display: 'flex', gap: '0.25rem' }}>
|
||||
<div style={{ display: 'flex', gap: '0.25rem', alignItems: 'center' }}>
|
||||
{['active', 'resolved'].map(tab => {
|
||||
const isActive = activeTab === tab;
|
||||
return (
|
||||
@@ -629,12 +795,36 @@ export default function CompliancePage({ onNavigate }) {
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{selectedDevices.size > 0 && canWrite() && (
|
||||
<button
|
||||
onClick={openGraniteLoader}
|
||||
title="Generate Granite Loader Sheet from selected devices"
|
||||
style={{
|
||||
marginLeft: '0.75rem',
|
||||
padding: '0.35rem 0.75rem',
|
||||
display: 'flex', alignItems: 'center', gap: '0.35rem',
|
||||
background: 'rgba(124, 58, 237, 0.12)',
|
||||
border: '1px solid rgba(124, 58, 237, 0.5)',
|
||||
borderRadius: '0.25rem',
|
||||
color: '#A78BFA',
|
||||
cursor: 'pointer',
|
||||
fontFamily: 'monospace', fontSize: '0.72rem', fontWeight: '600',
|
||||
textTransform: 'uppercase', letterSpacing: '0.04em',
|
||||
transition: 'all 0.15s',
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.background = 'rgba(124, 58, 237, 0.2)'; e.currentTarget.style.borderColor = 'rgba(124, 58, 237, 0.8)'; }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = 'rgba(124, 58, 237, 0.12)'; e.currentTarget.style.borderColor = 'rgba(124, 58, 237, 0.5)'; }}
|
||||
>
|
||||
<FileSpreadsheet style={{ width: '13px', height: '13px' }} />
|
||||
Granite ({selectedDevices.size})
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Hostname search */}
|
||||
<input
|
||||
value={hostSearch}
|
||||
onChange={e => setHostSearch(e.target.value)}
|
||||
onChange={e => { setHostSearch(e.target.value); setCurrentPage(1); }}
|
||||
placeholder="Search hostname…"
|
||||
style={{
|
||||
background: 'rgba(15,23,42,0.8)', border: '1px solid rgba(20,184,166,0.2)',
|
||||
@@ -650,12 +840,22 @@ export default function CompliancePage({ onNavigate }) {
|
||||
{/* Column headers */}
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '2fr 1fr 0.8fr 1.8fr 1fr 1.2fr 0.5fr 0.4fr',
|
||||
gridTemplateColumns: '0.3fr 2fr 1fr 0.8fr 1.8fr 1fr 1.2fr 0.5fr 0.4fr',
|
||||
padding: '0.5rem 1rem',
|
||||
borderBottom: '1px solid rgba(255,255,255,0.05)',
|
||||
fontSize: '0.62rem', color: '#334155',
|
||||
fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: '0.08em',
|
||||
alignItems: 'center',
|
||||
}}>
|
||||
<span style={{ display: 'flex', justifyContent: 'center' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={paginatedDevices.length > 0 && paginatedDevices.every(d => selectedDevices.has(d.hostname))}
|
||||
onChange={toggleSelectAll}
|
||||
style={{ cursor: 'pointer', accentColor: TEAL }}
|
||||
title="Select all on this page"
|
||||
/>
|
||||
</span>
|
||||
<span>Hostname</span>
|
||||
<span>IP Address</span>
|
||||
<span>Type</span>
|
||||
@@ -677,18 +877,78 @@ export default function CompliancePage({ onNavigate }) {
|
||||
</div>
|
||||
) : filteredDevices.length === 0 ? (
|
||||
<div style={{ padding: '3rem', textAlign: 'center', color: '#334155', fontFamily: 'monospace', fontSize: '0.8rem' }}>
|
||||
{lastUpload === null ? 'No reports uploaded yet' : activeTab === 'active' ? 'No non-compliant devices' : 'No resolved items'}
|
||||
{lastUpload === null ? 'No reports uploaded yet' : filterState ? 'No devices match the selected filter' : activeTab === 'active' ? 'No non-compliant devices' : 'No resolved items'}
|
||||
</div>
|
||||
) : (
|
||||
filteredDevices.map(device => (
|
||||
paginatedDevices.map(device => (
|
||||
<DeviceRow
|
||||
key={device.hostname}
|
||||
device={device}
|
||||
selected={selectedHost === device.hostname}
|
||||
checked={selectedDevices.has(device.hostname)}
|
||||
onCheck={() => toggleDeviceSelection(device.hostname)}
|
||||
onClick={() => setSelectedHost(selectedHost === device.hostname ? null : device.hostname)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
|
||||
{/* Pagination controls */}
|
||||
{filteredDevices.length > 0 && (
|
||||
<div style={{
|
||||
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
||||
padding: '0.75rem 1rem', borderTop: '1px solid rgba(255,255,255,0.05)',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<span style={{ fontSize: '0.68rem', color: '#475569', fontFamily: 'monospace' }}>
|
||||
Showing {((safePage - 1) * pageSize) + 1}–{Math.min(safePage * pageSize, filteredDevices.length)} of {filteredDevices.length}
|
||||
</span>
|
||||
<select
|
||||
value={pageSize}
|
||||
onChange={e => { setPageSize(Number(e.target.value)); setCurrentPage(1); }}
|
||||
style={{
|
||||
background: 'rgba(15,23,42,0.8)', border: '1px solid rgba(20,184,166,0.2)',
|
||||
borderRadius: '0.25rem', color: '#94A3B8', fontSize: '0.68rem',
|
||||
fontFamily: 'monospace', padding: '0.2rem 0.4rem', cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<option value={25}>25</option>
|
||||
<option value={50}>50</option>
|
||||
<option value={100}>100</option>
|
||||
<option value={200}>200</option>
|
||||
</select>
|
||||
<span style={{ fontSize: '0.68rem', color: '#334155', fontFamily: 'monospace' }}>per page</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.25rem' }}>
|
||||
<button
|
||||
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
|
||||
disabled={safePage <= 1}
|
||||
style={{
|
||||
padding: '0.3rem 0.6rem', borderRadius: '0.25rem',
|
||||
border: '1px solid rgba(20,184,166,0.2)', background: 'transparent',
|
||||
color: safePage <= 1 ? '#1E293B' : '#94A3B8', cursor: safePage <= 1 ? 'default' : 'pointer',
|
||||
fontFamily: 'monospace', fontSize: '0.72rem',
|
||||
}}
|
||||
>
|
||||
←
|
||||
</button>
|
||||
<span style={{ fontSize: '0.68rem', color: '#64748B', fontFamily: 'monospace', padding: '0 0.5rem' }}>
|
||||
{safePage} / {totalPages}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
|
||||
disabled={safePage >= totalPages}
|
||||
style={{
|
||||
padding: '0.3rem 0.6rem', borderRadius: '0.25rem',
|
||||
border: '1px solid rgba(20,184,166,0.2)', background: 'transparent',
|
||||
color: safePage >= totalPages ? '#1E293B' : '#94A3B8', cursor: safePage >= totalPages ? 'default' : 'pointer',
|
||||
fontFamily: 'monospace', fontSize: '0.72rem',
|
||||
}}
|
||||
>
|
||||
→
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>}
|
||||
|
||||
{/* ── Detail panel ─────────────────────────────────────────── */}
|
||||
@@ -831,11 +1091,18 @@ export default function CompliancePage({ onNavigate }) {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Granite Loader Modal ─────────────────────────────────── */}
|
||||
<LoaderModal
|
||||
isOpen={showLoaderModal}
|
||||
onClose={() => setShowLoaderModal(false)}
|
||||
initialDevices={showLoaderModal ? getLoaderDevices() : null}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DeviceRow({ device, selected, onClick }) {
|
||||
function DeviceRow({ device, selected, checked, onCheck, onClick }) {
|
||||
const truncateText = (text, maxLen = 80) => {
|
||||
if (!text) return '—';
|
||||
return text.length > maxLen ? text.slice(0, maxLen) + '…' : text;
|
||||
@@ -846,7 +1113,7 @@ function DeviceRow({ device, selected, onClick }) {
|
||||
onClick={onClick}
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '2fr 1fr 0.8fr 1.8fr 1fr 1.2fr 0.5fr 0.4fr',
|
||||
gridTemplateColumns: '0.3fr 2fr 1fr 0.8fr 1.8fr 1fr 1.2fr 0.5fr 0.4fr',
|
||||
padding: '0.625rem 1rem',
|
||||
borderBottom: '1px solid rgba(255,255,255,0.04)',
|
||||
cursor: 'pointer',
|
||||
@@ -858,6 +1125,15 @@ function DeviceRow({ device, selected, onClick }) {
|
||||
onMouseEnter={e => { if (!selected) e.currentTarget.style.background = 'rgba(255,255,255,0.025)'; }}
|
||||
onMouseLeave={e => { if (!selected) e.currentTarget.style.background = 'transparent'; }}
|
||||
>
|
||||
{/* Checkbox */}
|
||||
<div style={{ display: 'flex', justifyContent: 'center' }} onClick={e => e.stopPropagation()}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={onCheck}
|
||||
style={{ cursor: 'pointer', accentColor: TEAL }}
|
||||
/>
|
||||
</div>
|
||||
{/* Hostname */}
|
||||
<div style={{ fontFamily: 'monospace', fontSize: '0.78rem', color: selected ? TEAL : '#E2E8F0', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{device.hostname}
|
||||
@@ -906,4 +1182,4 @@ function DeviceRow({ device, selected, onClick }) {
|
||||
}
|
||||
|
||||
// Named exports for testing
|
||||
export { computeWorstStatus, groupByMetricFamily };
|
||||
export { computeWorstStatus, groupByMetricFamily, deriveNonMetricCategories, CATEGORY_COLORS };
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { ListTodo, RefreshCw, CheckSquare, Square, Loader, AlertCircle, X, Plus, CheckCircle, ChevronDown, ChevronRight, FileSpreadsheet, FileText } from 'lucide-react';
|
||||
import { ListTodo, RefreshCw, CheckSquare, Square, Loader, AlertCircle, X, Plus, CheckCircle, ChevronDown, ChevronRight, FileSpreadsheet, FileText, Trash2 } from 'lucide-react';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import ConsolidationModal from '../ConsolidationModal';
|
||||
import LoaderModal from '../LoaderModal';
|
||||
import TemplateSelector from '../TemplateSelector';
|
||||
import { generateConsolidatedSummary, generateConsolidatedDescription, extractFirstCve, extractCommonVendor } from '../../utils/jiraConsolidation';
|
||||
import RemediationModal from '../RemediationModal';
|
||||
import { generateConsolidatedSummary, generateConsolidatedDescription, extractFirstCve, extractCommonVendor, appendRemediationNotes } from '../../utils/jiraConsolidation';
|
||||
import { groupQueueItems } from '../../utils/queueGrouping';
|
||||
import { VENDOR_PROJECT_KEYS, VENDOR_ISSUE_TYPES, STEAM_ISSUE_TYPES, isVendorProject, getIssueTypesForProject } from './JiraPage';
|
||||
|
||||
@@ -315,6 +316,15 @@ export default function IvantiTodoQueuePage() {
|
||||
// Archer Template Selector panel — tracks which item ID has the panel expanded (Requirement 5.1)
|
||||
const [templatePanelOpenId, setTemplatePanelOpenId] = useState(null);
|
||||
|
||||
// Remediation Modal state — tracks which item has the modal open
|
||||
const [remediationModalItem, setRemediationModalItem] = useState(null);
|
||||
|
||||
// Local note counts — allows updating badge without full page reload
|
||||
const [localNoteCounts, setLocalNoteCounts] = useState({});
|
||||
|
||||
// Delete confirmation dialog state (Requirement 7)
|
||||
const [deleteConfirmItem, setDeleteConfirmItem] = useState(null);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Data fetching
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -479,7 +489,7 @@ export default function IvantiTodoQueuePage() {
|
||||
// ---------------------------------------------------------------------------
|
||||
// Floating action bar — "Create Jira Ticket" handler (Requirements 2.3, 2.4)
|
||||
// ---------------------------------------------------------------------------
|
||||
const handleCreateJiraTicket = useCallback(() => {
|
||||
const handleCreateJiraTicket = useCallback(async () => {
|
||||
if (selectedIds.size === 0) return;
|
||||
|
||||
if (selectedIds.size === 1) {
|
||||
@@ -488,11 +498,27 @@ export default function IvantiTodoQueuePage() {
|
||||
if (!item) return;
|
||||
setSingleJiraItem(item);
|
||||
const items = [item];
|
||||
let description = generateConsolidatedDescription(items);
|
||||
|
||||
// If the item is Remediate, fetch its notes and append to description (Requirement 8)
|
||||
if (item.workflow_type === 'Remediate') {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/ivanti/todo-queue/${item.id}/notes`, { credentials: 'include' });
|
||||
if (res.ok) {
|
||||
const notes = await res.json();
|
||||
if (notes.length > 0) {
|
||||
const notesMap = { [item.id]: notes };
|
||||
description = appendRemediationNotes(description, notesMap);
|
||||
}
|
||||
}
|
||||
} catch (_e) { /* best effort — proceed without notes */ }
|
||||
}
|
||||
|
||||
setSingleJiraForm({
|
||||
cve_id: extractFirstCve(items),
|
||||
vendor: extractCommonVendor(items),
|
||||
summary: generateConsolidatedSummary(items),
|
||||
description: generateConsolidatedDescription(items),
|
||||
description,
|
||||
source_context: 'ivanti_queue',
|
||||
project_key: '',
|
||||
issue_type: '',
|
||||
@@ -579,17 +605,51 @@ export default function IvantiTodoQueuePage() {
|
||||
setSelectionMode(false);
|
||||
}, []);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Delete queue item with confirmation for Remediate items with notes
|
||||
// ---------------------------------------------------------------------------
|
||||
const initiateDelete = useCallback((item) => {
|
||||
const noteCount = localNoteCounts[item.id] !== undefined
|
||||
? localNoteCounts[item.id]
|
||||
: (item.remediation_notes_count || 0);
|
||||
if (item.workflow_type === 'Remediate' && noteCount > 0) {
|
||||
setDeleteConfirmItem({ ...item, _noteCount: noteCount });
|
||||
} else {
|
||||
performDelete(item.id);
|
||||
}
|
||||
}, [localNoteCounts]);
|
||||
|
||||
const performDelete = useCallback(async (id) => {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/ivanti/todo-queue/${id}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
});
|
||||
if (res.ok) {
|
||||
setQueueItems((prev) => prev.filter((i) => i.id !== id));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error deleting queue item:', e);
|
||||
}
|
||||
setDeleteConfirmItem(null);
|
||||
}, []);
|
||||
|
||||
const cancelDelete = useCallback(() => {
|
||||
setDeleteConfirmItem(null);
|
||||
}, []);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Workflow type color helper
|
||||
// ---------------------------------------------------------------------------
|
||||
const getWorkflowColor = (workflowType) => {
|
||||
switch (workflowType) {
|
||||
case 'FP': return { col: '#F59E0B', rgb: '245,158,11' };
|
||||
case 'Archer': return { col: '#0EA5E9', rgb: '14,165,233' };
|
||||
case 'CARD': return { col: '#10B981', rgb: '16,185,129' };
|
||||
case 'GRANITE': return { col: '#A1887F', rgb: '161,136,127' };
|
||||
case 'DECOM': return { col: '#EF4444', rgb: '239,68,68' };
|
||||
default: return { col: '#94A3B8', rgb: '148,163,184' };
|
||||
case 'FP': return { col: '#F59E0B', rgb: '245,158,11' };
|
||||
case 'Archer': return { col: '#0EA5E9', rgb: '14,165,233' };
|
||||
case 'CARD': return { col: '#10B981', rgb: '16,185,129' };
|
||||
case 'GRANITE': return { col: '#A1887F', rgb: '161,136,127' };
|
||||
case 'DECOM': return { col: '#EF4444', rgb: '239,68,68' };
|
||||
case 'Remediate': return { col: '#A855F7', rgb: '168,85,247' };
|
||||
default: return { col: '#94A3B8', rgb: '148,163,184' };
|
||||
}
|
||||
};
|
||||
|
||||
@@ -739,7 +799,11 @@ export default function IvantiTodoQueuePage() {
|
||||
? cves.slice(0, 2).join(', ') + (cves.length > 2 ? ` +${cves.length - 2}` : '')
|
||||
: '';
|
||||
const isArcherItem = item.workflow_type === 'Archer';
|
||||
const isRemediateItem = item.workflow_type === 'Remediate';
|
||||
const isTemplatePanelOpen = templatePanelOpenId === item.id;
|
||||
const noteCount = localNoteCounts[item.id] !== undefined
|
||||
? localNoteCounts[item.id]
|
||||
: (item.remediation_notes_count || 0);
|
||||
|
||||
return (
|
||||
<React.Fragment key={item.id}>
|
||||
@@ -823,6 +887,75 @@ export default function IvantiTodoQueuePage() {
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Remediation Notes button (Requirement 5.1, 6.1, 6.2) */}
|
||||
{isRemediateItem && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setRemediationModalItem(item); }}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.3rem',
|
||||
padding: '0.2rem 0.5rem',
|
||||
borderRadius: '4px',
|
||||
border: '1px solid rgba(168, 85, 247, 0.2)',
|
||||
background: 'rgba(168, 85, 247, 0.05)',
|
||||
color: '#C084FC',
|
||||
cursor: 'pointer',
|
||||
fontSize: '0.62rem',
|
||||
fontFamily: 'monospace',
|
||||
fontWeight: 600,
|
||||
flexShrink: 0,
|
||||
transition: 'all 0.2s',
|
||||
position: 'relative',
|
||||
}}
|
||||
title="View remediation notes"
|
||||
aria-label="Remediation notes"
|
||||
>
|
||||
<FileText style={{ width: '11px', height: '11px' }} />
|
||||
Notes
|
||||
{noteCount > 0 && (
|
||||
<span style={{
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.55rem',
|
||||
fontWeight: 700,
|
||||
color: '#A855F7',
|
||||
background: 'rgba(168, 85, 247, 0.15)',
|
||||
border: '1px solid rgba(168, 85, 247, 0.3)',
|
||||
borderRadius: '999px',
|
||||
padding: '0.05rem 0.3rem',
|
||||
marginLeft: '0.15rem',
|
||||
}}>
|
||||
{noteCount > 99 ? '99+' : noteCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Delete button for Remediate items (Requirement 7) */}
|
||||
{isRemediateItem && canWrite() && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); initiateDelete(item); }}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
color: '#475569',
|
||||
padding: '0.2rem',
|
||||
borderRadius: '4px',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
flexShrink: 0,
|
||||
transition: 'color 0.15s',
|
||||
}}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.color = '#EF4444'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.color = '#475569'; }}
|
||||
title="Delete queue item"
|
||||
aria-label="Delete queue item"
|
||||
>
|
||||
<Trash2 style={{ width: '13px', height: '13px' }} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Ticket link badge (Requirements 6.3, 6.4) */}
|
||||
{ticketLinks[item.id] && (
|
||||
<a
|
||||
@@ -1097,8 +1230,71 @@ export default function IvantiTodoQueuePage() {
|
||||
<LoaderModal
|
||||
isOpen={showLoaderModal}
|
||||
onClose={() => setShowLoaderModal(false)}
|
||||
initialDevices={showLoaderModal ? queueItems.filter(i => selectedIds.has(i.id) && ['CARD', 'GRANITE', 'DECOM'].includes(i.workflow_type)).map(i => ({ ip_address: i.ip_address || '', hostname: i.hostname || '' })) : null}
|
||||
initialDevices={showLoaderModal ? queueItems.filter(i => selectedIds.has(i.id) && ['CARD', 'GRANITE', 'DECOM'].includes(i.workflow_type)).map(i => ({ ip_address: i.ip_address || '', hostname: i.hostname || '', host_id: i.host_id || null })) : null}
|
||||
/>
|
||||
|
||||
{/* Remediation Notes Modal */}
|
||||
{remediationModalItem && (
|
||||
<RemediationModal
|
||||
item={remediationModalItem}
|
||||
onClose={() => setRemediationModalItem(null)}
|
||||
onNoteAdded={() => {
|
||||
setLocalNoteCounts((prev) => ({
|
||||
...prev,
|
||||
[remediationModalItem.id]: (prev[remediationModalItem.id] !== undefined
|
||||
? prev[remediationModalItem.id]
|
||||
: (remediationModalItem.remediation_notes_count || 0)) + 1,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Delete Confirmation Dialog (Requirement 7) */}
|
||||
{deleteConfirmItem && (
|
||||
<div style={STYLES.modal}>
|
||||
<div style={STYLES.modalBackdrop} onClick={cancelDelete} />
|
||||
<div style={{ ...STYLES.modalContent, maxWidth: '400px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '1rem' }}>
|
||||
<AlertCircle style={{ width: '20px', height: '20px', color: '#EF4444' }} />
|
||||
<span style={{ fontFamily: 'monospace', fontSize: '0.8rem', fontWeight: 700, color: '#F8FAFC' }}>
|
||||
Delete Queue Item
|
||||
</span>
|
||||
</div>
|
||||
<p style={{ fontFamily: 'monospace', fontSize: '0.75rem', color: '#CBD5E1', lineHeight: 1.6, margin: '0 0 1rem 0' }}>
|
||||
This item has <span style={{ color: '#A855F7', fontWeight: 700 }}>{deleteConfirmItem._noteCount}</span> remediation note{deleteConfirmItem._noteCount !== 1 ? 's' : ''}.
|
||||
Deleting this item will <span style={{ color: '#EF4444', fontWeight: 600 }}>permanently delete</span> all associated remediation notes.
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: '0.5rem', justifyContent: 'flex-end' }}>
|
||||
<button
|
||||
onClick={cancelDelete}
|
||||
style={STYLES.btnCancel}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => performDelete(deleteConfirmItem.id)}
|
||||
style={{
|
||||
padding: '0.5rem 1rem',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid rgba(239, 68, 68, 0.4)',
|
||||
background: 'rgba(239, 68, 68, 0.15)',
|
||||
color: '#FCA5A5',
|
||||
cursor: 'pointer',
|
||||
fontSize: '0.8rem',
|
||||
fontWeight: 600,
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.4rem',
|
||||
transition: 'all 0.2s',
|
||||
}}
|
||||
>
|
||||
<Trash2 style={{ width: '14px', height: '14px' }} />
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -549,6 +549,10 @@ export default function JiraPage() {
|
||||
closed: tickets.filter(t => isClosedStatus(t.status)).length,
|
||||
};
|
||||
|
||||
// Split filtered into active and completed for separate display
|
||||
const activeFiltered = filtered.filter(t => !isClosedStatus(t.status));
|
||||
const completedFiltered = filtered.filter(t => isClosedStatus(t.status));
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Render
|
||||
@@ -693,6 +697,9 @@ export default function JiraPage() {
|
||||
{tickets.length === 0 ? 'No Jira tickets yet. Add one manually or create from Jira.' : 'No tickets match your filters.'}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Active tickets */}
|
||||
{activeFiltered.length > 0 && (
|
||||
<div style={{ ...STYLES.card, padding: '0', overflow: 'auto' }}>
|
||||
<table style={STYLES.table}>
|
||||
<thead>
|
||||
@@ -708,7 +715,7 @@ export default function JiraPage() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.map(t => (
|
||||
{activeFiltered.map(t => (
|
||||
<tr key={t.id} style={{ transition: 'background 0.15s' }}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'rgba(14, 165, 233, 0.05)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
|
||||
@@ -767,7 +774,7 @@ export default function JiraPage() {
|
||||
{canWrite() && (
|
||||
<button style={{ ...STYLES.btn, padding: '0.25rem 0.5rem', fontSize: '0.7rem' }} onClick={() => {
|
||||
setEditingId(t.id);
|
||||
setForm({ cve_id: t.cve_id, vendor: t.vendor, ticket_key: t.ticket_key, url: t.url || '', summary: t.summary || '', status: t.status });
|
||||
setForm({ cve_id: t.cve_id || '', vendor: t.vendor || '', ticket_key: t.ticket_key, url: t.url || '', summary: t.summary || '', status: t.status });
|
||||
setFormError(null);
|
||||
setShowForm(true);
|
||||
}} title="Edit">
|
||||
@@ -786,6 +793,117 @@ export default function JiraPage() {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Completed tickets — collapsible section */}
|
||||
{completedFiltered.length > 0 && (
|
||||
<details style={{ marginTop: '1.5rem' }}>
|
||||
<summary style={{
|
||||
cursor: 'pointer',
|
||||
fontSize: '0.85rem',
|
||||
fontWeight: 600,
|
||||
color: '#64748B',
|
||||
padding: '0.5rem 0',
|
||||
userSelect: 'none',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.5rem',
|
||||
}}>
|
||||
<CheckCircle size={14} style={{ color: '#10B981' }} />
|
||||
Completed ({completedFiltered.length})
|
||||
<span style={{ fontSize: '0.7rem', fontWeight: 400, color: '#475569' }}>— not synced on subsequent runs</span>
|
||||
</summary>
|
||||
<div style={{ ...STYLES.card, padding: '0', overflow: 'auto', marginTop: '0.5rem', opacity: 0.75 }}>
|
||||
<table style={STYLES.table}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={STYLES.th}>Ticket</th>
|
||||
<th style={STYLES.th}>CVE</th>
|
||||
<th style={STYLES.th}>Vendor</th>
|
||||
<th style={STYLES.th}>Source</th>
|
||||
<th style={STYLES.th}>Summary</th>
|
||||
<th style={STYLES.th}>Status</th>
|
||||
<th style={STYLES.th}>Last Synced</th>
|
||||
<th style={STYLES.th}>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{completedFiltered.map(t => (
|
||||
<tr key={t.id} style={{ transition: 'background 0.15s' }}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'rgba(14, 165, 233, 0.05)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
|
||||
>
|
||||
<td style={STYLES.td}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem' }}>
|
||||
<span style={{ fontFamily: 'monospace', fontWeight: 600, color: '#7DD3FC' }}>{t.ticket_key}</span>
|
||||
{t.url && (
|
||||
<a href={t.url} target="_blank" rel="noopener noreferrer" style={{ color: '#94A3B8' }} title="Open in Jira">
|
||||
<ExternalLink size={12} />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td style={{ ...STYLES.td, fontFamily: 'monospace', fontSize: '0.8rem' }}>{t.cve_id}</td>
|
||||
<td style={STYLES.td}>{t.vendor}</td>
|
||||
<td style={STYLES.td}>
|
||||
{(() => {
|
||||
const badge = getSourceBadge(t.source_context);
|
||||
return (
|
||||
<span style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
padding: '0.15rem 0.5rem',
|
||||
borderRadius: '9999px',
|
||||
fontSize: '0.65rem',
|
||||
fontWeight: 600,
|
||||
letterSpacing: '0.02em',
|
||||
background: `${badge.color}22`,
|
||||
color: badge.color,
|
||||
border: `1px solid ${badge.color}44`,
|
||||
whiteSpace: 'nowrap',
|
||||
}}>
|
||||
{badge.label}
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
</td>
|
||||
<td style={{ ...STYLES.td, maxWidth: '250px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{t.summary || '-'}</td>
|
||||
<td style={STYLES.td}>
|
||||
<span style={STYLES.badge(getStatusColor(t.status))}>
|
||||
<span style={{ width: 6, height: 6, borderRadius: '50%', background: getStatusColor(t.status) }} />
|
||||
{t.status}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ ...STYLES.td, fontSize: '0.75rem', color: '#94A3B8' }}>
|
||||
{t.last_synced_at ? new Date(t.last_synced_at).toLocaleDateString() : 'Never'}
|
||||
</td>
|
||||
<td style={STYLES.td}>
|
||||
<div style={{ display: 'flex', gap: '0.3rem' }}>
|
||||
{canWrite() && (
|
||||
<button style={{ ...STYLES.btn, padding: '0.25rem 0.5rem', fontSize: '0.7rem' }} onClick={() => {
|
||||
setEditingId(t.id);
|
||||
setForm({ cve_id: t.cve_id || '', vendor: t.vendor || '', ticket_key: t.ticket_key, url: t.url || '', summary: t.summary || '', status: t.status });
|
||||
setFormError(null);
|
||||
setShowForm(true);
|
||||
}} title="Edit">
|
||||
<Edit3 size={12} />
|
||||
</button>
|
||||
)}
|
||||
{canWrite() && (
|
||||
<button style={{ ...STYLES.btn, ...STYLES.btnDanger, padding: '0.25rem 0.5rem', fontSize: '0.7rem' }} onClick={() => deleteTicket(t.id)} title="Delete">
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Lookup Modal */}
|
||||
@@ -851,11 +969,11 @@ export default function JiraPage() {
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||||
<div>
|
||||
<label style={{ fontSize: '0.75rem', color: '#94A3B8', display: 'block', marginBottom: '0.25rem' }}>CVE ID</label>
|
||||
<input style={STYLES.input} placeholder="CVE-2024-1234" value={form.cve_id} onChange={e => setForm(f => ({ ...f, cve_id: e.target.value }))} disabled={!!editingId} />
|
||||
<input style={STYLES.input} placeholder="CVE-2024-1234" value={form.cve_id} onChange={e => setForm(f => ({ ...f, cve_id: e.target.value }))} />
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ fontSize: '0.75rem', color: '#94A3B8', display: 'block', marginBottom: '0.25rem' }}>Vendor</label>
|
||||
<input style={STYLES.input} placeholder="Vendor name" value={form.vendor} onChange={e => setForm(f => ({ ...f, vendor: e.target.value }))} disabled={!!editingId} />
|
||||
<input style={STYLES.input} placeholder="Vendor name" value={form.vendor} onChange={e => setForm(f => ({ ...f, vendor: e.target.value }))} />
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ fontSize: '0.75rem', color: '#94A3B8', display: 'block', marginBottom: '0.25rem' }}>Ticket Key</label>
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { RefreshCw, Loader, AlertCircle, PieChart, ChevronUp, ChevronDown, ChevronRight, ChevronsUpDown, Settings2, GripVertical, Eye, EyeOff, Filter, Download, RotateCcw, Trash2, X, ListTodo, Upload, FileText, Check, AlertTriangle, CornerUpRight, Edit3, Square, CheckSquare, MinusSquare, Search, Database, Plus, FileSpreadsheet } from 'lucide-react';
|
||||
import { RefreshCw, Loader, AlertCircle, PieChart, ChevronUp, ChevronDown, ChevronRight, ChevronsUpDown, Settings2, GripVertical, Eye, EyeOff, Filter, Download, RotateCcw, Trash2, X, ListTodo, Upload, FileText, Check, AlertTriangle, CornerUpRight, Edit3, Square, CheckSquare, MinusSquare, Search, Database, Plus, FileSpreadsheet, Layers } from 'lucide-react';
|
||||
import * as XLSX from 'xlsx';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import IvantiCountsChart from './IvantiCountsChart';
|
||||
import AnomalyBanner from './AnomalyBanner';
|
||||
import CveTooltip from '../CveTooltip';
|
||||
import CardOwnerTooltip from '../CardOwnerTooltip';
|
||||
import CardDetailModal from '../CardDetailModal';
|
||||
import RedirectModal from '../RedirectModal';
|
||||
import RemediationModal from '../RemediationModal';
|
||||
import AtlasBadge from '../AtlasBadge';
|
||||
import LoaderModal from '../LoaderModal';
|
||||
import CardActionModal from '../CardActionModal';
|
||||
@@ -858,7 +861,7 @@ function OverrideCell({ findingId, field, originalValue, initialOverride, canWri
|
||||
// ---------------------------------------------------------------------------
|
||||
// NoteCell — inline editable, saves on blur
|
||||
// ---------------------------------------------------------------------------
|
||||
function NoteCell({ findingId, initialNote }) {
|
||||
function NoteCell({ findingId, initialNote, onNoteSaved }) {
|
||||
const [value, setValue] = useState(initialNote || '');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const lastSaved = useRef(initialNote || '');
|
||||
@@ -879,12 +882,13 @@ function NoteCell({ findingId, initialNote }) {
|
||||
body: JSON.stringify({ note: value })
|
||||
});
|
||||
lastSaved.current = value;
|
||||
if (onNoteSaved) onNoteSaved(findingId, value);
|
||||
} catch (e) {
|
||||
console.error('Failed to save note:', e);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [findingId, value]);
|
||||
}, [findingId, value, onNoteSaved]);
|
||||
|
||||
return (
|
||||
<div style={{ position: 'relative' }}>
|
||||
@@ -1186,7 +1190,7 @@ function FilterDropdown({ anchorEl, colKey, findings, activeFilter, onFilterChan
|
||||
// ---------------------------------------------------------------------------
|
||||
// Render a single table cell by column key
|
||||
// ---------------------------------------------------------------------------
|
||||
function TableCell({ colKey, finding, canWrite, onCveMouseEnter, onCveMouseLeave, fpSubmissions, onEditSubmission, atlasStatusMap, onAtlasBadgeClick }) {
|
||||
function TableCell({ colKey, finding, canWrite, onCveMouseEnter, onCveMouseLeave, onIpMouseEnter, onIpMouseLeave, fpSubmissions, onEditSubmission, atlasStatusMap, onAtlasBadgeClick, onNoteSaved }) {
|
||||
switch (colKey) {
|
||||
case 'findingId':
|
||||
return (
|
||||
@@ -1257,12 +1261,31 @@ function TableCell({ colKey, finding, canWrite, onCveMouseEnter, onCveMouseLeave
|
||||
}
|
||||
/>
|
||||
);
|
||||
case 'ipAddress':
|
||||
case 'ipAddress': {
|
||||
// Display priority: IPv4 > Qualys IPv6 > Primary IPv6
|
||||
const displayIp = finding.ipAddress || finding.qualysIpv6 || finding.primaryIpv6 || '';
|
||||
const ipSource = finding.ipAddress ? null : finding.qualysIpv6 ? 'Q' : finding.primaryIpv6 ? 'v6' : null;
|
||||
const hoverIp = displayIp || null;
|
||||
return (
|
||||
<td style={{ padding: '0.45rem 0.75rem', whiteSpace: 'nowrap', color: '#94A3B8', fontFamily: 'monospace', fontSize: '0.72rem' }}>
|
||||
{finding.ipAddress || '—'}
|
||||
<td
|
||||
style={{ padding: '0.45rem 0.75rem', whiteSpace: 'nowrap', color: '#94A3B8', fontFamily: 'monospace', fontSize: '0.72rem', cursor: hoverIp ? 'help' : 'default' }}
|
||||
onMouseEnter={onIpMouseEnter && hoverIp ? (e) => onIpMouseEnter(hoverIp, e, finding.hostId) : undefined}
|
||||
onMouseLeave={onIpMouseLeave || undefined}
|
||||
title={ipSource === 'Q' ? 'Qualys IPv6 (no IPv4 available)' : ipSource === 'v6' ? 'Primary IPv6 (no IPv4 available)' : undefined}
|
||||
>
|
||||
{displayIp ? (
|
||||
<>
|
||||
<span style={{ maxWidth: '140px', overflow: 'hidden', textOverflow: 'ellipsis', display: 'inline-block', verticalAlign: 'middle' }}>{displayIp}</span>
|
||||
{ipSource && (
|
||||
<span style={{ marginLeft: '0.3rem', fontSize: '0.55rem', padding: '0.08rem 0.25rem', borderRadius: '0.2rem', background: ipSource === 'Q' ? 'rgba(245, 158, 11, 0.15)' : 'rgba(99, 102, 241, 0.15)', border: ipSource === 'Q' ? '1px solid rgba(245, 158, 11, 0.4)' : '1px solid rgba(99, 102, 241, 0.4)', color: ipSource === 'Q' ? '#FBBF24' : '#A5B4FC', fontWeight: '700', verticalAlign: 'middle' }}>
|
||||
{ipSource}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
) : '—'}
|
||||
</td>
|
||||
);
|
||||
}
|
||||
case 'dns':
|
||||
return (
|
||||
<OverrideCell
|
||||
@@ -1345,7 +1368,7 @@ function TableCell({ colKey, finding, canWrite, onCveMouseEnter, onCveMouseLeave
|
||||
case 'note':
|
||||
return (
|
||||
<td style={{ padding: '0.45rem 0.75rem' }}>
|
||||
<NoteCell findingId={finding.id} initialNote={finding.note} />
|
||||
<NoteCell findingId={finding.id} initialNote={finding.note} onNoteSaved={onNoteSaved} />
|
||||
</td>
|
||||
);
|
||||
default:
|
||||
@@ -1461,6 +1484,7 @@ function AddToQueuePopover({ finding, anchorRect, queueForm, setQueueForm, onAdd
|
||||
{ key: 'CARD', col: '#10B981', rgb: '16,185,129' },
|
||||
{ key: 'GRANITE', col: '#A1887F', rgb: '161,136,127' },
|
||||
{ key: 'DECOM', col: '#EF4444', rgb: '239,68,68' },
|
||||
{ key: 'Remediate', col: '#A855F7', rgb: '168,85,247' },
|
||||
].map(({ key, col, rgb }) => {
|
||||
const active = queueForm.workflowType === key;
|
||||
return (
|
||||
@@ -1550,6 +1574,9 @@ function QueuePanel({ open, items, onClose, onUpdate, onDelete, onDeleteMany, on
|
||||
const [createJiraOpen, setCreateJiraOpen] = useState(false);
|
||||
const [createJiraForm, setCreateJiraForm] = useState({ summary: '', cve_id: '', vendor: '', source_context: 'ivanti_queue', description: '', project_key: '', issue_type: '' });
|
||||
const [createJiraError, setCreateJiraError] = useState(null);
|
||||
|
||||
// Remediation Modal state
|
||||
const [remediationModalItem, setRemediationModalItem] = useState(null);
|
||||
const [createJiraSaving, setCreateJiraSaving] = useState(false);
|
||||
const [createJiraSummaryError, setCreateJiraSummaryError] = useState(null);
|
||||
|
||||
@@ -1747,7 +1774,7 @@ function QueuePanel({ open, items, onClose, onUpdate, onDelete, onDeleteMany, on
|
||||
};
|
||||
|
||||
// Open Create Jira modal pre-populated from a queue item
|
||||
const openCreateJiraFromQueue = (item) => {
|
||||
const openCreateJiraFromQueue = async (item) => {
|
||||
// Parse cves_json — it may be a JSON string or already an array
|
||||
let cves = [];
|
||||
if (item.cves_json) {
|
||||
@@ -1757,12 +1784,39 @@ function QueuePanel({ open, items, onClose, onUpdate, onDelete, onDeleteMany, on
|
||||
}
|
||||
const firstCve = (Array.isArray(cves) && cves.length > 0) ? cves[0] : '';
|
||||
const summary = (item.finding_title || '').slice(0, 255);
|
||||
|
||||
// Build description — include finding details and remediation notes for Remediate items
|
||||
let description = '';
|
||||
// Always include finding info in description
|
||||
const cveList = (Array.isArray(cves) && cves.length > 0) ? cves.join(', ') : 'None';
|
||||
description += `== ${item.vendor || 'Unknown Vendor'} ==\n`;
|
||||
description += `- ${item.finding_title || 'Untitled'}\n`;
|
||||
description += ` CVEs: ${cveList}\n`;
|
||||
description += ` Host: ${item.hostname || 'N/A'} (${item.ip_address || 'N/A'})\n`;
|
||||
|
||||
if (item.workflow_type === 'Remediate') {
|
||||
try {
|
||||
const notesRes = await fetch(`${API_BASE}/ivanti/todo-queue/${item.id}/notes`, { credentials: 'include' });
|
||||
if (notesRes.ok) {
|
||||
const notes = await notesRes.json();
|
||||
if (notes.length > 0) {
|
||||
const sorted = [...notes].sort((a, b) => new Date(a.created_at) - new Date(b.created_at));
|
||||
description += '\n== Remediation Notes ==\n';
|
||||
for (const note of sorted) {
|
||||
const date = note.created_at ? note.created_at.slice(0, 10) : 'Unknown';
|
||||
description += `[${date}] ${note.username}: ${note.note_text}\n`;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_) { /* best-effort */ }
|
||||
}
|
||||
|
||||
setCreateJiraForm({
|
||||
summary,
|
||||
cve_id: firstCve,
|
||||
vendor: item.vendor || '',
|
||||
source_context: 'ivanti_queue',
|
||||
description: '',
|
||||
description,
|
||||
project_key: '',
|
||||
issue_type: '',
|
||||
});
|
||||
@@ -1811,6 +1865,7 @@ function QueuePanel({ open, items, onClose, onUpdate, onDelete, onDeleteMany, on
|
||||
: item.workflow_type === 'Archer' ? { col: '#0EA5E9', rgb: '14,165,233' }
|
||||
: item.workflow_type === 'GRANITE' ? { col: '#A1887F', rgb: '161,136,127' }
|
||||
: item.workflow_type === 'DECOM' ? { col: '#EF4444', rgb: '239,68,68' }
|
||||
: item.workflow_type === 'Remediate' ? { col: '#A855F7', rgb: '168,85,247' }
|
||||
: { col: '#10B981', rgb: '16,185,129' };
|
||||
const cves = item.cves || [];
|
||||
const cveDisplay = cves.length > 0
|
||||
@@ -2002,13 +2057,60 @@ function QueuePanel({ open, items, onClose, onUpdate, onDelete, onDeleteMany, on
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Redirect button — completed items only */}
|
||||
{canWrite && done && (
|
||||
{/* Remediation Notes button — Remediate items only */}
|
||||
{item.workflow_type === 'Remediate' && (
|
||||
<button
|
||||
onClick={() => setRemediationModalItem(item)}
|
||||
style={{
|
||||
background: 'rgba(168, 85, 247, 0.08)',
|
||||
border: '1px solid rgba(168, 85, 247, 0.25)',
|
||||
borderRadius: '0.2rem',
|
||||
padding: '0.15rem 0.35rem',
|
||||
cursor: 'pointer',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.2rem',
|
||||
color: '#C084FC',
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.55rem',
|
||||
fontWeight: '700',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.04em',
|
||||
flexShrink: 0,
|
||||
transition: 'all 0.12s',
|
||||
position: 'relative',
|
||||
}}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.background = 'rgba(168, 85, 247, 0.18)'; e.currentTarget.style.borderColor = 'rgba(168, 85, 247, 0.45)'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.background = 'rgba(168, 85, 247, 0.08)'; e.currentTarget.style.borderColor = 'rgba(168, 85, 247, 0.25)'; }}
|
||||
title="View remediation notes"
|
||||
>
|
||||
<FileText style={{ width: '10px', height: '10px' }} />
|
||||
Notes
|
||||
{(item.remediation_notes_count || 0) > 0 && (
|
||||
<span style={{
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.5rem',
|
||||
fontWeight: 700,
|
||||
color: '#A855F7',
|
||||
background: 'rgba(168, 85, 247, 0.15)',
|
||||
border: '1px solid rgba(168, 85, 247, 0.3)',
|
||||
borderRadius: '999px',
|
||||
padding: '0 0.25rem',
|
||||
marginLeft: '0.1rem',
|
||||
}}>
|
||||
{item.remediation_notes_count > 99 ? '99+' : item.remediation_notes_count}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Redirect button — available on all items */}
|
||||
{canWrite && (
|
||||
<button
|
||||
onClick={() => setRedirectItem(item)}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#334155', padding: '1px', lineHeight: 1, flexShrink: 0 }}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: done ? '#334155' : '#475569', padding: '1px', lineHeight: 1, flexShrink: 0 }}
|
||||
onMouseEnter={(e) => e.currentTarget.style.color = '#0EA5E9'}
|
||||
onMouseLeave={(e) => e.currentTarget.style.color = '#334155'}
|
||||
onMouseLeave={(e) => e.currentTarget.style.color = done ? '#334155' : '#475569'}
|
||||
title="Redirect to another workflow"
|
||||
>
|
||||
<CornerUpRight style={{ width: '13px', height: '13px' }} />
|
||||
@@ -2951,6 +3053,17 @@ function QueuePanel({ open, items, onClose, onUpdate, onDelete, onDeleteMany, on
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Remediation Notes modal */}
|
||||
{remediationModalItem && (
|
||||
<RemediationModal
|
||||
item={remediationModalItem}
|
||||
onClose={() => setRemediationModalItem(null)}
|
||||
onNoteAdded={() => {
|
||||
if (onQueueRefresh) onQueueRefresh();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Create Jira Ticket modal */}
|
||||
{createJiraOpen && (
|
||||
<div style={{ position: 'fixed', inset: 0, zIndex: 10100, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
@@ -3168,9 +3281,9 @@ function QueuePanel({ open, items, onClose, onUpdate, onDelete, onDeleteMany, on
|
||||
onClose={() => setShowLoaderModal(false)}
|
||||
initialDevices={showLoaderModal ? (() => {
|
||||
const selected = items.filter(i => selectedIds.has(i.id) && ['CARD', 'GRANITE', 'DECOM'].includes(i.workflow_type));
|
||||
if (selected.length > 0) return selected.map(i => ({ ip_address: i.ip_address || '', hostname: i.hostname || '' }));
|
||||
if (selected.length > 0) return selected.map(i => ({ ip_address: i.ip_address || '', hostname: i.hostname || '', host_id: i.host_id || null }));
|
||||
// Standalone: use all CARD/GRANITE/DECOM items
|
||||
return items.filter(i => ['CARD', 'GRANITE', 'DECOM'].includes(i.workflow_type)).map(i => ({ ip_address: i.ip_address || '', hostname: i.hostname || '' }));
|
||||
return items.filter(i => ['CARD', 'GRANITE', 'DECOM'].includes(i.workflow_type)).map(i => ({ ip_address: i.ip_address || '', hostname: i.hostname || '', host_id: i.host_id || null }));
|
||||
})() : null}
|
||||
/>
|
||||
|
||||
@@ -3228,8 +3341,11 @@ function AttachmentSourcePicker({ files, onFilesChange, libraryDocs, onLibraryDo
|
||||
const debounceRef = useRef(null);
|
||||
|
||||
// Format file size helper
|
||||
const formatSize = (bytes) => {
|
||||
const n = Number(bytes);
|
||||
const formatSize = (val) => {
|
||||
if (!val && val !== 0) return '0 B';
|
||||
// If already a formatted string (e.g. "12.34 KB"), return as-is
|
||||
if (typeof val === 'string' && /[A-Za-z]/.test(val)) return val;
|
||||
const n = Number(val);
|
||||
if (isNaN(n) || n < 0) return '0 B';
|
||||
if (n < 1024) return n + ' B';
|
||||
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB';
|
||||
@@ -5001,6 +5117,7 @@ function SelectionToolbar({ count, workflowType, vendor, submitting, error, onWo
|
||||
{ type: 'CARD', color: '#10B981', rgb: '16,185,129' },
|
||||
{ type: 'GRANITE', color: '#A1887F', rgb: '161,136,127' },
|
||||
{ type: 'DECOM', color: '#EF4444', rgb: '239,68,68' },
|
||||
{ type: 'Remediate', color: '#A855F7', rgb: '168,85,247' },
|
||||
].map(({ type, color, rgb }) => {
|
||||
const active = workflowType === type;
|
||||
return (
|
||||
@@ -5261,6 +5378,7 @@ function RowVisibilityManager({ hiddenRowIds, findings, onRestore, onRestoreAll
|
||||
function BulkHideToolbar({ count, onHide, onClear, onAtlasBulk, canWrite }) {
|
||||
return (
|
||||
<div style={{
|
||||
position: 'sticky', top: 0, zIndex: 20,
|
||||
display: 'flex', alignItems: 'center', gap: '0.75rem',
|
||||
padding: '0.5rem 1rem',
|
||||
background: 'linear-gradient(135deg, rgba(15,23,42,0.95), rgba(30,41,59,0.95))',
|
||||
@@ -5832,6 +5950,13 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
|
||||
const tooltipCacheRef = useRef(new Map());
|
||||
const hoverTimerRef = useRef(null);
|
||||
|
||||
// CARD owner tooltip state & refs
|
||||
const [cardTooltipIp, setCardTooltipIp] = useState(null);
|
||||
const [cardTooltipAnchorRect, setCardTooltipAnchorRect] = useState(null);
|
||||
const [cardTooltipHostId, setCardTooltipHostId] = useState(null);
|
||||
const cardTooltipCacheRef = useRef(new Map());
|
||||
const cardHoverTimerRef = useRef(null);
|
||||
|
||||
// Atlas action plan state
|
||||
const [metricsTab, setMetricsTab] = useState('ivanti');
|
||||
const [atlasStatusMap, setAtlasStatusMap] = useState(new Map());
|
||||
@@ -5852,6 +5977,10 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
|
||||
const [cardConfigured, setCardConfigured] = useState(false);
|
||||
const [cardTeams, setCardTeams] = useState([]);
|
||||
|
||||
// Group-by-host toggle state
|
||||
const [groupByHost, setGroupByHost] = useState(false);
|
||||
const [expandedHosts, setExpandedHosts] = useState(new Set());
|
||||
|
||||
const updateColumns = useCallback((newOrder) => {
|
||||
setColumnOrder(newOrder);
|
||||
saveColumnOrder(newOrder);
|
||||
@@ -5920,6 +6049,53 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
|
||||
setTooltipAnchorRect(null);
|
||||
}, []);
|
||||
|
||||
// CARD owner tooltip hover handlers (with hover bridge — stays open when hovering tooltip)
|
||||
const handleIpMouseEnter = useCallback((ip, e, hostId) => {
|
||||
if (!ip) return;
|
||||
clearTimeout(cardHoverTimerRef.current);
|
||||
cardHoverTimerRef.current = setTimeout(() => {
|
||||
setCardTooltipIp(ip);
|
||||
setCardTooltipAnchorRect(e.target.getBoundingClientRect());
|
||||
setCardTooltipHostId(hostId || null);
|
||||
}, 400);
|
||||
}, []);
|
||||
|
||||
const handleIpMouseLeave = useCallback(() => {
|
||||
clearTimeout(cardHoverTimerRef.current);
|
||||
// Delay hiding to allow mouse to move into tooltip
|
||||
cardHoverTimerRef.current = setTimeout(() => {
|
||||
setCardTooltipIp(null);
|
||||
setCardTooltipAnchorRect(null);
|
||||
setCardTooltipHostId(null);
|
||||
}, 150);
|
||||
}, []);
|
||||
|
||||
const handleCardTooltipEnter = useCallback(() => {
|
||||
// Mouse entered tooltip — cancel the hide timer
|
||||
clearTimeout(cardHoverTimerRef.current);
|
||||
}, []);
|
||||
|
||||
const handleCardTooltipLeave = useCallback(() => {
|
||||
// Mouse left tooltip — hide it
|
||||
clearTimeout(cardHoverTimerRef.current);
|
||||
setCardTooltipIp(null);
|
||||
setCardTooltipAnchorRect(null);
|
||||
setCardTooltipHostId(null);
|
||||
}, []);
|
||||
|
||||
// CARD action — open CardActionModal from tooltip
|
||||
const [cardActionIp, setCardActionIp] = useState(null);
|
||||
const [cardActionData, setCardActionData] = useState(null);
|
||||
|
||||
const handleCardAction = useCallback((ip, data) => {
|
||||
setCardActionIp(ip);
|
||||
setCardActionData(data);
|
||||
// Close the tooltip
|
||||
setCardTooltipIp(null);
|
||||
setCardTooltipAnchorRect(null);
|
||||
setCardTooltipHostId(null);
|
||||
}, []);
|
||||
|
||||
const applyState = (data) => {
|
||||
setTotal(data.total ?? 0);
|
||||
setFindings(data.findings || []);
|
||||
@@ -5967,7 +6143,11 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
|
||||
|
||||
const fetchAtlasStatus = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/atlas/status`, { credentials: 'include' });
|
||||
const teamsParam = getActiveTeamsParam();
|
||||
const url = teamsParam
|
||||
? `${API_BASE}/atlas/status?teams=${encodeURIComponent(teamsParam)}`
|
||||
: `${API_BASE}/atlas/status`;
|
||||
const res = await fetch(url, { credentials: 'include' });
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
const map = new Map();
|
||||
@@ -5983,7 +6163,11 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
|
||||
setAtlasMetricsLoading(true);
|
||||
setAtlasMetricsError(null);
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/atlas/metrics`, { credentials: 'include' });
|
||||
const teamsParam = getActiveTeamsParam();
|
||||
const url = teamsParam
|
||||
? `${API_BASE}/atlas/metrics?teams=${encodeURIComponent(teamsParam)}`
|
||||
: `${API_BASE}/atlas/metrics`;
|
||||
const res = await fetch(url, { credentials: 'include' });
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setAtlasMetrics(data);
|
||||
@@ -6000,6 +6184,7 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
|
||||
|
||||
// CARD API — fetch status and teams (session-level caching)
|
||||
const cardTeamsFetchedRef = useRef(false);
|
||||
const cardTeamsRetryRef = useRef(0);
|
||||
const fetchCardStatus = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/card/status`, { credentials: 'include' });
|
||||
@@ -6007,19 +6192,30 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
|
||||
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);
|
||||
if (teams.length > 0) {
|
||||
setCardTeams(teams);
|
||||
cardTeamsFetchedRef.current = true;
|
||||
}
|
||||
} else if (cardTeamsRetryRef.current < 3) {
|
||||
// Retry silently after a delay (CARD teams endpoint can be slow)
|
||||
cardTeamsRetryRef.current += 1;
|
||||
setTimeout(() => fetchCardStatus(), 15000 * cardTeamsRetryRef.current);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[card-api] Failed to fetch CARD status:', err.message);
|
||||
// Retry on network error too
|
||||
if (cardTeamsRetryRef.current < 3) {
|
||||
cardTeamsRetryRef.current += 1;
|
||||
setTimeout(() => fetchCardStatus(), 15000 * cardTeamsRetryRef.current);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -6082,6 +6278,9 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
|
||||
.catch(() => {});
|
||||
// Also refresh FP workflow counts for the new scope
|
||||
fetchFPWorkflowCounts();
|
||||
// Refresh Atlas data for the new scope
|
||||
fetchAtlasStatus();
|
||||
fetchAtlasMetrics();
|
||||
}, [adminScope]); // eslint-disable-line
|
||||
|
||||
// Set/clear a single column filter
|
||||
@@ -6165,6 +6364,67 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
|
||||
return sort.dir === 'asc' ? cmp : -cmp;
|
||||
}), [filtered, sort]);
|
||||
|
||||
// Grouped view — aggregate findings by hostName + ipAddress
|
||||
const groupedByHost = useMemo(() => {
|
||||
if (!groupByHost) return { groups: [], singles: [] };
|
||||
const map = new Map();
|
||||
sorted.forEach(f => {
|
||||
const hostKey = `${(f.overrides?.hostName || f.hostName || '').toLowerCase()}||${(f.ipAddress || '').toLowerCase()}`;
|
||||
if (!map.has(hostKey)) {
|
||||
map.set(hostKey, {
|
||||
hostKey,
|
||||
hostName: f.overrides?.hostName || f.hostName || '',
|
||||
ipAddress: f.ipAddress || '',
|
||||
findings: [],
|
||||
highestSeverity: 0,
|
||||
highestVrrGroup: '',
|
||||
cveSet: new Set(),
|
||||
});
|
||||
}
|
||||
const group = map.get(hostKey);
|
||||
group.findings.push(f);
|
||||
if (f.severity > group.highestSeverity) {
|
||||
group.highestSeverity = f.severity;
|
||||
group.highestVrrGroup = f.vrrGroup || '';
|
||||
}
|
||||
(f.cves || []).forEach(c => group.cveSet.add(c));
|
||||
});
|
||||
// Separate: groups with 2+ findings vs singles that stay flat
|
||||
const groups = [];
|
||||
const singles = [];
|
||||
for (const g of map.values()) {
|
||||
if (g.findings.length > 1) groups.push(g);
|
||||
else singles.push(g.findings[0]);
|
||||
}
|
||||
groups.sort((a, b) => b.highestSeverity - a.highestSeverity);
|
||||
return { groups, singles };
|
||||
}, [sorted, groupByHost]);
|
||||
|
||||
// Combined render order for grouped mode: grouped hosts first, then singles
|
||||
const groupedRenderList = useMemo(() => {
|
||||
if (!groupByHost) return [];
|
||||
const list = [];
|
||||
groupedByHost.groups.forEach(g => list.push({ type: 'group', group: g }));
|
||||
groupedByHost.singles.forEach(f => list.push({ type: 'single', finding: f }));
|
||||
return list;
|
||||
}, [groupByHost, groupedByHost]);
|
||||
|
||||
const toggleHostExpand = useCallback((hostKey) => {
|
||||
setExpandedHosts(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(hostKey)) next.delete(hostKey); else next.add(hostKey);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const expandAllHosts = useCallback(() => {
|
||||
setExpandedHosts(new Set(groupedByHost.groups.map(g => g.hostKey)));
|
||||
}, [groupedByHost]);
|
||||
|
||||
const collapseAllHosts = useCallback(() => {
|
||||
setExpandedHosts(new Set());
|
||||
}, []);
|
||||
|
||||
// Select/deselect all visible rows
|
||||
const toggleSelectAll = useCallback(() => {
|
||||
const allVisibleIds = sorted.map(f => String(f.id));
|
||||
@@ -6281,6 +6541,10 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
|
||||
setEditSubmission(submission);
|
||||
}, []);
|
||||
|
||||
const handleNoteSaved = useCallback((findingId, note) => {
|
||||
setFindings(prev => prev.map(f => f.id === findingId ? { ...f, note } : f));
|
||||
}, []);
|
||||
|
||||
const handleEditSuccess = useCallback(() => {
|
||||
fetchFpSubmissions();
|
||||
fetchQueue();
|
||||
@@ -6908,6 +7172,24 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setGroupByHost(g => !g); setExpandedHosts(new Set()); }}
|
||||
title={groupByHost ? 'Switch to flat view' : 'Group findings by host'}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: '0.375rem',
|
||||
padding: '0.375rem 0.75rem',
|
||||
background: groupByHost ? 'rgba(139,92,246,0.15)' : 'rgba(139,92,246,0.06)',
|
||||
border: `1px solid rgba(139,92,246,${groupByHost ? '0.5' : '0.2'})`,
|
||||
borderRadius: '0.375rem',
|
||||
color: groupByHost ? '#A78BFA' : '#7C3AED',
|
||||
cursor: 'pointer',
|
||||
fontFamily: 'monospace', fontSize: '0.75rem', fontWeight: '600',
|
||||
textTransform: 'uppercase', letterSpacing: '0.05em',
|
||||
}}
|
||||
>
|
||||
<Layers style={{ width: '13px', height: '13px' }} />
|
||||
{groupByHost ? 'Grouped' : 'Group'}
|
||||
</button>
|
||||
<ColumnManager columnOrder={columnOrder} onChange={updateColumns} />
|
||||
<RowVisibilityManager hiddenRowIds={hiddenRowIds} findings={findings} onRestore={restoreRow} onRestoreAll={restoreAllRows} />
|
||||
<button
|
||||
@@ -6915,7 +7197,11 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
|
||||
setAtlasSyncing(true);
|
||||
setAtlasError(null);
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/atlas/sync`, { method: 'POST', credentials: 'include' });
|
||||
const teamsParam = getActiveTeamsParam();
|
||||
const syncUrl = teamsParam
|
||||
? `${API_BASE}/atlas/sync?teams=${encodeURIComponent(teamsParam)}`
|
||||
: `${API_BASE}/atlas/sync`;
|
||||
const res = await fetch(syncUrl, { method: 'POST', credentials: 'include' });
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.error || 'Atlas sync failed');
|
||||
@@ -7136,6 +7422,178 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{groupByHost ? (
|
||||
/* ---- Grouped-by-host view ---- */
|
||||
<>
|
||||
{groupedRenderList.length > 0 && (
|
||||
<tr>
|
||||
<td colSpan={visibleCols.length + 3} style={{ padding: '0.4rem 0.75rem', background: 'rgba(139,92,246,0.04)', borderBottom: '1px solid rgba(139,92,246,0.15)' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
||||
<span style={{ fontFamily: 'monospace', fontSize: '0.68rem', color: '#A78BFA', fontWeight: '600' }}>
|
||||
{groupedByHost.groups.length} grouped host{groupedByHost.groups.length !== 1 ? 's' : ''} · {groupedByHost.singles.length} single{groupedByHost.singles.length !== 1 ? 's' : ''} · {sorted.length} total
|
||||
</span>
|
||||
<button onClick={expandAllHosts} style={{ background: 'none', border: 'none', color: '#64748B', fontFamily: 'monospace', fontSize: '0.62rem', cursor: 'pointer', textDecoration: 'underline', padding: 0 }}>expand all</button>
|
||||
<button onClick={collapseAllHosts} style={{ background: 'none', border: 'none', color: '#64748B', fontFamily: 'monospace', fontSize: '0.62rem', cursor: 'pointer', textDecoration: 'underline', padding: 0 }}>collapse all</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{groupedRenderList.map((item, itemIdx) => {
|
||||
if (item.type === 'single') {
|
||||
// Render single-finding hosts as normal flat rows
|
||||
const finding = item.finding;
|
||||
const isSelected = selectedIds.has(finding.id);
|
||||
const rowBg = isSelected ? 'rgba(14,165,233,0.12)' : (itemIdx % 2 === 0 ? 'rgba(15,26,46,0.4)' : 'rgba(10,18,32,0.4)');
|
||||
const queued = isQueued(finding.id);
|
||||
return (
|
||||
<tr
|
||||
key={finding.id}
|
||||
style={{ borderBottom: '1px solid rgba(255,255,255,0.04)', background: rowBg }}
|
||||
onMouseEnter={(e) => { if (!isSelected) e.currentTarget.style.background = 'rgba(14,165,233,0.05)'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.background = rowBg; }}
|
||||
>
|
||||
<td style={{ padding: '0.45rem 0.5rem', textAlign: 'center', width: '36px', cursor: 'pointer' }} onClick={() => toggleRowSelection(finding.id)}>
|
||||
{selectedRowIds.has(String(finding.id))
|
||||
? <CheckSquare style={{ width: '13px', height: '13px', color: '#4fc3f7' }} />
|
||||
: <Square style={{ width: '13px', height: '13px', color: '#8892a2' }} />
|
||||
}
|
||||
</td>
|
||||
<td style={{ padding: '0.45rem 0.5rem', textAlign: 'center', width: '36px' }}>
|
||||
<button onClick={() => hideRow(finding.id)} title="Hide this row" style={{ background: 'none', border: 'none', padding: 0, cursor: 'pointer', display: 'inline-flex', alignItems: 'center', justifyContent: 'center' }} onMouseEnter={(e) => { e.currentTarget.querySelector('svg').style.color = '#4fc3f7'; }} onMouseLeave={(e) => { e.currentTarget.querySelector('svg').style.color = '#8892a2'; }}>
|
||||
<EyeOff style={{ width: '13px', height: '13px', color: '#8892a2' }} />
|
||||
</button>
|
||||
</td>
|
||||
<td style={{ padding: '0.45rem 0.5rem', textAlign: 'center', width: '36px' }} onClick={() => { if (queued) return; setSelectedIds((prev) => { const next = new Set(prev); if (next.has(finding.id)) next.delete(finding.id); else next.add(finding.id); return next; }); setLastClickedId(finding.id); }}>
|
||||
<input type="checkbox" readOnly checked={queued || isSelected} style={{ accentColor: queued ? '#10B981' : '#0EA5E9', width: '13px', height: '13px', cursor: queued ? 'default' : 'pointer', pointerEvents: 'none' }} />
|
||||
</td>
|
||||
{visibleCols.map((col) => (
|
||||
<TableCell key={col.key} colKey={col.key} finding={finding} canWrite={canWrite()} onCveMouseEnter={handleCveMouseEnter} onCveMouseLeave={handleCveMouseLeave} onIpMouseEnter={handleIpMouseEnter} onIpMouseLeave={handleIpMouseLeave} fpSubmissions={fpSubmissions} onEditSubmission={handleEditSubmission} atlasStatusMap={atlasStatusMap} onAtlasBadgeClick={(hostId) => { setAtlasSelectedHostId(hostId); setAtlasSelectedHostName(finding.hostName || finding.ipAddress || ''); setAtlasSelectedFindingId(finding.id || null); setAtlasPanelOpen(true); }} onNoteSaved={handleNoteSaved} />
|
||||
))}
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
// Render grouped host header + expandable sub-rows
|
||||
const group = item.group;
|
||||
const isExpanded = expandedHosts.has(group.hostKey);
|
||||
const sc = severityColor(group.highestVrrGroup);
|
||||
return (
|
||||
<React.Fragment key={group.hostKey}>
|
||||
{/* Host group header — uses same columns as regular rows */}
|
||||
<tr
|
||||
onClick={() => toggleHostExpand(group.hostKey)}
|
||||
style={{
|
||||
borderBottom: '1px solid rgba(139,92,246,0.15)',
|
||||
background: isExpanded ? 'rgba(139,92,246,0.06)' : 'rgba(15,26,46,0.5)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.background = 'rgba(139,92,246,0.08)'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.background = isExpanded ? 'rgba(139,92,246,0.06)' : 'rgba(15,26,46,0.5)'; }}
|
||||
>
|
||||
{/* Expand/collapse icon in first fixed column */}
|
||||
<td style={{ padding: '0.45rem 0.5rem', textAlign: 'center', width: '36px' }}>
|
||||
{isExpanded
|
||||
? <ChevronDown style={{ width: '13px', height: '13px', color: '#A78BFA' }} />
|
||||
: <ChevronRight style={{ width: '13px', height: '13px', color: '#A78BFA' }} />
|
||||
}
|
||||
</td>
|
||||
{/* Empty cells for hide + checkbox columns */}
|
||||
<td style={{ padding: '0.45rem 0.5rem', width: '36px' }} />
|
||||
<td style={{ padding: '0.45rem 0.5rem', width: '36px' }} />
|
||||
{/* Render each column cell — show host-level summary data in the matching column positions */}
|
||||
{visibleCols.map((col) => {
|
||||
switch (col.key) {
|
||||
case 'findingId':
|
||||
return (
|
||||
<td key={col.key} style={{ padding: '0.45rem 0.75rem', whiteSpace: 'nowrap' }}>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '0.35rem', padding: '0.15rem 0.5rem', borderRadius: '0.25rem', background: 'rgba(139,92,246,0.12)', border: '1px solid rgba(139,92,246,0.3)', fontFamily: 'monospace', fontSize: '0.65rem', fontWeight: '700', color: '#A78BFA' }}>
|
||||
{group.findings.length} findings
|
||||
</span>
|
||||
</td>
|
||||
);
|
||||
case 'severity':
|
||||
return (
|
||||
<td key={col.key} style={{ padding: '0.45rem 0.75rem', whiteSpace: 'nowrap' }}>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '0.3rem', padding: '0.2rem 0.45rem', borderRadius: '0.25rem', background: sc.bg, border: `1px solid ${sc.border}40`, fontFamily: 'monospace', fontWeight: '700', color: sc.text, fontSize: '0.72rem' }}>
|
||||
{group.highestSeverity.toFixed(2)}
|
||||
<span style={{ fontSize: '0.6rem', opacity: 0.75 }}>{group.highestVrrGroup}</span>
|
||||
</span>
|
||||
</td>
|
||||
);
|
||||
case 'hostName':
|
||||
return (
|
||||
<td key={col.key} style={{ padding: '0.45rem 0.75rem' }}>
|
||||
<span style={{ color: '#E2E8F0', fontFamily: 'monospace', fontSize: '0.75rem', fontWeight: '600' }}>
|
||||
{group.hostName || '—'}
|
||||
</span>
|
||||
</td>
|
||||
);
|
||||
case 'ipAddress':
|
||||
return (
|
||||
<td key={col.key} style={{ padding: '0.45rem 0.75rem', whiteSpace: 'nowrap' }}>
|
||||
<span style={{ color: '#0EA5E9', fontFamily: 'monospace', fontSize: '0.72rem' }}>
|
||||
{group.ipAddress || '—'}
|
||||
</span>
|
||||
</td>
|
||||
);
|
||||
case 'cves':
|
||||
return (
|
||||
<td key={col.key} style={{ padding: '0.45rem 0.75rem' }}>
|
||||
<span style={{ fontFamily: 'monospace', fontSize: '0.65rem', color: '#64748B' }}>
|
||||
{group.cveSet.size} CVE{group.cveSet.size !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</td>
|
||||
);
|
||||
default:
|
||||
return <td key={col.key} style={{ padding: '0.45rem 0.75rem' }} />;
|
||||
}
|
||||
})}
|
||||
</tr>
|
||||
{/* Expanded sub-rows — individual findings */}
|
||||
{isExpanded && group.findings.map((finding, idx) => {
|
||||
const isSelected = selectedIds.has(finding.id);
|
||||
const rowBg = isSelected ? 'rgba(14,165,233,0.12)' : (idx % 2 === 0 ? 'rgba(20,30,50,0.5)' : 'rgba(15,24,42,0.5)');
|
||||
const queued = isQueued(finding.id);
|
||||
return (
|
||||
<tr
|
||||
key={finding.id}
|
||||
style={{ borderBottom: '1px solid rgba(255,255,255,0.03)', background: rowBg, borderLeft: '3px solid rgba(139,92,246,0.25)' }}
|
||||
onMouseEnter={(e) => { if (!isSelected) e.currentTarget.style.background = 'rgba(14,165,233,0.05)'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.background = rowBg; }}
|
||||
>
|
||||
<td style={{ padding: '0.45rem 0.5rem', textAlign: 'center', width: '36px', cursor: 'pointer' }} onClick={() => toggleRowSelection(finding.id)}>
|
||||
{selectedRowIds.has(String(finding.id))
|
||||
? <CheckSquare style={{ width: '13px', height: '13px', color: '#4fc3f7' }} />
|
||||
: <Square style={{ width: '13px', height: '13px', color: '#8892a2' }} />
|
||||
}
|
||||
</td>
|
||||
<td style={{ padding: '0.45rem 0.5rem', textAlign: 'center', width: '36px' }}>
|
||||
<button onClick={() => hideRow(finding.id)} title="Hide this row" style={{ background: 'none', border: 'none', padding: 0, cursor: 'pointer', display: 'inline-flex', alignItems: 'center', justifyContent: 'center' }} onMouseEnter={(e) => { e.currentTarget.querySelector('svg').style.color = '#4fc3f7'; }} onMouseLeave={(e) => { e.currentTarget.querySelector('svg').style.color = '#8892a2'; }}>
|
||||
<EyeOff style={{ width: '13px', height: '13px', color: '#8892a2' }} />
|
||||
</button>
|
||||
</td>
|
||||
<td style={{ padding: '0.45rem 0.5rem', textAlign: 'center', width: '36px' }} onClick={() => { if (queued) return; setSelectedIds((prev) => { const next = new Set(prev); if (next.has(finding.id)) next.delete(finding.id); else next.add(finding.id); return next; }); setLastClickedId(finding.id); }}>
|
||||
<input type="checkbox" readOnly checked={queued || isSelected} style={{ accentColor: queued ? '#10B981' : '#0EA5E9', width: '13px', height: '13px', cursor: queued ? 'default' : 'pointer', pointerEvents: 'none' }} />
|
||||
</td>
|
||||
{visibleCols.map((col) => (
|
||||
<TableCell key={col.key} colKey={col.key} finding={finding} canWrite={canWrite()} onCveMouseEnter={handleCveMouseEnter} onCveMouseLeave={handleCveMouseLeave} onIpMouseEnter={handleIpMouseEnter} onIpMouseLeave={handleIpMouseLeave} fpSubmissions={fpSubmissions} onEditSubmission={handleEditSubmission} atlasStatusMap={atlasStatusMap} onAtlasBadgeClick={(hostId) => { setAtlasSelectedHostId(hostId); setAtlasSelectedHostName(finding.hostName || finding.ipAddress || ''); setAtlasSelectedFindingId(finding.id || null); setAtlasPanelOpen(true); }} onNoteSaved={handleNoteSaved} />
|
||||
))}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
{groupedRenderList.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={visibleCols.length + 3} style={{ textAlign: 'center', padding: '2rem', color: '#475569', fontFamily: 'monospace', fontSize: '0.75rem' }}>
|
||||
{activeFilterCount > 0 ? 'No findings match the current filters' : 'No findings found'}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
/* ---- Flat view (default) ---- */
|
||||
<>
|
||||
{sorted.map((finding, idx) => {
|
||||
const isSelected = selectedIds.has(finding.id);
|
||||
const rowBg = isSelected ? 'rgba(14,165,233,0.12)' : (idx % 2 === 0 ? 'rgba(15,26,46,0.4)' : 'rgba(10,18,32,0.4)');
|
||||
@@ -7218,7 +7676,7 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
|
||||
/>
|
||||
</td>
|
||||
{visibleCols.map((col) => (
|
||||
<TableCell key={col.key} colKey={col.key} finding={finding} canWrite={canWrite()} onCveMouseEnter={handleCveMouseEnter} onCveMouseLeave={handleCveMouseLeave} fpSubmissions={fpSubmissions} onEditSubmission={handleEditSubmission} atlasStatusMap={atlasStatusMap} onAtlasBadgeClick={(hostId) => { setAtlasSelectedHostId(hostId); setAtlasSelectedHostName(finding.hostName || finding.ipAddress || ''); setAtlasSelectedFindingId(finding.id || null); setAtlasPanelOpen(true); }} />
|
||||
<TableCell key={col.key} colKey={col.key} finding={finding} canWrite={canWrite()} onCveMouseEnter={handleCveMouseEnter} onCveMouseLeave={handleCveMouseLeave} onIpMouseEnter={handleIpMouseEnter} onIpMouseLeave={handleIpMouseLeave} fpSubmissions={fpSubmissions} onEditSubmission={handleEditSubmission} atlasStatusMap={atlasStatusMap} onAtlasBadgeClick={(hostId) => { setAtlasSelectedHostId(hostId); setAtlasSelectedHostName(finding.hostName || finding.ipAddress || ''); setAtlasSelectedFindingId(finding.id || null); setAtlasPanelOpen(true); }} onNoteSaved={handleNoteSaved} />
|
||||
))}
|
||||
</tr>
|
||||
);
|
||||
@@ -7230,6 +7688,8 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -7273,10 +7733,18 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
|
||||
onDeleteMany={deleteQueueItems}
|
||||
onClearCompleted={clearCompleted}
|
||||
onCreateFpWorkflow={handleCreateFpWorkflow}
|
||||
onRedirectComplete={(newItem) => {
|
||||
setQueueItems((prev) => [...prev, newItem].sort((a, b) =>
|
||||
(a.vendor || '').localeCompare(b.vendor || '') || a.id - b.id
|
||||
));
|
||||
onRedirectComplete={(updatedItem) => {
|
||||
setQueueItems((prev) => {
|
||||
// If item already exists (in-place update), replace it
|
||||
const exists = prev.some(i => i.id === updatedItem.id);
|
||||
if (exists) {
|
||||
return prev.map(i => i.id === updatedItem.id ? updatedItem : i);
|
||||
}
|
||||
// Otherwise it's a new item (redirect from completed), add it
|
||||
return [...prev, updatedItem].sort((a, b) =>
|
||||
(a.vendor || '').localeCompare(b.vendor || '') || a.id - b.id
|
||||
);
|
||||
});
|
||||
}}
|
||||
canWrite={canWrite}
|
||||
fpSubmissions={fpSubmissionsFiltered}
|
||||
@@ -7304,6 +7772,23 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
|
||||
anchorRect={tooltipAnchorRect}
|
||||
cache={tooltipCacheRef}
|
||||
/>
|
||||
<CardOwnerTooltip
|
||||
ip={cardTooltipIp}
|
||||
hostId={cardTooltipHostId}
|
||||
anchorRect={cardTooltipAnchorRect}
|
||||
cache={cardTooltipCacheRef}
|
||||
cardConfigured={cardConfigured}
|
||||
onAction={handleCardAction}
|
||||
onMouseEnter={handleCardTooltipEnter}
|
||||
onMouseLeave={handleCardTooltipLeave}
|
||||
/>
|
||||
<CardDetailModal
|
||||
isOpen={!!cardActionIp}
|
||||
onClose={() => { setCardActionIp(null); setCardActionData(null); }}
|
||||
ip={cardActionIp}
|
||||
ownerData={cardActionData}
|
||||
cardTeams={cardTeams}
|
||||
/>
|
||||
{atlasPanelOpen && atlasSelectedHostId && (
|
||||
<AtlasSlideOutPanel
|
||||
hostId={atlasSelectedHostId}
|
||||
|
||||
25
frontend/src/data/complianceCategories.json
Normal file
25
frontend/src/data/complianceCategories.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"1.1.1": "Logging & Monitoring",
|
||||
"1.1.3": "Logging & Monitoring",
|
||||
"1.4.1": "Logging & Monitoring",
|
||||
"2.3.4i": "Vulnerability Management",
|
||||
"2.3.6i": "Vulnerability Management",
|
||||
"2.3.8i": "Vulnerability Management",
|
||||
"5.2.4": "Access & MFA",
|
||||
"5.2.5": "Access & MFA",
|
||||
"5.2.6": "Access & MFA",
|
||||
"5.2.7": "Access & MFA",
|
||||
"5.2.8": "Access & MFA",
|
||||
"5.3.4": "Endpoint Protection",
|
||||
"5.5.4i": "Vulnerability Management",
|
||||
"5.5.5": "Decommissioned Assets",
|
||||
"5.8.1": "Application Security",
|
||||
"7.1.1": "Logging & Monitoring",
|
||||
"7.1.4": "Logging & Monitoring",
|
||||
"7.6.13": "Disaster Recovery",
|
||||
"7.6.16": "Disaster Recovery",
|
||||
"Missing_AppID": "Asset Data Quality",
|
||||
"Missing_DF": "Asset Data Quality",
|
||||
"Missing_OS": "Asset Data Quality",
|
||||
"5.5.2": "Other"
|
||||
}
|
||||
65
frontend/src/utils/graniteLoaderPicklists.js
Normal file
65
frontend/src/utils/graniteLoaderPicklists.js
Normal file
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Granite Loader Sheet picklist values.
|
||||
* Extracted from docs/Team_Device Loader.xlsx reference sheets.
|
||||
* These values are used for searchable dropdowns in the LoaderModal.
|
||||
*/
|
||||
|
||||
export const RESPONSIBLE_TEAMS = [
|
||||
'AE-LESS-ENDS', 'AE-TI-LESS-PIES', 'APVS-PCDS-DIGITAL-ACCESS', 'APVS-PCDS-DIGITAL-IDENTITY',
|
||||
'APVS-UNKNOWN', 'ARCHIVED', 'AVWO-NON-CHARTER', 'AVWO-UNKNOWN', 'CARD-ABANDONED-UNKNOWN',
|
||||
'CARD-UNKNOWN', 'CTEC-LAB-SQUAD', 'CTEC-UNKNOWN', 'FOE-FIELD OPS', 'FOE-FIELD OPS-ROC',
|
||||
'ISP-NDC-CENTENNIAL', 'ISP-NDC-CHARLOTTE', 'ISP-NDC-COUDERSPORT', 'ISP-NDC-SIMPSONVILLE',
|
||||
'ISP-UNKNOWN', 'IT', 'IT-DSSS-EDP', 'IT-SA-OPS', 'IT-SOC', 'MTG-CORE-ENG',
|
||||
'MTG-WSTC-SYSTEM CERTIFICATION', 'NEO-UNKNOWN', 'NOC-METRICS-DATA WAREHOUSE', 'NON-CHARTER',
|
||||
'NTS-AEO-ACCESS-ENG', 'NTS-AEO-ACCESS-OPS', 'NTS-AEO-INTELDEV', 'NTS-AEO-STEAM',
|
||||
'NTS-AVOC-AIA-ANALYTICS-OPS', 'NTS-AVOC-AIA-TOOLS', 'NTS-AVOC-AIA-TOOLS-CHANGEAUTO',
|
||||
'NTS-AVOC-AIA-TOOLS-PERFMANAG', 'NTS-AVOC-CVO', 'NTS-AVOC-OPSINTEL',
|
||||
'NTS-AVOC-OPSINTEL-NSIGHTS', 'NTS-AVOC-RC-KM', 'NTS-AVOC-RC-TICKETING',
|
||||
'NTS-AVOC-SCM-ISS', 'NTS-AVOC-SCM-SAPO', 'NTS-AVOC-VCO',
|
||||
'NTS-CPE-IRCS-DASDNS', 'NTS-CPE-IRCS-DATAENG', 'NTS-CPE-IRCS-DCMS',
|
||||
'NTS-CPE-IRCS-DEVICE-AUTOMATION', 'NTS-CPE-IRCS-DEVICE-OPERATIONS',
|
||||
'NTS-CPE-IRCS-INTERNETRELIABILITY', 'NTS-CPE-IRCS-SCP-OPERATIONS',
|
||||
'NTS-CPE-WIFIHWDEV-CPEHW-AUTOMATION', 'NTS-CVWO-VOICE-LAB', 'NTS-CVWO-VOICE-OPS',
|
||||
'NTS-CVWO-WIRELESS-HMNO-WHO', 'NTS-CVWO-WIRELESS-RANEA', 'NTS-CVWO-WIRELESS-WNBO',
|
||||
'NTS-CVWO-WIRELESS-WNO-MWF', 'NTS-CVWO-WIRELESS-WOP-WCO', 'NTS-ISP-NDC', 'NTS-ISP-OPS',
|
||||
'NTS-NEO-BB-IP', 'NTS-NEO-BB-OPTICAL', 'NTS-NEO-CORE-IP', 'NTS-NEO-CORE-OPTICAL',
|
||||
'NTS-NEO-IP-MGMT', 'NTS-NEO-OPSENG-LAB', 'NTS-NEO-OPSENG-TOOLS',
|
||||
'PRDCT-VSO-VDE-ADV-DEV', 'PRDCT-VSO-VDE-ADV-FOCUS', 'PRDCT-VSO-VDE-ADV-IPOIS',
|
||||
'PRDCT-VSO-VDE-ADV-PQI', 'PRDCT-VSO-VDE-ADV-SRTA', 'PRDCT-VSO-VDE-CI',
|
||||
'PRDCT-VSO-VDE-ENT', 'PRDCT-VSO-VDE-IPVENG', 'PRDCT-VSO-VDE-VCDT',
|
||||
'PRDCT-VSO-VDE-VOD-CMS', 'PRDCT-VSO-VDE-VOD-DEV', 'PRDCT-VSO-VDE-VOD-LAB',
|
||||
'PRDCT-VSO-VSW-AIS', 'PRDCT-VSO-VSW-CRESCENDO', 'PRDCT-VSO-VSW-ENTITLEMENTS',
|
||||
'PRDCT-VSO-VSW-GSD', 'PRDCT-VSO-VSW-IPVS', 'PRDCT-VSO-VSW-LANTERN',
|
||||
'PRDCT-VSO-VSW-LINEUPS', 'PRDCT-VSO-VSW-METADATA', 'PRDCT-VSO-VSW-NNS',
|
||||
'PRDCT-VSO-VSW-SETTINGS', 'PRDCT-VSO-VSW-SPECFLOW', 'PRDCT-VSO-VSW-SRE',
|
||||
'PRDCT-VSO-VSW-TEAM', 'PRDCT-VSO-VSW-TVE', 'PRDCT-VSO-VSW-VOD', 'PRDCT-VSO-VSW-VSI',
|
||||
'SB-EPS-ENTDATA', 'SDIT-CSD-ITLS-ACT', 'SDIT-CSD-ITLS-ENDS', 'SDIT-CSD-ITLS-LABOPS',
|
||||
'SDIT-CSD-ITLS-LNE', 'SDIT-CSD-ITLS-LPE', 'SDIT-CSD-ITLS-NSIE', 'SDIT-CSD-ITLS-PACE',
|
||||
'SDIT-CSD-ITLS-PIES', 'SDIT-CSD-ITLS-VLEO', 'SDIT-CSD-OBO-RDE', 'SDIT-DATA-ASSETS',
|
||||
'SDIT-DATAASSETS-MONGODBA', 'SDIT-DATAASSETS-ORACLEDBA', 'SDIT-EDIS-CDP-DAAS-DATALOGISTICS',
|
||||
'SDIT-EDIS-CDP-DAAS-NDS', 'SDIT-EDIS-CDS-DIGITALSERVICES', 'SDIT-EDIS-CIC-NEBULA',
|
||||
'SDIT-EDIS-ITEI-CLOUD', 'SDIT-EDIS-ITEI-EMAIL', 'SDIT-EDIS-ITEI-TAAS-CIE',
|
||||
'SDIT-EDIS-ITEI-TAAS-DM', 'SDIT-EDIS-NAAS-DESIGN', 'SDIT-EDIS-NAAS-FIREWALL',
|
||||
'SDIT-EDIS-NAAS-IMPLEMENTATION', 'SDIT-EDIS-NAAS-INSTALL', 'SDIT-EDIS-NAAS-NAT',
|
||||
'SDIT-EDIS-NAAS-OPERATIONS', 'SDIT-EDIS-PAAS-PRIVATE-CLOUD', 'SDIT-EDIS-PAAS-PUBCLD',
|
||||
'SDIT-ITSA-OPS', 'SDIT-ITSA-OPS-IDOS', 'SDIT-ITSA-OPS-PQR-SCI', 'SDIT-ITSA-TOO',
|
||||
'SDIT-MOBILE', 'SDIT-MOBILE-ACTIVATION', 'SDIT-PCDS-SA-ACTIVATIONS',
|
||||
'SDIT-PCDS-SA-ANALYSTS', 'SDIT-PCDS-SA-PROVISIONING', 'SDIT-PCDS-SA-SCI',
|
||||
'SDIT-SVCEXP-VCT-DESIGN', 'SN-OPS-NEWS', 'SPECTRUM ENTERPRISE', 'SPECTRUM REACH',
|
||||
'SROPS-DATA', 'SROPS-SPECTRUM REACH OPS', 'TEST-NEW-TEAM', 'TEST-OLD-TEAM',
|
||||
'VDE-MAPD-DEV', 'VDE-MAPD-DEV2', 'VDE-VOD-NVIS', 'VDE-VOD-REPORTING',
|
||||
'WTG-WAE-ACCESS ENGINEERING', 'WTG-WCE-SYS ENG', 'WTG-WDE-DEVICE ENGINEERING',
|
||||
'WTG-WRD-CONNECTIVITY', 'WTG-WRD-RESEARCH AND DEVELOPMENT',
|
||||
];
|
||||
|
||||
export const EQUIP_STATUSES = ['ACTIVE', 'DESIGNED', 'PENDING DECOMMISSION', 'DECOMMISSIONED'];
|
||||
|
||||
export const EQUIPMENT_CLASSES = ['S', 'C'];
|
||||
|
||||
// Small list — columns that should render as searchable dropdowns
|
||||
// Maps column ID → options array
|
||||
export const COLUMN_PICKLISTS = {
|
||||
RESPONSIBLE_TEAM: RESPONSIBLE_TEAMS,
|
||||
EQUIP_STATUS: EQUIP_STATUSES,
|
||||
EQUIPMENT_CLASS: EQUIPMENT_CLASSES,
|
||||
};
|
||||
@@ -90,3 +90,53 @@ export function extractCommonVendor(items) {
|
||||
const vendors = [...new Set(items.map(i => i.vendor).filter(Boolean))];
|
||||
return vendors.length === 1 ? vendors[0] : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Append remediation notes to a Jira ticket description.
|
||||
* Only appends if notesMap contains notes for at least one item.
|
||||
*
|
||||
* @param {string} baseDescription - The standard consolidated description
|
||||
* @param {Object} notesMap - { [queue_item_id]: Array<{username, note_text, created_at}> }
|
||||
* @returns {string} Description with remediation notes appended (or unchanged)
|
||||
*/
|
||||
export function appendRemediationNotes(baseDescription, notesMap) {
|
||||
if (!notesMap || typeof notesMap !== 'object') return baseDescription;
|
||||
|
||||
// Collect all notes from all items, sorted chronologically (oldest first)
|
||||
const allNotes = [];
|
||||
for (const [_itemId, notes] of Object.entries(notesMap)) {
|
||||
if (!Array.isArray(notes)) continue;
|
||||
for (const note of notes) {
|
||||
if (note && note.note_text) {
|
||||
allNotes.push(note);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (allNotes.length === 0) return baseDescription;
|
||||
|
||||
// Sort chronologically (oldest first)
|
||||
allNotes.sort((a, b) => new Date(a.created_at) - new Date(b.created_at));
|
||||
|
||||
let section = '\n== Remediation Notes ==\n';
|
||||
for (const note of allNotes) {
|
||||
const date = formatNoteDate(note.created_at);
|
||||
section += `[${date}] ${note.username}: ${note.note_text}\n`;
|
||||
}
|
||||
|
||||
return baseDescription + section;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a date string as YYYY-MM-DD for note display.
|
||||
* @param {string} dateStr - ISO date string
|
||||
* @returns {string} Formatted date
|
||||
*/
|
||||
function formatNoteDate(dateStr) {
|
||||
try {
|
||||
const d = new Date(dateStr);
|
||||
return d.toISOString().slice(0, 10);
|
||||
} catch {
|
||||
return 'Unknown';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,5 +25,14 @@
|
||||
"devDependencies": {
|
||||
"fast-check": "^4.8.0",
|
||||
"jest": "^30.3.0"
|
||||
},
|
||||
"jest": {
|
||||
"roots": [
|
||||
"<rootDir>/backend/__tests__"
|
||||
],
|
||||
"testPathIgnorePatterns": [
|
||||
"/node_modules/",
|
||||
"integration"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
BIN
shieldlogo.jpeg
Normal file
BIN
shieldlogo.jpeg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 348 KiB |
Reference in New Issue
Block a user