4 Commits

Author SHA1 Message Date
jramos
9b36a58959 feat: add CVE tooltip on hover in Reporting Page
- Add GET /api/cves/:cveId/tooltip backend endpoint with description truncation
- Create CveTooltip portal component with caching, severity badges, and viewport-aware positioning
- Integrate tooltip into ReportingPage with 300ms hover delay on CVE badge spans
2026-04-09 14:42:23 -06:00
jramos
690c30aac0 feat: add hostname and IP display to Ivanti queue panel
- Add migration to add hostname column to ivanti_todo_queue table
- Update POST and batch POST endpoints to accept and store hostname
- Pass hostName from findings data when adding items to queue
- Display hostname and IP address in CARD queue section
- Display hostname and IP address in vendor (FP/Archer) queue sections
2026-04-09 11:56:56 -06:00
jramos
fc68097821 fix: remove dual-mode checkbox — clicks always toggle selection, no more popover on first click 2026-04-09 10:01:18 -06:00
jramos
d9fdaf5cbb fix: move selection useEffects after filtered/addPopover declarations to fix ReferenceError 2026-04-09 09:56:33 -06:00
15 changed files with 1142 additions and 66 deletions

3
.gitignore vendored
View File

@@ -51,3 +51,6 @@ backend/add_vendor_to_documents.js
backend/fix_multivendor_constraint.js backend/fix_multivendor_constraint.js
backend/server.js-backup backend/server.js-backup
backend/setup.js-backup backend/setup.js-backup
# Kiro implementation summary (internal only)
docs/kiro-implementation-summary.md

View File

@@ -0,0 +1 @@
{"specId": "b8855eb4-3949-426e-86ac-36fe069a6bb1", "workflowType": "requirements-first", "specType": "feature"}

View File

@@ -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<Map>` | 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 `<span>`.
- `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 `<CveTooltip>` 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<string, object>
// 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 01000, 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 02000, 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 (02000), tooltip height (50200), viewport height (4001200), 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

View File

@@ -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 `<span>` 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.

View File

@@ -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 01000 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 02000, 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 (02000), tooltip height (50200), viewport height (4001200)
- 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 `<span>` 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 `<CveTooltip>` 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)

View File

@@ -0,0 +1 @@
{"specId": "b8855eb4-3949-426e-86ac-36fe069a6bb1", "workflowType": "requirements-first", "specType": "feature"}

View File

