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