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 '',
|
|
|
|
|
current_state TEXT NOT NULL CHECK(current_state IN ('ARCHIVED','RETURNED','CLOSED')),
|
|
|
|
|
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];
|
|
|
|
|
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]
|
|
|
|
|
);
|
|
|
|
|
console.log(`[Archive Detection] Finding ${record.finding_id} returned (ARCHIVED → RETURNED)`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error('[Archive Detection] Error processing returned findings:', err.message);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
console.log(`[Archive Detection] Processed ${disappearedIds.length} disappeared, checked ${currentIdsList.length} current findings against archive`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// 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);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
// Append a snapshot to history — every sync is stored; the history
|
|
|
|
|
// endpoint aggregates to last-per-day at query time (Option B).
|
|
|
|
|
await dbRun(db,
|
|
|
|
|
`INSERT INTO ivanti_counts_history (open_count, closed_count) VALUES (?, ?)`,
|
|
|
|
|
[openCount, closedCount]
|
|
|
|
|
);
|
|
|
|
|
|
2026-03-13 12:23:05 -06:00
|
|
|
console.log(`[Ivanti Findings] Counts updated — open: ${openCount}, closed: ${closedCount}`);
|
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-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);
|
|
|
|
|
}
|
|
|
|
|
|
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)
|
|
|
|
|
try {
|
|
|
|
|
await detectArchiveChanges(db, previousFindings, allFindings);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error('[Ivanti Findings] Archive detection failed (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);
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// 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' });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
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;
|