Files
cve-dashboard/backend/routes/scanPosture.js
Jordan Ramos 416bfd2e28 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
2026-08-21 10:33:46 -06:00

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;