mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-06 13:10:37 -04:00
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>
251 lines
10 KiB
PHP
251 lines
10 KiB
PHP
<?php
|
|
|
|
namespace App\Notifications\ImageScan;
|
|
|
|
use App\Enums\SecurityActionType;
|
|
use App\Notifications\CustomEmailNotification;
|
|
use App\Notifications\Dto\DiscordMessage;
|
|
use App\Notifications\Dto\PushoverMessage;
|
|
use App\Notifications\Dto\SlackMessage;
|
|
use App\Services\ImageScan\Finding;
|
|
use App\Services\ImageScan\ScanNotifications;
|
|
use App\Services\ImageScan\SecurityAction;
|
|
use App\Services\ImageScan\SecurityRules;
|
|
use App\Services\ImageScan\SecurityStatus;
|
|
use Illuminate\Database\Eloquent\Model;
|
|
use Illuminate\Notifications\Messages\MailMessage;
|
|
|
|
/**
|
|
* Sent right after a scan found new relevant problems with an exploit signal (spec 15.8): "api: 2 problems that
|
|
* attackers are using. Update to 1.28.", "…, and no fixed version exists yet.", or, when the version check has no
|
|
* result, "… Coolify could not check for a fixed version yet." Chat and webhook channels get the text without the
|
|
* count and never vulnerability IDs; the email lists the problems (at most VulnerabilityDigest::MAIL_ID_LIMIT).
|
|
*/
|
|
class ExploitedProblemFound extends CustomEmailNotification
|
|
{
|
|
public string $resourceName = '';
|
|
|
|
public string $resourceUuid = '';
|
|
|
|
public ?string $url = null;
|
|
|
|
public int $count;
|
|
|
|
/** At least one of the problems is in CISA KEV ("attackers are using"), else "attackers are likely to use". */
|
|
public bool $exploited;
|
|
|
|
/** The sentence of the action that fixes the most of the problems; null when none has an action. */
|
|
public ?string $action = null;
|
|
|
|
/** No problem has an action and the version check of one has no result, so Coolify cannot say that no fix exists. */
|
|
public bool $checkIncomplete = false;
|
|
|
|
/** @var list<array{title: string, display_id: string, link: ?string, score: ?float, severity: string, signal: ?string}> 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<array{finding: Finding, action: ?SecurityAction, incomplete?: bool}> $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(" <a href='{$this->url}'>{$button}</a>");
|
|
|
|
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<array{finding: Finding, action: ?SecurityAction}> $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;
|
|
}
|
|
}
|