diff --git a/backend/helpers/cardApi.js b/backend/helpers/cardApi.js index 35bf6b2..813d87e 100644 --- a/backend/helpers/cardApi.js +++ b/backend/helpers/cardApi.js @@ -252,8 +252,8 @@ async function getTeamAssets(teamName, { disposition, page, pageSize } = {}) { /** * GET /api/v1/owner/{assetId} — get owner record including update_token. */ -async function getOwner(assetId) { - const res = await cardGet(`/api/v1/owner/${encodeURIComponent(assetId)}`); +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 }; } @@ -298,35 +298,54 @@ async function redirectAsset(assetId, fromTeam, toTeam, updateToken) { /** * 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) { - const SUFFIXES = ['CTEC', 'NATL', 'CHTR', 'COML', 'RESI', 'WIFI', 'VOIP']; +async function resolveAssetId(ip, options) { + const quick = options && options.quick; + const SUFFIXES = quick ? ['CTEC'] : ['CTEC', 'NATL', 'CHTR', 'COML', 'RESI', 'WIFI', 'VOIP']; + const timeout = quick ? 15000 : undefined; // 15s 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)) { - const result = await getOwner(trimmedIp); - if (result.ok) return 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); + const result = await getOwner(candidate, timeout ? { timeout } : undefined); if (result.ok) return candidate; - } catch (_) { + } 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 - try { - const result = await getOwner(trimmedIp); - if (result.ok) return trimmedIp; - } catch (_) { - // Not found + // 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; diff --git a/backend/routes/cardApi.js b/backend/routes/cardApi.js index c8d6afa..71a9fbe 100644 --- a/backend/routes/cardApi.js +++ b/backend/routes/cardApi.js @@ -508,8 +508,11 @@ function createCardApiRouter() { * 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. * @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) => { @@ -522,8 +525,19 @@ function createCardApiRouter() { 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'; + // Resolve to full asset ID - const assetId = await resolveAssetId(ip.trim()); + 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 }); + } + return handleCardError(err, res); + } if (!assetId) { return res.status(404).json({ error: `Asset not found in CARD for IP: ${ip}` }); } @@ -552,6 +566,208 @@ function createCardApiRouter() { } }); + /** + * 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); + } + }); + /** * POST /enrich-batch * diff --git a/frontend/src/components/CardDetailModal.js b/frontend/src/components/CardDetailModal.js new file mode 100644 index 0000000..fdb8700 --- /dev/null +++ b/frontend/src/components/CardDetailModal.js @@ -0,0 +1,376 @@ +/** + * CardDetailModal — Full CARD ownership detail view + * + * Opens from the CARD tooltip "Actions" button on the reporting page. + * Shows the full ownership record and allows confirm/decline/redirect + * directly against the CARD API (no queue item required). + */ + +import React, { useState, useEffect, useCallback } from 'react'; +import { X, Loader, AlertCircle, CheckCircle, XCircle, ArrowRightLeft } from 'lucide-react'; + +// ⚠️ CONVENTION: Prefer using REACT_APP_API_BASE without an absolute URL fallback — other components use relative paths via the env var (e.g. '' default) rather than hardcoding http://localhost:3001/api +const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api'; + +const OVERLAY = { + position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.75)', backdropFilter: 'blur(4px)', + zIndex: 10200, display: 'flex', alignItems: 'center', justifyContent: 'center', +}; +const MODAL = { + background: 'linear-gradient(135deg, #1E293B, #0F172A)', + borderRadius: '1rem', border: '1px solid rgba(124, 58, 237, 0.25)', + width: '90vw', maxWidth: '580px', maxHeight: '85vh', overflow: 'auto', + padding: '1.5rem', position: 'relative', +}; +const SECTION = { + background: 'rgba(15, 23, 42, 0.6)', border: '1px solid rgba(51, 65, 85, 0.5)', + borderRadius: '0.5rem', padding: '0.75rem', marginBottom: '0.75rem', +}; +const LABEL = { fontSize: '0.6rem', color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: '0.2rem' }; +const VALUE = { fontSize: '0.75rem', color: '#E2E8F0', fontFamily: "'JetBrains Mono', monospace" }; +const TEAM_BADGE = (color) => ({ + display: 'inline-block', padding: '0.15rem 0.5rem', borderRadius: '0.25rem', + fontSize: '0.7rem', fontWeight: '600', fontFamily: 'monospace', + background: `${color}15`, border: `1px solid ${color}40`, color, +}); +const INPUT = { + width: '100%', boxSizing: 'border-box', background: 'rgba(15, 23, 42, 0.8)', + border: '1px solid rgba(51, 65, 85, 0.6)', borderRadius: '0.375rem', + color: '#E2E8F0', padding: '0.5rem 0.75rem', fontSize: '0.75rem', + fontFamily: "'JetBrains Mono', monospace", outline: 'none', +}; +const BTN = { + padding: '0.5rem 1.25rem', borderRadius: '0.375rem', border: 'none', + fontSize: '0.75rem', fontWeight: '600', cursor: 'pointer', transition: 'all 0.12s', +}; + +export default function CardDetailModal({ isOpen, onClose, ip, ownerData: initialOwnerData, cardTeams }) { + const [ownerData, setOwnerData] = useState(initialOwnerData || null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [action, setAction] = useState('confirm'); + const [teamName, setTeamName] = useState(''); + const [fromTeam, setFromTeam] = useState(''); + const [toTeam, setToTeam] = useState(''); + const [comment, setComment] = useState(''); + const [executing, setExecuting] = useState(false); + const [execError, setExecError] = useState(null); + const [execSuccess, setExecSuccess] = useState(null); + + // Fetch owner data if not provided or refresh on open + useEffect(() => { + if (!isOpen || !ip) return; + + // If we already have data from the tooltip cache, use it + if (initialOwnerData && !initialOwnerData.notFound && !initialOwnerData.error) { + setOwnerData(initialOwnerData); + // Pre-fill team fields + if (initialOwnerData.confirmed) { + setTeamName(initialOwnerData.confirmed.name || ''); + setFromTeam(initialOwnerData.confirmed.name || ''); + } else if (initialOwnerData.unconfirmed) { + setTeamName(initialOwnerData.unconfirmed.name || ''); + setFromTeam(initialOwnerData.unconfirmed.name || ''); + } + return; + } + + // Fetch fresh + setLoading(true); + setError(null); + fetch(`${API_BASE}/card/owner-lookup/${encodeURIComponent(ip)}`, { credentials: 'include' }) + .then(r => { + if (!r.ok) return r.json().then(d => { throw new Error(d.error || `HTTP ${r.status}`); }); + return r.json(); + }) + .then(data => { + setOwnerData(data); + if (data.confirmed) { + setTeamName(data.confirmed.name || ''); + setFromTeam(data.confirmed.name || ''); + } else if (data.unconfirmed) { + setTeamName(data.unconfirmed.name || ''); + setFromTeam(data.unconfirmed.name || ''); + } + setLoading(false); + }) + .catch(err => { + setError(err.message); + setLoading(false); + }); + }, [isOpen, ip, initialOwnerData]); + + // Reset state on close + useEffect(() => { + if (!isOpen) { + setExecError(null); + setExecSuccess(null); + setComment(''); + } + }, [isOpen]); + + const handleExecute = useCallback(async () => { + if (!ownerData?.asset_id) return; + setExecuting(true); + setExecError(null); + setExecSuccess(null); + + try { + let url, body; + const assetId = ownerData.asset_id; + + if (action === 'confirm') { + url = `${API_BASE}/card/owner/${encodeURIComponent(assetId)}/confirm`; + body = { teamName: teamName.trim(), comment: comment.trim() }; + } else if (action === 'decline') { + url = `${API_BASE}/card/owner/${encodeURIComponent(assetId)}/decline`; + body = { teamName: teamName.trim(), comment: comment.trim() }; + } else if (action === 'redirect') { + url = `${API_BASE}/card/owner/${encodeURIComponent(assetId)}/redirect`; + body = { fromTeam: fromTeam.trim(), toTeam: toTeam.trim() }; + } + + const res = await fetch(url, { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + + const data = await res.json(); + if (!res.ok) { + setExecError(data.error || data.message || `${action} failed.`); + } else { + setExecSuccess(`${action.charAt(0).toUpperCase() + action.slice(1)} successful.`); + } + } catch (err) { + setExecError(err.message || 'Network error.'); + } finally { + setExecuting(false); + } + }, [ownerData, action, teamName, fromTeam, toTeam, comment]); + + if (!isOpen) return null; + + const canExecute = () => { + if (action === 'confirm' || action === 'decline') return teamName.trim().length > 0; + if (action === 'redirect') return fromTeam.trim().length > 0 && toTeam.trim().length > 0; + return false; + }; + + return ( +
+
e.stopPropagation()}> + {/* Header */} +
+
+

