diff --git a/backend/routes/ivantiFindings.js b/backend/routes/ivantiFindings.js index 7ff48f1..de9e7ea 100644 --- a/backend/routes/ivantiFindings.js +++ b/backend/routes/ivantiFindings.js @@ -1,10 +1,12 @@ // Ivanti / RiskSense Host Findings Routes -// Caches hostFinding/search results in SQLite with daily auto-sync. -// Notes are stored separately so they survive cache refreshes. +// 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; @@ -74,369 +76,6 @@ const CLOSED_COUNT_FILTERS = [ } ]; -// --------------------------------------------------------------------------- -// 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 // --------------------------------------------------------------------------- @@ -501,107 +140,6 @@ function extractFinding(f) { }; } -// --------------------------------------------------------------------------- -// 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. @@ -622,17 +160,406 @@ function extractFPWorkflow(f) { } // --------------------------------------------------------------------------- -// 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. +// Batch upsert findings into ivanti_findings table +// Preserves note and override_* columns (user data) during upsert. // --------------------------------------------------------------------------- -async function syncFPWorkflowCounts(db, openFindings, apiKey, clientId, skipTls) { +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 * 18; + 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 + ); + 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})` + ); + }); + + 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 + ) + 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, + 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] + ); + } + + 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) @@ -678,7 +605,6 @@ async function syncFPWorkflowCounts(db, openFindings, apiKey, clientId, skipTls) } 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 @@ -687,8 +613,8 @@ async function syncFPWorkflowCounts(db, openFindings, apiKey, clientId, skipTls) 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`, + 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)); @@ -696,300 +622,12 @@ async function syncFPWorkflowCounts(db, openFindings, apiKey, clientId, skipTls) 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) { +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; @@ -997,7 +635,6 @@ async function runBUDriftChecker(db, newlyArchivedIds, apiKey, clientId, skipTls 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) { @@ -1052,7 +689,6 @@ async function runBUDriftChecker(db, newlyArchivedIds, apiKey, clientId, skipTls 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 } } @@ -1075,7 +711,6 @@ async function runBUDriftChecker(db, newlyArchivedIds, apiKey, clientId, skipTls classification = 'closed_on_platform'; reason = 'closed_on_platform'; } else { - // BU matches, severity >= 8.5, not closed — unexpected, leave as default classification = 'decommissioned'; reason = 'decommissioned'; } @@ -1084,16 +719,17 @@ async function runBUDriftChecker(db, newlyArchivedIds, apiKey, clientId, skipTls // 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 = ?`, + const { rows } = await pool.query( + `SELECT id FROM ivanti_finding_archives WHERE finding_id = $1`, [id] ); + const archive = rows[0]; if (archive) { - await dbRun(db, - `UPDATE ivanti_archive_transitions SET reason = ? - WHERE archive_id = ? AND id = ( + 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 = ? ORDER BY transitioned_at DESC LIMIT 1 + WHERE archive_id = $3 ORDER BY transitioned_at DESC LIMIT 1 )`, [reason, archive.id, archive.id] ); @@ -1110,20 +746,20 @@ async function runBUDriftChecker(db, newlyArchivedIds, apiKey, clientId, skipTls // --------------------------------------------------------------------------- // Anomaly Summary — compute and store post-sync anomaly report // --------------------------------------------------------------------------- -async function computeAnomalySummary(db, openCountDelta, closedCountDelta, newlyArchivedCount, returnedCount, classificationBreakdown, returnClassificationBreakdown) { +async function computeAnomalySummary(openCountDelta, closedCountDelta, newlyArchivedCount, returnedCount, classificationBreakdown, returnClassificationBreakdown) { try { - const isSignificant = newlyArchivedCount > 5 ? 1 : 0; + const isSignificant = newlyArchivedCount > 5; const classificationJson = JSON.stringify(classificationBreakdown || {}); const returnClassificationJson = JSON.stringify(returnClassificationBreakdown || {}); - await dbRun(db, + 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 (datetime('now'), ?, ?, ?, ?, ?, ?, ?)`, + 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] 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); @@ -1133,47 +769,278 @@ async function computeAnomalySummary(db, openCountDelta, closedCountDelta, newly } } +// --------------------------------------------------------------------------- +// 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(); - Promise.all([initTables(db), initArchiveTables(db)]) - .then(() => scheduleSync(db)) - .catch((err) => console.error('[Ivanti Findings] Init failed:', err)); + // Initialize sync schedule (no table init needed — schema handled by db-schema.sql) + scheduleSync(); - router.use(requireAuth(db)); + router.use(requireAuth()); /** * GET /api/ivanti/findings * - * Return cached Ivanti findings with notes and overrides merged in. + * 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 findings. + * 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: Array, lastSync: string|null, overrides: Object, total: number } + * @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 state = await readStateWithNotes(db); - - // Filter by teams if provided 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().toUpperCase()).filter(Boolean); + const teams = teamsParam.split(',').map(t => t.trim()).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; + const patterns = teams.map(t => `%${t}%`); + query += ` AND bu_ownership ILIKE ANY($${paramIndex++}::text[])`; + params.push(patterns); } } - res.json(state); - } catch { + 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: row.due_date, + lastFoundOn: 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 || '', + 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' }); } }); @@ -1184,14 +1051,51 @@ function createIvantiFindingsRouter(db, requireAuth) { * 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} 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(db); + await syncFindings(); try { - res.json(await readStateWithNotes(db)); - } catch { + // 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: row.due_date, + lastFoundOn: 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 || '', + 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' }); } }); @@ -1199,9 +1103,9 @@ function createIvantiFindingsRouter(db, requireAuth) { /** * 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. + * 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 } @@ -1210,23 +1114,30 @@ function createIvantiFindingsRouter(db, requireAuth) { 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().toUpperCase()).filter(Boolean); + const teams = teamsParam.split(',').map(t => t.trim()).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 }); + const patterns = teams.map(t => `%${t}%`); + whereExtra = ` AND bu_ownership ILIKE ANY($${paramIndex++}::text[])`; + params.push(patterns); } } - res.json({ ...(await readCounts(db)), filtered: false }); - } catch { + 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' }); } }); @@ -1235,29 +1146,24 @@ function createIvantiFindingsRouter(db, requireAuth) { * 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 || []); } - ); - }); + 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); @@ -1276,11 +1182,8 @@ function createIvantiFindingsRouter(db, requireAuth) { */ 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); } - ); - }); + const { rows } = await pool.query('SELECT fp_workflow_counts_json, fp_id_counts_json FROM ivanti_counts_cache WHERE id=1'); + const row = rows[0]; let findingCounts = {}; let idCounts = {}; try { findingCounts = JSON.parse(row?.fp_workflow_counts_json || '{}'); } catch (_) {} @@ -1291,7 +1194,8 @@ function createIvantiFindingsRouter(db, requireAuth) { idCounts, idTotal: Object.values(idCounts).reduce((a, b) => a + b, 0), }); - } catch { + } catch (err) { + console.error('[Ivanti Findings] GET /fp-workflow-counts error:', err.message); res.status(500).json({ error: 'Database error reading FP workflow counts' }); } }); @@ -1300,19 +1204,19 @@ function createIvantiFindingsRouter(db, requireAuth) { * 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, + 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 (_) {} @@ -1340,9 +1244,7 @@ function createIvantiFindingsRouter(db, requireAuth) { /** * 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. + * 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) @@ -1356,21 +1258,23 @@ function createIvantiFindingsRouter(db, requireAuth) { let rows; if (from && to) { - rows = await dbAll(db, + 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 >= ? AND sync_timestamp <= ? + WHERE sync_timestamp >= $1 AND sync_timestamp <= $2 ORDER BY sync_timestamp DESC`, [from, to] ); + rows = result.rows; } else { - rows = await dbAll(db, + 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 => { @@ -1400,15 +1304,14 @@ function createIvantiFindingsRouter(db, requireAuth) { /** * GET /api/ivanti/findings/bu-changes * - * Return all BU change events from ivanti_finding_bu_history, - * ordered by detected_at descending (newest first). + * 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 dbAll(db, + 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` @@ -1423,21 +1326,19 @@ function createIvantiFindingsRouter(db, requireAuth) { /** * 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). + * 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 dbAll(db, + const { rows } = await pool.query( `SELECT previous_bu, new_bu, detected_at FROM ivanti_finding_bu_history - WHERE finding_id = ? + WHERE finding_id = $1 ORDER BY detected_at DESC`, [findingId] ); @@ -1451,49 +1352,81 @@ function createIvantiFindingsRouter(db, requireAuth) { /** * 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). + * 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} field - The field to override; must be one of 'hostName', 'dns' - * @body {string} [value] - The override value; empty or omitted to clear + * @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: string, field: string, value: string|null } - * @returns {Object} 400 - { error: string } when field is not in the allowed list + * @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 */ - const OVERRIDE_ALLOWED = ['hostName', 'dns']; - router.put('/:findingId/override', requireGroup('Admin', 'Standard_User'), (req, res) => { - const { findingId } = req.params; - const { field, value } = req.body; + router.put('/:findingId/override', requireGroup('Admin', 'Standard_User'), async (req, res) => { + try { + const { findingId } = req.params; + const { hostName, dns, 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 }); + // 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(', ')}` }); } - ); - } 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 }); + 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' }); } }); @@ -1509,20 +1442,21 @@ function createIvantiFindingsRouter(db, requireAuth) { * @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); + 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); - 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 }); - } - ); + 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; @@ -1531,7 +1465,7 @@ function createIvantiFindingsRouter(db, requireAuth) { 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; +module.exports.upsertFindingsBatch = upsertFindingsBatch; diff --git a/backend/server.js b/backend/server.js index fc41c5b..3b357e0 100644 --- a/backend/server.js +++ b/backend/server.js @@ -193,7 +193,7 @@ app.use('/api/archer-tickets', createArcherTicketsRouter()); app.use('/api/ivanti/workflows', createIvantiWorkflowsRouter()); // Ivanti / RiskSense host findings routes (all authenticated users) -// NOTE: Still passes pool as db until task 8 migrates this route +// Pool imported directly inside the module; first arg kept for signature compat app.use('/api/ivanti/findings', createIvantiFindingsRouter(pool, requireAuth)); // Ivanti queue routes — per-user staging queue for FP / Archer workflows