Files
cve-dashboard/.kiro/specs/finding-archive-tracking/design.md
jramos 9bd5a52661 feat: implement finding archive tracking system
- Add migration script for ivanti_finding_archives and ivanti_archive_transitions tables
- Add archive detection logic (detectArchiveChanges, detectClosedFindings) in sync pipeline
- Add archive API router with list, stats, and history endpoints at /api/ivanti/archive
- Add ArchiveSummaryBar UI component with four state cards (ACTIVE, ARCHIVED, RETURNED, CLOSED)
- Integrate ArchiveSummaryBar into Ivanti findings page in App.js
- Register archive router in server.js
2026-04-03 15:20:04 -06:00

16 KiB
Raw Blame History

Design Document: Finding Archive Tracking

Overview

The Finding Archive Tracking system adds a detection layer to the existing Ivanti sync pipeline that identifies findings which disappear from sync results due to severity score drift. It tracks these findings through a four-state lifecycle (ACTIVE → ARCHIVED → RETURNED → CLOSED) with full transition history stored in two new SQLite tables. Three new API endpoints expose archive data, and an Archive Summary Bar UI component provides at-a-glance state counts on the Ivanti dashboard.

The system integrates directly into the existing syncFindings() function in ivantiFindings.js, comparing current sync results against the previous set to detect disappearances and reappearances. This approach requires no additional API calls to Ivanti and leverages the already-cached findings data.

Architecture

flowchart TD
    subgraph Ivanti Sync Pipeline
        A[syncFindings] --> B[Fetch all pages from Ivanti API]
        B --> C[Store findings in ivanti_findings_cache]
        C --> D[Archive Detection]
    end

    subgraph Archive Detection
        D --> E{Compare previous vs current finding IDs}
        E -->|Missing from current| F[Create/Update Archive Record → ARCHIVED]
        E -->|Returned in current| G[Update Archive Record → RETURNED]
        E -->|Closed in Ivanti| H[Update Archive Record → CLOSED]
        F --> I[Insert Transition History]
        G --> I
        H --> I
    end

    subgraph Archive API
        J[GET /api/ivanti/archive] --> K[(ivanti_finding_archives)]
        L[GET /api/ivanti/archive/:findingId/history] --> M[(ivanti_archive_transitions)]
        N[GET /api/ivanti/archive/stats] --> K
    end

    subgraph Frontend
        O[Archive Summary Bar] -->|fetch stats| N
        O -->|click state| J
        P[Transition History Panel] -->|fetch history| L
    end

Integration Points

  1. Sync Pipeline Hook: Archive detection runs after syncFindings() successfully stores new findings in the cache. It reads the previous findings from the cache before the update, then compares against the new set.
  2. Route Registration: The archive router is mounted at /api/ivanti/archive in server.js, following the same factory pattern as existing Ivanti routes.
  3. Frontend Integration: The Archive Summary Bar is rendered on the existing Ivanti findings page, above the findings table.

Components and Interfaces

1. Archive Detection Module (detectArchiveChanges)

Located within backend/routes/ivantiFindings.js, this async function runs after a successful sync.

/**
 * Compare previous and current finding sets to detect archive state changes.
 * @param {sqlite3.Database} db - SQLite database instance
 * @param {Array} previousFindings - Findings from before the sync update
 * @param {Array} currentFindings - Findings from the latest sync
 */
async function detectArchiveChanges(db, previousFindings, currentFindings) {
    // 1. Build ID sets from previous and current
    // 2. Disappeared = in previous but not in current → ARCHIVED
    // 3. Returned = in current AND has existing ARCHIVED record → RETURNED
    // 4. For each state change, upsert archive record + insert transition
}

2. Closed Finding Detection (detectClosedFindings)

Runs during the closed count sync to detect findings that transitioned to CLOSED in Ivanti.

/**
 * Check archived findings against Ivanti closed findings to detect remediation.
 * @param {sqlite3.Database} db - SQLite database instance
 * @param {Array} closedFindingIds - IDs of findings confirmed closed in Ivanti
 */
async function detectClosedFindings(db, closedFindingIds) {
    // For each archived/returned finding, if it appears in closed set → CLOSED
}

3. Archive API Router (createIvantiArchiveRouter)

Located at backend/routes/ivantiArchive.js, follows the existing factory pattern.

/**
 * @param {sqlite3.Database} db - SQLite database instance
 * @param {Function} requireAuth - Auth middleware factory
 * @returns {express.Router}
 */
function createIvantiArchiveRouter(db, requireAuth) {
    const router = express.Router();
    router.use(requireAuth(db));

    // GET / - List archive records, optional ?state= filter
    // GET /stats - Summary counts by state
    // GET /:findingId/history - Transition history for a finding

    return router;
}

