From bfa52c7f8f8363a44cb1544d69e2b6838da52712 Mon Sep 17 00:00:00 2001 From: root Date: Fri, 1 May 2026 17:36:28 +0000 Subject: [PATCH] fix: reclassify BU reassignment round-trips and fix backfill date-ordering bug --- .../backfill_return_classification.js | 29 +++-- .../migrations/reclassify_bu_roundtrips.js | 102 ++++++++++++++++++ backend/routes/ivantiFindings.js | 3 +- 3 files changed, 123 insertions(+), 11 deletions(-) create mode 100644 backend/migrations/reclassify_bu_roundtrips.js diff --git a/backend/migrations/backfill_return_classification.js b/backend/migrations/backfill_return_classification.js index f361829..4f769f5 100644 --- a/backend/migrations/backfill_return_classification.js +++ b/backend/migrations/backfill_return_classification.js @@ -62,17 +62,20 @@ async function main() { return; } + const force = process.argv.includes('--force'); let updated = 0; let skipped = 0; for (const row of rows) { - // Skip if already has a non-empty classification - let existing = {}; - try { existing = JSON.parse(row.return_classification_json || '{}'); } catch (_) {} - const hasData = Object.values(existing).some(v => v > 0); - if (hasData) { - skipped++; - continue; + // Skip if already has a non-empty classification (unless --force) + if (!force) { + let existing = {}; + try { existing = JSON.parse(row.return_classification_json || '{}'); } catch (_) {} + const hasData = Object.values(existing).some(v => v > 0); + if (hasData) { + skipped++; + continue; + } } // Find the date of this anomaly row @@ -112,13 +115,19 @@ async function main() { if (seen.has(rt.archive_id)) continue; seen.add(rt.archive_id); - // Find the most recent ARCHIVED transition for this archive record - // (the reason it was archived before it returned) + // Find the most recent ARCHIVED transition *before* this return + // (the reason it was archived before it came back) const archiveTransition = await dbGet(db, `SELECT reason FROM ivanti_archive_transitions WHERE archive_id = ? AND to_state = 'ARCHIVED' + AND transitioned_at <= ( + SELECT transitioned_at FROM ivanti_archive_transitions + WHERE archive_id = ? AND to_state = 'RETURNED' + AND DATE(transitioned_at) BETWEEN DATE(?, '-1 day') AND DATE(?, '+1 day') + ORDER BY transitioned_at DESC LIMIT 1 + ) ORDER BY transitioned_at DESC LIMIT 1`, - [rt.archive_id] + [rt.archive_id, rt.archive_id, date, date] ); if (archiveTransition && archiveTransition.reason) { diff --git a/backend/migrations/reclassify_bu_roundtrips.js b/backend/migrations/reclassify_bu_roundtrips.js new file mode 100644 index 0000000..9a96df1 --- /dev/null +++ b/backend/migrations/reclassify_bu_roundtrips.js @@ -0,0 +1,102 @@ +#!/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); +}); diff --git a/backend/routes/ivantiFindings.js b/backend/routes/ivantiFindings.js index 1217c05..10b35b9 100644 --- a/backend/routes/ivantiFindings.js +++ b/backend/routes/ivantiFindings.js @@ -315,10 +315,11 @@ async function detectArchiveChanges(db, previousFindings, currentFindings) { const returnClassification = { bu_reassignment: 0, severity_drift: 0, closed_on_platform: 0, decommissioned: 0 }; for (const archiveId of returnedArchiveIds) { try { - // Find the most recent ARCHIVED transition reason for this archive record + // Find the most recent ARCHIVED transition reason *before* this return const transition = await dbGet(db, `SELECT reason FROM ivanti_archive_transitions WHERE archive_id = ? AND to_state = 'ARCHIVED' + AND transitioned_at <= datetime('now') ORDER BY transitioned_at DESC LIMIT 1`, [archiveId] );