- 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
235 lines
10 KiB
JavaScript
235 lines
10 KiB
JavaScript
// 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;
|