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>
237 lines
9.4 KiB
PHP
237 lines
9.4 KiB
PHP
<?php
|
|
|
|
namespace App\Notifications\ImageScan;
|
|
|
|
use App\Notifications\CustomEmailNotification;
|
|
use App\Notifications\Dto\DiscordMessage;
|
|
use App\Notifications\Dto\PushoverMessage;
|
|
use App\Notifications\Dto\SlackMessage;
|
|
use Illuminate\Notifications\Messages\MailMessage;
|
|
|
|
/**
|
|
* The daily security digest of a team (spec 15.8): resources that became update_available or check_needed, or got a
|
|
* new fixing version, with their status, the primary action, and a link; ignores that end soon; failing servers.
|
|
* Chat and webhook channels get no vulnerability IDs and no counts; the email adds the why-list of each resource (at
|
|
* most MAIL_ID_LIMIT rows in total).
|
|
*/
|
|
class VulnerabilityDigest extends CustomEmailNotification
|
|
{
|
|
public const TITLE = 'Daily security digest';
|
|
|
|
public const BUTTON = 'Open security status';
|
|
|
|
public const MAIL_ID_LIMIT = 10;
|
|
|
|
/** Text limits: Discord embed description, Telegram message, Slack section text, Pushover message. */
|
|
public const DISCORD_LIMIT = 4096;
|
|
|
|
public const TELEGRAM_LIMIT = 4096;
|
|
|
|
public const SLACK_LIMIT = 3000;
|
|
|
|
public const PUSHOVER_LIMIT = 1024;
|
|
|
|
private const NAME_LIMIT = 100;
|
|
|
|
private const SENTENCE_LIMIT = 500;
|
|
|
|
public string $teamUrl;
|
|
|
|
/**
|
|
* @param array{resources: list<array{name: string, uuid: string, url: ?string, status: string, badge: string, action: ?string, why: list<array{title: string, display_id: string, link: ?string, score: ?float, severity: string, signal: ?string}>}>, ignores: list<array{resource: ?string, id: ?string, package: ?string, expires_at: string}>, servers: list<array{name: string, url: string}>} $digest
|
|
*/
|
|
public function __construct(public array $digest)
|
|
{
|
|
$this->onQueue('high');
|
|
$this->teamUrl = rtrim(base_url(), '/').'/vulnerabilities';
|
|
}
|
|
|
|
public function via(object $notifiable): array
|
|
{
|
|
return $notifiable->getEnabledChannels('image_vulnerabilities');
|
|
}
|
|
|
|
public function toMail(?object $notifiable = null): MailMessage
|
|
{
|
|
$budget = self::MAIL_ID_LIMIT;
|
|
$resources = array_map(function (array $resource) use (&$budget): array {
|
|
$why = array_slice($resource['why'], 0, max(0, $budget));
|
|
$budget -= count($why);
|
|
|
|
return [
|
|
'name' => NotificationText::mailMarkdown($resource['name']),
|
|
'url' => $resource['url'],
|
|
'summary' => NotificationText::mailMarkdown($this->resourceSentence($resource)),
|
|
'why' => array_map(NotificationText::mailWhy(...), $why),
|
|
];
|
|
}, $this->digest['resources']);
|
|
|
|
$ignores = array_map(function (array $ignore) use (&$budget): array {
|
|
$id = $ignore['id'];
|
|
if ($id !== null && $budget > 0) {
|
|
$budget--;
|
|
} elseif ($id !== null) {
|
|
$id = 'A vulnerability';
|
|
}
|
|
$subject = match (true) {
|
|
$id !== null && $ignore['package'] !== null => "{$id} in {$ignore['package']}",
|
|
$id !== null => $id,
|
|
default => "Package {$ignore['package']}",
|
|
};
|
|
|
|
return [
|
|
'subject' => NotificationText::mailMarkdown($subject),
|
|
'resource' => $ignore['resource'] === null ? null : NotificationText::mailMarkdown($ignore['resource']),
|
|
'expires_at' => $ignore['expires_at'],
|
|
];
|
|
}, $this->digest['ignores']);
|
|
|
|
return (new MailMessage)
|
|
->subject('Coolify: '.self::TITLE)
|
|
->view('emails.image-vulnerabilities', [
|
|
'intro' => 'Changes in the security status of your resources since the last digest.',
|
|
'resources' => $resources,
|
|
'ignores' => $ignores,
|
|
'servers' => array_map(fn (array $server): array => ['name' => NotificationText::mailMarkdown($server['name']), 'url' => $server['url']], $this->digest['servers']),
|
|
'url' => $this->teamUrl,
|
|
]);
|
|
}
|
|
|
|
public function toDiscord(): DiscordMessage
|
|
{
|
|
$suffix = "\n\n[".self::BUTTON."]({$this->teamUrl})";
|
|
$lines = $this->chatLines(
|
|
fn (array $resource): string => '• '.($resource['url'] === null
|
|
? NotificationText::discord($this->name($resource))
|
|
: '['.NotificationText::discord($this->name($resource)).']('.$resource['url'].')')
|
|
.': '.NotificationText::discord($this->resourceSentence($resource)),
|
|
NotificationText::discord(...),
|
|
self::DISCORD_LIMIT - mb_strlen($suffix),
|
|
);
|
|
|
|
return new DiscordMessage(
|
|
title: self::TITLE,
|
|
description: implode("\n", $lines).$suffix,
|
|
color: DiscordMessage::warningColor(),
|
|
);
|
|
}
|
|
|
|
public function toSlack(): SlackMessage
|
|
{
|
|
$suffix = "\n\n<{$this->teamUrl}|".self::BUTTON.'>';
|
|
$lines = $this->chatLines(
|
|
fn (array $resource): string => '• '.($resource['url'] === null
|
|
? NotificationText::slack($this->name($resource))
|
|
: '<'.$resource['url'].'|'.NotificationText::slack($this->name($resource)).'>')
|
|
.': '.NotificationText::slack($this->resourceSentence($resource)),
|
|
NotificationText::slack(...),
|
|
self::SLACK_LIMIT - mb_strlen($suffix),
|
|
);
|
|
|
|
return new SlackMessage(
|
|
title: self::TITLE,
|
|
description: implode("\n", $lines).$suffix,
|
|
color: SlackMessage::warningColor(),
|
|
);
|
|
}
|
|
|
|
public function toTelegram(): array
|
|
{
|
|
$prefix = 'Coolify: '.self::TITLE."\n\n";
|
|
$lines = $this->chatLines(
|
|
fn (array $resource): string => '• '.NotificationText::plain($this->name($resource)).': '.NotificationText::plain($this->resourceSentence($resource))
|
|
.($resource['url'] === null ? '' : "\n ".$resource['url']),
|
|
NotificationText::plain(...),
|
|
self::TELEGRAM_LIMIT - mb_strlen($prefix),
|
|
);
|
|
|
|
return [
|
|
'message' => $prefix.implode("\n", $lines),
|
|
'buttons' => [['text' => self::BUTTON, 'url' => $this->teamUrl]],
|
|
];
|
|
}
|
|
|
|
public function toPushover(): PushoverMessage
|
|
{
|
|
$button = ['text' => self::BUTTON, 'url' => $this->teamUrl];
|
|
// PushoverMessage appends each button to the message as a link.
|
|
$buttonLength = mb_strlen(" <a href='{$button['url']}'>{$button['text']}</a>");
|
|
$lines = $this->chatLines(
|
|
fn (array $resource): string => '• '.($resource['url'] === null
|
|
? NotificationText::html($this->name($resource))
|
|
: "<a href='".NotificationText::html($resource['url'])."'>".NotificationText::html($this->name($resource)).'</a>')
|
|
.': '.NotificationText::html($this->resourceSentence($resource)),
|
|
NotificationText::html(...),
|
|
self::PUSHOVER_LIMIT - $buttonLength,
|
|
);
|
|
|
|
return new PushoverMessage(
|
|
title: self::TITLE,
|
|
message: implode("\n", $lines),
|
|
buttons: [$button],
|
|
level: 'warning',
|
|
);
|
|
}
|
|
|
|
public function toWebhook(): array
|
|
{
|
|
return [
|
|
'success' => false,
|
|
'message' => self::TITLE,
|
|
'event' => 'image_vulnerabilities_digest',
|
|
'url' => $this->teamUrl,
|
|
'resources' => array_map(fn (array $resource): array => [
|
|
'name' => $resource['name'],
|
|
'uuid' => $resource['uuid'],
|
|
'url' => $resource['url'],
|
|
'status' => $resource['status'],
|
|
'action' => $resource['action'],
|
|
], $this->digest['resources']),
|
|
'ignores_ending' => $this->digest['ignores'] !== [],
|
|
'servers_failing' => array_map(fn (array $server): array => ['name' => $server['name'], 'url' => $server['url']], $this->digest['servers']),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* The lines of a chat message that fit in $budget characters: resources, then ignores and failing servers,
|
|
* without IDs or counts, then "And N more." for the rest.
|
|
*
|
|
* @param callable(array<string, mixed>): string $resourceLine
|
|
* @param callable(string): string $escape
|
|
* @return list<string>
|
|
*/
|
|
private function chatLines(callable $resourceLine, callable $escape, int $budget): array
|
|
{
|
|
$lines = array_map($resourceLine, $this->digest['resources']);
|
|
|
|
$ignores = count($this->digest['ignores']);
|
|
if ($ignores > 0) {
|
|
$lines[] = $ignores === 1 ? 'An ignore ends within 7 days.' : 'Some ignores end within 7 days.';
|
|
}
|
|
|
|
foreach ($this->digest['servers'] as $server) {
|
|
$lines[] = $escape('Image scans failed for 2 days on '.NotificationText::limit($server['name'], self::NAME_LIMIT).'.');
|
|
}
|
|
|
|
return NotificationText::fitLines($lines, $budget);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $resource
|
|
*/
|
|
private function name(array $resource): string
|
|
{
|
|
return NotificationText::limit($resource['name'], self::NAME_LIMIT);
|
|
}
|
|
|
|
/**
|
|
* "Security update. Update to 1.28." / "Check needed. Check again: the last check was 3 days ago."
|
|
*
|
|
* @param array<string, mixed> $resource
|
|
*/
|
|
private function resourceSentence(array $resource): string
|
|
{
|
|
return NotificationText::limit(trim($resource['badge'].'. '.($resource['action'] ?? '')), self::SENTENCE_LIMIT);
|
|
}
|
|
}
|