#!/usr/bin/env node // reclassify_bu_roundtrips.js // // Reclassifies archive transitions that were part of a BU reassignment // round-trip. These are findings that were archived (disappeared from sync) // and then returned within a short window — indicating they were temporarily // reassigned to a different BU and then reassigned back. // // The original drift checker couldn't classify these correctly because by the // time it queried Ivanti, the findings had already been reassigned back to // the expected BUs. // // After running this, re-run backfill_return_classification.js to update // the anomaly log with the corrected reasons. // // Usage: node backend/migrations/reclassify_bu_roundtrips.js const path = require('path'); const sqlite3 = require('sqlite3').verbose(); const DB_PATH = path.join(__dirname, '..', 'cve_database.db'); // Findings that were archived and returned within this many days are // considered BU reassignment round-trips const ROUNDTRIP_WINDOW_DAYS = 14; function dbAll(db, sql, params = []) { return new Promise((resolve, reject) => { db.all(sql, params, (err, rows) => { if (err) reject(err); else resolve(rows || []); }); }); } 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); // Find archive transitions where the finding was archived and then returned // within the roundtrip window, and the archive reason is still the default // severity_score_drift placeholder const roundtrips = await dbAll(db, ` SELECT t_arch.id AS archive_transition_id, t_arch.archive_id, a.finding_id, a.finding_title, t_arch.reason AS current_reason, DATE(t_arch.transitioned_at) AS archived_date, DATE(t_ret.transitioned_at) AS returned_date, JULIANDAY(t_ret.transitioned_at) - JULIANDAY(t_arch.transitioned_at) AS days_between FROM ivanti_archive_transitions t_arch JOIN ivanti_finding_archives a ON a.id = t_arch.archive_id JOIN ivanti_archive_transitions t_ret ON t_ret.archive_id = t_arch.archive_id AND t_ret.to_state = 'RETURNED' AND t_ret.transitioned_at > t_arch.transitioned_at WHERE t_arch.to_state = 'ARCHIVED' AND t_arch.reason = 'severity_score_drift' AND (JULIANDAY(t_ret.transitioned_at) - JULIANDAY(t_arch.transitioned_at)) BETWEEN 0 AND ? ORDER BY t_arch.transitioned_at DESC `, [ROUNDTRIP_WINDOW_DAYS]); if (roundtrips.length === 0) { console.log('No BU reassignment round-trips found to reclassify.'); db.close(); return; } console.log(`Found ${roundtrips.length} archive transitions to reclassify as bu_reassignment:\n`); let updated = 0; for (const rt of roundtrips) { console.log(` Finding ${rt.finding_id}: archived ${rt.archived_date}, returned ${rt.returned_date} (${Math.round(rt.days_between)}d) — ${rt.current_reason} → bu_reassignment`); await dbRun(db, `UPDATE ivanti_archive_transitions SET reason = 'bu_reassignment' WHERE id = ?`, [rt.archive_transition_id] ); updated++; } console.log(`\nReclassified ${updated} transitions.`); console.log('\nNow run the return classification backfill to update anomaly log rows:'); console.log(' node backend/migrations/backfill_return_classification.js'); db.close(); } main().catch(err => { console.error('Fatal error:', err); process.exit(1); });