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:
16
backend/migrations/add_ivanti_findings_scan_type.js
Normal file
16
backend/migrations/add_ivanti_findings_scan_type.js
Normal 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 };
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
56
frontend/src/components/ScanTypeBadge.js
Normal file
56
frontend/src/components/ScanTypeBadge.js
Normal 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;
|
||||
@@ -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':
|
||||
|
||||
Reference in New Issue
Block a user