837 lines
29 KiB
PHP
837 lines
29 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Services\TenantConfiguration;
|
|
|
|
final class SecurityComplianceComparablePayloadNormalizer
|
|
{
|
|
/**
|
|
* @var list<string>
|
|
*/
|
|
private const SUPPORTED_TYPES = [
|
|
'retentionCompliancePolicy',
|
|
'labelPolicy',
|
|
'dlpCompliancePolicy',
|
|
];
|
|
|
|
/**
|
|
* @var list<string>
|
|
*/
|
|
private const VOLATILE_ROOT_FIELDS = [
|
|
'@odata.context',
|
|
'@odata.etag',
|
|
'createdAt',
|
|
'createdDateTime',
|
|
'created_at',
|
|
'graphContext',
|
|
'lastModifiedDateTime',
|
|
'modifiedDateTime',
|
|
'sourceMetadata',
|
|
'source_metadata',
|
|
'tcmContext',
|
|
'updatedAt',
|
|
'updatedDateTime',
|
|
'updated_at',
|
|
'version',
|
|
'whenChanged',
|
|
];
|
|
|
|
/**
|
|
* @var array<string, list<string>>
|
|
*/
|
|
private const SUPPORTED_ROOT_FIELDS = [
|
|
'retentionCompliancePolicy' => [
|
|
'@odata.context',
|
|
'@odata.etag',
|
|
'DispositionAction',
|
|
'DisplayName',
|
|
'Duration',
|
|
'Enabled',
|
|
'ExcludedGroups',
|
|
'ExcludedLocations',
|
|
'ExcludedUsers',
|
|
'Identity',
|
|
'IncludedGroups',
|
|
'IncludedLocations',
|
|
'IncludedUsers',
|
|
'Name',
|
|
'RetentionAction',
|
|
'RetentionDuration',
|
|
'RetentionDurationUnit',
|
|
'State',
|
|
'displayName',
|
|
'duration',
|
|
'durationUnit',
|
|
'enabled',
|
|
'excludedGroups',
|
|
'excludedLocations',
|
|
'excludedUsers',
|
|
'id',
|
|
'includedGroups',
|
|
'includedLocations',
|
|
'includedUsers',
|
|
'name',
|
|
'retentionAction',
|
|
'retentionDuration',
|
|
'retentionDurationUnit',
|
|
'state',
|
|
],
|
|
'labelPolicy' => [
|
|
'@odata.context',
|
|
'@odata.etag',
|
|
'DefaultLabel',
|
|
'DisplayName',
|
|
'ExcludedGroups',
|
|
'ExcludedLocations',
|
|
'ExcludedUsers',
|
|
'Identity',
|
|
'IncludedGroups',
|
|
'IncludedLocations',
|
|
'IncludedUsers',
|
|
'Labels',
|
|
'Mandatory',
|
|
'Name',
|
|
'PublishedLabels',
|
|
'State',
|
|
'defaultLabel',
|
|
'displayName',
|
|
'excludedGroups',
|
|
'excludedLocations',
|
|
'excludedUsers',
|
|
'id',
|
|
'includedGroups',
|
|
'includedLocations',
|
|
'includedUsers',
|
|
'labels',
|
|
'mandatory',
|
|
'name',
|
|
'publishedLabels',
|
|
'state',
|
|
],
|
|
'dlpCompliancePolicy' => [
|
|
'@odata.context',
|
|
'@odata.etag',
|
|
'Actions',
|
|
'DisplayName',
|
|
'ExcludedLocations',
|
|
'Identity',
|
|
'IncludedLocations',
|
|
'Locations',
|
|
'Mode',
|
|
'Name',
|
|
'Rules',
|
|
'State',
|
|
'Workloads',
|
|
'actions',
|
|
'displayName',
|
|
'excludedLocations',
|
|
'id',
|
|
'includedLocations',
|
|
'locations',
|
|
'mode',
|
|
'name',
|
|
'rules',
|
|
'state',
|
|
'workloads',
|
|
],
|
|
];
|
|
|
|
/**
|
|
* @var list<string>
|
|
*/
|
|
private const SENSITIVE_CONTENT_KEYS = [
|
|
'auditmetadata.rawpayload',
|
|
'body',
|
|
'casecontent',
|
|
'content',
|
|
'dlpincidentcontent',
|
|
'ediscoverycasecontent',
|
|
'filecontent',
|
|
'fingerprint',
|
|
'mailbody',
|
|
'mailcontent',
|
|
'messagebody',
|
|
'messagecontent',
|
|
'providerresponse',
|
|
'rawpayload',
|
|
'sampledata',
|
|
'securityincidentcontent',
|
|
];
|
|
|
|
/**
|
|
* @var list<string>
|
|
*/
|
|
private const SENSITIVE_CONTENT_KEY_PARTS = [
|
|
'casecontent',
|
|
'contentcontainssensitiveinformation',
|
|
'dlpincident',
|
|
'ediscovery',
|
|
'fingerprint',
|
|
'mailcontent',
|
|
'messagecontent',
|
|
'providerresponse',
|
|
'rawpayload',
|
|
'securityincident',
|
|
];
|
|
|
|
/**
|
|
* @var array<string, array<string, list<string>>>
|
|
*/
|
|
private const SUPPORTED_NESTED_FIELDS = [
|
|
'retentionCompliancePolicy' => [
|
|
'IncludedLocations' => ['DisplayName', 'Identity', 'Name', 'displayName', 'id', 'name'],
|
|
'includedLocations' => ['DisplayName', 'Identity', 'Name', 'displayName', 'id', 'name'],
|
|
'ExcludedLocations' => ['DisplayName', 'Identity', 'Name', 'displayName', 'id', 'name'],
|
|
'excludedLocations' => ['DisplayName', 'Identity', 'Name', 'displayName', 'id', 'name'],
|
|
'IncludedGroups' => ['DisplayName', 'Identity', 'Mail', 'Name', 'displayName', 'id', 'mail', 'name'],
|
|
'includedGroups' => ['DisplayName', 'Identity', 'Mail', 'Name', 'displayName', 'id', 'mail', 'name'],
|
|
'ExcludedGroups' => ['DisplayName', 'Identity', 'Mail', 'Name', 'displayName', 'id', 'mail', 'name'],
|
|
'excludedGroups' => ['DisplayName', 'Identity', 'Mail', 'Name', 'displayName', 'id', 'mail', 'name'],
|
|
'IncludedUsers' => ['DisplayName', 'Identity', 'Mail', 'Name', 'UserPrincipalName', 'displayName', 'id', 'mail', 'name', 'userPrincipalName'],
|
|
'includedUsers' => ['DisplayName', 'Identity', 'Mail', 'Name', 'UserPrincipalName', 'displayName', 'id', 'mail', 'name', 'userPrincipalName'],
|
|
'ExcludedUsers' => ['DisplayName', 'Identity', 'Mail', 'Name', 'UserPrincipalName', 'displayName', 'id', 'mail', 'name', 'userPrincipalName'],
|
|
'excludedUsers' => ['DisplayName', 'Identity', 'Mail', 'Name', 'UserPrincipalName', 'displayName', 'id', 'mail', 'name', 'userPrincipalName'],
|
|
],
|
|
'labelPolicy' => [
|
|
'PublishedLabels' => ['DisplayName', 'Identity', 'Name', 'displayName', 'id', 'name'],
|
|
'publishedLabels' => ['DisplayName', 'Identity', 'Name', 'displayName', 'id', 'name'],
|
|
'Labels' => ['DisplayName', 'Identity', 'Name', 'displayName', 'id', 'name'],
|
|
'labels' => ['DisplayName', 'Identity', 'Name', 'displayName', 'id', 'name'],
|
|
'DefaultLabel' => ['DisplayName', 'Identity', 'Name', 'displayName', 'id', 'name'],
|
|
'defaultLabel' => ['DisplayName', 'Identity', 'Name', 'displayName', 'id', 'name'],
|
|
'IncludedLocations' => ['DisplayName', 'Identity', 'Name', 'displayName', 'id', 'name'],
|
|
'includedLocations' => ['DisplayName', 'Identity', 'Name', 'displayName', 'id', 'name'],
|
|
'ExcludedLocations' => ['DisplayName', 'Identity', 'Name', 'displayName', 'id', 'name'],
|
|
'excludedLocations' => ['DisplayName', 'Identity', 'Name', 'displayName', 'id', 'name'],
|
|
'IncludedGroups' => ['DisplayName', 'Identity', 'Mail', 'Name', 'displayName', 'id', 'mail', 'name'],
|
|
'includedGroups' => ['DisplayName', 'Identity', 'Mail', 'Name', 'displayName', 'id', 'mail', 'name'],
|
|
'ExcludedGroups' => ['DisplayName', 'Identity', 'Mail', 'Name', 'displayName', 'id', 'mail', 'name'],
|
|
'excludedGroups' => ['DisplayName', 'Identity', 'Mail', 'Name', 'displayName', 'id', 'mail', 'name'],
|
|
'IncludedUsers' => ['DisplayName', 'Identity', 'Mail', 'Name', 'UserPrincipalName', 'displayName', 'id', 'mail', 'name', 'userPrincipalName'],
|
|
'includedUsers' => ['DisplayName', 'Identity', 'Mail', 'Name', 'UserPrincipalName', 'displayName', 'id', 'mail', 'name', 'userPrincipalName'],
|
|
'ExcludedUsers' => ['DisplayName', 'Identity', 'Mail', 'Name', 'UserPrincipalName', 'displayName', 'id', 'mail', 'name', 'userPrincipalName'],
|
|
'excludedUsers' => ['DisplayName', 'Identity', 'Mail', 'Name', 'UserPrincipalName', 'displayName', 'id', 'mail', 'name', 'userPrincipalName'],
|
|
],
|
|
'dlpCompliancePolicy' => [
|
|
'Locations' => ['DisplayName', 'Identity', 'Name', 'displayName', 'id', 'name'],
|
|
'locations' => ['DisplayName', 'Identity', 'Name', 'displayName', 'id', 'name'],
|
|
'IncludedLocations' => ['DisplayName', 'Identity', 'Name', 'displayName', 'id', 'name'],
|
|
'includedLocations' => ['DisplayName', 'Identity', 'Name', 'displayName', 'id', 'name'],
|
|
'ExcludedLocations' => ['DisplayName', 'Identity', 'Name', 'displayName', 'id', 'name'],
|
|
'excludedLocations' => ['DisplayName', 'Identity', 'Name', 'displayName', 'id', 'name'],
|
|
'Workloads' => ['DisplayName', 'Identity', 'Name', 'displayName', 'id', 'name'],
|
|
'workloads' => ['DisplayName', 'Identity', 'Name', 'displayName', 'id', 'name'],
|
|
'Actions' => ['Action', 'DisplayName', 'Identity', 'Name', 'Type', 'action', 'displayName', 'id', 'name', 'type'],
|
|
'actions' => ['Action', 'DisplayName', 'Identity', 'Name', 'Type', 'action', 'displayName', 'id', 'name', 'type'],
|
|
'Rules' => ['Actions', 'DisplayName', 'Enabled', 'Mode', 'Name', 'Severity', 'State', 'actions', 'displayName', 'enabled', 'mode', 'name', 'severity', 'state'],
|
|
'rules' => ['Actions', 'DisplayName', 'Enabled', 'Mode', 'Name', 'Severity', 'State', 'actions', 'displayName', 'enabled', 'mode', 'name', 'severity', 'state'],
|
|
],
|
|
];
|
|
|
|
/**
|
|
* @var array<string, array<string, array<string, list<string>>>>
|
|
*/
|
|
private const SUPPORTED_NESTED_FIELD_CHILDREN = [
|
|
'dlpCompliancePolicy' => [
|
|
'Rules' => [
|
|
'Actions' => ['Action', 'DisplayName', 'Identity', 'Name', 'Type', 'action', 'displayName', 'id', 'name', 'type'],
|
|
'actions' => ['Action', 'DisplayName', 'Identity', 'Name', 'Type', 'action', 'displayName', 'id', 'name', 'type'],
|
|
],
|
|
'rules' => [
|
|
'Actions' => ['Action', 'DisplayName', 'Identity', 'Name', 'Type', 'action', 'displayName', 'id', 'name', 'type'],
|
|
'actions' => ['Action', 'DisplayName', 'Identity', 'Name', 'Type', 'action', 'displayName', 'id', 'name', 'type'],
|
|
],
|
|
],
|
|
];
|
|
|
|
public function __construct(
|
|
private readonly CoveragePayloadRedactor $redactor,
|
|
) {}
|
|
|
|
public function supports(string $canonicalType): bool
|
|
{
|
|
return in_array($canonicalType, self::SUPPORTED_TYPES, true);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $payload
|
|
* @return array<string, mixed>
|
|
*/
|
|
public function normalize(string $canonicalType, array $payload): array
|
|
{
|
|
if (! $this->supports($canonicalType)) {
|
|
return [
|
|
'canonical_type' => $canonicalType,
|
|
'supported' => false,
|
|
'diagnostics' => [
|
|
'unsupported_fields' => [],
|
|
'redacted_fields' => [],
|
|
'volatile_fields' => [],
|
|
'manual_review_fields' => [],
|
|
],
|
|
];
|
|
}
|
|
|
|
$redacted = $this->redactedPayload($payload);
|
|
|
|
return match ($canonicalType) {
|
|
'retentionCompliancePolicy' => $this->normalizeRetentionCompliancePolicy($payload, $redacted),
|
|
'labelPolicy' => $this->normalizeLabelPolicy($payload, $redacted),
|
|
'dlpCompliancePolicy' => $this->normalizeDlpCompliancePolicy($payload, $redacted),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @return list<string>
|
|
*/
|
|
public function volatileRootFields(): array
|
|
{
|
|
return self::VOLATILE_ROOT_FIELDS;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $rawPayload
|
|
* @param array<string, mixed> $payload
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function normalizeRetentionCompliancePolicy(array $rawPayload, array $payload): array
|
|
{
|
|
return $this->sortAssociative([
|
|
'canonical_type' => 'retentionCompliancePolicy',
|
|
'supported' => true,
|
|
'display_name' => $this->firstString($payload, ['DisplayName', 'displayName', 'Name', 'name', 'Identity']),
|
|
'enabled_state' => $this->enabledState($this->firstScalar($payload, ['Enabled', 'enabled', 'State', 'state'])),
|
|
'retention' => [
|
|
'duration' => $this->stringValue($this->firstScalar($payload, ['RetentionDuration', 'retentionDuration', 'Duration', 'duration'])),
|
|
'duration_unit' => $this->firstString($payload, ['RetentionDurationUnit', 'retentionDurationUnit', 'DurationUnit', 'durationUnit']),
|
|
'disposition_action' => $this->firstString($payload, ['DispositionAction', 'dispositionAction', 'RetentionAction', 'retentionAction']),
|
|
],
|
|
'scope' => [
|
|
'included_locations' => $this->listFromFields($payload, ['IncludedLocations', 'includedLocations', 'Locations', 'locations']),
|
|
'excluded_locations' => $this->listFromFields($payload, ['ExcludedLocations', 'excludedLocations']),
|
|
'included_groups' => $this->listFromFields($payload, ['IncludedGroups', 'includedGroups']),
|
|
'excluded_groups' => $this->listFromFields($payload, ['ExcludedGroups', 'excludedGroups']),
|
|
'included_users' => $this->listFromFields($payload, ['IncludedUsers', 'includedUsers']),
|
|
'excluded_users' => $this->listFromFields($payload, ['ExcludedUsers', 'excludedUsers']),
|
|
],
|
|
'diagnostics' => $this->diagnostics('retentionCompliancePolicy', $rawPayload, $payload),
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $rawPayload
|
|
* @param array<string, mixed> $payload
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function normalizeLabelPolicy(array $rawPayload, array $payload): array
|
|
{
|
|
return $this->sortAssociative([
|
|
'canonical_type' => 'labelPolicy',
|
|
'supported' => true,
|
|
'display_name' => $this->firstString($payload, ['DisplayName', 'displayName', 'Name', 'name', 'Identity']),
|
|
'state' => $this->firstString($payload, ['State', 'state']),
|
|
'labeling' => [
|
|
'published_labels' => $this->labelList($this->firstExisting($payload, ['PublishedLabels', 'publishedLabels', 'Labels', 'labels'])),
|
|
'default_label' => $this->labelName($this->firstExisting($payload, ['DefaultLabel', 'defaultLabel'])),
|
|
'mandatory' => $this->booleanString($this->firstScalar($payload, ['Mandatory', 'mandatory'])),
|
|
],
|
|
'scope' => [
|
|
'included_locations' => $this->listFromFields($payload, ['IncludedLocations', 'includedLocations', 'Locations', 'locations']),
|
|
'excluded_locations' => $this->listFromFields($payload, ['ExcludedLocations', 'excludedLocations']),
|
|
'included_groups' => $this->listFromFields($payload, ['IncludedGroups', 'includedGroups']),
|
|
'excluded_groups' => $this->listFromFields($payload, ['ExcludedGroups', 'excludedGroups']),
|
|
'included_users' => $this->listFromFields($payload, ['IncludedUsers', 'includedUsers']),
|
|
'excluded_users' => $this->listFromFields($payload, ['ExcludedUsers', 'excludedUsers']),
|
|
],
|
|
'diagnostics' => $this->diagnostics('labelPolicy', $rawPayload, $payload),
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $rawPayload
|
|
* @param array<string, mixed> $payload
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function normalizeDlpCompliancePolicy(array $rawPayload, array $payload): array
|
|
{
|
|
return $this->sortAssociative([
|
|
'canonical_type' => 'dlpCompliancePolicy',
|
|
'supported' => true,
|
|
'display_name' => $this->firstString($payload, ['DisplayName', 'displayName', 'Name', 'name', 'Identity']),
|
|
'state' => $this->firstString($payload, ['State', 'state']),
|
|
'mode' => $this->firstString($payload, ['Mode', 'mode']),
|
|
'scope' => [
|
|
'locations' => $this->listFromFields($payload, ['Locations', 'locations', 'IncludedLocations', 'includedLocations']),
|
|
'excluded_locations' => $this->listFromFields($payload, ['ExcludedLocations', 'excludedLocations']),
|
|
'workloads' => $this->listFromFields($payload, ['Workloads', 'workloads']),
|
|
],
|
|
'actions' => $this->scalarList($this->firstExisting($payload, ['Actions', 'actions'])),
|
|
'rules' => $this->ruleList($this->firstExisting($payload, ['Rules', 'rules'])),
|
|
'diagnostics' => $this->diagnostics('dlpCompliancePolicy', $rawPayload, $payload),
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $payload
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function redactedPayload(array $payload): array
|
|
{
|
|
$redacted = $this->redactor->redact($payload);
|
|
$redacted = is_array($redacted) ? $redacted : [];
|
|
|
|
return $this->redactUnsafeContent($redacted);
|
|
}
|
|
|
|
private function redactUnsafeContent(mixed $value, string $path = ''): mixed
|
|
{
|
|
if (! is_array($value)) {
|
|
return $value;
|
|
}
|
|
|
|
if (array_is_list($value)) {
|
|
return array_map(fn (mixed $item): mixed => $this->redactUnsafeContent($item, $path), $value);
|
|
}
|
|
|
|
$redacted = [];
|
|
|
|
foreach ($value as $key => $nestedValue) {
|
|
$key = (string) $key;
|
|
$nestedPath = $path === '' ? $key : $path.'.'.$key;
|
|
|
|
$redacted[$key] = $this->isUnsafeContentKey($nestedPath)
|
|
? '[redacted]'
|
|
: $this->redactUnsafeContent($nestedValue, $nestedPath);
|
|
}
|
|
|
|
return $redacted;
|
|
}
|
|
|
|
private function isUnsafeContentKey(string $path): bool
|
|
{
|
|
$normalized = strtolower(str_replace(['_', '-', ' '], '', $path));
|
|
$segments = explode('.', $normalized);
|
|
$last = end($segments);
|
|
|
|
foreach (self::SENSITIVE_CONTENT_KEYS as $sensitiveKey) {
|
|
if ($normalized === $sensitiveKey || $last === $sensitiveKey) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
foreach (self::SENSITIVE_CONTENT_KEY_PARTS as $sensitiveKeyPart) {
|
|
if (str_contains($normalized, $sensitiveKeyPart) || str_contains((string) $last, $sensitiveKeyPart)) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* @return array<string, list<string>>
|
|
*/
|
|
private function diagnostics(string $canonicalType, array $rawPayload, array $payload): array
|
|
{
|
|
$redactedFields = $this->redactedPaths($payload);
|
|
$unsupportedFields = $this->unsupportedRootFields($canonicalType, $payload);
|
|
|
|
return [
|
|
'unsupported_fields' => $unsupportedFields,
|
|
'redacted_fields' => $redactedFields,
|
|
'volatile_fields' => $this->presentVolatileFields($rawPayload),
|
|
'manual_review_fields' => $this->manualReviewFields($unsupportedFields, $redactedFields),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param list<string> $unsupportedFields
|
|
* @return list<string>
|
|
*/
|
|
private function manualReviewFields(array $unsupportedFields, array $redactedFields): array
|
|
{
|
|
$fields = array_values(array_filter(
|
|
$unsupportedFields,
|
|
static fn (string $field): bool => $field !== '' && ! in_array($field, $redactedFields, true),
|
|
));
|
|
|
|
sort($fields, SORT_NATURAL | SORT_FLAG_CASE);
|
|
|
|
return $fields;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $payload
|
|
* @return list<string>
|
|
*/
|
|
private function unsupportedRootFields(string $canonicalType, array $payload): array
|
|
{
|
|
$supported = self::SUPPORTED_ROOT_FIELDS[$canonicalType] ?? [];
|
|
$fields = array_values(array_filter(
|
|
array_map('strval', array_keys($payload)),
|
|
static fn (string $key): bool => ! in_array($key, $supported, true)
|
|
&& ! in_array($key, self::VOLATILE_ROOT_FIELDS, true)
|
|
));
|
|
|
|
foreach ($this->unsupportedNestedFields($canonicalType, $payload) as $field) {
|
|
$fields[] = $field;
|
|
}
|
|
|
|
sort($fields, SORT_NATURAL | SORT_FLAG_CASE);
|
|
|
|
return array_values(array_unique($fields));
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $payload
|
|
* @return list<string>
|
|
*/
|
|
private function unsupportedNestedFields(string $canonicalType, array $payload): array
|
|
{
|
|
$fields = [];
|
|
$supportedNestedFields = self::SUPPORTED_NESTED_FIELDS[$canonicalType] ?? [];
|
|
|
|
foreach ($supportedNestedFields as $rootField => $supportedFields) {
|
|
if (! array_key_exists($rootField, $payload)) {
|
|
continue;
|
|
}
|
|
|
|
foreach ($this->unsupportedNestedFieldsForValue(
|
|
$payload[$rootField],
|
|
$supportedFields,
|
|
$rootField,
|
|
self::SUPPORTED_NESTED_FIELD_CHILDREN[$canonicalType][$rootField] ?? [],
|
|
) as $field) {
|
|
$fields[] = $field;
|
|
}
|
|
}
|
|
|
|
return $fields;
|
|
}
|
|
|
|
/**
|
|
* @param list<string> $supportedFields
|
|
* @param array<string, list<string>> $childSchemas
|
|
* @return list<string>
|
|
*/
|
|
private function unsupportedNestedFieldsForValue(mixed $value, array $supportedFields, string $path, array $childSchemas = []): array
|
|
{
|
|
if (! is_array($value)) {
|
|
return [];
|
|
}
|
|
|
|
$fields = [];
|
|
|
|
if (array_is_list($value)) {
|
|
foreach ($value as $index => $nestedValue) {
|
|
foreach ($this->unsupportedNestedFieldsForValue($nestedValue, $supportedFields, $path.'.'.(string) $index, $childSchemas) as $field) {
|
|
$fields[] = $field;
|
|
}
|
|
}
|
|
|
|
return $fields;
|
|
}
|
|
|
|
foreach ($value as $key => $nestedValue) {
|
|
$key = (string) $key;
|
|
$nestedPath = $path.'.'.$key;
|
|
|
|
if (! in_array($key, $supportedFields, true)) {
|
|
$fields[] = $nestedPath;
|
|
|
|
continue;
|
|
}
|
|
|
|
$childSchema = $childSchemas[$key] ?? null;
|
|
|
|
if ($childSchema === null) {
|
|
if (is_array($nestedValue)) {
|
|
$fields[] = $nestedPath;
|
|
}
|
|
|
|
continue;
|
|
}
|
|
|
|
foreach ($this->unsupportedNestedFieldsForValue($nestedValue, $childSchema, $nestedPath) as $field) {
|
|
$fields[] = $field;
|
|
}
|
|
}
|
|
|
|
return $fields;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $payload
|
|
* @return list<string>
|
|
*/
|
|
private function presentVolatileFields(array $payload): array
|
|
{
|
|
$fields = array_values(array_filter(
|
|
self::VOLATILE_ROOT_FIELDS,
|
|
static fn (string $field): bool => array_key_exists($field, $payload),
|
|
));
|
|
|
|
sort($fields, SORT_NATURAL | SORT_FLAG_CASE);
|
|
|
|
return $fields;
|
|
}
|
|
|
|
/**
|
|
* @return list<string>
|
|
*/
|
|
private function redactedPaths(mixed $value, string $prefix = ''): array
|
|
{
|
|
if ($value === '[redacted]') {
|
|
return [$prefix];
|
|
}
|
|
|
|
if (! is_array($value)) {
|
|
return [];
|
|
}
|
|
|
|
$paths = [];
|
|
|
|
foreach ($value as $key => $nestedValue) {
|
|
$path = $prefix === '' ? (string) $key : $prefix.'.'.(string) $key;
|
|
|
|
foreach ($this->redactedPaths($nestedValue, $path) as $nestedPath) {
|
|
$paths[] = $nestedPath;
|
|
}
|
|
}
|
|
|
|
$paths = array_values(array_unique(array_filter($paths)));
|
|
sort($paths, SORT_NATURAL | SORT_FLAG_CASE);
|
|
|
|
return $paths;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $payload
|
|
* @param list<string> $fields
|
|
* @return list<string>
|
|
*/
|
|
private function listFromFields(array $payload, array $fields): array
|
|
{
|
|
$values = [];
|
|
|
|
foreach ($fields as $field) {
|
|
foreach ($this->scalarList($payload[$field] ?? null) as $value) {
|
|
$values[] = $value;
|
|
}
|
|
}
|
|
|
|
$values = array_values(array_unique($values));
|
|
sort($values, SORT_NATURAL | SORT_FLAG_CASE);
|
|
|
|
return $values;
|
|
}
|
|
|
|
/**
|
|
* @return list<string>
|
|
*/
|
|
private function scalarList(mixed $value): array
|
|
{
|
|
if ($value === null || $value === '') {
|
|
return [];
|
|
}
|
|
|
|
if (! is_array($value)) {
|
|
return is_scalar($value) ? [trim((string) $value)] : [];
|
|
}
|
|
|
|
$values = [];
|
|
|
|
foreach ($value as $item) {
|
|
if (is_array($item)) {
|
|
$label = $this->labelName($item);
|
|
|
|
if ($label !== null) {
|
|
$values[] = $label;
|
|
}
|
|
|
|
continue;
|
|
}
|
|
|
|
if (is_scalar($item) && trim((string) $item) !== '') {
|
|
$values[] = trim((string) $item);
|
|
}
|
|
}
|
|
|
|
$values = array_values(array_unique($values));
|
|
sort($values, SORT_NATURAL | SORT_FLAG_CASE);
|
|
|
|
return $values;
|
|
}
|
|
|
|
/**
|
|
* @return list<string>
|
|
*/
|
|
private function labelList(mixed $value): array
|
|
{
|
|
if ($value === null || $value === '') {
|
|
return [];
|
|
}
|
|
|
|
$items = is_array($value) && array_is_list($value) ? $value : [$value];
|
|
$labels = [];
|
|
|
|
foreach ($items as $item) {
|
|
$label = $this->labelName($item);
|
|
|
|
if ($label !== null) {
|
|
$labels[] = $label;
|
|
}
|
|
}
|
|
|
|
$labels = array_values(array_unique($labels));
|
|
sort($labels, SORT_NATURAL | SORT_FLAG_CASE);
|
|
|
|
return $labels;
|
|
}
|
|
|
|
private function labelName(mixed $value): ?string
|
|
{
|
|
if (is_array($value)) {
|
|
return $this->firstString($value, ['displayName', 'DisplayName', 'name', 'Name']);
|
|
}
|
|
|
|
return $this->stringValue($value);
|
|
}
|
|
|
|
/**
|
|
* @return list<array<string, mixed>>
|
|
*/
|
|
private function ruleList(mixed $value): array
|
|
{
|
|
if (! is_array($value)) {
|
|
return [];
|
|
}
|
|
|
|
$items = array_is_list($value) ? $value : [$value];
|
|
$rules = [];
|
|
|
|
foreach ($items as $item) {
|
|
if (! is_array($item)) {
|
|
continue;
|
|
}
|
|
|
|
$rules[] = array_filter([
|
|
'name' => $this->firstString($item, ['Name', 'name', 'DisplayName', 'displayName']),
|
|
'state' => $this->firstString($item, ['State', 'state', 'Enabled', 'enabled']),
|
|
'severity' => $this->firstString($item, ['Severity', 'severity']),
|
|
'mode' => $this->firstString($item, ['Mode', 'mode']),
|
|
'actions' => $this->scalarList($this->firstExisting($item, ['Actions', 'actions'])),
|
|
], static fn (mixed $nested): bool => $nested !== null && $nested !== [] && $nested !== '');
|
|
}
|
|
|
|
usort($rules, static fn (array $left, array $right): int => strcmp(
|
|
json_encode($left, JSON_THROW_ON_ERROR),
|
|
json_encode($right, JSON_THROW_ON_ERROR),
|
|
));
|
|
|
|
return $rules;
|
|
}
|
|
|
|
private function firstExisting(array $payload, array $fields): mixed
|
|
{
|
|
foreach ($fields as $field) {
|
|
if (array_key_exists($field, $payload)) {
|
|
return $payload[$field];
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private function firstScalar(array $payload, array $fields): mixed
|
|
{
|
|
foreach ($fields as $field) {
|
|
$value = $payload[$field] ?? null;
|
|
|
|
if (is_scalar($value)) {
|
|
return $value;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private function firstString(array $payload, array $fields): ?string
|
|
{
|
|
return $this->stringValue($this->firstScalar($payload, $fields));
|
|
}
|
|
|
|
private 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;
|
|
}
|
|
|
|
private function enabledState(mixed $value): ?string
|
|
{
|
|
if (is_bool($value)) {
|
|
return $value ? 'enabled' : 'disabled';
|
|
}
|
|
|
|
$value = $this->stringValue($value);
|
|
|
|
if ($value === null) {
|
|
return null;
|
|
}
|
|
|
|
return match (strtolower($value)) {
|
|
'1', 'enabled', 'true', 'yes' => 'enabled',
|
|
'0', 'disabled', 'false', 'no' => 'disabled',
|
|
default => $value,
|
|
};
|
|
}
|
|
|
|
private function booleanString(mixed $value): ?string
|
|
{
|
|
if (is_bool($value)) {
|
|
return $value ? 'yes' : 'no';
|
|
}
|
|
|
|
$value = $this->stringValue($value);
|
|
|
|
if ($value === null) {
|
|
return null;
|
|
}
|
|
|
|
return match (strtolower($value)) {
|
|
'1', 'true', 'yes' => 'yes',
|
|
'0', 'false', 'no' => 'no',
|
|
default => $value,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $value
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function sortAssociative(array $value): array
|
|
{
|
|
foreach ($value as $key => $nestedValue) {
|
|
if (is_array($nestedValue)) {
|
|
$value[$key] = array_is_list($nestedValue)
|
|
? $nestedValue
|
|
: $this->sortAssociative($nestedValue);
|
|
}
|
|
}
|
|
|
|
ksort($value, SORT_NATURAL | SORT_FLAG_CASE);
|
|
|
|
return $value;
|
|
}
|
|
}
|