164 lines
7.1 KiB
TypeScript
164 lines
7.1 KiB
TypeScript
import type { JsonValue, OriginalField, SourcePosition } from './canonical-model';
|
|
|
|
/** Independent derivation: these references never become FindingDefinition identity keys. */
|
|
export const REFERENCE_RECOGNIZER_VERSION = '1.0.0';
|
|
export type ReferenceFramework = 'mitre-attack' | 'mitre-d3fend';
|
|
export interface ReferenceCatalogEntry {
|
|
id: string;
|
|
title: string;
|
|
kind: 'technique' | 'tactic';
|
|
state: 'active' | 'deprecated' | 'revoked';
|
|
url: string;
|
|
tactics: string[];
|
|
}
|
|
export interface ReferenceCatalog {
|
|
schemaVersion: 1;
|
|
framework: ReferenceFramework;
|
|
version: string;
|
|
sourceUrl: string;
|
|
sourceSha256: string;
|
|
license: string;
|
|
entries: ReferenceCatalogEntry[];
|
|
}
|
|
export interface ReferenceCatalogSnapshot extends ReferenceCatalog {
|
|
/** SHA-256 of canonicalJson(catalog), including every recognition input and the notice. */
|
|
sha256: string;
|
|
}
|
|
export interface RecognizedReference {
|
|
framework: ReferenceFramework;
|
|
id: string;
|
|
title?: string;
|
|
kind?: 'technique' | 'tactic';
|
|
url?: string;
|
|
tactics: string[];
|
|
status: 'active' | 'deprecated' | 'revoked' | 'not-in-catalog';
|
|
evidence: 'declared' | 'mentioned';
|
|
matchedBy: 'id' | 'exact-title';
|
|
catalogVersion: string;
|
|
catalogSha256: string;
|
|
source: {
|
|
fieldKey: string;
|
|
fieldLabel: string;
|
|
fieldPath?: string;
|
|
/** JSON pointer within value; rawValue is authoritative when present. */
|
|
valuePath: string;
|
|
representation: 'rawValue' | 'value';
|
|
start: number;
|
|
end: number;
|
|
offsetUnit: 'utf16';
|
|
text: string;
|
|
};
|
|
}
|
|
export interface ReferenceOccurrenceResult {
|
|
occurrenceId: string;
|
|
importFileId: string;
|
|
sourceFileSha256?: string;
|
|
sourcePosition: SourcePosition;
|
|
references: RecognizedReference[];
|
|
}
|
|
export interface ReferenceRecognitionRun {
|
|
schemaVersion: 1;
|
|
id: string;
|
|
projectId: string;
|
|
assessmentId: string;
|
|
scanRunId: string;
|
|
recognizerVersion: string;
|
|
inputSha256: string;
|
|
catalogHashes: string[];
|
|
createdAt: string;
|
|
results: ReferenceOccurrenceResult[];
|
|
}
|
|
export interface FindingReferenceRecognition {
|
|
runId: string;
|
|
recognizerVersion: string;
|
|
createdAt: string;
|
|
references: RecognizedReference[];
|
|
}
|
|
|
|
/** Header semantics are framework-specific, never scanner-specific. */
|
|
export function declaredReferenceFramework(label: string): ReferenceFramework | undefined {
|
|
const value = label.trim().toLowerCase().replace(/[_-]+/g, ' ');
|
|
if (/\b(?:d3fend|mitre\s*(?:d3fend|defen[cs]e))\b/.test(value)) return 'mitre-d3fend';
|
|
if (/\b(?:att&?ck|mitre\s*(?:att&?ck|attack))\b/.test(value)) return 'mitre-attack';
|
|
return undefined;
|
|
}
|
|
|
|
function* strings(value: JsonValue, path = ''): Generator<{ text: string; path: string }> {
|
|
if (typeof value === 'string') yield { text: value, path };
|
|
else if (Array.isArray(value)) {
|
|
for (let i = 0; i < value.length; i++) yield* strings(value[i], `${path}/${i}`);
|
|
} else if (value && typeof value === 'object') {
|
|
for (const [key, item] of Object.entries(value)) yield* strings(item, `${path}/${key.replace(/~/g, '~0').replace(/\//g, '~1')}`);
|
|
}
|
|
}
|
|
const normalizeTitle = (value: string) => value.trim().replace(/\s+/g, ' ').toLowerCase();
|
|
|
|
/** Build indexes once per run, then process every source field offline. */
|
|
export function createReferenceRecognizer(catalogs: readonly ReferenceCatalogSnapshot[]) {
|
|
const indexes = catalogs.map(catalog => {
|
|
const titles = new Map<string, ReferenceCatalogEntry[]>();
|
|
for (const entry of catalog.entries) {
|
|
const key = normalizeTitle(entry.title);
|
|
titles.set(key, [...(titles.get(key) ?? []), entry]);
|
|
}
|
|
return { catalog, ids: new Map(catalog.entries.map(entry => [entry.id.toUpperCase(), entry])), titles };
|
|
});
|
|
return (fields: readonly OriginalField[]): RecognizedReference[] => {
|
|
const results: RecognizedReference[] = [];
|
|
for (const field of fields) {
|
|
const declared = declaredReferenceFramework(field.label);
|
|
const representation = typeof field.rawValue === 'string' ? 'rawValue' : 'value';
|
|
for (const { text, path } of strings(representation === 'rawValue' ? field.rawValue! : field.value)) {
|
|
for (const { catalog, ids, titles } of indexes) {
|
|
const evidence = declared === catalog.framework ? 'declared' : 'mentioned';
|
|
const occupied: Array<[number, number]> = [];
|
|
const add = (id: string, start: number, end: number, matchedBy: 'id' | 'exact-title', entry?: ReferenceCatalogEntry) => {
|
|
occupied.push([start, end]);
|
|
results.push({ framework: catalog.framework, id: entry?.id ?? id,
|
|
...(entry ? { title: entry.title, kind: entry.kind, url: entry.url } : {}),
|
|
tactics: entry?.tactics ?? [], status: entry?.state ?? 'not-in-catalog', evidence, matchedBy,
|
|
catalogVersion: catalog.version, catalogSha256: catalog.sha256,
|
|
source: { fieldKey: field.key, fieldLabel: field.label, ...(field.path ? { fieldPath: field.path } : {}),
|
|
valuePath: path, representation, start, end, offsetUnit: 'utf16', text: text.slice(start, end) } });
|
|
};
|
|
// Unicode-aware boundaries prevent partial IDs, embedded host names and longer dotted numbers.
|
|
const pattern = catalog.framework === 'mitre-attack'
|
|
? /(?<![\p{L}\p{N}_.-])(?:TA\d{4}|T\d{4}(?:\.\d{3})?)(?![\p{L}\p{N}_]|[.-][\p{L}\p{N}])/giu
|
|
: /(?<![\p{L}\p{N}_-])D3-[A-Z][A-Z0-9]*(?![\p{L}\p{N}_-])/giu;
|
|
for (const match of text.matchAll(pattern)) {
|
|
const id = match[0].toUpperCase();
|
|
add(id, match.index!, match.index! + match[0].length, 'id', ids.get(id));
|
|
}
|
|
if (evidence !== 'declared') continue;
|
|
// Only whole, explicitly delimited catalog titles; no fuzzy matches or prose inference.
|
|
for (const fragment of text.matchAll(/[^;,|/\r\n]+/g)) {
|
|
const pieces = [...fragment[0].matchAll(/[^\u2013\u2014]+/g)];
|
|
const candidates = [{ text: fragment[0], start: fragment.index! },
|
|
...pieces.map(piece => ({ text: piece[0], start: fragment.index! + piece.index! }))];
|
|
const separator = fragment[0].lastIndexOf(' - ');
|
|
if (separator >= 0) candidates.push({ text: fragment[0].slice(separator + 3), start: fragment.index! + separator + 3 });
|
|
for (const candidate of candidates) {
|
|
const trimmed = candidate.text.trim();
|
|
const start = candidate.start + candidate.text.indexOf(trimmed);
|
|
const end = start + trimmed.length;
|
|
if (!trimmed || occupied.some(([a, b]) => start < b && end > a)) continue;
|
|
const entries = titles.get(normalizeTitle(trimmed));
|
|
if (entries?.length === 1) add(entries[0].id, start, end, 'exact-title', entries[0]);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return results;
|
|
};
|
|
}
|
|
|
|
export function latestReferenceRuns(runs: readonly ReferenceRecognitionRun[]): ReferenceRecognitionRun[] {
|
|
const latest = new Map<string, ReferenceRecognitionRun>();
|
|
for (const run of runs) {
|
|
const previous = latest.get(run.scanRunId);
|
|
if (!previous || `${run.createdAt}\0${run.id}` > `${previous.createdAt}\0${previous.id}`) latest.set(run.scanRunId, run);
|
|
}
|
|
return [...latest.values()];
|
|
}
|