502 lines
18 KiB
PHP
502 lines
18 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Jobs;
|
|
|
|
use App\Models\EvidenceSnapshot;
|
|
use App\Models\Finding;
|
|
use App\Models\OperationRun;
|
|
use App\Models\ReviewPack;
|
|
use App\Models\Tenant;
|
|
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\ReviewPackStatus;
|
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
|
use Illuminate\Foundation\Queue\Queueable;
|
|
use Illuminate\Support\Facades\Log;
|
|
use Illuminate\Support\Facades\Storage;
|
|
use Throwable;
|
|
use ZipArchive;
|
|
|
|
class GenerateReviewPackJob implements ShouldQueue
|
|
{
|
|
use Queueable;
|
|
|
|
public function __construct(
|
|
public int $reviewPackId,
|
|
public int $operationRunId,
|
|
) {}
|
|
|
|
public function handle(OperationRunService $operationRunService): void
|
|
{
|
|
$reviewPack = ReviewPack::query()->with(['tenant', 'evidenceSnapshot.items'])->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 Tenant) {
|
|
$this->markFailed($reviewPack, $operationRun, $operationRunService, 'tenant_not_found', 'Tenant 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, Tenant $tenant, EvidenceSnapshot $snapshot, OperationRunService $operationRunService): void
|
|
{
|
|
$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,
|
|
);
|
|
|
|
// 7. Assemble ZIP
|
|
$tempFile = tempnam(sys_get_temp_dir(), 'review-pack-');
|
|
|
|
try {
|
|
$this->assembleZip($tempFile, $fileMap);
|
|
|
|
// 8. Compute SHA-256
|
|
$sha256 = hash_file('sha256', $tempFile);
|
|
$fileSize = filesize($tempFile);
|
|
|
|
// 9. Store on exports disk
|
|
$filePath = sprintf(
|
|
'review-packs/%s/%s.zip',
|
|
$tenant->external_id,
|
|
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: $summary,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @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,
|
|
Tenant $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
|
|
$files['metadata.json'] = json_encode([
|
|
'version' => '1.0',
|
|
'tenant_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,
|
|
],
|
|
], 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
|
|
$files['summary.json'] = json_encode([
|
|
'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(),
|
|
], 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);
|
|
|
|
return $includePii ? $payload : $this->redactArrayPii($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'];
|
|
|
|
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<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): 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);
|
|
}
|
|
|
|
$zip->close();
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|