Files
coolify/tests/Feature/ImageScan/SecurityActionsRebuildTest.php
033f0c84da v4.7 (#12478)
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>
2026-11-06 14:25:02 +01:00

166 lines
7.4 KiB
PHP

<?php
use App\Actions\ImageScan\RefreshCandidates;
use App\Actions\ImageScan\ScanImage;
use App\Enums\ApplicationDeploymentStatus;
use App\Enums\ImageScanStatus;
use App\Enums\SecurityActionType;
use App\Models\Application;
use App\Models\ApplicationDeploymentQueue;
use App\Models\Environment;
use App\Models\ImageScan;
use App\Models\InstanceSettings;
use App\Models\PrivateKey;
use App\Models\Project;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\Team;
use App\Services\ImageScan\RelevanceRules;
use App\Services\ImageScan\SecurityActions;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Symfony\Component\Yaml\Yaml;
uses(RefreshDatabase::class);
beforeEach(function () {
Server::flushIdentityMap();
Storage::fake('ssh-keys');
InstanceSettings::forceCreate(['id' => 0, 'is_image_scanning_allowed' => true]);
$team = Team::factory()->create();
$this->server = Server::factory()->create([
'team_id' => $team->id,
'private_key_id' => PrivateKey::factory()->create(['team_id' => $team->id])->id,
]);
$destination = StandaloneDocker::query()->where('server_id', $this->server->id)->firstOrFail();
$project = Project::factory()->create(['team_id' => $team->id]);
$environment = Environment::factory()->create(['project_id' => $project->id]);
$this->newApplication = fn (string $buildPack = 'dockerfile'): Application => Application::factory()->create([
'environment_id' => $environment->id,
'destination_id' => $destination->id,
'destination_type' => $destination->getMorphClass(),
'build_pack' => $buildPack,
]);
$this->application = ($this->newApplication)();
});
/**
* @param list<string> $ids
*/
function rebuildLoopScan(array $ids, bool $forceRebuild, int $pullRequestId = 0, ?Application $application = null): ImageScan
{
$application ??= test()->application;
$deployment = ApplicationDeploymentQueue::create([
'deployment_uuid' => Str::random(12),
'application_id' => $application->id,
'server_id' => test()->server->id,
'status' => ApplicationDeploymentStatus::FINISHED->value,
'commit' => 'abc123',
'pull_request_id' => $pullRequestId,
'force_rebuild' => $forceRebuild,
]);
return ImageScan::factory()->create([
'server_id' => test()->server->id,
'resource_type' => $application->getMorphClass(),
'resource_id' => $application->id,
'deployment_queue_id' => $deployment->id,
'image_ref' => $application->uuid.':abc123',
'builds_image' => true,
'findings' => array_map(fn (string $id): array => [
'id' => $id, 'aliases' => [], 'severity' => 'critical', 'package' => 'openssl', 'version' => '3.0.13',
'type' => 'apk', 'artifactId' => 'a-'.$id, 'fixedIn' => '3.0.15', 'fixState' => 'fixed', 'kev' => false,
], $ids),
]);
}
function rebuildLoopAction(ImageScan $scan, string $id): ?SecurityActionType
{
return SecurityActions::forScan($scan->fresh(), RelevanceRules::fromIgnores(collect()))['byFinding'][$id]?->type ?? null;
}
it('switches to an upgrade in the Dockerfile when a finding survives a rebuild without cache', function () {
rebuildLoopScan(['CVE-1'], forceRebuild: false);
$scan = rebuildLoopScan(['CVE-1', 'CVE-2'], forceRebuild: true);
$result = SecurityActions::forScan($scan->fresh(), RelevanceRules::fromIgnores(collect()));
expect($result['byFinding']['CVE-1']->type)->toBe(SecurityActionType::UpgradeInDockerfile)
->and($result['byFinding']['CVE-1']->sentence)->toBe('Upgrade openssl to 3.0.15 or later in your Dockerfile.')
->and($result['byFinding']['CVE-1']->button)->toBeFalse()
->and($result['byFinding']['CVE-2']->type)->toBe(SecurityActionType::Rebuild);
});
it('keeps the upgrade in the Dockerfile in later deploys with cache after a rebuild without cache did not fix it', function () {
rebuildLoopScan(['CVE-1'], forceRebuild: false);
rebuildLoopScan(['CVE-1'], forceRebuild: true);
rebuildLoopScan(['CVE-1', 'CVE-3'], forceRebuild: false);
$scan = rebuildLoopScan(['CVE-1', 'CVE-3', 'CVE-4'], forceRebuild: false);
expect(rebuildLoopAction($scan, 'CVE-1'))->toBe(SecurityActionType::UpgradeInDockerfile)
->and(rebuildLoopAction($scan, 'CVE-3'))->toBe(SecurityActionType::Rebuild)
->and(rebuildLoopAction($scan, 'CVE-4'))->toBe(SecurityActionType::Rebuild);
});
it('uses the latest rebuild without cache of the app', function () {
rebuildLoopScan(['CVE-1'], forceRebuild: false);
rebuildLoopScan(['CVE-1'], forceRebuild: true);
rebuildLoopScan(['CVE-2'], forceRebuild: false);
// The newest rebuild without cache removed CVE-1 and kept CVE-2.
rebuildLoopScan(['CVE-2'], forceRebuild: true);
$scan = rebuildLoopScan(['CVE-1', 'CVE-2'], forceRebuild: false);
expect(rebuildLoopAction($scan, 'CVE-1'))->toBe(SecurityActionType::Rebuild)
->and(rebuildLoopAction($scan, 'CVE-2'))->toBe(SecurityActionType::UpgradeInDockerfile);
});
it('keeps the rebuild when the deployment did not rebuild without cache', function () {
rebuildLoopScan(['CVE-1'], forceRebuild: false);
$scan = rebuildLoopScan(['CVE-1'], forceRebuild: false);
expect(rebuildLoopAction($scan, 'CVE-1'))->toBe(SecurityActionType::Rebuild);
});
it('keeps the rebuild after the first rebuild without cache of an app', function () {
$scan = rebuildLoopScan(['CVE-1'], forceRebuild: true);
expect(rebuildLoopAction($scan, 'CVE-1'))->toBe(SecurityActionType::Rebuild);
});
it('compares with the previous scan that is not a preview and completed', function () {
rebuildLoopScan(['CVE-9'], forceRebuild: false);
rebuildLoopScan(['CVE-1'], forceRebuild: false, pullRequestId: 4);
rebuildLoopScan(['CVE-1'], forceRebuild: false)->update(['status' => ImageScanStatus::Failed]);
$scan = rebuildLoopScan(['CVE-1'], forceRebuild: true);
expect(rebuildLoopAction($scan, 'CVE-1'))->toBe(SecurityActionType::Rebuild);
});
it('does not compare with the scans of another application', function () {
rebuildLoopScan(['CVE-1'], forceRebuild: false, application: ($this->newApplication)());
$scan = rebuildLoopScan(['CVE-1'], forceRebuild: true);
expect(rebuildLoopAction($scan, 'CVE-1'))->toBe(SecurityActionType::Rebuild);
});
it('looks for newer versions of an image a compose app pulls, but not of one it builds', function () {
$application = ($this->newApplication)('dockercompose');
$application->forceFill(['docker_compose' => Yaml::dump(['services' => [
'web' => ['build' => '.', 'image' => $application->uuid.'_web:abc123'],
'db' => ['image' => 'postgres:16'],
]], 10)])->save();
$scan = fn (string $imageRef): ImageScan => ImageScan::factory()->create([
'server_id' => $this->server->id,
'resource_type' => $application->getMorphClass(),
'resource_id' => $application->id,
'image_ref' => $imageRef,
'builds_image' => ScanImage::buildsImage($application, $imageRef),
'findings' => [['id' => 'CVE-1', 'severity' => 'critical', 'package' => 'openssl', 'version' => '3.0.13', 'type' => 'deb', 'fixedIn' => '3.0.15', 'fixState' => 'fixed']],
]);
expect(RefreshCandidates::needsCandidates($scan('postgres:16')))->toBeTrue()
->and(RefreshCandidates::needsCandidates($scan($application->uuid.'_web:abc123')))->toBeFalse();
});