mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-06 13:10:37 -04:00
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>
724 lines
33 KiB
PHP
724 lines
33 KiB
PHP
<?php
|
|
|
|
namespace App\Services\ImageScan;
|
|
|
|
use App\Actions\ImageScan\RefreshCandidates;
|
|
use App\Actions\ImageScan\ScanImage;
|
|
use App\Actions\ImageScan\UpdateResourceImage;
|
|
use App\Enums\ApplicationDeploymentStatus;
|
|
use App\Enums\ImageScanStatus;
|
|
use App\Enums\SecurityActionType;
|
|
use App\Enums\SecurityStatusType;
|
|
use App\Models\Application;
|
|
use App\Models\ApplicationDeploymentQueue;
|
|
use App\Models\ImageScan;
|
|
use App\Models\Server;
|
|
use App\Models\Service;
|
|
use App\Models\User;
|
|
use Closure;
|
|
use Illuminate\Database\Eloquent\Model;
|
|
use Illuminate\Support\Collection;
|
|
use Illuminate\Support\Facades\Cache;
|
|
use InvalidArgumentException;
|
|
|
|
/**
|
|
* What the main card of the Security page shows (spec 15.7, 15.11): the status of the resource, and one row per image
|
|
* with its status badge, sentence, primary action with its button or copy text, why-list, provenance, the other
|
|
* actions, and the running digest. Buttons are only offered when the user may run them; the Livewire actions authorize
|
|
* again.
|
|
*
|
|
* @phpstan-type ButtonData array{label: string, title: string, submit: string, actions: list<string>, warning: string}
|
|
* @phpstan-type ActionData array{key: string, type: string, sentence: string, copy: ?string, button: ?ButtonData}
|
|
* @phpstan-type WhyData array{title: string, display_id: string, link: ?string, score: string, cvss: ?string, cvssNote: ?string, severity: string, signal: ?string, exploited: bool, package: string, version: string}
|
|
* @phpstan-type ImageRow array{key: string, name: ?string, ref: string, status: string, checking: bool, badge: ?string, badgeType: string, open: bool, sentence: string, attackNote: ?string, advice: ?string, lines: list<string>, primary: ?ActionData, mergedWith: ?string, why: list<WhyData>, whyMore: int, provenance: ?string, others: list<ActionData>, moreActions: int, versionNotes: list<array{text: string, link: ?string}>, filter: ?string, override: ?array{service: string, from: string, to: string}}
|
|
*/
|
|
final class SecurityCard
|
|
{
|
|
public const OTHER_ACTIONS_LIMIT = 5;
|
|
|
|
/** Image rows, worst first (the order of the team page). */
|
|
private const ROW_ORDER = ['update_now', 'update_available', 'no_fix', 'check_needed', 'ok', 'not_checked'];
|
|
|
|
private const PROBLEM_STATUSES = ['update_now', 'update_available', 'no_fix', 'check_needed'];
|
|
|
|
private const DATABASE_NAMES = [
|
|
'postgresql' => 'PostgreSQL',
|
|
'redis' => 'Redis',
|
|
'mongodb' => 'MongoDB',
|
|
'mysql' => 'MySQL',
|
|
'mariadb' => 'MariaDB',
|
|
'keydb' => 'KeyDB',
|
|
'dragonfly' => 'Dragonfly',
|
|
'clickhouse' => 'ClickHouse',
|
|
];
|
|
|
|
/**
|
|
* @param Collection<int, Model> $owners The applications and databases of a service, else the resource.
|
|
* @param Collection<int, ImageScan> $scans The current scans of the owners, with their `resource` set.
|
|
* @param array<string, ResourceSecurity> $imageStatuses `SecurityStatus::forScan()` of the scans the caller computed, by scan UUID.
|
|
* @param (Closure(Model): RelevanceRules)|null $rulesFor The relevance rules of an owner, when the caller keeps them.
|
|
* @return array{status: string, checking: bool, badge: ?string, badgeType: string, header: ?string, total: int, single: bool, rows: list<ImageRow>}
|
|
*/
|
|
public static function for(Model $resource, ResourceSecurity $security, Collection $owners, Collection $scans, User $user, array $imageStatuses = [], ?Closure $rulesFor = null): array
|
|
{
|
|
$status = $security->status;
|
|
$rows = self::images($resource, $security, $owners, $scans, $user, $imageStatuses, $rulesFor);
|
|
$total = count($rows);
|
|
$needsUpdate = count(array_filter($rows, fn (array $row): bool => in_array($row['status'], ['update_now', 'update_available'], true)));
|
|
$waiting = array_filter($rows, fn (array $row): bool => $row['status'] === SecurityStatusType::CheckNeeded->value);
|
|
$checking = $status === SecurityStatusType::CheckNeeded && $waiting !== [] && ! in_array(false, array_column($waiting, 'checking'), true);
|
|
|
|
return [
|
|
'status' => $status->value,
|
|
'checking' => $checking,
|
|
'badge' => $checking ? SecuritySentences::CHECKING_BADGE : SecuritySentences::badge($status),
|
|
'badgeType' => self::badgeType($status),
|
|
'header' => $total > 1 ? SecuritySentences::imagesNeedingUpdate($needsUpdate, $total) : null,
|
|
'total' => $total,
|
|
'single' => $total === 1,
|
|
'rows' => $rows,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* One row per image the status counts: each current scan (of a service, of the applications and databases Coolify
|
|
* scans), the scanned owners of a service without a current scan, and the images of a Compose app without a
|
|
* result in its current deployment (`ResourceScans::uncheckedComposeRefs()`). Worst first; the first row with a problem
|
|
* is open, and a single image is always open. A service update that merges several images (spec 15.5) has its
|
|
* button on the first row of the group; the other rows say which row updates them.
|
|
*
|
|
* @param Collection<int, Model> $owners
|
|
* @param Collection<int, ImageScan> $scans
|
|
* @param array<string, ResourceSecurity> $imageStatuses
|
|
* @param (Closure(Model): RelevanceRules)|null $rulesFor
|
|
* @return list<ImageRow>
|
|
*/
|
|
public static function images(Model $resource, ResourceSecurity $security, Collection $owners, Collection $scans, User $user, array $imageStatuses = [], ?Closure $rulesFor = null): array
|
|
{
|
|
$rulesFor ??= fn (Model $owner): RelevanceRules => RelevanceRules::forResource($owner);
|
|
$isService = $resource instanceof Service;
|
|
$statusOwners = $isService ? $owners->filter(fn (Model $owner): bool => ResourceScans::isScanTarget($owner))->values() : $owners;
|
|
$isStatusOwner = fn (?Model $owner): bool => $owner instanceof Model && $statusOwners->contains(fn (Model $candidate): bool => $candidate->is($owner));
|
|
|
|
$digests = [];
|
|
foreach ($scans as $scan) {
|
|
$digests[self::ownerKey($scan->resource)] ??= SecuritySentences::shortDigest($scan->repo_digest ?: $scan->image_id);
|
|
}
|
|
|
|
$notChecked = new ResourceSecurity(SecurityStatusType::NotChecked, SecurityStatus::NOT_SCANNED);
|
|
$composeServices = ResourceScans::composeServiceNames($resource);
|
|
$entries = [];
|
|
foreach ($scans as $scan) {
|
|
if ($isService && ! $isStatusOwner($scan->resource)) {
|
|
continue;
|
|
}
|
|
$entries[] = [
|
|
'key' => 'scan-'.$scan->uuid,
|
|
'scan' => $scan,
|
|
'owner' => $scan->resource,
|
|
'ref' => (string) $scan->image_ref,
|
|
'name' => $isService ? self::ownerName($scan->resource) : ($composeServices[ScanImage::imageName((string) $scan->image_ref)] ?? null),
|
|
'security' => $imageStatuses[(string) $scan->uuid] ?? SecurityStatus::forScan($scan, $rulesFor($scan->resource)),
|
|
];
|
|
}
|
|
foreach ($statusOwners as $owner) {
|
|
if ($isService && ! $scans->contains(fn (ImageScan $scan): bool => $scan->resource instanceof Model && $scan->resource->is($owner))) {
|
|
$entries[] = ['key' => 'owner-'.$owner->getAttribute('uuid'), 'scan' => null, 'owner' => $owner, 'ref' => trim((string) $owner->getAttribute('image')), 'name' => self::ownerName($owner), 'security' => $notChecked];
|
|
}
|
|
}
|
|
foreach (ResourceScans::uncheckedComposeRefs($resource, $scans) as $image) {
|
|
$entries[] = ['key' => 'compose-'.md5($image['name'] ?? $image['ref']), 'scan' => null, 'owner' => $resource, 'ref' => $image['ref'], 'name' => $image['name'], 'security' => $notChecked];
|
|
}
|
|
|
|
$rank = array_flip(self::ROW_ORDER);
|
|
usort($entries, fn (array $a, array $b): int => [$rank[$a['security']->status->value], mb_strtolower((string) $a['name']), $a['ref']]
|
|
<=> [$rank[$b['security']->status->value], mb_strtolower((string) $b['name']), $b['ref']]);
|
|
|
|
$merged = array_values(array_filter($security->actions, fn (SecurityAction $action): bool => $action->parts !== []));
|
|
$mergedStats = [];
|
|
foreach ($security->actions as $index => $action) {
|
|
if ($action->parts !== []) {
|
|
$mergedStats[self::key($action)] = (array) data_get($security->facts, "action_stats.{$index}", []);
|
|
}
|
|
}
|
|
$groupFirst = [];
|
|
$single = count($entries) === 1;
|
|
$noun = match (true) {
|
|
$resource instanceof Application => 'this app',
|
|
$resource instanceof Service => 'this service',
|
|
default => 'this database',
|
|
};
|
|
$composeOverrides = $resource instanceof Application && $resource->build_pack === 'dockercompose' ? $resource->activeComposeImageOverrides() : [];
|
|
$opened = false;
|
|
$rows = [];
|
|
foreach ($entries as $entry) {
|
|
/** @var ResourceSecurity $imageSecurity */
|
|
$imageSecurity = $entry['security'];
|
|
/** @var ?ImageScan $scan */
|
|
$scan = $entry['scan'];
|
|
$owner = $entry['owner'];
|
|
$status = $imageSecurity->status;
|
|
$ref = $entry['ref'];
|
|
$name = $entry['name'];
|
|
$showsActions = in_array($status, [SecurityStatusType::UpdateNow, SecurityStatusType::UpdateAvailable], true);
|
|
|
|
$actions = [];
|
|
if ($showsActions) {
|
|
foreach ($imageSecurity->actions as $index => $action) {
|
|
$actions[] = self::action($resource, $action, (array) data_get($imageSecurity->facts, "action_stats.{$index}", []), $owners, $user, $digests);
|
|
}
|
|
}
|
|
|
|
$primaryAction = $showsActions ? $imageSecurity->primary() : null;
|
|
$primary = $actions[0] ?? null;
|
|
$mergedWith = null;
|
|
$group = $primaryAction === null ? null : self::mergedGroup($merged, $primaryAction, $owner);
|
|
if ($group !== null) {
|
|
$groupKey = self::key($group);
|
|
if (isset($groupFirst[$groupKey])) {
|
|
$mergedWith = $groupFirst[$groupKey];
|
|
$primary = null;
|
|
} else {
|
|
$groupFirst[$groupKey] = (string) ($name ?? $ref);
|
|
$primary = self::action($resource, $group, $mergedStats[$groupKey] ?? [], $owners, $user, $digests);
|
|
}
|
|
}
|
|
|
|
// A single image is always open; in a list, the first row with a problem (spec 15.11).
|
|
$open = $single || (! $opened && in_array($status->value, self::PROBLEM_STATUSES, true));
|
|
$opened = $opened || $open;
|
|
$others = array_slice($actions, 1);
|
|
// The scan is done and only the queued version check is missing: say that it runs, not "Check needed".
|
|
$checking = $status === SecurityStatusType::CheckNeeded && $scan !== null
|
|
&& $imageSecurity->checkReason === SecurityStatus::REASON_VERSIONS_UNCHECKED && RefreshCandidates::isQueued($scan);
|
|
|
|
$rows[] = [
|
|
'key' => $entry['key'],
|
|
'name' => $name,
|
|
'ref' => $ref,
|
|
'status' => $status->value,
|
|
'checking' => $checking,
|
|
'badge' => $checking ? SecuritySentences::CHECKING_BADGE : SecuritySentences::badge($status),
|
|
'badgeType' => self::badgeType($status),
|
|
'open' => $open,
|
|
'sentence' => $checking ? SecuritySentences::versionCheckRunning($ref, $scan) : SecuritySentences::imageSentence($imageSecurity, $ref, $scan),
|
|
'attackNote' => SecuritySentences::attackNote($imageSecurity),
|
|
'advice' => SecuritySentences::noFixAdvice($imageSecurity, $noun),
|
|
'lines' => [...SecuritySentences::lines($imageSecurity), ...self::eolLine($imageSecurity)],
|
|
'primary' => $primary,
|
|
'mergedWith' => $mergedWith,
|
|
'why' => in_array($status, [SecurityStatusType::UpdateNow, SecurityStatusType::UpdateAvailable, SecurityStatusType::NoFix], true)
|
|
? array_map(fn (array $why): array => self::whyRow($why), $imageSecurity->why)
|
|
: [],
|
|
'whyMore' => $showsActions ? max(0, $imageSecurity->moreFixed) : 0,
|
|
'provenance' => $scan === null || ! in_array($status, [SecurityStatusType::UpdateNow, SecurityStatusType::UpdateAvailable, SecurityStatusType::NoFix], true)
|
|
? null
|
|
: SecuritySentences::provenance($scan, $primaryAction, $imageSecurity, self::provenanceCounts($scan, $primaryAction)),
|
|
'others' => array_slice($others, 0, self::OTHER_ACTIONS_LIMIT),
|
|
'moreActions' => max(0, count($others) - self::OTHER_ACTIONS_LIMIT),
|
|
'versionNotes' => $scan === null ? [] : self::versionCheckNotes(collect([$scan]), $user),
|
|
'filter' => $single ? null : $ref,
|
|
'override' => $name !== null && isset($composeOverrides[$name]) ? ['service' => $name, ...$composeOverrides[$name]] : null,
|
|
];
|
|
}
|
|
|
|
return $rows;
|
|
}
|
|
|
|
private static function badgeType(SecurityStatusType $status): string
|
|
{
|
|
return match ($status) {
|
|
SecurityStatusType::UpdateNow => 'error',
|
|
SecurityStatusType::UpdateAvailable => 'warning',
|
|
default => 'neutral',
|
|
};
|
|
}
|
|
|
|
/**
|
|
* The severity of a CVSS base score: 9.0 critical, 7.0 high, 4.0 medium, 0.1 low.
|
|
*/
|
|
public static function cvssSeverity(float $score): string
|
|
{
|
|
return match (true) {
|
|
$score >= 9.0 => 'critical',
|
|
$score >= 7.0 => 'high',
|
|
$score >= 4.0 => 'medium',
|
|
$score > 0.0 => 'low',
|
|
default => 'unknown',
|
|
};
|
|
}
|
|
|
|
/**
|
|
* A why row. The score shows next to the severity badge only when its CVSS range has the same severity; else it
|
|
* is only in the tooltip ("CVSS 7.5"), so a badge never reads "Medium 7.5".
|
|
*
|
|
* @param array<string, mixed> $why
|
|
* @return WhyData
|
|
*/
|
|
private static function whyRow(array $why): array
|
|
{
|
|
$severity = strtolower((string) ($why['severity'] ?? 'unknown'));
|
|
$cvss = $why['score'] === null ? null : number_format((float) $why['score'], 1);
|
|
$matches = $cvss !== null && self::cvssSeverity((float) $why['score']) === $severity;
|
|
|
|
return [
|
|
'title' => ScannerText::tidyTitle((string) $why['title']),
|
|
'display_id' => (string) $why['display_id'],
|
|
'link' => $why['link'],
|
|
'score' => $why['score'] === null ? 'No score' : number_format((float) $why['score'], 1).' '.ucfirst((string) $why['severity']),
|
|
'cvss' => $matches ? $cvss : null,
|
|
'cvssNote' => $cvss === null ? null : 'CVSS '.$cvss,
|
|
'severity' => $severity,
|
|
'signal' => match ($why['signal']) {
|
|
SecurityRules::EXPLOITED => 'Used by attackers',
|
|
SecurityRules::LIKELY => 'Likely to be exploited',
|
|
default => null,
|
|
},
|
|
'exploited' => $why['signal'] === SecurityRules::EXPLOITED,
|
|
'package' => (string) ($why['package'] ?? ''),
|
|
'version' => (string) ($why['version'] ?? ''),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* The merged service update (from the status of the resource) that contains this image's update.
|
|
*
|
|
* @param list<SecurityAction> $merged
|
|
*/
|
|
private static function mergedGroup(array $merged, SecurityAction $action, Model $owner): ?SecurityAction
|
|
{
|
|
if ($action->type !== SecurityActionType::UpdateImage) {
|
|
return null;
|
|
}
|
|
|
|
foreach ($merged as $group) {
|
|
foreach ($group->parts as $part) {
|
|
if ($part['owner_type'] === $owner->getMorphClass() && (string) $part['owner_id'] === (string) $owner->getKey() && $part['ref'] === $action->ref) {
|
|
return $group;
|
|
}
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* The first fixed version of a package upgrade, for the provenance line: the highest fixed version of the findings
|
|
* of that package the action fixes.
|
|
*
|
|
* @return array{fixed_in?: ?string}
|
|
*/
|
|
private static function provenanceCounts(ImageScan $scan, ?SecurityAction $action): array
|
|
{
|
|
if ($action === null || ! in_array($action->type, [SecurityActionType::UpgradePackage, SecurityActionType::UpgradeInDockerfile], true)) {
|
|
return [];
|
|
}
|
|
|
|
$ids = array_flip(array_map('mb_strtolower', $action->fixedIds));
|
|
$fixedIn = null;
|
|
foreach (ResourceScans::findings($scan) as $finding) {
|
|
if ($finding->package === $action->target && isset($ids[mb_strtolower($finding->id)]) && filled($finding->fixedIn)
|
|
&& ($fixedIn === null || version_compare(ltrim((string) $finding->fixedIn, 'vV'), ltrim($fixedIn, 'vV'), '>'))) {
|
|
$fixedIn = (string) $finding->fixedIn;
|
|
}
|
|
}
|
|
|
|
return ['fixed_in' => $fixedIn];
|
|
}
|
|
|
|
private static function ownerKey(?Model $owner): string
|
|
{
|
|
return $owner === null ? '' : $owner->getMorphClass().':'.$owner->getKey();
|
|
}
|
|
|
|
/**
|
|
* Lines for version and base checks that only the user can unblock (`SecurityStatus::isBlockedCheck()`): they
|
|
* count as checked, so the card says why no newer version is offered. A registry login links to the server's
|
|
* registry logins when the user may update the server.
|
|
*
|
|
* @param Collection<int, ImageScan> $scans The current scans of the page.
|
|
* @return list<array{text: string, link: ?string}>
|
|
*/
|
|
public static function versionCheckNotes(Collection $scans, User $user): array
|
|
{
|
|
$notes = [];
|
|
foreach ($scans as $scan) {
|
|
$base = data_get($scan->candidates, 'base');
|
|
$checks = [false => [$scan->candidateStatus(), data_get($scan->candidates, 'error')]];
|
|
if (filled($scan->base_image_ref) && is_array($base) && ($base['image'] ?? null) === $scan->base_image_ref) {
|
|
$checks[true] = [$base['status'] ?? null, $base['error'] ?? null];
|
|
}
|
|
|
|
foreach ($checks as $isBase => [$status, $error]) {
|
|
if (! SecurityStatus::isBlockedCheck($status, $error)) {
|
|
continue;
|
|
}
|
|
|
|
$text = RefreshCandidates::message($scan, (bool) $isBase);
|
|
if ($text === null || isset($notes[$text])) {
|
|
continue;
|
|
}
|
|
|
|
$server = $scan->server;
|
|
$notes[$text] = [
|
|
'text' => $text,
|
|
'link' => $status === 'auth_required' && $server instanceof Server && $user->can('update', $server)
|
|
? route('server.registries', ['server_uuid' => $server->uuid])
|
|
: null,
|
|
];
|
|
}
|
|
}
|
|
|
|
return array_values($notes);
|
|
}
|
|
|
|
/**
|
|
* A stable key of an action: its type, the image and candidate, and every part of a merged update.
|
|
*/
|
|
public static function key(SecurityAction $action): string
|
|
{
|
|
return substr(sha1(json_encode([
|
|
$action->type->value,
|
|
$action->image,
|
|
$action->ref,
|
|
$action->target,
|
|
array_map(fn (array $part): array => [(string) $part['owner_type'], (string) $part['owner_id'], (string) $part['ref']], $action->parts),
|
|
]) ?: ''), 0, 16);
|
|
}
|
|
|
|
/**
|
|
* The deployment whose image runs now: the latest finished deployment that is not a preview.
|
|
*/
|
|
public static function runningDeployment(Application $application): ?ApplicationDeploymentQueue
|
|
{
|
|
return ApplicationDeploymentQueue::query()
|
|
->where('application_id', $application->id)
|
|
->where('pull_request_id', 0)
|
|
->where('status', ApplicationDeploymentStatus::FINISHED->value)
|
|
->orderByDesc('id')
|
|
->first();
|
|
}
|
|
|
|
/**
|
|
* The action the deploy gate popover shows for a blocked deployment: the action stored with the gate result, else
|
|
* the primary action of the blocked image (of a Compose app, the image whose primary action fixes the most
|
|
* findings with an exploit signal), computed once per version of its scans. Without a scan of the deployment, the
|
|
* primary action of the main card.
|
|
*
|
|
* @return array{sentence: string, button: bool}|null
|
|
*/
|
|
public static function gateAction(Application $application, ApplicationDeploymentQueue $deployment): ?array
|
|
{
|
|
$stored = data_get($deployment->scan_gate_result, 'action');
|
|
if (is_array($stored) && filled($stored['sentence'] ?? null)) {
|
|
return ['sentence' => (string) $stored['sentence'], 'button' => (bool) ($stored['button'] ?? false)];
|
|
}
|
|
|
|
$scans = fn () => ImageScan::query()
|
|
->where('resource_type', $application->getMorphClass())
|
|
->where('resource_id', $application->id)
|
|
->where('deployment_queue_id', $deployment->id)
|
|
->where('status', ImageScanStatus::Completed);
|
|
// The version changes with the scan, its version and base checks, and its stored status (ignores).
|
|
$versions = $scans()->orderBy('id')->get(['id', 'updated_at', 'candidates', 'summary'])
|
|
->map(fn (ImageScan $scan): array => [$scan->id, (string) $scan->updated_at, $scan->candidates, $scan->summary]);
|
|
if ($versions->isEmpty()) {
|
|
return self::actionSummary(SecurityStatus::stored($application)->primary());
|
|
}
|
|
|
|
return Cache::remember(
|
|
"image-scan:gate-action:{$deployment->id}:".md5((string) json_encode($versions)),
|
|
now()->addDay(),
|
|
function () use ($scans, $application): array {
|
|
$best = null;
|
|
$bestRank = null;
|
|
foreach ($scans()->get() as $scan) {
|
|
$action = self::rankedAction($scan->setRelation('resource', $application));
|
|
if ($action !== null && ($bestRank === null || $action['rank'] > $bestRank)) {
|
|
[$best, $bestRank] = [$action['action'], $action['rank']];
|
|
}
|
|
}
|
|
|
|
return ['action' => self::actionSummary($best)];
|
|
},
|
|
)['action'];
|
|
}
|
|
|
|
/**
|
|
* The primary action of a blocked image, stored with the gate result so the popover does not compute it again.
|
|
*
|
|
* @return array{sentence: string, button: bool}|null
|
|
*/
|
|
public static function blockedImageAction(ImageScan $scan): ?array
|
|
{
|
|
return self::actionSummary(self::rankedAction($scan)['action'] ?? null);
|
|
}
|
|
|
|
/**
|
|
* @return array{action: SecurityAction, rank: array{0: int, 1: int, 2: int}}|null
|
|
*/
|
|
private static function rankedAction(ImageScan $scan): ?array
|
|
{
|
|
$security = SecurityStatus::forScan($scan);
|
|
$primary = $security->primary();
|
|
if ($primary === null) {
|
|
return null;
|
|
}
|
|
$stats = (array) data_get($security->facts, 'action_stats.0', []);
|
|
|
|
return ['action' => $primary, 'rank' => [(int) ($stats['signal'] ?? 0), (int) ($stats['critical'] ?? 0) + (int) ($stats['high'] ?? 0), (int) ($stats['medium'] ?? 0)]];
|
|
}
|
|
|
|
/**
|
|
* @return array{sentence: string, button: bool}|null
|
|
*/
|
|
private static function actionSummary(?SecurityAction $action): ?array
|
|
{
|
|
return $action === null ? null : ['sentence' => $action->sentence, 'button' => $action->button];
|
|
}
|
|
|
|
/**
|
|
* A Git app can be rebuilt only from a known commit: "HEAD" or no commit would build whatever the branch has now.
|
|
*/
|
|
public static function knowsRunningCommit(Application $application, ApplicationDeploymentQueue $deployment): bool
|
|
{
|
|
$commit = trim((string) $deployment->commit);
|
|
|
|
return ! $application->git_based() || ($commit !== '' && strtoupper($commit) !== 'HEAD');
|
|
}
|
|
|
|
public static function shortCommit(?string $commit): string
|
|
{
|
|
$commit = trim((string) $commit);
|
|
|
|
return $commit === '' ? 'HEAD' : mb_substr($commit, 0, 7);
|
|
}
|
|
|
|
/**
|
|
* The end-of-life OS, when the sentence does not say it already.
|
|
*
|
|
* @return list<string>
|
|
*/
|
|
private static function eolLine(ResourceSecurity $security): array
|
|
{
|
|
if ($security->eol === null || $security->eol['name'] === '') {
|
|
return [];
|
|
}
|
|
|
|
$said = in_array($security->status, [SecurityStatusType::UpdateNow, SecurityStatusType::UpdateAvailable], true)
|
|
&& (int) data_get($security->facts, 'action_stats.0.eol', 0) > 0;
|
|
if ($said) {
|
|
return [];
|
|
}
|
|
|
|
return [trim('This image uses '.ucfirst($security->eol['name']).' '.$security->eol['version']).', which no longer gets security fixes.'];
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $stats
|
|
* @param Collection<int, Model> $owners
|
|
* @param array<string, ?string> $digests The short digest of the running image of each owner (morph type and key).
|
|
* @return ActionData
|
|
*/
|
|
private static function action(Model $resource, SecurityAction $action, array $stats, Collection $owners, User $user, array $digests = []): array
|
|
{
|
|
return [
|
|
'key' => self::key($action),
|
|
'type' => $action->type->value,
|
|
'sentence' => $action->sentence,
|
|
'copy' => $action->copy,
|
|
'button' => $action->button && (int) $resource->getKey() !== 0 ? self::button($resource, $action, $stats, $owners, $user, $digests) : null,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $stats
|
|
* @param Collection<int, Model> $owners
|
|
* @param array<string, ?string> $digests
|
|
* @return ButtonData|null
|
|
*/
|
|
private static function button(Model $resource, SecurityAction $action, array $stats, Collection $owners, User $user, array $digests): ?array
|
|
{
|
|
if ($action->type === SecurityActionType::Rebuild) {
|
|
if (! $resource instanceof Application || ! $user->can('deploy', $resource)) {
|
|
return null;
|
|
}
|
|
$deployment = self::runningDeployment($resource);
|
|
if ($deployment === null || ! self::knowsRunningCommit($resource, $deployment)) {
|
|
return null;
|
|
}
|
|
$commit = self::shortCommit($deployment->commit);
|
|
|
|
return [
|
|
'label' => 'Rebuild',
|
|
'title' => 'Rebuild without cache?',
|
|
'submit' => 'rebuild',
|
|
'actions' => ["Rebuild commit {$commit} without cache.", 'Redeploy the app.'],
|
|
'warning' => "Coolify rebuilds commit {$commit} without cache and redeploys it.",
|
|
];
|
|
}
|
|
|
|
if (! in_array($action->type, [SecurityActionType::UpdateImage, SecurityActionType::PullLatest], true) || ! $user->can('update', $resource)) {
|
|
return null;
|
|
}
|
|
|
|
if ($action->parts !== []) {
|
|
return self::mergedButton($resource, $action, $owners, $digests);
|
|
}
|
|
|
|
$image = (string) $action->image;
|
|
$ref = (string) $action->ref;
|
|
if ($image === '' || $ref === '') {
|
|
return null;
|
|
}
|
|
$owner = $resource instanceof Service
|
|
? $owners->first(fn (Model $owner): bool => $owner->getMorphClass() === ($stats['owner_type'] ?? null) && (string) $owner->getKey() === (string) ($stats['owner_id'] ?? ''))
|
|
: $resource;
|
|
if (! $owner instanceof Model) {
|
|
return null;
|
|
}
|
|
$ownerUuid = $resource instanceof Service ? (string) $owner->getAttribute('uuid') : '';
|
|
$submit = 'updateImage('.self::quote($ref).', '.self::quote($ownerUuid).')';
|
|
$restart = self::restartLine($resource, $owners);
|
|
|
|
if ($action->type === SecurityActionType::PullLatest) {
|
|
return [
|
|
'label' => 'Pull latest and restart',
|
|
'title' => "Pull the newest build of {$image}?",
|
|
'submit' => $submit,
|
|
'actions' => ["Pull the build of {$image} that Coolify scanned.", $restart],
|
|
'warning' => "Coolify pulls exactly the build that it scanned, not a build pushed after the check. The settings keep {$image}, so later restarts pull its newest build as usual.",
|
|
];
|
|
}
|
|
|
|
$tag = (string) $action->target;
|
|
$changes = $resource instanceof Service
|
|
? UpdateResourceImage::subResourcesUsing($resource, $image)
|
|
->map(fn (Model $subResource): string => self::ownerName($subResource).': '.self::change($image, $digests[self::ownerKey($subResource)] ?? null, $ref))
|
|
->all()
|
|
: [self::change($image, $digests[self::ownerKey($resource)] ?? null, $ref)];
|
|
|
|
return [
|
|
'label' => "Update to {$tag}",
|
|
'title' => "Update to {$tag}?",
|
|
'submit' => $submit,
|
|
'actions' => [...$changes, $restart],
|
|
'warning' => self::updateWarning($resource, $image, $ref),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* One confirmation for every image of a merged service update.
|
|
*
|
|
* @param Collection<int, Model> $owners
|
|
* @param array<string, ?string> $digests
|
|
* @return ButtonData|null
|
|
*/
|
|
private static function mergedButton(Model $resource, SecurityAction $action, Collection $owners, array $digests): ?array
|
|
{
|
|
if (! $resource instanceof Service) {
|
|
return null;
|
|
}
|
|
|
|
$changes = [];
|
|
foreach ($action->parts as $part) {
|
|
$image = (string) $part['image'];
|
|
$ref = (string) $part['ref'];
|
|
if ($image === '' || $ref === '') {
|
|
return null;
|
|
}
|
|
foreach (UpdateResourceImage::subResourcesUsing($resource, $image) as $subResource) {
|
|
$changes[self::ownerKey($subResource)] = self::ownerName($subResource).': '.self::change($image, $digests[self::ownerKey($subResource)] ?? null, $ref);
|
|
}
|
|
}
|
|
if ($changes === []) {
|
|
return null;
|
|
}
|
|
|
|
$tag = (string) $action->target;
|
|
|
|
return [
|
|
'label' => "Update to {$tag}",
|
|
'title' => "Update to {$tag}?",
|
|
'submit' => 'updateImages('.self::quote(self::key($action)).')',
|
|
'actions' => [...array_values($changes), self::restartLine($resource, $owners)],
|
|
'warning' => self::updateWarning($resource, '', ''),
|
|
];
|
|
}
|
|
|
|
private static function updateWarning(Model $resource, string $image, string $ref): string
|
|
{
|
|
if ($resource instanceof Service) {
|
|
return 'One-click services pin versions that work together. Check the service template before you update.';
|
|
}
|
|
if ($resource instanceof Application && $resource->build_pack === 'dockercompose') {
|
|
return 'Coolify redeploys the app with the new image. Your compose file in Git does not change: Coolify keeps using the new image until the file names another one.';
|
|
}
|
|
if ($resource instanceof Application) {
|
|
return 'Coolify redeploys the app with the new image.';
|
|
}
|
|
|
|
$fromTag = self::tag($image);
|
|
$toTag = self::tag($ref);
|
|
$name = self::DATABASE_NAMES[array_search($resource::class, STANDALONE_DATABASE_MODELS, true) ?: ''] ?? null;
|
|
if ($name !== null && $fromTag !== null && $toTag !== null && self::major($fromTag) !== null && self::major($fromTag) === self::major($toTag)) {
|
|
return SecuritySentences::databaseUpdateConfirmation($name, $fromTag, $toTag);
|
|
}
|
|
|
|
return 'Check the release notes. Some updates need a data migration.';
|
|
}
|
|
|
|
/**
|
|
* What restarts. A service restarts every container, so the line names its images.
|
|
*
|
|
* @param Collection<int, Model> $owners
|
|
*/
|
|
private static function restartLine(Model $resource, Collection $owners): string
|
|
{
|
|
if ($resource instanceof Service) {
|
|
$names = $owners->map(fn (Model $owner): string => self::ownerName($owner))->filter()->values()->all();
|
|
|
|
return $names === []
|
|
? 'Pull the images and restart the service.'
|
|
: 'Pull the images and restart the service: '.implode(', ', $names).'.';
|
|
}
|
|
|
|
return $resource instanceof Application ? 'Redeploy the app.' : 'Restart the database.';
|
|
}
|
|
|
|
/**
|
|
* "postgres:15.0 3f1a2b4c5d6e → postgres:15.13": the running image, its short digest, and the new image.
|
|
*/
|
|
private static function change(string $image, ?string $digest, string $ref): string
|
|
{
|
|
return trim($image.' '.($digest ?? '')).' → '.$ref;
|
|
}
|
|
|
|
private static function ownerName(Model $owner): string
|
|
{
|
|
return (string) ($owner->getAttribute('human_name') ?: $owner->getAttribute('name'));
|
|
}
|
|
|
|
private static function tag(string $ref): ?string
|
|
{
|
|
try {
|
|
return ImageRef::parse($ref)->tag;
|
|
} catch (InvalidArgumentException) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private static function major(string $tag): ?string
|
|
{
|
|
return preg_match('/^v?(\d+)/i', $tag, $matches) === 1 ? $matches[1] : null;
|
|
}
|
|
|
|
/**
|
|
* A single-quoted argument for the confirmation modal's submit action.
|
|
*/
|
|
private static function quote(string $value): string
|
|
{
|
|
return "'".str_replace(['\\', "'"], ['\\\\', "\\'"], $value)."'";
|
|
}
|
|
}
|