status !== ImageScanStatus::Completed || ! $scan->resource instanceof Model) { return self::notChecked(self::NOT_SCANNED); } return self::evaluate($scan, $rules); } /** * The status of a resource from its current scans, computed now. A service combines the images of its * applications and databases. A caller that loaded the current scans of the owners (`ResourceScans::current()`, * with `resource` set) passes them, and the statuses of `forScan()` it computed already, keyed by scan UUID. * * @param Collection|null $currentScans * @param array $imageStatuses */ public static function forResource(Model $resource, ?Collection $currentScans = null, array $imageStatuses = []): ResourceSecurity { $off = ImageScanPolicyResolver::offState($resource); if ($off !== null) { return self::notChecked($off); } $images = self::statusOwners($resource, ResourceScans::owners($resource)) ->map(function (Model $owner) use ($currentScans, $imageStatuses): array { $scans = $currentScans === null ? ResourceScans::currentFor($owner) : $currentScans->filter(fn (ImageScan $scan): bool => $scan->resource_type === $owner->getMorphClass() && (string) $scan->resource_id === (string) $owner->getKey())->values(); return [ 'parts' => $scans->map(fn (ImageScan $scan): ResourceSecurity => $imageStatuses[(string) $scan->uuid] ?? self::evaluate($scan))->all(), 'unchecked' => ResourceScans::uncheckedComposeImages($owner, $scans), ]; }) ->all(); return self::combineImages($images, $resource instanceof Service); } /** * The status of a resource from the stored statuses of its current scans. A scan without a stored status counts * as an image that is not checked. */ public static function stored(Model $resource): ResourceSecurity { return self::storedMany(collect([$resource]))[ResourceScans::key($resource)]; } /** * `stored()` for many resources with a fixed number of queries, also for many Compose applications. * * @param Collection $resources * @param Collection>|null $scans The current scans of the owners of the scanned * resources (`ResourceScans::currentForMany()` with * at least STORED_COLUMNS), when the caller has them. * @return array Keyed by `ResourceScans::key()`. */ public static function storedMany(Collection $resources, ?Collection $scans = null): array { $resources = $resources->values(); if ($resources->isEmpty()) { return []; } ResourceScans::loadScanRelations($resources); $offStates = $resources->mapWithKeys(fn (Model $resource): array => [ResourceScans::key($resource) => ImageScanPolicyResolver::offState($resource)]); $scanned = $resources->filter(fn (Model $resource): bool => $offStates[ResourceScans::key($resource)] === null)->values(); $owners = ResourceScans::ownersOfMany($scanned); $scans ??= ResourceScans::currentForMany($owners->flatten(1), self::STORED_COLUMNS); $composeRows = ResourceScans::composeDeploymentRows($owners->flatten(1), $scans); $result = []; foreach ($resources as $resource) { $key = ResourceScans::key($resource); if ($offStates[$key] !== null) { $result[$key] = self::notChecked($offStates[$key]); continue; } $images = self::statusOwners($resource, $owners->get($key, collect())) ->map(function (Model $owner) use ($scans, $composeRows): array { $ownerScans = $scans->get(ResourceScans::key($owner), collect()); return [ 'parts' => $ownerScans->map(fn (ImageScan $scan): ?ResourceSecurity => is_array(data_get($scan->summary, 'security')) ? ResourceSecurity::fromArray(data_get($scan->summary, 'security')) : null)->all(), 'unchecked' => ResourceScans::uncheckedComposeImages($owner, $ownerScans, $composeRows), ]; }) ->all(); $result[$key] = self::combineImages($images, $resource instanceof Service); } return $result; } /** * Adds the status of a completed scan to its summary (not saved). Preview scans get none: their actions would * compare with the scans of the application. */ public static function store(ImageScan $scan): void { if ($scan->status !== ImageScanStatus::Completed || ! $scan->resource instanceof Model) { return; } $summary = (array) $scan->summary; unset($summary['security']); if ($scan->deployment_queue_id === null || (int) ($scan->deploymentQueue?->pull_request_id ?? 0) === 0) { try { $summary['security'] = self::evaluate($scan)->toArray(); } catch (Throwable $e) { report($e); } } $scan->summary = $summary; } /** * A finding of a builder image that a builder update would fix: an OS package with a fixed version. */ private static function builderCanFix(Finding $finding): bool { return in_array(strtolower($finding->type), ['apk', 'deb', 'rpm'], true) && $finding->fixState === 'fixed' && ($finding->fixedIn ?? '') !== ''; } private static function notChecked(string $reason): ResourceSecurity { return new ResourceSecurity(status: SecurityStatusType::NotChecked, checkReason: $reason); } /** * The owners whose images count for the status: the applications and databases of a service that Coolify scans, * else the resource itself. * * @param Collection $owners * @return Collection */ private static function statusOwners(Model $resource, Collection $owners): Collection { return $resource instanceof Service ? $owners->filter(fn (Model $owner): bool => ResourceScans::isScanTarget($owner))->values() : $owners; } /** * Per scan owner: the status of each current scan (null: no stored status) and the number of its images without a * result. An owner without a current scan is one image that is not checked. * * @param list, unchecked: int}> $images */ private static function combineImages(array $images, bool $isService): ResourceSecurity { $total = 0; $parts = []; foreach ($images as $owner) { $total += max(1, count($owner['parts'])) + $owner['unchecked']; foreach ($owner['parts'] as $part) { if ($part instanceof ResourceSecurity) { $parts[] = $part; } } } if ($parts === []) { return self::notChecked(self::NOT_SCANNED); } return self::resolve($parts, ['checked' => count($parts), 'total' => $total], $isService); } /** * The status of one scan before it is combined with other images. */ private static function evaluate(ImageScan $scan, ?RelevanceRules $rules = null): ResourceSecurity { /** @var Model $owner */ $owner = $scan->resource; $rules ??= RelevanceRules::forResource($owner); $result = SecurityActions::forScan($scan, $rules); $actions = $result['actions']; $indexes = []; foreach ($actions as $index => $action) { $indexes[spl_object_id($action)] = $index; } $ownerData = ['owner' => self::ownerName($owner), 'owner_type' => $owner->getMorphClass(), 'owner_id' => $owner->getKey()]; $stats = array_map(fn (): array => [...self::emptyStats(), ...$ownerData], $actions); $counts = array_fill_keys(self::COUNT_KEYS, 0); $unfixed = []; $seen = []; $hasRelevant = false; $isBuilder = SecurityActions::imageKind($scan, $owner) === 'builder'; foreach (ResourceScans::findings($scan) as $finding) { $key = mb_strtolower($finding->id); if (isset($seen[$key]) || ! SecurityRules::isRelevant($finding, $rules)) { continue; } $seen[$key] = true; $hasRelevant = true; $signal = SecurityRules::exploitSignal($finding); $severity = strtolower($finding->severity); $action = $result['byFinding'][$finding->id] ?? null; $why = self::whyFor($finding, $signal); if (! $action instanceof SecurityAction) { if ($signal !== null) { $counts['signal_unfixed']++; $counts['kev_unfixed'] += $signal === SecurityRules::EXPLOITED ? 1 : 0; $counts['builder_unfixed'] += $isBuilder && self::builderCanFix($finding) ? 1 : 0; $unfixed[] = $why; } continue; } $index = $indexes[spl_object_id($action)]; $stats[$index]['relevant']++; $stats[$index]['why'][] = $why; $counts['relevant_fixed']++; if ($signal !== null) { $stats[$index]['signal']++; $counts['signal_fixed']++; } if ($signal === SecurityRules::EXPLOITED) { $stats[$index]['kev']++; $counts['kev_fixed']++; } if (in_array($severity, ['critical', 'high', 'medium'], true)) { $stats[$index][$severity]++; $counts[$severity.'_fixed']++; } } // Leaving an end-of-life OS counts as one critical relevant fix (spec 15.3). if ($result['eol'] instanceof SecurityAction) { $index = $indexes[spl_object_id($result['eol'])]; $stats[$index]['eol'] = 1; $stats[$index]['critical']++; $stats[$index]['relevant']++; $counts['critical_fixed']++; $counts['relevant_fixed']++; } foreach ($stats as $index => $entry) { $stats[$index]['why'] = self::topWhy($entry['why']); } $eolDistro = data_get($scan->summary, 'eol_distro'); $eol = is_array($eolDistro) ? ['name' => (string) ($eolDistro['name'] ?? ''), 'version' => (string) ($eolDistro['version'] ?? ''), 'fixed' => $result['eol'] !== null] : null; $reasons = []; if ((bool) data_get($scan->summary, 'truncated', false)) { $reasons[] = self::REASON_TRUNCATED; } if (self::databaseWasOld($scan)) { $reasons[] = self::REASON_DB_OUTDATED; } if (ResourceScans::runsOtherImage($scan, $owner)) { $reasons[] = self::REASON_IMAGE_CHANGED; } if (($hasRelevant || $eol !== null) && ! self::versionsChecked($scan, $owner)) { $reasons[] = self::REASON_VERSIONS_UNCHECKED; } return self::resolve([new ResourceSecurity( status: SecurityStatusType::Ok, actions: $actions, eol: $eol, checkedAt: $scan->scanned_at === null ? null : CarbonImmutable::instance($scan->scanned_at), facts: ['reasons' => $reasons, 'counts' => $counts, 'action_stats' => $stats, 'unfixed_why' => self::topWhy($unfixed)], )], null, false); } /** * Combines the statuses of images (or the raw data of one image) and applies the rules of spec 15.6. * * @param non-empty-list $parts * @param array{checked: int, total: int}|null $images */ private static function resolve(array $parts, ?array $images, bool $isService): ResourceSecurity { $entries = []; $counts = array_fill_keys(self::COUNT_KEYS, 0); $reasons = []; $unfixed = []; $eol = null; $checkedAt = null; $unknownTime = false; foreach ($parts as $part) { $facts = $part->facts; foreach ($part->actions as $index => $action) { $entries[] = ['action' => $action, 'stats' => self::stats(data_get($facts, "action_stats.{$index}"))]; } foreach (self::COUNT_KEYS as $key) { $counts[$key] += (int) data_get($facts, "counts.{$key}", 0); } $reasons = [...$reasons, ...array_filter((array) ($facts['reasons'] ?? []), 'is_string')]; $unfixed = [...$unfixed, ...array_map(fn ($entry): array => ResourceSecurity::whyEntry((array) $entry), (array) ($facts['unfixed_why'] ?? []))]; if ($part->eol !== null && ($eol === null || ($eol['fixed'] && ! $part->eol['fixed']))) { $eol = $part->eol; } if ($part->checkedAt === null) { $unknownTime = true; } elseif ($checkedAt === null || $part->checkedAt->lt($checkedAt)) { $checkedAt = $part->checkedAt; } } if ($unknownTime) { $checkedAt = null; } if ($isService) { $entries = self::mergeUpdates($entries); } $entries = self::sortEntries($entries); $actionStatus = match (true) { $counts['signal_fixed'] > 0 => SecurityStatusType::UpdateNow, $counts['relevant_fixed'] > 0 => SecurityStatusType::UpdateAvailable, $counts['signal_unfixed'] > 0 => SecurityStatusType::NoFix, default => SecurityStatusType::Ok, }; $storedReasons = array_values(array_unique($reasons)); $allReasons = $storedReasons; if ($checkedAt === null || $checkedAt->lt(now()->subDays(self::STALE_AFTER_DAYS))) { $allReasons[] = self::REASON_STALE; } if ($images !== null && $images['checked'] < $images['total']) { $allReasons[] = self::REASON_IMAGES_UNCHECKED; } $reason = collect(self::REASON_ORDER)->first(fn (string $code): bool => in_array($code, $allReasons, true)); $status = $reason === null ? $actionStatus : SecurityStatusType::CheckNeeded; $primary = $entries[0]['stats'] ?? null; $why = []; $moreFixed = 0; if ($primary !== null && in_array($actionStatus, [SecurityStatusType::UpdateNow, SecurityStatusType::UpdateAvailable], true)) { $why = $primary['why']; $moreFixed = max(0, $primary['relevant'] - $primary['eol'] - count($why)); } elseif ($actionStatus === SecurityStatusType::NoFix) { $why = self::topWhy($unfixed); } return new ResourceSecurity( status: $status, checkReason: $reason, checkContext: match ($reason) { self::REASON_IMAGES_UNCHECKED => $images, self::REASON_STALE => ['time' => $checkedAt?->toIso8601String()], default => [], }, actions: array_map(fn (array $entry): SecurityAction => $entry['action'], $entries), why: $why, moreFixed: $moreFixed, exploitedWithoutFix: $counts['signal_unfixed'] > 0 && in_array($status, [SecurityStatusType::UpdateNow, SecurityStatusType::UpdateAvailable], true), problemCount: match ($actionStatus) { SecurityStatusType::UpdateNow => $counts['signal_fixed'], SecurityStatusType::UpdateAvailable => $counts['critical_fixed'] + $counts['high_fixed'] + $counts['medium_fixed'], SecurityStatusType::NoFix => $counts['signal_unfixed'], default => 0, }, exploitedCount: $counts['signal_fixed'] + $counts['signal_unfixed'], anyExploited: match ($actionStatus) { SecurityStatusType::UpdateNow => $counts['kev_fixed'] > 0, SecurityStatusType::NoFix => $counts['kev_unfixed'] > 0, default => false, }, criticalCount: $counts['critical_fixed'], highCount: $counts['high_fixed'], mediumCount: $counts['medium_fixed'], fixedByPrimary: match ($actionStatus) { SecurityStatusType::UpdateNow => (int) ($primary['signal'] ?? 0), SecurityStatusType::UpdateAvailable => (int) ($primary['critical'] ?? 0) + (int) ($primary['high'] ?? 0) + (int) ($primary['medium'] ?? 0), default => 0, }, primaryCritical: (int) ($primary['critical'] ?? 0), primaryHigh: (int) ($primary['high'] ?? 0), primaryMedium: (int) ($primary['medium'] ?? 0), eol: $eol, checkedAt: $checkedAt, facts: [ 'action_status' => $actionStatus->value, 'reasons' => $storedReasons, 'counts' => $counts, 'action_stats' => array_map(fn (array $entry): array => $entry['stats'], $entries), 'unfixed_why' => self::topWhy($unfixed), ], builderImage: $counts['signal_unfixed'] > 0 && $counts['builder_unfixed'] === $counts['signal_unfixed'], ); } /** * Images of one service that run the same tag and update to the same tag get one action, also from different * repositories: "Update n8n and n8n runners to 2.10.4." The action lists every image in `parts`. * * @param list}> $entries * @return list}> */ private static function mergeUpdates(array $entries): array { $groups = []; foreach ($entries as $index => $entry) { $key = self::updateKey($entry); $groups[$key ?? 'single:'.$index][] = $index; } $merged = []; foreach ($groups as $indexes) { $group = array_map(fn (int $index): array => $entries[$index], $indexes); $names = array_values(array_unique(array_map(fn (array $entry): string => (string) $entry['stats']['owner'], $group))); if (count($group) === 1 || count($names) < 2) { $merged = [...$merged, ...$group]; continue; } $first = $group[0]['action']; $last = array_pop($names); $stats = self::emptyStats(); foreach ($group as $entry) { foreach (['signal', 'kev', 'critical', 'high', 'medium', 'relevant', 'eol'] as $key) { $stats[$key] += $entry['stats'][$key]; } $stats['why'] = [...$stats['why'], ...$entry['stats']['why']]; } $stats['why'] = self::topWhy($stats['why']); $merged[] = [ 'action' => new SecurityAction( type: SecurityActionType::UpdateImage, sentence: 'Update '.implode(', ', $names).' and '.$last.' to '.$first->target.'.', target: $first->target, copy: null, fixedIds: array_values(array_unique(array_merge(...array_map(fn (array $entry): array => $entry['action']->fixedIds, $group)))), button: collect($group)->every(fn (array $entry): bool => $entry['action']->button), image: $first->image, ref: $first->ref, parts: array_map(fn (array $entry): array => [ 'owner_type' => (string) $entry['stats']['owner_type'], 'owner_id' => $entry['stats']['owner_id'], 'image' => $entry['action']->image, 'ref' => $entry['action']->ref, ], $group), ), 'stats' => $stats, ]; } return $merged; } /** * Current tag and target tag of an update of a service image that can be merged, else null. * * @param array{action: SecurityAction, stats: array} $entry */ private static function updateKey(array $entry): ?string { $action = $entry['action']; if ($action->type !== SecurityActionType::UpdateImage || $action->sentence === SecurityActions::COOLIFY_TEXT || blank($action->target) || blank($action->image) || blank($entry['stats']['owner']) || blank($entry['stats']['owner_type'])) { return null; } try { $current = ImageRef::parse((string) $action->image); return $current->digest === null ? ($current->tag ?? 'latest').'|'.$action->target : null; } catch (InvalidArgumentException) { return null; } } /** * Most fixes with an exploit signal first, then most critical and high fixes (leaving an end-of-life OS counts as * one critical fix), then most medium fixes, then the order of the action types. * * @param list}> $entries * @return list}> */ private static function sortEntries(array $entries): array { $order = array_flip(array_map(fn (SecurityActionType $type): string => $type->value, SecurityActionType::cases())); $keyed = array_map(fn (array $entry, int $index): array => [...$entry, 'index' => $index], $entries, array_keys($entries)); usort($keyed, fn (array $a, array $b): int => [ -$a['stats']['signal'], -($a['stats']['critical'] + $a['stats']['high']), -$a['stats']['medium'], $order[$a['action']->type->value], $a['index'], ] <=> [ -$b['stats']['signal'], -($b['stats']['critical'] + $b['stats']['high']), -$b['stats']['medium'], $order[$b['action']->type->value], $b['index'], ]); return array_map(fn (array $entry): array => ['action' => $entry['action'], 'stats' => $entry['stats']], $keyed); } /** * What one action fixes: owner (name, morph type, key), relevant fixes with an exploit signal, `exploited` ones, * critical, high, and medium ones, all relevant ones, an end-of-life OS it leaves (counted in `critical` and `relevant` * too), and its why-list. * * @return array{owner: ?string, owner_type: ?string, owner_id: int|string|null, signal: int, kev: int, critical: int, high: int, medium: int, relevant: int, eol: int, why: list>} */ private static function emptyStats(): array { return ['owner' => null, 'owner_type' => null, 'owner_id' => null, 'signal' => 0, 'kev' => 0, 'critical' => 0, 'high' => 0, 'medium' => 0, 'relevant' => 0, 'eol' => 0, 'why' => []]; } /** * @return array{owner: ?string, owner_type: ?string, owner_id: int|string|null, signal: int, kev: int, critical: int, high: int, medium: int, relevant: int, eol: int, why: list>} */ private static function stats(mixed $data): array { $data = is_array($data) ? $data : []; $ownerId = $data['owner_id'] ?? null; return [ 'owner' => isset($data['owner']) ? (string) $data['owner'] : null, 'owner_type' => isset($data['owner_type']) ? (string) $data['owner_type'] : null, 'owner_id' => is_int($ownerId) || is_string($ownerId) ? $ownerId : null, 'signal' => (int) ($data['signal'] ?? 0), 'kev' => (int) ($data['kev'] ?? 0), 'critical' => (int) ($data['critical'] ?? 0), 'high' => (int) ($data['high'] ?? 0), 'medium' => (int) ($data['medium'] ?? 0), 'relevant' => (int) ($data['relevant'] ?? 0), 'eol' => (int) ($data['eol'] ?? 0), 'why' => array_map(fn ($entry): array => ResourceSecurity::whyEntry((array) $entry), (array) ($data['why'] ?? [])), ]; } /** * @return array{title: string, display_id: string, link: ?string, score: ?float, severity: string, signal: ?string, package: string, version: string} */ public static function whyFor(Finding $finding, ?string $signal): array { return [ 'title' => $finding->title, 'display_id' => $finding->displayId, 'link' => ResourceScans::advisoryUrl($finding->displayId), 'score' => $finding->cvssScore, 'severity' => strtolower($finding->severity), 'signal' => $signal, 'package' => $finding->package, 'version' => $finding->version, ]; } /** * `exploited`, then `likely`, then the highest score (no score last), then severity, at most $limit. * * @param list> $entries * @return list> */ public static function topWhy(array $entries, int $limit = self::WHY_LIMIT): array { $signalRank = [SecurityRules::EXPLOITED => 0, SecurityRules::LIKELY => 1]; $severityRank = ['critical' => 0, 'high' => 1, 'medium' => 2, 'low' => 3]; usort($entries, fn (array $a, array $b): int => [ $signalRank[$a['signal'] ?? ''] ?? 2, -($a['score'] ?? -1.0), $severityRank[$a['severity']] ?? 4, $a['display_id'], ] <=> [ $signalRank[$b['signal'] ?? ''] ?? 2, -($b['score'] ?? -1.0), $severityRank[$b['severity']] ?? 4, $b['display_id'], ]); return array_slice($entries, 0, $limit); } /** * The vulnerability database was more than DATABASE_MAX_AGE_DAYS old when the image was scanned. Unknown for rows * without the build time, which every completed scan records. */ private static function databaseWasOld(ImageScan $scan): bool { $builtAt = $scan->grype_db_built_at; $scannedAt = $scan->scanned_at; return $builtAt !== null && $scannedAt !== null && $builtAt->lt($scannedAt->copy()->subDays(self::DATABASE_MAX_AGE_DAYS)); } /** * The version check (images the user does not build) and the base check (Dockerfile apps with a base image) have * a result. `cannot_compare` and `no_better` count as checked, and so do the checks that only the user can unblock * (`isBlockedCheck()`): they have no result, and the card says why. A Dockerfile app also needs to know which * findings come from its current base image. Notifications pass `$blockedCounts = false`: they say that Coolify * could not check for a fixed version instead of "no fix yet". */ public static function versionsChecked(ImageScan $scan, Model $owner, bool $blockedCounts = true): bool { $settled = fn (mixed $status, mixed $error): bool => in_array($status, self::CHECKED_STATUSES, true) || ($blockedCounts && self::isBlockedCheck($status, $error)); $needsImageCheck = ! $owner instanceof Application || $owner->build_pack === 'dockerimage' || ($owner->build_pack === 'dockercompose' && $scan->builds_image === false); if ($needsImageCheck && ! $settled($scan->candidateStatus(), data_get($scan->candidates, 'error'))) { return false; } if ($owner instanceof Application && $owner->build_pack === 'dockerfile' && filled($scan->base_image_ref)) { $base = data_get($scan->candidates, 'base'); return is_array($base) && ($base['image'] ?? null) === $scan->base_image_ref && $settled($base['status'] ?? null, $base['error'] ?? null) && is_int($base['attributable'] ?? null); } return true; } /** * A version or base check that Coolify cannot finish by itself: the registry needs a login (`auth_required`), or * the server reached the daily limit of checks (`failed` with `daily_limit`). Other failures and a busy lock * (`failed` with `lock_busy`) are tried again, so they stay check_needed. */ public static function isBlockedCheck(mixed $status, mixed $error): bool { return $status === 'auth_required' || ($status === 'failed' && $error === 'daily_limit'); } private static function ownerName(Model $owner): ?string { $name = $owner instanceof ServiceApplication || $owner instanceof ServiceDatabase ? ($owner->human_name ?: $owner->name) : $owner->getAttribute('name'); return filled($name) ? (string) $name : null; } }