diff --git a/backend/.env.example b/backend/.env.example index 0f61a35..eda6ba3 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -6,3 +6,12 @@ CORS_ORIGINS=http://localhost:3000 # NVD API Key (optional - increases rate limit from 5 to 50 requests per 30s) # Request one at https://nvd.nist.gov/developers/request-an-api-key NVD_API_KEY= + +# Ivanti / RiskSense API (platform4.risksense.com) +# API key from your profile settings — does not expire like session cookies +IVANTI_API_KEY= +IVANTI_CLIENT_ID=1550 +IVANTI_FIRST_NAME= +IVANTI_LAST_NAME= +# Set to true if behind Charter's SSL inspection proxy (replicates Python verify=False) +IVANTI_SKIP_TLS=false diff --git a/backend/migrations/add_ivanti_sync_table.js b/backend/migrations/add_ivanti_sync_table.js new file mode 100644 index 0000000..67b4e45 --- /dev/null +++ b/backend/migrations/add_ivanti_sync_table.js @@ -0,0 +1,37 @@ +// Migration: Add ivanti_sync_state table +const sqlite3 = require('sqlite3').verbose(); +const path = require('path'); + +const dbPath = path.join(__dirname, '..', 'cve_database.db'); +const db = new sqlite3.Database(dbPath); + +console.log('Starting Ivanti sync state migration...'); + +db.serialize(() => { + db.run(` + CREATE TABLE IF NOT EXISTS ivanti_sync_state ( + id INTEGER PRIMARY KEY CHECK (id = 1), + total INTEGER DEFAULT 0, + workflows_json TEXT DEFAULT '[]', + synced_at DATETIME, + sync_status TEXT DEFAULT 'never', + error_message TEXT + ) + `, (err) => { + if (err) console.error('Error creating table:', err); + else console.log('✓ ivanti_sync_state table created'); + }); + + // Seed the single-row state record + db.run(` + INSERT OR IGNORE INTO ivanti_sync_state (id, total, workflows_json, sync_status) + VALUES (1, 0, '[]', 'never') + `, (err) => { + if (err) console.error('Error seeding state row:', err); + else console.log('✓ ivanti_sync_state row seeded'); + }); +}); + +db.close(() => { + console.log('Migration complete!'); +}); diff --git a/backend/routes/ivantiWorkflows.js b/backend/routes/ivantiWorkflows.js new file mode 100644 index 0000000..3937947 --- /dev/null +++ b/backend/routes/ivantiWorkflows.js @@ -0,0 +1,274 @@ +// Ivanti / RiskSense Workflow Routes +// Data is cached in SQLite and refreshed on a daily schedule or on-demand. +// Auth: x-api-key header (confirmed via platform4.risksense.com/doc/swagger.json) +// Error codes: 401 bad key, 419 insufficient privileges, 429 rate limited + +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; // 24 hours + +// --------------------------------------------------------------------------- +// HTTP helper — uses Node's https module directly so we can toggle +// rejectUnauthorized for Charter's SSL inspection proxy (IVANTI_SKIP_TLS=true) +// --------------------------------------------------------------------------- +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: 15000 + }; + + 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(); + }); +} + +// --------------------------------------------------------------------------- +// Ensure the sync state table exists (idempotent — safe to call on every start) +// --------------------------------------------------------------------------- +function initTable(db) { + return new Promise((resolve, reject) => { + db.serialize(() => { + db.run(` + CREATE TABLE IF NOT EXISTS ivanti_sync_state ( + id INTEGER PRIMARY KEY CHECK (id = 1), + total INTEGER DEFAULT 0, + workflows_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_sync_state (id, total, workflows_json, sync_status) + VALUES (1, 0, '[]', 'never') + `, (err) => { + if (err) reject(err); + else resolve(); + }); + }); + }); +} + +// --------------------------------------------------------------------------- +// Core sync — calls Ivanti API, stores result in SQLite +// --------------------------------------------------------------------------- +async function syncWorkflows(db) { + const apiKey = process.env.IVANTI_API_KEY; + const clientId = process.env.IVANTI_CLIENT_ID || '1550'; + const firstName = process.env.IVANTI_FIRST_NAME || ''; + const lastName = process.env.IVANTI_LAST_NAME || ''; + const skipTls = process.env.IVANTI_SKIP_TLS === 'true'; + + if (!apiKey) { + const errMsg = 'IVANTI_API_KEY not set in .env — skipping sync'; + console.warn('[Ivanti]', errMsg); + await new Promise((resolve) => { + db.run( + `UPDATE ivanti_sync_state SET sync_status='error', error_message=?, synced_at=datetime('now') WHERE id=1`, + [errMsg], resolve + ); + }); + return; + } + + console.log('[Ivanti] Syncing workflows...'); + + const urlPath = `/client/${encodeURIComponent(clientId)}/workflowBatch/search`; + const body = { + filters: [ + { + field: 'created_by_last_name', + exclusive: false, + operator: 'IN', + orWithPrevious: false, + implicitFilters: [], + value: lastName, + caseSensitive: false + }, + { + field: 'created_by_first_name', + exclusive: false, + operator: 'IN', + orWithPrevious: false, + implicitFilters: [], + value: firstName, + caseSensitive: false + } + ], + projection: 'internal', + sort: [{ field: 'created', direction: 'DESC' }], + page: 0, + size: 50 + }; + + try { + const result = await ivantiPost(urlPath, body, apiKey, skipTls); + + if (result.status === 401) { + throw new Error('Invalid or missing API key (401) — check IVANTI_API_KEY in .env'); + } + if (result.status === 419) { + throw new Error('Insufficient privileges (419) — API key lacks workflow access'); + } + if (result.status === 429) { + throw new Error('Rate limited (429) — will retry at next scheduled sync'); + } + if (result.status !== 200) { + throw new Error(`Ivanti API returned unexpected status ${result.status}`); + } + + const data = JSON.parse(result.body); + + // Spring Data REST format: { _embedded: { workflowBatches: [...] }, page: { totalElements, ... } } + let total = 0; + let workflows = []; + + if (data.page && typeof data.page.totalElements === 'number') { + total = data.page.totalElements; + workflows = data._embedded?.workflowBatches + || data._embedded?.workflowBatch + || []; + } else if (typeof data.total === 'number') { + total = data.total; + workflows = data.data || data.content || data.results || []; + } else if (typeof data.totalElements === 'number') { + total = data.totalElements; + workflows = data.content || data.data || []; + } else if (Array.isArray(data)) { + workflows = data; + total = data.length; + } + + await new Promise((resolve, reject) => { + db.run( + `UPDATE ivanti_sync_state + SET total=?, workflows_json=?, synced_at=datetime('now'), sync_status='success', error_message=NULL + WHERE id=1`, + [total, JSON.stringify(workflows)], + (err) => { if (err) reject(err); else resolve(); } + ); + }); + + console.log(`[Ivanti] Sync complete — ${total} workflows`); + } catch (err) { + const msg = err.message || 'Unknown error'; + console.error('[Ivanti] Sync failed:', msg); + await new Promise((resolve) => { + db.run( + `UPDATE ivanti_sync_state SET sync_status='error', error_message=?, synced_at=datetime('now') WHERE id=1`, + [msg], resolve + ); + }); + } +} + +// --------------------------------------------------------------------------- +// Scheduler — runs sync immediately if >24h stale, then every 24h +// --------------------------------------------------------------------------- +function scheduleSync(db) { + db.get('SELECT synced_at FROM ivanti_sync_state WHERE id = 1', (err, row) => { + if (err || !row || !row.synced_at) { + syncWorkflows(db); + } else { + const lastSync = new Date(row.synced_at.replace(' ', 'T') + 'Z'); + const hoursSince = (Date.now() - lastSync.getTime()) / (1000 * 60 * 60); + if (hoursSince >= 24) { + syncWorkflows(db); + } else { + const hoursUntil = (24 - hoursSince).toFixed(1); + console.log(`[Ivanti] Last sync ${hoursSince.toFixed(1)}h ago — next auto-sync in ${hoursUntil}h`); + } + } + }); + + setInterval(() => syncWorkflows(db), SYNC_INTERVAL_MS); +} + +// --------------------------------------------------------------------------- +// Helper — read current state from DB and return as JSON-ready object +// --------------------------------------------------------------------------- +function readState(db) { + return new Promise((resolve, reject) => { + db.get( + 'SELECT total, workflows_json, synced_at, sync_status, error_message FROM ivanti_sync_state WHERE id = 1', + (err, row) => { + if (err) return reject(err); + if (!row) return resolve({ total: 0, workflows: [], synced_at: null, sync_status: 'never', error_message: null }); + + let workflows = []; + try { workflows = JSON.parse(row.workflows_json || '[]'); } catch (_) { /* leave empty */ } + + resolve({ + total: row.total || 0, + workflows, + synced_at: row.synced_at, + sync_status: row.sync_status, + error_message: row.error_message + }); + } + ); + }); +} + +// --------------------------------------------------------------------------- +// Router +// --------------------------------------------------------------------------- +function createIvantiWorkflowsRouter(db, requireAuth) { + const router = express.Router(); + + // Init table and kick off scheduler (fire-and-forget on startup) + initTable(db) + .then(() => scheduleSync(db)) + .catch((err) => console.error('[Ivanti] Init failed:', err)); + + // All routes require authentication + router.use(requireAuth(db)); + + // GET / — return cached data (fast, no external call) + router.get('/', async (req, res) => { + try { + res.json(await readState(db)); + } catch { + res.status(500).json({ error: 'Database error reading sync state' }); + } + }); + + // POST /sync — trigger an immediate sync, await completion, return fresh state + router.post('/sync', async (req, res) => { + await syncWorkflows(db); + try { + res.json(await readState(db)); + } catch { + res.status(500).json({ error: 'Sync ran but could not read updated state' }); + } + }); + + return router; +} + +module.exports = createIvantiWorkflowsRouter; diff --git a/backend/server.js b/backend/server.js index b95d2eb..83d7402 100644 --- a/backend/server.js +++ b/backend/server.js @@ -21,6 +21,7 @@ const createNvdLookupRouter = require('./routes/nvdLookup'); const createWeeklyReportsRouter = require('./routes/weeklyReports'); const createKnowledgeBaseRouter = require('./routes/knowledgeBase'); const createArcherTicketsRouter = require('./routes/archerTickets'); +const createIvantiWorkflowsRouter = require('./routes/ivantiWorkflows'); const app = express(); const PORT = process.env.PORT || 3001; @@ -183,6 +184,9 @@ app.use('/api/knowledge-base', createKnowledgeBaseRouter(db, upload)); // Archer tickets routes (editor/admin for create/update/delete, all authenticated for view) app.use('/api/archer-tickets', createArcherTicketsRouter(db)); +// Ivanti / RiskSense workflow routes (all authenticated users) +app.use('/api/ivanti/workflows', createIvantiWorkflowsRouter(db, requireAuth)); + // ========== CVE ENDPOINTS ========== // Get all CVEs with optional filters (authenticated users) diff --git a/frontend/src/App.js b/frontend/src/App.js index 8df8716..edda734 100644 --- a/frontend/src/App.js +++ b/frontend/src/App.js @@ -1,5 +1,5 @@ import React, { useState, useEffect } from 'react'; -import { Search, FileText, AlertCircle, Download, Upload, Eye, Filter, CheckCircle, XCircle, Loader, Trash2, Plus, RefreshCw, Edit2, ChevronDown, Shield } from 'lucide-react'; +import { Search, FileText, AlertCircle, Download, Upload, Eye, Filter, CheckCircle, XCircle, Loader, Trash2, Plus, RefreshCw, Edit2, ChevronDown, Shield, Activity } from 'lucide-react'; import { useAuth } from './contexts/AuthContext'; import LoginForm from './components/LoginForm'; import UserMenu from './components/UserMenu'; @@ -221,6 +221,15 @@ export default function App() { }); const [addArcherTicketContext, setAddArcherTicketContext] = useState(null); // { cve_id, vendor } + // Ivanti workflows state + const [ivantiTotal, setIvantiTotal] = useState(null); + const [ivantiWorkflows, setIvantiWorkflows] = useState([]); + const [ivantiSyncedAt, setIvantiSyncedAt] = useState(null); + const [ivantiSyncStatus, setIvantiSyncStatus] = useState(null); + const [ivantiSyncError, setIvantiSyncError] = useState(null); + const [ivantiLoading, setIvantiLoading] = useState(false); + const [ivantiSyncing, setIvantiSyncing] = useState(false); + const toggleCVEExpand = (cveId) => { setExpandedCVEs(prev => ({ ...prev, [cveId]: !prev[cveId] })); }; @@ -333,6 +342,43 @@ export default function App() { } }; + const applyIvantiState = (data) => { + setIvantiTotal(data.total ?? 0); + setIvantiWorkflows(data.workflows || []); + setIvantiSyncedAt(data.synced_at || null); + setIvantiSyncStatus(data.sync_status || null); + setIvantiSyncError(data.error_message || null); + }; + + const fetchIvantiWorkflows = async () => { + setIvantiLoading(true); + try { + const response = await fetch(`${API_BASE}/ivanti/workflows`, { credentials: 'include' }); + const data = await response.json(); + if (response.ok) applyIvantiState(data); + } catch (err) { + console.error('Error loading Ivanti workflows:', err); + } finally { + setIvantiLoading(false); + } + }; + + const syncIvantiWorkflows = async () => { + setIvantiSyncing(true); + try { + const response = await fetch(`${API_BASE}/ivanti/workflows/sync`, { + method: 'POST', + credentials: 'include' + }); + const data = await response.json(); + if (response.ok) applyIvantiState(data); + } catch (err) { + console.error('Error syncing Ivanti workflows:', err); + } finally { + setIvantiSyncing(false); + } + }; + const fetchDocuments = async (cveId, vendor) => { const key = `${cveId}-${vendor}`; if (cveDocuments[key]) return; @@ -861,6 +907,7 @@ export default function App() { fetchVendors(); fetchJiraTickets(); fetchArcherTickets(); + fetchIvantiWorkflows(); fetchKnowledgeBaseArticles(); } // eslint-disable-next-line react-hooks/exhaustive-deps @@ -2347,6 +2394,97 @@ export default function App() { )} + + {/* Ivanti Workflows */} +
Loading...
+{ivantiSyncError}
+No workflows found
+Click Sync to load workflow data
+