4. Archive Summary Bar Component (ArchiveSummaryBar)

Located at frontend/src/components/pages/ArchiveSummaryBar.js.

/**
 * Displays four stat cards for ACTIVE, ARCHIVED, RETURNED, CLOSED counts.
 * @param {Object} props
 * @param {Function} props.onStateClick - Callback when a state card is clicked
 * @param {string|null} props.activeFilter - Currently selected state filter
 */
function ArchiveSummaryBar({ onStateClick, activeFilter }) { ... }

API Endpoint Specifications

Endpoint Method Auth Query Params Response
/api/ivanti/archive GET Required state (optional: ACTIVE, ARCHIVED, RETURNED, CLOSED) { archives: [...], total: N }
/api/ivanti/archive/stats GET Required None { ARCHIVED: N, RETURNED: N, CLOSED: N, total: N }
/api/ivanti/archive/:findingId/history GET Required None { finding_id: "...", transitions: [...] }

Data Models

ivanti_finding_archives Table

Column Type Constraints Description
id INTEGER PRIMARY KEY AUTOINCREMENT Row ID
finding_id TEXT NOT NULL UNIQUE Ivanti finding identifier
finding_title TEXT NOT NULL DEFAULT '' Finding title at time of archival
host_name TEXT NOT NULL DEFAULT '' Host name at time of archival
ip_address TEXT NOT NULL DEFAULT '' IP address at time of archival
current_state TEXT NOT NULL CHECK(IN ('ARCHIVED','RETURNED','CLOSED')) Current lifecycle state
last_severity REAL NOT NULL DEFAULT 0 Last known severity score
first_archived_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP When first archived
last_transition_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP When last state change occurred
created_at DATETIME DEFAULT CURRENT_TIMESTAMP Row creation time

Indexes:

  • idx_archive_finding_id on finding_id
  • idx_archive_current_state on current_state

ivanti_archive_transitions Table

Column Type Constraints Description
id INTEGER PRIMARY KEY AUTOINCREMENT Row ID
archive_id INTEGER NOT NULL, FK → ivanti_finding_archives(id) Parent archive record
from_state TEXT NOT NULL Previous state (or 'NONE' for initial)
to_state TEXT NOT NULL New state
severity_at_transition REAL NOT NULL DEFAULT 0 Severity score at time of transition
reason TEXT NOT NULL DEFAULT '' Human-readable reason
transitioned_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP When transition occurred

Indexes:

  • idx_transition_archive_id on archive_id

State Transition Diagram

Archive records are only created when a finding first disappears from sync results. Findings that remain present in sync results do not get archive records — they are simply "active" in the findings cache. The three database states are ARCHIVED, RETURNED, and CLOSED.

stateDiagram-v2
    [*] --> ARCHIVED : Finding disappears from sync (score drift)
    ARCHIVED --> RETURNED : Reappeared in sync
    ARCHIVED --> CLOSED : Confirmed remediated in Ivanti
    RETURNED --> ARCHIVED : Disappeared again
    RETURNED --> CLOSED : Confirmed remediated in Ivanti

Valid State Transitions

From State To State Reason
NONE → ARCHIVED severity_score_drift (first disappearance)
ARCHIVED → RETURNED reappeared_in_sync
ARCHIVED → CLOSED remediated_in_ivanti
RETURNED → ARCHIVED severity_score_drift
RETURNED → CLOSED remediated_in_ivanti

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: Disappeared findings are archived with complete metadata

For any set of previous findings and current findings, every finding present in the previous set but absent from the current set should have an Archive_Record with state ARCHIVED, and that record should contain the correct finding_id, finding_title, host_name, ip_address, and last_severity matching the original finding's data.

Validates: Requirements 1.1, 1.2, 2.2

Property 2: Returned findings transition from ARCHIVED to RETURNED

For any finding that has an Archive_Record with state ARCHIVED, if that finding reappears in the current sync results, the Archive_Record state should be updated to RETURNED and the last_severity should reflect the finding's current severity score.

Validates: Requirements 1.3

Property 3: Re-disappeared findings transition from RETURNED to ARCHIVED

For any finding that has an Archive_Record with state RETURNED, if that finding disappears from the current sync results, the Archive_Record state should be updated back to ARCHIVED.

Validates: Requirements 1.4

Property 4: Every state transition produces a history record with all required fields

For any state transition on an Archive_Record, a Transition_History row should be inserted containing a valid archive_id, the correct from_state and to_state, a severity_at_transition value, a non-empty reason string, and a transitioned_at timestamp.

Validates: Requirements 2.1

Property 5: Closed findings transition to CLOSED state

