Assessor_2.0/src/profiles/purple-knight/result-linker.ts

203 lines
8.6 KiB
TypeScript

import type { DatasetSheet } from '../../domain/dataset';
import type { FindingRecord } from '../../domain/finding';
import { toolIdForRecord } from '../../importers/status-normalization';
import { clean, plainTextValue } from '../../shared/text';
export function resultReferenceFields(record: FindingRecord) {
return record.fields.filter(
field =>
field.value &&
/^(?:result|results|result\s*(?:file|datei)|resultdatei|ergebnis|ergebnisse|ergebnisdatei)$/i.test(clean(field.label))
);
}
export function resultReferenceValues(record: FindingRecord): string[] {
return resultReferenceFields(record)
.flatMap(field => {
const raw = String(field.value ?? '');
const hrefs = [...raw.matchAll(/href\s*=\s*['"]([^'"]+)['"]/gi)].map(match => {
try {
return decodeURIComponent(match[1]);
} catch {
return match[1];
}
});
return [plainTextValue(raw), raw, ...hrefs];
})
.filter(Boolean);
}
/**
* Sieht ein Referenzwert nach einer Datei aus?
*
* `resultReferenceValues` liefert bewusst alles, was als Verweis in Frage
* kommt, damit die Zuordnung tolerant bleibt. Fuer die Anzeige taugt das nicht:
* Werkzeuge wie Maester schreiben ihr vollstaendiges Ergebnis samt Tabelle in
* dasselbe Feld, das bei Purple Knight einen Dateinamen enthaelt.
*/
export function isFileReference(value: string): boolean {
const candidate = clean(value);
if (!candidate || candidate.length > 120) return false;
if (candidate.includes('|')) return false;
if (/\.(?:csv|tsv|xlsx?|xlsb|ods|json|html?)\b/i.test(candidate)) return true;
// Ohne erkannte Dateiendung nur akzeptieren, wenn ein Pfadtrenner vorliegt:
// ein gewoehnlicher Satz (z. B. Maesters Ergebnistext) enthaelt weder eine
// Endung noch einen Trenner und darf nicht als Dateiverweis erscheinen.
if (!/[\\/]/.test(candidate)) return false;
return /^[\w .\-/\\]+$/.test(candidate);
}
export function linkedFileKey(value: unknown): string {
return plainTextValue(value)
.toLocaleLowerCase('de-DE')
.replace(/^.*[\\/]/, '')
.replace(/\.(?:csv|tsv|xlsx?|xlsb|ods|json|html?)\b.*$/i, '')
.replace(/[^a-z0-9]+/g, '');
}
function linkedFileWords(value: unknown): string[] {
return plainTextValue(value)
.toLocaleLowerCase('de-DE')
.split(/[^a-z0-9]+/)
.filter(word => word.length >= 4 && !['results', 'result', 'report', 'security', 'assessment'].includes(word));
}
export function linkedSheetScore(sheet: DatasetSheet, references: string[], identityTokens: string[]): number {
const names = [...new Set([sheet.sourceFile, sheet.sourceSheet, sheet.name].filter(Boolean) as string[])];
let score = 0;
references.forEach(reference =>
names.forEach(name => {
const referenceKey = linkedFileKey(reference);
const nameKey = linkedFileKey(name);
if (!referenceKey || !nameKey) return;
if (referenceKey === nameKey) score = Math.max(score, 120);
else if (Math.min(referenceKey.length, nameKey.length) >= 16 && (referenceKey.includes(nameKey) || nameKey.includes(referenceKey))) {
score = Math.max(score, 100);
}
})
);
const normalizedNames = names.map(linkedFileKey).join(' ');
if (identityTokens.some(token => normalizedNames.includes(linkedFileKey(token)))) score = Math.max(score, 80);
const referenceWords = new Set(references.flatMap(linkedFileWords));
const sharedWords = names.flatMap(linkedFileWords).filter(word => referenceWords.has(word));
if (sharedWords.length >= 3) score = Math.max(score, 65 + Math.min(10, sharedWords.length));
return score;
}
interface SourceTableReference {
tableIndex: number;
label?: string;
}
/** Provenienzspalte der Projektansicht (siehe `canonicalModelsToDataset`). */
const RECORD_SOURCE_FILE_LABEL = /^(?:quelle:\s*datei|source\s*file|source:\s*file)$/i;
/**
* Die Datei, aus der ein Datensatz stammt.
*
* `record.sourceFile` ist der Dateiname des Blattes, auf dem der Datensatz
* gerade gelesen wird. In der Projektansicht ("<Projekt> · Alle Datensätze")
* ist das nicht die Quelldatei, sondern der Projektname; die tatsaechliche
* Datei steht dort als Provenienzfeld "Quelle: Datei". Ohne diese
* Unterscheidung fand ein `source-table:`-Verweis genau in der Ansicht kein
* Blatt, in der Findings ueblicherweise gelesen werden.
*/
function recordSourceFile(record: FindingRecord): string {
const provenance = record.fields.find(field => RECORD_SOURCE_FILE_LABEL.test(clean(field.label)))?.value;
return clean(provenance) || clean(record.sourceFile);
}
function sourceTableReferences(references: string[]): SourceTableReference[] {
const result: SourceTableReference[] = [];
for (const reference of references) {
if (typeof reference !== 'string') continue;
const pattern = /source-table:(\d+)(?:\s+"([^"]+)")?/gi;
let match: RegExpExecArray | null;
while ((match = pattern.exec(reference))) {
result.push({ tableIndex: Number(match[1]), label: clean(match[2]) });
}
}
return result.filter(item => Number.isInteger(item.tableIndex) && item.tableIndex >= 0);
}
function hasConflictingTool(sheet: DatasetSheet, recordToolId: string | undefined): boolean {
const sheetToolId = clean(sheet.toolId).toLocaleLowerCase('en-US');
const resolvedRecordToolId = clean(recordToolId).toLocaleLowerCase('en-US');
return Boolean(
resolvedRecordToolId
&& resolvedRecordToolId !== 'generic'
&& sheetToolId
&& sheetToolId !== 'generic'
&& sheetToolId !== resolvedRecordToolId
);
}
const linkedSheetsCache = new WeakMap<FindingRecord, Map<string, DatasetSheet[]>>();
export function linkResultSheets(record: FindingRecord, sheets: DatasetSheet[], activeSheetId: string): DatasetSheet[] {
let recordCache = linkedSheetsCache.get(record);
if (!recordCache) {
recordCache = new Map();
linkedSheetsCache.set(record, recordCache);
}
const cached = recordCache.get(activeSheetId);
if (cached) return cached;
if (!resultReferenceFields(record).length || sheets.length < 2) {
recordCache.set(activeSheetId, []);
return [];
}
const references = resultReferenceValues(record);
const activeSheetToolId = sheets.find(sheet => sheet.id === activeSheetId)?.toolId;
const recordToolId = toolIdForRecord(record, activeSheetToolId);
const tableReferences = sourceTableReferences(references);
const directTableLabels = new Map(tableReferences.map(reference => [reference.tableIndex, reference.label]));
// Ein Quelltabellenverweis ist abschliessend: er benennt genau die Tabellen
// des eigenen Regelblocks. Faellt er auf die Namensaehnlichkeit unten
// zurueck, zieht er jede gleichnamige Tabelle des Berichts herein - CIS-CAT
// gibt dieselbe Belegtabelle je Regel erneut aus, aus einer Tabelle wurden
// so Dutzende mit tausenden Zeilen. Loest der Verweis nichts auf, bleibt das
// Ergebnis deshalb leer und der Reader weist auf die fehlende Datei hin.
if (directTableLabels.size > 0) {
const sourceFile = recordSourceFile(record);
const directMatches = sheets.filter(
sheet => !hasConflictingTool(sheet, recordToolId)
&& (!sourceFile || sheet.sourceFile === sourceFile)
&& sheet.sourceTableIndex !== undefined
&& directTableLabels.has(sheet.sourceTableIndex)
).map(sheet => {
const label = directTableLabels.get(sheet.sourceTableIndex!);
return label ? { ...sheet, name: label } : sheet;
});
recordCache.set(activeSheetId, directMatches);
return directMatches;
}
const tokenSource = [...references, ...record.fields.map(field => plainTextValue(field.value))].join(' ');
const identityTokens = [...tokenSource.matchAll(/(?:^|[^a-z0-9])((?:SI|ID|IND)[-_ ]?\d{3,})(?=$|[^a-z0-9])/gi)].map(
match => match[1]
);
const fileReferences = references.filter(isFileReference);
const linkReferences = fileReferences.length > 0 ? fileReferences : identityTokens;
if (!linkReferences.length) {
recordCache.set(activeSheetId, []);
return [];
}
// Ab hier gibt es keinen Quelltabellenverweis mehr: die Zuordnung geschieht
// ueber den Dateinamen, den Werkzeuge wie Purple Knight im Ergebnisfeld
// nennen.
const candidates = sheets
.filter(sheet => !hasConflictingTool(sheet, recordToolId))
.map(sheet => ({ sheet, score: linkedSheetScore(sheet, linkReferences, identityTokens) }))
.filter(item => !item.sheet.isProjectSheet && item.sheet.id !== activeSheetId && item.score >= 65)
.sort((left, right) => right.score - left.score);
const result = !candidates.length
? []
: (candidates[0].score >= 100 ? candidates.filter(item => item.score === candidates[0].score) : [candidates[0]]).map(item => item.sheet);
recordCache.set(activeSheetId, result);
return result;
}