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

406 lines
15 KiB
JavaScript
Raw Normal View History

// Shared CARD API helpers
// Centralizes HTTP calls for the CARD asset ownership API.
// Follows the same promise-based pattern as atlasApi.js, with the addition
// of OAuth Bearer token management (auto-acquire, cache, refresh, 401 retry).
//
// CARD API versioning:
// - Read endpoints (GET): /api/v1/...
// - Mutation endpoints (POST): /api/v2/...
const https = require('https');
const http = require('http');
const dns = require('dns');
// Force IPv4-first DNS resolution — card.charter.com has both IPv4 and IPv6
// records but IPv6 is unreachable from this network, causing timeouts.
dns.setDefaultResultOrder('ipv4first');
// ---------------------------------------------------------------------------
// Configuration — read from process.env at module load
// ---------------------------------------------------------------------------
const CARD_API_URL = process.env.CARD_API_URL || '';
const CARD_API_USER = process.env.CARD_API_USER || '';
const CARD_API_PASS = process.env.CARD_API_PASS || '';
const CARD_SKIP_TLS = process.env.CARD_SKIP_TLS === 'true';
const requiredVars = ['CARD_API_URL', 'CARD_API_USER', 'CARD_API_PASS'];
const missingVars = requiredVars.filter((v) => !process.env[v]);
if (missingVars.length > 0) {
console.warn(`[card-api] WARNING: Missing required environment variables: ${missingVars.join(', ')}. CARD API calls will fail.`);
}
const isConfigured = missingVars.length === 0;
// ---------------------------------------------------------------------------
// Token Manager — OAuth Bearer token with 1-hour TTL
// ---------------------------------------------------------------------------
let cachedToken = null; // { token: string, expiresAt: number (epoch ms) }
function tokenIsValid() {
if (!cachedToken) return false;
// Refresh if within 60 seconds of expiry
return cachedToken.expiresAt - Date.now() > 60_000;
}
function invalidateToken() {
cachedToken = null;
}
/**
* Acquire a new Bearer token from CARD /api/v1/auth/get_token using Basic Auth.
* Caches the token in memory with a 1-hour TTL.
*/
function acquireToken(timeout) {
const authString = Buffer.from(CARD_API_USER + ':' + CARD_API_PASS).toString('base64');
const fullUrl = new URL(CARD_API_URL + '/api/v1/auth/get_token');
const isHttps = fullUrl.protocol === 'https:';
const transport = isHttps ? https : http;
return new Promise((resolve, reject) => {
const reqOptions = {
hostname: fullUrl.hostname,
port: fullUrl.port || (isHttps ? 443 : 80),
path: fullUrl.pathname + fullUrl.search,
method: 'POST',
family: 4, // Force IPv4 — IPv6 is unreachable from this network
headers: {
'accept': 'application/json',
'authorization': 'Basic ' + authString,
'content-length': '0',
},
timeout: timeout || 30000,
};
if (isHttps) {
reqOptions.rejectUnauthorized = !CARD_SKIP_TLS;
}
const req = transport.request(reqOptions, (res) => {
let data = '';
res.on('data', (chunk) => { data += chunk; });
res.on('end', () => {
if (res.statusCode < 200 || res.statusCode >= 300) {
return reject(new Error(
`[card-api] Token acquisition failed with HTTP ${res.statusCode}: ${data.substring(0, 500)}`
));
}
// The CARD API returns the token as a JSON string or object.
// Try to parse; fall back to raw body as the token string.
let token;
try {
const parsed = JSON.parse(data);
token = typeof parsed === 'string' ? parsed
: parsed.token || parsed.access_token || data.trim();
} catch (_) {
// Response may be a plain token string (unquoted)
token = data.trim();
}
if (!token) {
return reject(new Error('[card-api] Token parse failure: empty token in response body.'));
}
cachedToken = {
token,
expiresAt: Date.now() + 60 * 60 * 1000, // 1-hour TTL
};
resolve(cachedToken.token);
});
});
req.on('timeout', () => req.destroy(new Error('GET /api/v1/auth/get_token timed out')));
req.on('error', (err) => {
reject(new Error(`[card-api] GET /api/v1/auth/get_token failed: ${err.message}`));
});
req.end();
});
}
/**
* Ensure we have a valid Bearer token, acquiring or refreshing as needed.
*/
async function ensureToken(timeout) {
if (tokenIsValid()) return cachedToken.token;
return acquireToken(timeout);
}
// ---------------------------------------------------------------------------
// Generic request — supports GET and POST with Bearer auth + 401 retry
// ---------------------------------------------------------------------------
async function cardRequest(method, urlPath, body, options) {
const timeout = (options && options.timeout) || 30000;
const skipAuth = (options && options.skipAuth) || false;
async function doRequest(bearerToken) {
const fullUrl = new URL(CARD_API_URL + urlPath);
const isHttps = fullUrl.protocol === 'https:';
const transport = isHttps ? https : http;
const headers = { 'accept': 'application/json' };
if (bearerToken) {
headers['authorization'] = 'Bearer ' + bearerToken;
}
let bodyStr = null;
if (body !== null && body !== undefined) {
bodyStr = JSON.stringify(body);
headers['content-type'] = 'application/json';
headers['content-length'] = Buffer.byteLength(bodyStr);
}
return new Promise((resolve, reject) => {
const reqOptions = {
hostname: fullUrl.hostname,
port: fullUrl.port || (isHttps ? 443 : 80),
path: fullUrl.pathname + fullUrl.search,
method,
family: 4, // Force IPv4 — IPv6 is unreachable from this network
headers,
timeout,
};
if (isHttps) {
reqOptions.rejectUnauthorized = !CARD_SKIP_TLS;
}
const req = transport.request(reqOptions, (res) => {
let data = '';
res.on('data', (chunk) => { data += chunk; });
res.on('end', () => resolve({ status: res.statusCode, body: data }));
});
req.on('timeout', () => req.destroy(new Error(`${method} ${urlPath} timed out`)));
req.on('error', (err) => {
reject(new Error(`[card-api] ${method} ${urlPath} failed: ${err.message}`));
});
if (bodyStr) req.write(bodyStr);
req.end();
});
}
// Skip auth for the token endpoint itself
if (skipAuth) {
return doRequest(null);
}
// Normal flow: ensure token → request → retry once on 401
let token = await ensureToken(timeout);
let result = await doRequest(token);
if (result.status === 401) {
// Invalidate and retry exactly once
invalidateToken();
token = await ensureToken(timeout);
result = await doRequest(token);
}
return result;
}
// ---------------------------------------------------------------------------
// Convenience wrappers
// ---------------------------------------------------------------------------
function cardGet(urlPath, options) {
return cardRequest('GET', urlPath, null, options);
}
function cardPost(urlPath, body, options) {
return cardRequest('POST', urlPath, body, options);
}
// ---------------------------------------------------------------------------
// High-level helpers used by the UAT test and routes
// ---------------------------------------------------------------------------
/**
* Test connection by acquiring a token. Returns { ok, token } or { ok, error }.
*/
async function testConnection() {
try {
const token = await acquireToken();
return { ok: true, token: token.substring(0, 12) + '...' };
} catch (err) {
return { ok: false, error: err.message };
}
}
/**
* GET /api/v1/teams list all CARD teams.
*/
async function getTeams() {
const res = await cardGet('/api/v1/teams');
return { status: res.status, body: res.body, ok: res.status >= 200 && res.status < 300 };
}
/**
* GET /api/v1/team/{teamName}/assets list assets for a team.
*/
async function getTeamAssets(teamName, { disposition, page, pageSize } = {}) {
const params = new URLSearchParams();
if (disposition) params.set('disposition', disposition);
if (page) params.set('page', String(page));
params.set('page_size', String(pageSize || 50));
const qs = params.toString();
const res = await cardGet(`/api/v1/team/${encodeURIComponent(teamName)}/assets${qs ? '?' + qs : ''}`);
return { status: res.status, body: res.body, ok: res.status >= 200 && res.status < 300 };
}
/**
* GET /api/v1/owner/{assetId} get owner record including update_token.
*/
async function getOwner(assetId, options) {
const res = await cardGet(`/api/v1/owner/${encodeURIComponent(assetId)}`, options);
return { status: res.status, body: res.body, ok: res.status >= 200 && res.status < 300 };
}
/**
* POST /api/v2/owner/{assetId}/confirm confirm asset to a team.
*/
async function confirmAsset(assetId, teamName, updateToken, comment) {
const params = new URLSearchParams({ update_token: updateToken });
if (comment) params.set('comment', comment);
const res = await cardPost(
`/api/v2/owner/${encodeURIComponent(assetId)}/confirm?${params.toString()}`,
{ name: teamName }
);
return { status: res.status, body: res.body, ok: res.status >= 200 && res.status < 300 };
}
/**
* POST /api/v2/owner/{assetId}/decline decline asset from a team.
*/
async function declineAsset(assetId, teamName, updateToken, comment) {
const params = new URLSearchParams({ update_token: updateToken });
if (comment) params.set('comment', comment);
const res = await cardPost(
`/api/v2/owner/${encodeURIComponent(assetId)}/decline?${params.toString()}`,
{ name: teamName }
);
return { status: res.status, body: res.body, ok: res.status >= 200 && res.status < 300 };
}
/**
* POST /api/v2/owner/{assetId}/{fromTeam}/redirect redirect asset between teams.
*/
async function redirectAsset(assetId, fromTeam, toTeam, updateToken) {
const params = new URLSearchParams({ update_token: updateToken });
const res = await cardPost(
`/api/v2/owner/${encodeURIComponent(assetId)}/${encodeURIComponent(fromTeam)}/redirect?${params.toString()}`,
{ name: toTeam }
);
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 };
}
/**
* GET /api/v2/asset-search/{assetId}?search_param=deep_search
* Search CARD by asset ID (e.g., "24.24.100.20-CTEC"). Returns the full
* enriched asset record including ncim_discovery, netops_granite_allips, etc.
*
* @param {string} assetId - CARD asset identifier (IP-SUFFIX format)
* @param {object} [options] - { timeout }
*/
async function searchByAssetId(assetId, options) {
const id = (assetId || '').trim();
if (!id) {
return { status: 400, body: '{"error":"Asset ID is required."}', ok: false };
}
const res = await cardGet(`/api/v2/asset-search/${encodeURIComponent(id)}?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.
*
* @param {string} ip - IP address or existing asset ID
* @param {object} [options] - { quick: true } to only try CTEC suffix (for tooltip/hover use)
*/
async function resolveAssetId(ip, options) {
const quick = options && options.quick;
const SUFFIXES = quick ? ['CTEC'] : ['CTEC', 'NATL', 'CHTR', 'COML', 'RESI', 'WIFI', 'VOIP'];
const timeout = quick ? 30000 : undefined; // 30s timeout for quick mode
const trimmedIp = (ip || '').trim();
if (!trimmedIp) return null;
// If it already has a suffix (contains a dash followed by letters), use as-is
if (/\d+-[A-Z]+$/i.test(trimmedIp)) {
try {
const result = await getOwner(trimmedIp, timeout ? { timeout } : undefined);
if (result.ok) return trimmedIp;
} catch (err) {
// Timeout — throw so caller can distinguish from "not found"
if (quick && err.message && err.message.includes('timed out')) {
throw new Error('CARD_TIMEOUT');
}
return null;
}
}
// Try each suffix
for (const suffix of SUFFIXES) {
const candidate = `${trimmedIp}-${suffix}`;
try {
const result = await getOwner(candidate, timeout ? { timeout } : undefined);
if (result.ok) return candidate;
} catch (err) {
// Timeout — throw so caller can distinguish from "not found"
if (quick && err.message && err.message.includes('timed out')) {
throw new Error('CARD_TIMEOUT');
}
// Continue to next suffix
}
}
// Try bare IP as last resort (skip in quick mode to avoid extra delay)
if (!quick) {
try {
const result = await getOwner(trimmedIp);
if (result.ok) return trimmedIp;
} catch (_) {
// Not found
}
}
return null;
}
module.exports = {
isConfigured,
missingVars,
cardRequest,
cardGet,
cardPost,
testConnection,
getTeams,
getTeamAssets,
getOwner,
confirmAsset,
declineAsset,
redirectAsset,
invalidateToken,
resolveAssetId,
searchByIvantiHostId,
searchByAssetId,
};