chore: reorganize docs, document migrations, gitignore operational files for v1.0.0 release
This commit is contained in:
270
docs/troubleshooting/bu-reassignment-check.js
Normal file
270
docs/troubleshooting/bu-reassignment-check.js
Normal file
@@ -0,0 +1,270 @@
|
||||
#!/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);
|
||||
});
|
||||
83
docs/troubleshooting/diagnose-chart-alignment.js
Normal file
83
docs/troubleshooting/diagnose-chart-alignment.js
Normal file
@@ -0,0 +1,83 @@
|
||||
#!/usr/bin/env node
|
||||
// Diagnostic: check alignment between counts history dates and anomaly log dates
|
||||
// Usage: node backend/scripts/diagnose-chart-alignment.js
|
||||
|
||||
const path = require('path');
|
||||
const sqlite3 = require('sqlite3').verbose();
|
||||
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 || []);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function fmtDate(d) {
|
||||
if (!d) return '';
|
||||
const p = d.split('-');
|
||||
if (p.length === 3) return `${p[1]}/${p[2]}/${p[0].slice(2)}`;
|
||||
return d;
|
||||
}
|
||||
|
||||
function extractDate(ts) {
|
||||
if (!ts) return '';
|
||||
return ts.split('T')[0].split(' ')[0];
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const db = new sqlite3.Database(DB_PATH);
|
||||
|
||||
// Get counts history dates (same query as the API)
|
||||
const countsRows = await dbAll(db,
|
||||
`SELECT date FROM (
|
||||
SELECT DATE(recorded_at) AS date,
|
||||
ROW_NUMBER() OVER (PARTITION BY DATE(recorded_at) ORDER BY recorded_at DESC) AS rn
|
||||
FROM ivanti_counts_history
|
||||
) WHERE rn = 1 ORDER BY date ASC`
|
||||
);
|
||||
const countsDates = new Set(countsRows.map(r => fmtDate(r.date)));
|
||||
|
||||
// Get anomaly history (same query as the API)
|
||||
const anomalyRows = await dbAll(db,
|
||||
`SELECT sync_timestamp, newly_archived_count, returned_count, return_classification_json
|
||||
FROM ivanti_sync_anomaly_log
|
||||
ORDER BY sync_timestamp DESC LIMIT 30`
|
||||
);
|
||||
|
||||
console.log('=== Counts History Dates (last 10) ===');
|
||||
const lastTen = countsRows.slice(-10);
|
||||
for (const r of lastTen) {
|
||||
console.log(` ${r.date} → ${fmtDate(r.date)}`);
|
||||
}
|
||||
|
||||
console.log('\n=== Anomaly Log Entries with Activity ===');
|
||||
for (const a of anomalyRows) {
|
||||
if (a.newly_archived_count === 0 && a.returned_count === 0) continue;
|
||||
const rawDate = extractDate(a.sync_timestamp);
|
||||
const dateKey = fmtDate(rawDate);
|
||||
const inCounts = countsDates.has(dateKey);
|
||||
console.log(` ${a.sync_timestamp} → raw="${rawDate}" → key="${dateKey}" | archived=${a.newly_archived_count} returned=${a.returned_count} | in counts: ${inCounts ? 'YES' : '*** NO ***'}`);
|
||||
}
|
||||
|
||||
console.log('\n=== All Anomaly Dates NOT in Counts History ===');
|
||||
let missingCount = 0;
|
||||
for (const a of anomalyRows) {
|
||||
const rawDate = extractDate(a.sync_timestamp);
|
||||
const dateKey = fmtDate(rawDate);
|
||||
if (!countsDates.has(dateKey)) {
|
||||
console.log(` MISSING: ${a.sync_timestamp} → "${dateKey}" (archived=${a.newly_archived_count}, returned=${a.returned_count})`);
|
||||
missingCount++;
|
||||
}
|
||||
}
|
||||
if (missingCount === 0) console.log(' (none — all anomaly dates have matching counts history)');
|
||||
|
||||
db.close();
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error('Fatal error:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
275
docs/troubleshooting/drift-check.js
Normal file
275
docs/troubleshooting/drift-check.js
Normal file
@@ -0,0 +1,275 @@
|
||||
#!/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);
|
||||
});
|
||||
197
docs/troubleshooting/export-reassigned-findings.js
Normal file
197
docs/troubleshooting/export-reassigned-findings.js
Normal file
@@ -0,0 +1,197 @@
|
||||
#!/usr/bin/env node
|
||||
// export-reassigned-findings.js — Generate an xlsx with findings reassigned to SDIT-CSD-ITLS-PIES
|
||||
//
|
||||
// Pulls data from the archive database and the BU reassignment check results.
|
||||
// Outputs to docs/reassigned-findings-2026-04-24.xlsx
|
||||
//
|
||||
// Usage: node backend/scripts/export-reassigned-findings.js
|
||||
|
||||
require('dotenv').config({ path: require('path').join(__dirname, '..', '.env') });
|
||||
|
||||
const path = require('path');
|
||||
const sqlite3 = require('sqlite3').verbose();
|
||||
const XLSX = require('xlsx');
|
||||
const { ivantiPost } = require('../helpers/ivantiApi');
|
||||
|
||||
const DB_PATH = path.join(__dirname, '..', 'cve_database.db');
|
||||
const OUTPUT_PATH = path.join(__dirname, '..', '..', 'docs', 'reassigned-findings-2026-04-24.xlsx');
|
||||
|
||||
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 results = new Map();
|
||||
const chunkSize = 50;
|
||||
|
||||
for (let i = 0; i < findingIds.length; i += chunkSize) {
|
||||
const chunk = findingIds.slice(i, i + chunkSize);
|
||||
const idList = chunk.join(',');
|
||||
const filters = [{
|
||||
field: 'id', exclusive: false, operator: 'IN',
|
||||
orWithPrevious: false, implicitFilters: [],
|
||||
value: idList, caseSensitive: false
|
||||
}];
|
||||
|
||||
let page = 0;
|
||||
let totalPages = 1;
|
||||
do {
|
||||
try {
|
||||
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) 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';
|
||||
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 fp = fpBuckets[0] || null;
|
||||
results.set(String(f.id), {
|
||||
bu,
|
||||
severity: typeof f.severity === 'number' ? f.severity : parseFloat(f.severity) || 0,
|
||||
state: f.status || '',
|
||||
fpId: fp ? fp.generatedId : '',
|
||||
fpState: fp ? fp.state : '',
|
||||
hostName: f.host?.hostName || '',
|
||||
ipAddress: f.host?.ipAddress || '',
|
||||
title: f.title || '',
|
||||
});
|
||||
}
|
||||
page++;
|
||||
} catch (err) {
|
||||
console.error(` Error on batch at ${i}:`, err.message);
|
||||
break;
|
||||
}
|
||||
} while (page < totalPages);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
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';
|
||||
|
||||
const db = new sqlite3.Database(DB_PATH);
|
||||
|
||||
// Get all archived/closed findings from the archive
|
||||
const archived = await dbAll(db,
|
||||
`SELECT finding_id, last_severity, finding_title, host_name, ip_address, current_state,
|
||||
DATE(first_archived_at) as archived_date, DATE(last_transition_at) as last_transition_date
|
||||
FROM ivanti_finding_archives
|
||||
WHERE current_state IN ('ARCHIVED', 'CLOSED')
|
||||
ORDER BY current_state, last_severity DESC`
|
||||
);
|
||||
|
||||
const ids = archived.map(a => a.finding_id);
|
||||
console.log(`Querying Ivanti for ${ids.length} findings...`);
|
||||
const currentData = await queryByFindingIds(ids, apiKey, clientId, skipTls);
|
||||
|
||||
// Build rows for each sheet
|
||||
const reassignedRows = [];
|
||||
const goneRows = [];
|
||||
const sameBuRows = [];
|
||||
|
||||
for (const arch of archived) {
|
||||
const current = currentData.get(arch.finding_id);
|
||||
|
||||
if (!current) {
|
||||
goneRows.push({
|
||||
'Finding ID': arch.finding_id,
|
||||
'Title': arch.finding_title,
|
||||
'Last Severity': arch.last_severity,
|
||||
'Host': arch.host_name,
|
||||
'IP Address': arch.ip_address,
|
||||
'Archive State': arch.current_state,
|
||||
'Archived Date': arch.archived_date,
|
||||
'Status': 'Gone from platform',
|
||||
});
|
||||
} else if (current.bu !== 'NTS-AEO-ACCESS-ENG' && current.bu !== 'NTS-AEO-STEAM') {
|
||||
reassignedRows.push({
|
||||
'Finding ID': arch.finding_id,
|
||||
'Title': current.title || arch.finding_title,
|
||||
'Last Severity (STEAM)': arch.last_severity,
|
||||
'Current Severity': current.severity,
|
||||
'Host': current.hostName || arch.host_name,
|
||||
'IP Address': current.ipAddress || arch.ip_address,
|
||||
'Previous BU': 'NTS-AEO-STEAM / ACCESS-ENG',
|
||||
'Current BU': current.bu,
|
||||
'Current State': current.state,
|
||||
'FP Workflow': current.fpId ? `${current.fpId} (${current.fpState})` : '',
|
||||
'Archive State': arch.current_state,
|
||||
'Archived Date': arch.archived_date,
|
||||
});
|
||||
} else {
|
||||
sameBuRows.push({
|
||||
'Finding ID': arch.finding_id,
|
||||
'Title': current.title || arch.finding_title,
|
||||
'Severity': current.severity,
|
||||
'Host': current.hostName || arch.host_name,
|
||||
'IP Address': current.ipAddress || arch.ip_address,
|
||||
'BU': current.bu,
|
||||
'Current State': current.state,
|
||||
'FP Workflow': current.fpId ? `${current.fpId} (${current.fpState})` : '',
|
||||
'Archive State': arch.current_state,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Create workbook
|
||||
const wb = XLSX.utils.book_new();
|
||||
|
||||
// Sheet 1: Reassigned findings
|
||||
const ws1 = XLSX.utils.json_to_sheet(reassignedRows);
|
||||
// Set column widths
|
||||
ws1['!cols'] = [
|
||||
{ wch: 14 }, { wch: 55 }, { wch: 18 }, { wch: 16 },
|
||||
{ wch: 30 }, { wch: 16 }, { wch: 28 }, { wch: 24 },
|
||||
{ wch: 14 }, { wch: 24 }, { wch: 14 }, { wch: 14 },
|
||||
];
|
||||
XLSX.utils.book_append_sheet(wb, ws1, 'Reassigned to SDIT-PIES');
|
||||
|
||||
// Sheet 2: Gone from platform
|
||||
if (goneRows.length > 0) {
|
||||
const ws2 = XLSX.utils.json_to_sheet(goneRows);
|
||||
ws2['!cols'] = [
|
||||
{ wch: 14 }, { wch: 55 }, { wch: 14 }, { wch: 30 },
|
||||
{ wch: 16 }, { wch: 14 }, { wch: 14 }, { wch: 20 },
|
||||
];
|
||||
XLSX.utils.book_append_sheet(wb, ws2, 'Gone from Platform');
|
||||
}
|
||||
|
||||
// Sheet 3: Still same BU
|
||||
if (sameBuRows.length > 0) {
|
||||
const ws3 = XLSX.utils.json_to_sheet(sameBuRows);
|
||||
ws3['!cols'] = [
|
||||
{ wch: 14 }, { wch: 55 }, { wch: 10 }, { wch: 30 },
|
||||
{ wch: 16 }, { wch: 24 }, { wch: 14 }, { wch: 24 }, { wch: 14 },
|
||||
];
|
||||
XLSX.utils.book_append_sheet(wb, ws3, 'Still Same BU');
|
||||
}
|
||||
|
||||
// Write file
|
||||
XLSX.writeFile(wb, OUTPUT_PATH);
|
||||
console.log(`\nExported to: ${OUTPUT_PATH}`);
|
||||
console.log(` Reassigned: ${reassignedRows.length}`);
|
||||
console.log(` Gone: ${goneRows.length}`);
|
||||
console.log(` Same BU: ${sameBuRows.length}`);
|
||||
|
||||
db.close();
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error('Fatal error:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
420
docs/troubleshooting/findings-count-investigation-2026-04-24.md
Normal file
420
docs/troubleshooting/findings-count-investigation-2026-04-24.md
Normal file
@@ -0,0 +1,420 @@
|
||||
# Findings Count Drop Investigation — 2026-04-24
|
||||
|
||||
## Summary
|
||||
|
||||
On 2026-04-24, the Findings Trend chart showed a sharp drop in both open and closed counts. The total (open + closed) fell from ~170 to 31, which is inconsistent with normal finding lifecycle behavior where findings move between open and closed but the total remains roughly stable.
|
||||
|
||||
---
|
||||
|
||||
## Timeline
|
||||
|
||||
| Date | Open | Closed | Total | Notes |
|
||||
|---|---|---|---|---|
|
||||
| 04/02 | 127 | 52 | 179 | Baseline |
|
||||
| 04/11 | 116 | 51 | 167 | Normal fluctuation |
|
||||
| 04/19 | 114 | 50 | 164 | Normal fluctuation |
|
||||
| 04/20 | 86 | 84 | 170 | Batch of findings closed — total stable |
|
||||
| 04/23 | 60 | 110 | 170 | Continued closure — total stable |
|
||||
| 04/24 | 15 | 16 | 31 | Anomalous drop |
|
||||
|
||||
The 04/20 and 04/23 snapshots show the expected pattern: open decreases, closed increases, total stays at ~170. The 04/24 snapshot breaks this pattern — both open and closed dropped simultaneously.
|
||||
|
||||
---
|
||||
|
||||
## Root Cause Analysis
|
||||
|
||||
### What the dashboard queries
|
||||
|
||||
The Ivanti sync fetches findings using two API calls, both filtered to:
|
||||
|
||||
- **BU:** `NTS-AEO-ACCESS-ENG`, `NTS-AEO-STEAM`
|
||||
- **Severity range:** `8.5–9.9` VRR
|
||||
- **State:** `Open` (first call) or `Closed` (second call)
|
||||
|
||||
Any finding that no longer matches all three criteria will not appear in the results.
|
||||
|
||||
### What happened
|
||||
|
||||
A re-test of the Ivanti API on 04/24 confirmed the API itself is returning only 15 open and 16 closed findings (`totalElements` field). This is not a pagination bug or partial response — the API is reporting these as the complete result sets.
|
||||
|
||||
### Likely explanation: VRR rescore
|
||||
|
||||
The most probable cause is a bulk VRR (Vulnerability Risk Rating) rescore on the Ivanti platform. If Ivanti recalculated severity scores and a large number of findings dropped below the `8.5` threshold, they would vanish from both the open and closed query results.
|
||||
|
||||
**Key detail:** The archive table stores `last_severity` — the score at the time the finding was last seen in our sync, not the current score in Ivanti. Archived findings show severities of 9.0–9.9, but this reflects their pre-rescore values. After a rescore, these same findings could now be rated below 8.5, which is why they no longer appear in our filtered queries.
|
||||
|
||||
This explains why:
|
||||
|
||||
- **Open findings dropped** from 60 to 15 — rescored findings fell below 8.5
|
||||
- **Closed findings dropped** from 110 to 16 — the same rescore affected closed findings too
|
||||
- **Archive caught 67 disappearances** from the open set, but did not previously track disappearances from the closed set
|
||||
|
||||
### Alternative explanations
|
||||
|
||||
- **BU reassignment:** Findings moved out of `NTS-AEO-ACCESS-ENG` or `NTS-AEO-STEAM` would also disappear. Less likely at this scale.
|
||||
- **Ivanti platform issue:** Temporary data availability problem. Can be ruled out if the counts remain low on subsequent syncs.
|
||||
- **Finding decommission:** Hosts removed from Ivanti entirely. Possible for some findings but unlikely for ~140 at once.
|
||||
|
||||
---
|
||||
|
||||
## Accounting
|
||||
|
||||
As of 04/24:
|
||||
|
||||
| Category | Count | Description |
|
||||
|---|---|---|
|
||||
| Open (API) | 15 | Currently in Ivanti open set, severity 8.5–9.9 |
|
||||
| Closed (API) | 16 | Currently in Ivanti closed set, severity 8.5–9.9 |
|
||||
| Archived | 67 | Disappeared from open set, not found in closed set |
|
||||
| Archive-Closed | 63 | Were archived, then confirmed in Ivanti closed set |
|
||||
| Returned | 1 | Was archived, then reappeared in open set |
|
||||
| **Tracked total** | **162** | |
|
||||
| **Expected total** | **~170** | |
|
||||
| **Unaccounted** | **~8** | Normal churn (decommissions, new findings offsetting) |
|
||||
|
||||
The 63 archive-closed findings were previously part of the ~110 closed count on 04/23. They have since disappeared from the closed API results (likely rescored below 8.5). Before this fix, disappearances from the closed set were not tracked.
|
||||
|
||||
---
|
||||
|
||||
## Fixes Applied
|
||||
|
||||
### 1. Bad data point removed
|
||||
|
||||
The 04/24 history row (15 open / 16 closed) was deleted from `ivanti_counts_history` to prevent it from skewing the trend chart.
|
||||
|
||||
### 2. Drift guard added
|
||||
|
||||
Before writing to `ivanti_counts_history`, the sync now compares the new total (open + closed) against the most recent history entry. If the new total drops below 50% of the previous total, the history write is skipped and a warning is logged. The live cache (`ivanti_counts_cache`) is still updated so current counts remain accurate.
|
||||
|
||||
### 3. Closed-set disappearance tracking (CLOSED_GONE)
|
||||
|
||||
A new archive state `CLOSED_GONE` was added. On each sync, findings previously marked as `CLOSED` in the archive are checked against the current closed API results. If a finding is no longer in the closed set, it transitions to `CLOSED_GONE` with reason `disappeared_from_closed_set`. This closes the visibility gap where findings could vanish from the closed API results without being tracked.
|
||||
|
||||
**Migration required:** `node backend/migrations/add_closed_gone_state.js`
|
||||
|
||||
### Archive state machine (updated)
|
||||
|
||||
```
|
||||
NONE ──→ ARCHIVED ──→ RETURNED ──→ ARCHIVED (cycle)
|
||||
│ │
|
||||
▼ ▼
|
||||
CLOSED ──→ CLOSED_GONE
|
||||
```
|
||||
|
||||
| State | Meaning |
|
||||
|---|---|
|
||||
| `ARCHIVED` | Disappeared from the open findings set; not found in closed set |
|
||||
| `RETURNED` | Was archived but reappeared in the open set |
|
||||
| `CLOSED` | Confirmed present in the Ivanti closed findings set |
|
||||
| `CLOSED_GONE` | Was confirmed closed, then disappeared from the closed set |
|
||||
|
||||
### 4. Automated sync anomaly detection
|
||||
|
||||
The manual diagnostic work from this investigation was formalized into an automated feature in the sync pipeline (`backend/routes/ivantiFindings.js`). After each sync, the system now:
|
||||
|
||||
- **Classifies disappearances** — queries Ivanti without BU/severity filters for newly archived finding IDs and labels each as `bu_reassignment`, `severity_drift`, `closed_on_platform`, or `decommissioned`. The classification is stored on the archive transition record, replacing the generic `severity_score_drift` default.
|
||||
- **Logs anomaly summaries** — writes a breakdown of count changes to `ivanti_sync_anomaly_log` after each sync, flagging syncs where more than 5 findings are archived as significant.
|
||||
- **Tracks BU changes per finding** — compares each finding's BU against the previous sync and records changes in `ivanti_finding_bu_history`.
|
||||
- **Surfaces anomalies in the UI** — an amber warning banner on the Vulnerability Triage page displays the latest anomaly summary when a significant count change is detected.
|
||||
|
||||
API endpoints for anomaly data: `GET /api/ivanti/findings/anomaly/latest`, `GET /api/ivanti/findings/anomaly/history`, `GET /api/ivanti/findings/bu-changes`, `GET /api/ivanti/findings/:findingId/bu-history`.
|
||||
|
||||
**Migration required:** `node backend/migrations/add_sync_anomaly_tables.js`
|
||||
|
||||
---
|
||||
|
||||
## Recommended Follow-Up
|
||||
|
||||
1. **Check with Ivanti platform team** whether a bulk VRR rescore occurred around 04/23–04/24.
|
||||
2. **Monitor the next few syncs** to see if counts stabilize at the new level or recover.
|
||||
3. **Consider querying without the severity filter** as a one-time diagnostic to see the true total of findings across all severities for the two BUs. This would confirm whether the findings still exist at lower severity scores.
|
||||
|
||||
---
|
||||
|
||||
## Appendix: Cached Data Analysis
|
||||
|
||||
A cross-reference of the 04/22 findings export against the current cached data and archive was performed to test the score drift hypothesis.
|
||||
|
||||
### Export reconciliation (04/22 export — ~80 open findings)
|
||||
|
||||
| Current status | Count |
|
||||
|---|---|
|
||||
| Still open in API | 8 |
|
||||
| Archived (disappeared from open set) | 44 |
|
||||
| Closed (confirmed in Ivanti closed set) | 26 |
|
||||
| Untracked | 0 |
|
||||
| **Total** | **78** |
|
||||
|
||||
Every finding from the export is accounted for. Zero findings are untracked.
|
||||
|
||||
### What disappeared on 04/24 (43 findings archived that day)
|
||||
|
||||
| Vulnerability | Count | Last-seen severity |
|
||||
|---|---|---|
|
||||
| OpenSSH regreSSHion (CVE-2024-6387) | 36 | 9.38 |
|
||||
| OpenSSH Multiple Security Vulnerabilities | 3 | 9.9 |
|
||||
| Rocky Linux sudo update (RLSA-2025:9978) | 2 | 9.06 |
|
||||
| Rocky Linux sqlite update (RLSA-2025:20936) | 1 | 9.9 |
|
||||
| Rocky Linux sqlite update (RLSA-2025:11992) | 1 | 9.9 |
|
||||
|
||||
### What survived (15 findings still in API)
|
||||
|
||||
| Vulnerability | Count | Current severity |
|
||||
|---|---|---|
|
||||
| OpenSSH Multiple Security Vulnerabilities | 9 | 9.9 |
|
||||
| OpenSSH regreSSHion (CVE-2024-6387) | 4 | 9.38 |
|
||||
| OpenSSH 7.4 Not Installed Multiple Vulnerabilities | 1 | 9.18 |
|
||||
| Rocky Linux sudo update (RLSA-2025:9978) | 1 | 9.06 |
|
||||
|
||||
### Conclusion: host-level VRR drift
|
||||
|
||||
The pattern is consistent with **host-level VRR score drift**, not a blanket CVE rescore. Key evidence:
|
||||
|
||||
- **Selective disappearance within the same CVE:** 36 of 40 regreSSHion findings disappeared, but 4 survived at the same last-seen severity (9.38). If the CVE itself were rescored, all would be affected equally.
|
||||
- **Same pattern for OpenSSH Multiple:** 3 of 12 disappeared at 9.9, while 9 survived at 9.9.
|
||||
- **High last-seen severities:** All disappeared findings had severities well above the 8.5 threshold (9.06–9.9), but `last_severity` reflects the score at time of last sync, not the current Ivanti score. A host-level rescore could move individual findings below 8.5 while leaving others on different hosts unchanged.
|
||||
|
||||
Ivanti calculates VRR per host-finding combination using factors like network exposure, asset criticality, and compensating controls. A platform-side recalculation of these host-level factors would produce exactly this pattern — some hosts for the same CVE drop below threshold while others remain above it.
|
||||
|
||||
**To fully confirm:** Query Ivanti without the severity filter for the disappeared finding IDs and check their current VRR scores. If they now show scores below 8.5, host-level drift is confirmed.
|
||||
|
||||
---
|
||||
|
||||
## Appendix B: Unfiltered API Query Results (04/24)
|
||||
|
||||
A follow-up diagnostic queried Ivanti **without the severity filter** to check whether the disappeared findings still exist at lower severity scores.
|
||||
|
||||
### Unfiltered totals
|
||||
|
||||
| State | Count (no severity filter) | Count (8.5–9.9 filter) |
|
||||
|---|---|---|
|
||||
| Open | 1,404 | 15 |
|
||||
| Closed | 280 | 16 |
|
||||
| **Total** | **1,684** | **31** |
|
||||
|
||||
The BUs have 1,684 total findings across all severities. The severity filter narrows this to 31.
|
||||
|
||||
### Cross-reference against 130 archived/closed findings
|
||||
|
||||
| Category | Count | Meaning |
|
||||
|---|---|---|
|
||||
| Completely gone from API | 124 | Not in Ivanti at any severity, open or closed |
|
||||
| Confirmed score drift | 1 | Juniper finding dropped from 9.0 to 7.57 |
|
||||
| Still high severity (>= 8.5) | 5 | Still in Ivanti closed set at original scores |
|
||||
|
||||
### Verdict: Score drift hypothesis DISPROVED
|
||||
|
||||
Only 1 of 130 findings actually drifted below the severity threshold. **124 findings are completely absent from the Ivanti API at any severity in any state.** They were not rescored — they were removed from the platform entirely.
|
||||
|
||||
This rules out VRR score drift as the primary cause and points to one of:
|
||||
|
||||
- **Host decommission / asset removal** — the hosts were removed from Ivanti's asset inventory
|
||||
- **BU reassignment** — the hosts were moved out of `NTS-AEO-ACCESS-ENG` / `NTS-AEO-STEAM` to a different business unit
|
||||
- **Platform-side data cleanup** — findings were purged or merged on the Ivanti side
|
||||
|
||||
Given the scale (124 findings disappearing simultaneously), a bulk operation on the Ivanti platform is the most likely explanation. This should be raised with the Ivanti platform administrators to determine what changed.
|
||||
|
||||
### Diagnostic script
|
||||
|
||||
The unfiltered query was originally performed using `backend/scripts/drift-check.js`. This logic has since been automated by the sync anomaly detection feature — the BU drift checker in `backend/routes/ivantiFindings.js` now runs these checks automatically after each sync. See the anomaly API endpoints (`/api/ivanti/findings/anomaly/latest`, `/api/ivanti/findings/bu-changes`) for current data.
|
||||
|
||||
---
|
||||
|
||||
## Appendix C: BU Reassignment Confirmation (04/24)
|
||||
|
||||
A follow-up query searched for the disappeared finding IDs with **no filters at all** (no BU, no severity, no state) to determine whether the findings still exist in Ivanti under a different business unit.
|
||||
|
||||
### Results
|
||||
|
||||
| Category | Count | Detail |
|
||||
|---|---|---|
|
||||
| Reassigned to `SDIT-CSD-ITLS-PIES` | 109 | Hosts moved to different BU |
|
||||
| Still same BU (STEAM/ACCESS-ENG) | 6 | 5 closed (timing), 1 severity drift (7.57) |
|
||||
| Completely gone from platform | 15 | Not found at any BU, severity, or state |
|
||||
|
||||
### Verdict: BU REASSIGNMENT CONFIRMED
|
||||
|
||||
**109 of 130 disappeared findings were reassigned from `NTS-AEO-STEAM` / `NTS-AEO-ACCESS-ENG` to `SDIT-CSD-ITLS-PIES`.** The severity scores are unchanged — the findings still exist at 9.38 and 9.9 — but they no longer match the dashboard's BU filter.
|
||||
|
||||
This is not score drift, not a platform bug, and not a data purge. It is a deliberate (or accidental) bulk BU reassignment on the Ivanti platform.
|
||||
|
||||
### FP workflow impact
|
||||
|
||||
69 of the 109 reassigned findings have FP workflows attached, predominantly `FP#0000459 (Approved)`. These are false positive approvals that were submitted by the STEAM/ACCESS-ENG team. The FP workflows followed the findings to the new BU. This should be reviewed with the team that performed the reassignment to determine whether the FP approvals are still valid under the new BU context.
|
||||
|
||||
### 15 truly gone findings
|
||||
|
||||
15 findings are not found in Ivanti at any BU, severity, or state. These are likely decommissioned hosts. All 15 are `OpenSSH Remote Unauthenticated Code Execution Vulnerability (regreSSHion)` at severity 9.30–9.38.
|
||||
|
||||
### Reassigned findings — 109 findings moved to `SDIT-CSD-ITLS-PIES`
|
||||
|
||||
**With approved FP workflows (58 findings):**
|
||||
|
||||
| Finding ID | Severity | FP Workflow | Host | IP Address |
|
||||
|---|---|---|---|---|
|
||||
| `2687687777` | 9.38 | FP#0000459 (Approved) | syn-098-120-000-078 | 98.120.0.78 |
|
||||
| `2687714078` | 9.38 | FP#0000459 (Approved) | syn-098-120-032-185 | 98.120.32.185 |
|
||||
| `2561784254` | 9.38 | FP#0000459 (Approved) | mon15-agg-sw | 10.240.78.177 |
|
||||
| `2561788625` | 9.38 | FP#0000459 (Approved) | mon16-agg-sw | 10.240.78.176 |
|
||||
| `2689641701` | 9.38 | FP#0000459 (Approved) | mon15-sw14 | 10.240.78.133 |
|
||||
| `2689642036` | 9.38 | FP#0000459 (Approved) | mon15-sw11 | 10.240.78.130 |
|
||||
| `2689642107` | 9.38 | FP#0000459 (Approved) | mon19-sw3 | 10.240.78.150 |
|
||||
| `2689642299` | 9.38 | FP#0000459 (Approved) | mon16-sw2 | 10.240.78.107 |
|
||||
| `2689643552` | 9.38 | FP#0000459 (Approved) | mon16-sw5 | 10.240.78.110 |
|
||||
| `2689645817` | 9.38 | FP#0000459 (Approved) | mon16-sw1 | 10.240.78.106 |
|
||||
| `2689646279` | 9.38 | FP#0000459 (Approved) | mon19-sw2 | 10.240.78.149 |
|
||||
| `2689647223` | 9.38 | FP#0000459 (Approved) | mon19-sw7 | 10.240.78.154 |
|
||||
| `2689647732` | 9.38 | FP#0000459 (Approved) | mon16-sw6 | 10.240.78.111 |
|
||||
| `2689662078` | 9.38 | FP#0000459 (Approved) | mon19-sw6 | 10.240.78.153 |
|
||||
| `2689662169` | 9.38 | FP#0000459 (Approved) | mon15-sw13 | 10.240.78.132 |
|
||||
| `2689667727` | 9.38 | FP#0000459 (Approved) | mon16-sw10 | 10.240.78.115 |
|
||||
| `2689674347` | 9.38 | FP#0000459 (Approved) | mon16-sw4 | 10.240.78.109 |
|
||||
| `2689680179` | 9.38 | FP#0000459 (Approved) | mon16-sw7 | 10.240.78.112 |
|
||||
| `2689687694` | 9.38 | FP#0000459 (Approved) | mon16-sw14 | 10.240.78.119 |
|
||||
| `2689703211` | 9.38 | FP#0000459 (Approved) | mon16-sw9 | 10.240.78.114 |
|
||||
| `2689704574` | 9.38 | FP#0000459 (Approved) | mon16-sw13 | 10.240.78.118 |
|
||||
| `2689707099` | 9.38 | FP#0000459 (Approved) | mon16-sw12 | 10.240.78.117 |
|
||||
| `2689711822` | 9.38 | FP#0000459 (Approved) | mon16-sw3 | 10.240.78.108 |
|
||||
| `2689712725` | 9.38 | FP#0000459 (Approved) | mon19-sw8 | 10.240.78.155 |
|
||||
| `2689715642` | 9.38 | FP#0000459 (Approved) | mon19-sw10 | 10.240.78.157 |
|
||||
| `2689717728` | 9.38 | FP#0000459 (Approved) | mon19-sw4 | 10.240.78.151 |
|
||||
| `2689721708` | 9.38 | FP#0000459 (Approved) | mon16-sw11 | 10.240.78.116 |
|
||||
| `2689722995` | 9.38 | FP#0000459 (Approved) | mon19-sw5 | 10.240.78.152 |
|
||||
| `2689723147` | 9.38 | FP#0000459 (Approved) | mon19-sw14 | 10.240.78.161 |
|
||||
| `2689723478` | 9.38 | FP#0000459 (Approved) | mon19-sw13 | 10.240.78.160 |
|
||||
| `2689723840` | 9.38 | FP#0000459 (Approved) | mon19-sw12 | 10.240.78.159 |
|
||||
| `2697106042` | 9.38 | FP#0000459 (Approved) | mon19-sw11 | 10.240.78.158 |
|
||||
| `2697107537` | 9.38 | FP#0000459 (Approved) | mon15-sw4 | 10.240.78.123 |
|
||||
| `2697108314` | 9.38 | FP#0000459 (Approved) | mon20-sw4 | 10.240.78.137 |
|
||||
| `2726771499` | 9.38 | FP#0000459 (Approved) | mon19-sw1 | 10.240.78.148 |
|
||||
| `2726805076` | 9.38 | FP#0000459 (Approved) | mon15-sw6 | 10.240.78.125 |
|
||||
| `2726863413` | 9.38 | FP#0000459 (Approved) | mon19-sw9 | 10.240.78.156 |
|
||||
| `2283414173` | 9.38 | FP#0000459 (Approved) | | 10.241.0.63 |
|
||||
| `2283664248` | 9.38 | FP#0000459 (Approved) | apc01se1shcc-n01-bmc | 10.244.11.51 |
|
||||
| `2460786621` | 9.38 | FP#0000459 (Approved) | | 172.27.72.1 |
|
||||
| `2521773008` | 9.38 | FP#0000459 (Approved) | | 96.37.185.145 |
|
||||
| `2663675680` | 9.38 | FP#0000459 (Approved) | mon17-sw9 | 10.240.78.170 |
|
||||
| `2663676188` | 9.38 | FP#0000459 (Approved) | mon17-sw11 | 10.240.78.172 |
|
||||
| `2663676366` | 9.38 | FP#0000459 (Approved) | mon17-sw8 | 10.240.78.169 |
|
||||
| `2663676895` | 9.38 | FP#0000459 (Approved) | mon17-sw5 | 10.240.78.166 |
|
||||
| `2663677778` | 9.38 | FP#0000459 (Approved) | mon17-sw13 | 10.240.78.174 |
|
||||
| `2663677987` | 9.38 | FP#0000459 (Approved) | mon17-sw12 | 10.240.78.173 |
|
||||
| `2663681315` | 9.38 | FP#0000459 (Approved) | mon17-sw6 | 10.240.78.167 |
|
||||
| `2663683699` | 9.38 | FP#0000459 (Approved) | mon17-sw14 | 10.240.78.175 |
|
||||
| `2663685466` | 9.38 | FP#0000459 (Approved) | mon17-sw7 | 10.240.78.168 |
|
||||
| `2663695383` | 9.38 | FP#0000459 (Approved) | mon17-sw10 | 10.240.78.171 |
|
||||
| `2744240319` | 9.38 | FP#0000459 (Approved) | syn-066-061-128-010 | 66.61.128.10 |
|
||||
| `2744252609` | 9.38 | FP#0000459 (Approved) | apa01se1shcc-bvi101-secondary | 66.61.128.233 |
|
||||
| `2744261786` | 9.38 | FP#0000459 (Approved) | syn-066-061-128-049 | 66.61.128.49 |
|
||||
| `2744295544` | 9.38 | FP#0000459 (Approved) | syn-066-061-128-018 | 66.61.128.18 |
|
||||
| `2312013545` | 9.90 | FP#0000459 (Approved) | | 10.244.4.26 |
|
||||
| `2329805541` | 9.90 | FP#0000459 (Approved) | | 10.244.11.5 |
|
||||
| `2329818159` | 9.90 | FP#0000459 (Approved) | | 10.244.11.6 |
|
||||
|
||||
**With rejected FP workflows (8 findings):**
|
||||
|
||||
| Finding ID | Severity | FP Workflow | Host | IP Address |
|
||||
|---|---|---|---|---|
|
||||
| `2281232044` | 9.38 | FP#0000460 (Rejected) | apc15se1shcc-n03 | 10.244.4.55 |
|
||||
| `2281440017` | 9.38 | FP#0000460 (Rejected) | apc01se1shcc-n03-bmc | 10.244.11.53 |
|
||||
| `2282142049` | 9.38 | FP#0000460 (Rejected) | | 10.244.4.30 |
|
||||
| `2282338246` | 9.38 | FP#0000460 (Rejected) | apc04se1shcc-n01-cimc | 10.244.11.63 |
|
||||
| `2283364439` | 9.90 | FP#0000470 (Rejected) | | 24.28.208.125 |
|
||||
| `2283577805` | 9.90 | FP#0000470 (Rejected) | syn-024-028-210-101 | 24.28.210.101 |
|
||||
| `2283734550` | 9.90 | FP#0000452 (Rejected) | | 10.244.11.27 |
|
||||
| `2286607835` | 9.90 | FP#0000452 (Rejected) | | 10.240.1.203 |
|
||||
|
||||
**Without FP workflows (43 findings):**
|
||||
|
||||
| Finding ID | Severity | Host | IP Address | Title |
|
||||
|---|---|---|---|---|
|
||||
| `2289169183` | 9.90 | | 10.240.78.20 | IPMI 2.0 RAKP Authentication |
|
||||
| `2458498036` | 9.90 | eon-node-dhcp | | OpenSSH Multiple Security Vulnerabilities |
|
||||
| `2352647807` | 9.90 | localhost | | Rocky Linux sqlite update (RLSA-2025:20936) |
|
||||
| `2312562977` | 9.90 | rphy-runner-falconv | | Rocky Linux sqlite update (RLSA-2025:11992) |
|
||||
| `2352629939` | 9.90 | rphy-runner-falconv | | Rocky Linux sqlite update (RLSA-2025:20936) |
|
||||
| `2281281250` | 9.38 | | 172.16.1.229 | OpenSSH regreSSHion |
|
||||
| `2282419417` | 9.38 | | 10.244.11.96 | OpenSSH regreSSHion |
|
||||
| `2282688566` | 9.38 | apc02se1shcc-n01-cimc | 10.244.11.54 | OpenSSH regreSSHion |
|
||||
| `2283112486` | 9.38 | apc14se1shcc-n02 | 10.244.4.51 | OpenSSH regreSSHion |
|
||||
| `2283720427` | 9.38 | | 10.244.11.86 | OpenSSH regreSSHion |
|
||||
| `2283873511` | 9.38 | apc02se1shcc-n02-cimc | 10.244.11.55 | OpenSSH regreSSHion |
|
||||
| `2284154592` | 9.38 | syn-024-028-208-105 | 24.28.208.105 | OpenSSH regreSSHion |
|
||||
| `2284337626` | 9.38 | apc14se1shcc-n01 | 10.244.4.50 | OpenSSH regreSSHion |
|
||||
| `2284372435` | 9.38 | apc15se1shcc-n01 | 10.244.4.53 | OpenSSH regreSSHion |
|
||||
| `2284395753` | 9.38 | apc07se1shcc-n02-cimc | 10.244.11.73 | OpenSSH regreSSHion |
|
||||
| `2284622624` | 9.38 | apc04se1shcc-n02-cimc | 10.244.11.64 | OpenSSH regreSSHion |
|
||||
| `2284681286` | 9.38 | apc15se1shcc-n02 | 10.244.4.54 | OpenSSH regreSSHion |
|
||||
| `2285988119` | 9.38 | | 10.244.4.28 | OpenSSH regreSSHion |
|
||||
| `2286255181` | 9.38 | | 10.244.11.94 | OpenSSH regreSSHion |
|
||||
| `2286422988` | 9.38 | c220-wzp27340ss5 | 10.241.0.43 | OpenSSH regreSSHion |
|
||||
| `2286541484` | 9.38 | apc02se1shcc-n03-cimc | 10.244.11.56 | OpenSSH regreSSHion |
|
||||
| `2286589497` | 9.38 | apc05se1shcc-n01-bmc | 10.244.11.66 | OpenSSH regreSSHion |
|
||||
| `2287156417` | 9.38 | apc13se1shcc-n01 | 10.244.4.47 | OpenSSH regreSSHion |
|
||||
| `2287168608` | 9.38 | apc13se1shcc-n03 | 10.244.4.49 | OpenSSH regreSSHion |
|
||||
| `2287400005` | 9.38 | apc14se1shcc-n03 | 10.244.4.52 | OpenSSH regreSSHion |
|
||||
| `2287503960` | 9.38 | apc07se1shcc-n01-cimc | 10.244.11.72 | OpenSSH regreSSHion |
|
||||
| `2287822934` | 9.38 | apc02ctsbcom7-n03-cimc | 10.244.4.25 | OpenSSH regreSSHion |
|
||||
| `2287849796` | 9.38 | | 10.244.4.29 | OpenSSH regreSSHion |
|
||||
| `2287917789` | 9.38 | apc07se1shcc-n03-cimc | 10.244.11.74 | OpenSSH regreSSHion |
|
||||
| `2287954330` | 9.38 | apc13se1shcc-n02 | 10.244.4.48 | OpenSSH regreSSHion |
|
||||
| `2288500154` | 9.38 | apc04se1shcc-n03-cimc | 10.244.11.65 | OpenSSH regreSSHion |
|
||||
| `2288545686` | 9.38 | apc02ctsbcom7-n02-cimc | 10.244.4.24 | OpenSSH regreSSHion |
|
||||
| `2288829837` | 9.38 | | 10.244.11.87 | OpenSSH regreSSHion |
|
||||
| `2288874420` | 9.38 | apc05se1shcc-n03-bmc | 10.244.11.68 | OpenSSH regreSSHion |
|
||||
| `2289487733` | 9.38 | apc05se1shcc-n02-bmc | 10.244.11.67 | OpenSSH regreSSHion |
|
||||
| `2289651084` | 9.38 | apc02ctsbcom7-n01-cimc | 10.244.4.23 | OpenSSH regreSSHion |
|
||||
| `2289802898` | 9.38 | | 10.244.11.57 | OpenSSH regreSSHion |
|
||||
| `2454510043` | 9.38 | | 10.244.11.95 | OpenSSH regreSSHion |
|
||||
| `2687702557` | 9.38 | syn-098-120-032-145 | 98.120.32.145 | OpenSSH regreSSHion |
|
||||
| `2687710954` | 9.38 | syn-098-120-000-129 | 98.120.0.129 | OpenSSH regreSSHion |
|
||||
| `2284209398` | 9.06 | rphy-runner-vecima | 68.114.184.84 | Rocky Linux sudo update (RLSA-2025:9978) |
|
||||
| `2288585418` | 9.06 | rphy-runner-falconv | | Rocky Linux sudo update (RLSA-2025:9978) |
|
||||
| `2728824329` | 8.50 | localhost | | Rocky Linux kernel update (RLSA-2026:6570) |
|
||||
|
||||
---
|
||||
|
||||
### Still same BU — 6 findings
|
||||
|
||||
| Finding ID | Severity | Current State | BU | Host | IP Address |
|
||||
|---|---|---|---|---|---|
|
||||
| `2359379898` | 9.06 | Closed | NTS-AEO-STEAM | aeo-bpa-app-01-lab | |
|
||||
| `2286639694` | 9.38 | Closed | NTS-AEO-STEAM | syn-024-024-116-183 | 24.24.116.183 |
|
||||
| `2744295322` | 7.57 | Open | NTS-AEO-STEAM | ana01pongcoc1 | 96.37.185.81 |
|
||||
| `2687694321` | 9.38 | Closed | NTS-AEO-ACCESS-ENG | asa04chaococ1 | 98.120.32.167 |
|
||||
| `2687701818` | 9.38 | Closed | NTS-AEO-ACCESS-ENG | asr01chaococ1 | 98.120.32.180 |
|
||||
| `2687702475` | 9.38 | Closed | NTS-AEO-ACCESS-ENG | asr02chaococ1 | 98.120.32.181 |
|
||||
|
||||
> Finding `2744295322` is the only confirmed score drift case — dropped from 9.0 to 7.57. The other 5 are in the Closed state and still match the BU and severity filters; they were likely closed between syncs.
|
||||
|
||||
---
|
||||
|
||||
### Completely gone from platform — 15 findings
|
||||
|
||||
These findings are not found in Ivanti at any BU, severity, or state. All are OpenSSH regreSSHion (CVE-2024-6387).
|
||||
|
||||
| Finding ID | Last Severity | Host | IP Address |
|
||||
|---|---|---|---|
|
||||
| `2283426805` | 9.38 | | 10.244.3.136 |
|
||||
| `2284481283` | 9.38 | | 10.244.3.165 |
|
||||
| `2285495688` | 9.38 | | 10.244.3.134 |
|
||||
| `2285658756` | 9.38 | | 10.244.3.137 |
|
||||
| `2285828688` | 9.38 | | 10.244.3.133 |
|
||||
| `2286763965` | 9.38 | | 10.244.3.135 |
|
||||
| `2286932880` | 9.38 | | 10.244.3.166 |
|
||||
| `2288594216` | 9.38 | | 10.244.3.164 |
|
||||
| `2289475366` | 9.38 | | 10.244.3.132 |
|
||||
| `2662566450` | 9.38 | syn-065-185-198-071 | 65.185.198.71 |
|
||||
| `2662633263` | 9.38 | syn-065-185-198-070 | 65.185.198.70 |
|
||||
| `2687700013` | 9.38 | syn-098-120-032-166 | 98.120.32.166 |
|
||||
| `2687707862` | 9.38 | syn-098-120-032-182 | 98.120.32.182 |
|
||||
| `2613547630` | 9.30 | 096-037-187-009 | 96.37.187.9 |
|
||||
| `2613548575` | 9.30 | 096-037-187-017 | 96.37.187.17 |
|
||||
|
||||
> The `10.244.3.x` subnet (9 findings) suggests a cluster of hosts that were decommissioned or removed from Ivanti's asset inventory entirely.
|
||||
|
||||
---
|
||||
|
||||
### Diagnostic scripts
|
||||
|
||||
The `drift-check.js` and `bu-reassignment-check.js` scripts used during this investigation have been removed from the repository. Their logic is now automated by the sync anomaly detection feature in `backend/routes/ivantiFindings.js`, which classifies disappearances as BU reassignment, severity drift, closure, or decommission after each sync.
|
||||
Reference in New Issue
Block a user