203 lines
8.6 KiB
TypeScript
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;
|
|
}
|