diff --git a/backend/routes/ivantiFindings.js b/backend/routes/ivantiFindings.js
index b1f355d..6e2541a 100644
--- a/backend/routes/ivantiFindings.js
+++ b/backend/routes/ivantiFindings.js
@@ -4,6 +4,7 @@
const express = require('express');
const https = require('https');
+const { requireRole } = require('../middleware/auth');
const IVANTI_URL_BASE = 'https://platform4.risksense.com/api/v1';
const SYNC_INTERVAL_MS = 24 * 60 * 60 * 1000;
@@ -151,9 +152,25 @@ function initTables(db) {
VALUES (1, 0, 0)
`, (err) => { if (err) return reject(err); });
+ db.run(`
+ CREATE TABLE IF NOT EXISTS ivanti_finding_overrides (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ finding_id TEXT NOT NULL,
+ field TEXT NOT NULL,
+ value TEXT NOT NULL DEFAULT '',
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ UNIQUE(finding_id, field)
+ )
+ `, (err) => { if (err) return reject(err); });
+
db.run(`
CREATE INDEX IF NOT EXISTS idx_finding_notes_finding_id
ON ivanti_finding_notes(finding_id)
+ `, (err) => { if (err) return reject(err); });
+
+ db.run(`
+ CREATE INDEX IF NOT EXISTS idx_finding_overrides_finding_id
+ ON ivanti_finding_overrides(finding_id)
`, (err) => {
if (err) reject(err);
else resolve();
@@ -395,9 +412,28 @@ function readCounts(db) {
});
}
+// Returns { findingId: { hostName: 'override', dns: 'override' }, ... }
+function readOverrides(db) {
+ return new Promise((resolve, reject) => {
+ db.all('SELECT finding_id, field, value FROM ivanti_finding_overrides', (err, rows) => {
+ if (err) return reject(err);
+ const map = {};
+ (rows || []).forEach((r) => {
+ if (!map[r.finding_id]) map[r.finding_id] = {};
+ map[r.finding_id][r.field] = r.value;
+ });
+ resolve(map);
+ });
+ });
+}
+
async function readStateWithNotes(db) {
- const [state, notes] = await Promise.all([readState(db), readNotes(db)]);
- state.findings = state.findings.map((f) => ({ ...f, note: notes[f.id] || '' }));
+ const [state, notes, overrides] = await Promise.all([readState(db), readNotes(db), readOverrides(db)]);
+ state.findings = state.findings.map((f) => ({
+ ...f,
+ note: notes[f.id] || '',
+ overrides: overrides[f.id] || {},
+ }));
return state;
}
@@ -441,6 +477,42 @@ function createIvantiFindingsRouter(db, requireAuth) {
}
});
+ // PUT /:findingId/override — save or clear a field override (editor/admin only)
+ const OVERRIDE_ALLOWED = ['hostName', 'dns'];
+ router.put('/:findingId/override', requireRole('editor', 'admin'), (req, res) => {
+ const { findingId } = req.params;
+ const { field, value } = req.body;
+
+ if (!OVERRIDE_ALLOWED.includes(field)) {
+ return res.status(400).json({ error: `Field '${field}' is not editable. Allowed: ${OVERRIDE_ALLOWED.join(', ')}` });
+ }
+
+ const val = String(value ?? '').trim();
+
+ if (val === '') {
+ // Empty value = clear the override (revert to Ivanti)
+ db.run(
+ 'DELETE FROM ivanti_finding_overrides WHERE finding_id = ? AND field = ?',
+ [findingId, field],
+ (err) => {
+ if (err) return res.status(500).json({ error: 'Failed to clear override' });
+ res.json({ finding_id: findingId, field, value: null });
+ }
+ );
+ } else {
+ db.run(
+ `INSERT INTO ivanti_finding_overrides (finding_id, field, value, updated_at)
+ VALUES (?, ?, ?, datetime('now'))
+ ON CONFLICT(finding_id, field) DO UPDATE SET value=excluded.value, updated_at=datetime('now')`,
+ [findingId, field, val],
+ (err) => {
+ if (err) return res.status(500).json({ error: 'Failed to save override' });
+ res.json({ finding_id: findingId, field, value: val });
+ }
+ );
+ }
+ });
+
// PUT /:findingId/note — save or update a note (max 255 chars enforced here)
router.put('/:findingId/note', (req, res) => {
const { findingId } = req.params;
diff --git a/frontend/src/components/pages/ReportingPage.js b/frontend/src/components/pages/ReportingPage.js
index e41e966..0f3d282 100644
--- a/frontend/src/components/pages/ReportingPage.js
+++ b/frontend/src/components/pages/ReportingPage.js
@@ -1,7 +1,8 @@
import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import ReactDOM from 'react-dom';
-import { RefreshCw, Loader, AlertCircle, PieChart, ChevronUp, ChevronDown, ChevronsUpDown, Settings2, GripVertical, Eye, EyeOff, Filter, Download } from 'lucide-react';
+import { RefreshCw, Loader, AlertCircle, PieChart, ChevronUp, ChevronDown, ChevronsUpDown, Settings2, GripVertical, Eye, EyeOff, Filter, Download, RotateCcw } from 'lucide-react';
import * as XLSX from 'xlsx';
+import { useAuth } from '../../contexts/AuthContext';
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
const STORAGE_KEY = 'steam_findings_columns_v2';
@@ -391,6 +392,119 @@ function SortIcon({ colKey, sort }) {
: