// Ivanti / RiskSense Host Findings Routes // Stores individual finding rows in PostgreSQL `ivanti_findings` table. // Notes and overrides are columns on the same table (no separate tables needed). // Daily auto-sync fetches from Ivanti API and upserts rows. const express = require('express'); const { requireGroup } = require('../middleware/auth'); const { ivantiPost } = require('../helpers/ivantiApi'); const pool = require('../db'); const SYNC_INTERVAL_MS = 24 * 60 * 60 * 1000; // PostgreSQL DATE columns return JS Date objects — normalize to 'YYYY-MM-DD' strings function formatDate(val) { if (!val) return null; if (val instanceof Date) { const y = val.getFullYear(); const m = String(val.getMonth() + 1).padStart(2, '0'); const d = String(val.getDate()).padStart(2, '0'); return `${y}-${m}-${d}`; } // Already a string — strip any time portion (e.g. "2025-05-22T00:00:00.000Z") return String(val).slice(0, 10); } // 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 } ]; // --------------------------------------------------------------------------- // Extract Qualys IPv6 address from hostAdditionalDetails // Looks for "IPv6 Address" (string) or "IPv6 Addresses" (array) fields // in the scanner-specific details from Qualys. // --------------------------------------------------------------------------- function extractQualysIpv6(f) { const details = f.hostAdditionalDetails || []; for (const entry of details) { if (entry['IPv6 Address']) return entry['IPv6 Address']; if (Array.isArray(entry['IPv6 Addresses']) && entry['IPv6 Addresses'].length > 0) { return entry['IPv6 Addresses'][0]; } } return ''; } // --------------------------------------------------------------------------- // 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, // IPv6 fallbacks for findings with no IPv4 qualysIpv6: extractQualysIpv6(f), primaryIpv6: f.assetCustomAttributes?.['1550_host_6']?.[0] || '', }; } // --------------------------------------------------------------------------- // 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' }; } // --------------------------------------------------------------------------- // Batch upsert findings into ivanti_findings table // Preserves note and override_* columns (user data) during upsert. // --------------------------------------------------------------------------- async function upsertFindingsBatch(findings, state) { if (findings.length === 0) return; const BATCH_SIZE = 100; for (let i = 0; i < findings.length; i += BATCH_SIZE) { const batch = findings.slice(i, i + BATCH_SIZE); const values = []; const placeholders = []; batch.forEach((f, idx) => { const offset = idx * 20; values.push( f.id, f.hostId, f.title || '', f.severity || 0, f.vrrGroup || '', f.hostName || '', f.ipAddress || '', f.dns || '', f.status || '', f.slaStatus || '', f.dueDate || null, f.lastFoundOn || null, f.buOwnership || '', f.cves || [], f.workflow ? f.workflow.id : null, f.workflow ? f.workflow.state : null, f.workflow ? f.workflow.type : null, state, f.qualysIpv6 || null, f.primaryIpv6 || null ); placeholders.push( `($${offset+1}, $${offset+2}, $${offset+3}, $${offset+4}, $${offset+5}, ` + `$${offset+6}, $${offset+7}, $${offset+8}, $${offset+9}, $${offset+10}, ` + `$${offset+11}, $${offset+12}, $${offset+13}, $${offset+14}, $${offset+15}, ` + `$${offset+16}, $${offset+17}, $${offset+18}, $${offset+19}, $${offset+20})` ); }); await pool.query(` INSERT INTO ivanti_findings ( id, host_id, title, severity, vrr_group, host_name, ip_address, dns, status, sla_status, due_date, last_found_on, bu_ownership, cves, workflow_id, workflow_state, workflow_type, state, qualys_ipv6, primary_ipv6 ) VALUES ${placeholders.join(', ')} ON CONFLICT (id) DO UPDATE SET host_id = EXCLUDED.host_id, title = EXCLUDED.title, severity = EXCLUDED.severity, vrr_group = EXCLUDED.vrr_group, host_name = EXCLUDED.host_name, ip_address = EXCLUDED.ip_address, dns = EXCLUDED.dns, status = EXCLUDED.status, sla_status = EXCLUDED.sla_status, due_date = EXCLUDED.due_date, last_found_on = EXCLUDED.last_found_on, bu_ownership = EXCLUDED.bu_ownership, cves = EXCLUDED.cves, workflow_id = EXCLUDED.workflow_id, workflow_state = EXCLUDED.workflow_state, workflow_type = EXCLUDED.workflow_type, state = EXCLUDED.state, qualys_ipv6 = EXCLUDED.qualys_ipv6, primary_ipv6 = EXCLUDED.primary_ipv6, synced_at = NOW() `, values); } } // --------------------------------------------------------------------------- // Archive detection — compare previous vs current findings to detect state changes // --------------------------------------------------------------------------- async function detectArchiveChanges(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 || finding.host_name || ''; const ipAddress = finding.ipAddress || finding.ip_address || ''; const severity = typeof finding.severity === 'number' ? finding.severity : 0; try { const { rows } = await pool.query( `SELECT id, current_state FROM ivanti_finding_archives WHERE finding_id = $1`, [id] ); const existing = rows[0]; if (existing && existing.current_state === 'RETURNED') { // Re-disappeared: RETURNED → ARCHIVED await pool.query( `UPDATE ivanti_finding_archives SET current_state = 'ARCHIVED', last_severity = $1, last_transition_at = NOW() WHERE id = $2`, [severity, existing.id] ); await pool.query( `INSERT INTO ivanti_archive_transitions (archive_id, from_state, to_state, severity_at_transition, reason, transitioned_at) VALUES ($1, 'RETURNED', 'ARCHIVED', $2, 'severity_score_drift', NOW())`, [existing.id, severity] ); console.log(`[Archive Detection] Finding ${id} re-archived (RETURNED → ARCHIVED)`); } else if (!existing) { // First disappearance: NONE → ARCHIVED const { rows: insertRows } = await pool.query( `INSERT INTO ivanti_finding_archives (finding_id, finding_title, host_name, ip_address, current_state, last_severity, first_archived_at, last_transition_at) VALUES ($1, $2, $3, $4, 'ARCHIVED', $5, NOW(), NOW()) RETURNING id`, [id, title, hostName, ipAddress, severity] ); const archiveId = insertRows[0].id; await pool.query( `INSERT INTO ivanti_archive_transitions (archive_id, from_state, to_state, severity_at_transition, reason, transitioned_at) VALUES ($1, 'NONE', 'ARCHIVED', $2, 'severity_score_drift', 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 returnedArchiveIds = []; try { const { rows: archivedRecords } = await pool.query( `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 pool.query( `UPDATE ivanti_finding_archives SET current_state = 'RETURNED', last_severity = $1, last_transition_at = NOW() WHERE id = $2`, [severity, record.id] ); await pool.query( `INSERT INTO ivanti_archive_transitions (archive_id, from_state, to_state, severity_at_transition, reason, transitioned_at) VALUES ($1, 'ARCHIVED', 'RETURNED', $2, 'reappeared_in_sync', 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 const returnClassification = { bu_reassignment: 0, severity_drift: 0, closed_on_platform: 0, decommissioned: 0 }; for (const archiveId of returnedArchiveIds) { try { const { rows } = await pool.query( `SELECT reason FROM ivanti_archive_transitions WHERE archive_id = $1 AND to_state = 'ARCHIVED' AND transitioned_at <= NOW() ORDER BY transitioned_at DESC LIMIT 1`, [archiveId] ); const transition = rows[0]; if (transition && transition.reason) { 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`); 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(closedFindingIds) { if (!closedFindingIds || closedFindingIds.length === 0) return; const closedSet = new Set(closedFindingIds.map(String)); try { const { rows: records } = await pool.query( `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 pool.query( `UPDATE ivanti_finding_archives SET current_state = 'CLOSED', last_transition_at = NOW() WHERE id = $1`, [record.id] ); await pool.query( `INSERT INTO ivanti_archive_transitions (archive_id, from_state, to_state, severity_at_transition, reason, transitioned_at) VALUES ($1, $2, 'CLOSED', $3, 'remediated_in_ivanti', 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. // --------------------------------------------------------------------------- async function detectClosedGoneFindings(closedFindingIds) { if (!closedFindingIds) return; const closedSet = new Set(closedFindingIds.map(String)); try { const { rows: records } = await pool.query( `SELECT id, finding_id, last_severity FROM ivanti_finding_archives WHERE current_state = 'CLOSED'` ); let goneCount = 0; for (const record of records) { if (closedSet.has(record.finding_id)) continue; try { await pool.query( `UPDATE ivanti_finding_archives SET current_state = 'CLOSED_GONE', last_transition_at = NOW() WHERE id = $1`, [record.id] ); await pool.query( `INSERT INTO ivanti_archive_transitions (archive_id, from_state, to_state, severity_at_transition, reason, transitioned_at) VALUES ($1, 'CLOSED', 'CLOSED_GONE', $2, 'disappeared_from_closed_set', 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); } } // --------------------------------------------------------------------------- // Fetch closed findings from Ivanti and upsert + update counts // --------------------------------------------------------------------------- async function syncClosedCount(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); const closedCount = data.page?.totalElements ?? data.page?.total ?? 0; const totalPages = data.page?.totalPages || 1; // Collect closed findings for upsert and archive detection const closedFindings = []; const closedFindingIds = []; const firstPageFindings = data._embedded?.hostFindings || []; firstPageFindings.forEach(f => { if (f.id) closedFindingIds.push(String(f.id)); closedFindings.push(extractFinding(f)); }); // Fetch remaining pages to collect all closed findings 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)); closedFindings.push(extractFinding(f)); }); } catch (err) { console.error(`[Ivanti Findings] Failed to fetch closed findings page ${pg}:`, err.message); break; } } // Upsert closed findings as individual rows with state='closed' await upsertFindingsBatch(closedFindings, 'closed'); // Update counts cache await pool.query( `UPDATE ivanti_counts_cache SET open_count=$1, closed_count=$2, synced_at=NOW() WHERE id=1`, [openCount, closedCount] ); // Drift guard — if the new total drops by more than 50% compared to the // most recent history snapshot, skip writing to history. const newTotal = openCount + closedCount; let skipHistory = false; try { const { rows } = await pool.query( `SELECT open_count, closed_count FROM ivanti_counts_history ORDER BY recorded_at DESC LIMIT 1` ); const prev = rows[0]; 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); } if (!skipHistory) { await pool.query( `INSERT INTO ivanti_counts_history (open_count, closed_count) VALUES ($1, $2)`, [openCount, closedCount] ); // Per-BU history snapshot — enables scoped trend lines try { await pool.query(` INSERT INTO ivanti_counts_history_by_bu (bu_ownership, state, count) SELECT bu_ownership, state, COUNT(*) FROM ivanti_findings WHERE bu_ownership != '' GROUP BY bu_ownership, state `); } catch (err) { console.error('[Ivanti Findings] Per-BU history snapshot failed (non-fatal):', err.message); } } console.log(`[Ivanti Findings] Counts updated — open: ${openCount}, closed: ${closedCount}${skipHistory ? ' (history write skipped — drift guard)' : ''}`); // Detect closed findings in the archive try { await detectClosedFindings(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(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 pool.query( `UPDATE ivanti_counts_cache SET open_count=$1, synced_at=NOW() WHERE id=1`, [openCount] ).catch(() => {}); } } // --------------------------------------------------------------------------- // Sync FP stats across ALL findings (open + closed). // --------------------------------------------------------------------------- async function syncFPWorkflowCounts(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); } // Aggregate unique FP# IDs by state const idCounts = {}; Object.values(fpIdMap).forEach(state => { idCounts[state] = (idCounts[state] || 0) + 1; }); await pool.query( `UPDATE ivanti_counts_cache SET fp_workflow_counts_json=$1, fp_id_counts_json=$2 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); } // --------------------------------------------------------------------------- // BU Drift Checker — post-sync classification of newly archived findings // --------------------------------------------------------------------------- // Managed BUs for drift classification — derived from IVANTI_MANAGED_BUS env var. // Findings leaving these BUs are classified as bu_reassignment. // Each tenant deployment sets this to their own managed teams. const MANAGED_BUS_VALUE = process.env.IVANTI_MANAGED_BUS || 'NTS-AEO-ACCESS-ENG,NTS-AEO-STEAM'; const EXPECTED_BUS = new Set(MANAGED_BUS_VALUE.split(',').map(b => b.trim()).filter(Boolean)); async function runBUDriftChecker(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; 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); } } // 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 { classification = 'decommissioned'; reason = 'decommissioned'; } summary[classification] = (summary[classification] || 0) + 1; // Update the most recent archive transition reason for this finding try { const { rows } = await pool.query( `SELECT id FROM ivanti_finding_archives WHERE finding_id = $1`, [id] ); const archive = rows[0]; if (archive) { await pool.query( `UPDATE ivanti_archive_transitions SET reason = $1 WHERE archive_id = $2 AND id = ( SELECT id FROM ivanti_archive_transitions WHERE archive_id = $3 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(openCountDelta, closedCountDelta, newlyArchivedCount, returnedCount, classificationBreakdown, returnClassificationBreakdown) { try { const isSignificant = newlyArchivedCount > 5; const classificationJson = JSON.stringify(classificationBreakdown || {}); const returnClassificationJson = JSON.stringify(returnClassificationBreakdown || {}); await pool.query( `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 (NOW(), $1, $2, $3, $4, $5, $6, $7)`, [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); } } // --------------------------------------------------------------------------- // Core sync — fetches ALL pages, upserts individual rows into ivanti_findings // --------------------------------------------------------------------------- async function syncFindings() { 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 pool.query( `UPDATE ivanti_sync_state SET sync_status='error', error_message=$1, synced_at=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 open findings from DB for archive detection let previousFindings = []; try { const { rows } = await pool.query( `SELECT id, title, host_name AS "hostName", ip_address AS "ipAddress", severity, bu_ownership AS "buOwnership" FROM ivanti_findings WHERE state = 'open'` ); previousFindings = rows; } 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 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 pool.query( `INSERT INTO ivanti_finding_bu_history (finding_id, finding_title, host_name, previous_bu, new_bu, detected_at) VALUES ($1, $2, $3, $4, $5, 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}`); } } 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); } // Upsert all open findings as individual rows await upsertFindingsBatch(allFindings, 'open'); // Mark findings that disappeared from the open set: // Any finding that was 'open' in DB but NOT in the current sync set // should NOT be automatically closed here — archive detection handles that. // However, we track the current sync IDs for reference. const currentIds = allFindings.map(f => f.id); // Update sync metadata await pool.query( `UPDATE ivanti_sync_state SET total=$1, synced_at=NOW(), sync_status='success', error_message=NULL WHERE id=1`, [allFindings.length] ); console.log(`[Ivanti Findings] Sync complete — ${allFindings.length} findings`); // Archive detection — compare previous vs current to detect disappeared/returned findings let archiveResult = { disappearedIds: [], returnedCount: 0, returnClassification: {} }; try { archiveResult = await detectArchiveChanges(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 { rows } = await pool.query( `SELECT open_count, closed_count FROM ivanti_counts_cache WHERE id = 1` ); if (rows[0]) { previousOpenCount = rows[0].open_count || 0; previousClosedCount = rows[0].closed_count || 0; } } catch (err) { console.error('[Ivanti Findings] Failed to read previous counts for anomaly summary (non-fatal):', err.message); } await syncClosedCount(allFindings.length, apiKey, clientId, skipTls); await syncFPWorkflowCounts(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(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 { rows } = await pool.query( `SELECT open_count, closed_count FROM ivanti_counts_cache WHERE id = 1` ); const currentOpenCount = rows[0]?.open_count || 0; const currentClosedCount = rows[0]?.closed_count || 0; const openCountDelta = currentOpenCount - previousOpenCount; const closedCountDelta = currentClosedCount - previousClosedCount; await computeAnomalySummary( 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); await pool.query( `UPDATE ivanti_sync_state SET sync_status='error', error_message=$1, synced_at=NOW() WHERE id=1`, [msg] ); } } // --------------------------------------------------------------------------- // Scheduler // --------------------------------------------------------------------------- async function scheduleSync() { try { const { rows } = await pool.query('SELECT synced_at FROM ivanti_sync_state WHERE id = 1'); const row = rows[0]; if (!row || !row.synced_at) { syncFindings(); } else { const lastSync = new Date(row.synced_at); const hoursSince = (Date.now() - lastSync.getTime()) / (1000 * 60 * 60); if (hoursSince >= 24) { syncFindings(); } else { console.log(`[Ivanti Findings] Last sync ${hoursSince.toFixed(1)}h ago — next auto-sync in ${(24 - hoursSince).toFixed(1)}h`); } } } catch (err) { console.error('[Ivanti Findings] Schedule sync check failed, triggering sync:', err.message); syncFindings(); } setInterval(() => syncFindings(), SYNC_INTERVAL_MS); } // --------------------------------------------------------------------------- // Router // --------------------------------------------------------------------------- function createIvantiFindingsRouter(db, requireAuth) { const router = express.Router(); // Initialize sync schedule (no table init needed — schema handled by db-schema.sql) scheduleSync(); router.use(requireAuth()); /** * GET /api/ivanti/findings * * Return findings from ivanti_findings table (state='open') with notes and overrides. * Accepts optional `teams` query parameter (comma-separated) to filter * findings by buOwnership. If omitted, returns all open findings. * * @query {string} [teams] - Comma-separated team names (e.g. 'STEAM,ACCESS-ENG') * @returns {Object} 200 - { findings, total, synced_at, sync_status, error_message } * @returns {Object} 500 - { error: string } on database error */ router.get('/', async (req, res) => { try { const teamsParam = req.query.teams; let query = `SELECT * FROM ivanti_findings WHERE state = 'open'`; const params = []; let paramIndex = 1; if (teamsParam) { const teams = teamsParam.split(',').map(t => t.trim()).filter(Boolean); if (teams.length > 0) { const patterns = teams.map(t => `%${t}%`); query += ` AND bu_ownership ILIKE ANY($${paramIndex++}::text[])`; params.push(patterns); } } query += ' ORDER BY severity DESC'; const { rows } = await pool.query(query, params); // Transform rows to match existing API response shape const findings = rows.map(row => ({ id: row.id, hostId: row.host_id, title: row.title, severity: parseFloat(row.severity), vrrGroup: row.vrr_group, hostName: row.host_name, ipAddress: row.ip_address, dns: row.dns, status: row.status, slaStatus: row.sla_status, dueDate: formatDate(row.due_date), lastFoundOn: formatDate(row.last_found_on), buOwnership: row.bu_ownership, cves: row.cves || [], workflow: row.workflow_id ? { id: row.workflow_id, state: row.workflow_state, type: row.workflow_type } : null, note: row.note || '', qualysIpv6: row.qualys_ipv6 || null, primaryIpv6: row.primary_ipv6 || null, overrides: { ...(row.override_host_name ? { hostName: row.override_host_name } : {}), ...(row.override_dns ? { dns: row.override_dns } : {}) } })); // Get sync metadata const metaResult = await pool.query('SELECT synced_at, sync_status, error_message FROM ivanti_sync_state WHERE id = 1'); const meta = metaResult.rows[0] || {}; res.json({ findings, total: findings.length, synced_at: meta.synced_at || null, sync_status: meta.sync_status || 'never', error_message: meta.error_message || null }); } catch (err) { console.error('[Ivanti Findings] GET / error:', err.message); 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, total, synced_at, sync_status, error_message } * @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(); try { // Return fresh state after sync const { rows } = await pool.query( `SELECT * FROM ivanti_findings WHERE state = 'open' ORDER BY severity DESC` ); const findings = rows.map(row => ({ id: row.id, hostId: row.host_id, title: row.title, severity: parseFloat(row.severity), vrrGroup: row.vrr_group, hostName: row.host_name, ipAddress: row.ip_address, dns: row.dns, status: row.status, slaStatus: row.sla_status, dueDate: formatDate(row.due_date), lastFoundOn: formatDate(row.last_found_on), buOwnership: row.bu_ownership, cves: row.cves || [], workflow: row.workflow_id ? { id: row.workflow_id, state: row.workflow_state, type: row.workflow_type } : null, note: row.note || '', qualysIpv6: row.qualys_ipv6 || null, primaryIpv6: row.primary_ipv6 || null, overrides: { ...(row.override_host_name ? { hostName: row.override_host_name } : {}), ...(row.override_dns ? { dns: row.override_dns } : {}) } })); const metaResult = await pool.query('SELECT synced_at, sync_status, error_message FROM ivanti_sync_state WHERE id = 1'); const meta = metaResult.rows[0] || {}; res.json({ findings, total: findings.length, synced_at: meta.synced_at || null, sync_status: meta.sync_status || 'never', error_message: meta.error_message || null }); } catch (err) { console.error('[Ivanti Findings] POST /sync read error:', err.message); res.status(500).json({ error: 'Sync ran but could not read updated state' }); } }); /** * GET /api/ivanti/findings/counts * * Return open vs closed finding totals. * Accepts optional `teams` query parameter to scope counts to specific BUs. * With Postgres, both open AND closed counts are per-BU 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; let whereExtra = ''; const params = []; let paramIndex = 1; if (teamsParam) { const teams = teamsParam.split(',').map(t => t.trim()).filter(Boolean); if (teams.length > 0) { const patterns = teams.map(t => `%${t}%`); whereExtra = ` AND bu_ownership ILIKE ANY($${paramIndex++}::text[])`; params.push(patterns); } } const { rows } = await pool.query( `SELECT state, COUNT(*) as count FROM ivanti_findings WHERE 1=1 ${whereExtra} GROUP BY state`, params ); const counts = { open: 0, closed: 0 }; rows.forEach(r => { counts[r.state] = parseInt(r.count); }); res.json({ ...counts, filtered: !!teamsParam }); } catch (err) { console.error('[Ivanti Findings] GET /counts error:', err.message); 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. * Accepts optional `teams` query parameter to scope the trend to specific BUs. * When teams is provided, uses the per-BU history table. * When no teams, returns the global aggregate history. * * @query {string} [teams] - Comma-separated team names (e.g. 'STEAM,ACCESS-ENG') * @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 teamsParam = req.query.teams; if (teamsParam) { // Per-BU history — filter and aggregate by selected teams const teams = teamsParam.split(',').map(t => t.trim()).filter(Boolean); if (teams.length > 0) { const patterns = teams.map(t => `%${t}%`); const { rows } = await pool.query( `SELECT date, SUM(CASE WHEN state = 'open' THEN count ELSE 0 END)::int AS open_count, SUM(CASE WHEN state = 'closed' THEN count ELSE 0 END)::int AS closed_count FROM ( SELECT recorded_at::date AS date, bu_ownership, state, count, ROW_NUMBER() OVER ( PARTITION BY recorded_at::date, bu_ownership, state ORDER BY recorded_at DESC ) AS rn FROM ivanti_counts_history_by_bu WHERE bu_ownership ILIKE ANY($1::text[]) ) sub WHERE rn = 1 GROUP BY date ORDER BY date ASC`, [patterns] ); return res.json({ history: rows }); } } // Global history (no filter) const { rows } = await pool.query( `SELECT date, open_count, closed_count FROM ( SELECT recorded_at::date AS date, open_count, closed_count, ROW_NUMBER() OVER ( PARTITION BY recorded_at::date ORDER BY recorded_at DESC ) AS rn FROM ivanti_counts_history ) sub WHERE rn = 1 ORDER BY date ASC` ); 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. * Accepts optional `teams` query parameter to scope to specific BUs. * * @query {string} [teams] - Comma-separated team names (e.g. 'STEAM,ACCESS-ENG') * @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 teamsParam = req.query.teams; let whereExtra = ''; const params = []; let paramIndex = 1; if (teamsParam) { const teams = teamsParam.split(',').map(t => t.trim()).filter(Boolean); if (teams.length > 0) { const patterns = teams.map(t => `%${t}%`); whereExtra = ` AND bu_ownership ILIKE ANY($${paramIndex++}::text[])`; params.push(patterns); } } // Finding counts: number of findings per workflow state const findingResult = await pool.query( `SELECT workflow_state, COUNT(*) as count FROM ivanti_findings WHERE workflow_id IS NOT NULL ${whereExtra} GROUP BY workflow_state`, params ); const findingCounts = {}; findingResult.rows.forEach(r => { const state = r.workflow_state || 'Unknown'; findingCounts[state] = parseInt(r.count); }); // ID counts: number of unique workflow IDs per state const idResult = await pool.query( `SELECT workflow_state, COUNT(DISTINCT workflow_id) as count FROM ivanti_findings WHERE workflow_id IS NOT NULL ${whereExtra} GROUP BY workflow_state`, params ); const idCounts = {}; idResult.rows.forEach(r => { const state = r.workflow_state || 'Unknown'; idCounts[state] = parseInt(r.count); }); 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 (err) { console.error('[Ivanti Findings] GET /fp-workflow-counts error:', err.message); 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. * * @returns {Object} 200 - { anomaly: Object|null } * @returns {Object} 500 - { error: string } on database error */ router.get('/anomaly/latest', async (req, res) => { try { const { rows } = await pool.query( `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` ); const row = rows[0]; 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. * * @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) { const result = await pool.query( `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 >= $1 AND sync_timestamp <= $2 ORDER BY sync_timestamp DESC`, [from, to] ); rows = result.rows; } else { const result = await pool.query( `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` ); rows = result.rows; } 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. * * @returns {Object} 200 - { changes: Array } * @returns {Object} 500 - { error: string } on database error */ router.get('/bu-changes', async (req, res) => { try { const { rows } = await pool.query( `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. * * @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 pool.query( `SELECT previous_bu, new_bu, detected_at FROM ivanti_finding_bu_history WHERE finding_id = $1 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 field overrides for a finding. Requires Admin or Standard_User group. * Accepts hostName and/or dns in the body. Empty/null values clear the override. * * @param {string} findingId - The finding identifier (URL param) * @body {string} [hostName] - Override for host name; empty/null to clear * @body {string} [dns] - Override for DNS; empty/null to clear * * @returns {Object} 200 - { finding_id, overrides: { hostName, dns } } * @returns {Object} 404 - { error: string } when finding not found * @returns {Object} 500 - { error: string } on database error */ router.put('/:findingId/override', requireGroup('Admin', 'Standard_User'), async (req, res) => { try { const { findingId } = req.params; const { hostName, dns, field, value } = req.body; // Support legacy single-field format: { field: 'hostName', value: 'x' } if (field !== undefined) { const OVERRIDE_ALLOWED = ['hostName', 'dns']; 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() || null; const col = field === 'hostName' ? 'override_host_name' : 'override_dns'; await pool.query( `UPDATE ivanti_findings SET ${col} = $1 WHERE id = $2`, [val, findingId] ); return res.json({ finding_id: findingId, field, value: val }); } // New multi-field format: { hostName: 'x', dns: 'y' } const overrideHostName = hostName !== undefined ? (String(hostName).trim() || null) : undefined; const overrideDns = dns !== undefined ? (String(dns).trim() || null) : undefined; if (overrideHostName !== undefined || overrideDns !== undefined) { const sets = []; const params = []; let idx = 1; if (overrideHostName !== undefined) { sets.push(`override_host_name = $${idx++}`); params.push(overrideHostName); } if (overrideDns !== undefined) { sets.push(`override_dns = $${idx++}`); params.push(overrideDns); } params.push(findingId); await pool.query( `UPDATE ivanti_findings SET ${sets.join(', ')} WHERE id = $${idx}`, params ); } // Return current override state const { rows } = await pool.query( `SELECT override_host_name, override_dns FROM ivanti_findings WHERE id = $1`, [findingId] ); if (rows.length === 0) { return res.status(404).json({ error: 'Finding not found' }); } res.json({ finding_id: findingId, overrides: { ...(rows[0].override_host_name ? { hostName: rows[0].override_host_name } : {}), ...(rows[0].override_dns ? { dns: rows[0].override_dns } : {}) } }); } catch (err) { console.error('[Ivanti Findings] PUT /:findingId/override error:', err.message); res.status(500).json({ error: 'Failed to save override' }); } }); /** * 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'), async (req, res) => { try { const { findingId } = req.params; const note = String(req.body.note || '').slice(0, 255); await pool.query( 'UPDATE ivanti_findings SET note = $1 WHERE id = $2', [note, findingId] ); res.json({ finding_id: findingId, note }); } catch (err) { console.error('[Ivanti Findings] PUT /:findingId/note error:', err.message); res.status(500).json({ error: 'Failed to save note' }); } }); return router; } module.exports = createIvantiFindingsRouter; module.exports.detectArchiveChanges = detectArchiveChanges; module.exports.detectClosedFindings = detectClosedFindings; module.exports.runBUDriftChecker = runBUDriftChecker; module.exports.computeAnomalySummary = computeAnomalySummary; module.exports.extractFinding = extractFinding; module.exports.upsertFindingsBatch = upsertFindingsBatch;