23 Commits

Author SHA1 Message Date
f24cdb5063 feat(reporting): add Open vs Closed donut chart to Metrics panel
Backend: adds ivanti_counts_cache table, fetches Closed count (page 0,
size 1) from Ivanti after each Open sync, and exposes GET /counts endpoint.

Frontend: replaces the Metrics placeholder with an SVG donut chart showing
Open vs Closed proportions with counts and percentages. Counts are fetched
on mount and refreshed after manual sync.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-13 12:23:05 -06:00
3e2546323e feat(reporting): add CSV and XLSX export to findings table
Adds an Export dropdown button to the Reporting page action bar.
Exports respect current filters, sort order, and column visibility.
CSV uses pure JS (UTF-8 BOM for Excel compatibility); XLSX uses SheetJS
with auto-fitted column widths.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-13 12:08:20 -06:00
b1a21e8771 docs: Add MOP for Workflow column color codes
Method of Procedure explaining FP# badge states, color meanings,
required actions, decision flowchart, and quick reference card.
Intended for training NTS-AEO team members on the Reporting page.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 15:45:48 -06:00
bc9e223ab7 Workflow column: FP# only, urgency-based colors
- Backend: only extract FP# workflows; SYS# auto-generated tickets
  are no longer stored or shown (not actionable for triage purposes).
  Findings with no FP# ticket show blank in the workflow column.
- Frontend: recolor workflow badges by action urgency —
  Expired/Rejected = red (act now), Reworked/Actionable = amber
  (resubmit), Requested = blue (waiting on approval).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 15:36:02 -06:00
2d1acca990 Add Workflow column to Reporting page with FP# priority matching
- Backend: extractFinding now flattens all workflowDistribution buckets
  and prioritises FP# (False Positive) tickets over SYS# workflows.
  Falls back to workflowGeneratedNames for FP# IDs not yet in distribution.
- Frontend: Add Workflow column (sortable, filterable) with state-coloured
  badge (green=Approved, blue=Requested, amber=Reworked/Actionable,
  red=Rejected, grey=Expired/unknown).
- Bump localStorage key to v2 so the new column appears on all clients
  without needing a manual cache clear.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 14:44:53 -06:00
9893460b64 feat(reporting): add Finding ID column
ID was already stored in the cache from f.id; exposed as a sortable
column (filterable: false — too many unique values to be useful as a filter).
Existing users get it appended to the end of their saved column order
via the loadColumnOrder merge logic; new users see it first.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 14:23:50 -06:00
51b1f99b3a feat(calendar): click due-date day to navigate to filtered Reporting view
- CalendarWidget accepts onDateClick prop; due-date cells are clickable
  with pointer cursor, red hover highlight, and updated tooltip
- App.js wires onDateClick: sets calendarFilter state and navigates to
  the Reporting page
- NavDrawer navigation to Reporting clears calendarFilter so it only
  applies on calendar-initiated navigation
- ReportingPage accepts filterDate prop; initializes columnFilters with
  { dueDate: Set([filterDate]) } so the view lands pre-filtered
