3 Commits

Author SHA1 Message Date
3fb20c147d Add Ivanti Workflows panel with API key auth and SQLite cache
- New panel below Archer tickets showing workflow count and list
- Backend proxies platform4.risksense.com workflowBatch/search via x-api-key
- SQLite cache table (ivanti_sync_state) stores latest sync result
- Auto-syncs on server startup if >24h stale, then every 24h via setInterval
- POST /api/ivanti/workflows/sync for on-demand sync with spinner feedback
- GET /api/ivanti/workflows returns cached data instantly (no live API call)
- Displays id.value, name, currentState, type, createdOn per workflow
- Shows last-synced timestamp and error messages inline
- IVANTI_SKIP_TLS flag for Charter SSL proxy environments

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-10 15:29:33 -06:00
f2e6069c08 docs: overhaul documentation for fork readiness
- Rewrite README from scratch: accurate stack versions, correct setup
  sequence, verified feature list, full API reference, architecture
  overview, and security model — all sourced directly from the codebase
- Remove internal/stale docs: COLOR_SCHEME_MODERNIZATION.md, plan.md,
  frontend/README.md (CRA boilerplate)
- Clean up DESIGN_SYSTEM.md: remove emoji headers and version footer
- Fix WEEKLY_REPORT_FEATURE.md: replace hardcoded absolute paths with
  relative paths
- Clean up test_cases_auth.md: remove stale branch and date references

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-26 14:30:17 -07:00
c89404cf26 Add CVE list pagination to prevent endless scrolling
Shows 5 CVEs by default with 'Show 5 more' and 'Show all' controls.
Resets to 5 when filters or search change. Collapses back when fully expanded.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-23 12:37:44 -07:00
12 changed files with 868 additions and 1949 deletions

View File

@@ -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.

View File

@@ -1,6 +1,6 @@
# CVE Intelligence Dashboard - Design System Reference
## 🎨 Color Palette
## Color Palette
### Primary Colors
```css
@@ -33,7 +33,7 @@
| **Medium** | `#0EA5E9` | `rgba(14, 165, 233, 0.25)` | `#7DD3FC` | `#0EA5E9` |
| **Low** | `#10B981` | `rgba(16, 185, 129, 0.25)` | `#6EE7B7` | `#10B981` |
## 📐 Layout Structure
## Layout Structure
### Three-Column Grid Layout
```
@@ -60,7 +60,7 @@
- **Desktop (lg+)**: 3-column layout (3-6-3 grid)
- **Tablet/Mobile**: Stacked single column
## 🎯 Component Specifications
## Component Specifications
### Stat Cards
```css
@@ -117,7 +117,7 @@ Letter Spacing: 0.5px
Glow Dot: 8px circle with pulse animation
```
## Interactions & Animations
## Interactions & Animations
### Hover Effects
- **Cards**: `translateY(-2px)`, enhanced border, subtle glow
@@ -151,7 +151,7 @@ Fast: all 0.2s ease
Ripple: width/height 0.5s
```
## 🔤 Typography
## Typography
### Font Families
```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)
```
## 🎨 Visual Effects
## Visual Effects
### Shadows
```css
@@ -223,7 +223,7 @@ linear-gradient(rgba(14, 165, 233, 0.025) 1px, transparent 1px)
Size: 20px × 20px
```
## 🧩 Specific Component Patterns
## Specific Component Patterns
### Wiki/Knowledge Base Entry
```css
@@ -261,7 +261,7 @@ Chevron: Rotate -90deg (collapsed) to 0deg (expanded)
Vendor Cards: Nested with reduced opacity borders
```
## 📱 Accessibility
## Accessibility
### Contrast Ratios
- 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
- Letter spacing: Generous for uppercase labels
## 🎯 Design Principles
## Design Principles
1. **Professional Sophistication**: Modern enterprise feel, not arcade
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
7. **Generous Spacing**: Breathing room prevents overwhelming density
---
**Last Updated**: February 10, 2026
**Version**: 2.0 (Modern Professional Redesign)

1841
README.md

File diff suppressed because it is too large Load Diff

View File

@@ -48,13 +48,13 @@ A new feature has been added to the CVE Dashboard that allows users to upload th
1. **Backend:**
```bash
cd /home/admin/cve-dashboard/backend
cd backend
node server.js
```
2. **Frontend:**
```bash
cd /home/admin/cve-dashboard/frontend
cd frontend
npm start
```

View File

@@ -6,3 +6,12 @@ CORS_ORIGINS=http://localhost:3000
# 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=
# 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

View 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!');
});

View 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;

View File

