Assessor_2.0/src/domain/result-db-supplemental-disc...

174 lines
7.7 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)
},
// Non-identifying aggregate counts and configuration state derived from
// PingCastle inventory tables. Each value is a plain count or boolean; no
// account, host, or directory identifier crosses this boundary.
...([
['user.account_count', 'User accounts'],
['user.account_enabled_count', 'Enabled user accounts'],
['user.account_disabled_count', 'Disabled user accounts'],
['user.account_active_count', 'Active user accounts'],
['user.password_never_expires_count', 'Users with non-expiring passwords'],
['computer.account_count', 'Computer accounts'],
['computer.account_enabled_count', 'Enabled computer accounts'],
['computer.account_disabled_count', 'Disabled computer accounts'],
['computer.account_active_count', 'Active computer accounts'],
['admin_group.count', 'Administrative groups'],
['trust.count', 'Domain trusts'],
['pki.certificate_template_count', 'Certificate templates'],
['pki.certificate_count', 'Certificates'],
['pki.dc_certificate_count', 'Domain controller certificates'],
['pki.delegation_count', 'PKI delegations'],
['operating_system.count', 'Distinct operating systems'],
['password_policy.minimum_length', 'Password minimum length'],
['password_policy.history_length', 'Password history length'],
['password_policy.lockout_threshold', 'Account lockout threshold'],
['password_policy.maximum_age_days', 'Maximum password age (days)']
] as const).map(([property, title]): DisclosureRule => ({
toolId: 'pingcastle', property, title,
subjectType: 'active-directory-environment', disclosureClass: 'aggregate',
disclose: value => integer(value, 0, 1_000_000_000)
})),
...([
['password_policy.complexity_enabled', 'Password complexity enabled'],
['entra.configuration_detected', 'Entra ID configuration detected'],
['laps.legacy.present', 'Legacy LAPS present'],
['laps.windows.present', 'Windows LAPS present']
] as const).map(([property, title]): DisclosureRule => ({
toolId: 'pingcastle', property, title,
subjectType: 'active-directory-environment', disclosureClass: 'configuration-state',
disclose: booleanValue
}))
];
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
};
}