mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-06 13:10:37 -04:00
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: peaklabs-dev <122374094+peaklabs-dev@users.noreply.github.com> Co-authored-by: Aditya Tripathi <aditya@climactic.co>
967 lines
47 KiB
PHP
967 lines
47 KiB
PHP
<?php
|
|
|
|
use App\Actions\ImageScan\ScanImage;
|
|
use App\Actions\ImageScan\UpdateResourceImage;
|
|
use App\Actions\Service\RestartService;
|
|
use App\Enums\ApplicationDeploymentStatus;
|
|
use App\Livewire\ImageScan\Vulnerabilities;
|
|
use App\Livewire\Project\Application\Deployment\Show;
|
|
use App\Livewire\Project\Application\Heading;
|
|
use App\Models\Application;
|
|
use App\Models\ApplicationDeploymentQueue;
|
|
use App\Models\AuditEvent;
|
|
use App\Models\Environment;
|
|
use App\Models\ImageScan;
|
|
use App\Models\InstanceSettings;
|
|
use App\Models\Project;
|
|
use App\Models\Server;
|
|
use App\Models\Service;
|
|
use App\Models\ServiceApplication;
|
|
use App\Models\StandaloneDocker;
|
|
use App\Models\StandalonePostgresql;
|
|
use App\Models\Team;
|
|
use App\Models\User;
|
|
use App\Services\ImageScan\ImageUpdateRefused;
|
|
use App\Services\ImageScan\ResourceScans;
|
|
use App\Services\ImageScan\SecurityActions;
|
|
use App\Services\ImageScan\SecurityCard;
|
|
use App\Services\ImageScan\SecurityStatus;
|
|
use Illuminate\Database\Eloquent\Model;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Support\Facades\Cache;
|
|
use Illuminate\Support\Facades\Queue;
|
|
use Illuminate\Support\Once;
|
|
use Illuminate\Support\Str;
|
|
use Livewire\Features\SupportTesting\Testable;
|
|
use Livewire\Livewire;
|
|
use Spatie\Activitylog\Models\Activity;
|
|
use Symfony\Component\Yaml\Yaml;
|
|
|
|
uses(RefreshDatabase::class);
|
|
|
|
beforeEach(function () {
|
|
Once::flush();
|
|
Cache::flush();
|
|
Server::flushIdentityMap();
|
|
config(['app.maintenance.store' => 'array']);
|
|
$this->withoutVite();
|
|
$this->withoutDefer();
|
|
InstanceSettings::forceCreate(['id' => 0, 'is_image_scanning_allowed' => true]);
|
|
Queue::fake();
|
|
|
|
$this->team = Team::factory()->create();
|
|
$this->admin = securityPageMember($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,
|
|
]);
|
|
});
|
|
|
|
function securityPageMember(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<string, mixed> $overrides
|
|
* @return array<string, mixed>
|
|
*/
|
|
function securityPageFinding(string $id, array $overrides = []): array
|
|
{
|
|
return [
|
|
'id' => $id,
|
|
'aliases' => [],
|
|
'severity' => 'high',
|
|
'package' => 'openssl',
|
|
'version' => '3.0.1',
|
|
'type' => 'apk',
|
|
'artifactId' => 'a-'.$id,
|
|
'fixedIn' => '3.0.2',
|
|
'fixState' => 'fixed',
|
|
'kev' => true,
|
|
'epss' => null,
|
|
'risk' => 1.0,
|
|
'summary' => '',
|
|
'layer' => 'app',
|
|
'title' => 'Problem '.$id,
|
|
'cvssScore' => 7.5,
|
|
...$overrides,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param list<array<string, mixed>> $entries
|
|
* @return array<string, mixed>
|
|
*/
|
|
function securityPageCandidates(array $entries, string $status = 'ok'): array
|
|
{
|
|
return ['status' => $status, 'checked_at' => now()->toIso8601String(), 'error' => null, 'tags_truncated' => false, 'entries' => $entries];
|
|
}
|
|
|
|
/**
|
|
* @param list<string> $fixedIds
|
|
* @return array<string, mixed>
|
|
*/
|
|
function securityPageEntry(string $ref, string $kind, array $fixedIds): array
|
|
{
|
|
return ['ref' => $ref, 'kind' => $kind, 'better' => true, 'fixed_ids' => $fixedIds, 'eol' => null];
|
|
}
|
|
|
|
/**
|
|
* @param list<array<string, mixed>> $findings
|
|
* @param array<string, mixed> $attributes
|
|
*/
|
|
function securityPageScan(Model $resource, string $imageRef, array $findings, array $attributes = []): ImageScan
|
|
{
|
|
return ImageScan::factory()->create([
|
|
'server_id' => test()->server->id,
|
|
'resource_type' => $resource->getMorphClass(),
|
|
'resource_id' => $resource->getKey(),
|
|
'image_ref' => $imageRef,
|
|
'builds_image' => false,
|
|
'findings' => $findings,
|
|
'candidates' => securityPageCandidates([], 'no_better'),
|
|
'summary' => ['truncated' => false, 'eol_distro' => null],
|
|
'grype_db_built_at' => now()->subDay(),
|
|
'scanned_at' => now(),
|
|
...$attributes,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $attributes
|
|
*/
|
|
function securityPageApplication(array $attributes = []): Application
|
|
{
|
|
return Application::factory()->create([
|
|
'environment_id' => test()->environment->id,
|
|
'destination_id' => test()->destination->id,
|
|
'destination_type' => test()->destination->getMorphClass(),
|
|
'build_pack' => 'dockerimage',
|
|
'docker_registry_image_name' => 'nginx',
|
|
'docker_registry_image_tag' => '1.27',
|
|
...$attributes,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $attributes
|
|
*/
|
|
function securityPageDeployment(Application $application, array $attributes = []): ApplicationDeploymentQueue
|
|
{
|
|
return ApplicationDeploymentQueue::create([
|
|
'deployment_uuid' => (string) Str::uuid(),
|
|
'application_id' => $application->id,
|
|
'server_id' => test()->server->id,
|
|
'server_name' => test()->server->name,
|
|
'destination_id' => test()->destination->id,
|
|
'commit' => 'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2',
|
|
'pull_request_id' => 0,
|
|
'status' => ApplicationDeploymentStatus::FINISHED->value,
|
|
...$attributes,
|
|
]);
|
|
}
|
|
|
|
function securityPageDatabase(): StandalonePostgresql
|
|
{
|
|
return StandalonePostgresql::forceCreate([
|
|
'name' => 'pg',
|
|
'postgres_password' => 'secret',
|
|
'image' => 'postgres:15.0',
|
|
'environment_id' => test()->environment->id,
|
|
'destination_id' => test()->destination->id,
|
|
'destination_type' => test()->destination->getMorphClass(),
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* A Dockerfile app from node:20.11-alpine, running the scanned commit: lodash (npm, exploited) needs a package
|
|
* upgrade, openssl (apk, likely) a rebuild without cache, and busybox comes from the base image, which a newer base
|
|
* fixes.
|
|
*/
|
|
function securityPageDockerfileApp(): Application
|
|
{
|
|
$application = securityPageApplication(['build_pack' => 'dockerfile', 'docker_registry_image_name' => null]);
|
|
$deployment = securityPageDeployment($application);
|
|
securityPageScan($application, $application->uuid.':'.$deployment->commit, [
|
|
securityPageFinding('CVE-2026-1000', ['package' => 'lodash', 'version' => '4.17.20', 'fixedIn' => '4.17.21', 'type' => 'npm', 'severity' => 'critical']),
|
|
securityPageFinding('CVE-2026-2000', ['kev' => false, 'epss' => 0.5]),
|
|
securityPageFinding('CVE-2026-3000', ['package' => 'busybox', 'kev' => false, 'severity' => 'critical', 'cvssScore' => 9.1]),
|
|
], [
|
|
'deployment_queue_id' => $deployment->id,
|
|
'builds_image' => true,
|
|
'base_image_ref' => 'node:20.11-alpine',
|
|
'candidates' => [...securityPageCandidates([], 'skipped'), 'base' => [
|
|
'image' => 'node:20.11-alpine', 'status' => 'ok', 'checked_at' => now()->toIso8601String(), 'error' => null,
|
|
'attributable' => 1, 'attributable_ids' => ['CVE-2026-3000'],
|
|
'entries' => [securityPageEntry('node:20.18-alpine', 'line', ['CVE-2026-3000'])],
|
|
]],
|
|
]);
|
|
|
|
return $application;
|
|
}
|
|
|
|
/**
|
|
* A service that runs n8n and its runners at 1.0, both with a newer 1.1 that fixes a problem attackers are using.
|
|
*
|
|
* @return array{0: Service, 1: ServiceApplication, 2: ServiceApplication}
|
|
*/
|
|
function securityPageN8nService(): array
|
|
{
|
|
$service = Service::factory()->create([
|
|
'environment_id' => test()->environment->id,
|
|
'server_id' => test()->server->id,
|
|
'destination_id' => test()->destination->id,
|
|
'destination_type' => test()->destination->getMorphClass(),
|
|
'docker_compose_raw' => Yaml::dump(['services' => [
|
|
'n8n' => ['image' => 'n8nio/n8n:1.0'],
|
|
'n8n runners' => ['image' => 'n8nio/runners:1.0'],
|
|
]], 10, 2),
|
|
]);
|
|
$main = ServiceApplication::forceCreate(['uuid' => (string) Str::uuid(), 'service_id' => $service->id, 'name' => 'n8n', 'image' => 'n8nio/n8n:1.0']);
|
|
$runners = ServiceApplication::forceCreate(['uuid' => (string) Str::uuid(), 'service_id' => $service->id, 'name' => 'n8n runners', 'image' => 'n8nio/runners:1.0']);
|
|
foreach ([[$main, 'n8nio/n8n:1.1', 'CVE-2026-0001'], [$runners, 'n8nio/runners:1.1', 'CVE-2026-0002']] as [$owner, $candidate, $id]) {
|
|
securityPageScan($owner, $owner->image, [securityPageFinding($id)], [
|
|
'candidates' => securityPageCandidates([securityPageEntry($candidate, 'line', [$id])]),
|
|
]);
|
|
}
|
|
|
|
return [$service, $main, $runners];
|
|
}
|
|
|
|
function securityPage(User $user, Model $resource, string $type, ?Team $sessionTeam = null): Testable
|
|
{
|
|
test()->actingAs($user);
|
|
session(['currentTeam' => $sessionTeam ?? test()->team]);
|
|
|
|
return Livewire::test(Vulnerabilities::class, ['resourceType' => $type, 'resourceUuid' => $resource->uuid]);
|
|
}
|
|
|
|
describe('main card', function () {
|
|
it('puts the sentence and the action first, the why-list inside the image, and no count outside All findings', function () {
|
|
$database = securityPageDatabase();
|
|
securityPageScan($database, 'postgres:15.0', [
|
|
securityPageFinding('CVE-2025-1094', ['title' => 'SQL injection in psql quoting', 'cvssScore' => 8.1]),
|
|
securityPageFinding('CVE-2026-0002', ['severity' => 'critical', 'fixedIn' => null, 'fixState' => 'not-fixed', 'kev' => false]),
|
|
securityPageFinding('CVE-2026-0003', ['severity' => 'medium', 'kev' => false]),
|
|
], ['candidates' => securityPageCandidates([securityPageEntry('postgres:15.13', 'line', ['CVE-2025-1094'])])]);
|
|
|
|
securityPage($this->admin, $database, 'database')
|
|
->assertSeeInOrder([
|
|
'has a security problem that attackers are using. Update to 15.13 to fix it.',
|
|
'Update to 15.13',
|
|
'Fixed by this update',
|
|
'SQL injection in psql quoting',
|
|
'How Coolify knows:',
|
|
'All findings (3)',
|
|
])
|
|
->assertDontSee('Top problems')
|
|
->assertDontSee('Most severe problems')
|
|
->assertDontSee('A newer version fixes these')
|
|
->assertDontSeeHtml('aria-label="Images"')
|
|
->assertDontSeeHtml('More for postgres:15.0');
|
|
});
|
|
|
|
it('shows the score next to the severity only when its CVSS range has the same severity', function () {
|
|
$database = securityPageDatabase();
|
|
securityPageScan($database, 'postgres:15.0', [
|
|
securityPageFinding('CVE-2026-0001', ['severity' => 'medium', 'cvssScore' => 7.5, 'title' => 'Medium with a high score']),
|
|
securityPageFinding('CVE-2026-0002', ['severity' => 'high', 'cvssScore' => 8.1, 'title' => 'High with a high score']),
|
|
], ['candidates' => securityPageCandidates([securityPageEntry('postgres:15.13', 'line', ['CVE-2026-0001', 'CVE-2026-0002'])])]);
|
|
$this->actingAs($this->admin);
|
|
|
|
$why = collect(SecurityCard::for($database, SecurityStatus::forResource($database), ResourceScans::owners($database), ResourceScans::current($database), $this->admin)['rows'][0]['why'])
|
|
->keyBy('display_id');
|
|
|
|
expect($why['CVE-2026-0002']['cvss'])->toBe('8.1')
|
|
->and($why['CVE-2026-0001']['cvss'])->toBeNull()
|
|
->and($why['CVE-2026-0001']['cvssNote'])->toBe('CVSS 7.5');
|
|
|
|
securityPage($this->admin, $database, 'database')
|
|
->assertSeeHtml('data-tooltip="CVSS 7.5"')
|
|
->assertSeeInOrder(['High with a high score', 'High', '8.1']);
|
|
});
|
|
|
|
it('links the more problems of the why-list to All findings', function () {
|
|
$database = securityPageDatabase();
|
|
$ids = array_map(fn (int $index): string => sprintf('CVE-2026-%04d', $index), range(1, 5));
|
|
securityPageScan($database, 'postgres:15.0', array_map(fn (string $id): array => securityPageFinding($id), $ids), [
|
|
'candidates' => securityPageCandidates([securityPageEntry('postgres:15.13', 'line', $ids)]),
|
|
]);
|
|
|
|
securityPage($this->admin, $database, 'database')
|
|
->assertSeeText('2 more problems this update fixes')
|
|
->assertSeeHtml("id: 'all-findings-section'");
|
|
});
|
|
|
|
it('tells a postgres 15.0 database to update now and why', function () {
|
|
$database = securityPageDatabase();
|
|
securityPageScan($database, 'postgres:15.0', [
|
|
securityPageFinding('CVE-2025-1094', ['package' => 'libpq5', 'version' => '15.0', 'fixedIn' => '15.10', 'type' => 'deb', 'cvssScore' => 8.1, 'title' => 'SQL injection in psql quoting']),
|
|
], ['candidates' => securityPageCandidates([securityPageEntry('postgres:15.13', 'line', ['CVE-2025-1094'])])]);
|
|
|
|
securityPage($this->admin, $database, 'database')
|
|
->assertSee('Update now')
|
|
->assertSeeText('postgres:15.0 has a security problem that attackers are using. Update to 15.13 to fix it.')
|
|
->assertSee('SQL injection in psql quoting')
|
|
->assertSee('CVE-2025-1094')
|
|
->assertSeeInOrder(['Fixed by this update', 'SQL injection in psql quoting', 'High', '8.1'])
|
|
->assertSee('Used by attackers')
|
|
->assertSee('Update to 15.13')
|
|
->assertSee('postgres:15.0 '.substr(ResourceScans::current($database)->first()->image_id, 7, 12).' → postgres:15.13')
|
|
->assertSee('This is a patch release of PostgreSQL 15. Your data stays as it is. The database restarts and is unavailable for a few seconds.')
|
|
->assertSee('Checked');
|
|
});
|
|
|
|
it('offers to pull a newer build of the image of a Docker image app', function () {
|
|
$application = securityPageApplication();
|
|
securityPageScan($application, 'nginx:1.27', [securityPageFinding('CVE-2026-0001')], [
|
|
'candidates' => securityPageCandidates([securityPageEntry('nginx:1.27', 'same_tag', ['CVE-2026-0001'])]),
|
|
]);
|
|
|
|
securityPage($this->admin, $application, 'application')
|
|
->assertSeeText('nginx:1.27 has a security problem that attackers are using. A newer build fixes it. Pull it and restart.')
|
|
->assertSee('Pull latest and restart')
|
|
->assertSee('Pull the build of nginx:1.27 that Coolify scanned.')
|
|
->assertSeeHtml('updateImage(\\u0027nginx:1.27\\u0027');
|
|
});
|
|
|
|
it('gives a Dockerfile app a rebuild, the package to upgrade, and the newer base image', function () {
|
|
$application = securityPageDockerfileApp();
|
|
|
|
securityPage($this->admin, $application, 'application')
|
|
->assertSee('Rebuild without cache to get the fixed system packages.')
|
|
->assertSee('Coolify rebuilds commit a1b2c3d without cache and redeploys it.')
|
|
->assertSeeHtml('submitAction: \'rebuild\'')
|
|
->assertSee('Upgrade lodash to 4.17.21 or later, then redeploy.')
|
|
->assertSeeHtml("copy(await ('lodash 4.17.21'))")
|
|
->assertSee('Change the base image to node:20.18-alpine in your Dockerfile, then redeploy.')
|
|
->assertSeeHtml("copy(await ('FROM node:20.18-alpine'))");
|
|
});
|
|
|
|
it('says that only a builder update fixes the OS packages of a Nixpacks app', function () {
|
|
$application = securityPageApplication(['build_pack' => 'nixpacks', 'docker_registry_image_name' => null]);
|
|
$deployment = securityPageDeployment($application);
|
|
securityPageScan($application, $application->uuid.':'.$deployment->commit, [securityPageFinding('CVE-2026-0001')], [
|
|
'deployment_queue_id' => $deployment->id,
|
|
'builds_image' => true,
|
|
]);
|
|
|
|
securityPage($this->admin, $application, 'application')
|
|
->assertSee('No fix yet')
|
|
->assertSeeText('This app has a security problem that attackers are using. '.SecurityActions::BUILDER_TEXT)
|
|
->assertDontSeeHtml('rebuild');
|
|
});
|
|
|
|
it('updates the images of a service that share a version in one action', function () {
|
|
[$service] = securityPageN8nService();
|
|
|
|
securityPage($this->admin, $service, 'service')
|
|
->assertSee('2 of 2 images need an update')
|
|
->assertSeeText('n8nio/n8n:1.0 has a security problem that attackers are using. Update to 1.1 to fix it.')
|
|
->assertSeeText('n8nio/runners:1.0 has a security problem that attackers are using. Update to 1.1 to fix it.')
|
|
->assertSee('Update to 1.1')
|
|
->assertSee('Updated with n8n')
|
|
->assertSee('Pull the images and restart the service: n8n, n8n runners.')
|
|
->assertSeeHtml('updateImages(');
|
|
});
|
|
|
|
it('says that no action is needed', function () {
|
|
$application = securityPageApplication();
|
|
securityPageScan($application, 'nginx:1.27', [securityPageFinding('CVE-2026-0001', ['kev' => false, 'severity' => 'medium', 'cvssScore' => 5.0])]);
|
|
|
|
securityPage($this->admin, $application, 'application')
|
|
->assertSeeText('nginx:1.27 needs no action.')
|
|
->assertDontSee('Update now')
|
|
->assertDontSee('Security update');
|
|
});
|
|
|
|
it('asks for a new check with the reason', function () {
|
|
$application = securityPageApplication();
|
|
securityPageScan($application, 'nginx:1.27', [securityPageFinding('CVE-2026-0001')], ['scanned_at' => now()->subDays(3)]);
|
|
|
|
securityPage($this->admin, $application, 'application')
|
|
->assertSee('Check needed')
|
|
->assertSeeText('nginx:1.27 needs another check: the last check was 3 days ago.')
|
|
->assertSee('Scan now');
|
|
});
|
|
|
|
it('shows only the error, not Not scanned yet, when the first scan could not run', function () {
|
|
$database = securityPageDatabase();
|
|
Cache::put(ScanImage::lastErrorCacheKey($database), ['code' => 'out_of_memory', 'context' => [], 'at' => now()->toIso8601String()], 60);
|
|
|
|
securityPage($this->admin, $database, 'database')
|
|
->assertSee('The last scan could not run')
|
|
->assertDontSee('Not scanned yet')
|
|
->assertSee('Scan now');
|
|
});
|
|
|
|
it('shows the actions to a read-only member without buttons', function () {
|
|
$database = securityPageDatabase();
|
|
securityPageScan($database, 'postgres:15.0', [securityPageFinding('CVE-2025-1094')], [
|
|
'candidates' => securityPageCandidates([securityPageEntry('postgres:15.13', 'line', ['CVE-2025-1094'])]),
|
|
]);
|
|
|
|
securityPage(securityPageMember($this->team), $database, 'database')
|
|
->assertSee('Update to 15.13 to fix it.')
|
|
->assertDontSeeHtml('updateImage(')
|
|
->assertDontSee('Scan now');
|
|
});
|
|
|
|
it('counts the findings that are not ignored in the All findings header', function () {
|
|
$application = securityPageApplication();
|
|
securityPageScan($application, 'nginx:1.27', [
|
|
securityPageFinding('CVE-2026-0001'),
|
|
securityPageFinding('CVE-2026-0002', ['fixedIn' => null, 'fixState' => 'not-fixed', 'package' => 'zlib', 'kev' => false]),
|
|
securityPageFinding('CVE-2026-0003', ['severity' => 'low']),
|
|
]);
|
|
|
|
securityPage($this->admin, $application, 'application')
|
|
->assertSee('All findings (3)')
|
|
->assertSeeText('Fix available upstream (2)')
|
|
->assertSeeText('No fix yet (1)')
|
|
->assertDontSee('zlib')
|
|
->call('setFixState', 'no_fix')
|
|
->assertSee('zlib');
|
|
});
|
|
});
|
|
|
|
describe('rebuild', function () {
|
|
it('queues the running commit without cache on the same server and destination', function () {
|
|
$application = securityPageDockerfileApp();
|
|
$deployer = securityPageMember($this->team, 'member', [
|
|
['scope' => 'resource', 'uuid' => $application->uuid, 'levels' => ['read', 'deploy']],
|
|
]);
|
|
|
|
securityPage($deployer, $application, 'application')
|
|
->call('rebuild', '')
|
|
->assertDispatched('success', 'Rebuild started.');
|
|
|
|
$queued = ApplicationDeploymentQueue::query()->where('application_id', $application->id)->latest('id')->first();
|
|
expect(ApplicationDeploymentQueue::query()->where('application_id', $application->id)->count())->toBe(2)
|
|
->and($queued->commit)->toBe('a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2')
|
|
->and((bool) $queued->force_rebuild)->toBeTrue()
|
|
->and((int) $queued->server_id)->toBe($this->server->id)
|
|
->and((int) $queued->destination_id)->toBe($this->destination->id)
|
|
->and(AuditEvent::query()->where('event', 'ui.image_scan.rebuild')->exists())->toBeTrue();
|
|
});
|
|
|
|
it('forbids a read-only member', function () {
|
|
$application = securityPageDockerfileApp();
|
|
|
|
securityPage(securityPageMember($this->team), $application, 'application')
|
|
->assertDontSeeHtml('submitAction: \'rebuild\'')
|
|
->call('rebuild', '')
|
|
->assertForbidden();
|
|
|
|
expect(ApplicationDeploymentQueue::query()->where('application_id', $application->id)->count())->toBe(1);
|
|
});
|
|
|
|
it('refuses Coolify\'s own app', function () {
|
|
Team::forceCreate(['id' => 0, 'name' => 'Root team', 'personal_team' => false]);
|
|
$this->admin->teams()->attach(0, ['role' => 'admin']);
|
|
$application = securityPageApplication(['id' => 0, 'build_pack' => 'dockerfile', 'docker_registry_image_name' => null]);
|
|
securityPageDeployment($application);
|
|
|
|
securityPage($this->admin->fresh(), $application, 'application')
|
|
->call('rebuild', '')
|
|
->assertForbidden();
|
|
|
|
expect(ApplicationDeploymentQueue::query()->where('application_id', 0)->count())->toBe(1);
|
|
});
|
|
|
|
it('refuses an app on a server that another Coolify instance manages now', function () {
|
|
$application = securityPageDockerfileApp();
|
|
$this->server->forceFill(['server_metadata' => ['transfer' => ['status' => 'transferred']]])->saveQuietly();
|
|
|
|
securityPage($this->admin, $application, 'application')
|
|
->assertDontSeeHtml('submitAction: \'rebuild\'')
|
|
->call('rebuild', '')
|
|
->assertDispatched('error', 'Another Coolify instance manages this server now.');
|
|
|
|
expect(ApplicationDeploymentQueue::query()->where('application_id', $application->id)->count())->toBe(1);
|
|
});
|
|
});
|
|
|
|
describe('rebuild limits', function () {
|
|
it('passes the image tag of the running deployment', function () {
|
|
$application = securityPageDockerfileApp();
|
|
SecurityCard::runningDeployment($application)->update(['docker_registry_image_tag' => 'v1.2']);
|
|
|
|
securityPage($this->admin, $application, 'application')
|
|
->call('rebuild', '')
|
|
->assertDispatched('success', 'Rebuild started.');
|
|
|
|
expect(ApplicationDeploymentQueue::query()->where('application_id', $application->id)->latest('id')->first()->docker_registry_image_tag)->toBe('v1.2');
|
|
});
|
|
|
|
it('hides and refuses the rebuild of a Git app whose running commit is not known', function (?string $commit) {
|
|
$application = securityPageDockerfileApp();
|
|
SecurityCard::runningDeployment($application)->update(['commit' => $commit]);
|
|
|
|
securityPage($this->admin, $application, 'application')
|
|
->assertSee('Rebuild without cache to get the fixed system packages.')
|
|
->assertDontSeeHtml('submitAction: \'rebuild\'')
|
|
->call('rebuild', '')
|
|
->assertDispatched('error', 'Coolify does not know which commit runs now. Redeploy the app first.');
|
|
|
|
expect(ApplicationDeploymentQueue::query()->where('application_id', $application->id)->count())->toBe(1);
|
|
})->with(['HEAD' => ['HEAD'], 'blank' => ['']]);
|
|
|
|
it('hides the rebuild of Coolify\'s own app', function () {
|
|
Team::forceCreate(['id' => 0, 'name' => 'Root team', 'personal_team' => false]);
|
|
$this->admin->teams()->attach(0, ['role' => 'admin']);
|
|
$application = securityPageApplication(['id' => 0, 'build_pack' => 'dockerfile', 'docker_registry_image_name' => null]);
|
|
$deployment = securityPageDeployment($application);
|
|
securityPageScan($application, $application->uuid.':'.$deployment->commit, [securityPageFinding('CVE-2026-2000', ['kev' => false, 'epss' => 0.5])], [
|
|
'deployment_queue_id' => $deployment->id,
|
|
'builds_image' => true,
|
|
]);
|
|
|
|
securityPage($this->admin->fresh(), $application, 'application')
|
|
->assertSee('Rebuild without cache to get the fixed system packages.')
|
|
->assertDontSeeHtml('submitAction: \'rebuild\'');
|
|
});
|
|
|
|
it('authorizes the rebuild against the team of the app after the session switched teams', function () {
|
|
$application = securityPageDockerfileApp();
|
|
$deployer = securityPageMember($this->team, 'member', [
|
|
['scope' => 'resource', 'uuid' => $application->uuid, 'levels' => ['read', 'deploy']],
|
|
]);
|
|
$ownTeam = Team::factory()->create();
|
|
$deployer->teams()->attach($ownTeam, ['role' => 'owner']);
|
|
|
|
$page = securityPage($deployer->fresh(), $application, 'application');
|
|
session(['currentTeam' => $ownTeam]);
|
|
$page->call('rebuild', '')->assertDispatched('success', 'Rebuild started.');
|
|
|
|
expect(AuditEvent::query()->where('event', 'ui.image_scan.rebuild')->sole()->team_id)->toBe($this->team->id);
|
|
});
|
|
});
|
|
|
|
describe('merged service update', function () {
|
|
it('updates every image of the action and restarts the service once', function () {
|
|
[$service, $main, $runners] = securityPageN8nService();
|
|
RestartService::shouldRun()->once()->andReturn(new Activity);
|
|
$key = SecurityCard::key(SecurityStatus::forResource($service)->actions[0]);
|
|
|
|
securityPage($this->admin, $service, 'service')
|
|
->call('updateImages', $key, '')
|
|
->assertDispatched('success', 'Update started.');
|
|
|
|
$compose = Yaml::parse($service->fresh()->docker_compose_raw);
|
|
expect($main->fresh()->image)->toBe('n8nio/n8n:1.1')
|
|
->and($runners->fresh()->image)->toBe('n8nio/runners:1.1')
|
|
->and(data_get($compose, 'services.n8n.image'))->toBe('n8nio/n8n:1.1')
|
|
->and(data_get($compose, 'services.n8n runners.image'))->toBe('n8nio/runners:1.1');
|
|
});
|
|
|
|
it('puts every image back when the restart fails', function () {
|
|
[$service, $main, $runners] = securityPageN8nService();
|
|
$raw = $service->docker_compose_raw;
|
|
RestartService::shouldRun()->once()->andThrow(new RuntimeException('ssh: connect to host failed'));
|
|
$key = SecurityCard::key(SecurityStatus::forResource($service)->actions[0]);
|
|
|
|
securityPage($this->admin, $service, 'service')
|
|
->call('updateImages', $key, '')
|
|
->assertDispatched('error', 'The restart failed. Coolify kept the old image.');
|
|
|
|
expect($service->fresh()->docker_compose_raw)->toBe($raw)
|
|
->and($main->fresh()->image)->toBe('n8nio/n8n:1.0')
|
|
->and($runners->fresh()->image)->toBe('n8nio/runners:1.0');
|
|
});
|
|
|
|
it('refuses a part that is no longer a better candidate of its own scan', function () {
|
|
[$service, $main, $runners] = securityPageN8nService();
|
|
$key = SecurityCard::key(SecurityStatus::forResource($service)->actions[0]);
|
|
RestartService::shouldRun()->never();
|
|
// The runners image changed after the page loaded; its scan no longer offers 1.1.
|
|
ResourceScans::currentFor($runners)->first()->update(['candidates' => securityPageCandidates([], 'no_better')]);
|
|
|
|
securityPage($this->admin, $service, 'service')
|
|
->call('updateImages', $key, '')
|
|
->assertDispatched('error');
|
|
|
|
expect($main->fresh()->image)->toBe('n8nio/n8n:1.0')
|
|
->and($runners->fresh()->image)->toBe('n8nio/runners:1.0');
|
|
});
|
|
|
|
it('forbids a read-only member', function () {
|
|
[$service] = securityPageN8nService();
|
|
$key = SecurityCard::key(SecurityStatus::forResource($service)->actions[0]);
|
|
RestartService::shouldRun()->never();
|
|
|
|
securityPage(securityPageMember($this->team), $service, 'service')
|
|
->call('updateImages', $key, '')
|
|
->assertForbidden();
|
|
});
|
|
});
|
|
|
|
describe('update limits', function () {
|
|
it('refuses a merged update on a server that another Coolify instance manages', function () {
|
|
[$service, $main, $runners] = securityPageN8nService();
|
|
$raw = $service->docker_compose_raw;
|
|
$key = SecurityCard::key(SecurityStatus::forResource($service)->actions[0]);
|
|
$this->server->forceFill(['server_metadata' => ['transfer' => ['status' => 'transferred']]])->saveQuietly();
|
|
Server::flushIdentityMap();
|
|
RestartService::shouldRun()->never();
|
|
|
|
securityPage($this->admin, $service, 'service')
|
|
->assertDontSeeHtml('updateImages(')
|
|
->call('updateImages', $key, '')
|
|
->assertDispatched('error');
|
|
|
|
expect($service->fresh()->docker_compose_raw)->toBe($raw)
|
|
->and($main->fresh()->image)->toBe('n8nio/n8n:1.0')
|
|
->and($runners->fresh()->image)->toBe('n8nio/runners:1.0');
|
|
expect(fn () => UpdateResourceImage::make()->handleParts($service->fresh(), [
|
|
['owner' => $main, 'ref' => 'n8nio/n8n:1.1'],
|
|
['owner' => $runners, 'ref' => 'n8nio/runners:1.1'],
|
|
], $this->admin))->toThrow(ImageUpdateRefused::class, 'Another Coolify instance manages this server now.');
|
|
expect($main->fresh()->image)->toBe('n8nio/n8n:1.0');
|
|
});
|
|
|
|
it('refuses a single update on a server that another Coolify instance manages', function () {
|
|
$database = securityPageDatabase();
|
|
securityPageScan($database, 'postgres:15.0', [securityPageFinding('CVE-2025-1094')], [
|
|
'candidates' => securityPageCandidates([securityPageEntry('postgres:15.13', 'line', ['CVE-2025-1094'])]),
|
|
]);
|
|
$this->server->forceFill(['server_metadata' => ['transfer' => ['status' => 'transferred']]])->saveQuietly();
|
|
Server::flushIdentityMap();
|
|
|
|
securityPage($this->admin, $database, 'database')
|
|
->call('updateImage', 'postgres:15.13', '', '')
|
|
->assertDispatched('error');
|
|
|
|
expect(fn () => UpdateResourceImage::run($database->fresh(), 'postgres:15.13', $this->admin))
|
|
->toThrow(ImageUpdateRefused::class, 'Another Coolify instance manages this server now.');
|
|
expect($database->fresh()->image)->toBe('postgres:15.0');
|
|
});
|
|
|
|
it('refuses a part with a ref that is not a better candidate of its own scan', function () {
|
|
[$service, $main, $runners] = securityPageN8nService();
|
|
RestartService::shouldRun()->never();
|
|
|
|
expect(fn () => UpdateResourceImage::make()->handleParts($service, [
|
|
['owner' => $main, 'ref' => 'n8nio/n8n:1.1'],
|
|
['owner' => $runners, 'ref' => 'n8nio/runners:0.9'],
|
|
], $this->admin))->toThrow(ImageUpdateRefused::class);
|
|
|
|
expect($main->fresh()->image)->toBe('n8nio/n8n:1.0')
|
|
->and($runners->fresh()->image)->toBe('n8nio/runners:1.0');
|
|
});
|
|
|
|
it('authorizes a merged update against the team of the service after the session switched teams', function () {
|
|
[$service, $main] = securityPageN8nService();
|
|
$writer = securityPageMember($this->team, 'member', [
|
|
['scope' => 'resource', 'uuid' => $service->uuid, 'levels' => ['read', 'write']],
|
|
]);
|
|
$ownTeam = Team::factory()->create();
|
|
$writer->teams()->attach($ownTeam, ['role' => 'owner']);
|
|
RestartService::shouldRun()->once()->andReturn(new Activity);
|
|
$key = SecurityCard::key(SecurityStatus::forResource($service)->actions[0]);
|
|
|
|
$page = securityPage($writer->fresh(), $service, 'service');
|
|
session(['currentTeam' => $ownTeam]);
|
|
$page->call('updateImages', $key, '')->assertDispatched('success', 'Update started.');
|
|
|
|
expect($main->fresh()->image)->toBe('n8nio/n8n:1.1')
|
|
->and(AuditEvent::query()->where('event', 'ui.image_scan.image_updated')->sole()->team_id)->toBe($this->team->id);
|
|
});
|
|
});
|
|
|
|
describe('sidebar', function () {
|
|
it('shows a red dot for update now and no count', function () {
|
|
$application = securityPageApplication();
|
|
securityPageScan($application, 'nginx:1.27', [securityPageFinding('CVE-2026-0001')], [
|
|
'candidates' => securityPageCandidates([securityPageEntry('nginx:1.28', 'line', ['CVE-2026-0001'])]),
|
|
]);
|
|
ResourceScans::refreshSummaries(ResourceScans::current($application));
|
|
$this->actingAs($this->admin);
|
|
session(['currentTeam' => $this->team]);
|
|
|
|
$this->blade('<x-application.configuration-sidebar :application="$application" current-route="project.application.configuration" />', [
|
|
'application' => $application->fresh(),
|
|
])->assertSee('Security')
|
|
->assertSee('aria-label="Security: update now"', false)
|
|
->assertDontSee('fixable now')
|
|
->assertDontSee('Vulnerabilities');
|
|
});
|
|
|
|
it('shows a yellow dot for a security update of a database', function () {
|
|
$database = securityPageDatabase();
|
|
securityPageScan($database, 'postgres:15.0', [securityPageFinding('CVE-2025-1094', ['kev' => false])], [
|
|
'candidates' => securityPageCandidates([securityPageEntry('postgres:15.13', 'line', ['CVE-2025-1094'])]),
|
|
]);
|
|
ResourceScans::refreshSummaries(ResourceScans::current($database));
|
|
$this->actingAs($this->admin);
|
|
session(['currentTeam' => $this->team]);
|
|
|
|
$this->blade('<x-database.configuration-sidebar :database="$database" current-route="project.database.configuration" />', [
|
|
'database' => $database->fresh(),
|
|
])->assertSee('aria-label="Security: security update"', false);
|
|
});
|
|
|
|
it('shows no dot when no action is needed', function () {
|
|
$application = securityPageApplication();
|
|
securityPageScan($application, 'nginx:1.27', []);
|
|
ResourceScans::refreshSummaries(ResourceScans::current($application));
|
|
$this->actingAs($this->admin);
|
|
session(['currentTeam' => $this->team]);
|
|
|
|
$this->blade('<x-application.configuration-sidebar :application="$application" current-route="project.application.configuration" />', [
|
|
'application' => $application->fresh(),
|
|
])->assertSee('Security')
|
|
->assertDontSee('aria-label="Security:', false);
|
|
});
|
|
});
|
|
|
|
describe('deploy gate popover', function () {
|
|
/**
|
|
* A Docker image app whose deploy of nginx:1.27 was blocked; a newer 1.28 fixes the problem.
|
|
*
|
|
* @return array{0: Application, 1: ApplicationDeploymentQueue}
|
|
*/
|
|
function securityPageBlockedDeploy(): array
|
|
{
|
|
$application = securityPageApplication();
|
|
$deployment = securityPageDeployment($application, [
|
|
'status' => ApplicationDeploymentStatus::FAILED->value,
|
|
'scan_gate_result' => [
|
|
'blocked' => true, 'scan_ran' => true, 'error' => null, 'count' => 1, 'top' => [],
|
|
'headline' => 'Deploy blocked: this image has a security problem that attackers are using.',
|
|
],
|
|
]);
|
|
securityPageScan($application, 'nginx:1.27', [securityPageFinding('CVE-2026-0001')], [
|
|
'deployment_queue_id' => $deployment->id,
|
|
'candidates' => securityPageCandidates([securityPageEntry('nginx:1.28', 'line', ['CVE-2026-0001'])]),
|
|
]);
|
|
|
|
return [$application, $deployment];
|
|
}
|
|
|
|
it('shows the action that fixes the blocked image next to Deploy anyway', function () {
|
|
[$application, $deployment] = securityPageBlockedDeploy();
|
|
$this->actingAs($this->admin);
|
|
session(['currentTeam' => $this->team]);
|
|
|
|
Livewire::test(Show::class, [
|
|
'project_uuid' => $this->environment->project->uuid,
|
|
'environment_uuid' => $this->environment->uuid,
|
|
'application_uuid' => $application->uuid,
|
|
'deployment_uuid' => $deployment->deployment_uuid,
|
|
])->assertSee('Update to 1.28.')
|
|
->assertSee('Open Security')
|
|
->assertSee('Deploy anyway');
|
|
});
|
|
|
|
it('reads the action stored with the gate result', function () {
|
|
[$application, $deployment] = securityPageBlockedDeploy();
|
|
$deployment->update(['scan_gate_result' => [...$deployment->scan_gate_result, 'action' => ['sentence' => 'Upgrade lodash to 4.17.21 or later, then redeploy.', 'button' => false]]]);
|
|
|
|
expect(SecurityCard::gateAction($application, $deployment->fresh()))
|
|
->toBe(['sentence' => 'Upgrade lodash to 4.17.21 or later, then redeploy.', 'button' => false]);
|
|
});
|
|
|
|
it('computes the action of an older result once per version of the scan', function () {
|
|
[$application, $deployment] = securityPageBlockedDeploy();
|
|
|
|
expect(SecurityCard::gateAction($application, $deployment))->toBe(['sentence' => 'Update to 1.28.', 'button' => true]);
|
|
|
|
ImageScan::query()->where('deployment_queue_id', $deployment->id)->toBase()->update(['findings' => json_encode([])]);
|
|
expect(SecurityCard::gateAction($application, $deployment))->toBe(['sentence' => 'Update to 1.28.', 'button' => true]);
|
|
|
|
ImageScan::query()->where('deployment_queue_id', $deployment->id)->toBase()->update(['candidates' => json_encode(securityPageCandidates([], 'no_better'))]);
|
|
expect(SecurityCard::gateAction($application, $deployment))->toBeNull();
|
|
});
|
|
|
|
it('shows the action in the application heading', function () {
|
|
[$application] = securityPageBlockedDeploy();
|
|
$this->actingAs($this->admin);
|
|
session(['currentTeam' => $this->team]);
|
|
|
|
Livewire::test(Heading::class, ['application' => $application])
|
|
->assertSee('Update to 1.28.')
|
|
->assertSee('Open Security');
|
|
});
|
|
});
|
|
|
|
describe('image list', function () {
|
|
/**
|
|
* The n8n service with a third image, postgres, that needs no action.
|
|
*
|
|
* @return array{0: Service, 1: ServiceApplication, 2: ServiceApplication, 3: ServiceApplication}
|
|
*/
|
|
function securityPageThreeImageService(): array
|
|
{
|
|
[$service, $main, $runners] = securityPageN8nService();
|
|
$postgres = ServiceApplication::forceCreate(['uuid' => (string) Str::uuid(), 'service_id' => $service->id, 'name' => 'postgres', 'image' => 'postgres:16-alpine']);
|
|
securityPageScan($postgres, 'postgres:16-alpine', [securityPageFinding('CVE-2026-0003', ['kev' => false, 'severity' => 'medium', 'cvssScore' => 5.0])]);
|
|
|
|
return [$service->fresh(), $main, $runners, $postgres];
|
|
}
|
|
|
|
it('shows one image open without a list frame: sentence, action, attack note, and provenance', function () {
|
|
$database = securityPageDatabase();
|
|
$database->forceFill(['image' => 'postgres:16-alpine'])->save();
|
|
$ids = ['CVE-2026-0001', 'CVE-2026-0002'];
|
|
securityPageScan($database, 'postgres:16-alpine', [
|
|
securityPageFinding($ids[0], ['kev' => false, 'severity' => 'critical']),
|
|
securityPageFinding($ids[1], ['kev' => false, 'severity' => 'critical']),
|
|
securityPageFinding('CVE-2026-0003', ['kev' => false, 'severity' => 'medium', 'fixedIn' => null, 'fixState' => 'not-fixed']),
|
|
], [
|
|
'repo_digest' => 'sha256:3f1a2b4c5d6e'.str_repeat('0', 52),
|
|
'candidates' => securityPageCandidates([[
|
|
...securityPageEntry('postgres:16-alpine', 'same_tag', $ids),
|
|
'fixes' => ['critical' => 2, 'high' => 0, 'medium' => 0],
|
|
'added' => 0,
|
|
'left' => ['critical' => 0, 'high' => 0, 'medium' => 0],
|
|
'scanned_at' => now()->subHours(2)->toIso8601String(),
|
|
]]),
|
|
]);
|
|
|
|
securityPage($this->admin, $database, 'database')
|
|
->assertSee('Security update')
|
|
->assertSeeText('postgres:16-alpine has a newer build that fixes 2 critical problems.')
|
|
->assertSeeInOrder(['has a newer build that fixes 2 critical problems.', 'Pull latest and restart', 'No known attacks on these problems.'])
|
|
->assertSee('No known attacks on these problems.')
|
|
->assertSee('Docker Hub has a newer build of 16-alpine. Coolify scanned it 2 hours ago: it fixes these problems and adds none that attackers use.')
|
|
->assertSee('Pull latest and restart')
|
|
->assertDontSee('3f1a2b4c5d6e')
|
|
->assertDontSee('known issues')
|
|
->assertDontSee('images need an update')
|
|
->assertDontSeeHtml('aria-label="Images"');
|
|
});
|
|
|
|
it('lists the images of a service worst first, opens the worst one, and puts a merged update on its first row', function () {
|
|
[$service, $main, $runners, $postgres] = securityPageThreeImageService();
|
|
$this->actingAs($this->admin);
|
|
|
|
$card = SecurityCard::for($service, SecurityStatus::forResource($service), ResourceScans::owners($service), ResourceScans::current($service), $this->admin);
|
|
|
|
expect($card['header'])->toBe('2 of 3 images need an update')
|
|
->and(array_column($card['rows'], 'name'))->toBe(['n8n', 'n8n runners', 'postgres'])
|
|
->and(array_column($card['rows'], 'open'))->toBe([true, false, false])
|
|
->and($card['rows'][0]['primary']['button']['submit'])->toStartWith('updateImages(')
|
|
->and($card['rows'][0]['mergedWith'])->toBeNull()
|
|
->and($card['rows'][1]['primary'])->toBeNull()
|
|
->and($card['rows'][1]['mergedWith'])->toBe('n8n')
|
|
->and($card['rows'][2]['status'])->toBe('ok')
|
|
->and($card['rows'][2]['primary'])->toBeNull();
|
|
|
|
securityPage($this->admin, $service, 'service')
|
|
->assertSee('2 of 3 images need an update')
|
|
->assertSeeHtml('aria-label="Images"')
|
|
->assertSeeInOrder(['n8nio/n8n:1.0', 'n8nio/runners:1.0', 'postgres:16-alpine'])
|
|
->assertSee('Updated with n8n')
|
|
->assertSeeText('postgres:16-alpine needs no action.');
|
|
});
|
|
|
|
it('shows every image of a Compose app, also the one without a result', function () {
|
|
$application = securityPageApplication([
|
|
'build_pack' => 'dockercompose', 'docker_registry_image_name' => null,
|
|
'docker_compose_raw' => Yaml::dump(['services' => ['web' => ['image' => 'nginx:1.27'], 'cache' => ['image' => 'redis:7']]], 10, 2),
|
|
]);
|
|
$deployment = securityPageDeployment($application);
|
|
securityPageScan($application, 'nginx:1.27', [securityPageFinding('CVE-2026-0001')], [
|
|
'deployment_queue_id' => $deployment->id,
|
|
'candidates' => securityPageCandidates([securityPageEntry('nginx:1.28', 'line', ['CVE-2026-0001'])]),
|
|
]);
|
|
$this->actingAs($this->admin);
|
|
|
|
$card = SecurityCard::for($application, SecurityStatus::forResource($application), ResourceScans::owners($application), ResourceScans::current($application), $this->admin);
|
|
|
|
expect($card['header'])->toBe('1 of 2 images needs an update')
|
|
->and(array_column($card['rows'], 'name'))->toBe(['web', 'cache'])
|
|
->and(array_column($card['rows'], 'status'))->toBe(['update_now', 'not_checked'])
|
|
->and($card['rows'][1]['key'])->toBe('compose-'.md5('cache'));
|
|
|
|
securityPage($this->admin, $application, 'application')
|
|
->assertSee('Check needed')
|
|
->assertSee('1 of 2 images needs an update')
|
|
->assertSeeText('redis:7 has not been checked yet.')
|
|
->assertSee('Scan now');
|
|
});
|
|
|
|
it('names the other problems a newer version adds, from its stored scan, without a count of what it fixes', function () {
|
|
$database = securityPageDatabase();
|
|
securityPageScan($database, 'postgres:15.0', [securityPageFinding('CVE-2025-1094')], [
|
|
'candidates' => securityPageCandidates([[
|
|
...securityPageEntry('postgres:15.13', 'line', ['CVE-2025-1094']),
|
|
'fixes' => ['critical' => 0, 'high' => 1, 'medium' => 0], 'added' => 3,
|
|
'scanned_at' => now()->subHour()->toIso8601String(),
|
|
]]),
|
|
]);
|
|
$this->actingAs($this->admin);
|
|
|
|
$card = SecurityCard::for($database, SecurityStatus::forResource($database), ResourceScans::owners($database), ResourceScans::current($database), $this->admin);
|
|
|
|
expect($card['rows'][0]['provenance'])
|
|
->toBe('15.13 is the newest 15.x release on Docker Hub. Coolify scanned it 1 hour ago: it fixes these problems and adds none that attackers use. It has 3 other problems that attackers are not known to use.');
|
|
});
|
|
|
|
it('filters the findings to the image of a row', function () {
|
|
[$service] = securityPageThreeImageService();
|
|
|
|
$page = securityPage($this->admin, $service, 'service')
|
|
->set('view', 'cve')
|
|
->call('showImageFindings', 'n8nio/runners:1.0')
|
|
->assertSet('images', ['n8nio/runners:1.0']);
|
|
expect(collect($page->viewData('items')->items())->map(fn (array $row): string => $row['finding']->id)->all())->toBe(['CVE-2026-0002']);
|
|
|
|
$page->call('showImageFindings', 'not-an-image:1.0')->assertSet('images', ['n8nio/runners:1.0']);
|
|
});
|
|
|
|
it('picks one image or every image for the findings, and keeps the image when the other filters reset', function () {
|
|
[$service] = securityPageThreeImageService();
|
|
|
|
securityPage($this->admin, $service, 'service')
|
|
->assertSee('All images')
|
|
->call('selectImage', 'n8nio/runners:1.0')
|
|
->assertSet('images', ['n8nio/runners:1.0'])
|
|
->call('toggleSeverity', 'high')
|
|
->call('resetFilters')
|
|
->assertSet('severities', [])
|
|
->assertSet('images', ['n8nio/runners:1.0'])
|
|
->call('selectImage', 'not-an-image:1.0')
|
|
->assertSet('images', ['n8nio/runners:1.0'])
|
|
->call('selectImage', '')
|
|
->assertSet('images', []);
|
|
});
|
|
|
|
it('cleans old stored titles, and shows the fallback title for a stored changelog entry', function () {
|
|
$database = securityPageDatabase();
|
|
securityPageScan($database, 'postgres:15.0', [
|
|
securityPageFinding('CVE-2025-1094', ['title' => 'Issue summary: a crafted string can inject SQL into psql']),
|
|
securityPageFinding('CVE-2026-111497', ['title' => '[1.0.9-10.0.1] - Fix out-of-bounds write in the Brotli decoder [CVE-2026-111497]']),
|
|
], ['candidates' => securityPageCandidates([securityPageEntry('postgres:15.13', 'line', ['CVE-2025-1094', 'CVE-2026-111497'])])]);
|
|
|
|
securityPage($this->admin, $database, 'database')
|
|
->assertSee('A crafted string can inject SQL into psql')
|
|
->assertSee('openssl 3.0.1: high vulnerability')
|
|
->assertDontSee('Issue summary:')
|
|
->assertDontSee('[1.0.9-10.0.1]')
|
|
->assertDontSee('Brotli decoder');
|
|
});
|
|
|
|
it('shows no header line for a service whose images need no update', function () {
|
|
[$service, $main, $runners] = securityPageN8nService();
|
|
foreach ([$main, $runners] as $owner) {
|
|
ResourceScans::currentFor($owner)->first()->update(['candidates' => securityPageCandidates([], 'no_better')]);
|
|
}
|
|
|
|
securityPage($this->admin, $service, 'service')
|
|
->assertSee('No fix yet')
|
|
->assertSeeText('n8nio/n8n:1.0 has a security problem that attackers are using. The fix is not in the newest n8nio/n8n 1.x image yet. Coolify checks every day.')
|
|
->assertSee('Until then, limit who can reach this service, or ignore the problem if the affected part is not used.')
|
|
->assertSeeInOrder(['No fix yet', 'Not fixed yet'])
|
|
->assertDontSee('images need an update')
|
|
->assertDontSee('Attackers are using this.');
|
|
});
|
|
});
|