Assessor_2.0/tests/importers/html-importer.test.ts

306 lines
13 KiB
TypeScript

// @vitest-environment jsdom
import { describe, expect, it } from 'vitest';
import { importHtml } from '../../src/importers/html-importer';
import { browserMarkupDocumentAdapter } from '../../src/platform/browser/import-runtime-adapters';
import { sourceMetadata } from './import-source-fixtures';
describe('HTML importer', () => {
it('extracts multiple named tables and their source metadata', () => {
const text = `
<main>
<table>
<caption>Findings</caption>
<tr><th>Name</th><th>Severity</th></tr>
<tr><td>Weak policy</td><td>High</td></tr>
</table>
<table id="systems">
<tr><th>Host</th><th>Status</th></tr>
<tr><td>dc01</td><td>Open</td></tr>
</table>
</main>
`;
const source = sourceMetadata('report.html', text.length, 'text/html');
const imported = importHtml(source, text, browserMarkupDocumentAdapter);
expect(imported.format).toBe('HTML');
expect(imported.sheets.map(sheet => sheet.name)).toEqual(['Findings', 'systems']);
expect(imported.sheets[0].rows).toEqual([
['Name', 'Severity'],
['Weak policy', 'High']
]);
expect(imported.sheets[0].sourceFile).toBe('report.html');
expect(imported.sheets[0].analysis.records[0].title).toBe('Weak policy');
});
it('removes active content before extracting table cells', () => {
const text = `
<table aria-label="Security findings">
<tr><th>Name</th><th>Evidence</th></tr>
<tr>
<td>Unsafe markup</td>
<td><script>window.evil = true</script><iframe src="https://example.invalid"></iframe>Safe evidence</td>
</tr>
</table>
`;
const source = sourceMetadata('untrusted.html', text.length, 'text/html');
const imported = importHtml(source, text, browserMarkupDocumentAdapter);
expect(imported.sheets[0].name).toBe('Security findings');
expect(imported.sheets[0].rows[1]).toEqual(['Unsafe markup', 'Safe evidence']);
expect(imported.sheets[0].raw).not.toContain('window.evil');
expect(imported.sheets[0].raw).not.toContain('example.invalid');
});
it('falls back to inert body text when no table exists', () => {
const text = '<html><body><h1>Summary</h1><p>No findings exported.</p><script>alert(1)</script></body></html>';
const source = sourceMetadata('summary.html', text.length, 'text/html');
const imported = importHtml(source, text, browserMarkupDocumentAdapter);
expect(imported.sheets).toHaveLength(1);
expect(imported.sheets[0].name).toBe('HTML');
expect(imported.sheets[0].rows.flat().join(' ')).toContain('Summary');
expect(imported.sheets[0].rows.flat().join(' ')).not.toContain('alert(1)');
});
it('uses bounded extraction for multi-megabyte browser-side HTML reports', () => {
const text = '<html><head><title>PingCastle</title></head><body>'
+ '<!--' + 'x'.repeat(4_200_000) + '-->'
+ '<table id="rules"><tr><th>Rule ID</th><th>Title</th></tr>'
+ '<tr><td>PC-BROWSER-1</td><td>Bounded browser extraction</td></tr></table>'
+ '</body></html>';
const source = sourceMetadata('ad_hc_browser.local.html', text.length, 'text/html');
const imported = importHtml(source, text, browserMarkupDocumentAdapter);
expect(imported.sheets[0].rows[1]).toEqual(['PC-BROWSER-1', 'Bounded browser extraction']);
expect(imported.intermediate?.diagnostics).toContainEqual(expect.objectContaining({
code: 'html-bounded-extraction',
severity: 'info'
}));
});
// Verweisziele sind seit der Freigabe erwuenscht - aber ausschliesslich als
// Web-Adresse. Jedes andere Schema muss weiterhin verschwinden, sonst wuerde
// ein Bericht ausfuehrbare Inhalte in die Oberflaeche tragen.
it('keeps web links from reports and discards every other scheme', () => {
const text = `
<table>
<caption>Refs</caption>
<tr><th>Quelle</th></tr>
<tr><td><a href="https://learn.microsoft.com/a">Doku</a></td></tr>
<tr><td><a href="javascript:alert(1)">Boese</a></td></tr>
<tr><td><a href="data:text/html;base64,PHNjcmlwdD4=">Daten</a></td></tr>
<tr><td><a href="file:///C:/geheim.txt">Datei</a></td></tr>
</table>
`;
const source = sourceMetadata('refs.html', text.length, 'text/html');
const rows = importHtml(source, text, browserMarkupDocumentAdapter).sheets[0].rows;
expect(rows[1][0]).toContain('<a href="https://learn.microsoft.com/a">');
expect(rows[1][0]).toContain('Doku');
expect(rows[2][0]).toContain('Boese');
expect(rows[3][0]).toContain('Daten');
expect(rows[4][0]).toContain('Datei');
rows.slice(2).forEach(row => {
expect(row[0]).not.toContain('href');
expect(row[0]).not.toContain('<a');
});
});
/**
* Regression: CIS-CAT spannt die fuehrende "AND"-Zelle einer Complex-Check-
* Tabelle per `rowspan` ueber alle Zeilen. Ohne Aufloesung hat nur die erste
* Zeile die volle Spaltenzahl - jede weitere Zeile hat dadurch eine Spalte
* weniger, die Kopfzeilenerkennung liest die erste Zeile faelschlich als
* Kopf, und ihr Inhalt (hier "Criterion:") verschwindet als Datenzeile.
*/
it('uebernimmt eine per rowspan gespannte Zelle in jede ueberspannte Zeile', () => {
const text = `
<table>
<caption>Complex Check</caption>
<tr><th rowspan="4">AND</th><td>Criterion:</td><td>Ensure setting is enabled</td></tr>
<tr><td>Existence Check:</td><td>At Least One Exists</td></tr>
<tr><td>Item Check:</td><td>All</td></tr>
<tr><td>Result:</td><td>Fail</td></tr>
</table>
`;
const source = sourceMetadata('rowspan.html', text.length, 'text/html');
const rows = importHtml(source, text, browserMarkupDocumentAdapter).sheets[0].rows;
expect(rows).toEqual([
['AND', 'Criterion:', 'Ensure setting is enabled'],
['AND', 'Existence Check:', 'At Least One Exists'],
['AND', 'Item Check:', 'All'],
['AND', 'Result:', 'Fail']
]);
});
/**
* CIS-CAT markiert in einer Belegtabelle (z. B. "Registry Item",
* "Auditeventpolicysubcategories Item") genau die Zeile(n) mit
* `<tr class="evaluated">`, gegen die ein Criterion tatsaechlich prueft -
* im Originalbericht als Hintergrundfarbe sichtbar. Diese Markierung muss
* bis zum auswertbaren Datensatz erhalten bleiben, sonst verschwindet im
* Ergebnisfenster, welche Zeile einer oft langen Tabelle zum Ergebnis
* gefuehrt hat.
*/
it('uebernimmt eine vom Bericht markierte Zeile bis zum auswertbaren Datensatz', () => {
const text = `
<table>
<caption>Registry Item</caption>
<tr><th>Name</th><th>Value</th></tr>
<tr><td>Hive</td><td>HKEY_LOCAL_MACHINE</td></tr>
<tr class="evaluated"><td>Type</td><td>reg_dword</td></tr>
<tr class="evaluated"><td>Value</td><td>5</td></tr>
<tr><td>Windows View</td><td>64_bit</td></tr>
</table>
`;
const source = sourceMetadata('evaluated-row.html', text.length, 'text/html');
const records = importHtml(source, text, browserMarkupDocumentAdapter).sheets[0].analysis.records;
expect(records.map(record => ({ name: record.fields[0].value, highlighted: record.highlighted ?? false }))).toEqual([
{ name: 'Hive', highlighted: false },
{ name: 'Type', highlighted: true },
{ name: 'Value', highlighted: true },
{ name: 'Windows View', highlighted: false }
]);
});
it('uebernimmt eine vom Bericht markierte Zeile auch in der stack-sicheren Extraktion grosser Berichte', () => {
const text = '<html><head><title>CIS-CAT</title></head><body>'
+ '<!--' + 'x'.repeat(4_200_000) + '-->'
+ '<table><caption>Registry Item</caption>'
+ '<tr><th>Name</th><th>Value</th></tr>'
+ '<tr><td>Hive</td><td>HKEY_LOCAL_MACHINE</td></tr>'
+ '<tr class="evaluated"><td>Value</td><td>5</td></tr>'
+ '</table></body></html>';
const source = sourceMetadata('evaluated-row-bounded.html', text.length, 'text/html');
const imported = importHtml(source, text, browserMarkupDocumentAdapter);
expect(imported.intermediate?.diagnostics).toContainEqual(expect.objectContaining({ code: 'html-bounded-extraction' }));
const records = imported.sheets[0].analysis.records;
expect(records.map(record => ({ name: record.fields[0].value, highlighted: record.highlighted ?? false }))).toEqual([
{ name: 'Hive', highlighted: false },
{ name: 'Value', highlighted: true }
]);
});
it('loest rowspan auch in der stack-sicheren Extraktion grosser Berichte auf', () => {
const text = '<html><head><title>CIS-CAT</title></head><body>'
+ '<!--' + 'x'.repeat(4_200_000) + '-->'
+ '<table><caption>Complex Check</caption>'
+ '<tr><th rowspan="4">AND</th><td>Criterion:</td><td>Ensure setting is enabled</td></tr>'
+ '<tr><td>Existence Check:</td><td>At Least One Exists</td></tr>'
+ '<tr><td>Item Check:</td><td>All</td></tr>'
+ '<tr><td>Result:</td><td>Fail</td></tr>'
+ '</table></body></html>';
const source = sourceMetadata('rowspan-bounded.html', text.length, 'text/html');
const imported = importHtml(source, text, browserMarkupDocumentAdapter);
expect(imported.intermediate?.diagnostics).toContainEqual(expect.objectContaining({ code: 'html-bounded-extraction' }));
expect(imported.sheets[0].rows).toEqual([
['AND', 'Criterion:', 'Ensure setting is enabled'],
['AND', 'Existence Check:', 'At Least One Exists'],
['AND', 'Item Check:', 'All'],
['AND', 'Result:', 'Fail']
]);
});
/**
* Regression: CIS-CAT bettet die Belegtabelle eines OR/AND-verknuepften
* Complex Checks in eine Zelle der aeusseren "Complex Check"-Tabelle ein.
* Die stack-sichere Extraktion grosser Berichte suchte bisher naiv nach
* dem naechsten `</table>` und schloss dadurch die AEUSSERE Tabelle schon
* am Ende der ERSTEN verschachtelten Tabelle - "Criterion:" der ersten
* Verknuepfung verschwand als vermeintliche Kopfzeile.
*/
it('loest eine in eine Tabellenzelle verschachtelte Tabelle in der stack-sicheren Extraktion korrekt auf', () => {
const text = '<html><head><title>CIS-CAT</title></head><body>'
+ '<!--' + 'x'.repeat(4_200_000) + '-->'
+ '<table><caption>Complex Check</caption>'
+ '<tr><td>OR</td><td>'
+ '<table><tbody>'
+ '<tr><td>Criterion:</td><td>Synthetic setting is A</td></tr>'
+ '<tr><td>Existence Check:</td><td>At Least One Exists</td></tr>'
+ '<tr><td>Item Check:</td><td>All</td></tr>'
+ '<tr><td>Result:</td><td>Fail</td></tr>'
+ '</tbody></table>'
+ '<table><caption>Registry Item</caption>'
+ '<tr><td>Setting</td><td>A-value</td></tr>'
+ '</table>'
+ '</td></tr></table>'
+ '<table><tbody>'
+ '<tr><td>Criterion:</td><td>Synthetic setting is B</td></tr>'
+ '<tr><td>Existence Check:</td><td>At Least One Exists</td></tr>'
+ '<tr><td>Item Check:</td><td>All</td></tr>'
+ '<tr><td>Result:</td><td>Fail</td></tr>'
+ '</tbody></table>'
+ '<table><caption>Registry Item</caption>'
+ '<tr><td>Setting</td><td>B-value</td></tr>'
+ '</table>'
+ '</body></html>';
const source = sourceMetadata('or-complex-check.html', text.length, 'text/html');
const imported = importHtml(source, text, browserMarkupDocumentAdapter);
expect(imported.intermediate?.diagnostics).toContainEqual(expect.objectContaining({ code: 'html-bounded-extraction' }));
expect(imported.sheets.map(sheet => ({ name: sheet.name, rows: sheet.rows }))).toEqual([
{ name: 'Complex Check', rows: [['OR', '']] },
{ name: 'Tabelle 2', rows: [
['Criterion:', 'Synthetic setting is A'],
['Existence Check:', 'At Least One Exists'],
['Item Check:', 'All'],
['Result:', 'Fail']
] },
{ name: 'Registry Item', rows: [['Setting', 'A-value']] },
{ name: 'Tabelle 4', rows: [
['Criterion:', 'Synthetic setting is B'],
['Existence Check:', 'At Least One Exists'],
['Item Check:', 'All'],
['Result:', 'Fail']
] },
{ name: 'Registry Item', rows: [['Setting', 'B-value']] }
]);
});
it('vervielfaeltigt eine per colspan gespannte Kopfzelle in jede ueberspannte Spalte', () => {
const text = `
<table>
<tr><th colspan="2">CIS Control Information</th></tr>
<tr><td>Control:</td><td>Maintain an inventory.</td></tr>
</table>
`;
const source = sourceMetadata('colspan.html', text.length, 'text/html');
const rows = importHtml(source, text, browserMarkupDocumentAdapter).sheets[0].rows;
expect(rows[0]).toEqual(['CIS Control Information', 'CIS Control Information']);
expect(rows[1]).toEqual(['Control:', 'Maintain an inventory.']);
});
it('escapes source text so a report cannot inject its own markup', () => {
const text = `
<table>
<caption>Injection</caption>
<tr><th>Wert</th></tr>
<tr><td>&lt;a href="javascript:alert(1)"&gt;klick&lt;/a&gt;<a href="https://example.org/x">ok</a></td></tr>
</table>
`;
const source = sourceMetadata('inject.html', text.length, 'text/html');
const value = importHtml(source, text, browserMarkupDocumentAdapter).sheets[0].rows[1][0];
expect(value).toContain('&lt;a href=&quot;javascript:alert(1)&quot;&gt;');
expect(value).toContain('<a href="https://example.org/x">');
expect(value).not.toContain('<a href="javascript:');
expect(value).not.toContain('<a href="data:');
});
});