Instead of blanket-marking managed BU hosts, now parses the Atlas API
response: if it returns a valid {active, inactive} structure, the host
is known. If it returns an error or 'not found' message (even with a
2xx status), the host is not known and won't show a badge.
This prevents the shield showing on hosts Atlas doesn't actually track,
while correctly showing it on hosts Atlas recognizes (with or without
plans).
772 lines
36 KiB
JavaScript
772 lines
36 KiB
JavaScript
// Atlas InfoSec Action Plans Routes
|
|
// Proxies CRUD operations to the Atlas API and maintains a local cache
|
|
// for fast badge rendering on the ReportingPage.
|
|
|
|
const express = require('express');
|
|
const pool = require('../db');
|
|
const { requireAuth, requireGroup } = require('../middleware/auth');
|
|
const logAudit = require('../helpers/auditLog');
|
|
const { isConfigured, atlasGet, atlasPut, atlasPatch, atlasPost } = require('../helpers/atlasApi');
|
|
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
|
|
const VALID_PLAN_TYPES = ['decommission', 'remediation', 'false_positive', 'risk_acceptance', 'scan_exclusion'];
|
|
const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
|
|
|
|
// Diagnostic log helper
|
|
function syncLog(msg) {
|
|
const line = `${new Date().toISOString()} ${msg}\n`;
|
|
try { fs.appendFileSync(path.join(__dirname, '..', 'atlas-sync-debug.log'), line); } catch (_) { /* ignore */ }
|
|
console.log(msg);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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 === true || row.has_action_plan === 1) {
|
|
result.hostsWithPlans++;
|
|
} else {
|
|
result.hostsWithoutPlans++;
|
|
}
|
|
|
|
let plans;
|
|
try {
|
|
plans = JSON.parse(row.plans_json);
|
|
} catch (e) {
|
|
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() {
|
|
const router = express.Router();
|
|
|
|
/**
|
|
* GET /metrics
|
|
*
|
|
* Returns aggregated Atlas action plan metrics from the local cache.
|
|
* Accepts optional `teams` query parameter to scope metrics to hosts
|
|
* belonging to specific BUs (via JOIN on ivanti_findings).
|
|
*
|
|
* @query {string} [teams] - Comma-separated team names (e.g. 'STEAM,ACCESS-ENG')
|
|
* @returns {Object} 200 - { totalHosts, hostsWithPlans, hostsWithoutPlans, plansByType, plansByStatus, totalPlans }
|
|
* @returns {Object} 503 - { error } when Atlas API is not configured
|
|
* @returns {Object} 500 - { error } on database failure
|
|
*/
|
|
router.get('/metrics', requireAuth(), 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 teamsParam = req.query.teams;
|
|
let rows;
|
|
|
|
if (teamsParam) {
|
|
const teams = teamsParam.split(',').map(t => t.trim()).filter(Boolean);
|
|
if (teams.length > 0) {
|
|
const patterns = teams.map(t => `%${t}%`);
|
|
const result = await pool.query(
|
|
`SELECT a.has_action_plan, a.plans_json
|
|
FROM atlas_action_plans_cache a
|
|
INNER JOIN (
|
|
SELECT DISTINCT host_id FROM ivanti_findings
|
|
WHERE bu_ownership ILIKE ANY($1::text[])
|
|
) f ON a.host_id = f.host_id
|
|
WHERE a.atlas_known = true`,
|
|
[patterns]
|
|
);
|
|
rows = result.rows;
|
|
} else {
|
|
const result = await pool.query(
|
|
`SELECT has_action_plan, plans_json FROM atlas_action_plans_cache WHERE atlas_known = true`
|
|
);
|
|
rows = result.rows;
|
|
}
|
|
} else {
|
|
const result = await pool.query(
|
|
`SELECT has_action_plan, plans_json FROM atlas_action_plans_cache WHERE atlas_known = true`
|
|
);
|
|
rows = result.rows;
|
|
}
|
|
|
|
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
|
|
*
|
|
* Returns atlas_action_plans_cache contents for status display.
|
|
* Accepts optional `teams` query parameter to scope results to hosts
|
|
* belonging to specific BUs (via JOIN on ivanti_findings).
|
|
*
|
|
* @query {string} [teams] - Comma-separated team names (e.g. 'STEAM,ACCESS-ENG')
|
|
* @returns {Array} 200 - Array of { host_id, has_action_plan, plan_count, plans_json, atlas_known, synced_at }
|
|
* @returns {Object} 503 - { error } when Atlas API is not configured
|
|
* @returns {Object} 500 - { error } on database failure
|
|
*/
|
|
router.get('/status', requireAuth(), 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 teamsParam = req.query.teams;
|
|
let rows;
|
|
|
|
if (teamsParam) {
|
|
const teams = teamsParam.split(',').map(t => t.trim()).filter(Boolean);
|
|
if (teams.length > 0) {
|
|
const patterns = teams.map(t => `%${t}%`);
|
|
const result = await pool.query(
|
|
`SELECT a.host_id, a.has_action_plan, a.plan_count, a.plans_json, a.atlas_known, a.synced_at
|
|
FROM atlas_action_plans_cache a
|
|
INNER JOIN (
|
|
SELECT DISTINCT host_id FROM ivanti_findings
|
|
WHERE bu_ownership ILIKE ANY($1::text[])
|
|
) f ON a.host_id = f.host_id`,
|
|
[patterns]
|
|
);
|
|
rows = result.rows;
|
|
} else {
|
|
const result = await pool.query(
|
|
`SELECT host_id, has_action_plan, plan_count, plans_json, atlas_known, synced_at FROM atlas_action_plans_cache`
|
|
);
|
|
rows = result.rows;
|
|
}
|
|
} else {
|
|
const result = await pool.query(
|
|
`SELECT host_id, has_action_plan, plan_count, plans_json, atlas_known, synced_at FROM atlas_action_plans_cache`
|
|
);
|
|
rows = result.rows;
|
|
}
|
|
|
|
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
|
|
*
|
|
* Syncs action plan data from Atlas for all hosts found in ivanti_findings.
|
|
* Fetches plans per host in batches of 5 and upserts into the local cache.
|
|
* Scopes to the provided teams or falls back to IVANTI_MANAGED_BUS.
|
|
* Requires Admin or Standard_User group.
|
|
*
|
|
* @query {string} [teams] - Comma-separated team names to scope sync (e.g. 'STEAM,ACCESS-ENG')
|
|
* @param {Object} [req.body]
|
|
* @param {string} [req.body.teams] - Comma-separated team names (alternative to query param)
|
|
* @returns {Object} 200 - { synced, withPlans, failed }
|
|
* @returns {Object} 503 - { error } when Atlas API is not configured
|
|
* @returns {Object} 500 - { error } on unexpected failure
|
|
*/
|
|
router.post('/sync', requireAuth(), 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 {
|
|
// Scope sync to the user's active teams if provided, otherwise sync only
|
|
// findings from managed BUs (IVANTI_MANAGED_BUS) to avoid polluting cache
|
|
// with "no plan" entries for BUs not covered by Atlas.
|
|
const teamsParam = req.query.teams || req.body.teams || '';
|
|
const managedBUs = (process.env.IVANTI_MANAGED_BUS || 'NTS-AEO-ACCESS-ENG,NTS-AEO-STEAM')
|
|
.split(',').map(b => b.trim()).filter(Boolean);
|
|
|
|
let findingsRows;
|
|
if (teamsParam) {
|
|
const teams = teamsParam.split(',').map(t => t.trim()).filter(Boolean);
|
|
if (teams.length > 0) {
|
|
const patterns = teams.map(t => `%${t}%`);
|
|
const result = await pool.query(
|
|
`SELECT DISTINCT host_id FROM ivanti_findings
|
|
WHERE host_id IS NOT NULL AND host_id > 0
|
|
AND bu_ownership ILIKE ANY($1::text[])`,
|
|
[patterns]
|
|
);
|
|
findingsRows = result.rows;
|
|
} else {
|
|
// No valid teams — fall back to managed BUs
|
|
const patterns = managedBUs.map(b => `%${b}%`);
|
|
const result = await pool.query(
|
|
`SELECT DISTINCT host_id FROM ivanti_findings
|
|
WHERE host_id IS NOT NULL AND host_id > 0
|
|
AND bu_ownership ILIKE ANY($1::text[])`,
|
|
[patterns]
|
|
);
|
|
findingsRows = result.rows;
|
|
}
|
|
} else {
|
|
// No teams specified — default to managed BUs only
|
|
const patterns = managedBUs.map(b => `%${b}%`);
|
|
const result = await pool.query(
|
|
`SELECT DISTINCT host_id FROM ivanti_findings
|
|
WHERE host_id IS NOT NULL AND host_id > 0
|
|
AND bu_ownership ILIKE ANY($1::text[])`,
|
|
[patterns]
|
|
);
|
|
findingsRows = result.rows;
|
|
}
|
|
|
|
const hostIds = findingsRows.map(r => r.host_id);
|
|
|
|
if (hostIds.length === 0) {
|
|
return res.json({ synced: 0, withPlans: 0, failed: 0 });
|
|
}
|
|
|
|
// Build a set of host IDs belonging to managed BUs — these always show the badge
|
|
const managedPatterns = managedBUs.map(b => `%${b}%`);
|
|
let managedHostIds = new Set();
|
|
try {
|
|
const { rows: managedRows } = await pool.query(
|
|
`SELECT DISTINCT host_id FROM ivanti_findings
|
|
WHERE host_id IS NOT NULL AND host_id > 0
|
|
AND bu_ownership ILIKE ANY($1::text[])`,
|
|
[managedPatterns]
|
|
);
|
|
managedHostIds = new Set(managedRows.map(r => r.host_id));
|
|
} catch (_) { /* non-fatal — fall back to plans-only logic */ }
|
|
|
|
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;
|
|
const isManagedHost = managedHostIds.has(hostId);
|
|
|
|
if (result.status >= 200 && result.status < 300) {
|
|
let allPlans = [];
|
|
let activePlans = [];
|
|
let atlasRecognizesHost = false;
|
|
try {
|
|
const parsed = JSON.parse(result.body);
|
|
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
// Check for "not found" error responses that come back as 200
|
|
if (parsed.error || parsed.message?.includes('not found')) {
|
|
atlasRecognizesHost = false;
|
|
} else {
|
|
atlasRecognizesHost = true;
|
|
activePlans = Array.isArray(parsed.active) ? parsed.active : [];
|
|
const inactive = Array.isArray(parsed.inactive) ? parsed.inactive : [];
|
|
allPlans = [...activePlans, ...inactive];
|
|
}
|
|
} else if (Array.isArray(parsed)) {
|
|
atlasRecognizesHost = true;
|
|
allPlans = parsed;
|
|
activePlans = parsed;
|
|
}
|
|
} catch (e) {
|
|
allPlans = [];
|
|
activePlans = [];
|
|
atlasRecognizesHost = false;
|
|
}
|
|
|
|
const planCount = activePlans.length;
|
|
const hasActionPlan = planCount > 0;
|
|
// Atlas knows this host if it returned a valid structured response
|
|
// (not "not found" or error). This determines whether the badge renders.
|
|
const atlasKnown = atlasRecognizesHost;
|
|
|
|
try {
|
|
if (!hasActionPlan) {
|
|
const { rows: existingRows } = await pool.query(
|
|
`SELECT has_action_plan, plans_json, synced_at FROM atlas_action_plans_cache WHERE host_id = $1`,
|
|
[hostId]
|
|
);
|
|
const existing = existingRows[0];
|
|
if (existing && existing.has_action_plan === true) {
|
|
let existingPlans = [];
|
|
try { existingPlans = JSON.parse(existing.plans_json || '[]'); } catch (_) {}
|
|
const hasBulkStub = existingPlans.some(p => p.source === 'bulk-create');
|
|
if (hasBulkStub) {
|
|
const ageMs = Date.now() - new Date(existing.synced_at).getTime();
|
|
const TEN_MINUTES = 10 * 60 * 1000;
|
|
if (ageMs < TEN_MINUTES) {
|
|
synced++;
|
|
withPlans++;
|
|
continue;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
await pool.query(
|
|
`INSERT INTO atlas_action_plans_cache (host_id, has_action_plan, plan_count, plans_json, atlas_known, synced_at)
|
|
VALUES ($1, $2, $3, $4, $5, 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,
|
|
atlas_known = EXCLUDED.atlas_known,
|
|
synced_at = EXCLUDED.synced_at`,
|
|
[hostId, hasActionPlan, planCount, JSON.stringify(allPlans), atlasKnown]
|
|
);
|
|
} 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}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
logAudit({
|
|
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
|
|
*
|
|
* Proxies a request to Atlas to retrieve action plans for a specific host.
|
|
*
|
|
* @param {number} req.params.hostId - Positive integer host identifier
|
|
* @returns {Object} 2xx - Action plans response from Atlas API
|
|
* @returns {Object} 400 - { error } when hostId is invalid
|
|
* @returns {Object} 502 - { error } when Atlas API is unreachable
|
|
* @returns {Object} 503 - { error } when Atlas API is not configured
|
|
*/
|
|
router.get('/hosts/:hostId/action-plans', requireAuth(), 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 {
|
|
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
|
|
*
|
|
* Creates a new action plan for a host via the Atlas API.
|
|
* Requires Admin or Standard_User group.
|
|
*
|
|
* @param {number} req.params.hostId - Positive integer host identifier
|
|
* @param {Object} req.body
|
|
* @param {string} req.body.plan_type - One of: decommission, remediation, false_positive, risk_acceptance, scan_exclusion
|
|
* @param {string} req.body.commit_date - Date in YYYY-MM-DD format
|
|
* @returns {Object} 2xx - Created plan response from Atlas API
|
|
* @returns {Object} 400 - { error } when hostId, plan_type, or commit_date is invalid
|
|
* @returns {Object} 502 - { error } when Atlas API is unreachable
|
|
* @returns {Object} 503 - { error } when Atlas API is not configured
|
|
*/
|
|
router.put('/hosts/:hostId/action-plans', requireAuth(), 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({
|
|
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
|
|
*
|
|
* Updates an existing action plan for a host via the Atlas API.
|
|
* Requires Admin or Standard_User group.
|
|
*
|
|
* @param {number} req.params.hostId - Positive integer host identifier
|
|
* @param {Object} req.body
|
|
* @param {string} req.body.action_plan_id - Non-empty string identifying the plan to update
|
|
* @param {Object} req.body.updates - Object containing fields to update
|
|
* @returns {Object} 2xx - Updated plan response from Atlas API
|
|
* @returns {Object} 400 - { error } when hostId, action_plan_id, or updates is invalid
|
|
* @returns {Object} 502 - { error } when Atlas API is unreachable
|
|
* @returns {Object} 503 - { error } when Atlas API is not configured
|
|
*/
|
|
router.patch('/hosts/:hostId/action-plans', requireAuth(), 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({
|
|
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
|
|
*
|
|
* Creates action plans for multiple hosts in a single request via the Atlas API.
|
|
* Optimistically updates the local cache with stub plans after a successful response.
|
|
* Requires Admin or Standard_User group.
|
|
*
|
|
* @param {Object} req.body
|
|
* @param {number[]} req.body.host_ids - Non-empty array of positive integer host identifiers
|
|
* @param {string} req.body.plan_type - One of: decommission, remediation, false_positive, risk_acceptance, scan_exclusion
|
|
* @param {string} req.body.commit_date - Date in YYYY-MM-DD format
|
|
* @returns {Object} 2xx - Bulk creation response from Atlas API
|
|
* @returns {Object} 400 - { error } when host_ids, plan_type, or commit_date is invalid
|
|
* @returns {Object} 502 - { error } when Atlas API is unreachable
|
|
* @returns {Object} 503 - { error } when Atlas API is not configured
|
|
*/
|
|
router.post('/hosts/bulk-action-plans', requireAuth(), 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; }
|
|
|
|
// Optimistically update local cache
|
|
for (const hid of host_ids) {
|
|
try {
|
|
const { rows: existingRows } = await pool.query(
|
|
`SELECT plan_count, plans_json FROM atlas_action_plans_cache WHERE host_id = $1`,
|
|
[hid]
|
|
);
|
|
const existing = existingRows[0];
|
|
|
|
let existingPlans = [];
|
|
if (existing && existing.plans_json) {
|
|
try { existingPlans = JSON.parse(existing.plans_json); } catch (_) {}
|
|
}
|
|
|
|
const stubPlan = { plan_type, commit_date, source: 'bulk-create', created_at: new Date().toISOString() };
|
|
const updatedPlans = [...existingPlans, stubPlan];
|
|
const newCount = updatedPlans.length;
|
|
|
|
await pool.query(
|
|
`INSERT INTO atlas_action_plans_cache (host_id, has_action_plan, plan_count, plans_json, atlas_known, synced_at)
|
|
VALUES ($1, true, $2, $3, true, NOW())
|
|
ON CONFLICT(host_id) DO UPDATE SET
|
|
has_action_plan = true,
|
|
plan_count = EXCLUDED.plan_count,
|
|
plans_json = EXCLUDED.plans_json,
|
|
atlas_known = true,
|
|
synced_at = EXCLUDED.synced_at`,
|
|
[hid, newCount, JSON.stringify(updatedPlans)]
|
|
);
|
|
} catch (cacheErr) {
|
|
console.error('[Atlas] Cache update failed for host', hid, ':', cacheErr.message);
|
|
}
|
|
}
|
|
|
|
logAudit({
|
|
userId: req.user.id,
|
|
username: req.user.username,
|
|
action: 'ATLAS_BULK_CREATE_PLANS',
|
|
entityType: 'atlas_action_plan',
|
|
entityId: null,
|
|
details: { host_ids, plan_type, commit_date, count: host_ids.length },
|
|
ipAddress: req.ip
|
|
});
|
|
|
|
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/:hostId/refresh-cache
|
|
*
|
|
* Triggers Atlas to refresh its Ivanti data cache, then updates the local
|
|
* action plans cache for the specified host. Useful when action plan creation
|
|
* fails due to stale finding IDs.
|
|
* Requires Admin or Standard_User group.
|
|
*
|
|
* @param {number} req.params.hostId - Positive integer host identifier
|
|
* @returns {Object} 200 - { success, message } on successful cache refresh
|
|
* @returns {Object} 400 - { error } when hostId is invalid
|
|
* @returns {Object} 502 - { error } when Atlas API is unreachable
|
|
* @returns {Object} 503 - { error } when Atlas API is not configured
|
|
*/
|
|
router.post('/hosts/:hostId/refresh-cache', requireAuth(), 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' });
|
|
}
|
|
|
|
try {
|
|
const result = await atlasPost('/cache/refresh-ivanti', {}, { timeout: 30000 });
|
|
|
|
if (result.status >= 200 && result.status < 300) {
|
|
// Also refresh our local action plans cache for this host
|
|
const plansResult = await atlasGet('/hosts/' + hostId + '/action-plans');
|
|
if (plansResult.status >= 200 && plansResult.status < 300) {
|
|
let allPlans = [];
|
|
let activePlans = [];
|
|
try {
|
|
const parsed = JSON.parse(plansResult.body);
|
|
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 (_) {}
|
|
|
|
const planCount = activePlans.length;
|
|
const hasActionPlan = planCount > 0;
|
|
const atlasKnown = allPlans.length > 0;
|
|
|
|
await pool.query(
|
|
`INSERT INTO atlas_action_plans_cache (host_id, has_action_plan, plan_count, plans_json, atlas_known, synced_at)
|
|
VALUES ($1, $2, $3, $4, $5, 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,
|
|
atlas_known = EXCLUDED.atlas_known,
|
|
synced_at = EXCLUDED.synced_at`,
|
|
[hostId, hasActionPlan, planCount, JSON.stringify(allPlans), atlasKnown]
|
|
);
|
|
}
|
|
|
|
res.json({ success: true, message: 'Atlas cache refreshed for host ' + hostId });
|
|
} 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 refresh-cache failed for host', hostId, ':', err.message);
|
|
res.status(502).json({ error: 'Failed to reach Atlas API: ' + err.message });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* POST /hosts/vulnerabilities
|
|
*
|
|
* Fetches Ivanti vulnerability data for the specified hosts from Atlas.
|
|
*
|
|
* @param {Object} req.body
|
|
* @param {number[]} req.body.host_ids - Non-empty array of positive integer host identifiers
|
|
* @returns {Object} 2xx - Vulnerability data response from Atlas API
|
|
* @returns {Object} 400 - { error } when host_ids is invalid
|
|
* @returns {Object} 502 - { error } when Atlas API is unreachable
|
|
* @returns {Object} 503 - { error } when Atlas API is not configured
|
|
*/
|
|
router.post('/hosts/vulnerabilities', requireAuth(), 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;
|