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 $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(); });