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');
|
|
|
|
|
const https = require('https');
|
|
|
|
|
|
|
|
|
|
const IVANTI_URL_BASE = 'https://platform4.risksense.com/api/v1';
|
|
|
|
|
const SYNC_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
|
|
|
|
|
|
|
|
|
const FINDINGS_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: 'Open',
|
|
|
|
|
caseSensitive: false
|
|
|
|
|
}
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// HTTP helper — mirrors the one in ivantiWorkflows.js
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
function ivantiPost(urlPath, body, apiKey, skipTls) {
|
|
|
|
|
const bodyStr = JSON.stringify(body);
|
|
|
|
|
const fullUrl = new URL(IVANTI_URL_BASE + urlPath);
|
|
|
|
|
|
|
|
|
|
return new Promise((resolve, reject) => {
|
|
|
|
|
const options = {
|
|
|
|
|
hostname: fullUrl.hostname,
|
|
|
|
|
path: fullUrl.pathname + fullUrl.search,
|
|
|
|
|
method: 'POST',
|
|
|
|
|
headers: {
|
|
|
|
|
'accept': '*/*',
|
|
|
|
|
'content-type': 'application/json',
|
|
|
|
|
'x-api-key': apiKey,
|
|
|
|
|
'x-http-client-type': 'browser',
|
|
|
|
|
'content-length': Buffer.byteLength(bodyStr)
|
|
|
|
|
},
|
|
|
|
|
rejectUnauthorized: !skipTls,
|
|
|
|
|
timeout: 20000
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const req = https.request(options, (res) => {
|
|
|
|
|
let data = '';
|
|
|
|
|
res.on('data', (chunk) => { data += chunk; });
|
|
|
|
|
res.on('end', () => resolve({ status: res.statusCode, body: data }));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
req.on('timeout', () => req.destroy(new Error('Request timed out')));
|
|
|
|
|
req.on('error', reject);
|
|
|
|
|
req.write(bodyStr);
|
|
|
|
|
req.end();
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// 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); });
|
|
|
|
|
|
|
|
|
|
db.run(`
|
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_finding_notes_finding_id
|
|
|
|
|
ON ivanti_finding_notes(finding_id)
|
|
|
|
|
`, (err) => {
|
|
|
|
|
if (err) reject(err);
|
|
|
|
|
else resolve();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// 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);
|
|
|
|
|
|
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),
|
|
|
|
|
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,
|
|
|
|
|
cves
|
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);
|
|
|
|
|
|
|
|
|
|
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`);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
const msg = err.message || 'Unknown error';
|
|
|
|
|
console.error('[Ivanti Findings] Sync failed:', msg);
|
|
|
|
|
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) => {
|
|
|
|
|
db.run(sql, params, (err) => { if (err) reject(err); else resolve(); });
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function readStateWithNotes(db) {
|
|
|
|
|
const [state, notes] = await Promise.all([readState(db), readNotes(db)]);
|
|
|
|
|
state.findings = state.findings.map((f) => ({ ...f, note: notes[f.id] || '' }));
|
|
|
|
|
return state;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Router
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
function createIvantiFindingsRouter(db, requireAuth) {
|
|
|
|
|
const router = express.Router();
|
|
|
|
|
|
|
|
|
|
initTables(db)
|
|
|
|
|
.then(() => scheduleSync(db))
|
|
|
|
|
.catch((err) => console.error('[Ivanti Findings] Init failed:', err));
|
|
|
|
|
|
|
|
|
|
router.use(requireAuth(db));
|
|
|
|
|
|
|
|
|
|
// GET / — cached findings with notes merged in
|
|
|
|
|
router.get('/', async (req, res) => {
|
|
|
|
|
try {
|
|
|
|
|
res.json(await readStateWithNotes(db));
|
|
|
|
|
} catch {
|
|
|
|
|
res.status(500).json({ error: 'Database error reading findings' });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// POST /sync — trigger immediate sync, return fresh state
|
|
|
|
|
router.post('/sync', async (req, res) => {
|
|
|
|
|
await syncFindings(db);
|
|
|
|
|
try {
|
|
|
|
|
res.json(await readStateWithNotes(db));
|
|
|
|
|
} catch {
|
|
|
|
|
res.status(500).json({ error: 'Sync ran but could not read updated state' });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// PUT /:findingId/note — save or update a note (max 255 chars enforced here)
|
|
|
|
|
router.put('/:findingId/note', (req, res) => {
|
|
|
|
|
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;
|