Files
coolify/app/Jobs/ImageScanDigestJob.php
033f0c84da v4.7 (#12478)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: peaklabs-dev <122374094+peaklabs-dev@users.noreply.github.com>
Co-authored-by: Aditya Tripathi <aditya@climactic.co>
2026-11-06 14:25:02 +01:00

252 lines
11 KiB
PHP

<?php
namespace App\Jobs;
use App\Enums\SecurityStatusType;
use App\Models\ImageScan;
use App\Models\ImageScanIgnore;
use App\Models\Server;
use App\Models\Team;
use App\Notifications\ImageScan\VulnerabilityDigest;
use App\Services\ImageScan\ResourceSecurity;
use App\Services\ImageScan\ScanNotifications;
use App\Services\ImageScan\SecuritySentences;
use App\Services\ImageScan\SecurityStatus;
use App\Services\ImageScan\TeamVulnerabilities;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Throwable;
/**
* The daily security digest of one team (spec 15.8): resources that became update_available (new problems with an
* action, or a new fixing version) or check_needed, ignores that end within 7 days, and servers whose rescans failed
* 2 days in a row. Sends one VulnerabilityDigest only when something changed, then records what it covered in the
* notification state of each scan owner (see ScanNotifications). An ignore is named once per end date (cache); a
* failing server once per run of failures.
*
* A resource whose status is check_needed only because its version check did not finish yet (right after a scan) is
* skipped until the check finished. While a resource needs a check, its new problems and fixing versions wait for
* the next status, at most MAX_HELD_DIGESTS digests; then they are named with "Check needed. {primary action}". Fixes of
* problems with an exploit signal never wait: they are named at once the same way.
*/
class ImageScanDigestJob implements ShouldBeEncrypted, ShouldBeUnique, ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public const IGNORE_WINDOW_DAYS = 7;
/** While a resource needs a check, its new problems without an exploit signal wait at most this many digests. */
public const MAX_HELD_DIGESTS = 3;
public $tries = 1;
public $timeout = 3600;
public $uniqueFor = 3600;
public function __construct(public Team $team)
{
$this->onQueue(isCloud() ? maintenance_queue() : 'default');
}
public function uniqueId(): string
{
return (string) $this->team->id;
}
public function handle(): void
{
if (! instanceSettings()->is_image_scanning_allowed) {
return;
}
/** @var list<array<string, mixed>> $marks */
$marks = [];
$resources = [];
foreach (TeamVulnerabilities::load($this->team->id)->entries as $entry) {
if (! $entry['scanned']) {
continue;
}
try {
$resource = $this->resourceDigest($entry['resource'], $entry['scans'], $entry['security'], $marks);
} catch (Throwable $e) {
report($e);
continue;
}
if ($resource !== null) {
$resources[] = $resource;
}
}
$ignores = $this->endingIgnores();
$servers = $this->failingServers();
if ($resources !== [] || $ignores->isNotEmpty() || $servers->isNotEmpty()) {
$order = array_flip(TeamVulnerabilities::STATUS_ORDER);
usort($resources, fn (array $a, array $b): int => [$order[$a['status']] ?? 99, $a['name']] <=> [$order[$b['status']] ?? 99, $b['name']]);
$this->team->notify(new VulnerabilityDigest([
'resources' => $resources,
'ignores' => $ignores->map(fn (ImageScanIgnore $ignore): array => [
'resource' => $ignore->resource instanceof Model ? (string) ScanNotifications::pageResource($ignore->resource)->getAttribute('name') : null,
'id' => $ignore->vulnerability_id,
'package' => $ignore->package,
'expires_at' => $ignore->expires_at->toDateString(),
])->values()->all(),
'servers' => $servers->map(fn (Server $server): array => [
'name' => (string) $server->name,
'url' => rtrim(base_url(), '/').'/server/'.$server->uuid,
])->values()->all(),
]));
foreach ($ignores as $ignore) {
Cache::put(self::ignoreCacheKey($ignore), $ignore->expires_at->toIso8601String(), $ignore->expires_at->copy()->addDay());
}
foreach ($servers as $server) {
ImageRescanJob::markFailureNotified($server);
}
}
foreach ($marks as $mark) {
try {
ScanNotifications::applyDigest($mark);
} catch (Throwable $e) {
report($e);
}
}
}
public static function ignoreCacheKey(ImageScanIgnore $ignore): string
{
return "image-scan-digest:ignore:{$ignore->id}";
}
/**
* The digest line of one resource page (a service covers its applications and databases), or null. Each scan
* owner has its own notification state; its current scans are decoded one owner at a time.
*
* @param Collection<int, ImageScan> $current The current scans, with their owner.
* @param list<array<string, mixed>> $marks Gets the marks of the resource only when its digest succeeded.
* @return array{name: string, uuid: string, url: ?string, status: string, badge: string, action: ?string, why: list<array<string, mixed>>}|null
*/
private function resourceDigest(Model $resource, Collection $current, ResourceSecurity $security, array &$marks): ?array
{
$resourceMarks = [];
$digest = $this->buildResourceDigest($resource, $current, $security, $resourceMarks);
array_push($marks, ...$resourceMarks);
return $digest;
}
/**
* @param Collection<int, ImageScan> $current
* @param list<array<string, mixed>> $marks
* @return array{name: string, uuid: string, url: ?string, status: string, badge: string, action: ?string, why: list<array<string, mixed>>}|null
*/
private function buildResourceDigest(Model $resource, Collection $current, ResourceSecurity $security, array &$marks): ?array
{
$owners = $current->map(fn (ImageScan $scan) => $scan->resource)
->filter(fn ($owner): bool => $owner instanceof Model)
->unique(fn (Model $owner): string => $owner->getMorphClass().':'.$owner->getKey());
$plans = $owners->map(fn (Model $owner): ?array => ScanNotifications::digestPlan($owner))->filter()->values();
if ($plans->isEmpty()) {
return null;
}
$needsCheck = $security->status === SecurityStatusType::CheckNeeded;
if ($needsCheck && $security->checkReason === SecurityStatus::REASON_VERSIONS_UNCHECKED && $plans->contains('pending', true)) {
return null;
}
$active = $plans->where('baseline', false);
$holds = fn (array $plan): bool => count($plan['newFixable']) > count($plan['newSignalFixable']) || count($plan['newCandidates']) > count($plan['newSignalCandidates']);
// Fixes of problems with an exploit signal are never held; other new problems are held at most MAX_HELD_DIGESTS digests.
$bypass = $needsCheck && $active->contains(fn (array $plan): bool => $plan['newSignalFixable'] !== [] || $plan['newSignalCandidates'] !== []);
$release = $needsCheck && $active->contains(fn (array $plan): bool => $plan['heldDigests'] >= self::MAX_HELD_DIGESTS) && $active->contains($holds);
$report = match ($security->status) {
SecurityStatusType::UpdateNow, SecurityStatusType::UpdateAvailable => $active->contains(fn (array $plan): bool => $plan['newFixable'] !== [] || $plan['newCandidates'] !== []),
SecurityStatusType::CheckNeeded => $active->isNotEmpty() && ($bypass || $release || ! $active->contains('checkReported', true)),
default => false,
};
foreach ($plans as $plan) {
$mark = $plan['mark'];
$mark['check_reported'] = $needsCheck;
$mark['held_digests'] = 0;
if ($needsCheck && ! $plan['baseline'] && ! $release) {
$mark['add_digest_ids'] = array_values(array_unique(array_map(fn ($finding): string => $finding->id, $plan['newSignalFixable'])));
$mark['add_candidates'] = $plan['newSignalCandidates'];
$mark['held_digests'] = $holds($plan) ? $plan['heldDigests'] + 1 : 0;
}
$marks[] = $mark;
}
if (! $report) {
return null;
}
$action = TeamVulnerabilities::action($security)['text'] ?? null;
if ($needsCheck && ($bypass || $release) && filled($security->primary()?->sentence)) {
$action = (string) $security->primary()->sentence;
}
return [
'name' => (string) $resource->getAttribute('name'),
'uuid' => (string) $resource->getAttribute('uuid'),
'url' => ScanNotifications::link($resource),
'status' => $security->status->value,
'badge' => (string) SecuritySentences::badge($security->status),
'action' => $action,
'why' => $needsCheck && ! $bypass && ! $release ? [] : $security->why,
];
}
/**
* Ignores of the team that end within IGNORE_WINDOW_DAYS and no digest named with this end date.
*
* @return Collection<int, ImageScanIgnore>
*/
private function endingIgnores(): Collection
{
return ImageScanIgnore::query()
->where('team_id', $this->team->id)
->whereNotNull('expires_at')
->where('expires_at', '>', now())
->where('expires_at', '<=', now()->addDays(self::IGNORE_WINDOW_DAYS))
->orderBy('expires_at')
->get()
->reject(fn (ImageScanIgnore $ignore): bool => Cache::get(self::ignoreCacheKey($ignore)) === $ignore->expires_at->toIso8601String())
->values();
}
/**
* Servers of the team that still scan images and whose daily rescan failed 2 days in a row, not named yet for
* this run of failures.
*
* @return Collection<int, Server>
*/
private function failingServers(): Collection
{
return Server::query()
->where('team_id', $this->team->id)
->with('settings')
->get()
->filter(fn (Server $server): bool => (bool) $server->settings?->is_image_scan_enabled
&& ! $server->isTransferredAway()
&& ImageRescanJob::failedDays($server) >= 2
&& ! ImageRescanJob::isFailureNotified($server))
->values();
}
}