From 416bfd2e28f8e5761a4ab9298e9b528d6588bf02 Mon Sep 17 00:00:00 2001 From: Jordan Ramos Date: Fri, 21 Aug 2026 10:33:46 -0600 Subject: [PATCH] Add Infoblox DNS lookup, Scan Posture page, and Ivanti OS fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Infoblox WAPI integration (read-only) for resolving IPs to FQDNs - backend/helpers/infobloxApi.js — Basic auth, host/PTR/IPv6 lookups - backend/routes/infoblox.js — /api/infoblox endpoints - Globe icon on Reporting page hostName/dns columns for one-click DNS lookup - Pending: WAPI credentials with API access permissions - Scan Posture page for Access Ops platform/version tracking - backend/routes/scanPosture.js - frontend/src/components/pages/ScanPosturePage.js - Page visibility and nav drawer entries - Ivanti findings OS field enrichment - Migration to add os_name/os_class/os_version columns - Backfill script for existing findings - ivantiFindings route updates to persist OS data on sync --- backend/.env.example | 11 + backend/helpers/infobloxApi.js | 168 +++ backend/migrations/add_ivanti_os_fields.js | 41 + backend/migrations/run-all.js | 1 + backend/routes/infoblox.js | 152 +++ backend/routes/ivantiFindings.js | 26 +- backend/routes/scanPosture.js | 234 ++++ backend/scripts/backfill-os-fields.js | 102 ++ backend/server.js | 8 + docs/api/infoblox | 1197 +++++++++++++++++ frontend/src/App.js | 2 + frontend/src/components/NavDrawer.js | 3 +- .../src/components/pages/ReportingPage.js | 109 +- .../src/components/pages/ScanPosturePage.js | 469 +++++++ frontend/src/config/pageVisibility.js | 1 + 15 files changed, 2518 insertions(+), 6 deletions(-) create mode 100644 backend/helpers/infobloxApi.js create mode 100644 backend/migrations/add_ivanti_os_fields.js create mode 100644 backend/routes/infoblox.js create mode 100644 backend/routes/scanPosture.js create mode 100644 backend/scripts/backfill-os-fields.js create mode 100644 docs/api/infoblox create mode 100644 frontend/src/components/pages/ScanPosturePage.js diff --git a/backend/.env.example b/backend/.env.example index fef3fe4..a883cc1 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -73,6 +73,17 @@ NETBOX_API_TOKEN= # Set to true if behind Charter's SSL inspection proxy NETBOX_SKIP_TLS=false +# Infoblox DNS Lookup (via api-proxy-ease.charterlab.com proxy) +# Read-only access — used to resolve IPs to authoritative FQDNs. +# Basic auth credentials for the WAPI proxy. +INFOBLOX_USER= +INFOBLOX_PASS= +# Override host/version if using a different proxy or WAPI version +# INFOBLOX_HOST=api-proxy-ease.charterlab.com +# INFOBLOX_VERSION=2.13.8 +# Set to true if behind Charter's SSL inspection proxy +INFOBLOX_SKIP_TLS=false + # PostgreSQL Database (Docker container steam-postgres) # If set, the backend uses Postgres instead of SQLite. # Format: postgresql://user:password@host:port/database diff --git a/backend/helpers/infobloxApi.js b/backend/helpers/infobloxApi.js new file mode 100644 index 0000000..b55340a --- /dev/null +++ b/backend/helpers/infobloxApi.js @@ -0,0 +1,168 @@ +// Infoblox WAPI helpers (read-only) +// Provides DNS host record lookups via the Infoblox WAPI proxy. +// Auth: HTTP Basic (username/password). +// Base URL: https://api-proxy-ease.charterlab.com/wapi/v2.13.8 +// +// This is a lightweight, read-only integration — no create/update/delete. +// Used to resolve IPs to their authoritative FQDN when Ivanti data is wrong. +// +// Quirks: +// - Infoblox returns 401 when a record:host query matches no records (not an +// empty array). This is treated as "not found" rather than an auth error. +// - Many hosts are IPv6-only in Infoblox. If IPv4 host lookup fails, the +// caller should try lookupByIpv6 or lookupPtrByIp as fallbacks. +// - PTR records exist for IPs that don't have a host record. Use +// lookupPtrByIp to resolve those. + +const https = require('https'); + +// --------------------------------------------------------------------------- +// Configuration +// --------------------------------------------------------------------------- +const INFOBLOX_HOST = process.env.INFOBLOX_HOST || 'api-proxy-ease.charterlab.com'; +const INFOBLOX_VERSION = process.env.INFOBLOX_VERSION || '2.13.8'; +const INFOBLOX_USER = process.env.INFOBLOX_USER || ''; +const INFOBLOX_PASS = process.env.INFOBLOX_PASS || ''; +const INFOBLOX_SKIP_TLS = process.env.INFOBLOX_SKIP_TLS === 'true'; + +const requiredVars = ['INFOBLOX_USER', 'INFOBLOX_PASS']; +const missingVars = requiredVars.filter((v) => !process.env[v]); +if (missingVars.length > 0) { + console.warn(`[infoblox-api] WARNING: Missing env vars: ${missingVars.join(', ')}. Infoblox lookups will fail.`); +} + +const isConfigured = missingVars.length === 0; + +// --------------------------------------------------------------------------- +// Generic WAPI GET request with Basic auth +// --------------------------------------------------------------------------- +function infobloxGet(path, queryParams = {}) { + return new Promise((resolve, reject) => { + const qs = new URLSearchParams(queryParams).toString(); + const fullPath = `/wapi/v${INFOBLOX_VERSION}/${path}${qs ? '?' + qs : ''}`; + + const auth = Buffer.from(`${INFOBLOX_USER}:${INFOBLOX_PASS}`).toString('base64'); + + const options = { + hostname: INFOBLOX_HOST, + port: 443, + path: fullPath, + method: 'GET', + headers: { + 'Authorization': `Basic ${auth}`, + 'Accept': 'application/json', + }, + rejectUnauthorized: !INFOBLOX_SKIP_TLS, + timeout: 15000, + }; + + const req = https.request(options, (res) => { + const chunks = []; + res.on('data', (chunk) => chunks.push(chunk)); + res.on('end', () => { + const body = Buffer.concat(chunks).toString('utf8'); + let parsed; + try { parsed = JSON.parse(body); } catch (_) { parsed = body; } + resolve({ status: res.statusCode, ok: res.statusCode >= 200 && res.statusCode < 300, body: parsed }); + }); + }); + + req.on('timeout', () => { req.destroy(); reject(new Error('Infoblox request timed out')); }); + req.on('error', (err) => reject(err)); + req.end(); + }); +} + +// --------------------------------------------------------------------------- +// lookupByIp — search host records that have a given IPv4 address. +// NOTE: Infoblox returns 401 when no host record exists for the IP. +// This is treated as "not found" (returns empty records), not auth error. +// --------------------------------------------------------------------------- +async function lookupByIp(ipAddress) { + const result = await infobloxGet('record:host', { ipv4addr: ipAddress }); + // 401 from Infoblox = no matching host record (quirk of the API) + if (result.status === 401) { + return { ok: true, records: [] }; + } + if (!result.ok) { + return { ok: false, status: result.status, error: result.body }; + } + const records = Array.isArray(result.body) ? result.body : []; + return { ok: true, records }; +} + +// --------------------------------------------------------------------------- +// lookupByIpv6 — search host records by IPv6 address +// --------------------------------------------------------------------------- +async function lookupByIpv6(ipv6Address) { + const result = await infobloxGet('record:host', { ipv6addr: ipv6Address }); + if (result.status === 401) { + return { ok: true, records: [] }; + } + if (!result.ok) { + return { ok: false, status: result.status, error: result.body }; + } + const records = Array.isArray(result.body) ? result.body : []; + return { ok: true, records }; +} + +// --------------------------------------------------------------------------- +// lookupByName — search host records by FQDN (exact match) +// --------------------------------------------------------------------------- +async function lookupByName(fqdn) { + const result = await infobloxGet('record:host', { name: fqdn }); + if (result.status === 401) { + return { ok: true, records: [] }; + } + if (!result.ok) { + return { ok: false, status: result.status, error: result.body }; + } + const records = Array.isArray(result.body) ? result.body : []; + return { ok: true, records }; +} + +// --------------------------------------------------------------------------- +// lookupPtrByIp — search PTR records for an IPv4 address. +// Many IPs only have a PTR record (no host record). The PTR gives the FQDN. +// --------------------------------------------------------------------------- +async function lookupPtrByIp(ipAddress) { + const result = await infobloxGet('record:ptr', { ipv4addr: ipAddress }); + if (result.status === 401) { + return { ok: true, records: [] }; + } + if (!result.ok) { + return { ok: false, status: result.status, error: result.body }; + } + const records = Array.isArray(result.body) ? result.body : []; + return { ok: true, records }; +} + +// --------------------------------------------------------------------------- +// lookupAnyByIp — tries host record first, then PTR as fallback. +// Returns the best available FQDN for a given IPv4 address. +// --------------------------------------------------------------------------- +async function lookupAnyByIp(ipAddress) { + // Try host record first + const hostResult = await lookupByIp(ipAddress); + if (hostResult.ok && hostResult.records.length > 0) { + return { ok: true, source: 'host', records: hostResult.records }; + } + + // Fall back to PTR record + const ptrResult = await lookupPtrByIp(ipAddress); + if (ptrResult.ok && ptrResult.records.length > 0) { + return { ok: true, source: 'ptr', records: ptrResult.records }; + } + + return { ok: true, source: null, records: [] }; +} + +module.exports = { + isConfigured, + missingVars, + lookupByIp, + lookupByIpv6, + lookupByName, + lookupPtrByIp, + lookupAnyByIp, +}; diff --git a/backend/migrations/add_ivanti_os_fields.js b/backend/migrations/add_ivanti_os_fields.js new file mode 100644 index 0000000..39c490e --- /dev/null +++ b/backend/migrations/add_ivanti_os_fields.js @@ -0,0 +1,41 @@ +// Migration: Add operating system fields to ivanti_findings +// Captures operatingSystemScanner data from the Ivanti/RiskSense API response. +// These fields enable grouping findings by platform and code version for the +// Scan Posture executive dashboard. +// +// Fields: +// os_name — full OS string from API (e.g., "Cisco NX-OS", "Red Hat Enterprise Linux 8.6") +// os_family — OS family (e.g., "Linux", "Not Reported") +// os_vendor — vendor string (e.g., "Red Hat", "Cisco", "Not Reported") +// +// Idempotent — safe to run multiple times. + +const pool = require('../db'); + +async function run() { + console.log('Starting migration: add_ivanti_os_fields...'); + try { + await pool.query(`ALTER TABLE ivanti_findings ADD COLUMN IF NOT EXISTS os_name TEXT DEFAULT NULL`); + console.log('✓ os_name column added (or already exists)'); + + await pool.query(`ALTER TABLE ivanti_findings ADD COLUMN IF NOT EXISTS os_family TEXT DEFAULT NULL`); + console.log('✓ os_family column added (or already exists)'); + + await pool.query(`ALTER TABLE ivanti_findings ADD COLUMN IF NOT EXISTS os_vendor TEXT DEFAULT NULL`); + console.log('✓ os_vendor column added (or already exists)'); + + // Index for platform grouping queries + await pool.query(`CREATE INDEX IF NOT EXISTS idx_findings_os_name ON ivanti_findings(os_name)`); + console.log('✓ idx_findings_os_name index created (or already exists)'); + + await pool.query(`CREATE INDEX IF NOT EXISTS idx_findings_os_vendor ON ivanti_findings(os_vendor)`); + console.log('✓ idx_findings_os_vendor index created (or already exists)'); + } catch (err) { + console.error('Migration error:', err.message); + process.exit(1); + } + console.log('Migration complete.'); + process.exit(0); +} + +run(); diff --git a/backend/migrations/run-all.js b/backend/migrations/run-all.js index fe8fd69..37c8751 100644 --- a/backend/migrations/run-all.js +++ b/backend/migrations/run-all.js @@ -38,6 +38,7 @@ const POSTGRES_MIGRATIONS = [ 'add_supplemental_metadata.js', 'add_ivanti_findings_scan_type.js', 'unify_tickets_table.js', + 'add_ivanti_os_fields.js', ]; async function runAll() { diff --git a/backend/routes/infoblox.js b/backend/routes/infoblox.js new file mode 100644 index 0000000..7ad2d83 --- /dev/null +++ b/backend/routes/infoblox.js @@ -0,0 +1,152 @@ +// Infoblox DNS Lookup Routes (read-only) +// Provides reverse DNS lookups via the Infoblox WAPI proxy at api-proxy-ease.charterlab.com. +// Used by the Reporting page to resolve IPs to their authoritative FQDN. + +const express = require('express'); +const { requireAuth, requireGroup } = require('../middleware/auth'); +const { + isConfigured, + missingVars, + lookupAnyByIp, + lookupByIpv6, + lookupByName, +} = require('../helpers/infobloxApi'); + +// --------------------------------------------------------------------------- +// Router factory +// --------------------------------------------------------------------------- +function createInfobloxRouter() { + const router = express.Router(); + + // All Infoblox routes require auth + Admin or Standard_User + router.use(requireAuth(), requireGroup('Admin', 'Standard_User')); + + /** + * GET /status + * + * Returns whether the Infoblox integration is configured. + */ + router.get('/status', (_req, res) => { + if (!isConfigured) { + return res.status(503).json({ configured: false, error: 'Infoblox API is not configured.', missingVars }); + } + return res.json({ configured: true }); + }); + + /** + * GET /lookup/ip/:ip + * + * Look up host records by IPv4 address. Tries host record first, then + * falls back to PTR record (many IPs only have PTR in Infoblox). + * + * @param {string} ip - IPv4 address to look up + * @response 200 - { records: [...], source: 'host'|'ptr' } + * @response 404 - { error: string } — no records found + * @response 503 - not configured + */ + router.get('/lookup/ip/:ip', async (req, res) => { + if (!isConfigured) { + return res.status(503).json({ error: 'Infoblox API is not configured.', missingVars }); + } + + const { ip } = req.params; + if (!ip || !ip.trim()) { + return res.status(400).json({ error: 'IP address is required.' }); + } + + try { + const result = await lookupAnyByIp(ip.trim()); + if (!result.ok) { + return res.status(502).json({ error: 'Infoblox lookup failed.', details: result.error }); + } + if (result.records.length === 0) { + return res.status(404).json({ error: `No host or PTR records found for IP ${ip}.` }); + } + return res.json({ records: result.records, source: result.source }); + } catch (err) { + console.error('[infoblox]', err.message); + if (err.message.includes('timed out')) { + return res.status(504).json({ error: 'Infoblox request timed out.' }); + } + return res.status(502).json({ error: 'Infoblox request failed.', details: err.message }); + } + }); + + /** + * GET /lookup/ipv6/:ip + * + * Look up host records by IPv6 address. + * + * @param {string} ip - IPv6 address to look up + * @response 200 - { records: [...] } + * @response 404 - no records found + */ + router.get('/lookup/ipv6/:ip', async (req, res) => { + if (!isConfigured) { + return res.status(503).json({ error: 'Infoblox API is not configured.', missingVars }); + } + + const { ip } = req.params; + if (!ip || !ip.trim()) { + return res.status(400).json({ error: 'IPv6 address is required.' }); + } + + try { + const result = await lookupByIpv6(ip.trim()); + if (!result.ok) { + return res.status(result.status || 502).json({ error: 'Infoblox lookup failed.', details: result.error }); + } + if (result.records.length === 0) { + return res.status(404).json({ error: `No host records found for IPv6 ${ip}.` }); + } + return res.json({ records: result.records }); + } catch (err) { + console.error('[infoblox]', err.message); + if (err.message.includes('timed out')) { + return res.status(504).json({ error: 'Infoblox request timed out.' }); + } + return res.status(502).json({ error: 'Infoblox request failed.', details: err.message }); + } + }); + + /** + * GET /lookup/name/:fqdn + * + * Look up host records by FQDN (exact match). + * + * @param {string} fqdn - Fully qualified domain name + * @response 200 - { records: [...] } + * @response 404 - no records found + */ + router.get('/lookup/name/:fqdn', async (req, res) => { + if (!isConfigured) { + return res.status(503).json({ error: 'Infoblox API is not configured.', missingVars }); + } + + const { fqdn } = req.params; + if (!fqdn || !fqdn.trim()) { + return res.status(400).json({ error: 'FQDN is required.' }); + } + + try { + const result = await lookupByName(fqdn.trim()); + if (!result.ok) { + return res.status(result.status || 502).json({ error: 'Infoblox lookup failed.', details: result.error }); + } + if (result.records.length === 0) { + return res.status(404).json({ error: `No host records found for ${fqdn}.` }); + } + return res.json({ records: result.records }); + } catch (err) { + console.error('[infoblox]', err.message); + if (err.message.includes('timed out')) { + return res.status(504).json({ error: 'Infoblox request timed out.' }); + } + return res.status(502).json({ error: 'Infoblox request failed.', details: err.message }); + } + }); + + return router; +} + +module.exports = createInfobloxRouter; diff --git a/backend/routes/ivantiFindings.js b/backend/routes/ivantiFindings.js index 13a0c54..3075566 100644 --- a/backend/routes/ivantiFindings.js +++ b/backend/routes/ivantiFindings.js @@ -158,6 +158,12 @@ function extractFinding(f) { const hasAgentId = details.some(entry => entry['Agent ID']); const scanType = hasAgentId ? 'agent' : 'network'; + // Operating system info from scanner detection + const osScanner = f.operatingSystemScanner || {}; + const osName = osScanner.name || null; + const osFamily = (osScanner.family && osScanner.family !== 'Not Reported') ? osScanner.family : null; + const osVendor = (osScanner.vendor && osScanner.vendor !== 'Not Reported') ? osScanner.vendor : null; + return { id: String(f.id), hostId: f.host?.hostId || null, @@ -178,6 +184,10 @@ function extractFinding(f) { qualysIpv6: extractQualysIpv6(f), primaryIpv6: f.assetCustomAttributes?.['1550_host_6']?.[0] || '', scanType, + // OS/platform fields for scan posture dashboard + osName, + osFamily, + osVendor, }; } @@ -214,7 +224,7 @@ async function upsertFindingsBatch(findings, state) { const placeholders = []; batch.forEach((f, idx) => { - const offset = idx * 21; + const offset = idx * 24; values.push( f.id, f.hostId, @@ -236,13 +246,17 @@ async function upsertFindingsBatch(findings, state) { state, f.qualysIpv6 || null, f.primaryIpv6 || null, - f.scanType || null + f.scanType || null, + f.osName || null, + f.osFamily || null, + f.osVendor || null ); placeholders.push( `($${offset+1}, $${offset+2}, $${offset+3}, $${offset+4}, $${offset+5}, ` + `$${offset+6}, $${offset+7}, $${offset+8}, $${offset+9}, $${offset+10}, ` + `$${offset+11}, $${offset+12}, $${offset+13}, $${offset+14}, $${offset+15}, ` + - `$${offset+16}, $${offset+17}, $${offset+18}, $${offset+19}, $${offset+20}, $${offset+21})` + `$${offset+16}, $${offset+17}, $${offset+18}, $${offset+19}, $${offset+20}, ` + + `$${offset+21}, $${offset+22}, $${offset+23}, $${offset+24})` ); }); @@ -252,7 +266,8 @@ async function upsertFindingsBatch(findings, state) { host_name, ip_address, dns, status, sla_status, due_date, last_found_on, bu_ownership, cves, workflow_id, workflow_state, workflow_type, state, - qualys_ipv6, primary_ipv6, scan_type + qualys_ipv6, primary_ipv6, scan_type, + os_name, os_family, os_vendor ) VALUES ${placeholders.join(', ')} ON CONFLICT (id) DO UPDATE SET @@ -276,6 +291,9 @@ async function upsertFindingsBatch(findings, state) { qualys_ipv6 = EXCLUDED.qualys_ipv6, primary_ipv6 = EXCLUDED.primary_ipv6, scan_type = EXCLUDED.scan_type, + os_name = EXCLUDED.os_name, + os_family = EXCLUDED.os_family, + os_vendor = EXCLUDED.os_vendor, synced_at = NOW() `, values); } diff --git a/backend/routes/scanPosture.js b/backend/routes/scanPosture.js new file mode 100644 index 0000000..e5d7d9d --- /dev/null +++ b/backend/routes/scanPosture.js @@ -0,0 +1,234 @@ +// Scan Posture Routes — Executive dashboard for Access Ops +// Aggregates Ivanti findings and compliance data by platform (hardware vendor/model) +// with OS version, host, and CVE drill-down. Provides tile-level summaries for leadership. + +const express = require('express'); +const pool = require('../db'); +const { requireAuth, requireGroup, requireTeam } = require('../middleware/auth'); + +// Shared CTE for vendor classification from OS name when no granite data exists +const VENDOR_CASE = ` + CASE + WHEN f.os_name ILIKE 'Cisco%' THEN 'CISCO' + WHEN f.os_name ILIKE 'JUNOS%' THEN 'JUNIPER' + WHEN f.os_name ILIKE 'Arista%' THEN 'ARISTA' + WHEN f.os_name ILIKE 'Red Hat%' THEN 'RED HAT' + WHEN f.os_name ILIKE '%Linux%' OR f.os_name ILIKE 'Ubuntu%' THEN 'LINUX' + WHEN f.os_name ILIKE 'Rocky%' THEN 'ROCKY LINUX' + WHEN f.os_name ILIKE 'AlmaLinux%' THEN 'ALMALINUX' + WHEN f.os_name ILIKE 'Windows%' THEN 'MICROSOFT' + WHEN f.os_name ILIKE 'iDRAC%' THEN 'DELL' + ELSE 'OTHER' + END`; + +function createScanPostureRouter() { + const router = express.Router(); + + router.use(requireAuth()); + router.use(requireGroup('Admin', 'Standard_User', 'Leadership')); + router.use(requireTeam()); + + /** + * GET /platforms + * + * Returns platform tiles grouped by hardware vendor and model (from compliance + * granite data), enriched with OS versions and finding counts from Ivanti. + * Falls back to OS-based vendor inference for hosts without compliance data. + * Results are scoped to the user's assigned teams. + * + * @response 200 - { platforms: [{ platform_vendor, platform_model, device_count, finding_count, critical_count, high_count, last_scan_time, primary_scan_type, os_versions: string[] }] } + * @response 500 - { error: string } + */ + router.get('/platforms', async (req, res) => { + try { + let buFilter = ''; + const params = []; + + if (req.teamScope) { + buFilter = `AND f.bu_ownership = ANY($1)`; + params.push(req.teamScope.ivanti); + } + + const { rows } = await pool.query(` + WITH enriched AS ( + SELECT + f.host_name, + f.os_name, + f.severity, + f.vrr_group, + f.last_found_on, + f.scan_type, + COALESCE(UPPER(c.vendor), ${VENDOR_CASE}) AS platform_vendor, + COALESCE(UPPER(c.model), 'UNSPECIFIED') AS platform_model + FROM ivanti_findings f + LEFT JOIN ( + SELECT DISTINCT ON (LOWER(hostname)) + LOWER(hostname) AS hostname_lower, + extra_json::json->>'granite - vendor' AS vendor, + extra_json::json->>'granite - model' AS model + FROM compliance_items + WHERE extra_json IS NOT NULL + AND extra_json LIKE '%granite - vendor%' + ) c ON c.hostname_lower = LOWER(f.host_name) + WHERE f.state = 'open' + AND f.os_name IS NOT NULL + AND f.os_name != 'Not Available' + ${buFilter} + ) + SELECT + platform_vendor, + platform_model, + COUNT(DISTINCT host_name) AS device_count, + COUNT(*) AS finding_count, + COUNT(*) FILTER (WHERE vrr_group = 'Critical') AS critical_count, + COUNT(*) FILTER (WHERE vrr_group = 'High') AS high_count, + MAX(last_found_on) AS last_scan_time, + MODE() WITHIN GROUP (ORDER BY scan_type) AS primary_scan_type, + json_agg(DISTINCT os_name) FILTER (WHERE os_name IS NOT NULL) AS os_versions + FROM enriched + GROUP BY platform_vendor, platform_model + ORDER BY COUNT(*) DESC + `, params); + + const platforms = rows.map(row => ({ + ...row, + os_versions: (row.os_versions || []).slice(0, 10), + })); + + res.json({ platforms }); + } catch (err) { + console.error('[Scan Posture] /platforms error:', err.message); + res.status(500).json({ error: 'Failed to fetch platform data' }); + } + }); + + /** + * GET /platforms/:vendor/:model/details + * + * Returns a software version → CVEs hierarchy for a specific hardware + * platform identified by vendor and model. Each OS version entry contains + * aggregate CVEs, host count, finding count, and severity info — CVEs are + * shown at the version level, not per-host. + * + * @param {string} vendor - Hardware vendor name (case-insensitive, uppercased internally) + * @param {string} model - Hardware model name (case-insensitive, uppercased internally) + * @response 200 - { platform_vendor, platform_model, os_versions: [{ os_name, host_count, finding_count, max_severity, last_scan_time, cves: string[], hostnames: string[] }] } + * @response 500 - { error: string } + */ + router.get('/platforms/:vendor/:model/details', async (req, res) => { + try { + const vendor = req.params.vendor.toUpperCase(); + const model = req.params.model.toUpperCase(); + let buFilter = ''; + const params = [vendor, model]; + + if (req.teamScope) { + buFilter = `AND f.bu_ownership = ANY($3)`; + params.push(req.teamScope.ivanti); + } + + // Get findings aggregated by os_name (software version) with CVEs and hostnames rolled up + const { rows } = await pool.query(` + WITH enriched AS ( + SELECT + f.os_name, + f.host_name, + f.severity, + f.last_found_on, + f.cves, + COALESCE(UPPER(c.vendor), ${VENDOR_CASE}) AS platform_vendor, + COALESCE(UPPER(c.model), 'UNSPECIFIED') AS platform_model + FROM ivanti_findings f + LEFT JOIN ( + SELECT DISTINCT ON (LOWER(hostname)) + LOWER(hostname) AS hostname_lower, + extra_json::json->>'granite - vendor' AS vendor, + extra_json::json->>'granite - model' AS model + FROM compliance_items + WHERE extra_json IS NOT NULL + AND extra_json LIKE '%granite - vendor%' + ) c ON c.hostname_lower = LOWER(f.host_name) + WHERE f.state = 'open' + AND f.os_name IS NOT NULL + AND f.os_name != 'Not Available' + ${buFilter} + ) + SELECT + os_name, + COUNT(DISTINCT host_name) AS host_count, + COUNT(*) AS finding_count, + MAX(severity) AS max_severity, + MAX(last_found_on) AS last_scan_time, + array_agg(DISTINCT unnested_cve) AS cves, + array_agg(DISTINCT host_name) AS hostnames + FROM enriched + LEFT JOIN LATERAL unnest(cves) AS unnested_cve ON true + WHERE platform_vendor = $1 AND platform_model = $2 + GROUP BY os_name + ORDER BY MAX(severity) DESC, COUNT(DISTINCT host_name) DESC + `, params); + + const os_versions = rows.map(row => ({ + os_name: row.os_name, + host_count: Number(row.host_count), + finding_count: Number(row.finding_count), + max_severity: row.max_severity, + last_scan_time: row.last_scan_time, + cves: (row.cves || []).filter(Boolean), + hostnames: (row.hostnames || []).filter(Boolean).sort(), + })); + + res.json({ + platform_vendor: vendor, + platform_model: model, + os_versions, + }); + } catch (err) { + console.error('[Scan Posture] /platforms/:vendor/:model/details error:', err.message); + res.status(500).json({ error: 'Failed to fetch platform details' }); + } + }); + + /** + * GET /summary + * + * Returns high-level summary stats for the scan posture dashboard header. + * Results are scoped to the user's assigned teams. + * + * @response 200 - { platform_count, total_devices, total_findings, critical_findings, high_findings, last_scan_time } + * @response 500 - { error: string } + */ + router.get('/summary', async (req, res) => { + try { + let buFilter = ''; + const params = []; + + if (req.teamScope) { + buFilter = `AND bu_ownership = ANY($1)`; + params.push(req.teamScope.ivanti); + } + + const { rows } = await pool.query(` + SELECT + COUNT(DISTINCT os_name) FILTER (WHERE os_name IS NOT NULL AND os_name != 'Not Available') AS platform_count, + COUNT(DISTINCT host_name) AS total_devices, + COUNT(*) AS total_findings, + COUNT(*) FILTER (WHERE vrr_group = 'Critical') AS critical_findings, + COUNT(*) FILTER (WHERE vrr_group = 'High') AS high_findings, + MAX(last_found_on) AS last_scan_time + FROM ivanti_findings + WHERE state = 'open' + ${buFilter} + `, params); + + res.json(rows[0] || {}); + } catch (err) { + console.error('[Scan Posture] /summary error:', err.message); + res.status(500).json({ error: 'Failed to fetch summary' }); + } + }); + + return router; +} + +module.exports = createScanPostureRouter; diff --git a/backend/scripts/backfill-os-fields.js b/backend/scripts/backfill-os-fields.js new file mode 100644 index 0000000..1a3bf86 --- /dev/null +++ b/backend/scripts/backfill-os-fields.js @@ -0,0 +1,102 @@ +#!/usr/bin/env node +// One-time backfill: Fetch OS info from Ivanti API for existing local findings +// and populate the new os_name, os_family, os_vendor columns. +// +// Strategy: Get all distinct host_ids from local DB, then fetch findings by host +// in batches to get the operatingSystemScanner data. Much faster than scanning +// all 800K+ Ivanti findings page by page. +// +// Safe to re-run — only updates rows where os_name IS NULL. + +require('dotenv').config(); +const pool = require('../db'); +const { ivantiPost } = require('../helpers/ivantiApi'); + +const apiKey = process.env.IVANTI_API_KEY; +const clientId = process.env.IVANTI_CLIENT_ID || '1550'; +const skipTls = process.env.IVANTI_SKIP_TLS === 'true'; + +async function backfill() { + if (!apiKey) { + console.error('IVANTI_API_KEY not set'); + process.exit(1); + } + + console.log('[Backfill OS] Starting...'); + + // Get all local finding IDs that need OS backfill + const { rows } = await pool.query( + `SELECT id FROM ivanti_findings WHERE os_name IS NULL LIMIT 10000` + ); + + if (rows.length === 0) { + console.log('[Backfill OS] No findings need backfill — all have os_name populated.'); + await pool.end(); + return; + } + + console.log(`[Backfill OS] ${rows.length} findings need OS data`); + + // Process in batches of 100 IDs via Ivanti search filter + const BATCH = 100; + let updated = 0; + const allIds = rows.map(r => r.id); + + for (let i = 0; i < allIds.length; i += BATCH) { + const batchIds = allIds.slice(i, i + BATCH); + const urlPath = `/client/${encodeURIComponent(clientId)}/hostFinding/search`; + + try { + const result = await ivantiPost(urlPath, { + filters: [ + { + field: 'id', + operator: 'IN', + value: batchIds.join(','), + exclusive: false, + orWithPrevious: false, + implicitFilters: [], + caseSensitive: false + } + ], + projection: 'internal', + sort: [{ field: 'id', direction: 'ASC' }], + page: 0, + size: BATCH + }, apiKey, skipTls); + + if (result.status !== 200) { + console.warn(`[Backfill OS] API returned ${result.status} for batch starting at ${i}`); + continue; + } + + const data = JSON.parse(result.body); + const findings = data._embedded?.hostFindings || []; + + for (const f of findings) { + const id = String(f.id); + const os = f.operatingSystemScanner || {}; + const osName = os.name || null; + const osFamily = (os.family && os.family !== 'Not Reported') ? os.family : null; + const osVendor = (os.vendor && os.vendor !== 'Not Reported') ? os.vendor : null; + + if (osName) { + await pool.query( + `UPDATE ivanti_findings SET os_name = $1, os_family = $2, os_vendor = $3 WHERE id = $4`, + [osName, osFamily, osVendor, id] + ); + updated++; + } + } + + console.log(`[Backfill OS] Batch ${Math.floor(i / BATCH) + 1}/${Math.ceil(allIds.length / BATCH)} — ${updated} updated`); + } catch (err) { + console.error(`[Backfill OS] Batch error at offset ${i}:`, err.message); + } + } + + console.log(`[Backfill OS] Complete — ${updated} findings updated with OS info`); + await pool.end(); +} + +backfill(); diff --git a/backend/server.js b/backend/server.js index a74add1..b65c73d 100644 --- a/backend/server.js +++ b/backend/server.js @@ -43,6 +43,8 @@ const createWebhooksRouter = require('./routes/webhooks'); const createNotificationsRouter = require('./routes/notifications'); const createNetboxRouter = require('./routes/netbox'); const createTicketsRouter = require('./routes/tickets'); +const createScanPostureRouter = require('./routes/scanPosture'); +const createInfobloxRouter = require('./routes/infoblox'); const app = express(); const PORT = process.env.PORT || 3001; @@ -293,6 +295,12 @@ app.use('/api/notifications', createNotificationsRouter()); // NetBox device inventory routes — DCIM device CRUD, IP cross-reference app.use('/api/netbox', createNetboxRouter()); +// Infoblox DNS lookup routes — read-only reverse DNS via api-proxy-ease.charterlab.com +app.use('/api/infoblox', createInfobloxRouter()); + +// Scan Posture routes — executive platform/code version dashboard (Access Ops) +app.use('/api/scan-posture', createScanPostureRouter()); + // GitLab webhook routes — receives issue lifecycle events (no auth required) app.use('/api/webhooks', createWebhooksRouter()); diff --git a/docs/api/infoblox b/docs/api/infoblox new file mode 100644 index 0000000..ad7a1e5 --- /dev/null +++ b/docs/api/infoblox @@ -0,0 +1,1197 @@ +Infoblox WAPI documentation +Introduction +The Infoblox WAPI is an interface based on REST (REpresentational State Transfer), also called a RESTful web API. It uses HTTP methods for operations and supports input and output in JSON and XML. + +Notation +The following conventions are used to describe syntax for WAPI methods and objects: + +What + +Description + +objref + +A reference to an object. This must be a reference returned from an earlier call. For more information, see Object Reference. + +WAPI + +Used as a generic start in an URL. In real calls, this needs to be replaced with /wapi/v2.13.8 or similar syntax. + +objtype + +The name of an object type, such as network. + +field + +The name of a field, such as comment. + +value + +The value of an item, such as a field. The value must be quoted according to where it is used. For information, see Naming and Values. + +[thing] + +These brackets are used to signify an optional value. + +a | b + +The symbol | is used to indicate that either a or b can be used. + +thing… + +... is used at the end of an item to signify that it can be repeated multiple times. Items must be separated in accordance with where they are used, such as & in arguments. + +{ } + +These brackets are used to group information in descriptions. + +Transport and Authentication +WAPI uses HTTPS (HTTP over SSL/TLS) as the transport mechanism. The server certificate used for WAPI is the same certificate used by NIOS for the GUI and PAPI. + +WAPI supports both HTTP basic authentication and certificate-based authentication. For certificate-based authentication, see URL here. It is supported to use the connection for multiple requests. In this case, authentication is handled by supplying the cookie (ibapauth) that was returned after the initial authentication. This cookie can be invalidated by sending a POST request to /wapi/v2.13.8/logout + +WAPI supports the same underlying authentication methods that NIOS supports for username and password. All WAPI users must have permissions that grant them access to the API (same as PAPI). + +Backward Compatibility +The Infoblox WAPI has a versioning scheme that is independent of the NIOS versioning scheme. The current WAPI version is 2.13.8. + +A current WAPI version is backward compatible with WAPI releases that have the same major WAPI version or with designated earlier major versions. Though the protocol itself may not be strictly backward compatible, the server emulates the correct behavior, when necessary. + +For example, a client that uses WAPI version X behaves the same way in version Y if X is supported by Y (that is X is lower than Y and X has the same major version as Y or X uses a major version that is supported by Y). + +The WAPI protocol is versioned (see URL in General Syntax and Options) independently from NIOS. Refer to the release notes for information about the WAPI version. + +Requirements and exceptions: + +Rely on errors returned by HTTP Error Status only, not by text messages or other components. + +New objects and fields may exist in a later WAPI version. Thus, additional fields may be returned and must be ignored. + +New syntaxes and values may be supported. Do not rely on receiving errors for illegal usage. + +In the URL, use the WAPI version that corresponds to the behavior you expect. Do not combine requests using different WAPI versions in the same session or connection. + +General Syntax and Options +All WAPI requests consist of three parts; URL, Arguments and Data (body). + +URL + +The first part of the URL identifies the requests as a WAPI request and specifies the expected version of WAPI. The URL syntax is wapi/v major.minor, e.g. wapi/v3.4/. The current version of the API is 2.13.8. + +The second part of the URL identifies the resource, such as a network, on which the request operates. + +Arguments + +CGI query arguments (after ?) can be used to specify general options and method specific options and data for the request. All options start with the character _ (underscore). + +The general options are: + +Option + +Description + +_return_type + +Data format for returned values; defaults to json. Valid choices: json, json-pretty, xml, xml-pretty. -pretty variants are the same except that they are formatted for readability. For more information, see Data Formats. + +_method + +An alternative way of specifying HTTP method and overrides the method used. The default is to use the actual HTTP method. Valid choices: GET, PUT, DELETE and POST + +Argument key = value pairs must be separated with &. The values must be quoted using % xx notation if they contain the following: =, &, +, %, or space. + +You can specify only atomic values as arguments (i.e. booleans, integers, or strings). You must use a method that contains a body if lists or structures are needed. Example: POST with _method=GET can be used for searching. + +In all method descriptions, you can use general options with all requests unless specifically noted. + +The methods have additional options as described in their respective sections. + +The following table lists the scheduling and approval specific options. Note that you can apply these options only to PUT, POST and DELETE requests. + +Option + +Description + +_schedinfo.scheduled_time + +If set, the requested operation will be scheduled for later execution at the specified time (specified in Epoch seconds). A reference to the created scheduledtask object will be returned. Only one of scheduled_time and schedule_now can be set in the request. + +_schedinfo.schedule_now + +If set to True, the operation will be scheduled for execution at the current time. Note that only scheduled_time or schedule_now can be set in the request. + +_schedinfo.predecessor_task + +Optional reference to a scheduled task that will be executed before the submitted task. + +_schedinfo.warnlevel + +Optional warning level for the operation, valid values are ‘WARN’ and ‘NONE’. If not specified, ‘NONE’ will be used. + +_approvalinfo.comment + +Comment for the approval operation (this can be optional or required depending on the settings for the approval workflow). + +_approvalinfo.query_mode + +Optional query mode for the approval operation. Valid values are “true” or “false”, if this is set to true and the request would have required approval, an error message will be returned. The default value for this is “false”. + +_approvalinfo.ticket_number + +Ticket number for the approval operation (this can be optional or required depending on the settings for the approval workflow). + +Data (Body) + +Contains data that is dependent on the method. For information about data format and how to specify it, see Data Formats. Only, PUT, and POST methods can have a Body on input. All methods have Body on output. + +Example + +The GET request: + +https://1.2.3.4/wapi/v2.13.8/networkview? +_return_type=xml-pretty&name=default +Returns with a body: + + + + + true + <_ref>networkview/ZG5zLm5ldHdvcmtfdmlldyQw:default/true + default + + +Naming and Values +WAPI uses a leading underscore (_) for all reserved arguments, fields, and items. Example: _return_type and _ref. + +Fields in objects always start with a letter (a-z) and are followed by a zero or more letters, digits, and underscores. No other characters are used in field identifiers. + +Field and argument values must be quoted according to where they are used. Examples: + +URL/CGI args, x-www-form-urlencoded: + +Use %xx encoding for “%”, “;”, “/”, “?”, “:”, “@”, “&”, “=”, “+”, “$”, “,” and ” ” (a space) + +JSON Data: + +Use JSON quoting, as specified at http://json.org + +XML Data + +Use XML quoting (& etc.) as needed for XML. + +Values set in WAPI object fields might differ from the effective value used for that particular field during product operation, which could be a value inherited from the Grid or the Grid Member depending on the particular object in question and the state of the object use flags. + +Object Reference +WAPI Objects are referenced using their Object References. WAPI returns this reference when an object is created, modified, deleted or read. This reference is used to identify the object for the same operations. + +An object reference is a string with the following format, without spaces: + +wapitype / refdata [ : name1 [ { / nameN }… ] ] + +Component + +Description + +wapitype + +The object type being referenced. Example: network. + +refdata + +Opaque internal object identifier. A sequence of letters, digits, “-” (dash) and “_” (underscore). + +nameN + +Object type dependent name component N. The component describes the object being referenced. This is only returned for objects with a defined name format. It is always optional on input and never used by the server. + +The documentation for each object type describes the format of its name components. Name components are separated by “/” (or only one component without a “/”). Each name component uses the URL quoting method (%xx notation) when necessary (for example if it contains a “/” character). + +If the name is defined for the object type, it can be used by a client to get basic information about an object without retrieving the full object. Example: the name of a host. However, an object’s name is not guaranteed to uniquely identify an object unless specifically noted in its description. + +The name is not used by the WAPI server on input, and any supplied value is disregarded. For example, a client is free to send a previously returned reference to the server, with or without the name part, including the leading colon (:). The result is not affected. + +Note that non-ascii values in name are returned using % notation, and should be interpreted as hex-encoded utf-8. + +Example: + +record:cname/ZG5 .... DE:t1.webapi16.foo.bar/default +Function Calls +Functions are associated with particular objects. The method specific option _function should be used to specify the name of function to call. Only POST method allows function calls. You can use either CGI argument key = value pairs or request’s data(body) to specify values for function arguments. Simultaneous use of CGI arguments and data(body) is not supported. + +Example 1 + +The POST request: + +https://1.2.3.4/wapi/v2.13.8/network/ +ZG5zLm5ldHdvcmskMTAuMC4wLjAvMjQvMA:10.0.0.0/24/default? +_function=next_available_ip&num=3 +Returns with a body: + +{ + "ips": [ + "10.0.0.1", + "10.0.0.2", + "10.0.0.3" + ] +} +Example 2 + +The POST request: + +https://1.2.3.4/wapi/v2.13.8/network/ +ZG5zLm5ldHdvcmskMTAuMC4wLjAvMjQvMA:10.0.0.0/24/default? +_function=next_available_ip +Sent with a body: + +{ + "num": 3 +} +Returns with a body: + +{ + "ips": [ + "10.0.0.1", + "10.0.0.2", + "10.0.0.3" + ] +} +Extensible Attributes +Object types that allow for extensible attributes have a field called extattrs, which can be read by including the name in the _return_fields option of the GET method. + +Extensible attributes are sets of name value pairs in which the values can be lists, if the attribute allows for multiple values. + +Searching for extensible attributes requires the use of a special syntax, as described under the GET method. + +Use Flags +Some fields are associated with a corresponding boolean flag value that has the prefix use_. For example, ttl is associated with the flag use_ttl. In an object, the value of this field will only take effect when its use flag is true. Otherwise, the value will be inherited from a higher level setting. + +Use flags and fields that contain the flags behave mostly like other object fields. They are special in the following ways: + +All use flags have names such as “use_*”, where “*” is typically the name of the associated field. Multiple fields may share the same use flag. + +Use flags can be read using _return_fields. + +If a field is part of the default fields returned on read (“basic object”), its associated use flag (if any) will also be included in the default set. + +Use flags can be written by PUT or POST requests. + +Writing a field that has a corresponding use flag will automatically set the use flag to true, unless the same request also sets the use flag to false. + +Data Formats +Input + +The body of the HTTP request contains data for the PUT and POST requests only. The format of the data defaults to JSON, but it can be changed using Content-Type: header. The valid content types are: + +Content Type + +Description + +application/json + +JSON format, see http://json.org for more information. + +application/xml + +XML format, see XML Format for more information. + +text/xml + +Alternative way to specify application/xml. + +application/x-www-form-urlencoded + +Arguments to method encoded in body. This is the same as specification after ?, but it can handle longer sequences and is directly supported by HTML forms. If arguments are encoded in the body, CGI query arguments won’t be allowed. + +Output + +Data returned to the client defaults to JSON, but can be changed using either Accept: header or _return_type. Accept: takes the same values as Content-Type, listed above (for exceptions to this, see Error Handling); _return_type overrides any Accept: header. + +XML Format +WAPI uses the following XML constructs: + +Element + +Description + + + +Array, child nodes are items in list. Names of child elements are not significant (and can be same). + + + +Object X, child nodes are members of object. X can be any value if used outside an object context + + + +Field X of object. Its value is the text of the element. Allowed types (T) are int, float, boolean and string (as in XML Schema Definition). String is the default and is not explicitly specified using type= on output. + + + +Field X with value null/None. + +Field syntax is used for “bare” values in list/array or as single values. X is not significant and will always be value on output. + +No name spaces are used or specified. + +Example: XML (xml-pretty style): + + + + + + <_ref>network/ZG5zLm5ldHdvcmskMTAuMC4wLjAvOC8w:20.0.0.0/8/default + false + 20.0.0.0/8 + default + + +If X is considered an illegal XML tag name, or if it begins with “tag” and is followed by a number it will be renamed to tag0-N and an additional “name” property will be added on retrieval and expected on input. For example, the XML for an object with extensible attributes that contain spaces in their names would look like the following: + + + + + 8.0.0.0/8 + + + d + + + c + + + b + + + <_ref>network/ZG5zLm5ldHdvcmskOC4wLjAuMC84LzA:8.0.0.0/8/default + + +Error Handling +All errors return a HTTP status code of 400 or higher. + +All methods use the following generic error status codes. Specific return codes used for a method are specified for each method. + +Status + +Description + +400 + +Bad Request. The request from the client is incorrect. This could be syntax errors in the request or data conflict issues on the server side. The request should not be repeated as is unless the error condition has been cleared (i.e. either the request syntax corrected or the state of the database changed.) + +500 + +Server Error. The error was not caused by any error in the request. Depending on the error the request may be successfully repeated as is. If not possible to resolve, please report to Infoblox (including the full error return with the “trace”). + +4xx codes refer to errors caused by the request or the data. To some extent, all of these are user errors. + +5xx codes refer to server or internal errors. These errors point to deficiency in the server code and are not usually possible under normal conditions. + +When the server returns an error with status code >= 400, the body is always in JSON format, irrespective of any Accept or _return_types. + +The returned message conforms to JSON, but is formatted to ensure that the first line of the body always contains the text “Error,” an error type, and an error message. + +A client that only gives a description of the error can simply show the first returned line. + +The full returned error data is an object with the following fields (all values are strings): + +Field + +Value + +Error + +Error type (followed by an explanation after :). + +code + +Symbolic error code. + +text + +Explanation of the error. + +trace + +Debug trace from the server, only if debug is on. + +Example of Error Return (trace shortened): + +{ "Error": "AdmConProtoError: Unknown argument/field: netwdork", + "code": "Client.Ibap.Proto", + "text": "Unknown argument/field: netwdork", + "trace": " File "/infoblox/common/lib/python/info..." +} +Methods +GET +Search and Read Objects: GET Method + +HTTP GET is used to read a single object or to search for objects. + +Syntax + +GET WAPI / objref [ ? option… ] + +or + +GET WAPI / objtype [ ? { option | condition }… ] + +Description + +GET is used to read objects. The objects to read can be specified either by using an Object Reference (objref) to read one specific object or by searching for objects of a specific type (objtype) with the given search conditions. + +Arguments to the search (objtype) form are field names and values to match. If no arguments are used, all object for the object type objtype are returned. + +The number of objects returned is limited by the option _max_results or, if _max_results is not specified, 1000 objects. If _max_results is not specified, the appliance returns an error when the number of returned objects would exceed 1000. Similarly, if _max_results is set to -500 (maximum of 500 objecs) the appliance returns an error if the number of returned objects would exceed 500. + +Options + +Method Option + +Description + +_max_results + +Maximum number of objects to be returned. If set to a negative number the appliance will return an error when the number of returned objects would exceed the setting. The default is -1000. If this is set to a positive number, the results will be truncated when necessary. + +_return_fields + +List of returned fields separated by commas. The use of _return_fields repeatedly is the same as listing several fields with commas. The default is the basic fields of the object. + +_return_fields+ + +Specified list of fields (comma separated) will be returned in addition to the basic fields of the object (documented for each object). + +_return_as_object + +If set to 1, a results object will be returned (see below for more information). If not specified, it defaults to 0. + +_paging + +If set to 1, the request is considered a paging request (see below for more information). If not specified, it defaults to 0. If set, _max_results must also be set. + +_page_id + +If set, the specified page of results will be returned. + +_proxy_search + +If set to ‘GM’, the request is redirected to Grid master for processing. If set to ‘LOCAL’, the request is processed locally. This option is applicable only on vConnector grid members. The default is ‘LOCAL’. + +_schema + +If this option is specified, a WAPI schema will be returned (see below for more information). + +_schema_version + +If this option is specified, a WAPI schema of particular version will be returned. If options is omitted, schema version is assumed to be 1. For the full list of available versions please refer to information below. + +_get_doc + +If this option is specified, a WAPI schema with documentation will be returned. Applicable only when _schema_version is 2. + +_schema_searchable + +If this option is specified, search only fields will also be returned. Applicable only when _schema_version is 2. + +_inheritance + +If this option is set to True, fields which support inheritance, will display data properly. + +Arguments + +There can be no arguments to objtype or it can have one or multiple conditions in the following format: + +{ field | * attribute [ ] } [ modifiers ] = value + +Where: + +field is a documented field of the object. + +attribute is the name of an extensible attribute. Must be prefixed by an asterisk (*) and optionally followed by a single space. + +modifiers is optional and can be one or more search modifiers supported by the field or extensible attribute value type. + +value is the value or regular expression to search for. + +When combining multiple conditions, all must be satisified in order to match an object (i.e. conditions are combined with AND). + +When a field is a list or an extensible attribute that can have multiple values, the condition is true if any value in the list matches. + +If no modifiers are used, it is an exact match. + +Search Modifiers + +A search argument can use the following modifiers: + +Modifier + +Functionality + +! + +Negates the condition. + +: + +Makes string matching case insensitive. + +~ + +Regular expression search. Expressions are unanchored. + +< + +Less than or equal. + +> + +Greater than or equal. + +Only one of the following can be specified at one time: greater than, less than, and regular expressions. + +You can find the modifiers that are supported by each field in the respective documentation. Unsupported combinations will result in an error. + +Depending on the attribute type, following are modifiers supported by extensible attributes: + +integer and date support !, < and >. All other types behave like strings and support !, ~ and :. + +Data Returned + +In the object reference form (objref) only one object is returned (as an object, not a list). In the search form (objtype) the request always returns a list of objects (even if zero or one objects is returned). + +Objects returned will by default consist of a set of basic fields, as listed in the documentation. The option _return_fields can be used to request a specific set of fields to return. + +Fields that have no value (not set in the NIOS database) or that are not allowed to be accessed by the user because of group access rights will not be returned (i.e. silently left out of the result). + +Returned objects will also contain a _ref field, containing the reference of the object. This can be used in subsequent calls that require a reference. + +If a search matches no objects, an empty list will be returned. + +If a results object is requested, an object with the following fields will be returned: + +Field + +Present + +Description + +result + +Always + +Actual result of the read operation, this is a list of objects. + +next_page_id + +Optional + +If there was a paging request, this is the ID for the next page of results. + +Some fields refer to other subobjects. Some of these fields also support nested return fields (see the field’s ‘Type’ section for more information). In the case of nested return fields, you can request specific fields of the subobject by concatenating them to the parent field using the ‘.’ (period) character. + +For example, during a search for record:host, you can request the return of the ‘bootserver’ field in subobject ‘ipv4addrs’ by passing a return field in the form of ‘ipv4addrs.bootserver’. You can also specify subobject fields as part of a _return_fields+ invocation. In this case, the specified return field will be returned in addition to the standard fields for the specified subobject. + +If an empty subobject field is passed, and the subobject field is a reference-only nest return field, it is equivalent to asking for the standard fields of that subobject. This can be useful if the subobject field returns only the reference of the subobject by default. For example, in the ‘permission’ object, the ‘object’ field normally contains only the reference of the object to which the permission applies. To request the standard fields for the object, you must explicitly reference the field name preceded by the keyword “object” and a period “.” For example, _return_fields=object.fqdn + +If a field can support multiple object types, for example ‘record’ inside allrecords, only fields common to all the multiple object types should be specified as subobject fields. Otherwise if a subobject for which the subfield is not valid exists, an error would be returned. + +Return Status/Errors + +Status + +Description + +200 + +Normal return. Referenced object or result of search in body. + +400 + +Results set would contain more than _max_results objects (only generated if _max_results is negative). + +404 + +Referenced object not found (if objref form is used, empty list and 200 is returned for empty search result) + +Results paging + +For searches that return a large number of results, paging is desirable. + +To start a paging request, the initial search request must have _paging and _return_as_object set to 1, and _max_results set to the desired page size. + +The server will then return a results object that contains the next_page_id field and the result field set to the first page of results. + +Note that the next_page_id field only contains URL-safe characters so it can be used as is and no quotation characters are required for subsequent requests. + +To get more results, you should send GET requests to the original object and set _page_id to the ID string returned in the previous page of results. + +The server does not return a next_page_id field in the last page of results. Paging requests are considered independent requests, so the set of results might change between requests if objects are added or removed from the server at the same time when the requests are occurring. + +For an invocation example, see the sample code section in the manual here. + +WAPI Schema Fetching + +If the _schema option is passed, the request will execute a schema fetch. Other options, such as _max_results, _return_fields, etc., will be ignored. + +The WAPI schema returned in the format requested using either the Accept: header or _return_type as specified by WAPI. + +Note that this is not intended to be a schema as defined by JSON or XML standards. + +If a WAPI schema is requested using the _schema option without specifying objtype, an object with the following fields will be returned: + +Field + +Description + +requested_version + +Actual requested version of the WAPI schema. + +supported_objects + +List of supported objects in the requested version. + +supported_versions + +List of all supported versions. + +Example. Use a GET request to get the WAPI schema: + +https://1.2.3.4/wapi/v1.0/?_schema +Returns with a body (lists shortened): + +{ "requested_version": "1.0", + "supported_objects": ["ipv4address", "ipv6address", "ipv6network", + "ipv6networkcontainer", "ipv6range", + "macfilteraddress", "network", ...], + "supported_versions": ["1.0", "1.1", "1.2", "1.2.1", ...] +} +If the described above is done specifying _schema_version=2, then following field will be returned additionally: + +Field + +Description + +schema_version + +The version of schema description requested. + +supported_schema_versions + +List of supported versions for schema description. + +Example: + +https://1.2.3.4/wapi/v2.5/?_schema=1&_schema_version=2 +Returns with a body (lists shortened): + +{ "requested_version": "2.5", + "schema_version": "2", + "supported_schema_versions": ["1", "2",], + "supported_objects": ["ad_auth_service", ... ], + "supported_versions": ["2.3","2.5", ... ] +} +If the objtype is specified for WAPI schema fetching, an object with the following fields will be returned: + +Field + +Description + +cloud_additional_restrictions + +List of cloud restrictions. + +fields + +List of fields of the object. + +restrictions + +List of object restrictions. + +type + +Requested objtype. + +version + +Actual requested version of the WAPI object schema. + +The fields specific to schema description #2: + +Field + +Description + +schema_version + +The version of schema description requested. + +wapi_primitive + +Determines if the requested WAPI primitive is object, structure or function call. + +The list of object restrictions that contain supported operations for the object. Example of operations: “create”, “delete”, “read”, “update”, “function call”, “permissions”, “global search”, “scheduling”, “csv”. + +The cloud_additional_restrictions field contains the list of additional unsupported operations when using Cloud Network Automation. Example of operations: “all”, “create”, “delete”, “read”, “update”, “function call”, “permissions”, “global search”, “scheduling”, “csv”. + +The returned fields list is composed by individual objects each describing a field of the API object. These objects have the following members: + +Parameter + +Description + +is_array + +True if this field is an array. + +name + +Name of this field. + +searchable_by + +String with supported search modifiers: “=”, “!”, “:”, “~”, “<”, “>”. + +standard_field + +True for fields that are returned by default. + +supports + +List of supported operations: “s”, “w”, “u”, “r”. + +type + +List of supported types. + +wapi_primitive + +Determines if the requested WAPI primitive is object, structure or function call. + +The fields specific to schema description #2: + +Field + +Description + +schema_version + +The version of schema description requested. + +wapi_primitive + +Determines if the requested WAPI primitive is object, structure or function call. + +supports_inline_funccall + +Determines if the field can be initialized by calling an inline function. + +doc + +The documentation of this field. It’s applicable only when _get_doc=1 is used. The returned documentation string might contain ReStructuredText directives. + +The version #2 delivers all information regarding structures and function calls. + +Please keep in mind that enum_values is changed in #2. It cannot be a dictionary, as it was in #2, but a list. + +Example. Use a GET request to get the ‘networkview’ WAPI object schema for WAPI version 1.4: + +https://1.2.3.4/wapi/v1.4/networkview?_schema +Returns with a body (lists shortened): + +{ "cloud_additional_restrictions": ["all"], + "fields": [{ + "is_array": false, + "name": "comment", + "searchable_by": ":=~", + "standard_field": true, + "supports": "rwus", + "type": ["string"] + }, { + "is_array": false, + "name": "name", + "searchable_by": ":=~", + "standard_field": true, + "supports": "rwus", + "type": ["string"] + }, ...], + "restrictions": ["scheduling", "csv"], + "type": "networkview", + "version": "1.4" +} +Example of new information for version #2 (the same request as above but differt objtype and HTTP arguments: + +https://1.2.3.4/wapi/v2.13.8/grid?_schema=1& + _schema_version=2&_get_doc=1 +Returns with a body (lists shortened and cut): + +{ "doc": "Test connectivity to the REST API endpoint.", + "is_array": false, + "name": "test_connection", + "schema": { + "input_fields": [], + "output_fields": [ + { "doc": "The overall status of connectivity test.", + "enum_values": [ + "FAILED", + "SUCCESS" + ], + "is_array": false, + "name": "overall_status", + "supports": "r", + "type": ["enum"] + }, + { "doc": "The test connectivity failed error message.", + "is_array": false, + "name": "error_message", + "supports": "r", + "type": ["string"] + } + ] + }, + "standard_field": false, + "supports": "rwu", + "type": ["testconnectivityparams"], + "wapi_primitive": "funccall" +}, +{ "doc": "The notification REST template instance. The parameters of + REST API endpoint template instance are prohibited to + change.", + "is_array": false, + "name": "template_instance", + "schema": { + "fields": [ + { "doc": "The name of REST API template parameter.", + "is_array": false, + "name": "template", + "supports": "rwu", + "type": ["string"] + }, + { "doc": "The notification REST template parameters.", + "is_array": true, + "name": "parameters", + "supports": "rwu", + "type": ["notification:rest:templateparameter"] + } + ] + }, + "standard_field": false, + "supports": "rwu", + "type": ["notification:rest:templateinstance"], + "wapi_primitive": "struct" +}, +WAPI Inheritance Data Fetching + +If the _inheritance option is passed and set to True, the request will fetch inheritance data. Inheritance data will only show for fields which were queried and support this mode. Inheritance support started from version 2.10.2. + +In case of basic inheritance, an object with following fields will be returned: + +Field + +Description + +inherited + +Flag to display whether value was inherited or not. + +multisource + +Flag to display if value was inherited from multiple sources. + +source + +String containing WAPI reference to source of data. Empty string if data belongs to a queried object. + +value + +Actual value. + +Example. Use a GET request to get the ‘grid’ object’s ‘ipam_threshold_settings’ and inheritance info, if needed: + +https://1.2.3.4/wapi/v2.10.3/network?_inheritance=True + &_return_fields=ipam_threshold_settings +Output when object’s data is shown: + +{ + "ipam_threshold_settings": { + "inherited": false, + "multisource": false, + "source": "", + "value": { + "reset_value": 86, + "trigger_value": 96 + } + } +}, +Output when inherited data is shown: + +{ + "ipam_threshold_settings": { + "inherited": true, + "multisource": false, + "source": "grid/b25lLmNsdXN0ZXIkMA:Infoblox", + "value": { + "reset_value": 85, + "trigger_value": 95 + } + } +}, +In case of multiple inheritance, array of values with their sources will be shown. An object with following fields will be returned: + +Field + +Description + +inherited + +Flag to display whether value was inherited or not. + +multisource + +Flag to display if value was inherited from multiple sources. + +values + +List of structs, describing values, inherited from multiple source. Structure described below. + +Structure of ‘values’ object: + +Field + +Description + +source + +String containing WAPI reference to source of data. Cannot be empty. + +value + +Actual value. + +Example of query: + +https://1.2.3.4/wapi/v2.10.3/network?_inheritance=True + &_return_fields=pxe_lease_time +Output: + +{ + "pxe_lease_time": { + "inherited": true, + "multisource": true, + "values": [ + { + "source": "grid:dhcpproperties/ZG5zLmNX9wZXJ0aWVzJDA:Infoblox", + "value": 43200 + }, + { + "source": "member:dhcpproperties/ZG5zMkMA:infoblox.localdomain", + "value": 403200 + } + ] + } +}, +In case if the object of inheritance is a list consisting of structs and every item of this list is inherited independently, items will be grouped by their sources. Some items may appear several times if they are inherited from multiple sources. + +Structure of such groups is described below: + +Field + +Description + +inherited + +Flag to display whether value was inherited or not. + +source + +String containing WAPI reference to source of data. Empty string if data belongs to a queried object. + +value + +List of structs, inherited from given source. + +Example of query: + +https://1.2.3.4/wapi/v2.10.3/network?_inheritance=True + &_return_fields=options +Output: + +{ + "options": [ + { + "inherited": true, + "source": "member:dhcpproperties/ZG5zL1lByb3BlcnRpZXMkMQ:mem.ber", + "values": [ + { + "name": "subnet-mask", + "num": 1, + "value": "255.255.254.0", + "vendor_class": "DHCP" + } + ] + }, + { + "inherited": true, + "source": "member:dhcpproperties/ZG5zLXMkMA:infoblox.localdomain", + "values": [ + { + "name": "subnet-mask", + "num": 1, + "value": "255.255.255.0", + "vendor_class": "DHCP" + } + ] + }, + { + "inherited": true, + "source": "grid:dhcpproperties/ZG5zLmNXN0Z9wZXJ0aWVzJDA:Infoblox", + "values": [ + { + "name": "dhcp-lease-time", + "num": 51, + "use_option": False, + "value": "43200", + "vendor_class": "DHCP" + } + ] + } + ] +}, +POST +Create Object: POST Method + +The POST method is used to create a new object. It can also be used for all other operations via the the wapi object + +Syntax +POST WAPI / objtype [ ? { options | field = value }… ] + +Description +The data for the request must contain all required fields of the objtype. Data can be given as arguments as shown above or as the body of the request (but not both). + +Options +Method Option + +Description + +_return_fields + +A list of returned fields separated by commas. The use of _return_fields repeatedly is the same as listing several fields with commas. The default is the basic fields of the object. + +_return_fields+ + +Specified list of fields (comma separated) will be returned in addition to the basic fields of the object (documented for each object). + +_inheritance + +If this option is set to True, fields which support inheritance, will display data properly. + +Options can be given only as query arguments as shown above, they cannot be included in the body of the request. + +Arguments +Arguments can be used to supply the object instead of using the body. + +Data (Body) +Data for object to be created. Can be used as alternative to arguments. All fields marked as required for the object must be supplied. All fields not supplied will be defaulted as specified for the object. See Use Flags for information about special handling for these fields. + +Data Returned +Object Reference of the object created, returned as a string. + +If required, specify the ‘_return_fields’ option to examine the values of fields that were set by the appliance as part of the insertion. It is possible for the appliance to return the newly inserted object, instead of a reference string. + +Passing an empty value to the ‘_return_fields’ option will cause only the object reference to be set inside the returned object. Passing an empty value to the ‘_return_fields+’ option will cause the returned object to contain its standard fields. Passing any other values will return the specified fields. + +Return Status/Errors + +Status + +Description + +201 + +Object created (success) + +PUT +Update Object: PUT Method + +The PUT method is used to update an existing object. The syntax of PUT is: + +Syntax +PUT WAPI / objref [ ? { option | field = value }… ] + +Description +PUT is used to update an existing object (given by the Object Reference, objref in the request). Only the fields supplied are updated (except as described for Use Flags). + +Options +Method Option + +Description + +_return_fields + +List of returned fields separated by commas. The use of _return_fields repeatedly is the same as listing several fields with commas. The default is the basic fields of the object. + +_return_fields+ + +Specified list of fields (comma separated) will be returned in addition to the basic fields of the object (documented for each object). + +_inheritance + +If this option is set to True, fields which support inheritance, will display data properly. + +Options can be given only as query arguments as shown above, they cannot be included in the body of the request. + +Arguments +The data to be updated can be given as argument as shown in the syntax or as the body of the request (but not both). + +Data (Body) +Data for object to be updated. Can be used as alternative to arguments. + +Data Returned +Object Reference of the object modified, returned as a string. +The object reference may have been changed by the operation. + +If required, specify the ‘_return_fields’ option to examine the values of fields that were set by the appliance as part of the update. It is possible for the appliance to return the newly updated object, instead of a reference string. + +Passing an empty value to the ‘_return_fields’ option will cause only the object reference to be set inside the returned object. Passing an empty value to the ‘_return_fields+’ option will cause the returned object to contain its standard fields. Passing any other values will return the specified fields. + +Return Status/Errors + +Status + +Description + +200 + +Object updated (success) + +DELETE +Delete Object: DELETE Method + +The DELETE method is used to delete an object. + +Syntax +DELETE WAPI / objref [ ? option… ] + +Description +DELETE is used to delete an existing object (given by the Object Reference, objref in the request). + +Options +There are no DELETE specific options. + +Arguments +There are no general DELETE arguments. Some of the objects has object-specific DELETE arguments, which are described in the ‘Delete arguments’ section of their respective documentation. + +Data Returned +Returns the Object Reference of the deleted object as a string. + +Return Status/Errors + +Status + +Description + +200 + +Object deleted (success) + diff --git a/frontend/src/App.js b/frontend/src/App.js index fcc92c0..0ee20c7 100644 --- a/frontend/src/App.js +++ b/frontend/src/App.js @@ -13,6 +13,7 @@ import VulnerabilityTriagePage from './components/pages/ReportingPage'; import KnowledgeBasePage from './components/pages/KnowledgeBasePage'; import ExportsPage from './components/pages/ExportsPage'; import CompliancePage from './components/pages/CompliancePage'; +import ScanPosturePage from './components/pages/ScanPosturePage'; import CCPMetricsPage from './components/pages/CCPMetricsPage'; import JiraPage from './components/pages/JiraPage'; import AdminPage from './components/pages/AdminPage'; @@ -177,6 +178,7 @@ export default function App() { {currentPage === 'home' && } {currentPage === 'triage' && } {currentPage === 'compliance' && } + {currentPage === 'scan-posture' && } {currentPage === 'ccp-metrics' && } {currentPage === 'knowledge-base' && } {currentPage === 'exports' && } diff --git a/frontend/src/components/NavDrawer.js b/frontend/src/components/NavDrawer.js index 82f9839..9c9d773 100644 --- a/frontend/src/components/NavDrawer.js +++ b/frontend/src/components/NavDrawer.js @@ -1,5 +1,5 @@ import React from 'react'; -import { X, Home, BarChart2, BookOpen, Download, ShieldCheck, Settings, Ticket, Building2, Layers } from 'lucide-react'; +import { X, Home, BarChart2, BookOpen, Download, ShieldCheck, Settings, Ticket, Building2, Layers, Monitor } from 'lucide-react'; import { useAuth } from '../contexts/AuthContext'; import { canAccessPage } from '../config/pageVisibility'; @@ -7,6 +7,7 @@ const NAV_ITEMS = [ { id: 'home', label: 'Home', icon: Home, color: '#0EA5E9', description: 'Main dashboard' }, { id: 'triage', label: 'Vuln Triage', icon: BarChart2, color: '#F59E0B', description: 'Active findings & CVE triage' }, { id: 'compliance', label: 'Compliance', icon: ShieldCheck, color: '#14B8A6', description: 'AEO posture & metrics' }, + { id: 'scan-posture', label: 'Scan Posture', icon: Monitor, color: '#6366F1', description: 'Platform rollup — exec view' }, { id: 'ccp-metrics', label: 'CCP Metrics', icon: Building2, color: '#A78BFA', description: 'Cross-vertical VCL reporting' }, { id: 'knowledge-base', label: 'Knowledge Base', icon: BookOpen, color: '#10B981', description: 'Articles & documentation' }, { id: 'exports', label: 'Exports', icon: Download, color: '#8B5CF6', description: 'Export data & reports' }, diff --git a/frontend/src/components/pages/ReportingPage.js b/frontend/src/components/pages/ReportingPage.js index 8806446..c00a0a5 100644 --- a/frontend/src/components/pages/ReportingPage.js +++ b/frontend/src/components/pages/ReportingPage.js @@ -1,6 +1,6 @@ import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react'; import ReactDOM from 'react-dom'; -import { RefreshCw, Loader, AlertCircle, PieChart, ChevronUp, ChevronDown, ChevronRight, ChevronsUpDown, Settings2, GripVertical, Eye, EyeOff, Filter, Download, RotateCcw, Trash2, X, ListTodo, Upload, FileText, Check, AlertTriangle, CornerUpRight, Edit3, Square, CheckSquare, MinusSquare, Search, Database, Plus, FileSpreadsheet, Layers } from 'lucide-react'; +import { RefreshCw, Loader, AlertCircle, PieChart, ChevronUp, ChevronDown, ChevronRight, ChevronsUpDown, Settings2, GripVertical, Eye, EyeOff, Filter, Download, RotateCcw, Trash2, X, ListTodo, Upload, FileText, Check, AlertTriangle, CornerUpRight, Edit3, Square, CheckSquare, MinusSquare, Search, Database, Plus, FileSpreadsheet, Layers, Globe } from 'lucide-react'; import * as XLSX from 'xlsx'; import { useAuth } from '../../contexts/AuthContext'; import IvantiCountsChart from './IvantiCountsChart'; @@ -747,6 +747,99 @@ function SortIcon({ colKey, sort }) { : ; } +// --------------------------------------------------------------------------- +// InfobloxLookupBtn — small DNS icon that queries Infoblox for the real hostname +// for a given IP address. On success, auto-saves the result as an override. +// --------------------------------------------------------------------------- +function InfobloxLookupBtn({ findingId, field, ipAddress, canWrite, onResolved }) { + const [loading, setLoading] = useState(false); + const [result, setResult] = useState(null); // { name } or null + const [error, setError] = useState(null); + + if (!canWrite || !ipAddress) return null; + + const handleLookup = async (e) => { + e.stopPropagation(); + setLoading(true); + setError(null); + setResult(null); + + try { + // Determine if IPv4 or IPv6 + const isV6 = ipAddress.includes(':'); + const endpoint = isV6 + ? `${API_BASE}/infoblox/lookup/ipv6/${encodeURIComponent(ipAddress)}` + : `${API_BASE}/infoblox/lookup/ip/${encodeURIComponent(ipAddress)}`; + + const res = await fetch(endpoint, { credentials: 'include' }); + if (res.status === 404) { + setError('No DNS record'); + return; + } + if (res.status === 503) { + setError('Not configured'); + return; + } + if (!res.ok) { + setError('Lookup failed'); + return; + } + + const data = await res.json(); + const records = data.records || []; + if (records.length === 0) { + setError('No records'); + return; + } + + // Use the first record's name (host record) or ptrdname (PTR record) as the FQDN + const rec = records[0]; + const fqdn = rec.name || rec.ptrdname || ''; + if (!fqdn) { + setError('No FQDN in record'); + return; + } + setResult({ name: fqdn }); + + // Auto-save as override + const saveRes = await fetch(`${API_BASE}/ivanti/findings/${findingId}/override`, { + method: 'PUT', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ field, value: fqdn }), + }); + if (saveRes.ok && onResolved) { + onResolved(fqdn); + } + } catch (_err) { + setError('Network error'); + } finally { + setLoading(false); + } + }; + + return ( + + ); +} + // --------------------------------------------------------------------------- // OverrideCell — inline editable hostname/dns with amber dot when overridden // --------------------------------------------------------------------------- @@ -1257,6 +1350,12 @@ function TableCell({ colKey, finding, canWrite, onCveMouseEnter, onCveMouseLeave canWrite={canWrite} suffix={ <> + + } /> ); case 'dueDate': { diff --git a/frontend/src/components/pages/ScanPosturePage.js b/frontend/src/components/pages/ScanPosturePage.js new file mode 100644 index 0000000..e01cea9 --- /dev/null +++ b/frontend/src/components/pages/ScanPosturePage.js @@ -0,0 +1,469 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { Monitor, Shield, AlertTriangle, Clock, ChevronDown, ChevronUp, ChevronRight, RefreshCw, Server, Wifi, X } from 'lucide-react'; + +const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api'; + +// --------------------------------------------------------------------------- +// Color helpers +// --------------------------------------------------------------------------- +const SEVERITY_COLORS = { + critical: '#EF4444', + high: '#F59E0B', + medium: '#3B82F6', + low: '#10B981', +}; + +function vendorColor(vendor) { + const v = (vendor || '').toUpperCase(); + if (v.includes('CISCO')) return '#0EA5E9'; + if (v.includes('JUNIPER')) return '#10B981'; + if (v.includes('ARISTA')) return '#8B5CF6'; + if (v.includes('NOKIA')) return '#F97316'; + if (v.includes('RED HAT')) return '#EF4444'; + if (v.includes('LINUX')) return '#F97316'; + if (v.includes('ROCKY')) return '#14B8A6'; + if (v.includes('ALMA')) return '#6366F1'; + if (v.includes('MICROSOFT')) return '#3B82F6'; + if (v.includes('DELL')) return '#A78BFA'; + if (v.includes('ADVA')) return '#EC4899'; + if (v.includes('VECIMA')) return '#84CC16'; + if (v.includes('ADTRAN')) return '#F472B6'; + return '#64748B'; +} + +function scanTypeLabel(type) { + if (type === 'agent') return 'Agent'; + if (type === 'network') return 'Network'; + return 'Mixed'; +} + +function formatDate(dateStr) { + if (!dateStr) return 'N/A'; + const d = new Date(dateStr); + if (isNaN(d.getTime())) return dateStr; + const now = new Date(); + const diffMs = now - d; + const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); + if (diffDays === 0) return 'Today'; + if (diffDays === 1) return 'Yesterday'; + if (diffDays < 7) return `${diffDays}d ago`; + return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); +} + +function formatModel(model) { + if (!model || model === 'UNSPECIFIED') return null; + return model; +} + +// --------------------------------------------------------------------------- +// Main Page Component +// --------------------------------------------------------------------------- +export default function ScanPosturePage() { + const [platforms, setPlatforms] = useState([]); + const [summary, setSummary] = useState(null); + const [expandedTile, setExpandedTile] = useState(null); + const [tileDetails, setTileDetails] = useState(null); + const [loading, setLoading] = useState(true); + const [detailLoading, setDetailLoading] = useState(false); + const [error, setError] = useState(null); + + const fetchData = useCallback(async () => { + setLoading(true); + setError(null); + try { + const [platRes, sumRes] = await Promise.all([ + fetch(`${API_BASE}/scan-posture/platforms`, { credentials: 'include' }), + fetch(`${API_BASE}/scan-posture/summary`, { credentials: 'include' }), + ]); + if (!platRes.ok) throw new Error(`Platforms: ${platRes.status}`); + if (!sumRes.ok) throw new Error(`Summary: ${sumRes.status}`); + const platData = await platRes.json(); + const sumData = await sumRes.json(); + setPlatforms(platData.platforms || []); + setSummary(sumData); + } catch (err) { + setError(err.message); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { fetchData(); }, [fetchData]); + + const handleTileClick = async (platform) => { + const key = `${platform.platform_vendor}|${platform.platform_model}`; + if (expandedTile === key) { + setExpandedTile(null); + setTileDetails(null); + return; + } + setExpandedTile(key); + setDetailLoading(true); + setTileDetails(null); + try { + const res = await fetch( + `${API_BASE}/scan-posture/platforms/${encodeURIComponent(platform.platform_vendor)}/${encodeURIComponent(platform.platform_model)}/details`, + { credentials: 'include' } + ); + if (!res.ok) throw new Error(`Details: ${res.status}`); + const data = await res.json(); + setTileDetails(data); + } catch (_err) { + setTileDetails({ os_versions: [] }); + } finally { + setDetailLoading(false); + } + }; + + const handleCollapse = () => { + setExpandedTile(null); + setTileDetails(null); + }; + + if (loading) { + return ( +
+ + Loading scan posture data... +
+ ); + } + + if (error) { + return ( +
+ + {error} + +
+ ); + } + + return ( +
+
+
+

Scan Posture

+

Platform and code version rollup — executive view

+
+ +
+ + {summary && } + + +
+ ); +} + +// --------------------------------------------------------------------------- +// Summary Bar +// --------------------------------------------------------------------------- +function SummaryBar({ summary }) { + const stats = [ + { label: 'Platforms', value: summary.platform_count || 0, icon: Monitor, color: '#0EA5E9' }, + { label: 'Devices', value: summary.total_devices || 0, icon: Server, color: '#10B981' }, + { label: 'Findings', value: summary.total_findings || 0, icon: Shield, color: '#F59E0B' }, + { label: 'Critical', value: summary.critical_findings || 0, icon: AlertTriangle, color: '#EF4444' }, + { label: 'Last Scan', value: formatDate(summary.last_scan_time), icon: Clock, color: '#8B5CF6' }, + ]; + + return ( +
+ {stats.map(({ label, value, icon: Icon, color }) => ( +
+ + {typeof value === 'number' ? value.toLocaleString() : value} + {label} +
+ ))} +
+ ); +} + +// --------------------------------------------------------------------------- +// Tile Grid +// --------------------------------------------------------------------------- +function TileGrid({ platforms, expandedTile, tileDetails, detailLoading, onTileClick, onCollapse }) { + if (platforms.length === 0) { + return ( +
+ +

