diff --git a/AGENTS.md b/AGENTS.md index 037099d..fea4074 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -178,6 +178,7 @@ ## UI Client Info dashboard +- Explicit user correction 2026-09-26: the proposed Work-style dashboard rearrangement was rejected and fully reverted. Preserve the existing tile layout, Overall Score, domain connections and Results popups. Reduce information only by removing duplicate overview facts within the existing tiles: Domain, Forest, both functional levels and DC OS stay in Domain Information; NetBIOS, schema version, domain creation and Entra identity stay in Directory; aggregate counts stay in Data. Remove repeated counts from Directory, Domain Information and Inventory only when they match the same fact already shown elsewhere; retain differing or non-numeric source values. Equal values for distinct facts (for example Domain and Forest) are not duplicates. Directory and Inventory Results retain their complete facts and original tables. This supersedes only earlier requirements to repeat those facts in several overview tiles. - Explicit user correction 2026-09-20: the former Client Info `Priority Findings` tile is named `Data`, sits to the left of `Domain Information`, and shows the total `Deviations` value plus the separate additional-result-state row only. Do not show duplicate `Critical` or `High` summary values in that tile. The remaining deviation value occupies the full header width and is a keyboard-accessible button that opens Work with the standard `Deviation` filter. - Explicit user correction 2026-09-18 supersedes the previous Client Info visual exception: `.client-command-center` follows the same Ableton-inspired ASSESSOR language as the approved Data Hub, Work and Pathfinder. Client Info may organize its own domain content, but has no exception for palette, surfaces, tiles, typography, controls, selection, branding, spacing or chart geometry. Existing `--client-info-*` roles are legacy implementation details, not authority for a separate visual theme; reuse the shared roles when bringing this view into conformity. - Treat Client Info as one connected executive security-posture canvas, not as a collection of unrelated report cards or a spreadsheet. Its stable reading order is overall posture, security domains, Data and Domain Information, then source evidence. diff --git a/docs/IMPLEMENTATION_PLAN.md b/docs/IMPLEMENTATION_PLAN.md index c86299e..9e70d82 100644 --- a/docs/IMPLEMENTATION_PLAN.md +++ b/docs/IMPLEMENTATION_PLAN.md @@ -4,6 +4,8 @@ Referenzerkennung 2026-09-26 (Phase 13D, erster Umfang umgesetzt und verifiziert Status: lebendes Projektdokument +2026-09-26 - Phase 7 / Doppelte Client-Info-Fakten entfernt (2.8.7, umgesetzt und verifiziert): Der zuvor erprobte Work-artige Dashboardumbau wurde auf ausdruecklichen Nutzerwunsch vollstaendig zurueckgenommen. Die anschliessend beauftragte kleine Bereinigung behaelt alle bestehenden Kacheln, Positionen, Donuts, Domainverbindungen, CSS-Regeln und Results-Popups. Domain, Forest, beide Functional Levels und DC OS stehen in Domain Information; NetBIOS, Schema, Erstellungsdatum und Entra-Identitaet in Directory; Bestandszahlen bleiben in Data. Directory und Inventory zeigen identische Uebersichtsfakten nicht nochmals; ihre Results behalten saemtliche Kategorienwerte und Originaltabellen. Verglichen werden nur dieselben benannten Fakten mit gleichem Anzeigewert, nie unterschiedliche Fakten allein wegen gleicher Werte. Abweichende oder nicht numerische Angaben bleiben erhalten; unbekannte Werte bleiben unbekannt. Entscheidung: Patchversion 2.8.7 fuer die rueckwaertskompatible Darstellungsbereinigung, keine Daten-, Import-, Scoring-, API- oder Persistenzaenderung. Verifikation: 52 fokussierte Tests einschliesslich befuellter/leerer Datenlage, nicht numerischer DC-Angabe, tatsaechlicher Directory-/Inventory-Results-Klickwege, Posture-Verbindungen und UI-/API-Versionsvertrag; beide Produktionsbuilds, Design-/Local-only- und Diffpruefung bestanden. Synthetische Light-/Dark-Browserpruefung bestaetigt 14px/16px Rollen, 20px Inset und vollstaendige Results. Die gebaute CSS-Datei ist unveraendert. Grenze: unterschiedliche Darstellungen desselben Quellwertes werden vorsichtshalber nicht als identisch unterdrueckt. Phasenstatus unveraendert. + 2026-09-26 - Phase 6/7 / Scanner-Einstieg im Report, CIS-Abschnitte und Work-Zusammenfuehrung (2.8.6, umgesetzt und verifiziert): Purple-Knight-Scanlaeufe mit Umfangsnamen (`for Active Directory`, `for Entra ID`, kombiniert) loesen ueber deklarierte Profil-`runNames` wieder auf das Purple-Knight-Profil auf; zuvor fielen sie in gemischtem Work auf generic zurueck (kein Problem/Next step, generische Status-, Facetten-, Result-Linker- und Graphlogik). Maester ohne Remediation-/HelpUrl-Feld zeigt den nativen Description-Abschnitt `#### Remediation action:` deklarativ (`firstRead.nextStepSection`) als Next step; der Text davor bleibt Hintergrund, jeder Text erscheint einmal. Eingefuehrte PowerShell-Befehle werden auch nach `script ...:` und bei Beginn mit Variablenzuweisung als Befehlsblock abgesetzt; exportbedingt flachgeklopfte Befehle erhalten nur in der Anzeige Zeilen an eindeutigen Grenzen (Anweisung nach schliessender Klammer, Pipeline), ausschliesslich Leerraum aendert sich. Purple-Knight-`Likelihood of compromise` endet vor dem angehaengten nativen `References`-Abschnitt (`firstRead.impactReferenceMarker`); die Referenztitel erscheinen an der Referenzposition, verlorene Absatzumbrueche werden angezeigt. CIS-Abschnittsnamen stammen ausschliesslich aus der gespeicherten Section-Score-Tabelle des eigenen Scanlaufs: `CIS Category` zeigt Nummer und Namen in numerischer Ordnung; Work-Ueberschriften ersetzen nach Nutzerkorrektur die fuehrende Regelnummer durch den Titel des umgebenden Abschnitts, die Nummer bleibt Source Rule ID, Plaketten zeigen Nummer plus Tooltip; widerspruechliche oder fehlende Titel lassen die Nummer stehen. Ursache fuer im Dummy nicht sichtbare Titel und Referenzen: Die Zusammenfuehrung von All Records mit GPO-Ergebnissen kopierte eine manuelle Feldliste. `withRebuiltLayout` uebernimmt jetzt alle Datensatzmetadaten und ersetzt nur Layoutfelder (id, index, title, fields, filled, meta, sourceFile, sourceSheet, highlighted); ein Schutztest mit synthetischem unbekanntem Zusatzfeld sichert kuenftige Metadaten. Dummy ueber den realen Work-Ladeweg (nur Zaehlungen): 411/411 CIS-Datensaetze mit Titeln und Referenzen, 160/160 benannte CIS-Kategorien, 92/92 getrennte Purple-Knight-Referenzabschnitte. Entscheidung: Patchversion 2.8.6 fuer rueckwaertskompatible Korrekturen und Verfeinerungen ueber gespeicherte Fakten; kein Reimport, keine Schema-, Import-, Scoring- oder Review-Aenderung. Verifikation: synthetische Regressionen fuer die tatsaechlichen Pfade (gemischtes Work mit und ohne GPO, Reader, Facetten, Kacheln, Zusammenfuehrung), Versionsvertragstests, beide Produktionsbuilds; Gesamtsuite 1.608 bestanden, 14 uebersprungen, dieselben sechs vorbestehenden fachfremden Fehler in GPO-/Security-Domain-/Result-DB-/Dashboard-Tests. Risiken/Grenzen: Security-Domain-Zuordnung bleibt stichwortbasiert (gesonderte Entscheidung offen); Pathfinder und Graphansichten zeigen CIS-Titel weiterhin mit Nummer; Purple-Knight-Referenztitel ohne Trennzeichen bleiben ein Textblock. Phasenstatus unveraendert. 2026-09-26 - Phase 7 / Finding-Reader-Abstaende (2.8.4, umgesetzt und verifiziert): Die gemeinsame Abschnittsregel umfasst jetzt div und section, damit GPO-Quellen und Baseline-Vergleich denselben Tokenabstand verwenden: space-4 (16 px) vor/nach der Trennlinie, space-2 (8 px) zwischen Feldueberschrift und Text. Redundante Abschlusslinien am letzten Quellblock und letzten Reader-Feld entfernt; Problem-/Next-step-Sonderabstaende bleiben erhalten. Synthetischer permanenter Klicktest prueft den gerenderten GPO-Reader vor, nach und nach erneutem Schliessen des Baseline-Disclosures. 44 gezielte Reader-/UI-/API-Versionsvertragstests, beide Produktionsbuilds, Design-/Local-only- und Diffpruefung bestanden. Synthetische Light-/Dark-Sichtpruefung bestaetigt jeweils rund 16 px Abschnittsabstand. Patchversion fuer die gemeinsame Darstellungskorrektur; keine Daten-, Import-, Bewertungs- oder Persistenzaenderung. Risiko: Die gemeinsame Regel wirkt auch auf weitere native Reader-Abschnitte; fokussierte Reader-Regressionen decken die bestehenden Faelle ab. Phasenstatus unveraendert. diff --git a/package-lock.json b/package-lock.json index cd5a3e2..37b8286 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "assessor", - "version": "2.8.6", + "version": "2.8.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "assessor", - "version": "2.8.6", + "version": "2.8.7", "dependencies": { "@fontsource-variable/inter": "^5.3.0", "jsdom": "^29.1.1", diff --git a/package.json b/package.json index 23f3265..2b1705e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "assessor", - "version": "2.8.6", + "version": "2.8.7", "private": true, "type": "module", "scripts": { diff --git a/src/features/dashboard/DashboardPanel.tsx b/src/features/dashboard/DashboardPanel.tsx index 0c1371d..b04cbed 100644 --- a/src/features/dashboard/DashboardPanel.tsx +++ b/src/features/dashboard/DashboardPanel.tsx @@ -756,11 +756,12 @@ function DashboardFactList({ items }: { items: DashboardFact[] }) { ; } -function DashboardFactGroup({ title, items, glyph, snapshot, evidenceGroup }: { +function DashboardFactGroup({ title, items, overviewItems, glyph, snapshot, evidenceGroup }: { title: string; items: DashboardFact[]; glyph: DashboardGlyphName; + overviewItems?: DashboardFact[]; snapshot?: EnvironmentSnapshot; evidenceGroup: EnvironmentEvidenceGroup; }) { - const tileItems = title === 'Directory' ? items.slice(0, 6) : items; + const tileItems = overviewItems ?? (title === 'Directory' ? items.slice(0, 6) : items); return

