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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user