diff --git a/app/Actions/ImageScan/SetServerImageScanning.php b/app/Actions/ImageScan/SetServerImageScanning.php new file mode 100644 index 0000000000..351da4f1a8 --- /dev/null +++ b/app/Actions/ImageScan/SetServerImageScanning.php @@ -0,0 +1,55 @@ +settings->update(['is_image_scan_enabled' => false]); + PrepareImageScannerJob::forget($server); + + return; + } + + self::ensureManagedHere($server); + if (! InstanceSettings::get()->is_image_scanning_allowed) { + throw new ImageScanningRefused('Image scanning is turned off for this Coolify instance.'); + } + + $server->settings->update(['is_image_scan_enabled' => true]); + self::prepareScanner($server); + } + + /** + * Gets the scanner on the server again, for a server that scans images. + */ + public static function prepareScanner(Server $server): void + { + Cache::put(PrepareImageScannerJob::stateCacheKey($server), PrepareImageScannerJob::STATE_PULLING, now()->addHour()); + PrepareImageScannerJob::dispatch($server); + } + + public static function ensureManagedHere(Server $server): void + { + if ($server->isTransferredAway()) { + throw new ImageScanningRefused('This server is managed by another Coolify instance.'); + } + } +} diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index d9080ea1df..a7b06ff686 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -80,6 +80,7 @@ class ApplicationsController extends Controller 'is_consistent_container_name_enabled', 'custom_internal_name', 'custom_container_name_prefix', + 'is_image_scan_enabled', ]; private const BOOLEAN_APPLICATION_SETTING_FIELDS = [ @@ -104,6 +105,7 @@ class ApplicationsController extends Controller 'is_log_drain_enabled', 'is_gpu_enabled', 'is_consistent_container_name_enabled', + 'is_image_scan_enabled', ]; protected function findTaggableResource(string $uuid, int|string $teamId): mixed @@ -498,6 +500,7 @@ class ApplicationsController extends Controller 'gpu_device_ids' => ['type' => 'string', 'nullable' => true, 'description' => 'Comma-separated GPU device IDs.'], 'gpu_options' => ['type' => 'string', 'nullable' => true, 'description' => 'Additional GPU options.'], 'is_consistent_container_name_enabled' => ['type' => 'boolean', 'description' => 'Use a consistent container name across deployments.'], + 'is_image_scan_enabled' => ['type' => 'boolean', 'description' => 'Scan the images of the application for vulnerabilities. Needs image scanning on the server. Default true.'], 'custom_internal_name' => ['type' => 'string', 'nullable' => true, 'description' => 'Custom internal container name. Turns is_consistent_container_name_enabled on when that field is not sent; sending it as false together with a name returns 422.'], 'custom_container_name_prefix' => ['type' => 'string', 'nullable' => true, 'description' => 'Prefix for generated container names (prefix-20260908T141530). Slugified and unique across the instance.'], 'preview_url_template' => ['type' => 'string', 'description' => 'Preview URL template.'], @@ -711,6 +714,7 @@ class ApplicationsController extends Controller 'gpu_device_ids' => ['type' => 'string', 'nullable' => true, 'description' => 'Comma-separated GPU device IDs.'], 'gpu_options' => ['type' => 'string', 'nullable' => true, 'description' => 'Additional GPU options.'], 'is_consistent_container_name_enabled' => ['type' => 'boolean', 'description' => 'Use a consistent container name across deployments.'], + 'is_image_scan_enabled' => ['type' => 'boolean', 'description' => 'Scan the images of the application for vulnerabilities. Needs image scanning on the server. Default true.'], 'custom_internal_name' => ['type' => 'string', 'nullable' => true, 'description' => 'Custom internal container name. Turns is_consistent_container_name_enabled on when that field is not sent; sending it as false together with a name returns 422.'], 'custom_container_name_prefix' => ['type' => 'string', 'nullable' => true, 'description' => 'Prefix for generated container names (prefix-20260908T141530). Slugified and unique across the instance.'], 'preview_url_template' => ['type' => 'string', 'description' => 'Preview URL template.'], @@ -924,6 +928,7 @@ class ApplicationsController extends Controller 'gpu_device_ids' => ['type' => 'string', 'nullable' => true, 'description' => 'Comma-separated GPU device IDs.'], 'gpu_options' => ['type' => 'string', 'nullable' => true, 'description' => 'Additional GPU options.'], 'is_consistent_container_name_enabled' => ['type' => 'boolean', 'description' => 'Use a consistent container name across deployments.'], + 'is_image_scan_enabled' => ['type' => 'boolean', 'description' => 'Scan the images of the application for vulnerabilities. Needs image scanning on the server. Default true.'], 'custom_internal_name' => ['type' => 'string', 'nullable' => true, 'description' => 'Custom internal container name. Turns is_consistent_container_name_enabled on when that field is not sent; sending it as false together with a name returns 422.'], 'custom_container_name_prefix' => ['type' => 'string', 'nullable' => true, 'description' => 'Prefix for generated container names (prefix-20260908T141530). Slugified and unique across the instance.'], 'preview_url_template' => ['type' => 'string', 'description' => 'Preview URL template.'], @@ -1137,6 +1142,7 @@ class ApplicationsController extends Controller 'gpu_device_ids' => ['type' => 'string', 'nullable' => true, 'description' => 'Comma-separated GPU device IDs.'], 'gpu_options' => ['type' => 'string', 'nullable' => true, 'description' => 'Additional GPU options.'], 'is_consistent_container_name_enabled' => ['type' => 'boolean', 'description' => 'Use a consistent container name across deployments.'], + 'is_image_scan_enabled' => ['type' => 'boolean', 'description' => 'Scan the images of the application for vulnerabilities. Needs image scanning on the server. Default true.'], 'custom_internal_name' => ['type' => 'string', 'nullable' => true, 'description' => 'Custom internal container name. Turns is_consistent_container_name_enabled on when that field is not sent; sending it as false together with a name returns 422.'], 'custom_container_name_prefix' => ['type' => 'string', 'nullable' => true, 'description' => 'Prefix for generated container names (prefix-20260908T141530). Slugified and unique across the instance.'], 'preview_url_template' => ['type' => 'string', 'description' => 'Preview URL template.'], @@ -1320,6 +1326,7 @@ class ApplicationsController extends Controller 'gpu_device_ids' => ['type' => 'string', 'nullable' => true, 'description' => 'Comma-separated GPU device IDs.'], 'gpu_options' => ['type' => 'string', 'nullable' => true, 'description' => 'Additional GPU options.'], 'is_consistent_container_name_enabled' => ['type' => 'boolean', 'description' => 'Use a consistent container name across deployments.'], + 'is_image_scan_enabled' => ['type' => 'boolean', 'description' => 'Scan the images of the application for vulnerabilities. Needs image scanning on the server. Default true.'], 'custom_internal_name' => ['type' => 'string', 'nullable' => true, 'description' => 'Custom internal container name. Turns is_consistent_container_name_enabled on when that field is not sent; sending it as false together with a name returns 422.'], 'custom_container_name_prefix' => ['type' => 'string', 'nullable' => true, 'description' => 'Prefix for generated container names (prefix-20260908T141530). Slugified and unique across the instance.'], 'preview_url_template' => ['type' => 'string', 'description' => 'Preview URL template.'], @@ -1499,6 +1506,7 @@ class ApplicationsController extends Controller 'gpu_device_ids' => ['type' => 'string', 'nullable' => true, 'description' => 'Comma-separated GPU device IDs.'], 'gpu_options' => ['type' => 'string', 'nullable' => true, 'description' => 'Additional GPU options.'], 'is_consistent_container_name_enabled' => ['type' => 'boolean', 'description' => 'Use a consistent container name across deployments.'], + 'is_image_scan_enabled' => ['type' => 'boolean', 'description' => 'Scan the images of the application for vulnerabilities. Needs image scanning on the server. Default true.'], 'custom_internal_name' => ['type' => 'string', 'nullable' => true, 'description' => 'Custom internal container name. Turns is_consistent_container_name_enabled on when that field is not sent; sending it as false together with a name returns 422.'], 'custom_container_name_prefix' => ['type' => 'string', 'nullable' => true, 'description' => 'Prefix for generated container names (prefix-20260908T141530). Slugified and unique across the instance.'], 'preview_url_template' => ['type' => 'string', 'description' => 'Preview URL template.'], @@ -3536,6 +3544,7 @@ class ApplicationsController extends Controller 'gpu_device_ids' => ['type' => 'string', 'nullable' => true, 'description' => 'Comma-separated GPU device IDs.'], 'gpu_options' => ['type' => 'string', 'nullable' => true, 'description' => 'Additional GPU options.'], 'is_consistent_container_name_enabled' => ['type' => 'boolean', 'description' => 'Use a consistent container name across deployments.'], + 'is_image_scan_enabled' => ['type' => 'boolean', 'description' => 'Scan the images of the application for vulnerabilities. Needs image scanning on the server. Default true.'], 'custom_internal_name' => ['type' => 'string', 'nullable' => true, 'description' => 'Custom internal container name. Turns is_consistent_container_name_enabled on when that field is not sent; sending it as false together with a name returns 422.'], 'custom_container_name_prefix' => ['type' => 'string', 'nullable' => true, 'description' => 'Prefix for generated container names (prefix-20260908T141530). Slugified and unique across the instance.'], 'preview_url_template' => ['type' => 'string', 'description' => 'Preview URL template.'], diff --git a/app/Http/Controllers/Api/OpenApi.php b/app/Http/Controllers/Api/OpenApi.php index 4af7e503bd..aa75fe540e 100644 --- a/app/Http/Controllers/Api/OpenApi.php +++ b/app/Http/Controllers/Api/OpenApi.php @@ -132,6 +132,79 @@ use OpenApi\Attributes as OA; new OA\Property(property: 'deleted_at', type: 'string', format: 'date-time', nullable: true), ], ), + new OA\Schema( + schema: 'SecurityStatus', + description: 'Security status of a resource from image scanning.', + properties: [ + new OA\Property(property: 'status', type: 'string', enum: ['update_now', 'update_available', 'no_fix', 'check_needed', 'ok', 'not_checked']), + new OA\Property(property: 'label', type: 'string'), + new OA\Property(property: 'action', type: 'string', nullable: true, description: 'What to do, as the UI shows it.'), + new OA\Property(property: 'check_reason', type: 'string', nullable: true), + new OA\Property(property: 'problem_count', type: 'integer'), + new OA\Property(property: 'exploited_count', type: 'integer'), + new OA\Property(property: 'any_exploited', type: 'boolean'), + new OA\Property(property: 'critical_count', type: 'integer'), + new OA\Property(property: 'high_count', type: 'integer'), + new OA\Property(property: 'medium_count', type: 'integer'), + new OA\Property(property: 'eol', type: 'object', nullable: true, description: 'The end-of-life OS of the image.'), + new OA\Property(property: 'checked_at', type: 'string', format: 'date-time', nullable: true), + new OA\Property(property: 'actions', type: 'array', items: new OA\Items(properties: [ + new OA\Property(property: 'type', type: 'string'), + new OA\Property(property: 'sentence', type: 'string'), + new OA\Property(property: 'target', type: 'string', nullable: true), + new OA\Property(property: 'copy', type: 'string', nullable: true), + new OA\Property(property: 'image', type: 'string', nullable: true), + new OA\Property(property: 'ref', type: 'string', nullable: true), + ], type: 'object')), + ], + type: 'object', + ), + new OA\Schema( + schema: 'ResourceVulnerabilities', + description: 'Image scan results of a resource.', + properties: [ + new OA\Property(property: 'uuid', type: 'string'), + new OA\Property(property: 'type', type: 'string', enum: ['application', 'service', 'database']), + new OA\Property(property: 'off_reason', type: 'string', nullable: true, enum: ['instance', 'server', 'application'], description: 'Why the resource is not scanned. Null when it is scanned.'), + new OA\Property(property: 'security', ref: '#/components/schemas/SecurityStatus', nullable: true), + new OA\Property(property: 'images', type: 'array', items: new OA\Items(properties: [ + new OA\Property(property: 'scan_uuid', type: 'string'), + new OA\Property(property: 'owner_uuid', type: 'string', description: 'The application, database, or service application or database that runs the image.'), + new OA\Property(property: 'image_ref', type: 'string'), + new OA\Property(property: 'scan_status', type: 'string', enum: ['queued', 'running', 'completed', 'failed']), + new OA\Property(property: 'error_code', type: 'string', nullable: true), + new OA\Property(property: 'scanned_at', type: 'string', format: 'date-time', nullable: true), + new OA\Property(property: 'truncated', type: 'boolean'), + new OA\Property(property: 'security_status', type: 'string'), + ], type: 'object')), + new OA\Property(property: 'findings', type: 'array', items: new OA\Items(properties: [ + new OA\Property(property: 'id', type: 'string'), + new OA\Property(property: 'display_id', type: 'string'), + new OA\Property(property: 'aliases', type: 'array', items: new OA\Items(type: 'string')), + new OA\Property(property: 'title', type: 'string'), + new OA\Property(property: 'severity', type: 'string', enum: ['critical', 'high', 'medium', 'low', 'unknown']), + new OA\Property(property: 'package', type: 'string'), + new OA\Property(property: 'version', type: 'string'), + new OA\Property(property: 'package_type', type: 'string'), + new OA\Property(property: 'fixed_in', type: 'string', nullable: true), + new OA\Property(property: 'state', type: 'string', enum: ['fixed', 'no_fix', 'ignored'], description: 'fixed: a fix is available upstream.'), + new OA\Property(property: 'ignore', type: 'object', nullable: true), + new OA\Property(property: 'kev', type: 'boolean', description: 'Listed in the CISA Known Exploited Vulnerabilities catalog.'), + new OA\Property(property: 'epss', type: 'number', nullable: true), + new OA\Property(property: 'risk', type: 'number', nullable: true), + new OA\Property(property: 'cvss_score', type: 'number', nullable: true), + new OA\Property(property: 'advisory_url', type: 'string', nullable: true), + new OA\Property(property: 'image_ref', type: 'string'), + new OA\Property(property: 'owner_uuid', type: 'string'), + ], type: 'object')), + new OA\Property(property: 'pagination', properties: [ + new OA\Property(property: 'page', type: 'integer'), + new OA\Property(property: 'per_page', type: 'integer'), + new OA\Property(property: 'total', type: 'integer'), + ], type: 'object'), + ], + type: 'object', + ), ], responses: [ new OA\Response( diff --git a/app/Http/Controllers/Api/ServerImageScanningController.php b/app/Http/Controllers/Api/ServerImageScanningController.php new file mode 100644 index 0000000000..40b7fbaca0 --- /dev/null +++ b/app/Http/Controllers/Api/ServerImageScanningController.php @@ -0,0 +1,176 @@ + (bool) InstanceSettings::get()->is_image_scanning_allowed, + 'is_image_scan_enabled' => (bool) $server->settings->is_image_scan_enabled, + 'scanner_state' => is_string($state) ? $state : null, + 'scanner_failed_step' => is_string($failedStep) ? $failedStep : null, + 'scanner_built_at' => is_string($builtAt) ? CarbonImmutable::parse($builtAt)->toIso8601String() : null, + 'scanner_disk_used_bytes' => is_int($diskUsed) ? $diskUsed : null, + ]; + } + + private function findServer(Request $request): ?Server + { + $teamId = getTeamIdFromToken(); + + return is_null($teamId) ? null : Server::whereTeamId($teamId)->whereUuid($request->route('uuid'))->first(); + } + + #[OA\Get( + summary: 'Get image scanning settings', + description: 'Get the image vulnerability scanning settings of a server owned by the authenticated team, and the state of the scanner on the server. Needs a team admin or owner.', + path: '/servers/{uuid}/image-scanning', + operationId: 'get-server-image-scanning', + security: [['bearerAuth' => []]], + tags: ['Servers'], + parameters: [ + new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Server UUID', schema: new OA\Schema(type: 'string')), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Image scanning settings.', + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'is_image_scanning_allowed', type: 'boolean', readOnly: true, description: 'Whether the Coolify instance allows image scanning.'), + new OA\Property(property: 'is_image_scan_enabled', type: 'boolean', description: 'Whether the server scans the images of its resources.'), + new OA\Property(property: 'scanner_state', type: 'string', nullable: true, enum: ['pulling', 'updating_database', 'ready', 'failed'], description: 'State of the scanner on the server. Null when unknown.'), + new OA\Property(property: 'scanner_failed_step', type: 'string', nullable: true, description: 'The step that failed when scanner_state is failed.'), + new OA\Property(property: 'scanner_built_at', type: 'string', format: 'date-time', nullable: true, description: 'Time of the vulnerability database on the server.'), + new OA\Property(property: 'scanner_disk_used_bytes', type: 'integer', nullable: true, description: 'Disk space that the scanner uses on the server.'), + ], + type: 'object', + ), + ), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 400, ref: '#/components/responses/400'), + new OA\Response(response: 403, description: 'Needs a team admin or owner.'), + new OA\Response(response: 404, ref: '#/components/responses/404'), + ], + )] + public function show(Request $request): JsonResponse + { + if (is_null(getTeamIdFromToken())) { + return invalidTokenResponse(); + } + + $server = $this->findServer($request); + if (! $server) { + return response()->json(['message' => 'Server not found.'], 404); + } + + $this->authorize('viewSecurity', $server); + + return response()->json($this->transform($server)); + } + + #[OA\Patch( + summary: 'Update image scanning settings', + description: 'Turn image vulnerability scanning of a server owned by the authenticated team on or off. Turning it on gets the scanner on the server in the background.', + path: '/servers/{uuid}/image-scanning', + operationId: 'update-server-image-scanning', + security: [['bearerAuth' => []]], + tags: ['Servers'], + parameters: [ + new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Server UUID', schema: new OA\Schema(type: 'string')), + ], + requestBody: new OA\RequestBody( + required: true, + content: new OA\JsonContent( + required: ['is_image_scan_enabled'], + properties: [ + new OA\Property(property: 'is_image_scan_enabled', type: 'boolean'), + ], + type: 'object', + ), + ), + responses: [ + new OA\Response(response: 200, description: 'Updated image scanning settings.'), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 400, ref: '#/components/responses/400'), + new OA\Response(response: 403, description: 'Needs a team admin or owner.'), + new OA\Response(response: 404, ref: '#/components/responses/404'), + new OA\Response(response: 409, description: 'The instance does not allow image scanning, or another Coolify instance manages the server.'), + new OA\Response(response: 422, ref: '#/components/responses/422'), + ], + )] + public function update(Request $request): JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $return = validateIncomingRequest($request); + if ($return instanceof JsonResponse) { + return $return; + } + + $server = $this->findServer($request); + if (! $server) { + return response()->json(['message' => 'Server not found.'], 404); + } + + $this->authorize('update', $server); + + $validator = customApiValidator($request->all(), [ + 'is_image_scan_enabled' => 'required|boolean', + ]); + $extraFields = array_diff(array_keys($request->all()), self::ALLOWED_FIELDS); + if ($validator->fails() || ! empty($extraFields)) { + $errors = $validator->errors(); + foreach ($extraFields as $field) { + $errors->add($field, 'This field is not allowed.'); + } + + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => $errors, + ], 422); + } + + $enabled = $request->boolean('is_image_scan_enabled'); + try { + SetServerImageScanning::run($server, $enabled); + } catch (ImageScanningRefused $e) { + return response()->json(['message' => $e->getMessage()], 409); + } + + auditLog($enabled ? 'api.server.image_scanning_enabled' : 'api.server.image_scanning_disabled', [ + 'team_id' => $teamId, + 'server_uuid' => $server->uuid, + 'server_name' => $server->name, + ]); + + return response()->json($this->transform($server->refresh())); + } +} diff --git a/app/Http/Controllers/Api/TeamController.php b/app/Http/Controllers/Api/TeamController.php index 98274e2d0d..ece5b4c2f8 100644 --- a/app/Http/Controllers/Api/TeamController.php +++ b/app/Http/Controllers/Api/TeamController.php @@ -238,12 +238,16 @@ class TeamController extends Controller mediaType: 'application/json', schema: new OA\Schema( type: 'object', - required: ['is_build_server_fallback_enabled'], + minProperties: 1, properties: [ 'is_build_server_fallback_enabled' => [ 'type' => 'boolean', 'description' => 'Whether deployments can fall back to the deployment server when no usable dedicated build server is available.', ], + 'scan_images_on_new_servers' => [ + 'type' => 'boolean', + 'description' => 'Whether image scanning is turned on for servers added to the team from now on.', + ], ], ), ), @@ -269,9 +273,10 @@ class TeamController extends Controller $this->authorize('update', $team); - $allowedFields = ['is_build_server_fallback_enabled']; + $allowedFields = ['is_build_server_fallback_enabled', 'scan_images_on_new_servers']; $validator = customApiValidator($request->all(), [ - 'is_build_server_fallback_enabled' => ['required', 'boolean'], + 'is_build_server_fallback_enabled' => ['required_without:scan_images_on_new_servers', 'boolean'], + 'scan_images_on_new_servers' => ['required_without:is_build_server_fallback_enabled', 'boolean'], ]); $extraFields = array_diff(array_keys($request->all()), $allowedFields); if ($validator->fails() || ! empty($extraFields)) { diff --git a/app/Http/Controllers/Api/VulnerabilitiesController.php b/app/Http/Controllers/Api/VulnerabilitiesController.php new file mode 100644 index 0000000000..0a5e06238b --- /dev/null +++ b/app/Http/Controllers/Api/VulnerabilitiesController.php @@ -0,0 +1,398 @@ + 4, 'high' => 3, 'medium' => 2, 'low' => 1, 'unknown' => 0]; + + private const FINDING_STATES = ['fixed', 'no_fix', 'ignored']; + + #[OA\Get( + summary: 'List vulnerabilities', + description: 'List the applications, services, and databases of the authenticated team with their stored security status from image scanning.', + path: '/vulnerabilities', + operationId: 'list-vulnerabilities', + security: [['bearerAuth' => []]], + tags: ['Vulnerabilities'], + parameters: [ + new OA\Parameter(name: 'status', in: 'query', required: false, description: 'Comma-separated statuses to return.', schema: new OA\Schema(type: 'string', example: 'update_now,update_available')), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Resources with their security status, worst first.', + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'is_image_scanning_allowed', type: 'boolean'), + new OA\Property(property: 'resources', type: 'array', items: new OA\Items( + properties: [ + new OA\Property(property: 'uuid', type: 'string'), + new OA\Property(property: 'name', type: 'string'), + new OA\Property(property: 'type', type: 'string', enum: ['application', 'service', 'database']), + new OA\Property(property: 'project_uuid', type: 'string', nullable: true), + new OA\Property(property: 'environment_uuid', type: 'string', nullable: true), + new OA\Property(property: 'server_uuid', type: 'string', nullable: true), + new OA\Property(property: 'images', properties: [ + new OA\Property(property: 'update', type: 'integer'), + new OA\Property(property: 'total', type: 'integer'), + ], type: 'object'), + new OA\Property(property: 'security', ref: '#/components/schemas/SecurityStatus'), + ], + type: 'object', + )), + ], + type: 'object', + ), + ), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 400, ref: '#/components/responses/400'), + ], + )] + public function index(Request $request): JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $statuses = $this->listParameter($request, 'status', TeamVulnerabilities::STATUS_ORDER); + if ($statuses instanceof JsonResponse) { + return $statuses; + } + + $rank = array_flip(TeamVulnerabilities::STATUS_ORDER); + $resources = TeamVulnerabilities::load((int) $teamId)->entries + ->filter(fn (array $entry): bool => $statuses === [] || in_array($entry['security']->status->value, $statuses, true)) + ->sortBy(fn (array $entry): array => [$rank[$entry['security']->status->value] ?? PHP_INT_MAX, (string) $entry['resource']->getAttribute('name')]) + ->map(fn (array $entry): array => [ + 'uuid' => (string) $entry['resource']->getAttribute('uuid'), + 'name' => (string) $entry['resource']->getAttribute('name'), + 'type' => $this->resourceType($entry['resource']), + 'project_uuid' => $entry['project']?->uuid, + 'environment_uuid' => $entry['environment']?->uuid, + 'server_uuid' => $entry['server']?->uuid, + 'images' => $entry['images'], + 'security' => $this->security($entry['security']), + ]) + ->values(); + + return response()->json([ + 'is_image_scanning_allowed' => (bool) instanceSettings()->is_image_scanning_allowed, + 'resources' => $resources, + ]); + } + + #[OA\Get( + summary: 'Get application vulnerabilities', + description: 'Get the security status, the scanned images, and the vulnerabilities of an application.', + path: '/applications/{uuid}/vulnerabilities', + operationId: 'get-application-vulnerabilities', + security: [['bearerAuth' => []]], + tags: ['Vulnerabilities'], + parameters: [ + new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Application UUID', schema: new OA\Schema(type: 'string')), + new OA\Parameter(ref: '#/components/parameters/VulnerabilityState'), + new OA\Parameter(ref: '#/components/parameters/VulnerabilitySeverity'), + new OA\Parameter(ref: '#/components/parameters/VulnerabilityImage'), + new OA\Parameter(ref: '#/components/parameters/VulnerabilitySearch'), + new OA\Parameter(ref: '#/components/parameters/VulnerabilityPage'), + new OA\Parameter(ref: '#/components/parameters/VulnerabilityPerPage'), + ], + responses: [ + new OA\Response(response: 200, description: 'Scan results.', content: new OA\JsonContent(ref: '#/components/schemas/ResourceVulnerabilities')), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 400, ref: '#/components/responses/400'), + new OA\Response(response: 404, ref: '#/components/responses/404'), + new OA\Response(response: 422, ref: '#/components/responses/422'), + ], + )] + public function application(Request $request): JsonResponse + { + return $this->show($request, 'application'); + } + + #[OA\Get( + summary: 'Get service vulnerabilities', + description: 'Get the security status, the scanned images, and the vulnerabilities of the applications and databases of a service.', + path: '/services/{uuid}/vulnerabilities', + operationId: 'get-service-vulnerabilities', + security: [['bearerAuth' => []]], + tags: ['Vulnerabilities'], + parameters: [ + new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Service UUID', schema: new OA\Schema(type: 'string')), + new OA\Parameter(ref: '#/components/parameters/VulnerabilityState'), + new OA\Parameter(ref: '#/components/parameters/VulnerabilitySeverity'), + new OA\Parameter(ref: '#/components/parameters/VulnerabilityImage'), + new OA\Parameter(ref: '#/components/parameters/VulnerabilitySearch'), + new OA\Parameter(ref: '#/components/parameters/VulnerabilityPage'), + new OA\Parameter(ref: '#/components/parameters/VulnerabilityPerPage'), + ], + responses: [ + new OA\Response(response: 200, description: 'Scan results.', content: new OA\JsonContent(ref: '#/components/schemas/ResourceVulnerabilities')), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 400, ref: '#/components/responses/400'), + new OA\Response(response: 404, ref: '#/components/responses/404'), + new OA\Response(response: 422, ref: '#/components/responses/422'), + ], + )] + public function service(Request $request): JsonResponse + { + return $this->show($request, 'service'); + } + + #[OA\Get( + summary: 'Get database vulnerabilities', + description: 'Get the security status, the scanned image, and the vulnerabilities of a standalone database.', + path: '/databases/{uuid}/vulnerabilities', + operationId: 'get-database-vulnerabilities', + security: [['bearerAuth' => []]], + tags: ['Vulnerabilities'], + parameters: [ + new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Database UUID', schema: new OA\Schema(type: 'string')), + new OA\Parameter(ref: '#/components/parameters/VulnerabilityState'), + new OA\Parameter(ref: '#/components/parameters/VulnerabilitySeverity'), + new OA\Parameter(ref: '#/components/parameters/VulnerabilityImage'), + new OA\Parameter(ref: '#/components/parameters/VulnerabilitySearch'), + new OA\Parameter(ref: '#/components/parameters/VulnerabilityPage'), + new OA\Parameter(ref: '#/components/parameters/VulnerabilityPerPage'), + ], + responses: [ + new OA\Response(response: 200, description: 'Scan results.', content: new OA\JsonContent(ref: '#/components/schemas/ResourceVulnerabilities')), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 400, ref: '#/components/responses/400'), + new OA\Response(response: 404, ref: '#/components/responses/404'), + new OA\Response(response: 422, ref: '#/components/responses/422'), + ], + )] + public function database(Request $request): JsonResponse + { + return $this->show($request, 'database'); + } + + private function show(Request $request, string $type): JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $validator = customApiValidator($request->query(), [ + 'state' => 'nullable|string', + 'severity' => 'nullable|string', + 'image' => 'nullable|string|max:1000', + 'search' => 'nullable|string|max:200', + 'page' => 'nullable|integer|min:1', + 'per_page' => 'nullable|integer|min:1|max:200', + ]); + if ($validator->fails()) { + return response()->json(['message' => 'Validation failed.', 'errors' => $validator->errors()], 422); + } + $states = $this->listParameter($request, 'state', self::FINDING_STATES); + $severities = $this->listParameter($request, 'severity', array_keys(self::SEVERITY_RANK)); + foreach ([$states, $severities] as $parameter) { + if ($parameter instanceof JsonResponse) { + return $parameter; + } + } + + $resource = ResourceScans::find($type, (string) $request->route('uuid'), [(int) $teamId]); + if (! $resource instanceof Model) { + return response()->json(['message' => ucfirst($type).' not found.'], 404); + } + $this->authorize('view', $resource); + + $offState = ImageScanPolicyResolver::offState($resource, ImageScanPolicyResolver::for($resource)->scanServer); + $scans = $offState === null ? ResourceScans::current($resource) : collect(); + $rules = []; + $rulesFor = function (Model $owner) use (&$rules): RelevanceRules { + return $rules[$owner->getMorphClass().':'.$owner->getKey()] ??= RelevanceRules::forResource($owner); + }; + + $imageStatuses = $scans->mapWithKeys(fn (ImageScan $scan): array => [(string) $scan->uuid => SecurityStatus::forScan($scan, $rulesFor($scan->resource))])->all(); + $findings = $this->findings($scans, $rulesFor); + + $image = trim((string) $request->query('image', '')); + $search = mb_strtolower(trim((string) $request->query('search', ''))); + $findings = array_values(array_filter($findings, fn (array $row): bool => ($states === [] || in_array($row['state'], $states, true)) + && ($severities === [] || in_array($row['severity'], $severities, true)) + && ($image === '' || $row['image_ref'] === $image) + && ($search === '' || str_contains(mb_strtolower(implode(' ', [$row['id'], ...$row['aliases'], $row['package'], $row['version']])), $search)))); + + $perPage = (int) $request->query('per_page', 50); + $page = (int) $request->query('page', 1); + + return response()->json([ + 'uuid' => (string) $resource->getAttribute('uuid'), + 'type' => $type, + 'off_reason' => $offState, + 'security' => $scans->isEmpty() ? null : $this->security(SecurityStatus::forResource($resource, $scans, $imageStatuses)), + 'images' => $scans->map(fn (ImageScan $scan): array => [ + 'scan_uuid' => (string) $scan->uuid, + 'owner_uuid' => (string) $scan->resource?->getAttribute('uuid'), + 'image_ref' => (string) $scan->image_ref, + 'scan_status' => $scan->status?->value, + 'error_code' => $scan->error_code?->value, + 'scanned_at' => $scan->scanned_at?->toIso8601String(), + 'truncated' => (bool) data_get($scan->summary, 'truncated', false), + 'security_status' => $imageStatuses[(string) $scan->uuid]->status->value, + ])->values(), + 'findings' => array_slice($findings, ($page - 1) * $perPage, $perPage), + 'pagination' => ['page' => $page, 'per_page' => $perPage, 'total' => count($findings)], + ]); + } + + /** + * The findings of the current scans, worst first, in the same fix states as the UI. + * + * @param Collection $scans + * @param Closure(Model): RelevanceRules $rulesFor + * @return list> + */ + private function findings(Collection $scans, Closure $rulesFor): array + { + $rows = []; + foreach ($scans as $scan) { + $owner = $scan->resource; + $rules = $rulesFor($owner); + $classify = SecurityActions::fixStates($scan, $rules); + + foreach (ResourceScans::findings($scan) as $finding) { + $ignore = $rules->ignores->matches($finding); + $rows[] = $this->finding($finding, $ignore === null ? $classify($finding)->value : 'ignored', $ignore) + [ + 'image_ref' => (string) $scan->image_ref, + 'owner_uuid' => (string) $owner->getAttribute('uuid'), + ]; + } + } + + usort($rows, fn (array $a, array $b): int => [$b['kev'], self::SEVERITY_RANK[$b['severity']], $b['risk'] ?? 0.0] + <=> [$a['kev'], self::SEVERITY_RANK[$a['severity']], $a['risk'] ?? 0.0]); + + return $rows; + } + + /** + * @return array + */ + private function finding(Finding $finding, string $state, ?ImageScanIgnore $ignore): array + { + return [ + 'id' => $finding->id, + 'display_id' => $finding->displayId, + 'aliases' => $finding->aliases, + 'title' => $finding->title, + 'severity' => TeamVulnerabilities::severityOf($finding), + 'package' => $finding->package, + 'version' => $finding->version, + 'package_type' => $finding->type, + 'fixed_in' => $finding->fixedIn, + 'state' => $state, + 'ignore' => $ignore === null ? null : [ + 'reason' => $ignore->reason, + 'expires_at' => $ignore->expires_at?->toIso8601String(), + 'team_wide' => $ignore->resource_type === null, + ], + 'kev' => $finding->kev, + 'epss' => $finding->epss, + 'risk' => $finding->risk, + 'cvss_score' => $finding->cvssScore, + 'advisory_url' => ResourceScans::advisoryUrl($finding->displayId), + ]; + } + + /** + * The security status without the internal facts and record IDs. + * + * @return array + */ + private function security(ResourceSecurity $security): array + { + return [ + 'status' => $security->status->value, + 'label' => TeamVulnerabilities::statusLabel($security->status), + 'action' => TeamVulnerabilities::action($security)['text'] ?? null, + 'check_reason' => $security->checkReason, + 'problem_count' => $security->problemCount, + 'exploited_count' => $security->exploitedCount, + 'any_exploited' => $security->anyExploited, + 'critical_count' => $security->criticalCount, + 'high_count' => $security->highCount, + 'medium_count' => $security->mediumCount, + 'eol' => $security->eol, + 'checked_at' => $security->checkedAt?->toIso8601String(), + 'actions' => array_map(fn (SecurityAction $action): array => [ + 'type' => $action->type->value, + 'sentence' => $action->sentence, + 'target' => $action->target, + 'copy' => $action->copy, + 'image' => $action->image, + 'ref' => $action->ref, + ], $security->actions), + ]; + } + + private function resourceType(Model $resource): string + { + return match (true) { + $resource instanceof Application => 'application', + $resource instanceof Service => 'service', + default => 'database', + }; + } + + /** + * A comma-separated query parameter, checked against the allowed values. Empty when not sent. + * + * @param list $allowed + * @return list|JsonResponse + */ + private function listParameter(Request $request, string $name, array $allowed): array|JsonResponse + { + $raw = $request->query($name); + if ($raw === null || $raw === '') { + return []; + } + if (! is_string($raw)) { + return response()->json(['message' => 'Validation failed.', 'errors' => [$name => ["The {$name} must be a comma-separated string."]]], 422); + } + + $values = array_values(array_unique(array_filter(array_map('trim', explode(',', strtolower($raw)))))); + $unknown = array_diff($values, $allowed); + if ($unknown !== []) { + return response()->json(['message' => 'Validation failed.', 'errors' => [$name => ['Allowed values: '.implode(', ', $allowed).'.']]], 422); + } + + return $values; + } +} diff --git a/app/Http/Middleware/ApiAbility.php b/app/Http/Middleware/ApiAbility.php index 3a48bc6b1d..76fddfe274 100644 --- a/app/Http/Middleware/ApiAbility.php +++ b/app/Http/Middleware/ApiAbility.php @@ -22,6 +22,7 @@ class ApiAbility extends CheckForAnyAbility 'deployments', 'resources', 'version', + 'vulnerabilities', ]; public const SCOPED_ACCESS_DENIED_MESSAGE = 'This API token is limited to selected projects, environments, or resources, so it cannot use this endpoint.'; diff --git a/app/Livewire/ImageScan/ServerImageScanning.php b/app/Livewire/ImageScan/ServerImageScanning.php index e8e37b803b..8d5cc9d96f 100644 --- a/app/Livewire/ImageScan/ServerImageScanning.php +++ b/app/Livewire/ImageScan/ServerImageScanning.php @@ -2,10 +2,12 @@ namespace App\Livewire\ImageScan; +use App\Actions\ImageScan\SetServerImageScanning; use App\Jobs\PrepareImageScannerJob; use App\Models\InstanceSettings; use App\Models\Server; use App\Services\ImageScan\ImageRef; +use App\Services\ImageScan\ImageScanningRefused; use App\Services\ImageScan\ScannerCommands; use App\Traits\ListensToTeamChannel; use Carbon\CarbonImmutable; @@ -47,19 +49,11 @@ class ServerImageScanning extends Component $this->authorize('update', $this->server); try { - $this->ensureManagedHere(); - if (! InstanceSettings::get()->is_image_scanning_allowed) { - $this->dispatch('error', message: 'Image scanning is turned off for this Coolify instance.'); - - return; - } - - $settings = $this->server->settings; - $settings->is_image_scan_enabled = true; - $settings->save(); + SetServerImageScanning::run($this->server, true); $this->auditImageScanning('image_scanning_enabled'); - $this->startPreparing(); $this->dispatch('success', 'Image scanning turned on. Coolify is getting the scanner.'); + } catch (ImageScanningRefused $e) { + $this->dispatch('error', message: $e->getMessage()); } catch (\Throwable $e) { return handleError($e, $this); } @@ -70,14 +64,14 @@ class ServerImageScanning extends Component $this->authorize('update', $this->server); try { - $this->ensureManagedHere(); - if (! InstanceSettings::get()->is_image_scanning_allowed || ! $this->isEnabled()) { + SetServerImageScanning::ensureManagedHere($this->server); + if (! InstanceSettings::get()->is_image_scanning_allowed || ! $this->server->settings->refresh()->is_image_scan_enabled) { $this->dispatch('error', message: 'Turn on image scanning first.'); return; } - $this->startPreparing(); + SetServerImageScanning::prepareScanner($this->server); $this->dispatch('success', 'Coolify is getting the scanner.'); } catch (\Throwable $e) { return handleError($e, $this); @@ -89,10 +83,7 @@ class ServerImageScanning extends Component $this->authorize('update', $this->server); try { - $settings = $this->server->settings; - $settings->is_image_scan_enabled = false; - $settings->save(); - PrepareImageScannerJob::forget($this->server); + SetServerImageScanning::run($this->server, false); $this->auditImageScanning('image_scanning_disabled'); $this->dispatch('success', 'Image scanning turned off.'); } catch (\Throwable $e) { @@ -105,7 +96,7 @@ class ServerImageScanning extends Component $this->authorize('update', $this->server); try { - $this->ensureManagedHere(); + SetServerImageScanning::ensureManagedHere($this->server); $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); @@ -116,24 +107,6 @@ class ServerImageScanning extends Component } } - private function startPreparing(): void - { - Cache::put(PrepareImageScannerJob::stateCacheKey($this->server), PrepareImageScannerJob::STATE_PULLING, now()->addHour()); - PrepareImageScannerJob::dispatch($this->server); - } - - private function isEnabled(): bool - { - return (bool) $this->server->settings->refresh()->is_image_scan_enabled; - } - - private function ensureManagedHere(): void - { - if ($this->server->isTransferredAway()) { - throw new \RuntimeException('This server is managed by another Coolify instance.'); - } - } - private function auditImageScanning(string $action): void { auditLog("ui.server.{$action}", [ diff --git a/app/Mcp/Servers/CoolifyServer.php b/app/Mcp/Servers/CoolifyServer.php index 7010bd40dc..e434911f53 100644 --- a/app/Mcp/Servers/CoolifyServer.php +++ b/app/Mcp/Servers/CoolifyServer.php @@ -34,6 +34,7 @@ use App\Mcp\Tools\GetServerResources; use App\Mcp\Tools\GetService; use App\Mcp\Tools\GetServiceApplication; use App\Mcp\Tools\GetServiceDatabase; +use App\Mcp\Tools\GetVulnerabilities; use App\Mcp\Tools\ListApplicationPreviews; use App\Mcp\Tools\ListApplications; use App\Mcp\Tools\ListAuditEvents; @@ -70,6 +71,7 @@ use App\Mcp\Tools\ManageDestination; use App\Mcp\Tools\ManageDns; use App\Mcp\Tools\ManageEnv; use App\Mcp\Tools\ManageGitSource; +use App\Mcp\Tools\ManageImageScanning; use App\Mcp\Tools\ManageIntegrationTokens; use App\Mcp\Tools\ManageNotifications; use App\Mcp\Tools\ManagePreview; @@ -118,6 +120,7 @@ Then: list_*/get_* for details. Debug is DB-first: - list_deployments → get_deployment(include_log_summary=true) - list_env_keys / list_shared_env_keys (names only, never values) - get_logs only if status is running; on failure use reason + next_tools (do not loop) +- get_vulnerabilities — image vulnerability scan results (list for the team, resource for one app/service/database); manage_image_scanning turns scanning on or off Lifecycle (requires token ability **deploy**): - control (start|stop|restart; stop needs confirm=true) @@ -218,6 +221,8 @@ MD; ManageNotifications::class, ListAuditEvents::class, GetServerMetrics::class, + GetVulnerabilities::class, + ManageImageScanning::class, ]; /** @@ -244,6 +249,7 @@ MD; ManageTags::class, ManageServer::class, ManageProxy::class, + ManageImageScanning::class, ManageCloudflareTunnel::class, DockerCleanup::class, ManageDestination::class, diff --git a/app/Mcp/Tools/GetVulnerabilities.php b/app/Mcp/Tools/GetVulnerabilities.php new file mode 100644 index 0000000000..6fce2983e0 --- /dev/null +++ b/app/Mcp/Tools/GetVulnerabilities.php @@ -0,0 +1,37 @@ + ['method' => 'GET', 'path' => 'vulnerabilities', 'ability' => 'read', 'description' => 'every resource with its security status, worst first (data: status, comma-separated: update_now, update_available, no_fix, check_needed, ok, not_checked)'], + 'resource' => ['method' => 'GET', 'paths' => [ + 'application' => 'applications/{uuid}/vulnerabilities', + 'service' => 'services/{uuid}/vulnerabilities', + 'database' => 'databases/{uuid}/vulnerabilities', + ], 'ability' => 'read', 'description' => 'status, scanned images, and vulnerabilities of one resource, worst first (data: state fixed|no_fix|ignored, severity, image, search, page, per_page max 200; state and severity are comma-separated)'], + ]; + } + + protected function pathParams(): array + { + return [ + 'uuid' => 'Resource UUID (resource action).', + ]; + } + + protected function dataDescription(): string + { + return 'Filters, sent as query parameters: status (list); state, severity, image, search, page, per_page (resource).'; + } +} diff --git a/app/Mcp/Tools/ManageApplication.php b/app/Mcp/Tools/ManageApplication.php index ea3d0b593e..0029362365 100644 --- a/app/Mcp/Tools/ManageApplication.php +++ b/app/Mcp/Tools/ManageApplication.php @@ -16,7 +16,7 @@ class ManageApplication extends RestActionTool protected function actions(): array { return [ - 'update' => ['method' => 'PATCH', 'path' => 'applications/{uuid}', 'ability' => 'write', 'description' => 'update application settings (data: same fields as the REST update, e.g. name, domains, git_branch, ports_exposes, build_command, is_container_label_readonly_enabled, include_coolify_default_variables; instant_deploy and manual_webhook_secret_* need deploy ability)'], + 'update' => ['method' => 'PATCH', 'path' => 'applications/{uuid}', 'ability' => 'write', 'description' => 'update application settings (data: same fields as the REST update, e.g. name, domains, git_branch, ports_exposes, build_command, is_container_label_readonly_enabled, include_coolify_default_variables, is_image_scan_enabled; instant_deploy and manual_webhook_secret_* need deploy ability)'], 'delete' => ['method' => 'DELETE', 'path' => 'applications/{uuid}', 'ability' => 'delete', 'confirm' => true, 'description' => 'delete the application and its containers (data: delete_volumes, delete_connected_networks, delete_configurations, docker_cleanup)'], 'clone' => ['method' => 'POST', 'path' => 'applications/{uuid}/clone', 'ability' => 'write', 'description' => 'clone the application to a destination of the team (data: destination_uuid, name, clone_volumes; clone_volumes needs deploy ability)'], 'move' => ['method' => 'POST', 'path' => 'applications/{uuid}/move', 'ability' => 'write', 'confirm' => true, 'description' => 'move the application to another environment of the team (data: environment_uuid)'], diff --git a/app/Mcp/Tools/ManageImageScanning.php b/app/Mcp/Tools/ManageImageScanning.php new file mode 100644 index 0000000000..b7a1ff11d7 --- /dev/null +++ b/app/Mcp/Tools/ManageImageScanning.php @@ -0,0 +1,34 @@ + ['method' => 'GET', 'path' => 'servers/{uuid}/image-scanning', 'ability' => 'read', 'description' => 'whether the instance allows scanning, whether the server scans, and the scanner state on the server'], + 'update_server' => ['method' => 'PATCH', 'path' => 'servers/{uuid}/image-scanning', 'ability' => 'write', 'description' => 'turn scanning of the server on or off (data: is_image_scan_enabled); turning it on gets the scanner on the server'], + 'update_team' => ['method' => 'PATCH', 'path' => 'team', 'ability' => 'write', 'description' => 'turn scanning on or off for servers added to the team from now on (data: scan_images_on_new_servers)'], + ]; + } + + protected function pathParams(): array + { + return [ + 'uuid' => 'Server UUID (get_server and update_server).', + ]; + } + + protected function dataDescription(): string + { + return 'Request body: is_image_scan_enabled (update_server), scan_images_on_new_servers (update_team).'; + } +} diff --git a/app/Models/Team.php b/app/Models/Team.php index 79f8844a1c..b8ffe23001 100644 --- a/app/Models/Team.php +++ b/app/Models/Team.php @@ -36,6 +36,7 @@ use OpenApi\Attributes as OA; 'show_boarding' => ['type' => 'boolean', 'description' => 'Whether to show the boarding screen or not.'], 'custom_server_limit' => ['type' => 'string', 'description' => 'The custom server limit.'], 'is_build_server_fallback_enabled' => ['type' => 'boolean', 'description' => 'Whether deployments can fall back to the deployment server when no usable dedicated build server is available.'], + 'scan_images_on_new_servers' => ['type' => 'boolean', 'description' => 'Whether image scanning is turned on for servers added to the team from now on.'], 'members' => new OA\Property( property: 'members', type: 'array', diff --git a/app/Services/ImageScan/ImageScanningRefused.php b/app/Services/ImageScan/ImageScanningRefused.php new file mode 100644 index 0000000000..ecf7cbda2c --- /dev/null +++ b/app/Services/ImageScan/ImageScanningRefused.php @@ -0,0 +1,10 @@ + 'boolean', + 'is_image_scan_enabled' => 'boolean', 'custom_internal_name' => ['nullable', ...ValidationPatterns::containerNameRules()], 'custom_container_name_prefix' => 'string|nullable|max:'.ApplicationSetting::MAX_CONTAINER_NAME_PREFIX_LENGTH, 'preview_url_template' => 'string', @@ -551,6 +552,7 @@ function removeUnnecessaryFieldsFromRequest(Request $request) $request->offsetUnset('gpu_device_ids'); $request->offsetUnset('gpu_options'); $request->offsetUnset('is_consistent_container_name_enabled'); + $request->offsetUnset('is_image_scan_enabled'); $request->offsetUnset('custom_internal_name'); $request->offsetUnset('custom_container_name_prefix'); $request->offsetUnset('docker_compose_raw'); diff --git a/openapi.json b/openapi.json index 5b25751e90..cba7986122 100644 --- a/openapi.json +++ b/openapi.json @@ -944,6 +944,10 @@ "type": "boolean", "description": "Use a consistent container name across deployments." }, + "is_image_scan_enabled": { + "type": "boolean", + "description": "Scan the images of the application for vulnerabilities. Needs image scanning on the server. Default true." + }, "custom_internal_name": { "type": "string", "nullable": true, @@ -1612,6 +1616,10 @@ "type": "boolean", "description": "Use a consistent container name across deployments." }, + "is_image_scan_enabled": { + "type": "boolean", + "description": "Scan the images of the application for vulnerabilities. Needs image scanning on the server. Default true." + }, "custom_internal_name": { "type": "string", "nullable": true, @@ -2280,6 +2288,10 @@ "type": "boolean", "description": "Use a consistent container name across deployments." }, + "is_image_scan_enabled": { + "type": "boolean", + "description": "Scan the images of the application for vulnerabilities. Needs image scanning on the server. Default true." + }, "custom_internal_name": { "type": "string", "nullable": true, @@ -2948,6 +2960,10 @@ "type": "boolean", "description": "Use a consistent container name across deployments." }, + "is_image_scan_enabled": { + "type": "boolean", + "description": "Scan the images of the application for vulnerabilities. Needs image scanning on the server. Default true." + }, "custom_internal_name": { "type": "string", "nullable": true, @@ -3506,6 +3522,10 @@ "type": "boolean", "description": "Use a consistent container name across deployments." }, + "is_image_scan_enabled": { + "type": "boolean", + "description": "Scan the images of the application for vulnerabilities. Needs image scanning on the server. Default true." + }, "custom_internal_name": { "type": "string", "nullable": true, @@ -4044,6 +4064,10 @@ "type": "boolean", "description": "Use a consistent container name across deployments." }, + "is_image_scan_enabled": { + "type": "boolean", + "description": "Scan the images of the application for vulnerabilities. Needs image scanning on the server. Default true." + }, "custom_internal_name": { "type": "string", "nullable": true, @@ -4861,6 +4885,10 @@ "type": "boolean", "description": "Use a consistent container name across deployments." }, + "is_image_scan_enabled": { + "type": "boolean", + "description": "Scan the images of the application for vulnerabilities. Needs image scanning on the server. Default true." + }, "custom_internal_name": { "type": "string", "nullable": true, @@ -19480,6 +19508,167 @@ ] } }, + "\/servers\/{uuid}\/image-scanning": { + "get": { + "tags": [ + "Servers" + ], + "summary": "Get image scanning settings", + "description": "Get the image vulnerability scanning settings of a server owned by the authenticated team, and the state of the scanner on the server. Needs a team admin or owner.", + "operationId": "get-server-image-scanning", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "Server UUID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Image scanning settings.", + "content": { + "application\/json": { + "schema": { + "properties": { + "is_image_scanning_allowed": { + "description": "Whether the Coolify instance allows image scanning.", + "type": "boolean", + "readOnly": true + }, + "is_image_scan_enabled": { + "description": "Whether the server scans the images of its resources.", + "type": "boolean" + }, + "scanner_state": { + "description": "State of the scanner on the server. Null when unknown.", + "type": [ + "string", + "null" + ], + "enum": [ + "pulling", + "updating_database", + "ready", + "failed" + ] + }, + "scanner_failed_step": { + "description": "The step that failed when scanner_state is failed.", + "type": [ + "string", + "null" + ] + }, + "scanner_built_at": { + "description": "Time of the vulnerability database on the server.", + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "scanner_disk_used_bytes": { + "description": "Disk space that the scanner uses on the server.", + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "403": { + "description": "Needs a team admin or owner." + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "patch": { + "tags": [ + "Servers" + ], + "summary": "Update image scanning settings", + "description": "Turn image vulnerability scanning of a server owned by the authenticated team on or off. Turning it on gets the scanner on the server in the background.", + "operationId": "update-server-image-scanning", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "Server UUID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application\/json": { + "schema": { + "required": [ + "is_image_scan_enabled" + ], + "properties": { + "is_image_scan_enabled": { + "type": "boolean" + } + }, + "type": "object" + } + } + } + }, + "responses": { + "200": { + "description": "Updated image scanning settings." + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "403": { + "description": "Needs a team admin or owner." + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "409": { + "description": "The instance does not allow image scanning, or another Coolify instance manages the server." + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, "\/servers\/{uuid}\/log-drains": { "get": { "tags": [ @@ -25793,13 +25982,15 @@ "content": { "application\/json": { "schema": { - "required": [ - "is_build_server_fallback_enabled" - ], + "minProperties": 1, "properties": { "is_build_server_fallback_enabled": { "type": "boolean", "description": "Whether deployments can fall back to the deployment server when no usable dedicated build server is available." + }, + "scan_images_on_new_servers": { + "type": "boolean", + "description": "Whether image scanning is turned on for servers added to the team from now on." } }, "type": "object" @@ -26405,6 +26596,314 @@ ] } }, + "\/vulnerabilities": { + "get": { + "tags": [ + "Vulnerabilities" + ], + "summary": "List vulnerabilities", + "description": "List the applications, services, and databases of the authenticated team with their stored security status from image scanning.", + "operationId": "list-vulnerabilities", + "parameters": [ + { + "name": "status", + "in": "query", + "description": "Comma-separated statuses to return.", + "required": false, + "schema": { + "type": "string", + "example": "update_now,update_available" + } + } + ], + "responses": { + "200": { + "description": "Resources with their security status, worst first.", + "content": { + "application\/json": { + "schema": { + "properties": { + "is_image_scanning_allowed": { + "type": "boolean" + }, + "resources": { + "type": "array", + "items": { + "properties": { + "uuid": { + "type": "string" + }, + "name": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "application", + "service", + "database" + ] + }, + "project_uuid": { + "type": [ + "string", + "null" + ] + }, + "environment_uuid": { + "type": [ + "string", + "null" + ] + }, + "server_uuid": { + "type": [ + "string", + "null" + ] + }, + "images": { + "properties": { + "update": { + "type": "integer" + }, + "total": { + "type": "integer" + } + }, + "type": "object" + }, + "security": { + "$ref": "#\/components\/schemas\/SecurityStatus" + } + }, + "type": "object" + } + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/applications\/{uuid}\/vulnerabilities": { + "get": { + "tags": [ + "Vulnerabilities" + ], + "summary": "Get application vulnerabilities", + "description": "Get the security status, the scanned images, and the vulnerabilities of an application.", + "operationId": "get-application-vulnerabilities", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "Application UUID", + "required": true, + "schema": { + "type": "string" + } + }, + { + "$ref": "#\/components\/parameters\/VulnerabilityState" + }, + { + "$ref": "#\/components\/parameters\/VulnerabilitySeverity" + }, + { + "$ref": "#\/components\/parameters\/VulnerabilityImage" + }, + { + "$ref": "#\/components\/parameters\/VulnerabilitySearch" + }, + { + "$ref": "#\/components\/parameters\/VulnerabilityPage" + }, + { + "$ref": "#\/components\/parameters\/VulnerabilityPerPage" + } + ], + "responses": { + "200": { + "description": "Scan results.", + "content": { + "application\/json": { + "schema": { + "$ref": "#\/components\/schemas\/ResourceVulnerabilities" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/services\/{uuid}\/vulnerabilities": { + "get": { + "tags": [ + "Vulnerabilities" + ], + "summary": "Get service vulnerabilities", + "description": "Get the security status, the scanned images, and the vulnerabilities of the applications and databases of a service.", + "operationId": "get-service-vulnerabilities", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "Service UUID", + "required": true, + "schema": { + "type": "string" + } + }, + { + "$ref": "#\/components\/parameters\/VulnerabilityState" + }, + { + "$ref": "#\/components\/parameters\/VulnerabilitySeverity" + }, + { + "$ref": "#\/components\/parameters\/VulnerabilityImage" + }, + { + "$ref": "#\/components\/parameters\/VulnerabilitySearch" + }, + { + "$ref": "#\/components\/parameters\/VulnerabilityPage" + }, + { + "$ref": "#\/components\/parameters\/VulnerabilityPerPage" + } + ], + "responses": { + "200": { + "description": "Scan results.", + "content": { + "application\/json": { + "schema": { + "$ref": "#\/components\/schemas\/ResourceVulnerabilities" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/databases\/{uuid}\/vulnerabilities": { + "get": { + "tags": [ + "Vulnerabilities" + ], + "summary": "Get database vulnerabilities", + "description": "Get the security status, the scanned image, and the vulnerabilities of a standalone database.", + "operationId": "get-database-vulnerabilities", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "Database UUID", + "required": true, + "schema": { + "type": "string" + } + }, + { + "$ref": "#\/components\/parameters\/VulnerabilityState" + }, + { + "$ref": "#\/components\/parameters\/VulnerabilitySeverity" + }, + { + "$ref": "#\/components\/parameters\/VulnerabilityImage" + }, + { + "$ref": "#\/components\/parameters\/VulnerabilitySearch" + }, + { + "$ref": "#\/components\/parameters\/VulnerabilityPage" + }, + { + "$ref": "#\/components\/parameters\/VulnerabilityPerPage" + } + ], + "responses": { + "200": { + "description": "Scan results.", + "content": { + "application\/json": { + "schema": { + "$ref": "#\/components\/schemas\/ResourceVulnerabilities" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, "\/vultr\/regions": { "get": { "tags": [ @@ -27125,6 +27624,305 @@ }, "type": "object" }, + "SecurityStatus": { + "description": "Security status of a resource from image scanning.", + "properties": { + "status": { + "type": "string", + "enum": [ + "update_now", + "update_available", + "no_fix", + "check_needed", + "ok", + "not_checked" + ] + }, + "label": { + "type": "string" + }, + "action": { + "description": "What to do, as the UI shows it.", + "type": [ + "string", + "null" + ] + }, + "check_reason": { + "type": [ + "string", + "null" + ] + }, + "problem_count": { + "type": "integer" + }, + "exploited_count": { + "type": "integer" + }, + "any_exploited": { + "type": "boolean" + }, + "critical_count": { + "type": "integer" + }, + "high_count": { + "type": "integer" + }, + "medium_count": { + "type": "integer" + }, + "eol": { + "description": "The end-of-life OS of the image.", + "type": [ + "object", + "null" + ] + }, + "checked_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "actions": { + "type": "array", + "items": { + "properties": { + "type": { + "type": "string" + }, + "sentence": { + "type": "string" + }, + "target": { + "type": [ + "string", + "null" + ] + }, + "copy": { + "type": [ + "string", + "null" + ] + }, + "image": { + "type": [ + "string", + "null" + ] + }, + "ref": { + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + } + } + }, + "type": "object" + }, + "ResourceVulnerabilities": { + "description": "Image scan results of a resource.", + "properties": { + "uuid": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "application", + "service", + "database" + ] + }, + "off_reason": { + "description": "Why the resource is not scanned. Null when it is scanned.", + "type": [ + "string", + "null" + ], + "enum": [ + "instance", + "server", + "application" + ] + }, + "security": { + "oneOf": [ + { + "$ref": "#\/components\/schemas\/SecurityStatus" + }, + { + "type": "null" + } + ] + }, + "images": { + "type": "array", + "items": { + "properties": { + "scan_uuid": { + "type": "string" + }, + "owner_uuid": { + "description": "The application, database, or service application or database that runs the image.", + "type": "string" + }, + "image_ref": { + "type": "string" + }, + "scan_status": { + "type": "string", + "enum": [ + "queued", + "running", + "completed", + "failed" + ] + }, + "error_code": { + "type": [ + "string", + "null" + ] + }, + "scanned_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "truncated": { + "type": "boolean" + }, + "security_status": { + "type": "string" + } + }, + "type": "object" + } + }, + "findings": { + "type": "array", + "items": { + "properties": { + "id": { + "type": "string" + }, + "display_id": { + "type": "string" + }, + "aliases": { + "type": "array", + "items": { + "type": "string" + } + }, + "title": { + "type": "string" + }, + "severity": { + "type": "string", + "enum": [ + "critical", + "high", + "medium", + "low", + "unknown" + ] + }, + "package": { + "type": "string" + }, + "version": { + "type": "string" + }, + "package_type": { + "type": "string" + }, + "fixed_in": { + "type": [ + "string", + "null" + ] + }, + "state": { + "description": "fixed: a fix is available upstream.", + "type": "string", + "enum": [ + "fixed", + "no_fix", + "ignored" + ] + }, + "ignore": { + "type": [ + "object", + "null" + ] + }, + "kev": { + "description": "Listed in the CISA Known Exploited Vulnerabilities catalog.", + "type": "boolean" + }, + "epss": { + "type": [ + "number", + "null" + ] + }, + "risk": { + "type": [ + "number", + "null" + ] + }, + "cvss_score": { + "type": [ + "number", + "null" + ] + }, + "advisory_url": { + "type": [ + "string", + "null" + ] + }, + "image_ref": { + "type": "string" + }, + "owner_uuid": { + "type": "string" + } + }, + "type": "object" + } + }, + "pagination": { + "properties": { + "page": { + "type": "integer" + }, + "per_page": { + "type": "integer" + }, + "total": { + "type": "integer" + } + }, + "type": "object" + } + }, + "type": "object" + }, "ApplicationPreview": { "properties": { "uuid": { @@ -29204,6 +30002,10 @@ "type": "boolean", "description": "Whether deployments can fall back to the deployment server when no usable dedicated build server is available." }, + "scan_images_on_new_servers": { + "type": "boolean", + "description": "Whether image scanning is turned on for servers added to the team from now on." + }, "members": { "description": "The members of the team.", "type": "array", @@ -29395,6 +30197,65 @@ } } }, + "parameters": { + "VulnerabilityState": { + "name": "state", + "in": "query", + "description": "Comma-separated finding states: fixed, no_fix, ignored. All when not sent.", + "required": false, + "schema": { + "type": "string" + } + }, + "VulnerabilitySeverity": { + "name": "severity", + "in": "query", + "description": "Comma-separated severities: critical, high, medium, low, unknown.", + "required": false, + "schema": { + "type": "string" + } + }, + "VulnerabilityImage": { + "name": "image", + "in": "query", + "description": "Only the findings of this scanned image ref.", + "required": false, + "schema": { + "type": "string" + } + }, + "VulnerabilitySearch": { + "name": "search", + "in": "query", + "description": "Text in the vulnerability ID, an alias, the package, or the version.", + "required": false, + "schema": { + "type": "string" + } + }, + "VulnerabilityPage": { + "name": "page", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 1, + "minimum": 1 + } + }, + "VulnerabilityPerPage": { + "name": "per_page", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 50, + "maximum": 200, + "minimum": 1 + } + } + }, "securitySchemes": { "bearerAuth": { "type": "http", @@ -29524,6 +30385,10 @@ "name": "Teams", "description": "Teams" }, + { + "name": "Vulnerabilities", + "description": "Vulnerabilities" + }, { "name": "Vultr", "description": "Vultr" diff --git a/openapi.yaml b/openapi.yaml index d0c9bd58b3..8f713c76b4 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -625,6 +625,9 @@ paths: is_consistent_container_name_enabled: type: boolean description: 'Use a consistent container name across deployments.' + is_image_scan_enabled: + type: boolean + description: 'Scan the images of the application for vulnerabilities. Needs image scanning on the server. Default true.' custom_internal_name: type: string nullable: true @@ -1070,6 +1073,9 @@ paths: is_consistent_container_name_enabled: type: boolean description: 'Use a consistent container name across deployments.' + is_image_scan_enabled: + type: boolean + description: 'Scan the images of the application for vulnerabilities. Needs image scanning on the server. Default true.' custom_internal_name: type: string nullable: true @@ -1515,6 +1521,9 @@ paths: is_consistent_container_name_enabled: type: boolean description: 'Use a consistent container name across deployments.' + is_image_scan_enabled: + type: boolean + description: 'Scan the images of the application for vulnerabilities. Needs image scanning on the server. Default true.' custom_internal_name: type: string nullable: true @@ -1960,6 +1969,9 @@ paths: is_consistent_container_name_enabled: type: boolean description: 'Use a consistent container name across deployments.' + is_image_scan_enabled: + type: boolean + description: 'Scan the images of the application for vulnerabilities. Needs image scanning on the server. Default true.' custom_internal_name: type: string nullable: true @@ -2342,6 +2354,9 @@ paths: is_consistent_container_name_enabled: type: boolean description: 'Use a consistent container name across deployments.' + is_image_scan_enabled: + type: boolean + description: 'Scan the images of the application for vulnerabilities. Needs image scanning on the server. Default true.' custom_internal_name: type: string nullable: true @@ -2710,6 +2725,9 @@ paths: is_consistent_container_name_enabled: type: boolean description: 'Use a consistent container name across deployments.' + is_image_scan_enabled: + type: boolean + description: 'Scan the images of the application for vulnerabilities. Needs image scanning on the server. Default true.' custom_internal_name: type: string nullable: true @@ -3258,6 +3276,9 @@ paths: is_consistent_container_name_enabled: type: boolean description: 'Use a consistent container name across deployments.' + is_image_scan_enabled: + type: boolean + description: 'Scan the images of the application for vulnerabilities. Needs image scanning on the server. Default true.' custom_internal_name: type: string nullable: true @@ -12375,6 +12396,89 @@ paths: security: - bearerAuth: [] + '/servers/{uuid}/image-scanning': + get: + tags: + - Servers + summary: 'Get image scanning settings' + description: 'Get the image vulnerability scanning settings of a server owned by the authenticated team, and the state of the scanner on the server. Needs a team admin or owner.' + operationId: get-server-image-scanning + parameters: + - + name: uuid + in: path + description: 'Server UUID' + required: true + schema: + type: string + responses: + '200': + description: 'Image scanning settings.' + content: + application/json: + schema: + properties: + is_image_scanning_allowed: { description: 'Whether the Coolify instance allows image scanning.', type: boolean, readOnly: true } + is_image_scan_enabled: { description: 'Whether the server scans the images of its resources.', type: boolean } + scanner_state: { description: 'State of the scanner on the server. Null when unknown.', type: [string, 'null'], enum: [pulling, updating_database, ready, failed] } + scanner_failed_step: { description: 'The step that failed when scanner_state is failed.', type: [string, 'null'] } + scanner_built_at: { description: 'Time of the vulnerability database on the server.', type: [string, 'null'], format: date-time } + scanner_disk_used_bytes: { description: 'Disk space that the scanner uses on the server.', type: [integer, 'null'] } + type: object + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '403': + description: 'Needs a team admin or owner.' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] + patch: + tags: + - Servers + summary: 'Update image scanning settings' + description: 'Turn image vulnerability scanning of a server owned by the authenticated team on or off. Turning it on gets the scanner on the server in the background.' + operationId: update-server-image-scanning + parameters: + - + name: uuid + in: path + description: 'Server UUID' + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + required: + - is_image_scan_enabled + properties: + is_image_scan_enabled: + type: boolean + type: object + responses: + '200': + description: 'Updated image scanning settings.' + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '403': + description: 'Needs a team admin or owner.' + '404': + $ref: '#/components/responses/404' + '409': + description: 'The instance does not allow image scanning, or another Coolify instance manages the server.' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] '/servers/{uuid}/log-drains': get: tags: @@ -16285,12 +16389,14 @@ paths: content: application/json: schema: - required: - - is_build_server_fallback_enabled + minProperties: 1 properties: is_build_server_fallback_enabled: type: boolean description: 'Whether deployments can fall back to the deployment server when no usable dedicated build server is available.' + scan_images_on_new_servers: + type: boolean + description: 'Whether image scanning is turned on for servers added to the team from now on.' type: object responses: '200': @@ -16690,6 +16796,174 @@ paths: security: - bearerAuth: [] + /vulnerabilities: + get: + tags: + - Vulnerabilities + summary: 'List vulnerabilities' + description: 'List the applications, services, and databases of the authenticated team with their stored security status from image scanning.' + operationId: list-vulnerabilities + parameters: + - + name: status + in: query + description: 'Comma-separated statuses to return.' + required: false + schema: + type: string + example: 'update_now,update_available' + responses: + '200': + description: 'Resources with their security status, worst first.' + content: + application/json: + schema: + properties: + is_image_scanning_allowed: { type: boolean } + resources: { type: array, items: { properties: { uuid: { type: string }, name: { type: string }, type: { type: string, enum: [application, service, database] }, project_uuid: { type: [string, 'null'] }, environment_uuid: { type: [string, 'null'] }, server_uuid: { type: [string, 'null'] }, images: { properties: { update: { type: integer }, total: { type: integer } }, type: object }, security: { $ref: '#/components/schemas/SecurityStatus' } }, type: object } } + type: object + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + security: + - + bearerAuth: [] + '/applications/{uuid}/vulnerabilities': + get: + tags: + - Vulnerabilities + summary: 'Get application vulnerabilities' + description: 'Get the security status, the scanned images, and the vulnerabilities of an application.' + operationId: get-application-vulnerabilities + parameters: + - + name: uuid + in: path + description: 'Application UUID' + required: true + schema: + type: string + - + $ref: '#/components/parameters/VulnerabilityState' + - + $ref: '#/components/parameters/VulnerabilitySeverity' + - + $ref: '#/components/parameters/VulnerabilityImage' + - + $ref: '#/components/parameters/VulnerabilitySearch' + - + $ref: '#/components/parameters/VulnerabilityPage' + - + $ref: '#/components/parameters/VulnerabilityPerPage' + responses: + '200': + description: 'Scan results.' + content: + application/json: + schema: + $ref: '#/components/schemas/ResourceVulnerabilities' + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '404': + $ref: '#/components/responses/404' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] + '/services/{uuid}/vulnerabilities': + get: + tags: + - Vulnerabilities + summary: 'Get service vulnerabilities' + description: 'Get the security status, the scanned images, and the vulnerabilities of the applications and databases of a service.' + operationId: get-service-vulnerabilities + parameters: + - + name: uuid + in: path + description: 'Service UUID' + required: true + schema: + type: string + - + $ref: '#/components/parameters/VulnerabilityState' + - + $ref: '#/components/parameters/VulnerabilitySeverity' + - + $ref: '#/components/parameters/VulnerabilityImage' + - + $ref: '#/components/parameters/VulnerabilitySearch' + - + $ref: '#/components/parameters/VulnerabilityPage' + - + $ref: '#/components/parameters/VulnerabilityPerPage' + responses: + '200': + description: 'Scan results.' + content: + application/json: + schema: + $ref: '#/components/schemas/ResourceVulnerabilities' + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '404': + $ref: '#/components/responses/404' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] + '/databases/{uuid}/vulnerabilities': + get: + tags: + - Vulnerabilities + summary: 'Get database vulnerabilities' + description: 'Get the security status, the scanned image, and the vulnerabilities of a standalone database.' + operationId: get-database-vulnerabilities + parameters: + - + name: uuid + in: path + description: 'Database UUID' + required: true + schema: + type: string + - + $ref: '#/components/parameters/VulnerabilityState' + - + $ref: '#/components/parameters/VulnerabilitySeverity' + - + $ref: '#/components/parameters/VulnerabilityImage' + - + $ref: '#/components/parameters/VulnerabilitySearch' + - + $ref: '#/components/parameters/VulnerabilityPage' + - + $ref: '#/components/parameters/VulnerabilityPerPage' + responses: + '200': + description: 'Scan results.' + content: + application/json: + schema: + $ref: '#/components/schemas/ResourceVulnerabilities' + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '404': + $ref: '#/components/responses/404' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] /vultr/regions: get: tags: @@ -17195,6 +17469,212 @@ components: - 'null' format: date-time type: object + SecurityStatus: + description: 'Security status of a resource from image scanning.' + properties: + status: + type: string + enum: + - update_now + - update_available + - no_fix + - check_needed + - ok + - not_checked + label: + type: string + action: + description: 'What to do, as the UI shows it.' + type: + - string + - 'null' + check_reason: + type: + - string + - 'null' + problem_count: + type: integer + exploited_count: + type: integer + any_exploited: + type: boolean + critical_count: + type: integer + high_count: + type: integer + medium_count: + type: integer + eol: + description: 'The end-of-life OS of the image.' + type: + - object + - 'null' + checked_at: + type: + - string + - 'null' + format: date-time + actions: + type: array + items: + properties: + type: + type: string + sentence: + type: string + target: + type: + - string + - 'null' + copy: + type: + - string + - 'null' + image: + type: + - string + - 'null' + ref: + type: + - string + - 'null' + type: object + type: object + ResourceVulnerabilities: + description: 'Image scan results of a resource.' + properties: + uuid: + type: string + type: + type: string + enum: + - application + - service + - database + off_reason: + description: 'Why the resource is not scanned. Null when it is scanned.' + type: + - string + - 'null' + enum: + - instance + - server + - application + security: + oneOf: + - + $ref: '#/components/schemas/SecurityStatus' + - + type: 'null' + images: + type: array + items: + properties: + scan_uuid: + type: string + owner_uuid: + description: 'The application, database, or service application or database that runs the image.' + type: string + image_ref: + type: string + scan_status: + type: string + enum: + - queued + - running + - completed + - failed + error_code: + type: + - string + - 'null' + scanned_at: + type: + - string + - 'null' + format: date-time + truncated: + type: boolean + security_status: + type: string + type: object + findings: + type: array + items: + properties: + id: + type: string + display_id: + type: string + aliases: + type: array + items: + type: string + title: + type: string + severity: + type: string + enum: + - critical + - high + - medium + - low + - unknown + package: + type: string + version: + type: string + package_type: + type: string + fixed_in: + type: + - string + - 'null' + state: + description: 'fixed: a fix is available upstream.' + type: string + enum: + - fixed + - no_fix + - ignored + ignore: + type: + - object + - 'null' + kev: + description: 'Listed in the CISA Known Exploited Vulnerabilities catalog.' + type: boolean + epss: + type: + - number + - 'null' + risk: + type: + - number + - 'null' + cvss_score: + type: + - number + - 'null' + advisory_url: + type: + - string + - 'null' + image_ref: + type: string + owner_uuid: + type: string + type: object + pagination: + properties: + page: + type: integer + per_page: + type: integer + total: + type: integer + type: object + type: object ApplicationPreview: properties: uuid: @@ -18696,6 +19176,9 @@ components: is_build_server_fallback_enabled: type: boolean description: 'Whether deployments can fall back to the deployment server when no usable dedicated build server is available.' + scan_images_on_new_servers: + type: boolean + description: 'Whether image scanning is turned on for servers added to the team from now on.' members: description: 'The members of the team.' type: array @@ -18818,6 +19301,52 @@ components: - 'null' example: forbidden type: object + parameters: + VulnerabilityState: + name: state + in: query + description: 'Comma-separated finding states: fixed, no_fix, ignored. All when not sent.' + required: false + schema: + type: string + VulnerabilitySeverity: + name: severity + in: query + description: 'Comma-separated severities: critical, high, medium, low, unknown.' + required: false + schema: + type: string + VulnerabilityImage: + name: image + in: query + description: 'Only the findings of this scanned image ref.' + required: false + schema: + type: string + VulnerabilitySearch: + name: search + in: query + description: 'Text in the vulnerability ID, an alias, the package, or the version.' + required: false + schema: + type: string + VulnerabilityPage: + name: page + in: query + required: false + schema: + type: integer + default: 1 + minimum: 1 + VulnerabilityPerPage: + name: per_page + in: query + required: false + schema: + type: integer + default: 50 + maximum: 200 + minimum: 1 securitySchemes: bearerAuth: type: http @@ -18914,6 +19443,9 @@ tags: - name: Teams description: Teams + - + name: Vulnerabilities + description: Vulnerabilities - name: Vultr description: Vultr diff --git a/routes/api.php b/routes/api.php index 0b4b95cb67..2de9a3eb4c 100644 --- a/routes/api.php +++ b/routes/api.php @@ -29,6 +29,7 @@ use App\Http\Controllers\Api\SentinelController; use App\Http\Controllers\Api\ServerCloudflareTunnelController; use App\Http\Controllers\Api\ServerDockerCleanupController; use App\Http\Controllers\Api\ServerDockerRegistriesController; +use App\Http\Controllers\Api\ServerImageScanningController; use App\Http\Controllers\Api\ServerLogDrainsController; use App\Http\Controllers\Api\ServerProxyController; use App\Http\Controllers\Api\ServersController; @@ -41,6 +42,7 @@ use App\Http\Controllers\Api\SharedEnvironmentVariablesController; use App\Http\Controllers\Api\TagsController; use App\Http\Controllers\Api\TeamController; use App\Http\Controllers\Api\VolumeBackupsController; +use App\Http\Controllers\Api\VulnerabilitiesController; use App\Http\Controllers\Api\VultrController; use App\Http\Middleware\ApiAllowed; use Illuminate\Support\Facades\Route; @@ -184,6 +186,9 @@ Route::group([ Route::get('/servers/{uuid}/sentinel', [ServerSentinelController::class, 'show'])->middleware(['api.ability:read']); Route::patch('/servers/{uuid}/sentinel', [ServerSentinelController::class, 'update'])->middleware(['api.ability:write', 'api.ability:deploy']); + Route::get('/servers/{uuid}/image-scanning', [ServerImageScanningController::class, 'show'])->middleware(['api.ability:read']); + Route::patch('/servers/{uuid}/image-scanning', [ServerImageScanningController::class, 'update'])->middleware(['api.ability:write']); + Route::get('/servers/{uuid}/cloudflare-tunnel', [ServerCloudflareTunnelController::class, 'show'])->middleware(['api.ability:read']); Route::patch('/servers/{uuid}/cloudflare-tunnel', [ServerCloudflareTunnelController::class, 'update'])->middleware(['api.ability:write']); Route::post('/servers/{uuid}/cloudflare-tunnel/enable', [ServerCloudflareTunnelController::class, 'enable'])->middleware(['api.ability:write']); @@ -248,6 +253,7 @@ Route::group([ Route::post('/servers/hostinger', [HostingerController::class, 'createServer'])->middleware(['api.ability:write']); Route::get('/resources', [ResourcesController::class, 'resources'])->middleware(['api.ability:read']); + Route::get('/vulnerabilities', [VulnerabilitiesController::class, 'index'])->middleware(['api.ability:read']); Route::get('/tags', [TagsController::class, 'tags'])->middleware(['api.ability:read']); Route::post('/tags', [TagsController::class, 'create'])->middleware(['api.ability:write']); @@ -263,6 +269,7 @@ Route::group([ Route::post('/applications/dockerimage', [ApplicationsController::class, 'create_dockerimage_application'])->middleware(['api.ability:write']); Route::get('/applications/{uuid}', [ApplicationsController::class, 'application_by_uuid'])->middleware(['api.ability:read']); + Route::get('/applications/{uuid}/vulnerabilities', [VulnerabilitiesController::class, 'application'])->middleware(['api.ability:read']); Route::patch('/applications/{uuid}', [ApplicationsController::class, 'update_by_uuid'])->middleware(['api.ability:write']); Route::delete('/applications/{uuid}', [ApplicationsController::class, 'delete_by_uuid'])->middleware(['api.ability:delete']); @@ -352,6 +359,7 @@ Route::group([ Route::post('/databases/sqlite', [DatabasesController::class, 'create_database_sqlite'])->middleware(['api.ability:write']); Route::get('/databases/{uuid}', [DatabasesController::class, 'database_by_uuid'])->middleware(['api.ability:read']); + Route::get('/databases/{uuid}/vulnerabilities', [VulnerabilitiesController::class, 'database'])->middleware(['api.ability:read']); Route::post('/databases/{uuid}/imports/uploads', [DatabasesController::class, 'upload_import'])->middleware(['api.ability:write'])->name('api.databases.imports.upload'); Route::post('/databases/{uuid}/imports', [DatabasesController::class, 'create_import'])->middleware(['api.ability:write'])->name('api.databases.imports.store'); Route::get('/databases/{uuid}/imports/{activity_id}', [DatabasesController::class, 'show_import'])->middleware(['api.ability:read'])->whereNumber('activity_id')->name('api.databases.imports.show'); @@ -403,6 +411,7 @@ Route::group([ Route::post('/services', [ServicesController::class, 'create_service'])->middleware(['api.ability:write']); Route::get('/services/{uuid}', [ServicesController::class, 'service_by_uuid'])->middleware(['api.ability:read']); + Route::get('/services/{uuid}/vulnerabilities', [VulnerabilitiesController::class, 'service'])->middleware(['api.ability:read']); Route::patch('/services/{uuid}', [ServicesController::class, 'update_by_uuid'])->middleware(['api.ability:write']); Route::delete('/services/{uuid}', [ServicesController::class, 'delete_by_uuid'])->middleware(['api.ability:delete']);