diff --git a/.kiro/specs/cve-tooltip-hover/.config.kiro b/.kiro/specs/cve-tooltip-hover/.config.kiro new file mode 100644 index 0000000..24d668d --- /dev/null +++ b/.kiro/specs/cve-tooltip-hover/.config.kiro @@ -0,0 +1 @@ +{"specId": "b8855eb4-3949-426e-86ac-36fe069a6bb1", "workflowType": "requirements-first", "specType": "feature"} \ No newline at end of file diff --git a/.kiro/specs/cve-tooltip-hover/design.md b/.kiro/specs/cve-tooltip-hover/design.md new file mode 100644 index 0000000..d44420b --- /dev/null +++ b/.kiro/specs/cve-tooltip-hover/design.md @@ -0,0 +1,229 @@ +# Design Document: CVE Tooltip Hover + +## Overview + +This feature adds a hover tooltip to CVE badges in the Reporting Page findings table. When a user pauses their cursor over a CVE identifier badge, the system fetches a brief description and severity from the backend and displays it in a styled floating tooltip. Responses are cached in-memory to avoid redundant API calls, and a 300ms hover delay prevents tooltip flicker during fast mouse movement. + +The implementation spans two layers: +1. A new lightweight backend endpoint (`/api/cves/:cveId/tooltip`) that queries the existing `cves` SQLite table and returns a trimmed response. +2. A frontend `CveTooltip` component rendered via a React portal, with an in-memory cache (React ref), hover delay timer, and viewport-aware positioning. + +## Architecture + +```mermaid +sequenceDiagram + participant User + participant CVEBadge as CVE Badge (ReportingPage) + participant Tooltip as CveTooltip Component + participant Cache as Tooltip Cache (useRef) + participant API as /api/cves/:cveId/tooltip + participant DB as SQLite (cves table) + + User->>CVEBadge: mouseenter + CVEBadge->>Tooltip: start 300ms delay timer + Note over Tooltip: If mouseout before 300ms, cancel + + alt Cache hit + Tooltip->>Cache: lookup(cveId) + Cache-->>Tooltip: cached data + Tooltip->>User: show tooltip (or skip if exists:false) + else Cache miss + Tooltip->>API: GET /api/cves/:cveId/tooltip + API->>DB: SELECT cve_id, description, severity FROM cves WHERE cve_id = ? + DB-->>API: row or null + API-->>Tooltip: { exists, cve_id, description, severity } + Tooltip->>Cache: store response + Tooltip->>User: show tooltip (or skip if exists:false) + end + + User->>CVEBadge: mouseleave + CVEBadge->>Tooltip: hide + clear timer +``` + +### Key Design Decisions + +1. **Inline endpoint in server.js** — The tooltip endpoint is a single GET route on the existing `/api/cves` path prefix. It follows the pattern of other simple CVE endpoints already defined inline in `server.js` (e.g., `/api/cves/check/:cveId`, `/api/cves/:cveId/vendors`). No separate route module needed. + +2. **React portal for tooltip rendering** — The tooltip is rendered via `ReactDOM.createPortal` to `document.body`, avoiding overflow/clipping issues from the table's scroll container. The ReportingPage already imports `ReactDOM` for other portal usage. + +3. **useRef for cache instead of useState** — The cache is a plain `Map` stored in a `useRef`. This avoids re-renders when cache entries are added and persists across renders without triggering updates. The cache is cleared when the findings data is re-synced. + +4. **Single shared tooltip instance** — Only one tooltip is visible at a time. The parent component tracks which CVE badge is hovered and passes the active CVE ID + badge position to the tooltip component. + +## Components and Interfaces + +### Backend + +#### `GET /api/cves/:cveId/tooltip` + +Added inline in `server.js` alongside existing CVE endpoints. + +- **Auth**: `requireAuth(db)` — session cookie required +- **Params**: `:cveId` — validated against `CVE_ID_PATTERN` (`/^CVE-\d{4}-\d{4,}$/`) +- **Query**: `SELECT cve_id, description, severity FROM cves WHERE cve_id = ? LIMIT 1` +- **Response (found)**: + ```json + { + "exists": true, + "cve_id": "CVE-2024-12345", + "description": "A vulnerability in...", + "severity": "High" + } + ``` +- **Response (not found)**: + ```json + { "exists": false } + ``` +- **Description truncation**: If `description.length > 300`, return `description.substring(0, 300) + '…'` + +### Frontend + +#### `CveTooltip` Component (new file: `frontend/src/components/CveTooltip.js`) + +A portal-rendered tooltip that receives positioning data and CVE info. + +**Props:** +| Prop | Type | Description | +|------|------|-------------| +| `cveId` | `string \| null` | The CVE ID to display. `null` hides the tooltip. | +| `anchorRect` | `DOMRect \| null` | Bounding rect of the hovered badge for positioning. | +| `cache` | `React.MutableRefObject` | Shared cache ref from parent. | + +**Internal state:** +- `data` — fetched tooltip payload (`{ exists, cve_id, description, severity }` or `null`) +- `loading` — boolean, true while fetch is in-flight + +**Behavior:** +1. When `cveId` changes to a non-null value, check `cache.current` for the CVE ID. +2. If cached and `exists: false`, render nothing. +3. If cached and `exists: true`, display immediately. +4. If not cached, set `loading = true`, fetch from API, store result in cache, set `loading = false`. +5. Position the tooltip above the badge by default. If the tooltip would overflow the top of the viewport, position it below instead. +6. Render via `ReactDOM.createPortal` to `document.body`. + +#### ReportingPage Integration + +Modifications to the existing `renderCell` function for the `'cves'` case: + +- Add `onMouseEnter` / `onMouseLeave` handlers to each CVE badge ``. +- `onMouseEnter`: Start a 300ms `setTimeout`. On fire, set active CVE ID + badge `getBoundingClientRect()` into state. +- `onMouseLeave`: Clear the timeout. Set active CVE ID to `null`. +- Render a single `` instance at the bottom of the component, passing the active CVE ID, anchor rect, and cache ref. +- On data sync (when findings are refreshed), call `cache.current.clear()`. + +## Data Models + +### Existing: `cves` Table (SQLite) + +The tooltip endpoint queries the existing table. No schema changes required. + +```sql +CREATE TABLE cves ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + cve_id TEXT NOT NULL, + vendor TEXT NOT NULL, + severity TEXT CHECK(severity IN ('Critical', 'High', 'Medium', 'Low')), + description TEXT, + published_date TEXT, + status TEXT DEFAULT 'Open', + created_by INTEGER, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(cve_id, vendor) +); +``` + +The query uses `LIMIT 1` since a CVE may have multiple vendor rows — the description and severity from any row suffice for the tooltip blurb. + +### Frontend Cache Structure + +```javascript +// cache.current is a Map +// Key: CVE ID string (e.g. "CVE-2024-12345") +// Value: API response object +// { exists: false } +// OR +// { exists: true, cve_id: string, description: string, severity: string } +``` + + +## Correctness Properties + +*A property is a characteristic or behavior that should hold true across all valid executions of a system — essentially, a formal statement about what the system should do. Properties serve as the bridge between human-readable specifications and machine-verifiable correctness guarantees.* + +### Property 1: Tooltip endpoint returns correct data for existing CVEs + +*For any* CVE record inserted into the `cves` table with a valid `cve_id`, `description`, and `severity`, a GET request to `/api/cves/:cveId/tooltip` SHALL return `{ exists: true }` with the matching `cve_id` and `severity`, and a `description` that is either the original (if ≤ 300 chars) or truncated to 300 chars + ellipsis. + +**Validates: Requirements 1.1, 1.3, 1.5** + +### Property 2: Description truncation preserves content and enforces length + +*For any* string of arbitrary length, the truncation function SHALL return the original string unchanged if its length is ≤ 300, or return exactly the first 300 characters followed by "…" if its length exceeds 300. In both cases, the output starts with the same characters as the input. + +**Validates: Requirements 1.5** + +### Property 3: Tooltip positioning flips based on available viewport space + +*For any* anchor rectangle position and viewport height, the tooltip SHALL be positioned above the anchor when `anchorRect.top` provides sufficient space for the tooltip height, and below the anchor otherwise. The tooltip SHALL never overflow the top or bottom of the viewport. + +**Validates: Requirements 3.1, 3.2** + +### Property 4: Cache round-trip — fetch then cache-hit avoids network call + +*For any* CVE ID, after the tooltip system fetches data from the API and stores it in the cache, a subsequent tooltip request for the same CVE ID SHALL return the identical cached data object without making an additional network request. + +**Validates: Requirements 4.1, 4.2** + +## Error Handling + +| Scenario | Layer | Behavior | +|----------|-------|----------| +| Invalid CVE ID format in URL param | Backend | Return `400 { error: 'Invalid CVE ID format.' }` | +| Database query error | Backend | Log error, return `500 { error: 'Internal server error.' }` | +| No session cookie / expired session | Backend | `requireAuth` middleware returns `401` | +| Network error during fetch | Frontend | Catch error, hide tooltip (do not cache failures), log to console | +| Fetch timeout / slow response | Frontend | Show loading state; if user moves away, cancel via AbortController | +| Component unmounts during fetch | Frontend | AbortController signal aborts in-flight request, no state update | + +**Key principle**: Transient errors (network failures, timeouts) are NOT cached. Only successful API responses (both `exists: true` and `exists: false`) are stored in the cache. This ensures a retry on next hover for failed requests. + +## Testing Strategy + +### Unit Tests (Example-Based) + +| Test | Validates | +|------|-----------| +| Endpoint returns `{ exists: false }` for unknown CVE ID | Req 1.2 | +| Endpoint returns 401 without session cookie | Req 1.4 | +| Endpoint returns 400 for malformed CVE ID (e.g. "not-a-cve") | Req 1.1 (error path) | +| Tooltip appears after 300ms hover delay | Req 5.1 | +| Tooltip cancelled if mouseout before 300ms | Req 5.2 | +| Tooltip hidden on mouseleave | Req 2.2 | +| Loading indicator shown while fetching | Req 2.5 | +| No tooltip shown when API returns `exists: false` | Req 2.6 | +| Severity badge uses correct color per level | Req 2.4 | +| Tooltip has max-width of 320px | Req 3.3 | +| Tooltip includes directional arrow element | Req 3.5 | +| Cache cleared on data sync/refresh | Req 4.4 | +| Cached `exists: false` suppresses tooltip and API call | Req 4.3 | + +### Property-Based Tests + +Property-based tests use **fast-check** (JavaScript PBT library, already compatible with the Jest/react-scripts test runner). + +Each property test runs a minimum of **100 iterations**. + +| Property | Tag | Focus | +|----------|-----|-------| +| Property 1 | `Feature: cve-tooltip-hover, Property 1: Tooltip endpoint returns correct data for existing CVEs` | Generate random CVE records (varying description lengths 0–1000, all 4 severity levels), insert into test DB, call endpoint, verify response shape and truncation | +| Property 2 | `Feature: cve-tooltip-hover, Property 2: Description truncation preserves content and enforces length` | Generate random strings of length 0–2000, apply truncation function, verify length invariant and prefix preservation | +| Property 3 | `Feature: cve-tooltip-hover, Property 3: Tooltip positioning flips based on available viewport space` | Generate random anchorRect.top (0–2000), tooltip height (50–200), viewport height (400–1200), verify position is within viewport bounds | +| Property 4 | `Feature: cve-tooltip-hover, Property 4: Cache round-trip` | Generate random CVE IDs and response payloads, store in cache Map, verify subsequent lookups return identical objects and no fetch is triggered | + +### Test Configuration + +- Test runner: `react-scripts test` (Jest) — already configured in the project +- PBT library: `fast-check` — install via `npm install --save-dev fast-check` in the `frontend/` directory +- Backend endpoint tests: Use supertest or direct handler invocation with a test SQLite DB +- Frontend component tests: React Testing Library with mocked fetch diff --git a/.kiro/specs/cve-tooltip-hover/requirements.md b/.kiro/specs/cve-tooltip-hover/requirements.md new file mode 100644 index 0000000..e72cac6 --- /dev/null +++ b/.kiro/specs/cve-tooltip-hover/requirements.md @@ -0,0 +1,73 @@ +# Requirements Document + +## Introduction + +Add a hover tooltip to CVE badges in the Reporting Page (vuln triage view). When a user hovers over a CVE identifier badge in the findings table, the system checks whether that CVE exists in the local SQLite database. If it does, a small tooltip appears showing a brief description/blurb about that CVE. CVEs not present in the database show no tooltip. + +## Glossary + +- **Reporting_Page**: The vulnerability triage view at `frontend/src/components/pages/ReportingPage.js` that displays Ivanti host findings in a sortable, filterable table. +- **CVE_Badge**: The styled `` element in the CVEs column of the findings table that displays a CVE identifier (e.g. CVE-2024-12345) with a purple pill/box appearance. +- **CVE_Tooltip**: A small floating box that appears on mouse hover over a CVE_Badge, displaying a text blurb about the CVE. +- **CVE_Database**: The `cves` table in the SQLite database (`backend/cve_database.db`) that stores CVE records including descriptions, severity, and vendor information. +- **Tooltip_Cache**: An in-memory lookup (React state or ref) that stores previously fetched CVE descriptions to avoid redundant API calls during the same session. +- **API_Server**: The Express backend at `backend/server.js` that serves CVE data via `/api` endpoints. + +## Requirements + +### Requirement 1: CVE Tooltip Data Endpoint + +**User Story:** As a frontend component, I want to fetch a brief description for a given CVE ID, so that the tooltip can display relevant information without loading unnecessary data. + +#### Acceptance Criteria + +1. WHEN a GET request is made to `/api/cves/:cveId/tooltip`, THE API_Server SHALL return a JSON object containing the `cve_id`, `description`, and `severity` fields for the matching CVE record. +2. WHEN a GET request is made to `/api/cves/:cveId/tooltip` for a CVE ID that does not exist in the CVE_Database, THE API_Server SHALL return a JSON object with `{ exists: false }` and HTTP status 200. +3. WHEN a GET request is made to `/api/cves/:cveId/tooltip` for a CVE ID that exists in the CVE_Database, THE API_Server SHALL return a JSON object with `{ exists: true, cve_id, description, severity }` and HTTP status 200. +4. THE API_Server SHALL require a valid session cookie for the `/api/cves/:cveId/tooltip` endpoint. +5. WHEN the `description` field exceeds 300 characters, THE API_Server SHALL truncate the description to 300 characters and append an ellipsis ("…"). + +### Requirement 2: Tooltip Display on CVE Badge Hover + +**User Story:** As a security analyst triaging findings, I want to see a brief description of a CVE when I hover over its badge in the findings table, so that I can quickly understand the vulnerability without leaving the page. + +#### Acceptance Criteria + +1. WHEN the user hovers the mouse cursor over a CVE_Badge in the Reporting_Page findings table, THE Reporting_Page SHALL display a CVE_Tooltip near the hovered badge. +2. WHEN the user moves the mouse cursor away from the CVE_Badge, THE Reporting_Page SHALL hide the CVE_Tooltip. +3. THE CVE_Tooltip SHALL display the CVE description text returned by the API_Server. +4. THE CVE_Tooltip SHALL display the severity level of the CVE using the existing severity color scheme (Critical: red, High: amber, Medium: sky blue, Low: emerald). +5. WHILE the CVE data is being fetched from the API_Server, THE CVE_Tooltip SHALL display a loading indicator. +6. WHEN the API_Server returns `exists: false` for a CVE ID, THE Reporting_Page SHALL not display a CVE_Tooltip for that badge. + +### Requirement 3: Tooltip Positioning and Styling + +**User Story:** As a security analyst, I want the CVE tooltip to be readable and not obstruct other table content, so that I can continue triaging while viewing CVE details. + +#### Acceptance Criteria + +1. THE CVE_Tooltip SHALL appear above the hovered CVE_Badge by default. +2. WHEN there is insufficient viewport space above the CVE_Badge, THE CVE_Tooltip SHALL appear below the badge instead. +3. THE CVE_Tooltip SHALL have a maximum width of 320 pixels. +4. THE CVE_Tooltip SHALL use the design system dark theme styling: dark background gradient, accent border, monospace font for the CVE ID, and standard font for the description text. +5. THE CVE_Tooltip SHALL include a small directional arrow pointing toward the CVE_Badge. + +### Requirement 4: Tooltip Response Caching + +**User Story:** As a security analyst scrolling through many findings, I want CVE tooltip data to load instantly for CVEs I have already hovered over, so that repeated hovers do not cause redundant network requests. + +#### Acceptance Criteria + +1. WHEN the Reporting_Page fetches tooltip data for a CVE ID, THE Tooltip_Cache SHALL store the response for that CVE ID. +2. WHEN the user hovers over a CVE_Badge for a CVE ID that exists in the Tooltip_Cache, THE Reporting_Page SHALL display the cached data without making an API call. +3. WHEN the user hovers over a CVE_Badge for a CVE ID where the Tooltip_Cache stores `exists: false`, THE Reporting_Page SHALL not display a tooltip and SHALL not make an API call. +4. WHEN the Reporting_Page performs a full data sync (refresh), THE Tooltip_Cache SHALL be cleared. + +### Requirement 5: Hover Delay + +**User Story:** As a security analyst, I want the tooltip to only appear after a brief pause on a CVE badge, so that tooltips do not flash distractingly when I move the mouse across the table quickly. + +#### Acceptance Criteria + +1. WHEN the user hovers over a CVE_Badge, THE Reporting_Page SHALL wait 300 milliseconds before initiating the tooltip display sequence. +2. IF the user moves the mouse away from the CVE_Badge before 300 milliseconds have elapsed, THEN THE Reporting_Page SHALL cancel the tooltip display and not make an API call. diff --git a/.kiro/specs/cve-tooltip-hover/tasks.md b/.kiro/specs/cve-tooltip-hover/tasks.md new file mode 100644 index 0000000..bf330e7 --- /dev/null +++ b/.kiro/specs/cve-tooltip-hover/tasks.md @@ -0,0 +1,107 @@ +# Implementation Plan: CVE Tooltip Hover + +## Overview + +Implement a hover tooltip for CVE badges in the Reporting Page findings table. The feature spans a backend endpoint (`GET /api/cves/:cveId/tooltip`) and a frontend `CveTooltip` portal component with in-memory caching and 300ms hover delay. Tasks are ordered backend-first, then frontend component, then integration, with property tests alongside each layer. + +## Tasks + +- [x] 1. Add backend tooltip endpoint + - [x] 1.1 Add `GET /api/cves/:cveId/tooltip` route inline in `backend/server.js` + - Place it alongside existing CVE endpoints (after `/api/cves/:cveId/vendors`) + - Validate `:cveId` against existing `CVE_ID_PATTERN`; return 400 for invalid format + - Query: `SELECT cve_id, description, severity FROM cves WHERE cve_id = ? LIMIT 1` + - If no row: return `{ exists: false }` with status 200 + - If row found: truncate `description` to 300 chars + "…" if needed, return `{ exists: true, cve_id, description, severity }` + - Protect with `requireAuth(db)` middleware + - _Requirements: 1.1, 1.2, 1.3, 1.4, 1.5_ + + - [ ]* 1.2 Write property test for tooltip endpoint data correctness + - **Property 1: Tooltip endpoint returns correct data for existing CVEs** + - Install `fast-check` as dev dependency in `frontend/` (shared test runner) + - Generate random CVE records with description lengths 0–1000 and all 4 severity levels + - Verify response shape, truncation at 300 chars, and prefix preservation + - **Validates: Requirements 1.1, 1.3, 1.5** + + - [ ]* 1.3 Write property test for description truncation + - **Property 2: Description truncation preserves content and enforces length** + - Extract truncation logic into a testable pure function + - Generate random strings of length 0–2000, verify length invariant and prefix match + - **Validates: Requirements 1.5** + +- [x] 2. Checkpoint — Verify backend endpoint + - Ensure all tests pass, ask the user if questions arise. + +- [x] 3. Create CveTooltip frontend component + - [x] 3.1 Create `frontend/src/components/CveTooltip.js` + - Portal-rendered component using `ReactDOM.createPortal` to `document.body` + - Props: `cveId` (string|null), `anchorRect` (DOMRect|null), `cache` (useRef Map) + - Internal state: `data`, `loading` + - On `cveId` change: check cache → if miss, fetch from `/api/cves/:cveId/tooltip` with AbortController + - If cached `exists: false` or fetch returns `exists: false`, render nothing + - Show loading spinner (Loader from lucide-react) while fetching + - Display: CVE ID in monospace, severity badge with design system colors, description text + - Max-width 320px, dark theme gradient background, accent border, directional arrow + - Position above anchor by default; flip below if insufficient viewport space above + - Do not cache transient errors (network failures) + - _Requirements: 2.1, 2.2, 2.3, 2.4, 2.5, 2.6, 3.1, 3.2, 3.3, 3.4, 3.5_ + + - [ ]* 3.2 Write property test for tooltip positioning logic + - **Property 3: Tooltip positioning flips based on available viewport space** + - Extract positioning calculation into a pure function + - Generate random anchorRect.top (0–2000), tooltip height (50–200), viewport height (400–1200) + - Verify tooltip never overflows top or bottom of viewport + - **Validates: Requirements 3.1, 3.2** + + - [ ]* 3.3 Write unit tests for CveTooltip component + - Test loading state renders spinner + - Test `exists: false` renders nothing + - Test severity badge uses correct color per level + - Test max-width constraint + - Test directional arrow element is present + - _Requirements: 2.4, 2.5, 2.6, 3.3, 3.5_ + +- [x] 4. Checkpoint — Verify CveTooltip component + - Ensure all tests pass, ask the user if questions arise. + +- [x] 5. Integrate tooltip into ReportingPage + - [x] 5.1 Add hover state and cache ref to ReportingPage + - Add state: `tooltipCveId` (string|null), `tooltipAnchorRect` (DOMRect|null) + - Add `useRef(new Map())` for tooltip cache + - Add `useRef` for hover delay timer + - Clear cache when findings data is re-synced (inside existing sync callback) + - _Requirements: 4.1, 4.4, 5.1_ + + - [x] 5.2 Add mouseenter/mouseleave handlers to CVE badge spans + - In the `renderCell` function for the `'cves'` column case, wrap each CVE badge `` with `onMouseEnter` and `onMouseLeave` + - `onMouseEnter`: start 300ms setTimeout; on fire, set `tooltipCveId` and `tooltipAnchorRect` from `getBoundingClientRect()` + - `onMouseLeave`: clear timeout, set `tooltipCveId` to null + - _Requirements: 2.1, 2.2, 5.1, 5.2_ + + - [x] 5.3 Render CveTooltip instance in ReportingPage + - Add single `` at the bottom of the ReportingPage return, passing `tooltipCveId`, `tooltipAnchorRect`, and cache ref + - _Requirements: 2.1, 4.2, 4.3_ + + - [ ]* 5.4 Write property test for cache round-trip behavior + - **Property 4: Cache round-trip — fetch then cache-hit avoids network call** + - Generate random CVE IDs and response payloads, store in Map, verify lookups return identical objects + - **Validates: Requirements 4.1, 4.2** + + - [ ]* 5.5 Write unit tests for hover delay and cache integration + - Test tooltip appears after 300ms delay (use fake timers) + - Test tooltip cancelled if mouseout before 300ms + - Test cached `exists: false` suppresses tooltip and API call + - Test cache cleared on data sync/refresh + - _Requirements: 4.3, 4.4, 5.1, 5.2_ + +- [x] 6. Final checkpoint — Ensure all tests pass + - Ensure all tests pass, ask the user if questions arise. + +## Notes + +- Tasks marked with `*` are optional and can be skipped for faster MVP +- Each task references specific requirements for traceability +- Checkpoints ensure incremental validation +- Property tests validate universal correctness properties from the design document +- Unit tests validate specific examples and edge cases +- The project uses plain JavaScript (no TypeScript), fast-check for PBT, and react-scripts test (Jest) diff --git a/backend/server.js b/backend/server.js index a2cefd4..7497316 100644 --- a/backend/server.js +++ b/backend/server.js @@ -348,6 +348,29 @@ app.get('/api/cves/:cveId/vendors', requireAuth(db), (req, res) => { }); }); +// Get tooltip data for a specific CVE (authenticated users) +app.get('/api/cves/:cveId/tooltip', requireAuth(db), (req, res) => { + const { cveId } = req.params; + + if (!CVE_ID_PATTERN.test(cveId)) { + return res.status(400).json({ error: 'Invalid CVE ID format.' }); + } + + db.get('SELECT cve_id, description, severity FROM cves WHERE cve_id = ? LIMIT 1', [cveId], (err, row) => { + if (err) { + console.error('Error fetching CVE tooltip:', err); + return res.status(500).json({ error: 'Internal server error.' }); + } + if (!row) { + return res.json({ exists: false }); + } + let description = row.description || ''; + if (description.length > 300) { + description = description.substring(0, 300) + '\u2026'; + } + res.json({ exists: true, cve_id: row.cve_id, description, severity: row.severity }); + }); +}); // Compliance export — reads from cve_document_status view app.get('/api/cves/compliance', requireAuth(db), (req, res) => { diff --git a/frontend/src/components/CveTooltip.js b/frontend/src/components/CveTooltip.js new file mode 100644 index 0000000..f6f74cc --- /dev/null +++ b/frontend/src/components/CveTooltip.js @@ -0,0 +1,243 @@ +import React, { useState, useEffect } from 'react'; +import ReactDOM from 'react-dom'; +import { Loader } from 'lucide-react'; + +const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api'; + +// --------------------------------------------------------------------------- +// Severity color mapping — matches DESIGN_SYSTEM.md badge colors +// --------------------------------------------------------------------------- +const SEVERITY_COLORS = { + Critical: { border: '#EF4444', bg: 'rgba(239, 68, 68, 0.25)', text: '#FCA5A5', dot: '#EF4444' }, + High: { border: '#F59E0B', bg: 'rgba(245, 158, 11, 0.25)', text: '#FCD34D', dot: '#F59E0B' }, + Medium: { border: '#0EA5E9', bg: 'rgba(14, 165, 233, 0.25)', text: '#7DD3FC', dot: '#0EA5E9' }, + Low: { border: '#10B981', bg: 'rgba(16, 185, 129, 0.25)', text: '#6EE7B7', dot: '#10B981' }, +}; + +// --------------------------------------------------------------------------- +// Pure positioning function — exported for testability +// --------------------------------------------------------------------------- +const TOOLTIP_GAP = 8; +const ARROW_SIZE = 6; + +export function calcTooltipPosition(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 }; +} + +// --------------------------------------------------------------------------- +// CveTooltip component +// --------------------------------------------------------------------------- +export default function CveTooltip({ cveId, anchorRect, cache }) { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(false); + + useEffect(() => { + if (!cveId) { + setData(null); + setLoading(false); + return; + } + + // Check cache first + if (cache.current.has(cveId)) { + setData(cache.current.get(cveId)); + setLoading(false); + return; + } + + // Cache miss — fetch from API + const controller = new AbortController(); + setLoading(true); + setData(null); + + fetch(`${API_BASE}/cves/${encodeURIComponent(cveId)}/tooltip`, { + credentials: 'include', + signal: controller.signal, + }) + .then((res) => { + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return res.json(); + }) + .then((payload) => { + cache.current.set(cveId, payload); + setData(payload); + setLoading(false); + }) + .catch((err) => { + if (err.name === 'AbortError') return; + // Do not cache transient errors + console.error('CveTooltip fetch error:', err); + setData(null); + setLoading(false); + }); + + return () => controller.abort(); + }, [cveId, cache]); + + // Nothing to show + if (!cveId || !anchorRect) return null; + if (!loading && !data) return null; + if (data && data.exists === false) return null; + + const severity = data?.severity || ''; + const colors = SEVERITY_COLORS[severity] || SEVERITY_COLORS.Medium; + + return ReactDOM.createPortal( + , + document.body, + ); +} + + +// --------------------------------------------------------------------------- +// TooltipBody — inner component that measures itself for positioning +// --------------------------------------------------------------------------- +function TooltipBody({ data, loading, anchorRect, colors, severity }) { + const tooltipRef = React.useRef(null); + const [pos, setPos] = React.useState({ top: 0, left: 0, placeAbove: true }); + + React.useLayoutEffect(() => { + if (!tooltipRef.current || !anchorRect) return; + const rect = tooltipRef.current.getBoundingClientRect(); + const vp = window.innerHeight; + setPos(calcTooltipPosition(anchorRect, rect.height, vp)); + }, [anchorRect, data, loading]); + + const tooltipStyle = { + position: 'fixed', + zIndex: 99999, + top: pos.top, + left: pos.left, + transform: 'translateX(-50%)', + maxWidth: 320, + minWidth: 200, + background: 'linear-gradient(135deg, rgba(30, 41, 59, 0.98), rgba(51, 65, 85, 0.95))', + border: `1.5px solid ${colors.border}`, + borderRadius: '0.5rem', + padding: '0.75rem', + boxShadow: `0 8px 24px rgba(0, 0, 0, 0.6), 0 0 16px ${colors.border}33`, + pointerEvents: 'none', + transition: 'opacity 0.15s ease', + }; + + // Directional arrow + 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 ${colors.border}`, + borderBottom: 'none', + } + : { + top: -ARROW_SIZE, + borderBottom: `${ARROW_SIZE}px solid ${colors.border}`, + borderTop: 'none', + }), + }; + + return ( +
+ {/* Arrow */} +
+ + {loading ? ( +
+ +
+ ) : data && data.exists ? ( + <> + {/* CVE ID header */} +
+ {data.cve_id} +
+ + {/* Severity badge */} + {severity && ( +
+ {/* Glow dot */} + + + {severity} + +
+ )} + + {/* Description */} + {data.description && ( +
+ {data.description} +
+ )} + + ) : null} +
+ ); +} diff --git a/frontend/src/components/pages/ReportingPage.js b/frontend/src/components/pages/ReportingPage.js index 01fde5a..3236a94 100644 --- a/frontend/src/components/pages/ReportingPage.js +++ b/frontend/src/components/pages/ReportingPage.js @@ -4,6 +4,7 @@ import { RefreshCw, Loader, AlertCircle, PieChart, ChevronUp, ChevronDown, Chevr import * as XLSX from 'xlsx'; import { useAuth } from '../../contexts/AuthContext'; import IvantiCountsChart from './IvantiCountsChart'; +import CveTooltip from '../CveTooltip'; const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api'; const STORAGE_KEY = 'steam_findings_columns_v2'; @@ -920,7 +921,7 @@ function FilterDropdown({ anchorEl, colKey, findings, activeFilter, onFilterChan // --------------------------------------------------------------------------- // Render a single table cell by column key // --------------------------------------------------------------------------- -function TableCell({ colKey, finding, canWrite }) { +function TableCell({ colKey, finding, canWrite, onCveMouseEnter, onCveMouseLeave }) { switch (colKey) { case 'findingId': return ( @@ -956,7 +957,12 @@ function TableCell({ colKey, finding, canWrite }) {
{shown.map((cve) => ( - + onCveMouseEnter(cve, e) : undefined} + onMouseLeave={onCveMouseLeave || undefined} + style={{ padding: '0.1rem 0.35rem', borderRadius: '0.2rem', background: 'rgba(139,92,246,0.1)', border: '1px solid rgba(139,92,246,0.3)', color: '#A78BFA', fontFamily: 'monospace', fontSize: '0.65rem', fontWeight: '600', whiteSpace: 'nowrap' }} + > {cve} ))} @@ -2312,11 +2318,32 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) { const [batchWorkflowType, setBatchWorkflowType] = useState('FP'); const [batchVendor, setBatchVendor] = useState(''); + // CVE tooltip state & refs + const [tooltipCveId, setTooltipCveId] = useState(null); + const [tooltipAnchorRect, setTooltipAnchorRect] = useState(null); + const tooltipCacheRef = useRef(new Map()); + const hoverTimerRef = useRef(null); + const updateColumns = useCallback((newOrder) => { setColumnOrder(newOrder); saveColumnOrder(newOrder); }, []); + // CVE tooltip hover handlers + const handleCveMouseEnter = useCallback((cveId, e) => { + clearTimeout(hoverTimerRef.current); + hoverTimerRef.current = setTimeout(() => { + setTooltipCveId(cveId); + setTooltipAnchorRect(e.target.getBoundingClientRect()); + }, 300); + }, []); + + const handleCveMouseLeave = useCallback(() => { + clearTimeout(hoverTimerRef.current); + setTooltipCveId(null); + setTooltipAnchorRect(null); + }, []); + const applyState = (data) => { setTotal(data.total ?? 0); setFindings(data.findings || []); @@ -2358,7 +2385,10 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) { try { const res = await fetch(`${API_BASE}/ivanti/findings`, { credentials: 'include' }); const data = await res.json(); - if (res.ok) applyState(data); + if (res.ok) { + applyState(data); + tooltipCacheRef.current.clear(); + } } catch (e) { console.error('Error loading findings:', e); } finally { @@ -2373,6 +2403,7 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) { const data = await res.json(); if (res.ok) { applyState(data); + tooltipCacheRef.current.clear(); fetchCounts(); // refresh counts after sync fetchFPWorkflowCounts(); // refresh FP workflow counts after sync } @@ -3182,7 +3213,7 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) { /> {visibleCols.map((col) => ( - + ))} ); @@ -3245,6 +3276,11 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) { selectedItems={fpModalItems} onSuccess={handleFpWorkflowSuccess} /> +
); }