Files
cve-dashboard/backend/routes/atlas.js

585 lines
24 KiB
JavaScript

// Atlas InfoSec Action Plans Routes
// Proxies CRUD operations to the Atlas API and maintains a local SQLite cache
// for fast badge rendering on the ReportingPage.
const express = require('express');
const { requireGroup } = require('../middleware/auth');
const logAudit = require('../helpers/auditLog');
const { isConfigured, atlasGet, atlasPut, atlasPatch, atlasPost } = require('../helpers/atlasApi');
const VALID_PLAN_TYPES = ['decommission', 'remediation', 'false_positive', 'risk_acceptance', 'scan_exclusion'];
const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
// ---------------------------------------------------------------------------
// DB helpers — promise wrappers for callback-based SQLite API
// ---------------------------------------------------------------------------
function dbRun(db, sql, params = []) {
return new Promise((resolve, reject) => {
db.run(sql, params, function (err) { if (err) reject(err); else resolve(this); });
});
}
function dbGet(db, sql, params = []) {
return new Promise((resolve, reject) => {
db.get(sql, params, (err, row) => { if (err) reject(err); else resolve(row); });
});
}
function dbAll(db, sql, params = []) {
return new Promise((resolve, reject) => {
db.all(sql, params, (err, rows) => { if (err) reject(err); else resolve(rows || []); });
});
}
// ---------------------------------------------------------------------------
// Pure aggregation function — exported for testability
// ---------------------------------------------------------------------------
function aggregateAtlasMetrics(rows) {
const result = {
totalHosts: rows.length,
hostsWithPlans: 0,
hostsWithoutPlans: 0,
plansByType: {},
plansByStatus: {},
totalPlans: 0
};
for (const row of rows) {
if (row.has_action_plan === 1) {
result.hostsWithPlans++;
} else {
result.hostsWithoutPlans++;
}
let plans;
try {
plans = JSON.parse(row.plans_json);
} catch (e) {
// Invalid JSON — skip plan details for this row
continue;
}
if (!Array.isArray(plans)) continue;
for (const plan of plans) {
result.totalPlans++;
if (plan.plan_type) {
result.plansByType[plan.plan_type] = (result.plansByType[plan.plan_type] || 0) + 1;
}
if (plan.status) {
result.plansByStatus[plan.status] = (result.plansByStatus[plan.status] || 0) + 1;
}
}
}
return result;
}
// ---------------------------------------------------------------------------
// Router factory
// ---------------------------------------------------------------------------
function createAtlasRouter(db, requireAuth) {
const router = express.Router();
// -----------------------------------------------------------------------
// GET /metrics
// Return aggregated Atlas metrics for chart rendering.
// Auth: any authenticated user
//
// Response 200:
// { totalHosts: number, hostsWithPlans: number, hostsWithoutPlans: number,
// plansByType: { [type: string]: number }, plansByStatus: { [status: string]: number },
// totalPlans: number }
// Response 503: { error: string } — Atlas not configured
// Response 500: { error: string } — DB query failure
// -----------------------------------------------------------------------
router.get('/metrics', requireAuth(db), async (req, res) => {
if (!isConfigured) {
return res.status(503).json({ error: 'Atlas API is not configured. Check ATLAS_API_URL, ATLAS_API_USER, and ATLAS_API_PASS environment variables.' });
}
try {
const rows = await dbAll(db,
`SELECT has_action_plan, plans_json FROM atlas_action_plans_cache`
);
const metrics = aggregateAtlasMetrics(rows);
res.json(metrics);
} catch (err) {
console.error('[Atlas] Error fetching metrics:', err.message);
res.status(500).json({ error: 'Failed to fetch Atlas metrics.' });
}
});
// -----------------------------------------------------------------------
// GET /status
// Return all cached Atlas rows for badge rendering.
// Auth: any authenticated user
//
// Response 200:
// [ { host_id: number, has_action_plan: 0|1, plan_count: number, synced_at: string }, ... ]
// Response 503: { error: string } — Atlas not configured
// Response 500: { error: string } — DB query failure
// -----------------------------------------------------------------------
router.get('/status', requireAuth(db), async (req, res) => {
if (!isConfigured) {
return res.status(503).json({ error: 'Atlas API is not configured. Check ATLAS_API_URL, ATLAS_API_USER, and ATLAS_API_PASS environment variables.' });
}
try {
const rows = await dbAll(db,
`SELECT host_id, has_action_plan, plan_count, synced_at FROM atlas_action_plans_cache`
);
res.json(rows);
} catch (err) {
console.error('[Atlas] Error fetching status:', err.message);
res.status(500).json({ error: 'Failed to fetch Atlas status.' });
}
});
// -----------------------------------------------------------------------
// POST /sync
// Sync Atlas action plan data for all hosts found in the Ivanti cache.
// Auth: Admin or Standard_User
//
// Request body: none
// Response 200:
// { synced: number, withPlans: number, failed: number }
// Response 503: { error: string } — Atlas not configured
// Response 500: { error: string } — sync failure or Ivanti cache parse error
// -----------------------------------------------------------------------
router.post('/sync', requireAuth(db), requireGroup('Admin', 'Standard_User'), async (req, res) => {
if (!isConfigured) {
return res.status(503).json({ error: 'Atlas API is not configured. Check ATLAS_API_URL, ATLAS_API_USER, and ATLAS_API_PASS environment variables.' });
}
try {
// 1. Read Ivanti findings cache and extract unique non-null hostIds
const cacheRow = await dbGet(db, `SELECT findings_json FROM ivanti_findings_cache WHERE id = 1`);
if (!cacheRow || !cacheRow.findings_json) {
return res.json({ synced: 0, withPlans: 0, failed: 0 });
}
let findings;
try {
findings = JSON.parse(cacheRow.findings_json);
} catch (parseErr) {
return res.status(500).json({ error: 'Failed to parse Ivanti findings cache.' });
}
const hostIdSet = new Set();
for (const f of findings) {
if (f.hostId != null && typeof f.hostId === 'number' && f.hostId > 0) {
hostIdSet.add(f.hostId);
}
}
const hostIds = [...hostIdSet];
if (hostIds.length === 0) {
return res.json({ synced: 0, withPlans: 0, failed: 0 });
}
// 2. Process hosts in batches of 5 concurrent requests
let synced = 0;
let withPlans = 0;
let failed = 0;
const BATCH_SIZE = 5;
for (let i = 0; i < hostIds.length; i += BATCH_SIZE) {
const batch = hostIds.slice(i, i + BATCH_SIZE);
const results = await Promise.allSettled(
batch.map(async (hostId) => {
const result = await atlasGet('/hosts/' + hostId + '/action-plans');
return { hostId, result };
})
);
for (const settled of results) {
if (settled.status === 'rejected') {
failed++;
console.warn('[Atlas Sync] Request failed for host:', settled.reason?.message || settled.reason);
continue;
}
const { hostId, result } = settled.value;
if (result.status >= 200 && result.status < 300) {
let allPlans = [];
let activePlans = [];
try {
const parsed = JSON.parse(result.body);
// Atlas returns { active: [...], inactive: [...] }
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
activePlans = Array.isArray(parsed.active) ? parsed.active : [];
const inactive = Array.isArray(parsed.inactive) ? parsed.inactive : [];
allPlans = [...activePlans, ...inactive];
} else if (Array.isArray(parsed)) {
allPlans = parsed;
activePlans = parsed;
}
} catch (e) {
allPlans = [];
activePlans = [];
}
// Badge counts only active plans — inactive are historical
const planCount = activePlans.length;
const hasActionPlan = planCount > 0 ? 1 : 0;
try {
await dbRun(db,
`INSERT INTO atlas_action_plans_cache (host_id, has_action_plan, plan_count, plans_json, synced_at)
VALUES (?, ?, ?, ?, datetime('now'))
ON CONFLICT(host_id) DO UPDATE SET
has_action_plan = excluded.has_action_plan,
plan_count = excluded.plan_count,
plans_json = excluded.plans_json,
synced_at = excluded.synced_at`,
[hostId, hasActionPlan, planCount, JSON.stringify(allPlans)]
);
} catch (dbErr) {
console.error('[Atlas Sync] DB upsert failed for host', hostId, ':', dbErr.message);
}
synced++;
if (hasActionPlan) withPlans++;
} else {
failed++;
console.warn(`[Atlas Sync] Non-2xx response for host ${hostId}: status ${result.status}`);
}
}
}
// 3. Log audit entry
logAudit(db, {
userId: req.user.id,
username: req.user.username,
action: 'ATLAS_SYNC',
entityType: 'atlas_action_plans',
entityId: null,
details: { synced, withPlans, failed, totalHosts: hostIds.length },
ipAddress: req.ip
});
res.json({ synced, withPlans, failed });
} catch (err) {
console.error('[Atlas Sync] Unexpected error:', err.message);
res.status(500).json({ error: 'Atlas sync failed: ' + err.message });
}
});
// -----------------------------------------------------------------------
// GET /hosts/:hostId/action-plans
// Proxy to Atlas API — returns live action plan data for a single host.
// Auth: any authenticated user
//
// Params: hostId (positive integer)
// Response 2xx: proxied Atlas response body (parsed JSON or raw)
// Response 400: { error: string } — invalid hostId
// Response 503: { error: string } — Atlas not configured
// Response 502: { error: string } — Atlas API unreachable
// -----------------------------------------------------------------------
router.get('/hosts/:hostId/action-plans', requireAuth(db), async (req, res) => {
if (!isConfigured) {
return res.status(503).json({ error: 'Atlas API is not configured. Check ATLAS_API_URL, ATLAS_API_USER, and ATLAS_API_PASS environment variables.' });
}
const hostId = parseInt(req.params.hostId, 10);
if (!Number.isInteger(hostId) || hostId <= 0) {
return res.status(400).json({ error: 'hostId must be a positive integer' });
}
try {
const result = await atlasGet('/hosts/' + hostId + '/action-plans');
if (result.status >= 200 && result.status < 300) {
let body;
try {
body = JSON.parse(result.body);
} catch (e) {
body = result.body;
}
res.status(result.status).json(body);
} else {
// Forward non-2xx Atlas responses to the client
let errorBody;
try {
errorBody = JSON.parse(result.body);
} catch (e) {
errorBody = { error: result.body };
}
res.status(result.status).json(errorBody);
}
} catch (err) {
console.error('[Atlas] GET action-plans failed for host', hostId, ':', err.message);
res.status(502).json({ error: 'Failed to reach Atlas API: ' + err.message });
}
});
// -----------------------------------------------------------------------
// PUT /hosts/:hostId/action-plans
// Create a new action plan for a host.
// Auth: Admin or Standard_User
//
// Params: hostId (positive integer)
// Request body:
// { plan_type: string (one of VALID_PLAN_TYPES), commit_date: string (YYYY-MM-DD),
// qualys_id?: string, active_host_findings_id?: string,
// jira_vnr?: string, archer_exc?: string }
// Response 2xx: proxied Atlas response body
// Response 400: { error: string } — invalid hostId, plan_type, or commit_date
// Response 503: { error: string } — Atlas not configured
// Response 502: { error: string } — Atlas API unreachable
// -----------------------------------------------------------------------
router.put('/hosts/:hostId/action-plans', requireAuth(db), requireGroup('Admin', 'Standard_User'), async (req, res) => {
if (!isConfigured) {
return res.status(503).json({ error: 'Atlas API is not configured. Check ATLAS_API_URL, ATLAS_API_USER, and ATLAS_API_PASS environment variables.' });
}
const hostId = parseInt(req.params.hostId, 10);
if (!Number.isInteger(hostId) || hostId <= 0) {
return res.status(400).json({ error: 'hostId must be a positive integer' });
}
const { plan_type, commit_date } = req.body || {};
if (!plan_type || !VALID_PLAN_TYPES.includes(plan_type)) {
return res.status(400).json({ error: 'plan_type must be one of: ' + VALID_PLAN_TYPES.join(', ') });
}
if (!commit_date || !DATE_PATTERN.test(commit_date)) {
return res.status(400).json({ error: 'commit_date must be a valid YYYY-MM-DD date string' });
}
try {
const result = await atlasPut('/hosts/' + hostId + '/action-plans', req.body);
logAudit(db, {
userId: req.user.id,
username: req.user.username,
action: 'ATLAS_CREATE_PLAN',
entityType: 'atlas_action_plan',
entityId: String(hostId),
details: { hostId, plan_type, commit_date },
ipAddress: req.ip
});
if (result.status >= 200 && result.status < 300) {
let body;
try {
body = JSON.parse(result.body);
} catch (e) {
body = result.body;
}
res.status(result.status).json(body);
} else {
let errorBody;
try {
errorBody = JSON.parse(result.body);
} catch (e) {
errorBody = { error: result.body };
}
res.status(result.status).json(errorBody);
}
} catch (err) {
console.error('[Atlas] PUT action-plans failed for host', hostId, ':', err.message);
res.status(502).json({ error: 'Failed to reach Atlas API: ' + err.message });
}
});
// -----------------------------------------------------------------------
// PATCH /hosts/:hostId/action-plans
// Update an existing action plan for a host.
// Auth: Admin or Standard_User
//
// Params: hostId (positive integer)
// Request body:
// { action_plan_id: string (non-empty), updates: object (non-null, non-array) }
// Response 2xx: proxied Atlas response body
// Response 400: { error: string } — invalid hostId, action_plan_id, or updates
// Response 503: { error: string } — Atlas not configured
// Response 502: { error: string } — Atlas API unreachable
// -----------------------------------------------------------------------
router.patch('/hosts/:hostId/action-plans', requireAuth(db), requireGroup('Admin', 'Standard_User'), async (req, res) => {
if (!isConfigured) {
return res.status(503).json({ error: 'Atlas API is not configured. Check ATLAS_API_URL, ATLAS_API_USER, and ATLAS_API_PASS environment variables.' });
}
const hostId = parseInt(req.params.hostId, 10);
if (!Number.isInteger(hostId) || hostId <= 0) {
return res.status(400).json({ error: 'hostId must be a positive integer' });
}
const { action_plan_id, updates } = req.body || {};
if (!action_plan_id || typeof action_plan_id !== 'string' || action_plan_id.trim() === '') {
return res.status(400).json({ error: 'action_plan_id is required and must be a non-empty string' });
}
if (!updates || typeof updates !== 'object' || Array.isArray(updates)) {
return res.status(400).json({ error: 'updates is required and must be an object' });
}
try {
const result = await atlasPatch('/hosts/' + hostId + '/action-plans', req.body);
logAudit(db, {
userId: req.user.id,
username: req.user.username,
action: 'ATLAS_UPDATE_PLAN',
entityType: 'atlas_action_plan',
entityId: String(hostId),
details: { hostId, action_plan_id },
ipAddress: req.ip
});
if (result.status >= 200 && result.status < 300) {
let body;
try {
body = JSON.parse(result.body);
} catch (e) {
body = result.body;
}
res.status(result.status).json(body);
} else {
let errorBody;
try {
errorBody = JSON.parse(result.body);
} catch (e) {
errorBody = { error: result.body };
}
res.status(result.status).json(errorBody);
}
} catch (err) {
console.error('[Atlas] PATCH action-plans failed for host', hostId, ':', err.message);
res.status(502).json({ error: 'Failed to reach Atlas API: ' + err.message });
}
});
// -----------------------------------------------------------------------
// POST /hosts/bulk-action-plans
// Create action plans for multiple hosts at once.
// Auth: Admin or Standard_User
//
// Request body:
// { host_ids: number[] (non-empty, positive integers),
// plan_type: string (one of VALID_PLAN_TYPES),
// commit_date: string (YYYY-MM-DD) }
// Response 2xx: proxied Atlas response body
// Response 400: { error: string } — invalid host_ids, plan_type, or commit_date
// Response 503: { error: string } — Atlas not configured
// Response 502: { error: string } — Atlas API unreachable
// -----------------------------------------------------------------------
router.post('/hosts/bulk-action-plans', requireAuth(db), requireGroup('Admin', 'Standard_User'), async (req, res) => {
if (!isConfigured) {
return res.status(503).json({ error: 'Atlas API is not configured. Check ATLAS_API_URL, ATLAS_API_USER, and ATLAS_API_PASS environment variables.' });
}
const { host_ids, plan_type, commit_date } = req.body || {};
if (!Array.isArray(host_ids) || host_ids.length === 0) {
return res.status(400).json({ error: 'host_ids must be a non-empty array of positive integers' });
}
for (const id of host_ids) {
if (!Number.isInteger(id) || id <= 0) {
return res.status(400).json({ error: 'host_ids must be a non-empty array of positive integers' });
}
}
if (!plan_type || !VALID_PLAN_TYPES.includes(plan_type)) {
return res.status(400).json({ error: 'plan_type must be one of: ' + VALID_PLAN_TYPES.join(', ') });
}
if (!commit_date || !DATE_PATTERN.test(commit_date)) {
return res.status(400).json({ error: 'commit_date must be a valid YYYY-MM-DD date string' });
}
try {
const result = await atlasPost('/hosts/create-bulk-action-plans', req.body);
if (result.status >= 200 && result.status < 300) {
let body;
try {
body = JSON.parse(result.body);
} catch (e) {
body = result.body;
}
res.status(result.status).json(body);
} else {
let errorBody;
try {
errorBody = JSON.parse(result.body);
} catch (e) {
errorBody = { error: result.body };
}
res.status(result.status).json(errorBody);
}
} catch (err) {
console.error('[Atlas] POST bulk-action-plans failed:', err.message);
res.status(502).json({ error: 'Failed to reach Atlas API: ' + err.message });
}
});
// -----------------------------------------------------------------------
// POST /hosts/vulnerabilities
// Fetch active Ivanti vulnerabilities for multiple hosts from Atlas.
// Used by the bulk action plan modal to populate the qualys_id dropdown.
// Auth: any authenticated user
//
// Request body: { host_ids: number[] }
// Response 2xx: proxied Atlas response body
// Response 400: { error: string } — invalid host_ids
// Response 503: { error: string } — Atlas not configured
// Response 502: { error: string } — Atlas API unreachable
// -----------------------------------------------------------------------
router.post('/hosts/vulnerabilities', requireAuth(db), async (req, res) => {
if (!isConfigured) {
return res.status(503).json({ error: 'Atlas API is not configured. Check ATLAS_API_URL, ATLAS_API_USER, and ATLAS_API_PASS environment variables.' });
}
const { host_ids } = req.body || {};
if (!Array.isArray(host_ids) || host_ids.length === 0) {
return res.status(400).json({ error: 'host_ids must be a non-empty array of positive integers' });
}
for (const id of host_ids) {
if (!Number.isInteger(id) || id <= 0) {
return res.status(400).json({ error: 'host_ids must be a non-empty array of positive integers' });
}
}
try {
const result = await atlasPost('/ivanti-vulnerabilities-by-host', { host_ids }, { timeout: 30000 });
if (result.status >= 200 && result.status < 300) {
let body;
try {
body = JSON.parse(result.body);
} catch (e) {
body = result.body;
}
res.status(result.status).json(body);
} else {
let errorBody;
try {
errorBody = JSON.parse(result.body);
} catch (e) {
errorBody = { error: result.body };
}
res.status(result.status).json(errorBody);
}
} catch (err) {
console.error('[Atlas] POST hosts/vulnerabilities failed:', err.message);
res.status(502).json({ error: 'Failed to reach Atlas API: ' + err.message });
}
});
return router;
}
module.exports = createAtlasRouter;
module.exports.aggregateAtlasMetrics = aggregateAtlasMetrics;