*/ private const SUPPORTED_TYPES = [ 'transportRule', 'acceptedDomain', 'appPermissionPolicy', 'meetingPolicy', ]; /** * @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 = [ 'transportRule' => [ '@odata.context', '@odata.etag', 'Actions', 'ApplyHtmlDisclaimerFallbackAction', 'ApplyHtmlDisclaimerLocation', 'Conditions', 'DeleteMessage', 'DisplayName', 'Enabled', 'ExceptIfFrom', 'ExceptIfRecipientDomainIs', 'Exceptions', 'From', 'Identity', 'Mode', 'Name', 'Priority', 'RecipientDomainIs', 'RedirectMessageTo', 'SenderDomainIs', 'SentTo', 'State', 'SubjectContainsWords', 'actions', 'conditions', 'createdDateTime', 'displayName', 'enabled', 'exceptions', 'id', 'mode', 'modifiedDateTime', 'name', 'priority', 'ruleMode', 'state', ], 'acceptedDomain' => [ '@odata.context', '@odata.etag', 'Default', 'DisplayName', 'DomainName', 'DomainType', 'Identity', 'IsDefault', 'Name', 'State', 'Status', 'createdDateTime', 'default', 'displayName', 'domainName', 'domainType', 'id', 'isDefault', 'modifiedDateTime', 'name', 'state', 'status', 'type', ], 'appPermissionPolicy' => [ '@odata.context', '@odata.etag', 'AllowAllApps', 'AllowAppList', 'AssignedGroups', 'AssignedUsers', 'BlockAppList', 'Description', 'DisplayName', 'GlobalCatalogAppsType', 'Identity', 'Name', 'PrivateCatalogAppsType', 'allowedAppIds', 'allowedApps', 'allowAppList', 'assignments', 'assignedGroups', 'assignedUsers', 'blockedAppIds', 'blockedApps', 'blockAppList', 'createdDateTime', 'displayName', 'id', 'mode', 'modifiedDateTime', 'name', 'policyMode', 'targets', ], 'meetingPolicy' => [ '@odata.context', '@odata.etag', 'AllowAnonymousUsersToJoinMeeting', 'AllowCloudRecording', 'AllowExternalParticipantGiveRequestControl', 'AllowMeetingReactions', 'AllowParticipantGiveRequestControl', 'AllowPowerPointSharing', 'AllowPSTNUsersToBypassLobby', 'AllowSharedNotes', 'AllowTranscription', 'AllowWhiteboard', 'AutoAdmittedUsers', 'ContentSharing', 'Description', 'DisplayName', 'ExternalAccess', 'Identity', 'LobbyBypassSettings', 'MeetingRecordingExpirationDays', 'Name', 'RecordingTranscription', 'ScreenSharingMode', 'State', 'allowAnonymousUsersToJoinMeeting', 'allowCloudRecording', 'allowExternalParticipantGiveRequestControl', 'allowMeetingReactions', 'allowParticipantGiveRequestControl', 'allowPowerPointSharing', 'allowPSTNUsersToBypassLobby', 'allowSharedNotes', 'allowTranscription', 'allowWhiteboard', 'autoAdmittedUsers', 'contentSharing', 'createdDateTime', 'displayName', 'externalAccess', 'id', 'lobbyBypassSettings', 'meetingRecordingExpirationDays', 'modifiedDateTime', 'name', 'recordingTranscription', 'screenSharingMode', 'state', ], ]; /** * @var list */ private const SENSITIVE_CONTENT_KEYS = [ 'auditmetadata.rawpayload', 'body', 'chatcontent', 'chatmessage', 'filecontent', 'mailbody', 'mailcontent', 'messagebody', 'mimecontent', 'operationruncontext', 'providerresponse', 'rawpayload', 'recordingcontent', 'recordingtranscript', 'recordingurl', 'transcriptcontent', 'transcripttext', ]; /** * @var list */ private const SENSITIVE_CONTENT_KEY_PARTS = [ 'applyhtmldisclaimertext', 'attachmentcontainswords', 'attachmentextensionmatcheswords', 'attachmentmatchespatterns', 'attachmentnamematchespatterns', 'attachmentpropertycontainswords', 'bodycontainswords', 'bodymatchespatterns', 'disclaimertext', 'headercontainsmessageheader', 'headercontainswords', 'headermatchespatterns', 'messageheadercontainswords', 'messageheadermatchespatterns', 'prependsubject', 'setheadername', 'setheadervalue', 'subjectcontainswords', 'subjectmatchespatterns', 'subjectorbodycontainswords', 'subjectorbodymatchespatterns', ]; 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' => [], ], ]; } $redacted = $this->redactedPayload($payload); return match ($canonicalType) { 'transportRule' => $this->normalizeTransportRule($payload, $redacted), 'acceptedDomain' => $this->normalizeAcceptedDomain($payload, $redacted), 'appPermissionPolicy' => $this->normalizeAppPermissionPolicy($payload, $redacted), 'meetingPolicy' => $this->normalizeMeetingPolicy($payload, $redacted), }; } /** * @return list */ public function volatileRootFields(): array { return self::VOLATILE_ROOT_FIELDS; } /** * @param array $rawPayload * @param array $payload * @return array */ private function normalizeTransportRule(array $rawPayload, array $payload): array { return $this->sortAssociative([ 'canonical_type' => 'transportRule', 'supported' => true, 'display_name' => $this->firstString($payload, ['displayName', 'DisplayName', 'name', 'Name', 'Identity']), 'enabled_state' => $this->enabledState($this->firstScalar($payload, ['enabled', 'Enabled', 'state', 'State'])), 'priority_order' => $this->stringValue($this->firstScalar($payload, ['priority', 'Priority'])), 'mode' => $this->firstString($payload, ['mode', 'Mode', 'ruleMode', 'RuleMode']), 'conditions' => $this->settingGroup($payload, ['conditions', 'Conditions'], [ 'From', 'RecipientDomainIs', 'SenderDomainIs', 'SentTo', 'SubjectContainsWords', ]), 'actions' => $this->settingGroup($payload, ['actions', 'Actions'], [ 'ApplyHtmlDisclaimerFallbackAction', 'ApplyHtmlDisclaimerLocation', 'DeleteMessage', 'RedirectMessageTo', ]), 'exceptions' => $this->settingGroup($payload, ['exceptions', 'Exceptions'], [ 'ExceptIfFrom', 'ExceptIfRecipientDomainIs', ]), 'source' => $this->sourceSummary($payload), 'diagnostics' => $this->diagnostics('transportRule', $rawPayload, $payload), ]); } /** * @param array $rawPayload * @param array $payload * @return array */ private function normalizeAcceptedDomain(array $rawPayload, array $payload): array { return $this->sortAssociative([ 'canonical_type' => 'acceptedDomain', 'supported' => true, 'domain_name' => $this->firstString($payload, ['domainName', 'DomainName', 'name', 'Name', 'displayName', 'DisplayName', 'Identity']), 'domain_type' => $this->firstString($payload, ['domainType', 'DomainType', 'type']), 'is_default' => $this->booleanString($this->firstScalar($payload, ['isDefault', 'IsDefault', 'default', 'Default'])), 'state' => $this->firstString($payload, ['state', 'State', 'status', 'Status']), 'source' => $this->sourceSummary($payload), 'diagnostics' => $this->diagnostics('acceptedDomain', $rawPayload, $payload), ]); } /** * @param array $rawPayload * @param array $payload * @return array */ private function normalizeAppPermissionPolicy(array $rawPayload, array $payload): array { $globalCatalogMode = $this->firstString($payload, ['GlobalCatalogAppsType', 'globalCatalogAppsType']); $privateCatalogMode = $this->firstString($payload, ['PrivateCatalogAppsType', 'privateCatalogAppsType']); return $this->sortAssociative([ 'canonical_type' => 'appPermissionPolicy', 'supported' => true, 'display_name' => $this->firstString($payload, ['displayName', 'DisplayName', 'name', 'Name', 'Identity']), 'policy_mode' => $this->policyMode([ $this->firstScalar($payload, ['mode', 'policyMode']), $globalCatalogMode, $privateCatalogMode, $this->firstScalar($payload, ['AllowAllApps', 'allowAllApps']), ]), 'allowed_apps' => $this->appList($this->firstExisting($payload, ['allowedApps', 'AllowAppList', 'allowAppList', 'allowedAppIds'])), 'blocked_apps' => $this->appList($this->firstExisting($payload, ['blockedApps', 'BlockAppList', 'blockAppList', 'blockedAppIds'])), 'targets' => $this->settingGroup($payload, ['assignments', 'targets'], ['AssignedGroups', 'assignedGroups', 'AssignedUsers', 'assignedUsers']), 'source' => $this->sourceSummary($payload), 'diagnostics' => $this->diagnostics('appPermissionPolicy', $rawPayload, $payload), ]); } /** * @param array $rawPayload * @param array $payload * @return array */ private function normalizeMeetingPolicy(array $rawPayload, array $payload): array { return $this->sortAssociative([ 'canonical_type' => 'meetingPolicy', 'supported' => true, 'display_name' => $this->firstString($payload, ['displayName', 'DisplayName', 'name', 'Name', 'Identity']), 'state' => $this->firstString($payload, ['state', 'State']), 'external_access' => $this->settingGroup($payload, ['externalAccess', 'ExternalAccess'], [ 'allowAnonymousUsersToJoinMeeting', 'AllowAnonymousUsersToJoinMeeting', 'allowExternalParticipantGiveRequestControl', 'AllowExternalParticipantGiveRequestControl', ]), 'recording_transcription' => $this->settingGroup($payload, ['recordingTranscription', 'RecordingTranscription'], [ 'allowCloudRecording', 'AllowCloudRecording', 'allowTranscription', 'AllowTranscription', 'meetingRecordingExpirationDays', 'MeetingRecordingExpirationDays', ]), 'lobby_admission' => $this->settingGroup($payload, ['lobbyBypassSettings', 'LobbyBypassSettings'], [ 'autoAdmittedUsers', 'AutoAdmittedUsers', 'allowPSTNUsersToBypassLobby', 'AllowPSTNUsersToBypassLobby', ]), 'content_sharing' => $this->settingGroup($payload, ['contentSharing', 'ContentSharing'], [ 'screenSharingMode', 'ScreenSharingMode', 'allowPowerPointSharing', 'AllowPowerPointSharing', 'allowWhiteboard', 'AllowWhiteboard', 'allowSharedNotes', 'AllowSharedNotes', 'allowMeetingReactions', 'AllowMeetingReactions', 'allowParticipantGiveRequestControl', 'AllowParticipantGiveRequestControl', ]), 'source' => $this->sourceSummary($payload), 'diagnostics' => $this->diagnostics('meetingPolicy', $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; } /** * @param array $payload * @param list $groupKeys * @param list $rootKeys * @return array */ private function settingGroup(array $payload, array $groupKeys, array $rootKeys = []): array { $settings = []; foreach ($groupKeys as $groupKey) { $group = $payload[$groupKey] ?? null; if (is_array($group)) { foreach ($this->normalizeSettingMap($group) as $key => $value) { $settings[$key] = $value; } } } foreach ($rootKeys as $rootKey) { if (! array_key_exists($rootKey, $payload)) { continue; } $settings[$this->settingKey($rootKey)] = $this->normalizeSettingValue($payload[$rootKey]); } ksort($settings, SORT_NATURAL | SORT_FLAG_CASE); return $settings; } /** * @param array $settings * @return array */ private function normalizeSettingMap(array $settings): array { $normalized = []; foreach ($settings as $key => $value) { $key = $this->settingKey((string) $key); if ($this->isUnsafeContentKey($key)) { $normalized[$key] = '[redacted]'; continue; } $normalized[$key] = $this->normalizeSettingValue($value); } ksort($normalized, SORT_NATURAL | SORT_FLAG_CASE); return $normalized; } private function normalizeSettingValue(mixed $value): mixed { if ($value === '[redacted]') { return $value; } if (is_bool($value)) { return $value; } if (is_scalar($value)) { $value = trim((string) $value); return $value !== '' ? $value : null; } if (! is_array($value)) { return null; } if (array_is_list($value)) { $items = array_map(fn (mixed $item): mixed => $this->normalizeSettingValue($item), $value); $items = array_values(array_filter($items, static fn (mixed $item): bool => $item !== null && $item !== '')); usort($items, static fn (mixed $left, mixed $right): int => strcmp( json_encode($left, JSON_THROW_ON_ERROR), json_encode($right, JSON_THROW_ON_ERROR), )); return $items; } return $this->normalizeSettingMap($value); } private function settingKey(string $key): string { return str($key) ->replaceMatches('/[^A-Za-z0-9]+/', '_') ->snake() ->trim('_') ->toString(); } 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 list $candidates */ private function policyMode(array $candidates): ?string { $parts = []; foreach ($candidates as $candidate) { if (is_bool($candidate)) { $parts[] = $candidate ? 'allow all apps' : 'selected apps only'; continue; } $value = $this->stringValue($candidate); if ($value !== null) { $parts[] = $value; } } $parts = array_values(array_unique($parts)); return $parts === [] ? null : implode('; ', $parts); } /** * @return list> */ private function appList(mixed $value): array { if ($value === null || $value === '') { return []; } $items = is_array($value) && array_is_list($value) ? $value : [$value]; $apps = []; foreach ($items as $item) { if (is_array($item)) { $apps[] = array_filter([ 'display_name' => $this->firstString($item, ['displayName', 'DisplayName', 'name', 'Name']), 'app_id' => $this->firstString($item, ['appId', 'AppId', 'id', 'Id', 'externalId']), ], static fn (mixed $nested): bool => $nested !== null && $nested !== ''); continue; } $value = $this->stringValue($item); if ($value !== null) { $apps[] = ['display_name' => $value]; } } $apps = array_values(array_filter($apps, static fn (array $item): bool => $item !== [])); usort($apps, static fn (array $left, array $right): int => strcmp( json_encode($left, JSON_THROW_ON_ERROR), json_encode($right, JSON_THROW_ON_ERROR), )); return $apps; } /** * @param array $payload * @return array */ private function sourceSummary(array $payload): array { return array_filter([ 'version' => $this->firstString($payload, ['sourceVersion', 'source_version']), 'schema_hash' => $this->firstString($payload, ['sourceSchemaHash', 'source_schema_hash']), ], static fn (?string $value): bool => $value !== null && $value !== ''); } /** * @param array $rawPayload * @param array $payload * @return array> */ private function diagnostics(string $canonicalType, array $rawPayload, array $payload): array { return [ 'unsupported_fields' => $this->unsupportedRootFields($canonicalType, $payload), 'redacted_fields' => $this->redactedPaths($payload), 'volatile_fields' => $this->presentVolatileFields($rawPayload), ]; } /** * @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), )); sort($fields, SORT_NATURAL | SORT_FLAG_CASE); 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 $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; } }