512 lines
17 KiB
PHP
512 lines
17 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();
|
|
|
|
if ($envelope->resourceType !== $this->resourceType()) {
|
|
return ExchangePowerShellNormalizerReadiness::blocked(
|
|
resourceType: $envelope->resourceType,
|
|
definition: $definition,
|
|
blockers: ['blocked_target_not_ready'],
|
|
itemCount: $envelope->itemCount,
|
|
);
|
|
}
|
|
|
|
if (! $envelope->accepted()) {
|
|
return ExchangePowerShellNormalizerReadiness::blocked(
|
|
resourceType: $this->resourceType(),
|
|
definition: $definition,
|
|
blockers: [$envelope->readinessBlocker ?? 'blocked_unknown_output'],
|
|
itemCount: $envelope->itemCount,
|
|
);
|
|
}
|
|
|
|
if ($envelope->emptyCollection) {
|
|
return ExchangePowerShellNormalizerReadiness::ready(
|
|
resourceType: $this->resourceType(),
|
|
definition: $definition,
|
|
normalizedPreviews: [],
|
|
hashPreviews: [],
|
|
itemCount: 0,
|
|
emptyCollection: true,
|
|
);
|
|
}
|
|
|
|
$previews = [];
|
|
$seenIdentities = [];
|
|
|
|
foreach ($envelope->items as $item) {
|
|
$identity = $this->stableIdentity($item);
|
|
|
|
if (($identity['allowed'] ?? false) !== true) {
|
|
return ExchangePowerShellNormalizerReadiness::blocked(
|
|
resourceType: $this->resourceType(),
|
|
definition: $definition,
|
|
blockers: [(string) ($identity['blocker'] ?? 'blocked_identity_unproven')],
|
|
itemCount: $envelope->itemCount,
|
|
);
|
|
}
|
|
|
|
$identityKey = (string) $identity['key'];
|
|
|
|
if (isset($seenIdentities[$identityKey])) {
|
|
return ExchangePowerShellNormalizerReadiness::blocked(
|
|
resourceType: $this->resourceType(),
|
|
definition: $definition,
|
|
blockers: ['duplicate_stable_identity'],
|
|
itemCount: $envelope->itemCount,
|
|
);
|
|
}
|
|
|
|
$seenIdentities[$identityKey] = true;
|
|
$normalized = $this->normalizePayload($item, $identity);
|
|
$hashInput = $this->hashes->build(
|
|
resourceType: $this->resourceType(),
|
|
sourceSurface: ExchangePowerShellCommandContracts::SOURCE_SURFACE,
|
|
commandContractName: (string) $definition['command_contract_name'],
|
|
commandContractVersion: (string) $definition['command_contract_version'],
|
|
payloadShapeVersion: self::PAYLOAD_SHAPE_VERSION,
|
|
normalizerVersion: $this->normalizerVersion(),
|
|
normalizedPayload: $normalized,
|
|
);
|
|
|
|
$hashPreview = $this->hashes->hash($hashInput);
|
|
$previews[] = [
|
|
'identity_key' => $identityKey,
|
|
'identity_sort_key' => (string) $identity['value'],
|
|
'hash_preview' => $hashPreview,
|
|
'normalized_preview' => $normalized,
|
|
];
|
|
}
|
|
|
|
usort($previews, static function (array $left, array $right): int {
|
|
return [$left['identity_sort_key'], $left['identity_key'], $left['hash_preview']]
|
|
<=> [$right['identity_sort_key'], $right['identity_key'], $right['hash_preview']];
|
|
});
|
|
|
|
return ExchangePowerShellNormalizerReadiness::ready(
|
|
resourceType: $this->resourceType(),
|
|
definition: $definition,
|
|
normalizedPreviews: array_values(array_map(
|
|
static fn (array $preview): array => $preview['normalized_preview'],
|
|
$previews,
|
|
)),
|
|
hashPreviews: array_values(array_map(
|
|
static fn (array $preview): string => (string) $preview['hash_preview'],
|
|
$previews,
|
|
)),
|
|
itemCount: count($previews),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @return array<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),
|
|
],
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* @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;
|
|
}
|
|
}
|