Assessor_2.0/src/importers/pingcastle-rule-validation.ts

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' }
} } : {}) };
}