Files
cve-dashboard/backend/routes/cardApi.js

1122 lines
51 KiB
JavaScript
Raw Normal View History

// CARD Asset Ownership API Routes
// Proxies CARD operations (confirm, decline, redirect, search) and orchestrates
// the two-step update_token flow for mutations.
const express = require('express');
const pool = require('../db');
const { requireAuth, requireGroup } = require('../middleware/auth');
const logAudit = require('../helpers/auditLog');
const {
isConfigured,
missingVars,
getTeams,
getTeamAssets,
getOwner,
confirmAsset,
declineAsset,
redirectAsset,
resolveAssetId,
searchByIvantiHostId,
} = require('../helpers/cardApi');
// ---------------------------------------------------------------------------
// Error classification — maps CARD API / token errors to client responses
// ---------------------------------------------------------------------------
function handleCardError(err, res) {
const msg = err.message || String(err);
console.error('[card-api]', msg);
if (msg.includes('Token acquisition failed')) {
if (msg.includes('HTTP 401')) {
return res.status(401).json({ error: 'CARD authorization failed. Check service account credentials.' });
}
if (msg.includes('HTTP 403')) {
return res.status(403).json({ error: 'CARD access denied. The service account may not be onboarded with the CARD team.' });
}
if (msg.includes('HTTP 525')) {
return res.status(502).json({ error: 'CARD LDAP error. The service account may not be provisioned correctly.' });
}
}
if (msg.includes('401')) {
return res.status(401).json({ error: 'CARD token expired or invalid. The request has been retried once automatically.' });
}
if (msg.includes('403')) {
return res.status(403).json({ error: 'Insufficient CARD permissions for this operation.' });
}
return res.status(502).json({ error: 'CARD API request failed.', details: msg });
}
// ---------------------------------------------------------------------------
// Router factory
// ---------------------------------------------------------------------------
function createCardApiRouter() {
const router = express.Router();
/**
* GET /status
*
* Returns whether the CARD API integration is configured.
*
* @response 200 - { configured: true }
* @response 503 - { configured: false, error: string, missingVars: string[] }
*/
router.get('/status', requireAuth(), (req, res) => {
if (!isConfigured) {
return res.status(503).json({ configured: false, error: 'CARD API is not configured.', missingVars });
}
res.json({ configured: true });
});
/**
* GET /teams
*
* Returns the list of teams from the CARD API.
*
* @response 200 - Array of team objects from CARD
* @response 503 - { error: string, missingVars: string[] } CARD not configured
*/
router.get('/teams', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
if (!isConfigured) {
return res.status(503).json({ error: 'CARD API is not configured.', missingVars });
}
try {
const result = await getTeams();
if (result.ok) {
let body;
try { body = JSON.parse(result.body); } catch (_) { body = result.body; }
const teams = Array.isArray(body) ? body : (body && body.teams) || [];
return res.json(teams);
}
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);
}
});
/**
* GET /teams/:teamName/assets
*
* Returns paginated assets for a team filtered by disposition.
*
* @param {string} teamName - Team name (path parameter)
* @query {string} disposition - Required. Asset disposition filter.
* @query {number} [page] - Page number for pagination.
* @query {number} [page_size=50] - Number of results per page.
* @response 200 - { assets: object[], total: number, ... } from CARD
* @response 400 - { error: string } missing disposition
* @response 503 - { error: string, missingVars: string[] } CARD not configured
*/
router.get('/teams/:teamName/assets', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
if (!isConfigured) {
return res.status(503).json({ error: 'CARD API is not configured.', missingVars });
}
const { teamName } = req.params;
const { disposition, page, page_size } = req.query;
if (!disposition) {
return res.status(400).json({ error: 'disposition query parameter is required.' });
}
try {
const result = await getTeamAssets(teamName, {
disposition,
page: page ? parseInt(page, 10) : undefined,
pageSize: page_size ? parseInt(page_size, 10) : 50,
});
if (result.ok) {
let body;
try { body = JSON.parse(result.body); } catch (_) { body = result.body; }
let resultCount = 0;
if (body && typeof body === 'object' && typeof body.total === 'number') {
resultCount = body.total;
} else if (body && Array.isArray(body.assets)) {
resultCount = body.assets.length;
}
logAudit({
userId: req.user.id,
username: req.user.username,
action: 'card_search',
entityType: 'card_asset',
entityId: teamName,
details: { disposition, resultCount },
ipAddress: req.ip,
});
return res.json(body);
}
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);
}
});
/**
* GET /owner/:assetId
*
* Returns the CARD owner record for a given asset ID.
*
* @param {string} assetId - CARD asset identifier (path parameter)
* @response 200 - CARD owner/asset object
* @response 503 - { error: string, missingVars: string[] } CARD not configured
*/
router.get('/owner/:assetId', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
if (!isConfigured) {
return res.status(503).json({ error: 'CARD API is not configured.', missingVars });
}
const { assetId } = req.params;
try {
const result = await getOwner(assetId);
if (result.ok) {
let body;
try { body = JSON.parse(result.body); } catch (_) { body = result.body; }
return res.json(body);
}
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 /queue/:queueItemId/confirm
*
* Confirms ownership of a CARD asset for a queue item. Fetches the owner
* record to obtain the update_token, then calls the CARD confirm endpoint.
* Marks the queue item as complete on success.
*
* @param {string} queueItemId - Queue item ID (path parameter)
* @body {string} teamName - Team name to confirm ownership for (required)
* @body {string} assetId - CARD asset identifier (required)
* @body {string} [comment] - Optional comment for the confirmation
* @response 200 - { success: true, cardResponse: object }
* @response 400 - { error: string } missing fields or item not pending
* @response 404 - { error: string } queue item not found
* @response 502 - { error: string } CARD API failure
* @response 503 - { error: string, missingVars: string[] } CARD not configured
*/
router.post('/queue/:queueItemId/confirm', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
if (!isConfigured) {
return res.status(503).json({ error: 'CARD API is not configured.', missingVars });
}
const { queueItemId } = req.params;
const { teamName, assetId: rawAssetId, comment } = req.body;
if (!teamName || typeof teamName !== 'string' || !teamName.trim()) {
return res.status(400).json({ error: 'teamName is required.' });
}
if (!rawAssetId || typeof rawAssetId !== 'string' || !rawAssetId.trim()) {
return res.status(400).json({ error: 'assetId is required.' });
}
// 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)) {
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}` });
}
assetId = resolved;
}
try {
const { rows } = await pool.query(
'SELECT * FROM ivanti_todo_queue WHERE id = $1 AND user_id = $2 AND workflow_type = $3',
[queueItemId, req.user.id, 'CARD']
);
const item = rows[0];
if (!item) {
return res.status(404).json({ error: 'Queue item not found.' });
}
if (item.status !== 'pending') {
return res.status(400).json({ error: 'Only pending queue items can be executed.' });
}
const ownerResult = await getOwner(assetId);
if (!ownerResult.ok) {
const errMsg = `Failed to fetch owner record: HTTP ${ownerResult.status}`;
logAudit({ userId: req.user.id, username: req.user.username, action: 'card_action_failed', entityType: 'ivanti_todo_queue', entityId: String(queueItemId), details: { actionType: 'confirm', assetId, error: errMsg, cardStatus: ownerResult.status }, ipAddress: req.ip });
let errorBody;
try { errorBody = JSON.parse(ownerResult.body); } catch (_) { errorBody = { error: ownerResult.body }; }
return res.status(ownerResult.status).json(errorBody);
}
let ownerData;
try { ownerData = JSON.parse(ownerResult.body); } catch (_) { ownerData = {}; }
const updateToken = ownerData.owner && ownerData.owner.update_token;
if (!updateToken) {
const errMsg = 'update_token not found in owner record.';
logAudit({ userId: req.user.id, username: req.user.username, action: 'card_action_failed', entityType: 'ivanti_todo_queue', entityId: String(queueItemId), details: { actionType: 'confirm', assetId, error: errMsg, cardStatus: null }, ipAddress: req.ip });
return res.status(502).json({ error: 'CARD API request failed.', details: errMsg });
}
const confirmResult = await confirmAsset(assetId, teamName.trim(), updateToken, comment || '');
if (confirmResult.ok) {
await pool.query(
"UPDATE ivanti_todo_queue SET status = 'complete', updated_at = NOW() WHERE id = $1",
[queueItemId]
);
let cardResponse;
try { cardResponse = JSON.parse(confirmResult.body); } catch (_) { cardResponse = confirmResult.body; }
logAudit({ userId: req.user.id, username: req.user.username, action: 'card_confirm', entityType: 'ivanti_todo_queue', entityId: String(queueItemId), details: { assetId, teamName: teamName.trim(), comment: comment || '', cardStatus: confirmResult.status }, ipAddress: req.ip });
return res.json({ success: true, cardResponse });
}
const errMsg = `Confirm failed: HTTP ${confirmResult.status}`;
logAudit({ userId: req.user.id, username: req.user.username, action: 'card_action_failed', entityType: 'ivanti_todo_queue', entityId: String(queueItemId), details: { actionType: 'confirm', assetId, error: errMsg, cardStatus: confirmResult.status }, ipAddress: req.ip });
let errorBody;
try { errorBody = JSON.parse(confirmResult.body); } catch (_) { errorBody = { error: confirmResult.body }; }
return res.status(confirmResult.status).json(errorBody);
} catch (err) {
logAudit({ userId: req.user.id, username: req.user.username, action: 'card_action_failed', entityType: 'ivanti_todo_queue', entityId: String(queueItemId), details: { actionType: 'confirm', assetId, error: err.message, cardStatus: null }, ipAddress: req.ip });
return handleCardError(err, res);
}
});
/**
* POST /queue/:queueItemId/decline
*
* Declines ownership of a CARD asset for a queue item. Fetches the owner
* record to obtain the update_token, then calls the CARD decline endpoint.
* Marks the queue item as complete on success.
*
* @param {string} queueItemId - Queue item ID (path parameter)
* @body {string} teamName - Team name declining ownership (required)
* @body {string} assetId - CARD asset identifier (required)
* @body {string} [comment] - Optional comment for the decline
* @response 200 - { success: true, cardResponse: object }
* @response 400 - { error: string } missing fields or item not pending
* @response 404 - { error: string } queue item not found
* @response 502 - { error: string } CARD API failure
* @response 503 - { error: string, missingVars: string[] } CARD not configured
*/
router.post('/queue/:queueItemId/decline', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
if (!isConfigured) {
return res.status(503).json({ error: 'CARD API is not configured.', missingVars });
}
const { queueItemId } = req.params;
const { teamName, assetId: rawAssetId, comment } = req.body;
if (!teamName || typeof teamName !== 'string' || !teamName.trim()) {
return res.status(400).json({ error: 'teamName is required.' });
}
if (!rawAssetId || typeof rawAssetId !== 'string' || !rawAssetId.trim()) {
return res.status(400).json({ error: 'assetId is required.' });
}
// 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)) {
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}` });
}
assetId = resolved;
}
try {
const { rows } = await pool.query(
'SELECT * FROM ivanti_todo_queue WHERE id = $1 AND user_id = $2 AND workflow_type = $3',
[queueItemId, req.user.id, 'CARD']
);
const item = rows[0];
if (!item) {
return res.status(404).json({ error: 'Queue item not found.' });
}
if (item.status !== 'pending') {
return res.status(400).json({ error: 'Only pending queue items can be executed.' });
}
const ownerResult = await getOwner(assetId);
if (!ownerResult.ok) {
const errMsg = `Failed to fetch owner record: HTTP ${ownerResult.status}`;
logAudit({ userId: req.user.id, username: req.user.username, action: 'card_action_failed', entityType: 'ivanti_todo_queue', entityId: String(queueItemId), details: { actionType: 'decline', assetId, error: errMsg, cardStatus: ownerResult.status }, ipAddress: req.ip });
let errorBody;
try { errorBody = JSON.parse(ownerResult.body); } catch (_) { errorBody = { error: ownerResult.body }; }
return res.status(ownerResult.status).json(errorBody);
}
let ownerData;
try { ownerData = JSON.parse(ownerResult.body); } catch (_) { ownerData = {}; }
const updateToken = ownerData.owner && ownerData.owner.update_token;
if (!updateToken) {
const errMsg = 'update_token not found in owner record. The asset may have already been actioned or the owner record is in an unexpected state.';
logAudit({ userId: req.user.id, username: req.user.username, action: 'card_action_failed', entityType: 'ivanti_todo_queue', entityId: String(queueItemId), details: { actionType: 'decline', assetId, error: errMsg, cardStatus: null, ownerResponse: ownerData }, ipAddress: req.ip });
return res.status(502).json({ error: 'CARD API request failed.', details: errMsg });
}
const declineResult = await declineAsset(assetId, teamName.trim(), updateToken, comment || '');
if (declineResult.ok) {
await pool.query(
"UPDATE ivanti_todo_queue SET status = 'complete', updated_at = NOW() WHERE id = $1",
[queueItemId]
);
let cardResponse;
try { cardResponse = JSON.parse(declineResult.body); } catch (_) { cardResponse = declineResult.body; }
logAudit({ userId: req.user.id, username: req.user.username, action: 'card_decline', entityType: 'ivanti_todo_queue', entityId: String(queueItemId), details: { assetId, teamName: teamName.trim(), comment: comment || '', cardStatus: declineResult.status }, ipAddress: req.ip });
return res.json({ success: true, cardResponse });
}
const errMsg = `Decline failed: HTTP ${declineResult.status}`;
logAudit({ userId: req.user.id, username: req.user.username, action: 'card_action_failed', entityType: 'ivanti_todo_queue', entityId: String(queueItemId), details: { actionType: 'decline', assetId, error: errMsg, cardStatus: declineResult.status }, ipAddress: req.ip });
let errorBody;
try { errorBody = JSON.parse(declineResult.body); } catch (_) { errorBody = { error: declineResult.body }; }
return res.status(declineResult.status).json(errorBody);
} catch (err) {
logAudit({ userId: req.user.id, username: req.user.username, action: 'card_action_failed', entityType: 'ivanti_todo_queue', entityId: String(queueItemId), details: { actionType: 'decline', assetId, error: err.message, cardStatus: null }, ipAddress: req.ip });
return handleCardError(err, res);
}
});
/**
* POST /queue/:queueItemId/redirect
*
* Redirects a CARD asset from one team to another for a queue item. Fetches
* the owner record to obtain the update_token, then calls the CARD redirect
* endpoint. Marks the queue item as complete on success.
*
* @param {string} queueItemId - Queue item ID (path parameter)
* @body {string} fromTeam - Current owning team (required)
* @body {string} toTeam - Target team to redirect to (required)
* @body {string} assetId - CARD asset identifier (required)
* @response 200 - { success: true, cardResponse: object }
* @response 400 - { error: string } missing fields or item not pending
* @response 404 - { error: string } queue item not found
* @response 502 - { error: string } CARD API failure
* @response 503 - { error: string, missingVars: string[] } CARD not configured
*/
router.post('/queue/:queueItemId/redirect', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
if (!isConfigured) {
return res.status(503).json({ error: 'CARD API is not configured.', missingVars });
}
const { queueItemId } = req.params;
const { fromTeam, toTeam, assetId: rawAssetId } = req.body;
if (!fromTeam || typeof fromTeam !== 'string' || !fromTeam.trim()) {
return res.status(400).json({ error: 'fromTeam is required.' });
}
if (!toTeam || typeof toTeam !== 'string' || !toTeam.trim()) {
return res.status(400).json({ error: 'toTeam is required.' });
}
if (!rawAssetId || typeof rawAssetId !== 'string' || !rawAssetId.trim()) {
return res.status(400).json({ error: 'assetId is required.' });
}
// 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)) {
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}` });
}
assetId = resolved;
}
try {
const { rows } = await pool.query(
'SELECT * FROM ivanti_todo_queue WHERE id = $1 AND user_id = $2 AND workflow_type = $3',
[queueItemId, req.user.id, 'CARD']
);
const item = rows[0];
if (!item) {
return res.status(404).json({ error: 'Queue item not found.' });
}
if (item.status !== 'pending') {
return res.status(400).json({ error: 'Only pending queue items can be executed.' });
}
const ownerResult = await getOwner(assetId);
if (!ownerResult.ok) {
const errMsg = `Failed to fetch owner record: HTTP ${ownerResult.status}`;
logAudit({ userId: req.user.id, username: req.user.username, action: 'card_action_failed', entityType: 'ivanti_todo_queue', entityId: String(queueItemId), details: { actionType: 'redirect', assetId, error: errMsg, cardStatus: ownerResult.status }, ipAddress: req.ip });
let errorBody;
try { errorBody = JSON.parse(ownerResult.body); } catch (_) { errorBody = { error: ownerResult.body }; }
return res.status(ownerResult.status).json(errorBody);
}
let ownerData;
try { ownerData = JSON.parse(ownerResult.body); } catch (_) { ownerData = {}; }
const updateToken = ownerData.owner && ownerData.owner.update_token;
if (!updateToken) {
const errMsg = 'update_token not found in owner record.';
logAudit({ userId: req.user.id, username: req.user.username, action: 'card_action_failed', entityType: 'ivanti_todo_queue', entityId: String(queueItemId), details: { actionType: 'redirect', assetId, error: errMsg, cardStatus: null }, ipAddress: req.ip });
return res.status(502).json({ error: 'CARD API request failed.', details: errMsg });
}
const redirectResult = await redirectAsset(assetId, fromTeam.trim(), toTeam.trim(), updateToken);
if (redirectResult.ok) {
await pool.query(
"UPDATE ivanti_todo_queue SET status = 'complete', updated_at = NOW() WHERE id = $1",
[queueItemId]
);
let cardResponse;
try { cardResponse = JSON.parse(redirectResult.body); } catch (_) { cardResponse = redirectResult.body; }
logAudit({ userId: req.user.id, username: req.user.username, action: 'card_redirect', entityType: 'ivanti_todo_queue', entityId: String(queueItemId), details: { assetId, fromTeam: fromTeam.trim(), toTeam: toTeam.trim(), cardStatus: redirectResult.status }, ipAddress: req.ip });
return res.json({ success: true, cardResponse });
}
const errMsg = `Redirect failed: HTTP ${redirectResult.status}`;
logAudit({ userId: req.user.id, username: req.user.username, action: 'card_action_failed', entityType: 'ivanti_todo_queue', entityId: String(queueItemId), details: { actionType: 'redirect', assetId, error: errMsg, cardStatus: redirectResult.status }, ipAddress: req.ip });
let errorBody;
try { errorBody = JSON.parse(redirectResult.body); } catch (_) { errorBody = { error: redirectResult.body }; }
return res.status(redirectResult.status).json(errorBody);
} catch (err) {
logAudit({ userId: req.user.id, username: req.user.username, action: 'card_action_failed', entityType: 'ivanti_todo_queue', entityId: String(queueItemId), details: { actionType: 'redirect', assetId, error: err.message, cardStatus: null }, ipAddress: req.ip });
return handleCardError(err, res);
}
});
/**
* GET /owner-lookup/:ip
*
* Resolve an IP to a CARD asset ID and return the full owner record.
* Used by the CARD Action Modal to display ownership state before
* confirm/decline/redirect operations.
*
* @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
* @response 504 - { error: string, timeout: true } CARD lookup timed out
* @response 503 - { error: string } CARD not configured
*/
router.get('/owner-lookup/:ip', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
if (!isConfigured) {
return res.status(503).json({ error: 'CARD API is not configured.', missingVars });
}
const ip = req.params.ip;
if (!ip || !ip.trim()) {
return res.status(400).json({ error: 'IP address is required.' });
}
// Use quick mode (CTEC only, 15s timeout) for tooltip lookups
const quick = req.query.quick === '1';
const hostId = req.query.hostId;
// 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
}
}
// 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}` });
}
// Fetch full owner record
try {
const ownerResult = await getOwner(assetId);
if (!ownerResult.ok) {
return res.status(ownerResult.status).json({ error: `Failed to fetch owner: HTTP ${ownerResult.status}` });
}
const data = JSON.parse(ownerResult.body);
const owner = data.owner || {};
res.json({
asset_id: assetId,
ip: ip.trim(),
confirmed: owner.confirmed || null,
unconfirmed: owner.unconfirmed || null,
declined: owner.declined || [],
candidate: owner.candidate || [],
update_token: owner.update_token || null,
});
} catch (err) {
return handleCardError(err, res);
}
});
/**
* POST /owner/:assetId/confirm
*
* Directly confirm ownership of a CARD asset (no queue item required).
* Fetches the owner record for the update_token, then calls CARD confirm.
*
* @param {string} assetId - CARD asset identifier or IP address (path parameter)
* @body {string} teamName - Team to confirm ownership for (required)
* @body {string} [comment] - Optional comment
* @response 200 - { success: true, cardResponse: object }
* @response 400 - { error: string } missing fields
* @response 404 - { error: string } asset not found
* @response 502 - { error: string } CARD API failure
* @response 503 - { error: string, missingVars: string[] } CARD not configured
*/
router.post('/owner/:assetId/confirm', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
if (!isConfigured) {
return res.status(503).json({ error: 'CARD API is not configured.', missingVars });
}
let assetId = req.params.assetId;
const { teamName, comment } = req.body || {};
if (!teamName || typeof teamName !== 'string' || !teamName.trim()) {
return res.status(400).json({ error: 'teamName is required.' });
}
// Resolve bare IP to full CARD asset ID
if (!/\d+-[A-Z]+$/i.test(assetId)) {
const resolved = await resolveAssetId(assetId);
if (!resolved) {
return res.status(404).json({ error: `Asset not found in CARD for: ${assetId}` });
}
assetId = resolved;
}
try {
const ownerResult = await getOwner(assetId);
if (!ownerResult.ok) {
return res.status(ownerResult.status).json({ error: `Failed to fetch owner record: HTTP ${ownerResult.status}` });
}
let ownerData;
try { ownerData = JSON.parse(ownerResult.body); } catch (_) { ownerData = {}; }
const updateToken = ownerData.owner && ownerData.owner.update_token;
if (!updateToken) {
return res.status(502).json({ error: 'update_token not found in owner record.' });
}
const confirmResult = await confirmAsset(assetId, teamName.trim(), updateToken, comment || '');
if (confirmResult.ok) {
let cardResponse;
try { cardResponse = JSON.parse(confirmResult.body); } catch (_) { cardResponse = confirmResult.body; }
logAudit({ userId: req.user.id, username: req.user.username, action: 'card_confirm_direct', entityType: 'card_asset', entityId: assetId, details: { teamName: teamName.trim(), comment: comment || '' }, ipAddress: req.ip });
return res.json({ success: true, cardResponse });
}
let errorBody;
try { errorBody = JSON.parse(confirmResult.body); } catch (_) { errorBody = { error: confirmResult.body }; }
return res.status(confirmResult.status).json(errorBody);
} catch (err) {
return handleCardError(err, res);
}
});
/**
* POST /owner/:assetId/decline
*
* Directly decline ownership of a CARD asset (no queue item required).
* Fetches the owner record for the update_token, then calls CARD decline.
*
* @param {string} assetId - CARD asset identifier or IP address (path parameter)
* @body {string} teamName - Team to decline ownership for (required)
* @body {string} [comment] - Optional comment
* @response 200 - { success: true, cardResponse: object }
* @response 400 - { error: string } missing fields
* @response 404 - { error: string } asset not found
* @response 502 - { error: string } CARD API failure
* @response 503 - { error: string, missingVars: string[] } CARD not configured
*/
router.post('/owner/:assetId/decline', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
if (!isConfigured) {
return res.status(503).json({ error: 'CARD API is not configured.', missingVars });
}
let assetId = req.params.assetId;
const { teamName, comment } = req.body || {};
if (!teamName || typeof teamName !== 'string' || !teamName.trim()) {
return res.status(400).json({ error: 'teamName is required.' });
}
if (!/\d+-[A-Z]+$/i.test(assetId)) {
const resolved = await resolveAssetId(assetId);
if (!resolved) {
return res.status(404).json({ error: `Asset not found in CARD for: ${assetId}` });
}
assetId = resolved;
}
try {
const ownerResult = await getOwner(assetId);
if (!ownerResult.ok) {
return res.status(ownerResult.status).json({ error: `Failed to fetch owner record: HTTP ${ownerResult.status}` });
}
let ownerData;
try { ownerData = JSON.parse(ownerResult.body); } catch (_) { ownerData = {}; }
const updateToken = ownerData.owner && ownerData.owner.update_token;
if (!updateToken) {
return res.status(502).json({ error: 'update_token not found in owner record.' });
}
const declineResult = await declineAsset(assetId, teamName.trim(), updateToken, comment || '');
if (declineResult.ok) {
let cardResponse;
try { cardResponse = JSON.parse(declineResult.body); } catch (_) { cardResponse = declineResult.body; }
logAudit({ userId: req.user.id, username: req.user.username, action: 'card_decline_direct', entityType: 'card_asset', entityId: assetId, details: { teamName: teamName.trim(), comment: comment || '' }, ipAddress: req.ip });
return res.json({ success: true, cardResponse });
}
let errorBody;
try { errorBody = JSON.parse(declineResult.body); } catch (_) { errorBody = { error: declineResult.body }; }
return res.status(declineResult.status).json(errorBody);
} catch (err) {
return handleCardError(err, res);
}
});
/**
* POST /owner/:assetId/redirect
*
* Directly redirect a CARD asset between teams (no queue item required).
* Fetches the owner record for the update_token, then calls CARD redirect.
*
* @param {string} assetId - CARD asset identifier or IP address (path parameter)
* @body {string} fromTeam - Current owning team (required)
* @body {string} toTeam - Target team (required)
* @response 200 - { success: true, cardResponse: object }
* @response 400 - { error: string } missing fields
* @response 404 - { error: string } asset not found
* @response 502 - { error: string } CARD API failure
* @response 503 - { error: string, missingVars: string[] } CARD not configured
*/
router.post('/owner/:assetId/redirect', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
if (!isConfigured) {
return res.status(503).json({ error: 'CARD API is not configured.', missingVars });
}
let assetId = req.params.assetId;
const { fromTeam, toTeam } = req.body || {};
if (!fromTeam || typeof fromTeam !== 'string' || !fromTeam.trim()) {
return res.status(400).json({ error: 'fromTeam is required.' });
}
if (!toTeam || typeof toTeam !== 'string' || !toTeam.trim()) {
return res.status(400).json({ error: 'toTeam is required.' });
}
if (!/\d+-[A-Z]+$/i.test(assetId)) {
const resolved = await resolveAssetId(assetId);
if (!resolved) {
return res.status(404).json({ error: `Asset not found in CARD for: ${assetId}` });
}
assetId = resolved;
}
try {
const ownerResult = await getOwner(assetId);
if (!ownerResult.ok) {
return res.status(ownerResult.status).json({ error: `Failed to fetch owner record: HTTP ${ownerResult.status}` });
}
let ownerData;
try { ownerData = JSON.parse(ownerResult.body); } catch (_) { ownerData = {}; }
const updateToken = ownerData.owner && ownerData.owner.update_token;
if (!updateToken) {
return res.status(502).json({ error: 'update_token not found in owner record.' });
}
const redirectResult = await redirectAsset(assetId, fromTeam.trim(), toTeam.trim(), updateToken);
if (redirectResult.ok) {
let cardResponse;
try { cardResponse = JSON.parse(redirectResult.body); } catch (_) { cardResponse = redirectResult.body; }
logAudit({ userId: req.user.id, username: req.user.username, action: 'card_redirect_direct', entityType: 'card_asset', entityId: assetId, details: { fromTeam: fromTeam.trim(), toTeam: toTeam.trim() }, ipAddress: req.ip });
return res.json({ success: true, cardResponse });
}
let errorBody;
try { errorBody = JSON.parse(redirectResult.body); } catch (_) { errorBody = { error: redirectResult.body }; }
return res.status(redirectResult.status).json(errorBody);
} catch (err) {
return handleCardError(err, res);
}
});
/**
* 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
*
* Batch lookup IPs in CARD to extract Granite loader fields. Fetches team
* assets (paginated, across confirmed, unconfirmed, and candidate
* dispositions) and matches against the provided IPs. When no team is
* specified, searches both NTS-AEO-STEAM and NTS-AEO-ACCESS-ENG.
* Returns enrichment results for each IP.
*
* @body {string[]} ips - Non-empty array of IP address strings (max 200)
* @body {string} [team] - Team name to search assets under. Defaults to both NTS-AEO-STEAM and NTS-AEO-ACCESS-ENG if omitted.
* @response 200 - { results: object[], enriched_count: number, not_found_count: number, total: number }
* Each result: { ip: string, found: boolean, equip_inst_id: string|null, hostname: string|null, site_name?: string|null, mgmt_ip_asn?: string|null, responsible_team?: string|null, equipment_class?: string, equip_template?: string|null, equip_status?: string|null, serial_number?: string|null, error?: string }
* @response 400 - { error: string } invalid or empty ips array, or exceeds 200
* @response 503 - { error: string, missingVars: string[] } CARD not configured
*/
router.post('/enrich-batch', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
if (!isConfigured) {
return res.status(503).json({ error: 'CARD API is not configured.', missingVars });
}
const { ips, team } = req.body || {};
if (!Array.isArray(ips) || ips.length === 0) {
return res.status(400).json({ error: 'ips must be a non-empty array of IP address strings.' });
}
if (ips.length > 200) {
return res.status(400).json({ error: 'Maximum 200 IPs per request.' });
}
// Build a set of IPs we're looking for
const targetIps = new Set(ips.map(ip => (ip || '').trim()).filter(Boolean));
const resultMap = {};
// Strategy: fetch team assets (paginated) and match against our target IPs.
// The team assets endpoint returns the full enriched record with ncim_discovery,
// card_flags, netops_granite_allips, etc.
const teams = team ? [team] : ['NTS-AEO-STEAM', 'NTS-AEO-ACCESS-ENG'];
const dispositions = ['confirmed', 'unconfirmed', 'candidate'];
let foundCount = 0;
for (const teamName of teams) {
if (foundCount >= targetIps.size) break;
for (const disposition of dispositions) {
if (foundCount >= targetIps.size) break;
let page = 1;
const pageSize = 200;
let hasMore = true;
while (hasMore && foundCount < targetIps.size) {
try {
const result = await getTeamAssets(teamName, { disposition, page, pageSize });
if (!result.ok) break;
const data = JSON.parse(result.body);
const assets = data.assets || data.results || (Array.isArray(data) ? data : []);
if (assets.length === 0) {
hasMore = false;
break;
}
// Match assets against target IPs
for (const asset of assets) {
const assetId = asset._id || '';
// Extract IP from asset ID (e.g., "10.240.78.110-CTEC" → "10.240.78.110")
const assetIp = assetId.replace(/-[A-Z]+$/i, '');
if (targetIps.has(assetIp) && !resultMap[assetIp]) {
resultMap[assetIp] = extractGraniteFields(asset, assetIp);
foundCount++;
}
}
// Check if there are more pages
const total = data.total || data.count || 0;
if (page * pageSize >= total || assets.length < pageSize) {
hasMore = false;
} else {
page++;
}
} catch (err) {
console.error(`[card-api] enrich-batch: Error fetching ${teamName}/${disposition} page ${page}:`, err.message);
hasMore = false;
}
}
}
}
// Build results array in the same order as input IPs
const results = [];
let enrichedCount = 0;
let notFoundCount = 0;
for (const ip of ips) {
const trimmedIp = (ip || '').trim();
if (!trimmedIp) {
results.push({ ip: '', found: false, equip_inst_id: null, hostname: null, error: 'Invalid IP' });
notFoundCount++;
continue;
}
if (resultMap[trimmedIp]) {
results.push({ ip: trimmedIp, found: true, ...resultMap[trimmedIp] });
enrichedCount++;
} else {
results.push({ ip: trimmedIp, found: false, equip_inst_id: null, hostname: null, error: 'IP not found in CARD team assets' });
notFoundCount++;
}
}
res.json({ results, enriched_count: enrichedCount, not_found_count: notFoundCount, total: ips.length });
});
return router;
}
/**
* Extract Granite-relevant fields from a CARD asset/owner record.
* The owner endpoint returns: owner (confirmed/unconfirmed/etc), card_flags, tmp.
* ncim_discovery is only available from the team assets endpoint.
*/
function extractGraniteFields(asset, ip) {
// card_flags can be an array or nested in the response differently
let flags = {};
if (asset.card_flags) {
flags = Array.isArray(asset.card_flags) ? (asset.card_flags[0] || {}) : asset.card_flags;
}
// Also check if flags are at the top level (owner endpoint format)
if (!flags.CARD_HOSTNAME && asset.CARD_HOSTNAME) {
flags = asset;
}
const ncim = asset.ncim_discovery || [];
const granite = asset.netops_granite_allips || [];
const iseGranite = asset.ise_granite_equipment || [];
const qualys = asset.qualys_hosts || [];
const ivanti = asset.ivanti_assets || [];
// EQUIP_INST_ID — from ncim_discovery or granite (only available from team assets endpoint)
let equip_inst_id = null;
let site_name = null;
let responsible_team = null;
let hostname = null;
if (ncim.length > 0) {
equip_inst_id = ncim[0].EQUIP_INST_ID || null;
responsible_team = ncim[0].GRANITE_RESP_TEAM || ncim[0].RESPONSIBLE_TEAM || null;
site_name = ncim[0].SITE_NAME || ncim[0].SITENAME || null;
hostname = ncim[0].HOSTNAME || null;
}
if (!equip_inst_id && Array.isArray(granite) && granite.length > 0) {
equip_inst_id = granite[0].EQUIP_INST_ID || null;
if (!site_name) site_name = granite[0].SITE_NAME || null;
if (!responsible_team) responsible_team = granite[0].RESPONSIBLE_TEAM || null;
}
if (!equip_inst_id && Array.isArray(iseGranite) && iseGranite.length > 0) {
equip_inst_id = iseGranite[0].EQUIP_INST_ID || null;
}
// Hostname from card_flags (primary source from owner endpoint)
if (!hostname && flags.CARD_HOSTNAME) {
const hostnames = Array.isArray(flags.CARD_HOSTNAME) ? flags.CARD_HOSTNAME : [flags.CARD_HOSTNAME];
hostname = hostnames[0] || null;
}
if (!hostname && qualys.length > 0) hostname = qualys[0].HOSTNAME || null;
if (!hostname && ivanti.length > 0) hostname = ivanti[0].hostName || null;
// ASN from card_flags
const mgmt_ip_asn = flags.CARD_ASN || null;
// CLLI → can be used as site hint
if (!site_name && flags.CARD_CLLI) {
site_name = flags.CARD_CLLI; // CLLI code, not full site name — user may need to map
}
// Equipment class — always S (Shelf) from CARD context
const equipment_class = 'S';
// Device ID → maps to SERIALNUMBER in Granite
const serial_number = flags.CARD_DEVICE_ID || null;
// Equip status from flags
const equip_status = flags.status || null;
// Vendor/model from card_flags
let equip_template = null;
if (flags.CARD_VENDOR_MODEL && Array.isArray(flags.CARD_VENDOR_MODEL) && flags.CARD_VENDOR_MODEL.length > 0) {
const vm = flags.CARD_VENDOR_MODEL[0];
equip_template = typeof vm === 'string' ? vm : (vm.vendor_model || null);
}
// Confirmed team from owner record
const confirmed_team = asset.owner && asset.owner.confirmed
? asset.owner.confirmed.name : null;
return {
equip_inst_id: equip_inst_id ? String(equip_inst_id) : null,
hostname,
site_name,
mgmt_ip_asn: mgmt_ip_asn ? String(mgmt_ip_asn) : null,
responsible_team: responsible_team || confirmed_team || null,
equipment_class,
equip_template,
equip_status,
serial_number,
};
}
module.exports = createCardApiRouter;