Compare commits
33 Commits
feature/wo
...
b58bd0650a
| Author | SHA1 | Date | |
|---|---|---|---|
| b58bd0650a | |||
| ae04bc981e | |||
| 7314dc16cb | |||
| 602c75bf24 | |||
| 706ef19872 | |||
| 8392124df5 | |||
| fbe4333e9b | |||
| 07894709ba | |||
| 071aef96a1 | |||
| a9404ff82a | |||
| f24cdb5063 | |||
| 3e2546323e | |||
| b1a21e8771 | |||
| bc9e223ab7 | |||
| 2d1acca990 | |||
| 9893460b64 | |||
| 51b1f99b3a | |||
| 669396f635 | |||
| 8b3ea22fa0 | |||
| 75b8ecc61d | |||
| ade3cc25ad | |||
| 3fd6158eb3 | |||
| 5bbaaf5918 | |||
| 1f36d302ea | |||
| 8697ba4ef3 | |||
| d3806e8ce3 | |||
| 931c42faeb | |||
| ea3b72db5c | |||
| d63e7cc9b9 | |||
| 37e183543a | |||
| 337ffe6f35 | |||
| 08c8c8a2a1 | |||
| 4ed7721a71 |
454
README.md
454
README.md
@@ -1,6 +1,6 @@
|
|||||||
# CVE Dashboard
|
# CVE Dashboard
|
||||||
|
|
||||||
A self-hosted vulnerability management dashboard for tracking CVE remediation status, maintaining vendor documentation, and managing risk acceptance workflows.
|
A self-hosted vulnerability management dashboard for tracking CVE remediation status, managing vendor documentation, monitoring Ivanti host findings, and overseeing False Positive (FP) workflows.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -13,6 +13,15 @@ A self-hosted vulnerability management dashboard for tracking CVE remediation st
|
|||||||
- [Configuration](#configuration)
|
- [Configuration](#configuration)
|
||||||
- [Running the Application](#running-the-application)
|
- [Running the Application](#running-the-application)
|
||||||
- [Features](#features)
|
- [Features](#features)
|
||||||
|
- [Authentication and User Roles](#authentication-and-user-roles)
|
||||||
|
- [Home Dashboard — CVE Management](#home-dashboard--cve-management)
|
||||||
|
- [Reporting — Host Findings](#reporting--host-findings)
|
||||||
|
- [Knowledge Base](#knowledge-base)
|
||||||
|
- [Archer Risk Acceptance Tickets](#archer-risk-acceptance-tickets)
|
||||||
|
- [Weekly Reports](#weekly-reports)
|
||||||
|
- [User Management](#user-management-admin)
|
||||||
|
- [Audit Log](#audit-log-admin)
|
||||||
|
- [Scripts](#scripts)
|
||||||
- [API Reference](#api-reference)
|
- [API Reference](#api-reference)
|
||||||
- [Architecture](#architecture)
|
- [Architecture](#architecture)
|
||||||
- [Database Schema](#database-schema)
|
- [Database Schema](#database-schema)
|
||||||
@@ -23,12 +32,15 @@ A self-hosted vulnerability management dashboard for tracking CVE remediation st
|
|||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
The CVE Dashboard answers a common problem in vulnerability management: before requesting false positive designations, you need to know whether a CVE has already been addressed, and whether the supporting vendor documentation exists. This application provides:
|
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.
|
||||||
|
|
||||||
- A searchable, filterable CVE list with per-vendor tracking
|
The application provides:
|
||||||
- Document storage attached to each CVE/vendor pair (advisories, emails, screenshots, patches)
|
|
||||||
|
- 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
|
||||||
- Archer risk acceptance ticket tracking (EXC numbers)
|
- **Ivanti/RiskSense integration** to sync open and closed host findings with live FP workflow tracking
|
||||||
|
- **Reporting page** with donut charts, advanced per-column filtering, inline editing, and CSV/XLSX export
|
||||||
|
- Archer risk acceptance ticket tracking (EXC numbers) linked to CVE/vendor pairs
|
||||||
- Weekly vulnerability report upload and processing
|
- 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
|
||||||
@@ -43,8 +55,9 @@ The CVE Dashboard answers a common problem in vulnerability management: before r
|
|||||||
| Database | SQLite3 |
|
| Database | SQLite3 |
|
||||||
| File uploads | Multer 2 |
|
| File uploads | Multer 2 |
|
||||||
| Auth | bcryptjs, cookie-based sessions |
|
| Auth | bcryptjs, cookie-based sessions |
|
||||||
| Frontend | React 19, lucide-react, react-markdown |
|
| Frontend | React 19, lucide-react, xlsx |
|
||||||
| Report processing | Python 3 (pandas, openpyxl) |
|
| Report processing | Python 3 (stdlib only — no extra packages required for notes import) |
|
||||||
|
| Weekly report processing | Python 3, pandas, openpyxl |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -52,7 +65,7 @@ The CVE Dashboard answers a common problem in vulnerability management: before r
|
|||||||
|
|
||||||
- Node.js 18 or later
|
- Node.js 18 or later
|
||||||
- npm
|
- npm
|
||||||
- Python 3 with pip (required only for weekly report processing)
|
- Python 3 (required for weekly report processing and bulk notes import)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -72,8 +85,6 @@ cd backend
|
|||||||
npm install
|
npm install
|
||||||
```
|
```
|
||||||
|
|
||||||
The root `package.json` lists the backend dependencies. Install them from the `backend/` directory where `server.js` lives.
|
|
||||||
|
|
||||||
### 3. Install frontend dependencies
|
### 3. Install frontend dependencies
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -81,7 +92,7 @@ cd frontend
|
|||||||
npm install
|
npm install
|
||||||
```
|
```
|
||||||
|
|
||||||
### 4. Install Python dependencies (for weekly report upload feature)
|
### 4. Install Python dependencies (for weekly report processing)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd backend/scripts
|
cd backend/scripts
|
||||||
@@ -90,9 +101,11 @@ pip install -r requirements.txt
|
|||||||
|
|
||||||
Required packages: `pandas>=2.0.0`, `openpyxl>=3.0.0`
|
Required packages: `pandas>=2.0.0`, `openpyxl>=3.0.0`
|
||||||
|
|
||||||
|
> The bulk notes import script (`import_notes_from_csv.py`) uses only Python stdlib and does **not** require these packages.
|
||||||
|
|
||||||
### 5. Initialize the database
|
### 5. Initialize the database
|
||||||
|
|
||||||
Run this once from the `backend/` directory to create the SQLite database, all tables, indexes, the uploads directory, and a default admin user:
|
Run once from the `backend/` directory to create the SQLite database, all tables, indexes, and a default admin user:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd backend
|
cd backend
|
||||||
@@ -107,15 +120,19 @@ This creates `backend/cve_database.db` and a default admin account:
|
|||||||
|
|
||||||
### 6. Run database migrations
|
### 6. Run database migrations
|
||||||
|
|
||||||
After the initial setup, apply the feature migrations in order:
|
After the initial setup, apply feature migrations in order:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd backend
|
cd backend
|
||||||
node migrations/add_weekly_reports_table.js
|
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_findings_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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
@@ -124,28 +141,37 @@ The application is configured via `.env` files. These files are gitignored and m
|
|||||||
|
|
||||||
### Backend: `backend/.env`
|
### Backend: `backend/.env`
|
||||||
|
|
||||||
```
|
```env
|
||||||
PORT=3001
|
PORT=3001
|
||||||
API_HOST=localhost
|
API_HOST=localhost
|
||||||
CORS_ORIGINS=http://YOUR_IP:3000
|
CORS_ORIGINS=http://YOUR_IP:3000
|
||||||
SESSION_SECRET=change-this-to-a-random-secret
|
SESSION_SECRET=change-this-to-a-long-random-string
|
||||||
NODE_ENV=development
|
NODE_ENV=production
|
||||||
|
|
||||||
# Optional: NVD API key for higher rate limits
|
# Optional: NVD API key for higher rate limits (50 req/30s vs 5 req/30s)
|
||||||
# Register at https://nvd.nist.gov/developers/request-an-api-key
|
# Register at https://nvd.nist.gov/developers/request-an-api-key
|
||||||
NVD_API_KEY=your-key-here
|
NVD_API_KEY=your-key-here
|
||||||
|
|
||||||
|
# Ivanti / RiskSense integration (required for Reporting page sync)
|
||||||
|
IVANTI_API_KEY=your-ivanti-api-key
|
||||||
|
IVANTI_CLIENT_ID=1550
|
||||||
|
# Optional: filter workflows to a specific person's submissions
|
||||||
|
IVANTI_FIRST_NAME=
|
||||||
|
IVANTI_LAST_NAME=
|
||||||
|
# Set to 'true' if your network has SSL inspection / self-signed certs
|
||||||
|
IVANTI_SKIP_TLS=false
|
||||||
```
|
```
|
||||||
|
|
||||||
### Frontend: `frontend/.env`
|
### Frontend: `frontend/.env`
|
||||||
|
|
||||||
```
|
```env
|
||||||
REACT_APP_API_BASE=http://YOUR_IP:3001/api
|
REACT_APP_API_BASE=http://YOUR_IP:3001/api
|
||||||
REACT_APP_API_HOST=http://YOUR_IP:3001
|
REACT_APP_API_HOST=http://YOUR_IP:3001
|
||||||
```
|
```
|
||||||
|
|
||||||
Replace `YOUR_IP` with the machine's IP address or `localhost` for local development.
|
Replace `YOUR_IP` with the machine's IP address or hostname. Use `localhost` for local-only access.
|
||||||
|
|
||||||
**Important:** React caches environment variables at build/start time. After changing `frontend/.env`, you must fully restart the frontend process. A page refresh alone is not sufficient.
|
> **Important:** React caches environment variables at build/start time. After changing `frontend/.env`, fully restart the frontend process — a browser refresh alone is not sufficient.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -165,19 +191,21 @@ The start script saves PIDs to `backend.pid` and `frontend.pid`. Logs are writte
|
|||||||
### Running manually
|
### Running manually
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Terminal 1 - backend
|
# Terminal 1 — backend
|
||||||
cd backend
|
cd backend
|
||||||
node server.js
|
node server.js
|
||||||
|
|
||||||
# Terminal 2 - frontend
|
# Terminal 2 — frontend
|
||||||
cd frontend
|
cd frontend
|
||||||
npm start
|
npm start
|
||||||
```
|
```
|
||||||
|
|
||||||
### Default ports
|
### Default ports
|
||||||
|
|
||||||
- Frontend: http://localhost:3000
|
| Service | URL |
|
||||||
- Backend API: http://localhost:3001
|
|---|---|
|
||||||
|
| Frontend | http://localhost:3000 |
|
||||||
|
| Backend API | http://localhost:3001 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -189,38 +217,167 @@ All routes require authentication. Three roles are supported:
|
|||||||
|
|
||||||
| Role | Permissions |
|
| Role | Permissions |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `viewer` | Read-only access to CVEs, documents, weekly reports, knowledge base, Archer tickets |
|
| `viewer` | Read-only: CVEs, documents, findings, reports, knowledge base, Archer tickets |
|
||||||
| `editor` | All viewer permissions plus: create/update CVEs, upload documents, upload weekly reports, manage knowledge base articles, manage Archer tickets |
|
| `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 |
|
||||||
| `admin` | All editor permissions plus: delete documents, delete weekly 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.
|
||||||
|
|
||||||
### CVE Management
|
---
|
||||||
|
|
||||||
- Add CVEs with full metadata: CVE ID, vendor, severity (Critical/High/Medium/Low), description, published date, and status (Open/In Progress/Addressed/Resolved)
|
### Home Dashboard — CVE Management
|
||||||
- The same CVE ID can be tracked across multiple vendors independently
|
|
||||||
- Filter the CVE list by search term, vendor, severity, and status
|
The home page is the primary CVE workflow tool.
|
||||||
- Edit any field on an existing CVE entry; file paths are updated automatically when CVE ID or vendor changes
|
|
||||||
|
**CVE List**
|
||||||
|
- Search CVEs by keyword (matches CVE ID, vendor, description)
|
||||||
|
- Filter by vendor, severity (Critical / High / Medium / Low), and status
|
||||||
|
- Color-coded severity badges: Critical (red), High (amber), Medium (sky blue), Low (green)
|
||||||
|
- Paginated list view
|
||||||
|
|
||||||
|
**CVE Operations (editor/admin)**
|
||||||
|
- Add a new CVE entry — NVD auto-fill populates description, severity, and published date automatically
|
||||||
|
- Edit any field on an existing CVE entry
|
||||||
|
- Update status for all vendor rows matching a CVE ID in one click
|
||||||
- Delete a single vendor entry or all vendor entries for a CVE ID
|
- Delete a single vendor entry or all vendor entries for a CVE ID
|
||||||
- Paginated list view to prevent performance issues with large datasets
|
- The same CVE ID can be tracked across multiple vendors independently
|
||||||
- Quick Check: look up a CVE ID and see all vendors tracking it with their current status
|
|
||||||
|
|
||||||
### NVD Integration
|
**Document Management**
|
||||||
|
- Upload documents attached to a CVE/vendor pair
|
||||||
|
- Supported document types: `advisory`, `email`, `screenshot`, `patch`, `other`
|
||||||
|
- Allowed file extensions: PDF, images (PNG, JPG, GIF, BMP, TIFF), Office documents (DOC, DOCX, XLS, XLSX, PPT, PPTX), text files (TXT, MD, CSV, LOG), email files (MSG, EML), and others (RTF, HTML, XML, JSON, YAML, ODF variants, ZIP, GZ, TAR, 7Z)
|
||||||
|
- File size limit: 10 MB per upload
|
||||||
|
- Admins can delete documents
|
||||||
|
|
||||||
|
**NVD Integration**
|
||||||
- Auto-fill CVE description, severity, and published date from the NIST NVD API 2.0 when adding a new CVE
|
- Auto-fill CVE description, severity, and published date from the NIST NVD API 2.0 when adding a new CVE
|
||||||
- Bulk NVD Sync: fetch updated metadata for all CVEs in the database in one operation (editor/admin)
|
- Bulk NVD Sync (editor/admin): fetch updated metadata for all CVEs in the database in one operation
|
||||||
- CVSS severity mapping cascades: v3.1 preferred, then v3.0, then v2.0
|
- CVSS severity cascade: v3.1 preferred, then v3.0, then v2.0
|
||||||
- NVD API key support via `NVD_API_KEY` environment variable for higher rate limits
|
- Rate-limit aware: respects NVD's 5 req/30s unauthenticated limit; with `NVD_API_KEY` the limit increases to 50 req/30s
|
||||||
|
|
||||||
### Document Management
|
**Archer Ticket Quick Navigation**
|
||||||
|
- Archer EXC numbers shown on CVE rows
|
||||||
|
- Clicking an EXC badge navigates to the Reporting page with that EXC number pre-filtered
|
||||||
|
|
||||||
Documents are attached to a CVE/vendor pair and stored on disk under `backend/uploads/<CVE-ID>/<vendor>/`.
|
**Calendar Widget**
|
||||||
|
- Shows current month with red dot indicators on dates where Ivanti findings are due
|
||||||
|
- Click a date to navigate to the Reporting page filtered to that due date
|
||||||
|
|
||||||
Supported document types: `advisory`, `email`, `screenshot`, `patch`, `other`
|
---
|
||||||
|
|
||||||
Allowed file extensions: PDF, images (PNG, JPG, GIF, BMP, TIFF), Office documents (DOC, DOCX, XLS, XLSX, PPT, PPTX), text files (TXT, MD, CSV, LOG), email files (MSG, EML), and others (RTF, HTML, XML, JSON, YAML, ODF variants).
|
### Reporting — Host Findings
|
||||||
|
|
||||||
File size limit: 10 MB per upload.
|
The Reporting page is the core operational view for remediation tracking. It integrates with Ivanti/RiskSense to show all host findings for the configured business units.
|
||||||
|
|
||||||
|
#### Syncing Data
|
||||||
|
|
||||||
|
Click **Sync** in the top-right of the page 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)
|
||||||
|
2. Fetches the closed finding count separately
|
||||||
|
3. Sweeps all closed findings to capture FP workflow states (including Approved FPs that are now closed)
|
||||||
|
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.
|
||||||
|
|
||||||
|
> **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`.
|
||||||
|
|
||||||
|
#### Metric Charts
|
||||||
|
|
||||||
|
Four donut charts are shown at the top of the page.
|
||||||
|
|
||||||
|
| 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. |
|
||||||
|
| **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. |
|
||||||
|
| **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 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. |
|
||||||
|
|
||||||
|
#### Findings Table
|
||||||
|
|
||||||
|
The table shows all open findings from the cache. Each row represents a single host finding.
|
||||||
|
|
||||||
|
**Columns**
|
||||||
|
|
||||||
|
| Column | Description |
|
||||||
|
|---|---|
|
||||||
|
| Finding ID | Ivanti finding identifier |
|
||||||
|
| Severity | Numerical VRR score with group label (CRITICAL / HIGH) |
|
||||||
|
| Title | Vulnerability title |
|
||||||
|
| CVEs | Associated CVE IDs — up to 2 shown, remainder as "+N" |
|
||||||
|
| Host | Hostname — inline editable (see Overrides below) |
|
||||||
|
| IP Address | Host IP address |
|
||||||
|
| DNS | DNS/FQDN — inline editable |
|
||||||
|
| Due Date | Remediation due date; red if overdue, amber if within 30 days |
|
||||||
|
| SLA | SLA status: OVERDUE / AT_RISK / WITHIN_SLA |
|
||||||
|
| BU | Business unit; STEAM rows are highlighted |
|
||||||
|
| Workflow | FP# ticket ID and state badge — color-coded by state |
|
||||||
|
| Last Found | Last detection date from Ivanti |
|
||||||
|
| Notes | Free-form notes field — inline editable, persists across syncs |
|
||||||
|
|
||||||
|
**Column Management**
|
||||||
|
|
||||||
|
Click the **Columns** button to open the column manager:
|
||||||
|
- Toggle column visibility with the eye icon
|
||||||
|
- Drag rows to reorder columns
|
||||||
|
- Column order and visibility persist to `localStorage`
|
||||||
|
|
||||||
|
**Sorting**
|
||||||
|
|
||||||
|
Click any sortable column header to sort ascending; click again to sort descending.
|
||||||
|
|
||||||
|
**Filtering**
|
||||||
|
|
||||||
|
Click the filter icon (⊙) on any filterable column header to open a filter dropdown:
|
||||||
|
- 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.
|
||||||
|
|
||||||
|
**Inline Editing**
|
||||||
|
|
||||||
|
- **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.
|
||||||
|
- **Notes**: Click to edit. Saves on blur. Maximum 255 characters. Notes survive cache refreshes.
|
||||||
|
|
||||||
|
**Exporting**
|
||||||
|
|
||||||
|
Click the **Export** button to download the current view (filtered, sorted, visible columns only):
|
||||||
|
- **CSV** — UTF-8 with BOM for Excel compatibility
|
||||||
|
- **Excel (.xlsx)** — Auto-fitted column widths
|
||||||
|
|
||||||
|
Filename format: `findings-export-YYYY-MM-DD.csv` / `.xlsx`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Knowledge Base
|
||||||
|
|
||||||
|
A document library for internal reference material such as policies, runbooks, and vendor advisories.
|
||||||
|
|
||||||
|
- 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)
|
||||||
|
- Download any document
|
||||||
|
- Filter and browse by category
|
||||||
|
- Editors and admins can upload and delete; all authenticated users can view
|
||||||
|
|
||||||
|
Allowed file types: PDF, Markdown, TXT, Office documents (DOC, DOCX, XLS, XLSX, PPT, PPTX), HTML, JSON, YAML, and images (PNG, JPG, GIF).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Archer Risk Acceptance Tickets
|
||||||
|
|
||||||
|
Track Archer exception tickets (EXC numbers) linked to specific CVE/vendor pairs.
|
||||||
|
|
||||||
|
- EXC number format: `EXC-NNNNN`
|
||||||
|
- Statuses: `Draft`, `Open`, `Under Review`, `Accepted`
|
||||||
|
- Optional Archer URL field for deep-linking to the Archer record
|
||||||
|
- Filter tickets by CVE ID, vendor, or status
|
||||||
|
- EXC numbers are unique across the system
|
||||||
|
- Clicking an EXC number on the home page navigates directly to the Reporting page with that EXC pre-filtered
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### Weekly Reports
|
### Weekly Reports
|
||||||
|
|
||||||
@@ -232,40 +389,71 @@ Editors and admins can upload weekly vulnerability reports as `.xlsx` files. The
|
|||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
### Archer Risk Acceptance Tickets
|
---
|
||||||
|
|
||||||
Track Archer exception tickets (EXC numbers) linked to specific CVE/vendor pairs.
|
|
||||||
|
|
||||||
- EXC number format: `EXC-NNNNN`
|
|
||||||
- Statuses: `Draft`, `Open`, `Under Review`, `Accepted`
|
|
||||||
- Optional Archer URL field for deep-linking to the Archer record
|
|
||||||
- Filter tickets by CVE ID, vendor, or status
|
|
||||||
- EXC numbers are unique across the system
|
|
||||||
|
|
||||||
### Knowledge Base
|
|
||||||
|
|
||||||
A document library for internal reference material such as policies, runbooks, and vendor advisories.
|
|
||||||
|
|
||||||
- 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)
|
|
||||||
- Download any document
|
|
||||||
- Filter and browse by category
|
|
||||||
- Editors and admins can upload and delete; all authenticated users can view
|
|
||||||
|
|
||||||
Allowed file types: PDF, Markdown, TXT, Office documents, HTML, JSON, YAML, and images.
|
|
||||||
|
|
||||||
### User Management (Admin)
|
### User Management (Admin)
|
||||||
|
|
||||||
Admins can create, update, and delete user accounts from the UI. Supported operations:
|
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
|
||||||
- Admins cannot demote themselves or deactivate their own account
|
- Admins cannot demote themselves or deactivate their own account
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### 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 audit log with filtering by user, action type, entity type, and date range. Results are paginated.
|
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).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scripts
|
||||||
|
|
||||||
|
### `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.
|
||||||
|
|
||||||
|
**CSV format:**
|
||||||
|
```csv
|
||||||
|
ID,NOTES
|
||||||
|
12345678,EXC-5754
|
||||||
|
87654321,Patched in Feb maintenance window
|
||||||
|
```
|
||||||
|
|
||||||
|
**Usage:**
|
||||||
|
```bash
|
||||||
|
cd backend/scripts
|
||||||
|
|
||||||
|
# Preview what would be imported (no writes)
|
||||||
|
python3 import_notes_from_csv.py input.csv --dry-run
|
||||||
|
|
||||||
|
# Import against the default database path
|
||||||
|
python3 import_notes_from_csv.py input.csv
|
||||||
|
|
||||||
|
# Import against a specific database
|
||||||
|
python3 import_notes_from_csv.py input.csv --db /path/to/cve_database.db
|
||||||
|
```
|
||||||
|
|
||||||
|
| Argument | Description |
|
||||||
|
|---|---|
|
||||||
|
| `csv_file` | Path to the input CSV (required) |
|
||||||
|
| `--db` | Path to the SQLite database (default: `../cve_database.db`) |
|
||||||
|
| `--dry-run` | Preview changes without writing to the database |
|
||||||
|
|
||||||
|
- Notes longer than 255 characters are truncated with a warning
|
||||||
|
- Finding IDs not present in the active Ivanti cache are skipped
|
||||||
|
- Uses UPSERT — running the same CSV twice is safe
|
||||||
|
|
||||||
|
**Dependencies:** Python stdlib only (no pip install required).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `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`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -286,21 +474,21 @@ All endpoints are prefixed with `/api`. All endpoints except `/api/auth/login` a
|
|||||||
|
|
||||||
| Method | Path | Role | Description |
|
| Method | Path | Role | Description |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| GET | `/api/cves` | viewer+ | List CVEs with optional filters: `search`, `vendor`, `severity`, `status` |
|
| GET | `/api/cves` | viewer+ | List CVEs; query params: `search`, `vendor`, `severity`, `status` |
|
||||||
| POST | `/api/cves` | editor+ | Create a new CVE entry |
|
| POST | `/api/cves` | editor+ | Create a new CVE entry |
|
||||||
| PUT | `/api/cves/:id` | editor+ | Update a CVE entry by row ID |
|
| PUT | `/api/cves/:id` | editor+ | Update a CVE entry by row ID |
|
||||||
| PATCH | `/api/cves/:cveId/status` | editor+ | Update status for all vendor rows matching a CVE ID |
|
| PATCH | `/api/cves/:cveId/status` | editor+ | Update status for all vendor rows matching a CVE ID |
|
||||||
| DELETE | `/api/cves/:id` | editor+ | Delete a single CVE vendor entry |
|
| DELETE | `/api/cves/:id` | editor+ | Delete a single CVE vendor entry |
|
||||||
| DELETE | `/api/cves/by-cve-id/:cveId` | editor+ | Delete all vendor entries for a CVE ID |
|
| DELETE | `/api/cves/by-cve-id/:cveId` | editor+ | Delete all vendor entries for a CVE ID |
|
||||||
| GET | `/api/cves/check/:cveId` | viewer+ | Quick check: does this CVE exist and what is its status? |
|
| GET | `/api/cves/check/:cveId` | viewer+ | Quick check: existence and status of a CVE |
|
||||||
| GET | `/api/cves/distinct-ids` | viewer+ | List all distinct CVE IDs (used by NVD sync) |
|
| GET | `/api/cves/distinct-ids` | viewer+ | All distinct CVE IDs (used by NVD sync) |
|
||||||
| GET | `/api/cves/:cveId/vendors` | viewer+ | List all vendor entries for a specific CVE ID |
|
| GET | `/api/cves/:cveId/vendors` | viewer+ | All vendor entries for a specific CVE ID |
|
||||||
|
|
||||||
### Documents
|
### Documents
|
||||||
|
|
||||||
| Method | Path | Role | Description |
|
| Method | Path | Role | Description |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| GET | `/api/cves/:cveId/documents` | viewer+ | List documents for a CVE, optionally filtered by `?vendor=` |
|
| GET | `/api/cves/:cveId/documents` | viewer+ | List documents for a CVE; optional `?vendor=` filter |
|
||||||
| POST | `/api/cves/:cveId/documents` | editor+ | Upload a document for a CVE/vendor pair |
|
| POST | `/api/cves/:cveId/documents` | editor+ | Upload a document for a CVE/vendor pair |
|
||||||
| DELETE | `/api/documents/:id` | admin | Delete a document and its file from disk |
|
| DELETE | `/api/documents/:id` | admin | Delete a document and its file from disk |
|
||||||
|
|
||||||
@@ -308,9 +496,27 @@ All endpoints are prefixed with `/api`. All endpoints except `/api/auth/login` a
|
|||||||
|
|
||||||
| Method | Path | Role | Description |
|
| Method | Path | Role | Description |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| GET | `/api/nvd/lookup/:cveId` | viewer+ | Look up a single CVE in the NVD 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
|
||||||
|
|
||||||
|
| 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 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 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 |
|
||||||
|
| 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` |
|
||||||
|
| PUT | `/api/ivanti/findings/:findingId/override` | editor+ | Override `hostName` or `dns` for a finding; empty value clears the override |
|
||||||
|
| PUT | `/api/ivanti/findings/:findingId/note` | viewer+ | Save or update a finding note (max 255 chars) |
|
||||||
|
|
||||||
### Weekly Reports
|
### Weekly Reports
|
||||||
|
|
||||||
| Method | Path | Role | Description |
|
| Method | Path | Role | Description |
|
||||||
@@ -335,7 +541,7 @@ All endpoints are prefixed with `/api`. All endpoints except `/api/auth/login` a
|
|||||||
|
|
||||||
| Method | Path | Role | Description |
|
| Method | Path | Role | Description |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| GET | `/api/archer-tickets` | viewer+ | List tickets, optional filters: `cve_id`, `vendor`, `status` |
|
| GET | `/api/archer-tickets` | viewer+ | List tickets; optional filters: `cve_id`, `vendor`, `status` |
|
||||||
| POST | `/api/archer-tickets` | editor+ | Create a new Archer ticket |
|
| POST | `/api/archer-tickets` | editor+ | Create a new Archer ticket |
|
||||||
| PUT | `/api/archer-tickets/:id` | editor+ | Update an Archer ticket |
|
| PUT | `/api/archer-tickets/:id` | editor+ | Update an Archer ticket |
|
||||||
| DELETE | `/api/archer-tickets/:id` | editor+ | Delete an Archer ticket |
|
| DELETE | `/api/archer-tickets/:id` | editor+ | Delete an Archer ticket |
|
||||||
@@ -354,15 +560,15 @@ All endpoints are prefixed with `/api`. All endpoints except `/api/auth/login` a
|
|||||||
|
|
||||||
| Method | Path | Role | Description |
|
| Method | Path | Role | Description |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| GET | `/api/audit-logs` | admin | Paginated audit log with filters |
|
| GET | `/api/audit-logs` | admin | Paginated audit log; filters: `user`, `action`, `entityType`, `startDate`, `endDate` |
|
||||||
| GET | `/api/audit-logs/actions` | admin | List distinct action types |
|
| GET | `/api/audit-logs/actions` | admin | List distinct action types for filter dropdowns |
|
||||||
|
|
||||||
### Utility
|
### Utility
|
||||||
|
|
||||||
| 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 CVEs, critical count, addressed count, document count) |
|
| GET | `/api/stats` | viewer+ | Dashboard statistics (total, critical count, addressed count, document count) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -374,10 +580,10 @@ cve-dashboard/
|
|||||||
├── stop-servers.sh # Stop all servers
|
├── stop-servers.sh # Stop all servers
|
||||||
│
|
│
|
||||||
├── backend/
|
├── backend/
|
||||||
│ ├── server.js # Express app, CVE/document endpoints, middleware
|
│ ├── server.js # Express app — routes, middleware, file upload, 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 (gitignored)
|
│ ├── uploads/ # File storage root (gitignored)
|
||||||
│ │ ├── <CVE-ID>/
|
│ │ ├── <CVE-ID>/
|
||||||
│ │ │ └── <vendor>/ # CVE documents stored here
|
│ │ │ └── <vendor>/ # CVE documents stored here
|
||||||
│ │ ├── weekly_reports/ # Uploaded vulnerability reports
|
│ │ ├── weekly_reports/ # Uploaded vulnerability reports
|
||||||
@@ -390,66 +596,89 @@ cve-dashboard/
|
|||||||
│ │ ├── nvdLookup.js # NVD API proxy
|
│ │ ├── nvdLookup.js # NVD API proxy
|
||||||
│ │ ├── weeklyReports.js # Weekly report upload and management
|
│ │ ├── 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
|
||||||
|
│ │ └── ivantiFindings.js # Ivanti host findings sync, notes, overrides, FP counts
|
||||||
│ ├── middleware/
|
│ ├── middleware/
|
||||||
│ │ └── auth.js # requireAuth and requireRole middleware
|
│ │ └── auth.js # requireAuth and requireRole middleware
|
||||||
│ ├── helpers/
|
│ ├── helpers/
|
||||||
│ │ ├── auditLog.js # logAudit helper
|
│ │ ├── auditLog.js # logAudit helper (fire-and-forget)
|
||||||
│ │ └── excelProcessor.js # Calls Python script for report processing
|
│ │ └── excelProcessor.js # Calls Python script for report processing
|
||||||
│ ├── migrations/
|
│ ├── migrations/
|
||||||
│ │ ├── add_weekly_reports_table.js
|
│ │ ├── 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_findings_tables.js # Findings cache, notes, counts, overrides tables
|
||||||
│ └── scripts/
|
│ └── scripts/
|
||||||
│ ├── split_cve_report.py # Python: splits multi-CVE rows in Excel reports
|
│ ├── split_cve_report.py # Splits multi-CVE rows in Excel reports
|
||||||
│ └── requirements.txt # pandas, openpyxl
|
│ ├── import_notes_from_csv.py # Bulk-import finding notes from CSV
|
||||||
|
│ └── requirements.txt # pandas, openpyxl (weekly report processing only)
|
||||||
│
|
│
|
||||||
└── frontend/
|
└── frontend/
|
||||||
└── src/
|
└── src/
|
||||||
├── App.js # Main application, CVE list, filters, modals
|
├── App.js # Home dashboard — CVE list, filters, modals, calendar
|
||||||
├── App.css # Global styles
|
├── App.css # Global styles and CSS variables
|
||||||
├── contexts/
|
├── contexts/
|
||||||
│ └── AuthContext.js # Auth state provider
|
│ └── AuthContext.js # Auth state provider (login, logout, role helpers)
|
||||||
└── components/
|
└── components/
|
||||||
├── LoginForm.js # Login page
|
├── LoginForm.js # Login page
|
||||||
|
├── NavDrawer.js # Side navigation drawer
|
||||||
├── UserMenu.js # User dropdown in header
|
├── UserMenu.js # User dropdown in header
|
||||||
|
├── 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
|
├── NvdSyncModal.js # Bulk NVD sync dialog with review/apply flow
|
||||||
├── WeeklyReportModal.js # Weekly report upload dialog
|
├── KnowledgeBaseModal.js # Knowledge base upload/list modal
|
||||||
├── KnowledgeBaseModal.js # Knowledge base upload/list
|
├── KnowledgeBaseViewer.js # Inline document viewer
|
||||||
└── KnowledgeBaseViewer.js # Inline document viewer
|
└── pages/
|
||||||
|
├── ReportingPage.js # Host findings: charts, table, filters, export
|
||||||
|
├── KnowledgeBasePage.js # Knowledge base page (placeholder)
|
||||||
|
└── ExportsPage.js # Exports page (placeholder)
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Database Schema
|
## Database Schema
|
||||||
|
|
||||||
### Core tables
|
### Core tables (created by `setup.js`)
|
||||||
|
|
||||||
**`cves`** - One row per CVE/vendor pair. `UNIQUE(cve_id, vendor)`.
|
**`cves`** — One row per CVE/vendor pair. `UNIQUE(cve_id, vendor)`.
|
||||||
|
|
||||||
**`documents`** - Files attached to a CVE/vendor pair. Foreign key to `cves(cve_id)`.
|
**`documents`** — Files attached to a CVE/vendor pair. Foreign key to `cves(cve_id)`.
|
||||||
|
|
||||||
**`required_documents`** - Vendor-specific document requirements (advisory, screenshot, etc.).
|
**`required_documents`** — Vendor-specific document requirements.
|
||||||
|
|
||||||
**`users`** - Accounts with roles: `admin`, `editor`, `viewer`.
|
**`users`** — Accounts with roles: `admin`, `editor`, `viewer`.
|
||||||
|
|
||||||
**`sessions`** - Active sessions. Expire after 24 hours.
|
**`sessions`** — Active sessions with 24-hour expiry.
|
||||||
|
|
||||||
**`audit_logs`** - Append-only log of all state-changing actions.
|
**`audit_logs`** — Append-only log of all state-changing actions.
|
||||||
|
|
||||||
### 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.
|
**`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)`.
|
**`archer_tickets`** — Archer EXC exception tickets linked to CVE/vendor pairs. `UNIQUE(exc_number)`. Foreign key `(cve_id, vendor)` with CASCADE delete.
|
||||||
|
|
||||||
|
**`ivanti_sync_state`** — Single-row cache (id=1) for Ivanti workflow batch data: total count, JSON array of workflows, sync timestamp, sync status.
|
||||||
|
|
||||||
|
**`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_finding_notes`** — Persistent per-finding notes keyed by finding ID. Survives findings cache refreshes. `UNIQUE(finding_id)`.
|
||||||
|
|
||||||
|
**`ivanti_counts_cache`** — Single-row cache (id=1) for finding metrics:
|
||||||
|
- `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)`.
|
||||||
|
|
||||||
### 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`).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -473,13 +702,14 @@ cve-dashboard/
|
|||||||
- Severity must be one of: `Critical`, `High`, `Medium`, `Low`
|
- Severity must be one of: `Critical`, `High`, `Medium`, `Low`
|
||||||
- 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+$/`
|
||||||
- All database operations use prepared statements
|
- Finding override field must be one of: `hostName`, `dns`
|
||||||
|
- All database operations use prepared statements (no string interpolation in SQL)
|
||||||
|
|
||||||
### Error handling
|
### Error handling
|
||||||
|
|
||||||
- 500 responses never leak 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 because they contain only validation messages written by the application
|
- Descriptive 400/409 responses are safe as they contain only application-authored validation messages
|
||||||
|
|
||||||
### Security headers
|
### Security headers
|
||||||
|
|
||||||
@@ -493,23 +723,27 @@ Applied to all responses:
|
|||||||
|
|
||||||
### Session cookies
|
### Session cookies
|
||||||
|
|
||||||
`httpOnly: true`, `sameSite: lax`, `secure: true` in production.
|
`httpOnly: true`, `sameSite: lax`, `secure: true` in production (`NODE_ENV=production`).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Migrations
|
## Migrations
|
||||||
|
|
||||||
Migrations are standalone Node.js scripts that alter the database directly. Run them in the order listed. They use `CREATE TABLE IF NOT EXISTS`, so they are safe to run again if needed.
|
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.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd backend
|
cd backend
|
||||||
node migrations/add_weekly_reports_table.js
|
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_findings_tables.js
|
||||||
```
|
```
|
||||||
|
|
||||||
For an existing deployment upgrading from an earlier schema, also check the legacy migration scripts in `backend/`:
|
For an existing deployment upgrading from an earlier schema, check the legacy migration scripts 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 to 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.
|
||||||
|
|||||||
@@ -1,93 +0,0 @@
|
|||||||
const { spawn } = require('child_process');
|
|
||||||
const path = require('path');
|
|
||||||
const fs = require('fs');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Process vulnerability report Excel file by splitting CVE IDs into separate rows
|
|
||||||
* @param {string} inputPath - Path to original Excel file
|
|
||||||
* @param {string} outputPath - Path for processed Excel file
|
|
||||||
* @returns {Promise<{original_rows: number, processed_rows: number, output_path: string}>}
|
|
||||||
*/
|
|
||||||
function processVulnerabilityReport(inputPath, outputPath) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const scriptPath = path.join(__dirname, '..', 'scripts', 'split_cve_report.py');
|
|
||||||
|
|
||||||
// Verify script exists
|
|
||||||
if (!fs.existsSync(scriptPath)) {
|
|
||||||
return reject(new Error(`Python script not found: ${scriptPath}`));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify input file exists
|
|
||||||
if (!fs.existsSync(inputPath)) {
|
|
||||||
return reject(new Error(`Input file not found: ${inputPath}`));
|
|
||||||
}
|
|
||||||
|
|
||||||
const python = spawn('python3', [scriptPath, inputPath, outputPath]);
|
|
||||||
|
|
||||||
let stdout = '';
|
|
||||||
let stderr = '';
|
|
||||||
let timedOut = false;
|
|
||||||
|
|
||||||
// 30 second timeout
|
|
||||||
const timeout = setTimeout(() => {
|
|
||||||
timedOut = true;
|
|
||||||
python.kill();
|
|
||||||
reject(new Error('Processing timed out. File may be too large or corrupted.'));
|
|
||||||
}, 30000);
|
|
||||||
|
|
||||||
python.stdout.on('data', (data) => {
|
|
||||||
stdout += data.toString();
|
|
||||||
});
|
|
||||||
|
|
||||||
python.stderr.on('data', (data) => {
|
|
||||||
stderr += data.toString();
|
|
||||||
});
|
|
||||||
|
|
||||||
python.on('close', (code) => {
|
|
||||||
clearTimeout(timeout);
|
|
||||||
|
|
||||||
if (timedOut) return;
|
|
||||||
|
|
||||||
if (code !== 0) {
|
|
||||||
// Parse Python error messages
|
|
||||||
if (stderr.includes('Sheet') && stderr.includes('not found')) {
|
|
||||||
return reject(new Error('Invalid Excel file. Expected "Vulnerabilities" sheet with "CVE ID" column.'));
|
|
||||||
}
|
|
||||||
if (stderr.includes('pandas') || stderr.includes('openpyxl')) {
|
|
||||||
return reject(new Error('Python dependencies missing. Run: pip3 install pandas openpyxl'));
|
|
||||||
}
|
|
||||||
return reject(new Error(`Python script failed: ${stderr || 'Unknown error'}`));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse output for row counts
|
|
||||||
const originalMatch = stdout.match(/Original rows:\s*(\d+)/);
|
|
||||||
const newMatch = stdout.match(/New rows:\s*(\d+)/);
|
|
||||||
|
|
||||||
if (!originalMatch || !newMatch) {
|
|
||||||
return reject(new Error('Failed to parse row counts from Python output'));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify output file was created
|
|
||||||
if (!fs.existsSync(outputPath)) {
|
|
||||||
return reject(new Error('Processed file was not created'));
|
|
||||||
}
|
|
||||||
|
|
||||||
resolve({
|
|
||||||
original_rows: parseInt(originalMatch[1]),
|
|
||||||
processed_rows: parseInt(newMatch[1]),
|
|
||||||
output_path: outputPath
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
python.on('error', (err) => {
|
|
||||||
clearTimeout(timeout);
|
|
||||||
if (err.code === 'ENOENT') {
|
|
||||||
reject(new Error('Python 3 is required but not found. Please install Python.'));
|
|
||||||
} else {
|
|
||||||
reject(err);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = { processVulnerabilityReport };
|
|
||||||
58
backend/migrations/add_ivanti_findings_tables.js
Normal file
58
backend/migrations/add_ivanti_findings_tables.js
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
// Migration: Add ivanti_findings_cache and ivanti_finding_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 Ivanti findings tables migration...');
|
||||||
|
|
||||||
|
db.serialize(() => {
|
||||||
|
// Cache table — single row holding the latest sync result
|
||||||
|
db.run(`
|
||||||
|
CREATE TABLE IF NOT EXISTS ivanti_findings_cache (
|
||||||
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||||
|
total INTEGER DEFAULT 0,
|
||||||
|
findings_json TEXT DEFAULT '[]',
|
||||||
|
synced_at DATETIME,
|
||||||
|
sync_status TEXT DEFAULT 'never',
|
||||||
|
error_message TEXT
|
||||||
|
)
|
||||||
|
`, (err) => {
|
||||||
|
if (err) console.error('Error creating findings cache table:', err);
|
||||||
|
else console.log('✓ ivanti_findings_cache table created');
|
||||||
|
});
|
||||||
|
|
||||||
|
db.run(`
|
||||||
|
INSERT OR IGNORE INTO ivanti_findings_cache (id, total, findings_json, sync_status)
|
||||||
|
VALUES (1, 0, '[]', 'never')
|
||||||
|
`, (err) => {
|
||||||
|
if (err) console.error('Error seeding findings cache row:', err);
|
||||||
|
else console.log('✓ ivanti_findings_cache row seeded');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Notes table — one row per finding, persists across cache refreshes
|
||||||
|
db.run(`
|
||||||
|
CREATE TABLE IF NOT EXISTS ivanti_finding_notes (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
finding_id TEXT NOT NULL UNIQUE,
|
||||||
|
note TEXT NOT NULL DEFAULT '',
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
`, (err) => {
|
||||||
|
if (err) console.error('Error creating finding notes table:', err);
|
||||||
|
else console.log('✓ ivanti_finding_notes table created');
|
||||||
|
});
|
||||||
|
|
||||||
|
db.run(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_finding_notes_finding_id
|
||||||
|
ON ivanti_finding_notes(finding_id)
|
||||||
|
`, (err) => {
|
||||||
|
if (err) console.error('Error creating notes index:', err);
|
||||||
|
else console.log('✓ finding_id index created');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
db.close(() => {
|
||||||
|
console.log('Migration complete!');
|
||||||
|
});
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
// Migration: Add weekly_reports table for vulnerability report uploads
|
|
||||||
|
|
||||||
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('Running migration: add_weekly_reports_table');
|
|
||||||
|
|
||||||
db.serialize(() => {
|
|
||||||
db.run(`
|
|
||||||
CREATE TABLE IF NOT EXISTS weekly_reports (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
upload_date DATE NOT NULL,
|
|
||||||
week_label VARCHAR(50),
|
|
||||||
original_filename VARCHAR(255),
|
|
||||||
processed_filename VARCHAR(255),
|
|
||||||
original_file_path VARCHAR(500),
|
|
||||||
processed_file_path VARCHAR(500),
|
|
||||||
row_count_original INTEGER,
|
|
||||||
row_count_processed INTEGER,
|
|
||||||
uploaded_by INTEGER,
|
|
||||||
uploaded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
is_current BOOLEAN DEFAULT 0,
|
|
||||||
FOREIGN KEY (uploaded_by) REFERENCES users(id)
|
|
||||||
)
|
|
||||||
`, (err) => {
|
|
||||||
if (err) {
|
|
||||||
console.error('Error creating weekly_reports table:', err);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
console.log('✓ Created weekly_reports table');
|
|
||||||
});
|
|
||||||
|
|
||||||
db.run(`
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_weekly_reports_date
|
|
||||||
ON weekly_reports(upload_date DESC)
|
|
||||||
`, (err) => {
|
|
||||||
if (err) {
|
|
||||||
console.error('Error creating date index:', err);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
console.log('✓ Created index on upload_date');
|
|
||||||
});
|
|
||||||
|
|
||||||
db.run(`
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_weekly_reports_current
|
|
||||||
ON weekly_reports(is_current)
|
|
||||||
`, (err) => {
|
|
||||||
if (err) {
|
|
||||||
console.error('Error creating current index:', err);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
console.log('✓ Created index on is_current');
|
|
||||||
console.log('\nMigration completed successfully!');
|
|
||||||
db.close();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
658
backend/routes/ivantiFindings.js
Normal file
658
backend/routes/ivantiFindings.js
Normal file
@@ -0,0 +1,658 @@
|
|||||||
|
// Ivanti / RiskSense Host Findings Routes
|
||||||
|
// Caches hostFinding/search results in SQLite with daily auto-sync.
|
||||||
|
// Notes are stored separately so they survive cache refreshes.
|
||||||
|
|
||||||
|
const express = require('express');
|
||||||
|
const https = require('https');
|
||||||
|
const { requireRole } = require('../middleware/auth');
|
||||||
|
|
||||||
|
const IVANTI_URL_BASE = 'https://platform4.risksense.com/api/v1';
|
||||||
|
const SYNC_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
const FINDINGS_FILTERS = [
|
||||||
|
// NOTE: This filters for Open findings only — Closed count is fetched separately via syncClosedCount()
|
||||||
|
{
|
||||||
|
field: 'assetCustomAttributes.1550_host_1.value',
|
||||||
|
exclusive: false,
|
||||||
|
operator: 'IN',
|
||||||
|
orWithPrevious: false,
|
||||||
|
implicitFilters: [],
|
||||||
|
value: 'NTS-AEO-ACCESS-ENG,NTS-AEO-STEAM',
|
||||||
|
caseSensitive: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'severity',
|
||||||
|
exclusive: false,
|
||||||
|
operator: 'RANGE',
|
||||||
|
orWithPrevious: false,
|
||||||
|
implicitFilters: [],
|
||||||
|
value: '8.5,9.9',
|
||||||
|
caseSensitive: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'generic_state',
|
||||||
|
exclusive: false,
|
||||||
|
operator: 'EXACT',
|
||||||
|
orWithPrevious: false,
|
||||||
|
implicitFilters: [],
|
||||||
|
value: 'Open',
|
||||||
|
caseSensitive: false
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
// Same BU + severity filters but for Closed state — used only to fetch the total count
|
||||||
|
const CLOSED_COUNT_FILTERS = [
|
||||||
|
{
|
||||||
|
field: 'assetCustomAttributes.1550_host_1.value',
|
||||||
|
exclusive: false,
|
||||||
|
operator: 'IN',
|
||||||
|
orWithPrevious: false,
|
||||||
|
implicitFilters: [],
|
||||||
|
value: 'NTS-AEO-ACCESS-ENG,NTS-AEO-STEAM',
|
||||||
|
caseSensitive: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'severity',
|
||||||
|
exclusive: false,
|
||||||
|
operator: 'RANGE',
|
||||||
|
orWithPrevious: false,
|
||||||
|
implicitFilters: [],
|
||||||
|
value: '8.5,9.9',
|
||||||
|
caseSensitive: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'generic_state',
|
||||||
|
exclusive: false,
|
||||||
|
operator: 'EXACT',
|
||||||
|
orWithPrevious: false,
|
||||||
|
implicitFilters: [],
|
||||||
|
value: 'Closed',
|
||||||
|
caseSensitive: false
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// HTTP helper — mirrors the one in ivantiWorkflows.js
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function ivantiPost(urlPath, body, apiKey, skipTls) {
|
||||||
|
const bodyStr = JSON.stringify(body);
|
||||||
|
const fullUrl = new URL(IVANTI_URL_BASE + urlPath);
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const options = {
|
||||||
|
hostname: fullUrl.hostname,
|
||||||
|
path: fullUrl.pathname + fullUrl.search,
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'accept': '*/*',
|
||||||
|
'content-type': 'application/json',
|
||||||
|
'x-api-key': apiKey,
|
||||||
|
'x-http-client-type': 'browser',
|
||||||
|
'content-length': Buffer.byteLength(bodyStr)
|
||||||
|
},
|
||||||
|
rejectUnauthorized: !skipTls,
|
||||||
|
timeout: 20000
|
||||||
|
};
|
||||||
|
|
||||||
|
const req = https.request(options, (res) => {
|
||||||
|
let data = '';
|
||||||
|
res.on('data', (chunk) => { data += chunk; });
|
||||||
|
res.on('end', () => resolve({ status: res.statusCode, body: data }));
|
||||||
|
});
|
||||||
|
|
||||||
|
req.on('timeout', () => req.destroy(new Error('Request timed out')));
|
||||||
|
req.on('error', reject);
|
||||||
|
req.write(bodyStr);
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Table init
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function initTables(db) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
db.serialize(() => {
|
||||||
|
db.run(`
|
||||||
|
CREATE TABLE IF NOT EXISTS ivanti_findings_cache (
|
||||||
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||||
|
total INTEGER DEFAULT 0,
|
||||||
|
findings_json TEXT DEFAULT '[]',
|
||||||
|
synced_at DATETIME,
|
||||||
|
sync_status TEXT DEFAULT 'never',
|
||||||
|
error_message TEXT
|
||||||
|
)
|
||||||
|
`, (err) => { if (err) return reject(err); });
|
||||||
|
|
||||||
|
db.run(`
|
||||||
|
INSERT OR IGNORE INTO ivanti_findings_cache (id, total, findings_json, sync_status)
|
||||||
|
VALUES (1, 0, '[]', 'never')
|
||||||
|
`, (err) => { if (err) return reject(err); });
|
||||||
|
|
||||||
|
db.run(`
|
||||||
|
CREATE TABLE IF NOT EXISTS ivanti_finding_notes (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
finding_id TEXT NOT NULL UNIQUE,
|
||||||
|
note TEXT NOT NULL DEFAULT '',
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
`, (err) => { if (err) return reject(err); });
|
||||||
|
|
||||||
|
db.run(`
|
||||||
|
CREATE TABLE IF NOT EXISTS ivanti_counts_cache (
|
||||||
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||||
|
open_count INTEGER DEFAULT 0,
|
||||||
|
closed_count INTEGER DEFAULT 0,
|
||||||
|
synced_at DATETIME
|
||||||
|
)
|
||||||
|
`, (err) => { if (err) return reject(err); });
|
||||||
|
|
||||||
|
// Idempotent column additions — errors mean the column already exists, which is fine
|
||||||
|
db.run(`ALTER TABLE ivanti_counts_cache ADD COLUMN fp_workflow_counts_json TEXT DEFAULT '{}'`, () => {});
|
||||||
|
db.run(`ALTER TABLE ivanti_counts_cache ADD COLUMN fp_id_counts_json TEXT DEFAULT '{}'`, () => {});
|
||||||
|
|
||||||
|
db.run(`
|
||||||
|
INSERT OR IGNORE INTO ivanti_counts_cache (id, open_count, closed_count)
|
||||||
|
VALUES (1, 0, 0)
|
||||||
|
`, (err) => { if (err) return reject(err); });
|
||||||
|
|
||||||
|
db.run(`
|
||||||
|
CREATE TABLE IF NOT EXISTS ivanti_finding_overrides (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
finding_id TEXT NOT NULL,
|
||||||
|
field TEXT NOT NULL,
|
||||||
|
value TEXT NOT NULL DEFAULT '',
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(finding_id, field)
|
||||||
|
)
|
||||||
|
`, (err) => { if (err) return reject(err); });
|
||||||
|
|
||||||
|
db.run(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_finding_notes_finding_id
|
||||||
|
ON ivanti_finding_notes(finding_id)
|
||||||
|
`, (err) => { if (err) return reject(err); });
|
||||||
|
|
||||||
|
db.run(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_finding_overrides_finding_id
|
||||||
|
ON ivanti_finding_overrides(finding_id)
|
||||||
|
`, (err) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Extract only the fields we need from a raw finding object
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function extractFinding(f) {
|
||||||
|
// statusEmbedded.dueDate = "2026-03-06T00:00:00" — strip to date part
|
||||||
|
const rawDueDate = f.statusEmbedded?.dueDate || '';
|
||||||
|
const dueDate = rawDueDate ? rawDueDate.split('T')[0] : '';
|
||||||
|
|
||||||
|
// BU ownership: assetCustomAttributes['1550_host_1'] is an array like ["NTS-AEO-STEAM"]
|
||||||
|
const buOwnership = f.assetCustomAttributes?.['1550_host_1']?.[0] || '';
|
||||||
|
|
||||||
|
// CVE list: vulnerabilities.vulnInfoList[].cve
|
||||||
|
const cves = (f.vulnerabilities?.vulnInfoList || []).map(v => v.cve).filter(Boolean);
|
||||||
|
|
||||||
|
// Workflow: only capture FP# (False Positive) tickets — SYS# are auto-generated
|
||||||
|
// system workflows and not actionable for our purposes.
|
||||||
|
const wfDist = f.workflowDistribution || {};
|
||||||
|
const fpBuckets = [
|
||||||
|
...(wfDist.actionableWorkflows || []),
|
||||||
|
...(wfDist.requestedWorkflows || []),
|
||||||
|
...(wfDist.reworkedWorkflows || []),
|
||||||
|
...(wfDist.rejectedWorkflows || []),
|
||||||
|
...(wfDist.expiredWorkflows || []),
|
||||||
|
...(wfDist.approvedWorkflows || []),
|
||||||
|
].filter(w => (w.generatedId || '').startsWith('FP#'));
|
||||||
|
|
||||||
|
// Priority: actionable > requested > reworked > rejected > expired > approved
|
||||||
|
const fpEntry = fpBuckets[0] || null;
|
||||||
|
|
||||||
|
// Fallback: if no FP# in distribution, check workflowGeneratedNames directly
|
||||||
|
const generatedNames = f.workflowGeneratedNames || [];
|
||||||
|
const fpFromNames = !fpEntry
|
||||||
|
? generatedNames.find(n => n.startsWith('FP#')) || null
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const workflow = fpEntry ? {
|
||||||
|
id: fpEntry.generatedId || '',
|
||||||
|
state: fpEntry.state || '',
|
||||||
|
type: 'FP',
|
||||||
|
} : fpFromNames ? {
|
||||||
|
id: fpFromNames,
|
||||||
|
state: '',
|
||||||
|
type: 'FP',
|
||||||
|
} : null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: String(f.id),
|
||||||
|
title: f.title || '',
|
||||||
|
severity: typeof f.severity === 'number' ? f.severity : parseFloat(f.severity) || 0,
|
||||||
|
vrrGroup: f.vrrGroup || f.severityGroup || '',
|
||||||
|
hostName: f.host?.hostName || '',
|
||||||
|
ipAddress: f.host?.ipAddress || '',
|
||||||
|
dns: f.dns || f.host?.fqdn || '',
|
||||||
|
status: f.status || '',
|
||||||
|
slaStatus: f.slaStatus || '',
|
||||||
|
dueDate,
|
||||||
|
lastFoundOn: f.lastFoundOn || '',
|
||||||
|
buOwnership,
|
||||||
|
cves,
|
||||||
|
workflow
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Fetch total count of Closed findings from Ivanti (page 0, size 1)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
async function syncClosedCount(db, openCount, apiKey, clientId, skipTls) {
|
||||||
|
const urlPath = `/client/${encodeURIComponent(clientId)}/hostFinding/search`;
|
||||||
|
try {
|
||||||
|
const body = {
|
||||||
|
filters: CLOSED_COUNT_FILTERS,
|
||||||
|
projection: 'internal',
|
||||||
|
sort: [{ field: 'severity', direction: 'ASC' }],
|
||||||
|
page: 0,
|
||||||
|
size: 1
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await ivantiPost(urlPath, body, apiKey, skipTls);
|
||||||
|
if (result.status !== 200) throw new Error(`Closed count API returned status ${result.status}`);
|
||||||
|
|
||||||
|
const data = JSON.parse(result.body);
|
||||||
|
// RiskSense returns total in page.totalElements or page.total
|
||||||
|
const closedCount = data.page?.totalElements ?? data.page?.total ?? 0;
|
||||||
|
|
||||||
|
await dbRun(db,
|
||||||
|
`UPDATE ivanti_counts_cache SET open_count=?, closed_count=?, synced_at=datetime('now') WHERE id=1`,
|
||||||
|
[openCount, closedCount]
|
||||||
|
);
|
||||||
|
console.log(`[Ivanti Findings] Counts updated — open: ${openCount}, closed: ${closedCount}`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Ivanti Findings] Failed to fetch closed count:', err.message);
|
||||||
|
// Still update open count so it stays in sync; leave closed_count as-is
|
||||||
|
await dbRun(db,
|
||||||
|
`UPDATE ivanti_counts_cache SET open_count=?, synced_at=datetime('now') WHERE id=1`,
|
||||||
|
[openCount]
|
||||||
|
).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Extract FP workflow id+state from a raw (un-extracted) finding
|
||||||
|
// Returns { id, state } or null if no FP# workflow present.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function extractFPWorkflow(f) {
|
||||||
|
const wfDist = f.workflowDistribution || {};
|
||||||
|
const fpBuckets = [
|
||||||
|
...(wfDist.actionableWorkflows || []),
|
||||||
|
...(wfDist.requestedWorkflows || []),
|
||||||
|
...(wfDist.reworkedWorkflows || []),
|
||||||
|
...(wfDist.rejectedWorkflows || []),
|
||||||
|
...(wfDist.expiredWorkflows || []),
|
||||||
|
...(wfDist.approvedWorkflows || []),
|
||||||
|
].filter(w => (w.generatedId || '').startsWith('FP#'));
|
||||||
|
const fpEntry = fpBuckets[0] || null;
|
||||||
|
if (!fpEntry) return null;
|
||||||
|
return { id: fpEntry.generatedId || '', state: fpEntry.state || 'Unknown' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Sync FP stats across ALL findings (open + closed).
|
||||||
|
//
|
||||||
|
// Produces two separate counts:
|
||||||
|
// findingCounts — number of *findings* per FP workflow state
|
||||||
|
// idCounts — number of *unique FP# ticket IDs* per state
|
||||||
|
// (one FP# can cover many findings; this chart counts tickets)
|
||||||
|
//
|
||||||
|
// Open findings come from the already-extracted allFindings array.
|
||||||
|
// Closed findings are swept page-by-page to catch Approved FPs.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
async function syncFPWorkflowCounts(db, openFindings, apiKey, clientId, skipTls) {
|
||||||
|
const findingCounts = {}; // state → # findings
|
||||||
|
const fpIdMap = {}; // FP# id → state (deduplicates across findings)
|
||||||
|
|
||||||
|
// Seed from open findings (already extracted, have workflow.id + workflow.state)
|
||||||
|
openFindings.forEach(f => {
|
||||||
|
if (!f.workflow) return;
|
||||||
|
const state = f.workflow.state || 'Unknown';
|
||||||
|
const id = f.workflow.id || '';
|
||||||
|
findingCounts[state] = (findingCounts[state] || 0) + 1;
|
||||||
|
if (id && !fpIdMap[id]) fpIdMap[id] = state;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sweep closed findings to pick up Approved (and any other closed FP states)
|
||||||
|
const urlPath = `/client/${encodeURIComponent(clientId)}/hostFinding/search`;
|
||||||
|
let page = 0;
|
||||||
|
let totalPages = 1;
|
||||||
|
|
||||||
|
try {
|
||||||
|
do {
|
||||||
|
const body = {
|
||||||
|
filters: CLOSED_COUNT_FILTERS,
|
||||||
|
projection: 'internal',
|
||||||
|
sort: [{ field: 'severity', direction: 'ASC' }],
|
||||||
|
page,
|
||||||
|
size: 100
|
||||||
|
};
|
||||||
|
const result = await ivantiPost(urlPath, body, apiKey, skipTls);
|
||||||
|
if (result.status !== 200) {
|
||||||
|
console.warn(`[Ivanti Findings] FP workflow counts: closed findings page ${page} returned ${result.status} — stopping sweep`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const data = JSON.parse(result.body);
|
||||||
|
totalPages = data.page?.totalPages || 1;
|
||||||
|
const findings = data._embedded?.hostFindings || [];
|
||||||
|
findings.forEach(f => {
|
||||||
|
const wf = extractFPWorkflow(f);
|
||||||
|
if (!wf) return;
|
||||||
|
findingCounts[wf.state] = (findingCounts[wf.state] || 0) + 1;
|
||||||
|
if (wf.id && !fpIdMap[wf.id]) fpIdMap[wf.id] = wf.state;
|
||||||
|
});
|
||||||
|
console.log(`[Ivanti Findings] FP workflow counts: closed page ${page + 1}/${totalPages}`);
|
||||||
|
page++;
|
||||||
|
} while (page < totalPages);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Ivanti Findings] FP workflow counts: closed sweep failed:', err.message);
|
||||||
|
// Fall through — store whatever we have from open findings
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aggregate unique FP# IDs by state
|
||||||
|
const idCounts = {};
|
||||||
|
Object.values(fpIdMap).forEach(state => {
|
||||||
|
idCounts[state] = (idCounts[state] || 0) + 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
await dbRun(db,
|
||||||
|
`UPDATE ivanti_counts_cache SET fp_workflow_counts_json=?, fp_id_counts_json=? WHERE id=1`,
|
||||||
|
[JSON.stringify(findingCounts), JSON.stringify(idCounts)]
|
||||||
|
).catch(e => console.error('[Ivanti Findings] Failed to store FP workflow counts:', e.message));
|
||||||
|
|
||||||
|
console.log('[Ivanti Findings] FP finding counts:', findingCounts);
|
||||||
|
console.log('[Ivanti Findings] FP workflow ID counts:', idCounts);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Core sync — fetches ALL pages, stores slimmed findings in SQLite
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
async function syncFindings(db) {
|
||||||
|
const apiKey = process.env.IVANTI_API_KEY;
|
||||||
|
const clientId = process.env.IVANTI_CLIENT_ID || '1550';
|
||||||
|
const skipTls = process.env.IVANTI_SKIP_TLS === 'true';
|
||||||
|
|
||||||
|
if (!apiKey) {
|
||||||
|
const errMsg = 'IVANTI_API_KEY not set in .env — skipping sync';
|
||||||
|
console.warn('[Ivanti Findings]', errMsg);
|
||||||
|
await dbRun(db, `UPDATE ivanti_findings_cache SET sync_status='error', error_message=?, synced_at=datetime('now') WHERE id=1`, [errMsg]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[Ivanti Findings] Starting sync...');
|
||||||
|
|
||||||
|
const urlPath = `/client/${encodeURIComponent(clientId)}/hostFinding/search`;
|
||||||
|
let allFindings = [];
|
||||||
|
let page = 0;
|
||||||
|
let totalPages = 1;
|
||||||
|
|
||||||
|
try {
|
||||||
|
do {
|
||||||
|
const body = {
|
||||||
|
filters: FINDINGS_FILTERS,
|
||||||
|
projection: 'internal',
|
||||||
|
sort: [{ field: 'severity', direction: 'ASC' }],
|
||||||
|
page,
|
||||||
|
size: 100
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await ivantiPost(urlPath, body, apiKey, skipTls);
|
||||||
|
|
||||||
|
if (result.status === 401) throw new Error('Invalid or missing API key (401)');
|
||||||
|
if (result.status === 419) throw new Error('Insufficient privileges (419) — API key lacks hostFinding access');
|
||||||
|
if (result.status === 429) throw new Error('Rate limited (429) — try again later');
|
||||||
|
if (result.status !== 200) throw new Error(`Ivanti API returned status ${result.status}`);
|
||||||
|
|
||||||
|
const data = JSON.parse(result.body);
|
||||||
|
totalPages = data.page?.totalPages || 1;
|
||||||
|
const findings = data._embedded?.hostFindings || [];
|
||||||
|
allFindings = allFindings.concat(findings.map(extractFinding));
|
||||||
|
|
||||||
|
console.log(`[Ivanti Findings] Page ${page + 1}/${totalPages} — ${allFindings.length} findings so far`);
|
||||||
|
page++;
|
||||||
|
} while (page < totalPages);
|
||||||
|
|
||||||
|
await dbRun(db,
|
||||||
|
`UPDATE ivanti_findings_cache SET total=?, findings_json=?, synced_at=datetime('now'), sync_status='success', error_message=NULL WHERE id=1`,
|
||||||
|
[allFindings.length, JSON.stringify(allFindings)]
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`[Ivanti Findings] Sync complete — ${allFindings.length} findings`);
|
||||||
|
await syncClosedCount(db, allFindings.length, apiKey, clientId, skipTls);
|
||||||
|
await syncFPWorkflowCounts(db, allFindings, apiKey, clientId, skipTls);
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err.message || 'Unknown error';
|
||||||
|
console.error('[Ivanti Findings] Sync failed:', msg);
|
||||||
|
await dbRun(db, `UPDATE ivanti_findings_cache SET sync_status='error', error_message=?, synced_at=datetime('now') WHERE id=1`, [msg]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Scheduler
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function scheduleSync(db) {
|
||||||
|
db.get('SELECT synced_at FROM ivanti_findings_cache WHERE id = 1', (err, row) => {
|
||||||
|
if (err || !row || !row.synced_at) {
|
||||||
|
syncFindings(db);
|
||||||
|
} else {
|
||||||
|
const lastSync = new Date(row.synced_at.replace(' ', 'T') + 'Z');
|
||||||
|
const hoursSince = (Date.now() - lastSync.getTime()) / (1000 * 60 * 60);
|
||||||
|
if (hoursSince >= 24) {
|
||||||
|
syncFindings(db);
|
||||||
|
} else {
|
||||||
|
console.log(`[Ivanti Findings] Last sync ${hoursSince.toFixed(1)}h ago — next auto-sync in ${(24 - hoursSince).toFixed(1)}h`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
setInterval(() => syncFindings(db), SYNC_INTERVAL_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// DB helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function dbRun(db, sql, params = []) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
db.run(sql, params, (err) => { if (err) reject(err); else resolve(); });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function readState(db) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
db.get(
|
||||||
|
'SELECT total, findings_json, synced_at, sync_status, error_message FROM ivanti_findings_cache WHERE id = 1',
|
||||||
|
(err, row) => {
|
||||||
|
if (err) return reject(err);
|
||||||
|
if (!row) return resolve({ total: 0, findings: [], synced_at: null, sync_status: 'never', error_message: null });
|
||||||
|
let findings = [];
|
||||||
|
try { findings = JSON.parse(row.findings_json || '[]'); } catch (_) { /* leave empty */ }
|
||||||
|
resolve({ total: row.total || 0, findings, synced_at: row.synced_at, sync_status: row.sync_status, error_message: row.error_message });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function readNotes(db) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
db.all('SELECT finding_id, note FROM ivanti_finding_notes', (err, rows) => {
|
||||||
|
if (err) return reject(err);
|
||||||
|
const map = {};
|
||||||
|
(rows || []).forEach((r) => { map[r.finding_id] = r.note; });
|
||||||
|
resolve(map);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function readCounts(db) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
db.get(
|
||||||
|
'SELECT open_count, closed_count, synced_at FROM ivanti_counts_cache WHERE id = 1',
|
||||||
|
(err, row) => {
|
||||||
|
if (err) return reject(err);
|
||||||
|
resolve({
|
||||||
|
open: row?.open_count ?? 0,
|
||||||
|
closed: row?.closed_count ?? 0,
|
||||||
|
synced_at: row?.synced_at ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns { findingId: { hostName: 'override', dns: 'override' }, ... }
|
||||||
|
function readOverrides(db) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
db.all('SELECT finding_id, field, value FROM ivanti_finding_overrides', (err, rows) => {
|
||||||
|
if (err) return reject(err);
|
||||||
|
const map = {};
|
||||||
|
(rows || []).forEach((r) => {
|
||||||
|
if (!map[r.finding_id]) map[r.finding_id] = {};
|
||||||
|
map[r.finding_id][r.field] = r.value;
|
||||||
|
});
|
||||||
|
resolve(map);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readStateWithNotes(db) {
|
||||||
|
const [state, notes, overrides] = await Promise.all([readState(db), readNotes(db), readOverrides(db)]);
|
||||||
|
state.findings = state.findings.map((f) => ({
|
||||||
|
...f,
|
||||||
|
note: notes[f.id] || '',
|
||||||
|
overrides: overrides[f.id] || {},
|
||||||
|
}));
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Router
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function createIvantiFindingsRouter(db, requireAuth) {
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
initTables(db)
|
||||||
|
.then(() => scheduleSync(db))
|
||||||
|
.catch((err) => console.error('[Ivanti Findings] Init failed:', err));
|
||||||
|
|
||||||
|
router.use(requireAuth(db));
|
||||||
|
|
||||||
|
// GET / — cached findings with notes merged in
|
||||||
|
router.get('/', async (req, res) => {
|
||||||
|
try {
|
||||||
|
res.json(await readStateWithNotes(db));
|
||||||
|
} catch {
|
||||||
|
res.status(500).json({ error: 'Database error reading findings' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /sync — trigger immediate sync, return fresh state
|
||||||
|
router.post('/sync', async (req, res) => {
|
||||||
|
await syncFindings(db);
|
||||||
|
try {
|
||||||
|
res.json(await readStateWithNotes(db));
|
||||||
|
} catch {
|
||||||
|
res.status(500).json({ error: 'Sync ran but could not read updated state' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /counts — open vs closed totals for pie chart
|
||||||
|
router.get('/counts', async (req, res) => {
|
||||||
|
try {
|
||||||
|
res.json(await readCounts(db));
|
||||||
|
} catch {
|
||||||
|
res.status(500).json({ error: 'Database error reading counts' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /fp-workflow-counts — FP finding + unique workflow counts (open + closed)
|
||||||
|
router.get('/fp-workflow-counts', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const row = await new Promise((resolve, reject) => {
|
||||||
|
db.get('SELECT fp_workflow_counts_json, fp_id_counts_json FROM ivanti_counts_cache WHERE id=1',
|
||||||
|
(err, row) => { if (err) reject(err); else resolve(row); }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
let findingCounts = {};
|
||||||
|
let idCounts = {};
|
||||||
|
try { findingCounts = JSON.parse(row?.fp_workflow_counts_json || '{}'); } catch (_) {}
|
||||||
|
try { idCounts = JSON.parse(row?.fp_id_counts_json || '{}'); } catch (_) {}
|
||||||
|
res.json({
|
||||||
|
findingCounts,
|
||||||
|
findingTotal: Object.values(findingCounts).reduce((a, b) => a + b, 0),
|
||||||
|
idCounts,
|
||||||
|
idTotal: Object.values(idCounts).reduce((a, b) => a + b, 0),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
res.status(500).json({ error: 'Database error reading FP workflow counts' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// PUT /:findingId/override — save or clear a field override (editor/admin only)
|
||||||
|
const OVERRIDE_ALLOWED = ['hostName', 'dns'];
|
||||||
|
router.put('/:findingId/override', requireRole('editor', 'admin'), (req, res) => {
|
||||||
|
const { findingId } = req.params;
|
||||||
|
const { field, value } = req.body;
|
||||||
|
|
||||||
|
if (!OVERRIDE_ALLOWED.includes(field)) {
|
||||||
|
return res.status(400).json({ error: `Field '${field}' is not editable. Allowed: ${OVERRIDE_ALLOWED.join(', ')}` });
|
||||||
|
}
|
||||||
|
|
||||||
|
const val = String(value ?? '').trim();
|
||||||
|
|
||||||
|
if (val === '') {
|
||||||
|
// Empty value = clear the override (revert to Ivanti)
|
||||||
|
db.run(
|
||||||
|
'DELETE FROM ivanti_finding_overrides WHERE finding_id = ? AND field = ?',
|
||||||
|
[findingId, field],
|
||||||
|
(err) => {
|
||||||
|
if (err) return res.status(500).json({ error: 'Failed to clear override' });
|
||||||
|
res.json({ finding_id: findingId, field, value: null });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
db.run(
|
||||||
|
`INSERT INTO ivanti_finding_overrides (finding_id, field, value, updated_at)
|
||||||
|
VALUES (?, ?, ?, datetime('now'))
|
||||||
|
ON CONFLICT(finding_id, field) DO UPDATE SET value=excluded.value, updated_at=datetime('now')`,
|
||||||
|
[findingId, field, val],
|
||||||
|
(err) => {
|
||||||
|
if (err) return res.status(500).json({ error: 'Failed to save override' });
|
||||||
|
res.json({ finding_id: findingId, field, value: val });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// PUT /:findingId/note — save or update a note (max 255 chars enforced here)
|
||||||
|
router.put('/:findingId/note', (req, res) => {
|
||||||
|
const { findingId } = req.params;
|
||||||
|
const note = String(req.body.note || '').slice(0, 255);
|
||||||
|
|
||||||
|
db.run(
|
||||||
|
`INSERT INTO ivanti_finding_notes (finding_id, note, updated_at)
|
||||||
|
VALUES (?, ?, datetime('now'))
|
||||||
|
ON CONFLICT(finding_id) DO UPDATE SET note=excluded.note, updated_at=datetime('now')`,
|
||||||
|
[findingId, note],
|
||||||
|
(err) => {
|
||||||
|
if (err) return res.status(500).json({ error: 'Failed to save note' });
|
||||||
|
res.json({ finding_id: findingId, note });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = createIvantiFindingsRouter;
|
||||||
@@ -1,261 +0,0 @@
|
|||||||
const express = require('express');
|
|
||||||
const path = require('path');
|
|
||||||
const fs = require('fs');
|
|
||||||
const { requireAuth, requireRole } = require('../middleware/auth');
|
|
||||||
const logAudit = require('../helpers/auditLog');
|
|
||||||
const { processVulnerabilityReport } = require('../helpers/excelProcessor');
|
|
||||||
|
|
||||||
function createWeeklyReportsRouter(db, upload) {
|
|
||||||
const router = express.Router();
|
|
||||||
|
|
||||||
// Helper to sanitize filename
|
|
||||||
function sanitizePathSegment(segment) {
|
|
||||||
if (!segment || typeof segment !== 'string') return '';
|
|
||||||
return segment
|
|
||||||
.replace(/\0/g, '')
|
|
||||||
.replace(/\.\./g, '')
|
|
||||||
.replace(/[\/\\]/g, '')
|
|
||||||
.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper to generate week label
|
|
||||||
function getWeekLabel(date) {
|
|
||||||
const now = new Date();
|
|
||||||
const uploadDate = new Date(date);
|
|
||||||
const daysDiff = Math.floor((now - uploadDate) / (1000 * 60 * 60 * 24));
|
|
||||||
|
|
||||||
if (daysDiff < 7) {
|
|
||||||
return "This week's report";
|
|
||||||
} else if (daysDiff < 14) {
|
|
||||||
return "Last week's report";
|
|
||||||
} else {
|
|
||||||
const month = uploadDate.getMonth() + 1;
|
|
||||||
const day = uploadDate.getDate();
|
|
||||||
const year = uploadDate.getFullYear();
|
|
||||||
return `Week of ${month.toString().padStart(2, '0')}/${day.toString().padStart(2, '0')}/${year}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// POST /api/weekly-reports/upload - Upload and process vulnerability report
|
|
||||||
router.post('/upload', requireAuth(db), requireRole(db, 'editor', 'admin'), upload.single('file'), async (req, res) => {
|
|
||||||
const uploadedFile = req.file;
|
|
||||||
|
|
||||||
if (!uploadedFile) {
|
|
||||||
return res.status(400).json({ error: 'No file uploaded' });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate file extension
|
|
||||||
const ext = path.extname(uploadedFile.originalname).toLowerCase();
|
|
||||||
if (ext !== '.xlsx') {
|
|
||||||
fs.unlinkSync(uploadedFile.path); // Clean up temp file
|
|
||||||
return res.status(400).json({ error: 'Only .xlsx files are allowed' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const timestamp = Date.now();
|
|
||||||
const sanitizedName = sanitizePathSegment(uploadedFile.originalname);
|
|
||||||
const reportsDir = path.join(__dirname, '..', 'uploads', 'weekly_reports');
|
|
||||||
|
|
||||||
// Create directory if it doesn't exist
|
|
||||||
if (!fs.existsSync(reportsDir)) {
|
|
||||||
fs.mkdirSync(reportsDir, { recursive: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
const originalFilename = `${timestamp}_original_${sanitizedName}`;
|
|
||||||
const processedFilename = `${timestamp}_processed_${sanitizedName}`;
|
|
||||||
const originalPath = path.join(reportsDir, originalFilename);
|
|
||||||
const processedPath = path.join(reportsDir, processedFilename);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Move uploaded file to permanent location
|
|
||||||
fs.renameSync(uploadedFile.path, originalPath);
|
|
||||||
|
|
||||||
// Process the file with Python script
|
|
||||||
const result = await processVulnerabilityReport(originalPath, processedPath);
|
|
||||||
|
|
||||||
const uploadDate = new Date().toISOString().split('T')[0];
|
|
||||||
|
|
||||||
// Update previous current reports to not current
|
|
||||||
db.run('UPDATE weekly_reports SET is_current = 0 WHERE is_current = 1', (err) => {
|
|
||||||
if (err) {
|
|
||||||
console.error('Error updating previous current reports:', err);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Insert new report record
|
|
||||||
const insertSql = `
|
|
||||||
INSERT INTO weekly_reports (
|
|
||||||
upload_date, week_label, original_filename, processed_filename,
|
|
||||||
original_file_path, processed_file_path, row_count_original,
|
|
||||||
row_count_processed, uploaded_by, is_current
|
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1)
|
|
||||||
`;
|
|
||||||
|
|
||||||
const weekLabel = getWeekLabel(uploadDate);
|
|
||||||
|
|
||||||
db.run(
|
|
||||||
insertSql,
|
|
||||||
[
|
|
||||||
uploadDate,
|
|
||||||
weekLabel,
|
|
||||||
sanitizedName,
|
|
||||||
processedFilename,
|
|
||||||
originalPath,
|
|
||||||
processedPath,
|
|
||||||
result.original_rows,
|
|
||||||
result.processed_rows,
|
|
||||||
req.user.id
|
|
||||||
],
|
|
||||||
function (err) {
|
|
||||||
if (err) {
|
|
||||||
console.error('Error inserting weekly report:', err);
|
|
||||||
return res.status(500).json({ error: 'Failed to save report metadata' });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Log audit entry
|
|
||||||
logAudit(
|
|
||||||
db,
|
|
||||||
req.user.id,
|
|
||||||
req.user.username,
|
|
||||||
'UPLOAD_WEEKLY_REPORT',
|
|
||||||
'weekly_reports',
|
|
||||||
this.lastID,
|
|
||||||
JSON.stringify({ filename: sanitizedName, rows: result.processed_rows }),
|
|
||||||
req.ip
|
|
||||||
);
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
id: this.lastID,
|
|
||||||
original_rows: result.original_rows,
|
|
||||||
processed_rows: result.processed_rows,
|
|
||||||
week_label: weekLabel
|
|
||||||
});
|
|
||||||
}
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
// Clean up files on error
|
|
||||||
if (fs.existsSync(originalPath)) fs.unlinkSync(originalPath);
|
|
||||||
if (fs.existsSync(processedPath)) fs.unlinkSync(processedPath);
|
|
||||||
|
|
||||||
console.error('Error processing vulnerability report:', error);
|
|
||||||
res.status(500).json({ error: error.message || 'Failed to process report' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// GET /api/weekly-reports - List all reports
|
|
||||||
router.get('/', requireAuth(db), (req, res) => {
|
|
||||||
const sql = `
|
|
||||||
SELECT id, upload_date, week_label, original_filename, processed_filename,
|
|
||||||
row_count_original, row_count_processed, is_current, uploaded_at
|
|
||||||
FROM weekly_reports
|
|
||||||
ORDER BY upload_date DESC, uploaded_at DESC
|
|
||||||
`;
|
|
||||||
|
|
||||||
db.all(sql, [], (err, rows) => {
|
|
||||||
if (err) {
|
|
||||||
console.error('Error fetching weekly reports:', err);
|
|
||||||
return res.status(500).json({ error: 'Failed to fetch reports' });
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json(rows);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// GET /api/weekly-reports/:id/download/:type - Download report file
|
|
||||||
router.get('/:id/download/:type', requireAuth(db), (req, res) => {
|
|
||||||
const { id, type } = req.params;
|
|
||||||
|
|
||||||
if (type !== 'original' && type !== 'processed') {
|
|
||||||
return res.status(400).json({ error: 'Invalid download type. Use "original" or "processed"' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const sql = `SELECT original_file_path, processed_file_path, original_filename FROM weekly_reports WHERE id = ?`;
|
|
||||||
|
|
||||||
db.get(sql, [id], (err, row) => {
|
|
||||||
if (err) {
|
|
||||||
console.error('Error fetching report:', err);
|
|
||||||
return res.status(500).json({ error: 'Failed to fetch report' });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!row) {
|
|
||||||
return res.status(404).json({ error: 'Report not found' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const filePath = type === 'original' ? row.original_file_path : row.processed_file_path;
|
|
||||||
|
|
||||||
if (!fs.existsSync(filePath)) {
|
|
||||||
return res.status(404).json({ error: 'File not found on disk' });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Log audit entry
|
|
||||||
logAudit(
|
|
||||||
db,
|
|
||||||
req.user.id,
|
|
||||||
req.user.username,
|
|
||||||
'DOWNLOAD_WEEKLY_REPORT',
|
|
||||||
'weekly_reports',
|
|
||||||
id,
|
|
||||||
JSON.stringify({ type }),
|
|
||||||
req.ip
|
|
||||||
);
|
|
||||||
|
|
||||||
const downloadName = type === 'original' ? row.original_filename : row.original_filename.replace('.xlsx', '_processed.xlsx');
|
|
||||||
|
|
||||||
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
|
|
||||||
res.setHeader('Content-Disposition', `attachment; filename="${downloadName}"`);
|
|
||||||
res.sendFile(filePath);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// DELETE /api/weekly-reports/:id - Delete report (admin only)
|
|
||||||
router.delete('/:id', requireAuth(db), requireRole(db, 'admin'), (req, res) => {
|
|
||||||
const { id } = req.params;
|
|
||||||
|
|
||||||
const sql = 'SELECT original_file_path, processed_file_path FROM weekly_reports WHERE id = ?';
|
|
||||||
|
|
||||||
db.get(sql, [id], (err, row) => {
|
|
||||||
if (err) {
|
|
||||||
console.error('Error fetching report for deletion:', err);
|
|
||||||
return res.status(500).json({ error: 'Failed to fetch report' });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!row) {
|
|
||||||
return res.status(404).json({ error: 'Report not found' });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete database record
|
|
||||||
db.run('DELETE FROM weekly_reports WHERE id = ?', [id], (err) => {
|
|
||||||
if (err) {
|
|
||||||
console.error('Error deleting report:', err);
|
|
||||||
return res.status(500).json({ error: 'Failed to delete report' });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete files
|
|
||||||
if (fs.existsSync(row.original_file_path)) {
|
|
||||||
fs.unlinkSync(row.original_file_path);
|
|
||||||
}
|
|
||||||
if (fs.existsSync(row.processed_file_path)) {
|
|
||||||
fs.unlinkSync(row.processed_file_path);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Log audit entry
|
|
||||||
logAudit(
|
|
||||||
db,
|
|
||||||
req.user.id,
|
|
||||||
req.user.username,
|
|
||||||
'DELETE_WEEKLY_REPORT',
|
|
||||||
'weekly_reports',
|
|
||||||
id,
|
|
||||||
null,
|
|
||||||
req.ip
|
|
||||||
);
|
|
||||||
|
|
||||||
res.json({ success: true });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
return router;
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = createWeeklyReportsRouter;
|
|
||||||
182
backend/scripts/import_notes_from_csv.py
Normal file
182
backend/scripts/import_notes_from_csv.py
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
import_notes_from_csv.py
|
||||||
|
------------------------
|
||||||
|
Mass-import finding notes from a CSV file into the CVE dashboard database.
|
||||||
|
|
||||||
|
CSV format (header row required, column names are case-insensitive):
|
||||||
|
ID,NOTES
|
||||||
|
12345,EXC-5754
|
||||||
|
67890,EXC-6001 - pending review
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python3 import_notes_from_csv.py <csv_file> [--db <db_path>] [--dry-run]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--db <path> Path to cve_database.db (default: ../cve_database.db)
|
||||||
|
--dry-run Print what would change without touching the database
|
||||||
|
"""
|
||||||
|
|
||||||
|
import csv
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
import argparse
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
NOTE_MAX_LEN = 255
|
||||||
|
|
||||||
|
DEFAULT_DB = os.path.join(os.path.dirname(__file__), '..', 'cve_database.db')
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args():
|
||||||
|
p = argparse.ArgumentParser(description='Import finding notes from CSV into the dashboard DB.')
|
||||||
|
p.add_argument('csv_file', help='Path to the CSV file (must have ID and NOTES columns)')
|
||||||
|
p.add_argument('--db', default=DEFAULT_DB, help=f'Path to SQLite database (default: {DEFAULT_DB})')
|
||||||
|
p.add_argument('--dry-run', action='store_true', help='Preview changes without writing to DB')
|
||||||
|
return p.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def load_csv(path):
|
||||||
|
"""Read CSV and return list of (finding_id, note) tuples."""
|
||||||
|
rows = []
|
||||||
|
with open(path, newline='', encoding='utf-8-sig') as f:
|
||||||
|
reader = csv.DictReader(f)
|
||||||
|
# Normalise header names to uppercase for case-insensitive matching
|
||||||
|
if reader.fieldnames is None:
|
||||||
|
print('ERROR: CSV file is empty or has no header row.')
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
normalised = {k.strip().upper(): k for k in reader.fieldnames}
|
||||||
|
if 'ID' not in normalised or 'NOTES' not in normalised:
|
||||||
|
print(f'ERROR: CSV must have "ID" and "NOTES" columns.')
|
||||||
|
print(f' Found columns: {list(reader.fieldnames)}')
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
id_col = normalised['ID']
|
||||||
|
notes_col = normalised['NOTES']
|
||||||
|
|
||||||
|
for i, row in enumerate(reader, start=2): # start=2 because row 1 is the header
|
||||||
|
finding_id = row[id_col].strip()
|
||||||
|
note = row[notes_col].strip()
|
||||||
|
|
||||||
|
if not finding_id:
|
||||||
|
print(f' WARNING row {i}: empty ID — skipping')
|
||||||
|
continue
|
||||||
|
|
||||||
|
if len(note) > NOTE_MAX_LEN:
|
||||||
|
print(f' WARNING row {i} ({finding_id}): note is {len(note)} chars, '
|
||||||
|
f'truncating to {NOTE_MAX_LEN}')
|
||||||
|
note = note[:NOTE_MAX_LEN]
|
||||||
|
|
||||||
|
rows.append((finding_id, note))
|
||||||
|
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def run(args):
|
||||||
|
csv_path = os.path.abspath(args.csv_file)
|
||||||
|
db_path = os.path.abspath(args.db)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ checks
|
||||||
|
if not os.path.exists(csv_path):
|
||||||
|
print(f'ERROR: CSV file not found: {csv_path}')
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if not os.path.exists(db_path):
|
||||||
|
print(f'ERROR: Database not found: {db_path}')
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
print(f'CSV : {csv_path}')
|
||||||
|
print(f'DB : {db_path}')
|
||||||
|
if args.dry_run:
|
||||||
|
print('MODE: DRY RUN — no changes will be written\n')
|
||||||
|
else:
|
||||||
|
print()
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------- load CSV
|
||||||
|
rows = load_csv(csv_path)
|
||||||
|
if not rows:
|
||||||
|
print('No valid rows found in CSV.')
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
print(f'Loaded {len(rows)} row(s) from CSV.\n')
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- open DB
|
||||||
|
con = sqlite3.connect(db_path)
|
||||||
|
con.row_factory = sqlite3.Row
|
||||||
|
cur = con.cursor()
|
||||||
|
|
||||||
|
# Fetch all known finding IDs — only IDs present here will be processed
|
||||||
|
import json
|
||||||
|
cur.execute('SELECT findings_json FROM ivanti_findings_cache WHERE id = 1')
|
||||||
|
cache_row = cur.fetchone()
|
||||||
|
known_ids = set()
|
||||||
|
if cache_row and cache_row['findings_json']:
|
||||||
|
try:
|
||||||
|
known_ids = {str(f['id']) for f in json.loads(cache_row['findings_json'])}
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if not known_ids:
|
||||||
|
print('ERROR: No findings found in the database cache.')
|
||||||
|
print(' Run a Sync from the dashboard first, then re-run this script.')
|
||||||
|
con.close()
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
print(f'{len(known_ids)} active finding(s) in cache.\n')
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------- process
|
||||||
|
inserted = 0
|
||||||
|
updated = 0
|
||||||
|
skipped = 0
|
||||||
|
|
||||||
|
for finding_id, note in rows:
|
||||||
|
str_id = str(finding_id)
|
||||||
|
|
||||||
|
if str_id not in known_ids:
|
||||||
|
print(f' SKIP {str_id} — not in active findings (resolved or never synced)')
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check if a note already exists
|
||||||
|
cur.execute('SELECT note FROM ivanti_finding_notes WHERE finding_id = ?', (str_id,))
|
||||||
|
existing = cur.fetchone()
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
if existing['note'] == note:
|
||||||
|
print(f' SKIP {str_id} — note unchanged')
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
action = 'UPDATE'
|
||||||
|
updated += 1
|
||||||
|
else:
|
||||||
|
action = 'INSERT'
|
||||||
|
inserted += 1
|
||||||
|
|
||||||
|
print(f' {action:6s} {str_id} → {note[:80]}{"…" if len(note) > 80 else ""}')
|
||||||
|
|
||||||
|
if not args.dry_run:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO ivanti_finding_notes (finding_id, note, updated_at)
|
||||||
|
VALUES (?, ?, datetime('now'))
|
||||||
|
ON CONFLICT(finding_id) DO UPDATE
|
||||||
|
SET note = excluded.note, updated_at = datetime('now')
|
||||||
|
""",
|
||||||
|
(str_id, note)
|
||||||
|
)
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------- summary
|
||||||
|
print()
|
||||||
|
if args.dry_run:
|
||||||
|
print(f'DRY RUN complete — would insert {inserted}, update {updated}, skip {skipped}.')
|
||||||
|
else:
|
||||||
|
con.commit()
|
||||||
|
print(f'Done — inserted {inserted}, updated {updated}, skipped {skipped} (unchanged).')
|
||||||
|
|
||||||
|
con.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
run(parse_args())
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
CVE Report Splitter
|
|
||||||
Splits multiple CVE IDs in a single row into separate rows for easier filtering and analysis.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import pandas as pd
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
def split_cve_report(input_file, output_file=None, sheet_name='Vulnerabilities', cve_column='CVE ID'):
|
|
||||||
"""
|
|
||||||
Split CVE IDs into separate rows.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
input_file: Path to input Excel file
|
|
||||||
output_file: Path to output file (default: adds '_Split' to input filename)
|
|
||||||
sheet_name: Name of sheet with vulnerability data (default: 'Vulnerabilities')
|
|
||||||
cve_column: Name of column containing CVE IDs (default: 'CVE ID')
|
|
||||||
"""
|
|
||||||
input_path = Path(input_file)
|
|
||||||
|
|
||||||
if not input_path.exists():
|
|
||||||
print(f"Error: File not found: {input_file}")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
if output_file is None:
|
|
||||||
output_file = input_path.parent / f"{input_path.stem}_Split{input_path.suffix}"
|
|
||||||
|
|
||||||
print(f"Reading: {input_file}")
|
|
||||||
|
|
||||||
try:
|
|
||||||
df = pd.read_excel(input_file, sheet_name=sheet_name)
|
|
||||||
except ValueError as e:
|
|
||||||
print(f"Error: Sheet '{sheet_name}' not found in workbook")
|
|
||||||
print(f"Available sheets: {pd.ExcelFile(input_file).sheet_names}")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
if cve_column not in df.columns:
|
|
||||||
print(f"Error: Column '{cve_column}' not found")
|
|
||||||
print(f"Available columns: {list(df.columns)}")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
original_rows = len(df)
|
|
||||||
print(f"Original rows: {original_rows}")
|
|
||||||
|
|
||||||
# Split CVE IDs by comma
|
|
||||||
df[cve_column] = df[cve_column].astype(str).str.split(',')
|
|
||||||
|
|
||||||
# Explode to create separate rows
|
|
||||||
df_exploded = df.explode(cve_column)
|
|
||||||
|
|
||||||
# Clean up CVE IDs
|
|
||||||
df_exploded[cve_column] = df_exploded[cve_column].str.strip()
|
|
||||||
df_exploded = df_exploded[df_exploded[cve_column].notna()]
|
|
||||||
df_exploded = df_exploded[df_exploded[cve_column] != 'nan']
|
|
||||||
df_exploded = df_exploded[df_exploded[cve_column] != '']
|
|
||||||
|
|
||||||
# Reset index
|
|
||||||
df_exploded = df_exploded.reset_index(drop=True)
|
|
||||||
|
|
||||||
new_rows = len(df_exploded)
|
|
||||||
print(f"New rows: {new_rows}")
|
|
||||||
print(f"Added {new_rows - original_rows} rows from splitting CVEs")
|
|
||||||
|
|
||||||
# Save output
|
|
||||||
df_exploded.to_excel(output_file, index=False, sheet_name=sheet_name)
|
|
||||||
print(f"\n✓ Success! Saved to: {output_file}")
|
|
||||||
|
|
||||||
return output_file
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
if len(sys.argv) < 2:
|
|
||||||
print("Usage: python3 split_cve_report.py <input_file.xlsx> [output_file.xlsx]")
|
|
||||||
print("\nExample:")
|
|
||||||
print(" python3 split_cve_report.py 'Vulnerability Workbook.xlsx'")
|
|
||||||
print(" python3 split_cve_report.py 'input.xlsx' 'output.xlsx'")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
input_file = sys.argv[1]
|
|
||||||
output_file = sys.argv[2] if len(sys.argv) > 2 else None
|
|
||||||
|
|
||||||
split_cve_report(input_file, output_file)
|
|
||||||
@@ -18,10 +18,10 @@ const createUsersRouter = require('./routes/users');
|
|||||||
const createAuditLogRouter = require('./routes/auditLog');
|
const createAuditLogRouter = require('./routes/auditLog');
|
||||||
const logAudit = require('./helpers/auditLog');
|
const logAudit = require('./helpers/auditLog');
|
||||||
const createNvdLookupRouter = require('./routes/nvdLookup');
|
const createNvdLookupRouter = require('./routes/nvdLookup');
|
||||||
const createWeeklyReportsRouter = require('./routes/weeklyReports');
|
|
||||||
const createKnowledgeBaseRouter = require('./routes/knowledgeBase');
|
const createKnowledgeBaseRouter = require('./routes/knowledgeBase');
|
||||||
const createArcherTicketsRouter = require('./routes/archerTickets');
|
const createArcherTicketsRouter = require('./routes/archerTickets');
|
||||||
const createIvantiWorkflowsRouter = require('./routes/ivantiWorkflows');
|
const createIvantiWorkflowsRouter = require('./routes/ivantiWorkflows');
|
||||||
|
const createIvantiFindingsRouter = require('./routes/ivantiFindings');
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const PORT = process.env.PORT || 3001;
|
const PORT = process.env.PORT || 3001;
|
||||||
@@ -175,9 +175,6 @@ const upload = multer({
|
|||||||
limits: { fileSize: 10 * 1024 * 1024 } // 10MB limit
|
limits: { fileSize: 10 * 1024 * 1024 } // 10MB limit
|
||||||
});
|
});
|
||||||
|
|
||||||
// Weekly reports routes (editor/admin for upload, all authenticated for download)
|
|
||||||
app.use('/api/weekly-reports', createWeeklyReportsRouter(db, upload));
|
|
||||||
|
|
||||||
// Knowledge base routes (editor/admin for upload/delete, all authenticated for view)
|
// Knowledge base routes (editor/admin for upload/delete, all authenticated for view)
|
||||||
app.use('/api/knowledge-base', createKnowledgeBaseRouter(db, upload));
|
app.use('/api/knowledge-base', createKnowledgeBaseRouter(db, upload));
|
||||||
|
|
||||||
@@ -187,6 +184,9 @@ app.use('/api/archer-tickets', createArcherTicketsRouter(db));
|
|||||||
// Ivanti / RiskSense workflow routes (all authenticated users)
|
// Ivanti / RiskSense workflow routes (all authenticated users)
|
||||||
app.use('/api/ivanti/workflows', createIvantiWorkflowsRouter(db, requireAuth));
|
app.use('/api/ivanti/workflows', createIvantiWorkflowsRouter(db, requireAuth));
|
||||||
|
|
||||||
|
// Ivanti / RiskSense host findings routes (all authenticated users)
|
||||||
|
app.use('/api/ivanti/findings', createIvantiFindingsRouter(db, requireAuth));
|
||||||
|
|
||||||
// ========== CVE ENDPOINTS ==========
|
// ========== CVE ENDPOINTS ==========
|
||||||
|
|
||||||
// Get all CVEs with optional filters (authenticated users)
|
// Get all CVEs with optional filters (authenticated users)
|
||||||
|
|||||||
120
docs/MOP-workflow-color-codes.md
Normal file
120
docs/MOP-workflow-color-codes.md
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
# MOP: Ivanti Finding Workflow Status — STEAM Security Dashboard
|
||||||
|
|
||||||
|
**Document Type:** Method of Procedure
|
||||||
|
**Applies To:** STEAM Security Dashboard — Reporting Page
|
||||||
|
**Audience:** NTS-AEO-ACCESS-ENG / NTS-AEO-STEAM team members
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Purpose
|
||||||
|
|
||||||
|
This document explains how to interpret the **Workflow** column on the Reporting page and what action to take for each status. The goal is to ensure every open finding is actively managed and no False Positive (FP) exception lapses unnoticed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Background
|
||||||
|
|
||||||
|
### What the Reporting Page Shows
|
||||||
|
The Reporting page displays **open findings only** (severity 8.5+, `generic_state = Open`). A finding disappears from this list when it is closed — which happens when a valid, approved FP exception is on file or when the vulnerability is remediated.
|
||||||
|
|
||||||
|
### What the Workflow Column Shows
|
||||||
|
The Workflow column tracks **FP# tickets only** — False Positive requests that a team member has manually submitted in Ivanti. These represent cases where the team has asserted a finding is not exploitable or applicable in our environment.
|
||||||
|
|
||||||
|
> **SYS# workflows are not shown.** SYS# are auto-generated system tracking records and do not require team action.
|
||||||
|
|
||||||
|
### Key Rule
|
||||||
|
If a finding appears in the Reporting page, it requires action — regardless of whether it has an FP# badge or not.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Workflow Column Color Codes
|
||||||
|
|
||||||
|
### 🔴 Red — Act Immediately
|
||||||
|
|
||||||
|
| State | What It Means | Required Action |
|
||||||
|
|---|---|---|
|
||||||
|
| **Expired** | An FP# ticket existed but the exception window has lapsed. The finding 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 request and denied it. The finding is considered a real, 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 | What It Means | Required Action |
|
||||||
|
|---|---|---|
|
||||||
|
| **Reworked** | The FP request was challenged by the reviewer and sent back for revision. | Review the reviewer's comments in Ivanti. Update the FP justification and **resubmit the ticket**. |
|
||||||
|
| **Actionable** | The FP ticket has been flagged as needing team action. | Open the ticket in Ivanti to review what is needed and respond accordingly. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 🔵 Blue — In Flight, Monitor
|
||||||
|
|
||||||
|
| State | What It Means | Required Action |
|
||||||
|
|---|---|---|
|
||||||
|
| **Requested** | An FP# ticket has been submitted and is awaiting security team approval. | No immediate action. Monitor for approval or rejection. If no response within your SLA window, follow up with the approver. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### — (No Badge) — Untriaged
|
||||||
|
|
||||||
|
| State | What It Means | Required Action |
|
||||||
|
|---|---|---|
|
||||||
|
| **No workflow badge** | No FP ticket has ever been submitted for this finding. | Triage the finding. Determine whether to: (1) remediate it, or (2) submit a new FP request if you have justification that it is a false positive. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Decision Flowchart
|
||||||
|
|
||||||
|
```
|
||||||
|
Finding appears in Reporting page
|
||||||
|
│
|
||||||
|
├── Does it have a Workflow badge?
|
||||||
|
│ │
|
||||||
|
│ ├── NO (—)
|
||||||
|
│ │ └── Triage → Remediate OR submit new FP request
|
||||||
|
│ │
|
||||||
|
│ └── YES → Check the color:
|
||||||
|
│ │
|
||||||
|
│ ├── 🔵 BLUE (Requested)
|
||||||
|
│ │ └── Wait for approval. Follow up if SLA window is approaching.
|
||||||
|
│ │
|
||||||
|
│ ├── 🟡 AMBER (Reworked / Actionable)
|
||||||
|
│ │ └── Open Ivanti ticket → Review feedback → Update → Resubmit
|
||||||
|
│ │
|
||||||
|
│ └── 🔴 RED
|
||||||
|
│ │
|
||||||
|
│ ├── Expired → Submit NEW FP request in Ivanti
|
||||||
|
│ │
|
||||||
|
│ └── Rejected → Remediate the vulnerability
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. How to Submit or Renew an FP Request in Ivanti
|
||||||
|
|
||||||
|
1. Log into [Ivanti / RiskSense](https://platform4.risksense.com)
|
||||||
|
2. Navigate to **Host Findings**
|
||||||
|
3. Search for the Finding ID shown in the dashboard (Finding ID column)
|
||||||
|
4. Select the finding → **Actions** → **Request False Positive**
|
||||||
|
5. Complete the justification form:
|
||||||
|
- Describe why the finding is not exploitable in this environment
|
||||||
|
- Reference any compensating controls, network segmentation, or vendor guidance
|
||||||
|
- Attach supporting evidence if available
|
||||||
|
6. Submit — ticket will appear as **Requested** (blue) in the dashboard once processed
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Quick Reference Card
|
||||||
|
|
||||||
|
| Badge Color | 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 |
|
||||||
|
| 🔵 Blue | Requested | Monitor — no action yet |
|
||||||
|
| — | No badge | Triage: remediate or submit FP |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Last updated: 2026-03-11*
|
||||||
@@ -12,7 +12,8 @@
|
|||||||
"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",
|
||||||
"web-vitals": "^2.1.4"
|
"web-vitals": "^2.1.4",
|
||||||
|
"xlsx": "^0.18.5"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "react-scripts start",
|
"start": "react-scripts start",
|
||||||
|
|||||||
@@ -1,14 +1,18 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { Search, FileText, AlertCircle, Download, Upload, Eye, Filter, CheckCircle, XCircle, Loader, Trash2, Plus, RefreshCw, Edit2, ChevronDown, Shield, Activity } from 'lucide-react';
|
import { Search, FileText, AlertCircle, Download, Upload, Eye, Filter, CheckCircle, XCircle, Loader, Trash2, Plus, RefreshCw, Edit2, ChevronDown, Shield, Activity, Menu } from 'lucide-react';
|
||||||
import { useAuth } from './contexts/AuthContext';
|
import { useAuth } from './contexts/AuthContext';
|
||||||
import LoginForm from './components/LoginForm';
|
import LoginForm from './components/LoginForm';
|
||||||
import UserMenu from './components/UserMenu';
|
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 WeeklyReportModal from './components/WeeklyReportModal';
|
|
||||||
import KnowledgeBaseModal from './components/KnowledgeBaseModal';
|
import KnowledgeBaseModal from './components/KnowledgeBaseModal';
|
||||||
import KnowledgeBaseViewer from './components/KnowledgeBaseViewer';
|
import KnowledgeBaseViewer from './components/KnowledgeBaseViewer';
|
||||||
|
import NavDrawer from './components/NavDrawer';
|
||||||
|
import CalendarWidget from './components/CalendarWidget';
|
||||||
|
import ReportingPage from './components/pages/ReportingPage';
|
||||||
|
import KnowledgeBasePage from './components/pages/KnowledgeBasePage';
|
||||||
|
import ExportsPage from './components/pages/ExportsPage';
|
||||||
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';
|
||||||
@@ -172,11 +176,14 @@ export default function App() {
|
|||||||
const [cveDocuments, setCveDocuments] = useState({});
|
const [cveDocuments, setCveDocuments] = useState({});
|
||||||
const [quickCheckCVE, setQuickCheckCVE] = useState('');
|
const [quickCheckCVE, setQuickCheckCVE] = useState('');
|
||||||
const [quickCheckResult, setQuickCheckResult] = useState(null);
|
const [quickCheckResult, setQuickCheckResult] = useState(null);
|
||||||
|
const [currentPage, setCurrentPage] = useState('home');
|
||||||
|
const [navOpen, setNavOpen] = useState(false);
|
||||||
|
const [calendarFilter, setCalendarFilter] = useState(null);
|
||||||
|
const [reportingExcFilter, setReportingExcFilter] = useState(null);
|
||||||
const [showAddCVE, setShowAddCVE] = useState(false);
|
const [showAddCVE, setShowAddCVE] = useState(false);
|
||||||
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 [showWeeklyReport, setShowWeeklyReport] = useState(false);
|
|
||||||
const [showKnowledgeBase, setShowKnowledgeBase] = useState(false);
|
const [showKnowledgeBase, setShowKnowledgeBase] = useState(false);
|
||||||
const [knowledgeBaseArticles, setKnowledgeBaseArticles] = useState([]);
|
const [knowledgeBaseArticles, setKnowledgeBaseArticles] = useState([]);
|
||||||
const [selectedKBArticle, setSelectedKBArticle] = useState(null);
|
const [selectedKBArticle, setSelectedKBArticle] = useState(null);
|
||||||
@@ -952,18 +959,39 @@ export default function App() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-intel-darkest grid-bg p-6 relative overflow-hidden fade-in">
|
<div className="min-h-screen bg-intel-darkest grid-bg p-6 relative overflow-hidden fade-in">
|
||||||
|
<NavDrawer
|
||||||
|
isOpen={navOpen}
|
||||||
|
onClose={() => setNavOpen(false)}
|
||||||
|
currentPage={currentPage}
|
||||||
|
onNavigate={(page) => {
|
||||||
|
// Clear contextual filters when navigating directly via the nav drawer
|
||||||
|
if (page === 'reporting') { setCalendarFilter(null); setReportingExcFilter(null); }
|
||||||
|
setCurrentPage(page);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
{/* Scanning line effect */}
|
{/* Scanning line effect */}
|
||||||
<div className="scan-line"></div>
|
<div className="scan-line"></div>
|
||||||
|
|
||||||
<div className="max-w-7xl mx-auto relative z-10">
|
<div className={`${currentPage === 'reporting' ? '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">
|
||||||
<div className="flex-1">
|
<div className="flex items-center gap-4 flex-1">
|
||||||
|
<button
|
||||||
|
onClick={() => setNavOpen(true)}
|
||||||
|
style={{ background: 'none', border: '1px solid rgba(14, 165, 233, 0.25)', borderRadius: '0.375rem', padding: '0.5rem', cursor: 'pointer', color: '#64748B', flexShrink: 0 }}
|
||||||
|
onMouseEnter={e => { e.currentTarget.style.color = '#0EA5E9'; e.currentTarget.style.borderColor = 'rgba(14, 165, 233, 0.6)'; }}
|
||||||
|
onMouseLeave={e => { e.currentTarget.style.color = '#64748B'; e.currentTarget.style.borderColor = 'rgba(14, 165, 233, 0.25)'; }}
|
||||||
|
title="Navigation"
|
||||||
|
>
|
||||||
|
<Menu className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
<div>
|
||||||
<h1 className="text-4xl font-bold text-intel-accent mb-1 font-mono tracking-tight">
|
<h1 className="text-4xl font-bold text-intel-accent mb-1 font-mono tracking-tight">
|
||||||
CVE INTEL
|
STEAM Security Dashboard
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-gray-400 text-sm font-sans">Threat Intelligence & Vulnerability Command Center</p>
|
<p className="text-gray-400 text-sm font-sans">NTS Threat Intelligence and Metric Aggregation</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{canWrite() && (
|
{canWrite() && (
|
||||||
@@ -975,15 +1003,6 @@ export default function App() {
|
|||||||
NVD Sync
|
NVD Sync
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{canWrite() && (
|
|
||||||
<button
|
|
||||||
onClick={() => setShowWeeklyReport(true)}
|
|
||||||
className="intel-button intel-button-success relative z-10 flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<Upload className="w-4 h-4" />
|
|
||||||
Weekly Report
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{canWrite() && (
|
{canWrite() && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowAddCVE(true)}
|
onClick={() => setShowAddCVE(true)}
|
||||||
@@ -997,8 +1016,8 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stats Bar - Modern refined styling */}
|
{/* Stats Bar - only shown on Home page */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
{currentPage === 'home' && <div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||||
<div style={STYLES.statCard}>
|
<div style={STYLES.statCard}>
|
||||||
<div style={{ position: 'absolute', top: 0, left: 0, right: 0, height: '2px', background: 'linear-gradient(90deg, transparent, #0EA5E9, transparent)', boxShadow: '0 0 8px rgba(14, 165, 233, 0.5)' }}></div>
|
<div style={{ position: 'absolute', top: 0, left: 0, right: 0, height: '2px', background: 'linear-gradient(90deg, transparent, #0EA5E9, transparent)', boxShadow: '0 0 8px rgba(14, 165, 233, 0.5)' }}></div>
|
||||||
<div style={{ fontSize: '0.75rem', textTransform: 'uppercase', letterSpacing: '0.1em', color: '#CBD5E1', marginBottom: '0.25rem' }}>Total CVEs</div>
|
<div style={{ fontSize: '0.75rem', textTransform: 'uppercase', letterSpacing: '0.1em', color: '#CBD5E1', marginBottom: '0.25rem' }}>Total CVEs</div>
|
||||||
@@ -1019,8 +1038,13 @@ export default function App() {
|
|||||||
<div style={{ fontSize: '0.75rem', textTransform: 'uppercase', letterSpacing: '0.1em', color: '#CBD5E1', marginBottom: '0.25rem' }}>Critical</div>
|
<div style={{ fontSize: '0.75rem', textTransform: 'uppercase', letterSpacing: '0.1em', color: '#CBD5E1', marginBottom: '0.25rem' }}>Critical</div>
|
||||||
<div style={{ fontSize: '1.5rem', fontWeight: '700', fontFamily: 'monospace', color: '#EF4444', textShadow: '0 0 16px rgba(239, 68, 68, 0.4)' }}>{cves.filter(c => c.severity === 'Critical').length}</div>
|
<div style={{ fontSize: '1.5rem', fontWeight: '700', fontFamily: 'monospace', color: '#EF4444', textShadow: '0 0 16px rgba(239, 68, 68, 0.4)' }}>{cves.filter(c => c.severity === 'Critical').length}</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
{/* Page content */}
|
||||||
|
{currentPage === 'reporting' && <ReportingPage filterDate={calendarFilter} filterEXC={reportingExcFilter} />}
|
||||||
|
{currentPage === 'knowledge-base' && <KnowledgeBasePage />}
|
||||||
|
{currentPage === 'exports' && <ExportsPage />}
|
||||||
|
|
||||||
{/* User Management Modal */}
|
{/* User Management Modal */}
|
||||||
{showUserManagement && (
|
{showUserManagement && (
|
||||||
@@ -1037,11 +1061,6 @@ export default function App() {
|
|||||||
<NvdSyncModal onClose={() => setShowNvdSync(false)} onSyncComplete={() => fetchCVEs()} />
|
<NvdSyncModal onClose={() => setShowNvdSync(false)} onSyncComplete={() => fetchCVEs()} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Weekly Report Modal */}
|
|
||||||
{showWeeklyReport && (
|
|
||||||
<WeeklyReportModal onClose={() => setShowWeeklyReport(false)} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Knowledge Base Modal */}
|
{/* Knowledge Base Modal */}
|
||||||
{showKnowledgeBase && (
|
{showKnowledgeBase && (
|
||||||
<KnowledgeBaseModal
|
<KnowledgeBaseModal
|
||||||
@@ -1640,8 +1659,8 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Three Column Layout */}
|
{/* Three Column Layout - Home page only */}
|
||||||
<div className="grid grid-cols-12 gap-6">
|
{currentPage === 'home' && <div className="grid grid-cols-12 gap-6">
|
||||||
{/* LEFT PANEL - Wiki/Knowledge Base */}
|
{/* LEFT PANEL - Wiki/Knowledge Base */}
|
||||||
<div className="col-span-12 lg:col-span-3 space-y-4">
|
<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 style={{...STYLES.intelCard, padding: '1.5rem', borderLeft: '3px solid #10B981'}} className="rounded-lg">
|
||||||
@@ -2207,63 +2226,12 @@ export default function App() {
|
|||||||
Calendar
|
Calendar
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
{/* Simple Calendar Grid */}
|
<CalendarWidget
|
||||||
<div className="mb-2">
|
onDateClick={(dateStr) => {
|
||||||
<div className="text-center mb-3">
|
setCalendarFilter(dateStr);
|
||||||
<span className="text-white font-semibold font-mono">February 2024</span>
|
setCurrentPage('reporting');
|
||||||
</div>
|
}}
|
||||||
<div className="grid grid-cols-7 gap-1 text-center text-xs mb-2">
|
/>
|
||||||
<div className="text-gray-400 font-mono">Su</div>
|
|
||||||
<div className="text-gray-400 font-mono">Mo</div>
|
|
||||||
<div className="text-gray-400 font-mono">Tu</div>
|
|
||||||
<div className="text-gray-400 font-mono">We</div>
|
|
||||||
<div className="text-gray-400 font-mono">Th</div>
|
|
||||||
<div className="text-gray-400 font-mono">Fr</div>
|
|
||||||
<div className="text-gray-400 font-mono">Sa</div>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-7 gap-1 text-center">
|
|
||||||
{/* Week 1 */}
|
|
||||||
<div className="text-gray-600 font-mono text-xs p-1">28</div>
|
|
||||||
<div className="text-gray-600 font-mono text-xs p-1">29</div>
|
|
||||||
<div className="text-gray-600 font-mono text-xs p-1">30</div>
|
|
||||||
<div className="text-gray-600 font-mono text-xs p-1">31</div>
|
|
||||||
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">1</div>
|
|
||||||
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">2</div>
|
|
||||||
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">3</div>
|
|
||||||
{/* Week 2 */}
|
|
||||||
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">4</div>
|
|
||||||
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">5</div>
|
|
||||||
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">6</div>
|
|
||||||
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">7</div>
|
|
||||||
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">8</div>
|
|
||||||
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">9</div>
|
|
||||||
<div className="bg-intel-accent/30 text-white font-mono text-xs p-1 rounded font-bold border border-intel-accent">10</div>
|
|
||||||
{/* Week 3 */}
|
|
||||||
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">11</div>
|
|
||||||
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">12</div>
|
|
||||||
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">13</div>
|
|
||||||
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">14</div>
|
|
||||||
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">15</div>
|
|
||||||
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">16</div>
|
|
||||||
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">17</div>
|
|
||||||
{/* Week 4 */}
|
|
||||||
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">18</div>
|
|
||||||
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">19</div>
|
|
||||||
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">20</div>
|
|
||||||
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">21</div>
|
|
||||||
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">22</div>
|
|
||||||
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">23</div>
|
|
||||||
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">24</div>
|
|
||||||
{/* Week 5 */}
|
|
||||||
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">25</div>
|
|
||||||
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">26</div>
|
|
||||||
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">27</div>
|
|
||||||
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">28</div>
|
|
||||||
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">29</div>
|
|
||||||
<div className="text-gray-600 font-mono text-xs p-1">1</div>
|
|
||||||
<div className="text-gray-600 font-mono text-xs p-1">2</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Open Vendor Tickets */}
|
{/* Open Vendor Tickets */}
|
||||||
@@ -2365,16 +2333,23 @@ export default function App() {
|
|||||||
>
|
>
|
||||||
{ticket.exc_number}
|
{ticket.exc_number}
|
||||||
</a>
|
</a>
|
||||||
{canWrite() && (
|
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => { setReportingExcFilter(ticket.exc_number); setCurrentPage('reporting'); }}
|
||||||
|
title="View findings referencing this ticket"
|
||||||
|
className="text-gray-400 hover:text-sky-400 transition-colors"
|
||||||
|
>
|
||||||
|
<Filter className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
{canWrite() && (<>
|
||||||
<button onClick={() => handleEditArcherTicket(ticket)} className="text-gray-400 hover:text-purple-400 transition-colors">
|
<button onClick={() => handleEditArcherTicket(ticket)} className="text-gray-400 hover:text-purple-400 transition-colors">
|
||||||
<Edit2 className="w-3 h-3" />
|
<Edit2 className="w-3 h-3" />
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => handleDeleteArcherTicket(ticket)} className="text-gray-400 hover:text-intel-danger transition-colors">
|
<button onClick={() => handleDeleteArcherTicket(ticket)} className="text-gray-400 hover:text-intel-danger transition-colors">
|
||||||
<Trash2 className="w-3 h-3" />
|
<Trash2 className="w-3 h-3" />
|
||||||
</button>
|
</button>
|
||||||
|
</>)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-white font-mono mb-1">{ticket.cve_id}</div>
|
<div className="text-xs text-white font-mono mb-1">{ticket.cve_id}</div>
|
||||||
<div className="text-xs text-gray-400">{ticket.vendor}</div>
|
<div className="text-xs text-gray-400">{ticket.vendor}</div>
|
||||||
@@ -2488,7 +2463,7 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
{/* End Right Panel */}
|
{/* End Right Panel */}
|
||||||
|
|
||||||
</div>
|
</div>}
|
||||||
{/* End Three Column Layout */}
|
{/* End Three Column Layout */}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
167
frontend/src/components/CalendarWidget.js
Normal file
167
frontend/src/components/CalendarWidget.js
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||||
|
|
||||||
|
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
||||||
|
|
||||||
|
const MONTH_NAMES = [
|
||||||
|
'January', 'February', 'March', 'April', 'May', 'June',
|
||||||
|
'July', 'August', 'September', 'October', 'November', 'December'
|
||||||
|
];
|
||||||
|
const DAY_NAMES = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'];
|
||||||
|
|
||||||
|
function toLocalDateStr(date) {
|
||||||
|
const y = date.getFullYear();
|
||||||
|
const m = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
|
const d = String(date.getDate()).padStart(2, '0');
|
||||||
|
return `${y}-${m}-${d}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CalendarWidget({ onDateClick }) {
|
||||||
|
const today = new Date();
|
||||||
|
const todayStr = toLocalDateStr(today);
|
||||||
|
|
||||||
|
const [calYear, setCalYear] = useState(today.getFullYear());
|
||||||
|
const [calMonth, setCalMonth] = useState(today.getMonth()); // 0-indexed
|
||||||
|
|
||||||
|
// Map of "YYYY-MM-DD" → count of findings due that day
|
||||||
|
const [dueDates, setDueDates] = useState({});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch(`${API_BASE}/ivanti/findings`, { credentials: 'include' })
|
||||||
|
.then((r) => (r.ok ? r.json() : null))
|
||||||
|
.then((data) => {
|
||||||
|
if (!data?.findings) return;
|
||||||
|
const counts = {};
|
||||||
|
data.findings.forEach((f) => {
|
||||||
|
if (f.dueDate) {
|
||||||
|
counts[f.dueDate] = (counts[f.dueDate] || 0) + 1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
setDueDates(counts);
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const prevMonth = () => {
|
||||||
|
if (calMonth === 0) { setCalMonth(11); setCalYear((y) => y - 1); }
|
||||||
|
else { setCalMonth((m) => m - 1); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const nextMonth = () => {
|
||||||
|
if (calMonth === 11) { setCalMonth(0); setCalYear((y) => y + 1); }
|
||||||
|
else { setCalMonth((m) => m + 1); }
|
||||||
|
};
|
||||||
|
|
||||||
|
// Build cell array: null = padding, number = day of month
|
||||||
|
const firstDow = new Date(calYear, calMonth, 1).getDay(); // 0=Sun
|
||||||
|
const daysInMonth = new Date(calYear, calMonth + 1, 0).getDate();
|
||||||
|
const cells = [
|
||||||
|
...Array(firstDow).fill(null),
|
||||||
|
...Array.from({ length: daysInMonth }, (_, i) => i + 1),
|
||||||
|
];
|
||||||
|
while (cells.length % 7 !== 0) cells.push(null); // complete last row
|
||||||
|
|
||||||
|
const hasDueDatesThisMonth = cells.some((day) => {
|
||||||
|
if (!day) return false;
|
||||||
|
const ds = `${calYear}-${String(calMonth + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
|
||||||
|
return !!dueDates[ds];
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* Month navigation */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '0.75rem' }}>
|
||||||
|
<button
|
||||||
|
onClick={prevMonth}
|
||||||
|
style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#64748B', padding: '2px 4px', borderRadius: '4px', lineHeight: 1, transition: 'color 0.15s' }}
|
||||||
|
onMouseEnter={(e) => { e.currentTarget.style.color = '#0EA5E9'; }}
|
||||||
|
onMouseLeave={(e) => { e.currentTarget.style.color = '#64748B'; }}
|
||||||
|
>
|
||||||
|
<ChevronLeft style={{ width: '14px', height: '14px' }} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<span style={{ color: '#E2E8F0', fontFamily: 'monospace', fontWeight: '600', fontSize: '0.85rem' }}>
|
||||||
|
{MONTH_NAMES[calMonth]} {calYear}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={nextMonth}
|
||||||
|
style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#64748B', padding: '2px 4px', borderRadius: '4px', lineHeight: 1, transition: 'color 0.15s' }}
|
||||||
|
onMouseEnter={(e) => { e.currentTarget.style.color = '#0EA5E9'; }}
|
||||||
|
onMouseLeave={(e) => { e.currentTarget.style.color = '#64748B'; }}
|
||||||
|
>
|
||||||
|
<ChevronRight style={{ width: '14px', height: '14px' }} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Day-of-week headers */}
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', gap: '2px', textAlign: 'center', marginBottom: '4px' }}>
|
||||||
|
{DAY_NAMES.map((d) => (
|
||||||
|
<div key={d} style={{ fontSize: '0.6rem', color: '#475569', fontFamily: 'monospace', fontWeight: '600', textTransform: 'uppercase' }}>
|
||||||
|
{d}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Day cells */}
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', gap: '2px' }}>
|
||||||
|
{cells.map((day, idx) => {
|
||||||
|
if (!day) return <div key={idx} />;
|
||||||
|
|
||||||
|
const dateStr = `${calYear}-${String(calMonth + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
|
||||||
|
const isToday = dateStr === todayStr;
|
||||||
|
const dueCount = dueDates[dateStr] || 0;
|
||||||
|
const hasDue = dueCount > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
title={hasDue ? `${dueCount} finding${dueCount > 1 ? 's' : ''} due — click to view` : undefined}
|
||||||
|
onClick={hasDue && onDateClick ? () => onDateClick(dateStr) : undefined}
|
||||||
|
style={{
|
||||||
|
display: 'flex', flexDirection: 'column', alignItems: 'center',
|
||||||
|
gap: '2px', padding: '3px 1px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
background: isToday ? 'rgba(14,165,233,0.2)' : 'transparent',
|
||||||
|
border: isToday ? '1px solid rgba(14,165,233,0.5)' : '1px solid transparent',
|
||||||
|
cursor: hasDue ? 'pointer' : 'default',
|
||||||
|
transition: hasDue ? 'background 0.15s' : undefined,
|
||||||
|
}}
|
||||||
|
onMouseEnter={hasDue ? (e) => { e.currentTarget.style.background = isToday ? 'rgba(14,165,233,0.35)' : 'rgba(239,68,68,0.15)'; } : undefined}
|
||||||
|
onMouseLeave={hasDue ? (e) => { e.currentTarget.style.background = isToday ? 'rgba(14,165,233,0.2)' : 'transparent'; } : undefined}
|
||||||
|
>
|
||||||
|
<span style={{
|
||||||
|
fontSize: '0.7rem', fontFamily: 'monospace', lineHeight: 1,
|
||||||
|
color: isToday ? '#0EA5E9' : hasDue ? '#EF4444' : '#CBD5E1',
|
||||||
|
fontWeight: (isToday || hasDue) ? '700' : '400',
|
||||||
|
}}>
|
||||||
|
{day}
|
||||||
|
</span>
|
||||||
|
{/* Red dot indicator for due dates */}
|
||||||
|
{hasDue ? (
|
||||||
|
<div style={{
|
||||||
|
width: '4px', height: '4px', borderRadius: '50%',
|
||||||
|
background: '#EF4444',
|
||||||
|
boxShadow: '0 0 4px rgba(239,68,68,0.6)',
|
||||||
|
flexShrink: 0,
|
||||||
|
}} />
|
||||||
|
) : (
|
||||||
|
<div style={{ width: '4px', height: '4px' }} /> // spacer to keep rows even
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Legend — only shown when there are due dates this month */}
|
||||||
|
{hasDueDatesThisMonth && (
|
||||||
|
<div style={{ marginTop: '0.75rem', paddingTop: '0.625rem', borderTop: '1px solid rgba(255,255,255,0.05)', display: 'flex', alignItems: 'center', gap: '0.375rem' }}>
|
||||||
|
<div style={{ width: '6px', height: '6px', borderRadius: '50%', background: '#EF4444', boxShadow: '0 0 4px rgba(239,68,68,0.5)', flexShrink: 0 }} />
|
||||||
|
<span style={{ fontSize: '0.62rem', color: '#64748B', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
||||||
|
Ivanti finding due
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
127
frontend/src/components/NavDrawer.js
Normal file
127
frontend/src/components/NavDrawer.js
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { X, Home, BarChart2, BookOpen, Download } from 'lucide-react';
|
||||||
|
|
||||||
|
const NAV_ITEMS = [
|
||||||
|
{ id: 'home', label: 'Home', icon: Home, color: '#0EA5E9', description: 'Main dashboard' },
|
||||||
|
{ id: 'reporting', label: 'Reporting', icon: BarChart2,color: '#F59E0B', description: 'Reports & analytics' },
|
||||||
|
{ 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' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function NavDrawer({ isOpen, onClose, currentPage, onNavigate }) {
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Backdrop */}
|
||||||
|
<div
|
||||||
|
onClick={onClose}
|
||||||
|
style={{
|
||||||
|
position: 'fixed', inset: 0,
|
||||||
|
background: 'rgba(0, 0, 0, 0.65)',
|
||||||
|
backdropFilter: 'blur(3px)',
|
||||||
|
zIndex: 50
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Drawer */}
|
||||||
|
<div style={{
|
||||||
|
position: 'fixed', top: 0, left: 0, bottom: 0, width: '280px',
|
||||||
|
background: 'linear-gradient(180deg, #0F1A2E 0%, #0A1628 100%)',
|
||||||
|
borderRight: '1px solid rgba(14, 165, 233, 0.2)',
|
||||||
|
boxShadow: '6px 0 32px rgba(0, 0, 0, 0.7)',
|
||||||
|
zIndex: 51,
|
||||||
|
display: 'flex', flexDirection: 'column',
|
||||||
|
padding: '1.5rem'
|
||||||
|
}}>
|
||||||
|
{/* Drawer header */}
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '2rem' }}>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontFamily: 'monospace', fontSize: '1rem', fontWeight: '700', color: '#0EA5E9', textTransform: 'uppercase', letterSpacing: '0.15em', textShadow: '0 0 12px rgba(14, 165, 233, 0.5)' }}>
|
||||||
|
STEAM
|
||||||
|
</div>
|
||||||
|
<div style={{ fontFamily: 'monospace', fontSize: '0.65rem', color: '#475569', textTransform: 'uppercase', letterSpacing: '0.1em', marginTop: '2px' }}>
|
||||||
|
Security Dashboard
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
style={{ color: '#475569', padding: '0.25rem', background: 'none', border: 'none', cursor: 'pointer', lineHeight: 1 }}
|
||||||
|
onMouseEnter={e => e.currentTarget.style.color = '#E2E8F0'}
|
||||||
|
onMouseLeave={e => e.currentTarget.style.color = '#475569'}
|
||||||
|
>
|
||||||
|
<X style={{ width: '20px', height: '20px' }} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Nav items */}
|
||||||
|
<nav style={{ display: 'flex', flexDirection: 'column', gap: '0.375rem' }}>
|
||||||
|
{NAV_ITEMS.map(({ id, label, icon: Icon, color, description }) => {
|
||||||
|
const active = currentPage === id;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={id}
|
||||||
|
onClick={() => { onNavigate(id); onClose(); }}
|
||||||
|
style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: '0.875rem',
|
||||||
|
padding: '0.75rem 0.875rem',
|
||||||
|
borderRadius: '0.5rem',
|
||||||
|
border: active ? `1px solid ${color}50` : '1px solid transparent',
|
||||||
|
background: active ? `${color}18` : 'transparent',
|
||||||
|
cursor: 'pointer', textAlign: 'left', width: '100%',
|
||||||
|
transition: 'background 0.15s, border-color 0.15s'
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => { if (!active) e.currentTarget.style.background = 'rgba(255,255,255,0.04)'; }}
|
||||||
|
onMouseLeave={e => { if (!active) e.currentTarget.style.background = 'transparent'; }}
|
||||||
|
>
|
||||||
|
{/* Icon box */}
|
||||||
|
<div style={{
|
||||||
|
width: '36px', height: '36px', flexShrink: 0,
|
||||||
|
borderRadius: '0.375rem',
|
||||||
|
background: `${color}18`,
|
||||||
|
border: `1px solid ${color}40`,
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center'
|
||||||
|
}}>
|
||||||
|
<Icon style={{ width: '17px', height: '17px', color }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Label + description */}
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{
|
||||||
|
fontFamily: 'monospace', fontSize: '0.8rem', fontWeight: '600',
|
||||||
|
color: active ? color : '#CBD5E1',
|
||||||
|
textTransform: 'uppercase', letterSpacing: '0.06em'
|
||||||
|
}}>
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '0.68rem', color: '#475569', marginTop: '1px' }}>
|
||||||
|
{description}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Active indicator dot */}
|
||||||
|
{active && (
|
||||||
|
<div style={{
|
||||||
|
width: '6px', height: '6px', borderRadius: '50%',
|
||||||
|
background: color, boxShadow: `0 0 8px ${color}`, flexShrink: 0
|
||||||
|
}} />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div style={{
|
||||||
|
marginTop: 'auto', paddingTop: '1rem',
|
||||||
|
borderTop: '1px solid rgba(255, 255, 255, 0.05)',
|
||||||
|
textAlign: 'center'
|
||||||
|
}}>
|
||||||
|
<div style={{ fontSize: '0.6rem', color: '#1E293B', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: '0.1em' }}>
|
||||||
|
NTS Threat Intelligence
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,291 +0,0 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
|
||||||
import { X, Loader, AlertCircle, CheckCircle, Upload as UploadIcon, Download, Star } from 'lucide-react';
|
|
||||||
|
|
||||||
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
|
||||||
|
|
||||||
export default function WeeklyReportModal({ onClose }) {
|
|
||||||
const [phase, setPhase] = useState('idle'); // idle, uploading, processing, success, error
|
|
||||||
const [selectedFile, setSelectedFile] = useState(null);
|
|
||||||
const [uploadProgress, setUploadProgress] = useState(0);
|
|
||||||
const [result, setResult] = useState(null);
|
|
||||||
const [existingReports, setExistingReports] = useState([]);
|
|
||||||
const [error, setError] = useState('');
|
|
||||||
|
|
||||||
// Fetch existing reports on mount
|
|
||||||
useEffect(() => {
|
|
||||||
fetchExistingReports();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const fetchExistingReports = async () => {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${API_BASE}/weekly-reports`, { credentials: 'include' });
|
|
||||||
if (!response.ok) throw new Error('Failed to fetch reports');
|
|
||||||
const data = await response.json();
|
|
||||||
setExistingReports(data);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error fetching reports:', err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleFileSelect = (e) => {
|
|
||||||
const file = e.target.files[0];
|
|
||||||
if (file) {
|
|
||||||
if (!file.name.endsWith('.xlsx')) {
|
|
||||||
setError('Please select an Excel file (.xlsx)');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setSelectedFile(file);
|
|
||||||
setError('');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleUpload = async () => {
|
|
||||||
if (!selectedFile) return;
|
|
||||||
|
|
||||||
setPhase('uploading');
|
|
||||||
setUploadProgress(0);
|
|
||||||
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append('file', selectedFile);
|
|
||||||
|
|
||||||
try {
|
|
||||||
setUploadProgress(50); // Simulated progress
|
|
||||||
setPhase('processing');
|
|
||||||
|
|
||||||
const response = await fetch(`${API_BASE}/weekly-reports/upload`, {
|
|
||||||
method: 'POST',
|
|
||||||
body: formData,
|
|
||||||
credentials: 'include'
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorData = await response.json();
|
|
||||||
throw new Error(errorData.error || 'Upload failed');
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
setResult(data);
|
|
||||||
setPhase('success');
|
|
||||||
|
|
||||||
// Refresh the list of existing reports
|
|
||||||
await fetchExistingReports();
|
|
||||||
} catch (err) {
|
|
||||||
setError(err.message);
|
|
||||||
setPhase('error');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDownload = async (id, type) => {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${API_BASE}/weekly-reports/${id}/download/${type}`, {
|
|
||||||
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 = `vulnerability_report_${type}.xlsx`;
|
|
||||||
document.body.appendChild(a);
|
|
||||||
a.click();
|
|
||||||
window.URL.revokeObjectURL(url);
|
|
||||||
document.body.removeChild(a);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error downloading file:', err);
|
|
||||||
setError(`Failed to download ${type} file`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const resetForm = () => {
|
|
||||||
setPhase('idle');
|
|
||||||
setSelectedFile(null);
|
|
||||||
setUploadProgress(0);
|
|
||||||
setResult(null);
|
|
||||||
setError('');
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="modal-overlay" onClick={onClose}>
|
|
||||||
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
|
|
||||||
{/* Header */}
|
|
||||||
<div className="modal-header">
|
|
||||||
<h2 className="modal-title">Weekly Vulnerability Report</h2>
|
|
||||||
<button onClick={onClose} className="modal-close">
|
|
||||||
<X className="w-5 h-5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Body */}
|
|
||||||
<div className="modal-body">
|
|
||||||
{/* Idle Phase - File Selection */}
|
|
||||||
{phase === 'idle' && (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium mb-2" style={{ color: '#94A3B8' }}>
|
|
||||||
Upload Excel File (.xlsx)
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
accept=".xlsx"
|
|
||||||
onChange={handleFileSelect}
|
|
||||||
className="intel-input w-full"
|
|
||||||
/>
|
|
||||||
{selectedFile && (
|
|
||||||
<p className="mt-2 text-sm" style={{ color: '#10B981' }}>
|
|
||||||
Selected: {selectedFile.name}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={handleUpload}
|
|
||||||
disabled={!selectedFile}
|
|
||||||
className={`intel-button w-full ${selectedFile ? 'intel-button-success' : 'opacity-50 cursor-not-allowed'}`}
|
|
||||||
>
|
|
||||||
<UploadIcon className="w-4 h-4 mr-2" />
|
|
||||||
Upload & Process
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<div className="flex items-start gap-2 p-3 rounded" style={{ background: 'rgba(239, 68, 68, 0.1)', border: '1px solid #EF4444' }}>
|
|
||||||
<AlertCircle className="w-5 h-5 flex-shrink-0" style={{ color: '#EF4444' }} />
|
|
||||||
<p style={{ color: '#FCA5A5' }}>{error}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Uploading Phase */}
|
|
||||||
{phase === 'uploading' && (
|
|
||||||
<div className="text-center py-8">
|
|
||||||
<Loader className="w-12 h-12 animate-spin mx-auto mb-4" style={{ color: '#0EA5E9' }} />
|
|
||||||
<p style={{ color: '#94A3B8' }}>Uploading file...</p>
|
|
||||||
<div className="w-full bg-gray-700 rounded-full h-2 mt-4">
|
|
||||||
<div
|
|
||||||
className="h-2 rounded-full transition-all"
|
|
||||||
style={{ width: `${uploadProgress}%`, background: '#0EA5E9' }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Processing Phase */}
|
|
||||||
{phase === 'processing' && (
|
|
||||||
<div className="text-center py-8">
|
|
||||||
<Loader className="w-12 h-12 animate-spin mx-auto mb-4" style={{ color: '#0EA5E9' }} />
|
|
||||||
<p style={{ color: '#94A3B8' }}>Processing vulnerability report...</p>
|
|
||||||
<p className="text-sm mt-2" style={{ color: '#64748B' }}>Splitting CVE IDs into separate rows</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Success Phase */}
|
|
||||||
{phase === 'success' && result && (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="flex items-center gap-2 p-4 rounded" style={{ background: 'rgba(16, 185, 129, 0.1)', border: '1px solid #10B981' }}>
|
|
||||||
<CheckCircle className="w-6 h-6" style={{ color: '#10B981' }} />
|
|
||||||
<div>
|
|
||||||
<p className="font-medium" style={{ color: '#34D399' }}>Upload Successful!</p>
|
|
||||||
<p className="text-sm mt-1" style={{ color: '#94A3B8' }}>
|
|
||||||
Original: {result.original_rows} rows → Processed: {result.processed_rows} rows
|
|
||||||
<span className="ml-2" style={{ color: '#10B981' }}>
|
|
||||||
(+{result.processed_rows - result.original_rows} rows from splitting CVEs)
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-3">
|
|
||||||
<button
|
|
||||||
onClick={() => handleDownload(result.id, 'original')}
|
|
||||||
className="intel-button flex-1"
|
|
||||||
>
|
|
||||||
<Download className="w-4 h-4 mr-2" />
|
|
||||||
Download Original
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => handleDownload(result.id, 'processed')}
|
|
||||||
className="intel-button intel-button-success flex-1"
|
|
||||||
>
|
|
||||||
<Download className="w-4 h-4 mr-2" />
|
|
||||||
Download Processed
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button onClick={resetForm} className="intel-button w-full">
|
|
||||||
Upload Another Report
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Error Phase */}
|
|
||||||
{phase === 'error' && (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="flex items-start gap-2 p-4 rounded" style={{ background: 'rgba(239, 68, 68, 0.1)', border: '1px solid #EF4444' }}>
|
|
||||||
<AlertCircle className="w-6 h-6 flex-shrink-0" style={{ color: '#EF4444' }} />
|
|
||||||
<div>
|
|
||||||
<p className="font-medium" style={{ color: '#FCA5A5' }}>Upload Failed</p>
|
|
||||||
<p className="text-sm mt-1" style={{ color: '#94A3B8' }}>{error}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button onClick={resetForm} className="intel-button w-full">
|
|
||||||
Try Again
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Existing Reports Section */}
|
|
||||||
{(phase === 'idle' || phase === 'success') && existingReports.length > 0 && (
|
|
||||||
<div className="mt-8">
|
|
||||||
<h3 className="text-lg font-medium mb-4" style={{ color: '#94A3B8' }}>
|
|
||||||
Previous Reports
|
|
||||||
</h3>
|
|
||||||
<div className="space-y-3">
|
|
||||||
{existingReports.map((report) => (
|
|
||||||
<div
|
|
||||||
key={report.id}
|
|
||||||
className="intel-card p-4"
|
|
||||||
>
|
|
||||||
<div className="flex items-start justify-between">
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="flex items-center gap-2 mb-2">
|
|
||||||
{report.is_current && (
|
|
||||||
<Star className="w-4 h-4 fill-current" style={{ color: '#F59E0B' }} />
|
|
||||||
)}
|
|
||||||
<p className="font-medium" style={{ color: report.is_current ? '#F59E0B' : '#94A3B8' }}>
|
|
||||||
{report.week_label}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm" style={{ color: '#64748B' }}>
|
|
||||||
{new Date(report.upload_date).toLocaleDateString()} •
|
|
||||||
{report.row_count_original} → {report.row_count_processed} rows
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<button
|
|
||||||
onClick={() => handleDownload(report.id, 'original')}
|
|
||||||
className="intel-button intel-button-small"
|
|
||||||
title="Download Original"
|
|
||||||
>
|
|
||||||
<Download className="w-3 h-3" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => handleDownload(report.id, 'processed')}
|
|
||||||
className="intel-button intel-button-success intel-button-small"
|
|
||||||
title="Download Processed"
|
|
||||||
>
|
|
||||||
<Download className="w-3 h-3" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
25
frontend/src/components/pages/ExportsPage.js
Normal file
25
frontend/src/components/pages/ExportsPage.js
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Download } from 'lucide-react';
|
||||||
|
|
||||||
|
export default function ExportsPage() {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: '60vh' }}>
|
||||||
|
<div style={{ textAlign: 'center' }}>
|
||||||
|
<div style={{
|
||||||
|
width: '72px', height: '72px', borderRadius: '1rem', margin: '0 auto 1.5rem',
|
||||||
|
background: 'rgba(139, 92, 246, 0.1)',
|
||||||
|
border: '1px solid rgba(139, 92, 246, 0.3)',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center'
|
||||||
|
}}>
|
||||||
|
<Download style={{ width: '36px', height: '36px', color: '#8B5CF6' }} />
|
||||||
|
</div>
|
||||||
|
<h2 style={{ fontFamily: 'monospace', fontSize: '1.5rem', fontWeight: '700', color: '#8B5CF6', textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: '0.5rem' }}>
|
||||||
|
Exports
|
||||||
|
</h2>
|
||||||
|
<p style={{ color: '#475569', fontSize: '0.875rem', fontFamily: 'monospace' }}>
|
||||||
|
Under construction — coming soon
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
25
frontend/src/components/pages/KnowledgeBasePage.js
Normal file
25
frontend/src/components/pages/KnowledgeBasePage.js
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { BookOpen } from 'lucide-react';
|
||||||
|
|
||||||
|
export default function KnowledgeBasePage() {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: '60vh' }}>
|
||||||
|
<div style={{ textAlign: 'center' }}>
|
||||||
|
<div style={{
|
||||||
|
width: '72px', height: '72px', borderRadius: '1rem', margin: '0 auto 1.5rem',
|
||||||
|
background: 'rgba(16, 185, 129, 0.1)',
|
||||||
|
border: '1px solid rgba(16, 185, 129, 0.3)',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center'
|
||||||
|
}}>
|
||||||
|
<BookOpen style={{ width: '36px', height: '36px', color: '#10B981' }} />
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
1679
frontend/src/components/pages/ReportingPage.js
Normal file
1679
frontend/src/components/pages/ReportingPage.js
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user