Assessor_2.0/src/domain/import-duplicates.ts

123 lines
4.8 KiB
TypeScript

import type { ImportFileRecord, ToolIdentity } from './canonical-model';
export const POSSIBLE_DUPLICATE_TOLERANCE_MS = 60_000;
function usableToolId(tool?: ToolIdentity): string | undefined {
const id = tool?.id.trim().toLocaleLowerCase('de-DE');
return id && id !== 'generic' && id !== 'unknown' && id !== 'multiple' ? id : undefined;
}
function timestamp(value?: string | number): number | undefined {
const parsed = typeof value === 'number' ? value : value ? Date.parse(value) : Number.NaN;
return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
}
export function isPossibleDuplicateScan(
leftTool: ToolIdentity | undefined,
leftDate: string | number | undefined,
rightTool: ToolIdentity | undefined,
rightDate: string | number | undefined,
toleranceMs = POSSIBLE_DUPLICATE_TOLERANCE_MS
): boolean {
const leftToolId = usableToolId(leftTool);
const rightToolId = usableToolId(rightTool);
const leftTimestamp = timestamp(leftDate);
const rightTimestamp = timestamp(rightDate);
return Boolean(leftToolId && leftToolId === rightToolId && leftTimestamp !== undefined && rightTimestamp !== undefined
&& Math.abs(leftTimestamp - rightTimestamp) <= toleranceMs);
}
export function findPossibleDuplicateImportFile(
files: ImportFileRecord[],
tool: ToolIdentity | undefined,
lastModified: number,
toleranceMs = POSSIBLE_DUPLICATE_TOLERANCE_MS
): ImportFileRecord | undefined {
return files.find(file => isPossibleDuplicateScan(
tool,
lastModified,
file.detectedTool,
file.lastModifiedAt ?? file.importedAt,
toleranceMs
));
}
export function possibleDuplicateLabel(file: ImportFileRecord): string {
const date = file.lastModifiedAt ?? file.importedAt;
return `${file.detectedTool.name} from ${new Date(date).toLocaleString('en-US')}`;
}
/**
* Werkzeuge betten den Zeitpunkt der Erhebung in den Dateinamen ein, etwa
* Purple Knight mit
* `Security_Assessment_Report_domain.local_2026_07_22_08_23_22_(Indicators_results).csv`.
* Derselbe Bericht in einem anderen Ausgabeformat (HTML statt CSV) hat andere
* Bytes und eine andere Dateisystemzeit, aber denselben Namensstamm und
* denselben eingebetteten Zeitstempel - er ist ueber den SHA-256-Abgleich
* deshalb nicht als Wiederholung erkennbar.
*/
export const SAME_REPORT_NAME_TOLERANCE_MS = 2 * 60 * 60 * 1000;
const EMBEDDED_TIMESTAMP =
/(\d{4})[-_.]?(\d{2})[-_.]?(\d{2})[-_.T ]+(\d{2})[-_.:]?(\d{2})[-_.:]?(\d{2})/;
export interface ScanReportName {
/** Namensstamm ohne Zeitstempel, Teilbericht-Suffix und Endung. */
base: string;
/** Aus dem Dateinamen gelesener Erhebungszeitpunkt. */
timestamp: number;
}
/**
* Zerlegt einen Dateinamen in Namensstamm und eingebetteten Zeitstempel.
* Liefert undefined, wenn der Name keinen Zeitstempel traegt - dann darf nicht
* ueber den Namen verglichen werden, weil Werkzeuge wie PingCastle bei jedem
* Lauf denselben konstanten Dateinamen erzeugen (`ad_hc_domain.html`) und
* sonst jeder neue Scan faelschlich als Wiederholung gaelte.
*/
export function scanReportName(fileName: string): ScanReportName | undefined {
const withoutExtension = fileName.replace(/\.[^.]+$/, '');
const match = EMBEDDED_TIMESTAMP.exec(withoutExtension);
if (!match) return undefined;
const [, year, month, day, hour, minute, second] = match;
const timestamp = Date.parse(`${year}-${month}-${day}T${hour}:${minute}:${second}`);
if (!Number.isFinite(timestamp)) return undefined;
const base = withoutExtension
.replace(EMBEDDED_TIMESTAMP, ' ')
// Teilbericht-Suffix eines mehrteiligen Exports, z. B. "_(Indicators_results)".
.replace(/[([{][^)\]}]*[)\]}]\s*$/, ' ')
.toLocaleLowerCase('de-DE')
.replace(/[^a-z0-9]+/g, '');
return base ? { base, timestamp } : undefined;
}
/**
* Gehoeren beide Dateinamen zum selben Bericht? Dafuer muessen Namensstamm
* uebereinstimmen und die eingebetteten Zeitstempel innerhalb der Toleranz
* liegen.
*/
export function isSameScanReportName(
leftFileName: string,
rightFileName: string,
toleranceMs = SAME_REPORT_NAME_TOLERANCE_MS
): boolean {
const left = scanReportName(leftFileName);
const right = scanReportName(rightFileName);
return Boolean(left && right && left.base === right.base
&& Math.abs(left.timestamp - right.timestamp) <= toleranceMs);
}
export function findSameScanReportFile(
files: ImportFileRecord[],
fileName: string,
toleranceMs = SAME_REPORT_NAME_TOLERANCE_MS
): ImportFileRecord | undefined {
return files.find(file => isSameScanReportName(fileName, file.fileName, toleranceMs));
}
export function sameScanReportLabel(file: ImportFileRecord): string {
const parsed = scanReportName(file.fileName);
const date = parsed ? new Date(parsed.timestamp) : new Date(file.lastModifiedAt ?? file.importedAt);
return `${file.fileName} (report from ${date.toLocaleString('en-US')})`;
}