Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 4m44s
Added BaselineReadinessGate, resolution propagation, and disclosure semantics logic per Spec 385. Integrated baseline unreadiness into Customer Review Workspace and Review Packs.
517 lines
23 KiB
PHP
517 lines
23 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Services\EnvironmentReviews;
|
|
|
|
use App\Models\EvidenceSnapshot;
|
|
use App\Models\EvidenceSnapshotItem;
|
|
use App\Support\EnvironmentReviewCompletenessState;
|
|
use App\Support\Findings\FindingOutcomeSemantics;
|
|
use App\Support\Governance\Controls\ComplianceEvidenceMappingV1;
|
|
use Illuminate\Support\Arr;
|
|
use Illuminate\Support\Collection;
|
|
|
|
final class EnvironmentReviewSectionFactory
|
|
{
|
|
public function __construct(
|
|
private readonly FindingOutcomeSemantics $findingOutcomeSemantics,
|
|
private readonly ComplianceEvidenceMappingV1 $complianceEvidenceMapping,
|
|
) {}
|
|
|
|
/**
|
|
* @return list<array<string, mixed>>
|
|
*/
|
|
public function make(EvidenceSnapshot $snapshot): array
|
|
{
|
|
$items = $snapshot->items->keyBy('dimension_key');
|
|
$findingsItem = $this->item($items, 'findings_summary');
|
|
$permissionItem = $this->item($items, 'permission_posture');
|
|
$rolesItem = $this->item($items, 'entra_admin_roles');
|
|
$baselineItem = $this->item($items, 'baseline_drift_posture');
|
|
$operationsItem = $this->item($items, 'operations_summary');
|
|
|
|
$controlInterpretation = $this->complianceEvidenceMapping->interpret($snapshot, $findingsItem);
|
|
|
|
return [
|
|
$this->executiveSummarySection($snapshot, $findingsItem, $permissionItem, $rolesItem, $baselineItem, $operationsItem),
|
|
$controlInterpretation['section'],
|
|
$this->openRisksSection($findingsItem),
|
|
$this->acceptedRisksSection($findingsItem),
|
|
$this->permissionPostureSection($permissionItem, $rolesItem),
|
|
$this->baselineDriftSection($baselineItem),
|
|
$this->operationsHealthSection($operationsItem),
|
|
];
|
|
}
|
|
|
|
private function executiveSummarySection(
|
|
EvidenceSnapshot $snapshot,
|
|
?EvidenceSnapshotItem $findingsItem,
|
|
?EvidenceSnapshotItem $permissionItem,
|
|
?EvidenceSnapshotItem $rolesItem,
|
|
?EvidenceSnapshotItem $baselineItem,
|
|
?EvidenceSnapshotItem $operationsItem,
|
|
): array {
|
|
$findingsSummary = $this->summary($findingsItem);
|
|
$permissionSummary = $this->summary($permissionItem);
|
|
$rolesSummary = $this->summary($rolesItem);
|
|
$baselineSummary = $this->summary($baselineItem);
|
|
$operationsSummary = $this->summary($operationsItem);
|
|
$findingOutcomes = is_array($findingsSummary['outcome_counts'] ?? null) ? $findingsSummary['outcome_counts'] : [];
|
|
$findingReportBuckets = is_array($findingsSummary['report_bucket_counts'] ?? null) ? $findingsSummary['report_bucket_counts'] : [];
|
|
$riskAcceptance = is_array($findingsSummary['risk_acceptance'] ?? null) ? $findingsSummary['risk_acceptance'] : [];
|
|
$canonicalControls = is_array($findingsSummary['canonical_controls'] ?? null) ? $findingsSummary['canonical_controls'] : [];
|
|
$baselineReadiness = $this->baselineReadiness($baselineSummary);
|
|
|
|
$openCount = (int) ($findingsSummary['open_count'] ?? 0);
|
|
$findingCount = (int) ($findingsSummary['count'] ?? 0);
|
|
$driftCount = (int) ($baselineSummary['open_drift_count'] ?? 0);
|
|
$postureScore = $permissionSummary['posture_score'] ?? null;
|
|
$operationFailures = (int) ($operationsSummary['failed_count'] ?? 0);
|
|
$partialOperations = (int) ($operationsSummary['partial_count'] ?? 0);
|
|
$outcomeSummary = $this->findingOutcomeSemantics->compactOutcomeSummary($findingOutcomes);
|
|
|
|
$highlights = array_values(array_filter([
|
|
sprintf('%d open risks from %d tracked findings.', $openCount, $findingCount),
|
|
$outcomeSummary !== null ? 'Terminal outcomes: '.$outcomeSummary.'.' : null,
|
|
$postureScore !== null ? sprintf('Permission posture score is %s.', $postureScore) : 'Permission posture report is unavailable.',
|
|
sprintf('%d baseline drift findings remain open.', $driftCount),
|
|
sprintf('%d recent operations failed and %d completed with warnings.', $operationFailures, $partialOperations),
|
|
$canonicalControls !== [] ? sprintf('%d canonical controls are referenced by the findings evidence.', count($canonicalControls)) : null,
|
|
sprintf('%d risk-accepted findings are currently governed.', (int) ($riskAcceptance['valid_governed_count'] ?? 0)),
|
|
sprintf('%d privileged Entra roles are captured in the evidence basis.', (int) ($rolesSummary['role_count'] ?? 0)),
|
|
]));
|
|
|
|
return [
|
|
'section_key' => 'executive_summary',
|
|
'title' => 'Executive summary',
|
|
'sort_order' => 10,
|
|
'required' => true,
|
|
'completeness_state' => $this->maxState([
|
|
$this->state($findingsItem),
|
|
$this->state($permissionItem),
|
|
$this->state($rolesItem),
|
|
$this->state($baselineItem),
|
|
$this->state($operationsItem),
|
|
])->value,
|
|
'source_snapshot_fingerprint' => (string) $snapshot->fingerprint,
|
|
'summary_payload' => [
|
|
'finding_count' => $findingCount,
|
|
'open_risk_count' => $openCount,
|
|
'finding_outcomes' => $findingOutcomes,
|
|
'finding_report_buckets' => $findingReportBuckets,
|
|
'posture_score' => $postureScore,
|
|
'baseline_drift_count' => $driftCount,
|
|
'failed_operation_count' => $operationFailures,
|
|
'partial_operation_count' => $partialOperations,
|
|
'canonical_control_count' => count($canonicalControls),
|
|
'canonical_controls' => $canonicalControls,
|
|
'risk_acceptance' => $riskAcceptance,
|
|
'baseline_readiness' => $baselineReadiness,
|
|
],
|
|
'render_payload' => [
|
|
'highlights' => $highlights,
|
|
'next_actions' => $this->nextActions(
|
|
openCount: $openCount,
|
|
driftCount: $driftCount,
|
|
operationFailures: $operationFailures,
|
|
postureScore: is_numeric($postureScore) ? (int) $postureScore : null,
|
|
riskWarnings: (int) ($riskAcceptance['warning_count'] ?? 0),
|
|
baselineReadiness: $baselineReadiness,
|
|
),
|
|
'included_dimensions' => collect($snapshot->items)
|
|
->map(static fn (EvidenceSnapshotItem $item): array => [
|
|
'key' => (string) $item->dimension_key,
|
|
'state' => (string) $item->state,
|
|
'required' => (bool) $item->required,
|
|
])
|
|
->values()
|
|
->all(),
|
|
'artifact_sources' => $this->artifactSourceSummaries(
|
|
$findingsItem,
|
|
$permissionItem,
|
|
$rolesItem,
|
|
$baselineItem,
|
|
$operationsItem,
|
|
),
|
|
],
|
|
'measured_at' => $snapshot->generated_at,
|
|
];
|
|
}
|
|
|
|
private function openRisksSection(?EvidenceSnapshotItem $findingsItem): array
|
|
{
|
|
$summary = $this->summary($findingsItem);
|
|
$entries = collect(Arr::wrap($summary['entries'] ?? []))
|
|
->filter(static fn (mixed $entry): bool => is_array($entry) && in_array((string) ($entry['status'] ?? ''), ['open', 'new', 'triaged', 'in_progress', 'reopened'], true))
|
|
->sortByDesc(static fn (array $entry): int => match ((string) ($entry['severity'] ?? 'low')) {
|
|
'critical' => 4,
|
|
'high' => 3,
|
|
'medium' => 2,
|
|
default => 1,
|
|
})
|
|
->take(5)
|
|
->values()
|
|
->all();
|
|
|
|
return [
|
|
'section_key' => 'open_risks',
|
|
'title' => 'Open risk highlights',
|
|
'sort_order' => 20,
|
|
'required' => true,
|
|
'completeness_state' => $this->state($findingsItem)->value,
|
|
'source_snapshot_fingerprint' => $this->sourceFingerprint($findingsItem),
|
|
'summary_payload' => [
|
|
'open_count' => (int) ($summary['open_count'] ?? 0),
|
|
'severity_counts' => is_array($summary['severity_counts'] ?? null) ? $summary['severity_counts'] : [],
|
|
'canonical_controls' => $this->canonicalControlsFromEntries($entries),
|
|
],
|
|
'render_payload' => [
|
|
'entries' => $entries,
|
|
'empty_state' => empty($entries) ? 'No open risks are recorded in the anchored evidence basis.' : null,
|
|
'artifact_sources' => $this->artifactSourceSummaries($findingsItem),
|
|
],
|
|
'measured_at' => $findingsItem?->measured_at,
|
|
];
|
|
}
|
|
|
|
private function acceptedRisksSection(?EvidenceSnapshotItem $findingsItem): array
|
|
{
|
|
$summary = $this->summary($findingsItem);
|
|
$entries = collect(Arr::wrap($summary['entries'] ?? []))
|
|
->filter(static fn (mixed $entry): bool => is_array($entry) && (string) ($entry['status'] ?? '') === 'risk_accepted')
|
|
->take(5)
|
|
->values()
|
|
->all();
|
|
$riskAcceptance = is_array($summary['risk_acceptance'] ?? null) ? $summary['risk_acceptance'] : [];
|
|
|
|
return [
|
|
'section_key' => 'accepted_risks',
|
|
'title' => 'Accepted risk summary',
|
|
'sort_order' => 30,
|
|
'required' => true,
|
|
'completeness_state' => $this->state($findingsItem)->value,
|
|
'source_snapshot_fingerprint' => $this->sourceFingerprint($findingsItem),
|
|
'summary_payload' => [
|
|
'status_marked_count' => (int) ($riskAcceptance['status_marked_count'] ?? 0),
|
|
'valid_governed_count' => (int) ($riskAcceptance['valid_governed_count'] ?? 0),
|
|
'warning_count' => (int) ($riskAcceptance['warning_count'] ?? 0),
|
|
'expired_count' => (int) ($riskAcceptance['expired_count'] ?? 0),
|
|
'revoked_count' => (int) ($riskAcceptance['revoked_count'] ?? 0),
|
|
'missing_exception_count' => (int) ($riskAcceptance['missing_exception_count'] ?? 0),
|
|
'canonical_controls' => $this->canonicalControlsFromEntries($entries),
|
|
],
|
|
'render_payload' => [
|
|
'entries' => $entries,
|
|
'disclosure' => (int) ($riskAcceptance['warning_count'] ?? 0) > 0
|
|
? 'Some accepted risks need governance follow-up before stakeholder delivery.'
|
|
: 'Accepted risks are governed by the anchored evidence basis.',
|
|
'artifact_sources' => $this->artifactSourceSummaries($findingsItem),
|
|
],
|
|
'measured_at' => $findingsItem?->measured_at,
|
|
];
|
|
}
|
|
|
|
private function permissionPostureSection(?EvidenceSnapshotItem $permissionItem, ?EvidenceSnapshotItem $rolesItem): array
|
|
{
|
|
$permissionSummary = $this->summary($permissionItem);
|
|
$rolesSummary = $this->summary($rolesItem);
|
|
|
|
return [
|
|
'section_key' => 'permission_posture',
|
|
'title' => 'Permission posture',
|
|
'sort_order' => 40,
|
|
'required' => true,
|
|
'completeness_state' => $this->maxState([
|
|
$this->state($permissionItem),
|
|
$this->state($rolesItem),
|
|
])->value,
|
|
'source_snapshot_fingerprint' => $this->sourceFingerprint($permissionItem) ?? $this->sourceFingerprint($rolesItem),
|
|
'summary_payload' => [
|
|
'posture_score' => $permissionSummary['posture_score'] ?? null,
|
|
'required_count' => (int) ($permissionSummary['required_count'] ?? 0),
|
|
'granted_count' => (int) ($permissionSummary['granted_count'] ?? 0),
|
|
'role_count' => (int) ($rolesSummary['role_count'] ?? 0),
|
|
],
|
|
'render_payload' => [
|
|
'permission_payload' => is_array($permissionSummary['payload'] ?? null) ? $permissionSummary['payload'] : [],
|
|
'roles' => is_array($rolesSummary['roles'] ?? null) ? $rolesSummary['roles'] : [],
|
|
'artifact_sources' => $this->artifactSourceSummaries($permissionItem, $rolesItem),
|
|
],
|
|
'measured_at' => $permissionItem?->measured_at ?? $rolesItem?->measured_at,
|
|
];
|
|
}
|
|
|
|
private function baselineDriftSection(?EvidenceSnapshotItem $baselineItem): array
|
|
{
|
|
$summary = $this->summary($baselineItem);
|
|
$baselineReadiness = $this->baselineReadiness($summary);
|
|
$publicationBlockers = $this->baselinePublicationBlockers($baselineReadiness);
|
|
$limitations = $this->baselineLimitations($baselineReadiness);
|
|
|
|
return [
|
|
'section_key' => 'baseline_drift_posture',
|
|
'title' => 'Baseline drift posture',
|
|
'sort_order' => 50,
|
|
'required' => true,
|
|
'completeness_state' => $this->state($baselineItem)->value,
|
|
'source_snapshot_fingerprint' => $this->sourceFingerprint($baselineItem),
|
|
'summary_payload' => [
|
|
'drift_count' => (int) ($summary['drift_count'] ?? 0),
|
|
'open_drift_count' => (int) ($summary['open_drift_count'] ?? 0),
|
|
'baseline_readiness' => $baselineReadiness,
|
|
'publication_blockers' => $publicationBlockers,
|
|
'limitations' => $limitations,
|
|
],
|
|
'render_payload' => [
|
|
'disclosure' => $this->baselineDisclosure(
|
|
baselineReadiness: $baselineReadiness,
|
|
openDriftCount: (int) ($summary['open_drift_count'] ?? 0),
|
|
),
|
|
'next_actions' => $this->baselineNextActions($baselineReadiness),
|
|
'artifact_sources' => $this->artifactSourceSummaries($baselineItem),
|
|
],
|
|
'measured_at' => $baselineItem?->measured_at,
|
|
];
|
|
}
|
|
|
|
private function operationsHealthSection(?EvidenceSnapshotItem $operationsItem): array
|
|
{
|
|
$summary = $this->summary($operationsItem);
|
|
|
|
return [
|
|
'section_key' => 'operations_health',
|
|
'title' => 'Operations health',
|
|
'sort_order' => 60,
|
|
'required' => true,
|
|
'completeness_state' => $this->state($operationsItem)->value,
|
|
'source_snapshot_fingerprint' => $this->sourceFingerprint($operationsItem),
|
|
'summary_payload' => [
|
|
'operation_count' => (int) ($summary['operation_count'] ?? 0),
|
|
'failed_count' => (int) ($summary['failed_count'] ?? 0),
|
|
'partial_count' => (int) ($summary['partial_count'] ?? 0),
|
|
],
|
|
'render_payload' => [
|
|
'entries' => array_values(array_slice(Arr::wrap($summary['entries'] ?? []), 0, 10)),
|
|
'artifact_sources' => $this->artifactSourceSummaries($operationsItem),
|
|
],
|
|
'measured_at' => $operationsItem?->measured_at,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return list<array{label: string, source_family: string, source_kind: string, source_target_kind: string, control_key: ?string, detector_key: ?string}>
|
|
*/
|
|
private function artifactSourceSummaries(?EvidenceSnapshotItem ...$items): array
|
|
{
|
|
return collect($items)
|
|
->filter(static fn (?EvidenceSnapshotItem $item): bool => $item instanceof EvidenceSnapshotItem)
|
|
->map(function (EvidenceSnapshotItem $item): array {
|
|
$descriptor = $item->artifactSourceDescriptor();
|
|
|
|
return [
|
|
'label' => (string) $item->dimension_key,
|
|
'source_family' => $descriptor->sourceFamily,
|
|
'source_kind' => $descriptor->sourceKind,
|
|
'source_target_kind' => $descriptor->sourceTargetKind,
|
|
'control_key' => $descriptor->controlKey,
|
|
'detector_key' => $descriptor->detectorKey,
|
|
];
|
|
})
|
|
->unique(static fn (array $item): string => implode('|', [
|
|
$item['source_family'],
|
|
$item['source_kind'],
|
|
$item['source_target_kind'],
|
|
$item['control_key'] ?? '',
|
|
$item['detector_key'] ?? '',
|
|
]))
|
|
->values()
|
|
->all();
|
|
}
|
|
|
|
private function item(Collection $items, string $key): ?EvidenceSnapshotItem
|
|
{
|
|
$item = $items->get($key);
|
|
|
|
return $item instanceof EvidenceSnapshotItem ? $item : null;
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function summary(?EvidenceSnapshotItem $item): array
|
|
{
|
|
return is_array($item?->summary_payload) ? $item->summary_payload : [];
|
|
}
|
|
|
|
private function state(?EvidenceSnapshotItem $item): EnvironmentReviewCompletenessState
|
|
{
|
|
return EnvironmentReviewCompletenessState::tryFrom((string) $item?->state)
|
|
?? EnvironmentReviewCompletenessState::Missing;
|
|
}
|
|
|
|
private function sourceFingerprint(?EvidenceSnapshotItem $item): ?string
|
|
{
|
|
$fingerprint = $item?->source_fingerprint;
|
|
|
|
return is_string($fingerprint) && $fingerprint !== '' ? $fingerprint : null;
|
|
}
|
|
|
|
/**
|
|
* @param list<array<string, mixed>> $entries
|
|
* @return list<array<string, mixed>>
|
|
*/
|
|
private function canonicalControlsFromEntries(array $entries): array
|
|
{
|
|
return collect($entries)
|
|
->map(static fn (array $entry): mixed => data_get($entry, 'canonical_control_resolution.control'))
|
|
->filter(static fn (mixed $control): bool => is_array($control) && filled($control['control_key'] ?? null))
|
|
->unique(static fn (array $control): string => (string) $control['control_key'])
|
|
->values()
|
|
->all();
|
|
}
|
|
|
|
/**
|
|
* @param array<int, EnvironmentReviewCompletenessState> $states
|
|
*/
|
|
private function maxState(array $states): EnvironmentReviewCompletenessState
|
|
{
|
|
if (in_array(EnvironmentReviewCompletenessState::Missing, $states, true)) {
|
|
return EnvironmentReviewCompletenessState::Missing;
|
|
}
|
|
|
|
if (in_array(EnvironmentReviewCompletenessState::Stale, $states, true)) {
|
|
return EnvironmentReviewCompletenessState::Stale;
|
|
}
|
|
|
|
if (in_array(EnvironmentReviewCompletenessState::Partial, $states, true)) {
|
|
return EnvironmentReviewCompletenessState::Partial;
|
|
}
|
|
|
|
return EnvironmentReviewCompletenessState::Complete;
|
|
}
|
|
|
|
/**
|
|
* @return list<string>
|
|
*/
|
|
private function nextActions(
|
|
int $openCount,
|
|
int $driftCount,
|
|
int $operationFailures,
|
|
?int $postureScore,
|
|
int $riskWarnings,
|
|
array $baselineReadiness = [],
|
|
): array {
|
|
$actions = [];
|
|
|
|
if ($openCount > 0) {
|
|
$actions[] = 'Review the highest-severity open findings with the tenant and confirm ownership.';
|
|
}
|
|
|
|
if ($riskWarnings > 0) {
|
|
$actions[] = 'Reconcile accepted-risk governance records before external delivery.';
|
|
}
|
|
|
|
if ($postureScore !== null && $postureScore < 80) {
|
|
$actions[] = 'Prioritize missing permissions or posture controls that materially affect review confidence.';
|
|
}
|
|
|
|
if ($driftCount > 0) {
|
|
$actions[] = 'Schedule remediation for recurring baseline drift to reduce repeated review findings.';
|
|
}
|
|
|
|
foreach ($this->baselineNextActions($baselineReadiness) as $baselineAction) {
|
|
$actions[] = $baselineAction;
|
|
}
|
|
|
|
if ($operationFailures > 0) {
|
|
$actions[] = 'Inspect recent failed operations to confirm tenant management workflows are stable.';
|
|
}
|
|
|
|
if ($actions === []) {
|
|
$actions[] = 'No immediate corrective action is required beyond the normal review cadence.';
|
|
}
|
|
|
|
return $actions;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $summary
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function baselineReadiness(array $summary): array
|
|
{
|
|
return is_array($summary['baseline_readiness'] ?? null) ? $summary['baseline_readiness'] : [];
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $baselineReadiness
|
|
* @return list<string>
|
|
*/
|
|
private function baselinePublicationBlockers(array $baselineReadiness): array
|
|
{
|
|
return collect($baselineReadiness['publication_blockers'] ?? [])
|
|
->filter(static fn (mixed $blocker): bool => is_string($blocker) && trim($blocker) !== '')
|
|
->values()
|
|
->all();
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $baselineReadiness
|
|
* @return list<array<string, mixed>>
|
|
*/
|
|
private function baselineLimitations(array $baselineReadiness): array
|
|
{
|
|
return collect($baselineReadiness['limitations'] ?? [])
|
|
->filter(static fn (mixed $limitation): bool => is_array($limitation) && is_string($limitation['code'] ?? null))
|
|
->values()
|
|
->all();
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $baselineReadiness
|
|
*/
|
|
private function baselineDisclosure(array $baselineReadiness, int $openDriftCount): string
|
|
{
|
|
$blockerCount = count($this->baselinePublicationBlockers($baselineReadiness));
|
|
$limitationCount = count($this->baselineLimitations($baselineReadiness));
|
|
|
|
if ($blockerCount > 0) {
|
|
return sprintf('%d baseline readiness blocker(s) must be resolved before customer-ready publication.', $blockerCount);
|
|
}
|
|
|
|
if ($limitationCount > 0) {
|
|
return sprintf('%d baseline limitation(s) qualify the customer-ready claim and must remain disclosed.', $limitationCount);
|
|
}
|
|
|
|
if ($openDriftCount > 0) {
|
|
return 'Baseline drift remains visible in this review and should be discussed as hardening work.';
|
|
}
|
|
|
|
$claim = (string) ($baselineReadiness['customer_safe_claim'] ?? '');
|
|
|
|
if ($claim === 'customer_ready') {
|
|
return 'Baseline compare readiness supports the customer-ready no-drift claim.';
|
|
}
|
|
|
|
return 'No open baseline drift findings are present in the anchored evidence basis.';
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $baselineReadiness
|
|
* @return list<string>
|
|
*/
|
|
private function baselineNextActions(array $baselineReadiness): array
|
|
{
|
|
$action = (string) ($baselineReadiness['next_action'] ?? '');
|
|
|
|
return match ($action) {
|
|
'open_baseline_subject_resolution' => ['Resolve baseline subject identity and provider-resource decisions before publication.'],
|
|
'open_evidence_basis' => ['Refresh baseline compare evidence before relying on the review output.'],
|
|
'open_operation_proof' => ['Inspect the latest baseline compare operation proof and rerun if needed.'],
|
|
'review_output_limitations' => ['Review and disclose baseline limitations before customer delivery.'],
|
|
default => [],
|
|
};
|
|
}
|
|
}
|