2 Commits

16 changed files with 1080 additions and 101 deletions

Binary file not shown.

7
.gitignore vendored
View File

@@ -52,5 +52,12 @@ backend/fix_multivendor_constraint.js
backend/server.js-backup
backend/setup.js-backup
# Compliance staging — keep folder, ignore contents
.compliance-staging/*
!.compliance-staging/.gitkeep
# Kiro agents (local only)
.kiro/agents/
# Kiro implementation summary (internal only)
docs/kiro-implementation-summary.md

View File

@@ -0,0 +1,13 @@
{
"enabled": true,
"name": "Doc Review",
"description": "Manually triggered after merging to master. Reads the recent git diff, classifies the changes, and proposes documentation updates following the doc-updater decision tree and doc-standards.md conventions.",
"version": "1",
"when": {
"type": "userTriggered"
},
"then": {
"type": "askAgent",
"prompt": "Run a documentation review against the latest changes on master. Follow these steps exactly:\n\n1. Run `git log --oneline -10` to see recent commits. If any commit message contains `[skip-docs]`, stop and report NO_DOC_UPDATE_NEEDED.\n\n2. Run `git diff HEAD~1 --stat` to get the list of changed files, then `git diff HEAD~1` to get the full diff. If the diff is larger than 500 lines, report NEEDS_HUMAN_REVIEW with a summary of which areas likely need docs.\n\n3. Read `.kiro/agents/doc-updater.md` for the full decision tree and `.kiro/steering/doc-standards.md` for formatting conventions.\n\n4. Follow the doc-updater decision tree: triage the change, decide if docs need updating, survey existing docs (README.md, docs/ folder), and propose surgical edits.\n\n5. For any proposed changes, apply them directly to the doc files. Only touch README.md and files under docs/. Never touch code files.\n\n6. After applying changes, output the SUMMARY block from the decision tree so Jordan can review what was changed and why."
}
}

View File

@@ -0,0 +1,13 @@
{
"enabled": true,
"name": "Ivanti API Debugger",
"description": "Manually triggered when debugging a failing Ivanti API call. Prompts for the endpoint, request payload, and error response, then invokes the ivanti-api-debugger agent to diagnose the issue and update ivanti-api-reference.md with any findings.",
"version": "1",
"when": {
"type": "userTriggered"
},
"then": {
"type": "askAgent",
"prompt": "You are the Ivanti API Debugger agent. Follow the instructions in `.kiro/agents/ivanti-api-debugger.md` exactly.\n\nAsk the user to provide the following (one clarifying question, not five — accept whatever they paste and infer the rest):\n\n1. The failing endpoint or route — either the Ivanti API path (e.g. `/workflowBatch/falsePositive/request`) or the backend route file/handler that makes the call (e.g. `backend/routes/ivantiWorkflows.js`)\n2. The request payload they sent (curl, JSON body, or code snippet)\n3. The response or error they got back (HTTP status, response body, or error message)\n\nOnce you have that context, follow the full diagnostic workflow described in `.kiro/agents/ivanti-api-debugger.md`: read the relevant route/service code, cross-reference `docs/ivanti-api-reference.md`, check for common Ivanti failure modes, form a hypothesis, and propose a concrete next request to try. If the user confirms a finding, update `docs/ivanti-api-reference.md` using its existing structure."
}
}

View File

@@ -0,0 +1,13 @@
{
"enabled": true,
"name": "Security Audit Tracker",
"description": "Manually triggered to scan the codebase for security issues and maintain a living audit tracker document. Prompts for scan scope (full repo or specific path) and mode (report only or report + update tracker). Invokes the security-audit-tracker agent for static analysis and doc tracking.",
"version": "1",
"when": {
"type": "userTriggered"
},
"then": {
"type": "askAgent",
"prompt": "You are the Security Audit Tracker agent. Follow the instructions in `.kiro/agents/security-audit-tracker.md` exactly.\n\nAsk the user to provide the following two inputs:\n\n1. **Scope:** \"full repo\" to scan the entire codebase, or a specific path/module to focus on (e.g. `backend/routes/`, `frontend/src/components/`, `backend/helpers/ivantiApi.js`)\n2. **Mode:** \"scan only\" (report findings to chat, no file writes) or \"scan + update tracker\" (report findings and merge them into the tracker doc at `docs/security-audit-tracker.md`)\n\nOnce you have both inputs, follow the full diagnostic and tracking workflow described in `.kiro/agents/security-audit-tracker.md`: determine scope, check for the tracker doc (create it if missing), scan for the security failure modes listed in the agent spec, cross-reference against previously tracked findings, and output a prioritised report. In \"scan + update tracker\" mode, also merge findings into the tracker doc and update its metadata."
}
}

View File

@@ -0,0 +1 @@
{"specId": "8ec01dea-8d5c-40c1-8778-ec2992adb37f", "workflowType": "requirements-first", "specType": "feature"}

View File

@@ -0,0 +1,290 @@
# Design Document: Multi-Metric Notes for Compliance Detail Panel
## Overview
This feature extends the compliance notes system so that a single note can be associated with multiple metrics in one action. Today, the `ComplianceDetailPanel` uses a single-select `<select>` dropdown to pick one metric before adding a note. When a remediation action covers several metrics on the same device, the analyst must repeat the note for each metric individually.
The change touches three layers:
1. **Database** — add a `group_id` column to `compliance_notes` so notes created together can be identified as a batch.
2. **API** — extend `POST /api/compliance/notes` to accept `metric_ids` (array) alongside the existing `metric_id` (string), inserting one row per metric inside a transaction.
3. **Frontend** — replace the single-select dropdown with a multi-select chip-based selector, add Select All / Deselect All, and group notes by `group_id` in the display.
Backward compatibility is preserved: the existing `metric_id` field continues to work, and notes created before this feature (which lack a `group_id`) render exactly as they do today.
## Architecture
The feature follows the existing compliance module architecture. No new files or route modules are introduced — changes are scoped to the existing `compliance.js` route file and `ComplianceDetailPanel.js` component.
```mermaid
sequenceDiagram
participant User
participant DetailPanel as ComplianceDetailPanel
participant API as POST /api/compliance/notes
participant DB as SQLite (compliance_notes)
User->>DetailPanel: Select multiple metrics via chip selector
User->>DetailPanel: Type note text, click Send
DetailPanel->>API: POST { hostname, metric_ids: [...], note }
API->>API: Validate inputs (note text, metric IDs)
API->>API: Generate group_id (UUID)
API->>DB: BEGIN TRANSACTION
loop For each metric_id in metric_ids
API->>DB: INSERT INTO compliance_notes (hostname, metric_id, note, group_id, created_by, created_at)
end
API->>DB: COMMIT
API->>DetailPanel: 201 { notes: [...created rows] }
DetailPanel->>DetailPanel: Group notes by group_id, refresh display
```
## Components and Interfaces
### Backend
**Modified: `POST /api/compliance/notes`**
Request body accepts either format:
```javascript
// New multi-metric format
{ hostname: "SERVER01", metric_ids: ["2.1.1", "2.3.2", "4.1.1"], note: "Vendor ticket VT-1234 opened" }
// Legacy single-metric format (still supported)
{ hostname: "SERVER01", metric_id: "2.1.1", note: "Vendor ticket VT-1234 opened" }
```
Precedence: if both `metric_id` and `metric_ids` are present, `metric_ids` wins.
Validation rules:
- `hostname` — required, string, 1300 chars, matches `/^[a-zA-Z0-9._-]+$/` (unchanged)
- `metric_ids` — array of strings, each non-empty and ≤50 chars, at least one entry
- `note` — required, non-empty after trimming, max 1000 chars (unchanged)
On success, the endpoint returns all created rows (with `username` joined from `users`) so the frontend can update without a separate fetch.
**New: Migration script `backend/migrations/add_compliance_notes_group_id.js`**
Adds the `group_id` column and backfills existing rows:
```sql
ALTER TABLE compliance_notes ADD COLUMN group_id TEXT;
CREATE INDEX idx_compliance_notes_group ON compliance_notes(group_id);
-- Backfill: each existing row gets its own unique group_id
UPDATE compliance_notes SET group_id = 'legacy-' || id WHERE group_id IS NULL;
```
The backfill ensures every row has a `group_id`, so the frontend grouping logic works uniformly without null checks.
### Frontend
**Modified: `ComplianceDetailPanel.js`**
The notes section is updated with three changes:
1. **Multi-select metric selector** — replaces the `<select>` dropdown with a chip-based toggle list. Each active metric is rendered as a clickable `MetricChip`. Selected chips get a highlighted border/background. A "Select All" / "Deselect All" toggle appears when there are 2+ active metrics.
2. **Submission logic**`handleAddNote` sends `metric_ids` (array of selected metric IDs) instead of `metric_id` (single string). The submit button is disabled when no metrics are selected or note text is empty.
3. **Note display grouping** — notes are grouped by `group_id` before rendering. Notes sharing a `group_id` are displayed as a single card with multiple `MetricChip` badges. Notes without a `group_id` (pre-migration legacy) render as individual entries, same as today.
**Component structure:**
```
ComplianceDetailPanel
├── Header (hostname, IP, device type, team)
├── Section: Failing Metrics
│ └── MetricRow (per active metric)
├── Section: Resolved Metrics
│ └── MetricRow (per resolved metric)
├── Section: History
│ └── MetricChip + seen count (per active metric)
└── Section: Notes
├── NoteCard (per group_id group, shows multiple MetricChips if multi-metric)
└── Add Note Form
├── MetricChipSelector (multi-select chip toggles)
│ ├── MetricChip (per active metric, clickable)
│ └── Select All / Deselect All toggle
├── Textarea (note text)
└── Send button (disabled when no metrics selected or text empty)
```
**MetricChipSelector behavior:**
| State | Behavior |
|---|---|
| 1 active metric | Chip is pre-selected and non-removable. No Select All toggle. |
| 2+ active metrics, panel just opened | First metric pre-selected. Select All toggle visible. |
| User clicks unselected chip | Chip added to selection |
| User clicks selected chip (2+ selected) | Chip removed from selection |
| User clicks selected chip (only 1 selected, 2+ metrics exist) | No-op — at least one must remain selected |
| Select All clicked | All active metrics selected, toggle label changes to "Deselect All" |
| Deselect All clicked | All metrics deselected except the first (to maintain minimum selection) |
**Design rationale — minimum selection of 1:** The submit button is disabled when no metrics are selected (Requirement 3.4). Rather than allowing the user to reach an empty state and see a disabled button, "Deselect All" keeps the first metric selected. This matches the current UX where a metric is always selected.
## Data Models
### compliance_notes table (modified)
| Column | Type | Description |
|---|---|---|
| `id` | INTEGER PK | Auto-increment row ID |
| `hostname` | TEXT NOT NULL | Device hostname |
| `metric_id` | TEXT NOT NULL | Compliance metric ID |
| `note` | TEXT NOT NULL | Note text (max 1000 chars) |
| `group_id` | TEXT | Batch identifier — rows from the same submission share this value |
| `created_by` | INTEGER FK | User ID of the note author |
| `created_at` | DATETIME | Timestamp of creation |
The `group_id` is a UUID v4 string generated server-side via `crypto.randomUUID()`. Single-metric submissions also receive a `group_id` so the frontend grouping logic is uniform.
**Index:** `idx_compliance_notes_group ON compliance_notes(group_id)` — supports the frontend's grouping query.
### API Response Shape
`POST /api/compliance/notes` response (201):
```json
{
"notes": [
{
"id": 42,
"hostname": "SERVER01",
"metric_id": "2.1.1",
"note": "Vendor ticket VT-1234 opened",
"group_id": "a1b2c3d4-...",
"created_at": "2025-01-15 14:30:00",
"created_by": "jsmith"
},
{
"id": 43,
"hostname": "SERVER01",
"metric_id": "2.3.2",
"note": "Vendor ticket VT-1234 opened",
"group_id": "a1b2c3d4-...",
"created_at": "2025-01-15 14:30:00",
"created_by": "jsmith"
}
]
}
```
`GET /api/compliance/items/:hostname` response — the existing `notes` array now includes `group_id`:
```json
{
"notes": [
{ "id": 43, "metric_id": "2.3.2", "note": "...", "group_id": "a1b2c3d4-...", "created_at": "...", "created_by": "jsmith" },
{ "id": 42, "metric_id": "2.1.1", "note": "...", "group_id": "a1b2c3d4-...", "created_at": "...", "created_by": "jsmith" },
{ "id": 10, "metric_id": "2.1.1", "note": "...", "group_id": "legacy-10", "created_at": "...", "created_by": "admin" }
]
}
```
The frontend groups consecutive notes by `group_id` to render multi-metric notes as a single card.
## 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: Select All / Deselect All round-trip
*For any* set of active metrics with size > 1, clicking "Select All" should result in all metrics being selected, and then clicking "Deselect All" should result in only the first metric remaining selected (minimum selection invariant).
**Validates: Requirements 2.1, 2.2**
### Property 2: Toggle label reflects selection state
*For any* set of active metrics, if the user manually selects every metric one by one, the toggle label should read "Deselect All" — the label is a pure function of whether all metrics are selected, regardless of how that state was reached.
**Validates: Requirements 2.3**
### Property 3: Multi-metric submission creates correct rows with shared group_id
*For any* valid hostname, non-empty note text, and non-empty array of valid metric IDs, submitting a note should create exactly N rows in `compliance_notes` (where N = length of the metric IDs array), all sharing the same `note` text, `created_by` user, `created_at` timestamp, and `group_id` value.
**Validates: Requirements 3.1, 3.2, 5.3, 5.7, 6.1**
### Property 4: Whitespace-only notes are rejected
*For any* string composed entirely of whitespace characters (spaces, tabs, newlines, or combinations thereof), the Notes API should reject the submission with a 400 error and create zero rows in the database.
**Validates: Requirements 3.3**
### Property 5: Atomic validation — invalid metric IDs reject the entire batch
*For any* array of metric IDs where at least one entry is invalid (empty string, exceeds 50 characters, or non-string), the Notes API should reject the entire request with a 400 error and insert zero rows, even if all other entries are valid.
**Validates: Requirements 5.2, 5.6**
### Property 6: Note grouping display
*For any* set of notes where multiple notes share the same `group_id`, the Detail Panel should render them as a single note entry displaying all associated Metric Chips, rather than as separate entries.
**Validates: Requirements 4.1, 4.2, 6.4**
### Property 7: Reverse chronological note ordering
*For any* set of notes with varying `created_at` timestamps and group sizes, the Detail Panel should display note groups in reverse chronological order (newest `created_at` first), regardless of how many metrics each group covers.
**Validates: Requirements 4.3**
## Error Handling
### Backend
| Scenario | Response | Behavior |
|---|---|---|
| Empty or whitespace-only note text | 400 `{ error: "Note cannot be empty" }` | No rows inserted |
| `metric_ids` is empty array | 400 `{ error: "At least one metric ID is required" }` | No rows inserted |
| Any metric ID in array is empty or >50 chars | 400 `{ error: "Invalid metric_id at index N" }` | No rows inserted (atomic rejection) |
| `metric_ids` is not an array (when provided) | 400 `{ error: "metric_ids must be an array" }` | Falls back to checking `metric_id` |
| Neither `metric_id` nor `metric_ids` provided | 400 `{ error: "metric_id or metric_ids is required" }` | No rows inserted |
| Database error during transaction | 500 `{ error: "Failed to save note" }` | Transaction rolled back, no partial inserts |
| Invalid hostname format | 400 `{ error: "Invalid hostname format" }` | No rows inserted (unchanged) |
Transaction safety: all inserts for a multi-metric note happen inside `BEGIN TRANSACTION` / `COMMIT`. If any insert fails, the transaction is rolled back and no rows are persisted.
### Frontend
| Scenario | Behavior |
|---|---|
| API returns 400 validation error | Display error message below the note input (existing `noteError` state) |
| API returns 500 server error | Display error message below the note input |
| Network failure | Display "Failed to save note" error |
| No metrics selected | Submit button is disabled, no API call made |
| Successful submission | Clear note text, refresh notes list, retain metric selection |
## Testing Strategy
### Unit Tests (example-based)
- **Backend:**
- Legacy `metric_id` field still creates a single note row (backward compatibility)
- Both `metric_id` and `metric_ids` provided — `metric_ids` takes precedence
- Single active metric pre-selects and is non-removable
- Response shape includes all created rows with `group_id` and `username`
- **Frontend:**
- MetricChipSelector renders correct number of chips for given active metrics
- Clicking a chip toggles its selection state
- Submit button disabled when note text is empty or no metrics selected
- Notes without `group_id` (legacy) render as individual entries
- Single active metric auto-selects and hides Select All toggle
### Property-Based Tests
Property-based tests use `fast-check` (JavaScript PBT library) with a minimum of 100 iterations per property.
Each property test is tagged with a comment referencing the design property:
- **Feature: compliance-multi-metric-notes, Property 3: Multi-metric submission creates correct rows with shared group_id**
- **Feature: compliance-multi-metric-notes, Property 4: Whitespace-only notes are rejected**
- **Feature: compliance-multi-metric-notes, Property 5: Atomic validation — invalid metric IDs reject the entire batch**
Backend properties (3, 4, 5) are tested against the route handler using a test SQLite database. Frontend properties (1, 2, 6, 7) are tested against the component rendering/grouping logic using React Testing Library with generated inputs.
### Integration Tests
- End-to-end flow: open detail panel → select multiple metrics → submit note → verify grouped display
- Migration script: verify `group_id` column exists and legacy rows are backfilled
- Backward compatibility: existing `GET /items/:hostname` response includes `group_id` field on notes

View File

@@ -0,0 +1,85 @@
# Requirements Document
## Introduction
The compliance detail panel currently allows users to add notes to a single metric at a time via a dropdown selector. When a remediation action, vendor ticket, or status update applies to multiple metrics on the same device, users must repeat the same note for each metric individually. This feature adds multi-metric selection to the note creation flow so that a single note can be associated with multiple metrics in one action, while preserving the existing per-metric note history and display.
## Glossary
- **Detail_Panel**: The slide-out panel (`ComplianceDetailPanel.js`) that opens when a user clicks a device row on the Compliance page. It displays failing metrics, resolved metrics, upload history, and notes for a single hostname.
- **Note**: A timestamped, user-attributed text entry stored in the `compliance_notes` table, keyed on `(hostname, metric_id)`. Notes persist across uploads and form a historical record.
- **Metric_Selector**: The UI control in the Detail_Panel's notes section that allows the user to choose which metric(s) a note applies to. Currently a single-select dropdown; this feature replaces it with a multi-select control.
- **Metric_Chip**: A small colored badge displaying a metric ID, used throughout the compliance UI to visually identify metrics by category color.
- **Notes_API**: The `POST /api/compliance/notes` endpoint that persists a note to the database.
- **Active_Metric**: A compliance item with `status = 'active'` for the selected hostname — these are the metrics currently failing.
## Requirements
### Requirement 1: Multi-Metric Selection UI
**User Story:** As a compliance analyst, I want to select multiple metrics when adding a note, so that I can document a single remediation action that covers several metrics without repeating myself.
#### Acceptance Criteria
1. WHEN the Detail_Panel is open for a hostname with more than one Active_Metric, THE Metric_Selector SHALL display all Active_Metrics as individually selectable options.
2. WHEN the user interacts with the Metric_Selector, THE Metric_Selector SHALL allow the user to select one or more Active_Metrics simultaneously.
3. WHEN the Detail_Panel is open for a hostname with exactly one Active_Metric, THE Metric_Selector SHALL pre-select that metric and remain visible as a single non-removable selection.
4. WHEN the Detail_Panel first opens for a hostname with multiple Active_Metrics, THE Metric_Selector SHALL pre-select the first Active_Metric by default.
5. THE Metric_Selector SHALL display each option using the Metric_Chip component with the metric's category color, so that metrics are visually distinguishable.
### Requirement 2: Select All / Deselect All
**User Story:** As a compliance analyst, I want a quick way to select or deselect all metrics, so that I can efficiently apply a note to every failing metric on a device.
#### Acceptance Criteria
1. WHEN the hostname has more than one Active_Metric, THE Metric_Selector SHALL display a "Select All" toggle that selects all Active_Metrics when activated.
2. WHEN all Active_Metrics are already selected, THE "Select All" toggle SHALL change to "Deselect All" and deselect all Active_Metrics when activated.
3. WHEN the user manually selects all Active_Metrics one by one, THE toggle label SHALL update to "Deselect All" to reflect the current state.
### Requirement 3: Multi-Metric Note Submission
**User Story:** As a compliance analyst, I want the system to save my note against all selected metrics in one action, so that the historical record accurately reflects which metrics the note covers.
#### Acceptance Criteria
1. WHEN the user submits a note with multiple metrics selected, THE Notes_API SHALL create one `compliance_notes` row per selected metric, all sharing the same note text, `created_by`, and `created_at` timestamp.
2. WHEN the user submits a note with a single metric selected, THE Notes_API SHALL create exactly one `compliance_notes` row, preserving backward compatibility with the existing behavior.
3. IF the note text is empty or contains only whitespace, THEN THE Notes_API SHALL reject the submission and return a validation error.
4. IF no metrics are selected, THEN THE Detail_Panel SHALL disable the submit button and prevent submission.
5. WHEN a multi-metric note is successfully saved, THE Detail_Panel SHALL clear the note text field, refresh the notes list, and retain the current metric selection.
### Requirement 4: Multi-Metric Note Display
**User Story:** As a compliance analyst, I want to see which metrics a note was applied to, so that I can understand the scope of past remediation actions.
#### Acceptance Criteria
1. WHEN a note was submitted for multiple metrics simultaneously, THE Detail_Panel SHALL display all associated Metric_Chips together on that note entry, visually grouped.
2. WHEN a note was submitted for a single metric, THE Detail_Panel SHALL continue to display a single Metric_Chip on that note entry, matching the current behavior.
3. THE Detail_Panel SHALL display notes in reverse chronological order, with the newest note first, regardless of how many metrics each note covers.
### Requirement 5: Backend Multi-Metric Notes Endpoint
**User Story:** As a developer, I want the notes API to accept an array of metric IDs, so that the frontend can submit a note for multiple metrics in a single request.
#### Acceptance Criteria
1. THE Notes_API SHALL accept a `metric_ids` field (array of strings) in the request body as an alternative to the existing `metric_id` field (single string).
2. WHEN `metric_ids` is provided, THE Notes_API SHALL validate that each entry is a non-empty string of 50 characters or fewer.
3. WHEN `metric_ids` is provided, THE Notes_API SHALL insert one `compliance_notes` row per metric ID, all within the same database transaction, sharing the same `created_at` timestamp.
4. WHEN the legacy `metric_id` field is provided instead of `metric_ids`, THE Notes_API SHALL continue to function as before, inserting a single row.
5. IF both `metric_id` and `metric_ids` are provided, THEN THE Notes_API SHALL use `metric_ids` and ignore `metric_id`.
6. IF any metric ID in the `metric_ids` array fails validation, THEN THE Notes_API SHALL reject the entire request and return a 400 error without inserting any rows.
7. THE Notes_API SHALL return all created note rows in the response, so the frontend can update the display without a separate fetch.
### Requirement 6: Note Grouping Identifier
**User Story:** As a developer, I want notes that were created together to share a group identifier, so that the frontend can visually group multi-metric notes in the display.
#### Acceptance Criteria
1. WHEN multiple notes are created from a single submission, THE Notes_API SHALL assign the same `group_id` value to all rows in that batch.
2. WHEN a single note is created, THE Notes_API SHALL assign a unique `group_id` to that row.
3. THE `group_id` SHALL be stored as a text column in the `compliance_notes` table.
4. THE Detail_Panel SHALL use the `group_id` to visually group notes that were submitted together, displaying them as a single note entry with multiple Metric_Chips rather than as separate entries.

View File

@@ -0,0 +1,105 @@
# Implementation Plan: Multi-Metric Notes for Compliance Detail Panel
## Overview
Extend the compliance notes system so a single note can be associated with multiple metrics in one action. Changes span three layers: a new migration script adding `group_id` to `compliance_notes`, updates to the `POST /notes` endpoint in `backend/routes/compliance.js` to accept `metric_ids` (array) and insert rows transactionally, and frontend changes in `ComplianceDetailPanel.js` to replace the single-select dropdown with a multi-select chip selector and group notes by `group_id` in the display.
## Tasks
- [x] 1. Create database migration for `group_id` column
- [x] 1.1 Create `backend/migrations/add_compliance_notes_group_id.js`
- Add `group_id TEXT` column to `compliance_notes` table via `ALTER TABLE`
- Create index `idx_compliance_notes_group` on `compliance_notes(group_id)`
- Backfill existing rows: `UPDATE compliance_notes SET group_id = 'legacy-' || id WHERE group_id IS NULL`
- Follow the existing migration pattern (sqlite3, serialize, console logging)
- _Requirements: 6.1, 6.2, 6.3_
- [x] 2. Update `POST /notes` endpoint to support multi-metric submissions
- [x] 2.1 Modify the `POST /notes` handler in `backend/routes/compliance.js` to accept `metric_ids` array
- Accept `metric_ids` (array of strings) as an alternative to `metric_id` (single string)
- When both are provided, `metric_ids` takes precedence
- When neither is provided, return 400 with `"metric_id or metric_ids is required"`
- When `metric_ids` is provided but is not an array, return 400 with `"metric_ids must be an array"`
- Normalize single `metric_id` into a one-element array internally so the rest of the logic is uniform
- _Requirements: 5.1, 5.4, 5.5_
- [x] 2.2 Add validation for `metric_ids` array entries
- Validate that `metric_ids` has at least one entry; return 400 with `"At least one metric ID is required"` if empty
- Validate each entry is a non-empty string of 50 characters or fewer; return 400 with `"Invalid metric_id at index N"` on failure
- Reject the entire request if any entry fails validation (atomic rejection, no partial inserts)
- _Requirements: 5.2, 5.6_
- [x] 2.3 Implement transactional multi-row insert with `group_id`
- Generate a `group_id` using `crypto.randomUUID()` for each submission (single or multi)
- Wrap all inserts in `BEGIN TRANSACTION` / `COMMIT` with `ROLLBACK` on error
- Insert one `compliance_notes` row per metric ID, all sharing the same `note`, `group_id`, `created_by`, and `created_at`
- _Requirements: 3.1, 3.2, 5.3, 6.1, 6.2_
- [x] 2.4 Update the response to return all created note rows
- After commit, query all created rows (joined with `users` for `username`) and return as `{ notes: [...] }`
- Each row includes `id`, `hostname`, `metric_id`, `note`, `group_id`, `created_at`, `created_by`
- Return HTTP 201 status
- _Requirements: 5.7_
- [x] 3. Update `GET /items/:hostname` to include `group_id` in notes response
- Add `cn.group_id` to the SELECT in the notes query within the `GET /items/:hostname` handler
- The existing query already fetches notes for the hostname; just add the column
- No other changes to this endpoint
- _Requirements: 6.3, 6.4_
- [x] 4. Checkpoint — Verify backend changes
- Ensure all backend changes are syntactically correct, ask the user if questions arise.
- [x] 5. Replace single-select dropdown with multi-select MetricChipSelector in `ComplianceDetailPanel.js`
- [x] 5.1 Replace `noteMetric` (string) state with `selectedMetrics` (array) state
- Initialize `selectedMetrics` with the first active metric's ID when detail loads (matching current default behavior)
- When there is exactly one active metric, pre-select it as a non-removable selection
- _Requirements: 1.3, 1.4_
- [x] 5.2 Build the multi-select chip-based metric selector UI
- Replace the existing `<select>` dropdown with a row of clickable `MetricChip` components
- Each active metric renders as a chip; selected chips get a highlighted border/background
- Clicking an unselected chip adds it to `selectedMetrics`
- Clicking a selected chip removes it, unless it is the only selected chip (minimum 1 selection)
- Only show the chip selector when there are 2+ active metrics (single metric is auto-selected)
- Style chips using existing `MetricChip` component patterns and category colors
- _Requirements: 1.1, 1.2, 1.5_
- [x] 5.3 Add Select All / Deselect All toggle
- Show a text toggle above or beside the chip row when there are 2+ active metrics
- "Select All" selects all active metrics; label changes to "Deselect All"
- "Deselect All" deselects all except the first metric (minimum selection invariant)
- Toggle label is a pure function of whether all metrics are selected
- Hide the toggle when there is only one active metric
- _Requirements: 2.1, 2.2, 2.3_
- [x] 6. Update note submission logic to send `metric_ids` array
- Modify `handleAddNote` to send `{ hostname, metric_ids: selectedMetrics, note }` instead of `{ hostname, metric_id: noteMetric, note }`
- Disable the submit button when `selectedMetrics` is empty or note text is empty
- On success, clear note text, refresh the detail panel, and retain the current metric selection
- Handle the new response shape (`{ notes: [...] }`) from the updated API
- _Requirements: 3.1, 3.4, 3.5_
- [x] 7. Update note display to group by `group_id`
- [x] 7.1 Add note grouping logic
- Group the `detail.notes` array by `group_id` before rendering
- Notes sharing a `group_id` are displayed as a single card with multiple `MetricChip` badges
- Notes without a `group_id` (pre-migration legacy, should not occur after backfill) render as individual entries
- Maintain reverse chronological order (newest `created_at` first) across groups
- _Requirements: 4.1, 4.2, 4.3, 6.4_
- [x] 7.2 Update the note card rendering
- For grouped notes, display all associated `MetricChip` components in the card header
- For single-metric notes, display one `MetricChip` (matching current behavior)
- Preserve existing note card styling (background, border, padding, typography)
- _Requirements: 4.1, 4.2_
- [x] 8. Final checkpoint — Verify full feature
- Ensure frontend compiles without errors, ask the user if questions arise.
## Notes
- No automated tests — feature is validated manually per user preference
- No new components or route modules required; all changes are scoped to existing files plus one migration
- The `group_id` backfill ensures legacy notes render correctly without null checks
- Each task references specific requirements for traceability

View File

@@ -0,0 +1,190 @@
# Documentation Standards — CVE Dashboard
These standards are reverse-engineered from the existing README.md and should be treated as the canonical style guide for all documentation in this repository. When updating docs, match these conventions exactly — consistency matters more than any individual stylistic preference.
---
## Language and Style
- Write in **present tense, active voice**. "The sync fetches findings" — not "Findings will be fetched by the sync."
- Do not use marketing language. No "powerful", "seamless", "robust", "cutting-edge", "unleash". Describe what the thing does, not how great it is.
- No emoji anywhere in documentation. None.
- Prefer precise technical vocabulary over casual phrasing. "Session tokens are stored in `httpOnly` cookies" — not "we keep the login thing in a secure cookie."
- **Spelling:** match whatever spelling variant already appears in the surrounding prose. Do not rewrite existing words to switch between US and British English — consistency within a section matters more than which variant is used.
---
## Punctuation and Formatting
- **Em-dashes (—)** for mid-sentence clarifications or appositives, surrounded by spaces: `sync requires Admin or Standard_User group — the sync then fetches findings`.
- **En-dashes ()** for numeric ranges: `8.59.9 VRR`, `20 attempts per 15-minute window`.
- Use **inline backticks** for: file paths, environment variables, CLI flags, function names, field names, HTTP status codes, and any literal value the reader should type or recognise verbatim.
- Use **bold** (`**text**`) for UI element names (button labels, tab names, form fields) and for label-style emphasis at the start of a sub-point.
- Avoid italics except for rare genuine emphasis. Do not use italics decoratively.
---
## Heading Hierarchy
Use a strict three-tier hierarchy per document:
```
# Document Title (one per file, matches repo/feature name)
## Top-Level Section (Overview, Features, Architecture, etc.)
### Subsection (a named feature, page, or concept)
**Sub-subsection label** (bold paragraph labels — NOT an #### heading)
```
- Do **not** use `####` or deeper headings. If you need a fourth level, it should be a bold inline label followed by content, matching the existing README pattern.
- Separate top-level `##` sections with a horizontal rule (`---`) on its own line, with blank lines above and below.
- Do not place horizontal rules between `###` subsections unless visually necessary.
---
## Table of Contents
- Any document over ~200 lines should open with a Table of Contents using markdown anchor links.
- Nest TOC entries to match heading depth (`##` entries flush-left, `###` entries indented two spaces).
- Anchor slugs: lowercase, spaces become hyphens, em-dashes become `--` (two hyphens). Example: `## Home — CVE Management` becomes `#home--cve-management`.
---
## Tables
Use tables for any reference material with two or more parallel attributes. This includes:
- Permission matrices (group → capabilities)
- Tech stack listings (layer → technology)
- Column descriptions (column → meaning)
- Environment variable references (name → purpose → default)
- Route references (method + path → description → auth requirement)
Table format:
```markdown
| Column | Description |
|---|---|
| `field_name` | What it does |
```
- Always use `---` separators (not `:---:` centred alignment) unless centring is specifically warranted.
- Keep cell content on a single line. If a value is long, prefer prose above or below the table.
- Use inline backticks for literal values inside cells.
---
## Code Blocks
- **Always include a language tag** on fenced code blocks: ` ```bash `, ` ```javascript `, ` ```json `, ` ```python `, ` ```sql `. Bare ` ``` ` is not acceptable.
- Shell commands use `bash` — even for single-line commands.
- Place code blocks on their own line, with blank lines before and after.
- Inside code blocks, do not use markdown formatting (no bold, no italics). Treat them as literal terminal/file content.
- Prefer showing the actual command over describing what to do. "Run `node setup.js` from `backend/`" with a code block — not "initialize the database."
---
## Callouts and Warnings
- Use a blockquote (`>`) for callouts, notes, and warnings. Do not use GitHub-flavoured admonition syntax (`> [!NOTE]`), Docusaurus admonitions, or custom HTML.
- Keep callouts to one or two sentences. If longer, promote to prose.
- Example: `> IVANTI_API_KEY must be set in backend/.env for sync to work.`
Bold prefixes for emphasis are acceptable inside a blockquote: `> **Warning:** This deletes all audit records older than 90 days.`
---
## Feature Documentation Pattern
When documenting a feature or page, follow this structure:
1. **One-sentence summary** of what the feature is and who uses it.
2. **Capability bullets** — what a user can do, grouped by role if access-controlled.
3. **Workflow or interaction details** — how the feature is used in practice (inline editing behaviour, filter semantics, persistence rules).
4. **Integration notes** — what external systems it touches, what credentials it needs, what it caches.
5. **Edge cases and restrictions** — delete rules, rate limits, role requirements.
Bold paragraph labels (`**Inline editing:**`, `**CVE Tooltips:**`, `**Filtering:**`) are the preferred pattern for calling out sub-behaviours within a feature section.
---
## Troubleshooting Entries
Every troubleshooting entry uses the **Symptom → Cause → Fix** triad:
```markdown
### Short description of the problem
**Symptom:** What the user sees or experiences. Be specific about error messages, console output, and observed behaviour.
**Cause:** The underlying reason, explained in one or two sentences. Include the technical mechanism (cookie flag, rate limiter, missing migration) — not just "it's broken."
**Fix:** Either:
1. Concrete action with a command or config change, **or**
2. Alternative action.
```
- Short entries (fix is one command) may collapse Cause into Fix, but Symptom must always appear explicitly.
- Use **Fix:** as the label, not "Solution" or "Resolution."
---
## CHANGELOG
This project does not currently maintain a `CHANGELOG.md`. The doc-updater agent should **not** create one unless explicitly instructed.
When a change ships, rely instead on:
- **Git commit messages** as the primary change history. Write them as complete sentences describing user-observable behaviour, not implementation detail. "Add inline editing to CVE reporting table" — not "fix table.jsx."
- **README updates** as the user-facing record of what the app does now. The README is the source of truth for current behaviour; git log is the source of truth for when and why it changed.
If a `CHANGELOG.md` is introduced later, this section should be rewritten to declare the chosen format. Until then, the agent should leave changelog concerns out of scope.
---
## Migration Documentation
When a feature requires a new database migration:
1. Add the migration filename to the README's **Migrations** section in the order it must be run.
2. If the migration is required for an existing deployment (not just fresh installs), add a Troubleshooting entry using the Symptom → Cause → Fix pattern for the error users will see if they skip it.
3. Note any data-transforming migrations explicitly — users need to know whether the migration is additive (safe to re-run) or transformative (destructive if re-run).
---
## API Reference Documentation
- Document routes as: `METHOD /path/with/:params`
- Group routes by resource or page (CVE routes, Ivanti routes, Audit routes).
- For each route, specify: purpose, required group, request body or query params, response shape summary. Keep it to 24 lines per route.
- Do not duplicate route implementation details. Link to the source file if deeper reference is needed.
- When a route enforces group-based authorisation, state the required group explicitly — do not imply it from context.
---
## What to Update When a Feature Ships
A new feature or meaningful change to existing behaviour should touch:
1. **README.md — Features section:** add or update the relevant `###` subsection, matching the Feature Documentation Pattern above.
2. **README.md — Table of Contents:** if a new feature added a new heading, update the TOC to match.
3. **README.md — Configuration:** if new env vars were introduced, add them to the Configuration table.
4. **README.md — API Reference:** if new routes were added, document them under the appropriate resource group.
5. **README.md — Database Schema:** if tables or columns changed, update the schema section.
6. **README.md — Migrations:** if a new migration was added, append it to the ordered list.
7. **docs/**: if the feature has its own standalone guide (setup guide, integration guide), create or update the relevant file in `docs/`.
If a change does not alter user-observable behaviour, configuration, data model, or API surface, it does not require doc changes. Internal refactors, test additions, and dependency bumps are exempt unless they change how the app is run or deployed.
---
## What NOT to Change
The doc-updater agent and human contributors alike should **leave alone**:
- Tone, voice, and spelling conventions of existing prose. Match, do not rewrite.
- Section ordering in the README — the current order is deliberate and reader-tested.
- Heading wording, unless the underlying feature has genuinely been renamed.
- Examples and command snippets that still work, even if they could be "more elegant."
- The overall shape of the Troubleshooting section — append new entries, do not reorganise existing ones.
Documentation churn is a cost. Only change what the code change requires.

View File

@@ -60,9 +60,8 @@ The application provides:
| Database | SQLite3 |
| File uploads | Multer 2 |
| Auth | bcryptjs, cookie-based sessions, express-rate-limit |
| Frontend | React 19, lucide-react, xlsx, rehype-sanitize |
| Frontend | React 19, lucide-react, recharts, xlsx, react-markdown, rehype-sanitize, mermaid |
| Compliance xlsx parsing | Python 3, pandas, openpyxl |
| Bulk notes import | Python 3 (stdlib only) |
---
@@ -106,7 +105,7 @@ apt install -y python3-pandas python3-openpyxl
> If apt packages are unavailable or you need a specific version, see `docs/python-venv-setup.md` for the venv fallback approach.
> The bulk notes import script (`import_notes_from_csv.py`) uses only Python stdlib and does **not** require these packages.
> A bulk notes import script (`import_notes_from_csv.py`) is also available in `backend/scripts/` for maintenance tasks like backfilling notes from a CSV. It uses only Python stdlib.
### 5. Configure environment variables
@@ -362,7 +361,9 @@ Each row represents a single Ivanti host finding.
**Column management:** Toggle visibility and drag to reorder via the **Columns** button. Order and visibility persist to `localStorage`.
**Export:** Click **Export** to download the current filtered view as CSV or XLSX. Requires Admin, Standard_User, or Leadership group.
**Row visibility:** Hide individual rows by clicking the `EyeOff` icon on any row, or select multiple rows via checkboxes and click **Hide Selected** in the bulk action toolbar. Hidden rows are excluded from the table, the Action Coverage chart, and exports. Use the **Hidden (N)** button in the toolbar to view and restore hidden rows individually or all at once. Hidden row state persists to `localStorage` across sessions. Row hiding is a personal view preference available to all user groups.
**Export:** Click **Export** to download the current filtered view as CSV or XLSX. Hidden rows and filtered rows are both excluded from exports. Requires Admin, Standard_User, or Leadership group.
---
@@ -509,45 +510,6 @@ Called automatically by the compliance upload flow. Parses the NTS_AEO xlsx repo
---
### `backend/scripts/import_notes_from_csv.py`
Bulk-import notes into the findings cache from a CSV file. Useful for onboarding existing notes or migrating from a spreadsheet.
**CSV format:**
```csv
ID,NOTES
12345678,EXC-5754
87654321,Patched in Feb maintenance window
```
**Usage:**
```bash
cd backend/scripts
# Preview what would be imported (no writes)
python3 import_notes_from_csv.py input.csv --dry-run
# Import against the default database path
python3 import_notes_from_csv.py input.csv
# Import against a specific database
python3 import_notes_from_csv.py input.csv --db /path/to/cve_database.db
```
| Argument | Description |
|---|---|
| `csv_file` | Path to the input CSV (required) |
| `--db` | Path to the SQLite database (default: `../cve_database.db`) |
| `--dry-run` | Preview changes without writing to the database |
- Notes longer than 255 characters are truncated with a warning
- Finding IDs not present in the active Ivanti cache are skipped
- Uses UPSERT — running the same CSV twice is safe
**Dependencies:** Python stdlib only (no pip install required).
---
## API Reference
All endpoints are prefixed with `/api`. All endpoints except `/api/auth/login` and `/api/auth/logout` require a valid session cookie. Group requirements are listed per endpoint.

View File

@@ -0,0 +1,29 @@
// Migration: Add group_id column to compliance_notes table
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_compliance_notes_group_id migration...');
db.serialize(() => {
db.run(`ALTER TABLE compliance_notes ADD COLUMN group_id TEXT`, (err) => {
if (err) console.error('Error adding group_id column:', err);
else console.log('✓ group_id column added to compliance_notes');
});
db.run(`CREATE INDEX IF NOT EXISTS idx_compliance_notes_group ON compliance_notes(group_id)`, (err) => {
if (err) console.error('Error creating group_id index:', err);
else console.log('✓ idx_compliance_notes_group created');
});
db.run(`UPDATE compliance_notes SET group_id = 'legacy-' || id WHERE group_id IS NULL`, (err) => {
if (err) console.error('Error backfilling group_id:', err);
else console.log('✓ Existing rows backfilled with legacy group_id');
});
});
db.close(() => {
console.log('Migration complete!');
});

View File

@@ -8,12 +8,13 @@
// GET /summary — metric health cards for a team (from latest upload)
// GET /items — non-compliant devices grouped by hostname (?team=X&status=active)
// GET /items/:hostname — detail panel: all metrics + notes + upload history for a device
// POST /notes — add a note to a (hostname, metric_id) pair
// POST /notes — add a note to one or more (hostname, metric_id) pairs
// GET /notes/:hostname/:metricId — notes for a specific device+metric
const express = require('express');
const path = require('path');
const fs = require('fs');
const crypto = require('crypto');
const { spawn } = require('child_process');
const PARSER_SCRIPT = path.join(__dirname, '../scripts/parse_compliance_xlsx.py');
@@ -488,7 +489,7 @@ function createComplianceRouter(db, upload, requireAuth, requireGroup) {
// Notes (all metrics for this hostname, sorted newest first)
const notes = await dbAll(db,
`SELECT cn.id, cn.metric_id, cn.note, cn.created_at,
`SELECT cn.id, cn.metric_id, cn.note, cn.group_id, cn.created_at,
u.username AS created_by
FROM compliance_notes cn
LEFT JOIN users u ON cn.created_by = u.id
@@ -517,42 +518,82 @@ function createComplianceRouter(db, upload, requireAuth, requireGroup) {
// -----------------------------------------------------------------------
// POST /notes
// Add a note to a (hostname, metric_id) pair.
// Body: { hostname, metric_id, note }
// Add a note to one or more (hostname, metric_id) pairs.
// Body: { hostname, metric_ids: [...], note } — or legacy { hostname, metric_id, note }
// -----------------------------------------------------------------------
router.post('/notes', requireGroup('Admin', 'Standard_User'), async (req, res) => {
const { hostname, metric_id, note } = req.body;
const { hostname, metric_id, metric_ids, note } = req.body;
if (!hostname || typeof hostname !== 'string' || hostname.length > 300 || !/^[a-zA-Z0-9._-]+$/.test(hostname)) {
return res.status(400).json({ error: 'Invalid hostname format' });
}
if (!metric_id || typeof metric_id !== 'string' || metric_id.length > 50) {
return res.status(400).json({ error: 'Invalid metric_id' });
// --- Resolve metric IDs: metric_ids takes precedence over metric_id ---
let resolvedIds;
if (metric_ids !== undefined) {
if (!Array.isArray(metric_ids)) {
return res.status(400).json({ error: 'metric_ids must be an array' });
}
resolvedIds = metric_ids;
} else if (metric_id !== undefined && metric_id !== null && metric_id !== '') {
if (typeof metric_id !== 'string' || metric_id.length > 50) {
return res.status(400).json({ error: 'Invalid metric_id' });
}
resolvedIds = [metric_id];
} else {
return res.status(400).json({ error: 'metric_id or metric_ids is required' });
}
// --- Validate resolved metric IDs ---
if (resolvedIds.length === 0) {
return res.status(400).json({ error: 'At least one metric ID is required' });
}
for (let i = 0; i < resolvedIds.length; i++) {
const mid = resolvedIds[i];
if (!mid || typeof mid !== 'string' || mid.length === 0 || mid.length > 50) {
return res.status(400).json({ error: `Invalid metric_id at index ${i}` });
}
}
const noteText = String(note || '').trim().slice(0, 1000);
if (!noteText) {
return res.status(400).json({ error: 'Note cannot be empty' });
}
try {
const { lastID } = await dbRun(db,
`INSERT INTO compliance_notes (hostname, metric_id, note, created_by, created_at)
VALUES (?, ?, ?, ?, datetime('now'))`,
[hostname, metric_id, noteText, req.user?.id || null]
);
const groupId = crypto.randomUUID();
const userId = req.user?.id || null;
const created = await dbGet(db,
`SELECT cn.id, cn.hostname, cn.metric_id, cn.note, cn.created_at,
try {
await dbRun(db, 'BEGIN TRANSACTION');
const insertedIds = [];
for (const mid of resolvedIds) {
const { lastID } = await dbRun(db,
`INSERT INTO compliance_notes (hostname, metric_id, note, group_id, created_by, created_at)
VALUES (?, ?, ?, ?, ?, datetime('now'))`,
[hostname, mid, noteText, groupId, userId]
);
insertedIds.push(lastID);
}
await dbRun(db, 'COMMIT');
// Fetch all created rows with username
const placeholders = insertedIds.map(() => '?').join(', ');
const notes = await dbAll(db,
`SELECT cn.id, cn.hostname, cn.metric_id, cn.note, cn.group_id, cn.created_at,
u.username AS created_by
FROM compliance_notes cn
LEFT JOIN users u ON cn.created_by = u.id
WHERE cn.id = ?`,
[lastID]
WHERE cn.id IN (${placeholders})
ORDER BY cn.id ASC`,
insertedIds
);
res.status(201).json(created);
res.status(201).json({ notes });
} catch (err) {
await dbRun(db, 'ROLLBACK').catch(() => {});
console.error('[Compliance] POST /notes error:', err.message);
res.status(500).json({ error: 'Failed to save note' });
}

View File

@@ -1,4 +1,4 @@
# CVE Tracking & NVD Sync Guide
# CVE Tracking & NVD Sync Spec
## Overview

View File

@@ -0,0 +1,154 @@
# Security Remediation Plan
Based on the External Data Handling security audit (April 2026). 17 findings total — 0 Critical, 2 High, 6 Medium, 6 Low, 3 Informational. Ordered by priority based on real-world exploitability and effort.
---
## Phase 1 — Data Exposure & XSS (High Priority)
### 1. L-4: Authenticate /uploads static file access
**Location:** `server.js:127`
**Risk:** Uploaded documents (vulnerability data, compliance files) served without authentication. Anyone with the URL can access them.
**Fix:** Replace `express.static('/uploads')` with a route handler that runs `requireAuth(db)` before streaming the file. Use `res.sendFile()` with the validated path.
**Effort:** Small — single route change.
### 2. M-6: Sanitize Mermaid SVG output with DOMPurify
**Location:** `frontend/src/components/KnowledgeBaseViewer.js:38`
**Risk:** Mermaid renders SVG which is injected via `innerHTML`. If KB content contains malicious markup, this is a stored XSS vector.
**Fix:** Install `dompurify`, sanitize the SVG string before assigning to `innerHTML`. Use `DOMPurify.sanitize(svgString, { USE_PROFILES: { svg: true } })`.
**Effort:** Small — add dependency, wrap one line.
### 3. M-4: Strip server file paths from compliance preview response
**Location:** `backend/routes/compliance.js:278`
**Risk:** Full server-side file path returned to client. Helps attackers map the filesystem.
**Fix:** Return only the filename (use `path.basename()`) instead of the full path. Or return a reference ID that maps to the file server-side.
**Effort:** Small — one-line change.
---
## Phase 2 — Deployment & Setup Hygiene
### 4. H-2: Add SESSION_SECRET to .env.example and setup-env.sh
**Location:** `backend/.env.example`, `backend/setup-env.sh`
**Risk:** Fresh deployments fail with no guidance on required env vars.
**Fix:** Add `SESSION_SECRET=` to `.env.example` with a comment explaining it should be a random 64+ character string. Add generation logic to `setup-env.sh` (e.g., `openssl rand -hex 32`).
**Effort:** Small.
### 5. I-3: Set user_group on default admin in setup.js
**Location:** `backend/setup.js:180`
**Risk:** Default admin created without `user_group`, potentially locked out of `requireGroup`-protected routes on fresh install.
**Fix:** Set `user_group = 'Admin'` in the INSERT statement for the default admin user.
**Effort:** Trivial — one column added to the INSERT.
---
## Phase 3 — Error Message Sanitization (Batch)
### 6. L-2: Sanitize Python parser error messages
**Location:** `backend/routes/compliance.js:284`
**Risk:** Stack traces and server paths leaked to client when Python parser fails.
**Fix:** Catch the error, log the full details server-side, return a generic "Compliance file parsing failed" message to the client.
**Effort:** Small.
### 7. L-3: Sanitize Ivanti API error responses
**Location:** `backend/routes/ivantiFpWorkflow.js:393`
**Risk:** Raw Ivanti API error body forwarded to client, potentially exposing internal API details.
**Fix:** Log the raw error server-side, return a generic "Ivanti API request failed" message to the client.
**Effort:** Small.
### 8. L-6: Remove group name from requireGroup error response
**Location:** `backend/middleware/auth.js:60`
**Risk:** Error response leaks the user's current group name, which is minor info disclosure.
**Fix:** Change the error message from something like "User group 'Viewer' not authorized" to "Insufficient permissions."
**Effort:** Trivial.
---
## Phase 4 — Security Headers
### 9. M-1: Add Content-Security-Policy header
**Location:** `server.js:107-113`
**Risk:** No CSP means no browser-side XSS mitigation layer.
**Fix:** Add a CSP header via middleware. Start with a report-only policy to avoid breaking things, then tighten. Suggested baseline:
```
default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'
```
Note: `'unsafe-inline'` for styles is needed because the app uses inline style objects extensively. Evaluate whether `script-src 'self'` breaks anything (it shouldn't with CRA).
**Effort:** Medium — needs testing to ensure nothing breaks.
### 10. M-2: Add Strict-Transport-Security (HSTS) header
**Location:** `server.js:107-113`
**Risk:** No HSTS means browsers don't enforce HTTPS on subsequent visits.
**Fix:** Add `Strict-Transport-Security: max-age=31536000; includeSubDomains` header. Only apply when running behind HTTPS (check `req.secure` or a trusted proxy header). Do NOT enable if the app is accessed over plain HTTP.
**Effort:** Small, but verify deployment is HTTPS-only first.
---
## Phase 5 — Operational Maintenance
### 11. L-5: Add expired session cleanup
**Location:** `backend/middleware/auth.js:271`
**Risk:** Sessions table grows indefinitely. Not a security exploit, but degrades performance over time.
**Fix:** Add a cleanup function that runs on server startup (and optionally on a setInterval) to DELETE sessions where `expires_at < CURRENT_TIMESTAMP`. Run once at boot, then every 6 hours.
**Effort:** Small.
---
## Phase 6 — Session Signing (Larger Effort)
### 12. H-1: Use SESSION_SECRET for HMAC-signed session tokens
**Location:** `server.js:33`
**Risk:** Session tokens are random bytes stored in DB with no signing. An attacker with DB read access can replay any session. For self-hosted SQLite, DB access already implies full compromise, so this is a defense-in-depth measure.
**Fix:** When creating a session, generate a random token and store its HMAC (using SESSION_SECRET) in the DB. On validation, recompute the HMAC and compare. This means a DB dump alone isn't enough to forge sessions — the attacker also needs the secret.
**Effort:** Medium — touches session creation, validation, and requires SESSION_SECRET to actually be wired in.
---
## Phase 7 — Investigate Before Changing
### 13. M-3: Review application/octet-stream in MIME allowlist
**Location:** `server.js:62`
**Risk:** Allows uploads that bypass MIME type checking. May be intentional for specific file types.
**Action:** Check what file types are uploaded that resolve to `application/octet-stream`. If none are legitimate, remove it from the allowlist. If some are (e.g., `.db` files, binary exports), consider adding those specific MIME types instead.
**Effort:** Investigation first, then trivial change.
### 14. M-5: Evaluate CORS HTTP origin policy
**Location:** `server.js:38-40`
**Risk:** CORS allows HTTP origins, no HTTPS enforcement.
**Action:** Check if production runs behind a reverse proxy with HTTPS termination. If yes, the backend legitimately sees HTTP origins from the proxy. If production traffic is ever plain HTTP end-to-end, restrict CORS to HTTPS origins only.
**Effort:** Investigation first, then small config change.
---
## Phase 8 — Low Priority / Monitor
### 15. L-1: Add startup warning for IVANTI_SKIP_TLS=true
**Location:** `backend/helpers/ivantiApi.js:28`
**Risk:** TLS validation disabled silently. Acceptable in dev, risky if accidentally left on in production.
**Fix:** Add a `console.warn('⚠ IVANTI_SKIP_TLS is enabled — TLS certificate validation is disabled')` at startup when the flag is set.
**Effort:** Trivial.
### 16. I-1: Monitor react-scripts version
**Location:** `frontend/package.json`
**Risk:** Build-time only, not runtime. No immediate action needed.
**Action:** Upgrade to latest react-scripts when convenient. Consider migrating to Vite if a major frontend overhaul is planned.
### 17. I-2: Monitor xlsx dependency
**Location:** `frontend/package.json`
**Risk:** Community fork, unmaintained since 2022. Used for spreadsheet parsing.
**Action:** Monitor for security advisories. If a vulnerability is found, evaluate alternatives (e.g., `exceljs`, `sheetjs` pro). No immediate action needed unless a CVE is published against it.
---
## Summary
| Phase | Items | Effort | Impact |
|-------|-------|--------|--------|
| 1 — Data Exposure & XSS | L-4, M-6, M-4 | Small | High |
| 2 — Deployment Hygiene | H-2, I-3 | Small | Medium |
| 3 — Error Sanitization | L-2, L-3, L-6 | Small | Low-Medium |
| 4 — Security Headers | M-1, M-2 | Medium | Medium |
| 5 — Session Cleanup | L-5 | Small | Low |
| 6 — Session Signing | H-1 | Medium | Medium |
| 7 — Investigate | M-3, M-5 | Investigation | TBD |
| 8 — Monitor | L-1, I-1, I-2 | Trivial | Low |

View File

@@ -42,7 +42,7 @@ export default function ComplianceDetailPanel({ hostname, onClose, onNoteAdded,
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [noteText, setNoteText] = useState('');
const [noteMetric, setNoteMetric] = useState('');
const [selectedMetrics, setSelectedMetrics] = useState([]);
const [submitting, setSubmitting] = useState(false);
const [noteError, setNoteError] = useState(null);
@@ -55,9 +55,9 @@ export default function ComplianceDetailPanel({ hostname, onClose, onNoteAdded,
if (!res.ok) throw new Error(data.error || 'Failed to load device');
setDetail(data);
// Default note metric to first active failing metric
// Default selected metrics to first active failing metric
const firstActive = (data.metrics || []).find(m => m.status === 'active');
if (firstActive) setNoteMetric(firstActive.metric_id);
if (firstActive) setSelectedMetrics([firstActive.metric_id]);
} catch (err) {
setError(err.message);
} finally {
@@ -68,7 +68,7 @@ export default function ComplianceDetailPanel({ hostname, onClose, onNoteAdded,
useEffect(() => { fetchDetail(); }, [fetchDetail]);
const handleAddNote = async () => {
if (!noteText.trim() || !noteMetric) return;
if (!noteText.trim() || selectedMetrics.length === 0) return;
setSubmitting(true);
setNoteError(null);
try {
@@ -76,7 +76,7 @@ export default function ComplianceDetailPanel({ hostname, onClose, onNoteAdded,
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ hostname, metric_id: noteMetric, note: noteText.trim() }),
body: JSON.stringify({ hostname, metric_ids: selectedMetrics, note: noteText.trim() }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Failed to save note');
@@ -194,39 +194,115 @@ export default function ComplianceDetailPanel({ hostname, onClose, onNoteAdded,
{detail.notes.length === 0 && (
<div style={{ color: '#334155', fontSize: '0.75rem', fontStyle: 'italic', marginBottom: '0.75rem' }}>No notes yet</div>
)}
{detail.notes.map(n => (
<div key={n.id} style={{
marginBottom: '0.75rem', padding: '0.625rem 0.75rem',
background: 'rgba(15,23,42,0.6)', borderRadius: '0.375rem',
border: '1px solid rgba(255,255,255,0.05)',
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.3rem' }}>
<MetricChip metricId={n.metric_id} category={activeMetrics.find(m => m.metric_id === n.metric_id)?.category || ''} />
<span style={{ fontSize: '0.68rem', color: '#334155', fontFamily: 'monospace' }}>
{n.created_by && `${n.created_by} · `}{n.created_at?.slice(0, 10)}
</span>
{(() => {
// Build a lookup map for metric categories (active + resolved)
const metricMap = {};
(detail.metrics || []).forEach(m => { metricMap[m.metric_id] = m.category; });
// Group notes by group_id, preserving reverse chronological order
const grouped = [];
const seen = new Set();
// detail.notes is already sorted newest-first from the API
for (const n of detail.notes) {
const gid = n.group_id;
if (!gid) {
// Legacy note without group_id — render individually
grouped.push({ key: `note-${n.id}`, notes: [n], note: n.note, created_by: n.created_by, created_at: n.created_at });
} else if (!seen.has(gid)) {
seen.add(gid);
const group = detail.notes.filter(x => x.group_id === gid);
grouped.push({ key: `group-${gid}`, notes: group, note: group[0].note, created_by: group[0].created_by, created_at: group[0].created_at });
}
}
return grouped.map(g => (
<div key={g.key} style={{
marginBottom: '0.75rem', padding: '0.625rem 0.75rem',
background: 'rgba(15,23,42,0.6)', borderRadius: '0.375rem',
border: '1px solid rgba(255,255,255,0.05)',
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '0.3rem' }}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem' }}>
{g.notes.map(n => (
<MetricChip key={n.id} metricId={n.metric_id} category={metricMap[n.metric_id] || ''} />
))}
</div>
<span style={{ fontSize: '0.68rem', color: '#334155', fontFamily: 'monospace', flexShrink: 0, marginLeft: '0.5rem', whiteSpace: 'nowrap' }}>
{g.created_by && `${g.created_by} · `}{g.created_at?.slice(0, 10)}
</span>
</div>
<div style={{ fontSize: '0.8rem', color: '#CBD5E1', lineHeight: 1.5 }}>{g.note}</div>
</div>
<div style={{ fontSize: '0.8rem', color: '#CBD5E1', lineHeight: 1.5 }}>{n.note}</div>
</div>
))}
));
})()}
{/* Add note */}
<div style={{ marginTop: 'auto', paddingTop: '0.75rem', borderTop: '1px solid rgba(255,255,255,0.05)' }}>
{activeMetrics.length > 1 && (
<select
value={noteMetric}
onChange={e => setNoteMetric(e.target.value)}
style={{
width: '100%', marginBottom: '0.5rem',
background: 'rgba(15,23,42,0.8)', border: '1px solid rgba(20,184,166,0.25)',
borderRadius: '0.25rem', color: '#CBD5E1',
padding: '0.4rem 0.5rem', fontSize: '0.75rem', fontFamily: 'monospace',
}}>
{activeMetrics.map(m => (
<option key={m.metric_id} value={m.metric_id}>{m.metric_id} {m.category}</option>
))}
</select>
)}
{activeMetrics.length > 1 && (() => {
const allSelected = activeMetrics.length > 0 && activeMetrics.every(m => selectedMetrics.includes(m.metric_id));
return (
<div style={{ marginBottom: '0.5rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.35rem' }}>
<span style={{ fontSize: '0.68rem', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: '0.05em', color: '#475569' }}>
Metrics
</span>
<button
onClick={() => {
if (allSelected) {
setSelectedMetrics([activeMetrics[0].metric_id]);
} else {
setSelectedMetrics(activeMetrics.map(m => m.metric_id));
}
}}
style={{
background: 'none', border: 'none', cursor: 'pointer',
fontSize: '0.68rem', fontFamily: 'monospace',
color: TEAL, padding: 0,
transition: 'opacity 0.15s',
}}
onMouseEnter={e => e.currentTarget.style.opacity = '0.7'}
onMouseLeave={e => e.currentTarget.style.opacity = '1'}
>
{allSelected ? 'Deselect All' : 'Select All'}
</button>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.35rem' }}>
{activeMetrics.map(m => {
const isSelected = selectedMetrics.includes(m.metric_id);
const color = categoryColor(m.category);
return (
<button
key={m.metric_id}
onClick={() => {
if (isSelected) {
if (selectedMetrics.length > 1) {
setSelectedMetrics(selectedMetrics.filter(id => id !== m.metric_id));
}
} else {
setSelectedMetrics([...selectedMetrics, m.metric_id]);
}
}}
style={{
display: 'inline-flex', alignItems: 'center',
padding: '0.25rem 0.5rem',
background: isSelected ? `${color}25` : `${color}08`,
border: `1px solid ${isSelected ? `${color}90` : `${color}30`}`,
borderRadius: '0.25rem',
color: isSelected ? color : `${color}90`,
fontSize: '0.7rem', fontFamily: 'monospace', fontWeight: '600',
cursor: (isSelected && selectedMetrics.length === 1) ? 'default' : 'pointer',
transition: 'all 0.15s',
opacity: (isSelected && selectedMetrics.length === 1) ? 0.85 : 1,
}}
>
{m.metric_id}
</button>
);
})}
</div>
</div>
);
})()}
<div style={{ display: 'flex', gap: '0.5rem' }}>
<textarea
value={noteText}
@@ -244,13 +320,13 @@ export default function ComplianceDetailPanel({ hostname, onClose, onNoteAdded,
onBlur={e => e.target.style.borderColor = 'rgba(20,184,166,0.25)'}
onKeyDown={e => { if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) handleAddNote(); }}
/>
<button onClick={handleAddNote} disabled={!noteText.trim() || submitting}
<button onClick={handleAddNote} disabled={!noteText.trim() || selectedMetrics.length === 0 || submitting}
style={{
padding: '0.5rem 0.625rem', flexShrink: 0,
background: noteText.trim() ? `${TEAL}20` : 'transparent',
border: `1px solid ${noteText.trim() ? TEAL : 'rgba(20,184,166,0.2)'}`,
borderRadius: '0.375rem', color: noteText.trim() ? TEAL : '#334155',
cursor: noteText.trim() ? 'pointer' : 'default', transition: 'all 0.15s',
background: (noteText.trim() && selectedMetrics.length > 0) ? `${TEAL}20` : 'transparent',
border: `1px solid ${(noteText.trim() && selectedMetrics.length > 0) ? TEAL : 'rgba(20,184,166,0.2)'}`,
borderRadius: '0.375rem', color: (noteText.trim() && selectedMetrics.length > 0) ? TEAL : '#334155',
cursor: (noteText.trim() && selectedMetrics.length > 0) ? 'pointer' : 'default', transition: 'all 0.15s',
}}>
{submitting
? <Loader style={{ width: '16px', height: '16px', animation: 'spin 1s linear infinite' }} />