'Docker Hub', 'ghcr.io' => 'GitHub Container Registry', 'quay.io' => 'Quay', ]; /** The badge while the queued version check of a scanned image runs. */ public const CHECKING_BADGE = 'Checking'; private const CHECK_REASONS = [ SecurityStatus::REASON_IMAGES_UNCHECKED, SecurityStatus::REASON_TRUNCATED, SecurityStatus::REASON_STALE, SecurityStatus::REASON_DB_OUTDATED, SecurityStatus::REASON_IMAGE_CHANGED, SecurityStatus::REASON_VERSIONS_UNCHECKED, ]; /** * The sentence under the status line. */ public static function headline(ResourceSecurity $security): string { return match ($security->status) { SecurityStatusType::UpdateNow => self::updateNow($security), SecurityStatusType::UpdateAvailable => self::updateAvailable($security), SecurityStatusType::NoFix => self::noFix($security), SecurityStatusType::Ok => 'No action needed.', SecurityStatusType::CheckNeeded => 'Coolify needs to check this image again: '.(in_array($security->checkReason, self::CHECK_REASONS, true) ? self::checkReason((string) $security->checkReason, self::checkContext($security)) : 'the last check is incomplete').'.', SecurityStatusType::NotChecked => self::checkReason((string) ($security->checkReason ?? 'not_scanned'), $security->checkContext), }; } /** * Extra lines under the headline. * * @return list */ public static function lines(ResourceSecurity $security): array { if (! $security->exploitedWithoutFix) { return []; } $unfixed = match ($security->status) { SecurityStatusType::UpdateNow => $security->exploitedCount - $security->problemCount, SecurityStatusType::UpdateAvailable => $security->exploitedCount, default => 0, }; if ($unfixed < 1) { return []; } $use = (int) data_get($security->facts, 'counts.kev_unfixed', 0) > 0 ? 'are using' : 'are likely to use'; return [$unfixed > 1 ? "{$unfixed} more problems that attackers {$use} have no fix yet." : "One more problem that attackers {$use} has no fix yet."]; } /** * The reason of a check_needed status, or the text of a not_checked status. * * @param array $context */ public static function checkReason(string $code, array $context = []): string { return match ($code) { SecurityStatus::REASON_IMAGES_UNCHECKED => (int) ($context['checked'] ?? 0).' of '.(int) ($context['total'] ?? 0).' images were checked', SecurityStatus::REASON_TRUNCATED => 'the scan result was too large to read completely', SecurityStatus::REASON_STALE => 'the last check was '.self::ago($context['time'] ?? null), SecurityStatus::REASON_DB_OUTDATED => 'the vulnerability database is out of date', SecurityStatus::REASON_IMAGE_CHANGED => 'the running image is not the checked image', SecurityStatus::REASON_VERSIONS_UNCHECKED => 'Coolify has not checked for newer versions yet', 'instance' => 'Image scanning is off for this Coolify instance.', 'server' => 'Image scanning is off for this server.', 'application' => 'Image scanning is off for this app.', default => 'This image has not been scanned yet.', }; } /** * The line after the why-list. Empty for none. */ public static function whyMore(int $count): string { if ($count <= 0) { return ''; } return $count === 1 ? 'and 1 more problem that this update fixes' : "and {$count} more problems that this update fixes"; } public static function badge(SecurityStatusType $status): ?string { return match ($status) { SecurityStatusType::UpdateNow => 'Update now', SecurityStatusType::UpdateAvailable => 'Security update', SecurityStatusType::NoFix => 'No fix yet', SecurityStatusType::CheckNeeded => 'Check needed', SecurityStatusType::Ok, SecurityStatusType::NotChecked => null, }; } /** * The confirmation before a database moves to a newer tag of the same major version. */ public static function databaseUpdateConfirmation(string $dbType, string $fromTag, string $toTag): string { $major = preg_match('/^v?(\d+)/i', $toTag, $matches) === 1 ? $matches[1] : (preg_match('/^v?(\d+)/i', $fromTag, $matches) === 1 ? $matches[1] : null); $name = $major === null ? $dbType : "{$dbType} {$major}"; return "This is a patch release of {$name}. Your data stays as it is. The database restarts and is unavailable for a few seconds."; } /** * The sentence of one image row (spec 15.11): it starts with the image ref, or with "This app" for an image that * Coolify builds (its ref is a long commit tag, shown in the row footer). With the scan, a no_fix sentence says * what the stored findings and version check know about a fix. */ public static function imageSentence(ResourceSecurity $security, string $imageRef, ?ImageScan $scan = null): string { $subject = self::builtByCoolify($scan) ? 'This app' : $imageRef; return match ($security->status) { SecurityStatusType::UpdateNow => self::updateNow($security, $subject, 'A newer build'), SecurityStatusType::UpdateAvailable => self::imageUpdateAvailable($security, $subject), SecurityStatusType::NoFix => $security->builderImage ? self::noFix($security, $subject) : "{$subject} has ".self::problems($security->problemCount).' that attackers '.($security->anyExploited ? 'are using' : 'are likely to use') .'. '.self::noFixState($scan, $security), SecurityStatusType::Ok => "{$subject} needs no action.", SecurityStatusType::CheckNeeded => "{$subject} needs another check: ".(in_array($security->checkReason, self::CHECK_REASONS, true) ? self::checkReason((string) $security->checkReason, self::checkContext($security)) : 'the last check is incomplete').'.', SecurityStatusType::NotChecked => in_array($security->checkReason, [null, SecurityStatus::NOT_SCANNED], true) ? "{$subject} has not been checked yet." : self::checkReason((string) $security->checkReason, $security->checkContext), }; } /** * The sentence of an image row while its queued version check runs. */ public static function versionCheckRunning(string $imageRef, ?ImageScan $scan = null): string { $subject = self::builtByCoolify($scan) ? 'this app' : $imageRef; return "Coolify is checking for newer versions of {$subject}."; } /** * The short action text of a no_fix status for the team page. Null for other statuses. */ public static function noFixAction(ResourceSecurity $security): ?string { if ($security->status !== SecurityStatusType::NoFix) { return null; } return $security->builderImage ? 'Needs a builder update in a Coolify release.' : 'No fix released yet. Coolify checks daily.'; } /** * Coolify builds the image (Dockerfile, Nixpacks, Railpack, static, Compose build), so its ref is a commit tag. */ private static function builtByCoolify(?ImageScan $scan): bool { if ($scan === null || ! (bool) $scan->builds_image) { return false; } $owner = $scan->resource; return $owner instanceof Model && in_array(SecurityActions::imageKind($scan, $owner), ['dockerfile', 'compose', 'builder'], true); } /** * What a no_fix image row says about a fix, in line with `noFixPackages()` of the provenance. When every listed * problem has a fixed package version upstream and the version check covered the newest image of the line without * finding it there: "The fix is not in the newest mysql 8.x image yet." Without that check, only that a fix exists. */ private static function noFixState(?ImageScan $scan, ResourceSecurity $security): string { $none = 'No fixed version exists yet, and Coolify checks every day.'; $findings = $scan === null ? [] : self::whyFindings($scan, $security); $withFix = count(array_filter($findings, fn (Finding $finding): bool => self::hasPackageFix($finding))); if ($findings === [] || $withFix === 0) { return $none; } $many = $security->problemCount > 1; if ($withFix < count($findings)) { return 'Some of them have a fix that the image does not include yet. Coolify checks every day.'; } $newest = self::newestImageWithout($scan, $findings); return match (true) { $newest !== null => ($many ? 'The fixes are' : 'The fix is')." not in the newest {$newest} image yet. Coolify checks every day.", $many => 'A fix exists for each, but the image does not include them yet. Coolify checks every day.', default => 'A fix exists, but the image does not include it yet. Coolify checks every day.', }; } /** * "mysql 8.x" when the stored version check (ok or no_better) found no newer build or tag of the line that has a * fix of the findings, so the newest image of the line does not have it. Null when the check did not cover it or * the tag is not a version. * * @param list $findings */ private static function newestImageWithout(ImageScan $scan, array $findings): ?string { if (! in_array($scan->candidateStatus(), ['ok', 'no_better'], true)) { return null; } try { $image = ImageRef::parse((string) $scan->image_ref); $name = (string) ImageRef::parse($image->qualifiedName()); } catch (InvalidArgumentException) { return null; } $line = self::releaseLine($image->tag, $image->tag); if ($line === null) { return null; } $ids = []; foreach ($findings as $finding) { foreach ([$finding->id, ...$finding->aliases] as $id) { $ids[mb_strtolower((string) $id)] = true; } } foreach ($scan->candidateEntries() as $entry) { if (! in_array($entry['kind'] ?? null, ['same_tag', 'line'], true)) { continue; } foreach ((array) ($entry['fixed_ids'] ?? []) as $id) { if (isset($ids[mb_strtolower((string) $id)])) { return null; } } } return "{$name} {$line}"; } /** * The stored findings of the why-list (by ID, display ID, or alias). * * @return list */ private static function whyFindings(ImageScan $scan, ResourceSecurity $security): array { $whyIds = array_flip(array_map(fn (array $why): string => mb_strtolower((string) $why['display_id']), $security->why)); return array_values(array_filter(ResourceScans::findings($scan), fn (Finding $finding): bool => array_intersect_key( array_flip(array_map('mb_strtolower', [$finding->id, $finding->displayId, ...$finding->aliases])), $whyIds) !== [])); } private static function hasPackageFix(Finding $finding): bool { return $finding->fixState === 'fixed' && filled($finding->fixedIn); } /** * The short note after the sentence of an image row that does not mention attackers itself (update_available). */ public static function attackNote(ResourceSecurity $security): ?string { if ($security->status !== SecurityStatusType::UpdateAvailable || $security->exploitedWithoutFix) { return null; } return 'No known attacks on '.(max(1, $security->fixedByPrimary) > 1 ? 'these problems' : 'this problem').'.'; } /** * The advice under a no_fix image row, until a fix exists ("this app", "this service", "this database"). Null for * other statuses and for builder images, whose sentence says that a builder update fixes them. */ public static function noFixAdvice(ResourceSecurity $security, string $resourceNoun = 'this app'): ?string { if ($security->status !== SecurityStatusType::NoFix || $security->builderImage) { return null; } return $security->problemCount > 1 ? "Until then, limit who can reach {$resourceNoun}, or ignore the problems if the affected parts are not used." : "Until then, limit who can reach {$resourceNoun}, or ignore the problem if the affected part is not used."; } /** * How Coolify knows that the action of an image row fixes its problems, or that no fix exists (spec 15.11), from * the stored version and base checks only. Null when there is nothing to say. * * @param array{fixes?: ?int, adds?: ?int, fixed_in?: ?string} $counts What the candidate fixes and adds, and the * first fixed version of a package upgrade. */ public static function provenance(ImageScan $scan, ?SecurityAction $action, ResourceSecurity $security, array $counts = []): ?string { if ($action === null) { return $security->status === SecurityStatusType::NoFix && ! $security->builderImage ? self::noFixProvenance($scan, $security) : null; } $registry = self::registryName((string) $scan->image_ref); switch ($action->type) { case SecurityActionType::PullLatest: case SecurityActionType::UpdateImage: $entry = self::candidateEntry($scan, $action); if ($entry === []) { return null; } $tag = (string) ($action->target ?? self::tagOf((string) $action->ref)); $line = self::releaseLine(self::tagOf((string) $scan->image_ref), $tag); $intro = $action->type === SecurityActionType::PullLatest ? "{$registry} has a newer build of {$tag}." : ($line === null ? "{$tag} is the newest release of this line on {$registry}." : "{$tag} is the newest {$line} release on {$registry}."); return "{$intro} Coolify scanned it".self::scannedAt($entry['scanned_at'] ?? null).self::candidateFindings($entry); case SecurityActionType::ChangeBase: $base = data_get($scan->candidates, 'base'); $entry = collect(is_array($base) && is_array($base['entries'] ?? null) ? $base['entries'] : []) ->first(fn ($entry): bool => is_array($entry) && ($entry['kind'] ?? null) === 'line' && ($entry['better'] ?? false) === true && (string) ($entry['ref'] ?? '') !== '' && self::tagOf((string) $entry['ref']) === self::tagOf((string) $action->target)); if (! is_array($entry)) { return null; } return "{$action->target} is the newest release of your base image. Coolify scanned it".self::scannedAt($entry['scanned_at'] ?? null).self::candidateFindings($entry); case SecurityActionType::UpgradePackage: case SecurityActionType::UpgradeInDockerfile: $fixedIn = $counts['fixed_in'] ?? null; return filled($fixedIn) && filled($action->target) ? "The advisory lists {$action->target} {$fixedIn} as the first fixed version." : null; case SecurityActionType::Rebuild: return filled($scan->base_image_ref) ? "The current {$scan->base_image_ref} image already has the fixed system packages." : 'The current base image already has the fixed system packages.'; default: return null; } } /** * "Docker Hub" for docker.io, "GitHub Container Registry" for ghcr.io, "Quay" for quay.io, else the host. */ public static function registryName(string $imageRef): string { try { $registry = ImageRef::parse($imageRef)->registry; } catch (InvalidArgumentException) { return 'the registry'; } return self::REGISTRY_NAMES[$registry] ?? $registry; } /** * The header of a list of several images: "2 of 3 images need an update". Null when none needs an update. */ public static function imagesNeedingUpdate(int $count, int $total): ?string { if ($count < 1) { return null; } return "{$count} of {$total} images ".($count === 1 ? 'needs' : 'need').' an update'; } /** * The count in the footer of an image row: "12 known issues". */ public static function knownIssues(int $known): string { return match (true) { $known < 1 => 'No known issues', $known === 1 => '1 known issue', default => "{$known} known issues", }; } /** * "sha256:" and the first 12 characters of the digest, without the prefix: "3f1a2b4c5d6e". */ public static function shortDigest(?string $digest): ?string { $digest = trim((string) $digest); if ($digest === '') { return null; } $hex = str_contains($digest, 'sha256:') ? substr($digest, (int) strpos($digest, 'sha256:') + strlen('sha256:')) : $digest; return substr($hex, 0, 12) ?: null; } /** * "Coolify checked Docker Hub 2 hours ago. No newer 16.x release fixes this yet." Only for images Coolify checks * for newer versions. */ private static function noFixProvenance(ImageScan $scan, ResourceSecurity $security): ?string { $checkedAt = is_array($scan->candidates) && ! array_is_list($scan->candidates) ? ($scan->candidates['checked_at'] ?? null) : null; if (! is_string($checkedAt) || ! in_array($scan->candidateStatus(), ['ok', 'no_better'], true)) { return null; } $one = $security->problemCount <= 1; $entries = collect($scan->candidateEntries()); $tag = self::tagOf((string) $scan->image_ref); $checked = 'Coolify checked '.self::registryName((string) $scan->image_ref).self::scannedAt($checkedAt); $newer = $entries->filter(fn (array $entry): bool => in_array($entry['kind'] ?? null, ['same_tag', 'line'], true)) ->map(fn (array $entry): string => ($entry['kind'] ?? null) === 'same_tag' ? "a newer build of {$tag}" : (string) self::tagOf((string) ($entry['ref'] ?? ''))) ->unique()->values()->all(); $line = self::releaseLine($tag, $tag); $sentences = [$newer === [] ? "{$checked}: you already run the newest ".($line === null ? "build of {$tag}" : "{$line} version").'.' : "{$checked}: ".self::andList($newer).(count($newer) > 1 ? ' are' : ' is').' newer, but not better.']; $sentences[] = self::noFixPackages($scan, $security, $one); $major = $entries->first(fn (array $entry): bool => ($entry['kind'] ?? null) === 'major' && array_key_exists('fixes_problems', $entry)); if (is_array($major)) { $fixed = array_flip(array_map(fn ($id): string => mb_strtolower((string) $id), (array) ($major['fixed_ids'] ?? []))); $whyIds = array_map(fn (array $why): string => mb_strtolower((string) $why['display_id']), $security->why); $fixedWhy = count(array_filter($whyIds, fn (string $id): bool => isset($fixed[$id]))); $ref = (string) ($major['ref'] ?? ''); $sentences[] = ($major['fixes_problems'] ?? false) === true && $fixedWhy > 0 ? "{$ref}, a new major version, fixes ".($fixedWhy < count($whyIds) ? 'some of them' : ($one ? 'it' : 'them')) .'. Coolify does not change major versions for you, because they can need a data migration or code changes: read its upgrade notes first.' : "The newest major version, {$ref}, is not better either."; } return implode(' ', array_filter($sentences)); } /** * Where the problems of a no_fix image are, and whether a fixed version of those packages exists upstream that the * image does not include yet. Null without known packages. */ private static function noFixPackages(ImageScan $scan, ResourceSecurity $security, bool $one): ?string { $packages = array_values(array_unique(array_filter(array_map(fn (array $why): string => (string) $why['package'], $security->why)))); if ($packages === []) { return null; } $findings = self::whyFindings($scan, $security); $withFix = count(array_filter($findings, fn (Finding $finding): bool => self::hasPackageFix($finding))); $names = self::andList($packages); $where = ($security->problemCount > count($security->why) ? 'The listed problems are' : ($one ? 'The problem is' : 'The problems are'))." in {$names}"; return match (true) { // No stored finding of the why-list: nothing is known about a fix. $findings === [] => "{$where}.", $withFix === 0 => "{$where}, and no fixed version of ".(count($packages) > 1 ? 'them' : 'it').' exists yet.', $withFix === count($findings) => "{$where}. Fixed versions exist, but the image does not include them yet.", default => "{$where}. Some of them have a fixed version that the image does not include yet.", }; } /** * "8.x" for the line of a tag, or "8.0.x" when both tags have three or more version parts with the same minor (a * patch of that minor). Null for a tag that is not a version. */ private static function releaseLine(?string $currentTag, ?string $tag): ?string { $parts = fn (?string $value): array => $value !== null && preg_match('/^[^0-9]*(\d+(?:\.\d+)*)/', $value, $match) === 1 ? explode('.', $match[1]) : []; [$current, $candidate] = [$parts($currentTag), $parts($tag)]; if ($candidate === []) { return null; } if (count($candidate) >= 3 && count($current) >= 3 && $current[0] === $candidate[0] && $current[1] === $candidate[1] && $currentTag !== $tag) { return "{$candidate[0]}.{$candidate[1]}.x"; } return "{$candidate[0]}.x"; } /** * "a", "a and b", "a, b, and c". * * @param list $items */ private static function andList(array $items): string { return count($items) < 3 ? implode(' and ', $items) : implode(', ', array_slice($items, 0, -1)).', and '.end($items); } /** * What the scan of an offered candidate found, without a count of its own, so it cannot contradict the sentence: * ": it fixes these problems and adds none that attackers use." The "better" rule (spec 15.4) guarantees that an * offered candidate adds no finding with an exploit signal. The other relevant findings it adds (`added`, unique * IDs) follow as their own sentence; none when the entry does not store them. * * @param array $entry */ private static function candidateFindings(array $entry): string { $text = ': it fixes these problems and adds none that attackers use.'; $added = $entry['added'] ?? null; if (is_int($added) && $added > 0) { $text .= $added === 1 ? ' It has 1 other problem that attackers are not known to use.' : " It has {$added} other problems that attackers are not known to use."; } return $text; } /** * The stored candidate of an update_image or pull_latest action. * * @return array */ private static function candidateEntry(ImageScan $scan, SecurityAction $action): array { $kind = $action->type === SecurityActionType::PullLatest ? 'same_tag' : 'line'; return collect($scan->updateCandidates()) ->first(fn (array $entry): bool => ($entry['kind'] ?? null) === $kind && ($action->ref === null || ($entry['ref'] ?? null) === $action->ref)) ?? []; } /** * " 2 hours ago", or nothing when the time is not known. */ private static function scannedAt(mixed $time): string { return is_string($time) && $time !== '' ? ' '.self::ago($time) : ''; } private static function tagOf(string $ref): ?string { try { return ImageRef::parse($ref)->tag; } catch (InvalidArgumentException) { return null; } } private static function major(?string $tag): ?string { return $tag !== null && preg_match('/^v?(\d+)[.\-]/i', $tag.'.', $matches) === 1 ? $matches[1] : null; } /** * update_available in the words of an image row: "postgres:16-alpine has a newer build that fixes 2 critical * problems." or "{ref} has a serious problem. Update to 16.4-alpine to fix it." Counts with medium problems are not * called serious: "{ref} has 6 problems (1 critical, 3 high, 2 medium)." */ private static function imageUpdateAvailable(ResourceSecurity $security, string $imageRef): string { $action = $security->primary(); $fixed = max(1, $security->fixedByPrimary); $only = match (true) { $security->primaryCritical > 0 && $security->primaryHigh === 0 && $security->primaryMedium === 0 => 'critical', $security->primaryMedium > 0 && $security->primaryCritical === 0 && $security->primaryHigh === 0 => 'medium', default => null, }; $parts = self::severityParts($security); $problems = match (true) { $fixed === 1 => $only === null ? 'a serious problem' : "a {$only} problem", $only !== null => "{$fixed} {$only} problems", default => "{$fixed} ".self::problemsNoun($security, 'problems').($parts === [] ? '' : ' ('.implode(', ', $parts).')'), }; $it = $fixed === 1 ? 'it' : 'them'; $sentence = (string) $action?->sentence; $majorNote = ' This is a major version change. Check for breaking changes.'; $note = ''; if (str_ends_with($sentence, $majorNote)) { $sentence = substr($sentence, 0, -strlen($majorNote)); $note = $majorNote; } $text = match (true) { $action === null => "{$imageRef} has {$problems}.", $action->type === SecurityActionType::PullLatest && $sentence !== SecurityActions::COOLIFY_TEXT => "{$imageRef} has a newer build that fixes {$problems}." .(str_contains($sentence, 'Redeploy this app to pull it.') ? ' Redeploy this app to pull it.' : ''), $action->type === SecurityActionType::UpdateImage && str_starts_with($sentence, 'Update to ') && str_ends_with($sentence, '.') => "{$imageRef} has {$problems}. ".substr($sentence, 0, -1)." to fix {$it}.", default => "{$imageRef} has {$problems}. {$sentence}", }; $eol = self::eolSentence($security); return trim($text.$note.($eol === '' ? '' : " {$eol}")); } private static function updateNow(ResourceSecurity $security, string $subject = 'This image', string $newerBuild = 'A newer build of this image'): string { $problems = self::problems($security->problemCount); $use = $security->anyExploited ? 'are using' : 'are likely to use'; $headline = "{$subject} has {$problems} that attackers {$use}."; $action = $security->primary(); if ($action === null) { return $headline; } $fixed = $security->fixedByPrimary; $what = $fixed > 0 && $fixed < $security->problemCount ? "{$fixed} of them" : ($security->problemCount === 1 ? 'it' : 'them'); $sentence = $action->sentence; if ($action->type === SecurityActionType::UpdateImage && str_starts_with($sentence, 'Update ') && $sentence !== SecurityActions::COOLIFY_TEXT && str_ends_with($sentence, '.')) { $sentence = substr($sentence, 0, -1)." to fix {$what}."; } elseif ($action->type === SecurityActionType::PullLatest && $sentence !== SecurityActions::COOLIFY_TEXT) { $sentence = "{$newerBuild} fixes {$what}. ".self::pullInstruction($sentence); } return trim("{$headline} {$sentence} ".self::eolSentence($security)); } private static function updateAvailable(ResourceSecurity $security): string { $action = $security->primary(); $fixed = max(1, $security->fixedByPrimary); $parts = self::severityParts($security); $breakdown = $parts === [] ? '' : ' ('.implode(', ', $parts).')'; $noun = self::problemsNoun($security, $fixed === 1 ? 'problem' : 'problems'); $fixes = "This fixes {$fixed} {$noun}{$breakdown}."; $sentence = $action?->sentence ?? ''; if ($action?->type === SecurityActionType::PullLatest && $sentence !== SecurityActions::COOLIFY_TEXT) { $sentence = 'A newer build of this image is available. '.self::pullInstruction($sentence); } $note = ''; $majorNote = ' This is a major version change. Check for breaking changes.'; if (str_ends_with($sentence, $majorNote)) { $sentence = substr($sentence, 0, -strlen($majorNote)); $note = $majorNote; } $text = trim("{$sentence} {$fixes}").$note; $eol = self::eolSentence($security); if ($eol !== '') { $text .= " {$eol}"; } if (! $security->exploitedWithoutFix) { $text .= ' Attackers are not known to use '.($fixed === 1 ? 'it' : 'them').'.'; } return $text; } /** * "1 critical", "3 high", "2 medium" for the fixes of the primary action, without the zero ones. * * @return list */ private static function severityParts(ResourceSecurity $security): array { return array_values(array_filter([ $security->primaryCritical > 0 ? "{$security->primaryCritical} critical" : null, $security->primaryHigh > 0 ? "{$security->primaryHigh} high" : null, $security->primaryMedium > 0 ? "{$security->primaryMedium} medium" : null, ])); } /** * "serious problems", or only "problems" when the fixes include medium ones. */ private static function problemsNoun(ResourceSecurity $security, string $noun): string { return $security->primaryMedium > 0 ? $noun : "serious {$noun}"; } private static function noFix(ResourceSecurity $security, string $subject = 'This image'): string { $problems = self::problems($security->problemCount); $use = $security->anyExploited ? 'are using' : 'are likely to use'; if ($security->builderImage) { return "{$subject} has {$problems} that attackers {$use}. ".SecurityActions::BUILDER_TEXT; } $many = $security->problemCount > 1; $ignore = $many ? 'ignore the problems if the affected parts are not used' : 'ignore the problem if the affected part is not used'; return "{$subject} has {$problems} that attackers {$use}, and no fixed version exists yet. Coolify checks every day and tells you when a fix is out. Until then, limit who can reach this app, or {$ignore}."; } /** * The "pull it" half of a pull_latest sentence: "Pull it and restart.", "Pull it and redeploy." (Compose), or * "Redeploy this app to pull it.". */ private static function pullInstruction(string $sentence): string { return match (true) { str_contains($sentence, 'Redeploy this app to pull it.') => 'Redeploy this app to pull it.', str_ends_with($sentence, ' and redeploy.') => 'Pull it and redeploy.', default => 'Pull it and restart.', }; } /** * Said when the primary action moves the image off an end-of-life OS. */ private static function eolSentence(ResourceSecurity $security): string { if ($security->eol === null || $security->eol['name'] === '' || (int) data_get($security->facts, 'action_stats.0.eol', 0) < 1) { return ''; } $name = ucfirst($security->eol['name']); return trim("It moves off {$name} {$security->eol['version']}").', which no longer gets security fixes.'; } private static function problems(int $count): string { return $count > 1 ? "{$count} security problems" : 'a security problem'; } /** * @return array */ private static function checkContext(ResourceSecurity $security): array { $context = $security->checkContext; if (($context['time'] ?? null) === null && $security->checkedAt !== null) { $context['time'] = $security->checkedAt->toIso8601String(); } return $context; } private static function ago(mixed $time): string { if (! is_string($time) || $time === '') { return 'a while ago'; } try { return CarbonImmutable::parse($time)->diffForHumans(); } catch (Throwable) { return 'a while ago'; } } }