Add Infoblox DNS lookup, Scan Posture page, and Ivanti OS fields

- 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
This commit is contained in:
Jordan Ramos
2026-08-21 10:33:46 -06:00
parent 7484e81b9a
commit 416bfd2e28
15 changed files with 2518 additions and 6 deletions

View File

@@ -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

View File

@@ -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,
};

View File

@@ -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();

View File

@@ -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() {

152
backend/routes/infoblox.js Normal file
View File

@@ -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;

View File

@@ -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);
}

View File

@@ -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;

View File

@@ -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();

View File

@@ -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());

1197
docs/api/infoblox Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -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' && <HomePage onNavigate={handleNavigate} showAddCVE={showAddCVE} setShowAddCVE={setShowAddCVE} />}
{currentPage === 'triage' && <VulnerabilityTriagePage filterDate={calendarFilter} filterEXC={reportingExcFilter} />}
{currentPage === 'compliance' && <CompliancePage onNavigate={setCurrentPage} />}
{currentPage === 'scan-posture' && <ScanPosturePage />}
{currentPage === 'ccp-metrics' && <CCPMetricsPage />}
{currentPage === 'knowledge-base' && <KnowledgeBasePage />}
{currentPage === 'exports' && <ExportsPage />}

View File

@@ -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' },

View File

