484 lines
19 KiB
PHP
484 lines
19 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Drift;
|
|
|
|
use App\Models\Finding;
|
|
use App\Models\OperationRun;
|
|
use App\Models\Policy;
|
|
use App\Models\PolicyVersion;
|
|
use App\Models\Tenant;
|
|
use App\Models\Workspace;
|
|
use App\Services\Drift\Normalizers\ScopeTagsNormalizer;
|
|
use App\Services\Drift\Normalizers\SettingsNormalizer;
|
|
use App\Services\Findings\FindingSlaPolicy;
|
|
use App\Services\Settings\SettingsResolver;
|
|
use Carbon\CarbonImmutable;
|
|
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,
|
|
private readonly SettingsResolver $settingsResolver,
|
|
private readonly FindingSlaPolicy $slaPolicy,
|
|
) {}
|
|
|
|
public function generate(Tenant $tenant, OperationRun $baseline, OperationRun $current, string $scopeKey): int
|
|
{
|
|
if (! $baseline->completed_at || ! $current->completed_at) {
|
|
throw new RuntimeException('Baseline/current run must be finished.');
|
|
}
|
|
|
|
$observedAt = CarbonImmutable::instance($current->completed_at);
|
|
|
|
/** @var array<string, mixed> $selection */
|
|
$selection = is_array($current->context) ? $current->context : [];
|
|
|
|
$policyTypes = Arr::get($selection, 'policy_types');
|
|
if (! is_array($policyTypes)) {
|
|
$policyTypes = [];
|
|
}
|
|
|
|
$policyTypes = array_values(array_filter(array_map('strval', $policyTypes)));
|
|
|
|
$created = 0;
|
|
$resolvedSeverity = $this->resolveSeverityForFindingType($tenant, Finding::FINDING_TYPE_DRIFT);
|
|
$seenRecurrenceKeys = [];
|
|
|
|
Policy::query()
|
|
->where('tenant_id', $tenant->getKey())
|
|
->whereIn('policy_type', $policyTypes)
|
|
->orderBy('id')
|
|
->chunk(200, function ($policies) use ($tenant, $baseline, $current, $scopeKey, $resolvedSeverity, $observedAt, &$seenRecurrenceKeys, &$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',
|
|
};
|
|
|
|
$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,
|
|
],
|
|
];
|
|
|
|
$dimension = $this->recurrenceDimension('policy_snapshot', $changeType);
|
|
$wasNew = $this->upsertDriftFinding(
|
|
tenant: $tenant,
|
|
baseline: $baseline,
|
|
current: $current,
|
|
scopeKey: $scopeKey,
|
|
subjectType: 'policy',
|
|
subjectExternalId: (string) $policy->external_id,
|
|
severity: $resolvedSeverity,
|
|
dimension: $dimension,
|
|
rawEvidence: $rawEvidence,
|
|
observedAt: $observedAt,
|
|
seenRecurrenceKeys: $seenRecurrenceKeys,
|
|
);
|
|
|
|
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) {
|
|
$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,
|
|
],
|
|
];
|
|
|
|
$dimension = $this->recurrenceDimension('policy_assignments', 'modified');
|
|
$wasNew = $this->upsertDriftFinding(
|
|
tenant: $tenant,
|
|
baseline: $baseline,
|
|
current: $current,
|
|
scopeKey: $scopeKey,
|
|
subjectType: 'assignment',
|
|
subjectExternalId: (string) $policy->external_id,
|
|
severity: $resolvedSeverity,
|
|
dimension: $dimension,
|
|
rawEvidence: $rawEvidence,
|
|
observedAt: $observedAt,
|
|
seenRecurrenceKeys: $seenRecurrenceKeys,
|
|
);
|
|
|
|
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;
|
|
}
|
|
|
|
$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,
|
|
],
|
|
];
|
|
|
|
$dimension = $this->recurrenceDimension('policy_scope_tags', 'modified');
|
|
$wasNew = $this->upsertDriftFinding(
|
|
tenant: $tenant,
|
|
baseline: $baseline,
|
|
current: $current,
|
|
scopeKey: $scopeKey,
|
|
subjectType: 'scope_tag',
|
|
subjectExternalId: (string) $policy->external_id,
|
|
severity: $resolvedSeverity,
|
|
dimension: $dimension,
|
|
rawEvidence: $rawEvidence,
|
|
observedAt: $observedAt,
|
|
seenRecurrenceKeys: $seenRecurrenceKeys,
|
|
);
|
|
|
|
if ($wasNew) {
|
|
$created++;
|
|
}
|
|
}
|
|
});
|
|
|
|
$this->resolveStaleDriftFindings(
|
|
tenant: $tenant,
|
|
scopeKey: $scopeKey,
|
|
seenRecurrenceKeys: $seenRecurrenceKeys,
|
|
observedAt: $observedAt,
|
|
);
|
|
|
|
return $created;
|
|
}
|
|
|
|
private function recurrenceDimension(string $kind, string $changeType): string
|
|
{
|
|
$kind = strtolower(trim($kind));
|
|
$changeType = strtolower(trim($changeType));
|
|
|
|
return match ($kind) {
|
|
'policy_snapshot', 'baseline_compare' => sprintf('%s:%s', $kind, $changeType),
|
|
default => $kind,
|
|
};
|
|
}
|
|
|
|
private function recurrenceKey(
|
|
int $tenantId,
|
|
string $scopeKey,
|
|
string $subjectType,
|
|
string $subjectExternalId,
|
|
string $dimension,
|
|
): string {
|
|
return hash('sha256', sprintf(
|
|
'drift:%d:%s:%s:%s:%s',
|
|
$tenantId,
|
|
$scopeKey,
|
|
$subjectType,
|
|
$subjectExternalId,
|
|
$dimension,
|
|
));
|
|
}
|
|
|
|
/**
|
|
* @param array<int, string> $seenRecurrenceKeys
|
|
* @param array<string, mixed> $rawEvidence
|
|
*/
|
|
private function upsertDriftFinding(
|
|
Tenant $tenant,
|
|
OperationRun $baseline,
|
|
OperationRun $current,
|
|
string $scopeKey,
|
|
string $subjectType,
|
|
string $subjectExternalId,
|
|
string $severity,
|
|
string $dimension,
|
|
array $rawEvidence,
|
|
CarbonImmutable $observedAt,
|
|
array &$seenRecurrenceKeys,
|
|
): bool {
|
|
$tenantId = (int) $tenant->getKey();
|
|
$recurrenceKey = $this->recurrenceKey($tenantId, $scopeKey, $subjectType, $subjectExternalId, $dimension);
|
|
$seenRecurrenceKeys[] = $recurrenceKey;
|
|
|
|
$finding = Finding::query()
|
|
->where('tenant_id', $tenant->getKey())
|
|
->where('finding_type', Finding::FINDING_TYPE_DRIFT)
|
|
->where('recurrence_key', $recurrenceKey)
|
|
->first();
|
|
|
|
$wasNew = ! $finding instanceof Finding;
|
|
|
|
if ($wasNew) {
|
|
$finding = new Finding;
|
|
} else {
|
|
$this->observeFinding($finding, $observedAt, (int) $current->getKey());
|
|
}
|
|
|
|
$finding->forceFill([
|
|
'tenant_id' => $tenantId,
|
|
'finding_type' => Finding::FINDING_TYPE_DRIFT,
|
|
'scope_key' => $scopeKey,
|
|
'baseline_operation_run_id' => $baseline->getKey(),
|
|
'current_operation_run_id' => $current->getKey(),
|
|
'recurrence_key' => $recurrenceKey,
|
|
'fingerprint' => $recurrenceKey,
|
|
'subject_type' => $subjectType,
|
|
'subject_external_id' => $subjectExternalId,
|
|
'severity' => $severity,
|
|
'evidence_jsonb' => $this->evidence->sanitize($rawEvidence),
|
|
]);
|
|
|
|
if ($wasNew) {
|
|
$slaDays = $this->slaPolicy->daysForSeverity($severity, $tenant);
|
|
|
|
$finding->forceFill([
|
|
'status' => Finding::STATUS_NEW,
|
|
'acknowledged_at' => null,
|
|
'acknowledged_by_user_id' => null,
|
|
'first_seen_at' => $observedAt,
|
|
'last_seen_at' => $observedAt,
|
|
'times_seen' => 1,
|
|
'sla_days' => $slaDays,
|
|
'due_at' => $this->slaPolicy->dueAtForSeverity($severity, $tenant, $observedAt),
|
|
]);
|
|
}
|
|
|
|
$status = (string) $finding->status;
|
|
|
|
if ($status === Finding::STATUS_RESOLVED) {
|
|
$resolvedAt = $finding->resolved_at;
|
|
|
|
if ($resolvedAt === null || $observedAt->greaterThan(CarbonImmutable::instance($resolvedAt))) {
|
|
$slaDays = $this->slaPolicy->daysForSeverity($severity, $tenant);
|
|
|
|
$finding->forceFill([
|
|
'status' => Finding::STATUS_REOPENED,
|
|
'reopened_at' => $observedAt,
|
|
'resolved_at' => null,
|
|
'resolved_reason' => null,
|
|
'closed_at' => null,
|
|
'closed_reason' => null,
|
|
'closed_by_user_id' => null,
|
|
'sla_days' => $slaDays,
|
|
'due_at' => $this->slaPolicy->dueAtForSeverity($severity, $tenant, $observedAt),
|
|
]);
|
|
}
|
|
}
|
|
|
|
$finding->save();
|
|
|
|
return $wasNew;
|
|
}
|
|
|
|
private function observeFinding(Finding $finding, CarbonImmutable $observedAt, int $currentOperationRunId): void
|
|
{
|
|
if ($finding->first_seen_at === null) {
|
|
$finding->first_seen_at = $observedAt;
|
|
}
|
|
|
|
if ($finding->last_seen_at === null || $observedAt->greaterThan(CarbonImmutable::instance($finding->last_seen_at))) {
|
|
$finding->last_seen_at = $observedAt;
|
|
}
|
|
|
|
$timesSeen = is_numeric($finding->times_seen) ? (int) $finding->times_seen : 0;
|
|
|
|
if ((int) ($finding->current_operation_run_id ?? 0) !== $currentOperationRunId) {
|
|
$finding->times_seen = max(0, $timesSeen) + 1;
|
|
} elseif ($timesSeen < 1) {
|
|
$finding->times_seen = 1;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param array<int, string> $seenRecurrenceKeys
|
|
*/
|
|
private function resolveStaleDriftFindings(
|
|
Tenant $tenant,
|
|
string $scopeKey,
|
|
array $seenRecurrenceKeys,
|
|
CarbonImmutable $observedAt,
|
|
): void {
|
|
$staleFindingsQuery = Finding::query()
|
|
->where('tenant_id', $tenant->getKey())
|
|
->where('finding_type', Finding::FINDING_TYPE_DRIFT)
|
|
->where('scope_key', $scopeKey)
|
|
->whereNotNull('recurrence_key')
|
|
->whereIn('status', Finding::openStatusesForQuery());
|
|
|
|
if ($seenRecurrenceKeys !== []) {
|
|
$staleFindingsQuery->whereNotIn('recurrence_key', $seenRecurrenceKeys);
|
|
}
|
|
|
|
$staleFindings = $staleFindingsQuery->get();
|
|
|
|
foreach ($staleFindings as $finding) {
|
|
if (! $finding instanceof Finding) {
|
|
continue;
|
|
}
|
|
|
|
$finding->forceFill([
|
|
'status' => Finding::STATUS_RESOLVED,
|
|
'resolved_at' => $observedAt,
|
|
'resolved_reason' => 'no_longer_detected',
|
|
])->save();
|
|
}
|
|
}
|
|
|
|
private function versionForRun(Policy $policy, OperationRun $run): ?PolicyVersion
|
|
{
|
|
if (! $run->completed_at) {
|
|
return null;
|
|
}
|
|
|
|
return PolicyVersion::query()
|
|
->where('tenant_id', $policy->tenant_id)
|
|
->where('policy_id', $policy->getKey())
|
|
->where('captured_at', '<=', $run->completed_at)
|
|
->latest('captured_at')
|
|
->first();
|
|
}
|
|
|
|
private function resolveSeverityForFindingType(Tenant $tenant, string $findingType): string
|
|
{
|
|
$workspace = $tenant->workspace;
|
|
|
|
if (! $workspace instanceof Workspace && is_numeric($tenant->workspace_id)) {
|
|
$workspace = Workspace::query()->whereKey((int) $tenant->workspace_id)->first();
|
|
}
|
|
|
|
if (! $workspace instanceof Workspace) {
|
|
return Finding::SEVERITY_MEDIUM;
|
|
}
|
|
|
|
$resolved = $this->settingsResolver->resolveValue(
|
|
workspace: $workspace,
|
|
domain: 'drift',
|
|
key: 'severity_mapping',
|
|
tenant: $tenant,
|
|
);
|
|
|
|
if (! is_array($resolved)) {
|
|
return Finding::SEVERITY_MEDIUM;
|
|
}
|
|
|
|
foreach ($resolved as $mappedFindingType => $mappedSeverity) {
|
|
if (! is_string($mappedFindingType) || ! is_string($mappedSeverity)) {
|
|
continue;
|
|
}
|
|
|
|
if ($mappedFindingType !== $findingType) {
|
|
continue;
|
|
}
|
|
|
|
$normalizedSeverity = strtolower($mappedSeverity);
|
|
|
|
if (in_array($normalizedSeverity, $this->supportedSeverities(), true)) {
|
|
return $normalizedSeverity;
|
|
}
|
|
|
|
break;
|
|
}
|
|
|
|
return Finding::SEVERITY_MEDIUM;
|
|
}
|
|
|
|
/**
|
|
* @return array<int, string>
|
|
*/
|
|
private function supportedSeverities(): array
|
|
{
|
|
return [
|
|
Finding::SEVERITY_LOW,
|
|
Finding::SEVERITY_MEDIUM,
|
|
Finding::SEVERITY_HIGH,
|
|
Finding::SEVERITY_CRITICAL,
|
|
];
|
|
}
|
|
}
|