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