TenantAtlas/apps/platform/app/Services/TenantConfiguration/ExchangePowerShellComparablePayloadBuilder.php
Ahmed Darrazi f8c298632b
Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 1m46s
feat: add exchange comparable payload builder
2026-07-09 21:10:17 +02:00

675 lines
23 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Services\TenantConfiguration;
use App\Support\TenantConfiguration\CaptureOutcome;
use App\Support\TenantConfiguration\CoverageLevel;
use App\Support\TenantConfiguration\EvidenceState;
use BackedEnum;
final class ExchangePowerShellComparablePayloadBuilder
{
public const string PAYLOAD_SHAPE_VERSION = 'exchange-powershell-comparable-payload-v1';
/**
* @var list<string>
*/
private const SUPPORTED_RESOURCE_TYPES = [
'transportRule',
'remoteDomain',
'inboundConnector',
];
/**
* @var list<string>
*/
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<string, array<string, array<string, string|array{enum: list<string>}>>>
*/
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<string, list<string>>
*/
private const PARTIAL_EXACT_VALUE_SECTIONS = [
'transportRule' => [
'material',
'rule_order',
],
'inboundConnector' => [
'material',
],
];
/**
* @param array<string, mixed> $normalizedPayload
* @param array<string, mixed> $metadata
* @return array{comparable: bool, blocker: string|null, resource_type: string, payload: array<string, mixed>|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<string, mixed>
*/
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<string, mixed> $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<string, mixed> $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<string, mixed> $normalizedPayload
* @return array<string, mixed>
*/
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<string, mixed> $normalizedPayload
* @return array<string, mixed>
*/
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<string, mixed> $normalizedPayload
* @return array<string, mixed>
*/
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<string, mixed> $normalizedPayload
* @return array<string, mixed>
*/
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<string, mixed> $normalizedPayload
* @return array<string, mixed>
*/
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<string, mixed> $normalizedPayload
* @return array<string, mixed>
*/
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<string, mixed> $metadata
* @return array<string, mixed>
*/
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<string, mixed> $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<string, mixed>
*/
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<string, mixed> $payload
* @return array<string, mixed>
*/
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<string, mixed>
*/
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<string, mixed> $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<string, string|array{enum: list<string>}> $policies
* @return string|array{enum: list<string>}|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<string>} $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<string>
*/
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,
];
}
}