import { describe, expect, it } from 'vitest'; import { cleanLineBreaks, extractMarkdownTables, hasMarkdown, parseInline, parseInlineTable, parseMarkdownBlocks } from '../../src/shared/markdown'; import { inlineResultTableSheets } from '../../src/features/linked-results/inline-result-tables'; import type { FindingRecord } from '../../src/domain/finding'; import { buildRecord } from '../../src/analysis/record-builder'; import { cellText } from '../../src/shared/text'; function recordWith(fields: Array<[string, string]>): FindingRecord { const built = fields.map(([label, value], index) => ({ label, value, index })); return { id: 'record', index: 0, title: '', fields: built, filled: built, meta: [] }; } /** Synthetische Nachbildung der Form, in der Werkzeuge ihre Texte liefern. */ const RESULT_DETAIL = [ 'The following applications have credentials expiring soon:', '', '| App Name | Credential Type | Expires In |', '| --- | --- | --- |', '| Backup Service | Client Secret | 15 days |', '| API Gateway | Certificate | 22 days |' ].join('\n'); const REMEDIATION = [ '#### Remediation action:', '', '1. Navigate to the admin center [Portal](https://example.invalid/admin).', '2. Select **Active users**.', '', '#### Related links', '', '* [Benchmark](https://example.invalid/benchmark)' ].join('\n'); describe('Markdown-Erkennung', () => { it('recovers a wrapped flattened table without requiring inline links or losing the following list', () => { const value = 'Introduction | Permission | Type | Explanation | |---|---|---| | Read | Indirect | First sentence.\nNote: full explanation. | | Write | Direct | Final sentence. |\n- Follow-up item'; expect(hasMarkdown(value)).toBe(true); const blocks = parseMarkdownBlocks(value); expect(blocks.filter(block => block.kind === 'table')).toEqual([{kind:'table', table:{headers:['Permission','Type','Explanation'],rows:[['Read','Indirect','First sentence. Note: full explanation.'],['Write','Direct','Final sentence.']]}}]); expect(blocks.at(-1)?.kind).toBe('list'); }); it('erkennt Ueberschriften, Auszeichnung, Verweise, Listen und Tabellen', () => { expect(hasMarkdown('#### Remediation action:')).toBe(true); expect(hasMarkdown('Select **Active users**.')).toBe(true); expect(hasMarkdown('See [Portal](https://example.invalid/a).')).toBe(true); expect(hasMarkdown('* erster Punkt')).toBe(true); expect(hasMarkdown(RESULT_DETAIL)).toBe(true); }); it('haelt gewoehnlichen Fliesstext unangetastet', () => { expect(hasMarkdown('Die Richtlinie deckt nicht alle Benutzer ab.')).toBe(false); expect(hasMarkdown('Gefunden: 5 Konten mit Kennwort ohne Ablauf.')).toBe(false); expect(hasMarkdown('')).toBe(false); }); }); describe('Bereinigung von Zeilenumbrüchen und Satzzeichen', () => { it('verbindet frei stehende Satzzeichen mit der vorherigen Zeile', () => { expect(cleanLineBreaks('ENTERPRISE DOMAIN CONTROLLERS\n\n.')).toBe('ENTERPRISE DOMAIN CONTROLLERS.'); expect(cleanLineBreaks('ENTERPRISE DOMAIN CONTROLLERS\n\n:')).toBe('ENTERPRISE DOMAIN CONTROLLERS:'); }); it('verbindet Umbrüche mitten im Satz vor Kleinbuchstaben', () => { expect(cleanLineBreaks('The recommended state for this setting\n\non Domain Controllers')).toBe('The recommended state for this setting on Domain Controllers'); expect(cleanLineBreaks('Access this computer from the network\n\nuser right on Domain Controllers')).toBe('Access this computer from the network user right on Domain Controllers'); }); }); describe('Blockauswertung', () => { it('trennt Tabelle und einleitenden Satz', () => { const blocks = parseMarkdownBlocks(RESULT_DETAIL); expect(blocks.map(block => block.kind)).toEqual(['paragraph', 'table']); const table = blocks[1] as Extract<(typeof blocks)[number], { kind: 'table' }>; expect(table.table.headers).toEqual(['App Name', 'Credential Type', 'Expires In']); expect(table.table.rows).toEqual([ ['Backup Service', 'Client Secret', '15 days'], ['API Gateway', 'Certificate', '22 days'] ]); }); it('erkennt Ueberschriften sowie nummerierte und ungeordnete Listen getrennt', () => { const blocks = parseMarkdownBlocks(REMEDIATION); expect(blocks.map(block => block.kind)).toEqual(['heading', 'list', 'heading', 'list']); const ordered = blocks[1] as Extract<(typeof blocks)[number], { kind: 'list' }>; expect(ordered.ordered).toBe(true); expect(ordered.items).toHaveLength(2); const unordered = blocks[3] as Extract<(typeof blocks)[number], { kind: 'list' }>; expect(unordered.ordered).toBe(false); }); it('zerlegt Fliesstext nicht an Ziffern mitten im Satz', () => { const blocks = parseMarkdownBlocks('Die Regel gilt seit 2019. Sie betrifft 3 Konten.'); expect(blocks.map(block => block.kind)).toEqual(['paragraph']); }); }); describe('Inline-Auszeichnung', () => { it('loest Fett, Kursiv, Code und Verweise auf', () => { expect(parseInline('Select **Active users** now')).toEqual([ { kind: 'text', text: 'Select ' }, { kind: 'strong', children: [{ kind: 'text', text: 'Active users' }] }, { kind: 'text', text: ' now' } ]); expect(parseInline('[Portal](https://example.invalid/a)')).toEqual([ { kind: 'link', href: 'https://example.invalid/a', children: [{ kind: 'text', text: 'Portal' }] } ]); expect(parseInline('Wert `Get-Item` pruefen')[1]).toEqual({ kind: 'code', text: 'Get-Item' }); }); it('liest Unterstriche innerhalb eines Wortes woertlich (CommonMark)', () => { const text = 'Source IP: [IP_189], Destination IP: [IP_188] via snake_case_name and my__dunder__name'; expect(parseInline(text)).toEqual([{ kind: 'text', text }]); expect(parseInline('a _kursiv_ b')[1]).toEqual({ kind: 'emphasis', children: [{ kind: 'text', text: 'kursiv' }] }); expect(parseInline('__fett__.')[0]).toEqual({ kind: 'strong', children: [{ kind: 'text', text: 'fett' }] }); }); it('liest doppelte Sternchen als Fett, nicht als zwei kursive Teile', () => { expect(parseInline('**Next**')).toEqual([{ kind: 'strong', children: [{ kind: 'text', text: 'Next' }] }]); }); /** * Regression: ohne Rekursion stand die innere Auszeichnung woertlich im * fetten Text - in der laufenden Anwendung waren so 16 Markdown-Reste * innerhalb bereits gerenderter strong-, em- und a-Elemente sichtbar. */ it('loest verschachtelte Auszeichnung vollstaendig auf', () => { expect(parseInline('**[Portal](https://example.invalid/a)**')).toEqual([ { kind: 'strong', children: [ { kind: 'link', href: 'https://example.invalid/a', children: [{ kind: 'text', text: 'Portal' }] } ] } ]); expect(parseInline('[**Portal**](https://example.invalid/a)')).toEqual([ { kind: 'link', href: 'https://example.invalid/a', children: [ { kind: 'strong', children: [{ kind: 'text', text: 'Portal' }] } ] } ]); }); it('gibt unausgezeichneten Text unveraendert zurueck', () => { expect(parseInline('nur Text')).toEqual([{ kind: 'text', text: 'nur Text' }]); }); }); describe('Einzeilige Markdown-Tabellen', () => { /** * Maester exportiert seine Ergebnistabellen ohne Zeilenumbrueche. In der * Datenbank enthaelt kein einziges Maester-Feld ein Zeilenende - die Tabelle * steht als eine Pipe-Kette in der Zelle. */ const FLAT = 'Investigate. The following applications have credentials expiring soon: ' + '| App Name | Credential Type | Expires In | | --- | --- | --- | ' + '| Backup Service | Client Secret | 15 days | | API Gateway | Certificate | 22 days |'; it('erkennt die Tabelle trotz fehlender Zeilenumbrueche', () => { expect(hasMarkdown(FLAT)).toBe(true); const blocks = parseMarkdownBlocks(FLAT); expect(blocks.map(block => block.kind)).toEqual(['paragraph', 'table']); const table = blocks[1] as Extract<(typeof blocks)[number], { kind: 'table' }>; expect(table.table.headers).toEqual(['App Name', 'Credential Type', 'Expires In']); expect(table.table.rows).toEqual([ ['Backup Service', 'Client Secret', '15 days'], ['API Gateway', 'Certificate', '22 days'] ]); }); it('behauptet keine Tabelle, wenn die Zellenzahl nicht aufgeht', () => { const ragged = '| A | B | | --- | --- | | 1 | 2 | 3 |'; expect(parseInlineTable(ragged)).toBeNull(); }); it('trennt eine Ueberschrift ab, die mitten in der Zeile steht', () => { const flat = 'Konten sollen getrennt sein. #### Remediation action: Neues Konto anlegen.'; const blocks = parseMarkdownBlocks(flat); expect(blocks.map(block => block.kind)).toEqual(['paragraph', 'heading', 'paragraph']); }); it('loest eine vollstaendig einzeilige Massnahmenbeschreibung auf', () => { const flat = '1.1.1 (L1) Ensure accounts are cloud-only. #### Remediation action: ' + 'Create separate accounts. #### Related links * [Admin Center](https://example.invalid/a) ' + '* [Benchmark](https://example.invalid/b)'; const blocks = parseMarkdownBlocks(flat); expect(blocks.map(block => block.kind)).toEqual(['paragraph', 'heading', 'paragraph', 'heading', 'list']); const links = blocks[4] as Extract<(typeof blocks)[number], { kind: 'list' }>; expect(links.items).toHaveLength(2); expect(links.items[0][0]).toEqual({ kind: 'link', href: 'https://example.invalid/a', children: [{ kind: 'text', text: 'Admin Center' }] }); }); it('zerschneidet keine Versionsnummer am Zeilenanfang', () => { const blocks = parseMarkdownBlocks('1.1.1 (L1) Ensure administrative accounts are cloud-only.'); expect(blocks).toHaveLength(1); expect(blocks[0].kind).toBe('paragraph'); }); /** * Regression: Maester/CIS-CAT liefern nummerierte Massnahmenschritte * einzeilig ohne echte Zeilenumbrueche. Ohne Erkennung einer echten, * lueckenlos ab 1 aufsteigenden Folge blieb die gesamte Anleitung * ununterscheidbar von Fliesstext. */ it('erkennt eine eingebettete, ab 1 aufsteigende nummerierte Liste ohne echte Zeilenumbrueche', () => { const flat = 'To correct the number of global tenant administrators: ' + '1. Navigate to the admin center [Portal](https://example.invalid/admin). ' + '2. Select **Active users**. 3. Search for the user. 4. Save changes.'; const blocks = parseMarkdownBlocks(flat); expect(blocks.map(block => block.kind)).toEqual(['paragraph', 'list']); const list = blocks[1] as Extract<(typeof blocks)[number], { kind: 'list' }>; expect(list.ordered).toBe(true); expect(list.items).toHaveLength(4); }); it('behandelt eine einzelne Ziffer mit Punkt weiterhin als Fliesstext, wenn keine Folge vorliegt', () => { const flat = 'Findings reference section 3. The report explains the rationale in detail.'; const blocks = parseMarkdownBlocks(flat); expect(blocks.map(block => block.kind)).toEqual(['paragraph']); }); /** * Regression: CISA ScubaGear verschachtelt eine ungeordnete Liste in einen * nummerierten Schritt, ohne echten Zeilenumbruch. Ohne Erkennung * verschwanden die Unterpunkte im Fliesstext des vorangehenden Schritts. */ it('erkennt eine verschachtelte Aufzaehlung ohne echten Zeilenumbruch', () => { const flat = 'Configure the following policy settings, per the values below:' + ' * Users > Include > Select users and groups' + ' * Target resources > Cloud apps > All cloud apps' + ' * Access controls > Grant > Require MFA'; const blocks = parseMarkdownBlocks(flat); expect(blocks.map(block => block.kind)).toEqual(['paragraph', 'list']); const list = blocks[1] as Extract<(typeof blocks)[number], { kind: 'list' }>; expect(list.ordered).toBe(false); expect(list.items).toHaveLength(3); }); /** * Regression: `cellText` faltet jeden Leerraum innerhalb einer Zeile zu einem * einzigen Leerzeichen. Die urspruengliche Markdown-Einrueckung vor dem * Aufzaehlungszeichen ist in der Anzeige damit verloren - eine Erkennung, die * sich auf Einrueckung stuetzt, greift dort nie. Unterscheidungsmerkmal ist * deshalb die Wiederholung, wie bei den nummerierten Listen. */ it('erkennt eine Aufzaehlung auch nach der Leerraumfaltung von cellText', () => { const folded = cellText('It is recommended to use least privileged roles.' + ' - Damien Bowden (damien.bowden@example.invalid)' + ' - Ravi Kalwani (ravi.kalwani@example.invalid)' + ' - Gael Colas (gael@example.invalid)'); expect(folded).not.toMatch(/ {2}/); const blocks = parseMarkdownBlocks(folded); expect(blocks.map(block => block.kind)).toEqual(['paragraph', 'list']); const list = blocks[1] as Extract<(typeof blocks)[number], { kind: 'list' }>; expect(list.items).toHaveLength(3); }); it('haelt einen einzelnen Gedankenstrich im Fliesstext unangetastet', () => { // Erst ab zwei Markierungen entsteht eine Liste; ein einzelner Strich ist // in Ergebnistexten regelmaessig Teil eines Satzes ("Ratio >= 1 - True"). const blocks = parseMarkdownBlocks('Current Ratio: 3.6 = 18 / 5 Ratio >= 1 - True'); expect(blocks).toHaveLength(1); expect(blocks[0].kind).toBe('paragraph'); }); it('zaehlt einen Gedankenstrich innerhalb eines Verweistitels nicht als Aufzaehlung', () => { const flat = 'Open [Recommendation - Require MFA](https://example.invalid/a) ' + 'and [Guidance - Review roles](https://example.invalid/b) in the admin portal.'; const blocks = parseMarkdownBlocks(flat); expect(blocks).toHaveLength(1); expect(blocks[0].kind).toBe('paragraph'); }); /** * Regression: Hinter der letzten Datenzeile folgt bei Maester regelmaessig * noch ein Hinweissatz. Weil die Zellenzahl dadurch kein Vielfaches der * Spaltenzahl mehr war, wurde die gesamte Tabelle verworfen und landete als * Pipe-Kette in der Oberflaeche. */ it('erkennt eine einzeilige Tabelle mit nachfolgendem Fliesstext', () => { const flat = '| Status | Name | | --- | --- | | Fail | [tyler@example.invalid]() | ' + '| Fail | [ann@example.invalid]() | Open [Recommendation](https://example.invalid/a) in the portal.'; const parsed = parseInlineTable(flat); expect(parsed?.table.headers).toEqual(['Status', 'Name']); expect(parsed?.table.rows).toHaveLength(2); expect(parsed?.after).toContain('in the portal.'); const blocks = parseMarkdownBlocks(flat); expect(blocks.map(block => block.kind)).toEqual(['table', 'paragraph']); }); /** * Regression: Eine Ueberschrift laeuft bis zum Zeilenende. Stand die Tabelle * in derselben kollabierten Zeile, verschluckte die Ueberschrift den * gesamten Ergebnisinhalt und er verschwand aus der Anzeige. */ it('trennt eine Tabelle von der Ueberschrift, die in derselben Zeile steht', () => { const flat = 'You have 3 of 16 users without MFA. #### Impacted resources ' + '| Status | Name | | --- | --- | | Fail | tyler@example.invalid | | Fail | ann@example.invalid |'; const blocks = parseMarkdownBlocks(flat); expect(blocks.map(block => block.kind)).toEqual(['paragraph', 'heading', 'table']); const heading = blocks[1] as Extract<(typeof blocks)[number], { kind: 'heading' }>; // Die erste Kopfzelle darf nicht in der Ueberschrift landen. expect(heading.content).toEqual([{ kind: 'text', text: 'Impacted resources' }]); const table = blocks[2] as Extract<(typeof blocks)[number], { kind: 'table' }>; expect(table.table.headers).toEqual(['Status', 'Name']); expect(table.table.rows).toHaveLength(2); }); it('trennt den Vortext vollstaendig von der Kopfzeile', () => { const parsed = parseInlineTable('Some intro sentence: | App | Owner | | --- | --- | | A | B |'); expect(parsed?.before).toBe('Some intro sentence:'); expect(parsed?.table.headers).toEqual(['App', 'Owner']); }); }); describe('Zeilenstruktur der Zellwerte', () => { /** * Regression: `buildRecord` hat den Anzeigewert mit `clean` gefaltet und dabei * jeden Zeilenumbruch entfernt. Damit war eine Markdown-Tabelle nicht mehr von * einem Satz zu unterscheiden, und `####` stand woertlich in der Oberflaeche. */ it('behaelt Zeilenumbrueche im Feldwert, faltet aber Leerraum innerhalb der Zeile', () => { const record = buildRecord({ row: ['Ein Satz.\r\n\r\n| A | B |\r\n| --- | --- |\r\n| 1 | 2 |'], headers: ['ResultDetail'], index: 0, sheetName: 'blatt' }); expect(record.fields[0].value).toContain('\n'); expect(extractMarkdownTables(record.fields[0].value)).toHaveLength(1); }); it('haelt den Titel einzeilig, damit Kachel und Kartenliste nicht umbrechen', () => { const record = buildRecord({ row: ['Erste Zeile\nZweite Zeile'], headers: ['Title'], index: 0, sheetName: 'blatt' }); expect(record.title).toBe('Erste Zeile Zweite Zeile'); }); it('faltet mehrfachen Leerraum innerhalb einer Zeile', () => { expect(cellText('a b\t\tc')).toBe('a b c'); expect(cellText(' a \n b ')).toBe('a\nb'); }); }); describe('Ergebnistabellen aus Textfeldern', () => { it('macht eine Tabelle im Feld ResultDetail als Blatt verfuegbar', () => { const sheets = inlineResultTableSheets(recordWith([['ResultDetail', RESULT_DETAIL]])); expect(sheets).toHaveLength(1); expect(sheets[0].name).toBe('ResultDetail'); expect(sheets[0].rows[0]).toEqual(['App Name', 'Credential Type', 'Expires In']); expect(sheets[0].analysis.records).toHaveLength(2); }); it('macht altes PurpleKnight-ResultDetail-JSON als Results-Tabelle verfuegbar', () => { const value = JSON.stringify([ ['DistinguishedName: CN=Synthetic,DC=example,DC=test', 'MinLength: 14', 'Ignored: False'], ['DistinguishedName: CN=Second,DC=example,DC=test', 'MinLength: 16', 'Ignored: True'] ]); const sheets = inlineResultTableSheets(recordWith([['ResultDetail', value]])); expect(sheets).toHaveLength(1); expect(sheets[0].rows).toEqual([ ['DistinguishedName', 'MinLength', 'Ignored'], ['CN=Synthetic,DC=example,DC=test', '14', 'False'], ['CN=Second,DC=example,DC=test', '16', 'True'] ]); }); it('erzeugt kein Blatt fuer Felder ohne Tabelle', () => { expect(inlineResultTableSheets(recordWith([['ResultDetail', 'Keine Auffaelligkeiten.']]))).toEqual([]); expect(inlineResultTableSheets(recordWith([['Description', RESULT_DETAIL]]))).toEqual([]); }); it('ignoriert eine Tabelle ohne Datenzeilen', () => { const empty = '| A | B |\n| --- | --- |'; expect(extractMarkdownTables(empty)).toEqual([]); }); });