id}"; } /** * @param int $lockWaitSeconds A deploy gate waits for the server lock; 0 tries once (background checks). * @param bool $retryWhenBusy Throw ScanLockBusy instead of storing `lock_busy`, so the caller retries later. * * @throws ScanLockBusy */ public function handle(ImageScan $scan, int $lockWaitSeconds = 0, bool $retryWhenBusy = false): void { $this->retryWhenBusy = $retryWhenBusy && $lockWaitSeconds <= 0; $scan = ImageScan::query()->with(['resource', 'server.settings', 'sbom'])->find($scan->id); if (! $scan instanceof ImageScan) { return; } if (! self::needsCandidates($scan)) { ScanNotifications::reevaluate($scan); return; } $server = $scan->server; $resource = $scan->resource; if (! $server instanceof Server || ! ImageRescanJob::canRunOn($server) || ! ImageScanPolicyResolver::for($resource, $server)->scanEnabled) { // No check runs, so an alert that waits for it is sent now. ScanNotifications::reevaluate($scan, checkGaveUp: true); return; } $this->commands = new ScannerCommands($server, Str::lower(Str::random(24))); $ranOnServer = false; $result = ['status' => 'skipped', 'error' => null, 'entries' => [], 'tags_truncated' => false]; $base = null; try { if (self::needsImageCandidates($scan)) { $result = $this->guarded(fn () => $this->check($scan, $resource, $lockWaitSeconds, $ranOnServer)); } if (self::needsBaseCandidates($scan)) { $base = $this->guarded(fn () => $this->checkBase($scan, $resource, $lockWaitSeconds, $ranOnServer)); } } finally { if ($ranOnServer) { instant_remote_process($this->commands->cleanup(), $server, throwError: false, timeout: self::TIMEOUT_DEFAULT); } } $this->store($scan, $result, $base); } /** * Runs one check with a fresh registry state and adds whether the tag list was cut. * * @param callable(): array{status: string, error: ?string, entries: list>} $check * @return array{status: string, error: ?string, entries: list>, tags_truncated: bool} */ private function guarded(callable $check): array { $this->auth = null; $this->loginRead = false; $this->authRequired = false; $this->anonymousWorked = false; $this->tagsTruncated = false; $this->error = null; try { $result = $check(); } catch (ScanLockBusy $e) { throw $e; } catch (Throwable $e) { report($e); $result = ['status' => 'failed', 'error' => 'unknown', 'entries' => [], 'attributable' => null, 'attributable_ids' => [], 'current_digest' => null]; } return [...$result, 'tags_truncated' => $this->tagsTruncated]; } public static function needsCandidates(ImageScan $scan): bool { return self::needsImageCandidates($scan) || self::needsBaseCandidates($scan); } /** * Candidates are looked for only for images the user does not build in Coolify (a Docker image app, or an image a * Compose app pulls), with a relevant finding or an end-of-life distro (spec 15.4). */ private static function needsImageCandidates(ImageScan $scan): bool { $resource = $scan->resource; if ($scan->status !== ImageScanStatus::Completed || ! $resource instanceof Model) { return false; } if ($resource instanceof Application && $resource->build_pack !== 'dockerimage' && ($resource->build_pack !== 'dockercompose' || $scan->builds_image !== false)) { return false; } return self::hasEolDistro($scan) || self::relevantFindings($scan, $resource) !== []; } /** * A newer base image is looked for on the same trigger when a Dockerfile build has a known base image (spec 15.4). * Nixpacks, Railpack, and static builds have no base image. */ private static function needsBaseCandidates(ImageScan $scan): bool { $resource = $scan->resource; return $scan->status === ImageScanStatus::Completed && $resource instanceof Application && $resource->build_pack === 'dockerfile' && filled($scan->base_image_ref) && (self::hasEolDistro($scan) || self::relevantFindings($scan, $resource) !== []); } /** * The relevant findings of the scan (spec 15.2), by the rules of the resource unless `$rules` is given. * * @return list */ public static function relevantFindings(ImageScan $scan, Model $resource, ?RelevanceRules $rules = null): array { $rules ??= RelevanceRules::forResource($resource); return array_values(array_filter( ResourceScans::findings($scan), fn (Finding $finding): bool => SecurityRules::isRelevant($finding, $rules), )); } private static function hasEolDistro(ImageScan $scan): bool { return is_array(data_get($scan->summary, 'eol_distro')); } /** * The last check finished less than FRESH_HOURS ago with a result that does not need a retry, also for the base image. */ public static function isFresh(ImageScan $scan): bool { $candidates = $scan->candidates; $checkedAt = is_array($candidates) ? ($candidates['checked_at'] ?? null) : null; $fresh = ['ok', 'no_better', 'cannot_compare', 'skipped']; if (! is_string($checkedAt) || ! in_array($scan->candidateStatus(), $fresh, true)) { return false; } if (self::needsBaseCandidates($scan) && ! in_array(data_get($candidates, 'base.status'), $fresh, true)) { return false; } try { return now()->subHours(self::FRESH_HOURS)->lt($checkedAt); } catch (Throwable) { return false; } } public static function shouldRefresh(ImageScan $scan): bool { return $scan->exists && ! self::isFresh($scan) && self::needsCandidates($scan); } /** * Text for a version check (or, with $base, a base check) without a better candidate (spec 7.3, 11). Null when a * candidate is better or nothing was checked. */ public static function message(ImageScan $scan, bool $base = false): ?string { $result = $base ? data_get($scan->candidates, 'base') : $scan->candidates; $status = is_array($result) ? ($result['status'] ?? null) : null; try { $image = ImageRef::parse((string) ($base ? $scan->base_image_ref : $scan->image_ref)); } catch (InvalidArgumentException) { return null; } $name = (string) ImageRef::parse($image->qualifiedName()); $server = (string) ($scan->server?->name ?? 'the server'); return match ($status) { 'no_better' => "No newer version of {$name} is better yet. Coolify checks again every day.", 'cannot_compare' => 'Coolify cannot compare versions of this image. Check the image page for a newer version.', 'auth_required' => "Coolify cannot read the tags of {$name}. Log in to {$image->registry} on {$server}.", 'failed' => data_get($result, 'error') === 'daily_limit' ? "Coolify reached the daily limit of version checks on {$server}. It checks again tomorrow." : "Coolify could not check for newer versions of {$name}. It tries again tomorrow.", default => null, }; } /** * @return array{status: string, error: ?string, entries: list>} */ private function check(ImageScan $scan, Model $resource, int $lockWaitSeconds, bool &$ranOnServer): array { try { $this->image = ImageRef::parse((string) $scan->image_ref); } catch (InvalidArgumentException) { return ['status' => 'cannot_compare', 'error' => null, 'entries' => []]; } if ($this->image->digest !== null) { return ['status' => 'cannot_compare', 'error' => null, 'entries' => []]; } $platform = $scan->sbom?->platform; if (! is_string($platform) || $platform === '') { return ['status' => 'failed', 'error' => 'unknown', 'entries' => []]; } $ranOnServer = true; $this->currentImageId = $scan->image_id; $tag = $this->image->tag ?? 'latest'; $selector = new TagSelector; $isVersion = $selector->isVersion($tag); $selected = ['line' => null, 'patch' => null, 'major' => null, 'floating' => false]; if ($isVersion) { $tags = $this->registry(fn () => $this->commands->tags($this->image->qualifiedName(), $this->auth), 'tags'); if ($tags === null) { return $this->failure(); } $tags = array_values(array_filter(array_map('trim', explode("\n", $tags)), fn (string $line) => $line !== '')); $this->tagsTruncated = count($tags) > TagSelector::MAX_TAGS; $selected = $selector->select($tag, $tags); } // The smallest change first: a newer build of the same tag, the newest patch of the same minor, then the newest // tag of the major. A safer patch ends the search of the line. The same-tag build does not take one of the // MAX_CANDIDATE_SCANS slots, so the patch and the line tag always fit. $entries = []; $scannedDigests = []; $tagScans = 0; if ($scan->repo_digest !== null) { $digest = $this->digest($tag); if ($digest === null && ($this->authRequired || ! $isVersion)) { return $this->failure(); } if ($digest !== null && $digest !== $scan->repo_digest) { $scannedDigests[] = $digest; $entries = [...$entries, ...$this->evaluate($scan, $resource, [['kind' => 'same_tag', 'tag' => $tag, 'digest' => $digest]], $platform, $lockWaitSeconds)]; } } foreach (array_filter([$selected['patch'], $selected['line']], fn (?string $lineTag): bool => $lineTag !== null && $lineTag !== $tag) as $lineTag) { $digest = $this->digest($lineTag); if ($digest === null) { $this->error ??= 'registry'; continue; } if ($digest === $scan->repo_digest || in_array($digest, $scannedDigests, true)) { continue; } $scannedDigests[] = $digest; $tagScans++; $lineEntries = $this->evaluate($scan, $resource, [['kind' => 'line', 'tag' => $lineTag, 'digest' => $digest]], $platform, $lockWaitSeconds); $entries = [...$entries, ...$lineEntries]; if (($lineEntries[0]['better'] ?? false) === true) { break; } } if ($selected['major'] !== null) { $entries[] = $this->majorEntry($scan, $resource, $selected['major'], $platform, $lockWaitSeconds, withScan: ! in_array(true, array_column($entries, 'better'), true) && $tagScans < self::MAX_CANDIDATE_SCANS); } $scanned = array_filter($entries, fn (array $entry) => $entry['kind'] !== 'major'); return [ 'status' => match (true) { in_array(true, array_column($entries, 'better'), true) => 'ok', $this->authRequired => 'auth_required', $this->error !== null => 'failed', ! $isVersion && $scanned === [] => 'cannot_compare', default => 'no_better', }, 'error' => $this->error, 'entries' => $entries, ]; } /** * The newest tag of a higher major. It is never offered as an update: a major version can need a data migration or * code changes. When nothing of the same major is safer, the check has a scan left, and the server has at least * MAJOR_SCAN_DAILY_RESERVE daily scans left, Coolify scans it, so the page can say whether it would fix the problems * (`fixes_problems`, the "better" rule). Its registry and scan problems, also a busy lock, never change the status * of the check. * * @return array */ private function majorEntry(ImageScan $scan, Model $resource, string $majorTag, string $platform, int $lockWaitSeconds, bool $withScan): array { $entry = [ 'ref' => (string) $this->image->withTag($majorTag), 'digest' => null, 'kind' => 'major', 'better' => false, 'fixed_ids' => [], 'fixes' => null, 'added' => null, 'left' => null, 'eol' => null, 'scanned_at' => null, ]; if (! $withScan || self::dailyScansLeft($this->commands->server) < self::MAJOR_SCAN_DAILY_RESERVE) { return $entry; } [$error, $authRequired] = [$this->error, $this->authRequired]; try { $digest = $this->digest($majorTag); $scanned = $digest === null ? [] : $this->evaluate($scan, $resource, [['kind' => 'major', 'tag' => $majorTag, 'digest' => $digest]], $platform, $lockWaitSeconds); } catch (ScanLockBusy) { $scanned = []; } [$this->error, $this->authRequired] = [$error, $authRequired]; if ($scanned === []) { return $entry; } return [...$scanned[0], 'better' => false, 'fixes_problems' => $scanned[0]['better']]; } /** * Scans the current base image and the newest tag of its line from the registry (spec 7.4): two candidate scans * with the usual lock, disk, reuse, and daily rules. Only a different tag can change the `FROM` line, so a newer * build of the same tag is not checked. The current base image is scanned also when no newer tag exists or the tag * cannot be compared (not a version, or pinned by digest), so `attributable` is set on every result but a failure, * an unparsable reference, or a truncated scan of the current base image. * * @return array{status: string, error: ?string, entries: list>, attributable: ?int, * attributable_ids: list, current_digest: ?string} */ private function checkBase(ImageScan $scan, Model $resource, int $lockWaitSeconds, bool &$ranOnServer): array { $result = fn (string $status, array $entries = [], ?array $attributable = null, ?string $currentDigest = null): array => [ 'status' => $status, 'error' => $status === 'cannot_compare' ? null : $this->error, 'entries' => $entries, 'attributable' => $attributable === null ? null : count($attributable), 'attributable_ids' => $attributable === null ? [] : array_values(array_unique(array_map(fn (Finding $finding) => $finding->id, $attributable))), 'current_digest' => $currentDigest, ]; $failure = fn (?array $attributable = null, ?string $currentDigest = null): array => [ ...$result('failed', [], $attributable, $currentDigest), ...$this->failure(), ]; try { $this->image = ImageRef::parse((string) $scan->base_image_ref); } catch (InvalidArgumentException) { return $result('cannot_compare'); } $tag = $this->image->tag ?? 'latest'; $selector = new TagSelector; $comparable = $this->image->digest === null && $selector->isVersion($tag); $platform = $scan->sbom?->platform; if (! is_string($platform) || $platform === '') { $this->error = 'unknown'; return $result('failed'); } $ranOnServer = true; // The disk check of the registry scans uses the size of the app image, an upper bound of its base image. $this->currentImageId = $scan->image_id; $this->currentImageSize = null; // A truncated scan of the current base image cannot tell which findings come from it. $attributableOf = fn (array $currentBase): ?array => $currentBase['truncated'] ? null : (new CandidateEvaluator)->shared(self::relevantFindings($scan, $resource, new RelevanceRules(IgnoreMatcher::forResource($resource), countMedium: true)), $currentBase['findings']); if (! $comparable) { // No newer tag can be chosen, but the current base image (by tag or pinned digest) still tells the origin. $currentDigest = $this->image->digest ?? $this->digest($tag); if ($currentDigest === null) { return $failure(); } $currentBase = $this->scanCandidate($currentDigest, $platform, $lockWaitSeconds); if ($currentBase === null) { return $failure(null, $currentDigest); } return $result('cannot_compare', [], $attributableOf($currentBase), $currentDigest); } $tags = $this->registry(fn () => $this->commands->tags($this->image->qualifiedName(), $this->auth), 'tags'); if ($tags === null) { return $failure(); } $tags = array_values(array_filter(array_map('trim', explode("\n", $tags)), fn (string $line) => $line !== '')); $this->tagsTruncated = count($tags) > TagSelector::MAX_TAGS; $line = $selector->select($tag, $tags)['line']; $hasLine = $line !== null && $line !== $tag; $currentDigest = $this->digest($tag); $lineDigest = $currentDigest === null || ! $hasLine ? null : $this->digest($line); if ($currentDigest === null || ($hasLine && $lineDigest === null)) { return $failure(null, $currentDigest); } // The current base image is scanned also without a newer tag, so the actions know which findings come from it. $currentBase = $this->scanCandidate($currentDigest, $platform, $lockWaitSeconds); if ($currentBase === null) { return $failure(null, $currentDigest); } $rules = RelevanceRules::forResource($resource); $evaluator = new CandidateEvaluator; $attributable = $attributableOf($currentBase); if (! $hasLine || $lineDigest === $currentDigest) { return $result('no_better', [], $attributable, $currentDigest); } $candidate = $this->scanCandidate($lineDigest, $platform, $lockWaitSeconds); if ($candidate === null) { return $failure($attributable, $currentDigest); } $candidateFindings = $candidate['findings']; $better = ! $currentBase['truncated'] && ! $candidate['truncated'] && $evaluator->isBetter($currentBase['findings'], $candidateFindings, $rules, $currentBase['eol'], $candidate['eol']); $entry = [ 'ref' => (string) $this->image->withTag($line), 'digest' => $lineDigest, 'kind' => 'line', 'better' => $better, 'fixed_ids' => $evaluator->fixedIds($attributable ?? [], $candidateFindings, $rules->withAllMediums()), 'fixes' => $evaluator->counts($evaluator->fixed($attributable ?? [], $candidateFindings)), 'added' => $evaluator->added($evaluator->relevant($currentBase['findings'], $rules), $candidateFindings, $rules), 'left' => $evaluator->counts($evaluator->relevant($candidateFindings, $rules)), 'eol' => $candidate['eol'], 'scanned_at' => now()->toIso8601String(), ]; return $result($better ? 'ok' : 'no_better', [$entry], $attributable, $currentDigest); } /** * @param list $candidates * @return list> */ private function evaluate(ImageScan $scan, Model $resource, array $candidates, string $platform, int $lockWaitSeconds): array { if ($candidates === []) { return []; } $rules = RelevanceRules::forResource($resource); $evaluator = new CandidateEvaluator; $current = ResourceScans::findings($scan); $currentRelevant = $evaluator->relevant($current, $rules); $currentEol = data_get($scan->summary, 'eol_distro'); $currentEol = is_array($currentEol) ? $currentEol : null; $entries = []; foreach ($candidates as $candidate) { $report = $this->scanCandidate($candidate['digest'], $platform, $lockWaitSeconds); if ($report === null) { continue; } $findings = $report['findings']; $entries[] = [ 'ref' => (string) $this->image->withTag($candidate['tag']), 'digest' => $candidate['digest'], 'kind' => $candidate['kind'], 'better' => ! $report['truncated'] && $evaluator->isBetter($current, $findings, $rules, $currentEol, $report['eol']), 'fixed_ids' => $evaluator->fixedIds($current, $findings, $rules->withAllMediums()), 'fixes' => $evaluator->counts($evaluator->fixed($currentRelevant, $findings)), 'added' => $evaluator->added($currentRelevant, $findings, $rules), 'left' => $evaluator->counts($evaluator->relevant($findings, $rules)), 'eol' => $report['eol'], 'scanned_at' => now()->toIso8601String(), ]; } return $entries; } /** * Runs Grype on the SBOM of a candidate digest; makes the SBOM from the registry first when this server has none. * Takes the scan lock of the server, so a deploy scan waits at most one candidate scan. A background check never * waits for the lock and leaves it to a deploy gate that waits. * * @return array{findings: list, eol: ?array{name: string, version: string}, truncated: bool}|null */ private function scanCandidate(string $digest, string $platform, int $lockWaitSeconds): ?array { $server = $this->commands->server; $lock = ScanImage::serverLock($server); if (! ScanImage::acquireServerLock($lock, $server, $lockWaitSeconds)) { if ($this->retryWhenBusy) { throw new ScanLockBusy; } $this->error ??= 'lock_busy'; return null; } try { $hex = substr($digest, strlen('sha256:')); $sbom = ImageSbom::query()->where('server_id', $server->id)->where('image_id', $digest)->first(); if ($sbom !== null) { $grype = $this->runStep($this->commands->grype($hex), 'grype', self::TIMEOUT_GRYPE); if ($grype !== null && $grype['code'] === 0) { $sbom->forceFill(['last_used_at' => now()])->save(); return $this->report($grype); } if ($grype === null || $grype['code'] !== self::EXIT_DOCKER_RUN) { $this->error ??= 'scan'; return null; } } if (! $this->hasDiskSpace()) { $this->error ??= 'disk'; return null; } if (! $this->reserveDailyScan($server)) { $this->error ??= 'daily_limit'; return null; } $created = $this->runStep($this->commands->sbomFromRegistry($this->image->qualifiedName().'@'.$digest, $platform, $hex, $this->auth), 'sbom', self::TIMEOUT_SBOM); $packages = $created !== null && $created['code'] === 0 ? $this->runStep($this->commands->packages($hex), 'packages', self::TIMEOUT_GRYPE) : null; if ($packages === null || $packages['code'] !== 0) { $this->error ??= 'scan'; return null; } $parsed = (new SyftPackageParser)->parse($this->payload($packages)[0]); ($sbom ?? new ImageSbom(['server_id' => $server->id, 'image_id' => $digest]))->forceFill([ 'platform' => $platform, 'packages' => array_map(fn ($package) => $package->toArray(), $parsed['packages']), 'packages_truncated' => $parsed['truncated'] || $this->payload($packages)[1], 'last_used_at' => now(), ])->save(); $grype = $this->runStep($this->commands->grype($hex), 'grype', self::TIMEOUT_GRYPE); if ($grype === null || $grype['code'] !== 0) { $this->error ??= 'scan'; return null; } return $this->report($grype); } finally { $lock->release(); } } /** * The disk rule of local scans (image size × 2 + 1 GB), with the size of the current image: Syft writes the * candidate's layers to the scratch directory. A current image that is no longer on the server counts as 0 bytes. */ private function hasDiskSpace(): bool { if ($this->currentImageSize === null) { $this->currentImageSize = 0; if (is_string($this->currentImageId) && preg_match('/\Asha256:[a-f0-9]{64}\z/', $this->currentImageId) === 1) { $inspect = $this->runStep($this->commands->inspect($this->currentImageId), 'inspect', self::TIMEOUT_DEFAULT); $parts = $inspect !== null && $inspect['code'] === 0 ? explode('|', trim($inspect['payload'])) : []; if (count($parts) === 4 && preg_match('/\A\d{1,18}\z/', $parts[3]) === 1) { $this->currentImageSize = (int) $parts[3]; } } } try { ScanImage::checkDiskSpace($this->commands, $this->currentImageSize); } catch (ScanFailed) { return false; } return true; } /** * The registry scans the server has left today. */ private static function dailyScansLeft(Server $server): int { $settings = ServerSetting::query()->where('server_id', $server->id)->first(['image_candidate_scans_today', 'image_candidate_scans_date']); $date = $settings?->image_candidate_scans_date; if ($date === null || ! today()->isSameDay($date)) { return self::DAILY_CANDIDATE_SCANS; } return max(0, self::DAILY_CANDIDATE_SCANS - (int) $settings->image_candidate_scans_today); } /** * Counts a registry scan against the daily limit of the server; false when the limit is reached. */ private function reserveDailyScan(Server $server): bool { $today = today()->toDateString(); ServerSetting::query() ->where('server_id', $server->id) ->where(fn ($query) => $query->whereNull('image_candidate_scans_date')->orWhereDate('image_candidate_scans_date', '<', $today)) ->update(['image_candidate_scans_today' => 0, 'image_candidate_scans_date' => $today]); return ServerSetting::query() ->where('server_id', $server->id) ->where('image_candidate_scans_today', '<', self::DAILY_CANDIDATE_SCANS) ->increment('image_candidate_scans_today') > 0; } private function digest(string $tag): ?string { $payload = $this->registry(fn () => $this->commands->digest((string) $this->image->withTag($tag), null, $this->auth), 'digest'); $digest = $payload === null ? '' : trim($payload); return ImageRef::isValidDigest($digest) ? $digest : null; } /** * Runs a registry command anonymously first. After a failure it reads the server's login for the registry of the * image once and repeats the command with it. Without a usable login, a private image needs one. When an anonymous * call already worked, a failure is a registry error, not a missing login. * * @param callable(): list $commands */ private function registry(callable $commands, string $step): ?string { $result = $this->runStep($commands(), $step, self::TIMEOUT_REGISTRY); if ($result !== null && $result['code'] === 0) { $this->anonymousWorked = $this->anonymousWorked || $this->auth === null; return $this->payload($result)[0]; } if ($result === null || in_array($result['code'], self::EXIT_TIMEOUT, true)) { $this->error ??= 'registry'; return null; } if ($this->loginRead || $this->anonymousWorked) { $this->error ??= 'registry'; return null; } $this->loginRead = true; $login = DockerRegistryLogins::registryAuthConfig($this->commands->server, $this->image->registry); if ($login['status'] !== 'ok') { $this->authRequired = $login['status'] === 'helper' || ! $this->image->isDockerOfficial(); $this->error ??= 'registry'; return null; } $this->auth = $login['config']; $result = $this->runStep($commands(), $step, self::TIMEOUT_REGISTRY); if ($result !== null && $result['code'] === 0) { return $this->payload($result)[0]; } $this->authRequired = true; $this->error ??= 'registry'; return null; } /** * @return array{status: string, error: ?string, entries: list>} */ private function failure(): array { return [ 'status' => $this->authRequired ? 'auth_required' : 'failed', 'error' => $this->authRequired ? 'registry_auth' : ($this->error ?? 'registry'), 'entries' => [], ]; } /** * @param array{code: int, size: ?int, payload: string} $grype * @return array{findings: list, eol: ?array{name: string, version: string}, truncated: bool} */ private function report(array $grype): array { [$payload, $cut] = $this->payload($grype); $report = (new GrypeReportParser)->parse($payload); return ['findings' => $report['findings'], 'eol' => $report['eol'], 'truncated' => $report['truncated'] || $cut]; } /** * Null when the batch did not run or printed no marker for $step. * * @param list $commands * @return array{code: int, size: ?int, payload: string}|null */ private function runStep(array $commands, string $step, int $timeout): ?array { $output = instant_remote_process($commands, $this->commands->server, throwError: false, timeout: $timeout); $parsed = ScannerCommands::parseOutput($output); if (! array_key_exists($step, $parsed['exit_codes'])) { return null; } return ['code' => $parsed['exit_codes'][$step], 'size' => $parsed['sizes'][$step] ?? null, 'payload' => $parsed['payload']]; } /** * Output over the limit was cut by `head -c`: keep only its complete lines. * * @param array{code: int, size: ?int, payload: string} $result * @return array{0: string, 1: bool} */ private function payload(array $result): array { $payload = $result['payload']; $cut = ($result['size'] ?? 0) > ScannerCommands::OUTPUT_LIMIT || strlen($payload) > ScannerCommands::OUTPUT_LIMIT; if ($cut) { $payload = substr($payload, 0, ScannerCommands::OUTPUT_LIMIT); $payload = substr($payload, 0, (int) strrpos($payload, "\n")); } return [$payload, $cut]; } /** * Writes the result on the current row and recomputes its summary: a better candidate makes findings of images * the user does not build fixable now (spec 7.5). * * @param array{status: string, error: ?string, entries: list>, tags_truncated: bool} $result * @param array{status: string, error: ?string, entries: list>, tags_truncated: bool, * attributable: ?int, attributable_ids: list, current_digest: ?string}|null $base */ private function store(ImageScan $scan, array $result, ?array $base): void { $fresh = ImageScan::query()->find($scan->id); if (! $fresh instanceof ImageScan || ! $scan->resource instanceof Model) { return; } $checkedAt = now()->toIso8601String(); $fresh->candidates = [ 'status' => $result['status'], 'checked_at' => $checkedAt, 'error' => $result['error'], 'tags_truncated' => $result['tags_truncated'], 'entries' => $result['entries'], ...($base === null ? [] : ['base' => [ 'image' => (string) $scan->base_image_ref, 'status' => $base['status'], 'checked_at' => $checkedAt, 'error' => $base['error'], 'tags_truncated' => $base['tags_truncated'], 'attributable' => $base['attributable'], 'attributable_ids' => $base['attributable_ids'], 'current_digest' => $base['current_digest'], 'entries' => $base['entries'], ]]), ]; $fresh->setRelation('resource', $scan->resource); ResourceScans::refreshSummaries(collect([$fresh])); // The alert for a scan waits for its version check, which can also bring a fix (spec 15.8). try { ScanNotifications::afterScan($fresh, candidateCheck: true); } catch (Throwable $e) { report($e); } self::forgetQueued($fresh); $teamId = method_exists($scan->resource, 'team') ? $scan->resource->team()?->id : null; ImageScanUpdated::dispatch($teamId, (string) $scan->resource->getAttribute('uuid'), (string) $fresh->uuid); } }