161 lines
5.5 KiB
JavaScript
161 lines
5.5 KiB
JavaScript
|
|
#!/usr/bin/env node
|
||
|
|
// backfill_anomaly_log.js — One-time backfill of ivanti_sync_anomaly_log
|
||
|
|
//
|
||
|
|
// Synthesizes anomaly log entries from existing ivanti_archive_transitions
|
||
|
|
// and ivanti_counts_history data so the archive activity sparkline on the
|
||
|
|
// Findings Trend chart has historical data to display.
|
||
|
|
//
|
||
|
|
// Safe to run multiple times — checks for existing rows before inserting.
|
||
|
|
//
|
||
|
|
// Usage: node backend/migrations/backfill_anomaly_log.js
|
||
|
|
|
||
|
|
const path = require('path');
|
||
|
|
const sqlite3 = require('sqlite3').verbose();
|
||
|
|
|
||
|
|
const DB_PATH = path.join(__dirname, '..', 'cve_database.db');
|
||
|
|
|
||
|
|
function dbAll(db, sql, params = []) {
|
||
|
|
return new Promise((resolve, reject) => {
|
||
|
|
db.all(sql, params, (err, rows) => {
|
||
|
|
if (err) reject(err);
|
||
|
|
else resolve(rows || []);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
function dbGet(db, sql, params = []) {
|
||
|
|
return new Promise((resolve, reject) => {
|
||
|
|
db.get(sql, params, (err, row) => {
|
||
|
|
if (err) reject(err);
|
||
|
|
else resolve(row);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
function dbRun(db, sql, params = []) {
|
||
|
|
return new Promise((resolve, reject) => {
|
||
|
|
db.run(sql, params, function (err) {
|
||
|
|
if (err) reject(err);
|
||
|
|
else resolve(this);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
async function main() {
|
||
|
|
const db = new sqlite3.Database(DB_PATH);
|
||
|
|
|
||
|
|
// Check if anomaly log already has data
|
||
|
|
const existing = await dbGet(db, 'SELECT COUNT(*) as cnt FROM ivanti_sync_anomaly_log');
|
||
|
|
if (existing.cnt > 0) {
|
||
|
|
console.log(`ivanti_sync_anomaly_log already has ${existing.cnt} rows — skipping backfill.`);
|
||
|
|
console.log('To force re-run, delete existing rows first:');
|
||
|
|
console.log(' sqlite3 backend/cve_database.db "DELETE FROM ivanti_sync_anomaly_log;"');
|
||
|
|
db.close();
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Get archive transitions grouped by date
|
||
|
|
const transitions = await dbAll(db,
|
||
|
|
`SELECT DATE(transitioned_at) as date,
|
||
|
|
to_state,
|
||
|
|
reason,
|
||
|
|
COUNT(*) as cnt
|
||
|
|
FROM ivanti_archive_transitions
|
||
|
|
GROUP BY date, to_state, reason
|
||
|
|
ORDER BY date`
|
||
|
|
);
|
||
|
|
|
||
|
|
// Get counts history (last snapshot per day) for delta computation
|
||
|
|
const countsRows = await dbAll(db,
|
||
|
|
`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`
|
||
|
|
);
|
||
|
|
|
||
|
|
// Build a map of date -> { open_count, closed_count }
|
||
|
|
const countsMap = {};
|
||
|
|
for (const row of countsRows) {
|
||
|
|
countsMap[row.date] = { open: row.open_count, closed: row.closed_count };
|
||
|
|
}
|
||
|
|
|
||
|
|
// Build per-date anomaly summaries from transitions
|
||
|
|
const dateMap = {};
|
||
|
|
for (const t of transitions) {
|
||
|
|
if (!dateMap[t.date]) {
|
||
|
|
dateMap[t.date] = { archived: 0, returned: 0, classification: {} };
|
||
|
|
}
|
||
|
|
const entry = dateMap[t.date];
|
||
|
|
|
||
|
|
if (t.to_state === 'ARCHIVED') {
|
||
|
|
entry.archived += t.cnt;
|
||
|
|
// All pre-feature transitions have reason 'severity_score_drift'
|
||
|
|
// but from the investigation we know the 04/24 batch was mostly
|
||
|
|
// BU reassignment. We can't retroactively classify without the
|
||
|
|
// Ivanti API, so we label them as 'unclassified' (pre-feature).
|
||
|
|
entry.classification.unclassified = (entry.classification.unclassified || 0) + t.cnt;
|
||
|
|
} else if (t.to_state === 'RETURNED') {
|
||
|
|
entry.returned += t.cnt;
|
||
|
|
}
|
||
|
|
// CLOSED transitions are not archive events — they're findings
|
||
|
|
// confirmed in the closed set, so we don't count them as archived.
|
||
|
|
}
|
||
|
|
|
||
|
|
// Compute deltas and insert rows
|
||
|
|
const dates = Object.keys(dateMap).sort();
|
||
|
|
let inserted = 0;
|
||
|
|
|
||
|
|
for (const date of dates) {
|
||
|
|
const entry = dateMap[date];
|
||
|
|
const counts = countsMap[date];
|
||
|
|
|
||
|
|
// Find the previous day's counts for delta computation
|
||
|
|
const dateIdx = countsRows.findIndex(r => r.date === date);
|
||
|
|
let openDelta = 0;
|
||
|
|
let closedDelta = 0;
|
||
|
|
|
||
|
|
if (counts && dateIdx > 0) {
|
||
|
|
const prev = countsRows[dateIdx - 1];
|
||
|
|
openDelta = counts.open - prev.open_count;
|
||
|
|
closedDelta = counts.closed - prev.closed_count;
|
||
|
|
}
|
||
|
|
|
||
|
|
const isSignificant = entry.archived > 5 ? 1 : 0;
|
||
|
|
const classificationJson = JSON.stringify(entry.classification);
|
||
|
|
|
||
|
|
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 (?, ?, ?, ?, ?, ?, ?)`,
|
||
|
|
[
|
||
|
|
`${date}T23:59:00`,
|
||
|
|
openDelta,
|
||
|
|
closedDelta,
|
||
|
|
entry.archived,
|
||
|
|
entry.returned,
|
||
|
|
classificationJson,
|
||
|
|
isSignificant,
|
||
|
|
]
|
||
|
|
);
|
||
|
|
inserted++;
|
||
|
|
|
||
|
|
const sigLabel = isSignificant ? ' [SIGNIFICANT]' : '';
|
||
|
|
console.log(` ${date}: ${entry.archived} archived, ${entry.returned} returned, delta open=${openDelta} closed=${closedDelta}${sigLabel}`);
|
||
|
|
}
|
||
|
|
|
||
|
|
console.log(`\nBackfill complete: ${inserted} anomaly log entries created.`);
|
||
|
|
db.close();
|
||
|
|
}
|
||
|
|
|
||
|
|
main().catch(err => {
|
||
|
|
console.error('Fatal error:', err);
|
||
|
|
process.exit(1);
|
||
|
|
});
|