CARD Asset Details

+
+ {ip} +
+ {ownerData?.asset_id && ( +
+ {ownerData.asset_id} +
+ )} +
+ +
+ + {/* Loading */} + {loading && ( +
+ +
Loading CARD data...
+
+ )} + + {/* Error */} + {error && ( +
+
+ + {error} +
+
+ )} + + {/* Owner data */} + {ownerData && !loading && ( + <> + {/* Ownership section */} +
+
Ownership
+
+
+ Confirmed: + {ownerData.confirmed ? ( + <> + {ownerData.confirmed.name} + + (score: {ownerData.confirmed.score}, {ownerData.confirmed.datasource || 'n/a'}) + + + ) : ( + + )} +
+
+ Unconfirmed: + {ownerData.unconfirmed ? ( + <> + {ownerData.unconfirmed.name} + + (score: {ownerData.unconfirmed.score}) + + + ) : ( + + )} +
+ {ownerData.candidate && ownerData.candidate.length > 0 && ( +
+ Candidates: +
+ {ownerData.candidate.map((c, i) => ( + {c.name} ({c.score}) + ))} +
+
+ )} + {ownerData.declined && ownerData.declined.length > 0 && ( +
+ Declined: +
+ {ownerData.declined.map((d, i) => ( + {d.name} + ))} +
+
+ )} +
+
+ + {/* Action section */} +
+
Action
+
+ {['confirm', 'decline', 'redirect'].map(a => ( + + ))} +
+ + {/* Action-specific fields */} + {(action === 'confirm' || action === 'decline') && ( +
+
+ + +
+
+ + setComment(e.target.value)} placeholder="Optional comment..." /> +
+
+ )} + + {action === 'redirect' && ( +
+
+ + +
+
+ + +
+
+ )} +
+ + {/* Execution error */} + {execError && ( +
+ + {execError} +
+ )} + + {/* Success */} + {execSuccess && ( +
+ + {execSuccess} +
+ )} + + {/* Footer */} +
+ + +
+ + )} +
+
+ ); +} diff --git a/frontend/src/components/CardOwnerTooltip.js b/frontend/src/components/CardOwnerTooltip.js new file mode 100644 index 0000000..3734611 --- /dev/null +++ b/frontend/src/components/CardOwnerTooltip.js @@ -0,0 +1,333 @@ +/** + * CardOwnerTooltip — CARD ownership hover tooltip + * + * Shows CARD asset ownership data (confirmed/unconfirmed/candidate teams) + * when hovering over an IP address in the findings table. + * Interactive — stays open when you hover into it, includes an Actions button. + * Follows the same portal + positioning pattern as CveTooltip. + */ + +import React, { useState, useEffect, useRef, useLayoutEffect, useCallback } from 'react'; +import ReactDOM from 'react-dom'; +import { Loader, AlertCircle, ExternalLink } from 'lucide-react'; + +// ⚠️ CONVENTION: Use relative API path from REACT_APP_API_BASE only (no absolute URL fallback). +// Other components use: const API_BASE = process.env.REACT_APP_API_BASE || '/api'; +const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api'; + +const TOOLTIP_GAP = 8; +const ARROW_SIZE = 6; +const BORDER_COLOR = '#7C3AED'; // purple to match CARD branding + +function calcPosition(anchorRect, tooltipHeight, viewportHeight) { + const spaceAbove = anchorRect.top; + const spaceBelow = viewportHeight - anchorRect.bottom; + const needed = tooltipHeight + TOOLTIP_GAP + ARROW_SIZE; + + const placeAbove = spaceAbove >= needed || spaceAbove >= spaceBelow; + + let top; + if (placeAbove) { + top = anchorRect.top - tooltipHeight - TOOLTIP_GAP - ARROW_SIZE; + if (top < 0) top = 0; + } else { + top = anchorRect.bottom + TOOLTIP_GAP + ARROW_SIZE; + if (top + tooltipHeight > viewportHeight) top = viewportHeight - tooltipHeight; + } + + const left = anchorRect.left + anchorRect.width / 2; + + return { top, left, placeAbove }; +} + +// --------------------------------------------------------------------------- +// Main exported component +// --------------------------------------------------------------------------- +export default function CardOwnerTooltip({ ip, anchorRect, cache, cardConfigured, onAction, onMouseEnter, onMouseLeave }) { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (!ip) { + setData(null); + setLoading(false); + setError(null); + return; + } + + if (!cardConfigured) { + setError('CARD not configured'); + setLoading(false); + return; + } + + // Check cache + if (cache.current.has(ip)) { + const cached = cache.current.get(ip); + if (cached.error) { + setError(cached.error); + setData(null); + } else { + setData(cached); + setError(null); + } + setLoading(false); + return; + } + + // Fetch + const controller = new AbortController(); + setLoading(true); + setData(null); + setError(null); + + fetch(`${API_BASE}/card/owner-lookup/${encodeURIComponent(ip)}?quick=1`, { + credentials: 'include', + signal: controller.signal, + }) + .then((res) => { + if (res.status === 404) { + const result = { notFound: true }; + cache.current.set(ip, result); + setData(result); + setLoading(false); + return; + } + if (res.status === 504) { + // Timeout — don't cache, can be retried + setError('CARD lookup timed out — try again'); + setLoading(false); + return; + } + if (res.status === 502) { + // CARD unreachable — don't cache + setError('CARD unavailable'); + setLoading(false); + return; + } + if (!res.ok) return res.json().then(d => { throw new Error(d.error || `HTTP ${res.status}`); }); + return res.json(); + }) + .then((payload) => { + if (!payload) return; // 404 already handled + cache.current.set(ip, payload); + setData(payload); + setLoading(false); + }) + .catch((err) => { + if (err.name === 'AbortError') return; + cache.current.set(ip, { error: err.message }); + setError(err.message); + setLoading(false); + }); + + return () => controller.abort(); + }, [ip, cache, cardConfigured]); + + if (!ip || !anchorRect) return null; + if (!loading && !data && !error) return null; + + return ReactDOM.createPortal( + , + document.body, + ); +} + +// --------------------------------------------------------------------------- +// TooltipBody — inner component for measurement + rendering +// --------------------------------------------------------------------------- +function TooltipBody({ data, loading, error, anchorRect, ip, onAction, onMouseEnter, onMouseLeave }) { + const tooltipRef = useRef(null); + const [pos, setPos] = useState({ top: 0, left: 0, placeAbove: true }); + + useLayoutEffect(() => { + if (!tooltipRef.current || !anchorRect) return; + const rect = tooltipRef.current.getBoundingClientRect(); + const vp = window.innerHeight; + setPos(calcPosition(anchorRect, rect.height, vp)); + }, [anchorRect, data, loading, error]); + + const handleAction = useCallback(() => { + if (onAction && ip) { + onAction(ip, data); + } + }, [onAction, ip, data]); + + const tooltipStyle = { + position: 'fixed', + zIndex: 99999, + top: pos.top, + left: pos.left, + transform: 'translateX(-50%)', + maxWidth: 340, + minWidth: 220, + background: 'linear-gradient(135deg, rgba(30, 41, 59, 0.98), rgba(51, 65, 85, 0.95))', + border: `1.5px solid ${BORDER_COLOR}`, + borderRadius: '0.5rem', + padding: '0.75rem', + boxShadow: `0 8px 24px rgba(0, 0, 0, 0.6), 0 0 16px ${BORDER_COLOR}33`, + pointerEvents: 'auto', + transition: 'opacity 0.15s ease', + }; + + const arrowStyle = { + position: 'absolute', + left: '50%', + transform: 'translateX(-50%)', + width: 0, + height: 0, + borderLeft: `${ARROW_SIZE}px solid transparent`, + borderRight: `${ARROW_SIZE}px solid transparent`, + ...(pos.placeAbove + ? { bottom: -ARROW_SIZE, borderTop: `${ARROW_SIZE}px solid ${BORDER_COLOR}`, borderBottom: 'none' } + : { top: -ARROW_SIZE, borderBottom: `${ARROW_SIZE}px solid ${BORDER_COLOR}`, borderTop: 'none' }), + }; + + const LABEL = { fontSize: '0.58rem', color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: '0.15rem' }; + const BADGE = (color) => ({ + display: 'inline-block', padding: '0.12rem 0.45rem', borderRadius: '0.2rem', + fontSize: '0.68rem', fontWeight: '600', fontFamily: "'JetBrains Mono', monospace", + background: `${color}18`, border: `1px solid ${color}50`, color, + }); + + return ( +
+
+ + {/* Header */} +
+ + CARD + + + {ip} + +
+ + {/* Loading */} + {loading && ( +
+ +
+ )} + + {/* Error */} + {error && !loading && ( +
+ + {error} +
+ )} + + {/* Not found */} + {data && data.notFound && !loading && ( +
+ Not found in CARD +
+ )} + + {/* Owner data */} + {data && !data.notFound && !data.error && !loading && ( +
+ {/* Asset ID */} + {data.asset_id && ( +
+
Asset ID
+
+ {data.asset_id} +
+
+ )} + + {/* Confirmed */} +
+
Confirmed Owner
+ {data.confirmed ? ( +
+ {data.confirmed.name} + {data.confirmed.score != null && ( + score: {data.confirmed.score} + )} +
+ ) : ( + + )} +
+ + {/* Unconfirmed */} + {data.unconfirmed && ( +
+
Unconfirmed
+
+ {data.unconfirmed.name} + {data.unconfirmed.score != null && ( + score: {data.unconfirmed.score} + )} +
+
+ )} + + {/* Candidates */} + {data.candidate && data.candidate.length > 0 && ( +
+
Candidates
+
+ {data.candidate.filter(c => c.name !== 'CARD-UNKNOWN').map((c, i) => ( + {c.name} ({c.score}) + ))} +
+
+ )} + + {/* Declined */} + {data.declined && data.declined.length > 0 && ( +
+
Declined
+
+ {data.declined.map((d, i) => ( + {d.name} + ))} +
+
+ )} + + {/* Actions button */} + {onAction && ( +
+ +
+ )} +
+ )} +
+ ); +} diff --git a/frontend/src/components/pages/ReportingPage.js b/frontend/src/components/pages/ReportingPage.js index fcd47db..1a5e0ec 100644 --- a/frontend/src/components/pages/ReportingPage.js +++ b/frontend/src/components/pages/ReportingPage.js @@ -6,6 +6,8 @@ import { useAuth } from '../../contexts/AuthContext'; import IvantiCountsChart from './IvantiCountsChart'; import AnomalyBanner from './AnomalyBanner'; import CveTooltip from '../CveTooltip'; +import CardOwnerTooltip from '../CardOwnerTooltip'; +import CardDetailModal from '../CardDetailModal'; import RedirectModal from '../RedirectModal'; import AtlasBadge from '../AtlasBadge'; import LoaderModal from '../LoaderModal'; @@ -1186,7 +1188,7 @@ function FilterDropdown({ anchorEl, colKey, findings, activeFilter, onFilterChan // --------------------------------------------------------------------------- // Render a single table cell by column key // --------------------------------------------------------------------------- -function TableCell({ colKey, finding, canWrite, onCveMouseEnter, onCveMouseLeave, fpSubmissions, onEditSubmission, atlasStatusMap, onAtlasBadgeClick }) { +function TableCell({ colKey, finding, canWrite, onCveMouseEnter, onCveMouseLeave, onIpMouseEnter, onIpMouseLeave, fpSubmissions, onEditSubmission, atlasStatusMap, onAtlasBadgeClick }) { switch (colKey) { case 'findingId': return ( @@ -1259,7 +1261,11 @@ function TableCell({ colKey, finding, canWrite, onCveMouseEnter, onCveMouseLeave ); case 'ipAddress': return ( - + onIpMouseEnter(finding.ipAddress, e) : undefined} + onMouseLeave={onIpMouseLeave || undefined} + > {finding.ipAddress || '—'} ); @@ -5832,6 +5838,12 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) { const tooltipCacheRef = useRef(new Map()); const hoverTimerRef = useRef(null); + // CARD owner tooltip state & refs + const [cardTooltipIp, setCardTooltipIp] = useState(null); + const [cardTooltipAnchorRect, setCardTooltipAnchorRect] = useState(null); + const cardTooltipCacheRef = useRef(new Map()); + const cardHoverTimerRef = useRef(null); + // Atlas action plan state const [metricsTab, setMetricsTab] = useState('ivanti'); const [atlasStatusMap, setAtlasStatusMap] = useState(new Map()); @@ -5924,6 +5936,49 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) { setTooltipAnchorRect(null); }, []); + // CARD owner tooltip hover handlers (with hover bridge — stays open when hovering tooltip) + const handleIpMouseEnter = useCallback((ip, e) => { + if (!ip) return; + clearTimeout(cardHoverTimerRef.current); + cardHoverTimerRef.current = setTimeout(() => { + setCardTooltipIp(ip); + setCardTooltipAnchorRect(e.target.getBoundingClientRect()); + }, 400); + }, []); + + const handleIpMouseLeave = useCallback(() => { + clearTimeout(cardHoverTimerRef.current); + // Delay hiding to allow mouse to move into tooltip + cardHoverTimerRef.current = setTimeout(() => { + setCardTooltipIp(null); + setCardTooltipAnchorRect(null); + }, 150); + }, []); + + const handleCardTooltipEnter = useCallback(() => { + // Mouse entered tooltip — cancel the hide timer + clearTimeout(cardHoverTimerRef.current); + }, []); + + const handleCardTooltipLeave = useCallback(() => { + // Mouse left tooltip — hide it + clearTimeout(cardHoverTimerRef.current); + setCardTooltipIp(null); + setCardTooltipAnchorRect(null); + }, []); + + // CARD action — open CardActionModal from tooltip + const [cardActionIp, setCardActionIp] = useState(null); + const [cardActionData, setCardActionData] = useState(null); + + const handleCardAction = useCallback((ip, data) => { + setCardActionIp(ip); + setCardActionData(data); + // Close the tooltip + setCardTooltipIp(null); + setCardTooltipAnchorRect(null); + }, []); + const applyState = (data) => { setTotal(data.total ?? 0); setFindings(data.findings || []); @@ -6004,6 +6059,7 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) { // CARD API — fetch status and teams (session-level caching) const cardTeamsFetchedRef = useRef(false); + const cardTeamsRetryRef = useRef(0); const fetchCardStatus = useCallback(async () => { try { const res = await fetch(`${API_BASE}/card/status`, { credentials: 'include' }); @@ -6011,19 +6067,30 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) { const data = await res.json(); setCardConfigured(data.configured === true); if (data.configured && !cardTeamsFetchedRef.current) { - cardTeamsFetchedRef.current = true; const teamsRes = await fetch(`${API_BASE}/card/teams`, { credentials: 'include' }); if (teamsRes.ok) { const teamsData = await teamsRes.json(); const teams = Array.isArray(teamsData) ? teamsData.map(t => t.card_team_name || t._id).filter(Boolean).sort() : []; - setCardTeams(teams); + if (teams.length > 0) { + setCardTeams(teams); + cardTeamsFetchedRef.current = true; + } + } else if (cardTeamsRetryRef.current < 3) { + // Retry silently after a delay (CARD teams endpoint can be slow) + cardTeamsRetryRef.current += 1; + setTimeout(() => fetchCardStatus(), 15000 * cardTeamsRetryRef.current); } } } } catch (err) { console.error('[card-api] Failed to fetch CARD status:', err.message); + // Retry on network error too + if (cardTeamsRetryRef.current < 3) { + cardTeamsRetryRef.current += 1; + setTimeout(() => fetchCardStatus(), 15000 * cardTeamsRetryRef.current); + } } }, []); @@ -7264,7 +7331,7 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) { {visibleCols.map((col) => ( - { setAtlasSelectedHostId(hostId); setAtlasSelectedHostName(finding.hostName || finding.ipAddress || ''); setAtlasSelectedFindingId(finding.id || null); setAtlasPanelOpen(true); }} /> + { setAtlasSelectedHostId(hostId); setAtlasSelectedHostName(finding.hostName || finding.ipAddress || ''); setAtlasSelectedFindingId(finding.id || null); setAtlasPanelOpen(true); }} /> ))} ); @@ -7372,7 +7439,7 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) { {visibleCols.map((col) => ( - { setAtlasSelectedHostId(hostId); setAtlasSelectedHostName(finding.hostName || finding.ipAddress || ''); setAtlasSelectedFindingId(finding.id || null); setAtlasPanelOpen(true); }} /> + { setAtlasSelectedHostId(hostId); setAtlasSelectedHostName(finding.hostName || finding.ipAddress || ''); setAtlasSelectedFindingId(finding.id || null); setAtlasPanelOpen(true); }} /> ))} ); @@ -7473,7 +7540,7 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) { /> {visibleCols.map((col) => ( - { setAtlasSelectedHostId(hostId); setAtlasSelectedHostName(finding.hostName || finding.ipAddress || ''); setAtlasSelectedFindingId(finding.id || null); setAtlasPanelOpen(true); }} /> + { setAtlasSelectedHostId(hostId); setAtlasSelectedHostName(finding.hostName || finding.ipAddress || ''); setAtlasSelectedFindingId(finding.id || null); setAtlasPanelOpen(true); }} /> ))} ); @@ -7569,6 +7636,22 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) { anchorRect={tooltipAnchorRect} cache={tooltipCacheRef} /> + + { setCardActionIp(null); setCardActionData(null); }} + ip={cardActionIp} + ownerData={cardActionData} + cardTeams={cardTeams} + /> {atlasPanelOpen && atlasSelectedHostId && (