- Existing Clear Filters button lets the user dismiss the filter normally

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 14:09:08 -06:00
669396f635 feat(calendar): live calendar with Ivanti due date indicators
- Replace hardcoded Feb 2024 static HTML with dynamic CalendarWidget component
- Auto-displays current month on load; prev/next chevron navigation
- Fetches /api/ivanti/findings on mount and builds a date→count map
- Days with findings due: date number rendered in red bold + red glowing dot below
- Today: sky-blue highlight + bold (combined with red if also a due date)
- Legend appears automatically when the displayed month has any due dates
- Tooltip on due-date cells shows count ("3 findings due")

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 13:44:44 -06:00
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
16 changed files with 2267 additions and 878 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,464 @@
// Ivanti / RiskSense Host Findings Routes
// Caches hostFinding/search results in SQLite with daily auto-sync.
// Notes are stored separately so they survive cache refreshes.
const express = require('express');
const https = require('https');
const IVANTI_URL_BASE = 'https://platform4.risksense.com/api/v1';
const SYNC_INTERVAL_MS = 24 * 60 * 60 * 1000;
const FINDINGS_FILTERS = [
// NOTE: This filters for Open findings only — Closed count is fetched separately via syncClosedCount()
{
field: 'assetCustomAttributes.1550_host_1.value',
exclusive: false,
operator: 'IN',
orWithPrevious: false,
implicitFilters: [],
value: 'NTS-AEO-ACCESS-ENG,NTS-AEO-STEAM',
caseSensitive: false
},
{
field: 'severity',
exclusive: false,
operator: 'RANGE',
orWithPrevious: false,
implicitFilters: [],
value: '8.5,9.9',
caseSensitive: false
},
{
field: 'generic_state',
exclusive: false,
operator: 'EXACT',
orWithPrevious: false,
implicitFilters: [],
value: 'Open',
caseSensitive: false
}
];
// Same BU + severity filters but for Closed state — used only to fetch the total count
const CLOSED_COUNT_FILTERS = [
{
field: 'assetCustomAttributes.1550_host_1.value',
exclusive: false,
operator: 'IN',
orWithPrevious: false,
implicitFilters: [],
value: 'NTS-AEO-ACCESS-ENG,NTS-AEO-STEAM',
caseSensitive: false
},
{
field: 'severity',
exclusive: false,
operator: 'RANGE',
orWithPrevious: false,
implicitFilters: [],
value: '8.5,9.9',
caseSensitive: false
},
{
field: 'generic_state',
exclusive: false,
operator: 'EXACT',
orWithPrevious: false,
implicitFilters: [],
value: 'Closed',
caseSensitive: false
}
];
// ---------------------------------------------------------------------------
// HTTP helper — mirrors the one in ivantiWorkflows.js
// ---------------------------------------------------------------------------
function ivantiPost(urlPath, body, apiKey, skipTls) {
const bodyStr = JSON.stringify(body);
const fullUrl = new URL(IVANTI_URL_BASE + urlPath);
return new Promise((resolve, reject) => {
const options = {
hostname: fullUrl.hostname,
path: fullUrl.pathname + fullUrl.search,
method: 'POST',
headers: {
'accept': '*/*',
'content-type': 'application/json',
'x-api-key': apiKey,
'x-http-client-type': 'browser',
'content-length': Buffer.byteLength(bodyStr)
},
rejectUnauthorized: !skipTls,
timeout: 20000
};
const req = https.request(options, (res) => {
let data = '';
res.on('data', (chunk) => { data += chunk; });
res.on('end', () => resolve({ status: res.statusCode, body: data }));
});
req.on('timeout', () => req.destroy(new Error('Request timed out')));
req.on('error', reject);
req.write(bodyStr);
req.end();
});
}
// ---------------------------------------------------------------------------
// Table init
// ---------------------------------------------------------------------------
function initTables(db) {
return new Promise((resolve, reject) => {
db.serialize(() => {
db.run(`
CREATE TABLE IF NOT EXISTS ivanti_findings_cache (
id INTEGER PRIMARY KEY CHECK (id = 1),
total INTEGER DEFAULT 0,
findings_json TEXT DEFAULT '[]',
synced_at DATETIME,
sync_status TEXT DEFAULT 'never',
error_message TEXT
)
`, (err) => { if (err) return reject(err); });
db.run(`
INSERT OR IGNORE INTO ivanti_findings_cache (id, total, findings_json, sync_status)
VALUES (1, 0, '[]', 'never')
`, (err) => { if (err) return reject(err); });
db.run(`
CREATE TABLE IF NOT EXISTS ivanti_finding_notes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
finding_id TEXT NOT NULL UNIQUE,
note TEXT NOT NULL DEFAULT '',
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`, (err) => { if (err) return reject(err); });
db.run(`
CREATE TABLE IF NOT EXISTS ivanti_counts_cache (
id INTEGER PRIMARY KEY CHECK (id = 1),
open_count INTEGER DEFAULT 0,
closed_count INTEGER DEFAULT 0,
synced_at DATETIME
)
`, (err) => { if (err) return reject(err); });
db.run(`
INSERT OR IGNORE INTO ivanti_counts_cache (id, open_count, closed_count)
VALUES (1, 0, 0)
`, (err) => { if (err) return reject(err); });
db.run(`
CREATE INDEX IF NOT EXISTS idx_finding_notes_finding_id
ON ivanti_finding_notes(finding_id)
`, (err) => {
if (err) reject(err);
else resolve();
});
});
});
}
// ---------------------------------------------------------------------------
// Extract only the fields we need from a raw finding object
// ---------------------------------------------------------------------------
function extractFinding(f) {
// statusEmbedded.dueDate = "2026-03-06T00:00:00" — strip to date part
const rawDueDate = f.statusEmbedded?.dueDate || '';
const dueDate = rawDueDate ? rawDueDate.split('T')[0] : '';
// BU ownership: assetCustomAttributes['1550_host_1'] is an array like ["NTS-AEO-STEAM"]
const buOwnership = f.assetCustomAttributes?.['1550_host_1']?.[0] || '';
// CVE list: vulnerabilities.vulnInfoList[].cve
const cves = (f.vulnerabilities?.vulnInfoList || []).map(v => v.cve).filter(Boolean);
// Workflow: only capture FP# (False Positive) tickets — SYS# are auto-generated
// system workflows and not actionable for our purposes.
const wfDist = f.workflowDistribution || {};
const fpBuckets = [
...(wfDist.actionableWorkflows || []),
...(wfDist.requestedWorkflows || []),
...(wfDist.reworkedWorkflows || []),
...(wfDist.rejectedWorkflows || []),
...(wfDist.expiredWorkflows || []),
...(wfDist.approvedWorkflows || []),
].filter(w => (w.generatedId || '').startsWith('FP#'));
// Priority: actionable > requested > reworked > rejected > expired > approved
const fpEntry = fpBuckets[0] || null;
// Fallback: if no FP# in distribution, check workflowGeneratedNames directly
const generatedNames = f.workflowGeneratedNames || [];
const fpFromNames = !fpEntry
? generatedNames.find(n => n.startsWith('FP#')) || null
: null;
const workflow = fpEntry ? {
id: fpEntry.generatedId || '',
state: fpEntry.state || '',
type: 'FP',
} : fpFromNames ? {
id: fpFromNames,
state: '',
type: 'FP',
} : null;
return {
id: String(f.id),
title: f.title || '',
severity: typeof f.severity === 'number' ? f.severity : parseFloat(f.severity) || 0,
vrrGroup: f.vrrGroup || f.severityGroup || '',
hostName: f.host?.hostName || '',
ipAddress: f.host?.ipAddress || '',
dns: f.dns || f.host?.fqdn || '',
status: f.status || '',
slaStatus: f.slaStatus || '',
dueDate,
lastFoundOn: f.lastFoundOn || '',
buOwnership,
cves,
workflow
};
}
// ---------------------------------------------------------------------------
// Fetch total count of Closed findings from Ivanti (page 0, size 1)
// ---------------------------------------------------------------------------
async function syncClosedCount(db, openCount, apiKey, clientId, skipTls) {
const urlPath = `/client/${encodeURIComponent(clientId)}/hostFinding/search`;
try {
const body = {
filters: CLOSED_COUNT_FILTERS,
projection: 'internal',
sort: [{ field: 'severity', direction: 'ASC' }],
page: 0,
size: 1
};
const result = await ivantiPost(urlPath, body, apiKey, skipTls);
if (result.status !== 200) throw new Error(`Closed count API returned status ${result.status}`);
const data = JSON.parse(result.body);
// RiskSense returns total in page.totalElements or page.total
const closedCount = data.page?.totalElements ?? data.page?.total ?? 0;
await dbRun(db,
`UPDATE ivanti_counts_cache SET open_count=?, closed_count=?, synced_at=datetime('now') WHERE id=1`,
[openCount, closedCount]
);
console.log(`[Ivanti Findings] Counts updated — open: ${openCount}, closed: ${closedCount}`);
} catch (err) {
console.error('[Ivanti Findings] Failed to fetch closed count:', err.message);
// Still update open count so it stays in sync; leave closed_count as-is
await dbRun(db,
`UPDATE ivanti_counts_cache SET open_count=?, synced_at=datetime('now') WHERE id=1`,
[openCount]
).catch(() => {});
}
}
// ---------------------------------------------------------------------------
// Core sync — fetches ALL pages, stores slimmed findings in SQLite
// ---------------------------------------------------------------------------
async function syncFindings(db) {
const apiKey = process.env.IVANTI_API_KEY;
const clientId = process.env.IVANTI_CLIENT_ID || '1550';
const skipTls = process.env.IVANTI_SKIP_TLS === 'true';
if (!apiKey) {
const errMsg = 'IVANTI_API_KEY not set in .env — skipping sync';
console.warn('[Ivanti Findings]', errMsg);
await dbRun(db, `UPDATE ivanti_findings_cache SET sync_status='error', error_message=?, synced_at=datetime('now') WHERE id=1`, [errMsg]);
return;
}
console.log('[Ivanti Findings] Starting sync...');
const urlPath = `/client/${encodeURIComponent(clientId)}/hostFinding/search`;
let allFindings = [];
let page = 0;
let totalPages = 1;
try {
do {
const body = {
filters: FINDINGS_FILTERS,
projection: 'internal',
sort: [{ field: 'severity', direction: 'ASC' }],
page,
size: 100
};
const result = await ivantiPost(urlPath, body, apiKey, skipTls);
if (result.status === 401) throw new Error('Invalid or missing API key (401)');
if (result.status === 419) throw new Error('Insufficient privileges (419) — API key lacks hostFinding access');
if (result.status === 429) throw new Error('Rate limited (429) — try again later');
if (result.status !== 200) throw new Error(`Ivanti API returned status ${result.status}`);
const data = JSON.parse(result.body);
totalPages = data.page?.totalPages || 1;
const findings = data._embedded?.hostFindings || [];
allFindings = allFindings.concat(findings.map(extractFinding));
console.log(`[Ivanti Findings] Page ${page + 1}/${totalPages}${allFindings.length} findings so far`);
page++;
} while (page < totalPages);
await dbRun(db,
`UPDATE ivanti_findings_cache SET total=?, findings_json=?, synced_at=datetime('now'), sync_status='success', error_message=NULL WHERE id=1`,
[allFindings.length, JSON.stringify(allFindings)]
);
console.log(`[Ivanti Findings] Sync complete — ${allFindings.length} findings`);
await syncClosedCount(db, allFindings.length, apiKey, clientId, skipTls);
} catch (err) {
const msg = err.message || 'Unknown error';
console.error('[Ivanti Findings] Sync failed:', msg);
await dbRun(db, `UPDATE ivanti_findings_cache SET sync_status='error', error_message=?, synced_at=datetime('now') WHERE id=1`, [msg]);
}
}
// ---------------------------------------------------------------------------
// Scheduler
// ---------------------------------------------------------------------------
function scheduleSync(db) {
db.get('SELECT synced_at FROM ivanti_findings_cache WHERE id = 1', (err, row) => {
if (err || !row || !row.synced_at) {
syncFindings(db);
} else {
const lastSync = new Date(row.synced_at.replace(' ', 'T') + 'Z');
const hoursSince = (Date.now() - lastSync.getTime()) / (1000 * 60 * 60);
if (hoursSince >= 24) {
syncFindings(db);
} else {
console.log(`[Ivanti Findings] Last sync ${hoursSince.toFixed(1)}h ago — next auto-sync in ${(24 - hoursSince).toFixed(1)}h`);
}
}
});
setInterval(() => syncFindings(db), SYNC_INTERVAL_MS);
}
// ---------------------------------------------------------------------------
// DB helpers
// ---------------------------------------------------------------------------
function dbRun(db, sql, params = []) {
return new Promise((resolve, reject) => {
db.run(sql, params, (err) => { if (err) reject(err); else resolve(); });
});
}
function readState(db) {
return new Promise((resolve, reject) => {
db.get(
'SELECT total, findings_json, synced_at, sync_status, error_message FROM ivanti_findings_cache WHERE id = 1',
(err, row) => {
if (err) return reject(err);
if (!row) return resolve({ total: 0, findings: [], synced_at: null, sync_status: 'never', error_message: null });
let findings = [];
try { findings = JSON.parse(row.findings_json || '[]'); } catch (_) { /* leave empty */ }
resolve({ total: row.total || 0, findings, synced_at: row.synced_at, sync_status: row.sync_status, error_message: row.error_message });
}
);
});
}
function readNotes(db) {
return new Promise((resolve, reject) => {
db.all('SELECT finding_id, note FROM ivanti_finding_notes', (err, rows) => {
if (err) return reject(err);
const map = {};
(rows || []).forEach((r) => { map[r.finding_id] = r.note; });
resolve(map);
});
});
}
function readCounts(db) {
return new Promise((resolve, reject) => {
db.get(
'SELECT open_count, closed_count, synced_at FROM ivanti_counts_cache WHERE id = 1',
(err, row) => {
if (err) return reject(err);
resolve({
open: row?.open_count ?? 0,
closed: row?.closed_count ?? 0,
synced_at: row?.synced_at ?? null,
});
}
);
});
}
async function readStateWithNotes(db) {
const [state, notes] = await Promise.all([readState(db), readNotes(db)]);
state.findings = state.findings.map((f) => ({ ...f, note: notes[f.id] || '' }));
return state;
}
// ---------------------------------------------------------------------------
// Router
// ---------------------------------------------------------------------------
function createIvantiFindingsRouter(db, requireAuth) {
const router = express.Router();
initTables(db)
.then(() => scheduleSync(db))
.catch((err) => console.error('[Ivanti Findings] Init failed:', err));
router.use(requireAuth(db));
// GET / — cached findings with notes merged in
router.get('/', async (req, res) => {
try {
res.json(await readStateWithNotes(db));
} catch {
res.status(500).json({ error: 'Database error reading findings' });
}
});
// POST /sync — trigger immediate sync, return fresh state
router.post('/sync', async (req, res) => {
await syncFindings(db);
try {
res.json(await readStateWithNotes(db));
} catch {
res.status(500).json({ error: 'Sync ran but could not read updated state' });
}
});
// GET /counts — open vs closed totals for pie chart
router.get('/counts', async (req, res) => {
try {
res.json(await readCounts(db));
} catch {
res.status(500).json({ error: 'Database error reading counts' });
}
});
// PUT /:findingId/note — save or update a note (max 255 chars enforced here)
router.put('/:findingId/note', (req, res) => {
const { findingId } = req.params;
const note = String(req.body.note || '').slice(0, 255);
db.run(
`INSERT INTO ivanti_finding_notes (finding_id, note, updated_at)
VALUES (?, ?, datetime('now'))
ON CONFLICT(finding_id) DO UPDATE SET note=excluded.note, updated_at=datetime('now')`,
[findingId, note],
(err) => {
if (err) return res.status(500).json({ error: 'Failed to save note' });
res.json({ finding_id: findingId, note });
}
);
});
return router;
}
module.exports = createIvantiFindingsRouter;

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 createAuditLogRouter = require('./routes/auditLog');
const logAudit = require('./helpers/auditLog'); const logAudit = require('./helpers/auditLog');
const createNvdLookupRouter = require('./routes/nvdLookup'); const createNvdLookupRouter = require('./routes/nvdLookup');
const createWeeklyReportsRouter = require('./routes/weeklyReports');
const createKnowledgeBaseRouter = require('./routes/knowledgeBase'); const createKnowledgeBaseRouter = require('./routes/knowledgeBase');
const createArcherTicketsRouter = require('./routes/archerTickets'); const createArcherTicketsRouter = require('./routes/archerTickets');
const createIvantiWorkflowsRouter = require('./routes/ivantiWorkflows'); const createIvantiWorkflowsRouter = require('./routes/ivantiWorkflows');
const createIvantiFindingsRouter = require('./routes/ivantiFindings');
const app = express(); const app = express();
const PORT = process.env.PORT || 3001; const PORT = process.env.PORT || 3001;
@@ -175,9 +175,6 @@ const upload = multer({
limits: { fileSize: 10 * 1024 * 1024 } // 10MB limit limits: { fileSize: 10 * 1024 * 1024 } // 10MB limit
}); });
// Weekly reports routes (editor/admin for upload, all authenticated for download)
app.use('/api/weekly-reports', createWeeklyReportsRouter(db, upload));
// Knowledge base routes (editor/admin for upload/delete, all authenticated for view) // Knowledge base routes (editor/admin for upload/delete, all authenticated for view)
app.use('/api/knowledge-base', createKnowledgeBaseRouter(db, upload)); app.use('/api/knowledge-base', createKnowledgeBaseRouter(db, upload));
@@ -187,6 +184,9 @@ app.use('/api/archer-tickets', createArcherTicketsRouter(db));
// Ivanti / RiskSense workflow routes (all authenticated users) // Ivanti / RiskSense workflow routes (all authenticated users)
app.use('/api/ivanti/workflows', createIvantiWorkflowsRouter(db, requireAuth)); app.use('/api/ivanti/workflows', createIvantiWorkflowsRouter(db, requireAuth));
// Ivanti / RiskSense host findings routes (all authenticated users)
app.use('/api/ivanti/findings', createIvantiFindingsRouter(db, requireAuth));
// ========== CVE ENDPOINTS ========== // ========== CVE ENDPOINTS ==========
// Get all CVEs with optional filters (authenticated users) // Get all CVEs with optional filters (authenticated users)

