15 Commits

Author SHA1 Message Date
8b3ea22fa0 Merge feature/reporting-page: full-width layout + in-panel scroll
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 13:24:03 -06:00
75b8ecc61d fix(reporting): full-width layout and in-panel vertical scroll
- Reporting page breaks out of max-w-7xl container to use full viewport width
- Table body scrolls within the panel (maxHeight: calc(100vh - 420px)) so you
  no longer need to scroll the entire page to reach the horizontal scrollbar
- Column headers are sticky (position: sticky, top 0) with opaque background
  so they remain visible while scrolling vertically through findings

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 13:23:56 -06:00
ade3cc25ad Merge feature/reporting-page: Add CVEs column
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 13:17:05 -06:00
3fd6158eb3 feat(reporting): add CVEs column from vulnerabilities.vulnInfoList
- Backend extracts cves[] array from f.vulnerabilities.vulnInfoList[].cve
- Frontend shows up to 2 CVE badges (purple) with "+N more" overflow tooltip
- Filter is multi-value aware: selecting a CVE matches any finding containing it
- FilterDropdown expands multi-value arrays into individual checkbox options
- Sort by CVE count (number of associated CVEs)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 13:17:01 -06:00
5bbaaf5918 Merge feature/reporting-page: BU Ownership column + column filters
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 13:03:20 -06:00
1f36d302ea feat(reporting): add BU Ownership column and per-column Excel-style filters
- buOwnership field extracted from assetCustomAttributes['1550_host_1'][0]
  and stored in SQLite cache; badge-styled cell (sky=STEAM, amber=ACCESS-ENG)
- All columns except Notes get a funnel filter button in the header
- FilterDropdown uses ReactDOM.createPortal + fixed positioning to escape
  overflowX:auto clipping; shows unique value checkboxes with search input,
  Select All, Clear, and a selected/total count footer
- Severity filter groups by vrrGroup label (CRITICAL/HIGH) not numeric value
- columnFilters state gates a useMemo filtered array before sorting
- Active filter count shown in panel header with amber badge; Clear Filters
  button appears in the toolbar when any filters are active
- Empty Set filter (Clear All) hides all rows, consistent with Excel

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 13:03:17 -06:00
8697ba4ef3 Reporting page: add Due Date, column manager (hide/reorder), remove Discovered/Source
Backend:
- Extract dueDate from statusEmbedded.dueDate (strip time portion)
- Remove discoveredOn and source from extractFinding (not needed)

Frontend:
- Add Due Date column (color-coded: red=past due, amber=within 30d, gray=future)
- Remove Discovered and Source columns
- ColumnManager component: gear button opens popover with drag-to-reorder and
  eye toggle per column; column state persisted to localStorage
- Column order/visibility survives page refresh and syncs
- SortIcon, TableCell, NoteCell all driven by current visible column list

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 12:47:11 -06:00
d3806e8ce3 Add Reporting page with Ivanti host findings table
Backend:
- New route /api/ivanti/findings (GET cached data, POST /sync, PUT /:id/note)
- Fetches all pages of hostFinding/search filtered to NTS-AEO groups, severity 8.5-9.9, Open state
- SQLite cache (ivanti_findings_cache) stores slimmed findings across syncs
- Separate ivanti_finding_notes table persists user notes by finding ID
- Daily auto-sync on startup + 24h interval, manual sync endpoint
- Notes capped at 255 chars server-side

Frontend (ReportingPage):
- Panel 1: Metric graphs placeholder (full width, amber theme)
- Panel 2: Sortable findings table (all columns click-to-sort with ASC/DESC toggle)
- Columns: Severity (color-coded badge), Title, Host, IP, DNS, SLA, Discovered, Last Found, Source, Notes
- Notes column: inline editable input, saves on blur via PUT endpoint
- Sync button with spinner, last-synced timestamp, error banner

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:56:37 -06:00
931c42faeb Merge feature/navigation: Add hamburger nav menu with 4-page navigation structure
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:47:47 -06:00
ea3b72db5c Add hamburger nav menu with 4-page navigation structure
- NavDrawer component: slide-in left drawer with backdrop, matches dark theme
- Nav items: Home, Reporting, Knowledge Base, Exports with color-coded icons
- Active page highlighted with colored background + indicator dot
- Placeholder pages for Reporting (amber), Knowledge Base (green), Exports (purple)
- Stats bar and three-column layout conditionally render on Home page only
- currentPage state drives all page switching

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:47:03 -06:00
d63e7cc9b9 Merge feature/remove-weekly-reports: Remove weekly report functionality
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:36:55 -06:00
37e183543a Remove weekly report functionality
- Delete backend/routes/weeklyReports.js
- Delete backend/migrations/add_weekly_reports_table.js
- Delete backend/scripts/split_cve_report.py
- Delete backend/helpers/excelProcessor.js
- Delete frontend/src/components/WeeklyReportModal.js
- Remove import, state, button, and modal from App.js
- Remove route registration and require from server.js
- Drop weekly_reports table from SQLite database

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:32:39 -06:00
337ffe6f35 Merge feature/cleanup-branding: Rebrand dashboard header to STEAM Security Dashboard
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:26:19 -06:00
08c8c8a2a1 Rebrand dashboard header to STEAM Security Dashboard
- Title: "CVE INTEL" → "STEAM Security Dashboard"
- Subtitle: "Threat Intelligence & Vulnerability Command Center" → "NTS Threat Intelligence and Metric Aggregation"

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:25:21 -06:00
4ed7721a71 Merge feature/workflow: Add Ivanti Workflows panel
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:22:43 -06:00
13 changed files with 1503 additions and 820 deletions

View File

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

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

View File

@@ -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();
});
});

View File

@@ -0,0 +1,324 @@
// 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 = [
{
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
}
];
// ---------------------------------------------------------------------------
// 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 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);
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
};
}
// ---------------------------------------------------------------------------
// 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`);
} 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);
});
});
}
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' });
}
});
// 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;

View File

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

View File

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

View File

