Add scan type indicator (agent vs network appliance) to Ivanti findings

Detect whether a finding was discovered by Qualys Cloud Agent (authenticated)
or a network appliance scan (unauthenticated) based on the presence of
'Agent ID' in hostAdditionalDetails from the Ivanti API response.

- Add scan_type column to ivanti_findings table (migration)
- Extract scanType in extractFinding() during sync
- Include scan_type in upsert and API response
- Add ScanTypeBadge component (green AGT / orange NET) on ReportingPage
- Add /raw-inspect diagnostic endpoint for inspecting raw Ivanti data
This commit is contained in:
Jordan Ramos
2026-08-18 10:54:23 -06:00
parent 3aa7a6e49e
commit d3adc8e1fc
5 changed files with 177 additions and 4 deletions

View File

@@ -0,0 +1,16 @@
// Migration: Add scan_type column to ivanti_findings table.
// Stores whether a finding was detected via Qualys agent (authenticated) or
// network appliance (unauthenticated). Derived from "Agent ID" presence in
// hostAdditionalDetails during sync.
const pool = require('../db');
async function up() {
await pool.query(`
ALTER TABLE ivanti_findings
ADD COLUMN IF NOT EXISTS scan_type VARCHAR(20) DEFAULT NULL
`);
console.log('[Migration] Added scan_type column to ivanti_findings');
}
module.exports = { up };

View File

