diff --git a/backend/helpers/cardApi.js b/backend/helpers/cardApi.js index 813d87e..815e7b2 100644 --- a/backend/helpers/cardApi.js +++ b/backend/helpers/cardApi.js @@ -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 }; } +/** + * 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. * Returns the first asset ID that returns a valid owner record, or null if none found. @@ -366,4 +383,5 @@ module.exports = { redirectAsset, invalidateToken, resolveAssetId, + searchByIvantiHostId, }; diff --git a/backend/routes/cardApi.js b/backend/routes/cardApi.js index 71a9fbe..efd91ee 100644 --- a/backend/routes/cardApi.js +++ b/backend/routes/cardApi.js @@ -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 * diff --git a/backend/routes/ivantiTodoQueue.js b/backend/routes/ivantiTodoQueue.js index 3c3c054..968b994 100644 --- a/backend/routes/ivantiTodoQueue.js +++ b/backend/routes/ivantiTodoQueue.js @@ -34,6 +34,7 @@ function createIvantiTodoQueueRouter() { * - vendor {string} * - workflow_type {string} One of: FP, Archer, CARD, GRANITE, DECOM, Remediate * - status {string} pending | complete + * - host_id {string|null} From the linked ivanti_findings record * - remediation_notes_count {number} * - created_at {string} * - updated_at {string} @@ -41,13 +42,15 @@ function createIvantiTodoQueueRouter() { router.get('/', requireAuth(), async (req, res) => { try { 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 LEFT JOIN ( SELECT queue_item_id, COUNT(*) AS note_count FROM queue_remediation_notes GROUP BY queue_item_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 ORDER BY q.vendor ASC, q.created_at ASC`, [req.user.id] diff --git a/frontend/src/components/CardActionModal.js b/frontend/src/components/CardActionModal.js index 106b5c9..73b1a93 100644 --- a/frontend/src/components/CardActionModal.js +++ b/frontend/src/components/CardActionModal.js @@ -63,7 +63,7 @@ export default function CardActionModal({ isOpen, onClose, item, initialAction, setOwnerData(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 => { if (!r.ok) return r.json().then(d => { throw new Error(d.error || `HTTP ${r.status}`); }); return r.json(); diff --git a/frontend/src/components/CardOwnerTooltip.js b/frontend/src/components/CardOwnerTooltip.js index 3734611..1289f72 100644 --- a/frontend/src/components/CardOwnerTooltip.js +++ b/frontend/src/components/CardOwnerTooltip.js @@ -43,7 +43,7 @@ function calcPosition(anchorRect, tooltipHeight, viewportHeight) { // --------------------------------------------------------------------------- // 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 [loading, setLoading] = useState(false); const [error, setError] = useState(null); @@ -76,13 +76,14 @@ export default function CardOwnerTooltip({ ip, anchorRect, cache, cardConfigured return; } - // Fetch + // Fetch — include hostId for fast-path resolution when available const controller = new AbortController(); setLoading(true); setData(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', signal: controller.signal, }) @@ -123,7 +124,7 @@ export default function CardOwnerTooltip({ ip, anchorRect, cache, cardConfigured }); return () => controller.abort(); - }, [ip, cache, cardConfigured]); + }, [ip, hostId, cache, cardConfigured]); if (!ip || !anchorRect) return null; if (!loading && !data && !error) return null; diff --git a/frontend/src/components/pages/ReportingPage.js b/frontend/src/components/pages/ReportingPage.js index f4589b8..42b56e8 100644 --- a/frontend/src/components/pages/ReportingPage.js +++ b/frontend/src/components/pages/ReportingPage.js @@ -1265,7 +1265,7 @@ function TableCell({ colKey, finding, canWrite, onCveMouseEnter, onCveMouseLeave return (