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.
1442 lines
56 KiB
PHP
1442 lines
56 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Jobs;
|
|
|
|
use App\Models\EnvironmentReview;
|
|
use App\Models\EvidenceSnapshot;
|
|
use App\Models\Finding;
|
|
use App\Models\ManagedEnvironment;
|
|
use App\Models\OperationRun;
|
|
use App\Models\ReviewPack;
|
|
use App\Services\Intune\SecretClassificationService;
|
|
use App\Services\OperationRunService;
|
|
use App\Services\ReviewPackService;
|
|
use App\Support\OperationRunOutcome;
|
|
use App\Support\OperationRunStatus;
|
|
use App\Support\RedactionIntegrity;
|
|
use App\Support\ReviewPacks\ReviewPackOutputReadiness;
|
|
use App\Support\ReviewPackStatus;
|
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
|
use Illuminate\Foundation\Queue\Queueable;
|
|
use Illuminate\Support\Collection;
|
|
use Illuminate\Support\Facades\Log;
|
|
use Illuminate\Support\Facades\Storage;
|
|
use Throwable;
|
|
use ZipArchive;
|
|
|
|
class GenerateReviewPackJob implements ShouldQueue
|
|
{
|
|
use Queueable;
|
|
|
|
public int $timeout = 240;
|
|
|
|
public bool $failOnTimeout = true;
|
|
|
|
public function __construct(
|
|
public int $reviewPackId,
|
|
public int $operationRunId,
|
|
) {}
|
|
|
|
public function handle(OperationRunService $operationRunService): void
|
|
{
|
|
$reviewPack = ReviewPack::query()->with(['tenant', 'evidenceSnapshot.items', 'environmentReview.sections'])->find($this->reviewPackId);
|
|
$operationRun = OperationRun::query()->find($this->operationRunId);
|
|
|
|
if (! $reviewPack instanceof ReviewPack || ! $operationRun instanceof OperationRun) {
|
|
Log::warning('GenerateReviewPackJob: missing records', [
|
|
'review_pack_id' => $this->reviewPackId,
|
|
'operation_run_id' => $this->operationRunId,
|
|
]);
|
|
|
|
return;
|
|
}
|
|
|
|
$tenant = $reviewPack->tenant;
|
|
|
|
if (! $tenant instanceof ManagedEnvironment) {
|
|
$this->markFailed($reviewPack, $operationRun, $operationRunService, 'tenant_not_found', 'ManagedEnvironment not found');
|
|
|
|
return;
|
|
}
|
|
|
|
$snapshot = $reviewPack->evidenceSnapshot;
|
|
|
|
if (! $snapshot instanceof EvidenceSnapshot) {
|
|
$this->markFailed($reviewPack, $operationRun, $operationRunService, 'missing_snapshot', 'Evidence snapshot not found');
|
|
|
|
return;
|
|
}
|
|
|
|
// Mark running via OperationRunService (auto-sets started_at)
|
|
$operationRunService->updateRun($operationRun, OperationRunStatus::Running->value);
|
|
$reviewPack->update(['status' => ReviewPackStatus::Generating->value]);
|
|
|
|
try {
|
|
$this->executeGeneration($reviewPack, $operationRun, $tenant, $snapshot, $operationRunService);
|
|
} catch (Throwable $e) {
|
|
$this->markFailed($reviewPack, $operationRun, $operationRunService, 'generation_error', $e->getMessage());
|
|
|
|
throw $e;
|
|
}
|
|
}
|
|
|
|
private function executeGeneration(ReviewPack $reviewPack, OperationRun $operationRun, ManagedEnvironment $tenant, EvidenceSnapshot $snapshot, OperationRunService $operationRunService): void
|
|
{
|
|
$review = $reviewPack->environmentReview;
|
|
|
|
if ($review instanceof EnvironmentReview) {
|
|
$this->executeReviewDerivedGeneration($reviewPack, $review, $operationRun, $tenant, $snapshot, $operationRunService);
|
|
|
|
return;
|
|
}
|
|
|
|
$options = $reviewPack->options ?? [];
|
|
$includePii = (bool) ($options['include_pii'] ?? true);
|
|
$includeOperations = (bool) ($options['include_operations'] ?? true);
|
|
$items = $snapshot->items->keyBy('dimension_key');
|
|
$findingsPayload = $this->itemSummaryPayload($items->get('findings_summary'));
|
|
$permissionPosturePayload = $this->itemSummaryPayload($items->get('permission_posture'));
|
|
$entraRolesPayload = $this->itemSummaryPayload($items->get('entra_admin_roles'));
|
|
$operationsPayload = $this->itemSummaryPayload($items->get('operations_summary'));
|
|
$riskAcceptance = is_array($snapshot->summary['risk_acceptance'] ?? null)
|
|
? $snapshot->summary['risk_acceptance']
|
|
: (is_array($findingsPayload['risk_acceptance'] ?? null) ? $findingsPayload['risk_acceptance'] : []);
|
|
|
|
$findings = collect(is_array($findingsPayload['entries'] ?? null) ? $findingsPayload['entries'] : []);
|
|
$recentOperations = collect($includeOperations && is_array($operationsPayload['entries'] ?? null) ? $operationsPayload['entries'] : []);
|
|
$hardening = is_array($snapshot->summary['hardening'] ?? null) ? $snapshot->summary['hardening'] : [];
|
|
$dataFreshness = $this->computeDataFreshness($items);
|
|
|
|
// 6. Build file map
|
|
$fileMap = $this->buildFileMap(
|
|
findings: $findings,
|
|
hardening: $hardening,
|
|
permissionPosture: is_array($permissionPosturePayload['payload'] ?? null) ? $permissionPosturePayload['payload'] : [],
|
|
entraAdminRoles: ['roles' => is_array($entraRolesPayload['roles'] ?? null) ? $entraRolesPayload['roles'] : []],
|
|
recentOperations: $recentOperations,
|
|
tenant: $tenant,
|
|
snapshot: $snapshot,
|
|
dataFreshness: $dataFreshness,
|
|
riskAcceptance: $riskAcceptance,
|
|
includePii: $includePii,
|
|
includeOperations: $includeOperations,
|
|
);
|
|
$fileCount = count($fileMap);
|
|
|
|
$operationRun = $operationRunService->updateRun(
|
|
$operationRun,
|
|
status: OperationRunStatus::Running->value,
|
|
outcome: OperationRunOutcome::Pending->value,
|
|
summaryCounts: [
|
|
'total' => $fileCount,
|
|
'processed' => 0,
|
|
'succeeded' => 0,
|
|
'failed' => 0,
|
|
'skipped' => 0,
|
|
],
|
|
);
|
|
|
|
// 7. Assemble ZIP
|
|
$tempFile = tempnam(sys_get_temp_dir(), 'review-pack-');
|
|
|
|
try {
|
|
$this->assembleZip($tempFile, $fileMap, function () use ($operationRunService, &$operationRun): void {
|
|
$operationRun = $operationRunService->incrementSummaryCounts($operationRun, [
|
|
'processed' => 1,
|
|
'created' => 1,
|
|
]);
|
|
});
|
|
|
|
// 8. Compute SHA-256
|
|
$sha256 = hash_file('sha256', $tempFile);
|
|
$fileSize = filesize($tempFile);
|
|
|
|
// 9. Store on exports disk
|
|
$filePath = sprintf(
|
|
'review-packs/%s/pack-%d-%s.zip',
|
|
$tenant->external_id,
|
|
(int) $reviewPack->getKey(),
|
|
now()->format('Y-m-d-His'),
|
|
);
|
|
|
|
Storage::disk('exports')->put($filePath, file_get_contents($tempFile));
|
|
} finally {
|
|
if (file_exists($tempFile)) {
|
|
unlink($tempFile);
|
|
}
|
|
}
|
|
|
|
// 10. Compute fingerprint
|
|
$fingerprint = app(ReviewPackService::class)->computeFingerprint($tenant, $options);
|
|
|
|
// 11. Compute summary
|
|
$summary = [
|
|
'finding_count' => (int) ($snapshot->summary['finding_count'] ?? $findings->count()),
|
|
'report_count' => (int) ($snapshot->summary['report_count'] ?? 0),
|
|
'operation_count' => $recentOperations->count(),
|
|
'data_freshness' => $dataFreshness,
|
|
'risk_acceptance' => $riskAcceptance,
|
|
'evidence_resolution' => [
|
|
'outcome' => 'resolved',
|
|
'snapshot_id' => (int) $snapshot->getKey(),
|
|
'snapshot_fingerprint' => (string) $snapshot->fingerprint,
|
|
'completeness_state' => (string) $snapshot->completeness_state,
|
|
],
|
|
];
|
|
|
|
// 12. Update ReviewPack
|
|
$retentionDays = (int) config('tenantpilot.review_pack.retention_days', 90);
|
|
$reviewPack->update([
|
|
'status' => ReviewPackStatus::Ready->value,
|
|
'evidence_snapshot_id' => (int) $snapshot->getKey(),
|
|
'fingerprint' => $fingerprint,
|
|
'sha256' => $sha256,
|
|
'file_size' => $fileSize,
|
|
'file_path' => $filePath,
|
|
'file_disk' => 'exports',
|
|
'generated_at' => now(),
|
|
'expires_at' => now()->addDays($retentionDays),
|
|
'summary' => $summary,
|
|
]);
|
|
|
|
// 13. Mark OperationRun completed (auto-sends OperationRunCompleted notification)
|
|
$operationRunService->updateRun(
|
|
$operationRun,
|
|
status: OperationRunStatus::Completed->value,
|
|
outcome: OperationRunOutcome::Succeeded->value,
|
|
summaryCounts: [
|
|
'total' => $fileCount,
|
|
'processed' => $fileCount,
|
|
'created' => $fileCount,
|
|
'finding_count' => (int) ($summary['finding_count'] ?? 0),
|
|
'report_count' => (int) ($summary['report_count'] ?? 0),
|
|
'operation_count' => (int) ($summary['operation_count'] ?? 0),
|
|
],
|
|
);
|
|
}
|
|
|
|
private function executeReviewDerivedGeneration(
|
|
ReviewPack $reviewPack,
|
|
EnvironmentReview $review,
|
|
OperationRun $operationRun,
|
|
ManagedEnvironment $tenant,
|
|
EvidenceSnapshot $snapshot,
|
|
OperationRunService $operationRunService,
|
|
): void {
|
|
$options = $reviewPack->options ?? [];
|
|
$includePii = (bool) ($options['include_pii'] ?? true);
|
|
$includeOperations = (bool) ($options['include_operations'] ?? true);
|
|
$generatedAt = now();
|
|
$sections = $this->reviewDerivedSections($review, $includeOperations);
|
|
|
|
$fileMap = $this->buildReviewDerivedFileMap(
|
|
reviewPack: $reviewPack,
|
|
review: $review,
|
|
tenant: $tenant,
|
|
snapshot: $snapshot,
|
|
sections: $sections,
|
|
includePii: $includePii,
|
|
includeOperations: $includeOperations,
|
|
generatedAt: $generatedAt,
|
|
);
|
|
$fileCount = count($fileMap);
|
|
|
|
$operationRun = $operationRunService->updateRun(
|
|
$operationRun,
|
|
status: OperationRunStatus::Running->value,
|
|
outcome: OperationRunOutcome::Pending->value,
|
|
summaryCounts: [
|
|
'total' => $fileCount,
|
|
'processed' => 0,
|
|
'succeeded' => 0,
|
|
'failed' => 0,
|
|
'skipped' => 0,
|
|
],
|
|
);
|
|
|
|
$tempFile = tempnam(sys_get_temp_dir(), 'review-pack-');
|
|
|
|
try {
|
|
$this->assembleZip($tempFile, $fileMap, function () use ($operationRunService, &$operationRun): void {
|
|
$operationRun = $operationRunService->incrementSummaryCounts($operationRun, [
|
|
'processed' => 1,
|
|
'created' => 1,
|
|
]);
|
|
});
|
|
|
|
$sha256 = hash_file('sha256', $tempFile);
|
|
$fileSize = filesize($tempFile);
|
|
$filePath = sprintf(
|
|
'review-packs/%s/review-%d-pack-%d-%s.zip',
|
|
$tenant->external_id,
|
|
(int) $review->getKey(),
|
|
(int) $reviewPack->getKey(),
|
|
$generatedAt->format('Y-m-d-His'),
|
|
);
|
|
|
|
Storage::disk('exports')->put($filePath, file_get_contents($tempFile));
|
|
} finally {
|
|
if (file_exists($tempFile)) {
|
|
unlink($tempFile);
|
|
}
|
|
}
|
|
|
|
$fingerprint = app(ReviewPackService::class)->computeFingerprintForReview($review, $options);
|
|
$reviewSummary = is_array($review->summary) ? $review->summary : [];
|
|
$summary = $this->reviewDerivedSummaryPayload(
|
|
reviewPack: $reviewPack,
|
|
review: $review,
|
|
snapshot: $snapshot,
|
|
sections: $sections,
|
|
includePii: $includePii,
|
|
includeOperations: $includeOperations,
|
|
hasReadyExport: true,
|
|
);
|
|
|
|
$retentionDays = (int) config('tenantpilot.review_pack.retention_days', 90);
|
|
$reviewPack->update([
|
|
'status' => ReviewPackStatus::Ready->value,
|
|
'evidence_snapshot_id' => (int) $snapshot->getKey(),
|
|
'fingerprint' => $fingerprint,
|
|
'sha256' => $sha256,
|
|
'file_size' => $fileSize,
|
|
'file_path' => $filePath,
|
|
'file_disk' => 'exports',
|
|
'generated_at' => $generatedAt,
|
|
'expires_at' => $generatedAt->copy()->addDays($retentionDays),
|
|
'summary' => $summary,
|
|
]);
|
|
|
|
$review->update([
|
|
'current_export_review_pack_id' => (int) $reviewPack->getKey(),
|
|
'summary' => array_merge($reviewSummary, [
|
|
'has_ready_export' => true,
|
|
'current_export_review_pack_id' => (int) $reviewPack->getKey(),
|
|
]),
|
|
]);
|
|
|
|
$operationRunService->updateRun(
|
|
$operationRun,
|
|
status: OperationRunStatus::Completed->value,
|
|
outcome: OperationRunOutcome::Succeeded->value,
|
|
summaryCounts: [
|
|
'total' => $fileCount,
|
|
'processed' => $fileCount,
|
|
'created' => 1,
|
|
'finding_count' => (int) ($summary['finding_count'] ?? 0),
|
|
'report_count' => (int) ($summary['report_count'] ?? 0),
|
|
'operation_count' => (int) ($summary['operation_count'] ?? 0),
|
|
'errors_recorded' => 0,
|
|
],
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @return array<string, ?string>
|
|
*/
|
|
private function computeDataFreshness($items): array
|
|
{
|
|
return [
|
|
'permission_posture' => $items->get('permission_posture')?->freshness_at?->toIso8601String(),
|
|
'entra_admin_roles' => $items->get('entra_admin_roles')?->freshness_at?->toIso8601String(),
|
|
'findings' => $items->get('findings_summary')?->freshness_at?->toIso8601String(),
|
|
'hardening' => $items->get('baseline_drift_posture')?->freshness_at?->toIso8601String(),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Build the file map for the ZIP contents.
|
|
*
|
|
* @return array<string, string>
|
|
*/
|
|
private function buildFileMap(
|
|
$findings,
|
|
array $hardening,
|
|
array $permissionPosture,
|
|
array $entraAdminRoles,
|
|
$recentOperations,
|
|
ManagedEnvironment $tenant,
|
|
EvidenceSnapshot $snapshot,
|
|
array $dataFreshness,
|
|
array $riskAcceptance,
|
|
bool $includePii,
|
|
bool $includeOperations,
|
|
): array {
|
|
$files = [];
|
|
|
|
// findings.csv
|
|
$files['findings.csv'] = $this->buildFindingsCsv($findings, $includePii);
|
|
|
|
// hardening.json
|
|
$files['hardening.json'] = json_encode($hardening, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR);
|
|
|
|
// metadata.json
|
|
$metadataPayload = [
|
|
'version' => '1.0',
|
|
'managed_environment_id' => $tenant->external_id,
|
|
'tenant_name' => $includePii ? $tenant->name : '[REDACTED]',
|
|
'generated_at' => now()->toIso8601String(),
|
|
'evidence_snapshot' => [
|
|
'id' => (int) $snapshot->getKey(),
|
|
'fingerprint' => (string) $snapshot->fingerprint,
|
|
'completeness_state' => (string) $snapshot->completeness_state,
|
|
'generated_at' => $snapshot->generated_at?->toIso8601String(),
|
|
],
|
|
'redaction_integrity' => [
|
|
'protected_values_hidden' => true,
|
|
'note' => RedactionIntegrity::protectedValueNote(),
|
|
],
|
|
'options' => [
|
|
'include_pii' => $includePii,
|
|
'include_operations' => $includeOperations,
|
|
],
|
|
];
|
|
$files['metadata.json'] = json_encode(
|
|
$this->redactReportPayload($metadataPayload, $includePii),
|
|
JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR,
|
|
);
|
|
|
|
// operations.csv
|
|
$files['operations.csv'] = $this->buildOperationsCsv($recentOperations, $includePii);
|
|
|
|
// reports/entra_admin_roles.json
|
|
$files['reports/entra_admin_roles.json'] = json_encode(
|
|
$this->redactReportPayload($entraAdminRoles, $includePii),
|
|
JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR,
|
|
);
|
|
|
|
// reports/permission_posture.json
|
|
$files['reports/permission_posture.json'] = json_encode(
|
|
$this->redactReportPayload($permissionPosture, $includePii),
|
|
JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR,
|
|
);
|
|
|
|
// summary.json
|
|
$summaryPayload = [
|
|
'data_freshness' => $dataFreshness,
|
|
'finding_count' => $findings->count(),
|
|
'report_count' => count(array_filter([$permissionPosture, $entraAdminRoles], static fn (array $payload): bool => $payload !== [])),
|
|
'operation_count' => $recentOperations->count(),
|
|
'risk_acceptance' => $riskAcceptance,
|
|
'snapshot_id' => (int) $snapshot->getKey(),
|
|
];
|
|
$files['summary.json'] = json_encode(
|
|
$this->redactReportPayload($summaryPayload, $includePii),
|
|
JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR,
|
|
);
|
|
|
|
return $files;
|
|
}
|
|
|
|
/**
|
|
* Build findings CSV content.
|
|
*
|
|
* @param \Illuminate\Database\Eloquent\Collection<int, Finding> $findings
|
|
*/
|
|
private function buildFindingsCsv($findings, bool $includePii): string
|
|
{
|
|
$handle = fopen('php://temp', 'r+');
|
|
$this->writeCsvRow($handle, ['id', 'finding_type', 'severity', 'status', 'title', 'description', 'created_at', 'updated_at']);
|
|
|
|
foreach ($findings as $finding) {
|
|
$row = $finding instanceof Finding
|
|
? [
|
|
$finding->id,
|
|
$finding->finding_type,
|
|
$finding->severity,
|
|
$finding->status,
|
|
$includePii ? ($finding->title ?? '') : '[REDACTED]',
|
|
$includePii ? ($finding->description ?? '') : '[REDACTED]',
|
|
$finding->created_at?->toIso8601String(),
|
|
$finding->updated_at?->toIso8601String(),
|
|
]
|
|
: [
|
|
$finding['id'] ?? '',
|
|
$finding['finding_type'] ?? '',
|
|
$finding['severity'] ?? '',
|
|
$finding['status'] ?? '',
|
|
$includePii ? ($finding['title'] ?? '') : '[REDACTED]',
|
|
$includePii ? ($finding['description'] ?? '') : '[REDACTED]',
|
|
$finding['created_at'] ?? '',
|
|
$finding['updated_at'] ?? '',
|
|
];
|
|
|
|
$this->writeCsvRow($handle, [
|
|
...$row,
|
|
]);
|
|
}
|
|
|
|
rewind($handle);
|
|
$content = stream_get_contents($handle);
|
|
fclose($handle);
|
|
|
|
return $content;
|
|
}
|
|
|
|
/**
|
|
* Build operations CSV content.
|
|
*/
|
|
private function buildOperationsCsv($operations, bool $includePii): string
|
|
{
|
|
$handle = fopen('php://temp', 'r+');
|
|
$this->writeCsvRow($handle, ['id', 'type', 'status', 'outcome', 'initiator', 'started_at', 'completed_at']);
|
|
|
|
foreach ($operations as $operation) {
|
|
$row = $operation instanceof OperationRun
|
|
? [
|
|
$operation->id,
|
|
$operation->type,
|
|
$operation->status,
|
|
$operation->outcome,
|
|
$includePii ? ($operation->user?->name ?? '') : '[REDACTED]',
|
|
$operation->started_at?->toIso8601String(),
|
|
$operation->completed_at?->toIso8601String(),
|
|
]
|
|
: [
|
|
$operation['id'] ?? '',
|
|
$operation['type'] ?? '',
|
|
$operation['status'] ?? '',
|
|
$operation['outcome'] ?? '',
|
|
$includePii ? ($operation['initiator_name'] ?? '') : '[REDACTED]',
|
|
$operation['started_at'] ?? '',
|
|
$operation['completed_at'] ?? '',
|
|
];
|
|
|
|
$this->writeCsvRow($handle, [
|
|
...$row,
|
|
]);
|
|
}
|
|
|
|
rewind($handle);
|
|
$content = stream_get_contents($handle);
|
|
fclose($handle);
|
|
|
|
return $content;
|
|
}
|
|
|
|
/**
|
|
* @param resource $handle
|
|
* @param array<int, mixed> $row
|
|
*/
|
|
private function writeCsvRow($handle, array $row): void
|
|
{
|
|
fputcsv($handle, $row, ',', '"', '\\');
|
|
}
|
|
|
|
/**
|
|
* Redact PII from a report payload.
|
|
*
|
|
* @param array<string, mixed> $payload
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function redactReportPayload(array $payload, bool $includePii): array
|
|
{
|
|
$payload = $this->redactProtectedPayload($payload);
|
|
|
|
if (! $includePii) {
|
|
$payload = $this->redactBaselineReadinessDiagnostics($payload);
|
|
$payload = $this->redactCustomerSafeIdentifiers($payload);
|
|
$payload = $this->redactArrayPii($payload);
|
|
}
|
|
|
|
return $payload;
|
|
}
|
|
|
|
/**
|
|
* Recursively redact PII fields from an array.
|
|
*
|
|
* @param array<string, mixed> $data
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function redactArrayPii(array $data): array
|
|
{
|
|
$piiKeys = [
|
|
'displayName',
|
|
'display_name',
|
|
'userPrincipalName',
|
|
'user_principal_name',
|
|
'email',
|
|
'mail',
|
|
'tenant_name',
|
|
'tenant_label',
|
|
'customer_name',
|
|
'owner_label',
|
|
'owner_name',
|
|
'actor_label',
|
|
'actor_name',
|
|
'initiator_name',
|
|
'requested_by',
|
|
'approved_by',
|
|
];
|
|
|
|
foreach ($data as $key => $value) {
|
|
if (is_string($key) && in_array($key, $piiKeys, true)) {
|
|
$data[$key] = '[REDACTED]';
|
|
} elseif (is_array($value)) {
|
|
$data[$key] = $this->redactArrayPii($value);
|
|
}
|
|
}
|
|
|
|
return $data;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $data
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function redactBaselineReadinessDiagnostics(array $data): array
|
|
{
|
|
foreach ($data as $key => $value) {
|
|
if ($key === 'baseline_readiness' && is_array($value)) {
|
|
$data[$key] = $this->customerSafeBaselineReadiness($value);
|
|
|
|
continue;
|
|
}
|
|
|
|
if ($key === 'output_readiness' && is_array($value)) {
|
|
$data[$key] = $this->customerSafeOutputReadiness($value);
|
|
|
|
continue;
|
|
}
|
|
|
|
if (is_array($value)) {
|
|
$data[$key] = $this->redactBaselineReadinessDiagnostics($value);
|
|
}
|
|
}
|
|
|
|
return $data;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $baselineReadiness
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function customerSafeBaselineReadiness(array $baselineReadiness): array
|
|
{
|
|
$summary = is_array($baselineReadiness['customer_safe_summary'] ?? null)
|
|
? $baselineReadiness['customer_safe_summary']
|
|
: [];
|
|
unset($summary['readiness_state']);
|
|
|
|
$publicationBlockers = collect($baselineReadiness['publication_blockers'] ?? [])
|
|
->filter(static fn (mixed $blocker): bool => is_scalar($blocker))
|
|
->map(fn (mixed $blocker): string => $this->plainText($blocker, ''))
|
|
->filter(static fn (string $blocker): bool => $blocker !== '')
|
|
->values()
|
|
->all();
|
|
$limitations = collect($baselineReadiness['limitations'] ?? [])
|
|
->filter(static fn (mixed $limitation): bool => is_array($limitation))
|
|
->map(fn (array $limitation): array => [
|
|
'summary' => $this->plainText(
|
|
$limitation['summary'] ?? null,
|
|
'Baseline limitation requires review before relying on this output.',
|
|
),
|
|
])
|
|
->values()
|
|
->all();
|
|
|
|
return [
|
|
'claim_label' => $this->baselineClaimLabel((string) ($baselineReadiness['customer_safe_claim'] ?? '')),
|
|
'readiness_label' => $this->baselineReadinessStateLabel((string) ($baselineReadiness['readiness_state'] ?? '')),
|
|
'publication_blocker_count' => count($publicationBlockers),
|
|
'publication_blockers' => $publicationBlockers,
|
|
'limitations' => $limitations,
|
|
'summary' => $summary,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $outputReadiness
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function customerSafeOutputReadiness(array $outputReadiness): array
|
|
{
|
|
$limitations = collect($outputReadiness['limitations'] ?? [])
|
|
->filter(static fn (mixed $limitation): bool => is_array($limitation))
|
|
->values();
|
|
$safe = [
|
|
'review_status' => (string) ($outputReadiness['review_status'] ?? ''),
|
|
'review_completeness_state' => (string) ($outputReadiness['review_completeness_state'] ?? ''),
|
|
'evidence_completeness_state' => (string) ($outputReadiness['evidence_completeness_state'] ?? ''),
|
|
'has_ready_export' => (bool) ($outputReadiness['has_ready_export'] ?? false),
|
|
'contains_pii' => false,
|
|
'protected_values_hidden' => true,
|
|
'disclosure_present' => (bool) ($outputReadiness['disclosure_present'] ?? false),
|
|
'sharing_boundary' => $this->sharingBoundaryLabel((string) ($outputReadiness['customer_safe_state'] ?? 'requires_review')),
|
|
'readiness_label' => $this->outputReadinessStateLabel((string) ($outputReadiness['readiness_state'] ?? '')),
|
|
'limitation_count' => $limitations->count(),
|
|
'section_summary' => is_array($outputReadiness['section_summary'] ?? null)
|
|
? $outputReadiness['section_summary']
|
|
: [],
|
|
];
|
|
|
|
if (is_array($outputReadiness['baseline_readiness'] ?? null)) {
|
|
$safe['baseline_readiness'] = $this->customerSafeBaselineReadiness($outputReadiness['baseline_readiness']);
|
|
}
|
|
|
|
return $safe;
|
|
}
|
|
|
|
/**
|
|
* @param array<int|string, mixed> $data
|
|
* @return array<int|string, mixed>
|
|
*/
|
|
private function redactCustomerSafeIdentifiers(array $data): array
|
|
{
|
|
foreach ($data as $key => $value) {
|
|
if (is_string($key) && $this->isInternalIdentifierKey($key) && $this->isNumericIdentifierValue($value)) {
|
|
unset($data[$key]);
|
|
|
|
continue;
|
|
}
|
|
|
|
if (is_array($value)) {
|
|
$data[$key] = $this->redactCustomerSafeIdentifiers($value);
|
|
}
|
|
}
|
|
|
|
return $data;
|
|
}
|
|
|
|
private function isInternalIdentifierKey(string $key): bool
|
|
{
|
|
return $key === 'id' || str_ends_with($key, '_id') || str_ends_with($key, '_ids');
|
|
}
|
|
|
|
private function isNumericIdentifierValue(mixed $value): bool
|
|
{
|
|
if (is_int($value)) {
|
|
return true;
|
|
}
|
|
|
|
if (is_string($value)) {
|
|
return ctype_digit($value);
|
|
}
|
|
|
|
if (! is_array($value) || $value === []) {
|
|
return false;
|
|
}
|
|
|
|
return collect($value)->every(static fn (mixed $item): bool => is_int($item) || (is_string($item) && ctype_digit($item)));
|
|
}
|
|
|
|
private function baselineReadinessStateLabel(string $state): string
|
|
{
|
|
return match ($state) {
|
|
'customer_ready' => 'Customer-ready baseline evidence',
|
|
'trusted_drift_detected', 'drift_findings_present' => 'Trusted drift findings present',
|
|
'baseline_compare_limited' => 'Customer-ready with disclosed baseline limitations',
|
|
'baseline_identity_unresolved' => 'Baseline subject identity unresolved',
|
|
'baseline_local_evidence_missing' => 'Baseline local evidence missing',
|
|
'baseline_provider_resource_missing' => 'Provider resource evidence missing',
|
|
'baseline_required_coverage_unsupported' => 'Required baseline coverage unsupported',
|
|
'baseline_compare_unproven' => 'Baseline compare proof missing',
|
|
'baseline_compare_stale' => 'Baseline compare evidence stale',
|
|
'baseline_compare_failed' => 'Baseline compare failed',
|
|
'baseline_compare_blocked', 'baseline_compare_not_completed' => 'Baseline compare blocked',
|
|
default => 'Baseline readiness unavailable',
|
|
};
|
|
}
|
|
|
|
private function baselineClaimLabel(string $claim): string
|
|
{
|
|
return match ($claim) {
|
|
'customer_ready' => 'Customer-ready',
|
|
'customer_ready_with_findings' => 'Customer-ready with findings',
|
|
'customer_ready_with_disclosed_limitations' => 'Customer-ready with disclosed limitations',
|
|
default => 'Not customer-ready',
|
|
};
|
|
}
|
|
|
|
private function outputReadinessStateLabel(string $state): string
|
|
{
|
|
return match ($state) {
|
|
ReviewPackOutputReadiness::STATE_CUSTOMER_SAFE_READY => 'Customer-safe review pack ready',
|
|
ReviewPackOutputReadiness::STATE_PUBLISHED_WITH_LIMITATIONS => 'Published with limitations',
|
|
ReviewPackOutputReadiness::STATE_INTERNAL_REVIEW_PACKAGE_AVAILABLE => 'Internal review package available',
|
|
ReviewPackOutputReadiness::STATE_EXPORT_NOT_READY => 'Export not ready',
|
|
default => 'Requires review',
|
|
};
|
|
}
|
|
|
|
private function sharingBoundaryLabel(string $state): string
|
|
{
|
|
return match ($state) {
|
|
'customer_safe_ready' => 'Customer-safe',
|
|
'internal_only' => 'Internal only',
|
|
'not_ready' => 'Not ready',
|
|
default => 'Requires review',
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @param array<int|string, mixed> $data
|
|
* @param array<int, string> $segments
|
|
* @return array<int|string, mixed>
|
|
*/
|
|
private function redactProtectedPayload(array $data, array $segments = []): array
|
|
{
|
|
foreach ($data as $key => $value) {
|
|
$nextSegments = [...$segments, (string) $key];
|
|
$jsonPointer = $this->jsonPointer($nextSegments);
|
|
|
|
if (is_string($key) && $this->classifier()->protectsField('snapshot', $key, $jsonPointer)) {
|
|
$data[$key] = SecretClassificationService::REDACTED;
|
|
|
|
continue;
|
|
}
|
|
|
|
if (is_array($value)) {
|
|
$data[$key] = $this->redactProtectedPayload($value, $nextSegments);
|
|
|
|
continue;
|
|
}
|
|
|
|
if (is_string($value)) {
|
|
$data[$key] = $this->classifier()->sanitizeAuditString($value);
|
|
}
|
|
}
|
|
|
|
return $data;
|
|
}
|
|
|
|
/**
|
|
* @param array<int, string> $segments
|
|
*/
|
|
private function jsonPointer(array $segments): string
|
|
{
|
|
if ($segments === []) {
|
|
return '/';
|
|
}
|
|
|
|
return '/'.implode('/', array_map(
|
|
static fn (string $segment): string => str_replace(['~', '/'], ['~0', '~1'], $segment),
|
|
$segments,
|
|
));
|
|
}
|
|
|
|
private function classifier(): SecretClassificationService
|
|
{
|
|
return app(SecretClassificationService::class);
|
|
}
|
|
|
|
/**
|
|
* Assemble a ZIP file from a file map.
|
|
*
|
|
* @param array<string, string> $fileMap
|
|
*/
|
|
private function assembleZip(string $tempFile, array $fileMap, ?callable $afterWrite = null): void
|
|
{
|
|
$zip = new ZipArchive;
|
|
$result = $zip->open($tempFile, ZipArchive::CREATE | ZipArchive::OVERWRITE);
|
|
|
|
if ($result !== true) {
|
|
throw new \RuntimeException("Failed to create ZIP archive: error code {$result}");
|
|
}
|
|
|
|
// Add files in alphabetical order for deterministic output
|
|
ksort($fileMap);
|
|
|
|
foreach ($fileMap as $filename => $content) {
|
|
$zip->addFromString($filename, $content);
|
|
|
|
$afterWrite && $afterWrite();
|
|
}
|
|
|
|
$zip->close();
|
|
}
|
|
|
|
/**
|
|
* @return array<string, string>
|
|
*/
|
|
private function buildReviewDerivedFileMap(
|
|
ReviewPack $reviewPack,
|
|
EnvironmentReview $review,
|
|
ManagedEnvironment $tenant,
|
|
EvidenceSnapshot $snapshot,
|
|
Collection $sections,
|
|
bool $includePii,
|
|
bool $includeOperations,
|
|
\Carbon\CarbonInterface $generatedAt,
|
|
): array {
|
|
$reviewSummary = is_array($review->summary) ? $review->summary : [];
|
|
$sectionFiles = $sections
|
|
->map(fn (mixed $section): string => $this->reviewDerivedSectionFilename($section))
|
|
->values()
|
|
->all();
|
|
$summaryPayload = $this->reviewDerivedSummaryPayload(
|
|
reviewPack: $reviewPack,
|
|
review: $review,
|
|
snapshot: $snapshot,
|
|
sections: $sections,
|
|
includePii: $includePii,
|
|
includeOperations: $includeOperations,
|
|
hasReadyExport: true,
|
|
);
|
|
$deliveryMetadata = $this->deliveryBundleMetadata(
|
|
reviewPack: $reviewPack,
|
|
review: $review,
|
|
snapshot: $snapshot,
|
|
generatedAt: $generatedAt,
|
|
sectionFiles: $sectionFiles,
|
|
outputReadiness: is_array($summaryPayload['output_readiness'] ?? null)
|
|
? $summaryPayload['output_readiness']
|
|
: [],
|
|
);
|
|
|
|
$metadataPayload = [
|
|
'version' => '1.0',
|
|
'managed_environment_id' => $tenant->external_id,
|
|
'tenant_name' => $includePii ? $tenant->name : '[REDACTED]',
|
|
'generated_at' => $generatedAt->toIso8601String(),
|
|
'delivery_bundle' => $deliveryMetadata,
|
|
'environment_review' => [
|
|
'id' => (int) $review->getKey(),
|
|
'status' => (string) $review->status,
|
|
'completeness_state' => (string) $review->completeness_state,
|
|
'published_at' => $review->published_at?->toIso8601String(),
|
|
'fingerprint' => (string) $review->fingerprint,
|
|
],
|
|
'evidence_snapshot' => [
|
|
'id' => (int) $snapshot->getKey(),
|
|
'fingerprint' => (string) $snapshot->fingerprint,
|
|
'completeness_state' => (string) $snapshot->completeness_state,
|
|
'generated_at' => $snapshot->generated_at?->toIso8601String(),
|
|
],
|
|
'options' => [
|
|
'include_pii' => $includePii,
|
|
'include_operations' => $includeOperations,
|
|
],
|
|
'output_readiness' => data_get($summaryPayload, 'output_readiness', []),
|
|
'redaction_integrity' => [
|
|
'protected_values_hidden' => true,
|
|
'note' => RedactionIntegrity::protectedValueNote(),
|
|
],
|
|
];
|
|
|
|
$files = [
|
|
'metadata.json' => json_encode(
|
|
$this->redactReportPayload($metadataPayload, $includePii),
|
|
JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR,
|
|
),
|
|
'summary.json' => json_encode(
|
|
$this->redactReportPayload($summaryPayload, $includePii),
|
|
JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR,
|
|
),
|
|
'sections.json' => json_encode($sections->map(function ($section) use ($includePii): array {
|
|
$summaryPayload = is_array($section->summary_payload) ? $section->summary_payload : [];
|
|
$renderPayload = is_array($section->render_payload) ? $section->render_payload : [];
|
|
|
|
return [
|
|
'section_key' => (string) $section->section_key,
|
|
'title' => (string) $section->title,
|
|
'sort_order' => (int) $section->sort_order,
|
|
'required' => (bool) $section->required,
|
|
'completeness_state' => (string) $section->completeness_state,
|
|
'summary_payload' => $this->redactReportPayload($summaryPayload, $includePii),
|
|
'render_payload' => $this->redactReportPayload($renderPayload, $includePii),
|
|
];
|
|
})->all(), JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR),
|
|
ReviewPackService::EXECUTIVE_ENTRYPOINT_FILENAME => $this->buildExecutiveEntrypoint(
|
|
review: $review,
|
|
tenant: $tenant,
|
|
snapshot: $snapshot,
|
|
reviewSummary: $reviewSummary,
|
|
outputReadiness: is_array($summaryPayload['output_readiness'] ?? null)
|
|
? $summaryPayload['output_readiness']
|
|
: [],
|
|
includePii: $includePii,
|
|
generatedAt: $generatedAt,
|
|
),
|
|
];
|
|
|
|
foreach ($sections as $section) {
|
|
$renderPayload = is_array($section->render_payload) ? $section->render_payload : [];
|
|
$summaryPayload = is_array($section->summary_payload) ? $section->summary_payload : [];
|
|
$filename = $this->reviewDerivedSectionFilename($section);
|
|
|
|
$files[$filename] = json_encode([
|
|
'section_key' => (string) $section->section_key,
|
|
'title' => (string) $section->title,
|
|
'sort_order' => (int) $section->sort_order,
|
|
'required' => (bool) $section->required,
|
|
'completeness_state' => (string) $section->completeness_state,
|
|
'summary_payload' => $this->redactReportPayload($summaryPayload, $includePii),
|
|
'render_payload' => $this->redactReportPayload($renderPayload, $includePii),
|
|
], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR);
|
|
}
|
|
|
|
return $files;
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function deliveryBundleSummary(EnvironmentReview $review, Collection $sections): array
|
|
{
|
|
return [
|
|
'contract' => ReviewPackService::REVIEW_DERIVED_DELIVERY_CONTRACT,
|
|
'executive_entrypoint_file' => ReviewPackService::EXECUTIVE_ENTRYPOINT_FILENAME,
|
|
'appendix_files' => ['metadata.json', 'summary.json', 'sections.json'],
|
|
'section_files' => $sections
|
|
->map(fn (mixed $section): string => $this->reviewDerivedSectionFilename($section))
|
|
->values()
|
|
->all(),
|
|
'interpretation_version' => $review->controlInterpretationVersion(),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function deliveryBundleMetadata(
|
|
ReviewPack $reviewPack,
|
|
EnvironmentReview $review,
|
|
EvidenceSnapshot $snapshot,
|
|
\Carbon\CarbonInterface $generatedAt,
|
|
array $sectionFiles,
|
|
array $outputReadiness,
|
|
): array {
|
|
$appendix = [
|
|
[
|
|
'file' => 'metadata.json',
|
|
'role' => 'bundle_metadata',
|
|
'description' => 'Structured delivery metadata and artifact role map.',
|
|
],
|
|
[
|
|
'file' => 'summary.json',
|
|
'role' => 'review_summary_appendix',
|
|
'description' => 'Structured released-review summary truth.',
|
|
],
|
|
[
|
|
'file' => 'sections.json',
|
|
'role' => 'section_detail_appendix',
|
|
'description' => 'Structured released-review section detail.',
|
|
],
|
|
];
|
|
|
|
foreach ($sectionFiles as $sectionFile) {
|
|
$appendix[] = [
|
|
'file' => $sectionFile,
|
|
'role' => 'section_appendix_entry',
|
|
'description' => 'Structured appendix entry for a released-review section.',
|
|
];
|
|
}
|
|
|
|
return [
|
|
'contract' => ReviewPackService::REVIEW_DERIVED_DELIVERY_CONTRACT,
|
|
'artifact_family' => 'review_pack',
|
|
'review_pack_id' => (int) $reviewPack->getKey(),
|
|
'generated_at' => $generatedAt->toIso8601String(),
|
|
'released_review' => [
|
|
'id' => (int) $review->getKey(),
|
|
'status' => (string) $review->status,
|
|
'completeness_state' => (string) $review->completeness_state,
|
|
'published_at' => $review->published_at?->toIso8601String(),
|
|
],
|
|
'interpretation_version' => $review->controlInterpretationVersion(),
|
|
'evidence_basis' => [
|
|
'snapshot_id' => (int) $snapshot->getKey(),
|
|
'snapshot_fingerprint' => (string) $snapshot->fingerprint,
|
|
'completeness_state' => (string) $snapshot->completeness_state,
|
|
'generated_at' => $snapshot->generated_at?->toIso8601String(),
|
|
],
|
|
'entrypoint' => [
|
|
'file' => ReviewPackService::EXECUTIVE_ENTRYPOINT_FILENAME,
|
|
'role' => 'executive_entrypoint',
|
|
'audience' => 'executive',
|
|
'format' => 'text/markdown',
|
|
],
|
|
'appendix' => $appendix,
|
|
'section_file_semantics' => 'Section completeness_state describes source and evidence completeness. A section appendix file may still exist when the section state is missing.',
|
|
'output_readiness' => $outputReadiness,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $reviewSummary
|
|
*/
|
|
private function buildExecutiveEntrypoint(
|
|
EnvironmentReview $review,
|
|
ManagedEnvironment $tenant,
|
|
EvidenceSnapshot $snapshot,
|
|
array $reviewSummary,
|
|
array $outputReadiness,
|
|
bool $includePii,
|
|
\Carbon\CarbonInterface $generatedAt,
|
|
): string {
|
|
$package = is_array($reviewSummary['governance_package'] ?? null)
|
|
? $this->redactReportPayload($reviewSummary['governance_package'], $includePii)
|
|
: [];
|
|
$controlInterpretation = is_array($reviewSummary['control_interpretation'] ?? null)
|
|
? $reviewSummary['control_interpretation']
|
|
: [];
|
|
$nonCertificationDisclosure = $this->plainText(
|
|
$controlInterpretation['non_certification_disclosure'] ?? null,
|
|
'TenantPilot interprets available evidence for review readiness. This is not a certification, legal attestation, or compliance guarantee.',
|
|
);
|
|
$tenantName = $includePii ? $tenant->name : '[REDACTED]';
|
|
$topFindings = is_array($package['top_findings'] ?? null) ? $package['top_findings'] : [];
|
|
$acceptedRisks = is_array($package['accepted_risks'] ?? null) ? $package['accepted_risks'] : [];
|
|
$decisionSummary = is_array($package['decision_summary'] ?? null) ? $package['decision_summary'] : [];
|
|
$governanceDecisions = is_array($decisionSummary['entries'] ?? null)
|
|
? $decisionSummary['entries']
|
|
: (is_array($package['governance_decisions'] ?? null) ? $package['governance_decisions'] : []);
|
|
$nextActions = is_array($reviewSummary['recommended_next_actions'] ?? null) ? $reviewSummary['recommended_next_actions'] : [];
|
|
$limitations = $this->executiveLimitationsLines($outputReadiness);
|
|
|
|
$lines = [
|
|
'# Executive summary',
|
|
'',
|
|
'ManagedEnvironment: '.$this->plainText($tenantName, '[REDACTED]'),
|
|
'Released review: '.($includePii ? '#'.((int) $review->getKey()) : 'current released review'),
|
|
'Review status: '.$this->plainText($review->status, 'unknown'),
|
|
'Generated at: '.$generatedAt->toIso8601String(),
|
|
'',
|
|
'## Executive story',
|
|
'',
|
|
$this->plainText($package['executive_summary'] ?? null, 'No executive summary is available for this released review.'),
|
|
'',
|
|
'## Evidence basis',
|
|
'',
|
|
$this->plainText(
|
|
$package['evidence_basis_summary'] ?? null,
|
|
$includePii
|
|
? sprintf('Anchored to evidence snapshot #%d with %s completeness.', (int) $snapshot->getKey(), (string) $snapshot->completeness_state)
|
|
: sprintf('Anchored to the released evidence snapshot with %s completeness.', (string) $snapshot->completeness_state),
|
|
),
|
|
'',
|
|
...($limitations === [] ? [] : [
|
|
'## Limitations',
|
|
'',
|
|
...$limitations,
|
|
'',
|
|
]),
|
|
'## Key findings',
|
|
'',
|
|
...$this->entryBullets($topFindings, 'No key findings are listed for this released review.'),
|
|
'',
|
|
'## Accepted risks',
|
|
'',
|
|
...$this->entryBullets($acceptedRisks, 'No accepted risks are listed for this released review.'),
|
|
'',
|
|
'## Governance decisions requiring awareness',
|
|
'',
|
|
...$this->decisionSummaryLines($decisionSummary, $governanceDecisions),
|
|
'',
|
|
'## Next actions',
|
|
'',
|
|
...$this->textBullets($nextActions, 'No next action is listed for this released review.'),
|
|
'',
|
|
'## Non-certification disclosure',
|
|
'',
|
|
$nonCertificationDisclosure,
|
|
'',
|
|
'## Structured auditor appendix',
|
|
'',
|
|
'This executive entrypoint is the first file to read. The structured auditor appendix remains available in metadata.json, summary.json, and sections.json.',
|
|
'',
|
|
];
|
|
|
|
return implode("\n", $lines);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $decisionSummary
|
|
* @param array<int, mixed> $entries
|
|
* @return list<string>
|
|
*/
|
|
private function decisionSummaryLines(array $decisionSummary, array $entries): array
|
|
{
|
|
$lines = [];
|
|
$summary = $this->plainText($decisionSummary['summary'] ?? null, '');
|
|
$nextAction = $this->plainText($decisionSummary['next_action'] ?? null, '');
|
|
|
|
if ($summary !== '') {
|
|
$lines[] = $summary;
|
|
}
|
|
|
|
if ($nextAction !== '') {
|
|
$lines[] = 'Next action: '.$nextAction;
|
|
}
|
|
|
|
if ($lines !== []) {
|
|
$lines[] = '';
|
|
}
|
|
|
|
return [
|
|
...$lines,
|
|
...$this->entryBullets($entries, 'No governance decisions require awareness in this released review.'),
|
|
];
|
|
}
|
|
|
|
private function reviewDerivedSections(EnvironmentReview $review, bool $includeOperations): Collection
|
|
{
|
|
return $review->sections
|
|
->filter(fn (mixed $section): bool => $includeOperations || $section->section_key !== 'operations_health')
|
|
->values();
|
|
}
|
|
|
|
private function reviewDerivedSectionFilename(mixed $section): string
|
|
{
|
|
return sprintf('sections/%02d-%s.json', (int) $section->sort_order, (string) $section->section_key);
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function reviewDerivedSummaryPayload(
|
|
ReviewPack $reviewPack,
|
|
EnvironmentReview $review,
|
|
EvidenceSnapshot $snapshot,
|
|
Collection $sections,
|
|
bool $includePii,
|
|
bool $includeOperations,
|
|
bool $hasReadyExport,
|
|
): array {
|
|
$reviewSummary = is_array($review->summary) ? $review->summary : [];
|
|
$publishBlockers = is_array($reviewSummary['publish_blockers'] ?? null) ? $reviewSummary['publish_blockers'] : [];
|
|
$sectionStateCounts = $this->sectionStateCounts($sections);
|
|
$requiredSections = $sections->filter(static fn (mixed $section): bool => (bool) $section->required)->values();
|
|
$requiredSectionStateCounts = $this->sectionStateCounts($requiredSections);
|
|
$outputReadiness = ReviewPackOutputReadiness::derive(
|
|
reviewStatus: (string) $review->status,
|
|
reviewCompletenessState: (string) $review->completeness_state,
|
|
evidenceCompletenessState: (string) $snapshot->completeness_state,
|
|
sectionStateCounts: $sectionStateCounts,
|
|
requiredSectionCount: $requiredSections->count(),
|
|
requiredSectionStateCounts: $requiredSectionStateCounts,
|
|
publishBlockers: $publishBlockers,
|
|
hasReadyExport: $hasReadyExport,
|
|
includePii: $includePii,
|
|
protectedValuesHidden: true,
|
|
disclosurePresent: $this->nonCertificationDisclosurePresent($reviewSummary),
|
|
baselineReadiness: is_array($reviewSummary['baseline_readiness'] ?? null)
|
|
? $reviewSummary['baseline_readiness']
|
|
: [],
|
|
);
|
|
|
|
$governancePackage = is_array($reviewSummary['governance_package'] ?? null)
|
|
? $this->redactReportPayload($reviewSummary['governance_package'], $includePii)
|
|
: [];
|
|
|
|
return array_merge($reviewSummary, [
|
|
'environment_review_id' => (int) $review->getKey(),
|
|
'review_status' => (string) $review->status,
|
|
'review_completeness_state' => (string) $review->completeness_state,
|
|
'section_count' => $sections->count(),
|
|
'section_state_counts' => $sectionStateCounts,
|
|
'required_section_state_counts' => $requiredSectionStateCounts,
|
|
'has_ready_export' => $hasReadyExport,
|
|
'finding_count' => (int) ($reviewSummary['finding_count'] ?? 0),
|
|
'report_count' => (int) ($reviewSummary['report_count'] ?? 0),
|
|
'operation_count' => $includeOperations ? (int) ($reviewSummary['operation_count'] ?? 0) : 0,
|
|
'highlights' => is_array($reviewSummary['highlights'] ?? null) ? $reviewSummary['highlights'] : [],
|
|
'publish_blockers' => $publishBlockers,
|
|
'governance_package' => $governancePackage,
|
|
'recommended_next_actions' => is_array($reviewSummary['recommended_next_actions'] ?? null)
|
|
? $reviewSummary['recommended_next_actions']
|
|
: [],
|
|
'delivery_bundle' => $this->deliveryBundleSummary($review, $sections),
|
|
'evidence_basis' => [
|
|
'snapshot_id' => (int) $snapshot->getKey(),
|
|
'snapshot_fingerprint' => (string) $snapshot->fingerprint,
|
|
'completeness_state' => (string) $snapshot->completeness_state,
|
|
'generated_at' => $snapshot->generated_at?->toIso8601String(),
|
|
],
|
|
'evidence_resolution' => [
|
|
'outcome' => 'resolved',
|
|
'snapshot_id' => (int) $snapshot->getKey(),
|
|
'snapshot_fingerprint' => (string) $snapshot->fingerprint,
|
|
'completeness_state' => (string) $snapshot->completeness_state,
|
|
],
|
|
'output_readiness' => $outputReadiness,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* @return array<string, int>
|
|
*/
|
|
private function sectionStateCounts(Collection $sections): array
|
|
{
|
|
return $sections
|
|
->countBy(static fn (mixed $section): string => (string) $section->completeness_state)
|
|
->map(static fn (int $count): int => max(0, $count))
|
|
->all();
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $reviewSummary
|
|
*/
|
|
private function nonCertificationDisclosurePresent(array $reviewSummary): bool
|
|
{
|
|
$controlInterpretation = is_array($reviewSummary['control_interpretation'] ?? null)
|
|
? $reviewSummary['control_interpretation']
|
|
: [];
|
|
$disclosure = $this->plainText(
|
|
$controlInterpretation['non_certification_disclosure'] ?? null,
|
|
'TenantPilot interprets available evidence for review readiness. This is not a certification, legal attestation, or compliance guarantee.',
|
|
);
|
|
|
|
return $disclosure !== '';
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $outputReadiness
|
|
* @return list<string>
|
|
*/
|
|
private function executiveLimitationsLines(array $outputReadiness): array
|
|
{
|
|
$codes = collect($outputReadiness['limitations'] ?? [])
|
|
->filter(static fn (mixed $limitation): bool => is_array($limitation) && is_string($limitation['code'] ?? null))
|
|
->pluck('code')
|
|
->values()
|
|
->all();
|
|
|
|
if ($codes === []) {
|
|
return [];
|
|
}
|
|
|
|
$lines = [];
|
|
|
|
if (in_array('evidence_basis_missing', $codes, true)) {
|
|
$lines[] = '- This review was published with a missing evidence basis.';
|
|
} elseif (in_array('evidence_basis_stale', $codes, true)) {
|
|
$lines[] = '- This review was published with a stale evidence basis.';
|
|
} elseif (in_array('evidence_basis_incomplete', $codes, true)) {
|
|
$lines[] = '- This review was published with an incomplete evidence basis.';
|
|
}
|
|
|
|
if (in_array('required_sections_incomplete', $codes, true)) {
|
|
$lines[] = '- Some required sections are included as structured appendices but are marked missing because their source evidence was incomplete at generation time.';
|
|
}
|
|
|
|
if (in_array('export_not_ready', $codes, true)) {
|
|
$lines[] = '- The package exists, but export readiness had not passed at generation time.';
|
|
}
|
|
|
|
if (in_array('contains_pii', $codes, true)) {
|
|
$lines[] = '- PII is included in this package. Review the contents before external sharing.';
|
|
}
|
|
|
|
if (in_array('publish_blockers_present', $codes, true)) {
|
|
$lines[] = '- Publish blockers remain recorded in the released review summary.';
|
|
}
|
|
|
|
if (in_array('baseline_publication_blockers_present', $codes, true)) {
|
|
$lines[] = '- Baseline readiness blockers remain recorded; do not treat this output as customer-ready until they are resolved.';
|
|
}
|
|
|
|
if (in_array('baseline_compare_unproven', $codes, true)) {
|
|
$lines[] = '- Baseline compare proof was not available for the customer-ready claim.';
|
|
}
|
|
|
|
if (in_array('baseline_compare_stale', $codes, true)) {
|
|
$lines[] = '- Baseline compare evidence was stale at generation time.';
|
|
}
|
|
|
|
if (in_array('baseline_compare_failed', $codes, true)) {
|
|
$lines[] = '- Baseline compare failed and should be rerun or investigated before external reliance.';
|
|
}
|
|
|
|
if (in_array('baseline_foundation_limitations', $codes, true)) {
|
|
$lines[] = '- Some baseline subjects rely on inventory, identity, or canonical foundation evidence rather than direct comparable proof.';
|
|
}
|
|
|
|
if (in_array('baseline_accepted_limitations', $codes, true)) {
|
|
$lines[] = '- Accepted baseline limitations qualify the customer-ready claim.';
|
|
}
|
|
|
|
if (in_array('baseline_exclusions_present', $codes, true)) {
|
|
$lines[] = '- Excluded non-governed baseline subjects are outside the governed no-drift claim.';
|
|
}
|
|
|
|
if (in_array('disclosure_missing', $codes, true)) {
|
|
$lines[] = '- The non-certification disclosure was not fully available in the released review payload.';
|
|
}
|
|
|
|
return $lines;
|
|
}
|
|
|
|
/**
|
|
* @param array<int, mixed> $entries
|
|
* @return list<string>
|
|
*/
|
|
private function entryBullets(array $entries, string $emptyText): array
|
|
{
|
|
if ($entries === []) {
|
|
return ['- '.$emptyText];
|
|
}
|
|
|
|
return collect($entries)
|
|
->filter(static fn (mixed $entry): bool => is_array($entry))
|
|
->map(function (array $entry): string {
|
|
$title = $this->plainText($entry['title'] ?? null, 'Entry');
|
|
$summary = $this->plainText($entry['summary'] ?? null, '');
|
|
|
|
return $summary === '' ? '- '.$title : '- '.$title.' - '.$summary;
|
|
})
|
|
->values()
|
|
->all();
|
|
}
|
|
|
|
/**
|
|
* @param array<int, mixed> $entries
|
|
* @return list<string>
|
|
*/
|
|
private function textBullets(array $entries, string $emptyText): array
|
|
{
|
|
$bullets = collect($entries)
|
|
->filter(static fn (mixed $entry): bool => is_string($entry) && trim($entry) !== '')
|
|
->map(fn (string $entry): string => '- '.$this->plainText($entry, ''))
|
|
->values()
|
|
->all();
|
|
|
|
return $bullets === [] ? ['- '.$emptyText] : $bullets;
|
|
}
|
|
|
|
private function plainText(mixed $value, string $fallback): string
|
|
{
|
|
if (! is_scalar($value) && $value !== null) {
|
|
return $fallback;
|
|
}
|
|
|
|
$text = preg_replace('/\s+/', ' ', trim((string) $value));
|
|
|
|
return is_string($text) && $text !== '' ? $text : $fallback;
|
|
}
|
|
|
|
private function markFailed(ReviewPack $reviewPack, OperationRun $operationRun, OperationRunService $operationRunService, string $reasonCode, string $errorMessage): void
|
|
{
|
|
$reviewPack->update([
|
|
'status' => ReviewPackStatus::Failed->value,
|
|
'summary' => array_merge($reviewPack->summary ?? [], [
|
|
'evidence_resolution' => array_merge($reviewPack->summary['evidence_resolution'] ?? [], [
|
|
'outcome' => $reasonCode,
|
|
'reasons' => [mb_substr($errorMessage, 0, 500)],
|
|
]),
|
|
]),
|
|
]);
|
|
|
|
$operationRunService->updateRun(
|
|
$operationRun,
|
|
status: OperationRunStatus::Completed->value,
|
|
outcome: OperationRunOutcome::Failed->value,
|
|
failures: [
|
|
['code' => $reasonCode, 'message' => mb_substr($errorMessage, 0, 500)],
|
|
],
|
|
);
|
|
}
|
|
|
|
private function itemSummaryPayload(mixed $item): array
|
|
{
|
|
if (! $item instanceof \App\Models\EvidenceSnapshotItem || ! is_array($item->summary_payload)) {
|
|
return [];
|
|
}
|
|
|
|
return $item->summary_payload;
|
|
}
|
|
}
|