View File

@@ -0,0 +1,120 @@
# MOP: Ivanti Finding Workflow Status — STEAM Security Dashboard
**Document Type:** Method of Procedure
**Applies To:** STEAM Security Dashboard — Reporting Page
**Audience:** NTS-AEO-ACCESS-ENG / NTS-AEO-STEAM team members
---
## 1. Purpose
This document explains how to interpret the **Workflow** column on the Reporting page and what action to take for each status. The goal is to ensure every open finding is actively managed and no False Positive (FP) exception lapses unnoticed.
---
## 2. Background
### What the Reporting Page Shows
The Reporting page displays **open findings only** (severity 8.5+, `generic_state = Open`). A finding disappears from this list when it is closed — which happens when a valid, approved FP exception is on file or when the vulnerability is remediated.
### What the Workflow Column Shows
The Workflow column tracks **FP# tickets only** — False Positive requests that a team member has manually submitted in Ivanti. These represent cases where the team has asserted a finding is not exploitable or applicable in our environment.
> **SYS# workflows are not shown.** SYS# are auto-generated system tracking records and do not require team action.
### Key Rule
If a finding appears in the Reporting page, it requires action — regardless of whether it has an FP# badge or not.
---
## 3. Workflow Column Color Codes
### 🔴 Red — Act Immediately
| State | What It Means | Required Action |
|---|---|---|
| **Expired** | An FP# ticket existed but the exception window has lapsed. The finding re-opened. | Log into Ivanti and submit a **new FP request** for this finding. Reference the previous ticket if relevant. |
| **Rejected** | The security team reviewed the FP request and denied it. The finding is considered a real, exploitable vulnerability. | **Remediate the vulnerability.** Apply the relevant patch, configuration change, or compensating control. Do not resubmit an FP without new evidence. |
---
### 🟡 Amber — Action Required Soon
| State | What It Means | Required Action |
|---|---|---|
| **Reworked** | The FP request was challenged by the reviewer and sent back for revision. | Review the reviewer's comments in Ivanti. Update the FP justification and **resubmit the ticket**. |
| **Actionable** | The FP ticket has been flagged as needing team action. | Open the ticket in Ivanti to review what is needed and respond accordingly. |
---
### 🔵 Blue — In Flight, Monitor
| State | What It Means | Required Action |
|---|---|---|
| **Requested** | An FP# ticket has been submitted and is awaiting security team approval. | No immediate action. Monitor for approval or rejection. If no response within your SLA window, follow up with the approver. |
---
### — (No Badge) — Untriaged
| State | What It Means | Required Action |
|---|---|---|
| **No workflow badge** | No FP ticket has ever been submitted for this finding. | Triage the finding. Determine whether to: (1) remediate it, or (2) submit a new FP request if you have justification that it is a false positive. |
---
## 4. Decision Flowchart
```
Finding appears in Reporting page
├── Does it have a Workflow badge?
│ │
│ ├── NO (—)
│ │ └── Triage → Remediate OR submit new FP request
│ │
│ └── YES → Check the color:
│ │
│ ├── 🔵 BLUE (Requested)
│ │ └── Wait for approval. Follow up if SLA window is approaching.
│ │
│ ├── 🟡 AMBER (Reworked / Actionable)
│ │ └── Open Ivanti ticket → Review feedback → Update → Resubmit
│ │
│ └── 🔴 RED
│ │
│ ├── Expired → Submit NEW FP request in Ivanti
│ │
│ └── Rejected → Remediate the vulnerability
```
---
## 5. How to Submit or Renew an FP Request in Ivanti
1. Log into [Ivanti / RiskSense](https://platform4.risksense.com)
2. Navigate to **Host Findings**
3. Search for the Finding ID shown in the dashboard (Finding ID column)
4. Select the finding → **Actions****Request False Positive**
5. Complete the justification form:
- Describe why the finding is not exploitable in this environment
- Reference any compensating controls, network segmentation, or vendor guidance
- Attach supporting evidence if available
6. Submit — ticket will appear as **Requested** (blue) in the dashboard once processed
---
## 6. Quick Reference Card
| Badge Color | State | One-Line Action |
|---|---|---|
| 🔴 Red | Expired | Renew FP request in Ivanti |
| 🔴 Red | Rejected | Remediate the vulnerability |
| 🟡 Amber | Reworked | Update and resubmit FP ticket |
| 🟡 Amber | Actionable | Review ticket in Ivanti |
| 🔵 Blue | Requested | Monitor — no action yet |
| — | No badge | Triage: remediate or submit FP |
---
*Last updated: 2026-03-11*

View File

@@ -12,7 +12,8 @@
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"react-scripts": "5.0.1", "react-scripts": "5.0.1",
"web-vitals": "^2.1.4" "web-vitals": "^2.1.4",
"xlsx": "^0.18.5"
}, },
"scripts": { "scripts": {
"start": "react-scripts start", "start": "react-scripts start",

View File

@@ -1,14 +1,18 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { Search, FileText, AlertCircle, Download, Upload, Eye, Filter, CheckCircle, XCircle, Loader, Trash2, Plus, RefreshCw, Edit2, ChevronDown, Shield, Activity } from 'lucide-react'; import { Search, FileText, AlertCircle, Download, Upload, Eye, Filter, CheckCircle, XCircle, Loader, Trash2, Plus, RefreshCw, Edit2, ChevronDown, Shield, Activity, Menu } from 'lucide-react';
import { useAuth } from './contexts/AuthContext'; import { useAuth } from './contexts/AuthContext';
import LoginForm from './components/LoginForm'; import LoginForm from './components/LoginForm';
import UserMenu from './components/UserMenu'; import UserMenu from './components/UserMenu';
import UserManagement from './components/UserManagement'; import UserManagement from './components/UserManagement';
import AuditLog from './components/AuditLog'; import AuditLog from './components/AuditLog';
import NvdSyncModal from './components/NvdSyncModal'; import NvdSyncModal from './components/NvdSyncModal';
import WeeklyReportModal from './components/WeeklyReportModal';
import KnowledgeBaseModal from './components/KnowledgeBaseModal'; import KnowledgeBaseModal from './components/KnowledgeBaseModal';
import KnowledgeBaseViewer from './components/KnowledgeBaseViewer'; import KnowledgeBaseViewer from './components/KnowledgeBaseViewer';
import NavDrawer from './components/NavDrawer';
import CalendarWidget from './components/CalendarWidget';
import ReportingPage from './components/pages/ReportingPage';
import KnowledgeBasePage from './components/pages/KnowledgeBasePage';
import ExportsPage from './components/pages/ExportsPage';
import './App.css'; import './App.css';
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api'; const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
@@ -172,11 +176,13 @@ export default function App() {
const [cveDocuments, setCveDocuments] = useState({}); const [cveDocuments, setCveDocuments] = useState({});
const [quickCheckCVE, setQuickCheckCVE] = useState(''); const [quickCheckCVE, setQuickCheckCVE] = useState('');
const [quickCheckResult, setQuickCheckResult] = useState(null); const [quickCheckResult, setQuickCheckResult] = useState(null);
const [currentPage, setCurrentPage] = useState('home');
const [navOpen, setNavOpen] = useState(false);
const [calendarFilter, setCalendarFilter] = useState(null);
const [showAddCVE, setShowAddCVE] = useState(false); const [showAddCVE, setShowAddCVE] = useState(false);
const [showUserManagement, setShowUserManagement] = useState(false); const [showUserManagement, setShowUserManagement] = useState(false);
const [showAuditLog, setShowAuditLog] = useState(false); const [showAuditLog, setShowAuditLog] = useState(false);
const [showNvdSync, setShowNvdSync] = useState(false); const [showNvdSync, setShowNvdSync] = useState(false);
const [showWeeklyReport, setShowWeeklyReport] = useState(false);
const [showKnowledgeBase, setShowKnowledgeBase] = useState(false); const [showKnowledgeBase, setShowKnowledgeBase] = useState(false);
const [knowledgeBaseArticles, setKnowledgeBaseArticles] = useState([]); const [knowledgeBaseArticles, setKnowledgeBaseArticles] = useState([]);
const [selectedKBArticle, setSelectedKBArticle] = useState(null); const [selectedKBArticle, setSelectedKBArticle] = useState(null);
@@ -952,18 +958,39 @@ export default function App() {
return ( return (
<div className="min-h-screen bg-intel-darkest grid-bg p-6 relative overflow-hidden fade-in"> <div className="min-h-screen bg-intel-darkest grid-bg p-6 relative overflow-hidden fade-in">
<NavDrawer
isOpen={navOpen}
onClose={() => setNavOpen(false)}
currentPage={currentPage}
onNavigate={(page) => {
// Clear calendar filter when navigating directly via the nav drawer
if (page === 'reporting') setCalendarFilter(null);
setCurrentPage(page);
}}
/>
{/* Scanning line effect */} {/* Scanning line effect */}
<div className="scan-line"></div> <div className="scan-line"></div>
<div className="max-w-7xl mx-auto relative z-10"> <div className={`${currentPage === 'reporting' ? 'w-full' : 'max-w-7xl mx-auto'} relative z-10`}>
{/* Header */} {/* Header */}
<div className="mb-8"> <div className="mb-8">
<div className="flex justify-between items-start mb-6"> <div className="flex justify-between items-start mb-6">
<div className="flex-1"> <div className="flex items-center gap-4 flex-1">
<h1 className="text-4xl font-bold text-intel-accent mb-1 font-mono tracking-tight"> <button
CVE INTEL onClick={() => setNavOpen(true)}
</h1> style={{ background: 'none', border: '1px solid rgba(14, 165, 233, 0.25)', borderRadius: '0.375rem', padding: '0.5rem', cursor: 'pointer', color: '#64748B', flexShrink: 0 }}
<p className="text-gray-400 text-sm font-sans">Threat Intelligence & Vulnerability Command Center</p> onMouseEnter={e => { e.currentTarget.style.color = '#0EA5E9'; e.currentTarget.style.borderColor = 'rgba(14, 165, 233, 0.6)'; }}
onMouseLeave={e => { e.currentTarget.style.color = '#64748B'; e.currentTarget.style.borderColor = 'rgba(14, 165, 233, 0.25)'; }}
title="Navigation"
>
<Menu className="w-5 h-5" />
</button>
<div>
<h1 className="text-4xl font-bold text-intel-accent mb-1 font-mono tracking-tight">
STEAM Security Dashboard
</h1>
<p className="text-gray-400 text-sm font-sans">NTS Threat Intelligence and Metric Aggregation</p>
</div>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{canWrite() && ( {canWrite() && (
@@ -975,15 +1002,6 @@ export default function App() {
NVD Sync NVD Sync
</button> </button>
)} )}
{canWrite() && (
<button
onClick={() => setShowWeeklyReport(true)}
className="intel-button intel-button-success relative z-10 flex items-center gap-2"
>
<Upload className="w-4 h-4" />
Weekly Report
</button>
)}
{canWrite() && ( {canWrite() && (
<button <button
onClick={() => setShowAddCVE(true)} onClick={() => setShowAddCVE(true)}
@@ -997,8 +1015,8 @@ export default function App() {
</div> </div>
</div> </div>
{/* Stats Bar - Modern refined styling */} {/* Stats Bar - only shown on Home page */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4"> {currentPage === 'home' && <div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div style={STYLES.statCard}> <div style={STYLES.statCard}>
<div style={{ position: 'absolute', top: 0, left: 0, right: 0, height: '2px', background: 'linear-gradient(90deg, transparent, #0EA5E9, transparent)', boxShadow: '0 0 8px rgba(14, 165, 233, 0.5)' }}></div> <div style={{ position: 'absolute', top: 0, left: 0, right: 0, height: '2px', background: 'linear-gradient(90deg, transparent, #0EA5E9, transparent)', boxShadow: '0 0 8px rgba(14, 165, 233, 0.5)' }}></div>
<div style={{ fontSize: '0.75rem', textTransform: 'uppercase', letterSpacing: '0.1em', color: '#CBD5E1', marginBottom: '0.25rem' }}>Total CVEs</div> <div style={{ fontSize: '0.75rem', textTransform: 'uppercase', letterSpacing: '0.1em', color: '#CBD5E1', marginBottom: '0.25rem' }}>Total CVEs</div>
@@ -1019,9 +1037,14 @@ export default function App() {
<div style={{ fontSize: '0.75rem', textTransform: 'uppercase', letterSpacing: '0.1em', color: '#CBD5E1', marginBottom: '0.25rem' }}>Critical</div> <div style={{ fontSize: '0.75rem', textTransform: 'uppercase', letterSpacing: '0.1em', color: '#CBD5E1', marginBottom: '0.25rem' }}>Critical</div>
<div style={{ fontSize: '1.5rem', fontWeight: '700', fontFamily: 'monospace', color: '#EF4444', textShadow: '0 0 16px rgba(239, 68, 68, 0.4)' }}>{cves.filter(c => c.severity === 'Critical').length}</div> <div style={{ fontSize: '1.5rem', fontWeight: '700', fontFamily: 'monospace', color: '#EF4444', textShadow: '0 0 16px rgba(239, 68, 68, 0.4)' }}>{cves.filter(c => c.severity === 'Critical').length}</div>
</div> </div>
</div> </div>}
</div> </div>
{/* Page content */}
{currentPage === 'reporting' && <ReportingPage filterDate={calendarFilter} />}
{currentPage === 'knowledge-base' && <KnowledgeBasePage />}
{currentPage === 'exports' && <ExportsPage />}
{/* User Management Modal */} {/* User Management Modal */}
{showUserManagement && ( {showUserManagement && (
<UserManagement onClose={() => setShowUserManagement(false)} /> <UserManagement onClose={() => setShowUserManagement(false)} />
@@ -1037,11 +1060,6 @@ export default function App() {
<NvdSyncModal onClose={() => setShowNvdSync(false)} onSyncComplete={() => fetchCVEs()} /> <NvdSyncModal onClose={() => setShowNvdSync(false)} onSyncComplete={() => fetchCVEs()} />
)} )}
{/* Weekly Report Modal */}
{showWeeklyReport && (
<WeeklyReportModal onClose={() => setShowWeeklyReport(false)} />
)}
{/* Knowledge Base Modal */} {/* Knowledge Base Modal */}
{showKnowledgeBase && ( {showKnowledgeBase && (
<KnowledgeBaseModal <KnowledgeBaseModal
@@ -1640,8 +1658,8 @@ export default function App() {
</div> </div>
)} )}
{/* Three Column Layout */} {/* Three Column Layout - Home page only */}
<div className="grid grid-cols-12 gap-6"> {currentPage === 'home' && <div className="grid grid-cols-12 gap-6">
{/* LEFT PANEL - Wiki/Knowledge Base */} {/* LEFT PANEL - Wiki/Knowledge Base */}
<div className="col-span-12 lg:col-span-3 space-y-4"> <div className="col-span-12 lg:col-span-3 space-y-4">
<div style={{...STYLES.intelCard, padding: '1.5rem', borderLeft: '3px solid #10B981'}} className="rounded-lg"> <div style={{...STYLES.intelCard, padding: '1.5rem', borderLeft: '3px solid #10B981'}} className="rounded-lg">
@@ -2207,63 +2225,12 @@ export default function App() {
Calendar Calendar
</h2> </h2>
{/* Simple Calendar Grid */} <CalendarWidget
<div className="mb-2"> onDateClick={(dateStr) => {
<div className="text-center mb-3"> setCalendarFilter(dateStr);
<span className="text-white font-semibold font-mono">February 2024</span> setCurrentPage('reporting');
</div> }}
<div className="grid grid-cols-7 gap-1 text-center text-xs mb-2"> />
<div className="text-gray-400 font-mono">Su</div>
<div className="text-gray-400 font-mono">Mo</div>
<div className="text-gray-400 font-mono">Tu</div>
<div className="text-gray-400 font-mono">We</div>
<div className="text-gray-400 font-mono">Th</div>
<div className="text-gray-400 font-mono">Fr</div>
<div className="text-gray-400 font-mono">Sa</div>
</div>
<div className="grid grid-cols-7 gap-1 text-center">
{/* Week 1 */}
<div className="text-gray-600 font-mono text-xs p-1">28</div>
<div className="text-gray-600 font-mono text-xs p-1">29</div>
<div className="text-gray-600 font-mono text-xs p-1">30</div>
<div className="text-gray-600 font-mono text-xs p-1">31</div>
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">1</div>
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">2</div>
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">3</div>
{/* Week 2 */}
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">4</div>
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">5</div>
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">6</div>
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">7</div>
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">8</div>
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">9</div>
<div className="bg-intel-accent/30 text-white font-mono text-xs p-1 rounded font-bold border border-intel-accent">10</div>
{/* Week 3 */}
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">11</div>
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">12</div>
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">13</div>
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">14</div>
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">15</div>
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">16</div>
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">17</div>
{/* Week 4 */}
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">18</div>
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">19</div>
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">20</div>
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">21</div>
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">22</div>
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">23</div>
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">24</div>
{/* Week 5 */}
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">25</div>
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">26</div>
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">27</div>
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">28</div>
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">29</div>
<div className="text-gray-600 font-mono text-xs p-1">1</div>
<div className="text-gray-600 font-mono text-xs p-1">2</div>
</div>
</div>
</div> </div>
{/* Open Vendor Tickets */} {/* Open Vendor Tickets */}
@@ -2488,7 +2455,7 @@ export default function App() {
</div> </div>
{/* End Right Panel */} {/* End Right Panel */}
</div> </div>}
{/* End Three Column Layout */} {/* End Three Column Layout */}
</div> </div>
</div> </div>

View File

@@ -0,0 +1,167 @@
import React, { useState, useEffect } from 'react';
import { ChevronLeft, ChevronRight } from 'lucide-react';
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
const MONTH_NAMES = [
'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December'
];
const DAY_NAMES = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'];
function toLocalDateStr(date) {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, '0');
const d = String(date.getDate()).padStart(2, '0');
return `${y}-${m}-${d}`;
}
export default function CalendarWidget({ onDateClick }) {
const today = new Date();
const todayStr = toLocalDateStr(today);
const [calYear, setCalYear] = useState(today.getFullYear());
const [calMonth, setCalMonth] = useState(today.getMonth()); // 0-indexed
// Map of "YYYY-MM-DD" → count of findings due that day
const [dueDates, setDueDates] = useState({});
useEffect(() => {
fetch(`${API_BASE}/ivanti/findings`, { credentials: 'include' })
.then((r) => (r.ok ? r.json() : null))
.then((data) => {
if (!data?.findings) return;
const counts = {};
data.findings.forEach((f) => {
if (f.dueDate) {
counts[f.dueDate] = (counts[f.dueDate] || 0) + 1;
}
});
setDueDates(counts);
})
.catch(() => {});
}, []);
const prevMonth = () => {
if (calMonth === 0) { setCalMonth(11); setCalYear((y) => y - 1); }
else { setCalMonth((m) => m - 1); }
};
const nextMonth = () => {
if (calMonth === 11) { setCalMonth(0); setCalYear((y) => y + 1); }
else { setCalMonth((m) => m + 1); }
};
// Build cell array: null = padding, number = day of month
const firstDow = new Date(calYear, calMonth, 1).getDay(); // 0=Sun
const daysInMonth = new Date(calYear, calMonth + 1, 0).getDate();
const cells = [
...Array(firstDow).fill(null),
...Array.from({ length: daysInMonth }, (_, i) => i + 1),
];
while (cells.length % 7 !== 0) cells.push(null); // complete last row
const hasDueDatesThisMonth = cells.some((day) => {
if (!day) return false;
const ds = `${calYear}-${String(calMonth + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
return !!dueDates[ds];
});
return (
<div>
{/* Month navigation */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '0.75rem' }}>
<button
onClick={prevMonth}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#64748B', padding: '2px 4px', borderRadius: '4px', lineHeight: 1, transition: 'color 0.15s' }}
onMouseEnter={(e) => { e.currentTarget.style.color = '#0EA5E9'; }}
onMouseLeave={(e) => { e.currentTarget.style.color = '#64748B'; }}
>
<ChevronLeft style={{ width: '14px', height: '14px' }} />
</button>
<span style={{ color: '#E2E8F0', fontFamily: 'monospace', fontWeight: '600', fontSize: '0.85rem' }}>
{MONTH_NAMES[calMonth]} {calYear}
</span>
<button
onClick={nextMonth}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#64748B', padding: '2px 4px', borderRadius: '4px', lineHeight: 1, transition: 'color 0.15s' }}
onMouseEnter={(e) => { e.currentTarget.style.color = '#0EA5E9'; }}
onMouseLeave={(e) => { e.currentTarget.style.color = '#64748B'; }}
>
<ChevronRight style={{ width: '14px', height: '14px' }} />
</button>
</div>
{/* Day-of-week headers */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', gap: '2px', textAlign: 'center', marginBottom: '4px' }}>
{DAY_NAMES.map((d) => (
<div key={d} style={{ fontSize: '0.6rem', color: '#475569', fontFamily: 'monospace', fontWeight: '600', textTransform: 'uppercase' }}>
{d}
</div>
))}
</div>
{/* Day cells */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', gap: '2px' }}>
{cells.map((day, idx) => {
if (!day) return <div key={idx} />;
const dateStr = `${calYear}-${String(calMonth + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
const isToday = dateStr === todayStr;
const dueCount = dueDates[dateStr] || 0;
const hasDue = dueCount > 0;
return (
<div
key={idx}
title={hasDue ? `${dueCount} finding${dueCount > 1 ? 's' : ''} due — click to view` : undefined}
onClick={hasDue && onDateClick ? () => onDateClick(dateStr) : undefined}
style={{
display: 'flex', flexDirection: 'column', alignItems: 'center',
gap: '2px', padding: '3px 1px',
borderRadius: '4px',
background: isToday ? 'rgba(14,165,233,0.2)' : 'transparent',
border: isToday ? '1px solid rgba(14,165,233,0.5)' : '1px solid transparent',
cursor: hasDue ? 'pointer' : 'default',
transition: hasDue ? 'background 0.15s' : undefined,
}}
onMouseEnter={hasDue ? (e) => { e.currentTarget.style.background = isToday ? 'rgba(14,165,233,0.35)' : 'rgba(239,68,68,0.15)'; } : undefined}
onMouseLeave={hasDue ? (e) => { e.currentTarget.style.background = isToday ? 'rgba(14,165,233,0.2)' : 'transparent'; } : undefined}
>
<span style={{
fontSize: '0.7rem', fontFamily: 'monospace', lineHeight: 1,
color: isToday ? '#0EA5E9' : hasDue ? '#EF4444' : '#CBD5E1',
fontWeight: (isToday || hasDue) ? '700' : '400',
}}>
{day}
</span>
{/* Red dot indicator for due dates */}
{hasDue ? (
<div style={{
width: '4px', height: '4px', borderRadius: '50%',
background: '#EF4444',
boxShadow: '0 0 4px rgba(239,68,68,0.6)',
flexShrink: 0,
}} />
) : (
<div style={{ width: '4px', height: '4px' }} /> // spacer to keep rows even
)}
</div>
);
})}
</div>
{/* Legend — only shown when there are due dates this month */}
{hasDueDatesThisMonth && (
<div style={{ marginTop: '0.75rem', paddingTop: '0.625rem', borderTop: '1px solid rgba(255,255,255,0.05)', display: 'flex', alignItems: 'center', gap: '0.375rem' }}>
<div style={{ width: '6px', height: '6px', borderRadius: '50%', background: '#EF4444', boxShadow: '0 0 4px rgba(239,68,68,0.5)', flexShrink: 0 }} />
<span style={{ fontSize: '0.62rem', color: '#64748B', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
Ivanti finding due
</span>
</div>
)}
</div>
);
}

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

File diff suppressed because it is too large Load Diff