- Add migration script for ivanti_finding_archives and ivanti_archive_transitions tables - Add archive detection logic (detectArchiveChanges, detectClosedFindings) in sync pipeline - Add archive API router with list, stats, and history endpoints at /api/ivanti/archive - Add ArchiveSummaryBar UI component with four state cards (ACTIVE, ARCHIVED, RETURNED, CLOSED) - Integrate ArchiveSummaryBar into Ivanti findings page in App.js - Register archive router in server.js
960 lines
40 KiB
JavaScript
960 lines
40 KiB
JavaScript
// Ivanti / RiskSense Host Findings Routes
|
|
// Caches hostFinding/search results in SQLite with daily auto-sync.
|
|
// Notes are stored separately so they survive cache refreshes.
|
|
|
|
const express = require('express');
|
|
const https = require('https');
|
|
const { requireRole } = require('../middleware/auth');
|
|
|
|
const IVANTI_URL_BASE = 'https://platform4.risksense.com/api/v1';
|
|
const SYNC_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
|
|
|
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: 'NTS-AEO-ACCESS-ENG,NTS-AEO-STEAM',
|
|
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: 'NTS-AEO-ACCESS-ENG,NTS-AEO-STEAM',
|
|
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
|
|
}
|
|
];
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// HTTP helper — mirrors the one in ivantiWorkflows.js
|
|
// ---------------------------------------------------------------------------
|
|
function ivantiPost(urlPath, body, apiKey, skipTls) {
|
|
const bodyStr = JSON.stringify(body);
|
|
const fullUrl = new URL(IVANTI_URL_BASE + urlPath);
|
|
|
|
return new Promise((resolve, reject) => {
|
|
const options = {
|
|
hostname: fullUrl.hostname,
|
|
path: fullUrl.pathname + fullUrl.search,
|
|
method: 'POST',
|
|
headers: {
|
|
'accept': '*/*',
|
|
'content-type': 'application/json',
|
|
'x-api-key': apiKey,
|
|
'x-http-client-type': 'browser',
|
|
'content-length': Buffer.byteLength(bodyStr)
|
|
},
|
|
rejectUnauthorized: !skipTls,
|
|
timeout: 20000
|
|
};
|
|
|
|
const req = https.request(options, (res) => {
|
|
let data = '';
|
|
res.on('data', (chunk) => { data += chunk; });
|
|
res.on('end', () => resolve({ status: res.statusCode, body: data }));
|
|
});
|
|
|
|
req.on('timeout', () => req.destroy(new Error('Request timed out')));
|
|
req.on('error', reject);
|
|
req.write(bodyStr);
|
|
req.end();
|
|
});
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Table init
|
|
// ---------------------------------------------------------------------------
|
|
function initTables(db) {
|
|
return new Promise((resolve, reject) => {
|
|
db.serialize(() => {
|
|
db.run(`
|
|
CREATE TABLE IF NOT EXISTS ivanti_findings_cache (
|
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
total INTEGER DEFAULT 0,
|
|
findings_json TEXT DEFAULT '[]',
|
|
synced_at DATETIME,
|
|
sync_status TEXT DEFAULT 'never',
|
|
error_message TEXT
|
|
)
|
|
`, (err) => { if (err) return reject(err); });
|
|
|
|
db.run(`
|
|
INSERT OR IGNORE INTO ivanti_findings_cache (id, total, findings_json, sync_status)
|
|
VALUES (1, 0, '[]', 'never')
|
|
`, (err) => { if (err) return reject(err); });
|
|
|
|
db.run(`
|
|
CREATE TABLE IF NOT EXISTS ivanti_finding_notes (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
finding_id TEXT NOT NULL UNIQUE,
|
|
note TEXT NOT NULL DEFAULT '',
|
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
)
|
|
`, (err) => { if (err) return reject(err); });
|
|
|
|
db.run(`
|
|
CREATE TABLE IF NOT EXISTS ivanti_counts_cache (
|
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
open_count INTEGER DEFAULT 0,
|
|
closed_count INTEGER DEFAULT 0,
|
|
synced_at DATETIME
|
|
)
|
|
`, (err) => { if (err) return reject(err); });
|
|
|
|
// Idempotent column additions — errors mean the column already exists, which is fine
|
|
db.run(`ALTER TABLE ivanti_counts_cache ADD COLUMN fp_workflow_counts_json TEXT DEFAULT '{}'`, () => {});
|
|
db.run(`ALTER TABLE ivanti_counts_cache ADD COLUMN fp_id_counts_json TEXT DEFAULT '{}'`, () => {});
|
|
|
|
db.run(`
|
|
INSERT OR IGNORE INTO ivanti_counts_cache (id, open_count, closed_count)
|
|
VALUES (1, 0, 0)
|
|
`, (err) => { if (err) return reject(err); });
|
|
|
|
db.run(`
|
|
CREATE TABLE IF NOT EXISTS ivanti_finding_overrides (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
finding_id TEXT NOT NULL,
|
|
field TEXT NOT NULL,
|
|
value TEXT NOT NULL DEFAULT '',
|
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
UNIQUE(finding_id, field)
|
|
)
|
|
`, (err) => { if (err) return reject(err); });
|
|
|
|
db.run(`
|
|
CREATE INDEX IF NOT EXISTS idx_finding_notes_finding_id
|
|
ON ivanti_finding_notes(finding_id)
|
|
`, (err) => { if (err) return reject(err); });
|
|
|
|
db.run(`
|
|
CREATE INDEX IF NOT EXISTS idx_finding_overrides_finding_id
|
|
ON ivanti_finding_overrides(finding_id)
|
|
`, (err) => { if (err) return reject(err); });
|
|
|
|
db.run(`
|
|
CREATE TABLE IF NOT EXISTS ivanti_counts_history (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
open_count INTEGER NOT NULL,
|
|
closed_count INTEGER NOT NULL,
|
|
recorded_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
)
|
|
`, (err) => {
|
|
if (err) reject(err);
|
|
else resolve();
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Archive table init — creates archive tracking tables alongside the main cache
|
|
// ---------------------------------------------------------------------------
|
|
function initArchiveTables(db) {
|
|
return new Promise((resolve, reject) => {
|
|
db.serialize(() => {
|
|
db.run(`
|
|
CREATE TABLE IF NOT EXISTS ivanti_finding_archives (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
finding_id TEXT NOT NULL UNIQUE,
|
|
finding_title TEXT NOT NULL DEFAULT '',
|
|
host_name TEXT NOT NULL DEFAULT '',
|
|
ip_address TEXT NOT NULL DEFAULT '',
|
|
current_state TEXT NOT NULL CHECK(current_state IN ('ARCHIVED','RETURNED','CLOSED')),
|
|
last_severity REAL NOT NULL DEFAULT 0,
|
|
first_archived_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
last_transition_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
)
|
|
`, (err) => { if (err) return reject(err); });
|
|
|
|
db.run(`
|
|
CREATE TABLE IF NOT EXISTS ivanti_archive_transitions (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
archive_id INTEGER NOT NULL,
|
|
from_state TEXT NOT NULL,
|
|
to_state TEXT NOT NULL,
|
|
severity_at_transition REAL NOT NULL DEFAULT 0,
|
|
reason TEXT NOT NULL DEFAULT '',
|
|
transitioned_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
FOREIGN KEY (archive_id) REFERENCES ivanti_finding_archives(id)
|
|
)
|
|
`, (err) => { if (err) return reject(err); });
|
|
|
|
db.run(`
|
|
CREATE INDEX IF NOT EXISTS idx_archive_finding_id
|
|
ON ivanti_finding_archives(finding_id)
|
|
`, (err) => { if (err) return reject(err); });
|
|
|
|
db.run(`
|
|
CREATE INDEX IF NOT EXISTS idx_archive_current_state
|
|
ON ivanti_finding_archives(current_state)
|
|
`, (err) => { if (err) return reject(err); });
|
|
|
|
db.run(`
|
|
CREATE INDEX IF NOT EXISTS idx_transition_archive_id
|
|
ON ivanti_archive_transitions(archive_id)
|
|
`, (err) => {
|
|
if (err) reject(err);
|
|
else resolve();
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Archive detection — compare previous vs current findings to detect state changes
|
|
// ---------------------------------------------------------------------------
|
|
async function detectArchiveChanges(db, previousFindings, currentFindings) {
|
|
const previousIds = new Set(previousFindings.map(f => String(f.id)));
|
|
const currentIds = new Set(currentFindings.map(f => String(f.id)));
|
|
|
|
// Build lookup maps for metadata
|
|
const previousMap = new Map(previousFindings.map(f => [String(f.id), f]));
|
|
const currentMap = new Map(currentFindings.map(f => [String(f.id), f]));
|
|
|
|
// 1. Disappeared findings: in previous but not in current → ARCHIVED
|
|
const disappearedIds = [...previousIds].filter(id => !currentIds.has(id));
|
|
|
|
for (const id of disappearedIds) {
|
|
const finding = previousMap.get(id);
|
|
const title = finding.title || '';
|
|
const hostName = finding.hostName || '';
|
|
const ipAddress = finding.ipAddress || '';
|
|
const severity = typeof finding.severity === 'number' ? finding.severity : 0;
|
|
|
|
try {
|
|
// Check if this finding already has an archive record
|
|
const existing = await dbGet(db,
|
|
`SELECT id, current_state FROM ivanti_finding_archives WHERE finding_id = ?`,
|
|
[id]
|
|
);
|
|
|
|
if (existing && existing.current_state === 'RETURNED') {
|
|
// Re-disappeared: RETURNED → ARCHIVED
|
|
await dbRun(db,
|
|
`UPDATE ivanti_finding_archives
|
|
SET current_state = 'ARCHIVED', last_severity = ?, last_transition_at = datetime('now')
|
|
WHERE id = ?`,
|
|
[severity, existing.id]
|
|
);
|
|
await dbRun(db,
|
|
`INSERT INTO ivanti_archive_transitions (archive_id, from_state, to_state, severity_at_transition, reason, transitioned_at)
|
|
VALUES (?, 'RETURNED', 'ARCHIVED', ?, 'severity_score_drift', datetime('now'))`,
|
|
[existing.id, severity]
|
|
);
|
|
console.log(`[Archive Detection] Finding ${id} re-archived (RETURNED → ARCHIVED)`);
|
|
} else if (!existing) {
|
|
// First disappearance: NONE → ARCHIVED
|
|
const result = await dbRun(db,
|
|
`INSERT INTO ivanti_finding_archives (finding_id, finding_title, host_name, ip_address, current_state, last_severity, first_archived_at, last_transition_at)
|
|
VALUES (?, ?, ?, ?, 'ARCHIVED', ?, datetime('now'), datetime('now'))`,
|
|
[id, title, hostName, ipAddress, severity]
|
|
);
|
|
const archiveId = result.lastID;
|
|
await dbRun(db,
|
|
`INSERT INTO ivanti_archive_transitions (archive_id, from_state, to_state, severity_at_transition, reason, transitioned_at)
|
|
VALUES (?, 'NONE', 'ARCHIVED', ?, 'severity_score_drift', datetime('now'))`,
|
|
[archiveId, severity]
|
|
);
|
|
console.log(`[Archive Detection] Finding ${id} archived (NONE → ARCHIVED)`);
|
|
}
|
|
// If existing state is ARCHIVED or CLOSED, no action needed
|
|
} catch (err) {
|
|
console.error(`[Archive Detection] Error processing disappeared finding ${id}:`, err.message);
|
|
}
|
|
}
|
|
|
|
// 2. Returned findings: in current AND has existing ARCHIVED record → RETURNED
|
|
const currentIdsList = [...currentIds];
|
|
if (currentIdsList.length > 0) {
|
|
try {
|
|
const archivedRecords = await dbAll(db,
|
|
`SELECT id, finding_id FROM ivanti_finding_archives WHERE current_state = 'ARCHIVED'`
|
|
);
|
|
|
|
for (const record of archivedRecords) {
|
|
if (currentIds.has(record.finding_id)) {
|
|
const finding = currentMap.get(record.finding_id);
|
|
const severity = typeof finding.severity === 'number' ? finding.severity : 0;
|
|
|
|
await dbRun(db,
|
|
`UPDATE ivanti_finding_archives
|
|
SET current_state = 'RETURNED', last_severity = ?, last_transition_at = datetime('now')
|
|
WHERE id = ?`,
|
|
[severity, record.id]
|
|
);
|
|
await dbRun(db,
|
|
`INSERT INTO ivanti_archive_transitions (archive_id, from_state, to_state, severity_at_transition, reason, transitioned_at)
|
|
VALUES (?, 'ARCHIVED', 'RETURNED', ?, 'reappeared_in_sync', datetime('now'))`,
|
|
[record.id, severity]
|
|
);
|
|
console.log(`[Archive Detection] Finding ${record.finding_id} returned (ARCHIVED → RETURNED)`);
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.error('[Archive Detection] Error processing returned findings:', err.message);
|
|
}
|
|
}
|
|
|
|
console.log(`[Archive Detection] Processed ${disappearedIds.length} disappeared, checked ${currentIdsList.length} current findings against archive`);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Closed finding detection — check archived/returned findings against Ivanti closed set
|
|
// ---------------------------------------------------------------------------
|
|
async function detectClosedFindings(db, closedFindingIds) {
|
|
if (!closedFindingIds || closedFindingIds.length === 0) return;
|
|
|
|
const closedSet = new Set(closedFindingIds.map(String));
|
|
|
|
try {
|
|
const records = await dbAll(db,
|
|
`SELECT id, finding_id, current_state, last_severity FROM ivanti_finding_archives WHERE current_state IN ('ARCHIVED', 'RETURNED')`
|
|
);
|
|
|
|
let closedCount = 0;
|
|
for (const record of records) {
|
|
if (!closedSet.has(record.finding_id)) continue;
|
|
|
|
try {
|
|
await dbRun(db,
|
|
`UPDATE ivanti_finding_archives
|
|
SET current_state = 'CLOSED', last_transition_at = datetime('now')
|
|
WHERE id = ?`,
|
|
[record.id]
|
|
);
|
|
await dbRun(db,
|
|
`INSERT INTO ivanti_archive_transitions (archive_id, from_state, to_state, severity_at_transition, reason, transitioned_at)
|
|
VALUES (?, ?, 'CLOSED', ?, 'remediated_in_ivanti', datetime('now'))`,
|
|
[record.id, record.current_state, record.last_severity || 0]
|
|
);
|
|
closedCount++;
|
|
console.log(`[Archive Detection] Finding ${record.finding_id} closed (${record.current_state} → CLOSED)`);
|
|
} catch (err) {
|
|
console.error(`[Archive Detection] Error closing finding ${record.finding_id}:`, err.message);
|
|
}
|
|
}
|
|
|
|
console.log(`[Archive Detection] Closed ${closedCount} findings as remediated`);
|
|
} catch (err) {
|
|
console.error('[Archive Detection] Error querying archive records for closed detection:', err.message);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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),
|
|
title: f.title || '',
|
|
severity: typeof f.severity === 'number' ? f.severity : parseFloat(f.severity) || 0,
|
|
vrrGroup: f.vrrGroup || f.severityGroup || '',
|
|
hostName: f.host?.hostName || '',
|
|
ipAddress: f.host?.ipAddress || '',
|
|
dns: f.dns || f.host?.fqdn || '',
|
|
status: f.status || '',
|
|
slaStatus: f.slaStatus || '',
|
|
dueDate,
|
|
lastFoundOn: f.lastFoundOn || '',
|
|
buOwnership,
|
|
cves,
|
|
workflow
|
|
};
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Fetch total count of Closed findings from Ivanti (page 0, size 1)
|
|
// ---------------------------------------------------------------------------
|
|
async function syncClosedCount(db, openCount, apiKey, clientId, skipTls) {
|
|
const urlPath = `/client/${encodeURIComponent(clientId)}/hostFinding/search`;
|
|
try {
|
|
const body = {
|
|
filters: CLOSED_COUNT_FILTERS,
|
|
projection: 'internal',
|
|
sort: [{ field: 'severity', direction: 'ASC' }],
|
|
page: 0,
|
|
size: 100
|
|
};
|
|
|
|
const result = await ivantiPost(urlPath, body, apiKey, skipTls);
|
|
if (result.status !== 200) throw new Error(`Closed count API returned status ${result.status}`);
|
|
|
|
const data = JSON.parse(result.body);
|
|
// RiskSense returns total in page.totalElements or page.total
|
|
const closedCount = data.page?.totalElements ?? data.page?.total ?? 0;
|
|
const totalPages = data.page?.totalPages || 1;
|
|
|
|
// Collect closed finding IDs for archive detection
|
|
const closedFindingIds = [];
|
|
const firstPageFindings = data._embedded?.hostFindings || [];
|
|
firstPageFindings.forEach(f => { if (f.id) closedFindingIds.push(String(f.id)); });
|
|
|
|
// Fetch remaining pages to collect all closed finding IDs
|
|
for (let pg = 1; pg < totalPages; pg++) {
|
|
try {
|
|
const pageBody = { ...body, page: pg };
|
|
const pageResult = await ivantiPost(urlPath, pageBody, apiKey, skipTls);
|
|
if (pageResult.status !== 200) break;
|
|
const pageData = JSON.parse(pageResult.body);
|
|
const pageFindings = pageData._embedded?.hostFindings || [];
|
|
pageFindings.forEach(f => { if (f.id) closedFindingIds.push(String(f.id)); });
|
|
} catch (err) {
|
|
console.error(`[Ivanti Findings] Failed to fetch closed findings page ${pg}:`, err.message);
|
|
break;
|
|
}
|
|
}
|
|
|
|
await dbRun(db,
|
|
`UPDATE ivanti_counts_cache SET open_count=?, closed_count=?, synced_at=datetime('now') WHERE id=1`,
|
|
[openCount, closedCount]
|
|
);
|
|
|
|
// Append a snapshot to history — every sync is stored; the history
|
|
// endpoint aggregates to last-per-day at query time (Option B).
|
|
await dbRun(db,
|
|
`INSERT INTO ivanti_counts_history (open_count, closed_count) VALUES (?, ?)`,
|
|
[openCount, closedCount]
|
|
);
|
|
|
|
console.log(`[Ivanti Findings] Counts updated — open: ${openCount}, closed: ${closedCount}`);
|
|
|
|
// Detect closed findings in the archive — wrap in try/catch so errors don't break sync
|
|
try {
|
|
await detectClosedFindings(db, closedFindingIds);
|
|
} catch (err) {
|
|
console.error('[Ivanti Findings] Closed finding archive detection failed (non-fatal):', err.message);
|
|
}
|
|
} catch (err) {
|
|
console.error('[Ivanti Findings] Failed to fetch closed count:', err.message);
|
|
// Still update open count so it stays in sync; leave closed_count as-is
|
|
await dbRun(db,
|
|
`UPDATE ivanti_counts_cache SET open_count=?, synced_at=datetime('now') WHERE id=1`,
|
|
[openCount]
|
|
).catch(() => {});
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Extract FP workflow id+state from a raw (un-extracted) finding
|
|
// Returns { id, state } or null if no FP# workflow present.
|
|
// ---------------------------------------------------------------------------
|
|
function extractFPWorkflow(f) {
|
|
const wfDist = f.workflowDistribution || {};
|
|
const fpBuckets = [
|
|
...(wfDist.actionableWorkflows || []),
|
|
...(wfDist.requestedWorkflows || []),
|
|
...(wfDist.reworkedWorkflows || []),
|
|
...(wfDist.rejectedWorkflows || []),
|
|
...(wfDist.expiredWorkflows || []),
|
|
...(wfDist.approvedWorkflows || []),
|
|
].filter(w => (w.generatedId || '').startsWith('FP#'));
|
|
const fpEntry = fpBuckets[0] || null;
|
|
if (!fpEntry) return null;
|
|
return { id: fpEntry.generatedId || '', state: fpEntry.state || 'Unknown' };
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Sync FP stats across ALL findings (open + closed).
|
|
//
|
|
// Produces two separate counts:
|
|
// findingCounts — number of *findings* per FP workflow state
|
|
// idCounts — number of *unique FP# ticket IDs* per state
|
|
// (one FP# can cover many findings; this chart counts tickets)
|
|
//
|
|
// Open findings come from the already-extracted allFindings array.
|
|
// Closed findings are swept page-by-page to catch Approved FPs.
|
|
// ---------------------------------------------------------------------------
|
|
async function syncFPWorkflowCounts(db, openFindings, apiKey, clientId, skipTls) {
|
|
const findingCounts = {}; // state → # findings
|
|
const fpIdMap = {}; // FP# id → state (deduplicates across findings)
|
|
|
|
// Seed from open findings (already extracted, have workflow.id + workflow.state)
|
|
openFindings.forEach(f => {
|
|
if (!f.workflow) return;
|
|
const state = f.workflow.state || 'Unknown';
|
|
const id = f.workflow.id || '';
|
|
findingCounts[state] = (findingCounts[state] || 0) + 1;
|
|
if (id && !fpIdMap[id]) fpIdMap[id] = state;
|
|
});
|
|
|
|
// Sweep closed findings to pick up Approved (and any other closed FP states)
|
|
const urlPath = `/client/${encodeURIComponent(clientId)}/hostFinding/search`;
|
|
let page = 0;
|
|
let totalPages = 1;
|
|
|
|
try {
|
|
do {
|
|
const body = {
|
|
filters: CLOSED_COUNT_FILTERS,
|
|
projection: 'internal',
|
|
sort: [{ field: 'severity', direction: 'ASC' }],
|
|
page,
|
|
size: 100
|
|
};
|
|
const result = await ivantiPost(urlPath, body, apiKey, skipTls);
|
|
if (result.status !== 200) {
|
|
console.warn(`[Ivanti Findings] FP workflow counts: closed findings page ${page} returned ${result.status} — stopping sweep`);
|
|
break;
|
|
}
|
|
const data = JSON.parse(result.body);
|
|
totalPages = data.page?.totalPages || 1;
|
|
const findings = data._embedded?.hostFindings || [];
|
|
findings.forEach(f => {
|
|
const wf = extractFPWorkflow(f);
|
|
if (!wf) return;
|
|
findingCounts[wf.state] = (findingCounts[wf.state] || 0) + 1;
|
|
if (wf.id && !fpIdMap[wf.id]) fpIdMap[wf.id] = wf.state;
|
|
});
|
|
console.log(`[Ivanti Findings] FP workflow counts: closed page ${page + 1}/${totalPages}`);
|
|
page++;
|
|
} while (page < totalPages);
|
|
} catch (err) {
|
|
console.error('[Ivanti Findings] FP workflow counts: closed sweep failed:', err.message);
|
|
// Fall through — store whatever we have from open findings
|
|
}
|
|
|
|
// Aggregate unique FP# IDs by state
|
|
const idCounts = {};
|
|
Object.values(fpIdMap).forEach(state => {
|
|
idCounts[state] = (idCounts[state] || 0) + 1;
|
|
});
|
|
|
|
await dbRun(db,
|
|
`UPDATE ivanti_counts_cache SET fp_workflow_counts_json=?, fp_id_counts_json=? WHERE id=1`,
|
|
[JSON.stringify(findingCounts), JSON.stringify(idCounts)]
|
|
).catch(e => console.error('[Ivanti Findings] Failed to store FP workflow counts:', e.message));
|
|
|
|
console.log('[Ivanti Findings] FP finding counts:', findingCounts);
|
|
console.log('[Ivanti Findings] FP workflow ID counts:', idCounts);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Core sync — fetches ALL pages, stores slimmed findings in SQLite
|
|
// ---------------------------------------------------------------------------
|
|
async function syncFindings(db) {
|
|
const apiKey = process.env.IVANTI_API_KEY;
|
|
const clientId = process.env.IVANTI_CLIENT_ID || '1550';
|
|
const skipTls = process.env.IVANTI_SKIP_TLS === 'true';
|
|
|
|
if (!apiKey) {
|
|
const errMsg = 'IVANTI_API_KEY not set in .env — skipping sync';
|
|
console.warn('[Ivanti Findings]', errMsg);
|
|
await dbRun(db, `UPDATE ivanti_findings_cache SET sync_status='error', error_message=?, synced_at=datetime('now') WHERE id=1`, [errMsg]);
|
|
return;
|
|
}
|
|
|
|
console.log('[Ivanti Findings] Starting sync...');
|
|
|
|
const urlPath = `/client/${encodeURIComponent(clientId)}/hostFinding/search`;
|
|
let allFindings = [];
|
|
let page = 0;
|
|
let totalPages = 1;
|
|
|
|
try {
|
|
do {
|
|
const body = {
|
|
filters: FINDINGS_FILTERS,
|
|
projection: 'internal',
|
|
sort: [{ field: 'severity', direction: 'ASC' }],
|
|
page,
|
|
size: 100
|
|
};
|
|
|
|
const result = await ivantiPost(urlPath, body, apiKey, skipTls);
|
|
|
|
if (result.status === 401) throw new Error('Invalid or missing API key (401)');
|
|
if (result.status === 419) throw new Error('Insufficient privileges (419) — API key lacks hostFinding access');
|
|
if (result.status === 429) throw new Error('Rate limited (429) — try again later');
|
|
if (result.status !== 200) throw new Error(`Ivanti API returned status ${result.status}`);
|
|
|
|
const data = JSON.parse(result.body);
|
|
totalPages = data.page?.totalPages || 1;
|
|
const findings = data._embedded?.hostFindings || [];
|
|
allFindings = allFindings.concat(findings.map(extractFinding));
|
|
|
|
console.log(`[Ivanti Findings] Page ${page + 1}/${totalPages} — ${allFindings.length} findings so far`);
|
|
page++;
|
|
} while (page < totalPages);
|
|
|
|
// Read previous findings BEFORE updating the cache (they'll be overwritten)
|
|
let previousFindings = [];
|
|
try {
|
|
const state = await readState(db);
|
|
previousFindings = state.findings || [];
|
|
} catch (err) {
|
|
console.error('[Ivanti Findings] Failed to read previous findings for archive detection:', err.message);
|
|
}
|
|
|
|
await dbRun(db,
|
|
`UPDATE ivanti_findings_cache SET total=?, findings_json=?, synced_at=datetime('now'), sync_status='success', error_message=NULL WHERE id=1`,
|
|
[allFindings.length, JSON.stringify(allFindings)]
|
|
);
|
|
|
|
console.log(`[Ivanti Findings] Sync complete — ${allFindings.length} findings`);
|
|
|
|
// Archive detection — compare previous vs current to detect disappeared/returned findings
|
|
// Only runs after a successful sync (skipped on error per requirement 1.5)
|
|
try {
|
|
await detectArchiveChanges(db, previousFindings, allFindings);
|
|
} catch (err) {
|
|
console.error('[Ivanti Findings] Archive detection failed (non-fatal):', err.message);
|
|
}
|
|
|
|
await syncClosedCount(db, allFindings.length, apiKey, clientId, skipTls);
|
|
await syncFPWorkflowCounts(db, allFindings, apiKey, clientId, skipTls);
|
|
} catch (err) {
|
|
const msg = err.message || 'Unknown error';
|
|
console.error('[Ivanti Findings] Sync failed:', msg);
|
|
// Archive detection is intentionally skipped on sync error (requirement 1.5)
|
|
await dbRun(db, `UPDATE ivanti_findings_cache SET sync_status='error', error_message=?, synced_at=datetime('now') WHERE id=1`, [msg]);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Scheduler
|
|
// ---------------------------------------------------------------------------
|
|
function scheduleSync(db) {
|
|
db.get('SELECT synced_at FROM ivanti_findings_cache WHERE id = 1', (err, row) => {
|
|
if (err || !row || !row.synced_at) {
|
|
syncFindings(db);
|
|
} else {
|
|
const lastSync = new Date(row.synced_at.replace(' ', 'T') + 'Z');
|
|
const hoursSince = (Date.now() - lastSync.getTime()) / (1000 * 60 * 60);
|
|
if (hoursSince >= 24) {
|
|
syncFindings(db);
|
|
} else {
|
|
console.log(`[Ivanti Findings] Last sync ${hoursSince.toFixed(1)}h ago — next auto-sync in ${(24 - hoursSince).toFixed(1)}h`);
|
|
}
|
|
}
|
|
});
|
|
|
|
setInterval(() => syncFindings(db), SYNC_INTERVAL_MS);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// DB helpers
|
|
// ---------------------------------------------------------------------------
|
|
function dbRun(db, sql, params = []) {
|
|
return new Promise((resolve, reject) => {
|
|
db.run(sql, params, function (err) { if (err) reject(err); else resolve(this); });
|
|
});
|
|
}
|
|
|
|
function dbGet(db, sql, params = []) {
|
|
return new Promise((resolve, reject) => {
|
|
db.get(sql, params, (err, row) => { if (err) reject(err); else resolve(row); });
|
|
});
|
|
}
|
|
|
|
function dbAll(db, sql, params = []) {
|
|
return new Promise((resolve, reject) => {
|
|
db.all(sql, params, (err, rows) => { if (err) reject(err); else resolve(rows || []); });
|
|
});
|
|
}
|
|
|
|
function readState(db) {
|
|
return new Promise((resolve, reject) => {
|
|
db.get(
|
|
'SELECT total, findings_json, synced_at, sync_status, error_message FROM ivanti_findings_cache WHERE id = 1',
|
|
(err, row) => {
|
|
if (err) return reject(err);
|
|
if (!row) return resolve({ total: 0, findings: [], synced_at: null, sync_status: 'never', error_message: null });
|
|
let findings = [];
|
|
try { findings = JSON.parse(row.findings_json || '[]'); } catch (_) { /* leave empty */ }
|
|
resolve({ total: row.total || 0, findings, synced_at: row.synced_at, sync_status: row.sync_status, error_message: row.error_message });
|
|
}
|
|
);
|
|
});
|
|
}
|
|
|
|
function readNotes(db) {
|
|
return new Promise((resolve, reject) => {
|
|
db.all('SELECT finding_id, note FROM ivanti_finding_notes', (err, rows) => {
|
|
if (err) return reject(err);
|
|
const map = {};
|
|
(rows || []).forEach((r) => { map[r.finding_id] = r.note; });
|
|
resolve(map);
|
|
});
|
|
});
|
|
}
|
|
|
|
function readCounts(db) {
|
|
return new Promise((resolve, reject) => {
|
|
db.get(
|
|
'SELECT open_count, closed_count, synced_at FROM ivanti_counts_cache WHERE id = 1',
|
|
(err, row) => {
|
|
if (err) return reject(err);
|
|
resolve({
|
|
open: row?.open_count ?? 0,
|
|
closed: row?.closed_count ?? 0,
|
|
synced_at: row?.synced_at ?? null,
|
|
});
|
|
}
|
|
);
|
|
});
|
|
}
|
|
|
|
// Returns { findingId: { hostName: 'override', dns: 'override' }, ... }
|
|
function readOverrides(db) {
|
|
return new Promise((resolve, reject) => {
|
|
db.all('SELECT finding_id, field, value FROM ivanti_finding_overrides', (err, rows) => {
|
|
if (err) return reject(err);
|
|
const map = {};
|
|
(rows || []).forEach((r) => {
|
|
if (!map[r.finding_id]) map[r.finding_id] = {};
|
|
map[r.finding_id][r.field] = r.value;
|
|
});
|
|
resolve(map);
|
|
});
|
|
});
|
|
}
|
|
|
|
async function readStateWithNotes(db) {
|
|
const [state, notes, overrides] = await Promise.all([readState(db), readNotes(db), readOverrides(db)]);
|
|
state.findings = state.findings.map((f) => ({
|
|
...f,
|
|
note: notes[f.id] || '',
|
|
overrides: overrides[f.id] || {},
|
|
}));
|
|
return state;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Router
|
|
// ---------------------------------------------------------------------------
|
|
function createIvantiFindingsRouter(db, requireAuth) {
|
|
const router = express.Router();
|
|
|
|
Promise.all([initTables(db), initArchiveTables(db)])
|
|
.then(() => scheduleSync(db))
|
|
.catch((err) => console.error('[Ivanti Findings] Init failed:', err));
|
|
|
|
router.use(requireAuth(db));
|
|
|
|
// GET / — cached findings with notes merged in
|
|
router.get('/', async (req, res) => {
|
|
try {
|
|
res.json(await readStateWithNotes(db));
|
|
} catch {
|
|
res.status(500).json({ error: 'Database error reading findings' });
|
|
}
|
|
});
|
|
|
|
// POST /sync — trigger immediate sync, return fresh state
|
|
router.post('/sync', async (req, res) => {
|
|
await syncFindings(db);
|
|
try {
|
|
res.json(await readStateWithNotes(db));
|
|
} catch {
|
|
res.status(500).json({ error: 'Sync ran but could not read updated state' });
|
|
}
|
|
});
|
|
|
|
// GET /counts — open vs closed totals for pie chart
|
|
router.get('/counts', async (req, res) => {
|
|
try {
|
|
res.json(await readCounts(db));
|
|
} catch {
|
|
res.status(500).json({ error: 'Database error reading counts' });
|
|
}
|
|
});
|
|
|
|
// GET /counts/history — last snapshot per day, ascending, for the trend chart.
|
|
// Uses a window function (ROW_NUMBER) to pick the final sync of each calendar day.
|
|
router.get('/counts/history', async (req, res) => {
|
|
try {
|
|
const rows = await new Promise((resolve, reject) => {
|
|
db.all(
|
|
`SELECT date, open_count, closed_count FROM (
|
|
SELECT DATE(recorded_at) AS date,
|
|
open_count, closed_count,
|
|
ROW_NUMBER() OVER (
|
|
PARTITION BY DATE(recorded_at)
|
|
ORDER BY recorded_at DESC
|
|
) AS rn
|
|
FROM ivanti_counts_history
|
|
) WHERE rn = 1
|
|
ORDER BY date ASC`,
|
|
[],
|
|
(err, rows) => { if (err) reject(err); else resolve(rows || []); }
|
|
);
|
|
});
|
|
res.json({ history: rows });
|
|
} catch (err) {
|
|
console.error('[Ivanti Findings] GET /counts/history error:', err.message);
|
|
res.status(500).json({ error: 'Database error' });
|
|
}
|
|
});
|
|
|
|
// GET /fp-workflow-counts — FP finding + unique workflow counts (open + closed)
|
|
router.get('/fp-workflow-counts', async (req, res) => {
|
|
try {
|
|
const row = await new Promise((resolve, reject) => {
|
|
db.get('SELECT fp_workflow_counts_json, fp_id_counts_json FROM ivanti_counts_cache WHERE id=1',
|
|
(err, row) => { if (err) reject(err); else resolve(row); }
|
|
);
|
|
});
|
|
let findingCounts = {};
|
|
let idCounts = {};
|
|
try { findingCounts = JSON.parse(row?.fp_workflow_counts_json || '{}'); } catch (_) {}
|
|
try { idCounts = JSON.parse(row?.fp_id_counts_json || '{}'); } catch (_) {}
|
|
res.json({
|
|
findingCounts,
|
|
findingTotal: Object.values(findingCounts).reduce((a, b) => a + b, 0),
|
|
idCounts,
|
|
idTotal: Object.values(idCounts).reduce((a, b) => a + b, 0),
|
|
});
|
|
} catch {
|
|
res.status(500).json({ error: 'Database error reading FP workflow counts' });
|
|
}
|
|
});
|
|
|
|
// PUT /:findingId/override — save or clear a field override (editor/admin only)
|
|
const OVERRIDE_ALLOWED = ['hostName', 'dns'];
|
|
router.put('/:findingId/override', requireRole('editor', 'admin'), (req, res) => {
|
|
const { findingId } = req.params;
|
|
const { field, value } = req.body;
|
|
|
|
if (!OVERRIDE_ALLOWED.includes(field)) {
|
|
return res.status(400).json({ error: `Field '${field}' is not editable. Allowed: ${OVERRIDE_ALLOWED.join(', ')}` });
|
|
}
|
|
|
|
const val = String(value ?? '').trim();
|
|
|
|
if (val === '') {
|
|
// Empty value = clear the override (revert to Ivanti)
|
|
db.run(
|
|
'DELETE FROM ivanti_finding_overrides WHERE finding_id = ? AND field = ?',
|
|
[findingId, field],
|
|
(err) => {
|
|
if (err) return res.status(500).json({ error: 'Failed to clear override' });
|
|
res.json({ finding_id: findingId, field, value: null });
|
|
}
|
|
);
|
|
} else {
|
|
db.run(
|
|
`INSERT INTO ivanti_finding_overrides (finding_id, field, value, updated_at)
|
|
VALUES (?, ?, ?, datetime('now'))
|
|
ON CONFLICT(finding_id, field) DO UPDATE SET value=excluded.value, updated_at=datetime('now')`,
|
|
[findingId, field, val],
|
|
(err) => {
|
|
if (err) return res.status(500).json({ error: 'Failed to save override' });
|
|
res.json({ finding_id: findingId, field, value: val });
|
|
}
|
|
);
|
|
}
|
|
});
|
|
|
|
// PUT /:findingId/note — save or update a note (max 255 chars enforced here)
|
|
router.put('/:findingId/note', (req, res) => {
|
|
const { findingId } = req.params;
|
|
const note = String(req.body.note || '').slice(0, 255);
|
|
|
|
db.run(
|
|
`INSERT INTO ivanti_finding_notes (finding_id, note, updated_at)
|
|
VALUES (?, ?, datetime('now'))
|
|
ON CONFLICT(finding_id) DO UPDATE SET note=excluded.note, updated_at=datetime('now')`,
|
|
[findingId, note],
|
|
(err) => {
|
|
if (err) return res.status(500).json({ error: 'Failed to save note' });
|
|
res.json({ finding_id: findingId, note });
|
|
}
|
|
);
|
|
});
|
|
|
|
return router;
|
|
}
|
|
|
|
module.exports = createIvantiFindingsRouter;
|
|
module.exports.detectArchiveChanges = detectArchiveChanges;
|
|
module.exports.detectClosedFindings = detectClosedFindings;
|
|
module.exports.initArchiveTables = initArchiveTables;
|