feat: add exchange comparable payload builder
Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 1m46s
Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 1m46s
This commit is contained in:
parent
bcec737cd4
commit
f8c298632b
@ -0,0 +1,674 @@
|
||||
<?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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,273 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\ProviderConnection;
|
||||
use App\Models\TenantConfigurationResource;
|
||||
use App\Models\TenantConfigurationResourceEvidence;
|
||||
use App\Models\TenantConfigurationResourceType;
|
||||
use App\Services\TenantConfiguration\ExchangePowerShellComparablePayloadBuilder;
|
||||
use App\Services\TenantConfiguration\ExchangePowerShellCommandContracts;
|
||||
use App\Services\TenantConfiguration\ExchangePowerShellTargetEvidenceNormalizer;
|
||||
use App\Services\TenantConfiguration\ResourceTypeRegistry;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\OperationRunType;
|
||||
use App\Support\TenantConfiguration\CaptureOutcome;
|
||||
use App\Support\TenantConfiguration\ClaimState;
|
||||
use App\Support\TenantConfiguration\CoverageLevel;
|
||||
use App\Support\TenantConfiguration\EvidenceState;
|
||||
use App\Support\TenantConfiguration\IdentityState;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
beforeEach(function (): void {
|
||||
app(ResourceTypeRegistry::class)->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);
|
||||
}
|
||||
@ -0,0 +1,601 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Services\TenantConfiguration\ExchangePowerShellComparablePayloadBuilder;
|
||||
use App\Services\TenantConfiguration\ExchangePowerShellCommandContracts;
|
||||
use App\Services\TenantConfiguration\ExchangePowerShellTargetEvidenceNormalizer;
|
||||
use App\Support\TenantConfiguration\CaptureOutcome;
|
||||
use App\Support\TenantConfiguration\CoverageLevel;
|
||||
use App\Support\TenantConfiguration\EvidenceState;
|
||||
|
||||
it('Spec437 builds deterministic comparable payloads from normalized payloads for each Exchange target', function (
|
||||
string $canonicalType,
|
||||
array $normalizedPayload,
|
||||
array $expectedPaths,
|
||||
array $forbiddenValues,
|
||||
): void {
|
||||
$normalizedPayload['raw_payload'] = ['secret' => '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);
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user