feat(server-security): scan host OS packages for vulnerabilities (#12482)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andras Bacsai
2026-11-09 10:12:56 +01:00
committed by GitHub
co-authored by Claude Opus 5.5
parent c8497912ba
commit 65dba32385
59 changed files with 2825 additions and 356 deletions
+1
View File
@@ -32,6 +32,7 @@
- For load tests against a dev VM, use your own instance (`COOLIFY_DEV_INSTANCE=<name> COOLIFY_DEV_SIDE_BY_SIDE=1 ./scripts/dev start`); other sessions can reseed the localhost VM of a shared instance. The 1 GiB VM drops packets when its conntrack table is full (`dmesg`: `nf_conntrack: table full`), so raise `net.netfilter.nf_conntrack_max` on it and run an idle load first to prove a clean baseline.
- Do not call `sleep()` in a browser test. The app server runs in the test process, so `sleep()` stops it; use `$page->wait()`.
- `Queue::fake()` also captures `dispatchSync()` of a `ShouldQueue` job, so the job never runs. Fake only the jobs to block (`Queue::fake([SomeJob::class])`). Exception: actions (`AsAction`) are queued as `JobDecorator`, so `Queue::fake([SomeAction::class])` does not stop them; use `Queue::fake()` and `SomeAction::assertPushed()`.
- Call `instant_remote_process()` with named arguments after the server (`throwError:`, `timeout:`). The 4th positional parameter is `no_sudo`, so `instant_remote_process($cmds, $server, false, 900)` runs without sudo on a non-root server and fails with misleading permission errors.
- `Notification::fake()` records nothing for a team that has no enabled channel. Turn one on (for example `emailNotificationSettings()->update(['use_instance_email_settings' => true, '<event>_email_notifications' => true])`) before `assertSentTo`.
- In tests, turning on `is_metrics_enabled` (and other Sentinel settings) with `save()`/`update()` runs the settings hook, which starts Sentinel over a real SSH connection on the sync queue. Use `saveQuietly()` or `Queue::fake()`.
- Call `visit()` directly in each `tests/v4/Browser` test body; Pest does not mark a test that only uses helper-wrapped `visit()` as a browser test, so it fails with `sendText() on null`.
+153
View File
@@ -0,0 +1,153 @@
<?php
namespace App\Actions\ImageScan;
use App\Actions\Server\CheckUpdates;
use App\Enums\ImageScanErrorCode;
use App\Jobs\ImageRescanJob;
use App\Models\Server;
use App\Models\ServerHostScan;
use App\Services\ImageScan\Finding;
use App\Services\ImageScan\GrypeReportParser;
use App\Services\ImageScan\RelevanceRules;
use App\Services\ImageScan\ScanFailed;
use App\Services\ImageScan\ScannerCommands;
use App\Services\ImageScan\SecurityRules;
use Carbon\CarbonImmutable;
use Illuminate\Support\Str;
use Lorisleiva\Actions\Concerns\AsAction;
use Throwable;
/**
* Scans the OS packages of a server with the image scanner tools and saves the result on its `ServerHostScan` row.
* Runs under the image scan lock of the server, so it never runs at the same time as an image scan.
*/
class ScanHostPackages
{
use AsAction;
private const TIMEOUT_DEFAULT = 120;
private const TIMEOUT_SBOM = 720;
private const TIMEOUT_GRYPE = 360;
/** A host has more findings than an image; all of them are counted. */
private const MAX_PARSED_FINDINGS = 20000;
/**
* @return bool False when another scan holds the server lock: the caller tries again later.
*/
public function handle(Server $server): bool
{
if (! ImageRescanJob::canRunOn($server)) {
return true;
}
$lock = ScanImage::serverLock($server);
if (! ScanImage::acquireServerLock($lock, $server, 0)) {
return false;
}
$commands = new ScannerCommands($server, Str::lower(Str::random(24)));
try {
$databaseBuiltAt = ScanImage::databaseBuiltAt($commands);
$packages = ScanImage::runStep($server, $commands->hostPackagesSize(), 'packages-size', self::TIMEOUT_DEFAULT);
if ($packages['code'] !== 0 || $packages['size'] === null) {
throw new ScanFailed(ImageScanErrorCode::Unknown);
}
ScanImage::checkDiskSpace($commands, $packages['size'] * 1024);
$sbom = ScanImage::runStep($server, $commands->sbomFromHost(), 'sbom', self::TIMEOUT_SBOM);
$copy = $sbom['exit_codes']['copy'] ?? 2;
if ($copy !== 0) {
throw new ScanFailed($copy === 1 ? ImageScanErrorCode::NoPackageDatabase : ImageScanErrorCode::Unknown);
}
ScanImage::assertSucceeded($sbom, 'sbom');
$grype = ScanImage::runStep($server, $commands->grype(ScannerCommands::HOST_SBOM), 'grype', self::TIMEOUT_GRYPE);
ScanImage::assertSucceeded($grype, 'grype');
$runningKernel = trim((string) instant_remote_process(['uname -r'], $server, throwError: false, timeout: self::TIMEOUT_DEFAULT));
$this->complete($server, $grype, $databaseBuiltAt, $runningKernel);
} catch (ScanFailed $e) {
self::recordFailure($server, $e->errorCode, $e->context);
} catch (Throwable $e) {
report($e);
self::recordFailure($server, ImageScanErrorCode::Unknown);
} finally {
instant_remote_process($commands->cleanup(), $server, throwError: false, timeout: self::TIMEOUT_DEFAULT);
$lock->release();
}
return true;
}
/**
* Stores the relevant findings (spec 15.2), problems with an exploit signal first, then the ones an update fixes.
* Findings in installed kernels that the server does not run are no risk now: they are not counted (only as `other_kernels`)
* and come last, so they do not push out the others.
*
* @param array{code: int, size: ?int, payload: string, exit_codes: array<string, int>, oom: list<string>} $grype
*/
private function complete(Server $server, array $grype, CarbonImmutable $databaseBuiltAt, string $runningKernel): void
{
[$payload, $cut] = ScanImage::readPayload($grype);
$report = (new GrypeReportParser)->parse($payload, self::MAX_PARSED_FINDINGS);
$rules = RelevanceRules::fromIgnores(collect());
$relevant = array_values(array_filter($report['findings'], fn (Finding $finding): bool => SecurityRules::isRelevant($finding, $rules)));
usort($relevant, fn (Finding $a, Finding $b): int => $this->rank($a, $runningKernel) <=> $this->rank($b, $runningKernel));
$summary = ['critical' => 0, 'high' => 0, 'medium' => 0, 'low' => 0, 'unknown' => 0, 'fixable' => 0, 'exploited' => 0, 'likely' => 0, 'other_kernels' => 0, 'total' => count($report['findings']), 'running_kernel' => $runningKernel !== '' ? $runningKernel : null];
foreach ($relevant as $finding) {
if (CheckUpdates::isOtherKernelPackage($finding->package, $finding->version, $runningKernel)) {
$summary['other_kernels']++;
continue;
}
$summary[$finding->severity]++;
$summary['fixable'] += $finding->fixedIn !== null ? 1 : 0;
$signal = SecurityRules::exploitSignal($finding);
$summary['exploited'] += $signal === SecurityRules::EXPLOITED ? 1 : 0;
$summary['likely'] += $signal === SecurityRules::LIKELY ? 1 : 0;
}
ServerHostScan::query()->updateOrCreate(['server_id' => $server->id], [
'error_code' => null,
'error_context' => null,
'eol' => $report['eol'],
'summary' => $summary,
'findings' => array_map(fn (Finding $finding): array => $finding->toArray(), array_slice($relevant, 0, ServerHostScan::MAX_STORED_FINDINGS)),
'findings_truncated' => $cut || $report['truncated'] || count($relevant) > ServerHostScan::MAX_STORED_FINDINGS,
'grype_db_built_at' => $databaseBuiltAt,
'scanned_at' => now(),
]);
}
/**
* @return array{0: int, 1: int, 2: int, 3: float}
*/
private function rank(Finding $finding, string $runningKernel): array
{
$otherKernel = CheckUpdates::isOtherKernelPackage($finding->package, $finding->version, $runningKernel) ? 1 : 0;
$signal = match (SecurityRules::exploitSignal($finding)) {
SecurityRules::EXPLOITED => 0,
SecurityRules::LIKELY => 1,
default => 2,
};
return [$otherKernel, $signal, $finding->fixedIn !== null ? 0 : 1, -($finding->risk ?? 0.0)];
}
/**
* Keeps the findings of the last completed scan.
*
* @param array<string, int|string> $context The values of the error message.
*/
public static function recordFailure(Server $server, ImageScanErrorCode $code, array $context = []): void
{
ServerHostScan::query()->updateOrCreate(['server_id' => $server->id], ['error_code' => $code, 'error_context' => $context ?: null]);
}
}
+18 -17
View File
@@ -427,13 +427,13 @@ class ScanImage
/**
* Updates a missing database once. Grype refuses a database older than 5 days.
*/
private function databaseBuiltAt(ScannerCommands $commands): CarbonImmutable
public static function databaseBuiltAt(ScannerCommands $commands): CarbonImmutable
{
$builtAt = $this->databaseStatus($commands);
$builtAt = self::databaseStatus($commands);
if ($builtAt === null) {
$this->updateDatabase($commands);
$builtAt = $this->databaseStatus($commands) ?? throw new ScanFailed(ImageScanErrorCode::DbMissing);
self::updateDatabase($commands);
$builtAt = self::databaseStatus($commands) ?? throw new ScanFailed(ImageScanErrorCode::DbMissing);
}
if ($builtAt->lt(now()->subDays(self::DB_MAX_AGE_DAYS))) {
@@ -443,7 +443,7 @@ class ScanImage
return $builtAt;
}
private function updateDatabase(ScannerCommands $commands): void
private static function updateDatabase(ScannerCommands $commands): void
{
$url = InstanceSettings::get()->image_scan_db_url;
@@ -456,9 +456,9 @@ class ScanImage
instant_remote_process($update, $commands->server, throwError: false, timeout: self::TIMEOUT_DB_UPDATE);
}
private function databaseStatus(ScannerCommands $commands): ?CarbonImmutable
private static function databaseStatus(ScannerCommands $commands): ?CarbonImmutable
{
$result = $this->runStep($commands->server, $commands->dbStatus(), 'db-status', self::TIMEOUT_DEFAULT);
$result = self::runStep($commands->server, $commands->dbStatus(), 'db-status', self::TIMEOUT_DEFAULT);
if (in_array($result['code'], self::EXIT_TIMEOUT, true)) {
throw new ScanFailed(ImageScanErrorCode::Timeout);
}
@@ -490,7 +490,7 @@ class ScanImage
}
$this->progress($resource, 'pull');
$result = $this->runStep($commands->server, $commands->pull($imageRef), 'pull', self::TIMEOUT_PULL);
$result = self::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);
}
@@ -504,7 +504,7 @@ class ScanImage
*/
private function inspect(ScannerCommands $commands, string $imageRef): array
{
$result = $this->runStep($commands->server, $commands->inspect($imageRef), 'inspect', self::TIMEOUT_DEFAULT);
$result = self::runStep($commands->server, $commands->inspect($imageRef), 'inspect', self::TIMEOUT_DEFAULT);
if ($result['code'] !== 0) {
throw new ScanFailed(ImageScanErrorCode::ImageMissing);
}
@@ -530,7 +530,8 @@ class ScanImage
/**
* `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).
* Candidate scans from a registry use the same rule (Syft writes the layers to the scratch directory), and host scans
* use it with the size of the copied package databases.
*
* @throws ScanFailed
*/
@@ -561,7 +562,7 @@ class ScanImage
if ($sbom !== null) {
$this->progressAll($resources, 'vulnerabilities');
$grype = $this->runStep($commands->server, $commands->grype($image['hex']), 'grype', self::TIMEOUT_GRYPE);
$grype = self::runStep($commands->server, $commands->grype($image['hex']), 'grype', self::TIMEOUT_GRYPE);
if ($grype['code'] !== self::EXIT_DOCKER_RUN) {
$this->assertSucceeded($grype, 'grype');
@@ -575,7 +576,7 @@ class ScanImage
$sbom = $this->createSbom($commands, $imageRef, $image, $sbom);
$this->progressAll($resources, 'vulnerabilities');
$grype = $this->runStep($commands->server, $commands->grype($image['hex']), 'grype', self::TIMEOUT_GRYPE);
$grype = self::runStep($commands->server, $commands->grype($image['hex']), 'grype', self::TIMEOUT_GRYPE);
$this->assertSucceeded($grype, 'grype');
return [$sbom, $grype];
@@ -588,12 +589,12 @@ class ScanImage
{
self::checkDiskSpace($commands, $image['size']);
$result = $this->runStep($commands->server, $commands->sbomFromLocal($imageRef, $image['hex']), 'save', self::TIMEOUT_SBOM);
$result = self::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);
$packages = self::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);
@@ -916,7 +917,7 @@ class ScanImage
* @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
public static 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);
@@ -940,7 +941,7 @@ class ScanImage
*
* @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
public static function assertSucceeded(array $result, string $step): void
{
$code = $result['exit_codes'][$step] ?? null;
if ($code !== 0 && $step !== 'save' && in_array($step, $result['oom'], true)) {
@@ -960,7 +961,7 @@ class ScanImage
* @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
public static function readPayload(array $result): array
{
$payload = $result['payload'];
$cut = ($result['size'] ?? 0) > ScannerCommands::OUTPUT_LIMIT || strlen($payload) > ScannerCommands::OUTPUT_LIMIT;
+201
View File
@@ -82,6 +82,19 @@ class CheckUpdates
*/
public const REBOOT_CHECK_COMMAND = <<<'SH'
sh -c 'if [ -f /var/run/reboot-required ]; then echo REBOOT=yes; exit 0; fi; if command -v needrestart >/dev/null 2>&1; then k=$(needrestart -b 2>/dev/null | sed -n "s/^NEEDRESTART-KSTA: *//p"); case "$k" in 2|3) echo REBOOT=yes; exit 0;; 1) echo REBOOT=no; exit 0;; esac; fi; if command -v dnf >/dev/null 2>&1; then o=$(LANG=C dnf needs-restarting -r 2>/dev/null); case "$o" in *"Reboot is required"*) echo REBOOT=yes; exit 0;; *"should not be necessary"*) echo REBOOT=no; exit 0;; esac; fi; if command -v zypper >/dev/null 2>&1; then zypper -q needs-rebooting >/dev/null 2>&1; rc=$?; if [ $rc -eq 102 ]; then echo REBOOT=yes; exit 0; fi; if [ $rc -eq 0 ]; then echo REBOOT=no; exit 0; fi; fi; if [ -d /var/lib/dpkg ]; then echo REBOOT=no; exit 0; fi; echo REBOOT=unknown'
SH;
/**
* Prints the running kernel (`RUNNING|<uname -r>`), each installed kernel (`KERNEL|<package>|<version>[|<arch>]`),
* and the kernel that boots next (`BOOT|<uname -r>`, or `BOOT|newest`) when the boot loader tells it, so the
* vulnerability list can tell an installed fix that waits for a reboot from a kernel that does not run.
* The boot kernel comes from `grubby --default-kernel` (RHEL family), else from GRUB_DEFAULT and the saved entry
* of GRUB: entry 0 is the newest kernel, a menu title or an entry ID contains the release. Other values print nothing.
*
* @see https://www.gnu.org/software/grub/manual/grub/html_node/Simple-configuration.html
*/
public const KERNELS_COMMAND = <<<'SH'
sh -c 'echo "RUNNING|$(uname -r)"; if command -v dpkg-query >/dev/null 2>&1; then dpkg-query -W -f="\${db:Status-Abbrev}|\${Package}|\${Version}\n" "linux-image-[0-9]*" 2>/dev/null | sed -n "s/^ii *|/KERNEL|/p"; fi; if command -v rpm >/dev/null 2>&1; then rpm -q --qf "KERNEL|%{NAME}|%{VERSION}-%{RELEASE}|%{ARCH}\n" kernel-core kernel kernel-uek-core 2>/dev/null | grep "^KERNEL|"; fi; if command -v grubby >/dev/null 2>&1; then b=$(grubby --default-kernel 2>/dev/null); case "$b" in */vmlinuz-*) echo "BOOT|${b##*/vmlinuz-}";; esac; elif [ -f /etc/default/grub ]; then d=$(cat /etc/default/grub /etc/default/grub.d/*.cfg 2>/dev/null | sed -n "s/^[[:space:]]*GRUB_DEFAULT=//p" | tail -n 1 | tr -d "\042\047"); if [ "$d" = saved ]; then d=$(grub-editenv list 2>/dev/null | sed -n "s/^saved_entry=//p"); fi; case "$d" in ""|0) echo "BOOT|newest";; *) r=$(printf "%s\n" "$d" | sed -n -e "s/.*with Linux \([^ ]*\).*/\1/p" -e "s/.*gnulinux-\([0-9][^>]*\)-\(advanced\|recovery\)-.*/\1/p" | head -n 1); if [ -n "$r" ]; then echo "BOOT|$r"; fi;; esac; fi; exit 0'
SH;
/**
@@ -233,6 +246,7 @@ SH;
$out = $this->addUpdateDetails($out, $packageManager, $securityPackages);
$out['reboot_required'] = $this->parseRebootStatus((string) instant_remote_process([self::REBOOT_CHECK_COMMAND], $server, false));
$out['kernels'] = $this->parseKernels((string) instant_remote_process([self::KERNELS_COMMAND], $server, false));
$out['osId'] = $osId;
$out['package_manager'] = $packageManager;
@@ -288,6 +302,53 @@ SH;
return null;
}
/**
* Updates of a saved check result that fix a vulnerable package: its own update, or for a kernel package that
* has the version in its name (Debian, Ubuntu: linux-modules-6.8.0-139-generic) the kernel updates of the same
* flavor, because the fixed kernel comes as a new package of the kernel meta packages. Empty on systems that only
* do full upgrades.
*
* @param array<string, mixed>|null $patchResult
* @return list<array<string, mixed>>
*/
public static function fixingUpdates(string $package, ?array $patchResult): array
{
$packageManager = data_get($patchResult, 'package_manager');
if (in_array($packageManager, self::FULL_UPGRADE_ONLY, true)) {
return [];
}
$updates = collect(data_get($patchResult, 'updates', []));
$ownUpdate = $updates->firstWhere('package', $package);
if ($ownUpdate !== null) {
return [$ownUpdate];
}
$flavor = self::kernelFlavor($package);
if ($flavor === null || self::riskFor($packageManager, $package) !== 'kernel') {
return [];
}
return $updates
->filter(fn ($update) => data_get($update, 'risk') === 'kernel' && self::kernelFlavor((string) data_get($update, 'package')) === $flavor)
->values()
->all();
}
/**
* Flavor of a Debian or Ubuntu kernel package, so that an update of another flavor (linux-aws for a generic
* kernel) is not offered as the fix: the suffix after the version (linux-modules-6.8.0-139-generic: generic),
* or the meta package name without the part and the HWE/LTS suffix (linux-image-generic-hwe-24.04: generic).
* Ubuntu's virtual meta packages install the generic kernel. Null when the name has no flavor.
*/
private static function kernelFlavor(string $package): ?string
{
if (preg_match('/^linux-.*?-\d+\.\d+\.\d+[^-]*(?:-\d+)?-([a-z].*)$/', $package, $matches) === 1
|| preg_match('/^linux-(?:(?:signed-|unsigned-)?image-|headers-|modules-(?:extra-)?|tools-)?([a-z][a-z0-9-]*?)(?:-(?:hwe|lts)-[\d.]+(?:-edge)?)?$/', $package, $matches) === 1) {
return $matches[1] === 'virtual' ? 'generic' : $matches[1];
}
return null;
}
/**
* Package names from `dnf updateinfo list --security` (dnf4: "RLSA-2024:1234 Important/Sec. openssl-1:3.0.7-27.el9.x86_64",
* dnf5: "FEDORA-2024-1 security Critical curl-8.6.0-1.fc40.x86_64 2024-01-01 00:00:00").
@@ -331,6 +392,146 @@ SH;
return array_values(array_unique($packages));
}
/**
* @param string $output Output of KERNELS_COMMAND.
* @return array{running: string, boot: ?string, installed: list<array{package: string, version: string, release: string}>}|null
*/
public function parseKernels(string $output): ?array
{
$running = null;
$boot = null;
$installed = [];
foreach (preg_split('/\R/', $output) as $line) {
$parts = explode('|', trim($line));
if ($parts[0] === 'RUNNING' && filled($parts[1] ?? null)) {
$running = $parts[1];
} elseif ($parts[0] === 'BOOT' && filled($parts[1] ?? null)) {
$boot = $parts[1];
} elseif ($parts[0] === 'KERNEL' && filled($parts[1] ?? null) && filled($parts[2] ?? null)) {
$installed[] = [
'package' => $parts[1],
'version' => $parts[2],
// uname -r of this kernel: the package name without the prefix (dpkg), or version.arch (rpm).
'release' => filled($parts[3] ?? null) ? "{$parts[2]}.{$parts[3]}" : (string) preg_replace('/^linux-image-/', '', $parts[1]),
];
}
}
if ($boot === 'newest') {
$boot = collect($installed)->reduce(fn (?array $carry, array $kernel) => $carry === null || self::versionAtLeast($kernel['version'], $carry['version']) ? $kernel : $carry)['release'] ?? null;
}
return $running === null ? null : ['running' => $running, 'boot' => $boot, 'installed' => $installed];
}
/**
* The installed kernel that the boot loader starts at the next reboot, or null when the last patch check
* could not tell.
*
* @param array<string, mixed>|null $patchResult
* @return array{package: string, version: string, release: string}|null
*/
public static function bootKernel(?array $patchResult): ?array
{
$boot = data_get($patchResult, 'kernels.boot');
return is_string($boot) ? collect(data_get($patchResult, 'kernels.installed', []))->firstWhere('release', $boot) : null;
}
/**
* Kernel state of a vulnerable package, from the kernels of the last patch check:
* - fixed_after_reboot: the package belongs to the running kernel, and the kernel that boots next has the fix.
* - runs_after_reboot: the package belongs to the kernel that boots next, which is not the running one.
* Both need the boot kernel (bootKernel()); without it, there is no claim. Other kernels that the server does
* not run: see isOtherKernelPackage().
*
* @param array<string, mixed>|null $patchResult
* @return 'fixed_after_reboot'|'runs_after_reboot'|null
*/
public static function kernelStatus(string $package, string $version, ?string $fixedIn, ?array $patchResult): ?string
{
$running = data_get($patchResult, 'kernels.running');
$installed = collect(data_get($patchResult, 'kernels.installed', []));
if (! is_string($running) || $installed->isEmpty()) {
return null;
}
$runningKernel = $installed->firstWhere('release', $running);
$kernel = $installed->first(fn (array $kernel) => self::kernelHasPackage($kernel, $package, $version));
if ($kernel === null || $runningKernel === null) {
return null;
}
$bootKernel = self::bootKernel($patchResult);
if ($bootKernel === null || $bootKernel['release'] === $running) {
return null;
}
if ($kernel['release'] !== $running) {
return $kernel['release'] === $bootKernel['release'] ? 'runs_after_reboot' : null;
}
return $fixedIn !== null && self::versionAtLeast($bootKernel['version'], $fixedIn) ? 'fixed_after_reboot' : null;
}
/**
* True for a package of an installed kernel that the server does not run (`uname -r`), older or newer. Its
* vulnerabilities are no risk now; a newer one runs after the next reboot and then replaces the running kernel. Debian kernel packages carry the release in the name
* (linux-modules-6.8.0-139-generic, linux-headers-6.8.0-139), RPM kernel packages the version.
*/
public static function isOtherKernelPackage(string $package, string $version, ?string $runningRelease): bool
{
if (blank($runningRelease)) {
return false;
}
if (preg_match('/^linux-.*?-(\d+\.\d+(?:\.\d+)?-\d+)(?:-|$)/', $package, $packageKernel) === 1) {
return preg_match('/^(\d+\.\d+(?:\.\d+)?-\d+)/', $runningRelease, $runningKernel) === 1
&& $packageKernel[1] !== $runningKernel[1];
}
if (! self::isRpmKernelPackage($package)) {
return false;
}
$version = self::withoutEpoch($version);
// SUSE: uname -r 6.4.0-150600.23.17-default for kernel-default 6.4.0-150600.23.17.1 (the last number is the rebuild).
if (preg_match('/^(.+)-[a-z][a-z0-9_]*$/', $runningRelease, $suseRelease) === 1) {
return (string) preg_replace('/\.\d+$/', '', $version) !== $suseRelease[1];
}
return $version !== (string) preg_replace('/\.[^.]+$/', '', $runningRelease);
}
/**
* Packages of a bootable RPM kernel (dnf, zypper). Userspace packages such as kernel-tools or kernel-headers have
* their own version and are not part of a kernel.
*/
private static function isRpmKernelPackage(string $package): bool
{
return self::riskFor('dnf', $package) === 'kernel' || self::riskFor('zypper', $package) === 'kernel';
}
/**
* Debian packages of a kernel have its release in the name (linux-modules-6.8.0-139-generic); RPM kernel packages
* have a fixed name and the version of the kernel.
*
* @param array{package: string, version: string, release: string} $kernel
*/
private static function kernelHasPackage(array $kernel, string $package, string $version): bool
{
if (str_ends_with($package, '-'.$kernel['release'])) {
return true;
}
return str_starts_with($package, 'kernel') && self::withoutEpoch($version) === self::withoutEpoch($kernel['version']);
}
private static function versionAtLeast(string $version, string $minimum): bool
{
return version_compare(self::withoutEpoch($version), self::withoutEpoch($minimum), '>=');
}
private static function withoutEpoch(string $version): string
{
return (string) preg_replace('/^\d+:/', '', $version);
}
public function parseRebootStatus(string $output): ?bool
{
return match (true) {
@@ -0,0 +1,64 @@
<?php
namespace App\Actions\Server;
use App\Models\Server;
use Illuminate\Support\Str;
use Lorisleiva\Actions\Concerns\AsAction;
use Spatie\Activitylog\Contracts\Activity;
/**
* Removes packages that nothing needs anymore, and old kernels. The package managers keep the running kernel.
*/
class RemoveUnusedPackages
{
use AsAction;
public string $jobQueue = 'high';
public function handle(Server $server, ?string $packageManager): Activity|array
{
$commands = self::commands($packageManager);
if ($commands === null) {
return ['error' => 'Coolify cannot remove unused packages on this operating system.'];
}
if ($server->serverStatus() === false) {
return ['error' => 'Server is not reachable or not ready.'];
}
$updateId = (string) Str::uuid();
$user = auth()->user();
return remote_process($commands, $server, type_uuid: $updateId, callEventOnFinish: 'ServerPackagesUpdateFinished', callEventData: [
'update_id' => $updateId,
'server_uuid' => $server->uuid,
'packages' => [],
'action' => 'remove_unused',
'package_manager' => $packageManager,
'actor_id' => $user?->id,
'actor_name' => $user?->name,
'actor_email' => $user?->email,
]);
}
public static function supports(?string $packageManager): bool
{
return self::commands($packageManager) !== null;
}
/**
* apt autoremove also removes old kernels. dnf autoremove does not, so old install-only packages (kernels) are
* removed separately; dnf fails when there are none. zypper removes old kernels with purge-kernels.
*
* @return list<string>|null Null when the package manager is not supported.
*/
public static function commands(?string $packageManager): ?array
{
return match ($packageManager) {
'apt' => ['env DEBIAN_FRONTEND=noninteractive apt-get -y autoremove'],
'dnf' => ['dnf autoremove -y', "sh -c 'dnf remove -y --oldinstallonly || true'"],
'zypper' => ['zypper --non-interactive purge-kernels'],
default => null,
};
}
}
+12 -13
View File
@@ -13,7 +13,10 @@ class UpdatePackage
public string $jobQueue = 'high';
public function handle(Server $server, string $osId, ?string $package = null, ?string $packageManager = null, bool $all = false): Activity|array
/**
* @param list<string> $packages Packages to update; ignored when $all is true.
*/
public function handle(Server $server, string $osId, array $packages = [], ?string $packageManager = null, bool $all = false): Activity|array
{
try {
if ($server->serverStatus() === false) {
@@ -22,25 +25,22 @@ class UpdatePackage
];
}
// Validate that package name is provided when not updating all packages
if (! $all && ($package === null || $package === '')) {
if (! $all && $packages === []) {
return [
'error' => "Package name required when 'all' is false.",
];
}
// Sanitize package name to prevent command injection
// Only allow alphanumeric characters, hyphens, underscores, periods, plus signs, and colons
// These are valid characters in package names across most package managers
$sanitizedPackage = '';
if ($package !== null && ! $all) {
if (! preg_match('/^[a-zA-Z0-9._+:-]+$/', $package)) {
// Only characters that are valid in package names of all supported package managers; no leading hyphen, so a name is never read as an option.
$packages = $all ? [] : array_values(array_unique($packages));
foreach ($packages as $package) {
if (! is_string($package) || ! preg_match('/^[a-zA-Z0-9][a-zA-Z0-9._+:-]*$/', $package)) {
return [
'error' => 'Invalid package name. Package names can only contain alphanumeric characters, hyphens, underscores, periods, plus signs, and colons.',
];
}
$sanitizedPackage = escapeshellarg($package);
}
$escapedPackages = implode(' ', array_map('escapeshellarg', $packages));
if (! $all && in_array($packageManager, CheckUpdates::FULL_UPGRADE_ONLY, true)) {
return [
@@ -48,7 +48,7 @@ class UpdatePackage
];
}
$commands = self::commands($packageManager, $all ? null : $sanitizedPackage);
$commands = self::commands($packageManager, $all ? null : $escapedPackages);
if ($commands === null) {
return [
'error' => 'OS not supported',
@@ -56,7 +56,6 @@ class UpdatePackage
}
$updateId = (string) Str::uuid();
$packages = $all ? [] : [$package];
$user = auth()->user();
return remote_process($commands, $server, type_uuid: $updateId, callEventOnFinish: 'ServerPackagesUpdateFinished', callEventData: [
@@ -77,7 +76,7 @@ class UpdatePackage
/**
* Update commands that never wait for input. apt keeps changed configuration files (confold) and uses the
* package default for unchanged ones (confdef). A null package updates all packages.
* package default for unchanged ones (confdef). A null package updates all packages. $escapedPackage can hold several escaped names, separated by spaces.
*
* @return array<int, string>|null
*/
+4 -2
View File
@@ -17,6 +17,7 @@ enum ImageScanErrorCode: string
case ImageMissing = 'image_missing';
case PullFailed = 'pull_failed';
case Truncated = 'truncated';
case NoPackageDatabase = 'no_package_database';
case Unknown = 'unknown';
/**
@@ -30,11 +31,11 @@ enum ImageScanErrorCode: string
$n = (string) ($context['n'] ?? '?');
return match ($this) {
self::DiskSpace => "Not enough disk space on {$server}: {$n} GB needed.",
self::DiskSpace => isset($context['n']) ? "Not enough disk space on {$server}: {$n} GB needed." : "Not enough disk space on {$server}.",
self::DbTooOld => "The vulnerability database is {$n} days old. {$server} cannot reach grype.anchore.io.",
self::DbMissing => "The vulnerability database is not on {$server} yet. Wait for the first update to finish or check that {$server} can reach grype.anchore.io.",
self::Timeout => 'The scan took too long and was stopped.',
self::OutOfMemory => 'The scanner needed more than 1 GB of memory for this image and was stopped.',
self::OutOfMemory => 'The scanner ran out of memory and was stopped. It can use up to 1 GB, and the server must have that much free memory.',
self::LockBusy => "Another scan is still running on {$server}.",
self::RegistryAuth => "Coolify cannot read this image from its registry. Log in to the registry on {$server}.",
self::BuildServerOff => 'Image scanning is off on the build server.',
@@ -43,6 +44,7 @@ enum ImageScanErrorCode: string
self::ImageMissing => "The image is not on {$server}. Click Scan now to pull and scan it. An image that Coolify builds needs a new deploy.",
self::PullFailed => "Coolify could not pull the image to {$server}. Check the image name, and log in to its registry on {$server} if it is private.",
self::Truncated => 'The scan result was too large to read completely.',
self::NoPackageDatabase => "Coolify found no package database of a supported package manager (dpkg, rpm, apk, pacman) on {$server}.",
self::Unknown => 'The scan could not run.',
};
}
+42 -4
View File
@@ -25,6 +25,9 @@ class SshMultiplexingHelper
/** After a failed scan (server offline), commands and the connection check do not scan again for this time. */
private const HOST_KEY_SCAN_RETRY_SECONDS = 600;
/** Enforce mode cannot connect without a pinned key, so it scans again sooner. The scan only reads the key, it trusts nothing more. */
private const HOST_KEY_SCAN_ENFORCE_RETRY_SECONDS = 60;
/**
* The warning that OpenSSH prints at the start of its own stderr when the server sends a host key that is not
* the pinned key: the banner, the fingerprint, the known_hosts lines, and the "is disabled to avoid
@@ -570,7 +573,8 @@ class SshMultiplexingHelper
return 'off';
}
return $mode === 'enforce' ? 'enforce' : 'warn';
// A team can make its own server stricter than the instance, never weaker than an instance "off".
return $mode === 'enforce' || data_get($server, 'settings.is_ssh_host_key_check_enforced') ? 'enforce' : 'warn';
}
/**
@@ -719,7 +723,7 @@ class SshMultiplexingHelper
/**
* Pin the host key that the server sends on the first connection (trust on first use).
* Nothing scans again within HOST_KEY_SCAN_RETRY_SECONDS of a failed scan. Commands also do not scan a server
* Nothing scans again within HOST_KEY_SCAN_RETRY_SECONDS (enforce mode: HOST_KEY_SCAN_ENFORCE_RETRY_SECONDS) of a failed scan. Commands also do not scan a server
* that is unreachable, so they do not wait for the scan timeout. The connection check passes
* $evenIfUnreachable, so the server learns its key when it is back. In enforce mode the check then fails with
* the scan error until the next scan.
@@ -739,7 +743,8 @@ class SshMultiplexingHelper
}
$scan = self::readHostKey($server);
if ($scan['key'] === null) {
Cache::put(self::hostKeyScanFailedCacheKey($server), $scan['error'], self::HOST_KEY_SCAN_RETRY_SECONDS);
$retrySeconds = self::hostKeyCheckingMode($server) === 'enforce' ? self::HOST_KEY_SCAN_ENFORCE_RETRY_SECONDS : self::HOST_KEY_SCAN_RETRY_SECONDS;
Cache::put(self::hostKeyScanFailedCacheKey($server), $scan['error'], $retrySeconds);
return;
}
@@ -790,13 +795,46 @@ class SshMultiplexingHelper
return true;
}
/**
* Read the saved host key settings into the settings in memory. A job that loaded the server before an admin
* enforced the check, trusted a new key, or reset the key must not connect with the old settings.
* Only the host key page changes these columns, and it saves them before it connects.
*/
private static function loadSavedHostKeySettings(Server $server): void
{
if (! $server->settings->exists) {
return;
}
$columns = ['ssh_host_key', 'ssh_host_key_source', 'ssh_host_key_trusted_at', 'ssh_host_key_pending', 'is_ssh_host_key_check_disabled', 'is_ssh_host_key_check_enforced'];
$stored = ServerSetting::query()->whereKey($server->settings->getKey())->first($columns);
if ($stored !== null) {
$server->settings->forceFill($stored->getAttributes())->syncOriginalAttributes($columns);
}
}
/**
* Stop the multiplexed connection after a change of the host key settings. The lock makes sure that no other
* process opens a connection with the old settings in the same moment: it opens it before the stop, or after it with the saved settings.
*/
public static function removeMuxFileAfterHostKeyChange(Server $server): void
{
try {
Cache::lock(self::connectionLockKey($server), config('constants.ssh.mux_lock_ttl'))
->block(config('constants.ssh.mux_lock_timeout'), fn () => self::removeMuxFile($server));
} catch (LockTimeoutException) {
self::removeMuxFile($server);
}
}
/** The key has the mode, so a failure of a warn mode scan does not stop an enforce mode scan for its longer retry time. */
private static function hostKeyScanFailedCacheKey(Server $server): string
{
return 'ssh_host_key_scan_failed_'.$server->uuid;
return 'ssh_host_key_scan_failed_'.self::hostKeyCheckingMode($server).'_'.$server->uuid;
}
private static function hostKeyOptions(Server $server): string
{
self::loadSavedHostKeySettings($server);
self::learnHostKey($server);
$mode = self::hostKeyCheckingMode($server);
+2
View File
@@ -164,6 +164,7 @@ class ImageRescanJob implements ShouldBeEncrypted, ShouldBeUnique, ShouldQueue
if ($listing === null) {
self::recordRescanResult($server, false);
$this->forgetProgress();
ScanHostPackagesJob::dispatch($server);
return;
}
@@ -198,6 +199,7 @@ class ImageRescanJob implements ShouldBeEncrypted, ShouldBeUnique, ShouldQueue
if (! $listing['truncated']) {
$this->cleanUp($server, $commands, $targets, $runningRefs);
}
ScanHostPackagesJob::dispatch($server);
}
/**
+42 -20
View File
@@ -22,8 +22,9 @@ use Illuminate\Support\Str;
* Pulls the pinned scanner images on a server and updates the Grype database.
*
* The progress is kept in the cache (`image-scan-prepare:{server_id}`: pulling, updating_database, ready,
* failed) and broadcast as ImageScanUpdated with the scan UUID `prepare`. The database update runs under the scan
* lock of the server; when it is busy, the job runs again later instead of waiting for it.
* failed) and broadcast as ImageScanUpdated with the scan UUID `prepare`. All steps run under the scan lock of the
* server, so a deletion of the scanner data cannot run at the same time; when the lock is busy, the job runs again
* later instead of waiting for it.
*/
class PrepareImageScannerJob implements ShouldBeEncrypted, ShouldBeUnique, ShouldQueue
{
@@ -129,6 +130,35 @@ class PrepareImageScannerJob implements ShouldBeEncrypted, ShouldBeUnique, Shoul
$commands = new ScannerCommands($server, Str::lower(Str::random(24)));
$this->setState($server, self::STATE_PULLING);
$lock = ScanImage::serverLock($server);
if (! ScanImage::acquireServerLock($lock, $server, 0)) {
if ($this->attempts() >= $this->tries) {
$this->setState($server, self::STATE_FAILED);
return;
}
$this->release(ScanResourceImagesJob::lockRetryDelay($this->attempts()));
return;
}
try {
$this->prepareLocked($server, $commands, $dbUrl);
} finally {
$lock->release();
}
}
/**
* Runs under the server lock, so a scan or a deletion of the scanner data cannot run at the same time.
*/
private function prepareLocked(Server $server, ScannerCommands $commands, ?string $dbUrl): void
{
// The scanner data can be deleted and scanning turned off before this job got the lock.
if (! $this->isStillEnabled($server)) {
return;
}
$pulled = self::run($commands->prepare(), $server, self::TIMEOUT_PREPARE);
if ($pulled['exit_codes'] === []) {
$this->setState($server, self::STATE_FAILED, self::FAILED_CONNECTION);
@@ -153,23 +183,7 @@ class PrepareImageScannerJob implements ShouldBeEncrypted, ShouldBeUnique, Shoul
}
$this->setState($server, self::STATE_UPDATING_DATABASE);
$lock = ScanImage::serverLock($server);
if (! ScanImage::acquireServerLock($lock, $server, 0)) {
if ($this->attempts() >= $this->tries) {
$this->setState($server, self::STATE_FAILED);
return;
}
$this->release(ScanResourceImagesJob::lockRetryDelay($this->attempts()));
return;
}
try {
$updated = self::run($commands->dbUpdate(is_string($dbUrl) && trim($dbUrl) !== '' ? trim($dbUrl) : null), $server, self::TIMEOUT_DB_UPDATE);
} finally {
$lock->release();
}
$updated = self::run($commands->dbUpdate(is_string($dbUrl) && trim($dbUrl) !== '' ? trim($dbUrl) : null), $server, self::TIMEOUT_DB_UPDATE);
if (($updated['exit_codes']['db-update'] ?? null) !== 0) {
$this->setState($server, self::STATE_FAILED);
@@ -194,7 +208,9 @@ class PrepareImageScannerJob implements ShouldBeEncrypted, ShouldBeUnique, Shoul
}
/**
* Caches the database build date and the disk use of the scanner data that the server page shows.
* Caches the database build date and the disk use of the scanner data that the server page shows. A valid
* database and all scanner images on the server prove that the scanner is ready, so this also marks it as
* ready, unless a preparation is running and sets the state itself.
*/
public static function storeDetails(Server $server, ScannerCommands $commands): void
{
@@ -207,6 +223,12 @@ class PrepareImageScannerJob implements ShouldBeEncrypted, ShouldBeUnique, Shoul
$built = is_array($report) && ($report['valid'] ?? false) === true ? ($report['built'] ?? null) : null;
if (is_string($built)) {
Cache::put(self::builtAtCacheKey($server), CarbonImmutable::parse($built)->toIso8601ZuluString(), now()->addDays(7));
if (! in_array(Cache::get(self::stateCacheKey($server)), [self::STATE_PULLING, self::STATE_UPDATING_DATABASE], true)
&& (self::run($commands->scannerImages(), $server, self::TIMEOUT_DB_STATUS)['exit_codes']['scanner-images'] ?? null) === 0) {
Cache::forget(self::failedStepCacheKey($server));
Cache::put(self::stateCacheKey($server), self::STATE_READY, now()->addDays(7));
ImageScanUpdated::dispatch($server->team_id, $server->uuid, self::SCAN_UUID);
}
}
} catch (\Throwable $e) {
report($e);
+98
View File
@@ -0,0 +1,98 @@
<?php
namespace App\Jobs;
use App\Actions\ImageScan\ScanHostPackages;
use App\Actions\ImageScan\ScanImage;
use App\Enums\ImageScanErrorCode;
use App\Events\ServerPatchCheckFinished;
use App\Models\Server;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
use Illuminate\Contracts\Queue\ShouldBeUniqueUntilProcessing;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Str;
/**
* Scans the OS packages of a server. A busy image scan lock runs the job again later; after the last try it records
* `LockBusy`. When it ends, the patches page reloads through `ServerPatchCheckFinished`.
*
* The unique lock ends when the job starts, so a scan requested during a running scan (for example, after a package
* update) is queued and runs after it, instead of being dropped.
*/
class ScanHostPackagesJob implements ShouldBeEncrypted, ShouldBeUniqueUntilProcessing, ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
private const RETRY_DELAY_SECONDS = 300;
public $tries = 6;
public $maxExceptions = 1;
/** A run never takes longer than the server scan lock lives, so the worker does not stop it before the cleanup. */
public $timeout = ScanImage::LOCK_TTL;
public $uniqueFor = ScanImage::LOCK_TTL;
public function __construct(public Server $server)
{
// Self-hosted Horizon works through 'high' first, so scans there would delay deployments.
$this->onQueue(isCloud() ? maintenance_queue() : 'default');
}
public function uniqueId(): string
{
return (string) $this->server->id;
}
public static function runningCacheKey(Server $server): string
{
return "server-host-scan-running:{$server->id}";
}
/**
* Queues a scan and marks it as running, so the patches page shows the progress after a reload. The mark lasts
* for the longest wait in the queue and the longest run. Each request writes a new token, so a scan that started
* before this request cannot clear the marker of this request.
*/
public static function dispatchManual(Server $server): void
{
Cache::put(self::runningCacheKey($server), (string) Str::uuid(), now()->addSeconds(2 * ScanImage::LOCK_TTL));
self::dispatch($server);
}
public function handle(): void
{
$server = $this->server->fresh(['settings']);
if (! $server instanceof Server) {
return;
}
$request = Cache::get(self::runningCacheKey($server));
if (! ScanHostPackages::run($server)) {
if ($this->attempts() < $this->tries) {
$this->release(self::RETRY_DELAY_SECONDS);
return;
}
ScanHostPackages::recordFailure($server, ImageScanErrorCode::LockBusy);
}
// A request made during this scan queued a new scan, which clears the marker when it ends.
if (Cache::get(self::runningCacheKey($server)) === $request) {
Cache::forget(self::runningCacheKey($server));
}
ServerPatchCheckFinished::dispatch($server->team_id, $server->uuid);
}
public function failed(): void
{
Cache::forget(self::runningCacheKey($this->server));
}
}
+8 -2
View File
@@ -4,12 +4,15 @@ namespace App\Listeners;
use App\Enums\ProcessStatus;
use App\Events\ServerPackagesUpdateFinished;
use App\Jobs\ImageRescanJob;
use App\Jobs\ScanHostPackagesJob;
use App\Jobs\ServerPatchCheckJob;
use App\Models\Server;
use Spatie\Activitylog\Models\Activity;
/**
* Records the result of a package update in the audit log and checks the server again.
* Records the result of a package update in the audit log, then checks the updates and the OS package
* vulnerabilities of the server again.
*/
class FinishServerPackagesUpdate
{
@@ -28,7 +31,7 @@ class FinishServerPackagesUpdate
'team_id' => $server->team_id,
'server_uuid' => $server->uuid,
'server_name' => $server->name,
'packages' => $packages === [] ? 'all' : $packages,
'packages' => ($event->data['action'] ?? null) === 'remove_unused' ? 'unused' : ($packages === [] ? 'all' : $packages),
'package_manager' => $event->data['package_manager'] ?? null,
'activity_id' => $activity?->id,
'result' => $status === ProcessStatus::FINISHED->value ? 'success' : 'failed',
@@ -39,5 +42,8 @@ class FinishServerPackagesUpdate
], $status === ProcessStatus::FINISHED->value ? 'info' : 'error');
ServerPatchCheckJob::dispatchManual($server);
if (ImageRescanJob::canRunOn($server)) {
ScanHostPackagesJob::dispatchManual($server);
}
}
}
@@ -0,0 +1,116 @@
<?php
namespace App\Livewire\Concerns;
use App\Actions\Server\RebootServer;
use App\Actions\Server\RemoveUnusedPackages;
use App\Actions\Server\UpdatePackage;
/**
* Package updates and the reboot of a server, from the saved result of the last patch check.
* The component must have a public `$server` and authorize `update` before it calls these methods.
*/
trait ManagesServerPackageUpdates
{
public function rebootServer()
{
try {
$this->authorize('update', $this->server);
$this->ensureManagedHere();
try {
RebootServer::run($this->server);
} catch (\Throwable $e) {
$this->auditPatchAction('reboot_requested', ['result' => 'failed', 'error' => class_basename($e)], 'error');
throw $e;
}
$this->auditPatchAction('reboot_requested', ['result' => 'started']);
$this->dispatch('success', 'Reboot started. The server is back in a few minutes, and Coolify then checks it again.');
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
/**
* Removes packages that nothing needs anymore and old kernels (apt autoremove or the equivalent).
*/
public function removeUnusedPackages()
{
try {
$this->authorize('update', $this->server);
$this->ensureManagedHere();
$packageManager = data_get($this->server->settings->patch_check_result, 'package_manager');
$activity = RemoveUnusedPackages::run($this->server, $packageManager);
if (is_array($activity)) {
$this->dispatch('error', message: $activity['error']);
return;
}
$this->auditPatchAction('packages_update_requested', [
'packages' => 'unused',
'package_manager' => $packageManager,
'activity_id' => $activity->id,
]);
$this->dispatch('startupdate');
$this->dispatch('activityMonitor', $activity->id);
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
/**
* @param list<string> $packages
* @return bool True when the update started.
*/
private function runUpdate(bool $all = false, array $packages = []): bool
{
$this->ensureManagedHere();
$savedResult = $this->server->settings->patch_check_result;
$packageManager = data_get($savedResult, 'package_manager');
$osId = data_get($savedResult, 'osId');
if (! $packageManager || ! $osId) {
$this->dispatch('error', message: 'Run "Check for updates" first.');
return false;
}
$activity = UpdatePackage::run(server: $this->server, osId: $osId, packages: $packages, packageManager: $packageManager, all: $all);
if (is_array($activity)) {
$this->dispatch('error', message: $activity['error'] ?? 'Something went wrong.');
return false;
}
$this->auditPatchAction('packages_update_requested', [
'packages' => $all ? 'all' : $packages,
'package_manager' => $packageManager,
'activity_id' => $activity->id,
]);
$this->dispatch('startupdate');
$this->dispatch('activityMonitor', $activity->id);
return true;
}
/**
* @param array<string, mixed> $context
*/
private function auditPatchAction(string $action, array $context, string $level = 'info'): void
{
auditLog("ui.server.{$action}", [
'team_id' => $this->server->team_id,
'server_uuid' => $this->server->uuid,
'server_name' => $this->server->name,
...$context,
], $level);
}
/**
* A transferred server is an archive: another instance manages the host.
*/
private function ensureManagedHere(): void
{
if ($this->server->isTransferredAway()) {
throw new \RuntimeException('This server is managed by another Coolify instance.');
}
}
}
+4
View File
@@ -47,6 +47,10 @@ class ScanSettings extends Component
#[Locked]
public ?string $scopeUuid = null;
/** Renders the rules without their own card, for a page that puts them inside another card. */
#[Locked]
public bool $embedded = false;
public ?string $gateMode = '';
public ?string $onScanError = '';
@@ -2,6 +2,7 @@
namespace App\Livewire\ImageScan;
use App\Actions\ImageScan\ScanImage;
use App\Actions\ImageScan\SetServerImageScanning;
use App\Jobs\PrepareImageScannerJob;
use App\Models\InstanceSettings;
@@ -95,15 +96,25 @@ class ServerImageScanning extends Component
{
$this->authorize('update', $this->server);
$lock = ScanImage::serverLock($this->server);
try {
SetServerImageScanning::ensureManagedHere($this->server);
if (! ScanImage::acquireServerLock($lock, $this->server, 0)) {
$this->dispatch('error', message: 'A scan or a scanner update is running on this server. Try again when it is complete.');
return;
}
$commands = new ScannerCommands($this->server, Str::lower(Str::random(24)));
instant_remote_process($commands->deleteScannerData(), $this->server, throwError: true, timeout: 120);
PrepareImageScannerJob::forget($this->server);
// Records the server as empty, so the page can hide the delete action until the data comes back.
Cache::put(PrepareImageScannerJob::diskUsedCacheKey($this->server), 0, now()->addDays(7));
$this->auditImageScanning('image_scanner_data_deleted');
$this->dispatch('success', 'Scanner data deleted.');
} catch (\Throwable $e) {
return handleError($e, $this, 'Could not delete the scanner data.');
} finally {
$lock->release();
}
}
@@ -133,6 +144,8 @@ class ServerImageScanning extends Component
->unique()
->implode(', '),
'diskUsed' => is_int($diskUsed) ? formatBytes($diskUsed, 1) : null,
// Hide the delete action only when the server is known to be empty. Missing cache data can still mean data on disk.
'hasScannerData' => is_string($state) || $diskUsed !== 0,
'toolVersions' => collect(['syft' => 'Syft', 'grype' => 'Grype', 'regctl' => 'regctl'])
->map(fn (string $name, string $tool) => $name.' '.config("constants.image_scan.images.{$tool}.version"))
->implode(', '),
+3 -2
View File
@@ -61,7 +61,8 @@ class Vulnerabilities extends Component
private const EXPIRES = ['30', '90', 'never'];
private const CHECKPOINTS = ['Get scanner', 'Update database', 'Read image', 'Find vulnerabilities'];
/** "Start scan" covers the wait in the queue and for the scan lock of the server; it does not get the scanner. */
private const CHECKPOINTS = ['Start scan', 'Update database', 'Read image', 'Find vulnerabilities'];
/** Progress stage of ScanImage => index in CHECKPOINTS. */
private const STAGE_CHECKPOINT = [ScanResourceImagesJob::QUEUED_STAGE => 0, 'database' => 1, 'pull' => 2, 'inspect' => 2, 'sbom' => 2, 'vulnerabilities' => 3];
@@ -959,7 +960,7 @@ class Vulnerabilities extends Component
'offState' => $offState,
'serverName' => (string) ($server?->name ?? 'this server'),
'serverUrl' => $server instanceof Server && $user->can('update', $server)
? route('server.security.image-scanning', ['server_uuid' => $server->uuid])
? route('server.security.vulnerabilities', ['server_uuid' => $server->uuid])
: null,
'canUpdate' => $canUpdate,
];
+29 -3
View File
@@ -19,6 +19,9 @@ class HostKey extends Component
public array $parameters = [];
/** The dropdown value: "warn" or "enforce". */
public string $checkMode = 'warn';
public function mount(string $server_uuid)
{
try {
@@ -68,20 +71,41 @@ class HostKey extends Component
{
return $this->changeHostKey($password, $disabled ? 'check_disabled' : 'check_enabled', fn (ServerSetting $settings) => $settings->forceFill([
'is_ssh_host_key_check_disabled' => $disabled,
'is_ssh_host_key_check_enforced' => $disabled ? false : $settings->is_ssh_host_key_check_enforced,
]));
}
/**
* Refuses a changed host key on this server ("enforce"), also when the instance only warns, or follows the instance
* again ("warn"). Saved at once from the dropdown, without a password: warn still reports a changed key.
* Refused while the check is disabled, so that a page open in another tab cannot turn the check back on without a password.
*/
public function setHostKeyCheckMode(string $mode): bool|string|Event
{
if (! in_array($mode, ['warn', 'enforce'], true)) {
return false;
}
$enforced = $mode === 'enforce';
return $this->changeHostKey(null, $enforced ? 'check_enforced' : 'check_not_enforced', function (ServerSetting $settings) use ($enforced) {
if ($settings->is_ssh_host_key_check_disabled) {
throw new \RuntimeException('The host key check is disabled for this server. Reload the page and enable the check first.');
}
$settings->forceFill(['is_ssh_host_key_check_enforced' => $enforced]);
}, confirmPassword: false);
}
/**
* @param callable(ServerSetting): mixed $change
*/
private function changeHostKey(?string $password, string $action, callable $change): bool|string|Event
private function changeHostKey(?string $password, string $action, callable $change, bool $confirmPassword = true): bool|string|Event
{
try {
$this->authorize('update', $this->server);
if (! $this->server->refresh()->canBeValidated()) {
throw new \RuntimeException('This server was transferred to another Coolify instance.');
}
if (! verifyPasswordConfirmation($password, $this)) {
if ($confirmPassword && ! verifyPasswordConfirmation($password, $this)) {
return 'The provided password is incorrect.';
}
@@ -98,7 +122,7 @@ class HostKey extends Component
'fingerprints' => ServerSetting::sshHostKeyFingerprints($settings->ssh_host_key),
]);
SshMultiplexingHelper::removeMuxFile($this->server);
SshMultiplexingHelper::removeMuxFileAfterHostKeyChange($this->server);
// The check can take up to a minute on a slow server, so it runs in the queue and the page polls for the result.
ServerConnectionCheckJob::dispatch($this->server);
$this->dispatch('hostKeySettingsChanged')->to(Index::class);
@@ -113,9 +137,11 @@ class HostKey extends Component
public function render(): View
{
$settings = $this->server->settings;
$this->checkMode = $settings->is_ssh_host_key_check_enforced ? 'enforce' : 'warn';
return view('livewire.server.security.host-key', [
'mode' => SshMultiplexingHelper::hostKeyCheckingMode($this->server),
'instanceMode' => config('constants.ssh.host_key_checking'),
'fingerprints' => ServerSetting::sshHostKeyFingerprints($settings->ssh_host_key),
'pendingFingerprints' => ServerSetting::sshHostKeyFingerprints($settings->ssh_host_key_pending),
]);
@@ -0,0 +1,229 @@
<?php
namespace App\Livewire\Server\Security;
use App\Actions\Server\CheckUpdates;
use App\Jobs\ImageRescanJob;
use App\Jobs\ScanHostPackagesJob;
use App\Livewire\Concerns\ManagesServerPackageUpdates;
use App\Models\Server;
use App\Services\ImageScan\Finding;
use App\Traits\ListensToTeamChannel;
use Illuminate\Contracts\View\View;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\Cache;
use Livewire\Attributes\Locked;
use Livewire\Component;
use Livewire\WithPagination;
/**
* Vulnerabilities in the operating system packages of a server (ScanHostPackages).
*/
class HostVulnerabilities extends Component
{
use AuthorizesRequests;
use ListensToTeamChannel;
use ManagesServerPackageUpdates;
use WithPagination;
public Server $server;
#[Locked]
public bool $hostScanRunning = false;
private const SORT_COLUMNS = ['vulnerability', 'package', 'fixed', 'severity'];
private const SEVERITY_ORDER = ['unknown' => 0, 'low' => 1, 'medium' => 2, 'high' => 3, 'critical' => 4];
public int $perPage = 10;
/** `<column>_<asc|desc>`, or empty for the stored order: exploited first, then fixable, then risk. */
#[Locked]
public string $sortBy = '';
public string $search = '';
/** Shows the findings in installed kernels that the server does not run; they are no risk now, so they are hidden. */
public bool $showOtherKernels = false;
public function sortFindings(string $sortBy): void
{
[$column, $direction] = array_pad(explode('_', $sortBy, 2), 2, null);
if (! in_array($column, self::SORT_COLUMNS, true) || ! in_array($direction, ['asc', 'desc'], true)) {
return;
}
$this->sortBy = $sortBy;
$this->resetPage();
}
/**
* @param list<Finding> $findings
* @return list<Finding>
*/
private function sortedFindings(array $findings): array
{
if ($this->sortBy === '') {
return $findings;
}
[$column, $direction] = explode('_', $this->sortBy, 2);
$compare = match ($column) {
'vulnerability' => fn (Finding $a, Finding $b): int => strnatcasecmp($a->displayId, $b->displayId),
'package' => fn (Finding $a, Finding $b): int => strnatcasecmp($a->package, $b->package),
'fixed' => fn (Finding $a, Finding $b): int => version_compare($a->fixedIn ?? '', $b->fixedIn ?? ''),
'severity' => fn (Finding $a, Finding $b): int => (self::SEVERITY_ORDER[$a->severity] ?? 0) <=> (self::SEVERITY_ORDER[$b->severity] ?? 0),
};
// Findings without a fix stay last in both directions; usort keeps the stored order for equal values.
usort($findings, function (Finding $a, Finding $b) use ($column, $compare, $direction): int {
if ($column === 'fixed' && ($a->fixedIn === null) !== ($b->fixedIn === null)) {
return $a->fixedIn === null ? 1 : -1;
}
return $direction === 'asc' ? $compare($a, $b) : $compare($b, $a);
});
return $findings;
}
public function updatedShowOtherKernels(): void
{
$this->resetPage();
}
public function updatedSearch(): void
{
$this->resetPage();
}
public function updatedPerPage(): void
{
$this->perPage = max(1, min(100, $this->perPage));
$this->resetPage();
}
public function getListeners(): array
{
return $this->teamChannelListeners([
'ServerPatchCheckFinished' => 'scanFinished',
]);
}
public function mount(string $server_uuid): void
{
$this->server = Server::ownedByCurrentTeam()->whereUuid($server_uuid)->firstOrFail();
$this->authorize('viewSecurity', $this->server);
$this->loadResult();
}
public function loadResult(): void
{
$this->server->settings->refresh();
$this->hostScanRunning = Cache::has(ScanHostPackagesJob::runningCacheKey($this->server));
}
public function scanFinished(array $event = []): void
{
$serverUuid = data_get($event, 'serverUuid');
if ($serverUuid !== null && $serverUuid !== $this->server->uuid) {
return;
}
$this->loadResult();
}
public function scanHostPackages(): mixed
{
try {
$this->authorize('update', $this->server);
if ($this->server->isTransferredAway()) {
throw new \RuntimeException('This server is managed by another Coolify instance.');
}
if (! ImageRescanJob::canRunOn($this->server)) {
$this->dispatch('error', message: 'Turn on image scanning for this server first.');
return null;
}
ScanHostPackagesJob::dispatchManual($this->server);
$this->hostScanRunning = true;
return null;
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
/**
* Updates the package of a host finding with the updates that the last patch check found for it.
*/
public function updateVulnerablePackage(string $package): mixed
{
try {
$this->authorize('update', $this->server);
$isFinding = collect($this->server->hostScan()->first()?->findingList() ?? [])->contains(fn ($finding) => $finding->package === $package);
$packages = array_column(CheckUpdates::fixingUpdates($package, $this->server->settings->patch_check_result), 'package');
if (! $isFinding || $packages === []) {
$this->dispatch('error', message: 'This package has no update in the last check. Run "Check for updates" on the Patching page.');
return null;
}
$this->runUpdate(packages: $packages);
return null;
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function render(): View
{
$hostScan = $this->server->hostScan()->first();
$patchResult = $this->server->settings->patch_check_result;
// The kernel that ran during the scan; the patch check can be older, for example from before a reboot.
$runningKernel = data_get($hostScan?->summary, 'running_kernel') ?? data_get($patchResult, 'kernels.running');
if (is_array($patchResult) && $runningKernel !== null) {
data_set($patchResult, 'kernels.running', $runningKernel);
}
[$otherKernelFindings, $activeFindings] = collect($hostScan?->findingList() ?? [])
->partition(fn ($finding) => CheckUpdates::isOtherKernelPackage($finding->package, $finding->version, $runningKernel))
->map(fn ($findings) => $findings->values()->all());
$hostFindings = $this->showOtherKernels ? [...$activeFindings, ...$otherKernelFindings] : $activeFindings;
$installedKernels = collect(data_get($patchResult, 'kernels.installed', []));
$runningKernelVersion = data_get($installedKernels->firstWhere('release', $runningKernel), 'version');
$search = trim($this->search);
$sortedFindings = $this->sortedFindings($hostFindings);
$matchingFindings = $search === '' ? $sortedFindings : array_values(array_filter(
$sortedFindings,
fn ($finding) => str(implode(' ', [$finding->displayId, $finding->id, ...$finding->aliases, $finding->package, $finding->title]))
->contains($search, ignoreCase: true),
));
$this->perPage = max(1, min(100, $this->perPage));
$lastPage = max(1, (int) ceil(count($matchingFindings) / $this->perPage));
$page = max(1, min((int) $this->getPage(), $lastPage));
if ($page !== $this->getPage()) {
$this->setPage($page);
}
$kernelStatusCounts = collect($activeFindings)
->map(fn ($finding) => CheckUpdates::kernelStatus($finding->package, $finding->version, $finding->fixedIn, $patchResult))
->filter()
->countBy();
return view('livewire.server.security.host-vulnerabilities', [
'kernelStatusCounts' => $kernelStatusCounts,
'otherKernelCount' => max(count($otherKernelFindings), (int) data_get($hostScan?->summary, 'other_kernels', 0)),
'hasOldKernels' => $runningKernelVersion !== null && $installedKernels->contains(fn ($kernel) => version_compare($kernel['version'], $runningKernelVersion, '<')),
'runningKernel' => $runningKernel,
'bootKernel' => data_get(CheckUpdates::bootKernel($patchResult), 'release'),
'hostScan' => $hostScan,
'hostFindings' => $hostFindings,
'shownHostFindings' => new LengthAwarePaginator(
collect($matchingFindings)->forPage($page, $this->perPage)->values(),
count($matchingFindings),
$this->perPage,
$page,
),
'hostScanAvailable' => ImageRescanJob::canRunOn($this->server),
'result' => $patchResult,
]);
}
}
+1 -1
View File
@@ -54,7 +54,7 @@ class Index extends Component
}
$this->refreshServer();
if ($this->server->isFunctional() !== $this->wasFunctionalBeforeConnectionCheck) {
// Load the page again: the morph cannot insert the patching section, and the navbar shows the old status.
// Load the page again: the navbar and the sidebar show the old status.
$this->redirectRoute('server.security', ['server_uuid' => $this->server->uuid], navigate: true);
return;
+93 -74
View File
@@ -3,22 +3,25 @@
namespace App\Livewire\Server\Security;
use App\Actions\Server\ConfigureAutomaticSecurityUpdates;
use App\Actions\Server\RebootServer;
use App\Actions\Server\UpdatePackage;
use App\Jobs\ServerPatchCheckJob;
use App\Livewire\Concerns\ManagesServerPackageUpdates;
use App\Models\Server;
use App\Notifications\Server\ServerPatchCheck;
use App\Traits\ListensToTeamChannel;
use Cron\CronExpression;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\Cache;
use Livewire\Attributes\Locked;
use Livewire\Component;
use Livewire\WithPagination;
class Patches extends Component
{
use AuthorizesRequests;
use ListensToTeamChannel;
use ManagesServerPackageUpdates;
use WithPagination;
public Server $server;
@@ -41,6 +44,28 @@ class Patches extends Component
public string $autoSecurityUpdatesTime = '03:00';
/** Package names are short, so a longer search can match nothing. */
private const SEARCH_MAX_LENGTH = 100;
public int $perPage = 10;
public string $search = '';
/** @var list<string> Packages of the last check that the user selected to update. */
public array $selectedPackages = [];
public function updatedSearch(): void
{
$this->search = mb_substr($this->search, 0, self::SEARCH_MAX_LENGTH);
$this->resetPage();
}
public function updatedPerPage(): void
{
$this->perPage = max(1, min(100, $this->perPage));
$this->resetPage();
}
public function getListeners()
{
return $this->teamChannelListeners([
@@ -69,6 +94,45 @@ class Patches extends Component
$this->result = $this->server->settings->patch_check_result;
$this->error = $this->server->settings->patch_check_error;
$this->checking = Cache::has(ServerPatchCheckJob::runningCacheKey($this->server));
$this->selectedPackages = array_values(array_intersect($this->selectedPackages, $this->savedPackages()));
}
/**
* @return list<string>
*/
private function savedPackages(): array
{
return collect(data_get($this->server->settings->patch_check_result, 'updates', []))->pluck('package')->all();
}
/**
* Selects all packages that match the search, or clears the selection when they are all selected.
*/
public function toggleAllPackages(): void
{
$search = trim($this->search);
$matching = array_values(array_filter($this->savedPackages(), fn (string $package) => $search === '' || str($package)->contains($search, ignoreCase: true)));
$this->selectedPackages = array_diff($matching, $this->selectedPackages) === []
? array_values(array_diff($this->selectedPackages, $matching))
: array_values(array_unique([...$this->selectedPackages, ...$matching]));
}
public function updateSelectedPackages()
{
try {
$this->authorize('update', $this->server);
$packages = array_values(array_intersect($this->savedPackages(), $this->selectedPackages));
if ($packages === []) {
$this->dispatch('error', message: 'Select the packages to update.');
return;
}
if ($this->runUpdate(packages: $packages)) {
$this->selectedPackages = [];
}
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function patchCheckFinished(array $event = []): void
@@ -106,31 +170,12 @@ class Patches extends Component
{
try {
$this->authorize('update', $this->server);
$savedPackages = collect(data_get($this->server->settings->patch_check_result, 'updates', []))->pluck('package');
if (! $savedPackages->contains($package)) {
if (! in_array($package, $this->savedPackages(), true)) {
$this->dispatch('error', message: 'This package has no update in the last check.');
return;
}
$this->runUpdate(package: $package);
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function rebootServer()
{
try {
$this->authorize('update', $this->server);
try {
RebootServer::run($this->server);
} catch (\Throwable $e) {
$this->auditPatchAction('reboot_requested', ['result' => 'failed', 'error' => class_basename($e)], 'error');
throw $e;
}
$this->auditPatchAction('reboot_requested', ['result' => 'started']);
$this->dispatch('success', 'Reboot started. The server is back in a few minutes, and Coolify then checks it again.');
$this->runUpdate(packages: [$package]);
} catch (\Throwable $e) {
return handleError($e, $this);
}
@@ -237,56 +282,6 @@ class Patches extends Component
return true;
}
/**
* @param array<string, mixed> $context
*/
private function auditPatchAction(string $action, array $context, string $level = 'info'): void
{
auditLog("ui.server.{$action}", [
'team_id' => $this->server->team_id,
'server_uuid' => $this->server->uuid,
'server_name' => $this->server->name,
...$context,
], $level);
}
/**
* A transferred server is an archive: another instance manages the host.
*/
private function ensureManagedHere(): void
{
if ($this->server->isTransferredAway()) {
throw new \RuntimeException('This server is managed by another Coolify instance.');
}
}
private function runUpdate(bool $all = false, ?string $package = null): void
{
$this->ensureManagedHere();
$savedResult = $this->server->settings->patch_check_result;
$packageManager = data_get($savedResult, 'package_manager');
$osId = data_get($savedResult, 'osId');
if (! $packageManager || ! $osId) {
$this->dispatch('error', message: 'Run "Check for updates" first.');
return;
}
$activity = UpdatePackage::run(server: $this->server, osId: $osId, package: $package, packageManager: $packageManager, all: $all);
if (is_array($activity)) {
$this->dispatch('error', message: $activity['error'] ?? 'Something went wrong.');
return;
}
$this->auditPatchAction('packages_update_requested', [
'packages' => $all ? 'all' : [$package],
'package_manager' => $packageManager,
'activity_id' => $activity->id,
]);
$this->dispatch('startupdate');
$this->dispatch('activityMonitor', $activity->id);
}
public function sendTestEmail()
{
if (! isDev()) {
@@ -324,6 +319,30 @@ class Patches extends Component
public function render()
{
return view('livewire.server.security.patches');
$updates = collect(data_get($this->result, 'updates', []))
->sortBy(fn ($update) => [data_get($update, 'security') ? 0 : 1, filled(data_get($update, 'risk')) ? 0 : 1, data_get($update, 'package')])
->values();
$search = trim($this->search);
$matchingUpdates = $search === '' ? $updates : $updates
->filter(fn ($update) => str(data_get($update, 'package'))->contains($search, ignoreCase: true))
->values();
$this->perPage = max(1, min(100, $this->perPage));
$lastPage = max(1, (int) ceil($matchingUpdates->count() / $this->perPage));
$page = max(1, min((int) $this->getPage(), $lastPage));
if ($page !== $this->getPage()) {
$this->setPage($page);
}
return view('livewire.server.security.patches', [
'updates' => $updates->all(),
'allMatchingSelected' => $matchingUpdates->isNotEmpty() && $matchingUpdates->pluck('package')->diff($this->selectedPackages)->isEmpty(),
'shownUpdates' => new LengthAwarePaginator(
$matchingUpdates->forPage($page, $this->perPage)->values(),
$matchingUpdates->count(),
$this->perPage,
$page,
),
]);
}
}
+6
View File
@@ -36,6 +36,7 @@ use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Http\Exceptions\ThrottleRequestsException;
@@ -724,6 +725,11 @@ class Server extends BaseModel
return $this->hasMany(ImageSbom::class);
}
public function hostScan(): HasOne
{
return $this->hasOne(ServerHostScan::class);
}
public function dockerCleanupExecutions()
{
return $this->hasMany(DockerCleanupExecution::class);
+62
View File
@@ -0,0 +1,62 @@
<?php
namespace App\Models;
use App\Enums\ImageScanErrorCode;
use App\Services\ImageScan\Finding;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* The last scan of the OS packages of a server, one row per server. A failed scan sets `error_code` and keeps the
* findings of the last completed scan (`scanned_at`). `error_context` holds the values of the error message.
*
* @property ?array{critical: int, high: int, medium: int, low: int, unknown: int, fixable: int, exploited: int, likely: int, other_kernels?: int, total: int, running_kernel?: string} $summary
*/
class ServerHostScan extends Model
{
use HasFactory;
/** Relevant findings that are stored; the summary counts all of them. */
public const MAX_STORED_FINDINGS = 500;
protected $fillable = [
'server_id',
'error_code',
'error_context',
'eol',
'summary',
'findings',
'findings_truncated',
'grype_db_built_at',
'scanned_at',
];
protected function casts(): array
{
return [
'error_code' => ImageScanErrorCode::class,
'error_context' => 'array',
'eol' => 'array',
'summary' => 'array',
'findings' => 'array',
'findings_truncated' => 'boolean',
'grype_db_built_at' => 'datetime',
'scanned_at' => 'datetime',
];
}
public function server(): BelongsTo
{
return $this->belongsTo(Server::class);
}
/**
* @return list<Finding>
*/
public function findingList(): array
{
return array_map(fn (array $finding) => Finding::fromArray($finding), $this->findings ?? []);
}
}
+2
View File
@@ -49,6 +49,7 @@ use OpenApi\Attributes as OA;
'ssh_host_key_trusted_at' => ['type' => 'string', 'format' => 'date-time', 'nullable' => true],
'ssh_host_key_pending' => ['type' => 'string', 'nullable' => true, 'description' => 'Host key that the server sent when it did not match the pinned key.'],
'is_ssh_host_key_check_disabled' => ['type' => 'boolean'],
'is_ssh_host_key_check_enforced' => ['type' => 'boolean', 'description' => 'Refuse a changed host key on this server, also when the instance only warns.'],
'ssh_host_key_fingerprints' => ['type' => 'array', 'items' => ['type' => 'string'], 'readOnly' => true, 'description' => 'SHA256 fingerprints of the pinned host keys, as `ssh-keygen -lf` shows them.'],
'ssh_host_key_pending_fingerprints' => ['type' => 'array', 'items' => ['type' => 'string'], 'readOnly' => true, 'description' => 'SHA256 fingerprints of the host key that did not match.'],
'is_sentinel_enabled' => ['type' => 'boolean'],
@@ -233,6 +234,7 @@ class ServerSetting extends Model
'image_candidate_scans_date' => 'date',
'ssh_host_key_trusted_at' => 'datetime',
'is_ssh_host_key_check_disabled' => 'boolean',
'is_ssh_host_key_check_enforced' => 'boolean',
];
/**
@@ -17,7 +17,7 @@ class ServerPatchCheck extends CustomEmailNotification
public function __construct(public Server $server, public array $patchData)
{
$this->onQueue('high');
$this->serverUrl = base_url().'/server/'.$this->server->uuid.'/security#server-patching-overview-section';
$this->serverUrl = base_url().'/server/'.$this->server->uuid.'/security/patches';
}
public function via(object $notifiable): array
+118 -4
View File
@@ -23,7 +23,7 @@ use InvalidArgumentException;
* memory limit stopped it (line `coolify-scan-oom {step}`, before the exit marker), and the container is removed in
* the same script. Only that line means "out of memory"; a 137 without it was a `timeout` kill. Steps: `pull-syft`,
* `pull-grype`, `pull-regctl` (prepare), `pull` (pull), `db-update`, `db-status`, `disk-db` and `disk-sboms` (diskUsage, size = bytes), `inspect`, `save` and `sbom`
* (sbomFromLocal), `sbom` (sbomFromRegistry), `grype`, `packages`, `tags`, `digest`.
* (sbomFromLocal), `sbom` (sbomFromRegistry), `copy` and `sbom` (sbomFromHost), `grype`, `packages`, `tags`, `digest`.
* - A failed run does not stop the batch, and the container and the work directory are still removed.
* - Methods with a payload print the line `coolify-scan-payload` after the markers, then at most 10485761
* bytes of the tool output (`head -c`), so the output is cut when `bytes > OUTPUT_LIMIT`. The file that holds the
@@ -63,6 +63,32 @@ class ScannerCommands
public const OOM_MARKER = 'coolify-scan-oom';
/** SBOM name of the server OS packages (`sboms/host.json`); image SBOMs are named by their 64-character ID. */
public const HOST_SBOM = 'host';
/** The os-release files and the package databases that the OS catalogers read. */
private const HOST_PACKAGE_PATHS = [
'/etc/os-release',
'/usr/lib/os-release',
'/var/lib/dpkg/status',
'/var/lib/dpkg/status.d',
'/lib/apk/db/installed',
'/var/lib/rpm',
'/usr/lib/sysimage/rpm',
'/var/lib/pacman/local',
];
private const HOST_PACKAGE_DATABASES = [
'/var/lib/dpkg/status',
'/var/lib/dpkg/status.d',
'/lib/apk/db/installed',
'/var/lib/rpm',
'/usr/lib/sysimage/rpm',
'/var/lib/pacman/local',
];
private const HOST_CATALOGERS = 'dpkg-db-cataloger,rpm-db-cataloger,apk-db-cataloger,alpm-db-cataloger';
private const SCANNER_USER = '65534:65534';
private const TIMEOUT_SBOM = 600;
@@ -104,6 +130,18 @@ class ScannerCommands
];
}
/**
* Checks that all scanner images are on the server. `docker image inspect` fails when one of them is missing.
*
* @return list<string>
*/
public function scannerImages(): array
{
$images = implode(' ', array_map(fn (string $tool) => escapeshellarg($this->image($tool)), ['syft', 'grype', 'regctl', 'init']));
return [$this->removeContainer(), $this->step('scanner-images', "docker image inspect {$images} >/dev/null 2>&1")];
}
/**
* @return list<string>
*/
@@ -224,6 +262,53 @@ class ScannerCommands
];
}
/**
* Prints `coolify-scan-exit packages-size {code} {KiB}`: the size of the host package databases that `sbomFromHost()`
* copies (`du -k`: BusyBox has no `-b`). Missing paths are skipped; the code is 1 when a path that exists cannot be measured.
*
* @return list<string>
*/
public function hostPackagesSize(): array
{
$paths = implode(' ', array_map(escapeshellarg(...), self::HOST_PACKAGE_PATHS));
return [$this->shell('total=0; failed=0; for p in '.$paths.'; do if [ -e "$p" ]; then if out=$(du -skL "$p" 2>/dev/null); then total=$((total + ${out%%[[:space:]]*})); else failed=1; fi; fi; done; echo "'.self::MARKER.' packages-size $failed $total"')];
}
/**
* Writes the SBOM of the OS packages of the server to `sboms/host.json`. Only the package databases are copied
* (`cp -RL`, so the copy has no links), then Syft reads the copy without network with the OS package catalogers.
* Prints `coolify-scan-exit copy {code}` (1 when no package database was found, 2 when a copy failed, so a partial
* database never reads as a complete scan) and the `sbom` marker.
*
* @return list<string>
*/
public function sbomFromHost(): array
{
$host = $this->workDir().'/in/host';
$copies = array_map(function (string $path) use ($host): string {
$source = escapeshellarg($path);
$target = escapeshellarg($host.$path);
$skipLink = $path === '/var/lib/rpm' ? " && [ ! -L {$source} ]" : '';
return "if [ -e {$source} ]{$skipLink}; then mkdir -p \"\$(dirname {$target})\" && cp -RL {$source} {$target} 2>/dev/null || failed=1; fi";
}, self::HOST_PACKAGE_PATHS);
$found = implode(' ', array_map(fn (string $path) => escapeshellarg($host.$path), self::HOST_PACKAGE_DATABASES));
$copied = "found=0; for p in {$found}; do if [ -e \"\$p\" ]; then found=1; fi; done; if [ \$failed -eq 1 ]; then echo '".self::MARKER." copy 2'; elif [ \$found -eq 1 ]; then echo '".self::MARKER." copy 0'; else echo '".self::MARKER." copy 1'; fi";
return [
$this->removeContainer(),
'rm -rf '.escapeshellarg($this->workDir()).' || true',
...$this->sbomDirectory(),
'rm -f '.escapeshellarg($this->sbomTmpPath(self::HOST_SBOM)).' || true',
'mkdir -p -m 0700 '.escapeshellarg($host).' '.escapeshellarg($this->workDir().'/scratch'),
$this->shell(implode('; ', ['failed=0', ...$copies, $copied])),
...$this->ownWorkDir(),
$this->writeSbom(self::HOST_SBOM, $this->dockerRun('syft', self::TIMEOUT_SBOM, false, [], ['type=bind,src='.$this->workDir().'/in,dst=/in,readonly'], ['scan', 'dir:/in/host', '--override-default-catalogers', self::HOST_CATALOGERS, '-o', 'syft-json', '-q'])),
...$this->finish(['rm -f '.escapeshellarg($this->sbomTmpPath(self::HOST_SBOM)).' || true']),
];
}
/**
* Writes the SBOM of a registry image to `sboms/{outName}.json` (network on, no socket).
*
@@ -261,7 +346,7 @@ class ScannerCommands
$this->databaseMount(),
$this->sbomMount($sbomName),
'type=bind,src='.$this->workDir().'/tpl,dst=/tpl,readonly',
], ['sbom:/sbom/sbom.json', '-o', 'template', '-t', '/tpl/grype.tmpl', '-q']), 'grype.tmpl');
], ['sbom:/sbom/sbom.json', '-o', 'template', '-t', '/tpl/grype.tmpl', '-q']), 'grype.tmpl', hostFilter: $sbomName === self::HOST_SBOM);
}
/**
@@ -529,12 +614,41 @@ class ScannerCommands
* Runs a tool whose stdout is the payload: stdout goes to a root-owned file in the work directory
* (not mounted into the container), then at most 10485761 bytes of it are printed.
*
* With $hostFilter the output keeps one line per vulnerability ID, package, package version, fix state, and fix
* versions, and drops the problems that the distribution will not fix, before the size is counted. All packages of
* the kernel source (`linux*`, `kernel*`) count as one package: the binary packages of one kernel repeat the same
* thousands of problems. Of these, the line of the kernel image (`linux-image-*`, `linux-modules-*`, `kernel`,
* `kernel-core`) is kept, because only that package has the kernel update and the kernel state. Each installed kernel
* keeps its own line (the version is in the key), so a problem of the running kernel is not hidden behind the same
* problem of an old one, and a fixed match is never hidden behind an unfixed one. The kept lines stay in the order
* of the output. Grype's own `--ignore-states` needs more than the memory limit. In the JSON lines a quote inside a
* value is escaped, so the `"id":"…"`, `"pkg":"…"`, and `"fix":"wont-fix"` matches only find keys. The filter runs in the pipe, before
* the file limit, because the raw output of a host can be larger than the limit (57 MB on CentOS Stream 9); the exit
* code of the run goes through `out.rc`, as `sh` has no `pipefail`. When the run succeeded, the exit code of `awk` is
* the exit code of the step, so a write failure (a full disk) fails the scan instead of storing a short report; a stop
* at the file limit still reads as cut output.
*
* @param array<string, mixed>|null $auth
* @return list<string>
*/
private function capture(string $step, string $dockerRun, ?string $template = null, ?array $auth = null): array
private function capture(string $step, string $dockerRun, ?string $template = null, ?array $auth = null, bool $hostFilter = false): array
{
$out = escapeshellarg($this->workDir().'/out');
$dedupe = implode(' ', [
'{ if (!match($0, /"id":"[^"]*"/)) { line[++n] = $0; next }',
'k = substr($0, RSTART, RLENGTH); image = 0;',
'if (match($0, /"pkg":"[^"]*"/)) { p = substr($0, RSTART, RLENGTH);',
'if (p ~ /^"pkg":"(linux|kernel)[-"]/) { image = (p ~ /^"pkg":"(linux-(image|modules)-[^"]*|kernel(-core)?)"$/); p = "kernel" } k = k p }',
'if (match($0, /"ver":"[^"]*"/)) k = k substr($0, RSTART, RLENGTH);',
'if (match($0, /"fix":"[^"]*"/)) k = k substr($0, RSTART, RLENGTH);',
'if (match($0, /"fixv":\[[^]]*\]/)) k = k substr($0, RSTART, RLENGTH);',
'if (k in at) { if (image && !isImage[k]) { line[at[k]] = $0; isImage[k] = 1 } next }',
'at[k] = ++n; isImage[k] = image; line[n] = $0 }',
'END { for (i = 1; i <= n; i++) print line[i] }',
]);
$run = $hostFilter
? "( {$dockerRun} 2>/dev/null; echo \$? > {$out}.rc ) | grep -v -F '\"fix\":\"wont-fix\"' | awk '{$dedupe}' > {$out}; frc=\$?; rc=\$(cat {$out}.rc 2>/dev/null || echo 1); if [ \"\$rc\" -eq 0 ]; then rc=\$frc; fi"
: "{$dockerRun} > {$out} 2>/dev/null; rc=\$?";
$commands = [...$this->start($template === null ? ['scratch'] : ['scratch', 'tpl'])];
if ($template !== null) {
$commands[] = $this->writeFile(file_get_contents(resource_path("image-scan/{$template}")), $this->workDir()."/tpl/{$template}");
@@ -546,7 +660,7 @@ class ScannerCommands
return [
...$commands,
...$this->ownWorkDir($auth !== null),
$this->shell('ulimit -f '.self::OUTPUT_FILE_LIMIT_BLOCKS."; {$dockerRun} > {$out} 2>/dev/null; rc=\$?; {$this->oomCheck($step)}; size=\$(wc -c < {$out} 2>/dev/null | tr -cd 0-9); size=\${size:-0}; if [ \"\$size\" -ge ".(self::OUTPUT_FILE_LIMIT_BLOCKS * 512).' ]; then rc=0; fi; echo "'.self::MARKER." {$step} \$rc \$size\""),
$this->shell('ulimit -f '.self::OUTPUT_FILE_LIMIT_BLOCKS."; {$run}; {$this->oomCheck($step)}; size=\$(wc -c < {$out} 2>/dev/null | tr -cd 0-9); size=\${size:-0}; if [ \"\$size\" -ge ".(self::OUTPUT_FILE_LIMIT_BLOCKS * 512).' ]; then rc=0; fi; echo "'.self::MARKER." {$step} \$rc \$size\""),
...$this->finish(['echo '.self::PAYLOAD_SEPARATOR, 'head -c '.(self::OUTPUT_LIMIT + 1)." {$out} 2>/dev/null || true"]),
];
}
@@ -52,6 +52,7 @@ use App\Models\ScheduledVolumeBackup;
use App\Models\ScheduledVolumeBackupExecution;
use App\Models\SecretManagerLink;
use App\Models\Server;
use App\Models\ServerHostScan;
use App\Models\ServerSetting;
use App\Models\Service;
use App\Models\ServiceApplication;
@@ -370,6 +371,7 @@ final class ServerTransferCoverage
ApplicationDeploymentQueue::class => 'Deployment history.',
ImageScan::class => 'Rebuilt by the next rescan.',
ImageSbom::class => 'Rebuilt by the next rescan.',
ServerHostScan::class => 'Rebuilt by the next host scan on the target.',
ImageScanNotificationState::class => 'Notification state restarts as a baseline after a transfer.',
ScheduledTaskExecution::class => 'Execution history.',
ScheduledDatabaseBackupExecution::class => 'Execution history.',
@@ -750,6 +750,7 @@ class ServerTransferImporter
'ssh_host_key_source' => filled($sshHostKey) ? 'transfer' : null,
'ssh_host_key_trusted_at' => filled($sshHostKey) ? data_get($settingsPayload, 'ssh_host_key_trusted_at') : null,
'is_ssh_host_key_check_disabled' => (bool) data_get($settingsPayload, 'is_ssh_host_key_check_disabled', false),
'is_ssh_host_key_check_enforced' => (bool) data_get($settingsPayload, 'is_ssh_host_key_check_enforced', false),
]);
$server->settings->cloudflare_tunnel_integration_token_id = $this->importedIntegrationTokenId(
data_get($settingsPayload, 'cloudflare_tunnel_integration_token_uuid'),
@@ -0,0 +1,25 @@
<?php
namespace Database\Factories;
use App\Models\Server;
use App\Models\ServerHostScan;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<ServerHostScan>
*/
class ServerHostScanFactory extends Factory
{
public function definition(): array
{
return [
'server_id' => Server::factory(),
'summary' => ['critical' => 0, 'high' => 0, 'medium' => 0, 'low' => 0, 'unknown' => 0, 'fixable' => 0, 'exploited' => 0, 'likely' => 0, 'total' => 0],
'findings' => [],
'findings_truncated' => false,
'grype_db_built_at' => now()->subHours(6),
'scanned_at' => now(),
];
}
}
@@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('server_host_scans', function (Blueprint $table) {
$table->id();
$table->foreignId('server_id')->unique()->constrained()->cascadeOnDelete();
$table->string('error_code')->nullable();
$table->jsonb('error_context')->nullable();
$table->jsonb('eol')->nullable();
$table->jsonb('summary')->nullable();
$table->jsonb('findings')->nullable();
$table->boolean('findings_truncated')->default(false);
$table->timestamp('grype_db_built_at')->nullable();
$table->timestamp('scanned_at')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('server_host_scans');
}
};
@@ -0,0 +1,22 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('server_settings', function (Blueprint $table) {
$table->boolean('is_ssh_host_key_check_enforced')->default(false);
});
}
public function down(): void
{
Schema::table('server_settings', function (Blueprint $table) {
$table->dropColumn('is_ssh_host_key_check_enforced');
});
}
};
+1 -1
View File
@@ -36,7 +36,7 @@ When you add a model or a setting to the transfer, add it to this file too.
| ✅ | Disk usage notification | threshold 70, check frequency `*/30 * * * *`, interval 12 h |
| ✅ | Security > Patches | check frequency `daily`, automatic security updates on at `04:30` (the last check result is not moved; the target shows "never" until its first check) |
| ✅ | Terminal | terminal access off |
| ➕ | Security > SSH host key | a trusted host key (shown by fingerprint), and "host key check" disabled on one server. Expected: the target shows the same fingerprint with source `transfer` and connects without a new trust step |
| ➕ | Security > SSH host key | a trusted host key (shown by fingerprint), "host key check" disabled on one server, and "Enforce" on another server. Expected: the target shows the same fingerprint with source `transfer`, keeps both per-server settings, and connects without a new trust step |
| ✅ | Image retention | "disable application image retention" on |
| ✅ | Labels / Traefik | generate exact labels on, Traefik certificate resolver `custom` |
| ✅ | Backup compression | CPU percentage 50 |
+28 -3
View File
@@ -3975,7 +3975,15 @@ input[type="search"]::-webkit-search-results-decoration {
}
.package-updates-table-grid {
grid-template-columns: minmax(12rem, 1.4fr) minmax(10rem, 1fr) 6rem;
grid-template-columns: 1.25rem minmax(12rem, 1.4fr) minmax(10rem, 1fr);
}
.held-updates-table-grid {
grid-template-columns: minmax(12rem, 1.4fr) minmax(10rem, 1fr) 7rem;
}
.host-vulnerabilities-table-grid {
grid-template-columns: minmax(12rem, 1.4fr) minmax(9rem, 1fr) minmax(9rem, 1fr) 6rem 6rem;
}
.scheduled-backups-table-grid {
@@ -4125,10 +4133,27 @@ input[type="search"]::-webkit-search-results-decoration {
}
.package-updates-table-grid {
grid-template-columns: minmax(0, 1fr) 5rem;
grid-template-columns: 1.25rem minmax(0, 1fr);
}
.package-updates-table-grid > :nth-child(2) {
.package-updates-table-grid > :nth-child(3) {
display: none;
}
.held-updates-table-grid {
grid-template-columns: minmax(0, 1fr) auto;
}
.held-updates-table-grid > :nth-child(2) {
display: none;
}
.host-vulnerabilities-table-grid {
grid-template-columns: minmax(0, 1fr) 5rem 5rem;
}
.host-vulnerabilities-table-grid > :nth-child(2),
.host-vulnerabilities-table-grid > :nth-child(3) {
display: none;
}
@@ -10,13 +10,15 @@
@php
$bodyId = $collapsible ? ($attributes->get('id') ?: 'section-'.md5($title)).'-body' : null;
// A card with nothing in its body shows only its title row.
$hasBody = $collapsible || $slot->hasActualContent();
@endphp
<section {{ $attributes->merge(['class' => 'application-settings-section', 'data-settings-section-title' => $title]) }}
@if ($collapsible) x-data="{ open: @js((bool) $open) }"
x-on:open-settings-section.window="if ($event.detail?.id === $el.id) { open = true; $nextTick(() => $el.scrollIntoView({ behavior: window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth', block: 'start' })) }" @endif>
<header @if ($collapsible) :class="open || 'is-collapsed'" @endif>
<div @class(['min-w-0 py-0.5', 'flex flex-wrap items-center gap-2' => isset($badges)])>
<header @if ($collapsible) :class="open || 'is-collapsed'" @elseif (! $hasBody) class="is-collapsed" @endif>
<div @class(['min-w-0 py-0.5', 'flex flex-wrap items-baseline gap-x-2 gap-y-1' => isset($badges)])>
@if ($collapsible)
<h3 @class(['flex items-center gap-1.5' => filled($description ?? $helper)])>
<button type="button" class="settings-section-toggle" x-on:click="open = ! open"
@@ -48,8 +50,10 @@
</div>
@endisset
</header>
<div @if ($collapsible) id="{{ $bodyId }}" x-show="open" x-cloak @endif
class="application-settings-section-body {{ $flush ? 'is-flush' : '' }}">
{{ $slot }}
</div>
@if ($hasBody)
<div @if ($collapsible) id="{{ $bodyId }}" x-show="open" x-cloak @endif
class="application-settings-section-body {{ $flush ? 'is-flush' : '' }}">
{{ $slot }}
</div>
@endif
</section>
@@ -0,0 +1,22 @@
{{--
Confirms and runs removeUnusedPackages() of the enclosing Livewire component (ManagesServerPackageUpdates).
Shows nothing when the package manager of the last patch check is not supported. With $triggerId, the dialog
opens from a hidden button with that id (for a menu item), not from a visible button.
--}}
@props(['packageManager' => null, 'buttonTitle' => 'Remove unused packages', 'triggerId' => null])
@if (\App\Actions\Server\RemoveUnusedPackages::supports($packageManager))
<x-modal-confirmation title="Remove unused packages?" :buttonTitle="$buttonTitle"
submitAction="removeUnusedPackages" :actions="[
'Removes packages that nothing needs anymore, and old kernels.',
'The running kernel and the newest kernel stay installed.',
]"
warningMessage="Coolify cannot restore removed packages. Check the logs when it is done."
:confirmWithText="false" :confirmWithPassword="false" step2ButtonText="Remove">
@if ($triggerId)
<x-slot:trigger>
<button id="{{ $triggerId }}" type="button">{{ $buttonTitle }}</button>
</x-slot:trigger>
@endif
</x-modal-confirmation>
@endif
@@ -51,6 +51,20 @@
'group' => 'Settings',
'disabled' => ! $server->isFunctional(),
],
[
'label' => 'Security',
'route' => 'server.security',
'active' => $activeMenu === 'security',
'icon' => 'shield-alert',
'group' => 'Security',
'visible' => auth()->user()?->can('update', $server),
'warning' => filled($server->settings->ssh_host_key_pending),
'children' => [
['label' => 'General', 'route' => 'server.security', 'active' => $activeSubMenu === 'overview', 'icon' => 'settings'],
['label' => 'Patching', 'route' => 'server.security.patches', 'active' => $activeSubMenu === 'patches', 'icon' => 'refresh', 'visible' => $server->isFunctional()],
['label' => 'Vulnerabilities', 'route' => 'server.security.vulnerabilities', 'active' => $activeSubMenu === 'vulnerabilities', 'icon' => 'shield-alert', 'visible' => $server->isFunctional()],
],
],
[
'label' => 'Cloudflare Tunnel',
'route' => 'server.cloudflare-tunnel',
@@ -185,19 +199,6 @@
'visible' => ! $server->isSwarm() && ! $server->isBuildServer(),
'disabled' => ! $server->isFunctional(),
],
[
'label' => 'Security',
'route' => 'server.security',
'active' => $activeMenu === 'security',
'icon' => 'shield-alert',
'group' => 'Security',
'visible' => auth()->user()?->can('update', $server),
'warning' => filled($server->settings->ssh_host_key_pending),
'children' => [
['label' => 'Overview', 'route' => 'server.security', 'active' => $activeSubMenu === 'overview', 'icon' => 'settings'],
['label' => 'Image Scanning', 'route' => 'server.security.image-scanning', 'active' => $activeSubMenu === 'image-scanning', 'icon' => 'shield-alert', 'visible' => $server->isFunctional()],
],
],
[
'label' => 'Transfer',
'route' => 'server.transfer',
@@ -34,6 +34,10 @@
</x-application.settings-subsection>
@endif
</x-application.settings-section>
@elseif ($embedded)
<x-application.settings-subsection id="image-scan-rules-section" title="Rules" :helper="$rulesHelper" first>
@include('livewire.image-scan.partials.scan-rules')
</x-application.settings-subsection>
@else
<x-application.settings-section id="image-scan-rules-section" title="Rules" :helper="$rulesHelper">
@include('livewire.image-scan.partials.scan-rules')
@@ -1,6 +1,6 @@
<div>
<x-slot:title>
{{ data_get_str($server, 'name')->limit(10) }} > Image Scanning | Coolify
{{ data_get_str($server, 'name')->limit(10) }} > Vulnerabilities | Coolify
</x-slot>
<livewire:server.navbar :server="$server" />
@@ -20,73 +20,34 @@
'failed' => $failedStep === 'updating_database' ? 'error' : 'pending',
default => 'idle',
};
$sectionHelper = collect([
'Scan the images on this server for known vulnerabilities before and after deployments. Coolify also scans the operating system packages of the server every day.',
"Scanner tools: {$toolVersions}.",
'Coolify downloads the latest vulnerability database every day, between 02:00 and 05:00 server time.'
.($builtAt ? ' Database built ' . $builtAt->diffForHumans() . '.' : ''),
$diskUsed ? "Disk used by scanner data: {$diskUsed}." : null,
$costText,
])->filter()->map(fn (string $line) => e($line))->implode('<br><br>');
@endphp
<div
class="server-settings-workspace application-settings-workspace mt-4 grid w-full max-w-none min-w-0 gap-8 lg:mt-0 xl:grid-cols-[210px_minmax(0,1fr)] xl:gap-8">
<x-server.sidebar :server="$server" activeMenu="security" activeSubMenu="image-scanning" />
<x-server.sidebar :server="$server" activeMenu="security" activeSubMenu="vulnerabilities" />
<div class="application-settings-form flex w-full min-w-0 flex-col gap-6">
<div class="application-settings-form flex w-full flex-col gap-6">
<x-application.settings-section id="server-image-scanning-section" title="Image scanning"
helper="Scan the images on this server for known vulnerabilities before and after deployments.">
@if (! $allowed)
<x-empty size="sm" title="Image scanning is turned off for this Coolify instance." />
@elseif (! $enabled)
<x-empty size="sm" icon-name="shield-alert" title="Image scanning is off for {{ $server->name }}."
description="{{ $costText }}">
:helper="$sectionHelper">
@if ($allowed)
@can('update', $server)
<x-slot:actions>
@can('update', $server)
<x-modal-confirmation title="Turn on image scanning?" buttonTitle="Turn on image scanning"
isHighlightedButton submitAction="enable" temporaryDisableTwoStepConfirmation
:confirmWithText="false" :confirmWithPassword="false" :actions="[$costText]"
step2ButtonText="Turn on image scanning" />
@else
<span class="text-[13px] text-neutral-500 dark:text-fg-faint">Ask a team admin to turn it
on.</span>
@endcan
</x-slot:actions>
</x-empty>
@else
<div class="flex flex-col gap-4">
<div class="divide-y divide-neutral-200 rounded-lg border border-neutral-200 dark:divide-white/[0.08] dark:border-white/[0.08]">
<x-checkpoint-item title="Get scanner" icon="download" :status="$pullState"
:description="match ($pullState) {
'running' => 'Pulling the scanner images.',
'success' => 'The scanner images are on this server.',
'error' => match ($failedStep) {
'setup' => 'Coolify could not set up the scanner storage on the server. Check that the SSH user can run Docker commands.',
'connection' => 'Coolify could not connect to the server. Check that the server is reachable, then try again.',
default => 'Coolify could not pull the scanner images. Check that the server can reach ' . $registries . '.',
},
default => 'Not done yet.',
}" />
<x-checkpoint-item title="Update database" icon="database" :status="$databaseState"
:description="match ($databaseState) {
'running' => 'Downloading the vulnerability database.',
'success' => $builtAt ? 'Database built ' . $builtAt->diffForHumans() . '.' : 'The vulnerability database is up to date.',
'error' => 'Coolify could not download the vulnerability database.',
default => 'Not done yet.',
}" />
</div>
<div class="flex flex-col gap-1 text-[12px] text-neutral-500 dark:text-fg-faint">
<p>Scanner tools: {{ $toolVersions }}.</p>
@if ($diskUsed)
<p>Disk used by scanner data: {{ $diskUsed }}.</p>
@endif
<p>{{ $costText }}</p>
</div>
@can('update', $server)
<div class="flex flex-wrap items-center gap-2">
@if ($prepareState === null || $prepareState === 'failed')
<x-forms.button type="button" wire:click="prepareScanner">
{{ $prepareState === 'failed' ? 'Try again' : 'Get scanner' }}
</x-forms.button>
@endif
<x-forms.button type="button" wire:click="disable">
Turn off image scanning
@if ($enabled && ($prepareState === null || $prepareState === 'failed'))
<x-forms.button type="button" wire:click="prepareScanner">
{{ $prepareState === 'failed' ? 'Try again' : 'Get scanner' }}
</x-forms.button>
@endif
{{-- Disabling forgets the cached state, but the files stay on the server. --}}
@if (! $enabled || $hasScannerData)
<x-modal-confirmation title="Delete scanner data?" buttonTitle="Delete scanner data"
isErrorButton submitAction="deleteScannerData" temporaryDisableTwoStepConfirmation
:confirmWithText="false" :confirmWithPassword="false" :actions="[
@@ -94,16 +55,68 @@
'Scan results in Coolify stay. The next scan gets the scanner again.',
]"
step2ButtonText="Delete scanner data" />
@endif
<div wire:key="image-scanning-change-{{ $enabled ? 'on' : 'off' }}">
<x-modal-confirmation
title="{{ $enabled ? 'Disable image scanning?' : 'Enable image scanning?' }}"
buttonTitle="{{ $enabled ? 'Disable' : 'Enable' }}"
submitAction="{{ $enabled ? 'disable' : 'enable' }}"
warningMessage="{{ $enabled ? 'New deployments and running images on this server are no longer checked for vulnerabilities. You can enable it again at any time.' : 'Scans run on this server and use its resources. You can disable it again at any time.' }}"
temporaryDisableTwoStepConfirmation :confirmWithText="false"
:confirmWithPassword="false" :actions="$enabled
? ['Coolify stops scanning the images and the operating system packages of this server.', 'Scan results and scanner data on the server stay.']
: [$costText]"
step2ButtonText="{{ $enabled ? 'Disable image scanning' : 'Enable image scanning' }}"
:isHighlightedButton="! $enabled" />
</div>
@endcan
</x-slot:actions>
@endcan
@endif
@if (! $allowed)
<x-empty size="sm" title="Image scanning is turned off for this Coolify instance." />
@elseif (! $enabled)
<x-empty size="sm" icon-name="shield-alert" title="Image scanning is off for {{ $server->name }}."
description="{{ $costText }}">
@cannot('update', $server)
<x-slot:actions>
<span class="text-[13px] text-neutral-500 dark:text-fg-faint">Ask a team admin to turn it
on.</span>
</x-slot:actions>
@endcannot
</x-empty>
@elseif ($prepareState !== 'ready')
<div class="divide-y divide-neutral-200 rounded-lg border border-neutral-200 dark:divide-white/[0.08] dark:border-white/[0.08]">
<x-checkpoint-item title="Get scanner" icon="download" :status="$pullState"
:description="match ($pullState) {
'running' => 'Pulling the scanner images.',
'success' => 'The scanner images are on this server.',
'error' => match ($failedStep) {
'setup' => 'Coolify could not set up the scanner storage on the server. Check that the SSH user can run Docker commands.',
'connection' => 'Coolify could not connect to the server. Check that the server is reachable, then try again.',
default => 'Coolify could not pull the scanner images. Check that the server can reach ' . $registries . '.',
},
default => 'Not done yet.',
}" />
<x-checkpoint-item title="Update database" icon="database" :status="$databaseState"
:description="match ($databaseState) {
'running' => 'Downloading the vulnerability database.',
'success' => $builtAt ? 'Database built ' . $builtAt->diffForHumans() . '.' : 'The vulnerability database is up to date.',
'error' => 'Coolify could not download the vulnerability database.',
default => 'Not done yet.',
}" />
</div>
@endif
@if ($allowed)
<div @class([
'mt-5 border-t border-neutral-200 pt-5 dark:border-white/[0.07]' => ! $enabled || $prepareState !== 'ready',
])>
<livewire:image-scan.scan-settings scope-type="server" :scope-uuid="$server->uuid" :embedded="true"
:key="'server-image-scan-settings-'.$server->uuid" />
</div>
@endif
</x-application.settings-section>
@if ($allowed)
<livewire:image-scan.scan-settings scope-type="server" :scope-uuid="$server->uuid"
:key="'server-image-scan-settings-'.$server->uuid" />
@endif
<livewire:server.security.host-vulnerabilities :server_uuid="$server->uuid" :key="'host-vulnerabilities-'.$server->uuid" />
</div>
</div>
</div>
@@ -61,7 +61,7 @@
'name' => $server->name,
'description' => $server->description,
'href' => route('server.show', ['server_uuid' => $server->uuid]),
'patchesHref' => route('server.security', ['server_uuid' => $server->uuid]).'#server-patching-overview-section',
'patchesHref' => route('server.security.patches', ['server_uuid' => $server->uuid]),
'status' => $status,
'statusType' => $statusType,
'resourceCount' => $server->definedResources()->count(),
@@ -71,7 +71,7 @@
[
'label' => 'Security',
'route' => 'server.security',
'active' => request()->routeIs('server.security'),
'active' => request()->routeIs('server.security*'),
// Also for a server that is not functional: a changed host key stops the connection, and the page to trust the new key is here.
'visible' => auth()->user()?->can('update', $server),
],
@@ -10,7 +10,18 @@
@endif
<x-application.settings-section id="server-host-key-section" title="SSH host key"
helper="Coolify trusts the first host key of the server. Later, a different key is reported (warn mode) or refused (enforce mode).">
helper="Coolify trusts the first host key of the server. Later, a different key is reported (warn mode, the default) or refused (enforce mode). The setting below sets enforce mode for this server only.<br><br>An instance admin sets the mode for all servers with <code>SSH_HOST_KEY_CHECKING=enforce</code> (or <code>warn</code>, <code>off</code>) in <code>/data/coolify/source/.env</code>, then restarts Coolify.">
<x-slot:badges>
@if (count($pendingFingerprints) > 0)
<x-status-badge status="Key changed" type="error" />
@elseif ($mode === 'off')
<x-status-badge status="Check off" type="warning" />
@elseif (count($fingerprints) > 0)
<x-status-badge :status="'Trusted, ' . $mode . ' mode'" type="success" />
@else
<x-status-badge status="No key yet" type="neutral" />
@endif
</x-slot:badges>
<x-slot:actions>
@can('update', $server)
<div class="flex flex-wrap items-center gap-2"
@@ -69,7 +80,13 @@
@endforelse
<p class="mt-1 text-xs text-neutral-500 dark:text-fg-dim">
@if (count($fingerprints) > 0)
Source: {{ $server->settings->ssh_host_key_source }}
{{ match ($server->settings->ssh_host_key_source) {
'tofu' => 'Saved on the first connection',
'upgrade' => 'Saved on the first connection after the Coolify upgrade',
'transfer' => 'Moved with the server transfer',
'manual' => 'Saved by a user',
default => 'Saved',
} }}
@if ($server->settings->ssh_host_key_trusted_at)
&middot; Trusted {{ $server->settings->ssh_host_key_trusted_at->toDateTimeString() }} UTC
@endif
@@ -77,15 +94,23 @@
@else
Saved on the next connection &middot;
@endif
Mode: {{ $mode }}
Mode: {{ $mode }}{{ $mode === 'enforce' && $instanceMode !== 'enforce' ? ' (set for this server)' : '' }}
</p>
</div>
</div>
<details class="text-xs text-neutral-500 dark:text-fg-dim">
<summary class="cursor-pointer">Compare on the server</summary>
<code class="mt-1 block break-all">for key in /etc/ssh/ssh_host_*_key.pub; do ssh-keygen -lf "$key"; done</code>
</details>
{{-- The choice only changes something when the instance warns; an instance "enforce" or "off" decides alone. --}}
@if ($instanceMode !== 'enforce' && $instanceMode !== 'off' && ! $server->settings->is_ssh_host_key_check_disabled)
<div class="max-w-sm">
<x-forms.listbox id="checkMode" label="When the host key changes" onChange="setHostKeyCheckMode"
:onChangeArgs="[]" canGate="update" :canResource="$server"
helper="Warn: Coolify connects and reports the new key. Enforce: Coolify refuses to connect until you trust or reset the new key, so deployments, backups, and checks on this server stop. A reinstall of the server also changes the key."
:options="[
['value' => 'warn', 'label' => 'Warn and connect'],
['value' => 'enforce', 'label' => 'Refuse to connect (enforce)'],
]" />
</div>
@endif
</div>
</x-application.settings-section>
</div>
@@ -0,0 +1,341 @@
<div>
<x-process-dialog @startupdate.window="processDialogOpen = true" closeWithX size="xl">
<x-slot:title>Package Update Logs</x-slot:title>
<x-slot:content>
<div class="flex h-full min-h-0 flex-col">
<livewire:activity-monitor fullHeight />
</div>
</x-slot:content>
</x-process-dialog>
@php
$hostSummary = $hostScan?->summary;
$rebootRequired = data_get($result, 'reboot_required') === true;
$hostScanError = $hostScan?->error_code?->message([...($hostScan->error_context ?? []), 'server' => $server->name]);
@endphp
<x-application.settings-section id="server-host-vulnerabilities-section" title="Vulnerabilities"
helper="Known vulnerabilities in the operating system packages of this server. Coolify scans them with the image scanner every day and after a package update. Problems that the distribution does not fix are not shown."
flush>
<x-slot:badges>
@if ($hostScanError && ! $hostScanRunning)
<x-status-badge status="Scan failed" type="error" />
@endif
@if ($hostSummary)
@foreach (['critical', 'high'] as $severity)
@if (($hostSummary[$severity] ?? 0) > 0)
<x-image-scan.severity-badge :severity="$severity" :count="$hostSummary[$severity]" />
@endif
@endforeach
@if (($hostSummary['exploited'] ?? 0) > 0)
<x-status-badge :status="$hostSummary['exploited'] . ' exploited'" type="error" />
@endif
@if (array_sum(array_intersect_key($hostSummary, array_flip(['critical', 'high', 'medium', 'low', 'unknown']))) === 0)
<x-status-badge status="No relevant problems" type="success" />
@endif
@elseif (! $hostScanAvailable)
<x-status-badge status="Off" type="neutral" />
@elseif (! $hostScanRunning)
<x-status-badge status="Not scanned yet" type="neutral" />
@endif
</x-slot:badges>
@if ($hostFindings !== [] || $hostScanAvailable)
<x-slot:actions>
@if ($hostFindings !== [])
<div class="w-56">
<x-table.search placeholder="Search by ID or package" loading-target="search"
wire:model.live.debounce.300ms="search" />
</div>
@endif
@if ($hostScanAvailable)
@can('update', $server)
<x-forms.button type="button" wire:click="scanHostPackages" :disabled="$hostScanRunning">
@if ($hostScanRunning)
<x-loading compact />
Scanning…
@else
<x-reicon name="refresh" class="size-3.5" />
Scan now
@endif
</x-forms.button>
@endcan
@endif
</x-slot:actions>
@endif
@if (! $hostScanAvailable && ! $hostScan)
<div class="p-4">
<x-empty size="sm" icon-name="shield-alert" title="Image scanning is off for {{ $server->name }}."
description="Enable image scanning below to also scan the operating system packages of this server.">
<x-slot:actions>
<a class="text-[13px] font-medium underline" href="#server-image-scanning-section">Image scanning settings</a>
</x-slot:actions>
</x-empty>
</div>
@else
<div class="flex flex-col gap-3 p-4" @if ($hostScanRunning) wire:poll.10s="loadResult" @endif>
<div class="flex flex-wrap items-center gap-2 text-sm text-neutral-600 dark:text-fg-dim">
<span>
Last scan:
@if ($hostScan?->scanned_at)
<span title="{{ $hostScan->scanned_at->toDateTimeString() }}">{{ $hostScan->scanned_at->diffForHumans() }}</span>
@else
never
@endif
</span>
@if ($hostSummary)
@if (($hostSummary['medium'] ?? 0) > 0)
<x-image-scan.severity-badge severity="medium" :count="$hostSummary['medium']" />
@endif
@if (($hostSummary['fixable'] ?? 0) > 0)
<x-status-badge :status="$hostSummary['fixable'] . ' fixed by an update'" type="warning" />
@endif
@endif
</div>
@if ($hostFindings !== [] && ($hostScan->findings_truncated || ! $result))
<p class="text-[12px] text-neutral-500 dark:text-fg-faint">
@if ($hostScan->findings_truncated)
Only the most important vulnerabilities of a larger result are shown.
@endif
@if (! $result)
Run "Check for updates" to see which packages have an update.
@endif
</p>
@endif
@if ($rebootRequired)
<x-callout type="warning" title="Reboot required">
<div class="flex flex-wrap items-center justify-between gap-3">
<span>
@if (($kernelStatusCounts['fixed_after_reboot'] ?? 0) > 0)
The server runs kernel <span class="font-mono">{{ $runningKernel }}</span>, which has {{ $kernelStatusCounts['fixed_after_reboot'] }}
listed {{ Str::plural('vulnerability', $kernelStatusCounts['fixed_after_reboot']) }}. The fixed kernel
<span class="font-mono">{{ $bootKernel }}</span> is installed and is the boot kernel. Reboot the server to start it.
@elseif ($runningKernel && $bootKernel && $bootKernel !== $runningKernel)
Kernel <span class="font-mono">{{ $bootKernel }}</span> is installed and starts after a reboot. The server now runs
<span class="font-mono">{{ $runningKernel }}</span>. No listed vulnerability needs this reboot.
@else
Some installed updates apply only after a reboot.
@endif
</span>
@can('update', $server)
<x-modal-confirmation title="Reboot server?" buttonTitle="Reboot server" isErrorButton
submitAction="rebootServer"
warningMessage="The containers on this server stop until the server is back." :actions="[
'The server reboots in a few seconds.',
'All containers on this server stop, and the containers with a restart policy start again after the reboot.',
'The server is not reachable for a few minutes.',
]"
confirmationText="{{ $server->name }}"
confirmationLabel="Enter the server name to confirm"
shortConfirmationLabel="Server name" :confirmWithPassword="false"
step2ButtonText="Reboot server" />
@endcan
</div>
</x-callout>
@endif
@if ($otherKernelCount > 0)
<div class="flex flex-wrap items-center gap-x-3 gap-y-2 text-[12px] text-neutral-500 dark:text-fg-faint">
<span>{{ $otherKernelCount }} {{ Str::plural('vulnerability', $otherKernelCount) }} in installed kernels that do not run {{ $showOtherKernels ? 'shown' : 'hidden' }}. No risk now.</span>
<button type="button" wire:click="$toggle('showOtherKernels')" class="cursor-pointer font-medium underline underline-offset-4">
{{ $showOtherKernels ? 'Hide' : 'Show' }}
</button>
@if ($hasOldKernels)
@can('update', $server)
<x-server.remove-unused-packages-button :packageManager="data_get($result, 'package_manager')" buttonTitle="Remove old kernels" />
@endcan
@endif
</div>
@endif
@if ($hostScanError)
<x-callout type="danger" title="The last scan failed">
{{ $hostScanError }}
</x-callout>
@endif
@if ($hostScan?->eol)
<x-callout type="warning" title="Operating system is end of life">
{{ data_get($hostScan->eol, 'name') }} {{ data_get($hostScan->eol, 'version') }} gets no
security updates anymore. Upgrade the operating system of this server.
</x-callout>
@endif
@if ($hostScan?->scanned_at && $hostFindings === [])
<x-empty size="sm" title="No relevant vulnerabilities"
description="The installed packages have no critical, high, or exploited vulnerabilities."
icon-name="check-circle" />
@endif
</div>
@if ($hostFindings !== [] && $shownHostFindings->isEmpty())
<div class="border-t border-neutral-200 p-6 dark:border-white/[0.08]">
<x-empty size="sm" title="No matching vulnerabilities"
description="Try another ID or package, or clear the search." icon-name="search" />
</div>
@elseif ($hostFindings !== [])
<div class="data-table">
<div class="data-table-header host-vulnerabilities-table-grid">
<x-table.sort-header label="Vulnerability" asc="vulnerability_asc" desc="vulnerability_desc" action="sortFindings" :current="$sortBy" />
<x-table.sort-header label="Package" asc="package_asc" desc="package_desc" action="sortFindings" :current="$sortBy" />
<x-table.sort-header label="Fixed in" asc="fixed_asc" desc="fixed_desc" action="sortFindings" :current="$sortBy" />
<div class="flex justify-end">
<x-table.sort-header label="Severity" asc="severity_asc" desc="severity_desc" first="desc" action="sortFindings" :current="$sortBy" />
</div>
<span class="text-right">Action</span>
</div>
@foreach ($shownHostFindings as $finding)
@php
$signal = \App\Services\ImageScan\SecurityRules::exploitSignal($finding);
$fixingUpdates = \App\Actions\Server\CheckUpdates::fixingUpdates($finding->package, $result);
$isOwnUpdate = count($fixingUpdates) === 1 && data_get($fixingUpdates[0], 'package') === $finding->package;
$updateRisks = collect($fixingUpdates)->pluck('risk')->filter()->unique()->values();
$updateRiskLabels = $updateRisks->map(fn ($risk) => \App\Actions\Server\CheckUpdates::RISK_LABELS[$risk] ?? null)->filter()->values()->all();
$kernelStatus = \App\Actions\Server\CheckUpdates::kernelStatus($finding->package, $finding->version, $finding->fixedIn, $result);
$isOtherKernel = \App\Actions\Server\CheckUpdates::isOtherKernelPackage($finding->package, $finding->version, $runningKernel);
$isOldKernel = $isOtherKernel && $kernelStatus !== 'runs_after_reboot';
if ($isOtherKernel) {
$fixingUpdates = [];
$updateRiskLabels = [];
}
$advisoryUrl = \App\Services\ImageScan\ResourceScans::advisoryUrl($finding->displayId);
$likelihood = \App\Services\ImageScan\ResourceScans::exploitLikelihood($finding->epss);
$aliases = array_values(array_filter($finding->aliases, fn (string $alias) => $alias !== $finding->displayId));
@endphp
<div wire:key="host-finding-{{ $finding->id }}-{{ $finding->package }}-{{ $finding->artifactId }}"
x-data="{ open: false }"
class="border-b border-neutral-200 last:border-b-0 dark:border-white/[0.08]">
<div x-on:click="open = ! open"
class="data-table-row host-vulnerabilities-table-grid w-full cursor-pointer text-left hover:bg-neutral-50 dark:hover:bg-white/[0.03]">
<div class="flex min-w-0 items-start gap-1.5">
<x-reicon name="chevron-down" class="mt-0.5 size-3.5 shrink-0 text-neutral-500 transition-transform duration-150 dark:text-fg-faint"
::class="open ? 'rotate-0' : '-rotate-90'" />
<div class="min-w-0">
<p class="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
<button type="button" x-on:click.stop="open = ! open" :aria-expanded="open ? 'true' : 'false'"
aria-label="Show details of {{ $finding->displayId }}"
class="shrink-0 cursor-pointer font-mono text-[12px] text-neutral-950 dark:text-fg">{{ $finding->displayId }}</button>
@if ($signal === \App\Services\ImageScan\SecurityRules::EXPLOITED)
<x-status-badge status="Exploited" type="error" />
@elseif ($signal === \App\Services\ImageScan\SecurityRules::LIKELY)
<x-status-badge status="Likely exploited" type="warning" />
@endif
</p>
@if (filled($finding->title))
<p class="mt-0.5 truncate text-[11px] text-neutral-500 dark:text-fg-faint">{{ $finding->title }}</p>
@endif
</div>
</div>
<div class="min-w-0">
<p class="truncate font-mono text-[12px] text-neutral-700 dark:text-fg-dim">{{ $finding->package }}</p>
<p class="mt-0.5 truncate text-[10px] text-neutral-500 dark:text-fg-faint">Installed: {{ $finding->version }}</p>
</div>
<div class="flex min-w-0 flex-col items-start gap-1">
<span class="truncate font-mono text-[12px] text-neutral-700 dark:text-fg-dim">{{ $finding->fixedIn ?? 'No fix yet' }}</span>
@if ($kernelStatus === 'fixed_after_reboot')
<x-status-badge status="Fix installed, reboot needed" type="warning" />
@elseif ($isOldKernel)
<x-status-badge status="Kernel not running" type="neutral" />
@elseif ($kernelStatus === 'runs_after_reboot')
<x-status-badge status="Runs after the reboot" type="neutral" />
@endif
@if ($fixingUpdates !== [])
<x-status-badge :status="$isOwnUpdate ? 'Update available: ' . data_get($fixingUpdates[0], 'new_version') : 'Kernel update available'" type="success" />
@endif
@foreach ($updateRiskLabels as $riskLabel)
<span class="flex items-center gap-1 text-[10px] text-neutral-500 dark:text-fg-faint">
<x-reicon name="alert-triangle" class="size-3 shrink-0 text-red-500" />
{{ $riskLabel }}
</span>
@endforeach
</div>
<div class="flex justify-end">
<x-image-scan.severity-badge :severity="$finding->severity" />
</div>
<div class="flex justify-end" x-on:click.stop>
@if ($fixingUpdates !== [])
@can('update', $server)
@if ($updateRiskLabels !== [])
<x-modal-confirmation title="Update {{ $isOwnUpdate ? $finding->package : 'the kernel' }}?" buttonTitle="Update"
submitAction="updateVulnerablePackage({{ Js::from($finding->package) }})" :actions="[
'Coolify updates ' . collect($fixingUpdates)->pluck('package')->implode(', ') . '.',
...$updateRiskLabels,
]"
warningMessage="Coolify cannot roll back a package update. Check the update logs when it is done."
:confirmWithText="false" :confirmWithPassword="false"
step2ButtonText="Update" />
@else
<x-forms.button type="button"
wire:click="updateVulnerablePackage({{ Js::from($finding->package) }})">
Update
</x-forms.button>
@endif
@endcan
@endif
</div>
</div>
<dl x-show="open" x-cloak x-transition.opacity.duration.150ms
class="grid grid-cols-[6.5rem_minmax(0,1fr)] gap-x-3 gap-y-1.5 px-4 pb-4 sm:grid-cols-[8rem_minmax(0,1fr)] sm:pl-9 text-[12px] text-neutral-700 dark:text-fg-dim">
@if (filled($finding->summary ?: $finding->title))
<dd class="col-span-2 mb-1 max-w-3xl leading-5">{{ $finding->summary ?: $finding->title }}</dd>
@endif
<dt class="text-neutral-500 dark:text-fg-faint">Package</dt>
<dd class="min-w-0 break-all font-mono">{{ $finding->package }}</dd>
<dt class="text-neutral-500 dark:text-fg-faint">Installed</dt>
<dd class="min-w-0 break-all font-mono">{{ $finding->version }}</dd>
<dt class="text-neutral-500 dark:text-fg-faint">Fixed in</dt>
<dd class="min-w-0 break-all font-mono">{{ $finding->fixedIn ?? 'No fix yet' }}</dd>
@if ($fixingUpdates !== [])
<dt class="text-neutral-500 dark:text-fg-faint">Update</dt>
<dd class="min-w-0 break-words">
@foreach ($fixingUpdates as $fixingUpdate)
<span class="block break-all font-mono">{{ data_get($fixingUpdate, 'package') }} {{ data_get($fixingUpdate, 'new_version') }}</span>
@endforeach
@if ($updateRiskLabels !== [])
<span class="block">{{ implode('. ', $updateRiskLabels) }}.</span>
@endif
</dd>
@endif
@if ($kernelStatus !== null || $isOtherKernel)
<dt class="text-neutral-500 dark:text-fg-faint">Kernel</dt>
<dd class="min-w-0 break-words">
@if ($kernelStatus === 'fixed_after_reboot')
The server runs kernel <span class="font-mono">{{ $runningKernel }}</span>. The kernel that starts after a reboot has the fix. Reboot the server to use it.
@elseif ($isOldKernel)
No risk now: the server does not run this kernel (it runs <span class="font-mono">{{ $runningKernel }}</span>).
@else
This kernel is installed, but the server runs it only after a reboot.
@endif
</dd>
@endif
@if ($finding->cvssScore !== null)
<dt class="text-neutral-500 dark:text-fg-faint">CVSS score</dt>
<dd>{{ number_format($finding->cvssScore, 1) }}</dd>
@endif
@if ($finding->kev)
<dt class="text-neutral-500 dark:text-fg-faint">Exploited</dt>
<dd>Attackers are using this, according to CISA.</dd>
@elseif ($likelihood !== null)
<dt class="text-neutral-500 dark:text-fg-faint">Exploit likelihood</dt>
<dd>{{ $likelihood }}</dd>
@endif
@if ($aliases !== [])
<dt class="text-neutral-500 dark:text-fg-faint">Also known as</dt>
<dd class="min-w-0 break-all font-mono">{{ implode(', ', $aliases) }}</dd>
@endif
@if ($advisoryUrl !== null)
<dt class="text-neutral-500 dark:text-fg-faint">Advisory</dt>
<dd><a href="{{ $advisoryUrl }}" target="_blank" rel="noopener noreferrer" class="font-medium underline underline-offset-4">Open {{ $finding->displayId }}</a></dd>
@endif
</dl>
</div>
@endforeach
<x-table-pagination :from="$shownHostFindings->firstItem() ?? 0" :to="$shownHostFindings->lastItem() ?? 0"
:total="$shownHostFindings->total()" :current-page="$shownHostFindings->currentPage()"
:last-page="$shownHostFindings->lastPage()" wire-target="previousPage,nextPage,perPage"
previous-action="previousPage" next-action="nextPage">
<x-slot:pageSize>
<x-page-size-select model="perPage" livewire
storage-key="coolify.page-size.server-host-vulnerabilities" />
</x-slot:pageSize>
</x-table-pagination>
</div>
@endif
@endif
</x-application.settings-section>
</div>
@@ -9,13 +9,12 @@
class="server-settings-workspace application-settings-workspace mt-4 grid w-full max-w-none min-w-0 gap-8 lg:mt-0 xl:grid-cols-[210px_minmax(0,1fr)] xl:gap-8">
<x-server.sidebar :server="$server" activeMenu="security" activeSubMenu="overview" />
<div class="application-settings-form flex w-full min-w-0 flex-col gap-6">
{{-- Links sent before the patching sections got their own page (notifications) end with their section ID. --}}
<div class="application-settings-form flex w-full min-w-0 flex-col gap-6"
x-data
x-init="if (['#server-patching-overview-section', '#server-package-updates-section', '#server-held-updates-section'].includes(location.hash)) { window.location.replace(@js(route('server.security.patches', ['server_uuid' => $server->uuid])) + location.hash) }">
<livewire:server.security.host-key :server_uuid="$server->uuid" />
<livewire:server.security.terminal-access :server_uuid="$server->uuid" />
{{-- Patching runs commands on the server, so it needs a validated server. The host key must stay visible when a changed key stops the connection. --}}
@if ($server->isFunctional())
<livewire:server.security.patches :server_uuid="$server->uuid" />
@endif
</div>
</div>
</div>
@@ -1,4 +1,10 @@
<div>
<x-slot:title>
{{ data_get_str($server, 'name')->limit(10) }} > Patching | Coolify
</x-slot>
<livewire:server.navbar :server="$server" />
<x-process-dialog @startupdate.window="processDialogOpen = true" closeWithX size="xl">
<x-slot:title>Package Update Logs</x-slot:title>
<x-slot:content>
@@ -9,10 +15,6 @@
</x-process-dialog>
@php
$updates = collect(data_get($result, 'updates', []))
->sortBy(fn ($update) => [data_get($update, 'security') ? 0 : 1, filled(data_get($update, 'risk')) ? 0 : 1, data_get($update, 'package')])
->values()
->all();
$totalUpdates = count($updates);
$heldUpdates = collect(data_get($result, 'held_updates', []))->sortBy('package')->values()->all();
$packageManager = data_get($result, 'package_manager');
@@ -24,36 +26,95 @@
@endphp
<div class="application-settings-form flex w-full flex-col gap-6">
<div
class="server-settings-workspace application-settings-workspace mt-4 grid w-full max-w-none min-w-0 gap-8 lg:mt-0 xl:grid-cols-[210px_minmax(0,1fr)] xl:gap-8">
<x-server.sidebar :server="$server" activeMenu="security" activeSubMenu="patches" />
<div class="application-settings-form flex w-full min-w-0 flex-col gap-6">
@if (! $server->isFunctional())
<x-empty size="sm" icon-name="shield-alert" title="Coolify cannot reach {{ $server->name }}."
description="Patching runs commands on the server. Validate the server first." />
@else
<form wire:submit="submit">
<x-unsaved-bar action="submit" />
<x-application.settings-section id="server-patching-overview-section" title="Server patching"
helper="Discover and apply operating system package updates.">
<x-slot:badges>
@if ($checking)
<x-status-badge status="Checking…" type="neutral" spinning />
@elseif ($error)
<x-status-badge status="Check failed" type="error" />
@elseif ($checkedAt)
@if ($totalUpdates === 0)
<x-status-badge status="Up to date" type="success" />
@elseif ($securityUpdates > 0)
<x-status-badge :status="$securityUpdates . ' ' . Str::plural('security update', $securityUpdates)" type="error" />
@else
<x-status-badge :status="$totalUpdates . ' ' . Str::plural('update', $totalUpdates)" type="warning" />
@endif
@if ($rebootRequired)
<x-status-badge status="Reboot required" type="warning" />
@endif
@else
<x-status-badge status="Not checked yet" type="neutral" />
@endif
</x-slot:badges>
<x-slot:actions>
<x-beta-badge />
@if (isDev())
<x-forms.button type="button" wire:click="sendTestEmail">
Send test email
</x-forms.button>
@endif
@can('update', $server)
@if ($rebootRequired)
<x-modal-confirmation title="Reboot server?" buttonTitle="Reboot server" isErrorButton
submitAction="rebootServer" :actions="[
'The server reboots in a few seconds.',
'All containers on this server stop, and the containers with a restart policy start again after the reboot.',
'The server is not reachable for a few minutes.',
]"
confirmationText="{{ $server->name }}"
confirmationLabel="Enter the server name to confirm"
shortConfirmationLabel="Server name" :confirmWithPassword="false"
step2ButtonText="Reboot server" />
@endif
<x-forms.button type="button" wire:click="checkForUpdates" :disabled="$checking">
<x-reicon name="refresh" class="size-3.5" />
Check for updates
</x-forms.button>
<x-split-action id="server-patching-actions">
<x-slot:main @click="open = ! open" aria-haspopup="menu">
Actions
</x-slot:main>
<button type="button" class="listbox-option justify-start! gap-2.5!" role="menuitem"
@click="open = false" wire:click="checkForUpdates" @disabled($checking)>
<x-reicon name="refresh" class="size-3.5 opacity-70" />
Check for updates
</button>
@if (\App\Actions\Server\RemoveUnusedPackages::supports($packageManager))
<button type="button" class="listbox-option justify-start! gap-2.5!" role="menuitem"
@click="open = false; document.getElementById('server-patching-remove-unused-trigger')?.click()">
<x-reicon name="trash" class="size-3.5 opacity-70" />
Remove unused packages
</button>
@endif
@if ($rebootRequired)
<button type="button" class="listbox-option justify-start! gap-2.5! text-red-500!" role="menuitem"
@click="open = false; document.getElementById('server-patching-reboot-trigger')?.click()">
<x-reicon name="restart" class="size-3.5 opacity-70" />
Reboot server
</button>
@endif
@if (isDev())
<button type="button" class="listbox-option justify-start! gap-2.5!" role="menuitem"
@click="open = false" wire:click="sendTestEmail">
<x-reicon name="mail" class="size-3.5 opacity-70" />
Send test email
</button>
@endif
</x-split-action>
<div class="hidden" aria-hidden="true">
<x-server.remove-unused-packages-button :packageManager="$packageManager"
triggerId="server-patching-remove-unused-trigger" />
@if ($rebootRequired)
<x-modal-confirmation title="Reboot server?" buttonTitle="Reboot server"
submitAction="rebootServer"
warningMessage="The containers on this server stop until the server is back." :actions="[
'The server reboots in a few seconds.',
'All containers on this server stop, and the containers with a restart policy start again after the reboot.',
'The server is not reachable for a few minutes.',
]"
confirmationText="{{ $server->name }}"
confirmationLabel="Enter the server name to confirm"
shortConfirmationLabel="Server name" :confirmWithPassword="false"
step2ButtonText="Reboot server">
<x-slot:trigger>
<button id="server-patching-reboot-trigger" type="button">Reboot server</button>
</x-slot:trigger>
</x-modal-confirmation>
@endif
</div>
@endcan
</x-slot:actions>
@@ -68,17 +129,6 @@
never
@endif
</span>
@if ($checkedAt && ! $error)
@if ($totalUpdates === 0)
<x-status-badge status="Up to date" type="success" />
@endif
@if ($securityUpdates > 0)
<x-status-badge :status="$securityUpdates . ' ' . Str::plural('security update', $securityUpdates)" type="error" />
@endif
@if ($rebootRequired)
<x-status-badge status="Reboot required" type="warning" />
@endif
@endif
</div>
@php
@@ -170,15 +220,18 @@
</x-application.settings-section>
</div>
@elseif ($error)
<x-application.settings-section title="Package updates"
<x-application.settings-section id="server-package-updates-section" title="Package updates"
helper="Available operating system updates for this server.">
<x-callout type="danger" title="Could not check for updates">
{{ $error }}
</x-callout>
</x-application.settings-section>
@elseif ($checkedAt && $totalUpdates === 0)
<x-application.settings-section title="Package updates"
<x-application.settings-section id="server-package-updates-section" title="Package updates"
helper="Available operating system updates for this server.">
<x-slot:badges>
<x-status-badge status="Up to date" type="success" />
</x-slot:badges>
<x-empty size="sm" title="Server is up to date"
description="No package updates are currently available." icon-name="check-circle" />
</x-application.settings-section>
@@ -186,8 +239,34 @@
<x-application.settings-section id="server-package-updates-section" title="Package updates"
helper="{{ $totalUpdates }} {{ Str::plural('update', $totalUpdates) }} available{{ $securityUpdates !== null ? ', ' . $securityUpdates . ' of them security ' . Str::plural('update', $securityUpdates) : '' }}."
flush>
@can('update', $server)
<x-slot:actions>
<x-slot:badges>
<x-status-badge :status="$totalUpdates . ' ' . Str::plural('update', $totalUpdates)" :type="$securityUpdates > 0 ? 'error' : 'warning'" />
</x-slot:badges>
<x-slot:actions>
<div class="w-56">
<x-table.search placeholder="Search packages" loading-target="search" maxlength="100"
wire:model.live.debounce.300ms="search" />
</div>
@can('update', $server)
@if ($selectedPackages !== [])
@php
$selectedRisky = collect($updates)->whereIn('package', $selectedPackages)->filter(fn ($update) => filled(data_get($update, 'risk')))->count();
@endphp
<x-forms.button type="button" wire:click="$set('selectedPackages', [])">Clear selection</x-forms.button>
{{-- The dialog has wire:ignore; a new key renders it again when the selection changes. --}}
<div wire:key="update-selected-{{ md5(implode(',', $selectedPackages)) }}">
<x-modal-confirmation title="Update selected packages?"
buttonTitle="Update {{ count($selectedPackages) }} selected" isHighlightedButton
submitAction="updateSelectedPackages" :actions="[
'Coolify updates ' . implode(', ', $selectedPackages) . '.',
$selectedRisky > 0
? $selectedRisky . ' of them can restart Docker, the running containers, or core system services, or need a reboot.'
: 'Some services on the server can restart.',
]"
warningMessage="Coolify cannot roll back a package update. Check the update logs when it is done."
:confirmWithText="false" :confirmWithPassword="false" step2ButtonText="Update" />
</div>
@else
<x-modal-confirmation title="Confirm package update?" buttonTitle="Update all packages"
isHighlightedButton submitAction="updateAllPackages" :actions="[
'All packages will be updated to their latest available versions.',
@@ -199,8 +278,9 @@
confirmationLabel="Confirm by entering the text below"
shortConfirmationLabel="Confirmation" :confirmWithPassword="false"
step2ButtonText="Update All Packages" />
</x-slot:actions>
@endcan
@endif
@endcan
</x-slot:actions>
@if ($fullUpgradeOnly)
<div class="px-4 pt-4">
@@ -210,16 +290,39 @@
</div>
@endif
@if ($shownUpdates->isEmpty())
<div class="p-6">
<x-empty size="sm" title="No matching updates"
description="Try another package name, or clear the search." icon-name="search" />
</div>
@else
<div class="data-table">
@php
$canSelect = ! $fullUpgradeOnly && auth()->user()?->can('update', $server);
@endphp
<div class="data-table-header package-updates-table-grid">
<span class="flex items-center">
@if ($canSelect)
<input type="checkbox" wire:click="toggleAllPackages" @checked($allMatchingSelected)
aria-label="Select all packages" wire:loading.attr="disabled"
class="size-4 cursor-pointer rounded-sm border-neutral-300 text-accent focus:ring-accent dark:border-white/20 dark:bg-transparent" />
@endif
</span>
<span>Package</span>
<span>New version</span>
<span class="text-right">Action</span>
</div>
@foreach ($updates as $update)
@foreach ($shownUpdates as $update)
<div wire:key="package-update-{{ data_get($update, 'package') }}-{{ data_get($update, 'architecture') }}"
class="data-table-row package-updates-table-grid border-b border-neutral-200 last:border-b-0 dark:border-white/[0.08]">
<div class="flex min-w-0 items-center gap-2">
<div class="flex items-center">
@if ($canSelect)
<input type="checkbox" wire:model.live="selectedPackages" value="{{ data_get($update, 'package') }}"
id="select-package-{{ data_get($update, 'package') }}"
aria-label="Select {{ data_get($update, 'package') }}"
class="size-4 cursor-pointer rounded-sm border-neutral-300 text-accent focus:ring-accent dark:border-white/20 dark:bg-transparent" />
@endif
</div>
<label for="select-package-{{ data_get($update, 'package') }}" class="flex min-w-0 cursor-pointer items-center gap-2">
@if (filled(data_get($update, 'risk')))
<span class="shrink-0" data-tooltip="{{ \App\Actions\Server\CheckUpdates::RISK_LABELS[data_get($update, 'risk')] ?? '' }}">
<x-reicon name="alert-triangle" class="size-4 text-red-500" />
@@ -232,7 +335,7 @@
@if (data_get($update, 'security'))
<x-status-badge status="Security" type="error" />
@endif
</div>
</label>
<div class="min-w-0">
<p class="truncate font-mono text-[12px] text-neutral-700 dark:text-fg-dim">
{{ data_get($update, 'new_version') }}
@@ -243,23 +346,19 @@
</p>
@endif
</div>
<div class="flex justify-end">
@if (! $fullUpgradeOnly)
@can('update', $server)
<x-forms.button type="button"
wire:click="updatePackage({{ Js::from(data_get($update, 'package')) }})">
Update
</x-forms.button>
@endcan
@endif
</div>
</div>
@endforeach
<div
class="flex min-h-11 items-center border-t border-neutral-200 px-4 text-[11px] text-neutral-500 dark:border-white/[0.08] dark:text-fg-faint">
{{ $totalUpdates }} {{ Str::plural('package update', $totalUpdates) }}
</div>
<x-table-pagination :from="$shownUpdates->firstItem() ?? 0" :to="$shownUpdates->lastItem() ?? 0"
:total="$shownUpdates->total()" :current-page="$shownUpdates->currentPage()"
:last-page="$shownUpdates->lastPage()" wire-target="previousPage,nextPage,perPage"
previous-action="previousPage" next-action="nextPage">
<x-slot:pageSize>
<x-page-size-select model="perPage" livewire
storage-key="coolify.page-size.server-package-updates" />
</x-slot:pageSize>
</x-table-pagination>
</div>
@endif
</x-application.settings-section>
@endif
@@ -267,15 +366,18 @@
<x-application.settings-section id="server-held-updates-section" title="Held back updates"
helper="The package manager does not install these updates. Phased rollout: Ubuntu releases the update to a part of all servers at a time, and this server gets it in a later phase. Held: a package hold (apt-mark hold) or a version pin (/etc/apk/world) on the server blocks the update."
flush>
<x-slot:badges>
<x-status-badge :status="count($heldUpdates) . ' held'" type="warning" />
</x-slot:badges>
<div class="data-table">
<div class="data-table-header package-updates-table-grid">
<div class="data-table-header held-updates-table-grid">
<span>Package</span>
<span>New version</span>
<span class="text-right">Reason</span>
</div>
@foreach ($heldUpdates as $update)
<div wire:key="held-update-{{ data_get($update, 'package') }}-{{ data_get($update, 'architecture') }}"
class="data-table-row package-updates-table-grid border-b border-neutral-200 last:border-b-0 dark:border-white/[0.08]">
class="data-table-row held-updates-table-grid border-b border-neutral-200 last:border-b-0 dark:border-white/[0.08]">
<span class="min-w-0 truncate font-mono text-[12px] text-neutral-950 dark:text-fg">
{{ data_get($update, 'package') }}
</span>
@@ -295,5 +397,8 @@
</div>
</x-application.settings-section>
@endif
@endif
</div>
</div>
</div>
@@ -1,6 +1,9 @@
<div class="application-settings-form w-full">
<x-application.settings-section id="server-terminal-access-section" title="Terminal access"
helper="Control dashboard terminal access for this server and its containers.">
<x-slot:badges>
<x-status-badge :status="$isTerminalEnabled ? 'Enabled' : 'Disabled'" :type="$isTerminalEnabled ? 'success' : 'neutral'" />
</x-slot:badges>
<x-slot:actions>
@can('update', $server)
<div wire:key="terminal-access-change-{{ $isTerminalEnabled }}">
+6 -2
View File
@@ -110,6 +110,7 @@ use App\Livewire\Server\Proxy\Show as ProxyShow;
use App\Livewire\Server\Registries as ServerRegistries;
use App\Livewire\Server\Resources as ResourcesShow;
use App\Livewire\Server\Security\Index as ServerSecurityIndex;
use App\Livewire\Server\Security\Patches as ServerSecurityPatches;
use App\Livewire\Server\Sentinel\Logs as SentinelLogs;
use App\Livewire\Server\Sentinel\Show as SentinelShow;
use App\Livewire\Server\Show as ServerShow;
@@ -562,9 +563,12 @@ Route::middleware(['auth', 'verified'])->group(function () {
Route::get('/github-runners', GithubRunners::class)->name('server.github-runners');
Route::get('/registries', ServerRegistries::class)->name('server.registries')->middleware('can.update.resource');
Route::get('/security', ServerSecurityIndex::class)->name('server.security')->middleware('can.update.resource');
Route::get('/security/image-scanning', ServerImageScanning::class)->name('server.security.image-scanning')->middleware('can.update.resource');
Route::get('/security/patches', ServerSecurityPatches::class)->name('server.security.patches')->middleware('can.update.resource');
Route::get('/security/vulnerabilities', ServerImageScanning::class)->name('server.security.vulnerabilities')->middleware('can.update.resource');
// Old sub pages. Links in sent notifications still use them.
foreach (['patches' => 'server-patching-overview-section', 'terminal-access' => 'server-terminal-access-section', 'host-key' => 'server-host-key-section'] as $page => $section) {
Route::get('/security/image-scanning', fn (string $server_uuid) => redirect(route('server.security.vulnerabilities', ['server_uuid' => $server_uuid])))
->name('server.security.image-scanning');
foreach (['terminal-access' => 'server-terminal-access-section', 'host-key' => 'server-host-key-section'] as $page => $section) {
Route::get("/security/{$page}", fn (string $server_uuid) => redirect(route('server.security', ['server_uuid' => $server_uuid]).'#'.$section))
->name("server.security.{$page}");
}
@@ -127,6 +127,7 @@ function rescanTestFake(array $overrides = []): void
'grype' => rescanTestPayload('grype', file_get_contents(base_path('tests/Fixtures/image-scan/grype.ndjson'))),
'sbom-delete' => 'coolify-scan-exit sbom-delete 0',
'disk-usage' => '',
'scanner-images' => 'coolify-scan-exit scanner-images 0',
], $overrides);
Process::fake(function (PendingProcess $process) use ($outputs) {
@@ -160,6 +161,7 @@ function rescanTestCommandKey(string $command): ?string
str_contains($command, 'coolify-scan-exit containers') => 'containers',
str_contains($command, 'coolify-scan-exit sbom-delete') => 'sbom-delete',
str_contains($command, 'coolify-scan-exit disk-db') => 'disk-usage',
str_contains($command, 'coolify-scan-exit scanner-images') => 'scanner-images',
str_contains($command, 'docker image inspect') => 'inspect',
str_contains($command, 'df -P') => 'disk',
str_contains($command, 'docker save') => 'sbom',
@@ -436,6 +438,39 @@ describe('rescan', function () {
->and(Cache::get(PrepareImageScannerJob::diskUsedCacheKey($this->server)))->toBe(1024);
});
it('marks the scanner as ready after a database update, so the server page does not ask to get it again', function () {
rescanTestFake(['db-status' => fn () => rescanTestDatabaseStatus(now()->subHours(3))]);
Cache::put(PrepareImageScannerJob::failedStepCacheKey($this->server), 'pulling');
rescanTestRun();
expect(Cache::get(PrepareImageScannerJob::stateCacheKey($this->server)))->toBe(PrepareImageScannerJob::STATE_READY)
->and(Cache::get(PrepareImageScannerJob::failedStepCacheKey($this->server)))->toBeNull();
});
it('keeps the failed preparation after a database update when a scanner image is missing', function () {
rescanTestFake([
'db-status' => fn () => rescanTestDatabaseStatus(now()->subHours(3)),
'scanner-images' => 'coolify-scan-exit scanner-images 1',
]);
Cache::put(PrepareImageScannerJob::stateCacheKey($this->server), PrepareImageScannerJob::STATE_FAILED);
Cache::put(PrepareImageScannerJob::failedStepCacheKey($this->server), 'pulling');
rescanTestRun();
expect(Cache::get(PrepareImageScannerJob::stateCacheKey($this->server)))->toBe(PrepareImageScannerJob::STATE_FAILED)
->and(Cache::get(PrepareImageScannerJob::failedStepCacheKey($this->server)))->toBe('pulling');
});
it('does not mark the scanner as ready while the scanner is being prepared', function () {
rescanTestFake(['db-status' => fn () => rescanTestDatabaseStatus(now()->subHours(3))]);
Cache::put(PrepareImageScannerJob::stateCacheKey($this->server), PrepareImageScannerJob::STATE_PULLING);
rescanTestRun();
expect(Cache::get(PrepareImageScannerJob::stateCacheKey($this->server)))->toBe(PrepareImageScannerJob::STATE_PULLING);
});
it('keeps the results and records the error for each resource when the database is too old', function () {
rescanTestFake([
'db-update' => 'coolify-scan-exit db-update 1',
@@ -0,0 +1,327 @@
<?php
use App\Actions\ImageScan\ScanHostPackages;
use App\Actions\ImageScan\ScanImage;
use App\Enums\ImageScanErrorCode;
use App\Events\ServerPackagesUpdateFinished;
use App\Jobs\ImageRescanJob;
use App\Jobs\ScanHostPackagesJob;
use App\Livewire\Server\Security\HostVulnerabilities;
use App\Models\InstanceSettings;
use App\Models\PrivateKey;
use App\Models\Server;
use App\Models\ServerHostScan;
use App\Models\Team;
use App\Models\User;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Process\PendingProcess;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\Storage;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
Server::flushIdentityMap();
Storage::fake('ssh-keys');
$this->withoutVite();
config(['constants.ssh.mux_enabled' => false, 'constants.ssh.max_retries' => 1]);
InstanceSettings::forceCreate(['id' => 0, 'is_image_scanning_allowed' => true]);
$this->team = Team::factory()->create();
$this->server = Server::factory()->create([
'team_id' => $this->team->id,
'private_key_id' => PrivateKey::factory()->create(['team_id' => $this->team->id])->id,
]);
$this->server->settings->forceFill(['is_reachable' => true, 'is_usable' => true, 'is_image_scan_enabled' => true])->saveQuietly();
$this->server = $this->server->fresh();
});
/**
* @param array<string, string|Closure(): string> $overrides
*/
function hostScanTestFake(array $overrides = []): void
{
$payload = file_get_contents(base_path('tests/Fixtures/image-scan/grype-host.ndjson'));
$outputs = array_merge([
'db-status' => "coolify-scan-exit db-status 0 180\ncoolify-scan-payload\n".json_encode(['built' => now()->subDay()->format('Y-m-d\TH:i:s\Z'), 'valid' => true]),
'packages-size' => 'coolify-scan-exit packages-size 0 204800',
'disk' => '107374182400 107374182400',
'sbom' => "coolify-scan-exit copy 0\ncoolify-scan-exit sbom 0",
'grype' => 'coolify-scan-exit grype 0 '.strlen($payload)."\ncoolify-scan-payload\n{$payload}",
'containers' => '',
], $overrides);
Process::fake(function (PendingProcess $process) use ($outputs) {
$script = remoteProcessScript($process);
$key = match (true) {
str_contains($script, 'coolify-scan-exit db-status') => 'db-status',
str_contains($script, 'coolify-scan-exit packages-size') => 'packages-size',
str_contains($script, 'df -P') => 'disk',
str_contains($script, 'dir:/in/host') => 'sbom',
str_contains($script, 'coolify-scan-exit grype') => 'grype',
str_contains($script, 'coolify-scan-exit containers') => 'containers',
default => null,
};
return Process::result(output: $key === null ? '' : value($outputs[$key]));
});
}
it('saves the relevant host findings, exploited and fixable ones first', function () {
hostScanTestFake();
expect(ScanHostPackages::run($this->server))->toBeTrue();
$scan = $this->server->hostScan()->firstOrFail();
expect($scan->error_code)->toBeNull()
->and($scan->scanned_at)->not->toBeNull()
->and($scan->findings_truncated)->toBeFalse()
->and($scan->summary)->toBe(['critical' => 2, 'high' => 1, 'medium' => 0, 'low' => 1, 'unknown' => 0, 'fixable' => 2, 'exploited' => 2, 'likely' => 1, 'other_kernels' => 0, 'total' => 5, 'running_kernel' => null])
->and(array_map(fn ($finding) => [$finding->displayId, $finding->package, $finding->fixedIn], $scan->findingList()))->toBe([
['CVE-2026-111497', 'libbrotli-dev', '1.1.0-2+deb13u1'],
['CVE-2023-5217', 'firefox-esr', null],
['CVE-2024-1283', 'gir1.2-javascriptcoregtk-4.1', '2.54.0-1~deb13u1'],
['CVE-2026-34980', 'cups', null],
]);
Process::assertRan(fn (PendingProcess $process) => str_contains(remoteProcessScript($process), 'coolify-scan-exit grype') && str_contains(remoteProcessScript($process), "'sbom:/sbom/sbom.json'"));
});
it('keeps the last findings and saves the error when the server has no package database', function () {
$previous = ServerHostScan::factory()->create(['server_id' => $this->server->id, 'findings' => [['id' => 'CVE-2024-0001', 'package' => 'curl']]]);
hostScanTestFake(['sbom' => "coolify-scan-exit copy 1\ncoolify-scan-exit sbom 0"]);
ScanHostPackages::run($this->server);
$scan = $previous->fresh();
expect($scan->error_code)->toBe(ImageScanErrorCode::NoPackageDatabase)
->and($scan->findings)->toBe([['id' => 'CVE-2024-0001', 'package' => 'curl']])
->and($scan->scanned_at->equalTo($previous->scanned_at))->toBeTrue();
});
it('keeps the last findings when a package database copy fails', function () {
$previous = ServerHostScan::factory()->create(['server_id' => $this->server->id, 'findings' => [['id' => 'CVE-2024-0001', 'package' => 'curl']]]);
hostScanTestFake(['sbom' => "coolify-scan-exit copy 2\ncoolify-scan-exit sbom 0"]);
ScanHostPackages::run($this->server);
$scan = $previous->fresh();
expect($scan->error_code)->toBe(ImageScanErrorCode::Unknown)
->and($scan->findings)->toBe([['id' => 'CVE-2024-0001', 'package' => 'curl']])
->and($scan->scanned_at->equalTo($previous->scanned_at))->toBeTrue();
Process::assertDidntRun(fn (PendingProcess $process) => str_contains(remoteProcessScript($process), 'coolify-scan-exit grype'));
});
it('saves the database age when the vulnerability database is too old and clears it after a later scan', function () {
hostScanTestFake(['db-status' => "coolify-scan-exit db-status 0 180\ncoolify-scan-payload\n".json_encode(['built' => now()->subDays(9)->format('Y-m-d\TH:i:s\Z'), 'valid' => true])]);
ScanHostPackages::run($this->server);
$scan = $this->server->hostScan()->first();
expect($scan->error_code)->toBe(ImageScanErrorCode::DbTooOld)
->and($scan->error_context)->toBe(['n' => 9])
->and($scan->error_code->message($scan->error_context))->toStartWith('The vulnerability database is 9 days old.');
hostScanTestFake();
ScanHostPackages::run($this->server);
expect($scan->fresh())->error_code->toBeNull()->error_context->toBeNull();
});
it('does not copy the package databases when the disk has not enough free space', function () {
$previous = ServerHostScan::factory()->create(['server_id' => $this->server->id, 'findings' => [['id' => 'CVE-2024-0001', 'package' => 'curl']]]);
hostScanTestFake(['packages-size' => 'coolify-scan-exit packages-size 0 512000', 'disk' => '107374182400 1500000000']);
expect(ScanHostPackages::run($this->server))->toBeTrue();
$scan = $previous->fresh();
expect($scan->error_code)->toBe(ImageScanErrorCode::DiskSpace)
->and($scan->findings)->toBe([['id' => 'CVE-2024-0001', 'package' => 'curl']]);
Process::assertNotRan(fn (PendingProcess $process) => str_contains(remoteProcessScript($process), 'dir:/in/host'));
});
it('does not copy the package databases when their size cannot be measured', function (string $output) {
$previous = ServerHostScan::factory()->create(['server_id' => $this->server->id, 'findings' => [['id' => 'CVE-2024-0001', 'package' => 'curl']]]);
hostScanTestFake(['packages-size' => $output]);
expect(ScanHostPackages::run($this->server))->toBeTrue();
$scan = $previous->fresh();
expect($scan->error_code)->toBe(ImageScanErrorCode::Unknown)
->and($scan->findings)->toBe([['id' => 'CVE-2024-0001', 'package' => 'curl']]);
Process::assertNotRan(fn (PendingProcess $process) => str_contains(remoteProcessScript($process), 'dir:/in/host'));
})->with([
'du failed' => ['coolify-scan-exit packages-size 1 0'],
'no output' => [''],
]);
it('returns false without scanning when another scan holds the server lock', function () {
hostScanTestFake();
$lock = ScanImage::serverLock($this->server);
$lock->get();
try {
expect(ScanHostPackages::run($this->server))->toBeFalse();
} finally {
$lock->release();
}
expect($this->server->hostScan()->exists())->toBeFalse();
Process::assertNothingRan();
});
it('retries a busy host scan and records lock_busy after the last try', function () {
hostScanTestFake();
$lock = ScanImage::serverLock($this->server);
$lock->get();
Cache::put(ScanHostPackagesJob::runningCacheKey($this->server), true);
try {
$job = (new ScanHostPackagesJob($this->server))->withFakeQueueInteractions();
$job->handle();
$job->assertReleased(300);
expect($this->server->hostScan()->exists())->toBeFalse()
->and(Cache::has(ScanHostPackagesJob::runningCacheKey($this->server)))->toBeTrue();
$lastTry = (new ScanHostPackagesJob($this->server))->withFakeQueueInteractions();
$lastTry->job->attempts = $lastTry->tries;
$lastTry->handle();
$lastTry->assertNotReleased();
} finally {
$lock->release();
}
expect($this->server->hostScan()->first()->error_code)->toBe(ImageScanErrorCode::LockBusy)
->and(Cache::has(ScanHostPackagesJob::runningCacheKey($this->server)))->toBeFalse();
Process::assertNothingRan();
});
it('keeps the running marker of a scan requested while a scan runs', function () {
Queue::fake([ScanHostPackagesJob::class]);
$payload = file_get_contents(base_path('tests/Fixtures/image-scan/grype-host.ndjson'));
hostScanTestFake(['grype' => function () use ($payload) {
ScanHostPackagesJob::dispatchManual($this->server);
return 'coolify-scan-exit grype 0 '.strlen($payload)."\ncoolify-scan-payload\n{$payload}";
}]);
ScanHostPackagesJob::dispatchManual($this->server);
(new ScanHostPackagesJob($this->server))->withFakeQueueInteractions()->handle();
expect(Cache::has(ScanHostPackagesJob::runningCacheKey($this->server)))->toBeTrue();
hostScanTestFake();
(new ScanHostPackagesJob($this->server))->withFakeQueueInteractions()->handle();
expect(Cache::has(ScanHostPackagesJob::runningCacheKey($this->server)))->toBeFalse();
});
it('does not scan a server with image scanning off', function () {
hostScanTestFake();
$this->server->settings->forceFill(['is_image_scan_enabled' => false])->saveQuietly();
ScanHostPackages::run($this->server->fresh());
expect($this->server->hostScan()->exists())->toBeFalse();
Process::assertNothingRan();
});
it('queues a host scan after a daily rescan that cannot list the containers', function () {
Queue::fake([ScanHostPackagesJob::class]);
hostScanTestFake();
(new ImageRescanJob($this->server))->handle();
expect(ImageRescanJob::failedDays($this->server))->toBe(1);
Queue::assertPushed(ScanHostPackagesJob::class, fn ($job) => $job->server->is($this->server));
});
it('queues a host scan after a completed daily rescan', function () {
Queue::fake([ScanHostPackagesJob::class]);
hostScanTestFake(['containers' => "coolify-scan-exit containers 0\ncoolify-scan-payload\n"]);
(new ImageRescanJob($this->server))->handle();
expect(ImageRescanJob::failedDays($this->server))->toBe(0);
Queue::assertPushed(ScanHostPackagesJob::class, fn ($job) => $job->server->is($this->server));
});
it('queues a host scan after a package update', function () {
Queue::fake();
ServerPackagesUpdateFinished::dispatch(['server_uuid' => $this->server->uuid, 'packages' => ['curl']]);
Queue::assertPushed(ScanHostPackagesJob::class, fn ($job) => $job->server->is($this->server));
expect(Cache::has(ScanHostPackagesJob::runningCacheKey($this->server)))->toBeTrue();
});
describe('vulnerabilities page', function () {
beforeEach(function () {
Queue::fake();
$this->admin = User::factory()->create();
$this->team->members()->attach($this->admin, ['role' => 'admin']);
ServerHostScan::factory()->create([
'server_id' => $this->server->id,
'summary' => ['critical' => 1, 'high' => 0, 'medium' => 0, 'low' => 0, 'unknown' => 0, 'fixable' => 1, 'exploited' => 1, 'likely' => 0, 'total' => 3],
'findings' => [['id' => 'CVE-2026-111497', 'severity' => 'critical', 'package' => 'libbrotli-dev', 'version' => '1.1.0-2+b7', 'fixedIn' => '1.1.0-2+deb13u1', 'kev' => true]],
]);
$this->server->settings->forceFill(['patch_check_result' => ['osId' => 'debian', 'package_manager' => 'apt', 'updates' => [['package' => 'libbrotli-dev', 'new_version' => '1.1.0-2+deb13u1']]]])->saveQuietly();
});
it('shows the host findings with the available update to an admin and queues a scan', function () {
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
Livewire::test(HostVulnerabilities::class, ['server_uuid' => $this->server->uuid])
->assertSee('CVE-2026-111497')
->assertSee('Update available: 1.1.0-2+deb13u1')
->call('scanHostPackages')
->assertSet('hostScanRunning', true);
Queue::assertPushed(ScanHostPackagesJob::class, fn ($job) => $job->server->is($this->server));
});
it('classifies kernel findings by the kernel that ran during the scan, not by an older patch check', function () {
$this->server->hostScan()->firstOrFail()->update([
'summary' => ['critical' => 1, 'high' => 0, 'medium' => 0, 'low' => 0, 'unknown' => 0, 'fixable' => 0, 'exploited' => 0, 'likely' => 0, 'other_kernels' => 1, 'total' => 2, 'running_kernel' => '6.8.0-140-generic'],
'findings' => [
['id' => 'CVE-2026-1001', 'severity' => 'critical', 'package' => 'linux-image-6.8.0-140-generic', 'version' => '6.8.0-140.140'],
['id' => 'CVE-2026-1002', 'severity' => 'high', 'package' => 'linux-image-6.8.0-139-generic', 'version' => '6.8.0-139.139'],
],
]);
$this->server->settings->forceFill(['patch_check_result' => ['osId' => 'ubuntu', 'package_manager' => 'apt', 'updates' => [], 'kernels' => [
'running' => '6.8.0-139-generic',
'installed' => [
['package' => 'linux-image-6.8.0-139-generic', 'version' => '6.8.0-139.139', 'release' => '6.8.0-139-generic'],
['package' => 'linux-image-6.8.0-140-generic', 'version' => '6.8.0-140.140', 'release' => '6.8.0-140-generic'],
],
]]])->saveQuietly();
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
Livewire::test(HostVulnerabilities::class, ['server_uuid' => $this->server->uuid])
->assertSee('CVE-2026-1001')
->assertDontSee('CVE-2026-1002')
->assertDontSee('starts after a reboot');
});
it('refuses the page to a member', function () {
$member = User::factory()->create();
$this->team->members()->attach($member, ['role' => 'member']);
$this->actingAs($member);
session(['currentTeam' => $this->team]);
Livewire::test(HostVulnerabilities::class, ['server_uuid' => $this->server->uuid])->assertForbidden();
Queue::assertNotPushed(ScanHostPackagesJob::class);
});
it('does not show the server of another team', function () {
$otherTeam = Team::factory()->create();
$otherTeam->members()->attach($this->admin, ['role' => 'owner']);
$this->actingAs($this->admin);
session(['currentTeam' => $otherTeam]);
expect(fn () => Livewire::test(HostVulnerabilities::class, ['server_uuid' => $this->server->uuid]))->toThrow(ModelNotFoundException::class);
});
});
+1 -1
View File
@@ -501,7 +501,7 @@ it('says that the scanner ran out of memory only when Docker reports that the me
expect($scan->status)->toBe(ImageScanStatus::Failed)
->and($scan->error_code)->toBe(ImageScanErrorCode::OutOfMemory)
->and(ImageScanErrorCode::OutOfMemory->message())->toBe('The scanner needed more than 1 GB of memory for this image and was stopped.');
->and(ImageScanErrorCode::OutOfMemory->message())->toBe('The scanner ran out of memory and was stopped. It can use up to 1 GB, and the server must have that much free memory.');
})->with([
'syft' => ['sbom', "coolify-scan-exit save 0\ncoolify-scan-oom sbom\ncoolify-scan-exit sbom 137"],
'packages' => ['packages', "coolify-scan-oom packages\n".scanImageTestPayload('packages', '', 0, 137)],
@@ -101,27 +101,38 @@ describe('server image scanning page', function () {
it('shows the off state with the turn on button', function () {
imageScanningPage()
->assertSee('Image scanning is off for '.$this->server->name.'.')
->assertSee('Turn on image scanning');
->assertSee('Enable image scanning?');
});
it('shows the progress of the scanner preparation', function () {
it('shows only the title row and the database build time in the helper when the scanner is ready', function () {
$this->server->settings->forceFill(['is_image_scan_enabled' => true])->saveQuietly();
Cache::put(PrepareImageScannerJob::stateCacheKey($this->server), 'ready');
Cache::put(PrepareImageScannerJob::builtAtCacheKey($this->server), now()->subDay()->toIso8601ZuluString());
imageScanningPage()
->assertSee('Get scanner')
->assertSee('Update database')
->assertSee('Database built')
->assertSee('Turn off image scanning')
->assertSee('Database built 1 day ago')
->assertSee('Coolify downloads the latest vulnerability database every day')
->assertDontSee('Update database')
->assertDontSee('Get scanner')
->assertSee('Disable image scanning?')
->assertSee('Delete scanner data');
});
it('shows the progress of the scanner preparation', function () {
$this->server->settings->forceFill(['is_image_scan_enabled' => true])->saveQuietly();
Cache::put(PrepareImageScannerJob::stateCacheKey($this->server), 'updating_database');
imageScanningPage()
->assertSee('Get scanner')
->assertSee('Downloading the vulnerability database.')
->assertDontSee('Database built');
});
it('refuses to turn on a server that another instance manages', function () {
Queue::fake();
$this->server->forceFill(['server_metadata' => ['transfer' => ['status' => 'transferred']]])->saveQuietly();
imageScanningPage()->call('enable')->assertDispatched('error', 'This server is managed by another Coolify instance.');
imageScanningPage()->call('enable')->assertDispatched('error', message: 'This server is managed by another Coolify instance.');
expect($this->server->settings->fresh()->is_image_scan_enabled)->toBeFalse();
Queue::assertNothingPushed();
@@ -133,7 +144,7 @@ describe('server image scanning page', function () {
imageScanningPage()
->assertSee('Image scanning is turned off for this Coolify instance.')
->assertDontSee('Turn on image scanning')
->assertDontSee('Enable image scanning?')
->call('enable')
->assertDispatched('error', message: 'Image scanning is turned off for this Coolify instance.');
@@ -152,6 +163,24 @@ describe('server image scanning page', function () {
expect(Cache::has(PrepareImageScannerJob::stateCacheKey($this->server)))->toBeFalse();
});
it('does not delete the scanner data while a scan holds the server lock', function () {
Process::fake(fn () => Process::result(output: ''));
$this->server->settings->forceFill(['is_image_scan_enabled' => true])->saveQuietly();
Cache::put(PrepareImageScannerJob::stateCacheKey($this->server), 'ready');
$lock = ScanImage::serverLock($this->server);
$lock->get();
imageScanningPage()
->call('deleteScannerData')
->assertDispatched('error')
->assertNotDispatched('success');
$lock->release();
Process::assertDidntRun(fn (PendingProcess $process) => str_contains(remoteProcessScript($process), 'docker volume rm -f coolify-grype-db'));
expect(Cache::get(PrepareImageScannerJob::stateCacheKey($this->server)))->toBe('ready')
->and(ScanImage::serverLock($this->server)->get())->toBeTrue();
});
it('reports a failed deletion and keeps the progress when the server cannot be reached', function () {
Process::fake(fn () => Process::result(errorOutput: 'ssh: connect to host failed', exitCode: 255));
$this->server->settings->forceFill(['is_image_scan_enabled' => true])->saveQuietly();
@@ -177,6 +206,22 @@ describe('server image scanning page', function () {
->assertSee('Disk used by scanner data: 1.1 GB');
});
it('hides the delete action only when the server is known to have no scanner data', function () {
Process::fake(fn () => Process::result(output: ''));
$this->server->settings->forceFill(['is_image_scan_enabled' => true])->saveQuietly();
// No cached state or disk use: the data can still be on the server.
imageScanningPage()->assertSee('Delete scanner data');
Cache::put(PrepareImageScannerJob::diskUsedCacheKey($this->server), 0);
imageScanningPage()->assertDontSee('Delete scanner data');
Cache::put(PrepareImageScannerJob::stateCacheKey($this->server), 'ready');
imageScanningPage()->assertSee('Delete scanner data');
imageScanningPage()->call('deleteScannerData')->assertDontSee('Delete scanner data');
});
it('shows no disk usage before the job has stored it', function () {
$this->server->settings->forceFill(['is_image_scan_enabled' => true])->saveQuietly();
@@ -243,7 +288,8 @@ describe('prepare image scanner job', function () {
(new PrepareImageScannerJob($this->server))->handle();
expect(Cache::get(PrepareImageScannerJob::stateCacheKey($this->server)))->toBe('ready')
->and(Cache::get(PrepareImageScannerJob::builtAtCacheKey($this->server)))->toBe('2026-10-25T08:15:00Z');
->and(Cache::get(PrepareImageScannerJob::builtAtCacheKey($this->server)))->toBe('2026-10-25T08:15:00Z')
->and(ScanImage::serverLock($this->server)->get())->toBeTrue();
Event::assertDispatched(ImageScanUpdated::class, 3);
Event::assertDispatched(ImageScanUpdated::class, fn ($event) => $event->resourceUuid === $this->server->uuid && $event->scanUuid === 'prepare' && $event->teamId === $this->team->id);
});
@@ -256,7 +302,7 @@ describe('prepare image scanner job', function () {
expect(Cache::get(PrepareImageScannerJob::diskUsedCacheKey($this->server)))->toBe(1234);
});
it('updates the database under the scan lock and runs again later when it is busy', function () {
it('prepares the scanner under the scan lock and runs again later when it is busy', function () {
fakeScannerPreparation();
$lock = ScanImage::serverLock($this->server);
$lock->get();
@@ -269,8 +315,8 @@ describe('prepare image scanner job', function () {
$job->handle();
$lock->release();
Process::assertDidntRun(fn (PendingProcess $process) => str_contains(remoteProcessScript($process), 'coolify-scan-exit db-update'));
expect(Cache::get(PrepareImageScannerJob::stateCacheKey($this->server)))->toBe('updating_database');
Process::assertNothingRan();
expect(Cache::get(PrepareImageScannerJob::stateCacheKey($this->server)))->toBe('pulling');
});
it('is unique per server', function () {
@@ -229,7 +229,7 @@ describe('scan now', function () {
vulnerabilitiesPage($this->admin, $this->application)
->assertSee('The last scan could not run')
->call('scanNow')
->assertSee('Get scanner')
->assertSee('Start scan')
->assertDontSee('The last scan could not run');
});
@@ -280,7 +280,7 @@ describe('scan progress', function () {
$checkpoints = vulnerabilitiesPage($this->admin, $this->application)->viewData('checkpoints');
expect(array_column($checkpoints, 'status', 'title'))->toBe(array_combine(
['Get scanner', 'Update database', 'Read image', 'Find vulnerabilities'],
['Start scan', 'Update database', 'Read image', 'Find vulnerabilities'],
$statuses,
));
})->with([
@@ -57,7 +57,7 @@ it('generates url using base_url instead of APP_URL', function () {
$notification = new ServerPatchCheck($mockServer, $patchData);
// The URL should use the FQDN from InstanceSettings, not APP_URL
expect($notification->serverUrl)->toBe('https://coolify.example.com/server/test-server-uuid/security#server-patching-overview-section');
expect($notification->serverUrl)->toBe('https://coolify.example.com/server/test-server-uuid/security/patches');
});
it('falls back to public_ipv4 with port when fqdn is not set', function () {
@@ -76,7 +76,7 @@ it('falls back to public_ipv4 with port when fqdn is not set', function () {
$notification = new ServerPatchCheck($mockServer, $patchData);
// The URL should use public IPv4 with default port 8000
expect($notification->serverUrl)->toBe('http://192.168.1.100:8000/server/test-server-uuid/security#server-patching-overview-section');
expect($notification->serverUrl)->toBe('http://192.168.1.100:8000/server/test-server-uuid/security/patches');
});
it('includes server url in all notification channels', function () {
@@ -103,23 +103,23 @@ it('includes server url in all notification channels', function () {
// Check Discord
$discord = $notification->toDiscord();
expect($discord->description)->toContain('https://coolify.test/server/abc-123/security#server-patching-overview-section');
expect($discord->description)->toContain('https://coolify.test/server/abc-123/security/patches');
// Check Telegram
$telegram = $notification->toTelegram();
expect($telegram['buttons'][0]['url'])->toBe('https://coolify.test/server/abc-123/security#server-patching-overview-section');
expect($telegram['buttons'][0]['url'])->toBe('https://coolify.test/server/abc-123/security/patches');
// Check Pushover
$pushover = $notification->toPushover();
expect($pushover->buttons[0]['url'])->toBe('https://coolify.test/server/abc-123/security#server-patching-overview-section');
expect($pushover->buttons[0]['url'])->toBe('https://coolify.test/server/abc-123/security/patches');
// Check Slack
$slack = $notification->toSlack();
expect($slack->description)->toContain('https://coolify.test/server/abc-123/security#server-patching-overview-section');
expect($slack->description)->toContain('https://coolify.test/server/abc-123/security/patches');
// Check Webhook
$webhook = $notification->toWebhook();
expect($webhook['url'])->toBe('https://coolify.test/server/abc-123/security#server-patching-overview-section');
expect($webhook['url'])->toBe('https://coolify.test/server/abc-123/security/patches');
});
it('uses correct url in error notifications', function () {
@@ -137,10 +137,10 @@ it('uses correct url in error notifications', function () {
// Check error Discord notification
$discord = $notification->toDiscord();
expect($discord->description)->toContain('https://coolify.production.com/server/error-server-uuid/security#server-patching-overview-section');
expect($discord->description)->toContain('https://coolify.production.com/server/error-server-uuid/security/patches');
// Check error webhook
$webhook = $notification->toWebhook();
expect($webhook['url'])->toBe('https://coolify.production.com/server/error-server-uuid/security#server-patching-overview-section')
expect($webhook['url'])->toBe('https://coolify.production.com/server/error-server-uuid/security/patches')
->and($webhook['event'])->toBe('server_patch_check_error');
});
@@ -233,6 +233,23 @@ describe('patches page', function () {
->call('updatePackage', 'curl; reboot')
->assertDispatched('error', message: 'This package has no update in the last check.');
});
it('keeps the selected packages when the update does not start, and clears them when it starts', function () {
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
$this->server->settings->forceFill(['patch_check_result' => patchesTestResult(2), 'patch_checked_at' => now()])->saveQuietly();
$activity = Activity::query()->create(['log_name' => 'default', 'description' => '', 'properties' => []]);
UpdatePackage::shouldRun()->twice()->andReturn(['error' => 'Server is not reachable.'], $activity);
Livewire::test(Patches::class, ['server_uuid' => $this->server->uuid])
->set('selectedPackages', ['openssl', 'curl'])
->call('updateSelectedPackages')
->assertDispatched('error', message: 'Server is not reachable.')
->assertSet('selectedPackages', ['openssl', 'curl'])
->call('updateSelectedPackages')
->assertDispatched('activityMonitor', $activity->id)
->assertSet('selectedPackages', []);
});
});
describe('patches authorization', function () {
+64 -10
View File
@@ -1,5 +1,6 @@
<?php
use App\Helpers\SshMultiplexingHelper;
use App\Jobs\ServerConnectionCheckJob;
use App\Livewire\Server\Navbar;
use App\Livewire\Server\Security\HostKey;
@@ -92,6 +93,59 @@ it('enables the host key check again', function () {
expect(AuditEvent::query()->where('event', 'ui.server.ssh_host_key.check_enabled')->exists())->toBeTrue();
});
it('enforces the host key check on one server from the dropdown and stops enforcing it again', function () {
config(['constants.ssh.host_key_checking' => 'warn']);
Livewire::test(HostKey::class, ['server_uuid' => $this->server->uuid])
->call('setHostKeyCheckMode', 'enforce')
->assertReturned(true);
expect($this->server->settings->fresh()->is_ssh_host_key_check_enforced)->toBeTrue();
expect(SshMultiplexingHelper::hostKeyCheckingMode($this->server->fresh()))->toBe('enforce');
expect(AuditEvent::query()->where('event', 'ui.server.ssh_host_key.check_enforced')->exists())->toBeTrue();
Livewire::test(HostKey::class, ['server_uuid' => $this->server->uuid])
->call('setHostKeyCheckMode', 'warn')
->assertReturned(true);
expect(SshMultiplexingHelper::hostKeyCheckingMode($this->server->fresh()))->toBe('warn');
});
it('does not turn a disabled host key check back on from a stale dropdown', function () {
config(['constants.ssh.host_key_checking' => 'warn']);
$component = Livewire::test(HostKey::class, ['server_uuid' => $this->server->uuid]);
$this->server->settings->forceFill(['is_ssh_host_key_check_disabled' => true])->save();
$component->call('setHostKeyCheckMode', 'enforce');
expect($this->server->settings->fresh())
->is_ssh_host_key_check_disabled->toBeTrue()
->is_ssh_host_key_check_enforced->toBeFalse();
expect(AuditEvent::query()->where('event', 'ui.server.ssh_host_key.check_enforced')->exists())->toBeFalse();
});
it('ignores a host key check mode that the dropdown does not offer', function () {
Livewire::test(HostKey::class, ['server_uuid' => $this->server->uuid])
->call('setHostKeyCheckMode', 'off')
->assertReturned(false);
expect($this->server->settings->fresh())
->is_ssh_host_key_check_disabled->toBeFalse()
->is_ssh_host_key_check_enforced->toBeFalse();
});
it('stops enforcing the host key check when an admin disables it', function () {
$this->server->settings->forceFill(['is_ssh_host_key_check_enforced' => true])->save();
Livewire::test(HostKey::class, ['server_uuid' => $this->server->uuid])
->call('setHostKeyCheckDisabled', true, 'password')
->assertReturned(true);
expect($this->server->settings->fresh())
->is_ssh_host_key_check_disabled->toBeTrue()
->is_ssh_host_key_check_enforced->toBeFalse();
});
it('does not change the host key of a server that was transferred to another instance', function (string $action, array $arguments) {
$component = Livewire::test(HostKey::class, ['server_uuid' => $this->server->uuid]);
$this->server->forceFill(['server_metadata' => ['transfer' => ['status' => 'transferred']]])->save();
@@ -116,18 +170,16 @@ it('shows the security tab for a server that a changed host key stopped', functi
->assertSee(route('server.security', ['server_uuid' => $this->server->uuid]));
});
it('shows the patching section after the trusted key makes the server functional again', function () {
it('shows the patching page only when the server is functional', function () {
$this->server->settings->forceFill(['is_reachable' => false, 'is_usable' => false])->save();
$page = Livewire::test(Index::class, ['server_uuid' => $this->server->uuid])
->assertDontSeeLivewire(Patches::class);
Livewire::test(Patches::class, ['server_uuid' => $this->server->uuid])
->assertSee('Coolify cannot reach')
->assertDontSee('Check frequency');
Livewire::test(HostKey::class, ['server_uuid' => $this->server->uuid])
->call('trustPendingKey', hash('sha256', PENDING_HOST_KEY_A), 'password')
->assertDispatched('hostKeySettingsChanged');
// The connection check that the trust runs found the server working.
$this->server->settings->forceFill(['is_reachable' => true, 'is_usable' => true])->save();
$page->dispatch('hostKeySettingsChanged')->assertSeeLivewire(Patches::class);
Livewire::test(Patches::class, ['server_uuid' => $this->server->fresh()->uuid])
->assertDontSee('Coolify cannot reach')
->assertSee('Check frequency');
});
it('queues the connection check instead of running it during the request', function () {
@@ -177,6 +229,7 @@ function hostKeyPageActions(): array
'reset' => ['resetHostKey', ['password']],
'trust' => ['trustPendingKey', [hash('sha256', PENDING_HOST_KEY_A), 'password']],
'disable check' => ['setHostKeyCheckDisabled', [true, 'password']],
'enforce check' => ['setHostKeyCheckMode', ['enforce']],
];
}
@@ -185,7 +238,8 @@ function expectHostKeyUnchanged(Server $server): void
expect($server->settings->fresh())
->ssh_host_key->toBe(PINNED_HOST_KEY)
->ssh_host_key_pending->toBe(PENDING_HOST_KEY_A)
->is_ssh_host_key_check_disabled->toBeFalse();
->is_ssh_host_key_check_disabled->toBeFalse()
->is_ssh_host_key_check_enforced->toBeFalse();
expect(AuditEvent::query()->where('event', 'like', 'ui.server.ssh_host_key.%')->exists())->toBeFalse();
Queue::assertNotPushed(ServerConnectionCheckJob::class);
}
@@ -2,6 +2,7 @@
use App\Helpers\SshMultiplexingHelper;
use App\Models\Server;
use App\Models\ServerSetting;
use Illuminate\Support\Facades\Log;
/**
@@ -67,3 +68,17 @@ it('does not log a warning for a known SSH host key checking mode', function (st
Log::shouldNotHaveReceived('warning');
})->with(['warn', 'enforce', 'off']);
it('lets a server enforce the check when the instance warns, but never turns on an instance that is off', function (string $instanceMode, bool $disabled, bool $enforced, string $expected) {
config(['constants.ssh.host_key_checking' => $instanceMode]);
$server = new Server;
$server->setRelation('settings', (new ServerSetting)->forceFill(['is_ssh_host_key_check_disabled' => $disabled, 'is_ssh_host_key_check_enforced' => $enforced]));
expect(SshMultiplexingHelper::hostKeyCheckingMode($server))->toBe($expected);
})->with([
'warn, server default' => ['warn', false, false, 'warn'],
'warn, server enforces' => ['warn', false, true, 'enforce'],
'enforce, server default' => ['enforce', false, false, 'enforce'],
'off, server enforces' => ['off', false, true, 'off'],
'warn, server disabled' => ['warn', true, false, 'off'],
]);
@@ -131,26 +131,31 @@ it('learns the host key in the connection check of an unreachable server in enfo
->is_reachable->toBeTrue();
});
it('does not scan again in the connection check for ten minutes after a failed scan', function (string $mode) {
it('scans again in the connection check only after the retry time of a failed scan', function (string $mode, int $retrySeconds) {
config(['constants.ssh.host_key_checking' => $mode]);
fakeHardeningSsh(HARDENING_KEY_B);
fakeHardeningSsh(null);
$server = makeHardeningServer(null);
$server->forceFill(['unreachable_count' => 1])->save();
Cache::put('ssh_host_key_scan_failed_'.$server->uuid, 'Connection refused', 600);
(new ServerConnectionCheckJob($server->fresh()))->handle();
Process::assertDidntRun(fn (PendingProcess $process) => str_contains($process->command, 'PubkeyAuthentication=no'));
expect($server->settings->fresh()->ssh_host_key)->toBeNull();
$this->travel(11)->minutes();
fakeHardeningSsh(HARDENING_KEY_B);
$this->travel($retrySeconds - 10)->seconds();
(new ServerConnectionCheckJob($server->fresh()))->handle();
expect(hardeningScanCount())->toBe(1)
->and($server->settings->fresh()->ssh_host_key)->toBeNull();
$this->travel(20)->seconds();
(new ServerConnectionCheckJob($server->fresh()))->handle();
expect(hardeningScanCount())->toBe(2)
->and($server->settings->fresh())
->ssh_host_key->toBe(HARDENING_KEY_B)
->is_reachable->toBeTrue();
})->with(['warn', 'enforce']);
})->with([
'warn' => ['warn', 600],
'enforce' => ['enforce', 60],
]);
it('reports a normal connection error when the host key check fails but the server sends the pinned key', function () {
config(['constants.ssh.host_key_checking' => 'enforce']);
@@ -221,6 +226,18 @@ it('quotes the host key alias in the ssh command', function () {
expect(SshMultiplexingHelper::generateSshCommand($server, 'ls'))->toContain("-o HostKeyAlias='coolify-{$server->uuid}' ");
});
it('uses the saved host key settings in a job that loaded the server before they changed', function () {
fakeHardeningSsh(null);
$server = makeHardeningServer();
expect(SshMultiplexingHelper::generateSshCommand($server, 'ls'))->toContain('-o StrictHostKeyChecking=no ');
ServerSetting::query()->whereKey($server->settings->id)->update(['is_ssh_host_key_check_enforced' => true, 'ssh_host_key' => HARDENING_KEY_B]);
expect(SshMultiplexingHelper::generateSshCommand($server, 'ls'))->toContain('-o StrictHostKeyChecking=yes ')
->and($server->settings->ssh_host_key)->toBe(HARDENING_KEY_B)
->and($server->settings->isDirty())->toBeFalse();
});
it('deletes the known_hosts file of an old host key', function () {
fakeHardeningSsh(null);
$server = makeHardeningServer();
@@ -0,0 +1,6 @@
{"eol": null}
{"art":"caf56ffa577fffac","cvss":[{"score":9.8,"source":"nvd@nist.gov","version":"3.1"}],"desc":"A buffer overflow vulnerability in the Brotli decoder (c/dec/decode.c) in Google Brotli versions 1.0.3 through 1.2.0 on all platforms allows a remote unauthenticated attacker to corrupt adjacent decoder state memory, disclose sensitive memory contents, cause a denial of service, or potentially execute arbitrary code using a crafted Brotli-compressed stream. Users need to update any Brotli lib","epss":0.82641,"fix":"fixed","fixv":["1.1.0-2+deb13u1"],"id":"CVE-2026-111497","kev":true,"mt":"exact-indirect-match","ns":"debian:distro:debian:13","pkg":"libbrotli-dev","rcvss":[{"score":9.8,"source":"nvd@nist.gov","version":"3.1"},{"score":9.2,"source":"cve-coordination@google.com","version":"4.0"}],"rdesc":"A buffer overflow vulnerability in the Brotli decoder (c/dec/decode.c) in Google Brotli versions 1.0.3 through 1.2.0 on all platforms allows a remote unauthenticated attacker to corrupt adjacent decoder state memory, disclose sensitive memory contents, cause a denial of service, or potentially execute arbitrary code using a crafted Brotli-compressed stream.\n\n\n\n\n\nUsers need to update any Brotli lib","rel":["CVE-2026-111497"],"risk":98.70000000000002,"sev":"Critical","sugg":"1.1.0-2+deb13u1","type":"deb","ver":"1.1.0-2+b7"}
{"art":"e5a076a9bb2f372c","cvss":[{"score":9.8,"source":"nvd@nist.gov","version":"3.1"}],"desc":"Heap buffer overflow in Skia in Google Chrome prior to 121.0.6167.160 allowed a remote attacker to potentially exploit heap corruption via a crafted HTML page. (Chromium security severity: High)","epss":0.21622,"fix":"fixed","fixv":["2.54.0-1~deb13u1"],"id":"CVE-2024-1283","kev":false,"mt":"exact-indirect-match","ns":"debian:distro:debian:13","pkg":"gir1.2-javascriptcoregtk-4.1","rcvss":[{"score":9.8,"source":"nvd@nist.gov","version":"3.1"},{"score":8.8,"source":"134c704f-9b21-4f2e-91b3-4a467353bcc0","version":"3.1"}],"rdesc":"Heap buffer overflow in Skia in Google Chrome prior to 121.0.6167.160 allowed a remote attacker to potentially exploit heap corruption via a crafted HTML page. (Chromium security severity: High)","rel":["CVE-2024-1283"],"risk":20.32468,"sev":"Critical","sugg":"2.54.0-1~deb13u1","type":"deb","ver":"2.52.6-1~deb13u1"}
{"art":"1bbf717e0203c0ca","cvss":[{"score":7.5,"source":"nvd@nist.gov","version":"3.1"}],"desc":"OpenPrinting CUPS is an open source printing system for Linux and other Unix-like operating systems. In versions 2.4.16 and prior, in a network-exposed cupsd with a shared target queue, an unauthorized client can send a Print-Job to that shared PostScript queue without authentication. The server accepts a page-border value supplied as textWithoutLanguage, preserves an embedded newline through opti","epss":0.01652,"fix":"not-fixed","fixv":[],"id":"CVE-2026-34980","kev":false,"mt":"exact-direct-match","ns":"debian:distro:debian:13","pkg":"cups","rcvss":[{"score":7.5,"source":"nvd@nist.gov","version":"3.1"},{"score":6.1,"source":"security-advisories@github.com","version":"4.0"}],"rdesc":"OpenPrinting CUPS is an open source printing system for Linux and other Unix-like operating systems. In versions 2.4.16 and prior, in a network-exposed cupsd with a shared target queue, an unauthorized client can send a Print-Job to that shared PostScript queue without authentication. The server accepts a page-border value supplied as textWithoutLanguage, preserves an embedded newline through opti","rel":["CVE-2026-34980"],"risk":1.239,"sev":"High","type":"deb","ver":"2.4.10-3+deb13u2"}
{"art":"eff5ff29a6135bf5","cvss":[],"desc":"Multiple integer overflows in libgd in PHP before 5.2.4 allow remote attackers to cause a denial of service (application crash) and possibly execute arbitrary code via a large (1) srcW or (2) srcH value to the (a) gdImageCopyResized function, or a large (3) sy (height) or (4) sx (width) value to the (b) gdImageCreate or the (c) gdImageCreateTrueColor function.","epss":0.04219,"fix":"not-fixed","fixv":[],"id":"CVE-2007-3996","kev":false,"mt":"exact-indirect-match","ns":"debian:distro:debian:13","pkg":"libwmflite-0.2-7","rcvss":[{"score":6.8,"source":"nvd@nist.gov","version":"2.0"}],"rdesc":"Multiple integer overflows in libgd in PHP before 5.2.4 allow remote attackers to cause a denial of service (application crash) and possibly execute arbitrary code via a large (1) srcW or (2) srcH value to the (a) gdImageCopyResized function, or a large (3) sy (height) or (4) sx (width) value to the (b) gdImageCreate or the (c) gdImageCreateTrueColor function.","rel":["CVE-2007-3996"],"risk":2.1094999999999997,"sev":"Medium","type":"deb","ver":"0.2.13-1.1+b3"}
{"art":"7c441cdad6d4c693","cvss":[],"desc":"Heap buffer overflow in vp8 encoding in libvpx in Google Chrome prior to 117.0.5938.132 and libvpx 1.13.1 allowed a remote attacker to potentially exploit heap corruption via a crafted HTML page. (Chromium security severity: High)","epss":0.49013,"fix":"not-fixed","fixv":[],"id":"CVE-2023-5217","kev":true,"mt":"exact-direct-match","ns":"debian:distro:debian:13","pkg":"firefox-esr","rcvss":[{"score":8.8,"source":"nvd@nist.gov","version":"3.1"},{"score":8.8,"source":"134c704f-9b21-4f2e-91b3-4a467353bcc0","version":"3.1"}],"rdesc":"Heap buffer overflow in vp8 encoding in libvpx in Google Chrome prior to 117.0.5938.132 and libvpx 1.13.1 allowed a remote attacker to potentially exploit heap corruption via a crafted HTML page. (Chromium security severity: High)","rel":["CVE-2023-5217"],"risk":5.250000000000001,"sev":"Negligible","type":"deb","ver":"140.15.0esr-1~deb13u1"}
@@ -44,7 +44,7 @@ it('allows only a full system upgrade on Arch Linux', function () {
$server = Mockery::mock(Server::class)->makePartial();
$server->shouldReceive('serverStatus')->andReturn(true);
expect(UpdatePackage::run($server, 'arch', 'curl', 'pacman'))
expect(UpdatePackage::run($server, 'arch', ['curl'], 'pacman'))
->toHaveKey('error', 'This operating system supports only a full system upgrade. Use "Update all packages".');
});
@@ -306,3 +306,52 @@ it('reads the reboot status and runs its check as one sudo shell script', functi
expect(parseCommandsByLineForSudo(collect([$command]), $server))->toBe(['sudo '.$command]);
}
});
it('finds the packages of kernels that the server does not run', function (string $package, string $version, string $running, bool $other) {
expect(CheckUpdates::isOtherKernelPackage($package, $version, $running))->toBe($other);
})->with([
'running debian kernel' => ['linux-modules-6.8.0-139-generic', '6.8.0-139.149', '6.8.0-139-generic', false],
'old debian kernel' => ['linux-modules-6.8.0-130-generic', '6.8.0-130.140', '6.8.0-139-generic', true],
'running rhel kernel' => ['kernel-core', '5.14.0-427.13.1.el9_4', '5.14.0-427.13.1.el9_4.x86_64', false],
'old rhel kernel' => ['kernel-core', '5.14.0-362.8.1.el9_3', '5.14.0-427.13.1.el9_4.x86_64', true],
'rhel kernel-tools' => ['kernel-tools', '5.14.0-362.8.1.el9_3', '5.14.0-427.13.1.el9_4.x86_64', false],
'rhel kernel-headers' => ['kernel-headers', '5.14.0-362.8.1.el9_3', '5.14.0-427.13.1.el9_4.x86_64', false],
'running sles kernel' => ['kernel-default', '6.4.0-150600.23.17.1', '6.4.0-150600.23.17-default', false],
'old sles kernel' => ['kernel-default', '6.4.0-150600.23.14.1', '6.4.0-150600.23.17-default', true],
'running tumbleweed kernel' => ['kernel-default', '6.11.8-1.1', '6.11.8-1-default', false],
]);
it('claims a kernel fix after a reboot only when the boot kernel has it', function (string $bootLine, ?string $runningStatus, ?string $newStatus) {
$patchResult = ['kernels' => (new CheckUpdates)->parseKernels(implode("\n", [
'RUNNING|6.8.0-130-generic',
'KERNEL|linux-image-6.8.0-130-generic|6.8.0-130.140',
'KERNEL|linux-image-6.8.0-139-generic|6.8.0-139.149',
$bootLine,
]))];
expect(CheckUpdates::kernelStatus('linux-modules-6.8.0-130-generic', '6.8.0-130.140', '6.8.0-135.1', $patchResult))->toBe($runningStatus)
->and(CheckUpdates::kernelStatus('linux-modules-6.8.0-139-generic', '6.8.0-139.149', null, $patchResult))->toBe($newStatus);
})->with([
'grub boots the newest kernel' => ['BOOT|newest', 'fixed_after_reboot', 'runs_after_reboot'],
'grub boots the newer kernel by name' => ['BOOT|6.8.0-139-generic', 'fixed_after_reboot', 'runs_after_reboot'],
'grub keeps the running kernel' => ['BOOT|6.8.0-130-generic', null, null],
'boot kernel unknown' => ['', null, null],
]);
it('claims a fix after a reboot only when the rhel boot kernel has it', function () {
$patchResult = ['kernels' => (new CheckUpdates)->parseKernels(implode("\n", [
'RUNNING|5.14.0-427.13.1.el9_4.x86_64',
'KERNEL|kernel-core|5.14.0-427.13.1.el9_4|x86_64',
'KERNEL|kernel-core|5.14.0-503.11.1.el9_5|x86_64',
'KERNEL|kernel-core|5.14.0-570.1.1.el9_6|x86_64',
'BOOT|5.14.0-503.11.1.el9_5.x86_64',
]))];
expect(CheckUpdates::kernelStatus('kernel-core', '5.14.0-427.13.1.el9_4', '5.14.0-503.1.1.el9_5', $patchResult))->toBe('fixed_after_reboot')
->and(CheckUpdates::kernelStatus('kernel-core', '5.14.0-427.13.1.el9_4', '5.14.0-570.1.1.el9_6', $patchResult))->toBeNull()
->and(CheckUpdates::kernelStatus('kernel-core', '5.14.0-570.1.1.el9_6', null, $patchResult))->toBeNull();
});
it('runs the kernel check as one sudo shell script', function () {
expect(parseCommandsByLineForSudo(collect([CheckUpdates::KERNELS_COMMAND]), patchCommandsNonRootServer()))->toBe(['sudo '.CheckUpdates::KERNELS_COMMAND]);
});
+98 -2
View File
@@ -2,6 +2,7 @@
use App\Models\Server;
use App\Services\ImageScan\ScannerCommands;
use Illuminate\Support\Facades\Process;
use Tests\TestCase;
uses(TestCase::class);
@@ -34,11 +35,13 @@ function scannerCommandLists(ScannerCommands $commands): array
'dbUpdate' => $commands->dbUpdate(null),
'dbUpdate with url' => $commands->dbUpdate('https://mirror.example.com/grype/databases'),
'dbStatus' => $commands->dbStatus(),
'scannerImages' => $commands->scannerImages(),
'inspect' => $commands->inspect('localhost:5000/team/app:1.2'),
'pull' => $commands->pull('localhost:5000/team/app:1.2'),
'diskFree' => $commands->diskFree(),
'diskUsage' => $commands->diskUsage(),
'sbomFromLocal' => $commands->sbomFromLocal('nginx', SCANNER_TEST_HEX),
'sbomFromHost' => $commands->sbomFromHost(),
'sbomFromRegistry' => $commands->sbomFromRegistry('ghcr.io/a/b@sha256:'.str_repeat('ab', 32), 'linux/amd64', 'cand-'.SCANNER_TEST_HEX, null),
'sbomFromRegistry with auth' => $commands->sbomFromRegistry('ghcr.io/a/b:1', 'linux/arm64/v8', 'cand-1', $auth),
'grype' => $commands->grype(SCANNER_TEST_HEX),
@@ -141,13 +144,13 @@ it('runs every scanner container with each hardening flag exactly once', functio
}
}
expect($runs)->toBe(12);
expect($runs)->toBe(13);
});
it('cuts the network only for runs that do not need a registry or the database download', function () {
$lists = scannerCommandLists($this->commands);
foreach (['dbStatus', 'sbomFromLocal', 'grype', 'packages'] as $name) {
foreach (['dbStatus', 'sbomFromLocal', 'sbomFromHost', 'grype', 'packages'] as $name) {
expect(scannerDockerRuns($lists[$name]))->each->toContain('--network none ');
}
foreach (['dbUpdate', 'sbomFromRegistry', 'tags', 'digest'] as $name) {
@@ -522,3 +525,96 @@ it('keeps the volume setup as one sudo shell line for a non-root SSH user', func
expect($parsed)->toBe(['sudo '.$setup]);
});
it('keeps a fixed host match when an unfixed match of another package has the same vulnerability and version', function () {
$script = implode("\n", scannerUnwrapped($this->commands->grype(ScannerCommands::HOST_SBOM)));
preg_match("/\| (grep -v -F .*?) > '/", $script, $filter);
$lines = [
'{"fix":"not-fixed","fixv":[],"id":"CVE-1","pkg":"linux-image","ver":"6.1"}',
'{"fix":"not-fixed","fixv":[],"id":"CVE-1","pkg":"linux-headers","ver":"6.1"}',
'{"fix":"fixed","fixv":["6.2"],"id":"CVE-1","pkg":"linux-libc-dev","ver":"6.1"}',
'{"fix":"wont-fix","fixv":[],"id":"CVE-2","pkg":"linux-image","ver":"6.1"}',
'{"fix":"not-fixed","fixv":[],"id":"CVE-1","pkg":"linux-image","ver":"6.0"}',
];
$result = Process::input(implode("\n", $lines)."\n")->run(['sh', '-c', $filter[1]]);
expect($result->successful())->toBeTrue()
->and(explode("\n", trim($result->output())))->toBe([$lines[0], $lines[2], $lines[4]]);
});
it('keeps one host match per package, and one line of the kernel image per kernel', function () {
$script = implode("\n", scannerUnwrapped($this->commands->grype(ScannerCommands::HOST_SBOM)));
preg_match("/\\| (grep -v -F .*?) > '/", $script, $filter);
$lines = [
'{"eol":null}',
'{"fix":"fixed","fixv":["3.0.15-1"],"id":"CVE-1","pkg":"libssl3","ver":"3.0.14-1"}',
'{"fix":"fixed","fixv":["3.0.15-1"],"id":"CVE-1","pkg":"openssl","ver":"3.0.14-1"}',
'{"fix":"fixed","fixv":["3.0.15-1"],"id":"CVE-1","pkg":"openssl","ver":"3.0.14-1"}',
'{"fix":"not-fixed","fixv":[],"id":"CVE-2","pkg":"linux-headers-6.1.0-18-common","ver":"6.1.76-1"}',
'{"fix":"not-fixed","fixv":[],"id":"CVE-2","pkg":"linux-image-6.1.0-18-amd64","ver":"6.1.76-1"}',
'{"fix":"not-fixed","fixv":[],"id":"CVE-2","pkg":"linux-libc-dev","ver":"6.1.76-1"}',
'{"fix":"not-fixed","fixv":[],"id":"CVE-3","pkg":"kernel-core","ver":"5.14.0-503.el9"}',
'{"fix":"not-fixed","fixv":[],"id":"CVE-3","pkg":"kernel-modules","ver":"5.14.0-503.el9"}',
'{"fix":"not-fixed","fixv":[],"id":"CVE-3","pkg":"kernelshark","ver":"5.14.0-503.el9"}',
];
$result = Process::input(implode("\n", $lines)."\n")->run(['sh', '-c', $filter[1]]);
expect($result->successful())->toBeTrue()
->and(explode("\n", trim($result->output())))->toBe([$lines[0], $lines[1], $lines[2], $lines[5], $lines[7], $lines[9]]);
});
it('runs the host grype step and prints the exit code of the run, or of the filter when the run succeeded', function (int $grypeExit, bool $writeFails, string $marker, array $kept) {
$dir = sys_get_temp_dir().'/coolify-scan-test-'.bin2hex(random_bytes(4));
mkdir($dir);
$lines = [
'{"eol":null}',
'{"fix":"not-fixed","fixv":[],"id":"CVE-1","pkg":"linux-image","ver":"6.1"}',
'{"fix":"not-fixed","fixv":[],"id":"CVE-1","pkg":"linux-headers","ver":"6.1"}',
'{"fix":"wont-fix","fixv":[],"id":"CVE-2","pkg":"linux-image","ver":"6.1"}',
];
file_put_contents("{$dir}/fixture", implode("\n", $lines)."\n");
file_put_contents("{$dir}/grype", "#!/bin/sh\ncat '{$dir}/fixture'\nexit {$grypeExit}\n");
chmod("{$dir}/grype", 0755);
if ($writeFails) {
mkdir("{$dir}/out");
}
$step = collect(scannerUnwrapped($this->commands->grype(ScannerCommands::HOST_SBOM)))->first(fn (string $line) => str_contains($line, 'coolify-scan-exit grype'));
$script = str_replace('/data/coolify/image-scans/tmp-'.SCANNER_TEST_RUN_ID, $dir, preg_replace('/timeout -k 10 \d+ docker run .*? 2>\/dev\/null; echo \$\? > /', "{$dir}/grype 2>/dev/null; echo \$? > ", $step, 1, $replaced));
$result = Process::run(['sh', '-c', $script]);
$output = is_file("{$dir}/out") ? file_get_contents("{$dir}/out") : '';
Process::run(['rm', '-rf', $dir]);
expect($replaced)->toBe(1)
->and(trim($result->output()))->toMatch($marker)
->and($output === '' ? [] : explode("\n", trim($output)))->toBe(array_map(fn (int $index) => $lines[$index], $kept));
})->with([
'run and write succeed' => [0, false, '/coolify-scan-exit grype 0 88$/', [0, 1]],
'run fails' => [2, false, '/coolify-scan-exit grype 2 88$/', [0, 1]],
'write fails' => [0, true, '/coolify-scan-exit grype [1-9]\\d* 0$/', []],
]);
it('copies only the package databases of the host and scans the copy with the OS catalogers', function () {
$script = implode("\n", scannerUnwrapped($this->commands->sbomFromHost()));
expect($script)
->toContain("cp -RL '/var/lib/dpkg/status' '/data/coolify/image-scans/tmp-".SCANNER_TEST_RUN_ID."/in/host/var/lib/dpkg/status'")
->toContain("[ -e '/var/lib/rpm' ] && [ ! -L '/var/lib/rpm' ]")
->toContain("'scan' 'dir:/in/host' '--override-default-catalogers' 'dpkg-db-cataloger,rpm-db-cataloger,apk-db-cataloger,alpm-db-cataloger'")
->toContain('type=bind,src=/data/coolify/image-scans/tmp-'.SCANNER_TEST_RUN_ID.'/in,dst=/in,readonly')
->toContain("mv -f '/data/coolify/image-scans/sboms/host.json.tmp-".SCANNER_TEST_RUN_ID."' '/data/coolify/image-scans/sboms/host.json'")
->toContain("echo 'coolify-scan-exit copy 1'")
->toContain("echo 'coolify-scan-exit copy 2'")
->not->toContain('|| true; fi')
->not->toContain('src=/,');
});
it('keeps one line per vulnerability and drops the problems that the distribution will not fix only from the host scan output', function () {
$host = implode("\n", scannerUnwrapped($this->commands->grype(ScannerCommands::HOST_SBOM)));
expect($host)->toContain('grep -v -F \'"fix":"wont-fix"\'')
->toContain('if (k in at)')
->and(implode("\n", scannerUnwrapped($this->commands->grype(SCANNER_TEST_HEX))))->not->toContain('grep -v')->not->toContain('k in at');
});
@@ -2386,6 +2386,7 @@ test('the pinned host key moves with the server', function () {
'ssh_host_key_trusted_at' => '2026-01-01 12:00:00',
'ssh_host_key_pending' => 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHjaL3Lqt0tZl4iVZw8x1v2b9fM8r6Yq0nH0bUq3dQ0h',
'is_ssh_host_key_check_disabled' => true,
'is_ssh_host_key_check_enforced' => true,
])->save();
$bundle = json_decode(json_encode($this->exporter->export($this->server->fresh())), true);
$this->service->forceDelete();
@@ -2401,5 +2402,6 @@ test('the pinned host key moves with the server', function () {
->ssh_host_key_source->toBe('transfer')
->ssh_host_key_pending->toBeNull()
->is_ssh_host_key_check_disabled->toBeTrue()
->is_ssh_host_key_check_enforced->toBeTrue()
->and($settings->ssh_host_key_trusted_at->toDateTimeString())->toBe('2026-01-01 12:00:00');
});