Files
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

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("&nbsp;<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;
}
}