*/ 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, ]; } }