*/ private const SUPPORTED_TYPES = [ 'retentionCompliancePolicy', 'labelPolicy', 'dlpCompliancePolicy', ]; /** * @var list */ private const VOLATILE_ROOT_FIELDS = [ '@odata.context', '@odata.etag', 'createdAt', 'createdDateTime', 'created_at', 'graphContext', 'lastModifiedDateTime', 'modifiedDateTime', 'sourceMetadata', 'source_metadata', 'tcmContext', 'updatedAt', 'updatedDateTime', 'updated_at', 'version', 'whenChanged', ]; /** * @var array> */ private const SUPPORTED_ROOT_FIELDS = [ 'retentionCompliancePolicy' => [ '@odata.context', '@odata.etag', 'DispositionAction', 'DisplayName', 'Duration', 'Enabled', 'ExcludedGroups', 'ExcludedLocations', 'ExcludedUsers', 'Identity', 'IncludedGroups', 'IncludedLocations', 'IncludedUsers', 'Name', 'RetentionAction', 'RetentionDuration', 'RetentionDurationUnit', 'State', 'displayName', 'duration', 'durationUnit', 'enabled', 'excludedGroups', 'excludedLocations', 'excludedUsers', 'id', 'includedGroups', 'includedLocations', 'includedUsers', 'name', 'retentionAction', 'retentionDuration', 'retentionDurationUnit', 'state', ], 'labelPolicy' => [ '@odata.context', '@odata.etag', 'DefaultLabel', 'DisplayName', 'ExcludedGroups', 'ExcludedLocations', 'ExcludedUsers', 'Identity', 'IncludedGroups', 'IncludedLocations', 'IncludedUsers', 'Labels', 'Mandatory', 'Name', 'PublishedLabels', 'State', 'defaultLabel', 'displayName', 'excludedGroups', 'excludedLocations', 'excludedUsers', 'id', 'includedGroups', 'includedLocations', 'includedUsers', 'labels', 'mandatory', 'name', 'publishedLabels', 'state', ], 'dlpCompliancePolicy' => [ '@odata.context', '@odata.etag', 'Actions', 'DisplayName', 'ExcludedLocations', 'Identity', 'IncludedLocations', 'Locations', 'Mode', 'Name', 'Rules', 'State', 'Workloads', 'actions', 'displayName', 'excludedLocations', 'id', 'includedLocations', 'locations', 'mode', 'name', 'rules', 'state', 'workloads', ], ]; /** * @var list */ private const SENSITIVE_CONTENT_KEYS = [ 'auditmetadata.rawpayload', 'body', 'casecontent', 'content', 'dlpincidentcontent', 'ediscoverycasecontent', 'filecontent', 'fingerprint', 'mailbody', 'mailcontent', 'messagebody', 'messagecontent', 'providerresponse', 'rawpayload', 'sampledata', 'securityincidentcontent', ]; /** * @var list */ private const SENSITIVE_CONTENT_KEY_PARTS = [ 'casecontent', 'contentcontainssensitiveinformation', 'dlpincident', 'ediscovery', 'fingerprint', 'mailcontent', 'messagecontent', 'providerresponse', 'rawpayload', 'securityincident', ]; /** * @var array>> */ private const SUPPORTED_NESTED_FIELDS = [ 'retentionCompliancePolicy' => [ 'IncludedLocations' => ['DisplayName', 'Identity', 'Name', 'displayName', 'id', 'name'], 'includedLocations' => ['DisplayName', 'Identity', 'Name', 'displayName', 'id', 'name'], 'ExcludedLocations' => ['DisplayName', 'Identity', 'Name', 'displayName', 'id', 'name'], 'excludedLocations' => ['DisplayName', 'Identity', 'Name', 'displayName', 'id', 'name'], 'IncludedGroups' => ['DisplayName', 'Identity', 'Mail', 'Name', 'displayName', 'id', 'mail', 'name'], 'includedGroups' => ['DisplayName', 'Identity', 'Mail', 'Name', 'displayName', 'id', 'mail', 'name'], 'ExcludedGroups' => ['DisplayName', 'Identity', 'Mail', 'Name', 'displayName', 'id', 'mail', 'name'], 'excludedGroups' => ['DisplayName', 'Identity', 'Mail', 'Name', 'displayName', 'id', 'mail', 'name'], 'IncludedUsers' => ['DisplayName', 'Identity', 'Mail', 'Name', 'UserPrincipalName', 'displayName', 'id', 'mail', 'name', 'userPrincipalName'], 'includedUsers' => ['DisplayName', 'Identity', 'Mail', 'Name', 'UserPrincipalName', 'displayName', 'id', 'mail', 'name', 'userPrincipalName'], 'ExcludedUsers' => ['DisplayName', 'Identity', 'Mail', 'Name', 'UserPrincipalName', 'displayName', 'id', 'mail', 'name', 'userPrincipalName'], 'excludedUsers' => ['DisplayName', 'Identity', 'Mail', 'Name', 'UserPrincipalName', 'displayName', 'id', 'mail', 'name', 'userPrincipalName'], ], 'labelPolicy' => [ 'PublishedLabels' => ['DisplayName', 'Identity', 'Name', 'displayName', 'id', 'name'], 'publishedLabels' => ['DisplayName', 'Identity', 'Name', 'displayName', 'id', 'name'], 'Labels' => ['DisplayName', 'Identity', 'Name', 'displayName', 'id', 'name'], 'labels' => ['DisplayName', 'Identity', 'Name', 'displayName', 'id', 'name'], 'DefaultLabel' => ['DisplayName', 'Identity', 'Name', 'displayName', 'id', 'name'], 'defaultLabel' => ['DisplayName', 'Identity', 'Name', 'displayName', 'id', 'name'], 'IncludedLocations' => ['DisplayName', 'Identity', 'Name', 'displayName', 'id', 'name'], 'includedLocations' => ['DisplayName', 'Identity', 'Name', 'displayName', 'id', 'name'], 'ExcludedLocations' => ['DisplayName', 'Identity', 'Name', 'displayName', 'id', 'name'], 'excludedLocations' => ['DisplayName', 'Identity', 'Name', 'displayName', 'id', 'name'], 'IncludedGroups' => ['DisplayName', 'Identity', 'Mail', 'Name', 'displayName', 'id', 'mail', 'name'], 'includedGroups' => ['DisplayName', 'Identity', 'Mail', 'Name', 'displayName', 'id', 'mail', 'name'], 'ExcludedGroups' => ['DisplayName', 'Identity', 'Mail', 'Name', 'displayName', 'id', 'mail', 'name'], 'excludedGroups' => ['DisplayName', 'Identity', 'Mail', 'Name', 'displayName', 'id', 'mail', 'name'], 'IncludedUsers' => ['DisplayName', 'Identity', 'Mail', 'Name', 'UserPrincipalName', 'displayName', 'id', 'mail', 'name', 'userPrincipalName'], 'includedUsers' => ['DisplayName', 'Identity', 'Mail', 'Name', 'UserPrincipalName', 'displayName', 'id', 'mail', 'name', 'userPrincipalName'], 'ExcludedUsers' => ['DisplayName', 'Identity', 'Mail', 'Name', 'UserPrincipalName', 'displayName', 'id', 'mail', 'name', 'userPrincipalName'], 'excludedUsers' => ['DisplayName', 'Identity', 'Mail', 'Name', 'UserPrincipalName', 'displayName', 'id', 'mail', 'name', 'userPrincipalName'], ], 'dlpCompliancePolicy' => [ 'Locations' => ['DisplayName', 'Identity', 'Name', 'displayName', 'id', 'name'], 'locations' => ['DisplayName', 'Identity', 'Name', 'displayName', 'id', 'name'], 'IncludedLocations' => ['DisplayName', 'Identity', 'Name', 'displayName', 'id', 'name'], 'includedLocations' => ['DisplayName', 'Identity', 'Name', 'displayName', 'id', 'name'], 'ExcludedLocations' => ['DisplayName', 'Identity', 'Name', 'displayName', 'id', 'name'], 'excludedLocations' => ['DisplayName', 'Identity', 'Name', 'displayName', 'id', 'name'], 'Workloads' => ['DisplayName', 'Identity', 'Name', 'displayName', 'id', 'name'], 'workloads' => ['DisplayName', 'Identity', 'Name', 'displayName', 'id', 'name'], 'Actions' => ['Action', 'DisplayName', 'Identity', 'Name', 'Type', 'action', 'displayName', 'id', 'name', 'type'], 'actions' => ['Action', 'DisplayName', 'Identity', 'Name', 'Type', 'action', 'displayName', 'id', 'name', 'type'], 'Rules' => ['Actions', 'DisplayName', 'Enabled', 'Mode', 'Name', 'Severity', 'State', 'actions', 'displayName', 'enabled', 'mode', 'name', 'severity', 'state'], 'rules' => ['Actions', 'DisplayName', 'Enabled', 'Mode', 'Name', 'Severity', 'State', 'actions', 'displayName', 'enabled', 'mode', 'name', 'severity', 'state'], ], ]; /** * @var array>>> */ private const SUPPORTED_NESTED_FIELD_CHILDREN = [ 'dlpCompliancePolicy' => [ 'Rules' => [ 'Actions' => ['Action', 'DisplayName', 'Identity', 'Name', 'Type', 'action', 'displayName', 'id', 'name', 'type'], 'actions' => ['Action', 'DisplayName', 'Identity', 'Name', 'Type', 'action', 'displayName', 'id', 'name', 'type'], ], 'rules' => [ 'Actions' => ['Action', 'DisplayName', 'Identity', 'Name', 'Type', 'action', 'displayName', 'id', 'name', 'type'], 'actions' => ['Action', 'DisplayName', 'Identity', 'Name', 'Type', 'action', 'displayName', 'id', 'name', 'type'], ], ], ]; public function __construct( private readonly CoveragePayloadRedactor $redactor, ) {} public function supports(string $canonicalType): bool { return in_array($canonicalType, self::SUPPORTED_TYPES, true); } /** * @param array $payload * @return array */ public function normalize(string $canonicalType, array $payload): array { if (! $this->supports($canonicalType)) { return [ 'canonical_type' => $canonicalType, 'supported' => false, 'diagnostics' => [ 'unsupported_fields' => [], 'redacted_fields' => [], 'volatile_fields' => [], 'manual_review_fields' => [], ], ]; } $redacted = $this->redactedPayload($payload); return match ($canonicalType) { 'retentionCompliancePolicy' => $this->normalizeRetentionCompliancePolicy($payload, $redacted), 'labelPolicy' => $this->normalizeLabelPolicy($payload, $redacted), 'dlpCompliancePolicy' => $this->normalizeDlpCompliancePolicy($payload, $redacted), }; } /** * @return list */ public function volatileRootFields(): array { return self::VOLATILE_ROOT_FIELDS; } /** * @param array $rawPayload * @param array $payload * @return array */ private function normalizeRetentionCompliancePolicy(array $rawPayload, array $payload): array { return $this->sortAssociative([ 'canonical_type' => 'retentionCompliancePolicy', 'supported' => true, 'display_name' => $this->firstString($payload, ['DisplayName', 'displayName', 'Name', 'name', 'Identity']), 'enabled_state' => $this->enabledState($this->firstScalar($payload, ['Enabled', 'enabled', 'State', 'state'])), 'retention' => [ 'duration' => $this->stringValue($this->firstScalar($payload, ['RetentionDuration', 'retentionDuration', 'Duration', 'duration'])), 'duration_unit' => $this->firstString($payload, ['RetentionDurationUnit', 'retentionDurationUnit', 'DurationUnit', 'durationUnit']), 'disposition_action' => $this->firstString($payload, ['DispositionAction', 'dispositionAction', 'RetentionAction', 'retentionAction']), ], 'scope' => [ 'included_locations' => $this->listFromFields($payload, ['IncludedLocations', 'includedLocations', 'Locations', 'locations']), 'excluded_locations' => $this->listFromFields($payload, ['ExcludedLocations', 'excludedLocations']), 'included_groups' => $this->listFromFields($payload, ['IncludedGroups', 'includedGroups']), 'excluded_groups' => $this->listFromFields($payload, ['ExcludedGroups', 'excludedGroups']), 'included_users' => $this->listFromFields($payload, ['IncludedUsers', 'includedUsers']), 'excluded_users' => $this->listFromFields($payload, ['ExcludedUsers', 'excludedUsers']), ], 'diagnostics' => $this->diagnostics('retentionCompliancePolicy', $rawPayload, $payload), ]); } /** * @param array $rawPayload * @param array $payload * @return array */ private function normalizeLabelPolicy(array $rawPayload, array $payload): array { return $this->sortAssociative([ 'canonical_type' => 'labelPolicy', 'supported' => true, 'display_name' => $this->firstString($payload, ['DisplayName', 'displayName', 'Name', 'name', 'Identity']), 'state' => $this->firstString($payload, ['State', 'state']), 'labeling' => [ 'published_labels' => $this->labelList($this->firstExisting($payload, ['PublishedLabels', 'publishedLabels', 'Labels', 'labels'])), 'default_label' => $this->labelName($this->firstExisting($payload, ['DefaultLabel', 'defaultLabel'])), 'mandatory' => $this->booleanString($this->firstScalar($payload, ['Mandatory', 'mandatory'])), ], 'scope' => [ 'included_locations' => $this->listFromFields($payload, ['IncludedLocations', 'includedLocations', 'Locations', 'locations']), 'excluded_locations' => $this->listFromFields($payload, ['ExcludedLocations', 'excludedLocations']), 'included_groups' => $this->listFromFields($payload, ['IncludedGroups', 'includedGroups']), 'excluded_groups' => $this->listFromFields($payload, ['ExcludedGroups', 'excludedGroups']), 'included_users' => $this->listFromFields($payload, ['IncludedUsers', 'includedUsers']), 'excluded_users' => $this->listFromFields($payload, ['ExcludedUsers', 'excludedUsers']), ], 'diagnostics' => $this->diagnostics('labelPolicy', $rawPayload, $payload), ]); } /** * @param array $rawPayload * @param array $payload * @return array */ private function normalizeDlpCompliancePolicy(array $rawPayload, array $payload): array { return $this->sortAssociative([ 'canonical_type' => 'dlpCompliancePolicy', 'supported' => true, 'display_name' => $this->firstString($payload, ['DisplayName', 'displayName', 'Name', 'name', 'Identity']), 'state' => $this->firstString($payload, ['State', 'state']), 'mode' => $this->firstString($payload, ['Mode', 'mode']), 'scope' => [ 'locations' => $this->listFromFields($payload, ['Locations', 'locations', 'IncludedLocations', 'includedLocations']), 'excluded_locations' => $this->listFromFields($payload, ['ExcludedLocations', 'excludedLocations']), 'workloads' => $this->listFromFields($payload, ['Workloads', 'workloads']), ], 'actions' => $this->scalarList($this->firstExisting($payload, ['Actions', 'actions'])), 'rules' => $this->ruleList($this->firstExisting($payload, ['Rules', 'rules'])), 'diagnostics' => $this->diagnostics('dlpCompliancePolicy', $rawPayload, $payload), ]); } /** * @param array $payload * @return array */ private function redactedPayload(array $payload): array { $redacted = $this->redactor->redact($payload); $redacted = is_array($redacted) ? $redacted : []; return $this->redactUnsafeContent($redacted); } private function redactUnsafeContent(mixed $value, string $path = ''): mixed { if (! is_array($value)) { return $value; } if (array_is_list($value)) { return array_map(fn (mixed $item): mixed => $this->redactUnsafeContent($item, $path), $value); } $redacted = []; foreach ($value as $key => $nestedValue) { $key = (string) $key; $nestedPath = $path === '' ? $key : $path.'.'.$key; $redacted[$key] = $this->isUnsafeContentKey($nestedPath) ? '[redacted]' : $this->redactUnsafeContent($nestedValue, $nestedPath); } return $redacted; } private function isUnsafeContentKey(string $path): bool { $normalized = strtolower(str_replace(['_', '-', ' '], '', $path)); $segments = explode('.', $normalized); $last = end($segments); foreach (self::SENSITIVE_CONTENT_KEYS as $sensitiveKey) { if ($normalized === $sensitiveKey || $last === $sensitiveKey) { return true; } } foreach (self::SENSITIVE_CONTENT_KEY_PARTS as $sensitiveKeyPart) { if (str_contains($normalized, $sensitiveKeyPart) || str_contains((string) $last, $sensitiveKeyPart)) { return true; } } return false; } /** * @return array> */ private function diagnostics(string $canonicalType, array $rawPayload, array $payload): array { $redactedFields = $this->redactedPaths($payload); $unsupportedFields = $this->unsupportedRootFields($canonicalType, $payload); return [ 'unsupported_fields' => $unsupportedFields, 'redacted_fields' => $redactedFields, 'volatile_fields' => $this->presentVolatileFields($rawPayload), 'manual_review_fields' => $this->manualReviewFields($unsupportedFields, $redactedFields), ]; } /** * @param list $unsupportedFields * @return list */ private function manualReviewFields(array $unsupportedFields, array $redactedFields): array { $fields = array_values(array_filter( $unsupportedFields, static fn (string $field): bool => $field !== '' && ! in_array($field, $redactedFields, true), )); sort($fields, SORT_NATURAL | SORT_FLAG_CASE); return $fields; } /** * @param array $payload * @return list */ private function unsupportedRootFields(string $canonicalType, array $payload): array { $supported = self::SUPPORTED_ROOT_FIELDS[$canonicalType] ?? []; $fields = array_values(array_filter( array_map('strval', array_keys($payload)), static fn (string $key): bool => ! in_array($key, $supported, true) && ! in_array($key, self::VOLATILE_ROOT_FIELDS, true) )); foreach ($this->unsupportedNestedFields($canonicalType, $payload) as $field) { $fields[] = $field; } sort($fields, SORT_NATURAL | SORT_FLAG_CASE); return array_values(array_unique($fields)); } /** * @param array $payload * @return list */ private function unsupportedNestedFields(string $canonicalType, array $payload): array { $fields = []; $supportedNestedFields = self::SUPPORTED_NESTED_FIELDS[$canonicalType] ?? []; foreach ($supportedNestedFields as $rootField => $supportedFields) { if (! array_key_exists($rootField, $payload)) { continue; } foreach ($this->unsupportedNestedFieldsForValue( $payload[$rootField], $supportedFields, $rootField, self::SUPPORTED_NESTED_FIELD_CHILDREN[$canonicalType][$rootField] ?? [], ) as $field) { $fields[] = $field; } } return $fields; } /** * @param list $supportedFields * @param array> $childSchemas * @return list */ private function unsupportedNestedFieldsForValue(mixed $value, array $supportedFields, string $path, array $childSchemas = []): array { if (! is_array($value)) { return []; } $fields = []; if (array_is_list($value)) { foreach ($value as $index => $nestedValue) { foreach ($this->unsupportedNestedFieldsForValue($nestedValue, $supportedFields, $path.'.'.(string) $index, $childSchemas) as $field) { $fields[] = $field; } } return $fields; } foreach ($value as $key => $nestedValue) { $key = (string) $key; $nestedPath = $path.'.'.$key; if (! in_array($key, $supportedFields, true)) { $fields[] = $nestedPath; continue; } $childSchema = $childSchemas[$key] ?? null; if ($childSchema === null) { if (is_array($nestedValue)) { $fields[] = $nestedPath; } continue; } foreach ($this->unsupportedNestedFieldsForValue($nestedValue, $childSchema, $nestedPath) as $field) { $fields[] = $field; } } return $fields; } /** * @param array $payload * @return list */ private function presentVolatileFields(array $payload): array { $fields = array_values(array_filter( self::VOLATILE_ROOT_FIELDS, static fn (string $field): bool => array_key_exists($field, $payload), )); sort($fields, SORT_NATURAL | SORT_FLAG_CASE); return $fields; } /** * @return list */ private function redactedPaths(mixed $value, string $prefix = ''): array { if ($value === '[redacted]') { return [$prefix]; } if (! is_array($value)) { return []; } $paths = []; foreach ($value as $key => $nestedValue) { $path = $prefix === '' ? (string) $key : $prefix.'.'.(string) $key; foreach ($this->redactedPaths($nestedValue, $path) as $nestedPath) { $paths[] = $nestedPath; } } $paths = array_values(array_unique(array_filter($paths))); sort($paths, SORT_NATURAL | SORT_FLAG_CASE); return $paths; } /** * @param array $payload * @param list $fields * @return list */ private function listFromFields(array $payload, array $fields): array { $values = []; foreach ($fields as $field) { foreach ($this->scalarList($payload[$field] ?? null) as $value) { $values[] = $value; } } $values = array_values(array_unique($values)); sort($values, SORT_NATURAL | SORT_FLAG_CASE); return $values; } /** * @return list */ private function scalarList(mixed $value): array { if ($value === null || $value === '') { return []; } if (! is_array($value)) { return is_scalar($value) ? [trim((string) $value)] : []; } $values = []; foreach ($value as $item) { if (is_array($item)) { $label = $this->labelName($item); if ($label !== null) { $values[] = $label; } continue; } if (is_scalar($item) && trim((string) $item) !== '') { $values[] = trim((string) $item); } } $values = array_values(array_unique($values)); sort($values, SORT_NATURAL | SORT_FLAG_CASE); return $values; } /** * @return list */ private function labelList(mixed $value): array { if ($value === null || $value === '') { return []; } $items = is_array($value) && array_is_list($value) ? $value : [$value]; $labels = []; foreach ($items as $item) { $label = $this->labelName($item); if ($label !== null) { $labels[] = $label; } } $labels = array_values(array_unique($labels)); sort($labels, SORT_NATURAL | SORT_FLAG_CASE); return $labels; } private function labelName(mixed $value): ?string { if (is_array($value)) { return $this->firstString($value, ['displayName', 'DisplayName', 'name', 'Name']); } return $this->stringValue($value); } /** * @return list> */ private function ruleList(mixed $value): array { if (! is_array($value)) { return []; } $items = array_is_list($value) ? $value : [$value]; $rules = []; foreach ($items as $item) { if (! is_array($item)) { continue; } $rules[] = array_filter([ 'name' => $this->firstString($item, ['Name', 'name', 'DisplayName', 'displayName']), 'state' => $this->firstString($item, ['State', 'state', 'Enabled', 'enabled']), 'severity' => $this->firstString($item, ['Severity', 'severity']), 'mode' => $this->firstString($item, ['Mode', 'mode']), 'actions' => $this->scalarList($this->firstExisting($item, ['Actions', 'actions'])), ], static fn (mixed $nested): bool => $nested !== null && $nested !== [] && $nested !== ''); } usort($rules, static fn (array $left, array $right): int => strcmp( json_encode($left, JSON_THROW_ON_ERROR), json_encode($right, JSON_THROW_ON_ERROR), )); return $rules; } private function firstExisting(array $payload, array $fields): mixed { foreach ($fields as $field) { if (array_key_exists($field, $payload)) { return $payload[$field]; } } return null; } private function firstScalar(array $payload, array $fields): mixed { foreach ($fields as $field) { $value = $payload[$field] ?? null; if (is_scalar($value)) { return $value; } } return null; } private function firstString(array $payload, array $fields): ?string { return $this->stringValue($this->firstScalar($payload, $fields)); } private function stringValue(mixed $value): ?string { if (! is_scalar($value)) { return null; } if (is_bool($value)) { return $value ? 'true' : 'false'; } $value = trim((string) $value); return $value !== '' ? $value : null; } private function enabledState(mixed $value): ?string { if (is_bool($value)) { return $value ? 'enabled' : 'disabled'; } $value = $this->stringValue($value); if ($value === null) { return null; } return match (strtolower($value)) { '1', 'enabled', 'true', 'yes' => 'enabled', '0', 'disabled', 'false', 'no' => 'disabled', default => $value, }; } private function booleanString(mixed $value): ?string { if (is_bool($value)) { return $value ? 'yes' : 'no'; } $value = $this->stringValue($value); if ($value === null) { return null; } return match (strtolower($value)) { '1', 'true', 'yes' => 'yes', '0', 'false', 'no' => 'no', default => $value, }; } /** * @param array $value * @return array */ private function sortAssociative(array $value): array { foreach ($value as $key => $nestedValue) { if (is_array($nestedValue)) { $value[$key] = array_is_list($nestedValue) ? $nestedValue : $this->sortAssociative($nestedValue); } } ksort($value, SORT_NATURAL | SORT_FLAG_CASE); return $value; } }