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:
@@ -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
|
||||
|
||||
168
backend/helpers/infobloxApi.js
Normal file
168
backend/helpers/infobloxApi.js
Normal 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,
|
||||
};
|
||||
41
backend/migrations/add_ivanti_os_fields.js
Normal file
41
backend/migrations/add_ivanti_os_fields.js
Normal 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();
|
||||
@@ -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
152
backend/routes/infoblox.js
Normal 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;
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
234
backend/routes/scanPosture.js
Normal file
234
backend/routes/scanPosture.js
Normal 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;
|
||||
102
backend/scripts/backfill-os-fields.js
Normal file
102
backend/scripts/backfill-os-fields.js
Normal 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();
|
||||
@@ -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
1197
docs/api/infoblox
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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 />}
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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': {
|
||||
|
||||
469
frontend/src/components/pages/ScanPosturePage.js
Normal file
469
frontend/src/components/pages/ScanPosturePage.js
Normal 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' },
|
||||
};
|
||||
@@ -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'],
|
||||
|
||||
Reference in New Issue
Block a user