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

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