197 lines
9.8 KiB
Markdown
197 lines
9.8 KiB
Markdown
|
|
# Design Document: Archive Finding Clarity
|
||
|
|
|
||
|
|
## Overview
|
||
|
|
|
||
|
|
This feature enhances the Ivanti Archive Findings panel on the STEAM Security Dashboard homepage to provide clearer context for archived findings. The changes span both backend (related active finding detection) and frontend (card rendering improvements).
|
||
|
|
|
||
|
|
The core additions are:
|
||
|
|
1. **Finding ID display** — Show the Ivanti finding ID on each archive card for cross-referencing
|
||
|
|
2. **Historical severity labeling** — Prefix severity with "Last seen:" to clarify it's a snapshot
|
||
|
|
3. **Related active finding detection** — Server-side matching of archived findings against the current findings cache by hostname + title
|
||
|
|
4. **Visual status indicators** — Icon and border color distinctions based on whether a related active finding exists
|
||
|
|
|
||
|
|
All matching is performed server-side in a single pass to avoid per-card API calls and keep the archive panel responsive.
|
||
|
|
|
||
|
|
## Architecture
|
||
|
|
|
||
|
|
The feature touches two layers:
|
||
|
|
|
||
|
|
```mermaid
|
||
|
|
flowchart LR
|
||
|
|
subgraph Backend
|
||
|
|
A[GET /api/ivanti/archive?state=X] --> B[Query ivanti_finding_archives]
|
||
|
|
B --> C[Parse ivanti_findings_cache JSON once]
|
||
|
|
C --> D[Match each archive record against active findings]
|
||
|
|
D --> E[Return archives with related_active field]
|
||
|
|
end
|
||
|
|
subgraph Frontend
|
||
|
|
E --> F[App.js archiveList.map]
|
||
|
|
F --> G[Render enhanced Archive Cards]
|
||
|
|
end
|
||
|
|
```
|
||
|
|
|
||
|
|
**Key design decision:** The related finding lookup is embedded in the existing `GET /api/ivanti/archive` endpoint rather than exposed as a separate endpoint. This avoids N+1 API calls from the frontend and keeps the archive panel's fetch pattern unchanged (single request per state filter click).
|
||
|
|
|
||
|
|
### Data Flow
|
||
|
|
|
||
|
|
1. User clicks a state card in `ArchiveSummaryBar` → triggers `handleArchiveStateClick(state)` in `App.js`
|
||
|
|
2. Frontend calls `GET /api/ivanti/archive?state={state}`
|
||
|
|
3. Backend queries `ivanti_finding_archives` for matching state
|
||
|
|
4. Backend reads `ivanti_findings_cache` row (id=1), parses `findings_json` once
|
||
|
|
5. For each archive record, backend runs the matching function against the parsed active findings
|
||
|
|
6. Backend returns `{ archives: [...], total: N }` where each archive object now includes a `related_active` field
|
||
|
|
7. Frontend renders each archive card with the new fields: finding ID, "Last seen:" severity, optional badge, icon/border
|
||
|
|
|
||
|
|
## Components and Interfaces
|
||
|
|
|
||
|
|
### Backend: Modified Archive Route (`backend/routes/ivantiArchive.js`)
|
||
|
|
|
||
|
|
**Changes to `GET /` handler:**
|
||
|
|
|
||
|
|
```javascript
|
||
|
|
// New matching function added to the module
|
||
|
|
function findRelatedActive(archive, activeFindings) {
|
||
|
|
// Returns { id, title, severity } or null
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
**`findRelatedActive(archive, activeFindings)` logic:**
|
||
|
|
- Input: one archive record, array of parsed active findings
|
||
|
|
- Filter active findings where:
|
||
|
|
- `hostName` exactly matches `archive.host_name` (case-sensitive, matching existing DB convention)
|
||
|
|
- AND the archive's `finding_title` is a case-insensitive substring of the active finding's `title`, OR vice versa
|
||
|
|
- AND the active finding's `id` is NOT equal to `archive.finding_id`
|
||
|
|
- If multiple matches, return the one with the highest `severity`
|
||
|
|
- If no matches, return `null`
|
||
|
|
|
||
|
|
**Modified response shape:**
|
||
|
|
```javascript
|
||
|
|
// Before
|
||
|
|
{ id, finding_id, finding_title, host_name, ip_address, current_state, last_severity, ... }
|
||
|
|
|
||
|
|
// After — same fields plus:
|
||
|
|
{ ...existing, related_active: null | { id: string, title: string, severity: number } }
|
||
|
|
```
|
||
|
|
|
||
|
|
### Frontend: Modified Archive Card Rendering (`frontend/src/App.js`)
|
||
|
|
|
||
|
|
The `archiveList.map()` block in `App.js` is updated to render:
|
||
|
|
|
||
|
|
1. **Finding title** (existing, unchanged)
|
||
|
|
2. **Finding ID** — new line below title, monospace, muted color (`#64748B`), font size `0.6rem`. Truncated with ellipsis at 20 characters, full value in `title` attribute for tooltip.
|
||
|
|
3. **Severity badge** — changed from raw number to "Last seen: X.X" format. Null/zero shows "Last seen: —".
|
||
|
|
4. **Related active badge** — conditional. When `related_active` is non-null, shows "Similar finding active" with the related finding's ID and severity, styled with accent color (`#0EA5E9`).
|
||
|
|
5. **Icon** — `AlertTriangle` (from lucide-react) when `related_active` is non-null, `CheckCircle` when null.
|
||
|
|
6. **Left border** — `#F59E0B` (amber) when `related_active` is non-null, `#10B981` (green) when null.
|
||
|
|
|
||
|
|
### No New Components
|
||
|
|
|
||
|
|
The archive card is rendered inline in `App.js` (not a separate component), consistent with the existing pattern. The changes modify the existing `archiveList.map()` JSX block. No new React components are introduced.
|
||
|
|
|
||
|
|
### No New API Endpoints
|
||
|
|
|
||
|
|
The related finding detection is added to the existing `GET /api/ivanti/archive` route. The `ArchiveSummaryBar` component and its `/stats` endpoint are unchanged.
|
||
|
|
|
||
|
|
## Data Models
|
||
|
|
|
||
|
|
### Existing Tables (unchanged)
|
||
|
|
|
||
|
|
**`ivanti_finding_archives`**
|
||
|
|
| Column | Type | Description |
|
||
|
|
|--------|------|-------------|
|
||
|
|
| id | INTEGER PK | Auto-increment row ID |
|
||
|
|
| finding_id | TEXT UNIQUE | Ivanti finding identifier |
|
||
|
|
| finding_title | TEXT | Finding title at archive time |
|
||
|
|
| host_name | TEXT | Hostname |
|
||
|
|
| ip_address | TEXT | IP address |
|
||
|
|
| current_state | TEXT | ARCHIVED, RETURNED, or CLOSED |
|
||
|
|
| last_severity | REAL | Severity at last transition |
|
||
|
|
| first_archived_at | DATETIME | First archive timestamp |
|
||
|
|
| last_transition_at | DATETIME | Last state change timestamp |
|
||
|
|
|
||
|
|
**`ivanti_findings_cache`** (row id=1)
|
||
|
|
| Column | Type | Description |
|
||
|
|
|--------|------|-------------|
|
||
|
|
| findings_json | TEXT | JSON array of active findings |
|
||
|
|
| total | INTEGER | Count of cached findings |
|
||
|
|
|
||
|
|
Each entry in `findings_json` has the shape produced by `extractFinding()` in `ivantiFindings.js`:
|
||
|
|
```javascript
|
||
|
|
{ id, title, severity, vrrGroup, hostName, ipAddress, dns, status, slaStatus, dueDate, lastFoundOn, buOwnership, cves, workflow }
|
||
|
|
```
|
||
|
|
|
||
|
|
### No Schema Changes
|
||
|
|
|
||
|
|
This feature requires no database migrations. All data needed for the matching logic already exists in the two tables above.
|
||
|
|
|
||
|
|
## 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: Finding ID display with truncation
|
||
|
|
|
||
|
|
*For any* archive record, the rendered card SHALL display the finding_id. If the finding_id is longer than 20 characters, the displayed text SHALL be truncated to 20 characters followed by an ellipsis. If the finding_id is 20 characters or fewer, it SHALL be displayed in full.
|
||
|
|
|
||
|
|
**Validates: Requirements 1.1, 1.2**
|
||
|
|
|
||
|
|
### Property 2: Historical severity labeling
|
||
|
|
|
||
|
|
*For any* archive record, the rendered severity display SHALL contain the text "Last seen:" followed by the severity value formatted to one decimal place. When the severity is null or zero, the display SHALL show "Last seen: —".
|
||
|
|
|
||
|
|
**Validates: Requirements 2.1, 2.3**
|
||
|
|
|
||
|
|
### Property 3: API response structure — related_active always present
|
||
|
|
|
||
|
|
*For any* request to the archive API and *for any* archive record in the response, the record SHALL contain a `related_active` field that is either `null` or an object with `id` (string), `title` (string), and `severity` (number) properties.
|
||
|
|
|
||
|
|
**Validates: Requirements 3.1, 3.4**
|
||
|
|
|
||
|
|
### Property 4: Matching logic — hostname and title substring
|
||
|
|
|
||
|
|
*For any* archived finding and *for any* active finding, the active finding is a related match if and only if: (a) the active finding's hostname exactly equals the archive's hostname, AND (b) the archive's title is a case-insensitive substring of the active finding's title OR the active finding's title is a case-insensitive substring of the archive's title, AND (c) the active finding's ID is not equal to the archive's finding_id.
|
||
|
|
|
||
|
|
**Validates: Requirements 3.2, 3.5**
|
||
|
|
|
||
|
|
### Property 5: Highest severity selection
|
||
|
|
|
||
|
|
*For any* archived finding with multiple matching active findings, the `related_active` field SHALL contain the match with the highest severity value.
|
||
|
|
|
||
|
|
**Validates: Requirements 3.3**
|
||
|
|
|
||
|
|
### Property 6: Badge visibility matches related_active presence
|
||
|
|
|
||
|
|
*For any* archive record, the "Similar finding active" badge SHALL be displayed if and only if the `related_active` field is non-null. When displayed, the badge SHALL include the related finding's ID and severity.
|
||
|
|
|
||
|
|
**Validates: Requirements 4.1, 4.3**
|
||
|
|
|
||
|
|
### Property 7: Icon and border determined by related_active, not lifecycle state
|
||
|
|
|
||
|
|
*For any* archive record, regardless of its lifecycle state (ARCHIVED, RETURNED, or CLOSED), the icon and left border color SHALL be determined solely by whether `related_active` is non-null (alert icon + amber border) or null (check icon + green border).
|
||
|
|
|
||
|
|
**Validates: Requirements 5.1, 5.2, 5.3**
|
||
|
|
|
||
|
|
## Error Handling
|
||
|
|
|
||
|
|
### Backend
|
||
|
|
|
||
|
|
| Scenario | Handling |
|
||
|
|
|----------|----------|
|
||
|
|
| `findings_json` is malformed or unparseable | Catch JSON.parse error, log warning, treat as empty array (all `related_active` fields become `null`) |
|
||
|
|
| `findings_json` column is NULL | Default to empty array |
|
||
|
|
| `ivanti_findings_cache` row missing (id=1) | Default to empty array — no related matches |
|
||
|
|
| Database query failure on archive records | Return 500 with `{ error: 'Failed to fetch archive records' }` (existing behavior) |
|
||
|
|
| Database query failure on findings cache | Log error, continue with empty active findings (graceful degradation) |
|
||
|
|
|
||
|
|
### Frontend
|
||
|
|
|
||
|
|
| Scenario | Handling |
|
||
|
|
|----------|----------|
|
||
|
|
| `related_active` field missing from response | Treat as `null` (no badge, green/check styling) |
|
||
|
|
| `finding_id` is empty string | Display finding title only (existing fallback behavior) |
|
||
|
|
| `last_severity` is undefined | Display "Last seen: —" |
|
||
|
|
| API returns error | Existing error handling in `handleArchiveStateClick` already catches and shows empty state |
|
||
|
|
|
||
|
|
## Testing Strategy
|
||
|
|
|
||
|
|
Testing is performed manually on the dev server. No automated tests are required for this feature.
|