Files
033f0c84da v4.7 (#12478)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: peaklabs-dev <122374094+peaklabs-dev@users.noreply.github.com>
Co-authored-by: Aditya Tripathi <aditya@climactic.co>
2026-11-06 14:25:02 +01:00

768 lines
34 KiB
PHP

<?php
namespace App\Services\ImageScan;
use App\Enums\SecurityActionType;
use App\Enums\SecurityStatusType;
use App\Models\ImageScan;
use Carbon\CarbonImmutable;
use Illuminate\Database\Eloquent\Model;
use InvalidArgumentException;
use Throwable;
/**
* The copy of the security status card (spec 15.5, 15.7, 15.11). Pure functions of the stored `ResourceSecurity` and
* the stored scan data.
*/
final class SecuritySentences
{
private const REGISTRY_NAMES = [
'docker.io' => '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<string>
*/
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<string, int|string|null> $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<Finding> $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<Finding>
*/
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<string> $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<string, mixed> $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<string, mixed>
*/
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<string>
*/
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<string, int|string|null>
*/
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';
}
}
}