Add CARD asset-search by Ivanti Host ID for faster lookups

Integrate CARD's new v2 asset-search endpoint that accepts Ivanti Asset ID
integers directly, eliminating the slow suffix-guessing resolution flow.

Changes:
- Add searchByIvantiHostId() helper to cardApi.js
- Add GET /api/card/asset-search/:hostId endpoint
- Update CARD queue confirm/decline/redirect to try host_id fast path first
- Update owner-lookup to accept optional hostId query param for fast resolution
- Pass hostId through CardOwnerTooltip and ReportingPage for tooltip lookups
- Join ivanti_findings in todo queue GET to expose host_id on queue items
- Update CardActionModal to pass host_id for faster owner-lookup
This commit is contained in:
Jordan Ramos
2026-06-09 11:57:13 -06:00
parent 2396a828cc
commit a8d3909798
6 changed files with 188 additions and 22 deletions

View File

@@ -295,6 +295,23 @@ async function redirectAsset(assetId, fromTeam, toTeam, updateToken) {
return { status: res.status, body: res.body, ok: res.status >= 200 && res.status < 300 }; return { status: res.status, body: res.body, ok: res.status >= 200 && res.status < 300 };
} }
/**
* GET /api/v2/asset-search/{ivantiHostId}?search_param=deep_search
* Search CARD by Ivanti Asset ID (8-digit integer). Returns the CARD asset
* record directly — no suffix guessing required.
*
* @param {string|number} ivantiHostId - 8-character integer Ivanti Host ID
* @param {object} [options] - { timeout }
*/
async function searchByIvantiHostId(ivantiHostId, options) {
const hostId = String(ivantiHostId).trim();
if (!hostId || !/^\d+$/.test(hostId)) {
return { status: 400, body: '{"error":"Invalid Ivanti host ID — must be an integer."}', ok: false };
}
const res = await cardGet(`/api/v2/asset-search/${hostId}?search_param=deep_search`, options);
return { status: res.status, body: res.body, ok: res.status >= 200 && res.status < 300 };
}
/** /**
* Resolve a bare IP address to a full CARD asset ID by trying known suffixes. * Resolve a bare IP address to a full CARD asset ID by trying known suffixes.
* Returns the first asset ID that returns a valid owner record, or null if none found. * Returns the first asset ID that returns a valid owner record, or null if none found.
@@ -366,4 +383,5 @@ module.exports = {
redirectAsset, redirectAsset,
invalidateToken, invalidateToken,
resolveAssetId, resolveAssetId,
searchByIvantiHostId,
}; };

View File

@@ -16,6 +16,7 @@ const {
declineAsset, declineAsset,
redirectAsset, redirectAsset,
resolveAssetId, resolveAssetId,
searchByIvantiHostId,
} = require('../helpers/cardApi'); } = require('../helpers/cardApi');
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -223,10 +224,31 @@ function createCardApiRouter() {
return res.status(400).json({ error: 'assetId is required.' }); return res.status(400).json({ error: 'assetId is required.' });
} }
// Resolve bare IP to full CARD asset ID // Resolve bare IP to full CARD asset ID — try Ivanti host_id fast path first
let assetId = rawAssetId.trim(); let assetId = rawAssetId.trim();
if (!/\d+-[A-Z]+$/i.test(assetId)) { if (!/\d+-[A-Z]+$/i.test(assetId)) {
const resolved = await resolveAssetId(assetId); let resolved = null;
// Fast path: look up the finding's host_id and search CARD directly
const findingRow = await pool.query(
'SELECT host_id FROM ivanti_findings WHERE ip_address = $1 LIMIT 1',
[assetId]
).then(r => r.rows[0]).catch(() => null);
if (findingRow && findingRow.host_id) {
try {
const searchResult = await searchByIvantiHostId(findingRow.host_id);
if (searchResult.ok) {
const searchData = JSON.parse(searchResult.body);
resolved = searchData._id || searchData.asset_id || searchData.id || null;
}
} catch (_) { /* fall through */ }
}
// Fallback: suffix guessing
if (!resolved) {
resolved = await resolveAssetId(assetId);
}
if (!resolved) { if (!resolved) {
return res.status(404).json({ error: `Asset not found in CARD for IP: ${assetId}` }); return res.status(404).json({ error: `Asset not found in CARD for IP: ${assetId}` });
} }
@@ -325,10 +347,31 @@ function createCardApiRouter() {
return res.status(400).json({ error: 'assetId is required.' }); return res.status(400).json({ error: 'assetId is required.' });
} }
// Resolve bare IP to full CARD asset ID // Resolve bare IP to full CARD asset ID — try Ivanti host_id fast path first
let assetId = rawAssetId.trim(); let assetId = rawAssetId.trim();
if (!/\d+-[A-Z]+$/i.test(assetId)) { if (!/\d+-[A-Z]+$/i.test(assetId)) {
const resolved = await resolveAssetId(assetId); let resolved = null;
// Fast path: look up the finding's host_id and search CARD directly
const findingRow = await pool.query(
'SELECT host_id FROM ivanti_findings WHERE ip_address = $1 LIMIT 1',
[assetId]
).then(r => r.rows[0]).catch(() => null);
if (findingRow && findingRow.host_id) {
try {
const searchResult = await searchByIvantiHostId(findingRow.host_id);
if (searchResult.ok) {
const searchData = JSON.parse(searchResult.body);
resolved = searchData._id || searchData.asset_id || searchData.id || null;
}
} catch (_) { /* fall through */ }
}
// Fallback: suffix guessing
if (!resolved) {
resolved = await resolveAssetId(assetId);
}
if (!resolved) { if (!resolved) {
return res.status(404).json({ error: `Asset not found in CARD for IP: ${assetId}` }); return res.status(404).json({ error: `Asset not found in CARD for IP: ${assetId}` });
} }
@@ -430,10 +473,31 @@ function createCardApiRouter() {
return res.status(400).json({ error: 'assetId is required.' }); return res.status(400).json({ error: 'assetId is required.' });
} }
// Resolve bare IP to full CARD asset ID (e.g., 10.240.78.110 → 10.240.78.110-CTEC) // Resolve bare IP to full CARD asset ID — try Ivanti host_id fast path first
let assetId = rawAssetId.trim(); let assetId = rawAssetId.trim();
if (!/\d+-[A-Z]+$/i.test(assetId)) { if (!/\d+-[A-Z]+$/i.test(assetId)) {
const resolved = await resolveAssetId(assetId); let resolved = null;
// Fast path: look up the finding's host_id and search CARD directly
const findingRow = await pool.query(
'SELECT host_id FROM ivanti_findings WHERE ip_address = $1 LIMIT 1',
[assetId]
).then(r => r.rows[0]).catch(() => null);
if (findingRow && findingRow.host_id) {
try {
const searchResult = await searchByIvantiHostId(findingRow.host_id);
if (searchResult.ok) {
const searchData = JSON.parse(searchResult.body);
resolved = searchData._id || searchData.asset_id || searchData.id || null;
}
} catch (_) { /* fall through */ }
}
// Fallback: suffix guessing
if (!resolved) {
resolved = await resolveAssetId(assetId);
}
if (!resolved) { if (!resolved) {
return res.status(404).json({ error: `Asset not found in CARD for IP: ${assetId}` }); return res.status(404).json({ error: `Asset not found in CARD for IP: ${assetId}` });
} }
@@ -509,6 +573,7 @@ function createCardApiRouter() {
* *
* @param {string} ip - IP address (path parameter) * @param {string} ip - IP address (path parameter)
* @query {string} [quick] - Set to "1" for quick mode (CTEC only, 15s timeout). Used for tooltip lookups. * @query {string} [quick] - Set to "1" for quick mode (CTEC only, 15s timeout). Used for tooltip lookups.
* @query {string} [hostId] - Ivanti Host ID (integer). When provided, uses CARD asset-search for faster resolution.
* @response 200 - { asset_id, ip, confirmed, unconfirmed, declined, candidate, update_token } * @response 200 - { asset_id, ip, confirmed, unconfirmed, declined, candidate, update_token }
* @response 400 - { error: string } — missing IP * @response 400 - { error: string } — missing IP
* @response 404 - { error: string } — IP not found in CARD * @response 404 - { error: string } — IP not found in CARD
@@ -527,17 +592,35 @@ function createCardApiRouter() {
// Use quick mode (CTEC only, 15s timeout) for tooltip lookups // Use quick mode (CTEC only, 15s timeout) for tooltip lookups
const quick = req.query.quick === '1'; const quick = req.query.quick === '1';
const hostId = req.query.hostId;
// Resolve to full asset ID // Fast path: if Ivanti hostId is provided, try asset-search first
let assetId; let assetId = null;
try { if (hostId && /^\d+$/.test(hostId)) {
assetId = await resolveAssetId(ip.trim(), quick ? { quick: true } : undefined); try {
} catch (err) { const searchResult = await searchByIvantiHostId(hostId);
if (err.message === 'CARD_TIMEOUT') { if (searchResult.ok) {
return res.status(504).json({ error: 'CARD lookup timed out', timeout: true }); const searchData = JSON.parse(searchResult.body);
// Extract asset_id from the search response (_id field or asset_id)
assetId = searchData._id || searchData.asset_id || searchData.id || null;
}
} catch (_) {
// Fall through to suffix resolution
} }
return handleCardError(err, res);
} }
// Fallback: resolve via IP + suffix guessing
if (!assetId) {
try {
assetId = await resolveAssetId(ip.trim(), quick ? { quick: true } : undefined);
} catch (err) {
if (err.message === 'CARD_TIMEOUT') {
return res.status(504).json({ error: 'CARD lookup timed out', timeout: true });
}
return handleCardError(err, res);
}
}
if (!assetId) { if (!assetId) {
return res.status(404).json({ error: `Asset not found in CARD for IP: ${ip}` }); return res.status(404).json({ error: `Asset not found in CARD for IP: ${ip}` });
} }
@@ -768,6 +851,61 @@ function createCardApiRouter() {
} }
}); });
/**
* GET /asset-search/:hostId
*
* Search CARD by Ivanti Asset ID (integer). Uses CARD's v2 asset-search
* endpoint with deep_search to find the associated CARD asset directly,
* bypassing the IP + suffix guessing flow.
*
* @param {string} hostId - Ivanti Host ID (8-digit integer, from ivanti_findings.host_id)
* @response 200 - CARD asset record
* @response 400 - { error: string } — invalid host ID
* @response 404 - { error: string } — asset not found
* @response 503 - { error: string, missingVars: string[] } — CARD not configured
*/
router.get('/asset-search/:hostId', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
if (!isConfigured) {
return res.status(503).json({ error: 'CARD API is not configured.', missingVars });
}
const { hostId } = req.params;
if (!hostId || !/^\d+$/.test(hostId.trim())) {
return res.status(400).json({ error: 'hostId must be a numeric Ivanti Asset ID.' });
}
try {
const result = await searchByIvantiHostId(hostId.trim());
if (result.ok) {
let body;
try { body = JSON.parse(result.body); } catch (_) { body = result.body; }
logAudit({
userId: req.user.id,
username: req.user.username,
action: 'card_asset_search',
entityType: 'card_asset',
entityId: hostId.trim(),
details: { search_type: 'ivanti_host_id' },
ipAddress: req.ip,
});
return res.json(body);
}
if (result.status === 404) {
return res.status(404).json({ error: `No CARD asset found for Ivanti Host ID: ${hostId}` });
}
let errorBody;
try { errorBody = JSON.parse(result.body); } catch (_) { errorBody = { error: result.body }; }
return res.status(result.status).json(errorBody);
} catch (err) {
return handleCardError(err, res);
}
});
/** /**
* POST /enrich-batch * POST /enrich-batch
* *

View File

@@ -34,6 +34,7 @@ function createIvantiTodoQueueRouter() {
* - vendor {string} * - vendor {string}
* - workflow_type {string} One of: FP, Archer, CARD, GRANITE, DECOM, Remediate * - workflow_type {string} One of: FP, Archer, CARD, GRANITE, DECOM, Remediate
* - status {string} pending | complete * - status {string} pending | complete
* - host_id {string|null} From the linked ivanti_findings record
* - remediation_notes_count {number} * - remediation_notes_count {number}
* - created_at {string} * - created_at {string}
* - updated_at {string} * - updated_at {string}
@@ -41,13 +42,15 @@ function createIvantiTodoQueueRouter() {
router.get('/', requireAuth(), async (req, res) => { router.get('/', requireAuth(), async (req, res) => {
try { try {
const { rows } = await pool.query( const { rows } = await pool.query(
`SELECT q.*, COALESCE(nc.note_count, 0) AS remediation_notes_count `SELECT q.*, COALESCE(nc.note_count, 0) AS remediation_notes_count,
f.host_id AS host_id
FROM ivanti_todo_queue q FROM ivanti_todo_queue q
LEFT JOIN ( LEFT JOIN (
SELECT queue_item_id, COUNT(*) AS note_count SELECT queue_item_id, COUNT(*) AS note_count
FROM queue_remediation_notes FROM queue_remediation_notes
GROUP BY queue_item_id GROUP BY queue_item_id
) nc ON nc.queue_item_id = q.id ) nc ON nc.queue_item_id = q.id
LEFT JOIN ivanti_findings f ON f.id = q.finding_id
WHERE q.user_id = $1 WHERE q.user_id = $1
ORDER BY q.vendor ASC, q.created_at ASC`, ORDER BY q.vendor ASC, q.created_at ASC`,
[req.user.id] [req.user.id]

View File

@@ -63,7 +63,7 @@ export default function CardActionModal({ isOpen, onClose, item, initialAction,
setOwnerData(null); setOwnerData(null);
setExecError(null); setExecError(null);
fetch(`${API_BASE}/card/owner-lookup/${encodeURIComponent(item.ip_address)}`, { credentials: 'include' }) fetch(`${API_BASE}/card/owner-lookup/${encodeURIComponent(item.ip_address)}${item.host_id ? '?hostId=' + encodeURIComponent(item.host_id) : ''}`, { credentials: 'include' })
.then(r => { .then(r => {
if (!r.ok) return r.json().then(d => { throw new Error(d.error || `HTTP ${r.status}`); }); if (!r.ok) return r.json().then(d => { throw new Error(d.error || `HTTP ${r.status}`); });
return r.json(); return r.json();

View File

@@ -43,7 +43,7 @@ function calcPosition(anchorRect, tooltipHeight, viewportHeight) {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Main exported component // Main exported component
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export default function CardOwnerTooltip({ ip, anchorRect, cache, cardConfigured, onAction, onMouseEnter, onMouseLeave }) { export default function CardOwnerTooltip({ ip, hostId, anchorRect, cache, cardConfigured, onAction, onMouseEnter, onMouseLeave }) {
const [data, setData] = useState(null); const [data, setData] = useState(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(null); const [error, setError] = useState(null);
@@ -76,13 +76,14 @@ export default function CardOwnerTooltip({ ip, anchorRect, cache, cardConfigured
return; return;
} }
// Fetch // Fetch — include hostId for fast-path resolution when available
const controller = new AbortController(); const controller = new AbortController();
setLoading(true); setLoading(true);
setData(null); setData(null);
setError(null); setError(null);
fetch(`${API_BASE}/card/owner-lookup/${encodeURIComponent(ip)}?quick=1`, { const hostIdParam = hostId ? `&hostId=${encodeURIComponent(hostId)}` : '';
fetch(`${API_BASE}/card/owner-lookup/${encodeURIComponent(ip)}?quick=1${hostIdParam}`, {
credentials: 'include', credentials: 'include',
signal: controller.signal, signal: controller.signal,
}) })
@@ -123,7 +124,7 @@ export default function CardOwnerTooltip({ ip, anchorRect, cache, cardConfigured
}); });
return () => controller.abort(); return () => controller.abort();
}, [ip, cache, cardConfigured]); }, [ip, hostId, cache, cardConfigured]);
if (!ip || !anchorRect) return null; if (!ip || !anchorRect) return null;
if (!loading && !data && !error) return null; if (!loading && !data && !error) return null;

View File

@@ -1265,7 +1265,7 @@ function TableCell({ colKey, finding, canWrite, onCveMouseEnter, onCveMouseLeave
return ( return (
<td <td
style={{ padding: '0.45rem 0.75rem', whiteSpace: 'nowrap', color: '#94A3B8', fontFamily: 'monospace', fontSize: '0.72rem', cursor: finding.ipAddress ? 'help' : 'default' }} style={{ padding: '0.45rem 0.75rem', whiteSpace: 'nowrap', color: '#94A3B8', fontFamily: 'monospace', fontSize: '0.72rem', cursor: finding.ipAddress ? 'help' : 'default' }}
onMouseEnter={onIpMouseEnter && finding.ipAddress ? (e) => onIpMouseEnter(finding.ipAddress, e) : undefined} onMouseEnter={onIpMouseEnter && finding.ipAddress ? (e) => onIpMouseEnter(finding.ipAddress, e, finding.hostId) : undefined}
onMouseLeave={onIpMouseLeave || undefined} onMouseLeave={onIpMouseLeave || undefined}
> >
{finding.ipAddress || '—'} {finding.ipAddress || '—'}
@@ -5937,6 +5937,7 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
// CARD owner tooltip state & refs // CARD owner tooltip state & refs
const [cardTooltipIp, setCardTooltipIp] = useState(null); const [cardTooltipIp, setCardTooltipIp] = useState(null);
const [cardTooltipAnchorRect, setCardTooltipAnchorRect] = useState(null); const [cardTooltipAnchorRect, setCardTooltipAnchorRect] = useState(null);
const [cardTooltipHostId, setCardTooltipHostId] = useState(null);
const cardTooltipCacheRef = useRef(new Map()); const cardTooltipCacheRef = useRef(new Map());
const cardHoverTimerRef = useRef(null); const cardHoverTimerRef = useRef(null);
@@ -6033,12 +6034,13 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
}, []); }, []);
// CARD owner tooltip hover handlers (with hover bridge — stays open when hovering tooltip) // CARD owner tooltip hover handlers (with hover bridge — stays open when hovering tooltip)
const handleIpMouseEnter = useCallback((ip, e) => { const handleIpMouseEnter = useCallback((ip, e, hostId) => {
if (!ip) return; if (!ip) return;
clearTimeout(cardHoverTimerRef.current); clearTimeout(cardHoverTimerRef.current);
cardHoverTimerRef.current = setTimeout(() => { cardHoverTimerRef.current = setTimeout(() => {
setCardTooltipIp(ip); setCardTooltipIp(ip);
setCardTooltipAnchorRect(e.target.getBoundingClientRect()); setCardTooltipAnchorRect(e.target.getBoundingClientRect());
setCardTooltipHostId(hostId || null);
}, 400); }, 400);
}, []); }, []);
@@ -6048,6 +6050,7 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
cardHoverTimerRef.current = setTimeout(() => { cardHoverTimerRef.current = setTimeout(() => {
setCardTooltipIp(null); setCardTooltipIp(null);
setCardTooltipAnchorRect(null); setCardTooltipAnchorRect(null);
setCardTooltipHostId(null);
}, 150); }, 150);
}, []); }, []);
@@ -6061,6 +6064,7 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
clearTimeout(cardHoverTimerRef.current); clearTimeout(cardHoverTimerRef.current);
setCardTooltipIp(null); setCardTooltipIp(null);
setCardTooltipAnchorRect(null); setCardTooltipAnchorRect(null);
setCardTooltipHostId(null);
}, []); }, []);
// CARD action — open CardActionModal from tooltip // CARD action — open CardActionModal from tooltip
@@ -6073,6 +6077,7 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
// Close the tooltip // Close the tooltip
setCardTooltipIp(null); setCardTooltipIp(null);
setCardTooltipAnchorRect(null); setCardTooltipAnchorRect(null);
setCardTooltipHostId(null);
}, []); }, []);
const applyState = (data) => { const applyState = (data) => {
@@ -7738,6 +7743,7 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
/> />
<CardOwnerTooltip <CardOwnerTooltip
ip={cardTooltipIp} ip={cardTooltipIp}
hostId={cardTooltipHostId}
anchorRect={cardTooltipAnchorRect} anchorRect={cardTooltipAnchorRect}
cache={cardTooltipCacheRef} cache={cardTooltipCacheRef}
cardConfigured={cardConfigured} cardConfigured={cardConfigured}