648 lines
23 KiB
PHP
648 lines
23 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Services\TenantConfiguration;
|
|
|
|
use App\Models\ManagedEnvironment;
|
|
use App\Models\ProviderConnection;
|
|
use App\Models\TenantConfigurationResource;
|
|
use App\Models\TenantConfigurationResourceEvidence;
|
|
use App\Models\TenantConfigurationResourceType;
|
|
use App\Models\TenantConfigurationSupportedScope;
|
|
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 Carbon\CarbonInterface;
|
|
use InvalidArgumentException;
|
|
use Throwable;
|
|
|
|
final class EntraCertifiedComparePackEvaluator
|
|
{
|
|
public const SCOPE_KEY = 'entra_core_compare_certified';
|
|
|
|
public const CLAIM_LABEL = 'Certified Entra Core Compare Pack: Conditional Access and Security Defaults';
|
|
|
|
/**
|
|
* @var list<string>
|
|
*/
|
|
public const DENOMINATOR = [
|
|
'conditionalAccessPolicy',
|
|
'securityDefaults',
|
|
];
|
|
|
|
/**
|
|
* @var array<string, string>
|
|
*/
|
|
private const EXPECTED_CONTRACT_KEYS = [
|
|
'conditionalAccessPolicy' => 'conditionalAccessPolicy',
|
|
'securityDefaults' => 'securityDefaults',
|
|
];
|
|
|
|
/**
|
|
* @var list<string>
|
|
*/
|
|
private const BLOCKER_PRIORITY = [
|
|
EntraCertifiedComparePackResult::BLOCKED_MISSING_EVIDENCE,
|
|
EntraCertifiedComparePackResult::BLOCKED_IDENTITY,
|
|
EntraCertifiedComparePackResult::BLOCKED_COMPARE,
|
|
EntraCertifiedComparePackResult::BLOCKED_RENDER,
|
|
EntraCertifiedComparePackResult::BLOCKED_REDACTION,
|
|
EntraCertifiedComparePackResult::BLOCKED_CLAIM_GUARD,
|
|
];
|
|
|
|
/**
|
|
* @var list<string>
|
|
*/
|
|
private const SENSITIVE_KEY_PARTS = [
|
|
'access_token',
|
|
'authorization',
|
|
'bearer',
|
|
'certificate',
|
|
'client_secret',
|
|
'cookie',
|
|
'credential',
|
|
'id_token',
|
|
'password',
|
|
'private_key',
|
|
'refresh_token',
|
|
'secret',
|
|
'set-cookie',
|
|
'token',
|
|
];
|
|
|
|
public function __construct(
|
|
private readonly SupportedScopeResolver $supportedScopes,
|
|
private readonly EntraCoverageComparator $comparator,
|
|
private readonly EntraRenderableSummaryBuilder $summaryBuilder,
|
|
private readonly CoveragePayloadRedactor $redactor,
|
|
private readonly ClaimGuard $claimGuard,
|
|
) {}
|
|
|
|
public function evaluate(
|
|
ManagedEnvironment $environment,
|
|
ProviderConnection $providerConnection,
|
|
): EntraCertifiedComparePackResult {
|
|
$this->assertSameScope($environment, $providerConnection);
|
|
|
|
$scope = $this->supportedScopes->findActive(self::SCOPE_KEY);
|
|
|
|
if (! $scope instanceof TenantConfigurationSupportedScope) {
|
|
return new EntraCertifiedComparePackResult(
|
|
scopeKey: self::SCOPE_KEY,
|
|
denominator: self::DENOMINATOR,
|
|
state: EntraCertifiedComparePackResult::NOT_EVALUATED,
|
|
blockers: ['supported_scope_missing'],
|
|
);
|
|
}
|
|
|
|
$scopeBlockers = $this->scopeBlockers($scope);
|
|
|
|
if ($scopeBlockers !== []) {
|
|
return new EntraCertifiedComparePackResult(
|
|
scopeKey: self::SCOPE_KEY,
|
|
denominator: self::DENOMINATOR,
|
|
state: EntraCertifiedComparePackResult::BLOCKED_MISSING_EVIDENCE,
|
|
blockers: $scopeBlockers,
|
|
);
|
|
}
|
|
|
|
$resourceResults = [];
|
|
$blockers = [];
|
|
|
|
foreach (self::DENOMINATOR as $canonicalType) {
|
|
$typeResult = $this->evaluateCanonicalType($canonicalType, $environment, $providerConnection);
|
|
$resourceResults[] = $typeResult;
|
|
$blockers = [...$blockers, ...$typeResult['blockers']];
|
|
}
|
|
|
|
$blockers = $this->uniqueStrings($blockers);
|
|
$claimState = null;
|
|
|
|
if ($blockers === []) {
|
|
$claimState = $this->claimGuard->evaluateCertifiedComparePackStatement(
|
|
claim: self::CLAIM_LABEL,
|
|
packPassed: true,
|
|
internalOperatorOnly: true,
|
|
);
|
|
|
|
if ($claimState !== ClaimState::InternalOnly) {
|
|
$blockers[] = EntraCertifiedComparePackResult::BLOCKED_CLAIM_GUARD;
|
|
}
|
|
}
|
|
|
|
return new EntraCertifiedComparePackResult(
|
|
scopeKey: self::SCOPE_KEY,
|
|
denominator: self::DENOMINATOR,
|
|
state: $this->overallState($blockers),
|
|
resourceResults: $resourceResults,
|
|
blockers: $this->uniqueStrings($blockers),
|
|
claimState: $claimState,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function evaluateCanonicalType(
|
|
string $canonicalType,
|
|
ManagedEnvironment $environment,
|
|
ProviderConnection $providerConnection,
|
|
): array {
|
|
$resourceType = TenantConfigurationResourceType::query()
|
|
->active()
|
|
->where('canonical_type', $canonicalType)
|
|
->orderBy('source_class')
|
|
->first();
|
|
|
|
if (! $resourceType instanceof TenantConfigurationResourceType) {
|
|
return $this->blockedTypeResult($canonicalType, ['resource_type_missing']);
|
|
}
|
|
|
|
$resources = TenantConfigurationResource::query()
|
|
->where('workspace_id', (int) $environment->workspace_id)
|
|
->where('managed_environment_id', (int) $environment->getKey())
|
|
->where('provider_connection_id', (int) $providerConnection->getKey())
|
|
->where('resource_type_id', (int) $resourceType->getKey())
|
|
->where('canonical_type', $canonicalType)
|
|
->with([
|
|
'latestEvidence.operationRun:id,workspace_id,managed_environment_id',
|
|
])
|
|
->orderBy('canonical_resource_key')
|
|
->get();
|
|
|
|
if ($resources->isEmpty()) {
|
|
return $this->blockedTypeResult($canonicalType, ['current_same_scope_resource_missing']);
|
|
}
|
|
|
|
$resourceResults = $resources
|
|
->map(fn (TenantConfigurationResource $resource): array => $this->evaluateResource($resource, $canonicalType))
|
|
->values();
|
|
|
|
$blockers = $this->uniqueStrings($resourceResults
|
|
->flatMap(fn (array $result): array => $result['blockers'])
|
|
->all());
|
|
|
|
return [
|
|
'canonical_type' => $canonicalType,
|
|
'resource_count' => $resources->count(),
|
|
'criteria' => [
|
|
'evidence' => $resourceResults->every(fn (array $result): bool => (bool) $result['criteria']['evidence']),
|
|
'identity' => $resourceResults->every(fn (array $result): bool => (bool) $result['criteria']['identity']),
|
|
'compare' => $resourceResults->every(fn (array $result): bool => (bool) $result['criteria']['compare']),
|
|
'render' => $resourceResults->every(fn (array $result): bool => (bool) $result['criteria']['render']),
|
|
'redaction' => $resourceResults->every(fn (array $result): bool => (bool) $result['criteria']['redaction']),
|
|
],
|
|
'certified' => $blockers === [],
|
|
'blockers' => $blockers,
|
|
'resources' => $resourceResults->all(),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function evaluateResource(TenantConfigurationResource $resource, string $canonicalType): array
|
|
{
|
|
$evidence = $resource->latestEvidence;
|
|
$blockers = [];
|
|
$criteria = [
|
|
'evidence' => false,
|
|
'identity' => false,
|
|
'compare' => false,
|
|
'render' => false,
|
|
'redaction' => false,
|
|
];
|
|
$reasons = [];
|
|
|
|
[$criteria['evidence'], $evidenceReasons] = $this->evidencePasses($resource, $evidence, $canonicalType);
|
|
$reasons = [...$reasons, ...$evidenceReasons];
|
|
|
|
if (! $criteria['evidence']) {
|
|
$blockers[] = EntraCertifiedComparePackResult::BLOCKED_MISSING_EVIDENCE;
|
|
}
|
|
|
|
[$criteria['identity'], $identityReasons] = $this->identityPasses($resource);
|
|
$reasons = [...$reasons, ...$identityReasons];
|
|
|
|
if (! $criteria['identity']) {
|
|
$blockers[] = EntraCertifiedComparePackResult::BLOCKED_IDENTITY;
|
|
}
|
|
|
|
$renderSummary = null;
|
|
|
|
if ($evidence instanceof TenantConfigurationResourceEvidence && is_array($evidence->normalized_payload)) {
|
|
[$criteria['compare'], $compareReasons] = $this->comparePasses($canonicalType, $evidence);
|
|
[$criteria['render'], $renderSummary, $renderReasons] = $this->renderPasses($resource, $canonicalType, $evidence);
|
|
[$criteria['redaction'], $redactionReasons] = $this->redactionPasses($evidence, $renderSummary);
|
|
$reasons = [...$reasons, ...$compareReasons, ...$renderReasons, ...$redactionReasons];
|
|
}
|
|
|
|
if (! $criteria['compare']) {
|
|
$blockers[] = EntraCertifiedComparePackResult::BLOCKED_COMPARE;
|
|
}
|
|
|
|
if (! $criteria['render']) {
|
|
$blockers[] = EntraCertifiedComparePackResult::BLOCKED_RENDER;
|
|
}
|
|
|
|
if (! $criteria['redaction']) {
|
|
$blockers[] = EntraCertifiedComparePackResult::BLOCKED_REDACTION;
|
|
}
|
|
|
|
return [
|
|
'resource_id' => (int) $resource->getKey(),
|
|
'canonical_resource_key' => (string) $resource->canonical_resource_key,
|
|
'criteria' => $criteria,
|
|
'certified' => $blockers === [],
|
|
'blockers' => $this->uniqueStrings($blockers),
|
|
'reasons' => $this->uniqueStrings($reasons),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return array{0: bool, 1: list<string>}
|
|
*/
|
|
private function evidencePasses(
|
|
TenantConfigurationResource $resource,
|
|
mixed $evidence,
|
|
string $canonicalType,
|
|
): array {
|
|
$reasons = [];
|
|
|
|
if (! $evidence instanceof TenantConfigurationResourceEvidence) {
|
|
return [false, ['latest_evidence_missing']];
|
|
}
|
|
|
|
if ((int) $resource->latest_evidence_id !== (int) $evidence->getKey()) {
|
|
$reasons[] = 'latest_evidence_pointer_mismatch';
|
|
}
|
|
|
|
foreach ([
|
|
'resource_id' => (int) $resource->getKey(),
|
|
'workspace_id' => (int) $resource->workspace_id,
|
|
'managed_environment_id' => (int) $resource->managed_environment_id,
|
|
'provider_connection_id' => (int) $resource->provider_connection_id,
|
|
'resource_type_id' => (int) $resource->resource_type_id,
|
|
] as $column => $expected) {
|
|
if ((int) $evidence->{$column} !== $expected) {
|
|
$reasons[] = $column.'_scope_mismatch';
|
|
}
|
|
}
|
|
|
|
if ($evidence->evidence_state !== EvidenceState::ContentBacked) {
|
|
$reasons[] = 'evidence_not_content_backed';
|
|
}
|
|
|
|
if ($evidence->capture_outcome !== CaptureOutcome::Captured) {
|
|
$reasons[] = 'evidence_not_captured';
|
|
}
|
|
|
|
if (! is_array($evidence->raw_payload) || $evidence->raw_payload === []) {
|
|
$reasons[] = 'raw_payload_missing';
|
|
}
|
|
|
|
if (! is_array($evidence->normalized_payload) || $evidence->normalized_payload === []) {
|
|
$reasons[] = 'normalized_payload_missing';
|
|
}
|
|
|
|
if (! is_string($evidence->payload_hash) || ! preg_match('/^[a-f0-9]{64}$/', $evidence->payload_hash)) {
|
|
$reasons[] = 'payload_hash_invalid';
|
|
}
|
|
|
|
if ((string) $resource->latest_payload_hash !== (string) $evidence->payload_hash) {
|
|
$reasons[] = 'latest_payload_hash_mismatch';
|
|
}
|
|
|
|
if ((string) $evidence->source_contract_key !== self::EXPECTED_CONTRACT_KEYS[$canonicalType]) {
|
|
$reasons[] = 'source_contract_mismatch';
|
|
}
|
|
|
|
foreach (['source_endpoint', 'source_version'] as $field) {
|
|
if (! is_string($evidence->{$field}) || trim($evidence->{$field}) === '') {
|
|
$reasons[] = $field.'_missing';
|
|
}
|
|
}
|
|
|
|
if (! $evidence->captured_at instanceof CarbonInterface) {
|
|
$reasons[] = 'captured_at_missing';
|
|
}
|
|
|
|
if ((int) $evidence->operation_run_id <= 0 || ! $evidence->operationRun) {
|
|
$reasons[] = 'operation_run_link_missing';
|
|
} elseif ((int) $evidence->operationRun->workspace_id !== (int) $resource->workspace_id
|
|
|| (int) $evidence->operationRun->managed_environment_id !== (int) $resource->managed_environment_id
|
|
) {
|
|
$reasons[] = 'operation_run_scope_mismatch';
|
|
}
|
|
|
|
if ($this->hasNewerEvidence($resource, $evidence)) {
|
|
$reasons[] = 'latest_evidence_not_current';
|
|
}
|
|
|
|
return [$reasons === [], $reasons];
|
|
}
|
|
|
|
/**
|
|
* @return array{0: bool, 1: list<string>}
|
|
*/
|
|
private function identityPasses(TenantConfigurationResource $resource): array
|
|
{
|
|
return $resource->latest_identity_state === IdentityState::Stable
|
|
? [true, []]
|
|
: [false, ['identity_state_'.$this->stateValue($resource->latest_identity_state)]];
|
|
}
|
|
|
|
/**
|
|
* @return array{0: bool, 1: list<string>}
|
|
*/
|
|
private function comparePasses(string $canonicalType, TenantConfigurationResourceEvidence $evidence): array
|
|
{
|
|
$normalizedPayload = is_array($evidence->normalized_payload) ? $evidence->normalized_payload : [];
|
|
$result = $this->comparator->compare($canonicalType, $normalizedPayload, $normalizedPayload);
|
|
$changes = collect(is_array($result['changes'] ?? null) ? $result['changes'] : []);
|
|
$unsupportedFields = data_get($normalizedPayload, 'diagnostics.unsupported_fields', []);
|
|
$reasons = [];
|
|
|
|
if (($result['supported'] ?? false) !== true) {
|
|
$reasons[] = 'compare_not_supported';
|
|
}
|
|
|
|
if (($result['classification'] ?? null) !== 'unchanged' || ($result['changed'] ?? true) !== false) {
|
|
$reasons[] = 'compare_not_deterministic';
|
|
}
|
|
|
|
if ($changes->contains(fn (mixed $change): bool => is_array($change) && ($change['classification'] ?? null) === 'unsupported_field')
|
|
|| (is_array($unsupportedFields) && $unsupportedFields !== [])
|
|
) {
|
|
$reasons[] = 'unsupported_fields_present';
|
|
}
|
|
|
|
return [$reasons === [], $reasons];
|
|
}
|
|
|
|
/**
|
|
* @return array{0: bool, 1: array<string, mixed>|null, 2: list<string>}
|
|
*/
|
|
private function renderPasses(
|
|
TenantConfigurationResource $resource,
|
|
string $canonicalType,
|
|
TenantConfigurationResourceEvidence $evidence,
|
|
): array {
|
|
$reasons = [];
|
|
$coverageLevel = $this->coverageLevel($evidence->coverage_level);
|
|
|
|
if (! $coverageLevel?->meets(CoverageLevel::Renderable)) {
|
|
$reasons[] = 'coverage_not_renderable';
|
|
}
|
|
|
|
$summary = $this->summaryBuilder->build($canonicalType, $evidence->normalized_payload, [
|
|
'claim_state' => $resource->latest_claim_state,
|
|
'identity_state' => $resource->latest_identity_state,
|
|
'evidence_state' => $resource->latest_evidence_state,
|
|
'coverage_level' => $evidence->coverage_level,
|
|
'last_captured' => $resource->latest_captured_at?->toDayDateTimeString(),
|
|
'source_version' => $evidence->source_version,
|
|
'source_schema_hash' => $evidence->source_schema_hash,
|
|
]);
|
|
|
|
if (! is_array($summary) || $summary === []) {
|
|
$reasons[] = 'render_summary_missing';
|
|
}
|
|
|
|
return [$reasons === [], $summary, $reasons];
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed>|null $summary
|
|
* @return array{0: bool, 1: list<string>}
|
|
*/
|
|
private function redactionPasses(TenantConfigurationResourceEvidence $evidence, ?array $summary): array
|
|
{
|
|
$reasons = [];
|
|
$redactedRaw = $this->redactor->redact($evidence->raw_payload);
|
|
$sensitiveValues = $this->sensitiveValues($evidence->raw_payload, $redactedRaw);
|
|
$safeOutput = [
|
|
'normalized_payload' => $evidence->normalized_payload,
|
|
'render_summary' => $summary,
|
|
'claim_label' => self::CLAIM_LABEL,
|
|
];
|
|
$encoded = json_encode($safeOutput, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
|
|
$lowerEncoded = strtolower($encoded);
|
|
|
|
foreach (['raw_payload', 'raw graph response', 'raw_graph_response', 'permission_context'] as $forbiddenToken) {
|
|
if (str_contains($lowerEncoded, $forbiddenToken)) {
|
|
$reasons[] = 'forbidden_output_token_'.$forbiddenToken;
|
|
}
|
|
}
|
|
|
|
foreach ($sensitiveValues as $value) {
|
|
if ($value !== '' && str_contains($encoded, $value)) {
|
|
$reasons[] = 'sensitive_value_leaked';
|
|
|
|
break;
|
|
}
|
|
}
|
|
|
|
return [$reasons === [], $reasons];
|
|
}
|
|
|
|
/**
|
|
* @return list<string>
|
|
*/
|
|
private function scopeBlockers(TenantConfigurationSupportedScope $scope): array
|
|
{
|
|
$metadata = is_array($scope->metadata) ? $scope->metadata : [];
|
|
$reasons = [];
|
|
|
|
try {
|
|
$resolved = $this->supportedScopes->resolveDefinition(
|
|
$scope,
|
|
TenantConfigurationResourceType::query()
|
|
->active()
|
|
->get(['canonical_type', 'source_class']),
|
|
);
|
|
} catch (Throwable) {
|
|
return ['supported_scope_unresolvable'];
|
|
}
|
|
|
|
if ($resolved['included_resource_types'] !== self::DENOMINATOR) {
|
|
$reasons[] = 'supported_scope_denominator_mismatch';
|
|
}
|
|
|
|
if ($resolved['minimum_coverage_level'] !== CoverageLevel::Certified) {
|
|
$reasons[] = 'supported_scope_minimum_not_certified';
|
|
}
|
|
|
|
if ($resolved['allow_beta'] !== false) {
|
|
$reasons[] = 'supported_scope_beta_allowed';
|
|
}
|
|
|
|
if ($resolved['customer_claims_allowed'] !== false) {
|
|
$reasons[] = 'supported_scope_customer_claims_allowed';
|
|
}
|
|
|
|
if (($metadata['graph_fallback_allowlist'] ?? null) !== ['securityDefaults']) {
|
|
$reasons[] = 'supported_scope_graph_fallback_allowlist_mismatch';
|
|
}
|
|
|
|
if (($metadata['resource_type_denominator'] ?? null) !== self::DENOMINATOR) {
|
|
$reasons[] = 'supported_scope_metadata_denominator_mismatch';
|
|
}
|
|
|
|
return $reasons;
|
|
}
|
|
|
|
/**
|
|
* @param list<string> $reasons
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function blockedTypeResult(string $canonicalType, array $reasons): array
|
|
{
|
|
return [
|
|
'canonical_type' => $canonicalType,
|
|
'resource_count' => 0,
|
|
'criteria' => [
|
|
'evidence' => false,
|
|
'identity' => false,
|
|
'compare' => false,
|
|
'render' => false,
|
|
'redaction' => false,
|
|
],
|
|
'certified' => false,
|
|
'blockers' => [EntraCertifiedComparePackResult::BLOCKED_MISSING_EVIDENCE],
|
|
'resources' => [],
|
|
'reasons' => $reasons,
|
|
];
|
|
}
|
|
|
|
private function assertSameScope(ManagedEnvironment $environment, ProviderConnection $providerConnection): void
|
|
{
|
|
if ((int) $providerConnection->workspace_id !== (int) $environment->workspace_id
|
|
|| (int) $providerConnection->managed_environment_id !== (int) $environment->getKey()
|
|
) {
|
|
throw new InvalidArgumentException('Provider connection scope mismatch while evaluating Entra certified compare pack.');
|
|
}
|
|
}
|
|
|
|
private function hasNewerEvidence(
|
|
TenantConfigurationResource $resource,
|
|
TenantConfigurationResourceEvidence $evidence,
|
|
): bool {
|
|
if (! $evidence->captured_at instanceof CarbonInterface) {
|
|
return true;
|
|
}
|
|
|
|
return TenantConfigurationResourceEvidence::query()
|
|
->where('resource_id', (int) $resource->getKey())
|
|
->where('workspace_id', (int) $resource->workspace_id)
|
|
->where('managed_environment_id', (int) $resource->managed_environment_id)
|
|
->where('provider_connection_id', (int) $resource->provider_connection_id)
|
|
->where('resource_type_id', (int) $resource->resource_type_id)
|
|
->where('id', '<>', (int) $evidence->getKey())
|
|
->where('evidence_state', EvidenceState::ContentBacked->value)
|
|
->where('capture_outcome', CaptureOutcome::Captured->value)
|
|
->where(function ($query) use ($evidence): void {
|
|
$query->where('captured_at', '>', $evidence->captured_at)
|
|
->orWhere(function ($query) use ($evidence): void {
|
|
$query->where('captured_at', '=', $evidence->captured_at)
|
|
->where('id', '>', (int) $evidence->getKey());
|
|
});
|
|
})
|
|
->exists();
|
|
}
|
|
|
|
private function overallState(array $blockers): string
|
|
{
|
|
$blockers = $this->uniqueStrings($blockers);
|
|
|
|
if ($blockers === []) {
|
|
return EntraCertifiedComparePackResult::PASSED;
|
|
}
|
|
|
|
foreach (self::BLOCKER_PRIORITY as $blocker) {
|
|
if (in_array($blocker, $blockers, true)) {
|
|
return $blocker;
|
|
}
|
|
}
|
|
|
|
return EntraCertifiedComparePackResult::NOT_EVALUATED;
|
|
}
|
|
|
|
private function coverageLevel(mixed $value): ?CoverageLevel
|
|
{
|
|
if ($value instanceof CoverageLevel) {
|
|
return $value;
|
|
}
|
|
|
|
return is_string($value) ? CoverageLevel::tryFrom($value) : null;
|
|
}
|
|
|
|
/**
|
|
* @return list<string>
|
|
*/
|
|
private function uniqueStrings(array $values): array
|
|
{
|
|
return array_values(array_unique(array_filter(
|
|
array_map(static fn (mixed $value): string => is_scalar($value) ? trim((string) $value) : '', $values),
|
|
static fn (string $value): bool => $value !== '',
|
|
)));
|
|
}
|
|
|
|
private function stateValue(mixed $state): string
|
|
{
|
|
return $state instanceof \BackedEnum ? (string) $state->value : (string) $state;
|
|
}
|
|
|
|
/**
|
|
* @return list<string>
|
|
*/
|
|
private function sensitiveValues(mixed $raw, mixed $redacted): array
|
|
{
|
|
$values = [];
|
|
|
|
if (! is_array($raw)) {
|
|
return [];
|
|
}
|
|
|
|
foreach ($raw as $key => $value) {
|
|
$key = (string) $key;
|
|
$redactedValue = is_array($redacted) ? ($redacted[$key] ?? null) : null;
|
|
|
|
if ($redactedValue === '[redacted]' && is_scalar($value)) {
|
|
$values[] = (string) $value;
|
|
|
|
continue;
|
|
}
|
|
|
|
if ($this->isSensitiveKey($key) && is_scalar($value)) {
|
|
$values[] = (string) $value;
|
|
}
|
|
|
|
if (is_array($value)) {
|
|
$values = [...$values, ...$this->sensitiveValues($value, $redactedValue)];
|
|
}
|
|
}
|
|
|
|
return $this->uniqueStrings($values);
|
|
}
|
|
|
|
private function isSensitiveKey(string $key): bool
|
|
{
|
|
$normalized = strtolower($key);
|
|
$compact = str_replace(['_', '-', ' '], '', $normalized);
|
|
|
|
foreach (self::SENSITIVE_KEY_PARTS as $part) {
|
|
$compactPart = str_replace(['_', '-', ' '], '', $part);
|
|
|
|
if (str_contains($normalized, $part) || str_contains($compact, $compactPart)) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
}
|