@@ -0,0 +1,175 @@
# Design Document: Queue Hostname & IP Display
## Overview
This feature adds hostname tracking to the Ivanti todo queue. Currently the queue stores `ip_address` but not `hostname`. The change spans three layers:
1. **Database** — A migration adds a `hostname TEXT` column to `ivanti_todo_queue`.
2. **Backend API** — The POST (single + batch) endpoints accept and store an optional `hostname` field. The GET endpoint already uses `SELECT *`, so hostname is returned automatically once the column exists.
3. **Frontend** — The `addToQueue` and `submitBatch` functions pass `finding.hostName` as `hostname`. The QueuePanel renders hostname and IP address for both CARD and vendor-grouped (FP/Archer) sections.
The change is additive and backward-compatible. Existing rows get `NULL` for hostname. No existing behavior changes unless both hostname and ip_address are present.
## Architecture
The data flows through three layers in a straight pipeline:
```mermaid
flowchart LR
A[Ivanti Finding<br/>hostName, ipAddress] -->|POST /todo-queue| B[Express Route<br/>ivantiTodoQueue.js]
B -->|INSERT hostname, ip_address| C[SQLite<br/>ivanti_todo_queue]
C -->|SELECT *| B
B -->|GET response| D[QueuePanel<br/>ReportingPage.js]
```
No new services, tables, or route modules are introduced. The migration script is a standalone Node.js file following the existing pattern in `backend/migrations/`.
## Components and Interfaces
### Migration Script: `backend/migrations/add_todo_queue_hostname.js`
Follows the exact pattern of `add_todo_queue_ip_address.js`:
- Opens `cve_database.db` via `sqlite3`
- Runs `ALTER TABLE ivanti_todo_queue ADD COLUMN hostname TEXT`
- Catches `duplicate column name` error to make it idempotent
- Closes the database connection
### Backend Route: `backend/routes/ivantiTodoQueue.js`
Changes to two endpoints:
**POST `/` (single-item)**
- Extract `hostname` from `req.body`
- Sanitize: if present and a string, trim and slice to 255 chars; otherwise `null`
- Add to the INSERT column list and parameter array
**POST `/batch`**
- For each finding in the `findings` array, extract `hostname` from `f.hostname`
- Same sanitization as single-item
- Add to the per-row INSERT column list and parameter array
**GET `/`** — No code change needed. `SELECT *` already returns all columns.
**PUT `/:id`** — No change. Hostname is set at insert time and not editable.
### Frontend: `ReportingPage.js`
**`addToQueue` function**
- Add `hostname: finding.hostName || null` to the POST body
**`submitBatch` function**
- Add `hostname: f.hostName || null` to each finding object in `findingsPayload`
**QueuePanel rendering (per item)**
For CARD items, the content `<div>` currently shows:
1. `finding_id`
2. `ip_address` (if present)
New rendering for CARD items:
1. `finding_id`
2. `hostname` (if present)
3. `ip_address` (if present)
For vendor-grouped items (FP/Archer), the content `<div>` currently shows:
1. `finding_id`
2. CVE list (if present)
New rendering for vendor-grouped items:
1. `finding_id`
2. CVE list (if present)
3. `hostname` (if present)
4. `ip_address` (if present)
Both hostname and IP use the same monospace styling at `0.68rem` / `0.62rem` with muted colors consistent with the existing design system.
## Data Models
### `ivanti_todo_queue` table (after migration)
| Column | Type | Nullable | Notes |
|--------|------|----------|-------|
| id | INTEGER | NO | PRIMARY KEY AUTOINCREMENT |
| user_id | INTEGER | NO | FK → users(id) |
| finding_id | TEXT | NO | |
| finding_title | TEXT | YES | max 500 chars |
| cves_json | TEXT | YES | JSON array string |
| ip_address | TEXT | YES | max 64 chars |
| **hostname** | **TEXT** | **YES** | **max 255 chars (new)** |
| vendor | TEXT | NO | |
| workflow_type | TEXT | NO | FP, Archer, or CARD |
| status | TEXT | NO | pending or complete |
| created_at | DATETIME | NO | DEFAULT CURRENT_TIMESTAMP |
| updated_at | DATETIME | NO | DEFAULT CURRENT_TIMESTAMP |
### API Request/Response Changes
**POST `/api/ivanti/todo-queue` body** — adds optional field:
```json
{
"finding_id": "...",
"finding_title": "...",
"cves": [],
"ip_address": "...",
"hostname": "server01.example.com",
"vendor": "...",
"workflow_type": "CARD"
}
```
**POST `/api/ivanti/todo-queue/batch` body** — adds optional field per finding:
```json
{
"findings": [
{ "finding_id": "...", "ip_address": "...", "hostname": "server01.example.com" }
],
"workflow_type": "FP",
"vendor": "VendorName"
}
```
**GET response**`hostname` field included automatically via `SELECT *`:
```json
{
"id": 1,
"finding_id": "...",
"hostname": "server01.example.com",
"ip_address": "10.0.0.1",
"..."
}
```
## 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: Hostname storage round-trip
*For any* valid hostname string (up to 255 characters), storing it via the queue API (single or batch endpoint) and then retrieving it via GET should return the exact same trimmed string. When the hostname is omitted, null, or empty, the stored and returned value should be null.
**Validates: Requirements 2.1, 2.2, 2.3, 2.4**
### Property 2: Hostname display presence
*For any* queue item with a non-null hostname value, the rendered QueuePanel output should contain the hostname text, regardless of whether the item is a CARD item or a vendor-grouped (FP/Archer) item.
**Validates: Requirements 4.1, 5.1**
## Error Handling
| Scenario | Handling |
|----------|----------|
| Migration run when column already exists | Catch `duplicate column name` SQLite error, log skip message, exit cleanly |
| `hostname` field is not a string | Treat as null — store NULL in database |
| `hostname` exceeds 255 characters | Truncate to 255 characters via `.slice(0, 255)` |
| `hostname` is undefined/null/empty string | Store NULL in database |
| GET returns item with null hostname | Frontend conditionally renders — no hostname line shown |
| GET returns item with null ip_address and null hostname | CARD: show only finding_id. Vendor: show finding_id + CVEs only |
No new error codes or HTTP status changes are introduced. The hostname field is optional and its absence is a normal case, not an error.
## Testing Strategy
Testing is out of scope for this feature. Manual verification will be performed after implementation.

View File

