diff --git a/.ai/lessons.md b/.ai/lessons.md index dccfba874f..5bde13fce9 100644 --- a/.ai/lessons.md +++ b/.ai/lessons.md @@ -32,6 +32,7 @@ - For load tests against a dev VM, use your own instance (`COOLIFY_DEV_INSTANCE= 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, '_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`. diff --git a/app/Actions/ImageScan/ScanHostPackages.php b/app/Actions/ImageScan/ScanHostPackages.php new file mode 100644 index 0000000000..7c12035408 --- /dev/null +++ b/app/Actions/ImageScan/ScanHostPackages.php @@ -0,0 +1,153 @@ +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, oom: list} $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 $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]); + } +} diff --git a/app/Actions/ImageScan/ScanImage.php b/app/Actions/ImageScan/ScanImage.php index 08ce3c37ab..3e3f76ac51 100644 --- a/app/Actions/ImageScan/ScanImage.php +++ b/app/Actions/ImageScan/ScanImage.php @@ -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 $commands * @return array{code: int, size: ?int, payload: string, exit_codes: array, oom: list} */ - 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, oom: list} $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, oom: list} $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; diff --git a/app/Actions/Server/CheckUpdates.php b/app/Actions/Server/CheckUpdates.php index 08775f9e6d..d207bb74d6 100644 --- a/app/Actions/Server/CheckUpdates.php +++ b/app/Actions/Server/CheckUpdates.php @@ -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|`), each installed kernel (`KERNEL||[|]`), + * and the kernel that boots next (`BOOT|`, 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|null $patchResult + * @return list> + */ + 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}|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|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|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) { diff --git a/app/Actions/Server/RemoveUnusedPackages.php b/app/Actions/Server/RemoveUnusedPackages.php new file mode 100644 index 0000000000..089462f59f --- /dev/null +++ b/app/Actions/Server/RemoveUnusedPackages.php @@ -0,0 +1,64 @@ + '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|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, + }; + } +} diff --git a/app/Actions/Server/UpdatePackage.php b/app/Actions/Server/UpdatePackage.php index 58e88ccc51..fbfe474500 100644 --- a/app/Actions/Server/UpdatePackage.php +++ b/app/Actions/Server/UpdatePackage.php @@ -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 $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|null */ diff --git a/app/Enums/ImageScanErrorCode.php b/app/Enums/ImageScanErrorCode.php index 04c8e4d408..4d137cdbd4 100644 --- a/app/Enums/ImageScanErrorCode.php +++ b/app/Enums/ImageScanErrorCode.php @@ -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.', }; } diff --git a/app/Helpers/SshMultiplexingHelper.php b/app/Helpers/SshMultiplexingHelper.php index 3809af7c43..51d52640c9 100644 --- a/app/Helpers/SshMultiplexingHelper.php +++ b/app/Helpers/SshMultiplexingHelper.php @@ -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); diff --git a/app/Jobs/ImageRescanJob.php b/app/Jobs/ImageRescanJob.php index 5c1e70292d..d331184c9d 100644 --- a/app/Jobs/ImageRescanJob.php +++ b/app/Jobs/ImageRescanJob.php @@ -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); } /** diff --git a/app/Jobs/PrepareImageScannerJob.php b/app/Jobs/PrepareImageScannerJob.php index b30a03b01a..53b61fc37e 100644 --- a/app/Jobs/PrepareImageScannerJob.php +++ b/app/Jobs/PrepareImageScannerJob.php @@ -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); diff --git a/app/Jobs/ScanHostPackagesJob.php b/app/Jobs/ScanHostPackagesJob.php new file mode 100644 index 0000000000..4737c9deb7 --- /dev/null +++ b/app/Jobs/ScanHostPackagesJob.php @@ -0,0 +1,98 @@ +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)); + } +} diff --git a/app/Listeners/FinishServerPackagesUpdate.php b/app/Listeners/FinishServerPackagesUpdate.php index 142859fafb..b2b3d7c3f4 100644 --- a/app/Listeners/FinishServerPackagesUpdate.php +++ b/app/Listeners/FinishServerPackagesUpdate.php @@ -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); + } } } diff --git a/app/Livewire/Concerns/ManagesServerPackageUpdates.php b/app/Livewire/Concerns/ManagesServerPackageUpdates.php new file mode 100644 index 0000000000..bbe852054a --- /dev/null +++ b/app/Livewire/Concerns/ManagesServerPackageUpdates.php @@ -0,0 +1,116 @@ +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 $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 $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.'); + } + } +} diff --git a/app/Livewire/ImageScan/ScanSettings.php b/app/Livewire/ImageScan/ScanSettings.php index 63488ed2d8..f0e142018e 100644 --- a/app/Livewire/ImageScan/ScanSettings.php +++ b/app/Livewire/ImageScan/ScanSettings.php @@ -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 = ''; diff --git a/app/Livewire/ImageScan/ServerImageScanning.php b/app/Livewire/ImageScan/ServerImageScanning.php index 8d5cc9d96f..2c92c48217 100644 --- a/app/Livewire/ImageScan/ServerImageScanning.php +++ b/app/Livewire/ImageScan/ServerImageScanning.php @@ -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(', '), diff --git a/app/Livewire/ImageScan/Vulnerabilities.php b/app/Livewire/ImageScan/Vulnerabilities.php index 688cf92d79..822ad99c42 100644 --- a/app/Livewire/ImageScan/Vulnerabilities.php +++ b/app/Livewire/ImageScan/Vulnerabilities.php @@ -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, ]; diff --git a/app/Livewire/Server/Security/HostKey.php b/app/Livewire/Server/Security/HostKey.php index 02a0f6e5d6..7e5c47ba3b 100644 --- a/app/Livewire/Server/Security/HostKey.php +++ b/app/Livewire/Server/Security/HostKey.php @@ -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), ]); diff --git a/app/Livewire/Server/Security/HostVulnerabilities.php b/app/Livewire/Server/Security/HostVulnerabilities.php new file mode 100644 index 0000000000..b5684ec723 --- /dev/null +++ b/app/Livewire/Server/Security/HostVulnerabilities.php @@ -0,0 +1,229 @@ + 0, 'low' => 1, 'medium' => 2, 'high' => 3, 'critical' => 4]; + + public int $perPage = 10; + + /** `_`, 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 $findings + * @return list + */ + 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, + ]); + } +} diff --git a/app/Livewire/Server/Security/Index.php b/app/Livewire/Server/Security/Index.php index 3e423fced0..430e4b930f 100644 --- a/app/Livewire/Server/Security/Index.php +++ b/app/Livewire/Server/Security/Index.php @@ -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; diff --git a/app/Livewire/Server/Security/Patches.php b/app/Livewire/Server/Security/Patches.php index 965eb59963..76d2a0b3f9 100644 --- a/app/Livewire/Server/Security/Patches.php +++ b/app/Livewire/Server/Security/Patches.php @@ -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 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 + */ + 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 $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, + ), + ]); } } diff --git a/app/Models/Server.php b/app/Models/Server.php index 3192b71881..0269dc3ddc 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -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); diff --git a/app/Models/ServerHostScan.php b/app/Models/ServerHostScan.php new file mode 100644 index 0000000000..8dfe0b722e --- /dev/null +++ b/app/Models/ServerHostScan.php @@ -0,0 +1,62 @@ + 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 + */ + public function findingList(): array + { + return array_map(fn (array $finding) => Finding::fromArray($finding), $this->findings ?? []); + } +} diff --git a/app/Models/ServerSetting.php b/app/Models/ServerSetting.php index dc144c10ad..9eb056de7b 100644 --- a/app/Models/ServerSetting.php +++ b/app/Models/ServerSetting.php @@ -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', ]; /** diff --git a/app/Notifications/Server/ServerPatchCheck.php b/app/Notifications/Server/ServerPatchCheck.php index e72ad0444e..84b3028502 100644 --- a/app/Notifications/Server/ServerPatchCheck.php +++ b/app/Notifications/Server/ServerPatchCheck.php @@ -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 diff --git a/app/Services/ImageScan/ScannerCommands.php b/app/Services/ImageScan/ScannerCommands.php index aa41451890..14e6e6174f 100644 --- a/app/Services/ImageScan/ScannerCommands.php +++ b/app/Services/ImageScan/ScannerCommands.php @@ -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 + */ + 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 */ @@ -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 + */ + 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 + */ + 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|null $auth * @return list */ - 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"]), ]; } diff --git a/app/Services/ServerTransfer/ServerTransferCoverage.php b/app/Services/ServerTransfer/ServerTransferCoverage.php index 0068c82c4c..af031c94ef 100644 --- a/app/Services/ServerTransfer/ServerTransferCoverage.php +++ b/app/Services/ServerTransfer/ServerTransferCoverage.php @@ -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.', diff --git a/app/Services/ServerTransfer/ServerTransferImporter.php b/app/Services/ServerTransfer/ServerTransferImporter.php index 47bcd66d5f..0bbe53ac07 100644 --- a/app/Services/ServerTransfer/ServerTransferImporter.php +++ b/app/Services/ServerTransfer/ServerTransferImporter.php @@ -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'), diff --git a/database/factories/ServerHostScanFactory.php b/database/factories/ServerHostScanFactory.php new file mode 100644 index 0000000000..e735fcb4a6 --- /dev/null +++ b/database/factories/ServerHostScanFactory.php @@ -0,0 +1,25 @@ + + */ +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(), + ]; + } +} diff --git a/database/migrations/2026_11_06_145819_create_server_host_scans_table.php b/database/migrations/2026_11_06_145819_create_server_host_scans_table.php new file mode 100644 index 0000000000..c2b3cf086a --- /dev/null +++ b/database/migrations/2026_11_06_145819_create_server_host_scans_table.php @@ -0,0 +1,30 @@ +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'); + } +}; diff --git a/database/migrations/2026_11_06_194613_add_ssh_host_key_check_enforced_to_server_settings_table.php b/database/migrations/2026_11_06_194613_add_ssh_host_key_check_enforced_to_server_settings_table.php new file mode 100644 index 0000000000..67ac1a5e66 --- /dev/null +++ b/database/migrations/2026_11_06_194613_add_ssh_host_key_check_enforced_to_server_settings_table.php @@ -0,0 +1,22 @@ +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'); + }); + } +}; diff --git a/docs/server-transfer-full-coverage.md b/docs/server-transfer-full-coverage.md index 4992151c00..503efc464e 100644 --- a/docs/server-transfer-full-coverage.md +++ b/docs/server-transfer-full-coverage.md @@ -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 | diff --git a/resources/css/app.css b/resources/css/app.css index fb2e9608ab..0d31b4ad22 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -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; } diff --git a/resources/views/components/application/settings-section.blade.php b/resources/views/components/application/settings-section.blade.php index 2bea1ed611..b34e2839be 100644 --- a/resources/views/components/application/settings-section.blade.php +++ b/resources/views/components/application/settings-section.blade.php @@ -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
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> -
-
isset($badges)])> + -
- {{ $slot }} -
+ @if ($hasBody) +
+ {{ $slot }} +
+ @endif
diff --git a/resources/views/components/server/remove-unused-packages-button.blade.php b/resources/views/components/server/remove-unused-packages-button.blade.php new file mode 100644 index 0000000000..587a04176c --- /dev/null +++ b/resources/views/components/server/remove-unused-packages-button.blade.php @@ -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)) + + @if ($triggerId) + + + + @endif + +@endif diff --git a/resources/views/components/server/sidebar.blade.php b/resources/views/components/server/sidebar.blade.php index 515e58ec08..6b1188e80d 100644 --- a/resources/views/components/server/sidebar.blade.php +++ b/resources/views/components/server/sidebar.blade.php @@ -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', diff --git a/resources/views/livewire/image-scan/scan-settings.blade.php b/resources/views/livewire/image-scan/scan-settings.blade.php index bd3b0bd660..3c41c78ed4 100644 --- a/resources/views/livewire/image-scan/scan-settings.blade.php +++ b/resources/views/livewire/image-scan/scan-settings.blade.php @@ -34,6 +34,10 @@ @endif + @elseif ($embedded) + + @include('livewire.image-scan.partials.scan-rules') + @else @include('livewire.image-scan.partials.scan-rules') diff --git a/resources/views/livewire/image-scan/server-image-scanning.blade.php b/resources/views/livewire/image-scan/server-image-scanning.blade.php index bfc0e74bc6..dbf3088af2 100644 --- a/resources/views/livewire/image-scan/server-image-scanning.blade.php +++ b/resources/views/livewire/image-scan/server-image-scanning.blade.php @@ -1,6 +1,6 @@
- {{ data_get_str($server, 'name')->limit(10) }} > Image Scanning | Coolify + {{ data_get_str($server, 'name')->limit(10) }} > Vulnerabilities | Coolify @@ -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('

'); @endphp
- + + +
-
- @if (! $allowed) - - @elseif (! $enabled) - + :helper="$sectionHelper"> + @if ($allowed) + @can('update', $server) - @can('update', $server) - - @else - Ask a team admin to turn it - on. - @endcan - - - @else -
-
- - -
- -
-

Scanner tools: {{ $toolVersions }}.

- @if ($diskUsed) -

Disk used by scanner data: {{ $diskUsed }}.

- @endif -

{{ $costText }}

-
- - @can('update', $server) -
- @if ($prepareState === null || $prepareState === 'failed') - - {{ $prepareState === 'failed' ? 'Try again' : 'Get scanner' }} - - @endif - - Turn off image scanning + @if ($enabled && ($prepareState === null || $prepareState === 'failed')) + + {{ $prepareState === 'failed' ? 'Try again' : 'Get scanner' }} + @endif + {{-- Disabling forgets the cached state, but the files stay on the server. --}} + @if (! $enabled || $hasScannerData) + @endif +
+
- @endcan + + @endcan + @endif + @if (! $allowed) + + @elseif (! $enabled) + + @cannot('update', $server) + + Ask a team admin to turn it + on. + + @endcannot + + @elseif ($prepareState !== 'ready') +
+ + +
+ @endif + @if ($allowed) +
! $enabled || $prepareState !== 'ready', + ])> +
@endif - @if ($allowed) - - @endif +
diff --git a/resources/views/livewire/server/index.blade.php b/resources/views/livewire/server/index.blade.php index 32ad36e7fb..6a8e6eb945 100644 --- a/resources/views/livewire/server/index.blade.php +++ b/resources/views/livewire/server/index.blade.php @@ -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(), diff --git a/resources/views/livewire/server/navbar.blade.php b/resources/views/livewire/server/navbar.blade.php index c23acf6c79..efab2c3ee2 100644 --- a/resources/views/livewire/server/navbar.blade.php +++ b/resources/views/livewire/server/navbar.blade.php @@ -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), ], diff --git a/resources/views/livewire/server/security/host-key.blade.php b/resources/views/livewire/server/security/host-key.blade.php index c59bdcfa11..105665b2cc 100644 --- a/resources/views/livewire/server/security/host-key.blade.php +++ b/resources/views/livewire/server/security/host-key.blade.php @@ -10,7 +10,18 @@ @endif + 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.

An instance admin sets the mode for all servers with SSH_HOST_KEY_CHECKING=enforce (or warn, off) in /data/coolify/source/.env, then restarts Coolify."> + + @if (count($pendingFingerprints) > 0) + + @elseif ($mode === 'off') + + @elseif (count($fingerprints) > 0) + + @else + + @endif + @can('update', $server)
@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) · Trusted {{ $server->settings->ssh_host_key_trusted_at->toDateTimeString() }} UTC @endif @@ -77,15 +94,23 @@ @else Saved on the next connection · @endif - Mode: {{ $mode }} + Mode: {{ $mode }}{{ $mode === 'enforce' && $instanceMode !== 'enforce' ? ' (set for this server)' : '' }}

-
- Compare on the server - for key in /etc/ssh/ssh_host_*_key.pub; do ssh-keygen -lf "$key"; done -
+ {{-- 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) +
+ +
+ @endif
diff --git a/resources/views/livewire/server/security/host-vulnerabilities.blade.php b/resources/views/livewire/server/security/host-vulnerabilities.blade.php new file mode 100644 index 0000000000..705caca3f7 --- /dev/null +++ b/resources/views/livewire/server/security/host-vulnerabilities.blade.php @@ -0,0 +1,341 @@ +
+ + Package Update Logs + +
+ +
+
+
+ @php + $hostSummary = $hostScan?->summary; + $rebootRequired = data_get($result, 'reboot_required') === true; + $hostScanError = $hostScan?->error_code?->message([...($hostScan->error_context ?? []), 'server' => $server->name]); + @endphp + + + @if ($hostScanError && ! $hostScanRunning) + + @endif + @if ($hostSummary) + @foreach (['critical', 'high'] as $severity) + @if (($hostSummary[$severity] ?? 0) > 0) + + @endif + @endforeach + @if (($hostSummary['exploited'] ?? 0) > 0) + + @endif + @if (array_sum(array_intersect_key($hostSummary, array_flip(['critical', 'high', 'medium', 'low', 'unknown']))) === 0) + + @endif + @elseif (! $hostScanAvailable) + + @elseif (! $hostScanRunning) + + @endif + + @if ($hostFindings !== [] || $hostScanAvailable) + + @if ($hostFindings !== []) +
+ +
+ @endif + @if ($hostScanAvailable) + @can('update', $server) + + @if ($hostScanRunning) + + Scanning… + @else + + Scan now + @endif + + @endcan + @endif +
+ @endif + + @if (! $hostScanAvailable && ! $hostScan) + + @else +
+
+ + Last scan: + @if ($hostScan?->scanned_at) + {{ $hostScan->scanned_at->diffForHumans() }} + @else + never + @endif + + @if ($hostSummary) + @if (($hostSummary['medium'] ?? 0) > 0) + + @endif + @if (($hostSummary['fixable'] ?? 0) > 0) + + @endif + @endif +
+ @if ($hostFindings !== [] && ($hostScan->findings_truncated || ! $result)) +

+ @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 +

+ @endif + + @if ($rebootRequired) + +
+ + @if (($kernelStatusCounts['fixed_after_reboot'] ?? 0) > 0) + The server runs kernel {{ $runningKernel }}, which has {{ $kernelStatusCounts['fixed_after_reboot'] }} + listed {{ Str::plural('vulnerability', $kernelStatusCounts['fixed_after_reboot']) }}. The fixed kernel + {{ $bootKernel }} is installed and is the boot kernel. Reboot the server to start it. + @elseif ($runningKernel && $bootKernel && $bootKernel !== $runningKernel) + Kernel {{ $bootKernel }} is installed and starts after a reboot. The server now runs + {{ $runningKernel }}. No listed vulnerability needs this reboot. + @else + Some installed updates apply only after a reboot. + @endif + + @can('update', $server) + + @endcan +
+
+ @endif + @if ($otherKernelCount > 0) +
+ {{ $otherKernelCount }} {{ Str::plural('vulnerability', $otherKernelCount) }} in installed kernels that do not run {{ $showOtherKernels ? 'shown' : 'hidden' }}. No risk now. + + @if ($hasOldKernels) + @can('update', $server) + + @endcan + @endif +
+ @endif + @if ($hostScanError) + + {{ $hostScanError }} + + @endif + @if ($hostScan?->eol) + + {{ data_get($hostScan->eol, 'name') }} {{ data_get($hostScan->eol, 'version') }} gets no + security updates anymore. Upgrade the operating system of this server. + + @endif + @if ($hostScan?->scanned_at && $hostFindings === []) + + @endif +
+ + @if ($hostFindings !== [] && $shownHostFindings->isEmpty()) +
+ +
+ @elseif ($hostFindings !== []) +
+
+ + + +
+ +
+ Action +
+ @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 +
+
+
+ +
+

+ + @if ($signal === \App\Services\ImageScan\SecurityRules::EXPLOITED) + + @elseif ($signal === \App\Services\ImageScan\SecurityRules::LIKELY) + + @endif +

+ @if (filled($finding->title)) +

{{ $finding->title }}

+ @endif +
+
+
+

{{ $finding->package }}

+

Installed: {{ $finding->version }}

+
+
+ {{ $finding->fixedIn ?? 'No fix yet' }} + @if ($kernelStatus === 'fixed_after_reboot') + + @elseif ($isOldKernel) + + @elseif ($kernelStatus === 'runs_after_reboot') + + @endif + @if ($fixingUpdates !== []) + + @endif + @foreach ($updateRiskLabels as $riskLabel) + + + {{ $riskLabel }} + + @endforeach +
+
+ +
+
+ @if ($fixingUpdates !== []) + @can('update', $server) + @if ($updateRiskLabels !== []) + + @else + + Update + + @endif + @endcan + @endif +
+
+
+ @if (filled($finding->summary ?: $finding->title)) +
{{ $finding->summary ?: $finding->title }}
+ @endif +
Package
+
{{ $finding->package }}
+
Installed
+
{{ $finding->version }}
+
Fixed in
+
{{ $finding->fixedIn ?? 'No fix yet' }}
+ @if ($fixingUpdates !== []) +
Update
+
+ @foreach ($fixingUpdates as $fixingUpdate) + {{ data_get($fixingUpdate, 'package') }} {{ data_get($fixingUpdate, 'new_version') }} + @endforeach + @if ($updateRiskLabels !== []) + {{ implode('. ', $updateRiskLabels) }}. + @endif +
+ @endif + @if ($kernelStatus !== null || $isOtherKernel) +
Kernel
+
+ @if ($kernelStatus === 'fixed_after_reboot') + The server runs kernel {{ $runningKernel }}. 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 {{ $runningKernel }}). + @else + This kernel is installed, but the server runs it only after a reboot. + @endif +
+ @endif + @if ($finding->cvssScore !== null) +
CVSS score
+
{{ number_format($finding->cvssScore, 1) }}
+ @endif + @if ($finding->kev) +
Exploited
+
Attackers are using this, according to CISA.
+ @elseif ($likelihood !== null) +
Exploit likelihood
+
{{ $likelihood }}
+ @endif + @if ($aliases !== []) +
Also known as
+
{{ implode(', ', $aliases) }}
+ @endif + @if ($advisoryUrl !== null) +
Advisory
+
Open {{ $finding->displayId }}
+ @endif +
+
+ @endforeach + + + + + +
+ @endif + @endif +
+
diff --git a/resources/views/livewire/server/security/index.blade.php b/resources/views/livewire/server/security/index.blade.php index 35dfa66779..3565f2dd37 100644 --- a/resources/views/livewire/server/security/index.blade.php +++ b/resources/views/livewire/server/security/index.blade.php @@ -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"> -
+ {{-- Links sent before the patching sections got their own page (notifications) end with their section ID. --}} +
- {{-- 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()) - - @endif
diff --git a/resources/views/livewire/server/security/patches.blade.php b/resources/views/livewire/server/security/patches.blade.php index d794239f86..5ac19a5ce6 100644 --- a/resources/views/livewire/server/security/patches.blade.php +++ b/resources/views/livewire/server/security/patches.blade.php @@ -1,4 +1,10 @@
+ + {{ data_get_str($server, 'name')->limit(10) }} > Patching | Coolify + + + + Package Update Logs @@ -9,10 +15,6 @@ @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 -
+
+ + +
+ @if (! $server->isFunctional()) + + @else
+ + @if ($checking) + + @elseif ($error) + + @elseif ($checkedAt) + @if ($totalUpdates === 0) + + @elseif ($securityUpdates > 0) + + @else + + @endif + @if ($rebootRequired) + + @endif + @else + + @endif + - @if (isDev()) - - Send test email - - @endif @can('update', $server) - @if ($rebootRequired) - - @endif - - - Check for updates - + + + Actions + + + @if (\App\Actions\Server\RemoveUnusedPackages::supports($packageManager)) + + @endif + @if ($rebootRequired) + + @endif + @if (isDev()) + + @endif + + @endcan @@ -68,17 +129,6 @@ never @endif - @if ($checkedAt && ! $error) - @if ($totalUpdates === 0) - - @endif - @if ($securityUpdates > 0) - - @endif - @if ($rebootRequired) - - @endif - @endif
@php @@ -170,15 +220,18 @@
@elseif ($error) - {{ $error }} @elseif ($checkedAt && $totalUpdates === 0) - + + + @@ -186,8 +239,34 @@ - @can('update', $server) - + + + + +
+ +
+ @can('update', $server) + @if ($selectedPackages !== []) + @php + $selectedRisky = collect($updates)->whereIn('package', $selectedPackages)->filter(fn ($update) => filled(data_get($update, 'risk')))->count(); + @endphp + Clear selection + {{-- The dialog has wire:ignore; a new key renders it again when the selection changes. --}} +
+ +
+ @else -
- @endcan + @endif + @endcan +
@if ($fullUpgradeOnly)
@@ -210,16 +290,39 @@
@endif + @if ($shownUpdates->isEmpty()) +
+ +
+ @else
+ @php + $canSelect = ! $fullUpgradeOnly && auth()->user()?->can('update', $server); + @endphp
+ + @if ($canSelect) + + @endif + Package New version - Action
- @foreach ($updates as $update) + @foreach ($shownUpdates as $update)
-
+
+ @if ($canSelect) + + @endif +
+
+

{{ data_get($update, 'new_version') }} @@ -243,23 +346,19 @@

@endif
-
- @if (! $fullUpgradeOnly) - @can('update', $server) - - Update - - @endcan - @endif -
@endforeach -
- {{ $totalUpdates }} {{ Str::plural('package update', $totalUpdates) }} -
+ + + + +
+ @endif
@endif @@ -267,15 +366,18 @@ + + +
-
+
Package New version Reason
@foreach ($heldUpdates as $update)
+ class="data-table-row held-updates-table-grid border-b border-neutral-200 last:border-b-0 dark:border-white/[0.08]"> {{ data_get($update, 'package') }} @@ -295,5 +397,8 @@
@endif + + @endif +
diff --git a/resources/views/livewire/server/security/terminal-access.blade.php b/resources/views/livewire/server/security/terminal-access.blade.php index 4d73e2c70a..fd8b8f4e12 100644 --- a/resources/views/livewire/server/security/terminal-access.blade.php +++ b/resources/views/livewire/server/security/terminal-access.blade.php @@ -1,6 +1,9 @@
+ + + @can('update', $server)
diff --git a/routes/web.php b/routes/web.php index 07b03bacf9..584eee77be 100644 --- a/routes/web.php +++ b/routes/web.php @@ -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}"); } diff --git a/tests/Feature/ImageScan/ImageRescanJobTest.php b/tests/Feature/ImageScan/ImageRescanJobTest.php index d21e6997de..006d9d4e4c 100644 --- a/tests/Feature/ImageScan/ImageRescanJobTest.php +++ b/tests/Feature/ImageScan/ImageRescanJobTest.php @@ -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', diff --git a/tests/Feature/ImageScan/ScanHostPackagesTest.php b/tests/Feature/ImageScan/ScanHostPackagesTest.php new file mode 100644 index 0000000000..4c5279f156 --- /dev/null +++ b/tests/Feature/ImageScan/ScanHostPackagesTest.php @@ -0,0 +1,327 @@ +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 $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); + }); +}); diff --git a/tests/Feature/ImageScan/ScanImageTest.php b/tests/Feature/ImageScan/ScanImageTest.php index fe45a0361f..7610403735 100644 --- a/tests/Feature/ImageScan/ScanImageTest.php +++ b/tests/Feature/ImageScan/ScanImageTest.php @@ -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)], diff --git a/tests/Feature/ImageScan/ServerImageScanningTest.php b/tests/Feature/ImageScan/ServerImageScanningTest.php index 3322eee766..d7f51efb17 100644 --- a/tests/Feature/ImageScan/ServerImageScanningTest.php +++ b/tests/Feature/ImageScan/ServerImageScanningTest.php @@ -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 () { diff --git a/tests/Feature/ImageScan/VulnerabilitiesPageTest.php b/tests/Feature/ImageScan/VulnerabilitiesPageTest.php index 7d8c064938..2e7f932b94 100644 --- a/tests/Feature/ImageScan/VulnerabilitiesPageTest.php +++ b/tests/Feature/ImageScan/VulnerabilitiesPageTest.php @@ -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([ diff --git a/tests/Feature/Server/ServerPatchCheckNotificationTest.php b/tests/Feature/Server/ServerPatchCheckNotificationTest.php index 04057275a4..1cdd12849c 100644 --- a/tests/Feature/Server/ServerPatchCheckNotificationTest.php +++ b/tests/Feature/Server/ServerPatchCheckNotificationTest.php @@ -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'); }); diff --git a/tests/Feature/Server/ServerPatchesTest.php b/tests/Feature/Server/ServerPatchesTest.php index 8661dd33a0..7156832fa8 100644 --- a/tests/Feature/Server/ServerPatchesTest.php +++ b/tests/Feature/Server/ServerPatchesTest.php @@ -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 () { diff --git a/tests/Feature/ServerHostKeyPageTest.php b/tests/Feature/ServerHostKeyPageTest.php index 8592cc4ff9..57a09c0aef 100644 --- a/tests/Feature/ServerHostKeyPageTest.php +++ b/tests/Feature/ServerHostKeyPageTest.php @@ -1,5 +1,6 @@ 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); } diff --git a/tests/Feature/SshHostKeyCheckingConfigTest.php b/tests/Feature/SshHostKeyCheckingConfigTest.php index a0df6b1251..9ae7f3e5ab 100644 --- a/tests/Feature/SshHostKeyCheckingConfigTest.php +++ b/tests/Feature/SshHostKeyCheckingConfigTest.php @@ -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'], +]); diff --git a/tests/Feature/SshHostKeyPinningHardeningTest.php b/tests/Feature/SshHostKeyPinningHardeningTest.php index 66b1a67d55..23caab83a9 100644 --- a/tests/Feature/SshHostKeyPinningHardeningTest.php +++ b/tests/Feature/SshHostKeyPinningHardeningTest.php @@ -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(); diff --git a/tests/Fixtures/image-scan/grype-host.ndjson b/tests/Fixtures/image-scan/grype-host.ndjson new file mode 100644 index 0000000000..2268609b31 --- /dev/null +++ b/tests/Fixtures/image-scan/grype-host.ndjson @@ -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"} diff --git a/tests/Unit/Actions/Server/ServerPatchCommandsTest.php b/tests/Unit/Actions/Server/ServerPatchCommandsTest.php index f1287cb066..1f97c2538e 100644 --- a/tests/Unit/Actions/Server/ServerPatchCommandsTest.php +++ b/tests/Unit/Actions/Server/ServerPatchCommandsTest.php @@ -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]); +}); diff --git a/tests/Unit/ImageScan/ScannerCommandsTest.php b/tests/Unit/ImageScan/ScannerCommandsTest.php index 5a680a2728..9d1d95ded4 100644 --- a/tests/Unit/ImageScan/ScannerCommandsTest.php +++ b/tests/Unit/ImageScan/ScannerCommandsTest.php @@ -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'); +}); diff --git a/tests/Unit/ServerTransfer/ServerTransferExporterImporterTest.php b/tests/Unit/ServerTransfer/ServerTransferExporterImporterTest.php index 598d4a23f1..7723051bbe 100644 --- a/tests/Unit/ServerTransfer/ServerTransferExporterImporterTest.php +++ b/tests/Unit/ServerTransfer/ServerTransferExporterImporterTest.php @@ -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'); });