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

104 lines
3.6 KiB
PHP

<?php
namespace App\Notifications\ImageScan;
use App\Services\ImageScan\ResourceScans;
use App\Services\ImageScan\SecurityRules;
/**
* Escapes values for each notification channel. Names, image refs, and tags come from users and registries.
*/
final class NotificationText
{
public static function plain(?string $value): string
{
return trim(preg_replace('/[\p{Cc}\p{Cf}]+/u', ' ', (string) $value) ?? '');
}
/** Discord markdown. */
public static function discord(?string $value): string
{
return preg_replace('/([\\\\*_~`|>\[\]()#])/u', '\\\\$1', self::plain($value)) ?? '';
}
/** Slack mrkdwn: only &, <, and > have a meaning that escaping can stop. */
public static function slack(?string $value): string
{
return str_replace(['&', '<', '>'], ['&amp;', '&lt;', '&gt;'], self::plain($value));
}
/** Pushover with `html=1`. */
public static function html(?string $value): string
{
return htmlspecialchars(self::plain($value), ENT_QUOTES | ENT_HTML5, 'UTF-8');
}
/** Markdown of the email body; Blade escapes the HTML. */
public static function mailMarkdown(?string $value): string
{
return preg_replace('/([\\\\`*_{}\[\]()#+\-.!|~=])/u', '\\\\$1', self::plain($value)) ?? '';
}
/**
* One row of a why-list in the email, as escaped Markdown: "Title ([CVE-2025-1094](https://nvd…), 8.1 High). Used
* by attackers." The ID links only to the advisory Coolify builds for it.
*
* @param array{title: string, display_id: string, score: ?float, severity: string, signal: ?string} $entry
*/
public static function mailWhy(array $entry): string
{
$id = (string) $entry['display_id'];
$link = ResourceScans::advisoryUrl($id);
$reference = $link === null ? self::mailMarkdown($id) : '['.self::mailMarkdown($id)."]({$link})";
$severity = ucfirst(strtolower(self::plain($entry['severity'])));
$score = $entry['score'] === null ? $severity : number_format((float) $entry['score'], 1).' '.$severity;
$label = match ($entry['signal']) {
SecurityRules::EXPLOITED => ' Used by attackers.',
SecurityRules::LIKELY => ' Likely to be exploited.',
default => '',
};
return self::mailMarkdown(self::limit($entry['title'], 200))." ({$reference}, ".self::mailMarkdown($score).').'.$label;
}
/**
* Cuts a raw value before it is escaped, so one long name cannot fill a message.
*/
public static function limit(?string $value, int $max): string
{
$value = self::plain($value);
return mb_strlen($value) > $max ? rtrim(mb_substr($value, 0, $max - 1)).'…' : $value;
}
/**
* The lines that fit in $budget characters, joined with new lines, then "And N more." for the rest.
*
* @param list<string> $lines
* @return list<string>
*/
public static function fitLines(array $lines, int $budget): array
{
$kept = [];
$used = 0;
$count = count($lines);
foreach ($lines as $index => $line) {
$remaining = $count - $index - 1;
$reserve = $remaining > 0 ? mb_strlen("\nAnd {$remaining} more.") : 0;
$length = mb_strlen($line) + ($kept === [] ? 0 : 1);
if ($used + $length + $reserve > $budget) {
break;
}
$kept[] = $line;
$used += $length;
}
$more = $count - count($kept);
if ($more > 0) {
$kept[] = "And {$more} more.";
}
return $kept;
}
}