@@ -21,6 +21,7 @@ const createNvdLookupRouter = require('./routes/nvdLookup');
const createWeeklyReportsRouter = require('./routes/weeklyReports');
const createKnowledgeBaseRouter = require('./routes/knowledgeBase');
const createArcherTicketsRouter = require('./routes/archerTickets');
const createIvantiWorkflowsRouter = require('./routes/ivantiWorkflows');
const app = express();
const PORT = process.env.PORT || 3001;
@@ -183,6 +184,9 @@ app.use('/api/knowledge-base', createKnowledgeBaseRouter(db, upload));
// Archer tickets routes (editor/admin for create/update/delete, all authenticated for view)
app.use('/api/archer-tickets', createArcherTicketsRouter(db));
// Ivanti / RiskSense workflow routes (all authenticated users)
app.use('/api/ivanti/workflows', createIvantiWorkflowsRouter(db, requireAuth));
// ========== CVE ENDPOINTS ==========
// Get all CVEs with optional filters (authenticated users)

View File

@@ -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)

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react';
import { Search, FileText, AlertCircle, Download, Upload, Eye, Filter, CheckCircle, XCircle, Loader, Trash2, Plus, RefreshCw, Edit2, ChevronDown, Shield } from 'lucide-react';
import { Search, FileText, AlertCircle, Download, Upload, Eye, Filter, CheckCircle, XCircle, Loader, Trash2, Plus, RefreshCw, Edit2, ChevronDown, Shield, Activity } from 'lucide-react';
import { useAuth } from './contexts/AuthContext';
import LoginForm from './components/LoginForm';
import UserMenu from './components/UserMenu';
@@ -200,6 +200,7 @@ export default function App() {
const [editNvdError, setEditNvdError] = useState(null);
const [editNvdAutoFilled, setEditNvdAutoFilled] = useState(false);
const [expandedCVEs, setExpandedCVEs] = useState({});
const [visibleCount, setVisibleCount] = useState(5);
const [jiraTickets, setJiraTickets] = useState([]);
const [showAddTicket, setShowAddTicket] = useState(false);
const [showEditTicket, setShowEditTicket] = useState(false);
@@ -220,6 +221,15 @@ export default function App() {
});
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) => {
setExpandedCVEs(prev => ({ ...prev, [cveId]: !prev[cveId] }));
};
@@ -332,6 +342,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 key = `${cveId}-${vendor}`;
if (cveDocuments[key]) return;
@@ -860,6 +907,7 @@ export default function App() {
fetchVendors();
fetchJiraTickets();
fetchArcherTickets();
fetchIvantiWorkflows();
fetchKnowledgeBaseArticles();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -869,6 +917,7 @@ export default function App() {
useEffect(() => {
if (isAuthenticated) {
fetchCVEs();
setVisibleCount(5);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchQuery, selectedVendor, selectedSeverity]);
@@ -1829,7 +1878,7 @@ export default function App() {
</div>
) : (
<div className="space-y-4">
{Object.entries(filteredGroupedCVEs).map(([cveId, vendorEntries]) => {
{Object.entries(filteredGroupedCVEs).slice(0, visibleCount).map(([cveId, vendorEntries]) => {
const isCVEExpanded = expandedCVEs[cveId];
const severityOrder = { 'Critical': 0, 'High': 1, 'Medium': 2, 'Low': 3 };
const highestSeverity = vendorEntries.reduce((highest, entry) => {
@@ -2101,6 +2150,40 @@ export default function App() {
</div>
);
})}
{/* Show more / pagination footer */}
{Object.keys(filteredGroupedCVEs).length > visibleCount && (
<div className="flex items-center justify-between pt-2">
<span className="text-gray-500 font-mono text-xs">
Showing {visibleCount} of {Object.keys(filteredGroupedCVEs).length} CVEs
</span>
<div className="flex gap-2">
<button
onClick={() => setVisibleCount(v => v + 5)}
className="intel-button intel-button-primary text-xs px-3 py-1"
>
Show 5 more
</button>
<button
onClick={() => setVisibleCount(Object.keys(filteredGroupedCVEs).length)}
className="intel-button text-xs px-3 py-1"
style={{ background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', color: '#94A3B8' }}
>
Show all
</button>
</div>
</div>
)}
{visibleCount > 5 && Object.keys(filteredGroupedCVEs).length <= visibleCount && Object.keys(filteredGroupedCVEs).length > 5 && (
<div className="flex justify-end pt-2">
<button
onClick={() => setVisibleCount(5)}
className="intel-button text-xs px-3 py-1"
style={{ background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', color: '#94A3B8' }}
>
Collapse
</button>
</div>
)}
</div>
)}
@@ -2311,6 +2394,97 @@ export default function App() {
)}
</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>
{/* End Right Panel */}

297
plan.md
View File

@@ -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

View File

@@ -1,7 +1,5 @@
# Authentication Feature - Test Cases
**Feature Branch:** feature/login
**Date:** 2026-01-28
**Tester:** _______________
---