Assessor_2.0/src/domain/result-db-disclosure.ts

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(/&nbsp;/giu, ' ')
.replace(/&amp;/giu, '&')
.replace(/&lt;/giu, '<')
.replace(/&gt;/giu, '>')
.replace(/&quot;/giu, '"')
.replace(/&#39;/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;
}