72 lines
4.5 KiB
TypeScript
72 lines
4.5 KiB
TypeScript
import type { ImportDiagnostic, SourceLocator } from '../domain/import-intermediate';
|
|
import type { FindingCandidate, ScannerReportSectionCandidate } from '../domain/source-extraction';
|
|
import { ImportError } from '../shared/errors';
|
|
import { markupText } from './html-extraction';
|
|
|
|
export function acceptPingCastleRuleId(ruleId: string, seen: Set<string>, source: SourceLocator, diagnostics: ImportDiagnostic[]): boolean {
|
|
if (!ruleId.trim()) throw new ImportError('PingCastle-Regel ohne Rule ID kann nicht vollständig importiert werden.', [{
|
|
severity: 'error', code: 'pingcastle-rule-id-missing', stage: 'extraction', source,
|
|
message: 'Ein nativer Regelblock enthält keine lesbare Rule ID.'
|
|
}]);
|
|
const identity = ruleId.trim().toLocaleUpperCase('en-US');
|
|
if (seen.has(identity)) {
|
|
diagnostics.push({
|
|
severity: 'warning', code: 'pingcastle-duplicate-rule-id', stage: 'extraction', source,
|
|
message: 'Eine Rule ID ist innerhalb der Quelle wiederholt. Die erste Darstellung bleibt das Finding; weitere Darstellungen werden nicht als zusätzliche Treffer gezählt.',
|
|
details: { ruleId, retained: 'first-presentation' }
|
|
});
|
|
return false;
|
|
}
|
|
seen.add(identity);
|
|
return true;
|
|
}
|
|
|
|
/** Matched counts describe findings; the adjacent catalog size is not a result count. */
|
|
export function pingCastleHtmlRuleCoverage(fileName: string, markup: string, candidates: FindingCandidate[]): {
|
|
diagnostics: ImportDiagnostic[]; section?: ScannerReportSectionCandidate;
|
|
} {
|
|
const diagnostics: ImportDiagnostic[] = [];
|
|
const rows: Array<Array<string | number>> = [];
|
|
const key = (value: string) => value.replace(/[^a-z]/gi, '').toLocaleLowerCase('en-US');
|
|
const actualByCategory = new Map<string, number>();
|
|
for (const candidate of candidates) {
|
|
const category = key(candidate.fields.find(field => field.label === 'Category')?.decodedValue ?? '');
|
|
actualByCategory.set(category, (actualByCategory.get(category) ?? 0) + 1);
|
|
}
|
|
const pattern = /<h2\b([^>]*)>([\s\S]*?)<\/h2\s*>/gi;
|
|
let match: RegExpExecArray | null;
|
|
let positiveDeclared = false;
|
|
while ((match = pattern.exec(markup))) {
|
|
if (!/\bclass\s*=\s*["'][^"']*\bindicator-header\b/i.test(match[1])) continue;
|
|
const heading = markupText(match[2]);
|
|
if (!/rules?\s+matched/i.test(heading)) continue;
|
|
const source = { fileName, objectPath: `html/indicator-headings/${rows.length}` };
|
|
const parsed = /^(.*?)\s+rule\s+details\s*\[\s*(\S+)\s+rules?\s+matched\s+on\s+a\s+total\s+of\s+(\S+)\s*\]/i.exec(heading);
|
|
const category = parsed?.[1] ?? heading;
|
|
const declared = parsed && /^\d+$/.test(parsed[2]) ? Number(parsed[2]) : NaN;
|
|
const catalog = parsed && /^\d+$/.test(parsed[3]) ? Number(parsed[3]) : NaN;
|
|
const actual = actualByCategory.get(key(category)) ?? 0;
|
|
rows.push([category, parsed?.[2] ?? '', parsed?.[3] ?? '', actual, heading]);
|
|
positiveDeclared ||= Number.isFinite(declared) && declared > 0;
|
|
if (!Number.isSafeInteger(declared) || !Number.isSafeInteger(catalog) || declared > catalog) {
|
|
diagnostics.push({ severity: 'warning', code: 'pingcastle-rule-count-invalid', stage: 'extraction', source,
|
|
message: 'Die deklarierte PingCastle-Regelbilanz ist ungültig; Originalangaben bleiben erhalten.' });
|
|
}
|
|
if (Number.isSafeInteger(declared) && declared !== actual) {
|
|
diagnostics.push({ severity: 'warning', code: 'pingcastle-rule-count-mismatch', stage: 'extraction', source,
|
|
message: 'Die deklarierte Trefferzahl stimmt nicht mit den extrahierten eindeutigen Regeln überein.',
|
|
details: { category, declared, actual } });
|
|
}
|
|
}
|
|
if (!candidates.length && (positiveDeclared || diagnostics.length)) throw new ImportError('PingCastle kündigt Regeln an, enthält aber keine auslesbaren Ergebnisse.', [
|
|
...diagnostics, { severity: 'error', code: 'pingcastle-rule-results-missing', stage: 'extraction',
|
|
message: 'Eine erfolgreiche Nullbefund-Verarbeitung ist bei dieser Regelbilanz nicht zulässig.', source: { fileName } }
|
|
]);
|
|
return { diagnostics, ...(rows.length ? { section: {
|
|
version: 1 as const, sectionKey: 'pingcastle-rule-coverage', label: 'PingCastle rule coverage', ordinal: 0,
|
|
state: 'observed' as const,
|
|
tables: [{ name: 'Declared and extracted rules', headers: ['Category', 'Declared matched', 'Declared catalog size', 'Extracted unique rules', 'Original heading'], rows }],
|
|
source: { fileName, objectPath: 'html/indicator-headings' }, attributes: { derivedColumn: 'Extracted unique rules' }
|
|
} } : {}) };
|
|
}
|