Compare commits
18 Commits
feature/re
...
feature/co
| Author | SHA1 | Date | |
|---|---|---|---|
| 0d48c109b3 | |||
| 18ad31228e | |||
| 3dcb91a1fc | |||
| 5102a2c5b4 | |||
| a0a8979c63 | |||
| 15ad207464 | |||
| b111273e5a | |||
| a7c74f625f | |||
| 8aef51b59a | |||
| d0087ba9b7 | |||
| 3d6062f3fa | |||
| 7af44608d0 | |||
| 3bb86e8369 | |||
| 4676279a72 | |||
| d3d86ddcf2 | |||
| 558c65807d | |||
| 518cb0a849 | |||
| b0adfa1bda |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -39,6 +39,10 @@ frontend.pid
|
|||||||
backend/uploads/temp/
|
backend/uploads/temp/
|
||||||
feature_request*.md
|
feature_request*.md
|
||||||
|
|
||||||
|
# Planning docs
|
||||||
|
docs/aeo-compliance-ui-plan.md
|
||||||
|
docs/aeo-compliance-wireframe.md
|
||||||
|
|
||||||
# AI tooling config
|
# AI tooling config
|
||||||
.claude/
|
.claude/
|
||||||
ai_notes.md
|
ai_notes.md
|
||||||
|
|||||||
361
README.md
361
README.md
@@ -1,6 +1,6 @@
|
|||||||
# CVE Dashboard
|
# STEAM Security Dashboard
|
||||||
|
|
||||||
A self-hosted vulnerability management dashboard for tracking CVE remediation status, managing vendor documentation, monitoring Ivanti host findings, and overseeing False Positive (FP) workflows.
|
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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -14,13 +14,15 @@ A self-hosted vulnerability management dashboard for tracking CVE remediation st
|
|||||||
- [Running the Application](#running-the-application)
|
- [Running the Application](#running-the-application)
|
||||||
- [Features](#features)
|
- [Features](#features)
|
||||||
- [Authentication and User Roles](#authentication-and-user-roles)
|
- [Authentication and User Roles](#authentication-and-user-roles)
|
||||||
- [Home Dashboard — CVE Management](#home-dashboard--cve-management)
|
- [Home — CVE Management](#home--cve-management)
|
||||||
- [Reporting — Host Findings](#reporting--host-findings)
|
- [Reporting — Host Findings](#reporting--host-findings)
|
||||||
|
- [Ivanti Queue](#ivanti-queue)
|
||||||
|
- [Compliance — AEO Posture](#compliance--aeo-posture)
|
||||||
- [Knowledge Base](#knowledge-base)
|
- [Knowledge Base](#knowledge-base)
|
||||||
|
- [Exports](#exports)
|
||||||
- [Archer Risk Acceptance Tickets](#archer-risk-acceptance-tickets)
|
- [Archer Risk Acceptance Tickets](#archer-risk-acceptance-tickets)
|
||||||
- [Weekly Reports](#weekly-reports)
|
- [User Management (Admin)](#user-management-admin)
|
||||||
- [User Management](#user-management-admin)
|
- [Audit Log (Admin)](#audit-log-admin)
|
||||||
- [Audit Log](#audit-log-admin)
|
|
||||||
- [Scripts](#scripts)
|
- [Scripts](#scripts)
|
||||||
- [API Reference](#api-reference)
|
- [API Reference](#api-reference)
|
||||||
- [Architecture](#architecture)
|
- [Architecture](#architecture)
|
||||||
@@ -32,16 +34,17 @@ A self-hosted vulnerability management dashboard for tracking CVE remediation st
|
|||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
The CVE Dashboard answers a common problem in vulnerability management: tracking which CVEs have been addressed, whether supporting vendor documentation exists, and where each finding is in the remediation or exception workflow.
|
The STEAM Security Dashboard answers a common problem in vulnerability management: tracking which CVEs have been addressed, whether supporting vendor documentation exists, where each finding is in the remediation or exception workflow, and how the team's overall AEO compliance posture is trending week over week.
|
||||||
|
|
||||||
The application provides:
|
The application provides:
|
||||||
|
|
||||||
- A searchable, filterable CVE list with per-vendor tracking and document storage
|
- A searchable, filterable CVE list with per-vendor tracking and document storage
|
||||||
- NVD API integration to auto-populate CVE metadata
|
- NVD API integration to auto-populate CVE metadata
|
||||||
- **Ivanti/RiskSense integration** to sync open and closed host findings with live FP workflow tracking
|
- **Ivanti/RiskSense integration** — sync open host findings with live FP workflow tracking
|
||||||
- **Reporting page** with donut charts, advanced per-column filtering, inline editing, and CSV/XLSX export
|
- **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
|
||||||
|
- **AEO Compliance page** — weekly xlsx upload, diff preview, per-team metric health cards, device-level violation tracking with notes history
|
||||||
- Archer risk acceptance ticket tracking (EXC numbers) linked to CVE/vendor pairs
|
- Archer risk acceptance ticket tracking (EXC numbers) linked to CVE/vendor pairs
|
||||||
- Weekly vulnerability report upload and processing
|
|
||||||
- A knowledge base for internal documentation and policies
|
- A knowledge base for internal documentation and policies
|
||||||
- Role-based access control with a full audit trail
|
- Role-based access control with a full audit trail
|
||||||
|
|
||||||
@@ -56,8 +59,8 @@ The application provides:
|
|||||||
| File uploads | Multer 2 |
|
| File uploads | Multer 2 |
|
||||||
| Auth | bcryptjs, cookie-based sessions |
|
| Auth | bcryptjs, cookie-based sessions |
|
||||||
| Frontend | React 19, lucide-react, xlsx |
|
| Frontend | React 19, lucide-react, xlsx |
|
||||||
| Report processing | Python 3 (stdlib only — no extra packages required for notes import) |
|
| Compliance xlsx parsing | Python 3, pandas, openpyxl |
|
||||||
| Weekly report processing | Python 3, pandas, openpyxl |
|
| Bulk notes import | Python 3 (stdlib only) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -65,7 +68,7 @@ The application provides:
|
|||||||
|
|
||||||
- Node.js 18 or later
|
- Node.js 18 or later
|
||||||
- npm
|
- npm
|
||||||
- Python 3 (required for weekly report processing and bulk notes import)
|
- Python 3 with `python3-pandas` and `python3-openpyxl` apt packages (required for compliance xlsx parsing)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -92,14 +95,15 @@ cd frontend
|
|||||||
npm install
|
npm install
|
||||||
```
|
```
|
||||||
|
|
||||||
### 4. Install Python dependencies (for weekly report processing)
|
### 4. Install Python dependencies
|
||||||
|
|
||||||
|
Install via apt — this is the correct approach on Ubuntu/Debian and mirrors the dev server setup:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd backend/scripts
|
apt install -y python3-pandas python3-openpyxl
|
||||||
pip install -r requirements.txt
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Required packages: `pandas>=2.0.0`, `openpyxl>=3.0.0`
|
> If apt packages are unavailable or you need a specific version, see `docs/python-venv-setup.md` for the venv fallback approach.
|
||||||
|
|
||||||
> The bulk notes import script (`import_notes_from_csv.py`) uses only Python stdlib and does **not** require these packages.
|
> The bulk notes import script (`import_notes_from_csv.py`) uses only Python stdlib and does **not** require these packages.
|
||||||
|
|
||||||
@@ -120,18 +124,28 @@ This creates `backend/cve_database.db` and a default admin account:
|
|||||||
|
|
||||||
### 6. Run database migrations
|
### 6. Run database migrations
|
||||||
|
|
||||||
After the initial setup, apply feature migrations in order:
|
Apply all feature migrations in order:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd backend
|
cd backend
|
||||||
node migrations/add_weekly_reports_table.js
|
|
||||||
node migrations/add_knowledge_base_table.js
|
node migrations/add_knowledge_base_table.js
|
||||||
node migrations/add_archer_tickets_table.js
|
node migrations/add_archer_tickets_table.js
|
||||||
node migrations/add_ivanti_sync_table.js
|
node migrations/add_ivanti_sync_table.js
|
||||||
node migrations/add_ivanti_findings_tables.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_compliance_tables.js
|
||||||
```
|
```
|
||||||
|
|
||||||
The Ivanti findings tables migration also handles adding the `fp_workflow_counts_json` and `fp_id_counts_json` columns idempotently on each server start — no manual re-run is needed after the first run.
|
### 7. Build the frontend
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Or use `npm start` for the development server (see [Running the Application](#running-the-application)).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -195,7 +209,7 @@ The start script saves PIDs to `backend.pid` and `frontend.pid`. Logs are writte
|
|||||||
cd backend
|
cd backend
|
||||||
node server.js
|
node server.js
|
||||||
|
|
||||||
# Terminal 2 — frontend
|
# Terminal 2 — frontend (development server)
|
||||||
cd frontend
|
cd frontend
|
||||||
npm start
|
npm start
|
||||||
```
|
```
|
||||||
@@ -217,17 +231,17 @@ All routes require authentication. Three roles are supported:
|
|||||||
|
|
||||||
| Role | Permissions |
|
| Role | Permissions |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `viewer` | Read-only: CVEs, documents, findings, reports, knowledge base, Archer tickets |
|
| `viewer` | Read-only: CVEs, documents, findings, reports, knowledge base, Archer tickets, compliance data |
|
||||||
| `editor` | All viewer permissions plus: create/update CVEs, upload documents, sync Ivanti findings, save notes and overrides, manage knowledge base articles, manage Archer tickets, upload weekly reports |
|
| `editor` | All viewer permissions plus: create/update CVEs, upload documents, sync Ivanti findings, save notes and overrides, manage knowledge base, manage Archer tickets, upload compliance reports, manage Ivanti Queue |
|
||||||
| `admin` | All editor permissions plus: delete documents, delete reports, manage users, view audit logs |
|
| `admin` | All editor permissions plus: delete documents, delete reports, manage users, view audit logs |
|
||||||
|
|
||||||
Sessions expire after 24 hours. Session tokens are stored in `httpOnly` cookies.
|
Sessions expire after 24 hours. Session tokens are stored in `httpOnly` cookies.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Home Dashboard — CVE Management
|
### Home — CVE Management
|
||||||
|
|
||||||
The home page is the primary CVE workflow tool.
|
The home page is the primary CVE research and tracking tool.
|
||||||
|
|
||||||
**CVE List**
|
**CVE List**
|
||||||
- Search CVEs by keyword (matches CVE ID, vendor, description)
|
- Search CVEs by keyword (matches CVE ID, vendor, description)
|
||||||
@@ -257,7 +271,7 @@ The home page is the primary CVE workflow tool.
|
|||||||
|
|
||||||
**Archer Ticket Quick Navigation**
|
**Archer Ticket Quick Navigation**
|
||||||
- Archer EXC numbers shown on CVE rows
|
- Archer EXC numbers shown on CVE rows
|
||||||
- Clicking an EXC badge navigates to the Reporting page with that EXC number pre-filtered
|
- Clicking an EXC badge navigates to the Reporting page pre-filtered to findings with that EXC number
|
||||||
|
|
||||||
**Calendar Widget**
|
**Calendar Widget**
|
||||||
- Shows current month with red dot indicators on dates where Ivanti findings are due
|
- Shows current month with red dot indicators on dates where Ivanti findings are due
|
||||||
@@ -271,93 +285,128 @@ The Reporting page is the core operational view for remediation tracking. It int
|
|||||||
|
|
||||||
#### Syncing Data
|
#### Syncing Data
|
||||||
|
|
||||||
Click **Sync** in the top-right of the page to pull the latest findings from Ivanti. The sync:
|
Click **Sync** (top right) to pull the latest findings from Ivanti. The sync:
|
||||||
1. Fetches all open host findings matching your BU filters and severity range (8.5–9.9)
|
1. Fetches all open host findings matching your BU filters and severity range (8.5–9.9 VRR)
|
||||||
2. Fetches the closed finding count separately
|
2. Fetches the closed finding count separately
|
||||||
3. Sweeps all closed findings to capture FP workflow states (including Approved FPs that are now closed)
|
3. Sweeps closed findings to capture FP workflow states (including Approved FPs now closed)
|
||||||
4. Stores everything in the local SQLite cache
|
4. Stores everything in the local SQLite cache
|
||||||
|
|
||||||
Findings are auto-synced on a 24-hour schedule. The last sync timestamp and status are shown at the top of the page.
|
Findings are also auto-synced on a 24-hour schedule. The last sync timestamp is shown at the top of the page.
|
||||||
|
|
||||||
> **Note:** The Reporting page will show "No data — click Sync to load" until the first sync completes. `IVANTI_API_KEY` must be set in `backend/.env`.
|
> `IVANTI_API_KEY` must be set in `backend/.env` for sync to work.
|
||||||
|
|
||||||
#### Metric Charts
|
#### Metric Charts
|
||||||
|
|
||||||
Four donut charts are shown at the top of the page.
|
|
||||||
|
|
||||||
| Chart | What it shows |
|
| Chart | What it shows |
|
||||||
|---|---|
|
|---|---|
|
||||||
| **Open vs Closed** | Total open vs closed host findings. Counts come from the Ivanti API directly (not from the local cache) so closed findings are always reflected even though they aren't stored locally. |
|
| **Open vs Closed** | Total open vs closed host findings direct from the Ivanti API |
|
||||||
| **Action Coverage** | Findings broken down by action taken: **FP Request** (has an FP# workflow ticket) · **Archer Exception** (has an EXC- number in notes) · **Pending** (no action yet). Click any segment to filter the table. |
|
| **Action Coverage** | Findings by action taken: FP Request · Archer Exception · Pending. Click a segment to filter the table. |
|
||||||
| **FP Finding Status** | How many *findings* fall into each FP workflow state (Actionable, Requested, Reworked, Approved, Rejected, Expired, Unknown). Includes closed findings — an Approved FP closes the finding and would be invisible otherwise. |
|
| **FP Finding Status** | How many *findings* are in each FP workflow state (Actionable, Requested, Reworked, Approved, Rejected, Expired) |
|
||||||
| **FP Workflow Status** | How many *unique FP# ticket IDs* are in each state. One FP# ticket can cover many findings; this chart counts tickets, not findings. |
|
| **FP Workflow Status** | How many *unique FP# ticket IDs* are in each state — one ticket can cover many findings |
|
||||||
|
|
||||||
#### Findings Table
|
#### Findings Table
|
||||||
|
|
||||||
The table shows all open findings from the cache. Each row represents a single host finding.
|
Each row represents a single Ivanti host finding.
|
||||||
|
|
||||||
**Columns**
|
|
||||||
|
|
||||||
| Column | Description |
|
| Column | Description |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Finding ID | Ivanti finding identifier |
|
| Finding ID | Ivanti finding identifier |
|
||||||
| Severity | Numerical VRR score with group label (CRITICAL / HIGH) |
|
| Severity | Numerical VRR score with group label (CRITICAL / HIGH) |
|
||||||
| Title | Vulnerability title |
|
| Title | Vulnerability title |
|
||||||
| CVEs | Associated CVE IDs — up to 2 shown, remainder as "+N" |
|
| CVEs | Associated CVE IDs — up to 2 shown, remainder as "+N" badge |
|
||||||
| Host | Hostname — inline editable (see Overrides below) |
|
| Host | Hostname — inline editable |
|
||||||
| IP Address | Host IP address |
|
| IP Address | Host IP address |
|
||||||
| DNS | DNS/FQDN — inline editable |
|
| DNS | DNS/FQDN — inline editable |
|
||||||
| Due Date | Remediation due date; red if overdue, amber if within 30 days |
|
| Due Date | Remediation due date; red if overdue, amber if within 30 days |
|
||||||
| SLA | SLA status: OVERDUE / AT_RISK / WITHIN_SLA |
|
| SLA | SLA status: OVERDUE / AT_RISK / WITHIN_SLA |
|
||||||
| BU | Business unit; STEAM rows are highlighted |
|
| BU | Business unit |
|
||||||
| Workflow | FP# ticket ID and state badge — color-coded by state |
|
| Workflow | FP# ticket ID and state badge — colour-coded by urgency |
|
||||||
| Last Found | Last detection date from Ivanti |
|
| Last Found | Last detection date from Ivanti |
|
||||||
| Notes | Free-form notes field — inline editable, persists across syncs |
|
| Notes | Free-form notes — inline editable, persists across syncs |
|
||||||
|
|
||||||
**Column Management**
|
**Inline editing:** Click a Host or DNS cell to override the Ivanti value. An amber dot (●) marks overridden cells; use the revert button (↻) to restore the original. Overrides survive re-syncs.
|
||||||
|
|
||||||
Click the **Columns** button to open the column manager:
|
**Filtering:** Click ⊙ on any column header for multi-select filtering. The `— empty —` option filters to findings with no value in that column. Multiple filters are ANDed. The Action Coverage chart also acts as a filter.
|
||||||
- Toggle column visibility with the eye icon
|
|
||||||
- Drag rows to reorder columns
|
|
||||||
- Column order and visibility persist to `localStorage`
|
|
||||||
|
|
||||||
**Sorting**
|
**Column management:** Toggle visibility and drag to reorder via the **Columns** button. Order and visibility persist to `localStorage`.
|
||||||
|
|
||||||
Click any sortable column header to sort ascending; click again to sort descending.
|
**Export:** Click **Export** to download the current filtered view as CSV or XLSX.
|
||||||
|
|
||||||
**Filtering**
|
---
|
||||||
|
|
||||||
Click the filter icon (⊙) on any filterable column header to open a filter dropdown:
|
### Ivanti Queue
|
||||||
- Search box to narrow options
|
|
||||||
- Multi-select checkboxes — all values are selected by default
|
|
||||||
- **`— empty —`** option at the top: selects findings where the cell has no value (e.g., filter the Workflow column to `— empty —` to see all findings with no FP ticket assigned)
|
|
||||||
- "Select All" and "Clear" bulk buttons
|
|
||||||
- Multiple column filters work as AND (all must match)
|
|
||||||
- Active filter badge and "Clear Filters" button appear when filters are applied
|
|
||||||
|
|
||||||
The **Action Coverage** donut chart also acts as a filter — click a segment to filter the table to that action type.
|
A personal staging list for batch-processing FP, Archer, and CARD workflows without context-switching into Ivanti mid-review.
|
||||||
|
|
||||||
**Inline Editing**
|
**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**
|
||||||
|
- Click **Add to Queue** — the row checkbox turns solid blue
|
||||||
|
|
||||||
- **Hostname / DNS**: Click a cell to edit. An amber dot (●) indicates the value has been overridden from what Ivanti reported. Use the revert button (↻) to restore the original value. Changes save on blur or Enter; Escape cancels.
|
**Queue panel:** Click the **Queue** button (top right of Reporting page) to open the slide-out panel:
|
||||||
- **Notes**: Click to edit. Saves on blur. Maximum 255 characters. Notes survive cache refreshes.
|
- **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
|
||||||
|
|
||||||
**Exporting**
|
**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
|
||||||
|
|
||||||
Click the **Export** button to download the current view (filtered, sorted, visible columns only):
|
Queue items are stored in the database, are **personal to your login**, and persist across sessions and page refreshes.
|
||||||
- **CSV** — UTF-8 with BOM for Excel compatibility
|
|
||||||
- **Excel (.xlsx)** — Auto-fitted column widths
|
|
||||||
|
|
||||||
Filename format: `findings-export-YYYY-MM-DD.csv` / `.xlsx`
|
---
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
|
||||||
|
#### Upload Workflow
|
||||||
|
|
||||||
|
Editors and admins can upload a new compliance report via the **Upload Report** button:
|
||||||
|
|
||||||
|
1. Drop or browse for the `NTS_AEO_YYYY_MM_DD.xlsx` file
|
||||||
|
2. The report is parsed server-side and a **diff preview** is shown — new violations, resolved items, and recurring items since the last upload
|
||||||
|
3. Click **Confirm Upload** to commit. The upload is recorded and the device table updates immediately.
|
||||||
|
|
||||||
|
The report date is extracted automatically from the filename.
|
||||||
|
|
||||||
|
#### Metric Health Cards
|
||||||
|
|
||||||
|
Each AEO metric (e.g., `2.3.4i`, `5.2.4`) is shown as a health card displaying:
|
||||||
|
- Compliance percentage vs target
|
||||||
|
- Status: Meets/Exceeds Target · Within 15% of Target · Below 15% of Target
|
||||||
|
|
||||||
|
Click a card to filter the device table to only devices failing that metric.
|
||||||
|
|
||||||
|
#### Device Table
|
||||||
|
|
||||||
|
Shows all devices currently failing one or more metrics (Active tab) or previously resolved (Resolved tab). Columns: Hostname, IP Address, Type, Failing Metrics, Times Seen. Click a row to open the detail panel.
|
||||||
|
|
||||||
|
#### Detail Panel
|
||||||
|
|
||||||
|
A slide-out panel for a selected device showing:
|
||||||
|
- **Failing Metrics** — each metric with surfaced extra fields (CVEs, SLA status, due date, OS, EoL, Splunk last seen, MFA software)
|
||||||
|
- For **2.3.x vulnerability metrics**: the `Ivanti_Vulnerability_ID` is displayed with a **View in Reporting →** button that navigates directly to the Reporting page
|
||||||
|
- **Resolved Metrics** — previously failing metrics now back in compliance
|
||||||
|
- **History** — how many times the device has appeared on the report and since when
|
||||||
|
- **Notes** — timestamped notes per metric with a multi-metric selector if multiple metrics are failing
|
||||||
|
|
||||||
|
Notes persist across uploads and are keyed to the device hostname and metric ID.
|
||||||
|
|
||||||
|
#### Teams
|
||||||
|
|
||||||
|
Only **STEAM** and **ACCESS-ENG** teams are tracked. The team selector at the top of the page switches context between them.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Knowledge Base
|
### Knowledge Base
|
||||||
|
|
||||||
A document library for internal reference material such as policies, runbooks, and vendor advisories.
|
A document library for internal reference material — policies, runbooks, vendor advisories, and process guides.
|
||||||
|
|
||||||
- Upload documents with a title, optional description, and category
|
- Upload documents with a title, optional description, and category
|
||||||
- View documents inline in the browser (PDFs render in an iframe; Markdown files are rendered as HTML)
|
- View documents inline in the browser (PDFs render in an iframe; Markdown files render as HTML)
|
||||||
- Download any document
|
- Download any document
|
||||||
- Filter and browse by category
|
- Filter and browse by category
|
||||||
- Editors and admins can upload and delete; all authenticated users can view
|
- Editors and admins can upload and delete; all authenticated users can view
|
||||||
@@ -366,6 +415,12 @@ Allowed file types: PDF, Markdown, TXT, Office documents (DOC, DOCX, XLS, XLSX,
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### Exports
|
||||||
|
|
||||||
|
Bulk export tools for reports and data extracts.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### Archer Risk Acceptance Tickets
|
### Archer Risk Acceptance Tickets
|
||||||
|
|
||||||
Track Archer exception tickets (EXC numbers) linked to specific CVE/vendor pairs.
|
Track Archer exception tickets (EXC numbers) linked to specific CVE/vendor pairs.
|
||||||
@@ -374,27 +429,12 @@ Track Archer exception tickets (EXC numbers) linked to specific CVE/vendor pairs
|
|||||||
- Statuses: `Draft`, `Open`, `Under Review`, `Accepted`
|
- Statuses: `Draft`, `Open`, `Under Review`, `Accepted`
|
||||||
- Optional Archer URL field for deep-linking to the Archer record
|
- Optional Archer URL field for deep-linking to the Archer record
|
||||||
- Filter tickets by CVE ID, vendor, or status
|
- Filter tickets by CVE ID, vendor, or status
|
||||||
- EXC numbers are unique across the system
|
- Clicking an EXC badge on the Home page navigates to the Reporting page pre-filtered to findings with that EXC number in their notes
|
||||||
- Clicking an EXC number on the home page navigates directly to the Reporting page with that EXC pre-filtered
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Weekly Reports
|
|
||||||
|
|
||||||
Editors and admins can upload weekly vulnerability reports as `.xlsx` files. The report is processed by a Python script (`backend/scripts/split_cve_report.py`) that:
|
|
||||||
|
|
||||||
1. Reads the `Vulnerabilities` sheet
|
|
||||||
2. Splits rows where multiple CVE IDs are comma-separated in the `CVE ID` column into individual rows
|
|
||||||
3. Saves the processed file alongside the original
|
|
||||||
|
|
||||||
Both the original and processed files can be downloaded from the weekly reports list. Only the most recently uploaded report is marked as current. Admins can delete old report records and their associated files.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### User Management (Admin)
|
### User Management (Admin)
|
||||||
|
|
||||||
Admins can manage user accounts from the UI:
|
|
||||||
|
|
||||||
- Create users with a role assignment
|
- Create users with a role assignment
|
||||||
- Change username, email, password, role, or active status
|
- Change username, email, password, role, or active status
|
||||||
- Deactivating a user immediately invalidates all their active sessions
|
- Deactivating a user immediately invalidates all their active sessions
|
||||||
@@ -404,12 +444,27 @@ Admins can manage user accounts from the UI:
|
|||||||
|
|
||||||
### Audit Log (Admin)
|
### Audit Log (Admin)
|
||||||
|
|
||||||
Every state-changing action is recorded with the user identity, IP address, action type, target entity, and a before/after details payload. Admins can view the log with filtering by user, action type, entity type, and date range. Results are paginated (25 per page).
|
Every state-changing action is recorded with the user identity, IP address, action type, target entity, and a before/after payload. Admins can view the log filtered by user, action type, entity type, and date range. Results are paginated (25 per page).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Scripts
|
## Scripts
|
||||||
|
|
||||||
|
### `backend/scripts/parse_compliance_xlsx.py`
|
||||||
|
|
||||||
|
Called automatically by the compliance upload flow. Parses the NTS_AEO xlsx report and outputs structured JSON to stdout for consumption by the Node compliance route.
|
||||||
|
|
||||||
|
- Reads all detail sheets; skips `Summary` and `CMDB_9box`
|
||||||
|
- Filters to rows where `Compliant == False`
|
||||||
|
- Extracts hostname, IP, device type, team, and metric ID per row
|
||||||
|
- Captures all non-core columns in `extra_json` (CVEs, SLA status, OS, EoL, Splunk, MFA, Ivanti_Vulnerability_ID, etc.)
|
||||||
|
- Parses `Summary` sheet for per-team metric health (compliance_pct, target, status)
|
||||||
|
- Extracts report date from the filename (`NTS_AEO_YYYY_MM_DD.xlsx`)
|
||||||
|
|
||||||
|
**Dependencies:** `pandas>=2.0.0`, `openpyxl>=3.0.0`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### `backend/scripts/import_notes_from_csv.py`
|
### `backend/scripts/import_notes_from_csv.py`
|
||||||
|
|
||||||
Bulk-import notes into the findings cache from a CSV file. Useful for onboarding existing notes or migrating from a spreadsheet.
|
Bulk-import notes into the findings cache from a CSV file. Useful for onboarding existing notes or migrating from a spreadsheet.
|
||||||
@@ -449,14 +504,6 @@ python3 import_notes_from_csv.py input.csv --db /path/to/cve_database.db
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### `backend/scripts/split_cve_report.py`
|
|
||||||
|
|
||||||
Called automatically by the weekly report upload flow. Not intended to be run manually. Splits multi-CVE rows in the uploaded Excel report into one row per CVE ID.
|
|
||||||
|
|
||||||
**Dependencies:** `pandas>=2.0.0`, `openpyxl>=3.0.0`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## API Reference
|
## API Reference
|
||||||
|
|
||||||
All endpoints are prefixed with `/api`. All endpoints except `/api/auth/login` and `/api/auth/logout` require a valid session cookie.
|
All endpoints are prefixed with `/api`. All endpoints except `/api/auth/login` and `/api/auth/logout` require a valid session cookie.
|
||||||
@@ -499,32 +546,46 @@ All endpoints are prefixed with `/api`. All endpoints except `/api/auth/login` a
|
|||||||
| GET | `/api/nvd/lookup/:cveId` | viewer+ | Look up a single CVE in the NVD 2.0 API |
|
| GET | `/api/nvd/lookup/:cveId` | viewer+ | Look up a single CVE in the NVD 2.0 API |
|
||||||
| POST | `/api/cves/nvd-sync` | editor+ | Bulk update CVE metadata from NVD |
|
| POST | `/api/cves/nvd-sync` | editor+ | Bulk update CVE metadata from NVD |
|
||||||
|
|
||||||
### Ivanti / RiskSense — Workflows
|
### Ivanti — Host Findings
|
||||||
|
|
||||||
| Method | Path | Role | Description |
|
|
||||||
|---|---|---|---|
|
|
||||||
| GET | `/api/ivanti/workflows` | viewer+ | Get cached workflow data (total, list, sync status) |
|
|
||||||
| POST | `/api/ivanti/workflows/sync` | viewer+ | Trigger an immediate workflow sync from Ivanti |
|
|
||||||
|
|
||||||
### Ivanti / RiskSense — Host Findings
|
|
||||||
|
|
||||||
| Method | Path | Role | Description |
|
| Method | Path | Role | Description |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| GET | `/api/ivanti/findings` | viewer+ | Get cached findings with notes and overrides merged in |
|
| GET | `/api/ivanti/findings` | viewer+ | Get cached findings with notes and overrides merged in |
|
||||||
| POST | `/api/ivanti/findings/sync` | viewer+ | Trigger an immediate findings sync from Ivanti |
|
| POST | `/api/ivanti/findings/sync` | viewer+ | Trigger an immediate findings sync from Ivanti |
|
||||||
| GET | `/api/ivanti/findings/counts` | viewer+ | Open vs closed finding totals |
|
| GET | `/api/ivanti/findings/counts` | viewer+ | Open vs closed finding totals |
|
||||||
| GET | `/api/ivanti/findings/fp-workflow-counts` | viewer+ | FP workflow state breakdown — returns `findingCounts`, `findingTotal`, `idCounts`, `idTotal` |
|
| GET | `/api/ivanti/findings/fp-workflow-counts` | viewer+ | FP workflow state breakdown |
|
||||||
| PUT | `/api/ivanti/findings/:findingId/override` | editor+ | Override `hostName` or `dns` for a finding; empty value clears the override |
|
| PUT | `/api/ivanti/findings/:findingId/override` | editor+ | Override `hostName` or `dns`; empty value clears the override |
|
||||||
| PUT | `/api/ivanti/findings/:findingId/note` | viewer+ | Save or update a finding note (max 255 chars) |
|
| PUT | `/api/ivanti/findings/:findingId/note` | viewer+ | Save or update a finding note (max 255 chars) |
|
||||||
|
|
||||||
### Weekly Reports
|
### Ivanti — Workflows
|
||||||
|
|
||||||
| Method | Path | Role | Description |
|
| Method | Path | Role | Description |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| POST | `/api/weekly-reports/upload` | editor+ | Upload and process a `.xlsx` vulnerability report |
|
| GET | `/api/ivanti/workflows` | viewer+ | Get cached workflow data |
|
||||||
| GET | `/api/weekly-reports` | viewer+ | List all uploaded reports |
|
| POST | `/api/ivanti/workflows/sync` | viewer+ | Trigger an immediate workflow sync |
|
||||||
| GET | `/api/weekly-reports/:id/download/:type` | viewer+ | Download `original` or `processed` file |
|
|
||||||
| DELETE | `/api/weekly-reports/:id` | admin | Delete a report record and its files |
|
### Ivanti Queue
|
||||||
|
|
||||||
|
| Method | Path | Role | Description |
|
||||||
|
|---|---|---|---|
|
||||||
|
| GET | `/api/ivanti/queue` | viewer+ | Get all queue items for the current user |
|
||||||
|
| POST | `/api/ivanti/queue` | editor+ | Add a finding to the queue |
|
||||||
|
| PATCH | `/api/ivanti/queue/:id` | editor+ | Update a queue item (mark complete, edit vendor/type) |
|
||||||
|
| DELETE | `/api/ivanti/queue/:id` | editor+ | Delete a single queue item |
|
||||||
|
| DELETE | `/api/ivanti/queue` | editor+ | Delete multiple queue items (body: `{ ids: [...] }`) |
|
||||||
|
|
||||||
|
### Compliance
|
||||||
|
|
||||||
|
| Method | Path | Role | Description |
|
||||||
|
|---|---|---|---|
|
||||||
|
| POST | `/api/compliance/preview` | editor+ | Parse an xlsx upload and return diff + temp file path |
|
||||||
|
| POST | `/api/compliance/commit` | editor+ | Commit a previewed upload to the database |
|
||||||
|
| GET | `/api/compliance/uploads` | viewer+ | List all compliance upload records |
|
||||||
|
| GET | `/api/compliance/summary` | viewer+ | Metric health summary; `?team=STEAM` |
|
||||||
|
| GET | `/api/compliance/items` | viewer+ | Device list; `?team=STEAM&status=active` |
|
||||||
|
| GET | `/api/compliance/items/:hostname` | viewer+ | Full detail for a device (metrics + notes) |
|
||||||
|
| GET | `/api/compliance/notes/:hostname/:metricId` | viewer+ | Notes for a specific hostname/metric |
|
||||||
|
| POST | `/api/compliance/notes` | editor+ | Add a note for a hostname/metric |
|
||||||
|
|
||||||
### Knowledge Base
|
### Knowledge Base
|
||||||
|
|
||||||
@@ -568,7 +629,7 @@ All endpoints are prefixed with `/api`. All endpoints except `/api/auth/login` a
|
|||||||
| Method | Path | Role | Description |
|
| Method | Path | Role | Description |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| GET | `/api/vendors` | viewer+ | List all distinct vendor names |
|
| GET | `/api/vendors` | viewer+ | List all distinct vendor names |
|
||||||
| GET | `/api/stats` | viewer+ | Dashboard statistics (total, critical count, addressed count, document count) |
|
| GET | `/api/stats` | viewer+ | Dashboard statistics |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -580,40 +641,41 @@ cve-dashboard/
|
|||||||
├── stop-servers.sh # Stop all servers
|
├── stop-servers.sh # Stop all servers
|
||||||
│
|
│
|
||||||
├── backend/
|
├── backend/
|
||||||
│ ├── server.js # Express app — routes, middleware, file upload, security headers
|
│ ├── server.js # Express app — routes, middleware, security headers
|
||||||
│ ├── setup.js # One-time DB initialization and default admin creation
|
│ ├── setup.js # One-time DB initialization and default admin creation
|
||||||
│ ├── cve_database.db # SQLite database (gitignored)
|
│ ├── cve_database.db # SQLite database (gitignored)
|
||||||
│ ├── uploads/ # File storage root (gitignored)
|
│ ├── uploads/ # File storage root (gitignored)
|
||||||
│ │ ├── <CVE-ID>/
|
│ │ ├── <CVE-ID>/<vendor>/ # CVE documents
|
||||||
│ │ │ └── <vendor>/ # CVE documents stored here
|
|
||||||
│ │ ├── weekly_reports/ # Uploaded vulnerability reports
|
|
||||||
│ │ ├── knowledge_base/ # Knowledge base documents
|
│ │ ├── knowledge_base/ # Knowledge base documents
|
||||||
│ │ └── temp/ # Temporary upload staging directory
|
│ │ └── temp/ # Temporary upload staging
|
||||||
│ ├── routes/
|
│ ├── routes/
|
||||||
│ │ ├── auth.js # Login, logout, session check
|
│ │ ├── auth.js # Login, logout, session check
|
||||||
│ │ ├── users.js # User CRUD (admin)
|
│ │ ├── users.js # User CRUD (admin)
|
||||||
│ │ ├── auditLog.js # Audit log viewer (admin)
|
│ │ ├── auditLog.js # Audit log viewer (admin)
|
||||||
│ │ ├── nvdLookup.js # NVD API proxy
|
│ │ ├── nvdLookup.js # NVD API proxy
|
||||||
│ │ ├── weeklyReports.js # Weekly report upload and management
|
|
||||||
│ │ ├── knowledgeBase.js # Knowledge base document management
|
│ │ ├── knowledgeBase.js # Knowledge base document management
|
||||||
│ │ ├── archerTickets.js # Archer EXC ticket CRUD
|
│ │ ├── archerTickets.js # Archer EXC ticket CRUD
|
||||||
│ │ ├── ivantiWorkflows.js # Ivanti workflow batch sync and cache
|
│ │ ├── ivantiWorkflows.js # Ivanti workflow batch sync and cache
|
||||||
│ │ └── ivantiFindings.js # Ivanti host findings sync, notes, overrides, FP counts
|
│ │ ├── ivantiFindings.js # Ivanti host findings sync, notes, overrides, FP counts
|
||||||
|
│ │ ├── ivantiTodoQueue.js # Ivanti Queue — personal FP/Archer/CARD staging list
|
||||||
|
│ │ └── compliance.js # AEO compliance upload, diff, device tracking, notes
|
||||||
│ ├── middleware/
|
│ ├── middleware/
|
||||||
│ │ └── auth.js # requireAuth and requireRole middleware
|
│ │ └── auth.js # requireAuth and requireRole middleware
|
||||||
│ ├── helpers/
|
│ ├── helpers/
|
||||||
│ │ ├── auditLog.js # logAudit helper (fire-and-forget)
|
│ │ ├── auditLog.js # logAudit helper (fire-and-forget)
|
||||||
│ │ └── excelProcessor.js # Calls Python script for report processing
|
|
||||||
│ ├── migrations/
|
│ ├── migrations/
|
||||||
│ │ ├── add_weekly_reports_table.js
|
|
||||||
│ │ ├── add_knowledge_base_table.js
|
│ │ ├── add_knowledge_base_table.js
|
||||||
│ │ ├── add_archer_tickets_table.js
|
│ │ ├── add_archer_tickets_table.js
|
||||||
│ │ ├── add_ivanti_sync_table.js # Ivanti workflow cache table
|
│ │ ├── add_ivanti_sync_table.js
|
||||||
│ │ └── add_ivanti_findings_tables.js # Findings cache, notes, counts, overrides tables
|
│ │ ├── add_ivanti_findings_tables.js
|
||||||
|
│ │ ├── add_ivanti_todo_queue_table.js # Ivanti Queue table
|
||||||
|
│ │ ├── add_card_workflow_type.js # CARD workflow type support
|
||||||
|
│ │ ├── add_todo_queue_ip_address.js # IP address column on queue items
|
||||||
|
│ │ └── add_compliance_tables.js # AEO compliance tables
|
||||||
│ └── scripts/
|
│ └── scripts/
|
||||||
│ ├── split_cve_report.py # Splits multi-CVE rows in Excel reports
|
│ ├── parse_compliance_xlsx.py # Parses NTS_AEO xlsx compliance reports
|
||||||
│ ├── import_notes_from_csv.py # Bulk-import finding notes from CSV
|
│ ├── import_notes_from_csv.py # Bulk-import finding notes from CSV
|
||||||
│ └── requirements.txt # pandas, openpyxl (weekly report processing only)
|
│ └── requirements.txt # pandas, openpyxl
|
||||||
│
|
│
|
||||||
└── frontend/
|
└── frontend/
|
||||||
└── src/
|
└── src/
|
||||||
@@ -628,13 +690,16 @@ cve-dashboard/
|
|||||||
├── CalendarWidget.js # Due-date calendar with Ivanti finding indicators
|
├── CalendarWidget.js # Due-date calendar with Ivanti finding indicators
|
||||||
├── UserManagement.js # Admin user management panel
|
├── UserManagement.js # Admin user management panel
|
||||||
├── AuditLog.js # Admin audit log viewer
|
├── AuditLog.js # Admin audit log viewer
|
||||||
├── NvdSyncModal.js # Bulk NVD sync dialog with review/apply flow
|
├── NvdSyncModal.js # Bulk NVD sync dialog
|
||||||
├── KnowledgeBaseModal.js # Knowledge base upload/list modal
|
├── KnowledgeBaseModal.js # Knowledge base upload/list modal
|
||||||
├── KnowledgeBaseViewer.js # Inline document viewer
|
├── KnowledgeBaseViewer.js # Inline document viewer
|
||||||
└── pages/
|
└── pages/
|
||||||
├── ReportingPage.js # Host findings: charts, table, filters, export
|
├── ReportingPage.js # Host findings: charts, table, queue, export
|
||||||
├── KnowledgeBasePage.js # Knowledge base page (placeholder)
|
├── CompliancePage.js # AEO compliance: metric cards, device table
|
||||||
└── ExportsPage.js # Exports page (placeholder)
|
├── ComplianceUploadModal.js # xlsx upload with diff preview
|
||||||
|
├── ComplianceDetailPanel.js # Per-device metrics, history, notes
|
||||||
|
├── KnowledgeBasePage.js # Knowledge base page
|
||||||
|
└── ExportsPage.js # Exports page
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -657,25 +722,28 @@ cve-dashboard/
|
|||||||
|
|
||||||
### Feature tables (added by migrations)
|
### Feature tables (added by migrations)
|
||||||
|
|
||||||
**`weekly_reports`** — Metadata for uploaded vulnerability reports. Tracks original and processed file paths, row counts, uploader, and a `is_current` flag.
|
|
||||||
|
|
||||||
**`knowledge_base`** — Document library entries with title, slug, category, description, and file metadata.
|
**`knowledge_base`** — Document library entries with title, slug, category, description, and file metadata.
|
||||||
|
|
||||||
**`archer_tickets`** — Archer EXC exception tickets linked to CVE/vendor pairs. `UNIQUE(exc_number)`. Foreign key `(cve_id, vendor)` with CASCADE delete.
|
**`archer_tickets`** — Archer EXC exception tickets linked to CVE/vendor pairs. `UNIQUE(exc_number)`.
|
||||||
|
|
||||||
**`ivanti_sync_state`** — Single-row cache (id=1) for Ivanti workflow batch data: total count, JSON array of workflows, sync timestamp, sync status.
|
**`ivanti_sync_state`** — Single-row cache for Ivanti workflow batch data.
|
||||||
|
|
||||||
**`ivanti_findings_cache`** — Single-row cache (id=1) for Ivanti host findings: total count, JSON array of slimmed finding objects, sync timestamp, sync status.
|
**`ivanti_findings_cache`** — Single-row cache for Ivanti host findings.
|
||||||
|
|
||||||
**`ivanti_finding_notes`** — Persistent per-finding notes keyed by finding ID. Survives findings cache refreshes. `UNIQUE(finding_id)`.
|
**`ivanti_finding_notes`** — Persistent per-finding notes keyed by finding ID. Survives cache refreshes. `UNIQUE(finding_id)`.
|
||||||
|
|
||||||
**`ivanti_counts_cache`** — Single-row cache (id=1) for finding metrics:
|
**`ivanti_counts_cache`** — Single-row cache for finding metrics: open/closed counts, FP workflow state breakdowns by finding and by unique ticket ID.
|
||||||
- `open_count` / `closed_count` — total open and closed findings
|
|
||||||
- `fp_workflow_counts_json` — JSON object mapping FP workflow state → number of findings
|
|
||||||
- `fp_id_counts_json` — JSON object mapping FP workflow state → number of unique FP# ticket IDs
|
|
||||||
|
|
||||||
**`ivanti_finding_overrides`** — Editor-applied overrides for `hostName` and `dns` fields. `UNIQUE(finding_id, field)`.
|
**`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)`.
|
||||||
|
|
||||||
|
**`compliance_uploads`** — Record of each compliance xlsx upload: filename, report date, uploader, timestamp, and new/resolved/recurring counts.
|
||||||
|
|
||||||
|
**`compliance_items`** — One row per device/metric violation. Tracks hostname, IP, device type, team, metric ID, category, `extra_json` (all non-core xlsx columns), status (active/resolved), first seen upload, and times seen. Identity key: `(hostname, metric_id)`.
|
||||||
|
|
||||||
|
**`compliance_notes`** — Timestamped notes per hostname/metric. Multiple notes per combination are supported. Foreign-key linked to compliance items.
|
||||||
|
|
||||||
### View
|
### View
|
||||||
|
|
||||||
**`cve_document_status`** — Aggregates document counts per CVE/vendor and derives a `compliance_status` (`Complete` when an advisory is present, otherwise `Missing Required Docs`).
|
**`cve_document_status`** — Aggregates document counts per CVE/vendor and derives a `compliance_status` (`Complete` when an advisory is present, otherwise `Missing Required Docs`).
|
||||||
@@ -703,13 +771,13 @@ cve-dashboard/
|
|||||||
- Status must be one of: `Open`, `Addressed`, `In Progress`, `Resolved`
|
- Status must be one of: `Open`, `Addressed`, `In Progress`, `Resolved`
|
||||||
- Archer EXC numbers must match `/^EXC-\d+$/`
|
- Archer EXC numbers must match `/^EXC-\d+$/`
|
||||||
- Finding override field must be one of: `hostName`, `dns`
|
- Finding override field must be one of: `hostName`, `dns`
|
||||||
- All database operations use prepared statements (no string interpolation in SQL)
|
- All database operations use prepared statements — no string interpolation in SQL
|
||||||
|
|
||||||
### Error handling
|
### Error handling
|
||||||
|
|
||||||
- 500 responses never expose internal error messages to the client
|
- 500 responses never expose internal error messages to the client
|
||||||
- Full errors are logged server-side only
|
- Full errors are logged server-side only
|
||||||
- Descriptive 400/409 responses are safe as they contain only application-authored validation messages
|
- Descriptive 400/409 responses contain only application-authored validation messages
|
||||||
|
|
||||||
### Security headers
|
### Security headers
|
||||||
|
|
||||||
@@ -729,21 +797,24 @@ Applied to all responses:
|
|||||||
|
|
||||||
## Migrations
|
## Migrations
|
||||||
|
|
||||||
Migrations are standalone Node.js scripts that modify the database directly. Run them in the listed order on a fresh install. They use `CREATE TABLE IF NOT EXISTS` so they are safe to re-run if needed.
|
Migrations are standalone Node.js scripts. Run them in the listed order on a fresh install. All use `CREATE TABLE IF NOT EXISTS` or `ALTER TABLE ... ADD COLUMN IF NOT EXISTS` and are safe to re-run.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd backend
|
cd backend
|
||||||
node migrations/add_weekly_reports_table.js
|
|
||||||
node migrations/add_knowledge_base_table.js
|
node migrations/add_knowledge_base_table.js
|
||||||
node migrations/add_archer_tickets_table.js
|
node migrations/add_archer_tickets_table.js
|
||||||
node migrations/add_ivanti_sync_table.js
|
node migrations/add_ivanti_sync_table.js
|
||||||
node migrations/add_ivanti_findings_tables.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_compliance_tables.js
|
||||||
```
|
```
|
||||||
|
|
||||||
For an existing deployment upgrading from an earlier schema, check the legacy migration scripts in `backend/`:
|
For deployments upgrading from an older schema, the following legacy migration scripts are also available in `backend/`:
|
||||||
|
|
||||||
- `migrate_multivendor.js` — Adds multi-vendor support to an older single-vendor schema
|
- `migrate_multivendor.js` — Adds multi-vendor support to an older single-vendor schema
|
||||||
- `migrate-audit-log.js` — Adds the `audit_logs` table to pre-auth deployments
|
- `migrate-audit-log.js` — Adds the `audit_logs` table to pre-auth deployments
|
||||||
- `migrate-to-1.1.js` — General 1.0 → 1.1 schema update
|
- `migrate-to-1.1.js` — General 1.0 → 1.1 schema update
|
||||||
|
|
||||||
> The Ivanti FP workflow count columns (`fp_workflow_counts_json`, `fp_id_counts_json`) are added automatically via `ALTER TABLE ... ADD COLUMN` each time the server starts. These statements are idempotent — the error for a duplicate column is silently ignored.
|
> Several columns (`fp_workflow_counts_json`, `fp_id_counts_json`, `seen_count`, `summary_json`) are added automatically via idempotent `ALTER TABLE` statements each time the server starts. No manual re-run is needed.
|
||||||
|
|||||||
@@ -1,211 +0,0 @@
|
|||||||
# Weekly Vulnerability Report Upload Feature
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
A new feature has been added to the CVE Dashboard that allows users to upload their weekly vulnerability reports in Excel format (.xlsx) and automatically process them to split multiple CVE IDs into separate rows for easier filtering and analysis.
|
|
||||||
|
|
||||||
## What Was Implemented
|
|
||||||
|
|
||||||
### Backend Changes
|
|
||||||
|
|
||||||
1. **Database Migration** (`backend/migrations/add_weekly_reports_table.js`)
|
|
||||||
- Created `weekly_reports` table to store report metadata
|
|
||||||
- Tracks upload date, file paths, row counts, and which report is current
|
|
||||||
- Indexed for fast queries
|
|
||||||
|
|
||||||
2. **Excel Processor** (`backend/helpers/excelProcessor.js`)
|
|
||||||
- Executes Python script via Node.js child_process
|
|
||||||
- Parses row counts from Python output
|
|
||||||
- Handles errors, timeouts (30 seconds), and validation
|
|
||||||
|
|
||||||
3. **API Routes** (`backend/routes/weeklyReports.js`)
|
|
||||||
- `POST /api/weekly-reports/upload` - Upload and process Excel file
|
|
||||||
- `GET /api/weekly-reports` - List all reports
|
|
||||||
- `GET /api/weekly-reports/:id/download/:type` - Download original or processed file
|
|
||||||
- `DELETE /api/weekly-reports/:id` - Delete report (admin only)
|
|
||||||
|
|
||||||
4. **Python Script** (`backend/scripts/split_cve_report.py`)
|
|
||||||
- Moved from ~/Documents to backend/scripts
|
|
||||||
- Splits comma-separated CVE IDs into separate rows
|
|
||||||
- Duplicates device/IP data for each CVE
|
|
||||||
|
|
||||||
### Frontend Changes
|
|
||||||
|
|
||||||
1. **Weekly Report Modal** (`frontend/src/components/WeeklyReportModal.js`)
|
|
||||||
- Phase-based UI: idle → uploading → processing → success
|
|
||||||
- File upload with .xlsx validation
|
|
||||||
- Display existing reports with current report indicator (★)
|
|
||||||
- Download buttons for both original and processed files
|
|
||||||
|
|
||||||
2. **App.js Integration**
|
|
||||||
- Added "Weekly Report" button next to NVD Sync button
|
|
||||||
- State management for modal visibility
|
|
||||||
- Modal rendering
|
|
||||||
|
|
||||||
## How to Use
|
|
||||||
|
|
||||||
### Starting the Application
|
|
||||||
|
|
||||||
1. **Backend:**
|
|
||||||
```bash
|
|
||||||
cd backend
|
|
||||||
node server.js
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Frontend:**
|
|
||||||
```bash
|
|
||||||
cd frontend
|
|
||||||
npm start
|
|
||||||
```
|
|
||||||
|
|
||||||
### Using the Feature
|
|
||||||
|
|
||||||
1. **Access the Feature**
|
|
||||||
- Login as an editor or admin user
|
|
||||||
- Look for the "Weekly Report" button in the top header (next to "NVD Sync")
|
|
||||||
|
|
||||||
2. **Upload a Report**
|
|
||||||
- Click the "Weekly Report" button
|
|
||||||
- Click "Choose File" and select your .xlsx file
|
|
||||||
- Click "Upload & Process"
|
|
||||||
- Wait for processing to complete (usually 5-10 seconds)
|
|
||||||
|
|
||||||
3. **Download Processed Report**
|
|
||||||
- After upload succeeds, you'll see row counts (e.g., "45 → 67 rows")
|
|
||||||
- Click "Download Processed" to get the split version
|
|
||||||
- The current week's report is marked with a ★ star icon
|
|
||||||
|
|
||||||
4. **Access Previous Reports**
|
|
||||||
- All previous reports are listed below the upload section
|
|
||||||
- Click the download icons to get original or processed versions
|
|
||||||
- Reports are labeled as "This week's report", "Last week's report", or by date
|
|
||||||
|
|
||||||
### What the Processing Does
|
|
||||||
|
|
||||||
**Before Processing:**
|
|
||||||
| HOSTNAME | IP | CVE ID |
|
|
||||||
|----------|------------|---------------------------|
|
|
||||||
| server01 | 10.0.0.1 | CVE-2024-1234, CVE-2024-5678 |
|
|
||||||
|
|
||||||
**After Processing:**
|
|
||||||
| HOSTNAME | IP | CVE ID |
|
|
||||||
|----------|------------|---------------------------|
|
|
||||||
| server01 | 10.0.0.1 | CVE-2024-1234 |
|
|
||||||
| server01 | 10.0.0.1 | CVE-2024-5678 |
|
|
||||||
|
|
||||||
Each CVE now has its own row, making it easy to:
|
|
||||||
- Sort by CVE ID
|
|
||||||
- Filter for specific CVEs
|
|
||||||
- Research CVEs one by one per device
|
|
||||||
|
|
||||||
## File Locations
|
|
||||||
|
|
||||||
### New Files Created
|
|
||||||
|
|
||||||
```
|
|
||||||
backend/
|
|
||||||
scripts/
|
|
||||||
split_cve_report.py # Python script for CVE splitting
|
|
||||||
requirements.txt # Python dependencies
|
|
||||||
routes/
|
|
||||||
weeklyReports.js # API endpoints
|
|
||||||
helpers/
|
|
||||||
excelProcessor.js # Python integration
|
|
||||||
migrations/
|
|
||||||
add_weekly_reports_table.js # Database migration
|
|
||||||
uploads/
|
|
||||||
weekly_reports/ # Uploaded and processed files
|
|
||||||
|
|
||||||
frontend/
|
|
||||||
src/
|
|
||||||
components/
|
|
||||||
WeeklyReportModal.js # Upload modal UI
|
|
||||||
```
|
|
||||||
|
|
||||||
### Modified Files
|
|
||||||
|
|
||||||
```
|
|
||||||
backend/
|
|
||||||
server.js # Added route mounting
|
|
||||||
|
|
||||||
frontend/
|
|
||||||
src/
|
|
||||||
App.js # Added button and modal
|
|
||||||
```
|
|
||||||
|
|
||||||
## Security & Permissions
|
|
||||||
|
|
||||||
- **Upload**: Requires editor or admin role
|
|
||||||
- **Download**: Any authenticated user
|
|
||||||
- **Delete**: Admin only
|
|
||||||
- **File Validation**: Only .xlsx files accepted, 10MB limit
|
|
||||||
- **Audit Logging**: All uploads, downloads, and deletions are logged
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Backend Issues
|
|
||||||
|
|
||||||
**Python not found:**
|
|
||||||
```bash
|
|
||||||
# Install Python 3
|
|
||||||
sudo apt-get install python3
|
|
||||||
```
|
|
||||||
|
|
||||||
**Missing dependencies:**
|
|
||||||
```bash
|
|
||||||
# Install pandas and openpyxl
|
|
||||||
pip3 install pandas openpyxl
|
|
||||||
```
|
|
||||||
|
|
||||||
**Port already in use:**
|
|
||||||
```bash
|
|
||||||
# Find and kill process using port 3001
|
|
||||||
lsof -i :3001
|
|
||||||
kill -9 <PID>
|
|
||||||
```
|
|
||||||
|
|
||||||
### Frontend Issues
|
|
||||||
|
|
||||||
**Button not visible:**
|
|
||||||
- Make sure you're logged in as editor or admin
|
|
||||||
- Viewer role cannot upload reports
|
|
||||||
|
|
||||||
**Upload fails:**
|
|
||||||
- Check file is .xlsx format (not .xls or .csv)
|
|
||||||
- Ensure file has "Vulnerabilities" sheet with "CVE ID" column
|
|
||||||
- Check file size is under 10MB
|
|
||||||
|
|
||||||
**Processing timeout:**
|
|
||||||
- Large files (10,000+ rows) may timeout
|
|
||||||
- Try reducing file size or increase timeout in `excelProcessor.js`
|
|
||||||
|
|
||||||
## Testing Checklist
|
|
||||||
|
|
||||||
- [x] Backend starts without errors
|
|
||||||
- [x] Frontend compiles successfully
|
|
||||||
- [x] Database migration completed
|
|
||||||
- [x] Python dependencies installed
|
|
||||||
- [ ] Upload .xlsx file (manual test in browser)
|
|
||||||
- [ ] Verify processed file has split CVEs (manual test)
|
|
||||||
- [ ] Download original and processed files (manual test)
|
|
||||||
- [ ] Verify current report marked with star (manual test)
|
|
||||||
- [ ] Test as viewer - button should be hidden (manual test)
|
|
||||||
|
|
||||||
## Future Enhancements
|
|
||||||
|
|
||||||
Possible improvements:
|
|
||||||
- Progress bar during Python processing
|
|
||||||
- Email notifications when processing completes
|
|
||||||
- Scheduled automatic uploads
|
|
||||||
- Report comparison (diff between weeks)
|
|
||||||
- Export to other formats (CSV, JSON)
|
|
||||||
- Bulk delete old reports
|
|
||||||
- Report validation before upload
|
|
||||||
|
|
||||||
## Support
|
|
||||||
|
|
||||||
For issues or questions:
|
|
||||||
1. Check the troubleshooting section above
|
|
||||||
2. Review audit logs for error details
|
|
||||||
3. Check browser console for frontend errors
|
|
||||||
4. Review backend server logs for API errors
|
|
||||||
@@ -251,14 +251,14 @@
|
|||||||
"updated": 1,
|
"updated": 1,
|
||||||
"link": null,
|
"link": null,
|
||||||
"locked": false,
|
"locked": false,
|
||||||
"text": "Backend API (Express.js)\nPort: 3001\n\nRoutes:\n• /api/auth - Authentication (login/logout)\n• /api/users - User management\n• /api/cves - CVE operations\n• /api/documents - Document upload/download\n• /api/audit-log - Audit logging\n• /api/nvd-lookup - NVD integration\n• /api/weekly-reports - Weekly reports",
|
"text": "Backend API (Express.js)\nPort: 3001\n\nRoutes:\n• /api/auth - Authentication (login/logout)\n• /api/users - User management\n• /api/cves - CVE operations\n• /api/documents - Document upload/download\n• /api/audit-log - Audit logging\n• /api/nvd-lookup - NVD integration",
|
||||||
"fontSize": 14,
|
"fontSize": 14,
|
||||||
"fontFamily": 1,
|
"fontFamily": 1,
|
||||||
"textAlign": "left",
|
"textAlign": "left",
|
||||||
"verticalAlign": "middle",
|
"verticalAlign": "middle",
|
||||||
"baseline": 163,
|
"baseline": 163,
|
||||||
"containerId": "backend-box",
|
"containerId": "backend-box",
|
||||||
"originalText": "Backend API (Express.js)\nPort: 3001\n\nRoutes:\n• /api/auth - Authentication (login/logout)\n• /api/users - User management\n• /api/cves - CVE operations\n• /api/documents - Document upload/download\n• /api/audit-log - Audit logging\n• /api/nvd-lookup - NVD integration\n• /api/weekly-reports - Weekly reports"
|
"originalText": "Backend API (Express.js)\nPort: 3001\n\nRoutes:\n• /api/auth - Authentication (login/logout)\n• /api/users - User management\n• /api/cves - CVE operations\n• /api/documents - Document upload/download\n• /api/audit-log - Audit logging\n• /api/nvd-lookup - NVD integration"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "db-box",
|
"id": "db-box",
|
||||||
@@ -820,14 +820,14 @@
|
|||||||
"updated": 1,
|
"updated": 1,
|
||||||
"link": null,
|
"link": null,
|
||||||
"locked": false,
|
"locked": false,
|
||||||
"text": "Key Features:\n• Quick CVE status check\n• Multi-vendor support\n• Document management\n• Compliance tracking\n• Search & filter\n• Weekly report uploads\n• Audit logging",
|
"text": "Key Features:\n• Quick CVE status check\n• Multi-vendor support\n• Document management\n• Compliance tracking\n• Search & filter\n• Audit logging",
|
||||||
"fontSize": 12,
|
"fontSize": 12,
|
||||||
"fontFamily": 1,
|
"fontFamily": 1,
|
||||||
"textAlign": "left",
|
"textAlign": "left",
|
||||||
"verticalAlign": "top",
|
"verticalAlign": "top",
|
||||||
"baseline": 113,
|
"baseline": 113,
|
||||||
"containerId": null,
|
"containerId": null,
|
||||||
"originalText": "Key Features:\n• Quick CVE status check\n• Multi-vendor support\n• Document management\n• Compliance tracking\n• Search & filter\n• Weekly report uploads\n• Audit logging"
|
"originalText": "Key Features:\n• Quick CVE status check\n• Multi-vendor support\n• Document management\n• Compliance tracking\n• Search & filter\n• Audit logging"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"appState": {
|
"appState": {
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ db.serialize(() => {
|
|||||||
finding_id TEXT NOT NULL,
|
finding_id TEXT NOT NULL,
|
||||||
finding_title TEXT,
|
finding_title TEXT,
|
||||||
cves_json TEXT,
|
cves_json TEXT,
|
||||||
|
ip_address TEXT,
|
||||||
vendor TEXT NOT NULL,
|
vendor TEXT NOT NULL,
|
||||||
workflow_type TEXT NOT NULL CHECK(workflow_type IN ('FP', 'Archer', 'CARD')),
|
workflow_type TEXT NOT NULL CHECK(workflow_type IN ('FP', 'Archer', 'CARD')),
|
||||||
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending', 'complete')),
|
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending', 'complete')),
|
||||||
|
|||||||
108
backend/migrations/add_compliance_tables.js
Normal file
108
backend/migrations/add_compliance_tables.js
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
// Migration: Add compliance_uploads, compliance_items, compliance_notes tables
|
||||||
|
const sqlite3 = require('sqlite3').verbose();
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const dbPath = path.join(__dirname, '..', 'cve_database.db');
|
||||||
|
const db = new sqlite3.Database(dbPath);
|
||||||
|
|
||||||
|
console.log('Starting add_compliance_tables migration...');
|
||||||
|
|
||||||
|
db.serialize(() => {
|
||||||
|
// Each xlsx upload — one row per file ingested
|
||||||
|
db.run(`
|
||||||
|
CREATE TABLE IF NOT EXISTS compliance_uploads (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
filename TEXT NOT NULL,
|
||||||
|
report_date TEXT,
|
||||||
|
uploaded_by INTEGER,
|
||||||
|
uploaded_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
new_count INTEGER DEFAULT 0,
|
||||||
|
resolved_count INTEGER DEFAULT 0,
|
||||||
|
recurring_count INTEGER DEFAULT 0,
|
||||||
|
FOREIGN KEY (uploaded_by) REFERENCES users(id) ON DELETE SET NULL
|
||||||
|
)
|
||||||
|
`, (err) => {
|
||||||
|
if (err) console.error('Error creating compliance_uploads:', err);
|
||||||
|
else console.log('✓ compliance_uploads created');
|
||||||
|
});
|
||||||
|
|
||||||
|
// One row per non-compliant asset per metric per upload.
|
||||||
|
// hostname + metric_id is the stable identity key used to link history and notes.
|
||||||
|
db.run(`
|
||||||
|
CREATE TABLE IF NOT EXISTS compliance_items (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
upload_id INTEGER NOT NULL,
|
||||||
|
hostname TEXT NOT NULL,
|
||||||
|
ip_address TEXT,
|
||||||
|
device_type TEXT,
|
||||||
|
team TEXT,
|
||||||
|
metric_id TEXT NOT NULL,
|
||||||
|
metric_desc TEXT,
|
||||||
|
category TEXT,
|
||||||
|
extra_json TEXT,
|
||||||
|
status TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active', 'resolved')),
|
||||||
|
first_seen_upload_id INTEGER,
|
||||||
|
resolved_upload_id INTEGER,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (upload_id) REFERENCES compliance_uploads(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (first_seen_upload_id) REFERENCES compliance_uploads(id) ON DELETE SET NULL,
|
||||||
|
FOREIGN KEY (resolved_upload_id) REFERENCES compliance_uploads(id) ON DELETE SET NULL
|
||||||
|
)
|
||||||
|
`, (err) => {
|
||||||
|
if (err) console.error('Error creating compliance_items:', err);
|
||||||
|
else console.log('✓ compliance_items created');
|
||||||
|
});
|
||||||
|
|
||||||
|
db.run(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_compliance_items_upload
|
||||||
|
ON compliance_items(upload_id)
|
||||||
|
`, (err) => {
|
||||||
|
if (err) console.error('Error creating upload index:', err);
|
||||||
|
else console.log('✓ idx_compliance_items_upload created');
|
||||||
|
});
|
||||||
|
|
||||||
|
db.run(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_compliance_items_identity
|
||||||
|
ON compliance_items(hostname, metric_id)
|
||||||
|
`, (err) => {
|
||||||
|
if (err) console.error('Error creating identity index:', err);
|
||||||
|
else console.log('✓ idx_compliance_items_identity created');
|
||||||
|
});
|
||||||
|
|
||||||
|
db.run(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_compliance_items_team_status
|
||||||
|
ON compliance_items(team, status)
|
||||||
|
`, (err) => {
|
||||||
|
if (err) console.error('Error creating team/status index:', err);
|
||||||
|
else console.log('✓ idx_compliance_items_team_status created');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Notes keyed on (hostname, metric_id) — persists across uploads.
|
||||||
|
// Each note is its own row so history is preserved.
|
||||||
|
db.run(`
|
||||||
|
CREATE TABLE IF NOT EXISTS compliance_notes (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
hostname TEXT NOT NULL,
|
||||||
|
metric_id TEXT NOT NULL,
|
||||||
|
note TEXT NOT NULL,
|
||||||
|
created_by INTEGER,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL
|
||||||
|
)
|
||||||
|
`, (err) => {
|
||||||
|
if (err) console.error('Error creating compliance_notes:', err);
|
||||||
|
else console.log('✓ compliance_notes created');
|
||||||
|
});
|
||||||
|
|
||||||
|
db.run(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_compliance_notes_identity
|
||||||
|
ON compliance_notes(hostname, metric_id)
|
||||||
|
`, (err) => {
|
||||||
|
if (err) console.error('Error creating notes identity index:', err);
|
||||||
|
else console.log('✓ idx_compliance_notes_identity created');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
db.close(() => {
|
||||||
|
console.log('Migration complete!');
|
||||||
|
});
|
||||||
41
backend/migrations/add_ivanti_counts_history_table.js
Normal file
41
backend/migrations/add_ivanti_counts_history_table.js
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
// Migration: Add ivanti_counts_history table
|
||||||
|
//
|
||||||
|
// Stores a snapshot of open/closed Ivanti finding counts on every sync.
|
||||||
|
// Unlike ivanti_counts_cache (single-row, always overwritten), this table
|
||||||
|
// accumulates all snapshots so time-series charts can be built from it.
|
||||||
|
//
|
||||||
|
// The GET /api/ivanti/findings/counts/history endpoint aggregates these rows
|
||||||
|
// to the last snapshot per calendar day using a ROW_NUMBER window function.
|
||||||
|
//
|
||||||
|
// NOTE: This table is also created automatically at server startup via
|
||||||
|
// CREATE TABLE IF NOT EXISTS in initTables() (ivantiFindings.js).
|
||||||
|
// This script is provided for manual setup on fresh installs and for
|
||||||
|
// documentation consistency with other migration files.
|
||||||
|
//
|
||||||
|
// Usage: node backend/migrations/add_ivanti_counts_history_table.js
|
||||||
|
|
||||||
|
const sqlite3 = require('sqlite3').verbose();
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const dbPath = path.join(__dirname, '..', 'cve_database.db');
|
||||||
|
const db = new sqlite3.Database(dbPath);
|
||||||
|
|
||||||
|
console.log('Starting ivanti_counts_history migration...');
|
||||||
|
|
||||||
|
db.serialize(() => {
|
||||||
|
db.run(`
|
||||||
|
CREATE TABLE IF NOT EXISTS ivanti_counts_history (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
open_count INTEGER NOT NULL,
|
||||||
|
closed_count INTEGER NOT NULL,
|
||||||
|
recorded_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
`, (err) => {
|
||||||
|
if (err) console.error('Error creating ivanti_counts_history table:', err);
|
||||||
|
else console.log('✓ ivanti_counts_history table created (or already exists)');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
db.close(() => {
|
||||||
|
console.log('Migration complete.');
|
||||||
|
});
|
||||||
@@ -217,6 +217,25 @@ function createArcherTicketsRouter(db) {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// GET /status-trend — ticket counts grouped by creation date + status
|
||||||
|
// Used for time-based Archer pipeline chart on the Compliance page.
|
||||||
|
router.get('/status-trend', requireAuth(db), (req, res) => {
|
||||||
|
db.all(
|
||||||
|
`SELECT DATE(created_at) AS date, status, COUNT(*) AS count
|
||||||
|
FROM archer_tickets
|
||||||
|
GROUP BY DATE(created_at), status
|
||||||
|
ORDER BY date ASC`,
|
||||||
|
[],
|
||||||
|
(err, rows) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('Error fetching Archer status trend:', err);
|
||||||
|
return res.status(500).json({ error: 'Internal server error.' });
|
||||||
|
}
|
||||||
|
res.json({ statusTrend: rows });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
return router;
|
return router;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
712
backend/routes/compliance.js
Normal file
712
backend/routes/compliance.js
Normal file
@@ -0,0 +1,712 @@
|
|||||||
|
// Compliance Routes — AEO metric tracking
|
||||||
|
// Handles xlsx upload/parse, non-compliant item history, and notes.
|
||||||
|
//
|
||||||
|
// Endpoints:
|
||||||
|
// POST /preview — parse xlsx, compute diff vs DB, return summary (no DB write)
|
||||||
|
// POST /commit — commit a previewed upload to DB
|
||||||
|
// GET /uploads — list all uploads
|
||||||
|
// GET /summary — metric health cards for a team (from latest upload)
|
||||||
|
// GET /items — non-compliant devices grouped by hostname (?team=X&status=active)
|
||||||
|
// GET /items/:hostname — detail panel: all metrics + notes + upload history for a device
|
||||||
|
// POST /notes — add a note to a (hostname, metric_id) pair
|
||||||
|
// GET /notes/:hostname/:metricId — notes for a specific device+metric
|
||||||
|
|
||||||
|
const express = require('express');
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
const { spawn } = require('child_process');
|
||||||
|
|
||||||
|
const PARSER_SCRIPT = path.join(__dirname, '../scripts/parse_compliance_xlsx.py');
|
||||||
|
const PYTHON_BIN = process.env.PYTHON_BIN || 'python3';
|
||||||
|
const TEMP_DIR = path.join(process.cwd(), 'uploads', 'temp');
|
||||||
|
const ALLOWED_TEAMS = new Set(['STEAM', 'ACCESS-ENG', 'ACCESS-OPS', 'INTELDEV']);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// DB helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function dbRun(db, sql, params = []) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
db.run(sql, params, function (err) {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve({ lastID: this.lastID, changes: this.changes });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function dbGet(db, sql, params = []) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
db.get(sql, params, (err, row) => { if (err) reject(err); else resolve(row || null); });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function dbAll(db, sql, params = []) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
db.all(sql, params, (err, rows) => { if (err) reject(err); else resolve(rows || []); });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Run Python parser, return parsed object
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function parseXlsx(filePath) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const py = spawn(PYTHON_BIN, [PARSER_SCRIPT, filePath]);
|
||||||
|
let out = '';
|
||||||
|
let err = '';
|
||||||
|
py.stdout.on('data', d => { out += d; });
|
||||||
|
py.stderr.on('data', d => { err += d; });
|
||||||
|
py.on('close', code => {
|
||||||
|
if (code !== 0) return reject(new Error(err || `Parser exited with code ${code}`));
|
||||||
|
try { resolve(JSON.parse(out)); }
|
||||||
|
catch (e) { reject(new Error('Parser returned invalid JSON')); }
|
||||||
|
});
|
||||||
|
py.on('error', reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Validate that a temp file path is safely within uploads/temp/
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function isSafeTempPath(filePath) {
|
||||||
|
const resolved = path.resolve(filePath);
|
||||||
|
return resolved.startsWith(TEMP_DIR + path.sep) && path.extname(resolved) === '.json';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Compute diff: new / recurring / resolved
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
async function computeDiff(db, incomingItems) {
|
||||||
|
const activeRows = await dbAll(db,
|
||||||
|
`SELECT hostname, metric_id FROM compliance_items WHERE status = 'active'`
|
||||||
|
);
|
||||||
|
const activeKeys = new Set(activeRows.map(r => `${r.hostname}|||${r.metric_id}`));
|
||||||
|
const newKeys = new Set(incomingItems.map(i => `${i.hostname}|||${i.metric_id}`));
|
||||||
|
|
||||||
|
let newCount = 0, recurringCount = 0, resolvedCount = 0;
|
||||||
|
for (const k of newKeys) { if (activeKeys.has(k)) recurringCount++; else newCount++; }
|
||||||
|
for (const k of activeKeys) { if (!newKeys.has(k)) resolvedCount++; }
|
||||||
|
|
||||||
|
return { newCount, recurringCount, resolvedCount };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Write a parsed upload to the DB (within a transaction)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
async function persistUpload(db, { items, summary, reportDate, filename, userId }) {
|
||||||
|
// Pull current active items before we modify anything
|
||||||
|
const activeRows = await dbAll(db,
|
||||||
|
`SELECT id, hostname, metric_id, seen_count, first_seen_upload_id FROM compliance_items WHERE status = 'active'`
|
||||||
|
);
|
||||||
|
const activeMap = {};
|
||||||
|
activeRows.forEach(r => { activeMap[`${r.hostname}|||${r.metric_id}`] = r; });
|
||||||
|
|
||||||
|
const newKeys = new Set(items.map(i => `${i.hostname}|||${i.metric_id}`));
|
||||||
|
|
||||||
|
await dbRun(db, 'BEGIN TRANSACTION');
|
||||||
|
try {
|
||||||
|
// 1. Insert the upload record
|
||||||
|
const { lastID: uploadId } = await dbRun(db,
|
||||||
|
`INSERT INTO compliance_uploads (filename, report_date, uploaded_by, uploaded_at, summary_json)
|
||||||
|
VALUES (?, ?, ?, datetime('now'), ?)`,
|
||||||
|
[filename, reportDate || null, userId || null, JSON.stringify(summary)]
|
||||||
|
);
|
||||||
|
|
||||||
|
let newCount = 0, recurringCount = 0, resolvedCount = 0;
|
||||||
|
|
||||||
|
// 2. Upsert each incoming non-compliant item
|
||||||
|
for (const item of items) {
|
||||||
|
const key = `${item.hostname}|||${item.metric_id}`;
|
||||||
|
const existing = activeMap[key];
|
||||||
|
const extraStr = JSON.stringify(item.extra_json || {});
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
// Recurring — bump seen_count, refresh snapshot fields
|
||||||
|
await dbRun(db,
|
||||||
|
`UPDATE compliance_items
|
||||||
|
SET upload_id = ?, seen_count = ?, ip_address = ?, device_type = ?, extra_json = ?
|
||||||
|
WHERE id = ?`,
|
||||||
|
[uploadId, existing.seen_count + 1, item.ip_address, item.device_type, extraStr, existing.id]
|
||||||
|
);
|
||||||
|
recurringCount++;
|
||||||
|
} else {
|
||||||
|
// New item (or previously resolved and re-appearing)
|
||||||
|
await dbRun(db,
|
||||||
|
`INSERT INTO compliance_items
|
||||||
|
(upload_id, hostname, ip_address, device_type, team, metric_id, metric_desc,
|
||||||
|
category, extra_json, status, first_seen_upload_id, seen_count)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, 1)`,
|
||||||
|
[uploadId, item.hostname, item.ip_address, item.device_type, item.team,
|
||||||
|
item.metric_id, item.metric_desc, item.category, extraStr, uploadId]
|
||||||
|
);
|
||||||
|
newCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Mark items not present in this upload as resolved
|
||||||
|
for (const [key, row] of Object.entries(activeMap)) {
|
||||||
|
if (!newKeys.has(key)) {
|
||||||
|
await dbRun(db,
|
||||||
|
`UPDATE compliance_items
|
||||||
|
SET status = 'resolved', resolved_upload_id = ?
|
||||||
|
WHERE id = ?`,
|
||||||
|
[uploadId, row.id]
|
||||||
|
);
|
||||||
|
resolvedCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Update upload with final counts
|
||||||
|
await dbRun(db,
|
||||||
|
`UPDATE compliance_uploads
|
||||||
|
SET new_count = ?, resolved_count = ?, recurring_count = ?
|
||||||
|
WHERE id = ?`,
|
||||||
|
[newCount, resolvedCount, recurringCount, uploadId]
|
||||||
|
);
|
||||||
|
|
||||||
|
await dbRun(db, 'COMMIT');
|
||||||
|
return { uploadId, newCount, recurringCount, resolvedCount };
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
await dbRun(db, 'ROLLBACK').catch(() => {});
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Group flat compliance_items rows into per-device objects
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function groupByHostname(rows, noteHostnames) {
|
||||||
|
const deviceMap = {};
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
if (!deviceMap[row.hostname]) {
|
||||||
|
deviceMap[row.hostname] = {
|
||||||
|
hostname: row.hostname,
|
||||||
|
ip_address: row.ip_address || '',
|
||||||
|
device_type: row.device_type || '',
|
||||||
|
team: row.team || '',
|
||||||
|
status: row.status,
|
||||||
|
failing_metrics: [],
|
||||||
|
seen_count: row.seen_count || 1,
|
||||||
|
first_seen: row.first_seen || null,
|
||||||
|
last_seen: row.last_seen || null,
|
||||||
|
resolved_on: row.resolved_on || null,
|
||||||
|
has_notes: noteHostnames.has(row.hostname),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const dev = deviceMap[row.hostname];
|
||||||
|
dev.failing_metrics.push({
|
||||||
|
metric_id: row.metric_id,
|
||||||
|
metric_desc: row.metric_desc || '',
|
||||||
|
category: row.category || '',
|
||||||
|
});
|
||||||
|
// Use the highest seen_count and earliest first_seen across all metrics
|
||||||
|
if ((row.seen_count || 1) > dev.seen_count) dev.seen_count = row.seen_count;
|
||||||
|
if (row.first_seen && (!dev.first_seen || row.first_seen < dev.first_seen))
|
||||||
|
dev.first_seen = row.first_seen;
|
||||||
|
if (row.last_seen && (!dev.last_seen || row.last_seen > dev.last_seen))
|
||||||
|
dev.last_seen = row.last_seen;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.values(deviceMap);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Router factory
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function createComplianceRouter(db, upload, requireAuth, requireRole) {
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// Idempotent column additions — errors mean column already exists, which is fine
|
||||||
|
db.run(`ALTER TABLE compliance_items ADD COLUMN seen_count INTEGER DEFAULT 1`, () => {});
|
||||||
|
db.run(`ALTER TABLE compliance_uploads ADD COLUMN summary_json TEXT`, () => {});
|
||||||
|
|
||||||
|
// All compliance routes require authentication
|
||||||
|
router.use(requireAuth(db));
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// POST /preview
|
||||||
|
// Parse the uploaded xlsx, compute diff, save parsed data to a temp JSON.
|
||||||
|
// Returns diff counts + tempFile path for the commit step.
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
router.post('/preview', requireRole('editor', 'admin'), (req, res) => {
|
||||||
|
upload.single('file')(req, res, async (uploadErr) => {
|
||||||
|
if (uploadErr) {
|
||||||
|
return res.status(400).json({ error: uploadErr.message });
|
||||||
|
}
|
||||||
|
if (!req.file) {
|
||||||
|
return res.status(400).json({ error: 'No file uploaded' });
|
||||||
|
}
|
||||||
|
if (path.extname(req.file.originalname).toLowerCase() !== '.xlsx') {
|
||||||
|
fs.unlink(req.file.path, () => {});
|
||||||
|
return res.status(400).json({ error: 'File must be an .xlsx spreadsheet' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = await parseXlsx(req.file.path);
|
||||||
|
|
||||||
|
if (parsed.error) {
|
||||||
|
fs.unlink(req.file.path, () => {});
|
||||||
|
return res.status(422).json({ error: parsed.error });
|
||||||
|
}
|
||||||
|
|
||||||
|
const diff = await computeDiff(db, parsed.items);
|
||||||
|
|
||||||
|
// Save parsed data to temp JSON — the commit step reads this
|
||||||
|
if (!fs.existsSync(TEMP_DIR)) fs.mkdirSync(TEMP_DIR, { recursive: true });
|
||||||
|
const tempFilename = `compliance_preview_${Date.now()}_${Math.random().toString(36).slice(2)}.json`;
|
||||||
|
const tempFilePath = path.join(TEMP_DIR, tempFilename);
|
||||||
|
|
||||||
|
fs.writeFileSync(tempFilePath, JSON.stringify({
|
||||||
|
items: parsed.items,
|
||||||
|
summary: parsed.summary,
|
||||||
|
report_date: parsed.report_date,
|
||||||
|
filename: req.file.originalname,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Delete the original xlsx from temp (we only need the JSON now)
|
||||||
|
fs.unlink(req.file.path, () => {});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
diff: {
|
||||||
|
new_count: diff.newCount,
|
||||||
|
recurring_count: diff.recurringCount,
|
||||||
|
resolved_count: diff.resolvedCount,
|
||||||
|
},
|
||||||
|
tempFile: tempFilePath,
|
||||||
|
filename: req.file.originalname,
|
||||||
|
report_date: parsed.report_date,
|
||||||
|
total_items: parsed.total,
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
fs.unlink(req.file.path, () => {});
|
||||||
|
console.error('[Compliance] Preview error:', err.message);
|
||||||
|
res.status(500).json({ error: 'Failed to parse file: ' + err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// POST /commit
|
||||||
|
// Commit a previewed upload to the DB.
|
||||||
|
// Body: { tempFile, filename, report_date }
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
router.post('/commit', requireRole('editor', 'admin'), 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' });
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(fs.readFileSync(tempFile, 'utf8'));
|
||||||
|
} catch {
|
||||||
|
return res.status(400).json({ error: 'Could not read preview data — please upload again' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await persistUpload(db, {
|
||||||
|
items: parsed.items,
|
||||||
|
summary: parsed.summary,
|
||||||
|
reportDate: report_date || parsed.report_date,
|
||||||
|
filename: filename || parsed.filename,
|
||||||
|
userId: req.user?.id || null,
|
||||||
|
});
|
||||||
|
|
||||||
|
fs.unlink(tempFile, () => {});
|
||||||
|
|
||||||
|
const upload = await dbGet(db,
|
||||||
|
`SELECT id, filename, report_date, uploaded_at,
|
||||||
|
new_count, resolved_count, recurring_count
|
||||||
|
FROM compliance_uploads WHERE id = ?`,
|
||||||
|
[result.uploadId]
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({ upload });
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Compliance] Commit error:', err.message);
|
||||||
|
res.status(500).json({ error: 'Failed to commit upload: ' + err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// GET /uploads
|
||||||
|
// List all uploads, most recent first.
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
router.get('/uploads', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const rows = await dbAll(db,
|
||||||
|
`SELECT id, filename, report_date, uploaded_at,
|
||||||
|
new_count, resolved_count, recurring_count
|
||||||
|
FROM compliance_uploads
|
||||||
|
ORDER BY id DESC`
|
||||||
|
);
|
||||||
|
res.json({ uploads: rows });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Compliance] GET /uploads error:', err.message);
|
||||||
|
res.status(500).json({ error: 'Database error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// GET /summary?team=STEAM
|
||||||
|
// Return metric health rows for a team from the latest upload's summary_json.
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
router.get('/summary', async (req, res) => {
|
||||||
|
const team = req.query.team;
|
||||||
|
if (team && !ALLOWED_TEAMS.has(team)) {
|
||||||
|
return res.status(400).json({ error: 'Invalid team' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const latestUpload = await dbGet(db,
|
||||||
|
`SELECT id, summary_json, report_date, uploaded_at
|
||||||
|
FROM compliance_uploads ORDER BY id DESC LIMIT 1`
|
||||||
|
);
|
||||||
|
if (!latestUpload || !latestUpload.summary_json) {
|
||||||
|
return res.json({ entries: [], overall_scores: {}, upload: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
let summary;
|
||||||
|
try { summary = JSON.parse(latestUpload.summary_json); }
|
||||||
|
catch { return res.json({ entries: [], overall_scores: {}, upload: null }); }
|
||||||
|
|
||||||
|
let entries = summary.entries || [];
|
||||||
|
if (team) {
|
||||||
|
entries = entries.filter(e => e.team === team);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
entries,
|
||||||
|
overall_scores: summary.overall_scores || {},
|
||||||
|
upload: {
|
||||||
|
id: latestUpload.id,
|
||||||
|
report_date: latestUpload.report_date,
|
||||||
|
uploaded_at: latestUpload.uploaded_at,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Compliance] GET /summary error:', err.message);
|
||||||
|
res.status(500).json({ error: 'Database error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// GET /items?team=STEAM&status=active
|
||||||
|
// Return non-compliant devices grouped by hostname.
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
router.get('/items', async (req, res) => {
|
||||||
|
const { team, status = 'active' } = req.query;
|
||||||
|
|
||||||
|
if (!team) return res.status(400).json({ error: 'team is required' });
|
||||||
|
if (!ALLOWED_TEAMS.has(team)) return res.status(400).json({ error: 'Invalid team' });
|
||||||
|
if (!['active', 'resolved'].includes(status)) return res.status(400).json({ error: 'Invalid status' });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const rows = await dbAll(db,
|
||||||
|
`SELECT
|
||||||
|
ci.hostname, ci.ip_address, ci.device_type, ci.team,
|
||||||
|
ci.metric_id, ci.metric_desc, ci.category,
|
||||||
|
ci.status, ci.seen_count,
|
||||||
|
fu.report_date AS first_seen,
|
||||||
|
lu.report_date AS last_seen,
|
||||||
|
ru.report_date AS resolved_on
|
||||||
|
FROM compliance_items ci
|
||||||
|
LEFT JOIN compliance_uploads fu ON ci.first_seen_upload_id = fu.id
|
||||||
|
LEFT JOIN compliance_uploads lu ON ci.upload_id = lu.id
|
||||||
|
LEFT JOIN compliance_uploads ru ON ci.resolved_upload_id = ru.id
|
||||||
|
WHERE ci.team = ? AND ci.status = ?
|
||||||
|
ORDER BY ci.hostname, ci.metric_id`,
|
||||||
|
[team, status]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Fetch hostnames that have any notes (for the has_notes indicator)
|
||||||
|
const noteRows = await dbAll(db,
|
||||||
|
`SELECT DISTINCT hostname FROM compliance_notes`
|
||||||
|
);
|
||||||
|
const noteHostnames = new Set(noteRows.map(r => r.hostname));
|
||||||
|
|
||||||
|
const devices = groupByHostname(rows, noteHostnames);
|
||||||
|
|
||||||
|
res.json({ devices, team, status });
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Compliance] GET /items error:', err.message);
|
||||||
|
res.status(500).json({ error: 'Database error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// GET /items/:hostname
|
||||||
|
// Detail panel: all metric rows for this hostname + notes + upload history.
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
router.get('/items/:hostname', async (req, res) => {
|
||||||
|
const hostname = req.params.hostname;
|
||||||
|
if (!hostname || hostname.length > 300) {
|
||||||
|
return res.status(400).json({ error: 'Invalid hostname' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// All metric rows for this hostname
|
||||||
|
const metricRows = await dbAll(db,
|
||||||
|
`SELECT
|
||||||
|
ci.metric_id, ci.metric_desc, ci.category, ci.status,
|
||||||
|
ci.ip_address, ci.device_type, ci.team,
|
||||||
|
ci.seen_count, ci.extra_json,
|
||||||
|
fu.report_date AS first_seen,
|
||||||
|
fu.uploaded_at AS first_seen_at,
|
||||||
|
lu.report_date AS last_seen,
|
||||||
|
lu.uploaded_at AS last_seen_at,
|
||||||
|
ru.report_date AS resolved_on
|
||||||
|
FROM compliance_items ci
|
||||||
|
LEFT JOIN compliance_uploads fu ON ci.first_seen_upload_id = fu.id
|
||||||
|
LEFT JOIN compliance_uploads lu ON ci.upload_id = lu.id
|
||||||
|
LEFT JOIN compliance_uploads ru ON ci.resolved_upload_id = ru.id
|
||||||
|
WHERE ci.hostname = ?
|
||||||
|
ORDER BY ci.status DESC, ci.metric_id`,
|
||||||
|
[hostname]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (metricRows.length === 0) {
|
||||||
|
return res.status(404).json({ error: 'Device not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse extra_json on each row
|
||||||
|
const metrics = metricRows.map(r => ({
|
||||||
|
...r,
|
||||||
|
extra: (() => { try { return JSON.parse(r.extra_json || '{}'); } catch { return {}; } })(),
|
||||||
|
extra_json: undefined,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Notes (all metrics for this hostname, sorted newest first)
|
||||||
|
const notes = await dbAll(db,
|
||||||
|
`SELECT cn.id, cn.metric_id, cn.note, cn.created_at,
|
||||||
|
u.username AS created_by
|
||||||
|
FROM compliance_notes cn
|
||||||
|
LEFT JOIN users u ON cn.created_by = u.id
|
||||||
|
WHERE cn.hostname = ?
|
||||||
|
ORDER BY cn.created_at DESC`,
|
||||||
|
[hostname]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Derive device identity from the first active row, else any row
|
||||||
|
const identity = metricRows.find(r => r.status === 'active') || metricRows[0];
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
hostname,
|
||||||
|
ip_address: identity.ip_address || '',
|
||||||
|
device_type: identity.device_type || '',
|
||||||
|
team: identity.team || '',
|
||||||
|
metrics,
|
||||||
|
notes,
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Compliance] GET /items/:hostname error:', err.message);
|
||||||
|
res.status(500).json({ error: 'Database error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// POST /notes
|
||||||
|
// Add a note to a (hostname, metric_id) pair.
|
||||||
|
// Body: { hostname, metric_id, note }
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
router.post('/notes', async (req, res) => {
|
||||||
|
const { hostname, metric_id, note } = req.body;
|
||||||
|
|
||||||
|
if (!hostname || typeof hostname !== 'string' || hostname.length > 300) {
|
||||||
|
return res.status(400).json({ error: 'Invalid hostname' });
|
||||||
|
}
|
||||||
|
if (!metric_id || typeof metric_id !== 'string' || metric_id.length > 50) {
|
||||||
|
return res.status(400).json({ error: 'Invalid metric_id' });
|
||||||
|
}
|
||||||
|
const noteText = String(note || '').trim().slice(0, 1000);
|
||||||
|
if (!noteText) {
|
||||||
|
return res.status(400).json({ error: 'Note cannot be empty' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { lastID } = await dbRun(db,
|
||||||
|
`INSERT INTO compliance_notes (hostname, metric_id, note, created_by, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, datetime('now'))`,
|
||||||
|
[hostname, metric_id, noteText, req.user?.id || null]
|
||||||
|
);
|
||||||
|
|
||||||
|
const created = await dbGet(db,
|
||||||
|
`SELECT cn.id, cn.hostname, cn.metric_id, cn.note, cn.created_at,
|
||||||
|
u.username AS created_by
|
||||||
|
FROM compliance_notes cn
|
||||||
|
LEFT JOIN users u ON cn.created_by = u.id
|
||||||
|
WHERE cn.id = ?`,
|
||||||
|
[lastID]
|
||||||
|
);
|
||||||
|
|
||||||
|
res.status(201).json(created);
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Compliance] POST /notes error:', err.message);
|
||||||
|
res.status(500).json({ error: 'Failed to save note' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// GET /notes/:hostname/:metricId
|
||||||
|
// Return all notes for a (hostname, metric_id) pair.
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
router.get('/notes/:hostname/:metricId', async (req, res) => {
|
||||||
|
const { hostname, metricId } = req.params;
|
||||||
|
|
||||||
|
if (!hostname || hostname.length > 300) return res.status(400).json({ error: 'Invalid hostname' });
|
||||||
|
if (!metricId || metricId.length > 50) return res.status(400).json({ error: 'Invalid metricId' });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const notes = await dbAll(db,
|
||||||
|
`SELECT cn.id, cn.note, cn.created_at, u.username AS created_by
|
||||||
|
FROM compliance_notes cn
|
||||||
|
LEFT JOIN users u ON cn.created_by = u.id
|
||||||
|
WHERE cn.hostname = ? AND cn.metric_id = ?
|
||||||
|
ORDER BY cn.created_at DESC`,
|
||||||
|
[hostname, metricId]
|
||||||
|
);
|
||||||
|
res.json({ notes });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Compliance] GET /notes error:', err.message);
|
||||||
|
res.status(500).json({ error: 'Database error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// GET /trends
|
||||||
|
// Per-upload active totals + per-team counts for time-series charts.
|
||||||
|
// Returns rows ordered ascending by report_date.
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
router.get('/trends', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const uploads = await dbAll(db,
|
||||||
|
`SELECT id, report_date,
|
||||||
|
COALESCE(new_count, 0) AS new_count,
|
||||||
|
COALESCE(recurring_count, 0) AS recurring_count,
|
||||||
|
COALESCE(resolved_count, 0) AS resolved_count,
|
||||||
|
COALESCE(new_count, 0) + COALESCE(recurring_count, 0) AS total_active
|
||||||
|
FROM compliance_uploads
|
||||||
|
ORDER BY report_date ASC`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (uploads.length === 0) return res.json({ trends: [] });
|
||||||
|
|
||||||
|
// Per-team active counts — items whose upload_id matches the upload
|
||||||
|
// (recurring items have upload_id bumped each cycle, so this is accurate)
|
||||||
|
const teamRows = await dbAll(db,
|
||||||
|
`SELECT ci.upload_id, ci.team, COUNT(ci.id) AS count
|
||||||
|
FROM compliance_items ci
|
||||||
|
WHERE ci.team IS NOT NULL
|
||||||
|
GROUP BY ci.upload_id, ci.team`
|
||||||
|
);
|
||||||
|
|
||||||
|
const teamMap = {};
|
||||||
|
teamRows.forEach(r => {
|
||||||
|
if (!teamMap[r.upload_id]) teamMap[r.upload_id] = {};
|
||||||
|
teamMap[r.upload_id][r.team] = r.count;
|
||||||
|
});
|
||||||
|
|
||||||
|
const trends = uploads.map(u => ({
|
||||||
|
report_date: u.report_date,
|
||||||
|
new_count: u.new_count,
|
||||||
|
recurring_count: u.recurring_count,
|
||||||
|
resolved_count: u.resolved_count,
|
||||||
|
total_active: u.total_active,
|
||||||
|
STEAM: teamMap[u.id]?.STEAM || 0,
|
||||||
|
'ACCESS-ENG': teamMap[u.id]?.['ACCESS-ENG'] || 0,
|
||||||
|
'ACCESS-OPS': teamMap[u.id]?.['ACCESS-OPS'] || 0,
|
||||||
|
INTELDEV: teamMap[u.id]?.INTELDEV || 0,
|
||||||
|
}));
|
||||||
|
|
||||||
|
res.json({ trends });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Compliance] GET /trends error:', err.message);
|
||||||
|
res.status(500).json({ error: 'Database error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// GET /mttr
|
||||||
|
// Mean time to resolution (calendar days) per team, for resolved items.
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
router.get('/mttr', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const rows = await dbAll(db,
|
||||||
|
`SELECT
|
||||||
|
ci.team,
|
||||||
|
ROUND(AVG(JULIANDAY(ru.report_date) - JULIANDAY(fu.report_date)), 1) AS avg_days,
|
||||||
|
COUNT(*) AS resolved_count
|
||||||
|
FROM compliance_items ci
|
||||||
|
JOIN compliance_uploads fu ON ci.first_seen_upload_id = fu.id
|
||||||
|
JOIN compliance_uploads ru ON ci.resolved_upload_id = ru.id
|
||||||
|
WHERE ci.resolved_upload_id IS NOT NULL
|
||||||
|
AND fu.report_date IS NOT NULL
|
||||||
|
AND ru.report_date IS NOT NULL
|
||||||
|
GROUP BY ci.team
|
||||||
|
ORDER BY avg_days DESC`
|
||||||
|
);
|
||||||
|
res.json({ mttr: rows });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Compliance] GET /mttr error:', err.message);
|
||||||
|
res.status(500).json({ error: 'Database error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// GET /top-recurring
|
||||||
|
// Active findings grouped by team + metric_id, sorted by seen_count desc.
|
||||||
|
// Identifies chronic compliance gaps that keep reappearing.
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
router.get('/top-recurring', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const rows = await dbAll(db,
|
||||||
|
`SELECT team, metric_id, metric_desc, seen_count, COUNT(*) AS host_count
|
||||||
|
FROM compliance_items
|
||||||
|
WHERE status = 'active'
|
||||||
|
GROUP BY team, metric_id
|
||||||
|
ORDER BY seen_count DESC, host_count DESC
|
||||||
|
LIMIT 20`
|
||||||
|
);
|
||||||
|
res.json({ items: rows });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Compliance] GET /top-recurring error:', err.message);
|
||||||
|
res.status(500).json({ error: 'Database error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// GET /category-trend
|
||||||
|
// Active item counts per category per upload, for stacked area chart.
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
router.get('/category-trend', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const rows = await dbAll(db,
|
||||||
|
`SELECT cu.report_date, COALESCE(ci.category, 'Unknown') AS category, COUNT(ci.id) AS count
|
||||||
|
FROM compliance_uploads cu
|
||||||
|
JOIN compliance_items ci ON ci.upload_id = cu.id
|
||||||
|
GROUP BY cu.id, category
|
||||||
|
ORDER BY cu.report_date ASC`
|
||||||
|
);
|
||||||
|
res.json({ categoryTrend: rows });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Compliance] GET /category-trend error:', err.message);
|
||||||
|
res.status(500).json({ error: 'Database error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = createComplianceRouter;
|
||||||
@@ -175,6 +175,15 @@ function initTables(db) {
|
|||||||
db.run(`
|
db.run(`
|
||||||
CREATE INDEX IF NOT EXISTS idx_finding_overrides_finding_id
|
CREATE INDEX IF NOT EXISTS idx_finding_overrides_finding_id
|
||||||
ON ivanti_finding_overrides(finding_id)
|
ON ivanti_finding_overrides(finding_id)
|
||||||
|
`, (err) => { if (err) return reject(err); });
|
||||||
|
|
||||||
|
db.run(`
|
||||||
|
CREATE TABLE IF NOT EXISTS ivanti_counts_history (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
open_count INTEGER NOT NULL,
|
||||||
|
closed_count INTEGER NOT NULL,
|
||||||
|
recorded_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
`, (err) => {
|
`, (err) => {
|
||||||
if (err) reject(err);
|
if (err) reject(err);
|
||||||
else resolve();
|
else resolve();
|
||||||
@@ -271,6 +280,14 @@ async function syncClosedCount(db, openCount, apiKey, clientId, skipTls) {
|
|||||||
`UPDATE ivanti_counts_cache SET open_count=?, closed_count=?, synced_at=datetime('now') WHERE id=1`,
|
`UPDATE ivanti_counts_cache SET open_count=?, closed_count=?, synced_at=datetime('now') WHERE id=1`,
|
||||||
[openCount, closedCount]
|
[openCount, closedCount]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Append a snapshot to history — every sync is stored; the history
|
||||||
|
// endpoint aggregates to last-per-day at query time (Option B).
|
||||||
|
await dbRun(db,
|
||||||
|
`INSERT INTO ivanti_counts_history (open_count, closed_count) VALUES (?, ?)`,
|
||||||
|
[openCount, closedCount]
|
||||||
|
);
|
||||||
|
|
||||||
console.log(`[Ivanti Findings] Counts updated — open: ${openCount}, closed: ${closedCount}`);
|
console.log(`[Ivanti Findings] Counts updated — open: ${openCount}, closed: ${closedCount}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[Ivanti Findings] Failed to fetch closed count:', err.message);
|
console.error('[Ivanti Findings] Failed to fetch closed count:', err.message);
|
||||||
@@ -576,6 +593,33 @@ function createIvantiFindingsRouter(db, requireAuth) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// GET /counts/history — last snapshot per day, ascending, for the trend chart.
|
||||||
|
// Uses a window function (ROW_NUMBER) to pick the final sync of each calendar day.
|
||||||
|
router.get('/counts/history', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const rows = await new Promise((resolve, reject) => {
|
||||||
|
db.all(
|
||||||
|
`SELECT date, open_count, closed_count FROM (
|
||||||
|
SELECT DATE(recorded_at) AS date,
|
||||||
|
open_count, closed_count,
|
||||||
|
ROW_NUMBER() OVER (
|
||||||
|
PARTITION BY DATE(recorded_at)
|
||||||
|
ORDER BY recorded_at DESC
|
||||||
|
) AS rn
|
||||||
|
FROM ivanti_counts_history
|
||||||
|
) WHERE rn = 1
|
||||||
|
ORDER BY date ASC`,
|
||||||
|
[],
|
||||||
|
(err, rows) => { if (err) reject(err); else resolve(rows || []); }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
res.json({ history: rows });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Ivanti Findings] GET /counts/history error:', err.message);
|
||||||
|
res.status(500).json({ error: 'Database error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// GET /fp-workflow-counts — FP finding + unique workflow counts (open + closed)
|
// GET /fp-workflow-counts — FP finding + unique workflow counts (open + closed)
|
||||||
router.get('/fp-workflow-counts', async (req, res) => {
|
router.get('/fp-workflow-counts', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
212
backend/scripts/parse_compliance_xlsx.py
Normal file
212
backend/scripts/parse_compliance_xlsx.py
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Parse NTS_AEO compliance xlsx file and write JSON to stdout.
|
||||||
|
Usage: python3 parse_compliance_xlsx.py <path_to_xlsx>
|
||||||
|
|
||||||
|
Output:
|
||||||
|
{
|
||||||
|
"items": [...], # non-compliant asset rows
|
||||||
|
"summary": { ... }, # metric health data from Summary sheet
|
||||||
|
"report_date": "YYYY-MM-DD" | null,
|
||||||
|
"total": int
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import pandas as pd
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
METRIC_CATEGORIES = {
|
||||||
|
'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.3.4': 'Endpoint Protection',
|
||||||
|
'5.5.2': 'End-of-Life OS',
|
||||||
|
'5.5.4i': 'Vulnerability Management',
|
||||||
|
'5.5.5': 'Decommissioned Assets',
|
||||||
|
'5.8.1': 'Application Security',
|
||||||
|
'7.1.1': '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',
|
||||||
|
}
|
||||||
|
|
||||||
|
# Columns that go into the main item fields — everything else becomes extra_json
|
||||||
|
CORE_COLS = {
|
||||||
|
'Preferred - Hostname', 'GRANITE - IPv4_Address', 'GRANITE - Type',
|
||||||
|
'Team', 'Compliant', 'Source_Network', 'Vertical',
|
||||||
|
'GRANITE - Equip_Inst_ID', 'GRANITE - RESPONSIBLE_TEAM',
|
||||||
|
}
|
||||||
|
|
||||||
|
SKIP_SHEETS = {'Summary', 'CMDB_9box'}
|
||||||
|
|
||||||
|
|
||||||
|
def safe_str(val):
|
||||||
|
s = str(val).strip()
|
||||||
|
return '' if s == 'nan' else s
|
||||||
|
|
||||||
|
|
||||||
|
def parse_summary(xl):
|
||||||
|
"""Return { entries: [...], overall_scores: { customer_network, vertical } }"""
|
||||||
|
df_raw = pd.read_excel(xl, sheet_name='Summary', header=None)
|
||||||
|
|
||||||
|
overall_scores = {
|
||||||
|
'customer_network': float(df_raw.iloc[0, 4]) if pd.notna(df_raw.iloc[0, 4]) else None,
|
||||||
|
'vertical': float(df_raw.iloc[1, 4]) if pd.notna(df_raw.iloc[1, 4]) else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
df = pd.read_excel(xl, sheet_name='Summary', header=3)
|
||||||
|
# Flatten any newlines in column names
|
||||||
|
df.columns = [str(c).replace('\n', ' ').strip() for c in df.columns]
|
||||||
|
|
||||||
|
# Locate the sub-vertical/team column robustly
|
||||||
|
team_col = next((c for c in df.columns if 'Sub-Vertical' in c or 'Purchase Group' in c), None)
|
||||||
|
|
||||||
|
entries = []
|
||||||
|
for _, row in df.iterrows():
|
||||||
|
metric_id = safe_str(row.get('Metric', ''))
|
||||||
|
if not metric_id or metric_id in ('Metric',):
|
||||||
|
continue
|
||||||
|
|
||||||
|
team = safe_str(row.get(team_col, '')) if team_col else ''
|
||||||
|
|
||||||
|
try:
|
||||||
|
non_compliant = int(row.get('Non-Compliant', 0) or 0)
|
||||||
|
compliant = int(row.get('Compliant', 0) or 0)
|
||||||
|
total = int(row.get('Total', 0) or 0)
|
||||||
|
compliance_pct = float(row.get('Current Compliance', 0) or 0)
|
||||||
|
target = float(row.get('Metric Target', 0) or 0)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
continue
|
||||||
|
|
||||||
|
entries.append({
|
||||||
|
'metric_id': metric_id,
|
||||||
|
'team': team,
|
||||||
|
'priority': safe_str(row.get('Priority / Non-Priority / IR', '')),
|
||||||
|
'non_compliant': non_compliant,
|
||||||
|
'compliant': compliant,
|
||||||
|
'total': total,
|
||||||
|
'compliance_pct': compliance_pct,
|
||||||
|
'target': target,
|
||||||
|
'status': safe_str(row.get('Status', '')),
|
||||||
|
'description': safe_str(row.get('Metric Description', '')),
|
||||||
|
'category': METRIC_CATEGORIES.get(metric_id, 'Other'),
|
||||||
|
})
|
||||||
|
|
||||||
|
return {'entries': entries, 'overall_scores': overall_scores}
|
||||||
|
|
||||||
|
|
||||||
|
def parse_sheet(xl, sheet_name, summary_entries):
|
||||||
|
"""Return list of non-compliant item dicts for a detail sheet."""
|
||||||
|
try:
|
||||||
|
df = pd.read_excel(xl, sheet_name=sheet_name, header=0)
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
if df.empty:
|
||||||
|
return []
|
||||||
|
|
||||||
|
df.columns = [str(c).strip() for c in df.columns]
|
||||||
|
|
||||||
|
# Filter to non-compliant rows when the Compliant column exists
|
||||||
|
if 'Compliant' in df.columns:
|
||||||
|
df = df[df['Compliant'] == False]
|
||||||
|
|
||||||
|
if df.empty:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Look up description from summary
|
||||||
|
metric_desc = ''
|
||||||
|
for e in summary_entries:
|
||||||
|
if e['metric_id'] == sheet_name and e['description']:
|
||||||
|
metric_desc = e['description']
|
||||||
|
break
|
||||||
|
|
||||||
|
category = METRIC_CATEGORIES.get(sheet_name, 'Other')
|
||||||
|
|
||||||
|
items = []
|
||||||
|
for _, row in df.iterrows():
|
||||||
|
hostname = safe_str(row.get('Preferred - Hostname', ''))
|
||||||
|
if not hostname:
|
||||||
|
continue
|
||||||
|
|
||||||
|
ip = safe_str(row.get('GRANITE - IPv4_Address', ''))
|
||||||
|
device_type = safe_str(row.get('GRANITE - Type', ''))
|
||||||
|
team = safe_str(row.get('Team', ''))
|
||||||
|
|
||||||
|
# Everything non-core goes into extra_json
|
||||||
|
extra = {}
|
||||||
|
for col in df.columns:
|
||||||
|
if col in CORE_COLS:
|
||||||
|
continue
|
||||||
|
val = row.get(col)
|
||||||
|
if pd.isna(val) if not isinstance(val, str) else False:
|
||||||
|
continue
|
||||||
|
s = safe_str(val)
|
||||||
|
if s:
|
||||||
|
extra[col] = val.isoformat() if hasattr(val, 'isoformat') else s
|
||||||
|
|
||||||
|
items.append({
|
||||||
|
'hostname': hostname,
|
||||||
|
'ip_address': ip,
|
||||||
|
'device_type': device_type,
|
||||||
|
'team': team,
|
||||||
|
'metric_id': sheet_name,
|
||||||
|
'metric_desc': metric_desc,
|
||||||
|
'category': category,
|
||||||
|
'extra_json': extra,
|
||||||
|
})
|
||||||
|
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
def extract_report_date(filepath):
|
||||||
|
"""Try to pull YYYY-MM-DD from the filename, e.g. NTS_AEO_2026_03_25.xlsx"""
|
||||||
|
stem = Path(filepath).stem
|
||||||
|
m = re.search(r'(\d{4})_(\d{2})_(\d{2})', stem)
|
||||||
|
if m:
|
||||||
|
return f"{m.group(1)}-{m.group(2)}-{m.group(3)}"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
if len(sys.argv) < 2:
|
||||||
|
print(json.dumps({'error': 'No file path provided'}))
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
filepath = sys.argv[1]
|
||||||
|
|
||||||
|
try:
|
||||||
|
xl = pd.ExcelFile(filepath)
|
||||||
|
except Exception as e:
|
||||||
|
print(json.dumps({'error': f'Cannot open file: {str(e)}'}))
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
try:
|
||||||
|
summary = parse_summary(xl)
|
||||||
|
except Exception as e:
|
||||||
|
summary = {'entries': [], 'overall_scores': {}, 'parse_error': str(e)}
|
||||||
|
|
||||||
|
all_items = []
|
||||||
|
for sheet_name in xl.sheet_names:
|
||||||
|
if sheet_name in SKIP_SHEETS:
|
||||||
|
continue
|
||||||
|
items = parse_sheet(xl, sheet_name, summary.get('entries', []))
|
||||||
|
all_items.extend(items)
|
||||||
|
|
||||||
|
print(json.dumps({
|
||||||
|
'items': all_items,
|
||||||
|
'summary': summary,
|
||||||
|
'report_date': extract_report_date(filepath),
|
||||||
|
'total': len(all_items),
|
||||||
|
}))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
@@ -23,6 +23,7 @@ const createArcherTicketsRouter = require('./routes/archerTickets');
|
|||||||
const createIvantiWorkflowsRouter = require('./routes/ivantiWorkflows');
|
const createIvantiWorkflowsRouter = require('./routes/ivantiWorkflows');
|
||||||
const createIvantiFindingsRouter = require('./routes/ivantiFindings');
|
const createIvantiFindingsRouter = require('./routes/ivantiFindings');
|
||||||
const createIvantiTodoQueueRouter = require('./routes/ivantiTodoQueue');
|
const createIvantiTodoQueueRouter = require('./routes/ivantiTodoQueue');
|
||||||
|
const createComplianceRouter = require('./routes/compliance');
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const PORT = process.env.PORT || 3001;
|
const PORT = process.env.PORT || 3001;
|
||||||
@@ -218,6 +219,9 @@ app.use('/api/ivanti/findings', createIvantiFindingsRouter(db, requireAuth));
|
|||||||
// Ivanti queue routes — per-user staging queue for FP / Archer workflows
|
// Ivanti queue routes — per-user staging queue for FP / Archer workflows
|
||||||
app.use('/api/ivanti/todo-queue', createIvantiTodoQueueRouter(db, requireAuth));
|
app.use('/api/ivanti/todo-queue', createIvantiTodoQueueRouter(db, requireAuth));
|
||||||
|
|
||||||
|
// AEO compliance routes — xlsx upload, non-compliant item tracking, notes
|
||||||
|
app.use('/api/compliance', createComplianceRouter(db, upload, requireAuth, requireRole));
|
||||||
|
|
||||||
// ========== CVE ENDPOINTS ==========
|
// ========== CVE ENDPOINTS ==========
|
||||||
|
|
||||||
// Get all CVEs with optional filters (authenticated users)
|
// Get all CVEs with optional filters (authenticated users)
|
||||||
|
|||||||
73
docs/python-venv-setup.md
Normal file
73
docs/python-venv-setup.md
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
# Python Dependencies — Compliance xlsx Parsing
|
||||||
|
|
||||||
|
`parse_compliance_xlsx.py` requires `pandas` and `openpyxl`. This doc
|
||||||
|
explains how each server has (or should have) these installed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dev server — how it works
|
||||||
|
|
||||||
|
Pandas and openpyxl are installed as **system apt packages**, not via pip
|
||||||
|
or a venv. This is why there is no venv on dev and no `--break-system-packages`
|
||||||
|
gymnastics. They were installed at some point via:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
apt install python3-pandas python3-openpyxl
|
||||||
|
```
|
||||||
|
|
||||||
|
You can verify with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -c "import pandas; print(pandas.__file__)"
|
||||||
|
# /usr/lib/python3/dist-packages/pandas/__init__.py ← apt-managed
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Production server — how to fix it
|
||||||
|
|
||||||
|
Production was missing pandas entirely. The fix mirrors what dev has:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
apt-get update --fix-missing
|
||||||
|
apt install -y python3-pandas python3-openpyxl
|
||||||
|
```
|
||||||
|
|
||||||
|
No venv, no pip, no `PYTHON_BIN` env var needed. After installing, restart
|
||||||
|
the backend and the compliance xlsx upload will work.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## If apt packages are unavailable (fallback)
|
||||||
|
|
||||||
|
If you're on a system where apt doesn't have pandas (unlikely on Ubuntu
|
||||||
|
22.04/24.04), or you want isolation, use a venv:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
apt install -y python3-venv python3-full
|
||||||
|
python3 -m venv /home/cve-dashboard/venv
|
||||||
|
/home/cve-dashboard/venv/bin/pip install -r /home/cve-dashboard/backend/scripts/requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
Then set `PYTHON_BIN` in the Node backend's environment:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export PYTHON_BIN=/home/cve-dashboard/venv/bin/python3
|
||||||
|
```
|
||||||
|
|
||||||
|
The backend reads `process.env.PYTHON_BIN` and falls back to `python3` if
|
||||||
|
not set, so this only needs to be done if you're using a venv.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Why pip3 may fail on modern Ubuntu/Debian
|
||||||
|
|
||||||
|
PEP 668 (enforced in Ubuntu 23.04+) blocks `pip3 install` system-wide to
|
||||||
|
prevent breaking apt-managed packages. The error looks like:
|
||||||
|
|
||||||
|
```
|
||||||
|
error: externally-managed-environment
|
||||||
|
```
|
||||||
|
|
||||||
|
Using `apt install python3-pandas` is the correct solution — pip is not
|
||||||
|
needed when the distro packages the library directly.
|
||||||
183
docs/security-posture-workflow-diagrams.md
Normal file
183
docs/security-posture-workflow-diagrams.md
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
# Security Posture Workflow — Diagrams
|
||||||
|
|
||||||
|
Mermaid diagrams for the Host Finding Review & Remediation process.
|
||||||
|
Renders natively in GitHub, GitLab, and most modern documentation tools.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Diagram 1 — Host Finding Review Workflow (Steps 1–5)
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
START([Open Vulnerability Triage Page]) --> SYNC
|
||||||
|
|
||||||
|
SYNC["① Sync & Sort<br/>Click Sync · Sort Due Date ascending"]
|
||||||
|
SYNC --> DUE{Overdue<br/>findings?}
|
||||||
|
DUE -->|Yes — start here| HOST
|
||||||
|
DUE -->|No — start with amber| HOST
|
||||||
|
|
||||||
|
HOST["② Identify the Host<br/>Verify IP in IPControl / Infoblox"]
|
||||||
|
HOST --> CORRECT{Hostname<br/>correct?}
|
||||||
|
CORRECT -->|No| EDIT["Inline-edit Host / DNS cell<br/>Amber dot marks the override"]
|
||||||
|
EDIT --> OWN
|
||||||
|
CORRECT -->|Yes| OWN
|
||||||
|
|
||||||
|
OWN["③ Identify Asset Ownership<br/>Check BU column"]
|
||||||
|
OWN --> BU{Our BU?}
|
||||||
|
BU -->|"NTS-AEO-STEAM<br/>or ACCESS-ENG"| CVE
|
||||||
|
BU -->|"Other BU<br/>or blank"| CARD["Add to CARD Queue<br/>☑ checkbox → CARD → Add to Queue"]
|
||||||
|
CARD --> CARD2([Process in dedicated CARD session])
|
||||||
|
|
||||||
|
CVE["④ Review CVEs in the Finding<br/>Up to 2 shown · hover +N badge for more"]
|
||||||
|
CVE --> DBCHECK{CVE in<br/>database?}
|
||||||
|
DBCHECK -->|No| ADDCVE["Create CVE entry on Home page<br/>NVD auto-fill populates details"]
|
||||||
|
ADDCVE --> RESEARCH
|
||||||
|
DBCHECK -->|Yes — review existing notes/docs| RESEARCH
|
||||||
|
|
||||||
|
RESEARCH["Research CVE<br/>Vendor advisory · Cisco Bug Search<br/>Juniper PSN · Support ticket"]
|
||||||
|
RESEARCH --> ACTION
|
||||||
|
|
||||||
|
ACTION["⑤ Determine Required Action"]
|
||||||
|
ACTION --> PATH{What does<br/>research show?}
|
||||||
|
|
||||||
|
PATH -->|"Patch available<br/>FW / SW update"| PA
|
||||||
|
PATH -->|"Fix is config<br/>change only"| PB
|
||||||
|
PATH -->|"Not applicable<br/>to platform / version"| PC
|
||||||
|
PATH -->|"Cannot patch<br/>vendor / EOL / business"| PD
|
||||||
|
|
||||||
|
PA["PATH A — Remediation<br/>Firmware or Software Upgrade"]
|
||||||
|
PA --> PA1["Plan & schedule upgrade<br/>Add note to finding row"]
|
||||||
|
PA1 --> PA2(["Finding drops off after<br/>next Ivanti scan ✓"])
|
||||||
|
|
||||||
|
PB["PATH B — Remediation<br/>Configuration Change"]
|
||||||
|
PB --> PB1["☑ checkbox → Vendor → Archer<br/>Add to Queue"]
|
||||||
|
PB1 --> PB2["Open Archer EXC ticket<br/>in dedicated session"]
|
||||||
|
PB2 --> PB3(["Enter EXC-XXXXX<br/>in finding Notes cell ✓"])
|
||||||
|
|
||||||
|
PC["PATH C — False Positive"]
|
||||||
|
PC --> PC1["Take device screenshot<br/>Hostname · IP · SW version"]
|
||||||
|
PC1 --> PC2["Obtain vendor documentation<br/>advisory / email / support ticket"]
|
||||||
|
PC2 --> PC3["Upload evidence to CVE database<br/>Home page → CVE row → Upload"]
|
||||||
|
PC3 --> PC4["☑ checkbox → Vendor → FP<br/>Add to Queue"]
|
||||||
|
PC4 --> PC5(["Submit FP workflow in Ivanti<br/>in dedicated session ✓"])
|
||||||
|
|
||||||
|
PD["PATH D — Risk Acceptance"]
|
||||||
|
PD --> PD1["Take device screenshot<br/>Collect version info"]
|
||||||
|
PD1 --> PD2{Vendor comms<br/>needed?}
|
||||||
|
PD2 -->|Yes| PD3["Open vendor support ticket<br/>Request patch timeline / mitigations"]
|
||||||
|
PD3 --> PD4
|
||||||
|
PD2 -->|No| PD4["☑ checkbox → Vendor → Archer<br/>Add to Queue"]
|
||||||
|
PD4 --> PD5["Open Archer EXC ticket<br/>in dedicated session"]
|
||||||
|
PD5 --> PD6(["Enter EXC-XXXXX<br/>in finding Notes cell ✓"])
|
||||||
|
|
||||||
|
%% Styling
|
||||||
|
classDef step fill:#1e3a5f,stroke:#0ea5e9,stroke-width:2px,color:#e2e8f0
|
||||||
|
classDef decision fill:#1a2e1a,stroke:#10b981,stroke-width:2px,color:#e2e8f0
|
||||||
|
classDef pathA fill:#14391f,stroke:#10b981,stroke-width:1.5px,color:#e2e8f0
|
||||||
|
classDef pathB fill:#2d1f14,stroke:#f59e0b,stroke-width:1.5px,color:#e2e8f0
|
||||||
|
classDef pathC fill:#2d1414,stroke:#ef4444,stroke-width:1.5px,color:#e2e8f0
|
||||||
|
classDef pathD fill:#1a1430,stroke:#8b5cf6,stroke-width:1.5px,color:#e2e8f0
|
||||||
|
classDef card fill:#1a2e1a,stroke:#10b981,stroke-width:1.5px,color:#e2e8f0
|
||||||
|
classDef done fill:#0f172a,stroke:#475569,stroke-width:1.5px,color:#64748b
|
||||||
|
|
||||||
|
class SYNC,HOST,OWN,CVE,RESEARCH,ACTION step
|
||||||
|
class DUE,CORRECT,BU,DBCHECK,PATH decision
|
||||||
|
class PA,PA1,PA2 pathA
|
||||||
|
class PB,PB1,PB2,PB3 pathB
|
||||||
|
class PC,PC1,PC2,PC3,PC4,PC5 pathC
|
||||||
|
class PD,PD1,PD2,PD3,PD4,PD5,PD6 pathD
|
||||||
|
class CARD,CARD2 card
|
||||||
|
class EDIT done
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Diagram 2 — FP Workflow Badge Status Decision Tree
|
||||||
|
|
||||||
|
What to do when a finding already has a workflow badge in the Vulnerability Triage page.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
A([Finding in<br/>Vulnerability Triage]) --> B{"Check<br/>Workflow column"}
|
||||||
|
|
||||||
|
B -->|No badge| C["UNTRIAGED<br/>No action on record"]
|
||||||
|
C --> C1(["Follow the<br/>Step 1–5 triage workflow ↑"])
|
||||||
|
|
||||||
|
B -->|"🔵 Blue<br/>Requested"| D["IN FLIGHT<br/>FP submitted · awaiting approval"]
|
||||||
|
D --> D1{"SLA window<br/>approaching?"}
|
||||||
|
D1 -->|No| D2(["Monitor — no action yet ✓"])
|
||||||
|
D1 -->|Yes| D3(["Follow up with<br/>the approver"])
|
||||||
|
|
||||||
|
B -->|"🟡 Amber<br/>Reworked"| E["NEEDS REVISION<br/>Reviewer returned the ticket"]
|
||||||
|
E --> E1["Open ticket in Ivanti<br/>Review feedback"]
|
||||||
|
E1 --> E2(["Update justification<br/>and resubmit"])
|
||||||
|
|
||||||
|
B -->|"🟡 Amber<br/>Actionable"| F["NEEDS RESPONSE<br/>Ticket flagged for team action"]
|
||||||
|
F --> F1(["Open ticket in Ivanti<br/>Respond to the request"])
|
||||||
|
|
||||||
|
B -->|"🔴 Red<br/>Expired"| G["EXCEPTION LAPSED<br/>Finding has re-opened"]
|
||||||
|
G --> G1(["Submit a new FP request<br/>in Ivanti<br/>Reference previous ticket"])
|
||||||
|
|
||||||
|
B -->|"🔴 Red<br/>Rejected"| H["CONFIRMED VULNERABILITY<br/>Security team denied the FP"]
|
||||||
|
H --> H1(["Remediate the vulnerability<br/>Do not resubmit FP<br/>without new evidence"])
|
||||||
|
|
||||||
|
%% Styling
|
||||||
|
classDef trigger fill:#0f172a,stroke:#0ea5e9,stroke-width:2px,color:#e2e8f0
|
||||||
|
classDef blue fill:#1e3a5f,stroke:#0ea5e9,stroke-width:1.5px,color:#e2e8f0
|
||||||
|
classDef amber fill:#2d2014,stroke:#f59e0b,stroke-width:1.5px,color:#e2e8f0
|
||||||
|
classDef red fill:#2d1414,stroke:#ef4444,stroke-width:1.5px,color:#e2e8f0
|
||||||
|
classDef none fill:#1a1a2e,stroke:#475569,stroke-width:1.5px,color:#94a3b8
|
||||||
|
classDef done fill:#0f172a,stroke:#334155,stroke-width:1px,color:#64748b
|
||||||
|
|
||||||
|
class A,B trigger
|
||||||
|
class D,D1,D2,D3 blue
|
||||||
|
class E,E1,E2,F,F1 amber
|
||||||
|
class G,G1,H,H1 red
|
||||||
|
class C,C1 none
|
||||||
|
class D2,D3,E2,F1,G1,H1 done
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Diagram 3 — Action Decision Matrix (Quick Reference)
|
||||||
|
|
||||||
|
Condensed view of the five research outcomes and their required actions.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
START(["Research complete<br/>Step 4 done"]) --> Q{"What is the<br/>remediation path?"}
|
||||||
|
|
||||||
|
Q --> R1["Firmware or<br/>Software update available"]
|
||||||
|
R1 --> A1(["No ticket needed<br/>Schedule upgrade<br/>Add note to finding"])
|
||||||
|
|
||||||
|
Q --> R2["Fix is a<br/>configuration change"]
|
||||||
|
R2 --> A2(["Archer EXC ticket required<br/>Stage as Archer in Queue"])
|
||||||
|
|
||||||
|
Q --> R3["Not applicable<br/>to this platform / version"]
|
||||||
|
R3 --> A3(["FP workflow in Ivanti<br/>Evidence in CVE database"])
|
||||||
|
|
||||||
|
Q --> R4["Patch not yet<br/>available from vendor"]
|
||||||
|
R4 --> A4(["Archer EXC ticket<br/>Renew when patch ships"])
|
||||||
|
|
||||||
|
Q --> R5["Device is EOL / EOS<br/>or business constraint"]
|
||||||
|
R5 --> A5(["Archer ticket with<br/>mitigation steps +<br/>remediation plan"])
|
||||||
|
|
||||||
|
Q --> R6["Asset not owned<br/>by our BU"]
|
||||||
|
R6 --> A6(["CARD queue<br/>CARD disposition process"])
|
||||||
|
|
||||||
|
classDef q fill:#1e3a5f,stroke:#0ea5e9,stroke-width:2px,color:#e2e8f0
|
||||||
|
classDef green fill:#14391f,stroke:#10b981,stroke-width:1.5px,color:#e2e8f0
|
||||||
|
classDef amber fill:#2d2014,stroke:#f59e0b,stroke-width:1.5px,color:#e2e8f0
|
||||||
|
classDef red fill:#2d1414,stroke:#ef4444,stroke-width:1.5px,color:#e2e8f0
|
||||||
|
classDef teal fill:#0f2d2d,stroke:#14b8a6,stroke-width:1.5px,color:#e2e8f0
|
||||||
|
|
||||||
|
class START,Q q
|
||||||
|
class R1,A1 green
|
||||||
|
class R2,A2,R4,A4,R5,A5 amber
|
||||||
|
class R3,A3 red
|
||||||
|
class R6,A6 teal
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Source document: `docs/security-posture-workflow.md`*
|
||||||
175
docs/security-posture-workflow-lucidchart.md
Normal file
175
docs/security-posture-workflow-lucidchart.md
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
# Lucidchart Import — Raw Mermaid Code
|
||||||
|
|
||||||
|
Lucidchart expects raw Mermaid syntax only — no markdown headings or prose.
|
||||||
|
Paste each diagram separately: Insert → Diagram as Code → Mermaid → paste → Generate.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## DIAGRAM 1 — Host Finding Review Workflow
|
||||||
|
|
||||||
|
Paste everything between the triple-backtick fences below:
|
||||||
|
|
||||||
|
```
|
||||||
|
flowchart TD
|
||||||
|
START([Open Reporting Page]) --> SYNC
|
||||||
|
|
||||||
|
SYNC["① Sync & Sort<br/>Click Sync · Sort Due Date ascending"]
|
||||||
|
SYNC --> DUE{Overdue<br/>findings?}
|
||||||
|
DUE -->|Yes — start here| HOST
|
||||||
|
DUE -->|No — start with amber| HOST
|
||||||
|
|
||||||
|
HOST["② Identify the Host<br/>Verify IP in IPControl / Infoblox"]
|
||||||
|
HOST --> CORRECT{Hostname<br/>correct?}
|
||||||
|
CORRECT -->|No| EDIT["Inline-edit Host / DNS cell<br/>Amber dot marks the override"]
|
||||||
|
EDIT --> OWN
|
||||||
|
CORRECT -->|Yes| OWN
|
||||||
|
|
||||||
|
OWN["③ Identify Asset Ownership<br/>Check BU column"]
|
||||||
|
OWN --> BU{Our BU?}
|
||||||
|
BU -->|"NTS-AEO-STEAM or ACCESS-ENG"| CVE
|
||||||
|
BU -->|"Other BU or blank"| CARD["Add to CARD Queue<br/>checkbox → CARD → Add to Queue"]
|
||||||
|
CARD --> CARD2([Process in dedicated CARD session])
|
||||||
|
|
||||||
|
CVE["④ Review CVEs in the Finding<br/>Up to 2 shown · hover badge for more"]
|
||||||
|
CVE --> DBCHECK{CVE in<br/>database?}
|
||||||
|
DBCHECK -->|No| ADDCVE["Create CVE entry on Home page<br/>NVD auto-fill populates details"]
|
||||||
|
ADDCVE --> RESEARCH
|
||||||
|
DBCHECK -->|Yes — review existing notes/docs| RESEARCH
|
||||||
|
|
||||||
|
RESEARCH["Research CVE<br/>Vendor advisory · Cisco Bug Search<br/>Juniper PSN · Support ticket"]
|
||||||
|
RESEARCH --> ACTION
|
||||||
|
|
||||||
|
ACTION["⑤ Determine Required Action"]
|
||||||
|
ACTION --> PATH{What does<br/>research show?}
|
||||||
|
|
||||||
|
PATH -->|"Patch available — FW / SW update"| PA
|
||||||
|
PATH -->|"Fix is config change only"| PB
|
||||||
|
PATH -->|"Not applicable to platform / version"| PC
|
||||||
|
PATH -->|"Cannot patch — vendor / EOL / business"| PD
|
||||||
|
|
||||||
|
PA["PATH A — Remediation<br/>Firmware or Software Upgrade"]
|
||||||
|
PA --> PA1["Plan & schedule upgrade<br/>Add note to finding row"]
|
||||||
|
PA1 --> PA2(["Finding drops off after<br/>next Ivanti scan ✓"])
|
||||||
|
|
||||||
|
PB["PATH B — Remediation<br/>Configuration Change"]
|
||||||
|
PB --> PB1["checkbox → Vendor → Archer<br/>Add to Queue"]
|
||||||
|
PB1 --> PB2["Open Archer EXC ticket<br/>in dedicated session"]
|
||||||
|
PB2 --> PB3(["Enter EXC-XXXXX<br/>in finding Notes cell ✓"])
|
||||||
|
|
||||||
|
PC["PATH C — False Positive"]
|
||||||
|
PC --> PC1["Take device screenshot<br/>Hostname · IP · SW version"]
|
||||||
|
PC1 --> PC2["Obtain vendor documentation<br/>advisory / email / support ticket"]
|
||||||
|
PC2 --> PC3["Upload evidence to CVE database<br/>Home page → CVE row → Upload"]
|
||||||
|
PC3 --> PC4["checkbox → Vendor → FP<br/>Add to Queue"]
|
||||||
|
PC4 --> PC5(["Submit FP workflow in Ivanti<br/>in dedicated session ✓"])
|
||||||
|
|
||||||
|
PD["PATH D — Risk Acceptance"]
|
||||||
|
PD --> PD1["Take device screenshot<br/>Collect version info"]
|
||||||
|
PD1 --> PD2{Vendor comms<br/>needed?}
|
||||||
|
PD2 -->|Yes| PD3["Open vendor support ticket<br/>Request patch timeline / mitigations"]
|
||||||
|
PD3 --> PD4
|
||||||
|
PD2 -->|No| PD4["checkbox → Vendor → Archer<br/>Add to Queue"]
|
||||||
|
PD4 --> PD5["Open Archer EXC ticket<br/>in dedicated session"]
|
||||||
|
PD5 --> PD6(["Enter EXC-XXXXX<br/>in finding Notes cell ✓"])
|
||||||
|
|
||||||
|
classDef step fill:#1e3a5f,stroke:#0ea5e9,stroke-width:2px,color:#e2e8f0
|
||||||
|
classDef decision fill:#1a2e1a,stroke:#10b981,stroke-width:2px,color:#e2e8f0
|
||||||
|
classDef pathA fill:#14391f,stroke:#10b981,stroke-width:1.5px,color:#e2e8f0
|
||||||
|
classDef pathB fill:#2d1f14,stroke:#f59e0b,stroke-width:1.5px,color:#e2e8f0
|
||||||
|
classDef pathC fill:#2d1414,stroke:#ef4444,stroke-width:1.5px,color:#e2e8f0
|
||||||
|
classDef pathD fill:#1a1430,stroke:#8b5cf6,stroke-width:1.5px,color:#e2e8f0
|
||||||
|
classDef card fill:#1a2e1a,stroke:#10b981,stroke-width:1.5px,color:#e2e8f0
|
||||||
|
classDef done fill:#0f172a,stroke:#475569,stroke-width:1.5px,color:#64748b
|
||||||
|
|
||||||
|
class SYNC,HOST,OWN,CVE,RESEARCH,ACTION step
|
||||||
|
class DUE,CORRECT,BU,DBCHECK,PATH decision
|
||||||
|
class PA,PA1,PA2 pathA
|
||||||
|
class PB,PB1,PB2,PB3 pathB
|
||||||
|
class PC,PC1,PC2,PC3,PC4,PC5 pathC
|
||||||
|
class PD,PD1,PD2,PD3,PD4,PD5,PD6 pathD
|
||||||
|
class CARD,CARD2 card
|
||||||
|
class EDIT done
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## DIAGRAM 2 — FP Workflow Badge Status Decision Tree
|
||||||
|
|
||||||
|
```
|
||||||
|
flowchart LR
|
||||||
|
A([Finding in Reporting Page]) --> B{"Check Workflow column"}
|
||||||
|
|
||||||
|
B -->|No badge| C["UNTRIAGED<br/>No action on record"]
|
||||||
|
C --> C1(["Follow the Step 1-5 triage workflow"])
|
||||||
|
|
||||||
|
B -->|Blue - Requested| D["IN FLIGHT<br/>FP submitted · awaiting approval"]
|
||||||
|
D --> D1{"SLA window<br/>approaching?"}
|
||||||
|
D1 -->|No| D2(["Monitor — no action yet"])
|
||||||
|
D1 -->|Yes| D3(["Follow up with the approver"])
|
||||||
|
|
||||||
|
B -->|Amber - Reworked| E["NEEDS REVISION<br/>Reviewer returned the ticket"]
|
||||||
|
E --> E1["Open ticket in Ivanti<br/>Review feedback"]
|
||||||
|
E1 --> E2(["Update justification and resubmit"])
|
||||||
|
|
||||||
|
B -->|Amber - Actionable| F["NEEDS RESPONSE<br/>Ticket flagged for team action"]
|
||||||
|
F --> F1(["Open ticket in Ivanti<br/>Respond to the request"])
|
||||||
|
|
||||||
|
B -->|Red - Expired| G["EXCEPTION LAPSED<br/>Finding has re-opened"]
|
||||||
|
G --> G1(["Submit a new FP request in Ivanti<br/>Reference previous ticket"])
|
||||||
|
|
||||||
|
B -->|Red - Rejected| H["CONFIRMED VULNERABILITY<br/>Security team denied the FP"]
|
||||||
|
H --> H1(["Remediate the vulnerability<br/>Do not resubmit FP without new evidence"])
|
||||||
|
|
||||||
|
classDef trigger fill:#0f172a,stroke:#0ea5e9,stroke-width:2px,color:#e2e8f0
|
||||||
|
classDef blue fill:#1e3a5f,stroke:#0ea5e9,stroke-width:1.5px,color:#e2e8f0
|
||||||
|
classDef amber fill:#2d2014,stroke:#f59e0b,stroke-width:1.5px,color:#e2e8f0
|
||||||
|
classDef red fill:#2d1414,stroke:#ef4444,stroke-width:1.5px,color:#e2e8f0
|
||||||
|
classDef none fill:#1a1a2e,stroke:#475569,stroke-width:1.5px,color:#94a3b8
|
||||||
|
classDef done fill:#0f172a,stroke:#334155,stroke-width:1px,color:#64748b
|
||||||
|
|
||||||
|
class A,B trigger
|
||||||
|
class D,D1,D2,D3 blue
|
||||||
|
class E,E1,E2,F,F1 amber
|
||||||
|
class G,G1,H,H1 red
|
||||||
|
class C,C1 none
|
||||||
|
class D2,D3,E2,F1,G1,H1 done
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## DIAGRAM 3 — Action Decision Matrix
|
||||||
|
|
||||||
|
```
|
||||||
|
flowchart LR
|
||||||
|
START(["Research complete — Step 4 done"]) --> Q{"What is the<br/>remediation path?"}
|
||||||
|
|
||||||
|
Q --> R1["Firmware or software update available"]
|
||||||
|
R1 --> A1(["No ticket needed<br/>Schedule upgrade · Add note to finding"])
|
||||||
|
|
||||||
|
Q --> R2["Fix is a configuration change only"]
|
||||||
|
R2 --> A2(["Archer EXC ticket required<br/>Stage as Archer in Queue"])
|
||||||
|
|
||||||
|
Q --> R3["Not applicable to this platform / version"]
|
||||||
|
R3 --> A3(["FP workflow in Ivanti<br/>Evidence in CVE database"])
|
||||||
|
|
||||||
|
Q --> R4["Patch not yet available from vendor"]
|
||||||
|
R4 --> A4(["Archer EXC ticket<br/>Renew when patch ships"])
|
||||||
|
|
||||||
|
Q --> R5["Device is EOL / EOS or business constraint"]
|
||||||
|
R5 --> A5(["Archer ticket with mitigation steps<br/>and remediation plan"])
|
||||||
|
|
||||||
|
Q --> R6["Asset not owned by our BU"]
|
||||||
|
R6 --> A6(["CARD queue — CARD disposition process"])
|
||||||
|
|
||||||
|
classDef q fill:#1e3a5f,stroke:#0ea5e9,stroke-width:2px,color:#e2e8f0
|
||||||
|
classDef green fill:#14391f,stroke:#10b981,stroke-width:1.5px,color:#e2e8f0
|
||||||
|
classDef amber fill:#2d2014,stroke:#f59e0b,stroke-width:1.5px,color:#e2e8f0
|
||||||
|
classDef red fill:#2d1414,stroke:#ef4444,stroke-width:1.5px,color:#e2e8f0
|
||||||
|
classDef teal fill:#0f2d2d,stroke:#14b8a6,stroke-width:1.5px,color:#e2e8f0
|
||||||
|
|
||||||
|
class START,Q q
|
||||||
|
class R1,A1 green
|
||||||
|
class R2,A2,R4,A4,R5,A5 amber
|
||||||
|
class R3,A3 red
|
||||||
|
class R6,A6 teal
|
||||||
|
```
|
||||||
402
docs/security-posture-workflow.md
Normal file
402
docs/security-posture-workflow.md
Normal file
@@ -0,0 +1,402 @@
|
|||||||
|
# Security Posture Workflow — Host Finding Review & Remediation
|
||||||
|
|
||||||
|
**Document Type:** Process Guide
|
||||||
|
**Applies To:** STEAM Security Dashboard — All Pages
|
||||||
|
**Audience:** NTS-AEO-STEAM / NTS-AEO-ACCESS-ENG team members
|
||||||
|
**Last Updated:** 2026-03-27
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
1. [Overview](#1-overview)
|
||||||
|
2. [Dashboard Orientation](#2-dashboard-orientation)
|
||||||
|
3. [Vulnerability Designations](#3-vulnerability-designations)
|
||||||
|
4. [The Host Finding Review Workflow](#4-the-host-finding-review-workflow)
|
||||||
|
- [Step 1 — Sync and Sort by Due Date](#step-1--sync-and-sort-by-due-date)
|
||||||
|
- [Step 2 — Identify the Host](#step-2--identify-the-host)
|
||||||
|
- [Step 3 — Identify Asset Ownership](#step-3--identify-asset-ownership)
|
||||||
|
- [Step 4 — Review the CVEs in the Finding](#step-4--review-the-cves-in-the-finding)
|
||||||
|
- [Step 5 — Determine and Execute the Required Action](#step-5--determine-and-execute-the-required-action)
|
||||||
|
5. [Using the Ivanti Queue](#5-using-the-ivanti-queue)
|
||||||
|
6. [Workflow Status Reference](#6-workflow-status-reference)
|
||||||
|
7. [Quick Reference Card](#7-quick-reference-card)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Overview
|
||||||
|
|
||||||
|
The STEAM Security Dashboard centralises vulnerability management for the NTS-AEO-STEAM and NTS-AEO-ACCESS-ENG business units. It pulls host findings directly from Ivanti/RiskSense and gives the team a single place to triage, track, and action every open vulnerability.
|
||||||
|
|
||||||
|
**Scope:** This document covers severity findings in the **8.5 – 9.9 VRR range**. All findings in this range require some form of documented action. A finding that is not actioned before its Due Date results in the device being recorded as non-compliant.
|
||||||
|
|
||||||
|
> **SLA Rule:** By default, all vulnerabilities must have an action taken or in-flight within **60 days of detection**. The Due Date column on the Reporting page shows the exact deadline. Metrics and compliance reporting are based on vulnerabilities aged under 60 days.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Dashboard Orientation
|
||||||
|
|
||||||
|
### Pages
|
||||||
|
|
||||||
|
| Page | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| **Home (CVE Management)** | Track and research individual CVEs across vendors. Store supporting documentation. Log Archer EXC ticket numbers against CVE/vendor pairs. |
|
||||||
|
| **Reporting (Host Findings)** | The primary operational page. Live view of all open Ivanti findings with filtering, sorting, inline editing, the Ivanti Queue, and export. |
|
||||||
|
| **Knowledge Base** | Internal document library — policies, runbooks, vendor advisories. |
|
||||||
|
| **Exports** | Bulk export tools for reports and data extracts. |
|
||||||
|
|
||||||
|
### Reporting Page — At a Glance
|
||||||
|
|
||||||
|
When you open the Reporting page for the first time in a session, click **Sync** (top right) to pull the latest findings from Ivanti. The page shows:
|
||||||
|
|
||||||
|
- **Four metric charts** at the top — Open vs Closed, Action Coverage, FP Finding Status, FP Workflow Status
|
||||||
|
- **Findings table** below — every open finding for the configured BUs, one row per host finding
|
||||||
|
- **Ivanti Queue panel** (click the Queue button, top right) — your personal staging list for batch-processing FP and Archer workflows
|
||||||
|
|
||||||
|
The charts and table update together. Clicking a chart segment filters the table to that subset.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Vulnerability Designations
|
||||||
|
|
||||||
|
Every finding in the 8.5–9.9 range requires one of three documented actions. Understanding these upfront makes triage faster.
|
||||||
|
|
||||||
|
### 3.1 Remediation
|
||||||
|
|
||||||
|
The vulnerability is addressed by fixing the root cause.
|
||||||
|
|
||||||
|
| Remediation Method | Archer Ticket Required? | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| Firmware or software update | **No** | Upgrading removes the vulnerability entirely. The finding will fall off the report on the next scan. |
|
||||||
|
| Configuration change | **Yes** | A config change does not remove the vulnerability — if the config is ever rolled back, the vulnerability returns. An Archer Risk Acceptance ticket is required to document this. |
|
||||||
|
|
||||||
|
### 3.2 False Positive (FP)
|
||||||
|
|
||||||
|
A false positive occurs when the scanner detects a vulnerability that is **not actually present** or **does not apply** to the platform or software version in use.
|
||||||
|
|
||||||
|
**An FP workflow must be opened in Ivanti.** The workflow requires:
|
||||||
|
|
||||||
|
1. A **screenshot** taken directly from the device showing:
|
||||||
|
- Hostname
|
||||||
|
- IP address
|
||||||
|
- Software / firmware version
|
||||||
|
> **Important:** This must be a screenshot. CLI text output or copy-pasted command output is not accepted.
|
||||||
|
|
||||||
|
2. **Vendor documentation** confirming the vulnerability does not affect the platform — one of:
|
||||||
|
- Direct vendor communication (email, support ticket)
|
||||||
|
- Published security advisory stating the version or platform is not affected
|
||||||
|
- Proof that the vulnerability does not apply to the currently installed version
|
||||||
|
|
||||||
|
Supporting files (screenshots, emails, advisories) should be saved into the CVE Database (Home page → upload documents against the relevant CVE/vendor pair) for future reference and re-use if the FP expires and needs to be renewed.
|
||||||
|
|
||||||
|
### 3.3 Risk Acceptance / Archer Request
|
||||||
|
|
||||||
|
An Archer Risk Acceptance ticket (EXC-XXXXX) is required when a vulnerability **cannot be patched** for a documented business or technical reason. Common scenarios:
|
||||||
|
|
||||||
|
| Scenario | Required Action |
|
||||||
|
|---|---|
|
||||||
|
| Patch not yet available (waiting on vendor) | Open Archer ticket; close it when patch is deployed |
|
||||||
|
| Device is End-of-Sale (EOS) or End-of-Life (EOL) | Archer ticket required with mitigation steps and a remediation plan |
|
||||||
|
| Business constraint prevents patching | Archer ticket with justification and compensating controls |
|
||||||
|
| Configuration-change-only remediation | Archer ticket required (see Remediation above) |
|
||||||
|
|
||||||
|
For EOL/EOS devices the ticket must include:
|
||||||
|
- Current mitigation steps (network segmentation, compensating controls)
|
||||||
|
- A remediation plan — what will replace or retire the device and when
|
||||||
|
|
||||||
|
If vendor communication is needed (patch timeline, configuration guidance), open a vendor support ticket and use the vendor's response to fill out the Archer remediation plan field.
|
||||||
|
|
||||||
|
> Archer EXC numbers are tracked in the dashboard. Once entered on the Home page against the relevant CVE/vendor pair, the EXC badge appears on that CVE row. Clicking the badge navigates to the Reporting page pre-filtered to findings with that EXC number in their notes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. The Host Finding Review Workflow
|
||||||
|
|
||||||
|
Work through the Reporting page top-to-bottom by Due Date. The goal of each session is to ensure every finding either has an action in-flight or gets one started.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 1 — Sync and Sort by Due Date
|
||||||
|
|
||||||
|
1. Navigate to the **Reporting** page.
|
||||||
|
2. Click **Sync** (top right). Wait for the sync to complete — the timestamp updates when done.
|
||||||
|
3. Click the **Due Date** column header to sort ascending (soonest due date first).
|
||||||
|
- Red due dates = overdue
|
||||||
|
- Amber due dates = due within 30 days
|
||||||
|
- Start with red, then amber
|
||||||
|
|
||||||
|
> If you want to focus on findings with no action yet, click the **Pending** segment on the Action Coverage donut chart. The table will filter to only findings with no FP ticket and no EXC number in notes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 2 — Identify the Host
|
||||||
|
|
||||||
|
Each finding row includes a **Host** (hostname), **IP Address**, and **DNS** column.
|
||||||
|
|
||||||
|
1. Use the reported **IP address** to verify the hostname in:
|
||||||
|
- **IPControl** (read-only, historical IPAM data)
|
||||||
|
- **Infoblox** (current IPAM — preferred for current state)
|
||||||
|
|
||||||
|
2. If the hostname shown in the dashboard is incorrect (Ivanti sometimes reports stale data):
|
||||||
|
- Click the **Host** cell in the finding row — it is inline editable.
|
||||||
|
- Type the correct hostname and press **Enter** or click away to save.
|
||||||
|
- An amber dot (●) will appear on the cell to indicate an override is in place. The original Ivanti value is preserved and can be restored using the revert button (↻).
|
||||||
|
- The same applies to the **DNS** column.
|
||||||
|
|
||||||
|
> Overrides survive Ivanti re-syncs — your corrections are not overwritten when new data is pulled.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 3 — Identify Asset Ownership
|
||||||
|
|
||||||
|
Check the **BU** column to determine ownership.
|
||||||
|
|
||||||
|
| BU Value | Ownership | Action |
|
||||||
|
|---|---|---|
|
||||||
|
| `NTS-AEO-STEAM` | Our team | Continue to Step 4 |
|
||||||
|
| `NTS-AEO-ACCESS-ENG` | Our team | Continue to Step 4 |
|
||||||
|
| Any other value, or blank | Not our asset | Add to CARD queue (see below) |
|
||||||
|
|
||||||
|
**If the asset is not owned by our BU:**
|
||||||
|
|
||||||
|
1. Check the checkbox at the left of the finding row.
|
||||||
|
2. A popover will appear. The **CARD** workflow type should already be selected.
|
||||||
|
- No vendor entry is required for CARD — the IP address is captured automatically for use when searching in CARD.
|
||||||
|
3. Click **Add to Queue**.
|
||||||
|
4. The finding is now staged in your Ivanti Queue under the **CARD** section.
|
||||||
|
|
||||||
|
CARD queue items are processed in a separate session — see the [Ivanti Queue](#5-using-the-ivanti-queue) section and the dedicated CARD process documentation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 4 — Review the CVEs in the Finding
|
||||||
|
|
||||||
|
Each finding has one or more CVEs listed in the **CVEs** column (up to 2 shown; hover the "+N" badge to see the rest).
|
||||||
|
|
||||||
|
For each CVE in the finding:
|
||||||
|
|
||||||
|
1. **Check if the CVE already exists in the database.**
|
||||||
|
- Navigate to the **Home** page.
|
||||||
|
- Search for the CVE ID in the search bar.
|
||||||
|
- If an entry exists for this CVE and vendor, review what's already documented — there may be existing notes, documents, or an Archer ticket already linked.
|
||||||
|
|
||||||
|
2. **If no entry exists, create one:**
|
||||||
|
- Click **Add CVE** on the Home page.
|
||||||
|
- Enter the CVE ID — the NVD auto-fill will populate the description, CVSS severity, and published date automatically.
|
||||||
|
- Select the correct vendor/platform.
|
||||||
|
- Save the entry.
|
||||||
|
|
||||||
|
3. **Research the CVE** to determine the required action:
|
||||||
|
- Check the vendor's security advisory portal (e.g., Juniper Security Advisories, Cisco Security Advisories / Bug Search Tool)
|
||||||
|
- Determine whether the CVE: (a) is a False Positive for this platform/version, (b) can be Remediated, or (c) requires a Risk Acceptance
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 5 — Determine and Execute the Required Action
|
||||||
|
|
||||||
|
Based on your research in Step 4, choose the path below.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Path A — Remediation (Firmware or Software Update)
|
||||||
|
|
||||||
|
> No Archer ticket required if the fix is a firmware or software upgrade.
|
||||||
|
|
||||||
|
1. Plan and schedule the upgrade with the relevant team.
|
||||||
|
2. No dashboard action is required beyond ensuring a note is added to the finding (click the **Notes** cell) confirming the upgrade is planned or complete.
|
||||||
|
3. After the device is upgraded, the finding will fall off the Reporting page on the next Ivanti scan if the vulnerability is no longer detected.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Path B — Remediation (Configuration Change)
|
||||||
|
|
||||||
|
> An Archer Risk Acceptance ticket **is required** when the fix is a configuration change.
|
||||||
|
|
||||||
|
1. Check the checkbox at the left of the finding row.
|
||||||
|
2. In the popover, enter the **Vendor / Platform** (e.g., Juniper, Cisco, ADTRAN).
|
||||||
|
3. Select **Archer** as the workflow type.
|
||||||
|
4. Click **Add to Queue**.
|
||||||
|
5. Process the Archer ticket in a dedicated session — see [Ivanti Queue](#5-using-the-ivanti-queue) and the Archer process documentation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Path C — False Positive
|
||||||
|
|
||||||
|
1. **Collect the required evidence:**
|
||||||
|
- Log into the device and **take a screenshot** showing the hostname, IP address, and software/firmware version.
|
||||||
|
- Obtain vendor documentation confirming the CVE does not affect this platform or version (security advisory, vendor email, etc.).
|
||||||
|
|
||||||
|
2. **Save supporting files to the database:**
|
||||||
|
- Go to the Home page and find (or create) the CVE entry for this vendor.
|
||||||
|
- Upload the screenshot as type `screenshot` and the vendor communication as type `advisory` or `email`.
|
||||||
|
- This ensures the evidence is accessible when the FP expires and needs to be renewed.
|
||||||
|
|
||||||
|
3. **Stage the finding in the queue:**
|
||||||
|
- Check the checkbox at the left of the finding row on the Reporting page.
|
||||||
|
- Enter the **Vendor / Platform**.
|
||||||
|
- Select **FP** as the workflow type.
|
||||||
|
- Click **Add to Queue**.
|
||||||
|
|
||||||
|
4. **Open the False Positive workflow in Ivanti:**
|
||||||
|
- Process queued FP items in a dedicated session.
|
||||||
|
- See the dedicated FP workflow documentation for the full Ivanti submission steps.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Path D — Risk Acceptance (Archer Ticket)
|
||||||
|
|
||||||
|
1. **Collect information** as you would for a False Positive (device screenshot, version info).
|
||||||
|
2. If vendor communication is required (patch timeline, EOL statement, recommended mitigations):
|
||||||
|
- Open a vendor support ticket requesting remediation steps, configuration guidance, or a patch commitment date.
|
||||||
|
- Use the vendor's response to fill out the Archer remediation plan.
|
||||||
|
3. **Stage the finding in the queue:**
|
||||||
|
- Check the checkbox on the finding row.
|
||||||
|
- Enter the **Vendor / Platform**.
|
||||||
|
- Select **Archer** as the workflow type.
|
||||||
|
- Click **Add to Queue**.
|
||||||
|
4. **Open the Archer Risk Acceptance ticket:**
|
||||||
|
- Process queued Archer items in a dedicated session.
|
||||||
|
- See the dedicated Archer process documentation for the full submission steps.
|
||||||
|
5. Once the EXC number is assigned, enter it in the finding's **Notes** cell on the Reporting page (format: `EXC-XXXXX`). The dashboard will recognise the pattern and include it in the Action Coverage chart under "Archer Exception".
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Using the Ivanti Queue
|
||||||
|
|
||||||
|
The Ivanti Queue is a personal staging list built into the Reporting page. Rather than interrupting your review to context-switch into Ivanti, you tag findings as you go and then batch-process all the Ivanti work in one focused session.
|
||||||
|
|
||||||
|
### Adding Items to the Queue
|
||||||
|
|
||||||
|
1. On the Reporting page, check the **checkbox at the far left** of any finding row.
|
||||||
|
2. A popover appears anchored to the row.
|
||||||
|
3. For **FP** and **Archer** items: enter the **Vendor / Platform** (free text — e.g., "Juniper MX", "Cisco IOS-XE").
|
||||||
|
4. Select the **workflow type**:
|
||||||
|
- **FP** — False Positive request to be submitted in Ivanti
|
||||||
|
- **Archer** — Archer Risk Acceptance ticket to be opened
|
||||||
|
- **CARD** — Asset not owned by our BU; IP address is captured automatically
|
||||||
|
5. Click **Add to Queue**. The row checkbox turns solid blue to indicate it is queued.
|
||||||
|
|
||||||
|
### Opening the Queue Panel
|
||||||
|
|
||||||
|
Click the **Queue** button in the top-right of the Reporting page. A slide-out panel opens from the right showing all your queued items.
|
||||||
|
|
||||||
|
- **CARD** items appear at the top of the panel in their own green section, with the IP address displayed for easy CARD search.
|
||||||
|
- **FP and Archer** items are grouped alphabetically by vendor/platform below.
|
||||||
|
- Each item shows: Finding ID, CVEs (or IP for CARD), and the workflow type badge (amber = FP, sky = Archer, green = CARD).
|
||||||
|
|
||||||
|
### Working the Queue
|
||||||
|
|
||||||
|
**Marking items complete:**
|
||||||
|
Once you have submitted the FP or Archer ticket in Ivanti (or actioned the CARD item), check the item's green checkbox to mark it complete. Completed items are shown with a strikethrough at reduced opacity.
|
||||||
|
|
||||||
|
**Deleting items:**
|
||||||
|
- Click the trash icon on an individual item to remove it.
|
||||||
|
- To remove multiple items at once: check the small red selection checkbox on the left of each item you want to remove, then click **Delete (N)** in the footer.
|
||||||
|
|
||||||
|
**Clearing completed items:**
|
||||||
|
Click **Clear Completed** in the footer to remove all marked-complete items at once.
|
||||||
|
|
||||||
|
> Queue items are stored in the database and are **personal to your login** — they persist across sessions and page refreshes. Other team members see only their own queue.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Workflow Status Reference
|
||||||
|
|
||||||
|
The **Workflow** column on the Reporting page tracks FP# tickets — False Positive requests submitted in Ivanti. The badge shows the ticket ID and its current state, colour-coded by urgency.
|
||||||
|
|
||||||
|
> SYS# workflows are auto-generated system tracking records. They are not displayed and do not require team action.
|
||||||
|
|
||||||
|
### Status Colour Codes
|
||||||
|
|
||||||
|
#### 🔴 Red — Act Immediately
|
||||||
|
|
||||||
|
| State | Meaning | Required Action |
|
||||||
|
|---|---|---|
|
||||||
|
| **Expired** | An FP# ticket existed but the exception window has lapsed. The finding has re-opened. | Log into Ivanti and submit a **new FP request** for this finding. Reference the previous ticket if relevant. |
|
||||||
|
| **Rejected** | The security team reviewed the FP and denied it. The finding is a confirmed, exploitable vulnerability. | **Remediate the vulnerability.** Apply the relevant patch, configuration change, or compensating control. Do not resubmit an FP without new evidence. |
|
||||||
|
|
||||||
|
#### 🟡 Amber — Action Required Soon
|
||||||
|
|
||||||
|
| State | Meaning | Required Action |
|
||||||
|
|---|---|---|
|
||||||
|
| **Reworked** | The FP request was challenged by the reviewer and returned for revision. | Open the ticket in Ivanti, review the feedback, update the justification, and **resubmit**. |
|
||||||
|
| **Actionable** | The FP ticket has been flagged as needing team action. | Open the ticket in Ivanti and respond to what is required. |
|
||||||
|
|
||||||
|
#### 🔵 Blue — In Flight, Monitor
|
||||||
|
|
||||||
|
| State | Meaning | Required Action |
|
||||||
|
|---|---|---|
|
||||||
|
| **Requested** | An FP# ticket has been submitted and is awaiting security team approval. | No immediate action. Monitor for approval or rejection. If the SLA window is approaching with no response, follow up with the approver. |
|
||||||
|
|
||||||
|
#### — (No Badge) — Untriaged
|
||||||
|
|
||||||
|
| State | Meaning | Required Action |
|
||||||
|
|---|---|---|
|
||||||
|
| **No workflow badge** | No FP ticket has ever been submitted for this finding. | Triage the finding using the workflow in Section 4. Determine whether to remediate, submit an FP, or open an Archer ticket. |
|
||||||
|
|
||||||
|
### Decision Flowchart
|
||||||
|
|
||||||
|
```
|
||||||
|
Finding appears in Reporting page
|
||||||
|
│
|
||||||
|
├── Check the Workflow column
|
||||||
|
│
|
||||||
|
├── No badge (—)
|
||||||
|
│ └── Triage → follow Section 4 workflow
|
||||||
|
│
|
||||||
|
└── Has a badge → check the colour:
|
||||||
|
│
|
||||||
|
├── 🔵 BLUE (Requested)
|
||||||
|
│ └── Monitor. Follow up if SLA window is approaching.
|
||||||
|
│
|
||||||
|
├── 🟡 AMBER (Reworked / Actionable)
|
||||||
|
│ └── Open Ivanti ticket → review feedback → update → resubmit
|
||||||
|
│
|
||||||
|
└── 🔴 RED
|
||||||
|
│
|
||||||
|
├── Expired → Submit a new FP request in Ivanti
|
||||||
|
│
|
||||||
|
└── Rejected → Remediate the vulnerability
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Quick Reference Card
|
||||||
|
|
||||||
|
### Action Decision Matrix
|
||||||
|
|
||||||
|
| Research Outcome | Config Change? | Action Required |
|
||||||
|
|---|---|---|
|
||||||
|
| Can be patched (firmware/software) | N/A | Upgrade device — no ticket needed |
|
||||||
|
| Can be patched (configuration change only) | Yes | Archer Risk Acceptance ticket (EXC-XXXXX) |
|
||||||
|
| False Positive — not applicable to platform/version | N/A | FP workflow in Ivanti + evidence in CVE database |
|
||||||
|
| Cannot be patched — patch pending from vendor | N/A | Archer Risk Acceptance ticket (renew when patched) |
|
||||||
|
| Cannot be patched — EOL/EOS device | N/A | Archer ticket with mitigation steps + remediation plan |
|
||||||
|
| Asset not owned by our BU | N/A | CARD queue → CARD asset disposition process |
|
||||||
|
|
||||||
|
### Workflow Badge Quick Reference
|
||||||
|
|
||||||
|
| Badge | State | One-Line Action |
|
||||||
|
|---|---|---|
|
||||||
|
| 🔴 Red | Expired | Renew FP request in Ivanti |
|
||||||
|
| 🔴 Red | Rejected | Remediate the vulnerability |
|
||||||
|
| 🟡 Amber | Reworked | Update and resubmit FP ticket |
|
||||||
|
| 🟡 Amber | Actionable | Review ticket in Ivanti and respond |
|
||||||
|
| 🔵 Blue | Requested | Monitor — no action yet |
|
||||||
|
| — | No badge | Triage: follow Section 4 |
|
||||||
|
|
||||||
|
### Dashboard Shortcut Reference
|
||||||
|
|
||||||
|
| Task | How |
|
||||||
|
|---|---|
|
||||||
|
| See only untriaged findings | Click **Pending** segment on Action Coverage chart |
|
||||||
|
| See findings due this week | Click a date on the Home page calendar widget |
|
||||||
|
| See all findings for a specific Archer ticket | Click the EXC badge on the Home page CVE row |
|
||||||
|
| Correct a wrong hostname | Click the Host cell inline on the Reporting page |
|
||||||
|
| Save a screenshot or advisory to a CVE | Home page → CVE row → Upload document |
|
||||||
|
| Stage findings for a batch FP/Archer session | Use the Ivanti Queue (checkbox column on Reporting page) |
|
||||||
|
| Filter to a specific vendor or SLA status | Click the filter icon (⊙) on the relevant column header |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Related documentation: FP Workflow Submission (Ivanti) · Archer Risk Acceptance Process · CARD Asset Disposition Process · MOP: Workflow Status Colour Codes*
|
||||||
158
docs/team-training-agenda.md
Normal file
158
docs/team-training-agenda.md
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
# STEAM Security Dashboard — Team Training Agenda
|
||||||
|
|
||||||
|
**Session length:** 30–40 minutes
|
||||||
|
**Format:** Live walkthrough (share your screen on the dashboard)
|
||||||
|
**Reference docs:** `security-posture-workflow.md` for full detail on anything covered here
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pre-meeting prep
|
||||||
|
|
||||||
|
- Have the dashboard open and logged in before the meeting starts
|
||||||
|
- Sync Vulnerability Triage page so data is fresh when you get there
|
||||||
|
- Print or share `security-posture-workflow.md` as a take-home reference
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Segment 1 — Why this tool exists (3 min)
|
||||||
|
|
||||||
|
**Talking points:**
|
||||||
|
- We have open Ivanti findings in the 8.5–9.9 VRR range — these are the ones we own and are accountable for
|
||||||
|
- Every finding needs a documented action within **60 days of detection** (the SLA rule)
|
||||||
|
- Findings that age past their Due Date make a device non-compliant in AEO posture reporting
|
||||||
|
- This dashboard is how we track, triage, and prove we've actioned everything — replaces manual spreadsheet tracking
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Segment 2 — Dashboard orientation (4 min)
|
||||||
|
|
||||||
|
**Show on screen:** Navigate through each page in the nav drawer
|
||||||
|
|
||||||
|
- **Home (CVE Management)** — our CVE research library; this is where we store screenshots, advisories, and Archer EXC numbers against each CVE/vendor pair
|
||||||
|
- **Vulnerability Triage (Host Findings)** — the daily operational page; this is where you spend most of your time
|
||||||
|
- **Compliance** — AEO posture data uploaded from the NTS_AEO xlsx; shows metric health per team
|
||||||
|
- **Knowledge Base** — internal docs, runbooks, advisories
|
||||||
|
- **Exports** — bulk data extracts when needed
|
||||||
|
|
||||||
|
> Tell the team: *"The Vulnerability Triage page is what we'll focus on today — that's where the workflow lives."*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Segment 3 — The three things you can do with a finding (5 min)
|
||||||
|
|
||||||
|
**Talking points — before showing the table, set context:**
|
||||||
|
|
||||||
|
Every finding in our range gets one of three designations:
|
||||||
|
|
||||||
|
1. **Remediation** — you fix the root cause
|
||||||
|
- Firmware/software upgrade → no ticket needed, finding drops off on next scan
|
||||||
|
- Configuration change → **Archer EXC ticket required** (if the config is ever rolled back, the vulnerability comes back — the ticket documents that we know)
|
||||||
|
|
||||||
|
2. **False Positive (FP)** — the scanner flagged something that doesn't actually apply to our platform or version
|
||||||
|
- Requires an FP workflow opened in Ivanti
|
||||||
|
- Evidence requirements: (a) **screenshot from the device** showing hostname, IP, and SW version — CLI text is not accepted; (b) vendor documentation (advisory, email, support ticket) confirming it doesn't affect us
|
||||||
|
- Upload evidence to the CVE database on the Home page so we can reuse it when the FP expires
|
||||||
|
|
||||||
|
3. **Risk Acceptance (Archer EXC)** — we can't patch, for a documented reason
|
||||||
|
- Vendor hasn't released a patch yet
|
||||||
|
- Device is EOL/EOS — needs mitigation steps + remediation plan in the ticket
|
||||||
|
- Business constraint — needs justification and compensating controls
|
||||||
|
- Format: enter `EXC-XXXXX` in the finding's Notes cell after the ticket is created
|
||||||
|
|
||||||
|
> Tell the team: *"Knowing which path you're on before you touch the dashboard makes triage fast. The workflow is just deciding which of these three it is."*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Segment 4 — The 5-step workflow on the Vulnerability Triage page (15 min)
|
||||||
|
|
||||||
|
**Show on screen:** Vulnerability Triage page, live walkthrough on a real finding
|
||||||
|
|
||||||
|
### Step 1 — Sync and sort (1 min)
|
||||||
|
- Click **Sync** top-right, wait for timestamp to update
|
||||||
|
- Click **Due Date** column to sort ascending — reds first, then ambers
|
||||||
|
- Red = overdue, Amber = due within 30 days — work these first
|
||||||
|
|
||||||
|
### Step 2 — Identify the host (3 min)
|
||||||
|
- Use the **IP address** in the row to verify the hostname in Infoblox (preferred) or IPControl
|
||||||
|
- If Ivanti has a stale hostname: click the **Host cell** directly in the table — it's inline editable
|
||||||
|
- An amber dot appears on overridden cells; original value is preserved and can be restored
|
||||||
|
- Show the revert button (↻) so they know corrections aren't permanent unless they want them to be
|
||||||
|
|
||||||
|
### Step 3 — Check who owns the asset (2 min)
|
||||||
|
- Look at the **BU column**
|
||||||
|
- If it's `NTS-AEO-STEAM` or `NTS-AEO-ACCESS-ENG` → our team, continue
|
||||||
|
- Anything else (or blank) → not ours → **CARD queue**
|
||||||
|
- Check the row checkbox, select CARD, click Add to Queue
|
||||||
|
- IP address is captured automatically for the CARD search
|
||||||
|
- Process CARD items in a separate session
|
||||||
|
|
||||||
|
### Step 4 — Look up the CVEs (4 min)
|
||||||
|
- Each row shows up to 2 CVEs; hover the **+N badge** to see more
|
||||||
|
- Go to Home page, search for the CVE ID
|
||||||
|
- If it exists → review existing notes, docs, and any EXC numbers already linked
|
||||||
|
- If not → click **Add CVE**, enter the CVE ID, NVD auto-fill populates the rest
|
||||||
|
- Research: vendor advisory portal (Juniper PSN, Cisco Bug Search) — determine if it's an FP, can be patched, or needs an Archer ticket
|
||||||
|
|
||||||
|
### Step 5 — Take action (5 min)
|
||||||
|
- **Patch available (firmware/SW)** — plan the upgrade, add a note to the finding row, done
|
||||||
|
- **Config change only** — checkbox → Vendor → select **Archer** → Add to Queue → process in Ivanti later
|
||||||
|
- **False Positive** — collect screenshot + vendor doc, upload to Home page CVE entry, then checkbox → Vendor → select **FP** → Add to Queue → submit FP in Ivanti in a separate session
|
||||||
|
- **Can't patch (Archer)** — same as config change path; once EXC number is issued, paste it into the finding's **Notes cell** (`EXC-XXXXX` format)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Segment 5 — The Ivanti Queue (5 min)
|
||||||
|
|
||||||
|
**Show on screen:** Click the Queue button, show the panel
|
||||||
|
|
||||||
|
- **Purpose:** tag findings as you triage, then batch all the Ivanti / Archer work in one focused session instead of context-switching constantly
|
||||||
|
- Three types: **FP** (amber), **Archer** (sky blue), **CARD** (green)
|
||||||
|
- CARD items show the IP address so you can search directly in CARD
|
||||||
|
- Check the green checkbox on an item when the Ivanti/Archer action is done
|
||||||
|
- Multi-select delete: check the small red boxes, click **Delete (N)** in the footer
|
||||||
|
- Queue is **personal to your login** — each person has their own; it persists across sessions
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Segment 6 — Workflow badge colours (3 min)
|
||||||
|
|
||||||
|
**Show on screen:** Workflow column on the Vulnerability Triage table
|
||||||
|
|
||||||
|
Quick rule: **red = act now, amber = act soon, blue = monitor, no badge = needs triage**
|
||||||
|
|
||||||
|
| Badge | What it means | What to do |
|
||||||
|
|---|---|---|
|
||||||
|
| Red — Expired | FP ticket lapsed, finding re-opened | Submit a new FP in Ivanti |
|
||||||
|
| Red — Rejected | Security team denied the FP | Remediate — do not resubmit without new evidence |
|
||||||
|
| Amber — Reworked | Reviewer returned the ticket | Open in Ivanti, update justification, resubmit |
|
||||||
|
| Amber — Actionable | Ticket flagged for team response | Open in Ivanti and respond |
|
||||||
|
| Blue — Requested | FP submitted, awaiting approval | Monitor; follow up if SLA is approaching |
|
||||||
|
| No badge | Never been triaged | Run it through the 5-step workflow |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Segment 7 — Quick tips (2 min)
|
||||||
|
|
||||||
|
Quick features worth pointing out before Q&A:
|
||||||
|
|
||||||
|
- **Filter to untriaged only** — click the **Pending** segment on the Action Coverage donut chart
|
||||||
|
- **Find all findings tied to an Archer ticket** — click the EXC badge on the Home page CVE row
|
||||||
|
- **Filter by vendor, IP, SLA status** — click the filter icon (⊙) on any column header
|
||||||
|
- **Save evidence once, reuse it** — uploading screenshots/advisories to the CVE database means when an FP expires you already have the files
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Segment 8 — Q&A (remaining time)
|
||||||
|
|
||||||
|
Suggested prompts to open discussion if no questions come up:
|
||||||
|
- *"Walk me through what you'd do if you saw a red 'Rejected' badge on a finding."*
|
||||||
|
- *"When would you use the Ivanti Queue versus just actioning something immediately?"*
|
||||||
|
- *"What's the difference between Path B (config change) and Path D (risk acceptance) — when does each apply?"*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Takeaway for the team
|
||||||
|
|
||||||
|
Point them to:
|
||||||
|
- `docs/security-posture-workflow.md` — the full process guide with all the steps, evidence requirements, and decision matrix
|
||||||
|
- `docs/security-posture-workflow-diagrams.md` — the Mermaid flowcharts if they're visual learners
|
||||||
333
docs/time-based-reporting-recommendations.md
Normal file
333
docs/time-based-reporting-recommendations.md
Normal file
@@ -0,0 +1,333 @@
|
|||||||
|
# Time-Based Reporting Recommendations
|
||||||
|
**Date:** 2026-04-02
|
||||||
|
**Author:** Engineering (Claude Code)
|
||||||
|
**Status:** Draft — for director review
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Executive Summary
|
||||||
|
|
||||||
|
This document analyzes the current CVE Dashboard data model and recommends a set of time-based visualizations that can be added to the Reporting page. Recommendations are grouped by feasibility: **Tier 1** can be built with data already in the database, **Tier 2** requires a lightweight new tracking table, and **Tier 3** requires structural additions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Current Data Inventory
|
||||||
|
|
||||||
|
### What Already Has Time-Series History
|
||||||
|
|
||||||
|
| Source | Table | Date Fields | History? |
|
||||||
|
|--------|-------|-------------|----------|
|
||||||
|
| Compliance uploads | `compliance_uploads` | `report_date`, `uploaded_at` | **Yes** — one row per report cycle |
|
||||||
|
| Compliance items | `compliance_items` | `created_at`, `first_seen_upload_id`, `resolved_upload_id` | **Yes** — tracks lifecycle |
|
||||||
|
| Archer tickets | `archer_tickets` | `created_at`, `updated_at` | **Yes** — full history |
|
||||||
|
| Todo queue | `ivanti_todo_queue` | `created_at`, `updated_at` | **Yes** — by action |
|
||||||
|
| Finding notes | `ivanti_finding_notes` | `updated_at` | **Yes** — note activity |
|
||||||
|
|
||||||
|
### What Is Point-in-Time Only (no history yet)
|
||||||
|
|
||||||
|
| Source | Table | Problem |
|
||||||
|
|--------|-------|---------|
|
||||||
|
| Ivanti findings | `ivanti_findings_cache` | Single-row cache — overwritten on every sync |
|
||||||
|
| Ivanti counts | `ivanti_counts_cache` | Single-row cache — no snapshots stored |
|
||||||
|
| FP workflow states | Computed from `findings_json` | Ephemeral — not persisted historically |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tier 1 Recommendations — Build Now (No Schema Changes)
|
||||||
|
|
||||||
|
All of these use data that is already in the database.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 1.1 Compliance Trend Line — Total Active Findings Over Time
|
||||||
|
|
||||||
|
**Description:** A line chart showing the total number of active (non-compliant) items per compliance upload date. This directly answers "are we improving over time?"
|
||||||
|
|
||||||
|
**Data Source:**
|
||||||
|
```sql
|
||||||
|
SELECT
|
||||||
|
cu.report_date,
|
||||||
|
COUNT(ci.id) AS active_count
|
||||||
|
FROM compliance_uploads cu
|
||||||
|
JOIN compliance_items ci ON ci.upload_id = cu.id AND ci.status = 'active'
|
||||||
|
GROUP BY cu.id
|
||||||
|
ORDER BY cu.report_date ASC;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Chart Type:** Line chart with data points per upload
|
||||||
|
**Axes:** X = Report Date, Y = Number of Active Findings
|
||||||
|
**Value-add:** Overlay a trend line (linear regression) to show trajectory
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 1.2 New / Recurring / Resolved Bar Chart — Per Report Cycle
|
||||||
|
|
||||||
|
**Description:** A grouped or stacked bar chart showing the delta breakdown for each compliance upload: how many findings were newly introduced, how many recurred from a prior cycle, and how many were resolved.
|
||||||
|
|
||||||
|
**Data Source:** Already computed and stored in `compliance_uploads`:
|
||||||
|
```sql
|
||||||
|
SELECT report_date, new_count, recurring_count, resolved_count
|
||||||
|
FROM compliance_uploads
|
||||||
|
ORDER BY report_date ASC;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Chart Type:** Stacked bar chart (one bar per upload date)
|
||||||
|
**Legend:** New (red/amber), Recurring (yellow), Resolved (green)
|
||||||
|
**Value-add:** Shows whether each reporting cycle is improving (more resolved than new) or degrading
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 1.3 Team Compliance Health Over Time — Multi-Line Chart
|
||||||
|
|
||||||
|
**Description:** A multi-line chart showing the active finding count per team per upload date. Answers "which team is trending better or worse?"
|
||||||
|
|
||||||
|
**Data Source:**
|
||||||
|
```sql
|
||||||
|
SELECT
|
||||||
|
cu.report_date,
|
||||||
|
ci.team,
|
||||||
|
COUNT(ci.id) AS active_count
|
||||||
|
FROM compliance_uploads cu
|
||||||
|
JOIN compliance_items ci ON ci.upload_id = cu.id AND ci.status = 'active'
|
||||||
|
GROUP BY cu.id, ci.team
|
||||||
|
ORDER BY cu.report_date ASC;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Chart Type:** Multi-line chart (one line per team)
|
||||||
|
**Teams:** STEAM, ACCESS-ENG, ACCESS-OPS, INTELDEV
|
||||||
|
**Value-add:** Immediately visible which team is outlier or improving fastest
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 1.4 Mean Time to Resolution (MTTR) — Per Team
|
||||||
|
|
||||||
|
**Description:** A bar chart showing average number of upload cycles between when a finding first appeared and when it was resolved, broken out by team.
|
||||||
|
|
||||||
|
**Data Source:**
|
||||||
|
```sql
|
||||||
|
SELECT
|
||||||
|
ci.team,
|
||||||
|
AVG(ci.resolved_upload_id - ci.first_seen_upload_id) AS avg_cycles_to_resolve,
|
||||||
|
COUNT(*) AS resolved_count
|
||||||
|
FROM compliance_items ci
|
||||||
|
WHERE ci.resolved_upload_id IS NOT NULL
|
||||||
|
GROUP BY ci.team;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Chart Type:** Horizontal bar chart
|
||||||
|
**Axes:** Y = Team, X = Average Cycles to Resolution
|
||||||
|
**Value-add:** Normalize to calendar days by joining with upload dates for true MTTR in days
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 1.5 Recurring Findings Heatmap — Seen Count Distribution
|
||||||
|
|
||||||
|
**Description:** A heatmap or bubble chart showing findings grouped by how many times they have recurred (`seen_count`). Identifies chronic, long-standing compliance gaps.
|
||||||
|
|
||||||
|
**Data Source:**
|
||||||
|
```sql
|
||||||
|
SELECT
|
||||||
|
team,
|
||||||
|
metric_id,
|
||||||
|
metric_desc,
|
||||||
|
seen_count,
|
||||||
|
COUNT(*) AS host_count
|
||||||
|
FROM compliance_items
|
||||||
|
WHERE status = 'active'
|
||||||
|
GROUP BY team, metric_id
|
||||||
|
ORDER BY seen_count DESC;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Chart Type:** Horizontal bar chart sorted by `seen_count`, grouped by team
|
||||||
|
**Value-add:** Highlights the "chronic" findings that repeatedly appear — high priority for remediation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 1.6 Archer Exception Ticket Status Over Time
|
||||||
|
|
||||||
|
**Description:** A line chart or cumulative area chart showing Archer ticket status transitions over time using `created_at` and `updated_at`.
|
||||||
|
|
||||||
|
**Data Source:**
|
||||||
|
```sql
|
||||||
|
SELECT
|
||||||
|
DATE(created_at) AS date,
|
||||||
|
status,
|
||||||
|
COUNT(*) AS count
|
||||||
|
FROM archer_tickets
|
||||||
|
GROUP BY DATE(created_at), status
|
||||||
|
ORDER BY date ASC;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Chart Type:** Stacked area chart
|
||||||
|
**Statuses:** Draft, Open, Under Review, Accepted
|
||||||
|
**Value-add:** Tracks exception request pipeline velocity — are exceptions getting processed or stacking up?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 1.7 Compliance Category Breakdown Over Time
|
||||||
|
|
||||||
|
**Description:** A stacked area chart showing what categories of compliance failures are driving the total over time (if the `category` field in `compliance_items` is populated).
|
||||||
|
|
||||||
|
**Data Source:**
|
||||||
|
```sql
|
||||||
|
SELECT
|
||||||
|
cu.report_date,
|
||||||
|
ci.category,
|
||||||
|
COUNT(ci.id) AS count
|
||||||
|
FROM compliance_uploads cu
|
||||||
|
JOIN compliance_items ci ON ci.upload_id = cu.id AND ci.status = 'active'
|
||||||
|
WHERE ci.category IS NOT NULL
|
||||||
|
GROUP BY cu.id, ci.category
|
||||||
|
ORDER BY cu.report_date ASC;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Chart Type:** Stacked area chart
|
||||||
|
**Value-add:** Shows whether one category dominates or if failures are spread across areas
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tier 2 Recommendations — Lightweight Schema Addition Required
|
||||||
|
|
||||||
|
These require adding one new table to persist snapshots of data that is currently overwritten on each sync.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.1 Ivanti Findings Count Over Time — Open vs Closed Trend
|
||||||
|
|
||||||
|
**Description:** The single most-requested metric: "are we making progress on vulnerabilities?" A line chart showing open and closed Ivanti finding counts over time.
|
||||||
|
|
||||||
|
**Problem:** The current `ivanti_counts_cache` is a single-row table overwritten on each sync. No history is kept.
|
||||||
|
|
||||||
|
**Solution:** Add a `ivanti_counts_history` table and append a row on every successful sync:
|
||||||
|
```sql
|
||||||
|
CREATE TABLE ivanti_counts_history (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
open_count INTEGER NOT NULL,
|
||||||
|
closed_count INTEGER NOT NULL,
|
||||||
|
recorded_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Backend change:** In the sync route (`POST /api/ivanti/findings/sync`), after updating the cache, also `INSERT INTO ivanti_counts_history`.
|
||||||
|
|
||||||
|
**New API endpoint:** `GET /api/ivanti/findings/counts/history`
|
||||||
|
```sql
|
||||||
|
SELECT open_count, closed_count, recorded_at
|
||||||
|
FROM ivanti_counts_history
|
||||||
|
ORDER BY recorded_at ASC;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Chart Type:** Dual-line chart
|
||||||
|
**Lines:** Open findings (red), Closed findings (green)
|
||||||
|
**Value-add:** Most direct measure of vulnerability remediation velocity
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.2 FP Workflow State Snapshots Over Time
|
||||||
|
|
||||||
|
**Description:** A stacked area or line chart showing how FP workflow states (Actionable, Requested, Approved, Rejected, Expired) trend over sync cycles.
|
||||||
|
|
||||||
|
**Solution:** Add a `ivanti_fp_workflow_history` table:
|
||||||
|
```sql
|
||||||
|
CREATE TABLE ivanti_fp_workflow_history (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
state TEXT NOT NULL,
|
||||||
|
finding_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
id_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
recorded_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Chart Type:** Stacked area chart
|
||||||
|
**Value-add:** Shows whether FP requests are being worked through or stacking up in "Requested" state
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.3 Todo Queue Velocity — Items Added vs Completed Per Week
|
||||||
|
|
||||||
|
**Description:** A bar chart showing weekly queue throughput (items added vs items marked complete).
|
||||||
|
|
||||||
|
**Data Source:** Already available in `ivanti_todo_queue.created_at` and `updated_at` + `status = 'complete'`:
|
||||||
|
```sql
|
||||||
|
SELECT
|
||||||
|
STRFTIME('%Y-W%W', created_at) AS week,
|
||||||
|
COUNT(*) AS items_added,
|
||||||
|
SUM(CASE WHEN status = 'complete' THEN 1 ELSE 0 END) AS items_completed
|
||||||
|
FROM ivanti_todo_queue
|
||||||
|
GROUP BY week
|
||||||
|
ORDER BY week ASC;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Chart Type:** Grouped bar chart (weekly)
|
||||||
|
**Value-add:** Measures operational pace of the team's workflow action throughput
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tier 3 Recommendations — Structural Additions (Future Consideration)
|
||||||
|
|
||||||
|
These require more significant changes but would provide powerful long-term reporting.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.1 Finding Age / Dwell Time Distribution
|
||||||
|
|
||||||
|
**Description:** A histogram showing how long open findings have been open (age in days). The `lastFoundOn` field exists in the Ivanti findings JSON but is not persisted to a structured table.
|
||||||
|
|
||||||
|
**Requirement:** Parse and store `lastFoundOn` from findings JSON into a structured column during sync.
|
||||||
|
|
||||||
|
**Value-add:** Highlights findings that have been open for 90+ days — high-priority remediation targets.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.2 SLA Breach Trends
|
||||||
|
|
||||||
|
**Description:** Track how many findings breach SLA (Due Date exceeded) over time. Currently SLA status is computed in the frontend on-the-fly.
|
||||||
|
|
||||||
|
**Requirement:** Add SLA breach tracking during sync — stamp findings that cross SLA date.
|
||||||
|
|
||||||
|
**Value-add:** Compliance and audit reporting for SLA adherence metrics.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Recommended Implementation Order
|
||||||
|
|
||||||
|
| Priority | Chart | Effort | Impact |
|
||||||
|
|----------|-------|--------|--------|
|
||||||
|
| 1 | 1.2 — New/Recurring/Resolved bar chart | Low (data ready) | High |
|
||||||
|
| 2 | 1.1 — Compliance trend line | Low (data ready) | High |
|
||||||
|
| 3 | 1.3 — Team health multi-line | Low (data ready) | High |
|
||||||
|
| 4 | 2.1 — Ivanti open/closed history | Medium (new table) | Very High |
|
||||||
|
| 5 | 1.4 — MTTR per team | Low (data ready) | Medium |
|
||||||
|
| 6 | 1.6 — Archer ticket pipeline | Low (data ready) | Medium |
|
||||||
|
| 7 | 2.3 — Queue velocity | Low (data ready) | Medium |
|
||||||
|
| 8 | 1.5 — Recurring findings heatmap | Low (data ready) | Medium |
|
||||||
|
| 9 | 2.2 — FP workflow snapshots | Medium (new table) | Medium |
|
||||||
|
| 10 | 1.7 — Category breakdown | Low (data ready) | Low–Medium |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Charting Library Consideration
|
||||||
|
|
||||||
|
The current implementation uses **hand-rolled SVG donut charts** (no external library). For time-series line/bar/area charts, the team should decide:
|
||||||
|
|
||||||
|
| Option | Pros | Cons |
|
||||||
|
|--------|------|------|
|
||||||
|
| **Continue hand-rolled SVG** | Zero dependencies, full style control | Significant effort for axes, labels, tooltips |
|
||||||
|
| **Recharts** (React-native) | Well-matched to React 19, composable, responsive | ~500KB dependency |
|
||||||
|
| **Chart.js via react-chartjs-2** | Mature, widely documented | Less React-idiomatic |
|
||||||
|
| **Lightweight: uPlot or Chart.xkcd** | Very small bundle | Less community support |
|
||||||
|
|
||||||
|
**Recommendation:** Recharts aligns best with the React 19 stack and allows declaring charts as JSX components consistent with the existing code style. It supports all chart types listed above.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes for Director Review
|
||||||
|
|
||||||
|
- All **Tier 1** recommendations can be implemented with zero database migrations — the data is already there.
|
||||||
|
- The **single highest-value addition** is `2.1 — Ivanti open/closed count history`, as it captures the most direct remediation progress metric. It only requires one new table and one line added to the sync handler.
|
||||||
|
- **Compliance charts (1.1–1.5)** will only be meaningful once multiple compliance uploads have been committed. If only 1–2 uploads exist currently, the trend will not show much until more data accumulates — but building the charts now means data will automatically populate them.
|
||||||
|
- All queries listed above have been validated against the actual database schema.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Next step: Review with director, confirm priority order, then schedule sprint for implementation.*
|
||||||
@@ -12,6 +12,7 @@
|
|||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"react-scripts": "5.0.1",
|
"react-scripts": "5.0.1",
|
||||||
|
"recharts": "^3.8.1",
|
||||||
"web-vitals": "^2.1.4",
|
"web-vitals": "^2.1.4",
|
||||||
"xlsx": "^0.18.5"
|
"xlsx": "^0.18.5"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -6,13 +6,12 @@ import UserMenu from './components/UserMenu';
|
|||||||
import UserManagement from './components/UserManagement';
|
import UserManagement from './components/UserManagement';
|
||||||
import AuditLog from './components/AuditLog';
|
import AuditLog from './components/AuditLog';
|
||||||
import NvdSyncModal from './components/NvdSyncModal';
|
import NvdSyncModal from './components/NvdSyncModal';
|
||||||
import KnowledgeBaseModal from './components/KnowledgeBaseModal';
|
|
||||||
import KnowledgeBaseViewer from './components/KnowledgeBaseViewer';
|
|
||||||
import NavDrawer from './components/NavDrawer';
|
import NavDrawer from './components/NavDrawer';
|
||||||
import CalendarWidget from './components/CalendarWidget';
|
import CalendarWidget from './components/CalendarWidget';
|
||||||
import ReportingPage from './components/pages/ReportingPage';
|
import VulnerabilityTriagePage from './components/pages/ReportingPage';
|
||||||
import KnowledgeBasePage from './components/pages/KnowledgeBasePage';
|
import KnowledgeBasePage from './components/pages/KnowledgeBasePage';
|
||||||
import ExportsPage from './components/pages/ExportsPage';
|
import ExportsPage from './components/pages/ExportsPage';
|
||||||
|
import CompliancePage from './components/pages/CompliancePage';
|
||||||
import './App.css';
|
import './App.css';
|
||||||
|
|
||||||
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';
|
||||||
@@ -184,9 +183,6 @@ export default function App() {
|
|||||||
const [showUserManagement, setShowUserManagement] = useState(false);
|
const [showUserManagement, setShowUserManagement] = useState(false);
|
||||||
const [showAuditLog, setShowAuditLog] = useState(false);
|
const [showAuditLog, setShowAuditLog] = useState(false);
|
||||||
const [showNvdSync, setShowNvdSync] = useState(false);
|
const [showNvdSync, setShowNvdSync] = useState(false);
|
||||||
const [showKnowledgeBase, setShowKnowledgeBase] = useState(false);
|
|
||||||
const [knowledgeBaseArticles, setKnowledgeBaseArticles] = useState([]);
|
|
||||||
const [selectedKBArticle, setSelectedKBArticle] = useState(null);
|
|
||||||
const [newCVE, setNewCVE] = useState({
|
const [newCVE, setNewCVE] = useState({
|
||||||
cve_id: '',
|
cve_id: '',
|
||||||
vendor: '',
|
vendor: '',
|
||||||
@@ -310,19 +306,6 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const fetchKnowledgeBaseArticles = async () => {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${API_BASE}/knowledge-base`, {
|
|
||||||
credentials: 'include'
|
|
||||||
});
|
|
||||||
if (!response.ok) throw new Error('Failed to fetch knowledge base articles');
|
|
||||||
const data = await response.json();
|
|
||||||
setKnowledgeBaseArticles(data);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error fetching knowledge base articles:', err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const fetchJiraTickets = async () => {
|
const fetchJiraTickets = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_BASE}/jira-tickets`, {
|
const response = await fetch(`${API_BASE}/jira-tickets`, {
|
||||||
@@ -441,45 +424,6 @@ export default function App() {
|
|||||||
alert(`Exporting ${selectedDocuments.length} documents for report attachment`);
|
alert(`Exporting ${selectedDocuments.length} documents for report attachment`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleViewKBArticle = async (articleId) => {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${API_BASE}/knowledge-base/${articleId}`, {
|
|
||||||
credentials: 'include'
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) throw new Error('Failed to fetch article');
|
|
||||||
|
|
||||||
const article = await response.json();
|
|
||||||
setSelectedKBArticle(article);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error fetching knowledge base article:', err);
|
|
||||||
setError('Failed to load article');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDownloadKBArticle = async (id, filename) => {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${API_BASE}/knowledge-base/${id}/download`, {
|
|
||||||
credentials: 'include'
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) throw new Error('Download failed');
|
|
||||||
|
|
||||||
const blob = await response.blob();
|
|
||||||
const url = window.URL.createObjectURL(blob);
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = url;
|
|
||||||
a.download = filename;
|
|
||||||
document.body.appendChild(a);
|
|
||||||
a.click();
|
|
||||||
window.URL.revokeObjectURL(url);
|
|
||||||
document.body.removeChild(a);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error downloading knowledge base article:', err);
|
|
||||||
setError('Failed to download document');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAddCVE = async (e) => {
|
const handleAddCVE = async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
try {
|
try {
|
||||||
@@ -915,7 +859,6 @@ export default function App() {
|
|||||||
fetchJiraTickets();
|
fetchJiraTickets();
|
||||||
fetchArcherTickets();
|
fetchArcherTickets();
|
||||||
fetchIvantiWorkflows();
|
fetchIvantiWorkflows();
|
||||||
fetchKnowledgeBaseArticles();
|
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [isAuthenticated]);
|
}, [isAuthenticated]);
|
||||||
@@ -965,14 +908,14 @@ export default function App() {
|
|||||||
currentPage={currentPage}
|
currentPage={currentPage}
|
||||||
onNavigate={(page) => {
|
onNavigate={(page) => {
|
||||||
// Clear contextual filters when navigating directly via the nav drawer
|
// Clear contextual filters when navigating directly via the nav drawer
|
||||||
if (page === 'reporting') { setCalendarFilter(null); setReportingExcFilter(null); }
|
if (page === 'triage') { setCalendarFilter(null); setReportingExcFilter(null); }
|
||||||
setCurrentPage(page);
|
setCurrentPage(page);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{/* Scanning line effect */}
|
{/* Scanning line effect */}
|
||||||
<div className="scan-line"></div>
|
<div className="scan-line"></div>
|
||||||
|
|
||||||
<div className={`${currentPage === 'reporting' ? 'w-full' : 'max-w-7xl mx-auto'} relative z-10`}>
|
<div className={`${currentPage === 'triage' ? 'w-full' : 'max-w-7xl mx-auto'} relative z-10`}>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<div className="flex justify-between items-start mb-6">
|
<div className="flex justify-between items-start mb-6">
|
||||||
@@ -1042,7 +985,8 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Page content */}
|
{/* Page content */}
|
||||||
{currentPage === 'reporting' && <ReportingPage filterDate={calendarFilter} filterEXC={reportingExcFilter} />}
|
{currentPage === 'triage' && <VulnerabilityTriagePage filterDate={calendarFilter} filterEXC={reportingExcFilter} />}
|
||||||
|
{currentPage === 'compliance' && <CompliancePage onNavigate={setCurrentPage} />}
|
||||||
{currentPage === 'knowledge-base' && <KnowledgeBasePage />}
|
{currentPage === 'knowledge-base' && <KnowledgeBasePage />}
|
||||||
{currentPage === 'exports' && <ExportsPage />}
|
{currentPage === 'exports' && <ExportsPage />}
|
||||||
|
|
||||||
@@ -1061,14 +1005,6 @@ export default function App() {
|
|||||||
<NvdSyncModal onClose={() => setShowNvdSync(false)} onSyncComplete={() => fetchCVEs()} />
|
<NvdSyncModal onClose={() => setShowNvdSync(false)} onSyncComplete={() => fetchCVEs()} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Knowledge Base Modal */}
|
|
||||||
{showKnowledgeBase && (
|
|
||||||
<KnowledgeBaseModal
|
|
||||||
onClose={() => setShowKnowledgeBase(false)}
|
|
||||||
onUpdate={fetchKnowledgeBaseArticles}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Add CVE Modal */}
|
{/* Add CVE Modal */}
|
||||||
{showAddCVE && (
|
{showAddCVE && (
|
||||||
<div className="fixed inset-0 modal-overlay flex items-center justify-center z-50 p-4">
|
<div className="fixed inset-0 modal-overlay flex items-center justify-center z-50 p-4">
|
||||||
@@ -1659,89 +1595,10 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Three Column Layout - Home page only */}
|
{/* Two Column Layout - Home page only */}
|
||||||
{currentPage === 'home' && <div className="grid grid-cols-12 gap-6">
|
{currentPage === 'home' && <div className="grid grid-cols-12 gap-6">
|
||||||
{/* LEFT PANEL - Wiki/Knowledge Base */}
|
|
||||||
<div className="col-span-12 lg:col-span-3 space-y-4">
|
|
||||||
<div style={{...STYLES.intelCard, padding: '1.5rem', borderLeft: '3px solid #10B981'}} className="rounded-lg">
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
|
||||||
<h2 style={{ fontSize: '1.125rem', fontWeight: '600', color: '#10B981', marginBottom: '0', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: '0.1em', textShadow: '0 0 12px rgba(16, 185, 129, 0.4)' }}>
|
|
||||||
Knowledge Base
|
|
||||||
</h2>
|
|
||||||
{(user?.role === 'admin' || user?.role === 'editor') && (
|
|
||||||
<button
|
|
||||||
onClick={() => setShowKnowledgeBase(true)}
|
|
||||||
className="intel-button intel-button-small"
|
|
||||||
style={{ fontSize: '0.75rem', padding: '0.375rem 0.75rem' }}
|
|
||||||
title="Manage Knowledge Base"
|
|
||||||
>
|
|
||||||
<Plus className="w-3 h-3" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Knowledge Base Entries */}
|
|
||||||
<div className="space-y-3">
|
|
||||||
{knowledgeBaseArticles.length === 0 ? (
|
|
||||||
<div className="text-center py-8" style={{ color: '#64748B' }}>
|
|
||||||
<FileText className="w-12 h-12 mx-auto mb-2 opacity-50" />
|
|
||||||
<p className="text-sm">No documents yet</p>
|
|
||||||
{(user?.role === 'admin' || user?.role === 'editor') && (
|
|
||||||
<button
|
|
||||||
onClick={() => setShowKnowledgeBase(true)}
|
|
||||||
className="intel-button intel-button-small mt-3"
|
|
||||||
>
|
|
||||||
Add First Document
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
knowledgeBaseArticles.slice(0, 5).map((article) => (
|
|
||||||
<div
|
|
||||||
key={article.id}
|
|
||||||
onClick={() => handleViewKBArticle(article.id)}
|
|
||||||
style={{ background: 'linear-gradient(135deg, rgba(30, 41, 59, 0.85) 0%, rgba(51, 65, 85, 0.75) 100%)', border: '1px solid rgba(16, 185, 129, 0.25)', borderRadius: '0.375rem', padding: '0.75rem', cursor: 'pointer', transition: 'all 0.2s' }}
|
|
||||||
className="hover:border-intel-success"
|
|
||||||
>
|
|
||||||
<h3 className="text-white font-semibold text-sm mb-1 font-mono">{article.title}</h3>
|
|
||||||
{article.description && (
|
|
||||||
<p className="text-gray-400 text-xs mb-2 line-clamp-2">{article.description}</p>
|
|
||||||
)}
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="text-xs text-intel-success font-mono">
|
|
||||||
{new Date(article.created_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}
|
|
||||||
</span>
|
|
||||||
{article.category && article.category !== 'General' && (
|
|
||||||
<span className="text-xs px-2 py-0.5 rounded" style={{ background: 'rgba(16, 185, 129, 0.2)', color: '#10B981' }}>
|
|
||||||
{article.category}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
{knowledgeBaseArticles.length > 5 && (
|
|
||||||
<button
|
|
||||||
onClick={() => setShowKnowledgeBase(true)}
|
|
||||||
className="text-xs text-center w-full py-2"
|
|
||||||
style={{ color: '#10B981' }}
|
|
||||||
>
|
|
||||||
View all {knowledgeBaseArticles.length} documents →
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* CENTER PANEL - Main Content */}
|
{/* CENTER PANEL - Main Content */}
|
||||||
<div className="col-span-12 lg:col-span-6 space-y-4">
|
<div className="col-span-12 lg:col-span-9 space-y-4">
|
||||||
{/* Knowledge Base Viewer */}
|
|
||||||
{selectedKBArticle ? (
|
|
||||||
<KnowledgeBaseViewer
|
|
||||||
article={selectedKBArticle}
|
|
||||||
onClose={() => setSelectedKBArticle(null)}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<>
|
<>
|
||||||
{/* Quick Check */}
|
{/* Quick Check */}
|
||||||
<div style={{...STYLES.intelCard, padding: '1.5rem'}} className="rounded-lg">
|
<div style={{...STYLES.intelCard, padding: '1.5rem'}} className="rounded-lg">
|
||||||
@@ -2214,7 +2071,6 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
{/* End Center Panel */}
|
{/* End Center Panel */}
|
||||||
|
|
||||||
@@ -2229,7 +2085,7 @@ export default function App() {
|
|||||||
<CalendarWidget
|
<CalendarWidget
|
||||||
onDateClick={(dateStr) => {
|
onDateClick={(dateStr) => {
|
||||||
setCalendarFilter(dateStr);
|
setCalendarFilter(dateStr);
|
||||||
setCurrentPage('reporting');
|
setCurrentPage('triage');
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -2335,7 +2191,7 @@ export default function App() {
|
|||||||
</a>
|
</a>
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
<button
|
<button
|
||||||
onClick={() => { setReportingExcFilter(ticket.exc_number); setCurrentPage('reporting'); }}
|
onClick={() => { setReportingExcFilter(ticket.exc_number); setCurrentPage('triage'); }}
|
||||||
title="View findings referencing this ticket"
|
title="View findings referencing this ticket"
|
||||||
className="text-gray-400 hover:text-sky-400 transition-colors"
|
className="text-gray-400 hover:text-sky-400 transition-colors"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { X, Home, BarChart2, BookOpen, Download } from 'lucide-react';
|
import { X, Home, BarChart2, BookOpen, Download, ShieldCheck } from 'lucide-react';
|
||||||
|
|
||||||
const NAV_ITEMS = [
|
const NAV_ITEMS = [
|
||||||
{ id: 'home', label: 'Home', icon: Home, color: '#0EA5E9', description: 'Main dashboard' },
|
{ id: 'home', label: 'Home', icon: Home, color: '#0EA5E9', description: 'Main dashboard' },
|
||||||
{ id: 'reporting', label: 'Reporting', icon: BarChart2,color: '#F59E0B', description: 'Reports & analytics' },
|
{ 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: 'knowledge-base', label: 'Knowledge Base', icon: BookOpen, color: '#10B981', description: 'Articles & documentation' },
|
{ 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: 'exports', label: 'Exports', icon: Download, color: '#8B5CF6', description: 'Export data & reports' },
|
||||||
];
|
];
|
||||||
|
|||||||
424
frontend/src/components/pages/ComplianceChartsPanel.js
Normal file
424
frontend/src/components/pages/ComplianceChartsPanel.js
Normal file
@@ -0,0 +1,424 @@
|
|||||||
|
// ComplianceChartsPanel.js
|
||||||
|
// Tier-1 time-based compliance charts using Recharts.
|
||||||
|
// Charts rendered: Active Findings Over Time, Change per Cycle,
|
||||||
|
// Team Health, MTTR by Team, Persistent Findings, Archer Pipeline.
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useMemo } from 'react';
|
||||||
|
import {
|
||||||
|
LineChart, Line,
|
||||||
|
BarChart, Bar,
|
||||||
|
XAxis, YAxis, CartesianGrid,
|
||||||
|
Tooltip, Legend,
|
||||||
|
ResponsiveContainer,
|
||||||
|
} from 'recharts';
|
||||||
|
import { TrendingUp, ChevronDown, ChevronUp, Loader } from 'lucide-react';
|
||||||
|
|
||||||
|
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
||||||
|
|
||||||
|
const TEAL = '#14B8A6';
|
||||||
|
|
||||||
|
const TEAM_COLORS = {
|
||||||
|
'STEAM': '#0EA5E9',
|
||||||
|
'ACCESS-ENG': '#F59E0B',
|
||||||
|
'ACCESS-OPS': '#8B5CF6',
|
||||||
|
'INTELDEV': '#10B981',
|
||||||
|
};
|
||||||
|
|
||||||
|
const ARCHER_STATUS_COLORS = {
|
||||||
|
'Draft': '#475569',
|
||||||
|
'Open': '#0EA5E9',
|
||||||
|
'Under Review': '#F59E0B',
|
||||||
|
'Accepted': '#10B981',
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Shared style tokens
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const AXIS_STYLE = { fontFamily: 'monospace', fontSize: '0.6rem', fill: '#475569' };
|
||||||
|
const GRID_STYLE = { stroke: 'rgba(255,255,255,0.05)', strokeDasharray: '3 3' };
|
||||||
|
const LEGEND_STYLE = { fontFamily: 'monospace', fontSize: '0.62rem', color: '#64748B' };
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Custom dark tooltip
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function DarkTooltip({ active, payload, label }) {
|
||||||
|
if (!active || !payload?.length) return null;
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
background: 'rgba(10,17,32,0.97)',
|
||||||
|
border: '1px solid rgba(20,184,166,0.3)',
|
||||||
|
borderRadius: '0.375rem',
|
||||||
|
padding: '0.5rem 0.75rem',
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontSize: '0.7rem',
|
||||||
|
minWidth: '130px',
|
||||||
|
}}>
|
||||||
|
<div style={{ color: TEAL, marginBottom: '0.3rem', fontWeight: '700', fontSize: '0.65rem' }}>
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
{payload.map(p => (
|
||||||
|
<div key={p.dataKey} style={{ color: p.color || '#94A3B8', marginTop: '0.125rem', display: 'flex', justifyContent: 'space-between', gap: '0.75rem' }}>
|
||||||
|
<span style={{ opacity: 0.8 }}>{p.name}</span>
|
||||||
|
<span style={{ fontWeight: '700' }}>
|
||||||
|
{typeof p.value === 'number'
|
||||||
|
? Number.isInteger(p.value) ? p.value : p.value.toFixed(1)
|
||||||
|
: p.value}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Chart card wrapper
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function ChartCard({ title, subtitle, children }) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
background: 'linear-gradient(135deg,rgba(30,41,59,0.95) 0%,rgba(15,23,42,0.98) 100%)',
|
||||||
|
border: '1px solid rgba(20,184,166,0.15)',
|
||||||
|
borderRadius: '0.5rem',
|
||||||
|
padding: '1rem 1.125rem 0.875rem',
|
||||||
|
}}>
|
||||||
|
<div style={{ marginBottom: '0.75rem' }}>
|
||||||
|
<div style={{
|
||||||
|
fontFamily: 'monospace', fontSize: '0.72rem', fontWeight: '700',
|
||||||
|
color: '#CBD5E1', textTransform: 'uppercase', letterSpacing: '0.08em',
|
||||||
|
}}>
|
||||||
|
{title}
|
||||||
|
</div>
|
||||||
|
{subtitle && (
|
||||||
|
<div style={{ fontSize: '0.62rem', color: '#334155', marginTop: '0.2rem', fontFamily: 'monospace' }}>
|
||||||
|
{subtitle}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Empty / no-data state
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function NoData({ msg }) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
height: '160px', color: '#334155',
|
||||||
|
fontFamily: 'monospace', fontSize: '0.72rem',
|
||||||
|
border: '1px dashed rgba(20,184,166,0.1)',
|
||||||
|
borderRadius: '0.375rem',
|
||||||
|
}}>
|
||||||
|
{msg || 'No data yet — upload compliance reports to populate this chart'}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Shorten a YYYY-MM-DD string to MM/DD/YY
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function fmtDate(d) {
|
||||||
|
if (!d) return '';
|
||||||
|
const p = d.split('-');
|
||||||
|
if (p.length === 3) return `${p[1]}/${p[2]}/${p[0].slice(2)}`;
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Chart 1 — Active Findings Over Time (line, total + per team)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function ActiveTrendChart({ data }) {
|
||||||
|
if (data.length < 2) return <NoData />;
|
||||||
|
return (
|
||||||
|
<ResponsiveContainer width="100%" height={210}>
|
||||||
|
<LineChart data={data} margin={{ top: 4, right: 12, bottom: 0, left: -12 }}>
|
||||||
|
<CartesianGrid {...GRID_STYLE} />
|
||||||
|
<XAxis dataKey="date" tick={AXIS_STYLE} />
|
||||||
|
<YAxis tick={AXIS_STYLE} allowDecimals={false} />
|
||||||
|
<Tooltip content={<DarkTooltip />} />
|
||||||
|
<Legend wrapperStyle={LEGEND_STYLE} iconSize={8} />
|
||||||
|
<Line
|
||||||
|
type="monotone" dataKey="total_active" name="Total"
|
||||||
|
stroke={TEAL} strokeWidth={2}
|
||||||
|
dot={{ r: 3, fill: TEAL, strokeWidth: 0 }}
|
||||||
|
activeDot={{ r: 5 }}
|
||||||
|
/>
|
||||||
|
{Object.entries(TEAM_COLORS).map(([team, color]) => (
|
||||||
|
<Line
|
||||||
|
key={team}
|
||||||
|
type="monotone" dataKey={team} name={team}
|
||||||
|
stroke={color} strokeWidth={1.5}
|
||||||
|
dot={false} strokeDasharray="5 3"
|
||||||
|
activeDot={{ r: 4, fill: color }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Chart 2 — New / Recurring / Resolved per cycle (stacked + grouped bar)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function DeltaChart({ data }) {
|
||||||
|
if (data.length === 0) return <NoData />;
|
||||||
|
return (
|
||||||
|
<ResponsiveContainer width="100%" height={210}>
|
||||||
|
<BarChart data={data} margin={{ top: 4, right: 12, bottom: 0, left: -12 }}>
|
||||||
|
<CartesianGrid {...GRID_STYLE} />
|
||||||
|
<XAxis dataKey="date" tick={AXIS_STYLE} />
|
||||||
|
<YAxis tick={AXIS_STYLE} allowDecimals={false} />
|
||||||
|
<Tooltip content={<DarkTooltip />} />
|
||||||
|
<Legend wrapperStyle={LEGEND_STYLE} iconSize={8} />
|
||||||
|
<Bar dataKey="new_count" name="New" stackId="in" fill="#EF4444" fillOpacity={0.85} radius={[0,0,0,0]} />
|
||||||
|
<Bar dataKey="recurring_count" name="Recurring" stackId="in" fill="#F59E0B" fillOpacity={0.85} radius={[2,2,0,0]} />
|
||||||
|
<Bar dataKey="resolved_count" name="Resolved" fill="#10B981" fillOpacity={0.8} radius={[2,2,2,2]} />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Chart 3 — Team Health Multi-Line
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function TeamTrendChart({ data }) {
|
||||||
|
if (data.length < 2) return <NoData />;
|
||||||
|
return (
|
||||||
|
<ResponsiveContainer width="100%" height={210}>
|
||||||
|
<LineChart data={data} margin={{ top: 4, right: 12, bottom: 0, left: -12 }}>
|
||||||
|
<CartesianGrid {...GRID_STYLE} />
|
||||||
|
<XAxis dataKey="date" tick={AXIS_STYLE} />
|
||||||
|
<YAxis tick={AXIS_STYLE} allowDecimals={false} />
|
||||||
|
<Tooltip content={<DarkTooltip />} />
|
||||||
|
<Legend wrapperStyle={LEGEND_STYLE} iconSize={8} />
|
||||||
|
{Object.entries(TEAM_COLORS).map(([team, color]) => (
|
||||||
|
<Line
|
||||||
|
key={team}
|
||||||
|
type="monotone" dataKey={team} name={team}
|
||||||
|
stroke={color} strokeWidth={2}
|
||||||
|
dot={{ r: 3, fill: color, strokeWidth: 0 }}
|
||||||
|
activeDot={{ r: 5 }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Chart 4 — MTTR by Team (horizontal bar)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function MttrChart({ data }) {
|
||||||
|
if (data.length === 0) return <NoData msg="No resolved findings yet — MTTR will appear after items are remediated" />;
|
||||||
|
return (
|
||||||
|
<ResponsiveContainer width="100%" height={Math.max(160, data.length * 44 + 40)}>
|
||||||
|
<BarChart data={data} layout="vertical" margin={{ top: 4, right: 48, bottom: 0, left: 4 }}>
|
||||||
|
<CartesianGrid {...GRID_STYLE} />
|
||||||
|
<XAxis type="number" tick={AXIS_STYLE} unit=" d" />
|
||||||
|
<YAxis type="category" dataKey="team" tick={AXIS_STYLE} width={86} />
|
||||||
|
<Tooltip content={<DarkTooltip />} />
|
||||||
|
<Bar dataKey="avg_days" name="Avg Days" fill={TEAL} fillOpacity={0.8} radius={[0, 3, 3, 0]}
|
||||||
|
label={{ position: 'right', style: { ...AXIS_STYLE, fill: '#64748B' }, formatter: v => `${v}d` }}
|
||||||
|
/>
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Chart 5 — Most Persistent Findings (horizontal bar by seen_count)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function RecurringChart({ data }) {
|
||||||
|
if (data.length === 0) return <NoData />;
|
||||||
|
const top10 = data.slice(0, 10).map(r => ({
|
||||||
|
...r,
|
||||||
|
label: r.metric_id.length > 18 ? r.metric_id.slice(0, 18) + '…' : r.metric_id,
|
||||||
|
}));
|
||||||
|
return (
|
||||||
|
<ResponsiveContainer width="100%" height={Math.max(160, top10.length * 28 + 40)}>
|
||||||
|
<BarChart data={top10} layout="vertical" margin={{ top: 4, right: 48, bottom: 0, left: 4 }}>
|
||||||
|
<CartesianGrid {...GRID_STYLE} />
|
||||||
|
<XAxis type="number" tick={AXIS_STYLE} unit=" cycles" allowDecimals={false} />
|
||||||
|
<YAxis type="category" dataKey="label" tick={AXIS_STYLE} width={110} />
|
||||||
|
<Tooltip content={<DarkTooltip />} formatter={(val, name, props) => [
|
||||||
|
`${val} cycles (${props.payload.host_count} host${props.payload.host_count !== 1 ? 's' : ''})`, props.payload.team
|
||||||
|
]} />
|
||||||
|
<Bar dataKey="seen_count" name="Cycles Seen" fill="#F59E0B" fillOpacity={0.85} radius={[0, 3, 3, 0]}
|
||||||
|
label={{ position: 'right', style: { ...AXIS_STYLE, fill: '#64748B' }, formatter: v => `${v}×` }}
|
||||||
|
/>
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Chart 6 — Archer Exception Ticket Pipeline (stacked bar by creation date)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function ArcherPipelineChart({ data }) {
|
||||||
|
if (data.length === 0) return <NoData msg="No Archer tickets recorded yet" />;
|
||||||
|
return (
|
||||||
|
<ResponsiveContainer width="100%" height={210}>
|
||||||
|
<BarChart data={data} margin={{ top: 4, right: 12, bottom: 0, left: -12 }}>
|
||||||
|
<CartesianGrid {...GRID_STYLE} />
|
||||||
|
<XAxis dataKey="date" tick={AXIS_STYLE} />
|
||||||
|
<YAxis tick={AXIS_STYLE} allowDecimals={false} />
|
||||||
|
<Tooltip content={<DarkTooltip />} />
|
||||||
|
<Legend wrapperStyle={LEGEND_STYLE} iconSize={8} />
|
||||||
|
{Object.entries(ARCHER_STATUS_COLORS).map(([status, color], i, arr) => (
|
||||||
|
<Bar
|
||||||
|
key={status}
|
||||||
|
dataKey={status} name={status} stackId="s"
|
||||||
|
fill={color} fillOpacity={0.85}
|
||||||
|
radius={i === arr.length - 1 ? [2, 2, 0, 0] : [0, 0, 0, 0]}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Main panel
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
export default function ComplianceChartsPanel() {
|
||||||
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [trends, setTrends] = useState([]);
|
||||||
|
const [mttr, setMttr] = useState([]);
|
||||||
|
const [recurring, setRecurring] = useState([]);
|
||||||
|
const [archerRaw, setArcherRaw] = useState([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
const load = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const [tRes, mRes, rRes, aRes] = await Promise.all([
|
||||||
|
fetch(`${API_BASE}/compliance/trends`, { credentials: 'include' }),
|
||||||
|
fetch(`${API_BASE}/compliance/mttr`, { credentials: 'include' }),
|
||||||
|
fetch(`${API_BASE}/compliance/top-recurring`, { credentials: 'include' }),
|
||||||
|
fetch(`${API_BASE}/archer-tickets/status-trend`, { credentials: 'include' }),
|
||||||
|
]);
|
||||||
|
if (cancelled) return;
|
||||||
|
if (tRes.ok) { const d = await tRes.json(); setTrends(d.trends || []); }
|
||||||
|
if (mRes.ok) { const d = await mRes.json(); setMttr(d.mttr || []); }
|
||||||
|
if (rRes.ok) { const d = await rRes.json(); setRecurring(d.items || []); }
|
||||||
|
if (aRes.ok) { const d = await aRes.json(); setArcherRaw(d.statusTrend || []); }
|
||||||
|
} catch { /* silent — charts will show no-data state */ }
|
||||||
|
finally { if (!cancelled) setLoading(false); }
|
||||||
|
};
|
||||||
|
load();
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Format trend rows — add short date label
|
||||||
|
const formattedTrends = useMemo(
|
||||||
|
() => trends.map(t => ({ ...t, date: fmtDate(t.report_date) })),
|
||||||
|
[trends]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Pivot archer raw rows → one object per date
|
||||||
|
const archerByDate = useMemo(() => {
|
||||||
|
if (!archerRaw.length) return [];
|
||||||
|
const map = {};
|
||||||
|
archerRaw.forEach(r => {
|
||||||
|
if (!map[r.date]) map[r.date] = { date: fmtDate(r.date) };
|
||||||
|
map[r.date][r.status] = r.count;
|
||||||
|
});
|
||||||
|
return Object.values(map).sort((a, b) => a.date.localeCompare(b.date));
|
||||||
|
}, [archerRaw]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ marginBottom: '1.5rem' }}>
|
||||||
|
|
||||||
|
{/* ── Section header / collapse toggle ──────────────────────── */}
|
||||||
|
<button
|
||||||
|
onClick={() => setCollapsed(c => !c)}
|
||||||
|
style={{
|
||||||
|
width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||||
|
background: 'none', border: 'none', cursor: 'pointer',
|
||||||
|
padding: '0 0 0.625rem 0',
|
||||||
|
borderBottom: collapsed ? 'none' : '1px solid rgba(20,184,166,0.1)',
|
||||||
|
marginBottom: collapsed ? 0 : '0.875rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||||
|
<TrendingUp style={{ width: '14px', height: '14px', color: TEAL }} />
|
||||||
|
<span style={{
|
||||||
|
fontFamily: 'monospace', fontSize: '0.65rem', fontWeight: '700',
|
||||||
|
color: '#334155', textTransform: 'uppercase', letterSpacing: '0.1em',
|
||||||
|
}}>
|
||||||
|
Historical Trends
|
||||||
|
</span>
|
||||||
|
{loading && (
|
||||||
|
<Loader style={{ width: '12px', height: '12px', color: '#334155', animation: 'spin 1s linear infinite' }} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{collapsed
|
||||||
|
? <ChevronDown style={{ width: '14px', height: '14px', color: '#334155' }} />
|
||||||
|
: <ChevronUp style={{ width: '14px', height: '14px', color: '#334155' }} />
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{!collapsed && (
|
||||||
|
<div style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(auto-fit, minmax(420px, 1fr))',
|
||||||
|
gap: '1rem',
|
||||||
|
}}>
|
||||||
|
|
||||||
|
{/* 1. Active findings over time */}
|
||||||
|
<ChartCard
|
||||||
|
title="Active Findings Over Time"
|
||||||
|
subtitle="Total non-compliant items per report cycle (solid) + per team (dashed)"
|
||||||
|
>
|
||||||
|
<ActiveTrendChart data={formattedTrends} />
|
||||||
|
</ChartCard>
|
||||||
|
|
||||||
|
{/* 2. New / Recurring / Resolved delta per cycle */}
|
||||||
|
<ChartCard
|
||||||
|
title="Change per Report Cycle"
|
||||||
|
subtitle="New (red) and recurring (amber) stacked; resolved (green) as separate bars"
|
||||||
|
>
|
||||||
|
<DeltaChart data={formattedTrends} />
|
||||||
|
</ChartCard>
|
||||||
|
|
||||||
|
{/* 3. Team health multi-line */}
|
||||||
|
<ChartCard
|
||||||
|
title="Team Compliance Health"
|
||||||
|
subtitle="Active findings per team per cycle — lower is better"
|
||||||
|
>
|
||||||
|
<TeamTrendChart data={formattedTrends} />
|
||||||
|
</ChartCard>
|
||||||
|
|
||||||
|
{/* 4. MTTR per team */}
|
||||||
|
<ChartCard
|
||||||
|
title="Mean Time to Resolution"
|
||||||
|
subtitle="Average calendar days between first-seen and resolved, by team"
|
||||||
|
>
|
||||||
|
<MttrChart data={mttr} />
|
||||||
|
</ChartCard>
|
||||||
|
|
||||||
|
{/* 5. Most persistent / recurring findings */}
|
||||||
|
<ChartCard
|
||||||
|
title="Most Persistent Findings"
|
||||||
|
subtitle="Active items with the highest recurrence count (top 10)"
|
||||||
|
>
|
||||||
|
<RecurringChart data={recurring} />
|
||||||
|
</ChartCard>
|
||||||
|
|
||||||
|
{/* 6. Archer ticket pipeline */}
|
||||||
|
<ChartCard
|
||||||
|
title="Archer Exception Pipeline"
|
||||||
|
subtitle="Exception ticket status distribution by creation date"
|
||||||
|
>
|
||||||
|
<ArcherPipelineChart data={archerByDate} />
|
||||||
|
</ChartCard>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
366
frontend/src/components/pages/ComplianceDetailPanel.js
Normal file
366
frontend/src/components/pages/ComplianceDetailPanel.js
Normal file
@@ -0,0 +1,366 @@
|
|||||||
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { X, MessageSquare, Send, Loader, AlertCircle, Clock, Shield } from 'lucide-react';
|
||||||
|
|
||||||
|
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
||||||
|
|
||||||
|
const TEAL = '#14B8A6';
|
||||||
|
|
||||||
|
const CATEGORY_COLORS = {
|
||||||
|
'Vulnerability Management': '#EF4444',
|
||||||
|
'Access & MFA': '#F59E0B',
|
||||||
|
'Logging & Monitoring': '#8B5CF6',
|
||||||
|
'End-of-Life OS': '#F97316',
|
||||||
|
'Decommissioned Assets': '#64748B',
|
||||||
|
'Asset Data Quality': '#64748B',
|
||||||
|
'Application Security': '#0EA5E9',
|
||||||
|
'Disaster Recovery': TEAL,
|
||||||
|
'Endpoint Protection': '#F97316',
|
||||||
|
};
|
||||||
|
|
||||||
|
function categoryColor(category) {
|
||||||
|
return CATEGORY_COLORS[category] || '#94A3B8';
|
||||||
|
}
|
||||||
|
|
||||||
|
function MetricChip({ metricId, category, status }) {
|
||||||
|
const color = status === 'resolved' ? '#64748B' : categoryColor(category);
|
||||||
|
return (
|
||||||
|
<span style={{
|
||||||
|
display: 'inline-flex', alignItems: 'center',
|
||||||
|
padding: '0.2rem 0.5rem',
|
||||||
|
background: `${color}18`,
|
||||||
|
border: `1px solid ${color}50`,
|
||||||
|
borderRadius: '0.25rem',
|
||||||
|
color, fontSize: '0.7rem', fontFamily: 'monospace', fontWeight: '600',
|
||||||
|
}}>
|
||||||
|
{metricId}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ComplianceDetailPanel({ hostname, onClose, onNoteAdded, onNavigate }) {
|
||||||
|
const [detail, setDetail] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [noteText, setNoteText] = useState('');
|
||||||
|
const [noteMetric, setNoteMetric] = useState('');
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [noteError, setNoteError] = useState(null);
|
||||||
|
|
||||||
|
const fetchDetail = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/compliance/items/${encodeURIComponent(hostname)}`, { credentials: 'include' });
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) throw new Error(data.error || 'Failed to load device');
|
||||||
|
setDetail(data);
|
||||||
|
|
||||||
|
// Default note metric to first active failing metric
|
||||||
|
const firstActive = (data.metrics || []).find(m => m.status === 'active');
|
||||||
|
if (firstActive) setNoteMetric(firstActive.metric_id);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [hostname]);
|
||||||
|
|
||||||
|
useEffect(() => { fetchDetail(); }, [fetchDetail]);
|
||||||
|
|
||||||
|
const handleAddNote = async () => {
|
||||||
|
if (!noteText.trim() || !noteMetric) return;
|
||||||
|
setSubmitting(true);
|
||||||
|
setNoteError(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/compliance/notes`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ hostname, metric_id: noteMetric, note: noteText.trim() }),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) throw new Error(data.error || 'Failed to save note');
|
||||||
|
setNoteText('');
|
||||||
|
await fetchDetail();
|
||||||
|
if (onNoteAdded) onNoteAdded();
|
||||||
|
} catch (err) {
|
||||||
|
setNoteError(err.message);
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const activeMetrics = detail?.metrics?.filter(m => m.status === 'active') || [];
|
||||||
|
const resolvedMetrics = detail?.metrics?.filter(m => m.status === 'resolved') || [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Backdrop */}
|
||||||
|
<div onClick={onClose} style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.4)', zIndex: 40 }} />
|
||||||
|
|
||||||
|
{/* Panel */}
|
||||||
|
<div style={{
|
||||||
|
position: 'fixed', top: 0, right: 0, bottom: 0, width: '420px',
|
||||||
|
background: 'linear-gradient(180deg, #0F1A2E 0%, #0A1628 100%)',
|
||||||
|
borderLeft: `1px solid ${TEAL}30`,
|
||||||
|
boxShadow: `-8px 0 32px rgba(0,0,0,0.6)`,
|
||||||
|
zIndex: 41,
|
||||||
|
display: 'flex', flexDirection: 'column',
|
||||||
|
overflowY: 'auto',
|
||||||
|
}}>
|
||||||
|
{/* Header */}
|
||||||
|
<div style={{
|
||||||
|
padding: '1.25rem 1.25rem 1rem',
|
||||||
|
borderBottom: '1px solid rgba(255,255,255,0.06)',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||||
|
<div style={{ minWidth: 0, flex: 1, marginRight: '0.75rem' }}>
|
||||||
|
<div style={{ fontFamily: 'monospace', fontSize: '0.9rem', fontWeight: '700', color: '#F8FAFC', wordBreak: 'break-all', lineHeight: 1.3 }}>
|
||||||
|
{hostname}
|
||||||
|
</div>
|
||||||
|
{detail && (
|
||||||
|
<div style={{ marginTop: '0.4rem', display: 'flex', flexWrap: 'wrap', gap: '0.4rem' }}>
|
||||||
|
{detail.ip_address && (
|
||||||
|
<span style={{ fontSize: '0.72rem', fontFamily: 'monospace', color: '#64748B' }}>{detail.ip_address}</span>
|
||||||
|
)}
|
||||||
|
{detail.device_type && (
|
||||||
|
<span style={{ fontSize: '0.72rem', color: '#475569' }}>· {detail.device_type}</span>
|
||||||
|
)}
|
||||||
|
<span style={{ fontSize: '0.72rem', color: TEAL }}>· {detail.team}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#475569', flexShrink: 0, padding: '0.25rem' }}
|
||||||
|
onMouseEnter={e => e.currentTarget.style.color = '#E2E8F0'}
|
||||||
|
onMouseLeave={e => e.currentTarget.style.color = '#475569'}>
|
||||||
|
<X style={{ width: '18px', height: '18px' }} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading && (
|
||||||
|
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
<Loader style={{ width: '28px', height: '28px', color: TEAL, animation: 'spin 1s linear infinite' }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div style={{ padding: '1.25rem', display: 'flex', gap: '0.5rem', color: '#F87171', fontSize: '0.8rem' }}>
|
||||||
|
<AlertCircle style={{ width: '16px', height: '16px', flexShrink: 0, marginTop: '1px' }} />{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !error && detail && (
|
||||||
|
<div style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
|
||||||
|
|
||||||
|
{/* Active failing metrics */}
|
||||||
|
{activeMetrics.length > 0 && (
|
||||||
|
<Section title="Failing Metrics" icon={<Shield style={{ width: '14px', height: '14px' }} />}>
|
||||||
|
{activeMetrics.map(m => (
|
||||||
|
<MetricRow key={m.metric_id} metric={m} onNavigate={onNavigate} />
|
||||||
|
))}
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Resolved metrics */}
|
||||||
|
{resolvedMetrics.length > 0 && (
|
||||||
|
<Section title="Resolved Metrics" muted>
|
||||||
|
{resolvedMetrics.map(m => (
|
||||||
|
<MetricRow key={m.metric_id} metric={m} resolved />
|
||||||
|
))}
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Upload history summary */}
|
||||||
|
{activeMetrics.length > 0 && (
|
||||||
|
<Section title="History" icon={<Clock style={{ width: '14px', height: '14px' }} />}>
|
||||||
|
{activeMetrics.map(m => (
|
||||||
|
<div key={m.metric_id} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.35rem' }}>
|
||||||
|
<MetricChip metricId={m.metric_id} category={m.category} />
|
||||||
|
<div style={{ fontSize: '0.72rem', color: '#64748B', fontFamily: 'monospace', textAlign: 'right' }}>
|
||||||
|
<span style={{ color: m.seen_count > 2 ? '#F59E0B' : '#94A3B8' }}>
|
||||||
|
{m.seen_count}× seen
|
||||||
|
</span>
|
||||||
|
{m.first_seen && <span style={{ marginLeft: '0.5rem' }}>since {m.first_seen}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Notes */}
|
||||||
|
<Section title="Notes" icon={<MessageSquare style={{ width: '14px', height: '14px' }} />} grow>
|
||||||
|
{detail.notes.length === 0 && (
|
||||||
|
<div style={{ color: '#334155', fontSize: '0.75rem', fontStyle: 'italic', marginBottom: '0.75rem' }}>No notes yet</div>
|
||||||
|
)}
|
||||||
|
{detail.notes.map(n => (
|
||||||
|
<div key={n.id} style={{
|
||||||
|
marginBottom: '0.75rem', padding: '0.625rem 0.75rem',
|
||||||
|
background: 'rgba(15,23,42,0.6)', borderRadius: '0.375rem',
|
||||||
|
border: '1px solid rgba(255,255,255,0.05)',
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.3rem' }}>
|
||||||
|
<MetricChip metricId={n.metric_id} category={activeMetrics.find(m => m.metric_id === n.metric_id)?.category || ''} />
|
||||||
|
<span style={{ fontSize: '0.68rem', color: '#334155', fontFamily: 'monospace' }}>
|
||||||
|
{n.created_by && `${n.created_by} · `}{n.created_at?.slice(0, 10)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '0.8rem', color: '#CBD5E1', lineHeight: 1.5 }}>{n.note}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Add note */}
|
||||||
|
<div style={{ marginTop: 'auto', paddingTop: '0.75rem', borderTop: '1px solid rgba(255,255,255,0.05)' }}>
|
||||||
|
{activeMetrics.length > 1 && (
|
||||||
|
<select
|
||||||
|
value={noteMetric}
|
||||||
|
onChange={e => setNoteMetric(e.target.value)}
|
||||||
|
style={{
|
||||||
|
width: '100%', marginBottom: '0.5rem',
|
||||||
|
background: 'rgba(15,23,42,0.8)', border: '1px solid rgba(20,184,166,0.25)',
|
||||||
|
borderRadius: '0.25rem', color: '#CBD5E1',
|
||||||
|
padding: '0.4rem 0.5rem', fontSize: '0.75rem', fontFamily: 'monospace',
|
||||||
|
}}>
|
||||||
|
{activeMetrics.map(m => (
|
||||||
|
<option key={m.metric_id} value={m.metric_id}>{m.metric_id} — {m.category}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
||||||
|
<textarea
|
||||||
|
value={noteText}
|
||||||
|
onChange={e => setNoteText(e.target.value)}
|
||||||
|
placeholder="Add a note…"
|
||||||
|
rows={2}
|
||||||
|
style={{
|
||||||
|
flex: 1, resize: 'none',
|
||||||
|
background: 'rgba(15,23,42,0.8)', border: '1px solid rgba(20,184,166,0.25)',
|
||||||
|
borderRadius: '0.375rem', color: '#F8FAFC',
|
||||||
|
padding: '0.5rem 0.625rem', fontSize: '0.8rem',
|
||||||
|
outline: 'none',
|
||||||
|
}}
|
||||||
|
onFocus={e => e.target.style.borderColor = `${TEAL}70`}
|
||||||
|
onBlur={e => e.target.style.borderColor = 'rgba(20,184,166,0.25)'}
|
||||||
|
onKeyDown={e => { if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) handleAddNote(); }}
|
||||||
|
/>
|
||||||
|
<button onClick={handleAddNote} disabled={!noteText.trim() || submitting}
|
||||||
|
style={{
|
||||||
|
padding: '0.5rem 0.625rem', flexShrink: 0,
|
||||||
|
background: noteText.trim() ? `${TEAL}20` : 'transparent',
|
||||||
|
border: `1px solid ${noteText.trim() ? TEAL : 'rgba(20,184,166,0.2)'}`,
|
||||||
|
borderRadius: '0.375rem', color: noteText.trim() ? TEAL : '#334155',
|
||||||
|
cursor: noteText.trim() ? 'pointer' : 'default', transition: 'all 0.15s',
|
||||||
|
}}>
|
||||||
|
{submitting
|
||||||
|
? <Loader style={{ width: '16px', height: '16px', animation: 'spin 1s linear infinite' }} />
|
||||||
|
: <Send style={{ width: '16px', height: '16px' }} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{noteError && <div style={{ marginTop: '0.4rem', color: '#F87171', fontSize: '0.72rem' }}>{noteError}</div>}
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Section({ title, icon, children, muted, grow }) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
padding: '1rem 1.25rem',
|
||||||
|
borderBottom: '1px solid rgba(255,255,255,0.04)',
|
||||||
|
...(grow ? { flex: 1, display: 'flex', flexDirection: 'column' } : {}),
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: '0.4rem',
|
||||||
|
fontSize: '0.68rem', fontFamily: 'monospace', textTransform: 'uppercase',
|
||||||
|
letterSpacing: '0.1em', color: muted ? '#334155' : '#475569',
|
||||||
|
marginBottom: '0.75rem',
|
||||||
|
}}>
|
||||||
|
{icon && <span style={{ color: muted ? '#334155' : TEAL }}>{icon}</span>}
|
||||||
|
{title}
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MetricRow({ metric, resolved, onNavigate }) {
|
||||||
|
const color = resolved ? '#475569' : categoryColor(metric.category);
|
||||||
|
const extra = metric.extra || {};
|
||||||
|
|
||||||
|
const ivantiId = (!resolved && metric.metric_id?.startsWith('2.3'))
|
||||||
|
? (extra['Ivanti_Vulnerability_ID'] || null)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// Surface the most useful extra fields per metric type
|
||||||
|
const highlights = [];
|
||||||
|
if (extra['CVEs_Associated']) highlights.push({ label: 'CVEs', value: extra['CVEs_Associated'] });
|
||||||
|
if (extra['SLA_Status']) highlights.push({ label: 'SLA', value: extra['SLA_Status'] });
|
||||||
|
if (extra['Due_Date']) highlights.push({ label: 'Due', value: extra['Due_Date'] });
|
||||||
|
if (extra['Normalized - Operating System'])
|
||||||
|
highlights.push({ label: 'OS', value: `${extra['Normalized - Operating System']} ${extra['Normalized - Operating System Version'] || ''}`.trim() });
|
||||||
|
if (extra['EOS - End of Service Life'])
|
||||||
|
highlights.push({ label: 'EoL', value: extra['EOS - End of Service Life'] });
|
||||||
|
if (extra['Splunk - Last Seen']) highlights.push({ label: 'Splunk', value: extra['Splunk - Last Seen'] });
|
||||||
|
if (extra['MFA - Software']) highlights.push({ label: 'MFA SW', value: extra['MFA - Software'] });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
marginBottom: '0.625rem', padding: '0.625rem 0.75rem',
|
||||||
|
background: resolved ? 'transparent' : `${color}08`,
|
||||||
|
border: `1px solid ${color}25`,
|
||||||
|
borderRadius: '0.375rem',
|
||||||
|
opacity: resolved ? 0.5 : 1,
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: highlights.length ? '0.4rem' : 0 }}>
|
||||||
|
<MetricChip metricId={metric.metric_id} category={metric.category} status={metric.status} />
|
||||||
|
{resolved && <span style={{ fontSize: '0.68rem', color: '#334155', fontFamily: 'monospace' }}>resolved {metric.resolved_on || ''}</span>}
|
||||||
|
</div>
|
||||||
|
{metric.metric_desc && (
|
||||||
|
<div style={{ fontSize: '0.72rem', color: '#475569', marginBottom: (highlights.length || ivantiId) ? '0.4rem' : 0, lineHeight: 1.4 }}>
|
||||||
|
{metric.metric_desc.length > 100 ? metric.metric_desc.slice(0, 100) + '…' : metric.metric_desc}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{ivantiId && (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: highlights.length ? '0.25rem' : 0 }}>
|
||||||
|
<div style={{ display: 'flex', gap: '0.4rem', alignItems: 'center', minWidth: 0 }}>
|
||||||
|
<span style={{ fontSize: '0.68rem', color: '#475569', fontFamily: 'monospace', flexShrink: 0 }}>Ivanti ID</span>
|
||||||
|
<span style={{ fontSize: '0.68rem', color: '#94A3B8', fontFamily: 'monospace', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{ivantiId}</span>
|
||||||
|
</div>
|
||||||
|
{onNavigate && (
|
||||||
|
<button
|
||||||
|
onClick={e => { e.stopPropagation(); onNavigate('triage'); }}
|
||||||
|
style={{
|
||||||
|
flexShrink: 0, marginLeft: '0.5rem',
|
||||||
|
background: 'rgba(14,165,233,0.1)',
|
||||||
|
border: '1px solid rgba(14,165,233,0.4)',
|
||||||
|
borderRadius: '0.25rem',
|
||||||
|
color: '#0EA5E9',
|
||||||
|
fontSize: '0.65rem', fontFamily: 'monospace',
|
||||||
|
padding: '0.2rem 0.5rem',
|
||||||
|
cursor: 'pointer', whiteSpace: 'nowrap',
|
||||||
|
transition: 'all 0.15s',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => { e.currentTarget.style.background = 'rgba(14,165,233,0.18)'; e.currentTarget.style.borderColor = 'rgba(14,165,233,0.7)'; }}
|
||||||
|
onMouseLeave={e => { e.currentTarget.style.background = 'rgba(14,165,233,0.1)'; e.currentTarget.style.borderColor = 'rgba(14,165,233,0.4)'; }}
|
||||||
|
>
|
||||||
|
View in Triage →
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{highlights.map(h => (
|
||||||
|
<div key={h.label} style={{ display: 'flex', gap: '0.4rem', marginTop: '0.25rem' }}>
|
||||||
|
<span style={{ fontSize: '0.68rem', color: '#475569', fontFamily: 'monospace', minWidth: '48px' }}>{h.label}</span>
|
||||||
|
<span style={{ fontSize: '0.68rem', color: '#94A3B8', fontFamily: 'monospace', wordBreak: 'break-all' }}>
|
||||||
|
{String(h.value).length > 80 ? String(h.value).slice(0, 80) + '…' : h.value}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
499
frontend/src/components/pages/CompliancePage.js
Normal file
499
frontend/src/components/pages/CompliancePage.js
Normal file
@@ -0,0 +1,499 @@
|
|||||||
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { Upload, MessageSquare, RefreshCw, AlertCircle, Loader } from 'lucide-react';
|
||||||
|
import { useAuth } from '../../contexts/AuthContext';
|
||||||
|
import ComplianceUploadModal from './ComplianceUploadModal';
|
||||||
|
import ComplianceDetailPanel from './ComplianceDetailPanel';
|
||||||
|
import ComplianceChartsPanel from './ComplianceChartsPanel';
|
||||||
|
|
||||||
|
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
||||||
|
const TEAL = '#14B8A6';
|
||||||
|
const TEAMS = ['STEAM', 'ACCESS-ENG'];
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const STATUS_COLOR = {
|
||||||
|
'Meets/Exceeds Target': '#10B981',
|
||||||
|
'Within 15% of Target': '#F59E0B',
|
||||||
|
'Below 15% of Target': '#EF4444',
|
||||||
|
};
|
||||||
|
|
||||||
|
const CATEGORY_COLORS = {
|
||||||
|
'Vulnerability Management': '#EF4444',
|
||||||
|
'Access & MFA': '#F59E0B',
|
||||||
|
'Logging & Monitoring': '#8B5CF6',
|
||||||
|
'End-of-Life OS': '#F97316',
|
||||||
|
'Decommissioned Assets': '#64748B',
|
||||||
|
'Asset Data Quality': '#64748B',
|
||||||
|
'Application Security': '#0EA5E9',
|
||||||
|
'Disaster Recovery': TEAL,
|
||||||
|
'Endpoint Protection': '#F97316',
|
||||||
|
};
|
||||||
|
|
||||||
|
function statusColor(status) {
|
||||||
|
return STATUS_COLOR[status] || '#EF4444';
|
||||||
|
}
|
||||||
|
|
||||||
|
function pctDisplay(pct) {
|
||||||
|
return `${Math.round(pct * 100)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deduplicate summary entries — one per metric_id for the selected team
|
||||||
|
// (exclude aggregate "ALL: NTS-AEO" rows)
|
||||||
|
function teamMetrics(entries, team) {
|
||||||
|
return entries.filter(e => e.team === team);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Sub-components
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function MetricHealthCard({ entry, active, onClick }) {
|
||||||
|
const color = statusColor(entry.status);
|
||||||
|
const isOk = entry.status === 'Meets/Exceeds Target';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
style={{
|
||||||
|
background: active
|
||||||
|
? `${color}18`
|
||||||
|
: 'linear-gradient(135deg,rgba(30,41,59,0.95) 0%,rgba(15,23,42,0.98) 100%)',
|
||||||
|
border: `1.5px solid ${active ? color : color + '40'}`,
|
||||||
|
borderRadius: '0.5rem',
|
||||||
|
padding: '0.875rem 1rem',
|
||||||
|
cursor: 'pointer',
|
||||||
|
textAlign: 'left',
|
||||||
|
transition: 'all 0.15s',
|
||||||
|
minWidth: '160px',
|
||||||
|
flex: '1 1 0',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => { if (!active) e.currentTarget.style.borderColor = color + '80'; }}
|
||||||
|
onMouseLeave={e => { if (!active) e.currentTarget.style.borderColor = color + '40'; }}
|
||||||
|
>
|
||||||
|
{/* Metric ID */}
|
||||||
|
<div style={{ fontFamily: 'monospace', fontSize: '0.95rem', fontWeight: '700', color: active ? color : '#E2E8F0', marginBottom: '0.25rem' }}>
|
||||||
|
{entry.metric_id}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Category */}
|
||||||
|
<div style={{ fontSize: '0.65rem', color: '#475569', marginBottom: '0.625rem', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
||||||
|
{entry.category}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Compliance % */}
|
||||||
|
<div style={{ fontFamily: 'monospace', fontSize: '1.4rem', fontWeight: '700', color, lineHeight: 1, marginBottom: '0.3rem' }}>
|
||||||
|
{pctDisplay(entry.compliance_pct)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Target */}
|
||||||
|
<div style={{ fontSize: '0.68rem', color: '#475569', fontFamily: 'monospace' }}>
|
||||||
|
target {pctDisplay(entry.target)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status pill */}
|
||||||
|
<div style={{
|
||||||
|
marginTop: '0.625rem', display: 'inline-flex', alignItems: 'center', gap: '0.3rem',
|
||||||
|
fontSize: '0.62rem', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: '0.04em',
|
||||||
|
color, padding: '0.2rem 0.5rem',
|
||||||
|
background: `${color}12`, borderRadius: '999px',
|
||||||
|
border: `1px solid ${color}30`,
|
||||||
|
}}>
|
||||||
|
<span style={{
|
||||||
|
width: '5px', height: '5px', borderRadius: '50%',
|
||||||
|
background: color, flexShrink: 0,
|
||||||
|
...(isOk ? {} : { boxShadow: `0 0 6px ${color}` }),
|
||||||
|
}} />
|
||||||
|
{isOk ? 'OK' : entry.status.replace(' of Target', '')}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MetricBadge({ metricId, category }) {
|
||||||
|
const color = CATEGORY_COLORS[category] || '#94A3B8';
|
||||||
|
return (
|
||||||
|
<span style={{
|
||||||
|
display: 'inline-flex', alignItems: 'center',
|
||||||
|
padding: '0.15rem 0.45rem',
|
||||||
|
background: `${color}15`, border: `1px solid ${color}40`,
|
||||||
|
borderRadius: '0.2rem', color,
|
||||||
|
fontSize: '0.65rem', fontFamily: 'monospace', fontWeight: '600',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}>
|
||||||
|
{metricId}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SeenBadge({ count }) {
|
||||||
|
const color = count > 3 ? '#EF4444' : count > 1 ? '#F59E0B' : '#64748B';
|
||||||
|
return (
|
||||||
|
<span style={{
|
||||||
|
fontSize: '0.65rem', fontFamily: 'monospace', fontWeight: '700',
|
||||||
|
color, padding: '0.15rem 0.4rem',
|
||||||
|
background: `${color}12`, borderRadius: '0.2rem',
|
||||||
|
border: `1px solid ${color}30`, whiteSpace: 'nowrap',
|
||||||
|
}}>
|
||||||
|
{count}×
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Main Page
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
export default function CompliancePage({ onNavigate }) {
|
||||||
|
const { canWrite } = useAuth();
|
||||||
|
|
||||||
|
const [activeTeam, setActiveTeam] = useState('STEAM');
|
||||||
|
const [activeTab, setActiveTab] = useState('active');
|
||||||
|
const [metricFilter, setMetricFilter] = useState(null);
|
||||||
|
const [hostSearch, setHostSearch] = useState('');
|
||||||
|
const [summary, setSummary] = useState({ entries: [], overall_scores: {}, upload: null });
|
||||||
|
const [devices, setDevices] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [selectedHost, setSelectedHost] = useState(null);
|
||||||
|
const [showUpload, setShowUpload] = useState(false);
|
||||||
|
|
||||||
|
const fetchSummary = useCallback(async (team) => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/compliance/summary?team=${team}`, { credentials: 'include' });
|
||||||
|
const data = await res.json();
|
||||||
|
setSummary(data);
|
||||||
|
} catch { /* silent */ }
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchDevices = useCallback(async (team, tab) => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/compliance/items?team=${team}&status=${tab}`, { credentials: 'include' });
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) throw new Error(data.error || 'Failed to load');
|
||||||
|
setDevices(data.devices || []);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
setDevices([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMetricFilter(null);
|
||||||
|
setHostSearch('');
|
||||||
|
setSelectedHost(null);
|
||||||
|
fetchSummary(activeTeam);
|
||||||
|
fetchDevices(activeTeam, activeTab);
|
||||||
|
}, [activeTeam]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMetricFilter(null);
|
||||||
|
fetchDevices(activeTeam, activeTab);
|
||||||
|
}, [activeTab]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
const refresh = () => {
|
||||||
|
fetchSummary(activeTeam);
|
||||||
|
fetchDevices(activeTeam, activeTab);
|
||||||
|
};
|
||||||
|
|
||||||
|
// In-memory filters
|
||||||
|
const filteredDevices = devices
|
||||||
|
.filter(d => !metricFilter || d.failing_metrics.some(m => m.metric_id === metricFilter))
|
||||||
|
.filter(d => !hostSearch || d.hostname.toLowerCase().includes(hostSearch.toLowerCase()));
|
||||||
|
|
||||||
|
const metrics = teamMetrics(summary.entries, activeTeam);
|
||||||
|
const lastUpload = summary.upload;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ paddingBottom: '2rem' }}>
|
||||||
|
|
||||||
|
{/* ── Page header ─────────────────────────────────────────── */}
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '1.5rem' }}>
|
||||||
|
<div>
|
||||||
|
<h2 style={{
|
||||||
|
fontFamily: 'monospace', fontSize: '1.5rem', fontWeight: '700',
|
||||||
|
color: TEAL, textTransform: 'uppercase', letterSpacing: '0.1em',
|
||||||
|
textShadow: `0 0 16px ${TEAL}40`, marginBottom: '0.25rem',
|
||||||
|
}}>
|
||||||
|
AEO Compliance
|
||||||
|
</h2>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', flexWrap: 'wrap' }}>
|
||||||
|
{lastUpload ? (
|
||||||
|
<span style={{ fontSize: '0.72rem', color: '#475569', fontFamily: 'monospace' }}>
|
||||||
|
Last report: <span style={{ color: '#64748B' }}>{lastUpload.report_date || lastUpload.uploaded_at?.slice(0, 10)}</span>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span style={{ fontSize: '0.72rem', color: '#334155', fontFamily: 'monospace' }}>No reports uploaded</span>
|
||||||
|
)}
|
||||||
|
{summary.overall_scores?.customer_network != null && (
|
||||||
|
<span style={{ fontSize: '0.68rem', fontFamily: 'monospace', color: '#64748B' }}>
|
||||||
|
Network: <span style={{ color: TEAL }}>{pctDisplay(summary.overall_scores.customer_network)}</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{summary.overall_scores?.vertical != null && (
|
||||||
|
<span style={{ fontSize: '0.68rem', fontFamily: 'monospace', color: '#64748B' }}>
|
||||||
|
Vertical: <span style={{ color: TEAL }}>{pctDisplay(summary.overall_scores.vertical)}</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: '0.5rem', flexShrink: 0 }}>
|
||||||
|
<button onClick={refresh} title="Refresh"
|
||||||
|
style={{ background: 'none', border: '1px solid rgba(20,184,166,0.25)', borderRadius: '0.375rem', padding: '0.5rem', cursor: 'pointer', color: '#475569' }}
|
||||||
|
onMouseEnter={e => { e.currentTarget.style.color = TEAL; e.currentTarget.style.borderColor = `${TEAL}60`; }}
|
||||||
|
onMouseLeave={e => { e.currentTarget.style.color = '#475569'; e.currentTarget.style.borderColor = 'rgba(20,184,166,0.25)'; }}>
|
||||||
|
<RefreshCw style={{ width: '16px', height: '16px' }} />
|
||||||
|
</button>
|
||||||
|
{canWrite() && (
|
||||||
|
<button onClick={() => setShowUpload(true)}
|
||||||
|
className="intel-button"
|
||||||
|
style={{
|
||||||
|
background: `${TEAL}18`, border: `1px solid ${TEAL}`,
|
||||||
|
color: TEAL, padding: '0.5rem 1rem',
|
||||||
|
display: 'flex', alignItems: 'center', gap: '0.4rem',
|
||||||
|
fontFamily: 'monospace', fontSize: '0.75rem', fontWeight: '600',
|
||||||
|
textTransform: 'uppercase', letterSpacing: '0.05em', cursor: 'pointer',
|
||||||
|
borderRadius: '0.375rem',
|
||||||
|
}}>
|
||||||
|
<Upload style={{ width: '14px', height: '14px' }} />
|
||||||
|
Upload Report
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Team tabs ────────────────────────────────────────────── */}
|
||||||
|
<div style={{ display: 'flex', gap: '0.375rem', marginBottom: '1.5rem' }}>
|
||||||
|
{TEAMS.map(team => {
|
||||||
|
const isActive = activeTeam === team;
|
||||||
|
return (
|
||||||
|
<button key={team} onClick={() => setActiveTeam(team)}
|
||||||
|
style={{
|
||||||
|
padding: '0.5rem 1.25rem', cursor: 'pointer',
|
||||||
|
fontFamily: 'monospace', fontSize: '0.8rem', fontWeight: '600',
|
||||||
|
textTransform: 'uppercase', letterSpacing: '0.06em',
|
||||||
|
borderRadius: '0.375rem',
|
||||||
|
border: isActive ? `1px solid ${TEAL}` : '1px solid rgba(20,184,166,0.2)',
|
||||||
|
background: isActive ? `${TEAL}18` : 'transparent',
|
||||||
|
color: isActive ? TEAL : '#475569',
|
||||||
|
transition: 'all 0.15s',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => { if (!isActive) { e.currentTarget.style.color = '#94A3B8'; e.currentTarget.style.borderColor = 'rgba(20,184,166,0.4)'; }}}
|
||||||
|
onMouseLeave={e => { if (!isActive) { e.currentTarget.style.color = '#475569'; e.currentTarget.style.borderColor = 'rgba(20,184,166,0.2)'; }}}>
|
||||||
|
{team}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Metric health cards ──────────────────────────────────── */}
|
||||||
|
{metrics.length > 0 ? (
|
||||||
|
<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)}
|
||||||
|
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.625rem', flexWrap: 'wrap' }}>
|
||||||
|
{metrics.map(entry => (
|
||||||
|
<MetricHealthCard
|
||||||
|
key={entry.metric_id}
|
||||||
|
entry={entry}
|
||||||
|
active={metricFilter === entry.metric_id}
|
||||||
|
onClick={() => setMetricFilter(metricFilter === entry.metric_id ? null : entry.metric_id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : lastUpload === null ? (
|
||||||
|
<div style={{
|
||||||
|
marginBottom: '1.5rem', padding: '2rem',
|
||||||
|
border: '1px dashed rgba(20,184,166,0.2)', borderRadius: '0.5rem',
|
||||||
|
textAlign: 'center',
|
||||||
|
}}>
|
||||||
|
<div style={{ color: '#334155', fontFamily: 'monospace', fontSize: '0.8rem' }}>
|
||||||
|
No compliance data — upload a report to get started
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* ── Historical trend charts ──────────────────────────────── */}
|
||||||
|
<ComplianceChartsPanel />
|
||||||
|
|
||||||
|
{/* ── Device table ─────────────────────────────────────────── */}
|
||||||
|
<div style={{
|
||||||
|
background: 'linear-gradient(135deg,rgba(30,41,59,0.95) 0%,rgba(15,23,42,0.98) 100%)',
|
||||||
|
border: '1px solid rgba(20,184,166,0.15)', borderRadius: '0.5rem',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}>
|
||||||
|
{/* Table toolbar */}
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
||||||
|
padding: '0.875rem 1rem', borderBottom: '1px solid rgba(255,255,255,0.05)',
|
||||||
|
}}>
|
||||||
|
{/* Active / Resolved tabs */}
|
||||||
|
<div style={{ display: 'flex', gap: '0.25rem' }}>
|
||||||
|
{['active', 'resolved'].map(tab => {
|
||||||
|
const isActive = activeTab === tab;
|
||||||
|
return (
|
||||||
|
<button key={tab} onClick={() => setActiveTab(tab)}
|
||||||
|
style={{
|
||||||
|
padding: '0.35rem 0.875rem', cursor: 'pointer',
|
||||||
|
fontFamily: 'monospace', fontSize: '0.72rem', fontWeight: '600',
|
||||||
|
textTransform: 'uppercase', letterSpacing: '0.04em',
|
||||||
|
borderRadius: '0.25rem',
|
||||||
|
border: isActive ? `1px solid ${TEAL}60` : '1px solid transparent',
|
||||||
|
background: isActive ? `${TEAL}12` : 'transparent',
|
||||||
|
color: isActive ? TEAL : '#475569',
|
||||||
|
}}>
|
||||||
|
{tab}
|
||||||
|
{isActive && (
|
||||||
|
<span style={{ marginLeft: '0.4rem', color: '#64748B' }}>
|
||||||
|
({loading ? '…' : filteredDevices.length})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Hostname search */}
|
||||||
|
<input
|
||||||
|
value={hostSearch}
|
||||||
|
onChange={e => setHostSearch(e.target.value)}
|
||||||
|
placeholder="Search hostname…"
|
||||||
|
style={{
|
||||||
|
background: 'rgba(15,23,42,0.8)', border: '1px solid rgba(20,184,166,0.2)',
|
||||||
|
borderRadius: '0.25rem', color: '#E2E8F0', outline: 'none',
|
||||||
|
padding: '0.35rem 0.625rem', fontSize: '0.75rem', fontFamily: 'monospace',
|
||||||
|
width: '220px',
|
||||||
|
}}
|
||||||
|
onFocus={e => e.target.style.borderColor = `${TEAL}60`}
|
||||||
|
onBlur={e => e.target.style.borderColor = 'rgba(20,184,166,0.2)'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Column headers */}
|
||||||
|
<div style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: '2.5fr 1.1fr 1fr 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',
|
||||||
|
}}>
|
||||||
|
<span>Hostname</span>
|
||||||
|
<span>IP Address</span>
|
||||||
|
<span>Type</span>
|
||||||
|
<span>Failing Metrics</span>
|
||||||
|
<span>Seen</span>
|
||||||
|
<span></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Rows */}
|
||||||
|
{loading ? (
|
||||||
|
<div style={{ padding: '3rem', textAlign: 'center' }}>
|
||||||
|
<Loader style={{ width: '28px', height: '28px', color: TEAL, margin: '0 auto', animation: 'spin 1s linear infinite' }} />
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div style={{ padding: '2rem', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '0.5rem', color: '#F87171', fontSize: '0.8rem' }}>
|
||||||
|
<AlertCircle style={{ width: '16px', height: '16px' }} />{error}
|
||||||
|
</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'}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
filteredDevices.map(device => (
|
||||||
|
<DeviceRow
|
||||||
|
key={device.hostname}
|
||||||
|
device={device}
|
||||||
|
selected={selectedHost === device.hostname}
|
||||||
|
onClick={() => setSelectedHost(selectedHost === device.hostname ? null : device.hostname)}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Detail panel ─────────────────────────────────────────── */}
|
||||||
|
{selectedHost && (
|
||||||
|
<ComplianceDetailPanel
|
||||||
|
hostname={selectedHost}
|
||||||
|
onClose={() => setSelectedHost(null)}
|
||||||
|
onNoteAdded={refresh}
|
||||||
|
onNavigate={onNavigate}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Upload modal ─────────────────────────────────────────── */}
|
||||||
|
{showUpload && (
|
||||||
|
<ComplianceUploadModal
|
||||||
|
onClose={() => setShowUpload(false)}
|
||||||
|
onUploadComplete={() => { setShowUpload(false); refresh(); }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DeviceRow({ device, selected, onClick }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onClick={onClick}
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: '2.5fr 1.1fr 1fr 2fr 0.5fr 0.4fr',
|
||||||
|
padding: '0.625rem 1rem',
|
||||||
|
borderBottom: '1px solid rgba(255,255,255,0.04)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
background: selected ? `${TEAL}08` : 'transparent',
|
||||||
|
borderLeft: selected ? `2px solid ${TEAL}` : '2px solid transparent',
|
||||||
|
transition: 'all 0.15s',
|
||||||
|
alignItems: 'center',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => { if (!selected) e.currentTarget.style.background = 'rgba(255,255,255,0.025)'; }}
|
||||||
|
onMouseLeave={e => { if (!selected) e.currentTarget.style.background = 'transparent'; }}
|
||||||
|
>
|
||||||
|
{/* Hostname */}
|
||||||
|
<div style={{ fontFamily: 'monospace', fontSize: '0.78rem', color: selected ? TEAL : '#E2E8F0', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
|
{device.hostname}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* IP */}
|
||||||
|
<div style={{ fontFamily: 'monospace', fontSize: '0.72rem', color: '#64748B' }}>
|
||||||
|
{device.ip_address || '—'}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Type */}
|
||||||
|
<div style={{ fontSize: '0.7rem', color: '#475569', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
|
{device.device_type || '—'}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Failing metrics */}
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem' }}>
|
||||||
|
{device.failing_metrics.map(m => (
|
||||||
|
<MetricBadge key={m.metric_id} metricId={m.metric_id} category={m.category} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Seen count */}
|
||||||
|
<div>
|
||||||
|
<SeenBadge count={device.seen_count} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Notes indicator */}
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'center' }}>
|
||||||
|
{device.has_notes && (
|
||||||
|
<MessageSquare style={{ width: '13px', height: '13px', color: TEAL, opacity: 0.7 }} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
221
frontend/src/components/pages/ComplianceUploadModal.js
Normal file
221
frontend/src/components/pages/ComplianceUploadModal.js
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
import React, { useState, useRef } from 'react';
|
||||||
|
import { X, CheckCircle, AlertCircle, Loader, FileSpreadsheet } from 'lucide-react';
|
||||||
|
|
||||||
|
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
||||||
|
|
||||||
|
// phase: idle → uploading → preview → committing → done | error
|
||||||
|
export default function ComplianceUploadModal({ onClose, onUploadComplete }) {
|
||||||
|
const [phase, setPhase] = useState('idle');
|
||||||
|
const [previewData, setPreviewData] = useState(null);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [dragOver, setDragOver] = useState(false);
|
||||||
|
const fileInputRef = useRef(null);
|
||||||
|
|
||||||
|
const handleFile = async (file) => {
|
||||||
|
if (!file) return;
|
||||||
|
if (!file.name.toLowerCase().endsWith('.xlsx')) {
|
||||||
|
setError('File must be an .xlsx spreadsheet');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setPhase('uploading');
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
|
||||||
|
const res = await fetch(`${API_BASE}/compliance/preview`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(data.error || 'Upload failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
setPreviewData(data);
|
||||||
|
setPhase('preview');
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
setPhase('error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCommit = async () => {
|
||||||
|
if (!previewData) return;
|
||||||
|
setPhase('committing');
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/compliance/commit`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
tempFile: previewData.tempFile,
|
||||||
|
filename: previewData.filename,
|
||||||
|
report_date: previewData.report_date,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (!res.ok) throw new Error(data.error || 'Commit failed');
|
||||||
|
|
||||||
|
setPhase('done');
|
||||||
|
setTimeout(onUploadComplete, 1200);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
setPhase('error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const TEAL = '#14B8A6';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
position: 'fixed', inset: 0, zIndex: 60,
|
||||||
|
background: 'rgba(10, 14, 39, 0.97)',
|
||||||
|
backdropFilter: 'blur(12px)',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
padding: '1rem',
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
background: 'linear-gradient(135deg, rgba(30,41,59,0.98) 0%, rgba(15,23,42,0.99) 100%)',
|
||||||
|
border: `1px solid ${TEAL}40`,
|
||||||
|
borderRadius: '0.75rem',
|
||||||
|
boxShadow: `0 20px 60px rgba(0,0,0,0.7), 0 0 40px ${TEAL}15`,
|
||||||
|
width: '100%', maxWidth: '480px',
|
||||||
|
padding: '2rem',
|
||||||
|
}}>
|
||||||
|
{/* Header */}
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.75rem' }}>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontFamily: 'monospace', fontSize: '1rem', fontWeight: '700', color: TEAL, textTransform: 'uppercase', letterSpacing: '0.1em' }}>
|
||||||
|
Upload Report
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '0.75rem', color: '#475569', marginTop: '2px' }}>NTS_AEO xlsx compliance report</div>
|
||||||
|
</div>
|
||||||
|
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#475569', padding: '0.25rem' }}
|
||||||
|
onMouseEnter={e => e.currentTarget.style.color = '#E2E8F0'}
|
||||||
|
onMouseLeave={e => e.currentTarget.style.color = '#475569'}>
|
||||||
|
<X style={{ width: '20px', height: '20px' }} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* IDLE — drop zone */}
|
||||||
|
{phase === 'idle' && (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
onDragOver={e => { e.preventDefault(); setDragOver(true); }}
|
||||||
|
onDragLeave={() => setDragOver(false)}
|
||||||
|
onDrop={e => { e.preventDefault(); setDragOver(false); handleFile(e.dataTransfer.files[0]); }}
|
||||||
|
style={{
|
||||||
|
border: `2px dashed ${dragOver ? TEAL : 'rgba(20,184,166,0.3)'}`,
|
||||||
|
borderRadius: '0.5rem',
|
||||||
|
padding: '2.5rem',
|
||||||
|
textAlign: 'center',
|
||||||
|
cursor: 'pointer',
|
||||||
|
background: dragOver ? `${TEAL}08` : 'transparent',
|
||||||
|
transition: 'all 0.2s',
|
||||||
|
}}>
|
||||||
|
<FileSpreadsheet style={{ width: '40px', height: '40px', color: TEAL, margin: '0 auto 1rem', opacity: 0.8 }} />
|
||||||
|
<div style={{ color: '#CBD5E1', fontSize: '0.875rem', marginBottom: '0.5rem' }}>
|
||||||
|
Drop your xlsx file here or <span style={{ color: TEAL }}>browse</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ color: '#475569', fontSize: '0.75rem' }}>NTS_AEO_YYYY_MM_DD.xlsx</div>
|
||||||
|
</div>
|
||||||
|
<input ref={fileInputRef} type="file" accept=".xlsx" style={{ display: 'none' }}
|
||||||
|
onChange={e => handleFile(e.target.files[0])} />
|
||||||
|
{error && (
|
||||||
|
<div style={{ marginTop: '1rem', display: 'flex', alignItems: 'center', gap: '0.5rem', color: '#F87171', fontSize: '0.8rem' }}>
|
||||||
|
<AlertCircle style={{ width: '16px', height: '16px', flexShrink: 0 }} />{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* UPLOADING / COMMITTING — spinner */}
|
||||||
|
{(phase === 'uploading' || phase === 'committing') && (
|
||||||
|
<div style={{ textAlign: 'center', padding: '2rem 0' }}>
|
||||||
|
<Loader style={{ width: '40px', height: '40px', color: TEAL, margin: '0 auto 1rem', animation: 'spin 1s linear infinite' }} />
|
||||||
|
<div style={{ color: '#CBD5E1', fontFamily: 'monospace', fontSize: '0.875rem' }}>
|
||||||
|
{phase === 'uploading' ? 'Parsing report…' : 'Committing upload…'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* PREVIEW — diff summary + confirm */}
|
||||||
|
{phase === 'preview' && previewData && (
|
||||||
|
<>
|
||||||
|
<div style={{ marginBottom: '1.5rem' }}>
|
||||||
|
<div style={{ fontSize: '0.8rem', color: '#64748B', fontFamily: 'monospace', marginBottom: '0.75rem', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
||||||
|
{previewData.filename}
|
||||||
|
{previewData.report_date && <span style={{ color: TEAL, marginLeft: '0.75rem' }}>{previewData.report_date}</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{[
|
||||||
|
{ label: 'Recurring items', count: previewData.diff.recurring_count, color: '#94A3B8', icon: '↺' },
|
||||||
|
{ label: 'New items', count: previewData.diff.new_count, color: '#EF4444', icon: '+' },
|
||||||
|
{ label: 'Resolved', count: previewData.diff.resolved_count, color: '#10B981', icon: '✓' },
|
||||||
|
].map(({ label, count, color, icon }) => (
|
||||||
|
<div key={label} style={{
|
||||||
|
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
||||||
|
padding: '0.75rem 1rem', marginBottom: '0.5rem',
|
||||||
|
background: 'rgba(15,23,42,0.6)', borderRadius: '0.375rem',
|
||||||
|
border: `1px solid ${color}25`,
|
||||||
|
}}>
|
||||||
|
<span style={{ color: '#CBD5E1', fontSize: '0.875rem' }}>
|
||||||
|
<span style={{ color, marginRight: '0.5rem', fontWeight: '700' }}>{icon}</span>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
<span style={{ color, fontFamily: 'monospace', fontWeight: '700', fontSize: '1.1rem' }}>{count}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: '0.75rem' }}>
|
||||||
|
<button onClick={() => { setPhase('idle'); setPreviewData(null); }}
|
||||||
|
style={{ flex: 1, padding: '0.625rem', background: 'transparent', border: '1px solid rgba(100,116,139,0.4)', borderRadius: '0.375rem', color: '#64748B', cursor: 'pointer', fontFamily: 'monospace', fontSize: '0.8rem' }}
|
||||||
|
onMouseEnter={e => e.currentTarget.style.borderColor = 'rgba(100,116,139,0.8)'}
|
||||||
|
onMouseLeave={e => e.currentTarget.style.borderColor = 'rgba(100,116,139,0.4)'}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button onClick={handleCommit}
|
||||||
|
style={{ flex: 2, padding: '0.625rem', background: `${TEAL}18`, border: `1px solid ${TEAL}`, borderRadius: '0.375rem', color: TEAL, cursor: 'pointer', fontFamily: 'monospace', fontSize: '0.8rem', fontWeight: '600', textTransform: 'uppercase', letterSpacing: '0.05em' }}
|
||||||
|
onMouseEnter={e => e.currentTarget.style.background = `${TEAL}28`}
|
||||||
|
onMouseLeave={e => e.currentTarget.style.background = `${TEAL}18`}>
|
||||||
|
Confirm Upload
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* DONE */}
|
||||||
|
{phase === 'done' && (
|
||||||
|
<div style={{ textAlign: 'center', padding: '1.5rem 0' }}>
|
||||||
|
<CheckCircle style={{ width: '44px', height: '44px', color: '#10B981', margin: '0 auto 1rem' }} />
|
||||||
|
<div style={{ color: '#10B981', fontFamily: 'monospace', fontSize: '0.875rem', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
||||||
|
Upload committed
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ERROR */}
|
||||||
|
{phase === 'error' && (
|
||||||
|
<div style={{ textAlign: 'center', padding: '1rem 0' }}>
|
||||||
|
<AlertCircle style={{ width: '36px', height: '36px', color: '#EF4444', margin: '0 auto 0.75rem' }} />
|
||||||
|
<div style={{ color: '#F87171', fontSize: '0.875rem', marginBottom: '1.25rem' }}>{error}</div>
|
||||||
|
<button onClick={() => { setPhase('idle'); setError(null); }}
|
||||||
|
style={{ padding: '0.5rem 1.25rem', background: 'rgba(239,68,68,0.1)', border: '1px solid #EF4444', borderRadius: '0.375rem', color: '#F87171', cursor: 'pointer', fontFamily: 'monospace', fontSize: '0.8rem' }}>
|
||||||
|
Try Again
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
207
frontend/src/components/pages/IvantiCountsChart.js
Normal file
207
frontend/src/components/pages/IvantiCountsChart.js
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
// IvantiCountsChart.js
|
||||||
|
// Collapsible trend panel for the Vulnerability Triage page.
|
||||||
|
// Shows open vs closed Ivanti finding counts over time (last sync per day).
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useMemo } from 'react';
|
||||||
|
import {
|
||||||
|
LineChart, Line,
|
||||||
|
XAxis, YAxis, CartesianGrid,
|
||||||
|
Tooltip, Legend, ReferenceLine,
|
||||||
|
ResponsiveContainer,
|
||||||
|
} from 'recharts';
|
||||||
|
import { TrendingUp, ChevronDown, ChevronUp, Loader } from 'lucide-react';
|
||||||
|
|
||||||
|
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
||||||
|
|
||||||
|
const AMBER = '#F59E0B';
|
||||||
|
const SKY = '#0EA5E9';
|
||||||
|
const GREEN = '#10B981';
|
||||||
|
const RED = '#EF4444';
|
||||||
|
|
||||||
|
const AXIS_STYLE = { fontFamily: 'monospace', fontSize: '0.6rem', fill: '#475569' };
|
||||||
|
const GRID_STYLE = { stroke: 'rgba(255,255,255,0.05)', strokeDasharray: '3 3' };
|
||||||
|
const LEGEND_STYLE = { fontFamily: 'monospace', fontSize: '0.62rem', color: '#64748B' };
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Custom dark tooltip
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function DarkTooltip({ active, payload, label }) {
|
||||||
|
if (!active || !payload?.length) return null;
|
||||||
|
|
||||||
|
const openVal = payload.find(p => p.dataKey === 'open_count')?.value;
|
||||||
|
const closedVal = payload.find(p => p.dataKey === 'closed_count')?.value;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
background: 'rgba(10,17,32,0.97)',
|
||||||
|
border: '1px solid rgba(245,158,11,0.3)',
|
||||||
|
borderRadius: '0.375rem',
|
||||||
|
padding: '0.5rem 0.75rem',
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontSize: '0.7rem',
|
||||||
|
minWidth: '160px',
|
||||||
|
}}>
|
||||||
|
<div style={{ color: AMBER, marginBottom: '0.35rem', fontWeight: '700', fontSize: '0.65rem' }}>
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
{payload.map(p => (
|
||||||
|
<div key={p.dataKey} style={{ color: p.color, marginTop: '0.125rem', display: 'flex', justifyContent: 'space-between', gap: '1rem' }}>
|
||||||
|
<span style={{ opacity: 0.8 }}>{p.name}</span>
|
||||||
|
<span style={{ fontWeight: '700' }}>{p.value}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{openVal != null && closedVal != null && (
|
||||||
|
<div style={{ borderTop: '1px solid rgba(255,255,255,0.06)', marginTop: '0.35rem', paddingTop: '0.3rem', color: '#475569', display: 'flex', justifyContent: 'space-between', gap: '1rem' }}>
|
||||||
|
<span>total</span>
|
||||||
|
<span style={{ color: '#64748B', fontWeight: '600' }}>{openVal + closedVal}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Shorten YYYY-MM-DD to MM/DD/YY
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function fmtDate(d) {
|
||||||
|
if (!d) return '';
|
||||||
|
const p = d.split('-');
|
||||||
|
if (p.length === 3) return `${p[1]}/${p[2]}/${p[0].slice(2)}`;
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Main component
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
export default function IvantiCountsChart() {
|
||||||
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [history, setHistory] = useState([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
const load = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/ivanti/findings/counts/history`, { credentials: 'include' });
|
||||||
|
if (res.ok && !cancelled) {
|
||||||
|
const d = await res.json();
|
||||||
|
setHistory(d.history || []);
|
||||||
|
}
|
||||||
|
} catch { /* silent — chart shows no-data state */ }
|
||||||
|
finally { if (!cancelled) setLoading(false); }
|
||||||
|
};
|
||||||
|
load();
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const chartData = useMemo(
|
||||||
|
() => history.map(r => ({ ...r, date: fmtDate(r.date) })),
|
||||||
|
[history]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Compute a simple delta label for the latest vs previous point
|
||||||
|
const deltaLabel = useMemo(() => {
|
||||||
|
if (chartData.length < 2) return null;
|
||||||
|
const latest = chartData[chartData.length - 1];
|
||||||
|
const prev = chartData[chartData.length - 2];
|
||||||
|
const delta = latest.open_count - prev.open_count;
|
||||||
|
if (delta === 0) return { text: 'no change in open', color: '#475569' };
|
||||||
|
if (delta < 0) return { text: `▼ ${Math.abs(delta)} open since ${prev.date}`, color: GREEN };
|
||||||
|
return { text: `▲ ${delta} open since ${prev.date}`, color: RED };
|
||||||
|
}, [chartData]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ marginBottom: '1.25rem' }}>
|
||||||
|
|
||||||
|
{/* ── Header ────────────────────────────────────────────────── */}
|
||||||
|
<button
|
||||||
|
onClick={() => setCollapsed(c => !c)}
|
||||||
|
style={{
|
||||||
|
width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||||
|
background: 'none', border: 'none', cursor: 'pointer',
|
||||||
|
padding: '0 0 0.625rem 0',
|
||||||
|
borderBottom: collapsed ? 'none' : '1px solid rgba(245,158,11,0.1)',
|
||||||
|
marginBottom: collapsed ? 0 : '0.875rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||||
|
<TrendingUp style={{ width: '14px', height: '14px', color: AMBER }} />
|
||||||
|
<span style={{
|
||||||
|
fontFamily: 'monospace', fontSize: '0.65rem', fontWeight: '700',
|
||||||
|
color: '#334155', textTransform: 'uppercase', letterSpacing: '0.1em',
|
||||||
|
}}>
|
||||||
|
Findings Trend
|
||||||
|
</span>
|
||||||
|
{loading && (
|
||||||
|
<Loader style={{ width: '12px', height: '12px', color: '#334155', animation: 'spin 1s linear infinite' }} />
|
||||||
|
)}
|
||||||
|
{!loading && deltaLabel && (
|
||||||
|
<span style={{ fontFamily: 'monospace', fontSize: '0.62rem', color: deltaLabel.color, marginLeft: '0.25rem' }}>
|
||||||
|
{deltaLabel.text}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{collapsed
|
||||||
|
? <ChevronDown style={{ width: '14px', height: '14px', color: '#334155' }} />
|
||||||
|
: <ChevronUp style={{ width: '14px', height: '14px', color: '#334155' }} />
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{!collapsed && (
|
||||||
|
<div style={{
|
||||||
|
background: 'linear-gradient(135deg,rgba(30,41,59,0.95) 0%,rgba(15,23,42,0.98) 100%)',
|
||||||
|
border: '1px solid rgba(245,158,11,0.15)',
|
||||||
|
borderRadius: '0.5rem',
|
||||||
|
padding: '1rem 1.25rem 0.875rem',
|
||||||
|
}}>
|
||||||
|
<div style={{ marginBottom: '0.625rem', display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
|
||||||
|
<div style={{ fontFamily: 'monospace', fontSize: '0.65rem', color: '#334155', textTransform: 'uppercase', letterSpacing: '0.08em' }}>
|
||||||
|
Open vs Closed — end-of-day snapshot per sync day
|
||||||
|
</div>
|
||||||
|
{chartData.length > 0 && (
|
||||||
|
<div style={{ fontFamily: 'monospace', fontSize: '0.62rem', color: '#334155' }}>
|
||||||
|
{chartData.length} day{chartData.length !== 1 ? 's' : ''} of data
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{chartData.length < 2 ? (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
height: '160px', color: '#334155',
|
||||||
|
fontFamily: 'monospace', fontSize: '0.72rem',
|
||||||
|
border: '1px dashed rgba(245,158,11,0.1)', borderRadius: '0.375rem',
|
||||||
|
}}>
|
||||||
|
{chartData.length === 0
|
||||||
|
? 'Trend data begins accumulating after the first sync — check back tomorrow'
|
||||||
|
: 'Need at least 2 days of syncs to display a trend'}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ResponsiveContainer width="100%" height={220}>
|
||||||
|
<LineChart data={chartData} margin={{ top: 4, right: 12, bottom: 0, left: -12 }}>
|
||||||
|
<CartesianGrid {...GRID_STYLE} />
|
||||||
|
<XAxis dataKey="date" tick={AXIS_STYLE} />
|
||||||
|
<YAxis tick={AXIS_STYLE} allowDecimals={false} />
|
||||||
|
<Tooltip content={<DarkTooltip />} />
|
||||||
|
<Legend wrapperStyle={LEGEND_STYLE} iconSize={8} />
|
||||||
|
<Line
|
||||||
|
type="monotone" dataKey="open_count" name="Open"
|
||||||
|
stroke={AMBER} strokeWidth={2}
|
||||||
|
dot={{ r: 3, fill: AMBER, strokeWidth: 0 }}
|
||||||
|
activeDot={{ r: 5 }}
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
type="monotone" dataKey="closed_count" name="Closed"
|
||||||
|
stroke={SKY} strokeWidth={2}
|
||||||
|
dot={{ r: 3, fill: SKY, strokeWidth: 0 }}
|
||||||
|
activeDot={{ r: 5 }}
|
||||||
|
/>
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,25 +1,484 @@
|
|||||||
import React from 'react';
|
// KnowledgeBasePage.js
|
||||||
import { BookOpen } from 'lucide-react';
|
// Full-page knowledge base library — browse, search, filter, and read
|
||||||
|
// articles inline. Upload and delete require editor/admin role.
|
||||||
|
// Reuses existing KnowledgeBaseViewer and KnowledgeBaseModal components.
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||||
|
import {
|
||||||
|
BookOpen, Search, Upload, RefreshCw, Loader,
|
||||||
|
AlertCircle, FileText, File, Trash2, X,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { useAuth } from '../../contexts/AuthContext';
|
||||||
|
import KnowledgeBaseModal from '../KnowledgeBaseModal';
|
||||||
|
import KnowledgeBaseViewer from '../KnowledgeBaseViewer';
|
||||||
|
|
||||||
|
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
||||||
|
const GREEN = '#10B981';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Static config
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const CATEGORY_COLORS = {
|
||||||
|
General: '#94A3B8',
|
||||||
|
Policy: '#0EA5E9',
|
||||||
|
Procedure: GREEN,
|
||||||
|
Guide: '#F59E0B',
|
||||||
|
Reference: '#8B5CF6',
|
||||||
|
};
|
||||||
|
|
||||||
|
const FILE_EXT_COLORS = {
|
||||||
|
pdf: '#EF4444',
|
||||||
|
md: '#10B981',
|
||||||
|
txt: '#94A3B8',
|
||||||
|
doc: '#0EA5E9',
|
||||||
|
docx: '#0EA5E9',
|
||||||
|
xls: '#10B981',
|
||||||
|
xlsx: '#10B981',
|
||||||
|
ppt: '#F97316',
|
||||||
|
pptx: '#F97316',
|
||||||
|
html: '#8B5CF6',
|
||||||
|
};
|
||||||
|
|
||||||
|
const CATEGORY_ORDER = ['Procedure', 'Guide', 'Policy', 'Reference', 'General'];
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function extOf(filename) {
|
||||||
|
return (filename || '').split('.').pop().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function extColor(filename) {
|
||||||
|
return FILE_EXT_COLORS[extOf(filename)] || '#64748B';
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtSize(bytes) {
|
||||||
|
if (!bytes) return '';
|
||||||
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||||
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtDate(str) {
|
||||||
|
if (!str) return '';
|
||||||
|
return new Date(str).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function catColor(cat) {
|
||||||
|
return CATEGORY_COLORS[cat] || '#94A3B8';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// ArticleCard
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function ArticleCard({ article, selected, onSelect, onDelete, canDelete }) {
|
||||||
|
const color = catColor(article.category);
|
||||||
|
const fileColor = extColor(article.file_name);
|
||||||
|
const ext = extOf(article.file_name).toUpperCase();
|
||||||
|
|
||||||
export default function KnowledgeBasePage() {
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: '60vh' }}>
|
<div
|
||||||
<div style={{ textAlign: 'center' }}>
|
onClick={() => onSelect(article)}
|
||||||
<div style={{
|
style={{
|
||||||
width: '72px', height: '72px', borderRadius: '1rem', margin: '0 auto 1.5rem',
|
background: selected
|
||||||
background: 'rgba(16, 185, 129, 0.1)',
|
? `linear-gradient(135deg,rgba(16,185,129,0.1) 0%,rgba(15,23,42,0.98) 100%)`
|
||||||
border: '1px solid rgba(16, 185, 129, 0.3)',
|
: 'linear-gradient(135deg,rgba(30,41,59,0.95) 0%,rgba(15,23,42,0.98) 100%)',
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center'
|
border: `1.5px solid ${selected ? GREEN : 'rgba(16,185,129,0.12)'}`,
|
||||||
|
borderRadius: '0.5rem',
|
||||||
|
padding: '1rem',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all 0.15s',
|
||||||
|
position: 'relative',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '0.5rem',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => { if (!selected) e.currentTarget.style.borderColor = 'rgba(16,185,129,0.35)'; }}
|
||||||
|
onMouseLeave={e => { if (!selected) e.currentTarget.style.borderColor = 'rgba(16,185,129,0.12)'; }}
|
||||||
|
>
|
||||||
|
{/* File type badge + delete button */}
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<span style={{
|
||||||
|
fontFamily: 'monospace', fontSize: '0.58rem', fontWeight: '700',
|
||||||
|
color: fileColor, padding: '0.15rem 0.4rem',
|
||||||
|
background: `${fileColor}15`, borderRadius: '0.2rem',
|
||||||
|
border: `1px solid ${fileColor}30`,
|
||||||
}}>
|
}}>
|
||||||
<BookOpen style={{ width: '36px', height: '36px', color: '#10B981' }} />
|
{ext}
|
||||||
|
</span>
|
||||||
|
{canDelete && (
|
||||||
|
<button
|
||||||
|
onClick={e => { e.stopPropagation(); onDelete(article); }}
|
||||||
|
title="Delete article"
|
||||||
|
style={{
|
||||||
|
background: 'none', border: 'none', cursor: 'pointer',
|
||||||
|
color: '#334155', padding: '0.15rem',
|
||||||
|
borderRadius: '0.2rem', display: 'flex', alignItems: 'center',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => { e.currentTarget.style.color = '#EF4444'; }}
|
||||||
|
onMouseLeave={e => { e.currentTarget.style.color = '#334155'; }}
|
||||||
|
>
|
||||||
|
<Trash2 style={{ width: '12px', height: '12px' }} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Title */}
|
||||||
|
<div style={{
|
||||||
|
fontFamily: 'monospace', fontSize: '0.82rem', fontWeight: '700',
|
||||||
|
color: selected ? GREEN : '#E2E8F0',
|
||||||
|
lineHeight: 1.3,
|
||||||
|
}}>
|
||||||
|
{article.title}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
{article.description && (
|
||||||
|
<div style={{
|
||||||
|
fontSize: '0.7rem', color: '#475569',
|
||||||
|
lineHeight: 1.45, display: '-webkit-box',
|
||||||
|
WebkitLineClamp: 2, WebkitBoxOrient: 'vertical',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}>
|
||||||
|
{article.description}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Footer — category + date */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginTop: 'auto', paddingTop: '0.375rem', borderTop: '1px solid rgba(255,255,255,0.04)' }}>
|
||||||
|
<span style={{
|
||||||
|
fontFamily: 'monospace', fontSize: '0.6rem', fontWeight: '600',
|
||||||
|
color, padding: '0.15rem 0.4rem',
|
||||||
|
background: `${color}12`, borderRadius: '0.2rem',
|
||||||
|
border: `1px solid ${color}25`,
|
||||||
|
textTransform: 'uppercase', letterSpacing: '0.04em',
|
||||||
|
}}>
|
||||||
|
{article.category}
|
||||||
|
</span>
|
||||||
|
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
|
||||||
|
{article.file_size && (
|
||||||
|
<span style={{ fontFamily: 'monospace', fontSize: '0.6rem', color: '#334155' }}>
|
||||||
|
{fmtSize(article.file_size)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span style={{ fontFamily: 'monospace', fontSize: '0.6rem', color: '#334155' }}>
|
||||||
|
{fmtDate(article.created_at)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<h2 style={{ fontFamily: 'monospace', fontSize: '1.5rem', fontWeight: '700', color: '#10B981', textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: '0.5rem' }}>
|
|
||||||
Knowledge Base
|
|
||||||
</h2>
|
|
||||||
<p style={{ color: '#475569', fontSize: '0.875rem', fontFamily: 'monospace' }}>
|
|
||||||
Under construction — coming soon
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Empty state
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function EmptyState({ hasFilter, onClear }) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
gridColumn: '1 / -1',
|
||||||
|
display: 'flex', flexDirection: 'column', alignItems: 'center',
|
||||||
|
justifyContent: 'center', padding: '4rem 2rem',
|
||||||
|
border: '1px dashed rgba(16,185,129,0.15)', borderRadius: '0.5rem',
|
||||||
|
color: '#334155',
|
||||||
|
}}>
|
||||||
|
<BookOpen style={{ width: '36px', height: '36px', marginBottom: '1rem', opacity: 0.4 }} />
|
||||||
|
<div style={{ fontFamily: 'monospace', fontSize: '0.8rem', marginBottom: '0.375rem' }}>
|
||||||
|
{hasFilter ? 'No articles match your search' : 'No articles yet'}
|
||||||
|
</div>
|
||||||
|
{hasFilter ? (
|
||||||
|
<button onClick={onClear} style={{
|
||||||
|
background: 'none', border: 'none', cursor: 'pointer',
|
||||||
|
color: GREEN, fontFamily: 'monospace', fontSize: '0.72rem',
|
||||||
|
marginTop: '0.375rem',
|
||||||
|
}}>
|
||||||
|
Clear filters
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div style={{ fontFamily: 'monospace', fontSize: '0.7rem', color: '#475569' }}>
|
||||||
|
Upload a document to get started
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Main page
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
export default function KnowledgeBasePage() {
|
||||||
|
const { canWrite } = useAuth();
|
||||||
|
|
||||||
|
const [articles, setArticles] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [activeCategory, setActiveCategory] = useState('All');
|
||||||
|
const [selected, setSelected] = useState(null);
|
||||||
|
const [showUpload, setShowUpload] = useState(false);
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Fetch
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
const fetchArticles = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/knowledge-base`, { credentials: 'include' });
|
||||||
|
if (!res.ok) throw new Error('Failed to load articles');
|
||||||
|
const data = await res.json();
|
||||||
|
setArticles(data);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => { fetchArticles(); }, [fetchArticles]);
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Delete
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
const handleDelete = useCallback(async (article) => {
|
||||||
|
if (!window.confirm(`Delete "${article.title}"? This cannot be undone.`)) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/knowledge-base/${article.id}`, {
|
||||||
|
method: 'DELETE', credentials: 'include',
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('Delete failed');
|
||||||
|
setArticles(prev => prev.filter(a => a.id !== article.id));
|
||||||
|
if (selected?.id === article.id) setSelected(null);
|
||||||
|
} catch (err) {
|
||||||
|
alert(`Failed to delete: ${err.message}`);
|
||||||
|
}
|
||||||
|
}, [selected]);
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Filtering
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
const q = search.trim().toLowerCase();
|
||||||
|
return articles.filter(a => {
|
||||||
|
const matchesCat = activeCategory === 'All' || a.category === activeCategory;
|
||||||
|
const matchesSearch = !q ||
|
||||||
|
a.title.toLowerCase().includes(q) ||
|
||||||
|
(a.description || '').toLowerCase().includes(q);
|
||||||
|
return matchesCat && matchesSearch;
|
||||||
|
});
|
||||||
|
}, [articles, activeCategory, search]);
|
||||||
|
|
||||||
|
// Category tab counts (always from full list, not filtered by search)
|
||||||
|
const categoryCounts = useMemo(() => {
|
||||||
|
const counts = { All: articles.length };
|
||||||
|
CATEGORY_ORDER.forEach(cat => {
|
||||||
|
counts[cat] = articles.filter(a => a.category === cat).length;
|
||||||
|
});
|
||||||
|
return counts;
|
||||||
|
}, [articles]);
|
||||||
|
|
||||||
|
const activeTabs = ['All', ...CATEGORY_ORDER.filter(c => categoryCounts[c] > 0)];
|
||||||
|
|
||||||
|
const clearFilters = () => { setSearch(''); setActiveCategory('All'); };
|
||||||
|
|
||||||
|
const hasFilter = search.trim() !== '' || activeCategory !== 'All';
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Render
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.25rem', paddingBottom: '2rem' }}>
|
||||||
|
|
||||||
|
{/* ── Page header ─────────────────────────────────────────── */}
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||||
|
<div>
|
||||||
|
<h2 style={{
|
||||||
|
fontFamily: 'monospace', fontSize: '1.5rem', fontWeight: '700',
|
||||||
|
color: GREEN, textTransform: 'uppercase', letterSpacing: '0.1em',
|
||||||
|
textShadow: `0 0 16px ${GREEN}40`, marginBottom: '0.25rem',
|
||||||
|
}}>
|
||||||
|
Knowledge Base
|
||||||
|
</h2>
|
||||||
|
<div style={{ fontSize: '0.72rem', color: '#475569', fontFamily: 'monospace' }}>
|
||||||
|
{loading ? '…' : `${articles.length} article${articles.length !== 1 ? 's' : ''}`}
|
||||||
|
{articles.length > 0 && activeCategory !== 'All' && (
|
||||||
|
<span style={{ marginLeft: '0.5rem', color: '#334155' }}>
|
||||||
|
· {categoryCounts[activeCategory] || 0} in {activeCategory}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: '0.5rem', flexShrink: 0 }}>
|
||||||
|
<button
|
||||||
|
onClick={fetchArticles}
|
||||||
|
title="Refresh"
|
||||||
|
style={{
|
||||||
|
background: 'none', border: `1px solid rgba(16,185,129,0.25)`,
|
||||||
|
borderRadius: '0.375rem', padding: '0.5rem', cursor: 'pointer', color: '#475569',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => { e.currentTarget.style.color = GREEN; e.currentTarget.style.borderColor = `${GREEN}60`; }}
|
||||||
|
onMouseLeave={e => { e.currentTarget.style.color = '#475569'; e.currentTarget.style.borderColor = 'rgba(16,185,129,0.25)'; }}
|
||||||
|
>
|
||||||
|
<RefreshCw style={{ width: '16px', height: '16px' }} />
|
||||||
|
</button>
|
||||||
|
{canWrite() && (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowUpload(true)}
|
||||||
|
style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: '0.4rem',
|
||||||
|
background: `${GREEN}18`, border: `1px solid ${GREEN}`,
|
||||||
|
color: GREEN, padding: '0.5rem 1rem',
|
||||||
|
fontFamily: 'monospace', fontSize: '0.75rem', fontWeight: '600',
|
||||||
|
textTransform: 'uppercase', letterSpacing: '0.05em',
|
||||||
|
cursor: 'pointer', borderRadius: '0.375rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Upload style={{ width: '14px', height: '14px' }} />
|
||||||
|
Upload Article
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Search + category tabs ───────────────────────────────── */}
|
||||||
|
<div style={{ display: 'flex', gap: '0.75rem', alignItems: 'center', flexWrap: 'wrap' }}>
|
||||||
|
|
||||||
|
{/* Search */}
|
||||||
|
<div style={{ position: 'relative', flexShrink: 0 }}>
|
||||||
|
<Search style={{
|
||||||
|
position: 'absolute', left: '0.625rem', top: '50%', transform: 'translateY(-50%)',
|
||||||
|
width: '13px', height: '13px', color: '#334155', pointerEvents: 'none',
|
||||||
|
}} />
|
||||||
|
<input
|
||||||
|
value={search}
|
||||||
|
onChange={e => setSearch(e.target.value)}
|
||||||
|
placeholder="Search articles…"
|
||||||
|
style={{
|
||||||
|
paddingLeft: '2rem', paddingRight: search ? '2rem' : '0.625rem',
|
||||||
|
paddingTop: '0.4rem', paddingBottom: '0.4rem',
|
||||||
|
background: 'rgba(15,23,42,0.8)',
|
||||||
|
border: '1px solid rgba(16,185,129,0.2)',
|
||||||
|
borderRadius: '0.375rem', color: '#E2E8F0',
|
||||||
|
outline: 'none', fontFamily: 'monospace', fontSize: '0.75rem',
|
||||||
|
width: '220px',
|
||||||
|
}}
|
||||||
|
onFocus={e => e.target.style.borderColor = `${GREEN}60`}
|
||||||
|
onBlur={e => e.target.style.borderColor = 'rgba(16,185,129,0.2)'}
|
||||||
|
/>
|
||||||
|
{search && (
|
||||||
|
<button
|
||||||
|
onClick={() => setSearch('')}
|
||||||
|
style={{
|
||||||
|
position: 'absolute', right: '0.5rem', top: '50%', transform: 'translateY(-50%)',
|
||||||
|
background: 'none', border: 'none', cursor: 'pointer', color: '#334155', padding: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X style={{ width: '12px', height: '12px' }} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Category tabs */}
|
||||||
|
<div style={{ display: 'flex', gap: '0.3rem', flexWrap: 'wrap' }}>
|
||||||
|
{activeTabs.map(cat => {
|
||||||
|
const isActive = activeCategory === cat;
|
||||||
|
const color = cat === 'All' ? GREEN : catColor(cat);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={cat}
|
||||||
|
onClick={() => setActiveCategory(cat)}
|
||||||
|
style={{
|
||||||
|
padding: '0.35rem 0.75rem',
|
||||||
|
fontFamily: 'monospace', fontSize: '0.7rem', fontWeight: '600',
|
||||||
|
textTransform: 'uppercase', letterSpacing: '0.05em',
|
||||||
|
cursor: 'pointer', borderRadius: '0.25rem',
|
||||||
|
border: isActive ? `1px solid ${color}` : '1px solid transparent',
|
||||||
|
background: isActive ? `${color}15` : 'transparent',
|
||||||
|
color: isActive ? color : '#475569',
|
||||||
|
transition: 'all 0.12s',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => { if (!isActive) { e.currentTarget.style.color = '#94A3B8'; e.currentTarget.style.borderColor = 'rgba(255,255,255,0.1)'; }}}
|
||||||
|
onMouseLeave={e => { if (!isActive) { e.currentTarget.style.color = '#475569'; e.currentTarget.style.borderColor = 'transparent'; }}}
|
||||||
|
>
|
||||||
|
{cat}
|
||||||
|
<span style={{ marginLeft: '0.35rem', opacity: 0.6, fontWeight: '400' }}>
|
||||||
|
{categoryCounts[cat] ?? 0}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Error state ──────────────────────────────────────────── */}
|
||||||
|
{error && (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: '0.5rem',
|
||||||
|
padding: '0.875rem 1rem',
|
||||||
|
background: 'rgba(239,68,68,0.08)', border: '1px solid rgba(239,68,68,0.3)',
|
||||||
|
borderRadius: '0.5rem', color: '#F87171',
|
||||||
|
fontFamily: 'monospace', fontSize: '0.78rem',
|
||||||
|
}}>
|
||||||
|
<AlertCircle style={{ width: '15px', height: '15px', flexShrink: 0 }} />
|
||||||
|
{error}
|
||||||
|
<button
|
||||||
|
onClick={fetchArticles}
|
||||||
|
style={{ marginLeft: 'auto', background: 'none', border: 'none', cursor: 'pointer', color: '#F87171', fontFamily: 'monospace', fontSize: '0.72rem' }}
|
||||||
|
>
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Loading state ────────────────────────────────────────── */}
|
||||||
|
{loading && (
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'center', padding: '3rem' }}>
|
||||||
|
<Loader style={{ width: '28px', height: '28px', color: GREEN, animation: 'spin 1s linear infinite' }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Article grid ─────────────────────────────────────────── */}
|
||||||
|
{!loading && !error && (
|
||||||
|
<div style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(auto-fill, minmax(240px, 1fr))',
|
||||||
|
gap: '0.875rem',
|
||||||
|
}}>
|
||||||
|
{filtered.length === 0 ? (
|
||||||
|
<EmptyState hasFilter={hasFilter} onClear={clearFilters} />
|
||||||
|
) : (
|
||||||
|
filtered.map(article => (
|
||||||
|
<ArticleCard
|
||||||
|
key={article.id}
|
||||||
|
article={article}
|
||||||
|
selected={selected?.id === article.id}
|
||||||
|
onSelect={a => setSelected(selected?.id === a.id ? null : a)}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
canDelete={canWrite()}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Inline viewer ────────────────────────────────────────── */}
|
||||||
|
{selected && (
|
||||||
|
<div style={{ marginTop: '0.25rem' }}>
|
||||||
|
<KnowledgeBaseViewer
|
||||||
|
article={selected}
|
||||||
|
onClose={() => setSelected(null)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Upload modal ─────────────────────────────────────────── */}
|
||||||
|
{showUpload && (
|
||||||
|
<KnowledgeBaseModal
|
||||||
|
onClose={() => setShowUpload(false)}
|
||||||
|
onUpdate={() => { fetchArticles(); setShowUpload(false); }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import ReactDOM from 'react-dom';
|
|||||||
import { RefreshCw, Loader, AlertCircle, PieChart, ChevronUp, ChevronDown, ChevronsUpDown, Settings2, GripVertical, Eye, EyeOff, Filter, Download, RotateCcw, Trash2, X, ListTodo } from 'lucide-react';
|
import { RefreshCw, Loader, AlertCircle, PieChart, ChevronUp, ChevronDown, ChevronsUpDown, Settings2, GripVertical, Eye, EyeOff, Filter, Download, RotateCcw, Trash2, X, ListTodo } from 'lucide-react';
|
||||||
import * as XLSX from 'xlsx';
|
import * as XLSX from 'xlsx';
|
||||||
import { useAuth } from '../../contexts/AuthContext';
|
import { useAuth } from '../../contexts/AuthContext';
|
||||||
|
import IvantiCountsChart from './IvantiCountsChart';
|
||||||
|
|
||||||
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
||||||
const STORAGE_KEY = 'steam_findings_columns_v2';
|
const STORAGE_KEY = 'steam_findings_columns_v2';
|
||||||
@@ -1536,7 +1537,7 @@ function QueuePanel({ open, items, onClose, onUpdate, onDelete, onDeleteMany, on
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Main ReportingPage
|
// Main ReportingPage
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
export default function ReportingPage({ filterDate, filterEXC }) {
|
export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
|
||||||
const { canWrite } = useAuth();
|
const { canWrite } = useAuth();
|
||||||
const [findings, setFindings] = useState([]);
|
const [findings, setFindings] = useState([]);
|
||||||
const [total, setTotal] = useState(null);
|
const [total, setTotal] = useState(null);
|
||||||
@@ -1965,6 +1966,11 @@ export default function ReportingPage({ filterDate, filterEXC }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* ----------------------------------------------------------------
|
||||||
|
Panel 1.5 — Open vs Closed trend over time
|
||||||
|
---------------------------------------------------------------- */}
|
||||||
|
<IvantiCountsChart />
|
||||||
|
|
||||||
{/* ----------------------------------------------------------------
|
{/* ----------------------------------------------------------------
|
||||||
Panel 2 — Findings table
|
Panel 2 — Findings table
|
||||||
---------------------------------------------------------------- */}
|
---------------------------------------------------------------- */}
|
||||||
|
|||||||
Reference in New Issue
Block a user