onQueue(isCloud() ? maintenance_queue() : 'default'); } public function uniqueId(): string { return (string) $this->team->id; } public function handle(): void { if (! instanceSettings()->is_image_scanning_allowed) { return; } /** @var list> $marks */ $marks = []; $resources = []; foreach (TeamVulnerabilities::load($this->team->id)->entries as $entry) { if (! $entry['scanned']) { continue; } try { $resource = $this->resourceDigest($entry['resource'], $entry['scans'], $entry['security'], $marks); } catch (Throwable $e) { report($e); continue; } if ($resource !== null) { $resources[] = $resource; } } $ignores = $this->endingIgnores(); $servers = $this->failingServers(); if ($resources !== [] || $ignores->isNotEmpty() || $servers->isNotEmpty()) { $order = array_flip(TeamVulnerabilities::STATUS_ORDER); usort($resources, fn (array $a, array $b): int => [$order[$a['status']] ?? 99, $a['name']] <=> [$order[$b['status']] ?? 99, $b['name']]); $this->team->notify(new VulnerabilityDigest([ 'resources' => $resources, 'ignores' => $ignores->map(fn (ImageScanIgnore $ignore): array => [ 'resource' => $ignore->resource instanceof Model ? (string) ScanNotifications::pageResource($ignore->resource)->getAttribute('name') : null, 'id' => $ignore->vulnerability_id, 'package' => $ignore->package, 'expires_at' => $ignore->expires_at->toDateString(), ])->values()->all(), 'servers' => $servers->map(fn (Server $server): array => [ 'name' => (string) $server->name, 'url' => rtrim(base_url(), '/').'/server/'.$server->uuid, ])->values()->all(), ])); foreach ($ignores as $ignore) { Cache::put(self::ignoreCacheKey($ignore), $ignore->expires_at->toIso8601String(), $ignore->expires_at->copy()->addDay()); } foreach ($servers as $server) { ImageRescanJob::markFailureNotified($server); } } foreach ($marks as $mark) { try { ScanNotifications::applyDigest($mark); } catch (Throwable $e) { report($e); } } } public static function ignoreCacheKey(ImageScanIgnore $ignore): string { return "image-scan-digest:ignore:{$ignore->id}"; } /** * The digest line of one resource page (a service covers its applications and databases), or null. Each scan * owner has its own notification state; its current scans are decoded one owner at a time. * * @param Collection $current The current scans, with their owner. * @param list> $marks Gets the marks of the resource only when its digest succeeded. * @return array{name: string, uuid: string, url: ?string, status: string, badge: string, action: ?string, why: list>}|null */ private function resourceDigest(Model $resource, Collection $current, ResourceSecurity $security, array &$marks): ?array { $resourceMarks = []; $digest = $this->buildResourceDigest($resource, $current, $security, $resourceMarks); array_push($marks, ...$resourceMarks); return $digest; } /** * @param Collection $current * @param list> $marks * @return array{name: string, uuid: string, url: ?string, status: string, badge: string, action: ?string, why: list>}|null */ private function buildResourceDigest(Model $resource, Collection $current, ResourceSecurity $security, array &$marks): ?array { $owners = $current->map(fn (ImageScan $scan) => $scan->resource) ->filter(fn ($owner): bool => $owner instanceof Model) ->unique(fn (Model $owner): string => $owner->getMorphClass().':'.$owner->getKey()); $plans = $owners->map(fn (Model $owner): ?array => ScanNotifications::digestPlan($owner))->filter()->values(); if ($plans->isEmpty()) { return null; } $needsCheck = $security->status === SecurityStatusType::CheckNeeded; if ($needsCheck && $security->checkReason === SecurityStatus::REASON_VERSIONS_UNCHECKED && $plans->contains('pending', true)) { return null; } $active = $plans->where('baseline', false); $holds = fn (array $plan): bool => count($plan['newFixable']) > count($plan['newSignalFixable']) || count($plan['newCandidates']) > count($plan['newSignalCandidates']); // Fixes of problems with an exploit signal are never held; other new problems are held at most MAX_HELD_DIGESTS digests. $bypass = $needsCheck && $active->contains(fn (array $plan): bool => $plan['newSignalFixable'] !== [] || $plan['newSignalCandidates'] !== []); $release = $needsCheck && $active->contains(fn (array $plan): bool => $plan['heldDigests'] >= self::MAX_HELD_DIGESTS) && $active->contains($holds); $report = match ($security->status) { SecurityStatusType::UpdateNow, SecurityStatusType::UpdateAvailable => $active->contains(fn (array $plan): bool => $plan['newFixable'] !== [] || $plan['newCandidates'] !== []), SecurityStatusType::CheckNeeded => $active->isNotEmpty() && ($bypass || $release || ! $active->contains('checkReported', true)), default => false, }; foreach ($plans as $plan) { $mark = $plan['mark']; $mark['check_reported'] = $needsCheck; $mark['held_digests'] = 0; if ($needsCheck && ! $plan['baseline'] && ! $release) { $mark['add_digest_ids'] = array_values(array_unique(array_map(fn ($finding): string => $finding->id, $plan['newSignalFixable']))); $mark['add_candidates'] = $plan['newSignalCandidates']; $mark['held_digests'] = $holds($plan) ? $plan['heldDigests'] + 1 : 0; } $marks[] = $mark; } if (! $report) { return null; } $action = TeamVulnerabilities::action($security)['text'] ?? null; if ($needsCheck && ($bypass || $release) && filled($security->primary()?->sentence)) { $action = (string) $security->primary()->sentence; } return [ 'name' => (string) $resource->getAttribute('name'), 'uuid' => (string) $resource->getAttribute('uuid'), 'url' => ScanNotifications::link($resource), 'status' => $security->status->value, 'badge' => (string) SecuritySentences::badge($security->status), 'action' => $action, 'why' => $needsCheck && ! $bypass && ! $release ? [] : $security->why, ]; } /** * Ignores of the team that end within IGNORE_WINDOW_DAYS and no digest named with this end date. * * @return Collection */ private function endingIgnores(): Collection { return ImageScanIgnore::query() ->where('team_id', $this->team->id) ->whereNotNull('expires_at') ->where('expires_at', '>', now()) ->where('expires_at', '<=', now()->addDays(self::IGNORE_WINDOW_DAYS)) ->orderBy('expires_at') ->get() ->reject(fn (ImageScanIgnore $ignore): bool => Cache::get(self::ignoreCacheKey($ignore)) === $ignore->expires_at->toIso8601String()) ->values(); } /** * Servers of the team that still scan images and whose daily rescan failed 2 days in a row, not named yet for * this run of failures. * * @return Collection */ private function failingServers(): Collection { return Server::query() ->where('team_id', $this->team->id) ->with('settings') ->get() ->filter(fn (Server $server): bool => (bool) $server->settings?->is_image_scan_enabled && ! $server->isTransferredAway() && ImageRescanJob::failedDays($server) >= 2 && ! ImageRescanJob::isFailureNotified($server)) ->values(); } }