## Summary - add the Spec 204 platform vocabulary foundation, including canonical glossary terms, registry ownership descriptors, canonical operation type and alias resolution, and explicit reason ownership and platform reason-family metadata - harden platform-facing compare, snapshot, evidence, monitoring, review, and reporting surfaces so they prefer governed-subject and canonical operation semantics while preserving intentional Intune-owned terminology - extend Spec 204 unit, feature, Filament, and architecture coverage and add the full spec artifacts, checklist, and completed task ledger ## Verification - ran the focused recent-change Sail verification pack for the new glossary and reason-semantics work - ran the full Spec 204 quickstart verification pack under Sail - ran `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` - ran an integrated-browser smoke pass covering tenant dashboard, operations, operation detail, baseline compare, evidence, reviews, review packs, provider connections, inventory items, backup schedules, onboarding, and the system dashboard/operations/failures/run-detail surfaces ## Notes - provider registration is unchanged and remains in `bootstrap/providers.php` - no new destructive actions or asset-registration changes are introduced by this branch Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #234
1022 lines
46 KiB
PHP
1022 lines
46 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Support\Baselines\Compare;
|
|
|
|
use App\Models\Finding;
|
|
use App\Models\PolicyVersion;
|
|
use App\Models\Tenant;
|
|
use App\Services\Baselines\Evidence\BaselinePolicyVersionResolver;
|
|
use App\Services\Baselines\Evidence\ContentEvidenceProvider;
|
|
use App\Services\Baselines\Evidence\EvidenceProvenance;
|
|
use App\Services\Baselines\Evidence\ResolvedEvidence;
|
|
use App\Services\Drift\DriftHasher;
|
|
use App\Services\Drift\Normalizers\AssignmentsNormalizer;
|
|
use App\Services\Drift\Normalizers\ScopeTagsNormalizer;
|
|
use App\Services\Drift\Normalizers\SettingsNormalizer;
|
|
use App\Services\Intune\IntuneRoleDefinitionNormalizer;
|
|
use App\Support\Baselines\OperatorActionCategory;
|
|
use App\Support\Baselines\ResolutionOutcome;
|
|
use App\Support\Baselines\ResolutionPath;
|
|
use App\Support\Baselines\SubjectClass;
|
|
use App\Support\Baselines\SubjectResolver;
|
|
use App\Support\Governance\GovernanceDomainKey;
|
|
use App\Support\Governance\GovernanceSubjectClass;
|
|
use App\Support\Governance\PlatformSubjectDescriptorNormalizer;
|
|
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
|
use App\Support\Ui\OperatorExplanation\TrustworthinessLevel;
|
|
|
|
final class IntuneCompareStrategy implements CompareStrategy
|
|
{
|
|
public function __construct(
|
|
private readonly BaselinePolicyVersionResolver $baselinePolicyVersionResolver,
|
|
private readonly DriftHasher $hasher,
|
|
private readonly SettingsNormalizer $settingsNormalizer,
|
|
private readonly AssignmentsNormalizer $assignmentsNormalizer,
|
|
private readonly ScopeTagsNormalizer $scopeTagsNormalizer,
|
|
private readonly ContentEvidenceProvider $contentEvidenceProvider,
|
|
private readonly IntuneRoleDefinitionNormalizer $roleDefinitionNormalizer,
|
|
private readonly SubjectResolver $subjectResolver,
|
|
) {}
|
|
|
|
public function key(): CompareStrategyKey
|
|
{
|
|
return CompareStrategyKey::intunePolicy();
|
|
}
|
|
|
|
public function capabilities(): array
|
|
{
|
|
return [
|
|
new CompareStrategyCapability(
|
|
strategyKey: $this->key(),
|
|
domainKeys: [GovernanceDomainKey::Intune->value],
|
|
subjectClasses: [GovernanceSubjectClass::Policy->value],
|
|
subjectTypeKeys: 'all',
|
|
),
|
|
new CompareStrategyCapability(
|
|
strategyKey: $this->key(),
|
|
domainKeys: [GovernanceDomainKey::PlatformFoundation->value],
|
|
subjectClasses: [GovernanceSubjectClass::ConfigurationResource->value],
|
|
subjectTypeKeys: 'all',
|
|
),
|
|
];
|
|
}
|
|
|
|
public function compare(
|
|
CompareOrchestrationContext $context,
|
|
Tenant $tenant,
|
|
array $baselineItems,
|
|
array $currentItems,
|
|
array $resolvedCurrentEvidence,
|
|
array $severityMapping,
|
|
): array {
|
|
$subjectResults = [];
|
|
$rbacRoleDefinitionSummary = $this->emptyRbacRoleDefinitionSummary();
|
|
$inventorySyncRunId = $context->inventorySyncRunId() ?? 0;
|
|
|
|
$baselinePlaceholderProvenance = EvidenceProvenance::build(
|
|
fidelity: EvidenceProvenance::FidelityMeta,
|
|
source: EvidenceProvenance::SourceInventory,
|
|
observedAt: null,
|
|
observedOperationRunId: null,
|
|
);
|
|
|
|
$currentMissingProvenance = EvidenceProvenance::build(
|
|
fidelity: EvidenceProvenance::FidelityMeta,
|
|
source: EvidenceProvenance::SourceInventory,
|
|
observedAt: null,
|
|
observedOperationRunId: $inventorySyncRunId > 0 ? $inventorySyncRunId : null,
|
|
);
|
|
|
|
foreach ($baselineItems as $key => $baselineItem) {
|
|
$currentItem = $currentItems[$key] ?? null;
|
|
$policyType = (string) ($baselineItem['policy_type'] ?? '');
|
|
$subjectKey = (string) ($baselineItem['subject_key'] ?? '');
|
|
$isRbacRoleDefinition = $policyType === 'intuneRoleDefinition';
|
|
$baselineProvenance = $this->baselineProvenanceFromMetaJsonb($baselineItem['meta_jsonb'] ?? []);
|
|
$baselinePolicyVersionId = $this->resolveBaselinePolicyVersionId(
|
|
tenant: $tenant,
|
|
baselineItem: $baselineItem,
|
|
baselineProvenance: $baselineProvenance,
|
|
);
|
|
$baselineComparableHash = $this->effectiveBaselineHash(
|
|
tenant: $tenant,
|
|
baselineItem: $baselineItem,
|
|
baselinePolicyVersionId: $baselinePolicyVersionId,
|
|
);
|
|
|
|
if (! is_array($currentItem)) {
|
|
if ($isRbacRoleDefinition && $baselinePolicyVersionId === null) {
|
|
$subjectResults[] = $this->gapResult(
|
|
policyType: $policyType,
|
|
subjectKey: $subjectKey,
|
|
subjectExternalId: (string) ($baselineItem['subject_external_id'] ?? ''),
|
|
operatorLabel: $this->operatorLabel($baselineItem, null),
|
|
compareState: CompareState::Incomplete,
|
|
reasonCode: 'missing_role_definition_baseline_version_reference',
|
|
baselineAvailability: 'available',
|
|
currentStateAvailability: 'missing',
|
|
trustLevel: TrustworthinessLevel::Unusable->value,
|
|
evidenceQuality: 'missing',
|
|
);
|
|
|
|
continue;
|
|
}
|
|
|
|
$evidence = $this->buildDriftEvidenceContract(
|
|
changeType: 'missing_policy',
|
|
policyType: $policyType,
|
|
subjectKey: $subjectKey,
|
|
displayName: $this->operatorLabel($baselineItem, null),
|
|
baselineHash: $baselineComparableHash,
|
|
currentHash: null,
|
|
baselineProvenance: $baselineProvenance,
|
|
currentProvenance: $currentMissingProvenance,
|
|
baselinePolicyVersionId: $baselinePolicyVersionId,
|
|
currentPolicyVersionId: null,
|
|
summaryKind: 'policy_snapshot',
|
|
baselineProfileId: $context->baselineProfileId,
|
|
baselineSnapshotId: $context->baselineSnapshotId,
|
|
compareOperationRunId: $context->operationRunId,
|
|
inventorySyncRunId: $inventorySyncRunId,
|
|
);
|
|
|
|
if ($isRbacRoleDefinition) {
|
|
$evidence['summary']['kind'] = 'rbac_role_definition';
|
|
$evidence['rbac_role_definition'] = $this->buildRoleDefinitionEvidencePayload(
|
|
tenant: $tenant,
|
|
baselinePolicyVersionId: $baselinePolicyVersionId,
|
|
currentPolicyVersionId: null,
|
|
baselineMeta: is_array($baselineItem['meta_jsonb'] ?? null) ? $baselineItem['meta_jsonb'] : [],
|
|
currentMeta: [],
|
|
diffKind: 'missing',
|
|
);
|
|
$rbacRoleDefinitionSummary['missing']++;
|
|
$rbacRoleDefinitionSummary['total_compared']++;
|
|
}
|
|
|
|
$subjectResults[] = $this->driftResult(
|
|
policyType: $policyType,
|
|
subjectKey: $subjectKey,
|
|
subjectExternalId: (string) ($baselineItem['subject_external_id'] ?? ''),
|
|
subjectType: is_string($baselineItem['subject_type'] ?? null) ? (string) $baselineItem['subject_type'] : null,
|
|
operatorLabel: $this->operatorLabel($baselineItem, null),
|
|
changeType: 'missing_policy',
|
|
severity: $isRbacRoleDefinition
|
|
? Finding::SEVERITY_HIGH
|
|
: $this->severityForChangeType($severityMapping, 'missing_policy'),
|
|
evidence: $evidence,
|
|
baselineAvailability: 'available',
|
|
currentStateAvailability: 'missing',
|
|
);
|
|
|
|
continue;
|
|
}
|
|
|
|
$currentEvidence = $resolvedCurrentEvidence[$key] ?? null;
|
|
|
|
if (! $currentEvidence instanceof ResolvedEvidence) {
|
|
$subjectResults[] = $this->gapResult(
|
|
policyType: $policyType,
|
|
subjectKey: $subjectKey,
|
|
subjectExternalId: (string) ($currentItem['subject_external_id'] ?? $baselineItem['subject_external_id'] ?? ''),
|
|
operatorLabel: $this->operatorLabel($baselineItem, $currentItem),
|
|
compareState: CompareState::Incomplete,
|
|
reasonCode: 'missing_current',
|
|
baselineAvailability: 'available',
|
|
currentStateAvailability: 'unknown',
|
|
trustLevel: TrustworthinessLevel::Unusable->value,
|
|
evidenceQuality: 'missing',
|
|
);
|
|
|
|
continue;
|
|
}
|
|
|
|
$currentPolicyVersionId = $this->currentPolicyVersionIdFromEvidence($currentEvidence);
|
|
|
|
if ($baselineComparableHash !== $currentEvidence->hash) {
|
|
$roleDefinitionDiff = null;
|
|
|
|
if ($isRbacRoleDefinition) {
|
|
if ($baselinePolicyVersionId === null) {
|
|
$subjectResults[] = $this->gapResult(
|
|
policyType: $policyType,
|
|
subjectKey: $subjectKey,
|
|
subjectExternalId: (string) ($currentItem['subject_external_id'] ?? $baselineItem['subject_external_id'] ?? ''),
|
|
operatorLabel: $this->operatorLabel($baselineItem, $currentItem),
|
|
compareState: CompareState::Incomplete,
|
|
reasonCode: 'missing_role_definition_baseline_version_reference',
|
|
baselineAvailability: 'available',
|
|
currentStateAvailability: 'available',
|
|
trustLevel: TrustworthinessLevel::Unusable->value,
|
|
evidenceQuality: $currentEvidence->fidelity,
|
|
);
|
|
|
|
continue;
|
|
}
|
|
|
|
if ($currentPolicyVersionId === null) {
|
|
$subjectResults[] = $this->gapResult(
|
|
policyType: $policyType,
|
|
subjectKey: $subjectKey,
|
|
subjectExternalId: (string) ($currentItem['subject_external_id'] ?? $baselineItem['subject_external_id'] ?? ''),
|
|
operatorLabel: $this->operatorLabel($baselineItem, $currentItem),
|
|
compareState: CompareState::Incomplete,
|
|
reasonCode: 'missing_role_definition_current_version_reference',
|
|
baselineAvailability: 'available',
|
|
currentStateAvailability: 'available',
|
|
trustLevel: TrustworthinessLevel::Unusable->value,
|
|
evidenceQuality: $currentEvidence->fidelity,
|
|
);
|
|
|
|
continue;
|
|
}
|
|
|
|
$roleDefinitionDiff = $this->resolveRoleDefinitionDiff(
|
|
tenant: $tenant,
|
|
baselinePolicyVersionId: $baselinePolicyVersionId,
|
|
currentPolicyVersionId: $currentPolicyVersionId,
|
|
);
|
|
|
|
if (! is_array($roleDefinitionDiff)) {
|
|
$subjectResults[] = $this->gapResult(
|
|
policyType: $policyType,
|
|
subjectKey: $subjectKey,
|
|
subjectExternalId: (string) ($currentItem['subject_external_id'] ?? $baselineItem['subject_external_id'] ?? ''),
|
|
operatorLabel: $this->operatorLabel($baselineItem, $currentItem),
|
|
compareState: CompareState::Incomplete,
|
|
reasonCode: 'missing_role_definition_compare_surface',
|
|
baselineAvailability: 'available',
|
|
currentStateAvailability: 'available',
|
|
trustLevel: TrustworthinessLevel::Unusable->value,
|
|
evidenceQuality: $currentEvidence->fidelity,
|
|
);
|
|
|
|
continue;
|
|
}
|
|
}
|
|
|
|
$summaryKind = $isRbacRoleDefinition
|
|
? 'rbac_role_definition'
|
|
: $this->selectSummaryKind(
|
|
tenant: $tenant,
|
|
policyType: $policyType,
|
|
baselinePolicyVersionId: $baselinePolicyVersionId,
|
|
currentPolicyVersionId: $currentPolicyVersionId,
|
|
);
|
|
|
|
$evidence = $this->buildDriftEvidenceContract(
|
|
changeType: 'different_version',
|
|
policyType: $policyType,
|
|
subjectKey: $subjectKey,
|
|
displayName: $this->operatorLabel($baselineItem, $currentItem),
|
|
baselineHash: $baselineComparableHash,
|
|
currentHash: (string) $currentEvidence->hash,
|
|
baselineProvenance: $baselineProvenance,
|
|
currentProvenance: $currentEvidence->tenantProvenance(),
|
|
baselinePolicyVersionId: $baselinePolicyVersionId,
|
|
currentPolicyVersionId: $currentPolicyVersionId,
|
|
summaryKind: $summaryKind,
|
|
baselineProfileId: $context->baselineProfileId,
|
|
baselineSnapshotId: $context->baselineSnapshotId,
|
|
compareOperationRunId: $context->operationRunId,
|
|
inventorySyncRunId: $inventorySyncRunId,
|
|
);
|
|
|
|
if ($isRbacRoleDefinition && is_array($roleDefinitionDiff)) {
|
|
$evidence['rbac_role_definition'] = $this->buildRoleDefinitionEvidencePayload(
|
|
tenant: $tenant,
|
|
baselinePolicyVersionId: $baselinePolicyVersionId,
|
|
currentPolicyVersionId: $currentPolicyVersionId,
|
|
baselineMeta: is_array($baselineItem['meta_jsonb'] ?? null) ? $baselineItem['meta_jsonb'] : [],
|
|
currentMeta: is_array($currentEvidence->meta ?? null) ? $currentEvidence->meta : (is_array($currentItem['meta_jsonb'] ?? null) ? $currentItem['meta_jsonb'] : []),
|
|
diffKind: (string) $roleDefinitionDiff['diff_kind'],
|
|
roleDefinitionDiff: $roleDefinitionDiff,
|
|
);
|
|
$rbacRoleDefinitionSummary['modified']++;
|
|
$rbacRoleDefinitionSummary['total_compared']++;
|
|
}
|
|
|
|
$subjectResults[] = $this->driftResult(
|
|
policyType: $policyType,
|
|
subjectKey: $subjectKey,
|
|
subjectExternalId: (string) ($currentItem['subject_external_id'] ?? $baselineItem['subject_external_id'] ?? ''),
|
|
subjectType: is_string($baselineItem['subject_type'] ?? null) ? (string) $baselineItem['subject_type'] : null,
|
|
operatorLabel: $this->operatorLabel($baselineItem, $currentItem),
|
|
changeType: 'different_version',
|
|
severity: $isRbacRoleDefinition
|
|
? $this->severityForRoleDefinitionDiff($roleDefinitionDiff)
|
|
: $this->severityForChangeType($severityMapping, 'different_version'),
|
|
evidence: $evidence,
|
|
baselineAvailability: 'available',
|
|
currentStateAvailability: 'available',
|
|
);
|
|
|
|
continue;
|
|
}
|
|
|
|
if ($isRbacRoleDefinition) {
|
|
$rbacRoleDefinitionSummary['unchanged']++;
|
|
$rbacRoleDefinitionSummary['total_compared']++;
|
|
}
|
|
|
|
$subjectResults[] = new CompareSubjectResult(
|
|
subjectIdentity: $this->subjectIdentity($policyType, (string) ($currentItem['subject_external_id'] ?? $baselineItem['subject_external_id'] ?? ''), $subjectKey),
|
|
projection: $this->subjectProjection($policyType, $this->operatorLabel($baselineItem, $currentItem), is_string($baselineItem['subject_type'] ?? null) ? (string) $baselineItem['subject_type'] : null, 'policy_snapshot'),
|
|
baselineAvailability: 'available',
|
|
currentStateAvailability: 'available',
|
|
compareState: CompareState::NoDrift,
|
|
trustLevel: $currentEvidence->fidelity === EvidenceProvenance::FidelityContent
|
|
? TrustworthinessLevel::Trustworthy->value
|
|
: TrustworthinessLevel::LimitedConfidence->value,
|
|
evidenceQuality: $currentEvidence->fidelity,
|
|
diagnostics: [
|
|
'strategy_key' => $this->key()->value,
|
|
],
|
|
);
|
|
}
|
|
|
|
foreach ($currentItems as $key => $currentItem) {
|
|
if (array_key_exists($key, $baselineItems)) {
|
|
continue;
|
|
}
|
|
|
|
$currentEvidence = $resolvedCurrentEvidence[$key] ?? null;
|
|
$policyType = (string) ($currentItem['policy_type'] ?? '');
|
|
$subjectKey = (string) ($currentItem['subject_key'] ?? '');
|
|
$isRbacRoleDefinition = $policyType === 'intuneRoleDefinition';
|
|
|
|
if (! $currentEvidence instanceof ResolvedEvidence) {
|
|
$subjectResults[] = $this->gapResult(
|
|
policyType: $policyType,
|
|
subjectKey: $subjectKey,
|
|
subjectExternalId: (string) ($currentItem['subject_external_id'] ?? ''),
|
|
operatorLabel: $this->operatorLabel(null, $currentItem),
|
|
compareState: CompareState::Incomplete,
|
|
reasonCode: 'missing_current',
|
|
baselineAvailability: 'missing',
|
|
currentStateAvailability: 'unknown',
|
|
trustLevel: TrustworthinessLevel::Unusable->value,
|
|
evidenceQuality: 'missing',
|
|
);
|
|
|
|
continue;
|
|
}
|
|
|
|
$currentPolicyVersionId = $this->currentPolicyVersionIdFromEvidence($currentEvidence);
|
|
|
|
if ($isRbacRoleDefinition && $currentPolicyVersionId === null) {
|
|
$subjectResults[] = $this->gapResult(
|
|
policyType: $policyType,
|
|
subjectKey: $subjectKey,
|
|
subjectExternalId: (string) ($currentItem['subject_external_id'] ?? ''),
|
|
operatorLabel: $this->operatorLabel(null, $currentItem),
|
|
compareState: CompareState::Incomplete,
|
|
reasonCode: 'missing_role_definition_current_version_reference',
|
|
baselineAvailability: 'missing',
|
|
currentStateAvailability: 'available',
|
|
trustLevel: TrustworthinessLevel::Unusable->value,
|
|
evidenceQuality: $currentEvidence->fidelity,
|
|
);
|
|
|
|
continue;
|
|
}
|
|
|
|
$evidence = $this->buildDriftEvidenceContract(
|
|
changeType: 'unexpected_policy',
|
|
policyType: $policyType,
|
|
subjectKey: $subjectKey,
|
|
displayName: $this->operatorLabel(null, $currentItem),
|
|
baselineHash: null,
|
|
currentHash: (string) $currentEvidence->hash,
|
|
baselineProvenance: $baselinePlaceholderProvenance,
|
|
currentProvenance: $currentEvidence->tenantProvenance(),
|
|
baselinePolicyVersionId: null,
|
|
currentPolicyVersionId: $currentPolicyVersionId,
|
|
summaryKind: 'policy_snapshot',
|
|
baselineProfileId: $context->baselineProfileId,
|
|
baselineSnapshotId: $context->baselineSnapshotId,
|
|
compareOperationRunId: $context->operationRunId,
|
|
inventorySyncRunId: $inventorySyncRunId,
|
|
);
|
|
|
|
if ($isRbacRoleDefinition) {
|
|
$evidence['summary']['kind'] = 'rbac_role_definition';
|
|
$evidence['rbac_role_definition'] = $this->buildRoleDefinitionEvidencePayload(
|
|
tenant: $tenant,
|
|
baselinePolicyVersionId: null,
|
|
currentPolicyVersionId: $currentPolicyVersionId,
|
|
baselineMeta: [],
|
|
currentMeta: is_array($currentEvidence->meta ?? null) ? $currentEvidence->meta : (is_array($currentItem['meta_jsonb'] ?? null) ? $currentItem['meta_jsonb'] : []),
|
|
diffKind: 'unexpected',
|
|
);
|
|
$rbacRoleDefinitionSummary['unexpected']++;
|
|
$rbacRoleDefinitionSummary['total_compared']++;
|
|
}
|
|
|
|
$subjectResults[] = $this->driftResult(
|
|
policyType: $policyType,
|
|
subjectKey: $subjectKey,
|
|
subjectExternalId: (string) ($currentItem['subject_external_id'] ?? ''),
|
|
subjectType: null,
|
|
operatorLabel: $this->operatorLabel(null, $currentItem),
|
|
changeType: 'unexpected_policy',
|
|
severity: $isRbacRoleDefinition
|
|
? Finding::SEVERITY_MEDIUM
|
|
: $this->severityForChangeType($severityMapping, 'unexpected_policy'),
|
|
evidence: $evidence,
|
|
baselineAvailability: 'missing',
|
|
currentStateAvailability: 'available',
|
|
);
|
|
}
|
|
|
|
return [
|
|
'subject_results' => $subjectResults,
|
|
'diagnostics' => [
|
|
'rbac_role_definitions' => $rbacRoleDefinitionSummary,
|
|
],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $baselineItem
|
|
* @param array<string, mixed>|null $currentItem
|
|
* @param array<string, mixed> $evidence
|
|
*/
|
|
private function driftResult(
|
|
string $policyType,
|
|
string $subjectKey,
|
|
string $subjectExternalId,
|
|
?string $subjectType,
|
|
string $operatorLabel,
|
|
string $changeType,
|
|
string $severity,
|
|
array $evidence,
|
|
string $baselineAvailability,
|
|
string $currentStateAvailability,
|
|
): CompareSubjectResult {
|
|
$fidelity = is_string($evidence['fidelity'] ?? null) && trim((string) $evidence['fidelity']) !== ''
|
|
? trim((string) $evidence['fidelity'])
|
|
: EvidenceProvenance::FidelityMeta;
|
|
|
|
return new CompareSubjectResult(
|
|
subjectIdentity: $this->subjectIdentity($policyType, $subjectExternalId, $subjectKey),
|
|
projection: $this->subjectProjection(
|
|
policyType: $policyType,
|
|
operatorLabel: $operatorLabel,
|
|
subjectType: $subjectType,
|
|
summaryKind: is_string(data_get($evidence, 'summary.kind')) ? (string) data_get($evidence, 'summary.kind') : null,
|
|
),
|
|
baselineAvailability: $baselineAvailability,
|
|
currentStateAvailability: $currentStateAvailability,
|
|
compareState: CompareState::Drift,
|
|
trustLevel: $fidelity === EvidenceProvenance::FidelityContent
|
|
? TrustworthinessLevel::Trustworthy->value
|
|
: TrustworthinessLevel::LimitedConfidence->value,
|
|
evidenceQuality: $fidelity,
|
|
severityRecommendation: $severity,
|
|
findingCandidate: new CompareFindingCandidate(
|
|
changeType: $changeType,
|
|
severity: $severity,
|
|
fingerprintBasis: [
|
|
'policy_type' => $policyType,
|
|
'subject_key' => $subjectKey,
|
|
'change_type' => $changeType,
|
|
],
|
|
evidencePayload: $evidence,
|
|
),
|
|
diagnostics: [
|
|
'strategy_key' => $this->key()->value,
|
|
],
|
|
);
|
|
}
|
|
|
|
private function gapResult(
|
|
string $policyType,
|
|
string $subjectKey,
|
|
string $subjectExternalId,
|
|
string $operatorLabel,
|
|
CompareState $compareState,
|
|
string $reasonCode,
|
|
string $baselineAvailability,
|
|
string $currentStateAvailability,
|
|
string $trustLevel,
|
|
string $evidenceQuality,
|
|
): CompareSubjectResult {
|
|
$descriptor = $this->subjectResolver->describeForCompare(
|
|
policyType: $policyType,
|
|
subjectExternalId: $subjectExternalId !== '' ? $subjectExternalId : null,
|
|
subjectKey: $subjectKey,
|
|
);
|
|
|
|
$outcome = match ($reasonCode) {
|
|
'missing_current' => $this->subjectResolver->missingExpectedRecord($descriptor),
|
|
'ambiguous_match' => $this->subjectResolver->ambiguousMatch($descriptor),
|
|
default => $this->subjectResolver->captureFailed($descriptor),
|
|
};
|
|
|
|
$gapRecord = array_merge($descriptor->toArray(), $outcome->toArray(), [
|
|
'reason_code' => $reasonCode,
|
|
'search_text' => strtolower(implode(' ', array_filter([$policyType, $subjectKey, $reasonCode, $operatorLabel]))),
|
|
]);
|
|
|
|
return new CompareSubjectResult(
|
|
subjectIdentity: $this->subjectIdentity($policyType, $subjectExternalId, $subjectKey),
|
|
projection: $this->subjectProjection($policyType, $operatorLabel, null, null),
|
|
baselineAvailability: $baselineAvailability,
|
|
currentStateAvailability: $currentStateAvailability,
|
|
compareState: $compareState,
|
|
trustLevel: $trustLevel,
|
|
evidenceQuality: $evidenceQuality,
|
|
diagnostics: [
|
|
'strategy_key' => $this->key()->value,
|
|
'reason_code' => $reasonCode,
|
|
'gap_record' => $gapRecord,
|
|
],
|
|
);
|
|
}
|
|
|
|
private function subjectIdentity(string $policyType, string $subjectExternalId, string $subjectKey): CompareSubjectIdentity
|
|
{
|
|
return new CompareSubjectIdentity(
|
|
domainKey: $this->domainKeyFor($policyType),
|
|
subjectClass: $this->subjectClassFor($policyType),
|
|
subjectTypeKey: $policyType,
|
|
externalSubjectId: $subjectExternalId !== '' ? $subjectExternalId : null,
|
|
subjectKey: $subjectKey,
|
|
);
|
|
}
|
|
|
|
private function subjectProjection(string $policyType, string $operatorLabel, ?string $subjectType, ?string $summaryKind): CompareSubjectProjection
|
|
{
|
|
return new CompareSubjectProjection(
|
|
platformSubjectClass: $this->platformSubjectClassFor($policyType, $subjectType),
|
|
domainKey: $this->domainKeyFor($policyType),
|
|
subjectTypeKey: $policyType,
|
|
operatorLabel: $operatorLabel,
|
|
summaryKind: $summaryKind,
|
|
additionalLabels: [
|
|
'policy_type_label' => InventoryPolicyTypeMeta::baselineCompareLabel($policyType) ?? $policyType,
|
|
'governed_subject_label' => (string) data_get($this->subjectDescriptor($policyType), 'display_label', $policyType),
|
|
],
|
|
subjectDescriptor: $this->subjectDescriptor($policyType),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function subjectDescriptor(string $policyType): array
|
|
{
|
|
static $cache = [];
|
|
|
|
if (array_key_exists($policyType, $cache)) {
|
|
return $cache[$policyType];
|
|
}
|
|
|
|
$result = app(PlatformSubjectDescriptorNormalizer::class)->fromArray([
|
|
'policy_type' => $policyType,
|
|
], 'baseline_compare');
|
|
|
|
return $cache[$policyType] = $result->descriptor->toArray();
|
|
}
|
|
|
|
private function domainKeyFor(string $policyType): string
|
|
{
|
|
return InventoryPolicyTypeMeta::isFoundation($policyType)
|
|
? GovernanceDomainKey::PlatformFoundation->value
|
|
: GovernanceDomainKey::Intune->value;
|
|
}
|
|
|
|
private function subjectClassFor(string $policyType): string
|
|
{
|
|
return InventoryPolicyTypeMeta::isFoundation($policyType)
|
|
? GovernanceSubjectClass::ConfigurationResource->value
|
|
: GovernanceSubjectClass::Policy->value;
|
|
}
|
|
|
|
private function platformSubjectClassFor(string $policyType, ?string $subjectType): string
|
|
{
|
|
if (is_string($subjectType) && trim($subjectType) !== '') {
|
|
return trim($subjectType);
|
|
}
|
|
|
|
return InventoryPolicyTypeMeta::isFoundation($policyType)
|
|
? GovernanceSubjectClass::ConfigurationResource->value
|
|
: 'policy';
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed>|null $baselineItem
|
|
* @param array<string, mixed>|null $currentItem
|
|
*/
|
|
private function operatorLabel(?array $baselineItem, ?array $currentItem): string
|
|
{
|
|
$displayName = $currentItem['meta_jsonb']['display_name']
|
|
?? $baselineItem['meta_jsonb']['display_name']
|
|
?? $currentItem['subject_key']
|
|
?? $baselineItem['subject_key']
|
|
?? 'Unknown subject';
|
|
|
|
return is_string($displayName) && trim($displayName) !== '' ? trim($displayName) : 'Unknown subject';
|
|
}
|
|
|
|
private function effectiveBaselineHash(Tenant $tenant, array $baselineItem, ?int $baselinePolicyVersionId): string
|
|
{
|
|
$storedHash = (string) ($baselineItem['baseline_hash'] ?? '');
|
|
|
|
if ($baselinePolicyVersionId === null) {
|
|
return $storedHash;
|
|
}
|
|
|
|
$baselineVersion = PolicyVersion::query()
|
|
->where('tenant_id', (int) $tenant->getKey())
|
|
->find($baselinePolicyVersionId);
|
|
|
|
if (! $baselineVersion instanceof PolicyVersion) {
|
|
return $storedHash;
|
|
}
|
|
|
|
return $this->contentEvidenceProvider->fromPolicyVersion(
|
|
version: $baselineVersion,
|
|
subjectExternalId: (string) ($baselineItem['subject_external_id'] ?? ''),
|
|
)->hash;
|
|
}
|
|
|
|
private function resolveBaselinePolicyVersionId(Tenant $tenant, array $baselineItem, array $baselineProvenance): ?int
|
|
{
|
|
$metaJsonb = is_array($baselineItem['meta_jsonb'] ?? null) ? $baselineItem['meta_jsonb'] : [];
|
|
$versionReferenceId = data_get($metaJsonb, 'version_reference.policy_version_id');
|
|
|
|
if (is_numeric($versionReferenceId)) {
|
|
return (int) $versionReferenceId;
|
|
}
|
|
|
|
$baselineFidelity = (string) ($baselineProvenance['fidelity'] ?? EvidenceProvenance::FidelityMeta);
|
|
$baselineSource = (string) ($baselineProvenance['source'] ?? EvidenceProvenance::SourceInventory);
|
|
|
|
if ($baselineFidelity !== EvidenceProvenance::FidelityContent || $baselineSource !== EvidenceProvenance::SourcePolicyVersion) {
|
|
return null;
|
|
}
|
|
|
|
$observedAt = $baselineProvenance['observed_at'] ?? null;
|
|
$observedAt = is_string($observedAt) ? trim($observedAt) : null;
|
|
|
|
if (! is_string($observedAt) || $observedAt === '') {
|
|
return null;
|
|
}
|
|
|
|
return $this->baselinePolicyVersionResolver->resolve(
|
|
tenant: $tenant,
|
|
policyType: (string) ($baselineItem['policy_type'] ?? ''),
|
|
subjectKey: (string) ($baselineItem['subject_key'] ?? ''),
|
|
observedAt: $observedAt,
|
|
);
|
|
}
|
|
|
|
private function currentPolicyVersionIdFromEvidence(ResolvedEvidence $evidence): ?int
|
|
{
|
|
$policyVersionId = $evidence->meta['policy_version_id'] ?? null;
|
|
|
|
return is_numeric($policyVersionId) ? (int) $policyVersionId : null;
|
|
}
|
|
|
|
private function selectSummaryKind(Tenant $tenant, string $policyType, ?int $baselinePolicyVersionId, ?int $currentPolicyVersionId): string
|
|
{
|
|
if ($baselinePolicyVersionId === null || $currentPolicyVersionId === null) {
|
|
return 'policy_snapshot';
|
|
}
|
|
|
|
$baselineVersion = PolicyVersion::query()
|
|
->where('tenant_id', (int) $tenant->getKey())
|
|
->find($baselinePolicyVersionId);
|
|
$currentVersion = PolicyVersion::query()
|
|
->where('tenant_id', (int) $tenant->getKey())
|
|
->find($currentPolicyVersionId);
|
|
|
|
if (! $baselineVersion instanceof PolicyVersion || ! $currentVersion instanceof PolicyVersion) {
|
|
return 'policy_snapshot';
|
|
}
|
|
|
|
$platform = is_string($baselineVersion->platform ?? null)
|
|
? (string) $baselineVersion->platform
|
|
: (is_string($currentVersion->platform ?? null) ? (string) $currentVersion->platform : null);
|
|
|
|
$baselineSnapshotHash = $this->hasher->hashNormalized([
|
|
'settings' => $this->settingsNormalizer->normalizeForDiff(
|
|
snapshot: is_array($baselineVersion->snapshot) ? $baselineVersion->snapshot : [],
|
|
policyType: $policyType,
|
|
platform: $platform,
|
|
),
|
|
'secret_fingerprints' => $this->fingerprintBucket($baselineVersion, 'snapshot'),
|
|
]);
|
|
$currentSnapshotHash = $this->hasher->hashNormalized([
|
|
'settings' => $this->settingsNormalizer->normalizeForDiff(
|
|
snapshot: is_array($currentVersion->snapshot) ? $currentVersion->snapshot : [],
|
|
policyType: $policyType,
|
|
platform: $platform,
|
|
),
|
|
'secret_fingerprints' => $this->fingerprintBucket($currentVersion, 'snapshot'),
|
|
]);
|
|
|
|
if ($baselineSnapshotHash !== $currentSnapshotHash) {
|
|
return 'policy_snapshot';
|
|
}
|
|
|
|
$baselineAssignmentsHash = $this->hasher->hashNormalized([
|
|
'assignments' => $this->assignmentsNormalizer->normalizeForDiff(is_array($baselineVersion->assignments) ? $baselineVersion->assignments : []),
|
|
'secret_fingerprints' => $this->fingerprintBucket($baselineVersion, 'assignments'),
|
|
]);
|
|
$currentAssignmentsHash = $this->hasher->hashNormalized([
|
|
'assignments' => $this->assignmentsNormalizer->normalizeForDiff(is_array($currentVersion->assignments) ? $currentVersion->assignments : []),
|
|
'secret_fingerprints' => $this->fingerprintBucket($currentVersion, 'assignments'),
|
|
]);
|
|
|
|
if ($baselineAssignmentsHash !== $currentAssignmentsHash) {
|
|
return 'policy_assignments';
|
|
}
|
|
|
|
$baselineScopeTagIds = $this->scopeTagsNormalizer->normalizeIdsForHash($baselineVersion->scope_tags);
|
|
$currentScopeTagIds = $this->scopeTagsNormalizer->normalizeIdsForHash($currentVersion->scope_tags);
|
|
|
|
if ($baselineScopeTagIds === null || $currentScopeTagIds === null) {
|
|
return 'policy_snapshot';
|
|
}
|
|
|
|
$baselineScopeTagsHash = $this->hasher->hashNormalized([
|
|
'scope_tag_ids' => $baselineScopeTagIds,
|
|
'secret_fingerprints' => $this->fingerprintBucket($baselineVersion, 'scope_tags'),
|
|
]);
|
|
$currentScopeTagsHash = $this->hasher->hashNormalized([
|
|
'scope_tag_ids' => $currentScopeTagIds,
|
|
'secret_fingerprints' => $this->fingerprintBucket($currentVersion, 'scope_tags'),
|
|
]);
|
|
|
|
return $baselineScopeTagsHash !== $currentScopeTagsHash
|
|
? 'policy_scope_tags'
|
|
: 'policy_snapshot';
|
|
}
|
|
|
|
private function severityForChangeType(array $severityMapping, string $changeType): string
|
|
{
|
|
$severity = $severityMapping[$changeType] ?? null;
|
|
|
|
if (! is_string($severity) || $severity === '') {
|
|
return match ($changeType) {
|
|
'missing_policy' => Finding::SEVERITY_HIGH,
|
|
'different_version' => Finding::SEVERITY_MEDIUM,
|
|
default => Finding::SEVERITY_LOW,
|
|
};
|
|
}
|
|
|
|
return $severity;
|
|
}
|
|
|
|
private function fingerprintBucket(PolicyVersion $version, string $bucket): array
|
|
{
|
|
$secretFingerprints = is_array($version->secret_fingerprints) ? $version->secret_fingerprints : [];
|
|
$bucketFingerprints = $secretFingerprints[$bucket] ?? [];
|
|
|
|
return is_array($bucketFingerprints) ? $bucketFingerprints : [];
|
|
}
|
|
|
|
private function buildDriftEvidenceContract(
|
|
string $changeType,
|
|
string $policyType,
|
|
string $subjectKey,
|
|
?string $displayName,
|
|
?string $baselineHash,
|
|
?string $currentHash,
|
|
array $baselineProvenance,
|
|
array $currentProvenance,
|
|
?int $baselinePolicyVersionId,
|
|
?int $currentPolicyVersionId,
|
|
string $summaryKind,
|
|
int $baselineProfileId,
|
|
int $baselineSnapshotId,
|
|
int $compareOperationRunId,
|
|
int $inventorySyncRunId,
|
|
): array {
|
|
return [
|
|
'change_type' => $changeType,
|
|
'policy_type' => $policyType,
|
|
'subject_key' => $subjectKey,
|
|
'display_name' => $displayName,
|
|
'summary' => [
|
|
'kind' => $summaryKind,
|
|
],
|
|
'baseline' => [
|
|
'policy_version_id' => $baselinePolicyVersionId,
|
|
'hash' => $baselineHash,
|
|
'provenance' => $baselineProvenance,
|
|
],
|
|
'current' => [
|
|
'policy_version_id' => $currentPolicyVersionId,
|
|
'hash' => $currentHash,
|
|
'provenance' => $currentProvenance,
|
|
],
|
|
'fidelity' => $this->fidelityFromPolicyVersionRefs($baselinePolicyVersionId, $currentPolicyVersionId),
|
|
'provenance' => [
|
|
'baseline_profile_id' => $baselineProfileId,
|
|
'baseline_snapshot_id' => $baselineSnapshotId,
|
|
'compare_operation_run_id' => $compareOperationRunId,
|
|
'inventory_sync_run_id' => $inventorySyncRunId,
|
|
],
|
|
];
|
|
}
|
|
|
|
private function buildRoleDefinitionEvidencePayload(
|
|
Tenant $tenant,
|
|
?int $baselinePolicyVersionId,
|
|
?int $currentPolicyVersionId,
|
|
array $baselineMeta,
|
|
array $currentMeta,
|
|
string $diffKind,
|
|
?array $roleDefinitionDiff = null,
|
|
): array {
|
|
$baselineVersion = $this->resolveRoleDefinitionVersion($tenant, $baselinePolicyVersionId);
|
|
$currentVersion = $this->resolveRoleDefinitionVersion($tenant, $currentPolicyVersionId);
|
|
$baselineNormalized = is_array($roleDefinitionDiff['baseline'] ?? null)
|
|
? $roleDefinitionDiff['baseline']
|
|
: $this->fallbackRoleDefinitionNormalized($baselineVersion, $baselineMeta);
|
|
$currentNormalized = is_array($roleDefinitionDiff['current'] ?? null)
|
|
? $roleDefinitionDiff['current']
|
|
: $this->fallbackRoleDefinitionNormalized($currentVersion, $currentMeta);
|
|
$changedKeys = is_array($roleDefinitionDiff['changed_keys'] ?? null)
|
|
? array_values(array_filter($roleDefinitionDiff['changed_keys'], 'is_string'))
|
|
: $this->roleDefinitionChangedKeys($baselineNormalized, $currentNormalized);
|
|
$metadataKeys = is_array($roleDefinitionDiff['metadata_keys'] ?? null)
|
|
? array_values(array_filter($roleDefinitionDiff['metadata_keys'], 'is_string'))
|
|
: array_values(array_diff($changedKeys, $this->roleDefinitionPermissionKeys($changedKeys)));
|
|
$permissionKeys = is_array($roleDefinitionDiff['permission_keys'] ?? null)
|
|
? array_values(array_filter($roleDefinitionDiff['permission_keys'], 'is_string'))
|
|
: $this->roleDefinitionPermissionKeys($changedKeys);
|
|
$resolvedDiffKind = is_string($roleDefinitionDiff['diff_kind'] ?? null)
|
|
? (string) $roleDefinitionDiff['diff_kind']
|
|
: $diffKind;
|
|
|
|
return [
|
|
'diff_kind' => $resolvedDiffKind,
|
|
'diff_fingerprint' => is_string($roleDefinitionDiff['diff_fingerprint'] ?? null)
|
|
? (string) $roleDefinitionDiff['diff_fingerprint']
|
|
: hash(
|
|
'sha256',
|
|
json_encode([
|
|
'diff_kind' => $resolvedDiffKind,
|
|
'changed_keys' => $changedKeys,
|
|
'baseline' => $baselineNormalized,
|
|
'current' => $currentNormalized,
|
|
], JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE),
|
|
),
|
|
'changed_keys' => $changedKeys,
|
|
'metadata_keys' => $metadataKeys,
|
|
'permission_keys' => $permissionKeys,
|
|
'baseline' => [
|
|
'normalized' => $baselineNormalized,
|
|
'is_built_in' => data_get($baselineMeta, 'rbac.is_built_in', data_get($baselineMeta, 'is_built_in')),
|
|
'role_permission_count' => data_get($baselineMeta, 'rbac.role_permission_count', data_get($baselineMeta, 'role_permission_count')),
|
|
],
|
|
'current' => [
|
|
'normalized' => $currentNormalized,
|
|
'is_built_in' => data_get($currentMeta, 'rbac.is_built_in', data_get($currentMeta, 'is_built_in')),
|
|
'role_permission_count' => data_get($currentMeta, 'rbac.role_permission_count', data_get($currentMeta, 'role_permission_count')),
|
|
],
|
|
];
|
|
}
|
|
|
|
private function resolveRoleDefinitionVersion(Tenant $tenant, ?int $policyVersionId): ?PolicyVersion
|
|
{
|
|
if ($policyVersionId === null) {
|
|
return null;
|
|
}
|
|
|
|
return PolicyVersion::query()
|
|
->where('tenant_id', (int) $tenant->getKey())
|
|
->find($policyVersionId);
|
|
}
|
|
|
|
private function fallbackRoleDefinitionNormalized(?PolicyVersion $version, array $meta): array
|
|
{
|
|
if ($version instanceof PolicyVersion) {
|
|
return $this->roleDefinitionNormalizer->buildEvidenceMap(
|
|
is_array($version->snapshot) ? $version->snapshot : [],
|
|
is_string($version->platform ?? null) ? (string) $version->platform : null,
|
|
);
|
|
}
|
|
|
|
$normalized = [];
|
|
$displayName = $meta['display_name'] ?? null;
|
|
|
|
if (is_string($displayName) && trim($displayName) !== '') {
|
|
$normalized['Role definition > Display name'] = trim($displayName);
|
|
}
|
|
|
|
$isBuiltIn = data_get($meta, 'rbac.is_built_in', data_get($meta, 'is_built_in'));
|
|
if (is_bool($isBuiltIn)) {
|
|
$normalized['Role definition > Role source'] = $isBuiltIn ? 'Built-in' : 'Custom';
|
|
}
|
|
|
|
$rolePermissionCount = data_get($meta, 'rbac.role_permission_count', data_get($meta, 'role_permission_count'));
|
|
if (is_numeric($rolePermissionCount)) {
|
|
$normalized['Role definition > Permission blocks'] = (int) $rolePermissionCount;
|
|
}
|
|
|
|
return $normalized;
|
|
}
|
|
|
|
private function roleDefinitionChangedKeys(array $baselineNormalized, array $currentNormalized): array
|
|
{
|
|
$keys = array_values(array_unique(array_merge(array_keys($baselineNormalized), array_keys($currentNormalized))));
|
|
sort($keys, SORT_STRING);
|
|
|
|
return array_values(array_filter(
|
|
$keys,
|
|
fn (string $key): bool => ($baselineNormalized[$key] ?? null) !== ($currentNormalized[$key] ?? null),
|
|
));
|
|
}
|
|
|
|
private function roleDefinitionPermissionKeys(array $keys): array
|
|
{
|
|
return array_values(array_filter(
|
|
$keys,
|
|
static fn (string $key): bool => str_starts_with($key, 'Permission block '),
|
|
));
|
|
}
|
|
|
|
private function fidelityFromPolicyVersionRefs(?int $baselinePolicyVersionId, ?int $currentPolicyVersionId): string
|
|
{
|
|
if ($baselinePolicyVersionId !== null && $currentPolicyVersionId !== null) {
|
|
return 'content';
|
|
}
|
|
|
|
if ($baselinePolicyVersionId !== null || $currentPolicyVersionId !== null) {
|
|
return 'mixed';
|
|
}
|
|
|
|
return 'meta';
|
|
}
|
|
|
|
private function resolveRoleDefinitionDiff(Tenant $tenant, int $baselinePolicyVersionId, int $currentPolicyVersionId): ?array
|
|
{
|
|
$baselineVersion = $this->resolveRoleDefinitionVersion($tenant, $baselinePolicyVersionId);
|
|
$currentVersion = $this->resolveRoleDefinitionVersion($tenant, $currentPolicyVersionId);
|
|
|
|
if (! $baselineVersion instanceof PolicyVersion || ! $currentVersion instanceof PolicyVersion) {
|
|
return null;
|
|
}
|
|
|
|
return $this->roleDefinitionNormalizer->classifyDiff(
|
|
baselineSnapshot: is_array($baselineVersion->snapshot) ? $baselineVersion->snapshot : [],
|
|
currentSnapshot: is_array($currentVersion->snapshot) ? $currentVersion->snapshot : [],
|
|
platform: is_string($currentVersion->platform ?? null)
|
|
? (string) $currentVersion->platform
|
|
: (is_string($baselineVersion->platform ?? null) ? (string) $baselineVersion->platform : null),
|
|
);
|
|
}
|
|
|
|
private function severityForRoleDefinitionDiff(?array $roleDefinitionDiff): string
|
|
{
|
|
return match ($roleDefinitionDiff['diff_kind'] ?? null) {
|
|
'metadata_only' => Finding::SEVERITY_LOW,
|
|
default => Finding::SEVERITY_HIGH,
|
|
};
|
|
}
|
|
|
|
private function emptyRbacRoleDefinitionSummary(): array
|
|
{
|
|
return [
|
|
'total_compared' => 0,
|
|
'unchanged' => 0,
|
|
'modified' => 0,
|
|
'missing' => 0,
|
|
'unexpected' => 0,
|
|
];
|
|
}
|
|
|
|
private function baselineProvenanceFromMetaJsonb(array $metaJsonb): array
|
|
{
|
|
$evidence = is_array($metaJsonb['evidence'] ?? null) ? $metaJsonb['evidence'] : $metaJsonb;
|
|
$fidelity = is_string($evidence['fidelity'] ?? null) ? strtolower(trim((string) $evidence['fidelity'])) : EvidenceProvenance::FidelityMeta;
|
|
$source = is_string($evidence['source'] ?? null) ? strtolower(trim((string) $evidence['source'])) : EvidenceProvenance::SourceInventory;
|
|
$observedAt = is_string($evidence['observed_at'] ?? null) ? trim((string) $evidence['observed_at']) : null;
|
|
$observedAtCarbon = null;
|
|
|
|
if ($observedAt !== null && $observedAt !== '') {
|
|
try {
|
|
$observedAtCarbon = \Carbon\CarbonImmutable::parse($observedAt);
|
|
} catch (\Throwable) {
|
|
$observedAtCarbon = null;
|
|
}
|
|
}
|
|
|
|
$observedOperationRunId = $evidence['observed_operation_run_id'] ?? null;
|
|
|
|
return EvidenceProvenance::build(
|
|
fidelity: EvidenceProvenance::isValidFidelity($fidelity) ? $fidelity : EvidenceProvenance::FidelityMeta,
|
|
source: EvidenceProvenance::isValidSource($source) ? $source : EvidenceProvenance::SourceInventory,
|
|
observedAt: $observedAtCarbon,
|
|
observedOperationRunId: is_numeric($observedOperationRunId) ? (int) $observedOperationRunId : null,
|
|
);
|
|
}
|
|
} |