135 lines
5.6 KiB
TypeScript
135 lines
5.6 KiB
TypeScript
import type { JsonValue } from './canonical-model';
|
|
import type { EnvironmentCollectionState, EnvironmentObservation, EnvironmentValueType } from './environment-context';
|
|
|
|
export const RESULT_DB_SUPPLEMENTAL_DISCLOSURE_POLICY_VERSION = '1.0.0';
|
|
|
|
export type ResultDbSupplementalDisclosureClass =
|
|
| 'aggregate'
|
|
| 'configuration-state'
|
|
| 'technical-metadata';
|
|
|
|
export interface ResultDbSupplementalDisclosure {
|
|
policyVersion: typeof RESULT_DB_SUPPLEMENTAL_DISCLOSURE_POLICY_VERSION;
|
|
disclosureClass: ResultDbSupplementalDisclosureClass;
|
|
property: string;
|
|
title: string;
|
|
subjectType: string;
|
|
value: JsonValue;
|
|
valueType: EnvironmentValueType;
|
|
state: EnvironmentCollectionState;
|
|
}
|
|
|
|
interface DisclosureRule {
|
|
toolId: string;
|
|
property: string;
|
|
title: string;
|
|
subjectType: string;
|
|
disclosureClass: ResultDbSupplementalDisclosureClass;
|
|
disclose(value: JsonValue): JsonValue | undefined;
|
|
}
|
|
|
|
function finiteNumber(value: JsonValue, minimum: number, maximum: number): number | undefined {
|
|
if (typeof value !== 'number' || !Number.isFinite(value) || value < minimum || value > maximum) return undefined;
|
|
return value;
|
|
}
|
|
|
|
function integer(value: JsonValue, minimum: number, maximum: number): number | undefined {
|
|
const numeric = finiteNumber(value, minimum, maximum);
|
|
return numeric !== undefined && Number.isInteger(numeric) ? numeric : undefined;
|
|
}
|
|
|
|
function booleanValue(value: JsonValue): boolean | undefined {
|
|
return typeof value === 'boolean' ? value : undefined;
|
|
}
|
|
|
|
function productVersion(value: JsonValue): string | undefined {
|
|
if (typeof value !== 'string') return undefined;
|
|
const normalized = value.trim();
|
|
return /^\d+(?:\.\d+){1,3}(?:[-+][a-z0-9.-]+)?$/i.test(normalized) ? normalized : undefined;
|
|
}
|
|
|
|
const pingCastleRules: DisclosureRule[] = [
|
|
{
|
|
toolId: 'pingcastle', property: 'report.engine_version', title: 'PingCastle engine version',
|
|
subjectType: 'scanner-report', disclosureClass: 'technical-metadata', disclose: productVersion
|
|
},
|
|
...['risk.domain', 'risk.global', 'risk.stale_objects', 'risk.privileged_accounts', 'risk.trusts', 'risk.anomalies']
|
|
.map((property): DisclosureRule => ({
|
|
toolId: 'pingcastle', property, title: property === 'risk.domain' || property === 'risk.global'
|
|
? 'Domain risk score'
|
|
: `${property.slice('risk.'.length).replaceAll('_', ' ')} score`,
|
|
subjectType: 'active-directory-environment', disclosureClass: 'aggregate',
|
|
disclose: value => finiteNumber(value, 0, 100)
|
|
})),
|
|
{
|
|
toolId: 'pingcastle', property: 'domain.functional_level', title: 'Domain functional level',
|
|
subjectType: 'active-directory-environment', disclosureClass: 'technical-metadata',
|
|
disclose: value => integer(value, 2000, 9999)
|
|
},
|
|
{
|
|
toolId: 'pingcastle', property: 'forest.functional_level', title: 'Forest functional level',
|
|
subjectType: 'active-directory-environment', disclosureClass: 'technical-metadata',
|
|
disclose: value => integer(value, 2000, 9999)
|
|
},
|
|
{
|
|
toolId: 'pingcastle', property: 'forest.schema_version', title: 'Forest schema version',
|
|
subjectType: 'active-directory-environment', disclosureClass: 'technical-metadata',
|
|
disclose: value => integer(value, 0, 10000)
|
|
},
|
|
{
|
|
toolId: 'pingcastle', property: 'domain.controller_count', title: 'Domain controller count',
|
|
subjectType: 'active-directory-environment', disclosureClass: 'aggregate',
|
|
disclose: value => integer(value, 0, 1000000)
|
|
},
|
|
{
|
|
toolId: 'pingcastle', property: 'domain.recycle_bin_enabled', title: 'AD Recycle Bin enabled',
|
|
subjectType: 'active-directory-environment', disclosureClass: 'configuration-state', disclose: booleanValue
|
|
},
|
|
{
|
|
toolId: 'pingcastle', property: 'entra.configuration_detected', title: 'Entra ID configuration detected',
|
|
subjectType: 'active-directory-environment', disclosureClass: 'configuration-state', disclose: booleanValue
|
|
},
|
|
{
|
|
toolId: 'pingcastle', property: 'logging.wef.configuration_count', title: 'WEF configuration count',
|
|
subjectType: 'active-directory-environment', disclosureClass: 'aggregate',
|
|
disclose: value => integer(value, 0, 1000000)
|
|
},
|
|
{
|
|
toolId: 'pingcastle', property: 'kerberos.krbtgt.version', title: 'krbtgt version',
|
|
subjectType: 'active-directory-environment', disclosureClass: 'technical-metadata',
|
|
disclose: value => integer(value, 0, 1000000)
|
|
}
|
|
];
|
|
|
|
const disclosureRules = new Map(
|
|
pingCastleRules.map(rule => [`${rule.toolId}\u0000${rule.property}`, rule])
|
|
);
|
|
|
|
/**
|
|
* Fail-closed privacy boundary for supplemental scanner context in Result DB.
|
|
* Findings use their canonical projection. Environment/details records only
|
|
* cross this boundary when a tool/property pair has an explicit rule and its
|
|
* normalized value conforms to the narrow non-identifying value contract.
|
|
*/
|
|
export function discloseSupplementalObservation(
|
|
observation: EnvironmentObservation
|
|
): ResultDbSupplementalDisclosure | undefined {
|
|
const toolId = observation.tool.id.trim().toLocaleLowerCase('en-US');
|
|
const rule = disclosureRules.get(`${toolId}\u0000${observation.property}`);
|
|
if (!rule) return undefined;
|
|
const disclosedValue = rule.disclose(observation.normalizedValue ?? observation.originalValue);
|
|
if (disclosedValue === undefined) return undefined;
|
|
return {
|
|
policyVersion: RESULT_DB_SUPPLEMENTAL_DISCLOSURE_POLICY_VERSION,
|
|
disclosureClass: rule.disclosureClass,
|
|
property: rule.property,
|
|
title: rule.title,
|
|
subjectType: rule.subjectType,
|
|
value: disclosedValue,
|
|
valueType: typeof disclosedValue === 'number' ? 'number'
|
|
: typeof disclosedValue === 'boolean' ? 'boolean'
|
|
: 'string',
|
|
state: observation.state
|
|
};
|
|
}
|