fix: prevent stale AI state and align UI contracts
This commit is contained in:
parent
d9ff6a8bff
commit
3f6259db30
|
|
@ -1,4 +1,4 @@
|
||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { AssessorApiClient } from '../../api/assessor-api-client';
|
import { AssessorApiClient } from '../../api/assessor-api-client';
|
||||||
import type { AiOverviewResponse, AiOverviewResult } from '../../domain/ai-overview';
|
import type { AiOverviewResponse, AiOverviewResult } from '../../domain/ai-overview';
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
|
|
@ -14,7 +14,7 @@ export interface AiOverviewController {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useAiOverview(projectId: string | undefined, assessmentIds: string[] = [], scanRunIds: string[] = []): AiOverviewController {
|
export function useAiOverview(projectId: string | undefined, assessmentIds: string[] = [], scanRunIds: string[] = []): AiOverviewController {
|
||||||
const [response, setResponse] = useState<AiOverviewResponse>();
|
const [storedResponse, setStoredResponse] = useState<{ scope: { key: string }; value: AiOverviewResponse }>();
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [generating, setGenerating] = useState(false);
|
const [generating, setGenerating] = useState(false);
|
||||||
const [error, setError] = useState<string>();
|
const [error, setError] = useState<string>();
|
||||||
|
|
@ -23,21 +23,35 @@ export function useAiOverview(projectId: string | undefined, assessmentIds: stri
|
||||||
const normalizedAssessmentIds = useMemo(() => assessmentKey ? assessmentKey.split('\u0000') : [], [assessmentKey]);
|
const normalizedAssessmentIds = useMemo(() => assessmentKey ? assessmentKey.split('\u0000') : [], [assessmentKey]);
|
||||||
const normalizedScanRunIds = useMemo(() => scanRunKey ? scanRunKey.split('\u0000') : [], [scanRunKey]);
|
const normalizedScanRunIds = useMemo(() => scanRunKey ? scanRunKey.split('\u0000') : [], [scanRunKey]);
|
||||||
const scopeKey = `${projectId ?? ''}\u0001${assessmentKey}\u0001${scanRunKey}`;
|
const scopeKey = `${projectId ?? ''}\u0001${assessmentKey}\u0001${scanRunKey}`;
|
||||||
|
const activeScope = useRef({ key: scopeKey });
|
||||||
|
if (activeScope.current.key !== scopeKey) activeScope.current = { key: scopeKey };
|
||||||
|
const requestScope = activeScope.current;
|
||||||
|
const response = storedResponse?.scope === requestScope ? storedResponse.value : undefined;
|
||||||
|
|
||||||
const refresh = useCallback(async () => {
|
const refresh = useCallback(async () => {
|
||||||
if (!projectId || (!normalizedAssessmentIds.length && !normalizedScanRunIds.length)) { setResponse(undefined); return; }
|
if (!projectId || (!normalizedAssessmentIds.length && !normalizedScanRunIds.length)) {
|
||||||
|
if (activeScope.current === requestScope) setStoredResponse(undefined);
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const next = await assessorApi.getAiOverview(projectId, normalizedAssessmentIds, normalizedScanRunIds);
|
const next = await assessorApi.getAiOverview(projectId, normalizedAssessmentIds, normalizedScanRunIds);
|
||||||
setResponse(next);
|
if (activeScope.current !== requestScope) return;
|
||||||
|
setStoredResponse({ scope: requestScope, value: next });
|
||||||
setError(undefined);
|
setError(undefined);
|
||||||
} catch (cause) {
|
} catch (cause) {
|
||||||
|
if (activeScope.current !== requestScope) return;
|
||||||
setError(cause instanceof Error ? cause.message : 'AI overview could not be loaded.');
|
setError(cause instanceof Error ? cause.message : 'AI overview could not be loaded.');
|
||||||
setResponse(current => current && ['queued', 'running'].includes(current.state) ? { ...current } : current);
|
setStoredResponse(current => current?.scope === requestScope && ['queued', 'running'].includes(current.value.state)
|
||||||
|
? { ...current, value: { ...current.value } }
|
||||||
|
: current);
|
||||||
}
|
}
|
||||||
}, [normalizedAssessmentIds, normalizedScanRunIds, projectId]);
|
}, [normalizedAssessmentIds, normalizedScanRunIds, projectId, requestScope]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let active = true;
|
let active = true;
|
||||||
|
setStoredResponse(undefined);
|
||||||
|
setError(undefined);
|
||||||
|
setGenerating(false);
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
void refresh().finally(() => { if (active) setLoading(false); });
|
void refresh().finally(() => { if (active) setLoading(false); });
|
||||||
return () => { active = false; };
|
return () => { active = false; };
|
||||||
|
|
@ -54,17 +68,20 @@ export function useAiOverview(projectId: string | undefined, assessmentIds: stri
|
||||||
setGenerating(true);
|
setGenerating(true);
|
||||||
setError(undefined);
|
setError(undefined);
|
||||||
try {
|
try {
|
||||||
setResponse(await assessorApi.generateAiOverview(projectId, {
|
const next = await assessorApi.generateAiOverview(projectId, {
|
||||||
assessmentIds: normalizedAssessmentIds,
|
assessmentIds: normalizedAssessmentIds,
|
||||||
scanRunIds: normalizedScanRunIds,
|
scanRunIds: normalizedScanRunIds,
|
||||||
idempotencyKey: createIdempotencyKey()
|
idempotencyKey: createIdempotencyKey()
|
||||||
}));
|
});
|
||||||
|
if (activeScope.current === requestScope) setStoredResponse({ scope: requestScope, value: next });
|
||||||
} catch (cause) {
|
} catch (cause) {
|
||||||
|
if (activeScope.current === requestScope) {
|
||||||
setError(cause instanceof Error ? cause.message : 'AI overview generation could not be started.');
|
setError(cause instanceof Error ? cause.message : 'AI overview generation could not be started.');
|
||||||
} finally {
|
|
||||||
setGenerating(false);
|
|
||||||
}
|
}
|
||||||
}, [normalizedAssessmentIds, normalizedScanRunIds, projectId]);
|
} finally {
|
||||||
|
if (activeScope.current === requestScope) setGenerating(false);
|
||||||
|
}
|
||||||
|
}, [normalizedAssessmentIds, normalizedScanRunIds, projectId, requestScope]);
|
||||||
|
|
||||||
return { response, loading, generating, error, generate };
|
return { response, loading, generating, error, generate };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import { resultReferenceValues } from '../../profiles/purple-knight/result-linke
|
||||||
import { pingCastleSectionIds } from '../../profiles/pingcastle/pingcastle-section-links';
|
import { pingCastleSectionIds } from '../../profiles/pingcastle/pingcastle-section-links';
|
||||||
import { recordResultSheets } from '../linked-results/record-results';
|
import { recordResultSheets } from '../linked-results/record-results';
|
||||||
import { resultMessageFor, resultOutcomeFor, resultSectionsFor, type ResultSection } from '../linked-results/result-message';
|
import { resultMessageFor, resultOutcomeFor, resultSectionsFor, type ResultSection } from '../linked-results/result-message';
|
||||||
|
import { RESULT_WINDOW_COLORS } from '../linked-results/result-window-colors';
|
||||||
import { FindingReader } from '../finding-reader/FindingReader';
|
import { FindingReader } from '../finding-reader/FindingReader';
|
||||||
import { ClusterSlotIcon, FloatingResultsWindow } from '../linked-results/FloatingResultsWindow';
|
import { ClusterSlotIcon, FloatingResultsWindow } from '../linked-results/FloatingResultsWindow';
|
||||||
import {
|
import {
|
||||||
|
|
@ -48,29 +49,6 @@ const TILE_GRID_INSET = 20;
|
||||||
* wie {@link RECORD_ROW_HEIGHT} nur eine Naeherung fuer die Scrollmathematik. */
|
* wie {@link RECORD_ROW_HEIGHT} nur eine Naeherung fuer die Scrollmathematik. */
|
||||||
const TILE_ROW_HEIGHT = TILE_MIN_HEIGHT + TILE_GAP;
|
const TILE_ROW_HEIGHT = TILE_MIN_HEIGHT + TILE_GAP;
|
||||||
|
|
||||||
// Fensterunterscheidungsfarben fuer gleichzeitig geoeffnete Result-Fenster.
|
|
||||||
// Enthielten Violet und Purple; beide ersetzt, weil violett als UI-Rolle
|
|
||||||
// vollstaendig entfernt ist (Nutzerentscheidung 2026-09-20, AGENTS.md
|
|
||||||
// "UI segments and tiles").
|
|
||||||
const resultColors = [
|
|
||||||
'#0e7490', // Cyan
|
|
||||||
'#059669', // Emerald
|
|
||||||
'#dc2626', // Red
|
|
||||||
'#2563eb', // Blue
|
|
||||||
'#ea580c', // Orange
|
|
||||||
'#0d9488', // Teal
|
|
||||||
'#be185d', // Rose
|
|
||||||
'#16a34a', // Green
|
|
||||||
'#d946ef', // Magenta
|
|
||||||
'#b45309', // Amber
|
|
||||||
'#4f46e5', // Indigo
|
|
||||||
'#ca8a04', // Gold
|
|
||||||
'#0369a1', // Sky Blue
|
|
||||||
'#78350f', // Brown
|
|
||||||
'#65a30d', // Lime
|
|
||||||
'#475569' // Slate
|
|
||||||
];
|
|
||||||
|
|
||||||
interface OpenResultWindow {
|
interface OpenResultWindow {
|
||||||
recordId: string;
|
recordId: string;
|
||||||
/** Werkzeugprofil des einzelnen Findings, auch in generischen Sammelblaettern. */
|
/** Werkzeugprofil des einzelnen Findings, auch in generischen Sammelblaettern. */
|
||||||
|
|
@ -258,8 +236,8 @@ export function DatasetViewer() {
|
||||||
return current.filter(item => item.recordId !== record.id);
|
return current.filter(item => item.recordId !== record.id);
|
||||||
}
|
}
|
||||||
const usedColors = new Set(current.map(item => item.color));
|
const usedColors = new Set(current.map(item => item.color));
|
||||||
const assignedColor = resultColors.find(candidate => !usedColors.has(candidate))
|
const assignedColor = RESULT_WINDOW_COLORS.find(candidate => !usedColors.has(candidate))
|
||||||
?? resultColors[current.length % resultColors.length];
|
?? RESULT_WINDOW_COLORS[current.length % RESULT_WINDOW_COLORS.length];
|
||||||
// Im Cluster bleibt genau ein Fenster maximiert. Bereits frei
|
// Im Cluster bleibt genau ein Fenster maximiert. Bereits frei
|
||||||
// herausgezogene Fenster werden dabei nicht veraendert und koennen
|
// herausgezogene Fenster werden dabei nicht veraendert und koennen
|
||||||
// gleichzeitig maximiert bleiben.
|
// gleichzeitig maximiert bleiben.
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import type { BackendStatus } from '../../app/app-state';
|
import type { BackendStatus } from '../../app/app-state';
|
||||||
import { AssessorApiClient } from '../../api/assessor-api-client';
|
import { AssessorApiClient } from '../../api/assessor-api-client';
|
||||||
import type {
|
import type {
|
||||||
|
|
@ -15,30 +15,38 @@ export function useRegulatoryMapping(
|
||||||
backendStatus: BackendStatus
|
backendStatus: BackendStatus
|
||||||
) {
|
) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [response, setResponse] = useState<RegulatoryMappingResponse>();
|
const [storedResponse, setStoredResponse] = useState<{ scope: { key: string }; value: RegulatoryMappingResponse }>();
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const resultProjectionId = occurrenceId ? `scanner-finding:${occurrenceId}` : '';
|
const resultProjectionId = occurrenceId ? `scanner-finding:${occurrenceId}` : '';
|
||||||
|
const scopeKey = `${projectId}\u0000${resultProjectionId}\u0000${backendStatus}`;
|
||||||
|
const activeScope = useRef({ key: scopeKey });
|
||||||
|
if (activeScope.current.key !== scopeKey) activeScope.current = { key: scopeKey };
|
||||||
|
const requestScope = activeScope.current;
|
||||||
|
const response = storedResponse?.scope === requestScope ? storedResponse.value : undefined;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
setResponse(undefined);
|
setStoredResponse(undefined);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
setError('');
|
setError('');
|
||||||
}, [projectId, resultProjectionId]);
|
}, [scopeKey]);
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
if (!projectId || !resultProjectionId || backendStatus !== 'connected') return;
|
if (!projectId || !resultProjectionId || backendStatus !== 'connected' || activeScope.current !== requestScope) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
setResponse(await assessorApi.getRegulatoryMappings(projectId, resultProjectionId));
|
const next = await assessorApi.getRegulatoryMappings(projectId, resultProjectionId);
|
||||||
|
if (activeScope.current !== requestScope) return;
|
||||||
|
setStoredResponse({ scope: requestScope, value: next });
|
||||||
setError('');
|
setError('');
|
||||||
} catch (reason) {
|
} catch (reason) {
|
||||||
|
if (activeScope.current !== requestScope) return;
|
||||||
setError(reason instanceof Error ? reason.message : 'Regulatory mapping could not be loaded.');
|
setError(reason instanceof Error ? reason.message : 'Regulatory mapping could not be loaded.');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
if (activeScope.current === requestScope) setLoading(false);
|
||||||
}
|
}
|
||||||
}, [backendStatus, projectId, resultProjectionId]);
|
}, [backendStatus, projectId, requestScope, resultProjectionId]);
|
||||||
|
|
||||||
useEffect(() => { if (open && !response && !loading && !error) void load(); }, [error, load, loading, open, response]);
|
useEffect(() => { if (open && !response && !loading && !error) void load(); }, [error, load, loading, open, response]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -48,33 +56,43 @@ export function useRegulatoryMapping(
|
||||||
}, [load, open, response]);
|
}, [load, open, response]);
|
||||||
|
|
||||||
const start = useCallback(async () => {
|
const start = useCallback(async () => {
|
||||||
if (!projectId || !resultProjectionId) return;
|
if (!projectId || !resultProjectionId || activeScope.current !== requestScope) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
setResponse(await assessorApi.generateRegulatoryMappings(projectId, { resultProjectionId }));
|
const next = await assessorApi.generateRegulatoryMappings(projectId, { resultProjectionId });
|
||||||
|
if (activeScope.current !== requestScope) return;
|
||||||
|
setStoredResponse({ scope: requestScope, value: next });
|
||||||
setError('');
|
setError('');
|
||||||
} catch (reason) {
|
} catch (reason) {
|
||||||
|
if (activeScope.current !== requestScope) return;
|
||||||
setError(reason instanceof Error ? reason.message : 'Regulatory mapping could not be started.');
|
setError(reason instanceof Error ? reason.message : 'Regulatory mapping could not be started.');
|
||||||
} finally { setLoading(false); }
|
} finally {
|
||||||
}, [projectId, resultProjectionId]);
|
if (activeScope.current === requestScope) setLoading(false);
|
||||||
|
}
|
||||||
|
}, [projectId, requestScope, resultProjectionId]);
|
||||||
|
|
||||||
const decide = useCallback(async (candidate: RegulatoryMappingCandidate, decision: RegulatoryMappingDecisionValue, comment?: string) => {
|
const decide = useCallback(async (candidate: RegulatoryMappingCandidate, decision: RegulatoryMappingDecisionValue, comment?: string) => {
|
||||||
if (!projectId || !resultProjectionId || !response?.run) return;
|
if (!projectId || !resultProjectionId || !response?.run || activeScope.current !== requestScope) return;
|
||||||
const latest = response.decisions.find(item => item.controlId === candidate.controlId);
|
const latest = response.decisions.find(item => item.controlId === candidate.controlId);
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
setResponse(await assessorApi.saveRegulatoryMappingDecision(projectId, {
|
const next = await assessorApi.saveRegulatoryMappingDecision(projectId, {
|
||||||
resultProjectionId,
|
resultProjectionId,
|
||||||
controlId: candidate.controlId,
|
controlId: candidate.controlId,
|
||||||
decision,
|
decision,
|
||||||
...(comment?.trim() ? { comment: comment.trim() } : {}),
|
...(comment?.trim() ? { comment: comment.trim() } : {}),
|
||||||
...(latest ? { expectedLatestDecisionId: latest.id } : {})
|
...(latest ? { expectedLatestDecisionId: latest.id } : {})
|
||||||
}));
|
});
|
||||||
|
if (activeScope.current !== requestScope) return;
|
||||||
|
setStoredResponse({ scope: requestScope, value: next });
|
||||||
setError('');
|
setError('');
|
||||||
} catch (reason) {
|
} catch (reason) {
|
||||||
|
if (activeScope.current !== requestScope) return;
|
||||||
setError(reason instanceof Error ? reason.message : 'The mapping decision could not be saved.');
|
setError(reason instanceof Error ? reason.message : 'The mapping decision could not be saved.');
|
||||||
} finally { setLoading(false); }
|
} finally {
|
||||||
}, [projectId, response, resultProjectionId]);
|
if (activeScope.current === requestScope) setLoading(false);
|
||||||
|
}
|
||||||
|
}, [projectId, requestScope, response, resultProjectionId]);
|
||||||
|
|
||||||
const acceptedCount = useMemo(() => {
|
const acceptedCount = useMemo(() => {
|
||||||
const latest = new Map<string, RegulatoryMappingDecisionValue>();
|
const latest = new Map<string, RegulatoryMappingDecisionValue>();
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
/** Shared non-violet affiliation palette for concurrently open Result windows. */
|
||||||
|
export const RESULT_WINDOW_COLORS = Array.from(
|
||||||
|
{ length: 16 },
|
||||||
|
(_, index) => `var(--result-window-color-${index + 1})`
|
||||||
|
);
|
||||||
|
|
@ -8,6 +8,7 @@ import { linkResultSheets } from '../../profiles/purple-knight/result-linker';
|
||||||
import { linkPingCastleSectionSheets } from '../../profiles/pingcastle/pingcastle-section-links';
|
import { linkPingCastleSectionSheets } from '../../profiles/pingcastle/pingcastle-section-links';
|
||||||
import { inlineResultTableSheets } from '../linked-results/inline-result-tables';
|
import { inlineResultTableSheets } from '../linked-results/inline-result-tables';
|
||||||
import { resultMessageFor, resultOutcomeFor, resultSectionsFor } from '../linked-results/result-message';
|
import { resultMessageFor, resultOutcomeFor, resultSectionsFor } from '../linked-results/result-message';
|
||||||
|
import { RESULT_WINDOW_COLORS } from '../linked-results/result-window-colors';
|
||||||
import { useFilteredRecords } from '../search-filter/use-filtered-records';
|
import { useFilteredRecords } from '../search-filter/use-filtered-records';
|
||||||
import { toolIdForRecord } from '../../importers/status-normalization';
|
import { toolIdForRecord } from '../../importers/status-normalization';
|
||||||
import mitreData from '../../assets/data/mitre-enterprise-v18.1.json';
|
import mitreData from '../../assets/data/mitre-enterprise-v18.1.json';
|
||||||
|
|
@ -15,12 +16,6 @@ import { buildFindingPathItems, loadStoredMitreMatrix, type FindingPathItem, typ
|
||||||
|
|
||||||
const defaultGraphData = mitreData as MitreGraphData;
|
const defaultGraphData = mitreData as MitreGraphData;
|
||||||
const severityLabels = { critical: 'Critical', high: 'High', medium: 'Medium', low: 'Low', info: 'Info', unknown: 'Unknown' } as const;
|
const severityLabels = { critical: 'Critical', high: 'High', medium: 'Medium', low: 'Low', info: 'Info', unknown: 'Unknown' } as const;
|
||||||
// Fensterunterscheidungsfarben fuer gleichzeitig geoeffnete Result-Fenster.
|
|
||||||
// Enthielten zwei violette Toene (#7c3aed, #6d28d9); ersetzt, weil violett als
|
|
||||||
// UI-Rolle vollstaendig entfernt ist (Nutzerentscheidung 2026-09-20, AGENTS.md
|
|
||||||
// "UI segments and tiles").
|
|
||||||
const resultColors = ['#0e7490', '#0f766e', '#b45309', '#be123c', '#1d4ed8', '#b91c6c'];
|
|
||||||
|
|
||||||
type SeverityFilter = 'all' | keyof typeof severityLabels;
|
type SeverityFilter = 'all' | keyof typeof severityLabels;
|
||||||
type OriginFilter = 'all' | 'native' | 'derived';
|
type OriginFilter = 'all' | 'native' | 'derived';
|
||||||
|
|
||||||
|
|
@ -216,7 +211,7 @@ export function MitreGraphView({ onOpenFinding }: MitreGraphViewProps) {
|
||||||
recordId: item.id,
|
recordId: item.id,
|
||||||
toolId: toolIdForRecord(item.record, activeSheet?.toolId ?? dataset?.profileId),
|
toolId: toolIdForRecord(item.record, activeSheet?.toolId ?? dataset?.profileId),
|
||||||
title: sheets.length ? `Results (${total.toLocaleString('en-US')}) – ${item.title}` : `Result – ${item.title}`,
|
title: sheets.length ? `Results (${total.toLocaleString('en-US')}) – ${item.title}` : `Result – ${item.title}`,
|
||||||
color: resultColors[nextColor.current++ % resultColors.length],
|
color: RESULT_WINDOW_COLORS[nextColor.current++ % RESULT_WINDOW_COLORS.length],
|
||||||
zIndex: ++nextZIndex.current,
|
zIndex: ++nextZIndex.current,
|
||||||
minimized: false
|
minimized: false
|
||||||
}]);
|
}]);
|
||||||
|
|
@ -313,13 +308,6 @@ export function MitreGraphView({ onOpenFinding }: MitreGraphViewProps) {
|
||||||
<div className="finding-path-scroll" ref={scrollRef}>
|
<div className="finding-path-scroll" ref={scrollRef}>
|
||||||
<section className="finding-tactic-board" ref={boardRef} aria-label="Findings by ATT&CK tactic">
|
<section className="finding-tactic-board" ref={boardRef} aria-label="Findings by ATT&CK tactic">
|
||||||
<svg className="finding-path-svg-canvas">
|
<svg className="finding-path-svg-canvas">
|
||||||
<defs>
|
|
||||||
<linearGradient id="pathGradient" x1="0%" y1="0%" x2="100%" y2="0%">
|
|
||||||
<stop offset="0%" stopColor="#a855f7" />
|
|
||||||
<stop offset="50%" stopColor="#3b82f6" />
|
|
||||||
<stop offset="100%" stopColor="#06b6d4" />
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
{svgLines.map((line, idx) => {
|
{svgLines.map((line, idx) => {
|
||||||
const cx1 = line.x1 + (line.x2 - line.x1) * 0.5;
|
const cx1 = line.x1 + (line.x2 - line.x1) * 0.5;
|
||||||
const cx2 = line.x1 + (line.x2 - line.x1) * 0.5;
|
const cx2 = line.x1 + (line.x2 - line.x1) * 0.5;
|
||||||
|
|
@ -327,7 +315,7 @@ export function MitreGraphView({ onOpenFinding }: MitreGraphViewProps) {
|
||||||
<path
|
<path
|
||||||
key={idx}
|
key={idx}
|
||||||
d={`M ${line.x1} ${line.y1} C ${cx1} ${line.y1}, ${cx2} ${line.y2}, ${line.x2} ${line.y2}`}
|
d={`M ${line.x1} ${line.y1} C ${cx1} ${line.y1}, ${cx2} ${line.y2}, ${line.x2} ${line.y2}`}
|
||||||
stroke="url(#pathGradient)"
|
stroke="var(--graph-edge)"
|
||||||
strokeWidth="3"
|
strokeWidth="3"
|
||||||
fill="none"
|
fill="none"
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { linkResultSheets } from '../../profiles/purple-knight/result-linker';
|
||||||
import { linkPingCastleSectionSheets } from '../../profiles/pingcastle/pingcastle-section-links';
|
import { linkPingCastleSectionSheets } from '../../profiles/pingcastle/pingcastle-section-links';
|
||||||
import { inlineResultTableSheets } from '../linked-results/inline-result-tables';
|
import { inlineResultTableSheets } from '../linked-results/inline-result-tables';
|
||||||
import { resultMessageFor, resultOutcomeFor, resultSectionsFor } from '../linked-results/result-message';
|
import { resultMessageFor, resultOutcomeFor, resultSectionsFor } from '../linked-results/result-message';
|
||||||
|
import { RESULT_WINDOW_COLORS } from '../linked-results/result-window-colors';
|
||||||
import { useFilteredRecords } from '../search-filter/use-filtered-records';
|
import { useFilteredRecords } from '../search-filter/use-filtered-records';
|
||||||
import { SearchFilterBar, SearchTextControl } from '../search-filter/SearchFilterBar';
|
import { SearchFilterBar, SearchTextControl } from '../search-filter/SearchFilterBar';
|
||||||
import { deriveDynamicFacets } from '../search-filter/dynamic-facets';
|
import { deriveDynamicFacets } from '../search-filter/dynamic-facets';
|
||||||
|
|
@ -26,12 +27,6 @@ import { PathfinderMicroCard, pathfinderMicroCardContract } from './PathfinderMi
|
||||||
const defaultGraphData = mitreData as MitreGraphData;
|
const defaultGraphData = mitreData as MitreGraphData;
|
||||||
const severityLabels = { critical: 'Critical', high: 'High', medium: 'Medium', low: 'Low', info: 'Info', unknown: 'Unknown' } as const;
|
const severityLabels = { critical: 'Critical', high: 'High', medium: 'Medium', low: 'Low', info: 'Info', unknown: 'Unknown' } as const;
|
||||||
const graphNodeKindLabels = { finding: 'Finding', category: 'Category', defense: 'D3FEND', target: 'Target', technique: 'Technique', tactic: 'Tactic' } as const;
|
const graphNodeKindLabels = { finding: 'Finding', category: 'Category', defense: 'D3FEND', target: 'Target', technique: 'Technique', tactic: 'Tactic' } as const;
|
||||||
// Fensterunterscheidungsfarben fuer gleichzeitig geoeffnete Result-Fenster.
|
|
||||||
// Enthielten zwei violette Toene (#7c3aed, #6d28d9); ersetzt, weil violett als
|
|
||||||
// UI-Rolle vollstaendig entfernt ist (Nutzerentscheidung 2026-09-20, AGENTS.md
|
|
||||||
// "UI segments and tiles").
|
|
||||||
const resultColors = ['#0e7490', '#0f766e', '#b45309', '#be123c', '#1d4ed8', '#b91c6c'];
|
|
||||||
|
|
||||||
interface PathSnapshotRequest { id: string; path: PathSnapshotState; }
|
interface PathSnapshotRequest { id: string; path: PathSnapshotState; }
|
||||||
interface ObsidianGraphViewProps {
|
interface ObsidianGraphViewProps {
|
||||||
active: boolean;
|
active: boolean;
|
||||||
|
|
@ -504,7 +499,7 @@ function ObsidianGraphContent({ active, onOpenFinding, pathSnapshotRequest, onPa
|
||||||
recordId: item.id,
|
recordId: item.id,
|
||||||
toolId: toolIdForRecord(item.record, activeSheet?.toolId ?? dataset?.profileId),
|
toolId: toolIdForRecord(item.record, activeSheet?.toolId ?? dataset?.profileId),
|
||||||
title: sheets.length ? `Results (${total.toLocaleString('en-US')}) – ${item.title}` : `Result – ${item.title}`,
|
title: sheets.length ? `Results (${total.toLocaleString('en-US')}) – ${item.title}` : `Result – ${item.title}`,
|
||||||
color: resultColors[nextColor.current++ % resultColors.length],
|
color: RESULT_WINDOW_COLORS[nextColor.current++ % RESULT_WINDOW_COLORS.length],
|
||||||
zIndex: ++nextZIndex.current,
|
zIndex: ++nextZIndex.current,
|
||||||
minimized: false
|
minimized: false
|
||||||
}]);
|
}]);
|
||||||
|
|
|
||||||
|
|
@ -2,22 +2,22 @@
|
||||||
.defender-header { order: 0; }
|
.defender-header { order: 0; }
|
||||||
.defender-source strong { color: var(--client-info-text); font-weight: var(--fw-regular); }
|
.defender-source strong { color: var(--client-info-text); font-weight: var(--fw-regular); }
|
||||||
.defender-context { display: flex; align-items: center; flex-wrap: wrap; gap: var(--space-2) var(--space-5); margin: 0; color: var(--muted); font-size: var(--type-meta-size); }
|
.defender-context { display: flex; align-items: center; flex-wrap: wrap; gap: var(--space-2) var(--space-5); margin: 0; color: var(--muted); font-size: var(--type-meta-size); }
|
||||||
.defender-metrics { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); justify-content: start; gap: var(--space-2); min-width: 0; }
|
.defender-metrics { display: grid; grid-template-columns: repeat(auto-fill, var(--tile-compact-width)); justify-content: start; gap: var(--segment-gap); min-width: 0; padding: var(--segment-inset); border: var(--bw-hairline) solid var(--segment-border); border-radius: var(--segment-radius); background: var(--segment-surface); }
|
||||||
.defender-metrics article { display: grid; align-content: center; gap: var(--space-2); min-width: 0; min-height: 88px; padding: var(--tile-inset); border: 0; border-radius: var(--btn-radius); background: var(--tile-surface); box-shadow: none; }
|
.defender-metrics article { display: grid; align-content: center; gap: var(--space-2); min-width: 0; min-height: 88px; padding: var(--tile-inset); border: 0; border-radius: var(--tile-radius); background: var(--tile-surface); box-shadow: none; }
|
||||||
.defender-metrics span { color: var(--client-info-muted); font-size: var(--type-label-size); font-weight: var(--fw-semibold); line-height: 1.3; }
|
.defender-metrics span { color: var(--client-info-muted); font-size: var(--type-table-body-size); font-weight: var(--fw-semibold); line-height: 1.3; }
|
||||||
.defender-metrics strong { color: var(--client-info-text); font-size: var(--type-h2-size); font-weight: var(--fw-semibold); font-variant-numeric: tabular-nums; line-height: 1; }
|
.defender-metrics strong { color: var(--client-info-text); font-size: var(--type-h2-size); font-weight: var(--fw-semibold); font-variant-numeric: tabular-nums; line-height: 1; }
|
||||||
.defender-tabs, .client-info-tabs { display: flex; flex-wrap: wrap; gap: var(--space-2); }
|
.defender-tabs, .client-info-tabs { display: flex; flex-wrap: wrap; gap: var(--space-2); }
|
||||||
.defender-tabs button, .client-info-tabs button { min-height: 36px; padding: var(--space-2) var(--space-4); border: var(--border-hair) solid transparent; border-radius: var(--btn-radius); color: var(--text); background: transparent; font-size: var(--ui-control-size); font-weight: var(--fw-semibold); cursor: pointer; }
|
.defender-tabs button, .client-info-tabs button { min-height: 36px; padding: var(--space-2) var(--space-4); border: var(--border-hair) solid transparent; border-radius: var(--btn-radius); color: var(--text); background: transparent; font-size: var(--ui-control-size); font-weight: var(--fw-semibold); cursor: pointer; }
|
||||||
.defender-tabs button:hover, .client-info-tabs button:hover { background: var(--tile-surface-hover); }
|
.defender-tabs button:hover, .client-info-tabs button:hover { background: var(--tile-surface-hover); }
|
||||||
.defender-tabs button:focus-visible, .client-info-tabs button:focus-visible { outline: var(--bw-emphasis) solid var(--focus-ring); outline-offset: calc(-1 * var(--bw-emphasis)); }
|
.defender-tabs button:focus-visible, .client-info-tabs button:focus-visible { outline: var(--bw-emphasis) solid var(--focus-ring); outline-offset: calc(-1 * var(--bw-emphasis)); }
|
||||||
.defender-tabs button[aria-pressed="true"], .client-info-tabs button[aria-pressed="true"] { outline: var(--bw-emphasis) solid var(--marking-color); outline-offset: calc(-1 * var(--bw-emphasis)); background: var(--tile-surface); }
|
.defender-tabs button[aria-pressed="true"], .client-info-tabs button[aria-pressed="true"] { outline: var(--bw-emphasis) solid var(--marking-color); outline-offset: calc(-1 * var(--bw-emphasis)); background: var(--tile-surface); }
|
||||||
.defender-view { display: grid; gap: var(--space-4); min-width: 0; padding: var(--tile-inset); border: 0; border-radius: var(--btn-radius); background: var(--tile-surface); }
|
.defender-view { display: grid; gap: var(--space-4); min-width: 0; padding: var(--tile-inset); border: 0; border-radius: var(--tile-radius); background: var(--tile-surface); }
|
||||||
.defender-view > header { display: flex; align-items: baseline; justify-content: space-between; flex-wrap: wrap; gap: var(--space-2); }
|
.defender-view > header { display: flex; align-items: baseline; justify-content: space-between; flex-wrap: wrap; gap: var(--space-2); }
|
||||||
.defender-view > header h2 { margin: 0; color: var(--heading); font-size: var(--type-h2-size); font-weight: var(--fw-semibold); }
|
.defender-view > header h2 { margin: 0; color: var(--heading); font-size: var(--type-h2-size); font-weight: var(--fw-semibold); }
|
||||||
.defender-view > header span { color: var(--muted); font-size: var(--type-meta-size); }
|
.defender-view > header span { color: var(--muted); font-size: var(--type-meta-size); }
|
||||||
.defender-table-wrap { min-width: 0; overflow-x: auto; background: var(--client-info-surface); }
|
.defender-table-wrap { min-width: 0; overflow-x: auto; background: var(--client-info-surface); }
|
||||||
.defender-table { width: 100%; border-collapse: collapse; table-layout: auto; color: var(--text); background: var(--tile-surface); font-size: var(--type-table-body-size); }
|
.defender-table { width: 100%; border-collapse: collapse; table-layout: auto; color: var(--text); background: var(--tile-surface); font-size: var(--type-table-body-size); }
|
||||||
.defender-table th { padding: var(--space-2) var(--space-3); border-bottom: var(--border-hair) solid var(--line-strong); color: var(--heading); font-size: var(--type-label-size); font-weight: var(--fw-semibold); text-align: left; text-transform: uppercase; vertical-align: bottom; }
|
.defender-table th { padding: var(--space-2) var(--space-3); border-bottom: var(--border-hair) solid var(--line-strong); color: var(--heading); font-size: var(--type-table-body-size); font-weight: var(--fw-semibold); text-align: left; text-transform: uppercase; vertical-align: bottom; }
|
||||||
.defender-table td { max-width: 440px; padding: var(--space-3); border-bottom: var(--border-hair) solid var(--line); line-height: 1.45; overflow-wrap: normal; word-break: normal; vertical-align: top; }
|
.defender-table td { max-width: 440px; padding: var(--space-3); border-bottom: var(--border-hair) solid var(--line); line-height: 1.45; overflow-wrap: normal; word-break: normal; vertical-align: top; }
|
||||||
.defender-table td > button { padding: 0; border: 0; color: var(--heading); background: transparent; font: inherit; font-weight: var(--fw-semibold); text-align: left; cursor: pointer; }
|
.defender-table td > button { padding: 0; border: 0; color: var(--heading); background: transparent; font: inherit; font-weight: var(--fw-semibold); text-align: left; cursor: pointer; }
|
||||||
.defender-table .defender-row-toggle { display: inline-flex; align-items: center; gap: var(--space-2); }
|
.defender-table .defender-row-toggle { display: inline-flex; align-items: center; gap: var(--space-2); }
|
||||||
|
|
@ -25,14 +25,14 @@
|
||||||
.defender-table td > button:hover { background: var(--tile-surface-hover); }
|
.defender-table td > button:hover { background: var(--tile-surface-hover); }
|
||||||
.defender-table td > button:focus-visible { outline: var(--bw-emphasis) solid var(--focus-ring); outline-offset: 2px; }
|
.defender-table td > button:focus-visible { outline: var(--bw-emphasis) solid var(--focus-ring); outline-offset: 2px; }
|
||||||
.defender-selected-row > td { background: var(--tile-surface-hover); }
|
.defender-selected-row > td { background: var(--tile-surface-hover); }
|
||||||
.defender-selected-row > td:first-child { box-shadow: inset 4px 0 var(--marking-color); }
|
.defender-selected-row > td:first-child { box-shadow: inset var(--marker-width) 0 var(--marking-color); }
|
||||||
.defender-detail-row > td { max-width: none; padding: 0 0 var(--space-3); border-bottom-color: var(--marking-color); }
|
.defender-detail-row > td { max-width: none; padding: 0 0 var(--space-3); border-bottom-color: var(--marking-color); }
|
||||||
.defender-table small { display: block; margin-top: var(--space-1); color: var(--muted); font-size: var(--type-meta-size); }
|
.defender-table small { display: block; margin-top: var(--space-1); color: var(--muted); font-size: var(--type-meta-size); }
|
||||||
.defender-severity { display: inline-flex; align-items: center; gap: var(--space-2); white-space: nowrap; }
|
.defender-severity { display: inline-flex; align-items: center; gap: var(--space-2); white-space: nowrap; }
|
||||||
.defender-severity i { width: 4px; height: 18px; background: var(--severity, var(--muted)); }
|
.defender-severity i { width: 4px; height: 18px; background: var(--severity, var(--muted)); }
|
||||||
.defender-conflict, .defender-warning { color: var(--warning); }
|
.defender-conflict, .defender-warning { color: var(--warning); }
|
||||||
.defender-unavailable, .defender-empty { color: var(--muted); }
|
.defender-unavailable, .defender-empty { color: var(--muted); }
|
||||||
.defender-drilldown { display: grid; gap: var(--space-3); padding: var(--tile-inset); border: 0; border-radius: var(--btn-radius); background: var(--tile-surface); box-shadow: none; }
|
.defender-drilldown { display: grid; gap: var(--space-3); padding: var(--tile-inset); border: 0; border-radius: var(--tile-radius); background: var(--tile-surface); box-shadow: none; }
|
||||||
.defender-drilldown h3, .defender-drilldown h4 { margin: 0; color: var(--heading); font-weight: var(--fw-semibold); }
|
.defender-drilldown h3, .defender-drilldown h4 { margin: 0; color: var(--heading); font-weight: var(--fw-semibold); }
|
||||||
.defender-drilldown h3 { font-size: var(--type-detail-title-size); }
|
.defender-drilldown h3 { font-size: var(--type-detail-title-size); }
|
||||||
.defender-drilldown h4 { font-size: var(--type-h4-size); }
|
.defender-drilldown h4 { font-size: var(--type-h4-size); }
|
||||||
|
|
@ -44,10 +44,8 @@
|
||||||
.defender-drilldown details > .defender-table, .defender-drilldown details > .defender-drilldown { margin-top: var(--space-3); }
|
.defender-drilldown details > .defender-table, .defender-drilldown details > .defender-drilldown { margin-top: var(--space-3); }
|
||||||
.defender-aggregate-summary, .defender-severity-strip { display: grid; grid-template-columns: repeat(auto-fill, var(--tile-compact-width)); justify-content: start; gap: var(--segment-gap); }
|
.defender-aggregate-summary, .defender-severity-strip { display: grid; grid-template-columns: repeat(auto-fill, var(--tile-compact-width)); justify-content: start; gap: var(--segment-gap); }
|
||||||
.defender-aggregate-summary > span, .defender-severity-strip article { display: grid; gap: var(--space-1); min-width: var(--tile-compact-width); padding: var(--space-3); background: var(--surface-inset); }
|
.defender-aggregate-summary > span, .defender-severity-strip article { display: grid; gap: var(--space-1); min-width: var(--tile-compact-width); padding: var(--space-3); background: var(--surface-inset); }
|
||||||
.defender-aggregate-summary strong { color: var(--heading); font-size: var(--type-label-size); font-weight: var(--fw-semibold); }
|
.defender-aggregate-summary strong { color: var(--heading); font-size: var(--type-table-body-size); font-weight: var(--fw-semibold); }
|
||||||
.defender-aggregate-summary span, .defender-severity-strip strong { font-size: var(--type-table-body-size); }
|
.defender-aggregate-summary span, .defender-severity-strip strong { font-size: var(--type-table-body-size); }
|
||||||
.defender-severity-strip article { grid-template-columns: minmax(0, 1fr) auto; align-items: center; }
|
.defender-severity-strip article { grid-template-columns: minmax(0, 1fr) auto; align-items: center; }
|
||||||
.defender-severity-strip strong { color: var(--heading); font-weight: var(--fw-semibold); font-variant-numeric: tabular-nums; }
|
.defender-severity-strip strong { color: var(--heading); font-weight: var(--fw-semibold); font-variant-numeric: tabular-nums; }
|
||||||
@container (max-width: 1100px) { .defender-metrics { grid-template-columns: repeat(3, minmax(0, 1fr)); } }
|
@container (max-width: 760px) { .defender-view > header { display: grid; } }
|
||||||
@container (max-width: 760px) { .defender-view > header { display: grid; } .defender-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
|
||||||
@container (max-width: 480px) { .defender-metrics { grid-template-columns: 1fr; } }
|
|
||||||
|
|
|
||||||
|
|
@ -319,6 +319,26 @@
|
||||||
"dedizierter, nicht violetter Kategorieton" ausfuellen. */
|
"dedizierter, nicht violetter Kategorieton" ausfuellen. */
|
||||||
--chart-tone-emphasis: var(--graph-relation);
|
--chart-tone-emphasis: var(--graph-relation);
|
||||||
|
|
||||||
|
/* Zugehoerigkeitsfarben fuer gleichzeitig geoeffnete Result-Fenster.
|
||||||
|
Sie unterscheiden Fenster, markieren aber weder Auswahl noch Fokus und
|
||||||
|
enthalten bewusst keinen violetten Ton. */
|
||||||
|
--result-window-color-1: #22A7C7;
|
||||||
|
--result-window-color-2: #2E9E6B;
|
||||||
|
--result-window-color-3: #E45E63;
|
||||||
|
--result-window-color-4: #3D8FD1;
|
||||||
|
--result-window-color-5: #E2680F;
|
||||||
|
--result-window-color-6: #1FA89E;
|
||||||
|
--result-window-color-7: #D6497A;
|
||||||
|
--result-window-color-8: #6B9E1F;
|
||||||
|
--result-window-color-9: #C99700;
|
||||||
|
--result-window-color-10: #B85206;
|
||||||
|
--result-window-color-11: #348CF2;
|
||||||
|
--result-window-color-12: #A98221;
|
||||||
|
--result-window-color-13: #2E93AD;
|
||||||
|
--result-window-color-14: #9A6840;
|
||||||
|
--result-window-color-15: #7FAE38;
|
||||||
|
--result-window-color-16: #6E7D8C;
|
||||||
|
|
||||||
/* Rahmen: Weiche Konturen; dezenter heller Trennstrich fuer Listen */
|
/* Rahmen: Weiche Konturen; dezenter heller Trennstrich fuer Listen */
|
||||||
--border-subtle: rgba(255, 255, 255, 0.14); /* Zellen-, Zeilentrenner */
|
--border-subtle: rgba(255, 255, 255, 0.14); /* Zellen-, Zeilentrenner */
|
||||||
--border-default: #383840; /* Controls, Tiles, Panels */
|
--border-default: #383840; /* Controls, Tiles, Panels */
|
||||||
|
|
@ -532,6 +552,24 @@
|
||||||
die frueher `--accent` wiederverwendeten. */
|
die frueher `--accent` wiederverwendeten. */
|
||||||
--chart-tone-emphasis: var(--graph-relation);
|
--chart-tone-emphasis: var(--graph-relation);
|
||||||
|
|
||||||
|
/* Dieselben Fensterrollen mit auf dem hellen Skin lesbaren Werten. */
|
||||||
|
--result-window-color-1: #087A96;
|
||||||
|
--result-window-color-2: #187447;
|
||||||
|
--result-window-color-3: #B72F3E;
|
||||||
|
--result-window-color-4: #2A6FA8;
|
||||||
|
--result-window-color-5: #B85206;
|
||||||
|
--result-window-color-6: #10756D;
|
||||||
|
--result-window-color-7: #B02F5E;
|
||||||
|
--result-window-color-8: #4F7A12;
|
||||||
|
--result-window-color-9: #8F6B00;
|
||||||
|
--result-window-color-10: #934000;
|
||||||
|
--result-window-color-11: #1C70C9;
|
||||||
|
--result-window-color-12: #7A5D0A;
|
||||||
|
--result-window-color-13: #167C95;
|
||||||
|
--result-window-color-14: #70421F;
|
||||||
|
--result-window-color-15: #567A18;
|
||||||
|
--result-window-color-16: #53616F;
|
||||||
|
|
||||||
--border-subtle: #E2E2E8;
|
--border-subtle: #E2E2E8;
|
||||||
--border-default: #CFCFD8;
|
--border-default: #CFCFD8;
|
||||||
--border-strong: #A8A8B4;
|
--border-strong: #A8A8B4;
|
||||||
|
|
|
||||||
539
test-output.json
539
test-output.json
|
|
@ -1,539 +0,0 @@
|
||||||
{
|
|
||||||
"schemaVersion": 1,
|
|
||||||
"executiveSummary": "The assessment indicates a critical posture driven by confirmed Tier 0 Active Directory and AD CS exposures. The authoritative summary reports 455 actionable findings, 450 failed results, and overall finding severity totals of 17 critical and 190 high. Critical indicators include AdminSDHolder permission changes, weak privileged password policy, unsafe certificate templates, AD CS web enrollment, an enabled domain-controller print spooler, and a Zerologon finding. Entra ID also shows recurring weaknesses in privileged-role governance, phishing-resistant MFA, risk-based access, legacy authentication, application consent, and external collaboration. Monitoring is weakened by failed centralized security-log and privileged-role alerting controls, while an unusual privileged sign-in warning requires investigation. Windows baseline deviations add legacy-protocol, auditing, firewall, and domain-controller hardening concerns. Coverage is incomplete because 169 checks were not run and 2 findings ended in execution errors; these gaps are not treated as passed or failed.",
|
|
||||||
"assessment": {
|
|
||||||
"riskLevel": "critical",
|
|
||||||
"confidence": "medium",
|
|
||||||
"rationale": "Multiple confirmed critical indicators affect Tier 0 identity infrastructure and AD CS, with additional high-risk identity and privilege-control deviations in Entra ID. Confidence is reduced by 169 not-run checks and 2 execution errors, but those gaps do not negate the confirmed critical evidence."
|
|
||||||
},
|
|
||||||
"environmentOverview": {
|
|
||||||
"summary": "Observed context includes Windows Server 2016 domain and forest functional levels, Windows Server 2019 operating-system observations, a single domain controller, PKI certificate-template inventory, disabled AD Recycle Bin, and no WEF configuration observed. These observations provide context only and are not treated as security findings.",
|
|
||||||
"factsUsed": [
|
|
||||||
"domain.functional_level",
|
|
||||||
"forest.functional_level",
|
|
||||||
"domain.controller_count",
|
|
||||||
"operating_system.name",
|
|
||||||
"inventory.sectionpki.certificate.templates.list.row_count",
|
|
||||||
"domain.recycle_bin_enabled",
|
|
||||||
"logging.wef.configuration_count"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"themes": [
|
|
||||||
{
|
|
||||||
"id": "T1",
|
|
||||||
"title": "Tier 0 Active Directory exposure",
|
|
||||||
"riskLevel": "critical",
|
|
||||||
"confidence": "high",
|
|
||||||
"summary": "Confirmed deviations affect core domain-control paths, including AdminSDHolder permissions, Zerologon, domain-controller print services, writable RBCD paths, shadow credentials on privileged objects, dangerous GPO user rights, and weak privileged-account password controls.",
|
|
||||||
"findingIds": [
|
|
||||||
"SI000055",
|
|
||||||
"SI000036",
|
|
||||||
"SI000077",
|
|
||||||
"SI000079",
|
|
||||||
"SI000080",
|
|
||||||
"SI000351",
|
|
||||||
"SI000302",
|
|
||||||
"SI000096",
|
|
||||||
"SI000067"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "T2",
|
|
||||||
"title": "AD CS certificate abuse paths",
|
|
||||||
"riskLevel": "critical",
|
|
||||||
"confidence": "high",
|
|
||||||
"summary": "Certificate services expose multiple abuse paths: requester-controlled subject alternative names, web enrollment accepting an unsafe authentication path, and templates combining several insecure settings. These findings can undermine privileged authentication and require careful PKI remediation.",
|
|
||||||
"findingIds": [
|
|
||||||
"SI000157",
|
|
||||||
"SI000095",
|
|
||||||
"SI000156"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "T3",
|
|
||||||
"title": "Privileged access governance",
|
|
||||||
"riskLevel": "high",
|
|
||||||
"confidence": "high",
|
|
||||||
"summary": "Privileged access is insufficiently separated and time-bound. Findings cover synchronized or mail-enabled administrators, excessive or permanent high privilege, external privileged users, privileged service principals using client secrets, root-scope access, and weak Global Administrator governance.",
|
|
||||||
"findingIds": [
|
|
||||||
"CIS.M365.1.1.1",
|
|
||||||
"CIS.M365.1.1.3",
|
|
||||||
"MT.1025",
|
|
||||||
"MT.1027",
|
|
||||||
"MT.1028",
|
|
||||||
"MT.1030",
|
|
||||||
"MT.1032",
|
|
||||||
"MT.1056",
|
|
||||||
"CISA.MS.AAD.7.1",
|
|
||||||
"CISA.MS.AAD.7.2",
|
|
||||||
"CISA.MS.AAD.7.3",
|
|
||||||
"CISA.MS.AAD.7.4"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "T4",
|
|
||||||
"title": "Authentication and Conditional Access",
|
|
||||||
"riskLevel": "high",
|
|
||||||
"confidence": "high",
|
|
||||||
"summary": "Identity protection is weakened by failures involving phishing-resistant MFA, administrator MFA, risk-based access, legacy authentication blocking, weak authentication methods, secure MFA registration, and managed-device requirements. Changes should be staged to avoid lockout.",
|
|
||||||
"findingIds": [
|
|
||||||
"CISA.MS.AAD.2.3",
|
|
||||||
"CISA.MS.AAD.3.1",
|
|
||||||
"CISA.MS.AAD.3.5",
|
|
||||||
"CISA.MS.AAD.3.6",
|
|
||||||
"CISA.MS.AAD.3.7",
|
|
||||||
"CISA.MS.AAD.3.8",
|
|
||||||
"CISA.MS.AAD.1.1",
|
|
||||||
"MT.1024.mfaRegistrationV2",
|
|
||||||
"MT.1024.signinRiskPolicy",
|
|
||||||
"MT.1024.userRiskPolicy",
|
|
||||||
"MT.1024.adminMFAV2",
|
|
||||||
"MT.1009",
|
|
||||||
"MT.1010",
|
|
||||||
"MT.1011",
|
|
||||||
"MT.1012",
|
|
||||||
"MT.1014"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "T5",
|
|
||||||
"title": "Detection and privileged monitoring",
|
|
||||||
"riskLevel": "high",
|
|
||||||
"confidence": "high",
|
|
||||||
"summary": "Central security-log forwarding and privileged-role assignment or activation alerting are not meeting the assessed controls. A warning also identifies unusual privileged sign-in activity, which requires investigation rather than automatic classification as compromise.",
|
|
||||||
"findingIds": [
|
|
||||||
"CISA.MS.AAD.4.1",
|
|
||||||
"CISA.MS.AAD.7.7",
|
|
||||||
"CISA.MS.AAD.7.9",
|
|
||||||
"MT.INV.001"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "T6",
|
|
||||||
"title": "Application and external access governance",
|
|
||||||
"riskLevel": "high",
|
|
||||||
"confidence": "high",
|
|
||||||
"summary": "Application consent, privileged service principals, expiring application credentials, guest invitations, and SharePoint external sharing show governance weaknesses. Together they increase the likelihood of excessive application or external-party access.",
|
|
||||||
"findingIds": [
|
|
||||||
"CISA.MS.AAD.5.2",
|
|
||||||
"CISA.MS.AAD.5.3",
|
|
||||||
"EIDSCA.CR01",
|
|
||||||
"MT.1024.applicationCredentialExpiry",
|
|
||||||
"MT.1027",
|
|
||||||
"CISA.MS.AAD.8.2",
|
|
||||||
"CISA.MS.SHAREPOINT.1.3"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "T7",
|
|
||||||
"title": "Windows and domain-controller baseline drift",
|
|
||||||
"riskLevel": "medium",
|
|
||||||
"confidence": "medium",
|
|
||||||
"summary": "Confirmed baseline deviations include legacy LM or NTLM acceptance, missing LDAP channel-binding or signing enforcement, SMBv1 settings, firewall configuration, credential-validation auditing, and SYSVOL or NETLOGON path hardening. Source severity is unknown for several checks, so risk is assessed conservatively.",
|
|
||||||
"findingIds": [
|
|
||||||
"S-OldNtlm",
|
|
||||||
"2.3.5.3",
|
|
||||||
"2.3.5.4",
|
|
||||||
"18.4.2",
|
|
||||||
"18.4.3",
|
|
||||||
"9.1.1",
|
|
||||||
"17.1.1",
|
|
||||||
"18.6.14.1"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"priorities": [
|
|
||||||
{
|
|
||||||
"rank": 1,
|
|
||||||
"title": "Investigate Tier 0 compromise indicators",
|
|
||||||
"timeframe": "immediate",
|
|
||||||
"owner": "Incident Response Lead with Active Directory Security Owner",
|
|
||||||
"rationale": "AdminSDHolder permission changes are a critical indicator and unusual privileged sign-in activity is separately flagged for investigation. Establish whether changes and sign-ins were authorized before routine configuration work alters evidence.",
|
|
||||||
"expectedRiskReduction": "Rapidly distinguishes authorized change from active compromise and enables containment before an attacker can retain or expand privileged access.",
|
|
||||||
"validation": "Document the AdminSDHolder ACL review, correlate privileged sign-in evidence, confirm authorized ownership of observed changes, and record containment or closure decisions.",
|
|
||||||
"findingIds": [
|
|
||||||
"SI000055",
|
|
||||||
"MT.INV.001"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"rank": 2,
|
|
||||||
"title": "Resolve critical domain-controller exposures",
|
|
||||||
"timeframe": "immediate",
|
|
||||||
"owner": "Active Directory Platform Owner",
|
|
||||||
"rationale": "The assessment confirms a Zerologon finding and an enabled print spooler on a domain controller. Both affect Tier 0 infrastructure and should be addressed before lower-severity baseline work.",
|
|
||||||
"expectedRiskReduction": "Reduces direct compromise and coercion or relay opportunities against domain-controller services.",
|
|
||||||
"validation": "Re-run the two source checks after remediation and verify the affected domain-controller configuration is in the intended state.",
|
|
||||||
"findingIds": [
|
|
||||||
"SI000036",
|
|
||||||
"SI000077"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"rank": 3,
|
|
||||||
"title": "Close AD CS certificate abuse paths",
|
|
||||||
"timeframe": "immediate",
|
|
||||||
"owner": "PKI Owner with Active Directory Security Owner",
|
|
||||||
"rationale": "Critical AD CS findings expose unsafe certificate enrollment and template behavior. Changes must preserve required issuance workflows while removing unnecessary subject-name control and other insecure template settings.",
|
|
||||||
"expectedRiskReduction": "Reduces certificate-based privilege escalation and identity impersonation paths.",
|
|
||||||
"validation": "Inventory affected templates and enrollment endpoints, document approved business requirements, apply approved changes, and re-run all cited AD CS checks.",
|
|
||||||
"findingIds": [
|
|
||||||
"SI000095",
|
|
||||||
"SI000157",
|
|
||||||
"SI000156"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"rank": 4,
|
|
||||||
"title": "Remove writable privilege-escalation paths in Active Directory",
|
|
||||||
"timeframe": "immediate",
|
|
||||||
"owner": "Active Directory Security Owner",
|
|
||||||
"rationale": "Writable RBCD on Tier 0 objects, shadow credentials on privileged objects, and dangerous GPO-granted rights create direct privilege-escalation paths. They should be reviewed and removed unless explicitly required.",
|
|
||||||
"expectedRiskReduction": "Eliminates high-impact paths that can convert lower privilege into control of privileged identities or systems.",
|
|
||||||
"validation": "Confirm each cited path has an approved owner and purpose, remove unauthorized access, and re-run the source checks to verify closure.",
|
|
||||||
"findingIds": [
|
|
||||||
"SI000079",
|
|
||||||
"SI000080",
|
|
||||||
"SI000351",
|
|
||||||
"SI000302"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"rank": 5,
|
|
||||||
"title": "Harden privileged account password controls",
|
|
||||||
"timeframe": "short_term",
|
|
||||||
"owner": "Active Directory Identity Owner",
|
|
||||||
"rationale": "Privileged users are reported under a weak password policy and privileged passwords that never expire are also identified. Correcting privileged credential policy reduces persistence and password-based takeover risk.",
|
|
||||||
"expectedRiskReduction": "Reduces exposure from weak or indefinitely valid privileged credentials.",
|
|
||||||
"validation": "Confirm privileged accounts are covered by the approved stronger policy, remove unsupported non-expiring settings, and re-run the cited checks.",
|
|
||||||
"findingIds": [
|
|
||||||
"SI000096",
|
|
||||||
"SI000067"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"rank": 6,
|
|
||||||
"title": "Rebuild privileged Entra access around least privilege and just-in-time activation",
|
|
||||||
"timeframe": "short_term",
|
|
||||||
"owner": "Entra ID Identity Governance Owner",
|
|
||||||
"rationale": "Multiple high findings show permanent, excessive, synchronized, mail-enabled, external, and service-principal privilege. Design emergency-access exceptions first, then reduce standing privilege and use eligible assignments where supported.",
|
|
||||||
"expectedRiskReduction": "Shrinks the standing cloud control-plane attack surface and limits blast radius from a compromised privileged identity.",
|
|
||||||
"validation": "Review privileged assignments, document approved exceptions, verify least-privilege role mapping, and re-run all cited privileged-access checks.",
|
|
||||||
"findingIds": [
|
|
||||||
"CIS.M365.1.1.1",
|
|
||||||
"MT.1025",
|
|
||||||
"MT.1027",
|
|
||||||
"MT.1028",
|
|
||||||
"MT.1030",
|
|
||||||
"MT.1032",
|
|
||||||
"MT.1056",
|
|
||||||
"CISA.MS.AAD.7.2",
|
|
||||||
"CISA.MS.AAD.7.3",
|
|
||||||
"CISA.MS.AAD.7.4"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"rank": 7,
|
|
||||||
"title": "Stage phishing-resistant MFA and risk-based Conditional Access",
|
|
||||||
"timeframe": "short_term",
|
|
||||||
"owner": "Entra ID Conditional Access Owner",
|
|
||||||
"rationale": "High findings show gaps in MFA strength, administrator MFA, risky sign-in handling, legacy authentication blocking, secure registration, and device requirements. Use staged testing with emergency-access exclusions and rollback planning.",
|
|
||||||
"expectedRiskReduction": "Substantially reduces account takeover from phishing, legacy authentication, risky sessions, and unauthorized MFA registration.",
|
|
||||||
"validation": "Test policies with pilot populations, confirm emergency access remains usable, review sign-in impact, then re-run the cited controls after staged enforcement.",
|
|
||||||
"findingIds": [
|
|
||||||
"CISA.MS.AAD.2.3",
|
|
||||||
"CISA.MS.AAD.3.1",
|
|
||||||
"CISA.MS.AAD.3.5",
|
|
||||||
"CISA.MS.AAD.3.6",
|
|
||||||
"MT.1024.adminMFAV2",
|
|
||||||
"MT.1009",
|
|
||||||
"MT.1010",
|
|
||||||
"MT.1011",
|
|
||||||
"MT.1012",
|
|
||||||
"MT.1014"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"rank": 8,
|
|
||||||
"title": "Restore centralized identity monitoring and privilege alerts",
|
|
||||||
"timeframe": "short_term",
|
|
||||||
"owner": "Security Operations Lead",
|
|
||||||
"rationale": "The assessment reports failed centralized Entra security-log collection and missing privileged-role assignment or activation alerting. These gaps delay detection and investigation of identity attacks.",
|
|
||||||
"expectedRiskReduction": "Improves detection, triage, and forensic visibility for sign-in, risk, application, and privileged-role events.",
|
|
||||||
"validation": "Verify required Entra logs reach the monitored security platform, generate test privileged-role events, and confirm alerts are received and triaged.",
|
|
||||||
"findingIds": [
|
|
||||||
"CISA.MS.AAD.4.1",
|
|
||||||
"CISA.MS.AAD.7.7",
|
|
||||||
"CISA.MS.AAD.7.9"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"rank": 9,
|
|
||||||
"title": "Tighten application consent, service-principal credentials, and external sharing",
|
|
||||||
"timeframe": "medium_term",
|
|
||||||
"owner": "Cloud Security and Microsoft 365 Governance Owner",
|
|
||||||
"rationale": "High findings cover user consent, missing admin-consent workflow, privileged service principals using secrets, expiring credentials, guest invitations, and broad SharePoint external sharing. Remediation should preserve required integrations.",
|
|
||||||
"expectedRiskReduction": "Reduces illicit consent, long-lived application credential abuse, and unintended external data access.",
|
|
||||||
"validation": "Review approved applications and collaboration requirements, validate credential rotations, test consent workflow, and re-run the cited checks.",
|
|
||||||
"findingIds": [
|
|
||||||
"CISA.MS.AAD.5.2",
|
|
||||||
"CISA.MS.AAD.5.3",
|
|
||||||
"MT.1027",
|
|
||||||
"MT.1024.applicationCredentialExpiry",
|
|
||||||
"CISA.MS.AAD.8.2",
|
|
||||||
"CISA.MS.SHAREPOINT.1.3"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"rank": 10,
|
|
||||||
"title": "Harden Windows and domain-controller security baselines",
|
|
||||||
"timeframe": "medium_term",
|
|
||||||
"owner": "Windows Platform Security Owner",
|
|
||||||
"rationale": "Confirmed baseline deviations include legacy authentication, LDAP protections, SMBv1, firewall state, auditing, and hardened domain paths. Apply changes in dependency order with compatibility testing, especially for legacy protocols.",
|
|
||||||
"expectedRiskReduction": "Reduces credential relay, downgrade, lateral-movement, and visibility risks across the Windows identity infrastructure.",
|
|
||||||
"validation": "Pilot baseline changes, review authentication and application impact, deploy approved settings, and re-run the cited checks.",
|
|
||||||
"findingIds": [
|
|
||||||
"S-OldNtlm",
|
|
||||||
"2.3.5.3",
|
|
||||||
"2.3.5.4",
|
|
||||||
"18.4.2",
|
|
||||||
"18.4.3",
|
|
||||||
"9.1.1",
|
|
||||||
"17.1.1",
|
|
||||||
"18.6.14.1"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"roadmap": [
|
|
||||||
{
|
|
||||||
"phase": 0,
|
|
||||||
"title": "Contain and validate Tier 0 risk",
|
|
||||||
"tasks": [
|
|
||||||
"Investigate AdminSDHolder and privileged sign-in anomalies before configuration cleanup.",
|
|
||||||
"Resolve or formally disposition critical domain-controller and AD CS exposures.",
|
|
||||||
"Remove unauthorized Tier 0 write paths and preserve investigation evidence."
|
|
||||||
],
|
|
||||||
"completionCriteria": [
|
|
||||||
"All Tier 0 items cited in immediate priorities have documented owners, disposition, and re-test evidence.",
|
|
||||||
"Any suspected compromise has a recorded incident decision and containment status.",
|
|
||||||
"No critical change is closed solely on configuration intent without a successful validation check."
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"phase": 1,
|
|
||||||
"title": "Secure privileged identities and authentication",
|
|
||||||
"tasks": [
|
|
||||||
"Strengthen privileged account credential policy.",
|
|
||||||
"Redesign Entra privileged assignments around least privilege and eligible activation.",
|
|
||||||
"Pilot phishing-resistant MFA, risk policies, legacy-authentication blocking, and secure registration controls."
|
|
||||||
],
|
|
||||||
"completionCriteria": [
|
|
||||||
"Privileged role assignments have documented business justification and approved exceptions.",
|
|
||||||
"Emergency-access paths are tested before broad Conditional Access enforcement.",
|
|
||||||
"Pilot authentication policies show acceptable sign-in impact and have rollback procedures."
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"phase": 2,
|
|
||||||
"title": "Improve monitoring and cloud governance",
|
|
||||||
"tasks": [
|
|
||||||
"Send required identity security logs to the monitored security platform.",
|
|
||||||
"Enable and test privileged assignment and activation alerts.",
|
|
||||||
"Tighten application consent, service-principal credentials, guest invitation, and external sharing governance."
|
|
||||||
],
|
|
||||||
"completionCriteria": [
|
|
||||||
"Test identity and privileged-role events are visible to security operations.",
|
|
||||||
"Application and external-access exceptions have named owners and review dates.",
|
|
||||||
"Cited monitoring and governance findings are re-tested after changes."
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"phase": 3,
|
|
||||||
"title": "Harden platform baselines and close coverage gaps",
|
|
||||||
"tasks": [
|
|
||||||
"Remediate high-impact Windows and domain-controller baseline deviations with compatibility testing.",
|
|
||||||
"Re-run failed execution checks and grouped not-run control areas with the required permissions, licensing, or data access.",
|
|
||||||
"Address remaining confirmed medium and lower-priority deviations in risk order."
|
|
||||||
],
|
|
||||||
"completionCriteria": [
|
|
||||||
"Legacy protocol and domain-controller hardening changes pass technical validation.",
|
|
||||||
"Execution-error checks return a definitive assessed outcome.",
|
|
||||||
"Not-run areas have either evidence, a documented technical limitation, or an accepted coverage exception."
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"phase": 4,
|
|
||||||
"title": "Institutionalize identity security governance",
|
|
||||||
"tasks": [
|
|
||||||
"Establish recurring privileged-access reviews and certificate-template reviews.",
|
|
||||||
"Maintain tested emergency-access procedures and Conditional Access change control.",
|
|
||||||
"Schedule repeat assessments and track deviations to closure or formal risk acceptance."
|
|
||||||
],
|
|
||||||
"completionCriteria": [
|
|
||||||
"Recurring reviews have defined owners, cadence, and evidence retention.",
|
|
||||||
"Critical identity controls are included in change monitoring and incident procedures.",
|
|
||||||
"Repeat assessment results show documented disposition for previously cited priority findings."
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"coverageGaps": [
|
|
||||||
{
|
|
||||||
"title": "Exchange and email threat protection not assessed",
|
|
||||||
"impact": "Malware scanning, Safe Attachments, spam and phishing defenses, SMTP AUTH, DKIM or DMARC enforcement, and related mail controls remain unknown.",
|
|
||||||
"nextStep": "Re-run the Exchange and Defender checks with the required service access and licensing, then classify each result independently.",
|
|
||||||
"evidenceIds": [
|
|
||||||
"CIS.M365.2.1.4",
|
|
||||||
"CIS.M365.2.1.5",
|
|
||||||
"CIS.M365.2.1.9",
|
|
||||||
"CISA.MS.EXO.10.1",
|
|
||||||
"CISA.MS.EXO.10.2",
|
|
||||||
"CISA.MS.EXO.11.1",
|
|
||||||
"CISA.MS.EXO.14.1",
|
|
||||||
"CISA.MS.EXO.5.1"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "Microsoft 365 audit and alert coverage not assessed",
|
|
||||||
"impact": "Mailbox auditing, audit-log availability, security alerts, and Purview audit coverage cannot be confirmed from this run.",
|
|
||||||
"nextStep": "Provide the scanner access needed to query audit and alert configuration, then re-run the affected controls.",
|
|
||||||
"evidenceIds": [
|
|
||||||
"CIS.M365.3.1.1",
|
|
||||||
"CISA.MS.EXO.13.1",
|
|
||||||
"CISA.MS.EXO.16.1",
|
|
||||||
"CISA.MS.EXO.17.1"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "Data loss prevention coverage not assessed",
|
|
||||||
"impact": "The presence and effectiveness of email DLP controls for sensitive data remain unknown.",
|
|
||||||
"nextStep": "Run the DLP checks with access to the applicable Microsoft 365 or Purview configuration and capture definitive results.",
|
|
||||||
"evidenceIds": [
|
|
||||||
"CISA.MS.EXO.8.1",
|
|
||||||
"CISA.MS.EXO.8.2",
|
|
||||||
"CISA.MS.EXO.8.3",
|
|
||||||
"CISA.MS.EXO.8.4"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "Application attack-path checks not assessed",
|
|
||||||
"impact": "High-risk application permissions, paths to Global Administrator, app-owner MFA, and group-owner consent controls remain unknown.",
|
|
||||||
"nextStep": "Re-run application governance and attack-path checks with sufficient Graph and directory read permissions.",
|
|
||||||
"evidenceIds": [
|
|
||||||
"CISA.MS.AAD.5.4",
|
|
||||||
"MT.1050",
|
|
||||||
"MT.1051",
|
|
||||||
"MT.1063"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "Device and credential exposure checks not assessed",
|
|
||||||
"impact": "Exposure of vulnerable devices and protection of critical credentials by endpoint controls cannot be confirmed.",
|
|
||||||
"nextStep": "Provide the required endpoint-security telemetry and re-run the device and credential-protection checks.",
|
|
||||||
"evidenceIds": [
|
|
||||||
"MT.1087",
|
|
||||||
"MT.1080",
|
|
||||||
"MT.1088",
|
|
||||||
"MT.1089"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "Teams collaboration controls not assessed",
|
|
||||||
"impact": "Presenter, anonymous participant, lobby, external control, reporting, and unmanaged-user restrictions remain partially unknown.",
|
|
||||||
"nextStep": "Re-run Teams policy checks with access to the tenant collaboration configuration.",
|
|
||||||
"evidenceIds": [
|
|
||||||
"MT.1037",
|
|
||||||
"CIS.M365.8.2.2",
|
|
||||||
"CIS.M365.8.5.3",
|
|
||||||
"MT.1042",
|
|
||||||
"MT.1046",
|
|
||||||
"MT.1047",
|
|
||||||
"MT.1048"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "Authentication control coverage remains incomplete",
|
|
||||||
"impact": "Security Defaults, FIDO2 restrictions, modern Exchange authentication, and some legacy-authentication user checks were not assessed.",
|
|
||||||
"nextStep": "Resolve permission or licensing prerequisites and re-run the authentication checks without treating current not-run states as compliant.",
|
|
||||||
"evidenceIds": [
|
|
||||||
"MT.1021",
|
|
||||||
"EIDSCA.AF05",
|
|
||||||
"MT.1044",
|
|
||||||
"MT.1033.0",
|
|
||||||
"MT.1033.1",
|
|
||||||
"MT.1033.2",
|
|
||||||
"MT.1033.3"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "Hybrid identity checks failed to execute",
|
|
||||||
"impact": "Seamless SSO hardening and synchronized-object matching protections remain unknown because their checks returned execution errors.",
|
|
||||||
"nextStep": "Correct the scanner data or execution prerequisites and re-run both checks to obtain definitive outcomes.",
|
|
||||||
"evidenceIds": [
|
|
||||||
"MT.1084",
|
|
||||||
"MT.1073"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"quickWins": [
|
|
||||||
{
|
|
||||||
"title": "Investigate the unusual privileged sign-in warning using existing sign-in logs",
|
|
||||||
"findingIds": [
|
|
||||||
"MT.INV.001"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "Review AdminSDHolder ACL changes against approved change records",
|
|
||||||
"findingIds": [
|
|
||||||
"SI000055"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "Disable the domain-controller Print Spooler where it is not required",
|
|
||||||
"findingIds": [
|
|
||||||
"SI000077"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "Enable privileged-role assignment and activation alert recipients",
|
|
||||||
"findingIds": [
|
|
||||||
"CISA.MS.AAD.7.7",
|
|
||||||
"CISA.MS.AAD.7.9"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "Require write permission for creation of new management groups",
|
|
||||||
"findingIds": [
|
|
||||||
"MT.1064"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "Start NTLM auditing before any deny policy is introduced",
|
|
||||||
"findingIds": [
|
|
||||||
"2.3.11.11",
|
|
||||||
"2.3.11.12",
|
|
||||||
"2.3.11.13"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"questions": [
|
|
||||||
"Were the AdminSDHolder permission changes authorized, and is there a corresponding approved change record?",
|
|
||||||
"What evidence supports or contradicts active exploitation of the Zerologon and AD CS findings?",
|
|
||||||
"Which certificate templates and web-enrollment services are business-critical, and what existing mitigations are documented?",
|
|
||||||
"Which privileged Entra assignments must remain permanent because of emergency-access or non-interactive service requirements?",
|
|
||||||
"Are Entra ID P1 or P2 and Privileged Identity Management capabilities licensed and operationally available?",
|
|
||||||
"Are any Conditional Access policies currently in report-only mode or excluded from the scanner view?",
|
|
||||||
"Are Entra security logs already forwarded to another monitored platform through a path the assessment could not observe?",
|
|
||||||
"Which external collaboration and SharePoint sharing scenarios are formally approved by the business?",
|
|
||||||
"Which applications depend on client secrets or privileged service-principal roles, and what rotation constraints apply?",
|
|
||||||
"What caused the not-run and execution-error checks: permissions, licensing, connectivity, missing telemetry, or scanner limitations?"
|
|
||||||
],
|
|
||||||
"assumptions": [
|
|
||||||
"The summary object is treated as authoritative for all assessment counts.",
|
|
||||||
"Failed results and deviation flags are treated as confirmed configuration deviations, not proof of successful exploitation.",
|
|
||||||
"Indicators of exposure are prioritized above comparable non-IoE deviations.",
|
|
||||||
"Warnings require investigation and are not treated as confirmed compromise.",
|
|
||||||
"Not-run checks and execution errors are treated only as coverage gaps.",
|
|
||||||
"Environment facts are used only for context and are not promoted into findings.",
|
|
||||||
"No missing description or recommendation is inferred beyond the evidence provided."
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
@ -0,0 +1,61 @@
|
||||||
|
// @vitest-environment jsdom
|
||||||
|
|
||||||
|
import { act, type ReactNode } from 'react';
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import { afterEach, expect, it, vi } from 'vitest';
|
||||||
|
import { AssessorApiClient } from '../../src/api/assessor-api-client';
|
||||||
|
import type { AiOverviewResponse } from '../../src/domain/ai-overview';
|
||||||
|
import { useAiOverview } from '../../src/features/dashboard/AiOverviewPanel';
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
function deferred<T>() {
|
||||||
|
let resolve!: (value: T) => void;
|
||||||
|
const promise = new Promise<T>(next => { resolve = next; });
|
||||||
|
return { promise, resolve };
|
||||||
|
}
|
||||||
|
|
||||||
|
function response(projectId: string): AiOverviewResponse {
|
||||||
|
return {
|
||||||
|
version: 1,
|
||||||
|
projectId,
|
||||||
|
scope: { assessmentIds: [`assessment-${projectId}`], scanRunIds: [] },
|
||||||
|
generationEnabled: true,
|
||||||
|
state: 'empty',
|
||||||
|
stale: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function HookProbe({ projectId }: { projectId: string }): ReactNode {
|
||||||
|
const controller = useAiOverview(projectId, [`assessment-${projectId}`]);
|
||||||
|
return <output>{controller.response?.projectId ?? 'empty'}</output>;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('does not show a previous project response when it resolves after the active project', async () => {
|
||||||
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||||
|
const first = deferred<AiOverviewResponse>();
|
||||||
|
const second = deferred<AiOverviewResponse>();
|
||||||
|
const request = vi.spyOn(AssessorApiClient.prototype, 'getAiOverview').mockImplementation(projectId =>
|
||||||
|
projectId === 'project-a' ? first.promise : second.promise);
|
||||||
|
const container = document.createElement('div');
|
||||||
|
document.body.append(container);
|
||||||
|
const root = createRoot(container);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await act(async () => root.render(<HookProbe projectId="project-a" />));
|
||||||
|
await act(async () => root.render(<HookProbe projectId="project-b" />));
|
||||||
|
expect(request).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
|
await act(async () => { second.resolve(response('project-b')); await Promise.resolve(); });
|
||||||
|
expect(container.textContent).toBe('project-b');
|
||||||
|
|
||||||
|
await act(async () => { first.resolve(response('project-a')); await Promise.resolve(); });
|
||||||
|
expect(container.textContent).toBe('project-b');
|
||||||
|
} finally {
|
||||||
|
await act(async () => root.unmount());
|
||||||
|
container.remove();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
@ -57,9 +57,14 @@ describe('Defender vulnerability dashboard projection', () => {
|
||||||
expect(dashboard).toContain('scopedRiskAcceptances={app.scopedRiskAcceptances} activeScanRunIds={app.activeScanRunIds}');
|
expect(dashboard).toContain('scopedRiskAcceptances={app.scopedRiskAcceptances} activeScanRunIds={app.activeScanRunIds}');
|
||||||
expect(dashboard).toContain('onScopedRiskAcceptance={app.backendStatus === \'connected\' ? write => app.recordScopedRiskAcceptance(write) : undefined}');
|
expect(dashboard).toContain('onScopedRiskAcceptance={app.backendStatus === \'connected\' ? write => app.recordScopedRiskAcceptance(write) : undefined}');
|
||||||
expect(styles).toContain('padding: var(--panel-inset)');
|
expect(styles).toContain('padding: var(--panel-inset)');
|
||||||
expect(styles).toContain('grid-template-columns: repeat(6, minmax(0, 1fr))');
|
expect(styles).toContain('grid-template-columns: repeat(auto-fill, var(--tile-compact-width))');
|
||||||
expect(styles).toContain('background: var(--tile-surface)');
|
expect(styles).toContain('background: var(--tile-surface)');
|
||||||
|
expect(styles).toMatch(/\.defender-metrics article \{[^}]*border-radius: var\(--tile-radius\)/);
|
||||||
|
expect(styles).toMatch(/\.defender-metrics span \{[^}]*font-size: var\(--type-table-body-size\)/);
|
||||||
|
expect(styles).toMatch(/\.defender-table th \{[^}]*font-size: var\(--type-table-body-size\)/);
|
||||||
|
expect(styles).toContain('box-shadow: inset var(--marker-width) 0 var(--marking-color)');
|
||||||
expect(styles).toMatch(/\.defender-drilldown \{[^}]*background: var\(--tile-surface\)/);
|
expect(styles).toMatch(/\.defender-drilldown \{[^}]*background: var\(--tile-surface\)/);
|
||||||
|
expect(styles).toMatch(/\.defender-drilldown \{[^}]*border-radius: var\(--tile-radius\)/);
|
||||||
expect(styles).not.toMatch(/\.defender-drilldown \{[^}]*background: var\(--client-info-surface\)/);
|
expect(styles).not.toMatch(/\.defender-drilldown \{[^}]*background: var\(--client-info-surface\)/);
|
||||||
expect(styles).toContain('outline: var(--bw-emphasis) solid var(--marking-color)');
|
expect(styles).toContain('outline: var(--bw-emphasis) solid var(--marking-color)');
|
||||||
expect(styles).toContain('outline: var(--bw-emphasis) solid var(--focus-ring)');
|
expect(styles).toContain('outline: var(--bw-emphasis) solid var(--focus-ring)');
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,67 @@
|
||||||
|
// @vitest-environment jsdom
|
||||||
|
|
||||||
|
import { act, type ReactNode } from 'react';
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import { afterEach, expect, it, vi } from 'vitest';
|
||||||
|
import { AssessorApiClient } from '../../src/api/assessor-api-client';
|
||||||
|
import type { RegulatoryMappingResponse } from '../../src/domain/regulatory-mapping';
|
||||||
|
import { useRegulatoryMapping } from '../../src/features/finding-reader/use-regulatory-mapping';
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
function deferred<T>() {
|
||||||
|
let resolve!: (value: T) => void;
|
||||||
|
const promise = new Promise<T>(next => { resolve = next; });
|
||||||
|
return { promise, resolve };
|
||||||
|
}
|
||||||
|
|
||||||
|
function response(projectId: string, occurrenceId: string): RegulatoryMappingResponse {
|
||||||
|
return {
|
||||||
|
version: 1,
|
||||||
|
projectId,
|
||||||
|
resultProjectionId: `scanner-finding:${occurrenceId}`,
|
||||||
|
generationEnabled: true,
|
||||||
|
state: 'empty',
|
||||||
|
decisions: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function HookProbe({ occurrenceId }: { occurrenceId: string }): ReactNode {
|
||||||
|
const controller = useRegulatoryMapping('project-1', occurrenceId, 'connected');
|
||||||
|
return <>
|
||||||
|
<button type="button" onClick={() => controller.setOpen(true)}>Open mapping</button>
|
||||||
|
<output>{controller.response?.resultProjectionId ?? 'empty'}</output>
|
||||||
|
</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('does not show a previous finding mapping when it resolves after the active finding', async () => {
|
||||||
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||||
|
const first = deferred<RegulatoryMappingResponse>();
|
||||||
|
const second = deferred<RegulatoryMappingResponse>();
|
||||||
|
const request = vi.spyOn(AssessorApiClient.prototype, 'getRegulatoryMappings').mockImplementation((_projectId, resultProjectionId) =>
|
||||||
|
resultProjectionId === 'scanner-finding:occurrence-a' ? first.promise : second.promise);
|
||||||
|
const container = document.createElement('div');
|
||||||
|
document.body.append(container);
|
||||||
|
const root = createRoot(container);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await act(async () => root.render(<HookProbe occurrenceId="occurrence-a" />));
|
||||||
|
await act(async () => container.querySelector<HTMLButtonElement>('button')!.click());
|
||||||
|
await act(async () => root.render(<HookProbe occurrenceId="occurrence-b" />));
|
||||||
|
await act(async () => container.querySelector<HTMLButtonElement>('button')!.click());
|
||||||
|
expect(request).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
|
await act(async () => { second.resolve(response('project-1', 'occurrence-b')); await Promise.resolve(); });
|
||||||
|
expect(container.textContent).toContain('scanner-finding:occurrence-b');
|
||||||
|
|
||||||
|
await act(async () => { first.resolve(response('project-1', 'occurrence-a')); await Promise.resolve(); });
|
||||||
|
expect(container.textContent).toContain('scanner-finding:occurrence-b');
|
||||||
|
expect(container.textContent).not.toContain('scanner-finding:occurrence-a');
|
||||||
|
} finally {
|
||||||
|
await act(async () => root.unmount());
|
||||||
|
container.remove();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { RESULT_WINDOW_COLORS } from '../../src/features/linked-results/result-window-colors';
|
||||||
|
|
||||||
|
const tokens = readFileSync(new URL('../../src/styles/ui-tokens.css', import.meta.url), 'utf8');
|
||||||
|
const datasetViewer = readFileSync(new URL('../../src/features/dataset-viewer/DatasetViewer.tsx', import.meta.url), 'utf8');
|
||||||
|
const mitreGraph = readFileSync(new URL('../../src/features/mitre-graph/MitreGraphView.tsx', import.meta.url), 'utf8');
|
||||||
|
const obsidianGraph = readFileSync(new URL('../../src/features/mitre-graph/ObsidianGraphView.tsx', import.meta.url), 'utf8');
|
||||||
|
|
||||||
|
describe('Result window affiliation colors', () => {
|
||||||
|
it('uses one shared token palette in Work and both graph views', () => {
|
||||||
|
expect(RESULT_WINDOW_COLORS).toHaveLength(16);
|
||||||
|
RESULT_WINDOW_COLORS.forEach((color, index) => {
|
||||||
|
expect(color).toBe(`var(--result-window-color-${index + 1})`);
|
||||||
|
expect(tokens.match(new RegExp(`--result-window-color-${index + 1}:`, 'g'))).toHaveLength(2);
|
||||||
|
});
|
||||||
|
expect(datasetViewer).toContain('RESULT_WINDOW_COLORS');
|
||||||
|
expect(mitreGraph).toContain('RESULT_WINDOW_COLORS');
|
||||||
|
expect(obsidianGraph).toContain('RESULT_WINDOW_COLORS');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps component code free of raw colors and the removed path gradient', () => {
|
||||||
|
for (const source of [datasetViewer, mitreGraph, obsidianGraph]) {
|
||||||
|
expect(source).not.toMatch(/#[0-9a-f]{3,8}\b/i);
|
||||||
|
}
|
||||||
|
expect(mitreGraph).not.toContain('linearGradient');
|
||||||
|
expect(mitreGraph).toContain('stroke="var(--graph-edge)"');
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue