Only record BU reassignment in ivanti_finding_bu_history when the previous_bu is a known managed BU (from EXPECTED_BUS). Findings that were never in our sync cache show as UNKNOWN which provides no actionable insight for asset movement tracking. Closes #28
1676 lines
72 KiB
JavaScript
1676 lines
72 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, previousBuMap) {
|
|
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 from the pre-sync snapshot (passed in from syncFindings)
|
|
const previousBu = (previousBuMap && previousBuMap.get(id)) || '';
|
|
|
|
// Only record if we have a known previous BU — "UNKNOWN → X" entries
|
|
// provide no actionable insight for asset movement tracking.
|
|
if (previousBu && EXPECTED_BUS.has(previousBu)) {
|
|
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 = [];
|
|
let previousBuMap = new Map(); // id → bu_ownership snapshot BEFORE upsert
|
|
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;
|
|
previousBuMap = new Map(rows.map(f => [String(f.id), f.buOwnership || '']));
|
|
} 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);
|
|
}
|
|
|
|
// Remove archived findings from ivanti_findings to prevent stale re-detection
|
|
if (archiveResult.disappearedIds && archiveResult.disappearedIds.length > 0) {
|
|
try {
|
|
const { rowCount } = await pool.query(
|
|
`DELETE FROM ivanti_findings WHERE state = 'open' AND CAST(id AS TEXT) = ANY($1::text[])`,
|
|
[archiveResult.disappearedIds.map(String)]
|
|
);
|
|
if (rowCount > 0) {
|
|
console.log(`[Ivanti Findings] Removed ${rowCount} archived findings from ivanti_findings`);
|
|
}
|
|
} catch (err) {
|
|
console.error('[Ivanti Findings] Failed to clean archived findings from table (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
|
|
// Filter out findings that were already in ARCHIVED state from a previous sync —
|
|
// only pass genuinely new disappearances to avoid re-classifying the same set every cycle.
|
|
let classificationBreakdown = { bu_reassignment: 0, severity_drift: 0, closed_on_platform: 0, decommissioned: 0 };
|
|
try {
|
|
let idsToCheck = archiveResult.disappearedIds || [];
|
|
if (idsToCheck.length > 0) {
|
|
const { rows: alreadyArchived } = await pool.query(
|
|
`SELECT finding_id FROM ivanti_finding_archives
|
|
WHERE current_state = 'ARCHIVED'
|
|
AND last_transition_at < NOW() - INTERVAL '2 hours'`
|
|
);
|
|
const alreadyArchivedSet = new Set(alreadyArchived.map(r => String(r.finding_id)));
|
|
const newlyArchivedOnly = idsToCheck.filter(id => !alreadyArchivedSet.has(String(id)));
|
|
console.log(`[BU Drift Checker] ${idsToCheck.length} disappeared total, ${newlyArchivedOnly.length} genuinely new (${alreadyArchivedSet.size} already archived, skipped)`);
|
|
idsToCheck = newlyArchivedOnly;
|
|
}
|
|
classificationBreakdown = await runBUDriftChecker(idsToCheck, apiKey, clientId, skipTls, previousBuMap);
|
|
} 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;
|