Files
cve-dashboard/backend/routes/ivantiFindings.js
jramos 602c75bf24 fix(reporting): source FP workflow status chart from DB instead of open-findings cache
The FP Workflow Status donut was reading from the in-memory open findings
array, so Approved FPs (which close the finding and remove it from the
open cache) were invisible.

Backend: during each sync, compute FP workflow state counts from open
findings then sweep all pages of closed findings to capture Approved
(and any other closed-state) FP workflows. Counts are stored in a new
fp_workflow_counts_json column on ivanti_counts_cache and exposed via
GET /api/ivanti/findings/fp-workflow-counts.

Frontend: FPWorkflowDonut now receives counts/total props from the new
endpoint (fetched on load and refreshed after manual sync) instead of
deriving them from the findings prop.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-16 11:43:57 -06:00

636 lines
25 KiB
JavaScript

// 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 { requireRole } = require('../middleware/auth');
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); });
// Add fp_workflow_counts_json column if it doesn't exist yet (idempotent migration)
db.run(`ALTER TABLE ivanti_counts_cache ADD COLUMN fp_workflow_counts_json TEXT DEFAULT '{}'`, () => {
// Ignore error — column already exists on subsequent startups
});
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 TABLE IF NOT EXISTS ivanti_finding_overrides (
id INTEGER PRIMARY KEY AUTOINCREMENT,
finding_id TEXT NOT NULL,
field TEXT NOT NULL,
value TEXT NOT NULL DEFAULT '',
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(finding_id, field)
)
`, (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) return reject(err); });
db.run(`
CREATE INDEX IF NOT EXISTS idx_finding_overrides_finding_id
ON ivanti_finding_overrides(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(() => {});
}
}
// ---------------------------------------------------------------------------
// Extract FP workflow state from a raw (un-extracted) finding
// ---------------------------------------------------------------------------
function extractFPWorkflowState(f) {
const wfDist = f.workflowDistribution || {};
const fpBuckets = [
...(wfDist.actionableWorkflows || []),
...(wfDist.requestedWorkflows || []),
...(wfDist.reworkedWorkflows || []),
...(wfDist.rejectedWorkflows || []),
...(wfDist.expiredWorkflows || []),
...(wfDist.approvedWorkflows || []),
].filter(w => (w.generatedId || '').startsWith('FP#'));
const fpEntry = fpBuckets[0] || null;
if (!fpEntry) return null;
return fpEntry.state || 'Unknown';
}
// ---------------------------------------------------------------------------
// Sync FP workflow state counts across ALL findings (open + closed)
// Open findings counts are derived from the already-extracted allFindings array.
// Closed findings are swept page-by-page to capture Approved FPs (which close
// the finding and thus disappear from the open-only cache).
// ---------------------------------------------------------------------------
async function syncFPWorkflowCounts(db, openFindings, apiKey, clientId, skipTls) {
// Seed counts from open findings (already have workflow.state)
const counts = {};
openFindings.forEach(f => {
if (!f.workflow) return;
const state = f.workflow.state || 'Unknown';
counts[state] = (counts[state] || 0) + 1;
});
// Sweep closed findings to pick up Approved (and any other closed FP states)
const urlPath = `/client/${encodeURIComponent(clientId)}/hostFinding/search`;
let page = 0;
let totalPages = 1;
try {
do {
const body = {
filters: CLOSED_COUNT_FILTERS,
projection: 'internal',
sort: [{ field: 'severity', direction: 'ASC' }],
page,
size: 100
};
const result = await ivantiPost(urlPath, body, apiKey, skipTls);
if (result.status !== 200) {
console.warn(`[Ivanti Findings] FP workflow counts: closed findings page ${page} returned ${result.status} — stopping sweep`);
break;
}
const data = JSON.parse(result.body);
totalPages = data.page?.totalPages || 1;
const findings = data._embedded?.hostFindings || [];
findings.forEach(f => {
const state = extractFPWorkflowState(f);
if (!state) return;
counts[state] = (counts[state] || 0) + 1;
});
console.log(`[Ivanti Findings] FP workflow counts: closed page ${page + 1}/${totalPages}`);
page++;
} while (page < totalPages);
} catch (err) {
console.error('[Ivanti Findings] FP workflow counts: closed sweep failed:', err.message);
// Fall through — store whatever counts we have from open findings
}
await dbRun(db,
`UPDATE ivanti_counts_cache SET fp_workflow_counts_json=? WHERE id=1`,
[JSON.stringify(counts)]
).catch(e => console.error('[Ivanti Findings] Failed to store FP workflow counts:', e.message));
console.log('[Ivanti Findings] FP workflow counts updated:', counts);
}
// ---------------------------------------------------------------------------
// 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);
await syncFPWorkflowCounts(db, allFindings, 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,
});
}
);
});
}
// Returns { findingId: { hostName: 'override', dns: 'override' }, ... }
function readOverrides(db) {
return new Promise((resolve, reject) => {
db.all('SELECT finding_id, field, value FROM ivanti_finding_overrides', (err, rows) => {
if (err) return reject(err);
const map = {};
(rows || []).forEach((r) => {
if (!map[r.finding_id]) map[r.finding_id] = {};
map[r.finding_id][r.field] = r.value;
});
resolve(map);
});
});
}
async function readStateWithNotes(db) {
const [state, notes, overrides] = await Promise.all([readState(db), readNotes(db), readOverrides(db)]);
state.findings = state.findings.map((f) => ({
...f,
note: notes[f.id] || '',
overrides: overrides[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' });
}
});
// GET /fp-workflow-counts — FP workflow state distribution (open + closed findings)
router.get('/fp-workflow-counts', async (req, res) => {
try {
const row = await new Promise((resolve, reject) => {
db.get('SELECT fp_workflow_counts_json FROM ivanti_counts_cache WHERE id=1',
(err, row) => { if (err) reject(err); else resolve(row); }
);
});
let counts = {};
try { counts = JSON.parse(row?.fp_workflow_counts_json || '{}'); } catch (_) {}
const total = Object.values(counts).reduce((a, b) => a + b, 0);
res.json({ counts, total });
} catch {
res.status(500).json({ error: 'Database error reading FP workflow counts' });
}
});
// PUT /:findingId/override — save or clear a field override (editor/admin only)
const OVERRIDE_ALLOWED = ['hostName', 'dns'];
router.put('/:findingId/override', requireRole('editor', 'admin'), (req, res) => {
const { findingId } = req.params;
const { field, value } = req.body;
if (!OVERRIDE_ALLOWED.includes(field)) {
return res.status(400).json({ error: `Field '${field}' is not editable. Allowed: ${OVERRIDE_ALLOWED.join(', ')}` });
}
const val = String(value ?? '').trim();
if (val === '') {
// Empty value = clear the override (revert to Ivanti)
db.run(
'DELETE FROM ivanti_finding_overrides WHERE finding_id = ? AND field = ?',
[findingId, field],
(err) => {
if (err) return res.status(500).json({ error: 'Failed to clear override' });
res.json({ finding_id: findingId, field, value: null });
}
);
} else {
db.run(
`INSERT INTO ivanti_finding_overrides (finding_id, field, value, updated_at)
VALUES (?, ?, ?, datetime('now'))
ON CONFLICT(finding_id, field) DO UPDATE SET value=excluded.value, updated_at=datetime('now')`,
[findingId, field, val],
(err) => {
if (err) return res.status(500).json({ error: 'Failed to save override' });
res.json({ finding_id: findingId, field, value: val });
}
);
}
});
// 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;