*/ abstract protected function materialFields(): array; /** * @return list> */ protected function identityAliasGroups(): array { return [ ['id', 'sourceId'], ]; } public function evaluate(ExchangePowerShellStructuredOutputEnvelope $envelope): ExchangePowerShellNormalizerReadiness { $definition = $this->definition(); if ($envelope->resourceType !== $this->resourceType()) { return ExchangePowerShellNormalizerReadiness::blocked( resourceType: $envelope->resourceType, definition: $definition, blockers: ['blocked_target_not_ready'], itemCount: $envelope->itemCount, ); } if (! $envelope->accepted()) { return ExchangePowerShellNormalizerReadiness::blocked( resourceType: $this->resourceType(), definition: $definition, blockers: [$envelope->readinessBlocker ?? 'blocked_unknown_output'], itemCount: $envelope->itemCount, ); } if ($envelope->emptyCollection) { return ExchangePowerShellNormalizerReadiness::ready( resourceType: $this->resourceType(), definition: $definition, normalizedPreviews: [], hashPreviews: [], itemCount: 0, emptyCollection: true, ); } $previews = []; $seenIdentities = []; foreach ($envelope->items as $item) { $identity = $this->stableIdentity($item); if (($identity['allowed'] ?? false) !== true) { return ExchangePowerShellNormalizerReadiness::blocked( resourceType: $this->resourceType(), definition: $definition, blockers: [(string) ($identity['blocker'] ?? 'blocked_identity_unproven')], itemCount: $envelope->itemCount, ); } $identityKey = (string) $identity['key']; if (isset($seenIdentities[$identityKey])) { return ExchangePowerShellNormalizerReadiness::blocked( resourceType: $this->resourceType(), definition: $definition, blockers: ['duplicate_stable_identity'], itemCount: $envelope->itemCount, ); } $seenIdentities[$identityKey] = true; $normalized = $this->normalizePayload($item, $identity); $hashInput = $this->hashes->build( resourceType: $this->resourceType(), sourceSurface: ExchangePowerShellCommandContracts::SOURCE_SURFACE, commandContractName: (string) $definition['command_contract_name'], commandContractVersion: (string) $definition['command_contract_version'], payloadShapeVersion: self::PAYLOAD_SHAPE_VERSION, normalizerVersion: $this->normalizerVersion(), normalizedPayload: $normalized, ); $hashPreview = $this->hashes->hash($hashInput); $previews[] = [ 'identity_key' => $identityKey, 'identity_sort_key' => (string) $identity['value'], 'hash_preview' => $hashPreview, 'normalized_preview' => $normalized, ]; } usort($previews, static function (array $left, array $right): int { return [$left['identity_sort_key'], $left['identity_key'], $left['hash_preview']] <=> [$right['identity_sort_key'], $right['identity_key'], $right['hash_preview']]; }); return ExchangePowerShellNormalizerReadiness::ready( resourceType: $this->resourceType(), definition: $definition, normalizedPreviews: array_values(array_map( static fn (array $preview): array => $preview['normalized_preview'], $previews, )), hashPreviews: array_values(array_map( static fn (array $preview): string => (string) $preview['hash_preview'], $previews, )), itemCount: count($previews), ); } /** * @return array */ public function definition(): array { $contract = $this->contract(); $shape = is_array($contract['response_shape'] ?? null) ? $contract['response_shape'] : []; return [ 'resource_type' => $this->resourceType(), 'source_surface' => ExchangePowerShellCommandContracts::SOURCE_SURFACE, 'command_contract_name' => (string) ($contract['command_name'] ?? ''), 'command_contract_version' => ExchangePowerShellCommandContracts::COMMAND_CONTRACT_VERSION, 'payload_shape_version' => self::PAYLOAD_SHAPE_VERSION, 'normalizer_version' => $this->normalizerVersion(), 'identity_candidate_fields' => $this->stringList($shape['required_identity_candidate_fields'] ?? []), 'material_fields' => $this->materialFields(), 'volatile_fields' => $this->stringList($shape['volatile_fields'] ?? []), 'sensitive_fields' => $this->stringList($shape['sensitive_fields'] ?? []), 'protected_fields' => $this->stringList($shape['protected_configuration_fields'] ?? []), 'ordering_rules' => [ 'associative_keys_sorted' => true, 'unordered_lists_sorted_by_canonical_value' => true, 'collection_ordering' => 'stable_identity_value_then_hash_preview', ], 'hash_input_rules' => [ 'includes_resource_type' => true, 'includes_source_surface' => true, 'includes_command_contract' => true, 'includes_payload_shape_version' => true, 'includes_normalizer_version' => true, 'excludes_raw_stdout' => true, 'excludes_raw_stderr' => true, 'excludes_operation_run_context' => true, 'excludes_runtime_timestamps' => true, 'excludes_powershell_session_metadata' => true, 'excludes_credential_metadata' => true, 'excludes_permission_metadata' => true, ], 'redaction_policy' => [ 'protected_configuration_values' => 'presence_only_redacted', 'sensitive_values' => 'presence_only_redacted', 'raw_provider_payload_default_visible' => false, ], 'readiness_blockers' => [ 'blocked_malformed_output', 'blocked_warning_prefixed_output', 'blocked_binary_output', 'blocked_oversized_output', 'blocked_nonzero_exit', 'blocked_timeout', 'identity_conflict', 'missing_stable_external_id', 'derived_identity_blocked', 'display_name_only', 'duplicate_stable_identity', 'unsupported_identity', 'blocked_redaction_unproven', 'blocked_hash_unready', 'blocked_target_not_ready', ], ]; } /** * @param array $payload * @param array $identity * @return array */ protected function normalizePayload(array $payload, array $identity): array { $material = []; $protected = []; $sensitive = []; $volatile = $this->volatileFields(); foreach ($this->materialFields() as $field) { if (! array_key_exists($field, $payload) || in_array($field, $volatile, true)) { continue; } if ($this->isProtectedField($field)) { $protected[$field] = $this->redactedMarker($payload[$field]); continue; } if ($this->isSensitiveField($field)) { $sensitive[$field] = $this->redactedMarker($payload[$field]); continue; } $material[$this->settingKey($field)] = $this->normalizeSettingValue($payload[$field]); } return $this->sortAssociative([ 'canonical_type' => $this->resourceType(), 'source_surface' => ExchangePowerShellCommandContracts::SOURCE_SURFACE, 'payload_shape_version' => self::PAYLOAD_SHAPE_VERSION, 'normalizer_version' => $this->normalizerVersion(), 'source_identity' => [ 'field' => $identity['field'], 'value' => $identity['value'], ], 'material' => $this->sortAssociative($material), 'protected_configuration' => $this->sortAssociative($protected), 'sensitive_content' => $this->sortAssociative($sensitive), 'diagnostics' => [ 'volatile_fields' => $volatile, 'protected_fields' => array_keys($protected), 'sensitive_fields' => array_keys($sensitive), ], ]); } /** * @param array $payload * @return array{allowed: bool, key?: string, field?: string, value?: string, blocker?: string} */ protected function stableIdentity(array $payload): array { foreach ($this->identityAliasGroups() as $group) { $values = []; foreach ($group as $field) { $value = $this->stringValue($payload[$field] ?? null); if ($value !== null) { $values[$field] = $value; } } if (count(array_unique(array_values($values))) > 1) { return ['allowed' => false, 'blocker' => 'identity_conflict']; } } foreach ($this->identityFields() as $field) { $value = $this->stringValue($payload[$field] ?? null); if ($value !== null) { return [ 'allowed' => true, 'field' => $field, 'value' => $value, 'key' => $this->resourceType().':'.$field.':'.hash('sha256', $value), ]; } } if ($this->hasDisplayOnlyIdentity($payload)) { return ['allowed' => false, 'blocker' => 'display_name_only']; } foreach ($this->derivedIdentityFields() as $field) { if ($this->stringValue($payload[$field] ?? null) !== null) { return ['allowed' => false, 'blocker' => 'derived_identity_blocked']; } } return ['allowed' => false, 'blocker' => 'missing_stable_external_id']; } /** * @param list $groupKeys * @param list $rootKeys * @return array */ protected function settingGroup(array $payload, array $groupKeys, array $rootKeys = []): array { $settings = []; foreach ($groupKeys as $groupKey) { $group = $payload[$groupKey] ?? null; if (! is_array($group)) { continue; } foreach ($group as $key => $value) { $field = (string) $key; $settings[$this->settingKey($field)] = $this->redactedOrNormalized($field, $value); } } foreach ($rootKeys as $rootKey) { if (! array_key_exists($rootKey, $payload)) { continue; } $settings[$this->settingKey($rootKey)] = $this->redactedOrNormalized($rootKey, $payload[$rootKey]); } return $this->sortAssociative($settings); } protected function redactedOrNormalized(string $field, mixed $value): mixed { if ($this->isProtectedField($field) || $this->isSensitiveField($field)) { return $this->redactedMarker($value); } return $this->normalizeSettingValue($value); } protected function redactedMarker(mixed $value): array { return [ 'value' => '[redacted]', 'present' => $value !== null, 'count' => is_array($value) ? count($value) : ($value === null ? 0 : 1), ]; } protected function normalizeSettingValue(mixed $value): mixed { 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_values(array_filter( array_map(fn (mixed $item): mixed => $this->normalizeSettingValue($item), $value), static fn (mixed $item): bool => $item !== null && $item !== '', )); usort($items, static fn (mixed $left, mixed $right): int => strcmp( json_encode($left, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR), json_encode($right, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR), )); return $items; } $normalized = []; foreach ($value as $key => $nestedValue) { $normalized[$this->settingKey((string) $key)] = $this->normalizeSettingValue($nestedValue); } return $this->sortAssociative($normalized); } /** * @param array $payload * @return array */ protected function sortAssociative(array $payload): array { ksort($payload, SORT_NATURAL | SORT_FLAG_CASE); foreach ($payload as $key => $value) { if (is_array($value) && ! array_is_list($value)) { $payload[$key] = $this->sortAssociative($value); } } return $payload; } protected function settingKey(string $key): string { $key = str_replace( ['IP', 'TLS', 'OOF', 'TNEF'], ['Ip', 'Tls', 'Oof', 'Tnef'], $key, ); return str($key) ->replaceMatches('/[^A-Za-z0-9]+/', '_') ->snake() ->trim('_') ->toString(); } protected 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; } /** * @return list */ protected function identityFields(): array { return $this->stringList(data_get($this->contract(), 'response_shape.required_identity_candidate_fields', [])); } /** * @return list */ protected function derivedIdentityFields(): array { return $this->stringList(data_get($this->contract(), 'response_shape.derived_identity_candidate_fields', [])); } /** * @return list */ protected function volatileFields(): array { return $this->stringList(data_get($this->contract(), 'response_shape.volatile_fields', [])); } protected function isSensitiveField(string $field): bool { return in_array( strtolower($field), array_map('strtolower', $this->stringList(data_get($this->contract(), 'response_shape.sensitive_fields', []))), true, ); } protected function isProtectedField(string $field): bool { return in_array( strtolower($field), array_map('strtolower', $this->stringList(data_get($this->contract(), 'response_shape.protected_configuration_fields', []))), true, ); } /** * @return array */ protected function contract(): array { return $this->contracts->contractForCanonicalType($this->resourceType()) ?? []; } /** * @return list */ protected function stringList(mixed $value): array { if (! is_array($value)) { return []; } return array_values(array_filter( array_map(static fn (mixed $item): string => is_string($item) ? trim($item) : '', $value), static fn (string $item): bool => $item !== '', )); } private function hasDisplayOnlyIdentity(array $payload): bool { foreach (['DisplayName', 'displayName', 'Name', 'name'] as $field) { if ($this->stringValue($payload[$field] ?? null) !== null) { return true; } } return false; } }