823 lines
45 KiB
TypeScript
823 lines
45 KiB
TypeScript
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<string, { id: string; label: string }> = {
|
|
'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<string, string>();
|
|
private readonly systemPrincipals = new Map<string, string>();
|
|
|
|
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<string, PolicyBaselineVariantAssessment['variants'][number]>();
|
|
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(/(?<![\p{L}\p{N}_.,])(\d{1,2})([.)])(?=[ \t]+\S)/gu)]
|
|
.map(match => ({ start: match.index, end: match.index + match[0].length, number: Number(match[1]), delimiter: match[2] }));
|
|
const accepted = new Set<number>();
|
|
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`(?<![\p{L}\p{N}_])\d+(?:[.,]\d+)?(?:\s*${durationUnit})?\s+or\s+(?:more|less|fewer|greater|higher|lower)\b`, 'giu'),
|
|
// Recommended target values: "should be set to 1", "configured to 0".
|
|
new RegExp(String.raw`\b(?:set|configured|changed)\s+to\s+\d+(?:[.,]\d+)?(?![\p{N}.,]*\p{N})`, 'giu'),
|
|
// The same instruction with its object in between, within one sentence and without other numbers:
|
|
// "set it to 1", "Set the ms-DS-MachineAccountQuota attribute on the domain NC head to 0".
|
|
new RegExp(String.raw`\bset\b[^.\n\p{N}]{1,80}?\bto\s+\d+(?![\p{N}.,]*\p{N})`, 'giu'),
|
|
// Documented values and flags: "the expected new value is 0000…11", "adding the number 1048576",
|
|
// "assign 1 as the Value", "2 for Audit mode", "(admincount=1)", "zero (0)", "a 20+ length password".
|
|
/\bvalue\s+(?:is|of)\s+\d+\b/giu,
|
|
/\bthe\s+(?:number|value|flag)\s+\d+\b/giu,
|
|
/\bassign\s+\d+\b/giu,
|
|
/(?<![\p{L}\p{N}_])\d+\s+(?:as\s+the\s+value|for\s+\p{L}+\s+mode)\b/giu,
|
|
/[\p{L}][\p{L}\p{N}_-]*\s*(?:>=|<=|=)\s*\d+\b/gu,
|
|
/\b(?:zero|one|two|three)\s*\(\d+\)/giu,
|
|
/(?<![\p{L}\p{N}_])\d+\+(?=\s)/gu,
|
|
// Counted alternatives and documented report limits: "through 2 methods", "only 10 will be listed below".
|
|
/(?<![\p{L}\p{N}_])\d+\s+(?:methods?|ways?|options?)\b/giu,
|
|
/\bonly\s+\d+\s+will\s+be\s+listed\b/giu
|
|
];
|
|
|
|
function redactEnvironmentQuantities(value: string, keepRecommendationThresholds = false): string {
|
|
const protectedTokens = new Map<string, string>();
|
|
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(/(?<![\p{L}\p{N}_])\d+(?:[.,]\d+)?\s*(?:(?:second|minute|hour|day|week|month|year)(?:s|\(s\))?|Sekunden?|Minuten?|Stunden?|Tagen?|Tage?|Wochen?|Monaten?|Monate?|Jahren?|Jahre?)(?!\p{L})/giu,
|
|
'[duration]')
|
|
.replace(/(?<![\p{L}\p{N}_])\d+(?:ms|s|m|mins?|h|hrs?|d|w|wk|mo|y|yrs?)(?![\p{L}\p{N}_])/gu, '[duration]');
|
|
for (const pattern of publicNumericTokens) redacted = protect(redacted, pattern);
|
|
redacted = redacted.replace(/(?<![\p{L}\p{N}_])\d+(?:[.,]\d+)*(?![\p{L}\p{N}_])/gu, '[count]');
|
|
// Protected tokens can nest (a list marker inside a threshold span), so restore until stable.
|
|
let restored = redacted;
|
|
do {
|
|
redacted = restored;
|
|
restored = redacted.replace(/\uE000[a-z]+\uE001/gu, key => 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/<tenant>`) 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`(?<![\d.])(?:${ipv4Octet}\.){3}${ipv4Octet}(?!\.?\d)`, 'gu');
|
|
|
|
/**
|
|
* Boundaries of documented special-purpose ranges (RFC 1918, loopback, link-local) and of the public
|
|
* address space around RFC 1918 (for example 11.0.0.0 to 172.15.255.255); not host addresses.
|
|
*/
|
|
const publicAddressRangeBoundaries = new Set([
|
|
'0.0.0.0', '255.255.255.255', '10.0.0.0', '10.255.255.255', '172.16.0.0', '172.31.255.255',
|
|
'192.168.0.0', '192.168.255.255', '127.0.0.0', '127.0.0.1', '127.255.255.255', '169.254.0.0', '169.254.255.255',
|
|
'9.255.255.255', '11.0.0.0', '126.255.255.255', '128.0.0.0', '128.0.0.1', '172.15.255.255', '172.32.0.0',
|
|
'192.167.255.255', '192.169.0.0'
|
|
]);
|
|
|
|
function isPublicWindowsSid(sid: string): boolean {
|
|
// Domain SIDs are reduced before this check; the shared list holds only tenant-independent SIDs.
|
|
return /^(?:S-1-5-32-\d+|S-1-5-(?:18|19|20|11|90-0|82-0)|S-1-1-0)$/iu.test(sid)
|
|
|| (!/^S-1-5-21-/iu.test(sid) && wellKnownSidName(sid) !== undefined);
|
|
}
|
|
|
|
function escapeRegExp(value: string): string {
|
|
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
}
|
|
|
|
function nestedStrings(value: unknown): string[] {
|
|
if (typeof value === 'string') return [value];
|
|
if (Array.isArray(value)) return value.flatMap(nestedStrings);
|
|
if (!value || typeof value !== 'object') return [];
|
|
return Object.values(value).flatMap(nestedStrings);
|
|
}
|
|
|
|
function privateLiterals(item: UnifiedResultProjection): string[] {
|
|
const values = [
|
|
item.affectedAsset, item.reviewOwner, item.projectId, item.assessmentId,
|
|
item.collectionRunId, item.scanRunId, item.importArtifactId, item.sourceViewId,
|
|
item.environmentSubjectIdentity, item.policyBackupInstanceId, item.policyGpoId,
|
|
item.policyNativeBackupId, ...nestedStrings(item.sourceLocator)
|
|
];
|
|
const expanded = values.flatMap(value => {
|
|
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<string, string>();
|
|
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/<id>/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<string, string> = {
|
|
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<keyof UnifiedResultProjection, FieldDisclosure> = {
|
|
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<keyof UnifiedResultProjection>;
|
|
|
|
const untracedWithheldFields = new Set<string>([
|
|
'reviewStatus', 'reviewDisposition', 'reviewPriority', 'reviewOwner', 'reviewDueDate'
|
|
] satisfies Array<keyof UnifiedResultProjection>);
|
|
|
|
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;
|
|
}
|