Add Atlas InfoSec action plans integration
Integrate Atlas InfoSec API to manage compliance action plans directly from the ReportingPage. Users can view, create, and update action plans for host findings without switching to the Atlas web tool. Backend: - Add atlasApi.js helper with Basic Auth, TLS skip, GET/PUT/PATCH/POST - Add atlas_action_plans_cache migration for SQLite cache table - Add atlas.js router with sync, status, and proxy CRUD endpoints - Mount Atlas router at /api/atlas in server.js - Extract hostId from Ivanti host findings during sync Frontend: - Add AtlasBadge component (amber=needs plan, green=has plan) - Add AtlasSlideOutPanel with plan list, create form, edit capability - Separate active plans from inactive history in collapsible section - Custom dark-themed plan type dropdown - Optimistic local state shows pending plans immediately after creation - Atlas sync button on ReportingPage toolbar - Prepopulate finding ID in create form from clicked row Environment: - Add ATLAS_API_URL, ATLAS_API_USER, ATLAS_API_PASS, ATLAS_SKIP_TLS to .env.example
This commit is contained in:
409
backend/routes/atlas.js
Normal file
409
backend/routes/atlas.js
Normal file
@@ -0,0 +1,409 @@
|
||||
// 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 || []); });
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Router factory
|
||||
// ---------------------------------------------------------------------------
|
||||
function createAtlasRouter(db, requireAuth) {
|
||||
const router = express.Router();
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// GET /status
|
||||
// Return all cached Atlas rows for badge rendering.
|
||||
// Auth: any authenticated user
|
||||
// -----------------------------------------------------------------------
|
||||
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
|
||||
// -----------------------------------------------------------------------
|
||||
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
|
||||
// -----------------------------------------------------------------------
|
||||
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
|
||||
// -----------------------------------------------------------------------
|
||||
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
|
||||
// -----------------------------------------------------------------------
|
||||
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
|
||||
// -----------------------------------------------------------------------
|
||||
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 });
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
module.exports = createAtlasRouter;
|
||||
Reference in New Issue
Block a user