// 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) { // statusEmbedded.dueDate = "2026-03-06T00:00:00" — strip to date part const rawDueDate = f.statusEmbedded?.dueDate || ''; const dueDate = rawDueDate ? rawDueDate.split('T')[0] : ''; // BU ownership: assetCustomAttributes['1550_host_1'] is an array like ["NTS-AEO-STEAM"] const buOwnership = f.assetCustomAttributes?.['1550_host_1']?.[0] || ''; // CVE list: vulnerabilities.vulnInfoList[].cve const cves = (f.vulnerabilities?.vulnInfoList || []).map(v => v.cve).filter(Boolean); // Workflow: flatten all distribution buckets, prioritise FP# over SYS# const wfDist = f.workflowDistribution || {}; const allWfEntries = [ ...(wfDist.actionableWorkflows || []), ...(wfDist.requestedWorkflows || []), ...(wfDist.approvedWorkflows || []), ...(wfDist.reworkedWorkflows || []), ...(wfDist.rejectedWorkflows || []), ...(wfDist.expiredWorkflows || []), ...(wfDist.latestSystemWorkflows || []), ]; // FP# (False Positive tickets) take priority over SYS# (system workflows) const fpEntry = allWfEntries.find(w => (w.generatedId || '').startsWith('FP#')); const sysEntry = allWfEntries.find(w => (w.generatedId || '').startsWith('SYS#')); const wfEntry = fpEntry || sysEntry || allWfEntries[0] || null; // If the distribution didn't surface an FP#, also check workflowGeneratedNames directly. // (Some FP# tickets only appear in the names list without full state info.) const generatedNames = f.workflowGeneratedNames || []; const fpFromNames = !fpEntry ? generatedNames.find(n => n.startsWith('FP#')) || null : null; const workflow = wfEntry ? { id: wfEntry.generatedId || '', state: wfEntry.state || '', type: wfEntry.type || wfEntry.acronym || '', } : fpFromNames ? { id: fpFromNames, state: '', type: 'FP', } : null; 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 || '', dueDate, lastFoundOn: f.lastFoundOn || '', buOwnership, cves, workflow }; } // --------------------------------------------------------------------------- // 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;