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

@@ -16,6 +16,7 @@ const {
declineAsset,
redirectAsset,
resolveAssetId,
searchByIvantiHostId,
} = require('../helpers/cardApi');
// ---------------------------------------------------------------------------
@@ -223,10 +224,31 @@ function createCardApiRouter() {
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();
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) {
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.' });
}
// 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();
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) {
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.' });
}
// 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();
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) {
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)
* @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 400 - { error: string } — missing IP
* @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
const quick = req.query.quick === '1';
const hostId = req.query.hostId;
// Resolve to full asset ID
let 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 });
// Fast path: if Ivanti hostId is provided, try asset-search first
let assetId = null;
if (hostId && /^\d+$/.test(hostId)) {
try {
const searchResult = await searchByIvantiHostId(hostId);
if (searchResult.ok) {
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) {
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
*