@@ -18,10 +18,10 @@ const createUsersRouter = require('./routes/users');
const createAuditLogRouter = require('./routes/auditLog');
const logAudit = require('./helpers/auditLog');
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 createIvantiFindingsRouter = require('./routes/ivantiFindings');
const app = express();
const PORT = process.env.PORT || 3001;
@@ -175,9 +175,6 @@ const upload = multer({
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)
app.use('/api/knowledge-base', createKnowledgeBaseRouter(db, upload));
@@ -187,6 +184,9 @@ app.use('/api/archer-tickets', createArcherTicketsRouter(db));
// Ivanti / RiskSense workflow routes (all authenticated users)
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 ==========
// Get all CVEs with optional filters (authenticated users)

View File

@@ -1,14 +1,17 @@
import React, { useState, useEffect } from 'react';
import { Search, FileText, AlertCircle, Download, Upload, Eye, Filter, CheckCircle, XCircle, Loader, Trash2, Plus, RefreshCw, Edit2, ChevronDown, Shield, Activity } from 'lucide-react';
import { Search, FileText, AlertCircle, Download, Upload, Eye, Filter, CheckCircle, XCircle, Loader, Trash2, Plus, RefreshCw, Edit2, ChevronDown, Shield, Activity, Menu } from 'lucide-react';
import { useAuth } from './contexts/AuthContext';
import LoginForm from './components/LoginForm';
import UserMenu from './components/UserMenu';
import UserManagement from './components/UserManagement';
import AuditLog from './components/AuditLog';
import NvdSyncModal from './components/NvdSyncModal';
import WeeklyReportModal from './components/WeeklyReportModal';
import KnowledgeBaseModal from './components/KnowledgeBaseModal';
import KnowledgeBaseViewer from './components/KnowledgeBaseViewer';
import NavDrawer from './components/NavDrawer';
import ReportingPage from './components/pages/ReportingPage';
import KnowledgeBasePage from './components/pages/KnowledgeBasePage';
import ExportsPage from './components/pages/ExportsPage';
import './App.css';
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
@@ -172,11 +175,12 @@ export default function App() {
const [cveDocuments, setCveDocuments] = useState({});
const [quickCheckCVE, setQuickCheckCVE] = useState('');
const [quickCheckResult, setQuickCheckResult] = useState(null);
const [currentPage, setCurrentPage] = useState('home');
const [navOpen, setNavOpen] = useState(false);
const [showAddCVE, setShowAddCVE] = useState(false);
const [showUserManagement, setShowUserManagement] = useState(false);
const [showAuditLog, setShowAuditLog] = useState(false);
const [showNvdSync, setShowNvdSync] = useState(false);
const [showWeeklyReport, setShowWeeklyReport] = useState(false);
const [showKnowledgeBase, setShowKnowledgeBase] = useState(false);
const [knowledgeBaseArticles, setKnowledgeBaseArticles] = useState([]);
const [selectedKBArticle, setSelectedKBArticle] = useState(null);
@@ -952,18 +956,35 @@ export default function App() {
return (
<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={setCurrentPage}
/>
{/* Scanning line effect */}
<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 */}
<div className="mb-8">
<div className="flex justify-between items-start mb-6">
<div className="flex-1">
<h1 className="text-4xl font-bold text-intel-accent mb-1 font-mono tracking-tight">
CVE INTEL
</h1>
<p className="text-gray-400 text-sm font-sans">Threat Intelligence & Vulnerability Command Center</p>
<div className="flex items-center gap-4 flex-1">
<button
onClick={() => setNavOpen(true)}
style={{ background: 'none', border: '1px solid rgba(14, 165, 233, 0.25)', borderRadius: '0.375rem', padding: '0.5rem', cursor: 'pointer', color: '#64748B', flexShrink: 0 }}
onMouseEnter={e => { e.currentTarget.style.color = '#0EA5E9'; e.currentTarget.style.borderColor = 'rgba(14, 165, 233, 0.6)'; }}
onMouseLeave={e => { e.currentTarget.style.color = '#64748B'; e.currentTarget.style.borderColor = 'rgba(14, 165, 233, 0.25)'; }}
title="Navigation"
>
<Menu className="w-5 h-5" />
</button>
<div>
<h1 className="text-4xl font-bold text-intel-accent mb-1 font-mono tracking-tight">
STEAM Security Dashboard
</h1>
<p className="text-gray-400 text-sm font-sans">NTS Threat Intelligence and Metric Aggregation</p>
</div>
</div>
<div className="flex items-center gap-3">
{canWrite() && (
@@ -975,15 +996,6 @@ export default function App() {
NVD Sync
</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() && (
<button
onClick={() => setShowAddCVE(true)}
@@ -997,8 +1009,8 @@ export default function App() {
</div>
</div>
{/* Stats Bar - Modern refined styling */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
{/* Stats Bar - only shown on Home page */}
{currentPage === 'home' && <div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<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={{ fontSize: '0.75rem', textTransform: 'uppercase', letterSpacing: '0.1em', color: '#CBD5E1', marginBottom: '0.25rem' }}>Total CVEs</div>
@@ -1019,9 +1031,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: '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>
{/* Page content */}
{currentPage === 'reporting' && <ReportingPage />}
{currentPage === 'knowledge-base' && <KnowledgeBasePage />}
{currentPage === 'exports' && <ExportsPage />}
{/* User Management Modal */}
{showUserManagement && (
<UserManagement onClose={() => setShowUserManagement(false)} />
@@ -1037,11 +1054,6 @@ export default function App() {
<NvdSyncModal onClose={() => setShowNvdSync(false)} onSyncComplete={() => fetchCVEs()} />
)}
{/* Weekly Report Modal */}
{showWeeklyReport && (
<WeeklyReportModal onClose={() => setShowWeeklyReport(false)} />
)}
{/* Knowledge Base Modal */}
{showKnowledgeBase && (
<KnowledgeBaseModal
@@ -1640,8 +1652,8 @@ export default function App() {
</div>
)}
{/* Three Column Layout */}
<div className="grid grid-cols-12 gap-6">
{/* Three Column Layout - Home page only */}
{currentPage === 'home' && <div className="grid grid-cols-12 gap-6">
{/* LEFT PANEL - Wiki/Knowledge Base */}
<div className="col-span-12 lg:col-span-3 space-y-4">
<div style={{...STYLES.intelCard, padding: '1.5rem', borderLeft: '3px solid #10B981'}} className="rounded-lg">
@@ -2488,7 +2500,7 @@ export default function App() {
</div>
{/* End Right Panel */}
</div>
</div>}
{/* End Three Column Layout */}
</div>
</div>

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

View File

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

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

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

View File

@@ -0,0 +1,899 @@
import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import ReactDOM from 'react-dom';
import { RefreshCw, Loader, AlertCircle, PieChart, ChevronUp, ChevronDown, ChevronsUpDown, Settings2, GripVertical, Eye, EyeOff, Filter } from 'lucide-react';
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
const STORAGE_KEY = 'steam_findings_columns_v1';
// ---------------------------------------------------------------------------
// Column definitions — source of truth for labels, sort behaviour, rendering
// ---------------------------------------------------------------------------
const COLUMN_DEFS = {
severity: { label: 'Severity', sortable: true, filterable: true },
title: { label: 'Title', sortable: true, filterable: true },
cves: { label: 'CVEs', sortable: true, filterable: true, multiValue: true },
hostName: { label: 'Host', sortable: true, filterable: true },
ipAddress: { label: 'IP Address', sortable: true, filterable: true },
dns: { label: 'DNS', sortable: true, filterable: true },
dueDate: { label: 'Due Date', sortable: true, filterable: true },
slaStatus: { label: 'SLA', sortable: true, filterable: true },
buOwnership: { label: 'BU', sortable: true, filterable: true },
lastFoundOn: { label: 'Last Found', sortable: true, filterable: true },
note: { label: 'Notes', sortable: false, filterable: false },
};
const DEFAULT_COLUMN_ORDER = [
{ key: 'severity', visible: true },
{ key: 'title', visible: true },
{ key: 'cves', visible: true },
{ key: 'hostName', visible: true },
{ key: 'ipAddress', visible: true },
{ key: 'dns', visible: true },
{ key: 'dueDate', visible: true },
{ key: 'slaStatus', visible: true },
{ key: 'buOwnership', visible: true },
{ key: 'lastFoundOn', visible: true },
{ key: 'note', visible: true },
];
// ---------------------------------------------------------------------------
// Persist / load column config
// ---------------------------------------------------------------------------
function loadColumnOrder() {
try {
const saved = JSON.parse(localStorage.getItem(STORAGE_KEY) || 'null');
if (saved && Array.isArray(saved)) {
const savedKeys = new Set(saved.map((c) => c.key));
const merged = saved.filter((c) => COLUMN_DEFS[c.key]);
DEFAULT_COLUMN_ORDER.forEach((d) => {
if (!savedKeys.has(d.key)) merged.push({ ...d });
});
return merged;
}
} catch { /* ignore */ }
return DEFAULT_COLUMN_ORDER.map((c) => ({ ...c }));
}
function saveColumnOrder(order) {
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(order)); } catch { /* ignore */ }
}
// ---------------------------------------------------------------------------
// Sort accessor by column key
// ---------------------------------------------------------------------------
function getVal(finding, key) {
switch (key) {
case 'severity': return finding.severity ?? 0;
case 'title': return finding.title ?? '';
case 'hostName': return finding.hostName ?? '';
case 'ipAddress': return finding.ipAddress ?? '';
case 'dns': return finding.dns ?? '';
case 'dueDate': return finding.dueDate ?? '';
case 'slaStatus': return finding.slaStatus ?? '';
case 'cves': return (finding.cves || []).length; // sort by CVE count
case 'buOwnership': return finding.buOwnership ?? '';
case 'lastFoundOn': return finding.lastFoundOn ?? '';
case 'note': return finding.note ?? '';
default: return '';
}
}
// ---------------------------------------------------------------------------
// Filter accessor — severity → vrrGroup label; cves handled as multi-value
// ---------------------------------------------------------------------------
function getFilterVal(finding, key) {
if (key === 'severity') return finding.vrrGroup || '';
if (key === 'cves') return (finding.cves || []).join(','); // not used directly; see multiValue logic
return String(getVal(finding, key) ?? '');
}
// ---------------------------------------------------------------------------
// Style helpers
// ---------------------------------------------------------------------------
function severityColor(vrrGroup) {
switch ((vrrGroup || '').toUpperCase()) {
case 'CRITICAL': return { bg: 'rgba(239,68,68,0.15)', border: '#EF4444', text: '#EF4444' };
case 'HIGH': return { bg: 'rgba(245,158,11,0.15)', border: '#F59E0B', text: '#F59E0B' };
case 'MEDIUM': return { bg: 'rgba(234,179,8,0.15)', border: '#EAB308', text: '#EAB308' };
default: return { bg: 'rgba(100,116,139,0.15)', border: '#64748B', text: '#94A3B8' };
}
}
function slaColor(slaStatus) {
switch ((slaStatus || '').toUpperCase()) {
case 'OVERDUE': return '#EF4444';
case 'AT_RISK': return '#F59E0B';
case 'WITHIN_SLA': return '#10B981';
default: return '#64748B';
}
}
function dueDateColor(dueDate) {
if (!dueDate) return '#64748B';
const today = new Date();
const due = new Date(dueDate);
const diffDays = Math.ceil((due - today) / (1000 * 60 * 60 * 24));
if (diffDays < 0) return '#EF4444';
if (diffDays <= 30) return '#F59E0B';
return '#94A3B8';
}
function SortIcon({ colKey, sort }) {
if (sort.field !== colKey) return <ChevronsUpDown style={{ width: '11px', height: '11px', opacity: 0.3, marginLeft: '3px', flexShrink: 0 }} />;
return sort.dir === 'asc'
? <ChevronUp style={{ width: '11px', height: '11px', color: '#0EA5E9', marginLeft: '3px', flexShrink: 0 }} />
: <ChevronDown style={{ width: '11px', height: '11px', color: '#0EA5E9', marginLeft: '3px', flexShrink: 0 }} />;
}
// ---------------------------------------------------------------------------
// NoteCell — inline editable, saves on blur
// ---------------------------------------------------------------------------
function NoteCell({ findingId, initialNote }) {
const [value, setValue] = useState(initialNote || '');
const [saving, setSaving] = useState(false);
const lastSaved = useRef(initialNote || '');
useEffect(() => {
setValue(initialNote || '');
lastSaved.current = initialNote || '';
}, [initialNote]);
const save = useCallback(async () => {
if (value === lastSaved.current) return;
setSaving(true);
try {
await fetch(`${API_BASE}/ivanti/findings/${encodeURIComponent(findingId)}/note`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ note: value })
});
lastSaved.current = value;
} catch (e) {
console.error('Failed to save note:', e);
} finally {
setSaving(false);
}
}, [findingId, value]);
return (
<div style={{ position: 'relative' }}>
<input
type="text"
value={value}
maxLength={255}
onChange={(e) => setValue(e.target.value)}
onBlur={save}
placeholder="Add note…"
style={{
width: '100%', minWidth: '160px',
background: 'rgba(14,165,233,0.05)',
border: '1px solid rgba(14,165,233,0.2)',
borderRadius: '4px', padding: '4px 8px',
color: '#CBD5E1', fontSize: '0.75rem',
fontFamily: 'inherit', outline: 'none', boxSizing: 'border-box'
}}
onFocus={(e) => { e.target.style.borderColor = 'rgba(14,165,233,0.6)'; e.target.style.background = 'rgba(14,165,233,0.1)'; }}
onBlurCapture={(e) => { e.target.style.borderColor = 'rgba(14,165,233,0.2)'; e.target.style.background = 'rgba(14,165,233,0.05)'; }}
/>
{saving && <Loader style={{ width: '10px', height: '10px', position: 'absolute', right: '6px', top: '6px', color: '#0EA5E9' }} />}
</div>
);
}
// ---------------------------------------------------------------------------
// ColumnManager — popover with drag-to-reorder and show/hide toggles
// ---------------------------------------------------------------------------
function ColumnManager({ columnOrder, onChange }) {
const [open, setOpen] = useState(false);
const [dragIdx, setDragIdx] = useState(null);
const [overIdx, setOverIdx] = useState(null);
const panelRef = useRef(null);
const btnRef = useRef(null);
useEffect(() => {
if (!open) return;
const handler = (e) => {
if (!panelRef.current?.contains(e.target) && !btnRef.current?.contains(e.target)) setOpen(false);
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [open]);
const toggleVisible = (key) => {
onChange(columnOrder.map((c) => c.key === key ? { ...c, visible: !c.visible } : c));
};
const handleDragStart = (idx) => setDragIdx(idx);
const handleDragOver = (e, idx) => { e.preventDefault(); setOverIdx(idx); };
const handleDrop = (idx) => {
if (dragIdx === null || dragIdx === idx) { setDragIdx(null); setOverIdx(null); return; }
const updated = [...columnOrder];
const [moved] = updated.splice(dragIdx, 1);
updated.splice(idx, 0, moved);
onChange(updated);
setDragIdx(null);
setOverIdx(null);
};
const visibleCount = columnOrder.filter((c) => c.visible).length;
return (
<div style={{ position: 'relative' }}>
<button
ref={btnRef}
onClick={() => setOpen((p) => !p)}
style={{
display: 'flex', alignItems: 'center', gap: '0.375rem',
padding: '0.375rem 0.75rem',
background: open ? 'rgba(14,165,233,0.15)' : 'rgba(14,165,233,0.07)',
border: `1px solid rgba(14,165,233,${open ? '0.5' : '0.25'})`,
borderRadius: '0.375rem',
color: '#0EA5E9', cursor: 'pointer',
fontFamily: 'monospace', fontSize: '0.75rem', fontWeight: '600',
textTransform: 'uppercase', letterSpacing: '0.05em'
}}
>
<Settings2 style={{ width: '13px', height: '13px' }} />
Columns
<span style={{ fontSize: '0.65rem', opacity: 0.7 }}>({visibleCount}/{columnOrder.length})</span>
</button>
{open && (
<div
ref={panelRef}
style={{
position: 'absolute', top: 'calc(100% + 8px)', right: 0,
width: '220px', zIndex: 100,
background: 'linear-gradient(180deg, #0F1A2E 0%, #0A1628 100%)',
border: '1px solid rgba(14,165,233,0.25)',
borderRadius: '0.5rem',
boxShadow: '0 8px 24px rgba(0,0,0,0.6)',
padding: '0.5rem'
}}
>
<div style={{ fontSize: '0.65rem', color: '#475569', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: '0.1em', padding: '0.25rem 0.5rem 0.5rem', borderBottom: '1px solid rgba(255,255,255,0.05)', marginBottom: '0.375rem' }}>
Drag to reorder · click to toggle
</div>
{columnOrder.map((col, idx) => {
const def = COLUMN_DEFS[col.key];
const isDragging = dragIdx === idx;
const isOver = overIdx === idx && dragIdx !== null && dragIdx !== idx;
return (
<div
key={col.key}
draggable
onDragStart={() => handleDragStart(idx)}
onDragOver={(e) => handleDragOver(e, idx)}
onDrop={() => handleDrop(idx)}
onDragEnd={() => { setDragIdx(null); setOverIdx(null); }}
style={{
display: 'flex', alignItems: 'center', gap: '0.5rem',
padding: '0.4rem 0.5rem', borderRadius: '0.25rem', cursor: 'grab',
opacity: isDragging ? 0.4 : 1,
background: isOver ? 'rgba(14,165,233,0.12)' : 'transparent',
borderTop: isOver ? '2px solid #0EA5E9' : '2px solid transparent',
transition: 'background 0.1s'
}}
>
<GripVertical style={{ width: '14px', height: '14px', color: '#334155', flexShrink: 0 }} />
<span style={{ flex: 1, fontSize: '0.78rem', color: col.visible ? '#CBD5E1' : '#475569', fontFamily: 'monospace' }}>
{def?.label || col.key}
</span>
<button
onClick={(e) => { e.stopPropagation(); toggleVisible(col.key); }}
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '2px', color: col.visible ? '#0EA5E9' : '#334155', lineHeight: 1 }}
>
{col.visible ? <Eye style={{ width: '14px', height: '14px' }} /> : <EyeOff style={{ width: '14px', height: '14px' }} />}
</button>
</div>
);
})}
</div>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// FilterDropdown — portal-based so it escapes overflow:auto clipping
// ---------------------------------------------------------------------------
function FilterDropdown({ anchorEl, colKey, findings, activeFilter, onFilterChange, onClose }) {
const [pos, setPos] = useState({ top: 0, left: 0 });
const [search, setSearch] = useState('');
const panelRef = useRef(null);
const inputRef = useRef(null);
// Compute fixed position from anchor button's viewport rect
useEffect(() => {
if (!anchorEl) return;
const r = anchorEl.getBoundingClientRect();
setPos({ top: r.bottom + 4, left: r.left });
setTimeout(() => inputRef.current?.focus(), 0);
}, [anchorEl]);
// Close on outside click
useEffect(() => {
const handler = (e) => {
if (panelRef.current && !panelRef.current.contains(e.target) &&
!(anchorEl && anchorEl.contains(e.target))) {
onClose();
}
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [anchorEl, onClose]);
// Close on Escape
useEffect(() => {
const handler = (e) => { if (e.key === 'Escape') onClose(); };
document.addEventListener('keydown', handler);
return () => document.removeEventListener('keydown', handler);
}, [onClose]);
// Unique values from the full (unfiltered) findings list.
// Multi-value columns (e.g. cves) expand their array so each item is a separate option.
const allValues = useMemo(() => {
const def = COLUMN_DEFS[colKey];
const vals = new Set();
findings.forEach((f) => {
if (def?.multiValue) {
(f[colKey] || []).forEach((v) => { if (String(v).trim()) vals.add(String(v).trim()); });
} else {
const v = getFilterVal(f, colKey).trim();
if (v) vals.add(v);
}
});
return [...vals].sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
}, [findings, colKey]);
const displayed = search.trim()
? allValues.filter((v) => v.toLowerCase().includes(search.toLowerCase()))
: allValues;
const isChecked = (val) => !activeFilter || activeFilter.has(val);
const activeCount = activeFilter ? activeFilter.size : allValues.length;
const toggle = (val) => {
let next;
if (!activeFilter) {
next = new Set(allValues);
next.delete(val);
} else {
next = new Set(activeFilter);
if (next.has(val)) next.delete(val); else next.add(val);
}
// If all values selected again, remove the filter entirely
onFilterChange(next.size >= allValues.length ? null : next);
};
return ReactDOM.createPortal(
<div
ref={panelRef}
style={{
position: 'fixed', top: pos.top, left: pos.left,
width: '220px', zIndex: 9999,
background: 'linear-gradient(180deg, #0F1A2E 0%, #0A1628 100%)',
border: '1px solid rgba(14,165,233,0.3)',
borderRadius: '0.5rem',
boxShadow: '0 8px 32px rgba(0,0,0,0.8)',
padding: '0.5rem',
}}
>
{/* Search */}
<input
ref={inputRef}
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search values…"
style={{
width: '100%', marginBottom: '0.375rem',
background: 'rgba(14,165,233,0.05)',
border: '1px solid rgba(14,165,233,0.2)',
borderRadius: '0.25rem', padding: '0.3rem 0.5rem',
color: '#CBD5E1', fontSize: '0.72rem',
fontFamily: 'monospace', outline: 'none', boxSizing: 'border-box',
}}
/>
{/* Select All / Clear */}
<div style={{ display: 'flex', gap: '0.375rem', marginBottom: '0.375rem', paddingBottom: '0.375rem', borderBottom: '1px solid rgba(255,255,255,0.06)' }}>
<button
onClick={() => onFilterChange(null)}
style={{ flex: 1, padding: '0.2rem', background: 'rgba(14,165,233,0.08)', border: '1px solid rgba(14,165,233,0.2)', borderRadius: '0.25rem', color: '#0EA5E9', cursor: 'pointer', fontSize: '0.65rem', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: '0.05em' }}
>
Select All
</button>
<button
onClick={() => onFilterChange(new Set())}
style={{ flex: 1, padding: '0.2rem', background: 'rgba(239,68,68,0.08)', border: '1px solid rgba(239,68,68,0.2)', borderRadius: '0.25rem', color: '#EF4444', cursor: 'pointer', fontSize: '0.65rem', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: '0.05em' }}
>
Clear
</button>
</div>
{/* Value checkboxes */}
<div style={{ maxHeight: '200px', overflowY: 'auto' }}>
{displayed.length === 0 ? (
<div style={{ fontSize: '0.68rem', color: '#475569', textAlign: 'center', padding: '0.5rem 0' }}>No values</div>
) : displayed.map((val) => (
<label
key={val}
style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', padding: '0.25rem 0.375rem', borderRadius: '0.25rem', cursor: 'pointer', color: isChecked(val) ? '#CBD5E1' : '#475569', fontSize: '0.72rem', fontFamily: 'monospace' }}
onMouseEnter={(e) => e.currentTarget.style.background = 'rgba(14,165,233,0.08)'}
onMouseLeave={(e) => e.currentTarget.style.background = 'transparent'}
>
<input
type="checkbox"
checked={isChecked(val)}
onChange={() => toggle(val)}
style={{ accentColor: '#0EA5E9', width: '12px', height: '12px', flexShrink: 0, cursor: 'pointer' }}
/>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{val}</span>
</label>
))}
</div>
{/* Status footer */}
<div style={{ marginTop: '0.375rem', paddingTop: '0.375rem', borderTop: '1px solid rgba(255,255,255,0.06)', fontSize: '0.65rem', color: '#475569', textAlign: 'center', fontFamily: 'monospace' }}>
{activeCount} / {allValues.length} selected
</div>
</div>,
document.body
);
}
// ---------------------------------------------------------------------------
// Render a single table cell by column key
// ---------------------------------------------------------------------------
function TableCell({ colKey, finding }) {
switch (colKey) {
case 'severity': {
const sc = severityColor(finding.vrrGroup);
return (
<td style={{ padding: '0.45rem 0.75rem', whiteSpace: 'nowrap' }}>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '0.3rem', padding: '0.2rem 0.45rem', borderRadius: '0.25rem', background: sc.bg, border: `1px solid ${sc.border}40`, fontFamily: 'monospace', fontWeight: '700', color: sc.text, fontSize: '0.72rem' }}>
{finding.severity?.toFixed(2)}
<span style={{ fontSize: '0.6rem', opacity: 0.75 }}>{finding.vrrGroup}</span>
</span>
</td>
);
}
case 'title':
return (
<td style={{ padding: '0.45rem 0.75rem', maxWidth: '280px' }}>
<span style={{ color: '#CBD5E1', display: 'block', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={finding.title}>
{finding.title}
</span>
</td>
);
case 'cves': {
const cves = finding.cves || [];
if (cves.length === 0) return <td style={{ padding: '0.45rem 0.75rem', color: '#475569' }}></td>;
const shown = cves.slice(0, 2);
const rest = cves.length - shown.length;
return (
<td style={{ padding: '0.45rem 0.75rem', minWidth: '160px', maxWidth: '240px' }}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.2rem' }}>
{shown.map((cve) => (
<span key={cve} style={{ padding: '0.1rem 0.35rem', borderRadius: '0.2rem', background: 'rgba(139,92,246,0.1)', border: '1px solid rgba(139,92,246,0.3)', color: '#A78BFA', fontFamily: 'monospace', fontSize: '0.65rem', fontWeight: '600', whiteSpace: 'nowrap' }}>
{cve}
</span>
))}
{rest > 0 && (
<span title={cves.slice(2).join('\n')} style={{ padding: '0.1rem 0.35rem', borderRadius: '0.2rem', background: 'rgba(100,116,139,0.12)', border: '1px solid rgba(100,116,139,0.25)', color: '#64748B', fontFamily: 'monospace', fontSize: '0.65rem', cursor: 'help', whiteSpace: 'nowrap' }}>
+{rest} more
</span>
)}
</div>
</td>
);
}
case 'hostName':
case 'ipAddress':
return (
<td style={{ padding: '0.45rem 0.75rem', whiteSpace: 'nowrap', color: '#94A3B8', fontFamily: 'monospace', fontSize: '0.72rem' }}>
{finding[colKey] || '—'}
</td>
);
case 'dns':
return (
<td style={{ padding: '0.45rem 0.75rem', maxWidth: '200px' }}>
<span style={{ color: '#94A3B8', fontFamily: 'monospace', fontSize: '0.72rem', display: 'block', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={finding.dns}>
{finding.dns || '—'}
</span>
</td>
);
case 'dueDate': {
const color = dueDateColor(finding.dueDate);
return (
<td style={{ padding: '0.45rem 0.75rem', whiteSpace: 'nowrap', fontFamily: 'monospace', fontSize: '0.72rem', fontWeight: '600', color }}>
{finding.dueDate || '—'}
</td>
);
}
case 'slaStatus':
return (
<td style={{ padding: '0.45rem 0.75rem', whiteSpace: 'nowrap', fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '600', color: slaColor(finding.slaStatus) }}>
{finding.slaStatus || '—'}
</td>
);
case 'buOwnership': {
const bu = finding.buOwnership || '';
const isSteam = bu.toUpperCase().includes('STEAM');
return (
<td style={{ padding: '0.45rem 0.75rem', whiteSpace: 'nowrap' }}>
{bu ? (
<span
title={bu}
style={{
display: 'inline-block', padding: '0.15rem 0.4rem',
borderRadius: '0.25rem',
background: isSteam ? 'rgba(14,165,233,0.1)' : 'rgba(245,158,11,0.1)',
border: `1px solid ${isSteam ? 'rgba(14,165,233,0.3)' : 'rgba(245,158,11,0.3)'}`,
color: isSteam ? '#0EA5E9' : '#F59E0B',
fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '600',
}}
>
{bu.replace('NTS-AEO-', '')}
</span>
) : (
<span style={{ color: '#475569' }}></span>
)}
</td>
);
}
case 'lastFoundOn':
return (
<td style={{ padding: '0.45rem 0.75rem', whiteSpace: 'nowrap', color: '#64748B', fontFamily: 'monospace', fontSize: '0.72rem' }}>
{finding.lastFoundOn || '—'}
</td>
);
case 'note':
return (
<td style={{ padding: '0.45rem 0.75rem' }}>
<NoteCell findingId={finding.id} initialNote={finding.note} />
</td>
);
default:
return <td style={{ padding: '0.45rem 0.75rem', color: '#64748B' }}></td>;
}
}
// ---------------------------------------------------------------------------
// Main ReportingPage
// ---------------------------------------------------------------------------
export default function ReportingPage() {
const [findings, setFindings] = useState([]);
const [total, setTotal] = useState(null);
const [syncedAt, setSyncedAt] = useState(null);
const [syncStatus, setSyncStatus] = useState(null);
const [syncError, setSyncError] = useState(null);
const [loading, setLoading] = useState(false);
const [syncing, setSyncing] = useState(false);
const [sort, setSort] = useState({ field: 'severity', dir: 'desc' });
const [columnOrder, setColumnOrder] = useState(loadColumnOrder);
const [columnFilters, setColumnFilters] = useState({});
const [openFilter, setOpenFilter] = useState(null);
const filterBtnRefs = useRef({});
const updateColumns = useCallback((newOrder) => {
setColumnOrder(newOrder);
saveColumnOrder(newOrder);
}, []);
const applyState = (data) => {
setTotal(data.total ?? 0);
setFindings(data.findings || []);
setSyncedAt(data.synced_at || null);
setSyncStatus(data.sync_status || null);
setSyncError(data.error_message || null);
};
const fetchFindings = async () => {
setLoading(true);
try {
const res = await fetch(`${API_BASE}/ivanti/findings`, { credentials: 'include' });
const data = await res.json();
if (res.ok) applyState(data);
} catch (e) {
console.error('Error loading findings:', e);
} finally {
setLoading(false);
}
};
const syncFindings = async () => {
setSyncing(true);
try {
const res = await fetch(`${API_BASE}/ivanti/findings/sync`, { method: 'POST', credentials: 'include' });
const data = await res.json();
if (res.ok) applyState(data);
} catch (e) {
console.error('Error syncing findings:', e);
} finally {
setSyncing(false);
}
};
useEffect(() => { fetchFindings(); }, []); // eslint-disable-line
// Set/clear a single column filter
const setColFilter = useCallback((colKey, vals) => {
setColumnFilters((prev) => {
if (!vals) {
const next = { ...prev };
delete next[colKey];
return next;
}
return { ...prev, [colKey]: vals };
});
}, []);
// Apply all active column filters to produce the visible row set
const filtered = useMemo(() => {
const active = Object.entries(columnFilters);
if (active.length === 0) return findings;
return findings.filter((f) =>
active.every(([key, vals]) => {
if (!vals || vals.size === 0) return false;
const def = COLUMN_DEFS[key];
if (def?.multiValue) {
// Row matches if ANY of its values is in the selected set
return (f[key] || []).some((v) => vals.has(String(v).trim()));
}
return vals.has(getFilterVal(f, key).trim());
})
);
}, [findings, columnFilters]);
// Visible columns in current order
const visibleCols = columnOrder.filter((c) => c.visible && COLUMN_DEFS[c.key]);
// Sort filtered results
const sorted = useMemo(() => [...filtered].sort((a, b) => {
const av = getVal(a, sort.field);
const bv = getVal(b, sort.field);
let cmp = 0;
if (typeof av === 'number' && typeof bv === 'number') {
cmp = av - bv;
} else {
cmp = String(av).localeCompare(String(bv), undefined, { numeric: true });
}
return sort.dir === 'asc' ? cmp : -cmp;
}), [filtered, sort]);
const toggleSort = (key) => {
setSort((prev) =>
prev.field === key
? { field: key, dir: prev.dir === 'asc' ? 'desc' : 'asc' }
: { field: key, dir: 'asc' }
);
};
const activeFilterCount = Object.keys(columnFilters).length;
const syncedDisplay = syncedAt
? `Synced ${new Date(syncedAt.replace(' ', 'T') + 'Z').toLocaleString()}`
: 'Never synced';
// -------------------------------------------------------------------------
// Render
// -------------------------------------------------------------------------
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.5rem' }}>
{/* ----------------------------------------------------------------
Panel 1 — Metrics placeholder
---------------------------------------------------------------- */}
<div style={{
background: 'linear-gradient(135deg, rgba(15,26,46,0.95) 0%, rgba(10,22,40,0.9) 100%)',
border: '1px solid rgba(245,158,11,0.2)',
borderLeft: '3px solid #F59E0B',
borderRadius: '0.5rem',
padding: '1.5rem',
boxShadow: '0 4px 16px rgba(0,0,0,0.4)'
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.625rem', marginBottom: '1rem' }}>
<PieChart style={{ width: '20px', height: '20px', color: '#F59E0B' }} />
<h2 style={{ fontFamily: 'monospace', fontSize: '1rem', fontWeight: '600', color: '#F59E0B', textTransform: 'uppercase', letterSpacing: '0.1em', textShadow: '0 0 12px rgba(245,158,11,0.4)', margin: 0 }}>
Metric Graphs
</h2>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '120px', border: '1px dashed rgba(245,158,11,0.2)', borderRadius: '0.375rem', background: 'rgba(245,158,11,0.03)' }}>
<p style={{ fontFamily: 'monospace', fontSize: '0.75rem', color: '#475569', textTransform: 'uppercase', letterSpacing: '0.1em' }}>
Pie charts &amp; metrics coming soon
</p>
</div>
</div>
{/* ----------------------------------------------------------------
Panel 2 — Findings table
---------------------------------------------------------------- */}
<div style={{
background: 'linear-gradient(135deg, rgba(15,26,46,0.95) 0%, rgba(10,22,40,0.9) 100%)',
border: '1px solid rgba(14,165,233,0.2)',
borderLeft: '3px solid #0EA5E9',
borderRadius: '0.5rem',
padding: '1.5rem',
boxShadow: '0 4px 16px rgba(0,0,0,0.4)'
}}>
{/* Panel header */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '0.5rem' }}>
<div>
<h2 style={{ fontFamily: 'monospace', fontSize: '1rem', fontWeight: '600', color: '#0EA5E9', textTransform: 'uppercase', letterSpacing: '0.1em', textShadow: '0 0 12px rgba(14,165,233,0.4)', margin: '0 0 4px 0' }}>
Host Findings
</h2>
<div style={{ fontFamily: 'monospace', fontSize: '0.7rem', color: '#475569' }}>
{syncedDisplay}
{syncStatus === 'success' && total !== null && (
<span style={{ marginLeft: '0.75rem', color: '#64748B' }}>
{activeFilterCount > 0 ? `${filtered.length} of ${total}` : total} findings
{activeFilterCount > 0 && (
<span style={{ marginLeft: '0.5rem', color: '#F59E0B' }}>
({activeFilterCount} filter{activeFilterCount > 1 ? 's' : ''} active)
</span>
)}
</span>
)}
</div>
</div>
{/* Action buttons */}
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
{activeFilterCount > 0 && (
<button
onClick={() => setColumnFilters({})}
style={{
display: 'flex', alignItems: 'center', gap: '0.375rem',
padding: '0.375rem 0.75rem',
background: 'rgba(245,158,11,0.08)',
border: '1px solid rgba(245,158,11,0.3)',
borderRadius: '0.375rem',
color: '#F59E0B', cursor: 'pointer',
fontFamily: 'monospace', fontSize: '0.75rem', fontWeight: '600',
textTransform: 'uppercase', letterSpacing: '0.05em'
}}
>
<Filter style={{ width: '11px', height: '11px' }} />
Clear Filters
</button>
)}
<ColumnManager columnOrder={columnOrder} onChange={updateColumns} />
<button
onClick={syncFindings}
disabled={syncing || loading}
style={{
display: 'flex', alignItems: 'center', gap: '0.375rem',
padding: '0.375rem 0.75rem',
background: 'rgba(14,165,233,0.1)',
border: '1px solid rgba(14,165,233,0.35)',
borderRadius: '0.375rem',
color: '#0EA5E9', cursor: 'pointer',
fontFamily: 'monospace', fontSize: '0.75rem', fontWeight: '600',
textTransform: 'uppercase', letterSpacing: '0.05em',
opacity: (syncing || loading) ? 0.6 : 1
}}
>
<RefreshCw style={{ width: '13px', height: '13px', animation: syncing ? 'spin 1s linear infinite' : 'none' }} />
{syncing ? 'Syncing…' : 'Sync'}
</button>
</div>
</div>
{/* Error banner */}
{syncStatus === 'error' && syncError && (
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '0.5rem', padding: '0.625rem 0.875rem', background: 'rgba(239,68,68,0.08)', border: '1px solid rgba(239,68,68,0.25)', borderRadius: '0.375rem', marginBottom: '1rem' }}>
<AlertCircle style={{ width: '15px', height: '15px', color: '#EF4444', flexShrink: 0, marginTop: '1px' }} />
<span style={{ fontSize: '0.75rem', color: '#FCA5A5', fontFamily: 'monospace' }}>{syncError}</span>
</div>
)}
{/* Content */}
{loading ? (
<div style={{ textAlign: 'center', padding: '3rem 0' }}>
<Loader style={{ width: '28px', height: '28px', color: '#0EA5E9', animation: 'spin 1s linear infinite', margin: '0 auto 0.75rem' }} />
<p style={{ fontFamily: 'monospace', fontSize: '0.75rem', color: '#475569' }}>Loading findings</p>
</div>
) : syncStatus === 'never' ? (
<div style={{ textAlign: 'center', padding: '3rem 0' }}>
<p style={{ fontFamily: 'monospace', fontSize: '0.75rem', color: '#475569' }}>Click Sync to load findings data</p>
</div>
) : (
<div style={{ overflowX: 'auto', overflowY: 'auto', maxHeight: 'calc(100vh - 420px)', minHeight: '200px', marginTop: '0.75rem' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.75rem', fontFamily: 'sans-serif' }}>
<thead>
<tr style={{ borderBottom: '1px solid rgba(14,165,233,0.2)' }}>
{visibleCols.map((col) => {
const def = COLUMN_DEFS[col.key];
const active = sort.field === col.key;
const isFiltered = !!columnFilters[col.key];
return (
<th
key={col.key}
onClick={def?.sortable ? () => toggleSort(col.key) : undefined}
style={{
padding: '0.5rem 0.75rem', textAlign: 'left',
fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '600',
color: active ? '#0EA5E9' : '#64748B',
textTransform: 'uppercase', letterSpacing: '0.08em',
whiteSpace: 'nowrap',
cursor: def?.sortable ? 'pointer' : 'default',
userSelect: 'none',
background: 'rgb(10, 20, 36)',
position: 'sticky', top: 0, zIndex: 10,
boxShadow: '0 1px 0 rgba(14,165,233,0.2)',
}}
>
<span style={{ display: 'inline-flex', alignItems: 'center' }}>
{def?.label || col.key}
{def?.sortable && <SortIcon colKey={col.key} sort={sort} />}
{def?.filterable && (
<button
ref={(el) => { filterBtnRefs.current[col.key] = el; }}
onClick={(e) => {
e.stopPropagation();
setOpenFilter(openFilter === col.key ? null : col.key);
}}
title={`Filter ${def.label}`}
style={{
background: 'none', border: 'none',
cursor: 'pointer', padding: '1px 1px 1px 3px',
color: isFiltered ? '#F59E0B' : '#334155',
lineHeight: 1, flexShrink: 0,
transition: 'color 0.15s',
}}
>
<Filter style={{ width: '10px', height: '10px' }} />
</button>
)}
</span>
</th>
);
})}
</tr>
</thead>
<tbody>
{sorted.map((finding, idx) => {
const rowBg = idx % 2 === 0 ? 'rgba(15,26,46,0.4)' : 'rgba(10,18,32,0.4)';
return (
<tr
key={finding.id}
style={{ borderBottom: '1px solid rgba(255,255,255,0.04)', background: rowBg }}
onMouseEnter={(e) => e.currentTarget.style.background = 'rgba(14,165,233,0.05)'}
onMouseLeave={(e) => e.currentTarget.style.background = rowBg}
>
{visibleCols.map((col) => (
<TableCell key={col.key} colKey={col.key} finding={finding} />
))}
</tr>
);
})}
{sorted.length === 0 && (
<tr>
<td colSpan={visibleCols.length} style={{ textAlign: 'center', padding: '2rem', color: '#475569', fontFamily: 'monospace', fontSize: '0.75rem' }}>
{activeFilterCount > 0 ? 'No findings match the current filters' : 'No findings found'}
</td>
</tr>
)}
</tbody>
</table>
</div>
)}
</div>
{/* Filter dropdown — rendered via portal at document.body */}
{openFilter && COLUMN_DEFS[openFilter]?.filterable && (
<FilterDropdown
anchorEl={filterBtnRefs.current[openFilter]}
colKey={openFilter}
findings={findings}
activeFilter={columnFilters[openFilter] || null}
onFilterChange={(vals) => setColFilter(openFilter, vals)}
onClose={() => setOpenFilter(null)}
/>
)}
</div>
);
}