TenantAtlas/apps/platform/app/Services/TenantConfiguration/ExchangePowerShellTargetEvidenceNormalizer.php
ahmido 30a6733f78 Spec 436: Exchange content-backed evidence promotion (#503)
Summary: Wire Exchange PowerShell evidence capture through Spec 435 structured envelopes, target normalizers, and hash builder for transportRule, remoteDomain, and inboundConnector; preserve append-only internal content-backed evidence with fail-closed readiness, output, identity, and redaction behavior; add Spec 436 artifacts and focused coverage. Validation: php artisan test --filter=Spec436 --compact PASS, 28 tests / 307 assertions; git diff --cached --check PASS before commit. Product/Ops: Livewire v4 unchanged; provider registration unchanged under apps/platform/bootstrap/providers.php; global search unchanged; no destructive/high-impact actions; no assets/browser/UI/deployment impact.
Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #503
2026-07-09 14:03:10 +00:00

559 lines
19 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Services\TenantConfiguration;
abstract class ExchangePowerShellTargetEvidenceNormalizer
{
public const string PAYLOAD_SHAPE_VERSION = 'exchange-powershell-normalized-payload-v1';
public function __construct(
protected readonly ExchangePowerShellCommandContracts $contracts,
protected readonly ExchangePowerShellHashInputBuilder $hashes,
) {}
abstract public function resourceType(): string;
abstract public function normalizerVersion(): string;
/**
* @return list<string>
*/
abstract protected function materialFields(): array;
/**
* @return list<list<string>>
*/
protected function identityAliasGroups(): array
{
return [
['id', 'sourceId'],
];
}
public function evaluate(ExchangePowerShellStructuredOutputEnvelope $envelope): ExchangePowerShellNormalizerReadiness
{
$definition = $this->definition();
$prepared = $this->prepareEvidenceItems($envelope);
if (($prepared['ready'] ?? false) !== true) {
return ExchangePowerShellNormalizerReadiness::blocked(
resourceType: $envelope->resourceType,
definition: $definition,
blockers: $prepared['blockers'],
itemCount: $prepared['item_count'],
);
}
return ExchangePowerShellNormalizerReadiness::ready(
resourceType: $this->resourceType(),
definition: $definition,
normalizedPreviews: array_values(array_map(
static fn (array $item): array => $item['normalized_payload'],
$prepared['items'],
)),
hashPreviews: array_values(array_map(
static fn (array $item): string => (string) $item['payload_hash'],
$prepared['items'],
)),
itemCount: $prepared['item_count'],
emptyCollection: $prepared['empty_collection'],
);
}
/**
* @return list<array{raw_payload: array<string, mixed>, normalized_payload: array<string, mixed>, payload_hash: string}>
*/
public function normalizedEvidenceItems(ExchangePowerShellStructuredOutputEnvelope $envelope): array
{
$prepared = $this->prepareEvidenceItems($envelope);
return ($prepared['ready'] ?? false) === true ? $prepared['items'] : [];
}
/**
* @return array<string, mixed>
*/
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<string, mixed> $payload
* @param array<string, mixed> $identity
* @return array<string, mixed>
*/
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),
],
]);
}
/**
* @return array{
* ready: bool,
* blockers: list<string>,
* items: list<array{raw_payload: array<string, mixed>, normalized_payload: array<string, mixed>, payload_hash: string}>,
* item_count: int,
* empty_collection: bool
* }
*/
private function prepareEvidenceItems(ExchangePowerShellStructuredOutputEnvelope $envelope): array
{
if ($envelope->resourceType !== $this->resourceType()) {
return $this->blockedEvidenceItems(['blocked_target_not_ready'], $envelope->itemCount);
}
if (! $envelope->accepted()) {
return $this->blockedEvidenceItems([$envelope->readinessBlocker ?? 'blocked_unknown_output'], $envelope->itemCount);
}
if ($envelope->emptyCollection) {
return [
'ready' => true,
'blockers' => [],
'items' => [],
'item_count' => 0,
'empty_collection' => true,
];
}
$definition = $this->definition();
$items = [];
$seenIdentities = [];
foreach ($envelope->items as $item) {
$identity = $this->stableIdentity($item);
if (($identity['allowed'] ?? false) !== true) {
return $this->blockedEvidenceItems([(string) ($identity['blocker'] ?? 'blocked_identity_unproven')], $envelope->itemCount);
}
$identityKey = (string) $identity['key'];
if (isset($seenIdentities[$identityKey])) {
return $this->blockedEvidenceItems(['duplicate_stable_identity'], $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,
);
$payloadHash = $this->hashes->hash($hashInput);
$items[] = [
'identity_key' => $identityKey,
'identity_sort_key' => (string) $identity['value'],
'payload_hash' => $payloadHash,
'raw_payload' => $item,
'normalized_payload' => $normalized,
];
}
usort($items, static function (array $left, array $right): int {
return [$left['identity_sort_key'], $left['identity_key'], $left['payload_hash']]
<=> [$right['identity_sort_key'], $right['identity_key'], $right['payload_hash']];
});
return [
'ready' => true,
'blockers' => [],
'items' => array_values(array_map(static fn (array $item): array => [
'raw_payload' => $item['raw_payload'],
'normalized_payload' => $item['normalized_payload'],
'payload_hash' => $item['payload_hash'],
], $items)),
'item_count' => count($items),
'empty_collection' => false,
];
}
/**
* @param list<string> $blockers
* @return array{
* ready: false,
* blockers: list<string>,
* items: list<array{raw_payload: array<string, mixed>, normalized_payload: array<string, mixed>, payload_hash: string}>,
* item_count: int,
* empty_collection: false
* }
*/
private function blockedEvidenceItems(array $blockers, int $itemCount): array
{
return [
'ready' => false,
'blockers' => $blockers,
'items' => [],
'item_count' => max(0, $itemCount),
'empty_collection' => false,
];
}
/**
* @param array<string, mixed> $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<string> $groupKeys
* @param list<string> $rootKeys
* @return array<string, mixed>
*/
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<string, mixed> $payload
* @return array<string, mixed>
*/
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<string>
*/
protected function identityFields(): array
{
return $this->stringList(data_get($this->contract(), 'response_shape.required_identity_candidate_fields', []));
}
/**
* @return list<string>
*/
protected function derivedIdentityFields(): array
{
return $this->stringList(data_get($this->contract(), 'response_shape.derived_identity_candidate_fields', []));
}
/**
* @return list<string>
*/
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<string, mixed>
*/
protected function contract(): array
{
return $this->contracts->contractForCanonicalType($this->resourceType()) ?? [];
}
/**
* @return list<string>
*/
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;
}
}