feat: add return classification for archive chart, CARD API integration, compliance charts, systemd services

This commit is contained in:
root
2026-05-01 17:15:41 +00:00
parent 8df961cce8
commit 15abf8bae4
21 changed files with 3639 additions and 210 deletions

615
backend/routes/cardApi.js Normal file
View File

@@ -0,0 +1,615 @@
// CARD Asset Ownership API Routes
// Proxies CARD operations (confirm, decline, redirect, search) and orchestrates
// the two-step update_token flow for mutations.
const express = require('express');
const { requireGroup } = require('../middleware/auth');
const logAudit = require('../helpers/auditLog');
const {
isConfigured,
missingVars,
getTeams,
getTeamAssets,
getOwner,
confirmAsset,
declineAsset,
redirectAsset,
} = require('../helpers/cardApi');
// ---------------------------------------------------------------------------
// DB helpers — promise wrappers for callback-based SQLite API
// ---------------------------------------------------------------------------
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); });
});
}
// ---------------------------------------------------------------------------
// Error classification — maps CARD API / token errors to client responses
// ---------------------------------------------------------------------------
function handleCardError(err, res) {
const msg = err.message || String(err);
console.error('[card-api]', msg);
// Token endpoint errors (from acquireToken rejections)
if (msg.includes('Token acquisition failed')) {
if (msg.includes('HTTP 401')) {
return res.status(401).json({ error: 'CARD authorization failed. Check service account credentials.' });
}
if (msg.includes('HTTP 403')) {
return res.status(403).json({ error: 'CARD access denied. The service account may not be onboarded with the CARD team.' });
}
if (msg.includes('HTTP 525')) {
return res.status(502).json({ error: 'CARD LDAP error. The service account may not be provisioned correctly.' });
}
}
// API call errors (after automatic 401 retry in helper)
if (msg.includes('401')) {
return res.status(401).json({ error: 'CARD token expired or invalid. The request has been retried once automatically.' });
}
if (msg.includes('403')) {
return res.status(403).json({ error: 'Insufficient CARD permissions for this operation.' });
}
// Catch-all
return res.status(502).json({ error: 'CARD API request failed.', details: msg });
}
// ---------------------------------------------------------------------------
// Router factory
// ---------------------------------------------------------------------------
function createCardApiRouter(db, requireAuth) {
const router = express.Router();
// -------------------------------------------------------------------
// GET /status
// Returns whether the CARD API integration is configured.
// -------------------------------------------------------------------
router.get('/status', requireAuth(db), (req, res) => {
if (!isConfigured) {
return res.status(503).json({
configured: false,
error: 'CARD API is not configured.',
missingVars,
});
}
res.json({ configured: true });
});
// -------------------------------------------------------------------
// GET /teams
// Proxy CARD teams list.
// -------------------------------------------------------------------
router.get('/teams', requireAuth(db), requireGroup('Admin', 'Standard_User'), async (req, res) => {
if (!isConfigured) {
return res.status(503).json({ error: 'CARD API is not configured.', missingVars });
}
try {
const result = await getTeams();
if (result.ok) {
let body;
try {
body = JSON.parse(result.body);
} catch (_) {
body = result.body;
}
// CARD API wraps teams in { teams: [...], response_time: ... }
const teams = Array.isArray(body) ? body : (body && body.teams) || [];
return res.json(teams);
}
// Forward CARD error status
let errorBody;
try {
errorBody = JSON.parse(result.body);
} catch (_) {
errorBody = { error: result.body };
}
return res.status(result.status).json(errorBody);
} catch (err) {
return handleCardError(err, res);
}
});
// -------------------------------------------------------------------
// GET /teams/:teamName/assets
// Proxy team assets with required disposition filter.
// -------------------------------------------------------------------
router.get('/teams/:teamName/assets', requireAuth(db), requireGroup('Admin', 'Standard_User'), async (req, res) => {
if (!isConfigured) {
return res.status(503).json({ error: 'CARD API is not configured.', missingVars });
}
const { teamName } = req.params;
const { disposition, page, page_size } = req.query;
if (!disposition) {
return res.status(400).json({ error: 'disposition query parameter is required.' });
}
try {
const result = await getTeamAssets(teamName, {
disposition,
page: page ? parseInt(page, 10) : undefined,
pageSize: page_size ? parseInt(page_size, 10) : 50,
});
if (result.ok) {
let body;
try {
body = JSON.parse(result.body);
} catch (_) {
body = result.body;
}
// Audit log for asset search (fire-and-forget)
let resultCount = 0;
if (body && typeof body === 'object' && typeof body.total === 'number') {
resultCount = body.total;
} else if (body && Array.isArray(body.assets)) {
resultCount = body.assets.length;
}
logAudit(db, {
userId: req.user.id,
username: req.user.username,
action: 'card_search',
entityType: 'card_asset',
entityId: teamName,
details: { disposition, resultCount },
ipAddress: req.ip,
});
return res.json(body);
}
let errorBody;
try {
errorBody = JSON.parse(result.body);
} catch (_) {
errorBody = { error: result.body };
}
return res.status(result.status).json(errorBody);
} catch (err) {
return handleCardError(err, res);
}
});
// -------------------------------------------------------------------
// GET /owner/:assetId
// Proxy owner record lookup.
// -------------------------------------------------------------------
router.get('/owner/:assetId', requireAuth(db), requireGroup('Admin', 'Standard_User'), async (req, res) => {
if (!isConfigured) {
return res.status(503).json({ error: 'CARD API is not configured.', missingVars });
}
const { assetId } = req.params;
try {
const result = await getOwner(assetId);
if (result.ok) {
let body;
try {
body = JSON.parse(result.body);
} catch (_) {
body = result.body;
}
return res.json(body);
}
let errorBody;
try {
errorBody = JSON.parse(result.body);
} catch (_) {
errorBody = { error: result.body };
}
return res.status(result.status).json(errorBody);
} catch (err) {
return handleCardError(err, res);
}
});
// -------------------------------------------------------------------
// POST /queue/:queueItemId/confirm
// Confirm asset to a team via CARD API.
// -------------------------------------------------------------------
router.post('/queue/:queueItemId/confirm', requireAuth(db), requireGroup('Admin', 'Standard_User'), async (req, res) => {
if (!isConfigured) {
return res.status(503).json({ error: 'CARD API is not configured.', missingVars });
}
const { queueItemId } = req.params;
const { teamName, assetId, comment } = req.body;
// Validate required fields
if (!teamName || typeof teamName !== 'string' || !teamName.trim()) {
return res.status(400).json({ error: 'teamName is required.' });
}
if (!assetId || typeof assetId !== 'string' || !assetId.trim()) {
return res.status(400).json({ error: 'assetId is required.' });
}
try {
// Validate queue item
const item = await dbGet(db,
'SELECT * FROM ivanti_todo_queue WHERE id = ? AND user_id = ? AND workflow_type = ?',
[queueItemId, req.user.id, 'CARD']
);
if (!item) {
return res.status(404).json({ error: 'Queue item not found.' });
}
if (item.status !== 'pending') {
return res.status(400).json({ error: 'Only pending queue items can be executed.' });
}
// Step 1: Get owner record for update_token
const ownerResult = await getOwner(assetId);
if (!ownerResult.ok) {
const errMsg = `Failed to fetch owner record: HTTP ${ownerResult.status}`;
console.error('[card-api]', errMsg);
logAudit(db, {
userId: req.user.id,
username: req.user.username,
action: 'card_action_failed',
entityType: 'ivanti_todo_queue',
entityId: String(queueItemId),
details: { actionType: 'confirm', assetId, error: errMsg, cardStatus: ownerResult.status },
ipAddress: req.ip,
});
let errorBody;
try { errorBody = JSON.parse(ownerResult.body); } catch (_) { errorBody = { error: ownerResult.body }; }
return res.status(ownerResult.status).json(errorBody);
}
let ownerData;
try { ownerData = JSON.parse(ownerResult.body); } catch (_) { ownerData = {}; }
const updateToken = ownerData.owner && ownerData.owner.update_token;
if (!updateToken) {
const errMsg = 'update_token not found in owner record.';
console.error('[card-api]', errMsg);
logAudit(db, {
userId: req.user.id,
username: req.user.username,
action: 'card_action_failed',
entityType: 'ivanti_todo_queue',
entityId: String(queueItemId),
details: { actionType: 'confirm', assetId, error: errMsg, cardStatus: null },
ipAddress: req.ip,
});
return res.status(502).json({ error: 'CARD API request failed.', details: errMsg });
}
// Step 2: Execute confirm mutation
const confirmResult = await confirmAsset(assetId, teamName.trim(), updateToken, comment || '');
if (confirmResult.ok) {
// Update queue item to complete
await dbRun(db,
"UPDATE ivanti_todo_queue SET status = 'complete', updated_at = CURRENT_TIMESTAMP WHERE id = ?",
[queueItemId]
);
let cardResponse;
try { cardResponse = JSON.parse(confirmResult.body); } catch (_) { cardResponse = confirmResult.body; }
// Audit log (fire-and-forget)
logAudit(db, {
userId: req.user.id,
username: req.user.username,
action: 'card_confirm',
entityType: 'ivanti_todo_queue',
entityId: String(queueItemId),
details: { assetId, teamName: teamName.trim(), comment: comment || '', cardStatus: confirmResult.status },
ipAddress: req.ip,
});
return res.json({ success: true, cardResponse });
}
// Mutation failed — leave queue item as pending
const errMsg = `Confirm failed: HTTP ${confirmResult.status}`;
console.error('[card-api]', errMsg);
logAudit(db, {
userId: req.user.id,
username: req.user.username,
action: 'card_action_failed',
entityType: 'ivanti_todo_queue',
entityId: String(queueItemId),
details: { actionType: 'confirm', assetId, error: errMsg, cardStatus: confirmResult.status },
ipAddress: req.ip,
});
let errorBody;
try { errorBody = JSON.parse(confirmResult.body); } catch (_) { errorBody = { error: confirmResult.body }; }
return res.status(confirmResult.status).json(errorBody);
} catch (err) {
console.error('[card-api] Confirm error:', err.message);
logAudit(db, {
userId: req.user.id,
username: req.user.username,
action: 'card_action_failed',
entityType: 'ivanti_todo_queue',
entityId: String(queueItemId),
details: { actionType: 'confirm', assetId, error: err.message, cardStatus: null },
ipAddress: req.ip,
});
return handleCardError(err, res);
}
});
// -------------------------------------------------------------------
// POST /queue/:queueItemId/decline
// Decline asset from a team via CARD API.
// -------------------------------------------------------------------
router.post('/queue/:queueItemId/decline', requireAuth(db), requireGroup('Admin', 'Standard_User'), async (req, res) => {
if (!isConfigured) {
return res.status(503).json({ error: 'CARD API is not configured.', missingVars });
}
const { queueItemId } = req.params;
const { teamName, assetId, comment } = req.body;
// Validate required fields
if (!teamName || typeof teamName !== 'string' || !teamName.trim()) {
return res.status(400).json({ error: 'teamName is required.' });
}
if (!assetId || typeof assetId !== 'string' || !assetId.trim()) {
return res.status(400).json({ error: 'assetId is required.' });
}
try {
// Validate queue item
const item = await dbGet(db,
'SELECT * FROM ivanti_todo_queue WHERE id = ? AND user_id = ? AND workflow_type = ?',
[queueItemId, req.user.id, 'CARD']
);
if (!item) {
return res.status(404).json({ error: 'Queue item not found.' });
}
if (item.status !== 'pending') {
return res.status(400).json({ error: 'Only pending queue items can be executed.' });
}
// Step 1: Get owner record for update_token
const ownerResult = await getOwner(assetId);
if (!ownerResult.ok) {
const errMsg = `Failed to fetch owner record: HTTP ${ownerResult.status}`;
console.error('[card-api]', errMsg);
logAudit(db, {
userId: req.user.id,
username: req.user.username,
action: 'card_action_failed',
entityType: 'ivanti_todo_queue',
entityId: String(queueItemId),
details: { actionType: 'decline', assetId, error: errMsg, cardStatus: ownerResult.status },
ipAddress: req.ip,
});
let errorBody;
try { errorBody = JSON.parse(ownerResult.body); } catch (_) { errorBody = { error: ownerResult.body }; }
return res.status(ownerResult.status).json(errorBody);
}
let ownerData;
try { ownerData = JSON.parse(ownerResult.body); } catch (_) { ownerData = {}; }
const updateToken = ownerData.owner && ownerData.owner.update_token;
if (!updateToken) {
const errMsg = 'update_token not found in owner record.';
console.error('[card-api]', errMsg);
logAudit(db, {
userId: req.user.id,
username: req.user.username,
action: 'card_action_failed',
entityType: 'ivanti_todo_queue',
entityId: String(queueItemId),
details: { actionType: 'decline', assetId, error: errMsg, cardStatus: null },
ipAddress: req.ip,
});
return res.status(502).json({ error: 'CARD API request failed.', details: errMsg });
}
// Step 2: Execute decline mutation
const declineResult = await declineAsset(assetId, teamName.trim(), updateToken, comment || '');
if (declineResult.ok) {
await dbRun(db,
"UPDATE ivanti_todo_queue SET status = 'complete', updated_at = CURRENT_TIMESTAMP WHERE id = ?",
[queueItemId]
);
let cardResponse;
try { cardResponse = JSON.parse(declineResult.body); } catch (_) { cardResponse = declineResult.body; }
logAudit(db, {
userId: req.user.id,
username: req.user.username,
action: 'card_decline',
entityType: 'ivanti_todo_queue',
entityId: String(queueItemId),
details: { assetId, teamName: teamName.trim(), comment: comment || '', cardStatus: declineResult.status },
ipAddress: req.ip,
});
return res.json({ success: true, cardResponse });
}
// Mutation failed
const errMsg = `Decline failed: HTTP ${declineResult.status}`;
console.error('[card-api]', errMsg);
logAudit(db, {
userId: req.user.id,
username: req.user.username,
action: 'card_action_failed',
entityType: 'ivanti_todo_queue',
entityId: String(queueItemId),
details: { actionType: 'decline', assetId, error: errMsg, cardStatus: declineResult.status },
ipAddress: req.ip,
});
let errorBody;
try { errorBody = JSON.parse(declineResult.body); } catch (_) { errorBody = { error: declineResult.body }; }
return res.status(declineResult.status).json(errorBody);
} catch (err) {
console.error('[card-api] Decline error:', err.message);
logAudit(db, {
userId: req.user.id,
username: req.user.username,
action: 'card_action_failed',
entityType: 'ivanti_todo_queue',
entityId: String(queueItemId),
details: { actionType: 'decline', assetId, error: err.message, cardStatus: null },
ipAddress: req.ip,
});
return handleCardError(err, res);
}
});
// -------------------------------------------------------------------
// POST /queue/:queueItemId/redirect
// Redirect asset from one team to another via CARD API.
// -------------------------------------------------------------------
router.post('/queue/:queueItemId/redirect', requireAuth(db), requireGroup('Admin', 'Standard_User'), async (req, res) => {
if (!isConfigured) {
return res.status(503).json({ error: 'CARD API is not configured.', missingVars });
}
const { queueItemId } = req.params;
const { fromTeam, toTeam, assetId } = req.body;
// Validate required fields
if (!fromTeam || typeof fromTeam !== 'string' || !fromTeam.trim()) {
return res.status(400).json({ error: 'fromTeam is required.' });
}
if (!toTeam || typeof toTeam !== 'string' || !toTeam.trim()) {
return res.status(400).json({ error: 'toTeam is required.' });
}
if (!assetId || typeof assetId !== 'string' || !assetId.trim()) {
return res.status(400).json({ error: 'assetId is required.' });
}
try {
// Validate queue item
const item = await dbGet(db,
'SELECT * FROM ivanti_todo_queue WHERE id = ? AND user_id = ? AND workflow_type = ?',
[queueItemId, req.user.id, 'CARD']
);
if (!item) {
return res.status(404).json({ error: 'Queue item not found.' });
}
if (item.status !== 'pending') {
return res.status(400).json({ error: 'Only pending queue items can be executed.' });
}
// Step 1: Get owner record for update_token
const ownerResult = await getOwner(assetId);
if (!ownerResult.ok) {
const errMsg = `Failed to fetch owner record: HTTP ${ownerResult.status}`;
console.error('[card-api]', errMsg);
logAudit(db, {
userId: req.user.id,
username: req.user.username,
action: 'card_action_failed',
entityType: 'ivanti_todo_queue',
entityId: String(queueItemId),
details: { actionType: 'redirect', assetId, error: errMsg, cardStatus: ownerResult.status },
ipAddress: req.ip,
});
let errorBody;
try { errorBody = JSON.parse(ownerResult.body); } catch (_) { errorBody = { error: ownerResult.body }; }
return res.status(ownerResult.status).json(errorBody);
}
let ownerData;
try { ownerData = JSON.parse(ownerResult.body); } catch (_) { ownerData = {}; }
const updateToken = ownerData.owner && ownerData.owner.update_token;
if (!updateToken) {
const errMsg = 'update_token not found in owner record.';
console.error('[card-api]', errMsg);
logAudit(db, {
userId: req.user.id,
username: req.user.username,
action: 'card_action_failed',
entityType: 'ivanti_todo_queue',
entityId: String(queueItemId),
details: { actionType: 'redirect', assetId, error: errMsg, cardStatus: null },
ipAddress: req.ip,
});
return res.status(502).json({ error: 'CARD API request failed.', details: errMsg });
}
// Step 2: Execute redirect mutation
const redirectResult = await redirectAsset(assetId, fromTeam.trim(), toTeam.trim(), updateToken);
if (redirectResult.ok) {
await dbRun(db,
"UPDATE ivanti_todo_queue SET status = 'complete', updated_at = CURRENT_TIMESTAMP WHERE id = ?",
[queueItemId]
);
let cardResponse;
try { cardResponse = JSON.parse(redirectResult.body); } catch (_) { cardResponse = redirectResult.body; }
logAudit(db, {
userId: req.user.id,
username: req.user.username,
action: 'card_redirect',
entityType: 'ivanti_todo_queue',
entityId: String(queueItemId),
details: { assetId, fromTeam: fromTeam.trim(), toTeam: toTeam.trim(), cardStatus: redirectResult.status },
ipAddress: req.ip,
});
return res.json({ success: true, cardResponse });
}
// Mutation failed
const errMsg = `Redirect failed: HTTP ${redirectResult.status}`;
console.error('[card-api]', errMsg);
logAudit(db, {
userId: req.user.id,
username: req.user.username,
action: 'card_action_failed',
entityType: 'ivanti_todo_queue',
entityId: String(queueItemId),
details: { actionType: 'redirect', assetId, error: errMsg, cardStatus: redirectResult.status },
ipAddress: req.ip,
});
let errorBody;
try { errorBody = JSON.parse(redirectResult.body); } catch (_) { errorBody = { error: redirectResult.body }; }
return res.status(redirectResult.status).json(errorBody);
} catch (err) {
console.error('[card-api] Redirect error:', err.message);
logAudit(db, {
userId: req.user.id,
username: req.user.username,
action: 'card_action_failed',
entityType: 'ivanti_todo_queue',
entityId: String(queueItemId),
details: { actionType: 'redirect', assetId, error: err.message, cardStatus: null },
ipAddress: req.ip,
});
return handleCardError(err, res);
}
});
return router;
}
module.exports = createCardApiRouter;

