Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 4m7s
Implemented deterministic Baseline Result Semantics (Spec 383), introducing CompareSubjectResult and CompareEvidenceResult. Replaced generic arrays with strict Data Transfer Objects for Baseline engine output.
386 lines
15 KiB
PHP
386 lines
15 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Tests\Feature\Baselines\Support;
|
|
|
|
use App\Models\ManagedEnvironment;
|
|
use App\Services\Baselines\Evidence\EvidenceProvenance;
|
|
use App\Services\Baselines\Evidence\ResolvedEvidence;
|
|
use App\Support\Baselines\Compare\CompareFindingCandidate;
|
|
use App\Support\Baselines\Compare\CompareOrchestrationContext;
|
|
use App\Support\Baselines\Compare\CompareState;
|
|
use App\Support\Baselines\Compare\CompareStrategy;
|
|
use App\Support\Baselines\Compare\CompareStrategyCapability;
|
|
use App\Support\Baselines\Compare\CompareStrategyKey;
|
|
use App\Support\Baselines\Compare\CompareSubjectIdentity;
|
|
use App\Support\Baselines\Compare\CompareSubjectProjection;
|
|
use App\Support\Baselines\Compare\CompareSubjectResult;
|
|
use App\Support\Baselines\OperatorActionCategory;
|
|
use App\Support\Baselines\ResolutionOutcome;
|
|
use App\Support\Baselines\ResolutionPath;
|
|
use App\Support\Baselines\SubjectClass;
|
|
use App\Support\Governance\GovernanceDomainKey;
|
|
use App\Support\Governance\GovernanceSubjectClass;
|
|
use App\Support\Governance\GovernanceSubjectTaxonomyRegistry;
|
|
use App\Support\Governance\GovernanceSubjectType;
|
|
|
|
final class FakeCompareStrategy implements CompareStrategy
|
|
{
|
|
public function key(): CompareStrategyKey
|
|
{
|
|
return CompareStrategyKey::from('future_control');
|
|
}
|
|
|
|
public function capabilities(): array
|
|
{
|
|
return [
|
|
new CompareStrategyCapability(
|
|
strategyKey: $this->key(),
|
|
domainKeys: [GovernanceDomainKey::Entra->value],
|
|
subjectClasses: [GovernanceSubjectClass::Control->value],
|
|
subjectTypeKeys: ['conditionalAccessPolicy'],
|
|
),
|
|
];
|
|
}
|
|
|
|
public function compare(
|
|
CompareOrchestrationContext $context,
|
|
ManagedEnvironment $tenant,
|
|
array $baselineItems,
|
|
array $currentItems,
|
|
array $resolvedCurrentEvidence,
|
|
array $severityMapping,
|
|
): array {
|
|
$subjectResults = [];
|
|
|
|
foreach ($baselineItems as $key => $baselineItem) {
|
|
$currentItem = $currentItems[$key] ?? null;
|
|
|
|
if (! is_array($currentItem)) {
|
|
$subjectResults[] = $this->driftResult(
|
|
context: $context,
|
|
baselineItem: $baselineItem,
|
|
currentItem: null,
|
|
currentEvidence: null,
|
|
changeType: 'missing_policy',
|
|
severity: 'high',
|
|
);
|
|
|
|
continue;
|
|
}
|
|
|
|
$currentEvidence = $resolvedCurrentEvidence[$key] ?? null;
|
|
|
|
if (! $currentEvidence instanceof ResolvedEvidence) {
|
|
$subjectResults[] = $this->gapResult(
|
|
policyType: (string) $baselineItem['policy_type'],
|
|
subjectKey: (string) $baselineItem['subject_key'],
|
|
externalSubjectId: (string) $baselineItem['subject_external_id'],
|
|
operatorLabel: (string) (($currentItem['meta_jsonb']['display_name'] ?? $baselineItem['meta_jsonb']['display_name'] ?? $baselineItem['subject_key']) ?: $baselineItem['subject_key']),
|
|
compareState: CompareState::Incomplete,
|
|
reasonCode: 'missing_local_evidence',
|
|
baselineAvailability: 'available',
|
|
currentStateAvailability: 'unknown',
|
|
trustLevel: 'unusable',
|
|
evidenceQuality: 'missing',
|
|
);
|
|
|
|
continue;
|
|
}
|
|
|
|
$baselineMeta = is_array($baselineItem['meta_jsonb'] ?? null) ? $baselineItem['meta_jsonb'] : [];
|
|
$currentMeta = is_array($currentItem['meta_jsonb'] ?? null) ? $currentItem['meta_jsonb'] : [];
|
|
|
|
if ($this->metaFingerprint($baselineMeta) !== $this->metaFingerprint($currentMeta)) {
|
|
$subjectResults[] = $this->driftResult(
|
|
context: $context,
|
|
baselineItem: $baselineItem,
|
|
currentItem: $currentItem,
|
|
currentEvidence: $currentEvidence,
|
|
changeType: 'different_version',
|
|
severity: 'medium',
|
|
);
|
|
|
|
continue;
|
|
}
|
|
|
|
$subjectResults[] = new CompareSubjectResult(
|
|
subjectIdentity: $this->identity(
|
|
policyType: (string) $baselineItem['policy_type'],
|
|
externalSubjectId: (string) $baselineItem['subject_external_id'],
|
|
subjectKey: (string) $baselineItem['subject_key'],
|
|
),
|
|
projection: $this->projection(
|
|
policyType: (string) $baselineItem['policy_type'],
|
|
operatorLabel: (string) (($currentItem['meta_jsonb']['display_name'] ?? $baselineItem['meta_jsonb']['display_name'] ?? $baselineItem['subject_key']) ?: $baselineItem['subject_key']),
|
|
),
|
|
baselineAvailability: 'available',
|
|
currentStateAvailability: 'available',
|
|
compareState: CompareState::NoDrift,
|
|
trustLevel: 'trustworthy',
|
|
evidenceQuality: $currentEvidence->fidelity,
|
|
);
|
|
}
|
|
|
|
foreach ($currentItems as $key => $currentItem) {
|
|
if (array_key_exists($key, $baselineItems)) {
|
|
continue;
|
|
}
|
|
|
|
$currentEvidence = $resolvedCurrentEvidence[$key] ?? null;
|
|
|
|
if (! $currentEvidence instanceof ResolvedEvidence) {
|
|
$subjectResults[] = $this->gapResult(
|
|
policyType: (string) $currentItem['policy_type'],
|
|
subjectKey: (string) $currentItem['subject_key'],
|
|
externalSubjectId: (string) $currentItem['subject_external_id'],
|
|
operatorLabel: (string) (($currentItem['meta_jsonb']['display_name'] ?? $currentItem['subject_key']) ?: $currentItem['subject_key']),
|
|
compareState: CompareState::Incomplete,
|
|
reasonCode: 'missing_local_evidence',
|
|
baselineAvailability: 'missing',
|
|
currentStateAvailability: 'unknown',
|
|
trustLevel: 'unusable',
|
|
evidenceQuality: 'missing',
|
|
);
|
|
|
|
continue;
|
|
}
|
|
|
|
$subjectResults[] = $this->driftResult(
|
|
context: $context,
|
|
baselineItem: null,
|
|
currentItem: $currentItem,
|
|
currentEvidence: $currentEvidence,
|
|
changeType: 'unexpected_policy',
|
|
severity: 'low',
|
|
);
|
|
}
|
|
|
|
return [
|
|
'subject_results' => $subjectResults,
|
|
'diagnostics' => [
|
|
'strategy_family' => 'future_control',
|
|
'state_counts' => [
|
|
'drift' => count(array_filter($subjectResults, static fn (CompareSubjectResult $result): bool => $result->compareState === CompareState::Drift)),
|
|
],
|
|
],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $meta
|
|
*/
|
|
private function metaFingerprint(array $meta): string
|
|
{
|
|
unset($meta['display_name'], $meta['category'], $meta['platform']);
|
|
|
|
return hash('sha256', json_encode($this->sortRecursive($meta), JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $value
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function sortRecursive(array $value): array
|
|
{
|
|
foreach ($value as $key => $nestedValue) {
|
|
if (! is_array($nestedValue)) {
|
|
continue;
|
|
}
|
|
|
|
$value[$key] = $this->sortRecursive($nestedValue);
|
|
}
|
|
|
|
ksort($value, SORT_STRING);
|
|
|
|
return $value;
|
|
}
|
|
|
|
private function driftResult(
|
|
CompareOrchestrationContext $context,
|
|
?array $baselineItem,
|
|
?array $currentItem,
|
|
?ResolvedEvidence $currentEvidence,
|
|
string $changeType,
|
|
string $severity,
|
|
): CompareSubjectResult {
|
|
$source = $baselineItem ?? $currentItem ?? [];
|
|
$policyType = (string) ($source['policy_type'] ?? 'conditionalAccessPolicy');
|
|
$subjectKey = (string) ($source['subject_key'] ?? 'unknown');
|
|
$externalSubjectId = (string) ($source['subject_external_id'] ?? 'unknown');
|
|
$operatorLabel = (string) ((($currentItem['meta_jsonb']['display_name'] ?? null) ?: ($baselineItem['meta_jsonb']['display_name'] ?? null) ?: $subjectKey) ?: $subjectKey);
|
|
$fidelity = $currentEvidence?->fidelity ?? EvidenceProvenance::FidelityMeta;
|
|
$baselineProvenance = EvidenceProvenance::build(
|
|
fidelity: EvidenceProvenance::FidelityMeta,
|
|
source: EvidenceProvenance::SourceInventory,
|
|
observedAt: null,
|
|
observedOperationRunId: null,
|
|
);
|
|
$currentProvenance = $currentEvidence?->tenantProvenance() ?? EvidenceProvenance::build(
|
|
fidelity: $fidelity,
|
|
source: EvidenceProvenance::SourceInventory,
|
|
observedAt: null,
|
|
observedOperationRunId: $context->inventorySyncRunId(),
|
|
);
|
|
|
|
return new CompareSubjectResult(
|
|
subjectIdentity: $this->identity($policyType, $externalSubjectId, $subjectKey),
|
|
projection: $this->projection($policyType, $operatorLabel),
|
|
baselineAvailability: $baselineItem === null ? 'missing' : 'available',
|
|
currentStateAvailability: $currentItem === null ? 'missing' : 'available',
|
|
compareState: CompareState::Drift,
|
|
trustLevel: $fidelity === EvidenceProvenance::FidelityContent ? 'trustworthy' : 'limited_confidence',
|
|
evidenceQuality: $fidelity,
|
|
severityRecommendation: $severity,
|
|
findingCandidate: new CompareFindingCandidate(
|
|
changeType: $changeType,
|
|
severity: $severity,
|
|
fingerprintBasis: [
|
|
'policy_type' => $policyType,
|
|
'subject_key' => $subjectKey,
|
|
'change_type' => $changeType,
|
|
],
|
|
evidencePayload: [
|
|
'change_type' => $changeType,
|
|
'policy_type' => $policyType,
|
|
'subject_key' => $subjectKey,
|
|
'display_name' => $operatorLabel,
|
|
'summary' => ['kind' => 'control_snapshot'],
|
|
'baseline' => [
|
|
'hash' => $baselineItem['baseline_hash'] ?? null,
|
|
'provenance' => $baselineProvenance,
|
|
],
|
|
'current' => [
|
|
'hash' => $currentEvidence?->hash,
|
|
'provenance' => $currentProvenance,
|
|
],
|
|
'fidelity' => $fidelity,
|
|
'provenance' => [
|
|
'baseline_profile_id' => $context->baselineProfileId,
|
|
'baseline_snapshot_id' => $context->baselineSnapshotId,
|
|
'compare_operation_run_id' => $context->operationRunId,
|
|
'inventory_sync_run_id' => $context->inventorySyncRunId(),
|
|
],
|
|
],
|
|
),
|
|
diagnostics: [
|
|
'strategy_key' => $this->key()->value,
|
|
],
|
|
);
|
|
}
|
|
|
|
private function gapResult(
|
|
string $policyType,
|
|
string $subjectKey,
|
|
string $externalSubjectId,
|
|
string $operatorLabel,
|
|
CompareState $compareState,
|
|
string $reasonCode,
|
|
string $baselineAvailability,
|
|
string $currentStateAvailability,
|
|
string $trustLevel,
|
|
string $evidenceQuality,
|
|
): CompareSubjectResult {
|
|
return new CompareSubjectResult(
|
|
subjectIdentity: $this->identity($policyType, $externalSubjectId, $subjectKey),
|
|
projection: $this->projection($policyType, $operatorLabel),
|
|
baselineAvailability: $baselineAvailability,
|
|
currentStateAvailability: $currentStateAvailability,
|
|
compareState: $compareState,
|
|
trustLevel: $trustLevel,
|
|
evidenceQuality: $evidenceQuality,
|
|
diagnostics: [
|
|
'reason_code' => $reasonCode,
|
|
'gap_record' => [
|
|
'policy_type' => $policyType,
|
|
'subject_key' => $subjectKey,
|
|
'subject_class' => SubjectClass::Derived->value,
|
|
'resolution_path' => ResolutionPath::Derived->value,
|
|
'resolution_outcome' => ResolutionOutcome::CaptureFailed->value,
|
|
'operator_action_category' => OperatorActionCategory::RunInventorySync->value,
|
|
'structural' => false,
|
|
'retryable' => $reasonCode === 'missing_local_evidence',
|
|
'reason_code' => $reasonCode,
|
|
'search_text' => strtolower(implode(' ', [$policyType, $subjectKey, $reasonCode])),
|
|
],
|
|
],
|
|
);
|
|
}
|
|
|
|
private function identity(string $policyType, string $externalSubjectId, string $subjectKey): CompareSubjectIdentity
|
|
{
|
|
return new CompareSubjectIdentity(
|
|
domainKey: GovernanceDomainKey::Entra->value,
|
|
subjectClass: GovernanceSubjectClass::Control->value,
|
|
subjectTypeKey: $policyType,
|
|
externalSubjectId: $externalSubjectId,
|
|
subjectKey: $subjectKey,
|
|
);
|
|
}
|
|
|
|
private function projection(string $policyType, string $operatorLabel): CompareSubjectProjection
|
|
{
|
|
return new CompareSubjectProjection(
|
|
platformSubjectClass: 'control',
|
|
domainKey: GovernanceDomainKey::Entra->value,
|
|
subjectTypeKey: $policyType,
|
|
operatorLabel: $operatorLabel,
|
|
summaryKind: 'control_snapshot',
|
|
);
|
|
}
|
|
}
|
|
|
|
final class FailingCompareStrategy implements CompareStrategy
|
|
{
|
|
public function key(): CompareStrategyKey
|
|
{
|
|
return CompareStrategyKey::from('failing_control');
|
|
}
|
|
|
|
public function capabilities(): array
|
|
{
|
|
return [
|
|
new CompareStrategyCapability(
|
|
strategyKey: $this->key(),
|
|
domainKeys: [GovernanceDomainKey::Entra->value],
|
|
subjectClasses: [GovernanceSubjectClass::Control->value],
|
|
subjectTypeKeys: ['conditionalAccessPolicy'],
|
|
),
|
|
];
|
|
}
|
|
|
|
public function compare(
|
|
CompareOrchestrationContext $context,
|
|
ManagedEnvironment $tenant,
|
|
array $baselineItems,
|
|
array $currentItems,
|
|
array $resolvedCurrentEvidence,
|
|
array $severityMapping,
|
|
): array {
|
|
throw new \RuntimeException('Synthetic strategy failure for compare testing.');
|
|
}
|
|
}
|
|
|
|
final class FakeGovernanceSubjectTaxonomyRegistry extends GovernanceSubjectTaxonomyRegistry
|
|
{
|
|
public function all(): array
|
|
{
|
|
return array_values(array_merge(parent::all(), [
|
|
new GovernanceSubjectType(
|
|
domainKey: GovernanceDomainKey::Entra,
|
|
subjectClass: GovernanceSubjectClass::Control,
|
|
subjectTypeKey: 'conditionalAccessPolicy',
|
|
label: 'Conditional Access Policy',
|
|
description: 'Synthetic test-only future domain control',
|
|
captureSupported: true,
|
|
compareSupported: true,
|
|
inventorySupported: true,
|
|
active: true,
|
|
supportMode: 'supported',
|
|
legacyBucket: null,
|
|
),
|
|
]));
|
|
}
|
|
}
|