import catalog from '../profiles/public-search/catalog.json'; import type { JsonValue } from './canonical-model'; import type { PolicyBaselineVariantAssessment } from './policy-evaluation'; import type { UnifiedResultCategory, UnifiedResultProjection } from './unified-result'; import { wellKnownSidName } from '../shared/windows-codes'; import { applyResultDbIdentityGuard, RESULT_DB_IDENTITY_GUARD_VERSION, type ResultDbIdentityGuard } from './result-db-identity-guard'; import { addedPlaceholders, type ResultDbDisclosureTrace } from './result-db-trace'; export const RESULT_DB_DISCLOSURE_POLICY_VERSION = '3.4.0'; const SAFE_TIMESTAMP = '1970-01-01T00:00:00.000Z'; const toolCatalog: Record = { 'cis': { id: 'cis-cat', label: 'CIS-CAT' }, 'cis-cat': { id: 'cis-cat', label: 'CIS-CAT' }, 'maester': { id: 'maester', label: 'Maester' }, 'pingcastle': { id: 'pingcastle', label: 'PingCastle' }, 'purple-knight': { id: 'purple-knight', label: 'Purple Knight' }, 'purple-knight-ad': { id: 'purple-knight-ad', label: 'Purple Knight for Active Directory' }, 'purple-knight-entra': { id: 'purple-knight-entra', label: 'Purple Knight for Entra ID' }, 'purple-knight-combined': { id: 'purple-knight-combined', label: 'Purple Knight for Active Directory and Entra ID' }, 'purpleknight': { id: 'purple-knight', label: 'Purple Knight' }, 'gpos': { id: 'GPOs', label: 'Group Policy' }, 'group-policy': { id: 'GPOs', label: 'Group Policy' } }; function safeTool(value?: string): { id: string; label: string } { return toolCatalog[value?.trim().toLocaleLowerCase('en-US') ?? ''] ?? { id: 'generic', label: 'Security assessment' }; } function disclosureTool(item: UnifiedResultProjection): { id: string; label: string } { if (safeTool(item.sourceTool).id !== 'purple-knight') return safeTool(item.sourceTool); if (item.sourceToolScope === 'active-directory') return safeTool('purple-knight-ad'); if (item.sourceToolScope === 'entra-id') return safeTool('purple-knight-entra'); if (item.sourceToolScope === 'combined') return safeTool('purple-knight-combined'); return safeTool(item.sourceTool); } /** * Documented Microsoft GUIDs (ASR rules, first-party application IDs, built-in role templates) are * public guidance. Every other GUID can be a tenant, object or resource identifier and stays redacted. */ const publicGuids = new Set([...catalog.asrIds, ...catalog.microsoftIdentifiers].map(id => id.toLocaleLowerCase('en-US'))); const guidPattern = /\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/giu; function isPublicGuid(value: string): boolean { return publicGuids.has(value.toLocaleLowerCase('en-US')); } function exactCatalogId(values: readonly string[], value?: string): string | undefined { const folded = value?.trim().toLocaleLowerCase('en-US'); return folded && values.some(candidate => candidate.toLocaleLowerCase('en-US') === folded) ? value!.trim() : undefined; } function safePublicRuleId(item: UnifiedResultProjection): string | undefined { const tool = disclosureTool(item).id; const scannerFamily = tool.replace(/-(?:ad|entra|combined)$/, ''); if (tool === 'GPOs' && item.sourceKind === 'policy-evaluation') { const id = item.sourceRuleId?.trim(); return id && !/^gpo-policy-identity-[0-9a-f]+$/i.test(id) ? id : undefined; } if (scannerFamily === 'maester') return exactCatalogId(catalog.maesterIds, item.sourceRuleId); if (scannerFamily === 'pingcastle') return exactCatalogId(catalog.pingcastleIds, item.sourceRuleId); if (scannerFamily === 'purple-knight') { const id = item.sourceRuleId?.trim(); return id && /^SI\d{3,12}$/i.test(id) ? id : undefined; } if (scannerFamily !== 'cis-cat') return undefined; // CIS recommendation numbers reach seven levels (18.10.42.11.1.1.1); a shorter cap truncates them // into a sibling's number. A longer dotted run is not a recommendation number. const match = item.sourceRuleId?.match(/(?:^|[^0-9.])([0-9]+(?:\.[0-9]+){1,7})(?:$|\.?[^0-9.])/); return match?.[1]; } const publicDomainNames = new Set([ 'microsoft.com', 'windows.com', 'office.com', 'microsoftonline.com', 'azure.com', 'cisecurity.org' ]); const publicAccountPrefixes = /^(?:BUILTIN|NT AUTHORITY)\\/i; /** Built-in Windows authorities; names below them are documented Microsoft principals, not customer accounts. */ const publicAccountAuthorities = /(?:^|[^\p{L}\p{N}])(?:BUILTIN|NT AUTHORITY|NT SERVICE|NT VIRTUAL MACHINE|RESTRICTED SERVICES|Window Manager|Font Driver Host)$/iu; const publicDomainRoles = new Set([ 'domain admins', 'domain users', 'domain guests', 'domain computers', 'domain controllers', 'cert publishers', 'schema admins', 'enterprise admins', 'group policy creator owners', 'read-only domain controllers', 'cloneable domain controllers', 'protected users', 'key admins', 'enterprise key admins', 'ras and ias servers' ]); class PolicyValueRedactor { private readonly principals = new Map(); private readonly systemPrincipals = new Map(); private domainToken(_identity: string): string { return '[DOMAIN]'; } private principalToken(identity: string): string { const key = identity.toLocaleLowerCase('en-US'); const existing = this.principals.get(key); if (existing) return existing; const token = `[DOMAIN_PRINCIPAL_${this.principals.size + 1}]`; this.principals.set(key, token); return token; } private systemPrincipalToken(identity: string): string { const key = identity.toLocaleLowerCase('en-US'); const existing = this.systemPrincipals.get(key); if (existing) return existing; const token = `[SYSTEM_PRINCIPAL_${this.systemPrincipals.size + 1}]`; this.systemPrincipals.set(key, token); return token; } text(value: string, redactAccountNames = true): string { let redacted = value; redacted = redacted.replace(/(\*?)S-1-\d+(?:-\d+){2,}\b/giu, (match, marker: string) => { const sid = match.slice(marker.length); const domain = /^S-1-5-21-(\d+)-(\d+)-(\d+)-(\d+(?:-\d+)*)$/iu.exec(sid); if (domain) return `${marker}S-1-5-21-${this.domainToken(`${domain[1]}-${domain[2]}-${domain[3]}`)}-${domain[4]}`; if (isPublicWindowsSid(sid)) return match; return `${marker}${this.systemPrincipalToken(sid)}`; }); redacted = redacted.replace(/\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/giu, match => this.principalToken(match)); if (redactAccountNames) { redacted = redacted.replace(/\b[A-Za-z0-9._-]+\\[A-Za-z0-9._$ -]+\b/gu, (match, offset: number, input: string) => { const separator = match.indexOf('\\'); if (publicAccountPrefixes.test(match) || publicAccountAuthorities.test(input.slice(0, offset) + match.slice(0, separator))) return match; const domain = match.slice(0, separator); const principal = match.slice(separator + 1); return publicDomainRoles.has(principal.toLocaleLowerCase('en-US')) ? `${this.domainToken(domain)}\\${principal}` : this.principalToken(match); }); } redacted = redacted .replace(/\\\\[^\s\\]+\\[^\r\n]*/gu, '[PATH]') .replace(/\b[A-Za-z]:\\[^\r\n]*/gu, '[PATH]') .replace(guidPattern, match => isPublicGuid(match) ? match : '[ID]') .replace(/\b(?:[a-z0-9-]+\.)+[a-z]{2,}\b/giu, match => publicDomainNames.has(match.toLocaleLowerCase('en-US')) ? match : this.domainToken(match)); return redacted; } json(value: JsonValue | undefined, propertyName?: string): JsonValue | undefined { if (value === undefined) return undefined; if (typeof value === 'string') { const structural = /^(?:key|valueName|operation|kind|state|typeCode)$/i.test(propertyName ?? ''); return this.text(value, !structural); } if (value === null || typeof value === 'number' || typeof value === 'boolean') return value; if (Array.isArray(value)) return value.map(item => this.json(item, propertyName) as JsonValue); const blocked = new Set([ 'projectid', 'projectname', 'assessmentid', 'runid', 'scanrunid', 'artifactid', 'importartifactid', 'gpoid', 'gpoguid', 'gponame', 'backupid', 'backupguid', 'backupname', 'filename', 'filepath', 'sourcepath', 'sourcelocator' ]); return Object.fromEntries(Object.entries(value) .filter(([key]) => !blocked.has(key.replace(/[^a-z0-9]/gi, '').toLocaleLowerCase('en-US'))) .map(([key, child]) => [key, this.json(child, key) as JsonValue])); } } function safePolicyNarrative(item: UnifiedResultProjection, redactor: PolicyValueRedactor, value?: string): string | undefined { if (!value?.trim()) return undefined; let result = value.trim(); for (const literal of privateLiterals(item)) result = result.replace(new RegExp(escapeRegExp(literal), 'giu'), '[redacted]'); result = redactor.text(result).replace(/(?:\[redacted\]\s*){2,}/giu, '[redacted] ').trim(); return result || undefined; } const baselineFamilies: Array<[RegExp, string]> = [ [/\bWindows\s+Server\b/iu, 'Microsoft Security Baseline - Windows Server'], [/\bWindows\s+(?:1[01]|client)\b/iu, 'Microsoft Security Baseline - Windows Client'], [/\b(?:Microsoft\s+365|Office)\b/iu, 'Microsoft Security Baseline - Microsoft 365 Apps'], [/\bEdge\b/iu, 'Microsoft Security Baseline - Microsoft Edge'], [/\bCIS\b/iu, 'CIS Benchmark'], [/\bMicrosoft\b/iu, 'Microsoft Security Baseline'] ]; /** * Only the baseline family is disclosed. Concrete releases (for example Windows 11 25H2 or * Windows Server 2025) would reveal the operating-system generations of the environment. */ function safeBaselineLabel(item: UnifiedResultProjection): string { const label = item.policyBaselineLabel?.trim() ?? ''; return baselineFamilies.find(([pattern]) => pattern.test(label))?.[1] ?? 'Baseline recommendation'; } function safeBaselineVariantAssessment( assessment: PolicyBaselineVariantAssessment | undefined, redactor: PolicyValueRedactor ): PolicyBaselineVariantAssessment | undefined { if (!assessment) return undefined; // Customer values never leave internal analysis. Identical expectations are collapsed so the // number of entries does not reveal how many baseline releases were compared. const expectations = new Map(); for (const variant of assessment.variants) { const baselineValues = redactor.json(variant.baselineValues as unknown as JsonValue) as unknown as typeof variant.baselineValues; const comparator = { version: variant.comparator.version, comparator: variant.comparator.comparator, relation: variant.comparator.relation, baseline: redactor.json(variant.comparator.baseline as unknown as JsonValue) as typeof variant.comparator.baseline, diagnosticCodes: controlledDiagnosticCodes(variant.comparator.diagnosticCodes) ?? [] }; const key = JSON.stringify([baselineValues, comparator]); if (!expectations.has(key)) { expectations.set(key, { settingId: `baseline-expectation-${expectations.size + 1}`, baselineValues, comparator }); } } return { version: 1, scope: 'backup-configuration', decision: assessment.decision, actualValues: [], variants: [...expectations.values()], diagnosticCodes: controlledDiagnosticCodes(assessment.diagnosticCodes) ?? [] }; } const narrativeTools = new Set(['cis-cat', 'maester', 'pingcastle', 'purple-knight']); /** * Tokens whose digits are public knowledge rather than facts about the assessed environment: * SIDs (already reduced to public or placeholder form), dotted versions and benchmark numbers, * vulnerability/update identifiers, rule and technique IDs, product names, ports and event IDs. */ const publicNumericTokens: RegExp[] = [ /\bS-1-\d+(?:-\d+)+\b/giu, /\[[A-Z]+(?:_[A-Z]+)*_\d+\]/gu, /\b(?:CVE|CWE|KB|MS)-?\d+(?:-\d+)*\b/giu, /\bT\d{4}(?:\.\d{3})?\b/gu, /\b[A-Z][A-Za-z]{0,9}[.-]\d+(?:[.-]\d+)*\b/gu, /\b\d+(?:\.\d+)+\b/gu, // Versions written directly after a name: `Tls1.2`, `Graph v1.0`, `SMBv3.1.1`. /\b[A-Za-z]+\d+(?:\.\d+)+\b/gu, /\b(?:Windows(?:\s+Server)?|Exchange(?:\s+Server)?|SQL\s+Server|Office|SharePoint)\s+(?:\d{4}(?:[- ]?R2)?|9[58]|1[01]|8\.1|[78])\b/giu, // Server releases named without a product ("pre 2012-R2 schema"). /\b(?:19|20)\d{2}[- ]R2\b/gu, // References to numbered parts of the same guidance or its source ("step 2", "Option 1", "Page 16"). /\b(?:steps?|options?|page)\s+\d{1,3}\b/giu, /\b(?:Microsoft|Office|Windows|Dynamics)\s+365\b/giu, // Documented attribute flags (`adminCount=1`, `adminCount attribute set to 1`) and CISA baseline sections. /\b[a-z]+[A-Z][A-Za-z]*(?:\s+attribute\s+set\s+to\s+|\s*=\s*)\d+\b/gu, /\bCISA\s+\d+(?=\.)/gu, /\b(?:event(?:\s+id)?|port)\s+\d+\b/giu, /\b\d+-bit\b/giu ]; function protectedTokenKey(index: number): string { let result = ''; let rest = index; do { result = String.fromCharCode(97 + (rest % 26)) + result; rest = Math.floor(rest / 26) - 1; } while (rest >= 0); return result; } /** * Exact dates, durations and counts in scanner prose fingerprint the assessed environment and * calibrate attack effort ("1 DC", "15 accounts", "55 days", "0 day(s) ago"). They become typed * placeholders; the originals remain in internal analysis. */ /** Numbered steps ("1. Open …", "2) Select …") structure guidance; they are not observed quantities. */ const listMarker = /^[ \t]*\d{1,3}[.)](?=[ \t])/gmu; /** * Inline ordered lists ("… center. 2. Select …", "Activate the rules. 2) Network …"). A marker counts * only inside a run 1, 2, 3 … of at least two items with the same delimiter, so a lone count such as * "set to 1. In addition" is not mistaken for a list. */ function inlineOrderedListMarkers(text: string): Array<[number, number]> { const candidates = [...text.matchAll(/(? ({ start: match.index, end: match.index + match[0].length, number: Number(match[1]), delimiter: match[2] })); const accepted = new Set(); candidates.forEach((candidate, index) => { if (candidate.number !== 1 || accepted.has(index)) return; const run = [index]; for (let next = index + 1, expected = 2; next < candidates.length; next += 1) { if (candidates[next].delimiter !== candidate.delimiter) continue; const number = candidates[next].number; // Sources occasionally repeat or skip one step number ("3. … 3. … 5. …"); those stay in the list. if (number === expected || number === expected + 1 || (number === expected - 1 && run.length > 1)) { run.push(next); expected = Math.max(expected, number + 1); } } if (run.length >= 2) run.forEach(position => accepted.add(position)); }); return [...accepted].map(position => [candidates[position].start, candidates[position].end]); } const durationUnit = String.raw`(?:(?:second|minute|hour|day|week|month|year)(?:s|\(s\))?|characters?|character\(s\)|` + String.raw`Sekunden?|Minuten?|Stunden?|Tagen?|Tage?|Wochen?|Monaten?|Monate?|Jahren?|Jahre?|Zeichen)`; /** * Recommendation thresholds ("at least 14 characters", "more than 180 days", "15 or more") are * vendor guidance, not facts about the environment. Only the recommendation field uses this * exception; titles and descriptions carry scanner observations and stay fully redacted. */ const recommendationThresholds: RegExp[] = [ new RegExp(String.raw`\b(?:at (?:least|most)|minimum(?: of)?|maximum(?: of)?|no (?:more|less|fewer) than|(?:more|less|fewer|greater|higher|lower|older) than|up to|every|within)\s+\d+(?:[.,]\d+)?(?:\s*${durationUnit}(?!\p{L}))?`, 'giu'), new RegExp(String.raw`(?=|<=|=)\s*\d+\b/gu, /\b(?:zero|one|two|three)\s*\(\d+\)/giu, /(?(); const protect = (text: string, pattern: RegExp) => text.replace(pattern, match => { // Grouped thousands (1.015 or 1,015) are counts, not versions; a retained address-range boundary // such as 10.255.255.255 is not a grouped number. if (/^\d{1,3}(?:[.,]\d{3})+$/.test(match) && !publicAddressRangeBoundaries.has(match)) return match; const key = `\uE000${protectedTokenKey(protectedTokens.size)}\uE001`; protectedTokens.set(key, match); return key; }); let redacted = value .replace(/\b\d{4}-\d{2}-\d{2}(?:[T ]\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?)?\b/gu, '[date]') .replace(/\b\d{1,2}[./]\d{1,2}[./]\d{2,4}\b/gu, '[date]') .replace(/\b\d{1,2}:\d{2}(?::\d{2})?\b/gu, '[time]'); // Inline runs first, so a run that starts at a line start ("1. \u2026" newline "\u2026 2. \u2026") stays complete. for (const [start, end] of inlineOrderedListMarkers(redacted).sort((left, right) => right[0] - left[0])) { const key = `\uE000${protectedTokenKey(protectedTokens.size)}\uE001`; protectedTokens.set(key, redacted.slice(start, end)); redacted = redacted.slice(0, start) + key + redacted.slice(end); } redacted = protect(redacted, listMarker); if (keepRecommendationThresholds) { for (const pattern of recommendationThresholds) redacted = protect(redacted, pattern); } redacted = redacted .replace(/(? protectedTokens.get(key) ?? key); } while (restored !== redacted); return restored; } /** * Group Policy and registry paths are public setting locations. Their segments * (`Configuration\Policies`, `System\CurrentControlSet`) are not DOMAIN\account pairs. A span runs * from a recognized root to the end of the line or a semicolon. */ const publicPathRoots = new RegExp(String.raw`(?:\b(?:Computer|User) Configuration|\bHK(?:LM|CU|U|CR|CC)\b|\bHKEY_[A-Z_]+|` + String.raw`\b(?:Software|System)(?=\\(?:Policies|Microsoft|CurrentControlSet|Classes|WOW6432Node)\b)|` + String.raw`\bApplications and Services Logs|` + String.raw`\b(?:Windows Settings|Security Settings|Local Policies|Account Policies|Administrative Templates|` + String.raw`Windows Components|Advanced Audit Policy Configuration))\\[^;\r\n]*`, 'giu'); function publicPathSpans(value: string): Array<[number, number]> { return [...value.matchAll(publicPathRoots)].map(match => [match.index, match.index + match[0].length]); } /** * Hosts of vendor and standards documentation. Tenant hosts (`*.onmicrosoft.com`, `*.sharepoint.com`, * `login.microsoftonline.com/`) are deliberately absent and stay redacted. */ const publicReferenceHosts = [ 'microsoft.com', 'cloud.microsoft', 'azure.com', 'office.com', 'github.com', 'maester.dev', 'cisa.gov', 'dhs.gov', 'cisecurity.org', 'mitre.org', 'nist.gov', 'pingcastle.com', 'semperis.com', 'owasp.org', 'ietf.org', 'rfc-editor.org', 'bsi.bund.de', 'aka.ms', 'windows.com', 'whitehouse.gov', 'citrix.com' ]; function isPublicReferenceHost(host: string): boolean { const normalized = host.toLocaleLowerCase('en-US').replace(/\.$/, ''); return publicReferenceHosts.some(domain => normalized === domain || normalized.endsWith(`.${domain}`)); } /** Document and program names such as `pfirewall.log`, `gpedit.msc` or `aad.md` are not domains. */ const publicFileName = /\.(?:log|exe|dll|msc|msi|cpl|sys|inf|ini|admx|adml|md|rego|ps1|psm1|psd1|json|xml|csv|txt|evtx|etl|cab|reg|pol|cer|pem|pfx|bat|cmd|vbs|html?|aspx?|pdf|docx?|xlsx?|pptx?|png|svg)$/iu; /** Dotted PascalCase code identifiers (`RecommendationDetails.ReactView`); DNS names are not written this way. */ const pascalCaseIdentifier = /^[A-Z][a-z0-9]+[A-Z][A-Za-z0-9]*(?:\.[A-Z][A-Za-z0-9]*)+$/u; /** .NET and Windows API namespaces (`System.DateTime`, `Windows.Storage`) and `ASP.NET`. */ const codeNamespace = /^(?:(?:System|Microsoft|Windows)(?:\.[A-Z][A-Za-z0-9]*)+|ASP\.NET)$/u; /** Sentence-initial words that follow a missing space after a period ("endpoint.The"); never a TLD. */ // Two-letter words are excluded because they collide with country TLDs (.in, .it, .to, .as). const sentenceStarter = /^(?:The|This|That|These|Those|Its|For|When|Where|While|See|Note|You|Please|Use|Any|All|Also|After|Before|Once|Otherwise)$/u; /** Microsoft Graph permission names (`Directory.ReadWrite.All`, `Policy.ReadWrite.AuthenticationMethod`). */ const graphPermission = /^[A-Z][A-Za-z]+\.(?:Read|ReadWrite|ReadBasic|Write|Manage|Send|Create|Delete|Invite|Export|AccessAsUser)[A-Za-z]*(?:\.[A-Z][A-Za-z]+)*$/u; function isPublicReferenceUrl(value: string): boolean { try { const url = new URL(value.replace(/[.,;:!?]+$/u, '')); if (!/^https?:$/u.test(url.protocol) || url.username || url.password || !isPublicReferenceHost(url.hostname)) return false; // A link that already carries a redaction placeholder or an identifier is tenant-specific. if (/\[|\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/iu.test(value)) return false; // Azure resource paths name customer resources (resource groups, vaults, workspaces). if (/\/(?:subscriptions|resourceGroups|providers|resource)\//iu.test(decodeURIComponent(value))) return false; const rest = decodeURIComponent(`${url.pathname}${url.search}${url.hash}`); if (/@|onmicrosoft|sharepoint/iu.test(rest)) return false; // Any further host-like token in the path must itself be public, a file name or a PascalCase // code identifier such as the Entra blade `RecommendationDetails.ReactView`. return [...rest.matchAll(/(?:[a-z0-9-]+\.)+[a-z]{2,}/giu)] .every(match => isPublicReferenceHost(match[0]) || publicFileName.test(match[0]) || pascalCaseIdentifier.test(match[0]) // Microsoft module names in documentation paths (`/module/microsoft.powershell.core/`). || /^microsoft\.[a-z]/iu.test(match[0])); } catch { return false; } } const ipv4Octet = String.raw`(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)`; /** A standalone IPv4 address, never part of a longer dotted sequence such as CIS 18.9.20.1.1. */ const ipv4Address = new RegExp(String.raw`(? { if (typeof value !== 'string') return []; const trimmed = value.trim(); if (trimmed.length < 3) return []; const spaced = trimmed.replace(/[-_.]+/g, ' ').replace(/\s+/g, ' ').trim(); return spaced !== trimmed && spaced.length >= 3 ? [trimmed, spaced] : [trimmed]; }); return [...new Set(expanded)].sort((left, right) => right.length - left.length); } /** * Keeps rule guidance useful while removing identifiers that scanner prose can repeat. * Unknown sources never reach this path: their narrative remains fail-closed. */ function redactNarrative( item: UnifiedResultProjection, value?: string, field: 'title' | 'description' | 'recommendation' = 'description' ): string | undefined { if (!value?.trim() || !narrativeTools.has(disclosureTool(item).id.replace(/-(?:ad|entra|combined)$/, ''))) return undefined; let redacted = value.trim() .replace(/<(?:br|\/p|\/li|\/div)\s*\/?>/giu, '\n') .replace(/<\/?[a-z][^>]*>/giu, ' ') .replace(/ /giu, ' ') .replace(/&/giu, '&') .replace(/</giu, '<') .replace(/>/giu, '>') .replace(/"/giu, '"') .replace(/'/giu, "'"); const cisRuleId = disclosureTool(item).id === 'cis-cat' ? safePublicRuleId(item) : undefined; // Public references are held out of every later rule (hosts, quantities) and restored at the end. const references = new Map(); const keepReference = (value: string) => { const key = `\uE100${protectedTokenKey(references.size)}\uE101`; references.set(key, value); return key; }; redacted = redacted // Links are judged first and as a whole, before any other placeholder can cut them: a GUID, // address or account inside a link rejects the complete link (`…/subscriptions//resourceGroups/…`). // Only `]` ends a link, for markdown links whose text is the URL itself. .replace(/\b(?:https?|ldap|ldaps):\/\/[^\s<>()\]]+/giu, match => { // Sentence punctuation after a link is not part of it. const url = match.replace(/[.,;:!?]+$/u, ''); return `${isPublicReferenceUrl(url) ? keepReference(url) : '[url]'}${match.slice(url.length)}`; }) .replace(/\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/giu, '[email]') .replace(/\bS-1-\d+(?:-\d+){2,}\b/giu, match => isPublicWindowsSid(match) ? match : '[sid]') // A documented Microsoft GUID is held out like a public reference, so its digit groups are not counts. .replace(guidPattern, match => isPublicGuid(match) ? keepReference(match) : '[id]') .replace(/\b(?:CN|OU|DC)=[^,\r\n]+(?:,(?:CN|OU|DC)=[^,\r\n]+)+/giu, '[directory-object]'); const pathSpans = publicPathSpans(redacted); redacted = redacted .replace(/\b[A-Za-z0-9._-]+\\[A-Za-z0-9._$-]+\b/gu, (match, offset: number, input: string) => { // Inside a path, or the direct continuation of a path the source hard-wrapped mid-segment // ("…\Terminal" + newline + "Server\UserConfig"): the span ends in a segment character and // the match starts the very next line. if (pathSpans.some(([start, end]) => offset >= start && (offset < end || (/^\r?\n$/u.test(input.slice(end, offset)) && /[\p{L}\p{N}]/u.test(input[end - 1] ?? ''))))) return match; if (input[offset - 1] === '\\' || input[offset + match.length] === '\\') return match; const authority = input.slice(0, offset) + match.slice(0, match.indexOf('\\')); return publicAccountAuthorities.test(authority) ? match : '[account]'; }) .replace(/\\\\[^\s\\]+\\[^\r\n]*/gu, '[network-path]') .replace(/\b[A-Za-z]:\\[^\r\n]*/gu, '[path]') .replace(ipv4Address, (match, offset: number, input: string) => (cisRuleId && (cisRuleId === match || cisRuleId.startsWith(`${match}.`))) || /\bRules?\s+$/iu.test(input.slice(Math.max(0, offset - 8), offset)) || publicAddressRangeBoundaries.has(match) ? match : '[ip]') .replace(/\b(?:[0-9a-f]{2}:){5}[0-9a-f]{2}\b/giu, '[mac]') .replace(/\b(?:[a-z0-9-]+\.)+[a-z]{2,}\b/giu, (match, offset: number, input: string) => { if (input[offset - 1] === '@') return '[domain]'; if (isPublicReferenceHost(match)) return keepReference(match); if (publicFileName.test(match) || graphPermission.test(match) || codeNamespace.test(match) || pascalCaseIdentifier.test(match)) return match; // Property access in code samples (`('Fido2') .keyRestrictions.isEnforced`). if (input[offset - 1] === '.') return match; // A missing space after a sentence ("service.This indicator") is not a host name. if (sentenceStarter.test(match.slice(match.lastIndexOf('.') + 1)) && /^\s/u.test(input.slice(offset + match.length))) return match; // Dotted policy identifiers such as ScubaGear `MS.AAD.2.3` continue with a numeric segment. if (/^\.\d/u.test(input.slice(offset + match.length))) return match; return '[domain]'; }) // Legal forms are matched in their registered spelling only. `Limited` is omitted: in guidance it is // an ordinary word ("Limited number of Global Admins", "sharing limited to existing guests"). .replace(/\b(?:[\p{L}\p{N}&.'-]+\s+){0,4}(?:GmbH|AG|SE|KG|OHG|UG|Inc\.?|Ltd\.?|LLC|Corp(?:oration)?\.?)(?![\p{L}\p{N}_])/gu, '[organization]'); for (const literal of privateLiterals(item)) { redacted = redacted.replace(new RegExp(escapeRegExp(literal), 'giu'), '[redacted]'); } // CIS-CAT prose is benchmark text; its numbers are public recommendations, not observations. if (disclosureTool(item).id !== 'cis-cat') redacted = redactEnvironmentQuantities(redacted, field === 'recommendation'); const literals = privateLiterals(item).map(literal => literal.toLocaleLowerCase('en-US')); redacted = redacted.replace(/\uE100[a-z]+\uE101/gu, key => { const reference = references.get(key) ?? key; // A public reference that still carries a private literal is not public. return literals.some(literal => reference.toLocaleLowerCase('en-US').includes(literal)) ? (reference.includes('://') ? '[url]' : isPublicGuid(reference) ? '[id]' : '[domain]') : reference; }); redacted = redacted .replace(/(?:\[redacted\]\s*){2,}/gu, '[redacted] ') .replace(/[ \t]{2,}/g, ' ') // Only sentence punctuation: " .onmicrosoft.com" or " .NET" must keep its leading space. .replace(/\s+([,.;:!?])(?![\p{L}\p{N}])/gu, '$1') .replace(/\s*\n\s*/g, '\n') .replace(/\n{3,}/g, '\n\n') .trim(); return redacted || undefined; } function controlledCategory(item: UnifiedResultProjection): UnifiedResultCategory[] { if (item.sourceKind === 'environment-observation') return item.categories; if (item.sourceKind === 'policy-evaluation') { const code = item.policyClassification ?? 'comparison'; const labels: Record = { misconfigured: 'Deviation', 'not-configured': 'Not configured', match: 'Passed', conflict: 'Conflicts', duplicate: 'Duplicates', 'review-required': 'Review required', comparison: 'Comparison' }; return [{ namespace: 'group-policy', categoryCode: code, categoryLabel: labels[code] ?? 'Comparison', categoryType: 'canonical', isPrimary: true, sourceTool: 'GPOs' }]; } const tool = disclosureTool(item); return [{ namespace: 'source-tool', categoryCode: tool.id, categoryLabel: tool.label, categoryType: 'canonical', isPrimary: true, sourceTool: tool.id }]; } function controlledTitle(item: UnifiedResultProjection): string { if (item.sourceKind === 'environment-observation') return item.title; if (item.sourceKind === 'policy-evaluation') { if (item.title.trim()) return item.title; if (item.policyClassification === 'conflict') return 'Group Policy consistency conflict'; if (item.policyClassification === 'duplicate') return 'Group Policy duplicate configuration'; if (item.policyDeliveryMechanism === 'advanced-audit-policy') return 'Group Policy audit-policy comparison'; if (item.policyDeliveryMechanism === 'security-template') return 'Group Policy security-setting comparison'; return 'Group Policy registry-setting comparison'; } const tool = disclosureTool(item); const ruleId = safePublicRuleId(item); const title = redactNarrative(item, item.title, 'title'); if (title) return title; return ruleId ? `${tool.label} public rule ${ruleId}` : `${tool.label} finding`; } function controlledDescription(item: UnifiedResultProjection): string | undefined { if (item.sourceKind !== 'policy-evaluation') return redactNarrative(item, item.description, 'description'); if (item.policyClassification === 'conflict') { return 'Different retained technical states were detected for the same Group Policy setting.'; } if (item.policyClassification === 'duplicate') { return 'The same technical state was detected in multiple Group Policy sources.'; } return 'Configured Group Policy value compared with the retained baseline recommendation. Backup comparison; effective endpoint state not verified.'; } function controlledDiagnosticCodes(values?: string[]): string[] | undefined { const safe = values?.filter(value => /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(value)); return safe?.length ? safe : undefined; } type FieldDisclosure = 'disclose' | 'withhold'; /** * Every projection field must be classified here; a new field fails compilation until it is. * `disclose` fields are either safe as-is or rebuilt/redacted below; scope handles are replaced * by removeResultDbScopeIdentifiers and the JSONL export after filtering. `withhold` fields are * internal analysis only: customer identities, provenance, source timestamps and client-owned * review/risk-acceptance state. A per-finding `score` is withheld: scanner indicator scores (Purple * Knight) and computed rule points (PingCastle) scale with the affected objects and calibrate attack effort. */ const RESULT_DB_FIELD_DISCLOSURE: Record = { resultProjectionId: 'disclose', sourceKind: 'disclose', sourceResultId: 'disclose', projectId: 'disclose', assessmentId: 'disclose', collectionRunId: 'disclose', scanRunId: 'disclose', gpoSnapshotId: 'disclose', evaluationRunId: 'disclose', sourceTool: 'disclose', sourceToolScope: 'disclose', sourceToolVersion: 'withhold', sourceProfileId: 'disclose', sourceProfileVersion: 'disclose', mappingRuleVersion: 'disclose', normalizationRuleVersion: 'disclose', presentationVersion: 'disclose', processingCompatibilityKey: 'disclose', processingStatus: 'disclose', reprocessingAvailable: 'disclose', sourceRuleId: 'disclose', canonicalDefinitionId: 'withhold', importArtifactId: 'withhold', sourceViewId: 'withhold', sourceLocator: 'withhold', environmentProperty: 'disclose', environmentSubjectType: 'disclose', environmentSubjectIdentity: 'withhold', environmentValueType: 'disclose', environmentState: 'disclose', environmentOriginalValue: 'withhold', environmentNormalizedValue: 'disclose', environmentSectionKey: 'withhold', environmentAttributes: 'withhold', supplementalDisclosurePolicyVersion: 'disclose', supplementalDisclosureClass: 'disclose', policyScope: 'disclose', policyDeliveryMechanism: 'disclose', policyState: 'disclose', policyIdentityStatus: 'disclose', policyIdentityKey: 'withhold', policyValues: 'withhold', policyDiagnosticCodes: 'disclose', policyBackupInstanceId: 'withhold', policyBackupLinkStatus: 'disclose', policyPreferenceCse: 'disclose', policyPreferenceAction: 'disclose', policyPreferenceTargeting: 'disclose', policyGpoId: 'withhold', policyNativeBackupId: 'withhold', policySourceSequence: 'withhold', policyBaselineId: 'withhold', policyBaselineLabel: 'disclose', policyBaselineValue: 'disclose', policyBaselineVariantAssessment: 'disclose', policyActualValue: 'withhold', policyComparisonRelation: 'disclose', policyCustomerState: 'disclose', policyClassification: 'disclose', policySourceCount: 'withhold', title: 'disclose', description: 'disclose', recommendation: 'disclose', affectedAsset: 'withhold', score: 'withhold', severityOriginal: 'disclose', severityNormalized: 'disclose', outcomeOriginal: 'disclose', outcomeNormalized: 'disclose', technicalExecutionState: 'disclose', resultType: 'disclose', isIoe: 'disclose', isDeviation: 'disclose', exposureState: 'disclose', effectiveState: 'disclose', lifecycleState: 'disclose', mappingConfidence: 'disclose', applicabilityState: 'disclose', completenessState: 'disclose', scopeState: 'disclose', effectivenessVerification: 'disclose', scopeConfidence: 'disclose', reviewStatus: 'withhold', reviewDisposition: 'withhold', reviewPriority: 'withhold', reviewOwner: 'withhold', reviewDueDate: 'withhold', sourceObservedAt: 'withhold', importedAt: 'disclose', evaluatedAt: 'withhold', createdAt: 'disclose', categories: 'disclose', frameworkReferences: 'disclose', disclosureTrace: 'disclose' }; /** Drops withheld and unclassified fields, so fields added at runtime cannot pass through. */ function onlyDisclosedFields(item: UnifiedResultProjection): UnifiedResultProjection { return Object.fromEntries(Object.entries(item).filter(([key]) => RESULT_DB_FIELD_DISCLOSURE[key as keyof UnifiedResultProjection] === 'disclose')) as unknown as UnifiedResultProjection; } /** * Fail-closed public projection used by Result DB and every downstream LLM/export path. * Internal analysis keeps the canonical source records and provenance unchanged. */ export function discloseResultDbProjection(item: UnifiedResultProjection, identityGuard?: ResultDbIdentityGuard): UnifiedResultProjection { return applyResultDbIdentityGuard(onlyDisclosedFields(disclosedValues(item)), identityGuard); } const tracedFields = [ 'title', 'description', 'recommendation', 'sourceRuleId', 'policyBaselineLabel', 'policyBaselineValue', 'policyBaselineVariantAssessment', 'environmentNormalizedValue', 'categories' ] as const satisfies ReadonlyArray; const untracedWithheldFields = new Set([ 'reviewStatus', 'reviewDisposition', 'reviewPriority', 'reviewOwner', 'reviewDueDate' ] satisfies Array); function hasValue(value: unknown): boolean { return value !== undefined && value !== null && value !== '' && !(Array.isArray(value) && value.length === 0); } /** * The materialized Result DB row: the disclosed projection plus its content-free redaction trace. * The trace is derived from the placeholders each step added per field, so it describes exactly the * stored row and never repeats an original value. */ export function discloseResultDbProjectionWithTrace( item: UnifiedResultProjection, identityGuard?: ResultDbIdentityGuard ): UnifiedResultProjection { const disclosed = onlyDisclosedFields(disclosedValues(item)); const guarded = applyResultDbIdentityGuard(disclosed, identityGuard); const redactions: ResultDbDisclosureTrace['redactions'] = []; for (const field of tracedFields) { for (const entry of addedPlaceholders(item[field], disclosed[field])) redactions.push({ field, ...entry }); for (const entry of addedPlaceholders(disclosed[field], guarded[field], rule => rule === 'private-value' ? 'identity-guard' : rule)) { redactions.push({ field, ...entry }); } } if (item.policyBaselineLabel && guarded.policyBaselineLabel && item.policyBaselineLabel !== guarded.policyBaselineLabel) { redactions.push({ field: 'policyBaselineLabel', rule: 'baseline-family', count: 1 }); } const collapsed = (item.policyBaselineVariantAssessment?.variants.length ?? 0) - (guarded.policyBaselineVariantAssessment?.variants.length ?? 0); if (collapsed > 0) redactions.push({ field: 'policyBaselineVariantAssessment', rule: 'baseline-expectations-collapsed', count: collapsed }); // Client-owned review state is withheld without a trace entry: even its presence would reveal // which findings the client has triaged or prioritized. const withheldFields = Object.keys(item) .filter(key => !untracedWithheldFields.has(key)) .filter(key => hasValue(item[key as keyof UnifiedResultProjection]) && !hasValue(guarded[key as keyof UnifiedResultProjection])) .sort(); const guardWithheldFields = tracedFields.filter(field => JSON.stringify(guarded[field] ?? '').includes('[withheld by identity guard]')); return { ...guarded, disclosureTrace: { version: 1, disclosurePolicyVersion: RESULT_DB_DISCLOSURE_POLICY_VERSION, identityGuardVersion: RESULT_DB_IDENTITY_GUARD_VERSION, redactions, withheldFields, guardWithheldFields } }; } function disclosedValues(item: UnifiedResultProjection): UnifiedResultProjection { const tool = disclosureTool(item); const policyEvaluation = item.sourceKind === 'policy-evaluation'; const policyRedactor = policyEvaluation ? new PolicyValueRedactor() : undefined; const rawPublicRuleId = safePublicRuleId(item); const publicRuleId = policyRedactor && rawPublicRuleId ? policyRedactor.text(rawPublicRuleId, false) : rawPublicRuleId; const policyDescription = policyRedactor ? safePolicyNarrative(item, policyRedactor, item.description) : undefined; const publicBaselineValue = policyRedactor ? policyRedactor.json(item.policyBaselineValue) : undefined; const publicBaselineVariants = policyRedactor ? safeBaselineVariantAssessment(item.policyBaselineVariantAssessment, policyRedactor) : undefined; return { ...item, frameworkReferences: [], sourceTool: tool.id, sourceToolVersion: undefined, sourceProfileId: tool.id, sourceRuleId: publicRuleId, canonicalDefinitionId: undefined, importArtifactId: undefined, sourceViewId: undefined, sourceLocator: undefined, environmentSubjectIdentity: undefined, environmentOriginalValue: undefined, environmentAttributes: undefined, environmentSectionKey: undefined, policyValues: undefined, policyBackupInstanceId: undefined, policyGpoId: undefined, policyNativeBackupId: undefined, policySourceSequence: undefined, policyIdentityKey: undefined, policyBaselineId: undefined, policyBaselineLabel: policyRedactor ? safeBaselineLabel(item) : undefined, policyBaselineValue: publicBaselineValue, policyBaselineVariantAssessment: publicBaselineVariants, policyActualValue: undefined, policySourceCount: undefined, policyDiagnosticCodes: controlledDiagnosticCodes(item.policyDiagnosticCodes), // A GPO title is the technical setting identity (ADMX name, registry key or section/key), like the // rule ID; `Software\Policies` is a registry path, not a DOMAIN\account pair. title: policyRedactor ? policyRedactor.text(controlledTitle(item), false) : controlledTitle(item), description: policyEvaluation ? [policyDescription, controlledDescription(item)].filter(Boolean).join(' ') : controlledDescription(item), recommendation: policyRedactor ? safePolicyNarrative(item, policyRedactor, item.recommendation) : redactNarrative(item, item.recommendation, 'recommendation'), affectedAsset: undefined, severityOriginal: item.severityNormalized, outcomeOriginal: item.outcomeNormalized, reviewOwner: undefined, reviewDueDate: undefined, sourceObservedAt: undefined, importedAt: SAFE_TIMESTAMP, evaluatedAt: undefined, createdAt: SAFE_TIMESTAMP, categories: controlledCategory(item) }; } /** Remove scope handles only after filtering/paging has completed. */ export function removeResultDbScopeIdentifiers(item: UnifiedResultProjection): UnifiedResultProjection { return { ...item, projectId: 'scoped-project', assessmentId: item.assessmentId ? 'scoped-assessment' : undefined, collectionRunId: 'scoped-collection', scanRunId: item.scanRunId ? 'scoped-scan' : undefined, gpoSnapshotId: undefined, evaluationRunId: undefined }; } export function resultDbDisclosureTool(value?: string): string { return safeTool(value).id; }