For the email only. */ public array $why = []; /** * Keeps only scalar values, so a resource deleted before the queued send does not fail it. * * @param Model $owner The scan owner; the text names its page resource (a service for its applications). * @param list $problems The new relevant findings with an exploit signal. * `incomplete`: the version check of its scan has no result. */ public function __construct(Model $owner, array $problems) { $this->onQueue('high'); $this->count = count($problems); $this->exploited = collect($problems)->contains(fn (array $problem): bool => SecurityRules::exploitSignal($problem['finding']) === SecurityRules::EXPLOITED); $this->action = self::mainAction($problems); $this->checkIncomplete = $this->action === null && collect($problems)->contains(fn (array $problem): bool => (bool) ($problem['incomplete'] ?? false)); $this->why = SecurityStatus::topWhy( array_map(fn (array $problem): array => SecurityStatus::whyFor($problem['finding'], SecurityRules::exploitSignal($problem['finding'])), $problems), VulnerabilityDigest::MAIL_ID_LIMIT, ); $resource = ScanNotifications::pageResource($owner); $this->resourceName = (string) $resource->getAttribute('name'); $this->resourceUuid = (string) $resource->getAttribute('uuid'); $this->url = ScanNotifications::link($resource); } public function via(object $notifiable): array { return $notifiable->getEnabledChannels('image_vulnerabilities'); } /** * The badge of the status the problems give: "Update now" with an action, "Check needed" when the version check * has no result, else "No fix yet". */ public function title(): string { return match (true) { $this->action !== null => 'Update now', $this->checkIncomplete => 'Check needed', default => 'No fix yet', }; } /** * The status value of the title. */ public function status(): string { return match (true) { $this->action !== null => 'update_now', $this->checkIncomplete => 'check_needed', default => 'no_fix', }; } /** * "api: 2 problems that attackers are using. Update to 1.28." Chat and webhook channels use the text without the * count ("api: problems that …"). */ public function text(bool $withCount): string { return NotificationText::plain($this->resourceName).': '.$this->sentence($withCount); } public function toMail(?object $notifiable = null): MailMessage { return (new MailMessage) ->subject('Coolify: '.$this->title().': '.NotificationText::plain($this->resourceName)) ->view('emails.image-vulnerabilities', [ 'intro' => 'A new check found security problems that attackers are using or are likely to use.', 'resources' => [[ 'name' => NotificationText::mailMarkdown($this->resourceName), 'url' => $this->url, 'summary' => NotificationText::mailMarkdown(ucfirst($this->sentence(withCount: true))), 'why' => array_map(NotificationText::mailWhy(...), $this->why), ]], 'ignores' => [], 'servers' => [], 'url' => $this->url ?? rtrim(base_url(), '/').'/vulnerabilities', ]); } public function toDiscord(): DiscordMessage { return new DiscordMessage( title: $this->title(), description: $this->fit(fn (string $name, string $sentence): string => ($this->url === null ? NotificationText::discord($name) : '['.NotificationText::discord($name)."]({$this->url})").': '.NotificationText::discord($sentence), VulnerabilityDigest::DISCORD_LIMIT), color: DiscordMessage::errorColor(), isCritical: true, ); } public function toSlack(): SlackMessage { return new SlackMessage( title: $this->title(), description: $this->fit(fn (string $name, string $sentence): string => ($this->url === null ? NotificationText::slack($name) : "<{$this->url}|".NotificationText::slack($name).'>').': '.NotificationText::slack($sentence), VulnerabilityDigest::SLACK_LIMIT), color: SlackMessage::errorColor(), ); } public function toTelegram(): array { return [ 'message' => $this->fit(fn (string $name, string $sentence): string => 'Coolify: '.$this->title()."\n\n".$name.': '.$sentence, VulnerabilityDigest::TELEGRAM_LIMIT), 'buttons' => $this->url === null ? [] : [['text' => VulnerabilityDigest::BUTTON, 'url' => $this->url]], ]; } public function toPushover(): PushoverMessage { $button = VulnerabilityDigest::BUTTON; $buttons = $this->url === null ? [] : [['text' => $button, 'url' => $this->url]]; $buttonLength = $this->url === null ? 0 : mb_strlen(" {$button}"); return new PushoverMessage( title: $this->title(), message: $this->fit(fn (string $name, string $sentence): string => NotificationText::html($name).': '.NotificationText::html($sentence), VulnerabilityDigest::PUSHOVER_LIMIT - $buttonLength), buttons: $buttons, level: 'error', ); } public function toWebhook(): array { return [ 'success' => false, 'message' => $this->title(), 'event' => 'image_exploited_problem', 'resource_name' => $this->resourceName, 'resource_uuid' => $this->resourceUuid, 'url' => $this->url, 'status' => $this->status(), 'text' => $this->text(withCount: false), 'action' => $this->action, ]; } /** * "2 problems that attackers are using. Update to 1.28." / "a problem that attackers are likely to use, and no * fixed version exists yet." */ private function sentence(bool $withCount): string { $problems = match (true) { $withCount => $this->count.' '.($this->count === 1 ? 'problem' : 'problems'), $this->count === 1 => 'a problem', default => 'problems', }; $use = $this->exploited ? 'are using' : 'are likely to use'; return match (true) { $this->action !== null => "{$problems} that attackers {$use}. {$this->action}", $this->checkIncomplete => "{$problems} that attackers {$use}. Coolify could not check for a fixed version yet.", default => "{$problems} that attackers {$use}, and no fixed version exists yet.", }; } /** * The action that fixes the most of the problems; on a tie the first in the order of the action types. * * @param list $problems */ private static function mainAction(array $problems): ?string { $order = array_flip(array_map(fn (SecurityActionType $type): string => $type->value, SecurityActionType::cases())); $tally = []; foreach ($problems as $problem) { $action = $problem['action']; if ($action instanceof SecurityAction && $action->sentence !== '') { $tally[$action->sentence] ??= ['count' => 0, 'order' => $order[$action->type->value]]; $tally[$action->sentence]['count']++; } } if ($tally === []) { return null; } uksort($tally, fn (string $a, string $b): int => [-$tally[$a]['count'], $tally[$a]['order']] <=> [-$tally[$b]['count'], $tally[$b]['order']]); return NotificationText::plain((string) array_key_first($tally)); } /** * The text of a channel within its limit: the name and sentence are cut shorter until the escaped text fits. * * @param callable(string, string): string $build Gets the raw name and sentence, escapes them. */ private function fit(callable $build, int $budget): string { $text = ''; foreach ([[100, 600], [60, 200], [30, 80]] as [$nameLimit, $sentenceLimit]) { $text = $build(NotificationText::limit($this->resourceName, $nameLimit), NotificationText::limit($this->sentence(withCount: false), $sentenceLimit)); if (mb_strlen($text) <= $budget) { break; } } return $text; } }