Add sync anomaly detection, BU drift monitoring, and findings count investigation
- Add BU drift checker that classifies archived findings as BU reassignment, severity drift, closure, or decommission via unfiltered Ivanti API queries - Add post-sync anomaly summary with significance threshold and classification breakdown stored in ivanti_sync_anomaly_log table - Add per-finding BU tracking that detects BU changes across syncs and records them in ivanti_finding_bu_history table - Add drift guard that skips trend history writes when total drops more than 50% - Add CLOSED_GONE archive state for findings that vanish from the closed set - Add anomaly banner UI on Vulnerability Triage page for significant sync changes - Add API endpoints for anomaly latest/history and BU change tracking - Add diagnostic scripts for drift checking and BU reassignment verification - Add investigation document and xlsx export for the April 2026 BU reassignment incident where 109 findings were moved to SDIT-CSD-ITLS-PIES - Migrations required: add_closed_gone_state.js, add_sync_anomaly_tables.js
This commit is contained in:
@@ -168,7 +168,7 @@ function initArchiveTables(db) {
|
||||
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')),
|
||||
current_state TEXT NOT NULL CHECK(current_state IN ('ARCHIVED','RETURNED','CLOSED','CLOSED_GONE')),
|
||||
last_severity REAL NOT NULL DEFAULT 0,
|
||||
first_archived_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
last_transition_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
@@ -305,7 +305,24 @@ async function detectArchiveChanges(db, previousFindings, currentFindings) {
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[Archive Detection] Processed ${disappearedIds.length} disappeared, checked ${currentIdsList.length} current findings against archive`);
|
||||
// Count returned findings for anomaly summary
|
||||
let returnedCount = 0;
|
||||
if (currentIdsList.length > 0) {
|
||||
try {
|
||||
// Count how many ARCHIVED records transitioned to RETURNED in this cycle
|
||||
// (already handled above, just count them)
|
||||
const archivedForCount = await dbAll(db,
|
||||
`SELECT id, finding_id FROM ivanti_finding_archives WHERE current_state = 'RETURNED' AND last_transition_at >= datetime('now', '-1 minute')`
|
||||
);
|
||||
returnedCount = archivedForCount.length;
|
||||
} catch (err) {
|
||||
// Non-fatal — returnedCount stays 0
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[Archive Detection] Processed ${disappearedIds.length} disappeared, ${returnedCount} returned, checked ${currentIdsList.length} current findings against archive`);
|
||||
|
||||
return { disappearedIds, returnedCount };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -350,6 +367,54 @@ async function detectClosedFindings(db, closedFindingIds) {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Closed-gone detection — find archive CLOSED findings that vanished from the
|
||||
// Ivanti closed API set. These are findings we previously confirmed as closed
|
||||
// but that no longer appear in the closed results (likely VRR rescore below
|
||||
// the severity threshold).
|
||||
// ---------------------------------------------------------------------------
|
||||
async function detectClosedGoneFindings(db, closedFindingIds) {
|
||||
if (!closedFindingIds) return;
|
||||
|
||||
const closedSet = new Set(closedFindingIds.map(String));
|
||||
|
||||
try {
|
||||
// Get all findings we previously marked as CLOSED in the archive
|
||||
const records = await dbAll(db,
|
||||
`SELECT id, finding_id, last_severity FROM ivanti_finding_archives WHERE current_state = 'CLOSED'`
|
||||
);
|
||||
|
||||
let goneCount = 0;
|
||||
for (const record of records) {
|
||||
// If this finding is still in the closed API set, it's fine
|
||||
if (closedSet.has(record.finding_id)) continue;
|
||||
|
||||
try {
|
||||
await dbRun(db,
|
||||
`UPDATE ivanti_finding_archives
|
||||
SET current_state = 'CLOSED_GONE', last_transition_at = datetime('now')
|
||||
WHERE id = ?`,
|
||||
[record.id]
|
||||
);
|
||||
await dbRun(db,
|
||||
`INSERT INTO ivanti_archive_transitions (archive_id, from_state, to_state, severity_at_transition, reason, transitioned_at)
|
||||
VALUES (?, 'CLOSED', 'CLOSED_GONE', ?, 'disappeared_from_closed_set', datetime('now'))`,
|
||||
[record.id, record.last_severity || 0]
|
||||
);
|
||||
goneCount++;
|
||||
} catch (err) {
|
||||
console.error(`[Archive Detection] Error marking finding ${record.finding_id} as CLOSED_GONE:`, err.message);
|
||||
}
|
||||
}
|
||||
|
||||
if (goneCount > 0) {
|
||||
console.warn(`[Archive Detection] ${goneCount} previously-closed findings disappeared from the Ivanti closed set (CLOSED → CLOSED_GONE)`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Archive Detection] Error in closed-gone detection:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Extract only the fields we need from a raw finding object
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -461,14 +526,36 @@ async function syncClosedCount(db, openCount, apiKey, clientId, skipTls) {
|
||||
[openCount, closedCount]
|
||||
);
|
||||
|
||||
// Drift guard — if the new total (open+closed) drops by more than 50%
|
||||
// compared to the most recent history snapshot, skip writing to history.
|
||||
// This prevents partial API responses from corrupting the trend chart.
|
||||
const newTotal = openCount + closedCount;
|
||||
let skipHistory = false;
|
||||
try {
|
||||
const prev = await dbGet(db,
|
||||
`SELECT open_count, closed_count FROM ivanti_counts_history ORDER BY recorded_at DESC LIMIT 1`
|
||||
);
|
||||
if (prev) {
|
||||
const prevTotal = (prev.open_count || 0) + (prev.closed_count || 0);
|
||||
if (prevTotal > 0 && newTotal < prevTotal * 0.5) {
|
||||
console.warn(`[Ivanti Findings] Drift guard triggered — new total ${newTotal} is <50% of previous ${prevTotal}. Skipping history write.`);
|
||||
skipHistory = true;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Ivanti Findings] Drift guard check failed (non-fatal):', err.message);
|
||||
}
|
||||
|
||||
// Append a snapshot to history — every sync is stored; the history
|
||||
// endpoint aggregates to last-per-day at query time (Option B).
|
||||
await dbRun(db,
|
||||
`INSERT INTO ivanti_counts_history (open_count, closed_count) VALUES (?, ?)`,
|
||||
[openCount, closedCount]
|
||||
);
|
||||
if (!skipHistory) {
|
||||
await dbRun(db,
|
||||
`INSERT INTO ivanti_counts_history (open_count, closed_count) VALUES (?, ?)`,
|
||||
[openCount, closedCount]
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`[Ivanti Findings] Counts updated — open: ${openCount}, closed: ${closedCount}`);
|
||||
console.log(`[Ivanti Findings] Counts updated — open: ${openCount}, closed: ${closedCount}${skipHistory ? ' (history write skipped — drift guard)' : ''}`);
|
||||
|
||||
// Detect closed findings in the archive — wrap in try/catch so errors don't break sync
|
||||
try {
|
||||
@@ -476,6 +563,13 @@ async function syncClosedCount(db, openCount, apiKey, clientId, skipTls) {
|
||||
} catch (err) {
|
||||
console.error('[Ivanti Findings] Closed finding archive detection failed (non-fatal):', err.message);
|
||||
}
|
||||
|
||||
// Detect findings that vanished from the closed set — CLOSED → CLOSED_GONE
|
||||
try {
|
||||
await detectClosedGoneFindings(db, closedFindingIds);
|
||||
} catch (err) {
|
||||
console.error('[Ivanti Findings] Closed-gone detection failed (non-fatal):', err.message);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Ivanti Findings] Failed to fetch closed count:', err.message);
|
||||
// Still update open count so it stays in sync; leave closed_count as-is
|
||||
@@ -637,6 +731,29 @@ async function syncFindings(db) {
|
||||
console.error('[Ivanti Findings] Failed to read previous findings for archive detection:', err.message);
|
||||
}
|
||||
|
||||
// Per-finding BU comparison — detect BU changes across syncs (Task 5.1)
|
||||
try {
|
||||
const previousMap = new Map(previousFindings.map(f => [String(f.id), f]));
|
||||
for (const finding of allFindings) {
|
||||
try {
|
||||
const prev = previousMap.get(String(finding.id));
|
||||
if (prev && prev.buOwnership && finding.buOwnership && prev.buOwnership !== finding.buOwnership) {
|
||||
await dbRun(db,
|
||||
`INSERT INTO ivanti_finding_bu_history (finding_id, finding_title, host_name, previous_bu, new_bu, detected_at)
|
||||
VALUES (?, ?, ?, ?, ?, datetime('now'))`,
|
||||
[String(finding.id), finding.title || '', finding.hostName || '', prev.buOwnership, finding.buOwnership]
|
||||
);
|
||||
console.log(`[BU Tracking] Finding ${finding.id} BU changed: ${prev.buOwnership} → ${finding.buOwnership}`);
|
||||
}
|
||||
// First-time findings (no prev entry) — store BU without recording a change event
|
||||
} catch (err) {
|
||||
console.error(`[BU Tracking] Error recording BU change for finding ${finding.id}:`, err.message);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[BU Tracking] BU comparison failed (non-fatal):', err.message);
|
||||
}
|
||||
|
||||
await dbRun(db,
|
||||
`UPDATE ivanti_findings_cache SET total=?, findings_json=?, synced_at=datetime('now'), sync_status='success', error_message=NULL WHERE id=1`,
|
||||
[allFindings.length, JSON.stringify(allFindings)]
|
||||
@@ -646,14 +763,60 @@ async function syncFindings(db) {
|
||||
|
||||
// Archive detection — compare previous vs current to detect disappeared/returned findings
|
||||
// Only runs after a successful sync (skipped on error per requirement 1.5)
|
||||
let archiveResult = { disappearedIds: [], returnedCount: 0 };
|
||||
try {
|
||||
await detectArchiveChanges(db, previousFindings, allFindings);
|
||||
archiveResult = await detectArchiveChanges(db, previousFindings, allFindings) || { disappearedIds: [], returnedCount: 0 };
|
||||
} catch (err) {
|
||||
console.error('[Ivanti Findings] Archive detection failed (non-fatal):', err.message);
|
||||
}
|
||||
|
||||
// Read previous counts BEFORE syncClosedCount updates them — needed for anomaly deltas
|
||||
let previousOpenCount = 0;
|
||||
let previousClosedCount = 0;
|
||||
try {
|
||||
const prevCounts = await dbGet(db,
|
||||
`SELECT open_count, closed_count FROM ivanti_counts_cache WHERE id = 1`
|
||||
);
|
||||
if (prevCounts) {
|
||||
previousOpenCount = prevCounts.open_count || 0;
|
||||
previousClosedCount = prevCounts.closed_count || 0;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Ivanti Findings] Failed to read previous counts for anomaly summary (non-fatal):', err.message);
|
||||
}
|
||||
|
||||
await syncClosedCount(db, allFindings.length, apiKey, clientId, skipTls);
|
||||
await syncFPWorkflowCounts(db, allFindings, apiKey, clientId, skipTls);
|
||||
|
||||
// Post-sync: BU drift checker for newly archived findings
|
||||
let classificationBreakdown = { bu_reassignment: 0, severity_drift: 0, closed_on_platform: 0, decommissioned: 0 };
|
||||
try {
|
||||
classificationBreakdown = await runBUDriftChecker(db, archiveResult.disappearedIds, apiKey, clientId, skipTls);
|
||||
} catch (err) {
|
||||
console.error('[Ivanti Findings] BU drift checker failed (non-fatal):', err.message);
|
||||
}
|
||||
|
||||
// Post-sync: Compute and store anomaly summary
|
||||
try {
|
||||
const currentCounts = await dbGet(db,
|
||||
`SELECT open_count, closed_count FROM ivanti_counts_cache WHERE id = 1`
|
||||
);
|
||||
const currentOpenCount = currentCounts?.open_count || 0;
|
||||
const currentClosedCount = currentCounts?.closed_count || 0;
|
||||
const openCountDelta = currentOpenCount - previousOpenCount;
|
||||
const closedCountDelta = currentClosedCount - previousClosedCount;
|
||||
|
||||
await computeAnomalySummary(
|
||||
db,
|
||||
openCountDelta,
|
||||
closedCountDelta,
|
||||
archiveResult.disappearedIds.length,
|
||||
archiveResult.returnedCount,
|
||||
classificationBreakdown
|
||||
);
|
||||
} 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);
|
||||
@@ -771,6 +934,151 @@ async function readStateWithNotes(db) {
|
||||
return state;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// BU Drift Checker — post-sync classification of newly archived findings
|
||||
// ---------------------------------------------------------------------------
|
||||
const EXPECTED_BUS = new Set(['NTS-AEO-ACCESS-ENG', 'NTS-AEO-STEAM']);
|
||||
|
||||
async function runBUDriftChecker(db, newlyArchivedIds, apiKey, clientId, skipTls) {
|
||||
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;
|
||||
|
||||
// Collect all API results across batches
|
||||
const foundMap = new Map();
|
||||
|
||||
for (let i = 0; i < newlyArchivedIds.length; i += chunkSize) {
|
||||
const chunk = newlyArchivedIds.slice(i, i + chunkSize);
|
||||
const idList = chunk.join(',');
|
||||
|
||||
try {
|
||||
const filters = [
|
||||
{
|
||||
field: 'id',
|
||||
exclusive: false,
|
||||
operator: 'IN',
|
||||
orWithPrevious: false,
|
||||
implicitFilters: [],
|
||||
value: idList,
|
||||
caseSensitive: false
|
||||
}
|
||||
];
|
||||
|
||||
let page = 0;
|
||||
let totalPages = 1;
|
||||
|
||||
do {
|
||||
const body = {
|
||||
filters,
|
||||
projection: 'internal',
|
||||
sort: [{ field: 'severity', direction: 'ASC' }],
|
||||
page,
|
||||
size: 100
|
||||
};
|
||||
|
||||
const result = await ivantiPost(urlPath, body, apiKey, skipTls);
|
||||
if (result.status !== 200) {
|
||||
console.error(`[BU Drift Checker] API returned status ${result.status} for batch starting at index ${i}`);
|
||||
break;
|
||||
}
|
||||
|
||||
const data = JSON.parse(result.body);
|
||||
totalPages = data.page?.totalPages || 1;
|
||||
const findings = data._embedded?.hostFindings || [];
|
||||
|
||||
for (const f of findings) {
|
||||
const bu = f.assetCustomAttributes?.['1550_host_1']?.[0] || 'UNKNOWN';
|
||||
const severity = typeof f.severity === 'number' ? f.severity : parseFloat(f.severity) || 0;
|
||||
const state = f.status || f.generic_state || '';
|
||||
foundMap.set(String(f.id), { bu, severity, state });
|
||||
}
|
||||
|
||||
page++;
|
||||
} while (page < totalPages);
|
||||
|
||||
console.log(`[BU Drift Checker] Batch ${Math.floor(i / chunkSize) + 1}: queried ${chunk.length} IDs, found ${foundMap.size} so far`);
|
||||
} catch (err) {
|
||||
console.error(`[BU Drift Checker] Error querying batch at index ${i}:`, err.message);
|
||||
// Skip failed batch, continue with remaining
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
// BU matches, severity >= 8.5, not closed — unexpected, leave as default
|
||||
classification = 'decommissioned';
|
||||
reason = 'decommissioned';
|
||||
}
|
||||
|
||||
summary[classification] = (summary[classification] || 0) + 1;
|
||||
|
||||
// Update the most recent archive transition reason for this finding
|
||||
try {
|
||||
const archive = await dbGet(db,
|
||||
`SELECT id FROM ivanti_finding_archives WHERE finding_id = ?`,
|
||||
[id]
|
||||
);
|
||||
if (archive) {
|
||||
await dbRun(db,
|
||||
`UPDATE ivanti_archive_transitions SET reason = ?
|
||||
WHERE archive_id = ? AND id = (
|
||||
SELECT id FROM ivanti_archive_transitions
|
||||
WHERE archive_id = ? ORDER BY transitioned_at DESC LIMIT 1
|
||||
)`,
|
||||
[reason, archive.id, archive.id]
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[BU Drift Checker] Error updating transition reason for finding ${id}:`, err.message);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[BU Drift Checker] Classification complete:`, summary);
|
||||
return summary;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Anomaly Summary — compute and store post-sync anomaly report
|
||||
// ---------------------------------------------------------------------------
|
||||
async function computeAnomalySummary(db, openCountDelta, closedCountDelta, newlyArchivedCount, returnedCount, classificationBreakdown) {
|
||||
try {
|
||||
const isSignificant = newlyArchivedCount > 5 ? 1 : 0;
|
||||
const classificationJson = JSON.stringify(classificationBreakdown || {});
|
||||
|
||||
await dbRun(db,
|
||||
`INSERT INTO ivanti_sync_anomaly_log
|
||||
(sync_timestamp, open_count_delta, closed_count_delta, newly_archived_count, returned_count, classification_json, is_significant)
|
||||
VALUES (datetime('now'), ?, ?, ?, ?, ?, ?)`,
|
||||
[openCountDelta, closedCountDelta, newlyArchivedCount, returnedCount, classificationJson, 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);
|
||||
} catch (err) {
|
||||
console.error('[Anomaly Summary] Failed to write anomaly summary (non-fatal):', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Router
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -898,6 +1206,152 @@ function createIvantiFindingsRouter(db, requireAuth) {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/ivanti/findings/anomaly/latest
|
||||
*
|
||||
* Return the most recent anomaly summary row from ivanti_sync_anomaly_log.
|
||||
* The classification_json column is parsed into an object in the response.
|
||||
*
|
||||
* @returns {Object} 200 - { anomaly: Object|null }
|
||||
* @returns {Object} 500 - { error: string } on database error
|
||||
*/
|
||||
router.get('/anomaly/latest', async (req, res) => {
|
||||
try {
|
||||
const row = await dbGet(db,
|
||||
`SELECT id, sync_timestamp, open_count_delta, closed_count_delta,
|
||||
newly_archived_count, returned_count, classification_json, is_significant
|
||||
FROM ivanti_sync_anomaly_log
|
||||
ORDER BY sync_timestamp DESC LIMIT 1`
|
||||
);
|
||||
if (!row) return res.json({ anomaly: null });
|
||||
let classification = {};
|
||||
try { classification = JSON.parse(row.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,
|
||||
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
|
||||
* (ISO date strings) for date-range filtering (inclusive). If neither is
|
||||
* provided, returns the last 30 rows ordered by sync_timestamp descending.
|
||||
*
|
||||
* @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) {
|
||||
rows = await dbAll(db,
|
||||
`SELECT sync_timestamp, open_count_delta, closed_count_delta,
|
||||
newly_archived_count, returned_count, classification_json, is_significant
|
||||
FROM ivanti_sync_anomaly_log
|
||||
WHERE sync_timestamp >= ? AND sync_timestamp <= ?
|
||||
ORDER BY sync_timestamp DESC`,
|
||||
[from, to]
|
||||
);
|
||||
} else {
|
||||
rows = await dbAll(db,
|
||||
`SELECT sync_timestamp, open_count_delta, closed_count_delta,
|
||||
newly_archived_count, returned_count, classification_json, is_significant
|
||||
FROM ivanti_sync_anomaly_log
|
||||
ORDER BY sync_timestamp DESC LIMIT 30`
|
||||
);
|
||||
}
|
||||
|
||||
const history = rows.map(row => {
|
||||
let classification = {};
|
||||
try { classification = JSON.parse(row.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,
|
||||
is_significant: !!row.is_significant
|
||||
};
|
||||
});
|
||||
|
||||
res.json({ history });
|
||||
} catch (err) {
|
||||
console.error('[Ivanti Findings] GET /anomaly/history error:', err.message);
|
||||
res.status(500).json({ error: 'Database error reading anomaly history' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/ivanti/findings/bu-changes
|
||||
*
|
||||
* Return all BU change events from ivanti_finding_bu_history,
|
||||
* ordered by detected_at descending (newest first).
|
||||
*
|
||||
* @returns {Object} 200 - { changes: Array<Object> }
|
||||
* @returns {Object} 500 - { error: string } on database error
|
||||
*/
|
||||
router.get('/bu-changes', async (req, res) => {
|
||||
try {
|
||||
const rows = await dbAll(db,
|
||||
`SELECT id, finding_id, finding_title, host_name, previous_bu, new_bu, detected_at
|
||||
FROM ivanti_finding_bu_history
|
||||
ORDER BY detected_at DESC`
|
||||
);
|
||||
res.json({ changes: rows });
|
||||
} catch (err) {
|
||||
console.error('[Ivanti Findings] GET /bu-changes error:', err.message);
|
||||
res.status(500).json({ error: 'Database error reading BU changes' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/ivanti/findings/:findingId/bu-history
|
||||
*
|
||||
* Return BU change history for a specific finding from ivanti_finding_bu_history,
|
||||
* ordered by detected_at descending (newest first).
|
||||
*
|
||||
* @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 dbAll(db,
|
||||
`SELECT previous_bu, new_bu, detected_at
|
||||
FROM ivanti_finding_bu_history
|
||||
WHERE finding_id = ?
|
||||
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
|
||||
*
|
||||
@@ -982,3 +1436,6 @@ module.exports = createIvantiFindingsRouter;
|
||||
module.exports.detectArchiveChanges = detectArchiveChanges;
|
||||
module.exports.detectClosedFindings = detectClosedFindings;
|
||||
module.exports.initArchiveTables = initArchiveTables;
|
||||
module.exports.runBUDriftChecker = runBUDriftChecker;
|
||||
module.exports.computeAnomalySummary = computeAnomalySummary;
|
||||
module.exports.extractFinding = extractFinding;
|
||||
|
||||
Reference in New Issue
Block a user