View File

@@ -13,8 +13,8 @@
// POST /notes — add a note to one or more (hostname, metric_id) pairs
// GET /notes/:hostname/:metricId — notes for a specific device+metric
// GET /trends — per-upload totals + per-team counts for time-series charts
// GET /mttr — mean time to resolution per team
// GET /top-recurring — chronic compliance gaps sorted by seen_count
// GET /mttr — aging findings distribution by seen_count bucket and team
// GET /top-recurring — net change waterfall (per-cycle start/new/recurring/resolved/end)
// GET /category-trend — active counts per category per upload for stacked area chart
const express = require('express');
@@ -240,6 +240,60 @@ function groupByHostname(rows, noteHostnames) {
return Object.values(deviceMap);
}
// ---------------------------------------------------------------------------
// Pure function: bucket active items by age group and pivot per-team counts
// ---------------------------------------------------------------------------
const BUCKET_ORDER = ['1 cycle', '23 cycles', '46 cycles', '7+ cycles'];
function bucketAgingItems(items) {
// Initialise empty buckets with all teams at zero
const teams = ['STEAM', 'ACCESS-ENG', 'ACCESS-OPS', 'INTELDEV'];
const buckets = {};
for (const b of BUCKET_ORDER) {
buckets[b] = { bucket: b, total: 0 };
for (const t of teams) buckets[b][t] = 0;
}
// Classify each item into a bucket
for (const item of items) {
const sc = item.seen_count;
let label;
if (sc === 1) label = '1 cycle';
else if (sc >= 2 && sc <= 3) label = '23 cycles';
else if (sc >= 4 && sc <= 6) label = '46 cycles';
else label = '7+ cycles';
const team = item.team;
buckets[label].total += 1;
if (team in buckets[label]) {
buckets[label][team] += 1;
}
}
// Return in ascending age order
return BUCKET_ORDER.map(b => buckets[b]);
}
// ---------------------------------------------------------------------------
// Pure function: compute waterfall chain from ordered upload records
// ---------------------------------------------------------------------------
function computeWaterfall(uploads) {
let start = 0;
return uploads.map((row) => {
const end = start + row.new_count + row.recurring_count - row.resolved_count;
const entry = {
date: row.report_date,
start,
new_count: row.new_count,
recurring_count: row.recurring_count,
resolved_count: row.resolved_count,
end,
};
start = end;
return entry;
});
}
// ---------------------------------------------------------------------------
// Router factory
// ---------------------------------------------------------------------------
@@ -1012,27 +1066,23 @@ function createComplianceRouter(db, upload, requireAuth, requireGroup) {
// -----------------------------------------------------------------------
// GET /mttr
// Mean time to resolution (calendar days) per team, for resolved items.
// Aging Findings Distribution — active findings bucketed by seen_count
// with per-team breakdown for stacked bar chart.
//
// Response: { mttr: [{ team, avg_days, resolved_count }] }
// Response: { aging: [{ bucket, total, STEAM, ACCESS-ENG, ACCESS-OPS, INTELDEV }] }
// -----------------------------------------------------------------------
router.get('/mttr', async (req, res) => {
try {
const rows = await dbAll(db,
`SELECT
ci.team,
ROUND(AVG(JULIANDAY(ru.report_date) - JULIANDAY(fu.report_date)), 1) AS avg_days,
COUNT(*) AS resolved_count
FROM compliance_items ci
JOIN compliance_uploads fu ON ci.first_seen_upload_id = fu.id
JOIN compliance_uploads ru ON ci.resolved_upload_id = ru.id
WHERE ci.resolved_upload_id IS NOT NULL
AND fu.report_date IS NOT NULL
AND ru.report_date IS NOT NULL
GROUP BY ci.team
ORDER BY avg_days DESC`
`SELECT COALESCE(seen_count, 1) AS seen_count, team
FROM compliance_items
WHERE status = 'active'`
);
res.json({ mttr: rows });
if (rows.length === 0) {
return res.json({ aging: [] });
}
const aging = bucketAgingItems(rows);
res.json({ aging });
} catch (err) {
console.error('[Compliance] GET /mttr error:', err.message);
res.status(500).json({ error: 'Database error' });
@@ -1041,23 +1091,24 @@ function createComplianceRouter(db, upload, requireAuth, requireGroup) {
// -----------------------------------------------------------------------
// GET /top-recurring
// Active findings grouped by team + metric_id, sorted by seen_count desc.
// Identifies chronic compliance gaps that keep reappearing.
// Net Change Waterfall — per-cycle net movement (start → +new →
// +recurring → resolved → end) computed from compliance_uploads.
//
// Response: { items: [{ team, metric_id, metric_desc, seen_count,
// host_count }] } — limited to top 20
// Response: { waterfall: [{ date, start, new_count, recurring_count,
// resolved_count, end }] }
// -----------------------------------------------------------------------
router.get('/top-recurring', async (req, res) => {
try {
const rows = await dbAll(db,
`SELECT team, metric_id, metric_desc, seen_count, COUNT(*) AS host_count
FROM compliance_items
WHERE status = 'active'
GROUP BY team, metric_id
ORDER BY seen_count DESC, host_count DESC
LIMIT 20`
`SELECT id, report_date,
COALESCE(new_count, 0) AS new_count,
COALESCE(recurring_count, 0) AS recurring_count,
COALESCE(resolved_count, 0) AS resolved_count
FROM compliance_uploads
ORDER BY report_date ASC`
);
res.json({ items: rows });
const waterfall = computeWaterfall(rows);
res.json({ waterfall });
} catch (err) {
console.error('[Compliance] GET /top-recurring error:', err.message);
res.status(500).json({ error: 'Database error' });
@@ -1089,4 +1140,4 @@ function createComplianceRouter(db, upload, requireAuth, requireGroup) {
return router;
}
module.exports = createComplianceRouter;
module.exports = { createComplianceRouter, bucketAgingItems, computeWaterfall };

View File

@@ -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
};
});