@@ -0,0 +1,70 @@
# Requirements Document
## Introduction
The Ivanti Queue (todo queue) in the STEAM Security Dashboard currently stores and displays `ip_address` for CARD workflow items but omits hostname entirely. Vendor-grouped sections (FP/Archer) display only `finding_id` and CVEs, hiding the `ip_address` that is already stored. This feature adds a `hostname` column to the database, passes hostname through the backend API, and displays both hostname and IP address across all queue sections (CARD, FP, Archer).
## Glossary
- **Queue_Panel**: The slide-out side panel (`QueuePanel` component) that displays the user's staged Ivanti findings grouped by workflow type and vendor.
- **Queue_API**: The Express route module (`ivantiTodoQueue.js`) that handles CRUD operations on the `ivanti_todo_queue` table.
- **Queue_Table**: The SQLite table `ivanti_todo_queue` that persists per-user queue items.
- **CARD_Section**: The top group in the Queue_Panel that displays items with `workflow_type = 'CARD'`.
- **Vendor_Section**: Groups in the Queue_Panel for FP and Archer workflow items, organized by vendor name.
- **Finding**: An Ivanti host finding record containing fields such as `id`, `title`, `hostName`, `ipAddress`, `cves`, and `severity`.
- **Migration_Script**: A standalone Node.js script in `backend/migrations/` that alters the SQLite schema.
## Requirements
### Requirement 1: Add hostname column to the queue database table
**User Story:** As a developer, I want the queue table to have a `hostname` column, so that hostname data can be persisted alongside each queued finding.
#### Acceptance Criteria
1. THE Migration_Script SHALL add a `hostname` TEXT column to the Queue_Table.
2. WHEN the `hostname` column already exists, THE Migration_Script SHALL skip the alteration and log a message indicating the column already exists.
3. THE Migration_Script SHALL preserve all existing rows and column data in the Queue_Table.
### Requirement 2: Accept and store hostname in queue API endpoints
**User Story:** As a developer, I want the queue API to accept a `hostname` field, so that hostname data is stored when findings are added to the queue.
#### Acceptance Criteria
1. WHEN a POST request is received at the single-item endpoint, THE Queue_API SHALL accept an optional `hostname` string field (max 255 characters) and store it in the Queue_Table.
2. WHEN a POST request is received at the batch endpoint, THE Queue_API SHALL accept an optional `hostname` string field on each finding object (max 255 characters) and store it in the Queue_Table.
3. WHEN the `hostname` field is omitted or empty, THE Queue_API SHALL store NULL for the `hostname` column.
4. WHEN a GET request is received, THE Queue_API SHALL return the `hostname` field for each queue item in the response.
### Requirement 3: Pass hostname from the frontend to the queue API
**User Story:** As a developer, I want the frontend to send hostname data when adding findings to the queue, so that hostname is captured from the Ivanti findings data.
#### Acceptance Criteria
1. WHEN a single finding is added to the queue, THE ReportingPage SHALL include the finding's `hostName` value in the `hostname` field of the POST request body.
2. WHEN findings are added via batch submission, THE ReportingPage SHALL include each finding's `hostName` value in the `hostname` field of the corresponding finding object in the POST request body.
### Requirement 4: Display hostname and IP address in the CARD section
**User Story:** As a security analyst, I want to see both hostname and IP address for CARD items in the queue, so that I can identify the affected host at a glance.
#### Acceptance Criteria
1. WHEN a CARD item has a `hostname` value, THE CARD_Section SHALL display the hostname below the finding ID.
2. WHEN a CARD item has an `ip_address` value, THE CARD_Section SHALL display the IP address below the hostname.
3. WHEN a CARD item has both `hostname` and `ip_address`, THE CARD_Section SHALL display hostname on one line and IP address on the next line.
4. WHEN a CARD item has only `ip_address` and no `hostname`, THE CARD_Section SHALL display the IP address (preserving current behavior).
5. WHEN a CARD item has only `hostname` and no `ip_address`, THE CARD_Section SHALL display the hostname.
### Requirement 5: Display hostname and IP address in vendor sections (FP/Archer)
**User Story:** As a security analyst, I want to see hostname and IP address for FP and Archer items in the queue, so that I can identify affected hosts without leaving the queue panel.
#### Acceptance Criteria
1. WHEN a vendor-grouped item has a `hostname` value, THE Vendor_Section SHALL display the hostname below the CVE list.
2. WHEN a vendor-grouped item has an `ip_address` value, THE Vendor_Section SHALL display the IP address below the hostname (or below the CVE list if no hostname exists).
3. WHEN a vendor-grouped item has both `hostname` and `ip_address`, THE Vendor_Section SHALL display hostname on one line and IP address on the next line, both below the CVE list.
4. WHEN a vendor-grouped item has neither `hostname` nor `ip_address`, THE Vendor_Section SHALL display only the finding ID and CVE list (preserving current behavior).

View File