For any finding that has an Archive_Record with state ARCHIVED or RETURNED, if that finding appears in the Ivanti closed findings set, the Archive_Record state should be updated to CLOSED and the transition reason should be "remediated_in_ivanti".

Validates: Requirements 2.3

Property 6: State filter returns only matching records

For any set of Archive_Records with various states, querying the archive list endpoint with a state filter should return only records whose current_state matches the filter, and the count should equal the number of records in that state.

Validates: Requirements 4.1

Property 7: Transition history is ordered by timestamp descending

For any finding with multiple Transition_History entries, the history endpoint should return entries ordered by transitioned_at descending, such that each entry's timestamp is greater than or equal to the next entry's timestamp.

Validates: Requirements 4.2

Property 8: Stats counts match actual record distribution

For any set of Archive_Records, the stats endpoint should return counts where the sum of all state counts equals the total number of Archive_Records, and each individual state count matches the actual number of records in that state.

Validates: Requirements 4.3

Property 9: Migration idempotency

For any number of consecutive executions of the migration script, the resulting database schema should be identical and no errors should occur on subsequent runs.

Validates: Requirements 6.2

Error Handling

Scenario Handling
Sync fails (API error, timeout) Archive detection is skipped entirely for that cycle. No archive records are created or modified. The sync error is logged as usual.
Database error during archive upsert Log the error, continue processing remaining findings. Do not abort the entire archive detection pass.
Database error during transition insert Log the error. The archive record state may have been updated but the transition history may be incomplete. This is acceptable as the current state is the source of truth.
Invalid state transition attempted The detection logic only performs valid transitions per the state diagram. Invalid transitions (e.g., CLOSED → ARCHIVED) are not possible by design since closed findings are excluded from the sync pipeline.
Missing finding metadata Use empty string defaults for finding_title, host_name, ip_address if the finding object lacks these fields. Severity defaults to 0.
Archive API query with invalid state parameter Return a 400 status code with message "Invalid state parameter. Valid values: ACTIVE, ARCHIVED, RETURNED, CLOSED". Explicit errors surface frontend bugs faster than silent fallbacks.
History query for non-existent finding Return 200 with empty transitions array (not 404), per requirement 4.5.

Testing Strategy

Unit Tests

Unit tests cover specific examples and edge cases:

  • Migration script creates both tables and all indexes (example, Req 3.13.4)
  • Archive detection skips when sync errors occur (example, Req 1.5)
  • Unauthenticated requests return 401 (example, Req 4.4)
  • History endpoint returns empty array for unknown finding (edge case, Req 4.5)
  • Archive Summary Bar renders four stat cards (example, Req 5.1)
  • Archive Summary Bar fetches stats on mount (example, Req 5.2)
  • Clicking a state card triggers filter callback (example, Req 5.3)

Property-Based Tests

Property-based tests use a PBT library (e.g., fast-check) to verify universal properties across generated inputs. Each test runs a minimum of 100 iterations.

Property Test Description Tag
Property 1 Generate random previous/current finding sets, run detection, verify all disappeared findings have correct ARCHIVED records Feature: finding-archive-tracking, Property 1: Disappeared findings are archived with complete metadata
Property 2 Generate archived findings, add some back to current set, verify RETURNED state Feature: finding-archive-tracking, Property 2: Returned findings transition from ARCHIVED to RETURNED
Property 3 Generate returned findings, remove some from current set, verify ARCHIVED state Feature: finding-archive-tracking, Property 3: Re-disappeared findings transition from RETURNED to ARCHIVED
Property 4 Generate random state transitions, verify each produces a complete history row Feature: finding-archive-tracking, Property 4: Every state transition produces a history record
Property 5 Generate archived/returned findings, mark some as closed, verify CLOSED state and reason Feature: finding-archive-tracking, Property 5: Closed findings transition to CLOSED state
Property 6 Generate archive records with random states, query with filter, verify only matching records returned Feature: finding-archive-tracking, Property 6: State filter returns only matching records
Property 7 Generate multiple transitions for a finding, query history, verify descending order Feature: finding-archive-tracking, Property 7: Transition history is ordered by timestamp descending
Property 8 Generate archive records with random states, query stats, verify counts match Feature: finding-archive-tracking, Property 8: Stats counts match actual record distribution
Property 9 Run migration N times, verify no errors and schema is consistent Feature: finding-archive-tracking, Property 9: Migration idempotency

Testing Tools

  • Test runner: Jest (via react-scripts for frontend, direct for backend)
  • Property-based testing: fast-check library
  • Database: In-memory SQLite (:memory:) for isolated test runs
  • HTTP testing: supertest for API endpoint tests