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

1627 lines
102 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 { readEnginePreferences, useEnginePreferences } from '../../app/engine-preferences';
import { ClientInfoTabs } from './ClientInfoTabs';
import { ClientWorkPackagesPanel } from './ClientWorkPackagesPanel';
import { captureSecurityView } from './capture-security-view';
import { profileForSheet } from '../project-import/workspace-profile';
import { ClientSecurityPanel, type SecurityFindingGroup } from './ClientSecurityPanel';
import { managementCategoryTexts } from '../../reporting/management-report-texts';
import { ClientContextHeatmap } from '../context-layer/ContextHeatmap';
import { ClientIsoControls } from '../../compliance/ui/ClientIsoControls';
import { BSI_PRACTICE_FACET_ID, BSI_REQUIREMENT_FACET_ID, ISO_CONTROL_FACET_ID, ISO_MAPPING_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 DashboardGlyphName } 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 {
clientInfoOverview, cloudInformationTileFacts, dashboardDomainInformation, dashboardEntraTenantCount, dashboardEnvironmentDetail, dashboardEnvironmentDomainCount,
dashboardEnvironmentTableNumber, dashboardGpoCount, dashboardObservationNumber, displayValue, numberFact, observationFact, observationFor,
sectionFor, operatingSystemOverview, UNKNOWN_FACT, type DashboardFact
} from './client-info-facts';
// Former home of the Client Info fact builders; existing imports keep working.
export { dashboardDomainInformation, dashboardEntraTenantCount, dashboardEnvironmentDetail, type DashboardEnvironmentDetail, type DashboardFact } from './client-info-facts';
import { useApp } from '../../app/AppProvider';
import { EmptyWorkspace } from '../../components/EmptyWorkspace/EmptyWorkspace';
import { DonutMetricCard } from '../../components/DonutMetricCard/DonutMetricCard';
import { assessedResultPercentages } from '../../components/DonutMetricCard/assessed-result-percentages';
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, ScoringMethodologyContent } from './ScoringMethodology';
import { RiskRegister, riskRegisterEntries } 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, dashboardGpoGapCounts, type DashboardResultState } from './dashboard-result-state';
import { AssessmentFactsExplanation } from './AssessmentFactsExplanation';
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 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 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 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' }
]
};
}
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;
}
/**
* 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;
}
/**
* 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>}
{!['computer-objects', 'user-accounts', 'gpos'].includes(evidenceGroup) && !!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);
// Client Info Security reads the complete selected assessment, never Work/Graph filters or a source sub-sheet.
const securitySheet = dataset?.sheets.find(sheet => sheet.isProjectSheet) ?? activeSheet;
const securityProfile = securitySheet === activeSheet ? activeProfile : profileForSheet(dataset, securitySheet);
const securityAnalysis = useDashboardAnalysis(securitySheet, dataset, securityProfile, reviews);
const engines = useEnginePreferences();
const impactContext = useAssessmentContext(Boolean(dataset), app.targetProjectId, app.activeAssessmentIds, app.activeScanRunIds, dataset);
const securityView = useMemo(() => {
const records = securityAnalysis.records.filter(record => dashboardResultState(record, securityProfile) === 'deviation');
const buckets = dashboardDomainAssignment(records, securitySheet?.toolId);
const domainCounts = securityDomainCatalog.map(domain => ({ id: domain.id, label: domain.label, count: buckets.get(domain.id)?.length ?? 0 }));
const domainIds = new Map([...buckets].flatMap(([id, members]) => members.map(record => [record.id, id] as const)));
const countsFor = (members: FindingRecord[]) => domainCounts.map(domain => ({ ...domain, count: members.filter(record => domainIds.get(record.id) === domain.id).length })).filter(domain => domain.count > 0);
const assigned = new Set<string>();
const keys = new Map(records.map(record => [record.id, managementWorkspaceDeviationKeys([record], () => true)[0]]));
const groups: SecurityFindingGroup[] = impactContext.status === 'ready' ? publishContextReport(impactContext.context).clusters.map((cluster, index) => {
const members = new Set(cluster.findingKeys);
const matches = records.filter(record => !assigned.has(record.id) && members.has(keys.get(record.id)!));
matches.forEach(record => assigned.add(record.id));
const text = engines.report ? managementCategoryTexts[cluster.id] : undefined;
return { id: cluster.id, number: String(index + 1).padStart(2, '0'), label: cluster.label, severity: cluster.severity,
readingName: text?.name, situation: text?.situation ?? cluster.description, lever: text?.lever,
records: matches, domains: countsFor(matches) };
}) : [];
const remaining = records.filter(record => !assigned.has(record.id));
if (remaining.length) groups.push({ id: 'unassigned', label: impactContext.status === 'ready' ? 'Unassigned findings' : 'All findings', severity: 'unknown',
situation: impactContext.status === 'ready' ? 'These findings have no matching published category assignment in the current scope.' : undefined,
records: remaining, domains: countsFor(remaining) });
return { groups, domains: domainCounts, total: records.length };
}, [securityAnalysis, securityProfile, securitySheet?.toolId, impactContext.status === 'ready' ? impactContext.context : impactContext.status, engines.report]);
const impactDistribution = useMemo(() => {
if (!engines.report || 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, engines.report]);
const [reportExporting, setReportExporting] = useState<'management' | 'technical' | 'security' | 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<'overview' | 'security' | null>(null);
// 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]
);
const securityDerivedGpoCount = useMemo(
() => dashboardGpoCount(securityAnalysis.sourceScores.find(source => /gpo/i.test(source.tool.label))?.records ?? []),
[securityAnalysis]
);
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)}
onOpenBsiRequirement={requirementId => jump(() => {
const targetSheet = sourceScanNavigationSheet(dataset, activeSheet);
if (targetSheet) {
app.setActiveSheet(targetSheet.id);
setStatusFilter('deviation');
}
setFacetFilter(BSI_REQUIREMENT_FACET_ID, { values: [requirementId.toLowerCase()] });
}, false)}
onOpenBsiPractice={practiceId => jump(() => {
const targetSheet = sourceScanNavigationSheet(dataset, activeSheet);
if (targetSheet) {
app.setActiveSheet(targetSheet.id);
setStatusFilter('deviation');
}
setFacetFilter(BSI_PRACTICE_FACET_ID, { values: [practiceId.toLowerCase()] });
}, false)}
onOpenMappingState={state => jump(() => {
const targetSheet = sourceScanNavigationSheet(dataset, activeSheet);
if (targetSheet) {
app.setActiveSheet(targetSheet.id);
setStatusFilter('deviation');
}
setFacetFilter(ISO_MAPPING_FACET_ID, { values: [state] });
}, false)}
// Client and timeslot like the Overview; no Source control, because the tab always covers the whole timeslot.
context={<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>
</header>}/>;
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 assessmentPercentages = assessedResultPercentages(totalResult.passed, deviations);
const assessmentCoverage = totalResult.coverage;
const nativePingCastleRisk = dashboardObservationNumber(environment.snapshot, 'risk.domain')
?? dashboardObservationNumber(environment.snapshot, 'risk.global');
const maesterSource = sourceScores.find(source => /maester/i.test(source.tool.label));
const gpoCount = gpoInventory.gpoCount ?? derivedGpoCount;
// The overview facts come from the same React-free builder the Technical Report reads.
const overview = clientInfoOverview(environment.snapshot, { gpoCount, inferredEntraTenant: Boolean(maesterSource?.records.length) });
const dataFact = (label: string) => overview.data.find(item => item.label === label)?.value;
const userAccounts = dataFact('User Accounts');
const computerAccounts = dataFact('Computer Objects');
const domainControllers = dataFact('Domain Controllers');
const entraTenants = dataFact('Entra Tenant');
const domainOverview = overview.domainInformation;
const inventoryOverview = overview.inventory;
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 securityResult = securityAnalysis.totalResult;
const securityPercentages = assessedResultPercentages(securityResult.passed, securityResult.deviations);
const securityMaesterSource = securityAnalysis.sourceScores.find(source => /maester/i.test(source.tool.label));
const securityGpoCount = gpoInventory.gpoCount ?? securityDerivedGpoCount;
const securityOverview = clientInfoOverview(environment.snapshot, { gpoCount: securityGpoCount, inferredEntraTenant: Boolean(securityMaesterSource?.records.length) });
const securityEntraTenants = securityOverview.data.find(item => item.label === 'Entra Tenant')?.value;
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';
const securityPanelView = <ClientSecurityPanel key={reportScopeKey}
dataset={dataset} sheetId={securitySheet!.id} toolId={securitySheet?.toolId} contextStatus={impactContext.status} {...securityView}
actions={<><button className="btn client-information-action" type="button" disabled={!engines.report || Boolean(reportExporting) || !app.targetProjectId || !hasSelectedTimeslotScope(app.activeScanRunIds, app.activeAssessmentIds) || !environment.snapshot || impactContext.status === 'loading'} onClick={() => void exportSecurityReport()}>
{reportExporting === 'security' ? 'Creating Security Export…' : 'HTML Security Export'}</button>
{reportError && <span role="alert">{reportError}</span>}</>}
metrics={<div className="client-security-metrics">
<section className="client-security-score"><h3>Overall Score</h3><strong>{securityAnalysis.overallScore === undefined ? '—' : securityAnalysis.overallScore > 0 && securityAnalysis.overallScore < .5 ? '<1' : Math.round(securityAnalysis.overallScore)}<small>/100</small></strong>
<div className={`client-security-score-bar tone-${dashboardScoreTone(securityAnalysis.overallScore)}`}><i style={shareStyle(securityAnalysis.overallScore ?? 0)}/></div>
<span>ASSESSOR</span><button type="button" className="btn" onClick={() => setScoringOpen('security')}>Scoring methodology ↗</button>
</section>
<section className="client-security-remediation"><h3>Remediation</h3><strong>{securityResult.deviations.toLocaleString('en-US')}</strong>
<span>Deviations · {securityPercentages.deviation === null ? '—' : `${Math.round(securityPercentages.deviation)}%`} of assessed checks</span>
<div className="client-security-share-bar"><i style={shareStyle(securityPercentages.deviation ?? 0)}/></div>
</section>
<EnvironmentResultsButton title="Assessment facts" trigger="Assessment facts"><table className="client-security-totals" aria-label="Assessment facts"><thead><tr><th scope="col">Metric</th><th scope="col">Value</th></tr></thead><tbody>{[
['Assessed', securityResult.assessed], ['Passed', securityResult.passed], ['Gaps', securityResult.unassessed],
['Assessment Coverage', securityResult.coverage === undefined ? undefined : `${securityResult.coverage}%`],
['Conflicts', securityResult.conflicts], ['Duplicates', securityResult.duplicates],
['Categories with deviations', engines.context && impactContext.status === 'ready' ? securityView.groups.filter(group => group.records.length).length : undefined],
['Base severity Critical categories', engines.context && impactContext.status === 'ready' ? securityView.groups.filter(group => group.records.length && group.severity === 'critical').length : undefined],
['Accepted risks (valid)', riskRegisterEntries(dataset, reviews, undefined, app.scopedRiskAcceptances).filter(entry => entry.state === 'Accepted').length]
].map(([label, value]) => <tr key={label}><th scope="row">{label}</th><td>{typeof value === 'number' ? value.toLocaleString('en-US') : value ?? '—'}</td></tr>)}</tbody></table>
<AssessmentFactsExplanation counts={securityResult} gpo={dashboardGpoGapCounts(securityAnalysis.records, securityProfile, securitySheet?.toolId ?? dataset.profileId)}/>
</EnvironmentResultsButton>
</div>}
overview={<section className="client-security-hardfacts" aria-label="Security overview and environment facts">
<div className="client-security-hardfacts-content">
<section className="client-data-facts client-security-inventory" aria-label="Environment facts">
<DashboardTopFact value={dashboardEnvironmentDomainCount(environment.snapshot)} label="AD Domains" glyph="domain" snapshot={environment.snapshot} evidenceGroup="ad-domains" />
<DashboardTopFact value={securityEntraTenants} label="Entra Tenant" glyph="cloud" snapshot={environment.snapshot} evidenceGroup="entra-tenants" fallback={!dashboardEntraTenantCount(environment.snapshot) && !dashboardObservationNumber(environment.snapshot, 'entra.configuration_detected') && securityMaesterSource?.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={securityGpoCount} label="GPOs" glyph="policy" snapshot={environment.snapshot} evidenceGroup="gpos" backupRecords={gpoInventory.records} fallback={securityGpoCount !== undefined && !gpoInventory.records?.length ? "Count derived from GPO identifiers in the loaded findings; no processed backup inventory is available." : undefined} />
</section>
<div className="client-security-fact-groups">{([
[
['Domain Information', domainOverview, 'directory', domainOverview],
['Resilience', environmentDetail.resilience, 'resilience', environmentDetail.resilience.slice(0, 4)],
['Inventory', inventoryOverview, 'inventory', inventoryOverview.slice(0, 4)]
], [
['Password policy', environmentDetail.passwordPolicy, 'password-policy', environmentDetail.passwordPolicy.slice(0, 4)],
['Operating systems', environmentDetail.operatingSystems.flatMap(group => [
{ label: group.label, value: numberFact(group.total) }, ...group.entries.map(entry => ({ label: entry.name, value: numberFact(entry.count) }))
]), 'operating-systems', operatingSystemOverview(environmentDetail.operatingSystems)],
['Cloud Information', cloudInformationTileFacts(environment.snapshot).map(fact => ({ label: fact.label, value: fact.values.join(', ') })), 'all']
]
] as Array<Array<[string, DashboardFact[], EnvironmentEvidenceGroup, DashboardFact[]?]>>).map((column, index) => <div className="client-security-fact-column" key={index}>{column.map(([title, facts, evidenceGroup, summary = facts]) => <section key={title}>
<header className="client-env-group-header"><h3>{title}</h3><EnvironmentResultsButton title={title}>
<DashboardFactList items={facts}/><EnvironmentEvidenceTables snapshot={environment.snapshot} group={evidenceGroup}/>
</EnvironmentResultsButton></header><DashboardFactList compact items={summary}/>
</section>)}{index === 1 && <>
{environment.failed && <p role="alert" className="muted">Environment facts could not be loaded.</p>}
</>}</div>)}
</div>
</div>
</section>}/>;
async function exportSecurityReport() {
if (!readEnginePreferences().report || reportExporting) return;
const scope = reportScopeKey;
setReportExporting('security'); setReportError(null);
try {
const width = document.querySelector('.client-security-panel')?.getBoundingClientRect().width ?? 0;
const captured = captureSecurityView(securityPanelView, width,
<ScoringMethodologyContent sources={securityAnalysis.sourceScores} context={dataset!.securityScoring}/>);
const [{ buildSecurityViewReportHtml }, { REPORT_ENGINE_VERSION }] = await Promise.all([
import('../../reporting/security-view-report'), import('../../reporting/report-run')
]);
const health = await assessorApi.health();
const [project, history] = await Promise.all([
assessorApi.getProject(app.targetProjectId!), assessorApi.listProjectAssessmentHistory(app.targetProjectId!)
]);
const context = impactContext.status === 'ready' ? publishContextReport(impactContext.context) : undefined;
if (context && context.repositoryRevision !== health.repositoryRevision) throw new Error('The assessment data changed. Reload the client before exporting Security.');
const generatedAt = new Date().toLocaleString('de-DE', { timeZone: 'Europe/Berlin' });
const selectedAssessmentIds = new Set([
...(app.activeAssessmentIds ?? []),
...history.scanRuns.filter(run => app.activeScanRunIds?.includes(run.id)).map(run => run.assessmentId)
]);
const selectedAssessments = history.assessments.filter(assessment => selectedAssessmentIds.has(assessment.id));
const snapshot = Object.freeze({ ...captured, width,
theme: document.documentElement.dataset.theme === 'dark' ? 'dark' as const : 'light' as const,
density: document.documentElement.dataset.density,
client: project.name,
assessments: selectedAssessments
.map(assessment => assessment.name + (assessment.effectiveAt ? ' · ' + assessment.effectiveAt : '')).join(' · ') || app.activeAssessmentIds?.join(' · ') || '—',
assessmentDetails: selectedAssessments
.map(assessment => ({ name: assessment.name, effectiveAt: assessment.effectiveAt })),
distribution: { total: securityView.total, domains: securityView.domains.map(({ label, count }) => ({ label, count })),
categories: securityView.groups.map(group => ({ label: group.label, count: group.records.length })) },
generatedAt, versions: [
['Erstellt', generatedAt], ['Datenstand', 'Revision ' + health.repositoryRevision],
['Report Engine', REPORT_ENGINE_VERSION], ['Context Engine', context?.engineVersion ?? '—'],
['Context-Katalog / Vokabular / Severity', context ? context.catalogVersion + ' / ' + context.vocabularyVersion + ' / ' + context.severityCatalogVersion : '—']
] as Array<[string, string]> });
const html = buildSecurityViewReportHtml({ read: () => snapshot });
const finalHealth = await assessorApi.health();
if (finalHealth.repositoryRevision !== health.repositoryRevision) throw new Error('The assessment data changed during export. Please retry.');
if (!readEnginePreferences().report || scope !== reportScopeRef.current) return;
const safeName = snapshot.client.replace(/[^\p{L}\p{N}._-]+/gu, '-').slice(0, 100);
downloadText(browserDownloadAdapter, 'ASSESSOR-Security-' + safeName + '-' + new Date().toISOString().slice(0, 10) + '.html', html, 'text/html;charset=utf-8');
} catch (reason) {
if (readEnginePreferences().report && scope === reportScopeRef.current) setReportError(reason instanceof Error ? reason.message : 'Security export could not be created.');
} finally { setReportExporting(null); }
}
return (
<><ClientInfoTabs tabs={[{ id: 'overview', label: 'Overview', content: <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>
<div className="client-overview-tools">
<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>
<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>
{!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">
<div className="client-overview-summary">
<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('overview'); }}>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>
<div 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={assessmentPercentages.deviation} percentageBasisLabel="Of assessed checks" 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>
</div>
</div>
<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>
</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)} />
<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> }, { id: 'security', label: 'Security', content: securityPanelView }, ...(engines.remediation ? [{ id: 'work-packages', label: 'Work Packages', lazy: true,
content: <ClientWorkPackagesPanel key={reportScopeKey}/> }] : [])]} />
{scoringOpen && <ScoringMethodology sources={scoringOpen === 'security' ? securityAnalysis.sourceScores : sourceScores} context={dataset.securityScoring} onClose={() => setScoringOpen(null)} />}</>
);
}