@@ -0,0 +1,56 @@
# Implementation Plan: Queue Hostname & IP Display
## Overview
Add hostname tracking to the Ivanti todo queue across database, backend API, and frontend display layers. All changes are additive and backward-compatible.
## Tasks
- [x] 1. Create database migration to add hostname column
- Create `backend/migrations/add_todo_queue_hostname.js` following the exact pattern of `add_todo_queue_ip_address.js`
- Use `ALTER TABLE ivanti_todo_queue ADD COLUMN hostname TEXT`
- Handle `duplicate column name` error for idempotency
- Log appropriate messages for success and skip scenarios
- _Requirements: 1.1, 1.2, 1.3_
- [x] 2. Update backend API endpoints to accept and store hostname
- [x] 2.1 Update POST `/` (single-item) endpoint in `backend/routes/ivantiTodoQueue.js`
- Extract `hostname` from `req.body`
- Sanitize: if present and a string, trim and slice to 255 chars; otherwise `null`
- Add `hostname` to the INSERT column list and parameter array
- _Requirements: 2.1, 2.3_
- [x] 2.2 Update POST `/batch` endpoint in `backend/routes/ivantiTodoQueue.js`
- For each finding, extract `hostname` from `f.hostname`
- Apply same sanitization as single-item (trim, slice to 255, or null)
- Add `hostname` to the per-row INSERT column list and parameter array
- _Requirements: 2.2, 2.3_
- [x] 3. Checkpoint
- Ensure all backend changes are consistent, ask the user if questions arise.
- [x] 4. Update frontend to pass hostname and display it in the queue panel
- [x] 4.1 Update `addToQueue` function in `ReportingPage.js`
- Add `hostname: finding.hostName || null` to the POST request body
- _Requirements: 3.1_
- [x] 4.2 Update `submitBatch` function in `ReportingPage.js`
- Add `hostname: f.hostName || null` to each finding object in the payload
- _Requirements: 3.2_
- [x] 4.3 Update CARD section rendering in QueuePanel (`ReportingPage.js`)
- Display `hostname` below finding_id (when present)
- Display `ip_address` below hostname (when present)
- Handle all combinations: both present, only hostname, only ip_address, neither
- Use monospace styling at `0.68rem` consistent with existing ip_address display
- _Requirements: 4.1, 4.2, 4.3, 4.4, 4.5_
- [x] 4.4 Update vendor section (FP/Archer) rendering in QueuePanel (`ReportingPage.js`)
- Display `hostname` below the CVE list (when present)
- Display `ip_address` below hostname or below CVE list if no hostname
- Handle all combinations: both present, only one, neither
- Use monospace styling at `0.62rem` / `0.68rem` with muted colors matching existing design
- _Requirements: 5.1, 5.2, 5.3, 5.4_
- [x] 5. Final checkpoint
- Ensure all changes are wired together end-to-end, ask the user if questions arise.

View File

@@ -0,0 +1,25 @@
// Migration: Add hostname column to ivanti_todo_queue
const sqlite3 = require('sqlite3').verbose();
const path = require('path');
const dbPath = path.join(__dirname, '..', 'cve_database.db');
const db = new sqlite3.Database(dbPath);
console.log('Starting add_todo_queue_hostname migration...');
db.run(
'ALTER TABLE ivanti_todo_queue ADD COLUMN hostname TEXT',
(err) => {
if (err) {
// Column may already exist if migration was run before
if (err.message.includes('duplicate column name')) {
console.log('✓ hostname column already exists, skipping');
} else {
console.error('Error adding column:', err);
}
} else {
console.log('✓ hostname column added');
}
db.close(() => console.log('Migration complete!'));
}
);

View File