@@ -36,6 +36,7 @@ const POSTGRES_MIGRATIONS = [
'add_session_impersonation.js',
'add_supplemental_tables.js',
'add_supplemental_metadata.js',
'add_ivanti_findings_scan_type.js',
];
async function runAll() {

View File

@@ -152,6 +152,12 @@ function extractFinding(f) {
type: 'FP',
} : null;
// Scan type: presence of "Agent ID" in hostAdditionalDetails = Qualys Cloud Agent (authenticated)
// Absence = network appliance scan (unauthenticated)
const details = f.hostAdditionalDetails || [];
const hasAgentId = details.some(entry => entry['Agent ID']);
const scanType = hasAgentId ? 'agent' : 'network';
return {
id: String(f.id),
hostId: f.host?.hostId || null,
@@ -171,6 +177,7 @@ function extractFinding(f) {
// IPv6 fallbacks for findings with no IPv4
qualysIpv6: extractQualysIpv6(f),
primaryIpv6: f.assetCustomAttributes?.['1550_host_6']?.[0] || '',
scanType,
};
}
@@ -207,7 +214,7 @@ async function upsertFindingsBatch(findings, state) {
const placeholders = [];
batch.forEach((f, idx) => {
const offset = idx * 20;
const offset = idx * 21;
values.push(
f.id,
f.hostId,
@@ -228,13 +235,14 @@ async function upsertFindingsBatch(findings, state) {
f.workflow ? f.workflow.type : null,
state,
f.qualysIpv6 || null,
f.primaryIpv6 || null
f.primaryIpv6 || null,
f.scanType || 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+19}, $${offset+20})`
`$${offset+16}, $${offset+17}, $${offset+18}, $${offset+19}, $${offset+20}, $${offset+21})`
);
});
@@ -244,7 +252,7 @@ async function upsertFindingsBatch(findings, state) {
host_name, ip_address, dns, status, sla_status,
due_date, last_found_on, bu_ownership, cves,
workflow_id, workflow_state, workflow_type, state,
qualys_ipv6, primary_ipv6
qualys_ipv6, primary_ipv6, scan_type
)
VALUES ${placeholders.join(', ')}
ON CONFLICT (id) DO UPDATE SET
@@ -267,6 +275,7 @@ async function upsertFindingsBatch(findings, state) {
state = EXCLUDED.state,
qualys_ipv6 = EXCLUDED.qualys_ipv6,
primary_ipv6 = EXCLUDED.primary_ipv6,
scan_type = EXCLUDED.scan_type,
synced_at = NOW()
`, values);
}
@@ -1130,6 +1139,7 @@ function createIvantiFindingsRouter(db, requireAuth) {
note: row.note || '',
qualysIpv6: row.qualys_ipv6 || null,
primaryIpv6: row.primary_ipv6 || null,
scanType: row.scan_type || null,
overrides: {
...(row.override_host_name ? { hostName: row.override_host_name } : {}),
...(row.override_dns ? { dns: row.override_dns } : {})
@@ -1197,6 +1207,7 @@ function createIvantiFindingsRouter(db, requireAuth) {
note: row.note || '',
qualysIpv6: row.qualys_ipv6 || null,
primaryIpv6: row.primary_ipv6 || null,
scanType: row.scan_type || null,
overrides: {
...(row.override_host_name ? { hostName: row.override_host_name } : {}),
...(row.override_dns ? { dns: row.override_dns } : {})
@@ -1849,6 +1860,92 @@ function createIvantiFindingsRouter(db, requireAuth) {
}
});
/**
* GET /api/ivanti/findings/raw-inspect
*
* Diagnostic endpoint: fetch a single raw finding from the Ivanti API by hostname
* and return the full hostAdditionalDetails, assetCustomAttributes, and source fields.
* Used to identify scanner type (Qualys agent vs network appliance).
* Requires Admin group.
*
* @query {string} q - Hostname or IP to search for (required)
* @query {number} [size=1] - Number of raw findings to return (max 5)
*
* @returns {Object} 200 - { query, results: Array<raw finding excerpts> }
* @returns {Object} 400 - { error } when q is missing
* @returns {Object} 503 - { error } when API key not configured
* @returns {Object} 500 - { error } on failure
*/
router.get('/raw-inspect', requireGroup('Admin'), async (req, res) => {
const q = (req.query.q || '').trim();
if (!q) return res.status(400).json({ error: 'q parameter is required (hostname or IP)' });
const size = Math.min(Math.max(parseInt(req.query.size) || 1, 1), 5);
try {
const clientId = process.env.IVANTI_CLIENT_ID || '1550';
const apiKey = process.env.IVANTI_API_KEY;
const skipTls = process.env.IVANTI_SKIP_TLS === 'true';
if (!apiKey) return res.status(503).json({ error: 'Ivanti API key not configured' });
const isIp = /^\d+\./.test(q);
const filters = [
{
field: isIp ? 'host.ipAddress' : 'hostName',
operator: isIp ? 'EXACT' : 'EXACT',
value: q,
exclusive: false,
orWithPrevious: false,
implicitFilters: [],
caseSensitive: false
}
];
const result = await ivantiPost(`/client/${clientId}/hostFinding/search`, {
filters,
projection: 'internal',
sort: [{ field: 'severity', direction: 'DESC' }],
page: 0,
size
}, apiKey, skipTls);
if (result.status !== 200) {
return res.status(502).json({ error: `Ivanti API returned ${result.status}`, body: result.body });
}
const data = JSON.parse(result.body);
const findings = data._embedded?.hostFindings || [];
// Return the scanner/agent-relevant fields from each raw finding
const results = findings.map(f => ({
id: f.id,
title: f.title,
severity: f.severity,
host: f.host || {},
hostAdditionalDetails: f.hostAdditionalDetails || [],
assetCustomAttributes: f.assetCustomAttributes || {},
source: f.source || null,
scannerName: f.scannerName || null,
discoveredOn: f.discoveredOn || null,
lastFoundOn: f.lastFoundOn || null,
// Include any other potentially useful fields for agent detection
network: f.network || null,
ports: f.ports || null,
services: f.services || null,
}));
res.json({
query: q,
totalMatches: data.page?.totalElements || 0,
returned: results.length,
results
});
} catch (err) {
console.error('[Ivanti] Raw inspect error:', err.message);
res.status(500).json({ error: 'Raw finding inspection failed', details: err.message });
}
});
return router;
}

View File

@@ -0,0 +1,56 @@
import React from 'react';
/**
* ScanTypeBadge — displays a small indicator showing whether a finding
* was detected by a Qualys Cloud Agent (authenticated) or a network
* appliance scan (unauthenticated).
*
* Props:
* scanType: 'agent' | 'network' | null
*/
const badgeStyles = {
agent: {
marginLeft: '0.3rem',
fontSize: '0.55rem',
padding: '0.08rem 0.25rem',
borderRadius: '0.2rem',
background: 'rgba(34, 197, 94, 0.12)',
border: '1px solid rgba(34, 197, 94, 0.4)',
color: '#4ADE80',
fontWeight: '700',
verticalAlign: 'middle',
cursor: 'help',
},
network: {
marginLeft: '0.3rem',
fontSize: '0.55rem',
padding: '0.08rem 0.25rem',
borderRadius: '0.2rem',
background: 'rgba(251, 146, 60, 0.12)',
border: '1px solid rgba(251, 146, 60, 0.4)',
color: '#FB923C',
fontWeight: '700',
verticalAlign: 'middle',
cursor: 'help',
},
};
function ScanTypeBadge({ scanType }) {
if (!scanType) return null;
const isAgent = scanType === 'agent';
const style = isAgent ? badgeStyles.agent : badgeStyles.network;
const label = isAgent ? 'AGT' : 'NET';
const title = isAgent
? 'Qualys Cloud Agent (authenticated scan)'
: 'Network appliance scan (unauthenticated)';
return (
<span style={style} title={title}>
{label}
</span>
);
}
export default ScanTypeBadge;

View File

@@ -13,6 +13,7 @@ import RedirectModal from '../RedirectModal';
import RemediationModal from '../RemediationModal';
import AtlasBadge from '../AtlasBadge';
import NetBoxBadge from '../NetBoxBadge';
import ScanTypeBadge from '../ScanTypeBadge';
import LoaderModal from '../LoaderModal';
import CardActionModal from '../CardActionModal';
import ConsolidationModal from '../ConsolidationModal';
@@ -1256,6 +1257,7 @@ function TableCell({ colKey, finding, canWrite, onCveMouseEnter, onCveMouseLeave
canWrite={canWrite}
suffix={
<>
<ScanTypeBadge scanType={finding.scanType} />
<AtlasBadge
hostId={finding.hostId}
atlasStatus={atlasStatusMap ? atlasStatusMap.get(finding.hostId) : undefined}
@@ -7574,6 +7576,7 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
<span style={{ color: '#E2E8F0', fontFamily: 'monospace', fontSize: '0.75rem', fontWeight: '600' }}>
{group.hostName || '—'}
</span>
<ScanTypeBadge scanType={group.scanType} />
</td>
);
case 'ipAddress':