mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-04 10:42:23 +00:00
- The Syft template no longer prints layer IDs; the parser ignores the line from older templates - Remove Finding::withLayer() - ImageScanFixState has two cases, fixed and no_fix, as every caller only used those
415 lines
18 KiB
PHP
415 lines
18 KiB
PHP
<?php
|
|
|
|
namespace App\Services\ImageScan;
|
|
|
|
use App\Actions\ImageScan\UpdateResourceImage;
|
|
use App\Enums\ImageScanFixState;
|
|
use App\Enums\ImageScanStatus;
|
|
use App\Enums\SecurityActionType;
|
|
use App\Models\Application;
|
|
use App\Models\ImageScan;
|
|
use App\Models\ServiceApplication;
|
|
use App\Models\ServiceDatabase;
|
|
use Illuminate\Database\Eloquent\Builder;
|
|
use Illuminate\Database\Eloquent\Model;
|
|
|
|
/**
|
|
* Maps each relevant finding of a scanned image to the one action that fixes it, or to none (spec 15.5).
|
|
*
|
|
* Reads only stored data: the findings, the version and base checks in `candidates`, and, for a Dockerfile app with
|
|
* OS findings that have a fix, two queries for the latest rebuild without cache of the app and the scan before it. A
|
|
* finding that a rebuild without cache did not remove gets `upgrade_in_dockerfile` instead of another `rebuild`, also
|
|
* in the scans of later deploys.
|
|
*/
|
|
final class SecurityActions
|
|
{
|
|
public const BUILDER_TEXT = 'A fix exists, but it reaches this app only with a builder update in a Coolify release. To choose the base image yourself, use the Dockerfile build pack.';
|
|
|
|
public const COOLIFY_TEXT = 'Update Coolify to get a newer version.';
|
|
|
|
private const OS_PACKAGE_TYPES = ['apk', 'deb', 'rpm'];
|
|
|
|
private const LANGUAGE_PACKAGE_TYPES = ['npm', 'python', 'go-module', 'java-archive', 'gem', 'php-composer', 'rust-crate', 'dotnet'];
|
|
|
|
private const BUTTON_TYPES = [SecurityActionType::UpdateImage, SecurityActionType::PullLatest, SecurityActionType::Rebuild];
|
|
|
|
/**
|
|
* The fix state of each finding of a scan for the findings lists: fixed when an action of the scan fixes it or a
|
|
* fixed package version exists, else no fix.
|
|
*
|
|
* @return \Closure(Finding): ImageScanFixState
|
|
*/
|
|
public static function fixStates(ImageScan $scan, IgnoreMatcher $ignores): \Closure
|
|
{
|
|
$byFinding = self::forScan($scan, $ignores)['byFinding'];
|
|
|
|
return fn (Finding $finding): ImageScanFixState => isset($byFinding[$finding->id]) || ($finding->fixState === 'fixed' && filled($finding->fixedIn))
|
|
? ImageScanFixState::Fixed
|
|
: ImageScanFixState::NoFix;
|
|
}
|
|
|
|
/**
|
|
* `byFinding` is keyed by finding ID. `eol` is the action that moves the image off an end-of-life OS.
|
|
*
|
|
* @return array{actions: list<SecurityAction>, byFinding: array<string, SecurityAction>, eol: ?SecurityAction}
|
|
*/
|
|
public static function forScan(ImageScan $scan, IgnoreMatcher $ignores): array
|
|
{
|
|
$owner = $scan->resource;
|
|
if (! $owner instanceof Model) {
|
|
return ['actions' => [], 'byFinding' => [], 'eol' => null];
|
|
}
|
|
|
|
$kind = self::imageKind($scan, $owner);
|
|
[$line, $sameTag] = in_array($kind, ['third_party', 'docker_image'], true) ? self::imageCandidates($scan) : [null, null];
|
|
$base = $kind === 'dockerfile' ? BaseImageCheck::betterBase($scan) : null;
|
|
// Null while Coolify does not know which findings come from the base image of a Dockerfile app.
|
|
$fromBase = $kind === 'dockerfile' ? self::baseIds($scan) : [];
|
|
$originKnown = $fromBase !== null;
|
|
$fromBase ??= [];
|
|
$lineFixed = self::idSet((array) ($line['fixed_ids'] ?? []));
|
|
$sameTagFixed = self::idSet((array) ($sameTag['fixed_ids'] ?? []));
|
|
$baseFixed = self::idSet((array) ($base['entry']['fixed_ids'] ?? []));
|
|
$beforeRebuild = null;
|
|
|
|
/** @var array<string, array{type: SecurityActionType, ids: list<string>, package: ?string, fixedIn: ?string, versions: list<string>}> $plans */
|
|
$plans = [];
|
|
/** @var array<string, string> $byFinding */
|
|
$byFinding = [];
|
|
foreach (ResourceScans::findings($scan) as $finding) {
|
|
if (! SecurityRules::isRelevant($finding, $ignores)) {
|
|
continue;
|
|
}
|
|
|
|
$ids = self::ids($finding);
|
|
$type = strtolower($finding->type);
|
|
$hasFix = $finding->fixState === 'fixed' && filled($finding->fixedIn);
|
|
$isOs = in_array($type, self::OS_PACKAGE_TYPES, true);
|
|
$isFromBase = self::hits($ids, $fromBase);
|
|
$isPackageFix = $hasFix && $originKnown && ! $isFromBase && in_array($type, self::LANGUAGE_PACKAGE_TYPES, true);
|
|
|
|
$action = match (true) {
|
|
self::hits($ids, $lineFixed) => SecurityActionType::UpdateImage,
|
|
self::hits($ids, $sameTagFixed) => SecurityActionType::PullLatest,
|
|
$isFromBase && self::hits($ids, $baseFixed) => SecurityActionType::ChangeBase,
|
|
$kind === 'dockerfile' && $isOs && $hasFix && $originKnown && ! $isFromBase => self::hits($ids, $beforeRebuild ??= self::idsBeforeForcedRebuild($scan, $owner))
|
|
? SecurityActionType::UpgradeInDockerfile
|
|
: SecurityActionType::Rebuild,
|
|
$kind !== 'third_party' && $isPackageFix => SecurityActionType::UpgradePackage,
|
|
$kind === 'docker_image' && $isOs && $hasFix => SecurityActionType::RebuildInCi,
|
|
default => null,
|
|
};
|
|
if ($action === null) {
|
|
continue;
|
|
}
|
|
|
|
$perPackage = in_array($action, [SecurityActionType::UpgradePackage, SecurityActionType::UpgradeInDockerfile], true);
|
|
$key = $action->value.($perPackage ? ':'.$type.'|'.$finding->package : '');
|
|
$plans[$key] ??= ['type' => $action, 'ids' => [], 'package' => $perPackage ? $finding->package : null, 'fixedIn' => null, 'versions' => []];
|
|
if (! in_array($finding->id, $plans[$key]['ids'], true)) {
|
|
$plans[$key]['ids'][] = $finding->id;
|
|
}
|
|
if ($perPackage) {
|
|
$plans[$key]['fixedIn'] = self::higher($plans[$key]['fixedIn'], (string) $finding->fixedIn);
|
|
$plans[$key]['versions'][] = $finding->version;
|
|
}
|
|
$byFinding[$finding->id] ??= $key;
|
|
}
|
|
|
|
$eolKey = null;
|
|
if (is_array(data_get($scan->summary, 'eol_distro'))) {
|
|
$eolType = match (true) {
|
|
$line !== null && empty($line['eol']) => SecurityActionType::UpdateImage,
|
|
$sameTag !== null && empty($sameTag['eol']) => SecurityActionType::PullLatest,
|
|
$base !== null && empty($base['entry']['eol']) => SecurityActionType::ChangeBase,
|
|
default => null,
|
|
};
|
|
if ($eolType !== null) {
|
|
$eolKey = $eolType->value;
|
|
$plans[$eolKey] ??= ['type' => $eolType, 'ids' => [], 'package' => null, 'fixedIn' => null, 'versions' => []];
|
|
}
|
|
}
|
|
|
|
$order = array_flip(array_map(fn (SecurityActionType $type): string => $type->value, SecurityActionType::cases()));
|
|
uasort($plans, fn (array $a, array $b): int => $order[$a['type']->value] <=> $order[$b['type']->value]);
|
|
|
|
$context = [
|
|
'image' => $scan->image_ref === null ? null : (string) $scan->image_ref,
|
|
'coolify' => self::isCoolifyResource($owner),
|
|
// A Compose app: Coolify keeps the new image as an override of its compose file and redeploys.
|
|
'compose' => $owner instanceof Application && $owner->build_pack === 'dockercompose',
|
|
'buttons' => ! self::isCoolifyResource($owner) && ! (bool) $scan->server?->isTransferredAway(),
|
|
'switchBlocked' => $line !== null || $sameTag !== null ? UpdateResourceImage::blockedReason($scan, $owner) !== null : false,
|
|
'line' => $line,
|
|
'sameTag' => $sameTag,
|
|
'base' => $base,
|
|
];
|
|
$actions = array_map(fn (array $plan): SecurityAction => self::action($plan, $context), $plans);
|
|
|
|
return [
|
|
'actions' => array_values($actions),
|
|
'byFinding' => array_map(fn (string $key): SecurityAction => $actions[$key], $byFinding),
|
|
'eol' => $eolKey === null ? null : $actions[$eolKey],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array{type: SecurityActionType, ids: list<string>, package: ?string, fixedIn: ?string, versions: list<string>} $plan
|
|
* @param array{image: ?string, coolify: bool, compose: bool, buttons: bool, switchBlocked: bool, line: ?array<string, mixed>, sameTag: ?array<string, mixed>, base: ?array{from: string, to: string, entry: array<string, mixed>}} $context
|
|
*/
|
|
private static function action(array $plan, array $context): SecurityAction
|
|
{
|
|
$type = $plan['type'];
|
|
$package = (string) $plan['package'];
|
|
$fixedIn = (string) $plan['fixedIn'];
|
|
$candidate = match ($type) {
|
|
SecurityActionType::UpdateImage => $context['line'],
|
|
SecurityActionType::PullLatest => $context['sameTag'],
|
|
default => null,
|
|
};
|
|
$ref = $candidate === null ? null : (string) $candidate['ref'];
|
|
$tag = $ref === null ? null : ImageRef::parse($ref)->tag;
|
|
$newBase = $type === SecurityActionType::ChangeBase ? (string) $context['base']['to'] : null;
|
|
|
|
$sentence = match ($type) {
|
|
SecurityActionType::UpdateImage => match (true) {
|
|
$context['coolify'] => self::COOLIFY_TEXT,
|
|
$context['compose'] && $context['switchBlocked'] => "Change the version to {$tag} in the compose file of this app, then redeploy.",
|
|
default => "Update to {$tag}.",
|
|
},
|
|
SecurityActionType::PullLatest => match (true) {
|
|
$context['coolify'] => self::COOLIFY_TEXT,
|
|
$context['compose'] && $context['switchBlocked'] => 'A newer build of this image fixes it. Redeploy this app to pull it.',
|
|
$context['compose'] => 'Pull the newer build of '.($ref ?? $context['image'] ?? 'this image').' and redeploy.',
|
|
default => 'Pull the newer build of '.($ref ?? $context['image'] ?? 'this image').' and restart.',
|
|
},
|
|
SecurityActionType::ChangeBase => "Change the base image to {$newBase} in your Dockerfile, then redeploy.",
|
|
SecurityActionType::Rebuild => 'Rebuild without cache to get the fixed system packages.',
|
|
SecurityActionType::UpgradeInDockerfile => "Upgrade {$package} to {$fixedIn} or later in your Dockerfile.",
|
|
SecurityActionType::UpgradePackage => "Upgrade {$package} to {$fixedIn} or later, then redeploy."
|
|
.(self::changesMajor($plan['versions'], $fixedIn) ? ' This is a major version change. Check for breaking changes.' : ''),
|
|
SecurityActionType::RebuildInCi => 'Rebuild the image in your CI, then redeploy.',
|
|
};
|
|
|
|
return new SecurityAction(
|
|
type: $type,
|
|
sentence: $sentence,
|
|
target: match ($type) {
|
|
SecurityActionType::UpdateImage, SecurityActionType::PullLatest => $tag,
|
|
SecurityActionType::ChangeBase => $newBase,
|
|
SecurityActionType::UpgradePackage, SecurityActionType::UpgradeInDockerfile => $package,
|
|
default => null,
|
|
},
|
|
copy: match ($type) {
|
|
SecurityActionType::ChangeBase => "FROM {$newBase}",
|
|
SecurityActionType::UpgradePackage => "{$package} {$fixedIn}",
|
|
default => null,
|
|
},
|
|
fixedIds: $plan['ids'],
|
|
button: in_array($type, self::BUTTON_TYPES, true) && $context['buttons']
|
|
&& ($type === SecurityActionType::Rebuild || ! $context['switchBlocked']),
|
|
image: $context['image'],
|
|
ref: $ref,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* third_party: Coolify pulls it. docker_image: a Docker image app the team builds itself. dockerfile, compose,
|
|
* builder (Nixpacks, Railpack, static): Coolify builds it.
|
|
*/
|
|
public static function imageKind(ImageScan $scan, Model $owner): string
|
|
{
|
|
if (! (bool) $scan->builds_image || ! $owner instanceof Application) {
|
|
return 'third_party';
|
|
}
|
|
|
|
return match ($owner->build_pack) {
|
|
'dockerimage' => 'docker_image',
|
|
'dockerfile' => 'dockerfile',
|
|
'dockercompose' => 'compose',
|
|
default => 'builder',
|
|
};
|
|
}
|
|
|
|
/**
|
|
* The better newer tag of the same line and the better newer build of the same tag. Never a major version.
|
|
*
|
|
* @return array{0: ?array<string, mixed>, 1: ?array<string, mixed>}
|
|
*/
|
|
private static function imageCandidates(ImageScan $scan): array
|
|
{
|
|
$candidates = collect($scan->updateCandidates());
|
|
|
|
return [
|
|
$candidates->first(fn (array $entry): bool => $entry['kind'] === 'line'),
|
|
$candidates->first(fn (array $entry): bool => $entry['kind'] === 'same_tag'),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* The relevant findings that the current base image has too. Empty without a base image. Null while unknown: the
|
|
* base check did not run yet, is for another base image, or did not scan the current base image (failed,
|
|
* auth_required).
|
|
*
|
|
* @return array<string, true>|null
|
|
*/
|
|
private static function baseIds(ImageScan $scan): ?array
|
|
{
|
|
if (blank($scan->base_image_ref)) {
|
|
return [];
|
|
}
|
|
|
|
$base = data_get($scan->candidates, 'base');
|
|
if (! is_array($base) || ($base['image'] ?? null) !== $scan->base_image_ref || ! is_int($base['attributable'] ?? null)) {
|
|
return null;
|
|
}
|
|
|
|
return self::idSet((array) ($base['attributable_ids'] ?? []));
|
|
}
|
|
|
|
/**
|
|
* The IDs that survived the latest rebuild without cache of the app (up to this scan): the findings of that
|
|
* rebuild's scan that the completed scan before it, not a preview, had too. A later deploy with cache keeps them,
|
|
* so `rebuild` is not offered again for a finding a rebuild did not fix. Empty before the first such rebuild.
|
|
*
|
|
* @return array<string, true>
|
|
*/
|
|
private static function idsBeforeForcedRebuild(ImageScan $scan, Model $owner): array
|
|
{
|
|
if (! $owner instanceof Application || ! $scan->exists) {
|
|
return [];
|
|
}
|
|
|
|
$scans = fn (): Builder => ImageScan::query()
|
|
->select(['id', 'findings'])
|
|
->where('resource_type', $owner->getMorphClass())
|
|
->where('resource_id', $owner->getKey())
|
|
->where('status', ImageScanStatus::Completed);
|
|
|
|
$rebuilt = $scans()
|
|
->where('id', '<=', $scan->getKey())
|
|
->whereHas('deploymentQueue', fn (Builder $query) => $query->where('pull_request_id', 0)->where('force_rebuild', true))
|
|
->orderByDesc('id')
|
|
->first();
|
|
if ($rebuilt === null) {
|
|
return [];
|
|
}
|
|
|
|
$previous = $scans()
|
|
->where('id', '<', $rebuilt->getKey())
|
|
->where(fn (Builder $query) => $query
|
|
->whereNull('deployment_queue_id')
|
|
->orWhereHas('deploymentQueue', fn (Builder $query) => $query->where('pull_request_id', 0)))
|
|
->orderByDesc('id')
|
|
->first();
|
|
if ($previous === null) {
|
|
return [];
|
|
}
|
|
|
|
$before = [];
|
|
foreach (ResourceScans::findings($previous) as $finding) {
|
|
$before = [...$before, ...self::ids($finding)];
|
|
}
|
|
$before = self::idSet($before);
|
|
|
|
$survived = [];
|
|
foreach (ResourceScans::findings($rebuilt) as $finding) {
|
|
$ids = self::ids($finding);
|
|
if (self::hits($ids, $before)) {
|
|
$survived = [...$survived, ...$ids];
|
|
}
|
|
}
|
|
|
|
return self::idSet($survived);
|
|
}
|
|
|
|
private static function isCoolifyResource(Model $owner): bool
|
|
{
|
|
if ($owner->getKey() !== null && (int) $owner->getKey() === 0) {
|
|
return true;
|
|
}
|
|
|
|
if (! $owner instanceof ServiceApplication && ! $owner instanceof ServiceDatabase) {
|
|
return false;
|
|
}
|
|
$serviceId = $owner->relationLoaded('service') ? $owner->service?->getKey() : $owner->getAttribute('service_id');
|
|
|
|
return $serviceId !== null && (int) $serviceId === 0;
|
|
}
|
|
|
|
/**
|
|
* The higher of two fixed versions (a leading `v` is ignored). A version is preferred to a value that is not one;
|
|
* of two values that are not versions, the first is kept.
|
|
*/
|
|
private static function higher(?string $current, string $candidate): string
|
|
{
|
|
if ($current === null) {
|
|
return $candidate;
|
|
}
|
|
|
|
$a = preg_replace('/^v/i', '', $current);
|
|
$b = preg_replace('/^v/i', '', $candidate);
|
|
$aIsVersion = preg_match('/^\d/', $a) === 1;
|
|
$bIsVersion = preg_match('/^\d/', $b) === 1;
|
|
if (! $aIsVersion || ! $bIsVersion) {
|
|
return $bIsVersion ? $candidate : $current;
|
|
}
|
|
|
|
return version_compare($b, $a, '>') ? $candidate : $current;
|
|
}
|
|
|
|
/**
|
|
* @param list<string> $versions
|
|
*/
|
|
private static function changesMajor(array $versions, string $fixedIn): bool
|
|
{
|
|
$target = self::major($fixedIn);
|
|
|
|
return $target !== null && collect($versions)->contains(fn (string $version): bool => ($major = self::major($version)) !== null && $major !== $target);
|
|
}
|
|
|
|
private static function major(string $version): ?int
|
|
{
|
|
return preg_match('/^v?(\d+)/i', $version, $match) === 1 ? (int) $match[1] : null;
|
|
}
|
|
|
|
/**
|
|
* @return list<string>
|
|
*/
|
|
private static function ids(Finding $finding): array
|
|
{
|
|
return array_values(array_filter(array_map('mb_strtolower', [$finding->id, ...$finding->aliases]), fn (string $id): bool => $id !== ''));
|
|
}
|
|
|
|
/**
|
|
* @param array<int, mixed> $ids
|
|
* @return array<string, true>
|
|
*/
|
|
private static function idSet(array $ids): array
|
|
{
|
|
$set = [];
|
|
foreach ($ids as $id) {
|
|
if (is_string($id) && $id !== '') {
|
|
$set[mb_strtolower($id)] = true;
|
|
}
|
|
}
|
|
|
|
return $set;
|
|
}
|
|
|
|
/**
|
|
* @param list<string> $ids
|
|
* @param array<string, true> $set
|
|
*/
|
|
private static function hits(array $ids, array $set): bool
|
|
{
|
|
foreach ($ids as $id) {
|
|
if (isset($set[$id])) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
}
|