Compare commits
25 Commits
enhancemen
...
feature/re
| Author | SHA1 | Date | |
|---|---|---|---|
| f24cdb5063 | |||
| 3e2546323e | |||
| b1a21e8771 | |||
| bc9e223ab7 | |||
| 2d1acca990 | |||
| 9893460b64 | |||
| 51b1f99b3a | |||
| 669396f635 | |||
| 8b3ea22fa0 | |||
| 75b8ecc61d | |||
| ade3cc25ad | |||
| 3fd6158eb3 | |||
| 5bbaaf5918 | |||
| 1f36d302ea | |||
| 8697ba4ef3 | |||
| d3806e8ce3 | |||
| 931c42faeb | |||
| ea3b72db5c | |||
| d63e7cc9b9 | |||
| 37e183543a | |||
| 337ffe6f35 | |||
| 08c8c8a2a1 | |||
| 4ed7721a71 | |||
| 3fb20c147d | |||
| f2e6069c08 |
@@ -1,79 +0,0 @@
|
|||||||
# CVE Dashboard - Color Scheme Modernization
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
Successfully modernized the color scheme from retro 80s/neon arcade aesthetic to a professional, sophisticated tactical intelligence platform look.
|
|
||||||
|
|
||||||
## Color Palette Changes
|
|
||||||
|
|
||||||
### Before (Neon/Retro)
|
|
||||||
- **Accent**: `#00D9FF` - Bright cyan (too neon)
|
|
||||||
- **Warning**: `#FFB800` - Bright yellow/orange (too saturated)
|
|
||||||
- **Danger**: `#FF3366` - Neon pink/red
|
|
||||||
- **Success**: `#00FF88` - Bright green (too bright)
|
|
||||||
- **Background Dark**: `#0A0E27`, `#131937`, `#1E2749`
|
|
||||||
|
|
||||||
### After (Modern Professional)
|
|
||||||
- **Accent**: `#0EA5E9` - Sky Blue (professional, refined cyan)
|
|
||||||
- **Warning**: `#F59E0B` - Amber (sophisticated, warm)
|
|
||||||
- **Danger**: `#EF4444` - Modern Red (urgent but refined)
|
|
||||||
- **Success**: `#10B981` - Emerald (professional green)
|
|
||||||
- **Background Dark**: `#0F172A`, `#1E293B`, `#334155` (Tailwind Slate palette)
|
|
||||||
|
|
||||||
## Design Philosophy
|
|
||||||
|
|
||||||
### Refinement Approach
|
|
||||||
1. **Reduced Glow Intensity**: Lowered opacity and blur radius on all glows from 0.9 to 0.4-0.5
|
|
||||||
2. **Subtler Borders**: Changed from 3px bright borders to 1.5-2px refined borders
|
|
||||||
3. **Professional Gradients**: Updated background gradients to use slate tones instead of stark blues
|
|
||||||
4. **Sophisticated Shadows**: Reduced shadow intensity while maintaining depth
|
|
||||||
5. **Text Shadow Refinement**: Reduced from aggressive glows to subtle halos
|
|
||||||
|
|
||||||
### Key Changes
|
|
||||||
|
|
||||||
#### Severity Badges
|
|
||||||
- **Critical**: Neon pink → Modern red with refined glow
|
|
||||||
- **High**: Bright yellow → Amber with warm tones
|
|
||||||
- **Medium**: Bright cyan → Sky blue professional
|
|
||||||
- **Low**: Bright green → Emerald sophisticated
|
|
||||||
|
|
||||||
#### Interactive Elements
|
|
||||||
- **Buttons**: Reduced glow from 25px to 20px radius, lowered opacity
|
|
||||||
- **Input Fields**: More subtle focus states, refined borders
|
|
||||||
- **Cards**: Gentler hover effects, professional elevation
|
|
||||||
- **Stat Cards**: Refined top accent lines, subtle glows
|
|
||||||
|
|
||||||
#### Layout Components
|
|
||||||
- **Wiki Panel**: Updated to emerald accent with professional borders
|
|
||||||
- **Calendar**: Sky blue accent with refined styling
|
|
||||||
- **Tickets Panel**: Amber accent maintaining urgency without neon feel
|
|
||||||
- **CVE Cards**: Slate-based gradients with professional depth
|
|
||||||
|
|
||||||
## Technical Implementation
|
|
||||||
|
|
||||||
### Files Modified
|
|
||||||
1. **App.css**: Updated all CSS variables, component styles, and utility classes
|
|
||||||
2. **App.js**: Updated inline STYLES object and all JSX color references
|
|
||||||
|
|
||||||
### CSS Variables Updated
|
|
||||||
```css
|
|
||||||
--intel-darkest: #0F172A
|
|
||||||
--intel-dark: #1E293B
|
|
||||||
--intel-medium: #334155
|
|
||||||
--intel-accent: #0EA5E9
|
|
||||||
--intel-warning: #F59E0B
|
|
||||||
--intel-danger: #EF4444
|
|
||||||
--intel-success: #10B981
|
|
||||||
--intel-grid: rgba(14, 165, 233, 0.08)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Maintained Features
|
|
||||||
✓ Pulsing button effects on hover/click
|
|
||||||
✓ Scanning line animation
|
|
||||||
✓ Card hover elevations
|
|
||||||
✓ Badge glow dots
|
|
||||||
✓ Grid background effect
|
|
||||||
✓ Three-column layout
|
|
||||||
✓ All interactive functionality
|
|
||||||
|
|
||||||
## Result
|
|
||||||
The dashboard now presents a modern, professional tactical intelligence platform aesthetic while preserving all the visual interest, depth, and functionality that made the original design engaging. The color scheme feels premium and sophisticated rather than arcade-like, suitable for enterprise security operations.
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# CVE Intelligence Dashboard - Design System Reference
|
# CVE Intelligence Dashboard - Design System Reference
|
||||||
|
|
||||||
## 🎨 Color Palette
|
## Color Palette
|
||||||
|
|
||||||
### Primary Colors
|
### Primary Colors
|
||||||
```css
|
```css
|
||||||
@@ -33,7 +33,7 @@
|
|||||||
| **Medium** | `#0EA5E9` | `rgba(14, 165, 233, 0.25)` | `#7DD3FC` | `#0EA5E9` |
|
| **Medium** | `#0EA5E9` | `rgba(14, 165, 233, 0.25)` | `#7DD3FC` | `#0EA5E9` |
|
||||||
| **Low** | `#10B981` | `rgba(16, 185, 129, 0.25)` | `#6EE7B7` | `#10B981` |
|
| **Low** | `#10B981` | `rgba(16, 185, 129, 0.25)` | `#6EE7B7` | `#10B981` |
|
||||||
|
|
||||||
## 📐 Layout Structure
|
## Layout Structure
|
||||||
|
|
||||||
### Three-Column Grid Layout
|
### Three-Column Grid Layout
|
||||||
```
|
```
|
||||||
@@ -60,7 +60,7 @@
|
|||||||
- **Desktop (lg+)**: 3-column layout (3-6-3 grid)
|
- **Desktop (lg+)**: 3-column layout (3-6-3 grid)
|
||||||
- **Tablet/Mobile**: Stacked single column
|
- **Tablet/Mobile**: Stacked single column
|
||||||
|
|
||||||
## 🎯 Component Specifications
|
## Component Specifications
|
||||||
|
|
||||||
### Stat Cards
|
### Stat Cards
|
||||||
```css
|
```css
|
||||||
@@ -117,7 +117,7 @@ Letter Spacing: 0.5px
|
|||||||
Glow Dot: 8px circle with pulse animation
|
Glow Dot: 8px circle with pulse animation
|
||||||
```
|
```
|
||||||
|
|
||||||
## ✨ Interactions & Animations
|
## Interactions & Animations
|
||||||
|
|
||||||
### Hover Effects
|
### Hover Effects
|
||||||
- **Cards**: `translateY(-2px)`, enhanced border, subtle glow
|
- **Cards**: `translateY(-2px)`, enhanced border, subtle glow
|
||||||
@@ -151,7 +151,7 @@ Fast: all 0.2s ease
|
|||||||
Ripple: width/height 0.5s
|
Ripple: width/height 0.5s
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🔤 Typography
|
## Typography
|
||||||
|
|
||||||
### Font Families
|
### Font Families
|
||||||
```css
|
```css
|
||||||
@@ -178,7 +178,7 @@ Accent Headings: 0 0 16px rgba(14, 165, 233, 0.3), 0 0 32px rgba(14, 165, 233, 0
|
|||||||
Badge Text: 0 0 8px rgba([color], 0.5)
|
Badge Text: 0 0 8px rgba([color], 0.5)
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🎨 Visual Effects
|
## Visual Effects
|
||||||
|
|
||||||
### Shadows
|
### Shadows
|
||||||
```css
|
```css
|
||||||
@@ -223,7 +223,7 @@ linear-gradient(rgba(14, 165, 233, 0.025) 1px, transparent 1px)
|
|||||||
Size: 20px × 20px
|
Size: 20px × 20px
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🧩 Specific Component Patterns
|
## Specific Component Patterns
|
||||||
|
|
||||||
### Wiki/Knowledge Base Entry
|
### Wiki/Knowledge Base Entry
|
||||||
```css
|
```css
|
||||||
@@ -261,7 +261,7 @@ Chevron: Rotate -90deg (collapsed) to 0deg (expanded)
|
|||||||
Vendor Cards: Nested with reduced opacity borders
|
Vendor Cards: Nested with reduced opacity borders
|
||||||
```
|
```
|
||||||
|
|
||||||
## 📱 Accessibility
|
## Accessibility
|
||||||
|
|
||||||
### Contrast Ratios
|
### Contrast Ratios
|
||||||
- Primary text on dark: 18.5:1 (AAA)
|
- Primary text on dark: 18.5:1 (AAA)
|
||||||
@@ -278,7 +278,7 @@ Vendor Cards: Nested with reduced opacity borders
|
|||||||
- Line height: 1.5 for body text
|
- Line height: 1.5 for body text
|
||||||
- Letter spacing: Generous for uppercase labels
|
- Letter spacing: Generous for uppercase labels
|
||||||
|
|
||||||
## 🎯 Design Principles
|
## Design Principles
|
||||||
|
|
||||||
1. **Professional Sophistication**: Modern enterprise feel, not arcade
|
1. **Professional Sophistication**: Modern enterprise feel, not arcade
|
||||||
2. **Tactical Intelligence**: Purpose-driven, information-dense
|
2. **Tactical Intelligence**: Purpose-driven, information-dense
|
||||||
@@ -288,7 +288,3 @@ Vendor Cards: Nested with reduced opacity borders
|
|||||||
6. **Monospace Data**: Technical data uses JetBrains Mono for clarity
|
6. **Monospace Data**: Technical data uses JetBrains Mono for clarity
|
||||||
7. **Generous Spacing**: Breathing room prevents overwhelming density
|
7. **Generous Spacing**: Breathing room prevents overwhelming density
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Last Updated**: February 10, 2026
|
|
||||||
**Version**: 2.0 (Modern Professional Redesign)
|
|
||||||
|
|||||||
@@ -48,13 +48,13 @@ A new feature has been added to the CVE Dashboard that allows users to upload th
|
|||||||
|
|
||||||
1. **Backend:**
|
1. **Backend:**
|
||||||
```bash
|
```bash
|
||||||
cd /home/admin/cve-dashboard/backend
|
cd backend
|
||||||
node server.js
|
node server.js
|
||||||
```
|
```
|
||||||
|
|
||||||
2. **Frontend:**
|
2. **Frontend:**
|
||||||
```bash
|
```bash
|
||||||
cd /home/admin/cve-dashboard/frontend
|
cd frontend
|
||||||
npm start
|
npm start
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -6,3 +6,12 @@ CORS_ORIGINS=http://localhost:3000
|
|||||||
# NVD API Key (optional - increases rate limit from 5 to 50 requests per 30s)
|
# 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
|
# Request one at https://nvd.nist.gov/developers/request-an-api-key
|
||||||
NVD_API_KEY=
|
NVD_API_KEY=
|
||||||
|
|
||||||
|
# Ivanti / RiskSense API (platform4.risksense.com)
|
||||||
|
# API key from your profile settings — does not expire like session cookies
|
||||||
|
IVANTI_API_KEY=
|
||||||
|
IVANTI_CLIENT_ID=1550
|
||||||
|
IVANTI_FIRST_NAME=
|
||||||
|
IVANTI_LAST_NAME=
|
||||||
|
# Set to true if behind Charter's SSL inspection proxy (replicates Python verify=False)
|
||||||
|
IVANTI_SKIP_TLS=false
|
||||||
|
|||||||
@@ -1,93 +0,0 @@
|
|||||||
const { spawn } = require('child_process');
|
|
||||||
const path = require('path');
|
|
||||||
const fs = require('fs');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Process vulnerability report Excel file by splitting CVE IDs into separate rows
|
|
||||||
* @param {string} inputPath - Path to original Excel file
|
|
||||||
* @param {string} outputPath - Path for processed Excel file
|
|
||||||
* @returns {Promise<{original_rows: number, processed_rows: number, output_path: string}>}
|
|
||||||
*/
|
|
||||||
function processVulnerabilityReport(inputPath, outputPath) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const scriptPath = path.join(__dirname, '..', 'scripts', 'split_cve_report.py');
|
|
||||||
|
|
||||||
// Verify script exists
|
|
||||||
if (!fs.existsSync(scriptPath)) {
|
|
||||||
return reject(new Error(`Python script not found: ${scriptPath}`));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify input file exists
|
|
||||||
if (!fs.existsSync(inputPath)) {
|
|
||||||
return reject(new Error(`Input file not found: ${inputPath}`));
|
|
||||||
}
|
|
||||||
|
|
||||||
const python = spawn('python3', [scriptPath, inputPath, outputPath]);
|
|
||||||
|
|
||||||
let stdout = '';
|
|
||||||
let stderr = '';
|
|
||||||
let timedOut = false;
|
|
||||||
|
|
||||||
// 30 second timeout
|
|
||||||
const timeout = setTimeout(() => {
|
|
||||||
timedOut = true;
|
|
||||||
python.kill();
|
|
||||||
reject(new Error('Processing timed out. File may be too large or corrupted.'));
|
|
||||||
}, 30000);
|
|
||||||
|
|
||||||
python.stdout.on('data', (data) => {
|
|
||||||
stdout += data.toString();
|
|
||||||
});
|
|
||||||
|
|
||||||
python.stderr.on('data', (data) => {
|
|
||||||
stderr += data.toString();
|
|
||||||
});
|
|
||||||
|
|
||||||
python.on('close', (code) => {
|
|
||||||
clearTimeout(timeout);
|
|
||||||
|
|
||||||
if (timedOut) return;
|
|
||||||
|
|
||||||
if (code !== 0) {
|
|
||||||
// Parse Python error messages
|
|
||||||
if (stderr.includes('Sheet') && stderr.includes('not found')) {
|
|
||||||
return reject(new Error('Invalid Excel file. Expected "Vulnerabilities" sheet with "CVE ID" column.'));
|
|
||||||
}
|
|
||||||
if (stderr.includes('pandas') || stderr.includes('openpyxl')) {
|
|
||||||
return reject(new Error('Python dependencies missing. Run: pip3 install pandas openpyxl'));
|
|
||||||
}
|
|
||||||
return reject(new Error(`Python script failed: ${stderr || 'Unknown error'}`));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse output for row counts
|
|
||||||
const originalMatch = stdout.match(/Original rows:\s*(\d+)/);
|
|
||||||
const newMatch = stdout.match(/New rows:\s*(\d+)/);
|
|
||||||
|
|
||||||
if (!originalMatch || !newMatch) {
|
|
||||||
return reject(new Error('Failed to parse row counts from Python output'));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify output file was created
|
|
||||||
if (!fs.existsSync(outputPath)) {
|
|
||||||
return reject(new Error('Processed file was not created'));
|
|
||||||
}
|
|
||||||
|
|
||||||
resolve({
|
|
||||||
original_rows: parseInt(originalMatch[1]),
|
|
||||||
processed_rows: parseInt(newMatch[1]),
|
|
||||||
output_path: outputPath
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
python.on('error', (err) => {
|
|
||||||
clearTimeout(timeout);
|
|
||||||
if (err.code === 'ENOENT') {
|
|
||||||
reject(new Error('Python 3 is required but not found. Please install Python.'));
|
|
||||||
} else {
|
|
||||||
reject(err);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = { processVulnerabilityReport };
|
|
||||||
58
backend/migrations/add_ivanti_findings_tables.js
Normal file
58
backend/migrations/add_ivanti_findings_tables.js
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
// Migration: Add ivanti_findings_cache and ivanti_finding_notes tables
|
||||||
|
const sqlite3 = require('sqlite3').verbose();
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const dbPath = path.join(__dirname, '..', 'cve_database.db');
|
||||||
|
const db = new sqlite3.Database(dbPath);
|
||||||
|
|
||||||
|
console.log('Starting Ivanti findings tables migration...');
|
||||||
|
|
||||||
|
db.serialize(() => {
|
||||||
|
// Cache table — single row holding the latest sync result
|
||||||
|
db.run(`
|
||||||
|
CREATE TABLE IF NOT EXISTS ivanti_findings_cache (
|
||||||
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||||
|
total INTEGER DEFAULT 0,
|
||||||
|
findings_json TEXT DEFAULT '[]',
|
||||||
|
synced_at DATETIME,
|
||||||
|
sync_status TEXT DEFAULT 'never',
|
||||||
|
error_message TEXT
|
||||||
|
)
|
||||||
|
`, (err) => {
|
||||||
|
if (err) console.error('Error creating findings cache table:', err);
|
||||||
|
else console.log('✓ ivanti_findings_cache table created');
|
||||||
|
});
|
||||||
|
|
||||||
|
db.run(`
|
||||||
|
INSERT OR IGNORE INTO ivanti_findings_cache (id, total, findings_json, sync_status)
|
||||||
|
VALUES (1, 0, '[]', 'never')
|
||||||
|
`, (err) => {
|
||||||
|
if (err) console.error('Error seeding findings cache row:', err);
|
||||||
|
else console.log('✓ ivanti_findings_cache row seeded');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Notes table — one row per finding, persists across cache refreshes
|
||||||
|
db.run(`
|
||||||
|
CREATE TABLE IF NOT EXISTS ivanti_finding_notes (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
finding_id TEXT NOT NULL UNIQUE,
|
||||||
|
note TEXT NOT NULL DEFAULT '',
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
`, (err) => {
|
||||||
|
if (err) console.error('Error creating finding notes table:', err);
|
||||||
|
else console.log('✓ ivanti_finding_notes table created');
|
||||||
|
});
|
||||||
|
|
||||||
|
db.run(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_finding_notes_finding_id
|
||||||
|
ON ivanti_finding_notes(finding_id)
|
||||||
|
`, (err) => {
|
||||||
|
if (err) console.error('Error creating notes index:', err);
|
||||||
|
else console.log('✓ finding_id index created');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
db.close(() => {
|
||||||
|
console.log('Migration complete!');
|
||||||
|
});
|
||||||
37
backend/migrations/add_ivanti_sync_table.js
Normal file
37
backend/migrations/add_ivanti_sync_table.js
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
// Migration: Add ivanti_sync_state 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 sync state migration...');
|
||||||
|
|
||||||
|
db.serialize(() => {
|
||||||
|
db.run(`
|
||||||
|
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
|
||||||
|
)
|
||||||
|
`, (err) => {
|
||||||
|
if (err) console.error('Error creating table:', err);
|
||||||
|
else console.log('✓ ivanti_sync_state table created');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Seed the single-row state record
|
||||||
|
db.run(`
|
||||||
|
INSERT OR IGNORE INTO ivanti_sync_state (id, total, workflows_json, sync_status)
|
||||||
|
VALUES (1, 0, '[]', 'never')
|
||||||
|
`, (err) => {
|
||||||
|
if (err) console.error('Error seeding state row:', err);
|
||||||
|
else console.log('✓ ivanti_sync_state row seeded');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
db.close(() => {
|
||||||
|
console.log('Migration complete!');
|
||||||
|
});
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
// Migration: Add weekly_reports table for vulnerability report uploads
|
|
||||||
|
|
||||||
const sqlite3 = require('sqlite3').verbose();
|
|
||||||
const path = require('path');
|
|
||||||
|
|
||||||
const dbPath = path.join(__dirname, '..', 'cve_database.db');
|
|
||||||
const db = new sqlite3.Database(dbPath);
|
|
||||||
|
|
||||||
console.log('Running migration: add_weekly_reports_table');
|
|
||||||
|
|
||||||
db.serialize(() => {
|
|
||||||
db.run(`
|
|
||||||
CREATE TABLE IF NOT EXISTS weekly_reports (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
upload_date DATE NOT NULL,
|
|
||||||
week_label VARCHAR(50),
|
|
||||||
original_filename VARCHAR(255),
|
|
||||||
processed_filename VARCHAR(255),
|
|
||||||
original_file_path VARCHAR(500),
|
|
||||||
processed_file_path VARCHAR(500),
|
|
||||||
row_count_original INTEGER,
|
|
||||||
row_count_processed INTEGER,
|
|
||||||
uploaded_by INTEGER,
|
|
||||||
uploaded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
is_current BOOLEAN DEFAULT 0,
|
|
||||||
FOREIGN KEY (uploaded_by) REFERENCES users(id)
|
|
||||||
)
|
|
||||||
`, (err) => {
|
|
||||||
if (err) {
|
|
||||||
console.error('Error creating weekly_reports table:', err);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
console.log('✓ Created weekly_reports table');
|
|
||||||
});
|
|
||||||
|
|
||||||
db.run(`
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_weekly_reports_date
|
|
||||||
ON weekly_reports(upload_date DESC)
|
|
||||||
`, (err) => {
|
|
||||||
if (err) {
|
|
||||||
console.error('Error creating date index:', err);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
console.log('✓ Created index on upload_date');
|
|
||||||
});
|
|
||||||
|
|
||||||
db.run(`
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_weekly_reports_current
|
|
||||||
ON weekly_reports(is_current)
|
|
||||||
`, (err) => {
|
|
||||||
if (err) {
|
|
||||||
console.error('Error creating current index:', err);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
console.log('✓ Created index on is_current');
|
|
||||||
console.log('\nMigration completed successfully!');
|
|
||||||
db.close();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
464
backend/routes/ivantiFindings.js
Normal file
464
backend/routes/ivantiFindings.js
Normal file
@@ -0,0 +1,464 @@
|
|||||||
|
// Ivanti / RiskSense Host Findings Routes
|
||||||
|
// Caches hostFinding/search results in SQLite with daily auto-sync.
|
||||||
|
// Notes are stored separately so they survive cache refreshes.
|
||||||
|
|
||||||
|
const express = require('express');
|
||||||
|
const https = require('https');
|
||||||
|
|
||||||
|
const IVANTI_URL_BASE = 'https://platform4.risksense.com/api/v1';
|
||||||
|
const SYNC_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
const FINDINGS_FILTERS = [
|
||||||
|
// NOTE: This filters for Open findings only — Closed count is fetched separately via syncClosedCount()
|
||||||
|
{
|
||||||
|
field: 'assetCustomAttributes.1550_host_1.value',
|
||||||
|
exclusive: false,
|
||||||
|
operator: 'IN',
|
||||||
|
orWithPrevious: false,
|
||||||
|
implicitFilters: [],
|
||||||
|
value: 'NTS-AEO-ACCESS-ENG,NTS-AEO-STEAM',
|
||||||
|
caseSensitive: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'severity',
|
||||||
|
exclusive: false,
|
||||||
|
operator: 'RANGE',
|
||||||
|
orWithPrevious: false,
|
||||||
|
implicitFilters: [],
|
||||||
|
value: '8.5,9.9',
|
||||||
|
caseSensitive: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'generic_state',
|
||||||
|
exclusive: false,
|
||||||
|
operator: 'EXACT',
|
||||||
|
orWithPrevious: false,
|
||||||
|
implicitFilters: [],
|
||||||
|
value: 'Open',
|
||||||
|
caseSensitive: false
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
// Same BU + severity filters but for Closed state — used only to fetch the total count
|
||||||
|
const CLOSED_COUNT_FILTERS = [
|
||||||
|
{
|
||||||
|
field: 'assetCustomAttributes.1550_host_1.value',
|
||||||
|
exclusive: false,
|
||||||
|
operator: 'IN',
|
||||||
|
orWithPrevious: false,
|
||||||
|
implicitFilters: [],
|
||||||
|
value: 'NTS-AEO-ACCESS-ENG,NTS-AEO-STEAM',
|
||||||
|
caseSensitive: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'severity',
|
||||||
|
exclusive: false,
|
||||||
|
operator: 'RANGE',
|
||||||
|
orWithPrevious: false,
|
||||||
|
implicitFilters: [],
|
||||||
|
value: '8.5,9.9',
|
||||||
|
caseSensitive: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'generic_state',
|
||||||
|
exclusive: false,
|
||||||
|
operator: 'EXACT',
|
||||||
|
orWithPrevious: false,
|
||||||
|
implicitFilters: [],
|
||||||
|
value: 'Closed',
|
||||||
|
caseSensitive: false
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// HTTP helper — mirrors the one in ivantiWorkflows.js
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function ivantiPost(urlPath, body, apiKey, skipTls) {
|
||||||
|
const bodyStr = JSON.stringify(body);
|
||||||
|
const fullUrl = new URL(IVANTI_URL_BASE + urlPath);
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const options = {
|
||||||
|
hostname: fullUrl.hostname,
|
||||||
|
path: fullUrl.pathname + fullUrl.search,
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'accept': '*/*',
|
||||||
|
'content-type': 'application/json',
|
||||||
|
'x-api-key': apiKey,
|
||||||
|
'x-http-client-type': 'browser',
|
||||||
|
'content-length': Buffer.byteLength(bodyStr)
|
||||||
|
},
|
||||||
|
rejectUnauthorized: !skipTls,
|
||||||
|
timeout: 20000
|
||||||
|
};
|
||||||
|
|
||||||
|
const req = https.request(options, (res) => {
|
||||||
|
let data = '';
|
||||||
|
res.on('data', (chunk) => { data += chunk; });
|
||||||
|
res.on('end', () => resolve({ status: res.statusCode, body: data }));
|
||||||
|
});
|
||||||
|
|
||||||
|
req.on('timeout', () => req.destroy(new Error('Request timed out')));
|
||||||
|
req.on('error', reject);
|
||||||
|
req.write(bodyStr);
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Table init
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function initTables(db) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
db.serialize(() => {
|
||||||
|
db.run(`
|
||||||
|
CREATE TABLE IF NOT EXISTS ivanti_findings_cache (
|
||||||
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||||
|
total INTEGER DEFAULT 0,
|
||||||
|
findings_json TEXT DEFAULT '[]',
|
||||||
|
synced_at DATETIME,
|
||||||
|
sync_status TEXT DEFAULT 'never',
|
||||||
|
error_message TEXT
|
||||||
|
)
|
||||||
|
`, (err) => { if (err) return reject(err); });
|
||||||
|
|
||||||
|
db.run(`
|
||||||
|
INSERT OR IGNORE INTO ivanti_findings_cache (id, total, findings_json, sync_status)
|
||||||
|
VALUES (1, 0, '[]', 'never')
|
||||||
|
`, (err) => { if (err) return reject(err); });
|
||||||
|
|
||||||
|
db.run(`
|
||||||
|
CREATE TABLE IF NOT EXISTS ivanti_finding_notes (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
finding_id TEXT NOT NULL UNIQUE,
|
||||||
|
note TEXT NOT NULL DEFAULT '',
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
`, (err) => { if (err) return reject(err); });
|
||||||
|
|
||||||
|
db.run(`
|
||||||
|
CREATE TABLE IF NOT EXISTS ivanti_counts_cache (
|
||||||
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||||
|
open_count INTEGER DEFAULT 0,
|
||||||
|
closed_count INTEGER DEFAULT 0,
|
||||||
|
synced_at DATETIME
|
||||||
|
)
|
||||||
|
`, (err) => { if (err) return reject(err); });
|
||||||
|
|
||||||
|
db.run(`
|
||||||
|
INSERT OR IGNORE INTO ivanti_counts_cache (id, open_count, closed_count)
|
||||||
|
VALUES (1, 0, 0)
|
||||||
|
`, (err) => { if (err) return reject(err); });
|
||||||
|
|
||||||
|
db.run(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_finding_notes_finding_id
|
||||||
|
ON ivanti_finding_notes(finding_id)
|
||||||
|
`, (err) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Extract only the fields we need from a raw finding object
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function extractFinding(f) {
|
||||||
|
// statusEmbedded.dueDate = "2026-03-06T00:00:00" — strip to date part
|
||||||
|
const rawDueDate = f.statusEmbedded?.dueDate || '';
|
||||||
|
const dueDate = rawDueDate ? rawDueDate.split('T')[0] : '';
|
||||||
|
|
||||||
|
// BU ownership: assetCustomAttributes['1550_host_1'] is an array like ["NTS-AEO-STEAM"]
|
||||||
|
const buOwnership = f.assetCustomAttributes?.['1550_host_1']?.[0] || '';
|
||||||
|
|
||||||
|
// CVE list: vulnerabilities.vulnInfoList[].cve
|
||||||
|
const cves = (f.vulnerabilities?.vulnInfoList || []).map(v => v.cve).filter(Boolean);
|
||||||
|
|
||||||
|
// Workflow: only capture FP# (False Positive) tickets — SYS# are auto-generated
|
||||||
|
// system workflows and not actionable for our purposes.
|
||||||
|
const wfDist = f.workflowDistribution || {};
|
||||||
|
const fpBuckets = [
|
||||||
|
...(wfDist.actionableWorkflows || []),
|
||||||
|
...(wfDist.requestedWorkflows || []),
|
||||||
|
...(wfDist.reworkedWorkflows || []),
|
||||||
|
...(wfDist.rejectedWorkflows || []),
|
||||||
|
...(wfDist.expiredWorkflows || []),
|
||||||
|
...(wfDist.approvedWorkflows || []),
|
||||||
|
].filter(w => (w.generatedId || '').startsWith('FP#'));
|
||||||
|
|
||||||
|
// Priority: actionable > requested > reworked > rejected > expired > approved
|
||||||
|
const fpEntry = fpBuckets[0] || null;
|
||||||
|
|
||||||
|
// Fallback: if no FP# in distribution, check workflowGeneratedNames directly
|
||||||
|
const generatedNames = f.workflowGeneratedNames || [];
|
||||||
|
const fpFromNames = !fpEntry
|
||||||
|
? generatedNames.find(n => n.startsWith('FP#')) || null
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const workflow = fpEntry ? {
|
||||||
|
id: fpEntry.generatedId || '',
|
||||||
|
state: fpEntry.state || '',
|
||||||
|
type: 'FP',
|
||||||
|
} : fpFromNames ? {
|
||||||
|
id: fpFromNames,
|
||||||
|
state: '',
|
||||||
|
type: 'FP',
|
||||||
|
} : null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: String(f.id),
|
||||||
|
title: f.title || '',
|
||||||
|
severity: typeof f.severity === 'number' ? f.severity : parseFloat(f.severity) || 0,
|
||||||
|
vrrGroup: f.vrrGroup || f.severityGroup || '',
|
||||||
|
hostName: f.host?.hostName || '',
|
||||||
|
ipAddress: f.host?.ipAddress || '',
|
||||||
|
dns: f.dns || f.host?.fqdn || '',
|
||||||
|
status: f.status || '',
|
||||||
|
slaStatus: f.slaStatus || '',
|
||||||
|
dueDate,
|
||||||
|
lastFoundOn: f.lastFoundOn || '',
|
||||||
|
buOwnership,
|
||||||
|
cves,
|
||||||
|
workflow
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Fetch total count of Closed findings from Ivanti (page 0, size 1)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
async function syncClosedCount(db, openCount, apiKey, clientId, skipTls) {
|
||||||
|
const urlPath = `/client/${encodeURIComponent(clientId)}/hostFinding/search`;
|
||||||
|
try {
|
||||||
|
const body = {
|
||||||
|
filters: CLOSED_COUNT_FILTERS,
|
||||||
|
projection: 'internal',
|
||||||
|
sort: [{ field: 'severity', direction: 'ASC' }],
|
||||||
|
page: 0,
|
||||||
|
size: 1
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await ivantiPost(urlPath, body, apiKey, skipTls);
|
||||||
|
if (result.status !== 200) throw new Error(`Closed count API returned status ${result.status}`);
|
||||||
|
|
||||||
|
const data = JSON.parse(result.body);
|
||||||
|
// RiskSense returns total in page.totalElements or page.total
|
||||||
|
const closedCount = data.page?.totalElements ?? data.page?.total ?? 0;
|
||||||
|
|
||||||
|
await dbRun(db,
|
||||||
|
`UPDATE ivanti_counts_cache SET open_count=?, closed_count=?, synced_at=datetime('now') WHERE id=1`,
|
||||||
|
[openCount, closedCount]
|
||||||
|
);
|
||||||
|
console.log(`[Ivanti Findings] Counts updated — open: ${openCount}, closed: ${closedCount}`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Ivanti Findings] Failed to fetch closed count:', err.message);
|
||||||
|
// Still update open count so it stays in sync; leave closed_count as-is
|
||||||
|
await dbRun(db,
|
||||||
|
`UPDATE ivanti_counts_cache SET open_count=?, synced_at=datetime('now') WHERE id=1`,
|
||||||
|
[openCount]
|
||||||
|
).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Core sync — fetches ALL pages, stores slimmed findings in SQLite
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
async function syncFindings(db) {
|
||||||
|
const apiKey = process.env.IVANTI_API_KEY;
|
||||||
|
const clientId = process.env.IVANTI_CLIENT_ID || '1550';
|
||||||
|
const skipTls = process.env.IVANTI_SKIP_TLS === 'true';
|
||||||
|
|
||||||
|
if (!apiKey) {
|
||||||
|
const errMsg = 'IVANTI_API_KEY not set in .env — skipping sync';
|
||||||
|
console.warn('[Ivanti Findings]', errMsg);
|
||||||
|
await dbRun(db, `UPDATE ivanti_findings_cache SET sync_status='error', error_message=?, synced_at=datetime('now') WHERE id=1`, [errMsg]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[Ivanti Findings] Starting sync...');
|
||||||
|
|
||||||
|
const urlPath = `/client/${encodeURIComponent(clientId)}/hostFinding/search`;
|
||||||
|
let allFindings = [];
|
||||||
|
let page = 0;
|
||||||
|
let totalPages = 1;
|
||||||
|
|
||||||
|
try {
|
||||||
|
do {
|
||||||
|
const body = {
|
||||||
|
filters: FINDINGS_FILTERS,
|
||||||
|
projection: 'internal',
|
||||||
|
sort: [{ field: 'severity', direction: 'ASC' }],
|
||||||
|
page,
|
||||||
|
size: 100
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await ivantiPost(urlPath, body, apiKey, skipTls);
|
||||||
|
|
||||||
|
if (result.status === 401) throw new Error('Invalid or missing API key (401)');
|
||||||
|
if (result.status === 419) throw new Error('Insufficient privileges (419) — API key lacks hostFinding access');
|
||||||
|
if (result.status === 429) throw new Error('Rate limited (429) — try again later');
|
||||||
|
if (result.status !== 200) throw new Error(`Ivanti API returned status ${result.status}`);
|
||||||
|
|
||||||
|
const data = JSON.parse(result.body);
|
||||||
|
totalPages = data.page?.totalPages || 1;
|
||||||
|
const findings = data._embedded?.hostFindings || [];
|
||||||
|
allFindings = allFindings.concat(findings.map(extractFinding));
|
||||||
|
|
||||||
|
console.log(`[Ivanti Findings] Page ${page + 1}/${totalPages} — ${allFindings.length} findings so far`);
|
||||||
|
page++;
|
||||||
|
} while (page < totalPages);
|
||||||
|
|
||||||
|
await dbRun(db,
|
||||||
|
`UPDATE ivanti_findings_cache SET total=?, findings_json=?, synced_at=datetime('now'), sync_status='success', error_message=NULL WHERE id=1`,
|
||||||
|
[allFindings.length, JSON.stringify(allFindings)]
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`[Ivanti Findings] Sync complete — ${allFindings.length} findings`);
|
||||||
|
await syncClosedCount(db, allFindings.length, apiKey, clientId, skipTls);
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err.message || 'Unknown error';
|
||||||
|
console.error('[Ivanti Findings] Sync failed:', msg);
|
||||||
|
await dbRun(db, `UPDATE ivanti_findings_cache SET sync_status='error', error_message=?, synced_at=datetime('now') WHERE id=1`, [msg]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Scheduler
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function scheduleSync(db) {
|
||||||
|
db.get('SELECT synced_at FROM ivanti_findings_cache WHERE id = 1', (err, row) => {
|
||||||
|
if (err || !row || !row.synced_at) {
|
||||||
|
syncFindings(db);
|
||||||
|
} else {
|
||||||
|
const lastSync = new Date(row.synced_at.replace(' ', 'T') + 'Z');
|
||||||
|
const hoursSince = (Date.now() - lastSync.getTime()) / (1000 * 60 * 60);
|
||||||
|
if (hoursSince >= 24) {
|
||||||
|
syncFindings(db);
|
||||||
|
} else {
|
||||||
|
console.log(`[Ivanti Findings] Last sync ${hoursSince.toFixed(1)}h ago — next auto-sync in ${(24 - hoursSince).toFixed(1)}h`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
setInterval(() => syncFindings(db), SYNC_INTERVAL_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// DB helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function dbRun(db, sql, params = []) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
db.run(sql, params, (err) => { if (err) reject(err); else resolve(); });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function readState(db) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
db.get(
|
||||||
|
'SELECT total, findings_json, synced_at, sync_status, error_message FROM ivanti_findings_cache WHERE id = 1',
|
||||||
|
(err, row) => {
|
||||||
|
if (err) return reject(err);
|
||||||
|
if (!row) return resolve({ total: 0, findings: [], synced_at: null, sync_status: 'never', error_message: null });
|
||||||
|
let findings = [];
|
||||||
|
try { findings = JSON.parse(row.findings_json || '[]'); } catch (_) { /* leave empty */ }
|
||||||
|
resolve({ total: row.total || 0, findings, synced_at: row.synced_at, sync_status: row.sync_status, error_message: row.error_message });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function readNotes(db) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
db.all('SELECT finding_id, note FROM ivanti_finding_notes', (err, rows) => {
|
||||||
|
if (err) return reject(err);
|
||||||
|
const map = {};
|
||||||
|
(rows || []).forEach((r) => { map[r.finding_id] = r.note; });
|
||||||
|
resolve(map);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function readCounts(db) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
db.get(
|
||||||
|
'SELECT open_count, closed_count, synced_at FROM ivanti_counts_cache WHERE id = 1',
|
||||||
|
(err, row) => {
|
||||||
|
if (err) return reject(err);
|
||||||
|
resolve({
|
||||||
|
open: row?.open_count ?? 0,
|
||||||
|
closed: row?.closed_count ?? 0,
|
||||||
|
synced_at: row?.synced_at ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readStateWithNotes(db) {
|
||||||
|
const [state, notes] = await Promise.all([readState(db), readNotes(db)]);
|
||||||
|
state.findings = state.findings.map((f) => ({ ...f, note: notes[f.id] || '' }));
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Router
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function createIvantiFindingsRouter(db, requireAuth) {
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
initTables(db)
|
||||||
|
.then(() => scheduleSync(db))
|
||||||
|
.catch((err) => console.error('[Ivanti Findings] Init failed:', err));
|
||||||
|
|
||||||
|
router.use(requireAuth(db));
|
||||||
|
|
||||||
|
// GET / — cached findings with notes merged in
|
||||||
|
router.get('/', async (req, res) => {
|
||||||
|
try {
|
||||||
|
res.json(await readStateWithNotes(db));
|
||||||
|
} catch {
|
||||||
|
res.status(500).json({ error: 'Database error reading findings' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /sync — trigger immediate sync, return fresh state
|
||||||
|
router.post('/sync', async (req, res) => {
|
||||||
|
await syncFindings(db);
|
||||||
|
try {
|
||||||
|
res.json(await readStateWithNotes(db));
|
||||||
|
} catch {
|
||||||
|
res.status(500).json({ error: 'Sync ran but could not read updated state' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /counts — open vs closed totals for pie chart
|
||||||
|
router.get('/counts', async (req, res) => {
|
||||||
|
try {
|
||||||
|
res.json(await readCounts(db));
|
||||||
|
} catch {
|
||||||
|
res.status(500).json({ error: 'Database error reading counts' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// PUT /:findingId/note — save or update a note (max 255 chars enforced here)
|
||||||
|
router.put('/:findingId/note', (req, res) => {
|
||||||
|
const { findingId } = req.params;
|
||||||
|
const note = String(req.body.note || '').slice(0, 255);
|
||||||
|
|
||||||
|
db.run(
|
||||||
|
`INSERT INTO ivanti_finding_notes (finding_id, note, updated_at)
|
||||||
|
VALUES (?, ?, datetime('now'))
|
||||||
|
ON CONFLICT(finding_id) DO UPDATE SET note=excluded.note, updated_at=datetime('now')`,
|
||||||
|
[findingId, note],
|
||||||
|
(err) => {
|
||||||
|
if (err) return res.status(500).json({ error: 'Failed to save note' });
|
||||||
|
res.json({ finding_id: findingId, note });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = createIvantiFindingsRouter;
|
||||||
274
backend/routes/ivantiWorkflows.js
Normal file
274
backend/routes/ivantiWorkflows.js
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
// Ivanti / RiskSense Workflow Routes
|
||||||
|
// Data is cached in SQLite and refreshed on a daily schedule or on-demand.
|
||||||
|
// Auth: x-api-key header (confirmed via platform4.risksense.com/doc/swagger.json)
|
||||||
|
// Error codes: 401 bad key, 419 insufficient privileges, 429 rate limited
|
||||||
|
|
||||||
|
const express = require('express');
|
||||||
|
const https = require('https');
|
||||||
|
|
||||||
|
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)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function initTable(db) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
db.serialize(() => {
|
||||||
|
db.run(`
|
||||||
|
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
|
||||||
|
)
|
||||||
|
`, (err) => { if (err) return reject(err); });
|
||||||
|
|
||||||
|
db.run(`
|
||||||
|
INSERT OR IGNORE INTO ivanti_sync_state (id, total, workflows_json, sync_status)
|
||||||
|
VALUES (1, 0, '[]', 'never')
|
||||||
|
`, (err) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Core sync — calls Ivanti API, stores result in SQLite
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
async function syncWorkflows(db) {
|
||||||
|
const apiKey = process.env.IVANTI_API_KEY;
|
||||||
|
const clientId = process.env.IVANTI_CLIENT_ID || '1550';
|
||||||
|
const firstName = process.env.IVANTI_FIRST_NAME || '';
|
||||||
|
const lastName = process.env.IVANTI_LAST_NAME || '';
|
||||||
|
const skipTls = process.env.IVANTI_SKIP_TLS === 'true';
|
||||||
|
|
||||||
|
if (!apiKey) {
|
||||||
|
const errMsg = 'IVANTI_API_KEY not set in .env — skipping sync';
|
||||||
|
console.warn('[Ivanti]', errMsg);
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
db.run(
|
||||||
|
`UPDATE ivanti_sync_state SET sync_status='error', error_message=?, synced_at=datetime('now') WHERE id=1`,
|
||||||
|
[errMsg], resolve
|
||||||
|
);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[Ivanti] Syncing workflows...');
|
||||||
|
|
||||||
|
const urlPath = `/client/${encodeURIComponent(clientId)}/workflowBatch/search`;
|
||||||
|
const body = {
|
||||||
|
filters: [
|
||||||
|
{
|
||||||
|
field: 'created_by_last_name',
|
||||||
|
exclusive: false,
|
||||||
|
operator: 'IN',
|
||||||
|
orWithPrevious: false,
|
||||||
|
implicitFilters: [],
|
||||||
|
value: lastName,
|
||||||
|
caseSensitive: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'created_by_first_name',
|
||||||
|
exclusive: false,
|
||||||
|
operator: 'IN',
|
||||||
|
orWithPrevious: false,
|
||||||
|
implicitFilters: [],
|
||||||
|
value: firstName,
|
||||||
|
caseSensitive: false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
projection: 'internal',
|
||||||
|
sort: [{ field: 'created', direction: 'DESC' }],
|
||||||
|
page: 0,
|
||||||
|
size: 50
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await ivantiPost(urlPath, body, apiKey, skipTls);
|
||||||
|
|
||||||
|
if (result.status === 401) {
|
||||||
|
throw new Error('Invalid or missing API key (401) — check IVANTI_API_KEY in .env');
|
||||||
|
}
|
||||||
|
if (result.status === 419) {
|
||||||
|
throw new Error('Insufficient privileges (419) — API key lacks workflow access');
|
||||||
|
}
|
||||||
|
if (result.status === 429) {
|
||||||
|
throw new Error('Rate limited (429) — will retry at next scheduled sync');
|
||||||
|
}
|
||||||
|
if (result.status !== 200) {
|
||||||
|
throw new Error(`Ivanti API returned unexpected status ${result.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = JSON.parse(result.body);
|
||||||
|
|
||||||
|
// Spring Data REST format: { _embedded: { workflowBatches: [...] }, page: { totalElements, ... } }
|
||||||
|
let total = 0;
|
||||||
|
let workflows = [];
|
||||||
|
|
||||||
|
if (data.page && typeof data.page.totalElements === 'number') {
|
||||||
|
total = data.page.totalElements;
|
||||||
|
workflows = data._embedded?.workflowBatches
|
||||||
|
|| data._embedded?.workflowBatch
|
||||||
|
|| [];
|
||||||
|
} else if (typeof data.total === 'number') {
|
||||||
|
total = data.total;
|
||||||
|
workflows = data.data || data.content || data.results || [];
|
||||||
|
} else if (typeof data.totalElements === 'number') {
|
||||||
|
total = data.totalElements;
|
||||||
|
workflows = data.content || data.data || [];
|
||||||
|
} else if (Array.isArray(data)) {
|
||||||
|
workflows = data;
|
||||||
|
total = data.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
db.run(
|
||||||
|
`UPDATE ivanti_sync_state
|
||||||
|
SET total=?, workflows_json=?, synced_at=datetime('now'), sync_status='success', error_message=NULL
|
||||||
|
WHERE id=1`,
|
||||||
|
[total, JSON.stringify(workflows)],
|
||||||
|
(err) => { if (err) reject(err); else resolve(); }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`[Ivanti] Sync complete — ${total} workflows`);
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err.message || 'Unknown error';
|
||||||
|
console.error('[Ivanti] Sync failed:', msg);
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
db.run(
|
||||||
|
`UPDATE ivanti_sync_state SET sync_status='error', error_message=?, synced_at=datetime('now') WHERE id=1`,
|
||||||
|
[msg], resolve
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Scheduler — runs sync immediately if >24h stale, then every 24h
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function scheduleSync(db) {
|
||||||
|
db.get('SELECT synced_at FROM ivanti_sync_state WHERE id = 1', (err, row) => {
|
||||||
|
if (err || !row || !row.synced_at) {
|
||||||
|
syncWorkflows(db);
|
||||||
|
} else {
|
||||||
|
const lastSync = new Date(row.synced_at.replace(' ', 'T') + 'Z');
|
||||||
|
const hoursSince = (Date.now() - lastSync.getTime()) / (1000 * 60 * 60);
|
||||||
|
if (hoursSince >= 24) {
|
||||||
|
syncWorkflows(db);
|
||||||
|
} else {
|
||||||
|
const hoursUntil = (24 - hoursSince).toFixed(1);
|
||||||
|
console.log(`[Ivanti] Last sync ${hoursSince.toFixed(1)}h ago — next auto-sync in ${hoursUntil}h`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
setInterval(() => syncWorkflows(db), SYNC_INTERVAL_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helper — read current state from DB and return as JSON-ready object
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function readState(db) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
db.get(
|
||||||
|
'SELECT total, workflows_json, synced_at, sync_status, error_message FROM ivanti_sync_state WHERE id = 1',
|
||||||
|
(err, row) => {
|
||||||
|
if (err) return reject(err);
|
||||||
|
if (!row) return resolve({ total: 0, workflows: [], synced_at: null, sync_status: 'never', error_message: null });
|
||||||
|
|
||||||
|
let workflows = [];
|
||||||
|
try { workflows = JSON.parse(row.workflows_json || '[]'); } catch (_) { /* leave empty */ }
|
||||||
|
|
||||||
|
resolve({
|
||||||
|
total: row.total || 0,
|
||||||
|
workflows,
|
||||||
|
synced_at: row.synced_at,
|
||||||
|
sync_status: row.sync_status,
|
||||||
|
error_message: row.error_message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Router
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function createIvantiWorkflowsRouter(db, requireAuth) {
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// Init table and kick off scheduler (fire-and-forget on startup)
|
||||||
|
initTable(db)
|
||||||
|
.then(() => scheduleSync(db))
|
||||||
|
.catch((err) => console.error('[Ivanti] Init failed:', err));
|
||||||
|
|
||||||
|
// All routes require authentication
|
||||||
|
router.use(requireAuth(db));
|
||||||
|
|
||||||
|
// GET / — return cached data (fast, no external call)
|
||||||
|
router.get('/', async (req, res) => {
|
||||||
|
try {
|
||||||
|
res.json(await readState(db));
|
||||||
|
} catch {
|
||||||
|
res.status(500).json({ error: 'Database error reading sync state' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /sync — trigger an immediate sync, await completion, return fresh state
|
||||||
|
router.post('/sync', async (req, res) => {
|
||||||
|
await syncWorkflows(db);
|
||||||
|
try {
|
||||||
|
res.json(await readState(db));
|
||||||
|
} catch {
|
||||||
|
res.status(500).json({ error: 'Sync ran but could not read updated state' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = createIvantiWorkflowsRouter;
|
||||||
@@ -1,261 +0,0 @@
|
|||||||
const express = require('express');
|
|
||||||
const path = require('path');
|
|
||||||
const fs = require('fs');
|
|
||||||
const { requireAuth, requireRole } = require('../middleware/auth');
|
|
||||||
const logAudit = require('../helpers/auditLog');
|
|
||||||
const { processVulnerabilityReport } = require('../helpers/excelProcessor');
|
|
||||||
|
|
||||||
function createWeeklyReportsRouter(db, upload) {
|
|
||||||
const router = express.Router();
|
|
||||||
|
|
||||||
// Helper to sanitize filename
|
|
||||||
function sanitizePathSegment(segment) {
|
|
||||||
if (!segment || typeof segment !== 'string') return '';
|
|
||||||
return segment
|
|
||||||
.replace(/\0/g, '')
|
|
||||||
.replace(/\.\./g, '')
|
|
||||||
.replace(/[\/\\]/g, '')
|
|
||||||
.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper to generate week label
|
|
||||||
function getWeekLabel(date) {
|
|
||||||
const now = new Date();
|
|
||||||
const uploadDate = new Date(date);
|
|
||||||
const daysDiff = Math.floor((now - uploadDate) / (1000 * 60 * 60 * 24));
|
|
||||||
|
|
||||||
if (daysDiff < 7) {
|
|
||||||
return "This week's report";
|
|
||||||
} else if (daysDiff < 14) {
|
|
||||||
return "Last week's report";
|
|
||||||
} else {
|
|
||||||
const month = uploadDate.getMonth() + 1;
|
|
||||||
const day = uploadDate.getDate();
|
|
||||||
const year = uploadDate.getFullYear();
|
|
||||||
return `Week of ${month.toString().padStart(2, '0')}/${day.toString().padStart(2, '0')}/${year}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// POST /api/weekly-reports/upload - Upload and process vulnerability report
|
|
||||||
router.post('/upload', requireAuth(db), requireRole(db, 'editor', 'admin'), upload.single('file'), async (req, res) => {
|
|
||||||
const uploadedFile = req.file;
|
|
||||||
|
|
||||||
if (!uploadedFile) {
|
|
||||||
return res.status(400).json({ error: 'No file uploaded' });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate file extension
|
|
||||||
const ext = path.extname(uploadedFile.originalname).toLowerCase();
|
|
||||||
if (ext !== '.xlsx') {
|
|
||||||
fs.unlinkSync(uploadedFile.path); // Clean up temp file
|
|
||||||
return res.status(400).json({ error: 'Only .xlsx files are allowed' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const timestamp = Date.now();
|
|
||||||
const sanitizedName = sanitizePathSegment(uploadedFile.originalname);
|
|
||||||
const reportsDir = path.join(__dirname, '..', 'uploads', 'weekly_reports');
|
|
||||||
|
|
||||||
// Create directory if it doesn't exist
|
|
||||||
if (!fs.existsSync(reportsDir)) {
|
|
||||||
fs.mkdirSync(reportsDir, { recursive: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
const originalFilename = `${timestamp}_original_${sanitizedName}`;
|
|
||||||
const processedFilename = `${timestamp}_processed_${sanitizedName}`;
|
|
||||||
const originalPath = path.join(reportsDir, originalFilename);
|
|
||||||
const processedPath = path.join(reportsDir, processedFilename);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Move uploaded file to permanent location
|
|
||||||
fs.renameSync(uploadedFile.path, originalPath);
|
|
||||||
|
|
||||||
// Process the file with Python script
|
|
||||||
const result = await processVulnerabilityReport(originalPath, processedPath);
|
|
||||||
|
|
||||||
const uploadDate = new Date().toISOString().split('T')[0];
|
|
||||||
|
|
||||||
// Update previous current reports to not current
|
|
||||||
db.run('UPDATE weekly_reports SET is_current = 0 WHERE is_current = 1', (err) => {
|
|
||||||
if (err) {
|
|
||||||
console.error('Error updating previous current reports:', err);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Insert new report record
|
|
||||||
const insertSql = `
|
|
||||||
INSERT INTO weekly_reports (
|
|
||||||
upload_date, week_label, original_filename, processed_filename,
|
|
||||||
original_file_path, processed_file_path, row_count_original,
|
|
||||||
row_count_processed, uploaded_by, is_current
|
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1)
|
|
||||||
`;
|
|
||||||
|
|
||||||
const weekLabel = getWeekLabel(uploadDate);
|
|
||||||
|
|
||||||
db.run(
|
|
||||||
insertSql,
|
|
||||||
[
|
|
||||||
uploadDate,
|
|
||||||
weekLabel,
|
|
||||||
sanitizedName,
|
|
||||||
processedFilename,
|
|
||||||
originalPath,
|
|
||||||
processedPath,
|
|
||||||
result.original_rows,
|
|
||||||
result.processed_rows,
|
|
||||||
req.user.id
|
|
||||||
],
|
|
||||||
function (err) {
|
|
||||||
if (err) {
|
|
||||||
console.error('Error inserting weekly report:', err);
|
|
||||||
return res.status(500).json({ error: 'Failed to save report metadata' });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Log audit entry
|
|
||||||
logAudit(
|
|
||||||
db,
|
|
||||||
req.user.id,
|
|
||||||
req.user.username,
|
|
||||||
'UPLOAD_WEEKLY_REPORT',
|
|
||||||
'weekly_reports',
|
|
||||||
this.lastID,
|
|
||||||
JSON.stringify({ filename: sanitizedName, rows: result.processed_rows }),
|
|
||||||
req.ip
|
|
||||||
);
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
id: this.lastID,
|
|
||||||
original_rows: result.original_rows,
|
|
||||||
processed_rows: result.processed_rows,
|
|
||||||
week_label: weekLabel
|
|
||||||
});
|
|
||||||
}
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
// Clean up files on error
|
|
||||||
if (fs.existsSync(originalPath)) fs.unlinkSync(originalPath);
|
|
||||||
if (fs.existsSync(processedPath)) fs.unlinkSync(processedPath);
|
|
||||||
|
|
||||||
console.error('Error processing vulnerability report:', error);
|
|
||||||
res.status(500).json({ error: error.message || 'Failed to process report' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// GET /api/weekly-reports - List all reports
|
|
||||||
router.get('/', requireAuth(db), (req, res) => {
|
|
||||||
const sql = `
|
|
||||||
SELECT id, upload_date, week_label, original_filename, processed_filename,
|
|
||||||
row_count_original, row_count_processed, is_current, uploaded_at
|
|
||||||
FROM weekly_reports
|
|
||||||
ORDER BY upload_date DESC, uploaded_at DESC
|
|
||||||
`;
|
|
||||||
|
|
||||||
db.all(sql, [], (err, rows) => {
|
|
||||||
if (err) {
|
|
||||||
console.error('Error fetching weekly reports:', err);
|
|
||||||
return res.status(500).json({ error: 'Failed to fetch reports' });
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json(rows);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// GET /api/weekly-reports/:id/download/:type - Download report file
|
|
||||||
router.get('/:id/download/:type', requireAuth(db), (req, res) => {
|
|
||||||
const { id, type } = req.params;
|
|
||||||
|
|
||||||
if (type !== 'original' && type !== 'processed') {
|
|
||||||
return res.status(400).json({ error: 'Invalid download type. Use "original" or "processed"' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const sql = `SELECT original_file_path, processed_file_path, original_filename FROM weekly_reports WHERE id = ?`;
|
|
||||||
|
|
||||||
db.get(sql, [id], (err, row) => {
|
|
||||||
if (err) {
|
|
||||||
console.error('Error fetching report:', err);
|
|
||||||
return res.status(500).json({ error: 'Failed to fetch report' });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!row) {
|
|
||||||
return res.status(404).json({ error: 'Report not found' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const filePath = type === 'original' ? row.original_file_path : row.processed_file_path;
|
|
||||||
|
|
||||||
if (!fs.existsSync(filePath)) {
|
|
||||||
return res.status(404).json({ error: 'File not found on disk' });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Log audit entry
|
|
||||||
logAudit(
|
|
||||||
db,
|
|
||||||
req.user.id,
|
|
||||||
req.user.username,
|
|
||||||
'DOWNLOAD_WEEKLY_REPORT',
|
|
||||||
'weekly_reports',
|
|
||||||
id,
|
|
||||||
JSON.stringify({ type }),
|
|
||||||
req.ip
|
|
||||||
);
|
|
||||||
|
|
||||||
const downloadName = type === 'original' ? row.original_filename : row.original_filename.replace('.xlsx', '_processed.xlsx');
|
|
||||||
|
|
||||||
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
|
|
||||||
res.setHeader('Content-Disposition', `attachment; filename="${downloadName}"`);
|
|
||||||
res.sendFile(filePath);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// DELETE /api/weekly-reports/:id - Delete report (admin only)
|
|
||||||
router.delete('/:id', requireAuth(db), requireRole(db, 'admin'), (req, res) => {
|
|
||||||
const { id } = req.params;
|
|
||||||
|
|
||||||
const sql = 'SELECT original_file_path, processed_file_path FROM weekly_reports WHERE id = ?';
|
|
||||||
|
|
||||||
db.get(sql, [id], (err, row) => {
|
|
||||||
if (err) {
|
|
||||||
console.error('Error fetching report for deletion:', err);
|
|
||||||
return res.status(500).json({ error: 'Failed to fetch report' });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!row) {
|
|
||||||
return res.status(404).json({ error: 'Report not found' });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete database record
|
|
||||||
db.run('DELETE FROM weekly_reports WHERE id = ?', [id], (err) => {
|
|
||||||
if (err) {
|
|
||||||
console.error('Error deleting report:', err);
|
|
||||||
return res.status(500).json({ error: 'Failed to delete report' });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete files
|
|
||||||
if (fs.existsSync(row.original_file_path)) {
|
|
||||||
fs.unlinkSync(row.original_file_path);
|
|
||||||
}
|
|
||||||
if (fs.existsSync(row.processed_file_path)) {
|
|
||||||
fs.unlinkSync(row.processed_file_path);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Log audit entry
|
|
||||||
logAudit(
|
|
||||||
db,
|
|
||||||
req.user.id,
|
|
||||||
req.user.username,
|
|
||||||
'DELETE_WEEKLY_REPORT',
|
|
||||||
'weekly_reports',
|
|
||||||
id,
|
|
||||||
null,
|
|
||||||
req.ip
|
|
||||||
);
|
|
||||||
|
|
||||||
res.json({ success: true });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
return router;
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = createWeeklyReportsRouter;
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
CVE Report Splitter
|
|
||||||
Splits multiple CVE IDs in a single row into separate rows for easier filtering and analysis.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import pandas as pd
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
def split_cve_report(input_file, output_file=None, sheet_name='Vulnerabilities', cve_column='CVE ID'):
|
|
||||||
"""
|
|
||||||
Split CVE IDs into separate rows.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
input_file: Path to input Excel file
|
|
||||||
output_file: Path to output file (default: adds '_Split' to input filename)
|
|
||||||
sheet_name: Name of sheet with vulnerability data (default: 'Vulnerabilities')
|
|
||||||
cve_column: Name of column containing CVE IDs (default: 'CVE ID')
|
|
||||||
"""
|
|
||||||
input_path = Path(input_file)
|
|
||||||
|
|
||||||
if not input_path.exists():
|
|
||||||
print(f"Error: File not found: {input_file}")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
if output_file is None:
|
|
||||||
output_file = input_path.parent / f"{input_path.stem}_Split{input_path.suffix}"
|
|
||||||
|
|
||||||
print(f"Reading: {input_file}")
|
|
||||||
|
|
||||||
try:
|
|
||||||
df = pd.read_excel(input_file, sheet_name=sheet_name)
|
|
||||||
except ValueError as e:
|
|
||||||
print(f"Error: Sheet '{sheet_name}' not found in workbook")
|
|
||||||
print(f"Available sheets: {pd.ExcelFile(input_file).sheet_names}")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
if cve_column not in df.columns:
|
|
||||||
print(f"Error: Column '{cve_column}' not found")
|
|
||||||
print(f"Available columns: {list(df.columns)}")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
original_rows = len(df)
|
|
||||||
print(f"Original rows: {original_rows}")
|
|
||||||
|
|
||||||
# Split CVE IDs by comma
|
|
||||||
df[cve_column] = df[cve_column].astype(str).str.split(',')
|
|
||||||
|
|
||||||
# Explode to create separate rows
|
|
||||||
df_exploded = df.explode(cve_column)
|
|
||||||
|
|
||||||
# Clean up CVE IDs
|
|
||||||
df_exploded[cve_column] = df_exploded[cve_column].str.strip()
|
|
||||||
df_exploded = df_exploded[df_exploded[cve_column].notna()]
|
|
||||||
df_exploded = df_exploded[df_exploded[cve_column] != 'nan']
|
|
||||||
df_exploded = df_exploded[df_exploded[cve_column] != '']
|
|
||||||
|
|
||||||
# Reset index
|
|
||||||
df_exploded = df_exploded.reset_index(drop=True)
|
|
||||||
|
|
||||||
new_rows = len(df_exploded)
|
|
||||||
print(f"New rows: {new_rows}")
|
|
||||||
print(f"Added {new_rows - original_rows} rows from splitting CVEs")
|
|
||||||
|
|
||||||
# Save output
|
|
||||||
df_exploded.to_excel(output_file, index=False, sheet_name=sheet_name)
|
|
||||||
print(f"\n✓ Success! Saved to: {output_file}")
|
|
||||||
|
|
||||||
return output_file
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
if len(sys.argv) < 2:
|
|
||||||
print("Usage: python3 split_cve_report.py <input_file.xlsx> [output_file.xlsx]")
|
|
||||||
print("\nExample:")
|
|
||||||
print(" python3 split_cve_report.py 'Vulnerability Workbook.xlsx'")
|
|
||||||
print(" python3 split_cve_report.py 'input.xlsx' 'output.xlsx'")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
input_file = sys.argv[1]
|
|
||||||
output_file = sys.argv[2] if len(sys.argv) > 2 else None
|
|
||||||
|
|
||||||
split_cve_report(input_file, output_file)
|
|
||||||
@@ -18,9 +18,10 @@ const createUsersRouter = require('./routes/users');
|
|||||||
const createAuditLogRouter = require('./routes/auditLog');
|
const createAuditLogRouter = require('./routes/auditLog');
|
||||||
const logAudit = require('./helpers/auditLog');
|
const logAudit = require('./helpers/auditLog');
|
||||||
const createNvdLookupRouter = require('./routes/nvdLookup');
|
const createNvdLookupRouter = require('./routes/nvdLookup');
|
||||||
const createWeeklyReportsRouter = require('./routes/weeklyReports');
|
|
||||||
const createKnowledgeBaseRouter = require('./routes/knowledgeBase');
|
const createKnowledgeBaseRouter = require('./routes/knowledgeBase');
|
||||||
const createArcherTicketsRouter = require('./routes/archerTickets');
|
const createArcherTicketsRouter = require('./routes/archerTickets');
|
||||||
|
const createIvantiWorkflowsRouter = require('./routes/ivantiWorkflows');
|
||||||
|
const createIvantiFindingsRouter = require('./routes/ivantiFindings');
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const PORT = process.env.PORT || 3001;
|
const PORT = process.env.PORT || 3001;
|
||||||
@@ -174,15 +175,18 @@ const upload = multer({
|
|||||||
limits: { fileSize: 10 * 1024 * 1024 } // 10MB limit
|
limits: { fileSize: 10 * 1024 * 1024 } // 10MB limit
|
||||||
});
|
});
|
||||||
|
|
||||||
// Weekly reports routes (editor/admin for upload, all authenticated for download)
|
|
||||||
app.use('/api/weekly-reports', createWeeklyReportsRouter(db, upload));
|
|
||||||
|
|
||||||
// Knowledge base routes (editor/admin for upload/delete, all authenticated for view)
|
// Knowledge base routes (editor/admin for upload/delete, all authenticated for view)
|
||||||
app.use('/api/knowledge-base', createKnowledgeBaseRouter(db, upload));
|
app.use('/api/knowledge-base', createKnowledgeBaseRouter(db, upload));
|
||||||
|
|
||||||
// Archer tickets routes (editor/admin for create/update/delete, all authenticated for view)
|
// Archer tickets routes (editor/admin for create/update/delete, all authenticated for view)
|
||||||
app.use('/api/archer-tickets', createArcherTicketsRouter(db));
|
app.use('/api/archer-tickets', createArcherTicketsRouter(db));
|
||||||
|
|
||||||
|
// Ivanti / RiskSense workflow routes (all authenticated users)
|
||||||
|
app.use('/api/ivanti/workflows', createIvantiWorkflowsRouter(db, requireAuth));
|
||||||
|
|
||||||
|
// Ivanti / RiskSense host findings routes (all authenticated users)
|
||||||
|
app.use('/api/ivanti/findings', createIvantiFindingsRouter(db, requireAuth));
|
||||||
|
|
||||||
// ========== CVE ENDPOINTS ==========
|
// ========== CVE ENDPOINTS ==========
|
||||||
|
|
||||||
// Get all CVEs with optional filters (authenticated users)
|
// Get all CVEs with optional filters (authenticated users)
|
||||||
|
|||||||
120
docs/MOP-workflow-color-codes.md
Normal file
120
docs/MOP-workflow-color-codes.md
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
# MOP: Ivanti Finding Workflow Status — STEAM Security Dashboard
|
||||||
|
|
||||||
|
**Document Type:** Method of Procedure
|
||||||
|
**Applies To:** STEAM Security Dashboard — Reporting Page
|
||||||
|
**Audience:** NTS-AEO-ACCESS-ENG / NTS-AEO-STEAM team members
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Purpose
|
||||||
|
|
||||||
|
This document explains how to interpret the **Workflow** column on the Reporting page and what action to take for each status. The goal is to ensure every open finding is actively managed and no False Positive (FP) exception lapses unnoticed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Background
|
||||||
|
|
||||||
|
### What the Reporting Page Shows
|
||||||
|
The Reporting page displays **open findings only** (severity 8.5+, `generic_state = Open`). A finding disappears from this list when it is closed — which happens when a valid, approved FP exception is on file or when the vulnerability is remediated.
|
||||||
|
|
||||||
|
### What the Workflow Column Shows
|
||||||
|
The Workflow column tracks **FP# tickets only** — False Positive requests that a team member has manually submitted in Ivanti. These represent cases where the team has asserted a finding is not exploitable or applicable in our environment.
|
||||||
|
|
||||||
|
> **SYS# workflows are not shown.** SYS# are auto-generated system tracking records and do not require team action.
|
||||||
|
|
||||||
|
### Key Rule
|
||||||
|
If a finding appears in the Reporting page, it requires action — regardless of whether it has an FP# badge or not.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Workflow Column Color Codes
|
||||||
|
|
||||||
|
### 🔴 Red — Act Immediately
|
||||||
|
|
||||||
|
| State | What It Means | Required Action |
|
||||||
|
|---|---|---|
|
||||||
|
| **Expired** | An FP# ticket existed but the exception window has lapsed. The finding re-opened. | Log into Ivanti and submit a **new FP request** for this finding. Reference the previous ticket if relevant. |
|
||||||
|
| **Rejected** | The security team reviewed the FP request and denied it. The finding is considered a real, exploitable vulnerability. | **Remediate the vulnerability.** Apply the relevant patch, configuration change, or compensating control. Do not resubmit an FP without new evidence. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 🟡 Amber — Action Required Soon
|
||||||
|
|
||||||
|
| State | What It Means | Required Action |
|
||||||
|
|---|---|---|
|
||||||
|
| **Reworked** | The FP request was challenged by the reviewer and sent back for revision. | Review the reviewer's comments in Ivanti. Update the FP justification and **resubmit the ticket**. |
|
||||||
|
| **Actionable** | The FP ticket has been flagged as needing team action. | Open the ticket in Ivanti to review what is needed and respond accordingly. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 🔵 Blue — In Flight, Monitor
|
||||||
|
|
||||||
|
| State | What It Means | Required Action |
|
||||||
|
|---|---|---|
|
||||||
|
| **Requested** | An FP# ticket has been submitted and is awaiting security team approval. | No immediate action. Monitor for approval or rejection. If no response within your SLA window, follow up with the approver. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### — (No Badge) — Untriaged
|
||||||
|
|
||||||
|
| State | What It Means | Required Action |
|
||||||
|
|---|---|---|
|
||||||
|
| **No workflow badge** | No FP ticket has ever been submitted for this finding. | Triage the finding. Determine whether to: (1) remediate it, or (2) submit a new FP request if you have justification that it is a false positive. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Decision Flowchart
|
||||||
|
|
||||||
|
```
|
||||||
|
Finding appears in Reporting page
|
||||||
|
│
|
||||||
|
├── Does it have a Workflow badge?
|
||||||
|
│ │
|
||||||
|
│ ├── NO (—)
|
||||||
|
│ │ └── Triage → Remediate OR submit new FP request
|
||||||
|
│ │
|
||||||
|
│ └── YES → Check the color:
|
||||||
|
│ │
|
||||||
|
│ ├── 🔵 BLUE (Requested)
|
||||||
|
│ │ └── Wait for approval. Follow up if SLA window is approaching.
|
||||||
|
│ │
|
||||||
|
│ ├── 🟡 AMBER (Reworked / Actionable)
|
||||||
|
│ │ └── Open Ivanti ticket → Review feedback → Update → Resubmit
|
||||||
|
│ │
|
||||||
|
│ └── 🔴 RED
|
||||||
|
│ │
|
||||||
|
│ ├── Expired → Submit NEW FP request in Ivanti
|
||||||
|
│ │
|
||||||
|
│ └── Rejected → Remediate the vulnerability
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. How to Submit or Renew an FP Request in Ivanti
|
||||||
|
|
||||||
|
1. Log into [Ivanti / RiskSense](https://platform4.risksense.com)
|
||||||
|
2. Navigate to **Host Findings**
|
||||||
|
3. Search for the Finding ID shown in the dashboard (Finding ID column)
|
||||||
|
4. Select the finding → **Actions** → **Request False Positive**
|
||||||
|
5. Complete the justification form:
|
||||||
|
- Describe why the finding is not exploitable in this environment
|
||||||
|
- Reference any compensating controls, network segmentation, or vendor guidance
|
||||||
|
- Attach supporting evidence if available
|
||||||
|
6. Submit — ticket will appear as **Requested** (blue) in the dashboard once processed
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Quick Reference Card
|
||||||
|
|
||||||
|
| Badge Color | State | One-Line Action |
|
||||||
|
|---|---|---|
|
||||||
|
| 🔴 Red | Expired | Renew FP request in Ivanti |
|
||||||
|
| 🔴 Red | Rejected | Remediate the vulnerability |
|
||||||
|
| 🟡 Amber | Reworked | Update and resubmit FP ticket |
|
||||||
|
| 🟡 Amber | Actionable | Review ticket in Ivanti |
|
||||||
|
| 🔵 Blue | Requested | Monitor — no action yet |
|
||||||
|
| — | No badge | Triage: remediate or submit FP |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Last updated: 2026-03-11*
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
# Getting Started with Create React App
|
|
||||||
|
|
||||||
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
|
|
||||||
|
|
||||||
## Available Scripts
|
|
||||||
|
|
||||||
In the project directory, you can run:
|
|
||||||
|
|
||||||
### `npm start`
|
|
||||||
|
|
||||||
Runs the app in the development mode.\
|
|
||||||
Open [http://localhost:3000](http://localhost:3000) to view it in your browser.
|
|
||||||
|
|
||||||
The page will reload when you make changes.\
|
|
||||||
You may also see any lint errors in the console.
|
|
||||||
|
|
||||||
### `npm test`
|
|
||||||
|
|
||||||
Launches the test runner in the interactive watch mode.\
|
|
||||||
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
|
|
||||||
|
|
||||||
### `npm run build`
|
|
||||||
|
|
||||||
Builds the app for production to the `build` folder.\
|
|
||||||
It correctly bundles React in production mode and optimizes the build for the best performance.
|
|
||||||
|
|
||||||
The build is minified and the filenames include the hashes.\
|
|
||||||
Your app is ready to be deployed!
|
|
||||||
|
|
||||||
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
|
|
||||||
|
|
||||||
### `npm run eject`
|
|
||||||
|
|
||||||
**Note: this is a one-way operation. Once you `eject`, you can't go back!**
|
|
||||||
|
|
||||||
If you aren't satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
|
|
||||||
|
|
||||||
Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you're on your own.
|
|
||||||
|
|
||||||
You don't have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn't feel obligated to use this feature. However we understand that this tool wouldn't be useful if you couldn't customize it when you are ready for it.
|
|
||||||
|
|
||||||
## Learn More
|
|
||||||
|
|
||||||
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
|
|
||||||
|
|
||||||
To learn React, check out the [React documentation](https://reactjs.org/).
|
|
||||||
|
|
||||||
### Code Splitting
|
|
||||||
|
|
||||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/code-splitting](https://facebook.github.io/create-react-app/docs/code-splitting)
|
|
||||||
|
|
||||||
### Analyzing the Bundle Size
|
|
||||||
|
|
||||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size](https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size)
|
|
||||||
|
|
||||||
### Making a Progressive Web App
|
|
||||||
|
|
||||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app](https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app)
|
|
||||||
|
|
||||||
### Advanced Configuration
|
|
||||||
|
|
||||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/advanced-configuration](https://facebook.github.io/create-react-app/docs/advanced-configuration)
|
|
||||||
|
|
||||||
### Deployment
|
|
||||||
|
|
||||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/deployment](https://facebook.github.io/create-react-app/docs/deployment)
|
|
||||||
|
|
||||||
### `npm run build` fails to minify
|
|
||||||
|
|
||||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify](https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify)
|
|
||||||
@@ -12,7 +12,8 @@
|
|||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"react-scripts": "5.0.1",
|
"react-scripts": "5.0.1",
|
||||||
"web-vitals": "^2.1.4"
|
"web-vitals": "^2.1.4",
|
||||||
|
"xlsx": "^0.18.5"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "react-scripts start",
|
"start": "react-scripts start",
|
||||||
|
|||||||
@@ -1,14 +1,18 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { Search, FileText, AlertCircle, Download, Upload, Eye, Filter, CheckCircle, XCircle, Loader, Trash2, Plus, RefreshCw, Edit2, ChevronDown, Shield } from 'lucide-react';
|
import { Search, FileText, AlertCircle, Download, Upload, Eye, Filter, CheckCircle, XCircle, Loader, Trash2, Plus, RefreshCw, Edit2, ChevronDown, Shield, Activity, Menu } from 'lucide-react';
|
||||||
import { useAuth } from './contexts/AuthContext';
|
import { useAuth } from './contexts/AuthContext';
|
||||||
import LoginForm from './components/LoginForm';
|
import LoginForm from './components/LoginForm';
|
||||||
import UserMenu from './components/UserMenu';
|
import UserMenu from './components/UserMenu';
|
||||||
import UserManagement from './components/UserManagement';
|
import UserManagement from './components/UserManagement';
|
||||||
import AuditLog from './components/AuditLog';
|
import AuditLog from './components/AuditLog';
|
||||||
import NvdSyncModal from './components/NvdSyncModal';
|
import NvdSyncModal from './components/NvdSyncModal';
|
||||||
import WeeklyReportModal from './components/WeeklyReportModal';
|
|
||||||
import KnowledgeBaseModal from './components/KnowledgeBaseModal';
|
import KnowledgeBaseModal from './components/KnowledgeBaseModal';
|
||||||
import KnowledgeBaseViewer from './components/KnowledgeBaseViewer';
|
import KnowledgeBaseViewer from './components/KnowledgeBaseViewer';
|
||||||
|
import NavDrawer from './components/NavDrawer';
|
||||||
|
import CalendarWidget from './components/CalendarWidget';
|
||||||
|
import ReportingPage from './components/pages/ReportingPage';
|
||||||
|
import KnowledgeBasePage from './components/pages/KnowledgeBasePage';
|
||||||
|
import ExportsPage from './components/pages/ExportsPage';
|
||||||
import './App.css';
|
import './App.css';
|
||||||
|
|
||||||
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
||||||
@@ -172,11 +176,13 @@ export default function App() {
|
|||||||
const [cveDocuments, setCveDocuments] = useState({});
|
const [cveDocuments, setCveDocuments] = useState({});
|
||||||
const [quickCheckCVE, setQuickCheckCVE] = useState('');
|
const [quickCheckCVE, setQuickCheckCVE] = useState('');
|
||||||
const [quickCheckResult, setQuickCheckResult] = useState(null);
|
const [quickCheckResult, setQuickCheckResult] = useState(null);
|
||||||
|
const [currentPage, setCurrentPage] = useState('home');
|
||||||
|
const [navOpen, setNavOpen] = useState(false);
|
||||||
|
const [calendarFilter, setCalendarFilter] = useState(null);
|
||||||
const [showAddCVE, setShowAddCVE] = useState(false);
|
const [showAddCVE, setShowAddCVE] = useState(false);
|
||||||
const [showUserManagement, setShowUserManagement] = useState(false);
|
const [showUserManagement, setShowUserManagement] = useState(false);
|
||||||
const [showAuditLog, setShowAuditLog] = useState(false);
|
const [showAuditLog, setShowAuditLog] = useState(false);
|
||||||
const [showNvdSync, setShowNvdSync] = useState(false);
|
const [showNvdSync, setShowNvdSync] = useState(false);
|
||||||
const [showWeeklyReport, setShowWeeklyReport] = useState(false);
|
|
||||||
const [showKnowledgeBase, setShowKnowledgeBase] = useState(false);
|
const [showKnowledgeBase, setShowKnowledgeBase] = useState(false);
|
||||||
const [knowledgeBaseArticles, setKnowledgeBaseArticles] = useState([]);
|
const [knowledgeBaseArticles, setKnowledgeBaseArticles] = useState([]);
|
||||||
const [selectedKBArticle, setSelectedKBArticle] = useState(null);
|
const [selectedKBArticle, setSelectedKBArticle] = useState(null);
|
||||||
@@ -221,6 +227,15 @@ export default function App() {
|
|||||||
});
|
});
|
||||||
const [addArcherTicketContext, setAddArcherTicketContext] = useState(null); // { cve_id, vendor }
|
const [addArcherTicketContext, setAddArcherTicketContext] = useState(null); // { cve_id, vendor }
|
||||||
|
|
||||||
|
// Ivanti workflows state
|
||||||
|
const [ivantiTotal, setIvantiTotal] = useState(null);
|
||||||
|
const [ivantiWorkflows, setIvantiWorkflows] = useState([]);
|
||||||
|
const [ivantiSyncedAt, setIvantiSyncedAt] = useState(null);
|
||||||
|
const [ivantiSyncStatus, setIvantiSyncStatus] = useState(null);
|
||||||
|
const [ivantiSyncError, setIvantiSyncError] = useState(null);
|
||||||
|
const [ivantiLoading, setIvantiLoading] = useState(false);
|
||||||
|
const [ivantiSyncing, setIvantiSyncing] = useState(false);
|
||||||
|
|
||||||
const toggleCVEExpand = (cveId) => {
|
const toggleCVEExpand = (cveId) => {
|
||||||
setExpandedCVEs(prev => ({ ...prev, [cveId]: !prev[cveId] }));
|
setExpandedCVEs(prev => ({ ...prev, [cveId]: !prev[cveId] }));
|
||||||
};
|
};
|
||||||
@@ -333,6 +348,43 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const applyIvantiState = (data) => {
|
||||||
|
setIvantiTotal(data.total ?? 0);
|
||||||
|
setIvantiWorkflows(data.workflows || []);
|
||||||
|
setIvantiSyncedAt(data.synced_at || null);
|
||||||
|
setIvantiSyncStatus(data.sync_status || null);
|
||||||
|
setIvantiSyncError(data.error_message || null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchIvantiWorkflows = async () => {
|
||||||
|
setIvantiLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE}/ivanti/workflows`, { credentials: 'include' });
|
||||||
|
const data = await response.json();
|
||||||
|
if (response.ok) applyIvantiState(data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error loading Ivanti workflows:', err);
|
||||||
|
} finally {
|
||||||
|
setIvantiLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const syncIvantiWorkflows = async () => {
|
||||||
|
setIvantiSyncing(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE}/ivanti/workflows/sync`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include'
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
if (response.ok) applyIvantiState(data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error syncing Ivanti workflows:', err);
|
||||||
|
} finally {
|
||||||
|
setIvantiSyncing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const fetchDocuments = async (cveId, vendor) => {
|
const fetchDocuments = async (cveId, vendor) => {
|
||||||
const key = `${cveId}-${vendor}`;
|
const key = `${cveId}-${vendor}`;
|
||||||
if (cveDocuments[key]) return;
|
if (cveDocuments[key]) return;
|
||||||
@@ -861,6 +913,7 @@ export default function App() {
|
|||||||
fetchVendors();
|
fetchVendors();
|
||||||
fetchJiraTickets();
|
fetchJiraTickets();
|
||||||
fetchArcherTickets();
|
fetchArcherTickets();
|
||||||
|
fetchIvantiWorkflows();
|
||||||
fetchKnowledgeBaseArticles();
|
fetchKnowledgeBaseArticles();
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
@@ -905,18 +958,39 @@ export default function App() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-intel-darkest grid-bg p-6 relative overflow-hidden fade-in">
|
<div className="min-h-screen bg-intel-darkest grid-bg p-6 relative overflow-hidden fade-in">
|
||||||
|
<NavDrawer
|
||||||
|
isOpen={navOpen}
|
||||||
|
onClose={() => setNavOpen(false)}
|
||||||
|
currentPage={currentPage}
|
||||||
|
onNavigate={(page) => {
|
||||||
|
// Clear calendar filter when navigating directly via the nav drawer
|
||||||
|
if (page === 'reporting') setCalendarFilter(null);
|
||||||
|
setCurrentPage(page);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
{/* Scanning line effect */}
|
{/* Scanning line effect */}
|
||||||
<div className="scan-line"></div>
|
<div className="scan-line"></div>
|
||||||
|
|
||||||
<div className="max-w-7xl mx-auto relative z-10">
|
<div className={`${currentPage === 'reporting' ? 'w-full' : 'max-w-7xl mx-auto'} relative z-10`}>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<div className="flex justify-between items-start mb-6">
|
<div className="flex justify-between items-start mb-6">
|
||||||
<div className="flex-1">
|
<div className="flex items-center gap-4 flex-1">
|
||||||
<h1 className="text-4xl font-bold text-intel-accent mb-1 font-mono tracking-tight">
|
<button
|
||||||
CVE INTEL
|
onClick={() => setNavOpen(true)}
|
||||||
</h1>
|
style={{ background: 'none', border: '1px solid rgba(14, 165, 233, 0.25)', borderRadius: '0.375rem', padding: '0.5rem', cursor: 'pointer', color: '#64748B', flexShrink: 0 }}
|
||||||
<p className="text-gray-400 text-sm font-sans">Threat Intelligence & Vulnerability Command Center</p>
|
onMouseEnter={e => { e.currentTarget.style.color = '#0EA5E9'; e.currentTarget.style.borderColor = 'rgba(14, 165, 233, 0.6)'; }}
|
||||||
|
onMouseLeave={e => { e.currentTarget.style.color = '#64748B'; e.currentTarget.style.borderColor = 'rgba(14, 165, 233, 0.25)'; }}
|
||||||
|
title="Navigation"
|
||||||
|
>
|
||||||
|
<Menu className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-4xl font-bold text-intel-accent mb-1 font-mono tracking-tight">
|
||||||
|
STEAM Security Dashboard
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-400 text-sm font-sans">NTS Threat Intelligence and Metric Aggregation</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{canWrite() && (
|
{canWrite() && (
|
||||||
@@ -928,15 +1002,6 @@ export default function App() {
|
|||||||
NVD Sync
|
NVD Sync
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{canWrite() && (
|
|
||||||
<button
|
|
||||||
onClick={() => setShowWeeklyReport(true)}
|
|
||||||
className="intel-button intel-button-success relative z-10 flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<Upload className="w-4 h-4" />
|
|
||||||
Weekly Report
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{canWrite() && (
|
{canWrite() && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowAddCVE(true)}
|
onClick={() => setShowAddCVE(true)}
|
||||||
@@ -950,8 +1015,8 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stats Bar - Modern refined styling */}
|
{/* Stats Bar - only shown on Home page */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
{currentPage === 'home' && <div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||||
<div style={STYLES.statCard}>
|
<div style={STYLES.statCard}>
|
||||||
<div style={{ position: 'absolute', top: 0, left: 0, right: 0, height: '2px', background: 'linear-gradient(90deg, transparent, #0EA5E9, transparent)', boxShadow: '0 0 8px rgba(14, 165, 233, 0.5)' }}></div>
|
<div style={{ position: 'absolute', top: 0, left: 0, right: 0, height: '2px', background: 'linear-gradient(90deg, transparent, #0EA5E9, transparent)', boxShadow: '0 0 8px rgba(14, 165, 233, 0.5)' }}></div>
|
||||||
<div style={{ fontSize: '0.75rem', textTransform: 'uppercase', letterSpacing: '0.1em', color: '#CBD5E1', marginBottom: '0.25rem' }}>Total CVEs</div>
|
<div style={{ fontSize: '0.75rem', textTransform: 'uppercase', letterSpacing: '0.1em', color: '#CBD5E1', marginBottom: '0.25rem' }}>Total CVEs</div>
|
||||||
@@ -972,9 +1037,14 @@ export default function App() {
|
|||||||
<div style={{ fontSize: '0.75rem', textTransform: 'uppercase', letterSpacing: '0.1em', color: '#CBD5E1', marginBottom: '0.25rem' }}>Critical</div>
|
<div style={{ fontSize: '0.75rem', textTransform: 'uppercase', letterSpacing: '0.1em', color: '#CBD5E1', marginBottom: '0.25rem' }}>Critical</div>
|
||||||
<div style={{ fontSize: '1.5rem', fontWeight: '700', fontFamily: 'monospace', color: '#EF4444', textShadow: '0 0 16px rgba(239, 68, 68, 0.4)' }}>{cves.filter(c => c.severity === 'Critical').length}</div>
|
<div style={{ fontSize: '1.5rem', fontWeight: '700', fontFamily: 'monospace', color: '#EF4444', textShadow: '0 0 16px rgba(239, 68, 68, 0.4)' }}>{cves.filter(c => c.severity === 'Critical').length}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Page content */}
|
||||||
|
{currentPage === 'reporting' && <ReportingPage filterDate={calendarFilter} />}
|
||||||
|
{currentPage === 'knowledge-base' && <KnowledgeBasePage />}
|
||||||
|
{currentPage === 'exports' && <ExportsPage />}
|
||||||
|
|
||||||
{/* User Management Modal */}
|
{/* User Management Modal */}
|
||||||
{showUserManagement && (
|
{showUserManagement && (
|
||||||
<UserManagement onClose={() => setShowUserManagement(false)} />
|
<UserManagement onClose={() => setShowUserManagement(false)} />
|
||||||
@@ -990,11 +1060,6 @@ export default function App() {
|
|||||||
<NvdSyncModal onClose={() => setShowNvdSync(false)} onSyncComplete={() => fetchCVEs()} />
|
<NvdSyncModal onClose={() => setShowNvdSync(false)} onSyncComplete={() => fetchCVEs()} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Weekly Report Modal */}
|
|
||||||
{showWeeklyReport && (
|
|
||||||
<WeeklyReportModal onClose={() => setShowWeeklyReport(false)} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Knowledge Base Modal */}
|
{/* Knowledge Base Modal */}
|
||||||
{showKnowledgeBase && (
|
{showKnowledgeBase && (
|
||||||
<KnowledgeBaseModal
|
<KnowledgeBaseModal
|
||||||
@@ -1593,8 +1658,8 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Three Column Layout */}
|
{/* Three Column Layout - Home page only */}
|
||||||
<div className="grid grid-cols-12 gap-6">
|
{currentPage === 'home' && <div className="grid grid-cols-12 gap-6">
|
||||||
{/* LEFT PANEL - Wiki/Knowledge Base */}
|
{/* LEFT PANEL - Wiki/Knowledge Base */}
|
||||||
<div className="col-span-12 lg:col-span-3 space-y-4">
|
<div className="col-span-12 lg:col-span-3 space-y-4">
|
||||||
<div style={{...STYLES.intelCard, padding: '1.5rem', borderLeft: '3px solid #10B981'}} className="rounded-lg">
|
<div style={{...STYLES.intelCard, padding: '1.5rem', borderLeft: '3px solid #10B981'}} className="rounded-lg">
|
||||||
@@ -2160,63 +2225,12 @@ export default function App() {
|
|||||||
Calendar
|
Calendar
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
{/* Simple Calendar Grid */}
|
<CalendarWidget
|
||||||
<div className="mb-2">
|
onDateClick={(dateStr) => {
|
||||||
<div className="text-center mb-3">
|
setCalendarFilter(dateStr);
|
||||||
<span className="text-white font-semibold font-mono">February 2024</span>
|
setCurrentPage('reporting');
|
||||||
</div>
|
}}
|
||||||
<div className="grid grid-cols-7 gap-1 text-center text-xs mb-2">
|
/>
|
||||||
<div className="text-gray-400 font-mono">Su</div>
|
|
||||||
<div className="text-gray-400 font-mono">Mo</div>
|
|
||||||
<div className="text-gray-400 font-mono">Tu</div>
|
|
||||||
<div className="text-gray-400 font-mono">We</div>
|
|
||||||
<div className="text-gray-400 font-mono">Th</div>
|
|
||||||
<div className="text-gray-400 font-mono">Fr</div>
|
|
||||||
<div className="text-gray-400 font-mono">Sa</div>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-7 gap-1 text-center">
|
|
||||||
{/* Week 1 */}
|
|
||||||
<div className="text-gray-600 font-mono text-xs p-1">28</div>
|
|
||||||
<div className="text-gray-600 font-mono text-xs p-1">29</div>
|
|
||||||
<div className="text-gray-600 font-mono text-xs p-1">30</div>
|
|
||||||
<div className="text-gray-600 font-mono text-xs p-1">31</div>
|
|
||||||
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">1</div>
|
|
||||||
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">2</div>
|
|
||||||
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">3</div>
|
|
||||||
{/* Week 2 */}
|
|
||||||
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">4</div>
|
|
||||||
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">5</div>
|
|
||||||
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">6</div>
|
|
||||||
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">7</div>
|
|
||||||
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">8</div>
|
|
||||||
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">9</div>
|
|
||||||
<div className="bg-intel-accent/30 text-white font-mono text-xs p-1 rounded font-bold border border-intel-accent">10</div>
|
|
||||||
{/* Week 3 */}
|
|
||||||
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">11</div>
|
|
||||||
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">12</div>
|
|
||||||
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">13</div>
|
|
||||||
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">14</div>
|
|
||||||
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">15</div>
|
|
||||||
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">16</div>
|
|
||||||
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">17</div>
|
|
||||||
{/* Week 4 */}
|
|
||||||
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">18</div>
|
|
||||||
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">19</div>
|
|
||||||
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">20</div>
|
|
||||||
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">21</div>
|
|
||||||
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">22</div>
|
|
||||||
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">23</div>
|
|
||||||
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">24</div>
|
|
||||||
{/* Week 5 */}
|
|
||||||
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">25</div>
|
|
||||||
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">26</div>
|
|
||||||
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">27</div>
|
|
||||||
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">28</div>
|
|
||||||
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">29</div>
|
|
||||||
<div className="text-gray-600 font-mono text-xs p-1">1</div>
|
|
||||||
<div className="text-gray-600 font-mono text-xs p-1">2</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Open Vendor Tickets */}
|
{/* Open Vendor Tickets */}
|
||||||
@@ -2347,10 +2361,101 @@ export default function App() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Ivanti Workflows */}
|
||||||
|
<div style={{...STYLES.intelCard, padding: '1.5rem', borderLeft: '3px solid #0D9488'}} className="rounded-lg">
|
||||||
|
<div className="flex justify-between items-center mb-1">
|
||||||
|
<h2 style={{ fontSize: '1.125rem', fontWeight: '600', color: '#0D9488', display: 'flex', alignItems: 'center', gap: '0.5rem', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: '0.1em', textShadow: '0 0 12px rgba(13, 148, 136, 0.4)' }}>
|
||||||
|
<Activity className="w-5 h-5" />
|
||||||
|
Ivanti Workflows
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
onClick={syncIvantiWorkflows}
|
||||||
|
disabled={ivantiSyncing || ivantiLoading}
|
||||||
|
className="intel-button intel-button-primary flex items-center gap-1 text-xs px-2 py-1"
|
||||||
|
title="Sync now"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`w-3 h-3 ${ivantiSyncing ? 'animate-spin' : ''}`} />
|
||||||
|
{ivantiSyncing ? 'Syncing…' : 'Sync'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Last synced line */}
|
||||||
|
<div className="text-xs text-gray-500 font-mono mb-4">
|
||||||
|
{ivantiSyncedAt
|
||||||
|
? `Synced ${new Date(ivantiSyncedAt.replace(' ', 'T') + 'Z').toLocaleString()}`
|
||||||
|
: 'Never synced'}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{ivantiLoading ? (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<Loader className="w-6 h-6 text-teal-400 animate-spin mx-auto mb-2" />
|
||||||
|
<p className="text-xs text-gray-400 font-mono">Loading...</p>
|
||||||
|
</div>
|
||||||
|
) : ivantiSyncStatus === 'error' ? (
|
||||||
|
<>
|
||||||
|
<div className="text-center mb-3">
|
||||||
|
<div style={{ fontSize: '2rem', fontWeight: '700', fontFamily: 'monospace', color: '#0D9488', textShadow: '0 0 16px rgba(13, 148, 136, 0.4)' }}>
|
||||||
|
{ivantiTotal ?? '—'}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-400 uppercase tracking-wider">Total Workflows</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start gap-2 p-2 rounded" style={{ background: 'rgba(239, 68, 68, 0.1)', border: '1px solid rgba(239, 68, 68, 0.3)' }}>
|
||||||
|
<AlertCircle className="w-4 h-4 text-intel-danger mt-0.5 shrink-0" />
|
||||||
|
<p className="text-xs text-red-400 font-mono">{ivantiSyncError}</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="text-center mb-3">
|
||||||
|
<div style={{ fontSize: '2rem', fontWeight: '700', fontFamily: 'monospace', color: '#0D9488', textShadow: '0 0 16px rgba(13, 148, 136, 0.4)' }}>
|
||||||
|
{ivantiSyncStatus === 'never' ? '—' : (ivantiTotal ?? '—')}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-400 uppercase tracking-wider">Total Workflows</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2 max-h-64 overflow-y-auto">
|
||||||
|
{ivantiWorkflows.slice(0, 10).map((wf, idx) => (
|
||||||
|
<div key={wf.uuid ?? idx} style={{ background: 'linear-gradient(135deg, rgba(30, 41, 59, 0.85) 0%, rgba(51, 65, 85, 0.75) 100%)', border: '1px solid rgba(13, 148, 136, 0.25)', borderRadius: '0.375rem', padding: '0.5rem', boxShadow: '0 2px 6px rgba(0, 0, 0, 0.25), inset 0 1px 0 rgba(255, 255, 255, 0.03)' }}>
|
||||||
|
<div className="flex items-start justify-between gap-2 mb-1">
|
||||||
|
<span className="font-mono text-xs font-semibold text-teal-300">
|
||||||
|
{wf.id?.value || wf.uuid?.slice(0, 8)}
|
||||||
|
</span>
|
||||||
|
{wf.currentState && (
|
||||||
|
<span style={{ fontSize: '0.65rem', padding: '0.2rem 0.4rem', borderRadius: '0.25rem', background: 'rgba(13, 148, 136, 0.2)', border: '1px solid #0D9488', color: '#0D9488', whiteSpace: 'nowrap', fontFamily: 'monospace' }}>
|
||||||
|
{wf.currentState}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-white truncate mb-1">{wf.name}</div>
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
{wf.type && (
|
||||||
|
<span className="text-xs text-gray-400 font-mono">{wf.type.replace(/_/g, ' ')}</span>
|
||||||
|
)}
|
||||||
|
{wf.createdOn && (
|
||||||
|
<span className="text-xs text-gray-500">{wf.createdOn}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{ivantiSyncStatus !== 'never' && ivantiTotal === 0 && (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<CheckCircle className="w-8 h-8 text-intel-success mx-auto mb-2" />
|
||||||
|
<p className="text-sm text-gray-400 italic font-mono">No workflows found</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{ivantiSyncStatus === 'never' && (
|
||||||
|
<div className="text-center py-6">
|
||||||
|
<p className="text-xs text-gray-500 font-mono">Click Sync to load workflow data</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* End Right Panel */}
|
{/* End Right Panel */}
|
||||||
|
|
||||||
</div>
|
</div>}
|
||||||
{/* End Three Column Layout */}
|
{/* End Three Column Layout */}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
167
frontend/src/components/CalendarWidget.js
Normal file
167
frontend/src/components/CalendarWidget.js
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||||
|
|
||||||
|
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
||||||
|
|
||||||
|
const MONTH_NAMES = [
|
||||||
|
'January', 'February', 'March', 'April', 'May', 'June',
|
||||||
|
'July', 'August', 'September', 'October', 'November', 'December'
|
||||||
|
];
|
||||||
|
const DAY_NAMES = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'];
|
||||||
|
|
||||||
|
function toLocalDateStr(date) {
|
||||||
|
const y = date.getFullYear();
|
||||||
|
const m = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
|
const d = String(date.getDate()).padStart(2, '0');
|
||||||
|
return `${y}-${m}-${d}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CalendarWidget({ onDateClick }) {
|
||||||
|
const today = new Date();
|
||||||
|
const todayStr = toLocalDateStr(today);
|
||||||
|
|
||||||
|
const [calYear, setCalYear] = useState(today.getFullYear());
|
||||||
|
const [calMonth, setCalMonth] = useState(today.getMonth()); // 0-indexed
|
||||||
|
|
||||||
|
// Map of "YYYY-MM-DD" → count of findings due that day
|
||||||
|
const [dueDates, setDueDates] = useState({});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch(`${API_BASE}/ivanti/findings`, { credentials: 'include' })
|
||||||
|
.then((r) => (r.ok ? r.json() : null))
|
||||||
|
.then((data) => {
|
||||||
|
if (!data?.findings) return;
|
||||||
|
const counts = {};
|
||||||
|
data.findings.forEach((f) => {
|
||||||
|
if (f.dueDate) {
|
||||||
|
counts[f.dueDate] = (counts[f.dueDate] || 0) + 1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
setDueDates(counts);
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const prevMonth = () => {
|
||||||
|
if (calMonth === 0) { setCalMonth(11); setCalYear((y) => y - 1); }
|
||||||
|
else { setCalMonth((m) => m - 1); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const nextMonth = () => {
|
||||||
|
if (calMonth === 11) { setCalMonth(0); setCalYear((y) => y + 1); }
|
||||||
|
else { setCalMonth((m) => m + 1); }
|
||||||
|
};
|
||||||
|
|
||||||
|
// Build cell array: null = padding, number = day of month
|
||||||
|
const firstDow = new Date(calYear, calMonth, 1).getDay(); // 0=Sun
|
||||||
|
const daysInMonth = new Date(calYear, calMonth + 1, 0).getDate();
|
||||||
|
const cells = [
|
||||||
|
...Array(firstDow).fill(null),
|
||||||
|
...Array.from({ length: daysInMonth }, (_, i) => i + 1),
|
||||||
|
];
|
||||||
|
while (cells.length % 7 !== 0) cells.push(null); // complete last row
|
||||||
|
|
||||||
|
const hasDueDatesThisMonth = cells.some((day) => {
|
||||||
|
if (!day) return false;
|
||||||
|
const ds = `${calYear}-${String(calMonth + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
|
||||||
|
return !!dueDates[ds];
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* Month navigation */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '0.75rem' }}>
|
||||||
|
<button
|
||||||
|
onClick={prevMonth}
|
||||||
|
style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#64748B', padding: '2px 4px', borderRadius: '4px', lineHeight: 1, transition: 'color 0.15s' }}
|
||||||
|
onMouseEnter={(e) => { e.currentTarget.style.color = '#0EA5E9'; }}
|
||||||
|
onMouseLeave={(e) => { e.currentTarget.style.color = '#64748B'; }}
|
||||||
|
>
|
||||||
|
<ChevronLeft style={{ width: '14px', height: '14px' }} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<span style={{ color: '#E2E8F0', fontFamily: 'monospace', fontWeight: '600', fontSize: '0.85rem' }}>
|
||||||
|
{MONTH_NAMES[calMonth]} {calYear}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={nextMonth}
|
||||||
|
style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#64748B', padding: '2px 4px', borderRadius: '4px', lineHeight: 1, transition: 'color 0.15s' }}
|
||||||
|
onMouseEnter={(e) => { e.currentTarget.style.color = '#0EA5E9'; }}
|
||||||
|
onMouseLeave={(e) => { e.currentTarget.style.color = '#64748B'; }}
|
||||||
|
>
|
||||||
|
<ChevronRight style={{ width: '14px', height: '14px' }} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Day-of-week headers */}
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', gap: '2px', textAlign: 'center', marginBottom: '4px' }}>
|
||||||
|
{DAY_NAMES.map((d) => (
|
||||||
|
<div key={d} style={{ fontSize: '0.6rem', color: '#475569', fontFamily: 'monospace', fontWeight: '600', textTransform: 'uppercase' }}>
|
||||||
|
{d}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Day cells */}
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', gap: '2px' }}>
|
||||||
|
{cells.map((day, idx) => {
|
||||||
|
if (!day) return <div key={idx} />;
|
||||||
|
|
||||||
|
const dateStr = `${calYear}-${String(calMonth + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
|
||||||
|
const isToday = dateStr === todayStr;
|
||||||
|
const dueCount = dueDates[dateStr] || 0;
|
||||||
|
const hasDue = dueCount > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
title={hasDue ? `${dueCount} finding${dueCount > 1 ? 's' : ''} due — click to view` : undefined}
|
||||||
|
onClick={hasDue && onDateClick ? () => onDateClick(dateStr) : undefined}
|
||||||
|
style={{
|
||||||
|
display: 'flex', flexDirection: 'column', alignItems: 'center',
|
||||||
|
gap: '2px', padding: '3px 1px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
background: isToday ? 'rgba(14,165,233,0.2)' : 'transparent',
|
||||||
|
border: isToday ? '1px solid rgba(14,165,233,0.5)' : '1px solid transparent',
|
||||||
|
cursor: hasDue ? 'pointer' : 'default',
|
||||||
|
transition: hasDue ? 'background 0.15s' : undefined,
|
||||||
|
}}
|
||||||
|
onMouseEnter={hasDue ? (e) => { e.currentTarget.style.background = isToday ? 'rgba(14,165,233,0.35)' : 'rgba(239,68,68,0.15)'; } : undefined}
|
||||||
|
onMouseLeave={hasDue ? (e) => { e.currentTarget.style.background = isToday ? 'rgba(14,165,233,0.2)' : 'transparent'; } : undefined}
|
||||||
|
>
|
||||||
|
<span style={{
|
||||||
|
fontSize: '0.7rem', fontFamily: 'monospace', lineHeight: 1,
|
||||||
|
color: isToday ? '#0EA5E9' : hasDue ? '#EF4444' : '#CBD5E1',
|
||||||
|
fontWeight: (isToday || hasDue) ? '700' : '400',
|
||||||
|
}}>
|
||||||
|
{day}
|
||||||
|
</span>
|
||||||
|
{/* Red dot indicator for due dates */}
|
||||||
|
{hasDue ? (
|
||||||
|
<div style={{
|
||||||
|
width: '4px', height: '4px', borderRadius: '50%',
|
||||||
|
background: '#EF4444',
|
||||||
|
boxShadow: '0 0 4px rgba(239,68,68,0.6)',
|
||||||
|
flexShrink: 0,
|
||||||
|
}} />
|
||||||
|
) : (
|
||||||
|
<div style={{ width: '4px', height: '4px' }} /> // spacer to keep rows even
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Legend — only shown when there are due dates this month */}
|
||||||
|
{hasDueDatesThisMonth && (
|
||||||
|
<div style={{ marginTop: '0.75rem', paddingTop: '0.625rem', borderTop: '1px solid rgba(255,255,255,0.05)', display: 'flex', alignItems: 'center', gap: '0.375rem' }}>
|
||||||
|
<div style={{ width: '6px', height: '6px', borderRadius: '50%', background: '#EF4444', boxShadow: '0 0 4px rgba(239,68,68,0.5)', flexShrink: 0 }} />
|
||||||
|
<span style={{ fontSize: '0.62rem', color: '#64748B', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
||||||
|
Ivanti finding due
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
127
frontend/src/components/NavDrawer.js
Normal file
127
frontend/src/components/NavDrawer.js
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { X, Home, BarChart2, BookOpen, Download } from 'lucide-react';
|
||||||
|
|
||||||
|
const NAV_ITEMS = [
|
||||||
|
{ id: 'home', label: 'Home', icon: Home, color: '#0EA5E9', description: 'Main dashboard' },
|
||||||
|
{ id: 'reporting', label: 'Reporting', icon: BarChart2,color: '#F59E0B', description: 'Reports & analytics' },
|
||||||
|
{ id: 'knowledge-base', label: 'Knowledge Base', icon: BookOpen, color: '#10B981', description: 'Articles & documentation' },
|
||||||
|
{ id: 'exports', label: 'Exports', icon: Download, color: '#8B5CF6', description: 'Export data & reports' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function NavDrawer({ isOpen, onClose, currentPage, onNavigate }) {
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Backdrop */}
|
||||||
|
<div
|
||||||
|
onClick={onClose}
|
||||||
|
style={{
|
||||||
|
position: 'fixed', inset: 0,
|
||||||
|
background: 'rgba(0, 0, 0, 0.65)',
|
||||||
|
backdropFilter: 'blur(3px)',
|
||||||
|
zIndex: 50
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Drawer */}
|
||||||
|
<div style={{
|
||||||
|
position: 'fixed', top: 0, left: 0, bottom: 0, width: '280px',
|
||||||
|
background: 'linear-gradient(180deg, #0F1A2E 0%, #0A1628 100%)',
|
||||||
|
borderRight: '1px solid rgba(14, 165, 233, 0.2)',
|
||||||
|
boxShadow: '6px 0 32px rgba(0, 0, 0, 0.7)',
|
||||||
|
zIndex: 51,
|
||||||
|
display: 'flex', flexDirection: 'column',
|
||||||
|
padding: '1.5rem'
|
||||||
|
}}>
|
||||||
|
{/* Drawer header */}
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '2rem' }}>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontFamily: 'monospace', fontSize: '1rem', fontWeight: '700', color: '#0EA5E9', textTransform: 'uppercase', letterSpacing: '0.15em', textShadow: '0 0 12px rgba(14, 165, 233, 0.5)' }}>
|
||||||
|
STEAM
|
||||||
|
</div>
|
||||||
|
<div style={{ fontFamily: 'monospace', fontSize: '0.65rem', color: '#475569', textTransform: 'uppercase', letterSpacing: '0.1em', marginTop: '2px' }}>
|
||||||
|
Security Dashboard
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
style={{ color: '#475569', padding: '0.25rem', background: 'none', border: 'none', cursor: 'pointer', lineHeight: 1 }}
|
||||||
|
onMouseEnter={e => e.currentTarget.style.color = '#E2E8F0'}
|
||||||
|
onMouseLeave={e => e.currentTarget.style.color = '#475569'}
|
||||||
|
>
|
||||||
|
<X style={{ width: '20px', height: '20px' }} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Nav items */}
|
||||||
|
<nav style={{ display: 'flex', flexDirection: 'column', gap: '0.375rem' }}>
|
||||||
|
{NAV_ITEMS.map(({ id, label, icon: Icon, color, description }) => {
|
||||||
|
const active = currentPage === id;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={id}
|
||||||
|
onClick={() => { onNavigate(id); onClose(); }}
|
||||||
|
style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: '0.875rem',
|
||||||
|
padding: '0.75rem 0.875rem',
|
||||||
|
borderRadius: '0.5rem',
|
||||||
|
border: active ? `1px solid ${color}50` : '1px solid transparent',
|
||||||
|
background: active ? `${color}18` : 'transparent',
|
||||||
|
cursor: 'pointer', textAlign: 'left', width: '100%',
|
||||||
|
transition: 'background 0.15s, border-color 0.15s'
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => { if (!active) e.currentTarget.style.background = 'rgba(255,255,255,0.04)'; }}
|
||||||
|
onMouseLeave={e => { if (!active) e.currentTarget.style.background = 'transparent'; }}
|
||||||
|
>
|
||||||
|
{/* Icon box */}
|
||||||
|
<div style={{
|
||||||
|
width: '36px', height: '36px', flexShrink: 0,
|
||||||
|
borderRadius: '0.375rem',
|
||||||
|
background: `${color}18`,
|
||||||
|
border: `1px solid ${color}40`,
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center'
|
||||||
|
}}>
|
||||||
|
<Icon style={{ width: '17px', height: '17px', color }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Label + description */}
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{
|
||||||
|
fontFamily: 'monospace', fontSize: '0.8rem', fontWeight: '600',
|
||||||
|
color: active ? color : '#CBD5E1',
|
||||||
|
textTransform: 'uppercase', letterSpacing: '0.06em'
|
||||||
|
}}>
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '0.68rem', color: '#475569', marginTop: '1px' }}>
|
||||||
|
{description}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Active indicator dot */}
|
||||||
|
{active && (
|
||||||
|
<div style={{
|
||||||
|
width: '6px', height: '6px', borderRadius: '50%',
|
||||||
|
background: color, boxShadow: `0 0 8px ${color}`, flexShrink: 0
|
||||||
|
}} />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div style={{
|
||||||
|
marginTop: 'auto', paddingTop: '1rem',
|
||||||
|
borderTop: '1px solid rgba(255, 255, 255, 0.05)',
|
||||||
|
textAlign: 'center'
|
||||||
|
}}>
|
||||||
|
<div style={{ fontSize: '0.6rem', color: '#1E293B', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: '0.1em' }}>
|
||||||
|
NTS Threat Intelligence
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,291 +0,0 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
|
||||||
import { X, Loader, AlertCircle, CheckCircle, Upload as UploadIcon, Download, Star } from 'lucide-react';
|
|
||||||
|
|
||||||
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
|
||||||
|
|
||||||
export default function WeeklyReportModal({ onClose }) {
|
|
||||||
const [phase, setPhase] = useState('idle'); // idle, uploading, processing, success, error
|
|
||||||
const [selectedFile, setSelectedFile] = useState(null);
|
|
||||||
const [uploadProgress, setUploadProgress] = useState(0);
|
|
||||||
const [result, setResult] = useState(null);
|
|
||||||
const [existingReports, setExistingReports] = useState([]);
|
|
||||||
const [error, setError] = useState('');
|
|
||||||
|
|
||||||
// Fetch existing reports on mount
|
|
||||||
useEffect(() => {
|
|
||||||
fetchExistingReports();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const fetchExistingReports = async () => {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${API_BASE}/weekly-reports`, { credentials: 'include' });
|
|
||||||
if (!response.ok) throw new Error('Failed to fetch reports');
|
|
||||||
const data = await response.json();
|
|
||||||
setExistingReports(data);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error fetching reports:', err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleFileSelect = (e) => {
|
|
||||||
const file = e.target.files[0];
|
|
||||||
if (file) {
|
|
||||||
if (!file.name.endsWith('.xlsx')) {
|
|
||||||
setError('Please select an Excel file (.xlsx)');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setSelectedFile(file);
|
|
||||||
setError('');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleUpload = async () => {
|
|
||||||
if (!selectedFile) return;
|
|
||||||
|
|
||||||
setPhase('uploading');
|
|
||||||
setUploadProgress(0);
|
|
||||||
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append('file', selectedFile);
|
|
||||||
|
|
||||||
try {
|
|
||||||
setUploadProgress(50); // Simulated progress
|
|
||||||
setPhase('processing');
|
|
||||||
|
|
||||||
const response = await fetch(`${API_BASE}/weekly-reports/upload`, {
|
|
||||||
method: 'POST',
|
|
||||||
body: formData,
|
|
||||||
credentials: 'include'
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorData = await response.json();
|
|
||||||
throw new Error(errorData.error || 'Upload failed');
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
setResult(data);
|
|
||||||
setPhase('success');
|
|
||||||
|
|
||||||
// Refresh the list of existing reports
|
|
||||||
await fetchExistingReports();
|
|
||||||
} catch (err) {
|
|
||||||
setError(err.message);
|
|
||||||
setPhase('error');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDownload = async (id, type) => {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${API_BASE}/weekly-reports/${id}/download/${type}`, {
|
|
||||||
credentials: 'include'
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) throw new Error('Download failed');
|
|
||||||
|
|
||||||
const blob = await response.blob();
|
|
||||||
const url = window.URL.createObjectURL(blob);
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = url;
|
|
||||||
a.download = `vulnerability_report_${type}.xlsx`;
|
|
||||||
document.body.appendChild(a);
|
|
||||||
a.click();
|
|
||||||
window.URL.revokeObjectURL(url);
|
|
||||||
document.body.removeChild(a);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error downloading file:', err);
|
|
||||||
setError(`Failed to download ${type} file`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const resetForm = () => {
|
|
||||||
setPhase('idle');
|
|
||||||
setSelectedFile(null);
|
|
||||||
setUploadProgress(0);
|
|
||||||
setResult(null);
|
|
||||||
setError('');
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="modal-overlay" onClick={onClose}>
|
|
||||||
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
|
|
||||||
{/* Header */}
|
|
||||||
<div className="modal-header">
|
|
||||||
<h2 className="modal-title">Weekly Vulnerability Report</h2>
|
|
||||||
<button onClick={onClose} className="modal-close">
|
|
||||||
<X className="w-5 h-5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Body */}
|
|
||||||
<div className="modal-body">
|
|
||||||
{/* Idle Phase - File Selection */}
|
|
||||||
{phase === 'idle' && (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium mb-2" style={{ color: '#94A3B8' }}>
|
|
||||||
Upload Excel File (.xlsx)
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
accept=".xlsx"
|
|
||||||
onChange={handleFileSelect}
|
|
||||||
className="intel-input w-full"
|
|
||||||
/>
|
|
||||||
{selectedFile && (
|
|
||||||
<p className="mt-2 text-sm" style={{ color: '#10B981' }}>
|
|
||||||
Selected: {selectedFile.name}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={handleUpload}
|
|
||||||
disabled={!selectedFile}
|
|
||||||
className={`intel-button w-full ${selectedFile ? 'intel-button-success' : 'opacity-50 cursor-not-allowed'}`}
|
|
||||||
>
|
|
||||||
<UploadIcon className="w-4 h-4 mr-2" />
|
|
||||||
Upload & Process
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<div className="flex items-start gap-2 p-3 rounded" style={{ background: 'rgba(239, 68, 68, 0.1)', border: '1px solid #EF4444' }}>
|
|
||||||
<AlertCircle className="w-5 h-5 flex-shrink-0" style={{ color: '#EF4444' }} />
|
|
||||||
<p style={{ color: '#FCA5A5' }}>{error}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Uploading Phase */}
|
|
||||||
{phase === 'uploading' && (
|
|
||||||
<div className="text-center py-8">
|
|
||||||
<Loader className="w-12 h-12 animate-spin mx-auto mb-4" style={{ color: '#0EA5E9' }} />
|
|
||||||
<p style={{ color: '#94A3B8' }}>Uploading file...</p>
|
|
||||||
<div className="w-full bg-gray-700 rounded-full h-2 mt-4">
|
|
||||||
<div
|
|
||||||
className="h-2 rounded-full transition-all"
|
|
||||||
style={{ width: `${uploadProgress}%`, background: '#0EA5E9' }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Processing Phase */}
|
|
||||||
{phase === 'processing' && (
|
|
||||||
<div className="text-center py-8">
|
|
||||||
<Loader className="w-12 h-12 animate-spin mx-auto mb-4" style={{ color: '#0EA5E9' }} />
|
|
||||||
<p style={{ color: '#94A3B8' }}>Processing vulnerability report...</p>
|
|
||||||
<p className="text-sm mt-2" style={{ color: '#64748B' }}>Splitting CVE IDs into separate rows</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Success Phase */}
|
|
||||||
{phase === 'success' && result && (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="flex items-center gap-2 p-4 rounded" style={{ background: 'rgba(16, 185, 129, 0.1)', border: '1px solid #10B981' }}>
|
|
||||||
<CheckCircle className="w-6 h-6" style={{ color: '#10B981' }} />
|
|
||||||
<div>
|
|
||||||
<p className="font-medium" style={{ color: '#34D399' }}>Upload Successful!</p>
|
|
||||||
<p className="text-sm mt-1" style={{ color: '#94A3B8' }}>
|
|
||||||
Original: {result.original_rows} rows → Processed: {result.processed_rows} rows
|
|
||||||
<span className="ml-2" style={{ color: '#10B981' }}>
|
|
||||||
(+{result.processed_rows - result.original_rows} rows from splitting CVEs)
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-3">
|
|
||||||
<button
|
|
||||||
onClick={() => handleDownload(result.id, 'original')}
|
|
||||||
className="intel-button flex-1"
|
|
||||||
>
|
|
||||||
<Download className="w-4 h-4 mr-2" />
|
|
||||||
Download Original
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => handleDownload(result.id, 'processed')}
|
|
||||||
className="intel-button intel-button-success flex-1"
|
|
||||||
>
|
|
||||||
<Download className="w-4 h-4 mr-2" />
|
|
||||||
Download Processed
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button onClick={resetForm} className="intel-button w-full">
|
|
||||||
Upload Another Report
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Error Phase */}
|
|
||||||
{phase === 'error' && (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="flex items-start gap-2 p-4 rounded" style={{ background: 'rgba(239, 68, 68, 0.1)', border: '1px solid #EF4444' }}>
|
|
||||||
<AlertCircle className="w-6 h-6 flex-shrink-0" style={{ color: '#EF4444' }} />
|
|
||||||
<div>
|
|
||||||
<p className="font-medium" style={{ color: '#FCA5A5' }}>Upload Failed</p>
|
|
||||||
<p className="text-sm mt-1" style={{ color: '#94A3B8' }}>{error}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button onClick={resetForm} className="intel-button w-full">
|
|
||||||
Try Again
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Existing Reports Section */}
|
|
||||||
{(phase === 'idle' || phase === 'success') && existingReports.length > 0 && (
|
|
||||||
<div className="mt-8">
|
|
||||||
<h3 className="text-lg font-medium mb-4" style={{ color: '#94A3B8' }}>
|
|
||||||
Previous Reports
|
|
||||||
</h3>
|
|
||||||
<div className="space-y-3">
|
|
||||||
{existingReports.map((report) => (
|
|
||||||
<div
|
|
||||||
key={report.id}
|
|
||||||
className="intel-card p-4"
|
|
||||||
>
|
|
||||||
<div className="flex items-start justify-between">
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="flex items-center gap-2 mb-2">
|
|
||||||
{report.is_current && (
|
|
||||||
<Star className="w-4 h-4 fill-current" style={{ color: '#F59E0B' }} />
|
|
||||||
)}
|
|
||||||
<p className="font-medium" style={{ color: report.is_current ? '#F59E0B' : '#94A3B8' }}>
|
|
||||||
{report.week_label}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm" style={{ color: '#64748B' }}>
|
|
||||||
{new Date(report.upload_date).toLocaleDateString()} •
|
|
||||||
{report.row_count_original} → {report.row_count_processed} rows
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<button
|
|
||||||
onClick={() => handleDownload(report.id, 'original')}
|
|
||||||
className="intel-button intel-button-small"
|
|
||||||
title="Download Original"
|
|
||||||
>
|
|
||||||
<Download className="w-3 h-3" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => handleDownload(report.id, 'processed')}
|
|
||||||
className="intel-button intel-button-success intel-button-small"
|
|
||||||
title="Download Processed"
|
|
||||||
>
|
|
||||||
<Download className="w-3 h-3" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
25
frontend/src/components/pages/ExportsPage.js
Normal file
25
frontend/src/components/pages/ExportsPage.js
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Download } from 'lucide-react';
|
||||||
|
|
||||||
|
export default function ExportsPage() {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: '60vh' }}>
|
||||||
|
<div style={{ textAlign: 'center' }}>
|
||||||
|
<div style={{
|
||||||
|
width: '72px', height: '72px', borderRadius: '1rem', margin: '0 auto 1.5rem',
|
||||||
|
background: 'rgba(139, 92, 246, 0.1)',
|
||||||
|
border: '1px solid rgba(139, 92, 246, 0.3)',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center'
|
||||||
|
}}>
|
||||||
|
<Download style={{ width: '36px', height: '36px', color: '#8B5CF6' }} />
|
||||||
|
</div>
|
||||||
|
<h2 style={{ fontFamily: 'monospace', fontSize: '1.5rem', fontWeight: '700', color: '#8B5CF6', textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: '0.5rem' }}>
|
||||||
|
Exports
|
||||||
|
</h2>
|
||||||
|
<p style={{ color: '#475569', fontSize: '0.875rem', fontFamily: 'monospace' }}>
|
||||||
|
Under construction — coming soon
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
25
frontend/src/components/pages/KnowledgeBasePage.js
Normal file
25
frontend/src/components/pages/KnowledgeBasePage.js
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { BookOpen } from 'lucide-react';
|
||||||
|
|
||||||
|
export default function KnowledgeBasePage() {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: '60vh' }}>
|
||||||
|
<div style={{ textAlign: 'center' }}>
|
||||||
|
<div style={{
|
||||||
|
width: '72px', height: '72px', borderRadius: '1rem', margin: '0 auto 1.5rem',
|
||||||
|
background: 'rgba(16, 185, 129, 0.1)',
|
||||||
|
border: '1px solid rgba(16, 185, 129, 0.3)',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center'
|
||||||
|
}}>
|
||||||
|
<BookOpen style={{ width: '36px', height: '36px', color: '#10B981' }} />
|
||||||
|
</div>
|
||||||
|
<h2 style={{ fontFamily: 'monospace', fontSize: '1.5rem', fontWeight: '700', color: '#10B981', textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: '0.5rem' }}>
|
||||||
|
Knowledge Base
|
||||||
|
</h2>
|
||||||
|
<p style={{ color: '#475569', fontSize: '0.875rem', fontFamily: 'monospace' }}>
|
||||||
|
Under construction — coming soon
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
1222
frontend/src/components/pages/ReportingPage.js
Normal file
1222
frontend/src/components/pages/ReportingPage.js
Normal file
File diff suppressed because it is too large
Load Diff
297
plan.md
297
plan.md
@@ -1,297 +0,0 @@
|
|||||||
# NVD Lookup + Retroactive Sync — Implementation Plan
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
Two capabilities on `feature/nvd-lookup` branch:
|
|
||||||
1. **Auto-fill on Add CVE** (DONE, stashed) — onBlur NVD lookup fills description/severity/date in the Add CVE modal
|
|
||||||
2. **Sync with NVD** (TO DO) — bulk tool for editors/admins to retroactively update existing CVE entries from NVD, with per-CVE choice to keep or replace description
|
|
||||||
|
|
||||||
## Current State
|
|
||||||
|
|
||||||
### Git State
|
|
||||||
- **Branch:** `feature/nvd-lookup` (branched from master post-audit-merge)
|
|
||||||
- **Stash:** `stash@{0}` contains the auto-fill implementation (4 files)
|
|
||||||
- **Master** now has audit logging (merged from feature/audit on 2026-01-30)
|
|
||||||
- Offsite repo is up to date through the feature/audit merge to master
|
|
||||||
|
|
||||||
### What's in the Stash
|
|
||||||
The stash contains working NVD auto-fill code that needs to be popped and conflict-resolved before continuing:
|
|
||||||
|
|
||||||
**`backend/routes/nvdLookup.js` (NEW file)**
|
|
||||||
- Factory function: `createNvdLookupRouter(db, requireAuth)`
|
|
||||||
- `GET /lookup/:cveId` endpoint
|
|
||||||
- Validates CVE ID format (regex: `CVE-YYYY-NNNNN`)
|
|
||||||
- Calls `https://services.nvd.nist.gov/rest/json/cves/2.0?cveId=...`
|
|
||||||
- 10-second timeout via `AbortSignal.timeout(10000)`
|
|
||||||
- Optional `apiKey` header from `NVD_API_KEY` env var
|
|
||||||
- CVSS severity cascade: v3.1 → v3.0 → v2.0
|
|
||||||
- Maps NVD uppercase severity to app format (CRITICAL→Critical, etc.)
|
|
||||||
- Returns: `{ description, severity, published_date }`
|
|
||||||
|
|
||||||
**`backend/server.js` (MODIFIED)**
|
|
||||||
- Adds `const createNvdLookupRouter = require('./routes/nvdLookup');`
|
|
||||||
- Adds `app.use('/api/nvd', createNvdLookupRouter(db, requireAuth));`
|
|
||||||
|
|
||||||
**`frontend/src/App.js` (MODIFIED)**
|
|
||||||
- New state: `nvdLoading`, `nvdError`, `nvdAutoFilled`
|
|
||||||
- New function: `lookupNVD(cveId)` — calls backend, auto-fills form fields
|
|
||||||
- CVE ID input: `onBlur` triggers lookup, `onChange` resets NVD feedback
|
|
||||||
- Spinner (Loader icon) in CVE ID field while loading
|
|
||||||
- Green "Auto-filled from NVD" with CheckCircle on success
|
|
||||||
- Amber warning with AlertCircle on errors (non-blocking)
|
|
||||||
- Description only fills if currently empty; severity + published_date always update
|
|
||||||
- NVD state resets on modal close (X, Cancel) and form submit
|
|
||||||
|
|
||||||
**`backend/.env.example` (MODIFIED)**
|
|
||||||
- Adds `NVD_API_KEY=` with comment about rate limits
|
|
||||||
|
|
||||||
### Stash Conflict Resolution
|
|
||||||
Popping the stash will conflict in `server.js` because master now has audit imports that didn't exist when the stash was created. Resolution:
|
|
||||||
|
|
||||||
The conflict is in the imports section. Keep ALL existing audit lines from master:
|
|
||||||
```js
|
|
||||||
const createAuditLogRouter = require('./routes/auditLog');
|
|
||||||
const logAudit = require('./helpers/auditLog');
|
|
||||||
```
|
|
||||||
AND add the NVD line:
|
|
||||||
```js
|
|
||||||
const createNvdLookupRouter = require('./routes/nvdLookup');
|
|
||||||
```
|
|
||||||
|
|
||||||
Similarly, keep the audit route mount and add the NVD mount after it:
|
|
||||||
```js
|
|
||||||
app.use('/api/audit-logs', createAuditLogRouter(db, requireAuth, requireRole));
|
|
||||||
app.use('/api/nvd', createNvdLookupRouter(db, requireAuth));
|
|
||||||
```
|
|
||||||
|
|
||||||
Then `git add backend/server.js` to mark resolved and `git stash drop`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Step 1: Resolve Stash + Rebase onto Master
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git checkout feature/nvd-lookup
|
|
||||||
git rebase master # Get audit changes into the branch
|
|
||||||
git stash pop # Apply NVD changes (will conflict in server.js)
|
|
||||||
# Resolve conflict in server.js as described above
|
|
||||||
git add backend/server.js
|
|
||||||
git stash drop
|
|
||||||
```
|
|
||||||
|
|
||||||
Verify: `backend/routes/nvdLookup.js` exists, `server.js` has both audit AND NVD imports/mounts.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Step 2: Backend — New Endpoints in `server.js`
|
|
||||||
|
|
||||||
### 2A: `GET /api/cves/distinct-ids`
|
|
||||||
Place BEFORE `GET /api/cves/check/:cveId` (to avoid route param conflict):
|
|
||||||
```js
|
|
||||||
app.get('/api/cves/distinct-ids', requireAuth(db), (req, res) => {
|
|
||||||
db.all('SELECT DISTINCT cve_id FROM cves ORDER BY cve_id', [], (err, rows) => {
|
|
||||||
if (err) return res.status(500).json({ error: err.message });
|
|
||||||
res.json(rows.map(r => r.cve_id));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2B: `POST /api/cves/nvd-sync`
|
|
||||||
Place after the existing `PATCH /api/cves/:cveId/status`:
|
|
||||||
```js
|
|
||||||
app.post('/api/cves/nvd-sync', requireAuth(db), requireRole('editor', 'admin'), (req, res) => {
|
|
||||||
const { updates } = req.body;
|
|
||||||
if (!Array.isArray(updates) || updates.length === 0) {
|
|
||||||
return res.status(400).json({ error: 'No updates provided' });
|
|
||||||
}
|
|
||||||
|
|
||||||
let updated = 0;
|
|
||||||
const errors = [];
|
|
||||||
let completed = 0;
|
|
||||||
|
|
||||||
db.serialize(() => {
|
|
||||||
updates.forEach((entry) => {
|
|
||||||
const fields = [];
|
|
||||||
const values = [];
|
|
||||||
if (entry.description !== null && entry.description !== undefined) {
|
|
||||||
fields.push('description = ?');
|
|
||||||
values.push(entry.description);
|
|
||||||
}
|
|
||||||
if (entry.severity !== null && entry.severity !== undefined) {
|
|
||||||
fields.push('severity = ?');
|
|
||||||
values.push(entry.severity);
|
|
||||||
}
|
|
||||||
if (entry.published_date !== null && entry.published_date !== undefined) {
|
|
||||||
fields.push('published_date = ?');
|
|
||||||
values.push(entry.published_date);
|
|
||||||
}
|
|
||||||
if (fields.length === 0) {
|
|
||||||
completed++;
|
|
||||||
if (completed === updates.length) sendResponse();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
fields.push('updated_at = CURRENT_TIMESTAMP');
|
|
||||||
values.push(entry.cve_id);
|
|
||||||
|
|
||||||
db.run(
|
|
||||||
`UPDATE cves SET ${fields.join(', ')} WHERE cve_id = ?`,
|
|
||||||
values,
|
|
||||||
function(err) {
|
|
||||||
if (err) {
|
|
||||||
errors.push({ cve_id: entry.cve_id, error: err.message });
|
|
||||||
} else {
|
|
||||||
updated += this.changes;
|
|
||||||
}
|
|
||||||
completed++;
|
|
||||||
if (completed === updates.length) sendResponse();
|
|
||||||
}
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
function sendResponse() {
|
|
||||||
logAudit(db, {
|
|
||||||
userId: req.user.id,
|
|
||||||
username: req.user.username,
|
|
||||||
action: 'cve_nvd_sync',
|
|
||||||
entityType: 'cve',
|
|
||||||
entityId: null,
|
|
||||||
details: { count: updated, cve_ids: updates.map(u => u.cve_id) },
|
|
||||||
ipAddress: req.ip
|
|
||||||
});
|
|
||||||
const result = { message: 'NVD sync completed', updated };
|
|
||||||
if (errors.length > 0) result.errors = errors;
|
|
||||||
res.json(result);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
**How "keep existing description" works:** If the user chooses to keep the existing description, the frontend sends `description: null` for that CVE. The backend skips null fields, so the description is not overwritten. Severity and published_date are always sent (auto-update).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Step 3: Frontend — New `NvdSyncModal.js` Component
|
|
||||||
|
|
||||||
**File:** `frontend/src/components/NvdSyncModal.js`
|
|
||||||
|
|
||||||
### Props
|
|
||||||
```jsx
|
|
||||||
<NvdSyncModal onClose={fn} onSyncComplete={fn} />
|
|
||||||
```
|
|
||||||
|
|
||||||
### Phase Machine
|
|
||||||
| Phase | What's shown |
|
|
||||||
|-------|-------------|
|
|
||||||
| `idle` | CVE count + "Fetch NVD Data" button |
|
|
||||||
| `fetching` | Progress bar, current CVE being fetched, cancel button |
|
|
||||||
| `review` | Comparison table with per-CVE description choice |
|
|
||||||
| `applying` | Spinner |
|
|
||||||
| `done` | Summary (X updated, Y errors) + Close button |
|
|
||||||
|
|
||||||
### Fetching Logic
|
|
||||||
- Iterate CVE IDs sequentially
|
|
||||||
- Call `GET /api/nvd/lookup/:cveId` for each
|
|
||||||
- 7-second delay between requests (safe for 5 req/30s without API key)
|
|
||||||
- On 429: wait 35 seconds, retry once
|
|
||||||
- On 404: mark as "Not found in NVD" (gray, skipped)
|
|
||||||
- On timeout/error: mark with warning (skipped)
|
|
||||||
- Support cancellation via AbortController
|
|
||||||
|
|
||||||
### Comparison Table Columns
|
|
||||||
| Column | Content |
|
|
||||||
|--------|---------|
|
|
||||||
| CVE ID | The identifier |
|
|
||||||
| Status | Icon: check=found, warning=error, dash=no changes |
|
|
||||||
| Severity | `[Current] → [NVD]` with color badges, or "No change" |
|
|
||||||
| Published Date | `Current → NVD` or "No change" |
|
|
||||||
| Description | Truncated preview with expand toggle. Current (red bg) vs NVD (green bg) when different |
|
|
||||||
| Choice | Radio: "Keep existing" (default) / "Use NVD" — only shown when descriptions differ |
|
|
||||||
|
|
||||||
### Bulk Controls
|
|
||||||
Above the table:
|
|
||||||
- Summary: `Found: N | Up to date: N | Changes: N | Not in NVD: N | Errors: N`
|
|
||||||
- Bulk toggle: "Keep All Existing" / "Use All NVD Descriptions"
|
|
||||||
|
|
||||||
Below the table:
|
|
||||||
- "Apply N Changes" button (count updates dynamically)
|
|
||||||
- "Cancel" button
|
|
||||||
|
|
||||||
### Apply Logic
|
|
||||||
Build updates array:
|
|
||||||
- For each CVE with NVD data (no error):
|
|
||||||
- Always include `severity` and `published_date` if different from current
|
|
||||||
- Include `description` only if user chose "Use NVD" — otherwise send `null`
|
|
||||||
- Skip CVEs where nothing changed
|
|
||||||
- POST to `/api/cves/nvd-sync`
|
|
||||||
- On success: call `onSyncComplete()` to refresh CVE list, then show done phase
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Step 4: Frontend — App.js Integration
|
|
||||||
|
|
||||||
Minimal changes following `AuditLog`/`UserManagement` pattern:
|
|
||||||
|
|
||||||
1. **Import:** Add `NvdSyncModal` and `RefreshCw` icon
|
|
||||||
2. **State:** Add `const [showNvdSync, setShowNvdSync] = useState(false);`
|
|
||||||
3. **Header button** (next to "Add CVE/Vendor", visible to editors/admins):
|
|
||||||
```jsx
|
|
||||||
{canWrite() && (
|
|
||||||
<button onClick={() => setShowNvdSync(true)}
|
|
||||||
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors flex items-center gap-2 shadow-md">
|
|
||||||
<RefreshCw className="w-5 h-5" />
|
|
||||||
Sync with NVD
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
```
|
|
||||||
4. **Modal render** (alongside other modals):
|
|
||||||
```jsx
|
|
||||||
{showNvdSync && (
|
|
||||||
<NvdSyncModal onClose={() => setShowNvdSync(false)} onSyncComplete={() => fetchCVEs()} />
|
|
||||||
)}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Step 5: AuditLog Badge
|
|
||||||
|
|
||||||
**File:** `frontend/src/components/AuditLog.js`
|
|
||||||
|
|
||||||
Add to the `ACTION_BADGES` object:
|
|
||||||
```js
|
|
||||||
cve_nvd_sync: { bg: 'bg-green-100', text: 'text-green-800' },
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Step 6: .env.example (already in stash)
|
|
||||||
```
|
|
||||||
# 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=
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## File Summary
|
|
||||||
|
|
||||||
| File | Action | Lines Changed (est.) |
|
|
||||||
|------|--------|---------------------|
|
|
||||||
| `backend/server.js` | Modify | +40 (NVD mount + 2 new endpoints) |
|
|
||||||
| `backend/routes/nvdLookup.js` | From stash | 0 (already complete) |
|
|
||||||
| `backend/.env.example` | From stash | +3 |
|
|
||||||
| `frontend/src/components/NvdSyncModal.js` | New | ~350-400 |
|
|
||||||
| `frontend/src/App.js` | Modify | +10 (import, state, button, modal) |
|
|
||||||
| `frontend/src/components/AuditLog.js` | Modify | +1 (badge entry) |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Verification Checklist
|
|
||||||
1. Pop stash, resolve conflict, verify `nvdLookup.js` and server.js are correct
|
|
||||||
2. Test NVD lookup via curl: `curl -b cookie.txt http://localhost:3001/api/nvd/lookup/CVE-2024-3094`
|
|
||||||
3. Test distinct-ids: `curl -b cookie.txt http://localhost:3001/api/cves/distinct-ids`
|
|
||||||
4. Open Add CVE modal, type CVE ID, tab out → verify auto-fill works
|
|
||||||
5. Click "Sync with NVD" button → modal opens with CVE count
|
|
||||||
6. Click "Fetch NVD Data" → progress bar, rate-limited fetching
|
|
||||||
7. Review comparison table → verify diffs shown correctly
|
|
||||||
8. Toggle description choices, click "Apply" → verify database updated
|
|
||||||
9. Confirm main CVE list refreshes with new data
|
|
||||||
10. Check audit log for `cve_nvd_sync` entry
|
|
||||||
@@ -1,7 +1,5 @@
|
|||||||
# Authentication Feature - Test Cases
|
# Authentication Feature - Test Cases
|
||||||
|
|
||||||
**Feature Branch:** feature/login
|
|
||||||
**Date:** 2026-01-28
|
|
||||||
**Tester:** _______________
|
**Tester:** _______________
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
Reference in New Issue
Block a user