Add IPv6 fallback display for findings without IPv4

Findings with no IPv4 address now display Qualys IPv6 or Primary IPv6 as
fallback in the IP column, with a badge indicator:
  - 'Q' (amber) = Qualys IPv6 from hostAdditionalDetails
  - 'v6' (indigo) = Primary IPv6 from assetCustomAttributes

Priority: IPv4 > Qualys IPv6 > Primary IPv6

Backend changes:
- extractFinding now captures qualysIpv6 and primaryIpv6
- New extractQualysIpv6 helper parses hostAdditionalDetails
- upsertFindingsBatch stores both fields
- API response includes qualysIpv6 and primaryIpv6
- Migration adds qualys_ipv6 and primary_ipv6 columns

The Qualys IPv6 is preferred over Primary IPv6 because it resolves in CARD
(confirmed via testing with PMADEV-1).
This commit is contained in:
Jordan Ramos
2026-06-09 13:29:43 -06:00
parent 23ea3983c8
commit 10239be83c
4 changed files with 85 additions and 9 deletions

View File

@@ -0,0 +1,32 @@
#!/usr/bin/env node
// Migration: Add qualys_ipv6 and primary_ipv6 columns to ivanti_findings
// These capture IPv6 addresses for findings that have no IPv4.
// Qualys IPv6 comes from hostAdditionalDetails; Primary IPv6 from assetCustomAttributes.
const pool = require('../db');
async function run() {
console.log('Adding IPv6 columns to ivanti_findings...');
try {
await pool.query(`
ALTER TABLE ivanti_findings
ADD COLUMN IF NOT EXISTS qualys_ipv6 TEXT DEFAULT NULL
`);
console.log('✓ qualys_ipv6 column added (or already exists)');
await pool.query(`
ALTER TABLE ivanti_findings
ADD COLUMN IF NOT EXISTS primary_ipv6 TEXT DEFAULT NULL
`);
console.log('✓ primary_ipv6 column added (or already exists)');
console.log('Migration complete.');
} catch (err) {
console.error('Migration failed:', err.message);
process.exit(1);
} finally {
await pool.end();
}
}
run();

View File

@@ -30,6 +30,7 @@ const POSTGRES_MIGRATIONS = [
'add_queue_remediation_notes_table.js',
'add_remediate_workflow_type.js',
'add_notifications_table.js',
'add_ivanti_findings_ipv6_columns.js',
];
async function runAll() {

View File

@@ -89,6 +89,22 @@ const CLOSED_COUNT_FILTERS = [
}
];
// ---------------------------------------------------------------------------
// Extract Qualys IPv6 address from hostAdditionalDetails
// Looks for "IPv6 Address" (string) or "IPv6 Addresses" (array) fields
// in the scanner-specific details from Qualys.
// ---------------------------------------------------------------------------
function extractQualysIpv6(f) {
const details = f.hostAdditionalDetails || [];
for (const entry of details) {
if (entry['IPv6 Address']) return entry['IPv6 Address'];
if (Array.isArray(entry['IPv6 Addresses']) && entry['IPv6 Addresses'].length > 0) {
return entry['IPv6 Addresses'][0];
}
}
return '';
}
// ---------------------------------------------------------------------------
// Extract only the fields we need from a raw finding object
// ---------------------------------------------------------------------------
@@ -149,7 +165,10 @@ function extractFinding(f) {
lastFoundOn: f.lastFoundOn || '',
buOwnership,
cves,
workflow
workflow,
// IPv6 fallbacks for findings with no IPv4
qualysIpv6: extractQualysIpv6(f),
primaryIpv6: f.assetCustomAttributes?.['1550_host_6']?.[0] || '',
};
}
@@ -186,7 +205,7 @@ async function upsertFindingsBatch(findings, state) {
const placeholders = [];
batch.forEach((f, idx) => {
const offset = idx * 18;
const offset = idx * 20;
values.push(
f.id,
f.hostId,
@@ -205,13 +224,15 @@ async function upsertFindingsBatch(findings, state) {
f.workflow ? f.workflow.id : null,
f.workflow ? f.workflow.state : null,
f.workflow ? f.workflow.type : null,
state
state,
f.qualysIpv6 || null,
f.primaryIpv6 || null
);
placeholders.push(
`($${offset+1}, $${offset+2}, $${offset+3}, $${offset+4}, $${offset+5}, ` +
`$${offset+6}, $${offset+7}, $${offset+8}, $${offset+9}, $${offset+10}, ` +
`$${offset+11}, $${offset+12}, $${offset+13}, $${offset+14}, $${offset+15}, ` +
`$${offset+16}, $${offset+17}, $${offset+18})`
`$${offset+16}, $${offset+17}, $${offset+18}, $${offset+19}, $${offset+20})`
);
});
@@ -220,7 +241,8 @@ async function upsertFindingsBatch(findings, state) {
id, host_id, title, severity, vrr_group,
host_name, ip_address, dns, status, sla_status,
due_date, last_found_on, bu_ownership, cves,
workflow_id, workflow_state, workflow_type, state
workflow_id, workflow_state, workflow_type, state,
qualys_ipv6, primary_ipv6
)
VALUES ${placeholders.join(', ')}
ON CONFLICT (id) DO UPDATE SET
@@ -241,6 +263,8 @@ async function upsertFindingsBatch(findings, state) {
workflow_state = EXCLUDED.workflow_state,
workflow_type = EXCLUDED.workflow_type,
state = EXCLUDED.state,
qualys_ipv6 = EXCLUDED.qualys_ipv6,
primary_ipv6 = EXCLUDED.primary_ipv6,
synced_at = NOW()
`, values);
}
@@ -1052,6 +1076,8 @@ function createIvantiFindingsRouter(db, requireAuth) {
cves: row.cves || [],
workflow: row.workflow_id ? { id: row.workflow_id, state: row.workflow_state, type: row.workflow_type } : null,
note: row.note || '',
qualysIpv6: row.qualys_ipv6 || null,
primaryIpv6: row.primary_ipv6 || null,
overrides: {
...(row.override_host_name ? { hostName: row.override_host_name } : {}),
...(row.override_dns ? { dns: row.override_dns } : {})
@@ -1108,6 +1134,8 @@ function createIvantiFindingsRouter(db, requireAuth) {
cves: row.cves || [],
workflow: row.workflow_id ? { id: row.workflow_id, state: row.workflow_state, type: row.workflow_type } : null,
note: row.note || '',
qualysIpv6: row.qualys_ipv6 || null,
primaryIpv6: row.primary_ipv6 || null,
overrides: {
...(row.override_host_name ? { hostName: row.override_host_name } : {}),
...(row.override_dns ? { dns: row.override_dns } : {})