From 10239be83c9bf866b9c94509d1fb3ae5ce1c66c3 Mon Sep 17 00:00:00 2001 From: Jordan Ramos Date: Tue, 9 Jun 2026 13:29:43 -0600 Subject: [PATCH] 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). --- .../add_ivanti_findings_ipv6_columns.js | 32 ++++++++++++++++ backend/migrations/run-all.js | 1 + backend/routes/ivantiFindings.js | 38 ++++++++++++++++--- .../src/components/pages/ReportingPage.js | 23 +++++++++-- 4 files changed, 85 insertions(+), 9 deletions(-) create mode 100644 backend/migrations/add_ivanti_findings_ipv6_columns.js diff --git a/backend/migrations/add_ivanti_findings_ipv6_columns.js b/backend/migrations/add_ivanti_findings_ipv6_columns.js new file mode 100644 index 0000000..0364ef3 --- /dev/null +++ b/backend/migrations/add_ivanti_findings_ipv6_columns.js @@ -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(); diff --git a/backend/migrations/run-all.js b/backend/migrations/run-all.js index 3c80b38..6b5ac5b 100644 --- a/backend/migrations/run-all.js +++ b/backend/migrations/run-all.js @@ -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() { diff --git a/backend/routes/ivantiFindings.js b/backend/routes/ivantiFindings.js index 0065c26..7d1c9af 100644 --- a/backend/routes/ivantiFindings.js +++ b/backend/routes/ivantiFindings.js @@ -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 } : {}) diff --git a/frontend/src/components/pages/ReportingPage.js b/frontend/src/components/pages/ReportingPage.js index 60588d2..e2942d0 100644 --- a/frontend/src/components/pages/ReportingPage.js +++ b/frontend/src/components/pages/ReportingPage.js @@ -1261,16 +1261,31 @@ function TableCell({ colKey, finding, canWrite, onCveMouseEnter, onCveMouseLeave } /> ); - case 'ipAddress': + case 'ipAddress': { + // Display priority: IPv4 > Qualys IPv6 > Primary IPv6 + const displayIp = finding.ipAddress || finding.qualysIpv6 || finding.primaryIpv6 || ''; + const ipSource = finding.ipAddress ? null : finding.qualysIpv6 ? 'Q' : finding.primaryIpv6 ? 'v6' : null; + const hoverIp = displayIp || null; return ( onIpMouseEnter(finding.ipAddress, e, finding.hostId) : undefined} + style={{ padding: '0.45rem 0.75rem', whiteSpace: 'nowrap', color: '#94A3B8', fontFamily: 'monospace', fontSize: '0.72rem', cursor: hoverIp ? 'help' : 'default' }} + onMouseEnter={onIpMouseEnter && hoverIp ? (e) => onIpMouseEnter(hoverIp, e, finding.hostId) : undefined} onMouseLeave={onIpMouseLeave || undefined} + title={ipSource === 'Q' ? 'Qualys IPv6 (no IPv4 available)' : ipSource === 'v6' ? 'Primary IPv6 (no IPv4 available)' : undefined} > - {finding.ipAddress || '—'} + {displayIp ? ( + <> + {displayIp} + {ipSource && ( + + {ipSource} + + )} + + ) : '—'} ); + } case 'dns': return (