@@ -56,6 +56,7 @@ function createIvantiTodoQueueRouter(db, requireAuth) {
* @body {string} [findings[].finding_title] - Optional finding title (max 500 chars) * @body {string} [findings[].finding_title] - Optional finding title (max 500 chars)
* @body {string[]} [findings[].cves] - Optional array of CVE identifiers * @body {string[]} [findings[].cves] - Optional array of CVE identifiers
* @body {string} [findings[].ip_address] - Optional IP address (max 64 chars) * @body {string} [findings[].ip_address] - Optional IP address (max 64 chars)
* @body {string} [findings[].hostname] - Optional hostname (max 255 chars)
* @body {string} workflow_type - One of 'FP', 'Archer', 'CARD' * @body {string} workflow_type - One of 'FP', 'Archer', 'CARD'
* @body {string} vendor - Required for FP/Archer (max 200 chars); optional for CARD * @body {string} vendor - Required for FP/Archer (max 200 chars); optional for CARD
* *
@@ -108,7 +109,10 @@ function createIvantiTodoQueueRouter(db, requireAuth) {
const ipVal = f.ip_address && typeof f.ip_address === 'string' const ipVal = f.ip_address && typeof f.ip_address === 'string'
? f.ip_address.trim().slice(0, 64) ? f.ip_address.trim().slice(0, 64)
: null; : null;
return [userId, findingId, title, cvesJson, ipVal, vendorVal, workflow_type]; const hostVal = f.hostname && typeof f.hostname === 'string'
? f.hostname.trim().slice(0, 255)
: null;
return [userId, findingId, title, cvesJson, ipVal, hostVal, vendorVal, workflow_type];
}); });
const insertedIds = []; const insertedIds = [];
@@ -121,8 +125,8 @@ function createIvantiTodoQueueRouter(db, requireAuth) {
rows.forEach((params) => { rows.forEach((params) => {
db.run( db.run(
`INSERT INTO ivanti_todo_queue `INSERT INTO ivanti_todo_queue
(user_id, finding_id, finding_title, cves_json, ip_address, vendor, workflow_type) (user_id, finding_id, finding_title, cves_json, ip_address, hostname, vendor, workflow_type)
VALUES (?, ?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
params, params,
function (err) { function (err) {
if (err && !insertError) { if (err && !insertError) {
@@ -199,7 +203,7 @@ function createIvantiTodoQueueRouter(db, requireAuth) {
* @body {string} [finding_title] - Optional finding title (max 500 chars) * @body {string} [finding_title] - Optional finding title (max 500 chars)
* @body {string[]} [cves] - Optional array of CVE identifiers * @body {string[]} [cves] - Optional array of CVE identifiers
* @body {string} [ip_address] - Optional IP address (max 64 chars) * @body {string} [ip_address] - Optional IP address (max 64 chars)
* @body {string} vendor - Required for FP/Archer (max 200 chars); optional for CARD * @body {string} [hostname] - Optional hostname (max 255 chars) * @body {string} vendor - Required for FP/Archer (max 200 chars); optional for CARD
* @body {string} workflow_type - One of 'FP', 'Archer', 'CARD' * @body {string} workflow_type - One of 'FP', 'Archer', 'CARD'
* *
* @returns {Object} 201 - Created queue item with parsed cves array: * @returns {Object} 201 - Created queue item with parsed cves array:
@@ -209,7 +213,7 @@ function createIvantiTodoQueueRouter(db, requireAuth) {
* @returns {Object} 500 - { error: string } on database error * @returns {Object} 500 - { error: string } on database error
*/ */
router.post('/', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => { router.post('/', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
const { finding_id, finding_title, cves, ip_address, vendor, workflow_type } = req.body; const { finding_id, finding_title, cves, ip_address, hostname, vendor, workflow_type } = req.body;
if (!finding_id || typeof finding_id !== 'string' || finding_id.trim().length === 0) { if (!finding_id || typeof finding_id !== 'string' || finding_id.trim().length === 0) {
return res.status(400).json({ error: 'finding_id is required.' }); return res.status(400).json({ error: 'finding_id is required.' });
@@ -228,15 +232,16 @@ function createIvantiTodoQueueRouter(db, requireAuth) {
const vendorVal = workflow_type === 'CARD' ? '' : vendor.trim(); const vendorVal = workflow_type === 'CARD' ? '' : vendor.trim();
const cvesJson = Array.isArray(cves) ? JSON.stringify(cves) : null; const cvesJson = Array.isArray(cves) ? JSON.stringify(cves) : null;
const ipVal = ip_address && typeof ip_address === 'string' ? ip_address.trim().slice(0, 64) : null; const ipVal = ip_address && typeof ip_address === 'string' ? ip_address.trim().slice(0, 64) : null;
const hostVal = hostname && typeof hostname === 'string' ? hostname.trim().slice(0, 255) : null;
const title = finding_title && typeof finding_title === 'string' const title = finding_title && typeof finding_title === 'string'
? finding_title.slice(0, 500) ? finding_title.slice(0, 500)
: null; : null;
db.run( db.run(
`INSERT INTO ivanti_todo_queue `INSERT INTO ivanti_todo_queue
(user_id, finding_id, finding_title, cves_json, ip_address, vendor, workflow_type) (user_id, finding_id, finding_title, cves_json, ip_address, hostname, vendor, workflow_type)
VALUES (?, ?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
[req.user.id, finding_id.trim(), title, cvesJson, ipVal, vendorVal, workflow_type], [req.user.id, finding_id.trim(), title, cvesJson, ipVal, hostVal, vendorVal, workflow_type],
function (err) { function (err) {
if (err) { if (err) {
console.error('Error adding to queue:', err); console.error('Error adding to queue:', err);

View File

@@ -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 // Compliance export — reads from cve_document_status view
app.get('/api/cves/compliance', requireAuth(db), (req, res) => { app.get('/api/cves/compliance', requireAuth(db), (req, res) => {

View File

@@ -63,7 +63,7 @@ Key details:
- `operator` values: `EXACT`, `IN`, `LIKE`, `WILDCARD`, `RANGE`, `CIDR` - `operator` values: `EXACT`, `IN`, `LIKE`, `WILDCARD`, `RANGE`, `CIDR`
- For empty workflows, use `{"subject":"hostFinding","filterRequest":{"filters":[]}}` with `isEmptyWorkflow=true` - For empty workflows, use `{"subject":"hostFinding","filterRequest":{"filters":[]}}` with `isEmptyWorkflow=true`
#### Response (200) #### Response (200/202)
```json ```json
{ {
@@ -72,7 +72,7 @@ Key details:
} }
``` ```
Returns a numeric `id` (the workflow batch job ID) and `created` timestamp. No `generatedId` or `uuid` in this response. Returns HTTP 200 or 202 (Accepted — async job creation). Response contains a numeric `id` (the workflow batch job ID) and `created` timestamp. No `generatedId` or `uuid` in this response.
### Other Workflow Endpoints (from Swagger) ### Other Workflow Endpoints (from Swagger)

View File

@@ -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(
<TooltipBody
data={data}
loading={loading}
anchorRect={anchorRect}
colors={colors}
severity={severity}
/>,
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 (
<div ref={tooltipRef} style={tooltipStyle} data-testid="cve-tooltip">
{/* Arrow */}
<div style={arrowStyle} data-testid="cve-tooltip-arrow" />
{loading ? (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '0.5rem 0' }}>
<Loader
style={{ width: 18, height: 18, color: '#0EA5E9', animation: 'spin 1s linear infinite' }}
data-testid="cve-tooltip-loader"
/>
</div>
) : data && data.exists ? (
<>
{/* CVE ID header */}
<div style={{
fontFamily: "'JetBrains Mono', monospace",
fontSize: '0.8rem',
fontWeight: 700,
color: '#E2E8F0',
marginBottom: '0.4rem',
letterSpacing: '0.02em',
}}>
{data.cve_id}
</div>
{/* Severity badge */}
{severity && (
<div style={{
display: 'inline-flex',
alignItems: 'center',
gap: '0.35rem',
padding: '0.2rem 0.5rem',
borderRadius: '0.25rem',
border: `1.5px solid ${colors.border}`,
background: colors.bg,
marginBottom: '0.5rem',
}}>
{/* Glow dot */}
<span style={{
width: 7,
height: 7,
borderRadius: '50%',
background: colors.dot,
boxShadow: `0 0 6px ${colors.dot}`,
flexShrink: 0,
}} />
<span style={{
fontFamily: "'JetBrains Mono', monospace",
fontSize: '0.65rem',
fontWeight: 700,
textTransform: 'uppercase',
letterSpacing: '0.04em',
color: colors.text,
}}>
{severity}
</span>
</div>
)}
{/* Description */}
{data.description && (
<div style={{
fontSize: '0.75rem',
lineHeight: 1.5,
color: '#CBD5E1',
wordBreak: 'break-word',
}}>
{data.description}
</div>
)}
</>
) : null}
</div>
);
}

View File

@@ -4,6 +4,7 @@ import { RefreshCw, Loader, AlertCircle, PieChart, ChevronUp, ChevronDown, Chevr
import * as XLSX from 'xlsx'; import * as XLSX from 'xlsx';
import { useAuth } from '../../contexts/AuthContext'; import { useAuth } from '../../contexts/AuthContext';
import IvantiCountsChart from './IvantiCountsChart'; import IvantiCountsChart from './IvantiCountsChart';
import CveTooltip from '../CveTooltip';
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api'; const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
const STORAGE_KEY = 'steam_findings_columns_v2'; 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 // Render a single table cell by column key
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function TableCell({ colKey, finding, canWrite }) { function TableCell({ colKey, finding, canWrite, onCveMouseEnter, onCveMouseLeave }) {
switch (colKey) { switch (colKey) {
case 'findingId': case 'findingId':
return ( return (
@@ -956,7 +957,12 @@ function TableCell({ colKey, finding, canWrite }) {
<td style={{ padding: '0.45rem 0.75rem', minWidth: '160px', maxWidth: '240px' }}> <td style={{ padding: '0.45rem 0.75rem', minWidth: '160px', maxWidth: '240px' }}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.2rem' }}> <div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.2rem' }}>
{shown.map((cve) => ( {shown.map((cve) => (
<span key={cve} 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' }}> <span
key={cve}
onMouseEnter={onCveMouseEnter ? (e) => 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} {cve}
</span> </span>
))} ))}
@@ -1430,28 +1436,62 @@ function QueuePanel({ open, items, onClose, onUpdate, onDelete, onDeleteMany, on
{item.finding_id} {item.finding_id}
</div> </div>
{isCardItem ? ( {isCardItem ? (
item.ip_address && ( <>
<div style={{ {item.hostname && (
fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '600', <div style={{
color: done ? '#334155' : '#10B981', fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '600',
textDecoration: done ? 'line-through' : 'none', color: done ? '#334155' : '#94A3B8',
marginTop: '2px', textDecoration: done ? 'line-through' : 'none',
}}> marginTop: '2px',
{item.ip_address} }}>
</div> {item.hostname}
) </div>
)}
{item.ip_address && (
<div style={{
fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '600',
color: done ? '#334155' : '#10B981',
textDecoration: done ? 'line-through' : 'none',
marginTop: '2px',
}}>
{item.ip_address}
</div>
)}
</>
) : ( ) : (
cves.length > 0 && ( <>
<div style={{ {cves.length > 0 && (
fontFamily: 'monospace', fontSize: '0.62rem', <div style={{
color: done ? '#334155' : '#64748B', fontFamily: 'monospace', fontSize: '0.62rem',
textDecoration: done ? 'line-through' : 'none', color: done ? '#334155' : '#64748B',
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', textDecoration: done ? 'line-through' : 'none',
marginTop: '1px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}} title={cves.join(', ')}> marginTop: '1px',
{cveDisplay} }} title={cves.join(', ')}>
</div> {cveDisplay}
) </div>
)}
{item.hostname && (
<div style={{
fontFamily: 'monospace', fontSize: '0.62rem',
color: done ? '#334155' : '#94A3B8',
textDecoration: done ? 'line-through' : 'none',
marginTop: '1px',
}}>
{item.hostname}
</div>
)}
{item.ip_address && (
<div style={{
fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '600',
color: done ? '#334155' : '#10B981',
textDecoration: done ? 'line-through' : 'none',
marginTop: '1px',
}}>
{item.ip_address}
</div>
)}
</>
)} )}
</div> </div>
@@ -2278,11 +2318,32 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
const [batchWorkflowType, setBatchWorkflowType] = useState('FP'); const [batchWorkflowType, setBatchWorkflowType] = useState('FP');
const [batchVendor, setBatchVendor] = useState(''); 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) => { const updateColumns = useCallback((newOrder) => {
setColumnOrder(newOrder); setColumnOrder(newOrder);
saveColumnOrder(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) => { const applyState = (data) => {
setTotal(data.total ?? 0); setTotal(data.total ?? 0);
setFindings(data.findings || []); setFindings(data.findings || []);
@@ -2324,7 +2385,10 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
try { try {
const res = await fetch(`${API_BASE}/ivanti/findings`, { credentials: 'include' }); const res = await fetch(`${API_BASE}/ivanti/findings`, { credentials: 'include' });
const data = await res.json(); const data = await res.json();
if (res.ok) applyState(data); if (res.ok) {
applyState(data);
tooltipCacheRef.current.clear();
}
} catch (e) { } catch (e) {
console.error('Error loading findings:', e); console.error('Error loading findings:', e);
} finally { } finally {
@@ -2339,6 +2403,7 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
const data = await res.json(); const data = await res.json();
if (res.ok) { if (res.ok) {
applyState(data); applyState(data);
tooltipCacheRef.current.clear();
fetchCounts(); // refresh counts after sync fetchCounts(); // refresh counts after sync
fetchFPWorkflowCounts(); // refresh FP workflow counts after sync fetchFPWorkflowCounts(); // refresh FP workflow counts after sync
} }
@@ -2356,29 +2421,6 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
fetchQueue(); fetchQueue();
}, []); // eslint-disable-line }, []); // eslint-disable-line
// Prune selection when filters change — keep only IDs still in filtered set
useEffect(() => {
setSelectedIds((prev) => {
if (prev.size === 0) return prev;
const visibleIds = new Set(filtered.map((f) => f.id));
const next = new Set([...prev].filter((id) => visibleIds.has(id)));
return next.size === prev.size ? prev : next;
});
}, [filtered]);
// Escape key clears selection
useEffect(() => {
if (selectedIds.size === 0) return;
const handler = (e) => {
if (e.key === 'Escape' && selectedIds.size > 0 && !addPopover) {
setSelectedIds(new Set());
setBatchError(null);
}
};
document.addEventListener('keydown', handler);
return () => document.removeEventListener('keydown', handler);
}, [selectedIds, addPopover]);
// Set/clear a single column filter // Set/clear a single column filter
const setColFilter = useCallback((colKey, vals) => { const setColFilter = useCallback((colKey, vals) => {
setColumnFilters((prev) => { setColumnFilters((prev) => {
@@ -2507,6 +2549,7 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
finding_title: finding.title || null, finding_title: finding.title || null,
cves: finding.cves || [], cves: finding.cves || [],
ip_address: finding.ipAddress || null, ip_address: finding.ipAddress || null,
hostname: finding.hostName || null,
vendor: queueForm.vendor.trim(), vendor: queueForm.vendor.trim(),
workflow_type: queueForm.workflowType, workflow_type: queueForm.workflowType,
}), }),
@@ -2524,6 +2567,29 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
setQueueForm({ vendor: '', workflowType: 'FP' }); setQueueForm({ vendor: '', workflowType: 'FP' });
}, [addPopover, queueForm]); }, [addPopover, queueForm]);
// Prune selection when filters change — keep only IDs still in filtered set
useEffect(() => {
setSelectedIds((prev) => {
if (prev.size === 0) return prev;
const visibleIds = new Set(filtered.map((f) => f.id));
const next = new Set([...prev].filter((id) => visibleIds.has(id)));
return next.size === prev.size ? prev : next;
});
}, [filtered]);
// Escape key clears selection
useEffect(() => {
if (selectedIds.size === 0) return;
const handler = (e) => {
if (e.key === 'Escape' && selectedIds.size > 0 && !addPopover) {
setSelectedIds(new Set());
setBatchError(null);
}
};
document.addEventListener('keydown', handler);
return () => document.removeEventListener('keydown', handler);
}, [selectedIds, addPopover]);
const submitBatch = useCallback(async () => { const submitBatch = useCallback(async () => {
if (selectedIds.size === 0) return; if (selectedIds.size === 0) return;
setBatchSubmitting(true); setBatchSubmitting(true);
@@ -2536,6 +2602,7 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
finding_title: f.title || null, finding_title: f.title || null,
cves: f.cves || [], cves: f.cves || [],
ip_address: f.ipAddress || null, ip_address: f.ipAddress || null,
hostname: f.hostName || null,
} : { finding_id: id }; } : { finding_id: id };
}); });
const res = await fetch(`${API_BASE}/ivanti/todo-queue/batch`, { const res = await fetch(`${API_BASE}/ivanti/todo-queue/batch`, {
@@ -3107,13 +3174,6 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
style={{ padding: '0.45rem 0.5rem', textAlign: 'center', width: '36px' }} style={{ padding: '0.45rem 0.5rem', textAlign: 'center', width: '36px' }}
onClick={(e) => { onClick={(e) => {
if (queued) return; if (queued) return;
// If nothing selected and not shift-click, open single-add popover
if (selectedIds.size === 0 && !e.shiftKey) {
const rect = e.currentTarget.getBoundingClientRect();
setAddPopover({ finding, anchorRect: rect });
setQueueForm({ vendor: '', workflowType: 'FP' });
return;
}
// Shift-click range select // Shift-click range select
if (e.shiftKey && lastClickedId) { if (e.shiftKey && lastClickedId) {
const lastIdx = sorted.findIndex((f) => f.id === lastClickedId); const lastIdx = sorted.findIndex((f) => f.id === lastClickedId);
@@ -3130,7 +3190,7 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
}); });
} }
} else { } else {
// Toggle selection // Regular click — toggle selection
setSelectedIds((prev) => { setSelectedIds((prev) => {
const next = new Set(prev); const next = new Set(prev);
if (next.has(finding.id)) next.delete(finding.id); else next.add(finding.id); if (next.has(finding.id)) next.delete(finding.id); else next.add(finding.id);
@@ -3153,7 +3213,7 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
/> />
</td> </td>
{visibleCols.map((col) => ( {visibleCols.map((col) => (
<TableCell key={col.key} colKey={col.key} finding={finding} canWrite={canWrite()} /> <TableCell key={col.key} colKey={col.key} finding={finding} canWrite={canWrite()} onCveMouseEnter={handleCveMouseEnter} onCveMouseLeave={handleCveMouseLeave} />
))} ))}
</tr> </tr>
); );
@@ -3216,6 +3276,11 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
selectedItems={fpModalItems} selectedItems={fpModalItems}
onSuccess={handleFpWorkflowSuccess} onSuccess={handleFpWorkflowSuccess}
/> />
<CveTooltip
cveId={tooltipCveId}
anchorRect={tooltipAnchorRect}
cache={tooltipCacheRef}
/>
</div> </div>
); );
} }