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:
root
2026-04-23 21:52:53 +00:00
parent e1b000870c
commit 4c04c9870a
14 changed files with 3914 additions and 11 deletions

View File

@@ -6,6 +6,8 @@ import { useAuth } from '../../contexts/AuthContext';
import IvantiCountsChart from './IvantiCountsChart';
import CveTooltip from '../CveTooltip';
import RedirectModal from '../RedirectModal';
import AtlasBadge from '../AtlasBadge';
import AtlasSlideOutPanel from '../AtlasSlideOutPanel';
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
const STORAGE_KEY = 'steam_findings_columns_v2';
@@ -514,7 +516,7 @@ function SortIcon({ colKey, sort }) {
// ---------------------------------------------------------------------------
// OverrideCell — inline editable hostname/dns with amber dot when overridden
// ---------------------------------------------------------------------------
function OverrideCell({ findingId, field, originalValue, initialOverride, canWrite }) {
function OverrideCell({ findingId, field, originalValue, initialOverride, canWrite, suffix }) {
const effective = initialOverride ?? originalValue ?? '';
const [value, setValue] = useState(effective);
const [isOverridden, setOverridden] = useState(!!initialOverride);
@@ -620,6 +622,7 @@ function OverrideCell({ findingId, field, originalValue, initialOverride, canWri
</button>
)}
</span>
{suffix}
</td>
);
}
@@ -955,7 +958,7 @@ function FilterDropdown({ anchorEl, colKey, findings, activeFilter, onFilterChan
// ---------------------------------------------------------------------------
// Render a single table cell by column key
// ---------------------------------------------------------------------------
function TableCell({ colKey, finding, canWrite, onCveMouseEnter, onCveMouseLeave, fpSubmissions, onEditSubmission }) {
function TableCell({ colKey, finding, canWrite, onCveMouseEnter, onCveMouseLeave, fpSubmissions, onEditSubmission, atlasStatusMap, onAtlasBadgeClick }) {
switch (colKey) {
case 'findingId':
return (
@@ -1017,6 +1020,13 @@ function TableCell({ colKey, finding, canWrite, onCveMouseEnter, onCveMouseLeave
originalValue={finding.hostName}
initialOverride={finding.overrides?.hostName ?? null}
canWrite={canWrite}
suffix={
<AtlasBadge
hostId={finding.hostId}
atlasStatus={atlasStatusMap ? atlasStatusMap.get(finding.hostId) : undefined}
onClick={onAtlasBadgeClick}
/>
}
/>
);
case 'ipAddress':
@@ -3620,6 +3630,15 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
const tooltipCacheRef = useRef(new Map());
const hoverTimerRef = useRef(null);
// Atlas action plan state
const [atlasStatusMap, setAtlasStatusMap] = useState(new Map());
const [atlasSyncing, setAtlasSyncing] = useState(false);
const [atlasError, setAtlasError] = useState(null);
const [atlasPanelOpen, setAtlasPanelOpen] = useState(false);
const [atlasSelectedHostId, setAtlasSelectedHostId] = useState(null);
const [atlasSelectedHostName, setAtlasSelectedHostName] = useState(null);
const [atlasSelectedFindingId, setAtlasSelectedFindingId] = useState(null);
const updateColumns = useCallback((newOrder) => {
setColumnOrder(newOrder);
saveColumnOrder(newOrder);
@@ -3724,6 +3743,20 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
}
};
const fetchAtlasStatus = useCallback(async () => {
try {
const res = await fetch(`${API_BASE}/atlas/status`, { credentials: 'include' });
if (res.ok) {
const data = await res.json();
const map = new Map();
data.forEach(row => map.set(row.host_id, row));
setAtlasStatusMap(map);
}
} catch (err) {
console.error('[Atlas] Failed to fetch status:', err.message);
}
}, []);
const fetchFindings = async () => {
setLoading(true);
try {
@@ -3764,6 +3797,7 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
fetchFPWorkflowCounts();
fetchQueue();
fetchFpSubmissions();
fetchAtlasStatus();
}, []); // eslint-disable-line
// Set/clear a single column filter
@@ -4437,6 +4471,46 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
</button>
<ColumnManager columnOrder={columnOrder} onChange={updateColumns} />
<RowVisibilityManager hiddenRowIds={hiddenRowIds} findings={findings} onRestore={restoreRow} onRestoreAll={restoreAllRows} />
<button
onClick={async () => {
setAtlasSyncing(true);
setAtlasError(null);
try {
const res = await fetch(`${API_BASE}/atlas/sync`, { method: 'POST', credentials: 'include' });
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || 'Atlas sync failed');
}
await fetchAtlasStatus();
} catch (err) {
setAtlasError(err.message);
} finally {
setAtlasSyncing(false);
}
}}
disabled={atlasSyncing || !canWrite()}
title={!canWrite() ? 'Insufficient permissions' : 'Sync Atlas action plan status'}
style={{
display: 'inline-flex', alignItems: 'center', gap: '0.4rem',
padding: '0.4rem 0.75rem',
background: atlasSyncing ? 'rgba(14,165,233,0.08)' : 'rgba(14,165,233,0.06)',
border: '1px solid rgba(14,165,233,0.2)',
borderRadius: '0.375rem',
color: atlasSyncing ? '#475569' : '#0EA5E9',
fontSize: '0.72rem',
fontFamily: "'JetBrains Mono', monospace",
fontWeight: 600,
cursor: atlasSyncing || !canWrite() ? 'not-allowed' : 'pointer',
opacity: !canWrite() ? 0.5 : 1,
textTransform: 'uppercase',
letterSpacing: '0.05em',
}}
>
{atlasSyncing
? <Loader style={{ width: 13, height: 13, animation: 'spin 1s linear infinite' }} />
: <Database style={{ width: 13, height: 13 }} />}
Atlas
</button>
<button
onClick={syncFindings}
disabled={syncing || loading}
@@ -4465,6 +4539,12 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
<span style={{ fontSize: '0.75rem', color: '#FCA5A5', fontFamily: 'monospace' }}>{syncError}</span>
</div>
)}
{atlasError && (
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '0.5rem', padding: '0.625rem 0.875rem', background: 'rgba(239,68,68,0.08)', border: '1px solid rgba(239,68,68,0.25)', borderRadius: '0.375rem', marginBottom: '1rem' }}>
<AlertCircle style={{ width: '15px', height: '15px', color: '#EF4444', flexShrink: 0, marginTop: '1px' }} />
<span style={{ fontSize: '0.75rem', color: '#FCA5A5', fontFamily: 'monospace' }}>Atlas: {atlasError}</span>
</div>
)}
{/* Content */}
{loading ? (
@@ -4696,7 +4776,7 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
/>
</td>
{visibleCols.map((col) => (
<TableCell key={col.key} colKey={col.key} finding={finding} canWrite={canWrite()} onCveMouseEnter={handleCveMouseEnter} onCveMouseLeave={handleCveMouseLeave} fpSubmissions={fpSubmissions} onEditSubmission={handleEditSubmission} />
<TableCell key={col.key} colKey={col.key} finding={finding} canWrite={canWrite()} onCveMouseEnter={handleCveMouseEnter} onCveMouseLeave={handleCveMouseLeave} fpSubmissions={fpSubmissions} onEditSubmission={handleEditSubmission} atlasStatusMap={atlasStatusMap} onAtlasBadgeClick={(hostId) => { setAtlasSelectedHostId(hostId); setAtlasSelectedHostName(finding.hostName || finding.ipAddress || ''); setAtlasSelectedFindingId(finding.id || null); setAtlasPanelOpen(true); }} />
))}
</tr>
);
@@ -4778,6 +4858,21 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
anchorRect={tooltipAnchorRect}
cache={tooltipCacheRef}
/>
{atlasPanelOpen && atlasSelectedHostId && (
<AtlasSlideOutPanel
hostId={atlasSelectedHostId}
hostName={atlasSelectedHostName}
findingId={atlasSelectedFindingId}
onClose={() => {
setAtlasPanelOpen(false);
setAtlasSelectedHostId(null);
setAtlasSelectedHostName(null);
setAtlasSelectedFindingId(null);
}}
canWrite={canWrite()}
onPlanChange={fetchAtlasStatus}
/>
)}
</div>
);
}