@@ -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 }) {
: <ChevronDown style={{ width: '11px', height: '11px', color: '#0EA5E9', marginLeft: '3px', flexShrink: 0 }} />;
}
// ---------------------------------------------------------------------------
// 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 (
<button
onClick={handleLookup}
disabled={loading}
title={result ? `Infoblox: ${result.name}` : error ? `Infoblox: ${error}` : `Look up DNS in Infoblox for ${ipAddress}`}
style={{
background: 'none', border: 'none', padding: '0 2px',
cursor: loading ? 'wait' : 'pointer',
color: result ? '#10B981' : error ? '#EF4444' : '#64748B',
lineHeight: 1, display: 'inline-flex', alignItems: 'center', flexShrink: 0,
opacity: loading ? 0.5 : 1,
transition: 'color 0.2s',
}}
>
{loading
? <Loader style={{ width: '11px', height: '11px', animation: 'spin 1s linear infinite' }} />
: <Globe style={{ width: '11px', height: '11px' }} />
}
</button>
);
}
// ---------------------------------------------------------------------------
// OverrideCell — inline editable hostname/dns with amber dot when overridden
// ---------------------------------------------------------------------------
@@ -1257,6 +1350,12 @@ function TableCell({ colKey, finding, canWrite, onCveMouseEnter, onCveMouseLeave
canWrite={canWrite}
suffix={
<>
<InfobloxLookupBtn
findingId={finding.id}
field="hostName"
ipAddress={finding.ipAddress || finding.qualysIpv6 || finding.primaryIpv6}
canWrite={canWrite}
/>
<ScanTypeBadge scanType={finding.scanType} />
<AtlasBadge
hostId={finding.hostId}
@@ -1306,6 +1405,14 @@ function TableCell({ colKey, finding, canWrite, onCveMouseEnter, onCveMouseLeave
originalValue={finding.dns}
initialOverride={finding.overrides?.dns ?? null}
canWrite={canWrite}
suffix={
<InfobloxLookupBtn
findingId={finding.id}
field="dns"
ipAddress={finding.ipAddress || finding.qualysIpv6 || finding.primaryIpv6}
canWrite={canWrite}
/>
}
/>
);
case 'dueDate': {

View File

@@ -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 (
<div style={styles.loadingContainer}>
<RefreshCw style={{ width: 24, height: 24, color: '#0EA5E9', animation: 'spin 1s linear infinite' }} />
<span style={{ color: '#94A3B8', marginLeft: 8 }}>Loading scan posture data...</span>
</div>
);
}
if (error) {
return (
<div style={styles.errorContainer}>
<AlertTriangle style={{ width: 20, height: 20, color: '#F59E0B' }} />
<span style={{ color: '#F59E0B', marginLeft: 8 }}>{error}</span>
<button onClick={fetchData} style={styles.retryBtn}>Retry</button>
</div>
);
}
return (
<div style={styles.page}>
<div style={styles.header}>
<div>
<h1 style={styles.title}>Scan Posture</h1>
<p style={styles.subtitle}>Platform and code version rollup executive view</p>
</div>
<button onClick={fetchData} style={styles.refreshBtn}>
<RefreshCw style={{ width: 14, height: 14 }} />
Refresh
</button>
</div>
{summary && <SummaryBar summary={summary} />}
<TileGrid
platforms={platforms}
expandedTile={expandedTile}
tileDetails={tileDetails}
detailLoading={detailLoading}
onTileClick={handleTileClick}
onCollapse={handleCollapse}
/>
</div>
);
}
// ---------------------------------------------------------------------------
// 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 (
<div style={styles.summaryBar}>
{stats.map(({ label, value, icon: Icon, color }) => (
<div key={label} style={styles.summaryItem}>
<Icon style={{ width: 14, height: 14, color, flexShrink: 0 }} />
<span style={styles.summaryValue}>{typeof value === 'number' ? value.toLocaleString() : value}</span>
<span style={styles.summaryLabel}>{label}</span>
</div>
))}
</div>
);
}
// ---------------------------------------------------------------------------
// Tile Grid
// ---------------------------------------------------------------------------
function TileGrid({ platforms, expandedTile, tileDetails, detailLoading, onTileClick, onCollapse }) {
if (platforms.length === 0) {
return (
<div style={styles.emptyState}>
<Monitor style={{ width: 40, height: 40, color: '#334155' }} />
<p style={{ color: '#64748B', marginTop: 12 }}>No platform data available yet.</p>
<p style={{ color: '#475569', fontSize: '0.75rem' }}>OS data is populated during Ivanti sync.</p>
</div>
);
}
return (
<div style={styles.tileGrid}>
{platforms.map((p) => {
const key = `${p.platform_vendor}|${p.platform_model}`;
const isExpanded = expandedTile === key;
return (
<div key={key} style={{ gridColumn: isExpanded ? '1 / -1' : undefined }}>
<PlatformTile
platform={p}
isExpanded={isExpanded}
details={isExpanded ? tileDetails : null}
detailLoading={isExpanded && detailLoading}
onClick={() => onTileClick(p)}
onCollapse={onCollapse}
/>
</div>
);
})}
</div>
);
}
// ---------------------------------------------------------------------------
// 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 (
<div
style={{
...styles.tile,
borderColor: isExpanded ? `${color}60` : hovered ? `${color}50` : `${color}25`,
background: isExpanded ? `${color}08` : hovered ? `${color}06` : 'rgba(15, 23, 42, 0.6)',
transform: !isExpanded && hovered ? 'translateY(-1px)' : 'none',
}}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
>
<button onClick={onClick} style={styles.tileClickArea}>
<div style={styles.tileHeader}>
<div style={{ ...styles.tileIcon, background: `${color}18`, borderColor: `${color}40` }}>
<Monitor style={{ width: 16, height: 16, color }} />
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ ...styles.tileName, color }}>{platform.platform_vendor}</div>
{model && <div style={styles.tileModel}>{model}</div>}
</div>
<div style={styles.tileHeaderRight}>
<div style={styles.tileScanBadge}>
{platform.primary_scan_type === 'agent' ? (
<Server style={{ width: 10, height: 10, color: '#10B981' }} />
) : (
<Wifi style={{ width: 10, height: 10, color: '#F59E0B' }} />
)}
<span style={{ fontSize: '0.6rem', color: '#64748B', textTransform: 'uppercase' }}>
{scanTypeLabel(platform.primary_scan_type)}
</span>
</div>
{isExpanded ? (
<ChevronUp style={{ width: 14, height: 14, color: '#64748B' }} />
) : (
<ChevronDown style={{ width: 14, height: 14, color: '#475569' }} />
)}
</div>
</div>
<div style={styles.tileStats}>
<div style={styles.tileStat}>
<span style={styles.tileStatValue}>{Number(platform.device_count).toLocaleString()}</span>
<span style={styles.tileStatLabel}>Devices</span>
</div>
<div style={styles.tileStat}>
<span style={styles.tileStatValue}>{Number(platform.finding_count).toLocaleString()}</span>
<span style={styles.tileStatLabel}>Findings</span>
</div>
{Number(platform.critical_count) > 0 && (
<div style={styles.tileStat}>
<span style={{ ...styles.tileStatValue, color: SEVERITY_COLORS.critical }}>
{Number(platform.critical_count).toLocaleString()}
</span>
<span style={styles.tileStatLabel}>Critical</span>
</div>
)}
{Number(platform.high_count) > 0 && (
<div style={styles.tileStat}>
<span style={{ ...styles.tileStatValue, color: SEVERITY_COLORS.high }}>
{Number(platform.high_count).toLocaleString()}
</span>
<span style={styles.tileStatLabel}>High</span>
</div>
)}
<div style={{ ...styles.tileStat, marginLeft: 'auto' }}>
<span style={{ ...styles.tileStatValue, fontSize: '0.75rem', color: '#94A3B8' }}>
{formatDate(platform.last_scan_time)}
</span>
<span style={styles.tileStatLabel}>Last Scan</span>
</div>
</div>
</button>
{/* Expanded detail section */}
{isExpanded && (
<div style={styles.expandedSection}>
<div style={styles.expandedHeader}>
<span style={{ fontSize: '0.7rem', color: '#94A3B8', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
{platform.platform_vendor} {model || ''} Code Versions & CVEs
</span>
<button onClick={onCollapse} style={styles.collapseBtn}>
<X style={{ width: 12, height: 12 }} />
Collapse
</button>
</div>
{detailLoading ? (
<div style={{ padding: '1.5rem', textAlign: 'center' }}>
<RefreshCw style={{ width: 16, height: 16, color: '#0EA5E9', animation: 'spin 1s linear infinite' }} />
</div>
) : details && details.os_versions ? (
<div style={styles.expandedContent}>
{details.os_versions.map((osVersion) => (
<OSVersionSection key={osVersion.os_name} osVersion={osVersion} color={color} />
))}
{details.os_versions.length === 0 && (
<div style={{ padding: '1rem', textAlign: 'center', color: '#64748B', fontSize: '0.75rem' }}>
No detailed data available for this platform.
</div>
)}
</div>
) : null}
</div>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// 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 (
<div style={styles.osSection}>
<button onClick={() => setExpanded(!expanded)} style={styles.osSectionHeader}>
{expanded ? (
<ChevronDown style={{ width: 12, height: 12, color: '#64748B', flexShrink: 0 }} />
) : (
<ChevronRight style={{ width: 12, height: 12, color: '#64748B', flexShrink: 0 }} />
)}
<span style={{ ...styles.osName, color }}>{osVersion.os_name}</span>
<span style={styles.osMeta}>{hostCount} host{hostCount !== 1 ? 's' : ''}</span>
<span style={styles.osMeta}>{osVersion.finding_count || 0} finding{osVersion.finding_count !== 1 ? 's' : ''}</span>
{osVersion.max_severity && (
<span style={{
...styles.severityBadge,
background: osVersion.max_severity >= 9 ? `${SEVERITY_COLORS.critical}15` : osVersion.max_severity >= 7 ? `${SEVERITY_COLORS.high}15` : `${SEVERITY_COLORS.medium}15`,
color: osVersion.max_severity >= 9 ? SEVERITY_COLORS.critical : osVersion.max_severity >= 7 ? SEVERITY_COLORS.high : SEVERITY_COLORS.medium,
}}>
{Number(osVersion.max_severity).toFixed(1)}
</span>
)}
{hasCves && (
<span style={styles.cveBadge}>{osVersion.cves.length} CVE{osVersion.cves.length !== 1 ? 's' : ''}</span>
)}
</button>
{expanded && (
<div style={styles.osExpandedContent}>
{hasCves && (
<div style={styles.osSubSection}>
<div style={styles.osSubLabel}>CVEs</div>
<div style={styles.osCveList}>
{osVersion.cves.map((cve) => (
<a
key={cve}
href={`https://nvd.nist.gov/vuln/detail/${cve}`}
target="_blank"
rel="noopener noreferrer"
style={{ ...styles.hostCveLink, color }}
>
{cve}
</a>
))}
</div>
</div>
)}
{hasHostnames && (
<div style={styles.osSubSection}>
<div style={styles.osSubLabel}>Hosts</div>
<div style={styles.osHostnameList}>
{osVersion.hostnames.map((name) => (
<span key={name} style={styles.hostnameChip}>{name}</span>
))}
</div>
</div>
)}
</div>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// 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' },
};

View File

@@ -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'],