271 lines
10 KiB
JavaScript
271 lines
10 KiB
JavaScript
#!/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);
|
|
});
|