#!/usr/bin/env node // bu-reassignment-check.js — Check if disappeared findings were reassigned to a different BU // // Queries Ivanti for the specific finding IDs that are completely gone from our // BU-filtered results, using NO filters at all (just the finding IDs). // If they come back with a different BU, that confirms BU reassignment. // // Usage: node backend/scripts/bu-reassignment-check.js 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'); function dbAll(db, sql, params = []) { return new Promise((resolve, reject) => { db.all(sql, params, (err, rows) => { if (err) reject(err); else resolve(rows || []); }); }); } async function queryByFindingIds(findingIds, apiKey, clientId, skipTls) { const urlPath = `/client/${encodeURIComponent(clientId)}/hostFinding/search`; const allResults = []; // Ivanti's IN filter can handle batches — but let's chunk to be safe const chunkSize = 50; for (let i = 0; i < findingIds.length; i += chunkSize) { const chunk = findingIds.slice(i, i + chunkSize); const idList = chunk.join(','); // Query with ONLY the finding ID filter — no BU, no severity, no state const filters = [ { field: 'id', exclusive: false, operator: 'IN', orWithPrevious: false, implicitFilters: [], value: idList, caseSensitive: false } ]; let page = 0; let totalPages = 1; do { const body = { filters, projection: 'internal', sort: [{ field: 'severity', direction: 'ASC' }], page, size: 100 }; try { const result = await ivantiPost(urlPath, body, apiKey, skipTls); if (result.status !== 200) { console.error(` API returned status ${result.status} for chunk starting at ${i}`); break; } const data = JSON.parse(result.body); totalPages = data.page?.totalPages || 1; const findings = data._embedded?.hostFindings || []; for (const f of findings) { const bu = f.assetCustomAttributes?.['1550_host_1']?.[0] || 'UNKNOWN'; allResults.push({ id: String(f.id), severity: typeof f.severity === 'number' ? f.severity : parseFloat(f.severity) || 0, title: f.title || '', hostName: f.host?.hostName || '', ipAddress: f.host?.ipAddress || '', state: f.status || f.generic_state || '', bu, // Check for FP workflow fpWorkflow: extractFP(f) }); } console.error(` Chunk ${Math.floor(i/chunkSize)+1}: page ${page+1}/${totalPages}, ${findings.length} results`); page++; } catch (err) { console.error(` Error querying chunk at ${i}:`, err.message); break; } } while (page < totalPages); } return allResults; } function extractFP(f) { const wfDist = f.workflowDistribution || {}; const fpBuckets = [ ...(wfDist.approvedWorkflows || []), ...(wfDist.actionableWorkflows || []), ...(wfDist.requestedWorkflows || []), ...(wfDist.reworkedWorkflows || []), ...(wfDist.rejectedWorkflows || []), ...(wfDist.expiredWorkflows || []), ].filter(w => (w.generatedId || '').startsWith('FP#')); const entry = fpBuckets[0]; if (!entry) return null; return { id: entry.generatedId, state: entry.state }; } 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'); process.exit(1); } const db = new sqlite3.Database(DB_PATH); // Get the 124 finding IDs that were completely gone from BU-filtered results const goneFindings = await dbAll(db, `SELECT finding_id, last_severity, finding_title, current_state FROM ivanti_finding_archives WHERE current_state IN ('ARCHIVED', 'CLOSED')` ); const goneIds = goneFindings.map(f => f.finding_id); console.error(`\n=== BU Reassignment Check ===`); console.error(`Querying Ivanti for ${goneIds.length} disappeared finding IDs (no BU/severity/state filter)...\n`); const results = await queryByFindingIds(goneIds, apiKey, clientId, skipTls); const foundMap = new Map(results.map(r => [r.id, r])); // Categorize const reassigned = []; // Found with different BU const sameBU = []; // Found with same BU (STEAM or ACCESS-ENG) const notFound = []; // Still not found even without filters const withFP = []; // Has an FP workflow (any state) for (const arch of goneFindings) { const found = foundMap.get(arch.finding_id); if (!found) { notFound.push(arch); } else if (found.bu !== 'NTS-AEO-ACCESS-ENG' && found.bu !== 'NTS-AEO-STEAM') { reassigned.push({ ...arch, currentBU: found.bu, currentSeverity: found.severity, currentState: found.state, fp: found.fpWorkflow }); if (found.fpWorkflow) withFP.push({ ...arch, ...found }); } else { sameBU.push({ ...arch, currentBU: found.bu, currentSeverity: found.severity, currentState: found.state, fp: found.fpWorkflow }); if (found.fpWorkflow) withFP.push({ ...arch, ...found }); } } console.log(''); console.log('='.repeat(130)); console.log('BU REASSIGNMENT CHECK RESULTS'); console.log('='.repeat(130)); console.log(`\nREASSIGNED TO DIFFERENT BU: ${reassigned.length} findings`); console.log('-'.repeat(130)); if (reassigned.length > 0) { console.log( 'Finding ID'.padEnd(14) + 'Archive State'.padEnd(15) + 'Last Sev'.padEnd(10) + 'Current Sev'.padEnd(13) + 'Current BU'.padEnd(30) + 'FP Workflow'.padEnd(25) + 'Title' ); console.log('-'.repeat(130)); for (const f of reassigned) { const fpStr = f.fp ? `${f.fp.id} (${f.fp.state})` : '-'; console.log( f.finding_id.padEnd(14) + f.current_state.padEnd(15) + f.last_severity.toFixed(2).padEnd(10) + f.currentSeverity.toFixed(2).padEnd(13) + f.currentBU.padEnd(30) + fpStr.padEnd(25) + f.finding_title.substring(0, 40) ); } } console.log(`\nSTILL SAME BU (but missing from filtered results): ${sameBU.length} findings`); console.log('-'.repeat(130)); if (sameBU.length > 0) { console.log( 'Finding ID'.padEnd(14) + 'Archive State'.padEnd(15) + 'Last Sev'.padEnd(10) + 'Current Sev'.padEnd(13) + 'Current BU'.padEnd(30) + 'Current State'.padEnd(15) + 'Title' ); console.log('-'.repeat(130)); for (const f of sameBU) { console.log( f.finding_id.padEnd(14) + f.current_state.padEnd(15) + f.last_severity.toFixed(2).padEnd(10) + f.currentSeverity.toFixed(2).padEnd(13) + f.currentBU.padEnd(30) + f.currentState.padEnd(15) + f.finding_title.substring(0, 40) ); } } console.log(`\nCOMPLETELY GONE (not found even without any filters): ${notFound.length} findings`); if (notFound.length > 0 && notFound.length <= 20) { console.log('-'.repeat(130)); for (const f of notFound) { console.log(` ${f.finding_id} ${f.last_severity.toFixed(2)} ${f.finding_title.substring(0, 60)}`); } } if (withFP.length > 0) { console.log(`\nFINDINGS WITH FP WORKFLOWS: ${withFP.length}`); console.log('-'.repeat(130)); for (const f of withFP) { const fpStr = f.fpWorkflow ? `${f.fpWorkflow.id} (${f.fpWorkflow.state})` : f.fp ? `${f.fp.id} (${f.fp.state})` : '-'; console.log(` ${f.finding_id || f.id} ${fpStr} ${f.bu || f.currentBU} ${(f.finding_title || f.title || '').substring(0, 50)}`); } } // Summary console.log(''); console.log('='.repeat(130)); console.log('SUMMARY'); console.log('='.repeat(130)); console.log(` Total disappeared findings checked: ${goneFindings.length}`); console.log(` Reassigned to different BU: ${reassigned.length}`); console.log(` Still same BU (unexpected): ${sameBU.length}`); console.log(` Completely gone from platform: ${notFound.length}`); console.log(` Have FP workflows: ${withFP.length}`); if (reassigned.length > 0) { const buCounts = {}; reassigned.forEach(f => { buCounts[f.currentBU] = (buCounts[f.currentBU] || 0) + 1; }); console.log('\n BU reassignment breakdown:'); for (const [bu, cnt] of Object.entries(buCounts).sort((a, b) => b[1] - a[1])) { console.log(` ${bu}: ${cnt} findings`); } } if (reassigned.length > goneFindings.length * 0.5) { console.log('\n VERDICT: BU REASSIGNMENT CONFIRMED.'); } else if (notFound.length > goneFindings.length * 0.5) { console.log('\n VERDICT: Findings removed from platform entirely (decommission or data purge).'); } else { console.log('\n VERDICT: Mixed causes — review individual categories above.'); } db.close(); } main().catch(err => { console.error('Fatal error:', err); process.exit(1); });