$ctx Context of the error message (server). * `headline` counts the problems (email, deploy log); `headline_plain` does not (chat and webhook channels). * @return array{blocked: bool, headline: string, headline_plain: string, new: list>, carried: list>, error: ?string, scan_ran: bool, mode: string, eol: ?array{name: string, version: string}, baseline_missing: bool} */ public static function evaluate(?ImageScan $new, ?ImageScan $running, string $mode, RelevanceRules $rules, ?ImageScanErrorCode $error = null, array $ctx = [], ImageScanOnError $onScanError = ImageScanOnError::Deploy, bool $baselineMissing = false): array { $mode = ImageScanGateMode::tryFrom($mode) ?? ImageScanGateMode::Off; $completed = $new !== null && $new->status === ImageScanStatus::Completed; if ($mode === ImageScanGateMode::Off) { return self::result(scanRan: $completed && $error === null, mode: $mode); } if ($error === null && ! $completed) { $error = ImageScanErrorCode::Unknown; } // A cut report can miss the findings that block, so it counts as a scan that could not run. if ($error === null && (bool) data_get($new->summary, 'truncated', false)) { $error = ImageScanErrorCode::Truncated; } if ($error !== null) { $blocked = $onScanError === ImageScanOnError::Block; $headline = ($blocked ? 'Deploy blocked: ' : 'Scan skipped: ').$error->message($ctx); return self::result( blocked: $blocked, headline: $headline, headlinePlain: $headline, error: $error->value, mode: $mode, ); } $runningIds = $running === null ? [] : self::idSet(ResourceScans::findings($running)); $blocking = []; $carried = []; $seen = []; foreach (ResourceScans::findings($new) as $finding) { $key = mb_strtolower($finding->id); if (isset($seen[$key]) || ! SecurityRules::isRelevant($finding, $rules)) { continue; } $seen[$key] = true; $signal = SecurityRules::exploitSignal($finding); if ($mode === ImageScanGateMode::Strict) { if ($signal !== null || self::hasPackageFix($finding)) { $blocking[] = self::row($finding, $signal); } continue; } if ($signal === null) { continue; } if ($baselineMissing || self::inSet($finding, $runningIds)) { $carried[] = self::row($finding, $signal); } else { $blocking[] = self::row($finding, $signal); } } $eol = null; $eolDistro = data_get($new->summary, 'eol_distro'); if ($mode === ImageScanGateMode::Strict && is_array($eolDistro)) { $eol = [ 'name' => ScannerText::clean($eolDistro['name'] ?? '', 64), 'version' => ScannerText::clean($eolDistro['version'] ?? '', 32), ]; } $blocking = self::sorted($blocking); $blocked = $blocking !== [] || $eol !== null; return self::result( blocked: $blocked, headline: $blocked ? self::headline($blocking, $eol) : '', headlinePlain: $blocked ? self::headline($blocking, $eol, counted: false) : '', new: $blocking, carried: self::sorted($carried), scanRan: true, mode: $mode, eol: $eol, baselineMissing: $baselineMissing && $mode === ImageScanGateMode::On, ); } /** * The deploy log lines of a result: the headline and the blocking problems, or "passed", then the problems with * an exploit signal that the running image has too. * * @param array $result * @return list */ public static function logLines(array $result): array { $lines = []; $error = $result['error'] ?? null; $headline = (string) ($result['headline'] ?? ''); if ($error !== null) { return $headline === '' ? [] : [$headline]; } if (! ($result['scan_ran'] ?? false)) { return []; } if ($result['blocked'] ?? false) { $lines[] = $headline; $lines = [...$lines, ...self::rowLines((array) ($result['new'] ?? []), null)]; $eol = $result['eol'] ?? null; if (is_array($eol)) { $lines[] = ' '.trim('This image uses '.ucfirst((string) $eol['name']).' '.$eol['version']).', which no longer gets security fixes.'; } $lines[] = 'See the Security page for all problems.'; } else { $lines[] = 'Image scan: passed.'; } $baselineMissing = (bool) ($result['baseline_missing'] ?? false); if ($baselineMissing) { $lines[] = 'Coolify has no earlier scan of this app to compare with. This deploy becomes the baseline.'; } $carried = (array) ($result['carried'] ?? []); if ($carried !== []) { $use = collect($carried)->contains(fn (array $row): bool => $row['signal'] === SecurityRules::EXPLOITED) ? 'are using' : 'are likely to use'; $lines[] = ($baselineMissing ? 'This image has ' : 'Already in the running image: ').self::problems(count($carried))." that attackers {$use}."; $lines = [...$lines, ...self::rowLines($carried, $baselineMissing ? null : 'already running')]; } return $lines; } /** * The result as it is stored on the deployment (`scan_gate_result`): the surfaces read `blocked`, `scan_ran`, * `error`, `count`, `headline`, and `top` (the blocking problems). * * @param array $result * @return array */ public static function stored(array $result): array { $new = (array) ($result['new'] ?? []); return [ 'blocked' => (bool) ($result['blocked'] ?? false), 'scan_ran' => (bool) ($result['scan_ran'] ?? false), 'error' => $result['error'] ?? null, 'mode' => (string) ($result['mode'] ?? ImageScanGateMode::Off->value), 'count' => count($new) + (is_array($result['eol'] ?? null) ? 1 : 0), 'headline' => (string) ($result['headline'] ?? ''), 'headline_plain' => (string) ($result['headline_plain'] ?? ''), 'top' => array_slice($new, 0, self::LOG_LIMIT), 'carried' => array_slice((array) ($result['carried'] ?? []), 0, self::LOG_LIMIT), 'carried_count' => count((array) ($result['carried'] ?? [])), 'baseline_missing' => (bool) ($result['baseline_missing'] ?? false), ]; } /** * @param list> $blocking * @param array{name: string, version: string}|null $eol */ private static function headline(array $blocking, ?array $eol, bool $counted = true): string { $signal = array_values(array_filter($blocking, fn (array $row): bool => $row['signal'] !== null)); if ($signal !== []) { $use = collect($signal)->contains(fn (array $row): bool => $row['signal'] === SecurityRules::EXPLOITED) ? 'are using' : 'are likely to use'; return $counted ? 'Deploy blocked: this image has '.self::problems(count($signal))." that attackers {$use}." : "Deploy blocked: this image has security problems that attackers {$use}."; } if (! $counted) { return 'Deploy blocked: this image needs a security update.'; } $count = count($blocking) + ($eol !== null ? 1 : 0); return 'Deploy blocked: this image has '.self::problems($count).' that '.($count === 1 ? 'needs' : 'need').' a security update.'; } private static function problems(int $count): string { return $count > 1 ? "{$count} security problems" : 'a security problem'; } /** * @param list> $rows * @return list */ private static function rowLines(array $rows, ?string $suffix): array { $lines = []; foreach (array_slice($rows, 0, self::LOG_LIMIT) as $row) { $score = is_numeric($row['score'] ?? null) ? number_format((float) $row['score'], 1).' ' : ''; $note = $suffix ?? match ($row['signal'] ?? null) { SecurityRules::EXPLOITED => 'used by attackers', SecurityRules::LIKELY => 'likely to be exploited', default => filled($row['fixed_in'] ?? null) ? 'fixed in '.$row['fixed_in'] : '', }; $lines[] = rtrim(" {$row['display_id']} {$score}".ucfirst((string) $row['severity'])." {$row['package']} {$row['version']} {$note}"); } if (count($rows) > self::LOG_LIMIT) { $lines[] = ' and '.(count($rows) - self::LOG_LIMIT).' more'; } return $lines; } private static function hasPackageFix(Finding $finding): bool { return $finding->fixState === 'fixed' && filled($finding->fixedIn); } /** * @param list $findings * @return array */ private static function idSet(array $findings): array { $ids = []; foreach ($findings as $finding) { foreach ([$finding->id, ...$finding->aliases] as $id) { if ($id !== '') { $ids[mb_strtolower($id)] = true; } } } return $ids; } /** * @param array $ids */ private static function inSet(Finding $finding, array $ids): bool { foreach ([$finding->id, ...$finding->aliases] as $id) { if ($id !== '' && isset($ids[mb_strtolower($id)])) { return true; } } return false; } /** * @return array{id: string, display_id: string, severity: string, score: ?float, package: string, version: string, fixed_in: ?string, kev: bool, signal: ?string} */ private static function row(Finding $finding, ?string $signal): array { return [ 'id' => ScannerText::clean($finding->id, 128), 'display_id' => ScannerText::clean($finding->displayId, 128), 'severity' => strtolower(ScannerText::clean($finding->severity, 16)), 'score' => $finding->cvssScore, 'package' => ScannerText::clean($finding->package, 200), 'version' => ScannerText::clean($finding->version, 100), 'fixed_in' => $finding->fixedIn === null ? null : ScannerText::clean($finding->fixedIn, 100), 'kev' => $finding->kev, 'signal' => $signal, ]; } /** * Exploited first, then likely, then the score. * * @param list> $rows * @return list> */ private static function sorted(array $rows): array { $rank = fn (?string $signal): int => match ($signal) { SecurityRules::EXPLOITED => 2, SecurityRules::LIKELY => 1, default => 0, }; $severity = ['critical' => 2, 'high' => 1]; usort($rows, fn (array $a, array $b): int => [$rank($b['signal']), $b['score'] ?? -1.0, $severity[$b['severity']] ?? 0] <=> [$rank($a['signal']), $a['score'] ?? -1.0, $severity[$a['severity']] ?? 0]); return $rows; } /** * @param list> $new * @param list> $carried * @param array{name: string, version: string}|null $eol * @return array{blocked: bool, headline: string, headline_plain: string, new: list>, carried: list>, error: ?string, scan_ran: bool, mode: string, eol: ?array{name: string, version: string}, baseline_missing: bool} */ private static function result(bool $blocked = false, string $headline = '', string $headlinePlain = '', array $new = [], array $carried = [], ?string $error = null, bool $scanRan = false, ImageScanGateMode $mode = ImageScanGateMode::Off, ?array $eol = null, bool $baselineMissing = false): array { return [ 'blocked' => $blocked, 'headline' => $headline, 'headline_plain' => $headlinePlain, 'new' => $new, 'carried' => $carried, 'error' => $error, 'scan_ran' => $scanRan, 'mode' => $mode->value, 'eol' => $eol, 'baseline_missing' => $baselineMissing, ]; } }