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

987 lines
40 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace App\Actions\ImageScan;
use App\Enums\ApplicationDeploymentStatus;
use App\Enums\ImageScanErrorCode;
use App\Enums\ImageScanStatus;
use App\Events\ImageScanUpdated;
use App\Jobs\RefreshCandidatesJob;
use App\Models\Application;
use App\Models\ApplicationDeploymentQueue;
use App\Models\ImageSbom;
use App\Models\ImageScan;
use App\Models\InstanceSettings;
use App\Models\Server;
use App\Services\ImageScan\Finding;
use App\Services\ImageScan\GrypeReportParser;
use App\Services\ImageScan\IgnoreMatcher;
use App\Services\ImageScan\ImageRef;
use App\Services\ImageScan\ImageScanPolicyResolver;
use App\Services\ImageScan\Package;
use App\Services\ImageScan\ResolvedImageScanPolicy;
use App\Services\ImageScan\ResourceScans;
use App\Services\ImageScan\ScanFailed;
use App\Services\ImageScan\ScannerCommands;
use App\Services\ImageScan\ScanNotifications;
use App\Services\ImageScan\SecurityStatus;
use App\Services\ImageScan\SyftPackageParser;
use Carbon\CarbonImmutable;
use Illuminate\Contracts\Cache\Lock;
use Illuminate\Contracts\Cache\LockTimeoutException;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\UniqueConstraintViolationException;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Str;
use InvalidArgumentException;
use Lorisleiva\Actions\Concerns\AsAction;
use Symfony\Component\Yaml\Exception\ParseException;
use Symfony\Component\Yaml\Yaml;
use Throwable;
/**
* Scans one local image on a server with Syft and Grype and stores the result for a resource (spec 4.1).
*
* A scan row exists only for an image ID that was inspected. Progress lives in the cache
* (`progressCacheKey()`), the last failure too (`lastErrorCacheKey()`).
*/
class ScanImage
{
use AsAction;
/**
* The longest run of handle() under the lock: database status, update, and status again, inspect, pull, and
* inspect again, Grype on a stored SBOM that is gone, disk check, SBOM, packages, Grype, cleanup. The lock must
* not expire under a scan that still runs.
*/
public const LOCK_TTL = 2 * self::TIMEOUT_DEFAULT + self::TIMEOUT_DB_UPDATE
+ 2 * self::TIMEOUT_DEFAULT + self::TIMEOUT_PULL
+ self::TIMEOUT_GRYPE + self::TIMEOUT_DEFAULT + self::TIMEOUT_SBOM + 2 * self::TIMEOUT_GRYPE
+ self::TIMEOUT_DEFAULT;
/**
* One wait of a deploy gate for the server lock (spec 5); background scans never wait. The gate waits again while
* the lock is busy, see `DeploymentImageScan::gateLockWaitSeconds()`.
*/
public const GATE_LOCK_WAIT_SECONDS = 120;
/** The deploy time that a gate keeps for its own scan (SBOM and Grype) after it waited for the lock. */
public const GATE_SCAN_RESERVE_SECONDS = self::TIMEOUT_SBOM + self::TIMEOUT_GRYPE;
public const LAST_ERROR_TTL_DAYS = 7;
/** ScannerCommands budgets plus 60 s. */
private const TIMEOUT_DEFAULT = 120;
private const TIMEOUT_DB_UPDATE = 960;
private const TIMEOUT_SBOM = 1260;
private const TIMEOUT_GRYPE = 360;
private const TIMEOUT_PULL = 660;
private const DB_MAX_AGE_DAYS = 5;
private const DISK_RESERVE_BYTES = 1073741824;
/** Exit codes of `timeout`: stopped, or killed after the grace period. */
private const EXIT_TIMEOUT = [124, 137];
/** Exit code of a failed `docker run`, for example a missing bind mount source. */
private const EXIT_DOCKER_RUN = 125;
private string $runId = '';
private CarbonImmutable $startedAt;
private bool $pullWhenMissing = false;
/**
* Never throws for scan problems. Callers such as the deploy gate must use the returned scan: on a failure it is an
* unsaved scan with status Failed and an error code, and a completed scan row of the same image keeps its results.
* A Failed row is saved only when no row exists yet for the inspected image.
*
* @param int $lockWaitSeconds A deploy gate waits for the server lock; 0 tries once (background scans).
* @param ?string $baseImageRef The base image of the final stage of a Dockerfile build (spec 7.4), stored on the scan.
* @param bool $queueCandidateCheck Queue the check for newer versions; the deploy gate queues it itself.
* @param bool $retryWhenBusy The caller retries later when the lock is busy: a busy lock is not recorded as the last error.
* @param bool $pullWhenMissing Pull an image that is not on the server ("Scan now"). Images that Coolify builds are never pulled.
*/
public function handle(Server $server, Model $resource, string $imageRef, ?ApplicationDeploymentQueue $deployment = null, int $lockWaitSeconds = 0, ?string $baseImageRef = null, bool $queueCandidateCheck = true, bool $retryWhenBusy = false, bool $pullWhenMissing = false): ImageScan
{
$this->runId = Str::lower(Str::random(24));
$this->pullWhenMissing = $pullWhenMissing;
$this->startedAt = CarbonImmutable::now();
$policy = ImageScanPolicyResolver::for($resource, $server);
$context = ['server' => (string) $server->name];
if ($server->isTransferredAway() || ! $policy->scanEnabled) {
return $this->unsavedFailure($server, $resource, $imageRef, null, ImageScanErrorCode::ScanningOff, $context);
}
try {
ImageRef::parse($imageRef);
} catch (InvalidArgumentException) {
return $this->failBeforeRow($server, $resource, $imageRef, null, ImageScanErrorCode::Unknown, $context);
}
$lock = self::serverLock($server);
if (! self::acquireServerLock($lock, $server, $lockWaitSeconds)) {
return $retryWhenBusy
? $this->unsavedFailure($server, $resource, $imageRef, null, ImageScanErrorCode::LockBusy, $context)
: $this->failBeforeRow($server, $resource, $imageRef, null, ImageScanErrorCode::LockBusy, $context);
}
try {
$scan = $this->scan($server, $resource, $imageRef, $deployment, $policy, $context, self::validBaseImageRef($baseImageRef));
} finally {
$lock->release();
}
$this->notifyAfterScan($scan, $deployment?->id);
if ($queueCandidateCheck) {
$this->queueCandidateCheck($scan);
}
return $scan;
}
/**
* Records what the notifications covered and alerts for new problems that attackers are using or are likely to
* use (spec 15.8). The alert is a queued notification, so a deploy never waits for it.
*/
private function notifyAfterScan(ImageScan $scan, ?int $deploymentId = null): void
{
try {
ScanNotifications::afterScan($scan, $deploymentId);
} catch (Throwable $e) {
report($e);
}
}
/**
* Looks for a newer version that fixes the findings in a queued job, after the lock is released, so a deploy never waits for it (spec 7.1).
*/
private function queueCandidateCheck(ImageScan $scan): void
{
try {
if (RefreshCandidates::shouldRefresh($scan)) {
RefreshCandidatesJob::queueCheck($scan);
}
} catch (Throwable $e) {
report($e);
}
}
/**
* Rescans one local image for every resource that runs it on the server (spec 4.5): one SBOM and one Grype run,
* then the row of each resource, with its own summary. Never throws for scan problems; a failure is recorded per
* resource like in handle(), and completed rows keep their results.
*
* @param list<array{0: Model, 1: string}> $targets Each resource with the image ref of its container.
* @param bool $retryWhenBusy The caller retries later when the lock is busy: a busy lock is not recorded as the last error.
* @return list<ImageScan>
*/
public function rescan(Server $server, string $imageId, array $targets, int $lockWaitSeconds = 0, bool $retryWhenBusy = false): array
{
$this->runId = Str::lower(Str::random(24));
$this->startedAt = CarbonImmutable::now();
$context = ['server' => (string) $server->name];
if ($server->isTransferredAway()) {
return [];
}
$targets = array_values(array_filter(
array_map(fn (array $target): array => [...$target, ImageScanPolicyResolver::for($target[0], $server)], $targets),
fn (array $target): bool => $target[2]->scanEnabled,
));
if ($targets === []) {
return [];
}
if (preg_match('/\Asha256:[a-f0-9]{64}\z/', $imageId) !== 1) {
return array_map(fn (array $target) => $this->failBeforeRow($server, $target[0], $target[1], null, ImageScanErrorCode::Unknown, $context), $targets);
}
$lock = self::serverLock($server);
if (! self::acquireServerLock($lock, $server, $lockWaitSeconds)) {
return array_map(fn (array $target) => $retryWhenBusy
? $this->unsavedFailure($server, $target[0], $target[1], $imageId, ImageScanErrorCode::LockBusy, $context)
: $this->failBeforeRow($server, $target[0], $target[1], $imageId, ImageScanErrorCode::LockBusy, $context), $targets);
}
try {
$scans = $this->scanShared($server, $imageId, $targets, $context);
} finally {
$lock->release();
}
foreach ($scans as $scan) {
$this->notifyAfterScan($scan);
}
return $scans;
}
/**
* One scan or scan data change at a time per server (spec 4.1).
*/
public static function serverLock(Server $server): Lock
{
return Cache::lock("image-scan:{$server->id}", self::LOCK_TTL, Str::random(40));
}
/**
* Takes the server lock. A deploy gate ($lockWaitSeconds > 0) that has to wait marks it with
* `gateWaitingCacheKey()`, so background scans leave the lock to it. A background run ($lockWaitSeconds 0) tries
* once and does not take the lock while a gate waits; it retries later.
*/
public static function acquireServerLock(Lock $lock, Server $server, int $lockWaitSeconds): bool
{
if ($lockWaitSeconds <= 0) {
return ! self::isGateWaiting($server) && (bool) $lock->get();
}
if ($lock->get()) {
return true;
}
Cache::put(self::gateWaitingCacheKey($server), true, $lockWaitSeconds + 5);
try {
return (bool) $lock->block($lockWaitSeconds);
} catch (LockTimeoutException) {
return false;
} finally {
Cache::forget(self::gateWaitingCacheKey($server));
}
}
public static function gateWaitingCacheKey(Server $server): string
{
return "image-scan:gate-waiting:{$server->id}";
}
/**
* A deploy gate waits for the server lock.
*/
public static function isGateWaiting(Server $server): bool
{
return Cache::has(self::gateWaitingCacheKey($server));
}
/**
* The scan could not run because the server lock was busy and the caller retries later (see `$retryWhenBusy`).
*/
public static function lockWasBusy(ImageScan $scan): bool
{
return ! $scan->exists && $scan->status === ImageScanStatus::Failed && $scan->error_code === ImageScanErrorCode::LockBusy;
}
public static function progressCacheKey(Model $resource): string
{
return "image-scan:progress:{$resource->getMorphClass()}:{$resource->getKey()}";
}
public static function lastErrorCacheKey(Model $resource): string
{
return "image-scan:last-error:{$resource->getMorphClass()}:{$resource->getKey()}";
}
/**
* A base image reference that ImageRef accepts, else null.
*/
private static function validBaseImageRef(?string $ref): ?string
{
if ($ref === null || mb_strlen($ref) > 512) {
return null;
}
try {
ImageRef::parse($ref);
} catch (InvalidArgumentException) {
return null;
}
return $ref;
}
/**
* @param array{server: string} $context
*/
private function scan(Server $server, Model $resource, string $imageRef, ?ApplicationDeploymentQueue $deployment, ResolvedImageScanPolicy $policy, array $context, ?string $baseImageRef = null): ImageScan
{
$commands = new ScannerCommands($server, $this->runId);
$isBaseline = ! $this->resourceScans($resource)->where('status', ImageScanStatus::Completed)->exists();
$image = null;
try {
$this->progress($resource, 'database');
$databaseBuiltAt = $this->databaseBuiltAt($commands);
$this->progress($resource, 'inspect');
$image = $this->inspectOrPull($commands, $resource, $imageRef);
$this->progress($resource, 'sbom');
[$sbom, $grype] = $this->sbomAndReport($commands, [$resource], $imageRef, $image);
return $this->complete($server, $resource, $imageRef, $deployment, $policy, $sbom, $this->report($grype), $image, $databaseBuiltAt, $isBaseline, $baseImageRef);
} catch (ScanFailed $e) {
$errorContext = [...$context, ...$e->context];
return $image === null
? $this->failBeforeRow($server, $resource, $imageRef, null, $e->errorCode, $errorContext)
: $this->failAfterInspect($server, $resource, $imageRef, $deployment, $image, $e->errorCode, $errorContext);
} catch (Throwable $e) {
$this->rememberError($resource, ImageScanErrorCode::Unknown, $context);
throw $e;
} finally {
$this->forgetProgress($resource);
instant_remote_process($commands->cleanup(), $server, throwError: false, timeout: self::TIMEOUT_DEFAULT);
}
}
/**
* @param list<array{0: Model, 1: string, 2: ResolvedImageScanPolicy}> $targets
* @param array{server: string} $context
* @return list<ImageScan>
*/
private function scanShared(Server $server, string $imageId, array $targets, array $context): array
{
$commands = new ScannerCommands($server, $this->runId);
$resources = array_map(fn (array $target): Model => $target[0], $targets);
$isBaseline = array_map(fn (Model $resource): bool => ! $this->resourceScans($resource)->where('status', ImageScanStatus::Completed)->exists(), $resources);
$image = null;
try {
$this->progressAll($resources, 'database');
$databaseBuiltAt = $this->databaseBuiltAt($commands);
$this->progressAll($resources, 'inspect');
// By ID, not by a tag that can point to another image by now.
$image = $this->inspect($commands, $imageId);
if ($image['id'] !== $imageId) {
$image = null;
throw new ScanFailed(ImageScanErrorCode::ImageMissing);
}
$this->progressAll($resources, 'sbom');
[$sbom, $grype] = $this->sbomAndReport($commands, $resources, $imageId, $image);
$report = $this->report($grype);
return array_map(
fn (array $target, int $index) => $this->complete($server, $target[0], $target[1], null, $target[2], $sbom, $report, $image, $databaseBuiltAt, $isBaseline[$index]),
$targets,
array_keys($targets),
);
} catch (ScanFailed $e) {
$errorContext = [...$context, ...$e->context];
return array_map(fn (array $target) => $image === null
? $this->failBeforeRow($server, $target[0], $target[1], null, $e->errorCode, $errorContext)
: $this->failAfterInspect($server, $target[0], $target[1], null, $image, $e->errorCode, $errorContext), $targets);
} catch (Throwable $e) {
foreach ($resources as $resource) {
$this->rememberError($resource, ImageScanErrorCode::Unknown, $context);
}
throw $e;
} finally {
foreach ($resources as $resource) {
$this->forgetProgress($resource);
}
instant_remote_process($commands->cleanup(), $server, throwError: false, timeout: self::TIMEOUT_DEFAULT);
}
}
/**
* @return Builder<ImageScan>
*/
private function resourceScans(Model $resource): Builder
{
return ImageScan::query()
->where('resource_type', $resource->getMorphClass())
->where('resource_id', $resource->getKey());
}
private function progress(Model $resource, string $stage): void
{
Cache::put(self::progressCacheKey($resource), [
'run_id' => $this->runId,
'stage' => $stage,
'started_at' => $this->startedAt->toIso8601String(),
], self::LOCK_TTL);
$this->broadcast($resource, $this->runId, final: false);
}
/**
* @param list<Model> $resources
*/
private function progressAll(array $resources, string $stage): void
{
foreach ($resources as $resource) {
$this->progress($resource, $stage);
}
}
/**
* Updates a missing database once. Grype refuses a database older than 5 days.
*/
private function databaseBuiltAt(ScannerCommands $commands): CarbonImmutable
{
$builtAt = $this->databaseStatus($commands);
if ($builtAt === null) {
$this->updateDatabase($commands);
$builtAt = $this->databaseStatus($commands) ?? throw new ScanFailed(ImageScanErrorCode::DbMissing);
}
if ($builtAt->lt(now()->subDays(self::DB_MAX_AGE_DAYS))) {
throw new ScanFailed(ImageScanErrorCode::DbTooOld, ['n' => (int) floor($builtAt->diffInDays(now()))]);
}
return $builtAt;
}
private function updateDatabase(ScannerCommands $commands): void
{
$url = InstanceSettings::get()->image_scan_db_url;
try {
$update = $commands->dbUpdate(is_string($url) && trim($url) !== '' ? trim($url) : null);
} catch (InvalidArgumentException) {
return;
}
instant_remote_process($update, $commands->server, throwError: false, timeout: self::TIMEOUT_DB_UPDATE);
}
private function databaseStatus(ScannerCommands $commands): ?CarbonImmutable
{
$result = $this->runStep($commands->server, $commands->dbStatus(), 'db-status', self::TIMEOUT_DEFAULT);
if (in_array($result['code'], self::EXIT_TIMEOUT, true)) {
throw new ScanFailed(ImageScanErrorCode::Timeout);
}
$status = $result['code'] === 0 ? json_decode($result['payload'], true) : null;
$built = is_array($status) && ($status['valid'] ?? false) === true ? ($status['built'] ?? null) : null;
if (! is_string($built) || preg_match('/\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})\z/', $built) !== 1) {
return null;
}
return CarbonImmutable::parse($built);
}
/**
* Inspects the image. With $pullWhenMissing, an image that is not on the server and that Coolify does not build is
* pulled first.
*
* @return array{id: string, hex: string, repo_digest: ?string, platform: string, size: int}
*/
private function inspectOrPull(ScannerCommands $commands, Model $resource, string $imageRef): array
{
try {
return $this->inspect($commands, $imageRef);
} catch (ScanFailed $e) {
if ($e->errorCode !== ImageScanErrorCode::ImageMissing || ! $this->pullWhenMissing || self::buildsImage($resource, $imageRef)) {
throw $e;
}
}
$this->progress($resource, 'pull');
$result = $this->runStep($commands->server, $commands->pull($imageRef), 'pull', self::TIMEOUT_PULL);
if ($result['code'] !== 0) {
throw new ScanFailed(in_array($result['code'], self::EXIT_TIMEOUT, true) ? ImageScanErrorCode::Timeout : ImageScanErrorCode::PullFailed);
}
$this->progress($resource, 'inspect');
return $this->inspect($commands, $imageRef);
}
/**
* @return array{id: string, hex: string, repo_digest: ?string, platform: string, size: int}
*/
private function inspect(ScannerCommands $commands, string $imageRef): array
{
$result = $this->runStep($commands->server, $commands->inspect($imageRef), 'inspect', self::TIMEOUT_DEFAULT);
if ($result['code'] !== 0) {
throw new ScanFailed(ImageScanErrorCode::ImageMissing);
}
$parts = explode('|', trim($result['payload']));
if (count($parts) !== 4
|| preg_match('/\Asha256:([a-f0-9]{64})\z/', $parts[0], $id) !== 1
|| preg_match('#\A[a-z0-9]+/[a-z0-9_]+\z#', $parts[2]) !== 1
|| preg_match('/\A\d{1,18}\z/', $parts[3]) !== 1) {
throw new ScanFailed(ImageScanErrorCode::Unknown);
}
$repoDigest = Str::afterLast($parts[1], '@');
return [
'id' => $parts[0],
'hex' => $id[1],
'repo_digest' => str_contains($parts[1], '@') && ImageRef::isValidDigest($repoDigest) ? $repoDigest : null,
'platform' => $parts[2],
'size' => (int) $parts[3],
];
}
/**
* `docker save` and the SBOM need image size × 2 + 1 GiB on the Docker root and on the scan directory.
* Candidate scans from a registry use the same rule (Syft writes the layers to the scratch directory).
*
* @throws ScanFailed
*/
public static function checkDiskSpace(ScannerCommands $commands, int $imageSize): void
{
$output = instant_remote_process($commands->diskFree(), $commands->server, throwError: false, timeout: self::TIMEOUT_DEFAULT);
if (preg_match('/(\d+) +(\d+)\s*\z/', (string) $output, $free) !== 1) {
throw new ScanFailed(ImageScanErrorCode::Unknown);
}
$needed = $imageSize * 2 + self::DISK_RESERVE_BYTES;
if ((int) $free[1] < $needed || (int) $free[2] < $needed) {
throw new ScanFailed(ImageScanErrorCode::DiskSpace, ['n' => (int) ceil($needed / self::DISK_RESERVE_BYTES)]);
}
}
/**
* Reuses the SBOM of the image on this server. When its file is gone, Grype cannot mount it: the file is
* created once more and the row is updated in place (scans of other resources keep their link).
*
* @param list<Model> $resources
* @param array{id: string, hex: string, repo_digest: ?string, platform: string, size: int} $image
* @return array{0: ImageSbom, 1: array{code: int, size: ?int, payload: string, exit_codes: array<string, int>, oom: list<string>}}
*/
private function sbomAndReport(ScannerCommands $commands, array $resources, string $imageRef, array $image): array
{
$sbom = ImageSbom::query()->where('server_id', $commands->server->id)->where('image_id', $image['id'])->first();
if ($sbom !== null) {
$this->progressAll($resources, 'vulnerabilities');
$grype = $this->runStep($commands->server, $commands->grype($image['hex']), 'grype', self::TIMEOUT_GRYPE);
if ($grype['code'] !== self::EXIT_DOCKER_RUN) {
$this->assertSucceeded($grype, 'grype');
$sbom->forceFill(['last_used_at' => now()])->save();
return [$sbom, $grype];
}
$this->progressAll($resources, 'sbom');
}
$sbom = $this->createSbom($commands, $imageRef, $image, $sbom);
$this->progressAll($resources, 'vulnerabilities');
$grype = $this->runStep($commands->server, $commands->grype($image['hex']), 'grype', self::TIMEOUT_GRYPE);
$this->assertSucceeded($grype, 'grype');
return [$sbom, $grype];
}
/**
* @param array{id: string, hex: string, repo_digest: ?string, platform: string, size: int} $image
*/
private function createSbom(ScannerCommands $commands, string $imageRef, array $image, ?ImageSbom $sbom): ImageSbom
{
self::checkDiskSpace($commands, $image['size']);
$result = $this->runStep($commands->server, $commands->sbomFromLocal($imageRef, $image['hex']), 'save', self::TIMEOUT_SBOM);
// `docker save` has no memory limit: its 137 is always a timeout kill.
$this->assertSucceeded($result, 'save');
$this->assertSucceeded($result, 'sbom');
$packages = $this->runStep($commands->server, $commands->packages($image['hex']), 'packages', self::TIMEOUT_GRYPE);
$this->assertSucceeded($packages, 'packages');
[$payload, $cut] = $this->readPayload($packages);
$parsed = (new SyftPackageParser)->parse($payload);
$values = [
'platform' => $image['platform'],
'packages' => array_map(fn (Package $package) => $package->toArray(), $parsed['packages']),
'packages_truncated' => $parsed['truncated'] || $cut,
'last_used_at' => now(),
];
if ($sbom !== null) {
$sbom->forceFill($values)->save();
return $sbom;
}
return ImageSbom::query()->create([
'server_id' => $commands->server->id,
'image_id' => $image['id'],
...$values,
]);
}
/**
* Findings of the Grype output, sorted by risk.
*
* @param array{code: int, size: ?int, payload: string, exit_codes: array<string, int>, oom: list<string>} $grype
* @return array{findings: list<Finding>, eol: ?array{name: string, version: string}, truncated: bool}
*/
private function report(array $grype): array
{
[$payload, $cut] = $this->readPayload($grype);
$report = (new GrypeReportParser)->parse($payload);
$findings = $report['findings'];
usort($findings, fn (Finding $a, Finding $b) => ($b->risk ?? 0.0) <=> ($a->risk ?? 0.0));
return ['findings' => $findings, 'eol' => $report['eol'], 'truncated' => $report['truncated'] || $cut];
}
/**
* @param array{findings: list<Finding>, eol: ?array{name: string, version: string}, truncated: bool} $report
* @param array{id: string, hex: string, repo_digest: ?string, platform: string, size: int} $image
*/
private function complete(Server $server, Model $resource, string $imageRef, ?ApplicationDeploymentQueue $deployment, ResolvedImageScanPolicy $policy, ImageSbom $sbom, array $report, array $image, CarbonImmutable $databaseBuiltAt, bool $isBaseline, ?string $baseImageRef = null): ImageScan
{
$findings = $report['findings'];
$buildsImage = self::buildsImage($resource, $imageRef);
$existing = $this->resourceScans($resource)->where('image_id', $image['id'])->with('deploymentQueue:id,pull_request_id')->first();
$summary = ResourceScans::summaryFor($findings, IgnoreMatcher::forResource($resource), $report['eol'], $report['truncated']);
$scan = $this->upsert($resource, $image['id'], [
'server_id' => $server->id,
'image_sbom_id' => $sbom->id,
'repo_digest' => $image['repo_digest'],
'image_ref' => mb_substr($imageRef, 0, 512),
...$this->deploymentLink($resource, $existing, $deployment),
...($baseImageRef !== null ? ['base_image_ref' => $baseImageRef] : []),
'status' => ImageScanStatus::Completed,
'error_code' => null,
'error_context' => null,
'summary' => $summary,
'findings' => array_map(fn (Finding $finding) => $finding->toArray(), $findings),
'builds_image' => $buildsImage,
'is_baseline' => $isBaseline,
'grype_db_built_at' => $databaseBuiltAt,
'scanned_at' => now(),
]);
$scan->setRelation('resource', $resource);
SecurityStatus::store($scan);
$scan->save();
Cache::forget(self::lastErrorCacheKey($resource));
$this->forgetProgress($resource);
$this->broadcast($resource, (string) $scan->uuid);
return $scan;
}
/**
* The deployment a scan row belongs to, which decides the current scans of an application (all images of one
* deployment). A scan without a deployment (rescan, Scan now) joins the latest finished deployment of the
* application, unless the row belongs to a newer deployment. A preview never takes over the row of the application.
*
* @return array{deployment_queue_id?: int}
*/
private function deploymentLink(Model $resource, ?ImageScan $existing, ?ApplicationDeploymentQueue $deployment): array
{
$linked = $existing?->deployment_queue_id;
$linkedToPreview = $linked !== null && (int) ($existing->deploymentQueue?->pull_request_id ?? 0) !== 0;
if ($deployment !== null) {
return (int) $deployment->pull_request_id !== 0 && $existing !== null && ! $linkedToPreview
? []
: ['deployment_queue_id' => (int) $deployment->id];
}
if (! $resource instanceof Application || $linkedToPreview) {
return [];
}
$latest = ApplicationDeploymentQueue::query()
->where('application_id', (string) $resource->getKey())
->where('pull_request_id', 0)
->where('status', ApplicationDeploymentStatus::FINISHED->value)
->max('id');
return $latest === null || ($linked !== null && (int) $linked >= (int) $latest)
? []
: ['deployment_queue_id' => (int) $latest];
}
/**
* Creates or updates the row of (resource, image ID). A row that another run inserts at the same time is updated.
*
* @param array<string, mixed> $values
*/
private function upsert(Model $resource, string $imageId, array $values): ImageScan
{
$scan = $this->firstOrCreateScan($resource, $imageId, $values) ?? throw new ScanFailed(ImageScanErrorCode::Unknown);
if (! $scan->wasRecentlyCreated) {
$scan->forceFill($values)->save();
}
return $scan;
}
/**
* `firstOrCreate` catches a unique violation and reads the other row. Its savepoint can roll back that row inside
* an outer transaction, so one more attempt is made before giving up.
*
* @param array<string, mixed> $values
*/
private function firstOrCreateScan(Model $resource, string $imageId, array $values): ?ImageScan
{
$keys = [
'resource_type' => $resource->getMorphClass(),
'resource_id' => $resource->getKey(),
'image_id' => $imageId,
];
for ($attempt = 0; $attempt < 2; $attempt++) {
try {
return ImageScan::query()->firstOrCreate($keys, $values);
} catch (UniqueConstraintViolationException) {
continue;
}
}
return null;
}
/**
* Keeps an existing row of the image (and its results). Saves a Failed row only for an image without one.
*
* @param array{id: string, hex: string, repo_digest: ?string, platform: string, size: int} $image
* @param array<string, int|string> $context
*/
private function failAfterInspect(Server $server, Model $resource, string $imageRef, ?ApplicationDeploymentQueue $deployment, array $image, ImageScanErrorCode $code, array $context): ImageScan
{
$values = [
'server_id' => $server->id,
'repo_digest' => $image['repo_digest'],
'image_ref' => mb_substr($imageRef, 0, 512),
'deployment_queue_id' => $deployment?->id,
'status' => ImageScanStatus::Failed,
'error_code' => $code,
'error_context' => $context,
'builds_image' => self::buildsImage($resource, $imageRef),
];
$scan = $this->firstOrCreateScan($resource, $image['id'], $values);
if ($scan === null || ! $scan->wasRecentlyCreated) {
return $this->failBeforeRow($server, $resource, $imageRef, $image['id'], $code, $context, $image['repo_digest']);
}
$scan->setRelation('resource', $resource);
$this->rememberError($resource, $code, $context);
$this->forgetProgress($resource);
$this->broadcast($resource, (string) $scan->uuid);
return $scan;
}
/**
* @param array<string, int|string> $context
*/
private function failBeforeRow(Server $server, Model $resource, string $imageRef, ?string $imageId, ImageScanErrorCode $code, array $context, ?string $repoDigest = null): ImageScan
{
$scan = $this->unsavedFailure($server, $resource, $imageRef, $imageId, $code, $context, $repoDigest);
// Another run of the resource is scanning: its progress stays, and a busy lock is not its error.
$otherRun = data_get(Cache::get(self::progressCacheKey($resource)), 'run_id');
$otherRunActive = is_string($otherRun) && $otherRun !== $this->runId;
if (! ($otherRunActive && $code === ImageScanErrorCode::LockBusy)) {
$this->rememberError($resource, $code, $context);
}
$this->forgetProgress($resource);
$this->broadcast($resource, $this->runId);
return $scan;
}
/**
* Removes the progress of the resource only when it is the progress of this run.
*/
private function forgetProgress(Model $resource): void
{
if (data_get(Cache::get(self::progressCacheKey($resource)), 'run_id') === $this->runId) {
Cache::forget(self::progressCacheKey($resource));
}
}
/**
* @param array<string, int|string> $context
*/
private function unsavedFailure(Server $server, Model $resource, string $imageRef, ?string $imageId, ImageScanErrorCode $code, array $context, ?string $repoDigest = null): ImageScan
{
$scan = (new ImageScan)->forceFill([
'server_id' => $server->id,
'resource_type' => $resource->getMorphClass(),
'resource_id' => $resource->getKey(),
'image_id' => $imageId,
'repo_digest' => $repoDigest,
'image_ref' => mb_substr($imageRef, 0, 512),
'status' => ImageScanStatus::Failed,
'error_code' => $code,
'error_context' => $context,
]);
$scan->setRelation('resource', $resource);
return $scan;
}
/**
* @param array<string, int|string> $context
*/
private function rememberError(Model $resource, ImageScanErrorCode $code, array $context): void
{
Cache::put(self::lastErrorCacheKey($resource), [
'code' => $code->value,
'context' => $context,
'at' => now()->toIso8601String(),
], now()->addDays(self::LAST_ERROR_TTL_DAYS));
}
/**
* Whether the team builds the image: Coolify builds the images of Dockerfile, Nixpacks, Railpack, and static apps,
* and the Compose images of a service with `build` (see composeBuildsImage). Docker images of the team are built by
* the team unless it says otherwise; Docker Hub official images are not. Other images are pulled.
*/
public static function buildsImage(Model $resource, string $imageRef): bool
{
if (! $resource instanceof Application) {
return false;
}
if ($resource->build_pack === 'dockercompose') {
return self::composeBuildsImage($resource, $imageRef);
}
if ($resource->build_pack !== 'dockerimage') {
return true;
}
$buildsByTeam = $resource->settings?->image_builds_by_team;
if ($buildsByTeam !== null) {
return (bool) $buildsByTeam;
}
try {
return ! ImageRef::parse($imageRef)->isDockerOfficial();
} catch (InvalidArgumentException) {
return true;
}
}
/**
* A Compose image is built by Coolify when its name is `{app uuid}_{service}` (the name the parser gives a service
* with `build` and no `image`), or when a service with `build` in the app's Compose file names the same image
* (tag ignored). Every other image of the file is pulled.
*/
private static function composeBuildsImage(Application $application, string $imageRef): bool
{
$name = self::imageName($imageRef);
if (filled($application->uuid) && str_starts_with($name, strtolower($application->uuid).'_')) {
return true;
}
try {
$compose = Yaml::parse((string) ($application->docker_compose ?: $application->docker_compose_raw));
} catch (ParseException) {
return false;
}
foreach ((array) data_get($compose, 'services', []) as $service) {
$image = data_get($service, 'image');
if (data_get($service, 'build') !== null && is_string($image) && $image !== '' && self::imageName($image) === $name) {
return true;
}
}
return false;
}
/**
* The image reference without tag and digest, lowercase.
*/
public static function imageName(string $imageRef): string
{
$name = strtolower(trim(explode('@', $imageRef, 2)[0]));
return (string) preg_replace('#:[^/]*$#', '', $name);
}
/**
* Runs one command batch and reads the exit code of $step. No output or no marker means the batch did not run.
*
* @param list<string> $commands
* @return array{code: int, size: ?int, payload: string, exit_codes: array<string, int>, oom: list<string>}
*/
private function runStep(Server $server, array $commands, string $step, int $timeout): array
{
$output = instant_remote_process($commands, $server, throwError: false, timeout: $timeout);
$parsed = ScannerCommands::parseOutput($output);
if ($output === null || trim($output) === '' || ! array_key_exists($step, $parsed['exit_codes'])) {
throw new ScanFailed(ImageScanErrorCode::Unknown);
}
return [
'code' => $parsed['exit_codes'][$step],
'size' => $parsed['sizes'][$step] ?? null,
'payload' => $parsed['payload'],
'exit_codes' => $parsed['exit_codes'],
'oom' => $parsed['oom'],
];
}
/**
* Throws for a failed $step of the batch. Only Docker's own `OOMKilled` (the `coolify-scan-oom` line of the
* memory-limited Syft and Grype containers) means out of memory; a 137 without it was killed by `timeout`.
*
* @param array{code: int, size: ?int, payload: string, exit_codes: array<string, int>, oom: list<string>} $result
*/
private function assertSucceeded(array $result, string $step): void
{
$code = $result['exit_codes'][$step] ?? null;
if ($code !== 0 && $step !== 'save' && in_array($step, $result['oom'], true)) {
throw new ScanFailed(ImageScanErrorCode::OutOfMemory);
}
if (in_array($code, self::EXIT_TIMEOUT, true)) {
throw new ScanFailed(ImageScanErrorCode::Timeout);
}
if ($code !== 0) {
throw new ScanFailed(ImageScanErrorCode::Unknown);
}
}
/**
* Output over the limit was cut by `head -c`: keep only the complete lines that were read.
*
* @param array{code: int, size: ?int, payload: string, exit_codes: array<string, int>, oom: list<string>} $result
* @return array{0: string, 1: bool}
*/
private function readPayload(array $result): array
{
$payload = $result['payload'];
$cut = ($result['size'] ?? 0) > ScannerCommands::OUTPUT_LIMIT || strlen($payload) > ScannerCommands::OUTPUT_LIMIT;
if ($cut) {
$payload = substr($payload, 0, ScannerCommands::OUTPUT_LIMIT);
$lastNewline = strrpos($payload, "\n");
$payload = $lastNewline === false ? '' : substr($payload, 0, $lastNewline);
}
return [$payload, $cut];
}
/**
* @param bool $final False for a progress update while the scan runs.
*/
private function broadcast(Model $resource, string $scanUuid, bool $final = true): void
{
$teamId = method_exists($resource, 'team') ? $resource->team()?->id : null;
ImageScanUpdated::dispatch($teamId, (string) $resource->getAttribute('uuid'), $scanUuid, $final);
}
}