#!/usr/bin/env node // drift-check.js — One-time diagnostic to confirm host-level VRR score drift // // Queries Ivanti WITHOUT the severity filter for the same BUs, then cross- // references the results against our archived finding IDs to see if they // still exist at lower severity scores. // // Usage: node backend/scripts/drift-check.js // // Output: prints a comparison table and summary. Does NOT modify cve_database.db // permanently — uses a temporary in-memory table for the comparison. require('dotenv').config({ path: require('path').join(__dirname, '..', '.env') }); const path = require('path'); const sqlite3 = require('sqlite3').verbose(); const { ivantiPost } = require('../helpers/ivantiApi'); const DB_PATH = path.join(__dirname, '..', 'cve_database.db'); // Same BU filter, NO severity filter, NO state filter — get everything const ALL_FINDINGS_FILTERS = [ { field: 'assetCustomAttributes.1550_host_1.value', exclusive: false, operator: 'IN', orWithPrevious: false, implicitFilters: [], value: 'NTS-AEO-ACCESS-ENG,NTS-AEO-STEAM', caseSensitive: false } ]; 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 fetchAllFindings(apiKey, clientId, skipTls, state) { const urlPath = `/client/${encodeURIComponent(clientId)}/hostFinding/search`; const filters = [ ...ALL_FINDINGS_FILTERS, { field: 'generic_state', exclusive: false, operator: 'EXACT', orWithPrevious: false, implicitFilters: [], value: state, caseSensitive: false } ]; let allFindings = []; let page = 0; let totalPages = 1; do { const body = { filters, projection: 'internal', sort: [{ field: 'severity', direction: 'ASC' }], page, size: 100 }; const result = await ivantiPost(urlPath, body, apiKey, skipTls); if (result.status !== 200) { console.error(` API returned status ${result.status} on page ${page}`); break; } const data = JSON.parse(result.body); totalPages = data.page?.totalPages || 1; const findings = data._embedded?.hostFindings || []; for (const f of findings) { allFindings.push({ id: String(f.id), severity: typeof f.severity === 'number' ? f.severity : parseFloat(f.severity) || 0, title: f.title || '', hostName: f.host?.hostName || '', state }); } console.error(` ${state} page ${page + 1}/${totalPages} — ${allFindings.length} findings so far`); page++; } while (page < totalPages); return allFindings; } async function main() { const apiKey = process.env.IVANTI_API_KEY; const clientId = process.env.IVANTI_CLIENT_ID || '1550'; const skipTls = process.env.IVANTI_SKIP_TLS === 'true'; if (!apiKey) { console.error('IVANTI_API_KEY not set in backend/.env'); process.exit(1); } console.error('=== Drift Check: Querying Ivanti WITHOUT severity filter ===\n'); // Fetch all Open findings (no severity filter) console.error('Fetching ALL Open findings (no severity filter)...'); const openFindings = await fetchAllFindings(apiKey, clientId, skipTls, 'Open'); console.error(` Total Open (all severities): ${openFindings.length}\n`); // Fetch all Closed findings (no severity filter) console.error('Fetching ALL Closed findings (no severity filter)...'); const closedFindings = await fetchAllFindings(apiKey, clientId, skipTls, 'Closed'); console.error(` Total Closed (all severities): ${closedFindings.length}\n`); const allFindings = [...openFindings, ...closedFindings]; const findingMap = new Map(allFindings.map(f => [f.id, f])); console.error(`Total findings across both states: ${allFindings.length}\n`); // Open the database and get archived finding IDs const db = new sqlite3.Database(DB_PATH); const archived = await dbAll(db, `SELECT finding_id, last_severity, finding_title, host_name, current_state FROM ivanti_finding_archives WHERE current_state IN ('ARCHIVED', 'CLOSED') ORDER BY current_state, last_severity DESC` ); console.log(''); console.log('='.repeat(120)); console.log('DRIFT CHECK RESULTS'); console.log('='.repeat(120)); console.log(''); // Categorize results const drifted = []; // Found in API at lower severity (below 8.5) const stillHigh = []; // Found in API, severity still >= 8.5 const gone = []; // Not found in API at all (any severity) const stateChanged = []; // Found but in different state for (const arch of archived) { const current = findingMap.get(arch.finding_id); if (!current) { gone.push(arch); } else if (current.severity < 8.5) { drifted.push({ ...arch, currentSeverity: current.severity, currentState: current.state }); } else { stillHigh.push({ ...arch, currentSeverity: current.severity, currentState: current.state }); } } // Print drifted findings console.log(`CONFIRMED SCORE DRIFT (now below 8.5): ${drifted.length} findings`); console.log('-'.repeat(120)); if (drifted.length > 0) { console.log( 'Finding ID'.padEnd(14) + 'Archive State'.padEnd(15) + 'Last Severity'.padEnd(15) + 'Current Severity'.padEnd(18) + 'Delta'.padEnd(10) + 'Current State'.padEnd(15) + 'Title' ); console.log('-'.repeat(120)); for (const f of drifted) { const delta = (f.currentSeverity - f.last_severity).toFixed(2); console.log( f.finding_id.padEnd(14) + f.current_state.padEnd(15) + f.last_severity.toFixed(2).padEnd(15) + f.currentSeverity.toFixed(2).padEnd(18) + delta.padEnd(10) + f.currentState.padEnd(15) + f.finding_title.substring(0, 50) ); } } console.log(''); console.log(`STILL HIGH SEVERITY (>= 8.5, should be in filtered results): ${stillHigh.length} findings`); console.log('-'.repeat(120)); if (stillHigh.length > 0) { console.log( 'Finding ID'.padEnd(14) + 'Archive State'.padEnd(15) + 'Last Severity'.padEnd(15) + 'Current Severity'.padEnd(18) + 'Current State'.padEnd(15) + 'Title' ); console.log('-'.repeat(120)); for (const f of stillHigh) { console.log( f.finding_id.padEnd(14) + f.current_state.padEnd(15) + f.last_severity.toFixed(2).padEnd(15) + f.currentSeverity.toFixed(2).padEnd(18) + f.currentState.padEnd(15) + f.finding_title.substring(0, 50) ); } } console.log(''); console.log(`COMPLETELY GONE (not in API at any severity): ${gone.length} findings`); console.log('-'.repeat(120)); if (gone.length > 0) { console.log( 'Finding ID'.padEnd(14) + 'Archive State'.padEnd(15) + 'Last Severity'.padEnd(15) + 'Title' ); console.log('-'.repeat(120)); for (const f of gone) { console.log( f.finding_id.padEnd(14) + f.current_state.padEnd(15) + f.last_severity.toFixed(2).padEnd(15) + f.finding_title.substring(0, 50) ); } } // Summary console.log(''); console.log('='.repeat(120)); console.log('SUMMARY'); console.log('='.repeat(120)); console.log(` Archived/Closed findings checked: ${archived.length}`); console.log(` Confirmed score drift (< 8.5): ${drifted.length}`); console.log(` Still high severity (>= 8.5): ${stillHigh.length}`); console.log(` Completely gone from API: ${gone.length}`); console.log(''); if (drifted.length > 0) { const avgDelta = drifted.reduce((sum, f) => sum + (f.currentSeverity - f.last_severity), 0) / drifted.length; const minNew = Math.min(...drifted.map(f => f.currentSeverity)); const maxNew = Math.max(...drifted.map(f => f.currentSeverity)); console.log(` Score drift range: ${minNew.toFixed(2)} – ${maxNew.toFixed(2)} (avg delta: ${avgDelta.toFixed(2)})`); } if (drifted.length > archived.length * 0.5) { console.log('\n VERDICT: Host-level VRR score drift CONFIRMED.'); console.log(' The majority of disappeared findings still exist in Ivanti but at lower severity scores.'); } else if (drifted.length > 0) { console.log('\n VERDICT: Partial score drift detected. Some findings drifted, others may have been removed.'); } else if (gone.length > archived.length * 0.5) { console.log('\n VERDICT: Score drift NOT confirmed. Most findings are completely gone from the API.'); console.log(' This suggests BU reassignment, host decommission, or a platform-side data issue.'); } db.close(); } main().catch(err => { console.error('Fatal error:', err); process.exit(1); });