376 lines
18 KiB
TypeScript
376 lines
18 KiB
TypeScript
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 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([]);
|
|
});
|
|
});
|