306 lines
13 KiB
TypeScript
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><a href="javascript:alert(1)">klick</a><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('<a href="javascript:alert(1)">');
|
|
expect(value).toContain('<a href="https://example.org/x">');
|
|
expect(value).not.toContain('<a href="javascript:');
|
|
expect(value).not.toContain('<a href="data:');
|
|
});
|
|
});
|