1715 lines
102 KiB
TypeScript
1715 lines
102 KiB
TypeScript
import { readEnginePreferences, useEnginePreferences } from '../../app/engine-preferences';
|
||
import { ClientContextHeatmap } from '../context-layer/ContextHeatmap';
|
||
import { ClientIsoControls } from '../../compliance/ui/ClientIsoControls';
|
||
import { ISO_CONTROL_FACET_ID, ISO_THEME_FACET_ID } from '../../compliance/ui/compliance-work-facets';
|
||
import { CONTEXT_CLUSTER_FACET_ID } from '../context-layer/context-cluster-facet';
|
||
import { useAssessmentContext } from '../context-layer/use-assessment-context';
|
||
import { publishContextReport } from '../../assessment-context/context-report-publication';
|
||
import { managementFindingDistribution, managementWorkspaceDeviationKeys } from '../../reporting/management-business-impact';
|
||
import { businessImpactControls, BUSINESS_IMPACT_FACET_ID } from '../search-filter/business-impact-controls';
|
||
import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from 'react';
|
||
import { AssessorApiClient } from '../../api/assessor-api-client';
|
||
import { hasSelectedTimeslotScope } from '../../app/work-scope';
|
||
import { downloadText } from '../../exports/text-download';
|
||
import { browserDownloadAdapter } from '../../platform/browser/browser-download-adapter';
|
||
import { OperatingSystemInventory } from './OperatingSystemInventory';
|
||
import { EnvironmentResultsButton } from './EnvironmentResultsButton';
|
||
import { DashboardFactList, DashboardGlyph, type DashboardFact, type DashboardGlyphName } from './ClientTileParts';
|
||
export type { DashboardFact } from './ClientTileParts';
|
||
import { CloudInformationTile } from './CloudInformationTile';
|
||
import { EnvironmentEvidenceTables } from './EnvironmentEvidenceTables';
|
||
import { environmentEvidenceForGroup, type EnvironmentEvidenceGroup } from './environment-result-evidence';
|
||
import type { GpoBackupStatusRecord } from '../../api/assessor-api-contract';
|
||
import { operatingSystemInventory, type OperatingSystemInventoryGroup } from './operating-system-inventory';
|
||
import { useApp } from '../../app/AppProvider';
|
||
import { EmptyWorkspace } from '../../components/EmptyWorkspace/EmptyWorkspace';
|
||
import { DonutMetricCard } from '../../components/DonutMetricCard/DonutMetricCard';
|
||
import { DonutSmallArcMarker, donutArcPath, donutPercentageLabel } from '../../components/DonutMetricCard/donut-arc';
|
||
import { PanelLoadingState } from '../../components/PanelLoadingState/PanelLoadingState';
|
||
import { TriangleIcon } from '../../components/TriangleIcon';
|
||
import type { ImportedDataset } from '../../domain/dataset';
|
||
import type { EnvironmentObservation, EnvironmentSnapshot } from '../../domain/environment-context';
|
||
import type { FindingRecord } from '../../domain/finding';
|
||
import type { ImportProfile } from '../../domain/import-profile';
|
||
import type { Review } from '../../domain/review';
|
||
import { weightedSecurityScore, type SecurityScoreContext, type SecuritySourceScore } from '../../domain/security-scoring';
|
||
import { dashboardSecuritySources } from './dashboard-security-score';
|
||
import { SECURITY_DOMAIN_FACET_ID, dashboardDomainAssignment, securityDomainCatalog } from './security-domains';
|
||
import { ScoringMethodology } from './ScoringMethodology';
|
||
import { RiskRegister } from './RiskRegister';
|
||
import { deriveDynamicFacets, matchesDynamicFacets, recountFacetsAgainstActiveFilters, sourceScanNavigationSheet, type DynamicFacet, type DynamicFacetFilter, type DynamicFacetValue } from '../search-filter/dynamic-facets';
|
||
import { WorkTimeslotControl } from '../search-filter/WorkTimeslotControl';
|
||
import { dashboardResultState, dashboardScoreFromStates, type DashboardResultState } from './dashboard-result-state';
|
||
|
||
export { dashboardResultState } from './dashboard-result-state';
|
||
import { AiOverviewPanel, useAiOverview } from '../../compliance/ui/AiOverviewPanel';
|
||
import { DEFENDER_FOR_IOT_PROFILE_ID } from '../../domain/defender-for-iot';
|
||
import { DefenderVulnerabilityPanel } from '../defender-for-iot/DefenderVulnerabilityPanel';
|
||
import { defenderModelForDataset } from '../defender-for-iot/defender-dataset-model';
|
||
|
||
const assessorApi = new AssessorApiClient();
|
||
export { dashboardDomainAssignment, dashboardDomainRecords } from './security-domains';
|
||
|
||
interface DashboardPanelProps {
|
||
onOpenWork(): void;
|
||
}
|
||
|
||
function shareStyle(value: number): CSSProperties {
|
||
return { '--share': `${value}%` } as unknown as CSSProperties;
|
||
}
|
||
|
||
function share(count: number, total: number): number {
|
||
return total > 0 ? (count / total) * 100 : 0;
|
||
}
|
||
|
||
export function dashboardExplicitIoeCount(records: FindingRecord[]): number {
|
||
return dashboardExplicitIoeRecordIds(records).length;
|
||
}
|
||
|
||
export function dashboardExplicitIoeRecordIds(records: FindingRecord[]): string[] {
|
||
return records
|
||
.filter(record => record.fields.some(field => /^ioes?\s+found$/i.test(field.value.trim())))
|
||
.map(record => record.id);
|
||
}
|
||
|
||
interface DashboardStatusMetricProps {
|
||
label: string;
|
||
value: number;
|
||
percentage: number;
|
||
tone: 'all' | 'deviation' | 'passed' | 'not-run';
|
||
large?: boolean;
|
||
helper?: string;
|
||
active?: boolean;
|
||
onClick?(): void;
|
||
}
|
||
|
||
function DashboardStatusMetric({ label, value, percentage, tone, large = false, helper, active, onClick }: DashboardStatusMetricProps) {
|
||
const percentageLabel = donutPercentageLabel(percentage);
|
||
const content = <>
|
||
<div className={`dashboard-status-ring dashboard-status-ring-${tone}`}>
|
||
<svg viewBox="0 0 120 120" aria-hidden="true">
|
||
<circle className="dashboard-status-ring-track" cx="60" cy="60" r="48" pathLength="100" />
|
||
{percentage > 0 && <path className="dashboard-status-ring-progress" d={donutArcPath(percentage)} />}
|
||
<DonutSmallArcMarker percentage={percentage}/>
|
||
</svg>
|
||
<span><span className={`dashboard-donut-primary${value.toLocaleString('en-US').length >= 5 ? ' is-compact' : ''}`}><strong>{value.toLocaleString('en-US')}</strong><small>{percentageLabel}</small></span></span>
|
||
</div>
|
||
<div className="dashboard-status-copy">
|
||
<span>{label}</span>
|
||
{helper && <small>{helper}</small>}
|
||
</div>
|
||
</>;
|
||
const className = `dashboard-status-metric${large ? ' dashboard-status-metric-large' : ''}`;
|
||
return onClick
|
||
? <button className={className} type="button" aria-label={`${label}: ${value.toLocaleString('en-US')}, ${percentageLabel}`} aria-pressed={Boolean(active)} onClick={onClick}>{content}</button>
|
||
: <article className={className}>{content}</article>;
|
||
}
|
||
|
||
interface SegmentedDistributionProps {
|
||
heading: string;
|
||
total: number;
|
||
values: DynamicFacetValue[];
|
||
severity?: boolean;
|
||
formatLabel?(label: string): string;
|
||
onSelect(value: string): void;
|
||
}
|
||
|
||
export function dashboardScanSourceLabel(label: string): string {
|
||
const parts = label.split(/\s+·\s+/);
|
||
const timestamp = parts.at(-1) ?? '';
|
||
if (parts.length < 2 || !/^\d{4}-\d{2}-\d{2}(?:T.*)?$/.test(timestamp) || Number.isNaN(Date.parse(timestamp))) return label;
|
||
return parts.slice(0, -1).join(' · ');
|
||
}
|
||
|
||
export interface DashboardRiskTool {
|
||
id: string;
|
||
label: string;
|
||
sourceValues: string[];
|
||
count: number;
|
||
}
|
||
|
||
const dashboardCisCatSeverityValue = 'dashboard-cis-cat-without-severity';
|
||
|
||
export interface DashboardCisCatSeverityRemainder extends DynamicFacetValue {
|
||
sourceValues: string[];
|
||
}
|
||
|
||
export function dashboardRiskTools(values: DynamicFacetValue[]): DashboardRiskTool[] {
|
||
const tools = new Map<string, DashboardRiskTool>();
|
||
values.forEach(value => {
|
||
const label = dashboardScanSourceLabel(value.label);
|
||
const id = label.toLocaleLowerCase('en-US');
|
||
const existing = tools.get(id);
|
||
if (existing) {
|
||
existing.sourceValues.push(value.value);
|
||
existing.count += value.count;
|
||
} else tools.set(id, { id, label, sourceValues: [value.value], count: value.count });
|
||
});
|
||
return [...tools.values()];
|
||
}
|
||
|
||
export function dashboardCisCatSeverityRemainder(
|
||
sourceValues: DynamicFacetValue[],
|
||
cisCatSeverityValues: DynamicFacetValue[]
|
||
): DashboardCisCatSeverityRemainder | undefined {
|
||
const tool = dashboardRiskTools(sourceValues)
|
||
.find(item => /^cis-cat(?:\s+pro)?$/i.test(item.label.trim()));
|
||
if (!tool) return undefined;
|
||
const classified = cisCatSeverityValues.reduce((sum, value) => sum + value.count, 0);
|
||
const count = Math.max(0, tool.count - classified);
|
||
return count > 0 ? {
|
||
value: dashboardCisCatSeverityValue,
|
||
label: 'CIS-CAT',
|
||
count,
|
||
sourceValues: tool.sourceValues
|
||
} : undefined;
|
||
}
|
||
|
||
/** Compact distribution based on the supplied dashboard reference. */
|
||
function SegmentedDistribution({ heading, total, values, severity = false, formatLabel = label => label, onSelect }: SegmentedDistributionProps) {
|
||
return (
|
||
<section className="dashboard-block dashboard-distribution">
|
||
<h2>{heading}</h2>
|
||
{values.length === 0 ? (
|
||
<p className="dashboard-empty">This scan report carries no such classification.</p>
|
||
) : (
|
||
<>
|
||
<div className="dashboard-segmented-bar" aria-hidden="true">
|
||
{values.map((value, index) => (
|
||
<span
|
||
className={`dashboard-segment ${severity ? `severity-${value.value}` : `dashboard-viz-${index % 6 + 1}`}`}
|
||
key={value.value}
|
||
style={shareStyle(share(value.count, total))}
|
||
/>
|
||
))}
|
||
</div>
|
||
<ul className="dashboard-distribution-legend">
|
||
{values.map((value, index) => (
|
||
<li key={value.value}>
|
||
<button
|
||
className="dashboard-legend-button"
|
||
type="button"
|
||
onClick={() => onSelect(value.value)}
|
||
title={`Open in Work, filtered by ${value.label}`}
|
||
>
|
||
<span className={`dashboard-legend-swatch ${severity ? `severity-${value.value}` : `dashboard-viz-${index % 6 + 1}`}`} aria-hidden="true" />
|
||
<span className="dashboard-legend-copy">
|
||
<span>{formatLabel(value.label)}</span>
|
||
<strong>{value.count.toLocaleString('en-US')} <small>· {share(value.count, total).toFixed(0)}%</small></strong>
|
||
</span>
|
||
</button>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</>
|
||
)}
|
||
</section>
|
||
);
|
||
}
|
||
|
||
const overviewProperties: Array<[string, string]> = [
|
||
['domain.fqdn', 'Domain'],
|
||
['domain.functional_level', 'Domain functional level'],
|
||
['forest.functional_level', 'Forest functional level'],
|
||
['forest.schema_version', 'Schema version'],
|
||
['domain.controller_count', 'Domain controllers'],
|
||
['domain.recycle_bin_enabled', 'AD Recycle Bin'],
|
||
['backup.last_detected_at', 'Last AD backup'],
|
||
['laps.legacy.installation_date', 'Legacy LAPS'],
|
||
['laps.windows.installation_date', 'Windows LAPS'],
|
||
['logging.wef.configuration_count', 'WEF configurations'],
|
||
['kerberos.krbtgt.password_last_changed', 'krbtgt password changed'],
|
||
['report.engine_version', 'PingCastle engine']
|
||
];
|
||
|
||
function displayValue(observation: EnvironmentObservation): string {
|
||
if (observation.state === 'explicitly-absent') return observation.originalValue === false ? 'Not detected' : 'Not present';
|
||
const value = observation.normalizedValue ?? observation.originalValue;
|
||
if (typeof value === 'boolean') return value ? 'Enabled' : 'Disabled';
|
||
if (value === null || value === '') return 'No value';
|
||
return String(value);
|
||
}
|
||
|
||
function useEnvironmentSnapshot(projectId?: string, scanRunIds?: string[]) {
|
||
const [snapshot, setSnapshot] = useState<EnvironmentSnapshot>();
|
||
const [failed, setFailed] = useState(false);
|
||
|
||
useEffect(() => {
|
||
let active = true;
|
||
setSnapshot(undefined);
|
||
setFailed(false);
|
||
if (!projectId) return () => { active = false; };
|
||
assessorApi.getEnvironmentSnapshot({ projectId, scanRunIds })
|
||
.then(value => { if (active) setSnapshot(value); })
|
||
.catch(() => { if (active) setFailed(true); });
|
||
return () => { active = false; };
|
||
}, [projectId, scanRunIds?.join('|')]);
|
||
|
||
return { snapshot, failed };
|
||
}
|
||
|
||
function EnvironmentOverview({ projectId, snapshot, failed }: { projectId?: string; snapshot?: EnvironmentSnapshot; failed: boolean }) {
|
||
|
||
const observations = useMemo(() => {
|
||
const byProperty = new Map<string, EnvironmentObservation>();
|
||
snapshot?.observations.forEach(observation => byProperty.set(observation.property, observation));
|
||
return overviewProperties
|
||
.map(([property, label]) => ({ label, observation: byProperty.get(property) }))
|
||
.filter((item): item is { label: string; observation: EnvironmentObservation } => Boolean(item.observation));
|
||
}, [snapshot]);
|
||
const operatingSystems = useMemo(() => [...new Set(snapshot?.observations
|
||
.filter(item => item.property === 'operating_system.name')
|
||
.map(displayValue) ?? [])], [snapshot]);
|
||
if (!projectId) return null;
|
||
return (
|
||
<section className="dashboard-environment" aria-labelledby="environment-overview-heading">
|
||
<div className="datahub-section-heading">
|
||
<div><h2 id="environment-overview-heading">Directory overview</h2></div>
|
||
{snapshot && <strong>{snapshot.sections.length.toLocaleString('en-US')} sections</strong>}
|
||
</div>
|
||
{!snapshot && !failed && <p className="dashboard-empty">Loading Client Information…</p>}
|
||
{failed && <p className="dashboard-empty">Client Information could not be loaded.</p>}
|
||
{snapshot && snapshot.sections.length === 0 && <p className="dashboard-empty">This import predates structured scanner-context capture.</p>}
|
||
{snapshot && (
|
||
<div className="dashboard-coverage-summary" aria-label="Directory coverage summary">
|
||
<span><strong>{snapshot.observations.length.toLocaleString('en-US')}</strong><small>typed observations</small></span>
|
||
<span><strong>{(snapshot.coverage.empty + snapshot.coverage.explicitlyAbsent).toLocaleString('en-US')}</strong><small>empty or absent</small></span>
|
||
<span><strong>{snapshot.coverage.restricted.toLocaleString('en-US')}</strong><small>restricted</small></span>
|
||
<span><strong>{snapshot.coverage.incomplete.toLocaleString('en-US')}</strong><small>incomplete</small></span>
|
||
</div>
|
||
)}
|
||
{snapshot && (observations.length > 0 || operatingSystems.length > 0) && (
|
||
<dl className="dashboard-facts">
|
||
{observations.map(({ label, observation }) => (
|
||
<div key={observation.property}><dt>{label}</dt><dd>{displayValue(observation)}</dd></div>
|
||
))}
|
||
{operatingSystems.length > 0 && <div><dt>OS</dt><dd>{operatingSystems.join(', ')}</dd></div>}
|
||
</dl>
|
||
)}
|
||
</section>
|
||
);
|
||
}
|
||
|
||
interface RiskProfileProps {
|
||
sourceFacet?: DynamicFacet;
|
||
categoryFacet?: DynamicFacet;
|
||
severityFacet?: DynamicFacet;
|
||
records: FindingRecord[];
|
||
profile: ImportProfile;
|
||
reviews: Record<string, Review>;
|
||
environmentSnapshot?: EnvironmentSnapshot;
|
||
onSelectTool(sourceValues: string[]): void;
|
||
onSelectCategory(value: string, sourceValues: string[]): void;
|
||
onSelectSeverity(value: string, sourceValues: string[]): void;
|
||
}
|
||
|
||
type RiskScoreObservation = Pick<EnvironmentObservation, 'property' | 'normalizedValue' | 'originalValue'>;
|
||
|
||
const pingCastleRiskCategoryCatalog = [
|
||
['risk.anomalies', 'Anomalies'],
|
||
['risk.stale_objects', 'Stale Objects'],
|
||
['risk.privileged_accounts', 'Privileged Accounts'],
|
||
['risk.trusts', 'Trusts']
|
||
] as const;
|
||
|
||
export function dashboardPingCastleRiskCategories(observations: RiskScoreObservation[], fallback: DynamicFacetValue[]): DynamicFacetValue[] {
|
||
return pingCastleRiskCategoryCatalog.map(([property, label]) => {
|
||
const observation = observations.find(item => item.property === property);
|
||
const sourceValue = observation?.normalizedValue ?? observation?.originalValue;
|
||
const numericValue = typeof sourceValue === 'number' ? sourceValue : Number(sourceValue);
|
||
const fallbackValue = fallback.find(value => value.label.toLocaleLowerCase('en-US') === label.toLocaleLowerCase('en-US'));
|
||
return {
|
||
value: fallbackValue?.value ?? label.toLocaleLowerCase('en-US'),
|
||
label,
|
||
count: Number.isFinite(numericValue) ? numericValue : fallbackValue?.count ?? 0
|
||
};
|
||
});
|
||
}
|
||
|
||
const fullRiskProfileId = 'full-risk-profile';
|
||
|
||
function RiskProfile({ sourceFacet, categoryFacet, severityFacet, records, profile, reviews, environmentSnapshot, onSelectTool, onSelectCategory, onSelectSeverity }: RiskProfileProps) {
|
||
const tools = useMemo(() => dashboardRiskTools(sourceFacet?.values ?? []), [sourceFacet]);
|
||
const [selectedToolId, setSelectedToolId] = useState('');
|
||
const selectedTool = tools.find(tool => tool.id === selectedToolId)
|
||
?? tools.find(tool => tool.id.includes('pingcastle'))
|
||
?? tools[0];
|
||
const fullProfile = selectedToolId === fullRiskProfileId;
|
||
const effectiveProfileId = fullProfile ? fullRiskProfileId : selectedTool?.id ?? fullRiskProfileId;
|
||
const scopedRecords = useMemo(() => fullProfile
|
||
? records
|
||
: selectedTool && sourceFacet
|
||
? records.filter(record => matchesDynamicFacets(record, [sourceFacet], { [sourceFacet.id]: { values: selectedTool.sourceValues } }, reviews))
|
||
: [], [fullProfile, records, reviews, selectedTool, sourceFacet]);
|
||
const scopedFacets = useMemo(() => recountFacetsAgainstActiveFilters({
|
||
facets: [categoryFacet, severityFacet].filter((facet): facet is DynamicFacet => Boolean(facet)),
|
||
records: scopedRecords,
|
||
filters: {},
|
||
reviews,
|
||
statusFilter: 'all',
|
||
matchesStatus: (record, status) => profile.matchesStatus(record, status)
|
||
}), [categoryFacet, profile, reviews, scopedRecords, severityFacet]);
|
||
const scopedCategories = scopedFacets.find(facet => facet.id === categoryFacet?.id)?.values ?? [];
|
||
const categories = fullProfile
|
||
? tools.map(tool => ({ value: tool.id, label: tool.label, count: tool.count }))
|
||
: selectedTool?.id.includes('pingcastle')
|
||
? dashboardPingCastleRiskCategories(
|
||
environmentSnapshot?.observations.filter(observation => observation.tool.id === 'pingcastle') ?? [],
|
||
scopedCategories
|
||
)
|
||
: [...scopedCategories].sort((left, right) => right.count - left.count || left.label.localeCompare(right.label));
|
||
const severities = scopedFacets.find(facet => facet.id === severityFacet?.id)?.values ?? [];
|
||
const deviations = scopedRecords.filter(record => profile.matchesStatus(record, 'deviation')).length;
|
||
const marked = scopedRecords.filter(record => reviews[record.id]?.include).length;
|
||
const severityTotal = severities.reduce((sum, value) => sum + value.count, 0);
|
||
let severityOffset = 0;
|
||
return (
|
||
<section className="dashboard-block dashboard-risk-profile">
|
||
<header className="dashboard-risk-header">
|
||
{tools.length > 0 ? <select className="dashboard-risk-tool" aria-label="Risk profile tool" value={effectiveProfileId} onChange={event => setSelectedToolId(event.target.value)}>
|
||
<option value={fullRiskProfileId}>Full Risk Profile</option>
|
||
{tools.map(tool => <option value={tool.id} key={tool.id}>{tool.label} Risk Profile</option>)}
|
||
</select> : <h2>Risk profile</h2>}
|
||
<div className="dashboard-marked-summary"><span>Marked for report</span><strong>{marked.toLocaleString('en-US')} / {deviations.toLocaleString('en-US')}</strong></div>
|
||
</header>
|
||
<div className="dashboard-risk-body">
|
||
{categories.length > 0 ? (
|
||
<ul className="dashboard-risk-categories">
|
||
{categories.slice(0, 4).map(category => (
|
||
<li key={category.value}>
|
||
<button type="button" onClick={() => {
|
||
if (fullProfile) {
|
||
const tool = tools.find(item => item.id === category.value);
|
||
if (tool) onSelectTool(tool.sourceValues);
|
||
} else if (selectedTool) onSelectCategory(category.value, selectedTool.sourceValues);
|
||
}} title={`Open in Work, filtered by ${category.label}`}>
|
||
<span>{category.label}</span><strong>{category.count.toLocaleString('en-US')}</strong>
|
||
</button>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
) : <p className="dashboard-empty">No risk categories available.</p>}
|
||
<div className="dashboard-risk-severity">
|
||
{severities.length > 0 ? <>
|
||
<div className="dashboard-risk-donut" role="img" aria-label="Risk severity distribution: 100%">
|
||
<svg viewBox="0 0 120 120" aria-hidden="true">
|
||
<circle className="dashboard-risk-donut-track" cx="60" cy="60" r="48" pathLength="100" />
|
||
{severities.map(severity => {
|
||
const percentage = share(severity.count, severityTotal);
|
||
const offset = severityOffset;
|
||
severityOffset += percentage;
|
||
return <g key={severity.value}><path
|
||
className={`dashboard-risk-donut-segment severity-${severity.value}`}
|
||
d={donutArcPath(percentage, offset)}
|
||
/><DonutSmallArcMarker percentage={percentage} offset={offset} className={`dashboard-risk-donut-marker severity-${severity.value}`}/></g>;
|
||
})}
|
||
</svg>
|
||
<span><span className="dashboard-donut-primary"><strong>{severityTotal.toLocaleString('en-US')}</strong><small>100%</small></span></span>
|
||
</div>
|
||
<ul className="dashboard-risk-legend">
|
||
{severities.map(severity => (
|
||
<li key={severity.value}>
|
||
<button type="button" onClick={() => fullProfile
|
||
? onSelectSeverity(severity.value, [])
|
||
: selectedTool && onSelectSeverity(severity.value, selectedTool.sourceValues)} title={`Open in Work, filtered by ${severity.label}`}>
|
||
<i className={`severity-${severity.value}`} aria-hidden="true" />
|
||
<span>{severity.label}</span>
|
||
<strong>{severity.count.toLocaleString('en-US')}</strong>
|
||
</button>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</> : <p className="dashboard-empty">No severity distribution available.</p>}
|
||
</div>
|
||
</div>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
interface DashboardEnvironmentMetric {
|
||
label: string;
|
||
value: number;
|
||
detail?: string;
|
||
restricted?: boolean;
|
||
tone?: string;
|
||
onClick?(): void;
|
||
}
|
||
|
||
interface DashboardEnvironmentSummary {
|
||
directoryFacts: Array<{ label: string; value: string }>;
|
||
coverage: DashboardEnvironmentMetric[];
|
||
identity: DashboardEnvironmentMetric[];
|
||
controlPaths: DashboardEnvironmentMetric[];
|
||
certificates: DashboardEnvironmentMetric[];
|
||
gpo: DashboardEnvironmentMetric[];
|
||
risk: DashboardEnvironmentMetric[];
|
||
resilienceFacts: Array<{ label: string; value: string }>;
|
||
}
|
||
|
||
function observationFor(snapshot: EnvironmentSnapshot | undefined, property: string, label?: string, sectionKey?: string) {
|
||
return snapshot?.observations.find(observation => observation.property === property
|
||
|| Boolean(label && observation.label === label && (!sectionKey || observation.sectionKey === sectionKey)));
|
||
}
|
||
|
||
function observationNumber(snapshot: EnvironmentSnapshot | undefined, property: string, label?: string, sectionKey?: string): number {
|
||
const observation = observationFor(snapshot, property, label, sectionKey);
|
||
const value = observation?.normalizedValue ?? observation?.originalValue;
|
||
const numeric = typeof value === 'number' ? value : Number(value);
|
||
return Number.isFinite(numeric) ? numeric : 0;
|
||
}
|
||
|
||
function observationText(snapshot: EnvironmentSnapshot | undefined, property: string, label?: string): string {
|
||
const observation = observationFor(snapshot, property, label);
|
||
return observation ? displayValue(observation) : 'No data';
|
||
}
|
||
|
||
function sectionObservationCount(snapshot: EnvironmentSnapshot | undefined, sectionKey: string): number {
|
||
return snapshot?.observations.filter(observation => observation.sectionKey === sectionKey).length ?? 0;
|
||
}
|
||
|
||
function sectionFor(snapshot: EnvironmentSnapshot | undefined, sectionKey: string) {
|
||
return snapshot?.sections.find(section => section.sectionKey === sectionKey);
|
||
}
|
||
|
||
function sectionTableCount(snapshot: EnvironmentSnapshot | undefined, sectionKey: string): number {
|
||
return sectionFor(snapshot, sectionKey)?.tables.length ?? 0;
|
||
}
|
||
|
||
function sectionRowCount(snapshot: EnvironmentSnapshot | undefined, sectionKey: string): number {
|
||
return sectionFor(snapshot, sectionKey)?.tables.reduce((sum, table) => sum + table.rows.length, 0) ?? 0;
|
||
}
|
||
|
||
function isRestricted(snapshot: EnvironmentSnapshot | undefined, sectionKey: string): boolean {
|
||
return ['license-restricted', 'access-denied'].includes(sectionFor(snapshot, sectionKey)?.state ?? '');
|
||
}
|
||
|
||
export function dashboardEnvironmentSummary(snapshot?: EnvironmentSnapshot): DashboardEnvironmentSummary {
|
||
const operatingSystems = [...new Set(snapshot?.observations
|
||
.filter(observation => observation.property === 'operating_system.name')
|
||
.map(displayValue) ?? [])];
|
||
return {
|
||
directoryFacts: [
|
||
{ label: 'Domain', value: observationText(snapshot, 'domain.fqdn', 'Domain') },
|
||
{ label: 'Domain FL', value: observationText(snapshot, 'domain.functional_level', 'Domain Functional Level') },
|
||
{ label: 'Forest FL', value: observationText(snapshot, 'forest.functional_level', 'Forest Functional Level') },
|
||
{ label: 'Schema', value: observationText(snapshot, 'forest.schema_version', 'Schema version') },
|
||
{ label: 'Domain Controllers', value: observationText(snapshot, 'domain.controller_count', 'DC count') }
|
||
],
|
||
coverage: [
|
||
{ label: 'Observed', value: snapshot?.coverage.observed ?? 0 },
|
||
{ label: 'Empty', value: snapshot?.coverage.empty ?? 0 },
|
||
{ label: 'Explicitly absent', value: snapshot?.coverage.explicitlyAbsent ?? 0 },
|
||
{ label: 'Restricted', value: snapshot?.coverage.restricted ?? 0 },
|
||
{ label: 'Incomplete', value: snapshot?.coverage.incomplete ?? 0 }
|
||
],
|
||
identity: [
|
||
{ label: 'Admin groups', value: sectionObservationCount(snapshot, 'sectionAdminGroups'), detail: 'observations', restricted: isRestricted(snapshot, 'sectionAdminGroups') },
|
||
{ label: 'Never-expiring users', value: sectionObservationCount(snapshot, 'sectionneverexpiresuser') },
|
||
{ label: 'Account analysis', value: sectionObservationCount(snapshot, 'sectionComputerInformation'), restricted: isRestricted(snapshot, 'sectionComputerInformation') },
|
||
{ label: 'Honey Pot', value: sectionObservationCount(snapshot, 'sectionUserInformation'), restricted: isRestricted(snapshot, 'sectionUserInformation') },
|
||
{ label: 'Trusted delegation', value: sectionObservationCount(snapshot, 'sectionenabledtrusteddelegationcomputer') }
|
||
],
|
||
controlPaths: [
|
||
{ label: 'Control paths', value: sectionObservationCount(snapshot, 'sectionControlPathsAnalysis'), detail: 'observations' },
|
||
{ label: 'Indirect-path tables', value: sectionTableCount(snapshot, 'sectionControlPathsAnalysis') },
|
||
{ label: 'Foreign-domain rows', value: sectionRowCount(snapshot, 'sectionControlPathsAnalysis') },
|
||
{ label: 'Trusts score', value: observationNumber(snapshot, 'risk.trusts', 'Trusts score') },
|
||
{ label: 'Discovered domains', value: sectionRowCount(snapshot, 'sectionTrustsdetails') }
|
||
],
|
||
certificates: [
|
||
{ label: 'PKI', value: sectionObservationCount(snapshot, 'sectionPKI'), detail: 'observations' },
|
||
{ label: 'Certificate templates', value: observationNumber(snapshot, 'pki.certificate_template_count', 'Certificate Templates list row count', 'sectionPKI') },
|
||
{ label: 'Certificates', value: observationNumber(snapshot, 'pki.certificate_count', 'Certificates list row count', 'sectionPKI') },
|
||
{ label: 'DC certificates', value: observationNumber(snapshot, 'pki.dc_certificate_count', 'DC Certificates list row count', 'sectionPKI') },
|
||
{ label: 'Delegations', value: observationNumber(snapshot, 'pki.delegation_count', 'Delegations list row count', 'sectionPKI') }
|
||
],
|
||
gpo: [
|
||
{ label: 'GPO', value: sectionObservationCount(snapshot, 'sectionGPO'), detail: 'observations' },
|
||
{ label: 'Privilege assignments', value: observationNumber(snapshot, 'gpo.privilege_count', 'Privileges list row count', 'sectionGPO') },
|
||
{ label: 'Password policies', value: observationNumber(snapshot, 'password.policy_count', 'Password policies list row count', 'sectionPasswordPolicies') },
|
||
{ label: 'Audit settings', value: observationNumber(snapshot, 'gpo.audit_setting_count', 'Audit settings list row count', 'sectionGPO') },
|
||
{ label: 'Defender ASR', value: observationNumber(snapshot, 'gpo.defender_asr_count', 'Defender ASR row count', 'sectionGPO') },
|
||
{ label: 'Firewall rules', value: observationNumber(snapshot, 'gpo.firewall_rule_count', 'Firewall Rules row count', 'sectionGPO') }
|
||
],
|
||
risk: [
|
||
{ label: 'Domain risk', value: observationNumber(snapshot, 'risk.global', 'Domain Risk Level') },
|
||
{ label: 'Anomalies', value: observationNumber(snapshot, 'risk.anomalies', 'Anomalies score') },
|
||
{ label: 'Privileged accounts', value: observationNumber(snapshot, 'risk.privileged_accounts', 'Privileged Accounts score') },
|
||
{ label: 'Stale objects', value: observationNumber(snapshot, 'risk.stale_objects', 'Stale Objects score') },
|
||
{ label: 'Trusts', value: observationNumber(snapshot, 'risk.trusts', 'Trusts score') }
|
||
],
|
||
resilienceFacts: [
|
||
{ label: 'AD Recycle Bin', value: observationText(snapshot, 'domain.recycle_bin_enabled', 'Recycle Bin enabled') },
|
||
{ label: 'Last backup', value: observationText(snapshot, 'backup.last_detected_at', 'Last detected AD backup') },
|
||
{ label: 'krbtgt changed', value: observationText(snapshot, 'kerberos.krbtgt.password_last_changed', 'krbtgt password last changed') },
|
||
{ label: 'Legacy LAPS', value: observationText(snapshot, 'laps.legacy.installation_date', 'Legacy LAPS installation date') },
|
||
{ label: 'Windows LAPS', value: observationText(snapshot, 'laps.windows.installation_date', 'Windows LAPS installation date') },
|
||
{ label: 'WEF', value: observationText(snapshot, 'logging.wef.configuration_count', 'WEF configuration count') },
|
||
{ label: 'OS', value: operatingSystems.length ? operatingSystems.join(', ') : 'No data' }
|
||
]
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Umgebungsdetails der geladenen Client Info.
|
||
*
|
||
* Die Zone beantwortet, was ausserhalb der Befunde ueber die Umgebung bekannt
|
||
* ist: Verzeichnis, Bestand, Passwortrichtlinie, PKI, Wiederanlauf und die
|
||
* werkzeugeigenen Kennzahlen. Unbekanntes bleibt ein Gedankenstrich - eine
|
||
* fehlende Angabe wird nie als Null dargestellt.
|
||
*/
|
||
export interface DashboardEnvironmentDetail {
|
||
operatingSystems: OperatingSystemInventoryGroup[];
|
||
directory: DashboardFact[];
|
||
inventory: DashboardFact[];
|
||
passwordPolicy: DashboardFact[];
|
||
certificates: DashboardFact[];
|
||
resilience: DashboardFact[];
|
||
toolContext: DashboardFact[];
|
||
restricted: string[];
|
||
}
|
||
|
||
|
||
const UNKNOWN_FACT = '—';
|
||
|
||
/**
|
||
* PingCastle schreibt den Hilfetext einer Zelle als weitere Attribute in
|
||
* denselben Text ("Default Domain Policy " data-bs-html=..."). Fuer die
|
||
* Anzeige bleibt der Wert vor dem Attributrest stehen; der vollstaendige
|
||
* Originalwert bleibt in der Beobachtung erhalten.
|
||
*/
|
||
function factText(value: string | undefined): string {
|
||
const cleaned = (value ?? '').replace(/\s*"\s*data-bs[\s\S]*$/i, '').trim();
|
||
return cleaned || UNKNOWN_FACT;
|
||
}
|
||
|
||
/**
|
||
* A native forest name (PingCastle XML, Purple Knight) takes priority over one
|
||
* the PingCastle HTML import derived from the configuration partition path; a
|
||
* derived value is shown with its derivation note.
|
||
*/
|
||
function forestFact(snapshot: EnvironmentSnapshot | undefined): DashboardFact {
|
||
const observed = (snapshot?.observations ?? []).filter(item => item.property === 'forest.name' && item.state === 'observed');
|
||
const observation = observed.find(item => !item.attributes.derivation) ?? observed[0];
|
||
if (!observation) return { label: 'Forest', value: UNKNOWN_FACT };
|
||
const value = factText(displayValue(observation));
|
||
return observation.attributes.derivation === 'configuration-partition-dn' && value !== UNKNOWN_FACT
|
||
? { label: 'Forest', value, note: 'derived from configuration path' }
|
||
: { label: 'Forest', value };
|
||
}
|
||
|
||
function observationFact(snapshot: EnvironmentSnapshot | undefined, property: string, label?: string): string {
|
||
const observation = observationFor(snapshot, property, label);
|
||
return observation ? factText(displayValue(observation)) : UNKNOWN_FACT;
|
||
}
|
||
|
||
function optionalObservationNumber(snapshot: EnvironmentSnapshot | undefined, property: string, label?: string, sectionKey?: string): number | undefined {
|
||
const observation = observationFor(snapshot, property, label, sectionKey);
|
||
if (!observation) return undefined;
|
||
const value = observation.normalizedValue ?? observation.originalValue;
|
||
const numeric = typeof value === 'number' ? value : Number(value);
|
||
return Number.isFinite(numeric) ? numeric : undefined;
|
||
}
|
||
|
||
function numberFact(value: number | undefined): string {
|
||
return value === undefined ? UNKNOWN_FACT : value.toLocaleString('en-US');
|
||
}
|
||
|
||
function environmentTable(snapshot: EnvironmentSnapshot | undefined, sectionKey: string, tableName: RegExp) {
|
||
return sectionFor(snapshot, sectionKey)?.tables.find(table => tableName.test(table.name.trim()));
|
||
}
|
||
|
||
/** Erster Zeilenwert einer benannten Tabelle unter ihrer Spaltenueberschrift. */
|
||
function environmentTableValue(
|
||
snapshot: EnvironmentSnapshot | undefined, sectionKey: string, tableName: RegExp, header: RegExp
|
||
): string {
|
||
const table = environmentTable(snapshot, sectionKey, tableName);
|
||
const column = table?.headers.findIndex(value => header.test(value.trim())) ?? -1;
|
||
if (!table || column < 0) return UNKNOWN_FACT;
|
||
return factText(table.rows[0]?.[column] === null || table.rows[0]?.[column] === undefined ? '' : String(table.rows[0][column]));
|
||
}
|
||
|
||
function environmentTableRowCount(snapshot: EnvironmentSnapshot | undefined, sectionKey: string, tableName: RegExp): number | undefined {
|
||
return environmentTable(snapshot, sectionKey, tableName)?.rows.length;
|
||
}
|
||
|
||
function restrictedSections(snapshot: EnvironmentSnapshot | undefined): string[] {
|
||
return (snapshot?.sections ?? [])
|
||
.filter(section => ['license-restricted', 'access-denied'].includes(section.state))
|
||
.map(section => section.label);
|
||
}
|
||
|
||
export function dashboardEntraTenantCount(snapshot?: EnvironmentSnapshot): number | undefined {
|
||
const observed = (snapshot?.observations ?? []).filter(observation => observation.state === 'observed');
|
||
const ids = new Set(observed.filter(observation => observation.property === 'entra.tenant_id')
|
||
.map(observation => displayValue(observation).trim()).filter(Boolean));
|
||
if (ids.size) return ids.size;
|
||
const names = new Set(observed.filter(observation => observation.property === 'entra.tenant_name')
|
||
.map(observation => observation.subject.identity?.trim() || displayValue(observation).trim()).filter(Boolean));
|
||
return names.size || undefined;
|
||
}
|
||
|
||
export function dashboardEnvironmentDetail(snapshot?: EnvironmentSnapshot): DashboardEnvironmentDetail {
|
||
const userTable = (header: RegExp) => dashboardEnvironmentTableNumber(snapshot, 'sectionUserInformation', header);
|
||
const categoryScores = (snapshot?.observations ?? [])
|
||
.filter(observation => /^purple_knight\.(?:ad|entra)\.category_score\./.test(observation.property))
|
||
.map(observation => ({
|
||
label: `Purple Knight ${observation.property.startsWith('purple_knight.entra.') ? 'Entra' : 'AD'} · ${observation.label}`,
|
||
value: numberFact(optionalObservationNumber(snapshot, observation.property))
|
||
}));
|
||
const entraDirectoryValue = observationFor(snapshot, 'entra.tenant_name')
|
||
? observationFact(snapshot, 'entra.tenant_name', 'Entra tenant')
|
||
: observationFact(snapshot, 'entra.configuration_detected', 'Entra ID configuration detected');
|
||
return {
|
||
operatingSystems: operatingSystemInventory(snapshot),
|
||
directory: [
|
||
{ label: 'Domain', value: observationFact(snapshot, 'domain.fqdn', 'Domain') },
|
||
{ label: 'NetBIOS name', value: observationFact(snapshot, 'domain.netbios_name', 'Netbios Name') },
|
||
forestFact(snapshot),
|
||
{ label: 'Domains', value: numberFact(dashboardEnvironmentDomainCount(snapshot)) },
|
||
{ label: 'Domain functional level', value: observationFact(snapshot, 'domain.functional_level', 'Domain Functional Level') },
|
||
{ label: 'Forest functional level', value: observationFact(snapshot, 'forest.functional_level', 'Forest Functional Level') },
|
||
{ label: 'Schema version', value: observationFact(snapshot, 'forest.schema_version', 'Schema version') },
|
||
{ label: 'Domain created', value: observationFact(snapshot, 'domain.created_at', 'Creation date') },
|
||
{ label: 'Entra ID', value: entraDirectoryValue }
|
||
],
|
||
inventory: [
|
||
{ label: 'User accounts', value: numberFact(userTable(/^nb user accounts$/i)) },
|
||
{ label: 'Enabled / disabled', value: `${numberFact(userTable(/^nb enabled$/i))} / ${numberFact(userTable(/^nb disabled$/i))}` },
|
||
{ label: 'Password never expires', value: numberFact(userTable(/^nb pwd never expire$/i)) },
|
||
{ label: 'Computer objects', value: numberFact(dashboardEnvironmentTableNumber(snapshot, 'sectionComputerInformation', /^nb computer accounts$/i)) },
|
||
{ label: 'Domain controllers', value: observationFact(snapshot, 'domain.controller_count', 'DC count') },
|
||
{ label: 'Admin groups', value: numberFact(environmentTableRowCount(snapshot, 'sectionAdminGroups', /^admin groups list$/i)) },
|
||
{ label: 'Trusts', value: numberFact(environmentTableRowCount(snapshot, 'sectionTrustsdetails', /^trusts list$/i)) },
|
||
{ label: 'Assessed host', value: observationFact(snapshot, 'host.name') }
|
||
],
|
||
passwordPolicy: [
|
||
{ label: 'Policy', value: environmentTableValue(snapshot, 'sectionPasswordPolicies', /^password policies list$/i, /^policy name$/i) },
|
||
{ label: 'Complexity', value: environmentTableValue(snapshot, 'sectionPasswordPolicies', /^password policies list$/i, /^complexity$/i) },
|
||
{ label: 'Minimum length', value: environmentTableValue(snapshot, 'sectionPasswordPolicies', /^password policies list$/i, /^min password length$/i) },
|
||
{ label: 'History', value: environmentTableValue(snapshot, 'sectionPasswordPolicies', /^password policies list$/i, /^password history$/i) },
|
||
{ label: 'Maximum age', value: environmentTableValue(snapshot, 'sectionPasswordPolicies', /^password policies list$/i, /^max password age$/i) },
|
||
{ label: 'Lockout threshold', value: environmentTableValue(snapshot, 'sectionPasswordPolicies', /^password policies list$/i, /^lockout threshold$/i) }
|
||
],
|
||
certificates: [
|
||
{ label: 'Certificate templates', value: numberFact(environmentTableRowCount(snapshot, 'sectionPKI', /^certificate templates list$/i)) },
|
||
{ label: 'Certificates', value: numberFact(environmentTableRowCount(snapshot, 'sectionPKI', /^certificates list$/i)) },
|
||
{ label: 'DC certificates', value: numberFact(environmentTableRowCount(snapshot, 'sectionPKI', /^dc certificates list$/i)) },
|
||
{ label: 'PKI delegations', value: numberFact(environmentTableRowCount(snapshot, 'sectionPKI', /^delegations list$/i)) }
|
||
],
|
||
resilience: [
|
||
{ label: 'AD Recycle Bin', value: observationFact(snapshot, 'domain.recycle_bin_enabled', 'Recycle Bin enabled') },
|
||
{ label: 'Last AD backup', value: observationFact(snapshot, 'backup.last_detected_at', 'Last detected AD backup') },
|
||
{ label: 'krbtgt password', value: observationFact(snapshot, 'kerberos.krbtgt.password_last_changed', 'krbtgt password last changed') },
|
||
{ label: 'krbtgt version', value: observationFact(snapshot, 'kerberos.krbtgt.version', 'krbtgt version') },
|
||
{ label: 'Legacy LAPS', value: observationFact(snapshot, 'laps.legacy.installation_date', 'Legacy LAPS installation date') },
|
||
{ label: 'Windows LAPS', value: observationFact(snapshot, 'laps.windows.installation_date', 'Windows LAPS installation date') },
|
||
{ label: 'WEF configurations', value: observationFact(snapshot, 'logging.wef.configuration_count', 'WEF configuration count') }
|
||
],
|
||
toolContext: [
|
||
{ label: 'PingCastle engine', value: observationFact(snapshot, 'report.engine_version', 'PingCastle engine version') },
|
||
{ label: 'PingCastle domain risk', value: numberFact(optionalObservationNumber(snapshot, 'risk.domain') ?? optionalObservationNumber(snapshot, 'risk.global', 'Domain Risk Level')) },
|
||
{ label: 'Purple Knight AD posture', value: numberFact(optionalObservationNumber(snapshot, 'purple_knight.ad.security_posture_score')) },
|
||
{ label: 'Purple Knight AD IOEs', value: numberFact(optionalObservationNumber(snapshot, 'purple_knight.ad.ioes_found')) },
|
||
{ label: 'Purple Knight AD evaluated', value: numberFact(optionalObservationNumber(snapshot, 'purple_knight.ad.evaluated')) },
|
||
{ label: 'Purple Knight Entra tenant', value: observationFact(snapshot, 'entra.tenant_name', 'Entra tenant') },
|
||
{ label: 'Purple Knight Entra posture', value: numberFact(optionalObservationNumber(snapshot, 'purple_knight.entra.security_posture_score')) },
|
||
{ label: 'Purple Knight Entra IOEs', value: numberFact(optionalObservationNumber(snapshot, 'purple_knight.entra.ioes_found')) },
|
||
{ label: 'Purple Knight Entra evaluated', value: numberFact(optionalObservationNumber(snapshot, 'purple_knight.entra.evaluated')) },
|
||
...categoryScores,
|
||
{ label: 'CIS-CAT benchmark', value: observationFact(snapshot, 'benchmark.name') },
|
||
{ label: 'CIS-CAT profile', value: observationFact(snapshot, 'benchmark.profile') },
|
||
{ label: 'CIS-CAT score', value: numberFact(optionalObservationNumber(snapshot, 'benchmark.score')) },
|
||
...(snapshot?.observations ?? [])
|
||
.filter(observation => observation.tool.id === 'maester' && ['entra.tenant_id', 'entra.tenant_name', 'report.generated_at', 'report.engine_version', 'maester.duration', 'maester.tests.actual_total', 'maester.runner.host', 'maester.runner.os', 'maester.powershell.version'].includes(observation.property))
|
||
.map(observation => ({ label: observation.label.startsWith('Maester') ? observation.label : `Maester ${observation.label}`, value: observation.state === 'observed' ? factText(displayValue(observation)) : UNKNOWN_FACT }))
|
||
],
|
||
restricted: restrictedSections(snapshot)
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Kompakter, quellengestuetzter Verzeichniskontext fuer die erste Leselinie.
|
||
* Die Werte verwenden dieselben normalisierten Beobachtungen wie Environment
|
||
* Detail; fehlende Scannerangaben bleiben ausdruecklich unbekannt.
|
||
*/
|
||
export function dashboardDomainInformation(snapshot?: EnvironmentSnapshot): DashboardFact[] {
|
||
const entra = observationFor(snapshot, 'entra.tenant_name')
|
||
? observationFact(snapshot, 'entra.tenant_name', 'Entra tenant')
|
||
: observationFact(snapshot, 'entra.configuration_detected', 'Entra ID configuration detected');
|
||
const dcOperatingSystems = [...new Set((snapshot?.observations ?? [])
|
||
.filter(observation => observation.state === 'observed'
|
||
&& observation.property === 'operating_system.name'
|
||
&& observation.subject.type === 'computer-information.domain-controllers')
|
||
.map(observation => factText(displayValue(observation)))
|
||
.filter(value => value !== UNKNOWN_FACT))];
|
||
return [
|
||
{ label: 'Domain', value: observationFact(snapshot, 'domain.fqdn', 'Domain') },
|
||
{ label: 'NetBIOS name', value: observationFact(snapshot, 'domain.netbios_name', 'Netbios Name') },
|
||
forestFact(snapshot),
|
||
{ label: 'Domains', value: numberFact(dashboardEnvironmentDomainCount(snapshot)) },
|
||
{ label: 'Domain controllers', value: observationFact(snapshot, 'domain.controller_count', 'DC count') },
|
||
{ label: 'Domain functional level', value: observationFact(snapshot, 'domain.functional_level', 'Domain Functional Level') },
|
||
{ label: 'Forest functional level', value: observationFact(snapshot, 'forest.functional_level', 'Forest Functional Level') },
|
||
{ label: 'DC OS', value: dcOperatingSystems.length ? dcOperatingSystems.join(', ') : UNKNOWN_FACT },
|
||
{ label: 'Schema version', value: observationFact(snapshot, 'forest.schema_version', 'Schema version') },
|
||
{ label: 'Domain created', value: observationFact(snapshot, 'domain.created_at', 'Creation date') },
|
||
{ label: 'Entra ID', value: entra }
|
||
];
|
||
}
|
||
|
||
interface DashboardMetricTileProps {
|
||
label: string;
|
||
value: string;
|
||
tone?: string;
|
||
emphasis?: boolean;
|
||
active?: boolean;
|
||
onClick?(): void;
|
||
}
|
||
|
||
function DashboardMetricTile({ label, value, tone = 'neutral', emphasis = false, active, onClick }: DashboardMetricTileProps) {
|
||
const content = <><span>{label}</span><strong>{value}</strong></>;
|
||
const className = `client-info-metric tone-${tone}${emphasis ? ' is-emphasis' : ''}`;
|
||
return onClick
|
||
? <button className={className} type="button" aria-pressed={Boolean(active)} onClick={onClick}>{content}</button>
|
||
: <article className={className}>{content}</article>;
|
||
}
|
||
|
||
/** Source-backed summary facts appear in the tiles and remain available in Results. */
|
||
|
||
function DashboardFactGroup({ title, items, overviewItems, glyph, snapshot, evidenceGroup }: {
|
||
title: string; items: DashboardFact[]; glyph: DashboardGlyphName;
|
||
overviewItems?: DashboardFact[];
|
||
snapshot?: EnvironmentSnapshot; evidenceGroup: EnvironmentEvidenceGroup;
|
||
}) {
|
||
const tileItems = (overviewItems ?? items).slice(0, 4);
|
||
return <section className="client-env-group">
|
||
<header className="client-env-group-header">
|
||
<h3 className="client-env-group-title">
|
||
<DashboardGlyph name={glyph} />
|
||
<span>{title}</span>
|
||
</h3>
|
||
<EnvironmentResultsButton title={title}>
|
||
<DashboardFactList items={items}/>
|
||
<EnvironmentEvidenceTables snapshot={snapshot} group={evidenceGroup}/>
|
||
</EnvironmentResultsButton>
|
||
</header>
|
||
<DashboardFactList items={tileItems}/>
|
||
</section>;
|
||
}
|
||
|
||
interface DashboardDonutSegment {
|
||
label: string;
|
||
value: number;
|
||
tone: string;
|
||
onClick?(): void;
|
||
}
|
||
|
||
function DashboardDonut({ total, centerLabel, segments }: { total: number; centerLabel: string; segments: DashboardDonutSegment[] }) {
|
||
let offset = 0;
|
||
return <div className="client-info-donut-layout">
|
||
<div className="client-info-donut" role="img" aria-label={`${centerLabel}: ${total.toLocaleString('en-US')}`}>
|
||
<svg viewBox="0 0 120 120" aria-hidden="true">
|
||
<circle className="client-info-donut-track" cx="60" cy="60" r="48" pathLength="100" />
|
||
{segments.filter(segment => segment.value > 0).map(segment => {
|
||
const percentage = share(segment.value, total);
|
||
const segmentOffset = offset;
|
||
offset += percentage;
|
||
return <g key={segment.label}><path className={`client-info-donut-segment tone-${segment.tone}`}
|
||
d={donutArcPath(percentage, segmentOffset)} />
|
||
<DonutSmallArcMarker percentage={percentage} offset={segmentOffset} className={`client-info-donut-marker tone-${segment.tone}`}/></g>;
|
||
})}
|
||
</svg>
|
||
<span><strong>{total.toLocaleString('en-US')}</strong><small>100%</small><em>{centerLabel}</em></span>
|
||
</div>
|
||
<ul className="client-info-legend">
|
||
{segments.map(segment => <li key={segment.label}>{segment.onClick
|
||
? <button type="button" onClick={segment.onClick}><i className={`tone-${segment.tone}`} /><span>{segment.label}</span><strong>{segment.value.toLocaleString('en-US')}</strong></button>
|
||
: <span className="client-info-legend-row"><i className={`tone-${segment.tone}`} /><span>{segment.label}</span><strong>{segment.value.toLocaleString('en-US')}</strong></span>}</li>)}
|
||
</ul>
|
||
</div>;
|
||
}
|
||
|
||
export function DashboardMetricBars({ items, tone = 'accent' }: { items: DashboardEnvironmentMetric[]; tone?: string }) {
|
||
const maximum = Math.max(1, ...items.map(item => item.value));
|
||
const ordinalTones = ['critical', 'high', 'medium', 'accent', 'neutral'];
|
||
return <ul className="client-info-bars">{items.map((item, index) => {
|
||
const resolvedTone = item.tone ?? (tone === 'accent' ? ordinalTones[index % ordinalTones.length] : tone);
|
||
const content = <>
|
||
<span className="client-info-bar-label">{item.label}</span>
|
||
<strong><span>{item.value.toLocaleString('en-US')}</span>{item.detail && <small className="client-info-bar-detail">{item.detail}</small>}</strong>
|
||
<small className={`client-info-bar-status${item.restricted ? '' : ' is-empty'}`} title={item.restricted ? 'Some source details are restricted' : undefined} aria-hidden={!item.restricted}>{item.restricted ? 'Restricted' : '\u00a0'}</small>
|
||
<span className="client-info-bar-track"><i className={`tone-${resolvedTone}`} style={shareStyle(share(item.value, maximum))} /></span>
|
||
</>;
|
||
return <li key={item.label}>{item.onClick
|
||
? <button className="client-info-bar-row" type="button" onClick={item.onClick}>{content}</button>
|
||
: <div className="client-info-bar-row">{content}</div>}
|
||
</li>;
|
||
})}</ul>;
|
||
}
|
||
|
||
interface DashboardCardSummary {
|
||
label: string;
|
||
value: number | string;
|
||
detail?: string;
|
||
}
|
||
|
||
export function DashboardLargeCard({ title, meta, summary, className = '', details, initiallyExpanded = true, children }: { title: string; meta?: string; summary?: DashboardCardSummary; className?: string; details?: ReactNode; initiallyExpanded?: boolean; children?: ReactNode }) {
|
||
const [expanded, setExpanded] = useState(initiallyExpanded);
|
||
const detailsId = useId();
|
||
return <section className={`client-info-card client-info-detail-card ${expanded ? 'is-expanded' : 'is-collapsed'} ${className}`.trim()}>
|
||
<header onClick={() => setExpanded(value => !value)}>
|
||
<span className="client-info-card-heading"><h2>{title}</h2>{summary && <span className="client-info-card-primary">— {summary.label} {typeof summary.value === 'number' ? summary.value.toLocaleString('en-US') : summary.value}{summary.detail ? ` ${summary.detail}` : ''}</span>}{meta && <span className="client-info-card-meta">{meta}</span>}</span>
|
||
<button className="client-info-card-toggle" type="button" aria-expanded={expanded} aria-controls={detailsId}
|
||
aria-label={`${expanded ? 'Hide' : 'Show'} details for ${title}`} title={`${expanded ? 'Hide' : 'Show'} details`}>
|
||
<TriangleIcon direction={expanded ? 'down' : 'right'} />
|
||
</button>
|
||
</header>
|
||
{expanded && <div className="client-info-card-content" id={detailsId}>
|
||
<div className="client-info-card-details">{children}</div>
|
||
{details && <div className="client-info-card-extra">{details}</div>}
|
||
</div>}
|
||
</section>;
|
||
}
|
||
|
||
export function DashboardDonutCard({ title, meta, initiallyExpanded = true, children }: { title: string; meta: string; initiallyExpanded?: boolean; children: ReactNode }) {
|
||
const [expanded, setExpanded] = useState(initiallyExpanded);
|
||
const detailsId = useId();
|
||
return <section className={`client-info-card client-info-donut-card ${expanded ? 'is-expanded' : 'is-collapsed'}`}>
|
||
<header onClick={() => setExpanded(value => !value)}>
|
||
<span className="client-info-card-heading"><h2>{title}</h2><span className="client-info-card-primary">— {meta}</span></span>
|
||
<button className="client-info-card-toggle" type="button" aria-expanded={expanded} aria-controls={detailsId}
|
||
aria-label={`${expanded ? 'Hide' : 'Show'} details for ${title}`} title={`${expanded ? 'Hide' : 'Show'} details`}>
|
||
<TriangleIcon direction={expanded ? 'down' : 'right'} />
|
||
</button>
|
||
</header>
|
||
{expanded && <div className="client-info-card-content" id={detailsId}><div className="client-info-card-details">{children}</div></div>}
|
||
</section>;
|
||
}
|
||
|
||
|
||
interface DashboardSourceScore {
|
||
tool: DashboardRiskTool;
|
||
records: FindingRecord[];
|
||
passed: number;
|
||
deviations: number;
|
||
assessed: number;
|
||
reviewRequired?: number;
|
||
reevaluationRequired?: number;
|
||
score?: number;
|
||
weight?: number;
|
||
rating?: SecuritySourceScore;
|
||
}
|
||
|
||
function DashboardSourceResults({ sources, snapshot }: { sources: DashboardSourceScore[]; snapshot?: EnvironmentSnapshot }) {
|
||
const [visibleRecords, setVisibleRecords] = useState(100);
|
||
const quantity = (value?: number) => value === undefined ? '—' : value.toLocaleString('en-US');
|
||
const records = sources.flatMap(source => source.records.map(record => ({ source, record })));
|
||
return <div className="client-env-evidence">
|
||
<section className="client-env-evidence-section">
|
||
<h3>Source result contributions</h3>
|
||
{sources.length ? <div className="client-env-evidence-scroll"><table><thead><tr>
|
||
<th scope="col">Tool</th><th scope="col">Results</th><th scope="col">Passed</th>
|
||
<th scope="col">Deviations</th><th scope="col">Assessed</th><th scope="col">Review required</th>
|
||
<th scope="col">Reevaluation required</th><th scope="col">Score</th><th scope="col">Weight</th>
|
||
<th scope="col">Scoring method</th><th scope="col">Issues</th>
|
||
</tr></thead><tbody>{sources.map(source => <tr key={source.tool.id}>
|
||
<td>{source.tool.label}</td><td>{quantity(source.records.length)}</td><td>{quantity(source.passed)}</td>
|
||
<td>{quantity(source.deviations)}</td><td>{quantity(source.assessed)}</td>
|
||
<td>{quantity(source.reviewRequired)}</td><td>{quantity(source.reevaluationRequired)}</td>
|
||
<td>{quantity(source.score)}</td><td>{quantity(source.weight)}</td>
|
||
<td>{source.rating?.methods.join(', ') || '—'}</td><td>{source.rating?.issues.join(' · ') || '—'}</td>
|
||
</tr>)}</tbody></table></div> : <p>No source results available.</p>}
|
||
</section>
|
||
{sources.flatMap(source => source.rating?.scans.map(scan => ({ source, scan })) ?? []).length > 0 && <section className="client-env-evidence-section">
|
||
<h3>Scan score evidence</h3>
|
||
<div className="client-env-evidence-scroll"><table><thead><tr>
|
||
<th scope="col">Tool</th><th scope="col">Method</th><th scope="col">Score</th>
|
||
<th scope="col">Native property</th><th scope="col">Native value</th><th scope="col">Passed</th>
|
||
<th scope="col">Deviations</th><th scope="col">Assessed</th><th scope="col">Eligible</th>
|
||
<th scope="col">Run IDs</th><th scope="col">Import file IDs</th><th scope="col">Issues</th>
|
||
</tr></thead><tbody>{sources.flatMap(source => source.rating?.scans.map(scan => ({ source, scan })) ?? [])
|
||
.map(({ source, scan }) => <tr key={scan.id}>
|
||
<td>{source.tool.label}</td><td>{scan.method}</td><td>{quantity(scan.score)}</td>
|
||
<td>{scan.nativeProperty ?? '—'}</td><td>{quantity(scan.nativeValue)}</td>
|
||
<td>{quantity(scan.passed)}</td><td>{quantity(scan.deviations)}</td><td>{quantity(scan.assessed)}</td>
|
||
<td>{quantity(scan.eligible)}</td><td>{scan.runIds.join(', ') || '—'}</td>
|
||
<td>{scan.importFileIds.join(', ') || '—'}</td><td>{scan.issues.join(' · ') || '—'}</td>
|
||
</tr>)}</tbody></table></div>
|
||
</section>}
|
||
{records.length > 0 && <section className="client-env-evidence-section">
|
||
<h3>Source finding records <span>{quantity(records.length)}</span></h3>
|
||
<div className="client-env-evidence-scroll"><table><thead><tr>
|
||
<th scope="col">Tool</th><th scope="col">Finding</th><th scope="col">Record ID</th>
|
||
<th scope="col">Original fields</th><th scope="col">Filled fields</th><th scope="col">Metadata</th>
|
||
<th scope="col">Scan run</th><th scope="col">Import file</th><th scope="col">Source sheet</th>
|
||
<th scope="col">Source file</th><th scope="col">Policy source evidence</th><th scope="col">Complete retained record</th>
|
||
</tr></thead><tbody>{records.slice(0, visibleRecords).map(({ source, record }) => <tr key={`${source.tool.id}:${record.id}`}>
|
||
<td>{source.tool.label}</td><td>{record.title}</td><td>{record.id}</td>
|
||
<td>{JSON.stringify(record.fields)}</td><td>{JSON.stringify(record.filled)}</td><td>{JSON.stringify(record.meta)}</td>
|
||
<td>{record.scanRunId ?? '—'}</td><td>{record.importFileId ?? '—'}</td>
|
||
<td>{record.sourceSheet ?? '—'}</td><td>{record.sourceFile ?? '—'}</td>
|
||
<td>{record.policySourceEvidence ? JSON.stringify(record.policySourceEvidence) : '—'}</td>
|
||
<td>{JSON.stringify(record)}</td>
|
||
</tr>)}</tbody></table></div>
|
||
{visibleRecords < records.length && <button className="btn" type="button" onClick={() => setVisibleRecords(value => value + 100)}>
|
||
Show more findings ({quantity(visibleRecords)} of {quantity(records.length)})
|
||
</button>}
|
||
</section>}
|
||
<EnvironmentEvidenceTables snapshot={snapshot} group="all"/>
|
||
</div>;
|
||
}
|
||
|
||
interface DashboardDomainScore {
|
||
id: string;
|
||
label: string;
|
||
glyph: DashboardGlyphName;
|
||
score?: number;
|
||
passed: number;
|
||
deviations: number;
|
||
notConfigured: number;
|
||
conflicts: number;
|
||
duplicates: number;
|
||
assessed: number;
|
||
records: FindingRecord[];
|
||
reviewRecords: FindingRecord[];
|
||
}
|
||
|
||
export function dashboardOverallScore(sourceScores: Array<{ score?: number; weight?: number }>): number | undefined {
|
||
return weightedSecurityScore(sourceScores);
|
||
}
|
||
|
||
function dashboardFieldValue(record: FindingRecord, pattern: RegExp): string | undefined {
|
||
return record.fields.find(field => pattern.test(field.label.trim()))?.value;
|
||
}
|
||
|
||
function dashboardObservationNumber(snapshot: EnvironmentSnapshot | undefined, property: string): number | undefined {
|
||
const observation = snapshot?.observations.find(item => item.property === property && item.state === 'observed');
|
||
const value = observation?.normalizedValue ?? observation?.originalValue;
|
||
const number = typeof value === 'number' ? value : Number(value);
|
||
return Number.isFinite(number) ? number : undefined;
|
||
}
|
||
|
||
function dashboardEnvironmentTableNumber(snapshot: EnvironmentSnapshot | undefined, sectionKey: string, header: RegExp): number | undefined {
|
||
const values = snapshot?.sections
|
||
.filter(section => section.sectionKey === sectionKey)
|
||
.flatMap(section => section.tables.flatMap(table => {
|
||
const column = table.headers.findIndex(value => header.test(value.trim()));
|
||
if (column < 0) return [];
|
||
return table.rows.flatMap(row => {
|
||
const numeric = Number(row[column]);
|
||
return Number.isFinite(numeric) ? [numeric] : [];
|
||
});
|
||
})) ?? [];
|
||
return values.length ? values.reduce((sum, value) => sum + value, 0) : undefined;
|
||
}
|
||
|
||
/**
|
||
* Zahl der Domaenen. Nennt eine Quelle sie ausdruecklich (Purple Knight:
|
||
* "AD: number of domains"), gilt diese Angabe; sonst bleibt die Zahl der
|
||
* verschiedenen beobachteten Domaenennamen die belegbare Untergrenze.
|
||
*/
|
||
function dashboardEnvironmentDomainCount(snapshot: EnvironmentSnapshot | undefined): number | undefined {
|
||
const declared = dashboardObservationNumber(snapshot, 'domain.count');
|
||
if (declared !== undefined) return declared;
|
||
const domains = new Set(snapshot?.observations
|
||
.filter(item => item.property === 'domain.fqdn' && item.state === 'observed')
|
||
.map(item => String(item.normalizedValue ?? item.originalValue).trim().toLocaleLowerCase('en-US'))
|
||
.filter(Boolean));
|
||
return domains.size || undefined;
|
||
}
|
||
|
||
/**
|
||
* Zahl der erfassten Gruppenrichtlinien aus den verarbeiteten Sicherungen.
|
||
*
|
||
* Die frueher benutzte Zaehlung der GUIDs aus Findingtexten war eine
|
||
* Schaetzung: sie fand nur GPOs, die ueberhaupt einen Befund tragen, und
|
||
* zaehlte jede sonstige GUID mit. Die Verarbeitungsliste nennt die Zahl
|
||
* dagegen selbst.
|
||
*/
|
||
function useGpoInventory(projectId?: string): { gpoCount?: number; snapshots?: number; records?: GpoBackupStatusRecord[] } {
|
||
const [inventory, setInventory] = useState<{ gpoCount?: number; snapshots?: number; records?: GpoBackupStatusRecord[] }>({});
|
||
|
||
useEffect(() => {
|
||
let active = true;
|
||
setInventory({});
|
||
if (!projectId) return () => { active = false; };
|
||
assessorApi.listProjectGpoBackupStatus(projectId)
|
||
.then(response => {
|
||
if (!active) return;
|
||
const processed = response.records.filter(record => record.processingStatus);
|
||
const gpoCount = processed.reduce((sum, record) => sum + record.gpoCount, 0);
|
||
setInventory(processed.length ? { gpoCount, snapshots: processed.length, records: processed } : {});
|
||
})
|
||
.catch(() => { if (active) setInventory({}); });
|
||
return () => { active = false; };
|
||
}, [projectId]);
|
||
|
||
return inventory;
|
||
}
|
||
|
||
function dashboardGpoCount(records: FindingRecord[]): number | undefined {
|
||
const identifiers = new Set<string>();
|
||
const pattern = /\{?[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\}?/gi;
|
||
records.forEach(record => [record.sourceFile, ...record.fields.map(field => field.value)].filter(Boolean).forEach(value => {
|
||
String(value).match(pattern)?.forEach(identifier => identifiers.add(identifier.toLocaleLowerCase('en-US')));
|
||
}));
|
||
return identifiers.size || undefined;
|
||
}
|
||
|
||
|
||
/**
|
||
* Verbindungskurven zwischen Overall Score und den Security Domains.
|
||
*
|
||
* Die Kurven entstehen aus der tatsaechlichen Lage von Ring und Domainknoten,
|
||
* nicht aus angenommenen Koordinaten: Zuvor lagen feste Zielpunkte in einem
|
||
* ungleichmaessig gestreckten Koordinatensystem (`preserveAspectRatio="none"`),
|
||
* sodass die Linien nur bei genau einer Fenstergroesse an ihrem Knoten
|
||
* ankamen und sonst daneben endeten.
|
||
*/
|
||
export function DashboardPostureConnectors({ nodeCount }: { nodeCount: number }) {
|
||
// Gemessen wird ab dem eigenen Element: der Ref des umgebenden Abschnitts ist
|
||
// waehrend des Layouteffekts eines Kindes noch nicht gesetzt.
|
||
const svgRef = useRef<SVGSVGElement | null>(null);
|
||
const [geometry, setGeometry] = useState<{ width: number; height: number; paths: string[] }>({ width: 0, height: 0, paths: [] });
|
||
|
||
const measure = useCallback(() => {
|
||
const posture = svgRef.current?.parentElement;
|
||
const ring = posture?.querySelector('.client-overall-ring');
|
||
const nodes = [...(posture?.querySelectorAll('.client-domains .client-domain-score') ?? [])];
|
||
if (!posture || !ring || !nodes.length) {
|
||
setGeometry(current => (current.paths.length ? { width: 0, height: 0, paths: [] } : current));
|
||
return;
|
||
}
|
||
const frame = posture.getBoundingClientRect();
|
||
const ringBox = ring.getBoundingClientRect();
|
||
const start = { x: ringBox.right - frame.left, y: ringBox.top + ringBox.height / 2 - frame.top };
|
||
const paths = nodes.map((node, index) => {
|
||
const box = node.getBoundingClientRect();
|
||
const end = { x: box.left - frame.left, y: box.top + box.height / 2 - frame.top };
|
||
// Leichte Faecherung am Ring, damit die Kurven nicht als eine Linie
|
||
// starten; die Ankunft trifft die Knotenmitte exakt.
|
||
const originY = start.y + (index - (nodes.length - 1) / 2) * 6;
|
||
const reach = Math.max(24, (end.x - start.x) * 0.45);
|
||
return `M${start.x.toFixed(1)} ${originY.toFixed(1)} C${(start.x + reach).toFixed(1)} ${originY.toFixed(1)}, ${(end.x - reach).toFixed(1)} ${end.y.toFixed(1)}, ${end.x.toFixed(1)} ${end.y.toFixed(1)}`;
|
||
});
|
||
setGeometry(current => current.width === frame.width && current.height === frame.height
|
||
&& current.paths.length === paths.length && current.paths.every((path, index) => path === paths[index])
|
||
? current : { width: frame.width, height: frame.height, paths });
|
||
}, []);
|
||
|
||
useLayoutEffect(() => {
|
||
measure();
|
||
const posture = svgRef.current?.parentElement;
|
||
// Zwei Ausloeser mit unterschiedlicher Reichweite: der Beobachter meldet
|
||
// jede Groessenaenderung der beteiligten Flaechen, das Fensterereignis
|
||
// greift auch dann, wenn die Seite gerade nicht gezeichnet wird und der
|
||
// Beobachter deshalb keine Rueckrufe liefert.
|
||
window.addEventListener('resize', measure);
|
||
const observer = posture && typeof ResizeObserver !== 'undefined' ? new ResizeObserver(() => measure()) : undefined;
|
||
if (posture && observer) {
|
||
observer.observe(posture);
|
||
posture.querySelectorAll('.client-domains, .client-overall').forEach(element => observer.observe(element));
|
||
}
|
||
return () => {
|
||
window.removeEventListener('resize', measure);
|
||
observer?.disconnect();
|
||
};
|
||
}, [measure, nodeCount]);
|
||
|
||
return <svg
|
||
ref={svgRef}
|
||
className="client-posture-connectors"
|
||
viewBox={`0 0 ${Math.max(1, geometry.width)} ${Math.max(1, geometry.height)}`}
|
||
aria-hidden="true"
|
||
>
|
||
{geometry.paths.map((path, index) => <path key={index} d={path} />)}
|
||
</svg>;
|
||
}
|
||
|
||
function dashboardScoreTone(score?: number): 'unknown' | 'critical' | 'medium' | 'passed' {
|
||
return score === undefined ? 'unknown' : score < 50 ? 'critical' : score < 75 ? 'medium' : 'passed';
|
||
}
|
||
|
||
export function DashboardOverallRing({ score, onClick }: { score?: number; onClick?(): void }) {
|
||
const value = score ?? 0;
|
||
const scoreLabel = score !== undefined && score > 0 && score < 0.5 ? '<1' : Math.round(value).toString();
|
||
const tone = dashboardScoreTone(score);
|
||
const label = `Overall Score: ${score === undefined ? 'No data' : scoreLabel}`;
|
||
const content = <>
|
||
<svg viewBox="0 0 120 120" aria-hidden="true">
|
||
<circle className="client-overall-ring-track" cx="60" cy="60" r="48" pathLength="100" />
|
||
{score !== undefined && <><path className="client-overall-ring-value" d={donutArcPath(value)} /><DonutSmallArcMarker percentage={value}/></>}
|
||
</svg>
|
||
<span><span className="client-overall-ring-number"><strong>{score === undefined ? '—' : scoreLabel}</strong>{score !== undefined && <small>/100</small>}</span><em>ASSESSOR</em></span>
|
||
</>;
|
||
return onClick
|
||
? <button className={`client-overall-ring tone-${tone}`} type="button" aria-label={`Open remediation overview in Work. ${label}`} onClick={onClick}>{content}</button>
|
||
: <div className={`client-overall-ring tone-${tone}`} role="img" aria-label={label}>{content}</div>;
|
||
}
|
||
|
||
export function DashboardTopFact({ value, label, glyph, snapshot, evidenceGroup, backupRecords, fallback }: {
|
||
value?: number | string; label: string; glyph: DashboardGlyphName; snapshot?: EnvironmentSnapshot;
|
||
evidenceGroup: EnvironmentEvidenceGroup; backupRecords?: GpoBackupStatusRecord[]; fallback?: string;
|
||
}) {
|
||
const evidence = environmentEvidenceForGroup(snapshot, evidenceGroup);
|
||
return <EnvironmentResultsButton title={label} triggerClassName="client-data-fact"
|
||
trigger={<><DashboardGlyph name={glyph}/><span><strong>{value ?? '—'}</strong><small>{label}</small></span></>}>
|
||
<DashboardFactList items={[{ label, value: value === undefined ? UNKNOWN_FACT : String(value) }]}/>
|
||
{fallback && <p className="client-env-evidence-empty">{fallback}</p>}
|
||
{!!evidence.observations.length && <table className="client-data-fact-evidence"><thead><tr><th>Field</th><th>Original value</th><th>Source</th><th>Scan run</th></tr></thead>
|
||
<tbody>{evidence.observations.map(item => <tr key={item.id}><td>{item.label}</td><td>{typeof item.originalValue === 'string' ? item.originalValue : JSON.stringify(item.originalValue)}</td><td>{item.tool.name}</td><td>{item.scanRunId}</td></tr>)}</tbody>
|
||
</table>}
|
||
{!!backupRecords?.length && <><p className="client-env-evidence-empty">All processed client backups · the overview count includes every backup listed below.</p>
|
||
<table className="client-data-fact-evidence"><thead><tr><th>Backup file</th><th>GPOs</th><th>Processing status</th><th>Updated</th></tr></thead>
|
||
<tbody>{backupRecords.map(record => <tr key={record.artifactId}><td>{record.fileName}</td><td>{record.gpoCount}</td><td>{record.processingStatus}</td><td>{record.updatedAt}</td></tr>)}</tbody>
|
||
</table></>}
|
||
<EnvironmentEvidenceTables snapshot={snapshot} group={evidenceGroup}/>
|
||
</EnvironmentResultsButton>;
|
||
}
|
||
|
||
function dashboardDomainAmount(domain: DashboardDomainScore): number {
|
||
return domain.assessed === 0 && domain.reviewRecords.length > 0 ? domain.reviewRecords.length : domain.deviations;
|
||
}
|
||
|
||
function DashboardDomainRow({ domain, distributionTotal, onClick }: { domain: DashboardDomainScore; distributionTotal: number; onClick?(): void }) {
|
||
// Der Knoten traegt die Bestehensquote der Domaene. Ohne
|
||
// bewertete Ergebnisse bleibt das Sinnbild stehen - eine Null waere hier
|
||
// eine Aussage, die die Datenlage nicht deckt.
|
||
const tone = domain.score === undefined ? 'unknown' : domain.score < 50 ? 'critical' : domain.score < 75 ? 'medium' : 'passed';
|
||
const reviewOnly = domain.assessed === 0 && domain.reviewRecords.length > 0;
|
||
const amount = dashboardDomainAmount(domain);
|
||
const content = <>
|
||
<span className="client-domain-amount-bar" aria-hidden="true"><i style={shareStyle(share(amount, distributionTotal))} /></span>
|
||
<span className={`client-domain-score tone-${tone}`} title={domain.score === undefined ? 'Not assessed' : `Pass rate ${Math.round(domain.score)}%`}>
|
||
{domain.score === undefined ? <DashboardGlyph name={domain.glyph}/> : <em>{Math.round(domain.score)}</em>}
|
||
</span>
|
||
<span className="client-domain-copy"><strong>{domain.label}</strong></span>
|
||
<b title={reviewOnly ? 'Review required' : 'Deviations'}>{amount.toLocaleString('en-US')}</b>
|
||
</>;
|
||
return <li>{onClick ? <button type="button" onClick={onClick}>{content}</button> : <div>{content}</div>}</li>;
|
||
}
|
||
|
||
const impactLabels = Object.fromEntries(businessImpactControls.map(theme => [theme.id, theme.label]));
|
||
|
||
export function DashboardBusinessImpact({ distribution, loading, onSelect }: {
|
||
distribution?: ReturnType<typeof managementFindingDistribution>; loading: boolean; onSelect?: (themeId: string) => void;
|
||
}) {
|
||
const titleId = useId();
|
||
const max = Math.max(1, ...(distribution?.themes.map(theme => theme.count) ?? []));
|
||
return <section className="client-business-impact" aria-labelledby={titleId}>
|
||
<h3 id={titleId}>Possible Business Impact</h3>
|
||
{distribution ? <div className="client-business-impact-grid" role="list">
|
||
{distribution.themes.map(theme => <div role="listitem" key={theme.id} data-theme={theme.id}>
|
||
<button type="button" className="client-business-impact-column" disabled={!onSelect || theme.count === 0}
|
||
onClick={() => onSelect?.(theme.id)} aria-label={`${impactLabels[theme.id]}: ${theme.count}`}>
|
||
<strong>{theme.count.toLocaleString('en-US')}</strong>
|
||
<span className="client-business-impact-track" aria-hidden="true"><i style={shareStyle(share(theme.count, max))} /></span>
|
||
<span className="client-business-impact-label">{impactLabels[theme.id]}</span>
|
||
</button>
|
||
</div>)}
|
||
</div> : <p role="status">{loading ? 'Loading Business Impact…' : 'Business Impact is unavailable for this assessment.'}</p>}
|
||
</section>;
|
||
}
|
||
|
||
export interface DashboardNativeMetrics {
|
||
pingCastleRisk?: number;
|
||
purpleKnightPosture?: number;
|
||
purpleKnightIoes?: number;
|
||
benchmarkScore?: number;
|
||
assessedHost?: string;
|
||
domainCount?: number;
|
||
gpoCount?: number;
|
||
}
|
||
|
||
function nativeMeta(parts: Array<string | undefined>): string {
|
||
return parts.filter(Boolean).join(' · ');
|
||
}
|
||
|
||
/**
|
||
* Jede Quellenzeile nennt zuerst ihren vergleichbaren ASSESSOR-Score. Die
|
||
* werkzeugeigene Zahl steht daneben und immer mit ihrem Namen - eine
|
||
* PingCastle-Risikozahl (hoch ist schlecht) darf nie als Score gelesen werden.
|
||
*/
|
||
function DashboardSourceListItem({ source, native, onClick }: { source: DashboardSourceScore; native: DashboardNativeMetrics; onClick?(): void }) {
|
||
const normalized = source.tool.label.toLocaleLowerCase('en-US').replace(/[^a-z0-9]+/g, '');
|
||
const kind = normalized.includes('pingcastle') ? 'pingcastle' : normalized.includes('purple') ? 'purple' : normalized.includes('maester') ? 'maester' : normalized.includes('cis') ? 'cis' : normalized.includes('gpo') ? 'gpo' : 'other';
|
||
const score = source.score;
|
||
const tone = score === undefined ? 'unknown' : score < 50 ? 'critical' : score < 75 ? 'medium' : 'passed';
|
||
const assessorScore = { value: score === undefined ? '—' : Math.round(score), unit: '/100 ASSESSOR' };
|
||
const nativeScans = source.rating?.scans.filter(scan => scan.nativeValue !== undefined) ?? [];
|
||
const exactNative = nativeScans.length === 1 ? nativeScans[0].nativeValue : undefined;
|
||
const scoreMethod = source.reevaluationRequired && !source.assessed ? 'Re-evaluation required'
|
||
: source.reviewRequired && !source.assessed ? 'Review required'
|
||
: source.rating?.methods.length === 1
|
||
? ({ native: 'Native score', 'inverted-native': '100 − native risk', 'pass-rate': 'Pass rate', unavailable: 'Not scoreable' })[source.rating.methods[0]]
|
||
: source.rating ? 'Combined scan methods' : 'Pass rate';
|
||
const domains = native.domainCount === undefined ? undefined : `${native.domainCount} ${native.domainCount === 1 ? 'domain' : 'domains'}`;
|
||
const presentation = kind === 'pingcastle'
|
||
? { name: 'PingCastle', glyph: 'scan' as const, ...assessorScore,
|
||
meta: nativeMeta([exactNative === undefined ? undefined : `PingCastle risk ${exactNative}/100`, domains]) || 'Domain assessment' }
|
||
: kind === 'purple' ? { name: 'Purple Knight', glyph: 'shield' as const, ...assessorScore,
|
||
meta: nativeMeta([
|
||
exactNative === undefined ? undefined : `Posture ${exactNative}/100`,
|
||
`${source.deviations} deviations`
|
||
]) || 'Identity indicators' }
|
||
: kind === 'maester' ? { name: 'Maester', glyph: 'cloud' as const, ...assessorScore,
|
||
meta: `${source.passed.toLocaleString('en-US')} / ${source.assessed.toLocaleString('en-US')} tests passed` }
|
||
: kind === 'cis' ? { name: 'CIS-CAT', glyph: 'clipboard' as const, ...assessorScore,
|
||
meta: nativeMeta([
|
||
exactNative === undefined ? undefined : `Benchmark score ${exactNative}`,
|
||
`${source.assessed.toLocaleString('en-US')} controls`
|
||
]) }
|
||
: kind === 'gpo' ? { name: 'GPO Analysis', glyph: 'domain' as const, ...assessorScore,
|
||
meta: nativeMeta([source.reevaluationRequired ? `${source.reevaluationRequired.toLocaleString('en-US')} saved comparisons`
|
||
: source.reviewRequired && !source.assessed ? `${source.reviewRequired.toLocaleString('en-US')} comparisons require review`
|
||
: `${source.deviations.toLocaleString('en-US')} baseline deviations`, `${native.gpoCount ?? '—'} GPOs`]) }
|
||
: { name: source.tool.label, glyph: 'policy' as const, ...assessorScore, meta: `${source.assessed.toLocaleString('en-US')} controls assessed` };
|
||
const content = <><DashboardGlyph name={presentation.glyph}/><span>{presentation.name}</span><strong>{presentation.value}<small>{presentation.unit}</small></strong><em>{scoreMethod} · {presentation.meta}{source.rating?.issues.length ? ' · Provisional' : ''}</em></>;
|
||
return <li>{onClick
|
||
? <button className={`client-source-${kind} tone-${tone}`} type="button" onClick={onClick}>{content}</button>
|
||
: <div className={`client-source-${kind} tone-${tone}`}>{content}</div>}
|
||
</li>;
|
||
}
|
||
|
||
const NO_RECORDS: FindingRecord[] = [];
|
||
|
||
/**
|
||
* Auswertung der geladenen Ergebnisse fuer die Client Info.
|
||
*
|
||
* Ein Mandantenblatt traegt hier mehrere tausend Datensaetze. Die Ansicht
|
||
* braucht davon Quellen-, Domain- und Prioritaetssichten gleichzeitig; jede
|
||
* einzeln berechnet las dieselben Datensaetze erneut und baute je Domain den
|
||
* Klassifikationstext neu auf. Bei acht Domains, fuenf Quellen und vierzehn
|
||
* Punkteberechnungen waren das mehrere hunderttausend Zugriffe - und zwar bei
|
||
* jedem Rendern, also auch bei jeder Zustandsaenderung der Anwendung.
|
||
*
|
||
* Deshalb entsteht die gesamte Ableitung hier in einem Durchlauf und nur dann
|
||
* neu, wenn sich Daten, Profil, Reviews, Facetten oder Quellenauswahl aendern.
|
||
* Zustand, Schweregrad und Domainzuordnung je Datensatz werden dabei einmal
|
||
* bestimmt und anschliessend nur noch nachgeschlagen.
|
||
*/
|
||
type DashboardAnalysis = ReturnType<typeof dashboardAnalysis>;
|
||
|
||
/**
|
||
* Die Auswertung ueberlebt den Ansichtswechsel. Ein `useMemo` gilt nur fuer die
|
||
* Lebensdauer der Komponente; Client Info wird beim Wechsel abgebaut und beim
|
||
* Zurueckkehren neu aufgebaut, sodass dieselbe Rechnung sonst bei jedem Besuch
|
||
* erneut liefe.
|
||
*/
|
||
const analysisCache = new WeakMap<object, {
|
||
dataset: ImportedDataset;
|
||
profile: ImportProfile;
|
||
reviews: Record<string, Review>;
|
||
sourceFilter?: DynamicFacetFilter;
|
||
value: DashboardAnalysis;
|
||
}>();
|
||
|
||
/**
|
||
* Facetten und Auswertung entstehen gemeinsam und werden gemeinsam behalten.
|
||
*
|
||
* Die Facettenliste wird bei jedem Aufbau der Ansicht neu erzeugt; sie taugt
|
||
* deshalb nicht als Schluessel eines ansichtsuebergreifenden Zwischenspeichers.
|
||
* Massgeblich sind die Eingaben, aus denen beides entsteht: Blatt, Datensatz,
|
||
* Profil und Reviews.
|
||
*/
|
||
function useDashboardAnalysis(
|
||
activeSheet: ReturnType<typeof useApp>['activeSheet'],
|
||
dataset: ImportedDataset | null | undefined,
|
||
activeProfile: ImportProfile,
|
||
reviews: Record<string, Review>,
|
||
sourceFilter?: DynamicFacetFilter
|
||
) {
|
||
return useMemo(() => {
|
||
const cached = activeSheet && dataset ? analysisCache.get(activeSheet) : undefined;
|
||
if (cached && cached.dataset === dataset && cached.profile === activeProfile && cached.reviews === reviews && cached.sourceFilter === sourceFilter) {
|
||
return cached.value;
|
||
}
|
||
const facets = activeSheet ? deriveDynamicFacets(activeSheet, dataset, activeProfile, reviews) : [];
|
||
const value = dashboardAnalysis(activeSheet, activeProfile, reviews, facets, sourceFilter, dataset?.securityScoring);
|
||
if (activeSheet && dataset) analysisCache.set(activeSheet, { dataset, profile: activeProfile, reviews, sourceFilter, value });
|
||
return value;
|
||
}, [activeProfile, activeSheet, dataset, reviews, sourceFilter]);
|
||
}
|
||
|
||
export function dashboardAnalysis(
|
||
activeSheet: ReturnType<typeof useApp>['activeSheet'],
|
||
activeProfile: ImportProfile,
|
||
reviews: Record<string, Review>,
|
||
facets: DynamicFacet[],
|
||
sourceFilter?: DynamicFacetFilter,
|
||
securityScoring?: SecurityScoreContext
|
||
) {
|
||
{
|
||
const sourceFacet = facets.find(facet =>
|
||
/^(?:quelle|source):\s*scan$/i.test(facet.label.trim()) || facet.source === 'provenance');
|
||
const sourceFilters = sourceFacet && sourceFilter?.values?.length ? { [sourceFacet.id]: sourceFilter } : undefined;
|
||
const allRecords = activeSheet?.analysis.records ?? NO_RECORDS;
|
||
const records = sourceFilters ? allRecords.filter(record => matchesDynamicFacets(record, [sourceFacet!], sourceFilters, reviews)) : allRecords;
|
||
const countedFacets = sourceFilters ? recountFacetsAgainstActiveFilters({
|
||
facets, records, filters: {}, reviews, statusFilter: 'all',
|
||
matchesStatus: (record, status) => activeProfile.matchesStatus(record, status)
|
||
}) : facets;
|
||
const statusFacet = countedFacets.find(facet => facet.source === 'profile-status');
|
||
const severityFacet = countedFacets.find(facet => facet.severityScale);
|
||
|
||
const states = new Map<FindingRecord, DashboardResultState>();
|
||
records.forEach(record => {
|
||
const state = dashboardResultState(record, activeProfile);
|
||
states.set(record, state);
|
||
});
|
||
const domainBuckets = dashboardDomainAssignment(records, activeSheet?.toolId);
|
||
const scoreOf = (subset: FindingRecord[]) =>
|
||
dashboardScoreFromStates(subset.map(record => states.get(record) ?? 'unknown'), subset);
|
||
|
||
const tools = dashboardRiskTools(sourceFacet?.values ?? []);
|
||
const sourceScores: DashboardSourceScore[] = dashboardSecuritySources(tools.map(tool => {
|
||
const toolRecords = sourceFacet
|
||
? records.filter(record => matchesDynamicFacets(record, [sourceFacet], { [sourceFacet.id]: { values: tool.sourceValues } }, reviews))
|
||
: NO_RECORDS;
|
||
return { tool, records: toolRecords, ...scoreOf(toolRecords) };
|
||
}).filter(source => source.records.length > 0), securityScoring, Boolean(activeSheet?.isProjectSheet && !sourceFilters));
|
||
const domains: DashboardDomainScore[] = securityDomainCatalog.map(domain => {
|
||
const domainRecords = domainBuckets.get(domain.id) ?? NO_RECORDS;
|
||
const score = scoreOf(domainRecords);
|
||
return {
|
||
id: domain.id,
|
||
label: domain.label,
|
||
glyph: domain.glyph,
|
||
// The clickable population is exactly the number displayed. Passed
|
||
// and contextual GPO states remain visible only as secondary counts.
|
||
records: domainRecords.filter(record => states.get(record) === 'deviation'),
|
||
reviewRecords: domainRecords.filter(record => ['review-required', 'reevaluation-required'].includes(states.get(record) ?? '')),
|
||
...score
|
||
};
|
||
});
|
||
const totalResult = dashboardScoreFromStates(records.map(record => states.get(record) ?? 'unknown'), records);
|
||
const gapRecordIds = records.filter(record => !['passed', 'deviation', 'not-applicable'].includes(states.get(record) ?? 'unknown'))
|
||
.map(record => record.id);
|
||
|
||
return {
|
||
total: records.length,
|
||
records,
|
||
statusFacet,
|
||
severityFacet,
|
||
sourceFacet,
|
||
tools,
|
||
reports: sourceFacet?.values.length ?? 0,
|
||
statusCount: (value: string) => statusFacet?.values.find(item => item.value === value)?.count ?? 0,
|
||
sourceScores,
|
||
domains,
|
||
totalResult,
|
||
gapRecordIds,
|
||
overallScore: dashboardOverallScore(sourceScores)
|
||
};
|
||
}
|
||
}
|
||
|
||
export function DashboardPanel({ onOpenWork }: DashboardPanelProps) {
|
||
const app = useApp();
|
||
const { dataset, activeSheet, activeProfile, reviews, setStatusFilter, setFacetFilter, setPathFindingIds, resetFilters, setView } = app;
|
||
const [clientInfoView, setClientInfoView] = useState<'overview' | 'defender' | 'compliance' | 'ai'>('overview');
|
||
const hasDefender = useMemo(() => Boolean(dataset && defenderModelForDataset(dataset).available), [dataset]);
|
||
const pureDefender = dataset?.profileId === DEFENDER_FOR_IOT_PROFILE_ID;
|
||
const environment = useEnvironmentSnapshot(app.targetProjectId, app.activeScanRunIds);
|
||
const aiOverview = useAiOverview(app.targetProjectId, app.activeAssessmentIds ?? [], app.activeScanRunIds ?? []);
|
||
const showAiOverview = aiOverview.available && Boolean(aiOverview.loading || aiOverview.response?.generationEnabled || aiOverview.response?.run);
|
||
// Compliance Engine tab: shown while the engine is on and the backend can deliver the timeslot's ISO overview.
|
||
const showCompliance = useEnginePreferences().compliance && app.backendStatus === 'connected' && Boolean(app.targetProjectId)
|
||
&& Boolean(app.activeAssessmentIds?.length || app.activeScanRunIds?.length);
|
||
|
||
useEffect(() => {
|
||
if (pureDefender && clientInfoView === 'overview') setClientInfoView('defender');
|
||
else if (clientInfoView === 'defender' && !hasDefender) setClientInfoView('overview');
|
||
else if (clientInfoView === 'ai' && !showAiOverview) setClientInfoView(pureDefender ? 'defender' : 'overview');
|
||
else if (clientInfoView === 'compliance' && !showCompliance) setClientInfoView(pureDefender ? 'defender' : 'overview');
|
||
}, [clientInfoView, hasDefender, pureDefender, showAiOverview, showCompliance]);
|
||
const gpoInventory = useGpoInventory(app.targetProjectId);
|
||
const sourceFilter = app.facetFilters['provenance:source-scan'];
|
||
const analysis = useDashboardAnalysis(activeSheet, dataset, activeProfile, reviews, sourceFilter);
|
||
const engines = useEnginePreferences();
|
||
const impactContext = useAssessmentContext(Boolean(dataset) && engines.report, app.targetProjectId, app.activeAssessmentIds, app.activeScanRunIds, dataset);
|
||
const impactDistribution = useMemo(() => {
|
||
if (impactContext.status !== 'ready') return undefined;
|
||
const keys = managementWorkspaceDeviationKeys(analysis.records, record => dashboardResultState(record, activeProfile) === 'deviation');
|
||
return managementFindingDistribution(publishContextReport(impactContext.context), analysis.totalResult.deviations, keys);
|
||
}, [impactContext.status === 'ready' ? impactContext.context : undefined, analysis, activeProfile]);
|
||
const [reportExporting, setReportExporting] = useState<'management' | 'technical' | null>(null);
|
||
const [reportError, setReportError] = useState<string | null>(null);
|
||
const reportScopeKey = JSON.stringify([app.targetProjectId, app.activeScanRunIds, app.activeAssessmentIds]);
|
||
const reportScopeRef = useRef(reportScopeKey);
|
||
reportScopeRef.current = reportScopeKey;
|
||
const exportReport = async (kind: 'management' | 'technical') => {
|
||
const projectId = app.targetProjectId;
|
||
if (!readEnginePreferences().report || !projectId || !dataset || !hasSelectedTimeslotScope(app.activeScanRunIds, app.activeAssessmentIds)) return;
|
||
if (kind === 'management' && !readEnginePreferences().context) {
|
||
setReportError('Management Report requires Context Engine. Enable it in Engines and try again.');
|
||
return;
|
||
}
|
||
const requestedScope = reportScopeKey;
|
||
setReportExporting(kind);
|
||
setReportError(null);
|
||
try {
|
||
// The separate Report Engine renders and archives on the server; the download is the archived file.
|
||
const run = await assessorApi.createReportRun(projectId, {
|
||
kind, scanRunIds: app.activeScanRunIds, assessmentIds: app.activeAssessmentIds
|
||
});
|
||
if (!readEnginePreferences().report || (kind === 'management' && !readEnginePreferences().context) || reportScopeRef.current !== requestedScope) return;
|
||
downloadText(browserDownloadAdapter, run.fileName, run.html, 'text/html;charset=utf-8');
|
||
} catch (reason) {
|
||
if (readEnginePreferences().report) setReportError(reason instanceof Error ? reason.message : 'Report could not be generated.');
|
||
} finally {
|
||
setReportExporting(null);
|
||
}
|
||
};
|
||
const environmentDetail = useMemo(() => dashboardEnvironmentDetail(environment.snapshot), [environment.snapshot]);
|
||
const domainInformation = useMemo(() => dashboardDomainInformation(environment.snapshot), [environment.snapshot]);
|
||
const [scoringOpen, setScoringOpen] = useState(false);
|
||
// Ersatzzaehlung aus Findingtexten, solange die Verarbeitungsliste der
|
||
// Sicherungen noch nicht geantwortet hat. Sie liest jeden Feldwert der
|
||
// GPO-Datensaetze und darf deshalb nicht bei jedem Rendern erneut laufen.
|
||
const derivedGpoCount = useMemo(
|
||
() => dashboardGpoCount(analysis.sourceScores.find(source => /gpo/i.test(source.tool.label))?.records ?? []),
|
||
[analysis]
|
||
);
|
||
|
||
if (!dataset || !activeSheet) return app.targetProjectId
|
||
? <div className="dashboard-page panel-is-loading"><h2>Client Info</h2><PanelLoadingState message={app.status}/></div>
|
||
: <EmptyWorkspace />;
|
||
const jump = (apply: () => void, preserveSource = true) => {
|
||
resetFilters();
|
||
if (preserveSource && sourceFilter && analysis.sourceFacet) setFacetFilter(analysis.sourceFacet.id, sourceFilter);
|
||
app.setActionOnly(false);
|
||
setStatusFilter('deviation');
|
||
apply();
|
||
setView('columns');
|
||
onOpenWork();
|
||
};
|
||
const clientInfoViews: Array<readonly ['overview' | 'defender' | 'compliance' | 'ai', string]> = [
|
||
...(pureDefender ? [] : [['overview', 'Overview'] as const]),
|
||
...(hasDefender ? [['defender', 'Microsoft Defender for IoT'] as const] : []),
|
||
...(showCompliance ? [['compliance', 'Compliance'] as const] : []),
|
||
...(showAiOverview ? [['ai', 'AI Overview'] as const] : [])
|
||
];
|
||
const clientInfoTabs = clientInfoViews.length > 1 ? <nav className="client-info-tabs" aria-label="Client Info views">
|
||
{clientInfoViews.map(([id, title]) =>
|
||
<button type="button" key={id} aria-pressed={clientInfoView === id} onClick={() => setClientInfoView(id)}>{title}</button>)}
|
||
</nav> : undefined;
|
||
if (clientInfoView === 'ai' && showAiOverview) return <AiOverviewPanel controller={aiOverview} navigation={clientInfoTabs} />;
|
||
// Compliance Engine tab: one tile per framework; a Results window opens a control or theme in Work.
|
||
if (clientInfoView === 'compliance' && showCompliance) return <ClientIsoControls navigation={clientInfoTabs} projectId={app.targetProjectId} assessmentIds={app.activeAssessmentIds} scanRunIds={app.activeScanRunIds}
|
||
backendStatus={app.backendStatus} onOpenControl={controlId => jump(() => {
|
||
const targetSheet = sourceScanNavigationSheet(dataset, activeSheet);
|
||
if (targetSheet) {
|
||
app.setActiveSheet(targetSheet.id);
|
||
setStatusFilter('deviation');
|
||
}
|
||
setFacetFilter(ISO_CONTROL_FACET_ID, { values: [controlId] });
|
||
}, false)}
|
||
onOpenTheme={theme => jump(() => {
|
||
const targetSheet = sourceScanNavigationSheet(dataset, activeSheet);
|
||
if (targetSheet) {
|
||
app.setActiveSheet(targetSheet.id);
|
||
setStatusFilter('deviation');
|
||
}
|
||
setFacetFilter(ISO_THEME_FACET_ID, { values: [theme.toLowerCase()] });
|
||
}, false)}/>;
|
||
if (hasDefender && (clientInfoView === 'defender' || pureDefender)) {
|
||
return <DefenderVulnerabilityPanel dataset={dataset} navigation={clientInfoTabs} reviews={reviews}
|
||
scopedRiskAcceptances={app.scopedRiskAcceptances} activeScanRunIds={app.activeScanRunIds}
|
||
onScopedRiskAcceptance={app.backendStatus === 'connected' ? write => app.recordScopedRiskAcceptance(write) : undefined} />;
|
||
}
|
||
|
||
const {
|
||
total, statusFacet, severityFacet, sourceFacet, tools, reports,
|
||
sourceScores, domains, totalResult, gapRecordIds, overallScore
|
||
} = analysis;
|
||
const deviations = totalResult.deviations;
|
||
const assessmentCoverage = totalResult.coverage;
|
||
const nativePingCastleRisk = dashboardObservationNumber(environment.snapshot, 'risk.domain')
|
||
?? dashboardObservationNumber(environment.snapshot, 'risk.global');
|
||
const userAccounts = dashboardEnvironmentTableNumber(environment.snapshot, 'sectionUserInformation', /^nb user accounts$/i);
|
||
const computerAccounts = dashboardEnvironmentTableNumber(environment.snapshot, 'sectionComputerInformation', /^nb computer accounts$/i);
|
||
const domainControllers = dashboardObservationNumber(environment.snapshot, 'domain.controller_count');
|
||
// Give repeated overview facts one home; Results retains the full category.
|
||
// Compare only the same named fact, never unrelated facts with equal values.
|
||
const countOverview = new Map([
|
||
['Domains', numberFact(dashboardEnvironmentDomainCount(environment.snapshot))],
|
||
['Domain controllers', numberFact(domainControllers)],
|
||
['User accounts', numberFact(userAccounts)],
|
||
['Computer objects', numberFact(computerAccounts)]
|
||
]);
|
||
const alreadyInData = (item: DashboardFact) => countOverview.get(item.label) === item.value;
|
||
const domainOverview = domainInformation.filter(item => !alreadyInData(item));
|
||
const inventoryOverview = environmentDetail.inventory.filter(item =>
|
||
!alreadyInData(item) && !domainOverview.some(shown => shown.label === item.label && shown.value === item.value));
|
||
const maesterSource = sourceScores.find(source => /maester/i.test(source.tool.label));
|
||
const entraTenants = dashboardEntraTenantCount(environment.snapshot)
|
||
?? dashboardObservationNumber(environment.snapshot, 'entra.configuration_detected')
|
||
?? (maesterSource?.records.length ? 1 : undefined);
|
||
const gpoCount = gpoInventory.gpoCount ?? derivedGpoCount;
|
||
const nativeMetrics: DashboardNativeMetrics = {
|
||
pingCastleRisk: nativePingCastleRisk,
|
||
purpleKnightPosture: dashboardObservationNumber(environment.snapshot, 'purple_knight.ad.security_posture_score'),
|
||
purpleKnightIoes: dashboardObservationNumber(environment.snapshot, 'purple_knight.ad.ioes_found'),
|
||
benchmarkScore: dashboardObservationNumber(environment.snapshot, 'benchmark.score'),
|
||
assessedHost: observationFor(environment.snapshot, 'host.name') ? observationFact(environment.snapshot, 'host.name') : undefined,
|
||
domainCount: dashboardEnvironmentDomainCount(environment.snapshot),
|
||
gpoCount
|
||
};
|
||
const sourceOrder = ['pingcastle', 'purpleknight', 'maester', 'cis-cat', 'gpos'];
|
||
const orderedSourceScores = sourceScores.filter(source =>
|
||
source.tool.label.toLocaleLowerCase('en-US').replace(/[^a-z0-9]+/g, '') !== 'tabulardata'
|
||
).sort((left, right) => {
|
||
const rank = (label: string) => {
|
||
const normalized = label.toLocaleLowerCase('en-US').replace(/[^a-z0-9]+/g, '');
|
||
const index = sourceOrder.findIndex(item => normalized.includes(item.replace(/[^a-z0-9]+/g, '')));
|
||
return index < 0 ? sourceOrder.length : index;
|
||
};
|
||
return rank(left.tool.label) - rank(right.tool.label) || left.tool.label.localeCompare(right.tool.label);
|
||
});
|
||
const displayedDomains = domains;
|
||
const domainDistributionTotal = displayedDomains.reduce((sum, domain) => sum + dashboardDomainAmount(domain), 0);
|
||
const overallTone = dashboardScoreTone(overallScore);
|
||
|
||
const sourceOverviewContent = (dismissResults: () => void) => orderedSourceScores.length ? <ul>{orderedSourceScores.map(source =>
|
||
<DashboardSourceListItem key={source.tool.id} source={source} native={nativeMetrics}
|
||
onClick={sourceFacet && source.tool.sourceValues.length ? () => { dismissResults(); jump(() => {
|
||
setFacetFilter(sourceFacet.id, { values: source.tool.sourceValues });
|
||
// The source drilldown includes saved GPO review/conflict/duplicate rows.
|
||
if (/gpo/i.test(source.tool.label)) setStatusFilter('all');
|
||
}); } : undefined} />
|
||
)}</ul> : <p className="dashboard-empty">No source results available.</p>;
|
||
const selectedSource = sourceFilter?.values?.length
|
||
? tools.find(tool => tool.sourceValues.length === sourceFilter.values!.length
|
||
&& tool.sourceValues.every(value => sourceFilter.values!.includes(value)))?.id ?? 'selection'
|
||
: 'all';
|
||
|
||
return (
|
||
<div className="dashboard-page client-command-center">
|
||
{clientInfoTabs}
|
||
<header className="client-overview-header">
|
||
<div className="client-overview-identity"><span>Client:</span><strong>{dataset.fileName}</strong></div>
|
||
<div className="client-overview-assessment"><span>Assessment:</span><WorkTimeslotControl /></div>
|
||
<label className="client-overview-source"><span>Source:</span><select value={selectedSource} onChange={event => {
|
||
const tool = tools.find(candidate => candidate.id === event.currentTarget.value);
|
||
if (!sourceFacet) return;
|
||
const targetSheet = sourceScanNavigationSheet(dataset, activeSheet);
|
||
if (targetSheet) app.setActiveSheet(targetSheet.id);
|
||
setFacetFilter(sourceFacet.id, tool ? { values: tool.sourceValues } : null);
|
||
}}><option value="all">Scan · All</option>{selectedSource === 'selection' && <option value="selection">Scan · Selection</option>}{tools.map(tool => <option key={tool.id} value={tool.id}>{tool.label}</option>)}</select></label>
|
||
<div className="client-report-actions" aria-label="Assessment reports">
|
||
<button className="btn client-information-action" type="button" disabled={!engines.report || Boolean(reportExporting) || !app.targetProjectId || !hasSelectedTimeslotScope(app.activeScanRunIds, app.activeAssessmentIds)} onClick={() => void exportReport('management')}>
|
||
{reportExporting === 'management' ? 'Creating Management Report…' : 'Management Report'}
|
||
</button>
|
||
<button className="btn client-information-action" type="button" disabled={!engines.report || Boolean(reportExporting) || !app.targetProjectId || !hasSelectedTimeslotScope(app.activeScanRunIds, app.activeAssessmentIds)} onClick={() => void exportReport('technical')}>
|
||
{reportExporting === 'technical' ? 'Creating Technical Report…' : 'Technical Report'}
|
||
</button>
|
||
</div>
|
||
{!engines.report && <span>Report Engine is off</span>}
|
||
{engines.report && reportError && <p className="client-report-error" role="alert">{reportError}</p>}
|
||
</header>
|
||
|
||
<div className="client-overview-main">
|
||
<section className={`client-posture tone-${overallTone}`} aria-label="Security posture">
|
||
<DashboardPostureConnectors nodeCount={displayedDomains.length} />
|
||
<section className="client-overall" aria-labelledby="client-overall-heading">
|
||
<div className="client-overall-head">
|
||
<h2 id="client-overall-heading">Overall Score</h2>
|
||
<EnvironmentResultsButton title="Overall Score">{dismissResults => <>
|
||
<div className="client-environment-sources">{sourceOverviewContent(dismissResults)}</div>
|
||
{environmentDetail.restricted.length > 0 && <p className="client-environment-restricted">Restricted by the source tool, not empty: {environmentDetail.restricted.join(', ')}</p>}
|
||
<button className="btn" type="button" onClick={() => { dismissResults(); setScoringOpen(true); }}>Scoring methodology ↗</button>
|
||
<DashboardSourceResults sources={orderedSourceScores} snapshot={environment.snapshot}/>
|
||
</>}</EnvironmentResultsButton>
|
||
</div>
|
||
<DashboardOverallRing score={overallScore} onClick={() => jump(() => undefined, false)} />
|
||
<div className="client-overall-coverage">
|
||
<span><strong>Assessment Coverage</strong><b>{assessmentCoverage === undefined ? '—' : `${assessmentCoverage}%`}</b></span>
|
||
<i><u style={shareStyle(assessmentCoverage ?? 0)} /></i>
|
||
<small>{totalResult.assessed.toLocaleString('en-US')} / {totalResult.eligible.toLocaleString('en-US')} eligible controls assessed</small>
|
||
</div>
|
||
</section>
|
||
|
||
<section className="client-domains" aria-labelledby="client-domains-heading">
|
||
<header><h2 id="client-domains-heading">Security Domains</h2></header>
|
||
<ul>{displayedDomains.map(domain => <DashboardDomainRow key={domain.id} domain={domain} distributionTotal={domainDistributionTotal} onClick={domain.records.length
|
||
? () => jump(() => { setFacetFilter(SECURITY_DOMAIN_FACET_ID, { values: [domain.id] }); })
|
||
: domain.assessed === 0 && domain.reviewRecords.length ? () => jump(() => {
|
||
setStatusFilter('all');
|
||
setPathFindingIds(domain.reviewRecords.map(record => record.id));
|
||
}) : undefined} />)}</ul>
|
||
{engines.report && engines.context && <DashboardBusinessImpact distribution={impactDistribution} loading={impactContext.status === 'loading'}
|
||
onSelect={themeId => jump(() => {
|
||
const contextFilter = app.facetFilters[CONTEXT_CLUSTER_FACET_ID];
|
||
if (contextFilter) setFacetFilter(CONTEXT_CLUSTER_FACET_ID, contextFilter);
|
||
setFacetFilter(BUSINESS_IMPACT_FACET_ID, { values: [themeId] });
|
||
})} />}
|
||
</section>
|
||
</section>
|
||
|
||
<aside className="client-priority" aria-labelledby="client-priority-heading">
|
||
<section className="client-priority-findings">
|
||
<header><h2 id="client-priority-heading">Data</h2><DonutMetricCard className="datahub-reference-knob client-priority-total" label="Deviations" value={deviations} percentage={share(deviations, total)} tone="deviation" onClick={() => jump(() => undefined)} /></header>
|
||
<div className="client-priority-context" aria-label="Additional result states">
|
||
{([
|
||
['passed', 'Passed', totalResult.passed],
|
||
['not-configured', 'Not configured', totalResult.notConfigured],
|
||
['duplicates', 'Duplicates', totalResult.duplicates],
|
||
['conflicts', 'Conflicts', totalResult.conflicts]
|
||
] as const).map(([status, label, count]) => <button type="button" key={status} disabled={count === 0} onClick={() => jump(() => setStatusFilter(status))}><strong>{count.toLocaleString('en-US')}</strong><span>{label}</span></button>)}
|
||
<button type="button" disabled={totalResult.unassessed === 0} onClick={() => jump(() => {
|
||
setStatusFilter('all');
|
||
setPathFindingIds(gapRecordIds);
|
||
})}><strong>{totalResult.unassessed.toLocaleString('en-US')}</strong><span>Gaps</span></button>
|
||
</div>
|
||
<section className="client-data-facts" aria-label="Environment facts">
|
||
<DashboardTopFact value={dashboardEnvironmentDomainCount(environment.snapshot)} label="AD Domains" glyph="domain" snapshot={environment.snapshot} evidenceGroup="ad-domains" />
|
||
<DashboardTopFact value={entraTenants} label="Entra Tenant" glyph="cloud" snapshot={environment.snapshot} evidenceGroup="entra-tenants" fallback={!dashboardEntraTenantCount(environment.snapshot) && !dashboardObservationNumber(environment.snapshot, 'entra.configuration_detected') && maesterSource?.records.length ? "Tenant presence inferred from loaded Maester results; no retained tenant identity is available." : undefined} />
|
||
<DashboardTopFact value={userAccounts} label="User Accounts" glyph="user" snapshot={environment.snapshot} evidenceGroup="user-accounts" />
|
||
<DashboardTopFact value={computerAccounts} label="Computer Objects" glyph="computer" snapshot={environment.snapshot} evidenceGroup="computer-objects" />
|
||
<DashboardTopFact value={domainControllers} label="Domain Controllers" glyph="server" snapshot={environment.snapshot} evidenceGroup="domain-controllers" />
|
||
<DashboardTopFact value={gpoCount} label="GPOs" glyph="policy" snapshot={environment.snapshot} evidenceGroup="gpos" backupRecords={gpoInventory.records} fallback={gpoCount !== undefined && !gpoInventory.records?.length ? "Count derived from GPO identifiers in the loaded findings; no processed backup inventory is available." : undefined} />
|
||
</section>
|
||
</section>
|
||
<section className="client-domain-information" aria-labelledby="client-domain-information-heading">
|
||
<header className="client-env-group-header">
|
||
<h2 id="client-domain-information-heading">Domain Information</h2>
|
||
<EnvironmentResultsButton title="Domain Information">
|
||
<DashboardFactList items={domainInformation}/>
|
||
<EnvironmentEvidenceTables snapshot={environment.snapshot} group="directory"/>
|
||
</EnvironmentResultsButton>
|
||
</header>
|
||
<dl>{domainOverview.map(item => <div key={item.label}><dt>{item.label}</dt><dd>{item.value}</dd></div>)}</dl>
|
||
</section>
|
||
</aside>
|
||
</div>
|
||
|
||
<ClientContextHeatmap onOpenCluster={clusterId => jump(() => {
|
||
const targetSheet = sourceScanNavigationSheet(dataset, activeSheet);
|
||
if (targetSheet) {
|
||
app.setActiveSheet(targetSheet.id);
|
||
setStatusFilter('deviation');
|
||
}
|
||
setFacetFilter(CONTEXT_CLUSTER_FACET_ID, { values: [clusterId] });
|
||
}, false)} />
|
||
|
||
{scoringOpen && <ScoringMethodology sources={sourceScores} context={dataset.securityScoring} onClose={() => setScoringOpen(false)} />}
|
||
|
||
<div className="client-environment-grid" aria-label="Environment overview">
|
||
<DashboardFactGroup title="Resilience" items={environmentDetail.resilience} glyph="shield" snapshot={environment.snapshot} evidenceGroup="resilience" />
|
||
<DashboardFactGroup title="Inventory" items={environmentDetail.inventory} overviewItems={inventoryOverview} glyph="computer" snapshot={environment.snapshot} evidenceGroup="inventory" />
|
||
<DashboardFactGroup title="Password policy" items={environmentDetail.passwordPolicy} glyph="lock" snapshot={environment.snapshot} evidenceGroup="password-policy" />
|
||
<DashboardFactGroup title="Certificates & PKI" items={environmentDetail.certificates} glyph="policy" snapshot={environment.snapshot} evidenceGroup="certificates" />
|
||
<OperatingSystemInventory groups={environmentDetail.operatingSystems} icon={<DashboardGlyph name="computer"/>} snapshot={environment.snapshot} />
|
||
<CloudInformationTile snapshot={environment.snapshot} icon={<DashboardGlyph name="cloud"/>}
|
||
key={`cloud-information:${app.targetProjectId}:${app.activeAssessmentIds?.join(',')}:${app.activeScanRunIds?.join(',')}`}
|
||
scope={app.targetProjectId ? { projectId: app.targetProjectId, assessmentId: app.activeAssessmentIds?.length === 1 ? app.activeAssessmentIds[0] : undefined,
|
||
scanRunIds: app.activeScanRunIds } : undefined} />
|
||
</div>
|
||
|
||
<RiskRegister dataset={dataset} reviews={reviews} scopedRiskAcceptances={app.scopedRiskAcceptances} onOpenFinding={recordId => jump(() => setPathFindingIds([recordId]))} />
|
||
</div>
|
||
);
|
||
}
|