Add Reporting page with Ivanti host findings table
Backend:
- New route /api/ivanti/findings (GET cached data, POST /sync, PUT /:id/note)
- Fetches all pages of hostFinding/search filtered to NTS-AEO groups, severity 8.5-9.9, Open state
- SQLite cache (ivanti_findings_cache) stores slimmed findings across syncs
- Separate ivanti_finding_notes table persists user notes by finding ID
- Daily auto-sync on startup + 24h interval, manual sync endpoint
- Notes capped at 255 chars server-side
Frontend (ReportingPage):
- Panel 1: Metric graphs placeholder (full width, amber theme)
- Panel 2: Sortable findings table (all columns click-to-sort with ASC/DESC toggle)
- Columns: Severity (color-coded badge), Title, Host, IP, DNS, SLA, Discovered, Last Found, Source, Notes
- Notes column: inline editable input, saves on blur via PUT endpoint
- Sync button with spinner, last-synced timestamp, error banner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:56:37 -06:00
|
|
|
// Ivanti / RiskSense Host Findings Routes
|
|
|
|
|
// Caches hostFinding/search results in SQLite with daily auto-sync.
|
|
|
|
|
// Notes are stored separately so they survive cache refreshes.
|
|
|
|
|
|
|
|
|
|
const express = require('express');
|
2026-04-06 16:18:07 -06:00
|
|
|
const { requireGroup } = require('../middleware/auth');
|
2026-04-07 16:20:24 -06:00
|
|
|
const { ivantiPost } = require('../helpers/ivantiApi');
|
Add Reporting page with Ivanti host findings table
Backend:
- New route /api/ivanti/findings (GET cached data, POST /sync, PUT /:id/note)
- Fetches all pages of hostFinding/search filtered to NTS-AEO groups, severity 8.5-9.9, Open state
- SQLite cache (ivanti_findings_cache) stores slimmed findings across syncs
- Separate ivanti_finding_notes table persists user notes by finding ID
- Daily auto-sync on startup + 24h interval, manual sync endpoint
- Notes capped at 255 chars server-side
Frontend (ReportingPage):
- Panel 1: Metric graphs placeholder (full width, amber theme)
- Panel 2: Sortable findings table (all columns click-to-sort with ASC/DESC toggle)
- Columns: Severity (color-coded badge), Title, Host, IP, DNS, SLA, Discovered, Last Found, Source, Notes
- Notes column: inline editable input, saves on blur via PUT endpoint
- Sync button with spinner, last-synced timestamp, error banner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:56:37 -06:00
|
|
|
|
|
|
|
|
const SYNC_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
|
|
|
|
|
|
|
|
|
const FINDINGS_FILTERS = [
|
2026-03-13 12:23:05 -06:00
|
|
|
// NOTE: This filters for Open findings only — Closed count is fetched separately via syncClosedCount()
|
Add Reporting page with Ivanti host findings table
Backend:
- New route /api/ivanti/findings (GET cached data, POST /sync, PUT /:id/note)
- Fetches all pages of hostFinding/search filtered to NTS-AEO groups, severity 8.5-9.9, Open state
- SQLite cache (ivanti_findings_cache) stores slimmed findings across syncs
- Separate ivanti_finding_notes table persists user notes by finding ID
- Daily auto-sync on startup + 24h interval, manual sync endpoint
- Notes capped at 255 chars server-side
Frontend (ReportingPage):
- Panel 1: Metric graphs placeholder (full width, amber theme)
- Panel 2: Sortable findings table (all columns click-to-sort with ASC/DESC toggle)
- Columns: Severity (color-coded badge), Title, Host, IP, DNS, SLA, Discovered, Last Found, Source, Notes
- Notes column: inline editable input, saves on blur via PUT endpoint
- Sync button with spinner, last-synced timestamp, error banner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:56:37 -06:00
|
|
|
{
|
|
|
|
|
field: 'assetCustomAttributes.1550_host_1.value',
|
|
|
|
|
exclusive: false,
|
|
|
|
|
operator: 'IN',
|
|
|
|
|
orWithPrevious: false,
|
|
|
|
|
implicitFilters: [],
|
|
|
|
|
value: 'NTS-AEO-ACCESS-ENG,NTS-AEO-STEAM',
|
|
|
|
|
caseSensitive: false
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
field: 'severity',
|
|
|
|
|
exclusive: false,
|
|
|
|
|
operator: 'RANGE',
|
|
|
|
|
orWithPrevious: false,
|
|
|
|
|
implicitFilters: [],
|
|
|
|
|
value: '8.5,9.9',
|
|
|
|
|
caseSensitive: false
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
field: 'generic_state',
|
|
|
|
|
exclusive: false,
|
|
|
|
|
operator: 'EXACT',
|
|
|
|
|
orWithPrevious: false,
|
|
|
|
|
implicitFilters: [],
|
|
|
|
|
value: 'Open',
|
|
|
|
|
caseSensitive: false
|
|
|
|
|
}
|
|
|
|
|
];
|
|
|
|
|
|
2026-03-13 12:23:05 -06:00
|
|
|
// Same BU + severity filters but for Closed state — used only to fetch the total count
|
|
|
|
|
const CLOSED_COUNT_FILTERS = [
|
|
|
|
|
{
|
|
|
|
|
field: 'assetCustomAttributes.1550_host_1.value',
|
|
|
|
|
exclusive: false,
|
|
|
|
|
operator: 'IN',
|
|
|
|
|
orWithPrevious: false,
|
|
|
|
|
implicitFilters: [],
|
|
|
|
|
value: 'NTS-AEO-ACCESS-ENG,NTS-AEO-STEAM',
|
|
|
|
|
caseSensitive: false
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
field: 'severity',
|
|
|
|
|
exclusive: false,
|
|
|
|
|
operator: 'RANGE',
|
|
|
|
|
orWithPrevious: false,
|
|
|
|
|
implicitFilters: [],
|
|
|
|
|
value: '8.5,9.9',
|
|
|
|
|
caseSensitive: false
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
field: 'generic_state',
|
|
|
|
|
exclusive: false,
|
|
|
|
|
operator: 'EXACT',
|
|
|
|
|
orWithPrevious: false,
|
|
|
|
|
implicitFilters: [],
|
|
|
|
|
value: 'Closed',
|
|
|
|
|
caseSensitive: false
|
|
|
|
|
}
|
|
|
|
|
];
|
|
|
|
|
|
Add Reporting page with Ivanti host findings table
Backend:
- New route /api/ivanti/findings (GET cached data, POST /sync, PUT /:id/note)
- Fetches all pages of hostFinding/search filtered to NTS-AEO groups, severity 8.5-9.9, Open state
- SQLite cache (ivanti_findings_cache) stores slimmed findings across syncs
- Separate ivanti_finding_notes table persists user notes by finding ID
- Daily auto-sync on startup + 24h interval, manual sync endpoint
- Notes capped at 255 chars server-side
Frontend (ReportingPage):
- Panel 1: Metric graphs placeholder (full width, amber theme)
- Panel 2: Sortable findings table (all columns click-to-sort with ASC/DESC toggle)
- Columns: Severity (color-coded badge), Title, Host, IP, DNS, SLA, Discovered, Last Found, Source, Notes
- Notes column: inline editable input, saves on blur via PUT endpoint
- Sync button with spinner, last-synced timestamp, error banner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:56:37 -06:00
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Table init
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
function initTables(db) {
|
|
|
|
|
return new Promise((resolve, reject) => {
|
|
|
|
|
db.serialize(() => {
|
|
|
|
|
db.run(`
|
|
|
|
|
CREATE TABLE IF NOT EXISTS ivanti_findings_cache (
|
|
|
|
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
|
|
|
total INTEGER DEFAULT 0,
|
|
|
|
|
findings_json TEXT DEFAULT '[]',
|
|
|
|
|
synced_at DATETIME,
|
|
|
|
|
sync_status TEXT DEFAULT 'never',
|
|
|
|
|
error_message TEXT
|
|
|
|
|
)
|
|
|
|
|
`, (err) => { if (err) return reject(err); });
|
|
|
|
|
|
|
|
|
|
db.run(`
|
|
|
|
|
INSERT OR IGNORE INTO ivanti_findings_cache (id, total, findings_json, sync_status)
|
|
|
|
|
VALUES (1, 0, '[]', 'never')
|
|
|
|
|
`, (err) => { if (err) return reject(err); });
|
|
|
|
|
|
|
|
|
|
db.run(`
|
|
|
|
|
CREATE TABLE IF NOT EXISTS ivanti_finding_notes (
|
|
|
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
|
|
|
finding_id TEXT NOT NULL UNIQUE,
|
|
|
|
|
note TEXT NOT NULL DEFAULT '',
|
|
|
|
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
|
|
|
)
|
|
|
|
|
`, (err) => { if (err) return reject(err); });
|
|
|
|
|
|
2026-03-13 12:23:05 -06:00
|
|
|
db.run(`
|
|
|
|
|
CREATE TABLE IF NOT EXISTS ivanti_counts_cache (
|
|
|
|
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
|
|
|
open_count INTEGER DEFAULT 0,
|
|
|
|
|
closed_count INTEGER DEFAULT 0,
|
|
|
|
|
synced_at DATETIME
|
|
|
|
|
)
|
|
|
|
|
`, (err) => { if (err) return reject(err); });
|
|
|
|
|
|
2026-03-16 12:13:13 -06:00
|
|
|
// Idempotent column additions — errors mean the column already exists, which is fine
|
|
|
|
|
db.run(`ALTER TABLE ivanti_counts_cache ADD COLUMN fp_workflow_counts_json TEXT DEFAULT '{}'`, () => {});
|
|
|
|
|
db.run(`ALTER TABLE ivanti_counts_cache ADD COLUMN fp_id_counts_json TEXT DEFAULT '{}'`, () => {});
|
2026-03-16 11:43:57 -06:00
|
|
|
|
2026-03-13 12:23:05 -06:00
|
|
|
db.run(`
|
|
|
|
|
INSERT OR IGNORE INTO ivanti_counts_cache (id, open_count, closed_count)
|
|
|
|
|
VALUES (1, 0, 0)
|
|
|
|
|
`, (err) => { if (err) return reject(err); });
|
|
|
|
|
|
2026-03-13 15:39:37 -06:00
|
|
|
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); });
|
|
|
|
|
|
Add Reporting page with Ivanti host findings table
Backend:
- New route /api/ivanti/findings (GET cached data, POST /sync, PUT /:id/note)
- Fetches all pages of hostFinding/search filtered to NTS-AEO groups, severity 8.5-9.9, Open state
- SQLite cache (ivanti_findings_cache) stores slimmed findings across syncs
- Separate ivanti_finding_notes table persists user notes by finding ID
- Daily auto-sync on startup + 24h interval, manual sync endpoint
- Notes capped at 255 chars server-side
Frontend (ReportingPage):
- Panel 1: Metric graphs placeholder (full width, amber theme)
- Panel 2: Sortable findings table (all columns click-to-sort with ASC/DESC toggle)
- Columns: Severity (color-coded badge), Title, Host, IP, DNS, SLA, Discovered, Last Found, Source, Notes
- Notes column: inline editable input, saves on blur via PUT endpoint
- Sync button with spinner, last-synced timestamp, error banner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:56:37 -06:00
|
|
|
db.run(`
|
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_finding_notes_finding_id
|
|
|
|
|
ON ivanti_finding_notes(finding_id)
|
2026-03-13 15:39:37 -06:00
|
|
|
`, (err) => { if (err) return reject(err); });
|
|
|
|
|
|
|
|
|
|
db.run(`
|
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_finding_overrides_finding_id
|
|
|
|
|
ON ivanti_finding_overrides(finding_id)
|
2026-04-02 10:12:04 -06:00
|
|
|
`, (err) => { if (err) return reject(err); });
|
|
|
|
|
|
|
|
|
|
db.run(`
|
|
|
|
|
CREATE TABLE IF NOT EXISTS ivanti_counts_history (
|
|
|
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
|
|
|
open_count INTEGER NOT NULL,
|
|
|
|
|
closed_count INTEGER NOT NULL,
|
|
|
|
|
recorded_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
|
|
|
)
|
Add Reporting page with Ivanti host findings table
Backend:
- New route /api/ivanti/findings (GET cached data, POST /sync, PUT /:id/note)
- Fetches all pages of hostFinding/search filtered to NTS-AEO groups, severity 8.5-9.9, Open state
- SQLite cache (ivanti_findings_cache) stores slimmed findings across syncs
- Separate ivanti_finding_notes table persists user notes by finding ID
- Daily auto-sync on startup + 24h interval, manual sync endpoint
- Notes capped at 255 chars server-side
Frontend (ReportingPage):
- Panel 1: Metric graphs placeholder (full width, amber theme)
- Panel 2: Sortable findings table (all columns click-to-sort with ASC/DESC toggle)
- Columns: Severity (color-coded badge), Title, Host, IP, DNS, SLA, Discovered, Last Found, Source, Notes
- Notes column: inline editable input, saves on blur via PUT endpoint
- Sync button with spinner, last-synced timestamp, error banner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:56:37 -06:00
|
|
|
`, (err) => {
|
|
|
|
|
if (err) reject(err);
|
|
|
|
|
else resolve();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-03 15:20:04 -06:00
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Archive table init — creates archive tracking tables alongside the main cache
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
function initArchiveTables(db) {
|
|
|
|
|
return new Promise((resolve, reject) => {
|
|
|
|
|
db.serialize(() => {
|
|
|
|
|
db.run(`
|
|
|
|
|
CREATE TABLE IF NOT EXISTS ivanti_finding_archives (
|
|
|
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
|
|
|
finding_id TEXT NOT NULL UNIQUE,
|
|
|
|
|
finding_title TEXT NOT NULL DEFAULT '',
|
|
|
|
|
host_name TEXT NOT NULL DEFAULT '',
|
|
|
|
|
ip_address TEXT NOT NULL DEFAULT '',
|
2026-04-24 20:34:34 +00:00
|
|
|
current_state TEXT NOT NULL CHECK(current_state IN ('ARCHIVED','RETURNED','CLOSED','CLOSED_GONE')),
|
2026-04-03 15:20:04 -06:00
|
|
|
last_severity REAL NOT NULL DEFAULT 0,
|
|
|
|
|
first_archived_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
|
|
|
last_transition_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
|
|
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
|
|
|
)
|
|
|
|
|
`, (err) => { if (err) return reject(err); });
|
|
|
|
|
|
|
|
|
|
db.run(`
|
|
|
|
|
CREATE TABLE IF NOT EXISTS ivanti_archive_transitions (
|
|
|
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
|
|
|
archive_id INTEGER NOT NULL,
|
|
|
|
|
from_state TEXT NOT NULL,
|
|
|
|
|
to_state TEXT NOT NULL,
|
|
|
|
|
severity_at_transition REAL NOT NULL DEFAULT 0,
|
|
|
|
|
reason TEXT NOT NULL DEFAULT '',
|
|
|
|
|
transitioned_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
|
|
|
FOREIGN KEY (archive_id) REFERENCES ivanti_finding_archives(id)
|
|
|
|
|
)
|
|
|
|
|
`, (err) => { if (err) return reject(err); });
|
|
|
|
|
|
|
|
|
|
db.run(`
|
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_archive_finding_id
|
|
|
|
|
ON ivanti_finding_archives(finding_id)
|
|
|
|
|
`, (err) => { if (err) return reject(err); });
|
|
|
|
|
|
|
|
|
|
db.run(`
|
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_archive_current_state
|
|
|
|
|
ON ivanti_finding_archives(current_state)
|
|
|
|
|
`, (err) => { if (err) return reject(err); });
|
|
|
|
|
|
|
|
|
|
db.run(`
|
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_transition_archive_id
|
|
|
|
|
ON ivanti_archive_transitions(archive_id)
|
|
|
|
|
`, (err) => {
|
|
|
|
|
if (err) reject(err);
|
|
|
|
|
else resolve();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Archive detection — compare previous vs current findings to detect state changes
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
async function detectArchiveChanges(db, previousFindings, currentFindings) {
|
|
|
|
|
const previousIds = new Set(previousFindings.map(f => String(f.id)));
|
|
|
|
|
const currentIds = new Set(currentFindings.map(f => String(f.id)));
|
|
|
|
|
|
|
|
|
|
// Build lookup maps for metadata
|
|
|
|
|
const previousMap = new Map(previousFindings.map(f => [String(f.id), f]));
|
|
|
|
|
const currentMap = new Map(currentFindings.map(f => [String(f.id), f]));
|
|
|
|
|
|
|
|
|
|
// 1. Disappeared findings: in previous but not in current → ARCHIVED
|
|
|
|
|
const disappearedIds = [...previousIds].filter(id => !currentIds.has(id));
|
|
|
|
|
|
|
|
|
|
for (const id of disappearedIds) {
|
|
|
|
|
const finding = previousMap.get(id);
|
|
|
|
|
const title = finding.title || '';
|
|
|
|
|
const hostName = finding.hostName || '';
|
|
|
|
|
const ipAddress = finding.ipAddress || '';
|
|
|
|
|
const severity = typeof finding.severity === 'number' ? finding.severity : 0;
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
// Check if this finding already has an archive record
|
|
|
|
|
const existing = await dbGet(db,
|
|
|
|
|
`SELECT id, current_state FROM ivanti_finding_archives WHERE finding_id = ?`,
|
|
|
|
|
[id]
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (existing && existing.current_state === 'RETURNED') {
|
|
|
|
|
// Re-disappeared: RETURNED → ARCHIVED
|
|
|
|
|
await dbRun(db,
|
|
|
|
|
`UPDATE ivanti_finding_archives
|
|
|
|
|
SET current_state = 'ARCHIVED', last_severity = ?, last_transition_at = datetime('now')
|
|
|
|
|
WHERE id = ?`,
|
|
|
|
|
[severity, existing.id]
|
|
|
|
|
);
|
|
|
|
|
await dbRun(db,
|
|
|
|
|
`INSERT INTO ivanti_archive_transitions (archive_id, from_state, to_state, severity_at_transition, reason, transitioned_at)
|
|
|
|
|
VALUES (?, 'RETURNED', 'ARCHIVED', ?, 'severity_score_drift', datetime('now'))`,
|
|
|
|
|
[existing.id, severity]
|
|
|
|
|
);
|
|
|
|
|
console.log(`[Archive Detection] Finding ${id} re-archived (RETURNED → ARCHIVED)`);
|
|
|
|
|
} else if (!existing) {
|
|
|
|
|
// First disappearance: NONE → ARCHIVED
|
|
|
|
|
const result = await dbRun(db,
|
|
|
|
|
`INSERT INTO ivanti_finding_archives (finding_id, finding_title, host_name, ip_address, current_state, last_severity, first_archived_at, last_transition_at)
|
|
|
|
|
VALUES (?, ?, ?, ?, 'ARCHIVED', ?, datetime('now'), datetime('now'))`,
|
|
|
|
|
[id, title, hostName, ipAddress, severity]
|
|
|
|
|
);
|
|
|
|
|
const archiveId = result.lastID;
|
|
|
|
|
await dbRun(db,
|
|
|
|
|
`INSERT INTO ivanti_archive_transitions (archive_id, from_state, to_state, severity_at_transition, reason, transitioned_at)
|
|
|
|
|
VALUES (?, 'NONE', 'ARCHIVED', ?, 'severity_score_drift', datetime('now'))`,
|
|
|
|
|
[archiveId, severity]
|
|
|
|
|
);
|
|
|
|
|
console.log(`[Archive Detection] Finding ${id} archived (NONE → ARCHIVED)`);
|
|
|
|
|
}
|
|
|
|
|
// If existing state is ARCHIVED or CLOSED, no action needed
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error(`[Archive Detection] Error processing disappeared finding ${id}:`, err.message);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 2. Returned findings: in current AND has existing ARCHIVED record → RETURNED
|
|
|
|
|
const currentIdsList = [...currentIds];
|
2026-05-01 17:15:41 +00:00
|
|
|
const returnedArchiveIds = []; // track archive IDs of returned findings for classification
|
2026-04-03 15:20:04 -06:00
|
|
|
if (currentIdsList.length > 0) {
|
|
|
|
|
try {
|
|
|
|
|
const archivedRecords = await dbAll(db,
|
|
|
|
|
`SELECT id, finding_id FROM ivanti_finding_archives WHERE current_state = 'ARCHIVED'`
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
for (const record of archivedRecords) {
|
|
|
|
|
if (currentIds.has(record.finding_id)) {
|
|
|
|
|
const finding = currentMap.get(record.finding_id);
|
|
|
|
|
const severity = typeof finding.severity === 'number' ? finding.severity : 0;
|
|
|
|
|
|
|
|
|
|
await dbRun(db,
|
|
|
|
|
`UPDATE ivanti_finding_archives
|
|
|
|
|
SET current_state = 'RETURNED', last_severity = ?, last_transition_at = datetime('now')
|
|
|
|
|
WHERE id = ?`,
|
|
|
|
|
[severity, record.id]
|
|
|
|
|
);
|
|
|
|
|
await dbRun(db,
|
|
|
|
|
`INSERT INTO ivanti_archive_transitions (archive_id, from_state, to_state, severity_at_transition, reason, transitioned_at)
|
|
|
|
|
VALUES (?, 'ARCHIVED', 'RETURNED', ?, 'reappeared_in_sync', datetime('now'))`,
|
|
|
|
|
[record.id, severity]
|
|
|
|
|
);
|
2026-05-01 17:15:41 +00:00
|
|
|
returnedArchiveIds.push(record.id);
|
2026-04-03 15:20:04 -06:00
|
|
|
console.log(`[Archive Detection] Finding ${record.finding_id} returned (ARCHIVED → RETURNED)`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error('[Archive Detection] Error processing returned findings:', err.message);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-24 20:34:34 +00:00
|
|
|
// Count returned findings for anomaly summary
|
2026-05-01 17:15:41 +00:00
|
|
|
let returnedCount = returnedArchiveIds.length;
|
|
|
|
|
|
|
|
|
|
// Classify returned findings by looking up the reason they were originally archived.
|
|
|
|
|
// This tells us *why* they came back (e.g., BU reassignment back to team).
|
|
|
|
|
const returnClassification = { bu_reassignment: 0, severity_drift: 0, closed_on_platform: 0, decommissioned: 0 };
|
|
|
|
|
for (const archiveId of returnedArchiveIds) {
|
2026-04-24 20:34:34 +00:00
|
|
|
try {
|
2026-05-01 17:15:41 +00:00
|
|
|
// Find the most recent ARCHIVED transition reason for this archive record
|
|
|
|
|
const transition = await dbGet(db,
|
|
|
|
|
`SELECT reason FROM ivanti_archive_transitions
|
|
|
|
|
WHERE archive_id = ? AND to_state = 'ARCHIVED'
|
|
|
|
|
ORDER BY transitioned_at DESC LIMIT 1`,
|
|
|
|
|
[archiveId]
|
2026-04-24 20:34:34 +00:00
|
|
|
);
|
2026-05-01 17:15:41 +00:00
|
|
|
if (transition && transition.reason) {
|
|
|
|
|
// Reason format is either a plain key or "key:detail" (e.g., "bu_reassignment:SOME-BU")
|
|
|
|
|
const reasonKey = transition.reason.split(':')[0];
|
|
|
|
|
if (reasonKey in returnClassification) {
|
|
|
|
|
returnClassification[reasonKey]++;
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-04-24 20:34:34 +00:00
|
|
|
} catch (err) {
|
2026-05-01 17:15:41 +00:00
|
|
|
// Non-fatal — skip this finding's classification
|
2026-04-24 20:34:34 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
console.log(`[Archive Detection] Processed ${disappearedIds.length} disappeared, ${returnedCount} returned, checked ${currentIdsList.length} current findings against archive`);
|
2026-05-01 17:15:41 +00:00
|
|
|
if (returnedCount > 0) {
|
|
|
|
|
console.log(`[Archive Detection] Return classification:`, returnClassification);
|
|
|
|
|
}
|
2026-04-24 20:34:34 +00:00
|
|
|
|
2026-05-01 17:15:41 +00:00
|
|
|
return { disappearedIds, returnedCount, returnClassification };
|
2026-04-03 15:20:04 -06:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Closed finding detection — check archived/returned findings against Ivanti closed set
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
async function detectClosedFindings(db, closedFindingIds) {
|
|
|
|
|
if (!closedFindingIds || closedFindingIds.length === 0) return;
|
|
|
|
|
|
|
|
|
|
const closedSet = new Set(closedFindingIds.map(String));
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const records = await dbAll(db,
|
|
|
|
|
`SELECT id, finding_id, current_state, last_severity FROM ivanti_finding_archives WHERE current_state IN ('ARCHIVED', 'RETURNED')`
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
let closedCount = 0;
|
|
|
|
|
for (const record of records) {
|
|
|
|
|
if (!closedSet.has(record.finding_id)) continue;
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
await dbRun(db,
|
|
|
|
|
`UPDATE ivanti_finding_archives
|
|
|
|
|
SET current_state = 'CLOSED', last_transition_at = datetime('now')
|
|
|
|
|
WHERE id = ?`,
|
|
|
|
|
[record.id]
|
|
|
|
|
);
|
|
|
|
|
await dbRun(db,
|
|
|
|
|
`INSERT INTO ivanti_archive_transitions (archive_id, from_state, to_state, severity_at_transition, reason, transitioned_at)
|
|
|
|
|
VALUES (?, ?, 'CLOSED', ?, 'remediated_in_ivanti', datetime('now'))`,
|
|
|
|
|
[record.id, record.current_state, record.last_severity || 0]
|
|
|
|
|
);
|
|
|
|
|
closedCount++;
|
|
|
|
|
console.log(`[Archive Detection] Finding ${record.finding_id} closed (${record.current_state} → CLOSED)`);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error(`[Archive Detection] Error closing finding ${record.finding_id}:`, err.message);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
console.log(`[Archive Detection] Closed ${closedCount} findings as remediated`);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error('[Archive Detection] Error querying archive records for closed detection:', err.message);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-24 20:34:34 +00:00
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Closed-gone detection — find archive CLOSED findings that vanished from the
|
|
|
|
|
// Ivanti closed API set. These are findings we previously confirmed as closed
|
|
|
|
|
// but that no longer appear in the closed results (likely VRR rescore below
|
|
|
|
|
// the severity threshold).
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
async function detectClosedGoneFindings(db, closedFindingIds) {
|
|
|
|
|
if (!closedFindingIds) return;
|
|
|
|
|
|
|
|
|
|
const closedSet = new Set(closedFindingIds.map(String));
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
// Get all findings we previously marked as CLOSED in the archive
|
|
|
|
|
const records = await dbAll(db,
|
|
|
|
|
`SELECT id, finding_id, last_severity FROM ivanti_finding_archives WHERE current_state = 'CLOSED'`
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
let goneCount = 0;
|
|
|
|
|
for (const record of records) {
|
|
|
|
|
// If this finding is still in the closed API set, it's fine
|
|
|
|
|
if (closedSet.has(record.finding_id)) continue;
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
await dbRun(db,
|
|
|
|
|
`UPDATE ivanti_finding_archives
|
|
|
|
|
SET current_state = 'CLOSED_GONE', last_transition_at = datetime('now')
|
|
|
|
|
WHERE id = ?`,
|
|
|
|
|
[record.id]
|
|
|
|
|
);
|
|
|
|
|
await dbRun(db,
|
|
|
|
|
`INSERT INTO ivanti_archive_transitions (archive_id, from_state, to_state, severity_at_transition, reason, transitioned_at)
|
|
|
|
|
VALUES (?, 'CLOSED', 'CLOSED_GONE', ?, 'disappeared_from_closed_set', datetime('now'))`,
|
|
|
|
|
[record.id, record.last_severity || 0]
|
|
|
|
|
);
|
|
|
|
|
goneCount++;
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error(`[Archive Detection] Error marking finding ${record.finding_id} as CLOSED_GONE:`, err.message);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (goneCount > 0) {
|
|
|
|
|
console.warn(`[Archive Detection] ${goneCount} previously-closed findings disappeared from the Ivanti closed set (CLOSED → CLOSED_GONE)`);
|
|
|
|
|
}
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error('[Archive Detection] Error in closed-gone detection:', err.message);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
Add Reporting page with Ivanti host findings table
Backend:
- New route /api/ivanti/findings (GET cached data, POST /sync, PUT /:id/note)
- Fetches all pages of hostFinding/search filtered to NTS-AEO groups, severity 8.5-9.9, Open state
- SQLite cache (ivanti_findings_cache) stores slimmed findings across syncs
- Separate ivanti_finding_notes table persists user notes by finding ID
- Daily auto-sync on startup + 24h interval, manual sync endpoint
- Notes capped at 255 chars server-side
Frontend (ReportingPage):
- Panel 1: Metric graphs placeholder (full width, amber theme)
- Panel 2: Sortable findings table (all columns click-to-sort with ASC/DESC toggle)
- Columns: Severity (color-coded badge), Title, Host, IP, DNS, SLA, Discovered, Last Found, Source, Notes
- Notes column: inline editable input, saves on blur via PUT endpoint
- Sync button with spinner, last-synced timestamp, error banner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:56:37 -06:00
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Extract only the fields we need from a raw finding object
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
function extractFinding(f) {
|
2026-03-11 12:47:11 -06:00
|
|
|
// statusEmbedded.dueDate = "2026-03-06T00:00:00" — strip to date part
|
|
|
|
|
const rawDueDate = f.statusEmbedded?.dueDate || '';
|
|
|
|
|
const dueDate = rawDueDate ? rawDueDate.split('T')[0] : '';
|
|
|
|
|
|
2026-03-11 13:03:17 -06:00
|
|
|
// BU ownership: assetCustomAttributes['1550_host_1'] is an array like ["NTS-AEO-STEAM"]
|
|
|
|
|
const buOwnership = f.assetCustomAttributes?.['1550_host_1']?.[0] || '';
|
|
|
|
|
|
2026-03-11 13:17:01 -06:00
|
|
|
// CVE list: vulnerabilities.vulnInfoList[].cve
|
|
|
|
|
const cves = (f.vulnerabilities?.vulnInfoList || []).map(v => v.cve).filter(Boolean);
|
|
|
|
|
|
2026-03-11 15:36:02 -06:00
|
|
|
// Workflow: only capture FP# (False Positive) tickets — SYS# are auto-generated
|
|
|
|
|
// system workflows and not actionable for our purposes.
|
2026-03-11 14:44:53 -06:00
|
|
|
const wfDist = f.workflowDistribution || {};
|
2026-03-11 15:36:02 -06:00
|
|
|
const fpBuckets = [
|
|
|
|
|
...(wfDist.actionableWorkflows || []),
|
|
|
|
|
...(wfDist.requestedWorkflows || []),
|
|
|
|
|
...(wfDist.reworkedWorkflows || []),
|
|
|
|
|
...(wfDist.rejectedWorkflows || []),
|
|
|
|
|
...(wfDist.expiredWorkflows || []),
|
|
|
|
|
...(wfDist.approvedWorkflows || []),
|
|
|
|
|
].filter(w => (w.generatedId || '').startsWith('FP#'));
|
|
|
|
|
|
|
|
|
|
// Priority: actionable > requested > reworked > rejected > expired > approved
|
|
|
|
|
const fpEntry = fpBuckets[0] || null;
|
|
|
|
|
|
|
|
|
|
// Fallback: if no FP# in distribution, check workflowGeneratedNames directly
|
2026-03-11 14:44:53 -06:00
|
|
|
const generatedNames = f.workflowGeneratedNames || [];
|
|
|
|
|
const fpFromNames = !fpEntry
|
|
|
|
|
? generatedNames.find(n => n.startsWith('FP#')) || null
|
|
|
|
|
: null;
|
|
|
|
|
|
2026-03-11 15:36:02 -06:00
|
|
|
const workflow = fpEntry ? {
|
|
|
|
|
id: fpEntry.generatedId || '',
|
|
|
|
|
state: fpEntry.state || '',
|
|
|
|
|
type: 'FP',
|
2026-03-11 14:44:53 -06:00
|
|
|
} : fpFromNames ? {
|
|
|
|
|
id: fpFromNames,
|
|
|
|
|
state: '',
|
|
|
|
|
type: 'FP',
|
|
|
|
|
} : null;
|
|
|
|
|
|
Add Reporting page with Ivanti host findings table
Backend:
- New route /api/ivanti/findings (GET cached data, POST /sync, PUT /:id/note)
- Fetches all pages of hostFinding/search filtered to NTS-AEO groups, severity 8.5-9.9, Open state
- SQLite cache (ivanti_findings_cache) stores slimmed findings across syncs
- Separate ivanti_finding_notes table persists user notes by finding ID
- Daily auto-sync on startup + 24h interval, manual sync endpoint
- Notes capped at 255 chars server-side
Frontend (ReportingPage):
- Panel 1: Metric graphs placeholder (full width, amber theme)
- Panel 2: Sortable findings table (all columns click-to-sort with ASC/DESC toggle)
- Columns: Severity (color-coded badge), Title, Host, IP, DNS, SLA, Discovered, Last Found, Source, Notes
- Notes column: inline editable input, saves on blur via PUT endpoint
- Sync button with spinner, last-synced timestamp, error banner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:56:37 -06:00
|
|
|
return {
|
|
|
|
|
id: String(f.id),
|
Add Atlas InfoSec action plans integration
Integrate Atlas InfoSec API to manage compliance action plans directly from
the ReportingPage. Users can view, create, and update action plans for host
findings without switching to the Atlas web tool.
Backend:
- Add atlasApi.js helper with Basic Auth, TLS skip, GET/PUT/PATCH/POST
- Add atlas_action_plans_cache migration for SQLite cache table
- Add atlas.js router with sync, status, and proxy CRUD endpoints
- Mount Atlas router at /api/atlas in server.js
- Extract hostId from Ivanti host findings during sync
Frontend:
- Add AtlasBadge component (amber=needs plan, green=has plan)
- Add AtlasSlideOutPanel with plan list, create form, edit capability
- Separate active plans from inactive history in collapsible section
- Custom dark-themed plan type dropdown
- Optimistic local state shows pending plans immediately after creation
- Atlas sync button on ReportingPage toolbar
- Prepopulate finding ID in create form from clicked row
Environment:
- Add ATLAS_API_URL, ATLAS_API_USER, ATLAS_API_PASS, ATLAS_SKIP_TLS to .env.example
2026-04-23 21:52:53 +00:00
|
|
|
hostId: f.host?.hostId || null,
|
Add Reporting page with Ivanti host findings table
Backend:
- New route /api/ivanti/findings (GET cached data, POST /sync, PUT /:id/note)
- Fetches all pages of hostFinding/search filtered to NTS-AEO groups, severity 8.5-9.9, Open state
- SQLite cache (ivanti_findings_cache) stores slimmed findings across syncs
- Separate ivanti_finding_notes table persists user notes by finding ID
- Daily auto-sync on startup + 24h interval, manual sync endpoint
- Notes capped at 255 chars server-side
Frontend (ReportingPage):
- Panel 1: Metric graphs placeholder (full width, amber theme)
- Panel 2: Sortable findings table (all columns click-to-sort with ASC/DESC toggle)
- Columns: Severity (color-coded badge), Title, Host, IP, DNS, SLA, Discovered, Last Found, Source, Notes
- Notes column: inline editable input, saves on blur via PUT endpoint
- Sync button with spinner, last-synced timestamp, error banner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:56:37 -06:00
|
|
|
title: f.title || '',
|
|
|
|
|
severity: typeof f.severity === 'number' ? f.severity : parseFloat(f.severity) || 0,
|
|
|
|
|
vrrGroup: f.vrrGroup || f.severityGroup || '',
|
|
|
|
|
hostName: f.host?.hostName || '',
|
|
|
|
|
ipAddress: f.host?.ipAddress || '',
|
|
|
|
|
dns: f.dns || f.host?.fqdn || '',
|
|
|
|
|
status: f.status || '',
|
|
|
|
|
slaStatus: f.slaStatus || '',
|
2026-03-11 12:47:11 -06:00
|
|
|
dueDate,
|
2026-03-11 13:03:17 -06:00
|
|
|
lastFoundOn: f.lastFoundOn || '',
|
2026-03-11 13:17:01 -06:00
|
|
|
buOwnership,
|
2026-03-11 14:44:53 -06:00
|
|
|
cves,
|
|
|
|
|
workflow
|
Add Reporting page with Ivanti host findings table
Backend:
- New route /api/ivanti/findings (GET cached data, POST /sync, PUT /:id/note)
- Fetches all pages of hostFinding/search filtered to NTS-AEO groups, severity 8.5-9.9, Open state
- SQLite cache (ivanti_findings_cache) stores slimmed findings across syncs
- Separate ivanti_finding_notes table persists user notes by finding ID
- Daily auto-sync on startup + 24h interval, manual sync endpoint
- Notes capped at 255 chars server-side
Frontend (ReportingPage):
- Panel 1: Metric graphs placeholder (full width, amber theme)
- Panel 2: Sortable findings table (all columns click-to-sort with ASC/DESC toggle)
- Columns: Severity (color-coded badge), Title, Host, IP, DNS, SLA, Discovered, Last Found, Source, Notes
- Notes column: inline editable input, saves on blur via PUT endpoint
- Sync button with spinner, last-synced timestamp, error banner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:56:37 -06:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-13 12:23:05 -06:00
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Fetch total count of Closed findings from Ivanti (page 0, size 1)
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
async function syncClosedCount(db, openCount, apiKey, clientId, skipTls) {
|
|
|
|
|
const urlPath = `/client/${encodeURIComponent(clientId)}/hostFinding/search`;
|
|
|
|
|
try {
|
|
|
|
|
const body = {
|
|
|
|
|
filters: CLOSED_COUNT_FILTERS,
|
|
|
|
|
projection: 'internal',
|
|
|
|
|
sort: [{ field: 'severity', direction: 'ASC' }],
|
|
|
|
|
page: 0,
|
2026-04-03 15:20:04 -06:00
|
|
|
size: 100
|
2026-03-13 12:23:05 -06:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const result = await ivantiPost(urlPath, body, apiKey, skipTls);
|
|
|
|
|
if (result.status !== 200) throw new Error(`Closed count API returned status ${result.status}`);
|
|
|
|
|
|
|
|
|
|
const data = JSON.parse(result.body);
|
|
|
|
|
// RiskSense returns total in page.totalElements or page.total
|
|
|
|
|
const closedCount = data.page?.totalElements ?? data.page?.total ?? 0;
|
2026-04-03 15:20:04 -06:00
|
|
|
const totalPages = data.page?.totalPages || 1;
|
|
|
|
|
|
|
|
|
|
// Collect closed finding IDs for archive detection
|
|
|
|
|
const closedFindingIds = [];
|
|
|
|
|
const firstPageFindings = data._embedded?.hostFindings || [];
|
|
|
|
|
firstPageFindings.forEach(f => { if (f.id) closedFindingIds.push(String(f.id)); });
|
|
|
|
|
|
|
|
|
|
// Fetch remaining pages to collect all closed finding IDs
|
|
|
|
|
for (let pg = 1; pg < totalPages; pg++) {
|
|
|
|
|
try {
|
|
|
|
|
const pageBody = { ...body, page: pg };
|
|
|
|
|
const pageResult = await ivantiPost(urlPath, pageBody, apiKey, skipTls);
|
|
|
|
|
if (pageResult.status !== 200) break;
|
|
|
|
|
const pageData = JSON.parse(pageResult.body);
|
|
|
|
|
const pageFindings = pageData._embedded?.hostFindings || [];
|
|
|
|
|
pageFindings.forEach(f => { if (f.id) closedFindingIds.push(String(f.id)); });
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error(`[Ivanti Findings] Failed to fetch closed findings page ${pg}:`, err.message);
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-03-13 12:23:05 -06:00
|
|
|
|
|
|
|
|
await dbRun(db,
|
|
|
|
|
`UPDATE ivanti_counts_cache SET open_count=?, closed_count=?, synced_at=datetime('now') WHERE id=1`,
|
|
|
|
|
[openCount, closedCount]
|
|
|
|
|
);
|
2026-04-02 10:12:04 -06:00
|
|
|
|
2026-04-24 20:34:34 +00:00
|
|
|
// Drift guard — if the new total (open+closed) drops by more than 50%
|
|
|
|
|
// compared to the most recent history snapshot, skip writing to history.
|
|
|
|
|
// This prevents partial API responses from corrupting the trend chart.
|
|
|
|
|
const newTotal = openCount + closedCount;
|
|
|
|
|
let skipHistory = false;
|
|
|
|
|
try {
|
|
|
|
|
const prev = await dbGet(db,
|
|
|
|
|
`SELECT open_count, closed_count FROM ivanti_counts_history ORDER BY recorded_at DESC LIMIT 1`
|
|
|
|
|
);
|
|
|
|
|
if (prev) {
|
|
|
|
|
const prevTotal = (prev.open_count || 0) + (prev.closed_count || 0);
|
|
|
|
|
if (prevTotal > 0 && newTotal < prevTotal * 0.5) {
|
|
|
|
|
console.warn(`[Ivanti Findings] Drift guard triggered — new total ${newTotal} is <50% of previous ${prevTotal}. Skipping history write.`);
|
|
|
|
|
skipHistory = true;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error('[Ivanti Findings] Drift guard check failed (non-fatal):', err.message);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-02 10:12:04 -06:00
|
|
|
// Append a snapshot to history — every sync is stored; the history
|
|
|
|
|
// endpoint aggregates to last-per-day at query time (Option B).
|
2026-04-24 20:34:34 +00:00
|
|
|
if (!skipHistory) {
|
|
|
|
|
await dbRun(db,
|
|
|
|
|
`INSERT INTO ivanti_counts_history (open_count, closed_count) VALUES (?, ?)`,
|
|
|
|
|
[openCount, closedCount]
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-04-02 10:12:04 -06:00
|
|
|
|
2026-04-24 20:34:34 +00:00
|
|
|
console.log(`[Ivanti Findings] Counts updated — open: ${openCount}, closed: ${closedCount}${skipHistory ? ' (history write skipped — drift guard)' : ''}`);
|
2026-04-03 15:20:04 -06:00
|
|
|
|
|
|
|
|
// Detect closed findings in the archive — wrap in try/catch so errors don't break sync
|
|
|
|
|
try {
|
|
|
|
|
await detectClosedFindings(db, closedFindingIds);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error('[Ivanti Findings] Closed finding archive detection failed (non-fatal):', err.message);
|
|
|
|
|
}
|
2026-04-24 20:34:34 +00:00
|
|
|
|
|
|
|
|
// Detect findings that vanished from the closed set — CLOSED → CLOSED_GONE
|
|
|
|
|
try {
|
|
|
|
|
await detectClosedGoneFindings(db, closedFindingIds);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error('[Ivanti Findings] Closed-gone detection failed (non-fatal):', err.message);
|
|
|
|
|
}
|
2026-03-13 12:23:05 -06:00
|
|
|
} catch (err) {
|
|
|
|
|
console.error('[Ivanti Findings] Failed to fetch closed count:', err.message);
|
|
|
|
|
// Still update open count so it stays in sync; leave closed_count as-is
|
|
|
|
|
await dbRun(db,
|
|
|
|
|
`UPDATE ivanti_counts_cache SET open_count=?, synced_at=datetime('now') WHERE id=1`,
|
|
|
|
|
[openCount]
|
|
|
|
|
).catch(() => {});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-16 11:43:57 -06:00
|
|
|
// ---------------------------------------------------------------------------
|
2026-03-16 12:13:13 -06:00
|
|
|
// Extract FP workflow id+state from a raw (un-extracted) finding
|
|
|
|
|
// Returns { id, state } or null if no FP# workflow present.
|
2026-03-16 11:43:57 -06:00
|
|
|
// ---------------------------------------------------------------------------
|
2026-03-16 12:13:13 -06:00
|
|
|
function extractFPWorkflow(f) {
|
2026-03-16 11:43:57 -06:00
|
|
|
const wfDist = f.workflowDistribution || {};
|
|
|
|
|
const fpBuckets = [
|
|
|
|
|
...(wfDist.actionableWorkflows || []),
|
|
|
|
|
...(wfDist.requestedWorkflows || []),
|
|
|
|
|
...(wfDist.reworkedWorkflows || []),
|
|
|
|
|
...(wfDist.rejectedWorkflows || []),
|
|
|
|
|
...(wfDist.expiredWorkflows || []),
|
|
|
|
|
...(wfDist.approvedWorkflows || []),
|
|
|
|
|
].filter(w => (w.generatedId || '').startsWith('FP#'));
|
|
|
|
|
const fpEntry = fpBuckets[0] || null;
|
|
|
|
|
if (!fpEntry) return null;
|
2026-03-16 12:13:13 -06:00
|
|
|
return { id: fpEntry.generatedId || '', state: fpEntry.state || 'Unknown' };
|
2026-03-16 11:43:57 -06:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
2026-03-16 12:13:13 -06:00
|
|
|
// Sync FP stats across ALL findings (open + closed).
|
|
|
|
|
//
|
|
|
|
|
// Produces two separate counts:
|
|
|
|
|
// findingCounts — number of *findings* per FP workflow state
|
|
|
|
|
// idCounts — number of *unique FP# ticket IDs* per state
|
|
|
|
|
// (one FP# can cover many findings; this chart counts tickets)
|
|
|
|
|
//
|
|
|
|
|
// Open findings come from the already-extracted allFindings array.
|
|
|
|
|
// Closed findings are swept page-by-page to catch Approved FPs.
|
2026-03-16 11:43:57 -06:00
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
async function syncFPWorkflowCounts(db, openFindings, apiKey, clientId, skipTls) {
|
2026-03-16 12:13:13 -06:00
|
|
|
const findingCounts = {}; // state → # findings
|
|
|
|
|
const fpIdMap = {}; // FP# id → state (deduplicates across findings)
|
|
|
|
|
|
|
|
|
|
// Seed from open findings (already extracted, have workflow.id + workflow.state)
|
2026-03-16 11:43:57 -06:00
|
|
|
openFindings.forEach(f => {
|
|
|
|
|
if (!f.workflow) return;
|
|
|
|
|
const state = f.workflow.state || 'Unknown';
|
2026-03-16 12:13:13 -06:00
|
|
|
const id = f.workflow.id || '';
|
|
|
|
|
findingCounts[state] = (findingCounts[state] || 0) + 1;
|
|
|
|
|
if (id && !fpIdMap[id]) fpIdMap[id] = state;
|
2026-03-16 11:43:57 -06:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Sweep closed findings to pick up Approved (and any other closed FP states)
|
|
|
|
|
const urlPath = `/client/${encodeURIComponent(clientId)}/hostFinding/search`;
|
|
|
|
|
let page = 0;
|
|
|
|
|
let totalPages = 1;
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
do {
|
|
|
|
|
const body = {
|
|
|
|
|
filters: CLOSED_COUNT_FILTERS,
|
|
|
|
|
projection: 'internal',
|
|
|
|
|
sort: [{ field: 'severity', direction: 'ASC' }],
|
|
|
|
|
page,
|
|
|
|
|
size: 100
|
|
|
|
|
};
|
|
|
|
|
const result = await ivantiPost(urlPath, body, apiKey, skipTls);
|
|
|
|
|
if (result.status !== 200) {
|
|
|
|
|
console.warn(`[Ivanti Findings] FP workflow counts: closed findings page ${page} returned ${result.status} — stopping sweep`);
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
const data = JSON.parse(result.body);
|
|
|
|
|
totalPages = data.page?.totalPages || 1;
|
|
|
|
|
const findings = data._embedded?.hostFindings || [];
|
|
|
|
|
findings.forEach(f => {
|
2026-03-16 12:13:13 -06:00
|
|
|
const wf = extractFPWorkflow(f);
|
|
|
|
|
if (!wf) return;
|
|
|
|
|
findingCounts[wf.state] = (findingCounts[wf.state] || 0) + 1;
|
|
|
|
|
if (wf.id && !fpIdMap[wf.id]) fpIdMap[wf.id] = wf.state;
|
2026-03-16 11:43:57 -06:00
|
|
|
});
|
|
|
|
|
console.log(`[Ivanti Findings] FP workflow counts: closed page ${page + 1}/${totalPages}`);
|
|
|
|
|
page++;
|
|
|
|
|
} while (page < totalPages);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error('[Ivanti Findings] FP workflow counts: closed sweep failed:', err.message);
|
2026-03-16 12:13:13 -06:00
|
|
|
// Fall through — store whatever we have from open findings
|
2026-03-16 11:43:57 -06:00
|
|
|
}
|
|
|
|
|
|
2026-03-16 12:13:13 -06:00
|
|
|
// Aggregate unique FP# IDs by state
|
|
|
|
|
const idCounts = {};
|
|
|
|
|
Object.values(fpIdMap).forEach(state => {
|
|
|
|
|
idCounts[state] = (idCounts[state] || 0) + 1;
|
|
|
|
|
});
|
|
|
|
|
|
2026-03-16 11:43:57 -06:00
|
|
|
await dbRun(db,
|
2026-03-16 12:13:13 -06:00
|
|
|
`UPDATE ivanti_counts_cache SET fp_workflow_counts_json=?, fp_id_counts_json=? WHERE id=1`,
|
|
|
|
|
[JSON.stringify(findingCounts), JSON.stringify(idCounts)]
|
2026-03-16 11:43:57 -06:00
|
|
|
).catch(e => console.error('[Ivanti Findings] Failed to store FP workflow counts:', e.message));
|
|
|
|
|
|
2026-03-16 12:13:13 -06:00
|
|
|
console.log('[Ivanti Findings] FP finding counts:', findingCounts);
|
|
|
|
|
console.log('[Ivanti Findings] FP workflow ID counts:', idCounts);
|
2026-03-16 11:43:57 -06:00
|
|
|
}
|
|
|
|
|
|
Add Reporting page with Ivanti host findings table
Backend:
- New route /api/ivanti/findings (GET cached data, POST /sync, PUT /:id/note)
- Fetches all pages of hostFinding/search filtered to NTS-AEO groups, severity 8.5-9.9, Open state
- SQLite cache (ivanti_findings_cache) stores slimmed findings across syncs
- Separate ivanti_finding_notes table persists user notes by finding ID
- Daily auto-sync on startup + 24h interval, manual sync endpoint
- Notes capped at 255 chars server-side
Frontend (ReportingPage):
- Panel 1: Metric graphs placeholder (full width, amber theme)
- Panel 2: Sortable findings table (all columns click-to-sort with ASC/DESC toggle)
- Columns: Severity (color-coded badge), Title, Host, IP, DNS, SLA, Discovered, Last Found, Source, Notes
- Notes column: inline editable input, saves on blur via PUT endpoint
- Sync button with spinner, last-synced timestamp, error banner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:56:37 -06:00
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Core sync — fetches ALL pages, stores slimmed findings in SQLite
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
async function syncFindings(db) {
|
|
|
|
|
const apiKey = process.env.IVANTI_API_KEY;
|
|
|
|
|
const clientId = process.env.IVANTI_CLIENT_ID || '1550';
|
|
|
|
|
const skipTls = process.env.IVANTI_SKIP_TLS === 'true';
|
|
|
|
|
|
|
|
|
|
if (!apiKey) {
|
|
|
|
|
const errMsg = 'IVANTI_API_KEY not set in .env — skipping sync';
|
|
|
|
|
console.warn('[Ivanti Findings]', errMsg);
|
|
|
|
|
await dbRun(db, `UPDATE ivanti_findings_cache SET sync_status='error', error_message=?, synced_at=datetime('now') WHERE id=1`, [errMsg]);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
console.log('[Ivanti Findings] Starting sync...');
|
|
|
|
|
|
|
|
|
|
const urlPath = `/client/${encodeURIComponent(clientId)}/hostFinding/search`;
|
|
|
|
|
let allFindings = [];
|
|
|
|
|
let page = 0;
|
|
|
|
|
let totalPages = 1;
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
do {
|
|
|
|
|
const body = {
|
|
|
|
|
filters: FINDINGS_FILTERS,
|
|
|
|
|
projection: 'internal',
|
|
|
|
|
sort: [{ field: 'severity', direction: 'ASC' }],
|
|
|
|
|
page,
|
|
|
|
|
size: 100
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const result = await ivantiPost(urlPath, body, apiKey, skipTls);
|
|
|
|
|
|
|
|
|
|
if (result.status === 401) throw new Error('Invalid or missing API key (401)');
|
|
|
|
|
if (result.status === 419) throw new Error('Insufficient privileges (419) — API key lacks hostFinding access');
|
|
|
|
|
if (result.status === 429) throw new Error('Rate limited (429) — try again later');
|
|
|
|
|
if (result.status !== 200) throw new Error(`Ivanti API returned status ${result.status}`);
|
|
|
|
|
|
|
|
|
|
const data = JSON.parse(result.body);
|
|
|
|
|
totalPages = data.page?.totalPages || 1;
|
|
|
|
|
const findings = data._embedded?.hostFindings || [];
|
|
|
|
|
allFindings = allFindings.concat(findings.map(extractFinding));
|
|
|
|
|
|
|
|
|
|
console.log(`[Ivanti Findings] Page ${page + 1}/${totalPages} — ${allFindings.length} findings so far`);
|
|
|
|
|
page++;
|
|
|
|
|
} while (page < totalPages);
|
|
|
|
|
|
2026-04-03 15:20:04 -06:00
|
|
|
// Read previous findings BEFORE updating the cache (they'll be overwritten)
|
|
|
|
|
let previousFindings = [];
|
|
|
|
|
try {
|
|
|
|
|
const state = await readState(db);
|
|
|
|
|
previousFindings = state.findings || [];
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error('[Ivanti Findings] Failed to read previous findings for archive detection:', err.message);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-24 20:34:34 +00:00
|
|
|
// Per-finding BU comparison — detect BU changes across syncs (Task 5.1)
|
|
|
|
|
try {
|
|
|
|
|
const previousMap = new Map(previousFindings.map(f => [String(f.id), f]));
|
|
|
|
|
for (const finding of allFindings) {
|
|
|
|
|
try {
|
|
|
|
|
const prev = previousMap.get(String(finding.id));
|
|
|
|
|
if (prev && prev.buOwnership && finding.buOwnership && prev.buOwnership !== finding.buOwnership) {
|
|
|
|
|
await dbRun(db,
|
|
|
|
|
`INSERT INTO ivanti_finding_bu_history (finding_id, finding_title, host_name, previous_bu, new_bu, detected_at)
|
|
|
|
|
VALUES (?, ?, ?, ?, ?, datetime('now'))`,
|
|
|
|
|
[String(finding.id), finding.title || '', finding.hostName || '', prev.buOwnership, finding.buOwnership]
|
|
|
|
|
);
|
|
|
|
|
console.log(`[BU Tracking] Finding ${finding.id} BU changed: ${prev.buOwnership} → ${finding.buOwnership}`);
|
|
|
|
|
}
|
|
|
|
|
// First-time findings (no prev entry) — store BU without recording a change event
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error(`[BU Tracking] Error recording BU change for finding ${finding.id}:`, err.message);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error('[BU Tracking] BU comparison failed (non-fatal):', err.message);
|
|
|
|
|
}
|
|
|
|
|
|
Add Reporting page with Ivanti host findings table
Backend:
- New route /api/ivanti/findings (GET cached data, POST /sync, PUT /:id/note)
- Fetches all pages of hostFinding/search filtered to NTS-AEO groups, severity 8.5-9.9, Open state
- SQLite cache (ivanti_findings_cache) stores slimmed findings across syncs
- Separate ivanti_finding_notes table persists user notes by finding ID
- Daily auto-sync on startup + 24h interval, manual sync endpoint
- Notes capped at 255 chars server-side
Frontend (ReportingPage):
- Panel 1: Metric graphs placeholder (full width, amber theme)
- Panel 2: Sortable findings table (all columns click-to-sort with ASC/DESC toggle)
- Columns: Severity (color-coded badge), Title, Host, IP, DNS, SLA, Discovered, Last Found, Source, Notes
- Notes column: inline editable input, saves on blur via PUT endpoint
- Sync button with spinner, last-synced timestamp, error banner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:56:37 -06:00
|
|
|
await dbRun(db,
|
|
|
|
|
`UPDATE ivanti_findings_cache SET total=?, findings_json=?, synced_at=datetime('now'), sync_status='success', error_message=NULL WHERE id=1`,
|
|
|
|
|
[allFindings.length, JSON.stringify(allFindings)]
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
console.log(`[Ivanti Findings] Sync complete — ${allFindings.length} findings`);
|
2026-04-03 15:20:04 -06:00
|
|
|
|
|
|
|
|
// Archive detection — compare previous vs current to detect disappeared/returned findings
|
|
|
|
|
// Only runs after a successful sync (skipped on error per requirement 1.5)
|
2026-05-01 17:15:41 +00:00
|
|
|
let archiveResult = { disappearedIds: [], returnedCount: 0, returnClassification: {} };
|
2026-04-03 15:20:04 -06:00
|
|
|
try {
|
2026-05-01 17:15:41 +00:00
|
|
|
archiveResult = await detectArchiveChanges(db, previousFindings, allFindings) || { disappearedIds: [], returnedCount: 0, returnClassification: {} };
|
2026-04-03 15:20:04 -06:00
|
|
|
} catch (err) {
|
|
|
|
|
console.error('[Ivanti Findings] Archive detection failed (non-fatal):', err.message);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-24 20:34:34 +00:00
|
|
|
// Read previous counts BEFORE syncClosedCount updates them — needed for anomaly deltas
|
|
|
|
|
let previousOpenCount = 0;
|
|
|
|
|
let previousClosedCount = 0;
|
|
|
|
|
try {
|
|
|
|
|
const prevCounts = await dbGet(db,
|
|
|
|
|
`SELECT open_count, closed_count FROM ivanti_counts_cache WHERE id = 1`
|
|
|
|
|
);
|
|
|
|
|
if (prevCounts) {
|
|
|
|
|
previousOpenCount = prevCounts.open_count || 0;
|
|
|
|
|
previousClosedCount = prevCounts.closed_count || 0;
|
|
|
|
|
}
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error('[Ivanti Findings] Failed to read previous counts for anomaly summary (non-fatal):', err.message);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-13 12:23:05 -06:00
|
|
|
await syncClosedCount(db, allFindings.length, apiKey, clientId, skipTls);
|
2026-03-16 11:43:57 -06:00
|
|
|
await syncFPWorkflowCounts(db, allFindings, apiKey, clientId, skipTls);
|
2026-04-24 20:34:34 +00:00
|
|
|
|
|
|
|
|
// Post-sync: BU drift checker for newly archived findings
|
|
|
|
|
let classificationBreakdown = { bu_reassignment: 0, severity_drift: 0, closed_on_platform: 0, decommissioned: 0 };
|
|
|
|
|
try {
|
|
|
|
|
classificationBreakdown = await runBUDriftChecker(db, archiveResult.disappearedIds, apiKey, clientId, skipTls);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error('[Ivanti Findings] BU drift checker failed (non-fatal):', err.message);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Post-sync: Compute and store anomaly summary
|
|
|
|
|
try {
|
|
|
|
|
const currentCounts = await dbGet(db,
|
|
|
|
|
`SELECT open_count, closed_count FROM ivanti_counts_cache WHERE id = 1`
|
|
|
|
|
);
|
|
|
|
|
const currentOpenCount = currentCounts?.open_count || 0;
|
|
|
|
|
const currentClosedCount = currentCounts?.closed_count || 0;
|
|
|
|
|
const openCountDelta = currentOpenCount - previousOpenCount;
|
|
|
|
|
const closedCountDelta = currentClosedCount - previousClosedCount;
|
|
|
|
|
|
|
|
|
|
await computeAnomalySummary(
|
|
|
|
|
db,
|
|
|
|
|
openCountDelta,
|
|
|
|
|
closedCountDelta,
|
|
|
|
|
archiveResult.disappearedIds.length,
|
|
|
|
|
archiveResult.returnedCount,
|
2026-05-01 17:15:41 +00:00
|
|
|
classificationBreakdown,
|
|
|
|
|
archiveResult.returnClassification || {}
|
2026-04-24 20:34:34 +00:00
|
|
|
);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error('[Ivanti Findings] Anomaly summary failed (non-fatal):', err.message);
|
|
|
|
|
}
|
Add Reporting page with Ivanti host findings table
Backend:
- New route /api/ivanti/findings (GET cached data, POST /sync, PUT /:id/note)
- Fetches all pages of hostFinding/search filtered to NTS-AEO groups, severity 8.5-9.9, Open state
- SQLite cache (ivanti_findings_cache) stores slimmed findings across syncs
- Separate ivanti_finding_notes table persists user notes by finding ID
- Daily auto-sync on startup + 24h interval, manual sync endpoint
- Notes capped at 255 chars server-side
Frontend (ReportingPage):
- Panel 1: Metric graphs placeholder (full width, amber theme)
- Panel 2: Sortable findings table (all columns click-to-sort with ASC/DESC toggle)
- Columns: Severity (color-coded badge), Title, Host, IP, DNS, SLA, Discovered, Last Found, Source, Notes
- Notes column: inline editable input, saves on blur via PUT endpoint
- Sync button with spinner, last-synced timestamp, error banner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:56:37 -06:00
|
|
|
} catch (err) {
|
|
|
|
|
const msg = err.message || 'Unknown error';
|
|
|
|
|
console.error('[Ivanti Findings] Sync failed:', msg);
|
2026-04-03 15:20:04 -06:00
|
|
|
// Archive detection is intentionally skipped on sync error (requirement 1.5)
|
Add Reporting page with Ivanti host findings table
Backend:
- New route /api/ivanti/findings (GET cached data, POST /sync, PUT /:id/note)
- Fetches all pages of hostFinding/search filtered to NTS-AEO groups, severity 8.5-9.9, Open state
- SQLite cache (ivanti_findings_cache) stores slimmed findings across syncs
- Separate ivanti_finding_notes table persists user notes by finding ID
- Daily auto-sync on startup + 24h interval, manual sync endpoint
- Notes capped at 255 chars server-side
Frontend (ReportingPage):
- Panel 1: Metric graphs placeholder (full width, amber theme)
- Panel 2: Sortable findings table (all columns click-to-sort with ASC/DESC toggle)
- Columns: Severity (color-coded badge), Title, Host, IP, DNS, SLA, Discovered, Last Found, Source, Notes
- Notes column: inline editable input, saves on blur via PUT endpoint
- Sync button with spinner, last-synced timestamp, error banner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:56:37 -06:00
|
|
|
await dbRun(db, `UPDATE ivanti_findings_cache SET sync_status='error', error_message=?, synced_at=datetime('now') WHERE id=1`, [msg]);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Scheduler
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
function scheduleSync(db) {
|
|
|
|
|
db.get('SELECT synced_at FROM ivanti_findings_cache WHERE id = 1', (err, row) => {
|
|
|
|
|
if (err || !row || !row.synced_at) {
|
|
|
|
|
syncFindings(db);
|
|
|
|
|
} else {
|
|
|
|
|
const lastSync = new Date(row.synced_at.replace(' ', 'T') + 'Z');
|
|
|
|
|
const hoursSince = (Date.now() - lastSync.getTime()) / (1000 * 60 * 60);
|
|
|
|
|
if (hoursSince >= 24) {
|
|
|
|
|
syncFindings(db);
|
|
|
|
|
} else {
|
|
|
|
|
console.log(`[Ivanti Findings] Last sync ${hoursSince.toFixed(1)}h ago — next auto-sync in ${(24 - hoursSince).toFixed(1)}h`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
setInterval(() => syncFindings(db), SYNC_INTERVAL_MS);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// DB helpers
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
function dbRun(db, sql, params = []) {
|
|
|
|
|
return new Promise((resolve, reject) => {
|
2026-04-03 15:20:04 -06:00
|
|
|
db.run(sql, params, function (err) { if (err) reject(err); else resolve(this); });
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function dbGet(db, sql, params = []) {
|
|
|
|
|
return new Promise((resolve, reject) => {
|
|
|
|
|
db.get(sql, params, (err, row) => { if (err) reject(err); else resolve(row); });
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function dbAll(db, sql, params = []) {
|
|
|
|
|
return new Promise((resolve, reject) => {
|
|
|
|
|
db.all(sql, params, (err, rows) => { if (err) reject(err); else resolve(rows || []); });
|
Add Reporting page with Ivanti host findings table
Backend:
- New route /api/ivanti/findings (GET cached data, POST /sync, PUT /:id/note)
- Fetches all pages of hostFinding/search filtered to NTS-AEO groups, severity 8.5-9.9, Open state
- SQLite cache (ivanti_findings_cache) stores slimmed findings across syncs
- Separate ivanti_finding_notes table persists user notes by finding ID
- Daily auto-sync on startup + 24h interval, manual sync endpoint
- Notes capped at 255 chars server-side
Frontend (ReportingPage):
- Panel 1: Metric graphs placeholder (full width, amber theme)
- Panel 2: Sortable findings table (all columns click-to-sort with ASC/DESC toggle)
- Columns: Severity (color-coded badge), Title, Host, IP, DNS, SLA, Discovered, Last Found, Source, Notes
- Notes column: inline editable input, saves on blur via PUT endpoint
- Sync button with spinner, last-synced timestamp, error banner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:56:37 -06:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function readState(db) {
|
|
|
|
|
return new Promise((resolve, reject) => {
|
|
|
|
|
db.get(
|
|
|
|
|
'SELECT total, findings_json, synced_at, sync_status, error_message FROM ivanti_findings_cache WHERE id = 1',
|
|
|
|
|
(err, row) => {
|
|
|
|
|
if (err) return reject(err);
|
|
|
|
|
if (!row) return resolve({ total: 0, findings: [], synced_at: null, sync_status: 'never', error_message: null });
|
|
|
|
|
let findings = [];
|
|
|
|
|
try { findings = JSON.parse(row.findings_json || '[]'); } catch (_) { /* leave empty */ }
|
|
|
|
|
resolve({ total: row.total || 0, findings, synced_at: row.synced_at, sync_status: row.sync_status, error_message: row.error_message });
|
|
|
|
|
}
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function readNotes(db) {
|
|
|
|
|
return new Promise((resolve, reject) => {
|
|
|
|
|
db.all('SELECT finding_id, note FROM ivanti_finding_notes', (err, rows) => {
|
|
|
|
|
if (err) return reject(err);
|
|
|
|
|
const map = {};
|
|
|
|
|
(rows || []).forEach((r) => { map[r.finding_id] = r.note; });
|
|
|
|
|
resolve(map);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-13 12:23:05 -06:00
|
|
|
function readCounts(db) {
|
|
|
|
|
return new Promise((resolve, reject) => {
|
|
|
|
|
db.get(
|
|
|
|
|
'SELECT open_count, closed_count, synced_at FROM ivanti_counts_cache WHERE id = 1',
|
|
|
|
|
(err, row) => {
|
|
|
|
|
if (err) return reject(err);
|
|
|
|
|
resolve({
|
|
|
|
|
open: row?.open_count ?? 0,
|
|
|
|
|
closed: row?.closed_count ?? 0,
|
|
|
|
|
synced_at: row?.synced_at ?? null,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-13 15:39:37 -06:00
|
|
|
// 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);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
Add Reporting page with Ivanti host findings table
Backend:
- New route /api/ivanti/findings (GET cached data, POST /sync, PUT /:id/note)
- Fetches all pages of hostFinding/search filtered to NTS-AEO groups, severity 8.5-9.9, Open state
- SQLite cache (ivanti_findings_cache) stores slimmed findings across syncs
- Separate ivanti_finding_notes table persists user notes by finding ID
- Daily auto-sync on startup + 24h interval, manual sync endpoint
- Notes capped at 255 chars server-side
Frontend (ReportingPage):
- Panel 1: Metric graphs placeholder (full width, amber theme)
- Panel 2: Sortable findings table (all columns click-to-sort with ASC/DESC toggle)
- Columns: Severity (color-coded badge), Title, Host, IP, DNS, SLA, Discovered, Last Found, Source, Notes
- Notes column: inline editable input, saves on blur via PUT endpoint
- Sync button with spinner, last-synced timestamp, error banner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:56:37 -06:00
|
|
|
async function readStateWithNotes(db) {
|
2026-03-13 15:39:37 -06:00
|
|
|
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] || {},
|
|
|
|
|
}));
|
Add Reporting page with Ivanti host findings table
Backend:
- New route /api/ivanti/findings (GET cached data, POST /sync, PUT /:id/note)
- Fetches all pages of hostFinding/search filtered to NTS-AEO groups, severity 8.5-9.9, Open state
- SQLite cache (ivanti_findings_cache) stores slimmed findings across syncs
- Separate ivanti_finding_notes table persists user notes by finding ID
- Daily auto-sync on startup + 24h interval, manual sync endpoint
- Notes capped at 255 chars server-side
Frontend (ReportingPage):
- Panel 1: Metric graphs placeholder (full width, amber theme)
- Panel 2: Sortable findings table (all columns click-to-sort with ASC/DESC toggle)
- Columns: Severity (color-coded badge), Title, Host, IP, DNS, SLA, Discovered, Last Found, Source, Notes
- Notes column: inline editable input, saves on blur via PUT endpoint
- Sync button with spinner, last-synced timestamp, error banner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:56:37 -06:00
|
|
|
return state;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-24 20:34:34 +00:00
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// BU Drift Checker — post-sync classification of newly archived findings
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
const EXPECTED_BUS = new Set(['NTS-AEO-ACCESS-ENG', 'NTS-AEO-STEAM']);
|
|
|
|
|
|
|
|
|
|
async function runBUDriftChecker(db, newlyArchivedIds, apiKey, clientId, skipTls) {
|
|
|
|
|
const summary = { bu_reassignment: 0, severity_drift: 0, closed_on_platform: 0, decommissioned: 0 };
|
|
|
|
|
|
|
|
|
|
if (!newlyArchivedIds || newlyArchivedIds.length === 0) return summary;
|
|
|
|
|
|
|
|
|
|
const urlPath = `/client/${encodeURIComponent(clientId)}/hostFinding/search`;
|
|
|
|
|
const chunkSize = 50;
|
|
|
|
|
|
|
|
|
|
// Collect all API results across batches
|
|
|
|
|
const foundMap = new Map();
|
|
|
|
|
|
|
|
|
|
for (let i = 0; i < newlyArchivedIds.length; i += chunkSize) {
|
|
|
|
|
const chunk = newlyArchivedIds.slice(i, i + chunkSize);
|
|
|
|
|
const idList = chunk.join(',');
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const filters = [
|
|
|
|
|
{
|
|
|
|
|
field: 'id',
|
|
|
|
|
exclusive: false,
|
|
|
|
|
operator: 'IN',
|
|
|
|
|
orWithPrevious: false,
|
|
|
|
|
implicitFilters: [],
|
|
|
|
|
value: idList,
|
|
|
|
|
caseSensitive: false
|
|
|
|
|
}
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
let page = 0;
|
|
|
|
|
let totalPages = 1;
|
|
|
|
|
|
|
|
|
|
do {
|
|
|
|
|
const body = {
|
|
|
|
|
filters,
|
|
|
|
|
projection: 'internal',
|
|
|
|
|
sort: [{ field: 'severity', direction: 'ASC' }],
|
|
|
|
|
page,
|
|
|
|
|
size: 100
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const result = await ivantiPost(urlPath, body, apiKey, skipTls);
|
|
|
|
|
if (result.status !== 200) {
|
|
|
|
|
console.error(`[BU Drift Checker] API returned status ${result.status} for batch starting at index ${i}`);
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const data = JSON.parse(result.body);
|
|
|
|
|
totalPages = data.page?.totalPages || 1;
|
|
|
|
|
const findings = data._embedded?.hostFindings || [];
|
|
|
|
|
|
|
|
|
|
for (const f of findings) {
|
|
|
|
|
const bu = f.assetCustomAttributes?.['1550_host_1']?.[0] || 'UNKNOWN';
|
|
|
|
|
const severity = typeof f.severity === 'number' ? f.severity : parseFloat(f.severity) || 0;
|
|
|
|
|
const state = f.status || f.generic_state || '';
|
|
|
|
|
foundMap.set(String(f.id), { bu, severity, state });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
page++;
|
|
|
|
|
} while (page < totalPages);
|
|
|
|
|
|
|
|
|
|
console.log(`[BU Drift Checker] Batch ${Math.floor(i / chunkSize) + 1}: queried ${chunk.length} IDs, found ${foundMap.size} so far`);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error(`[BU Drift Checker] Error querying batch at index ${i}:`, err.message);
|
|
|
|
|
// Skip failed batch, continue with remaining
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Classify each archived finding and update the archive transition reason
|
|
|
|
|
for (const id of newlyArchivedIds) {
|
|
|
|
|
const found = foundMap.get(id);
|
|
|
|
|
let classification;
|
|
|
|
|
let reason;
|
|
|
|
|
|
|
|
|
|
if (!found) {
|
|
|
|
|
classification = 'decommissioned';
|
|
|
|
|
reason = 'decommissioned';
|
|
|
|
|
} else if (!EXPECTED_BUS.has(found.bu)) {
|
|
|
|
|
classification = 'bu_reassignment';
|
|
|
|
|
reason = `bu_reassignment:${found.bu}`;
|
|
|
|
|
} else if (found.severity < 8.5) {
|
|
|
|
|
classification = 'severity_drift';
|
|
|
|
|
reason = `severity_drift:${found.severity}`;
|
|
|
|
|
} else if (found.state === 'Closed') {
|
|
|
|
|
classification = 'closed_on_platform';
|
|
|
|
|
reason = 'closed_on_platform';
|
|
|
|
|
} else {
|
|
|
|
|
// BU matches, severity >= 8.5, not closed — unexpected, leave as default
|
|
|
|
|
classification = 'decommissioned';
|
|
|
|
|
reason = 'decommissioned';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
summary[classification] = (summary[classification] || 0) + 1;
|
|
|
|
|
|
|
|
|
|
// Update the most recent archive transition reason for this finding
|
|
|
|
|
try {
|
|
|
|
|
const archive = await dbGet(db,
|
|
|
|
|
`SELECT id FROM ivanti_finding_archives WHERE finding_id = ?`,
|
|
|
|
|
[id]
|
|
|
|
|
);
|
|
|
|
|
if (archive) {
|
|
|
|
|
await dbRun(db,
|
|
|
|
|
`UPDATE ivanti_archive_transitions SET reason = ?
|
|
|
|
|
WHERE archive_id = ? AND id = (
|
|
|
|
|
SELECT id FROM ivanti_archive_transitions
|
|
|
|
|
WHERE archive_id = ? ORDER BY transitioned_at DESC LIMIT 1
|
|
|
|
|
)`,
|
|
|
|
|
[reason, archive.id, archive.id]
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error(`[BU Drift Checker] Error updating transition reason for finding ${id}:`, err.message);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
console.log(`[BU Drift Checker] Classification complete:`, summary);
|
|
|
|
|
return summary;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Anomaly Summary — compute and store post-sync anomaly report
|
|
|
|
|
// ---------------------------------------------------------------------------
|
2026-05-01 17:15:41 +00:00
|
|
|
async function computeAnomalySummary(db, openCountDelta, closedCountDelta, newlyArchivedCount, returnedCount, classificationBreakdown, returnClassificationBreakdown) {
|
2026-04-24 20:34:34 +00:00
|
|
|
try {
|
|
|
|
|
const isSignificant = newlyArchivedCount > 5 ? 1 : 0;
|
|
|
|
|
const classificationJson = JSON.stringify(classificationBreakdown || {});
|
2026-05-01 17:15:41 +00:00
|
|
|
const returnClassificationJson = JSON.stringify(returnClassificationBreakdown || {});
|
2026-04-24 20:34:34 +00:00
|
|
|
|
|
|
|
|
await dbRun(db,
|
|
|
|
|
`INSERT INTO ivanti_sync_anomaly_log
|
2026-05-01 17:15:41 +00:00
|
|
|
(sync_timestamp, open_count_delta, closed_count_delta, newly_archived_count, returned_count, classification_json, return_classification_json, is_significant)
|
|
|
|
|
VALUES (datetime('now'), ?, ?, ?, ?, ?, ?, ?)`,
|
|
|
|
|
[openCountDelta, closedCountDelta, newlyArchivedCount, returnedCount, classificationJson, returnClassificationJson, isSignificant]
|
2026-04-24 20:34:34 +00:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
console.log(`[Anomaly Summary] Sync anomaly logged — open_delta: ${openCountDelta}, closed_delta: ${closedCountDelta}, archived: ${newlyArchivedCount}, returned: ${returnedCount}, significant: ${!!isSignificant}`);
|
|
|
|
|
console.log(`[Anomaly Summary] Classification:`, classificationBreakdown);
|
2026-05-01 17:15:41 +00:00
|
|
|
if (returnedCount > 0) {
|
|
|
|
|
console.log(`[Anomaly Summary] Return classification:`, returnClassificationBreakdown);
|
|
|
|
|
}
|
2026-04-24 20:34:34 +00:00
|
|
|
} catch (err) {
|
|
|
|
|
console.error('[Anomaly Summary] Failed to write anomaly summary (non-fatal):', err.message);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
Add Reporting page with Ivanti host findings table
Backend:
- New route /api/ivanti/findings (GET cached data, POST /sync, PUT /:id/note)
- Fetches all pages of hostFinding/search filtered to NTS-AEO groups, severity 8.5-9.9, Open state
- SQLite cache (ivanti_findings_cache) stores slimmed findings across syncs
- Separate ivanti_finding_notes table persists user notes by finding ID
- Daily auto-sync on startup + 24h interval, manual sync endpoint
- Notes capped at 255 chars server-side
Frontend (ReportingPage):
- Panel 1: Metric graphs placeholder (full width, amber theme)
- Panel 2: Sortable findings table (all columns click-to-sort with ASC/DESC toggle)
- Columns: Severity (color-coded badge), Title, Host, IP, DNS, SLA, Discovered, Last Found, Source, Notes
- Notes column: inline editable input, saves on blur via PUT endpoint
- Sync button with spinner, last-synced timestamp, error banner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:56:37 -06:00
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Router
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
function createIvantiFindingsRouter(db, requireAuth) {
|
|
|
|
|
const router = express.Router();
|
|
|
|
|
|
2026-04-03 15:20:04 -06:00
|
|
|
Promise.all([initTables(db), initArchiveTables(db)])
|
Add Reporting page with Ivanti host findings table
Backend:
- New route /api/ivanti/findings (GET cached data, POST /sync, PUT /:id/note)
- Fetches all pages of hostFinding/search filtered to NTS-AEO groups, severity 8.5-9.9, Open state
- SQLite cache (ivanti_findings_cache) stores slimmed findings across syncs
- Separate ivanti_finding_notes table persists user notes by finding ID
- Daily auto-sync on startup + 24h interval, manual sync endpoint
- Notes capped at 255 chars server-side
Frontend (ReportingPage):
- Panel 1: Metric graphs placeholder (full width, amber theme)
- Panel 2: Sortable findings table (all columns click-to-sort with ASC/DESC toggle)
- Columns: Severity (color-coded badge), Title, Host, IP, DNS, SLA, Discovered, Last Found, Source, Notes
- Notes column: inline editable input, saves on blur via PUT endpoint
- Sync button with spinner, last-synced timestamp, error banner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:56:37 -06:00
|
|
|
.then(() => scheduleSync(db))
|
|
|
|
|
.catch((err) => console.error('[Ivanti Findings] Init failed:', err));
|
|
|
|
|
|
|
|
|
|
router.use(requireAuth(db));
|
|
|
|
|
|
Add Atlas InfoSec action plans integration
Integrate Atlas InfoSec API to manage compliance action plans directly from
the ReportingPage. Users can view, create, and update action plans for host
findings without switching to the Atlas web tool.
Backend:
- Add atlasApi.js helper with Basic Auth, TLS skip, GET/PUT/PATCH/POST
- Add atlas_action_plans_cache migration for SQLite cache table
- Add atlas.js router with sync, status, and proxy CRUD endpoints
- Mount Atlas router at /api/atlas in server.js
- Extract hostId from Ivanti host findings during sync
Frontend:
- Add AtlasBadge component (amber=needs plan, green=has plan)
- Add AtlasSlideOutPanel with plan list, create form, edit capability
- Separate active plans from inactive history in collapsible section
- Custom dark-themed plan type dropdown
- Optimistic local state shows pending plans immediately after creation
- Atlas sync button on ReportingPage toolbar
- Prepopulate finding ID in create form from clicked row
Environment:
- Add ATLAS_API_URL, ATLAS_API_USER, ATLAS_API_PASS, ATLAS_SKIP_TLS to .env.example
2026-04-23 21:52:53 +00:00
|
|
|
/**
|
|
|
|
|
* GET /api/ivanti/findings
|
|
|
|
|
*
|
|
|
|
|
* Return cached Ivanti findings with notes and overrides merged in.
|
|
|
|
|
*
|
|
|
|
|
* @returns {Object} 200 - { findings: Array<Object>, lastSync: string|null, overrides: Object }
|
|
|
|
|
* @returns {Object} 500 - { error: string } on database error
|
|
|
|
|
*/
|
Add Reporting page with Ivanti host findings table
Backend:
- New route /api/ivanti/findings (GET cached data, POST /sync, PUT /:id/note)
- Fetches all pages of hostFinding/search filtered to NTS-AEO groups, severity 8.5-9.9, Open state
- SQLite cache (ivanti_findings_cache) stores slimmed findings across syncs
- Separate ivanti_finding_notes table persists user notes by finding ID
- Daily auto-sync on startup + 24h interval, manual sync endpoint
- Notes capped at 255 chars server-side
Frontend (ReportingPage):
- Panel 1: Metric graphs placeholder (full width, amber theme)
- Panel 2: Sortable findings table (all columns click-to-sort with ASC/DESC toggle)
- Columns: Severity (color-coded badge), Title, Host, IP, DNS, SLA, Discovered, Last Found, Source, Notes
- Notes column: inline editable input, saves on blur via PUT endpoint
- Sync button with spinner, last-synced timestamp, error banner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:56:37 -06:00
|
|
|
router.get('/', async (req, res) => {
|
|
|
|
|
try {
|
|
|
|
|
res.json(await readStateWithNotes(db));
|
|
|
|
|
} catch {
|
|
|
|
|
res.status(500).json({ error: 'Database error reading findings' });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
Add Atlas InfoSec action plans integration
Integrate Atlas InfoSec API to manage compliance action plans directly from
the ReportingPage. Users can view, create, and update action plans for host
findings without switching to the Atlas web tool.
Backend:
- Add atlasApi.js helper with Basic Auth, TLS skip, GET/PUT/PATCH/POST
- Add atlas_action_plans_cache migration for SQLite cache table
- Add atlas.js router with sync, status, and proxy CRUD endpoints
- Mount Atlas router at /api/atlas in server.js
- Extract hostId from Ivanti host findings during sync
Frontend:
- Add AtlasBadge component (amber=needs plan, green=has plan)
- Add AtlasSlideOutPanel with plan list, create form, edit capability
- Separate active plans from inactive history in collapsible section
- Custom dark-themed plan type dropdown
- Optimistic local state shows pending plans immediately after creation
- Atlas sync button on ReportingPage toolbar
- Prepopulate finding ID in create form from clicked row
Environment:
- Add ATLAS_API_URL, ATLAS_API_USER, ATLAS_API_PASS, ATLAS_SKIP_TLS to .env.example
2026-04-23 21:52:53 +00:00
|
|
|
/**
|
|
|
|
|
* POST /api/ivanti/findings/sync
|
|
|
|
|
*
|
|
|
|
|
* Trigger an immediate Ivanti findings sync and return the fresh state.
|
|
|
|
|
* Requires Admin or Standard_User group.
|
|
|
|
|
*
|
|
|
|
|
* @returns {Object} 200 - { findings: Array<Object>, lastSync: string|null, overrides: Object }
|
|
|
|
|
* @returns {Object} 500 - { error: string } if sync ran but state could not be read
|
|
|
|
|
*/
|
2026-04-07 09:52:26 -06:00
|
|
|
router.post('/sync', requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
Add Reporting page with Ivanti host findings table
Backend:
- New route /api/ivanti/findings (GET cached data, POST /sync, PUT /:id/note)
- Fetches all pages of hostFinding/search filtered to NTS-AEO groups, severity 8.5-9.9, Open state
- SQLite cache (ivanti_findings_cache) stores slimmed findings across syncs
- Separate ivanti_finding_notes table persists user notes by finding ID
- Daily auto-sync on startup + 24h interval, manual sync endpoint
- Notes capped at 255 chars server-side
Frontend (ReportingPage):
- Panel 1: Metric graphs placeholder (full width, amber theme)
- Panel 2: Sortable findings table (all columns click-to-sort with ASC/DESC toggle)
- Columns: Severity (color-coded badge), Title, Host, IP, DNS, SLA, Discovered, Last Found, Source, Notes
- Notes column: inline editable input, saves on blur via PUT endpoint
- Sync button with spinner, last-synced timestamp, error banner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:56:37 -06:00
|
|
|
await syncFindings(db);
|
|
|
|
|
try {
|
|
|
|
|
res.json(await readStateWithNotes(db));
|
|
|
|
|
} catch {
|
|
|
|
|
res.status(500).json({ error: 'Sync ran but could not read updated state' });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
Add Atlas InfoSec action plans integration
Integrate Atlas InfoSec API to manage compliance action plans directly from
the ReportingPage. Users can view, create, and update action plans for host
findings without switching to the Atlas web tool.
Backend:
- Add atlasApi.js helper with Basic Auth, TLS skip, GET/PUT/PATCH/POST
- Add atlas_action_plans_cache migration for SQLite cache table
- Add atlas.js router with sync, status, and proxy CRUD endpoints
- Mount Atlas router at /api/atlas in server.js
- Extract hostId from Ivanti host findings during sync
Frontend:
- Add AtlasBadge component (amber=needs plan, green=has plan)
- Add AtlasSlideOutPanel with plan list, create form, edit capability
- Separate active plans from inactive history in collapsible section
- Custom dark-themed plan type dropdown
- Optimistic local state shows pending plans immediately after creation
- Atlas sync button on ReportingPage toolbar
- Prepopulate finding ID in create form from clicked row
Environment:
- Add ATLAS_API_URL, ATLAS_API_USER, ATLAS_API_PASS, ATLAS_SKIP_TLS to .env.example
2026-04-23 21:52:53 +00:00
|
|
|
/**
|
|
|
|
|
* GET /api/ivanti/findings/counts
|
|
|
|
|
*
|
|
|
|
|
* Return open vs closed finding totals for the pie chart.
|
|
|
|
|
*
|
|
|
|
|
* @returns {Object} 200 - { open: number, closed: number }
|
|
|
|
|
* @returns {Object} 500 - { error: string } on database error
|
|
|
|
|
*/
|
2026-03-13 12:23:05 -06:00
|
|
|
router.get('/counts', async (req, res) => {
|
|
|
|
|
try {
|
|
|
|
|
res.json(await readCounts(db));
|
|
|
|
|
} catch {
|
|
|
|
|
res.status(500).json({ error: 'Database error reading counts' });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
Add Atlas InfoSec action plans integration
Integrate Atlas InfoSec API to manage compliance action plans directly from
the ReportingPage. Users can view, create, and update action plans for host
findings without switching to the Atlas web tool.
Backend:
- Add atlasApi.js helper with Basic Auth, TLS skip, GET/PUT/PATCH/POST
- Add atlas_action_plans_cache migration for SQLite cache table
- Add atlas.js router with sync, status, and proxy CRUD endpoints
- Mount Atlas router at /api/atlas in server.js
- Extract hostId from Ivanti host findings during sync
Frontend:
- Add AtlasBadge component (amber=needs plan, green=has plan)
- Add AtlasSlideOutPanel with plan list, create form, edit capability
- Separate active plans from inactive history in collapsible section
- Custom dark-themed plan type dropdown
- Optimistic local state shows pending plans immediately after creation
- Atlas sync button on ReportingPage toolbar
- Prepopulate finding ID in create form from clicked row
Environment:
- Add ATLAS_API_URL, ATLAS_API_USER, ATLAS_API_PASS, ATLAS_SKIP_TLS to .env.example
2026-04-23 21:52:53 +00:00
|
|
|
/**
|
|
|
|
|
* GET /api/ivanti/findings/counts/history
|
|
|
|
|
*
|
|
|
|
|
* Return the last snapshot per day (ascending) for the trend chart.
|
|
|
|
|
* Uses a ROW_NUMBER window function to pick the final sync of each calendar day.
|
|
|
|
|
*
|
|
|
|
|
* @returns {Object} 200 - { history: Array<{ date: string, open_count: number, closed_count: number }> }
|
|
|
|
|
* @returns {Object} 500 - { error: string } on database error
|
|
|
|
|
*/
|
2026-04-02 10:12:04 -06:00
|
|
|
router.get('/counts/history', async (req, res) => {
|
|
|
|
|
try {
|
|
|
|
|
const rows = await new Promise((resolve, reject) => {
|
|
|
|
|
db.all(
|
|
|
|
|
`SELECT date, open_count, closed_count FROM (
|
|
|
|
|
SELECT DATE(recorded_at) AS date,
|
|
|
|
|
open_count, closed_count,
|
|
|
|
|
ROW_NUMBER() OVER (
|
|
|
|
|
PARTITION BY DATE(recorded_at)
|
|
|
|
|
ORDER BY recorded_at DESC
|
|
|
|
|
) AS rn
|
|
|
|
|
FROM ivanti_counts_history
|
|
|
|
|
) WHERE rn = 1
|
|
|
|
|
ORDER BY date ASC`,
|
|
|
|
|
[],
|
|
|
|
|
(err, rows) => { if (err) reject(err); else resolve(rows || []); }
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
res.json({ history: rows });
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error('[Ivanti Findings] GET /counts/history error:', err.message);
|
|
|
|
|
res.status(500).json({ error: 'Database error' });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
Add Atlas InfoSec action plans integration
Integrate Atlas InfoSec API to manage compliance action plans directly from
the ReportingPage. Users can view, create, and update action plans for host
findings without switching to the Atlas web tool.
Backend:
- Add atlasApi.js helper with Basic Auth, TLS skip, GET/PUT/PATCH/POST
- Add atlas_action_plans_cache migration for SQLite cache table
- Add atlas.js router with sync, status, and proxy CRUD endpoints
- Mount Atlas router at /api/atlas in server.js
- Extract hostId from Ivanti host findings during sync
Frontend:
- Add AtlasBadge component (amber=needs plan, green=has plan)
- Add AtlasSlideOutPanel with plan list, create form, edit capability
- Separate active plans from inactive history in collapsible section
- Custom dark-themed plan type dropdown
- Optimistic local state shows pending plans immediately after creation
- Atlas sync button on ReportingPage toolbar
- Prepopulate finding ID in create form from clicked row
Environment:
- Add ATLAS_API_URL, ATLAS_API_USER, ATLAS_API_PASS, ATLAS_SKIP_TLS to .env.example
2026-04-23 21:52:53 +00:00
|
|
|
/**
|
|
|
|
|
* GET /api/ivanti/findings/fp-workflow-counts
|
|
|
|
|
*
|
|
|
|
|
* Return FP finding counts and unique workflow ID counts (open + closed),
|
|
|
|
|
* broken down by workflow status.
|
|
|
|
|
*
|
|
|
|
|
* @returns {Object} 200 - { findingCounts: Object, findingTotal: number, idCounts: Object, idTotal: number }
|
|
|
|
|
* @returns {Object} 500 - { error: string } on database error
|
|
|
|
|
*/
|
2026-03-16 11:43:57 -06:00
|
|
|
router.get('/fp-workflow-counts', async (req, res) => {
|
|
|
|
|
try {
|
|
|
|
|
const row = await new Promise((resolve, reject) => {
|
2026-03-16 12:13:13 -06:00
|
|
|
db.get('SELECT fp_workflow_counts_json, fp_id_counts_json FROM ivanti_counts_cache WHERE id=1',
|
2026-03-16 11:43:57 -06:00
|
|
|
(err, row) => { if (err) reject(err); else resolve(row); }
|
|
|
|
|
);
|
|
|
|
|
});
|
2026-03-16 12:13:13 -06:00
|
|
|
let findingCounts = {};
|
|
|
|
|
let idCounts = {};
|
|
|
|
|
try { findingCounts = JSON.parse(row?.fp_workflow_counts_json || '{}'); } catch (_) {}
|
|
|
|
|
try { idCounts = JSON.parse(row?.fp_id_counts_json || '{}'); } catch (_) {}
|
|
|
|
|
res.json({
|
|
|
|
|
findingCounts,
|
|
|
|
|
findingTotal: Object.values(findingCounts).reduce((a, b) => a + b, 0),
|
|
|
|
|
idCounts,
|
|
|
|
|
idTotal: Object.values(idCounts).reduce((a, b) => a + b, 0),
|
|
|
|
|
});
|
2026-03-16 11:43:57 -06:00
|
|
|
} catch {
|
|
|
|
|
res.status(500).json({ error: 'Database error reading FP workflow counts' });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-24 20:34:34 +00:00
|
|
|
/**
|
|
|
|
|
* GET /api/ivanti/findings/anomaly/latest
|
|
|
|
|
*
|
|
|
|
|
* Return the most recent anomaly summary row from ivanti_sync_anomaly_log.
|
|
|
|
|
* The classification_json column is parsed into an object in the response.
|
|
|
|
|
*
|
|
|
|
|
* @returns {Object} 200 - { anomaly: Object|null }
|
|
|
|
|
* @returns {Object} 500 - { error: string } on database error
|
|
|
|
|
*/
|
|
|
|
|
router.get('/anomaly/latest', async (req, res) => {
|
|
|
|
|
try {
|
|
|
|
|
const row = await dbGet(db,
|
|
|
|
|
`SELECT id, sync_timestamp, open_count_delta, closed_count_delta,
|
2026-05-01 17:15:41 +00:00
|
|
|
newly_archived_count, returned_count, classification_json, return_classification_json, is_significant
|
2026-04-24 20:34:34 +00:00
|
|
|
FROM ivanti_sync_anomaly_log
|
|
|
|
|
ORDER BY sync_timestamp DESC LIMIT 1`
|
|
|
|
|
);
|
|
|
|
|
if (!row) return res.json({ anomaly: null });
|
|
|
|
|
let classification = {};
|
|
|
|
|
try { classification = JSON.parse(row.classification_json || '{}'); } catch (_) {}
|
2026-05-01 17:15:41 +00:00
|
|
|
let return_classification = {};
|
|
|
|
|
try { return_classification = JSON.parse(row.return_classification_json || '{}'); } catch (_) {}
|
2026-04-24 20:34:34 +00:00
|
|
|
res.json({
|
|
|
|
|
anomaly: {
|
|
|
|
|
id: row.id,
|
|
|
|
|
sync_timestamp: row.sync_timestamp,
|
|
|
|
|
open_count_delta: row.open_count_delta,
|
|
|
|
|
closed_count_delta: row.closed_count_delta,
|
|
|
|
|
newly_archived_count: row.newly_archived_count,
|
|
|
|
|
returned_count: row.returned_count,
|
|
|
|
|
classification,
|
2026-05-01 17:15:41 +00:00
|
|
|
return_classification,
|
2026-04-24 20:34:34 +00:00
|
|
|
is_significant: !!row.is_significant
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error('[Ivanti Findings] GET /anomaly/latest error:', err.message);
|
|
|
|
|
res.status(500).json({ error: 'Database error reading latest anomaly' });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* GET /api/ivanti/findings/anomaly/history
|
|
|
|
|
*
|
|
|
|
|
* Return anomaly history. Accepts optional `from` and `to` query parameters
|
|
|
|
|
* (ISO date strings) for date-range filtering (inclusive). If neither is
|
|
|
|
|
* provided, returns the last 30 rows ordered by sync_timestamp descending.
|
|
|
|
|
*
|
|
|
|
|
* @query {string} [from] - Inclusive start date (ISO string)
|
|
|
|
|
* @query {string} [to] - Inclusive end date (ISO string)
|
|
|
|
|
*
|
|
|
|
|
* @returns {Object} 200 - { history: Array<Object> }
|
|
|
|
|
* @returns {Object} 500 - { error: string } on database error
|
|
|
|
|
*/
|
|
|
|
|
router.get('/anomaly/history', async (req, res) => {
|
|
|
|
|
try {
|
|
|
|
|
const { from, to } = req.query;
|
|
|
|
|
let rows;
|
|
|
|
|
|
|
|
|
|
if (from && to) {
|
|
|
|
|
rows = await dbAll(db,
|
|
|
|
|
`SELECT sync_timestamp, open_count_delta, closed_count_delta,
|
2026-05-01 17:15:41 +00:00
|
|
|
newly_archived_count, returned_count, classification_json, return_classification_json, is_significant
|
2026-04-24 20:34:34 +00:00
|
|
|
FROM ivanti_sync_anomaly_log
|
|
|
|
|
WHERE sync_timestamp >= ? AND sync_timestamp <= ?
|
|
|
|
|
ORDER BY sync_timestamp DESC`,
|
|
|
|
|
[from, to]
|
|
|
|
|
);
|
|
|
|
|
} else {
|
|
|
|
|
rows = await dbAll(db,
|
|
|
|
|
`SELECT sync_timestamp, open_count_delta, closed_count_delta,
|
2026-05-01 17:15:41 +00:00
|
|
|
newly_archived_count, returned_count, classification_json, return_classification_json, is_significant
|
2026-04-24 20:34:34 +00:00
|
|
|
FROM ivanti_sync_anomaly_log
|
|
|
|
|
ORDER BY sync_timestamp DESC LIMIT 30`
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const history = rows.map(row => {
|
|
|
|
|
let classification = {};
|
|
|
|
|
try { classification = JSON.parse(row.classification_json || '{}'); } catch (_) {}
|
2026-05-01 17:15:41 +00:00
|
|
|
let return_classification = {};
|
|
|
|
|
try { return_classification = JSON.parse(row.return_classification_json || '{}'); } catch (_) {}
|
2026-04-24 20:34:34 +00:00
|
|
|
return {
|
|
|
|
|
sync_timestamp: row.sync_timestamp,
|
|
|
|
|
open_count_delta: row.open_count_delta,
|
|
|
|
|
closed_count_delta: row.closed_count_delta,
|
|
|
|
|
newly_archived_count: row.newly_archived_count,
|
|
|
|
|
returned_count: row.returned_count,
|
|
|
|
|
classification,
|
2026-05-01 17:15:41 +00:00
|
|
|
return_classification,
|
2026-04-24 20:34:34 +00:00
|
|
|
is_significant: !!row.is_significant
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
res.json({ history });
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error('[Ivanti Findings] GET /anomaly/history error:', err.message);
|
|
|
|
|
res.status(500).json({ error: 'Database error reading anomaly history' });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* GET /api/ivanti/findings/bu-changes
|
|
|
|
|
*
|
|
|
|
|
* Return all BU change events from ivanti_finding_bu_history,
|
|
|
|
|
* ordered by detected_at descending (newest first).
|
|
|
|
|
*
|
|
|
|
|
* @returns {Object} 200 - { changes: Array<Object> }
|
|
|
|
|
* @returns {Object} 500 - { error: string } on database error
|
|
|
|
|
*/
|
|
|
|
|
router.get('/bu-changes', async (req, res) => {
|
|
|
|
|
try {
|
|
|
|
|
const rows = await dbAll(db,
|
|
|
|
|
`SELECT id, finding_id, finding_title, host_name, previous_bu, new_bu, detected_at
|
|
|
|
|
FROM ivanti_finding_bu_history
|
|
|
|
|
ORDER BY detected_at DESC`
|
|
|
|
|
);
|
|
|
|
|
res.json({ changes: rows });
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error('[Ivanti Findings] GET /bu-changes error:', err.message);
|
|
|
|
|
res.status(500).json({ error: 'Database error reading BU changes' });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* GET /api/ivanti/findings/:findingId/bu-history
|
|
|
|
|
*
|
|
|
|
|
* Return BU change history for a specific finding from ivanti_finding_bu_history,
|
|
|
|
|
* ordered by detected_at descending (newest first).
|
|
|
|
|
*
|
|
|
|
|
* @param {string} findingId - The finding identifier (URL param)
|
|
|
|
|
*
|
|
|
|
|
* @returns {Object} 200 - { finding_id: string, history: Array<Object> }
|
|
|
|
|
* @returns {Object} 500 - { error: string } on database error
|
|
|
|
|
*/
|
|
|
|
|
router.get('/:findingId/bu-history', async (req, res) => {
|
|
|
|
|
try {
|
|
|
|
|
const { findingId } = req.params;
|
|
|
|
|
const rows = await dbAll(db,
|
|
|
|
|
`SELECT previous_bu, new_bu, detected_at
|
|
|
|
|
FROM ivanti_finding_bu_history
|
|
|
|
|
WHERE finding_id = ?
|
|
|
|
|
ORDER BY detected_at DESC`,
|
|
|
|
|
[findingId]
|
|
|
|
|
);
|
|
|
|
|
res.json({ finding_id: findingId, history: rows });
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error('[Ivanti Findings] GET /:findingId/bu-history error:', err.message);
|
|
|
|
|
res.status(500).json({ error: 'Database error reading finding BU history' });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
Add Atlas InfoSec action plans integration
Integrate Atlas InfoSec API to manage compliance action plans directly from
the ReportingPage. Users can view, create, and update action plans for host
findings without switching to the Atlas web tool.
Backend:
- Add atlasApi.js helper with Basic Auth, TLS skip, GET/PUT/PATCH/POST
- Add atlas_action_plans_cache migration for SQLite cache table
- Add atlas.js router with sync, status, and proxy CRUD endpoints
- Mount Atlas router at /api/atlas in server.js
- Extract hostId from Ivanti host findings during sync
Frontend:
- Add AtlasBadge component (amber=needs plan, green=has plan)
- Add AtlasSlideOutPanel with plan list, create form, edit capability
- Separate active plans from inactive history in collapsible section
- Custom dark-themed plan type dropdown
- Optimistic local state shows pending plans immediately after creation
- Atlas sync button on ReportingPage toolbar
- Prepopulate finding ID in create form from clicked row
Environment:
- Add ATLAS_API_URL, ATLAS_API_USER, ATLAS_API_PASS, ATLAS_SKIP_TLS to .env.example
2026-04-23 21:52:53 +00:00
|
|
|
/**
|
|
|
|
|
* PUT /api/ivanti/findings/:findingId/override
|
|
|
|
|
*
|
|
|
|
|
* Save or clear a field override for a finding. Requires Admin or Standard_User group.
|
|
|
|
|
* Sending an empty value clears the override (reverts to Ivanti-sourced data).
|
|
|
|
|
*
|
|
|
|
|
* @param {string} findingId - The finding identifier (URL param)
|
|
|
|
|
* @body {string} field - The field to override; must be one of 'hostName', 'dns'
|
|
|
|
|
* @body {string} [value] - The override value; empty or omitted to clear
|
|
|
|
|
*
|
|
|
|
|
* @returns {Object} 200 - { finding_id: string, field: string, value: string|null }
|
|
|
|
|
* @returns {Object} 400 - { error: string } when field is not in the allowed list
|
|
|
|
|
* @returns {Object} 500 - { error: string } on database error
|
|
|
|
|
*/
|
2026-03-13 15:39:37 -06:00
|
|
|
const OVERRIDE_ALLOWED = ['hostName', 'dns'];
|
2026-04-06 16:18:07 -06:00
|
|
|
router.put('/:findingId/override', requireGroup('Admin', 'Standard_User'), (req, res) => {
|
2026-03-13 15:39:37 -06:00
|
|
|
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 });
|
|
|
|
|
}
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
Add Atlas InfoSec action plans integration
Integrate Atlas InfoSec API to manage compliance action plans directly from
the ReportingPage. Users can view, create, and update action plans for host
findings without switching to the Atlas web tool.
Backend:
- Add atlasApi.js helper with Basic Auth, TLS skip, GET/PUT/PATCH/POST
- Add atlas_action_plans_cache migration for SQLite cache table
- Add atlas.js router with sync, status, and proxy CRUD endpoints
- Mount Atlas router at /api/atlas in server.js
- Extract hostId from Ivanti host findings during sync
Frontend:
- Add AtlasBadge component (amber=needs plan, green=has plan)
- Add AtlasSlideOutPanel with plan list, create form, edit capability
- Separate active plans from inactive history in collapsible section
- Custom dark-themed plan type dropdown
- Optimistic local state shows pending plans immediately after creation
- Atlas sync button on ReportingPage toolbar
- Prepopulate finding ID in create form from clicked row
Environment:
- Add ATLAS_API_URL, ATLAS_API_USER, ATLAS_API_PASS, ATLAS_SKIP_TLS to .env.example
2026-04-23 21:52:53 +00:00
|
|
|
/**
|
|
|
|
|
* PUT /api/ivanti/findings/:findingId/note
|
|
|
|
|
*
|
|
|
|
|
* Save or update a note for a finding (max 255 characters).
|
|
|
|
|
* Requires Admin or Standard_User group.
|
|
|
|
|
*
|
|
|
|
|
* @param {string} findingId - The finding identifier (URL param)
|
|
|
|
|
* @body {string} [note] - The note text (truncated to 255 chars)
|
|
|
|
|
*
|
|
|
|
|
* @returns {Object} 200 - { finding_id: string, note: string }
|
|
|
|
|
* @returns {Object} 500 - { error: string } on database error
|
|
|
|
|
*/
|
2026-04-07 09:52:26 -06:00
|
|
|
router.put('/:findingId/note', requireGroup('Admin', 'Standard_User'), (req, res) => {
|
Add Reporting page with Ivanti host findings table
Backend:
- New route /api/ivanti/findings (GET cached data, POST /sync, PUT /:id/note)
- Fetches all pages of hostFinding/search filtered to NTS-AEO groups, severity 8.5-9.9, Open state
- SQLite cache (ivanti_findings_cache) stores slimmed findings across syncs
- Separate ivanti_finding_notes table persists user notes by finding ID
- Daily auto-sync on startup + 24h interval, manual sync endpoint
- Notes capped at 255 chars server-side
Frontend (ReportingPage):
- Panel 1: Metric graphs placeholder (full width, amber theme)
- Panel 2: Sortable findings table (all columns click-to-sort with ASC/DESC toggle)
- Columns: Severity (color-coded badge), Title, Host, IP, DNS, SLA, Discovered, Last Found, Source, Notes
- Notes column: inline editable input, saves on blur via PUT endpoint
- Sync button with spinner, last-synced timestamp, error banner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:56:37 -06:00
|
|
|
const { findingId } = req.params;
|
|
|
|
|
const note = String(req.body.note || '').slice(0, 255);
|
|
|
|
|
|
|
|
|
|
db.run(
|
|
|
|
|
`INSERT INTO ivanti_finding_notes (finding_id, note, updated_at)
|
|
|
|
|
VALUES (?, ?, datetime('now'))
|
|
|
|
|
ON CONFLICT(finding_id) DO UPDATE SET note=excluded.note, updated_at=datetime('now')`,
|
|
|
|
|
[findingId, note],
|
|
|
|
|
(err) => {
|
|
|
|
|
if (err) return res.status(500).json({ error: 'Failed to save note' });
|
|
|
|
|
res.json({ finding_id: findingId, note });
|
|
|
|
|
}
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return router;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
module.exports = createIvantiFindingsRouter;
|
2026-04-03 15:20:04 -06:00
|
|
|
module.exports.detectArchiveChanges = detectArchiveChanges;
|
|
|
|
|
module.exports.detectClosedFindings = detectClosedFindings;
|
|
|
|
|
module.exports.initArchiveTables = initArchiveTables;
|
2026-04-24 20:34:34 +00:00
|
|
|
module.exports.runBUDriftChecker = runBUDriftChecker;
|
|
|
|
|
module.exports.computeAnomalySummary = computeAnomalySummary;
|
|
|
|
|
module.exports.extractFinding = extractFinding;
|