Assessor_2.0/src/features/dashboard/DashboardPanel.tsx

1531 lines
87 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from 'react';
import { AssessorApiClient } from '../../api/assessor-api-client';
import { OperatingSystemInventory } from './OperatingSystemInventory';
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 { 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 { compareSecurityScores, 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 { 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';
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';
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 roundedPercentage = Math.min(100, Math.max(0, Math.round(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" />
<circle className="dashboard-status-ring-progress" cx="60" cy="60" r="48" pathLength="100" strokeDasharray={`${roundedPercentage} ${100 - roundedPercentage}`} />
</svg>
<span><span className={`dashboard-donut-primary${value.toLocaleString('en-US').length >= 5 ? ' is-compact' : ''}`}><strong>{value.toLocaleString('en-US')}</strong><small>{roundedPercentage}%</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')}, ${roundedPercentage}%`} 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 scanner context…</p>}
{failed && <p className="dashboard-empty">Scanner context 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 <circle
className={`dashboard-risk-donut-segment severity-${severity.value}`}
cx="60"
cy="60"
r="48"
key={severity.value}
pathLength="100"
strokeDasharray={`${percentage} ${100 - percentage}`}
strokeDashoffset={-offset}
/>;
})}
</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[];
}
export interface DashboardFact {
label: string;
value: 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;
}
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 dashboardMaesterTenantCount(snapshot?: EnvironmentSnapshot): number | undefined {
const tenants = new Set((snapshot?.observations ?? [])
.filter(observation => observation.tool.id === 'maester' && observation.property === 'entra.tenant_id' && observation.state === 'observed')
.map(observation => displayValue(observation).trim()).filter(Boolean));
return tenants.size || undefined;
}
export function dashboardEnvironmentDetail(snapshot?: EnvironmentSnapshot): DashboardEnvironmentDetail {
const userTable = (header: RegExp) => dashboardEnvironmentTableNumber(snapshot, 'sectionUserInformation', header);
const categoryScores = (snapshot?.observations ?? [])
.filter(observation => observation.property.startsWith('purple_knight.ad.category_score.'))
.map(observation => ({ label: observation.label, value: numberFact(optionalObservationNumber(snapshot, observation.property)) }));
return {
operatingSystems: operatingSystemInventory(snapshot),
directory: [
{ label: 'Domain', value: observationFact(snapshot, 'domain.fqdn', 'Domain') },
{ label: 'NetBIOS name', value: observationFact(snapshot, 'domain.netbios_name', 'Netbios Name') },
{ label: 'Forest', value: observationFact(snapshot, 'forest.name') },
{ 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: observationFact(snapshot, 'entra.configuration_detected', 'Entra ID configuration detected') }
],
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 posture', value: numberFact(optionalObservationNumber(snapshot, 'purple_knight.ad.security_posture_score')) },
{ label: 'Purple Knight IOEs', value: numberFact(optionalObservationNumber(snapshot, 'purple_knight.ad.ioes_found')) },
{ label: 'Purple Knight evaluated', value: numberFact(optionalObservationNumber(snapshot, 'purple_knight.ad.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[] {
return [
{ label: 'Domain', value: observationFact(snapshot, 'domain.fqdn', 'Domain') },
{ label: 'NetBIOS name', value: observationFact(snapshot, 'domain.netbios_name', 'Netbios Name') },
{ label: 'Forest', value: observationFact(snapshot, 'forest.name') },
{ label: 'Domains', value: numberFact(dashboardEnvironmentDomainCount(snapshot)) },
{ label: 'Domain functional level', value: observationFact(snapshot, 'domain.functional_level', 'Domain Functional Level') },
{ label: 'Domain controllers', value: observationFact(snapshot, 'domain.controller_count', 'DC count') },
{ 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: observationFact(snapshot, 'entra.configuration_detected', 'Entra ID configuration detected') }
];
}
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>;
}
/**
* Faktgruppe der Umgebungsdetails. Rein visuell: eine Gruppenregel und darunter
* ein flieszendes Raster gleich breiter Faktzellen. Ohne nebeneinanderstehende
* Karten kann keine hohe Gruppe ihre Nachbarn in die Hoehe strecken.
*
* Meldet die Quelle in einer Gruppe keinen einzigen Wert, bleibt sie als eine
* Zeile mit ausdruecklicher Aussage stehen und bleibt aufklappbar. Es wird kein
* Wert entfernt, zusammengefasst oder abgeleitet; Unbekanntes bleibt der
* Gedankenstrich der Datenschicht.
*/
function DashboardFactGroup({ title, items, glyph }: { title: string; items: DashboardFact[]; glyph: DashboardGlyphName }) {
const unreported = items.length > 0 && items.every(item => item.value === UNKNOWN_FACT);
const [expanded, setExpanded] = useState(false);
const listId = useId();
return <section className="client-env-group">
<h3 className="client-env-group-title">
<DashboardGlyph name={glyph} />
<span>{title}</span>
{unreported && <>
<span className="client-env-group-note">not reported by source</span>
<button className="client-env-group-toggle" type="button" aria-expanded={expanded} aria-controls={listId}
aria-label={`${expanded ? 'Hide' : 'Show'} ${title} fields`} title={`${expanded ? 'Hide' : 'Show'} fields`}
onClick={() => setExpanded(value => !value)}>
<TriangleIcon direction={expanded ? 'down' : 'right'} />
</button>
</>}
</h3>
{(!unreported || expanded) && <dl className="client-env-facts" id={listId}>
{items.map(item => <div key={item.label}><dt>{item.label}</dt><dd>{item.value}</dd></div>)}
</dl>}
</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 <circle className={`client-info-donut-segment tone-${segment.tone}`} cx="60" cy="60" r="48" pathLength="100" key={segment.label}
strokeDasharray={`${percentage} ${100 - percentage}`} strokeDashoffset={-segmentOffset} />;
})}
</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>;
}
type DashboardGlyphName = 'domain' | 'cloud' | 'user' | 'computer' | 'server' | 'policy' | 'shield' | 'lock' | 'code' | 'chart' | 'clipboard' | 'folder' | 'scan';
interface DashboardSourceScore {
tool: DashboardRiskTool;
records: FindingRecord[];
passed: number;
deviations: number;
assessed: number;
reviewRequired?: number;
reevaluationRequired?: number;
score?: number;
weight?: number;
rating?: SecuritySourceScore;
}
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 } {
const [inventory, setInventory] = useState<{ gpoCount?: number; snapshots?: number }>({});
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 } : {});
})
.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;
}
function DashboardGlyph({ name }: { name: DashboardGlyphName }) {
const content = name === 'user' ? <><circle cx="12" cy="8" r="4"/><path d="M4 21c0-4 3-7 8-7s8 3 8 7"/></>
: name === 'computer' ? <><rect x="3" y="4" width="18" height="13"/><path d="M8 21h8M12 17v4"/></>
: name === 'server' ? <><rect x="4" y="3" width="16" height="7"/><rect x="4" y="14" width="16" height="7"/><path d="M8 6h.01M8 17h.01M12 6h5M12 17h5"/></>
: name === 'policy' || name === 'clipboard' ? <><path d="M7 3h8l4 4v14H7z"/><path d="M15 3v5h5M10 12h6M10 16h6"/></>
: name === 'cloud' ? <path d="M6 19h12a4 4 0 0 0 0-8 6 6 0 0 0-11.5-1.5A4.8 4.8 0 0 0 6 19z"/>
: name === 'shield' || name === 'scan' ? <><path d="M12 2l8 3v6c0 5-3.4 8.5-8 11-4.6-2.5-8-6-8-11V5z"/><path d="M9 12l2 2 4-5"/></>
: name === 'lock' ? <><rect x="5" y="10" width="14" height="11"/><path d="M8 10V7a4 4 0 0 1 8 0v3M12 14v3"/></>
: name === 'code' ? <><rect x="3" y="4" width="18" height="16"/><path d="M9 9l-3 3 3 3M15 9l3 3-3 3M13 8l-2 8"/></>
: name === 'chart' ? <><path d="M3 20h18M5 17l4-5 3 3 6-9"/><path d="M18 6h3v3"/></>
: name === 'folder' ? <path d="M3 6h7l2 2h9v11H3z"/>
: <><path d="M4 21V7h6v14M14 21V3h6v18M2 21h20"/><path d="M7 10h.01M7 14h.01M17 7h.01M17 11h.01"/></>;
return <svg className="client-glyph" viewBox="0 0 24 24" aria-hidden="true">{content}</svg>;
}
/**
* 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 tone = dashboardScoreTone(score);
const label = `Overall Score: ${score === undefined ? 'No data' : Math.round(score)}`;
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 && <circle className="client-overall-ring-value" cx="60" cy="60" r="48" pathLength="100" strokeDasharray={`${value} ${100 - value}`} />}
</svg>
<span><span className="client-overall-ring-number"><strong>{score === undefined ? '—' : Math.round(score)}</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>;
}
function DashboardTopFact({ value, label, glyph }: { value?: number | string; label: string; glyph: DashboardGlyphName }) {
return <article className="client-data-fact"><DashboardGlyph name={glyph}/><span><strong>{value ?? '—'}</strong><small>{label}</small></span></article>;
}
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>;
}
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);
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 unclassifiedRecords = domainBuckets.get('unclassified') ?? NO_RECORDS;
const unclassifiedScore = scoreOf(unclassifiedRecords);
const unclassifiedDomain: DashboardDomainScore | undefined = unclassifiedRecords.length ? {
id: 'unclassified',
label: 'Unclassified',
glyph: 'scan',
records: unclassifiedRecords.filter(record => states.get(record) === 'deviation'),
reviewRecords: unclassifiedRecords.filter(record => ['review-required', 'reevaluation-required'].includes(states.get(record) ?? '')),
...unclassifiedScore
} : undefined;
const totalResult = dashboardScoreFromStates(records.map(record => states.get(record) ?? 'unknown'), records);
return {
total: records.length,
statusFacet,
severityFacet,
sourceFacet,
tools,
reports: sourceFacet?.values.length ?? 0,
statusCount: (value: string) => statusFacet?.values.find(item => item.value === value)?.count ?? 0,
sourceScores,
domains,
unclassifiedDomain,
totalResult,
overallScore: dashboardOverallScore(sourceScores)
};
}
}
export function DashboardPanel({ onOpenWork }: DashboardPanelProps) {
const app = useApp();
const { dataset, activeSheet, activeProfile, reviews, setStatusFilter, setFacetFilter, setPathFindingIds, resetFilters, setView } = app;
const environment = useEnvironmentSnapshot(app.targetProjectId, app.activeScanRunIds);
const gpoInventory = useGpoInventory(app.targetProjectId);
const sourceFilter = app.facetFilters['provenance:source-scan'];
const analysis = useDashboardAnalysis(activeSheet, dataset, activeProfile, reviews, sourceFilter);
const environmentDetail = useMemo(() => dashboardEnvironmentDetail(environment.snapshot), [environment.snapshot]);
const domainInformation = useMemo(() => dashboardDomainInformation(environment.snapshot), [environment.snapshot]);
const [scoringOpen, setScoringOpen] = useState(false);
const [restrictedOpen, setRestrictedOpen] = useState(false);
const overallScoreRef = useRef<HTMLElement | null>(null);
const environmentDetailRef = useRef<HTMLDetailsElement | null>(null);
const scoreComparison = useMemo(() => dataset?.securityScoring?.baseline
? compareSecurityScores(dataset.securityScoring.baseline, dataset.securityScoring.scans) : undefined, [dataset?.securityScoring]);
// 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]
);
useLayoutEffect(() => {
const syncEnvironmentTileHeight = () => {
const overallHeight = overallScoreRef.current?.getBoundingClientRect().height;
if (!overallHeight || !environmentDetailRef.current) return;
environmentDetailRef.current.style.setProperty('--client-environment-tile-height', `${overallHeight * 0.75}px`);
};
syncEnvironmentTileHeight();
window.addEventListener('resize', syncEnvironmentTileHeight);
const observer = overallScoreRef.current && typeof ResizeObserver !== 'undefined'
? new ResizeObserver(syncEnvironmentTileHeight) : undefined;
if (overallScoreRef.current && observer) observer.observe(overallScoreRef.current);
return () => {
window.removeEventListener('resize', syncEnvironmentTileHeight);
observer?.disconnect();
};
}, [dataset, activeSheet]);
const [clientInfoView, setClientInfoView] = useState<'overview' | 'defender'>('overview');
const hasDefender = useMemo(() => Boolean(dataset && defenderModelForDataset(dataset).available), [dataset]);
useEffect(() => {
if (!hasDefender) setClientInfoView('overview');
}, [hasDefender]);
if (!dataset || !activeSheet) return app.targetProjectId
? <div className="dashboard-page panel-is-loading"><h2>Client Info</h2><PanelLoadingState message={app.status}/></div>
: <EmptyWorkspace />;
if (dataset.profileId === DEFENDER_FOR_IOT_PROFILE_ID) return <DefenderVulnerabilityPanel dataset={dataset} />;
const clientInfoTabs = hasDefender ? <nav className="client-info-tabs" aria-label="Client Info views">
{([['overview', 'Overview'], ['defender', 'Microsoft Defender for IoT']] as const).map(([id, title]) =>
<button type="button" key={id} aria-pressed={clientInfoView === id} onClick={() => setClientInfoView(id)}>{title}</button>)}
</nav> : undefined;
if (clientInfoView === 'defender' && hasDefender) {
return <DefenderVulnerabilityPanel dataset={dataset} navigation={clientInfoTabs} />;
}
const {
total, statusFacet, severityFacet, sourceFacet, tools, reports, statusCount,
sourceScores, domains, unclassifiedDomain, totalResult, 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');
const maesterSource = sourceScores.find(source => /maester/i.test(source.tool.label));
const entraTenants = dashboardMaesterTenantCount(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 = unclassifiedDomain?.deviations ? [...domains, unclassifiedDomain] : domains;
const domainDistributionTotal = displayedDomains.reduce((sum, domain) => sum + dashboardDomainAmount(domain), 0);
const overallTone = dashboardScoreTone(overallScore);
const jump = (apply: () => void, preserveSource = true) => {
resetFilters();
if (preserveSource && sourceFilter && sourceFacet) setFacetFilter(sourceFacet.id, sourceFilter);
app.setActionOnly(false);
setStatusFilter('deviation');
apply();
setView('columns');
onOpenWork();
};
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>
</header>
<div className="client-overview-main">
<section className={`client-posture tone-${overallTone}`} aria-label="Security posture">
<DashboardPostureConnectors nodeCount={displayedDomains.length} />
<section ref={overallScoreRef} className="client-overall" aria-labelledby="client-overall-heading">
<h2 id="client-overall-heading">Overall Score</h2>
<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>
</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>)}
</div>
<section className="client-data-facts" aria-label="Environment facts">
<DashboardTopFact value={dashboardEnvironmentDomainCount(environment.snapshot)} label="AD Domains" glyph="domain" />
<DashboardTopFact value={entraTenants} label="Entra Tenant" glyph="cloud" />
<DashboardTopFact value={userAccounts} label="User Accounts" glyph="user" />
<DashboardTopFact value={computerAccounts} label="Computer Objects" glyph="computer" />
<DashboardTopFact value={domainControllers} label="Domain Controllers" glyph="server" />
<DashboardTopFact value={gpoCount} label="GPOs" glyph="policy" />
</section>
</section>
<section className="client-domain-information" aria-labelledby="client-domain-information-heading">
<h2 id="client-domain-information-heading">Domain Information</h2>
<dl>{domainInformation.map(item => <div key={item.label}><dt>{item.label}</dt><dd>{item.value}</dd></div>)}</dl>
</section>
<section className="client-assessment-gaps">
<h2>Assessment Gaps</h2><strong>{totalResult.unassessed.toLocaleString('en-US')}</strong><span>Unassessed controls</span>
{totalResult.reevaluationRequired > 0 && <div className="client-gpo-readiness" role="status">
<h3>GPO re-evaluation required</h3>
<p>{totalResult.reevaluationRequired.toLocaleString('en-US')} saved comparisons await re-evaluation. Open the GPO import in Data Hub and select Re-evaluate to calculate current results.</p>
<button className="btn" type="button" onClick={() => jump(() => setStatusFilter('all'))}>View saved results</button>
</div>}
<dl><div><dt><DashboardGlyph name="clipboard"/>Manual review</dt><dd>{(totalResult.reviewRequired + statusCount('manual')).toLocaleString('en-US')}</dd></div>
{totalResult.reevaluationRequired > 0 && <div><dt><DashboardGlyph name="clipboard"/>GPO re-evaluation</dt><dd>{totalResult.reevaluationRequired.toLocaleString('en-US')}</dd></div>}
<div><dt><DashboardGlyph name="folder"/>Missing evidence</dt><dd>{Math.max(0, totalResult.missingEvidence - statusCount('manual')).toLocaleString('en-US')}</dd></div>
{scoreComparison && <div><dt><DashboardGlyph name="clipboard"/>Awaiting revalidation</dt><dd>{dataset.securityScoring?.historyUnavailable ? '—' : scoreComparison.awaitingRevalidation.reduce((sum, item) => sum + item.occurrenceIds.length, 0).toLocaleString('en-US')}</dd></div>}
</dl>
<button className="btn client-overview-action" type="button" onClick={() => jump(() => setStatusFilter(statusCount('not-run') ? 'not-run' : 'all'))}>Review gaps →</button>
</section>
</aside>
</div>
{scoringOpen && <ScoringMethodology sources={sourceScores} context={dataset.securityScoring} onClose={() => setScoringOpen(false)} />}
<details ref={environmentDetailRef} className="client-environment-detail" aria-labelledby="client-environment-detail-heading">
<summary>
<TriangleIcon direction="right" />
<h2 id="client-environment-detail-heading">Environment Detail</h2>
<span className="client-env-meta">
<span>{environment.failed ? 'Scanner context could not be loaded' : environment.snapshot
? `${environment.snapshot.observations.length.toLocaleString('en-US')} observations · ${environment.snapshot.sections.length.toLocaleString('en-US')} sections`
: 'Loading scanner context…'}</span>
{environmentDetail.restricted.length > 0 && <span>{environmentDetail.restricted.length.toLocaleString('en-US')} restricted sections</span>}
</span>
</summary>
{environmentDetail.restricted.length > 0 && <button className="btn client-env-restricted-toggle" type="button"
aria-expanded={restrictedOpen} aria-controls="client-environment-restricted"
onClick={() => setRestrictedOpen(value => !value)}>
{environmentDetail.restricted.length.toLocaleString('en-US')} restricted sections
<TriangleIcon direction={restrictedOpen ? 'down' : 'right'} />
</button>}
{restrictedOpen && environmentDetail.restricted.length > 0
&& <p className="client-environment-restricted" id="client-environment-restricted">
Restricted by the source tool, not empty: {environmentDetail.restricted.join(', ')}
</p>}
<div className="client-environment-grid">
<section className="client-environment-sources" aria-labelledby="client-environment-sources-heading">
<header><h3 id="client-environment-sources-heading"><DashboardGlyph name="scan"/><span>Source Overview</span></h3><button className="btn" type="button" onClick={() => setScoringOpen(true)}>Scoring methodology ↗</button></header>
{orderedSourceScores.length ? <ul>{orderedSourceScores.map(source => <DashboardSourceListItem key={source.tool.id} source={source} native={nativeMetrics} onClick={sourceFacet && source.tool.sourceValues.length ? () => jump(() => {
setFacetFilter(sourceFacet.id, { values: source.tool.sourceValues });
// A source drilldown opens its saved results, including GPO review,
// conflict and duplicate rows. Explicit deviation actions stay filtered.
if (/gpo/i.test(source.tool.label)) setStatusFilter('all');
}) : undefined} />)}</ul> : <p className="dashboard-empty">No source results available.</p>}
</section>
<DashboardFactGroup title="Directory" items={environmentDetail.directory} glyph="domain" />
<DashboardFactGroup title="Resilience" items={environmentDetail.resilience} glyph="shield" />
<DashboardFactGroup title="Inventory" items={environmentDetail.inventory} glyph="computer" />
<DashboardFactGroup title="Password policy" items={environmentDetail.passwordPolicy} glyph="lock" />
<DashboardFactGroup title="Certificates & PKI" items={environmentDetail.certificates} glyph="policy" />
<DashboardFactGroup title="Source metrics" items={environmentDetail.toolContext} glyph="chart" />
<OperatingSystemInventory groups={environmentDetail.operatingSystems} icon={<DashboardGlyph name="computer"/>} />
</div>
</details>
<RiskRegister dataset={dataset} reviews={reviews} onOpenFinding={recordId => jump(() => setPathFindingIds([recordId]))} />
</div>
);
}