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>
188 lines
8.8 KiB
PHP
188 lines
8.8 KiB
PHP
<?php
|
|
|
|
namespace App\Services\ImageScan;
|
|
|
|
use App\Enums\SecurityStatusType;
|
|
use Carbon\CarbonImmutable;
|
|
use Throwable;
|
|
|
|
/**
|
|
* The security status of one image or one resource (spec 15.6), with what the main card shows (15.7).
|
|
*
|
|
* Counts follow the status the actions give (the status without the check reasons):
|
|
* - update_now: `problemCount` is the relevant findings with an exploit signal that have an action, `fixedByPrimary`
|
|
* how many of them the primary action fixes, `anyExploited` whether one of them is `exploited`.
|
|
* - update_available: `problemCount` is the relevant critical, high, and medium findings that have an action
|
|
* (`criticalCount` + `highCount` + `mediumCount`), `fixedByPrimary` how many of them the primary action fixes.
|
|
* - no_fix: `problemCount` is the relevant findings with an exploit signal without an action, `anyExploited` whether
|
|
* one of them is `exploited`.
|
|
*
|
|
* `exploitedCount` is all relevant findings with an exploit signal, with or without an action; so
|
|
* `exploitedCount - problemCount` of update_now (or `exploitedCount` of update_available) have no fix yet.
|
|
* `primaryCritical`, `primaryHigh`, and `primaryMedium` are the critical, high, and medium fixes of the primary action.
|
|
*
|
|
* An action that leaves an end-of-life OS counts it as one critical relevant fix (`problemCount`, `criticalCount`,
|
|
* `fixedByPrimary`, `primaryCritical`); `eol.fixed` says so. It is not in `why` or `moreFixed`.
|
|
*
|
|
* `builderImage`: every no_fix finding is an OS package with a fix in an image that a builder (Nixpacks, Railpack,
|
|
* static) builds, so a builder update fixes it and the no_fix text says so.
|
|
*
|
|
* `checkedAt` is the time of the oldest current scan of the resource (null when one has no time); `stale` uses it.
|
|
*
|
|
* `checkReason` is the check_needed reason (`images_unchecked` {checked, total}, `stale` {time}, `db_outdated`,
|
|
* `image_changed`, `versions_unchecked`, `truncated`; context in `checkContext`), or the not_checked reason (`instance`, `server`,
|
|
* `application` for scanning off, `not_scanned`).
|
|
*
|
|
* `why` lists up to 3 findings the primary action fixes (no_fix: the findings with an exploit signal that have no
|
|
* action); `moreFixed` is the other relevant findings the primary action fixes.
|
|
*
|
|
* `facts` is what `SecurityStatus` needs to combine the statuses of several images without the findings: the status
|
|
* the actions give, the check reasons that do not depend on the time, counts, and per action (in the order of
|
|
* `actions`) what it fixes and its why-list.
|
|
*/
|
|
final readonly class ResourceSecurity
|
|
{
|
|
/**
|
|
* @param array<string, int|string|null> $checkContext
|
|
* @param list<SecurityAction> $actions The primary action first.
|
|
* @param list<array{title: string, display_id: string, link: ?string, score: ?float, severity: string, signal: ?string, package: string, version: string}> $why
|
|
* @param array{name: string, version: string, fixed: bool}|null $eol The end-of-life OS, and whether an action leaves it.
|
|
* @param array<string, mixed> $facts
|
|
*/
|
|
public function __construct(
|
|
public SecurityStatusType $status,
|
|
public ?string $checkReason = null,
|
|
public array $checkContext = [],
|
|
public array $actions = [],
|
|
public array $why = [],
|
|
public int $moreFixed = 0,
|
|
public bool $exploitedWithoutFix = false,
|
|
public int $problemCount = 0,
|
|
public int $exploitedCount = 0,
|
|
public bool $anyExploited = false,
|
|
public int $criticalCount = 0,
|
|
public int $highCount = 0,
|
|
public int $fixedByPrimary = 0,
|
|
public int $primaryCritical = 0,
|
|
public int $primaryHigh = 0,
|
|
public ?array $eol = null,
|
|
public ?CarbonImmutable $checkedAt = null,
|
|
public array $facts = [],
|
|
public bool $builderImage = false,
|
|
public int $mediumCount = 0,
|
|
public int $primaryMedium = 0,
|
|
) {}
|
|
|
|
public function primary(): ?SecurityAction
|
|
{
|
|
return $this->actions[0] ?? null;
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
public function toArray(): array
|
|
{
|
|
return [
|
|
'status' => $this->status->value,
|
|
'check_reason' => $this->checkReason,
|
|
'check_context' => $this->checkContext,
|
|
'actions' => array_map(fn (SecurityAction $action): array => $action->toArray(), $this->actions),
|
|
'why' => $this->why,
|
|
'more_fixed' => $this->moreFixed,
|
|
'exploited_without_fix' => $this->exploitedWithoutFix,
|
|
'problem_count' => $this->problemCount,
|
|
'exploited_count' => $this->exploitedCount,
|
|
'any_exploited' => $this->anyExploited,
|
|
'critical_count' => $this->criticalCount,
|
|
'high_count' => $this->highCount,
|
|
'medium_count' => $this->mediumCount,
|
|
'fixed_by_primary' => $this->fixedByPrimary,
|
|
'primary_critical' => $this->primaryCritical,
|
|
'primary_high' => $this->primaryHigh,
|
|
'primary_medium' => $this->primaryMedium,
|
|
'eol' => $this->eol,
|
|
'checked_at' => $this->checkedAt?->toIso8601String(),
|
|
'facts' => $this->facts,
|
|
'builder_image' => $this->builderImage,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $data
|
|
*/
|
|
public static function fromArray(array $data): self
|
|
{
|
|
$eol = is_array($data['eol'] ?? null) ? $data['eol'] : null;
|
|
$facts = (array) ($data['facts'] ?? []);
|
|
$stats = (array) ($facts['action_stats'] ?? []);
|
|
$actions = [];
|
|
$actionStats = [];
|
|
foreach (array_values((array) ($data['actions'] ?? [])) as $index => $actionData) {
|
|
$action = is_array($actionData) ? SecurityAction::fromArray($actionData) : null;
|
|
if ($action !== null) {
|
|
$actions[] = $action;
|
|
$actionStats[] = $stats[$index] ?? [];
|
|
}
|
|
}
|
|
if (array_key_exists('action_stats', $facts)) {
|
|
$facts['action_stats'] = $actionStats;
|
|
}
|
|
|
|
return new self(
|
|
status: SecurityStatusType::tryFrom((string) ($data['status'] ?? '')) ?? SecurityStatusType::NotChecked,
|
|
checkReason: isset($data['check_reason']) ? (string) $data['check_reason'] : null,
|
|
checkContext: (array) ($data['check_context'] ?? []),
|
|
actions: $actions,
|
|
why: array_values(array_map(fn ($entry): array => self::whyEntry((array) $entry), (array) ($data['why'] ?? []))),
|
|
moreFixed: (int) ($data['more_fixed'] ?? 0),
|
|
exploitedWithoutFix: (bool) ($data['exploited_without_fix'] ?? false),
|
|
problemCount: (int) ($data['problem_count'] ?? 0),
|
|
exploitedCount: (int) ($data['exploited_count'] ?? 0),
|
|
anyExploited: (bool) ($data['any_exploited'] ?? false),
|
|
criticalCount: (int) ($data['critical_count'] ?? 0),
|
|
highCount: (int) ($data['high_count'] ?? 0),
|
|
fixedByPrimary: (int) ($data['fixed_by_primary'] ?? 0),
|
|
primaryCritical: (int) ($data['primary_critical'] ?? 0),
|
|
primaryHigh: (int) ($data['primary_high'] ?? 0),
|
|
eol: $eol === null ? null : ['name' => (string) ($eol['name'] ?? ''), 'version' => (string) ($eol['version'] ?? ''), 'fixed' => (bool) ($eol['fixed'] ?? false)],
|
|
checkedAt: self::time($data['checked_at'] ?? null),
|
|
facts: $facts,
|
|
builderImage: (bool) ($data['builder_image'] ?? false),
|
|
mediumCount: (int) ($data['medium_count'] ?? 0),
|
|
primaryMedium: (int) ($data['primary_medium'] ?? 0),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $entry
|
|
* @return array{title: string, display_id: string, link: ?string, score: ?float, severity: string, signal: ?string, package: string, version: string}
|
|
*/
|
|
public static function whyEntry(array $entry): array
|
|
{
|
|
return [
|
|
'title' => ScannerText::storedTitle((string) ($entry['title'] ?? ''), (string) ($entry['package'] ?? ''), (string) ($entry['version'] ?? ''), (string) ($entry['severity'] ?? 'unknown')),
|
|
'display_id' => (string) ($entry['display_id'] ?? ''),
|
|
'link' => isset($entry['link']) ? (string) $entry['link'] : null,
|
|
'score' => is_numeric($entry['score'] ?? null) ? (float) $entry['score'] : null,
|
|
'severity' => (string) ($entry['severity'] ?? 'unknown'),
|
|
'signal' => isset($entry['signal']) ? (string) $entry['signal'] : null,
|
|
'package' => (string) ($entry['package'] ?? ''),
|
|
'version' => (string) ($entry['version'] ?? ''),
|
|
];
|
|
}
|
|
|
|
private static function time(mixed $value): ?CarbonImmutable
|
|
{
|
|
if (! is_string($value) || $value === '') {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
return CarbonImmutable::parse($value);
|
|
} catch (Throwable) {
|
|
return null;
|
|
}
|
|
}
|
|
}
|