Files
cve-dashboard/backend/routes/ivantiFindings.js
Jordan Ramos a2234ccc1a Write BU history records from drift checker for anomaly banner detail view
The drift checker now inserts into ivanti_finding_bu_history when it
classifies archived findings as bu_reassignment. Previously only the
inline per-finding BU comparison (for findings still in sync) wrote
history records — archived findings that moved BU were counted in the
anomaly summary but had no detail records for the banner to display.

Also captures title and hostName from the Ivanti API response in the
drift checker for richer detail display, and adjusts the banner's
time window to 10 minutes before sync_timestamp to catch records
written during the drift check phase.
2026-06-15 09:29:46 -06:00

1644 lines
70 KiB
JavaScript

// 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 || '';
const title = f.title || '';
const hostName = f.host?.hostName || f.hostName || '';
foundMap.set(String(f.id), { bu, severity, state, title, hostName });
}
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);
}
// Record BU reassignment in ivanti_finding_bu_history for detail view
if (classification === 'bu_reassignment' && found) {
try {
// Determine previous BU — look up from the cached finding record
const { rows: prevRows } = await pool.query(
`SELECT bu_ownership FROM ivanti_findings WHERE id = $1`,
[id]
);
const previousBu = prevRows[0]?.bu_ownership || 'UNKNOWN';
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())`,
[id, found.title || '', found.hostName || '', previousBu, found.bu]
);
} catch (err) {
console.error(`[BU Drift Checker] Error recording BU change 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<Object> }
* @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 BU change events from ivanti_finding_bu_history.
* Accepts optional `since` to filter by date, or `limit` to cap the result count.
* If `since` is provided, returns all changes on or after that timestamp.
* If neither is provided, returns the most recent 200 rows (max 500).
*
* @query {string} [since] - ISO timestamp; return changes where detected_at >= this value
* @query {string} [limit] - Maximum number of rows to return (default 200, max 500); ignored when `since` is provided
* @returns {Object} 200 - { changes: Array<{ id, finding_id, finding_title, host_name, previous_bu, new_bu, detected_at }> }
* @returns {Object} 500 - { error: string } on database error
*/
router.get('/bu-changes', async (req, res) => {
try {
const { since, limit } = req.query;
let rows;
if (since) {
const result = await pool.query(
`SELECT id, finding_id, finding_title, host_name, previous_bu, new_bu, detected_at
FROM ivanti_finding_bu_history
WHERE detected_at >= $1
ORDER BY detected_at DESC`,
[since]
);
rows = result.rows;
} else {
const maxRows = Math.min(parseInt(limit) || 200, 500);
const result = 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
LIMIT $1`,
[maxRows]
);
rows = result.rows;
}
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<Object> }
* @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;