84 lines
5.2 KiB
TypeScript
84 lines
5.2 KiB
TypeScript
import type { FindingRecord } from '../../domain/finding';
|
|
|
|
export const SECURITY_DOMAIN_FACET_ID = 'derived:security-domain';
|
|
|
|
export const securityDomainCatalog = [
|
|
{ id: 'identity', label: 'Identity & Account Hygiene', glyph: 'user', pattern: /account|identity|user|password|stale|inactive|honey\s*pot/i },
|
|
{ id: 'privilege', label: 'Privilege & Attack Paths', glyph: 'shield', pattern: /privileg|admin|delegat|control\s*path|attack\s*path|tier\s*0|pim/i },
|
|
{ id: 'authentication', label: 'Authentication & Access', glyph: 'lock', pattern: /authenticat|\bmfa\b|conditional\s*access|kerberos|sign[ -]?in|break[ -]?glass|\bldaps?\b|channel\s*binding|anonymous\s*binding|rootdse|tls\s*1\.[01]/i },
|
|
{ id: 'infrastructure', label: 'AD Infrastructure & PKI', glyph: 'server', pattern: /domain\s*controller|forest|\btrust|certificate|\bpki\b|\bdns\b|replication|schema|organizational\s*units?|\bous?\b.*accidental\s*deletion|\bntfrs\b|\bsysvol\b/i },
|
|
{ id: 'hardening', label: 'System Hardening', glyph: 'code', pattern: /cis-cat|benchmark|hardening|hardened\s*paths?|firewall|print\s*spooler|\bspooler\b|attack\s*surface|\basr\b|defender|operating\s*system|obsolete\s+os|unsupported\s+(?:os|operating\s*system)|script\s*engines?|\bcve-\d+/i },
|
|
{ id: 'gpo', label: 'GPO & Baseline', glyph: 'policy', pattern: /\bgpo\b|group\s*policy|baseline|policy\s*(?:setting|deviation|conflict)|duplicates?/i },
|
|
{ id: 'cloud', label: 'Cloud & Applications', glyph: 'cloud', pattern: /\bcloud\b|\bentra\b|application|\bapps?\b|exchange|teams|sharepoint|guest|intune|purview/i },
|
|
{ id: 'recovery', label: 'Monitoring & Recovery', glyph: 'chart', pattern: /audit|monitor|logging|\bwef\b|backup|recover|recycle\s*bin|sensor|event\s*log/i }
|
|
] as const;
|
|
|
|
export const unclassifiedSecurityDomain = { id: 'unclassified', label: 'Unclassified' } as const;
|
|
|
|
const MAESTER_CLOUD_SUITE = /(?:^|[\s,;|\["'])(?:orca(?:\.|\b)|exo(?=$|[\s,;|\]"'])|eidsca(?:\.|\b)|cisa\.ms\.(?:exo|aad)(?:\.|\b)|maester\/(?:exchange|teams|entra)(?=$|[\s,;|\]"']))/i;
|
|
|
|
const recordDomainEvidenceCache = new WeakMap<FindingRecord, {
|
|
text: string;
|
|
narrativeText: string;
|
|
explicitGpo: boolean;
|
|
scannerSuiteText: string;
|
|
}>();
|
|
|
|
function recordDomainEvidence(record: FindingRecord): {
|
|
text: string;
|
|
narrativeText: string;
|
|
explicitGpo: boolean;
|
|
scannerSuiteText: string;
|
|
} {
|
|
let cached = recordDomainEvidenceCache.get(record);
|
|
if (cached === undefined) {
|
|
const fields = record.fields;
|
|
cached = {
|
|
text: [record.title, ...fields
|
|
.filter(field => /^(?:title|name|finding|description|category|subcategory|sub-category|categories|control area|policy section|policy type|source rule id)$/i.test(field.label.trim()))
|
|
.map(field => field.value)].join(' '),
|
|
narrativeText: fields
|
|
.filter(field => /^(?:technical explanation|rationale|recommendation|remediation(?: guidance| recommendation)?|advised solution|solution|impact|details|test details)$/i.test(field.label.trim()))
|
|
.map(field => field.value).join(' '),
|
|
explicitGpo: fields.some(field => /^category$/i.test(field.label.trim()) && /^gpos?$/i.test(field.value.trim())),
|
|
// Maester exposes stable suite/control identifiers in BLOCK, ID and TAG.
|
|
// They are fallback evidence only: descriptive security-domain matches
|
|
// above retain priority and the source values remain unchanged.
|
|
scannerSuiteText: [record.title, ...fields
|
|
.filter(field => /^(?:block|id|tag|tags|category|categories)$/i.test(field.label.trim()))
|
|
.map(field => field.value)].join(' ')
|
|
};
|
|
recordDomainEvidenceCache.set(record, cached);
|
|
}
|
|
return cached;
|
|
}
|
|
|
|
/** The one shared Client Info / Work classification for a retained record. */
|
|
export function securityDomainIdForRecord(record: FindingRecord): string {
|
|
const evidence = recordDomainEvidence(record);
|
|
if (evidence.explicitGpo) return 'gpo';
|
|
const descriptiveMatch = securityDomainCatalog.find(domain => domain.pattern.test(evidence.text));
|
|
if (descriptiveMatch) return descriptiveMatch.id;
|
|
// Broader narrative fields are useful fallback evidence, but they may mention
|
|
// several domains in one remediation. Assign only when the evidence is unique;
|
|
// otherwise keeping Unclassified is more honest than inventing a category.
|
|
const narrativeMatches = securityDomainCatalog.filter(domain => domain.pattern.test(evidence.narrativeText));
|
|
if (narrativeMatches.length === 1) return narrativeMatches[0].id;
|
|
if (MAESTER_CLOUD_SUITE.test(evidence.scannerSuiteText)) return 'cloud';
|
|
return unclassifiedSecurityDomain.id;
|
|
}
|
|
|
|
/** Assign every record once so Client Info distributions reconcile to the total. */
|
|
export function dashboardDomainAssignment(records: FindingRecord[]): Map<string, FindingRecord[]> {
|
|
const buckets = new Map<string, FindingRecord[]>([
|
|
...securityDomainCatalog.map(domain => [domain.id, [] as FindingRecord[]] as const),
|
|
[unclassifiedSecurityDomain.id, [] as FindingRecord[]] as const
|
|
]);
|
|
records.forEach(record => buckets.get(securityDomainIdForRecord(record))!.push(record));
|
|
return buckets;
|
|
}
|
|
|
|
export function dashboardDomainRecords(records: FindingRecord[], domainId: string): FindingRecord[] {
|
|
return dashboardDomainAssignment(records).get(domainId) ?? [];
|
|
}
|