'array']); $this->withoutVite(); InstanceSettings::forceCreate(['id' => 0, 'is_image_scanning_allowed' => true]); Queue::fake(); $this->team = Team::factory()->create(); $this->admin = vulnerabilitiesMember($this->team, 'admin'); $this->server = Server::factory()->create(['team_id' => $this->team->id]); $this->server->settings->forceFill(['is_image_scan_enabled' => true])->saveQuietly(); $this->destination = StandaloneDocker::query()->where('server_id', $this->server->id)->firstOrFail(); $this->environment = Environment::factory()->create([ 'project_id' => Project::factory()->create(['team_id' => $this->team->id])->id, ]); $this->application = Application::factory()->create([ 'environment_id' => $this->environment->id, 'destination_id' => $this->destination->id, 'destination_type' => $this->destination->getMorphClass(), 'build_pack' => 'dockerimage', 'docker_registry_image_name' => 'nginx', 'docker_registry_image_tag' => '1.27', ]); }); function vulnerabilitiesMember(Team $team, string $role = 'member', ?array $access = null): User { $user = User::factory()->create(); $user->teams()->attach($team, ['role' => $role, 'access' => $access === null ? null : json_encode($access)]); return $user->fresh(); } /** * @param array $overrides * @return array */ function vulnerabilitiesFinding(array $overrides = []): array { return [ 'id' => 'CVE-2024-0001', 'aliases' => [], 'severity' => 'high', 'package' => 'openssl', 'version' => '3.0.1', 'type' => 'apk', 'artifactId' => 'a1', 'fixedIn' => '3.0.2', 'fixState' => 'fixed', 'kev' => false, 'epss' => 0.2, 'risk' => 1.0, 'summary' => 'A bug.', 'layer' => 'app', ...$overrides, ]; } /** * A stored summary whose security status counts `$fixed` relevant findings that an action fixes. * * @return array */ function vulnerabilitiesSummary(int $fixed, array $overrides = []): array { return ['truncated' => false, 'eol_distro' => null, 'security' => ['facts' => ['counts' => ['relevant_fixed' => $fixed]]], ...$overrides]; } /** * @param list> $findings * @param array $attributes */ function vulnerabilitiesScan(Model $resource, array $findings, array $attributes = []): ImageScan { return ImageScan::factory()->create([ 'server_id' => test()->server->id, 'resource_type' => $resource->getMorphClass(), 'resource_id' => $resource->getKey(), 'image_ref' => 'nginx:1.27', 'builds_image' => true, 'findings' => $findings, 'summary' => ['truncated' => false, 'eol_distro' => null], 'scanned_at' => now(), ...$attributes, ]); } function vulnerabilitiesPage(User $user, Model $resource, string $type = 'application', ?Team $sessionTeam = null): Testable { test()->actingAs($user); session(['currentTeam' => $sessionTeam ?? test()->team]); return Livewire::test(Vulnerabilities::class, ['resourceType' => $type, 'resourceUuid' => $resource->uuid]); } function vulnerabilitiesService(): Service { return Service::factory()->create([ 'environment_id' => test()->environment->id, 'server_id' => test()->server->id, 'destination_id' => test()->destination->id, 'destination_type' => test()->destination->getMorphClass(), ]); } describe('authorization', function () { it('shows the findings to a member with read access', function () { vulnerabilitiesScan($this->application, [vulnerabilitiesFinding()]); vulnerabilitiesPage(vulnerabilitiesMember($this->team), $this->application) ->assertOk() ->assertSee('openssl') ->assertSee('CVE-2024-0001') ->assertDontSee('Scan now'); }); it('does not let a member scan or ignore', function () { vulnerabilitiesScan($this->application, [vulnerabilitiesFinding()]); $member = vulnerabilitiesMember($this->team); vulnerabilitiesPage($member, $this->application)->call('scanNow')->assertForbidden(); vulnerabilitiesPage($member, $this->application) ->call('ignore', 'CVE-2024-0001', 'vulnerability', 'resource', '90', 'Not reachable') ->assertForbidden(); Queue::assertNotPushed(ScanResourceImagesJob::class); expect(ImageScanIgnore::query()->count())->toBe(0); }); it('returns not found for a resource of another team', function () { $stranger = vulnerabilitiesMember(Team::factory()->create(), 'owner'); vulnerabilitiesPage($stranger, $this->application, 'application', $stranger->teams->first())->assertNotFound(); }); }); describe('compose image override', function () { beforeEach(function () { $this->application->forceFill([ 'build_pack' => 'dockercompose', 'docker_compose_raw' => "services:\n web:\n image: 'nginx:1.27'\n", 'docker_compose_image_overrides' => ['web' => ['from' => 'nginx:1.27', 'to' => 'nginx:1.29']], ])->saveQuietly(); }); it('lets the next deploy use the image of the compose file again', function () { vulnerabilitiesPage($this->admin, $this->application) ->call('removeImageOverride', 'web') ->assertDispatched('success'); expect($this->application->fresh()->docker_compose_image_overrides)->toBeNull(); }); it('keeps the override on a server that another Coolify instance manages now', function () { $this->server->forceFill(['server_metadata' => ['transfer' => ['status' => 'transferred']]])->saveQuietly(); Server::flushIdentityMap(); vulnerabilitiesPage($this->admin, $this->application) ->call('removeImageOverride', 'web') ->assertDispatched('error', 'Another Coolify instance manages this server now.'); expect($this->application->fresh()->docker_compose_image_overrides)->toBe(['web' => ['from' => 'nginx:1.27', 'to' => 'nginx:1.29']]); }); it('does not let a member remove the override', function () { vulnerabilitiesPage(vulnerabilitiesMember($this->team), $this->application) ->call('removeImageOverride', 'web') ->assertForbidden(); expect($this->application->fresh()->docker_compose_image_overrides)->not->toBeNull(); }); }); describe('scan now', function () { it('queues one scan of the running image and refuses a second one within a minute', function () { vulnerabilitiesScan($this->application, [vulnerabilitiesFinding()]); $page = vulnerabilitiesPage($this->admin, $this->application); $page->call('scanNow')->assertDispatched('success', 'Scan started'); $page->call('scanNow')->assertDispatched('error'); Queue::assertPushed(ScanResourceImagesJob::class, 1); Queue::assertPushed(ScanResourceImagesJob::class, fn (ScanResourceImagesJob $job) => $job->resource->is($this->application) && $job->imageRefs === ['nginx:1.27'] && $job->pullWhenMissing); }); it('shows the scan as running right after the click, without the last error', function () { vulnerabilitiesScan($this->application, [vulnerabilitiesFinding()]); Cache::put(ScanImage::lastErrorCacheKey($this->application), ['code' => 'image_missing', 'context' => ['server' => 'localhost']]); vulnerabilitiesPage($this->admin, $this->application) ->assertSee('The last scan could not run') ->call('scanNow') ->assertSee('Get scanner') ->assertDontSee('The last scan could not run'); }); it('asks for a deploy when no image of the app is known', function () { $this->application->forceFill(['build_pack' => 'nixpacks', 'docker_registry_image_name' => null])->saveQuietly(); vulnerabilitiesPage($this->admin, $this->application) ->call('scanNow') ->assertDispatched('error', 'Deploy the app first.'); Queue::assertNotPushed(ScanResourceImagesJob::class); }); it('does not start a scan on a server that another Coolify instance manages now', function () { vulnerabilitiesScan($this->application, [vulnerabilitiesFinding()]); $this->server->forceFill(['server_metadata' => ['transfer' => ['status' => 'transferred']]])->saveQuietly(); Server::flushIdentityMap(); vulnerabilitiesPage($this->admin, $this->application) ->call('scanNow') ->assertDispatched('error', 'Another Coolify instance manages this server now.') ->assertNotDispatched('success'); Queue::assertNotPushed(ScanResourceImagesJob::class); }); it('does not start a scan while one is running', function () { vulnerabilitiesScan($this->application, [vulnerabilitiesFinding()]); Cache::put(ScanImage::progressCacheKey($this->application), ['run_id' => 'x', 'stage' => 'sbom', 'started_at' => now()->toIso8601String()]); vulnerabilitiesPage($this->admin, $this->application) ->assertSee('Find vulnerabilities') ->call('scanNow') ->assertDispatched('error'); Queue::assertNotPushed(ScanResourceImagesJob::class); }); }); describe('scan progress', function () { it('shows each checkpoint in order for the stage of the scan', function (?string $stage, array $statuses) { Cache::put(ScanImage::progressCacheKey($this->application), array_filter([ 'run_id' => 'x', 'stage' => $stage, 'started_at' => now()->toIso8601String(), ])); $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'], $statuses, )); })->with([ 'before the first stage' => [null, ['running', 'pending', 'pending', 'pending']], 'database' => ['database', ['success', 'running', 'pending', 'pending']], 'inspect' => ['inspect', ['success', 'success', 'running', 'pending']], 'sbom' => ['sbom', ['success', 'success', 'running', 'pending']], 'vulnerabilities' => ['vulnerabilities', ['success', 'success', 'success', 'running']], ]); }); describe('ignore', function () { it('rejects an invalid vulnerability ID', function () { vulnerabilitiesScan($this->application, [vulnerabilitiesFinding()]); vulnerabilitiesPage($this->admin, $this->application) ->call('ignore', 'CVE 2024 ', 'vulnerability', 'resource', '90', 'Not reachable') ->assertHasErrors('vulnerability_id'); expect(ImageScanIgnore::query()->count())->toBe(0); }); it('shows a validation error as a toast', function () { vulnerabilitiesScan($this->application, [vulnerabilitiesFinding()]); vulnerabilitiesPage($this->admin, $this->application) ->call('ignore', 'CVE-2024-0001', 'vulnerability', 'resource', '90', '') ->assertHasErrors('reason') ->assertDispatched('error'); }); it('refreshes the counts of the other team resources after a team-wide ignore', function () { $other = Application::factory()->create([ 'environment_id' => $this->environment->id, 'destination_id' => $this->destination->id, 'destination_type' => $this->destination->getMorphClass(), 'build_pack' => 'dockerimage', 'docker_registry_image_name' => 'nginx', 'docker_registry_image_tag' => '1.27', ]); $critical = vulnerabilitiesFinding(['severity' => 'critical']); vulnerabilitiesScan($this->application, [$critical]); $otherScan = vulnerabilitiesScan($other, [$critical], ['summary' => vulnerabilitiesSummary(1)]); $otherTeamApp = Application::factory()->create([ 'environment_id' => Environment::factory()->create(['project_id' => Project::factory()->create(['team_id' => Team::factory()->create()->id])->id])->id, 'destination_id' => $this->destination->id, 'destination_type' => $this->destination->getMorphClass(), ]); $foreignScan = vulnerabilitiesScan($otherTeamApp, [$critical], ['summary' => vulnerabilitiesSummary(7)]); vulnerabilitiesPage($this->admin, $this->application) ->call('ignore', 'CVE-2024-0001', 'vulnerability', 'team', '90', 'Not reachable'); Queue::assertPushed(RefreshImageScanSummariesJob::class, fn (RefreshImageScanSummariesJob $job) => $job->teamId === $this->team->id); expect(ResourceScans::storedFixedCount($otherScan->fresh()))->toBe(1); (new RefreshImageScanSummariesJob($this->team->id))->handle(); expect(ResourceScans::storedFixedCount($otherScan->fresh()))->toBe(0) ->and(ResourceScans::storedFixedCount($foreignScan->fresh()))->toBe(7); }); it('rejects an ID that is not in the results', function () { vulnerabilitiesScan($this->application, [vulnerabilitiesFinding()]); vulnerabilitiesPage($this->admin, $this->application) ->call('ignore', 'CVE-2099-9999', 'vulnerability', 'resource', '90', 'Not reachable') ->assertHasErrors('vulnerability_id'); expect(ImageScanIgnore::query()->count())->toBe(0); }); it('stores the ignore for the team of the resource after the session switched teams', function () { $otherTeam = Team::factory()->create(); $this->admin->teams()->attach($otherTeam, ['role' => 'owner']); vulnerabilitiesScan($this->application, [vulnerabilitiesFinding()]); $page = vulnerabilitiesPage($this->admin, $this->application); session(['currentTeam' => $otherTeam]); $page->call('ignore', 'CVE-2024-0001', 'vulnerability', 'resource', '90', 'Not reachable') ->assertHasNoErrors() ->assertDispatched('success', 'Ignored'); $ignore = ImageScanIgnore::query()->sole(); expect($ignore->team_id)->toBe($this->team->id) ->and($ignore->resource_type)->toBe($this->application->getMorphClass()) ->and($ignore->resource_id)->toBe($this->application->id) ->and($ignore->vulnerability_id)->toBe('CVE-2024-0001') ->and($ignore->created_by)->toBe($this->admin->id) ->and($ignore->expires_at->isSameDay(now()->addDays(90)))->toBeTrue(); }); it('ignores a package for all team resources without an expiry', function () { vulnerabilitiesScan($this->application, [vulnerabilitiesFinding(['epss' => null])]); vulnerabilitiesPage($this->admin, $this->application) ->call('ignore', 'openssl', 'package', 'team', 'never', 'Patched by the base image') ->assertHasNoErrors(); $ignore = ImageScanIgnore::query()->sole(); expect($ignore->package)->toBe('openssl') ->and($ignore->vulnerability_id)->toBeNull() ->and($ignore->resource_id)->toBeNull() ->and($ignore->expires_at)->toBeNull(); }); it('refuses an ignore without an expiry for a problem with an exploit signal', function (string $target, string $kind, array $finding) { vulnerabilitiesScan($this->application, [vulnerabilitiesFinding(['epss' => null]), vulnerabilitiesFinding($finding)]); vulnerabilitiesPage($this->admin, $this->application) ->call('ignore', $target, $kind, 'resource', 'never', 'Not reachable') ->assertHasErrors(['expires']) ->assertDispatched('error', 'Attackers are using or are likely to use this problem, so its ignore must expire. Choose 30 or 90 days.'); expect(ImageScanIgnore::query()->count())->toBe(0); vulnerabilitiesPage($this->admin, $this->application) ->call('ignore', $target, $kind, 'resource', '90', 'Not reachable') ->assertHasNoErrors(); expect(ImageScanIgnore::query()->sole()->expires_at->isSameDay(now()->addDays(90)))->toBeTrue(); })->with([ 'exploited ID' => ['CVE-2026-0002', 'vulnerability', ['id' => 'CVE-2026-0002', 'kev' => true, 'epss' => null]], 'exploited alias' => ['CVE-2026-0003', 'vulnerability', ['id' => 'GHSA-aaaa-bbbb-cccc', 'aliases' => ['CVE-2026-0003'], 'kev' => true, 'epss' => null]], 'likely to be exploited' => ['CVE-2026-0004', 'vulnerability', ['id' => 'CVE-2026-0004', 'epss' => 0.5, 'cvssScore' => 9.8]], 'package with an exploited problem' => ['zlib', 'package', ['id' => 'CVE-2026-0005', 'package' => 'zlib', 'kev' => true, 'epss' => null]], ]); it('offers "Never" only for problems without an exploit signal', function () { vulnerabilitiesScan($this->application, [ vulnerabilitiesFinding(['epss' => null]), vulnerabilitiesFinding(['id' => 'CVE-2026-0002', 'package' => 'zlib', 'kev' => true, 'epss' => null]), ]); $html = preg_replace('/\s+/', ' ', vulnerabilitiesPage($this->admin, $this->application)->html()); expect($html)->toContain("target: 'CVE-2026-0002', kind: 'vulnerability', owner: '{$this->application->uuid}', neverAllowed: false,") ->and($html)->toContain("target: 'zlib', kind: 'package', owner: '{$this->application->uuid}', neverAllowed: false,") ->and($html)->toContain("target: 'CVE-2024-0001', kind: 'vulnerability', owner: '{$this->application->uuid}', neverAllowed: true,") ->and($html)->toContain("target: 'openssl', kind: 'package', owner: '{$this->application->uuid}', neverAllowed: true,"); }); it('lets only team admins ignore for all team resources', function () { $writer = vulnerabilitiesMember($this->team, 'member', [ ['scope' => 'resource', 'uuid' => $this->application->uuid, 'levels' => ['read', 'write']], ]); vulnerabilitiesScan($this->application, [vulnerabilitiesFinding()]); vulnerabilitiesPage($writer, $this->application) ->call('ignore', 'CVE-2024-0001', 'vulnerability', 'team', '30', 'Not reachable') ->assertForbidden(); vulnerabilitiesPage($writer, $this->application) ->call('ignore', 'CVE-2024-0001', 'vulnerability', 'resource', '30', 'Not reachable') ->assertHasNoErrors(); expect(ImageScanIgnore::query()->sole()->resource_id)->toBe($this->application->id); }); it('updates the stored count of the scan', function () { $scan = vulnerabilitiesScan($this->application, [vulnerabilitiesFinding(['severity' => 'critical', 'type' => 'npm'])], [ 'summary' => vulnerabilitiesSummary(1), ]); vulnerabilitiesPage($this->admin, $this->application) ->call('ignore', 'CVE-2024-0001', 'vulnerability', 'resource', '90', 'Not reachable'); expect(ResourceScans::storedFixedCount($scan->fresh()))->toBe(0) ->and($scan->fresh()->summary['ignored'])->toBe(1); }); }); describe('findings', function () { it('shows the findings with a fixed version by default', function () { vulnerabilitiesScan($this->application, [ vulnerabilitiesFinding(), vulnerabilitiesFinding(['id' => 'CVE-2024-0002', 'package' => 'zlib', 'fixedIn' => null, 'fixState' => 'not-fixed']), ]); vulnerabilitiesPage($this->admin, $this->application) ->assertSee('openssl') ->assertDontSee('zlib') ->call('setFixState', 'no_fix') ->assertSee('zlib') ->assertDontSee('openssl'); }); it('finds a vulnerability by an alias', function () { vulnerabilitiesScan($this->application, [ vulnerabilitiesFinding(['id' => 'CVE-2024-1111', 'aliases' => ['GHSA-abcd-efgh-ijkl'], 'package' => 'lodash']), vulnerabilitiesFinding(['id' => 'CVE-2024-2222', 'package' => 'express']), ]); vulnerabilitiesPage($this->admin, $this->application) ->set('search', 'ghsa-abcd') ->assertSee('lodash') ->assertDontSee('express'); }); it('links only CVE and GHSA IDs that pass the ID pattern', function () { vulnerabilitiesScan($this->application, [ vulnerabilitiesFinding(['id' => 'CVE-2024-0001', 'aliases' => ['GHSA-aaaa-bbbb-cccc', 'bad alias