From f8c298632b9399f1b899df389b36fb77ff70a984 Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Thu, 9 Jul 2026 21:10:17 +0200 Subject: [PATCH] feat: add exchange comparable payload builder --- ...angePowerShellComparablePayloadBuilder.php | 674 ++++++++++++++++++ ...ExchangeComparablePromotionFeatureTest.php | 273 +++++++ ...37ExchangeComparablePayloadBuilderTest.php | 601 ++++++++++++++++ 3 files changed, 1548 insertions(+) create mode 100644 apps/platform/app/Services/TenantConfiguration/ExchangePowerShellComparablePayloadBuilder.php create mode 100644 apps/platform/tests/Feature/TenantConfiguration/Spec437ExchangeComparablePromotionFeatureTest.php create mode 100644 apps/platform/tests/Unit/Support/TenantConfiguration/Spec437ExchangeComparablePayloadBuilderTest.php diff --git a/apps/platform/app/Services/TenantConfiguration/ExchangePowerShellComparablePayloadBuilder.php b/apps/platform/app/Services/TenantConfiguration/ExchangePowerShellComparablePayloadBuilder.php new file mode 100644 index 00000000..400d55c1 --- /dev/null +++ b/apps/platform/app/Services/TenantConfiguration/ExchangePowerShellComparablePayloadBuilder.php @@ -0,0 +1,674 @@ + + */ + private const SUPPORTED_RESOURCE_TYPES = [ + 'transportRule', + 'remoteDomain', + 'inboundConnector', + ]; + + /** + * @var list + */ + private const UNSAFE_REMOTE_DOMAIN_IDENTITY_FIELDS = [ + 'domainname', + 'displayname', + 'name', + ]; + + /** + * Fields listed here may keep exact normalized values in target sections + * only when the value matches the expected safe scalar shape. All other + * values in those sections must already be redacted markers. + * + * @var array}>>> + */ + private const SAFE_EXACT_VALUE_POLICIES = [ + 'transportRule' => [ + 'material' => [ + 'enabled' => 'bool', + 'mode' => ['enum' => ['enforce', 'audit', 'auditandnotify']], + 'state' => ['enum' => ['enabled', 'disabled']], + ], + 'rule_order' => [ + 'priority' => 'non_negative_integer', + ], + 'conditions' => [], + 'actions' => [ + 'delete_message' => 'bool', + 'apply_html_disclaimer_fallback_action' => ['enum' => ['wrap', 'ignore', 'reject']], + 'apply_html_disclaimer_location' => ['enum' => ['append', 'prepend']], + ], + 'exceptions' => [], + 'protected_configuration' => [], + 'sensitive_content' => [], + ], + 'remoteDomain' => [ + 'remote_domain_class' => [ + 'is_default' => 'bool', + 'domain_name_is_identity' => 'bool', + ], + 'settings' => [ + 'auto_reply_enabled' => 'bool', + ], + 'protected_configuration' => [], + 'sensitive_content' => [], + ], + 'inboundConnector' => [ + 'material' => [ + 'connector_type' => ['enum' => ['partner', 'onpremises']], + 'enabled' => 'bool', + 'require_tls' => 'bool', + ], + 'routing' => [ + 'connector_type' => ['enum' => ['partner', 'onpremises']], + 'enabled' => 'bool', + 'require_tls' => 'bool', + ], + 'protected_configuration' => [], + 'sensitive_content' => [], + ], + ]; + + /** + * These sections are only read through explicit fields in target payload builders. + * Unknown keys in these sections are ignored by output and therefore do not need + * leakage blocking. + * + * @var array> + */ + private const PARTIAL_EXACT_VALUE_SECTIONS = [ + 'transportRule' => [ + 'material', + 'rule_order', + ], + 'inboundConnector' => [ + 'material', + ], + ]; + + /** + * @param array $normalizedPayload + * @param array $metadata + * @return array{comparable: bool, blocker: string|null, resource_type: string, payload: array|null, comparable_hash?: string} + */ + public function build( + string $resourceType, + array $normalizedPayload, + ?string $payloadHash = null, + array $metadata = [], + ): array { + $resourceType = trim($resourceType); + + if (! in_array($resourceType, self::SUPPORTED_RESOURCE_TYPES, true)) { + return $this->blocked($resourceType, 'unsupported_resource_type'); + } + + if ($normalizedPayload === []) { + return $this->blocked($resourceType, 'missing_normalized_payload'); + } + + if (($normalizedPayload['canonical_type'] ?? null) !== $resourceType) { + return $this->blocked($resourceType, 'normalized_payload_type_mismatch'); + } + + if (($normalizedPayload['payload_shape_version'] ?? null) !== ExchangePowerShellTargetEvidenceNormalizer::PAYLOAD_SHAPE_VERSION) { + return $this->blocked($resourceType, 'unsupported_normalized_payload_shape'); + } + + $contentBackedBlocker = $this->contentBackedBlocker($payloadHash, $metadata); + + if ($contentBackedBlocker !== null) { + return $this->blocked($resourceType, $contentBackedBlocker); + } + + $identity = $this->sourceIdentity($normalizedPayload); + + if ($identity === null) { + return $this->blocked($resourceType, 'missing_stable_identity'); + } + + if ($resourceType === 'remoteDomain' && in_array($this->canonicalFieldKey($identity['field']), self::UNSAFE_REMOTE_DOMAIN_IDENTITY_FIELDS, true)) { + return $this->blocked($resourceType, 'unsafe_identity'); + } + + if ($this->identityHmacKey() === null) { + return $this->blocked($resourceType, 'missing_identity_hash_key'); + } + + if ($this->firstUnsafeExactValuePath($resourceType, $normalizedPayload) !== null) { + return $this->blocked($resourceType, 'unsafe_exact_value'); + } + + $payload = $this->sortAssociative([ + 'workload' => 'exchange', + 'resource_type' => $resourceType, + 'comparable_payload_shape_version' => self::PAYLOAD_SHAPE_VERSION, + 'compare_input' => 'normalized_payload_only', + 'identity' => $this->comparableIdentity($resourceType, $identity), + ...$this->targetPayload($resourceType, $normalizedPayload), + 'redaction' => $this->redactionSummary($normalizedPayload), + 'source' => $this->sourceMetadata($normalizedPayload), + 'provenance' => $this->provenance($payloadHash, $metadata), + ]); + + return [ + 'comparable' => true, + 'blocker' => null, + 'resource_type' => $resourceType, + 'payload' => $payload, + 'comparable_hash' => $this->hashComparablePayload($payload), + ]; + } + + /** + * @return array{field: string, value: string} + */ + private function sourceIdentity(array $normalizedPayload): ?array + { + $identity = $normalizedPayload['source_identity'] ?? null; + + if (! is_array($identity)) { + return null; + } + + $field = $this->stringValue($identity['field'] ?? null); + $value = $this->stringValue($identity['value'] ?? null); + + if ($field === null || $value === null) { + return null; + } + + return [ + 'field' => $field, + 'value' => $value, + ]; + } + + /** + * @param array{field: string, value: string} $identity + * @return array + */ + private function comparableIdentity(string $resourceType, array $identity): array + { + $valueHmac = $this->identityHmac($resourceType, $identity); + + return [ + 'field' => $identity['field'], + 'value_hmac' => $valueHmac, + 'source_key' => $resourceType.':'.$identity['field'].':'.$valueHmac, + ]; + } + + /** + * @param array $metadata + */ + private function contentBackedBlocker(?string $payloadHash, array $metadata): ?string + { + $payloadHash = $this->stringValue($payloadHash); + + if ($payloadHash === null) { + return 'missing_payload_hash'; + } + + if (! preg_match('/^[a-f0-9]{64}$/', $payloadHash)) { + return 'invalid_payload_hash'; + } + + if ($this->stringValue($metadata['evidence_state'] ?? null) !== EvidenceState::ContentBacked->value) { + return 'evidence_not_content_backed'; + } + + if ($this->stringValue($metadata['coverage_level'] ?? null) !== CoverageLevel::ContentBacked->value) { + return 'coverage_not_content_backed'; + } + + if ($this->stringValue($metadata['capture_outcome'] ?? null) !== CaptureOutcome::Captured->value) { + return 'capture_not_captured'; + } + + return null; + } + + /** + * @param array{field: string, value: string} $identity + */ + private function identityHmac(string $resourceType, array $identity): string + { + return hash_hmac( + 'sha256', + $resourceType."\0".$identity['field']."\0".$identity['value'], + (string) $this->identityHmacKey(), + ); + } + + private function identityHmacKey(): ?string + { + $key = $this->stringValue(config('app.key')); + + if ($key === null) { + return null; + } + + if (str_starts_with($key, 'base64:')) { + $decoded = base64_decode(substr($key, 7), true); + + return $decoded !== false && $decoded !== '' ? $decoded : null; + } + + return $key; + } + + /** + * @param array $normalizedPayload + */ + private function firstUnsafeExactValuePath(string $resourceType, array $normalizedPayload): ?string + { + foreach (self::SAFE_EXACT_VALUE_POLICIES[$resourceType] as $section => $safeFields) { + $sectionValue = $this->arrayValue($normalizedPayload[$section] ?? null); + + if (in_array($section, self::PARTIAL_EXACT_VALUE_SECTIONS[$resourceType] ?? [], true)) { + foreach ($safeFields as $field => $policy) { + $value = $this->sectionValueByCanonicalField($sectionValue, (string) $field); + + if ($value === null || $this->isRedactedMarker($value)) { + continue; + } + + if (! $this->safeExactValue($policy, $value)) { + return $section.'.'.(string) $field; + } + } + + continue; + } + + foreach ($sectionValue as $field => $value) { + if ($value === null || $this->isRedactedMarker($value)) { + continue; + } + + $policy = $this->exactValuePolicy($safeFields, (string) $field); + + if ($policy === null || ! $this->safeExactValue($policy, $value)) { + return $section.'.'.(string) $field; + } + } + } + + return null; + } + + /** + * @param array $normalizedPayload + * @return array + */ + private function targetPayload(string $resourceType, array $normalizedPayload): array + { + return match ($resourceType) { + 'transportRule' => $this->transportRulePayload($normalizedPayload), + 'remoteDomain' => $this->remoteDomainPayload($normalizedPayload), + 'inboundConnector' => $this->inboundConnectorPayload($normalizedPayload), + }; + } + + /** + * @param array $normalizedPayload + * @return array + */ + private function transportRulePayload(array $normalizedPayload): array + { + $material = $this->arrayValue($normalizedPayload['material'] ?? null); + + return [ + 'material' => $this->sortAssociative([ + 'enabled' => $this->sanitizeComparableValue($material['Enabled'] ?? $material['enabled'] ?? null), + 'mode' => $this->sanitizeComparableValue($material['Mode'] ?? $material['mode'] ?? null), + 'state' => $this->sanitizeComparableValue($material['State'] ?? $material['state'] ?? null), + 'priority' => $this->sanitizeComparableValue(data_get($normalizedPayload, 'rule_order.priority')), + ]), + 'conditions' => $this->sanitizeComparableMap($normalizedPayload['conditions'] ?? []), + 'actions' => $this->sanitizeComparableMap($normalizedPayload['actions'] ?? []), + 'exceptions' => $this->sanitizeComparableMap($normalizedPayload['exceptions'] ?? []), + 'protected_values' => $this->sanitizeComparableMap($normalizedPayload['protected_configuration'] ?? []), + 'sensitive_values' => $this->sanitizeComparableMap($normalizedPayload['sensitive_content'] ?? []), + ]; + } + + /** + * @param array $normalizedPayload + * @return array + */ + private function remoteDomainPayload(array $normalizedPayload): array + { + return [ + 'remote_domain_class' => $this->sanitizeComparableMap($normalizedPayload['remote_domain_class'] ?? []), + 'settings' => $this->sanitizeComparableMap($normalizedPayload['settings'] ?? []), + 'protected_values' => $this->sanitizeComparableMap($normalizedPayload['protected_configuration'] ?? []), + 'sensitive_values' => $this->sanitizeComparableMap($normalizedPayload['sensitive_content'] ?? []), + ]; + } + + /** + * @param array $normalizedPayload + * @return array + */ + private function inboundConnectorPayload(array $normalizedPayload): array + { + $material = $this->arrayValue($normalizedPayload['material'] ?? null); + + return [ + 'material' => $this->sortAssociative([ + 'connector_type' => $this->sanitizeComparableValue($material['ConnectorType'] ?? $material['connector_type'] ?? null), + 'enabled' => $this->sanitizeComparableValue($material['Enabled'] ?? $material['enabled'] ?? null), + 'require_tls' => $this->sanitizeComparableValue($material['RequireTls'] ?? $material['require_tls'] ?? null), + ]), + 'routing' => $this->sanitizeComparableMap($normalizedPayload['routing'] ?? []), + 'protected_values' => $this->sanitizeComparableMap($normalizedPayload['protected_configuration'] ?? []), + 'sensitive_values' => $this->sanitizeComparableMap($normalizedPayload['sensitive_content'] ?? []), + ]; + } + + /** + * @param array $normalizedPayload + * @return array + */ + private function redactionSummary(array $normalizedPayload): array + { + return $this->sortAssociative([ + 'protected_fields' => $this->stringList(data_get($normalizedPayload, 'diagnostics.protected_fields', [])), + 'sensitive_fields' => $this->stringList(data_get($normalizedPayload, 'diagnostics.sensitive_fields', [])), + 'volatile_fields_ignored' => $this->stringList(data_get($normalizedPayload, 'diagnostics.volatile_fields', [])), + 'protected_values_use_safe_markers' => true, + ]); + } + + /** + * @param array $normalizedPayload + * @return array + */ + private function sourceMetadata(array $normalizedPayload): array + { + $source = $this->arrayValue($normalizedPayload['source'] ?? null); + + return $this->sortAssociative([ + 'source_surface' => $this->stringValue($source['source_surface'] ?? $normalizedPayload['source_surface'] ?? null), + 'source_contract_key' => $this->stringValue($source['source_contract_key'] ?? null), + 'command_contract_name' => $this->stringValue($source['command_contract_name'] ?? null), + 'command_contract_version' => $this->stringValue($source['command_contract_version'] ?? null), + 'payload_shape_version' => $this->stringValue($source['payload_shape_version'] ?? $normalizedPayload['payload_shape_version'] ?? null), + 'normalizer_version' => $this->stringValue($source['normalizer_version'] ?? $normalizedPayload['normalizer_version'] ?? null), + ]); + } + + /** + * @param array $metadata + * @return array + */ + private function provenance(?string $payloadHash, array $metadata): array + { + return $this->sortAssociative([ + 'payload_hash' => $this->stringValue($payloadHash), + 'source_contract_key' => $this->stringValue($metadata['source_contract_key'] ?? null), + 'source_endpoint' => $this->stringValue($metadata['source_endpoint'] ?? null), + 'source_version' => $this->stringValue($metadata['source_version'] ?? null), + 'source_schema_hash' => $this->stringValue($metadata['source_schema_hash'] ?? null), + 'evidence_state' => $this->stringValue($metadata['evidence_state'] ?? null), + 'coverage_level' => $this->stringValue($metadata['coverage_level'] ?? null), + 'capture_outcome' => $this->stringValue($metadata['capture_outcome'] ?? null), + ]); + } + + /** + * @param array $payload + */ + private function hashComparablePayload(array $payload): string + { + unset($payload['provenance']); + + return hash('sha256', json_encode($this->sortAssociative($payload), JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR)); + } + + private function sanitizeComparableValue(mixed $value): mixed + { + if ($this->isRedactedMarker($value)) { + $present = (bool) ($value['present'] ?? false); + $count = is_numeric($value['count'] ?? null) ? (int) $value['count'] : ($present ? 1 : 0); + + return [ + 'redacted' => true, + 'present' => $present, + 'count' => max(0, $count), + 'state' => $present ? ($count > 1 ? 'present_multiple' : 'present') : 'absent', + ]; + } + + if (is_bool($value) || is_int($value) || is_float($value)) { + return $value; + } + + if (is_string($value)) { + $value = trim($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->sanitizeComparableValue($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_THROW_ON_ERROR), + json_encode($right, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR), + )); + + return $items; + } + + return $this->sanitizeComparableMap($value); + } + + /** + * @param mixed $value + * @return array + */ + private function sanitizeComparableMap(mixed $value): array + { + $value = $this->arrayValue($value); + $sanitized = []; + + foreach ($value as $key => $item) { + $sanitized[(string) $key] = $this->sanitizeComparableValue($item); + } + + return $this->sortAssociative($sanitized); + } + + private function isRedactedMarker(mixed $value): bool + { + if (! is_array($value) || array_is_list($value)) { + return false; + } + + return ($value['value'] ?? null) === '[redacted]' || ($value['redacted'] ?? null) === true; + } + + /** + * @param array $payload + * @return array + */ + private function sortAssociative(array $payload): array + { + $payload = array_filter($payload, static fn (mixed $value): bool => $value !== null); + + 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; + } + + /** + * @return array + */ + private function arrayValue(mixed $value): array + { + if (! is_array($value) || array_is_list($value)) { + return []; + } + + return $value; + } + + private function stringValue(mixed $value): ?string + { + if ($value instanceof BackedEnum) { + $value = $value->value; + } + + if (! is_scalar($value)) { + return null; + } + + if (is_bool($value)) { + return $value ? 'true' : 'false'; + } + + $value = trim((string) $value); + + return $value !== '' ? $value : null; + } + + /** + * @param array $payload + */ + private function sectionValueByCanonicalField(array $payload, string $field): mixed + { + $field = $this->canonicalFieldKey($field); + + foreach ($payload as $candidateField => $value) { + if ($this->canonicalFieldKey((string) $candidateField) === $field) { + return $value; + } + } + + return null; + } + + /** + * @param array}> $policies + * @return string|array{enum: list}|null + */ + private function exactValuePolicy(array $policies, string $field): string|array|null + { + $field = $this->canonicalFieldKey($field); + + foreach ($policies as $policyField => $policy) { + if ($this->canonicalFieldKey((string) $policyField) === $field) { + return $policy; + } + } + + return null; + } + + /** + * @param string|array{enum: list} $policy + */ + private function safeExactValue(string|array $policy, mixed $value): bool + { + if ($policy === 'bool') { + return is_bool($value); + } + + if ($policy === 'non_negative_integer') { + if (is_int($value)) { + return $value >= 0; + } + + if (! is_string($value)) { + return false; + } + + $value = trim($value); + + return $value !== '' && ctype_digit($value); + } + + if (is_array($policy) && array_key_exists('enum', $policy)) { + $value = $this->stringValue($value); + + if ($value === null) { + return false; + } + + $allowed = array_map(fn (string $item): string => $this->canonicalFieldKey($item), $policy['enum']); + + return in_array($this->canonicalFieldKey($value), $allowed, true); + } + + return false; + } + + private function canonicalFieldKey(string $field): string + { + return strtolower(preg_replace('/[^A-Za-z0-9]+/', '', $field) ?? ''); + } + + /** + * @return list + */ + private function stringList(mixed $value): array + { + if (! is_array($value)) { + return []; + } + + $values = array_values(array_filter( + array_map(static fn (mixed $item): string => is_string($item) ? trim($item) : '', $value), + static fn (string $item): bool => $item !== '', + )); + + sort($values, SORT_NATURAL | SORT_FLAG_CASE); + + return $values; + } + + /** + * @return array{comparable: false, blocker: string, resource_type: string, payload: null} + */ + private function blocked(string $resourceType, string $blocker): array + { + return [ + 'comparable' => false, + 'blocker' => $blocker, + 'resource_type' => $resourceType, + 'payload' => null, + ]; + } +} diff --git a/apps/platform/tests/Feature/TenantConfiguration/Spec437ExchangeComparablePromotionFeatureTest.php b/apps/platform/tests/Feature/TenantConfiguration/Spec437ExchangeComparablePromotionFeatureTest.php new file mode 100644 index 00000000..83d9c321 --- /dev/null +++ b/apps/platform/tests/Feature/TenantConfiguration/Spec437ExchangeComparablePromotionFeatureTest.php @@ -0,0 +1,273 @@ +syncDefaults(); +}); + +it('Spec437 derives comparable payloads without mutating evidence resource resource-type or OperationRun truth', function (): void { + [$user, $environment] = createMinimalUserWithTenant(role: 'owner'); + $connection = ProviderConnection::factory()->withCredential()->create([ + 'workspace_id' => (int) $environment->workspace_id, + 'managed_environment_id' => (int) $environment->getKey(), + ]); + $resourceType = spec437FeatureResourceType('transportRule'); + $operationRun = OperationRun::factory()->withUser($user)->forTenant($environment)->create([ + 'type' => OperationRunType::TenantConfigurationCapture->value, + 'status' => OperationRunStatus::Completed->value, + 'outcome' => OperationRunOutcome::Succeeded->value, + 'context' => [ + 'target_scope' => [ + 'workspace_id' => (int) $environment->workspace_id, + 'managed_environment_id' => (int) $environment->getKey(), + 'provider_connection_id' => (int) $connection->getKey(), + ], + 'resource_types' => ['transportRule'], + ], + ]); + $resource = TenantConfigurationResource::factory()->create([ + 'workspace_id' => (int) $environment->workspace_id, + 'managed_environment_id' => (int) $environment->getKey(), + 'provider_connection_id' => (int) $connection->getKey(), + 'resource_type_id' => (int) $resourceType->getKey(), + 'canonical_type' => 'transportRule', + 'latest_evidence_state' => EvidenceState::ContentBacked->value, + 'latest_identity_state' => IdentityState::Stable->value, + 'latest_claim_state' => ClaimState::InternalOnly->value, + 'latest_payload_hash' => spec437FeaturePayloadHash(), + ]); + $evidence = TenantConfigurationResourceEvidence::factory()->create([ + 'resource_id' => (int) $resource->getKey(), + 'workspace_id' => (int) $environment->workspace_id, + 'managed_environment_id' => (int) $environment->getKey(), + 'provider_connection_id' => (int) $connection->getKey(), + 'resource_type_id' => (int) $resourceType->getKey(), + 'operation_run_id' => (int) $operationRun->getKey(), + 'source_contract_key' => 'exchange_powershell.transportRule', + 'source_endpoint' => 'exchange_online_powershell_rest:Get-TransportRule', + 'source_metadata' => ['spec' => 437, 'source_identity' => 'must-not-override'], + 'raw_payload' => ['Guid' => 'spec437-rule-guid-1', 'SubjectContainsWords' => ['secret-subject']], + 'normalized_payload' => spec437FeatureTransportRuleNormalizedPayload(), + 'payload_hash' => spec437FeaturePayloadHash(), + 'permission_context' => ['provider_connection_id' => (int) $connection->getKey()], + 'evidence_state' => EvidenceState::ContentBacked->value, + 'coverage_level' => CoverageLevel::ContentBacked->value, + 'capture_outcome' => CaptureOutcome::Captured->value, + ]); + $resource->forceFill([ + 'latest_evidence_id' => (int) $evidence->getKey(), + 'latest_payload_hash' => spec437FeaturePayloadHash(), + ])->save(); + + $beforeEvidence = $evidence->fresh(); + $beforeResource = $resource->fresh(); + $beforeResourceType = $resourceType->fresh(); + $beforeOperationRun = $operationRun->fresh(); + + $result = app(ExchangePowerShellComparablePayloadBuilder::class)->build( + resourceType: 'transportRule', + normalizedPayload: $beforeEvidence->normalized_payload, + payloadHash: $beforeEvidence->payload_hash, + metadata: [ + 'source_contract_key' => $beforeEvidence->source_contract_key, + 'source_endpoint' => $beforeEvidence->source_endpoint, + 'evidence_state' => $beforeEvidence->evidence_state->value, + 'coverage_level' => $beforeEvidence->coverage_level->value, + 'capture_outcome' => $beforeEvidence->capture_outcome->value, + 'source_identity' => ['field' => 'metadata', 'value' => 'unsafe-override'], + ], + ); + + $afterEvidence = $evidence->fresh(); + $afterResource = $resource->fresh(); + $afterResourceType = $resourceType->fresh(); + $afterOperationRun = $operationRun->fresh(); + + expect($result['comparable'])->toBeTrue() + ->and($result['payload']['provenance']['coverage_level'])->toBe(CoverageLevel::ContentBacked->value) + ->and($result['payload']['identity']['field'])->toBe('Guid') + ->and($afterEvidence->raw_payload)->toBe($beforeEvidence->raw_payload) + ->and($afterEvidence->normalized_payload)->toBe($beforeEvidence->normalized_payload) + ->and($afterEvidence->payload_hash)->toBe($beforeEvidence->payload_hash) + ->and($afterEvidence->operation_run_id)->toBe($beforeEvidence->operation_run_id) + ->and($afterEvidence->source_metadata)->toBe($beforeEvidence->source_metadata) + ->and($afterEvidence->coverage_level)->toBe(CoverageLevel::ContentBacked) + ->and($afterResource->latest_evidence_id)->toBe($beforeResource->latest_evidence_id) + ->and($afterResource->latest_evidence_state)->toBe($beforeResource->latest_evidence_state) + ->and($afterResource->latest_claim_state)->toBe($beforeResource->latest_claim_state) + ->and($afterResource->latest_payload_hash)->toBe($beforeResource->latest_payload_hash) + ->and($afterResourceType->default_coverage_level)->toBe($beforeResourceType->default_coverage_level) + ->and($afterOperationRun->type)->toBe($beforeOperationRun->type) + ->and($afterOperationRun->context)->toBe($beforeOperationRun->context); +}); + +it('Spec437 returns no comparable artifact for missing unsafe or unsupported evidence input', function (): void { + $builder = app(ExchangePowerShellComparablePayloadBuilder::class); + $missing = $builder->build('transportRule', []); + $unsafe = $builder->build('remoteDomain', array_replace_recursive(spec437FeatureRemoteDomainNormalizedPayload(), [ + 'source_identity' => ['field' => 'DomainName', 'value' => 'contoso.example'], + ]), payloadHash: spec437FeaturePayloadHash(), metadata: spec437FeatureEvidenceMetadata()); + $unsupported = $builder->build('outboundConnector', spec437FeatureRemoteDomainNormalizedPayload()); + + expect($missing['comparable'])->toBeFalse() + ->and($missing['payload'])->toBeNull() + ->and($unsafe['comparable'])->toBeFalse() + ->and($unsafe['payload'])->toBeNull() + ->and($unsupported['comparable'])->toBeFalse() + ->and($unsupported['payload'])->toBeNull() + ->and(TenantConfigurationResource::query()->count())->toBe(0) + ->and(TenantConfigurationResourceEvidence::query()->count())->toBe(0); +}); + +it('Spec437 introduces no product surface trigger persisted compare table tenant-id ownership or OperationRun type', function (): void { + $builderSource = file_get_contents(app_path('Services/TenantConfiguration/ExchangePowerShellComparablePayloadBuilder.php')) ?: ''; + $operationTypes = OperationRunType::values(); + + expect($builderSource) + ->not->toContain('GenericPayloadNormalizer') + ->not->toContain('ExchangeTeamsComparablePayloadNormalizer') + ->not->toContain('CoverageEvidenceWriter') + ->not->toContain('TenantConfigurationResourceEvidence::') + ->and(Schema::hasColumn('tenant_configuration_resources', 'tenant_id'))->toBeFalse() + ->and(Schema::hasColumn('tenant_configuration_resource_evidence', 'tenant_id'))->toBeFalse() + ->and(glob(app_path('Filament/**/*ExchangePowerShellComparable*')) ?: [])->toBe([]) + ->and(glob(app_path('Livewire/**/*ExchangePowerShellComparable*')) ?: [])->toBe([]) + ->and(glob(base_path('routes/*exchange*comparable*')) ?: [])->toBe([]) + ->and(glob(resource_path('views/**/*ExchangePowerShellComparable*')) ?: [])->toBe([]) + ->and(glob(database_path('migrations/*exchange*comparable*')) ?: [])->toBe([]) + ->and(glob(database_path('migrations/*tenant*configuration*compare*')) ?: [])->toBe([]) + ->and(File::exists(app_path('Jobs/TenantConfiguration/BuildExchangePowerShellComparablePayloadJob.php')))->toBeFalse() + ->and(File::exists(app_path('Console/Commands/BuildExchangePowerShellComparablePayload.php')))->toBeFalse() + ->and($operationTypes) + ->not->toContain('tenant_configuration.exchange_comparable') + ->not->toContain('tenant_configuration.exchange_powershell_compare') + ->not->toContain('tenant_configuration.compare'); +}); + +function spec437FeatureResourceType(string $canonicalType): TenantConfigurationResourceType +{ + return TenantConfigurationResourceType::query() + ->where('canonical_type', $canonicalType) + ->firstOrFail(); +} + +function spec437FeatureTransportRuleNormalizedPayload(): array +{ + return [ + 'canonical_type' => 'transportRule', + 'source_surface' => ExchangePowerShellCommandContracts::SOURCE_SURFACE, + 'payload_shape_version' => ExchangePowerShellTargetEvidenceNormalizer::PAYLOAD_SHAPE_VERSION, + 'normalizer_version' => 'exchange-transport-rule-readiness-v1', + 'source_identity' => ['field' => 'Guid', 'value' => 'spec437-rule-guid-1'], + 'material' => [ + 'Enabled' => true, + 'Mode' => 'Enforce', + 'State' => 'Enabled', + ], + 'rule_order' => ['priority' => '0'], + 'conditions' => [ + 'sender_domain_is' => spec437FeatureRedactedMarker(count: 1), + 'subject_contains_words' => spec437FeatureRedactedMarker(count: 1), + ], + 'actions' => [ + 'delete_message' => true, + 'set_header_value' => spec437FeatureRedactedMarker(count: 1), + ], + 'exceptions' => [], + 'protected_configuration' => [ + 'sender_domain_is' => spec437FeatureRedactedMarker(count: 1), + ], + 'sensitive_content' => [ + 'subject_contains_words' => spec437FeatureRedactedMarker(count: 1), + ], + 'diagnostics' => [ + 'volatile_fields' => ['WhenChanged', 'ExchangeVersion', 'RunspaceId'], + 'protected_fields' => ['sender_domain_is'], + 'sensitive_fields' => ['subject_contains_words'], + ], + 'source' => [ + 'source_surface' => ExchangePowerShellCommandContracts::SOURCE_SURFACE, + 'source_contract_key' => 'exchange_powershell.transportRule', + 'command_contract_name' => 'Get-TransportRule', + 'command_contract_version' => ExchangePowerShellCommandContracts::COMMAND_CONTRACT_VERSION, + 'payload_shape_version' => ExchangePowerShellTargetEvidenceNormalizer::PAYLOAD_SHAPE_VERSION, + 'normalizer_version' => 'exchange-transport-rule-readiness-v1', + ], + ]; +} + +function spec437FeatureRemoteDomainNormalizedPayload(): array +{ + return [ + 'canonical_type' => 'remoteDomain', + 'source_surface' => ExchangePowerShellCommandContracts::SOURCE_SURFACE, + 'payload_shape_version' => ExchangePowerShellTargetEvidenceNormalizer::PAYLOAD_SHAPE_VERSION, + 'normalizer_version' => 'exchange-remote-domain-readiness-v1', + 'source_identity' => ['field' => 'Identity', 'value' => 'spec437-remote-domain-1'], + 'material' => ['IsDefault' => false], + 'remote_domain_class' => [ + 'domain_name_is_identity' => false, + 'is_default' => false, + ], + 'settings' => [ + 'auto_reply_enabled' => true, + 'auto_forward_enabled' => spec437FeatureRedactedMarker(count: 1), + ], + 'protected_configuration' => [ + 'domain_name' => spec437FeatureRedactedMarker(count: 1), + ], + 'sensitive_content' => [], + 'source' => [ + 'source_surface' => ExchangePowerShellCommandContracts::SOURCE_SURFACE, + 'source_contract_key' => 'exchange_powershell.remoteDomain', + 'command_contract_name' => 'Get-RemoteDomain', + 'command_contract_version' => ExchangePowerShellCommandContracts::COMMAND_CONTRACT_VERSION, + 'payload_shape_version' => ExchangePowerShellTargetEvidenceNormalizer::PAYLOAD_SHAPE_VERSION, + 'normalizer_version' => 'exchange-remote-domain-readiness-v1', + ], + ]; +} + +function spec437FeatureRedactedMarker(int $count = 1, bool $present = true): array +{ + return [ + 'value' => '[redacted]', + 'present' => $present, + 'count' => $count, + ]; +} + +function spec437FeatureEvidenceMetadata(): array +{ + return [ + 'evidence_state' => EvidenceState::ContentBacked->value, + 'coverage_level' => CoverageLevel::ContentBacked->value, + 'capture_outcome' => CaptureOutcome::Captured->value, + ]; +} + +function spec437FeaturePayloadHash(): string +{ + return str_repeat('b', 64); +} diff --git a/apps/platform/tests/Unit/Support/TenantConfiguration/Spec437ExchangeComparablePayloadBuilderTest.php b/apps/platform/tests/Unit/Support/TenantConfiguration/Spec437ExchangeComparablePayloadBuilderTest.php new file mode 100644 index 00000000..d16bab62 --- /dev/null +++ b/apps/platform/tests/Unit/Support/TenantConfiguration/Spec437ExchangeComparablePayloadBuilderTest.php @@ -0,0 +1,601 @@ + 'spec437-raw-provider-secret']; + $normalizedPayload['raw_stdout'] = 'spec437-raw-stdout-secret'; + $normalizedPayload['raw_stderr'] = 'spec437-raw-stderr-secret'; + $normalizedPayload['transcript'] = 'spec437-transcript-secret'; + $normalizedPayload['operation_run_context'] = ['token' => 'spec437-run-context-token']; + + $result = app(ExchangePowerShellComparablePayloadBuilder::class)->build( + resourceType: $canonicalType, + normalizedPayload: $normalizedPayload, + payloadHash: spec437UnitPayloadHash(), + metadata: [ + ...spec437UnitEvidenceMetadata(), + 'source_identity' => ['field' => 'metadata', 'value' => 'spec437-metadata-override'], + 'source_endpoint' => 'exchange_online_powershell_rest:'.$canonicalType, + ], + ); + + expect($result['comparable'])->toBeTrue() + ->and($result['blocker'])->toBeNull() + ->and($result['payload']['workload'])->toBe('exchange') + ->and($result['payload']['resource_type'])->toBe($canonicalType) + ->and($result['payload']['compare_input'])->toBe('normalized_payload_only') + ->and($result['payload']['identity']['source_key'])->toBe( + $canonicalType.':'.data_get($normalizedPayload, 'source_identity.field').':'.spec437UnitIdentityHmac( + $canonicalType, + (string) data_get($normalizedPayload, 'source_identity.field'), + (string) data_get($normalizedPayload, 'source_identity.value'), + ), + ) + ->and($result['payload']['identity']['value_hmac'])->toBe(spec437UnitIdentityHmac( + $canonicalType, + (string) data_get($normalizedPayload, 'source_identity.field'), + (string) data_get($normalizedPayload, 'source_identity.value'), + )) + ->and($result['payload']['provenance']['payload_hash'])->toBe(spec437UnitPayloadHash()) + ->and($result['payload']['source']['source_surface'])->toBe(ExchangePowerShellCommandContracts::SOURCE_SURFACE) + ->and($result['payload']['source']['payload_shape_version'])->toBe(ExchangePowerShellTargetEvidenceNormalizer::PAYLOAD_SHAPE_VERSION); + + foreach ($expectedPaths as $path => $value) { + expect(data_get($result['payload'], $path))->toBe($value); + } + + $encoded = json_encode($result, JSON_THROW_ON_ERROR); + + foreach ($forbiddenValues as $forbiddenValue) { + expect($encoded)->not->toContain($forbiddenValue); + } + + expect($encoded) + ->not->toContain('spec437-raw-provider-secret') + ->not->toContain('spec437-raw-stdout-secret') + ->not->toContain('spec437-raw-stderr-secret') + ->not->toContain('spec437-transcript-secret') + ->not->toContain('spec437-run-context-token') + ->not->toContain('spec437-metadata-override') + ->not->toContain((string) data_get($normalizedPayload, 'source_identity.value')); +})->with([ + 'transportRule' => [ + 'transportRule', + spec437UnitTransportRuleNormalizedPayload(), + [ + 'material.enabled' => true, + 'material.mode' => 'Enforce', + 'material.state' => 'Enabled', + 'material.priority' => '0', + 'conditions.sender_domain_is.redacted' => true, + 'conditions.sender_domain_is.count' => 2, + 'actions.delete_message' => true, + 'actions.set_header_value.redacted' => true, + 'exceptions.except_if_from.redacted' => true, + ], + [ + 'contoso.example', + 'fabrikam.example', + 'subject-secret', + 'header-secret', + 'except-secret@example.com', + 'spec437-rule-guid-1', + ], + ], + 'remoteDomain' => [ + 'remoteDomain', + spec437UnitRemoteDomainNormalizedPayload(), + [ + 'remote_domain_class.is_default' => false, + 'settings.auto_reply_enabled' => true, + 'settings.auto_forward_enabled.redacted' => true, + 'settings.mail_tips_access_scope.redacted' => true, + 'protected_values.domain_name.redacted' => true, + ], + [ + 'contoso.example', + 'mailtips-secret', + 'spec437-remote-domain-1', + ], + ], + 'inboundConnector' => [ + 'inboundConnector', + spec437UnitInboundConnectorNormalizedPayload(), + [ + 'material.connector_type' => 'Partner', + 'material.enabled' => true, + 'material.require_tls' => true, + 'routing.sender_ip_addresses.redacted' => true, + 'routing.sender_ip_addresses.count' => 1, + 'routing.smart_hosts.redacted' => true, + 'routing.tls_sender_certificate_name.redacted' => true, + 'routing.comment.redacted' => true, + ], + [ + '203.0.113.10', + 'mail.contoso.example', + 'CN=secret-cert', + 'sensitive routing note', + 'spec437-inbound-connector-1', + ], + ], +]); + +it('Spec437 blocks unsupported targets missing normalized identity and unsafe remote-domain identity', function ( + string $resourceType, + array $normalizedPayload, + string $expectedBlocker, +): void { + $result = app(ExchangePowerShellComparablePayloadBuilder::class)->build( + $resourceType, + $normalizedPayload, + payloadHash: spec437UnitPayloadHash(), + metadata: spec437UnitEvidenceMetadata(), + ); + + expect($result['comparable'])->toBeFalse() + ->and($result['blocker'])->toBe($expectedBlocker) + ->and($result['payload'])->toBeNull(); +})->with([ + 'unsupported target' => ['acceptedDomain', spec437UnitTransportRuleNormalizedPayload(), 'unsupported_resource_type'], + 'missing normalized payload' => ['transportRule', [], 'missing_normalized_payload'], + 'wrong canonical type' => ['transportRule', spec437UnitRemoteDomainNormalizedPayload(), 'normalized_payload_type_mismatch'], + 'missing source identity' => ['transportRule', array_diff_key(spec437UnitTransportRuleNormalizedPayload(), ['source_identity' => true]), 'missing_stable_identity'], + 'remote domain domain-only identity' => [ + 'remoteDomain', + array_replace_recursive(spec437UnitRemoteDomainNormalizedPayload(), [ + 'source_identity' => ['field' => 'DomainName', 'value' => 'contoso.example'], + ]), + 'unsafe_identity', + ], + 'remote domain display-only identity' => [ + 'remoteDomain', + array_replace_recursive(spec437UnitRemoteDomainNormalizedPayload(), [ + 'source_identity' => ['field' => 'DisplayName', 'value' => 'Contoso remote domain'], + ]), + 'unsafe_identity', + ], + 'remote domain snake-case domain identity' => [ + 'remoteDomain', + array_replace_recursive(spec437UnitRemoteDomainNormalizedPayload(), [ + 'source_identity' => ['field' => 'domain_name', 'value' => 'contoso.example'], + ]), + 'unsafe_identity', + ], + 'remote domain uppercase display identity' => [ + 'remoteDomain', + array_replace_recursive(spec437UnitRemoteDomainNormalizedPayload(), [ + 'source_identity' => ['field' => 'DISPLAY_NAME', 'value' => 'Contoso remote domain'], + ]), + 'unsafe_identity', + ], +]); + +it('Spec437 blocks non content-backed or uncaptured evidence provenance', function ( + ?string $payloadHash, + array $metadata, + string $expectedBlocker, +): void { + $result = app(ExchangePowerShellComparablePayloadBuilder::class)->build( + 'transportRule', + spec437UnitTransportRuleNormalizedPayload(), + payloadHash: $payloadHash, + metadata: $metadata, + ); + + expect($result['comparable'])->toBeFalse() + ->and($result['blocker'])->toBe($expectedBlocker) + ->and($result['payload'])->toBeNull(); +})->with([ + 'missing payload hash' => [null, spec437UnitEvidenceMetadata(), 'missing_payload_hash'], + 'invalid payload hash' => ['spec437-source-payload-hash', spec437UnitEvidenceMetadata(), 'invalid_payload_hash'], + 'wrong evidence state' => [ + spec437UnitPayloadHash(), + [...spec437UnitEvidenceMetadata(), 'evidence_state' => EvidenceState::NotCaptured->value], + 'evidence_not_content_backed', + ], + 'wrong coverage level' => [ + spec437UnitPayloadHash(), + [...spec437UnitEvidenceMetadata(), 'coverage_level' => CoverageLevel::Detected->value], + 'coverage_not_content_backed', + ], + 'wrong capture outcome' => [ + spec437UnitPayloadHash(), + [...spec437UnitEvidenceMetadata(), 'capture_outcome' => CaptureOutcome::BlockedUnsupported->value], + 'capture_not_captured', + ], +]); + +it('Spec437 blocks unsafe exact values in protected comparable sections', function ( + string $resourceType, + array $normalizedPayload, + string $forbiddenValue, +): void { + $result = app(ExchangePowerShellComparablePayloadBuilder::class)->build( + $resourceType, + $normalizedPayload, + payloadHash: spec437UnitPayloadHash(), + metadata: spec437UnitEvidenceMetadata(), + ); + $encoded = json_encode($result, JSON_THROW_ON_ERROR); + + expect($result['comparable'])->toBeFalse() + ->and($result['blocker'])->toBe('unsafe_exact_value') + ->and($result['payload'])->toBeNull() + ->and($encoded)->not->toContain($forbiddenValue); +})->with([ + 'transportRule raw condition' => [ + 'transportRule', + array_replace_recursive(spec437UnitTransportRuleNormalizedPayload(), [ + 'conditions' => ['sender_domain_is' => 'contoso.example'], + ]), + 'contoso.example', + ], + 'transportRule raw action' => [ + 'transportRule', + array_replace_recursive(spec437UnitTransportRuleNormalizedPayload(), [ + 'actions' => ['set_header_value' => 'header-secret'], + ]), + 'header-secret', + ], + 'remoteDomain raw setting' => [ + 'remoteDomain', + array_replace_recursive(spec437UnitRemoteDomainNormalizedPayload(), [ + 'settings' => ['mail_tips_access_scope' => 'mailtips-secret'], + ]), + 'mailtips-secret', + ], + 'remoteDomain raw protected value' => [ + 'remoteDomain', + array_replace_recursive(spec437UnitRemoteDomainNormalizedPayload(), [ + 'protected_configuration' => ['domain_name' => 'contoso.example'], + ]), + 'contoso.example', + ], + 'inboundConnector raw routing' => [ + 'inboundConnector', + array_replace_recursive(spec437UnitInboundConnectorNormalizedPayload(), [ + 'routing' => ['sender_ip_addresses' => '203.0.113.10'], + ]), + '203.0.113.10', + ], + 'inboundConnector raw protected value' => [ + 'inboundConnector', + array_replace_recursive(spec437UnitInboundConnectorNormalizedPayload(), [ + 'protected_configuration' => ['sender_ip_addresses' => '203.0.113.10'], + ]), + '203.0.113.10', + ], +]); + +it('Spec437 blocks malformed exact values on allowlisted comparable fields', function ( + string $resourceType, + array $normalizedPayload, + string $forbiddenValue, +): void { + $result = app(ExchangePowerShellComparablePayloadBuilder::class)->build( + $resourceType, + $normalizedPayload, + payloadHash: spec437UnitPayloadHash(), + metadata: spec437UnitEvidenceMetadata(), + ); + $encoded = json_encode($result, JSON_THROW_ON_ERROR); + + expect($result['comparable'])->toBeFalse() + ->and($result['blocker'])->toBe('unsafe_exact_value') + ->and($result['payload'])->toBeNull() + ->and($encoded)->not->toContain($forbiddenValue); +})->with([ + 'transportRule malformed bool action' => [ + 'transportRule', + array_replace_recursive(spec437UnitTransportRuleNormalizedPayload(), [ + 'actions' => ['delete_message' => 'header-secret'], + ]), + 'header-secret', + ], + 'transportRule malformed enum action' => [ + 'transportRule', + array_replace_recursive(spec437UnitTransportRuleNormalizedPayload(), [ + 'actions' => ['apply_html_disclaimer_fallback_action' => 'secret-fallback'], + ]), + 'secret-fallback', + ], + 'transportRule malformed priority' => [ + 'transportRule', + array_replace_recursive(spec437UnitTransportRuleNormalizedPayload(), [ + 'rule_order' => ['priority' => 'priority-secret'], + ]), + 'priority-secret', + ], + 'remoteDomain malformed bool setting' => [ + 'remoteDomain', + array_replace_recursive(spec437UnitRemoteDomainNormalizedPayload(), [ + 'settings' => ['auto_reply_enabled' => 'mailtips-secret'], + ]), + 'mailtips-secret', + ], + 'inboundConnector malformed enum routing' => [ + 'inboundConnector', + array_replace_recursive(spec437UnitInboundConnectorNormalizedPayload(), [ + 'routing' => ['connector_type' => 'mail.contoso.example'], + ]), + 'mail.contoso.example', + ], + 'inboundConnector malformed bool material' => [ + 'inboundConnector', + array_replace_recursive(spec437UnitInboundConnectorNormalizedPayload(), [ + 'material' => ['RequireTls' => 'tls-secret'], + ]), + 'tls-secret', + ], +]); + +it('Spec437 comparable hash changes only for material or safe redacted-state changes', function (): void { + $builder = app(ExchangePowerShellComparablePayloadBuilder::class); + $base = spec437UnitTransportRuleNormalizedPayload(); + $volatileOnly = array_replace_recursive($base, [ + 'diagnostics' => [ + 'volatile_fields' => ['RunspaceId', 'WhenChanged', 'ExchangeVersion'], + ], + 'runtime_context' => [ + 'WhenChanged' => '2026-07-09T10:00:00Z', + ], + ]); + $materialChanged = array_replace_recursive($base, [ + 'material' => [ + 'Enabled' => false, + ], + ]); + $redactedExactChangedSameCount = array_replace_recursive($base, [ + 'conditions' => [ + 'sender_domain_is' => spec437UnitRedactedMarker(count: 2), + ], + ]); + $redactedCountChanged = array_replace_recursive($base, [ + 'conditions' => [ + 'sender_domain_is' => spec437UnitRedactedMarker(count: 3), + ], + ]); + + $baseResult = spec437UnitBuild($builder, 'transportRule', $base); + + expect($baseResult['comparable_hash'])->toBe(spec437UnitBuild($builder, 'transportRule', $volatileOnly)['comparable_hash']) + ->and($baseResult['comparable_hash'])->not->toBe(spec437UnitBuild($builder, 'transportRule', $materialChanged)['comparable_hash']) + ->and($baseResult['comparable_hash'])->toBe(spec437UnitBuild($builder, 'transportRule', $redactedExactChangedSameCount)['comparable_hash']) + ->and($baseResult['comparable_hash'])->not->toBe(spec437UnitBuild($builder, 'transportRule', $redactedCountChanged)['comparable_hash']); +}); + +it('Spec437 comparable hash behavior is deterministic for remoteDomain and inboundConnector targets', function ( + string $resourceType, + array $base, + array $volatileOnly, + array $materialChanged, + array $redactedCountChanged, +): void { + $builder = app(ExchangePowerShellComparablePayloadBuilder::class); + $baseResult = spec437UnitBuild($builder, $resourceType, $base); + + expect($baseResult['comparable_hash'])->toBe(spec437UnitBuild($builder, $resourceType, $volatileOnly)['comparable_hash']) + ->and($baseResult['comparable_hash'])->not->toBe(spec437UnitBuild($builder, $resourceType, $materialChanged)['comparable_hash']) + ->and($baseResult['comparable_hash'])->not->toBe(spec437UnitBuild($builder, $resourceType, $redactedCountChanged)['comparable_hash']); +})->with([ + 'remoteDomain' => [ + 'remoteDomain', + spec437UnitRemoteDomainNormalizedPayload(), + array_replace_recursive(spec437UnitRemoteDomainNormalizedPayload(), [ + 'diagnostics' => ['volatile_fields' => ['RunspaceId', 'WhenChanged', 'ExchangeVersion']], + 'runtime_context' => ['WhenChanged' => '2026-07-09T10:00:00Z'], + ]), + array_replace_recursive(spec437UnitRemoteDomainNormalizedPayload(), [ + 'settings' => ['auto_reply_enabled' => false], + ]), + array_replace_recursive(spec437UnitRemoteDomainNormalizedPayload(), [ + 'protected_configuration' => ['domain_name' => spec437UnitRedactedMarker(count: 2)], + ]), + ], + 'inboundConnector' => [ + 'inboundConnector', + spec437UnitInboundConnectorNormalizedPayload(), + array_replace_recursive(spec437UnitInboundConnectorNormalizedPayload(), [ + 'diagnostics' => ['volatile_fields' => ['RunspaceId', 'WhenChanged', 'ExchangeVersion']], + 'runtime_context' => ['WhenChanged' => '2026-07-09T10:00:00Z'], + ]), + array_replace_recursive(spec437UnitInboundConnectorNormalizedPayload(), [ + 'material' => ['RequireTls' => false], + 'routing' => ['require_tls' => false], + ]), + array_replace_recursive(spec437UnitInboundConnectorNormalizedPayload(), [ + 'routing' => ['sender_ip_addresses' => spec437UnitRedactedMarker(count: 2)], + 'protected_configuration' => ['sender_ip_addresses' => spec437UnitRedactedMarker(count: 2)], + ]), + ], +]); + +function spec437UnitTransportRuleNormalizedPayload(): array +{ + return [ + 'canonical_type' => 'transportRule', + 'source_surface' => ExchangePowerShellCommandContracts::SOURCE_SURFACE, + 'payload_shape_version' => ExchangePowerShellTargetEvidenceNormalizer::PAYLOAD_SHAPE_VERSION, + 'normalizer_version' => 'exchange-transport-rule-readiness-v1', + 'source_identity' => ['field' => 'Guid', 'value' => 'spec437-rule-guid-1'], + 'material' => [ + 'DisplayName' => 'Spec 437 transport rule', + 'Enabled' => true, + 'Mode' => 'Enforce', + 'State' => 'Enabled', + ], + 'rule_order' => [ + 'priority' => '0', + 'order_is_identity' => false, + ], + 'conditions' => [ + 'sender_domain_is' => spec437UnitRedactedMarker(count: 2), + 'subject_contains_words' => spec437UnitRedactedMarker(count: 1), + ], + 'actions' => [ + 'delete_message' => true, + 'set_header_value' => spec437UnitRedactedMarker(count: 1), + ], + 'exceptions' => [ + 'except_if_from' => spec437UnitRedactedMarker(count: 1), + ], + 'protected_configuration' => [ + 'sender_domain_is' => spec437UnitRedactedMarker(count: 2), + ], + 'sensitive_content' => [ + 'subject_contains_words' => spec437UnitRedactedMarker(count: 1), + ], + 'diagnostics' => [ + 'volatile_fields' => ['WhenChanged', 'ExchangeVersion', 'RunspaceId'], + 'protected_fields' => ['sender_domain_is'], + 'sensitive_fields' => ['subject_contains_words'], + ], + 'source' => spec437UnitSourceMetadata('transportRule', 'Get-TransportRule', 'exchange-transport-rule-readiness-v1'), + ]; +} + +function spec437UnitRemoteDomainNormalizedPayload(): array +{ + return [ + 'canonical_type' => 'remoteDomain', + 'source_surface' => ExchangePowerShellCommandContracts::SOURCE_SURFACE, + 'payload_shape_version' => ExchangePowerShellTargetEvidenceNormalizer::PAYLOAD_SHAPE_VERSION, + 'normalizer_version' => 'exchange-remote-domain-readiness-v1', + 'source_identity' => ['field' => 'Identity', 'value' => 'spec437-remote-domain-1'], + 'material' => [ + 'Identity' => 'spec437-remote-domain-1', + 'IsDefault' => false, + ], + 'remote_domain_class' => [ + 'domain_name_is_identity' => false, + 'is_default' => false, + ], + 'settings' => [ + 'auto_reply_enabled' => true, + 'auto_forward_enabled' => spec437UnitRedactedMarker(count: 1), + 'mail_tips_access_scope' => spec437UnitRedactedMarker(count: 1), + ], + 'protected_configuration' => [ + 'domain_name' => spec437UnitRedactedMarker(count: 1), + ], + 'sensitive_content' => [ + 'mail_tips_access_scope' => spec437UnitRedactedMarker(count: 1), + ], + 'diagnostics' => [ + 'volatile_fields' => ['WhenChanged', 'ExchangeVersion', 'RunspaceId'], + 'protected_fields' => ['domain_name'], + 'sensitive_fields' => ['mail_tips_access_scope'], + ], + 'source' => spec437UnitSourceMetadata('remoteDomain', 'Get-RemoteDomain', 'exchange-remote-domain-readiness-v1'), + ]; +} + +function spec437UnitInboundConnectorNormalizedPayload(): array +{ + return [ + 'canonical_type' => 'inboundConnector', + 'source_surface' => ExchangePowerShellCommandContracts::SOURCE_SURFACE, + 'payload_shape_version' => ExchangePowerShellTargetEvidenceNormalizer::PAYLOAD_SHAPE_VERSION, + 'normalizer_version' => 'exchange-inbound-connector-readiness-v1', + 'source_identity' => ['field' => 'ConnectorId', 'value' => 'spec437-inbound-connector-1'], + 'material' => [ + 'ConnectorType' => 'Partner', + 'Enabled' => true, + 'RequireTls' => true, + ], + 'routing' => [ + 'connector_type' => 'Partner', + 'enabled' => true, + 'require_tls' => true, + 'sender_ip_addresses' => spec437UnitRedactedMarker(count: 1), + 'smart_hosts' => spec437UnitRedactedMarker(count: 1), + 'tls_sender_certificate_name' => spec437UnitRedactedMarker(count: 1), + 'comment' => spec437UnitRedactedMarker(count: 1), + ], + 'protected_configuration' => [ + 'sender_ip_addresses' => spec437UnitRedactedMarker(count: 1), + ], + 'sensitive_content' => [ + 'smart_hosts' => spec437UnitRedactedMarker(count: 1), + 'tls_sender_certificate_name' => spec437UnitRedactedMarker(count: 1), + 'comment' => spec437UnitRedactedMarker(count: 1), + ], + 'diagnostics' => [ + 'volatile_fields' => ['WhenChanged', 'ExchangeVersion', 'RunspaceId'], + 'protected_fields' => ['sender_ip_addresses'], + 'sensitive_fields' => ['smart_hosts', 'tls_sender_certificate_name', 'comment'], + ], + 'source' => spec437UnitSourceMetadata('inboundConnector', 'Get-InboundConnector', 'exchange-inbound-connector-readiness-v1'), + ]; +} + +function spec437UnitRedactedMarker(int $count = 1, bool $present = true): array +{ + return [ + 'value' => '[redacted]', + 'present' => $present, + 'count' => $count, + ]; +} + +function spec437UnitSourceMetadata(string $canonicalType, string $commandName, string $normalizerVersion): array +{ + return [ + 'source_surface' => ExchangePowerShellCommandContracts::SOURCE_SURFACE, + 'source_contract_key' => 'exchange_powershell.'.$canonicalType, + 'command_contract_name' => $commandName, + 'command_contract_version' => ExchangePowerShellCommandContracts::COMMAND_CONTRACT_VERSION, + 'payload_shape_version' => ExchangePowerShellTargetEvidenceNormalizer::PAYLOAD_SHAPE_VERSION, + 'normalizer_version' => $normalizerVersion, + ]; +} + +function spec437UnitEvidenceMetadata(): array +{ + return [ + 'evidence_state' => EvidenceState::ContentBacked->value, + 'coverage_level' => CoverageLevel::ContentBacked->value, + 'capture_outcome' => CaptureOutcome::Captured->value, + ]; +} + +function spec437UnitBuild(ExchangePowerShellComparablePayloadBuilder $builder, string $resourceType, array $normalizedPayload): array +{ + return $builder->build( + $resourceType, + $normalizedPayload, + payloadHash: spec437UnitPayloadHash(), + metadata: spec437UnitEvidenceMetadata(), + ); +} + +function spec437UnitPayloadHash(): string +{ + return str_repeat('a', 64); +} + +function spec437UnitIdentityHmac(string $resourceType, string $field, string $value): string +{ + $key = (string) config('app.key'); + + if (str_starts_with($key, 'base64:')) { + $decoded = base64_decode(substr($key, 7), true); + $key = $decoded !== false ? $decoded : ''; + } + + return hash_hmac('sha256', $resourceType."\0".$field."\0".$value, $key); +}