onQueue(isCloud() ? maintenance_queue() : 'default'); } public function uniqueId(): string { return (string) $this->server->id; } /** * Scanning is on for the server and allowed on the instance, and this instance can reach the server. */ public static function canRunOn(Server $server): bool { return (bool) $server->settings?->is_image_scan_enabled && (bool) InstanceSettings::query()->whereKey(0)->value('is_image_scanning_allowed') && ! $server->isTransferredAway() && $server->isFunctional(); } /** * Counts the days in a row on which the rescan of the server failed, for the digest (spec 8). A failed day is * one on which the running containers could not be listed or no image could be scanned. Success resets it. */ public static function recordRescanResult(Server $server, bool $succeeded): void { $key = self::failuresCacheKey($server); if ($succeeded) { Cache::forget($key); return; } $state = Cache::get($key); $today = now()->toDateString(); $state = match (true) { ! is_array($state) => ['days' => 1, 'last' => $today, 'notified' => false], ($state['last'] ?? null) === $today => $state, ($state['last'] ?? null) === now()->subDay()->toDateString() => [...$state, 'days' => (int) ($state['days'] ?? 0) + 1, 'last' => $today], default => ['days' => 1, 'last' => $today, 'notified' => false], }; Cache::put($key, $state, now()->addDays(self::FAILURE_STATE_DAYS)); } /** * Days in a row on which the rescan of the server failed, 0 after a success. */ public static function failedDays(Server $server): int { $state = Cache::get(self::failuresCacheKey($server)); return is_array($state) ? (int) ($state['days'] ?? 0) : 0; } public static function isFailureNotified(Server $server): bool { return (bool) data_get(Cache::get(self::failuresCacheKey($server)), 'notified', false); } public static function markFailureNotified(Server $server): void { $state = Cache::get(self::failuresCacheKey($server)); if (is_array($state)) { Cache::put(self::failuresCacheKey($server), [...$state, 'notified' => true], now()->addDays(self::FAILURE_STATE_DAYS)); } } private static function failuresCacheKey(Server $server): string { return "image-rescan:failures:{$server->id}"; } public function handle(): void { $server = $this->server->fresh(['settings']); if (! $server instanceof Server || ! self::canRunOn($server)) { return; } $commands = new ScannerCommands($server, Str::lower(Str::random(24))); // Never sleeps on the scan lock, so a deploy gate gets it first. A busy lock runs the job again later and it // goes on where it stopped; the last attempt runs without the busy steps. $lastAttempt = $this->attempts() >= $this->tries; $progress = $this->progress(); if (! $progress['database']) { if (! $this->updateDatabase($server, $commands) && ! $lastAttempt) { $this->retryLater($progress); return; } $progress['database'] = true; } $listing = $this->runningContainers($server, $commands); if ($listing === null) { self::recordRescanResult($server, false); $this->forgetProgress(); return; } [$targets, $runningRefs] = $this->targets($server, $listing['containers']); foreach ($targets as $imageId => $imageTargets) { if (in_array($imageId, $progress['done'], true)) { continue; } try { $scans = ScanImage::make()->rescan($server, $imageId, $imageTargets, 0, retryWhenBusy: ! $lastAttempt); if (! $lastAttempt && $scans !== [] && collect($scans)->every(fn (ImageScan $scan): bool => ScanImage::lockWasBusy($scan))) { $this->retryLater($progress); return; } $progress['scanned'] += count($scans); $progress['completed'] += count(array_filter($scans, fn (ImageScan $scan): bool => $scan->status === ImageScanStatus::Completed)); $this->refreshCandidates($scans); } catch (Throwable $e) { $progress['errors']++; report($e); } $progress['done'][] = $imageId; } self::recordRescanResult($server, $progress['completed'] > 0 || ($progress['scanned'] === 0 && $progress['errors'] === 0)); $this->forgetProgress(); $this->refreshCountsAfterEndedIgnores($server); // A cut list would make running images look stopped. if (! $listing['truncated']) { $this->cleanUp($server, $commands, $targets, $runningRefs); } } /** * An ignore that ended counts its findings again: the stored counts of the resources it covered are written again, * also for images that no longer run. Once per team and day, for ignores that ended in the last two days: by their * end date, or by the 90 days after which an ignore no longer covers a problem with an exploit signal. */ private function refreshCountsAfterEndedIgnores(Server $server): void { try { $signalLimit = now()->subDays(IgnoreMatcher::EXPLOIT_SIGNAL_IGNORE_DAYS); $ended = ImageScanIgnore::query() ->where('team_id', $server->team_id) ->where(fn ($query) => $query ->where(fn ($query) => $query ->whereNotNull('expires_at') ->where('expires_at', '<=', now()) ->where('expires_at', '>', now()->subDays(2))) ->orWhere(fn ($query) => $query ->where('created_at', '<=', $signalLimit) ->where('created_at', '>', $signalLimit->copy()->subDays(2)) ->where(fn ($query) => $query->whereNull('expires_at')->orWhere('expires_at', '>', now()->subDays(2))))) ->get(); if ($ended->isEmpty() || ! Cache::add("image-scan:ended-ignores:{$server->team_id}:".now()->toDateString(), true, now()->addDay())) { return; } if ($ended->contains(fn (ImageScanIgnore $ignore): bool => $ignore->resource_id === null)) { RefreshImageScanSummariesJob::dispatch((int) $server->team_id); return; } $ended->map(fn (ImageScanIgnore $ignore) => $ignore->resource) ->filter(fn ($resource): bool => $resource instanceof Model) ->unique(fn (Model $resource): string => $resource->getMorphClass().':'.$resource->getKey()) ->each(fn (Model $resource) => ResourceScans::refreshSummaries(ResourceScans::currentFor($resource))); } catch (Throwable $e) { report($e); } } /** * What earlier attempts of this job did: database updated, image IDs rescanned, and the counts of the result. * * @return array{database: bool, done: list, scanned: int, completed: int, errors: int} */ private function progress(): array { $empty = ['database' => false, 'done' => [], 'scanned' => 0, 'completed' => 0, 'errors' => 0]; $key = $this->progressCacheKey(); $stored = $key === null ? null : Cache::get($key); return is_array($stored) ? [...$empty, ...$stored] : $empty; } /** * @param array{database: bool, done: list, scanned: int, completed: int, errors: int} $progress */ private function retryLater(array $progress): void { $key = $this->progressCacheKey(); if ($key !== null) { Cache::put($key, $progress, now()->addHours(12)); } $this->release(ScanResourceImagesJob::lockRetryDelay($this->attempts())); } private function forgetProgress(): void { $key = $this->progressCacheKey(); if ($key !== null) { Cache::forget($key); } } /** * A released job runs again from its stored payload, which keeps its UUID. */ private function progressCacheKey(): ?string { $uuid = $this->job?->uuid(); return $uuid === null ? null : "image-rescan:progress:{$uuid}"; } /** * Looks for newer versions of each rescanned image that has a relevant finding (spec 4.5, 15.4). * The rescan released the lock; each candidate scan takes it again. Runs here, one image after the other, under * the unique lock of RefreshCandidatesJob: a queued or running job of the same scan does the check instead. When * the scan lock is busy, the check moves to a queued job that retries later. * * @param list $scans */ private function refreshCandidates(array $scans): void { $uniqueLock = new UniqueLock(Cache::store()); foreach ($scans as $scan) { try { if (! RefreshCandidates::shouldRefresh($scan)) { continue; } $job = new RefreshCandidatesJob($scan); if (! $uniqueLock->acquire($job)) { continue; } $busy = false; try { RefreshCandidates::run($scan, 0, retryWhenBusy: true); } catch (ScanLockBusy) { $busy = true; } finally { $uniqueLock->release($job); } if ($busy) { RefreshCandidatesJob::queueCheck($scan); } } catch (Throwable $e) { report($e); } } } /** * Retention and SBOM deletion under the scan lock, so a scan that starts meanwhile keeps its SBOM and rows. When * the lock is busy they skip a day. * * @param array> $targets * @param array> $runningRefs */ private function cleanUp(Server $server, ScannerCommands $commands, array $targets, array $runningRefs): void { $lock = ScanImage::serverLock($server); if (! ScanImage::acquireServerLock($lock, $server, 0)) { return; } try { $this->applyRetention($server, $targets, $runningRefs); $this->deleteUnusedSboms($server, $commands); } catch (Throwable $e) { report($e); } finally { $lock->release(); } } /** * Updates the database under the scan lock, so no scan reads it while it changes. A failed update keeps the * existing database; the scans report when it is too old. False when the lock is busy. */ private function updateDatabase(Server $server, ScannerCommands $commands): bool { $lock = ScanImage::serverLock($server); if (! ScanImage::acquireServerLock($lock, $server, 0)) { return false; } $url = InstanceSettings::get()->image_scan_db_url; try { $output = instant_remote_process($commands->dbUpdate(is_string($url) && trim($url) !== '' ? trim($url) : null), $server, throwError: false, timeout: self::TIMEOUT_DB_UPDATE); if ((ScannerCommands::parseOutput($output)['exit_codes']['db-update'] ?? null) === 0) { PrepareImageScannerJob::storeDetails($server, $commands); } } catch (Throwable $e) { report($e); } finally { $lock->release(); } return true; } /** * The running containers, or null when they could not be listed. `truncated`: the list was cut at the output cap. * * @return array{containers: list}>, truncated: bool}|null */ private function runningContainers(Server $server, ScannerCommands $commands): ?array { try { $parsed = ScannerCommands::parseOutput(instant_remote_process($commands->runningContainers(), $server, throwError: false, timeout: self::TIMEOUT_DEFAULT)); } catch (Throwable $e) { report($e); return null; } if (($parsed['exit_codes']['containers'] ?? null) !== 0) { return null; } $payload = $parsed['payload']; $truncated = strlen($payload) > ScannerCommands::OUTPUT_LIMIT; if ($truncated) { $payload = substr($payload, 0, (int) strrpos(substr($payload, 0, ScannerCommands::OUTPUT_LIMIT), "\n")); } $containers = []; foreach (explode("\n", $payload) as $line) { $container = json_decode(trim($line), true); if (! is_array($container) || ! is_string($container['id'] ?? null) || preg_match('/\Asha256:[a-f0-9]{64}\z/', $container['id']) !== 1) { continue; } $containers[] = [ 'id' => $container['id'], 'ref' => is_string($container['ref'] ?? null) ? $container['ref'] : '', 'labels' => collect(is_array($container['labels'] ?? null) ? $container['labels'] : []) ->filter(fn ($value, $key) => is_string($key) && is_scalar($value)) ->map(fn ($value) => (string) $value), ]; } return ['containers' => $containers, 'truncated' => $truncated]; } /** * The resources of this server that run each image ID, with the image ref of their container, and the image * refs each resource runs. Containers of resources on other servers, of previews that no longer exist, and of * no resource are skipped. * * @param list}> $containers * @return array{0: array>, 1: array>} */ private function targets(Server $server, array $containers): array { $applications = $server->applications(); $databases = $server->databases(); $services = $server->services()->with(['applications', 'databases'])->get(); $targets = []; $runningRefs = []; foreach ($containers as $container) { $labels = $container['labels']; if (filter_var($labels->get('com.docker.compose.oneoff'), FILTER_VALIDATE_BOOLEAN)) { continue; } $resource = match (true) { isContainerOfType($labels, 'application') => $this->applicationOwner($applications, $labels), isContainerOfType($labels, 'service') => resolveServiceContainerOwner($services, $labels)[2], isContainerOfType($labels, 'database') => resolveContainerOwner($databases, $labels, 'database'), default => null, }; if (! $resource instanceof Model) { continue; } $key = $resource->getMorphClass().':'.$resource->getKey(); $targets[$container['id']][$key] ??= [$resource, $container['ref'] !== '' ? $container['ref'] : $container['id']]; $runningRefs[$key][] = $container['ref']; } return [array_map('array_values', $targets), $runningRefs]; } /** * @param Collection $applications * @param Collection $labels */ private function applicationOwner(Collection $applications, Collection $labels): ?Application { $application = resolveContainerOwner($applications, $labels, 'application'); $pullRequestId = (int) $labels->get('coolify.pullRequestId', 0); if (! $application instanceof Application || $pullRequestId === 0) { return $application instanceof Application ? $application : null; } $previewExists = ApplicationPreview::query() ->where('application_id', $application->id) ->where('pull_request_id', $pullRequestId) ->exists(); return $previewExists ? $application : null; } /** * Keeps the scans of running images, of each application's rollback list, and the latest scans per resource; * deletes scans of previews that no longer exist (spec 4.3). Only rows of this server. * * @param array> $targets * @param array> $runningRefs */ private function applyRetention(Server $server, array $targets, array $runningRefs): void { $running = []; foreach ($targets as $imageId => $imageTargets) { foreach ($imageTargets as [$resource]) { $running[$resource->getMorphClass().':'.$resource->getKey().':'.$imageId] = true; } } ImageScan::query() ->where('server_id', $server->id) ->select(['resource_type', 'resource_id']) ->distinct() ->get() ->each(function (ImageScan $group) use ($server, $running, $runningRefs) { $scans = ImageScan::query() ->where('server_id', $server->id) ->where('resource_type', $group->resource_type) ->where('resource_id', $group->resource_id) ->with('deploymentQueue:id,pull_request_id') ->orderByRaw('coalesce(scanned_at, updated_at) desc') ->orderByDesc('id') ->get(['id', 'resource_type', 'resource_id', 'image_id', 'image_ref', 'deployment_queue_id', 'scanned_at', 'updated_at']); $application = $group->resource_type === (new Application)->getMorphClass() ? Application::withTrashed()->with('settings')->find($group->resource_id) : null; $rollback = $application !== null ? $this->rollbackList($server, $application, $runningRefs[$group->resource_type.':'.$group->resource_id] ?? []) : ['deployments' => [], 'refs' => []]; $previews = $application !== null ? $this->existingPreviews($application) : []; $delete = []; foreach ($scans as $index => $scan) { $pullRequestId = $this->previewPullRequestId($scan, $application); $previewGone = $pullRequestId !== null && ! in_array($pullRequestId, $previews, true); $isRunning = isset($running[$scan->resource_type.':'.$scan->resource_id.':'.$scan->image_id]); $keep = $index < self::KEEP_LATEST_SCANS || in_array((int) $scan->deployment_queue_id, $rollback['deployments'], true) || in_array($scan->image_ref, $rollback['refs'], true); // A row is per image, so a preview and production can share it: a running image always stays. if (! $isRunning && ($previewGone || ! $keep)) { $delete[] = $scan->id; } } if ($delete !== []) { ImageScan::query()->where('server_id', $server->id)->whereKey($delete)->delete(); } }); } /** * The deployments and image refs of the images that CleanupDocker keeps for rollbacks besides the running ones: * the latest `docker_images_to_keep` finished deployments whose image is not running, none when the server * turned off image retention. * * @param list $runningRefs * @return array{deployments: list, refs: list} */ private function rollbackList(Server $server, Application $application, array $runningRefs): array { $keep = $server->settings?->disable_application_image_retention ? 0 : max(0, (int) ($application->settings?->docker_images_to_keep ?? 2)); if ($keep === 0) { return ['deployments' => [], 'refs' => []]; } $deployments = ApplicationDeploymentQueue::query() ->where('application_id', (string) $application->id) ->where('pull_request_id', 0) ->where('status', ApplicationDeploymentStatus::FINISHED->value) ->orderByDesc('created_at') ->orderByDesc('id') ->get(['id', 'commit']); $repository = $application->docker_registry_image_name ?: $application->uuid; $commits = $deployments->pluck('commit') ->filter(fn ($commit) => filled($commit)) ->unique() ->reject(fn (string $commit) => in_array($repository.':'.substr($commit, 0, 128), $runningRefs, true)) ->take($keep) ->values(); return [ 'deployments' => $deployments->whereIn('commit', $commits->all())->pluck('id')->map(fn ($id) => (int) $id)->values()->all(), 'refs' => $commits->map(fn (string $commit) => $repository.':'.substr($commit, 0, 128))->all(), ]; } /** * @return list */ private function existingPreviews(Application $application): array { return ApplicationPreview::query() ->where('application_id', $application->id) ->pluck('pull_request_id') ->map(fn ($id) => (int) $id) ->all(); } /** * The pull request of a preview scan: from its deployment, else from the preview tag of its image * (`{image}:pr-{id}-{commit}`). Null for a scan of the application itself. */ private function previewPullRequestId(ImageScan $scan, ?Application $application): ?int { if ($application === null) { return null; } $fromDeployment = (int) ($scan->deploymentQueue?->pull_request_id ?? 0); if ($fromDeployment > 0) { return $fromDeployment; } $repository = preg_quote($application->docker_registry_image_name ?: $application->uuid, '/'); if (preg_match('/\A'.$repository.':pr-(\d+)-/', (string) $scan->image_ref, $match) === 1) { return (int) $match[1]; } return null; } /** * Deletes SBOMs of this server that no scan uses and that were not used for 30 days, files first. */ private function deleteUnusedSboms(Server $server, ScannerCommands $commands): void { $sboms = ImageSbom::query() ->where('server_id', $server->id) ->where('last_used_at', '<', now()->subDays(self::SBOM_UNUSED_DAYS)) ->whereDoesntHave('scans') ->orderBy('id') ->limit(self::MAX_SBOM_DELETES) ->get(['id', 'image_id']) ->filter(fn (ImageSbom $sbom) => preg_match('/\Asha256:[a-f0-9]{64}\z/', (string) $sbom->image_id) === 1); if ($sboms->isEmpty()) { return; } $output = instant_remote_process($commands->deleteSboms($sboms->pluck('image_id')->values()->all()), $server, throwError: false, timeout: self::TIMEOUT_DEFAULT); if ((ScannerCommands::parseOutput($output)['exit_codes']['sbom-delete'] ?? null) !== 0) { return; } ImageSbom::query() ->where('server_id', $server->id) ->whereKey($sboms->pluck('id')->all()) ->whereDoesntHave('scans') ->delete(); } }