No platform data available yet.

+

OS data is populated during Ivanti sync.

+
+ ); + } + + return ( +
+ {platforms.map((p) => { + const key = `${p.platform_vendor}|${p.platform_model}`; + const isExpanded = expandedTile === key; + return ( +
+ onTileClick(p)} + onCollapse={onCollapse} + /> +
+ ); + })} +
+ ); +} + +// --------------------------------------------------------------------------- +// Platform Tile (expandable) +// --------------------------------------------------------------------------- +function PlatformTile({ platform, isExpanded, details, detailLoading, onClick, onCollapse }) { + const color = vendorColor(platform.platform_vendor); + const [hovered, setHovered] = useState(false); + const model = formatModel(platform.platform_model); + + return ( +
setHovered(true)} + onMouseLeave={() => setHovered(false)} + > + + + {/* Expanded detail section */} + {isExpanded && ( +
+
+ + {platform.platform_vendor} {model || ''} — Code Versions & CVEs + + +
+ + {detailLoading ? ( +
+ +
+ ) : details && details.os_versions ? ( +
+ {details.os_versions.map((osVersion) => ( + + ))} + {details.os_versions.length === 0 && ( +
+ No detailed data available for this platform. +
+ )} +
+ ) : null} +
+ )} +
+ ); +} + +// --------------------------------------------------------------------------- +// OS Version Section (collapsible, shows CVEs and hostnames) +// --------------------------------------------------------------------------- +function OSVersionSection({ osVersion, color }) { + const [expanded, setExpanded] = useState(false); + const hostCount = osVersion.host_count || 0; + const hasCves = osVersion.cves && osVersion.cves.length > 0; + const hasHostnames = osVersion.hostnames && osVersion.hostnames.length > 0; + + return ( +
+ + + {expanded && ( +
+ {hasCves && ( +
+
CVEs
+
+ {osVersion.cves.map((cve) => ( + + {cve} + + ))} +
+
+ )} + {hasHostnames && ( +
+
Hosts
+
+ {osVersion.hostnames.map((name) => ( + {name} + ))} +
+
+ )} +
+ )} +
+ ); +} + + + +// --------------------------------------------------------------------------- +// Styles +// --------------------------------------------------------------------------- +const styles = { + page: { padding: '1.5rem', maxWidth: '1400px', margin: '0 auto' }, + header: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.5rem' }, + title: { fontSize: '1.25rem', fontWeight: '700', color: '#E2E8F0', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: '0.05em', margin: 0 }, + subtitle: { fontSize: '0.75rem', color: '#64748B', margin: '2px 0 0 0' }, + refreshBtn: { display: 'flex', alignItems: 'center', gap: '0.375rem', padding: '0.375rem 0.75rem', background: 'rgba(14, 165, 233, 0.08)', border: '1px solid rgba(14, 165, 233, 0.25)', borderRadius: '6px', color: '#0EA5E9', fontSize: '0.7rem', fontFamily: 'monospace', textTransform: 'uppercase', cursor: 'pointer' }, + retryBtn: { marginLeft: 12, padding: '4px 12px', background: 'rgba(245, 158, 11, 0.15)', border: '1px solid rgba(245, 158, 11, 0.4)', borderRadius: '4px', color: '#F59E0B', fontSize: '0.7rem', cursor: 'pointer' }, + summaryBar: { display: 'flex', gap: '1.5rem', padding: '0.875rem 1.25rem', background: 'rgba(15, 23, 42, 0.5)', border: '1px solid rgba(51, 65, 85, 0.4)', borderRadius: '8px', marginBottom: '1.5rem', flexWrap: 'wrap' }, + summaryItem: { display: 'flex', alignItems: 'center', gap: '0.375rem' }, + summaryValue: { fontSize: '0.85rem', fontWeight: '600', color: '#E2E8F0', fontFamily: 'monospace' }, + summaryLabel: { fontSize: '0.65rem', color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.04em' }, + tileGrid: { display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: '0.75rem' }, + tile: { borderRadius: '10px', border: '1px solid', transition: 'all 0.15s ease', overflow: 'hidden' }, + tileClickArea: { display: 'flex', flexDirection: 'column', padding: '0.875rem 1rem', cursor: 'pointer', textAlign: 'left', width: '100%', background: 'none', border: 'none', color: 'inherit' }, + tileHeader: { display: 'flex', alignItems: 'center', gap: '0.625rem', marginBottom: '0.5rem' }, + tileIcon: { width: '30px', height: '30px', borderRadius: '6px', border: '1px solid', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }, + tileName: { fontSize: '0.8rem', fontWeight: '700', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: '0.04em', lineHeight: 1.2 }, + tileModel: { fontSize: '0.65rem', color: '#94A3B8', fontFamily: 'monospace', marginTop: '1px' }, + tileHeaderRight: { display: 'flex', alignItems: 'center', gap: '0.5rem', marginLeft: 'auto' }, + tileScanBadge: { display: 'flex', alignItems: 'center', gap: '3px', padding: '2px 6px', background: 'rgba(30, 41, 59, 0.6)', borderRadius: '4px' }, + tileStats: { display: 'flex', gap: '1rem', alignItems: 'flex-end' }, + tileStat: { display: 'flex', flexDirection: 'column' }, + tileStatValue: { fontSize: '0.9rem', fontWeight: '700', color: '#E2E8F0', fontFamily: 'monospace' }, + tileStatLabel: { fontSize: '0.55rem', color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.04em' }, + // Expanded section + expandedSection: { borderTop: '1px solid rgba(51, 65, 85, 0.3)', background: 'rgba(15, 23, 42, 0.3)' }, + expandedHeader: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '0.5rem 1rem', borderBottom: '1px solid rgba(51, 65, 85, 0.2)' }, + collapseBtn: { display: 'flex', alignItems: 'center', gap: '4px', padding: '3px 8px', background: 'rgba(100, 116, 139, 0.15)', border: '1px solid rgba(100, 116, 139, 0.3)', borderRadius: '4px', color: '#94A3B8', fontSize: '0.6rem', fontFamily: 'monospace', textTransform: 'uppercase', cursor: 'pointer' }, + expandedContent: { padding: '0.5rem 0.75rem', maxHeight: '500px', overflowY: 'auto' }, + // OS Version sections + osSection: { marginBottom: '0.25rem', borderRadius: '6px', border: '1px solid rgba(51, 65, 85, 0.2)', overflow: 'hidden' }, + osSectionHeader: { display: 'flex', alignItems: 'center', gap: '0.5rem', padding: '0.5rem 0.75rem', width: '100%', background: 'rgba(30, 41, 59, 0.4)', border: 'none', cursor: 'pointer', textAlign: 'left', color: 'inherit' }, + osName: { fontSize: '0.75rem', fontWeight: '600', fontFamily: 'monospace', flex: 1 }, + osMeta: { fontSize: '0.6rem', color: '#64748B', whiteSpace: 'nowrap' }, + // OS version CVE list & badges + severityBadge: { padding: '1px 5px', borderRadius: '3px', fontSize: '0.6rem', fontWeight: '700', fontFamily: 'monospace' }, + cveBadge: { fontSize: '0.55rem', color: '#94A3B8', background: 'rgba(51, 65, 85, 0.4)', padding: '1px 5px', borderRadius: '3px' }, + osExpandedContent: { padding: '0.5rem 0.75rem 0.5rem 1.75rem' }, + osSubSection: { marginBottom: '0.5rem' }, + osSubLabel: { fontSize: '0.6rem', color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.04em', marginBottom: '0.25rem', fontFamily: 'monospace' }, + osCveList: { display: 'flex', flexWrap: 'wrap', gap: '0.375rem' }, + hostCveLink: { fontSize: '0.65rem', fontFamily: 'monospace', fontWeight: '500', textDecoration: 'none', padding: '1px 4px', borderRadius: '3px', background: 'rgba(30, 41, 59, 0.5)' }, + osHostnameList: { display: 'flex', flexWrap: 'wrap', gap: '0.375rem' }, + hostnameChip: { fontSize: '0.6rem', fontFamily: 'monospace', color: '#CBD5E1', padding: '2px 6px', borderRadius: '3px', background: 'rgba(30, 41, 59, 0.5)', border: '1px solid rgba(51, 65, 85, 0.3)' }, + // States + loadingContainer: { display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '4rem 0' }, + errorContainer: { display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '4rem 0' }, + emptyState: { display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: '4rem 0' }, +}; diff --git a/frontend/src/config/pageVisibility.js b/frontend/src/config/pageVisibility.js index 20d0918..e6f0846 100644 --- a/frontend/src/config/pageVisibility.js +++ b/frontend/src/config/pageVisibility.js @@ -7,6 +7,7 @@ export const PAGE_VISIBILITY = { home: [], triage: ['Admin', 'Standard_User', 'Leadership'], compliance: ['Admin', 'Standard_User', 'Leadership'], + 'scan-posture': ['Admin', 'Standard_User', 'Leadership'], 'ccp-metrics': ['Admin', 'Leadership'], 'knowledge-base': [], exports: ['Admin', 'Standard_User', 'Leadership'],