// 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 { requireGroup } = require('../middleware/auth'); const { ivantiPost } = require('../helpers/ivantiApi'); const SYNC_INTERVAL_MS = 24 * 60 * 60 * 1000; // Configurable BU filter — broadened via env var to support multi-tenancy. // Users see only their assigned teams' findings (filtered at query time). const BU_FILTER_VALUE = process.env.IVANTI_BU_FILTER || 'NTS-AEO-ACCESS-ENG,NTS-AEO-STEAM'; 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: BU_FILTER_VALUE, 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: BU_FILTER_VALUE, 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 } ]; // --------------------------------------------------------------------------- // 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); }); // Idempotent column additions — errors mean the column already exists, which is fine db.run(`ALTER TABLE ivanti_counts_cache ADD COLUMN fp_workflow_counts_json TEXT DEFAULT '{}'`, () => {}); db.run(`ALTER TABLE ivanti_counts_cache ADD COLUMN fp_id_counts_json TEXT DEFAULT '{}'`, () => {}); 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) return reject(err); }); db.run(` CREATE TABLE IF NOT EXISTS ivanti_counts_history ( id INTEGER PRIMARY KEY AUTOINCREMENT, open_count INTEGER NOT NULL, closed_count INTEGER NOT NULL, recorded_at DATETIME DEFAULT CURRENT_TIMESTAMP ) `, (err) => { if (err) reject(err); else resolve(); }); }); }); } // --------------------------------------------------------------------------- // Archive table init — creates archive tracking tables alongside the main cache // --------------------------------------------------------------------------- function initArchiveTables(db) { return new Promise((resolve, reject) => { db.serialize(() => { db.run(` CREATE TABLE IF NOT EXISTS ivanti_finding_archives ( id INTEGER PRIMARY KEY AUTOINCREMENT, finding_id TEXT NOT NULL UNIQUE, finding_title TEXT NOT NULL DEFAULT '', host_name TEXT NOT NULL DEFAULT '', ip_address TEXT NOT NULL DEFAULT '', current_state TEXT NOT NULL CHECK(current_state IN ('ARCHIVED','RETURNED','CLOSED','CLOSED_GONE')), last_severity REAL NOT NULL DEFAULT 0, first_archived_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, last_transition_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ) `, (err) => { if (err) return reject(err); }); db.run(` CREATE TABLE IF NOT EXISTS ivanti_archive_transitions ( id INTEGER PRIMARY KEY AUTOINCREMENT, archive_id INTEGER NOT NULL, from_state TEXT NOT NULL, to_state TEXT NOT NULL, severity_at_transition REAL NOT NULL DEFAULT 0, reason TEXT NOT NULL DEFAULT '', transitioned_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (archive_id) REFERENCES ivanti_finding_archives(id) ) `, (err) => { if (err) return reject(err); }); db.run(` CREATE INDEX IF NOT EXISTS idx_archive_finding_id ON ivanti_finding_archives(finding_id) `, (err) => { if (err) return reject(err); }); db.run(` CREATE INDEX IF NOT EXISTS idx_archive_current_state ON ivanti_finding_archives(current_state) `, (err) => { if (err) return reject(err); }); db.run(` CREATE INDEX IF NOT EXISTS idx_transition_archive_id ON ivanti_archive_transitions(archive_id) `, (err) => { if (err) reject(err); else resolve(); }); }); }); } // --------------------------------------------------------------------------- // Archive detection — compare previous vs current findings to detect state changes // --------------------------------------------------------------------------- async function detectArchiveChanges(db, previousFindings, currentFindings) { const previousIds = new Set(previousFindings.map(f => String(f.id))); const currentIds = new Set(currentFindings.map(f => String(f.id))); // Build lookup maps for metadata const previousMap = new Map(previousFindings.map(f => [String(f.id), f])); const currentMap = new Map(currentFindings.map(f => [String(f.id), f])); // 1. Disappeared findings: in previous but not in current → ARCHIVED const disappearedIds = [...previousIds].filter(id => !currentIds.has(id)); for (const id of disappearedIds) { const finding = previousMap.get(id); const title = finding.title || ''; const hostName = finding.hostName || ''; const ipAddress = finding.ipAddress || ''; const severity = typeof finding.severity === 'number' ? finding.severity : 0; try { // Check if this finding already has an archive record const existing = await dbGet(db, `SELECT id, current_state FROM ivanti_finding_archives WHERE finding_id = ?`, [id] ); if (existing && existing.current_state === 'RETURNED') { // Re-disappeared: RETURNED → ARCHIVED await dbRun(db, `UPDATE ivanti_finding_archives SET current_state = 'ARCHIVED', last_severity = ?, last_transition_at = datetime('now') WHERE id = ?`, [severity, existing.id] ); await dbRun(db, `INSERT INTO ivanti_archive_transitions (archive_id, from_state, to_state, severity_at_transition, reason, transitioned_at) VALUES (?, 'RETURNED', 'ARCHIVED', ?, 'severity_score_drift', datetime('now'))`, [existing.id, severity] ); console.log(`[Archive Detection] Finding ${id} re-archived (RETURNED → ARCHIVED)`); } else if (!existing) { // First disappearance: NONE → ARCHIVED const result = await dbRun(db, `INSERT INTO ivanti_finding_archives (finding_id, finding_title, host_name, ip_address, current_state, last_severity, first_archived_at, last_transition_at) VALUES (?, ?, ?, ?, 'ARCHIVED', ?, datetime('now'), datetime('now'))`, [id, title, hostName, ipAddress, severity] ); const archiveId = result.lastID; await dbRun(db, `INSERT INTO ivanti_archive_transitions (archive_id, from_state, to_state, severity_at_transition, reason, transitioned_at) VALUES (?, 'NONE', 'ARCHIVED', ?, 'severity_score_drift', datetime('now'))`, [archiveId, severity] ); console.log(`[Archive Detection] Finding ${id} archived (NONE → ARCHIVED)`); } // If existing state is ARCHIVED or CLOSED, no action needed } catch (err) { console.error(`[Archive Detection] Error processing disappeared finding ${id}:`, err.message); } } // 2. Returned findings: in current AND has existing ARCHIVED record → RETURNED const currentIdsList = [...currentIds]; const returnedArchiveIds = []; // track archive IDs of returned findings for classification if (currentIdsList.length > 0) { try { const archivedRecords = await dbAll(db, `SELECT id, finding_id FROM ivanti_finding_archives WHERE current_state = 'ARCHIVED'` ); for (const record of archivedRecords) { if (currentIds.has(record.finding_id)) { const finding = currentMap.get(record.finding_id); const severity = typeof finding.severity === 'number' ? finding.severity : 0; await dbRun(db, `UPDATE ivanti_finding_archives SET current_state = 'RETURNED', last_severity = ?, last_transition_at = datetime('now') WHERE id = ?`, [severity, record.id] ); await dbRun(db, `INSERT INTO ivanti_archive_transitions (archive_id, from_state, to_state, severity_at_transition, reason, transitioned_at) VALUES (?, 'ARCHIVED', 'RETURNED', ?, 'reappeared_in_sync', datetime('now'))`, [record.id, severity] ); returnedArchiveIds.push(record.id); console.log(`[Archive Detection] Finding ${record.finding_id} returned (ARCHIVED → RETURNED)`); } } } catch (err) { console.error('[Archive Detection] Error processing returned findings:', err.message); } } // Count returned findings for anomaly summary let returnedCount = returnedArchiveIds.length; // Classify returned findings by looking up the reason they were originally archived. // This tells us *why* they came back (e.g., BU reassignment back to team). const returnClassification = { bu_reassignment: 0, severity_drift: 0, closed_on_platform: 0, decommissioned: 0 }; for (const archiveId of returnedArchiveIds) { try { // Find the most recent ARCHIVED transition reason *before* this return const transition = await dbGet(db, `SELECT reason FROM ivanti_archive_transitions WHERE archive_id = ? AND to_state = 'ARCHIVED' AND transitioned_at <= datetime('now') ORDER BY transitioned_at DESC LIMIT 1`, [archiveId] ); if (transition && transition.reason) { // Reason format is either a plain key or "key:detail" (e.g., "bu_reassignment:SOME-BU") const reasonKey = transition.reason.split(':')[0]; if (reasonKey in returnClassification) { returnClassification[reasonKey]++; } } } catch (err) { // Non-fatal — skip this finding's classification } } console.log(`[Archive Detection] Processed ${disappearedIds.length} disappeared, ${returnedCount} returned, checked ${currentIdsList.length} current findings against archive`); if (returnedCount > 0) { console.log(`[Archive Detection] Return classification:`, returnClassification); } return { disappearedIds, returnedCount, returnClassification }; } // --------------------------------------------------------------------------- // Closed finding detection — check archived/returned findings against Ivanti closed set // --------------------------------------------------------------------------- async function detectClosedFindings(db, closedFindingIds) { if (!closedFindingIds || closedFindingIds.length === 0) return; const closedSet = new Set(closedFindingIds.map(String)); try { const records = await dbAll(db, `SELECT id, finding_id, current_state, last_severity FROM ivanti_finding_archives WHERE current_state IN ('ARCHIVED', 'RETURNED')` ); let closedCount = 0; for (const record of records) { if (!closedSet.has(record.finding_id)) continue; try { await dbRun(db, `UPDATE ivanti_finding_archives SET current_state = 'CLOSED', last_transition_at = datetime('now') WHERE id = ?`, [record.id] ); await dbRun(db, `INSERT INTO ivanti_archive_transitions (archive_id, from_state, to_state, severity_at_transition, reason, transitioned_at) VALUES (?, ?, 'CLOSED', ?, 'remediated_in_ivanti', datetime('now'))`, [record.id, record.current_state, record.last_severity || 0] ); closedCount++; console.log(`[Archive Detection] Finding ${record.finding_id} closed (${record.current_state} → CLOSED)`); } catch (err) { console.error(`[Archive Detection] Error closing finding ${record.finding_id}:`, err.message); } } console.log(`[Archive Detection] Closed ${closedCount} findings as remediated`); } catch (err) { console.error('[Archive Detection] Error querying archive records for closed detection:', err.message); } } // --------------------------------------------------------------------------- // Closed-gone detection — find archive CLOSED findings that vanished from the // Ivanti closed API set. These are findings we previously confirmed as closed // but that no longer appear in the closed results (likely VRR rescore below // the severity threshold). // --------------------------------------------------------------------------- async function detectClosedGoneFindings(db, closedFindingIds) { if (!closedFindingIds) return; const closedSet = new Set(closedFindingIds.map(String)); try { // Get all findings we previously marked as CLOSED in the archive const records = await dbAll(db, `SELECT id, finding_id, last_severity FROM ivanti_finding_archives WHERE current_state = 'CLOSED'` ); let goneCount = 0; for (const record of records) { // If this finding is still in the closed API set, it's fine if (closedSet.has(record.finding_id)) continue; try { await dbRun(db, `UPDATE ivanti_finding_archives SET current_state = 'CLOSED_GONE', last_transition_at = datetime('now') WHERE id = ?`, [record.id] ); await dbRun(db, `INSERT INTO ivanti_archive_transitions (archive_id, from_state, to_state, severity_at_transition, reason, transitioned_at) VALUES (?, 'CLOSED', 'CLOSED_GONE', ?, 'disappeared_from_closed_set', datetime('now'))`, [record.id, record.last_severity || 0] ); goneCount++; } catch (err) { console.error(`[Archive Detection] Error marking finding ${record.finding_id} as CLOSED_GONE:`, err.message); } } if (goneCount > 0) { console.warn(`[Archive Detection] ${goneCount} previously-closed findings disappeared from the Ivanti closed set (CLOSED → CLOSED_GONE)`); } } catch (err) { console.error('[Archive Detection] Error in closed-gone detection:', err.message); } } // --------------------------------------------------------------------------- // 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), hostId: f.host?.hostId || null, 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: 100 }; 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; const totalPages = data.page?.totalPages || 1; // Collect closed finding IDs for archive detection const closedFindingIds = []; const firstPageFindings = data._embedded?.hostFindings || []; firstPageFindings.forEach(f => { if (f.id) closedFindingIds.push(String(f.id)); }); // Fetch remaining pages to collect all closed finding IDs for (let pg = 1; pg < totalPages; pg++) { try { const pageBody = { ...body, page: pg }; const pageResult = await ivantiPost(urlPath, pageBody, apiKey, skipTls); if (pageResult.status !== 200) break; const pageData = JSON.parse(pageResult.body); const pageFindings = pageData._embedded?.hostFindings || []; pageFindings.forEach(f => { if (f.id) closedFindingIds.push(String(f.id)); }); } catch (err) { console.error(`[Ivanti Findings] Failed to fetch closed findings page ${pg}:`, err.message); break; } } await dbRun(db, `UPDATE ivanti_counts_cache SET open_count=?, closed_count=?, synced_at=datetime('now') WHERE id=1`, [openCount, closedCount] ); // Drift guard — if the new total (open+closed) drops by more than 50% // compared to the most recent history snapshot, skip writing to history. // This prevents partial API responses from corrupting the trend chart. const newTotal = openCount + closedCount; let skipHistory = false; try { const prev = await dbGet(db, `SELECT open_count, closed_count FROM ivanti_counts_history ORDER BY recorded_at DESC LIMIT 1` ); if (prev) { const prevTotal = (prev.open_count || 0) + (prev.closed_count || 0); if (prevTotal > 0 && newTotal < prevTotal * 0.5) { console.warn(`[Ivanti Findings] Drift guard triggered — new total ${newTotal} is <50% of previous ${prevTotal}. Skipping history write.`); skipHistory = true; } } } catch (err) { console.error('[Ivanti Findings] Drift guard check failed (non-fatal):', err.message); } // Append a snapshot to history — every sync is stored; the history // endpoint aggregates to last-per-day at query time (Option B). if (!skipHistory) { await dbRun(db, `INSERT INTO ivanti_counts_history (open_count, closed_count) VALUES (?, ?)`, [openCount, closedCount] ); } console.log(`[Ivanti Findings] Counts updated — open: ${openCount}, closed: ${closedCount}${skipHistory ? ' (history write skipped — drift guard)' : ''}`); // Detect closed findings in the archive — wrap in try/catch so errors don't break sync try { await detectClosedFindings(db, closedFindingIds); } catch (err) { console.error('[Ivanti Findings] Closed finding archive detection failed (non-fatal):', err.message); } // Detect findings that vanished from the closed set — CLOSED → CLOSED_GONE try { await detectClosedGoneFindings(db, closedFindingIds); } catch (err) { console.error('[Ivanti Findings] Closed-gone detection failed (non-fatal):', err.message); } } 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 id+state from a raw (un-extracted) finding // Returns { id, state } or null if no FP# workflow present. // --------------------------------------------------------------------------- function extractFPWorkflow(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 { id: fpEntry.generatedId || '', state: fpEntry.state || 'Unknown' }; } // --------------------------------------------------------------------------- // Sync FP stats across ALL findings (open + closed). // // Produces two separate counts: // findingCounts — number of *findings* per FP workflow state // idCounts — number of *unique FP# ticket IDs* per state // (one FP# can cover many findings; this chart counts tickets) // // Open findings come from the already-extracted allFindings array. // Closed findings are swept page-by-page to catch Approved FPs. // --------------------------------------------------------------------------- async function syncFPWorkflowCounts(db, openFindings, apiKey, clientId, skipTls) { const findingCounts = {}; // state → # findings const fpIdMap = {}; // FP# id → state (deduplicates across findings) // Seed from open findings (already extracted, have workflow.id + workflow.state) openFindings.forEach(f => { if (!f.workflow) return; const state = f.workflow.state || 'Unknown'; const id = f.workflow.id || ''; findingCounts[state] = (findingCounts[state] || 0) + 1; if (id && !fpIdMap[id]) fpIdMap[id] = state; }); // 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 wf = extractFPWorkflow(f); if (!wf) return; findingCounts[wf.state] = (findingCounts[wf.state] || 0) + 1; if (wf.id && !fpIdMap[wf.id]) fpIdMap[wf.id] = wf.state; }); 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 we have from open findings } // Aggregate unique FP# IDs by state const idCounts = {}; Object.values(fpIdMap).forEach(state => { idCounts[state] = (idCounts[state] || 0) + 1; }); await dbRun(db, `UPDATE ivanti_counts_cache SET fp_workflow_counts_json=?, fp_id_counts_json=? WHERE id=1`, [JSON.stringify(findingCounts), JSON.stringify(idCounts)] ).catch(e => console.error('[Ivanti Findings] Failed to store FP workflow counts:', e.message)); console.log('[Ivanti Findings] FP finding counts:', findingCounts); console.log('[Ivanti Findings] FP workflow ID counts:', idCounts); } // --------------------------------------------------------------------------- // 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); // Read previous findings BEFORE updating the cache (they'll be overwritten) let previousFindings = []; try { const state = await readState(db); previousFindings = state.findings || []; } catch (err) { console.error('[Ivanti Findings] Failed to read previous findings for archive detection:', err.message); } // Per-finding BU comparison — detect BU changes across syncs (Task 5.1) try { const previousMap = new Map(previousFindings.map(f => [String(f.id), f])); for (const finding of allFindings) { try { const prev = previousMap.get(String(finding.id)); if (prev && prev.buOwnership && finding.buOwnership && prev.buOwnership !== finding.buOwnership) { await dbRun(db, `INSERT INTO ivanti_finding_bu_history (finding_id, finding_title, host_name, previous_bu, new_bu, detected_at) VALUES (?, ?, ?, ?, ?, datetime('now'))`, [String(finding.id), finding.title || '', finding.hostName || '', prev.buOwnership, finding.buOwnership] ); console.log(`[BU Tracking] Finding ${finding.id} BU changed: ${prev.buOwnership} → ${finding.buOwnership}`); } // First-time findings (no prev entry) — store BU without recording a change event } catch (err) { console.error(`[BU Tracking] Error recording BU change for finding ${finding.id}:`, err.message); } } } catch (err) { console.error('[BU Tracking] BU comparison failed (non-fatal):', err.message); } 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)] ); // Invalidate in-memory cache so next read parses fresh data invalidateFindingsCache(); console.log(`[Ivanti Findings] Sync complete — ${allFindings.length} findings`); // Archive detection — compare previous vs current to detect disappeared/returned findings // Only runs after a successful sync (skipped on error per requirement 1.5) let archiveResult = { disappearedIds: [], returnedCount: 0, returnClassification: {} }; try { archiveResult = await detectArchiveChanges(db, previousFindings, allFindings) || { disappearedIds: [], returnedCount: 0, returnClassification: {} }; } catch (err) { console.error('[Ivanti Findings] Archive detection failed (non-fatal):', err.message); } // Read previous counts BEFORE syncClosedCount updates them — needed for anomaly deltas let previousOpenCount = 0; let previousClosedCount = 0; try { const prevCounts = await dbGet(db, `SELECT open_count, closed_count FROM ivanti_counts_cache WHERE id = 1` ); if (prevCounts) { previousOpenCount = prevCounts.open_count || 0; previousClosedCount = prevCounts.closed_count || 0; } } catch (err) { console.error('[Ivanti Findings] Failed to read previous counts for anomaly summary (non-fatal):', err.message); } await syncClosedCount(db, allFindings.length, apiKey, clientId, skipTls); await syncFPWorkflowCounts(db, allFindings, apiKey, clientId, skipTls); // Post-sync: BU drift checker for newly archived findings let classificationBreakdown = { bu_reassignment: 0, severity_drift: 0, closed_on_platform: 0, decommissioned: 0 }; try { classificationBreakdown = await runBUDriftChecker(db, archiveResult.disappearedIds, apiKey, clientId, skipTls); } catch (err) { console.error('[Ivanti Findings] BU drift checker failed (non-fatal):', err.message); } // Post-sync: Compute and store anomaly summary try { const currentCounts = await dbGet(db, `SELECT open_count, closed_count FROM ivanti_counts_cache WHERE id = 1` ); const currentOpenCount = currentCounts?.open_count || 0; const currentClosedCount = currentCounts?.closed_count || 0; const openCountDelta = currentOpenCount - previousOpenCount; const closedCountDelta = currentClosedCount - previousClosedCount; await computeAnomalySummary( db, openCountDelta, closedCountDelta, archiveResult.disappearedIds.length, archiveResult.returnedCount, classificationBreakdown, archiveResult.returnClassification || {} ); } catch (err) { console.error('[Ivanti Findings] Anomaly summary failed (non-fatal):', err.message); } } catch (err) { const msg = err.message || 'Unknown error'; console.error('[Ivanti Findings] Sync failed:', msg); // Archive detection is intentionally skipped on sync error (requirement 1.5) 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, function (err) { if (err) reject(err); else resolve(this); }); }); } function dbGet(db, sql, params = []) { return new Promise((resolve, reject) => { db.get(sql, params, (err, row) => { if (err) reject(err); else resolve(row); }); }); } function dbAll(db, sql, params = []) { return new Promise((resolve, reject) => { db.all(sql, params, (err, rows) => { if (err) reject(err); else resolve(rows || []); }); }); } // --------------------------------------------------------------------------- // In-memory findings cache — avoids re-parsing the large JSON blob on every request. // Invalidated on sync (when findings_json is updated). // --------------------------------------------------------------------------- let _findingsCache = null; // { findings, total, synced_at, sync_status, error_message } let _findingsCacheAt = null; // synced_at value when cache was built function invalidateFindingsCache() { _findingsCache = null; _findingsCacheAt = null; } 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 }); // Return cached if synced_at hasn't changed if (_findingsCache && _findingsCacheAt === row.synced_at) { return resolve({ ..._findingsCache }); } let findings = []; try { findings = JSON.parse(row.findings_json || '[]'); } catch (_) { /* leave empty */ } const state = { total: row.total || 0, findings, synced_at: row.synced_at, sync_status: row.sync_status, error_message: row.error_message }; // Store in cache _findingsCache = state; _findingsCacheAt = row.synced_at; resolve({ ...state }); } ); }); } 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; } // --------------------------------------------------------------------------- // BU Drift Checker — post-sync classification of newly archived findings // --------------------------------------------------------------------------- const EXPECTED_BUS = new Set(['NTS-AEO-ACCESS-ENG', 'NTS-AEO-STEAM']); async function runBUDriftChecker(db, newlyArchivedIds, apiKey, clientId, skipTls) { const summary = { bu_reassignment: 0, severity_drift: 0, closed_on_platform: 0, decommissioned: 0 }; if (!newlyArchivedIds || newlyArchivedIds.length === 0) return summary; const urlPath = `/client/${encodeURIComponent(clientId)}/hostFinding/search`; const chunkSize = 50; // Collect all API results across batches const foundMap = new Map(); for (let i = 0; i < newlyArchivedIds.length; i += chunkSize) { const chunk = newlyArchivedIds.slice(i, i + chunkSize); const idList = chunk.join(','); try { const filters = [ { field: 'id', exclusive: false, operator: 'IN', orWithPrevious: false, implicitFilters: [], value: idList, caseSensitive: false } ]; let page = 0; let totalPages = 1; do { const body = { filters, projection: 'internal', sort: [{ field: 'severity', direction: 'ASC' }], page, size: 100 }; const result = await ivantiPost(urlPath, body, apiKey, skipTls); if (result.status !== 200) { console.error(`[BU Drift Checker] API returned status ${result.status} for batch starting at index ${i}`); break; } const data = JSON.parse(result.body); totalPages = data.page?.totalPages || 1; const findings = data._embedded?.hostFindings || []; for (const f of findings) { const bu = f.assetCustomAttributes?.['1550_host_1']?.[0] || 'UNKNOWN'; const severity = typeof f.severity === 'number' ? f.severity : parseFloat(f.severity) || 0; const state = f.status || f.generic_state || ''; foundMap.set(String(f.id), { bu, severity, state }); } page++; } while (page < totalPages); console.log(`[BU Drift Checker] Batch ${Math.floor(i / chunkSize) + 1}: queried ${chunk.length} IDs, found ${foundMap.size} so far`); } catch (err) { console.error(`[BU Drift Checker] Error querying batch at index ${i}:`, err.message); // Skip failed batch, continue with remaining } } // Classify each archived finding and update the archive transition reason for (const id of newlyArchivedIds) { const found = foundMap.get(id); let classification; let reason; if (!found) { classification = 'decommissioned'; reason = 'decommissioned'; } else if (!EXPECTED_BUS.has(found.bu)) { classification = 'bu_reassignment'; reason = `bu_reassignment:${found.bu}`; } else if (found.severity < 8.5) { classification = 'severity_drift'; reason = `severity_drift:${found.severity}`; } else if (found.state === 'Closed') { classification = 'closed_on_platform'; reason = 'closed_on_platform'; } else { // BU matches, severity >= 8.5, not closed — unexpected, leave as default classification = 'decommissioned'; reason = 'decommissioned'; } summary[classification] = (summary[classification] || 0) + 1; // Update the most recent archive transition reason for this finding try { const archive = await dbGet(db, `SELECT id FROM ivanti_finding_archives WHERE finding_id = ?`, [id] ); if (archive) { await dbRun(db, `UPDATE ivanti_archive_transitions SET reason = ? WHERE archive_id = ? AND id = ( SELECT id FROM ivanti_archive_transitions WHERE archive_id = ? ORDER BY transitioned_at DESC LIMIT 1 )`, [reason, archive.id, archive.id] ); } } catch (err) { console.error(`[BU Drift Checker] Error updating transition reason for finding ${id}:`, err.message); } } console.log(`[BU Drift Checker] Classification complete:`, summary); return summary; } // --------------------------------------------------------------------------- // Anomaly Summary — compute and store post-sync anomaly report // --------------------------------------------------------------------------- async function computeAnomalySummary(db, openCountDelta, closedCountDelta, newlyArchivedCount, returnedCount, classificationBreakdown, returnClassificationBreakdown) { try { const isSignificant = newlyArchivedCount > 5 ? 1 : 0; const classificationJson = JSON.stringify(classificationBreakdown || {}); const returnClassificationJson = JSON.stringify(returnClassificationBreakdown || {}); await dbRun(db, `INSERT INTO ivanti_sync_anomaly_log (sync_timestamp, open_count_delta, closed_count_delta, newly_archived_count, returned_count, classification_json, return_classification_json, is_significant) VALUES (datetime('now'), ?, ?, ?, ?, ?, ?, ?)`, [openCountDelta, closedCountDelta, newlyArchivedCount, returnedCount, classificationJson, returnClassificationJson, isSignificant] ); console.log(`[Anomaly Summary] Sync anomaly logged — open_delta: ${openCountDelta}, closed_delta: ${closedCountDelta}, archived: ${newlyArchivedCount}, returned: ${returnedCount}, significant: ${!!isSignificant}`); console.log(`[Anomaly Summary] Classification:`, classificationBreakdown); if (returnedCount > 0) { console.log(`[Anomaly Summary] Return classification:`, returnClassificationBreakdown); } } catch (err) { console.error('[Anomaly Summary] Failed to write anomaly summary (non-fatal):', err.message); } } // --------------------------------------------------------------------------- // Router // --------------------------------------------------------------------------- function createIvantiFindingsRouter(db, requireAuth) { const router = express.Router(); Promise.all([initTables(db), initArchiveTables(db)]) .then(() => scheduleSync(db)) .catch((err) => console.error('[Ivanti Findings] Init failed:', err)); router.use(requireAuth(db)); /** * GET /api/ivanti/findings * * Return cached Ivanti findings with notes and overrides merged in. * Accepts optional `teams` query parameter (comma-separated) to filter * findings by buOwnership. If omitted, returns all findings. * * @query {string} [teams] - Comma-separated team names (e.g. 'STEAM,ACCESS-ENG') * @returns {Object} 200 - { findings: Array, lastSync: string|null, overrides: Object, total: number } * @returns {Object} 500 - { error: string } on database error */ router.get('/', async (req, res) => { try { const state = await readStateWithNotes(db); // Filter by teams if provided const teamsParam = req.query.teams; if (teamsParam) { const teams = teamsParam.split(',').map(t => t.trim().toUpperCase()).filter(Boolean); if (teams.length > 0) { state.findings = state.findings.filter(f => teams.some(t => (f.buOwnership || '').toUpperCase().includes(t)) ); state.total = state.findings.length; } } res.json(state); } catch { res.status(500).json({ error: 'Database error reading findings' }); } }); /** * POST /api/ivanti/findings/sync * * Trigger an immediate Ivanti findings sync and return the fresh state. * Requires Admin or Standard_User group. * * @returns {Object} 200 - { findings: Array, lastSync: string|null, overrides: Object } * @returns {Object} 500 - { error: string } if sync ran but state could not be read */ router.post('/sync', requireGroup('Admin', 'Standard_User'), 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 /api/ivanti/findings/counts * * Return open vs closed finding totals for the pie chart. * Accepts optional `teams` query parameter to scope the open count * to specific BUs. Closed count remains global (approximate) when filtered. * * @query {string} [teams] - Comma-separated team names (e.g. 'STEAM,ACCESS-ENG') * @returns {Object} 200 - { open: number, closed: number, filtered: boolean } * @returns {Object} 500 - { error: string } on database error */ router.get('/counts', async (req, res) => { try { const teamsParam = req.query.teams; if (teamsParam) { const teams = teamsParam.split(',').map(t => t.trim().toUpperCase()).filter(Boolean); if (teams.length > 0) { // For open count, filter the cached findings by team const state = await readState(db); const filtered = state.findings.filter(f => teams.some(t => (f.buOwnership || '').toUpperCase().includes(t)) ); // Closed count is global — we don't store per-finding closed data const counts = await readCounts(db); return res.json({ open: filtered.length, closed: counts.closed, filtered: true }); } } res.json({ ...(await readCounts(db)), filtered: false }); } catch { res.status(500).json({ error: 'Database error reading counts' }); } }); /** * GET /api/ivanti/findings/counts/history * * Return the last snapshot per day (ascending) for the trend chart. * Uses a ROW_NUMBER window function to pick the final sync of each calendar day. * * @returns {Object} 200 - { history: Array<{ date: string, open_count: number, closed_count: number }> } * @returns {Object} 500 - { error: string } on database error */ router.get('/counts/history', async (req, res) => { try { const rows = await new Promise((resolve, reject) => { db.all( `SELECT date, open_count, closed_count FROM ( SELECT DATE(recorded_at) AS date, open_count, closed_count, ROW_NUMBER() OVER ( PARTITION BY DATE(recorded_at) ORDER BY recorded_at DESC ) AS rn FROM ivanti_counts_history ) WHERE rn = 1 ORDER BY date ASC`, [], (err, rows) => { if (err) reject(err); else resolve(rows || []); } ); }); res.json({ history: rows }); } catch (err) { console.error('[Ivanti Findings] GET /counts/history error:', err.message); res.status(500).json({ error: 'Database error' }); } }); /** * GET /api/ivanti/findings/fp-workflow-counts * * Return FP finding counts and unique workflow ID counts (open + closed), * broken down by workflow status. * * @returns {Object} 200 - { findingCounts: Object, findingTotal: number, idCounts: Object, idTotal: number } * @returns {Object} 500 - { error: string } on database error */ router.get('/fp-workflow-counts', async (req, res) => { try { const row = await new Promise((resolve, reject) => { db.get('SELECT fp_workflow_counts_json, fp_id_counts_json FROM ivanti_counts_cache WHERE id=1', (err, row) => { if (err) reject(err); else resolve(row); } ); }); let findingCounts = {}; let idCounts = {}; try { findingCounts = JSON.parse(row?.fp_workflow_counts_json || '{}'); } catch (_) {} try { idCounts = JSON.parse(row?.fp_id_counts_json || '{}'); } catch (_) {} res.json({ findingCounts, findingTotal: Object.values(findingCounts).reduce((a, b) => a + b, 0), idCounts, idTotal: Object.values(idCounts).reduce((a, b) => a + b, 0), }); } catch { res.status(500).json({ error: 'Database error reading FP workflow counts' }); } }); /** * GET /api/ivanti/findings/anomaly/latest * * Return the most recent anomaly summary row from ivanti_sync_anomaly_log. * The classification_json column is parsed into an object in the response. * * @returns {Object} 200 - { anomaly: Object|null } * @returns {Object} 500 - { error: string } on database error */ router.get('/anomaly/latest', async (req, res) => { try { const row = await dbGet(db, `SELECT id, sync_timestamp, open_count_delta, closed_count_delta, newly_archived_count, returned_count, classification_json, return_classification_json, is_significant FROM ivanti_sync_anomaly_log ORDER BY sync_timestamp DESC LIMIT 1` ); if (!row) return res.json({ anomaly: null }); let classification = {}; try { classification = JSON.parse(row.classification_json || '{}'); } catch (_) {} let return_classification = {}; try { return_classification = JSON.parse(row.return_classification_json || '{}'); } catch (_) {} res.json({ anomaly: { id: row.id, sync_timestamp: row.sync_timestamp, open_count_delta: row.open_count_delta, closed_count_delta: row.closed_count_delta, newly_archived_count: row.newly_archived_count, returned_count: row.returned_count, classification, return_classification, is_significant: !!row.is_significant } }); } catch (err) { console.error('[Ivanti Findings] GET /anomaly/latest error:', err.message); res.status(500).json({ error: 'Database error reading latest anomaly' }); } }); /** * GET /api/ivanti/findings/anomaly/history * * Return anomaly history. Accepts optional `from` and `to` query parameters * (ISO date strings) for date-range filtering (inclusive). If neither is * provided, returns the last 30 rows ordered by sync_timestamp descending. * * @query {string} [from] - Inclusive start date (ISO string) * @query {string} [to] - Inclusive end date (ISO string) * * @returns {Object} 200 - { history: Array } * @returns {Object} 500 - { error: string } on database error */ router.get('/anomaly/history', async (req, res) => { try { const { from, to } = req.query; let rows; if (from && to) { rows = await dbAll(db, `SELECT sync_timestamp, open_count_delta, closed_count_delta, newly_archived_count, returned_count, classification_json, return_classification_json, is_significant FROM ivanti_sync_anomaly_log WHERE sync_timestamp >= ? AND sync_timestamp <= ? ORDER BY sync_timestamp DESC`, [from, to] ); } else { rows = await dbAll(db, `SELECT sync_timestamp, open_count_delta, closed_count_delta, newly_archived_count, returned_count, classification_json, return_classification_json, is_significant FROM ivanti_sync_anomaly_log ORDER BY sync_timestamp DESC LIMIT 30` ); } const history = rows.map(row => { let classification = {}; try { classification = JSON.parse(row.classification_json || '{}'); } catch (_) {} let return_classification = {}; try { return_classification = JSON.parse(row.return_classification_json || '{}'); } catch (_) {} return { sync_timestamp: row.sync_timestamp, open_count_delta: row.open_count_delta, closed_count_delta: row.closed_count_delta, newly_archived_count: row.newly_archived_count, returned_count: row.returned_count, classification, return_classification, is_significant: !!row.is_significant }; }); res.json({ history }); } catch (err) { console.error('[Ivanti Findings] GET /anomaly/history error:', err.message); res.status(500).json({ error: 'Database error reading anomaly history' }); } }); /** * GET /api/ivanti/findings/bu-changes * * Return all BU change events from ivanti_finding_bu_history, * ordered by detected_at descending (newest first). * * @returns {Object} 200 - { changes: Array } * @returns {Object} 500 - { error: string } on database error */ router.get('/bu-changes', async (req, res) => { try { const rows = await dbAll(db, `SELECT id, finding_id, finding_title, host_name, previous_bu, new_bu, detected_at FROM ivanti_finding_bu_history ORDER BY detected_at DESC` ); res.json({ changes: rows }); } catch (err) { console.error('[Ivanti Findings] GET /bu-changes error:', err.message); res.status(500).json({ error: 'Database error reading BU changes' }); } }); /** * GET /api/ivanti/findings/:findingId/bu-history * * Return BU change history for a specific finding from ivanti_finding_bu_history, * ordered by detected_at descending (newest first). * * @param {string} findingId - The finding identifier (URL param) * * @returns {Object} 200 - { finding_id: string, history: Array } * @returns {Object} 500 - { error: string } on database error */ router.get('/:findingId/bu-history', async (req, res) => { try { const { findingId } = req.params; const rows = await dbAll(db, `SELECT previous_bu, new_bu, detected_at FROM ivanti_finding_bu_history WHERE finding_id = ? ORDER BY detected_at DESC`, [findingId] ); res.json({ finding_id: findingId, history: rows }); } catch (err) { console.error('[Ivanti Findings] GET /:findingId/bu-history error:', err.message); res.status(500).json({ error: 'Database error reading finding BU history' }); } }); /** * PUT /api/ivanti/findings/:findingId/override * * Save or clear a field override for a finding. Requires Admin or Standard_User group. * Sending an empty value clears the override (reverts to Ivanti-sourced data). * * @param {string} findingId - The finding identifier (URL param) * @body {string} field - The field to override; must be one of 'hostName', 'dns' * @body {string} [value] - The override value; empty or omitted to clear * * @returns {Object} 200 - { finding_id: string, field: string, value: string|null } * @returns {Object} 400 - { error: string } when field is not in the allowed list * @returns {Object} 500 - { error: string } on database error */ const OVERRIDE_ALLOWED = ['hostName', 'dns']; router.put('/:findingId/override', requireGroup('Admin', 'Standard_User'), (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 /api/ivanti/findings/:findingId/note * * Save or update a note for a finding (max 255 characters). * Requires Admin or Standard_User group. * * @param {string} findingId - The finding identifier (URL param) * @body {string} [note] - The note text (truncated to 255 chars) * * @returns {Object} 200 - { finding_id: string, note: string } * @returns {Object} 500 - { error: string } on database error */ router.put('/:findingId/note', requireGroup('Admin', 'Standard_User'), (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; module.exports.detectArchiveChanges = detectArchiveChanges; module.exports.detectClosedFindings = detectClosedFindings; module.exports.initArchiveTables = initArchiveTables; module.exports.runBUDriftChecker = runBUDriftChecker; module.exports.computeAnomalySummary = computeAnomalySummary; module.exports.extractFinding = extractFinding;