feat: add return classification for archive chart, CARD API integration, compliance charts, systemd services
This commit is contained in:
@@ -275,6 +275,7 @@ async function detectArchiveChanges(db, previousFindings, currentFindings) {
|
||||
|
||||
// 2. Returned findings: in current AND has existing ARCHIVED record → RETURNED
|
||||
const currentIdsList = [...currentIds];
|
||||
const returnedArchiveIds = []; // track archive IDs of returned findings for classification
|
||||
if (currentIdsList.length > 0) {
|
||||
try {
|
||||
const archivedRecords = await dbAll(db,
|
||||
@@ -297,6 +298,7 @@ async function detectArchiveChanges(db, previousFindings, currentFindings) {
|
||||
VALUES (?, 'ARCHIVED', 'RETURNED', ?, 'reappeared_in_sync', datetime('now'))`,
|
||||
[record.id, severity]
|
||||
);
|
||||
returnedArchiveIds.push(record.id);
|
||||
console.log(`[Archive Detection] Finding ${record.finding_id} returned (ARCHIVED → RETURNED)`);
|
||||
}
|
||||
}
|
||||
@@ -306,23 +308,38 @@ async function detectArchiveChanges(db, previousFindings, currentFindings) {
|
||||
}
|
||||
|
||||
// Count returned findings for anomaly summary
|
||||
let returnedCount = 0;
|
||||
if (currentIdsList.length > 0) {
|
||||
let returnedCount = returnedArchiveIds.length;
|
||||
|
||||
// Classify returned findings by looking up the reason they were originally archived.
|
||||
// This tells us *why* they came back (e.g., BU reassignment back to team).
|
||||
const returnClassification = { bu_reassignment: 0, severity_drift: 0, closed_on_platform: 0, decommissioned: 0 };
|
||||
for (const archiveId of returnedArchiveIds) {
|
||||
try {
|
||||
// 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')`
|
||||
// Find the most recent ARCHIVED transition reason for this archive record
|
||||
const transition = await dbGet(db,
|
||||
`SELECT reason FROM ivanti_archive_transitions
|
||||
WHERE archive_id = ? AND to_state = 'ARCHIVED'
|
||||
ORDER BY transitioned_at DESC LIMIT 1`,
|
||||
[archiveId]
|
||||
);
|
||||
returnedCount = archivedForCount.length;
|
||||
if (transition && transition.reason) {
|
||||
// Reason format is either a plain key or "key:detail" (e.g., "bu_reassignment:SOME-BU")
|
||||
const reasonKey = transition.reason.split(':')[0];
|
||||
if (reasonKey in returnClassification) {
|
||||
returnClassification[reasonKey]++;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// Non-fatal — returnedCount stays 0
|
||||
// Non-fatal — skip this finding's classification
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[Archive Detection] Processed ${disappearedIds.length} disappeared, ${returnedCount} returned, checked ${currentIdsList.length} current findings against archive`);
|
||||
if (returnedCount > 0) {
|
||||
console.log(`[Archive Detection] Return classification:`, returnClassification);
|
||||
}
|
||||
|
||||
return { disappearedIds, returnedCount };
|
||||
return { disappearedIds, returnedCount, returnClassification };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -763,9 +780,9 @@ 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 };
|
||||
let archiveResult = { disappearedIds: [], returnedCount: 0, returnClassification: {} };
|
||||
try {
|
||||
archiveResult = await detectArchiveChanges(db, previousFindings, allFindings) || { disappearedIds: [], returnedCount: 0 };
|
||||
archiveResult = await detectArchiveChanges(db, previousFindings, allFindings) || { disappearedIds: [], returnedCount: 0, returnClassification: {} };
|
||||
} catch (err) {
|
||||
console.error('[Ivanti Findings] Archive detection failed (non-fatal):', err.message);
|
||||
}
|
||||
@@ -812,7 +829,8 @@ async function syncFindings(db) {
|
||||
closedCountDelta,
|
||||
archiveResult.disappearedIds.length,
|
||||
archiveResult.returnedCount,
|
||||
classificationBreakdown
|
||||
classificationBreakdown,
|
||||
archiveResult.returnClassification || {}
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('[Ivanti Findings] Anomaly summary failed (non-fatal):', err.message);
|
||||
@@ -1060,20 +1078,24 @@ async function runBUDriftChecker(db, newlyArchivedIds, apiKey, clientId, skipTls
|
||||
// ---------------------------------------------------------------------------
|
||||
// Anomaly Summary — compute and store post-sync anomaly report
|
||||
// ---------------------------------------------------------------------------
|
||||
async function computeAnomalySummary(db, openCountDelta, closedCountDelta, newlyArchivedCount, returnedCount, classificationBreakdown) {
|
||||
async function computeAnomalySummary(db, openCountDelta, closedCountDelta, newlyArchivedCount, returnedCount, classificationBreakdown, returnClassificationBreakdown) {
|
||||
try {
|
||||
const isSignificant = newlyArchivedCount > 5 ? 1 : 0;
|
||||
const classificationJson = JSON.stringify(classificationBreakdown || {});
|
||||
const returnClassificationJson = JSON.stringify(returnClassificationBreakdown || {});
|
||||
|
||||
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]
|
||||
(sync_timestamp, open_count_delta, closed_count_delta, newly_archived_count, returned_count, classification_json, return_classification_json, is_significant)
|
||||
VALUES (datetime('now'), ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[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);
|
||||
}
|
||||
@@ -1219,13 +1241,15 @@ function createIvantiFindingsRouter(db, requireAuth) {
|
||||
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
|
||||
newly_archived_count, returned_count, classification_json, return_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 (_) {}
|
||||
let return_classification = {};
|
||||
try { return_classification = JSON.parse(row.return_classification_json || '{}'); } catch (_) {}
|
||||
res.json({
|
||||
anomaly: {
|
||||
id: row.id,
|
||||
@@ -1235,6 +1259,7 @@ function createIvantiFindingsRouter(db, requireAuth) {
|
||||
newly_archived_count: row.newly_archived_count,
|
||||
returned_count: row.returned_count,
|
||||
classification,
|
||||
return_classification,
|
||||
is_significant: !!row.is_significant
|
||||
}
|
||||
});
|
||||
@@ -1265,7 +1290,7 @@ function createIvantiFindingsRouter(db, requireAuth) {
|
||||
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
|
||||
newly_archived_count, returned_count, classification_json, return_classification_json, is_significant
|
||||
FROM ivanti_sync_anomaly_log
|
||||
WHERE sync_timestamp >= ? AND sync_timestamp <= ?
|
||||
ORDER BY sync_timestamp DESC`,
|
||||
@@ -1274,7 +1299,7 @@ function createIvantiFindingsRouter(db, requireAuth) {
|
||||
} else {
|
||||
rows = await dbAll(db,
|
||||
`SELECT sync_timestamp, open_count_delta, closed_count_delta,
|
||||
newly_archived_count, returned_count, classification_json, is_significant
|
||||
newly_archived_count, returned_count, classification_json, return_classification_json, is_significant
|
||||
FROM ivanti_sync_anomaly_log
|
||||
ORDER BY sync_timestamp DESC LIMIT 30`
|
||||
);
|
||||
@@ -1283,6 +1308,8 @@ function createIvantiFindingsRouter(db, requireAuth) {
|
||||
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,
|
||||
@@ -1290,6 +1317,7 @@ function createIvantiFindingsRouter(db, requireAuth) {
|
||||
newly_archived_count: row.newly_archived_count,
|
||||
returned_count: row.returned_count,
|
||||
classification,
|
||||
return_classification,
|
||||
is_significant: !!row.is_significant
|
||||
};
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user