@@ -772,7 +773,7 @@ function DashboardFactGroup({ title, items, glyph, snapshot, evidenceGroup }: {

- +
; } @@ -1426,6 +1427,21 @@ export function DashboardPanel({ onOpenWork }: DashboardPanelProps) { const userAccounts = dashboardEnvironmentTableNumber(environment.snapshot, 'sectionUserInformation', /^nb user accounts$/i); const computerAccounts = dashboardEnvironmentTableNumber(environment.snapshot, 'sectionComputerInformation', /^nb computer accounts$/i); const domainControllers = dashboardObservationNumber(environment.snapshot, 'domain.controller_count'); + // Give repeated overview facts one home; Results retains the full category. + // Compare only the same named fact, never unrelated facts with equal values. + const directoryDetailLabels = ['NetBIOS name', 'Schema version', 'Domain created', 'Entra ID']; + const countOverview = new Map([ + ['Domains', numberFact(dashboardEnvironmentDomainCount(environment.snapshot))], + ['Domain controllers', numberFact(domainControllers)], + ['User accounts', numberFact(userAccounts)], + ['Computer objects', numberFact(computerAccounts)] + ]); + const alreadyInData = (item: DashboardFact) => countOverview.get(item.label) === item.value; + const domainOverview = domainInformation.filter(item => !directoryDetailLabels.includes(item.label) && !alreadyInData(item)); + const directoryOverview = environmentDetail.directory.filter(item => + !alreadyInData(item) && !domainOverview.some(shown => shown.label === item.label && shown.value === item.value)); + const inventoryOverview = environmentDetail.inventory.filter(item => + !alreadyInData(item) && !domainOverview.some(shown => shown.label === item.label && shown.value === item.value)); const maesterSource = sourceScores.find(source => /maester/i.test(source.tool.label)); const entraTenants = dashboardEntraTenantCount(environment.snapshot) ?? dashboardObservationNumber(environment.snapshot, 'entra.configuration_detected') @@ -1558,7 +1574,7 @@ export function DashboardPanel({ onOpenWork }: DashboardPanelProps) {

Domain Information

-
{domainInformation.map(item =>
{item.label}
{item.value}
)}
+
{domainOverview.map(item =>
{item.label}
{item.value}
)}
@@ -1566,9 +1582,9 @@ export function DashboardPanel({ onOpenWork }: DashboardPanelProps) { {scoringOpen && setScoringOpen(false)} />}
- + - + } snapshot={environment.snapshot} /> diff --git a/tests/app/app-version.test.ts b/tests/app/app-version.test.ts index a30e967..ee10e00 100644 --- a/tests/app/app-version.test.ts +++ b/tests/app/app-version.test.ts @@ -9,7 +9,7 @@ const components = readFileSync(new URL('../../src/styles/components.css', impor describe('ASSESSOR version in the brand header', () => { it('shows the centrally maintained product version and opens About from the brand', () => { - expect(ASSESSOR_PRODUCT_VERSION).toBe('2.8.6'); + expect(ASSESSOR_PRODUCT_VERSION).toBe('2.8.7'); expect(app).toContain("import { ASSESSOR_PRODUCT_VERSION } from '../domain/assessor-release';"); expect(app).toContain('aria-label="About ASSESSOR"'); expect(app).toContain("import assessorLogo from '../assets/branding/assessor-icon-transparent.png';"); diff --git a/tests/features/client-info-facts.test.tsx b/tests/features/client-info-facts.test.tsx new file mode 100644 index 0000000..9655a16 --- /dev/null +++ b/tests/features/client-info-facts.test.tsx @@ -0,0 +1,62 @@ +// @vitest-environment jsdom +import { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { afterEach, expect, it, vi } from 'vitest'; +import { useApp } from '../../src/app/AppProvider'; +import { AssessorApiClient } from '../../src/api/assessor-api-client'; +import { DashboardPanel } from '../../src/features/dashboard/DashboardPanel'; +import { clientFactsSnapshot, clientFactsState } from '../fixtures/client-info-facts'; + +vi.mock('../../src/app/AppProvider', () => ({ useApp: vi.fn() })); +vi.mock('../../src/features/search-filter/WorkTimeslotControl', () => ({ WorkTimeslotControl: () => Synthetic timeslot })); +afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); document.body.replaceChildren(); }); + +it.each(['populated', 'unavailable', 'non-numeric count'])('shows each overview fact once in the existing tiles and preserves Results: %s', async mode => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + const snapshot = structuredClone(clientFactsSnapshot); + if (mode === 'unavailable') { snapshot.observations = []; snapshot.sections = []; } + if (mode === 'non-numeric count') { + const count = snapshot.observations.find(item => item.property === 'domain.controller_count')!; + count.originalValue = count.normalizedValue = 'Two reported controllers'; count.valueType = 'string'; + } + vi.spyOn(AssessorApiClient.prototype, 'getEnvironmentSnapshot').mockResolvedValue(snapshot); + vi.spyOn(AssessorApiClient.prototype, 'listProjectGpoBackupStatus').mockResolvedValue({ records: [] } as never); + vi.mocked(useApp).mockReturnValue(clientFactsState() as unknown as ReturnType); + const host = document.createElement('div'); document.body.append(host); const root = createRoot(host); + try { + await act(async () => root.render()); + const domain = host.querySelector('.client-domain-information')!; + const groups = [...host.querySelectorAll('.client-env-group')]; + const directory = groups.find(group => group.querySelector('h3')?.textContent === 'Directory')!; + const inventory = groups.find(group => group.querySelector('h3')?.textContent === 'Inventory')!; + const labels = (element: Element) => [...element.querySelectorAll('dt')].map(item => item.textContent); + expect(host.querySelector('.client-posture')).not.toBeNull(); + expect(host.querySelector('.client-posture-connectors')).not.toBeNull(); + expect(host.querySelector('.client-priority')).not.toBeNull(); + expect(host.querySelectorAll('.client-data-fact')).toHaveLength(6); + expect(groups).toHaveLength(5); + expect(labels(directory)).toEqual(['NetBIOS name', 'Schema version', 'Domain created', 'Entra ID']); + expect(labels(domain)).toEqual(mode === 'non-numeric count' + ? ['Domain', 'Forest', 'Domain controllers', 'Domain functional level', 'Forest functional level', 'DC OS'] + : ['Domain', 'Forest', 'Domain functional level', 'Forest functional level', 'DC OS']); + expect(labels(inventory)).toEqual(['Enabled / disabled', 'Password never expires', 'Admin groups', 'Trusts', 'Assessed host']); + // Equal domain/forest and functional-level values are distinct facts, not duplicates. + if (mode !== 'unavailable') { + expect([...domain.querySelectorAll('dd')].filter(item => item.textContent === 'contoso.example')).toHaveLength(2); + expect([...domain.querySelectorAll('dd')].filter(item => item.textContent === 'Windows Server 2016')).toHaveLength(2); + } else expect([...domain.querySelectorAll('dd')].every(item => item.textContent === '—')).toBe(true); + for (const [title, group, expected] of [ + ['Directory', directory, ['Domain', 'Forest', 'Domains', 'Domain functional level', 'Schema version']], + ['Inventory', inventory, ['User accounts', 'Computer objects', 'Domain controllers', 'Assessed host']] + ] as const) { + const trigger = group.querySelector('button')!; + expect(trigger.getAttribute('aria-label')).toBe(`Open ${title} results`); + await act(async () => trigger.click()); + const popup = document.querySelector('.floating-data-window')!; + for (const label of expected) expect(labels(popup)).toContain(label); + if (mode !== 'unavailable') expect(popup.querySelector('table')).not.toBeNull(); + await act(async () => document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))); + expect(document.querySelector('.floating-data-window')).toBeNull(); + } + } finally { await act(async () => root.unmount()); } +}); diff --git a/tests/features/dashboard-environment-detail.test.ts b/tests/features/dashboard-environment-detail.test.ts index 6b247f6..7ff97ec 100644 --- a/tests/features/dashboard-environment-detail.test.ts +++ b/tests/features/dashboard-environment-detail.test.ts @@ -122,7 +122,7 @@ describe('client info environment detail', () => { expect(component).not.toContain('className="client-environment-detail"'); expect(component).not.toContain('environmentDetailRef'); expect(component).toContain(''); - expect(component).toContain(''); + expect(component).toContain(''); expect(component).toContain('unit: \'/100 ASSESSOR\''); expect(component).toContain('PingCastle risk ${exactNative}/100'); expect(component).toContain('useGpoInventory'); diff --git a/tests/fixtures/client-info-facts.ts b/tests/fixtures/client-info-facts.ts new file mode 100644 index 0000000..32e20a0 --- /dev/null +++ b/tests/fixtures/client-info-facts.ts @@ -0,0 +1,48 @@ +import type { EnvironmentSnapshot, EnvironmentObservation, ScannerReportSection } from '../../src/domain/environment-context'; +import { makeSheet } from '../../src/importers/sheet-factory'; +import { genericProfile } from '../../src/profiles/generic/generic-profile'; + +const tool = { id: 'synthetic', name: 'Synthetic scanner' }; +const observation = (property: string, value: string | number): EnvironmentObservation => ({ + version: 1, id: property, scanRunId: 'synthetic-run', importFileId: 'synthetic-file', tool, + subject: { type: 'directory' }, property, label: property, originalValue: value, normalizedValue: value, + valueType: typeof value === 'number' ? 'number' : 'string', state: 'observed', + source: { rowIndex: 0 }, mappingVersion: '1', attributes: {} +}); +const section = (sectionKey: string, name: string, headers: string[], rows: (string | number)[][]): ScannerReportSection => ({ + version: 1, id: sectionKey, scanRunId: 'synthetic-run', importFileId: 'synthetic-file', tool, + sectionKey, label: name, ordinal: 0, state: 'observed', tables: [{ name, headers, rows }], + source: { rowIndex: 0 }, extractorVersion: '1', attributes: {} +}); + +export const clientFactsSnapshot: EnvironmentSnapshot = { + version: 1, projectId: 'synthetic-project', scanRunIds: ['synthetic-run'], generatedAt: '2026-01-15T00:00:00Z', + coverage: { observed: 11, explicitlyAbsent: 0, empty: 0, restricted: 0, incomplete: 0 }, + observations: [ + observation('domain.fqdn', 'contoso.example'), observation('forest.name', 'contoso.example'), + observation('domain.netbios_name', 'CONTOSO'), observation('domain.count', 1), + observation('domain.controller_count', 2), observation('domain.functional_level', 'Windows Server 2016'), + observation('forest.functional_level', 'Windows Server 2016'), observation('forest.schema_version', '87'), + observation('domain.created_at', '2020-01-15'), observation('entra.tenant_name', 'Synthetic tenant'), + observation('host.name', 'Synthetic host') + ], + sections: [ + section('sectionDomainInformation', 'Directory evidence', ['Domain', 'Forest'], [['contoso.example', 'contoso.example']]), + section('sectionUserInformation', 'Account analysis', ['Nb User Accounts', 'Nb Enabled', 'Nb Disabled', 'Nb pwd never Expire'], [[16, 15, 1, 5]]), + section('sectionComputerInformation', 'Computer analysis', ['Nb Computer Accounts'], [[24]]) + ] +}; + +export function clientFactsState() { + const sheet = makeSheet('Synthetic layout', [ + ['Name', 'Status', 'Severity', 'Source: Scan'], ['Synthetic finding', 'Failed', 'High', 'Scanner A'] + ], '', 'synthetic.csv', { isProjectSheet: true }); + return { + dataset: { fileName: 'Synthetic layout', sheets: [sheet], scanTool: 'Generic' }, + activeSheet: sheet, activeProfile: genericProfile, reviews: {}, facetFilters: {}, + targetProjectId: 'synthetic-project', activeScanRunIds: ['synthetic-run'], activeAssessmentIds: ['synthetic-assessment'], + backendStatus: 'unavailable', projects: [], assessments: [], selectedProject: null, + setStatusFilter() {}, setFacetFilter() {}, setPathFindingIds() {}, resetFilters() {}, + setView() {}, setActionOnly() {}, setActiveSheet() {} + }; +} diff --git a/tests/server/assessor-http-server.test.ts b/tests/server/assessor-http-server.test.ts index 093e3fa..dd4dbce 100644 --- a/tests/server/assessor-http-server.test.ts +++ b/tests/server/assessor-http-server.test.ts @@ -52,7 +52,7 @@ describe('local ASSESSOR HTTP server', () => { const health = await fetch(`${baseUrl}/api/v1/health`); expect(await health.json()).toEqual({ - status: 'ok', application: 'ASSESSOR', productVersion: '2.8.6', apiVersion: 1, repositoryRevision: 0, persistence: 'memory' + status: 'ok', application: 'ASSESSOR', productVersion: '2.8.7', apiVersion: 1, repositoryRevision: 0, persistence: 'memory' }); expect(health.headers.get('access-control-allow-origin')).toBeNull(); });