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>
931 lines
37 KiB
PHP
931 lines
37 KiB
PHP
<?php
|
|
|
|
namespace App\Services\ImageScan;
|
|
|
|
use App\Actions\ImageScan\RefreshCandidates;
|
|
use App\Actions\ImageScan\ScanImage;
|
|
use App\Enums\ApplicationDeploymentStatus;
|
|
use App\Enums\ImageScanErrorCode;
|
|
use App\Enums\ImageScanGateMode;
|
|
use App\Enums\ImageScanStatus;
|
|
use App\Exceptions\DeploymentException;
|
|
use App\Jobs\RefreshCandidatesJob;
|
|
use App\Jobs\ScanResourceImagesJob;
|
|
use App\Models\Application;
|
|
use App\Models\ApplicationDeploymentQueue;
|
|
use App\Models\ImageScan;
|
|
use App\Models\User;
|
|
use Closure;
|
|
use Illuminate\Support\Facades\Log;
|
|
use InvalidArgumentException;
|
|
use Symfony\Component\Yaml\Yaml;
|
|
use Throwable;
|
|
|
|
/**
|
|
* The image scan steps of one application deployment: the deploy gate (spec 15.8), the start pins of security
|
|
* updates, and the scan after the start. `ApplicationDeploymentJob` creates one per job and calls it at fixed points.
|
|
*
|
|
* The job changes its servers and Compose file while it deploys, so this class reads them from the job when a step
|
|
* needs them, and writes the Compose file back through the job.
|
|
*/
|
|
final class DeploymentImageScan
|
|
{
|
|
private ?ResolvedImageScanPolicy $policy = null;
|
|
|
|
private ?int $policyServerId = null;
|
|
|
|
private ?bool $rollbackRanBefore = null;
|
|
|
|
/** @var array<string, mixed>|null The worst gate result of this deployment (blocked, then error, then pass). */
|
|
private ?array $gateResult = null;
|
|
|
|
/** The gate decided for the whole deployment (bypass, rollback, or a scan that cannot run). */
|
|
private bool $gateSettled = false;
|
|
|
|
/** @var array<string, true> Images whose scan ran or was reused before the start, so they need no scan after it. */
|
|
private array $scannedImages = [];
|
|
|
|
/** Repo digest reference that replaces the image tag in the compose file. */
|
|
private ?string $pinnedImage = null;
|
|
|
|
/** Whether the scanned build of a "Pull latest" security update has the image tag now, so no pull may replace it. */
|
|
private bool $startPinned = false;
|
|
|
|
/** The base image of the final stage of a Dockerfile build (spec 7.4). */
|
|
private ?string $baseImageRef = null;
|
|
|
|
/** @var array<string, true> Compose images that the gate checked on the server. */
|
|
private array $checkedImages = [];
|
|
|
|
/** @var array<int, true> IDs of stored scans that the gate reused; they join this deployment when it finishes. */
|
|
private array $reusedScans = [];
|
|
|
|
/** @var list<string>|null Image IDs that the gate blocked in earlier deployments, read once per job. */
|
|
private ?array $blockedImageIds = null;
|
|
|
|
/**
|
|
* @param Closure(string): mixed $read Reads a job property by name, when the step runs.
|
|
* @param Closure(string, string): mixed $write Sets `docker_compose` or `generated_docker_compose` on the job.
|
|
* @param Closure(array<int|string, mixed> ...): mixed $run The job's `execute_remote_command()`.
|
|
* @param Closure(): void $checkForCancellation
|
|
*/
|
|
public function __construct(
|
|
private readonly Application $application,
|
|
private readonly ApplicationDeploymentQueue $deployment,
|
|
private readonly Closure $read,
|
|
private readonly Closure $write,
|
|
private readonly Closure $run,
|
|
private readonly Closure $checkForCancellation,
|
|
) {}
|
|
|
|
/** The repo digest that the start uses instead of the tag, set by the gate. */
|
|
public function pinnedImage(): ?string
|
|
{
|
|
return $this->pinnedImage;
|
|
}
|
|
|
|
/** The scanned build of a "Pull latest" security update has the tag, so the start must not pull it. */
|
|
public function startPinned(): bool
|
|
{
|
|
return $this->startPinned;
|
|
}
|
|
|
|
public function baseImageRef(): ?string
|
|
{
|
|
return $this->baseImageRef;
|
|
}
|
|
|
|
/**
|
|
* The gate scanned the new image while the deployment was in progress, so its stored security status compared it
|
|
* with the image of the previous deployment. Now that this deployment finished, the status is stored again.
|
|
*/
|
|
public function refreshSecurity(): void
|
|
{
|
|
if ($this->job('pull_request_id') !== 0 || ! ImageScanPolicyResolver::isScanOn($this->application)) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
ResourceScans::refreshSummaries(ResourceScans::currentFor($this->application));
|
|
} catch (Throwable $e) {
|
|
report($e);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The compose file from Git decides which image overrides of security updates still apply. A preview deploys
|
|
* the compose file of its branch, so it keeps them.
|
|
*/
|
|
public function forgetStaleComposeImageOverrides(): void
|
|
{
|
|
if ($this->job('pull_request_id') !== 0) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
$this->application->forgetStaleComposeImageOverrides();
|
|
} catch (Throwable $e) {
|
|
report($e);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The deploy gate (spec 15.8): stops the start of an image with security problems, by the "Block deploys" mode.
|
|
* Does nothing, and runs no remote command, unless the gate applies to this application.
|
|
*/
|
|
public function checkGate(string $imageRef): void
|
|
{
|
|
if ($this->gateSettled || ! $this->gateApplies()) {
|
|
return;
|
|
}
|
|
|
|
($this->checkForCancellation)();
|
|
|
|
if ($this->settleGateForDeployment()) {
|
|
$this->pinBypassedImage();
|
|
|
|
return;
|
|
}
|
|
|
|
if ($this->application->build_pack === 'dockerimage' && ! $this->startPinned) {
|
|
$this->deployment->addLogEntry("Pulling image ({$imageRef}) to scan it before the start.");
|
|
($this->run)([
|
|
serverDockerConfigPrefix($this->job('server')).'docker pull '.escapeshellarg($imageRef),
|
|
'hidden' => true,
|
|
]);
|
|
}
|
|
|
|
$image = $this->inspectImageForScan($imageRef);
|
|
|
|
if ($image !== null && $this->job('rollback') && $this->imageRanBefore($image['id'])) {
|
|
$this->deployment->addLogEntry('Rollback to an image that ran before. The image scan does not block it.');
|
|
|
|
return;
|
|
}
|
|
|
|
if ($image !== null) {
|
|
$this->checkedImages[$imageRef] = true;
|
|
$this->pinScannedImage($image['repo_digest']);
|
|
}
|
|
|
|
$error = $this->errorBeforeScan();
|
|
if ($error !== null) {
|
|
$this->gateSettled = true;
|
|
$this->applyGate(null, ...$error);
|
|
|
|
return;
|
|
}
|
|
|
|
// Read before the scan: a scan of the same image takes over the row of the running image.
|
|
$running = $this->runningImageScan($imageRef);
|
|
$baselineMissing = false;
|
|
if ($running === null && $this->hasEarlierFinishedDeployment()) {
|
|
$running = $this->latestEarlierImageScan($imageRef, $image['id'] ?? null);
|
|
// An image the gate blocked before is never its own baseline.
|
|
$baselineMissing = $running === null && ! ($image !== null && in_array($image['id'], $this->blockedImageIds(), true));
|
|
}
|
|
$reused = $image !== null ? $this->completedImageScan($image['id']) : null;
|
|
try {
|
|
$scan = $reused ?? $this->scanWhenLockFree($imageRef);
|
|
} catch (DeploymentException $e) {
|
|
// A cancel while the gate waits for the lock.
|
|
throw $e;
|
|
} catch (Throwable $e) {
|
|
// An unexpected error is a scan that could not run: "If the scan cannot run" decides, not the error.
|
|
report($e);
|
|
$this->applyGate(null, ImageScanErrorCode::Unknown, imageRef: $imageRef, repoDigest: $image['repo_digest'] ?? null, imageId: $image['id'] ?? null);
|
|
|
|
return;
|
|
}
|
|
$this->queueCandidates($scan);
|
|
|
|
$this->applyGate($scan, imageRef: $imageRef, repoDigest: $image['repo_digest'] ?? null, running: $running, baselineMissing: $baselineMissing, imageId: $image['id'] ?? $scan->image_id);
|
|
|
|
if ($reused !== null) {
|
|
$this->reusedScans[(int) $reused->id] = true;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Scans the image for the gate. A busy server lock is not a scan error: another scan holds it, and background
|
|
* scans do not start while the gate waits. The gate waits in steps, checks for a cancel between them, and stops
|
|
* at `gateLockWaitSeconds()`; only then the busy lock follows "If the scan cannot run".
|
|
*/
|
|
private function scanWhenLockFree(string $imageRef): ImageScan
|
|
{
|
|
$server = $this->job('server');
|
|
$deadline = now()->addSeconds($this->gateLockWaitSeconds());
|
|
$waitLogged = false;
|
|
|
|
while (true) {
|
|
$wait = (int) max(1, min(ScanImage::GATE_LOCK_WAIT_SECONDS, ceil(now()->diffInSeconds($deadline, false))));
|
|
$lastTry = now()->addSeconds($wait)->gte($deadline);
|
|
$scan = ScanImage::run($server, $this->application, $imageRef, $this->deployment, lockWaitSeconds: $wait, baseImageRef: $this->baseImageRef, queueCandidateCheck: false, retryWhenBusy: ! $lastTry);
|
|
|
|
if ($lastTry || ! ScanImage::lockWasBusy($scan)) {
|
|
return $scan;
|
|
}
|
|
|
|
if (! $waitLogged) {
|
|
$waitLogged = true;
|
|
$minutes = (int) ceil(now()->diffInSeconds($deadline, false) / 60);
|
|
$this->deployment->addLogEntry("Another image scan is running on {$server->name}. Waiting up to {$minutes} more minutes for it to finish.");
|
|
}
|
|
($this->checkForCancellation)();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* How long the gate waits in total for the server lock: until the lock expires at the latest, and only while the
|
|
* deployment keeps time for its own scan before its timeout. At least one lock wait.
|
|
*/
|
|
private function gateLockWaitSeconds(): int
|
|
{
|
|
$startedAt = $this->job('started_at');
|
|
$elapsed = $startedAt === null ? 0 : max(0, now()->getTimestamp() - (int) $startedAt);
|
|
$left = (int) $this->job('timeout') - $elapsed - ScanImage::GATE_SCAN_RESERVE_SECONDS;
|
|
|
|
return max(ScanImage::GATE_LOCK_WAIT_SECONDS, min(ScanImage::LOCK_TTL, $left));
|
|
}
|
|
|
|
/**
|
|
* Gates every image of a Compose deployment before the running containers stop, and keeps Compose from pulling
|
|
* a different image for a checked service.
|
|
*/
|
|
public function checkComposeGate(): void
|
|
{
|
|
if (! $this->gateApplies()) {
|
|
return;
|
|
}
|
|
|
|
if ($this->usesCustomComposeCommand()) {
|
|
// The command can build or pull other images than the Compose file names, so no image is checked.
|
|
if (! $this->gateSettled && ! $this->settleGateForDeployment()) {
|
|
$this->gateSettled = true;
|
|
$this->applyGate(null, ...$this->errorBeforeScan());
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
$services = collect(data_get(Yaml::parse((string) $this->job('generated_docker_compose')), 'services', []));
|
|
$images = $services->map(fn ($service) => data_get($service, 'image'))->filter(fn ($image) => is_string($image) && $image !== '')->unique();
|
|
|
|
foreach ($images as $image) {
|
|
$this->checkGate($image);
|
|
}
|
|
|
|
if (! $this->gateSettled && $services->contains(fn ($service) => ! is_string(data_get($service, 'image')))) {
|
|
// A raw Compose service without an image gets a name that Coolify does not know.
|
|
$this->gateSettled = true;
|
|
$this->applyGate(null, ImageScanErrorCode::Unknown);
|
|
}
|
|
|
|
if ($this->checkedImages === [] || $this->application->settings->is_raw_compose_deployment_enabled) {
|
|
return;
|
|
}
|
|
|
|
$compose = Yaml::parse((string) $this->job('generated_docker_compose'));
|
|
foreach ($compose['services'] ?? [] as $name => $service) {
|
|
if (isset($this->checkedImages[(string) data_get($service, 'image')])) {
|
|
$compose['services'][$name]['pull_policy'] = 'never';
|
|
}
|
|
}
|
|
|
|
$yaml = Yaml::dump($compose, 10);
|
|
($this->write)('generated_docker_compose', $yaml);
|
|
$this->writeComposeFileToWorkdir($yaml);
|
|
}
|
|
|
|
/**
|
|
* The gate runs only when the instance allows scanning, the application scan is on, and its rules block deploys.
|
|
*/
|
|
public function gateApplies(): bool
|
|
{
|
|
// Scanning off on the server still gates: the scan cannot run, and "If the scan cannot run" decides.
|
|
return ImageScanPolicyResolver::isAllowedFor($this->application)
|
|
&& $this->policy()->gateMode !== ImageScanGateMode::Off->value;
|
|
}
|
|
|
|
/**
|
|
* The stored scans that the gate reused belong to this deployment once it finished, so the current scans of the
|
|
* application (all images of a Compose deployment) include them. Until then they stay with the deployment that
|
|
* ran them: a deployment that is running or failed never takes them over. Preview deployments keep the link of
|
|
* the application. Called right after the deployment is marked finished (`post_deployment()`, `just_restart()`).
|
|
*/
|
|
public function linkReusedScans(): void
|
|
{
|
|
if ($this->job('pull_request_id') !== 0 || $this->reusedScans === []) {
|
|
return;
|
|
}
|
|
|
|
// completeDeployment() does nothing when the deployment was cancelled or failed meanwhile.
|
|
if (ApplicationDeploymentQueue::query()->whereKey($this->deployment->id)->value('status') !== ApplicationDeploymentStatus::FINISHED->value) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
$this->application->imageScans()
|
|
->whereKey(array_keys($this->reusedScans))
|
|
->where(fn ($query) => $query
|
|
->whereNull('deployment_queue_id')
|
|
->orWhere('deployment_queue_id', '!=', $this->deployment->id))
|
|
->update(['deployment_queue_id' => $this->deployment->id]);
|
|
$this->reusedScans = [];
|
|
} catch (Throwable $e) {
|
|
report($e);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* A "Pull latest" security update of a Docker image app: pulls the build that Coolify scanned and gives it the
|
|
* configured tag, so the gate and the start use it and do not pull the tag again.
|
|
*/
|
|
public function applyStartPin(): void
|
|
{
|
|
if ($this->application->build_pack !== 'dockerimage' || $this->job('pull_request_id') !== 0 || $this->job('rollback')) {
|
|
return;
|
|
}
|
|
|
|
$imageName = $this->job('production_image_name');
|
|
$server = $this->job('server');
|
|
$pins = ImageStartPin::for(ImageStartPin::take($this->application, $server->id), [$imageName]);
|
|
if ($pins === []) {
|
|
return;
|
|
}
|
|
|
|
$this->deployment->addLogEntry("Pulling the scanned build of {$imageName}.");
|
|
($this->run)(...array_map(fn (string $command): array => [$command, 'hidden' => true],
|
|
ImageStartPin::commands($pins, serverDockerConfigPrefix($server))));
|
|
$this->startPinned = true;
|
|
}
|
|
|
|
/**
|
|
* A "Pull latest" security update of a Compose app: after the pull of the registry images, gives the scanned builds
|
|
* their tags again. `up` uses local images, so the containers run the scanned builds.
|
|
*/
|
|
public function applyComposeStartPins(): void
|
|
{
|
|
if ($this->job('pull_request_id') !== 0 || $this->job('docker_compose_custom_build_command')) {
|
|
return;
|
|
}
|
|
|
|
$server = $this->job('server');
|
|
$pins = ImageStartPin::take($this->application, $server->id);
|
|
if ($pins === []) {
|
|
return;
|
|
}
|
|
|
|
$this->deployment->addLogEntry('Pulling the scanned builds of the security update.');
|
|
($this->run)(...array_map(fn (string $command): array => [$command, 'hidden' => true],
|
|
ImageStartPin::commands($pins, serverDockerConfigPrefix($server))));
|
|
}
|
|
|
|
/**
|
|
* Remembers the base image of the final stage of a Dockerfile build for the image scan (spec 7.4). Runs no remote
|
|
* command and never fails the deployment.
|
|
*/
|
|
public function rememberDockerfileBaseImage(): void
|
|
{
|
|
$this->baseImageRef = null;
|
|
|
|
try {
|
|
if ($this->application->build_pack !== 'dockerfile'
|
|
|| ! ImageScanPolicyResolver::isAllowedFor($this->application)
|
|
|| (ImageScanPolicyResolver::offState($this->application, $this->job('server')) !== null
|
|
&& ImageScanPolicyResolver::offState($this->application, $this->job('mainServer')) !== null)) {
|
|
return;
|
|
}
|
|
|
|
$dockerfile = filled($this->application->dockerfile) ? (string) $this->application->dockerfile : (string) $this->job('saved_outputs')->get('dockerfile_from_repo');
|
|
$this->baseImageRef = DockerfileBaseImage::finalStage($dockerfile, $this->application->dockerfile_target_build);
|
|
} catch (Throwable $e) {
|
|
Log::warning("Deployment {$this->job('deployment_uuid')} could not read the base image for the image scan: {$e->getMessage()}");
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Queues one scan of the started images when the gate did not scan them before the start. Restarts, skipped
|
|
* builds, and additional servers run an image that was scanned before, so they get no scan (spec 4.4), except
|
|
* for a rollback.
|
|
*/
|
|
public function dispatchScanAfterStart(): void
|
|
{
|
|
try {
|
|
if ((($this->job('skip_build') || $this->job('is_this_additional_server')) && ! $this->job('rollback'))
|
|
|| ImageScanPolicyResolver::offState($this->application, $this->job('mainServer')) !== null) {
|
|
return;
|
|
}
|
|
|
|
// Images that the gate scanned before the start need no second scan; the others (a scan that could
|
|
// not run, a rollback image that ran before) are scanned now.
|
|
if ($this->application->build_pack === 'dockercompose') {
|
|
$images = collect(data_get(Yaml::parse((string) $this->job('generated_docker_compose')), 'services', []))
|
|
->map(fn ($service) => data_get($service, 'image'))
|
|
->filter(fn ($image) => is_string($image) && $image !== '' && ! isset($this->scannedImages[$image]))
|
|
->unique()
|
|
->values()
|
|
->all();
|
|
} else {
|
|
$imageName = $this->job('production_image_name');
|
|
$images = $imageName !== null && ! isset($this->scannedImages[$imageName])
|
|
? [$this->pinnedImage ?? $imageName]
|
|
: [];
|
|
}
|
|
|
|
if ($images !== []) {
|
|
ScanResourceImagesJob::dispatch($this->application, $this->deployment->id, $images, false, $this->baseImageRef);
|
|
}
|
|
} catch (Throwable $e) {
|
|
Log::warning("Deployment {$this->job('deployment_uuid')} could not queue the image scan: {$e->getMessage()}");
|
|
}
|
|
}
|
|
|
|
private function job(string $property): mixed
|
|
{
|
|
return ($this->read)($property);
|
|
}
|
|
|
|
/**
|
|
* Writes the Compose file again after the gate changed it, with the same commands as the first write.
|
|
*/
|
|
private function writeComposeFileToWorkdir(string $yaml): void
|
|
{
|
|
$uuid = $this->job('deployment_uuid');
|
|
$path = $this->job('workdir').$this->job('docker_compose_location');
|
|
|
|
($this->run)([
|
|
"docker exec {$uuid} rm -f {$path}",
|
|
'hidden' => true,
|
|
], [
|
|
"docker exec -i {$uuid} tee {$path} > /dev/null",
|
|
'input' => $yaml,
|
|
'hidden' => true,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Queues the check of newer versions and of the base image for the scanned image. The gate does not wait for it:
|
|
* it reads only the findings.
|
|
*/
|
|
private function queueCandidates(ImageScan $scan): void
|
|
{
|
|
try {
|
|
$scan->setRelation('resource', $this->application);
|
|
if ($scan->exists && RefreshCandidates::shouldRefresh($scan)) {
|
|
RefreshCandidatesJob::queueCheck($scan);
|
|
}
|
|
} catch (Throwable $e) {
|
|
report($e);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The scan of the image the application ran before this deployment: a completed scan of the latest finished
|
|
* deployment that is not a preview. The deployment decides, not the scan time: a redeploy of an older image links
|
|
* its older scan to the newer deployment. A Compose application matches the image by name. Null before the first
|
|
* deploy, so every finding of the new image counts as new.
|
|
*/
|
|
private function runningImageScan(string $imageRef): ?ImageScan
|
|
{
|
|
$query = fn () => $this->application->imageScans()
|
|
->where('status', ImageScanStatus::Completed)
|
|
->whereHas('deploymentQueue', fn ($query) => $query
|
|
->where('pull_request_id', 0)
|
|
->where('status', ApplicationDeploymentStatus::FINISHED->value))
|
|
->orderByDesc('deployment_queue_id')
|
|
->orderByDesc('scanned_at')
|
|
->orderByDesc('id');
|
|
|
|
if ($this->application->build_pack !== 'dockercompose') {
|
|
return $query()->first();
|
|
}
|
|
|
|
$name = ScanImage::imageName($imageRef);
|
|
$id = $query()->get(['id', 'image_ref'])
|
|
->first(fn (ImageScan $scan): bool => ScanImage::imageName((string) $scan->image_ref) === $name)?->id;
|
|
|
|
return $id === null ? null : ImageScan::query()->find($id);
|
|
}
|
|
|
|
/**
|
|
* A finished deployment of this application that is not a preview ran before this one, so this is not the first
|
|
* deploy.
|
|
*/
|
|
private function hasEarlierFinishedDeployment(): bool
|
|
{
|
|
return ApplicationDeploymentQueue::query()
|
|
->where('application_id', (string) $this->application->id)
|
|
->where('pull_request_id', 0)
|
|
->where('status', ApplicationDeploymentStatus::FINISHED->value)
|
|
->whereKeyNot($this->deployment->id)
|
|
->exists();
|
|
}
|
|
|
|
/**
|
|
* The gate's baseline when no scan of the running image exists although the app was deployed before (for
|
|
* example, scanning was turned on after the deploy): the newest completed scan that is not a preview. The scan of
|
|
* an image the gate blocked never counts, and neither does the image of this deploy, so trying a blocked image
|
|
* again cannot pass.
|
|
*/
|
|
private function latestEarlierImageScan(string $imageRef, ?string $imageId): ?ImageScan
|
|
{
|
|
$name = $this->application->build_pack === 'dockercompose' ? ScanImage::imageName($imageRef) : null;
|
|
$excluded = array_values(array_unique(array_filter([...$this->blockedImageIds(), $imageId])));
|
|
|
|
return $this->application->imageScans()
|
|
->where('status', ImageScanStatus::Completed)
|
|
->where(fn ($query) => $query
|
|
->whereNull('deployment_queue_id')
|
|
->orWhereHas('deploymentQueue', fn ($deployment) => $deployment
|
|
->where('pull_request_id', 0)
|
|
->whereKeyNot($this->deployment->id)))
|
|
->whereDoesntHave('deploymentQueue', fn ($deployment) => $deployment->where('scan_gate_result->blocked', true))
|
|
->when($excluded !== [], fn ($query) => $query->whereNotIn('image_id', $excluded))
|
|
->orderByDesc('scanned_at')
|
|
->orderByDesc('id')
|
|
->limit(50)
|
|
->get()
|
|
->first(fn (ImageScan $scan): bool => $name === null || ScanImage::imageName((string) $scan->image_ref) === $name);
|
|
}
|
|
|
|
/**
|
|
* The images that the gate blocked in an earlier deployment of this application that is not a preview: the image
|
|
* ID stored with the result, or a scan linked to the blocked deployment. The stored ID keeps the block when the
|
|
* scan was removed.
|
|
*
|
|
* @return list<string>
|
|
*/
|
|
private function blockedImageIds(): array
|
|
{
|
|
if ($this->blockedImageIds !== null) {
|
|
return $this->blockedImageIds;
|
|
}
|
|
|
|
$blocked = fn ($query) => $query
|
|
->where('pull_request_id', 0)
|
|
->whereKeyNot($this->deployment->id)
|
|
->where('scan_gate_result->blocked', true);
|
|
|
|
$stored = $blocked(ApplicationDeploymentQueue::query()->where('application_id', (string) $this->application->id))
|
|
->get(['id', 'scan_gate_result'])
|
|
->map(fn (ApplicationDeploymentQueue $deployment) => data_get($deployment->scan_gate_result, 'image_id'));
|
|
$scanned = $this->application->imageScans()
|
|
->whereNotNull('image_id')
|
|
->whereHas('deploymentQueue', $blocked)
|
|
->pluck('image_id');
|
|
|
|
return $this->blockedImageIds = $stored->merge($scanned)
|
|
->filter(fn ($id): bool => is_string($id) && $id !== '')
|
|
->unique()
|
|
->values()
|
|
->all();
|
|
}
|
|
|
|
/**
|
|
* "Deploy anyway" of a Docker image starts the image that was blocked, not a newer image behind the same tag.
|
|
*/
|
|
private function pinBypassedImage(): void
|
|
{
|
|
if ($this->application->build_pack !== 'dockerimage' || $this->gateBypassedBy() === null) {
|
|
return;
|
|
}
|
|
|
|
$queue = $this->deployment;
|
|
$pinned = data_get($queue->scan_gate_result, 'repo_digest');
|
|
if (! is_string($pinned) && filled($queue->parent_deployment_uuid)) {
|
|
$pinned = data_get(ApplicationDeploymentQueue::query()
|
|
->where('deployment_uuid', $queue->parent_deployment_uuid)
|
|
->where('application_id', $queue->application_id)
|
|
->value('scan_gate_result'), 'repo_digest');
|
|
}
|
|
if (! is_string($pinned)) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
$ref = ImageRef::parse($pinned);
|
|
$configured = ImageRef::parse($this->job('production_image_name'));
|
|
} catch (InvalidArgumentException) {
|
|
return;
|
|
}
|
|
|
|
if ($ref->digest === null || $ref->qualifiedName() !== $configured->qualifiedName()) {
|
|
return;
|
|
}
|
|
|
|
if (! $this->pinCompose((string) $ref)) {
|
|
return;
|
|
}
|
|
|
|
$this->deployment->addLogEntry("Image scan: the deployment starts the blocked image {$ref}.");
|
|
}
|
|
|
|
/**
|
|
* Starts the scanned image where the start could pull another one: a Docker image, an image from a build
|
|
* server, or a Swarm service. Without a repo digest the image stays as it is.
|
|
*/
|
|
private function pinScannedImage(?string $repoDigest): void
|
|
{
|
|
if ($repoDigest === null || $this->application->build_pack === 'dockercompose') {
|
|
return;
|
|
}
|
|
|
|
if ($this->application->build_pack !== 'dockerimage' && ! $this->job('use_build_server') && ! $this->job('mainServer')->isSwarm()) {
|
|
return;
|
|
}
|
|
|
|
if (! $this->pinCompose($repoDigest)) {
|
|
return;
|
|
}
|
|
|
|
$this->deployment->addLogEntry("Image scan: the deployment starts {$repoDigest}.");
|
|
}
|
|
|
|
/**
|
|
* Sets the image of the application service in the job's Compose file. False when the file has no such service.
|
|
*/
|
|
private function pinCompose(string $image): bool
|
|
{
|
|
$compose = Yaml::parse((string) $this->job('docker_compose'));
|
|
$container = $this->job('container_name');
|
|
if (! isset($compose['services'][$container])) {
|
|
return false;
|
|
}
|
|
|
|
$compose['services'][$container]['image'] = $image;
|
|
$yaml = Yaml::dump($compose, 10);
|
|
($this->write)('generated_docker_compose', $yaml);
|
|
($this->write)('docker_compose', $yaml);
|
|
$this->pinnedImage = $image;
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* The image is scanned on the build server when it is there, else on the main server. The rules always resolve
|
|
* through the main destination server.
|
|
*/
|
|
private function policy(): ResolvedImageScanPolicy
|
|
{
|
|
$server = $this->imageIsOnBuildServer() ? $this->job('build_server') : null;
|
|
|
|
if ($this->policy === null || $this->policyServerId !== $server?->id) {
|
|
$this->policy = ImageScanPolicyResolver::for($this->application, $server);
|
|
$this->policyServerId = $server?->id;
|
|
}
|
|
|
|
return $this->policy;
|
|
}
|
|
|
|
/**
|
|
* The image is on the build server when the deployment built there. A restart and a Docker image app keep the
|
|
* image on the deployment server, even with a build server.
|
|
*/
|
|
private function imageIsOnBuildServer(): bool
|
|
{
|
|
if (! $this->job('use_build_server')) {
|
|
return false;
|
|
}
|
|
|
|
$buildServer = $this->job('build_server');
|
|
|
|
return $buildServer !== null && $this->job('server')->is($buildServer);
|
|
}
|
|
|
|
private function usesCustomComposeCommand(): bool
|
|
{
|
|
return filled($this->job('docker_compose_custom_start_command')) || filled($this->job('docker_compose_custom_build_command'));
|
|
}
|
|
|
|
/**
|
|
* Decisions for the whole deployment that need no image: "Deploy anyway", and a rollback to a commit that ran before.
|
|
*/
|
|
private function settleGateForDeployment(): bool
|
|
{
|
|
$bypassedBy = $this->gateBypassedBy();
|
|
if ($bypassedBy !== null) {
|
|
$this->gateSettled = true;
|
|
$this->logGateBypass($bypassedBy);
|
|
|
|
return true;
|
|
}
|
|
|
|
if ($this->job('rollback') && $this->rollbackCommitRanBefore()) {
|
|
$this->gateSettled = true;
|
|
$this->deployment->addLogEntry('Rollback to an image that ran before. The image scan does not block it.');
|
|
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* The reason the gate cannot scan: scanning is off where the image is, or a custom Compose command.
|
|
*
|
|
* @return array{0: ImageScanErrorCode, 1: array{server: string}}|null
|
|
*/
|
|
private function errorBeforeScan(): ?array
|
|
{
|
|
$policy = $this->policy();
|
|
|
|
if (! $policy->scanEnabled) {
|
|
$error = $this->imageIsOnBuildServer() ? ImageScanErrorCode::BuildServerOff : ImageScanErrorCode::ScanningOff;
|
|
|
|
return [$error, ['server' => (string) ($policy->scanServer?->name ?? $this->job('server')->name)]];
|
|
}
|
|
|
|
if ($this->usesCustomComposeCommand()) {
|
|
return [ImageScanErrorCode::CustomCommand, ['server' => (string) $this->job('server')->name]];
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* A rollback is not gated when a finished deployment of this application ran its commit (spec 4.4). A local tag
|
|
* alone does not count: the image of a blocked deployment stays tagged on the server.
|
|
*/
|
|
private function rollbackCommitRanBefore(): bool
|
|
{
|
|
$commit = $this->job('commit');
|
|
if (blank($commit) || $commit === 'HEAD') {
|
|
return false;
|
|
}
|
|
|
|
return $this->rollbackRanBefore ??= ApplicationDeploymentQueue::query()
|
|
->where('application_id', (string) $this->application->id)
|
|
->where('status', ApplicationDeploymentStatus::FINISHED->value)
|
|
->where('commit', $commit)
|
|
->where('pull_request_id', $this->job('pull_request_id'))
|
|
->exists();
|
|
}
|
|
|
|
/**
|
|
* An image ran before when a finished deployment of this application scanned it.
|
|
*/
|
|
private function imageRanBefore(string $imageId): bool
|
|
{
|
|
return $this->application->imageScans()
|
|
->where('image_id', $imageId)
|
|
->whereHas('deploymentQueue', fn ($query) => $query->where('status', ApplicationDeploymentStatus::FINISHED->value))
|
|
->exists();
|
|
}
|
|
|
|
private function completedImageScan(string $imageId): ?ImageScan
|
|
{
|
|
return $this->application->imageScans()
|
|
->where('image_id', $imageId)
|
|
->where('status', ImageScanStatus::Completed)
|
|
->first();
|
|
}
|
|
|
|
/**
|
|
* The local image ID and the registry digest reference of the image, read with one `docker image inspect`.
|
|
* A locally built image (classic store, no push) has no repo digest.
|
|
*
|
|
* @return array{id: string, repo_digest: ?string}|null
|
|
*/
|
|
private function inspectImageForScan(string $imageRef): ?array
|
|
{
|
|
try {
|
|
$ref = ImageRef::parse($imageRef);
|
|
} catch (InvalidArgumentException) {
|
|
return null;
|
|
}
|
|
|
|
$format = escapeshellarg('{{.Id}} {{range .RepoDigests}}{{.}} {{end}}');
|
|
($this->run)([
|
|
"docker image inspect --format {$format} ".escapeshellarg($imageRef).' 2>/dev/null',
|
|
'hidden' => true,
|
|
'ignore_errors' => true,
|
|
'save' => 'image_scan_inspect',
|
|
'append' => false,
|
|
]);
|
|
|
|
$output = str($this->job('saved_outputs')->get('image_scan_inspect'))->trim()->value();
|
|
$parts = preg_split('/\s+/', $output, -1, PREG_SPLIT_NO_EMPTY);
|
|
$id = array_shift($parts);
|
|
if (! is_string($id) || ! ImageRef::isValidDigest($id)) {
|
|
return null;
|
|
}
|
|
|
|
$repoDigest = null;
|
|
foreach ($parts as $candidate) {
|
|
try {
|
|
$parsed = ImageRef::parse($candidate);
|
|
} catch (InvalidArgumentException) {
|
|
continue;
|
|
}
|
|
|
|
if ($parsed->digest !== null && $parsed->qualifiedName() === $ref->qualifiedName()) {
|
|
$repoDigest = (string) $parsed;
|
|
break;
|
|
}
|
|
}
|
|
|
|
return ['id' => $id, 'repo_digest' => $repoDigest];
|
|
}
|
|
|
|
/**
|
|
* Writes the result on the deployment, logs it, and stops the deployment when the gate blocks.
|
|
*
|
|
* @param array<string, int|string> $errorContext
|
|
* @param ImageScan|null $running The scan of the image the application ran before this deployment.
|
|
* @param bool $baselineMissing The app was deployed before, but Coolify has no earlier scan to compare with.
|
|
* @param string|null $imageId The local image ID, stored so a later deploy of a blocked image stays blocked.
|
|
*/
|
|
private function applyGate(?ImageScan $scan, ?ImageScanErrorCode $error = null, array $errorContext = [], ?string $imageRef = null, ?string $repoDigest = null, ?ImageScan $running = null, bool $baselineMissing = false, ?string $imageId = null): void
|
|
{
|
|
$completed = $scan !== null && $scan->status === ImageScanStatus::Completed;
|
|
|
|
if ($scan !== null && ! $completed && $error === null) {
|
|
$error = $scan->error_code ?? ImageScanErrorCode::Unknown;
|
|
$errorContext = (array) $scan->error_context;
|
|
}
|
|
|
|
$scan?->setRelation('resource', $this->application);
|
|
$policy = $this->policy();
|
|
$result = SecurityGate::evaluate($scan, $running, $policy->gateMode, new RelevanceRules(IgnoreMatcher::forResource($this->application), $policy->countMedium), $error, $errorContext, $policy->onScanError, $baselineMissing);
|
|
$stored = SecurityGate::stored($result);
|
|
if ($stored['blocked'] && $completed) {
|
|
// The action that fixes the blocked image, for the gate popover.
|
|
try {
|
|
$action = SecurityCard::blockedImageAction($scan);
|
|
if ($action !== null) {
|
|
$stored['action'] = $action;
|
|
}
|
|
} catch (Throwable $e) {
|
|
report($e);
|
|
}
|
|
}
|
|
|
|
// A Compose deployment gates several images: keep the worst result (blocked, then error, then pass).
|
|
$rank = fn (?array $gateResult): int => match (true) {
|
|
$gateResult === null => -1,
|
|
(bool) ($gateResult['blocked'] ?? false) => 2,
|
|
($gateResult['error'] ?? null) !== null => 1,
|
|
default => 0,
|
|
};
|
|
if ($rank($stored) >= $rank($this->gateResult)) {
|
|
// The digest lets "Deploy anyway" start the image that was blocked.
|
|
$this->gateResult = [
|
|
...$stored,
|
|
...($repoDigest !== null ? ['repo_digest' => $repoDigest] : []),
|
|
...($imageId !== null && $imageId !== '' ? ['image_id' => $imageId] : []),
|
|
];
|
|
$this->deployment->update(['scan_gate_result' => $this->gateResult]);
|
|
}
|
|
if ($result['scan_ran'] && $imageRef !== null) {
|
|
$this->scannedImages[$imageRef] = true;
|
|
}
|
|
|
|
// A pass logs "passed" first; the lines after it warn about problems that the running image has too.
|
|
$failed = $result['blocked'] || $result['error'] !== null;
|
|
foreach (SecurityGate::logLines($result) as $index => $line) {
|
|
$this->deployment->addLogEntry($line, $failed || $index > 0 ? 'stderr' : 'stdout');
|
|
}
|
|
|
|
if ($result['blocked']) {
|
|
throw DeploymentException::alreadyLogged($result['headline']);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The user who chose "Deploy anyway" for this deployment, or for the main deployment that queued it.
|
|
*/
|
|
private function gateBypassedBy(): ?int
|
|
{
|
|
$queue = $this->deployment;
|
|
$userId = $queue->scan_gate_bypassed_by;
|
|
|
|
if (blank($userId) && filled($queue->parent_deployment_uuid)) {
|
|
$userId = ApplicationDeploymentQueue::query()
|
|
->where('deployment_uuid', $queue->parent_deployment_uuid)
|
|
->where('application_id', $queue->application_id)
|
|
->value('scan_gate_bypassed_by');
|
|
}
|
|
|
|
return filled($userId) ? (int) $userId : null;
|
|
}
|
|
|
|
private function logGateBypass(int $userId): void
|
|
{
|
|
$user = User::query()->find($userId);
|
|
$name = $user?->name ?? 'a deleted user';
|
|
|
|
$this->deployment->addLogEntry("Deploy anyway by {$name}.", 'stderr');
|
|
|
|
auditLog('ui.application.image_scan_gate_bypassed', [
|
|
'team_id' => $this->application->team()?->id,
|
|
'application_uuid' => $this->application->uuid,
|
|
'deployment_uuid' => $this->job('deployment_uuid'),
|
|
'user_id' => $userId,
|
|
'actor_id' => $user?->id,
|
|
'actor_name' => $user?->name,
|
|
'actor_email' => $user?->email,
|
|
]);
|
|
}
|
|
}
|