Compare commits
107 Commits
feature/co
...
af951fdc12
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
af951fdc12
|
||
|
|
7f7d3a2977
|
||
|
|
034d3963b9
|
||
|
|
c8b3626ac5
|
||
|
|
8e377bb85f
|
||
|
|
5a9df2103f | ||
|
|
bfa52c7f8f | ||
|
|
3202b0707c | ||
|
|
15abf8bae4 | ||
| 8df961cce8 | |||
|
|
7a179f19a1 | ||
|
|
4f960d0866 | ||
|
|
caa1d539cc | ||
|
|
b1069b1a05 | ||
|
|
1186f9f807 | ||
|
|
e13b18c169 | ||
|
|
05d47c91a8 | ||
|
|
b0c3daba01 | ||
|
|
675847de0c | ||
|
|
623b57ca06 | ||
|
|
06c6821d85 | ||
|
|
8da62f0f14 | ||
|
|
5a9dc007db | ||
|
|
3f9e1da2a3 | ||
|
|
7ea4ceb8df | ||
|
|
00a6f7ae0f | ||
|
|
69809955a9 | ||
|
|
6ee68f5521 | ||
|
|
5ffedad02f | ||
|
|
8bf8dc55dd | ||
|
|
53439b2af8 | ||
|
|
4c04c9870a | ||
|
|
e1b000870c | ||
|
|
f3ba322403 | ||
|
|
0bea387ac9 | ||
|
|
aa3ce3bae9 | ||
|
|
0cdaecf890 | ||
|
|
043c85cc69 | ||
|
|
6082721452 | ||
|
|
a214393723 | ||
|
|
f141fa58a1 | ||
|
|
e1b0236874 | ||
|
|
ed48522932 | ||
|
|
938dda400a | ||
|
|
732873dd6a | ||
|
|
0fe8e94d51 | ||
|
|
28bce28fc9 | ||
|
|
72fd79ea42 | ||
|
|
f63c286458 | ||
|
|
93c144576f | ||
|
|
fa3b045a2f | ||
|
|
4583d09750 | ||
|
|
75ac8c823a | ||
|
|
68e36b4bac | ||
|
|
d24b45b404 | ||
|
|
d64eb7eec4 | ||
|
|
6cb65fddc1 | ||
|
|
0ca83c6736 | ||
|
|
06268880da | ||
|
|
b4f0ddcb78 | ||
|
|
55e3e074a5 | ||
|
|
66bbeb84a5 | ||
|
|
4578f8cd85 | ||
|
|
5469a86e6e | ||
|
|
2b6db1f903 | ||
|
|
7c97bc3a84 | ||
|
|
835fbf26e7 | ||
|
|
c4aaeff2a1 | ||
|
|
df30430956 | ||
|
|
57f11c362b | ||
|
|
4df83d36dd | ||
|
|
0a7a7c2827 | ||
|
|
1963faf9b8 | ||
|
|
9b36a58959 | ||
|
|
690c30aac0 | ||
|
|
fc68097821 | ||
|
|
d9fdaf5cbb | ||
|
|
cb3da6980c | ||
|
|
ccc3576706 | ||
|
|
5405926550 | ||
|
|
328e48ea8c | ||
|
|
41f9c35586 | ||
|
|
729dada05c | ||
|
|
5d417edf82 | ||
|
|
03e60c9daf | ||
|
|
ee9403ab47 | ||
|
|
3d04cd393f | ||
|
|
382bc81a7e | ||
|
|
7302ece958 | ||
|
|
80d80c099f | ||
|
|
a2a43a8685 | ||
|
|
a711972054 | ||
|
|
8a6a3485e9 | ||
|
|
169a0d2337 | ||
|
|
c50fc5d8a8 | ||
|
|
e9e2c0961d | ||
|
|
d910af847e | ||
|
|
73fd747576 | ||
| 1ef57b0504 | |||
|
|
d1fe0bf455 | ||
|
|
3f7887eba6 | ||
|
|
9bd5a52661 | ||
|
|
2b4ec5d8e2 | ||
|
|
62592e9821 | ||
| 2fead2cfef | |||
| 7c0ba41514 | |||
| 9c6c03a518 |
BIN
.compliance-staging/.gitkeep
Normal file
BIN
.compliance-staging/.gitkeep
Normal file
Binary file not shown.
29
.gitignore
vendored
29
.gitignore
vendored
@@ -39,10 +39,6 @@ frontend.pid
|
||||
backend/uploads/temp/
|
||||
feature_request*.md
|
||||
|
||||
# Planning docs
|
||||
docs/aeo-compliance-ui-plan.md
|
||||
docs/aeo-compliance-wireframe.md
|
||||
|
||||
# AI tooling config
|
||||
.claude/
|
||||
ai_notes.md
|
||||
@@ -51,3 +47,28 @@ backend/add_vendor_to_documents.js
|
||||
backend/fix_multivendor_constraint.js
|
||||
backend/server.js-backup
|
||||
backend/setup.js-backup
|
||||
|
||||
# Compliance staging — keep folder, ignore contents
|
||||
.compliance-staging/*
|
||||
!.compliance-staging/.gitkeep
|
||||
|
||||
# Kiro agents (local only)
|
||||
.kiro/
|
||||
|
||||
# Zip files
|
||||
*.zip
|
||||
|
||||
# Production DB copies
|
||||
cve_database_prod.db
|
||||
cve_database.db.prod
|
||||
cve_database.db.backup
|
||||
database.db
|
||||
|
||||
# Operations — local admin records, UAT logs, firewall requests, data exports
|
||||
docs/operations/
|
||||
|
||||
# Data exports — local spreadsheets
|
||||
docs/data-exports/
|
||||
|
||||
# Python cache
|
||||
__pycache__/
|
||||
|
||||
121
.gitlab-ci.yml
Normal file
121
.gitlab-ci.yml
Normal file
@@ -0,0 +1,121 @@
|
||||
# =============================================================================
|
||||
# GitLab CI/CD Pipeline — STEAM Security Dashboard
|
||||
# =============================================================================
|
||||
#
|
||||
# Pipeline stages:
|
||||
# 1. install — install dependencies for backend and frontend
|
||||
# 2. lint — run linters / static checks
|
||||
# 3. test — run backend (Jest) and frontend (react-scripts) tests
|
||||
# 4. build — produce the production frontend bundle
|
||||
# 5. deploy — restart services on the local machine (manual trigger)
|
||||
#
|
||||
# Executor: shell (runs directly on dashboard-dev using system Node.js)
|
||||
# Uses cache (not artifacts) for node_modules to avoid upload size limits.
|
||||
# =============================================================================
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Global cache — persists node_modules between pipeline runs on this runner
|
||||
# ---------------------------------------------------------------------------
|
||||
cache:
|
||||
key: ${CI_COMMIT_REF_SLUG}
|
||||
paths:
|
||||
- node_modules/
|
||||
- frontend/node_modules/
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stages run in order; jobs within a stage run in parallel
|
||||
# ---------------------------------------------------------------------------
|
||||
stages:
|
||||
- install
|
||||
- lint
|
||||
- test
|
||||
- build
|
||||
- deploy
|
||||
|
||||
# =============================================================================
|
||||
# STAGE 1: Install dependencies
|
||||
# =============================================================================
|
||||
|
||||
install-backend:
|
||||
stage: install
|
||||
script:
|
||||
- npm install
|
||||
|
||||
install-frontend:
|
||||
stage: install
|
||||
script:
|
||||
- cd frontend
|
||||
- npm install
|
||||
|
||||
# =============================================================================
|
||||
# STAGE 2: Lint / static analysis
|
||||
# =============================================================================
|
||||
|
||||
lint-frontend:
|
||||
stage: lint
|
||||
script:
|
||||
- cd frontend
|
||||
- npm install
|
||||
- npx eslint src/ --max-warnings 0
|
||||
allow_failure: true # non-blocking until the team cleans up existing warnings
|
||||
|
||||
# =============================================================================
|
||||
# STAGE 3: Tests
|
||||
# =============================================================================
|
||||
|
||||
test-backend:
|
||||
stage: test
|
||||
script:
|
||||
- npm install
|
||||
- npx jest --ci --forceExit --detectOpenHandles backend/__tests__/
|
||||
timeout: 5 minutes
|
||||
|
||||
test-frontend:
|
||||
stage: test
|
||||
script:
|
||||
- cd frontend
|
||||
- npm install
|
||||
- CI=true npx react-scripts test --watchAll=false --ci --forceExit
|
||||
timeout: 5 minutes
|
||||
allow_failure: true # 2 test suites have pre-existing ESM/env issues — fix separately
|
||||
|
||||
# =============================================================================
|
||||
# STAGE 4: Build the production frontend bundle
|
||||
# =============================================================================
|
||||
|
||||
build-frontend:
|
||||
stage: build
|
||||
script:
|
||||
- cd frontend
|
||||
- npm install
|
||||
- CI=false REACT_APP_API_BASE=/api REACT_APP_API_HOST="" npm run build
|
||||
artifacts:
|
||||
paths:
|
||||
- frontend/build/
|
||||
expire_in: 7 days
|
||||
|
||||
# =============================================================================
|
||||
# STAGE 5: Deploy
|
||||
# =============================================================================
|
||||
# Since the runner IS the app server (dashboard-dev), deploy just restarts
|
||||
# the services locally. No SSH needed.
|
||||
#
|
||||
# Manual trigger only, and only from the main/master branch.
|
||||
# =============================================================================
|
||||
|
||||
deploy:
|
||||
stage: deploy
|
||||
rules:
|
||||
- if: $CI_COMMIT_BRANCH == "main" || $CI_COMMIT_BRANCH == "master"
|
||||
when: manual
|
||||
environment:
|
||||
name: production
|
||||
script:
|
||||
- echo "Deploying on dashboard-dev..."
|
||||
- cd /home/cve-dashboard
|
||||
- git pull origin ${CI_COMMIT_BRANCH}
|
||||
- npm install
|
||||
- cd frontend && npm install && npm run build && cd ..
|
||||
- ./stop-servers.sh || true
|
||||
- ./start-servers.sh
|
||||
- echo "Deploy complete."
|
||||
59
CHANGELOG.md
Normal file
59
CHANGELOG.md
Normal file
@@ -0,0 +1,59 @@
|
||||
# Changelog
|
||||
|
||||
## v1.0.0 — 2026-05-01
|
||||
|
||||
First official release. Consolidates all features developed since initial commit into a stable, documented, deployment-ready package.
|
||||
|
||||
### Core Platform
|
||||
- CVE tracking with multi-vendor support, document storage, and NVD API auto-fill
|
||||
- Session-based authentication with four user groups (Admin, Standard_User, Leadership, Read_Only)
|
||||
- Full audit logging of all state-changing actions
|
||||
- Dark tactical intelligence UI theme with monospace typography
|
||||
|
||||
### Ivanti Integration
|
||||
- Live sync of open host findings from Ivanti/RiskSense API (auto-sync every 24h)
|
||||
- Reporting page with donut metric charts, advanced per-column filtering, inline editing
|
||||
- FP workflow submission directly to Ivanti API with file attachments
|
||||
- Ivanti Queue — personal staging list for batch FP, Archer, CARD, and Granite workflows
|
||||
- Queue item redirect between workflow types after completion
|
||||
- Row visibility controls with localStorage persistence
|
||||
|
||||
### Archive and Anomaly Tracking
|
||||
- Automatic detection of disappeared and returned findings across syncs
|
||||
- BU drift checker — classifies archived findings by reason (BU reassignment, severity drift, closed on platform, decommissioned)
|
||||
- Return classification — explains why findings came back (BU reassigned back, severity re-escalated, etc.)
|
||||
- Findings Trend chart with archive activity sparkline and shift reason tooltips
|
||||
- Anomaly banner for significant archive events
|
||||
|
||||
### Compliance (AEO Posture)
|
||||
- Weekly NTS_AEO xlsx upload with diff preview (new, resolved, recurring)
|
||||
- Schema drift detection with breaking/silent-miss/cosmetic classification
|
||||
- Admin config reconciliation for parser updates
|
||||
- Per-team metric health cards with grouped categories and variant pills
|
||||
- Device-level violation tracking with timestamped notes history
|
||||
- Multi-metric note grouping
|
||||
- Upload rollback support
|
||||
|
||||
### Integrations
|
||||
- Jira Data Center — create, sync, and track tickets linked to CVE/vendor pairs
|
||||
- Archer — risk acceptance exception tracking (EXC numbers)
|
||||
- Atlas InfoSec — action plan cache, bulk creation from row selection, metrics reporting
|
||||
- CARD API — Granite/CARD asset lookup for network device workflows
|
||||
- NVD API — auto-fill CVE metadata with bulk sync support
|
||||
|
||||
### Knowledge Base
|
||||
- Internal document library with inline PDF and Markdown rendering
|
||||
- Category-based browsing and search
|
||||
|
||||
### Admin
|
||||
- Full-page admin panel with user management, audit log, and system info tabs
|
||||
- Themed confirm modals replacing browser dialogs
|
||||
- User profile panel with self-service password change
|
||||
|
||||
### Infrastructure
|
||||
- Consolidated `setup.js` with complete database schema (27 tables, all indexes and triggers)
|
||||
- systemd service files for persistent deployment
|
||||
- GitLab CI/CD pipeline (install, lint, test, build, deploy)
|
||||
- GPG-signed commits for code provenance
|
||||
- Organized documentation structure (api, design, guides, security, testing, troubleshooting)
|
||||
- Migration scripts documented and retained for existing deployment upgrades
|
||||
856
README.md
856
README.md
@@ -1,820 +1,130 @@
|
||||
# STEAM Security Dashboard
|
||||
# STEAM Security Dashboard v1.0.0
|
||||
|
||||
A self-hosted vulnerability management dashboard for the NTS-AEO-STEAM and NTS-AEO-ACCESS-ENG business units. Centralises CVE tracking, Ivanti host finding triage, AEO compliance posture, FP and Archer exception workflows, and internal documentation in a single interface.
|
||||
A self-hosted vulnerability management dashboard for the NTS-AEO-STEAM and NTS-AEO-ACCESS-ENG business units. Centralises CVE tracking, Ivanti host finding triage, AEO compliance posture, FP/Archer/CARD exception workflows, and internal documentation in a single interface.
|
||||
|
||||
---
|
||||
## Quick Start
|
||||
|
||||
## Table of Contents
|
||||
### Prerequisites
|
||||
|
||||
- [Overview](#overview)
|
||||
- [Tech Stack](#tech-stack)
|
||||
- [Prerequisites](#prerequisites)
|
||||
- [Installation](#installation)
|
||||
- [Configuration](#configuration)
|
||||
- [Running the Application](#running-the-application)
|
||||
- [Features](#features)
|
||||
- [Authentication and User Roles](#authentication-and-user-roles)
|
||||
- [Home — CVE Management](#home--cve-management)
|
||||
- [Reporting — Host Findings](#reporting--host-findings)
|
||||
- [Ivanti Queue](#ivanti-queue)
|
||||
- [Compliance — AEO Posture](#compliance--aeo-posture)
|
||||
- [Knowledge Base](#knowledge-base)
|
||||
- [Exports](#exports)
|
||||
- [Archer Risk Acceptance Tickets](#archer-risk-acceptance-tickets)
|
||||
- [User Management (Admin)](#user-management-admin)
|
||||
- [Audit Log (Admin)](#audit-log-admin)
|
||||
- [Scripts](#scripts)
|
||||
- [API Reference](#api-reference)
|
||||
- [Architecture](#architecture)
|
||||
- [Database Schema](#database-schema)
|
||||
- [Security Model](#security-model)
|
||||
- [Migrations](#migrations)
|
||||
- Node.js 18+
|
||||
- Python 3 with `python3-pandas` and `python3-openpyxl` (for compliance xlsx parsing)
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
The STEAM Security Dashboard answers a common problem in vulnerability management: tracking which CVEs have been addressed, whether supporting vendor documentation exists, where each finding is in the remediation or exception workflow, and how the team's overall AEO compliance posture is trending week over week.
|
||||
|
||||
The application provides:
|
||||
|
||||
- A searchable, filterable CVE list with per-vendor tracking and document storage
|
||||
- NVD API integration to auto-populate CVE metadata
|
||||
- **Ivanti/RiskSense integration** — sync open host findings with live FP workflow tracking
|
||||
- **Reporting page** with donut charts, advanced per-column filtering, inline editing, Ivanti Queue, and CSV/XLSX export
|
||||
- **Ivanti Queue** — personal staging list for batch-processing FP, Archer, and CARD workflows
|
||||
- **AEO Compliance page** — weekly xlsx upload, diff preview, per-team metric health cards, device-level violation tracking with notes history
|
||||
- Archer risk acceptance ticket tracking (EXC numbers) linked to CVE/vendor pairs
|
||||
- A knowledge base for internal documentation and policies
|
||||
- Role-based access control with a full audit trail
|
||||
|
||||
---
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Layer | Technology |
|
||||
|---|---|
|
||||
| Backend | Node.js, Express 5 |
|
||||
| Database | SQLite3 |
|
||||
| File uploads | Multer 2 |
|
||||
| Auth | bcryptjs, cookie-based sessions |
|
||||
| Frontend | React 19, lucide-react, xlsx |
|
||||
| Compliance xlsx parsing | Python 3, pandas, openpyxl |
|
||||
| Bulk notes import | Python 3 (stdlib only) |
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Node.js 18 or later
|
||||
- npm
|
||||
- Python 3 with `python3-pandas` and `python3-openpyxl` apt packages (required for compliance xlsx parsing)
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
### 1. Clone the repository
|
||||
### Install
|
||||
|
||||
```bash
|
||||
git clone <repo-url>
|
||||
cd cve-dashboard
|
||||
```
|
||||
|
||||
### 2. Install backend dependencies
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
# Backend dependencies
|
||||
npm install
|
||||
```
|
||||
|
||||
### 3. Install frontend dependencies
|
||||
# Frontend dependencies
|
||||
cd frontend && npm install && cd ..
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
```
|
||||
|
||||
### 4. Install Python dependencies
|
||||
|
||||
Install via apt — this is the correct approach on Ubuntu/Debian and mirrors the dev server setup:
|
||||
|
||||
```bash
|
||||
# Python dependencies (Ubuntu/Debian)
|
||||
apt install -y python3-pandas python3-openpyxl
|
||||
```
|
||||
|
||||
> If apt packages are unavailable or you need a specific version, see `docs/python-venv-setup.md` for the venv fallback approach.
|
||||
|
||||
> The bulk notes import script (`import_notes_from_csv.py`) uses only Python stdlib and does **not** require these packages.
|
||||
|
||||
### 5. Initialize the database
|
||||
|
||||
Run once from the `backend/` directory to create the SQLite database, all tables, indexes, and a default admin user:
|
||||
### Configure
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
node setup.js
|
||||
cp backend/.env.example backend/.env
|
||||
# Edit backend/.env — at minimum set SESSION_SECRET:
|
||||
# openssl rand -base64 32
|
||||
```
|
||||
|
||||
This creates `backend/cve_database.db` and a default admin account:
|
||||
- Username: `admin`
|
||||
- Password: `admin123`
|
||||
See `backend/.env.example` for all available options including Ivanti API, Jira, and Atlas integration keys.
|
||||
|
||||
**Change the admin password immediately after first login.**
|
||||
|
||||
### 6. Run database migrations
|
||||
|
||||
Apply all feature migrations in order:
|
||||
### Initialize Database
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
node migrations/add_knowledge_base_table.js
|
||||
node migrations/add_archer_tickets_table.js
|
||||
node migrations/add_ivanti_sync_table.js
|
||||
node migrations/add_ivanti_findings_tables.js
|
||||
node migrations/add_ivanti_todo_queue_table.js
|
||||
node migrations/add_card_workflow_type.js
|
||||
node migrations/add_todo_queue_ip_address.js
|
||||
node migrations/add_compliance_tables.js
|
||||
node backend/setup.js
|
||||
```
|
||||
|
||||
### 7. Build the frontend
|
||||
Creates the database with the complete schema and prints a one-time admin password. Save it.
|
||||
|
||||
### Build and Run
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm run build
|
||||
# Build frontend
|
||||
cd frontend && npm run build && cd ..
|
||||
|
||||
# Start servers
|
||||
./start-servers.sh
|
||||
```
|
||||
|
||||
Or use `npm start` for the development server (see [Running the Application](#running-the-application)).
|
||||
Dashboard: http://localhost:3000 · API: http://localhost:3001
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
The application is configured via `.env` files. These files are gitignored and must be created manually per environment.
|
||||
|
||||
### Backend: `backend/.env`
|
||||
|
||||
```env
|
||||
PORT=3001
|
||||
API_HOST=localhost
|
||||
CORS_ORIGINS=http://YOUR_IP:3000
|
||||
SESSION_SECRET=change-this-to-a-long-random-string
|
||||
NODE_ENV=production
|
||||
|
||||
# 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
|
||||
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`
|
||||
|
||||
```env
|
||||
REACT_APP_API_BASE=http://YOUR_IP:3001/api
|
||||
REACT_APP_API_HOST=http://YOUR_IP:3001
|
||||
```
|
||||
|
||||
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`, fully restart the frontend process — a browser refresh alone is not sufficient.
|
||||
|
||||
---
|
||||
|
||||
## Running the Application
|
||||
|
||||
### Using the helper scripts (recommended)
|
||||
|
||||
From the project root:
|
||||
|
||||
```bash
|
||||
./start-servers.sh # Starts backend and frontend in the background
|
||||
./stop-servers.sh # Stops all servers
|
||||
```
|
||||
|
||||
The start script saves PIDs to `backend.pid` and `frontend.pid`. Logs are written to `backend/backend.log` and `frontend/frontend.log`.
|
||||
|
||||
### Running manually
|
||||
|
||||
```bash
|
||||
# Terminal 1 — backend
|
||||
cd backend
|
||||
node server.js
|
||||
|
||||
# Terminal 2 — frontend (development server)
|
||||
cd frontend
|
||||
npm start
|
||||
```
|
||||
|
||||
### Default ports
|
||||
|
||||
| Service | URL |
|
||||
|---|---|
|
||||
| Frontend | http://localhost:3000 |
|
||||
| Backend API | http://localhost:3001 |
|
||||
|
||||
---
|
||||
For persistent deployments, use the systemd services in `systemd/`. See the full manual for setup instructions.
|
||||
|
||||
## Features
|
||||
|
||||
### Authentication and User Roles
|
||||
|
||||
All routes require authentication. Three roles are supported:
|
||||
|
||||
| Role | Permissions |
|
||||
|---|---|
|
||||
| `viewer` | Read-only: CVEs, documents, findings, reports, knowledge base, Archer tickets, compliance data |
|
||||
| `editor` | All viewer permissions plus: create/update CVEs, upload documents, sync Ivanti findings, save notes and overrides, manage knowledge base, manage Archer tickets, upload compliance reports, manage Ivanti Queue |
|
||||
| `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.
|
||||
|
||||
---
|
||||
|
||||
### Home — CVE Management
|
||||
|
||||
The home page is the primary CVE research and tracking tool.
|
||||
|
||||
**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
|
||||
- The same CVE ID can be tracked across multiple vendors independently
|
||||
|
||||
**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
|
||||
- Bulk NVD Sync (editor/admin): fetch updated metadata for all CVEs in the database in one operation
|
||||
- CVSS severity cascade: v3.1 preferred, then v3.0, then v2.0
|
||||
- Rate-limit aware: respects NVD's 5 req/30s unauthenticated limit; with `NVD_API_KEY` the limit increases to 50 req/30s
|
||||
|
||||
**Archer Ticket Quick Navigation**
|
||||
- Archer EXC numbers shown on CVE rows
|
||||
- Clicking an EXC badge navigates to the Reporting page pre-filtered to findings with that EXC number
|
||||
|
||||
**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
|
||||
|
||||
---
|
||||
|
||||
### Reporting — Host Findings
|
||||
|
||||
The Reporting page is the core operational view for remediation tracking. It integrates with Ivanti/RiskSense to show all host findings for the configured business units.
|
||||
|
||||
#### Syncing Data
|
||||
|
||||
Click **Sync** (top right) to pull the latest findings from Ivanti. The sync:
|
||||
1. Fetches all open host findings matching your BU filters and severity range (8.5–9.9 VRR)
|
||||
2. Fetches the closed finding count separately
|
||||
3. Sweeps closed findings to capture FP workflow states (including Approved FPs now closed)
|
||||
4. Stores everything in the local SQLite cache
|
||||
|
||||
Findings are also auto-synced on a 24-hour schedule. The last sync timestamp is shown at the top of the page.
|
||||
|
||||
> `IVANTI_API_KEY` must be set in `backend/.env` for sync to work.
|
||||
|
||||
#### Metric Charts
|
||||
|
||||
| Chart | What it shows |
|
||||
|---|---|
|
||||
| **Open vs Closed** | Total open vs closed host findings direct from the Ivanti API |
|
||||
| **Action Coverage** | Findings by action taken: FP Request · Archer Exception · Pending. Click a segment to filter the table. |
|
||||
| **FP Finding Status** | How many *findings* are in each FP workflow state (Actionable, Requested, Reworked, Approved, Rejected, Expired) |
|
||||
| **FP Workflow Status** | How many *unique FP# ticket IDs* are in each state — one ticket can cover many findings |
|
||||
|
||||
#### Findings Table
|
||||
|
||||
Each row represents a single Ivanti host finding.
|
||||
|
||||
| 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" badge |
|
||||
| Host | Hostname — inline editable |
|
||||
| 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 |
|
||||
| Workflow | FP# ticket ID and state badge — colour-coded by urgency |
|
||||
| Last Found | Last detection date from Ivanti |
|
||||
| Notes | Free-form notes — inline editable, persists across syncs |
|
||||
|
||||
**Inline editing:** Click a Host or DNS cell to override the Ivanti value. An amber dot (●) marks overridden cells; use the revert button (↻) to restore the original. Overrides survive re-syncs.
|
||||
|
||||
**Filtering:** Click ⊙ on any column header for multi-select filtering. The `— empty —` option filters to findings with no value in that column. Multiple filters are ANDed. The Action Coverage chart also acts as a filter.
|
||||
|
||||
**Column management:** Toggle visibility and drag to reorder via the **Columns** button. Order and visibility persist to `localStorage`.
|
||||
|
||||
**Export:** Click **Export** to download the current filtered view as CSV or XLSX.
|
||||
|
||||
---
|
||||
|
||||
### Ivanti Queue
|
||||
|
||||
A personal staging list for batch-processing FP, Archer, and CARD workflows without context-switching into Ivanti mid-review.
|
||||
|
||||
**Adding items:** Check the checkbox at the far left of any finding row. A popover appears:
|
||||
- For **FP** and **Archer** items: enter the Vendor / Platform (e.g., "Juniper MX", "Cisco IOS-XE")
|
||||
- For **CARD** items: no vendor entry required — the IP address is captured automatically
|
||||
- Select the workflow type: **FP**, **Archer**, or **CARD**
|
||||
- Click **Add to Queue** — the row checkbox turns solid blue
|
||||
|
||||
**Queue panel:** Click the **Queue** button (top right of Reporting page) to open the slide-out panel:
|
||||
- **CARD** items appear at the top in their own section with the IP address displayed
|
||||
- **FP and Archer** items are grouped alphabetically by vendor below
|
||||
- Badges show workflow type: amber = FP, sky = Archer, green = CARD
|
||||
|
||||
**Working the queue:**
|
||||
- Check the green checkbox on an item to mark it complete (strikethrough at reduced opacity)
|
||||
- Delete individual items with the trash icon, or select multiple and use **Delete (N)**
|
||||
- **Clear Completed** removes all marked-complete items at once
|
||||
|
||||
Queue items are stored in the database, are **personal to your login**, and persist across sessions and page refreshes.
|
||||
|
||||
---
|
||||
|
||||
### Compliance — AEO Posture
|
||||
|
||||
The Compliance page tracks NTS-AEO team posture against the AEO compliance framework using weekly xlsx reports exported from the NTS_AEO reporting system.
|
||||
|
||||
#### Upload Workflow
|
||||
|
||||
Editors and admins can upload a new compliance report via the **Upload Report** button:
|
||||
|
||||
1. Drop or browse for the `NTS_AEO_YYYY_MM_DD.xlsx` file
|
||||
2. The report is parsed server-side and a **diff preview** is shown — new violations, resolved items, and recurring items since the last upload
|
||||
3. Click **Confirm Upload** to commit. The upload is recorded and the device table updates immediately.
|
||||
|
||||
The report date is extracted automatically from the filename.
|
||||
|
||||
#### Metric Health Cards
|
||||
|
||||
Each AEO metric (e.g., `2.3.4i`, `5.2.4`) is shown as a health card displaying:
|
||||
- Compliance percentage vs target
|
||||
- Status: Meets/Exceeds Target · Within 15% of Target · Below 15% of Target
|
||||
|
||||
Click a card to filter the device table to only devices failing that metric.
|
||||
|
||||
#### Device Table
|
||||
|
||||
Shows all devices currently failing one or more metrics (Active tab) or previously resolved (Resolved tab). Columns: Hostname, IP Address, Type, Failing Metrics, Times Seen. Click a row to open the detail panel.
|
||||
|
||||
#### Detail Panel
|
||||
|
||||
A slide-out panel for a selected device showing:
|
||||
- **Failing Metrics** — each metric with surfaced extra fields (CVEs, SLA status, due date, OS, EoL, Splunk last seen, MFA software)
|
||||
- For **2.3.x vulnerability metrics**: the `Ivanti_Vulnerability_ID` is displayed with a **View in Reporting →** button that navigates directly to the Reporting page
|
||||
- **Resolved Metrics** — previously failing metrics now back in compliance
|
||||
- **History** — how many times the device has appeared on the report and since when
|
||||
- **Notes** — timestamped notes per metric with a multi-metric selector if multiple metrics are failing
|
||||
|
||||
Notes persist across uploads and are keyed to the device hostname and metric ID.
|
||||
|
||||
#### Teams
|
||||
|
||||
Only **STEAM** and **ACCESS-ENG** teams are tracked. The team selector at the top of the page switches context between them.
|
||||
|
||||
---
|
||||
|
||||
### Knowledge Base
|
||||
|
||||
A document library for internal reference material — policies, runbooks, vendor advisories, and process guides.
|
||||
|
||||
- Upload documents with a title, optional description, and category
|
||||
- View documents inline in the browser (PDFs render in an iframe; Markdown files render 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).
|
||||
|
||||
---
|
||||
|
||||
### Exports
|
||||
|
||||
Bulk export tools for reports and data extracts.
|
||||
|
||||
---
|
||||
|
||||
### 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
|
||||
- Clicking an EXC badge on the Home page navigates to the Reporting page pre-filtered to findings with that EXC number in their notes
|
||||
|
||||
---
|
||||
|
||||
### User Management (Admin)
|
||||
|
||||
- Create users with a role assignment
|
||||
- Change username, email, password, role, or active status
|
||||
- Deactivating a user immediately invalidates all their active sessions
|
||||
- Admins cannot demote themselves or deactivate their own account
|
||||
|
||||
---
|
||||
|
||||
### Audit Log (Admin)
|
||||
|
||||
Every state-changing action is recorded with the user identity, IP address, action type, target entity, and a before/after payload. Admins can view the log filtered by user, action type, entity type, and date range. Results are paginated (25 per page).
|
||||
|
||||
---
|
||||
|
||||
## Scripts
|
||||
|
||||
### `backend/scripts/parse_compliance_xlsx.py`
|
||||
|
||||
Called automatically by the compliance upload flow. Parses the NTS_AEO xlsx report and outputs structured JSON to stdout for consumption by the Node compliance route.
|
||||
|
||||
- Reads all detail sheets; skips `Summary` and `CMDB_9box`
|
||||
- Filters to rows where `Compliant == False`
|
||||
- Extracts hostname, IP, device type, team, and metric ID per row
|
||||
- Captures all non-core columns in `extra_json` (CVEs, SLA status, OS, EoL, Splunk, MFA, Ivanti_Vulnerability_ID, etc.)
|
||||
- Parses `Summary` sheet for per-team metric health (compliance_pct, target, status)
|
||||
- Extracts report date from the filename (`NTS_AEO_YYYY_MM_DD.xlsx`)
|
||||
|
||||
**Dependencies:** `pandas>=2.0.0`, `openpyxl>=3.0.0`
|
||||
|
||||
---
|
||||
|
||||
### `backend/scripts/import_notes_from_csv.py`
|
||||
|
||||
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).
|
||||
|
||||
---
|
||||
|
||||
## API Reference
|
||||
|
||||
All endpoints are prefixed with `/api`. All endpoints except `/api/auth/login` and `/api/auth/logout` require a valid session cookie.
|
||||
|
||||
### Auth
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|---|---|---|---|
|
||||
| POST | `/api/auth/login` | Public | Log in, receive session cookie |
|
||||
| POST | `/api/auth/logout` | Public | Invalidate session |
|
||||
| GET | `/api/auth/me` | Session | Get current user info |
|
||||
| POST | `/api/auth/cleanup-sessions` | Session | Delete expired sessions |
|
||||
|
||||
### CVEs
|
||||
|
||||
| Method | Path | Role | Description |
|
||||
|---|---|---|---|
|
||||
| GET | `/api/cves` | viewer+ | List CVEs; query params: `search`, `vendor`, `severity`, `status` |
|
||||
| POST | `/api/cves` | editor+ | Create a new CVE entry |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| GET | `/api/cves/check/:cveId` | viewer+ | Quick check: existence and status of a CVE |
|
||||
| GET | `/api/cves/distinct-ids` | viewer+ | All distinct CVE IDs (used by NVD sync) |
|
||||
| GET | `/api/cves/:cveId/vendors` | viewer+ | All vendor entries for a specific CVE ID |
|
||||
|
||||
### Documents
|
||||
|
||||
| Method | Path | Role | Description |
|
||||
|---|---|---|---|
|
||||
| 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 |
|
||||
| DELETE | `/api/documents/:id` | admin | Delete a document and its file from disk |
|
||||
|
||||
### NVD
|
||||
|
||||
| Method | Path | Role | Description |
|
||||
|---|---|---|---|
|
||||
| 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 |
|
||||
|
||||
### Ivanti — 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 |
|
||||
| PUT | `/api/ivanti/findings/:findingId/override` | editor+ | Override `hostName` or `dns`; empty value clears the override |
|
||||
| PUT | `/api/ivanti/findings/:findingId/note` | viewer+ | Save or update a finding note (max 255 chars) |
|
||||
|
||||
### Ivanti — Workflows
|
||||
|
||||
| Method | Path | Role | Description |
|
||||
|---|---|---|---|
|
||||
| GET | `/api/ivanti/workflows` | viewer+ | Get cached workflow data |
|
||||
| POST | `/api/ivanti/workflows/sync` | viewer+ | Trigger an immediate workflow sync |
|
||||
|
||||
### Ivanti Queue
|
||||
|
||||
| Method | Path | Role | Description |
|
||||
|---|---|---|---|
|
||||
| GET | `/api/ivanti/queue` | viewer+ | Get all queue items for the current user |
|
||||
| POST | `/api/ivanti/queue` | editor+ | Add a finding to the queue |
|
||||
| PATCH | `/api/ivanti/queue/:id` | editor+ | Update a queue item (mark complete, edit vendor/type) |
|
||||
| DELETE | `/api/ivanti/queue/:id` | editor+ | Delete a single queue item |
|
||||
| DELETE | `/api/ivanti/queue` | editor+ | Delete multiple queue items (body: `{ ids: [...] }`) |
|
||||
|
||||
### Compliance
|
||||
|
||||
| Method | Path | Role | Description |
|
||||
|---|---|---|---|
|
||||
| POST | `/api/compliance/preview` | editor+ | Parse an xlsx upload and return diff + temp file path |
|
||||
| POST | `/api/compliance/commit` | editor+ | Commit a previewed upload to the database |
|
||||
| GET | `/api/compliance/uploads` | viewer+ | List all compliance upload records |
|
||||
| GET | `/api/compliance/summary` | viewer+ | Metric health summary; `?team=STEAM` |
|
||||
| GET | `/api/compliance/items` | viewer+ | Device list; `?team=STEAM&status=active` |
|
||||
| GET | `/api/compliance/items/:hostname` | viewer+ | Full detail for a device (metrics + notes) |
|
||||
| GET | `/api/compliance/notes/:hostname/:metricId` | viewer+ | Notes for a specific hostname/metric |
|
||||
| POST | `/api/compliance/notes` | editor+ | Add a note for a hostname/metric |
|
||||
|
||||
### Knowledge Base
|
||||
|
||||
| Method | Path | Role | Description |
|
||||
|---|---|---|---|
|
||||
| POST | `/api/knowledge-base/upload` | editor+ | Upload a new knowledge base document |
|
||||
| GET | `/api/knowledge-base` | viewer+ | List all articles |
|
||||
| GET | `/api/knowledge-base/:id` | viewer+ | Get article metadata |
|
||||
| GET | `/api/knowledge-base/:id/content` | viewer+ | Get file content for inline display |
|
||||
| GET | `/api/knowledge-base/:id/download` | viewer+ | Download the file |
|
||||
| DELETE | `/api/knowledge-base/:id` | editor+ | Delete article and file |
|
||||
|
||||
### Archer Tickets
|
||||
|
||||
| Method | Path | Role | Description |
|
||||
|---|---|---|---|
|
||||
| GET | `/api/archer-tickets` | viewer+ | List tickets; optional filters: `cve_id`, `vendor`, `status` |
|
||||
| POST | `/api/archer-tickets` | editor+ | Create a new Archer ticket |
|
||||
| PUT | `/api/archer-tickets/:id` | editor+ | Update an Archer ticket |
|
||||
| DELETE | `/api/archer-tickets/:id` | editor+ | Delete an Archer ticket |
|
||||
|
||||
### Users (Admin only)
|
||||
|
||||
| Method | Path | Role | Description |
|
||||
|---|---|---|---|
|
||||
| GET | `/api/users` | admin | List all users |
|
||||
| GET | `/api/users/:id` | admin | Get a single user |
|
||||
| POST | `/api/users` | admin | Create a user |
|
||||
| PATCH | `/api/users/:id` | admin | Update a user |
|
||||
| DELETE | `/api/users/:id` | admin | Delete a user |
|
||||
|
||||
### Audit Logs (Admin only)
|
||||
|
||||
| Method | Path | Role | Description |
|
||||
|---|---|---|---|
|
||||
| GET | `/api/audit-logs` | admin | Paginated audit log; filters: `user`, `action`, `entityType`, `startDate`, `endDate` |
|
||||
| GET | `/api/audit-logs/actions` | admin | List distinct action types for filter dropdowns |
|
||||
|
||||
### Utility
|
||||
|
||||
| Method | Path | Role | Description |
|
||||
|---|---|---|---|
|
||||
| GET | `/api/vendors` | viewer+ | List all distinct vendor names |
|
||||
| GET | `/api/stats` | viewer+ | Dashboard statistics |
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
| Feature | Description |
|
||||
|---------|-------------|
|
||||
| **CVE Management** | Track CVEs across multiple vendors with document storage and NVD auto-fill |
|
||||
| **Reporting** | Ivanti host finding triage with donut charts, inline editing, advanced filtering, CSV/XLSX export |
|
||||
| **Ivanti Queue** | Personal staging list for batch FP, Archer, CARD, and Granite workflows |
|
||||
| **FP Workflow** | Submit false positive workflows directly to Ivanti API with attachments |
|
||||
| **Compliance** | Weekly AEO xlsx upload with diff preview, drift detection, per-team metric health cards |
|
||||
| **Archive Tracking** | Automatic detection of disappeared/returned findings with BU reassignment classification |
|
||||
| **Findings Trend** | Historical open vs closed chart with archive activity sparkline and shift reason tooltips |
|
||||
| **Jira Integration** | Create, sync, and track Jira Data Center tickets linked to CVE/vendor pairs |
|
||||
| **Archer Tickets** | Track risk acceptance exceptions (EXC numbers) linked to findings |
|
||||
| **CARD API** | Granite/CARD asset lookup integration for network device workflows |
|
||||
| **Knowledge Base** | Internal document library with inline PDF/Markdown viewing |
|
||||
| **Access Control** | Four user groups (Admin, Standard_User, Leadership, Read_Only) with full audit trail |
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
cve-dashboard/
|
||||
├── start-servers.sh # Start backend + frontend in background
|
||||
├── stop-servers.sh # Stop all servers
|
||||
│
|
||||
├── backend/
|
||||
│ ├── server.js # Express app — routes, middleware, security headers
|
||||
│ ├── setup.js # One-time DB initialization and default admin creation
|
||||
│ ├── cve_database.db # SQLite database (gitignored)
|
||||
│ ├── uploads/ # File storage root (gitignored)
|
||||
│ │ ├── <CVE-ID>/<vendor>/ # CVE documents
|
||||
│ │ ├── knowledge_base/ # Knowledge base documents
|
||||
│ │ └── temp/ # Temporary upload staging
|
||||
│ ├── routes/
|
||||
│ │ ├── auth.js # Login, logout, session check
|
||||
│ │ ├── users.js # User CRUD (admin)
|
||||
│ │ ├── auditLog.js # Audit log viewer (admin)
|
||||
│ │ ├── nvdLookup.js # NVD API proxy
|
||||
│ │ ├── knowledgeBase.js # Knowledge base document management
|
||||
│ │ ├── archerTickets.js # Archer EXC ticket CRUD
|
||||
│ │ ├── ivantiWorkflows.js # Ivanti workflow batch sync and cache
|
||||
│ │ ├── ivantiFindings.js # Ivanti host findings sync, notes, overrides, FP counts
|
||||
│ │ ├── ivantiTodoQueue.js # Ivanti Queue — personal FP/Archer/CARD staging list
|
||||
│ │ └── compliance.js # AEO compliance upload, diff, device tracking, notes
|
||||
│ ├── middleware/
|
||||
│ │ └── auth.js # requireAuth and requireRole middleware
|
||||
│ ├── helpers/
|
||||
│ │ ├── auditLog.js # logAudit helper (fire-and-forget)
|
||||
│ ├── migrations/
|
||||
│ │ ├── add_knowledge_base_table.js
|
||||
│ │ ├── add_archer_tickets_table.js
|
||||
│ │ ├── add_ivanti_sync_table.js
|
||||
│ │ ├── add_ivanti_findings_tables.js
|
||||
│ │ ├── add_ivanti_todo_queue_table.js # Ivanti Queue table
|
||||
│ │ ├── add_card_workflow_type.js # CARD workflow type support
|
||||
│ │ ├── add_todo_queue_ip_address.js # IP address column on queue items
|
||||
│ │ └── add_compliance_tables.js # AEO compliance tables
|
||||
│ └── scripts/
|
||||
│ ├── parse_compliance_xlsx.py # Parses NTS_AEO xlsx compliance reports
|
||||
│ ├── import_notes_from_csv.py # Bulk-import finding notes from CSV
|
||||
│ └── requirements.txt # pandas, openpyxl
|
||||
│
|
||||
└── frontend/
|
||||
└── src/
|
||||
├── App.js # Home dashboard — CVE list, filters, modals, calendar
|
||||
├── App.css # Global styles and CSS variables
|
||||
├── contexts/
|
||||
│ └── AuthContext.js # Auth state provider (login, logout, role helpers)
|
||||
└── components/
|
||||
├── LoginForm.js # Login page
|
||||
├── NavDrawer.js # Side navigation drawer
|
||||
├── UserMenu.js # User dropdown in header
|
||||
├── CalendarWidget.js # Due-date calendar with Ivanti finding indicators
|
||||
├── UserManagement.js # Admin user management panel
|
||||
├── AuditLog.js # Admin audit log viewer
|
||||
├── NvdSyncModal.js # Bulk NVD sync dialog
|
||||
├── KnowledgeBaseModal.js # Knowledge base upload/list modal
|
||||
├── KnowledgeBaseViewer.js # Inline document viewer
|
||||
└── pages/
|
||||
├── ReportingPage.js # Host findings: charts, table, queue, export
|
||||
├── CompliancePage.js # AEO compliance: metric cards, device table
|
||||
├── ComplianceUploadModal.js # xlsx upload with diff preview
|
||||
├── ComplianceDetailPanel.js # Per-device metrics, history, notes
|
||||
├── KnowledgeBasePage.js # Knowledge base page
|
||||
└── ExportsPage.js # Exports page
|
||||
│ ├── server.js # Express API server
|
||||
│ ├── setup.js # Database initialization (run once)
|
||||
│ ├── routes/ # API route handlers
|
||||
│ ├── helpers/ # API clients (Ivanti, Jira, Atlas, CARD)
|
||||
│ ├── middleware/ # Auth middleware
|
||||
│ ├── migrations/ # Schema migrations (for existing deployments)
|
||||
│ └── scripts/ # Compliance parser, data import utilities
|
||||
├── frontend/
|
||||
│ ├── src/
|
||||
│ │ ├── App.js # Main app with routing
|
||||
│ │ ├── components/ # React components
|
||||
│ │ └── contexts/ # Auth context
|
||||
│ └── public/
|
||||
├── docs/
|
||||
│ ├── api/ # API specs (Ivanti, Atlas, Jira)
|
||||
│ ├── design/ # Design system, workflow diagrams
|
||||
│ ├── guides/ # User guides, full reference manual
|
||||
│ ├── security/ # Security audits and remediation plans
|
||||
│ ├── testing/ # Test plans and scripts
|
||||
│ └── troubleshooting/ # Investigation scripts and reports
|
||||
├── systemd/ # systemd service files
|
||||
├── start-servers.sh
|
||||
└── stop-servers.sh
|
||||
```
|
||||
|
||||
---
|
||||
## Tech Stack
|
||||
|
||||
## Database Schema
|
||||
| Layer | Technology |
|
||||
|-------|------------|
|
||||
| Backend | Node.js 18+, Express 5, SQLite3 |
|
||||
| Frontend | React 19, Recharts, Lucide React |
|
||||
| Auth | bcryptjs, cookie-based sessions, express-rate-limit |
|
||||
| Compliance | Python 3, pandas, openpyxl |
|
||||
|
||||
### Core tables (created by `setup.js`)
|
||||
## Documentation
|
||||
|
||||
**`cves`** — One row per CVE/vendor pair. `UNIQUE(cve_id, vendor)`.
|
||||
- **[Full Reference Manual](docs/guides/full-reference-manual.md)** — comprehensive feature documentation, API reference, database schema, security model, and configuration details
|
||||
- **[Migration Guide](backend/migrations/README.md)** — schema migration scripts for upgrading existing deployments
|
||||
- **[Design System](docs/design/design-system.md)** — UI component patterns and color system
|
||||
- **[Ivanti API Reference](docs/api/ivanti-api-reference.md)** — Ivanti/RiskSense API integration details
|
||||
- **[Jira API Use Cases](docs/api/jira-api-use-cases.md)** — Jira Data Center API compliance summary
|
||||
|
||||
**`documents`** — Files attached to a CVE/vendor pair. Foreign key to `cves(cve_id)`.
|
||||
## License
|
||||
|
||||
**`required_documents`** — Vendor-specific document requirements.
|
||||
|
||||
**`users`** — Accounts with roles: `admin`, `editor`, `viewer`.
|
||||
|
||||
**`sessions`** — Active sessions with 24-hour expiry.
|
||||
|
||||
**`audit_logs`** — Append-only log of all state-changing actions.
|
||||
|
||||
### Feature tables (added by migrations)
|
||||
|
||||
**`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)`.
|
||||
|
||||
**`ivanti_sync_state`** — Single-row cache for Ivanti workflow batch data.
|
||||
|
||||
**`ivanti_findings_cache`** — Single-row cache for Ivanti host findings.
|
||||
|
||||
**`ivanti_finding_notes`** — Persistent per-finding notes keyed by finding ID. Survives cache refreshes. `UNIQUE(finding_id)`.
|
||||
|
||||
**`ivanti_counts_cache`** — Single-row cache for finding metrics: open/closed counts, FP workflow state breakdowns by finding and by unique ticket ID.
|
||||
|
||||
**`ivanti_finding_overrides`** — Editor-applied overrides for `hostName` and `dns` fields. `UNIQUE(finding_id, field)`.
|
||||
|
||||
**`ivanti_todo_queue`** — Personal per-user queue of findings staged for FP, Archer, or CARD processing. Keyed by `(user_id, finding_id)`.
|
||||
|
||||
**`compliance_uploads`** — Record of each compliance xlsx upload: filename, report date, uploader, timestamp, and new/resolved/recurring counts.
|
||||
|
||||
**`compliance_items`** — One row per device/metric violation. Tracks hostname, IP, device type, team, metric ID, category, `extra_json` (all non-core xlsx columns), status (active/resolved), first seen upload, and times seen. Identity key: `(hostname, metric_id)`.
|
||||
|
||||
**`compliance_notes`** — Timestamped notes per hostname/metric. Multiple notes per combination are supported. Foreign-key linked to compliance items.
|
||||
|
||||
### View
|
||||
|
||||
**`cve_document_status`** — Aggregates document counts per CVE/vendor and derives a `compliance_status` (`Complete` when an advisory is present, otherwise `Missing Required Docs`).
|
||||
Internal use only — Charter Communications / NTS-AEO.
|
||||
|
||||
---
|
||||
|
||||
## Security Model
|
||||
|
||||
### File upload security
|
||||
|
||||
- Extension allowlist enforced by Multer; executables (`.exe`, `.js`, `.sh`, `.py`, `.bat`, etc.) are blocked
|
||||
- MIME type prefix validation in addition to extension checking
|
||||
- 10 MB per-file size limit
|
||||
- Filenames are sanitized: path separators, `..` sequences, null bytes, and non-alphanumeric characters are removed
|
||||
|
||||
### Path traversal prevention
|
||||
|
||||
- `sanitizePathSegment()` strips `/`, `\`, `..`, and null bytes from any value used in `path.join()`
|
||||
- `isPathWithinUploads()` verifies resolved paths stay within the uploads root before any file operation
|
||||
|
||||
### Input validation
|
||||
|
||||
- CVE ID must match `/^CVE-\d{4}-\d{4,}$/`
|
||||
- Severity must be one of: `Critical`, `High`, `Medium`, `Low`
|
||||
- Status must be one of: `Open`, `Addressed`, `In Progress`, `Resolved`
|
||||
- Archer EXC numbers must match `/^EXC-\d+$/`
|
||||
- Finding override field must be one of: `hostName`, `dns`
|
||||
- All database operations use prepared statements — no string interpolation in SQL
|
||||
|
||||
### Error handling
|
||||
|
||||
- 500 responses never expose internal error messages to the client
|
||||
- Full errors are logged server-side only
|
||||
- Descriptive 400/409 responses contain only application-authored validation messages
|
||||
|
||||
### Security headers
|
||||
|
||||
Applied to all responses:
|
||||
|
||||
- `X-Content-Type-Options: nosniff`
|
||||
- `X-Frame-Options: SAMEORIGIN`
|
||||
- `X-XSS-Protection: 1; mode=block`
|
||||
- `Referrer-Policy: strict-origin-when-cross-origin`
|
||||
- `Permissions-Policy: camera=(), microphone=(), geolocation=()`
|
||||
|
||||
### Session cookies
|
||||
|
||||
`httpOnly: true`, `sameSite: lax`, `secure: true` in production (`NODE_ENV=production`).
|
||||
|
||||
---
|
||||
|
||||
## Migrations
|
||||
|
||||
Migrations are standalone Node.js scripts. Run them in the listed order on a fresh install. All use `CREATE TABLE IF NOT EXISTS` or `ALTER TABLE ... ADD COLUMN IF NOT EXISTS` and are safe to re-run.
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
node migrations/add_knowledge_base_table.js
|
||||
node migrations/add_archer_tickets_table.js
|
||||
node migrations/add_ivanti_sync_table.js
|
||||
node migrations/add_ivanti_findings_tables.js
|
||||
node migrations/add_ivanti_todo_queue_table.js
|
||||
node migrations/add_card_workflow_type.js
|
||||
node migrations/add_todo_queue_ip_address.js
|
||||
node migrations/add_compliance_tables.js
|
||||
```
|
||||
|
||||
For deployments upgrading from an older schema, the following legacy migration scripts are also available in `backend/`:
|
||||
|
||||
- `migrate_multivendor.js` — Adds multi-vendor support to an older single-vendor schema
|
||||
- `migrate-audit-log.js` — Adds the `audit_logs` table to pre-auth deployments
|
||||
- `migrate-to-1.1.js` — General 1.0 → 1.1 schema update
|
||||
|
||||
> Several columns (`fp_workflow_counts_json`, `fp_id_counts_json`, `seen_count`, `summary_json`) are added automatically via idempotent `ALTER TABLE` statements each time the server starts. No manual re-run is needed.
|
||||
*Designed and built by Jordan Ramos (jordan.ramos@spectrum.com)*
|
||||
|
||||
@@ -3,6 +3,10 @@ PORT=3001
|
||||
API_HOST=localhost
|
||||
CORS_ORIGINS=http://localhost:3000
|
||||
|
||||
# Session secret — REQUIRED. Server will not start without this.
|
||||
# Generate with: openssl rand -base64 32
|
||||
SESSION_SECRET=
|
||||
|
||||
# NVD API Key (optional - increases rate limit from 5 to 50 requests per 30s)
|
||||
# Request one at https://nvd.nist.gov/developers/request-an-api-key
|
||||
NVD_API_KEY=
|
||||
@@ -15,3 +19,38 @@ IVANTI_FIRST_NAME=
|
||||
IVANTI_LAST_NAME=
|
||||
# Set to true if behind Charter's SSL inspection proxy (replicates Python verify=False)
|
||||
IVANTI_SKIP_TLS=false
|
||||
|
||||
# Atlas InfoSec API (atlas-infosec.caas.charterlab.com)
|
||||
# Service account credentials for Basic Auth — used to sync and manage action plans
|
||||
ATLAS_API_URL=
|
||||
ATLAS_API_USER=
|
||||
ATLAS_API_PASS=
|
||||
# Set to true if behind Charter's SSL inspection proxy (disables TLS cert verification)
|
||||
ATLAS_SKIP_TLS=false
|
||||
|
||||
# Jira Data Center REST API
|
||||
# VPN or Charter Network connection required for all Jira instances.
|
||||
# Service accounts use Basic Auth (JIRA_API_USER + JIRA_API_TOKEN).
|
||||
# PATs require ATLSUP approval and naming convention: Function - Team - ATLSUP-XXXXX
|
||||
# Rate limits: 1440 requests/day, burst of 60/minute.
|
||||
JIRA_BASE_URL=
|
||||
JIRA_AUTH_METHOD=basic
|
||||
# Basic Auth — service account credentials
|
||||
JIRA_API_USER=
|
||||
JIRA_API_TOKEN=
|
||||
# PAT Auth — set JIRA_AUTH_METHOD=pat to use
|
||||
JIRA_PAT=
|
||||
# Default project key and issue type for creating issues from the dashboard
|
||||
JIRA_PROJECT_KEY=
|
||||
JIRA_ISSUE_TYPE=Task
|
||||
# Set to true if behind Charter's SSL inspection proxy
|
||||
JIRA_SKIP_TLS=false
|
||||
|
||||
# CARD Asset Ownership API (card.charter.com / card.caas.stage.charterlab.com)
|
||||
# OAuth Bearer token auth — service account must be onboarded with the CARD team.
|
||||
# Tokens are acquired automatically via Basic Auth and cached for 1 hour.
|
||||
CARD_API_URL=
|
||||
CARD_API_USER=
|
||||
CARD_API_PASS=
|
||||
# Set to true if behind Charter's SSL inspection proxy
|
||||
CARD_SKIP_TLS=false
|
||||
|
||||
48
backend/__tests__/auth-password-change.property.test.js
Normal file
48
backend/__tests__/auth-password-change.property.test.js
Normal file
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Property-Based Test: Password Change Round-Trip
|
||||
*
|
||||
* Feature: user-profile, Property 3: Password change round-trip
|
||||
*
|
||||
* For any valid current password and any new password of 8+ characters,
|
||||
* after a successful change, bcrypt.compare(newPassword, storedHash) returns true.
|
||||
*
|
||||
* Validates: Requirements 2.2, 2.7
|
||||
*/
|
||||
|
||||
const fc = require('fast-check');
|
||||
const bcrypt = require('bcryptjs');
|
||||
|
||||
// bcrypt cost factor — production uses 10, but we use 4 (the minimum) here
|
||||
// to keep 100 iterations feasible within test timeouts. The round-trip property
|
||||
// holds regardless of cost factor.
|
||||
const BCRYPT_COST = 4;
|
||||
|
||||
describe('Feature: user-profile, Property 3: Password change round-trip', () => {
|
||||
it('after a password change, bcrypt.compare(newPassword, newHash) returns true', async () => {
|
||||
await fc.assert(
|
||||
fc.asyncProperty(
|
||||
// Current password: any non-empty string (length >= 1)
|
||||
fc.string({ minLength: 1, maxLength: 72 }),
|
||||
// New password: any string of length >= 8 (bcrypt max input is 72 bytes)
|
||||
fc.string({ minLength: 8, maxLength: 72 }),
|
||||
async (currentPassword, newPassword) => {
|
||||
// Step 1: Hash the current password (simulates existing stored hash)
|
||||
const currentHash = await bcrypt.hash(currentPassword, BCRYPT_COST);
|
||||
|
||||
// Step 2: Verify the current password against the stored hash
|
||||
// (simulates the bcrypt.compare check in the change-password route)
|
||||
const currentPasswordValid = await bcrypt.compare(currentPassword, currentHash);
|
||||
expect(currentPasswordValid).toBe(true);
|
||||
|
||||
// Step 3: Hash the new password (simulates bcrypt.hash(newPassword, 10) in the route)
|
||||
const newHash = await bcrypt.hash(newPassword, BCRYPT_COST);
|
||||
|
||||
// Step 4: Verify the new password matches the new hash (round-trip property)
|
||||
const newPasswordValid = await bcrypt.compare(newPassword, newHash);
|
||||
expect(newPasswordValid).toBe(true);
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
}, 120000); // 2-minute timeout for 100 bcrypt iterations
|
||||
});
|
||||
84
backend/__tests__/auth-profile-completeness.property.test.js
Normal file
84
backend/__tests__/auth-profile-completeness.property.test.js
Normal file
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Property-Based Test: Profile API Returns Complete User Data Matching Database
|
||||
*
|
||||
* Feature: user-profile, Property 2: Profile API returns complete user data matching database
|
||||
*
|
||||
* For any active user record, the profile route's mapping logic produces a
|
||||
* response object with all 6 required fields (id, username, email, group,
|
||||
* created_at, last_login) and each value matches the corresponding column
|
||||
* in the users table. The `group` field maps from the `user_group` column.
|
||||
*
|
||||
* Validates: Requirements 4.1
|
||||
*/
|
||||
|
||||
const fc = require('fast-check');
|
||||
|
||||
/**
|
||||
* Simulates the exact mapping logic from GET /api/auth/profile in routes/auth.js:
|
||||
*
|
||||
* res.json({
|
||||
* id: user.id,
|
||||
* username: user.username,
|
||||
* email: user.email,
|
||||
* group: user.user_group,
|
||||
* created_at: user.created_at,
|
||||
* last_login: user.last_login
|
||||
* });
|
||||
*/
|
||||
function mapUserRowToProfileResponse(user) {
|
||||
return {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
group: user.user_group,
|
||||
created_at: user.created_at,
|
||||
last_login: user.last_login
|
||||
};
|
||||
}
|
||||
|
||||
describe('Feature: user-profile, Property 2: Profile API returns complete user data matching database', () => {
|
||||
it('profile response contains all 6 required fields matching the database row', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
// Generate arbitrary user rows matching the users table schema
|
||||
fc.record({
|
||||
id: fc.integer({ min: 1, max: 1000000 }),
|
||||
username: fc.string({ minLength: 1, maxLength: 50 }),
|
||||
email: fc.string({ minLength: 3, maxLength: 255 }),
|
||||
user_group: fc.constantFrom('Admin', 'Standard_User', 'Read_Only'),
|
||||
created_at: fc.integer({ min: 1577836800000, max: 1924991999000 })
|
||||
.map(ts => new Date(ts).toISOString().replace('T', ' ').slice(0, 19)),
|
||||
last_login: fc.oneof(
|
||||
fc.integer({ min: 1577836800000, max: 1924991999000 })
|
||||
.map(ts => new Date(ts).toISOString().replace('T', ' ').slice(0, 19)),
|
||||
fc.constant(null)
|
||||
),
|
||||
is_active: fc.constant(1)
|
||||
}),
|
||||
(userRow) => {
|
||||
const response = mapUserRowToProfileResponse(userRow);
|
||||
|
||||
// Assert all 6 required fields are present
|
||||
expect(response).toHaveProperty('id');
|
||||
expect(response).toHaveProperty('username');
|
||||
expect(response).toHaveProperty('email');
|
||||
expect(response).toHaveProperty('group');
|
||||
expect(response).toHaveProperty('created_at');
|
||||
expect(response).toHaveProperty('last_login');
|
||||
|
||||
// Assert each value matches the corresponding database column
|
||||
expect(response.id).toBe(userRow.id);
|
||||
expect(response.username).toBe(userRow.username);
|
||||
expect(response.email).toBe(userRow.email);
|
||||
expect(response.group).toBe(userRow.user_group); // group maps from user_group
|
||||
expect(response.created_at).toBe(userRow.created_at);
|
||||
expect(response.last_login).toBe(userRow.last_login);
|
||||
|
||||
// Assert exactly 6 keys — no extra fields leaked
|
||||
expect(Object.keys(response)).toHaveLength(6);
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
});
|
||||
39
backend/__tests__/auth-short-password.property.test.js
Normal file
39
backend/__tests__/auth-short-password.property.test.js
Normal file
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Property-Based Test: Short Passwords Are Rejected (Server-Side)
|
||||
*
|
||||
* Feature: user-profile, Property 6 (server-side): Short passwords are rejected
|
||||
*
|
||||
* For any string of length 0 to 7, the server-side validation logic
|
||||
* (newPassword.length < 8) correctly identifies them as too short,
|
||||
* meaning the password change would return 400 and the stored hash
|
||||
* would remain unchanged.
|
||||
*
|
||||
* Validates: Requirements 2.5, 5.4
|
||||
*/
|
||||
|
||||
const fc = require('fast-check');
|
||||
|
||||
describe('Feature: user-profile, Property 6 (server-side): Short passwords are rejected', () => {
|
||||
it('any string of length 0–7 is rejected by the server-side length validation', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
// Generate arbitrary strings of length 0 to 7
|
||||
fc.string({ minLength: 0, maxLength: 7 }),
|
||||
(shortPassword) => {
|
||||
// This is the exact validation check from POST /api/auth/change-password:
|
||||
// if (newPassword.length < 8) return res.status(400).json({ error: '...' })
|
||||
const wouldBeRejected = shortPassword.length < 8;
|
||||
|
||||
// Every generated string must be rejected by the validation
|
||||
expect(wouldBeRejected).toBe(true);
|
||||
|
||||
// The stored hash remains unchanged because the route returns
|
||||
// early before reaching the bcrypt.hash / UPDATE query.
|
||||
// This is a structural guarantee — the early return prevents
|
||||
// any mutation of the password_hash column.
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
});
|
||||
53
backend/__tests__/auth-wrong-password.property.test.js
Normal file
53
backend/__tests__/auth-wrong-password.property.test.js
Normal file
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Property-Based Test: Incorrect Current Password Is Always Rejected
|
||||
*
|
||||
* Feature: user-profile, Property 4: Incorrect current password is always rejected
|
||||
*
|
||||
* For any password string that does not match the user's current password,
|
||||
* the endpoint returns 401 and the stored hash remains unchanged.
|
||||
*
|
||||
* Validates: Requirements 2.3
|
||||
*/
|
||||
|
||||
const fc = require('fast-check');
|
||||
const bcrypt = require('bcryptjs');
|
||||
|
||||
// bcrypt cost factor — production uses 10, but we use 4 (the minimum) here
|
||||
// to keep 100 iterations feasible within test timeouts. The rejection property
|
||||
// holds regardless of cost factor.
|
||||
const BCRYPT_COST = 4;
|
||||
|
||||
describe('Feature: user-profile, Property 4: Incorrect current password is always rejected', () => {
|
||||
it('bcrypt.compare rejects any wrong password and the stored hash remains unchanged', async () => {
|
||||
await fc.assert(
|
||||
fc.asyncProperty(
|
||||
// Current password: any non-empty string (bcrypt max input is 72 bytes)
|
||||
fc.string({ minLength: 1, maxLength: 72 }),
|
||||
// Wrong password: any non-empty string (will be filtered to differ from current)
|
||||
fc.string({ minLength: 1, maxLength: 72 }),
|
||||
async (currentPassword, wrongPassword) => {
|
||||
// Ensure the wrong password is always different from the current password
|
||||
fc.pre(wrongPassword !== currentPassword);
|
||||
|
||||
// Step 1: Hash the current password (simulates existing stored hash)
|
||||
const currentHash = await bcrypt.hash(currentPassword, BCRYPT_COST);
|
||||
|
||||
// Capture the hash before the failed attempt
|
||||
const hashBefore = currentHash;
|
||||
|
||||
// Step 2: Attempt to verify the wrong password against the stored hash
|
||||
// (simulates the bcrypt.compare check in the change-password route)
|
||||
const isValid = await bcrypt.compare(wrongPassword, currentHash);
|
||||
|
||||
// The wrong password must always be rejected
|
||||
expect(isValid).toBe(false);
|
||||
|
||||
// Step 3: The stored hash remains unchanged after the failed attempt
|
||||
// (no mutation should occur on rejection)
|
||||
expect(currentHash).toBe(hashBefore);
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
}, 120000); // 2-minute timeout for 100 bcrypt iterations
|
||||
});
|
||||
BIN
backend/cve_database.db.backupNVD
Normal file
BIN
backend/cve_database.db.backupNVD
Normal file
Binary file not shown.
104
backend/helpers/atlasApi.js
Normal file
104
backend/helpers/atlasApi.js
Normal file
@@ -0,0 +1,104 @@
|
||||
// Shared Atlas InfoSec API helpers
|
||||
// Centralizes HTTP calls so the atlas router uses a single implementation.
|
||||
// Follows the same promise-based pattern as ivantiApi.js.
|
||||
|
||||
const https = require('https');
|
||||
const http = require('http');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Configuration — read from process.env at module load
|
||||
// ---------------------------------------------------------------------------
|
||||
const ATLAS_API_URL = process.env.ATLAS_API_URL || '';
|
||||
const ATLAS_API_USER = process.env.ATLAS_API_USER || '';
|
||||
const ATLAS_API_PASS = process.env.ATLAS_API_PASS || '';
|
||||
const ATLAS_SKIP_TLS = process.env.ATLAS_SKIP_TLS === 'true';
|
||||
|
||||
const requiredVars = ['ATLAS_API_URL', 'ATLAS_API_USER', 'ATLAS_API_PASS'];
|
||||
const missingVars = requiredVars.filter((v) => !process.env[v]);
|
||||
if (missingVars.length > 0) {
|
||||
console.warn(`[atlas-api] WARNING: Missing required environment variables: ${missingVars.join(', ')}. Atlas API calls will fail.`);
|
||||
}
|
||||
|
||||
const isConfigured = missingVars.length === 0;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Generic request — supports GET, PUT, PATCH, POST
|
||||
// ---------------------------------------------------------------------------
|
||||
function atlasRequest(method, urlPath, body, options) {
|
||||
const timeout = (options && options.timeout) || 15000;
|
||||
const authString = Buffer.from(ATLAS_API_USER + ':' + ATLAS_API_PASS).toString('base64');
|
||||
const fullUrl = new URL(ATLAS_API_URL + urlPath);
|
||||
const isHttps = fullUrl.protocol === 'https:';
|
||||
const transport = isHttps ? https : http;
|
||||
|
||||
const headers = {
|
||||
'accept': 'application/json',
|
||||
'authorization': 'Basic ' + authString
|
||||
};
|
||||
|
||||
let bodyStr = null;
|
||||
if (body !== null && body !== undefined) {
|
||||
bodyStr = JSON.stringify(body);
|
||||
headers['content-type'] = 'application/json';
|
||||
headers['content-length'] = Buffer.byteLength(bodyStr);
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const reqOptions = {
|
||||
hostname: fullUrl.hostname,
|
||||
port: fullUrl.port || (isHttps ? 443 : 80),
|
||||
path: fullUrl.pathname + fullUrl.search,
|
||||
method: method,
|
||||
headers: headers,
|
||||
timeout: timeout
|
||||
};
|
||||
|
||||
if (isHttps) {
|
||||
reqOptions.rejectUnauthorized = !ATLAS_SKIP_TLS;
|
||||
}
|
||||
|
||||
const req = transport.request(reqOptions, (res) => {
|
||||
let data = '';
|
||||
res.on('data', (chunk) => { data += chunk; });
|
||||
res.on('end', () => resolve({ status: res.statusCode, body: data }));
|
||||
});
|
||||
|
||||
req.on('timeout', () => req.destroy(new Error(method + ' ' + urlPath + ' timed out')));
|
||||
req.on('error', (err) => {
|
||||
reject(new Error(method + ' ' + urlPath + ' failed: ' + err.message));
|
||||
});
|
||||
|
||||
if (bodyStr) {
|
||||
req.write(bodyStr);
|
||||
}
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Convenience wrappers
|
||||
// ---------------------------------------------------------------------------
|
||||
function atlasGet(urlPath, options) {
|
||||
return atlasRequest('GET', urlPath, null, options);
|
||||
}
|
||||
|
||||
function atlasPut(urlPath, body, options) {
|
||||
return atlasRequest('PUT', urlPath, body, options);
|
||||
}
|
||||
|
||||
function atlasPatch(urlPath, body, options) {
|
||||
return atlasRequest('PATCH', urlPath, body, options);
|
||||
}
|
||||
|
||||
function atlasPost(urlPath, body, options) {
|
||||
return atlasRequest('POST', urlPath, body, options);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
isConfigured,
|
||||
atlasRequest,
|
||||
atlasGet,
|
||||
atlasPut,
|
||||
atlasPatch,
|
||||
atlasPost
|
||||
};
|
||||
305
backend/helpers/cardApi.js
Normal file
305
backend/helpers/cardApi.js
Normal file
@@ -0,0 +1,305 @@
|
||||
// Shared CARD API helpers
|
||||
// Centralizes HTTP calls for the CARD asset ownership API.
|
||||
// Follows the same promise-based pattern as atlasApi.js, with the addition
|
||||
// of OAuth Bearer token management (auto-acquire, cache, refresh, 401 retry).
|
||||
//
|
||||
// CARD API versioning:
|
||||
// - Read endpoints (GET): /api/v1/...
|
||||
// - Mutation endpoints (POST): /api/v2/...
|
||||
|
||||
const https = require('https');
|
||||
const http = require('http');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Configuration — read from process.env at module load
|
||||
// ---------------------------------------------------------------------------
|
||||
const CARD_API_URL = process.env.CARD_API_URL || '';
|
||||
const CARD_API_USER = process.env.CARD_API_USER || '';
|
||||
const CARD_API_PASS = process.env.CARD_API_PASS || '';
|
||||
const CARD_SKIP_TLS = process.env.CARD_SKIP_TLS === 'true';
|
||||
|
||||
const requiredVars = ['CARD_API_URL', 'CARD_API_USER', 'CARD_API_PASS'];
|
||||
const missingVars = requiredVars.filter((v) => !process.env[v]);
|
||||
if (missingVars.length > 0) {
|
||||
console.warn(`[card-api] WARNING: Missing required environment variables: ${missingVars.join(', ')}. CARD API calls will fail.`);
|
||||
}
|
||||
|
||||
const isConfigured = missingVars.length === 0;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Token Manager — OAuth Bearer token with 1-hour TTL
|
||||
// ---------------------------------------------------------------------------
|
||||
let cachedToken = null; // { token: string, expiresAt: number (epoch ms) }
|
||||
|
||||
function tokenIsValid() {
|
||||
if (!cachedToken) return false;
|
||||
// Refresh if within 60 seconds of expiry
|
||||
return cachedToken.expiresAt - Date.now() > 60_000;
|
||||
}
|
||||
|
||||
function invalidateToken() {
|
||||
cachedToken = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Acquire a new Bearer token from CARD /api/v1/auth/get_token using Basic Auth.
|
||||
* Caches the token in memory with a 1-hour TTL.
|
||||
*/
|
||||
function acquireToken(timeout) {
|
||||
const authString = Buffer.from(CARD_API_USER + ':' + CARD_API_PASS).toString('base64');
|
||||
const fullUrl = new URL(CARD_API_URL + '/api/v1/auth/get_token');
|
||||
const isHttps = fullUrl.protocol === 'https:';
|
||||
const transport = isHttps ? https : http;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const reqOptions = {
|
||||
hostname: fullUrl.hostname,
|
||||
port: fullUrl.port || (isHttps ? 443 : 80),
|
||||
path: fullUrl.pathname + fullUrl.search,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'accept': 'application/json',
|
||||
'authorization': 'Basic ' + authString,
|
||||
'content-length': '0',
|
||||
},
|
||||
timeout: timeout || 15000,
|
||||
};
|
||||
|
||||
if (isHttps) {
|
||||
reqOptions.rejectUnauthorized = !CARD_SKIP_TLS;
|
||||
}
|
||||
|
||||
const req = transport.request(reqOptions, (res) => {
|
||||
let data = '';
|
||||
res.on('data', (chunk) => { data += chunk; });
|
||||
res.on('end', () => {
|
||||
if (res.statusCode < 200 || res.statusCode >= 300) {
|
||||
return reject(new Error(
|
||||
`[card-api] Token acquisition failed with HTTP ${res.statusCode}: ${data.substring(0, 500)}`
|
||||
));
|
||||
}
|
||||
|
||||
// The CARD API returns the token as a JSON string or object.
|
||||
// Try to parse; fall back to raw body as the token string.
|
||||
let token;
|
||||
try {
|
||||
const parsed = JSON.parse(data);
|
||||
token = typeof parsed === 'string' ? parsed
|
||||
: parsed.token || parsed.access_token || data.trim();
|
||||
} catch (_) {
|
||||
// Response may be a plain token string (unquoted)
|
||||
token = data.trim();
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
return reject(new Error('[card-api] Token parse failure: empty token in response body.'));
|
||||
}
|
||||
|
||||
cachedToken = {
|
||||
token,
|
||||
expiresAt: Date.now() + 60 * 60 * 1000, // 1-hour TTL
|
||||
};
|
||||
resolve(cachedToken.token);
|
||||
});
|
||||
});
|
||||
|
||||
req.on('timeout', () => req.destroy(new Error('GET /api/v1/auth/get_token timed out')));
|
||||
req.on('error', (err) => {
|
||||
reject(new Error(`[card-api] GET /api/v1/auth/get_token failed: ${err.message}`));
|
||||
});
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure we have a valid Bearer token, acquiring or refreshing as needed.
|
||||
*/
|
||||
async function ensureToken(timeout) {
|
||||
if (tokenIsValid()) return cachedToken.token;
|
||||
return acquireToken(timeout);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Generic request — supports GET and POST with Bearer auth + 401 retry
|
||||
// ---------------------------------------------------------------------------
|
||||
async function cardRequest(method, urlPath, body, options) {
|
||||
const timeout = (options && options.timeout) || 15000;
|
||||
const skipAuth = (options && options.skipAuth) || false;
|
||||
|
||||
async function doRequest(bearerToken) {
|
||||
const fullUrl = new URL(CARD_API_URL + urlPath);
|
||||
const isHttps = fullUrl.protocol === 'https:';
|
||||
const transport = isHttps ? https : http;
|
||||
|
||||
const headers = { 'accept': 'application/json' };
|
||||
|
||||
if (bearerToken) {
|
||||
headers['authorization'] = 'Bearer ' + bearerToken;
|
||||
}
|
||||
|
||||
let bodyStr = null;
|
||||
if (body !== null && body !== undefined) {
|
||||
bodyStr = JSON.stringify(body);
|
||||
headers['content-type'] = 'application/json';
|
||||
headers['content-length'] = Buffer.byteLength(bodyStr);
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const reqOptions = {
|
||||
hostname: fullUrl.hostname,
|
||||
port: fullUrl.port || (isHttps ? 443 : 80),
|
||||
path: fullUrl.pathname + fullUrl.search,
|
||||
method,
|
||||
headers,
|
||||
timeout,
|
||||
};
|
||||
|
||||
if (isHttps) {
|
||||
reqOptions.rejectUnauthorized = !CARD_SKIP_TLS;
|
||||
}
|
||||
|
||||
const req = transport.request(reqOptions, (res) => {
|
||||
let data = '';
|
||||
res.on('data', (chunk) => { data += chunk; });
|
||||
res.on('end', () => resolve({ status: res.statusCode, body: data }));
|
||||
});
|
||||
|
||||
req.on('timeout', () => req.destroy(new Error(`${method} ${urlPath} timed out`)));
|
||||
req.on('error', (err) => {
|
||||
reject(new Error(`[card-api] ${method} ${urlPath} failed: ${err.message}`));
|
||||
});
|
||||
|
||||
if (bodyStr) req.write(bodyStr);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
// Skip auth for the token endpoint itself
|
||||
if (skipAuth) {
|
||||
return doRequest(null);
|
||||
}
|
||||
|
||||
// Normal flow: ensure token → request → retry once on 401
|
||||
let token = await ensureToken(timeout);
|
||||
let result = await doRequest(token);
|
||||
|
||||
if (result.status === 401) {
|
||||
// Invalidate and retry exactly once
|
||||
invalidateToken();
|
||||
token = await ensureToken(timeout);
|
||||
result = await doRequest(token);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Convenience wrappers
|
||||
// ---------------------------------------------------------------------------
|
||||
function cardGet(urlPath, options) {
|
||||
return cardRequest('GET', urlPath, null, options);
|
||||
}
|
||||
|
||||
function cardPost(urlPath, body, options) {
|
||||
return cardRequest('POST', urlPath, body, options);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// High-level helpers used by the UAT test and routes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Test connection by acquiring a token. Returns { ok, token } or { ok, error }.
|
||||
*/
|
||||
async function testConnection() {
|
||||
try {
|
||||
const token = await acquireToken();
|
||||
return { ok: true, token: token.substring(0, 12) + '...' };
|
||||
} catch (err) {
|
||||
return { ok: false, error: err.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/teams — list all CARD teams.
|
||||
*/
|
||||
async function getTeams() {
|
||||
const res = await cardGet('/api/v1/teams');
|
||||
return { status: res.status, body: res.body, ok: res.status >= 200 && res.status < 300 };
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/team/{teamName}/assets — list assets for a team.
|
||||
*/
|
||||
async function getTeamAssets(teamName, { disposition, page, pageSize } = {}) {
|
||||
const params = new URLSearchParams();
|
||||
if (disposition) params.set('disposition', disposition);
|
||||
if (page) params.set('page', String(page));
|
||||
params.set('page_size', String(pageSize || 50));
|
||||
|
||||
const qs = params.toString();
|
||||
const res = await cardGet(`/api/v1/team/${encodeURIComponent(teamName)}/assets${qs ? '?' + qs : ''}`);
|
||||
return { status: res.status, body: res.body, ok: res.status >= 200 && res.status < 300 };
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/owner/{assetId} — get owner record including update_token.
|
||||
*/
|
||||
async function getOwner(assetId) {
|
||||
const res = await cardGet(`/api/v1/owner/${encodeURIComponent(assetId)}`);
|
||||
return { status: res.status, body: res.body, ok: res.status >= 200 && res.status < 300 };
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v2/owner/{assetId}/confirm — confirm asset to a team.
|
||||
*/
|
||||
async function confirmAsset(assetId, teamName, updateToken, comment) {
|
||||
const params = new URLSearchParams({ update_token: updateToken });
|
||||
if (comment) params.set('comment', comment);
|
||||
const res = await cardPost(
|
||||
`/api/v2/owner/${encodeURIComponent(assetId)}/confirm?${params.toString()}`,
|
||||
{ name: teamName }
|
||||
);
|
||||
return { status: res.status, body: res.body, ok: res.status >= 200 && res.status < 300 };
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v2/owner/{assetId}/decline — decline asset from a team.
|
||||
*/
|
||||
async function declineAsset(assetId, teamName, updateToken, comment) {
|
||||
const params = new URLSearchParams({ update_token: updateToken });
|
||||
if (comment) params.set('comment', comment);
|
||||
const res = await cardPost(
|
||||
`/api/v2/owner/${encodeURIComponent(assetId)}/decline?${params.toString()}`,
|
||||
{ name: teamName }
|
||||
);
|
||||
return { status: res.status, body: res.body, ok: res.status >= 200 && res.status < 300 };
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v2/owner/{assetId}/{fromTeam}/redirect — redirect asset between teams.
|
||||
*/
|
||||
async function redirectAsset(assetId, fromTeam, toTeam, updateToken) {
|
||||
const params = new URLSearchParams({ update_token: updateToken });
|
||||
const res = await cardPost(
|
||||
`/api/v2/owner/${encodeURIComponent(assetId)}/${encodeURIComponent(fromTeam)}/redirect?${params.toString()}`,
|
||||
{ name: toTeam }
|
||||
);
|
||||
return { status: res.status, body: res.body, ok: res.status >= 200 && res.status < 300 };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
isConfigured,
|
||||
missingVars,
|
||||
cardRequest,
|
||||
cardGet,
|
||||
cardPost,
|
||||
testConnection,
|
||||
getTeams,
|
||||
getTeamAssets,
|
||||
getOwner,
|
||||
confirmAsset,
|
||||
declineAsset,
|
||||
redirectAsset,
|
||||
invalidateToken,
|
||||
};
|
||||
332
backend/helpers/driftChecker.js
Normal file
332
backend/helpers/driftChecker.js
Normal file
@@ -0,0 +1,332 @@
|
||||
// Drift Checker — compares xlsx schema against parser config to detect structural drift
|
||||
// Returns categorised findings: breaking, silent_miss, cosmetic
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
/**
|
||||
* Load and validate the compliance parser configuration file.
|
||||
* @param {string} configPath — absolute or relative path to compliance_config.json
|
||||
* @returns {object} parsed config with metric_categories, core_cols, skip_sheets
|
||||
* @throws {Error} descriptive error if file missing, invalid JSON, or missing required keys
|
||||
*/
|
||||
function loadConfig(configPath) {
|
||||
let raw;
|
||||
try {
|
||||
raw = fs.readFileSync(configPath, 'utf8');
|
||||
} catch (err) {
|
||||
if (err.code === 'ENOENT') {
|
||||
throw new Error(`Configuration file not found: ${configPath}`);
|
||||
}
|
||||
throw new Error(`Failed to read configuration file: ${err.message}`);
|
||||
}
|
||||
|
||||
let config;
|
||||
try {
|
||||
config = JSON.parse(raw);
|
||||
} catch (err) {
|
||||
throw new Error(`Configuration file contains invalid JSON: ${err.message}`);
|
||||
}
|
||||
|
||||
if (!config.metric_categories || typeof config.metric_categories !== 'object' || Array.isArray(config.metric_categories)) {
|
||||
throw new Error('Configuration file is missing required key "metric_categories" (must be an object)');
|
||||
}
|
||||
if (!Array.isArray(config.core_cols)) {
|
||||
throw new Error('Configuration file is missing required key "core_cols" (must be an array)');
|
||||
}
|
||||
if (!Array.isArray(config.skip_sheets)) {
|
||||
throw new Error('Configuration file is missing required key "skip_sheets" (must be an array)');
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare an xlsx schema against the parser config and produce a drift report.
|
||||
* @param {object} schema — output of extract_xlsx_schema.py: { sheets: [{ name, columns, metric_values? }] }
|
||||
* @param {object} config — parsed compliance_config.json: { metric_categories, core_cols, skip_sheets }
|
||||
* @returns {{ breaking: Array, silent_miss: Array, cosmetic: Array }}
|
||||
*/
|
||||
function compareSchemaToDrift(schema, config) {
|
||||
const breaking = [];
|
||||
const silent_miss = [];
|
||||
const cosmetic = [];
|
||||
|
||||
const metricCategoryKeys = new Set(Object.keys(config.metric_categories));
|
||||
const coreCols = new Set(config.core_cols);
|
||||
const skipSheets = new Set(config.skip_sheets);
|
||||
|
||||
// Build lookup of xlsx sheet names and find the Summary sheet
|
||||
const xlsxSheetNames = new Set();
|
||||
let summarySheet = null;
|
||||
|
||||
for (const sheet of schema.sheets) {
|
||||
xlsxSheetNames.add(sheet.name);
|
||||
if (sheet.name === 'Summary') {
|
||||
summarySheet = sheet;
|
||||
}
|
||||
}
|
||||
|
||||
// Identify detail sheets: present in xlsx AND not in skip_sheets
|
||||
const detailSheets = schema.sheets.filter(s => !skipSheets.has(s.name));
|
||||
|
||||
// Build set of metric values from the Summary sheet (used by multiple rules)
|
||||
const summaryMetrics = new Set(
|
||||
(summarySheet && Array.isArray(summarySheet.metric_values)) ? summarySheet.metric_values : []
|
||||
);
|
||||
|
||||
// --- Breaking rules ---
|
||||
|
||||
// Missing core column: a detail sheet is missing a column from core_cols.
|
||||
// Collect per-column stats first, then classify: if a column is missing from
|
||||
// ALL detail sheets it's breaking. If missing from only some (e.g. 5.8.1 uses
|
||||
// CMDB columns), it's cosmetic — the parser handles it via extra_json.
|
||||
const coreColMissingMap = {}; // col -> [sheet names missing it]
|
||||
for (const sheet of detailSheets) {
|
||||
const sheetCols = new Set(sheet.columns || []);
|
||||
for (const coreCol of config.core_cols) {
|
||||
if (!sheetCols.has(coreCol)) {
|
||||
if (!coreColMissingMap[coreCol]) coreColMissingMap[coreCol] = [];
|
||||
coreColMissingMap[coreCol].push(sheet.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const coreCol of Object.keys(coreColMissingMap)) {
|
||||
const missingSheets = coreColMissingMap[coreCol];
|
||||
if (detailSheets.length > 0 && missingSheets.length >= detailSheets.length) {
|
||||
// Missing from ALL detail sheets — genuinely breaking
|
||||
breaking.push({
|
||||
severity: 'breaking',
|
||||
message: `Core column "${coreCol}" is missing from all ${detailSheets.length} detail sheet(s)`,
|
||||
value: coreCol,
|
||||
sheet: null
|
||||
});
|
||||
} else {
|
||||
// Missing from some sheets — structural difference, not drift
|
||||
cosmetic.push({
|
||||
severity: 'cosmetic',
|
||||
message: `Core column "${coreCol}" is missing from ${missingSheets.length} of ${detailSheets.length} detail sheet(s): ${missingSheets.join(', ')}`,
|
||||
value: coreCol,
|
||||
sheet: null
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Missing detail sheet: a sheet in metric_categories (not in skip_sheets) is absent from xlsx.
|
||||
// If the metric still appears in the Summary's metric_values, it's tracked but has zero
|
||||
// violations this week — downgrade to cosmetic instead of breaking.
|
||||
for (const metricKey of metricCategoryKeys) {
|
||||
if (!skipSheets.has(metricKey) && !xlsxSheetNames.has(metricKey)) {
|
||||
if (summaryMetrics.has(metricKey)) {
|
||||
cosmetic.push({
|
||||
severity: 'cosmetic',
|
||||
message: `Metric "${metricKey}" has no detail sheet this week — still tracked in Summary (zero violations)`,
|
||||
value: metricKey,
|
||||
sheet: null
|
||||
});
|
||||
} else {
|
||||
breaking.push({
|
||||
severity: 'breaking',
|
||||
message: `Expected detail sheet "${metricKey}" (metric category) is missing from the workbook`,
|
||||
value: metricKey,
|
||||
sheet: null
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Silent-miss rules ---
|
||||
|
||||
// Unknown metric value: a metric value in Summary is not a key in metric_categories
|
||||
if (summarySheet && Array.isArray(summarySheet.metric_values)) {
|
||||
for (const metricVal of summarySheet.metric_values) {
|
||||
if (!metricCategoryKeys.has(metricVal)) {
|
||||
silent_miss.push({
|
||||
severity: 'silent_miss',
|
||||
message: `Unknown metric "${metricVal}" in Summary — not in metric_categories`,
|
||||
value: metricVal,
|
||||
sheet: 'Summary'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Unknown sheet: an xlsx sheet not in skip_sheets and not in metric_categories
|
||||
for (const sheet of schema.sheets) {
|
||||
if (!skipSheets.has(sheet.name) && !metricCategoryKeys.has(sheet.name)) {
|
||||
silent_miss.push({
|
||||
severity: 'silent_miss',
|
||||
message: `Unknown sheet "${sheet.name}" — not in skip_sheets or metric_categories`,
|
||||
value: sheet.name,
|
||||
sheet: sheet.name
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// --- Cosmetic rules ---
|
||||
|
||||
// New column in detail sheet: a detail sheet has columns not in core_cols
|
||||
for (const sheet of detailSheets) {
|
||||
for (const col of (sheet.columns || [])) {
|
||||
if (!coreCols.has(col)) {
|
||||
cosmetic.push({
|
||||
severity: 'cosmetic',
|
||||
message: `New column "${col}" in sheet "${sheet.name}" — will be captured in extra_json`,
|
||||
value: col,
|
||||
sheet: sheet.name
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Stale metric category: a key in metric_categories not in Summary metric values
|
||||
for (const metricKey of metricCategoryKeys) {
|
||||
if (!summaryMetrics.has(metricKey)) {
|
||||
cosmetic.push({
|
||||
severity: 'cosmetic',
|
||||
message: `Stale metric category "${metricKey}" — not found in Summary sheet metric values`,
|
||||
value: metricKey,
|
||||
sheet: null
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { breaking, silent_miss, cosmetic };
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconcile the parser config to resolve breaking drift findings.
|
||||
*
|
||||
* Breaking — "missing detail sheet":
|
||||
* A metric_categories key has no matching xlsx sheet. But if the metric
|
||||
* still appears in the Summary sheet's metric_values, it's a legitimate
|
||||
* tracked metric that simply doesn't have violations this week — keep it.
|
||||
* Only remove metrics absent from BOTH the xlsx sheets AND the Summary.
|
||||
*
|
||||
* Breaking — "missing core column":
|
||||
* A core_cols entry is absent from one or more detail sheets. Only remove
|
||||
* if the column is missing from ALL detail sheets (some sheets like 5.8.1
|
||||
* have a completely different column structure and shouldn't cause removal).
|
||||
*
|
||||
* Silent-miss — "unknown metric":
|
||||
* A metric value in the Summary is not in metric_categories. Add it as 'Other'.
|
||||
*
|
||||
* Silent-miss — "unknown sheet":
|
||||
* Left as a warning. Auto-adding unknown sheets creates a reconcile loop.
|
||||
*
|
||||
* @param {string} configPath — path to compliance_config.json
|
||||
* @param {object} driftReport — the drift report from compareSchemaToDrift()
|
||||
* @param {object} [schema] — optional xlsx schema (with sheets[].name and Summary metric_values)
|
||||
* @returns {{ changes: Array<{ action: string, key: string, value: string }>, config: object }}
|
||||
*/
|
||||
function reconcileConfig(configPath, driftReport, schema) {
|
||||
const config = loadConfig(configPath);
|
||||
const changes = [];
|
||||
|
||||
// Build a set of metric values from the Summary sheet (if schema provided)
|
||||
const summaryMetrics = new Set();
|
||||
if (schema && Array.isArray(schema.sheets)) {
|
||||
const summarySheet = schema.sheets.find(function(s) { return s.name === 'Summary'; });
|
||||
if (summarySheet && Array.isArray(summarySheet.metric_values)) {
|
||||
summarySheet.metric_values.forEach(function(v) { summaryMetrics.add(v); });
|
||||
}
|
||||
}
|
||||
|
||||
// Build a set of xlsx sheet names (if schema provided)
|
||||
const xlsxSheetNames = new Set();
|
||||
if (schema && Array.isArray(schema.sheets)) {
|
||||
schema.sheets.forEach(function(s) { xlsxSheetNames.add(s.name); });
|
||||
}
|
||||
|
||||
// Count how many detail sheets exist in the xlsx (excluding skip_sheets)
|
||||
const skipSheets = new Set(config.skip_sheets);
|
||||
const detailSheetCount = schema
|
||||
? schema.sheets.filter(function(s) { return !skipSheets.has(s.name); }).length
|
||||
: 0;
|
||||
|
||||
// --- Resolve breaking findings ---
|
||||
|
||||
for (const finding of (driftReport.breaking || [])) {
|
||||
// Missing detail sheet: remove from metric_categories ONLY if the metric
|
||||
// is also absent from the Summary's metric_values. If it's in the Summary,
|
||||
// it's still a tracked metric — the sheet just has zero violations this week.
|
||||
if (finding.message.includes('is missing from the workbook') && finding.value in config.metric_categories) {
|
||||
if (summaryMetrics.has(finding.value)) {
|
||||
// Metric is in the Summary — keep it, just note it's sheet-less this week
|
||||
changes.push({
|
||||
action: 'kept',
|
||||
key: 'metric_categories',
|
||||
value: finding.value,
|
||||
detail: `Kept metric "${finding.value}" — no detail sheet this week but still tracked in Summary`
|
||||
});
|
||||
} else {
|
||||
const oldCategory = config.metric_categories[finding.value];
|
||||
delete config.metric_categories[finding.value];
|
||||
changes.push({
|
||||
action: 'removed',
|
||||
key: 'metric_categories',
|
||||
value: finding.value,
|
||||
detail: `Removed stale metric category "${finding.value}" (was "${oldCategory}") — absent from both workbook sheets and Summary`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Missing core column: only remove if the column is missing from ALL detail sheets.
|
||||
// Some sheets (e.g. 5.8.1 with CMDB columns) have a completely different structure
|
||||
// and shouldn't cause removal of columns that exist in most other sheets.
|
||||
if (finding.message.includes('is missing core column') && config.core_cols.includes(finding.value)) {
|
||||
if (!changes.some(function(c) { return c.key === 'core_cols' && c.value === finding.value; })) {
|
||||
const missingFromCount = (driftReport.breaking || []).filter(
|
||||
function(f) { return f.message.includes('is missing core column') && f.value === finding.value; }
|
||||
).length;
|
||||
|
||||
if (detailSheetCount > 0 && missingFromCount >= detailSheetCount) {
|
||||
// Missing from ALL detail sheets — safe to remove
|
||||
config.core_cols = config.core_cols.filter(function(c) { return c !== finding.value; });
|
||||
changes.push({
|
||||
action: 'removed',
|
||||
key: 'core_cols',
|
||||
value: finding.value,
|
||||
detail: `Removed core column "${finding.value}" — missing from all ${detailSheetCount} detail sheet(s)`
|
||||
});
|
||||
} else {
|
||||
// Missing from some sheets but present in others — keep it
|
||||
changes.push({
|
||||
action: 'kept',
|
||||
key: 'core_cols',
|
||||
value: finding.value,
|
||||
detail: `Kept core column "${finding.value}" — missing from ${missingFromCount} of ${detailSheetCount} detail sheet(s)`
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Resolve silent-miss findings ---
|
||||
|
||||
for (const finding of (driftReport.silent_miss || [])) {
|
||||
// Unknown metric in Summary: add to metric_categories as 'Other'
|
||||
if (finding.message.includes('not in metric_categories') && !(finding.value in config.metric_categories)) {
|
||||
config.metric_categories[finding.value] = 'Other';
|
||||
changes.push({
|
||||
action: 'added',
|
||||
key: 'metric_categories',
|
||||
value: finding.value,
|
||||
detail: `Added new metric "${finding.value}" to metric_categories as "Other"`
|
||||
});
|
||||
}
|
||||
|
||||
// Unknown sheet: left as a warning — auto-adding creates a reconcile loop.
|
||||
}
|
||||
|
||||
// Only write if there were actual config mutations (not just 'kept' entries)
|
||||
const hasMutations = changes.some(function(c) { return c.action !== 'kept'; });
|
||||
if (hasMutations) {
|
||||
fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n', 'utf8');
|
||||
}
|
||||
|
||||
return { changes, config };
|
||||
}
|
||||
|
||||
module.exports = { compareSchemaToDrift, loadConfig, reconcileConfig };
|
||||
154
backend/helpers/ivantiApi.js
Normal file
154
backend/helpers/ivantiApi.js
Normal file
@@ -0,0 +1,154 @@
|
||||
// Shared Ivanti / RiskSense API helpers
|
||||
// Centralizes HTTP calls so ivantiWorkflows.js, ivantiFindings.js, and
|
||||
// ivantiFpWorkflow.js all use the same implementation.
|
||||
|
||||
const https = require('https');
|
||||
|
||||
const IVANTI_URL_BASE = 'https://platform4.risksense.com/api/v1';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// JSON POST — used for search, workflow creation, etc.
|
||||
// ---------------------------------------------------------------------------
|
||||
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: 15000
|
||||
};
|
||||
|
||||
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();
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Multipart POST — used for file attachment uploads.
|
||||
// Constructs multipart/form-data manually using Node's https module.
|
||||
// ---------------------------------------------------------------------------
|
||||
function ivantiMultipartPost(urlPath, fileBuffer, fileName, apiKey, skipTls) {
|
||||
const boundary = '----IvantiUpload' + Date.now().toString(36) + Math.random().toString(36).slice(2);
|
||||
const fullUrl = new URL(IVANTI_URL_BASE + urlPath);
|
||||
|
||||
// Build multipart body
|
||||
const preamble = Buffer.from(
|
||||
`--${boundary}\r\n` +
|
||||
`Content-Disposition: form-data; name="file"; filename="${fileName}"\r\n` +
|
||||
`Content-Type: application/octet-stream\r\n\r\n`
|
||||
);
|
||||
const epilogue = Buffer.from(`\r\n--${boundary}--\r\n`);
|
||||
const bodyBuffer = Buffer.concat([preamble, fileBuffer, epilogue]);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const options = {
|
||||
hostname: fullUrl.hostname,
|
||||
path: fullUrl.pathname + fullUrl.search,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'accept': '*/*',
|
||||
'content-type': `multipart/form-data; boundary=${boundary}`,
|
||||
'x-api-key': apiKey,
|
||||
'x-http-client-type': 'browser',
|
||||
'content-length': bodyBuffer.length
|
||||
},
|
||||
rejectUnauthorized: !skipTls,
|
||||
timeout: 30000
|
||||
};
|
||||
|
||||
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(bodyBuffer);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Multipart form POST — used for endpoints that accept mixed form fields + files.
|
||||
// fields: array of { name, value } for text form fields
|
||||
// files: array of { name, buffer, filename } for file uploads
|
||||
// ---------------------------------------------------------------------------
|
||||
function ivantiFormPost(urlPath, fields, files, apiKey, skipTls) {
|
||||
const boundary = '----IvantiForm' + Date.now().toString(36) + Math.random().toString(36).slice(2);
|
||||
const fullUrl = new URL(IVANTI_URL_BASE + urlPath);
|
||||
|
||||
const parts = [];
|
||||
|
||||
// Text fields
|
||||
for (const { name, value } of fields) {
|
||||
parts.push(Buffer.from(
|
||||
`--${boundary}\r\n` +
|
||||
`Content-Disposition: form-data; name="${name}"\r\n\r\n` +
|
||||
`${value}\r\n`
|
||||
));
|
||||
}
|
||||
|
||||
// File fields
|
||||
for (const { name, buffer, filename, contentType } of files) {
|
||||
parts.push(Buffer.from(
|
||||
`--${boundary}\r\n` +
|
||||
`Content-Disposition: form-data; name="${name}"; filename="${filename}"\r\n` +
|
||||
`Content-Type: ${contentType || 'application/octet-stream'}\r\n\r\n`
|
||||
));
|
||||
parts.push(buffer);
|
||||
parts.push(Buffer.from('\r\n'));
|
||||
}
|
||||
|
||||
parts.push(Buffer.from(`--${boundary}--\r\n`));
|
||||
const bodyBuffer = Buffer.concat(parts);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const options = {
|
||||
hostname: fullUrl.hostname,
|
||||
path: fullUrl.pathname + fullUrl.search,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'accept': '*/*',
|
||||
'content-type': `multipart/form-data; boundary=${boundary}`,
|
||||
'x-api-key': apiKey,
|
||||
'x-http-client-type': 'browser',
|
||||
'content-length': bodyBuffer.length
|
||||
},
|
||||
rejectUnauthorized: !skipTls,
|
||||
timeout: 60000
|
||||
};
|
||||
|
||||
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(bodyBuffer);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { IVANTI_URL_BASE, ivantiPost, ivantiMultipartPost, ivantiFormPost };
|
||||
453
backend/helpers/jiraApi.js
Normal file
453
backend/helpers/jiraApi.js
Normal file
@@ -0,0 +1,453 @@
|
||||
// Shared Jira Data Center REST API helpers
|
||||
// Centralizes HTTP calls for Jira issue operations.
|
||||
// Follows the same promise-based pattern as atlasApi.js and ivantiApi.js.
|
||||
//
|
||||
// =========================================================================
|
||||
// Charter Jira REST API Compliance
|
||||
// =========================================================================
|
||||
// Authentication:
|
||||
// - Service accounts use Basic Auth (required for shared integrations).
|
||||
// - PATs require ATLSUP approval and naming convention:
|
||||
// Function - Team - Approved ATLSUP ticket
|
||||
// - SSO must NOT be used for REST API integrations.
|
||||
//
|
||||
// Rate limiting (Charter-posted):
|
||||
// - 1 440 requests/day max
|
||||
// - Burst cap of 60 requests/minute (accumulates 1 req/idle minute)
|
||||
// - 429 response when limits are hit server-side
|
||||
//
|
||||
// Automation delays (Charter requirement):
|
||||
// - 1 second delay between GET requests
|
||||
// - 2 second delay between PUT, POST, or DELETE requests
|
||||
//
|
||||
// Forbidden patterns:
|
||||
// - /rest/api/2/field — must specify fields explicitly in every call
|
||||
// - /rest/api/2/issue/bulk — bulk updates are not allowed
|
||||
// - Single-issue GET loops — use bulk JQL search instead
|
||||
//
|
||||
// Required patterns:
|
||||
// - All GET requests MUST include a ?fields= parameter
|
||||
// - JQL MUST include at least one of: project+updated, assignee+updated,
|
||||
// status+updated
|
||||
// - JQL should use &updated>=-Xh to only fetch changed issues
|
||||
// - maxResults=1000 for search queries
|
||||
// - Issues must be updated one at a time (no bulk PUT)
|
||||
// =========================================================================
|
||||
|
||||
const https = require('https');
|
||||
const http = require('http');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Configuration — read from process.env at module load
|
||||
// ---------------------------------------------------------------------------
|
||||
const JIRA_BASE_URL = process.env.JIRA_BASE_URL || '';
|
||||
const JIRA_AUTH_METHOD = (process.env.JIRA_AUTH_METHOD || 'basic').toLowerCase();
|
||||
const JIRA_API_USER = process.env.JIRA_API_USER || '';
|
||||
const JIRA_API_TOKEN = process.env.JIRA_API_TOKEN || '';
|
||||
const JIRA_PAT = process.env.JIRA_PAT || '';
|
||||
const JIRA_SKIP_TLS = process.env.JIRA_SKIP_TLS === 'true';
|
||||
const JIRA_PROJECT_KEY = process.env.JIRA_PROJECT_KEY || '';
|
||||
const JIRA_ISSUE_TYPE = process.env.JIRA_ISSUE_TYPE || 'Task';
|
||||
|
||||
const requiredVars = JIRA_AUTH_METHOD === 'pat'
|
||||
? ['JIRA_BASE_URL', 'JIRA_PAT']
|
||||
: ['JIRA_BASE_URL', 'JIRA_API_USER', 'JIRA_API_TOKEN'];
|
||||
|
||||
const missingVars = requiredVars.filter((v) => !process.env[v]);
|
||||
if (missingVars.length > 0) {
|
||||
console.warn(`[jira-api] WARNING: Missing required environment variables: ${missingVars.join(', ')}. Jira API calls will fail.`);
|
||||
}
|
||||
|
||||
const isConfigured = missingVars.length === 0;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Default fields — every GET must specify fields explicitly.
|
||||
// /rest/api/2/field is forbidden; we define the field list here.
|
||||
// ---------------------------------------------------------------------------
|
||||
const DEFAULT_FIELDS = [
|
||||
'summary', 'status', 'assignee', 'created', 'updated',
|
||||
'priority', 'issuetype', 'project', 'resolution'
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Rate limiter — enforces Charter's posted limits
|
||||
// 1 440 events/day, burst of 60 events/minute
|
||||
// ---------------------------------------------------------------------------
|
||||
const DAILY_LIMIT = 1440;
|
||||
const BURST_LIMIT = 60;
|
||||
const MINUTE_MS = 60 * 1000;
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
let dailyLog = [];
|
||||
let minuteLog = [];
|
||||
|
||||
function pruneLog(log, windowMs) {
|
||||
const cutoff = Date.now() - windowMs;
|
||||
while (log.length > 0 && log[0] < cutoff) {
|
||||
log.shift();
|
||||
}
|
||||
}
|
||||
|
||||
function checkRateLimit() {
|
||||
pruneLog(dailyLog, DAY_MS);
|
||||
pruneLog(minuteLog, MINUTE_MS);
|
||||
|
||||
if (dailyLog.length >= DAILY_LIMIT) {
|
||||
return { allowed: false, reason: `Daily Jira API limit reached (${DAILY_LIMIT}/day). Resets at midnight.` };
|
||||
}
|
||||
if (minuteLog.length >= BURST_LIMIT) {
|
||||
return { allowed: false, reason: `Burst Jira API limit reached (${BURST_LIMIT}/min). Wait and retry.` };
|
||||
}
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
function recordRequest() {
|
||||
const now = Date.now();
|
||||
dailyLog.push(now);
|
||||
minuteLog.push(now);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return current rate limit usage for diagnostics.
|
||||
*/
|
||||
function getRateLimitStatus() {
|
||||
pruneLog(dailyLog, DAY_MS);
|
||||
pruneLog(minuteLog, MINUTE_MS);
|
||||
return {
|
||||
daily: { used: dailyLog.length, limit: DAILY_LIMIT, remaining: DAILY_LIMIT - dailyLog.length },
|
||||
burst: { used: minuteLog.length, limit: BURST_LIMIT, remaining: BURST_LIMIT - minuteLog.length }
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Inter-request delay — Charter automation requirements
|
||||
// 1s between GETs, 2s between PUT/POST/DELETE
|
||||
// ---------------------------------------------------------------------------
|
||||
const GET_DELAY_MS = 1000;
|
||||
const WRITE_DELAY_MS = 2000;
|
||||
|
||||
let lastRequestTime = 0;
|
||||
let lastRequestMethod = '';
|
||||
|
||||
/**
|
||||
* Wait the required delay before issuing the next request.
|
||||
* GET → 1s, PUT/POST/DELETE → 2s since the previous request.
|
||||
*/
|
||||
function waitForDelay(method) {
|
||||
const now = Date.now();
|
||||
const requiredDelay = (lastRequestMethod === 'GET') ? GET_DELAY_MS
|
||||
: (lastRequestMethod !== '') ? WRITE_DELAY_MS : 0;
|
||||
const elapsed = now - lastRequestTime;
|
||||
const remaining = requiredDelay - elapsed;
|
||||
|
||||
if (remaining > 0) {
|
||||
return new Promise(resolve => setTimeout(resolve, remaining));
|
||||
}
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Blocked endpoint guard
|
||||
// ---------------------------------------------------------------------------
|
||||
const BLOCKED_PATHS = [
|
||||
'/rest/api/2/field', // Must specify fields in call, not query field list
|
||||
'/rest/api/2/issue/bulk', // Bulk updates are not allowed
|
||||
];
|
||||
|
||||
function isBlockedPath(urlPath) {
|
||||
return BLOCKED_PATHS.some(blocked => urlPath.startsWith(blocked));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Generic request — supports GET, POST, PUT, DELETE
|
||||
// Enforces rate limits, inter-request delays, and blocked-path guards.
|
||||
// ---------------------------------------------------------------------------
|
||||
async function jiraRequest(method, urlPath, body, options) {
|
||||
// Block forbidden endpoints
|
||||
if (isBlockedPath(urlPath)) {
|
||||
return Promise.reject(new Error(`Blocked: ${urlPath} is not allowed per Charter Jira API policy.`));
|
||||
}
|
||||
|
||||
const limit = checkRateLimit();
|
||||
if (!limit.allowed) {
|
||||
return Promise.reject(new Error(limit.reason));
|
||||
}
|
||||
|
||||
// Enforce inter-request delay
|
||||
await waitForDelay(method);
|
||||
|
||||
const timeout = (options && options.timeout) || 15000;
|
||||
const fullUrl = new URL(JIRA_BASE_URL + urlPath);
|
||||
const isHttps = fullUrl.protocol === 'https:';
|
||||
const transport = isHttps ? https : http;
|
||||
|
||||
const headers = {
|
||||
'accept': 'application/json'
|
||||
};
|
||||
|
||||
// Auth header
|
||||
if (JIRA_AUTH_METHOD === 'pat') {
|
||||
headers['authorization'] = 'Bearer ' + JIRA_PAT;
|
||||
} else {
|
||||
const authString = Buffer.from(JIRA_API_USER + ':' + JIRA_API_TOKEN).toString('base64');
|
||||
headers['authorization'] = 'Basic ' + authString;
|
||||
}
|
||||
|
||||
let bodyStr = null;
|
||||
if (body !== null && body !== undefined) {
|
||||
bodyStr = JSON.stringify(body);
|
||||
headers['content-type'] = 'application/json';
|
||||
headers['content-length'] = Buffer.byteLength(bodyStr);
|
||||
}
|
||||
|
||||
recordRequest();
|
||||
lastRequestTime = Date.now();
|
||||
lastRequestMethod = method;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const reqOptions = {
|
||||
hostname: fullUrl.hostname,
|
||||
port: fullUrl.port || (isHttps ? 443 : 80),
|
||||
path: fullUrl.pathname + fullUrl.search,
|
||||
method: method,
|
||||
headers: headers,
|
||||
timeout: timeout
|
||||
};
|
||||
|
||||
if (isHttps) {
|
||||
reqOptions.rejectUnauthorized = !JIRA_SKIP_TLS;
|
||||
}
|
||||
|
||||
const req = transport.request(reqOptions, (res) => {
|
||||
let data = '';
|
||||
res.on('data', (chunk) => { data += chunk; });
|
||||
res.on('end', () => {
|
||||
if (res.statusCode === 429) {
|
||||
resolve({ status: 429, body: data, rateLimited: true });
|
||||
} else {
|
||||
resolve({ status: res.statusCode, body: data });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
req.on('timeout', () => req.destroy(new Error(method + ' ' + urlPath + ' timed out')));
|
||||
req.on('error', (err) => {
|
||||
reject(new Error(method + ' ' + urlPath + ' failed: ' + err.message));
|
||||
});
|
||||
|
||||
if (bodyStr) {
|
||||
req.write(bodyStr);
|
||||
}
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Convenience wrappers
|
||||
// ---------------------------------------------------------------------------
|
||||
function jiraGet(urlPath, options) {
|
||||
return jiraRequest('GET', urlPath, null, options);
|
||||
}
|
||||
|
||||
function jiraPost(urlPath, body, options) {
|
||||
return jiraRequest('POST', urlPath, body, options);
|
||||
}
|
||||
|
||||
function jiraPut(urlPath, body, options) {
|
||||
return jiraRequest('PUT', urlPath, body, options);
|
||||
}
|
||||
|
||||
function jiraDelete(urlPath, options) {
|
||||
return jiraRequest('DELETE', urlPath, null, options);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// High-level Jira operations — all comply with Charter requirements
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Fetch a single issue by key using a GET with explicit ?fields= parameter.
|
||||
* Charter requires all GETs to specify fields — /rest/api/2/field is forbidden.
|
||||
*
|
||||
* NOTE: For syncing multiple tickets, prefer searchIssuesByKeys() which uses
|
||||
* a single bulk JQL search instead of one GET per issue.
|
||||
*
|
||||
* @param {string} issueKey - e.g. "VULN-123"
|
||||
* @param {string[]} [fields] - Jira field names to return
|
||||
*/
|
||||
async function getIssue(issueKey, fields) {
|
||||
const jql = `key = "${issueKey}" AND project = ${JIRA_PROJECT_KEY}`;
|
||||
const result = await searchIssues(jql, { fields: fields || DEFAULT_FIELDS, maxResults: 1, startAt: 0 });
|
||||
if (result.ok && result.data.issues && result.data.issues.length > 0) {
|
||||
return { ok: true, data: result.data.issues[0] };
|
||||
}
|
||||
if (result.ok && (!result.data.issues || result.data.issues.length === 0)) {
|
||||
return { ok: false, status: 404, body: 'Issue not found' };
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk-fetch issues by their keys using a single JQL search.
|
||||
* This is the Charter-compliant way to sync multiple tickets — avoids
|
||||
* querying one issue at a time.
|
||||
*
|
||||
* @param {string[]} issueKeys - Array of Jira issue keys
|
||||
* @param {object} [opts] - { fields, maxResults }
|
||||
*/
|
||||
async function searchIssuesByKeys(issueKeys, opts) {
|
||||
if (!issueKeys || issueKeys.length === 0) {
|
||||
return { ok: true, data: { total: 0, issues: [] } };
|
||||
}
|
||||
|
||||
// Build JQL: key in (KEY-1, KEY-2, ...) — Charter requires project+updated
|
||||
// or similar, but key-based search is inherently scoped. We add updated
|
||||
// clause for compliance.
|
||||
const keyList = issueKeys.map(k => `"${k}"`).join(', ');
|
||||
const jql = `key in (${keyList}) AND updated >= -24h AND project = ${JIRA_PROJECT_KEY}`;
|
||||
const fields = (opts && opts.fields) || DEFAULT_FIELDS;
|
||||
const maxResults = Math.min((opts && opts.maxResults) || 1000, 1000);
|
||||
|
||||
return searchIssues(jql, { fields, maxResults, startAt: 0 });
|
||||
}
|
||||
|
||||
/**
|
||||
* Search issues via JQL (POST to /rest/api/2/search).
|
||||
* Charter requirements enforced:
|
||||
* - fields array is always specified (never omitted)
|
||||
* - maxResults capped at 1000
|
||||
*
|
||||
* The caller is responsible for including an &updated clause in the JQL
|
||||
* for recurring/scheduled queries.
|
||||
*
|
||||
* @param {string} jql - JQL query string
|
||||
* @param {object} [opts] - { startAt, maxResults, fields }
|
||||
*/
|
||||
async function searchIssues(jql, opts) {
|
||||
const startAt = (opts && opts.startAt) || 0;
|
||||
const maxResults = Math.min((opts && opts.maxResults) || 1000, 1000);
|
||||
const fields = (opts && opts.fields) || DEFAULT_FIELDS;
|
||||
|
||||
const fieldList = encodeURIComponent(fields.join(','));
|
||||
const encodedJql = encodeURIComponent(jql);
|
||||
const queryString = `?jql=${encodedJql}&fields=${fieldList}&maxResults=${maxResults}&startAt=${startAt}`;
|
||||
const res = await jiraGet('/rest/api/2/search' + queryString);
|
||||
if (res.status === 200) {
|
||||
return { ok: true, data: JSON.parse(res.body) };
|
||||
}
|
||||
return { ok: false, status: res.status, body: res.body, rateLimited: res.rateLimited };
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new Jira issue (POST, subject to 2s delay).
|
||||
* @param {object} fields - Jira issue fields object
|
||||
*/
|
||||
async function createIssue(fields) {
|
||||
const res = await jiraPost('/rest/api/2/issue', { fields });
|
||||
if (res.status === 201) {
|
||||
return { ok: true, data: JSON.parse(res.body) };
|
||||
}
|
||||
return { ok: false, status: res.status, body: res.body, rateLimited: res.rateLimited };
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a single Jira issue (PUT, subject to 2s delay).
|
||||
* Charter forbids bulk updates — issues must be updated one at a time.
|
||||
* @param {string} issueKey
|
||||
* @param {object} fields - Fields to update
|
||||
*/
|
||||
async function updateIssue(issueKey, fields) {
|
||||
const res = await jiraPut(
|
||||
`/rest/api/2/issue/${encodeURIComponent(issueKey)}`,
|
||||
{ fields }
|
||||
);
|
||||
// Jira returns 204 on successful update
|
||||
if (res.status === 204) {
|
||||
return { ok: true };
|
||||
}
|
||||
return { ok: false, status: res.status, body: res.body, rateLimited: res.rateLimited };
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a comment to an existing issue (POST, subject to 2s delay).
|
||||
*/
|
||||
async function addComment(issueKey, commentBody) {
|
||||
const res = await jiraPost(
|
||||
`/rest/api/2/issue/${encodeURIComponent(issueKey)}/comment`,
|
||||
{ body: commentBody }
|
||||
);
|
||||
if (res.status === 201) {
|
||||
return { ok: true, data: JSON.parse(res.body) };
|
||||
}
|
||||
return { ok: false, status: res.status, body: res.body, rateLimited: res.rateLimited };
|
||||
}
|
||||
|
||||
/**
|
||||
* Transition an issue to a new status (POST, subject to 2s delay).
|
||||
* @param {string} issueKey
|
||||
* @param {string} transitionId
|
||||
*/
|
||||
async function transitionIssue(issueKey, transitionId) {
|
||||
const res = await jiraPost(
|
||||
`/rest/api/2/issue/${encodeURIComponent(issueKey)}/transitions`,
|
||||
{ transition: { id: transitionId } }
|
||||
);
|
||||
if (res.status === 204) {
|
||||
return { ok: true };
|
||||
}
|
||||
return { ok: false, status: res.status, body: res.body, rateLimited: res.rateLimited };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available transitions for an issue.
|
||||
* Uses GET with explicit fields parameter (transitions endpoint returns
|
||||
* transitions by default, but we include the query param for compliance).
|
||||
*/
|
||||
async function getTransitions(issueKey) {
|
||||
const res = await jiraGet(
|
||||
`/rest/api/2/issue/${encodeURIComponent(issueKey)}/transitions`
|
||||
);
|
||||
if (res.status === 200) {
|
||||
return { ok: true, data: JSON.parse(res.body) };
|
||||
}
|
||||
return { ok: false, status: res.status, body: res.body, rateLimited: res.rateLimited };
|
||||
}
|
||||
|
||||
/**
|
||||
* Test connectivity — calls /rest/api/2/myself to verify credentials.
|
||||
* This is a lightweight GET that returns the authenticated user.
|
||||
*/
|
||||
async function testConnection() {
|
||||
try {
|
||||
const res = await jiraGet('/rest/api/2/myself');
|
||||
if (res.status === 200) {
|
||||
const user = JSON.parse(res.body);
|
||||
return { ok: true, user: { name: user.name, displayName: user.displayName, emailAddress: user.emailAddress } };
|
||||
}
|
||||
return { ok: false, status: res.status, body: res.body };
|
||||
} catch (err) {
|
||||
return { ok: false, error: err.message };
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
isConfigured,
|
||||
jiraRequest,
|
||||
jiraGet,
|
||||
jiraPost,
|
||||
jiraPut,
|
||||
jiraDelete,
|
||||
getIssue,
|
||||
searchIssuesByKeys,
|
||||
searchIssues,
|
||||
createIssue,
|
||||
updateIssue,
|
||||
addComment,
|
||||
transitionIssue,
|
||||
getTransitions,
|
||||
testConnection,
|
||||
getRateLimitStatus,
|
||||
DEFAULT_FIELDS,
|
||||
JIRA_PROJECT_KEY,
|
||||
JIRA_ISSUE_TYPE
|
||||
};
|
||||
@@ -12,7 +12,7 @@ function requireAuth(db) {
|
||||
try {
|
||||
const session = await new Promise((resolve, reject) => {
|
||||
db.get(
|
||||
`SELECT s.*, u.id as user_id, u.username, u.email, u.role, u.is_active
|
||||
`SELECT s.*, u.id as user_id, u.username, u.email, u.role, u.user_group, u.is_active
|
||||
FROM sessions s
|
||||
JOIN users u ON s.user_id = u.id
|
||||
WHERE s.session_id = ? AND s.expires_at > datetime('now')`,
|
||||
@@ -37,7 +37,8 @@ function requireAuth(db) {
|
||||
id: session.user_id,
|
||||
username: session.username,
|
||||
email: session.email,
|
||||
role: session.role
|
||||
role: session.role,
|
||||
group: session.user_group
|
||||
};
|
||||
|
||||
next();
|
||||
@@ -48,18 +49,18 @@ function requireAuth(db) {
|
||||
};
|
||||
}
|
||||
|
||||
// Require specific role(s)
|
||||
function requireRole(...allowedRoles) {
|
||||
// Require specific group(s)
|
||||
function requireGroup(...allowedGroups) {
|
||||
return (req, res, next) => {
|
||||
if (!req.user) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
|
||||
if (!allowedRoles.includes(req.user.role)) {
|
||||
if (!allowedGroups.includes(req.user.group)) {
|
||||
return res.status(403).json({
|
||||
error: 'Insufficient permissions',
|
||||
required: allowedRoles,
|
||||
current: req.user.role
|
||||
required: allowedGroups,
|
||||
current: req.user.group
|
||||
});
|
||||
}
|
||||
|
||||
@@ -67,4 +68,4 @@ function requireRole(...allowedRoles) {
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { requireAuth, requireRole };
|
||||
module.exports = { requireAuth, requireGroup };
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
// Migration script: Add audit_logs table
|
||||
// Run: node migrate-audit-log.js
|
||||
|
||||
const sqlite3 = require('sqlite3').verbose();
|
||||
const fs = require('fs');
|
||||
|
||||
const DB_FILE = './cve_database.db';
|
||||
const BACKUP_FILE = `./cve_database_backup_${Date.now()}.db`;
|
||||
|
||||
function run(db, sql, params = []) {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.run(sql, params, function(err) {
|
||||
if (err) reject(err);
|
||||
else resolve(this);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function get(db, sql, params = []) {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.get(sql, params, (err, row) => {
|
||||
if (err) reject(err);
|
||||
else resolve(row);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function migrate() {
|
||||
console.log('╔════════════════════════════════════════════════════════╗');
|
||||
console.log('║ CVE Database Migration: Add Audit Logs ║');
|
||||
console.log('╚════════════════════════════════════════════════════════╝\n');
|
||||
|
||||
if (!fs.existsSync(DB_FILE)) {
|
||||
console.log('❌ Database not found. Run setup.js for fresh install.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Backup database
|
||||
console.log('📦 Creating backup...');
|
||||
fs.copyFileSync(DB_FILE, BACKUP_FILE);
|
||||
console.log(` ✓ Backup saved to: ${BACKUP_FILE}\n`);
|
||||
|
||||
const db = new sqlite3.Database(DB_FILE);
|
||||
|
||||
try {
|
||||
// Check if table already exists
|
||||
const exists = await get(db,
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='audit_logs'"
|
||||
);
|
||||
|
||||
if (exists) {
|
||||
console.log('⏭️ audit_logs table already exists, nothing to do.');
|
||||
} else {
|
||||
console.log('1️⃣ Creating audit_logs table...');
|
||||
await run(db, `
|
||||
CREATE TABLE audit_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER,
|
||||
username VARCHAR(50) NOT NULL,
|
||||
action VARCHAR(50) NOT NULL,
|
||||
entity_type VARCHAR(50) NOT NULL,
|
||||
entity_id VARCHAR(100),
|
||||
details TEXT,
|
||||
ip_address VARCHAR(45),
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
console.log(' ✓ Table created');
|
||||
|
||||
console.log('2️⃣ Creating indexes...');
|
||||
await run(db, 'CREATE INDEX IF NOT EXISTS idx_audit_user_id ON audit_logs(user_id)');
|
||||
await run(db, 'CREATE INDEX IF NOT EXISTS idx_audit_action ON audit_logs(action)');
|
||||
await run(db, 'CREATE INDEX IF NOT EXISTS idx_audit_entity_type ON audit_logs(entity_type)');
|
||||
await run(db, 'CREATE INDEX IF NOT EXISTS idx_audit_created_at ON audit_logs(created_at)');
|
||||
console.log(' ✓ Indexes created');
|
||||
}
|
||||
|
||||
console.log('\n╔════════════════════════════════════════════════════════╗');
|
||||
console.log('║ MIGRATION COMPLETE! ║');
|
||||
console.log('╚════════════════════════════════════════════════════════╝');
|
||||
console.log('\n📋 Summary:');
|
||||
console.log(' ✓ audit_logs table ready');
|
||||
console.log(`\n💾 Backup saved: ${BACKUP_FILE}`);
|
||||
console.log('\n🚀 Restart your server to apply changes.\n');
|
||||
|
||||
} catch (error) {
|
||||
console.error('\n❌ Migration failed:', error.message);
|
||||
console.log(`\n🔄 To restore from backup: cp ${BACKUP_FILE} ${DB_FILE}`);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
migrate();
|
||||
@@ -1,289 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
// Migration script: v1.0.0 -> v1.1.0
|
||||
// Adds: users, sessions tables, multi-vendor support, vendor column in documents
|
||||
// Run: node migrate-to-1.1.js
|
||||
|
||||
const sqlite3 = require('sqlite3').verbose();
|
||||
const bcrypt = require('bcryptjs');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const DB_FILE = './cve_database.db';
|
||||
const BACKUP_FILE = `./cve_database_backup_${Date.now()}.db`;
|
||||
|
||||
async function migrate() {
|
||||
console.log('╔════════════════════════════════════════════════════════╗');
|
||||
console.log('║ CVE Database Migration: v1.0.0 → v1.1.0 ║');
|
||||
console.log('╚════════════════════════════════════════════════════════╝\n');
|
||||
|
||||
// Check if database exists
|
||||
if (!fs.existsSync(DB_FILE)) {
|
||||
console.log('❌ Database not found. Run setup.js for fresh install.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Backup database
|
||||
console.log('📦 Creating backup...');
|
||||
fs.copyFileSync(DB_FILE, BACKUP_FILE);
|
||||
console.log(` ✓ Backup saved to: ${BACKUP_FILE}\n`);
|
||||
|
||||
const db = new sqlite3.Database(DB_FILE);
|
||||
|
||||
try {
|
||||
// Run migrations in sequence
|
||||
await addUsersTable(db);
|
||||
await addSessionsTable(db);
|
||||
await addVendorToDocuments(db);
|
||||
await updateCvesConstraint(db);
|
||||
await createDefaultAdmin(db);
|
||||
await updateView(db);
|
||||
|
||||
console.log('\n╔════════════════════════════════════════════════════════╗');
|
||||
console.log('║ MIGRATION COMPLETE! ║');
|
||||
console.log('╚════════════════════════════════════════════════════════╝');
|
||||
console.log('\n📋 Summary:');
|
||||
console.log(' ✓ Users table added');
|
||||
console.log(' ✓ Sessions table added');
|
||||
console.log(' ✓ Vendor column added to documents');
|
||||
console.log(' ✓ Multi-vendor constraint applied to cves');
|
||||
console.log(' ✓ Default admin user created (admin/admin123)');
|
||||
console.log(`\n💾 Backup saved: ${BACKUP_FILE}`);
|
||||
console.log('\n🚀 Restart your server to apply changes.\n');
|
||||
|
||||
} catch (error) {
|
||||
console.error('\n❌ Migration failed:', error.message);
|
||||
console.log(`\n🔄 To restore from backup: cp ${BACKUP_FILE} ${DB_FILE}`);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
function run(db, sql, params = []) {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.run(sql, params, function(err) {
|
||||
if (err) reject(err);
|
||||
else resolve(this);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function get(db, sql, params = []) {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.get(sql, params, (err, row) => {
|
||||
if (err) reject(err);
|
||||
else resolve(row);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function all(db, sql, params = []) {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.all(sql, params, (err, rows) => {
|
||||
if (err) reject(err);
|
||||
else resolve(rows);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function addUsersTable(db) {
|
||||
console.log('1️⃣ Adding users table...');
|
||||
|
||||
const exists = await get(db,
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='users'"
|
||||
);
|
||||
|
||||
if (exists) {
|
||||
console.log(' ⏭️ Users table already exists, skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
await run(db, `
|
||||
CREATE TABLE users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username VARCHAR(50) UNIQUE NOT NULL,
|
||||
email VARCHAR(255) UNIQUE NOT NULL,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
role VARCHAR(20) NOT NULL DEFAULT 'viewer',
|
||||
is_active BOOLEAN DEFAULT 1,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
last_login TIMESTAMP,
|
||||
CHECK (role IN ('admin', 'editor', 'viewer'))
|
||||
)
|
||||
`);
|
||||
|
||||
await run(db, 'CREATE INDEX IF NOT EXISTS idx_users_username ON users(username)');
|
||||
console.log(' ✓ Users table created');
|
||||
}
|
||||
|
||||
async function addSessionsTable(db) {
|
||||
console.log('2️⃣ Adding sessions table...');
|
||||
|
||||
const exists = await get(db,
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='sessions'"
|
||||
);
|
||||
|
||||
if (exists) {
|
||||
console.log(' ⏭️ Sessions table already exists, skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
await run(db, `
|
||||
CREATE TABLE sessions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id VARCHAR(255) UNIQUE NOT NULL,
|
||||
user_id INTEGER NOT NULL,
|
||||
expires_at TIMESTAMP NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)
|
||||
`);
|
||||
|
||||
await run(db, 'CREATE INDEX IF NOT EXISTS idx_sessions_session_id ON sessions(session_id)');
|
||||
await run(db, 'CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id)');
|
||||
await run(db, 'CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions(expires_at)');
|
||||
console.log(' ✓ Sessions table created');
|
||||
}
|
||||
|
||||
async function addVendorToDocuments(db) {
|
||||
console.log('3️⃣ Adding vendor column to documents...');
|
||||
|
||||
// Check if vendor column exists
|
||||
const columns = await all(db, "PRAGMA table_info(documents)");
|
||||
const hasVendor = columns.some(col => col.name === 'vendor');
|
||||
|
||||
if (hasVendor) {
|
||||
console.log(' ⏭️ Vendor column already exists, skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
// Add vendor column
|
||||
await run(db, "ALTER TABLE documents ADD COLUMN vendor VARCHAR(100)");
|
||||
|
||||
// Populate vendor from the cves table based on cve_id
|
||||
await run(db, `
|
||||
UPDATE documents
|
||||
SET vendor = (
|
||||
SELECT c.vendor
|
||||
FROM cves c
|
||||
WHERE c.cve_id = documents.cve_id
|
||||
LIMIT 1
|
||||
)
|
||||
WHERE vendor IS NULL
|
||||
`);
|
||||
|
||||
// Set default for any remaining nulls
|
||||
await run(db, "UPDATE documents SET vendor = 'Unknown' WHERE vendor IS NULL");
|
||||
|
||||
await run(db, 'CREATE INDEX IF NOT EXISTS idx_doc_vendor ON documents(vendor)');
|
||||
console.log(' ✓ Vendor column added and populated');
|
||||
}
|
||||
|
||||
async function updateCvesConstraint(db) {
|
||||
console.log('4️⃣ Updating CVEs table for multi-vendor support...');
|
||||
|
||||
// Check current schema
|
||||
const tableInfo = await get(db,
|
||||
"SELECT sql FROM sqlite_master WHERE type='table' AND name='cves'"
|
||||
);
|
||||
|
||||
if (tableInfo.sql.includes('UNIQUE(cve_id, vendor)')) {
|
||||
console.log(' ⏭️ Multi-vendor constraint already exists, skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
// SQLite doesn't support ALTER CONSTRAINT, so we need to rebuild the table
|
||||
console.log(' 📋 Rebuilding table with new constraint...');
|
||||
|
||||
// Create new table with correct schema
|
||||
await run(db, `
|
||||
CREATE TABLE cves_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
cve_id VARCHAR(20) NOT NULL,
|
||||
vendor VARCHAR(100) NOT NULL,
|
||||
severity VARCHAR(20) NOT NULL,
|
||||
description TEXT,
|
||||
published_date DATE,
|
||||
status VARCHAR(50) DEFAULT 'Open',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(cve_id, vendor)
|
||||
)
|
||||
`);
|
||||
|
||||
// Copy data
|
||||
await run(db, `
|
||||
INSERT INTO cves_new (id, cve_id, vendor, severity, description, published_date, status, created_at, updated_at)
|
||||
SELECT id, cve_id, vendor, severity, description, published_date, status, created_at, updated_at
|
||||
FROM cves
|
||||
`);
|
||||
|
||||
// Drop old table
|
||||
await run(db, 'DROP TABLE cves');
|
||||
|
||||
// Rename new table
|
||||
await run(db, 'ALTER TABLE cves_new RENAME TO cves');
|
||||
|
||||
// Recreate indexes
|
||||
await run(db, 'CREATE INDEX IF NOT EXISTS idx_cve_id ON cves(cve_id)');
|
||||
await run(db, 'CREATE INDEX IF NOT EXISTS idx_vendor ON cves(vendor)');
|
||||
await run(db, 'CREATE INDEX IF NOT EXISTS idx_severity ON cves(severity)');
|
||||
await run(db, 'CREATE INDEX IF NOT EXISTS idx_status ON cves(status)');
|
||||
|
||||
console.log(' ✓ Multi-vendor constraint applied');
|
||||
}
|
||||
|
||||
async function createDefaultAdmin(db) {
|
||||
console.log('5️⃣ Creating default admin user...');
|
||||
|
||||
const exists = await get(db, "SELECT id FROM users WHERE username = 'admin'");
|
||||
|
||||
if (exists) {
|
||||
console.log(' ⏭️ Admin user already exists, skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash('admin123', 10);
|
||||
|
||||
await run(db, `
|
||||
INSERT INTO users (username, email, password_hash, role, is_active)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`, ['admin', 'admin@localhost', passwordHash, 'admin', 1]);
|
||||
|
||||
console.log(' ✓ Admin user created (admin/admin123)');
|
||||
}
|
||||
|
||||
async function updateView(db) {
|
||||
console.log('6️⃣ Updating document status view...');
|
||||
|
||||
// Drop old view if exists
|
||||
await run(db, 'DROP VIEW IF EXISTS cve_document_status');
|
||||
|
||||
// Create updated view with multi-vendor support
|
||||
await run(db, `
|
||||
CREATE VIEW cve_document_status AS
|
||||
SELECT
|
||||
c.id as record_id,
|
||||
c.cve_id,
|
||||
c.vendor,
|
||||
c.severity,
|
||||
c.status,
|
||||
COUNT(DISTINCT d.id) as total_documents,
|
||||
COUNT(DISTINCT CASE WHEN d.type = 'advisory' THEN d.id END) as advisory_count,
|
||||
COUNT(DISTINCT CASE WHEN d.type = 'email' THEN d.id END) as email_count,
|
||||
COUNT(DISTINCT CASE WHEN d.type = 'screenshot' THEN d.id END) as screenshot_count,
|
||||
CASE
|
||||
WHEN COUNT(DISTINCT CASE WHEN d.type = 'advisory' THEN d.id END) > 0
|
||||
THEN 'Complete'
|
||||
ELSE 'Missing Required Docs'
|
||||
END as compliance_status
|
||||
FROM cves c
|
||||
LEFT JOIN documents d ON c.cve_id = d.cve_id AND c.vendor = d.vendor
|
||||
GROUP BY c.id, c.cve_id, c.vendor, c.severity, c.status
|
||||
`);
|
||||
|
||||
console.log(' ✓ View updated');
|
||||
}
|
||||
|
||||
// Run migration
|
||||
migrate();
|
||||
@@ -1,39 +0,0 @@
|
||||
// Migration: Add jira_tickets table
|
||||
const sqlite3 = require('sqlite3').verbose();
|
||||
const path = require('path');
|
||||
|
||||
const dbPath = path.join(__dirname, 'cve_database.db');
|
||||
const db = new sqlite3.Database(dbPath);
|
||||
|
||||
console.log('Starting JIRA tickets migration...');
|
||||
|
||||
db.serialize(() => {
|
||||
// Create jira_tickets table
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS jira_tickets (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
cve_id TEXT NOT NULL,
|
||||
vendor TEXT NOT NULL,
|
||||
ticket_key TEXT NOT NULL,
|
||||
url TEXT,
|
||||
summary TEXT,
|
||||
status TEXT DEFAULT 'Open' CHECK(status IN ('Open', 'In Progress', 'Closed')),
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (cve_id, vendor) REFERENCES cves(cve_id, vendor) ON DELETE CASCADE
|
||||
)
|
||||
`, (err) => {
|
||||
if (err) console.error('Error creating table:', err);
|
||||
else console.log('✓ jira_tickets table created');
|
||||
});
|
||||
|
||||
// Create indexes
|
||||
db.run('CREATE INDEX IF NOT EXISTS idx_jira_tickets_cve ON jira_tickets(cve_id, vendor)');
|
||||
db.run('CREATE INDEX IF NOT EXISTS idx_jira_tickets_status ON jira_tickets(status)');
|
||||
|
||||
console.log('✓ Indexes created');
|
||||
});
|
||||
|
||||
db.close(() => {
|
||||
console.log('Migration complete!');
|
||||
});
|
||||
@@ -1,128 +0,0 @@
|
||||
const sqlite3 = require('sqlite3').verbose();
|
||||
const db = new sqlite3.Database('./cve_database.db');
|
||||
|
||||
console.log('🔄 Starting database migration for multi-vendor support...\n');
|
||||
|
||||
db.serialize(() => {
|
||||
// Backup existing data
|
||||
console.log('📦 Creating backup tables...');
|
||||
db.run(`CREATE TABLE IF NOT EXISTS cves_backup AS SELECT * FROM cves`, (err) => {
|
||||
if (err) console.error('Backup error:', err);
|
||||
else console.log('✓ CVEs backed up');
|
||||
});
|
||||
|
||||
db.run(`CREATE TABLE IF NOT EXISTS documents_backup AS SELECT * FROM documents`, (err) => {
|
||||
if (err) console.error('Backup error:', err);
|
||||
else console.log('✓ Documents backed up');
|
||||
});
|
||||
|
||||
// Drop old table
|
||||
console.log('\n🗑️ Dropping old cves table...');
|
||||
db.run(`DROP TABLE IF EXISTS cves`, (err) => {
|
||||
if (err) {
|
||||
console.error('Drop error:', err);
|
||||
return;
|
||||
}
|
||||
console.log('✓ Old table dropped');
|
||||
|
||||
// Create new table with UNIQUE(cve_id, vendor) instead of UNIQUE(cve_id)
|
||||
console.log('\n🏗️ Creating new cves table with multi-vendor support...');
|
||||
db.run(`
|
||||
CREATE TABLE cves (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
cve_id VARCHAR(20) NOT NULL,
|
||||
vendor VARCHAR(100) NOT NULL,
|
||||
severity VARCHAR(20) NOT NULL,
|
||||
description TEXT,
|
||||
published_date DATE,
|
||||
status VARCHAR(50) DEFAULT 'Open',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(cve_id, vendor)
|
||||
)
|
||||
`, (err) => {
|
||||
if (err) {
|
||||
console.error('Create error:', err);
|
||||
return;
|
||||
}
|
||||
console.log('✓ New table created with UNIQUE(cve_id, vendor)');
|
||||
|
||||
// Restore data
|
||||
console.log('\n📥 Restoring data...');
|
||||
db.run(`INSERT INTO cves SELECT * FROM cves_backup`, (err) => {
|
||||
if (err) {
|
||||
console.error('Restore error:', err);
|
||||
return;
|
||||
}
|
||||
console.log('✓ Data restored');
|
||||
|
||||
// Recreate indexes
|
||||
console.log('\n🔍 Creating indexes...');
|
||||
db.run(`CREATE INDEX idx_cve_id ON cves(cve_id)`, () => {
|
||||
console.log('✓ Index: idx_cve_id');
|
||||
});
|
||||
db.run(`CREATE INDEX idx_vendor ON cves(vendor)`, () => {
|
||||
console.log('✓ Index: idx_vendor');
|
||||
});
|
||||
db.run(`CREATE INDEX idx_severity ON cves(severity)`, () => {
|
||||
console.log('✓ Index: idx_severity');
|
||||
});
|
||||
db.run(`CREATE INDEX idx_status ON cves(status)`, () => {
|
||||
console.log('✓ Index: idx_status');
|
||||
});
|
||||
|
||||
// Update view
|
||||
console.log('\n👁️ Updating cve_document_status view...');
|
||||
db.run(`DROP VIEW IF EXISTS cve_document_status`, (err) => {
|
||||
if (err) console.error('Drop view error:', err);
|
||||
|
||||
db.run(`
|
||||
CREATE VIEW cve_document_status AS
|
||||
SELECT
|
||||
c.id as record_id,
|
||||
c.cve_id,
|
||||
c.vendor,
|
||||
c.severity,
|
||||
c.status,
|
||||
COUNT(DISTINCT d.id) as total_documents,
|
||||
COUNT(DISTINCT CASE WHEN d.type = 'advisory' THEN d.id END) as advisory_count,
|
||||
COUNT(DISTINCT CASE WHEN d.type = 'email' THEN d.id END) as email_count,
|
||||
COUNT(DISTINCT CASE WHEN d.type = 'screenshot' THEN d.id END) as screenshot_count,
|
||||
CASE
|
||||
WHEN COUNT(DISTINCT CASE WHEN d.type = 'advisory' THEN d.id END) > 0
|
||||
THEN 'Complete'
|
||||
ELSE 'Missing Required Docs'
|
||||
END as compliance_status
|
||||
FROM cves c
|
||||
LEFT JOIN documents d ON c.cve_id = d.cve_id AND c.vendor = d.vendor
|
||||
GROUP BY c.id, c.cve_id, c.vendor, c.severity, c.status
|
||||
`, (err) => {
|
||||
if (err) {
|
||||
console.error('Create view error:', err);
|
||||
} else {
|
||||
console.log('✓ View recreated');
|
||||
}
|
||||
|
||||
console.log('\n✅ Migration complete!');
|
||||
console.log('\n📊 Summary:');
|
||||
|
||||
db.get('SELECT COUNT(*) as count FROM cves', (err, row) => {
|
||||
if (!err) console.log(` Total CVE entries: ${row.count}`);
|
||||
|
||||
db.get('SELECT COUNT(DISTINCT cve_id) as count FROM cves', (err, row) => {
|
||||
if (!err) console.log(` Unique CVE IDs: ${row.count}`);
|
||||
|
||||
console.log('\n💡 Next steps:');
|
||||
console.log(' 1. Restart backend: pkill -f "node server.js" && node server.js &');
|
||||
console.log(' 2. Replace frontend/src/App.js with multi-vendor version');
|
||||
console.log(' 3. Test by adding same CVE with multiple vendors\n');
|
||||
|
||||
db.close();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
41
backend/migrations/README.md
Normal file
41
backend/migrations/README.md
Normal file
@@ -0,0 +1,41 @@
|
||||
# Database Migrations
|
||||
|
||||
These migration scripts were used to evolve the database schema during development. **They are NOT needed for fresh deployments** — `setup.js` contains the complete v1.0.0 schema.
|
||||
|
||||
These are retained for reference and for upgrading existing deployments that were set up before v1.0.0.
|
||||
|
||||
## Schema Migrations (run in order for existing deployments)
|
||||
|
||||
| Script | Purpose |
|
||||
|--------|---------|
|
||||
| `add_ivanti_sync_table.js` | Creates `ivanti_sync_state` table for tracking Ivanti sync status |
|
||||
| `add_ivanti_findings_tables.js` | Creates `ivanti_findings_cache`, `ivanti_finding_notes`, `ivanti_counts_cache`, `ivanti_finding_overrides` tables |
|
||||
| `add_ivanti_counts_history_table.js` | Creates `ivanti_counts_history` table for trend chart data |
|
||||
| `add_ivanti_todo_queue_table.js` | Creates `ivanti_todo_queue` table for FP/Archer workflow queuing |
|
||||
| `add_todo_queue_hostname.js` | Adds `hostname` column to `ivanti_todo_queue` |
|
||||
| `add_todo_queue_ip_address.js` | Adds `ip_address` column to `ivanti_todo_queue` |
|
||||
| `add_fp_submissions_table.js` | Creates `ivanti_fp_submissions` table for false positive workflow tracking |
|
||||
| `add_fp_submission_editing.js` | Adds `lifecycle_status`, `ivanti_workflow_batch_uuid`, `updated_at` columns and `ivanti_fp_submission_history` table |
|
||||
| `add_knowledge_base_table.js` | Creates `knowledge_base` table for KB article storage |
|
||||
| `add_user_groups.js` | Adds `user_group` column to `users` table with validation triggers |
|
||||
| `add_created_by_columns.js` | Adds `created_by` column to `compliance_notes` and `knowledge_base` tables |
|
||||
| `add_compliance_tables.js` | Creates `compliance_uploads`, `compliance_items`, `compliance_notes` tables |
|
||||
| `add_compliance_notes_group_id.js` | Adds `group_id` column to `compliance_notes` for multi-metric note grouping |
|
||||
| `add_archer_tickets_table.js` | Creates `archer_tickets` table for Archer exception tracking |
|
||||
| `add_archer_tickets_timestamps.js` | Adds `created_at` and `updated_at` columns to `archer_tickets` |
|
||||
| `add_jira_sync_columns.js` | Adds Jira sync-related columns to `jira_tickets` |
|
||||
| `add_card_workflow_type.js` | Adds `CARD` to `workflow_type` CHECK constraint on `ivanti_todo_queue` |
|
||||
| `add_granite_workflow_type.js` | Adds `GRANITE` to `workflow_type` CHECK constraint on `ivanti_todo_queue` |
|
||||
| `add_finding_archive_tables.js` | Creates `ivanti_finding_archives` and `ivanti_archive_transitions` tables |
|
||||
| `add_closed_gone_state.js` | Adds `CLOSED_GONE` to `current_state` CHECK constraint on `ivanti_finding_archives` |
|
||||
| `add_sync_anomaly_tables.js` | Creates `ivanti_sync_anomaly_log` and `ivanti_finding_bu_history` tables |
|
||||
| `add_atlas_action_plans_cache.js` | Creates `atlas_action_plans_cache` table for Atlas API caching |
|
||||
| `add_return_classification.js` | Adds `return_classification_json` column to `ivanti_sync_anomaly_log` |
|
||||
|
||||
## Data Migrations (one-time backfills)
|
||||
|
||||
| Script | Purpose |
|
||||
|--------|---------|
|
||||
| `backfill_anomaly_log.js` | Synthesizes anomaly log entries from existing archive transitions for historical chart data |
|
||||
| `backfill_return_classification.js` | Populates `return_classification_json` for existing anomaly rows with returned findings. Supports `--force` flag to re-run. |
|
||||
| `reclassify_bu_roundtrips.js` | Reclassifies archive transitions that were BU reassignment round-trips (archived then returned within 14 days) from the default `severity_score_drift` to `bu_reassignment` |
|
||||
56
backend/migrations/add_archer_tickets_timestamps.js
Normal file
56
backend/migrations/add_archer_tickets_timestamps.js
Normal file
@@ -0,0 +1,56 @@
|
||||
// Migration: Add created_at / updated_at columns to archer_tickets
|
||||
//
|
||||
// SQLite does not support ALTER TABLE ADD COLUMN IF NOT EXISTS, so we check
|
||||
// PRAGMA table_info first and only add the column when it is absent.
|
||||
//
|
||||
// Run on any instance where archer_tickets was created before these columns
|
||||
// were added to the schema (symptoms: every /api/archer-tickets call → 500).
|
||||
//
|
||||
// Usage: node backend/migrations/add_archer_tickets_timestamps.js
|
||||
|
||||
const sqlite3 = require('sqlite3').verbose();
|
||||
const path = require('path');
|
||||
|
||||
const dbPath = path.join(__dirname, '..', 'cve_database.db');
|
||||
const db = new sqlite3.Database(dbPath);
|
||||
|
||||
console.log('Starting archer_tickets timestamp migration...');
|
||||
|
||||
db.all('PRAGMA table_info(archer_tickets)', [], (err, columns) => {
|
||||
if (err) {
|
||||
console.error('Error reading table info:', err);
|
||||
return db.close();
|
||||
}
|
||||
|
||||
const names = columns.map(c => c.name);
|
||||
|
||||
db.serialize(() => {
|
||||
if (!names.includes('created_at')) {
|
||||
db.run(
|
||||
`ALTER TABLE archer_tickets ADD COLUMN created_at DATETIME DEFAULT CURRENT_TIMESTAMP`,
|
||||
(err) => {
|
||||
if (err) console.error('Error adding created_at:', err);
|
||||
else console.log('✓ created_at column added');
|
||||
}
|
||||
);
|
||||
} else {
|
||||
console.log('✓ created_at already exists — skipping');
|
||||
}
|
||||
|
||||
if (!names.includes('updated_at')) {
|
||||
db.run(
|
||||
`ALTER TABLE archer_tickets ADD COLUMN updated_at DATETIME DEFAULT CURRENT_TIMESTAMP`,
|
||||
(err) => {
|
||||
if (err) console.error('Error adding updated_at:', err);
|
||||
else console.log('✓ updated_at column added');
|
||||
}
|
||||
);
|
||||
} else {
|
||||
console.log('✓ updated_at already exists — skipping');
|
||||
}
|
||||
});
|
||||
|
||||
db.close(() => {
|
||||
console.log('Migration complete. Restart the backend server.');
|
||||
});
|
||||
});
|
||||
37
backend/migrations/add_atlas_action_plans_cache.js
Normal file
37
backend/migrations/add_atlas_action_plans_cache.js
Normal file
@@ -0,0 +1,37 @@
|
||||
// Migration: Add atlas_action_plans_cache table
|
||||
const sqlite3 = require('sqlite3').verbose();
|
||||
const path = require('path');
|
||||
|
||||
const dbPath = path.join(__dirname, '..', 'cve_database.db');
|
||||
const db = new sqlite3.Database(dbPath);
|
||||
|
||||
console.log('Starting Atlas action plans cache migration...');
|
||||
|
||||
db.serialize(() => {
|
||||
// Cache table — one row per host, holding cached Atlas action plan status
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS atlas_action_plans_cache (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
host_id INTEGER NOT NULL UNIQUE,
|
||||
has_action_plan INTEGER NOT NULL DEFAULT 0,
|
||||
plan_count INTEGER NOT NULL DEFAULT 0,
|
||||
plans_json TEXT NOT NULL DEFAULT '[]',
|
||||
synced_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`, (err) => {
|
||||
if (err) console.error('Error creating atlas_action_plans_cache table:', err);
|
||||
else console.log('✓ atlas_action_plans_cache table created');
|
||||
});
|
||||
|
||||
db.run(`
|
||||
CREATE INDEX IF NOT EXISTS idx_atlas_cache_host_id
|
||||
ON atlas_action_plans_cache(host_id)
|
||||
`, (err) => {
|
||||
if (err) console.error('Error creating host_id index:', err);
|
||||
else console.log('✓ idx_atlas_cache_host_id index created');
|
||||
});
|
||||
});
|
||||
|
||||
db.close(() => {
|
||||
console.log('Migration complete!');
|
||||
});
|
||||
130
backend/migrations/add_closed_gone_state.js
Normal file
130
backend/migrations/add_closed_gone_state.js
Normal file
@@ -0,0 +1,130 @@
|
||||
// Migration: Add CLOSED_GONE state to ivanti_finding_archives
|
||||
//
|
||||
// The archive table tracks findings that disappear from the Open findings set.
|
||||
// Previously it only tracked: ARCHIVED → RETURNED → CLOSED.
|
||||
//
|
||||
// This migration adds a CLOSED_GONE state for findings that were confirmed
|
||||
// in the Ivanti Closed set but then disappeared from it on a subsequent sync.
|
||||
// This closes a visibility gap where findings could vanish from the Closed API
|
||||
// results (e.g., due to VRR rescore below the severity threshold) without
|
||||
// being tracked.
|
||||
//
|
||||
// SQLite does not support ALTER TABLE to modify CHECK constraints, so this
|
||||
// migration recreates the table with the expanded constraint.
|
||||
//
|
||||
// Safe to re-run — uses IF NOT EXISTS and checks for existing data.
|
||||
//
|
||||
// Usage: node backend/migrations/add_closed_gone_state.js
|
||||
|
||||
const path = require('path');
|
||||
const sqlite3 = require('sqlite3').verbose();
|
||||
const dbPath = path.join(__dirname, '..', 'cve_database.db');
|
||||
const db = new sqlite3.Database(dbPath);
|
||||
|
||||
console.log('Starting CLOSED_GONE state migration...');
|
||||
|
||||
function run(sql, params = []) {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.run(sql, params, function (err) {
|
||||
if (err) reject(err);
|
||||
else resolve(this);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function all(sql, params = []) {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.all(sql, params, (err, rows) => {
|
||||
if (err) reject(err);
|
||||
else resolve(rows || []);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function migrate() {
|
||||
// Check if the table already has the CLOSED_GONE state
|
||||
const tableInfo = await all("SELECT sql FROM sqlite_master WHERE name='ivanti_finding_archives'");
|
||||
if (tableInfo.length > 0 && tableInfo[0].sql.includes('CLOSED_GONE')) {
|
||||
console.log('✓ ivanti_finding_archives already has CLOSED_GONE state — skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
if (tableInfo.length === 0) {
|
||||
// Table doesn't exist yet — create it fresh with the new constraint
|
||||
await run(`
|
||||
CREATE TABLE ivanti_finding_archives (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
finding_id TEXT NOT NULL UNIQUE,
|
||||
finding_title TEXT NOT NULL DEFAULT '',
|
||||
host_name TEXT NOT NULL DEFAULT '',
|
||||
ip_address TEXT NOT NULL DEFAULT '',
|
||||
current_state TEXT NOT NULL CHECK(current_state IN ('ARCHIVED','RETURNED','CLOSED','CLOSED_GONE')),
|
||||
last_severity REAL NOT NULL DEFAULT 0,
|
||||
first_archived_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
last_transition_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
console.log('✓ Created ivanti_finding_archives with CLOSED_GONE state');
|
||||
return;
|
||||
}
|
||||
|
||||
// Table exists but needs the constraint updated — recreate with data migration
|
||||
console.log(' Recreating table with expanded CHECK constraint...');
|
||||
|
||||
await run('BEGIN TRANSACTION');
|
||||
try {
|
||||
// 1. Rename existing table
|
||||
await run('ALTER TABLE ivanti_finding_archives RENAME TO ivanti_finding_archives_old');
|
||||
|
||||
// 2. Create new table with expanded constraint
|
||||
await run(`
|
||||
CREATE TABLE ivanti_finding_archives (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
finding_id TEXT NOT NULL UNIQUE,
|
||||
finding_title TEXT NOT NULL DEFAULT '',
|
||||
host_name TEXT NOT NULL DEFAULT '',
|
||||
ip_address TEXT NOT NULL DEFAULT '',
|
||||
current_state TEXT NOT NULL CHECK(current_state IN ('ARCHIVED','RETURNED','CLOSED','CLOSED_GONE')),
|
||||
last_severity REAL NOT NULL DEFAULT 0,
|
||||
first_archived_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
last_transition_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
|
||||
// 3. Copy data
|
||||
await run(`
|
||||
INSERT INTO ivanti_finding_archives
|
||||
(id, finding_id, finding_title, host_name, ip_address, current_state,
|
||||
last_severity, first_archived_at, last_transition_at, created_at)
|
||||
SELECT id, finding_id, finding_title, host_name, ip_address, current_state,
|
||||
last_severity, first_archived_at, last_transition_at, created_at
|
||||
FROM ivanti_finding_archives_old
|
||||
`);
|
||||
|
||||
// 4. Recreate indexes
|
||||
await run('CREATE INDEX IF NOT EXISTS idx_archive_finding_id ON ivanti_finding_archives(finding_id)');
|
||||
await run('CREATE INDEX IF NOT EXISTS idx_archive_current_state ON ivanti_finding_archives(current_state)');
|
||||
|
||||
// 5. Drop old table
|
||||
await run('DROP TABLE ivanti_finding_archives_old');
|
||||
|
||||
await run('COMMIT');
|
||||
console.log('✓ ivanti_finding_archives updated with CLOSED_GONE state');
|
||||
} catch (err) {
|
||||
await run('ROLLBACK').catch(() => {});
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
migrate()
|
||||
.then(() => {
|
||||
console.log('Migration complete.');
|
||||
db.close();
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('Migration failed:', err);
|
||||
db.close();
|
||||
process.exit(1);
|
||||
});
|
||||
29
backend/migrations/add_compliance_notes_group_id.js
Normal file
29
backend/migrations/add_compliance_notes_group_id.js
Normal file
@@ -0,0 +1,29 @@
|
||||
// Migration: Add group_id column to compliance_notes table
|
||||
const sqlite3 = require('sqlite3').verbose();
|
||||
const path = require('path');
|
||||
|
||||
const dbPath = path.join(__dirname, '..', 'cve_database.db');
|
||||
const db = new sqlite3.Database(dbPath);
|
||||
|
||||
console.log('Starting add_compliance_notes_group_id migration...');
|
||||
|
||||
db.serialize(() => {
|
||||
db.run(`ALTER TABLE compliance_notes ADD COLUMN group_id TEXT`, (err) => {
|
||||
if (err) console.error('Error adding group_id column:', err);
|
||||
else console.log('✓ group_id column added to compliance_notes');
|
||||
});
|
||||
|
||||
db.run(`CREATE INDEX IF NOT EXISTS idx_compliance_notes_group ON compliance_notes(group_id)`, (err) => {
|
||||
if (err) console.error('Error creating group_id index:', err);
|
||||
else console.log('✓ idx_compliance_notes_group created');
|
||||
});
|
||||
|
||||
db.run(`UPDATE compliance_notes SET group_id = 'legacy-' || id WHERE group_id IS NULL`, (err) => {
|
||||
if (err) console.error('Error backfilling group_id:', err);
|
||||
else console.log('✓ Existing rows backfilled with legacy group_id');
|
||||
});
|
||||
});
|
||||
|
||||
db.close(() => {
|
||||
console.log('Migration complete!');
|
||||
});
|
||||
76
backend/migrations/add_created_by_columns.js
Normal file
76
backend/migrations/add_created_by_columns.js
Normal file
@@ -0,0 +1,76 @@
|
||||
// Migration: Add created_by column to cves, archer_tickets, and jira_tickets tables
|
||||
// Stores the user ID of the creator for ownership-based delete checks.
|
||||
// Idempotent — safe to run multiple times.
|
||||
const sqlite3 = require('sqlite3').verbose();
|
||||
const path = require('path');
|
||||
|
||||
/**
|
||||
* Run the migration against the given database instance.
|
||||
* Exported for testing with in-memory databases.
|
||||
* @param {sqlite3.Database} db
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
function runMigration(db) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const tables = ['cves', 'archer_tickets', 'jira_tickets'];
|
||||
let completed = 0;
|
||||
|
||||
db.serialize(() => {
|
||||
tables.forEach((table) => {
|
||||
db.all(`PRAGMA table_info(${table})`, (err, columns) => {
|
||||
if (err) {
|
||||
// Table may not exist yet — skip gracefully
|
||||
console.log(`⚠ Could not inspect ${table}: ${err.message} — skipping`);
|
||||
completed++;
|
||||
if (completed === tables.length) resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
const hasCreatedBy = columns.some(col => col.name === 'created_by');
|
||||
|
||||
if (hasCreatedBy) {
|
||||
console.log(`✓ ${table}.created_by already exists — skipping`);
|
||||
completed++;
|
||||
if (completed === tables.length) resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
db.run(
|
||||
`ALTER TABLE ${table} ADD COLUMN created_by INTEGER REFERENCES users(id)`,
|
||||
(err) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
console.log(`✓ Added created_by column to ${table}`);
|
||||
completed++;
|
||||
if (completed === tables.length) resolve();
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Run directly if executed as a script
|
||||
if (require.main === module) {
|
||||
const dbPath = path.join(__dirname, '..', 'cve_database.db');
|
||||
const db = new sqlite3.Database(dbPath);
|
||||
console.log('Starting add_created_by_columns migration...');
|
||||
|
||||
runMigration(db)
|
||||
.then(() => {
|
||||
console.log('Migration complete!');
|
||||
db.close(() => {
|
||||
console.log('Database connection closed.');
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('Migration failed:', err);
|
||||
db.close();
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { runMigration };
|
||||
75
backend/migrations/add_finding_archive_tables.js
Normal file
75
backend/migrations/add_finding_archive_tables.js
Normal file
@@ -0,0 +1,75 @@
|
||||
// Migration: Add ivanti_finding_archives and ivanti_archive_transitions 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 finding archive tables migration...');
|
||||
|
||||
db.serialize(() => {
|
||||
// Archive records — one row per finding that has entered the archive lifecycle
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS ivanti_finding_archives (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
finding_id TEXT NOT NULL UNIQUE,
|
||||
finding_title TEXT NOT NULL DEFAULT '',
|
||||
host_name TEXT NOT NULL DEFAULT '',
|
||||
ip_address TEXT NOT NULL DEFAULT '',
|
||||
current_state TEXT NOT NULL CHECK(current_state IN ('ARCHIVED', 'RETURNED', 'CLOSED')),
|
||||
last_severity REAL NOT NULL DEFAULT 0,
|
||||
first_archived_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
last_transition_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`, (err) => {
|
||||
if (err) console.error('Error creating ivanti_finding_archives table:', err);
|
||||
else console.log('✓ ivanti_finding_archives table created');
|
||||
});
|
||||
|
||||
// Transition history — one row per state change on an archive record
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS ivanti_archive_transitions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
archive_id INTEGER NOT NULL,
|
||||
from_state TEXT NOT NULL,
|
||||
to_state TEXT NOT NULL,
|
||||
severity_at_transition REAL NOT NULL DEFAULT 0,
|
||||
reason TEXT NOT NULL DEFAULT '',
|
||||
transitioned_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (archive_id) REFERENCES ivanti_finding_archives(id)
|
||||
)
|
||||
`, (err) => {
|
||||
if (err) console.error('Error creating ivanti_archive_transitions table:', err);
|
||||
else console.log('✓ ivanti_archive_transitions table created');
|
||||
});
|
||||
|
||||
// Indexes for query performance
|
||||
db.run(`
|
||||
CREATE INDEX IF NOT EXISTS idx_archive_finding_id
|
||||
ON ivanti_finding_archives(finding_id)
|
||||
`, (err) => {
|
||||
if (err) console.error('Error creating idx_archive_finding_id:', err);
|
||||
else console.log('✓ idx_archive_finding_id index created');
|
||||
});
|
||||
|
||||
db.run(`
|
||||
CREATE INDEX IF NOT EXISTS idx_archive_current_state
|
||||
ON ivanti_finding_archives(current_state)
|
||||
`, (err) => {
|
||||
if (err) console.error('Error creating idx_archive_current_state:', err);
|
||||
else console.log('✓ idx_archive_current_state index created');
|
||||
});
|
||||
|
||||
db.run(`
|
||||
CREATE INDEX IF NOT EXISTS idx_transition_archive_id
|
||||
ON ivanti_archive_transitions(archive_id)
|
||||
`, (err) => {
|
||||
if (err) console.error('Error creating idx_transition_archive_id:', err);
|
||||
else console.log('✓ idx_transition_archive_id index created');
|
||||
});
|
||||
});
|
||||
|
||||
db.close(() => {
|
||||
console.log('Migration complete!');
|
||||
});
|
||||
94
backend/migrations/add_fp_submission_editing.js
Normal file
94
backend/migrations/add_fp_submission_editing.js
Normal file
@@ -0,0 +1,94 @@
|
||||
// Migration: Add FP submission editing support (lifecycle status, batch UUID, history table)
|
||||
const sqlite3 = require('sqlite3').verbose();
|
||||
const path = require('path');
|
||||
|
||||
const dbPath = path.join(__dirname, '..', 'cve_database.db');
|
||||
const db = new sqlite3.Database(dbPath);
|
||||
|
||||
console.log('Starting FP submission editing migration...');
|
||||
|
||||
db.serialize(() => {
|
||||
// Add lifecycle_status column to ivanti_fp_submissions
|
||||
// Wrapped in try/catch style via callback — SQLite throws if column already exists
|
||||
db.run(
|
||||
`ALTER TABLE ivanti_fp_submissions ADD COLUMN lifecycle_status TEXT NOT NULL DEFAULT 'submitted' CHECK(lifecycle_status IN ('submitted', 'approved', 'rejected', 'rework', 'resubmitted'))`,
|
||||
(err) => {
|
||||
if (err) {
|
||||
if (err.message.includes('duplicate column')) {
|
||||
console.log('✓ lifecycle_status column already exists');
|
||||
} else {
|
||||
console.error('Error adding lifecycle_status column:', err.message);
|
||||
}
|
||||
} else {
|
||||
console.log('✓ lifecycle_status column added');
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Add ivanti_workflow_batch_uuid column
|
||||
db.run(
|
||||
`ALTER TABLE ivanti_fp_submissions ADD COLUMN ivanti_workflow_batch_uuid TEXT`,
|
||||
(err) => {
|
||||
if (err) {
|
||||
if (err.message.includes('duplicate column')) {
|
||||
console.log('✓ ivanti_workflow_batch_uuid column already exists');
|
||||
} else {
|
||||
console.error('Error adding ivanti_workflow_batch_uuid column:', err.message);
|
||||
}
|
||||
} else {
|
||||
console.log('✓ ivanti_workflow_batch_uuid column added');
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Add updated_at column (SQLite requires constant defaults for ALTER TABLE, so default to NULL)
|
||||
db.run(
|
||||
`ALTER TABLE ivanti_fp_submissions ADD COLUMN updated_at DATETIME DEFAULT NULL`,
|
||||
(err) => {
|
||||
if (err) {
|
||||
if (err.message.includes('duplicate column')) {
|
||||
console.log('✓ updated_at column already exists');
|
||||
} else {
|
||||
console.error('Error adding updated_at column:', err.message);
|
||||
}
|
||||
} else {
|
||||
console.log('✓ updated_at column added');
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Create submission history table
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS ivanti_fp_submission_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
submission_id INTEGER NOT NULL,
|
||||
user_id INTEGER NOT NULL,
|
||||
username TEXT NOT NULL,
|
||||
change_type TEXT NOT NULL CHECK(change_type IN (
|
||||
'created', 'fields_updated', 'findings_added',
|
||||
'attachments_added', 'status_changed'
|
||||
)),
|
||||
change_details_json TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (submission_id) REFERENCES ivanti_fp_submissions(id) ON DELETE CASCADE
|
||||
)
|
||||
`, (err) => {
|
||||
if (err) console.error('Error creating history table:', err.message);
|
||||
else console.log('✓ ivanti_fp_submission_history table created');
|
||||
});
|
||||
|
||||
// Create index on submission_id for history lookups
|
||||
db.run(
|
||||
`CREATE INDEX IF NOT EXISTS idx_fp_history_submission ON ivanti_fp_submission_history(submission_id)`,
|
||||
(err) => {
|
||||
if (err) console.error('Error creating history index:', err.message);
|
||||
else console.log('✓ idx_fp_history_submission index created');
|
||||
}
|
||||
);
|
||||
|
||||
console.log('✓ Migration statements queued');
|
||||
});
|
||||
|
||||
db.close(() => {
|
||||
console.log('Migration complete!');
|
||||
});
|
||||
57
backend/migrations/add_fp_submissions_table.js
Normal file
57
backend/migrations/add_fp_submissions_table.js
Normal file
@@ -0,0 +1,57 @@
|
||||
// Migration: Add ivanti_fp_submissions table
|
||||
const sqlite3 = require('sqlite3').verbose();
|
||||
const path = require('path');
|
||||
|
||||
const dbPath = path.join(__dirname, '..', 'cve_database.db');
|
||||
const db = new sqlite3.Database(dbPath);
|
||||
|
||||
console.log('Starting ivanti_fp_submissions migration...');
|
||||
|
||||
db.serialize(() => {
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS ivanti_fp_submissions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
username TEXT NOT NULL,
|
||||
ivanti_workflow_batch_id INTEGER,
|
||||
ivanti_generated_id TEXT,
|
||||
workflow_name TEXT NOT NULL,
|
||||
reason TEXT NOT NULL,
|
||||
description TEXT,
|
||||
expiration_date TEXT NOT NULL,
|
||||
scope_override TEXT NOT NULL DEFAULT 'Authorized',
|
||||
finding_ids_json TEXT NOT NULL,
|
||||
queue_item_ids_json TEXT NOT NULL,
|
||||
attachment_count INTEGER DEFAULT 0,
|
||||
attachment_results_json TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'success' CHECK(status IN ('success', 'partial', 'failed')),
|
||||
error_message TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`, (err) => {
|
||||
if (err) console.error('Error creating table:', err);
|
||||
else console.log('✓ ivanti_fp_submissions table created');
|
||||
});
|
||||
|
||||
db.run(
|
||||
'CREATE INDEX IF NOT EXISTS idx_fp_submissions_user ON ivanti_fp_submissions(user_id)',
|
||||
(err) => {
|
||||
if (err) console.error('Error creating index:', err);
|
||||
else console.log('✓ user_id index created');
|
||||
}
|
||||
);
|
||||
|
||||
db.run(
|
||||
'CREATE INDEX IF NOT EXISTS idx_fp_submissions_ivanti_id ON ivanti_fp_submissions(ivanti_generated_id)',
|
||||
(err) => {
|
||||
if (err) console.error('Error creating index:', err);
|
||||
else console.log('✓ ivanti_generated_id index created');
|
||||
}
|
||||
);
|
||||
|
||||
console.log('✓ Migration statements queued');
|
||||
});
|
||||
|
||||
db.close(() => {
|
||||
console.log('Migration complete!');
|
||||
});
|
||||
80
backend/migrations/add_granite_workflow_type.js
Normal file
80
backend/migrations/add_granite_workflow_type.js
Normal file
@@ -0,0 +1,80 @@
|
||||
// Migration: Add GRANITE to workflow_type CHECK constraint on ivanti_todo_queue
|
||||
// SQLite cannot ALTER a CHECK constraint, so this recreates the table.
|
||||
const sqlite3 = require('sqlite3').verbose();
|
||||
const path = require('path');
|
||||
|
||||
const dbPath = path.join(__dirname, '..', 'cve_database.db');
|
||||
const db = new sqlite3.Database(dbPath);
|
||||
|
||||
console.log('Starting add_granite_workflow_type migration...');
|
||||
|
||||
db.serialize(() => {
|
||||
db.run('PRAGMA foreign_keys = OFF', (err) => {
|
||||
if (err) console.error('PRAGMA error:', err);
|
||||
});
|
||||
|
||||
db.run('BEGIN TRANSACTION', (err) => {
|
||||
if (err) { console.error('BEGIN error:', err); return; }
|
||||
});
|
||||
|
||||
db.run(`
|
||||
CREATE TABLE ivanti_todo_queue_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
finding_id TEXT NOT NULL,
|
||||
finding_title TEXT,
|
||||
cves_json TEXT,
|
||||
ip_address TEXT,
|
||||
hostname TEXT,
|
||||
vendor TEXT NOT NULL,
|
||||
workflow_type TEXT NOT NULL CHECK(workflow_type IN ('FP', 'Archer', 'CARD', 'GRANITE')),
|
||||
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending', 'complete')),
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)
|
||||
`, (err) => {
|
||||
if (err) console.error('Error creating new table:', err);
|
||||
else console.log('✓ ivanti_todo_queue_new created');
|
||||
});
|
||||
|
||||
db.run(
|
||||
'INSERT INTO ivanti_todo_queue_new SELECT id, user_id, finding_id, finding_title, cves_json, ip_address, hostname, vendor, workflow_type, status, created_at, updated_at FROM ivanti_todo_queue',
|
||||
(err) => {
|
||||
if (err) console.error('Error copying data:', err);
|
||||
else console.log('✓ Data copied');
|
||||
}
|
||||
);
|
||||
|
||||
db.run('DROP TABLE ivanti_todo_queue', (err) => {
|
||||
if (err) console.error('Error dropping old table:', err);
|
||||
else console.log('✓ Old table dropped');
|
||||
});
|
||||
|
||||
db.run(
|
||||
'ALTER TABLE ivanti_todo_queue_new RENAME TO ivanti_todo_queue',
|
||||
(err) => {
|
||||
if (err) console.error('Error renaming table:', err);
|
||||
else console.log('✓ Table renamed');
|
||||
}
|
||||
);
|
||||
|
||||
db.run(
|
||||
'CREATE INDEX IF NOT EXISTS idx_todo_queue_user ON ivanti_todo_queue(user_id, status)',
|
||||
(err) => {
|
||||
if (err) console.error('Error creating index:', err);
|
||||
else console.log('✓ Index recreated');
|
||||
}
|
||||
);
|
||||
|
||||
db.run('COMMIT', (err) => {
|
||||
if (err) console.error('COMMIT error:', err);
|
||||
else console.log('✓ Transaction committed');
|
||||
});
|
||||
|
||||
db.run('PRAGMA foreign_keys = ON', () => {}); // FIXME: Callback does not handle the error parameter (should be `(err) => { if (err) ... }`)
|
||||
});
|
||||
|
||||
db.close(() => {
|
||||
console.log('Migration complete!');
|
||||
});
|
||||
63
backend/migrations/add_jira_sync_columns.js
Normal file
63
backend/migrations/add_jira_sync_columns.js
Normal file
@@ -0,0 +1,63 @@
|
||||
// Migration: Add Jira API sync columns to jira_tickets table
|
||||
// Adds jira_id, jira_status, and last_synced_at columns to support
|
||||
// live synchronization with Jira Data Center REST API.
|
||||
// Idempotent — safe to run multiple times.
|
||||
|
||||
const sqlite3 = require('sqlite3').verbose();
|
||||
const path = require('path');
|
||||
|
||||
const dbPath = path.join(__dirname, '..', 'cve_database.db');
|
||||
const db = new sqlite3.Database(dbPath);
|
||||
|
||||
console.log('Starting Jira sync columns migration...');
|
||||
|
||||
const newColumns = [
|
||||
{ name: 'jira_id', sql: 'ALTER TABLE jira_tickets ADD COLUMN jira_id TEXT' },
|
||||
{ name: 'jira_status', sql: 'ALTER TABLE jira_tickets ADD COLUMN jira_status TEXT' },
|
||||
{ name: 'last_synced_at', sql: 'ALTER TABLE jira_tickets ADD COLUMN last_synced_at DATETIME' }
|
||||
];
|
||||
|
||||
db.all('PRAGMA table_info(jira_tickets)', (err, columns) => {
|
||||
if (err) {
|
||||
console.error('Could not inspect jira_tickets:', err.message);
|
||||
console.log('Run migrate_jira_tickets.js first to create the table.');
|
||||
db.close();
|
||||
return;
|
||||
}
|
||||
|
||||
const existingNames = new Set(columns.map(c => c.name));
|
||||
let pending = 0;
|
||||
|
||||
db.serialize(() => {
|
||||
newColumns.forEach(({ name, sql }) => {
|
||||
if (existingNames.has(name)) {
|
||||
console.log(`✓ jira_tickets.${name} already exists — skipping`);
|
||||
} else {
|
||||
pending++;
|
||||
db.run(sql, (runErr) => {
|
||||
if (runErr) {
|
||||
console.error(`✗ Failed to add ${name}:`, runErr.message);
|
||||
} else {
|
||||
console.log(`✓ Added jira_tickets.${name}`);
|
||||
}
|
||||
pending--;
|
||||
if (pending === 0) finish();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Create index on jira_id for lookups
|
||||
db.run('CREATE INDEX IF NOT EXISTS idx_jira_tickets_jira_id ON jira_tickets(jira_id)', (idxErr) => {
|
||||
if (idxErr) console.error('Index error:', idxErr.message);
|
||||
else console.log('✓ jira_id index created');
|
||||
});
|
||||
|
||||
if (pending === 0) finish();
|
||||
});
|
||||
});
|
||||
|
||||
function finish() {
|
||||
db.close(() => {
|
||||
console.log('Migration complete!');
|
||||
});
|
||||
}
|
||||
57
backend/migrations/add_return_classification.js
Normal file
57
backend/migrations/add_return_classification.js
Normal file
@@ -0,0 +1,57 @@
|
||||
// Migration: Add return_classification_json column to ivanti_sync_anomaly_log
|
||||
//
|
||||
// Stores the classification breakdown for returned findings (e.g., how many
|
||||
// returned due to BU reassignment back to team, severity re-escalation, etc.)
|
||||
//
|
||||
// Safe to re-run — uses ALTER TABLE with IF NOT EXISTS pattern.
|
||||
//
|
||||
// Usage: node backend/migrations/add_return_classification.js
|
||||
|
||||
const path = require('path');
|
||||
const sqlite3 = require('sqlite3').verbose();
|
||||
const dbPath = path.join(__dirname, '..', 'cve_database.db');
|
||||
const db = new sqlite3.Database(dbPath);
|
||||
|
||||
console.log('Starting return classification migration...');
|
||||
|
||||
function run(sql, params = []) {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.run(sql, params, function (err) {
|
||||
if (err) reject(err);
|
||||
else resolve(this);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function all(sql, params = []) {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.all(sql, params, (err, rows) => {
|
||||
if (err) reject(err);
|
||||
else resolve(rows || []);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function migrate() {
|
||||
// Check if column already exists
|
||||
const columns = await all(`PRAGMA table_info(ivanti_sync_anomaly_log)`);
|
||||
const hasColumn = columns.some(c => c.name === 'return_classification_json');
|
||||
|
||||
if (!hasColumn) {
|
||||
await run(`ALTER TABLE ivanti_sync_anomaly_log ADD COLUMN return_classification_json TEXT NOT NULL DEFAULT '{}'`);
|
||||
console.log('✓ Added return_classification_json column to ivanti_sync_anomaly_log');
|
||||
} else {
|
||||
console.log('✓ return_classification_json column already exists — skipping');
|
||||
}
|
||||
}
|
||||
|
||||
migrate()
|
||||
.then(() => {
|
||||
console.log('Migration complete.');
|
||||
db.close();
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('Migration failed:', err);
|
||||
db.close();
|
||||
process.exit(1);
|
||||
});
|
||||
90
backend/migrations/add_sync_anomaly_tables.js
Normal file
90
backend/migrations/add_sync_anomaly_tables.js
Normal file
@@ -0,0 +1,90 @@
|
||||
// Migration: Add sync anomaly detection and BU drift monitoring tables
|
||||
//
|
||||
// Creates two new tables:
|
||||
// - ivanti_sync_anomaly_log — stores one row per sync cycle with the
|
||||
// anomaly summary breakdown (count deltas, classification, significance).
|
||||
// - ivanti_finding_bu_history — records BU change events detected on
|
||||
// individual findings across syncs.
|
||||
//
|
||||
// Safe to re-run — uses CREATE TABLE IF NOT EXISTS and CREATE INDEX IF NOT EXISTS.
|
||||
//
|
||||
// Usage: node backend/migrations/add_sync_anomaly_tables.js
|
||||
|
||||
const path = require('path');
|
||||
const sqlite3 = require('sqlite3').verbose();
|
||||
const dbPath = path.join(__dirname, '..', 'cve_database.db');
|
||||
const db = new sqlite3.Database(dbPath);
|
||||
|
||||
console.log('Starting sync anomaly tables migration...');
|
||||
|
||||
function run(sql, params = []) {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.run(sql, params, function (err) {
|
||||
if (err) reject(err);
|
||||
else resolve(this);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function all(sql, params = []) {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.all(sql, params, (err, rows) => {
|
||||
if (err) reject(err);
|
||||
else resolve(rows || []);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function migrate() {
|
||||
// 1. Create ivanti_sync_anomaly_log table
|
||||
await run(`
|
||||
CREATE TABLE IF NOT EXISTS ivanti_sync_anomaly_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
sync_timestamp DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
open_count_delta INTEGER NOT NULL DEFAULT 0,
|
||||
closed_count_delta INTEGER NOT NULL DEFAULT 0,
|
||||
newly_archived_count INTEGER NOT NULL DEFAULT 0,
|
||||
returned_count INTEGER NOT NULL DEFAULT 0,
|
||||
classification_json TEXT NOT NULL DEFAULT '{}',
|
||||
is_significant INTEGER NOT NULL DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
console.log('✓ ivanti_sync_anomaly_log table ready');
|
||||
|
||||
// 2. Create ivanti_finding_bu_history table
|
||||
await run(`
|
||||
CREATE TABLE IF NOT EXISTS ivanti_finding_bu_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
finding_id TEXT NOT NULL,
|
||||
finding_title TEXT NOT NULL DEFAULT '',
|
||||
host_name TEXT NOT NULL DEFAULT '',
|
||||
previous_bu TEXT NOT NULL,
|
||||
new_bu TEXT NOT NULL,
|
||||
detected_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
console.log('✓ ivanti_finding_bu_history table ready');
|
||||
|
||||
// 3. Create indexes
|
||||
await run('CREATE INDEX IF NOT EXISTS idx_anomaly_sync_timestamp ON ivanti_sync_anomaly_log(sync_timestamp)');
|
||||
console.log('✓ idx_anomaly_sync_timestamp index ready');
|
||||
|
||||
await run('CREATE INDEX IF NOT EXISTS idx_bu_history_finding_id ON ivanti_finding_bu_history(finding_id)');
|
||||
console.log('✓ idx_bu_history_finding_id index ready');
|
||||
|
||||
await run('CREATE INDEX IF NOT EXISTS idx_bu_history_detected_at ON ivanti_finding_bu_history(detected_at)');
|
||||
console.log('✓ idx_bu_history_detected_at index ready');
|
||||
}
|
||||
|
||||
migrate()
|
||||
.then(() => {
|
||||
console.log('Migration complete.');
|
||||
db.close();
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('Migration failed:', err);
|
||||
db.close();
|
||||
process.exit(1);
|
||||
});
|
||||
25
backend/migrations/add_todo_queue_hostname.js
Normal file
25
backend/migrations/add_todo_queue_hostname.js
Normal file
@@ -0,0 +1,25 @@
|
||||
// Migration: Add hostname column to ivanti_todo_queue
|
||||
const sqlite3 = require('sqlite3').verbose();
|
||||
const path = require('path');
|
||||
|
||||
const dbPath = path.join(__dirname, '..', 'cve_database.db');
|
||||
const db = new sqlite3.Database(dbPath);
|
||||
|
||||
console.log('Starting add_todo_queue_hostname migration...');
|
||||
|
||||
db.run(
|
||||
'ALTER TABLE ivanti_todo_queue ADD COLUMN hostname TEXT',
|
||||
(err) => {
|
||||
if (err) {
|
||||
// Column may already exist if migration was run before
|
||||
if (err.message.includes('duplicate column name')) {
|
||||
console.log('✓ hostname column already exists, skipping');
|
||||
} else {
|
||||
console.error('Error adding column:', err);
|
||||
}
|
||||
} else {
|
||||
console.log('✓ hostname column added');
|
||||
}
|
||||
db.close(() => console.log('Migration complete!'));
|
||||
}
|
||||
);
|
||||
146
backend/migrations/add_user_groups.js
Normal file
146
backend/migrations/add_user_groups.js
Normal file
@@ -0,0 +1,146 @@
|
||||
// Migration: Add user_group column to users table and map legacy roles
|
||||
// Mapping: admin→Admin, editor→Standard_User, viewer→Read_Only
|
||||
// NULL/unrecognized roles default to Read_Only
|
||||
// Idempotent — safe to run multiple times
|
||||
const sqlite3 = require('sqlite3').verbose();
|
||||
const path = require('path');
|
||||
|
||||
/**
|
||||
* Run the migration against the given database instance.
|
||||
* Exported for testing with in-memory databases.
|
||||
* @param {sqlite3.Database} db
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
function runMigration(db) {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.serialize(() => {
|
||||
// Check if user_group column already exists
|
||||
db.all("PRAGMA table_info(users)", (err, columns) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
|
||||
const hasUserGroup = columns.some(col => col.name === 'user_group');
|
||||
|
||||
if (hasUserGroup) {
|
||||
console.log('✓ user_group column already exists — skipping migration');
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Adding user_group column to users table...');
|
||||
|
||||
// SQLite doesn't support ADD COLUMN with CHECK inline in all versions,
|
||||
// so we add the column first, map values, then recreate with constraint.
|
||||
// However, SQLite also doesn't support ALTER TABLE ADD CONSTRAINT.
|
||||
// Strategy: add column, map values, create index.
|
||||
// The CHECK constraint is enforced via table rebuild.
|
||||
|
||||
db.run(
|
||||
`ALTER TABLE users ADD COLUMN user_group VARCHAR(20) NOT NULL DEFAULT 'Read_Only'`,
|
||||
(err) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
console.log('✓ Added user_group column');
|
||||
|
||||
// Map existing roles to groups
|
||||
db.run(
|
||||
`UPDATE users SET user_group = 'Admin' WHERE role = 'admin'`,
|
||||
function(err) {
|
||||
if (err) { reject(err); return; }
|
||||
console.log(` ✓ Mapped ${this.changes} admin(s) → Admin`);
|
||||
|
||||
db.run(
|
||||
`UPDATE users SET user_group = 'Standard_User' WHERE role = 'editor'`,
|
||||
function(err) {
|
||||
if (err) { reject(err); return; }
|
||||
console.log(` ✓ Mapped ${this.changes} editor(s) → Standard_User`);
|
||||
|
||||
db.run(
|
||||
`UPDATE users SET user_group = 'Read_Only' WHERE role = 'viewer'`,
|
||||
function(err) {
|
||||
if (err) { reject(err); return; }
|
||||
console.log(` ✓ Mapped ${this.changes} viewer(s) → Read_Only`);
|
||||
|
||||
// Map NULL or unrecognized roles to Read_Only
|
||||
db.run(
|
||||
`UPDATE users SET user_group = 'Read_Only' WHERE user_group = 'Read_Only' AND role NOT IN ('admin', 'editor', 'viewer')`,
|
||||
function(err) {
|
||||
if (err) { reject(err); return; }
|
||||
console.log(` ✓ Mapped ${this.changes} unrecognized role(s) → Read_Only`);
|
||||
|
||||
// Create index on user_group
|
||||
db.run(
|
||||
`CREATE INDEX IF NOT EXISTS idx_users_user_group ON users(user_group)`,
|
||||
(err) => {
|
||||
if (err) { reject(err); return; }
|
||||
console.log('✓ Created idx_users_user_group index');
|
||||
|
||||
// Add CHECK constraint via trigger (SQLite can't ALTER TABLE ADD CONSTRAINT)
|
||||
db.run(
|
||||
`CREATE TRIGGER IF NOT EXISTS check_user_group_insert
|
||||
BEFORE INSERT ON users
|
||||
FOR EACH ROW
|
||||
WHEN NEW.user_group NOT IN ('Admin', 'Standard_User', 'Leadership', 'Read_Only')
|
||||
BEGIN
|
||||
SELECT RAISE(ABORT, 'Invalid user_group value. Must be Admin, Standard_User, Leadership, or Read_Only');
|
||||
END`,
|
||||
(err) => {
|
||||
if (err) { reject(err); return; }
|
||||
db.run(
|
||||
`CREATE TRIGGER IF NOT EXISTS check_user_group_update
|
||||
BEFORE UPDATE OF user_group ON users
|
||||
FOR EACH ROW
|
||||
WHEN NEW.user_group NOT IN ('Admin', 'Standard_User', 'Leadership', 'Read_Only')
|
||||
BEGIN
|
||||
SELECT RAISE(ABORT, 'Invalid user_group value. Must be Admin, Standard_User, Leadership, or Read_Only');
|
||||
END`,
|
||||
(err) => {
|
||||
if (err) { reject(err); return; }
|
||||
console.log('✓ Created user_group validation triggers');
|
||||
console.log('Migration complete!');
|
||||
resolve();
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Run directly if executed as a script
|
||||
if (require.main === module) {
|
||||
const dbPath = path.join(__dirname, '..', 'cve_database.db');
|
||||
const db = new sqlite3.Database(dbPath);
|
||||
console.log('Starting add_user_groups migration...');
|
||||
|
||||
runMigration(db)
|
||||
.then(() => {
|
||||
db.close(() => {
|
||||
console.log('Database connection closed.');
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('Migration failed:', err);
|
||||
db.close();
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { runMigration };
|
||||
160
backend/migrations/backfill_anomaly_log.js
Normal file
160
backend/migrations/backfill_anomaly_log.js
Normal file
@@ -0,0 +1,160 @@
|
||||
#!/usr/bin/env node
|
||||
// backfill_anomaly_log.js — One-time backfill of ivanti_sync_anomaly_log
|
||||
//
|
||||
// Synthesizes anomaly log entries from existing ivanti_archive_transitions
|
||||
// and ivanti_counts_history data so the archive activity sparkline on the
|
||||
// Findings Trend chart has historical data to display.
|
||||
//
|
||||
// Safe to run multiple times — checks for existing rows before inserting.
|
||||
//
|
||||
// Usage: node backend/migrations/backfill_anomaly_log.js
|
||||
|
||||
const path = require('path');
|
||||
const sqlite3 = require('sqlite3').verbose();
|
||||
|
||||
const DB_PATH = path.join(__dirname, '..', 'cve_database.db');
|
||||
|
||||
function dbAll(db, sql, params = []) {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.all(sql, params, (err, rows) => {
|
||||
if (err) reject(err);
|
||||
else resolve(rows || []);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function dbGet(db, sql, params = []) {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.get(sql, params, (err, row) => {
|
||||
if (err) reject(err);
|
||||
else resolve(row);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function dbRun(db, sql, params = []) {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.run(sql, params, function (err) {
|
||||
if (err) reject(err);
|
||||
else resolve(this);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const db = new sqlite3.Database(DB_PATH);
|
||||
|
||||
// Check if anomaly log already has data
|
||||
const existing = await dbGet(db, 'SELECT COUNT(*) as cnt FROM ivanti_sync_anomaly_log');
|
||||
if (existing.cnt > 0) {
|
||||
console.log(`ivanti_sync_anomaly_log already has ${existing.cnt} rows — skipping backfill.`);
|
||||
console.log('To force re-run, delete existing rows first:');
|
||||
console.log(' sqlite3 backend/cve_database.db "DELETE FROM ivanti_sync_anomaly_log;"');
|
||||
db.close();
|
||||
return;
|
||||
}
|
||||
|
||||
// Get archive transitions grouped by date
|
||||
const transitions = await dbAll(db,
|
||||
`SELECT DATE(transitioned_at) as date,
|
||||
to_state,
|
||||
reason,
|
||||
COUNT(*) as cnt
|
||||
FROM ivanti_archive_transitions
|
||||
GROUP BY date, to_state, reason
|
||||
ORDER BY date`
|
||||
);
|
||||
|
||||
// Get counts history (last snapshot per day) for delta computation
|
||||
const countsRows = await dbAll(db,
|
||||
`SELECT date, open_count, closed_count FROM (
|
||||
SELECT DATE(recorded_at) AS date,
|
||||
open_count, closed_count,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY DATE(recorded_at)
|
||||
ORDER BY recorded_at DESC
|
||||
) AS rn
|
||||
FROM ivanti_counts_history
|
||||
) WHERE rn = 1
|
||||
ORDER BY date ASC`
|
||||
);
|
||||
|
||||
// Build a map of date -> { open_count, closed_count }
|
||||
const countsMap = {};
|
||||
for (const row of countsRows) {
|
||||
countsMap[row.date] = { open: row.open_count, closed: row.closed_count };
|
||||
}
|
||||
|
||||
// Build per-date anomaly summaries from transitions
|
||||
const dateMap = {};
|
||||
for (const t of transitions) {
|
||||
if (!dateMap[t.date]) {
|
||||
dateMap[t.date] = { archived: 0, returned: 0, classification: {} };
|
||||
}
|
||||
const entry = dateMap[t.date];
|
||||
|
||||
if (t.to_state === 'ARCHIVED') {
|
||||
entry.archived += t.cnt;
|
||||
// All pre-feature transitions have reason 'severity_score_drift'
|
||||
// but from the investigation we know the 04/24 batch was mostly
|
||||
// BU reassignment. We can't retroactively classify without the
|
||||
// Ivanti API, so we label them as 'unclassified' (pre-feature).
|
||||
entry.classification.unclassified = (entry.classification.unclassified || 0) + t.cnt;
|
||||
} else if (t.to_state === 'RETURNED') {
|
||||
entry.returned += t.cnt;
|
||||
}
|
||||
// CLOSED transitions are not archive events — they're findings
|
||||
// confirmed in the closed set, so we don't count them as archived.
|
||||
}
|
||||
|
||||
// Compute deltas and insert rows
|
||||
const dates = Object.keys(dateMap).sort();
|
||||
let inserted = 0;
|
||||
|
||||
for (const date of dates) {
|
||||
const entry = dateMap[date];
|
||||
const counts = countsMap[date];
|
||||
|
||||
// Find the previous day's counts for delta computation
|
||||
const dateIdx = countsRows.findIndex(r => r.date === date);
|
||||
let openDelta = 0;
|
||||
let closedDelta = 0;
|
||||
|
||||
if (counts && dateIdx > 0) {
|
||||
const prev = countsRows[dateIdx - 1];
|
||||
openDelta = counts.open - prev.open_count;
|
||||
closedDelta = counts.closed - prev.closed_count;
|
||||
}
|
||||
|
||||
const isSignificant = entry.archived > 5 ? 1 : 0;
|
||||
const classificationJson = JSON.stringify(entry.classification);
|
||||
|
||||
await dbRun(db,
|
||||
`INSERT INTO ivanti_sync_anomaly_log
|
||||
(sync_timestamp, open_count_delta, closed_count_delta,
|
||||
newly_archived_count, returned_count, classification_json, is_significant)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
`${date}T23:59:00`,
|
||||
openDelta,
|
||||
closedDelta,
|
||||
entry.archived,
|
||||
entry.returned,
|
||||
classificationJson,
|
||||
isSignificant,
|
||||
]
|
||||
);
|
||||
inserted++;
|
||||
|
||||
const sigLabel = isSignificant ? ' [SIGNIFICANT]' : '';
|
||||
console.log(` ${date}: ${entry.archived} archived, ${entry.returned} returned, delta open=${openDelta} closed=${closedDelta}${sigLabel}`);
|
||||
}
|
||||
|
||||
console.log(`\nBackfill complete: ${inserted} anomaly log entries created.`);
|
||||
db.close();
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error('Fatal error:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
165
backend/migrations/backfill_return_classification.js
Normal file
165
backend/migrations/backfill_return_classification.js
Normal file
@@ -0,0 +1,165 @@
|
||||
#!/usr/bin/env node
|
||||
// backfill_return_classification.js
|
||||
//
|
||||
// Retroactively populates return_classification_json for existing anomaly log
|
||||
// rows that have returned_count > 0 but an empty return classification.
|
||||
//
|
||||
// For each such row, looks at archive transitions that went ARCHIVED → RETURNED
|
||||
// on that date, then finds the *prior* archive reason (the most recent
|
||||
// transition to ARCHIVED for that same archive record) to determine why the
|
||||
// finding originally left — which tells us why it came back.
|
||||
//
|
||||
// Safe to run multiple times — only updates rows with empty classification.
|
||||
//
|
||||
// Usage: node backend/migrations/backfill_return_classification.js
|
||||
|
||||
const path = require('path');
|
||||
const sqlite3 = require('sqlite3').verbose();
|
||||
|
||||
const DB_PATH = path.join(__dirname, '..', 'cve_database.db');
|
||||
|
||||
function dbAll(db, sql, params = []) {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.all(sql, params, (err, rows) => {
|
||||
if (err) reject(err);
|
||||
else resolve(rows || []);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function dbGet(db, sql, params = []) {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.get(sql, params, (err, row) => {
|
||||
if (err) reject(err);
|
||||
else resolve(row);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function dbRun(db, sql, params = []) {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.run(sql, params, function (err) {
|
||||
if (err) reject(err);
|
||||
else resolve(this);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const db = new sqlite3.Database(DB_PATH);
|
||||
|
||||
// Find anomaly log rows that have returned findings but no return classification
|
||||
const rows = await dbAll(db,
|
||||
`SELECT id, sync_timestamp, returned_count, return_classification_json
|
||||
FROM ivanti_sync_anomaly_log
|
||||
WHERE returned_count > 0
|
||||
ORDER BY sync_timestamp ASC`
|
||||
);
|
||||
|
||||
if (rows.length === 0) {
|
||||
console.log('No anomaly log rows with returned findings found — nothing to backfill.');
|
||||
db.close();
|
||||
return;
|
||||
}
|
||||
|
||||
const force = process.argv.includes('--force');
|
||||
let updated = 0;
|
||||
let skipped = 0;
|
||||
|
||||
for (const row of rows) {
|
||||
// Skip if already has a non-empty classification (unless --force)
|
||||
if (!force) {
|
||||
let existing = {};
|
||||
try { existing = JSON.parse(row.return_classification_json || '{}'); } catch (_) {}
|
||||
const hasData = Object.values(existing).some(v => v > 0);
|
||||
if (hasData) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Find the date of this anomaly row
|
||||
const date = row.sync_timestamp.split('T')[0].split(' ')[0];
|
||||
|
||||
// Find all ARCHIVED → RETURNED transitions on this date
|
||||
const returnTransitions = await dbAll(db,
|
||||
`SELECT archive_id
|
||||
FROM ivanti_archive_transitions
|
||||
WHERE to_state = 'RETURNED'
|
||||
AND DATE(transitioned_at) = ?`,
|
||||
[date]
|
||||
);
|
||||
|
||||
if (returnTransitions.length === 0) {
|
||||
// No transitions found for this date — try a wider window (±1 day)
|
||||
// since sync_timestamp and transitioned_at might not align exactly
|
||||
const wider = await dbAll(db,
|
||||
`SELECT archive_id
|
||||
FROM ivanti_archive_transitions
|
||||
WHERE to_state = 'RETURNED'
|
||||
AND DATE(transitioned_at) BETWEEN DATE(?, '-1 day') AND DATE(?, '+1 day')`,
|
||||
[date, date]
|
||||
);
|
||||
if (wider.length === 0) {
|
||||
console.log(` ${date}: ${row.returned_count} returned but no matching transitions found — skipping`);
|
||||
continue;
|
||||
}
|
||||
returnTransitions.push(...wider);
|
||||
}
|
||||
|
||||
// For each returned finding, look up the prior archive reason
|
||||
const classification = { bu_reassignment: 0, severity_drift: 0, closed_on_platform: 0, decommissioned: 0 };
|
||||
const seen = new Set();
|
||||
|
||||
for (const rt of returnTransitions) {
|
||||
if (seen.has(rt.archive_id)) continue;
|
||||
seen.add(rt.archive_id);
|
||||
|
||||
// Find the most recent ARCHIVED transition *before* this return
|
||||
// (the reason it was archived before it came back)
|
||||
const archiveTransition = await dbGet(db,
|
||||
`SELECT reason FROM ivanti_archive_transitions
|
||||
WHERE archive_id = ? AND to_state = 'ARCHIVED'
|
||||
AND transitioned_at <= (
|
||||
SELECT transitioned_at FROM ivanti_archive_transitions
|
||||
WHERE archive_id = ? AND to_state = 'RETURNED'
|
||||
AND DATE(transitioned_at) BETWEEN DATE(?, '-1 day') AND DATE(?, '+1 day')
|
||||
ORDER BY transitioned_at DESC LIMIT 1
|
||||
)
|
||||
ORDER BY transitioned_at DESC LIMIT 1`,
|
||||
[rt.archive_id, rt.archive_id, date, date]
|
||||
);
|
||||
|
||||
if (archiveTransition && archiveTransition.reason) {
|
||||
const reasonKey = archiveTransition.reason.split(':')[0];
|
||||
if (reasonKey in classification) {
|
||||
classification[reasonKey]++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const classificationJson = JSON.stringify(classification);
|
||||
await dbRun(db,
|
||||
`UPDATE ivanti_sync_anomaly_log
|
||||
SET return_classification_json = ?
|
||||
WHERE id = ?`,
|
||||
[classificationJson, row.id]
|
||||
);
|
||||
|
||||
const parts = Object.entries(classification)
|
||||
.filter(([, v]) => v > 0)
|
||||
.map(([k, v]) => `${v} ${k}`);
|
||||
const breakdown = parts.length > 0 ? parts.join(', ') : 'unclassified';
|
||||
|
||||
console.log(` ${date}: ${row.returned_count} returned — ${breakdown}`);
|
||||
updated++;
|
||||
}
|
||||
|
||||
console.log(`\nBackfill complete: ${updated} rows updated, ${skipped} already had data.`);
|
||||
db.close();
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error('Fatal error:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
102
backend/migrations/reclassify_bu_roundtrips.js
Normal file
102
backend/migrations/reclassify_bu_roundtrips.js
Normal file
@@ -0,0 +1,102 @@
|
||||
#!/usr/bin/env node
|
||||
// reclassify_bu_roundtrips.js
|
||||
//
|
||||
// Reclassifies archive transitions that were part of a BU reassignment
|
||||
// round-trip. These are findings that were archived (disappeared from sync)
|
||||
// and then returned within a short window — indicating they were temporarily
|
||||
// reassigned to a different BU and then reassigned back.
|
||||
//
|
||||
// The original drift checker couldn't classify these correctly because by the
|
||||
// time it queried Ivanti, the findings had already been reassigned back to
|
||||
// the expected BUs.
|
||||
//
|
||||
// After running this, re-run backfill_return_classification.js to update
|
||||
// the anomaly log with the corrected reasons.
|
||||
//
|
||||
// Usage: node backend/migrations/reclassify_bu_roundtrips.js
|
||||
|
||||
const path = require('path');
|
||||
const sqlite3 = require('sqlite3').verbose();
|
||||
|
||||
const DB_PATH = path.join(__dirname, '..', 'cve_database.db');
|
||||
|
||||
// Findings that were archived and returned within this many days are
|
||||
// considered BU reassignment round-trips
|
||||
const ROUNDTRIP_WINDOW_DAYS = 14;
|
||||
|
||||
function dbAll(db, sql, params = []) {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.all(sql, params, (err, rows) => {
|
||||
if (err) reject(err);
|
||||
else resolve(rows || []);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function dbRun(db, sql, params = []) {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.run(sql, params, function (err) {
|
||||
if (err) reject(err);
|
||||
else resolve(this);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const db = new sqlite3.Database(DB_PATH);
|
||||
|
||||
// Find archive transitions where the finding was archived and then returned
|
||||
// within the roundtrip window, and the archive reason is still the default
|
||||
// severity_score_drift placeholder
|
||||
const roundtrips = await dbAll(db, `
|
||||
SELECT
|
||||
t_arch.id AS archive_transition_id,
|
||||
t_arch.archive_id,
|
||||
a.finding_id,
|
||||
a.finding_title,
|
||||
t_arch.reason AS current_reason,
|
||||
DATE(t_arch.transitioned_at) AS archived_date,
|
||||
DATE(t_ret.transitioned_at) AS returned_date,
|
||||
JULIANDAY(t_ret.transitioned_at) - JULIANDAY(t_arch.transitioned_at) AS days_between
|
||||
FROM ivanti_archive_transitions t_arch
|
||||
JOIN ivanti_finding_archives a ON a.id = t_arch.archive_id
|
||||
JOIN ivanti_archive_transitions t_ret
|
||||
ON t_ret.archive_id = t_arch.archive_id
|
||||
AND t_ret.to_state = 'RETURNED'
|
||||
AND t_ret.transitioned_at > t_arch.transitioned_at
|
||||
WHERE t_arch.to_state = 'ARCHIVED'
|
||||
AND t_arch.reason = 'severity_score_drift'
|
||||
AND (JULIANDAY(t_ret.transitioned_at) - JULIANDAY(t_arch.transitioned_at)) BETWEEN 0 AND ?
|
||||
ORDER BY t_arch.transitioned_at DESC
|
||||
`, [ROUNDTRIP_WINDOW_DAYS]);
|
||||
|
||||
if (roundtrips.length === 0) {
|
||||
console.log('No BU reassignment round-trips found to reclassify.');
|
||||
db.close();
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Found ${roundtrips.length} archive transitions to reclassify as bu_reassignment:\n`);
|
||||
|
||||
let updated = 0;
|
||||
for (const rt of roundtrips) {
|
||||
console.log(` Finding ${rt.finding_id}: archived ${rt.archived_date}, returned ${rt.returned_date} (${Math.round(rt.days_between)}d) — ${rt.current_reason} → bu_reassignment`);
|
||||
|
||||
await dbRun(db,
|
||||
`UPDATE ivanti_archive_transitions SET reason = 'bu_reassignment' WHERE id = ?`,
|
||||
[rt.archive_transition_id]
|
||||
);
|
||||
updated++;
|
||||
}
|
||||
|
||||
console.log(`\nReclassified ${updated} transitions.`);
|
||||
console.log('\nNow run the return classification backfill to update anomaly log rows:');
|
||||
console.log(' node backend/migrations/backfill_return_classification.js');
|
||||
|
||||
db.close();
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error('Fatal error:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
// routes/archerTickets.js
|
||||
const express = require('express');
|
||||
const { requireAuth, requireRole } = require('../middleware/auth');
|
||||
const { requireAuth, requireGroup } = require('../middleware/auth');
|
||||
const logAudit = require('../helpers/auditLog');
|
||||
|
||||
// Validation helpers
|
||||
@@ -48,7 +48,7 @@ function createArcherTicketsRouter(db) {
|
||||
});
|
||||
|
||||
// Create Archer ticket
|
||||
router.post('/', requireAuth(db), requireRole('editor', 'admin'), (req, res) => {
|
||||
router.post('/', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
|
||||
const { exc_number, archer_url, status, cve_id, vendor } = req.body;
|
||||
|
||||
// Validation
|
||||
@@ -74,9 +74,9 @@ function createArcherTicketsRouter(db) {
|
||||
const validatedStatus = status || 'Draft';
|
||||
|
||||
db.run(
|
||||
`INSERT INTO archer_tickets (exc_number, archer_url, status, cve_id, vendor)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
[exc_number.trim(), archer_url || null, validatedStatus, cve_id, vendor],
|
||||
`INSERT INTO archer_tickets (exc_number, archer_url, status, cve_id, vendor, created_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
[exc_number.trim(), archer_url || null, validatedStatus, cve_id, vendor, req.user.id],
|
||||
function(err) {
|
||||
if (err) {
|
||||
console.error('Error creating Archer ticket:', err);
|
||||
@@ -89,8 +89,8 @@ function createArcherTicketsRouter(db) {
|
||||
logAudit(db, {
|
||||
userId: req.user.id,
|
||||
action: 'CREATE_ARCHER_TICKET',
|
||||
targetType: 'archer_ticket',
|
||||
targetId: this.lastID,
|
||||
entityType: 'archer_ticket',
|
||||
entityId: String(this.lastID),
|
||||
details: { exc_number, archer_url, status: validatedStatus, cve_id, vendor },
|
||||
ipAddress: req.ip
|
||||
});
|
||||
@@ -104,7 +104,7 @@ function createArcherTicketsRouter(db) {
|
||||
});
|
||||
|
||||
// Update Archer ticket
|
||||
router.put('/:id', requireAuth(db), requireRole('editor', 'admin'), (req, res) => {
|
||||
router.put('/:id', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
|
||||
const { id } = req.params;
|
||||
const { exc_number, archer_url, status } = req.body;
|
||||
|
||||
@@ -172,8 +172,8 @@ function createArcherTicketsRouter(db) {
|
||||
logAudit(db, {
|
||||
userId: req.user.id,
|
||||
action: 'UPDATE_ARCHER_TICKET',
|
||||
targetType: 'archer_ticket',
|
||||
targetId: id,
|
||||
entityType: 'archer_ticket',
|
||||
entityId: String(id),
|
||||
details: { before: existing, changes: req.body },
|
||||
ipAddress: req.ip
|
||||
});
|
||||
@@ -184,8 +184,29 @@ function createArcherTicketsRouter(db) {
|
||||
});
|
||||
});
|
||||
|
||||
// Helper: perform the actual Archer ticket deletion
|
||||
function performArcherDelete(db, req, res, id, ticket) {
|
||||
db.run('DELETE FROM archer_tickets WHERE id = ?', [id], function(err) {
|
||||
if (err) {
|
||||
console.error(err);
|
||||
return res.status(500).json({ error: 'Internal server error.' });
|
||||
}
|
||||
|
||||
logAudit(db, {
|
||||
userId: req.user.id,
|
||||
action: 'DELETE_ARCHER_TICKET',
|
||||
entityType: 'archer_ticket',
|
||||
entityId: String(id),
|
||||
details: { deleted: ticket },
|
||||
ipAddress: req.ip
|
||||
});
|
||||
|
||||
res.json({ message: 'Archer ticket deleted successfully' });
|
||||
});
|
||||
}
|
||||
|
||||
// Delete Archer ticket
|
||||
router.delete('/:id', requireAuth(db), requireRole('editor', 'admin'), (req, res) => {
|
||||
router.delete('/:id', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
|
||||
const { id } = req.params;
|
||||
|
||||
db.get('SELECT * FROM archer_tickets WHERE id = ?', [id], (err, ticket) => {
|
||||
@@ -197,23 +218,45 @@ function createArcherTicketsRouter(db) {
|
||||
return res.status(404).json({ error: 'Archer ticket not found.' });
|
||||
}
|
||||
|
||||
db.run('DELETE FROM archer_tickets WHERE id = ?', [id], function(err) {
|
||||
if (err) {
|
||||
console.error(err);
|
||||
return res.status(500).json({ error: 'Internal server error.' });
|
||||
// Admin bypasses all delete restrictions
|
||||
if (req.user.group === 'Admin') {
|
||||
return performArcherDelete(db, req, res, id, ticket);
|
||||
}
|
||||
|
||||
// Standard_User: ownership check
|
||||
if (ticket.created_by && ticket.created_by !== req.user.id) {
|
||||
return res.status(403).json({ error: 'You can only delete resources you created' });
|
||||
}
|
||||
|
||||
// Standard_User: compliance linkage check
|
||||
const excNumber = ticket.exc_number;
|
||||
db.all(
|
||||
`SELECT ci.id, ci.extra_json
|
||||
FROM compliance_items ci
|
||||
JOIN compliance_uploads cu ON ci.upload_id = cu.id
|
||||
WHERE ci.status = 'active' AND ci.extra_json LIKE ?`,
|
||||
[`%${excNumber}%`],
|
||||
(compErr, compLinks) => {
|
||||
// If compliance_items table doesn't exist yet, treat as no linkage
|
||||
if (compErr && compErr.message && compErr.message.includes('no such table')) {
|
||||
compLinks = [];
|
||||
} else if (compErr) {
|
||||
console.error(compErr);
|
||||
return res.status(500).json({ error: 'Internal server error.' });
|
||||
}
|
||||
|
||||
const isLinked = (compLinks || []).some(cl => {
|
||||
const json = cl.extra_json || '';
|
||||
return json.includes(excNumber);
|
||||
});
|
||||
|
||||
if (isLinked) {
|
||||
return res.status(403).json({ error: 'Cannot delete ticket linked to compliance report. Contact an admin.' });
|
||||
}
|
||||
|
||||
return performArcherDelete(db, req, res, id, ticket);
|
||||
}
|
||||
|
||||
logAudit(db, {
|
||||
userId: req.user.id,
|
||||
action: 'DELETE_ARCHER_TICKET',
|
||||
targetType: 'archer_ticket',
|
||||
targetId: id,
|
||||
details: { deleted: ticket },
|
||||
ipAddress: req.ip
|
||||
});
|
||||
|
||||
res.json({ message: 'Archer ticket deleted successfully' });
|
||||
});
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
587
backend/routes/atlas.js
Normal file
587
backend/routes/atlas.js
Normal file
@@ -0,0 +1,587 @@
|
||||
// Atlas InfoSec Action Plans Routes
|
||||
// Proxies CRUD operations to the Atlas API and maintains a local SQLite cache
|
||||
// for fast badge rendering on the ReportingPage.
|
||||
|
||||
const express = require('express');
|
||||
const { requireGroup } = require('../middleware/auth');
|
||||
const logAudit = require('../helpers/auditLog');
|
||||
const { isConfigured, atlasGet, atlasPut, atlasPatch, atlasPost } = require('../helpers/atlasApi');
|
||||
|
||||
const VALID_PLAN_TYPES = ['decommission', 'remediation', 'false_positive', 'risk_acceptance', 'scan_exclusion'];
|
||||
const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DB helpers — promise wrappers for callback-based SQLite API
|
||||
// ---------------------------------------------------------------------------
|
||||
function dbRun(db, sql, params = []) {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.run(sql, params, function (err) { if (err) reject(err); else resolve(this); });
|
||||
});
|
||||
}
|
||||
|
||||
function dbGet(db, sql, params = []) {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.get(sql, params, (err, row) => { if (err) reject(err); else resolve(row); });
|
||||
});
|
||||
}
|
||||
|
||||
function dbAll(db, sql, params = []) {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.all(sql, params, (err, rows) => { if (err) reject(err); else resolve(rows || []); });
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pure aggregation function — exported for testability
|
||||
// ---------------------------------------------------------------------------
|
||||
function aggregateAtlasMetrics(rows) {
|
||||
const result = {
|
||||
totalHosts: rows.length,
|
||||
hostsWithPlans: 0,
|
||||
hostsWithoutPlans: 0,
|
||||
plansByType: {},
|
||||
plansByStatus: {},
|
||||
totalPlans: 0
|
||||
};
|
||||
|
||||
for (const row of rows) {
|
||||
if (row.has_action_plan === 1) {
|
||||
result.hostsWithPlans++;
|
||||
} else {
|
||||
result.hostsWithoutPlans++;
|
||||
}
|
||||
|
||||
let plans;
|
||||
try {
|
||||
plans = JSON.parse(row.plans_json);
|
||||
} catch (e) {
|
||||
// Invalid JSON — skip plan details for this row
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!Array.isArray(plans)) continue;
|
||||
|
||||
for (const plan of plans) {
|
||||
result.totalPlans++;
|
||||
|
||||
if (plan.plan_type) {
|
||||
result.plansByType[plan.plan_type] = (result.plansByType[plan.plan_type] || 0) + 1;
|
||||
}
|
||||
|
||||
if (plan.status) {
|
||||
result.plansByStatus[plan.status] = (result.plansByStatus[plan.status] || 0) + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Router factory
|
||||
// ---------------------------------------------------------------------------
|
||||
function createAtlasRouter(db, requireAuth) {
|
||||
const router = express.Router();
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// GET /metrics
|
||||
// Return aggregated Atlas metrics for chart rendering.
|
||||
// Auth: any authenticated user
|
||||
//
|
||||
// Response 200:
|
||||
// { totalHosts: number, hostsWithPlans: number, hostsWithoutPlans: number,
|
||||
// plansByType: { [type: string]: number }, plansByStatus: { [status: string]: number },
|
||||
// totalPlans: number }
|
||||
// Response 503: { error: string } — Atlas not configured
|
||||
// Response 500: { error: string } — DB query failure
|
||||
// -----------------------------------------------------------------------
|
||||
router.get('/metrics', requireAuth(db), async (req, res) => {
|
||||
if (!isConfigured) {
|
||||
return res.status(503).json({ error: 'Atlas API is not configured. Check ATLAS_API_URL, ATLAS_API_USER, and ATLAS_API_PASS environment variables.' });
|
||||
}
|
||||
|
||||
try {
|
||||
const rows = await dbAll(db,
|
||||
`SELECT has_action_plan, plans_json FROM atlas_action_plans_cache`
|
||||
);
|
||||
const metrics = aggregateAtlasMetrics(rows);
|
||||
res.json(metrics);
|
||||
} catch (err) {
|
||||
console.error('[Atlas] Error fetching metrics:', err.message);
|
||||
res.status(500).json({ error: 'Failed to fetch Atlas metrics.' });
|
||||
}
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// GET /status
|
||||
// Return all cached Atlas rows for badge rendering.
|
||||
// Auth: any authenticated user
|
||||
//
|
||||
// Response 200:
|
||||
// [ { host_id: number, has_action_plan: 0|1, plan_count: number, synced_at: string }, ... ]
|
||||
// Response 503: { error: string } — Atlas not configured
|
||||
// Response 500: { error: string } — DB query failure
|
||||
// -----------------------------------------------------------------------
|
||||
router.get('/status', requireAuth(db), async (req, res) => {
|
||||
if (!isConfigured) {
|
||||
return res.status(503).json({ error: 'Atlas API is not configured. Check ATLAS_API_URL, ATLAS_API_USER, and ATLAS_API_PASS environment variables.' });
|
||||
}
|
||||
|
||||
try {
|
||||
const rows = await dbAll(db,
|
||||
`SELECT host_id, has_action_plan, plan_count, synced_at FROM atlas_action_plans_cache`
|
||||
);
|
||||
res.json(rows);
|
||||
} catch (err) {
|
||||
console.error('[Atlas] Error fetching status:', err.message);
|
||||
res.status(500).json({ error: 'Failed to fetch Atlas status.' });
|
||||
}
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// POST /sync
|
||||
// Sync Atlas action plan data for all hosts found in the Ivanti cache.
|
||||
// Auth: Admin or Standard_User
|
||||
//
|
||||
// Request body: none
|
||||
// Response 200:
|
||||
// { synced: number, withPlans: number, failed: number }
|
||||
// Response 503: { error: string } — Atlas not configured
|
||||
// Response 500: { error: string } — sync failure or Ivanti cache parse error
|
||||
// -----------------------------------------------------------------------
|
||||
router.post('/sync', requireAuth(db), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||
if (!isConfigured) {
|
||||
return res.status(503).json({ error: 'Atlas API is not configured. Check ATLAS_API_URL, ATLAS_API_USER, and ATLAS_API_PASS environment variables.' });
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. Read Ivanti findings cache and extract unique non-null hostIds
|
||||
const cacheRow = await dbGet(db, `SELECT findings_json FROM ivanti_findings_cache WHERE id = 1`);
|
||||
if (!cacheRow || !cacheRow.findings_json) {
|
||||
return res.json({ synced: 0, withPlans: 0, failed: 0 });
|
||||
}
|
||||
|
||||
let findings;
|
||||
try {
|
||||
findings = JSON.parse(cacheRow.findings_json);
|
||||
} catch (parseErr) {
|
||||
return res.status(500).json({ error: 'Failed to parse Ivanti findings cache.' });
|
||||
}
|
||||
|
||||
const hostIdSet = new Set();
|
||||
for (const f of findings) {
|
||||
if (f.hostId != null && typeof f.hostId === 'number' && f.hostId > 0) {
|
||||
hostIdSet.add(f.hostId);
|
||||
}
|
||||
}
|
||||
const hostIds = [...hostIdSet];
|
||||
|
||||
if (hostIds.length === 0) {
|
||||
return res.json({ synced: 0, withPlans: 0, failed: 0 });
|
||||
}
|
||||
|
||||
// 2. Process hosts in batches of 5 concurrent requests
|
||||
let synced = 0;
|
||||
let withPlans = 0;
|
||||
let failed = 0;
|
||||
const BATCH_SIZE = 5;
|
||||
|
||||
for (let i = 0; i < hostIds.length; i += BATCH_SIZE) {
|
||||
const batch = hostIds.slice(i, i + BATCH_SIZE);
|
||||
const results = await Promise.allSettled(
|
||||
batch.map(async (hostId) => {
|
||||
const result = await atlasGet('/hosts/' + hostId + '/action-plans');
|
||||
return { hostId, result };
|
||||
})
|
||||
);
|
||||
|
||||
for (const settled of results) {
|
||||
if (settled.status === 'rejected') {
|
||||
failed++;
|
||||
console.warn('[Atlas Sync] Request failed for host:', settled.reason?.message || settled.reason);
|
||||
continue;
|
||||
}
|
||||
|
||||
const { hostId, result } = settled.value;
|
||||
|
||||
if (result.status >= 200 && result.status < 300) {
|
||||
let allPlans = [];
|
||||
let activePlans = [];
|
||||
try {
|
||||
const parsed = JSON.parse(result.body);
|
||||
// Atlas returns { active: [...], inactive: [...] }
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
activePlans = Array.isArray(parsed.active) ? parsed.active : [];
|
||||
const inactive = Array.isArray(parsed.inactive) ? parsed.inactive : [];
|
||||
allPlans = [...activePlans, ...inactive];
|
||||
} else if (Array.isArray(parsed)) {
|
||||
allPlans = parsed;
|
||||
activePlans = parsed;
|
||||
}
|
||||
} catch (e) {
|
||||
allPlans = [];
|
||||
activePlans = [];
|
||||
}
|
||||
|
||||
// Badge counts only active plans — inactive are historical
|
||||
const planCount = activePlans.length;
|
||||
const hasActionPlan = planCount > 0 ? 1 : 0;
|
||||
|
||||
try {
|
||||
await dbRun(db,
|
||||
`INSERT INTO atlas_action_plans_cache (host_id, has_action_plan, plan_count, plans_json, synced_at)
|
||||
VALUES (?, ?, ?, ?, datetime('now'))
|
||||
ON CONFLICT(host_id) DO UPDATE SET
|
||||
has_action_plan = excluded.has_action_plan,
|
||||
plan_count = excluded.plan_count,
|
||||
plans_json = excluded.plans_json,
|
||||
synced_at = excluded.synced_at`,
|
||||
[hostId, hasActionPlan, planCount, JSON.stringify(allPlans)]
|
||||
);
|
||||
} catch (dbErr) {
|
||||
console.error('[Atlas Sync] DB upsert failed for host', hostId, ':', dbErr.message);
|
||||
}
|
||||
|
||||
synced++;
|
||||
if (hasActionPlan) withPlans++;
|
||||
} else {
|
||||
failed++;
|
||||
console.warn(`[Atlas Sync] Non-2xx response for host ${hostId}: status ${result.status}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Log audit entry
|
||||
logAudit(db, {
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'ATLAS_SYNC',
|
||||
entityType: 'atlas_action_plans',
|
||||
entityId: null,
|
||||
details: { synced, withPlans, failed, totalHosts: hostIds.length },
|
||||
ipAddress: req.ip
|
||||
});
|
||||
|
||||
res.json({ synced, withPlans, failed });
|
||||
} catch (err) {
|
||||
console.error('[Atlas Sync] Unexpected error:', err.message);
|
||||
res.status(500).json({ error: 'Atlas sync failed: ' + err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// GET /hosts/:hostId/action-plans
|
||||
// Proxy to Atlas API — returns live action plan data for a single host.
|
||||
// Auth: any authenticated user
|
||||
//
|
||||
// Params: hostId (positive integer)
|
||||
// Response 2xx: proxied Atlas response body (parsed JSON or raw)
|
||||
// Response 400: { error: string } — invalid hostId
|
||||
// Response 503: { error: string } — Atlas not configured
|
||||
// Response 502: { error: string } — Atlas API unreachable
|
||||
// -----------------------------------------------------------------------
|
||||
router.get('/hosts/:hostId/action-plans', requireAuth(db), async (req, res) => {
|
||||
if (!isConfigured) {
|
||||
return res.status(503).json({ error: 'Atlas API is not configured. Check ATLAS_API_URL, ATLAS_API_USER, and ATLAS_API_PASS environment variables.' });
|
||||
}
|
||||
|
||||
const hostId = parseInt(req.params.hostId, 10);
|
||||
if (!Number.isInteger(hostId) || hostId <= 0) {
|
||||
return res.status(400).json({ error: 'hostId must be a positive integer' });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await atlasGet('/hosts/' + hostId + '/action-plans');
|
||||
|
||||
if (result.status >= 200 && result.status < 300) {
|
||||
let body;
|
||||
try {
|
||||
body = JSON.parse(result.body);
|
||||
} catch (e) {
|
||||
body = result.body;
|
||||
}
|
||||
res.status(result.status).json(body);
|
||||
} else {
|
||||
// Forward non-2xx Atlas responses to the client
|
||||
let errorBody;
|
||||
try {
|
||||
errorBody = JSON.parse(result.body);
|
||||
} catch (e) {
|
||||
errorBody = { error: result.body };
|
||||
}
|
||||
res.status(result.status).json(errorBody);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Atlas] GET action-plans failed for host', hostId, ':', err.message);
|
||||
res.status(502).json({ error: 'Failed to reach Atlas API: ' + err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// PUT /hosts/:hostId/action-plans
|
||||
// Create a new action plan for a host.
|
||||
// Auth: Admin or Standard_User
|
||||
//
|
||||
// Params: hostId (positive integer)
|
||||
// Request body:
|
||||
// { plan_type: string (one of VALID_PLAN_TYPES), commit_date: string (YYYY-MM-DD),
|
||||
// qualys_id?: string, active_host_findings_id?: string,
|
||||
// jira_vnr?: string, archer_exc?: string }
|
||||
// Response 2xx: proxied Atlas response body
|
||||
// Response 400: { error: string } — invalid hostId, plan_type, or commit_date
|
||||
// Response 503: { error: string } — Atlas not configured
|
||||
// Response 502: { error: string } — Atlas API unreachable
|
||||
// -----------------------------------------------------------------------
|
||||
router.put('/hosts/:hostId/action-plans', requireAuth(db), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||
if (!isConfigured) {
|
||||
return res.status(503).json({ error: 'Atlas API is not configured. Check ATLAS_API_URL, ATLAS_API_USER, and ATLAS_API_PASS environment variables.' });
|
||||
}
|
||||
|
||||
const hostId = parseInt(req.params.hostId, 10);
|
||||
if (!Number.isInteger(hostId) || hostId <= 0) {
|
||||
return res.status(400).json({ error: 'hostId must be a positive integer' });
|
||||
}
|
||||
|
||||
const { plan_type, commit_date } = req.body || {};
|
||||
|
||||
if (!plan_type || !VALID_PLAN_TYPES.includes(plan_type)) {
|
||||
return res.status(400).json({ error: 'plan_type must be one of: ' + VALID_PLAN_TYPES.join(', ') });
|
||||
}
|
||||
|
||||
if (!commit_date || !DATE_PATTERN.test(commit_date)) {
|
||||
return res.status(400).json({ error: 'commit_date must be a valid YYYY-MM-DD date string' });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await atlasPut('/hosts/' + hostId + '/action-plans', req.body);
|
||||
|
||||
logAudit(db, {
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'ATLAS_CREATE_PLAN',
|
||||
entityType: 'atlas_action_plan',
|
||||
entityId: String(hostId),
|
||||
details: { hostId, plan_type, commit_date },
|
||||
ipAddress: req.ip
|
||||
});
|
||||
|
||||
if (result.status >= 200 && result.status < 300) {
|
||||
let body;
|
||||
try {
|
||||
body = JSON.parse(result.body);
|
||||
} catch (e) {
|
||||
body = result.body;
|
||||
}
|
||||
res.status(result.status).json(body);
|
||||
} else {
|
||||
let errorBody;
|
||||
try {
|
||||
errorBody = JSON.parse(result.body);
|
||||
} catch (e) {
|
||||
errorBody = { error: result.body };
|
||||
}
|
||||
res.status(result.status).json(errorBody);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Atlas] PUT action-plans failed for host', hostId, ':', err.message);
|
||||
res.status(502).json({ error: 'Failed to reach Atlas API: ' + err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// PATCH /hosts/:hostId/action-plans
|
||||
// Update an existing action plan for a host.
|
||||
// Auth: Admin or Standard_User
|
||||
//
|
||||
// Params: hostId (positive integer)
|
||||
// Request body:
|
||||
// { action_plan_id: string (non-empty), updates: object (non-null, non-array) }
|
||||
// Response 2xx: proxied Atlas response body
|
||||
// Response 400: { error: string } — invalid hostId, action_plan_id, or updates
|
||||
// Response 503: { error: string } — Atlas not configured
|
||||
// Response 502: { error: string } — Atlas API unreachable
|
||||
// -----------------------------------------------------------------------
|
||||
router.patch('/hosts/:hostId/action-plans', requireAuth(db), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||
if (!isConfigured) {
|
||||
return res.status(503).json({ error: 'Atlas API is not configured. Check ATLAS_API_URL, ATLAS_API_USER, and ATLAS_API_PASS environment variables.' });
|
||||
}
|
||||
|
||||
const hostId = parseInt(req.params.hostId, 10);
|
||||
if (!Number.isInteger(hostId) || hostId <= 0) {
|
||||
return res.status(400).json({ error: 'hostId must be a positive integer' });
|
||||
}
|
||||
|
||||
const { action_plan_id, updates } = req.body || {};
|
||||
|
||||
if (!action_plan_id || typeof action_plan_id !== 'string' || action_plan_id.trim() === '') {
|
||||
return res.status(400).json({ error: 'action_plan_id is required and must be a non-empty string' });
|
||||
}
|
||||
|
||||
if (!updates || typeof updates !== 'object' || Array.isArray(updates)) {
|
||||
return res.status(400).json({ error: 'updates is required and must be an object' });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await atlasPatch('/hosts/' + hostId + '/action-plans', req.body);
|
||||
|
||||
logAudit(db, {
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'ATLAS_UPDATE_PLAN',
|
||||
entityType: 'atlas_action_plan',
|
||||
entityId: String(hostId),
|
||||
details: { hostId, action_plan_id },
|
||||
ipAddress: req.ip
|
||||
});
|
||||
|
||||
if (result.status >= 200 && result.status < 300) {
|
||||
let body;
|
||||
try {
|
||||
body = JSON.parse(result.body);
|
||||
} catch (e) {
|
||||
body = result.body;
|
||||
}
|
||||
res.status(result.status).json(body);
|
||||
} else {
|
||||
let errorBody;
|
||||
try {
|
||||
errorBody = JSON.parse(result.body);
|
||||
} catch (e) {
|
||||
errorBody = { error: result.body };
|
||||
}
|
||||
res.status(result.status).json(errorBody);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Atlas] PATCH action-plans failed for host', hostId, ':', err.message);
|
||||
res.status(502).json({ error: 'Failed to reach Atlas API: ' + err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// POST /hosts/bulk-action-plans
|
||||
// Create action plans for multiple hosts at once.
|
||||
// Auth: Admin or Standard_User
|
||||
//
|
||||
// Request body:
|
||||
// { host_ids: number[] (non-empty, positive integers),
|
||||
// plan_type: string (one of VALID_PLAN_TYPES),
|
||||
// commit_date: string (YYYY-MM-DD) }
|
||||
// Response 2xx: proxied Atlas response body
|
||||
// Response 400: { error: string } — invalid host_ids, plan_type, or commit_date
|
||||
// Response 503: { error: string } — Atlas not configured
|
||||
// Response 502: { error: string } — Atlas API unreachable
|
||||
// -----------------------------------------------------------------------
|
||||
router.post('/hosts/bulk-action-plans', requireAuth(db), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||
if (!isConfigured) {
|
||||
return res.status(503).json({ error: 'Atlas API is not configured. Check ATLAS_API_URL, ATLAS_API_USER, and ATLAS_API_PASS environment variables.' });
|
||||
}
|
||||
|
||||
const { host_ids, plan_type, commit_date } = req.body || {};
|
||||
|
||||
if (!Array.isArray(host_ids) || host_ids.length === 0) {
|
||||
return res.status(400).json({ error: 'host_ids must be a non-empty array of positive integers' });
|
||||
}
|
||||
|
||||
for (const id of host_ids) {
|
||||
if (!Number.isInteger(id) || id <= 0) {
|
||||
return res.status(400).json({ error: 'host_ids must be a non-empty array of positive integers' });
|
||||
}
|
||||
}
|
||||
|
||||
if (!plan_type || !VALID_PLAN_TYPES.includes(plan_type)) {
|
||||
return res.status(400).json({ error: 'plan_type must be one of: ' + VALID_PLAN_TYPES.join(', ') });
|
||||
}
|
||||
|
||||
if (!commit_date || !DATE_PATTERN.test(commit_date)) {
|
||||
return res.status(400).json({ error: 'commit_date must be a valid YYYY-MM-DD date string' });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await atlasPost('/hosts/create-bulk-action-plans', req.body);
|
||||
|
||||
if (result.status >= 200 && result.status < 300) {
|
||||
let body;
|
||||
try {
|
||||
body = JSON.parse(result.body);
|
||||
} catch (e) {
|
||||
body = result.body;
|
||||
}
|
||||
res.status(result.status).json(body);
|
||||
} else {
|
||||
let errorBody;
|
||||
try {
|
||||
errorBody = JSON.parse(result.body);
|
||||
} catch (e) {
|
||||
errorBody = { error: result.body };
|
||||
}
|
||||
res.status(result.status).json(errorBody);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Atlas] POST bulk-action-plans failed:', err.message);
|
||||
res.status(502).json({ error: 'Failed to reach Atlas API: ' + err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// POST /hosts/vulnerabilities
|
||||
// Fetch active Ivanti vulnerabilities for multiple hosts from Atlas.
|
||||
// Used by the bulk action plan modal to populate the qualys_id dropdown.
|
||||
// Auth: any authenticated user
|
||||
//
|
||||
// Request body: { host_ids: number[] }
|
||||
// Response 2xx: proxied Atlas response body
|
||||
// Response 400: { error: string } — invalid host_ids
|
||||
// Response 503: { error: string } — Atlas not configured
|
||||
// Response 502: { error: string } — Atlas API unreachable
|
||||
// -----------------------------------------------------------------------
|
||||
router.post('/hosts/vulnerabilities', requireAuth(db), async (req, res) => {
|
||||
if (!isConfigured) {
|
||||
return res.status(503).json({ error: 'Atlas API is not configured. Check ATLAS_API_URL, ATLAS_API_USER, and ATLAS_API_PASS environment variables.' });
|
||||
}
|
||||
|
||||
const { host_ids } = req.body || {};
|
||||
|
||||
if (!Array.isArray(host_ids) || host_ids.length === 0) {
|
||||
return res.status(400).json({ error: 'host_ids must be a non-empty array of positive integers' });
|
||||
}
|
||||
|
||||
for (const id of host_ids) {
|
||||
if (!Number.isInteger(id) || id <= 0) {
|
||||
return res.status(400).json({ error: 'host_ids must be a non-empty array of positive integers' });
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await atlasPost('/ivanti-vulnerabilities-by-host', { host_ids }, { timeout: 30000 });
|
||||
|
||||
console.log('[Atlas] POST /ivanti-vulnerabilities-by-host status:', result.status, 'body length:', result.body?.length);
|
||||
console.log('[Atlas] Response preview:', result.body?.substring(0, 500));
|
||||
|
||||
if (result.status >= 200 && result.status < 300) {
|
||||
let body;
|
||||
try {
|
||||
body = JSON.parse(result.body);
|
||||
} catch (e) {
|
||||
body = result.body;
|
||||
}
|
||||
res.status(result.status).json(body);
|
||||
} else {
|
||||
let errorBody;
|
||||
try {
|
||||
errorBody = JSON.parse(result.body);
|
||||
} catch (e) {
|
||||
errorBody = { error: result.body };
|
||||
}
|
||||
res.status(result.status).json(errorBody);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Atlas] POST hosts/vulnerabilities failed:', err.message);
|
||||
res.status(502).json({ error: 'Failed to reach Atlas API: ' + err.message });
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
module.exports = createAtlasRouter;
|
||||
module.exports.aggregateAtlasMetrics = aggregateAtlasMetrics;
|
||||
@@ -1,11 +1,11 @@
|
||||
// Audit Log Routes (Admin only)
|
||||
const express = require('express');
|
||||
|
||||
function createAuditLogRouter(db, requireAuth, requireRole) {
|
||||
function createAuditLogRouter(db, requireAuth, requireGroup) {
|
||||
const router = express.Router();
|
||||
|
||||
// All routes require admin role
|
||||
router.use(requireAuth(db), requireRole('admin'));
|
||||
// All routes require Admin group
|
||||
router.use(requireAuth(db), requireGroup('Admin'));
|
||||
|
||||
// Get paginated audit logs with filters
|
||||
router.get('/', async (req, res) => {
|
||||
|
||||
@@ -2,12 +2,35 @@
|
||||
const express = require('express');
|
||||
const bcrypt = require('bcryptjs');
|
||||
const crypto = require('crypto');
|
||||
const rateLimit = require('express-rate-limit');
|
||||
const { requireAuth, requireGroup } = require('../middleware/auth');
|
||||
|
||||
const loginLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||
max: 20, // 20 attempts per window
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
message: { error: 'Too many login attempts. Please try again in 15 minutes.' }
|
||||
});
|
||||
|
||||
function createAuthRouter(db, logAudit) {
|
||||
const router = express.Router();
|
||||
|
||||
// Login
|
||||
router.post('/login', async (req, res) => {
|
||||
/**
|
||||
* POST /api/auth/login
|
||||
*
|
||||
* Authenticates a user with username and password, creates a session,
|
||||
* and sets an httpOnly session cookie. Rate-limited to 20 attempts per 15 minutes.
|
||||
*
|
||||
* @body {string} username - The user's login username
|
||||
* @body {string} password - The user's password
|
||||
* @returns {object} 200 - { message: 'Login successful', user: { id, username, email, group } }
|
||||
* @returns {object} 400 - { error: 'Username and password are required' }
|
||||
* @returns {object} 401 - { error: 'Invalid username or password' } | { error: 'Account is disabled' }
|
||||
* @returns {object} 429 - { error: 'Too many login attempts. Please try again in 15 minutes.' }
|
||||
* @returns {object} 500 - { error: 'Login failed' }
|
||||
*/
|
||||
router.post('/login', loginLimiter, async (req, res) => {
|
||||
const { username, password } = req.body;
|
||||
|
||||
if (!username || !password) {
|
||||
@@ -110,7 +133,7 @@ function createAuthRouter(db, logAudit) {
|
||||
action: 'login',
|
||||
entityType: 'auth',
|
||||
entityId: null,
|
||||
details: { role: user.role },
|
||||
details: { group: user.user_group },
|
||||
ipAddress: req.ip
|
||||
});
|
||||
|
||||
@@ -120,7 +143,7 @@ function createAuthRouter(db, logAudit) {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
role: user.role
|
||||
group: user.user_group
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
@@ -129,7 +152,14 @@ function createAuthRouter(db, logAudit) {
|
||||
}
|
||||
});
|
||||
|
||||
// Logout
|
||||
/**
|
||||
* POST /api/auth/logout
|
||||
*
|
||||
* Ends the current user session by deleting it from the database
|
||||
* and clearing the session cookie.
|
||||
*
|
||||
* @returns {object} 200 - { message: 'Logged out successfully' }
|
||||
*/
|
||||
router.post('/logout', async (req, res) => {
|
||||
const sessionId = req.cookies?.session_id;
|
||||
|
||||
@@ -172,7 +202,16 @@ function createAuthRouter(db, logAudit) {
|
||||
res.json({ message: 'Logged out successfully' });
|
||||
});
|
||||
|
||||
// Get current user
|
||||
/**
|
||||
* GET /api/auth/me
|
||||
*
|
||||
* Returns the currently authenticated user based on the session cookie.
|
||||
* Clears the cookie and returns 401 if the session is expired or the account is disabled.
|
||||
*
|
||||
* @returns {object} 200 - { user: { id, username, email, group } }
|
||||
* @returns {object} 401 - { error: 'Not authenticated' } | { error: 'Session expired' } | { error: 'Account is disabled' }
|
||||
* @returns {object} 500 - { error: 'Failed to get user' }
|
||||
*/
|
||||
router.get('/me', async (req, res) => {
|
||||
const sessionId = req.cookies?.session_id;
|
||||
|
||||
@@ -183,7 +222,7 @@ function createAuthRouter(db, logAudit) {
|
||||
try {
|
||||
const session = await new Promise((resolve, reject) => {
|
||||
db.get(
|
||||
`SELECT s.*, u.id as user_id, u.username, u.email, u.role, u.is_active
|
||||
`SELECT s.*, u.id as user_id, u.username, u.email, u.user_group, u.is_active
|
||||
FROM sessions s
|
||||
JOIN users u ON s.user_id = u.id
|
||||
WHERE s.session_id = ? AND s.expires_at > datetime('now')`,
|
||||
@@ -210,7 +249,7 @@ function createAuthRouter(db, logAudit) {
|
||||
id: session.user_id,
|
||||
username: session.username,
|
||||
email: session.email,
|
||||
role: session.role
|
||||
group: session.user_group
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
@@ -219,13 +258,148 @@ function createAuthRouter(db, logAudit) {
|
||||
}
|
||||
});
|
||||
|
||||
// Clean up expired sessions (admin only)
|
||||
router.post('/cleanup-sessions', async (req, res) => {
|
||||
// Basic auth check - require a valid session to call this
|
||||
const sessionId = req.cookies?.session_id;
|
||||
if (!sessionId) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
/**
|
||||
* GET /api/auth/profile
|
||||
*
|
||||
* Returns the full profile for the currently authenticated user.
|
||||
* Queries the database for up-to-date account details including
|
||||
* creation date and last login timestamp.
|
||||
*
|
||||
* @returns {object} 200 - { id, username, email, group, created_at, last_login }
|
||||
* @returns {object} 401 - { error: 'Account is disabled' } (clears session cookie)
|
||||
* @returns {object} 500 - { error: 'Failed to fetch profile' }
|
||||
*/
|
||||
router.get('/profile', requireAuth(db), async (req, res) => {
|
||||
try {
|
||||
const user = await new Promise((resolve, reject) => {
|
||||
db.get(
|
||||
'SELECT id, username, email, user_group, created_at, last_login, is_active FROM users WHERE id = ?',
|
||||
[req.user.id],
|
||||
(err, row) => {
|
||||
if (err) reject(err);
|
||||
else resolve(row);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
if (!user || !user.is_active) {
|
||||
res.clearCookie('session_id');
|
||||
return res.status(401).json({ error: 'Account is disabled' });
|
||||
}
|
||||
|
||||
res.json({
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
group: user.user_group,
|
||||
created_at: user.created_at,
|
||||
last_login: user.last_login
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Profile fetch error:', err);
|
||||
res.status(500).json({ error: 'Failed to fetch profile' });
|
||||
}
|
||||
});
|
||||
|
||||
// Rate limiter for password change — 5 attempts per 15-minute window, keyed by session cookie
|
||||
const passwordChangeLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||
max: 5,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
keyGenerator: (req) => req.cookies?.session_id || req.ip,
|
||||
message: { error: 'Too many password change attempts. Please try again later.' }
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/auth/change-password
|
||||
*
|
||||
* Allows the authenticated user to change their own password.
|
||||
* Rate-limited to 5 attempts per 15-minute window per session.
|
||||
*
|
||||
* @body {string} currentPassword - The user's current password
|
||||
* @body {string} newPassword - The desired new password (min 8 characters)
|
||||
* @returns {object} 200 - { message: 'Password changed successfully' }
|
||||
* @returns {object} 400 - { error: 'Current password and new password are required' } | { error: 'New password must be at least 8 characters' }
|
||||
* @returns {object} 401 - { error: 'Account is disabled' } | { error: 'Current password is incorrect' }
|
||||
* @returns {object} 429 - { error: 'Too many password change attempts. Please try again later.' }
|
||||
* @returns {object} 500 - { error: 'Failed to change password' }
|
||||
*/
|
||||
router.post('/change-password', requireAuth(db), passwordChangeLimiter, async (req, res) => {
|
||||
const { currentPassword, newPassword } = req.body;
|
||||
|
||||
if (!currentPassword || !newPassword) {
|
||||
return res.status(400).json({ error: 'Current password and new password are required' });
|
||||
}
|
||||
|
||||
if (newPassword.length < 8) {
|
||||
return res.status(400).json({ error: 'New password must be at least 8 characters' });
|
||||
}
|
||||
|
||||
try {
|
||||
// Fetch user's password hash and active status
|
||||
const user = await new Promise((resolve, reject) => {
|
||||
db.get(
|
||||
'SELECT password_hash, is_active FROM users WHERE id = ?',
|
||||
[req.user.id],
|
||||
(err, row) => {
|
||||
if (err) reject(err);
|
||||
else resolve(row);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
if (!user || !user.is_active) {
|
||||
return res.status(401).json({ error: 'Account is disabled' });
|
||||
}
|
||||
|
||||
// Verify current password
|
||||
const validPassword = await bcrypt.compare(currentPassword, user.password_hash);
|
||||
if (!validPassword) {
|
||||
return res.status(401).json({ error: 'Current password is incorrect' });
|
||||
}
|
||||
|
||||
// Hash new password and update
|
||||
const newHash = await bcrypt.hash(newPassword, 10);
|
||||
await new Promise((resolve, reject) => {
|
||||
db.run(
|
||||
'UPDATE users SET password_hash = ? WHERE id = ?',
|
||||
[newHash, req.user.id],
|
||||
(err) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
logAudit(db, {
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'password_change',
|
||||
entityType: 'auth',
|
||||
entityId: null,
|
||||
details: null,
|
||||
ipAddress: req.ip
|
||||
});
|
||||
|
||||
res.json({ message: 'Password changed successfully' });
|
||||
} catch (err) {
|
||||
console.error('Password change error:', err);
|
||||
res.status(500).json({ error: 'Failed to change password' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/auth/cleanup-sessions
|
||||
*
|
||||
* Deletes all expired sessions from the database. Requires Admin group.
|
||||
*
|
||||
* @returns {object} 200 - { message: 'Expired sessions cleaned up' }
|
||||
* @returns {object} 401 - { error: 'Authentication required' }
|
||||
* @returns {object} 403 - { error: 'Insufficient permissions', required: ['Admin'], current: '...' }
|
||||
* @returns {object} 500 - { error: 'Cleanup failed' }
|
||||
*/
|
||||
router.post('/cleanup-sessions', requireAuth(db), requireGroup('Admin'), async (req, res) => {
|
||||
try {
|
||||
await new Promise((resolve, reject) => {
|
||||
db.run(
|
||||
|
||||
615
backend/routes/cardApi.js
Normal file
615
backend/routes/cardApi.js
Normal file
@@ -0,0 +1,615 @@
|
||||
// CARD Asset Ownership API Routes
|
||||
// Proxies CARD operations (confirm, decline, redirect, search) and orchestrates
|
||||
// the two-step update_token flow for mutations.
|
||||
|
||||
const express = require('express');
|
||||
const { requireGroup } = require('../middleware/auth');
|
||||
const logAudit = require('../helpers/auditLog');
|
||||
const {
|
||||
isConfigured,
|
||||
missingVars,
|
||||
getTeams,
|
||||
getTeamAssets,
|
||||
getOwner,
|
||||
confirmAsset,
|
||||
declineAsset,
|
||||
redirectAsset,
|
||||
} = require('../helpers/cardApi');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DB helpers — promise wrappers for callback-based SQLite API
|
||||
// ---------------------------------------------------------------------------
|
||||
function dbRun(db, sql, params = []) {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.run(sql, params, function (err) { if (err) reject(err); else resolve(this); });
|
||||
});
|
||||
}
|
||||
|
||||
function dbGet(db, sql, params = []) {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.get(sql, params, (err, row) => { if (err) reject(err); else resolve(row); });
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Error classification — maps CARD API / token errors to client responses
|
||||
// ---------------------------------------------------------------------------
|
||||
function handleCardError(err, res) {
|
||||
const msg = err.message || String(err);
|
||||
console.error('[card-api]', msg);
|
||||
|
||||
// Token endpoint errors (from acquireToken rejections)
|
||||
if (msg.includes('Token acquisition failed')) {
|
||||
if (msg.includes('HTTP 401')) {
|
||||
return res.status(401).json({ error: 'CARD authorization failed. Check service account credentials.' });
|
||||
}
|
||||
if (msg.includes('HTTP 403')) {
|
||||
return res.status(403).json({ error: 'CARD access denied. The service account may not be onboarded with the CARD team.' });
|
||||
}
|
||||
if (msg.includes('HTTP 525')) {
|
||||
return res.status(502).json({ error: 'CARD LDAP error. The service account may not be provisioned correctly.' });
|
||||
}
|
||||
}
|
||||
|
||||
// API call errors (after automatic 401 retry in helper)
|
||||
if (msg.includes('401')) {
|
||||
return res.status(401).json({ error: 'CARD token expired or invalid. The request has been retried once automatically.' });
|
||||
}
|
||||
if (msg.includes('403')) {
|
||||
return res.status(403).json({ error: 'Insufficient CARD permissions for this operation.' });
|
||||
}
|
||||
|
||||
// Catch-all
|
||||
return res.status(502).json({ error: 'CARD API request failed.', details: msg });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Router factory
|
||||
// ---------------------------------------------------------------------------
|
||||
function createCardApiRouter(db, requireAuth) {
|
||||
const router = express.Router();
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// GET /status
|
||||
// Returns whether the CARD API integration is configured.
|
||||
// -------------------------------------------------------------------
|
||||
router.get('/status', requireAuth(db), (req, res) => {
|
||||
if (!isConfigured) {
|
||||
return res.status(503).json({
|
||||
configured: false,
|
||||
error: 'CARD API is not configured.',
|
||||
missingVars,
|
||||
});
|
||||
}
|
||||
res.json({ configured: true });
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// GET /teams
|
||||
// Proxy CARD teams list.
|
||||
// -------------------------------------------------------------------
|
||||
router.get('/teams', requireAuth(db), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||
if (!isConfigured) {
|
||||
return res.status(503).json({ error: 'CARD API is not configured.', missingVars });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await getTeams();
|
||||
|
||||
if (result.ok) {
|
||||
let body;
|
||||
try {
|
||||
body = JSON.parse(result.body);
|
||||
} catch (_) {
|
||||
body = result.body;
|
||||
}
|
||||
// CARD API wraps teams in { teams: [...], response_time: ... }
|
||||
const teams = Array.isArray(body) ? body : (body && body.teams) || [];
|
||||
return res.json(teams);
|
||||
}
|
||||
|
||||
// Forward CARD error status
|
||||
let errorBody;
|
||||
try {
|
||||
errorBody = JSON.parse(result.body);
|
||||
} catch (_) {
|
||||
errorBody = { error: result.body };
|
||||
}
|
||||
return res.status(result.status).json(errorBody);
|
||||
} catch (err) {
|
||||
return handleCardError(err, res);
|
||||
}
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// GET /teams/:teamName/assets
|
||||
// Proxy team assets with required disposition filter.
|
||||
// -------------------------------------------------------------------
|
||||
router.get('/teams/:teamName/assets', requireAuth(db), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||
if (!isConfigured) {
|
||||
return res.status(503).json({ error: 'CARD API is not configured.', missingVars });
|
||||
}
|
||||
|
||||
const { teamName } = req.params;
|
||||
const { disposition, page, page_size } = req.query;
|
||||
|
||||
if (!disposition) {
|
||||
return res.status(400).json({ error: 'disposition query parameter is required.' });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await getTeamAssets(teamName, {
|
||||
disposition,
|
||||
page: page ? parseInt(page, 10) : undefined,
|
||||
pageSize: page_size ? parseInt(page_size, 10) : 50,
|
||||
});
|
||||
|
||||
if (result.ok) {
|
||||
let body;
|
||||
try {
|
||||
body = JSON.parse(result.body);
|
||||
} catch (_) {
|
||||
body = result.body;
|
||||
}
|
||||
|
||||
// Audit log for asset search (fire-and-forget)
|
||||
let resultCount = 0;
|
||||
if (body && typeof body === 'object' && typeof body.total === 'number') {
|
||||
resultCount = body.total;
|
||||
} else if (body && Array.isArray(body.assets)) {
|
||||
resultCount = body.assets.length;
|
||||
}
|
||||
logAudit(db, {
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'card_search',
|
||||
entityType: 'card_asset',
|
||||
entityId: teamName,
|
||||
details: { disposition, resultCount },
|
||||
ipAddress: req.ip,
|
||||
});
|
||||
|
||||
return res.json(body);
|
||||
}
|
||||
|
||||
let errorBody;
|
||||
try {
|
||||
errorBody = JSON.parse(result.body);
|
||||
} catch (_) {
|
||||
errorBody = { error: result.body };
|
||||
}
|
||||
return res.status(result.status).json(errorBody);
|
||||
} catch (err) {
|
||||
return handleCardError(err, res);
|
||||
}
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// GET /owner/:assetId
|
||||
// Proxy owner record lookup.
|
||||
// -------------------------------------------------------------------
|
||||
router.get('/owner/:assetId', requireAuth(db), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||
if (!isConfigured) {
|
||||
return res.status(503).json({ error: 'CARD API is not configured.', missingVars });
|
||||
}
|
||||
|
||||
const { assetId } = req.params;
|
||||
|
||||
try {
|
||||
const result = await getOwner(assetId);
|
||||
|
||||
if (result.ok) {
|
||||
let body;
|
||||
try {
|
||||
body = JSON.parse(result.body);
|
||||
} catch (_) {
|
||||
body = result.body;
|
||||
}
|
||||
return res.json(body);
|
||||
}
|
||||
|
||||
let errorBody;
|
||||
try {
|
||||
errorBody = JSON.parse(result.body);
|
||||
} catch (_) {
|
||||
errorBody = { error: result.body };
|
||||
}
|
||||
return res.status(result.status).json(errorBody);
|
||||
} catch (err) {
|
||||
return handleCardError(err, res);
|
||||
}
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// POST /queue/:queueItemId/confirm
|
||||
// Confirm asset to a team via CARD API.
|
||||
// -------------------------------------------------------------------
|
||||
router.post('/queue/:queueItemId/confirm', requireAuth(db), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||
if (!isConfigured) {
|
||||
return res.status(503).json({ error: 'CARD API is not configured.', missingVars });
|
||||
}
|
||||
|
||||
const { queueItemId } = req.params;
|
||||
const { teamName, assetId, comment } = req.body;
|
||||
|
||||
// Validate required fields
|
||||
if (!teamName || typeof teamName !== 'string' || !teamName.trim()) {
|
||||
return res.status(400).json({ error: 'teamName is required.' });
|
||||
}
|
||||
if (!assetId || typeof assetId !== 'string' || !assetId.trim()) {
|
||||
return res.status(400).json({ error: 'assetId is required.' });
|
||||
}
|
||||
|
||||
try {
|
||||
// Validate queue item
|
||||
const item = await dbGet(db,
|
||||
'SELECT * FROM ivanti_todo_queue WHERE id = ? AND user_id = ? AND workflow_type = ?',
|
||||
[queueItemId, req.user.id, 'CARD']
|
||||
);
|
||||
|
||||
if (!item) {
|
||||
return res.status(404).json({ error: 'Queue item not found.' });
|
||||
}
|
||||
if (item.status !== 'pending') {
|
||||
return res.status(400).json({ error: 'Only pending queue items can be executed.' });
|
||||
}
|
||||
|
||||
// Step 1: Get owner record for update_token
|
||||
const ownerResult = await getOwner(assetId);
|
||||
if (!ownerResult.ok) {
|
||||
const errMsg = `Failed to fetch owner record: HTTP ${ownerResult.status}`;
|
||||
console.error('[card-api]', errMsg);
|
||||
logAudit(db, {
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'card_action_failed',
|
||||
entityType: 'ivanti_todo_queue',
|
||||
entityId: String(queueItemId),
|
||||
details: { actionType: 'confirm', assetId, error: errMsg, cardStatus: ownerResult.status },
|
||||
ipAddress: req.ip,
|
||||
});
|
||||
let errorBody;
|
||||
try { errorBody = JSON.parse(ownerResult.body); } catch (_) { errorBody = { error: ownerResult.body }; }
|
||||
return res.status(ownerResult.status).json(errorBody);
|
||||
}
|
||||
|
||||
let ownerData;
|
||||
try { ownerData = JSON.parse(ownerResult.body); } catch (_) { ownerData = {}; }
|
||||
const updateToken = ownerData.owner && ownerData.owner.update_token;
|
||||
|
||||
if (!updateToken) {
|
||||
const errMsg = 'update_token not found in owner record.';
|
||||
console.error('[card-api]', errMsg);
|
||||
logAudit(db, {
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'card_action_failed',
|
||||
entityType: 'ivanti_todo_queue',
|
||||
entityId: String(queueItemId),
|
||||
details: { actionType: 'confirm', assetId, error: errMsg, cardStatus: null },
|
||||
ipAddress: req.ip,
|
||||
});
|
||||
return res.status(502).json({ error: 'CARD API request failed.', details: errMsg });
|
||||
}
|
||||
|
||||
// Step 2: Execute confirm mutation
|
||||
const confirmResult = await confirmAsset(assetId, teamName.trim(), updateToken, comment || '');
|
||||
|
||||
if (confirmResult.ok) {
|
||||
// Update queue item to complete
|
||||
await dbRun(db,
|
||||
"UPDATE ivanti_todo_queue SET status = 'complete', updated_at = CURRENT_TIMESTAMP WHERE id = ?",
|
||||
[queueItemId]
|
||||
);
|
||||
|
||||
let cardResponse;
|
||||
try { cardResponse = JSON.parse(confirmResult.body); } catch (_) { cardResponse = confirmResult.body; }
|
||||
|
||||
// Audit log (fire-and-forget)
|
||||
logAudit(db, {
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'card_confirm',
|
||||
entityType: 'ivanti_todo_queue',
|
||||
entityId: String(queueItemId),
|
||||
details: { assetId, teamName: teamName.trim(), comment: comment || '', cardStatus: confirmResult.status },
|
||||
ipAddress: req.ip,
|
||||
});
|
||||
|
||||
return res.json({ success: true, cardResponse });
|
||||
}
|
||||
|
||||
// Mutation failed — leave queue item as pending
|
||||
const errMsg = `Confirm failed: HTTP ${confirmResult.status}`;
|
||||
console.error('[card-api]', errMsg);
|
||||
logAudit(db, {
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'card_action_failed',
|
||||
entityType: 'ivanti_todo_queue',
|
||||
entityId: String(queueItemId),
|
||||
details: { actionType: 'confirm', assetId, error: errMsg, cardStatus: confirmResult.status },
|
||||
ipAddress: req.ip,
|
||||
});
|
||||
|
||||
let errorBody;
|
||||
try { errorBody = JSON.parse(confirmResult.body); } catch (_) { errorBody = { error: confirmResult.body }; }
|
||||
return res.status(confirmResult.status).json(errorBody);
|
||||
} catch (err) {
|
||||
console.error('[card-api] Confirm error:', err.message);
|
||||
logAudit(db, {
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'card_action_failed',
|
||||
entityType: 'ivanti_todo_queue',
|
||||
entityId: String(queueItemId),
|
||||
details: { actionType: 'confirm', assetId, error: err.message, cardStatus: null },
|
||||
ipAddress: req.ip,
|
||||
});
|
||||
return handleCardError(err, res);
|
||||
}
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// POST /queue/:queueItemId/decline
|
||||
// Decline asset from a team via CARD API.
|
||||
// -------------------------------------------------------------------
|
||||
router.post('/queue/:queueItemId/decline', requireAuth(db), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||
if (!isConfigured) {
|
||||
return res.status(503).json({ error: 'CARD API is not configured.', missingVars });
|
||||
}
|
||||
|
||||
const { queueItemId } = req.params;
|
||||
const { teamName, assetId, comment } = req.body;
|
||||
|
||||
// Validate required fields
|
||||
if (!teamName || typeof teamName !== 'string' || !teamName.trim()) {
|
||||
return res.status(400).json({ error: 'teamName is required.' });
|
||||
}
|
||||
if (!assetId || typeof assetId !== 'string' || !assetId.trim()) {
|
||||
return res.status(400).json({ error: 'assetId is required.' });
|
||||
}
|
||||
|
||||
try {
|
||||
// Validate queue item
|
||||
const item = await dbGet(db,
|
||||
'SELECT * FROM ivanti_todo_queue WHERE id = ? AND user_id = ? AND workflow_type = ?',
|
||||
[queueItemId, req.user.id, 'CARD']
|
||||
);
|
||||
|
||||
if (!item) {
|
||||
return res.status(404).json({ error: 'Queue item not found.' });
|
||||
}
|
||||
if (item.status !== 'pending') {
|
||||
return res.status(400).json({ error: 'Only pending queue items can be executed.' });
|
||||
}
|
||||
|
||||
// Step 1: Get owner record for update_token
|
||||
const ownerResult = await getOwner(assetId);
|
||||
if (!ownerResult.ok) {
|
||||
const errMsg = `Failed to fetch owner record: HTTP ${ownerResult.status}`;
|
||||
console.error('[card-api]', errMsg);
|
||||
logAudit(db, {
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'card_action_failed',
|
||||
entityType: 'ivanti_todo_queue',
|
||||
entityId: String(queueItemId),
|
||||
details: { actionType: 'decline', assetId, error: errMsg, cardStatus: ownerResult.status },
|
||||
ipAddress: req.ip,
|
||||
});
|
||||
let errorBody;
|
||||
try { errorBody = JSON.parse(ownerResult.body); } catch (_) { errorBody = { error: ownerResult.body }; }
|
||||
return res.status(ownerResult.status).json(errorBody);
|
||||
}
|
||||
|
||||
let ownerData;
|
||||
try { ownerData = JSON.parse(ownerResult.body); } catch (_) { ownerData = {}; }
|
||||
const updateToken = ownerData.owner && ownerData.owner.update_token;
|
||||
|
||||
if (!updateToken) {
|
||||
const errMsg = 'update_token not found in owner record.';
|
||||
console.error('[card-api]', errMsg);
|
||||
logAudit(db, {
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'card_action_failed',
|
||||
entityType: 'ivanti_todo_queue',
|
||||
entityId: String(queueItemId),
|
||||
details: { actionType: 'decline', assetId, error: errMsg, cardStatus: null },
|
||||
ipAddress: req.ip,
|
||||
});
|
||||
return res.status(502).json({ error: 'CARD API request failed.', details: errMsg });
|
||||
}
|
||||
|
||||
// Step 2: Execute decline mutation
|
||||
const declineResult = await declineAsset(assetId, teamName.trim(), updateToken, comment || '');
|
||||
|
||||
if (declineResult.ok) {
|
||||
await dbRun(db,
|
||||
"UPDATE ivanti_todo_queue SET status = 'complete', updated_at = CURRENT_TIMESTAMP WHERE id = ?",
|
||||
[queueItemId]
|
||||
);
|
||||
|
||||
let cardResponse;
|
||||
try { cardResponse = JSON.parse(declineResult.body); } catch (_) { cardResponse = declineResult.body; }
|
||||
|
||||
logAudit(db, {
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'card_decline',
|
||||
entityType: 'ivanti_todo_queue',
|
||||
entityId: String(queueItemId),
|
||||
details: { assetId, teamName: teamName.trim(), comment: comment || '', cardStatus: declineResult.status },
|
||||
ipAddress: req.ip,
|
||||
});
|
||||
|
||||
return res.json({ success: true, cardResponse });
|
||||
}
|
||||
|
||||
// Mutation failed
|
||||
const errMsg = `Decline failed: HTTP ${declineResult.status}`;
|
||||
console.error('[card-api]', errMsg);
|
||||
logAudit(db, {
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'card_action_failed',
|
||||
entityType: 'ivanti_todo_queue',
|
||||
entityId: String(queueItemId),
|
||||
details: { actionType: 'decline', assetId, error: errMsg, cardStatus: declineResult.status },
|
||||
ipAddress: req.ip,
|
||||
});
|
||||
|
||||
let errorBody;
|
||||
try { errorBody = JSON.parse(declineResult.body); } catch (_) { errorBody = { error: declineResult.body }; }
|
||||
return res.status(declineResult.status).json(errorBody);
|
||||
} catch (err) {
|
||||
console.error('[card-api] Decline error:', err.message);
|
||||
logAudit(db, {
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'card_action_failed',
|
||||
entityType: 'ivanti_todo_queue',
|
||||
entityId: String(queueItemId),
|
||||
details: { actionType: 'decline', assetId, error: err.message, cardStatus: null },
|
||||
ipAddress: req.ip,
|
||||
});
|
||||
return handleCardError(err, res);
|
||||
}
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// POST /queue/:queueItemId/redirect
|
||||
// Redirect asset from one team to another via CARD API.
|
||||
// -------------------------------------------------------------------
|
||||
router.post('/queue/:queueItemId/redirect', requireAuth(db), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||
if (!isConfigured) {
|
||||
return res.status(503).json({ error: 'CARD API is not configured.', missingVars });
|
||||
}
|
||||
|
||||
const { queueItemId } = req.params;
|
||||
const { fromTeam, toTeam, assetId } = req.body;
|
||||
|
||||
// Validate required fields
|
||||
if (!fromTeam || typeof fromTeam !== 'string' || !fromTeam.trim()) {
|
||||
return res.status(400).json({ error: 'fromTeam is required.' });
|
||||
}
|
||||
if (!toTeam || typeof toTeam !== 'string' || !toTeam.trim()) {
|
||||
return res.status(400).json({ error: 'toTeam is required.' });
|
||||
}
|
||||
if (!assetId || typeof assetId !== 'string' || !assetId.trim()) {
|
||||
return res.status(400).json({ error: 'assetId is required.' });
|
||||
}
|
||||
|
||||
try {
|
||||
// Validate queue item
|
||||
const item = await dbGet(db,
|
||||
'SELECT * FROM ivanti_todo_queue WHERE id = ? AND user_id = ? AND workflow_type = ?',
|
||||
[queueItemId, req.user.id, 'CARD']
|
||||
);
|
||||
|
||||
if (!item) {
|
||||
return res.status(404).json({ error: 'Queue item not found.' });
|
||||
}
|
||||
if (item.status !== 'pending') {
|
||||
return res.status(400).json({ error: 'Only pending queue items can be executed.' });
|
||||
}
|
||||
|
||||
// Step 1: Get owner record for update_token
|
||||
const ownerResult = await getOwner(assetId);
|
||||
if (!ownerResult.ok) {
|
||||
const errMsg = `Failed to fetch owner record: HTTP ${ownerResult.status}`;
|
||||
console.error('[card-api]', errMsg);
|
||||
logAudit(db, {
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'card_action_failed',
|
||||
entityType: 'ivanti_todo_queue',
|
||||
entityId: String(queueItemId),
|
||||
details: { actionType: 'redirect', assetId, error: errMsg, cardStatus: ownerResult.status },
|
||||
ipAddress: req.ip,
|
||||
});
|
||||
let errorBody;
|
||||
try { errorBody = JSON.parse(ownerResult.body); } catch (_) { errorBody = { error: ownerResult.body }; }
|
||||
return res.status(ownerResult.status).json(errorBody);
|
||||
}
|
||||
|
||||
let ownerData;
|
||||
try { ownerData = JSON.parse(ownerResult.body); } catch (_) { ownerData = {}; }
|
||||
const updateToken = ownerData.owner && ownerData.owner.update_token;
|
||||
|
||||
if (!updateToken) {
|
||||
const errMsg = 'update_token not found in owner record.';
|
||||
console.error('[card-api]', errMsg);
|
||||
logAudit(db, {
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'card_action_failed',
|
||||
entityType: 'ivanti_todo_queue',
|
||||
entityId: String(queueItemId),
|
||||
details: { actionType: 'redirect', assetId, error: errMsg, cardStatus: null },
|
||||
ipAddress: req.ip,
|
||||
});
|
||||
return res.status(502).json({ error: 'CARD API request failed.', details: errMsg });
|
||||
}
|
||||
|
||||
// Step 2: Execute redirect mutation
|
||||
const redirectResult = await redirectAsset(assetId, fromTeam.trim(), toTeam.trim(), updateToken);
|
||||
|
||||
if (redirectResult.ok) {
|
||||
await dbRun(db,
|
||||
"UPDATE ivanti_todo_queue SET status = 'complete', updated_at = CURRENT_TIMESTAMP WHERE id = ?",
|
||||
[queueItemId]
|
||||
);
|
||||
|
||||
let cardResponse;
|
||||
try { cardResponse = JSON.parse(redirectResult.body); } catch (_) { cardResponse = redirectResult.body; }
|
||||
|
||||
logAudit(db, {
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'card_redirect',
|
||||
entityType: 'ivanti_todo_queue',
|
||||
entityId: String(queueItemId),
|
||||
details: { assetId, fromTeam: fromTeam.trim(), toTeam: toTeam.trim(), cardStatus: redirectResult.status },
|
||||
ipAddress: req.ip,
|
||||
});
|
||||
|
||||
return res.json({ success: true, cardResponse });
|
||||
}
|
||||
|
||||
// Mutation failed
|
||||
const errMsg = `Redirect failed: HTTP ${redirectResult.status}`;
|
||||
console.error('[card-api]', errMsg);
|
||||
logAudit(db, {
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'card_action_failed',
|
||||
entityType: 'ivanti_todo_queue',
|
||||
entityId: String(queueItemId),
|
||||
details: { actionType: 'redirect', assetId, error: errMsg, cardStatus: redirectResult.status },
|
||||
ipAddress: req.ip,
|
||||
});
|
||||
|
||||
let errorBody;
|
||||
try { errorBody = JSON.parse(redirectResult.body); } catch (_) { errorBody = { error: redirectResult.body }; }
|
||||
return res.status(redirectResult.status).json(errorBody);
|
||||
} catch (err) {
|
||||
console.error('[card-api] Redirect error:', err.message);
|
||||
logAudit(db, {
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'card_action_failed',
|
||||
entityType: 'ivanti_todo_queue',
|
||||
entityId: String(queueItemId),
|
||||
details: { actionType: 'redirect', assetId, error: err.message, cardStatus: null },
|
||||
ipAddress: req.ip,
|
||||
});
|
||||
return handleCardError(err, res);
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
module.exports = createCardApiRouter;
|
||||
@@ -2,24 +2,35 @@
|
||||
// Handles xlsx upload/parse, non-compliant item history, and notes.
|
||||
//
|
||||
// Endpoints:
|
||||
// POST /preview — parse xlsx, compute diff vs DB, return summary (no DB write)
|
||||
// POST /commit — commit a previewed upload to DB
|
||||
// GET /uploads — list all uploads
|
||||
// GET /summary — metric health cards for a team (from latest upload)
|
||||
// GET /items — non-compliant devices grouped by hostname (?team=X&status=active)
|
||||
// GET /items/:hostname — detail panel: all metrics + notes + upload history for a device
|
||||
// POST /notes — add a note to a (hostname, metric_id) pair
|
||||
// POST /preview — parse xlsx, run drift check, compute diff (no DB write)
|
||||
// POST /reconcile-config — patch compliance_config.json to resolve drift findings
|
||||
// POST /commit — commit a previewed upload to DB
|
||||
// GET /uploads — list all uploads
|
||||
// POST /rollback/:uploadId — roll back the most recent upload (Admin only)
|
||||
// GET /summary — metric health cards for a team (from latest upload)
|
||||
// GET /items — non-compliant devices grouped by hostname (?team=X&status=active)
|
||||
// GET /items/:hostname — detail panel: all metrics + notes + upload history for a device
|
||||
// POST /notes — add a note to one or more (hostname, metric_id) pairs
|
||||
// GET /notes/:hostname/:metricId — notes for a specific device+metric
|
||||
// GET /trends — per-upload totals + per-team counts for time-series charts
|
||||
// GET /mttr — aging findings distribution by seen_count bucket and team
|
||||
// GET /top-recurring — net change waterfall (per-cycle start/new/recurring/resolved/end)
|
||||
// GET /category-trend — active counts per category per upload for stacked area chart
|
||||
|
||||
const express = require('express');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const crypto = require('crypto');
|
||||
const { spawn } = require('child_process');
|
||||
const { loadConfig, compareSchemaToDrift, reconcileConfig } = require('../helpers/driftChecker');
|
||||
const logAudit = require('../helpers/auditLog');
|
||||
|
||||
const PARSER_SCRIPT = path.join(__dirname, '../scripts/parse_compliance_xlsx.py');
|
||||
const PYTHON_BIN = process.env.PYTHON_BIN || 'python3';
|
||||
const TEMP_DIR = path.join(process.cwd(), 'uploads', 'temp');
|
||||
const ALLOWED_TEAMS = new Set(['STEAM', 'ACCESS-ENG', 'ACCESS-OPS', 'INTELDEV']);
|
||||
const PARSER_SCRIPT = path.join(__dirname, '../scripts/parse_compliance_xlsx.py');
|
||||
const SCHEMA_SCRIPT = path.join(__dirname, '../scripts/extract_xlsx_schema.py');
|
||||
const CONFIG_PATH = path.join(__dirname, '..', 'scripts', 'compliance_config.json');
|
||||
const PYTHON_BIN = process.env.PYTHON_BIN || 'python3';
|
||||
const TEMP_DIR = path.join(process.cwd(), 'uploads', 'temp');
|
||||
const ALLOWED_TEAMS = new Set(['STEAM', 'ACCESS-ENG', 'ACCESS-OPS', 'INTELDEV']);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DB helpers
|
||||
@@ -62,6 +73,25 @@ function parseXlsx(filePath) {
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Run Python schema extractor, return xlsx schema object
|
||||
// ---------------------------------------------------------------------------
|
||||
function extractXlsxSchema(filePath) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const py = spawn(PYTHON_BIN, [SCHEMA_SCRIPT, filePath]);
|
||||
let out = '';
|
||||
let err = '';
|
||||
py.stdout.on('data', d => { out += d; });
|
||||
py.stderr.on('data', d => { err += d; });
|
||||
py.on('close', code => {
|
||||
if (code !== 0) return reject(new Error(err || `Schema extractor exited with code ${code}`));
|
||||
try { resolve(JSON.parse(out)); }
|
||||
catch (e) { reject(new Error('Schema extractor returned invalid JSON')); }
|
||||
});
|
||||
py.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Validate that a temp file path is safely within uploads/temp/
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -210,10 +240,64 @@ function groupByHostname(rows, noteHostnames) {
|
||||
return Object.values(deviceMap);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pure function: bucket active items by age group and pivot per-team counts
|
||||
// ---------------------------------------------------------------------------
|
||||
const BUCKET_ORDER = ['1 cycle', '2–3 cycles', '4–6 cycles', '7+ cycles'];
|
||||
|
||||
function bucketAgingItems(items) {
|
||||
// Initialise empty buckets with all teams at zero
|
||||
const teams = ['STEAM', 'ACCESS-ENG', 'ACCESS-OPS', 'INTELDEV'];
|
||||
const buckets = {};
|
||||
for (const b of BUCKET_ORDER) {
|
||||
buckets[b] = { bucket: b, total: 0 };
|
||||
for (const t of teams) buckets[b][t] = 0;
|
||||
}
|
||||
|
||||
// Classify each item into a bucket
|
||||
for (const item of items) {
|
||||
const sc = item.seen_count;
|
||||
let label;
|
||||
if (sc === 1) label = '1 cycle';
|
||||
else if (sc >= 2 && sc <= 3) label = '2–3 cycles';
|
||||
else if (sc >= 4 && sc <= 6) label = '4–6 cycles';
|
||||
else label = '7+ cycles';
|
||||
|
||||
const team = item.team;
|
||||
buckets[label].total += 1;
|
||||
if (team in buckets[label]) {
|
||||
buckets[label][team] += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Return in ascending age order
|
||||
return BUCKET_ORDER.map(b => buckets[b]);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pure function: compute waterfall chain from ordered upload records
|
||||
// ---------------------------------------------------------------------------
|
||||
function computeWaterfall(uploads) {
|
||||
let start = 0;
|
||||
return uploads.map((row) => {
|
||||
const end = start + row.new_count + row.recurring_count - row.resolved_count;
|
||||
const entry = {
|
||||
date: row.report_date,
|
||||
start,
|
||||
new_count: row.new_count,
|
||||
recurring_count: row.recurring_count,
|
||||
resolved_count: row.resolved_count,
|
||||
end,
|
||||
};
|
||||
start = end;
|
||||
return entry;
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Router factory
|
||||
// ---------------------------------------------------------------------------
|
||||
function createComplianceRouter(db, upload, requireAuth, requireRole) {
|
||||
function createComplianceRouter(db, upload, requireAuth, requireGroup) {
|
||||
const router = express.Router();
|
||||
|
||||
// Idempotent column additions — errors mean column already exists, which is fine
|
||||
@@ -227,8 +311,17 @@ function createComplianceRouter(db, upload, requireAuth, requireRole) {
|
||||
// POST /preview
|
||||
// Parse the uploaded xlsx, compute diff, save parsed data to a temp JSON.
|
||||
// Returns diff counts + tempFile path for the commit step.
|
||||
//
|
||||
// Body: multipart/form-data with `file` field (xlsx)
|
||||
// Response: {
|
||||
// drift: { breaking: [], silent_miss: [], cosmetic: [] } | null,
|
||||
// drift_error: string | null,
|
||||
// diff: { new_count, recurring_count, resolved_count },
|
||||
// tempFile: string, filename: string,
|
||||
// report_date: string, total_items: number
|
||||
// }
|
||||
// -----------------------------------------------------------------------
|
||||
router.post('/preview', requireRole('editor', 'admin'), (req, res) => {
|
||||
router.post('/preview', requireGroup('Admin', 'Standard_User'), (req, res) => {
|
||||
upload.single('file')(req, res, async (uploadErr) => {
|
||||
if (uploadErr) {
|
||||
return res.status(400).json({ error: uploadErr.message });
|
||||
@@ -242,6 +335,31 @@ function createComplianceRouter(db, upload, requireAuth, requireRole) {
|
||||
}
|
||||
|
||||
try {
|
||||
// --- Drift check: load config, extract schema, compare ---
|
||||
let drift = null;
|
||||
let drift_error = null;
|
||||
|
||||
let config;
|
||||
try {
|
||||
config = loadConfig(CONFIG_PATH);
|
||||
} catch (configErr) {
|
||||
fs.unlink(req.file.path, () => {});
|
||||
return res.status(500).json({ error: 'Configuration file could not be loaded: ' + configErr.message });
|
||||
}
|
||||
|
||||
let xlsxSchema = null;
|
||||
try {
|
||||
xlsxSchema = await extractXlsxSchema(req.file.path);
|
||||
if (xlsxSchema.error) {
|
||||
throw new Error(xlsxSchema.error);
|
||||
}
|
||||
drift = compareSchemaToDrift(xlsxSchema, config);
|
||||
} catch (driftErr) {
|
||||
drift = null;
|
||||
drift_error = driftErr.message || 'Drift check failed';
|
||||
}
|
||||
|
||||
// --- Existing parse flow ---
|
||||
const parsed = await parseXlsx(req.file.path);
|
||||
|
||||
if (parsed.error) {
|
||||
@@ -260,13 +378,16 @@ function createComplianceRouter(db, upload, requireAuth, requireRole) {
|
||||
items: parsed.items,
|
||||
summary: parsed.summary,
|
||||
report_date: parsed.report_date,
|
||||
filename: req.file.originalname,
|
||||
filename: req.file.originalname.replace(/[^\w.\-() ]/g, '_'),
|
||||
}));
|
||||
|
||||
// Delete the original xlsx from temp (we only need the JSON now)
|
||||
fs.unlink(req.file.path, () => {});
|
||||
|
||||
res.json({
|
||||
drift,
|
||||
drift_error,
|
||||
schema: xlsxSchema,
|
||||
diff: {
|
||||
new_count: diff.newCount,
|
||||
recurring_count: diff.recurringCount,
|
||||
@@ -286,12 +407,65 @@ function createComplianceRouter(db, upload, requireAuth, requireRole) {
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// POST /reconcile-config
|
||||
// Admin-only. Patches compliance_config.json to resolve breaking and
|
||||
// silent-miss drift findings, then re-runs the drift check and returns
|
||||
// the updated report. Logs every change to the audit trail.
|
||||
//
|
||||
// Body: { drift: { breaking: [...], silent_miss: [...] } }
|
||||
// Response: { changes: [{ action, key, value, detail }], message: string }
|
||||
// -----------------------------------------------------------------------
|
||||
router.post('/reconcile-config', requireGroup('Admin'), async (req, res) => {
|
||||
const { drift, schema } = req.body;
|
||||
|
||||
if (!drift || typeof drift !== 'object') {
|
||||
return res.status(400).json({ error: 'drift report is required in request body' });
|
||||
}
|
||||
|
||||
const hasFindings = (drift.breaking && drift.breaking.length > 0) ||
|
||||
(drift.silent_miss && drift.silent_miss.length > 0);
|
||||
if (!hasFindings) {
|
||||
return res.status(400).json({ error: 'No breaking or silent-miss findings to reconcile' });
|
||||
}
|
||||
|
||||
try {
|
||||
const { changes } = reconcileConfig(CONFIG_PATH, drift, schema || null);
|
||||
|
||||
if (changes.length === 0) {
|
||||
return res.json({ changes: [], message: 'No changes needed' });
|
||||
}
|
||||
|
||||
// Audit log each change
|
||||
for (const change of changes) {
|
||||
logAudit(db, {
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'compliance_config_reconcile',
|
||||
entityType: 'compliance_config',
|
||||
entityId: change.value,
|
||||
details: { action: change.action, key: change.key, detail: change.detail },
|
||||
ipAddress: req.ip,
|
||||
});
|
||||
}
|
||||
|
||||
res.json({ changes, message: `Reconciled ${changes.length} config change(s)` });
|
||||
|
||||
} catch (err) {
|
||||
console.error('[Compliance] Reconcile config error:', err.message);
|
||||
res.status(500).json({ error: 'Failed to reconcile config: ' + err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// POST /commit
|
||||
// Commit a previewed upload to the DB.
|
||||
// Body: { tempFile, filename, report_date }
|
||||
//
|
||||
// Body: { tempFile: string, filename: string, report_date: string }
|
||||
// Response: { upload: { id, filename, report_date, uploaded_at,
|
||||
// new_count, resolved_count, recurring_count } }
|
||||
// -----------------------------------------------------------------------
|
||||
router.post('/commit', requireRole('editor', 'admin'), async (req, res) => {
|
||||
router.post('/commit', requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||
const { tempFile, filename, report_date } = req.body;
|
||||
|
||||
if (!tempFile || typeof tempFile !== 'string') {
|
||||
@@ -340,6 +514,9 @@ function createComplianceRouter(db, upload, requireAuth, requireRole) {
|
||||
// -----------------------------------------------------------------------
|
||||
// GET /uploads
|
||||
// List all uploads, most recent first.
|
||||
//
|
||||
// Response: { uploads: [{ id, filename, report_date, uploaded_at,
|
||||
// new_count, resolved_count, recurring_count }] }
|
||||
// -----------------------------------------------------------------------
|
||||
router.get('/uploads', async (req, res) => {
|
||||
try {
|
||||
@@ -356,9 +533,133 @@ function createComplianceRouter(db, upload, requireAuth, requireRole) {
|
||||
}
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// POST /rollback/:uploadId
|
||||
// Admin-only. Rolls back a specific upload. Only the most recent upload
|
||||
// can be rolled back to avoid cascading data integrity issues.
|
||||
//
|
||||
// Params: uploadId — integer ID of the upload to roll back
|
||||
// Response: { message: string, rolled_back: { upload_id, filename,
|
||||
// report_date, items_deleted, items_reactivated } }
|
||||
//
|
||||
// Reversal logic:
|
||||
// 1. Delete items first seen in this upload (new items)
|
||||
// 2. Re-activate items resolved by this upload
|
||||
// 3. Revert recurring items: decrement seen_count, point upload_id
|
||||
// back to the previous upload
|
||||
// 4. Delete the upload record
|
||||
// -----------------------------------------------------------------------
|
||||
router.post('/rollback/:uploadId', requireGroup('Admin'), async (req, res) => {
|
||||
const uploadId = parseInt(req.params.uploadId, 10);
|
||||
if (isNaN(uploadId)) {
|
||||
return res.status(400).json({ error: 'Invalid upload ID' });
|
||||
}
|
||||
|
||||
try {
|
||||
// Verify the upload exists
|
||||
const upload = await dbGet(db,
|
||||
`SELECT id, filename, report_date, new_count, resolved_count, recurring_count
|
||||
FROM compliance_uploads WHERE id = ?`,
|
||||
[uploadId]
|
||||
);
|
||||
if (!upload) {
|
||||
return res.status(404).json({ error: 'Upload not found' });
|
||||
}
|
||||
|
||||
// Only allow rolling back the most recent upload
|
||||
const latest = await dbGet(db,
|
||||
`SELECT id FROM compliance_uploads ORDER BY id DESC LIMIT 1`
|
||||
);
|
||||
if (latest.id !== uploadId) {
|
||||
return res.status(400).json({
|
||||
error: 'Only the most recent upload can be rolled back',
|
||||
latest_upload_id: latest.id
|
||||
});
|
||||
}
|
||||
|
||||
// Find the previous upload (to restore recurring items' upload_id)
|
||||
const previousUpload = await dbGet(db,
|
||||
`SELECT id FROM compliance_uploads WHERE id < ? ORDER BY id DESC LIMIT 1`,
|
||||
[uploadId]
|
||||
);
|
||||
|
||||
await dbRun(db, 'BEGIN TRANSACTION');
|
||||
|
||||
try {
|
||||
// 1. Delete items that were NEW in this upload
|
||||
const deleteNew = await dbRun(db,
|
||||
`DELETE FROM compliance_items WHERE first_seen_upload_id = ? AND upload_id = ?`,
|
||||
[uploadId, uploadId]
|
||||
);
|
||||
|
||||
// 2. Re-activate items that were RESOLVED by this upload
|
||||
const reactivate = await dbRun(db,
|
||||
`UPDATE compliance_items
|
||||
SET status = 'active', resolved_upload_id = NULL
|
||||
WHERE resolved_upload_id = ?`,
|
||||
[uploadId]
|
||||
);
|
||||
|
||||
// 3. Revert RECURRING items: decrement seen_count, restore upload_id
|
||||
if (previousUpload) {
|
||||
await dbRun(db,
|
||||
`UPDATE compliance_items
|
||||
SET upload_id = ?, seen_count = MAX(seen_count - 1, 1)
|
||||
WHERE upload_id = ? AND first_seen_upload_id != ?`,
|
||||
[previousUpload.id, uploadId, uploadId]
|
||||
);
|
||||
}
|
||||
|
||||
// 4. Delete the upload record
|
||||
await dbRun(db, `DELETE FROM compliance_uploads WHERE id = ?`, [uploadId]);
|
||||
|
||||
await dbRun(db, 'COMMIT');
|
||||
|
||||
// Audit log
|
||||
logAudit(db, {
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'compliance_upload_rollback',
|
||||
entityType: 'compliance_upload',
|
||||
entityId: String(uploadId),
|
||||
details: {
|
||||
filename: upload.filename,
|
||||
report_date: upload.report_date,
|
||||
items_deleted: deleteNew.changes,
|
||||
items_reactivated: reactivate.changes,
|
||||
},
|
||||
ipAddress: req.ip,
|
||||
});
|
||||
|
||||
res.json({
|
||||
message: `Rolled back upload "${upload.filename}"`,
|
||||
rolled_back: {
|
||||
upload_id: uploadId,
|
||||
filename: upload.filename,
|
||||
report_date: upload.report_date,
|
||||
items_deleted: deleteNew.changes,
|
||||
items_reactivated: reactivate.changes,
|
||||
},
|
||||
});
|
||||
|
||||
} catch (err) {
|
||||
await dbRun(db, 'ROLLBACK').catch(() => {});
|
||||
throw err;
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
console.error('[Compliance] Rollback error:', err.message);
|
||||
res.status(500).json({ error: 'Failed to rollback upload: ' + err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// GET /summary?team=STEAM
|
||||
// Return metric health rows for a team from the latest upload's summary_json.
|
||||
//
|
||||
// Query: team — optional, one of ALLOWED_TEAMS
|
||||
// Response: { entries: [...], overall_scores: {}, upload: { id,
|
||||
// report_date, uploaded_at } | null }
|
||||
// -----------------------------------------------------------------------
|
||||
router.get('/summary', async (req, res) => {
|
||||
const team = req.query.team;
|
||||
@@ -402,6 +703,12 @@ function createComplianceRouter(db, upload, requireAuth, requireRole) {
|
||||
// -----------------------------------------------------------------------
|
||||
// GET /items?team=STEAM&status=active
|
||||
// Return non-compliant devices grouped by hostname.
|
||||
//
|
||||
// Query: team — required, one of ALLOWED_TEAMS
|
||||
// status — optional, 'active' (default) or 'resolved'
|
||||
// Response: { devices: [{ hostname, ip_address, device_type, team,
|
||||
// status, failing_metrics, seen_count, first_seen, last_seen,
|
||||
// resolved_on, has_notes }], team, status }
|
||||
// -----------------------------------------------------------------------
|
||||
router.get('/items', async (req, res) => {
|
||||
const { team, status = 'active' } = req.query;
|
||||
@@ -447,6 +754,12 @@ function createComplianceRouter(db, upload, requireAuth, requireRole) {
|
||||
// -----------------------------------------------------------------------
|
||||
// GET /items/:hostname
|
||||
// Detail panel: all metric rows for this hostname + notes + upload history.
|
||||
//
|
||||
// Params: hostname — device hostname string
|
||||
// Response: { hostname, ip_address, device_type, team,
|
||||
// metrics: [{ metric_id, metric_desc, category, status, seen_count,
|
||||
// extra, first_seen, last_seen, resolved_on, ... }],
|
||||
// notes: [{ id, metric_id, note, group_id, created_at, created_by }] }
|
||||
// -----------------------------------------------------------------------
|
||||
router.get('/items/:hostname', async (req, res) => {
|
||||
const hostname = req.params.hostname;
|
||||
@@ -488,7 +801,7 @@ function createComplianceRouter(db, upload, requireAuth, requireRole) {
|
||||
|
||||
// Notes (all metrics for this hostname, sorted newest first)
|
||||
const notes = await dbAll(db,
|
||||
`SELECT cn.id, cn.metric_id, cn.note, cn.created_at,
|
||||
`SELECT cn.id, cn.metric_id, cn.note, cn.group_id, cn.created_at,
|
||||
u.username AS created_by
|
||||
FROM compliance_notes cn
|
||||
LEFT JOIN users u ON cn.created_by = u.id
|
||||
@@ -517,42 +830,86 @@ function createComplianceRouter(db, upload, requireAuth, requireRole) {
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// POST /notes
|
||||
// Add a note to a (hostname, metric_id) pair.
|
||||
// Body: { hostname, metric_id, note }
|
||||
// Add a note to one or more (hostname, metric_id) pairs.
|
||||
//
|
||||
// Body: { hostname: string, metric_ids: string[], note: string }
|
||||
// — or legacy: { hostname: string, metric_id: string, note: string }
|
||||
// Response: { notes: [{ id, hostname, metric_id, note, group_id,
|
||||
// created_at, created_by }] }
|
||||
// -----------------------------------------------------------------------
|
||||
router.post('/notes', async (req, res) => {
|
||||
const { hostname, metric_id, note } = req.body;
|
||||
router.post('/notes', requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||
const { hostname, metric_id, metric_ids, note } = req.body;
|
||||
|
||||
if (!hostname || typeof hostname !== 'string' || hostname.length > 300) {
|
||||
return res.status(400).json({ error: 'Invalid hostname' });
|
||||
if (!hostname || typeof hostname !== 'string' || hostname.length > 300 || !/^[a-zA-Z0-9._-]+$/.test(hostname)) {
|
||||
return res.status(400).json({ error: 'Invalid hostname format' });
|
||||
}
|
||||
if (!metric_id || typeof metric_id !== 'string' || metric_id.length > 50) {
|
||||
return res.status(400).json({ error: 'Invalid metric_id' });
|
||||
|
||||
// --- Resolve metric IDs: metric_ids takes precedence over metric_id ---
|
||||
let resolvedIds;
|
||||
if (metric_ids !== undefined) {
|
||||
if (!Array.isArray(metric_ids)) {
|
||||
return res.status(400).json({ error: 'metric_ids must be an array' });
|
||||
}
|
||||
resolvedIds = metric_ids;
|
||||
} else if (metric_id !== undefined && metric_id !== null && metric_id !== '') {
|
||||
if (typeof metric_id !== 'string' || metric_id.length > 50) {
|
||||
return res.status(400).json({ error: 'Invalid metric_id' });
|
||||
}
|
||||
resolvedIds = [metric_id];
|
||||
} else {
|
||||
return res.status(400).json({ error: 'metric_id or metric_ids is required' });
|
||||
}
|
||||
|
||||
// --- Validate resolved metric IDs ---
|
||||
if (resolvedIds.length === 0) {
|
||||
return res.status(400).json({ error: 'At least one metric ID is required' });
|
||||
}
|
||||
for (let i = 0; i < resolvedIds.length; i++) {
|
||||
const mid = resolvedIds[i];
|
||||
if (!mid || typeof mid !== 'string' || mid.length === 0 || mid.length > 50) {
|
||||
return res.status(400).json({ error: `Invalid metric_id at index ${i}` });
|
||||
}
|
||||
}
|
||||
|
||||
const noteText = String(note || '').trim().slice(0, 1000);
|
||||
if (!noteText) {
|
||||
return res.status(400).json({ error: 'Note cannot be empty' });
|
||||
}
|
||||
|
||||
try {
|
||||
const { lastID } = await dbRun(db,
|
||||
`INSERT INTO compliance_notes (hostname, metric_id, note, created_by, created_at)
|
||||
VALUES (?, ?, ?, ?, datetime('now'))`,
|
||||
[hostname, metric_id, noteText, req.user?.id || null]
|
||||
);
|
||||
const groupId = crypto.randomUUID();
|
||||
const userId = req.user?.id || null;
|
||||
|
||||
const created = await dbGet(db,
|
||||
`SELECT cn.id, cn.hostname, cn.metric_id, cn.note, cn.created_at,
|
||||
try {
|
||||
await dbRun(db, 'BEGIN TRANSACTION');
|
||||
|
||||
const insertedIds = [];
|
||||
for (const mid of resolvedIds) {
|
||||
const { lastID } = await dbRun(db,
|
||||
`INSERT INTO compliance_notes (hostname, metric_id, note, group_id, created_by, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, datetime('now'))`,
|
||||
[hostname, mid, noteText, groupId, userId]
|
||||
);
|
||||
insertedIds.push(lastID);
|
||||
}
|
||||
|
||||
await dbRun(db, 'COMMIT');
|
||||
|
||||
// Fetch all created rows with username
|
||||
const placeholders = insertedIds.map(() => '?').join(', ');
|
||||
const notes = await dbAll(db,
|
||||
`SELECT cn.id, cn.hostname, cn.metric_id, cn.note, cn.group_id, cn.created_at,
|
||||
u.username AS created_by
|
||||
FROM compliance_notes cn
|
||||
LEFT JOIN users u ON cn.created_by = u.id
|
||||
WHERE cn.id = ?`,
|
||||
[lastID]
|
||||
WHERE cn.id IN (${placeholders})
|
||||
ORDER BY cn.id ASC`,
|
||||
insertedIds
|
||||
);
|
||||
|
||||
res.status(201).json(created);
|
||||
res.status(201).json({ notes });
|
||||
|
||||
} catch (err) {
|
||||
await dbRun(db, 'ROLLBACK').catch(() => {});
|
||||
console.error('[Compliance] POST /notes error:', err.message);
|
||||
res.status(500).json({ error: 'Failed to save note' });
|
||||
}
|
||||
@@ -561,6 +918,10 @@ function createComplianceRouter(db, upload, requireAuth, requireRole) {
|
||||
// -----------------------------------------------------------------------
|
||||
// GET /notes/:hostname/:metricId
|
||||
// Return all notes for a (hostname, metric_id) pair.
|
||||
//
|
||||
// Params: hostname — device hostname string
|
||||
// metricId — metric identifier string
|
||||
// Response: { notes: [{ id, note, created_at, created_by }] }
|
||||
// -----------------------------------------------------------------------
|
||||
router.get('/notes/:hostname/:metricId', async (req, res) => {
|
||||
const { hostname, metricId } = req.params;
|
||||
@@ -584,10 +945,76 @@ function createComplianceRouter(db, upload, requireAuth, requireRole) {
|
||||
}
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// DELETE /notes/:id
|
||||
// Delete a note (or all notes in the same group_id) by note ID.
|
||||
// Only the note author or an Admin can delete.
|
||||
//
|
||||
// Params: id — note row ID
|
||||
// Query: ?group=true — delete all notes sharing the same group_id
|
||||
// Response: { deleted: number }
|
||||
// -----------------------------------------------------------------------
|
||||
router.delete('/notes/:id', requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||
const noteId = parseInt(req.params.id, 10);
|
||||
if (isNaN(noteId)) return res.status(400).json({ error: 'Invalid note ID' });
|
||||
|
||||
const deleteGroup = req.query.group === 'true';
|
||||
|
||||
try {
|
||||
// Fetch the note to verify ownership
|
||||
const note = await dbGet(db,
|
||||
`SELECT id, hostname, metric_id, note, group_id, created_by FROM compliance_notes WHERE id = ?`,
|
||||
[noteId]
|
||||
);
|
||||
if (!note) return res.status(404).json({ error: 'Note not found' });
|
||||
|
||||
// Only the author or an Admin can delete
|
||||
const isAuthor = req.user && String(req.user.id) === String(note.created_by);
|
||||
const isAdminUser = req.user && req.user.group === 'Admin';
|
||||
if (!isAuthor && !isAdminUser) {
|
||||
return res.status(403).json({ error: 'You can only delete your own notes' });
|
||||
}
|
||||
|
||||
let deleted = 0;
|
||||
if (deleteGroup && note.group_id) {
|
||||
const result = await dbRun(db,
|
||||
`DELETE FROM compliance_notes WHERE group_id = ?`,
|
||||
[note.group_id]
|
||||
);
|
||||
deleted = result.changes || 0;
|
||||
} else {
|
||||
const result = await dbRun(db,
|
||||
`DELETE FROM compliance_notes WHERE id = ?`,
|
||||
[noteId]
|
||||
);
|
||||
deleted = result.changes || 0;
|
||||
}
|
||||
|
||||
logAudit(db, {
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'compliance_note_delete',
|
||||
entityType: 'compliance_note',
|
||||
entityId: String(noteId),
|
||||
details: JSON.stringify({ hostname: note.hostname, group_id: note.group_id, deleted_count: deleted }),
|
||||
ipAddress: req.ip,
|
||||
});
|
||||
|
||||
res.json({ deleted });
|
||||
} catch (err) {
|
||||
console.error('[Compliance] DELETE /notes error:', err.message);
|
||||
res.status(500).json({ error: 'Failed to delete note' });
|
||||
}
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// GET /trends
|
||||
// Per-upload active totals + per-team counts for time-series charts.
|
||||
// Returns rows ordered ascending by report_date.
|
||||
//
|
||||
// Response: { trends: [{ report_date, new_count, recurring_count,
|
||||
// resolved_count, total_active, STEAM, ACCESS-ENG, ACCESS-OPS,
|
||||
// INTELDEV }] }
|
||||
// -----------------------------------------------------------------------
|
||||
router.get('/trends', async (req, res) => {
|
||||
try {
|
||||
@@ -639,25 +1066,23 @@ function createComplianceRouter(db, upload, requireAuth, requireRole) {
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// GET /mttr
|
||||
// Mean time to resolution (calendar days) per team, for resolved items.
|
||||
// Aging Findings Distribution — active findings bucketed by seen_count
|
||||
// with per-team breakdown for stacked bar chart.
|
||||
//
|
||||
// Response: { aging: [{ bucket, total, STEAM, ACCESS-ENG, ACCESS-OPS, INTELDEV }] }
|
||||
// -----------------------------------------------------------------------
|
||||
router.get('/mttr', async (req, res) => {
|
||||
try {
|
||||
const rows = await dbAll(db,
|
||||
`SELECT
|
||||
ci.team,
|
||||
ROUND(AVG(JULIANDAY(ru.report_date) - JULIANDAY(fu.report_date)), 1) AS avg_days,
|
||||
COUNT(*) AS resolved_count
|
||||
FROM compliance_items ci
|
||||
JOIN compliance_uploads fu ON ci.first_seen_upload_id = fu.id
|
||||
JOIN compliance_uploads ru ON ci.resolved_upload_id = ru.id
|
||||
WHERE ci.resolved_upload_id IS NOT NULL
|
||||
AND fu.report_date IS NOT NULL
|
||||
AND ru.report_date IS NOT NULL
|
||||
GROUP BY ci.team
|
||||
ORDER BY avg_days DESC`
|
||||
`SELECT COALESCE(seen_count, 1) AS seen_count, team
|
||||
FROM compliance_items
|
||||
WHERE status = 'active'`
|
||||
);
|
||||
res.json({ mttr: rows });
|
||||
if (rows.length === 0) {
|
||||
return res.json({ aging: [] });
|
||||
}
|
||||
const aging = bucketAgingItems(rows);
|
||||
res.json({ aging });
|
||||
} catch (err) {
|
||||
console.error('[Compliance] GET /mttr error:', err.message);
|
||||
res.status(500).json({ error: 'Database error' });
|
||||
@@ -666,20 +1091,24 @@ function createComplianceRouter(db, upload, requireAuth, requireRole) {
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// GET /top-recurring
|
||||
// Active findings grouped by team + metric_id, sorted by seen_count desc.
|
||||
// Identifies chronic compliance gaps that keep reappearing.
|
||||
// Net Change Waterfall — per-cycle net movement (start → +new →
|
||||
// +recurring → −resolved → end) computed from compliance_uploads.
|
||||
//
|
||||
// Response: { waterfall: [{ date, start, new_count, recurring_count,
|
||||
// resolved_count, end }] }
|
||||
// -----------------------------------------------------------------------
|
||||
router.get('/top-recurring', async (req, res) => {
|
||||
try {
|
||||
const rows = await dbAll(db,
|
||||
`SELECT team, metric_id, metric_desc, seen_count, COUNT(*) AS host_count
|
||||
FROM compliance_items
|
||||
WHERE status = 'active'
|
||||
GROUP BY team, metric_id
|
||||
ORDER BY seen_count DESC, host_count DESC
|
||||
LIMIT 20`
|
||||
`SELECT id, report_date,
|
||||
COALESCE(new_count, 0) AS new_count,
|
||||
COALESCE(recurring_count, 0) AS recurring_count,
|
||||
COALESCE(resolved_count, 0) AS resolved_count
|
||||
FROM compliance_uploads
|
||||
ORDER BY report_date ASC`
|
||||
);
|
||||
res.json({ items: rows });
|
||||
const waterfall = computeWaterfall(rows);
|
||||
res.json({ waterfall });
|
||||
} catch (err) {
|
||||
console.error('[Compliance] GET /top-recurring error:', err.message);
|
||||
res.status(500).json({ error: 'Database error' });
|
||||
@@ -689,6 +1118,8 @@ function createComplianceRouter(db, upload, requireAuth, requireRole) {
|
||||
// -----------------------------------------------------------------------
|
||||
// GET /category-trend
|
||||
// Active item counts per category per upload, for stacked area chart.
|
||||
//
|
||||
// Response: { categoryTrend: [{ report_date, category, count }] }
|
||||
// -----------------------------------------------------------------------
|
||||
router.get('/category-trend', async (req, res) => {
|
||||
try {
|
||||
@@ -709,4 +1140,4 @@ function createComplianceRouter(db, upload, requireAuth, requireRole) {
|
||||
return router;
|
||||
}
|
||||
|
||||
module.exports = createComplianceRouter;
|
||||
module.exports = { createComplianceRouter, bucketAgingItems, computeWaterfall };
|
||||
|
||||
223
backend/routes/ivantiArchive.js
Normal file
223
backend/routes/ivantiArchive.js
Normal file
@@ -0,0 +1,223 @@
|
||||
// Ivanti Archive Routes — list, stats, and transition history for archived findings
|
||||
const express = require('express');
|
||||
|
||||
const VALID_STATES = ['ACTIVE', 'ARCHIVED', 'RETURNED', 'CLOSED'];
|
||||
|
||||
/**
|
||||
* Find the most severe active finding related to an archived finding.
|
||||
*
|
||||
* A match requires:
|
||||
* - Exact hostname match (case-sensitive)
|
||||
* - The archive title is a case-insensitive substring of the active title, or vice versa
|
||||
* - The active finding ID differs from the archive's finding_id
|
||||
*
|
||||
* @param {Object} archive - Archive record from ivanti_finding_archives
|
||||
* @param {Array} activeFindings - Parsed entries from ivanti_findings_cache
|
||||
* @returns {{ id: string, title: string, severity: number } | null}
|
||||
*/
|
||||
function findRelatedActive(archive, activeFindings) {
|
||||
const archiveTitle = (archive.finding_title || '').toLowerCase();
|
||||
|
||||
const matches = activeFindings.filter(f => {
|
||||
if (f.hostName !== archive.host_name) return false;
|
||||
if (f.id === archive.finding_id) return false;
|
||||
|
||||
const activeTitle = (f.title || '').toLowerCase();
|
||||
if (!archiveTitle.includes(activeTitle) && !activeTitle.includes(archiveTitle)) return false;
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
if (matches.length === 0) return null;
|
||||
|
||||
const best = matches.reduce((a, b) => (b.severity > a.severity ? b : a));
|
||||
return { id: best.id, title: best.title, severity: best.severity };
|
||||
}
|
||||
|
||||
function createIvantiArchiveRouter(db, requireAuth) {
|
||||
const router = express.Router();
|
||||
|
||||
// All routes require authentication
|
||||
router.use(requireAuth(db));
|
||||
|
||||
/**
|
||||
* GET /
|
||||
* List archive records with optional state filtering.
|
||||
*
|
||||
* @query {string} [state] - Filter by lifecycle state (ACTIVE, ARCHIVED, RETURNED, CLOSED)
|
||||
* @returns {Object} 200 - { archives: Array<ArchiveRecord>, total: number }
|
||||
* @returns {Object} 400 - { error: string } when state param is invalid
|
||||
* @returns {Object} 500 - { error: string } on database failure
|
||||
*/
|
||||
router.get('/', async (req, res) => {
|
||||
const { state } = req.query;
|
||||
|
||||
if (state && !VALID_STATES.includes(state)) {
|
||||
return res.status(400).json({
|
||||
error: 'Invalid state parameter. Valid values: ACTIVE, ARCHIVED, RETURNED, CLOSED'
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
let query = 'SELECT * FROM ivanti_finding_archives';
|
||||
const params = [];
|
||||
|
||||
if (state) {
|
||||
query += ' WHERE current_state = ?';
|
||||
params.push(state);
|
||||
}
|
||||
|
||||
query += ' ORDER BY last_transition_at DESC';
|
||||
|
||||
const archives = await new Promise((resolve, reject) => {
|
||||
db.all(query, params, (err, rows) => {
|
||||
if (err) reject(err);
|
||||
else resolve(rows || []);
|
||||
});
|
||||
});
|
||||
|
||||
// Fetch and parse active findings cache for related-finding enrichment
|
||||
let activeFindings = [];
|
||||
try {
|
||||
const cacheRow = await new Promise((resolve, reject) => {
|
||||
db.get(
|
||||
'SELECT findings_json FROM ivanti_findings_cache WHERE id = 1',
|
||||
(err, row) => {
|
||||
if (err) reject(err);
|
||||
else resolve(row);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
if (cacheRow && cacheRow.findings_json) {
|
||||
activeFindings = JSON.parse(cacheRow.findings_json);
|
||||
}
|
||||
} catch (cacheErr) {
|
||||
console.warn('Failed to load findings cache for related-active matching:', cacheErr);
|
||||
}
|
||||
|
||||
if (!Array.isArray(activeFindings)) {
|
||||
activeFindings = [];
|
||||
}
|
||||
|
||||
// Enrich each archive record with related active finding info
|
||||
const enrichedArchives = archives.map(archive => ({
|
||||
...archive,
|
||||
related_active: findRelatedActive(archive, activeFindings)
|
||||
}));
|
||||
|
||||
res.json({ archives: enrichedArchives, total: enrichedArchives.length });
|
||||
} catch (err) {
|
||||
console.error('Archive list error:', err);
|
||||
res.status(500).json({ error: 'Failed to fetch archive records' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /stats
|
||||
* Summary counts of archive records by lifecycle state.
|
||||
* ACTIVE is implicit: live findings in the cache that have no ARCHIVED/RETURNED archive record.
|
||||
*
|
||||
* @returns {Object} 200 - { ACTIVE: number, ARCHIVED: number, RETURNED: number, CLOSED: number, total: number }
|
||||
* @returns {Object} 500 - { error: string } on database failure
|
||||
*/
|
||||
router.get('/stats', async (req, res) => {
|
||||
try {
|
||||
// Count archive records by state
|
||||
const rows = await new Promise((resolve, reject) => {
|
||||
db.all(
|
||||
`SELECT current_state, COUNT(*) as count
|
||||
FROM ivanti_finding_archives
|
||||
GROUP BY current_state`,
|
||||
(err, rows) => {
|
||||
if (err) reject(err);
|
||||
else resolve(rows || []);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
const stats = { ACTIVE: 0, ARCHIVED: 0, RETURNED: 0, CLOSED: 0 };
|
||||
|
||||
for (const row of rows) {
|
||||
if (stats.hasOwnProperty(row.current_state)) {
|
||||
stats[row.current_state] = row.count;
|
||||
}
|
||||
}
|
||||
|
||||
// Compute ACTIVE: total live findings minus those with ARCHIVED or RETURNED records
|
||||
const cacheRow = await new Promise((resolve, reject) => {
|
||||
db.get(
|
||||
'SELECT total FROM ivanti_findings_cache WHERE id = 1',
|
||||
(err, row) => {
|
||||
if (err) reject(err);
|
||||
else resolve(row);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
const liveFindingsCount = (cacheRow && cacheRow.total) || 0;
|
||||
// Findings that are ARCHIVED or RETURNED are "missing" from the live set,
|
||||
// so ACTIVE = live count (all findings currently present in sync results)
|
||||
stats.ACTIVE = liveFindingsCount;
|
||||
|
||||
const total = stats.ACTIVE + stats.ARCHIVED + stats.RETURNED + stats.CLOSED;
|
||||
|
||||
res.json({ ...stats, total });
|
||||
} catch (err) {
|
||||
console.error('Archive stats error:', err);
|
||||
res.status(500).json({ error: 'Failed to fetch archive stats' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /:findingId/history
|
||||
* Transition history for a specific archived finding, ordered by most recent first.
|
||||
* Returns an empty transitions array if the finding has no archive record.
|
||||
*
|
||||
* @param {string} findingId - Ivanti finding identifier (route param)
|
||||
* @returns {Object} 200 - { finding_id: string, transitions: Array<TransitionRecord> }
|
||||
* @returns {Object} 500 - { error: string } on database failure
|
||||
*/
|
||||
router.get('/:findingId/history', async (req, res) => {
|
||||
const { findingId } = req.params;
|
||||
|
||||
try {
|
||||
const archive = await new Promise((resolve, reject) => {
|
||||
db.get(
|
||||
'SELECT id FROM ivanti_finding_archives WHERE finding_id = ?',
|
||||
[findingId],
|
||||
(err, row) => {
|
||||
if (err) reject(err);
|
||||
else resolve(row);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
if (!archive) {
|
||||
return res.json({ finding_id: findingId, transitions: [] });
|
||||
}
|
||||
|
||||
const transitions = await new Promise((resolve, reject) => {
|
||||
db.all(
|
||||
`SELECT * FROM ivanti_archive_transitions
|
||||
WHERE archive_id = ?
|
||||
ORDER BY transitioned_at DESC`,
|
||||
[archive.id],
|
||||
(err, rows) => {
|
||||
if (err) reject(err);
|
||||
else resolve(rows || []);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
res.json({ finding_id: findingId, transitions });
|
||||
} catch (err) {
|
||||
console.error('Archive history error:', err);
|
||||
res.status(500).json({ error: 'Failed to fetch transition history' });
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
module.exports = createIvantiArchiveRouter;
|
||||
File diff suppressed because it is too large
Load Diff
1431
backend/routes/ivantiFpWorkflow.js
Normal file
1431
backend/routes/ivantiFpWorkflow.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,92 +1,311 @@
|
||||
// routes/ivantiTodoQueue.js
|
||||
const express = require('express');
|
||||
const { requireGroup } = require('../middleware/auth');
|
||||
const logAudit = require('../helpers/auditLog');
|
||||
|
||||
const VALID_WORKFLOW_TYPES = ['FP', 'Archer', 'CARD'];
|
||||
const VALID_WORKFLOW_TYPES = ['FP', 'Archer', 'CARD', 'GRANITE'];
|
||||
const VALID_STATUSES = ['pending', 'complete'];
|
||||
|
||||
function isValidVendor(vendor) {
|
||||
return typeof vendor === 'string' && vendor.trim().length > 0 && vendor.length <= 200;
|
||||
if (typeof vendor !== 'string') return false;
|
||||
const trimmed = vendor.trim();
|
||||
return trimmed.length > 0 && trimmed.length <= 200;
|
||||
}
|
||||
|
||||
function createIvantiTodoQueueRouter(db, requireAuth) {
|
||||
const router = express.Router();
|
||||
|
||||
// GET /api/ivanti/todo-queue
|
||||
// Fetch current user's queue items, ordered by vendor then created_at
|
||||
/**
|
||||
* GET /api/ivanti/todo-queue
|
||||
*
|
||||
* Fetch the current user's queue items, ordered by vendor then created_at.
|
||||
*
|
||||
* @returns {Array<Object>} 200 - Array of queue items, each with:
|
||||
* id, user_id, finding_id, finding_title, cves_json, ip_address,
|
||||
* vendor, workflow_type, status, created_at, updated_at, cves (parsed array)
|
||||
* @returns {Object} 500 - { error: string } on database error
|
||||
*/
|
||||
router.get('/', requireAuth(db), (req, res) => {
|
||||
db.all(
|
||||
`SELECT * FROM ivanti_todo_queue
|
||||
WHERE user_id = ?
|
||||
ORDER BY vendor ASC, created_at ASC`,
|
||||
`SELECT q.*,
|
||||
o.value AS override_hostname
|
||||
FROM ivanti_todo_queue q
|
||||
LEFT JOIN ivanti_finding_overrides o
|
||||
ON o.finding_id = q.finding_id AND o.field = 'hostName'
|
||||
WHERE q.user_id = ?
|
||||
ORDER BY q.vendor ASC, q.created_at ASC`,
|
||||
[req.user.id],
|
||||
(err, rows) => {
|
||||
if (err) {
|
||||
console.error('Error fetching todo queue:', err);
|
||||
return res.status(500).json({ error: 'Internal server error.' });
|
||||
}
|
||||
// Parse cves_json back to array for each row
|
||||
// Parse cves_json back to array; prefer overridden hostname
|
||||
const parsed = rows.map((r) => ({
|
||||
...r,
|
||||
hostname: r.override_hostname || r.hostname,
|
||||
cves: r.cves_json ? JSON.parse(r.cves_json) : [],
|
||||
}));
|
||||
// Clean up the extra column from the response
|
||||
parsed.forEach((r) => delete r.override_hostname);
|
||||
res.json(parsed);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
// POST /api/ivanti/todo-queue
|
||||
// Add a finding to the queue
|
||||
router.post('/', requireAuth(db), (req, res) => {
|
||||
const { finding_id, finding_title, cves, ip_address, vendor, workflow_type } = req.body;
|
||||
/**
|
||||
* POST /api/ivanti/todo-queue/batch
|
||||
*
|
||||
* Add multiple findings to the current user's queue in a single transaction.
|
||||
*
|
||||
* @body {Object[]} findings - Required array of 1–200 finding objects
|
||||
* @body {string} findings[].finding_id - Required, non-empty finding identifier
|
||||
* @body {string} [findings[].finding_title] - Optional finding title (max 500 chars)
|
||||
* @body {string[]} [findings[].cves] - Optional array of CVE identifiers
|
||||
* @body {string} [findings[].ip_address] - Optional IP address (max 64 chars)
|
||||
* @body {string} [findings[].hostname] - Optional hostname (max 255 chars)
|
||||
* @body {string} workflow_type - One of 'FP', 'Archer', 'CARD', 'GRANITE'
|
||||
* @body {string} vendor - Required for FP/Archer (max 200 chars); optional for CARD/GRANITE
|
||||
*
|
||||
* @returns {Object} 201 - { items: Array<Object> } array of created queue items,
|
||||
* each with: id, user_id, finding_id, finding_title, cves_json, ip_address,
|
||||
* vendor, workflow_type, status, created_at, updated_at, cves (parsed array)
|
||||
* @returns {Object} 400 - { error: string } on validation failure
|
||||
* @returns {Object} 500 - { error: string } on database/transaction error (all inserts rolled back)
|
||||
*/
|
||||
router.post('/batch', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
|
||||
const { findings, workflow_type, vendor } = req.body;
|
||||
|
||||
// --- Validation ---
|
||||
if (!Array.isArray(findings) || findings.length < 1 || findings.length > 200) {
|
||||
return res.status(400).json({ error: 'findings array must contain 1-200 items.' });
|
||||
}
|
||||
|
||||
for (let i = 0; i < findings.length; i++) {
|
||||
const f = findings[i];
|
||||
if (!f || typeof f.finding_id !== 'string' || f.finding_id.trim().length === 0) {
|
||||
return res.status(400).json({ error: 'Each finding must have a non-empty finding_id string.' });
|
||||
}
|
||||
}
|
||||
|
||||
if (!VALID_WORKFLOW_TYPES.includes(workflow_type)) {
|
||||
return res.status(400).json({ error: 'workflow_type must be FP, Archer, CARD, or GRANITE.' });
|
||||
}
|
||||
|
||||
if (!['CARD', 'GRANITE'].includes(workflow_type)) {
|
||||
if (!isValidVendor(vendor)) {
|
||||
return res.status(400).json({ error: 'vendor is required for FP and Archer workflows.' });
|
||||
}
|
||||
}
|
||||
|
||||
if (vendor !== undefined && vendor !== '' && typeof vendor === 'string' && vendor.trim().length > 200) {
|
||||
return res.status(400).json({ error: 'vendor must be under 200 chars.' });
|
||||
}
|
||||
|
||||
const vendorVal = ['CARD', 'GRANITE'].includes(workflow_type) ? '' : vendor.trim();
|
||||
const userId = req.user.id;
|
||||
|
||||
// --- Transactional batch insert ---
|
||||
// Prepare all row values upfront
|
||||
const rows = findings.map((f) => {
|
||||
const findingId = f.finding_id.trim();
|
||||
const title = f.finding_title && typeof f.finding_title === 'string'
|
||||
? f.finding_title.slice(0, 500)
|
||||
: null;
|
||||
const cvesJson = Array.isArray(f.cves) ? JSON.stringify(f.cves) : null;
|
||||
const ipVal = f.ip_address && typeof f.ip_address === 'string'
|
||||
? f.ip_address.trim().slice(0, 64)
|
||||
: null;
|
||||
const hostVal = f.hostname && typeof f.hostname === 'string'
|
||||
? f.hostname.trim().slice(0, 255)
|
||||
: null;
|
||||
return [userId, findingId, title, cvesJson, ipVal, hostVal, vendorVal, workflow_type];
|
||||
});
|
||||
|
||||
const insertedIds = [];
|
||||
let insertError = null;
|
||||
let remaining = rows.length;
|
||||
|
||||
db.serialize(() => {
|
||||
db.run('BEGIN TRANSACTION');
|
||||
|
||||
rows.forEach((params) => {
|
||||
db.run(
|
||||
`INSERT INTO ivanti_todo_queue
|
||||
(user_id, finding_id, finding_title, cves_json, ip_address, hostname, vendor, workflow_type)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
params,
|
||||
function (err) {
|
||||
if (err && !insertError) {
|
||||
insertError = err;
|
||||
} else if (!err) {
|
||||
insertedIds.push(this.lastID);
|
||||
}
|
||||
remaining--;
|
||||
|
||||
// After all insert callbacks have fired, commit or rollback
|
||||
if (remaining === 0) {
|
||||
if (insertError) {
|
||||
db.run('ROLLBACK', () => {
|
||||
console.error('Batch insert error:', insertError);
|
||||
return res.status(500).json({ error: 'Internal server error.' });
|
||||
});
|
||||
} else {
|
||||
db.run('COMMIT', (commitErr) => {
|
||||
if (commitErr) {
|
||||
console.error('Batch commit error:', commitErr);
|
||||
db.run('ROLLBACK', () => {});
|
||||
return res.status(500).json({ error: 'Internal server error.' });
|
||||
}
|
||||
|
||||
// Fetch all inserted rows
|
||||
const placeholders = insertedIds.map(() => '?').join(',');
|
||||
db.all(
|
||||
`SELECT q.*, o.value AS override_hostname
|
||||
FROM ivanti_todo_queue q
|
||||
LEFT JOIN ivanti_finding_overrides o
|
||||
ON o.finding_id = q.finding_id AND o.field = 'hostName'
|
||||
WHERE q.id IN (${placeholders})`,
|
||||
insertedIds,
|
||||
(fetchErr, fetchedRows) => {
|
||||
if (fetchErr) {
|
||||
console.error('Error fetching inserted batch rows:', fetchErr);
|
||||
return res.status(500).json({ error: 'Internal server error.' });
|
||||
}
|
||||
|
||||
const items = (fetchedRows || []).map((r) => {
|
||||
const item = {
|
||||
...r,
|
||||
hostname: r.override_hostname || r.hostname,
|
||||
cves: r.cves_json ? JSON.parse(r.cves_json) : [],
|
||||
};
|
||||
delete item.override_hostname;
|
||||
return item;
|
||||
});
|
||||
|
||||
// Audit log (fire-and-forget)
|
||||
logAudit(db, {
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'batch_add_to_queue',
|
||||
entityType: 'ivanti_todo_queue',
|
||||
entityId: null,
|
||||
details: {
|
||||
count: insertedIds.length,
|
||||
workflow_type: workflow_type,
|
||||
finding_ids: findings.map((f) => f.finding_id.trim()),
|
||||
},
|
||||
ipAddress: req.ip,
|
||||
});
|
||||
|
||||
return res.status(201).json({ items });
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/ivanti/todo-queue
|
||||
*
|
||||
* Add a single finding to the current user's queue.
|
||||
*
|
||||
* @body {string} finding_id - Required, non-empty finding identifier
|
||||
* @body {string} [finding_title] - Optional finding title (max 500 chars)
|
||||
* @body {string[]} [cves] - Optional array of CVE identifiers
|
||||
* @body {string} [ip_address] - Optional IP address (max 64 chars)
|
||||
* @body {string} [hostname] - Optional hostname (max 255 chars)
|
||||
* @body {string} workflow_type - One of 'FP', 'Archer', 'CARD', 'GRANITE'
|
||||
* @body {string} vendor - Required for FP/Archer (max 200 chars); optional for CARD/GRANITE
|
||||
*
|
||||
* @returns {Object} 201 - Created queue item with parsed cves array:
|
||||
* id, user_id, finding_id, finding_title, cves_json, ip_address,
|
||||
* vendor, workflow_type, status, created_at, updated_at, cves
|
||||
* @returns {Object} 400 - { error: string } on validation failure
|
||||
* @returns {Object} 500 - { error: string } on database error
|
||||
*/
|
||||
router.post('/', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
|
||||
const { finding_id, finding_title, cves, ip_address, hostname, vendor, workflow_type } = req.body;
|
||||
|
||||
if (!finding_id || typeof finding_id !== 'string' || finding_id.trim().length === 0) {
|
||||
return res.status(400).json({ error: 'finding_id is required.' });
|
||||
}
|
||||
if (!VALID_WORKFLOW_TYPES.includes(workflow_type)) {
|
||||
return res.status(400).json({ error: 'workflow_type must be FP, Archer, or CARD.' });
|
||||
return res.status(400).json({ error: 'workflow_type must be FP, Archer, CARD, or GRANITE.' });
|
||||
}
|
||||
// Vendor is required for FP and Archer, optional for CARD
|
||||
if (workflow_type !== 'CARD' && !isValidVendor(vendor)) {
|
||||
// Vendor is required for FP and Archer, optional for CARD/GRANITE
|
||||
if (!['CARD', 'GRANITE'].includes(workflow_type) && !isValidVendor(vendor)) {
|
||||
return res.status(400).json({ error: 'vendor is required for FP and Archer workflows.' });
|
||||
}
|
||||
if (vendor !== undefined && vendor !== '' && !isValidVendor(vendor)) {
|
||||
return res.status(400).json({ error: 'vendor must be under 200 chars.' });
|
||||
}
|
||||
|
||||
const vendorVal = workflow_type === 'CARD' ? '' : vendor.trim();
|
||||
const vendorVal = ['CARD', 'GRANITE'].includes(workflow_type) ? '' : vendor.trim();
|
||||
const cvesJson = Array.isArray(cves) ? JSON.stringify(cves) : null;
|
||||
const ipVal = ip_address && typeof ip_address === 'string' ? ip_address.trim().slice(0, 64) : null;
|
||||
const hostVal = hostname && typeof hostname === 'string' ? hostname.trim().slice(0, 255) : null;
|
||||
const title = finding_title && typeof finding_title === 'string'
|
||||
? finding_title.slice(0, 500)
|
||||
: null;
|
||||
|
||||
db.run(
|
||||
`INSERT INTO ivanti_todo_queue
|
||||
(user_id, finding_id, finding_title, cves_json, ip_address, vendor, workflow_type)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
[req.user.id, finding_id.trim(), title, cvesJson, ipVal, vendorVal, workflow_type],
|
||||
(user_id, finding_id, finding_title, cves_json, ip_address, hostname, vendor, workflow_type)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[req.user.id, finding_id.trim(), title, cvesJson, ipVal, hostVal, vendorVal, workflow_type],
|
||||
function (err) {
|
||||
if (err) {
|
||||
console.error('Error adding to queue:', err);
|
||||
return res.status(500).json({ error: 'Internal server error.' });
|
||||
}
|
||||
db.get(
|
||||
'SELECT * FROM ivanti_todo_queue WHERE id = ?',
|
||||
`SELECT q.*, o.value AS override_hostname
|
||||
FROM ivanti_todo_queue q
|
||||
LEFT JOIN ivanti_finding_overrides o
|
||||
ON o.finding_id = q.finding_id AND o.field = 'hostName'
|
||||
WHERE q.id = ?`,
|
||||
[this.lastID],
|
||||
(err2, row) => {
|
||||
if (err2 || !row) {
|
||||
return res.status(201).json({ id: this.lastID, message: 'Added to queue.' });
|
||||
}
|
||||
res.status(201).json({ ...row, cves: row.cves_json ? JSON.parse(row.cves_json) : [] });
|
||||
const result = {
|
||||
...row,
|
||||
hostname: row.override_hostname || row.hostname,
|
||||
cves: row.cves_json ? JSON.parse(row.cves_json) : [],
|
||||
};
|
||||
delete result.override_hostname;
|
||||
res.status(201).json(result);
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
// PUT /api/ivanti/todo-queue/:id
|
||||
// Update vendor, workflow_type, or status — scoped to current user
|
||||
router.put('/:id', requireAuth(db), (req, res) => {
|
||||
/**
|
||||
* PUT /api/ivanti/todo-queue/:id
|
||||
*
|
||||
* Update vendor, workflow_type, or status on a queue item — scoped to current user.
|
||||
*
|
||||
* @param {string} id - Queue item ID (URL parameter)
|
||||
* @body {string} [vendor] - New vendor string (max 200 chars)
|
||||
* @body {string} [workflow_type] - One of 'FP', 'Archer', 'CARD', 'GRANITE'
|
||||
* @body {string} [status] - One of 'pending', 'complete'
|
||||
*
|
||||
* @returns {Object} 200 - Updated queue item with parsed cves array:
|
||||
* id, user_id, finding_id, finding_title, cves_json, ip_address,
|
||||
* vendor, workflow_type, status, created_at, updated_at, cves
|
||||
* @returns {Object} 400 - { error: string } on validation failure or no fields to update
|
||||
* @returns {Object} 404 - { error: string } if item not found for current user
|
||||
* @returns {Object} 500 - { error: string } on database error
|
||||
*/
|
||||
router.put('/:id', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
|
||||
const { id } = req.params;
|
||||
const { vendor, workflow_type, status } = req.body;
|
||||
|
||||
@@ -94,7 +313,7 @@ function createIvantiTodoQueueRouter(db, requireAuth) {
|
||||
return res.status(400).json({ error: 'vendor must be a non-empty string (max 200 chars).' });
|
||||
}
|
||||
if (workflow_type !== undefined && !VALID_WORKFLOW_TYPES.includes(workflow_type)) {
|
||||
return res.status(400).json({ error: 'workflow_type must be FP or Archer.' });
|
||||
return res.status(400).json({ error: 'workflow_type must be FP, Archer, CARD, or GRANITE.' });
|
||||
}
|
||||
if (status !== undefined && !VALID_STATUSES.includes(status)) {
|
||||
return res.status(400).json({ error: 'status must be pending or complete.' });
|
||||
@@ -144,13 +363,23 @@ function createIvantiTodoQueueRouter(db, requireAuth) {
|
||||
return res.status(500).json({ error: 'Internal server error.' });
|
||||
}
|
||||
db.get(
|
||||
'SELECT * FROM ivanti_todo_queue WHERE id = ?',
|
||||
`SELECT q.*, o.value AS override_hostname
|
||||
FROM ivanti_todo_queue q
|
||||
LEFT JOIN ivanti_finding_overrides o
|
||||
ON o.finding_id = q.finding_id AND o.field = 'hostName'
|
||||
WHERE q.id = ?`,
|
||||
[id],
|
||||
(err3, row) => {
|
||||
if (err3 || !row) {
|
||||
return res.json({ message: 'Queue item updated.' });
|
||||
}
|
||||
res.json({ ...row, cves: row.cves_json ? JSON.parse(row.cves_json) : [] });
|
||||
const result = {
|
||||
...row,
|
||||
hostname: row.override_hostname || row.hostname,
|
||||
cves: row.cves_json ? JSON.parse(row.cves_json) : [],
|
||||
};
|
||||
delete result.override_hostname;
|
||||
res.json(result);
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -159,10 +388,127 @@ function createIvantiTodoQueueRouter(db, requireAuth) {
|
||||
);
|
||||
});
|
||||
|
||||
// DELETE /api/ivanti/todo-queue/completed
|
||||
// Bulk-delete all completed items for the current user
|
||||
// IMPORTANT: This route must be registered BEFORE DELETE /:id
|
||||
router.delete('/completed', requireAuth(db), (req, res) => {
|
||||
/**
|
||||
* POST /api/ivanti/todo-queue/:id/redirect
|
||||
*
|
||||
* Redirect a completed queue item to a different workflow type.
|
||||
* Creates a new pending item copying finding data from the original.
|
||||
*
|
||||
* @param {string} id - Original queue item ID (URL parameter)
|
||||
* @body {string} workflow_type - Target workflow type: 'FP', 'Archer', 'CARD', or 'GRANITE'
|
||||
* @body {string} [vendor] - Required for FP/Archer (max 200 chars); ignored for CARD/GRANITE
|
||||
*
|
||||
* @returns {Object} 201 - Newly created queue item with parsed cves array
|
||||
* @returns {Object} 400 - { error: string } on validation failure or item not complete
|
||||
* @returns {Object} 404 - { error: string } if item not found for current user
|
||||
* @returns {Object} 500 - { error: string } on database error
|
||||
*/
|
||||
router.post('/:id/redirect', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
|
||||
const { id } = req.params;
|
||||
const { workflow_type, vendor } = req.body;
|
||||
|
||||
// --- Validation ---
|
||||
if (!VALID_WORKFLOW_TYPES.includes(workflow_type)) {
|
||||
return res.status(400).json({ error: 'workflow_type must be FP, Archer, CARD, or GRANITE.' });
|
||||
}
|
||||
|
||||
if (!['CARD', 'GRANITE'].includes(workflow_type)) {
|
||||
if (!isValidVendor(vendor)) {
|
||||
return res.status(400).json({ error: 'vendor is required for FP and Archer workflows.' });
|
||||
}
|
||||
}
|
||||
|
||||
if (vendor !== undefined && vendor !== '' && typeof vendor === 'string' && vendor.trim().length > 200) {
|
||||
return res.status(400).json({ error: 'vendor must be under 200 chars.' });
|
||||
}
|
||||
|
||||
const vendorVal = ['CARD', 'GRANITE'].includes(workflow_type) ? '' : vendor.trim();
|
||||
|
||||
// --- Fetch original item scoped to current user ---
|
||||
db.get(
|
||||
'SELECT * FROM ivanti_todo_queue WHERE id = ? AND user_id = ?',
|
||||
[id, req.user.id],
|
||||
(err, original) => {
|
||||
if (err) {
|
||||
console.error('Error fetching queue item for redirect:', err);
|
||||
return res.status(500).json({ error: 'Internal server error.' });
|
||||
}
|
||||
if (!original) {
|
||||
return res.status(404).json({ error: 'Queue item not found.' });
|
||||
}
|
||||
if (original.status !== 'complete') {
|
||||
return res.status(400).json({ error: 'Only completed queue items can be redirected.' });
|
||||
}
|
||||
|
||||
// --- INSERT new row copying finding data from original ---
|
||||
db.run(
|
||||
`INSERT INTO ivanti_todo_queue
|
||||
(user_id, finding_id, finding_title, cves_json, ip_address, hostname, vendor, workflow_type)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[req.user.id, original.finding_id, original.finding_title, original.cves_json, original.ip_address, original.hostname, vendorVal, workflow_type],
|
||||
function (insertErr) {
|
||||
if (insertErr) {
|
||||
console.error('Error inserting redirected queue item:', insertErr);
|
||||
return res.status(500).json({ error: 'Internal server error.' });
|
||||
}
|
||||
|
||||
const newId = this.lastID;
|
||||
|
||||
// --- Fetch the inserted row ---
|
||||
db.get(
|
||||
`SELECT q.*, o.value AS override_hostname
|
||||
FROM ivanti_todo_queue q
|
||||
LEFT JOIN ivanti_finding_overrides o
|
||||
ON o.finding_id = q.finding_id AND o.field = 'hostName'
|
||||
WHERE q.id = ?`,
|
||||
[newId],
|
||||
(fetchErr, row) => {
|
||||
if (fetchErr || !row) {
|
||||
console.error('Error fetching redirected queue item:', fetchErr);
|
||||
return res.status(500).json({ error: 'Internal server error.' });
|
||||
}
|
||||
|
||||
// Audit log (fire-and-forget)
|
||||
logAudit(db, {
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'queue_item_redirected',
|
||||
entityType: 'ivanti_todo_queue',
|
||||
entityId: String(original.id),
|
||||
details: {
|
||||
original_workflow_type: original.workflow_type,
|
||||
target_workflow_type: workflow_type,
|
||||
new_item_id: newId,
|
||||
vendor: vendorVal,
|
||||
},
|
||||
ipAddress: req.ip,
|
||||
});
|
||||
|
||||
const result = {
|
||||
...row,
|
||||
hostname: row.override_hostname || row.hostname,
|
||||
cves: row.cves_json ? JSON.parse(row.cves_json) : [],
|
||||
};
|
||||
delete result.override_hostname;
|
||||
return res.status(201).json(result);
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/ivanti/todo-queue/completed
|
||||
*
|
||||
* Bulk-delete all completed items for the current user.
|
||||
* IMPORTANT: This route must be registered BEFORE DELETE /:id.
|
||||
*
|
||||
* @returns {Object} 200 - { message: string, deleted: number }
|
||||
* @returns {Object} 500 - { error: string } on database error
|
||||
*/
|
||||
router.delete('/completed', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
|
||||
db.run(
|
||||
"DELETE FROM ivanti_todo_queue WHERE user_id = ? AND status = 'complete'",
|
||||
[req.user.id],
|
||||
@@ -176,9 +522,18 @@ function createIvantiTodoQueueRouter(db, requireAuth) {
|
||||
);
|
||||
});
|
||||
|
||||
// DELETE /api/ivanti/todo-queue/:id
|
||||
// Delete a single item — scoped to current user
|
||||
router.delete('/:id', requireAuth(db), (req, res) => {
|
||||
/**
|
||||
* DELETE /api/ivanti/todo-queue/:id
|
||||
*
|
||||
* Delete a single queue item — scoped to current user.
|
||||
*
|
||||
* @param {string} id - Queue item ID (URL parameter)
|
||||
*
|
||||
* @returns {Object} 200 - { message: string }
|
||||
* @returns {Object} 404 - { error: string } if item not found for current user
|
||||
* @returns {Object} 500 - { error: string } on database error
|
||||
*/
|
||||
router.delete('/:id', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
|
||||
const { id } = req.params;
|
||||
|
||||
db.get(
|
||||
|
||||
@@ -4,48 +4,11 @@
|
||||
// Error codes: 401 bad key, 419 insufficient privileges, 429 rate limited
|
||||
|
||||
const express = require('express');
|
||||
const https = require('https');
|
||||
const { requireGroup } = require('../middleware/auth');
|
||||
const { ivantiPost } = require('../helpers/ivantiApi');
|
||||
|
||||
const IVANTI_URL_BASE = 'https://platform4.risksense.com/api/v1';
|
||||
const SYNC_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HTTP helper — uses Node's https module directly so we can toggle
|
||||
// rejectUnauthorized for Charter's SSL inspection proxy (IVANTI_SKIP_TLS=true)
|
||||
// ---------------------------------------------------------------------------
|
||||
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: 15000
|
||||
};
|
||||
|
||||
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();
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Ensure the sync state table exists (idempotent — safe to call on every start)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -259,7 +222,7 @@ function createIvantiWorkflowsRouter(db, requireAuth) {
|
||||
});
|
||||
|
||||
// POST /sync — trigger an immediate sync, await completion, return fresh state
|
||||
router.post('/sync', async (req, res) => {
|
||||
router.post('/sync', requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||
await syncWorkflows(db);
|
||||
try {
|
||||
res.json(await readState(db));
|
||||
|
||||
809
backend/routes/jiraTickets.js
Normal file
809
backend/routes/jiraTickets.js
Normal file
@@ -0,0 +1,809 @@
|
||||
// routes/jiraTickets.js
|
||||
// Jira ticket CRUD + Jira REST API integration endpoints.
|
||||
// Extracted from server.js inline endpoints and extended with live Jira
|
||||
// operations (lookup, sync, create-in-jira, connection test).
|
||||
//
|
||||
// Charter Jira REST API compliance:
|
||||
// - All GETs include explicit field lists (no /rest/api/2/field)
|
||||
// - Sync uses bulk JQL search, not one-issue-at-a-time GETs
|
||||
// - No /rest/api/2/issue/bulk — updates are one at a time
|
||||
// - Inter-request delays enforced in jiraApi.js (1s GET, 2s write)
|
||||
// - Rate limits enforced client-side (1440/day, 60/min burst)
|
||||
|
||||
const express = require('express');
|
||||
const { requireAuth, requireGroup } = require('../middleware/auth');
|
||||
const logAudit = require('../helpers/auditLog');
|
||||
const jiraApi = require('../helpers/jiraApi');
|
||||
|
||||
// Validation helpers
|
||||
const CVE_ID_PATTERN = /^CVE-\d{4}-\d{4,}$/;
|
||||
const VALID_TICKET_STATUSES = ['Open', 'In Progress', 'Closed'];
|
||||
|
||||
function isValidCveId(cveId) {
|
||||
return typeof cveId === 'string' && CVE_ID_PATTERN.test(cveId);
|
||||
}
|
||||
|
||||
function isValidVendor(vendor) {
|
||||
return typeof vendor === 'string' && vendor.trim().length > 0 && vendor.length <= 200;
|
||||
}
|
||||
|
||||
function createJiraTicketsRouter(db) {
|
||||
const router = express.Router();
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Jira API integration endpoints
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* GET /api/jira/connection-test
|
||||
*
|
||||
* Verify Jira credentials and connectivity by testing the configured
|
||||
* Jira API connection. Admin only.
|
||||
*
|
||||
* @returns {object} 200 - { connected: true, user: { name, ... } }
|
||||
* @returns {object} 502 - { connected: false, status, error } | { connected: false, error }
|
||||
* @returns {object} 503 - { error } when Jira API is not configured
|
||||
*/
|
||||
router.get('/connection-test', requireAuth(db), requireGroup('Admin'), async (req, res) => {
|
||||
if (!jiraApi.isConfigured) {
|
||||
return res.status(503).json({ error: 'Jira API is not configured. Set JIRA_BASE_URL and credentials in backend/.env.' });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await jiraApi.testConnection();
|
||||
if (result.ok) {
|
||||
logAudit(db, {
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'jira_connection_test',
|
||||
entityType: 'jira_integration',
|
||||
entityId: null,
|
||||
details: { success: true, user: result.user.name },
|
||||
ipAddress: req.ip
|
||||
});
|
||||
return res.json({ connected: true, user: result.user });
|
||||
}
|
||||
return res.status(502).json({ connected: false, status: result.status, error: result.body || result.error });
|
||||
} catch (err) {
|
||||
return res.status(502).json({ connected: false, error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/jira/rate-limit
|
||||
*
|
||||
* Return current Jira API rate limit usage. Admin only.
|
||||
*
|
||||
* @returns {object} 200 - { burst: { remaining, limit, ... }, daily: { remaining, limit, ... } }
|
||||
*/
|
||||
router.get('/rate-limit', requireAuth(db), requireGroup('Admin'), (req, res) => {
|
||||
res.json(jiraApi.getRateLimitStatus());
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/jira/lookup/:issueKey
|
||||
*
|
||||
* Fetch a single issue from Jira by its issue key (e.g., PROJECT-123).
|
||||
* Uses explicit `?fields=` parameter per Charter Jira REST API requirement.
|
||||
*
|
||||
* @param {string} issueKey - Jira issue key (path parameter, format: PROJECT-123)
|
||||
* @returns {object} 200 - { key, summary, status, assignee, priority, issuetype, created, updated, self }
|
||||
* @returns {object} 400 - { error } when issue key format is invalid
|
||||
* @returns {object} 404 - { error } when issue not found in Jira
|
||||
* @returns {object} 429 - { error } when Jira rate limit exceeded
|
||||
* @returns {object} 502 - { error, details } on Jira API error
|
||||
* @returns {object} 503 - { error } when Jira API is not configured
|
||||
*/
|
||||
router.get('/lookup/:issueKey', requireAuth(db), async (req, res) => {
|
||||
if (!jiraApi.isConfigured) {
|
||||
return res.status(503).json({ error: 'Jira API is not configured.' });
|
||||
}
|
||||
|
||||
const { issueKey } = req.params;
|
||||
if (!issueKey || !/^[A-Z][A-Z0-9_]+-\d+$/.test(issueKey)) {
|
||||
return res.status(400).json({ error: 'Invalid Jira issue key format. Expected PROJECT-123.' });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await jiraApi.getIssue(issueKey);
|
||||
if (result.ok) {
|
||||
const issue = result.data;
|
||||
return res.json({
|
||||
key: issue.key,
|
||||
summary: issue.fields.summary,
|
||||
status: issue.fields.status ? issue.fields.status.name : null,
|
||||
assignee: issue.fields.assignee ? issue.fields.assignee.displayName : null,
|
||||
priority: issue.fields.priority ? issue.fields.priority.name : null,
|
||||
issuetype: issue.fields.issuetype ? issue.fields.issuetype.name : null,
|
||||
created: issue.fields.created,
|
||||
updated: issue.fields.updated,
|
||||
self: issue.self
|
||||
});
|
||||
}
|
||||
if (result.rateLimited) {
|
||||
return res.status(429).json({ error: 'Jira rate limit exceeded. Try again later.' });
|
||||
}
|
||||
return res.status(result.status === 404 ? 404 : 502).json({
|
||||
error: result.status === 404 ? 'Issue not found in Jira.' : 'Jira API error.',
|
||||
details: result.body
|
||||
});
|
||||
} catch (err) {
|
||||
return res.status(502).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/jira/search
|
||||
*
|
||||
* Search Jira issues using a JQL query. Results are capped at 1000 per page.
|
||||
* Charter compliance: JQL must include project+updated, assignee+updated,
|
||||
* or status+updated. Fields are always specified explicitly.
|
||||
*
|
||||
* @body {string} jql - JQL query string (required, max 2000 chars)
|
||||
* @body {number} [startAt] - Pagination offset
|
||||
* @body {number} [maxResults] - Page size (max 1000)
|
||||
* @body {string[]} [fields] - Explicit field list for the Jira response
|
||||
* @returns {object} 200 - { total, startAt, maxResults, issues: [{ key, summary, status, assignee, priority, issuetype, created, updated }] }
|
||||
* @returns {object} 400 - { error } when JQL is missing or too long
|
||||
* @returns {object} 429 - { error } when Jira rate limit exceeded
|
||||
* @returns {object} 502 - { error, details } on Jira search failure
|
||||
* @returns {object} 503 - { error } when Jira API is not configured
|
||||
*/
|
||||
router.post('/search', requireAuth(db), async (req, res) => {
|
||||
if (!jiraApi.isConfigured) {
|
||||
return res.status(503).json({ error: 'Jira API is not configured.' });
|
||||
}
|
||||
|
||||
const { jql, startAt, maxResults, fields } = req.body;
|
||||
if (!jql || typeof jql !== 'string' || jql.trim().length === 0) {
|
||||
return res.status(400).json({ error: 'JQL query is required.' });
|
||||
}
|
||||
if (jql.length > 2000) {
|
||||
return res.status(400).json({ error: 'JQL query too long (max 2000 chars).' });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await jiraApi.searchIssues(jql, {
|
||||
startAt,
|
||||
maxResults: Math.min(maxResults || 1000, 1000),
|
||||
fields: fields || undefined
|
||||
});
|
||||
if (result.ok) {
|
||||
const data = result.data;
|
||||
return res.json({
|
||||
total: data.total,
|
||||
startAt: data.startAt,
|
||||
maxResults: data.maxResults,
|
||||
issues: (data.issues || []).map(issue => ({
|
||||
key: issue.key,
|
||||
summary: issue.fields.summary,
|
||||
status: issue.fields.status ? issue.fields.status.name : null,
|
||||
assignee: issue.fields.assignee ? issue.fields.assignee.displayName : null,
|
||||
priority: issue.fields.priority ? issue.fields.priority.name : null,
|
||||
issuetype: issue.fields.issuetype ? issue.fields.issuetype.name : null,
|
||||
created: issue.fields.created,
|
||||
updated: issue.fields.updated
|
||||
}))
|
||||
});
|
||||
}
|
||||
if (result.rateLimited) {
|
||||
return res.status(429).json({ error: 'Jira rate limit exceeded. Try again later.' });
|
||||
}
|
||||
return res.status(502).json({ error: 'Jira search failed.', details: result.body });
|
||||
} catch (err) {
|
||||
return res.status(502).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/jira/create-in-jira
|
||||
*
|
||||
* Create a new issue in Jira via the REST API and insert a linked local
|
||||
* record in the `jira_tickets` table. Requires Admin or Standard_User group.
|
||||
* Subject to 2s write delay enforced by jiraApi.
|
||||
*
|
||||
* @body {string} cve_id - CVE identifier (required, format: CVE-YYYY-NNNNN)
|
||||
* @body {string} vendor - Vendor name (required, max 200 chars)
|
||||
* @body {string} summary - Issue summary (required, max 255 chars)
|
||||
* @body {string} [description] - Issue description
|
||||
* @body {string} [project_key] - Jira project key (defaults to JIRA_PROJECT_KEY env var)
|
||||
* @body {string} [issue_type] - Jira issue type name (defaults to JIRA_ISSUE_TYPE env var)
|
||||
* @returns {object} 201 - { id, ticket_key, jira_url, message }
|
||||
* @returns {object} 207 - { warning, jira_key, jira_url, error } when Jira issue created but local save failed
|
||||
* @returns {object} 400 - { error } on validation failure
|
||||
* @returns {object} 429 - { error } when Jira rate limit exceeded
|
||||
* @returns {object} 502 - { error, details } on Jira API failure
|
||||
* @returns {object} 503 - { error } when Jira API is not configured
|
||||
*/
|
||||
router.post('/create-in-jira', requireAuth(db), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||
if (!jiraApi.isConfigured) {
|
||||
return res.status(503).json({ error: 'Jira API is not configured.' });
|
||||
}
|
||||
|
||||
const { cve_id, vendor, summary, description, project_key, issue_type } = req.body;
|
||||
|
||||
if (!cve_id || !isValidCveId(cve_id)) {
|
||||
return res.status(400).json({ error: 'Valid CVE ID is required.' });
|
||||
}
|
||||
if (!vendor || !isValidVendor(vendor)) {
|
||||
return res.status(400).json({ error: 'Valid vendor is required.' });
|
||||
}
|
||||
if (!summary || typeof summary !== 'string' || summary.trim().length === 0 || summary.length > 255) {
|
||||
return res.status(400).json({ error: 'Summary is required (max 255 chars).' });
|
||||
}
|
||||
|
||||
const projectKey = project_key || jiraApi.JIRA_PROJECT_KEY;
|
||||
const issueType = issue_type || jiraApi.JIRA_ISSUE_TYPE;
|
||||
|
||||
if (!projectKey) {
|
||||
return res.status(400).json({ error: 'Project key is required. Set JIRA_PROJECT_KEY in .env or provide project_key in request.' });
|
||||
}
|
||||
|
||||
const fields = {
|
||||
project: { key: projectKey },
|
||||
summary: summary.trim(),
|
||||
issuetype: { name: issueType }
|
||||
};
|
||||
|
||||
if (description) {
|
||||
fields.description = description;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await jiraApi.createIssue(fields);
|
||||
if (!result.ok) {
|
||||
if (result.rateLimited) {
|
||||
return res.status(429).json({ error: 'Jira rate limit exceeded. Try again later.' });
|
||||
}
|
||||
return res.status(502).json({ error: 'Failed to create Jira issue.', details: result.body });
|
||||
}
|
||||
|
||||
const jiraIssue = result.data;
|
||||
const ticketKey = jiraIssue.key;
|
||||
const jiraUrl = jiraIssue.self
|
||||
? jiraIssue.self.replace(/\/rest\/api\/2\/issue\/.*/, `/browse/${ticketKey}`)
|
||||
: null;
|
||||
|
||||
db.run(
|
||||
`INSERT INTO jira_tickets (cve_id, vendor, ticket_key, url, summary, status, jira_id, jira_status, last_synced_at, created_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, datetime('now'), ?)`,
|
||||
[cve_id, vendor, ticketKey, jiraUrl, summary.trim(), 'Open', jiraIssue.id, 'Open', req.user.id],
|
||||
function(err) {
|
||||
if (err) {
|
||||
console.error('Error saving local Jira ticket record:', err);
|
||||
return res.status(207).json({
|
||||
warning: 'Issue created in Jira but local record failed to save.',
|
||||
jira_key: ticketKey,
|
||||
jira_url: jiraUrl,
|
||||
error: err.message
|
||||
});
|
||||
}
|
||||
|
||||
logAudit(db, {
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'jira_ticket_create_via_api',
|
||||
entityType: 'jira_ticket',
|
||||
entityId: this.lastID.toString(),
|
||||
details: { cve_id, vendor, ticket_key: ticketKey, jira_id: jiraIssue.id, project_key: projectKey },
|
||||
ipAddress: req.ip
|
||||
});
|
||||
|
||||
res.status(201).json({
|
||||
id: this.lastID,
|
||||
ticket_key: ticketKey,
|
||||
jira_url: jiraUrl,
|
||||
message: 'Jira issue created and linked successfully'
|
||||
});
|
||||
}
|
||||
);
|
||||
} catch (err) {
|
||||
return res.status(502).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/jira/sync-all
|
||||
*
|
||||
* Bulk-sync all local tickets that have a Jira key by fetching their
|
||||
* latest status from Jira. Uses a single JQL bulk search per batch
|
||||
* instead of one GET per ticket (Charter-compliant). Stops early if
|
||||
* the rate limit budget is running low. Admin only.
|
||||
*
|
||||
* @returns {object} 200 - { synced, failed, skipped, unchanged, errors: string[] }
|
||||
* @returns {object} 500 - { error } on database error
|
||||
* @returns {object} 503 - { error } when Jira API is not configured
|
||||
*/
|
||||
router.post('/sync-all', requireAuth(db), requireGroup('Admin'), async (req, res) => {
|
||||
if (!jiraApi.isConfigured) {
|
||||
return res.status(503).json({ error: 'Jira API is not configured.' });
|
||||
}
|
||||
|
||||
db.all(
|
||||
"SELECT * FROM jira_tickets WHERE ticket_key IS NOT NULL AND ticket_key != ''",
|
||||
[],
|
||||
async (err, tickets) => {
|
||||
if (err) {
|
||||
console.error(err);
|
||||
return res.status(500).json({ error: 'Internal server error.' });
|
||||
}
|
||||
|
||||
if (tickets.length === 0) {
|
||||
return res.json({ synced: 0, failed: 0, skipped: 0, unchanged: 0, errors: [] });
|
||||
}
|
||||
|
||||
const results = { synced: 0, failed: 0, skipped: 0, unchanged: 0, errors: [] };
|
||||
|
||||
// Batch keys into groups of 100 for JQL (avoid overly long queries)
|
||||
const BATCH_SIZE = 100;
|
||||
const batches = [];
|
||||
for (let i = 0; i < tickets.length; i += BATCH_SIZE) {
|
||||
batches.push(tickets.slice(i, i + BATCH_SIZE));
|
||||
}
|
||||
|
||||
for (const batch of batches) {
|
||||
// Check rate limit before each batch
|
||||
const rateStatus = jiraApi.getRateLimitStatus();
|
||||
if (rateStatus.burst.remaining <= 5 || rateStatus.daily.remaining <= 10) {
|
||||
const remaining = tickets.length - results.synced - results.failed - results.unchanged;
|
||||
results.skipped += remaining;
|
||||
results.errors.push('Rate limit approaching — stopped sync early to preserve budget.');
|
||||
break;
|
||||
}
|
||||
|
||||
const keys = batch.map(t => t.ticket_key);
|
||||
try {
|
||||
// Bulk JQL search — Charter-compliant, single request per batch
|
||||
const result = await jiraApi.searchIssuesByKeys(keys);
|
||||
if (!result.ok) {
|
||||
if (result.rateLimited) {
|
||||
results.skipped += batch.length;
|
||||
results.errors.push('Jira rate limit hit during sync.');
|
||||
break;
|
||||
}
|
||||
results.failed += batch.length;
|
||||
results.errors.push(`Batch search failed: HTTP ${result.status}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Build a map of key → Jira issue data
|
||||
const issueMap = {};
|
||||
for (const issue of (result.data.issues || [])) {
|
||||
issueMap[issue.key] = issue;
|
||||
}
|
||||
|
||||
// Update each local ticket from the search results
|
||||
for (const ticket of batch) {
|
||||
const issue = issueMap[ticket.ticket_key];
|
||||
if (!issue) {
|
||||
// Issue not returned — either not updated in last 24h or not found
|
||||
results.unchanged++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const jiraStatus = issue.fields.status ? issue.fields.status.name : null;
|
||||
const jiraSummary = issue.fields.summary || ticket.summary;
|
||||
const localStatus = mapJiraStatusToLocal(jiraStatus);
|
||||
|
||||
try {
|
||||
await new Promise((resolve, reject) => {
|
||||
db.run(
|
||||
`UPDATE jira_tickets SET summary = ?, status = ?, jira_status = ?, last_synced_at = datetime('now'), updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
|
||||
[jiraSummary, localStatus, jiraStatus, ticket.id],
|
||||
(updateErr) => updateErr ? reject(updateErr) : resolve()
|
||||
);
|
||||
});
|
||||
results.synced++;
|
||||
} catch (dbErr) {
|
||||
results.failed++;
|
||||
results.errors.push(`${ticket.ticket_key}: DB update failed — ${dbErr.message}`);
|
||||
}
|
||||
}
|
||||
} catch (searchErr) {
|
||||
results.failed += batch.length;
|
||||
results.errors.push(`Batch search error: ${searchErr.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
logAudit(db, {
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'jira_sync_all',
|
||||
entityType: 'jira_integration',
|
||||
entityId: null,
|
||||
details: results,
|
||||
ipAddress: req.ip
|
||||
});
|
||||
|
||||
res.json(results);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/jira/:id/sync
|
||||
*
|
||||
* Sync a single local ticket with Jira by fetching the latest status,
|
||||
* summary, and mapping the Jira status to the local three-state model.
|
||||
* Uses getIssue with explicit fields (Charter-compliant GET).
|
||||
* Requires Admin or Standard_User group.
|
||||
*
|
||||
* @param {number} id - Local jira_tickets row ID (path parameter)
|
||||
* @returns {object} 200 - { message, ticket_key, jira_status, local_status, summary }
|
||||
* @returns {object} 400 - { error } when ticket has no Jira key
|
||||
* @returns {object} 404 - { error } when local ticket not found
|
||||
* @returns {object} 429 - { error } when Jira rate limit exceeded
|
||||
* @returns {object} 500 - { error } on database error
|
||||
* @returns {object} 502 - { error, details } on Jira API failure
|
||||
* @returns {object} 503 - { error } when Jira API is not configured
|
||||
*/
|
||||
router.post('/:id/sync', requireAuth(db), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||
if (!jiraApi.isConfigured) {
|
||||
return res.status(503).json({ error: 'Jira API is not configured.' });
|
||||
}
|
||||
|
||||
const { id } = req.params;
|
||||
|
||||
db.get('SELECT * FROM jira_tickets WHERE id = ?', [id], async (err, ticket) => {
|
||||
if (err) {
|
||||
console.error(err);
|
||||
return res.status(500).json({ error: 'Internal server error.' });
|
||||
}
|
||||
if (!ticket) {
|
||||
return res.status(404).json({ error: 'JIRA ticket not found.' });
|
||||
}
|
||||
if (!ticket.ticket_key) {
|
||||
return res.status(400).json({ error: 'Ticket has no Jira key to sync.' });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await jiraApi.getIssue(ticket.ticket_key);
|
||||
if (!result.ok) {
|
||||
if (result.rateLimited) {
|
||||
return res.status(429).json({ error: 'Jira rate limit exceeded. Try again later.' });
|
||||
}
|
||||
return res.status(502).json({ error: 'Failed to fetch issue from Jira.', details: result.body });
|
||||
}
|
||||
|
||||
const issue = result.data;
|
||||
const jiraStatus = issue.fields.status ? issue.fields.status.name : null;
|
||||
const jiraSummary = issue.fields.summary || ticket.summary;
|
||||
const localStatus = mapJiraStatusToLocal(jiraStatus);
|
||||
|
||||
db.run(
|
||||
`UPDATE jira_tickets SET summary = ?, status = ?, jira_status = ?, last_synced_at = datetime('now'), updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
|
||||
[jiraSummary, localStatus, jiraStatus, id],
|
||||
function(updateErr) {
|
||||
if (updateErr) {
|
||||
console.error('Error updating synced ticket:', updateErr);
|
||||
return res.status(500).json({ error: 'Internal server error.' });
|
||||
}
|
||||
|
||||
logAudit(db, {
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'jira_ticket_sync',
|
||||
entityType: 'jira_ticket',
|
||||
entityId: id,
|
||||
details: { ticket_key: ticket.ticket_key, jira_status: jiraStatus, local_status: localStatus },
|
||||
ipAddress: req.ip
|
||||
});
|
||||
|
||||
res.json({
|
||||
message: 'Ticket synced with Jira',
|
||||
ticket_key: ticket.ticket_key,
|
||||
jira_status: jiraStatus,
|
||||
local_status: localStatus,
|
||||
summary: jiraSummary
|
||||
});
|
||||
}
|
||||
);
|
||||
} catch (err) {
|
||||
return res.status(502).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Local CRUD endpoints (migrated from server.js)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* GET /api/jira
|
||||
*
|
||||
* List all local JIRA ticket records with optional filters.
|
||||
* Results are ordered by `created_at` descending.
|
||||
*
|
||||
* @query {string} [cve_id] - Filter by CVE ID
|
||||
* @query {string} [vendor] - Filter by vendor name
|
||||
* @query {string} [status] - Filter by ticket status (Open, In Progress, Closed)
|
||||
* @returns {object[]} 200 - Array of jira_tickets rows
|
||||
* @returns {object} 500 - { error } on database error
|
||||
*/
|
||||
router.get('/', requireAuth(db), (req, res) => {
|
||||
const { cve_id, vendor, status } = req.query;
|
||||
|
||||
let query = 'SELECT * FROM jira_tickets WHERE 1=1';
|
||||
const params = [];
|
||||
|
||||
if (cve_id) {
|
||||
query += ' AND cve_id = ?';
|
||||
params.push(cve_id);
|
||||
}
|
||||
if (vendor) {
|
||||
query += ' AND vendor = ?';
|
||||
params.push(vendor);
|
||||
}
|
||||
if (status) {
|
||||
query += ' AND status = ?';
|
||||
params.push(status);
|
||||
}
|
||||
|
||||
query += ' ORDER BY created_at DESC';
|
||||
|
||||
db.all(query, params, (err, rows) => {
|
||||
if (err) {
|
||||
console.error('Error fetching JIRA tickets:', err);
|
||||
return res.status(500).json({ error: 'Internal server error.' });
|
||||
}
|
||||
res.json(rows);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/jira
|
||||
*
|
||||
* Create a local JIRA ticket record (manual entry, no Jira API call).
|
||||
* Requires Admin or Standard_User group.
|
||||
*
|
||||
* @body {string} cve_id - CVE identifier (required, format: CVE-YYYY-NNNNN)
|
||||
* @body {string} vendor - Vendor name (required, max 200 chars)
|
||||
* @body {string} ticket_key - Jira issue key (required, max 50 chars)
|
||||
* @body {string} [url] - URL to the Jira issue (max 500 chars)
|
||||
* @body {string} [summary] - Ticket summary (max 500 chars)
|
||||
* @body {string} [status] - Ticket status: Open, In Progress, or Closed (defaults to Open)
|
||||
* @returns {object} 201 - { id, message }
|
||||
* @returns {object} 400 - { error } on validation failure
|
||||
* @returns {object} 500 - { error } on database error
|
||||
*/
|
||||
router.post('/', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
|
||||
const { cve_id, vendor, ticket_key, url, summary, status } = req.body;
|
||||
|
||||
if (!cve_id || !isValidCveId(cve_id)) {
|
||||
return res.status(400).json({ error: 'Valid CVE ID is required.' });
|
||||
}
|
||||
if (!vendor || !isValidVendor(vendor)) {
|
||||
return res.status(400).json({ error: 'Valid vendor is required.' });
|
||||
}
|
||||
if (!ticket_key || typeof ticket_key !== 'string' || ticket_key.trim().length === 0 || ticket_key.length > 50) {
|
||||
return res.status(400).json({ error: 'Ticket key is required (max 50 chars).' });
|
||||
}
|
||||
if (url && (typeof url !== 'string' || url.length > 500)) {
|
||||
return res.status(400).json({ error: 'URL must be under 500 characters.' });
|
||||
}
|
||||
if (summary && (typeof summary !== 'string' || summary.length > 500)) {
|
||||
return res.status(400).json({ error: 'Summary must be under 500 characters.' });
|
||||
}
|
||||
if (status && !VALID_TICKET_STATUSES.includes(status)) {
|
||||
return res.status(400).json({ error: `Status must be one of: ${VALID_TICKET_STATUSES.join(', ')}` });
|
||||
}
|
||||
|
||||
const ticketStatus = status || 'Open';
|
||||
|
||||
db.run(
|
||||
`INSERT INTO jira_tickets (cve_id, vendor, ticket_key, url, summary, status, created_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
[cve_id, vendor, ticket_key.trim(), url || null, summary || null, ticketStatus, req.user.id],
|
||||
function(err) {
|
||||
if (err) {
|
||||
console.error('Error creating JIRA ticket:', err);
|
||||
return res.status(500).json({ error: 'Internal server error.' });
|
||||
}
|
||||
|
||||
logAudit(db, {
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'jira_ticket_create',
|
||||
entityType: 'jira_ticket',
|
||||
entityId: this.lastID.toString(),
|
||||
details: { cve_id, vendor, ticket_key, status: ticketStatus },
|
||||
ipAddress: req.ip
|
||||
});
|
||||
|
||||
res.status(201).json({
|
||||
id: this.lastID,
|
||||
message: 'JIRA ticket created successfully'
|
||||
});
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /api/jira/:id
|
||||
*
|
||||
* Update a local JIRA ticket record. Only provided fields are updated.
|
||||
* Requires Admin or Standard_User group.
|
||||
*
|
||||
* @param {number} id - Local jira_tickets row ID (path parameter)
|
||||
* @body {string} [ticket_key] - Jira issue key (max 50 chars)
|
||||
* @body {string} [url] - URL to the Jira issue (max 500 chars, or null)
|
||||
* @body {string} [summary] - Ticket summary (max 500 chars, or null)
|
||||
* @body {string} [status] - Ticket status: Open, In Progress, or Closed
|
||||
* @returns {object} 200 - { message, changes }
|
||||
* @returns {object} 400 - { error } on validation failure or no fields provided
|
||||
* @returns {object} 404 - { error } when ticket not found
|
||||
* @returns {object} 500 - { error } on database error
|
||||
*/
|
||||
router.put('/:id', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
|
||||
const { id } = req.params;
|
||||
const { ticket_key, url, summary, status } = req.body;
|
||||
|
||||
if (ticket_key !== undefined && (typeof ticket_key !== 'string' || ticket_key.trim().length === 0 || ticket_key.length > 50)) {
|
||||
return res.status(400).json({ error: 'Ticket key must be under 50 chars.' });
|
||||
}
|
||||
if (url !== undefined && url !== null && (typeof url !== 'string' || url.length > 500)) {
|
||||
return res.status(400).json({ error: 'URL must be under 500 characters.' });
|
||||
}
|
||||
if (summary !== undefined && summary !== null && (typeof summary !== 'string' || summary.length > 500)) {
|
||||
return res.status(400).json({ error: 'Summary must be under 500 characters.' });
|
||||
}
|
||||
if (status !== undefined && !VALID_TICKET_STATUSES.includes(status)) {
|
||||
return res.status(400).json({ error: `Status must be one of: ${VALID_TICKET_STATUSES.join(', ')}` });
|
||||
}
|
||||
|
||||
const fields = [];
|
||||
const values = [];
|
||||
|
||||
if (ticket_key !== undefined) { fields.push('ticket_key = ?'); values.push(ticket_key.trim()); }
|
||||
if (url !== undefined) { fields.push('url = ?'); values.push(url); }
|
||||
if (summary !== undefined) { fields.push('summary = ?'); values.push(summary); }
|
||||
if (status !== undefined) { fields.push('status = ?'); values.push(status); }
|
||||
|
||||
if (fields.length === 0) {
|
||||
return res.status(400).json({ error: 'No fields to update.' });
|
||||
}
|
||||
|
||||
fields.push('updated_at = CURRENT_TIMESTAMP');
|
||||
values.push(id);
|
||||
|
||||
db.get('SELECT * FROM jira_tickets WHERE id = ?', [id], (err, existing) => {
|
||||
if (err) {
|
||||
console.error(err);
|
||||
return res.status(500).json({ error: 'Internal server error.' });
|
||||
}
|
||||
if (!existing) {
|
||||
return res.status(404).json({ error: 'JIRA ticket not found.' });
|
||||
}
|
||||
|
||||
db.run(`UPDATE jira_tickets SET ${fields.join(', ')} WHERE id = ?`, values, function(updateErr) {
|
||||
if (updateErr) {
|
||||
console.error('Error updating JIRA ticket:', updateErr);
|
||||
return res.status(500).json({ error: 'Internal server error.' });
|
||||
}
|
||||
|
||||
logAudit(db, {
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'jira_ticket_update',
|
||||
entityType: 'jira_ticket',
|
||||
entityId: id,
|
||||
details: { before: existing, changes: req.body },
|
||||
ipAddress: req.ip
|
||||
});
|
||||
|
||||
res.json({ message: 'JIRA ticket updated successfully', changes: this.changes });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/jira/:id
|
||||
*
|
||||
* Delete a local JIRA ticket record. Admins bypass all restrictions.
|
||||
* Standard_User can only delete tickets they created, and cannot delete
|
||||
* tickets linked to active compliance items.
|
||||
*
|
||||
* @param {number} id - Local jira_tickets row ID (path parameter)
|
||||
* @returns {object} 200 - { message }
|
||||
* @returns {object} 403 - { error } when ownership check fails or ticket is linked to compliance
|
||||
* @returns {object} 404 - { error } when ticket not found
|
||||
* @returns {object} 500 - { error } on database error
|
||||
*/
|
||||
router.delete('/:id', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
|
||||
const { id } = req.params;
|
||||
|
||||
db.get('SELECT * FROM jira_tickets WHERE id = ?', [id], (err, ticket) => {
|
||||
if (err) {
|
||||
console.error(err);
|
||||
return res.status(500).json({ error: 'Internal server error.' });
|
||||
}
|
||||
if (!ticket) {
|
||||
return res.status(404).json({ error: 'JIRA ticket not found.' });
|
||||
}
|
||||
|
||||
// Admin bypasses all delete restrictions
|
||||
if (req.user.group === 'Admin') {
|
||||
return performJiraDelete();
|
||||
}
|
||||
|
||||
// Standard_User: ownership check
|
||||
if (ticket.created_by && ticket.created_by !== req.user.id) {
|
||||
return res.status(403).json({ error: 'You can only delete resources you created' });
|
||||
}
|
||||
|
||||
// Standard_User: compliance linkage check
|
||||
const ticketKey = ticket.ticket_key;
|
||||
db.all(
|
||||
`SELECT ci.id, ci.extra_json
|
||||
FROM compliance_items ci
|
||||
JOIN compliance_uploads cu ON ci.upload_id = cu.id
|
||||
WHERE ci.status = 'active' AND ci.extra_json LIKE ?`,
|
||||
[`%${ticketKey}%`],
|
||||
(compErr, compLinks) => {
|
||||
if (compErr && compErr.message && compErr.message.includes('no such table')) {
|
||||
compLinks = [];
|
||||
} else if (compErr) {
|
||||
console.error(compErr);
|
||||
return res.status(500).json({ error: 'Internal server error.' });
|
||||
}
|
||||
|
||||
const isLinked = (compLinks || []).some(cl => {
|
||||
const json = cl.extra_json || '';
|
||||
return json.includes(ticketKey);
|
||||
});
|
||||
|
||||
if (isLinked) {
|
||||
return res.status(403).json({ error: 'Cannot delete ticket linked to compliance report. Contact an admin.' });
|
||||
}
|
||||
|
||||
return performJiraDelete();
|
||||
}
|
||||
);
|
||||
|
||||
function performJiraDelete() {
|
||||
db.run('DELETE FROM jira_tickets WHERE id = ?', [id], function(deleteErr) {
|
||||
if (deleteErr) {
|
||||
console.error('Error deleting JIRA ticket:', deleteErr);
|
||||
return res.status(500).json({ error: 'Internal server error.' });
|
||||
}
|
||||
|
||||
logAudit(db, {
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'jira_ticket_delete',
|
||||
entityType: 'jira_ticket',
|
||||
entityId: id,
|
||||
details: { ticket_key: ticket.ticket_key, cve_id: ticket.cve_id, vendor: ticket.vendor },
|
||||
ipAddress: req.ip
|
||||
});
|
||||
|
||||
res.json({ message: 'JIRA ticket deleted successfully' });
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Map a Jira workflow status name to the local three-state model.
|
||||
* Jira statuses vary by project workflow, so this uses broad categories.
|
||||
*/
|
||||
function mapJiraStatusToLocal(jiraStatus) {
|
||||
if (!jiraStatus) return 'Open';
|
||||
const lower = jiraStatus.toLowerCase();
|
||||
if (['closed', 'done', 'resolved', 'complete', 'completed', 'cancelled', 'canceled', "won't do", 'declined'].some(s => lower.includes(s))) {
|
||||
return 'Closed';
|
||||
}
|
||||
if (['in progress', 'in review', 'in development', 'in testing', 'review', 'testing', 'dev', 'active', 'implementing'].some(s => lower.includes(s))) {
|
||||
return 'In Progress';
|
||||
}
|
||||
return 'Open';
|
||||
}
|
||||
|
||||
module.exports = createJiraTicketsRouter;
|
||||
@@ -1,7 +1,7 @@
|
||||
const express = require('express');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const { requireAuth, requireRole } = require('../middleware/auth');
|
||||
const { requireAuth, requireGroup } = require('../middleware/auth');
|
||||
const logAudit = require('../helpers/auditLog');
|
||||
|
||||
function createKnowledgeBaseRouter(db, upload) {
|
||||
@@ -39,8 +39,20 @@ function createKnowledgeBaseRouter(db, upload) {
|
||||
return ALLOWED_EXTENSIONS.has(ext);
|
||||
}
|
||||
|
||||
// POST /api/knowledge-base/upload - Upload new document
|
||||
router.post('/upload', requireAuth(db), requireRole(db, 'editor', 'admin'), (req, res, next) => {
|
||||
/**
|
||||
* POST /api/knowledge-base/upload
|
||||
* Upload a new knowledge base document.
|
||||
*
|
||||
* @body {string} title - Article title (required)
|
||||
* @body {string} [description] - Article description
|
||||
* @body {string} [category] - Article category (defaults to 'General')
|
||||
* @body {File} file - The document file to upload (multipart/form-data)
|
||||
*
|
||||
* @response 200 - { success: true, id: number, title: string, slug: string, category: string }
|
||||
* @response 400 - { error: string } - Missing title, no file, or invalid file type
|
||||
* @response 500 - { error: string } - Database or filesystem error
|
||||
*/
|
||||
router.post('/upload', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res, next) => {
|
||||
upload.single('file')(req, res, (err) => {
|
||||
if (err) {
|
||||
console.error('[KB Upload] Multer error:', err);
|
||||
@@ -80,22 +92,15 @@ function createKnowledgeBaseRouter(db, upload) {
|
||||
const slug = generateSlug(title);
|
||||
const kbDir = path.join(__dirname, '..', 'uploads', 'knowledge_base');
|
||||
|
||||
// Create directory if it doesn't exist
|
||||
if (!fs.existsSync(kbDir)) {
|
||||
fs.mkdirSync(kbDir, { recursive: true });
|
||||
}
|
||||
|
||||
const filename = `${timestamp}_${sanitizedName}`;
|
||||
const filePath = path.join(kbDir, filename);
|
||||
|
||||
try {
|
||||
// Move uploaded file to permanent location
|
||||
fs.renameSync(uploadedFile.path, filePath);
|
||||
|
||||
// Keep file in temp location until DB insert succeeds
|
||||
// Check if slug already exists
|
||||
db.get('SELECT id FROM knowledge_base WHERE slug = ?', [slug], (err, row) => {
|
||||
if (err) {
|
||||
fs.unlinkSync(filePath);
|
||||
fs.unlinkSync(uploadedFile.path);
|
||||
console.error('Error checking slug:', err);
|
||||
return res.status(500).json({ error: 'Database error' });
|
||||
}
|
||||
@@ -126,22 +131,32 @@ function createKnowledgeBaseRouter(db, upload) {
|
||||
],
|
||||
function (err) {
|
||||
if (err) {
|
||||
fs.unlinkSync(filePath);
|
||||
fs.unlinkSync(uploadedFile.path);
|
||||
console.error('Error inserting knowledge base entry:', err);
|
||||
return res.status(500).json({ error: 'Failed to save document metadata' });
|
||||
}
|
||||
|
||||
// DB insert succeeded — now move file to permanent location
|
||||
try {
|
||||
if (!fs.existsSync(kbDir)) {
|
||||
fs.mkdirSync(kbDir, { recursive: true });
|
||||
}
|
||||
fs.renameSync(uploadedFile.path, filePath);
|
||||
} catch (moveErr) {
|
||||
console.error('Error moving file to permanent location:', moveErr);
|
||||
// File is orphaned in temp but DB record exists — log and continue
|
||||
}
|
||||
|
||||
// Log audit entry
|
||||
logAudit(
|
||||
db,
|
||||
req.user.id,
|
||||
req.user.username,
|
||||
'CREATE_KB_ARTICLE',
|
||||
'knowledge_base',
|
||||
this.lastID,
|
||||
JSON.stringify({ title: title.trim(), filename: sanitizedName }),
|
||||
req.ip
|
||||
);
|
||||
logAudit(db, {
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'CREATE_KB_ARTICLE',
|
||||
entityType: 'knowledge_base',
|
||||
entityId: String(this.lastID),
|
||||
details: { title: title.trim(), filename: sanitizedName },
|
||||
ipAddress: req.ip
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
@@ -154,14 +169,20 @@ function createKnowledgeBaseRouter(db, upload) {
|
||||
);
|
||||
});
|
||||
} catch (error) {
|
||||
// Clean up file on error
|
||||
if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
|
||||
// Clean up temp file on error
|
||||
if (uploadedFile && fs.existsSync(uploadedFile.path)) fs.unlinkSync(uploadedFile.path);
|
||||
console.error('Error uploading knowledge base document:', error);
|
||||
res.status(500).json({ error: error.message || 'Failed to upload document' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/knowledge-base - List all articles
|
||||
/**
|
||||
* GET /api/knowledge-base
|
||||
* List all knowledge base articles.
|
||||
*
|
||||
* @response 200 - Array of article objects: [{ id, title, slug, description, category, file_name, file_type, file_size, created_at, updated_at, created_by_username }]
|
||||
* @response 500 - { error: string }
|
||||
*/
|
||||
router.get('/', requireAuth(db), (req, res) => {
|
||||
const sql = `
|
||||
SELECT
|
||||
@@ -183,7 +204,16 @@ function createKnowledgeBaseRouter(db, upload) {
|
||||
});
|
||||
});
|
||||
|
||||
// GET /api/knowledge-base/:id - Get single article details
|
||||
/**
|
||||
* GET /api/knowledge-base/:id
|
||||
* Get a single article's details by ID.
|
||||
*
|
||||
* @param {string} id - Article ID (route parameter)
|
||||
*
|
||||
* @response 200 - { id, title, slug, description, category, file_name, file_type, file_size, created_at, updated_at, created_by_username }
|
||||
* @response 404 - { error: 'Article not found' }
|
||||
* @response 500 - { error: string }
|
||||
*/
|
||||
router.get('/:id', requireAuth(db), (req, res) => {
|
||||
const { id } = req.params;
|
||||
|
||||
@@ -211,7 +241,17 @@ function createKnowledgeBaseRouter(db, upload) {
|
||||
});
|
||||
});
|
||||
|
||||
// GET /api/knowledge-base/:id/content - Get document content for display
|
||||
/**
|
||||
* GET /api/knowledge-base/:id/content
|
||||
* Get document content for inline display. Returns the raw file with appropriate
|
||||
* Content-Type headers. Markdown and text files are served as text/plain.
|
||||
*
|
||||
* @param {string} id - Article ID (route parameter)
|
||||
*
|
||||
* @response 200 - Raw file content with Content-Type and Content-Disposition headers
|
||||
* @response 404 - { error: string } - Article or file not found
|
||||
* @response 500 - { error: string }
|
||||
*/
|
||||
router.get('/:id/content', requireAuth(db), (req, res) => {
|
||||
const { id } = req.params;
|
||||
|
||||
@@ -232,16 +272,15 @@ function createKnowledgeBaseRouter(db, upload) {
|
||||
}
|
||||
|
||||
// Log audit entry
|
||||
logAudit(
|
||||
db,
|
||||
req.user.id,
|
||||
req.user.username,
|
||||
'VIEW_KB_ARTICLE',
|
||||
'knowledge_base',
|
||||
id,
|
||||
JSON.stringify({ filename: row.file_name }),
|
||||
req.ip
|
||||
);
|
||||
logAudit(db, {
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'VIEW_KB_ARTICLE',
|
||||
entityType: 'knowledge_base',
|
||||
entityId: String(id),
|
||||
details: { filename: row.file_name },
|
||||
ipAddress: req.ip
|
||||
});
|
||||
|
||||
// Determine content type for inline display
|
||||
let contentType = row.file_type || 'application/octet-stream';
|
||||
@@ -253,17 +292,28 @@ function createKnowledgeBaseRouter(db, upload) {
|
||||
contentType = 'text/plain; charset=utf-8';
|
||||
}
|
||||
|
||||
const safeFileName = row.file_name.replace(/["\r\n\\]/g, '');
|
||||
res.setHeader('Content-Type', contentType);
|
||||
// Use inline instead of attachment to allow browser to display
|
||||
res.setHeader('Content-Disposition', `inline; filename="${row.file_name}"`);
|
||||
res.setHeader('Content-Disposition', `inline; filename="${safeFileName}"`);
|
||||
// Allow iframe embedding from frontend origin
|
||||
res.removeHeader('X-Frame-Options');
|
||||
res.setHeader('Content-Security-Policy', "frame-ancestors 'self' http://71.85.90.9:3000 http://localhost:3000");
|
||||
const corsOrigins = process.env.CORS_ORIGINS ? process.env.CORS_ORIGINS.split(',').join(' ') : 'http://localhost:3000';
|
||||
res.setHeader('Content-Security-Policy', `frame-ancestors 'self' ${corsOrigins}`);
|
||||
res.sendFile(row.file_path);
|
||||
});
|
||||
});
|
||||
|
||||
// GET /api/knowledge-base/:id/download - Download document
|
||||
/**
|
||||
* GET /api/knowledge-base/:id/download
|
||||
* Download a knowledge base document as an attachment.
|
||||
*
|
||||
* @param {string} id - Article ID (route parameter)
|
||||
*
|
||||
* @response 200 - File download with Content-Disposition: attachment header
|
||||
* @response 404 - { error: string } - Article or file not found
|
||||
* @response 500 - { error: string }
|
||||
*/
|
||||
router.get('/:id/download', requireAuth(db), (req, res) => {
|
||||
const { id } = req.params;
|
||||
|
||||
@@ -284,28 +334,39 @@ function createKnowledgeBaseRouter(db, upload) {
|
||||
}
|
||||
|
||||
// Log audit entry
|
||||
logAudit(
|
||||
db,
|
||||
req.user.id,
|
||||
req.user.username,
|
||||
'DOWNLOAD_KB_ARTICLE',
|
||||
'knowledge_base',
|
||||
id,
|
||||
JSON.stringify({ filename: row.file_name }),
|
||||
req.ip
|
||||
);
|
||||
logAudit(db, {
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'DOWNLOAD_KB_ARTICLE',
|
||||
entityType: 'knowledge_base',
|
||||
entityId: String(id),
|
||||
details: { filename: row.file_name },
|
||||
ipAddress: req.ip
|
||||
});
|
||||
|
||||
const safeDownloadName = row.file_name.replace(/["\r\n\\]/g, '');
|
||||
res.setHeader('Content-Type', row.file_type || 'application/octet-stream');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${row.file_name}"`);
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${safeDownloadName}"`);
|
||||
res.sendFile(row.file_path);
|
||||
});
|
||||
});
|
||||
|
||||
// DELETE /api/knowledge-base/:id - Delete article
|
||||
router.delete('/:id', requireAuth(db), requireRole(db, 'editor', 'admin'), (req, res) => {
|
||||
/**
|
||||
* DELETE /api/knowledge-base/:id
|
||||
* Delete a knowledge base article and its associated file.
|
||||
* Standard_User can only delete articles they created. Admin can delete any article.
|
||||
*
|
||||
* @param {string} id - Article ID (route parameter)
|
||||
*
|
||||
* @response 200 - { success: true }
|
||||
* @response 403 - { error: string } - Ownership check failed for Standard_User
|
||||
* @response 404 - { error: 'Article not found' }
|
||||
* @response 500 - { error: string }
|
||||
*/
|
||||
router.delete('/:id', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
|
||||
const { id } = req.params;
|
||||
|
||||
const sql = 'SELECT file_path, title FROM knowledge_base WHERE id = ?';
|
||||
const sql = 'SELECT file_path, title, created_by FROM knowledge_base WHERE id = ?';
|
||||
|
||||
db.get(sql, [id], (err, row) => {
|
||||
if (err) {
|
||||
@@ -317,6 +378,11 @@ function createKnowledgeBaseRouter(db, upload) {
|
||||
return res.status(404).json({ error: 'Article not found' });
|
||||
}
|
||||
|
||||
// Ownership check: Standard_User can only delete articles they created
|
||||
if (req.user.group === 'Standard_User' && row.created_by !== req.user.id) {
|
||||
return res.status(403).json({ error: 'You can only delete resources you created' });
|
||||
}
|
||||
|
||||
// Delete database record
|
||||
db.run('DELETE FROM knowledge_base WHERE id = ?', [id], (err) => {
|
||||
if (err) {
|
||||
@@ -330,16 +396,15 @@ function createKnowledgeBaseRouter(db, upload) {
|
||||
}
|
||||
|
||||
// Log audit entry
|
||||
logAudit(
|
||||
db,
|
||||
req.user.id,
|
||||
req.user.username,
|
||||
'DELETE_KB_ARTICLE',
|
||||
'knowledge_base',
|
||||
id,
|
||||
JSON.stringify({ title: row.title }),
|
||||
req.ip
|
||||
);
|
||||
logAudit(db, {
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'DELETE_KB_ARTICLE',
|
||||
entityType: 'knowledge_base',
|
||||
entityId: String(id),
|
||||
details: { title: row.title },
|
||||
ipAddress: req.ip
|
||||
});
|
||||
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
@@ -2,18 +2,18 @@
|
||||
const express = require('express');
|
||||
const bcrypt = require('bcryptjs');
|
||||
|
||||
function createUsersRouter(db, requireAuth, requireRole, logAudit) {
|
||||
function createUsersRouter(db, requireAuth, requireGroup, logAudit) {
|
||||
const router = express.Router();
|
||||
|
||||
// All routes require admin role
|
||||
router.use(requireAuth(db), requireRole('admin'));
|
||||
// All routes require Admin group
|
||||
router.use(requireAuth(db), requireGroup('Admin'));
|
||||
|
||||
// Get all users
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const users = await new Promise((resolve, reject) => {
|
||||
db.all(
|
||||
`SELECT id, username, email, role, is_active, created_at, last_login
|
||||
`SELECT id, username, email, user_group AS 'group', is_active, created_at, last_login
|
||||
FROM users ORDER BY created_at DESC`,
|
||||
(err, rows) => {
|
||||
if (err) reject(err);
|
||||
@@ -33,7 +33,7 @@ function createUsersRouter(db, requireAuth, requireRole, logAudit) {
|
||||
try {
|
||||
const user = await new Promise((resolve, reject) => {
|
||||
db.get(
|
||||
`SELECT id, username, email, role, is_active, created_at, last_login
|
||||
`SELECT id, username, email, user_group AS 'group', is_active, created_at, last_login
|
||||
FROM users WHERE id = ?`,
|
||||
[req.params.id],
|
||||
(err, row) => {
|
||||
@@ -56,14 +56,17 @@ function createUsersRouter(db, requireAuth, requireRole, logAudit) {
|
||||
|
||||
// Create new user
|
||||
router.post('/', async (req, res) => {
|
||||
const { username, email, password, role } = req.body;
|
||||
const { username, email, password, group } = req.body;
|
||||
const VALID_GROUPS = ['Admin', 'Standard_User', 'Leadership', 'Read_Only'];
|
||||
|
||||
if (!username || !email || !password) {
|
||||
return res.status(400).json({ error: 'Username, email, and password are required' });
|
||||
}
|
||||
|
||||
if (role && !['admin', 'editor', 'viewer'].includes(role)) {
|
||||
return res.status(400).json({ error: 'Invalid role. Must be admin, editor, or viewer' });
|
||||
const userGroup = group || 'Read_Only';
|
||||
|
||||
if (!VALID_GROUPS.includes(userGroup)) {
|
||||
return res.status(400).json({ error: 'Invalid group. Must be one of: Admin, Standard_User, Leadership, Read_Only' });
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -71,9 +74,9 @@ function createUsersRouter(db, requireAuth, requireRole, logAudit) {
|
||||
|
||||
const result = await new Promise((resolve, reject) => {
|
||||
db.run(
|
||||
`INSERT INTO users (username, email, password_hash, role)
|
||||
`INSERT INTO users (username, email, password_hash, user_group)
|
||||
VALUES (?, ?, ?, ?)`,
|
||||
[username, email, passwordHash, role || 'viewer'],
|
||||
[username, email, passwordHash, userGroup],
|
||||
function(err) {
|
||||
if (err) reject(err);
|
||||
else resolve({ id: this.lastID });
|
||||
@@ -87,7 +90,7 @@ function createUsersRouter(db, requireAuth, requireRole, logAudit) {
|
||||
action: 'user_create',
|
||||
entityType: 'user',
|
||||
entityId: String(result.id),
|
||||
details: { created_username: username, role: role || 'viewer' },
|
||||
details: { created_username: username, group: userGroup },
|
||||
ipAddress: req.ip
|
||||
});
|
||||
|
||||
@@ -97,7 +100,7 @@ function createUsersRouter(db, requireAuth, requireRole, logAudit) {
|
||||
id: result.id,
|
||||
username,
|
||||
email,
|
||||
role: role || 'viewer'
|
||||
group: userGroup
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
@@ -111,20 +114,42 @@ function createUsersRouter(db, requireAuth, requireRole, logAudit) {
|
||||
|
||||
// Update user
|
||||
router.patch('/:id', async (req, res) => {
|
||||
const { username, email, password, role, is_active } = req.body;
|
||||
const { username, email, password, group, is_active } = req.body;
|
||||
const VALID_GROUPS = ['Admin', 'Standard_User', 'Leadership', 'Read_Only'];
|
||||
const userId = req.params.id;
|
||||
|
||||
// Prevent self-demotion from admin
|
||||
if (userId == req.user.id && role && role !== 'admin') {
|
||||
return res.status(400).json({ error: 'Cannot remove your own admin role' });
|
||||
// Validate group if provided
|
||||
if (group && !VALID_GROUPS.includes(group)) {
|
||||
return res.status(400).json({ error: 'Invalid group. Must be one of: Admin, Standard_User, Leadership, Read_Only' });
|
||||
}
|
||||
|
||||
// Prevent admin self-demotion
|
||||
if (String(userId) === String(req.user.id) && group && group !== 'Admin') {
|
||||
return res.status(400).json({ error: 'Cannot remove your own admin group' });
|
||||
}
|
||||
|
||||
// Prevent self-deactivation
|
||||
if (userId == req.user.id && is_active === false) {
|
||||
if (String(userId) === String(req.user.id) && is_active === false) {
|
||||
return res.status(400).json({ error: 'Cannot deactivate your own account' });
|
||||
}
|
||||
|
||||
try {
|
||||
// Fetch current user record before update (needed for group change audit)
|
||||
const currentUser = await new Promise((resolve, reject) => {
|
||||
db.get(
|
||||
'SELECT user_group FROM users WHERE id = ?',
|
||||
[userId],
|
||||
(err, row) => {
|
||||
if (err) reject(err);
|
||||
else resolve(row);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
if (!currentUser) {
|
||||
return res.status(404).json({ error: 'User not found' });
|
||||
}
|
||||
|
||||
const updates = [];
|
||||
const values = [];
|
||||
|
||||
@@ -141,12 +166,9 @@ function createUsersRouter(db, requireAuth, requireRole, logAudit) {
|
||||
updates.push('password_hash = ?');
|
||||
values.push(passwordHash);
|
||||
}
|
||||
if (role) {
|
||||
if (!['admin', 'editor', 'viewer'].includes(role)) {
|
||||
return res.status(400).json({ error: 'Invalid role' });
|
||||
}
|
||||
updates.push('role = ?');
|
||||
values.push(role);
|
||||
if (group) {
|
||||
updates.push('user_group = ?');
|
||||
values.push(group);
|
||||
}
|
||||
if (typeof is_active === 'boolean') {
|
||||
updates.push('is_active = ?');
|
||||
@@ -173,7 +195,7 @@ function createUsersRouter(db, requireAuth, requireRole, logAudit) {
|
||||
const updatedFields = {};
|
||||
if (username) updatedFields.username = username;
|
||||
if (email) updatedFields.email = email;
|
||||
if (role) updatedFields.role = role;
|
||||
if (group) updatedFields.group = group;
|
||||
if (typeof is_active === 'boolean') updatedFields.is_active = is_active;
|
||||
if (password) updatedFields.password_changed = true;
|
||||
|
||||
@@ -187,6 +209,22 @@ function createUsersRouter(db, requireAuth, requireRole, logAudit) {
|
||||
ipAddress: req.ip
|
||||
});
|
||||
|
||||
// Log specific audit entry for group changes
|
||||
if (group && group !== currentUser.user_group) {
|
||||
logAudit(db, {
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'user_group_change',
|
||||
entityType: 'user',
|
||||
entityId: String(userId),
|
||||
details: {
|
||||
previous_group: currentUser.user_group,
|
||||
new_group: group
|
||||
},
|
||||
ipAddress: req.ip
|
||||
});
|
||||
}
|
||||
|
||||
// If user was deactivated, delete their sessions
|
||||
if (is_active === false) {
|
||||
await new Promise((resolve) => {
|
||||
@@ -209,7 +247,7 @@ function createUsersRouter(db, requireAuth, requireRole, logAudit) {
|
||||
const userId = req.params.id;
|
||||
|
||||
// Prevent self-deletion
|
||||
if (userId == req.user.id) {
|
||||
if (String(userId) === String(req.user.id)) {
|
||||
return res.status(400).json({ error: 'Cannot delete your own account' });
|
||||
}
|
||||
|
||||
|
||||
388
backend/scripts/card-granite-lookup.js
Normal file
388
backend/scripts/card-granite-lookup.js
Normal file
@@ -0,0 +1,388 @@
|
||||
#!/usr/bin/env node
|
||||
// ==========================================================================
|
||||
// CARD → Granite Lookup Script (v2)
|
||||
// ==========================================================================
|
||||
// Queries CARD team assets endpoint (which returns full enriched records
|
||||
// including ncim_discovery with EQUIP_INST_ID) for the 109 reassigned IPs
|
||||
// from the findings-count investigation Appendix C.
|
||||
//
|
||||
// Generates:
|
||||
// docs/card-lookup-results.csv — full CARD data for review
|
||||
// docs/granite-reassignment-upload.csv — Team_Device Loader format
|
||||
//
|
||||
// Usage:
|
||||
// cd backend
|
||||
// node scripts/card-granite-lookup.js
|
||||
// ==========================================================================
|
||||
|
||||
require('dotenv').config({ path: require('path').join(__dirname, '..', '.env') });
|
||||
|
||||
const cardApi = require('../helpers/cardApi');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// IP → hostname mapping from Appendix C
|
||||
// ---------------------------------------------------------------------------
|
||||
const REASSIGNED = {
|
||||
// With approved FP workflows (58)
|
||||
'98.120.0.78': 'syn-098-120-000-078', '98.120.32.185': 'syn-098-120-032-185',
|
||||
'10.240.78.177': 'mon15-agg-sw', '10.240.78.176': 'mon16-agg-sw',
|
||||
'10.240.78.133': 'mon15-sw14', '10.240.78.130': 'mon15-sw11',
|
||||
'10.240.78.150': 'mon19-sw3', '10.240.78.107': 'mon16-sw2',
|
||||
'10.240.78.110': 'mon16-sw5', '10.240.78.106': 'mon16-sw1',
|
||||
'10.240.78.149': 'mon19-sw2', '10.240.78.154': 'mon19-sw7',
|
||||
'10.240.78.111': 'mon16-sw6', '10.240.78.153': 'mon19-sw6',
|
||||
'10.240.78.132': 'mon15-sw13', '10.240.78.115': 'mon16-sw10',
|
||||
'10.240.78.109': 'mon16-sw4', '10.240.78.112': 'mon16-sw7',
|
||||
'10.240.78.119': 'mon16-sw14', '10.240.78.114': 'mon16-sw9',
|
||||
'10.240.78.118': 'mon16-sw13', '10.240.78.117': 'mon16-sw12',
|
||||
'10.240.78.108': 'mon16-sw3', '10.240.78.155': 'mon19-sw8',
|
||||
'10.240.78.157': 'mon19-sw10', '10.240.78.151': 'mon19-sw4',
|
||||
'10.240.78.116': 'mon16-sw11', '10.240.78.152': 'mon19-sw5',
|
||||
'10.240.78.161': 'mon19-sw14', '10.240.78.160': 'mon19-sw13',
|
||||
'10.240.78.159': 'mon19-sw12', '10.240.78.158': 'mon19-sw11',
|
||||
'10.240.78.123': 'mon15-sw4', '10.240.78.137': 'mon20-sw4',
|
||||
'10.240.78.148': 'mon19-sw1', '10.240.78.125': 'mon15-sw6',
|
||||
'10.240.78.156': 'mon19-sw9', '10.241.0.63': '',
|
||||
'10.244.11.51': 'apc01se1shcc-n01-bmc', '172.27.72.1': '',
|
||||
'96.37.185.145': '', '10.240.78.170': 'mon17-sw9',
|
||||
'10.240.78.172': 'mon17-sw11', '10.240.78.169': 'mon17-sw8',
|
||||
'10.240.78.166': 'mon17-sw5', '10.240.78.174': 'mon17-sw13',
|
||||
'10.240.78.173': 'mon17-sw12', '10.240.78.167': 'mon17-sw6',
|
||||
'10.240.78.175': 'mon17-sw14', '10.240.78.168': 'mon17-sw7',
|
||||
'10.240.78.171': 'mon17-sw10', '66.61.128.10': 'syn-066-061-128-010',
|
||||
'66.61.128.233': 'apa01se1shcc-bvi101-secondary',
|
||||
'66.61.128.49': 'syn-066-061-128-049', '66.61.128.18': 'syn-066-061-128-018',
|
||||
'10.244.4.26': '', '10.244.11.5': '', '10.244.11.6': '',
|
||||
// With rejected FP workflows (8)
|
||||
'10.244.4.55': 'apc15se1shcc-n03', '10.244.11.53': 'apc01se1shcc-n03-bmc',
|
||||
'10.244.4.30': '', '10.244.11.63': 'apc04se1shcc-n01-cimc',
|
||||
'24.28.208.125': '', '24.28.210.101': 'syn-024-028-210-101',
|
||||
'10.244.11.27': '', '10.240.1.203': '',
|
||||
// Without FP workflows (43)
|
||||
'10.240.78.20': '', '172.16.1.229': '',
|
||||
'10.244.11.96': '', '10.244.11.54': 'apc02se1shcc-n01-cimc',
|
||||
'10.244.4.51': 'apc14se1shcc-n02', '10.244.11.86': '',
|
||||
'10.244.11.55': 'apc02se1shcc-n02-cimc', '24.28.208.105': 'syn-024-028-208-105',
|
||||
'10.244.4.50': 'apc14se1shcc-n01', '10.244.4.53': 'apc15se1shcc-n01',
|
||||
'10.244.11.73': 'apc07se1shcc-n02-cimc', '10.244.11.64': 'apc04se1shcc-n02-cimc',
|
||||
'10.244.4.54': 'apc15se1shcc-n02', '10.244.4.28': '',
|
||||
'10.244.11.94': '', '10.241.0.43': 'c220-wzp27340ss5',
|
||||
'10.244.11.56': 'apc02se1shcc-n03-cimc', '10.244.11.66': 'apc05se1shcc-n01-bmc',
|
||||
'10.244.4.47': 'apc13se1shcc-n01', '10.244.4.49': 'apc13se1shcc-n03',
|
||||
'10.244.4.52': 'apc14se1shcc-n03', '10.244.11.72': 'apc07se1shcc-n01-cimc',
|
||||
'10.244.4.25': 'apc02ctsbcom7-n03-cimc', '10.244.4.29': '',
|
||||
'10.244.11.74': 'apc07se1shcc-n03-cimc', '10.244.4.48': 'apc13se1shcc-n02',
|
||||
'10.244.11.65': 'apc04se1shcc-n03-cimc', '10.244.4.24': 'apc02ctsbcom7-n02-cimc',
|
||||
'10.244.11.87': '', '10.244.11.68': 'apc05se1shcc-n03-bmc',
|
||||
'10.244.11.67': 'apc05se1shcc-n02-bmc', '10.244.4.23': 'apc02ctsbcom7-n01-cimc',
|
||||
'10.244.11.57': '', '10.244.11.95': '',
|
||||
'98.120.32.145': 'syn-098-120-032-145', '98.120.0.129': 'syn-098-120-000-129',
|
||||
'68.114.184.84': 'rphy-runner-vecima',
|
||||
};
|
||||
|
||||
const TARGET_IPS = new Set(Object.keys(REASSIGNED));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fetch all assets for both teams, then match against our IP list
|
||||
// ---------------------------------------------------------------------------
|
||||
async function fetchTeamAssets(teamName) {
|
||||
const allAssets = [];
|
||||
let page = 1;
|
||||
const pageSize = 200;
|
||||
|
||||
while (true) {
|
||||
// Fetch confirmed assets (these have the richest data)
|
||||
const result = await cardApi.getTeamAssets(teamName, {
|
||||
disposition: 'confirmed',
|
||||
page,
|
||||
pageSize,
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
console.error(` Failed to fetch ${teamName} page ${page}: HTTP ${result.status}`);
|
||||
break;
|
||||
}
|
||||
|
||||
let data;
|
||||
try { data = JSON.parse(result.body); } catch (_) { break; }
|
||||
|
||||
const assets = Array.isArray(data) ? data : (data.assets || data.results || []);
|
||||
allAssets.push(...assets);
|
||||
|
||||
const total = data.total || assets.length;
|
||||
console.log(` ${teamName} page ${page}: ${assets.length} assets (total: ${total})`);
|
||||
|
||||
if (allAssets.length >= total || assets.length === 0) break;
|
||||
page++;
|
||||
}
|
||||
|
||||
return allAssets;
|
||||
}
|
||||
|
||||
function extractIPFromAssetId(assetId) {
|
||||
// Asset IDs are like "10.240.78.110-CTEC" — strip the suffix
|
||||
if (!assetId) return null;
|
||||
const parts = assetId.split('-');
|
||||
// Rejoin all but the last part (the suffix like CTEC, NATL, etc.)
|
||||
// But only if the last part looks like a suffix (not a number)
|
||||
const last = parts[parts.length - 1];
|
||||
if (/^\d+$/.test(last)) return assetId; // All numeric, probably just an IP
|
||||
return parts.slice(0, -1).join('-');
|
||||
}
|
||||
|
||||
function extractGraniteData(asset) {
|
||||
const id = asset._id || '';
|
||||
const ip = extractIPFromAssetId(id);
|
||||
const flags = (asset.card_flags && asset.card_flags[0]) || {};
|
||||
const ncim = asset.ncim_discovery || [];
|
||||
const qualys = asset.qualys_hosts || [];
|
||||
const ivanti = asset.ivanti_assets || [];
|
||||
const granite = asset.netops_granite_allips || null;
|
||||
const iseGranite = asset.ise_granite_equipment || null;
|
||||
|
||||
// Extract EQUIP_INST_ID from ncim_discovery (primary source)
|
||||
let equipInstId = null;
|
||||
let graniteTeam = null;
|
||||
let entityId = null;
|
||||
let sysLocation = null;
|
||||
let ncimHostname = null;
|
||||
|
||||
if (ncim.length > 0) {
|
||||
equipInstId = ncim[0].EQUIP_INST_ID || null;
|
||||
graniteTeam = ncim[0].GRANITE_RESP_TEAM || ncim[0].RESPONSIBLE_TEAM || null;
|
||||
entityId = ncim[0].ENTITYID || null;
|
||||
sysLocation = ncim[0].SYSLOCATION || null;
|
||||
ncimHostname = ncim[0].HOSTNAME || null;
|
||||
}
|
||||
|
||||
// Fallback: check netops_granite_allips
|
||||
if (!equipInstId && granite && Array.isArray(granite) && granite.length > 0) {
|
||||
equipInstId = granite[0].EQUIP_INST_ID || null;
|
||||
}
|
||||
|
||||
// Fallback: check ise_granite_equipment
|
||||
if (!equipInstId && iseGranite && Array.isArray(iseGranite) && iseGranite.length > 0) {
|
||||
equipInstId = iseGranite[0].EQUIP_INST_ID || null;
|
||||
}
|
||||
|
||||
const hostname = ncimHostname
|
||||
|| (flags.CARD_HOSTNAME && flags.CARD_HOSTNAME[0])
|
||||
|| (qualys.length > 0 && qualys[0].HOSTNAME)
|
||||
|| (ivanti.length > 0 && ivanti[0].hostName)
|
||||
|| '';
|
||||
|
||||
const confirmedTeam = asset.owner && asset.owner.confirmed
|
||||
? asset.owner.confirmed.name : null;
|
||||
|
||||
return {
|
||||
ip,
|
||||
assetId: id,
|
||||
hostname,
|
||||
equipInstId,
|
||||
graniteTeam,
|
||||
entityId,
|
||||
sysLocation,
|
||||
confirmedTeam,
|
||||
deviceId: flags.CARD_DEVICE_ID || null,
|
||||
asn: flags.CARD_ASN || null,
|
||||
vendorModel: (flags.CARD_VENDOR_MODEL || []).map(v => v.vendor_model || v).join(', '),
|
||||
status: flags.status || null,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main
|
||||
// ---------------------------------------------------------------------------
|
||||
async function main() {
|
||||
console.log('=== CARD → Granite Lookup (v2 — team assets endpoint) ===');
|
||||
console.log(`Target IPs: ${TARGET_IPS.size}`);
|
||||
console.log(`CARD_API_URL: ${process.env.CARD_API_URL}`);
|
||||
console.log('');
|
||||
|
||||
if (!cardApi.isConfigured) {
|
||||
console.error('CARD API is not configured.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Fetch assets from both teams
|
||||
const teams = ['NTS-AEO-STEAM', 'NTS-AEO-ACCESS-ENG'];
|
||||
const allAssets = [];
|
||||
|
||||
for (const team of teams) {
|
||||
console.log(`Fetching ${team}...`);
|
||||
const assets = await fetchTeamAssets(team);
|
||||
allAssets.push(...assets);
|
||||
console.log(` Total: ${assets.length} assets\n`);
|
||||
}
|
||||
|
||||
// Also fetch candidate/unconfirmed in case some were reassigned
|
||||
for (const team of teams) {
|
||||
for (const disp of ['candidate', 'unconfirmed']) {
|
||||
console.log(`Fetching ${team} (${disp})...`);
|
||||
try {
|
||||
const result = await cardApi.getTeamAssets(team, { disposition: disp, pageSize: 200 });
|
||||
if (result.ok) {
|
||||
const data = JSON.parse(result.body);
|
||||
const assets = Array.isArray(data) ? data : (data.assets || data.results || []);
|
||||
allAssets.push(...assets);
|
||||
console.log(` ${assets.length} assets`);
|
||||
}
|
||||
} catch (_) { /* skip */ }
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\nTotal assets fetched: ${allAssets.length}`);
|
||||
|
||||
// Build IP → asset map
|
||||
const ipMap = new Map();
|
||||
for (const asset of allAssets) {
|
||||
const id = asset._id || '';
|
||||
const ip = extractIPFromAssetId(id);
|
||||
if (ip && !ipMap.has(ip)) {
|
||||
ipMap.set(ip, asset);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Unique IPs in CARD: ${ipMap.size}`);
|
||||
|
||||
// Match against our target IPs
|
||||
const matched = [];
|
||||
const notFound = [];
|
||||
|
||||
for (const ip of TARGET_IPS) {
|
||||
const asset = ipMap.get(ip);
|
||||
if (asset) {
|
||||
matched.push(extractGraniteData(asset));
|
||||
} else {
|
||||
notFound.push(ip);
|
||||
}
|
||||
}
|
||||
|
||||
// For IPs not found in team assets, fall back to individual owner lookup
|
||||
if (notFound.length > 0) {
|
||||
console.log(`\n${notFound.length} IPs not in team assets — trying individual owner lookups...`);
|
||||
const SUFFIXES = ['CTEC', 'NATL', 'TWC', 'BHN', 'CHTR'];
|
||||
const stillNotFound = [];
|
||||
|
||||
for (const ip of notFound) {
|
||||
let found = false;
|
||||
for (const suffix of SUFFIXES) {
|
||||
try {
|
||||
const result = await cardApi.getOwner(`${ip}-${suffix}`);
|
||||
if (result.ok) {
|
||||
const data = JSON.parse(result.body);
|
||||
// Owner endpoint is slim — extract what we can
|
||||
const ncim = data.ncim_discovery || [];
|
||||
matched.push({
|
||||
ip,
|
||||
assetId: data._id || `${ip}-${suffix}`,
|
||||
hostname: REASSIGNED[ip] || '',
|
||||
equipInstId: ncim.length > 0 ? (ncim[0].EQUIP_INST_ID || null) : null,
|
||||
graniteTeam: ncim.length > 0 ? (ncim[0].GRANITE_RESP_TEAM || null) : null,
|
||||
entityId: ncim.length > 0 ? (ncim[0].ENTITYID || null) : null,
|
||||
sysLocation: ncim.length > 0 ? (ncim[0].SYSLOCATION || null) : null,
|
||||
confirmedTeam: data.owner && data.owner.confirmed ? data.owner.confirmed.name : null,
|
||||
deviceId: null,
|
||||
asn: null,
|
||||
vendorModel: '',
|
||||
status: null,
|
||||
});
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
} catch (_) { /* continue */ }
|
||||
}
|
||||
if (!found) stillNotFound.push(ip);
|
||||
}
|
||||
|
||||
if (stillNotFound.length > 0) {
|
||||
console.log(`\n${stillNotFound.length} IPs not found anywhere in CARD:`);
|
||||
stillNotFound.forEach(ip => console.log(` ${ip} (${REASSIGNED[ip] || 'no hostname'})`));
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by IP
|
||||
matched.sort((a, b) => {
|
||||
const aParts = a.ip.split('.').map(Number);
|
||||
const bParts = b.ip.split('.').map(Number);
|
||||
for (let i = 0; i < 4; i++) {
|
||||
if (aParts[i] !== bParts[i]) return aParts[i] - bParts[i];
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
|
||||
// Summary
|
||||
const withEquipId = matched.filter(r => r.equipInstId);
|
||||
const withoutEquipId = matched.filter(r => !r.equipInstId);
|
||||
|
||||
console.log('\n=== Summary ===');
|
||||
console.log(`Matched in CARD: ${matched.length}`);
|
||||
console.log(`With EQUIP_INST_ID: ${withEquipId.length}`);
|
||||
console.log(`Without EQUIP_INST_ID: ${withoutEquipId.length}`);
|
||||
|
||||
// Print results
|
||||
console.log('\n=== Results with EQUIP_INST_ID ===');
|
||||
console.log('IP Address | EQUIP_INST_ID | Hostname | Granite Team');
|
||||
console.log('-'.repeat(100));
|
||||
for (const r of withEquipId) {
|
||||
console.log(`${r.ip.padEnd(20)} | ${String(r.equipInstId).padEnd(13)} | ${(r.hostname || '').padEnd(30)} | ${r.graniteTeam || '-'}`);
|
||||
}
|
||||
|
||||
if (withoutEquipId.length > 0) {
|
||||
console.log('\n=== Results WITHOUT EQUIP_INST_ID ===');
|
||||
for (const r of withoutEquipId) {
|
||||
console.log(` ${r.ip.padEnd(20)} ${(r.hostname || REASSIGNED[r.ip] || '').padEnd(30)} confirmed: ${r.confirmedTeam || '-'}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Write full CSV
|
||||
const csvPath = path.join(__dirname, '..', '..', 'docs', 'card-lookup-results.csv');
|
||||
const csvHeader = 'IP Address,CARD Asset ID,Hostname,EQUIP_INST_ID,Granite Team,Entity ID,SysLocation,Confirmed Team,Device ID,ASN,Vendor Model,Status';
|
||||
const csvRows = matched.map(r =>
|
||||
[r.ip, r.assetId, r.hostname, r.equipInstId, r.graniteTeam, r.entityId, r.sysLocation, r.confirmedTeam, r.deviceId, r.asn, r.vendorModel, r.status]
|
||||
.map(v => v === null || v === undefined ? '' : `"${String(v).replace(/"/g, '""')}"`)
|
||||
.join(',')
|
||||
);
|
||||
fs.writeFileSync(csvPath, csvHeader + '\n' + csvRows.join('\n') + '\n', 'utf8');
|
||||
console.log(`\nFull CSV: ${csvPath}`);
|
||||
|
||||
// Write Granite Team_Device Loader CSV
|
||||
const graniteHeaders = [
|
||||
'DELETE', 'SET_CONFIRMED', 'EQUIPMENT CLASS', 'EQUIP_INST_ID', 'SITE_NAME',
|
||||
'EQUIP_NAME', 'EQUIP_TEMPLATE', 'EQUIP_STATUS',
|
||||
'UDA#RESPONSIBLE ORGANIZATION#RESPONSIBLE TEAM',
|
||||
'UDA#IP_ADDRESSING#IPV4_ADDRESS',
|
||||
'UDA#IP_ADDRESSING#MAC ADDRESS', 'UDA#IP_ADDRESSING#MGMT_IP_ASN', 'SERIALNUMBER',
|
||||
];
|
||||
|
||||
const graniteRows = withEquipId.map(r => [
|
||||
'', // DELETE
|
||||
'', // SET_CONFIRMED
|
||||
'S', // EQUIPMENT CLASS (Shelf)
|
||||
r.equipInstId, // EQUIP_INST_ID
|
||||
'', // SITE_NAME
|
||||
r.hostname || REASSIGNED[r.ip] || '', // EQUIP_NAME
|
||||
'', // EQUIP_TEMPLATE
|
||||
'', // EQUIP_STATUS
|
||||
'NTS-AEO-STEAM', // RESPONSIBLE TEAM
|
||||
r.ip, // IPV4_ADDRESS
|
||||
'', // MAC ADDRESS
|
||||
r.asn || '', // MGMT_IP_ASN
|
||||
r.deviceId || '', // SERIALNUMBER
|
||||
]);
|
||||
|
||||
const granitePath = path.join(__dirname, '..', '..', 'docs', 'granite-reassignment-upload.csv');
|
||||
const graniteContent = [
|
||||
graniteHeaders.join(','),
|
||||
...graniteRows.map(r => r.map(v => `"${String(v).replace(/"/g, '""')}"`).join(','))
|
||||
].join('\n');
|
||||
fs.writeFileSync(granitePath, graniteContent + '\n', 'utf8');
|
||||
console.log(`Granite upload CSV (${withEquipId.length} rows): ${granitePath}`);
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error('Unhandled error:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
44
backend/scripts/compliance_config.json
Normal file
44
backend/scripts/compliance_config.json
Normal file
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"metric_categories": {
|
||||
"1.1.1": "Logging & Monitoring",
|
||||
"1.1.3": "Logging & Monitoring",
|
||||
"1.4.1": "Logging & Monitoring",
|
||||
"2.3.4i": "Vulnerability Management",
|
||||
"2.3.6i": "Vulnerability Management",
|
||||
"2.3.8i": "Vulnerability Management",
|
||||
"5.2.4": "Access & MFA",
|
||||
"5.2.5": "Access & MFA",
|
||||
"5.2.6": "Access & MFA",
|
||||
"5.2.7": "Access & MFA",
|
||||
"5.2.8": "Access & MFA",
|
||||
"5.3.4": "Endpoint Protection",
|
||||
"5.5.4i": "Vulnerability Management",
|
||||
"5.5.5": "Decommissioned Assets",
|
||||
"5.8.1": "Application Security",
|
||||
"7.1.1": "Logging & Monitoring",
|
||||
"7.1.4": "Logging & Monitoring",
|
||||
"7.6.13": "Disaster Recovery",
|
||||
"7.6.16": "Disaster Recovery",
|
||||
"Missing_AppID": "Asset Data Quality",
|
||||
"Missing_DF": "Asset Data Quality",
|
||||
"Missing_OS": "Asset Data Quality",
|
||||
"5.5.2": "Other"
|
||||
},
|
||||
"core_cols": [
|
||||
"Preferred - Hostname",
|
||||
"GRANITE - IPv4_Address",
|
||||
"GRANITE - Type",
|
||||
"Team",
|
||||
"Compliant",
|
||||
"Source_Network",
|
||||
"Vertical",
|
||||
"GRANITE - Equip_Inst_ID",
|
||||
"GRANITE - RESPONSIBLE_TEAM"
|
||||
],
|
||||
"skip_sheets": [
|
||||
"Summary",
|
||||
"CMDB_9box",
|
||||
"Vulns",
|
||||
"Aging Dashboard"
|
||||
]
|
||||
}
|
||||
84
backend/scripts/dump_xlsx_schema.py
Normal file
84
backend/scripts/dump_xlsx_schema.py
Normal file
@@ -0,0 +1,84 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Dump the structural schema of a compliance xlsx file as JSON.
|
||||
Usage: python3 dump_xlsx_schema.py <path_to_xlsx>
|
||||
|
||||
Output:
|
||||
{
|
||||
"sheets": [
|
||||
{
|
||||
"name": "SheetName",
|
||||
"columns": ["Col A", "Col B", ...],
|
||||
"row_count": 150,
|
||||
"metric_values": ["2.3.4i", "5.2.4", ...] // only if a Metric column exists
|
||||
},
|
||||
...
|
||||
]
|
||||
}
|
||||
|
||||
Dependencies: openpyxl (already in requirements.txt)
|
||||
"""
|
||||
import sys
|
||||
import json
|
||||
from openpyxl import load_workbook
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 2:
|
||||
print(json.dumps({'error': 'No file path provided'}))
|
||||
sys.exit(1)
|
||||
|
||||
filepath = sys.argv[1]
|
||||
|
||||
try:
|
||||
wb = load_workbook(filepath, read_only=True, data_only=True)
|
||||
except Exception as e:
|
||||
print(json.dumps({'error': f'Cannot open file: {str(e)}'}))
|
||||
sys.exit(1)
|
||||
|
||||
sheets = []
|
||||
for sheet_name in wb.sheetnames:
|
||||
ws = wb[sheet_name]
|
||||
rows = list(ws.iter_rows(max_row=1, values_only=True))
|
||||
columns = [str(c).strip() for c in rows[0] if c is not None] if rows else []
|
||||
|
||||
# Count data rows (excluding header)
|
||||
row_count = 0
|
||||
for _ in ws.iter_rows(min_row=2, values_only=True):
|
||||
row_count += 1
|
||||
|
||||
# Extract metric values if a Metric column exists in the Summary sheet
|
||||
metric_values = []
|
||||
if sheet_name == 'Summary':
|
||||
# Summary has header at row 4 (0-indexed row 3), read from row 5 onward
|
||||
header_rows = list(ws.iter_rows(min_row=4, max_row=4, values_only=True))
|
||||
if header_rows:
|
||||
summary_cols = [str(c).strip() if c else '' for c in header_rows[0]]
|
||||
metric_idx = None
|
||||
for i, col in enumerate(summary_cols):
|
||||
if col == 'Metric':
|
||||
metric_idx = i
|
||||
break
|
||||
if metric_idx is not None:
|
||||
for row in ws.iter_rows(min_row=5, values_only=True):
|
||||
if row[metric_idx] is not None:
|
||||
val = str(row[metric_idx]).strip()
|
||||
if val and val != 'Metric':
|
||||
metric_values.append(val)
|
||||
|
||||
entry = {
|
||||
'name': sheet_name,
|
||||
'columns': columns,
|
||||
'row_count': row_count,
|
||||
}
|
||||
if metric_values:
|
||||
entry['metric_values'] = sorted(set(metric_values))
|
||||
|
||||
sheets.append(entry)
|
||||
|
||||
wb.close()
|
||||
print(json.dumps({'sheets': sheets}, indent=2))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
91
backend/scripts/extract_xlsx_schema.py
Normal file
91
backend/scripts/extract_xlsx_schema.py
Normal file
@@ -0,0 +1,91 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Extract the structural schema of a compliance xlsx file as JSON.
|
||||
Usage: python3 extract_xlsx_schema.py <path_to_xlsx>
|
||||
|
||||
Output:
|
||||
{
|
||||
"sheets": [
|
||||
{
|
||||
"name": "Summary",
|
||||
"columns": ["Metric", "Non-Compliant", "..."],
|
||||
"metric_values": ["2.3.4i", "5.2.4", "..."]
|
||||
},
|
||||
{
|
||||
"name": "2.3.4i",
|
||||
"columns": ["Preferred - Hostname", "GRANITE - IPv4_Address", "..."]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
- Uses openpyxl in read-only mode.
|
||||
- Extracts sheet names, first-row column headers per sheet, and unique metric
|
||||
values from the Summary sheet (header at row 4, data from row 5 onward).
|
||||
- On error, returns { "error": "..." } on stdout and exits with non-zero code.
|
||||
|
||||
Dependencies: openpyxl (already in requirements.txt)
|
||||
"""
|
||||
import sys
|
||||
import json
|
||||
from openpyxl import load_workbook
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 2:
|
||||
print(json.dumps({"error": "No file path provided"}))
|
||||
sys.exit(1)
|
||||
|
||||
filepath = sys.argv[1]
|
||||
|
||||
try:
|
||||
wb = load_workbook(filepath, read_only=True, data_only=True)
|
||||
except Exception as e:
|
||||
print(json.dumps({"error": f"Cannot open file: {str(e)}"}))
|
||||
sys.exit(1)
|
||||
|
||||
if not wb.sheetnames:
|
||||
print(json.dumps({"error": "Workbook contains no sheets"}))
|
||||
wb.close()
|
||||
sys.exit(1)
|
||||
|
||||
sheets = []
|
||||
for sheet_name in wb.sheetnames:
|
||||
ws = wb[sheet_name]
|
||||
|
||||
# Extract first-row column headers
|
||||
rows = list(ws.iter_rows(max_row=1, values_only=True))
|
||||
columns = [str(c).strip() for c in rows[0] if c is not None] if rows else []
|
||||
|
||||
entry = {
|
||||
"name": sheet_name,
|
||||
"columns": columns,
|
||||
}
|
||||
|
||||
# Extract metric values from the Summary sheet
|
||||
# Summary has header at row 4, data from row 5 onward
|
||||
if sheet_name == "Summary":
|
||||
metric_values = []
|
||||
header_rows = list(ws.iter_rows(min_row=4, max_row=4, values_only=True))
|
||||
if header_rows:
|
||||
summary_cols = [str(c).strip() if c else "" for c in header_rows[0]]
|
||||
metric_idx = None
|
||||
for i, col in enumerate(summary_cols):
|
||||
if col == "Metric":
|
||||
metric_idx = i
|
||||
break
|
||||
if metric_idx is not None:
|
||||
for row in ws.iter_rows(min_row=5, values_only=True):
|
||||
if row[metric_idx] is not None:
|
||||
val = str(row[metric_idx]).strip()
|
||||
if val and val != "Metric":
|
||||
metric_values.append(val)
|
||||
entry["metric_values"] = sorted(set(metric_values))
|
||||
|
||||
sheets.append(entry)
|
||||
|
||||
wb.close()
|
||||
print(json.dumps({"sheets": sheets}))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -12,39 +12,35 @@ Output:
|
||||
}
|
||||
"""
|
||||
import sys
|
||||
import os
|
||||
import json
|
||||
import re
|
||||
import pandas as pd
|
||||
from pathlib import Path
|
||||
|
||||
METRIC_CATEGORIES = {
|
||||
'2.3.4i': 'Vulnerability Management',
|
||||
'2.3.6i': 'Vulnerability Management',
|
||||
'2.3.8i': 'Vulnerability Management',
|
||||
'5.2.4': 'Access & MFA',
|
||||
'5.2.5': 'Access & MFA',
|
||||
'5.2.6': 'Access & MFA',
|
||||
'5.3.4': 'Endpoint Protection',
|
||||
'5.5.2': 'End-of-Life OS',
|
||||
'5.5.4i': 'Vulnerability Management',
|
||||
'5.5.5': 'Decommissioned Assets',
|
||||
'5.8.1': 'Application Security',
|
||||
'7.1.1': 'Logging & Monitoring',
|
||||
'7.6.13': 'Disaster Recovery',
|
||||
'7.6.16': 'Disaster Recovery',
|
||||
'Missing_AppID': 'Asset Data Quality',
|
||||
'Missing_DF': 'Asset Data Quality',
|
||||
'Missing_OS': 'Asset Data Quality',
|
||||
}
|
||||
|
||||
# Columns that go into the main item fields — everything else becomes extra_json
|
||||
CORE_COLS = {
|
||||
'Preferred - Hostname', 'GRANITE - IPv4_Address', 'GRANITE - Type',
|
||||
'Team', 'Compliant', 'Source_Network', 'Vertical',
|
||||
'GRANITE - Equip_Inst_ID', 'GRANITE - RESPONSIBLE_TEAM',
|
||||
}
|
||||
def load_config():
|
||||
"""Load parser configuration from compliance_config.json."""
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
config_path = os.path.join(script_dir, 'compliance_config.json')
|
||||
|
||||
SKIP_SHEETS = {'Summary', 'CMDB_9box'}
|
||||
try:
|
||||
with open(config_path, 'r') as f:
|
||||
config = json.load(f)
|
||||
except FileNotFoundError:
|
||||
print(f"Error: Configuration file not found: {config_path}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
except json.JSONDecodeError as e:
|
||||
print(f"Error: Invalid JSON in configuration file {config_path}: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
return config
|
||||
|
||||
|
||||
_config = load_config()
|
||||
METRIC_CATEGORIES = _config['metric_categories']
|
||||
CORE_COLS = set(_config['core_cols'])
|
||||
SKIP_SHEETS = set(_config['skip_sheets'])
|
||||
|
||||
|
||||
def safe_str(val):
|
||||
|
||||
@@ -12,7 +12,7 @@ const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
// Auth imports
|
||||
const { requireAuth, requireRole } = require('./middleware/auth');
|
||||
const { requireAuth, requireGroup } = require('./middleware/auth');
|
||||
const createAuthRouter = require('./routes/auth');
|
||||
const createUsersRouter = require('./routes/users');
|
||||
const createAuditLogRouter = require('./routes/auditLog');
|
||||
@@ -23,12 +23,21 @@ const createArcherTicketsRouter = require('./routes/archerTickets');
|
||||
const createIvantiWorkflowsRouter = require('./routes/ivantiWorkflows');
|
||||
const createIvantiFindingsRouter = require('./routes/ivantiFindings');
|
||||
const createIvantiTodoQueueRouter = require('./routes/ivantiTodoQueue');
|
||||
const createComplianceRouter = require('./routes/compliance');
|
||||
const createIvantiArchiveRouter = require('./routes/ivantiArchive');
|
||||
const createIvantiFpWorkflowRouter = require('./routes/ivantiFpWorkflow');
|
||||
const { createComplianceRouter } = require('./routes/compliance');
|
||||
const createAtlasRouter = require('./routes/atlas');
|
||||
const createJiraTicketsRouter = require('./routes/jiraTickets');
|
||||
const createCardApiRouter = require('./routes/cardApi');
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3001;
|
||||
const API_HOST = process.env.API_HOST || 'localhost';
|
||||
const SESSION_SECRET = process.env.SESSION_SECRET || 'default-secret-change-me';
|
||||
const SESSION_SECRET = process.env.SESSION_SECRET;
|
||||
if (!SESSION_SECRET) {
|
||||
console.error('FATAL: SESSION_SECRET environment variable must be set');
|
||||
process.exit(1);
|
||||
}
|
||||
const CORS_ORIGINS = process.env.CORS_ORIGINS
|
||||
? process.env.CORS_ORIGINS.split(',')
|
||||
: ['http://localhost:3000'];
|
||||
@@ -160,10 +169,10 @@ const db = new sqlite3.Database('./cve_database.db', (err) => {
|
||||
app.use('/api/auth', createAuthRouter(db, logAudit));
|
||||
|
||||
// User management routes (admin only)
|
||||
app.use('/api/users', createUsersRouter(db, requireAuth, requireRole, logAudit));
|
||||
app.use('/api/users', createUsersRouter(db, requireAuth, requireGroup, logAudit));
|
||||
|
||||
// Audit log routes (admin only)
|
||||
app.use('/api/audit-logs', createAuditLogRouter(db, requireAuth, requireRole));
|
||||
app.use('/api/audit-logs', createAuditLogRouter(db, requireAuth, requireGroup));
|
||||
|
||||
// NVD lookup routes (authenticated users)
|
||||
app.use('/api/nvd', createNvdLookupRouter(db, requireAuth));
|
||||
@@ -219,8 +228,23 @@ app.use('/api/ivanti/findings', createIvantiFindingsRouter(db, requireAuth));
|
||||
// Ivanti queue routes — per-user staging queue for FP / Archer workflows
|
||||
app.use('/api/ivanti/todo-queue', createIvantiTodoQueueRouter(db, requireAuth));
|
||||
|
||||
// Ivanti archive routes — finding archive tracking for severity score drift
|
||||
app.use('/api/ivanti/archive', createIvantiArchiveRouter(db, requireAuth));
|
||||
|
||||
// Ivanti FP workflow routes — submit False Positive workflows to Ivanti API
|
||||
app.use('/api/ivanti/fp-workflow', createIvantiFpWorkflowRouter(db, requireAuth));
|
||||
|
||||
// AEO compliance routes — xlsx upload, non-compliant item tracking, notes
|
||||
app.use('/api/compliance', createComplianceRouter(db, upload, requireAuth, requireRole));
|
||||
app.use('/api/compliance', createComplianceRouter(db, upload, requireAuth, requireGroup));
|
||||
|
||||
// Atlas InfoSec action plan routes — proxy CRUD to Atlas API, local cache for badges
|
||||
app.use('/api/atlas', createAtlasRouter(db, requireAuth));
|
||||
|
||||
// Jira ticket routes — local CRUD + Jira REST API integration (lookup, sync, create)
|
||||
app.use('/api/jira-tickets', createJiraTicketsRouter(db));
|
||||
|
||||
// CARD Asset Ownership API routes — proxy CARD operations, mutation flow, asset search
|
||||
app.use('/api/card', createCardApiRouter(db, requireAuth));
|
||||
|
||||
// ========== CVE ENDPOINTS ==========
|
||||
|
||||
@@ -336,6 +360,29 @@ app.get('/api/cves/:cveId/vendors', requireAuth(db), (req, res) => {
|
||||
});
|
||||
});
|
||||
|
||||
// Get tooltip data for a specific CVE (authenticated users)
|
||||
app.get('/api/cves/:cveId/tooltip', requireAuth(db), (req, res) => {
|
||||
const { cveId } = req.params;
|
||||
|
||||
if (!CVE_ID_PATTERN.test(cveId)) {
|
||||
return res.status(400).json({ error: 'Invalid CVE ID format.' });
|
||||
}
|
||||
|
||||
db.get('SELECT cve_id, description, severity FROM cves WHERE cve_id = ? LIMIT 1', [cveId], (err, row) => {
|
||||
if (err) {
|
||||
console.error('Error fetching CVE tooltip:', err);
|
||||
return res.status(500).json({ error: 'Internal server error.' });
|
||||
}
|
||||
if (!row) {
|
||||
return res.json({ exists: false });
|
||||
}
|
||||
let description = row.description || '';
|
||||
if (description.length > 300) {
|
||||
description = description.substring(0, 300) + '\u2026';
|
||||
}
|
||||
res.json({ exists: true, cve_id: row.cve_id, description, severity: row.severity });
|
||||
});
|
||||
});
|
||||
|
||||
// Compliance export — reads from cve_document_status view
|
||||
app.get('/api/cves/compliance', requireAuth(db), (req, res) => {
|
||||
@@ -349,7 +396,7 @@ app.get('/api/cves/compliance', requireAuth(db), (req, res) => {
|
||||
});
|
||||
|
||||
// Create new CVE entry - ALLOW MULTIPLE VENDORS (editor or admin)
|
||||
app.post('/api/cves', requireAuth(db), requireRole('editor', 'admin'), (req, res) => {
|
||||
app.post('/api/cves', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
|
||||
const { cve_id, vendor, severity, description, published_date } = req.body;
|
||||
|
||||
// Input validation
|
||||
@@ -370,11 +417,11 @@ app.post('/api/cves', requireAuth(db), requireRole('editor', 'admin'), (req, res
|
||||
}
|
||||
|
||||
const query = `
|
||||
INSERT INTO cves (cve_id, vendor, severity, description, published_date)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
INSERT INTO cves (cve_id, vendor, severity, description, published_date, created_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`;
|
||||
|
||||
db.run(query, [cve_id, vendor, severity, description, published_date], function(err) {
|
||||
db.run(query, [cve_id, vendor, severity, description, published_date, req.user.id], function(err) {
|
||||
if (err) {
|
||||
console.error('DATABASE ERROR:', err);
|
||||
if (err.message.includes('UNIQUE constraint failed')) {
|
||||
@@ -403,7 +450,7 @@ app.post('/api/cves', requireAuth(db), requireRole('editor', 'admin'), (req, res
|
||||
|
||||
|
||||
// Update CVE status (editor or admin)
|
||||
app.patch('/api/cves/:cveId/status', requireAuth(db), requireRole('editor', 'admin'), (req, res) => {
|
||||
app.patch('/api/cves/:cveId/status', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
|
||||
const { cveId } = req.params;
|
||||
const { status } = req.body;
|
||||
|
||||
@@ -431,7 +478,7 @@ app.patch('/api/cves/:cveId/status', requireAuth(db), requireRole('editor', 'adm
|
||||
});
|
||||
|
||||
// Bulk sync CVE data from NVD (editor or admin)
|
||||
app.post('/api/cves/nvd-sync', requireAuth(db), requireRole('editor', 'admin'), (req, res) => {
|
||||
app.post('/api/cves/nvd-sync', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
|
||||
const { updates } = req.body;
|
||||
if (!Array.isArray(updates) || updates.length === 0) {
|
||||
return res.status(400).json({ error: 'No updates provided' });
|
||||
@@ -501,7 +548,7 @@ app.post('/api/cves/nvd-sync', requireAuth(db), requireRole('editor', 'admin'),
|
||||
// ========== CVE EDIT & DELETE ENDPOINTS ==========
|
||||
|
||||
// Edit single CVE entry (editor or admin)
|
||||
app.put('/api/cves/:id', requireAuth(db), requireRole('editor', 'admin'), (req, res) => {
|
||||
app.put('/api/cves/:id', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
|
||||
const { id } = req.params;
|
||||
const { cve_id, vendor, severity, description, published_date, status } = req.body;
|
||||
|
||||
@@ -645,7 +692,7 @@ app.put('/api/cves/:id', requireAuth(db), requireRole('editor', 'admin'), (req,
|
||||
});
|
||||
|
||||
// Delete entire CVE - all vendors (editor or admin) - MUST be before /:id route
|
||||
app.delete('/api/cves/by-cve-id/:cveId', requireAuth(db), requireRole('editor', 'admin'), (req, res) => {
|
||||
app.delete('/api/cves/by-cve-id/:cveId', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
|
||||
const { cveId } = req.params;
|
||||
|
||||
// Get all rows for this CVE ID to know what we're deleting
|
||||
@@ -653,6 +700,151 @@ app.delete('/api/cves/by-cve-id/:cveId', requireAuth(db), requireRole('editor',
|
||||
if (err) { console.error(err); return res.status(500).json({ error: 'Internal server error.' }); }
|
||||
if (!rows || rows.length === 0) return res.status(404).json({ error: 'No CVE entries found for this CVE ID' });
|
||||
|
||||
// Ownership check: Standard_User can only delete CVEs they created
|
||||
if (req.user.group === 'Standard_User') {
|
||||
const notOwned = rows.some(row => row.created_by !== req.user.id);
|
||||
if (notOwned) {
|
||||
return res.status(403).json({ error: 'You can only delete resources you created' });
|
||||
}
|
||||
|
||||
// Cascade impact check for Standard_User
|
||||
// Query all three cascade-deleted resource types in parallel
|
||||
db.all('SELECT id, exc_number, cve_id, vendor FROM archer_tickets WHERE cve_id = ?', [cveId], (archerErr, archerTickets) => {
|
||||
if (archerErr) { console.error(archerErr); return res.status(500).json({ error: 'Internal server error.' }); }
|
||||
|
||||
db.all('SELECT id, cve_id, vendor, ticket_key, status FROM jira_tickets WHERE cve_id = ?', [cveId], (jiraErr, jiraTickets) => {
|
||||
// If jira_tickets table doesn't exist yet, treat as empty
|
||||
if (jiraErr && jiraErr.message && jiraErr.message.includes('no such table')) {
|
||||
jiraTickets = [];
|
||||
} else if (jiraErr) {
|
||||
console.error(jiraErr);
|
||||
return res.status(500).json({ error: 'Internal server error.' });
|
||||
}
|
||||
|
||||
db.all('SELECT id, name, type FROM documents WHERE cve_id = ?', [cveId], (docErr, docs) => {
|
||||
if (docErr) { console.error(docErr); return res.status(500).json({ error: 'Internal server error.' }); }
|
||||
|
||||
const allTickets = [
|
||||
...(archerTickets || []).map(t => ({ ...t, source: 'archer', key: t.exc_number })),
|
||||
...(jiraTickets || []).map(t => ({ ...t, source: 'jira', key: t.ticket_key }))
|
||||
];
|
||||
|
||||
// If no tickets at all, no compliance linkage possible — return cascade info
|
||||
if (allTickets.length === 0) {
|
||||
return res.json({
|
||||
cascade_impact: {
|
||||
archer_tickets: [],
|
||||
jira_tickets: [],
|
||||
documents: (docs || []).map(d => ({ id: d.id, name: d.name, type: d.type })),
|
||||
blocked: false,
|
||||
blocked_reason: null
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Check compliance linkage for each ticket
|
||||
// A ticket is compliance-linked if its key (exc_number or ticket_key) or cve_id
|
||||
// appears in active compliance_items extra_json
|
||||
const likeConditions = [];
|
||||
const likeParams = [];
|
||||
for (const t of allTickets) {
|
||||
likeConditions.push('ci.extra_json LIKE ?');
|
||||
likeParams.push(`%${t.key}%`);
|
||||
}
|
||||
// Also check if the CVE ID itself appears in compliance extra_json
|
||||
likeConditions.push('ci.extra_json LIKE ?');
|
||||
likeParams.push(`%${cveId}%`);
|
||||
|
||||
db.all(
|
||||
`SELECT ci.id, ci.extra_json, cu.report_date
|
||||
FROM compliance_items ci
|
||||
JOIN compliance_uploads cu ON ci.upload_id = cu.id
|
||||
WHERE ci.status = 'active' AND (${likeConditions.join(' OR ')})`,
|
||||
likeParams,
|
||||
(compErr, compLinks) => {
|
||||
// If compliance_items table doesn't exist yet, treat as no linkage
|
||||
if (compErr && compErr.message && compErr.message.includes('no such table')) {
|
||||
compLinks = [];
|
||||
} else if (compErr) {
|
||||
console.error(compErr);
|
||||
return res.status(500).json({ error: 'Internal server error.' });
|
||||
}
|
||||
|
||||
// Determine which tickets are compliance-linked by checking extra_json matches
|
||||
const linkedTicketKeys = new Set();
|
||||
for (const cl of (compLinks || [])) {
|
||||
const json = cl.extra_json || '';
|
||||
for (const t of allTickets) {
|
||||
if (json.includes(t.key)) {
|
||||
linkedTicketKeys.add(`${t.source}:${t.id}`);
|
||||
}
|
||||
}
|
||||
// If CVE ID itself is in compliance data, all tickets are considered linked
|
||||
if (json.includes(cveId)) {
|
||||
for (const t of allTickets) {
|
||||
linkedTicketKeys.add(`${t.source}:${t.id}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const archerTicketsResult = (archerTickets || []).map(t => ({
|
||||
id: t.id,
|
||||
exc_number: t.exc_number,
|
||||
compliance_linked: linkedTicketKeys.has(`archer:${t.id}`)
|
||||
}));
|
||||
|
||||
const jiraTicketsResult = (jiraTickets || []).map(t => ({
|
||||
id: t.id,
|
||||
ticket_key: t.ticket_key,
|
||||
compliance_linked: linkedTicketKeys.has(`jira:${t.id}`)
|
||||
}));
|
||||
|
||||
const documentsResult = (docs || []).map(d => ({
|
||||
id: d.id,
|
||||
name: d.name,
|
||||
type: d.type
|
||||
}));
|
||||
|
||||
const hasComplianceLink = archerTicketsResult.some(t => t.compliance_linked)
|
||||
|| jiraTicketsResult.some(t => t.compliance_linked);
|
||||
|
||||
if (hasComplianceLink) {
|
||||
const blockedArcher = archerTicketsResult.find(t => t.compliance_linked);
|
||||
const blockedJira = jiraTicketsResult.find(t => t.compliance_linked);
|
||||
const blockedLabel = blockedArcher
|
||||
? `Archer ticket ${blockedArcher.exc_number}`
|
||||
: `JIRA ticket ${blockedJira.ticket_key}`;
|
||||
return res.status(403).json({
|
||||
error: 'CVE deletion blocked: associated ticket linked to compliance report',
|
||||
cascade_impact: {
|
||||
archer_tickets: archerTicketsResult,
|
||||
jira_tickets: jiraTicketsResult,
|
||||
documents: documentsResult,
|
||||
blocked: true,
|
||||
blocked_reason: `${blockedLabel} is linked to a compliance report`
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Not blocked — return cascade impact for frontend warning
|
||||
return res.json({
|
||||
cascade_impact: {
|
||||
archer_tickets: archerTicketsResult,
|
||||
jira_tickets: jiraTicketsResult,
|
||||
documents: documentsResult,
|
||||
blocked: false,
|
||||
blocked_reason: null
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
return; // Exit early — Standard_User flow handled above
|
||||
}
|
||||
|
||||
// Admin flow: proceed directly with deletion (no cascade check)
|
||||
// Delete all documents from DB
|
||||
db.run('DELETE FROM documents WHERE cve_id = ?', [cveId], (docErr) => {
|
||||
if (docErr) console.error('Error deleting documents:', docErr);
|
||||
@@ -685,13 +877,71 @@ app.delete('/api/cves/by-cve-id/:cveId', requireAuth(db), requireRole('editor',
|
||||
});
|
||||
|
||||
// Delete single CVE vendor entry (editor or admin)
|
||||
app.delete('/api/cves/:id', requireAuth(db), requireRole('editor', 'admin'), (req, res) => {
|
||||
app.delete('/api/cves/:id', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
|
||||
const { id } = req.params;
|
||||
|
||||
db.get('SELECT * FROM cves WHERE id = ?', [id], (err, cve) => {
|
||||
if (err) { console.error(err); return res.status(500).json({ error: 'Internal server error.' }); }
|
||||
if (!cve) return res.status(404).json({ error: 'CVE entry not found' });
|
||||
|
||||
// Ownership check: Standard_User can only delete CVEs they created
|
||||
if (req.user.group === 'Standard_User' && cve.created_by !== req.user.id) {
|
||||
return res.status(403).json({ error: 'You can only delete resources you created' });
|
||||
}
|
||||
|
||||
// Cascade/compliance check for Standard_User
|
||||
if (req.user.group === 'Standard_User') {
|
||||
return db.all('SELECT id, exc_number FROM archer_tickets WHERE cve_id = ? AND vendor = ?', [cve.cve_id, cve.vendor], (archerErr, archerTickets) => {
|
||||
if (archerErr) { console.error(archerErr); return res.status(500).json({ error: 'Internal server error.' }); }
|
||||
|
||||
db.all('SELECT id, ticket_key FROM jira_tickets WHERE cve_id = ? AND vendor = ?', [cve.cve_id, cve.vendor], (jiraErr, jiraTickets) => {
|
||||
if (jiraErr && jiraErr.message && jiraErr.message.includes('no such table')) { jiraTickets = []; }
|
||||
else if (jiraErr) { console.error(jiraErr); return res.status(500).json({ error: 'Internal server error.' }); }
|
||||
|
||||
const allTickets = [
|
||||
...(archerTickets || []).map(t => ({ ...t, source: 'archer', key: t.exc_number })),
|
||||
...(jiraTickets || []).map(t => ({ ...t, source: 'jira', key: t.ticket_key }))
|
||||
];
|
||||
|
||||
if (allTickets.length === 0) {
|
||||
return doSingleCveDelete(req, res, id, cve);
|
||||
}
|
||||
|
||||
const likeConditions = allTickets.map(() => 'ci.extra_json LIKE ?');
|
||||
const likeParams = allTickets.map(t => `%${t.key}%`);
|
||||
|
||||
db.all(
|
||||
`SELECT ci.id, ci.extra_json FROM compliance_items ci
|
||||
JOIN compliance_uploads cu ON ci.upload_id = cu.id
|
||||
WHERE ci.status = 'active' AND (${likeConditions.join(' OR ')})`,
|
||||
likeParams,
|
||||
(compErr, compLinks) => {
|
||||
if (compErr && compErr.message && compErr.message.includes('no such table')) { compLinks = []; }
|
||||
else if (compErr) { console.error(compErr); return res.status(500).json({ error: 'Internal server error.' }); }
|
||||
|
||||
const hasLink = (compLinks || []).some(cl => {
|
||||
const json = cl.extra_json || '';
|
||||
return allTickets.some(t => json.includes(t.key));
|
||||
});
|
||||
|
||||
if (hasLink) {
|
||||
return res.status(403).json({
|
||||
error: 'CVE deletion blocked: associated ticket linked to compliance report',
|
||||
cascade_impact: { blocked: true, blocked_reason: 'Associated ticket is linked to a compliance report' }
|
||||
});
|
||||
}
|
||||
|
||||
return doSingleCveDelete(req, res, id, cve);
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
doSingleCveDelete(req, res, id, cve);
|
||||
});
|
||||
|
||||
function doSingleCveDelete(req, res, id, cve) {
|
||||
// Delete associated documents from DB
|
||||
db.all('SELECT id, file_path FROM documents WHERE cve_id = ? AND vendor = ?', [cve.cve_id, cve.vendor], (docErr, docs) => {
|
||||
if (docErr) console.error('Error fetching documents:', docErr);
|
||||
@@ -738,7 +988,7 @@ app.delete('/api/cves/:id', requireAuth(db), requireRole('editor', 'admin'), (re
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ========== DOCUMENT ENDPOINTS ==========
|
||||
@@ -767,7 +1017,7 @@ app.get('/api/cves/:cveId/documents', requireAuth(db), (req, res) => {
|
||||
});
|
||||
|
||||
// Upload document - ADD ERROR HANDLING FOR MULTER (editor or admin)
|
||||
app.post('/api/cves/:cveId/documents', requireAuth(db), requireRole('editor', 'admin'), (req, res, next) => {
|
||||
app.post('/api/cves/:cveId/documents', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res, next) => {
|
||||
upload.single('file')(req, res, (err) => {
|
||||
if (err) {
|
||||
console.error('Upload error:', err.message);
|
||||
@@ -875,7 +1125,7 @@ app.post('/api/cves/:cveId/documents', requireAuth(db), requireRole('editor', 'a
|
||||
});
|
||||
});
|
||||
// Delete document (admin only)
|
||||
app.delete('/api/documents/:id', requireAuth(db), requireRole('admin'), (req, res) => {
|
||||
app.delete('/api/documents/:id', requireAuth(db), requireGroup('Admin'), (req, res) => {
|
||||
const { id } = req.params;
|
||||
|
||||
// First get the file path to delete the actual file
|
||||
@@ -943,192 +1193,6 @@ app.get('/api/stats', requireAuth(db), (req, res) => {
|
||||
});
|
||||
});
|
||||
|
||||
// ========== JIRA TICKET ENDPOINTS ==========
|
||||
|
||||
// Get all JIRA tickets (with optional filters)
|
||||
app.get('/api/jira-tickets', requireAuth(db), (req, res) => {
|
||||
const { cve_id, vendor, status } = req.query;
|
||||
|
||||
let query = 'SELECT * FROM jira_tickets WHERE 1=1';
|
||||
const params = [];
|
||||
|
||||
if (cve_id) {
|
||||
query += ' AND cve_id = ?';
|
||||
params.push(cve_id);
|
||||
}
|
||||
if (vendor) {
|
||||
query += ' AND vendor = ?';
|
||||
params.push(vendor);
|
||||
}
|
||||
if (status) {
|
||||
query += ' AND status = ?';
|
||||
params.push(status);
|
||||
}
|
||||
|
||||
query += ' ORDER BY created_at DESC';
|
||||
|
||||
db.all(query, params, (err, rows) => {
|
||||
if (err) {
|
||||
console.error('Error fetching JIRA tickets:', err);
|
||||
return res.status(500).json({ error: 'Internal server error.' });
|
||||
}
|
||||
res.json(rows);
|
||||
});
|
||||
});
|
||||
|
||||
// Create JIRA ticket
|
||||
app.post('/api/jira-tickets', requireAuth(db), requireRole('editor', 'admin'), (req, res) => {
|
||||
const { cve_id, vendor, ticket_key, url, summary, status } = req.body;
|
||||
|
||||
// Validation
|
||||
if (!cve_id || !isValidCveId(cve_id)) {
|
||||
return res.status(400).json({ error: 'Valid CVE ID is required.' });
|
||||
}
|
||||
if (!vendor || !isValidVendor(vendor)) {
|
||||
return res.status(400).json({ error: 'Valid vendor is required.' });
|
||||
}
|
||||
if (!ticket_key || typeof ticket_key !== 'string' || ticket_key.trim().length === 0 || ticket_key.length > 50) {
|
||||
return res.status(400).json({ error: 'Ticket key is required (max 50 chars).' });
|
||||
}
|
||||
if (url && (typeof url !== 'string' || url.length > 500)) {
|
||||
return res.status(400).json({ error: 'URL must be under 500 characters.' });
|
||||
}
|
||||
if (summary && (typeof summary !== 'string' || summary.length > 500)) {
|
||||
return res.status(400).json({ error: 'Summary must be under 500 characters.' });
|
||||
}
|
||||
if (status && !VALID_TICKET_STATUSES.includes(status)) {
|
||||
return res.status(400).json({ error: `Status must be one of: ${VALID_TICKET_STATUSES.join(', ')}` });
|
||||
}
|
||||
|
||||
const ticketStatus = status || 'Open';
|
||||
|
||||
const query = `
|
||||
INSERT INTO jira_tickets (cve_id, vendor, ticket_key, url, summary, status)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`;
|
||||
|
||||
db.run(query, [cve_id, vendor, ticket_key.trim(), url || null, summary || null, ticketStatus], function(err) {
|
||||
if (err) {
|
||||
console.error('Error creating JIRA ticket:', err);
|
||||
return res.status(500).json({ error: 'Internal server error.' });
|
||||
}
|
||||
|
||||
logAudit(db, {
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'jira_ticket_create',
|
||||
entityType: 'jira_ticket',
|
||||
entityId: this.lastID.toString(),
|
||||
details: { cve_id, vendor, ticket_key, status: ticketStatus },
|
||||
ipAddress: req.ip
|
||||
});
|
||||
|
||||
res.status(201).json({
|
||||
id: this.lastID,
|
||||
message: 'JIRA ticket created successfully'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Update JIRA ticket
|
||||
app.put('/api/jira-tickets/:id', requireAuth(db), requireRole('editor', 'admin'), (req, res) => {
|
||||
const { id } = req.params;
|
||||
const { ticket_key, url, summary, status } = req.body;
|
||||
|
||||
// Validation
|
||||
if (ticket_key !== undefined && (typeof ticket_key !== 'string' || ticket_key.trim().length === 0 || ticket_key.length > 50)) {
|
||||
return res.status(400).json({ error: 'Ticket key must be under 50 chars.' });
|
||||
}
|
||||
if (url !== undefined && url !== null && (typeof url !== 'string' || url.length > 500)) {
|
||||
return res.status(400).json({ error: 'URL must be under 500 characters.' });
|
||||
}
|
||||
if (summary !== undefined && summary !== null && (typeof summary !== 'string' || summary.length > 500)) {
|
||||
return res.status(400).json({ error: 'Summary must be under 500 characters.' });
|
||||
}
|
||||
if (status !== undefined && !VALID_TICKET_STATUSES.includes(status)) {
|
||||
return res.status(400).json({ error: `Status must be one of: ${VALID_TICKET_STATUSES.join(', ')}` });
|
||||
}
|
||||
|
||||
// Build dynamic update
|
||||
const fields = [];
|
||||
const values = [];
|
||||
|
||||
if (ticket_key !== undefined) { fields.push('ticket_key = ?'); values.push(ticket_key.trim()); }
|
||||
if (url !== undefined) { fields.push('url = ?'); values.push(url); }
|
||||
if (summary !== undefined) { fields.push('summary = ?'); values.push(summary); }
|
||||
if (status !== undefined) { fields.push('status = ?'); values.push(status); }
|
||||
|
||||
if (fields.length === 0) {
|
||||
return res.status(400).json({ error: 'No fields to update.' });
|
||||
}
|
||||
|
||||
fields.push('updated_at = CURRENT_TIMESTAMP');
|
||||
values.push(id);
|
||||
|
||||
db.get('SELECT * FROM jira_tickets WHERE id = ?', [id], (err, existing) => {
|
||||
if (err) {
|
||||
console.error(err);
|
||||
return res.status(500).json({ error: 'Internal server error.' });
|
||||
}
|
||||
if (!existing) {
|
||||
return res.status(404).json({ error: 'JIRA ticket not found.' });
|
||||
}
|
||||
|
||||
db.run(`UPDATE jira_tickets SET ${fields.join(', ')} WHERE id = ?`, values, function(updateErr) {
|
||||
if (updateErr) {
|
||||
console.error('Error updating JIRA ticket:', updateErr);
|
||||
return res.status(500).json({ error: 'Internal server error.' });
|
||||
}
|
||||
|
||||
logAudit(db, {
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'jira_ticket_update',
|
||||
entityType: 'jira_ticket',
|
||||
entityId: id,
|
||||
details: { before: existing, changes: req.body },
|
||||
ipAddress: req.ip
|
||||
});
|
||||
|
||||
res.json({ message: 'JIRA ticket updated successfully', changes: this.changes });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Delete JIRA ticket
|
||||
app.delete('/api/jira-tickets/:id', requireAuth(db), requireRole('editor', 'admin'), (req, res) => {
|
||||
const { id } = req.params;
|
||||
|
||||
db.get('SELECT * FROM jira_tickets WHERE id = ?', [id], (err, ticket) => {
|
||||
if (err) {
|
||||
console.error(err);
|
||||
return res.status(500).json({ error: 'Internal server error.' });
|
||||
}
|
||||
if (!ticket) {
|
||||
return res.status(404).json({ error: 'JIRA ticket not found.' });
|
||||
}
|
||||
|
||||
db.run('DELETE FROM jira_tickets WHERE id = ?', [id], function(deleteErr) {
|
||||
if (deleteErr) {
|
||||
console.error('Error deleting JIRA ticket:', deleteErr);
|
||||
return res.status(500).json({ error: 'Internal server error.' });
|
||||
}
|
||||
|
||||
logAudit(db, {
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'jira_ticket_delete',
|
||||
entityType: 'jira_ticket',
|
||||
entityId: id,
|
||||
details: { ticket_key: ticket.ticket_key, cve_id: ticket.cve_id, vendor: ticket.vendor },
|
||||
ipAddress: req.ip
|
||||
});
|
||||
|
||||
res.json({ message: 'JIRA ticket deleted successfully' });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Start server
|
||||
app.listen(PORT, () => {
|
||||
console.log(`CVE API server running on http://${API_HOST}:${PORT}`);
|
||||
|
||||
882
backend/setup.js
882
backend/setup.js
@@ -1,333 +1,641 @@
|
||||
// Setup Script for CVE Database
|
||||
// This creates a fresh database with multi-vendor support built-in
|
||||
// Setup Script for CVE Dashboard v1.0.0
|
||||
// Creates a fresh database with the complete schema including all tables,
|
||||
// indexes, triggers, and views needed for a new deployment.
|
||||
//
|
||||
// Usage: node backend/setup.js
|
||||
//
|
||||
// This consolidates the original schema plus all migration scripts into a
|
||||
// single idempotent setup. Migration scripts in backend/migrations/ are
|
||||
// retained for reference but are NOT needed on fresh deployments.
|
||||
|
||||
const sqlite3 = require('sqlite3').verbose();
|
||||
const bcrypt = require('bcryptjs');
|
||||
const crypto = require('crypto');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const DB_FILE = './cve_database.db';
|
||||
const UPLOADS_DIR = './uploads';
|
||||
const DB_FILE = path.join(__dirname, 'cve_database.db');
|
||||
const UPLOADS_DIR = path.join(__dirname, 'uploads');
|
||||
|
||||
// Initialize database with schema
|
||||
function initializeDatabase() {
|
||||
// ---------------------------------------------------------------------------
|
||||
// Database helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
function dbRun(db, sql, params = []) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const db = new sqlite3.Database(DB_FILE, (err) => {
|
||||
db.run(sql, params, function (err) {
|
||||
if (err) reject(err);
|
||||
});
|
||||
|
||||
const schema = `
|
||||
CREATE TABLE IF NOT EXISTS cves (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
cve_id VARCHAR(20) NOT NULL,
|
||||
vendor VARCHAR(100) NOT NULL,
|
||||
severity VARCHAR(20) NOT NULL,
|
||||
description TEXT,
|
||||
published_date DATE,
|
||||
status VARCHAR(50) DEFAULT 'Open',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(cve_id, vendor)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS documents (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
cve_id VARCHAR(20) NOT NULL,
|
||||
vendor VARCHAR(100) NOT NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
type VARCHAR(50) NOT NULL,
|
||||
file_path VARCHAR(500) NOT NULL,
|
||||
file_size VARCHAR(20),
|
||||
mime_type VARCHAR(100),
|
||||
uploaded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
notes TEXT,
|
||||
FOREIGN KEY (cve_id) REFERENCES cves(cve_id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS required_documents (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
vendor VARCHAR(100) NOT NULL,
|
||||
document_type VARCHAR(50) NOT NULL,
|
||||
is_mandatory BOOLEAN DEFAULT 1,
|
||||
description TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_cve_id ON cves(cve_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_vendor ON cves(vendor);
|
||||
CREATE INDEX IF NOT EXISTS idx_severity ON cves(severity);
|
||||
CREATE INDEX IF NOT EXISTS idx_status ON cves(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_doc_cve_id ON documents(cve_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_doc_vendor ON documents(vendor);
|
||||
CREATE INDEX IF NOT EXISTS idx_doc_type ON documents(type);
|
||||
|
||||
-- Users table for authentication
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username VARCHAR(50) UNIQUE NOT NULL,
|
||||
email VARCHAR(255) UNIQUE NOT NULL,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
role VARCHAR(20) NOT NULL DEFAULT 'viewer',
|
||||
is_active BOOLEAN DEFAULT 1,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
last_login TIMESTAMP,
|
||||
CHECK (role IN ('admin', 'editor', 'viewer'))
|
||||
);
|
||||
|
||||
-- Sessions table for session management
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id VARCHAR(255) UNIQUE NOT NULL,
|
||||
user_id INTEGER NOT NULL,
|
||||
expires_at TIMESTAMP NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_session_id ON sessions(session_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions(expires_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
|
||||
|
||||
-- Audit log table for tracking user actions
|
||||
CREATE TABLE IF NOT EXISTS audit_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER,
|
||||
username VARCHAR(50) NOT NULL,
|
||||
action VARCHAR(50) NOT NULL,
|
||||
entity_type VARCHAR(50) NOT NULL,
|
||||
entity_id VARCHAR(100),
|
||||
details TEXT,
|
||||
ip_address VARCHAR(45),
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_user_id ON audit_logs(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_action ON audit_logs(action);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_entity_type ON audit_logs(entity_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_created_at ON audit_logs(created_at);
|
||||
|
||||
INSERT OR IGNORE INTO required_documents (vendor, document_type, is_mandatory, description) VALUES
|
||||
('Microsoft', 'advisory', 1, 'Official Microsoft Security Advisory'),
|
||||
('Microsoft', 'screenshot', 0, 'Proof of patch application'),
|
||||
('Cisco', 'advisory', 1, 'Cisco Security Advisory'),
|
||||
('Oracle', 'advisory', 1, 'Oracle Security Alert'),
|
||||
('VMware', 'advisory', 1, 'VMware Security Advisory'),
|
||||
('Adobe', 'advisory', 1, 'Adobe Security Bulletin');
|
||||
|
||||
CREATE VIEW IF NOT EXISTS cve_document_status AS
|
||||
SELECT
|
||||
c.id as record_id,
|
||||
c.cve_id,
|
||||
c.vendor,
|
||||
c.severity,
|
||||
c.status,
|
||||
COUNT(DISTINCT d.id) as total_documents,
|
||||
COUNT(DISTINCT CASE WHEN d.type = 'advisory' THEN d.id END) as advisory_count,
|
||||
COUNT(DISTINCT CASE WHEN d.type = 'email' THEN d.id END) as email_count,
|
||||
COUNT(DISTINCT CASE WHEN d.type = 'screenshot' THEN d.id END) as screenshot_count,
|
||||
CASE
|
||||
WHEN COUNT(DISTINCT CASE WHEN d.type = 'advisory' THEN d.id END) > 0
|
||||
THEN 'Complete'
|
||||
ELSE 'Missing Required Docs'
|
||||
END as compliance_status
|
||||
FROM cves c
|
||||
LEFT JOIN documents d ON c.cve_id = d.cve_id AND c.vendor = d.vendor
|
||||
GROUP BY c.id, c.cve_id, c.vendor, c.severity, c.status;
|
||||
`;
|
||||
|
||||
db.exec(schema, (err) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
console.log('✓ Database initialized successfully');
|
||||
resolve(db);
|
||||
}
|
||||
else resolve(this);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Create uploads directory structure
|
||||
function createUploadsDirectory() {
|
||||
if (!fs.existsSync(UPLOADS_DIR)) {
|
||||
fs.mkdirSync(UPLOADS_DIR, { recursive: true });
|
||||
console.log('✓ Created uploads directory');
|
||||
} else {
|
||||
console.log('✓ Uploads directory already exists');
|
||||
}
|
||||
}
|
||||
|
||||
// Create default admin user
|
||||
async function createDefaultAdmin(db) {
|
||||
function dbGet(db, sql, params = []) {
|
||||
return new Promise((resolve, reject) => {
|
||||
// Check if admin already exists
|
||||
db.get('SELECT id FROM users WHERE username = ?', ['admin'], async (err, row) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
|
||||
if (row) {
|
||||
console.log('✓ Default admin user already exists');
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
// Create admin user with password 'admin123'
|
||||
const passwordHash = await bcrypt.hash('admin123', 10);
|
||||
|
||||
db.run(
|
||||
`INSERT INTO users (username, email, password_hash, role, is_active)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
['admin', 'admin@localhost', passwordHash, 'admin', 1],
|
||||
(err) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
console.log('✓ Created default admin user (admin/admin123)');
|
||||
resolve();
|
||||
}
|
||||
}
|
||||
);
|
||||
db.get(sql, params, (err, row) => {
|
||||
if (err) reject(err);
|
||||
else resolve(row);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Add sample CVE data (optional - for testing)
|
||||
async function addSampleData(db) {
|
||||
console.log('\n📝 Adding sample CVE data for testing...');
|
||||
|
||||
const sampleCVEs = [
|
||||
{
|
||||
cve_id: 'CVE-2024-SAMPLE-1',
|
||||
vendor: 'Microsoft',
|
||||
severity: 'Critical',
|
||||
description: 'Sample remote code execution vulnerability',
|
||||
published_date: '2024-01-15'
|
||||
},
|
||||
{
|
||||
cve_id: 'CVE-2024-SAMPLE-1',
|
||||
vendor: 'Cisco',
|
||||
severity: 'High',
|
||||
description: 'Sample remote code execution vulnerability',
|
||||
published_date: '2024-01-15'
|
||||
}
|
||||
function dbExec(db, sql) {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.exec(sql, (err) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Schema — complete v1.0.0 database structure
|
||||
// ---------------------------------------------------------------------------
|
||||
async function initializeDatabase(db) {
|
||||
await dbExec(db, `
|
||||
|
||||
-- =================================================================
|
||||
-- Core CVE tracking tables
|
||||
-- =================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS cves (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
cve_id VARCHAR(20) NOT NULL,
|
||||
vendor VARCHAR(100) NOT NULL,
|
||||
severity VARCHAR(20) NOT NULL,
|
||||
description TEXT,
|
||||
published_date DATE,
|
||||
status VARCHAR(50) DEFAULT 'Open',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(cve_id, vendor)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS documents (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
cve_id VARCHAR(20) NOT NULL,
|
||||
vendor VARCHAR(100) NOT NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
type VARCHAR(50) NOT NULL,
|
||||
file_path VARCHAR(500) NOT NULL,
|
||||
file_size VARCHAR(20),
|
||||
mime_type VARCHAR(100),
|
||||
uploaded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
notes TEXT,
|
||||
FOREIGN KEY (cve_id) REFERENCES cves(cve_id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS required_documents (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
vendor VARCHAR(100) NOT NULL,
|
||||
document_type VARCHAR(50) NOT NULL,
|
||||
is_mandatory BOOLEAN DEFAULT 1,
|
||||
description TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_cve_id ON cves(cve_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_vendor ON cves(vendor);
|
||||
CREATE INDEX IF NOT EXISTS idx_severity ON cves(severity);
|
||||
CREATE INDEX IF NOT EXISTS idx_status ON cves(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_doc_cve_id ON documents(cve_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_doc_vendor ON documents(vendor);
|
||||
CREATE INDEX IF NOT EXISTS idx_doc_type ON documents(type);
|
||||
|
||||
-- =================================================================
|
||||
-- Authentication and session management
|
||||
-- =================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username VARCHAR(50) UNIQUE NOT NULL,
|
||||
email VARCHAR(255) UNIQUE NOT NULL,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
role VARCHAR(20) NOT NULL DEFAULT 'viewer',
|
||||
is_active BOOLEAN DEFAULT 1,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
last_login TIMESTAMP,
|
||||
user_group VARCHAR(20) NOT NULL DEFAULT 'Read_Only',
|
||||
CHECK (role IN ('admin', 'editor', 'viewer'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id VARCHAR(255) UNIQUE NOT NULL,
|
||||
user_id INTEGER NOT NULL,
|
||||
expires_at TIMESTAMP NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_session_id ON sessions(session_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions(expires_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_user_group ON users(user_group);
|
||||
|
||||
-- =================================================================
|
||||
-- Audit logging
|
||||
-- =================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS audit_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER,
|
||||
username VARCHAR(50) NOT NULL,
|
||||
action VARCHAR(50) NOT NULL,
|
||||
entity_type VARCHAR(50) NOT NULL,
|
||||
entity_id VARCHAR(100),
|
||||
details TEXT,
|
||||
ip_address VARCHAR(45),
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_user_id ON audit_logs(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_action ON audit_logs(action);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_entity_type ON audit_logs(entity_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_created_at ON audit_logs(created_at);
|
||||
|
||||
-- =================================================================
|
||||
-- Jira integration
|
||||
-- =================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS jira_tickets (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
cve_id TEXT NOT NULL,
|
||||
vendor TEXT NOT NULL,
|
||||
ticket_key TEXT NOT NULL,
|
||||
url TEXT,
|
||||
summary TEXT,
|
||||
status TEXT DEFAULT 'Open' CHECK(status IN ('Open', 'In Progress', 'Closed')),
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (cve_id, vendor) REFERENCES cves(cve_id, vendor) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_jira_tickets_cve ON jira_tickets(cve_id, vendor);
|
||||
CREATE INDEX IF NOT EXISTS idx_jira_tickets_status ON jira_tickets(status);
|
||||
|
||||
-- =================================================================
|
||||
-- Archer integration
|
||||
-- =================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS archer_tickets (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
exc_number TEXT NOT NULL UNIQUE,
|
||||
archer_url TEXT,
|
||||
status TEXT DEFAULT 'Draft' CHECK(status IN ('Draft', 'Open', 'Under Review', 'Accepted')),
|
||||
cve_id TEXT NOT NULL,
|
||||
vendor TEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (cve_id, vendor) REFERENCES cves(cve_id, vendor) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_archer_tickets_cve ON archer_tickets(cve_id, vendor);
|
||||
CREATE INDEX IF NOT EXISTS idx_archer_tickets_status ON archer_tickets(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_archer_tickets_exc ON archer_tickets(exc_number);
|
||||
|
||||
-- =================================================================
|
||||
-- Knowledge base
|
||||
-- =================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS knowledge_base (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
slug VARCHAR(255) UNIQUE NOT NULL,
|
||||
description TEXT,
|
||||
category VARCHAR(100),
|
||||
file_path VARCHAR(500),
|
||||
file_name VARCHAR(255),
|
||||
file_type VARCHAR(50),
|
||||
file_size INTEGER,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by INTEGER,
|
||||
FOREIGN KEY (created_by) REFERENCES users(id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_knowledge_base_slug ON knowledge_base(slug);
|
||||
CREATE INDEX IF NOT EXISTS idx_knowledge_base_category ON knowledge_base(category);
|
||||
CREATE INDEX IF NOT EXISTS idx_knowledge_base_created_at ON knowledge_base(created_at DESC);
|
||||
|
||||
-- =================================================================
|
||||
-- Ivanti findings sync and cache
|
||||
-- =================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ivanti_sync_state (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
total INTEGER DEFAULT 0,
|
||||
workflows_json TEXT DEFAULT '[]',
|
||||
synced_at DATETIME,
|
||||
sync_status TEXT DEFAULT 'never',
|
||||
error_message TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ivanti_findings_cache (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
total INTEGER DEFAULT 0,
|
||||
findings_json TEXT DEFAULT '[]',
|
||||
synced_at DATETIME,
|
||||
sync_status TEXT DEFAULT 'never',
|
||||
error_message TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ivanti_finding_notes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
finding_id TEXT NOT NULL UNIQUE,
|
||||
note TEXT NOT NULL DEFAULT '',
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_finding_notes_finding_id ON ivanti_finding_notes(finding_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ivanti_counts_cache (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
open_count INTEGER DEFAULT 0,
|
||||
closed_count INTEGER DEFAULT 0,
|
||||
synced_at DATETIME,
|
||||
fp_workflow_counts_json TEXT DEFAULT '{}',
|
||||
fp_id_counts_json TEXT DEFAULT '{}'
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ivanti_finding_overrides (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
finding_id TEXT NOT NULL,
|
||||
field TEXT NOT NULL,
|
||||
value TEXT NOT NULL DEFAULT '',
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(finding_id, field)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_finding_overrides_finding_id ON ivanti_finding_overrides(finding_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ivanti_counts_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
open_count INTEGER NOT NULL,
|
||||
closed_count INTEGER NOT NULL,
|
||||
recorded_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- =================================================================
|
||||
-- Ivanti FP (False Positive) submissions
|
||||
-- =================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ivanti_fp_submissions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
username TEXT NOT NULL,
|
||||
ivanti_workflow_batch_id INTEGER,
|
||||
ivanti_generated_id TEXT,
|
||||
ivanti_workflow_batch_uuid TEXT,
|
||||
workflow_name TEXT NOT NULL,
|
||||
reason TEXT NOT NULL,
|
||||
description TEXT,
|
||||
expiration_date TEXT NOT NULL,
|
||||
scope_override TEXT NOT NULL DEFAULT 'Authorized',
|
||||
finding_ids_json TEXT NOT NULL,
|
||||
queue_item_ids_json TEXT NOT NULL,
|
||||
attachment_count INTEGER DEFAULT 0,
|
||||
attachment_results_json TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'success' CHECK(status IN ('success', 'partial', 'failed')),
|
||||
lifecycle_status TEXT NOT NULL DEFAULT 'submitted' CHECK(lifecycle_status IN ('submitted', 'approved', 'rejected', 'rework', 'resubmitted')),
|
||||
error_message TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_fp_submissions_user ON ivanti_fp_submissions(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_fp_submissions_ivanti_id ON ivanti_fp_submissions(ivanti_generated_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ivanti_fp_submission_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
submission_id INTEGER NOT NULL,
|
||||
user_id INTEGER NOT NULL,
|
||||
username TEXT NOT NULL,
|
||||
change_type TEXT NOT NULL CHECK(change_type IN (
|
||||
'created', 'fields_updated', 'findings_added',
|
||||
'attachments_added', 'status_changed'
|
||||
)),
|
||||
change_details_json TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (submission_id) REFERENCES ivanti_fp_submissions(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_fp_history_submission ON ivanti_fp_submission_history(submission_id);
|
||||
|
||||
-- =================================================================
|
||||
-- Ivanti todo queue (FP, Archer, CARD, GRANITE workflows)
|
||||
-- =================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ivanti_todo_queue (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
finding_id TEXT NOT NULL,
|
||||
finding_title TEXT,
|
||||
cves_json TEXT,
|
||||
ip_address TEXT,
|
||||
hostname TEXT,
|
||||
vendor TEXT NOT NULL,
|
||||
workflow_type TEXT NOT NULL CHECK(workflow_type IN ('FP', 'Archer', 'CARD', 'GRANITE')),
|
||||
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending', 'complete')),
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_todo_queue_user ON ivanti_todo_queue(user_id, status);
|
||||
|
||||
-- =================================================================
|
||||
-- Ivanti archive detection and anomaly tracking
|
||||
-- =================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ivanti_finding_archives (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
finding_id TEXT NOT NULL UNIQUE,
|
||||
finding_title TEXT NOT NULL DEFAULT '',
|
||||
host_name TEXT NOT NULL DEFAULT '',
|
||||
ip_address TEXT NOT NULL DEFAULT '',
|
||||
current_state TEXT NOT NULL CHECK(current_state IN ('ARCHIVED','RETURNED','CLOSED','CLOSED_GONE')),
|
||||
last_severity REAL NOT NULL DEFAULT 0,
|
||||
first_archived_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
last_transition_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_archive_finding_id ON ivanti_finding_archives(finding_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_archive_current_state ON ivanti_finding_archives(current_state);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ivanti_archive_transitions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
archive_id INTEGER NOT NULL,
|
||||
from_state TEXT NOT NULL,
|
||||
to_state TEXT NOT NULL,
|
||||
severity_at_transition REAL NOT NULL DEFAULT 0,
|
||||
reason TEXT NOT NULL DEFAULT '',
|
||||
transitioned_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (archive_id) REFERENCES ivanti_finding_archives(id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_transition_archive_id ON ivanti_archive_transitions(archive_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ivanti_sync_anomaly_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
sync_timestamp DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
open_count_delta INTEGER NOT NULL DEFAULT 0,
|
||||
closed_count_delta INTEGER NOT NULL DEFAULT 0,
|
||||
newly_archived_count INTEGER NOT NULL DEFAULT 0,
|
||||
returned_count INTEGER NOT NULL DEFAULT 0,
|
||||
classification_json TEXT NOT NULL DEFAULT '{}',
|
||||
return_classification_json TEXT NOT NULL DEFAULT '{}',
|
||||
is_significant INTEGER NOT NULL DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_anomaly_sync_timestamp ON ivanti_sync_anomaly_log(sync_timestamp);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ivanti_finding_bu_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
finding_id TEXT NOT NULL,
|
||||
finding_title TEXT NOT NULL DEFAULT '',
|
||||
host_name TEXT NOT NULL DEFAULT '',
|
||||
previous_bu TEXT NOT NULL,
|
||||
new_bu TEXT NOT NULL,
|
||||
detected_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_bu_history_finding_id ON ivanti_finding_bu_history(finding_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_bu_history_detected_at ON ivanti_finding_bu_history(detected_at);
|
||||
|
||||
-- =================================================================
|
||||
-- Atlas action plans cache
|
||||
-- =================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS atlas_action_plans_cache (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
host_id INTEGER NOT NULL UNIQUE,
|
||||
has_action_plan INTEGER NOT NULL DEFAULT 0,
|
||||
plan_count INTEGER NOT NULL DEFAULT 0,
|
||||
plans_json TEXT NOT NULL DEFAULT '[]',
|
||||
synced_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_atlas_cache_host_id ON atlas_action_plans_cache(host_id);
|
||||
|
||||
-- =================================================================
|
||||
-- Compliance (NTS AEO) tracking
|
||||
-- =================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS compliance_uploads (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
filename TEXT NOT NULL,
|
||||
report_date TEXT,
|
||||
uploaded_by INTEGER,
|
||||
uploaded_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
new_count INTEGER DEFAULT 0,
|
||||
resolved_count INTEGER DEFAULT 0,
|
||||
recurring_count INTEGER DEFAULT 0,
|
||||
summary_json TEXT,
|
||||
FOREIGN KEY (uploaded_by) REFERENCES users(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS compliance_items (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
upload_id INTEGER NOT NULL,
|
||||
hostname TEXT NOT NULL,
|
||||
ip_address TEXT,
|
||||
device_type TEXT,
|
||||
team TEXT,
|
||||
metric_id TEXT NOT NULL,
|
||||
metric_desc TEXT,
|
||||
category TEXT,
|
||||
extra_json TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active', 'resolved')),
|
||||
first_seen_upload_id INTEGER,
|
||||
resolved_upload_id INTEGER,
|
||||
seen_count INTEGER DEFAULT 1,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (upload_id) REFERENCES compliance_uploads(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (first_seen_upload_id) REFERENCES compliance_uploads(id) ON DELETE SET NULL,
|
||||
FOREIGN KEY (resolved_upload_id) REFERENCES compliance_uploads(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_compliance_items_upload ON compliance_items(upload_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_compliance_items_identity ON compliance_items(hostname, metric_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_compliance_items_team_status ON compliance_items(team, status);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS compliance_notes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
hostname TEXT NOT NULL,
|
||||
metric_id TEXT NOT NULL,
|
||||
note TEXT NOT NULL,
|
||||
group_id TEXT,
|
||||
created_by INTEGER,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_compliance_notes_identity ON compliance_notes(hostname, metric_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_compliance_notes_group ON compliance_notes(group_id);
|
||||
|
||||
-- =================================================================
|
||||
-- Document compliance view
|
||||
-- =================================================================
|
||||
|
||||
CREATE VIEW IF NOT EXISTS cve_document_status AS
|
||||
SELECT
|
||||
c.id as record_id,
|
||||
c.cve_id,
|
||||
c.vendor,
|
||||
c.severity,
|
||||
c.status,
|
||||
COUNT(DISTINCT d.id) as total_documents,
|
||||
COUNT(DISTINCT CASE WHEN d.type = 'advisory' THEN d.id END) as advisory_count,
|
||||
COUNT(DISTINCT CASE WHEN d.type = 'email' THEN d.id END) as email_count,
|
||||
COUNT(DISTINCT CASE WHEN d.type = 'screenshot' THEN d.id END) as screenshot_count,
|
||||
CASE
|
||||
WHEN COUNT(DISTINCT CASE WHEN d.type = 'advisory' THEN d.id END) > 0
|
||||
THEN 'Complete'
|
||||
ELSE 'Missing Required Docs'
|
||||
END as compliance_status
|
||||
FROM cves c
|
||||
LEFT JOIN documents d ON c.cve_id = d.cve_id AND c.vendor = d.vendor
|
||||
GROUP BY c.id, c.cve_id, c.vendor, c.severity, c.status;
|
||||
|
||||
-- =================================================================
|
||||
-- Seed data
|
||||
-- =================================================================
|
||||
|
||||
INSERT OR IGNORE INTO required_documents (vendor, document_type, is_mandatory, description) VALUES
|
||||
('Microsoft', 'advisory', 1, 'Official Microsoft Security Advisory'),
|
||||
('Microsoft', 'screenshot', 0, 'Proof of patch application'),
|
||||
('Cisco', 'advisory', 1, 'Cisco Security Advisory'),
|
||||
('Oracle', 'advisory', 1, 'Oracle Security Alert'),
|
||||
('VMware', 'advisory', 1, 'VMware Security Advisory'),
|
||||
('Adobe', 'advisory', 1, 'Adobe Security Bulletin');
|
||||
`);
|
||||
|
||||
console.log('✓ Database schema initialized');
|
||||
|
||||
// User group validation triggers (cannot be in db.exec multi-statement)
|
||||
await dbRun(db, `
|
||||
CREATE TRIGGER IF NOT EXISTS check_user_group_insert
|
||||
BEFORE INSERT ON users
|
||||
FOR EACH ROW
|
||||
WHEN NEW.user_group NOT IN ('Admin', 'Standard_User', 'Leadership', 'Read_Only')
|
||||
BEGIN
|
||||
SELECT RAISE(ABORT, 'Invalid user_group value. Must be Admin, Standard_User, Leadership, or Read_Only');
|
||||
END
|
||||
`);
|
||||
|
||||
await dbRun(db, `
|
||||
CREATE TRIGGER IF NOT EXISTS check_user_group_update
|
||||
BEFORE UPDATE OF user_group ON users
|
||||
FOR EACH ROW
|
||||
WHEN NEW.user_group NOT IN ('Admin', 'Standard_User', 'Leadership', 'Read_Only')
|
||||
BEGIN
|
||||
SELECT RAISE(ABORT, 'Invalid user_group value. Must be Admin, Standard_User, Leadership, or Read_Only');
|
||||
END
|
||||
`);
|
||||
|
||||
console.log('✓ Triggers created');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Directory setup
|
||||
// ---------------------------------------------------------------------------
|
||||
function createDirectories() {
|
||||
const dirs = [
|
||||
UPLOADS_DIR,
|
||||
path.join(UPLOADS_DIR, 'temp'),
|
||||
path.join(UPLOADS_DIR, 'knowledge_base'),
|
||||
];
|
||||
|
||||
for (const cve of sampleCVEs) {
|
||||
await new Promise((resolve, reject) => {
|
||||
db.run(
|
||||
`INSERT OR IGNORE INTO cves (cve_id, vendor, severity, description, published_date)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
[cve.cve_id, cve.vendor, cve.severity, cve.description, cve.published_date],
|
||||
(err) => {
|
||||
if (err) reject(err);
|
||||
else {
|
||||
console.log(` ✓ Added sample: ${cve.cve_id} / ${cve.vendor}`);
|
||||
resolve();
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
for (const dir of dirs) {
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
console.log(`✓ Created directory: ${path.relative(__dirname, dir)}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('ℹ️ Sample data added - demonstrates multi-vendor support');
|
||||
}
|
||||
|
||||
// Verify database structure
|
||||
async function verifySetup(db) {
|
||||
return new Promise((resolve) => {
|
||||
db.get('SELECT sql FROM sqlite_master WHERE type="table" AND name="cves"', (err, row) => {
|
||||
if (err) {
|
||||
console.error('Warning: Could not verify setup:', err);
|
||||
} else {
|
||||
console.log('\n📋 CVEs table structure:');
|
||||
console.log(row.sql);
|
||||
|
||||
// Check if UNIQUE constraint is correct
|
||||
if (row.sql.includes('UNIQUE(cve_id, vendor)')) {
|
||||
console.log('\n✅ Multi-vendor support: ENABLED');
|
||||
} else {
|
||||
console.log('\n⚠️ Warning: Multi-vendor constraint may not be set correctly');
|
||||
}
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
// ---------------------------------------------------------------------------
|
||||
// Default admin user
|
||||
// ---------------------------------------------------------------------------
|
||||
async function createDefaultAdmin(db) {
|
||||
const existing = await dbGet(db, 'SELECT id FROM users WHERE username = ?', ['admin']);
|
||||
if (existing) {
|
||||
console.log('✓ Default admin user already exists');
|
||||
return;
|
||||
}
|
||||
|
||||
const generatedPassword = crypto.randomBytes(12).toString('base64url');
|
||||
const passwordHash = await bcrypt.hash(generatedPassword, 10);
|
||||
|
||||
await dbRun(db,
|
||||
`INSERT INTO users (username, email, password_hash, role, user_group, is_active)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
['admin', 'admin@localhost', passwordHash, 'admin', 'Admin', 1]
|
||||
);
|
||||
|
||||
console.log('✓ Created default admin user');
|
||||
console.log(`\n ╔══════════════════════════════════════════╗`);
|
||||
console.log(` ║ Admin credentials (save these now!) ║`);
|
||||
console.log(` ║ Username: admin ║`);
|
||||
console.log(` ║ Password: ${generatedPassword.padEnd(29)}║`);
|
||||
console.log(` ╚══════════════════════════════════════════╝\n`);
|
||||
}
|
||||
|
||||
// Display setup summary
|
||||
// ---------------------------------------------------------------------------
|
||||
// Setup summary
|
||||
// ---------------------------------------------------------------------------
|
||||
function displaySummary() {
|
||||
console.log('\n╔════════════════════════════════════════════════════════╗');
|
||||
console.log('║ CVE DATABASE SETUP COMPLETE! ║');
|
||||
console.log('║ CVE DASHBOARD v1.0.0 — SETUP COMPLETE ║');
|
||||
console.log('╚════════════════════════════════════════════════════════╝');
|
||||
console.log('\n📊 What was created:');
|
||||
console.log(' ✓ SQLite database (cve_database.db)');
|
||||
console.log(' ✓ Tables: cves, documents, required_documents, users, sessions, audit_logs');
|
||||
console.log(' ✓ Multi-vendor support with UNIQUE(cve_id, vendor)');
|
||||
console.log(' ✓ Vendor column in documents table');
|
||||
console.log(' ✓ User authentication with session-based auth');
|
||||
console.log(' ✓ Indexes for fast queries');
|
||||
console.log(' ✓ Document compliance view');
|
||||
console.log(' ✓ Uploads directory for file storage');
|
||||
console.log(' ✓ Default admin user (admin/admin123)');
|
||||
console.log('\n📁 File structure will be:');
|
||||
console.log(' uploads/');
|
||||
console.log(' └── CVE-XXXX-XXXX/');
|
||||
console.log(' ├── Vendor1/');
|
||||
console.log(' │ ├── advisory.pdf');
|
||||
console.log(' │ └── screenshot.png');
|
||||
console.log(' └── Vendor2/');
|
||||
console.log(' └── advisory.pdf');
|
||||
console.log('\n📊 Tables created:');
|
||||
console.log(' Core: cves, documents, required_documents');
|
||||
console.log(' Auth: users, sessions');
|
||||
console.log(' Audit: audit_logs');
|
||||
console.log(' Jira: jira_tickets');
|
||||
console.log(' Archer: archer_tickets');
|
||||
console.log(' KB: knowledge_base');
|
||||
console.log(' Ivanti: ivanti_sync_state, ivanti_findings_cache,');
|
||||
console.log(' ivanti_finding_notes, ivanti_counts_cache,');
|
||||
console.log(' ivanti_finding_overrides, ivanti_counts_history,');
|
||||
console.log(' ivanti_fp_submissions, ivanti_fp_submission_history,');
|
||||
console.log(' ivanti_todo_queue');
|
||||
console.log(' Archives: ivanti_finding_archives, ivanti_archive_transitions,');
|
||||
console.log(' ivanti_sync_anomaly_log, ivanti_finding_bu_history');
|
||||
console.log(' Atlas: atlas_action_plans_cache');
|
||||
console.log(' Compliance: compliance_uploads, compliance_items, compliance_notes');
|
||||
console.log('\n🚀 Next steps:');
|
||||
console.log(' 1. Start the backend API:');
|
||||
console.log(' → cd backend && node server.js');
|
||||
console.log(' 2. Start the frontend:');
|
||||
console.log(' → cd frontend && npm start');
|
||||
console.log(' 3. Open http://localhost:3000');
|
||||
console.log(' 4. Start adding CVEs with multiple vendors!');
|
||||
console.log('\n💡 Key Features:');
|
||||
console.log(' • Add same CVE-ID with different vendors');
|
||||
console.log(' • Each vendor has separate document storage');
|
||||
console.log(' • Quick Check shows all vendors for a CVE');
|
||||
console.log(' • Document compliance tracking per vendor');
|
||||
console.log(' • Required docs: Advisory (mandatory for most vendors)\n');
|
||||
console.log(' 1. Copy .env.example to .env and configure API keys');
|
||||
console.log(' 2. Start the backend: node backend/server.js');
|
||||
console.log(' 3. Build the frontend: cd frontend && npm run build');
|
||||
console.log(' 4. Open the dashboard and log in with the admin credentials above\n');
|
||||
}
|
||||
|
||||
// Main execution
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main
|
||||
// ---------------------------------------------------------------------------
|
||||
async function main() {
|
||||
console.log('🚀 CVE Database Setup (Multi-Vendor Support)\n');
|
||||
console.log('🚀 CVE Dashboard v1.0.0 — Database Setup\n');
|
||||
console.log('════════════════════════════════════════\n');
|
||||
|
||||
try {
|
||||
// Create uploads directory
|
||||
createUploadsDirectory();
|
||||
|
||||
// Initialize database
|
||||
const db = await initializeDatabase();
|
||||
|
||||
// Create default admin user
|
||||
try {
|
||||
createDirectories();
|
||||
|
||||
const db = new sqlite3.Database(DB_FILE);
|
||||
await initializeDatabase(db);
|
||||
await createDefaultAdmin(db);
|
||||
|
||||
// Add sample data
|
||||
await addSampleData(db);
|
||||
|
||||
// Verify setup
|
||||
await verifySetup(db);
|
||||
|
||||
// Close database connection
|
||||
db.close((err) => {
|
||||
if (err) console.error('Error closing database:', err);
|
||||
else console.log('\n✓ Database connection closed');
|
||||
|
||||
// Display summary
|
||||
else console.log('✓ Database connection closed');
|
||||
displaySummary();
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Setup Error:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Run the setup
|
||||
main();
|
||||
|
||||
BIN
cve_database.db
Normal file
BIN
cve_database.db
Normal file
Binary file not shown.
0
cve_database.db.backup
Normal file
0
cve_database.db.backup
Normal file
0
database.db
Normal file
0
database.db
Normal file
1316
docs/api/atlasinfosec-api-spec.json
Normal file
1316
docs/api/atlasinfosec-api-spec.json
Normal file
File diff suppressed because it is too large
Load Diff
194
docs/api/ivanti-api-reference.md
Normal file
194
docs/api/ivanti-api-reference.md
Normal file
@@ -0,0 +1,194 @@
|
||||
# Ivanti / RiskSense API Reference
|
||||
|
||||
Base URL: `https://platform4.risksense.com/api/v1`
|
||||
Swagger: `https://platform4.risksense.com/doc/swagger.json`
|
||||
|
||||
Auth: `x-api-key` header. Error codes: 401 bad key, 419 insufficient privileges, 429 rate limited.
|
||||
|
||||
## Endpoints Used
|
||||
|
||||
### Search Workflow Batches
|
||||
|
||||
```
|
||||
POST /client/{clientId}/workflowBatch/search
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
Standard JSON body with filters, projection, sort, page, size. Used by `ivantiWorkflows.js` for the daily sync.
|
||||
|
||||
### Create False Positive Workflow
|
||||
|
||||
```
|
||||
POST /client/{clientId}/workflowBatch/falsePositive/request
|
||||
Content-Type: multipart/form-data
|
||||
```
|
||||
|
||||
This endpoint does NOT accept JSON. It requires `multipart/form-data` with the following fields:
|
||||
|
||||
| Field | Type | Required | Notes |
|
||||
|-------|------|----------|-------|
|
||||
| `name` | string | yes | Workflow batch name (max 255) |
|
||||
| `reason` | string | yes | Reason for the FP determination |
|
||||
| `description` | string | yes | Description (can be empty string but field must be present) |
|
||||
| `expirationDate` | string | yes | ISO-8601 date, e.g. `2026-06-01` |
|
||||
| `overrideControl` | string | yes | `AUTHORIZED`, `NONE`, or `AUTOMATED`. Use `AUTHORIZED` for standard FP workflows. `NONE` with `isEmptyWorkflow=true` is rejected (400). |
|
||||
| `isEmptyWorkflow` | boolean | yes | `true` if no findings attached, `false` otherwise |
|
||||
| `subjectFilterRequest` | string | yes | Stringified JSON (see format below) |
|
||||
| `files` | file | no | Attachments sent inline in the same request |
|
||||
|
||||
#### subjectFilterRequest format
|
||||
|
||||
This is the critical field. It must be a stringified JSON object with this exact structure:
|
||||
|
||||
```json
|
||||
{
|
||||
"subject": "hostFinding",
|
||||
"filterRequest": {
|
||||
"filters": [
|
||||
{
|
||||
"field": "id",
|
||||
"exclusive": false,
|
||||
"operator": "IN",
|
||||
"value": "2283734550,2283734551"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Key details:
|
||||
- `subject` must be `"hostFinding"` — without this, the API returns 500
|
||||
- `filters` is nested inside `filterRequest`, NOT at the top level — `{"filters":[]}` at the top level returns 500
|
||||
- `value` for multiple IDs is comma-separated as a single string, not an array
|
||||
- `operator` values: `EXACT`, `IN`, `LIKE`, `WILDCARD`, `RANGE`, `CIDR`
|
||||
- For empty workflows, use `{"subject":"hostFinding","filterRequest":{"filters":[]}}` with `isEmptyWorkflow=true`
|
||||
|
||||
#### Response (200/202)
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 33418832,
|
||||
"created": "2026-04-08T18:16:08"
|
||||
}
|
||||
```
|
||||
|
||||
Returns HTTP 200 or 202 (Accepted — async job creation). Response contains a numeric `id` (the workflow batch job ID) and `created` timestamp. No `generatedId` or `uuid` in this response.
|
||||
|
||||
### Map Findings to Existing Workflow (tested 2026-04-13)
|
||||
|
||||
```
|
||||
POST /client/{clientId}/workflowBatch/falsePositive/{workflowBatchUuid}/map
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
Maps additional host findings to an existing FP workflow batch. Used by the FP submission editing feature to add findings after initial creation.
|
||||
|
||||
**Critical: one finding per call.** The map endpoint only reliably maps one finding per request. Sending multiple finding IDs via the `IN` operator or comma-separated values results in only the first finding being mapped. The multipart/form-data format (used by the create endpoint) returns 500 on this endpoint.
|
||||
|
||||
#### Request body
|
||||
|
||||
```json
|
||||
{
|
||||
"subject": "hostFinding",
|
||||
"filterRequest": {
|
||||
"filters": [
|
||||
{
|
||||
"field": "id",
|
||||
"exclusive": false,
|
||||
"operator": "EXACT",
|
||||
"value": "2283734550"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Key details:
|
||||
- Must be `application/json` (NOT multipart/form-data — returns 500)
|
||||
- Use `EXACT` operator with a single finding ID per call
|
||||
- `IN` operator with comma-separated IDs only maps the first finding
|
||||
- Loop through findings and make one API call per finding
|
||||
- The `workflowBatchUuid` in the URL is the UUID from the search endpoint (not the numeric batch ID from create)
|
||||
|
||||
#### Response (200)
|
||||
|
||||
Returns the updated workflow batch object on success.
|
||||
|
||||
#### UUID resolution
|
||||
|
||||
The `workflowBatchUuid` required in the URL is NOT returned by the create endpoint. To obtain it:
|
||||
|
||||
1. Search via `POST /client/{clientId}/workflowBatch/search` with `{ field: 'name', operator: 'EXACT', value: '<workflow_name>' }`
|
||||
2. Use `projection: 'internal'` to get full batch objects
|
||||
3. The UUID is in the `uuid` field of the returned batch object
|
||||
4. Cache the UUID locally after first resolution (stored in `ivanti_fp_submissions.ivanti_workflow_batch_uuid`)
|
||||
|
||||
#### Implementation in dashboard
|
||||
|
||||
The `resolveWorkflowBatchUuid()` helper in `backend/routes/ivantiFpWorkflow.js` handles UUID resolution:
|
||||
- Returns cached UUID if available in the local submission record
|
||||
- Otherwise searches Ivanti by workflow name, extracts `batch.uuid`, and caches it for future use
|
||||
|
||||
The findings map loop in the `POST /submissions/:id/findings` endpoint:
|
||||
- Iterates through each finding ID individually
|
||||
- Makes one JSON POST per finding with `EXACT` operator
|
||||
- Tracks which findings succeeded vs failed
|
||||
- Only marks queue items as complete for successfully mapped findings
|
||||
- Returns both `addedFindings` and `failedFindings` arrays in the response
|
||||
|
||||
### Other Workflow Endpoints (from Swagger)
|
||||
|
||||
These are available but not all are currently used by the dashboard:
|
||||
|
||||
| Endpoint | Purpose | Status |
|
||||
|----------|---------|--------|
|
||||
| `/workflowBatch/acceptance/request` | Risk acceptance workflow | Not used |
|
||||
| `/workflowBatch/remediation/request` | Remediation workflow | Not used |
|
||||
| `/workflowBatch/severityChange/request` | Severity change workflow | Not used |
|
||||
| `/workflowBatch/{workflowType}/approve` | Approve a workflow (needs `workflowBatchUuid`) | Not used |
|
||||
| `/workflowBatch/{workflowType}/reject` | Reject a workflow | Not used |
|
||||
| `/workflowBatch/{workflowType}/rework` | Send back for rework | Not used |
|
||||
| `/workflowBatch/{workflowType}/update` | Update a workflow | Not used |
|
||||
| `/workflowBatch/{workflowType}/{workflowBatchUuid}/map` | Map findings to workflow | Used (FP editing) |
|
||||
| `/workflowBatch/{workflowType}/{workflowBatchUuid}/unmap` | Unmap findings | Not used |
|
||||
| `/workflowBatch/{workflowType}/{workflowBatchUuid}/attach` | Attach file to existing workflow | **Broken — see note** |
|
||||
| `/workflowBatch/{workflowType}/{workflowBatchUuid}/detach` | Detach file | Not used |
|
||||
| `/workflowBatch/model` | Get model/schema | Not used |
|
||||
| `/workflowBatch/filter` | Get available filter fields | Not used |
|
||||
| `/workflowBatch/suggest` | Get suggested values for a filter field | Not used |
|
||||
|
||||
### Known Limitations
|
||||
|
||||
#### Attach endpoint does not work (tested 2026-04-13)
|
||||
|
||||
The `/workflowBatch/{workflowType}/{workflowBatchUuid}/attach` endpoint is listed in the Swagger spec but returns HTTP 400 (Bad Request) for all tested request formats:
|
||||
|
||||
- `multipart/form-data` with field name `file` (singular) — 400
|
||||
- `multipart/form-data` with field name `files` (plural) — 400
|
||||
- Tested with `Content-Type: application/octet-stream` and `image/png` — both 400
|
||||
- Tested with both `ivantiMultipartPost` and `ivantiFormPost` helpers — both 400
|
||||
|
||||
The Ivanti response is a generic Spring Boot error with no detail message:
|
||||
```json
|
||||
{"timestamp":"...","status":400,"error":"Bad Request","path":"/api/v1/client/1550/workflowBatch/falsePositive/{uuid}/attach"}
|
||||
```
|
||||
|
||||
**Workaround:** File attachments can only be uploaded during the initial workflow creation (sent inline with the `/workflowBatch/falsePositive/request` endpoint). To add attachments to an existing workflow, users must upload them directly in the Ivanti platform UI.
|
||||
|
||||
#### Search by numeric batch ID does not work
|
||||
|
||||
The `/workflowBatch/search` endpoint does not support filtering by the numeric `id` returned from the create endpoint. Searching with `{ field: 'id', operator: 'EXACT', value: '33432541' }` returns 0 results. Searching by `name` field works and returns the workflow batch object including the `uuid` field needed for map/attach operations.
|
||||
|
||||
#### UUID not returned by create endpoint
|
||||
|
||||
The `/workflowBatch/falsePositive/request` create endpoint returns only `{ id: <number>, created: <timestamp> }`. The `uuid` needed for map/attach/approve/reject operations must be obtained separately via the search endpoint.
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `IVANTI_API_KEY` | — | Required. API key for authentication |
|
||||
| `IVANTI_CLIENT_ID` | `1550` | Client ID in the Ivanti platform |
|
||||
| `IVANTI_SKIP_TLS` | `false` | Set `true` to skip TLS verification |
|
||||
| `IVANTI_FIRST_NAME` | — | Used for workflow search filter (sync) |
|
||||
| `IVANTI_LAST_NAME` | — | Used for workflow search filter (sync) |
|
||||
53228
docs/api/ivanti-neurons-swagger.json
Normal file
53228
docs/api/ivanti-neurons-swagger.json
Normal file
File diff suppressed because one or more lines are too long
170
docs/api/jira-api-use-cases.md
Normal file
170
docs/api/jira-api-use-cases.md
Normal file
@@ -0,0 +1,170 @@
|
||||
# Jira REST API Use Cases — STEAM Security Dashboard
|
||||
|
||||
## Overview
|
||||
|
||||
The STEAM Security Dashboard is a self-hosted vulnerability management tool used by the NTS-AEO-STEAM and NTS-AEO-ACCESS-ENG teams. It integrates with Jira Data Center to create, track, and sync vulnerability remediation tickets linked to CVE records.
|
||||
|
||||
All API calls are made from a single Node.js backend process. The integration uses Basic Auth with a service account and enforces Charter's posted rate limits client-side.
|
||||
|
||||
---
|
||||
|
||||
## Charter Compliance Summary
|
||||
|
||||
| Requirement | Implementation |
|
||||
|---|---|
|
||||
| Authentication | Basic Auth with service account (`JIRA_API_USER` + `JIRA_API_TOKEN`) |
|
||||
| Rate limit — daily | Client-side enforced: 1 440 requests/day max |
|
||||
| Rate limit — burst | Client-side enforced: 60 requests/minute max |
|
||||
| Inter-request delay — GETs | 1 second minimum between GET requests |
|
||||
| Inter-request delay — writes | 2 seconds minimum between PUT/POST/DELETE requests |
|
||||
| Explicit field lists | Every GET includes `?fields=` parameter; `/rest/api/2/field` is blocked |
|
||||
| No bulk updates | Issues are updated one at a time; `/rest/api/2/issue/bulk` is blocked |
|
||||
| Bulk reads via JQL | Multi-ticket sync uses a single `GET /rest/api/2/search` with JQL query parameters, not per-issue GETs |
|
||||
| Single-issue fetch via JQL | `GET /rest/api/2/search?jql=key="KEY" AND project=<KEY>&fields=...&maxResults=1` |
|
||||
| JQL scoping | All recurring JQL queries include `updated >= -Xh` clause and `project = <KEY>` scoping |
|
||||
| `maxResults` cap | Search queries capped at 1 000 results per page |
|
||||
|
||||
---
|
||||
|
||||
## Use Cases
|
||||
|
||||
### 1. Connection Test
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Endpoint** | `GET /rest/api/2/myself` |
|
||||
| **Trigger** | Admin clicks "Test Connection" on the Jira settings panel |
|
||||
| **Frequency** | Manual, infrequent (a few times per day at most) |
|
||||
| **Purpose** | Verify service account credentials and connectivity |
|
||||
| **Fields requested** | Default (myself endpoint returns user profile) |
|
||||
|
||||
### 2. Create Issue
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Endpoint** | `POST /rest/api/2/issue` |
|
||||
| **Trigger** | User clicks "Create in Jira" from a CVE detail panel |
|
||||
| **Frequency** | Manual, estimated 5–20 per day |
|
||||
| **Purpose** | Create a vulnerability remediation ticket linked to a CVE/vendor pair |
|
||||
| **Fields sent** | `project.key`, `summary`, `issuetype.name`, `description` |
|
||||
| **Notes** | A local record is also created in the dashboard database linking the Jira key to the CVE |
|
||||
|
||||
### 3. Get Single Issue
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Endpoint** | `GET /rest/api/2/search?jql=key="ISSUE-KEY" AND project=<KEY>&fields=summary,status,assignee,created,updated,priority,issuetype,project,resolution&maxResults=1` |
|
||||
| **Trigger** | User clicks "Sync" on a single Jira ticket row |
|
||||
| **Frequency** | Manual, estimated 10–30 per day |
|
||||
| **Purpose** | Refresh a single ticket's status and summary from Jira via JQL search |
|
||||
| **Notes** | Uses JQL-based lookup instead of single-issue GET per Charter compliance. Fields are always specified explicitly. |
|
||||
|
||||
### 4. Update Issue
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Endpoint** | `PUT /rest/api/2/issue/{issueKey}` |
|
||||
| **Trigger** | Future feature — local edits synced back to Jira |
|
||||
| **Frequency** | Manual, estimated 5–10 per day when enabled |
|
||||
| **Purpose** | Update issue summary or other fields from the dashboard |
|
||||
| **Notes** | Issues are updated one at a time; bulk PUT is not used |
|
||||
|
||||
### 5. Add Comment
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Endpoint** | `POST /rest/api/2/issue/{issueKey}/comment` |
|
||||
| **Trigger** | Dashboard adds audit trail comments to linked tickets |
|
||||
| **Frequency** | Automated on certain actions, estimated 5–15 per day |
|
||||
| **Purpose** | Maintain an audit trail on the Jira ticket for compliance visibility |
|
||||
|
||||
### 6. Get Transitions
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Endpoint** | `GET /rest/api/2/issue/{issueKey}/transitions` |
|
||||
| **Trigger** | Dashboard checks available workflow transitions before moving a ticket |
|
||||
| **Frequency** | Manual, paired with transition calls, estimated 5–10 per day |
|
||||
| **Purpose** | Discover valid status transitions for the issue's current workflow state |
|
||||
|
||||
### 7. Transition Issue
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Endpoint** | `POST /rest/api/2/issue/{issueKey}/transitions` |
|
||||
| **Trigger** | User moves a ticket to a new status from the dashboard |
|
||||
| **Frequency** | Manual, estimated 5–10 per day |
|
||||
| **Purpose** | Move ticket through workflow states (e.g., Open to In Progress to Closed) |
|
||||
|
||||
### 8. JQL Search (Bulk Sync)
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Endpoint** | `GET /rest/api/2/search?jql=...&fields=...&maxResults=...&startAt=...` |
|
||||
| **Trigger** | Admin clicks "Sync All" on the Jira tickets panel |
|
||||
| **Frequency** | Manual, estimated 1–3 times per day |
|
||||
| **Purpose** | Bulk-refresh all tracked tickets in a single request instead of per-issue GETs |
|
||||
| **JQL pattern** | `key in ("KEY-1", "KEY-2", ...) AND updated >= -24h AND project = <KEY>` |
|
||||
| **Fields requested** | `summary, status, assignee, created, updated, priority, issuetype, project, resolution` |
|
||||
| **Batch size** | 100 keys per JQL query; multiple batches if needed |
|
||||
| **Notes** | Uses GET with URL-encoded query parameters per Charter compliance. Stops early if rate limit budget is running low (burst remaining <= 5 or daily remaining <= 10) |
|
||||
|
||||
### 9. Issue Lookup
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Endpoint** | `GET /rest/api/2/search?jql=key="ISSUE-KEY" AND project=<KEY>&fields=...&maxResults=1` |
|
||||
| **Trigger** | User looks up a Jira issue by key from the dashboard search |
|
||||
| **Frequency** | Manual, estimated 5–15 per day |
|
||||
| **Purpose** | Quick lookup of any Jira issue to view its current state via JQL search |
|
||||
|
||||
---
|
||||
|
||||
## Estimated Daily API Usage
|
||||
|
||||
| Operation | Estimated calls/day | Method | Delay enforced |
|
||||
|---|---|---|---|
|
||||
| Connection test | 2–5 | GET | 1s |
|
||||
| Create issue | 5–20 | POST | 2s |
|
||||
| Get single issue | 10–30 | GET | 1s |
|
||||
| Update issue | 5–10 | PUT | 2s |
|
||||
| Add comment | 5–15 | POST | 2s |
|
||||
| Get transitions | 5–10 | GET | 1s |
|
||||
| Transition issue | 5–10 | POST | 2s |
|
||||
| JQL search (sync) | 1–5 | GET | 1s |
|
||||
| Issue lookup | 5–15 | GET | 1s |
|
||||
| **Total estimated** | **43–120** | | |
|
||||
|
||||
Well within the 1 440/day limit. Burst usage stays under 60/minute due to enforced inter-request delays.
|
||||
|
||||
---
|
||||
|
||||
## Blocked Endpoints
|
||||
|
||||
The integration explicitly blocks these endpoints to comply with Charter policy:
|
||||
|
||||
- `/rest/api/2/field` — field metadata is never queried; fields are specified in code
|
||||
- `/rest/api/2/issue/bulk` — bulk updates are not used; issues are updated individually
|
||||
|
||||
---
|
||||
|
||||
## Error Handling
|
||||
|
||||
- **429 responses**: Surfaced to the user as "Rate limit exceeded. Try again later." No automatic retry.
|
||||
- **5xx responses**: Surfaced as "Jira API error" with the response body for debugging.
|
||||
- **Network failures**: Caught and surfaced with the error message.
|
||||
- **Timeout**: 15 second timeout per request; surfaced as a timeout error.
|
||||
|
||||
---
|
||||
|
||||
## UAT Test Evidence
|
||||
|
||||
The UAT test script (`backend/scripts/jira-uat-test.js`) exercises all use cases listed above and produces a log file at `backend/scripts/jira-uat-test.log`. This log can be attached to or referenced in the ATLSUP approval ticket.
|
||||
|
||||
To run:
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
node scripts/jira-uat-test.js
|
||||
```
|
||||
|
||||
1080
docs/guides/full-reference-manual.md
Normal file
1080
docs/guides/full-reference-manual.md
Normal file
File diff suppressed because it is too large
Load Diff
94
docs/guides/kb-compliance-guide.md
Normal file
94
docs/guides/kb-compliance-guide.md
Normal file
@@ -0,0 +1,94 @@
|
||||
# AEO Compliance Tracking Guide
|
||||
|
||||
## Overview
|
||||
|
||||
The Compliance page tracks AEO security posture metrics for the STEAM and ACCESS-ENG teams. It processes weekly xlsx compliance reports, shows per-metric health cards, and tracks non-compliant devices down to the individual hostname level.
|
||||
|
||||
## Teams Tracked
|
||||
|
||||
Only two teams are monitored:
|
||||
- **STEAM** (NTS-AEO-STEAM)
|
||||
- **ACCESS-ENG** (NTS-AEO-ACCESS-ENG)
|
||||
|
||||
## Uploading a Compliance Report
|
||||
|
||||
### Prerequisites
|
||||
- You must have editor or admin access
|
||||
- The report must be an `.xlsx` file (the standard NTS_AEO compliance export)
|
||||
|
||||
### Upload Process
|
||||
|
||||
1. Navigate to the **Compliance** page
|
||||
2. Click the **Upload Report** button
|
||||
3. Drag and drop the xlsx file or click to browse
|
||||
4. The system parses the spreadsheet using a Python backend script and shows a **preview**:
|
||||
- **New items**: Devices/metrics appearing for the first time
|
||||
- **Recurring items**: Devices/metrics that were already non-compliant
|
||||
- **Resolved items**: Previously non-compliant items no longer in the report
|
||||
5. Review the diff summary
|
||||
6. Click **Commit** to save the data
|
||||
|
||||
The upload is a two-step process (preview then commit) so you can verify the data before it's written to the database.
|
||||
|
||||
## Health Cards
|
||||
|
||||
After uploading, the page displays metric health cards for each team. Each card shows:
|
||||
|
||||
- **Metric ID** — the compliance metric identifier
|
||||
- **Category** — the metric category (Vulnerability Management, Access & MFA, Logging & Monitoring, etc.)
|
||||
- **Compliance %** — current compliance percentage
|
||||
- **Target** — the required target percentage
|
||||
- **Status** — color-coded:
|
||||
- Green: Meets/Exceeds Target
|
||||
- Amber: Within 15% of Target
|
||||
- Red: Below 15% of Target
|
||||
|
||||
Click a health card to filter the device list to that specific metric.
|
||||
|
||||
## Metric Categories
|
||||
|
||||
| Category | Color |
|
||||
|----------|-------|
|
||||
| Vulnerability Management | Red |
|
||||
| Access & MFA | Amber |
|
||||
| Logging & Monitoring | Purple |
|
||||
| End-of-Life OS | Orange |
|
||||
| Decommissioned Assets | Slate |
|
||||
| Asset Data Quality | Slate |
|
||||
| Application Security | Blue |
|
||||
| Disaster Recovery | Teal |
|
||||
| Endpoint Protection | Orange |
|
||||
|
||||
## Device-Level Tracking
|
||||
|
||||
Below the health cards, the device list shows non-compliant devices grouped by hostname. Each device entry shows:
|
||||
|
||||
- Hostname and IP address
|
||||
- Device type and team assignment
|
||||
- Failing metrics with first-seen and last-seen dates
|
||||
- Seen count (how many consecutive reports the device has been non-compliant)
|
||||
|
||||
### Device Detail Panel
|
||||
|
||||
Click a device to open the detail panel showing:
|
||||
- All metrics the device is failing
|
||||
- Upload history (when the device first appeared, when it was last seen)
|
||||
- Per-metric notes with timestamps
|
||||
|
||||
### Adding Notes
|
||||
|
||||
You can add notes to one or more metrics on a device at once:
|
||||
1. Open the device detail panel
|
||||
2. Select the metrics the note applies to using the chip selector — click individual metric chips to toggle them, or use **Select All** / **Deselect All** for bulk selection
|
||||
3. Type your note and click send
|
||||
4. Notes are timestamped and attributed to the logged-in user
|
||||
|
||||
When a note is submitted for multiple metrics, it appears as a single grouped entry in the notes history with all associated metric chips displayed together. Notes are useful for tracking remediation progress, vendor ticket numbers, or explaining why a device is non-compliant.
|
||||
|
||||
## Data Flow
|
||||
|
||||
1. Weekly xlsx report is uploaded through the dashboard
|
||||
2. Python parser extracts team metrics and non-compliant devices
|
||||
3. Diff is computed against existing data (new/recurring/resolved)
|
||||
4. On commit: new items are inserted, recurring items have their seen_count incremented, resolved items are marked with resolved_on date
|
||||
5. Health cards and device lists update automatically
|
||||
104
docs/guides/kb-cve-tracking-guide.md
Normal file
104
docs/guides/kb-cve-tracking-guide.md
Normal file
@@ -0,0 +1,104 @@
|
||||
# CVE Tracking & NVD Sync Spec
|
||||
|
||||
## Overview
|
||||
|
||||
The Home page (CVE Management) is where you track individual CVEs across vendors, store supporting documentation, and link Archer risk acceptance tickets. It serves as the reference library for all vulnerability research and evidence.
|
||||
|
||||
## Adding a CVE
|
||||
|
||||
1. Click "Add CVE" on the Home page
|
||||
2. Enter the **CVE ID** (format: CVE-YYYY-NNNNN, e.g., CVE-2024-6387)
|
||||
3. Click the NVD lookup button to auto-populate fields from the National Vulnerability Database:
|
||||
- Description
|
||||
- Severity (Critical, High, Medium, Low)
|
||||
- Published date
|
||||
4. Select or type the **Vendor/Platform** (e.g., Cisco, Juniper, ADTRAN)
|
||||
5. Review and adjust any fields as needed
|
||||
6. Click Save
|
||||
|
||||
### NVD Auto-Population
|
||||
|
||||
The NVD lookup queries the NIST NVD 2.0 API and extracts:
|
||||
- English description
|
||||
- CVSS severity using a cascade: v3.1 → v3.0 → v2.0
|
||||
- Published date
|
||||
|
||||
If the NVD API is rate-limited (429 response), wait a few seconds and try again. Having an NVD API key configured in the backend `.env` file increases the rate limit.
|
||||
|
||||
## CVE Details
|
||||
|
||||
Each CVE entry tracks:
|
||||
|
||||
| Field | Description |
|
||||
|-------|-------------|
|
||||
| CVE ID | The CVE identifier (e.g., CVE-2024-6387) |
|
||||
| Vendor | The affected vendor/platform |
|
||||
| Severity | Critical, High, Medium, or Low |
|
||||
| Description | Vulnerability description (from NVD or manual entry) |
|
||||
| Published Date | When the CVE was published |
|
||||
| Status | Open, In Progress, Addressed, or Resolved |
|
||||
|
||||
## Document Storage
|
||||
|
||||
Each CVE/vendor pair can have supporting documents attached. These serve as evidence for FP workflows, Archer tickets, and audit purposes.
|
||||
|
||||
### Uploading Documents
|
||||
1. Open a CVE entry
|
||||
2. Click "Upload Document"
|
||||
3. Select the file (max 10 MB)
|
||||
4. Documents are stored in `uploads/cves/{cveId}/{vendor}/` on the server
|
||||
|
||||
### Document Types
|
||||
- **Advisory** — vendor security advisories
|
||||
- **Email** — vendor communications or support ticket responses
|
||||
- **Screenshot** — device screenshots showing version info
|
||||
- **Patch** — patch notes or release documentation
|
||||
- **Other** — any other supporting evidence
|
||||
|
||||
### Why Store Documents Here?
|
||||
Documents uploaded to CVE entries can be reused across multiple FP workflows. When an FP expires and needs renewal, the evidence is already in the dashboard rather than having to track it down again.
|
||||
|
||||
## Archer Ticket Tracking
|
||||
|
||||
Archer risk acceptance tickets (EXC-XXXXX) are linked to CVE/vendor pairs.
|
||||
|
||||
### Adding an Archer Ticket
|
||||
1. Open a CVE entry
|
||||
2. Click "Add Archer Ticket"
|
||||
3. Enter the EXC number (e.g., EXC-12345)
|
||||
4. Optionally add the Archer URL and status
|
||||
|
||||
### EXC Badge Integration
|
||||
Once an EXC number is entered:
|
||||
- An EXC badge appears on the CVE card on the Home page
|
||||
- Clicking the badge navigates to the Reporting page pre-filtered to findings with that EXC number in their notes
|
||||
- The Action Coverage chart on the Reporting page classifies findings with EXC numbers as "Archer Exception"
|
||||
|
||||
## Vendor Tracking
|
||||
|
||||
CVEs can be tracked across multiple vendors. Each CVE/vendor combination is a separate entry, allowing you to:
|
||||
- Track different remediation statuses per vendor
|
||||
- Store vendor-specific documentation
|
||||
- Link different Archer tickets per vendor
|
||||
|
||||
## Editing CVEs
|
||||
|
||||
1. Click the edit icon on a CVE card
|
||||
2. Modify any fields
|
||||
3. Use the NVD lookup button to refresh data from NVD if needed
|
||||
4. Click Save
|
||||
|
||||
## Quick Check
|
||||
|
||||
The Quick Check feature on the Home page lets you look up a CVE ID without adding it to the database:
|
||||
1. Type a CVE ID in the Quick Check field
|
||||
2. Press Enter — the NVD data is fetched and displayed
|
||||
3. If you want to track it, click "Add CVE" to create an entry
|
||||
|
||||
## Tips
|
||||
|
||||
- Always upload screenshots and vendor advisories to the CVE entry before submitting an FP workflow — reviewers may ask for this evidence
|
||||
- Use the status field to track progress: Open → In Progress → Addressed → Resolved
|
||||
- Link Archer EXC numbers as soon as the ticket is created — this updates the Action Coverage chart immediately
|
||||
- The search bar on the Home page searches across CVE ID, vendor, and description
|
||||
- Filter by vendor or severity using the dropdowns to focus on specific areas
|
||||
110
docs/guides/kb-fp-submission-editing-guide.md
Normal file
110
docs/guides/kb-fp-submission-editing-guide.md
Normal file
@@ -0,0 +1,110 @@
|
||||
# FP Workflow Queue & Submission Editing Guide
|
||||
|
||||
## Overview
|
||||
|
||||
The STEAM Security Dashboard allows you to create, track, and edit False Positive (FP) workflow submissions directly from the Reporting Page. This guide covers the full workflow from adding findings to the queue through editing and resubmitting FP workflows.
|
||||
|
||||
## Adding Findings to the Queue
|
||||
|
||||
1. On the Reporting Page, select findings by clicking the checkboxes in the findings table
|
||||
2. Use Shift+Click to select a range of findings
|
||||
3. In the selection toolbar that appears, choose the workflow type (FP, Archer, or CARD)
|
||||
4. Enter the vendor name (not required for CARD)
|
||||
5. Click "Add to Queue"
|
||||
|
||||
The findings will appear in the Ivanti Queue panel (click the "Queue" button in the top-right).
|
||||
|
||||
## Creating an FP Workflow
|
||||
|
||||
1. Open the Queue panel
|
||||
2. Select the pending FP items you want to submit using the checkboxes
|
||||
3. Click "Create FP Workflow" at the bottom of the panel
|
||||
4. Fill in the required fields:
|
||||
- **Workflow Name**: Use the format `FP — CVE-XXXX-XXXX — Vendor` (e.g., `FP — CVE-2024-6387 — Cisco_STEAM`)
|
||||
- **Reason / Justification**: Explain why these findings are false positives
|
||||
- **Description** (optional): Additional context
|
||||
- **Expiration Date**: Must be a future date
|
||||
- **Scope Override**: Leave as "Authorized" for standard FP workflows
|
||||
5. Attach supporting files (screenshots, evidence) — up to 10 files, 10 MB each
|
||||
6. Click Submit
|
||||
|
||||
The workflow is created in the Ivanti platform and the queue items are marked as complete.
|
||||
|
||||
## Viewing Submissions
|
||||
|
||||
Your FP submissions appear in the "Submissions" section at the bottom of the Queue panel. Each submission shows:
|
||||
- Workflow name
|
||||
- Ivanti batch ID
|
||||
- Lifecycle status badge (color-coded)
|
||||
- Finding count
|
||||
- Submission date
|
||||
|
||||
Click any submission to open the Edit Modal.
|
||||
|
||||
## Lifecycle Status
|
||||
|
||||
Submissions go through these states:
|
||||
|
||||
| Status | Color | Meaning |
|
||||
|--------|-------|---------|
|
||||
| Submitted | Sky Blue | Awaiting review |
|
||||
| Rework | Amber | Reviewer sent it back — action needed |
|
||||
| Rejected | Red | Reviewer denied the FP request |
|
||||
| Resubmitted | Sky Blue | Edited and sent back for review |
|
||||
| Approved | Green | FP accepted — no further action |
|
||||
|
||||
The status badge automatically syncs with the Ivanti platform state when findings data is refreshed.
|
||||
|
||||
## Editing an Existing Submission
|
||||
|
||||
Open a submission from the Queue panel to access the Edit Modal with four tabs:
|
||||
|
||||
### Details Tab
|
||||
- Edit the workflow name, reason, description, expiration date, and scope override
|
||||
- Click "Save Details" to push changes to the Ivanti platform
|
||||
- If the submission was in Rework or Rejected status, saving automatically changes it to Resubmitted
|
||||
|
||||
### Findings Tab
|
||||
- View the current list of finding IDs mapped to this workflow
|
||||
- Add more findings from your pending FP queue items
|
||||
- Select the items to add and click "Add Findings"
|
||||
- Each finding is mapped individually to the Ivanti workflow
|
||||
|
||||
### Attachments Tab
|
||||
- View files that were uploaded with the original submission
|
||||
- **Note**: Adding attachments to an existing workflow is not supported via the Ivanti API. To add more files, upload them directly in the Ivanti platform.
|
||||
|
||||
### History Tab
|
||||
- View a chronological log of all changes made to the submission
|
||||
- Shows finding additions with the actual finding IDs
|
||||
- Displays Ivanti reviewer notes (rework feedback, approval notes) pulled directly from the Ivanti platform
|
||||
|
||||
## Handling Rework Requests
|
||||
|
||||
When a submission comes back for rework:
|
||||
|
||||
1. Open the submission from the Queue panel — the status badge will show "Rework" (amber)
|
||||
2. Go to the **History** tab to read the reviewer's notes explaining what needs to change
|
||||
3. Common rework reasons:
|
||||
- Need more screenshots showing remediation
|
||||
- Need to verify specific software versions
|
||||
- Missing evidence for some findings
|
||||
4. Go to the **Findings** tab to add any additional findings if needed
|
||||
5. Upload additional screenshots directly in the Ivanti platform (Attachments tab has a link)
|
||||
6. Go to the **Details** tab to update the reason/description if needed
|
||||
7. Click "Save Details" — the status automatically changes to Resubmitted
|
||||
|
||||
## Changing Status Manually
|
||||
|
||||
Use the status dropdown in the Edit Modal to manually change the lifecycle status. This is useful when:
|
||||
- You receive notification outside the dashboard that a submission was rejected
|
||||
- You want to mark a submission as approved after confirming in Ivanti
|
||||
|
||||
**Note**: Approved submissions are locked and cannot be edited.
|
||||
|
||||
## Tips
|
||||
|
||||
- Always include enough screenshots per audit guidance (e.g., 10 screenshots for 20-50 findings)
|
||||
- Use the naming convention `FP — CVE-XXXX-XXXX — Vendor_Team` for easy identification
|
||||
- Check the FP Workflow Status donut chart on the Reporting Page for an overview of all your FP ticket states
|
||||
- The workflow column in the findings table shows the current Ivanti state for each finding
|
||||
89
docs/guides/kb-ivanti-queue-guide.md
Normal file
89
docs/guides/kb-ivanti-queue-guide.md
Normal file
@@ -0,0 +1,89 @@
|
||||
# Ivanti Queue & Batch Operations Guide
|
||||
|
||||
## Overview
|
||||
|
||||
The Ivanti Queue is a personal staging area for batch-processing vulnerability findings. You select findings from the Reporting Page table, assign them a workflow type and vendor, and stage them in the queue. From there you can create FP workflows, track Archer exceptions, or manage CARD dispositions.
|
||||
|
||||
## Workflow Types
|
||||
|
||||
| Type | Color | Purpose | Vendor Required? |
|
||||
|------|-------|---------|-----------------|
|
||||
| FP | Amber | False Positive — finding is not actually a vulnerability | Yes |
|
||||
| Archer | Blue | Risk Acceptance — vulnerability exists but can't be patched | Yes |
|
||||
| CARD | Green | Asset disposition — device not owned by your BU | No |
|
||||
|
||||
## Adding Findings to the Queue
|
||||
|
||||
### Single Finding
|
||||
1. In the findings table, click the checkbox area on a row (not the checkbox itself — click the cell)
|
||||
2. A popover appears with:
|
||||
- The finding ID
|
||||
- Vendor/Platform input field (required for FP and Archer)
|
||||
- Workflow type toggle (FP / Archer / CARD)
|
||||
3. Enter the vendor name and select the workflow type
|
||||
4. Click "Add to Queue"
|
||||
|
||||
### Batch Add (Multiple Findings)
|
||||
1. Select multiple findings using checkboxes (Shift+Click for range selection)
|
||||
2. The selection toolbar appears at the top of the table
|
||||
3. Choose the workflow type (FP / Archer / CARD)
|
||||
4. Enter the vendor name (not needed for CARD)
|
||||
5. Click "Add to Queue" — all selected findings are added at once (up to 200 per batch)
|
||||
|
||||
## The Queue Panel
|
||||
|
||||
Click the **Queue** button (top right of the Reporting Page) to open the slide-out panel. The badge shows the count of pending items.
|
||||
|
||||
### Layout
|
||||
- Items are grouped by vendor (alphabetically)
|
||||
- CARD items appear in their own green section at the top
|
||||
- Each item shows: finding ID, CVEs, hostname, IP address, and workflow type badge
|
||||
|
||||
### Item Actions
|
||||
|
||||
| Action | How |
|
||||
|--------|-----|
|
||||
| Mark complete | Click the green checkbox |
|
||||
| Mark pending | Uncheck the green checkbox |
|
||||
| Select for deletion | Click the red checkbox (left side) |
|
||||
| Delete selected | Click "Delete (N)" button in footer |
|
||||
| Clear all completed | Click "Clear Completed" button in footer |
|
||||
| Redirect workflow | Click the redirect arrow (↗) on completed items |
|
||||
|
||||
### Redirect Feature
|
||||
|
||||
When a finding is completed under one workflow type but needs to be processed under another:
|
||||
1. Complete the item first
|
||||
2. Click the redirect arrow (↗) icon
|
||||
3. Choose the new workflow type
|
||||
4. A new pending item is created with the same finding data but the new workflow type
|
||||
|
||||
Example: You submitted an FP but it was rejected. You now need to open an Archer ticket instead. Complete the FP item, then redirect it to Archer.
|
||||
|
||||
## Creating FP Workflows from the Queue
|
||||
|
||||
1. Open the Queue panel
|
||||
2. Select pending FP items using the checkboxes
|
||||
3. Click "Create FP Workflow" in the footer (only enabled when FP items are selected)
|
||||
4. Fill in the workflow details (name, reason, description, expiration date)
|
||||
5. Attach supporting files (screenshots, evidence)
|
||||
6. Submit — the workflow is created in Ivanti and queue items are marked complete
|
||||
|
||||
See the [FP Submission Editing Guide](kb-fp-submission-editing-guide.md) for details on editing submitted workflows.
|
||||
|
||||
## FP Submissions Section
|
||||
|
||||
Below the queue items, a "Submissions" section shows your previously submitted FP workflows with:
|
||||
- Workflow name and Ivanti batch ID
|
||||
- Lifecycle status badge (Submitted, Rework, Rejected, Resubmitted, Approved)
|
||||
- Finding count and submission date
|
||||
|
||||
Click any submission to open the Edit Modal for viewing details, adding findings, or reading reviewer notes.
|
||||
|
||||
## Tips
|
||||
|
||||
- Group related findings by vendor before adding to the queue — this makes it easier to create batch FP workflows
|
||||
- Use CARD for findings on devices that belong to another team — no vendor entry needed
|
||||
- The queue is per-user — other team members can't see or modify your queue items
|
||||
- Completed items stay in the queue until you clear them, so you have a record of what was processed
|
||||
- Use the redirect feature when a workflow type needs to change after initial processing
|
||||
92
docs/guides/kb-reporting-page-guide.md
Normal file
92
docs/guides/kb-reporting-page-guide.md
Normal file
@@ -0,0 +1,92 @@
|
||||
# Reporting Page Guide
|
||||
|
||||
## Overview
|
||||
|
||||
The Reporting Page is the primary operational page in the STEAM Security Dashboard. It provides a live view of all open Ivanti host findings with filtering, sorting, inline editing, metric charts, and export capabilities.
|
||||
|
||||
## Getting Started
|
||||
|
||||
1. Navigate to the Reporting page from the sidebar
|
||||
2. Click **Sync** (top right) to pull the latest findings from Ivanti
|
||||
3. The sync timestamp updates when complete — findings, charts, and counts all refresh together
|
||||
|
||||
## Metric Charts
|
||||
|
||||
Four donut charts appear at the top of the page:
|
||||
|
||||
### Open vs Closed
|
||||
Shows the total count of open and closed findings across all synced data.
|
||||
|
||||
### Action Coverage
|
||||
Breaks down open findings into three categories:
|
||||
- **FP Request** (blue) — findings with an FP workflow ticket in Ivanti
|
||||
- **Archer Exception** (amber) — findings with an EXC-XXXXX number in their notes
|
||||
- **Pending** (red) — findings with no action taken yet
|
||||
|
||||
Click a chart segment to filter the table to that category. Click again or use "clear filter" to remove.
|
||||
|
||||
### FP Finding Status
|
||||
Shows the distribution of findings across FP workflow states (Requested, Reworked, Actionable, Approved, Rejected, Expired).
|
||||
|
||||
### FP Workflow Status
|
||||
Shows the count of unique FP ticket IDs per state — one FP ticket can cover many findings.
|
||||
|
||||
## Findings Table
|
||||
|
||||
### Columns
|
||||
The table has 13 columns. All are visible by default:
|
||||
|
||||
| Column | Description |
|
||||
|--------|-------------|
|
||||
| Finding ID | Ivanti host finding identifier |
|
||||
| Severity | VRR score with severity group (Critical, High, Medium) |
|
||||
| Title | Vulnerability title |
|
||||
| CVEs | Associated CVE identifiers (hover for tooltip details) |
|
||||
| Host | Hostname (inline editable) |
|
||||
| IP Address | Device IP |
|
||||
| DNS | DNS name (inline editable) |
|
||||
| Due Date | SLA deadline — red if overdue, amber if within 30 days |
|
||||
| SLA | SLA status (Overdue, At Risk, Within SLA) |
|
||||
| BU | Business unit ownership (STEAM or ACCESS-ENG) |
|
||||
| Workflow | FP workflow badge showing ticket ID and state |
|
||||
| Last Found | Date the finding was last detected by scanner |
|
||||
| Notes | Free-text notes field (inline editable) |
|
||||
|
||||
### Column Management
|
||||
Click the **Columns** button (gear icon) to:
|
||||
- Show/hide columns by clicking the eye icon
|
||||
- Drag columns to reorder them
|
||||
- Your column configuration is saved in your browser
|
||||
|
||||
### Sorting
|
||||
Click any sortable column header to sort. Click again to reverse direction. The active sort column is highlighted in blue.
|
||||
|
||||
### Filtering
|
||||
Click the filter icon on any filterable column header to open a dropdown with all unique values. Check/uncheck values to filter. Use "Select All" or "Clear" for bulk operations. A search box lets you find specific values quickly.
|
||||
|
||||
Active filters show as amber badges above the table. Click "Clear Filters" to remove all column filters at once.
|
||||
|
||||
### Inline Editing
|
||||
|
||||
Three columns support inline editing:
|
||||
|
||||
- **Host**: Click the hostname to edit. An amber dot appears when an override is active. Click the revert button (↻) to restore the original Ivanti value. Overrides survive re-syncs.
|
||||
- **DNS**: Same behavior as Host.
|
||||
- **Notes**: Click to type. Saves automatically on blur. Use notes to record EXC numbers (e.g., `EXC-12345`) — the Action Coverage chart will classify these as "Archer Exception".
|
||||
|
||||
## Selecting Findings
|
||||
|
||||
Check the checkbox on any row to select it. Use Shift+Click for range selection. The "select all" checkbox in the header selects all visible (non-queued) findings.
|
||||
|
||||
When findings are selected, a toolbar appears with:
|
||||
- Workflow type toggle (FP / Archer / CARD)
|
||||
- Vendor input field (not needed for CARD)
|
||||
- "Add to Queue" button to stage findings for batch processing
|
||||
|
||||
## Export
|
||||
|
||||
Click the **Export** dropdown to download the current filtered/sorted view as:
|
||||
- **CSV** — comma-separated values with UTF-8 BOM
|
||||
- **Excel (.xlsx)** — formatted spreadsheet with auto-fit column widths
|
||||
|
||||
Only visible columns are included in the export.
|
||||
106
docs/guides/kb-user-management-guide.md
Normal file
106
docs/guides/kb-user-management-guide.md
Normal file
@@ -0,0 +1,106 @@
|
||||
# User Management & Roles Guide
|
||||
|
||||
## Overview
|
||||
|
||||
The STEAM Security Dashboard uses role-based access control with four user groups. Only administrators can manage users. All user operations are logged in the audit trail.
|
||||
|
||||
## User Groups
|
||||
|
||||
| Group | Access Level | Description |
|
||||
|-------|-------------|-------------|
|
||||
| Admin | Full access | All operations including user management, delete, audit log |
|
||||
| Standard_User | Operational access | Create, edit, limited delete (own resources only), exports |
|
||||
| Leadership | Read-only + exports | View all data, download CSV/XLSX exports |
|
||||
| Read_Only | View only | Read-only access to all pages, no modifications |
|
||||
|
||||
## Permission Matrix
|
||||
|
||||
| Action | Admin | Standard_User | Leadership | Read_Only |
|
||||
|--------|-------|---------------|------------|-----------|
|
||||
| View findings/CVEs | Yes | Yes | Yes | Yes |
|
||||
| Sync Ivanti data | Yes | Yes | No | No |
|
||||
| Edit hostname/DNS overrides | Yes | Yes | No | No |
|
||||
| Edit notes | Yes | Yes | No | No |
|
||||
| Add to queue | Yes | Yes | No | No |
|
||||
| Create FP workflows | Yes | Yes | No | No |
|
||||
| Edit FP submissions | Yes | Yes | No | No |
|
||||
| Upload compliance reports | Yes | Yes | No | No |
|
||||
| Add CVEs | Yes | Yes | No | No |
|
||||
| Upload documents | Yes | Yes | No | No |
|
||||
| Export CSV/XLSX | Yes | Yes | Yes | No |
|
||||
| Delete CVEs/documents | Yes | Own only | No | No |
|
||||
| Manage users | Yes | No | No | No |
|
||||
| View audit log | Yes | No | No | No |
|
||||
|
||||
## Managing Users (Admin Only)
|
||||
|
||||
### Accessing User Management
|
||||
1. Click the user icon in the top navigation bar
|
||||
2. Select "User Management" from the menu
|
||||
3. The user list shows all accounts with their group, status, and last login
|
||||
|
||||
### Creating a New User
|
||||
1. Click "Add User"
|
||||
2. Fill in the required fields:
|
||||
- **Username** — must be unique
|
||||
- **Email** — user's email address
|
||||
- **Password** — initial password (user should change on first login)
|
||||
- **Group** — select from Admin, Standard_User, Leadership, or Read_Only
|
||||
3. Click Save
|
||||
|
||||
New users default to Read_Only if no group is specified.
|
||||
|
||||
### Editing a User
|
||||
1. Click the edit icon on the user row
|
||||
2. Modify username, email, or group
|
||||
3. Optionally set a new password (leave blank to keep current)
|
||||
4. Click Save
|
||||
|
||||
### Changing User Groups
|
||||
When changing a user's group, a confirmation dialog appears. Extra warnings are shown when:
|
||||
- Removing Admin privileges from a user
|
||||
- Upgrading a user to Admin
|
||||
|
||||
Group changes are logged separately in the audit trail with the previous and new group recorded.
|
||||
|
||||
### Deactivating Users
|
||||
Users can be deactivated rather than deleted. Deactivated users cannot log in but their data and audit history are preserved.
|
||||
|
||||
## Authentication
|
||||
|
||||
- Sessions use httpOnly cookies with 24-hour expiry
|
||||
- Passwords are hashed with bcryptjs
|
||||
- All API endpoints (except login) require a valid session
|
||||
- Failed login attempts are not rate-limited at the application level
|
||||
|
||||
## Audit Log
|
||||
|
||||
The audit log records all significant actions in the dashboard. Only admins can view it.
|
||||
|
||||
### What's Logged
|
||||
- User creation, updates, group changes, deletion
|
||||
- CVE creation, updates, deletion
|
||||
- Document uploads and deletions
|
||||
- Ivanti sync operations
|
||||
- FP workflow submissions and edits
|
||||
- Queue operations
|
||||
- Compliance uploads
|
||||
- Login/logout events
|
||||
|
||||
### Audit Entry Fields
|
||||
Each entry includes:
|
||||
- Timestamp
|
||||
- User who performed the action
|
||||
- Action type (e.g., user_create, ivanti_fp_workflow_created)
|
||||
- Entity type and ID
|
||||
- Details (JSON with specifics of what changed)
|
||||
- IP address
|
||||
|
||||
## Default Admin Account
|
||||
|
||||
On first setup (`node setup.js`), a default admin account is created:
|
||||
- Username: `admin`
|
||||
- Password: set during setup
|
||||
- Group: `Admin`
|
||||
|
||||
Change the default password immediately after first login.
|
||||
617
docs/security/security-audit-2026-04-01.md
Normal file
617
docs/security/security-audit-2026-04-01.md
Normal file
@@ -0,0 +1,617 @@
|
||||
# Security Audit Report — STEAM Security Dashboard
|
||||
|
||||
**Date:** 2026-04-01
|
||||
**Scope:** Full codebase — backend routes, authentication, file handling, Python scripts, React frontend
|
||||
**Methodology:** Static analysis across four parallel audit tracks
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
The audit identified **31 findings** across four severity levels. The most serious issues are concentrated in the **authentication and authorization layer** — several endpoints are either completely unauthenticated or have role-checking middleware called with the wrong arguments, silently bypassing access control. These require immediate remediation before the application is exposed to a broader user base.
|
||||
|
||||
| Severity | Count |
|
||||
|----------|-------|
|
||||
| Critical | 6 |
|
||||
| High | 9 |
|
||||
| Medium | 10 |
|
||||
| Low / Info | 6 |
|
||||
| **Total** | **31** |
|
||||
|
||||
The application has strong foundational security in several areas: all database queries use parameterized statements (no SQL injection risk), path traversal prevention is comprehensive, Python script execution uses `spawn` with argument arrays (no shell injection), and file type allowlisting is in place. The vulnerabilities are largely in middleware wiring and missing access controls rather than fundamental design flaws.
|
||||
|
||||
---
|
||||
|
||||
## Critical Findings
|
||||
|
||||
---
|
||||
|
||||
### C-1 — Missing Authentication on Ivanti Findings Endpoints
|
||||
|
||||
**File:** `backend/routes/ivantiFindings.js:552–600`
|
||||
|
||||
The findings router imports `requireRole` but **not** `requireAuth`. No authentication middleware is applied at the router level or on individual routes. Four endpoints are fully unauthenticated:
|
||||
|
||||
```js
|
||||
const { requireRole } = require('../middleware/auth'); // requireAuth never imported
|
||||
|
||||
router.get('/', async (req, res) => { // line 552 — no auth
|
||||
router.post('/sync', async (req, res) => { // line 561 — no auth
|
||||
router.get('/counts', async (req, res) => { // line 571 — no auth
|
||||
router.get('/fp-workflow-counts', ...) // line 580 — no auth
|
||||
```
|
||||
|
||||
**Impact:** Any unauthenticated attacker on the network can read the full list of Ivanti host findings (hostnames, IPs, CVEs, severity, SLA status), trigger a sync operation, and enumerate all finding metrics.
|
||||
|
||||
**Fix:** Import `requireAuth` and apply it to the router or each route:
|
||||
```js
|
||||
const { requireAuth, requireRole } = require('../middleware/auth');
|
||||
router.use(requireAuth(db));
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### C-2 — Broken requireRole Call — Privilege Escalation in Knowledge Base
|
||||
|
||||
**File:** `backend/routes/knowledgeBase.js:43, 305`
|
||||
|
||||
`requireRole` is called with `db` as the first argument:
|
||||
|
||||
```js
|
||||
router.post('/upload', requireAuth(db), requireRole(db, 'editor', 'admin'), ...)
|
||||
router.delete('/:id', requireAuth(db), requireRole(db, 'editor', 'admin'), ...)
|
||||
```
|
||||
|
||||
The function signature is `function requireRole(...allowedRoles)`. It does not accept `db`. The database object is treated as the first "allowed role", so the check becomes `req.user.role === db` — an object comparison that always evaluates false, meaning **the check never blocks anyone**. Any authenticated viewer can upload and delete knowledge base documents.
|
||||
|
||||
**Fix:** Remove `db` from all `requireRole` calls:
|
||||
```js
|
||||
requireRole('editor', 'admin')
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### C-3 — Unauthenticated Ivanti Finding Note Writes
|
||||
|
||||
**File:** `backend/routes/ivantiFindings.js:639`
|
||||
|
||||
The PUT endpoint for saving finding notes has no authentication middleware:
|
||||
|
||||
```js
|
||||
router.put('/:findingId/note', (req, res) => {
|
||||
const note = String(req.body.note || '').slice(0, 255);
|
||||
db.run(`INSERT INTO ivanti_finding_notes ...`);
|
||||
});
|
||||
```
|
||||
|
||||
**Impact:** Any unauthenticated request can write notes to any finding. Notes are visible to all users and used during remediation triage. An attacker could inject false status information (e.g. "EXC-12345 — patched") to mislead the team or cover tracks.
|
||||
|
||||
**Fix:** Add `requireAuth(db)` to this route.
|
||||
|
||||
---
|
||||
|
||||
### C-4 — No Brute Force Protection on Login Endpoint
|
||||
|
||||
**File:** `backend/routes/auth.js:10`
|
||||
|
||||
The login endpoint has no rate limiting, attempt counting, or lockout:
|
||||
|
||||
```js
|
||||
router.post('/login', async (req, res) => {
|
||||
const { username, password } = req.body;
|
||||
// Direct DB lookup, unlimited attempts
|
||||
```
|
||||
|
||||
**Impact:** An attacker can run unlimited password guesses against any account at full network speed. With the default credentials documented in the README and displayed in the UI (see F-2), admin accounts are a trivial target.
|
||||
|
||||
**Fix:** Apply `express-rate-limit` to the login route:
|
||||
```js
|
||||
const rateLimit = require('express-rate-limit');
|
||||
const loginLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 20 });
|
||||
router.post('/login', loginLimiter, async (req, res) => { ... });
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### C-5 — Default Credentials Displayed in Login UI
|
||||
|
||||
**File:** `frontend/src/components/LoginForm.js:104`
|
||||
|
||||
The login form renders hardcoded credentials in plain text:
|
||||
|
||||
```jsx
|
||||
<p className="text-sm text-gray-500 text-center font-mono">
|
||||
Default: <span className="text-intel-accent">admin</span> /
|
||||
<span className="text-intel-accent">admin123</span>
|
||||
</p>
|
||||
```
|
||||
|
||||
**Impact:** Anyone who opens the login page — including unauthenticated users — sees the default admin credentials. Combined with C-4 (no rate limiting), this is a direct path to admin compromise if the password has not been changed.
|
||||
|
||||
**Fix:** Remove this block entirely. Document default credentials only in the deployment guide. Enforce password change on first login server-side.
|
||||
|
||||
---
|
||||
|
||||
### C-6 — Missing Sandbox Attribute on Knowledge Base PDF Iframe
|
||||
|
||||
**File:** `frontend/src/components/KnowledgeBaseViewer.js:195`
|
||||
|
||||
The inline document viewer renders uploaded files in an unsandboxed iframe:
|
||||
|
||||
```jsx
|
||||
<iframe
|
||||
src={`${API_BASE}/knowledge-base/${article.id}/content`}
|
||||
title={article.title}
|
||||
className="w-full h-full rounded"
|
||||
>
|
||||
```
|
||||
|
||||
**Impact:** A malicious PDF or HTML file uploaded by an editor could execute JavaScript within the application's origin, accessing `localStorage`, `sessionStorage`, and DOM of the parent page. An attacker with editor access could upload a file that steals session data from any user who views it.
|
||||
|
||||
**Fix:** Add a restrictive `sandbox` attribute:
|
||||
```jsx
|
||||
<iframe
|
||||
sandbox="allow-same-origin allow-scripts"
|
||||
src={...}
|
||||
title={article.title}
|
||||
/>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## High Findings
|
||||
|
||||
---
|
||||
|
||||
### H-1 — /cleanup-sessions Missing Role Check
|
||||
|
||||
**File:** `backend/routes/auth.js:223`
|
||||
|
||||
The comment says "admin only" but the endpoint only checks for any valid session:
|
||||
|
||||
```js
|
||||
router.post('/cleanup-sessions', async (req, res) => {
|
||||
const sessionId = req.cookies?.session_id;
|
||||
if (!sessionId) return res.status(401).json({ error: '...' });
|
||||
// No role check
|
||||
```
|
||||
|
||||
**Fix:** Apply `requireAuth(db)` and `requireRole('admin')`.
|
||||
|
||||
---
|
||||
|
||||
### H-2 — Hardcoded Fallback SESSION_SECRET
|
||||
|
||||
**File:** `backend/server.js:31`
|
||||
|
||||
```js
|
||||
const SESSION_SECRET = process.env.SESSION_SECRET || 'default-secret-change-me';
|
||||
```
|
||||
|
||||
If the `.env` file is missing or the variable is unset, all sessions are signed with a publicly known string. An attacker who knows the secret can forge valid session cookies.
|
||||
|
||||
**Fix:** Fail hard on startup if the secret is not set:
|
||||
```js
|
||||
const SESSION_SECRET = process.env.SESSION_SECRET;
|
||||
if (!SESSION_SECRET) throw new Error('SESSION_SECRET environment variable must be set');
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### H-3 — Audit Log Parameter Mismatch — Silent Audit Trail Gaps
|
||||
|
||||
**Files:** `backend/routes/archerTickets.js:89–95, 172, 206` and `backend/routes/knowledgeBase.js:235–244, 287–296`
|
||||
|
||||
The `logAudit` helper expects an object with `entityType` and `entityId`. These callers use the wrong keys (`targetType`, `targetId`) or pass positional arguments instead of an object:
|
||||
|
||||
```js
|
||||
// archerTickets.js — wrong keys
|
||||
logAudit(db, { ..., targetType: 'archer_ticket', targetId: this.lastID, ... });
|
||||
|
||||
// knowledgeBase.js — positional (wrong pattern)
|
||||
logAudit(db, req.user.id, req.user.username, 'VIEW_KB_ARTICLE', 'knowledge_base', id, ...);
|
||||
```
|
||||
|
||||
**Impact:** All Archer ticket and Knowledge Base operations produce audit log rows with `NULL` entity type and entity ID. Security investigations and compliance reviews will show these actions occurred but not what was affected.
|
||||
|
||||
**Fix:** Align all callers to the object format expected by `auditLog.js`:
|
||||
```js
|
||||
logAudit(db, { userId, username, action, entityType, entityId, details, ipAddress });
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### H-4 — Viewers Can Write Compliance Notes
|
||||
|
||||
**Files:** `backend/routes/compliance.js:522` (also flagged by file-upload audit)
|
||||
|
||||
The POST /notes endpoint is protected by authentication but not by role:
|
||||
|
||||
```js
|
||||
router.post('/notes', async (req, res) => { // no requireRole()
|
||||
```
|
||||
|
||||
**Impact:** Any viewer can add notes to any compliance item. Notes surface in the detail panel and influence remediation decisions. False notes cannot be deleted via the API.
|
||||
|
||||
**Fix:** `requireRole('editor', 'admin')` on this route.
|
||||
|
||||
---
|
||||
|
||||
### H-5 — Sync Endpoints Accessible to All Authenticated Users
|
||||
|
||||
**Files:** `backend/routes/ivantiFindings.js:561`, `backend/routes/ivantiWorkflows.js:262`
|
||||
|
||||
POST /sync on both routers requires only authentication, not editor/admin role. Any viewer can trigger expensive Ivanti API calls repeatedly.
|
||||
|
||||
**Impact:** Viewer-role users can cause repeated large API fetches, potentially hitting Ivanti rate limits and blocking legitimate syncs for the team.
|
||||
|
||||
**Fix:** Add `requireRole('editor', 'admin')` to both POST /sync routes.
|
||||
|
||||
---
|
||||
|
||||
### H-6 — HTTP Header Injection via Unsanitized Filename in Content-Disposition
|
||||
|
||||
**File:** `backend/routes/knowledgeBase.js:258, 299`
|
||||
|
||||
The original uploaded filename (user-controlled) is written directly into the `Content-Disposition` response header:
|
||||
|
||||
```js
|
||||
res.setHeader('Content-Disposition', `inline; filename="${row.file_name}"`);
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${row.file_name}"`);
|
||||
```
|
||||
|
||||
`row.file_name` stores `uploadedFile.originalname` which is not sanitized for use in HTTP headers. A filename containing `"\r\n` characters can split the response and inject arbitrary headers.
|
||||
|
||||
**Fix:**
|
||||
```js
|
||||
const safeFilename = row.file_name.replace(/["\r\n\\]/g, '');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${safeFilename}"`);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### H-7 — Race Condition in Knowledge Base File Upload
|
||||
|
||||
**File:** `backend/routes/knowledgeBase.js:91–155`
|
||||
|
||||
The file is moved to its permanent location (line 93) before the database record is created (line 114). If the DB insert fails, the file is orphaned on disk. Two concurrent uploads with the same slug can also bypass the uniqueness check due to the async gap between the slug check query and the insert.
|
||||
|
||||
**Fix:** Keep the file in the temp directory until the DB insert succeeds, then move it:
|
||||
```js
|
||||
db.run(insertSql, [...], function(err) {
|
||||
if (err) { fs.unlinkSync(uploadedFile.path); return res.status(500)...; }
|
||||
fs.renameSync(uploadedFile.path, filePath);
|
||||
res.json({ success: true });
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### H-8 — Hardcoded Default Admin Password in setup.js
|
||||
|
||||
**File:** `backend/setup.js:175`
|
||||
|
||||
```js
|
||||
const passwordHash = await bcrypt.hash('admin123', 10);
|
||||
```
|
||||
|
||||
If `setup.js` is re-run on an existing deployment (e.g. during a restore), the admin password resets to a known value. The password is also documented in the README and displayed in the login UI (C-5).
|
||||
|
||||
**Fix:** Generate a random password on first run and print it once to stdout, or require it as a CLI argument. Never hardcode credentials in source.
|
||||
|
||||
---
|
||||
|
||||
### H-9 — ReactMarkdown Renders HTML Without Sanitization
|
||||
|
||||
**File:** `frontend/src/components/KnowledgeBaseViewer.js:169–171`
|
||||
|
||||
```jsx
|
||||
<ReactMarkdown>{content}</ReactMarkdown>
|
||||
```
|
||||
|
||||
`ReactMarkdown` by default allows raw HTML in markdown (via `rehype-raw`). A knowledge base article containing `<img src=x onerror="...">` or `<script>` tags would execute JavaScript in the viewer's browser.
|
||||
|
||||
**Fix:** Add `rehype-sanitize`:
|
||||
```jsx
|
||||
import rehypeSanitize from 'rehype-sanitize';
|
||||
<ReactMarkdown rehypePlugins={[rehypeSanitize]}>{content}</ReactMarkdown>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Medium Findings
|
||||
|
||||
---
|
||||
|
||||
### M-1 — No CSRF Token Protection on State-Changing Requests
|
||||
|
||||
**Files:** All POST / PUT / DELETE routes
|
||||
|
||||
Cookies are `SameSite: lax` which provides partial protection, but `lax` still allows top-level cross-site navigations to carry cookies. No CSRF token is validated server-side. Combined with the permissive CORS configuration, cross-site request forgery is possible against editors and admins.
|
||||
|
||||
**Fix:** Either upgrade session cookie to `SameSite: strict`, or implement a CSRF token (double-submit cookie pattern or `csurf` middleware).
|
||||
|
||||
---
|
||||
|
||||
### M-2 — CORS Allows Credentials with Explicit Origin List
|
||||
|
||||
**File:** `backend/server.js:111–114`
|
||||
|
||||
```js
|
||||
app.use(cors({ origin: CORS_ORIGINS, credentials: true }));
|
||||
```
|
||||
|
||||
`credentials: true` with explicit origins means any subdomain compromise or DNS hijacking of a listed origin could allow cross-origin authenticated requests. This is the correct pattern for this use case, but worth hardening.
|
||||
|
||||
**Fix:** Ensure `CORS_ORIGINS` is reviewed whenever the deployment changes. Consider `SameSite: strict` on cookies to reduce reliance on CORS for CSRF protection.
|
||||
|
||||
---
|
||||
|
||||
### M-3 — No Rate Limiting on NVD API Proxy
|
||||
|
||||
**File:** `backend/routes/nvdLookup.js:13`
|
||||
|
||||
Any authenticated user can trigger NVD API calls in rapid succession. NVD enforces a 5 req/30s unauthenticated limit, which can be exhausted by a single user making 5 lookups.
|
||||
|
||||
**Fix:** Add a server-side 1-hour cache keyed by CVE ID to avoid repeated external lookups, plus a per-user rate limit.
|
||||
|
||||
---
|
||||
|
||||
### M-4 — Admin Self-Demotion Check Uses Loose Equality
|
||||
|
||||
**File:** `backend/routes/users.js:118`
|
||||
|
||||
```js
|
||||
if (userId == req.user.id && role && role !== 'admin') {
|
||||
```
|
||||
|
||||
Using `==` allows type coercion. If `userId` is passed as a different type than `req.user.id`, the comparison may not match correctly.
|
||||
|
||||
**Fix:** `String(userId) === String(req.user.id)`.
|
||||
|
||||
---
|
||||
|
||||
### M-5 — Missing Hostname Format Validation
|
||||
|
||||
**File:** `backend/routes/compliance.js:451`
|
||||
|
||||
The hostname route parameter is used in SQL queries and responses. Only length is checked (>300). No format validation rejects characters outside a valid hostname range.
|
||||
|
||||
**Fix:**
|
||||
```js
|
||||
if (!/^[a-zA-Z0-9._-]+$/.test(hostname)) {
|
||||
return res.status(400).json({ error: 'Invalid hostname format' });
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### M-6 — Vendor Field Validated Before Trim
|
||||
|
||||
**File:** `backend/routes/ivantiTodoQueue.js:8, 56`
|
||||
|
||||
Vendor length is validated before `.trim()` is called. A string of 200 spaces passes validation but becomes an empty string after trimming, which then passes without a vendor value for FP/Archer items that require one.
|
||||
|
||||
**Fix:** Trim first, then validate length and presence.
|
||||
|
||||
---
|
||||
|
||||
### M-7 — Unsanitized Original Filename Stored in Compliance Temp JSON
|
||||
|
||||
**File:** `backend/routes/compliance.js:262`
|
||||
|
||||
```js
|
||||
filename: req.file.originalname, // user-controlled, unsanitized
|
||||
```
|
||||
|
||||
The original filename is stored in the temp JSON and later echoed back to the frontend. Special characters could cause log injection or unexpected display issues.
|
||||
|
||||
**Fix:** `filename: sanitizePathSegment(req.file.originalname)`.
|
||||
|
||||
---
|
||||
|
||||
### M-8 — Hardcoded Frontend Origin in CSP Header
|
||||
|
||||
**File:** `backend/routes/knowledgeBase.js:261`
|
||||
|
||||
```js
|
||||
res.setHeader('Content-Security-Policy',
|
||||
"frame-ancestors 'self' http://71.85.90.9:3000 http://localhost:3000");
|
||||
```
|
||||
|
||||
IP address is hardcoded. If the deployment IP changes, the CSP header will block inline document viewing without an obvious error and require a code change.
|
||||
|
||||
**Fix:** Use `CORS_ORIGINS` from the environment variable.
|
||||
|
||||
---
|
||||
|
||||
### M-9 — Sensitive API Error Messages Forwarded to UI
|
||||
|
||||
**Files:** `frontend/src/App.js:801, 816, 847, 886`
|
||||
|
||||
```js
|
||||
} catch (err) {
|
||||
alert(`Error: ${err.message}`);
|
||||
}
|
||||
```
|
||||
|
||||
Raw API error messages are displayed in browser alerts. If the backend leaks stack traces or query information in error responses, this information reaches the user directly.
|
||||
|
||||
**Fix:** Show generic user-facing messages; log details to the console in development only.
|
||||
|
||||
---
|
||||
|
||||
### M-10 — User-Supplied Data in window.confirm Dialogs
|
||||
|
||||
**File:** `frontend/src/App.js:806, 891`
|
||||
|
||||
```js
|
||||
if (!window.confirm(`Delete ticket ${ticket.ticket_key}?`)) return;
|
||||
```
|
||||
|
||||
A ticket with a crafted `ticket_key` value (e.g. containing newlines or misleading text) could produce a deceptive confirmation dialog used to social-engineer users.
|
||||
|
||||
**Fix:** Use a React modal component with escaped, controlled text instead of `window.confirm`.
|
||||
|
||||
---
|
||||
|
||||
## Low / Info Findings
|
||||
|
||||
---
|
||||
|
||||
### L-1 — Silent ROLLBACK on Compliance Transaction Failure
|
||||
|
||||
**File:** `backend/routes/compliance.js:167`
|
||||
|
||||
```js
|
||||
await dbRun(db, 'ROLLBACK').catch(() => {});
|
||||
```
|
||||
|
||||
If the rollback itself fails, the error is swallowed entirely. A failed rollback leaves an open transaction that can cause subsequent operations to block.
|
||||
|
||||
**Fix:** Log rollback failures even if execution continues.
|
||||
|
||||
---
|
||||
|
||||
### L-2 — Fire-and-Forget Audit Logging
|
||||
|
||||
**File:** `backend/helpers/auditLog.js:9`
|
||||
|
||||
Audit log writes fail silently. If the database is under load or unavailable, audit records are dropped with no alert.
|
||||
|
||||
**Fix:** Log audit write failures to stderr so they surface in server logs.
|
||||
|
||||
---
|
||||
|
||||
### L-3 — Async Temp File Cleanup With No Error Handling
|
||||
|
||||
**File:** `backend/routes/compliance.js:239, 247, 266, 281, 322`
|
||||
|
||||
```js
|
||||
fs.unlink(req.file.path, () => {});
|
||||
```
|
||||
|
||||
Cleanup failures accumulate silently, potentially causing disk exhaustion over time.
|
||||
|
||||
**Fix:** Log errors on unlink failure (excluding ENOENT which is expected).
|
||||
|
||||
---
|
||||
|
||||
### L-4 — IVANTI_SKIP_TLS Disables Certificate Validation
|
||||
|
||||
**File:** `backend/routes/ivantiFindings.js:385`
|
||||
|
||||
`IVANTI_SKIP_TLS=true` disables TLS verification for all Ivanti API calls, enabling man-in-the-middle attacks against the sync. It is controlled purely by environment variable with no warning.
|
||||
|
||||
**Fix:** Log a prominent warning on startup when this flag is active, and ensure it is never set in production.
|
||||
|
||||
---
|
||||
|
||||
### L-5 — console.error in Production Frontend Code
|
||||
|
||||
**Files:** `frontend/src/contexts/AuthContext.js:26`, `KnowledgeBaseViewer.js:31, 56`
|
||||
|
||||
Full error objects are logged to the browser console in production builds. In a monitored environment, these could expose internal details to anyone with DevTools open.
|
||||
|
||||
**Fix:** Guard with `if (process.env.NODE_ENV === 'development')` or use a structured logging library.
|
||||
|
||||
---
|
||||
|
||||
### L-6 — localStorage Column Config Lacks Structural Validation
|
||||
|
||||
**File:** `frontend/src/components/pages/ReportingPage.js:51–68`
|
||||
|
||||
Column order/visibility is loaded from `localStorage` and merged with defaults. If the stored data is tampered with (via XSS or DevTools), the parsed structure is used with only partial validation.
|
||||
|
||||
**Fix:** Validate each loaded item against the known `COLUMN_DEFS` whitelist before use (a `hasOwnProperty` check is already present; ensure it runs on every item before the merge).
|
||||
|
||||
---
|
||||
|
||||
## Summary Table
|
||||
|
||||
| ID | Severity | Title | File |
|
||||
|----|----------|-------|------|
|
||||
| C-1 | Critical | Missing auth on Ivanti findings endpoints | ivantiFindings.js:552 |
|
||||
| C-2 | Critical | requireRole(db) call bypasses role check in KB routes | knowledgeBase.js:43,305 |
|
||||
| C-3 | Critical | Unauthenticated finding note writes | ivantiFindings.js:639 |
|
||||
| C-4 | Critical | No brute force protection on login | auth.js:10 |
|
||||
| C-5 | Critical | Default credentials displayed in login UI | LoginForm.js:104 |
|
||||
| C-6 | Critical | Missing sandbox on PDF/document iframe | KnowledgeBaseViewer.js:195 |
|
||||
| H-1 | High | /cleanup-sessions missing role check | auth.js:223 |
|
||||
| H-2 | High | Hardcoded fallback SESSION_SECRET | server.js:31 |
|
||||
| H-3 | High | Audit log parameter mismatch — silent trail gaps | archerTickets.js, knowledgeBase.js |
|
||||
| H-4 | High | Viewers can write compliance notes | compliance.js:522 |
|
||||
| H-5 | High | Sync endpoints accessible to all authenticated users | ivantiFindings.js:561, ivantiWorkflows.js:262 |
|
||||
| H-6 | High | HTTP header injection via Content-Disposition filename | knowledgeBase.js:258,299 |
|
||||
| H-7 | High | Race condition in KB file upload | knowledgeBase.js:91 |
|
||||
| H-8 | High | Hardcoded default admin password in setup.js | setup.js:175 |
|
||||
| H-9 | High | ReactMarkdown renders HTML without sanitization | KnowledgeBaseViewer.js:169 |
|
||||
| M-1 | Medium | No CSRF token protection | All state-changing routes |
|
||||
| M-2 | Medium | CORS credentials with explicit origin list | server.js:111 |
|
||||
| M-3 | Medium | No rate limiting on NVD API proxy | nvdLookup.js:13 |
|
||||
| M-4 | Medium | Admin self-demotion check uses loose equality | users.js:118 |
|
||||
| M-5 | Medium | Missing hostname format validation | compliance.js:451 |
|
||||
| M-6 | Medium | Vendor field validated before trim | ivantiTodoQueue.js:8,56 |
|
||||
| M-7 | Medium | Unsanitized original filename in temp JSON | compliance.js:262 |
|
||||
| M-8 | Medium | Hardcoded frontend IP in CSP header | knowledgeBase.js:261 |
|
||||
| M-9 | Medium | API error messages forwarded to UI | App.js:801,816,847,886 |
|
||||
| M-10 | Medium | User data in window.confirm dialogs | App.js:806,891 |
|
||||
| L-1 | Low | Silent ROLLBACK on transaction failure | compliance.js:167 |
|
||||
| L-2 | Low | Fire-and-forget audit logging | auditLog.js:9 |
|
||||
| L-3 | Low | Async temp file cleanup with no error handling | compliance.js:239+ |
|
||||
| L-4 | Low | IVANTI_SKIP_TLS with no startup warning | ivantiFindings.js:385 |
|
||||
| L-5 | Low | console.error exposed in production frontend | AuthContext.js, KnowledgeBaseViewer.js |
|
||||
| L-6 | Low | localStorage column config lacks structural validation | ReportingPage.js:51 |
|
||||
|
||||
---
|
||||
|
||||
## Remediation Priority
|
||||
|
||||
### Immediate — fix before adding users
|
||||
|
||||
1. **C-1** — Add `requireAuth` import and router-level middleware to `ivantiFindings.js`
|
||||
2. **C-2** — Remove `db` from all `requireRole(db, ...)` calls in `knowledgeBase.js`
|
||||
3. **C-3** — Add `requireAuth(db)` to the finding note PUT route
|
||||
4. **C-4** — Add `express-rate-limit` to the login route (20 attempts / 15 min)
|
||||
5. **C-5** — Remove default credentials from `LoginForm.js`
|
||||
6. **H-2** — Hard-fail on startup if `SESSION_SECRET` is not set in env
|
||||
|
||||
### Short-term — next maintenance window
|
||||
|
||||
7. **C-6** — Add `sandbox` attribute to the KB iframe
|
||||
8. **H-3** — Fix `logAudit` call signatures in `archerTickets.js` and `knowledgeBase.js`
|
||||
9. **H-4** — Add `requireRole('editor', 'admin')` to POST /compliance/notes
|
||||
10. **H-5** — Add `requireRole('editor', 'admin')` to both POST /sync routes
|
||||
11. **H-6** — Sanitize filename for `Content-Disposition` header
|
||||
12. **H-7** — Move file after DB insert succeeds in KB upload
|
||||
13. **H-8** — Remove hardcoded password from `setup.js`; generate random on first run
|
||||
14. **H-9** — Add `rehype-sanitize` to `ReactMarkdown` usage
|
||||
|
||||
### Medium-term
|
||||
|
||||
15. **M-1** — Implement CSRF token or upgrade cookie to `SameSite: strict`
|
||||
16. **M-3** — Add server-side CVE lookup cache
|
||||
17. **M-5** — Add hostname format regex validation
|
||||
18. **M-8** — Pull frontend origin from `CORS_ORIGINS` env var for CSP header
|
||||
19. **M-9** — Replace `alert(err.message)` with user-friendly error messages
|
||||
20. Remaining medium and low findings
|
||||
|
||||
---
|
||||
|
||||
## Positive Security Observations
|
||||
|
||||
The following were explicitly verified as secure and should be preserved:
|
||||
|
||||
- **SQL injection prevention** — all queries use SQLite3 parameterized statements throughout
|
||||
- **Path traversal prevention** — `sanitizePathSegment()` and `isPathWithinUploads()` are comprehensive and consistently applied
|
||||
- **Python script execution** — `spawn('python3', [SCRIPT, filePath])` passes arguments as an array, not a shell string — no command injection possible
|
||||
- **Python scripts** — no `eval()`, `exec()`, `pickle.load()`, or shell calls in any script
|
||||
- **File size enforcement** — 10 MB limit applied via multer before route handlers execute
|
||||
- **File type allowlisting** — extension + MIME prefix validation applied at upload
|
||||
- **Static file serving** — `express.static` with `{ dotfiles: 'deny', index: false }` prevents directory listing
|
||||
- **Temp file path validation** — `isSafeTempPath()` enforces `.json` extension on compliance temp files
|
||||
- **Password hashing** — bcrypt with cost factor 10 used throughout
|
||||
|
||||
---
|
||||
|
||||
*Audit scope: static analysis only. Dynamic testing (active exploitation, fuzzing, dependency CVE scan) not performed.*
|
||||
337
docs/security/security-audit-tracker.md
Normal file
337
docs/security/security-audit-tracker.md
Normal file
@@ -0,0 +1,337 @@
|
||||
# Security Audit Tracker — STEAM Security Dashboard
|
||||
|
||||
**Last scan:** 2026-04-20
|
||||
**Scope:** Full repository — backend routes, middleware, helpers, scripts, frontend components
|
||||
**Baseline:** `docs/security-audit-2026-04-01.md` (31 findings), `docs/security-remediation-plan.md` (17 prioritised items)
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Remediation Status — April 1 Audit](#remediation-status--april-1-audit)
|
||||
- [New Findings — April 20 Scan](#new-findings--april-20-scan)
|
||||
- [Open Finding Summary](#open-finding-summary)
|
||||
- [Positive Security Observations](#positive-security-observations)
|
||||
- [Scan Metadata](#scan-metadata)
|
||||
|
||||
---
|
||||
|
||||
## Remediation Status — April 1 Audit
|
||||
|
||||
Cross-reference of the 31 original findings against the current codebase. Status: **Fixed**, **Partial**, or **Open**.
|
||||
|
||||
### Critical Findings
|
||||
|
||||
| ID | Title | Status | Evidence |
|
||||
|---|---|---|---|
|
||||
| C-1 | Missing auth on Ivanti findings endpoints | **Fixed** | `ivantiFindings.js` — router uses `requireAuth(db)` at router level, `requireGroup` on sync |
|
||||
| C-2 | `requireRole(db)` bypasses role check in KB routes | **Fixed** | `knowledgeBase.js` — uses `requireGroup('Admin', 'Standard_User')` correctly |
|
||||
| C-3 | Unauthenticated finding note writes | **Fixed** | `ivantiFindings.js` — note routes behind `requireAuth(db)` |
|
||||
| C-4 | No brute force protection on login | **Fixed** | `auth.js` — `loginLimiter` (20 attempts / 15 min) applied to POST /login |
|
||||
| C-5 | Default credentials displayed in login UI | **Fixed** | `LoginForm.js` — no hardcoded credentials in the component |
|
||||
| C-6 | Missing sandbox on KB document iframe | **Fixed** | `KnowledgeBaseViewer.js:282` — `sandbox="allow-same-origin"` applied |
|
||||
|
||||
### High Findings
|
||||
|
||||
| ID | Title | Status | Evidence |
|
||||
|---|---|---|---|
|
||||
| H-1 | `/cleanup-sessions` missing role check | **Fixed** | `auth.js` — `requireAuth(db), requireGroup('Admin')` applied |
|
||||
| H-2 | Hardcoded fallback SESSION_SECRET | **Fixed** | `server.js:34-37` — hard-fails with `process.exit(1)` if unset |
|
||||
| H-3 | Audit log parameter mismatch — silent trail gaps | **Partial** | `knowledgeBase.js` — fixed. `archerTickets.js` — `logAudit` calls missing `username` field (see N-1 below) |
|
||||
| H-4 | Viewers can write compliance notes | **Fixed** | `compliance.js` — `requireGroup('Admin', 'Standard_User')` on POST /notes |
|
||||
| H-5 | Sync endpoints accessible to all authenticated users | **Fixed** | Both `ivantiFindings.js` and `ivantiWorkflows.js` — `requireGroup('Admin', 'Standard_User')` on POST /sync |
|
||||
| H-6 | HTTP header injection via Content-Disposition filename | **Fixed** | `knowledgeBase.js` — filename sanitized with `.replace(/["\r\n\\]/g, '')` |
|
||||
| H-7 | Race condition in KB file upload | **Fixed** | `knowledgeBase.js` — file moved after DB insert succeeds |
|
||||
| H-8 | Hardcoded default admin password in setup.js | **Fixed** | `setup.js` — generates random password via `crypto.randomBytes(12)` |
|
||||
| H-9 | ReactMarkdown renders HTML without sanitization | **Fixed** | `KnowledgeBaseViewer.js` — `rehypeSanitize` plugin applied |
|
||||
|
||||
### Medium Findings
|
||||
|
||||
| ID | Title | Status | Evidence |
|
||||
|---|---|---|---|
|
||||
| M-1 | No CSRF token protection | **Open** | Cookies use `SameSite: lax` — no CSRF token implemented |
|
||||
| M-2 | CORS credentials with explicit origin list | **Open** | Acceptable for this deployment model — monitor |
|
||||
| M-3 | No rate limiting on NVD API proxy | **Open** | No server-side cache or per-user rate limit on `/api/nvd/lookup` |
|
||||
| M-4 | Admin self-demotion check uses loose equality | **Fixed** | `users.js` — uses `String(userId) === String(req.user.id)` |
|
||||
| M-5 | Missing hostname format validation | **Fixed** | `compliance.js` POST /notes — regex validation `^[a-zA-Z0-9._-]+$` |
|
||||
| M-6 | Vendor field validated before trim | **Open** | `ivantiTodoQueue.js:8` — `isValidVendor()` checks length before trim |
|
||||
| M-7 | Unsanitized original filename in temp JSON | **Open** | `compliance.js:344` — `req.file.originalname` passed directly |
|
||||
| M-8 | Hardcoded frontend IP in CSP header | **Fixed** | `knowledgeBase.js:302` — reads from `CORS_ORIGINS` env var |
|
||||
| M-9 | API error messages forwarded to UI | **Open** | Frontend still uses `alert(err.message)` in several places |
|
||||
| M-10 | User data in window.confirm dialogs | **Open** | Frontend still uses `window.confirm` with user-supplied data |
|
||||
|
||||
### Low / Info Findings
|
||||
|
||||
| ID | Title | Status | Evidence |
|
||||
|---|---|---|---|
|
||||
| L-1 | Silent ROLLBACK on transaction failure | **Open** | `compliance.js:167` — `.catch(() => {})` still swallows errors |
|
||||
| L-2 | Fire-and-forget audit logging | **Partial** | `auditLog.js` — now logs to `console.error` on failure, but no alerting |
|
||||
| L-3 | Async temp file cleanup with no error handling | **Open** | `compliance.js` — `fs.unlink(path, () => {})` still used |
|
||||
| L-4 | IVANTI_SKIP_TLS with no startup warning | **Open** | No startup warning when `IVANTI_SKIP_TLS=true` |
|
||||
| L-5 | console.error in production frontend | **Open** | No environment guard on console.error calls |
|
||||
| L-6 | localStorage column config lacks structural validation | **Open** | No change observed |
|
||||
|
||||
### Remediation Plan Items (not in original 31)
|
||||
|
||||
| ID | Title | Status | Evidence |
|
||||
|---|---|---|---|
|
||||
| RP-1 | Authenticate /uploads static file access | **Open** | `server.js:127` — `express.static('uploads')` still unauthenticated |
|
||||
| RP-2 | Sanitize Mermaid SVG output with DOMPurify | **Open** | `KnowledgeBaseViewer.js:38` — `innerHTML = svg` without DOMPurify |
|
||||
| RP-3 | Strip server file paths from compliance preview response | **Open** | `compliance.js:342` — full `tempFilePath` returned to client |
|
||||
| RP-4 | Add SESSION_SECRET to .env.example | **Open** | `.env.example` — no `SESSION_SECRET` entry |
|
||||
|
||||
---
|
||||
|
||||
## New Findings — April 20 Scan
|
||||
|
||||
Findings discovered in this scan that were not present in the April 1 audit.
|
||||
|
||||
---
|
||||
|
||||
### N-1 — Archer Ticket Audit Logs Missing `username` Field (Medium)
|
||||
|
||||
**File:** `backend/routes/archerTickets.js:89, 172, 195`
|
||||
|
||||
All three `logAudit` calls in the Archer tickets router omit the `username` field:
|
||||
|
||||
```js
|
||||
logAudit(db, {
|
||||
userId: req.user.id,
|
||||
action: 'CREATE_ARCHER_TICKET',
|
||||
// username: req.user.username ← missing
|
||||
...
|
||||
});
|
||||
```
|
||||
|
||||
The `auditLog.js` helper defaults missing username to `'unknown'`, so all Archer ticket audit entries show `username = 'unknown'` instead of the actual user.
|
||||
|
||||
**Impact:** Audit trail for Archer ticket operations cannot identify which user performed the action. Compliance reviews and incident investigations are degraded.
|
||||
|
||||
**Fix:** Add `username: req.user.username` to all three `logAudit` calls.
|
||||
|
||||
---
|
||||
|
||||
### N-2 — `migrate-to-1.1.js` Contains Hardcoded Admin Password (Medium)
|
||||
|
||||
**File:** `backend/migrate-to-1.1.js:246`
|
||||
|
||||
```js
|
||||
const passwordHash = await bcrypt.hash('admin123', 10);
|
||||
```
|
||||
|
||||
While `setup.js` was fixed to generate random passwords (H-8), the migration script still hardcodes `admin123`. If this migration is run on an existing deployment, it resets the admin password to a known value.
|
||||
|
||||
**Impact:** Running the migration on a production system resets the admin account to a publicly known password.
|
||||
|
||||
**Fix:** Either generate a random password (matching `setup.js` pattern) or skip admin creation if the user already exists.
|
||||
|
||||
---
|
||||
|
||||
### N-3 — Compliance Preview Returns Full Server Filesystem Path (Medium)
|
||||
|
||||
**File:** `backend/routes/compliance.js:342`
|
||||
|
||||
```js
|
||||
tempFile: tempFilePath,
|
||||
```
|
||||
|
||||
The preview endpoint returns the full server-side path (e.g. `/home/cve-dashboard/backend/uploads/temp/compliance_preview_...json`) to the frontend. The commit endpoint then receives this path back and reads the file. This exposes the server's directory structure to any authenticated user.
|
||||
|
||||
**Impact:** Information disclosure — authenticated users learn the server's absolute filesystem layout, which aids further exploitation.
|
||||
|
||||
**Fix:** Return only the filename. Reconstruct the full path server-side in the commit handler:
|
||||
```js
|
||||
tempFile: tempFilename, // just the basename
|
||||
// In commit handler:
|
||||
const tempFile = path.join(TEMP_DIR, path.basename(req.body.tempFile));
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### N-4 — `/uploads` Static Directory Served Without Authentication (High)
|
||||
|
||||
**File:** `backend/server.js:127`
|
||||
|
||||
```js
|
||||
app.use('/uploads', express.static('uploads', {
|
||||
dotfiles: 'deny',
|
||||
index: false
|
||||
}));
|
||||
```
|
||||
|
||||
All uploaded files (CVE documents, compliance data, knowledge base articles) are served as static files without any authentication check. Anyone who knows or guesses a file URL can access sensitive vulnerability documentation, compliance reports, and internal knowledge base content.
|
||||
|
||||
**Impact:** Unauthenticated access to all uploaded documents. File paths are predictable (CVE ID + vendor + timestamp-filename pattern).
|
||||
|
||||
**Fix:** Replace with an authenticated route handler:
|
||||
```js
|
||||
app.use('/uploads', requireAuth(db), express.static('uploads', { ... }));
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### N-5 — Mermaid SVG Rendered via `innerHTML` Without Sanitization (Medium)
|
||||
|
||||
**File:** `frontend/src/components/KnowledgeBaseViewer.js:38`
|
||||
|
||||
```js
|
||||
ref.current.innerHTML = svg;
|
||||
```
|
||||
|
||||
Mermaid-generated SVG is injected directly into the DOM via `innerHTML`. While Mermaid itself sanitizes most input, a crafted diagram definition in a knowledge base article could potentially produce SVG with embedded event handlers or script elements.
|
||||
|
||||
**Impact:** Stored XSS vector if Mermaid's internal sanitization is bypassed. Any user viewing the article would execute the payload.
|
||||
|
||||
**Fix:** Sanitize the SVG string before injection:
|
||||
```js
|
||||
import DOMPurify from 'dompurify';
|
||||
ref.current.innerHTML = DOMPurify.sanitize(svg, { USE_PROFILES: { svg: true } });
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### N-6 — `SESSION_SECRET` Not Documented in `.env.example` (Low)
|
||||
|
||||
**File:** `backend/.env.example`
|
||||
|
||||
The `SESSION_SECRET` environment variable is required for the server to start (hard-fail added per H-2 fix), but it is not listed in `.env.example`. Fresh deployments will fail with no guidance on what to set.
|
||||
|
||||
**Fix:** Add to `.env.example`:
|
||||
```
|
||||
# Session signing secret — generate with: openssl rand -hex 32
|
||||
SESSION_SECRET=
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### N-7 — `requireGroup` Error Response Leaks Current User Group (Low)
|
||||
|
||||
**File:** `backend/middleware/auth.js:55-60`
|
||||
|
||||
```js
|
||||
return res.status(403).json({
|
||||
error: 'Insufficient permissions',
|
||||
required: allowedGroups,
|
||||
current: req.user.group
|
||||
});
|
||||
```
|
||||
|
||||
The 403 response includes both the required groups and the user's current group. This is minor information disclosure — an attacker probing endpoints learns the exact group membership of the compromised account and which groups are needed.
|
||||
|
||||
**Fix:** Remove `required` and `current` from the response:
|
||||
```js
|
||||
return res.status(403).json({ error: 'Insufficient permissions' });
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### N-8 — No Content-Security-Policy Header on Main Application (Medium)
|
||||
|
||||
**File:** `backend/server.js:107-113`
|
||||
|
||||
Security headers include `X-Content-Type-Options`, `X-Frame-Options`, `X-XSS-Protection`, `Referrer-Policy`, and `Permissions-Policy`, but no `Content-Security-Policy` header. CSP is the primary browser-side defense against XSS.
|
||||
|
||||
**Impact:** No browser-enforced restriction on script sources. If an XSS vulnerability exists (e.g. N-5), there is no CSP to mitigate it.
|
||||
|
||||
**Fix:** Add a baseline CSP header:
|
||||
```js
|
||||
res.setHeader('Content-Security-Policy',
|
||||
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; " +
|
||||
"img-src 'self' data:; font-src 'self'; connect-src 'self'");
|
||||
```
|
||||
Start with `Content-Security-Policy-Report-Only` to avoid breaking existing functionality.
|
||||
|
||||
---
|
||||
|
||||
### N-9 — Expired Sessions Not Cleaned Up Automatically (Low)
|
||||
|
||||
**File:** `backend/server.js`, `backend/routes/auth.js`
|
||||
|
||||
The `sessions` table has no automatic cleanup. Expired sessions accumulate indefinitely. The `/cleanup-sessions` endpoint exists but must be triggered manually by an admin.
|
||||
|
||||
**Impact:** Performance degradation over time as the sessions table grows. Not directly exploitable, but expired session rows increase the surface for timing attacks on session lookups.
|
||||
|
||||
**Fix:** Add a cleanup interval on server startup:
|
||||
```js
|
||||
setInterval(() => {
|
||||
db.run("DELETE FROM sessions WHERE expires_at < datetime('now')");
|
||||
}, 6 * 60 * 60 * 1000); // every 6 hours
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Open Finding Summary
|
||||
|
||||
Prioritised list of all open findings requiring action.
|
||||
|
||||
### High Priority
|
||||
|
||||
| ID | Severity | Title | Source |
|
||||
|---|---|---|---|
|
||||
| N-4 | High | `/uploads` static directory served without authentication | New |
|
||||
|
||||
### Medium Priority
|
||||
|
||||
| ID | Severity | Title | Source |
|
||||
|---|---|---|---|
|
||||
| M-1 | Medium | No CSRF token protection | April 1 |
|
||||
| M-3 | Medium | No rate limiting on NVD API proxy | April 1 |
|
||||
| N-1 | Medium | Archer ticket audit logs missing `username` field | New |
|
||||
| N-2 | Medium | `migrate-to-1.1.js` contains hardcoded admin password | New |
|
||||
| N-3 | Medium | Compliance preview returns full server filesystem path | New |
|
||||
| N-5 | Medium | Mermaid SVG rendered via `innerHTML` without sanitization | New |
|
||||
| N-8 | Medium | No Content-Security-Policy header on main application | New |
|
||||
| M-6 | Medium | Vendor field validated before trim | April 1 |
|
||||
| M-7 | Medium | Unsanitized original filename in temp JSON | April 1 |
|
||||
| M-9 | Medium | API error messages forwarded to UI | April 1 |
|
||||
| M-10 | Medium | User data in `window.confirm` dialogs | April 1 |
|
||||
|
||||
### Low Priority
|
||||
|
||||
| ID | Severity | Title | Source |
|
||||
|---|---|---|---|
|
||||
| N-6 | Low | `SESSION_SECRET` not documented in `.env.example` | New |
|
||||
| N-7 | Low | `requireGroup` error response leaks current user group | New |
|
||||
| N-9 | Low | Expired sessions not cleaned up automatically | New |
|
||||
| L-1 | Low | Silent ROLLBACK on transaction failure | April 1 |
|
||||
| L-3 | Low | Async temp file cleanup with no error handling | April 1 |
|
||||
| L-4 | Low | IVANTI_SKIP_TLS with no startup warning | April 1 |
|
||||
| L-5 | Low | console.error in production frontend | April 1 |
|
||||
| L-6 | Low | localStorage column config lacks structural validation | April 1 |
|
||||
|
||||
---
|
||||
|
||||
## Positive Security Observations
|
||||
|
||||
Verified secure patterns that should be preserved:
|
||||
|
||||
- **SQL injection prevention** — all queries use parameterized statements throughout the entire codebase
|
||||
- **Path traversal prevention** — `sanitizePathSegment()` and `isPathWithinUploads()` consistently applied in `server.js`, `compliance.js`, and `knowledgeBase.js`
|
||||
- **Python script execution** — `spawn('python3', [SCRIPT, filePath])` with argument arrays — no shell injection
|
||||
- **File upload security** — extension allowlist + MIME prefix validation + 10 MB size limit via multer
|
||||
- **Password hashing** — bcrypt with cost factor 10 used for all password storage
|
||||
- **Session management** — 32-byte random session IDs via `crypto.randomBytes`, httpOnly cookies, 24h expiry
|
||||
- **Rate limiting** — login endpoint protected with 20 attempts per 15-minute window
|
||||
- **Audit trail** — comprehensive audit logging on all state-changing operations (with noted exceptions above)
|
||||
- **Self-modification prevention** — admin cannot demote or deactivate their own account
|
||||
- **Ownership-scoped deletion** — Standard_User can only delete resources they created
|
||||
- **Compliance linkage protection** — deletion blocked when tickets are linked to active compliance reports
|
||||
- **Temp file path validation** — `isSafeTempPath()` enforces `.json` extension and `uploads/temp/` directory
|
||||
- **Static file serving** — `dotfiles: 'deny'` and `index: false` prevent directory listing
|
||||
|
||||
---
|
||||
|
||||
## Scan Metadata
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Scan date | 2026-04-20 |
|
||||
| Scan type | Full repository static analysis |
|
||||
| Scope | `backend/`, `frontend/src/`, config files |
|
||||
| Baseline | `docs/security-audit-2026-04-01.md` |
|
||||
| Previous findings | 31 (6 Critical, 9 High, 10 Medium, 6 Low/Info) |
|
||||
| Remediated | 20 fully fixed, 2 partially fixed |
|
||||
| Still open (from baseline) | 13 |
|
||||
| New findings | 9 |
|
||||
| Total open | 22 (1 High, 11 Medium, 10 Low) |
|
||||
| Methodology | Static analysis — code review of all route handlers, middleware, helpers, and frontend components |
|
||||
154
docs/security/security-remediation-plan.md
Normal file
154
docs/security/security-remediation-plan.md
Normal file
@@ -0,0 +1,154 @@
|
||||
# Security Remediation Plan
|
||||
|
||||
Based on the External Data Handling security audit (April 2026). 17 findings total — 0 Critical, 2 High, 6 Medium, 6 Low, 3 Informational. Ordered by priority based on real-world exploitability and effort.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — Data Exposure & XSS (High Priority)
|
||||
|
||||
### 1. L-4: Authenticate /uploads static file access
|
||||
**Location:** `server.js:127`
|
||||
**Risk:** Uploaded documents (vulnerability data, compliance files) served without authentication. Anyone with the URL can access them.
|
||||
**Fix:** Replace `express.static('/uploads')` with a route handler that runs `requireAuth(db)` before streaming the file. Use `res.sendFile()` with the validated path.
|
||||
**Effort:** Small — single route change.
|
||||
|
||||
### 2. M-6: Sanitize Mermaid SVG output with DOMPurify
|
||||
**Location:** `frontend/src/components/KnowledgeBaseViewer.js:38`
|
||||
**Risk:** Mermaid renders SVG which is injected via `innerHTML`. If KB content contains malicious markup, this is a stored XSS vector.
|
||||
**Fix:** Install `dompurify`, sanitize the SVG string before assigning to `innerHTML`. Use `DOMPurify.sanitize(svgString, { USE_PROFILES: { svg: true } })`.
|
||||
**Effort:** Small — add dependency, wrap one line.
|
||||
|
||||
### 3. M-4: Strip server file paths from compliance preview response
|
||||
**Location:** `backend/routes/compliance.js:278`
|
||||
**Risk:** Full server-side file path returned to client. Helps attackers map the filesystem.
|
||||
**Fix:** Return only the filename (use `path.basename()`) instead of the full path. Or return a reference ID that maps to the file server-side.
|
||||
**Effort:** Small — one-line change.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — Deployment & Setup Hygiene
|
||||
|
||||
### 4. H-2: Add SESSION_SECRET to .env.example and setup-env.sh
|
||||
**Location:** `backend/.env.example`, `backend/setup-env.sh`
|
||||
**Risk:** Fresh deployments fail with no guidance on required env vars.
|
||||
**Fix:** Add `SESSION_SECRET=` to `.env.example` with a comment explaining it should be a random 64+ character string. Add generation logic to `setup-env.sh` (e.g., `openssl rand -hex 32`).
|
||||
**Effort:** Small.
|
||||
|
||||
### 5. I-3: Set user_group on default admin in setup.js
|
||||
**Location:** `backend/setup.js:180`
|
||||
**Risk:** Default admin created without `user_group`, potentially locked out of `requireGroup`-protected routes on fresh install.
|
||||
**Fix:** Set `user_group = 'Admin'` in the INSERT statement for the default admin user.
|
||||
**Effort:** Trivial — one column added to the INSERT.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — Error Message Sanitization (Batch)
|
||||
|
||||
### 6. L-2: Sanitize Python parser error messages
|
||||
**Location:** `backend/routes/compliance.js:284`
|
||||
**Risk:** Stack traces and server paths leaked to client when Python parser fails.
|
||||
**Fix:** Catch the error, log the full details server-side, return a generic "Compliance file parsing failed" message to the client.
|
||||
**Effort:** Small.
|
||||
|
||||
### 7. L-3: Sanitize Ivanti API error responses
|
||||
**Location:** `backend/routes/ivantiFpWorkflow.js:393`
|
||||
**Risk:** Raw Ivanti API error body forwarded to client, potentially exposing internal API details.
|
||||
**Fix:** Log the raw error server-side, return a generic "Ivanti API request failed" message to the client.
|
||||
**Effort:** Small.
|
||||
|
||||
### 8. L-6: Remove group name from requireGroup error response
|
||||
**Location:** `backend/middleware/auth.js:60`
|
||||
**Risk:** Error response leaks the user's current group name, which is minor info disclosure.
|
||||
**Fix:** Change the error message from something like "User group 'Viewer' not authorized" to "Insufficient permissions."
|
||||
**Effort:** Trivial.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4 — Security Headers
|
||||
|
||||
### 9. M-1: Add Content-Security-Policy header
|
||||
**Location:** `server.js:107-113`
|
||||
**Risk:** No CSP means no browser-side XSS mitigation layer.
|
||||
**Fix:** Add a CSP header via middleware. Start with a report-only policy to avoid breaking things, then tighten. Suggested baseline:
|
||||
```
|
||||
default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'
|
||||
```
|
||||
Note: `'unsafe-inline'` for styles is needed because the app uses inline style objects extensively. Evaluate whether `script-src 'self'` breaks anything (it shouldn't with CRA).
|
||||
**Effort:** Medium — needs testing to ensure nothing breaks.
|
||||
|
||||
### 10. M-2: Add Strict-Transport-Security (HSTS) header
|
||||
**Location:** `server.js:107-113`
|
||||
**Risk:** No HSTS means browsers don't enforce HTTPS on subsequent visits.
|
||||
**Fix:** Add `Strict-Transport-Security: max-age=31536000; includeSubDomains` header. Only apply when running behind HTTPS (check `req.secure` or a trusted proxy header). Do NOT enable if the app is accessed over plain HTTP.
|
||||
**Effort:** Small, but verify deployment is HTTPS-only first.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5 — Operational Maintenance
|
||||
|
||||
### 11. L-5: Add expired session cleanup
|
||||
**Location:** `backend/middleware/auth.js:271`
|
||||
**Risk:** Sessions table grows indefinitely. Not a security exploit, but degrades performance over time.
|
||||
**Fix:** Add a cleanup function that runs on server startup (and optionally on a setInterval) to DELETE sessions where `expires_at < CURRENT_TIMESTAMP`. Run once at boot, then every 6 hours.
|
||||
**Effort:** Small.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6 — Session Signing (Larger Effort)
|
||||
|
||||
### 12. H-1: Use SESSION_SECRET for HMAC-signed session tokens
|
||||
**Location:** `server.js:33`
|
||||
**Risk:** Session tokens are random bytes stored in DB with no signing. An attacker with DB read access can replay any session. For self-hosted SQLite, DB access already implies full compromise, so this is a defense-in-depth measure.
|
||||
**Fix:** When creating a session, generate a random token and store its HMAC (using SESSION_SECRET) in the DB. On validation, recompute the HMAC and compare. This means a DB dump alone isn't enough to forge sessions — the attacker also needs the secret.
|
||||
**Effort:** Medium — touches session creation, validation, and requires SESSION_SECRET to actually be wired in.
|
||||
|
||||
---
|
||||
|
||||
## Phase 7 — Investigate Before Changing
|
||||
|
||||
### 13. M-3: Review application/octet-stream in MIME allowlist
|
||||
**Location:** `server.js:62`
|
||||
**Risk:** Allows uploads that bypass MIME type checking. May be intentional for specific file types.
|
||||
**Action:** Check what file types are uploaded that resolve to `application/octet-stream`. If none are legitimate, remove it from the allowlist. If some are (e.g., `.db` files, binary exports), consider adding those specific MIME types instead.
|
||||
**Effort:** Investigation first, then trivial change.
|
||||
|
||||
### 14. M-5: Evaluate CORS HTTP origin policy
|
||||
**Location:** `server.js:38-40`
|
||||
**Risk:** CORS allows HTTP origins, no HTTPS enforcement.
|
||||
**Action:** Check if production runs behind a reverse proxy with HTTPS termination. If yes, the backend legitimately sees HTTP origins from the proxy. If production traffic is ever plain HTTP end-to-end, restrict CORS to HTTPS origins only.
|
||||
**Effort:** Investigation first, then small config change.
|
||||
|
||||
---
|
||||
|
||||
## Phase 8 — Low Priority / Monitor
|
||||
|
||||
### 15. L-1: Add startup warning for IVANTI_SKIP_TLS=true
|
||||
**Location:** `backend/helpers/ivantiApi.js:28`
|
||||
**Risk:** TLS validation disabled silently. Acceptable in dev, risky if accidentally left on in production.
|
||||
**Fix:** Add a `console.warn('⚠ IVANTI_SKIP_TLS is enabled — TLS certificate validation is disabled')` at startup when the flag is set.
|
||||
**Effort:** Trivial.
|
||||
|
||||
### 16. I-1: Monitor react-scripts version
|
||||
**Location:** `frontend/package.json`
|
||||
**Risk:** Build-time only, not runtime. No immediate action needed.
|
||||
**Action:** Upgrade to latest react-scripts when convenient. Consider migrating to Vite if a major frontend overhaul is planned.
|
||||
|
||||
### 17. I-2: Monitor xlsx dependency
|
||||
**Location:** `frontend/package.json`
|
||||
**Risk:** Community fork, unmaintained since 2022. Used for spreadsheet parsing.
|
||||
**Action:** Monitor for security advisories. If a vulnerability is found, evaluate alternatives (e.g., `exceljs`, `sheetjs` pro). No immediate action needed unless a CVE is published against it.
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| Phase | Items | Effort | Impact |
|
||||
|-------|-------|--------|--------|
|
||||
| 1 — Data Exposure & XSS | L-4, M-6, M-4 | Small | High |
|
||||
| 2 — Deployment Hygiene | H-2, I-3 | Small | Medium |
|
||||
| 3 — Error Sanitization | L-2, L-3, L-6 | Small | Low-Medium |
|
||||
| 4 — Security Headers | M-1, M-2 | Medium | Medium |
|
||||
| 5 — Session Cleanup | L-5 | Small | Low |
|
||||
| 6 — Session Signing | H-1 | Medium | Medium |
|
||||
| 7 — Investigate | M-3, M-5 | Investigation | TBD |
|
||||
| 8 — Monitor | L-1, I-1, I-2 | Trivial | Low |
|
||||
1078
docs/testing/run-audit-tests.sh
Executable file
1078
docs/testing/run-audit-tests.sh
Executable file
File diff suppressed because it is too large
Load Diff
270
docs/troubleshooting/bu-reassignment-check.js
Normal file
270
docs/troubleshooting/bu-reassignment-check.js
Normal file
@@ -0,0 +1,270 @@
|
||||
#!/usr/bin/env node
|
||||
// bu-reassignment-check.js — Check if disappeared findings were reassigned to a different BU
|
||||
//
|
||||
// Queries Ivanti for the specific finding IDs that are completely gone from our
|
||||
// BU-filtered results, using NO filters at all (just the finding IDs).
|
||||
// If they come back with a different BU, that confirms BU reassignment.
|
||||
//
|
||||
// Usage: node backend/scripts/bu-reassignment-check.js
|
||||
|
||||
require('dotenv').config({ path: require('path').join(__dirname, '..', '.env') });
|
||||
|
||||
const path = require('path');
|
||||
const sqlite3 = require('sqlite3').verbose();
|
||||
const { ivantiPost } = require('../helpers/ivantiApi');
|
||||
|
||||
const DB_PATH = path.join(__dirname, '..', 'cve_database.db');
|
||||
|
||||
function dbAll(db, sql, params = []) {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.all(sql, params, (err, rows) => {
|
||||
if (err) reject(err);
|
||||
else resolve(rows || []);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function queryByFindingIds(findingIds, apiKey, clientId, skipTls) {
|
||||
const urlPath = `/client/${encodeURIComponent(clientId)}/hostFinding/search`;
|
||||
const allResults = [];
|
||||
|
||||
// Ivanti's IN filter can handle batches — but let's chunk to be safe
|
||||
const chunkSize = 50;
|
||||
for (let i = 0; i < findingIds.length; i += chunkSize) {
|
||||
const chunk = findingIds.slice(i, i + chunkSize);
|
||||
const idList = chunk.join(',');
|
||||
|
||||
// Query with ONLY the finding ID filter — no BU, no severity, no state
|
||||
const filters = [
|
||||
{
|
||||
field: 'id',
|
||||
exclusive: false,
|
||||
operator: 'IN',
|
||||
orWithPrevious: false,
|
||||
implicitFilters: [],
|
||||
value: idList,
|
||||
caseSensitive: false
|
||||
}
|
||||
];
|
||||
|
||||
let page = 0;
|
||||
let totalPages = 1;
|
||||
|
||||
do {
|
||||
const body = {
|
||||
filters,
|
||||
projection: 'internal',
|
||||
sort: [{ field: 'severity', direction: 'ASC' }],
|
||||
page,
|
||||
size: 100
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await ivantiPost(urlPath, body, apiKey, skipTls);
|
||||
if (result.status !== 200) {
|
||||
console.error(` API returned status ${result.status} for chunk starting at ${i}`);
|
||||
break;
|
||||
}
|
||||
|
||||
const data = JSON.parse(result.body);
|
||||
totalPages = data.page?.totalPages || 1;
|
||||
const findings = data._embedded?.hostFindings || [];
|
||||
|
||||
for (const f of findings) {
|
||||
const bu = f.assetCustomAttributes?.['1550_host_1']?.[0] || 'UNKNOWN';
|
||||
allResults.push({
|
||||
id: String(f.id),
|
||||
severity: typeof f.severity === 'number' ? f.severity : parseFloat(f.severity) || 0,
|
||||
title: f.title || '',
|
||||
hostName: f.host?.hostName || '',
|
||||
ipAddress: f.host?.ipAddress || '',
|
||||
state: f.status || f.generic_state || '',
|
||||
bu,
|
||||
// Check for FP workflow
|
||||
fpWorkflow: extractFP(f)
|
||||
});
|
||||
}
|
||||
|
||||
console.error(` Chunk ${Math.floor(i/chunkSize)+1}: page ${page+1}/${totalPages}, ${findings.length} results`);
|
||||
page++;
|
||||
} catch (err) {
|
||||
console.error(` Error querying chunk at ${i}:`, err.message);
|
||||
break;
|
||||
}
|
||||
} while (page < totalPages);
|
||||
}
|
||||
|
||||
return allResults;
|
||||
}
|
||||
|
||||
function extractFP(f) {
|
||||
const wfDist = f.workflowDistribution || {};
|
||||
const fpBuckets = [
|
||||
...(wfDist.approvedWorkflows || []),
|
||||
...(wfDist.actionableWorkflows || []),
|
||||
...(wfDist.requestedWorkflows || []),
|
||||
...(wfDist.reworkedWorkflows || []),
|
||||
...(wfDist.rejectedWorkflows || []),
|
||||
...(wfDist.expiredWorkflows || []),
|
||||
].filter(w => (w.generatedId || '').startsWith('FP#'));
|
||||
const entry = fpBuckets[0];
|
||||
if (!entry) return null;
|
||||
return { id: entry.generatedId, state: entry.state };
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const apiKey = process.env.IVANTI_API_KEY;
|
||||
const clientId = process.env.IVANTI_CLIENT_ID || '1550';
|
||||
const skipTls = process.env.IVANTI_SKIP_TLS === 'true';
|
||||
|
||||
if (!apiKey) {
|
||||
console.error('IVANTI_API_KEY not set');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const db = new sqlite3.Database(DB_PATH);
|
||||
|
||||
// Get the 124 finding IDs that were completely gone from BU-filtered results
|
||||
const goneFindings = await dbAll(db,
|
||||
`SELECT finding_id, last_severity, finding_title, current_state
|
||||
FROM ivanti_finding_archives
|
||||
WHERE current_state IN ('ARCHIVED', 'CLOSED')`
|
||||
);
|
||||
|
||||
const goneIds = goneFindings.map(f => f.finding_id);
|
||||
console.error(`\n=== BU Reassignment Check ===`);
|
||||
console.error(`Querying Ivanti for ${goneIds.length} disappeared finding IDs (no BU/severity/state filter)...\n`);
|
||||
|
||||
const results = await queryByFindingIds(goneIds, apiKey, clientId, skipTls);
|
||||
|
||||
const foundMap = new Map(results.map(r => [r.id, r]));
|
||||
|
||||
// Categorize
|
||||
const reassigned = []; // Found with different BU
|
||||
const sameBU = []; // Found with same BU (STEAM or ACCESS-ENG)
|
||||
const notFound = []; // Still not found even without filters
|
||||
const withFP = []; // Has an FP workflow (any state)
|
||||
|
||||
for (const arch of goneFindings) {
|
||||
const found = foundMap.get(arch.finding_id);
|
||||
if (!found) {
|
||||
notFound.push(arch);
|
||||
} else if (found.bu !== 'NTS-AEO-ACCESS-ENG' && found.bu !== 'NTS-AEO-STEAM') {
|
||||
reassigned.push({ ...arch, currentBU: found.bu, currentSeverity: found.severity, currentState: found.state, fp: found.fpWorkflow });
|
||||
if (found.fpWorkflow) withFP.push({ ...arch, ...found });
|
||||
} else {
|
||||
sameBU.push({ ...arch, currentBU: found.bu, currentSeverity: found.severity, currentState: found.state, fp: found.fpWorkflow });
|
||||
if (found.fpWorkflow) withFP.push({ ...arch, ...found });
|
||||
}
|
||||
}
|
||||
|
||||
console.log('');
|
||||
console.log('='.repeat(130));
|
||||
console.log('BU REASSIGNMENT CHECK RESULTS');
|
||||
console.log('='.repeat(130));
|
||||
|
||||
console.log(`\nREASSIGNED TO DIFFERENT BU: ${reassigned.length} findings`);
|
||||
console.log('-'.repeat(130));
|
||||
if (reassigned.length > 0) {
|
||||
console.log(
|
||||
'Finding ID'.padEnd(14) +
|
||||
'Archive State'.padEnd(15) +
|
||||
'Last Sev'.padEnd(10) +
|
||||
'Current Sev'.padEnd(13) +
|
||||
'Current BU'.padEnd(30) +
|
||||
'FP Workflow'.padEnd(25) +
|
||||
'Title'
|
||||
);
|
||||
console.log('-'.repeat(130));
|
||||
for (const f of reassigned) {
|
||||
const fpStr = f.fp ? `${f.fp.id} (${f.fp.state})` : '-';
|
||||
console.log(
|
||||
f.finding_id.padEnd(14) +
|
||||
f.current_state.padEnd(15) +
|
||||
f.last_severity.toFixed(2).padEnd(10) +
|
||||
f.currentSeverity.toFixed(2).padEnd(13) +
|
||||
f.currentBU.padEnd(30) +
|
||||
fpStr.padEnd(25) +
|
||||
f.finding_title.substring(0, 40)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\nSTILL SAME BU (but missing from filtered results): ${sameBU.length} findings`);
|
||||
console.log('-'.repeat(130));
|
||||
if (sameBU.length > 0) {
|
||||
console.log(
|
||||
'Finding ID'.padEnd(14) +
|
||||
'Archive State'.padEnd(15) +
|
||||
'Last Sev'.padEnd(10) +
|
||||
'Current Sev'.padEnd(13) +
|
||||
'Current BU'.padEnd(30) +
|
||||
'Current State'.padEnd(15) +
|
||||
'Title'
|
||||
);
|
||||
console.log('-'.repeat(130));
|
||||
for (const f of sameBU) {
|
||||
console.log(
|
||||
f.finding_id.padEnd(14) +
|
||||
f.current_state.padEnd(15) +
|
||||
f.last_severity.toFixed(2).padEnd(10) +
|
||||
f.currentSeverity.toFixed(2).padEnd(13) +
|
||||
f.currentBU.padEnd(30) +
|
||||
f.currentState.padEnd(15) +
|
||||
f.finding_title.substring(0, 40)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\nCOMPLETELY GONE (not found even without any filters): ${notFound.length} findings`);
|
||||
if (notFound.length > 0 && notFound.length <= 20) {
|
||||
console.log('-'.repeat(130));
|
||||
for (const f of notFound) {
|
||||
console.log(` ${f.finding_id} ${f.last_severity.toFixed(2)} ${f.finding_title.substring(0, 60)}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (withFP.length > 0) {
|
||||
console.log(`\nFINDINGS WITH FP WORKFLOWS: ${withFP.length}`);
|
||||
console.log('-'.repeat(130));
|
||||
for (const f of withFP) {
|
||||
const fpStr = f.fpWorkflow ? `${f.fpWorkflow.id} (${f.fpWorkflow.state})` : f.fp ? `${f.fp.id} (${f.fp.state})` : '-';
|
||||
console.log(` ${f.finding_id || f.id} ${fpStr} ${f.bu || f.currentBU} ${(f.finding_title || f.title || '').substring(0, 50)}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Summary
|
||||
console.log('');
|
||||
console.log('='.repeat(130));
|
||||
console.log('SUMMARY');
|
||||
console.log('='.repeat(130));
|
||||
console.log(` Total disappeared findings checked: ${goneFindings.length}`);
|
||||
console.log(` Reassigned to different BU: ${reassigned.length}`);
|
||||
console.log(` Still same BU (unexpected): ${sameBU.length}`);
|
||||
console.log(` Completely gone from platform: ${notFound.length}`);
|
||||
console.log(` Have FP workflows: ${withFP.length}`);
|
||||
|
||||
if (reassigned.length > 0) {
|
||||
const buCounts = {};
|
||||
reassigned.forEach(f => { buCounts[f.currentBU] = (buCounts[f.currentBU] || 0) + 1; });
|
||||
console.log('\n BU reassignment breakdown:');
|
||||
for (const [bu, cnt] of Object.entries(buCounts).sort((a, b) => b[1] - a[1])) {
|
||||
console.log(` ${bu}: ${cnt} findings`);
|
||||
}
|
||||
}
|
||||
|
||||
if (reassigned.length > goneFindings.length * 0.5) {
|
||||
console.log('\n VERDICT: BU REASSIGNMENT CONFIRMED.');
|
||||
} else if (notFound.length > goneFindings.length * 0.5) {
|
||||
console.log('\n VERDICT: Findings removed from platform entirely (decommission or data purge).');
|
||||
} else {
|
||||
console.log('\n VERDICT: Mixed causes — review individual categories above.');
|
||||
}
|
||||
|
||||
db.close();
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error('Fatal error:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
83
docs/troubleshooting/diagnose-chart-alignment.js
Normal file
83
docs/troubleshooting/diagnose-chart-alignment.js
Normal file
@@ -0,0 +1,83 @@
|
||||
#!/usr/bin/env node
|
||||
// Diagnostic: check alignment between counts history dates and anomaly log dates
|
||||
// Usage: node backend/scripts/diagnose-chart-alignment.js
|
||||
|
||||
const path = require('path');
|
||||
const sqlite3 = require('sqlite3').verbose();
|
||||
const DB_PATH = path.join(__dirname, '..', 'cve_database.db');
|
||||
|
||||
function dbAll(db, sql, params = []) {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.all(sql, params, (err, rows) => {
|
||||
if (err) reject(err);
|
||||
else resolve(rows || []);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function fmtDate(d) {
|
||||
if (!d) return '';
|
||||
const p = d.split('-');
|
||||
if (p.length === 3) return `${p[1]}/${p[2]}/${p[0].slice(2)}`;
|
||||
return d;
|
||||
}
|
||||
|
||||
function extractDate(ts) {
|
||||
if (!ts) return '';
|
||||
return ts.split('T')[0].split(' ')[0];
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const db = new sqlite3.Database(DB_PATH);
|
||||
|
||||
// Get counts history dates (same query as the API)
|
||||
const countsRows = await dbAll(db,
|
||||
`SELECT date FROM (
|
||||
SELECT DATE(recorded_at) AS date,
|
||||
ROW_NUMBER() OVER (PARTITION BY DATE(recorded_at) ORDER BY recorded_at DESC) AS rn
|
||||
FROM ivanti_counts_history
|
||||
) WHERE rn = 1 ORDER BY date ASC`
|
||||
);
|
||||
const countsDates = new Set(countsRows.map(r => fmtDate(r.date)));
|
||||
|
||||
// Get anomaly history (same query as the API)
|
||||
const anomalyRows = await dbAll(db,
|
||||
`SELECT sync_timestamp, newly_archived_count, returned_count, return_classification_json
|
||||
FROM ivanti_sync_anomaly_log
|
||||
ORDER BY sync_timestamp DESC LIMIT 30`
|
||||
);
|
||||
|
||||
console.log('=== Counts History Dates (last 10) ===');
|
||||
const lastTen = countsRows.slice(-10);
|
||||
for (const r of lastTen) {
|
||||
console.log(` ${r.date} → ${fmtDate(r.date)}`);
|
||||
}
|
||||
|
||||
console.log('\n=== Anomaly Log Entries with Activity ===');
|
||||
for (const a of anomalyRows) {
|
||||
if (a.newly_archived_count === 0 && a.returned_count === 0) continue;
|
||||
const rawDate = extractDate(a.sync_timestamp);
|
||||
const dateKey = fmtDate(rawDate);
|
||||
const inCounts = countsDates.has(dateKey);
|
||||
console.log(` ${a.sync_timestamp} → raw="${rawDate}" → key="${dateKey}" | archived=${a.newly_archived_count} returned=${a.returned_count} | in counts: ${inCounts ? 'YES' : '*** NO ***'}`);
|
||||
}
|
||||
|
||||
console.log('\n=== All Anomaly Dates NOT in Counts History ===');
|
||||
let missingCount = 0;
|
||||
for (const a of anomalyRows) {
|
||||
const rawDate = extractDate(a.sync_timestamp);
|
||||
const dateKey = fmtDate(rawDate);
|
||||
if (!countsDates.has(dateKey)) {
|
||||
console.log(` MISSING: ${a.sync_timestamp} → "${dateKey}" (archived=${a.newly_archived_count}, returned=${a.returned_count})`);
|
||||
missingCount++;
|
||||
}
|
||||
}
|
||||
if (missingCount === 0) console.log(' (none — all anomaly dates have matching counts history)');
|
||||
|
||||
db.close();
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error('Fatal error:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
275
docs/troubleshooting/drift-check.js
Normal file
275
docs/troubleshooting/drift-check.js
Normal file
@@ -0,0 +1,275 @@
|
||||
#!/usr/bin/env node
|
||||
// drift-check.js — One-time diagnostic to confirm host-level VRR score drift
|
||||
//
|
||||
// Queries Ivanti WITHOUT the severity filter for the same BUs, then cross-
|
||||
// references the results against our archived finding IDs to see if they
|
||||
// still exist at lower severity scores.
|
||||
//
|
||||
// Usage: node backend/scripts/drift-check.js
|
||||
//
|
||||
// Output: prints a comparison table and summary. Does NOT modify cve_database.db
|
||||
// permanently — uses a temporary in-memory table for the comparison.
|
||||
|
||||
require('dotenv').config({ path: require('path').join(__dirname, '..', '.env') });
|
||||
|
||||
const path = require('path');
|
||||
const sqlite3 = require('sqlite3').verbose();
|
||||
const { ivantiPost } = require('../helpers/ivantiApi');
|
||||
|
||||
const DB_PATH = path.join(__dirname, '..', 'cve_database.db');
|
||||
|
||||
// Same BU filter, NO severity filter, NO state filter — get everything
|
||||
const ALL_FINDINGS_FILTERS = [
|
||||
{
|
||||
field: 'assetCustomAttributes.1550_host_1.value',
|
||||
exclusive: false,
|
||||
operator: 'IN',
|
||||
orWithPrevious: false,
|
||||
implicitFilters: [],
|
||||
value: 'NTS-AEO-ACCESS-ENG,NTS-AEO-STEAM',
|
||||
caseSensitive: false
|
||||
}
|
||||
];
|
||||
|
||||
function dbAll(db, sql, params = []) {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.all(sql, params, (err, rows) => {
|
||||
if (err) reject(err);
|
||||
else resolve(rows || []);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function dbRun(db, sql, params = []) {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.run(sql, params, function (err) {
|
||||
if (err) reject(err);
|
||||
else resolve(this);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchAllFindings(apiKey, clientId, skipTls, state) {
|
||||
const urlPath = `/client/${encodeURIComponent(clientId)}/hostFinding/search`;
|
||||
const filters = [
|
||||
...ALL_FINDINGS_FILTERS,
|
||||
{
|
||||
field: 'generic_state',
|
||||
exclusive: false,
|
||||
operator: 'EXACT',
|
||||
orWithPrevious: false,
|
||||
implicitFilters: [],
|
||||
value: state,
|
||||
caseSensitive: false
|
||||
}
|
||||
];
|
||||
|
||||
let allFindings = [];
|
||||
let page = 0;
|
||||
let totalPages = 1;
|
||||
|
||||
do {
|
||||
const body = {
|
||||
filters,
|
||||
projection: 'internal',
|
||||
sort: [{ field: 'severity', direction: 'ASC' }],
|
||||
page,
|
||||
size: 100
|
||||
};
|
||||
|
||||
const result = await ivantiPost(urlPath, body, apiKey, skipTls);
|
||||
if (result.status !== 200) {
|
||||
console.error(` API returned status ${result.status} on page ${page}`);
|
||||
break;
|
||||
}
|
||||
|
||||
const data = JSON.parse(result.body);
|
||||
totalPages = data.page?.totalPages || 1;
|
||||
const findings = data._embedded?.hostFindings || [];
|
||||
|
||||
for (const f of findings) {
|
||||
allFindings.push({
|
||||
id: String(f.id),
|
||||
severity: typeof f.severity === 'number' ? f.severity : parseFloat(f.severity) || 0,
|
||||
title: f.title || '',
|
||||
hostName: f.host?.hostName || '',
|
||||
state
|
||||
});
|
||||
}
|
||||
|
||||
console.error(` ${state} page ${page + 1}/${totalPages} — ${allFindings.length} findings so far`);
|
||||
page++;
|
||||
} while (page < totalPages);
|
||||
|
||||
return allFindings;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const apiKey = process.env.IVANTI_API_KEY;
|
||||
const clientId = process.env.IVANTI_CLIENT_ID || '1550';
|
||||
const skipTls = process.env.IVANTI_SKIP_TLS === 'true';
|
||||
|
||||
if (!apiKey) {
|
||||
console.error('IVANTI_API_KEY not set in backend/.env');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.error('=== Drift Check: Querying Ivanti WITHOUT severity filter ===\n');
|
||||
|
||||
// Fetch all Open findings (no severity filter)
|
||||
console.error('Fetching ALL Open findings (no severity filter)...');
|
||||
const openFindings = await fetchAllFindings(apiKey, clientId, skipTls, 'Open');
|
||||
console.error(` Total Open (all severities): ${openFindings.length}\n`);
|
||||
|
||||
// Fetch all Closed findings (no severity filter)
|
||||
console.error('Fetching ALL Closed findings (no severity filter)...');
|
||||
const closedFindings = await fetchAllFindings(apiKey, clientId, skipTls, 'Closed');
|
||||
console.error(` Total Closed (all severities): ${closedFindings.length}\n`);
|
||||
|
||||
const allFindings = [...openFindings, ...closedFindings];
|
||||
const findingMap = new Map(allFindings.map(f => [f.id, f]));
|
||||
|
||||
console.error(`Total findings across both states: ${allFindings.length}\n`);
|
||||
|
||||
// Open the database and get archived finding IDs
|
||||
const db = new sqlite3.Database(DB_PATH);
|
||||
|
||||
const archived = await dbAll(db,
|
||||
`SELECT finding_id, last_severity, finding_title, host_name, current_state
|
||||
FROM ivanti_finding_archives
|
||||
WHERE current_state IN ('ARCHIVED', 'CLOSED')
|
||||
ORDER BY current_state, last_severity DESC`
|
||||
);
|
||||
|
||||
console.log('');
|
||||
console.log('='.repeat(120));
|
||||
console.log('DRIFT CHECK RESULTS');
|
||||
console.log('='.repeat(120));
|
||||
console.log('');
|
||||
|
||||
// Categorize results
|
||||
const drifted = []; // Found in API at lower severity (below 8.5)
|
||||
const stillHigh = []; // Found in API, severity still >= 8.5
|
||||
const gone = []; // Not found in API at all (any severity)
|
||||
const stateChanged = []; // Found but in different state
|
||||
|
||||
for (const arch of archived) {
|
||||
const current = findingMap.get(arch.finding_id);
|
||||
if (!current) {
|
||||
gone.push(arch);
|
||||
} else if (current.severity < 8.5) {
|
||||
drifted.push({ ...arch, currentSeverity: current.severity, currentState: current.state });
|
||||
} else {
|
||||
stillHigh.push({ ...arch, currentSeverity: current.severity, currentState: current.state });
|
||||
}
|
||||
}
|
||||
|
||||
// Print drifted findings
|
||||
console.log(`CONFIRMED SCORE DRIFT (now below 8.5): ${drifted.length} findings`);
|
||||
console.log('-'.repeat(120));
|
||||
if (drifted.length > 0) {
|
||||
console.log(
|
||||
'Finding ID'.padEnd(14) +
|
||||
'Archive State'.padEnd(15) +
|
||||
'Last Severity'.padEnd(15) +
|
||||
'Current Severity'.padEnd(18) +
|
||||
'Delta'.padEnd(10) +
|
||||
'Current State'.padEnd(15) +
|
||||
'Title'
|
||||
);
|
||||
console.log('-'.repeat(120));
|
||||
for (const f of drifted) {
|
||||
const delta = (f.currentSeverity - f.last_severity).toFixed(2);
|
||||
console.log(
|
||||
f.finding_id.padEnd(14) +
|
||||
f.current_state.padEnd(15) +
|
||||
f.last_severity.toFixed(2).padEnd(15) +
|
||||
f.currentSeverity.toFixed(2).padEnd(18) +
|
||||
delta.padEnd(10) +
|
||||
f.currentState.padEnd(15) +
|
||||
f.finding_title.substring(0, 50)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('');
|
||||
console.log(`STILL HIGH SEVERITY (>= 8.5, should be in filtered results): ${stillHigh.length} findings`);
|
||||
console.log('-'.repeat(120));
|
||||
if (stillHigh.length > 0) {
|
||||
console.log(
|
||||
'Finding ID'.padEnd(14) +
|
||||
'Archive State'.padEnd(15) +
|
||||
'Last Severity'.padEnd(15) +
|
||||
'Current Severity'.padEnd(18) +
|
||||
'Current State'.padEnd(15) +
|
||||
'Title'
|
||||
);
|
||||
console.log('-'.repeat(120));
|
||||
for (const f of stillHigh) {
|
||||
console.log(
|
||||
f.finding_id.padEnd(14) +
|
||||
f.current_state.padEnd(15) +
|
||||
f.last_severity.toFixed(2).padEnd(15) +
|
||||
f.currentSeverity.toFixed(2).padEnd(18) +
|
||||
f.currentState.padEnd(15) +
|
||||
f.finding_title.substring(0, 50)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('');
|
||||
console.log(`COMPLETELY GONE (not in API at any severity): ${gone.length} findings`);
|
||||
console.log('-'.repeat(120));
|
||||
if (gone.length > 0) {
|
||||
console.log(
|
||||
'Finding ID'.padEnd(14) +
|
||||
'Archive State'.padEnd(15) +
|
||||
'Last Severity'.padEnd(15) +
|
||||
'Title'
|
||||
);
|
||||
console.log('-'.repeat(120));
|
||||
for (const f of gone) {
|
||||
console.log(
|
||||
f.finding_id.padEnd(14) +
|
||||
f.current_state.padEnd(15) +
|
||||
f.last_severity.toFixed(2).padEnd(15) +
|
||||
f.finding_title.substring(0, 50)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Summary
|
||||
console.log('');
|
||||
console.log('='.repeat(120));
|
||||
console.log('SUMMARY');
|
||||
console.log('='.repeat(120));
|
||||
console.log(` Archived/Closed findings checked: ${archived.length}`);
|
||||
console.log(` Confirmed score drift (< 8.5): ${drifted.length}`);
|
||||
console.log(` Still high severity (>= 8.5): ${stillHigh.length}`);
|
||||
console.log(` Completely gone from API: ${gone.length}`);
|
||||
console.log('');
|
||||
|
||||
if (drifted.length > 0) {
|
||||
const avgDelta = drifted.reduce((sum, f) => sum + (f.currentSeverity - f.last_severity), 0) / drifted.length;
|
||||
const minNew = Math.min(...drifted.map(f => f.currentSeverity));
|
||||
const maxNew = Math.max(...drifted.map(f => f.currentSeverity));
|
||||
console.log(` Score drift range: ${minNew.toFixed(2)} – ${maxNew.toFixed(2)} (avg delta: ${avgDelta.toFixed(2)})`);
|
||||
}
|
||||
|
||||
if (drifted.length > archived.length * 0.5) {
|
||||
console.log('\n VERDICT: Host-level VRR score drift CONFIRMED.');
|
||||
console.log(' The majority of disappeared findings still exist in Ivanti but at lower severity scores.');
|
||||
} else if (drifted.length > 0) {
|
||||
console.log('\n VERDICT: Partial score drift detected. Some findings drifted, others may have been removed.');
|
||||
} else if (gone.length > archived.length * 0.5) {
|
||||
console.log('\n VERDICT: Score drift NOT confirmed. Most findings are completely gone from the API.');
|
||||
console.log(' This suggests BU reassignment, host decommission, or a platform-side data issue.');
|
||||
}
|
||||
|
||||
db.close();
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error('Fatal error:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
197
docs/troubleshooting/export-reassigned-findings.js
Normal file
197
docs/troubleshooting/export-reassigned-findings.js
Normal file
@@ -0,0 +1,197 @@
|
||||
#!/usr/bin/env node
|
||||
// export-reassigned-findings.js — Generate an xlsx with findings reassigned to SDIT-CSD-ITLS-PIES
|
||||
//
|
||||
// Pulls data from the archive database and the BU reassignment check results.
|
||||
// Outputs to docs/reassigned-findings-2026-04-24.xlsx
|
||||
//
|
||||
// Usage: node backend/scripts/export-reassigned-findings.js
|
||||
|
||||
require('dotenv').config({ path: require('path').join(__dirname, '..', '.env') });
|
||||
|
||||
const path = require('path');
|
||||
const sqlite3 = require('sqlite3').verbose();
|
||||
const XLSX = require('xlsx');
|
||||
const { ivantiPost } = require('../helpers/ivantiApi');
|
||||
|
||||
const DB_PATH = path.join(__dirname, '..', 'cve_database.db');
|
||||
const OUTPUT_PATH = path.join(__dirname, '..', '..', 'docs', 'reassigned-findings-2026-04-24.xlsx');
|
||||
|
||||
function dbAll(db, sql, params = []) {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.all(sql, params, (err, rows) => {
|
||||
if (err) reject(err);
|
||||
else resolve(rows || []);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function queryByFindingIds(findingIds, apiKey, clientId, skipTls) {
|
||||
const urlPath = `/client/${encodeURIComponent(clientId)}/hostFinding/search`;
|
||||
const results = new Map();
|
||||
const chunkSize = 50;
|
||||
|
||||
for (let i = 0; i < findingIds.length; i += chunkSize) {
|
||||
const chunk = findingIds.slice(i, i + chunkSize);
|
||||
const idList = chunk.join(',');
|
||||
const filters = [{
|
||||
field: 'id', exclusive: false, operator: 'IN',
|
||||
orWithPrevious: false, implicitFilters: [],
|
||||
value: idList, caseSensitive: false
|
||||
}];
|
||||
|
||||
let page = 0;
|
||||
let totalPages = 1;
|
||||
do {
|
||||
try {
|
||||
const body = { filters, projection: 'internal', sort: [{ field: 'severity', direction: 'ASC' }], page, size: 100 };
|
||||
const result = await ivantiPost(urlPath, body, apiKey, skipTls);
|
||||
if (result.status !== 200) break;
|
||||
const data = JSON.parse(result.body);
|
||||
totalPages = data.page?.totalPages || 1;
|
||||
const findings = data._embedded?.hostFindings || [];
|
||||
for (const f of findings) {
|
||||
const bu = f.assetCustomAttributes?.['1550_host_1']?.[0] || 'UNKNOWN';
|
||||
const wfDist = f.workflowDistribution || {};
|
||||
const fpBuckets = [
|
||||
...(wfDist.approvedWorkflows || []), ...(wfDist.actionableWorkflows || []),
|
||||
...(wfDist.requestedWorkflows || []), ...(wfDist.reworkedWorkflows || []),
|
||||
...(wfDist.rejectedWorkflows || []), ...(wfDist.expiredWorkflows || []),
|
||||
].filter(w => (w.generatedId || '').startsWith('FP#'));
|
||||
const fp = fpBuckets[0] || null;
|
||||
results.set(String(f.id), {
|
||||
bu,
|
||||
severity: typeof f.severity === 'number' ? f.severity : parseFloat(f.severity) || 0,
|
||||
state: f.status || '',
|
||||
fpId: fp ? fp.generatedId : '',
|
||||
fpState: fp ? fp.state : '',
|
||||
hostName: f.host?.hostName || '',
|
||||
ipAddress: f.host?.ipAddress || '',
|
||||
title: f.title || '',
|
||||
});
|
||||
}
|
||||
page++;
|
||||
} catch (err) {
|
||||
console.error(` Error on batch at ${i}:`, err.message);
|
||||
break;
|
||||
}
|
||||
} while (page < totalPages);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const apiKey = process.env.IVANTI_API_KEY;
|
||||
const clientId = process.env.IVANTI_CLIENT_ID || '1550';
|
||||
const skipTls = process.env.IVANTI_SKIP_TLS === 'true';
|
||||
|
||||
const db = new sqlite3.Database(DB_PATH);
|
||||
|
||||
// Get all archived/closed findings from the archive
|
||||
const archived = await dbAll(db,
|
||||
`SELECT finding_id, last_severity, finding_title, host_name, ip_address, current_state,
|
||||
DATE(first_archived_at) as archived_date, DATE(last_transition_at) as last_transition_date
|
||||
FROM ivanti_finding_archives
|
||||
WHERE current_state IN ('ARCHIVED', 'CLOSED')
|
||||
ORDER BY current_state, last_severity DESC`
|
||||
);
|
||||
|
||||
const ids = archived.map(a => a.finding_id);
|
||||
console.log(`Querying Ivanti for ${ids.length} findings...`);
|
||||
const currentData = await queryByFindingIds(ids, apiKey, clientId, skipTls);
|
||||
|
||||
// Build rows for each sheet
|
||||
const reassignedRows = [];
|
||||
const goneRows = [];
|
||||
const sameBuRows = [];
|
||||
|
||||
for (const arch of archived) {
|
||||
const current = currentData.get(arch.finding_id);
|
||||
|
||||
if (!current) {
|
||||
goneRows.push({
|
||||
'Finding ID': arch.finding_id,
|
||||
'Title': arch.finding_title,
|
||||
'Last Severity': arch.last_severity,
|
||||
'Host': arch.host_name,
|
||||
'IP Address': arch.ip_address,
|
||||
'Archive State': arch.current_state,
|
||||
'Archived Date': arch.archived_date,
|
||||
'Status': 'Gone from platform',
|
||||
});
|
||||
} else if (current.bu !== 'NTS-AEO-ACCESS-ENG' && current.bu !== 'NTS-AEO-STEAM') {
|
||||
reassignedRows.push({
|
||||
'Finding ID': arch.finding_id,
|
||||
'Title': current.title || arch.finding_title,
|
||||
'Last Severity (STEAM)': arch.last_severity,
|
||||
'Current Severity': current.severity,
|
||||
'Host': current.hostName || arch.host_name,
|
||||
'IP Address': current.ipAddress || arch.ip_address,
|
||||
'Previous BU': 'NTS-AEO-STEAM / ACCESS-ENG',
|
||||
'Current BU': current.bu,
|
||||
'Current State': current.state,
|
||||
'FP Workflow': current.fpId ? `${current.fpId} (${current.fpState})` : '',
|
||||
'Archive State': arch.current_state,
|
||||
'Archived Date': arch.archived_date,
|
||||
});
|
||||
} else {
|
||||
sameBuRows.push({
|
||||
'Finding ID': arch.finding_id,
|
||||
'Title': current.title || arch.finding_title,
|
||||
'Severity': current.severity,
|
||||
'Host': current.hostName || arch.host_name,
|
||||
'IP Address': current.ipAddress || arch.ip_address,
|
||||
'BU': current.bu,
|
||||
'Current State': current.state,
|
||||
'FP Workflow': current.fpId ? `${current.fpId} (${current.fpState})` : '',
|
||||
'Archive State': arch.current_state,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Create workbook
|
||||
const wb = XLSX.utils.book_new();
|
||||
|
||||
// Sheet 1: Reassigned findings
|
||||
const ws1 = XLSX.utils.json_to_sheet(reassignedRows);
|
||||
// Set column widths
|
||||
ws1['!cols'] = [
|
||||
{ wch: 14 }, { wch: 55 }, { wch: 18 }, { wch: 16 },
|
||||
{ wch: 30 }, { wch: 16 }, { wch: 28 }, { wch: 24 },
|
||||
{ wch: 14 }, { wch: 24 }, { wch: 14 }, { wch: 14 },
|
||||
];
|
||||
XLSX.utils.book_append_sheet(wb, ws1, 'Reassigned to SDIT-PIES');
|
||||
|
||||
// Sheet 2: Gone from platform
|
||||
if (goneRows.length > 0) {
|
||||
const ws2 = XLSX.utils.json_to_sheet(goneRows);
|
||||
ws2['!cols'] = [
|
||||
{ wch: 14 }, { wch: 55 }, { wch: 14 }, { wch: 30 },
|
||||
{ wch: 16 }, { wch: 14 }, { wch: 14 }, { wch: 20 },
|
||||
];
|
||||
XLSX.utils.book_append_sheet(wb, ws2, 'Gone from Platform');
|
||||
}
|
||||
|
||||
// Sheet 3: Still same BU
|
||||
if (sameBuRows.length > 0) {
|
||||
const ws3 = XLSX.utils.json_to_sheet(sameBuRows);
|
||||
ws3['!cols'] = [
|
||||
{ wch: 14 }, { wch: 55 }, { wch: 10 }, { wch: 30 },
|
||||
{ wch: 16 }, { wch: 24 }, { wch: 14 }, { wch: 24 }, { wch: 14 },
|
||||
];
|
||||
XLSX.utils.book_append_sheet(wb, ws3, 'Still Same BU');
|
||||
}
|
||||
|
||||
// Write file
|
||||
XLSX.writeFile(wb, OUTPUT_PATH);
|
||||
console.log(`\nExported to: ${OUTPUT_PATH}`);
|
||||
console.log(` Reassigned: ${reassignedRows.length}`);
|
||||
console.log(` Gone: ${goneRows.length}`);
|
||||
console.log(` Same BU: ${sameBuRows.length}`);
|
||||
|
||||
db.close();
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error('Fatal error:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
420
docs/troubleshooting/findings-count-investigation-2026-04-24.md
Normal file
420
docs/troubleshooting/findings-count-investigation-2026-04-24.md
Normal file
@@ -0,0 +1,420 @@
|
||||
# Findings Count Drop Investigation — 2026-04-24
|
||||
|
||||
## Summary
|
||||
|
||||
On 2026-04-24, the Findings Trend chart showed a sharp drop in both open and closed counts. The total (open + closed) fell from ~170 to 31, which is inconsistent with normal finding lifecycle behavior where findings move between open and closed but the total remains roughly stable.
|
||||
|
||||
---
|
||||
|
||||
## Timeline
|
||||
|
||||
| Date | Open | Closed | Total | Notes |
|
||||
|---|---|---|---|---|
|
||||
| 04/02 | 127 | 52 | 179 | Baseline |
|
||||
| 04/11 | 116 | 51 | 167 | Normal fluctuation |
|
||||
| 04/19 | 114 | 50 | 164 | Normal fluctuation |
|
||||
| 04/20 | 86 | 84 | 170 | Batch of findings closed — total stable |
|
||||
| 04/23 | 60 | 110 | 170 | Continued closure — total stable |
|
||||
| 04/24 | 15 | 16 | 31 | Anomalous drop |
|
||||
|
||||
The 04/20 and 04/23 snapshots show the expected pattern: open decreases, closed increases, total stays at ~170. The 04/24 snapshot breaks this pattern — both open and closed dropped simultaneously.
|
||||
|
||||
---
|
||||
|
||||
## Root Cause Analysis
|
||||
|
||||
### What the dashboard queries
|
||||
|
||||
The Ivanti sync fetches findings using two API calls, both filtered to:
|
||||
|
||||
- **BU:** `NTS-AEO-ACCESS-ENG`, `NTS-AEO-STEAM`
|
||||
- **Severity range:** `8.5–9.9` VRR
|
||||
- **State:** `Open` (first call) or `Closed` (second call)
|
||||
|
||||
Any finding that no longer matches all three criteria will not appear in the results.
|
||||
|
||||
### What happened
|
||||
|
||||
A re-test of the Ivanti API on 04/24 confirmed the API itself is returning only 15 open and 16 closed findings (`totalElements` field). This is not a pagination bug or partial response — the API is reporting these as the complete result sets.
|
||||
|
||||
### Likely explanation: VRR rescore
|
||||
|
||||
The most probable cause is a bulk VRR (Vulnerability Risk Rating) rescore on the Ivanti platform. If Ivanti recalculated severity scores and a large number of findings dropped below the `8.5` threshold, they would vanish from both the open and closed query results.
|
||||
|
||||
**Key detail:** The archive table stores `last_severity` — the score at the time the finding was last seen in our sync, not the current score in Ivanti. Archived findings show severities of 9.0–9.9, but this reflects their pre-rescore values. After a rescore, these same findings could now be rated below 8.5, which is why they no longer appear in our filtered queries.
|
||||
|
||||
This explains why:
|
||||
|
||||
- **Open findings dropped** from 60 to 15 — rescored findings fell below 8.5
|
||||
- **Closed findings dropped** from 110 to 16 — the same rescore affected closed findings too
|
||||
- **Archive caught 67 disappearances** from the open set, but did not previously track disappearances from the closed set
|
||||
|
||||
### Alternative explanations
|
||||
|
||||
- **BU reassignment:** Findings moved out of `NTS-AEO-ACCESS-ENG` or `NTS-AEO-STEAM` would also disappear. Less likely at this scale.
|
||||
- **Ivanti platform issue:** Temporary data availability problem. Can be ruled out if the counts remain low on subsequent syncs.
|
||||
- **Finding decommission:** Hosts removed from Ivanti entirely. Possible for some findings but unlikely for ~140 at once.
|
||||
|
||||
---
|
||||
|
||||
## Accounting
|
||||
|
||||
As of 04/24:
|
||||
|
||||
| Category | Count | Description |
|
||||
|---|---|---|
|
||||
| Open (API) | 15 | Currently in Ivanti open set, severity 8.5–9.9 |
|
||||
| Closed (API) | 16 | Currently in Ivanti closed set, severity 8.5–9.9 |
|
||||
| Archived | 67 | Disappeared from open set, not found in closed set |
|
||||
| Archive-Closed | 63 | Were archived, then confirmed in Ivanti closed set |
|
||||
| Returned | 1 | Was archived, then reappeared in open set |
|
||||
| **Tracked total** | **162** | |
|
||||
| **Expected total** | **~170** | |
|
||||
| **Unaccounted** | **~8** | Normal churn (decommissions, new findings offsetting) |
|
||||
|
||||
The 63 archive-closed findings were previously part of the ~110 closed count on 04/23. They have since disappeared from the closed API results (likely rescored below 8.5). Before this fix, disappearances from the closed set were not tracked.
|
||||
|
||||
---
|
||||
|
||||
## Fixes Applied
|
||||
|
||||
### 1. Bad data point removed
|
||||
|
||||
The 04/24 history row (15 open / 16 closed) was deleted from `ivanti_counts_history` to prevent it from skewing the trend chart.
|
||||
|
||||
### 2. Drift guard added
|
||||
|
||||
Before writing to `ivanti_counts_history`, the sync now compares the new total (open + closed) against the most recent history entry. If the new total drops below 50% of the previous total, the history write is skipped and a warning is logged. The live cache (`ivanti_counts_cache`) is still updated so current counts remain accurate.
|
||||
|
||||
### 3. Closed-set disappearance tracking (CLOSED_GONE)
|
||||
|
||||
A new archive state `CLOSED_GONE` was added. On each sync, findings previously marked as `CLOSED` in the archive are checked against the current closed API results. If a finding is no longer in the closed set, it transitions to `CLOSED_GONE` with reason `disappeared_from_closed_set`. This closes the visibility gap where findings could vanish from the closed API results without being tracked.
|
||||
|
||||
**Migration required:** `node backend/migrations/add_closed_gone_state.js`
|
||||
|
||||
### Archive state machine (updated)
|
||||
|
||||
```
|
||||
NONE ──→ ARCHIVED ──→ RETURNED ──→ ARCHIVED (cycle)
|
||||
│ │
|
||||
▼ ▼
|
||||
CLOSED ──→ CLOSED_GONE
|
||||
```
|
||||
|
||||
| State | Meaning |
|
||||
|---|---|
|
||||
| `ARCHIVED` | Disappeared from the open findings set; not found in closed set |
|
||||
| `RETURNED` | Was archived but reappeared in the open set |
|
||||
| `CLOSED` | Confirmed present in the Ivanti closed findings set |
|
||||
| `CLOSED_GONE` | Was confirmed closed, then disappeared from the closed set |
|
||||
|
||||
### 4. Automated sync anomaly detection
|
||||
|
||||
The manual diagnostic work from this investigation was formalized into an automated feature in the sync pipeline (`backend/routes/ivantiFindings.js`). After each sync, the system now:
|
||||
|
||||
- **Classifies disappearances** — queries Ivanti without BU/severity filters for newly archived finding IDs and labels each as `bu_reassignment`, `severity_drift`, `closed_on_platform`, or `decommissioned`. The classification is stored on the archive transition record, replacing the generic `severity_score_drift` default.
|
||||
- **Logs anomaly summaries** — writes a breakdown of count changes to `ivanti_sync_anomaly_log` after each sync, flagging syncs where more than 5 findings are archived as significant.
|
||||
- **Tracks BU changes per finding** — compares each finding's BU against the previous sync and records changes in `ivanti_finding_bu_history`.
|
||||
- **Surfaces anomalies in the UI** — an amber warning banner on the Vulnerability Triage page displays the latest anomaly summary when a significant count change is detected.
|
||||
|
||||
API endpoints for anomaly data: `GET /api/ivanti/findings/anomaly/latest`, `GET /api/ivanti/findings/anomaly/history`, `GET /api/ivanti/findings/bu-changes`, `GET /api/ivanti/findings/:findingId/bu-history`.
|
||||
|
||||
**Migration required:** `node backend/migrations/add_sync_anomaly_tables.js`
|
||||
|
||||
---
|
||||
|
||||
## Recommended Follow-Up
|
||||
|
||||
1. **Check with Ivanti platform team** whether a bulk VRR rescore occurred around 04/23–04/24.
|
||||
2. **Monitor the next few syncs** to see if counts stabilize at the new level or recover.
|
||||
3. **Consider querying without the severity filter** as a one-time diagnostic to see the true total of findings across all severities for the two BUs. This would confirm whether the findings still exist at lower severity scores.
|
||||
|
||||
---
|
||||
|
||||
## Appendix: Cached Data Analysis
|
||||
|
||||
A cross-reference of the 04/22 findings export against the current cached data and archive was performed to test the score drift hypothesis.
|
||||
|
||||
### Export reconciliation (04/22 export — ~80 open findings)
|
||||
|
||||
| Current status | Count |
|
||||
|---|---|
|
||||
| Still open in API | 8 |
|
||||
| Archived (disappeared from open set) | 44 |
|
||||
| Closed (confirmed in Ivanti closed set) | 26 |
|
||||
| Untracked | 0 |
|
||||
| **Total** | **78** |
|
||||
|
||||
Every finding from the export is accounted for. Zero findings are untracked.
|
||||
|
||||
### What disappeared on 04/24 (43 findings archived that day)
|
||||
|
||||
| Vulnerability | Count | Last-seen severity |
|
||||
|---|---|---|
|
||||
| OpenSSH regreSSHion (CVE-2024-6387) | 36 | 9.38 |
|
||||
| OpenSSH Multiple Security Vulnerabilities | 3 | 9.9 |
|
||||
| Rocky Linux sudo update (RLSA-2025:9978) | 2 | 9.06 |
|
||||
| Rocky Linux sqlite update (RLSA-2025:20936) | 1 | 9.9 |
|
||||
| Rocky Linux sqlite update (RLSA-2025:11992) | 1 | 9.9 |
|
||||
|
||||
### What survived (15 findings still in API)
|
||||
|
||||
| Vulnerability | Count | Current severity |
|
||||
|---|---|---|
|
||||
| OpenSSH Multiple Security Vulnerabilities | 9 | 9.9 |
|
||||
| OpenSSH regreSSHion (CVE-2024-6387) | 4 | 9.38 |
|
||||
| OpenSSH 7.4 Not Installed Multiple Vulnerabilities | 1 | 9.18 |
|
||||
| Rocky Linux sudo update (RLSA-2025:9978) | 1 | 9.06 |
|
||||
|
||||
### Conclusion: host-level VRR drift
|
||||
|
||||
The pattern is consistent with **host-level VRR score drift**, not a blanket CVE rescore. Key evidence:
|
||||
|
||||
- **Selective disappearance within the same CVE:** 36 of 40 regreSSHion findings disappeared, but 4 survived at the same last-seen severity (9.38). If the CVE itself were rescored, all would be affected equally.
|
||||
- **Same pattern for OpenSSH Multiple:** 3 of 12 disappeared at 9.9, while 9 survived at 9.9.
|
||||
- **High last-seen severities:** All disappeared findings had severities well above the 8.5 threshold (9.06–9.9), but `last_severity` reflects the score at time of last sync, not the current Ivanti score. A host-level rescore could move individual findings below 8.5 while leaving others on different hosts unchanged.
|
||||
|
||||
Ivanti calculates VRR per host-finding combination using factors like network exposure, asset criticality, and compensating controls. A platform-side recalculation of these host-level factors would produce exactly this pattern — some hosts for the same CVE drop below threshold while others remain above it.
|
||||
|
||||
**To fully confirm:** Query Ivanti without the severity filter for the disappeared finding IDs and check their current VRR scores. If they now show scores below 8.5, host-level drift is confirmed.
|
||||
|
||||
---
|
||||
|
||||
## Appendix B: Unfiltered API Query Results (04/24)
|
||||
|
||||
A follow-up diagnostic queried Ivanti **without the severity filter** to check whether the disappeared findings still exist at lower severity scores.
|
||||
|
||||
### Unfiltered totals
|
||||
|
||||
| State | Count (no severity filter) | Count (8.5–9.9 filter) |
|
||||
|---|---|---|
|
||||
| Open | 1,404 | 15 |
|
||||
| Closed | 280 | 16 |
|
||||
| **Total** | **1,684** | **31** |
|
||||
|
||||
The BUs have 1,684 total findings across all severities. The severity filter narrows this to 31.
|
||||
|
||||
### Cross-reference against 130 archived/closed findings
|
||||
|
||||
| Category | Count | Meaning |
|
||||
|---|---|---|
|
||||
| Completely gone from API | 124 | Not in Ivanti at any severity, open or closed |
|
||||
| Confirmed score drift | 1 | Juniper finding dropped from 9.0 to 7.57 |
|
||||
| Still high severity (>= 8.5) | 5 | Still in Ivanti closed set at original scores |
|
||||
|
||||
### Verdict: Score drift hypothesis DISPROVED
|
||||
|
||||
Only 1 of 130 findings actually drifted below the severity threshold. **124 findings are completely absent from the Ivanti API at any severity in any state.** They were not rescored — they were removed from the platform entirely.
|
||||
|
||||
This rules out VRR score drift as the primary cause and points to one of:
|
||||
|
||||
- **Host decommission / asset removal** — the hosts were removed from Ivanti's asset inventory
|
||||
- **BU reassignment** — the hosts were moved out of `NTS-AEO-ACCESS-ENG` / `NTS-AEO-STEAM` to a different business unit
|
||||
- **Platform-side data cleanup** — findings were purged or merged on the Ivanti side
|
||||
|
||||
Given the scale (124 findings disappearing simultaneously), a bulk operation on the Ivanti platform is the most likely explanation. This should be raised with the Ivanti platform administrators to determine what changed.
|
||||
|
||||
### Diagnostic script
|
||||
|
||||
The unfiltered query was originally performed using `backend/scripts/drift-check.js`. This logic has since been automated by the sync anomaly detection feature — the BU drift checker in `backend/routes/ivantiFindings.js` now runs these checks automatically after each sync. See the anomaly API endpoints (`/api/ivanti/findings/anomaly/latest`, `/api/ivanti/findings/bu-changes`) for current data.
|
||||
|
||||
---
|
||||
|
||||
## Appendix C: BU Reassignment Confirmation (04/24)
|
||||
|
||||
A follow-up query searched for the disappeared finding IDs with **no filters at all** (no BU, no severity, no state) to determine whether the findings still exist in Ivanti under a different business unit.
|
||||
|
||||
### Results
|
||||
|
||||
| Category | Count | Detail |
|
||||
|---|---|---|
|
||||
| Reassigned to `SDIT-CSD-ITLS-PIES` | 109 | Hosts moved to different BU |
|
||||
| Still same BU (STEAM/ACCESS-ENG) | 6 | 5 closed (timing), 1 severity drift (7.57) |
|
||||
| Completely gone from platform | 15 | Not found at any BU, severity, or state |
|
||||
|
||||
### Verdict: BU REASSIGNMENT CONFIRMED
|
||||
|
||||
**109 of 130 disappeared findings were reassigned from `NTS-AEO-STEAM` / `NTS-AEO-ACCESS-ENG` to `SDIT-CSD-ITLS-PIES`.** The severity scores are unchanged — the findings still exist at 9.38 and 9.9 — but they no longer match the dashboard's BU filter.
|
||||
|
||||
This is not score drift, not a platform bug, and not a data purge. It is a deliberate (or accidental) bulk BU reassignment on the Ivanti platform.
|
||||
|
||||
### FP workflow impact
|
||||
|
||||
69 of the 109 reassigned findings have FP workflows attached, predominantly `FP#0000459 (Approved)`. These are false positive approvals that were submitted by the STEAM/ACCESS-ENG team. The FP workflows followed the findings to the new BU. This should be reviewed with the team that performed the reassignment to determine whether the FP approvals are still valid under the new BU context.
|
||||
|
||||
### 15 truly gone findings
|
||||
|
||||
15 findings are not found in Ivanti at any BU, severity, or state. These are likely decommissioned hosts. All 15 are `OpenSSH Remote Unauthenticated Code Execution Vulnerability (regreSSHion)` at severity 9.30–9.38.
|
||||
|
||||
### Reassigned findings — 109 findings moved to `SDIT-CSD-ITLS-PIES`
|
||||
|
||||
**With approved FP workflows (58 findings):**
|
||||
|
||||
| Finding ID | Severity | FP Workflow | Host | IP Address |
|
||||
|---|---|---|---|---|
|
||||
| `2687687777` | 9.38 | FP#0000459 (Approved) | syn-098-120-000-078 | 98.120.0.78 |
|
||||
| `2687714078` | 9.38 | FP#0000459 (Approved) | syn-098-120-032-185 | 98.120.32.185 |
|
||||
| `2561784254` | 9.38 | FP#0000459 (Approved) | mon15-agg-sw | 10.240.78.177 |
|
||||
| `2561788625` | 9.38 | FP#0000459 (Approved) | mon16-agg-sw | 10.240.78.176 |
|
||||
| `2689641701` | 9.38 | FP#0000459 (Approved) | mon15-sw14 | 10.240.78.133 |
|
||||
| `2689642036` | 9.38 | FP#0000459 (Approved) | mon15-sw11 | 10.240.78.130 |
|
||||
| `2689642107` | 9.38 | FP#0000459 (Approved) | mon19-sw3 | 10.240.78.150 |
|
||||
| `2689642299` | 9.38 | FP#0000459 (Approved) | mon16-sw2 | 10.240.78.107 |
|
||||
| `2689643552` | 9.38 | FP#0000459 (Approved) | mon16-sw5 | 10.240.78.110 |
|
||||
| `2689645817` | 9.38 | FP#0000459 (Approved) | mon16-sw1 | 10.240.78.106 |
|
||||
| `2689646279` | 9.38 | FP#0000459 (Approved) | mon19-sw2 | 10.240.78.149 |
|
||||
| `2689647223` | 9.38 | FP#0000459 (Approved) | mon19-sw7 | 10.240.78.154 |
|
||||
| `2689647732` | 9.38 | FP#0000459 (Approved) | mon16-sw6 | 10.240.78.111 |
|
||||
| `2689662078` | 9.38 | FP#0000459 (Approved) | mon19-sw6 | 10.240.78.153 |
|
||||
| `2689662169` | 9.38 | FP#0000459 (Approved) | mon15-sw13 | 10.240.78.132 |
|
||||
| `2689667727` | 9.38 | FP#0000459 (Approved) | mon16-sw10 | 10.240.78.115 |
|
||||
| `2689674347` | 9.38 | FP#0000459 (Approved) | mon16-sw4 | 10.240.78.109 |
|
||||
| `2689680179` | 9.38 | FP#0000459 (Approved) | mon16-sw7 | 10.240.78.112 |
|
||||
| `2689687694` | 9.38 | FP#0000459 (Approved) | mon16-sw14 | 10.240.78.119 |
|
||||
| `2689703211` | 9.38 | FP#0000459 (Approved) | mon16-sw9 | 10.240.78.114 |
|
||||
| `2689704574` | 9.38 | FP#0000459 (Approved) | mon16-sw13 | 10.240.78.118 |
|
||||
| `2689707099` | 9.38 | FP#0000459 (Approved) | mon16-sw12 | 10.240.78.117 |
|
||||
| `2689711822` | 9.38 | FP#0000459 (Approved) | mon16-sw3 | 10.240.78.108 |
|
||||
| `2689712725` | 9.38 | FP#0000459 (Approved) | mon19-sw8 | 10.240.78.155 |
|
||||
| `2689715642` | 9.38 | FP#0000459 (Approved) | mon19-sw10 | 10.240.78.157 |
|
||||
| `2689717728` | 9.38 | FP#0000459 (Approved) | mon19-sw4 | 10.240.78.151 |
|
||||
| `2689721708` | 9.38 | FP#0000459 (Approved) | mon16-sw11 | 10.240.78.116 |
|
||||
| `2689722995` | 9.38 | FP#0000459 (Approved) | mon19-sw5 | 10.240.78.152 |
|
||||
| `2689723147` | 9.38 | FP#0000459 (Approved) | mon19-sw14 | 10.240.78.161 |
|
||||
| `2689723478` | 9.38 | FP#0000459 (Approved) | mon19-sw13 | 10.240.78.160 |
|
||||
| `2689723840` | 9.38 | FP#0000459 (Approved) | mon19-sw12 | 10.240.78.159 |
|
||||
| `2697106042` | 9.38 | FP#0000459 (Approved) | mon19-sw11 | 10.240.78.158 |
|
||||
| `2697107537` | 9.38 | FP#0000459 (Approved) | mon15-sw4 | 10.240.78.123 |
|
||||
| `2697108314` | 9.38 | FP#0000459 (Approved) | mon20-sw4 | 10.240.78.137 |
|
||||
| `2726771499` | 9.38 | FP#0000459 (Approved) | mon19-sw1 | 10.240.78.148 |
|
||||
| `2726805076` | 9.38 | FP#0000459 (Approved) | mon15-sw6 | 10.240.78.125 |
|
||||
| `2726863413` | 9.38 | FP#0000459 (Approved) | mon19-sw9 | 10.240.78.156 |
|
||||
| `2283414173` | 9.38 | FP#0000459 (Approved) | | 10.241.0.63 |
|
||||
| `2283664248` | 9.38 | FP#0000459 (Approved) | apc01se1shcc-n01-bmc | 10.244.11.51 |
|
||||
| `2460786621` | 9.38 | FP#0000459 (Approved) | | 172.27.72.1 |
|
||||
| `2521773008` | 9.38 | FP#0000459 (Approved) | | 96.37.185.145 |
|
||||
| `2663675680` | 9.38 | FP#0000459 (Approved) | mon17-sw9 | 10.240.78.170 |
|
||||
| `2663676188` | 9.38 | FP#0000459 (Approved) | mon17-sw11 | 10.240.78.172 |
|
||||
| `2663676366` | 9.38 | FP#0000459 (Approved) | mon17-sw8 | 10.240.78.169 |
|
||||
| `2663676895` | 9.38 | FP#0000459 (Approved) | mon17-sw5 | 10.240.78.166 |
|
||||
| `2663677778` | 9.38 | FP#0000459 (Approved) | mon17-sw13 | 10.240.78.174 |
|
||||
| `2663677987` | 9.38 | FP#0000459 (Approved) | mon17-sw12 | 10.240.78.173 |
|
||||
| `2663681315` | 9.38 | FP#0000459 (Approved) | mon17-sw6 | 10.240.78.167 |
|
||||
| `2663683699` | 9.38 | FP#0000459 (Approved) | mon17-sw14 | 10.240.78.175 |
|
||||
| `2663685466` | 9.38 | FP#0000459 (Approved) | mon17-sw7 | 10.240.78.168 |
|
||||
| `2663695383` | 9.38 | FP#0000459 (Approved) | mon17-sw10 | 10.240.78.171 |
|
||||
| `2744240319` | 9.38 | FP#0000459 (Approved) | syn-066-061-128-010 | 66.61.128.10 |
|
||||
| `2744252609` | 9.38 | FP#0000459 (Approved) | apa01se1shcc-bvi101-secondary | 66.61.128.233 |
|
||||
| `2744261786` | 9.38 | FP#0000459 (Approved) | syn-066-061-128-049 | 66.61.128.49 |
|
||||
| `2744295544` | 9.38 | FP#0000459 (Approved) | syn-066-061-128-018 | 66.61.128.18 |
|
||||
| `2312013545` | 9.90 | FP#0000459 (Approved) | | 10.244.4.26 |
|
||||
| `2329805541` | 9.90 | FP#0000459 (Approved) | | 10.244.11.5 |
|
||||
| `2329818159` | 9.90 | FP#0000459 (Approved) | | 10.244.11.6 |
|
||||
|
||||
**With rejected FP workflows (8 findings):**
|
||||
|
||||
| Finding ID | Severity | FP Workflow | Host | IP Address |
|
||||
|---|---|---|---|---|
|
||||
| `2281232044` | 9.38 | FP#0000460 (Rejected) | apc15se1shcc-n03 | 10.244.4.55 |
|
||||
| `2281440017` | 9.38 | FP#0000460 (Rejected) | apc01se1shcc-n03-bmc | 10.244.11.53 |
|
||||
| `2282142049` | 9.38 | FP#0000460 (Rejected) | | 10.244.4.30 |
|
||||
| `2282338246` | 9.38 | FP#0000460 (Rejected) | apc04se1shcc-n01-cimc | 10.244.11.63 |
|
||||
| `2283364439` | 9.90 | FP#0000470 (Rejected) | | 24.28.208.125 |
|
||||
| `2283577805` | 9.90 | FP#0000470 (Rejected) | syn-024-028-210-101 | 24.28.210.101 |
|
||||
| `2283734550` | 9.90 | FP#0000452 (Rejected) | | 10.244.11.27 |
|
||||
| `2286607835` | 9.90 | FP#0000452 (Rejected) | | 10.240.1.203 |
|
||||
|
||||
**Without FP workflows (43 findings):**
|
||||
|
||||
| Finding ID | Severity | Host | IP Address | Title |
|
||||
|---|---|---|---|---|
|
||||
| `2289169183` | 9.90 | | 10.240.78.20 | IPMI 2.0 RAKP Authentication |
|
||||
| `2458498036` | 9.90 | eon-node-dhcp | | OpenSSH Multiple Security Vulnerabilities |
|
||||
| `2352647807` | 9.90 | localhost | | Rocky Linux sqlite update (RLSA-2025:20936) |
|
||||
| `2312562977` | 9.90 | rphy-runner-falconv | | Rocky Linux sqlite update (RLSA-2025:11992) |
|
||||
| `2352629939` | 9.90 | rphy-runner-falconv | | Rocky Linux sqlite update (RLSA-2025:20936) |
|
||||
| `2281281250` | 9.38 | | 172.16.1.229 | OpenSSH regreSSHion |
|
||||
| `2282419417` | 9.38 | | 10.244.11.96 | OpenSSH regreSSHion |
|
||||
| `2282688566` | 9.38 | apc02se1shcc-n01-cimc | 10.244.11.54 | OpenSSH regreSSHion |
|
||||
| `2283112486` | 9.38 | apc14se1shcc-n02 | 10.244.4.51 | OpenSSH regreSSHion |
|
||||
| `2283720427` | 9.38 | | 10.244.11.86 | OpenSSH regreSSHion |
|
||||
| `2283873511` | 9.38 | apc02se1shcc-n02-cimc | 10.244.11.55 | OpenSSH regreSSHion |
|
||||
| `2284154592` | 9.38 | syn-024-028-208-105 | 24.28.208.105 | OpenSSH regreSSHion |
|
||||
| `2284337626` | 9.38 | apc14se1shcc-n01 | 10.244.4.50 | OpenSSH regreSSHion |
|
||||
| `2284372435` | 9.38 | apc15se1shcc-n01 | 10.244.4.53 | OpenSSH regreSSHion |
|
||||
| `2284395753` | 9.38 | apc07se1shcc-n02-cimc | 10.244.11.73 | OpenSSH regreSSHion |
|
||||
| `2284622624` | 9.38 | apc04se1shcc-n02-cimc | 10.244.11.64 | OpenSSH regreSSHion |
|
||||
| `2284681286` | 9.38 | apc15se1shcc-n02 | 10.244.4.54 | OpenSSH regreSSHion |
|
||||
| `2285988119` | 9.38 | | 10.244.4.28 | OpenSSH regreSSHion |
|
||||
| `2286255181` | 9.38 | | 10.244.11.94 | OpenSSH regreSSHion |
|
||||
| `2286422988` | 9.38 | c220-wzp27340ss5 | 10.241.0.43 | OpenSSH regreSSHion |
|
||||
| `2286541484` | 9.38 | apc02se1shcc-n03-cimc | 10.244.11.56 | OpenSSH regreSSHion |
|
||||
| `2286589497` | 9.38 | apc05se1shcc-n01-bmc | 10.244.11.66 | OpenSSH regreSSHion |
|
||||
| `2287156417` | 9.38 | apc13se1shcc-n01 | 10.244.4.47 | OpenSSH regreSSHion |
|
||||
| `2287168608` | 9.38 | apc13se1shcc-n03 | 10.244.4.49 | OpenSSH regreSSHion |
|
||||
| `2287400005` | 9.38 | apc14se1shcc-n03 | 10.244.4.52 | OpenSSH regreSSHion |
|
||||
| `2287503960` | 9.38 | apc07se1shcc-n01-cimc | 10.244.11.72 | OpenSSH regreSSHion |
|
||||
| `2287822934` | 9.38 | apc02ctsbcom7-n03-cimc | 10.244.4.25 | OpenSSH regreSSHion |
|
||||
| `2287849796` | 9.38 | | 10.244.4.29 | OpenSSH regreSSHion |
|
||||
| `2287917789` | 9.38 | apc07se1shcc-n03-cimc | 10.244.11.74 | OpenSSH regreSSHion |
|
||||
| `2287954330` | 9.38 | apc13se1shcc-n02 | 10.244.4.48 | OpenSSH regreSSHion |
|
||||
| `2288500154` | 9.38 | apc04se1shcc-n03-cimc | 10.244.11.65 | OpenSSH regreSSHion |
|
||||
| `2288545686` | 9.38 | apc02ctsbcom7-n02-cimc | 10.244.4.24 | OpenSSH regreSSHion |
|
||||
| `2288829837` | 9.38 | | 10.244.11.87 | OpenSSH regreSSHion |
|
||||
| `2288874420` | 9.38 | apc05se1shcc-n03-bmc | 10.244.11.68 | OpenSSH regreSSHion |
|
||||
| `2289487733` | 9.38 | apc05se1shcc-n02-bmc | 10.244.11.67 | OpenSSH regreSSHion |
|
||||
| `2289651084` | 9.38 | apc02ctsbcom7-n01-cimc | 10.244.4.23 | OpenSSH regreSSHion |
|
||||
| `2289802898` | 9.38 | | 10.244.11.57 | OpenSSH regreSSHion |
|
||||
| `2454510043` | 9.38 | | 10.244.11.95 | OpenSSH regreSSHion |
|
||||
| `2687702557` | 9.38 | syn-098-120-032-145 | 98.120.32.145 | OpenSSH regreSSHion |
|
||||
| `2687710954` | 9.38 | syn-098-120-000-129 | 98.120.0.129 | OpenSSH regreSSHion |
|
||||
| `2284209398` | 9.06 | rphy-runner-vecima | 68.114.184.84 | Rocky Linux sudo update (RLSA-2025:9978) |
|
||||
| `2288585418` | 9.06 | rphy-runner-falconv | | Rocky Linux sudo update (RLSA-2025:9978) |
|
||||
| `2728824329` | 8.50 | localhost | | Rocky Linux kernel update (RLSA-2026:6570) |
|
||||
|
||||
---
|
||||
|
||||
### Still same BU — 6 findings
|
||||
|
||||
| Finding ID | Severity | Current State | BU | Host | IP Address |
|
||||
|---|---|---|---|---|---|
|
||||
| `2359379898` | 9.06 | Closed | NTS-AEO-STEAM | aeo-bpa-app-01-lab | |
|
||||
| `2286639694` | 9.38 | Closed | NTS-AEO-STEAM | syn-024-024-116-183 | 24.24.116.183 |
|
||||
| `2744295322` | 7.57 | Open | NTS-AEO-STEAM | ana01pongcoc1 | 96.37.185.81 |
|
||||
| `2687694321` | 9.38 | Closed | NTS-AEO-ACCESS-ENG | asa04chaococ1 | 98.120.32.167 |
|
||||
| `2687701818` | 9.38 | Closed | NTS-AEO-ACCESS-ENG | asr01chaococ1 | 98.120.32.180 |
|
||||
| `2687702475` | 9.38 | Closed | NTS-AEO-ACCESS-ENG | asr02chaococ1 | 98.120.32.181 |
|
||||
|
||||
> Finding `2744295322` is the only confirmed score drift case — dropped from 9.0 to 7.57. The other 5 are in the Closed state and still match the BU and severity filters; they were likely closed between syncs.
|
||||
|
||||
---
|
||||
|
||||
### Completely gone from platform — 15 findings
|
||||
|
||||
These findings are not found in Ivanti at any BU, severity, or state. All are OpenSSH regreSSHion (CVE-2024-6387).
|
||||
|
||||
| Finding ID | Last Severity | Host | IP Address |
|
||||
|---|---|---|---|
|
||||
| `2283426805` | 9.38 | | 10.244.3.136 |
|
||||
| `2284481283` | 9.38 | | 10.244.3.165 |
|
||||
| `2285495688` | 9.38 | | 10.244.3.134 |
|
||||
| `2285658756` | 9.38 | | 10.244.3.137 |
|
||||
| `2285828688` | 9.38 | | 10.244.3.133 |
|
||||
| `2286763965` | 9.38 | | 10.244.3.135 |
|
||||
| `2286932880` | 9.38 | | 10.244.3.166 |
|
||||
| `2288594216` | 9.38 | | 10.244.3.164 |
|
||||
| `2289475366` | 9.38 | | 10.244.3.132 |
|
||||
| `2662566450` | 9.38 | syn-065-185-198-071 | 65.185.198.71 |
|
||||
| `2662633263` | 9.38 | syn-065-185-198-070 | 65.185.198.70 |
|
||||
| `2687700013` | 9.38 | syn-098-120-032-166 | 98.120.32.166 |
|
||||
| `2687707862` | 9.38 | syn-098-120-032-182 | 98.120.32.182 |
|
||||
| `2613547630` | 9.30 | 096-037-187-009 | 96.37.187.9 |
|
||||
| `2613548575` | 9.30 | 096-037-187-017 | 96.37.187.17 |
|
||||
|
||||
> The `10.244.3.x` subnet (9 findings) suggests a cluster of hosts that were decommissioned or removed from Ivanti's asset inventory entirely.
|
||||
|
||||
---
|
||||
|
||||
### Diagnostic scripts
|
||||
|
||||
The `drift-check.js` and `bu-reassignment-check.js` scripts used during this investigation have been removed from the repository. Their logic is now automated by the sync anomaly detection feature in `backend/routes/ivantiFindings.js`, which classifies disappearances as BU reassignment, severity drift, closure, or decommission after each sync.
|
||||
@@ -8,11 +8,13 @@
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^13.5.0",
|
||||
"lucide-react": "^0.563.0",
|
||||
"mermaid": "^11.14.0",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-scripts": "5.0.1",
|
||||
"recharts": "^3.8.1",
|
||||
"rehype-sanitize": "^6.0.0",
|
||||
"web-vitals": "^2.1.4",
|
||||
"xlsx": "^0.18.5"
|
||||
},
|
||||
@@ -39,5 +41,16 @@
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
},
|
||||
"jest": {
|
||||
"transformIgnorePatterns": [
|
||||
"node_modules/(?!(fast-check)/)"
|
||||
],
|
||||
"moduleNameMapper": {
|
||||
"^pure-rand/(.*)$": "<rootDir>/node_modules/pure-rand/lib/$1.js"
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"fast-check": "^4.7.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -544,6 +544,16 @@ body {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@keyframes confirmFadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes confirmSlideUp {
|
||||
from { opacity: 0; transform: translateY(12px) scale(0.97); }
|
||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||
}
|
||||
|
||||
/* Tooltip with enhanced styling */
|
||||
.tooltip {
|
||||
position: relative;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Search, FileText, AlertCircle, Download, Upload, Eye, Filter, CheckCircle, XCircle, Loader, Trash2, Plus, RefreshCw, Edit2, ChevronDown, Shield, Activity, Menu } from 'lucide-react';
|
||||
import { Search, FileText, AlertCircle, AlertTriangle, Download, Upload, Eye, Filter, CheckCircle, XCircle, Loader, Trash2, Plus, RefreshCw, Edit2, ChevronDown, Shield, Activity, Menu } from 'lucide-react';
|
||||
import { useAuth } from './contexts/AuthContext';
|
||||
import LoginForm from './components/LoginForm';
|
||||
import UserMenu from './components/UserMenu';
|
||||
@@ -8,10 +8,14 @@ import AuditLog from './components/AuditLog';
|
||||
import NvdSyncModal from './components/NvdSyncModal';
|
||||
import NavDrawer from './components/NavDrawer';
|
||||
import CalendarWidget from './components/CalendarWidget';
|
||||
import ConfirmModal from './components/ConfirmModal';
|
||||
import VulnerabilityTriagePage from './components/pages/ReportingPage';
|
||||
import KnowledgeBasePage from './components/pages/KnowledgeBasePage';
|
||||
import ExportsPage from './components/pages/ExportsPage';
|
||||
import CompliancePage from './components/pages/CompliancePage';
|
||||
import JiraPage from './components/pages/JiraPage';
|
||||
import AdminPage from './components/pages/AdminPage';
|
||||
import ArchiveSummaryBar from './components/pages/ArchiveSummaryBar';
|
||||
import './App.css';
|
||||
|
||||
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
||||
@@ -161,7 +165,7 @@ const API_HOST = process.env.REACT_APP_API_HOST || 'http://localhost:3001';
|
||||
const severityLevels = ['All Severities', 'Critical', 'High', 'Medium', 'Low'];
|
||||
|
||||
export default function App() {
|
||||
const { isAuthenticated, loading: authLoading, canWrite, isAdmin, user } = useAuth();
|
||||
const { isAuthenticated, loading: authLoading, canWrite, canDelete, canExport, isAdmin } = useAuth();
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedVendor, setSelectedVendor] = useState('All Vendors');
|
||||
const [selectedSeverity, setSelectedSeverity] = useState('All Severities');
|
||||
@@ -175,7 +179,17 @@ export default function App() {
|
||||
const [cveDocuments, setCveDocuments] = useState({});
|
||||
const [quickCheckCVE, setQuickCheckCVE] = useState('');
|
||||
const [quickCheckResult, setQuickCheckResult] = useState(null);
|
||||
const [currentPage, setCurrentPage] = useState('home');
|
||||
const VALID_PAGES = new Set(['home', 'triage', 'compliance', 'knowledge-base', 'exports', 'jira', 'admin']);
|
||||
const [currentPage, setCurrentPageRaw] = useState(() => {
|
||||
try {
|
||||
const saved = localStorage.getItem('cve-dashboard-page');
|
||||
return saved && VALID_PAGES.has(saved) ? saved : 'home';
|
||||
} catch { return 'home'; }
|
||||
});
|
||||
const setCurrentPage = (page) => {
|
||||
setCurrentPageRaw(page);
|
||||
try { localStorage.setItem('cve-dashboard-page', page); } catch {}
|
||||
};
|
||||
const [navOpen, setNavOpen] = useState(false);
|
||||
const [calendarFilter, setCalendarFilter] = useState(null);
|
||||
const [reportingExcFilter, setReportingExcFilter] = useState(null);
|
||||
@@ -233,6 +247,15 @@ export default function App() {
|
||||
const [ivantiLoading, setIvantiLoading] = useState(false);
|
||||
const [ivantiSyncing, setIvantiSyncing] = useState(false);
|
||||
|
||||
// Archive filter state
|
||||
const [archiveFilter, setArchiveFilter] = useState(null);
|
||||
const [archiveRefreshKey, setArchiveRefreshKey] = useState(0);
|
||||
const [archiveList, setArchiveList] = useState([]);
|
||||
const [archiveListLoading, setArchiveListLoading] = useState(false);
|
||||
|
||||
// Confirmation modal state — replaces window.confirm()
|
||||
const [pendingConfirm, setPendingConfirm] = useState(null);
|
||||
|
||||
const toggleCVEExpand = (cveId) => {
|
||||
setExpandedCVEs(prev => ({ ...prev, [cveId]: !prev[cveId] }));
|
||||
};
|
||||
@@ -366,6 +389,22 @@ export default function App() {
|
||||
console.error('Error syncing Ivanti workflows:', err);
|
||||
} finally {
|
||||
setIvantiSyncing(false);
|
||||
setArchiveRefreshKey(k => k + 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleArchiveStateClick = (state) => {
|
||||
const newFilter = archiveFilter === state ? null : state;
|
||||
setArchiveFilter(newFilter);
|
||||
if (newFilter) {
|
||||
setArchiveListLoading(true);
|
||||
fetch(`${API_BASE}/ivanti/archive?state=${newFilter}`, { credentials: 'include' })
|
||||
.then(res => res.ok ? res.json() : Promise.reject())
|
||||
.then(data => setArchiveList(data.archives || []))
|
||||
.catch(() => setArchiveList([]))
|
||||
.finally(() => setArchiveListLoading(false));
|
||||
} else {
|
||||
setArchiveList([]);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -508,26 +547,30 @@ export default function App() {
|
||||
};
|
||||
|
||||
const handleDeleteDocument = async (docId, cveId, vendor) => {
|
||||
if (!window.confirm('Are you sure you want to delete this document?')) {
|
||||
return;
|
||||
}
|
||||
setPendingConfirm({
|
||||
title: 'Delete Document',
|
||||
message: 'Are you sure you want to delete this document?',
|
||||
confirmText: 'Delete',
|
||||
onConfirm: async () => {
|
||||
setPendingConfirm(null);
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/documents/${docId}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/documents/${docId}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include'
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to delete document');
|
||||
|
||||
if (!response.ok) throw new Error('Failed to delete document');
|
||||
|
||||
alert('Document deleted successfully!');
|
||||
const key = `${cveId}-${vendor}`;
|
||||
delete cveDocuments[key];
|
||||
await fetchDocuments(cveId, vendor);
|
||||
fetchCVEs();
|
||||
} catch (err) {
|
||||
alert(`Error: ${err.message}`);
|
||||
}
|
||||
alert('Document deleted successfully!');
|
||||
const key = `${cveId}-${vendor}`;
|
||||
delete cveDocuments[key];
|
||||
await fetchDocuments(cveId, vendor);
|
||||
fetchCVEs();
|
||||
} catch (err) {
|
||||
alert(`Error: ${err.message}`);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleEditCVE = (cve) => {
|
||||
@@ -620,65 +663,73 @@ export default function App() {
|
||||
};
|
||||
|
||||
const handleDeleteCVEEntry = async (cve) => {
|
||||
if (!window.confirm(`Are you sure you want to delete the "${cve.vendor}" entry for ${cve.cve_id}? This will also delete all associated documents.`)) {
|
||||
return;
|
||||
}
|
||||
setPendingConfirm({
|
||||
title: 'Delete Vendor Entry',
|
||||
message: `Are you sure you want to delete the "${cve.vendor}" entry for ${cve.cve_id}? This will also delete all associated documents.`,
|
||||
confirmText: 'Delete',
|
||||
onConfirm: async () => {
|
||||
setPendingConfirm(null);
|
||||
try {
|
||||
const url = `${API_BASE}/cves/${cve.id}`;
|
||||
console.log('DELETE request to:', url);
|
||||
const response = await fetch(url, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
try {
|
||||
const url = `${API_BASE}/cves/${cve.id}`;
|
||||
console.log('DELETE request to:', url);
|
||||
const response = await fetch(url, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include'
|
||||
});
|
||||
if (!response.ok) {
|
||||
const contentType = response.headers.get('content-type');
|
||||
if (contentType && contentType.includes('application/json')) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.error || 'Failed to delete CVE entry');
|
||||
} else {
|
||||
throw new Error(`Server returned ${response.status} ${response.statusText}. Check API_BASE configuration.`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const contentType = response.headers.get('content-type');
|
||||
if (contentType && contentType.includes('application/json')) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.error || 'Failed to delete CVE entry');
|
||||
} else {
|
||||
throw new Error(`Server returned ${response.status} ${response.statusText}. Check API_BASE configuration.`);
|
||||
alert(`Deleted ${cve.vendor} entry for ${cve.cve_id}`);
|
||||
fetchCVEs();
|
||||
fetchVendors();
|
||||
} catch (err) {
|
||||
alert(`Error: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
alert(`Deleted ${cve.vendor} entry for ${cve.cve_id}`);
|
||||
fetchCVEs();
|
||||
fetchVendors();
|
||||
} catch (err) {
|
||||
alert(`Error: ${err.message}`);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleDeleteEntireCVE = async (cveId, vendorCount) => {
|
||||
if (!window.confirm(`Are you sure you want to delete ALL ${vendorCount} vendor entries for ${cveId}? This will permanently remove all associated documents and files.`)) {
|
||||
return;
|
||||
}
|
||||
setPendingConfirm({
|
||||
title: 'Delete Entire CVE',
|
||||
message: `Are you sure you want to delete ALL ${vendorCount} vendor entries for ${cveId}? This will permanently remove all associated documents and files.`,
|
||||
confirmText: 'Delete All',
|
||||
onConfirm: async () => {
|
||||
setPendingConfirm(null);
|
||||
try {
|
||||
const url = `${API_BASE}/cves/by-cve-id/${encodeURIComponent(cveId)}`;
|
||||
console.log('DELETE request to:', url);
|
||||
const response = await fetch(url, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
try {
|
||||
const url = `${API_BASE}/cves/by-cve-id/${encodeURIComponent(cveId)}`;
|
||||
console.log('DELETE request to:', url);
|
||||
const response = await fetch(url, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include'
|
||||
});
|
||||
if (!response.ok) {
|
||||
const contentType = response.headers.get('content-type');
|
||||
if (contentType && contentType.includes('application/json')) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.error || 'Failed to delete CVE');
|
||||
} else {
|
||||
throw new Error(`Server returned ${response.status} ${response.statusText}. Check API_BASE configuration.`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const contentType = response.headers.get('content-type');
|
||||
if (contentType && contentType.includes('application/json')) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.error || 'Failed to delete CVE');
|
||||
} else {
|
||||
throw new Error(`Server returned ${response.status} ${response.statusText}. Check API_BASE configuration.`);
|
||||
alert(`Deleted all entries for ${cveId}`);
|
||||
fetchCVEs();
|
||||
fetchVendors();
|
||||
} catch (err) {
|
||||
alert(`Error: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
alert(`Deleted all entries for ${cveId}`);
|
||||
fetchCVEs();
|
||||
fetchVendors();
|
||||
} catch (err) {
|
||||
alert(`Error: ${err.message}`);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleAddTicket = async (e) => {
|
||||
@@ -746,18 +797,25 @@ export default function App() {
|
||||
};
|
||||
|
||||
const handleDeleteTicket = async (ticket) => {
|
||||
if (!window.confirm(`Delete ticket ${ticket.ticket_key}?`)) return;
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/jira-tickets/${ticket.id}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include'
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to delete ticket');
|
||||
alert('Ticket deleted');
|
||||
fetchJiraTickets();
|
||||
} catch (err) {
|
||||
alert(`Error: ${err.message}`);
|
||||
}
|
||||
setPendingConfirm({
|
||||
title: 'Delete Ticket',
|
||||
message: `Delete ticket ${ticket.ticket_key}?`,
|
||||
confirmText: 'Delete',
|
||||
onConfirm: async () => {
|
||||
setPendingConfirm(null);
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/jira-tickets/${ticket.id}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include'
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to delete ticket');
|
||||
alert('Ticket deleted');
|
||||
fetchJiraTickets();
|
||||
} catch (err) {
|
||||
alert(`Error: ${err.message}`);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const openAddTicketForCVE = (cve_id, vendor) => {
|
||||
@@ -831,21 +889,28 @@ export default function App() {
|
||||
};
|
||||
|
||||
const handleDeleteArcherTicket = async (ticket) => {
|
||||
if (!window.confirm(`Delete Archer ticket ${ticket.exc_number}?`)) return;
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/archer-tickets/${ticket.id}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include'
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to delete Archer ticket');
|
||||
alert('Archer ticket deleted');
|
||||
fetchArcherTickets();
|
||||
} catch (err) {
|
||||
alert(`Error: ${err.message}`);
|
||||
}
|
||||
setPendingConfirm({
|
||||
title: 'Delete Archer Ticket',
|
||||
message: `Delete Archer ticket ${ticket.exc_number}?`,
|
||||
confirmText: 'Delete',
|
||||
onConfirm: async () => {
|
||||
setPendingConfirm(null);
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/archer-tickets/${ticket.id}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include'
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to delete Archer ticket');
|
||||
alert('Archer ticket deleted');
|
||||
fetchArcherTickets();
|
||||
} catch (err) {
|
||||
alert(`Error: ${err.message}`);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const openAddArcherTicketForCVE = (cve_id, vendor) => {
|
||||
const _openAddArcherTicketForCVE = (cve_id, vendor) => {
|
||||
setAddArcherTicketContext({ cve_id, vendor });
|
||||
setArcherTicketForm({ exc_number: '', archer_url: '', status: 'Draft', cve_id, vendor });
|
||||
setShowAddArcherTicket(true);
|
||||
@@ -989,6 +1054,9 @@ export default function App() {
|
||||
{currentPage === 'compliance' && <CompliancePage onNavigate={setCurrentPage} />}
|
||||
{currentPage === 'knowledge-base' && <KnowledgeBasePage />}
|
||||
{currentPage === 'exports' && <ExportsPage />}
|
||||
{currentPage === 'jira' && <JiraPage />}
|
||||
{currentPage === 'admin' && isAdmin() && <AdminPage />}
|
||||
{currentPage === 'admin' && !isAdmin() && (() => { setCurrentPage('home'); return null; })()}
|
||||
|
||||
{/* User Management Modal */}
|
||||
{showUserManagement && (
|
||||
@@ -1723,7 +1791,7 @@ export default function App() {
|
||||
<span className="text-gray-500 mx-2">•</span>
|
||||
<span className="text-gray-300">{cves.length}</span> vendor entr{cves.length !== 1 ? 'ies' : 'y'}
|
||||
</p>
|
||||
{selectedDocuments.length > 0 && (
|
||||
{selectedDocuments.length > 0 && canExport() && (
|
||||
<button
|
||||
onClick={exportSelectedDocuments}
|
||||
className="intel-button intel-button-primary flex items-center gap-2"
|
||||
@@ -1810,7 +1878,7 @@ export default function App() {
|
||||
<span>Published: {vendorEntries[0].published_date}</span>
|
||||
<span className="text-intel-accent">•</span>
|
||||
<span>{vendorEntries.length} affected vendor{vendorEntries.length > 1 ? 's' : ''}</span>
|
||||
{canWrite() && vendorEntries.length >= 2 && (
|
||||
{isAdmin() && vendorEntries.length >= 2 && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleDeleteEntireCVE(cveId, vendorEntries.length); }}
|
||||
className="ml-2 px-3 py-1 text-xs intel-button intel-button-danger flex items-center gap-1"
|
||||
@@ -1871,7 +1939,7 @@ export default function App() {
|
||||
<Edit2 className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
{canWrite() && (
|
||||
{canDelete(cve) && (
|
||||
<button
|
||||
onClick={() => handleDeleteCVEEntry(cve)}
|
||||
className="px-3 py-2 text-intel-danger hover:bg-intel-medium rounded border border-intel-danger/50 transition-all flex items-center gap-1"
|
||||
@@ -2003,9 +2071,11 @@ export default function App() {
|
||||
<button onClick={() => handleEditTicket(ticket)} className="p-1 text-gray-400 hover:text-intel-warning transition-colors">
|
||||
<Edit2 className="w-4 h-4" />
|
||||
</button>
|
||||
{canDelete(ticket) && (
|
||||
<button onClick={() => handleDeleteTicket(ticket)} className="p-1 text-gray-400 hover:text-intel-danger transition-colors">
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -2129,9 +2199,11 @@ export default function App() {
|
||||
<button onClick={() => handleEditTicket(ticket)} className="text-gray-400 hover:text-intel-warning transition-colors">
|
||||
<Edit2 className="w-3 h-3" />
|
||||
</button>
|
||||
{canDelete(ticket) && (
|
||||
<button onClick={() => handleDeleteTicket(ticket)} className="text-gray-400 hover:text-intel-danger transition-colors">
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -2197,14 +2269,16 @@ export default function App() {
|
||||
>
|
||||
<Filter className="w-3 h-3" />
|
||||
</button>
|
||||
{canWrite() && (<>
|
||||
{canWrite() && (
|
||||
<button onClick={() => handleEditArcherTicket(ticket)} className="text-gray-400 hover:text-purple-400 transition-colors">
|
||||
<Edit2 className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
{canDelete(ticket) && (
|
||||
<button onClick={() => handleDeleteArcherTicket(ticket)} className="text-gray-400 hover:text-intel-danger transition-colors">
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</button>
|
||||
</>)}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-white font-mono mb-1">{ticket.cve_id}</div>
|
||||
@@ -2233,6 +2307,7 @@ export default function App() {
|
||||
<Activity className="w-5 h-5" />
|
||||
Ivanti Workflows
|
||||
</h2>
|
||||
{canWrite() && (
|
||||
<button
|
||||
onClick={syncIvantiWorkflows}
|
||||
disabled={ivantiSyncing || ivantiLoading}
|
||||
@@ -2242,6 +2317,7 @@ export default function App() {
|
||||
<RefreshCw className={`w-3 h-3 ${ivantiSyncing ? 'animate-spin' : ''}`} />
|
||||
{ivantiSyncing ? 'Syncing…' : 'Sync'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Last synced line */}
|
||||
@@ -2251,6 +2327,71 @@ export default function App() {
|
||||
: 'Never synced'}
|
||||
</div>
|
||||
|
||||
{/* Archive Summary Bar */}
|
||||
<ArchiveSummaryBar onStateClick={handleArchiveStateClick} activeFilter={archiveFilter} refreshKey={archiveRefreshKey} />
|
||||
|
||||
{/* Archive list — shown when a state card is clicked */}
|
||||
{archiveFilter && (
|
||||
<div style={{ marginBottom: '1rem' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.5rem' }}>
|
||||
<span style={{ fontFamily: 'monospace', fontSize: '0.75rem', color: '#94A3B8', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
||||
{archiveFilter} findings
|
||||
</span>
|
||||
<button
|
||||
onClick={() => { setArchiveFilter(null); setArchiveList([]); }}
|
||||
style={{ background: 'none', border: 'none', color: '#94A3B8', cursor: 'pointer', fontFamily: 'monospace', fontSize: '0.7rem' }}
|
||||
>
|
||||
✕ Clear
|
||||
</button>
|
||||
</div>
|
||||
{archiveListLoading ? (
|
||||
<div style={{ textAlign: 'center', padding: '1rem', color: '#94A3B8', fontFamily: 'monospace', fontSize: '0.75rem' }}>Loading…</div>
|
||||
) : archiveList.length === 0 ? (
|
||||
<div style={{ textAlign: 'center', padding: '1rem', color: '#64748B', fontFamily: 'monospace', fontSize: '0.72rem', border: '1px dashed rgba(100, 116, 139, 0.3)', borderRadius: '0.375rem' }}>
|
||||
No {archiveFilter.toLowerCase()} findings
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ maxHeight: '240px', overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: '0.375rem' }}>
|
||||
{archiveList.map((a) => (
|
||||
<div key={a.id} style={{ background: 'linear-gradient(135deg, rgba(30, 41, 59, 0.85), rgba(51, 65, 85, 0.75))', border: '1px solid rgba(100, 116, 139, 0.25)', borderLeft: a.related_active ? '3px solid #F59E0B' : '3px solid #10B981', borderRadius: '0.375rem', padding: '0.5rem' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'start', gap: '0.5rem', marginBottom: '0.25rem' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'start', gap: '0.375rem', flex: 1, minWidth: 0 }}>
|
||||
{a.related_active ? (
|
||||
<AlertTriangle style={{ width: '13px', height: '13px', color: '#F59E0B', flexShrink: 0, marginTop: '1px' }} />
|
||||
) : (
|
||||
<CheckCircle style={{ width: '13px', height: '13px', color: '#10B981', flexShrink: 0, marginTop: '1px' }} />
|
||||
)}
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<span style={{ fontFamily: 'monospace', fontSize: '0.7rem', fontWeight: '600', color: '#E2E8F0', display: 'block' }}>{a.finding_title || a.finding_id}</span>
|
||||
{a.finding_id && (
|
||||
<span
|
||||
title={a.finding_id}
|
||||
style={{ fontFamily: 'monospace', fontSize: '0.6rem', color: '#64748B', display: 'block', marginTop: '0.1rem' }}
|
||||
>
|
||||
{a.finding_id.length > 20 ? a.finding_id.slice(0, 20) + '…' : a.finding_id}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<span style={{ fontFamily: 'monospace', fontSize: '0.55rem', padding: '0.15rem 0.35rem', borderRadius: '0.25rem', background: 'rgba(100, 116, 139, 0.2)', border: '1px solid rgba(100, 116, 139, 0.4)', color: '#94A3B8', whiteSpace: 'nowrap' }}>
|
||||
Last seen: {(a.last_severity && a.last_severity !== 0) ? a.last_severity.toFixed(1) : '—'}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ fontFamily: 'monospace', fontSize: '0.65rem', color: '#64748B', marginLeft: '1.375rem' }}>
|
||||
{a.host_name}{a.ip_address ? ` (${a.ip_address})` : ''}
|
||||
</div>
|
||||
{a.related_active && (
|
||||
<div style={{ fontFamily: 'monospace', fontSize: '0.6rem', color: '#0EA5E9', marginTop: '0.35rem', marginLeft: '1.375rem', padding: '0.2rem 0.4rem', background: 'rgba(14, 165, 233, 0.1)', border: '1px solid rgba(14, 165, 233, 0.25)', borderRadius: '0.25rem', display: 'inline-block' }}>
|
||||
Similar finding active — ID: {a.related_active.id} ({a.related_active.severity?.toFixed(1) ?? '—'})
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{ivantiLoading ? (
|
||||
<div className="text-center py-8">
|
||||
<Loader className="w-6 h-6 text-teal-400 animate-spin mx-auto mb-2" />
|
||||
@@ -2321,6 +2462,17 @@ export default function App() {
|
||||
|
||||
</div>}
|
||||
{/* End Three Column Layout */}
|
||||
|
||||
{/* Confirmation Modal */}
|
||||
<ConfirmModal
|
||||
open={!!pendingConfirm}
|
||||
title={pendingConfirm?.title}
|
||||
message={pendingConfirm?.message}
|
||||
confirmText={pendingConfirm?.confirmText}
|
||||
variant={pendingConfirm?.variant || 'danger'}
|
||||
onConfirm={pendingConfirm?.onConfirm}
|
||||
onCancel={() => setPendingConfirm(null)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,598 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Search, FileText, AlertCircle, Download, Upload, Eye, Filter, CheckCircle, XCircle, Loader } from 'lucide-react';
|
||||
|
||||
const API_BASE = 'http://192.168.2.117:3001/api';
|
||||
|
||||
const severityLevels = ['All Severities', 'Critical', 'High', 'Medium', 'Low'];
|
||||
|
||||
export default function App() {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedVendor, setSelectedVendor] = useState('All Vendors');
|
||||
const [selectedSeverity, setSelectedSeverity] = useState('All Severities');
|
||||
const [selectedCVE, setSelectedCVE] = useState(null);
|
||||
const [selectedDocuments, setSelectedDocuments] = useState([]);
|
||||
const [cves, setCves] = useState([]);
|
||||
const [vendors, setVendors] = useState(['All Vendors']);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [cveDocuments, setCveDocuments] = useState({});
|
||||
const [quickCheckCVE, setQuickCheckCVE] = useState('');
|
||||
const [quickCheckResult, setQuickCheckResult] = useState(null);
|
||||
const [showAddCVE, setShowAddCVE] = useState(false);
|
||||
const [newCVE, setNewCVE] = useState({
|
||||
cve_id: '',
|
||||
vendor: '',
|
||||
severity: 'Medium',
|
||||
description: '',
|
||||
published_date: new Date().toISOString().split('T')[0]
|
||||
});
|
||||
const [uploadingFile, setUploadingFile] = useState(false);
|
||||
|
||||
// Fetch CVEs from API
|
||||
useEffect(() => {
|
||||
fetchCVEs();
|
||||
fetchVendors();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Refetch when filters change
|
||||
useEffect(() => {
|
||||
fetchCVEs();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [searchQuery, selectedVendor, selectedSeverity]);
|
||||
|
||||
const fetchCVEs = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (searchQuery) params.append('search', searchQuery);
|
||||
if (selectedVendor !== 'All Vendors') params.append('vendor', selectedVendor);
|
||||
if (selectedSeverity !== 'All Severities') params.append('severity', selectedSeverity);
|
||||
|
||||
const response = await fetch(`${API_BASE}/cves?${params}`);
|
||||
if (!response.ok) throw new Error('Failed to fetch CVEs');
|
||||
const data = await response.json();
|
||||
setCves(data);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
console.error('Error fetching CVEs:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchVendors = async () => {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/vendors`);
|
||||
if (!response.ok) throw new Error('Failed to fetch vendors');
|
||||
const data = await response.json();
|
||||
setVendors(['All Vendors', ...data]);
|
||||
} catch (err) {
|
||||
console.error('Error fetching vendors:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchDocuments = async (cveId) => {
|
||||
if (cveDocuments[cveId]) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/cves/${cveId}/documents`);
|
||||
if (!response.ok) throw new Error('Failed to fetch documents');
|
||||
const data = await response.json();
|
||||
setCveDocuments(prev => ({ ...prev, [cveId]: data }));
|
||||
} catch (err) {
|
||||
console.error('Error fetching documents:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const quickCheckCVEStatus = async () => {
|
||||
if (!quickCheckCVE.trim()) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/cves/check/${quickCheckCVE.trim()}`);
|
||||
if (!response.ok) throw new Error('Failed to check CVE');
|
||||
const data = await response.json();
|
||||
setQuickCheckResult(data);
|
||||
} catch (err) {
|
||||
console.error('Error checking CVE:', err);
|
||||
setQuickCheckResult({ error: err.message });
|
||||
}
|
||||
};
|
||||
|
||||
const handleViewDocuments = async (cveId) => {
|
||||
if (selectedCVE === cveId) {
|
||||
setSelectedCVE(null);
|
||||
} else {
|
||||
setSelectedCVE(cveId);
|
||||
await fetchDocuments(cveId);
|
||||
}
|
||||
};
|
||||
|
||||
const getSeverityColor = (severity) => {
|
||||
const colors = {
|
||||
'Critical': 'bg-red-100 text-red-800',
|
||||
'High': 'bg-orange-100 text-orange-800',
|
||||
'Medium': 'bg-yellow-100 text-yellow-800',
|
||||
'Low': 'bg-blue-100 text-blue-800'
|
||||
};
|
||||
return colors[severity] || 'bg-gray-100 text-gray-800';
|
||||
};
|
||||
|
||||
const toggleDocumentSelection = (docId) => {
|
||||
setSelectedDocuments(prev =>
|
||||
prev.includes(docId)
|
||||
? prev.filter(id => id !== docId)
|
||||
: [...prev, docId]
|
||||
);
|
||||
};
|
||||
|
||||
const exportSelectedDocuments = () => {
|
||||
alert(`Exporting ${selectedDocuments.length} documents for report attachment`);
|
||||
};
|
||||
|
||||
const handleAddCVE = async (e) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/cves`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(newCVE)
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to add CVE');
|
||||
|
||||
alert(`CVE ${newCVE.cve_id} added successfully!`);
|
||||
setShowAddCVE(false);
|
||||
setNewCVE({
|
||||
cve_id: '',
|
||||
vendor: '',
|
||||
severity: 'Medium',
|
||||
description: '',
|
||||
published_date: new Date().toISOString().split('T')[0]
|
||||
});
|
||||
fetchCVEs();
|
||||
} catch (err) {
|
||||
alert(`Error: ${err.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileUpload = async (cveId, vendor) => {
|
||||
const fileInput = document.createElement('input');
|
||||
fileInput.type = 'file';
|
||||
fileInput.accept = '.pdf,.png,.jpg,.jpeg,.txt,.doc,.docx';
|
||||
|
||||
fileInput.onchange = async (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
|
||||
const docType = prompt(
|
||||
'Document type (advisory, email, screenshot, patch, other):',
|
||||
'advisory'
|
||||
);
|
||||
if (!docType) return;
|
||||
|
||||
const notes = prompt('Notes (optional):');
|
||||
|
||||
setUploadingFile(true);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('cveId', cveId);
|
||||
formData.append('vendor', vendor);
|
||||
formData.append('type', docType);
|
||||
if (notes) formData.append('notes', notes);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/cves/${cveId}/documents`, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to upload document');
|
||||
|
||||
alert(`Document uploaded successfully!`);
|
||||
delete cveDocuments[cveId];
|
||||
await fetchDocuments(cveId);
|
||||
fetchCVEs();
|
||||
} catch (err) {
|
||||
alert(`Error: ${err.message}`);
|
||||
} finally {
|
||||
setUploadingFile(false);
|
||||
}
|
||||
};
|
||||
|
||||
fileInput.click();
|
||||
};
|
||||
|
||||
const filteredCVEs = cves;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 p-6">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="mb-8 flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">CVE Dashboard</h1>
|
||||
<p className="text-gray-600">Query vulnerabilities, manage vendors, and attach documentation</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowAddCVE(true)}
|
||||
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors flex items-center gap-2"
|
||||
>
|
||||
<span className="text-xl">+</span>
|
||||
Add New CVE
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Add CVE Modal */}
|
||||
{showAddCVE && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-lg shadow-xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
|
||||
<div className="p-6">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="text-2xl font-bold text-gray-900">Add New CVE</h2>
|
||||
<button
|
||||
onClick={() => setShowAddCVE(false)}
|
||||
className="text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<XCircle className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleAddCVE} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
CVE ID *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
placeholder="CVE-2024-1234"
|
||||
value={newCVE.cve_id}
|
||||
onChange={(e) => setNewCVE({...newCVE, cve_id: e.target.value.toUpperCase()})}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Vendor *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
placeholder="Microsoft, Cisco, Oracle, etc."
|
||||
value={newCVE.vendor}
|
||||
onChange={(e) => setNewCVE({...newCVE, vendor: e.target.value})}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Severity *
|
||||
</label>
|
||||
<select
|
||||
value={newCVE.severity}
|
||||
onChange={(e) => setNewCVE({...newCVE, severity: e.target.value})}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="Critical">Critical</option>
|
||||
<option value="High">High</option>
|
||||
<option value="Medium">Medium</option>
|
||||
<option value="Low">Low</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Description *
|
||||
</label>
|
||||
<textarea
|
||||
required
|
||||
placeholder="Brief description of the vulnerability"
|
||||
value={newCVE.description}
|
||||
onChange={(e) => setNewCVE({...newCVE, description: e.target.value})}
|
||||
rows={3}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Published Date *
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
required
|
||||
value={newCVE.published_date}
|
||||
onChange={(e) => setNewCVE({...newCVE, published_date: e.target.value})}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-4">
|
||||
<button
|
||||
type="submit"
|
||||
className="flex-1 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors font-medium"
|
||||
>
|
||||
Add CVE
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAddCVE(false)}
|
||||
className="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quick Check */}
|
||||
<div className="bg-gradient-to-r from-blue-50 to-indigo-50 rounded-lg shadow-sm p-6 mb-6 border border-blue-200">
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-3">Quick CVE Status Check</h2>
|
||||
<div className="flex gap-3">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Enter CVE ID (e.g., CVE-2024-1234)"
|
||||
value={quickCheckCVE}
|
||||
onChange={(e) => setQuickCheckCVE(e.target.value)}
|
||||
onKeyPress={(e) => e.key === 'Enter' && quickCheckCVEStatus()}
|
||||
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
<button
|
||||
onClick={quickCheckCVEStatus}
|
||||
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors font-medium"
|
||||
>
|
||||
Check Status
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{quickCheckResult && (
|
||||
<div className={`mt-4 p-4 rounded-lg ${quickCheckResult.exists ? 'bg-green-50 border border-green-200' : 'bg-yellow-50 border border-yellow-200'}`}>
|
||||
{quickCheckResult.error ? (
|
||||
<div className="flex items-start gap-3">
|
||||
<XCircle className="w-5 h-5 text-red-600 mt-0.5" />
|
||||
<div>
|
||||
<p className="font-medium text-red-900">Error</p>
|
||||
<p className="text-sm text-red-700">{quickCheckResult.error}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : quickCheckResult.exists ? (
|
||||
<div className="flex items-start gap-3">
|
||||
<CheckCircle className="w-5 h-5 text-green-600 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<p className="font-medium text-green-900">✓ CVE Addressed</p>
|
||||
<div className="mt-2 space-y-1 text-sm text-gray-700">
|
||||
<p><strong>Vendor:</strong> {quickCheckResult.cve.vendor}</p>
|
||||
<p><strong>Severity:</strong> {quickCheckResult.cve.severity}</p>
|
||||
<p><strong>Status:</strong> {quickCheckResult.cve.status}</p>
|
||||
<p><strong>Documents:</strong> {quickCheckResult.cve.total_documents} attached</p>
|
||||
<div className="mt-2 flex gap-3">
|
||||
<span className={`px-2 py-1 rounded text-xs font-medium ${quickCheckResult.compliance.advisory ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'}`}>
|
||||
{quickCheckResult.compliance.advisory ? '✓' : '✗'} Advisory
|
||||
</span>
|
||||
<span className={`px-2 py-1 rounded text-xs font-medium ${quickCheckResult.compliance.email ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-600'}`}>
|
||||
{quickCheckResult.compliance.email ? '✓' : '○'} Email
|
||||
</span>
|
||||
<span className={`px-2 py-1 rounded text-xs font-medium ${quickCheckResult.compliance.screenshot ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-600'}`}>
|
||||
{quickCheckResult.compliance.screenshot ? '✓' : '○'} Screenshot
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertCircle className="w-5 h-5 text-yellow-600 mt-0.5" />
|
||||
<div>
|
||||
<p className="font-medium text-yellow-900">Not Found</p>
|
||||
<p className="text-sm text-yellow-700">This CVE has not been addressed yet. No entry exists in the database.</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Search and Filters */}
|
||||
<div className="bg-white rounded-lg shadow-sm p-6 mb-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="md:col-span-1">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
<Search className="inline w-4 h-4 mr-1" />
|
||||
Search CVEs
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="CVE ID or description..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
<Filter className="inline w-4 h-4 mr-1" />
|
||||
Vendor
|
||||
</label>
|
||||
<select
|
||||
value={selectedVendor}
|
||||
onChange={(e) => setSelectedVendor(e.target.value)}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
>
|
||||
{vendors.map(vendor => (
|
||||
<option key={vendor} value={vendor}>{vendor}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
<AlertCircle className="inline w-4 h-4 mr-1" />
|
||||
Severity
|
||||
</label>
|
||||
<select
|
||||
value={selectedSeverity}
|
||||
onChange={(e) => setSelectedSeverity(e.target.value)}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
>
|
||||
{severityLevels.map(level => (
|
||||
<option key={level} value={level}>{level}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Results Summary */}
|
||||
<div className="mb-4 flex justify-between items-center">
|
||||
<p className="text-gray-600">
|
||||
Found {filteredCVEs.length} CVE{filteredCVEs.length !== 1 ? 's' : ''}
|
||||
</p>
|
||||
{selectedDocuments.length > 0 && (
|
||||
<button
|
||||
onClick={exportSelectedDocuments}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
Export {selectedDocuments.length} Document{selectedDocuments.length !== 1 ? 's' : ''} for Report
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* CVE List */}
|
||||
{loading ? (
|
||||
<div className="bg-white rounded-lg shadow-sm p-12 text-center">
|
||||
<Loader className="w-12 h-12 text-blue-600 mx-auto mb-4 animate-spin" />
|
||||
<p className="text-gray-600">Loading CVEs...</p>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="bg-white rounded-lg shadow-sm p-12 text-center">
|
||||
<XCircle className="w-12 h-12 text-red-500 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">Error Loading CVEs</h3>
|
||||
<p className="text-gray-600 mb-4">{error}</p>
|
||||
<button
|
||||
onClick={fetchCVEs}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{filteredCVEs.map(cve => {
|
||||
const documents = cveDocuments[cve.cve_id] || [];
|
||||
|
||||
return (
|
||||
<div key={cve.cve_id} className="bg-white rounded-lg shadow-sm border border-gray-200">
|
||||
<div className="p-6">
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<h3 className="text-xl font-semibold text-gray-900">{cve.cve_id}</h3>
|
||||
<span className={`px-3 py-1 rounded-full text-sm font-medium ${getSeverityColor(cve.severity)}`}>
|
||||
{cve.severity}
|
||||
</span>
|
||||
<span className={`px-3 py-1 rounded-full text-xs font-medium ${cve.doc_status === 'Complete' ? 'bg-green-100 text-green-800' : 'bg-yellow-100 text-yellow-800'}`}>
|
||||
{cve.doc_status === 'Complete' ? '✓ Docs Complete' : '⚠ Incomplete'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-gray-700 mb-2">{cve.description}</p>
|
||||
<div className="flex items-center gap-4 text-sm text-gray-500">
|
||||
<span>Vendor: <span className="font-medium text-gray-700">{cve.vendor}</span></span>
|
||||
<span>Published: {cve.published_date}</span>
|
||||
<span>Status: <span className="font-medium text-gray-700">{cve.status}</span></span>
|
||||
<span className="flex items-center gap-1">
|
||||
<FileText className="w-4 h-4" />
|
||||
{cve.document_count} document{cve.document_count !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleViewDocuments(cve.cve_id)}
|
||||
className="px-4 py-2 text-blue-600 hover:bg-blue-50 rounded-lg transition-colors flex items-center gap-2"
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
{selectedCVE === cve.cve_id ? 'Hide' : 'View'} Documents
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Documents Section */}
|
||||
{selectedCVE === cve.cve_id && (
|
||||
<div className="mt-4 pt-4 border-t border-gray-200">
|
||||
<h4 className="text-sm font-semibold text-gray-700 mb-3 flex items-center gap-2">
|
||||
<FileText className="w-4 h-4" />
|
||||
Attached Documents ({documents.length})
|
||||
</h4>
|
||||
{documents.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{documents.map(doc => (
|
||||
<div
|
||||
key={doc.id}
|
||||
className="flex items-center justify-between p-3 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3 flex-1">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedDocuments.includes(doc.id)}
|
||||
onChange={() => toggleDocumentSelection(doc.id)}
|
||||
className="w-4 h-4 text-blue-600 rounded focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
<FileText className="w-5 h-5 text-gray-400" />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-gray-900">{doc.name}</p>
|
||||
<p className="text-xs text-gray-500 capitalize">
|
||||
{doc.type} • {doc.file_size}
|
||||
{doc.notes && ` • ${doc.notes}`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
href={`http://localhost:3001/${doc.file_path}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="px-3 py-1 text-sm text-blue-600 hover:bg-blue-50 rounded transition-colors"
|
||||
>
|
||||
View
|
||||
</a>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-gray-500 italic">No documents attached yet</p>
|
||||
)}
|
||||
<button
|
||||
onClick={() => handleFileUpload(cve.cve_id, cve.vendor)}
|
||||
disabled={uploadingFile}
|
||||
className="mt-3 px-4 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded-lg transition-colors flex items-center gap-2 disabled:opacity-50"
|
||||
>
|
||||
<Upload className="w-4 h-4" />
|
||||
{uploadingFile ? 'Uploading...' : 'Upload New Document'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filteredCVEs.length === 0 && !loading && (
|
||||
<div className="bg-white rounded-lg shadow-sm p-12 text-center">
|
||||
<AlertCircle className="w-12 h-12 text-gray-400 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">No CVEs Found</h3>
|
||||
<p className="text-gray-600">Try adjusting your search criteria or filters</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import logo from './logo.svg';
|
||||
import './App.css';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<div className="App">
|
||||
<header className="App-header">
|
||||
<img src={logo} className="App-logo" alt="logo" />
|
||||
<p>
|
||||
Edit <code>src/App.js</code> and save to reload.
|
||||
</p>
|
||||
<a
|
||||
className="App-link"
|
||||
href="https://reactjs.org"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Learn React
|
||||
</a>
|
||||
</header>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user