Beschreibung Implementiert das Drift MVP Feature (Spec: 044-drift-mvp) mit Fokus auf automatische Drift-Erkennung zwischen Inventory Sync Runs und Bulk-Triage für Findings. Was wurde implementiert? Drift-Erkennung: Vergleicht Policy-Snapshots, Assignments und Scope Tags zwischen Baseline- und Current-Runs. Deterministische Fingerprints verhindern Duplikate. Findings UI: Neue Filament Resource für Findings mit Listen- und Detail-Ansicht. DB-only Diffs (keine Graph-Calls zur Laufzeit). Bulk Acknowledge: "Acknowledge selected" (Bulk-Action auf der Liste) "Acknowledge all matching" (Header-Action, respektiert aktuelle Filter; Type-to-Confirm bei >100 Findings) Scope Tag Fix: Behebt False Positives bei Legacy-Daten ohne scope_tags.ids (inferiert Default-Werte). Authorization: Tenant-isoliert, Rollen-basiert (Owner/Manager/Operator können acknowledge). Tests: Vollständige Pest-Coverage (28 Tests, 347 Assertions) für Drift-Logik, UI und Bulk-Actions. Warum diese Änderungen? Problem: Keine automatisierte Drift-Erkennung; manuelle Triage bei vielen Findings ist mühsam. Lösung: Async Drift-Generierung mit persistenter Findings-Tabelle. Safe Bulk-Tools für Massen-Triage ohne Deletes. Konformität: Folgt AGENTS.md Workflow, Spec-Kit (Tasks + Checklists abgehakt), Laravel/Filament Best Practices. Technische Details Neue Dateien: ~40 (Models, Services, Tests, Views, Migrations) Änderungen: Filament Resources, Jobs, Policies DB: Neue findings Tabelle (JSONB für Evidence, Indexes für Performance) Tests: ./vendor/bin/sail artisan test tests/Feature/Drift --parallel → 28 passed Migration: ./vendor/bin/sail artisan migrate (neue Tabelle + Indexes) Screenshots / Links Spec: spec.md Tasks: tasks.md (alle abgehakt) UI: Findings-Liste mit Bulk-Actions; Detail-View mit Diffs Checklist Tests passieren (parallel + serial) Code formatiert (./vendor/bin/pint --dirty) Migration reversibel Tenant-Isolation enforced No Graph-Calls in Views Authorization checks Spec + Tasks aligned Deployment Notes Neue Migration: create_findings_table Neue Permissions: drift.view, drift.acknowledge Queue-Job: GenerateDriftFindingsJob (async, deduped)
306 lines
14 KiB
PHP
306 lines
14 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Drift;
|
|
|
|
use App\Models\Finding;
|
|
use App\Models\InventorySyncRun;
|
|
use App\Models\Policy;
|
|
use App\Models\PolicyVersion;
|
|
use App\Models\Tenant;
|
|
use App\Services\Drift\Normalizers\ScopeTagsNormalizer;
|
|
use App\Services\Drift\Normalizers\SettingsNormalizer;
|
|
use Illuminate\Support\Arr;
|
|
use RuntimeException;
|
|
|
|
class DriftFindingGenerator
|
|
{
|
|
public function __construct(
|
|
private readonly DriftHasher $hasher,
|
|
private readonly DriftEvidence $evidence,
|
|
private readonly SettingsNormalizer $settingsNormalizer,
|
|
private readonly ScopeTagsNormalizer $scopeTagsNormalizer,
|
|
) {}
|
|
|
|
public function generate(Tenant $tenant, InventorySyncRun $baseline, InventorySyncRun $current, string $scopeKey): int
|
|
{
|
|
if (! $baseline->finished_at || ! $current->finished_at) {
|
|
throw new RuntimeException('Baseline/current run must be finished.');
|
|
}
|
|
|
|
/** @var array<string, mixed> $selection */
|
|
$selection = is_array($current->selection_payload) ? $current->selection_payload : [];
|
|
|
|
$policyTypes = Arr::get($selection, 'policy_types');
|
|
if (! is_array($policyTypes)) {
|
|
$policyTypes = [];
|
|
}
|
|
|
|
$policyTypes = array_values(array_filter(array_map('strval', $policyTypes)));
|
|
|
|
$created = 0;
|
|
|
|
Policy::query()
|
|
->where('tenant_id', $tenant->getKey())
|
|
->whereIn('policy_type', $policyTypes)
|
|
->orderBy('id')
|
|
->chunk(200, function ($policies) use ($tenant, $baseline, $current, $scopeKey, &$created): void {
|
|
foreach ($policies as $policy) {
|
|
if (! $policy instanceof Policy) {
|
|
continue;
|
|
}
|
|
|
|
$baselineVersion = $this->versionForRun($policy, $baseline);
|
|
$currentVersion = $this->versionForRun($policy, $current);
|
|
|
|
if ($baselineVersion instanceof PolicyVersion || $currentVersion instanceof PolicyVersion) {
|
|
$policyType = (string) ($policy->policy_type ?? '');
|
|
$platform = is_string($policy->platform ?? null) ? $policy->platform : null;
|
|
|
|
$baselineSnapshot = $baselineVersion instanceof PolicyVersion && is_array($baselineVersion->snapshot)
|
|
? $baselineVersion->snapshot
|
|
: [];
|
|
$currentSnapshot = $currentVersion instanceof PolicyVersion && is_array($currentVersion->snapshot)
|
|
? $currentVersion->snapshot
|
|
: [];
|
|
|
|
$baselineNormalized = $this->settingsNormalizer->normalizeForDiff($baselineSnapshot, $policyType, $platform);
|
|
$currentNormalized = $this->settingsNormalizer->normalizeForDiff($currentSnapshot, $policyType, $platform);
|
|
|
|
$baselineSnapshotHash = $this->hasher->hashNormalized($baselineNormalized);
|
|
$currentSnapshotHash = $this->hasher->hashNormalized($currentNormalized);
|
|
|
|
if ($baselineSnapshotHash !== $currentSnapshotHash) {
|
|
$changeType = match (true) {
|
|
$baselineVersion instanceof PolicyVersion && ! $currentVersion instanceof PolicyVersion => 'removed',
|
|
! $baselineVersion instanceof PolicyVersion && $currentVersion instanceof PolicyVersion => 'added',
|
|
default => 'modified',
|
|
};
|
|
|
|
$fingerprint = $this->hasher->fingerprint(
|
|
tenantId: (int) $tenant->getKey(),
|
|
scopeKey: $scopeKey,
|
|
subjectType: 'policy',
|
|
subjectExternalId: (string) $policy->external_id,
|
|
changeType: $changeType,
|
|
baselineHash: $baselineSnapshotHash,
|
|
currentHash: $currentSnapshotHash,
|
|
);
|
|
|
|
$rawEvidence = [
|
|
'change_type' => $changeType,
|
|
'summary' => [
|
|
'kind' => 'policy_snapshot',
|
|
'changed_fields' => ['snapshot_hash'],
|
|
],
|
|
'baseline' => [
|
|
'policy_id' => $policy->external_id,
|
|
'policy_version_id' => $baselineVersion?->getKey(),
|
|
'snapshot_hash' => $baselineSnapshotHash,
|
|
],
|
|
'current' => [
|
|
'policy_id' => $policy->external_id,
|
|
'policy_version_id' => $currentVersion?->getKey(),
|
|
'snapshot_hash' => $currentSnapshotHash,
|
|
],
|
|
];
|
|
|
|
$finding = Finding::query()->firstOrNew([
|
|
'tenant_id' => $tenant->getKey(),
|
|
'fingerprint' => $fingerprint,
|
|
]);
|
|
|
|
$wasNew = ! $finding->exists;
|
|
|
|
$finding->forceFill([
|
|
'finding_type' => Finding::FINDING_TYPE_DRIFT,
|
|
'scope_key' => $scopeKey,
|
|
'baseline_run_id' => $baseline->getKey(),
|
|
'current_run_id' => $current->getKey(),
|
|
'subject_type' => 'policy',
|
|
'subject_external_id' => (string) $policy->external_id,
|
|
'severity' => Finding::SEVERITY_MEDIUM,
|
|
'evidence_jsonb' => $this->evidence->sanitize($rawEvidence),
|
|
]);
|
|
|
|
if ($wasNew) {
|
|
$finding->forceFill([
|
|
'status' => Finding::STATUS_NEW,
|
|
'acknowledged_at' => null,
|
|
'acknowledged_by_user_id' => null,
|
|
]);
|
|
}
|
|
|
|
$finding->save();
|
|
|
|
if ($wasNew) {
|
|
$created++;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (! $baselineVersion instanceof PolicyVersion || ! $currentVersion instanceof PolicyVersion) {
|
|
continue;
|
|
}
|
|
|
|
$baselineAssignments = is_array($baselineVersion->assignments) ? $baselineVersion->assignments : [];
|
|
$currentAssignments = is_array($currentVersion->assignments) ? $currentVersion->assignments : [];
|
|
|
|
$baselineAssignmentsHash = $this->hasher->hashNormalized($baselineAssignments);
|
|
$currentAssignmentsHash = $this->hasher->hashNormalized($currentAssignments);
|
|
|
|
if ($baselineAssignmentsHash !== $currentAssignmentsHash) {
|
|
$fingerprint = $this->hasher->fingerprint(
|
|
tenantId: (int) $tenant->getKey(),
|
|
scopeKey: $scopeKey,
|
|
subjectType: 'assignment',
|
|
subjectExternalId: (string) $policy->external_id,
|
|
changeType: 'modified',
|
|
baselineHash: (string) ($baselineAssignmentsHash ?? ''),
|
|
currentHash: (string) ($currentAssignmentsHash ?? ''),
|
|
);
|
|
|
|
$rawEvidence = [
|
|
'change_type' => 'modified',
|
|
'summary' => [
|
|
'kind' => 'policy_assignments',
|
|
'changed_fields' => ['assignments_hash'],
|
|
],
|
|
'baseline' => [
|
|
'policy_id' => $policy->external_id,
|
|
'policy_version_id' => $baselineVersion->getKey(),
|
|
'assignments_hash' => $baselineAssignmentsHash,
|
|
],
|
|
'current' => [
|
|
'policy_id' => $policy->external_id,
|
|
'policy_version_id' => $currentVersion->getKey(),
|
|
'assignments_hash' => $currentAssignmentsHash,
|
|
],
|
|
];
|
|
|
|
$finding = Finding::query()->firstOrNew([
|
|
'tenant_id' => $tenant->getKey(),
|
|
'fingerprint' => $fingerprint,
|
|
]);
|
|
|
|
$wasNew = ! $finding->exists;
|
|
|
|
$finding->forceFill([
|
|
'finding_type' => Finding::FINDING_TYPE_DRIFT,
|
|
'scope_key' => $scopeKey,
|
|
'baseline_run_id' => $baseline->getKey(),
|
|
'current_run_id' => $current->getKey(),
|
|
'subject_type' => 'assignment',
|
|
'subject_external_id' => (string) $policy->external_id,
|
|
'severity' => Finding::SEVERITY_MEDIUM,
|
|
'evidence_jsonb' => $this->evidence->sanitize($rawEvidence),
|
|
]);
|
|
|
|
if ($wasNew) {
|
|
$finding->forceFill([
|
|
'status' => Finding::STATUS_NEW,
|
|
'acknowledged_at' => null,
|
|
'acknowledged_by_user_id' => null,
|
|
]);
|
|
}
|
|
|
|
$finding->save();
|
|
|
|
if ($wasNew) {
|
|
$created++;
|
|
}
|
|
}
|
|
|
|
$baselineScopeTagIds = $this->scopeTagsNormalizer->normalizeIdsForHash($baselineVersion->scope_tags);
|
|
$currentScopeTagIds = $this->scopeTagsNormalizer->normalizeIdsForHash($currentVersion->scope_tags);
|
|
|
|
if ($baselineScopeTagIds === null || $currentScopeTagIds === null) {
|
|
continue;
|
|
}
|
|
|
|
$baselineScopeTagsHash = $this->hasher->hashNormalized($baselineScopeTagIds);
|
|
$currentScopeTagsHash = $this->hasher->hashNormalized($currentScopeTagIds);
|
|
|
|
if ($baselineScopeTagsHash === $currentScopeTagsHash) {
|
|
continue;
|
|
}
|
|
|
|
$fingerprint = $this->hasher->fingerprint(
|
|
tenantId: (int) $tenant->getKey(),
|
|
scopeKey: $scopeKey,
|
|
subjectType: 'scope_tag',
|
|
subjectExternalId: (string) $policy->external_id,
|
|
changeType: 'modified',
|
|
baselineHash: $baselineScopeTagsHash,
|
|
currentHash: $currentScopeTagsHash,
|
|
);
|
|
|
|
$rawEvidence = [
|
|
'change_type' => 'modified',
|
|
'summary' => [
|
|
'kind' => 'policy_scope_tags',
|
|
'changed_fields' => ['scope_tags_hash'],
|
|
],
|
|
'baseline' => [
|
|
'policy_id' => $policy->external_id,
|
|
'policy_version_id' => $baselineVersion->getKey(),
|
|
'scope_tags_hash' => $baselineScopeTagsHash,
|
|
],
|
|
'current' => [
|
|
'policy_id' => $policy->external_id,
|
|
'policy_version_id' => $currentVersion->getKey(),
|
|
'scope_tags_hash' => $currentScopeTagsHash,
|
|
],
|
|
];
|
|
|
|
$finding = Finding::query()->firstOrNew([
|
|
'tenant_id' => $tenant->getKey(),
|
|
'fingerprint' => $fingerprint,
|
|
]);
|
|
|
|
$wasNew = ! $finding->exists;
|
|
|
|
$finding->forceFill([
|
|
'finding_type' => Finding::FINDING_TYPE_DRIFT,
|
|
'scope_key' => $scopeKey,
|
|
'baseline_run_id' => $baseline->getKey(),
|
|
'current_run_id' => $current->getKey(),
|
|
'subject_type' => 'scope_tag',
|
|
'subject_external_id' => (string) $policy->external_id,
|
|
'severity' => Finding::SEVERITY_MEDIUM,
|
|
'evidence_jsonb' => $this->evidence->sanitize($rawEvidence),
|
|
]);
|
|
|
|
if ($wasNew) {
|
|
$finding->forceFill([
|
|
'status' => Finding::STATUS_NEW,
|
|
'acknowledged_at' => null,
|
|
'acknowledged_by_user_id' => null,
|
|
]);
|
|
}
|
|
|
|
$finding->save();
|
|
|
|
if ($wasNew) {
|
|
$created++;
|
|
}
|
|
}
|
|
});
|
|
|
|
return $created;
|
|
}
|
|
|
|
private function versionForRun(Policy $policy, InventorySyncRun $run): ?PolicyVersion
|
|
{
|
|
if (! $run->finished_at) {
|
|
return null;
|
|
}
|
|
|
|
return PolicyVersion::query()
|
|
->where('tenant_id', $policy->tenant_id)
|
|
->where('policy_id', $policy->getKey())
|
|
->where('captured_at', '<=', $run->finished_at)
|
|
->latest('captured_at')
|
|
->first();
|
|
}
|
|
}
|