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>
This commit is contained in:
Andras Bacsai
2026-11-06 14:25:02 +01:00
committed by GitHub
co-authored by Claude Opus 5.5 peaklabs-dev Aditya Tripathi
parent b7dc2e435b
commit 033f0c84da
581 changed files with 58626 additions and 2456 deletions
@@ -0,0 +1,151 @@
<?php
use App\Models\ImageScanIgnore;
use App\Services\ImageScan\CandidateEvaluator;
use App\Services\ImageScan\Finding;
use App\Services\ImageScan\GrypeReportParser;
use App\Services\ImageScan\RelevanceRules;
const EVALUATOR_EOL = ['name' => 'alpine', 'version' => '3.16.2'];
/**
* @return list<Finding>
*/
function evaluatorFixture(string $name): array
{
return (new GrypeReportParser)->parse(file_get_contents(dirname(__DIR__, 2)."/Fixtures/image-scan/{$name}.ndjson"))['findings'];
}
function evaluatorFinding(string $id, string $severity = 'high', bool $kev = false, array $aliases = [], ?float $epss = null, ?float $score = null): Finding
{
return Finding::fromArray([
'id' => $id, 'aliases' => $aliases, 'severity' => $severity, 'package' => 'openssl', 'version' => '1.0', 'type' => 'apk',
'artifactId' => 'a', 'fixedIn' => '1.1', 'fixState' => 'fixed', 'kev' => $kev, 'epss' => $epss, 'cvssScore' => $score,
]);
}
function evaluatorIgnores(string ...$ids): RelevanceRules
{
return RelevanceRules::fromIgnores(collect(array_map(fn (string $id) => new ImageScanIgnore(['reason' => 'test', 'vulnerability_id' => $id]), $ids)));
}
it('does not recommend 15.11-alpine: it adds findings with an exploit signal that 15.0-alpine does not have', function () {
$evaluator = new CandidateEvaluator;
expect($evaluator->isBetter(evaluatorFixture('grype'), evaluatorFixture('grype-candidate-15.11'), evaluatorIgnores(), EVALUATOR_EOL, null))->toBeFalse();
});
it('recommends 15-alpine although it adds critical Go findings in gosu: it fixes an exploitable SQL injection', function () {
$evaluator = new CandidateEvaluator;
$ignores = evaluatorIgnores();
$current = evaluatorFixture('grype');
$candidate = evaluatorFixture('grype-candidate-15-alpine');
$fixed = $evaluator->fixedIds($current, $candidate, $ignores);
expect($evaluator->isBetter($current, $candidate, $ignores, EVALUATOR_EOL, null))->toBeTrue()
->and($evaluator->isBetter($current, $candidate, $ignores, null, null))->toBeTrue()
->and($fixed)->toContain('CVE-2025-1094')
// A medium with an EPSS of 0.10 or more is relevant.
->and($fixed)->toContain('CVE-2023-2650');
});
it('lists only the relevant findings the candidate no longer has under any of their IDs', function () {
$evaluator = new CandidateEvaluator;
$current = [
evaluatorFinding('GHSA-1111', aliases: ['CVE-2024-1']),
evaluatorFinding('GHSA-2222', aliases: ['CVE-2024-2']),
evaluatorFinding('CVE-2024-3', 'medium'),
evaluatorFinding('CVE-2024-4', 'critical'),
];
$candidate = [evaluatorFinding('CVE-2024-1')];
expect($evaluator->fixedIds($current, $candidate, evaluatorIgnores('CVE-2024-4')))->toBe(['GHSA-2222', 'CVE-2024-2']);
});
it('counts a candidate with fewer relevant findings as better, also when it adds a critical finding', function () {
$evaluator = new CandidateEvaluator;
$current = [evaluatorFinding('CVE-1', 'high'), evaluatorFinding('CVE-2', 'high'), evaluatorFinding('CVE-3', 'medium')];
expect($evaluator->isBetter($current, [evaluatorFinding('CVE-1', 'high')], evaluatorIgnores(), null, null))->toBeTrue()
->and($evaluator->isBetter($current, [evaluatorFinding('CVE-9', 'critical')], evaluatorIgnores(), null, null))->toBeTrue()
->and($evaluator->isBetter($current, [evaluatorFinding('CVE-1', 'high'), evaluatorFinding('CVE-9', 'medium'), evaluatorFinding('CVE-8', 'low')], evaluatorIgnores(), null, null))->toBeTrue();
});
it('does not count a candidate with as many relevant findings and no fixed exploit signal as better', function () {
$evaluator = new CandidateEvaluator;
$current = [evaluatorFinding('CVE-1', 'high'), evaluatorFinding('CVE-2', 'medium')];
expect($evaluator->isBetter($current, [evaluatorFinding('CVE-1', 'high')], evaluatorIgnores(), null, null))->toBeFalse()
->and($evaluator->isBetter($current, [evaluatorFinding('CVE-3', 'high')], evaluatorIgnores(), null, null))->toBeFalse()
->and($evaluator->isBetter($current, [evaluatorFinding('CVE-3', 'critical'), evaluatorFinding('CVE-2', 'medium')], evaluatorIgnores(), null, null))->toBeFalse()
->and($evaluator->isBetter($current, [], evaluatorIgnores('CVE-1'), null, null))->toBeFalse();
});
it('counts a candidate that fixes a finding with an exploit signal as better, also with more relevant findings', function () {
$evaluator = new CandidateEvaluator;
$current = [evaluatorFinding('CVE-1', 'high', epss: 0.5, score: 8.1)];
expect($evaluator->isBetter($current, [evaluatorFinding('CVE-2', 'critical'), evaluatorFinding('CVE-3', 'high')], evaluatorIgnores(), null, null))->toBeTrue()
->and($evaluator->isBetter($current, [evaluatorFinding('CVE-2', 'critical'), evaluatorFinding('CVE-3', 'high')], evaluatorIgnores('CVE-1'), null, null))->toBeFalse();
});
it('drives the candidate search with a fixable high known exploited finding', function () {
$evaluator = new CandidateEvaluator;
$current = [evaluatorFinding('CVE-1', 'high', kev: true)];
expect($evaluator->isBetter($current, [], evaluatorIgnores(), null, null))->toBeTrue()
->and($evaluator->fixedIds($current, [], evaluatorIgnores()))->toBe(['CVE-1']);
});
it('never counts a candidate that adds a finding with an exploit signal as better, even when it fixes others', function () {
$evaluator = new CandidateEvaluator;
$current = [evaluatorFinding('CVE-1', 'critical'), evaluatorFinding('GHSA-9', 'low', kev: true, aliases: ['CVE-9']), evaluatorFinding('CVE-5', 'high', kev: true)];
expect($evaluator->isBetter($current, [evaluatorFinding('CVE-3', 'low', kev: true)], evaluatorIgnores(), EVALUATOR_EOL, null))->toBeFalse()
->and($evaluator->isBetter($current, [evaluatorFinding('CVE-4', 'high', epss: 0.3, score: 7.5)], evaluatorIgnores(), null, null))->toBeFalse()
->and($evaluator->isBetter($current, [evaluatorFinding('CVE-4', 'high', epss: 0.3, score: 7.5)], evaluatorIgnores('CVE-4'), null, null))->toBeTrue()
->and($evaluator->isBetter($current, [evaluatorFinding('cve-9', 'low', kev: true)], evaluatorIgnores(), null, null))->toBeTrue();
});
it('does not treat a finding of the current image whose EPSS went up as added', function () {
$evaluator = new CandidateEvaluator;
$current = [evaluatorFinding('CVE-1', 'critical'), evaluatorFinding('CVE-2', 'high', epss: 0.05, score: 7.5)];
expect($evaluator->isBetter($current, [evaluatorFinding('CVE-2', 'high', epss: 0.4, score: 7.5)], evaluatorIgnores(), null, null))->toBeTrue();
});
it('counts leaving an end-of-life distro as better and an end-of-life distro as one relevant problem', function () {
$evaluator = new CandidateEvaluator;
$none = evaluatorIgnores();
expect($evaluator->isBetter([], [], $none, EVALUATOR_EOL, null))->toBeTrue()
->and($evaluator->isBetter([evaluatorFinding('CVE-1', 'high')], [evaluatorFinding('CVE-1', 'high'), evaluatorFinding('CVE-2', 'critical')], $none, EVALUATOR_EOL, null))->toBeTrue()
->and($evaluator->isBetter([], [], $none, EVALUATOR_EOL, EVALUATOR_EOL))->toBeFalse()
->and($evaluator->isBetter([evaluatorFinding('CVE-1', 'critical')], [], $none, null, EVALUATOR_EOL))->toBeFalse()
->and($evaluator->isBetter([evaluatorFinding('CVE-1', 'critical'), evaluatorFinding('CVE-2', 'high')], [], $none, null, EVALUATOR_EOL))->toBeTrue()
->and($evaluator->isBetter([evaluatorFinding('CVE-1', 'critical')], [], $none, EVALUATOR_EOL, EVALUATOR_EOL))->toBeTrue()
->and($evaluator->isBetter([], [], $none, null, null))->toBeFalse();
});
it('counts relevant findings by severity, mediums only with an exploit signal', function () {
$evaluator = new CandidateEvaluator;
$findings = [
evaluatorFinding('CVE-1', 'critical'),
evaluatorFinding('CVE-2', 'high'),
evaluatorFinding('CVE-3', 'medium'),
evaluatorFinding('CVE-4', 'medium', kev: true),
evaluatorFinding('CVE-5', 'low', kev: true),
evaluatorFinding('CVE-6', 'high'),
];
expect($evaluator->counts($evaluator->relevant($findings, evaluatorIgnores('CVE-6'))))->toBe(['critical' => 1, 'high' => 1, 'medium' => 1]);
});
it('keeps the current findings that another image also has under one of their IDs', function () {
$evaluator = new CandidateEvaluator;
$inBase = evaluatorFinding('GHSA-aaaa', aliases: ['CVE-2026-0001']);
$added = evaluatorFinding('CVE-2099-0001');
expect($evaluator->shared([$inBase, $added], [evaluatorFinding('cve-2026-0001')]))->toBe([$inBase])
->and($evaluator->shared([$inBase, $added], []))->toBe([]);
});
@@ -0,0 +1,36 @@
<?php
use App\Services\ImageScan\ComposeImageVariable;
it('reads the variable of an image in the forms Coolify can change', function (string $image, string $key, bool $tagOnly, ?string $default, ?string $value, ?string $resolved) {
$variable = ComposeImageVariable::parse($image);
expect($variable)->not->toBeNull()
->and($variable->key)->toBe($key)
->and($variable->tagOnly)->toBe($tagOnly)
->and($variable->default)->toBe($default)
->and($variable->resolve($value))->toBe($resolved);
})->with([
'whole image' => ['${N8N_IMAGE}', 'N8N_IMAGE', false, null, 'n8nio/n8n:1.0', 'n8nio/n8n:1.0'],
'whole image with a default' => ['${N8N_IMAGE:-n8nio/n8n:1.0}', 'N8N_IMAGE', false, 'n8nio/n8n:1.0', null, 'n8nio/n8n:1.0'],
'whole image without braces' => ['$N8N_IMAGE', 'N8N_IMAGE', false, null, 'n8nio/n8n:1.1', 'n8nio/n8n:1.1'],
'tag' => ['nginx:${NGINX_VERSION}', 'NGINX_VERSION', true, null, '1.27', 'nginx:1.27'],
'tag with a default' => ['nginx:${NGINX_VERSION:-1.27}', 'NGINX_VERSION', true, '1.27', '', 'nginx:1.27'],
'tag of an image on a registry with a port' => ['registry.local:5000/team/app:${TAG-2.0}', 'TAG', true, '2.0', null, 'registry.local:5000/team/app:2.0'],
'no value and no default' => ['nginx:${NGINX_VERSION}', 'NGINX_VERSION', true, null, null, null],
]);
it('refuses the forms Coolify cannot change', function (string $image) {
expect(ComposeImageVariable::parse($image))->toBeNull();
})->with([
'two variables' => ['${REPO}:${TAG}'],
'variable in the name' => ['ghcr.io/${ORG}/app:1.0'],
'variable and a suffix' => ['nginx:${VERSION}-alpine'],
'no variable' => ['nginx:1.27'],
]);
it('gives the value for a new tag and keeps how the image name was written', function () {
expect(ComposeImageVariable::parse('${IMAGE}')->valueFor('docker.io/library/nginx:1.27', '1.29'))->toBe('docker.io/library/nginx:1.29')
->and(ComposeImageVariable::parse('${IMAGE}')->valueFor('registry.local:5000/app', '2.0'))->toBe('registry.local:5000/app:2.0')
->and(ComposeImageVariable::parse('nginx:${TAG}')->valueFor('nginx:1.27', '1.29'))->toBe('1.29');
});
@@ -0,0 +1,36 @@
<?php
use App\Services\ImageScan\DockerfileBaseImage;
it('reads the base image of the final stage', function (string $dockerfile, ?string $expected) {
expect(DockerfileBaseImage::finalStage($dockerfile))->toBe($expected);
})->with([
'single stage' => ["FROM node:20.11-alpine\nWORKDIR /app\nCOPY . .\nCMD [\"node\", \"index.js\"]\n", 'node:20.11-alpine'],
'multi-stage, last stage is an image' => ["FROM node:20 AS build\nRUN npm ci\nFROM nginx:1.27-alpine\nCOPY --from=build /app/dist /usr/share/nginx/html\n", 'nginx:1.27-alpine'],
'multi-stage, alias chain' => ["FROM node:20 AS base\nFROM base AS build\nRUN npm ci\nFROM build\nCMD [\"node\"]\n", 'node:20'],
'alias names compare without case' => ["FROM node:20 AS Build\nFROM build\n", 'node:20'],
'platform flag and alias' => ["FROM --platform=\$BUILDPLATFORM golang:1.23 AS b\nRUN go build\nFROM alpine:3.20\nCOPY --from=b /out /app\n", 'alpine:3.20'],
'platform flag on the final stage' => ["FROM --platform=linux/amd64 alpine:3.20\n", 'alpine:3.20'],
'alias of a stage with a platform flag' => ["FROM --platform=\$BUILDPLATFORM golang:1.23 AS b\nFROM b\n", 'golang:1.23'],
'ARG in FROM' => ["ARG BASE=node:20\nFROM \${BASE}\n", null],
'ARG without braces' => ["ARG VERSION=20\nFROM node:\$VERSION\n", null],
'ARG reached through an alias' => ["ARG BASE=node:20\nFROM \$BASE AS build\nFROM build\n", null],
'comments, lowercase from, and line continuations' => ["# syntax=docker/dockerfile:1\n# FROM ubuntu:22.04\nfrom node:20 \\\n as build\nRUN echo \\\n FROM debian:12\n # FROM fedora:40\nfrom \\\n build\n", 'node:20'],
'heredoc body is not an instruction' => ["FROM alpine:3.20\nRUN <<EOF\nFROM debian:12\nEOF\n", 'alpine:3.20'],
'scratch' => ["FROM golang:1.23 AS build\nFROM scratch\nCOPY --from=build /app /app\n", null],
'scratch through an alias' => ["FROM scratch AS base\nFROM base\n", null],
'invalid reference' => ["FROM Node:20!\n", null],
'digest reference' => ['FROM node@sha256:'.str_repeat('a', 64)."\n", 'node@sha256:'.str_repeat('a', 64)],
'registry with a port' => ["FROM localhost:5000/team/base:1.2 AS b\nFROM b\n", 'localhost:5000/team/base:1.2'],
'no FROM' => ["RUN echo hi\n", null],
'empty' => ['', null],
'CRLF line endings' => ["FROM node:20 AS build\r\nFROM build\r\n", 'node:20'],
]);
it('reads the base image of the build target stage', function () {
$dockerfile = "FROM node:20 AS build\nFROM build AS dev\nFROM nginx:1.27 AS prod\n";
expect(DockerfileBaseImage::finalStage($dockerfile, 'dev'))->toBe('node:20')
->and(DockerfileBaseImage::finalStage($dockerfile, 'PROD'))->toBe('nginx:1.27')
->and(DockerfileBaseImage::finalStage($dockerfile, 'missing'))->toBeNull();
});
@@ -0,0 +1,378 @@
<?php
use App\Services\ImageScan\Finding;
use App\Services\ImageScan\GrypeReportParser;
use App\Services\ImageScan\ScannerText;
use Illuminate\Support\Arr;
function imageScanFixture(string $name): string
{
return file_get_contents(dirname(__DIR__, 2).'/Fixtures/image-scan/'.$name);
}
function grypeLine(array $overrides = []): string
{
return json_encode(array_merge([
'id' => 'CVE-2024-0001', 'rel' => [], 'sev' => 'High', 'pkg' => 'openssl', 'ver' => '1.0',
'type' => 'apk', 'art' => 'abc', 'fix' => 'fixed', 'fixv' => ['1.1'], 'kev' => false, 'desc' => 'x',
], $overrides));
}
it('parses the postgres 15.0 report', function () {
$r = (new GrypeReportParser)->parse(imageScanFixture('grype.ndjson'));
expect($r['findings'])->toHaveCount(166)
->and($r['truncated'])->toBeFalse()
->and($r['eol'])->toBe(['name' => 'alpine', 'version' => '3.16.2']);
$f = collect($r['findings'])->firstWhere('id', 'CVE-2025-1094');
expect($f)->toBeInstanceOf(Finding::class)
->and($f->severity)->toBe('high')->and($f->fixedIn)->toBe('15.11')->and($f->fixState)->toBe('fixed')
->and($f->epss)->toBe(0.9003)->and($f->kev)->toBeFalse()->and($f->package)->toBe('postgresql')
->and($f->version)->toBe('15.0')->and($f->type)->toBe('binary')->and($f->artifactId)->toBe('0bbf5c9dd36d4a50')
->and($f->risk)->toBe(70.2234)->and($f->layer)->toBe('app')->and($f->aliases)->toBe([]);
});
it('reads known exploited and aliases', function () {
$f = collect((new GrypeReportParser)->parse(imageScanFixture('grype-kev.ndjson'))['findings'])
->firstWhere('id', 'GHSA-jfh8-c2jp-5v3q');
expect($f->kev)->toBeTrue()->and($f->aliases)->toBe(['CVE-2021-44228'])->and($f->severity)->toBe('critical');
});
it('returns a null eol when the report has no distro alert', function () {
$r = (new GrypeReportParser)->parse(imageScanFixture('grype-kev.ndjson'));
expect($r['eol'])->toBeNull();
});
it('does not count the eol trailer as a finding', function () {
$r = (new GrypeReportParser)->parse(grypeLine()."\n".'{"eol":{"name":"alpine","version":"3.16"}}');
expect($r['findings'])->toHaveCount(1)->and($r['eol'])->toBe(['name' => 'alpine', 'version' => '3.16']);
});
it('caps findings and marks truncation', function () {
$r = (new GrypeReportParser)->parse(imageScanFixture('grype.ndjson'), 10);
expect($r['findings'])->toHaveCount(10)->and($r['truncated'])->toBeTrue();
});
it('is not truncated when the count equals the limit', function () {
$r = (new GrypeReportParser)->parse(imageScanFixture('grype-kev.ndjson'), 7);
expect($r['findings'])->toHaveCount(7)->and($r['truncated'])->toBeFalse();
});
it('still reads the eol trailer after the cap is reached', function () {
$r = (new GrypeReportParser)->parse(imageScanFixture('grype.ndjson'), 10);
expect($r['eol'])->toBe(['name' => 'alpine', 'version' => '3.16.2']);
});
it('reads the eol line when it comes first, as the current template writes it', function () {
$lines = explode("\n", trim(imageScanFixture('grype.ndjson')));
$eolFirst = implode("\n", [array_pop($lines), ...$lines]);
$r = (new GrypeReportParser)->parse($eolFirst);
expect($r['eol'])->toBe(['name' => 'alpine', 'version' => '3.16.2'])
->and($r['findings'])->toHaveCount(166);
});
it('keeps the findings with an exploit signal first, then the highest risk, when it cuts', function () {
$lines = [
grypeLine(['id' => 'LOW-RISK', 'risk' => 1.0]),
grypeLine(['id' => 'HIGH-RISK', 'risk' => 50.0]),
grypeLine(['id' => 'LIKELY', 'risk' => 0.5, 'epss' => 0.5, 'cvss' => [['source' => 'nvd@nist.gov', 'version' => '3.1', 'score' => 8.0]]]),
grypeLine(['id' => 'KEV', 'risk' => 0.1, 'kev' => true]),
];
$r = (new GrypeReportParser)->parse(implode("\n", $lines), 3);
expect($r['truncated'])->toBeTrue()
->and(array_map(fn (Finding $f) => $f->id, $r['findings']))->toBe(['KEV', 'LIKELY', 'HIGH-RISK']);
});
it('writes the eol line before the findings and cuts long descriptions in the template', function () {
$template = file_get_contents(dirname(__DIR__, 3).'/resources/image-scan/grype.tmpl');
expect(strpos($template, '"eol"'))->toBeLessThan(strpos($template, 'range .Matches'))
->and($template)->toContain('trunc 400')
->and($template)->not->toContain('trunc 600');
});
it('drops invalid lines and strips control characters', function () {
$line = grypeLine(['id' => "CVE-1\u{1b}", 'pkg' => "a\nb\u{1b}[31m", 'fixv' => []]);
$r = (new GrypeReportParser)->parse("not json\n{$line}\n".json_encode(['id' => '../../etc']));
expect($r['findings'])->toHaveCount(1)
->and($r['findings'][0]->package)->toBe('ab[31m')
->and($r['findings'][0]->id)->toBe('CVE-1');
});
it('drops lines whose id does not match the id pattern', function (string $id) {
$r = (new GrypeReportParser)->parse(grypeLine(['id' => $id]));
expect($r['findings'])->toBe([]);
})->with(['path' => '../../etc', 'empty' => '', 'shell' => 'CVE-1; rm -rf /', 'ansi' => "CVE-1\u{1b}[31m", 'too long' => str_repeat('A', 65), 'leading dash' => '-CVE']);
it('maps severities to lowercase and unknown values to unknown', function (string $raw, string $expected) {
$r = (new GrypeReportParser)->parse(grypeLine(['sev' => $raw]));
expect($r['findings'][0]->severity)->toBe($expected);
})->with([['Critical', 'critical'], ['HIGH', 'high'], ['Medium', 'medium'], ['Low', 'low'], ['Negligible', 'low'], ['', 'unknown'], ['weird', 'unknown']]);
it('cuts strings longer than their caps', function () {
$r = (new GrypeReportParser)->parse(grypeLine([
'pkg' => str_repeat('p', 300), 'ver' => str_repeat('v', 300), 'desc' => str_repeat('d', 400),
]));
$f = $r['findings'][0];
expect(strlen($f->package))->toBe(214)->and(strlen($f->version))->toBe(128)->and(strlen($f->summary))->toBe(300);
});
it('uses the suggested version, then the first fix version when fixed, otherwise null', function () {
$parse = fn (array $o) => (new GrypeReportParser)->parse(grypeLine($o))['findings'][0]->fixedIn;
expect($parse(['sugg' => '2.0', 'fixv' => ['1.1']]))->toBe('2.0')
->and($parse(['fixv' => ['1.1', '1.2']]))->toBe('1.1')
->and($parse(['fix' => 'not-fixed', 'fixv' => ['1.1']]))->toBeNull()
->and($parse(['fix' => 'fixed', 'fixv' => []]))->toBeNull();
});
it('leaves epss and risk null when absent', function () {
$f = (new GrypeReportParser)->parse(grypeLine())['findings'][0];
expect($f->epss)->toBeNull()->and($f->risk)->toBeNull();
});
it('rejects input over 10 MB as truncated and empty', function () {
$r = (new GrypeReportParser)->parse(str_repeat('a', 10 * 1024 * 1024 + 1));
expect($r)->toBe(['findings' => [], 'truncated' => true, 'eol' => null]);
});
it('ignores lines nested deeper than the allowed depth', function () {
$deep = str_repeat('[', 20).str_repeat(']', 20);
$r = (new GrypeReportParser)->parse($deep."\n".grypeLine());
expect($r['findings'])->toHaveCount(1);
});
it('keeps the layer of a stored finding', function () {
$f = (new GrypeReportParser)->parse(imageScanFixture('grype-kev.ndjson'))['findings'][0];
$stored = Finding::fromArray([...$f->toArray(), 'layer' => 'base']);
expect($f->layer)->toBe('app')
->and($stored->toArray())->toBe([...$f->toArray(), 'layer' => 'base'])
->and(Finding::fromArray(Arr::except($f->toArray(), 'layer'))->layer)->toBe('app');
});
it('round trips a finding through toArray and fromArray', function () {
$f = (new GrypeReportParser)->parse(imageScanFixture('grype-kev.ndjson'))['findings'][0];
expect(Finding::fromArray($f->toArray()))->toEqual($f);
});
it('parses the candidate reports', function () {
$a = (new GrypeReportParser)->parse(imageScanFixture('grype-candidate-15.11.ndjson'));
$b = (new GrypeReportParser)->parse(imageScanFixture('grype-candidate-15-alpine.ndjson'));
expect($a['findings'])->not->toBeEmpty()->and($b['findings'])->toHaveCount(64)
->and(collect($a['findings'])->where('severity', 'critical')->count())->toBe(20);
});
function parsedFinding(array $overrides = []): Finding
{
return (new GrypeReportParser)->parse(grypeLine($overrides))['findings'][0];
}
it('reads the score, title, display ID, namespace, and match type of the postgres anchor', function () {
$f = collect((new GrypeReportParser)->parse(imageScanFixture('grype.ndjson'))['findings'])->firstWhere('id', 'CVE-2025-1094');
expect($f->cvssScore)->toBe(8.1)
->and($f->displayId)->toBe('CVE-2025-1094')
->and($f->title)->toStartWith('Improper neutralization of quoting syntax in PostgreSQL libpq functions')
->and(mb_strlen($f->title))->toBeLessThanOrEqual(120)
->and($f->namespace)->toBe('nvd:cpe')
->and($f->matchType)->toBe('cpe-match');
});
it('shows the CVE alias as the display ID of a GHSA finding and takes its own score before the NVD score', function () {
$f = collect((new GrypeReportParser)->parse(imageScanFixture('grype-kev.ndjson'))['findings'])->firstWhere('id', 'GHSA-jfh8-c2jp-5v3q');
expect($f->displayId)->toBe('CVE-2021-44228')
->and($f->cvssScore)->toBe(10.0)
->and($f->title)->toBe('Remote code injection in Log4j')
->and($f->namespace)->toBe('github:language:java')
->and($f->matchType)->toBe('exact-direct-match');
$p6 = collect((new GrypeReportParser)->parse(imageScanFixture('grype-kev.ndjson'))['findings'])->firstWhere('id', 'GHSA-p6xc-xr62-6r2g');
expect($p6->cvssScore)->toBe(8.6);
});
it('uses the finding ID as the display ID when no alias is a CVE', function (array $overrides, string $expected) {
expect(parsedFinding($overrides)->displayId)->toBe($expected);
})->with([
'own CVE id' => [['id' => 'CVE-2024-0001', 'rel' => ['GHSA-aaaa-bbbb-cccc']], 'CVE-2024-0001'],
'alias' => [['id' => 'GHSA-aaaa-bbbb-cccc', 'rel' => ['GHSA-dddd-eeee-ffff', 'CVE-2023-1234']], 'CVE-2023-1234'],
'none' => [['id' => 'GHSA-aaaa-bbbb-cccc', 'rel' => ['GO-2023-1']], 'GHSA-aaaa-bbbb-cccc'],
]);
it('picks the score from the own source, then NVD, then the highest CVSS version, and ignores v2', function (array $overrides, ?float $expected) {
$nvd = ['source' => 'nvd@nist.gov', 'version' => '3.1', 'score' => 9.8];
$own = ['source' => 'alpine', 'version' => '3.1', 'score' => 5.3];
$base = ['ns' => 'alpine:distro:alpine:3.19', 'cvss' => [$nvd, $own], 'rcvss' => []];
expect(parsedFinding([...$base, ...$overrides])->cvssScore)->toBe($expected);
})->with([
'own source before NVD' => [[], 5.3],
'v4 before v3.1 in the same source' => [['cvss' => [
['source' => 'x', 'version' => '3.1', 'score' => 9.0], ['source' => 'x', 'version' => '4.0', 'score' => 7.1],
]], 7.1],
'v3.1 before v3.0' => [['cvss' => [
['source' => 'x', 'version' => '3.0', 'score' => 9.0], ['source' => 'x', 'version' => '3.1', 'score' => 6.1],
]], 6.1],
'NVD when the own source has none' => [['cvss' => [['source' => 'nvd@nist.gov', 'version' => '3.1', 'score' => 9.8]]], 9.8],
'NVD from the related vulnerability' => [['cvss' => [], 'rcvss' => [['source' => 'nvd@nist.gov', 'version' => '3.1', 'score' => 7.4]]], 7.4],
'related non-NVD source is not used' => [['cvss' => [], 'rcvss' => [['source' => 'other', 'version' => '3.1', 'score' => 7.4]]], null],
'v2 only' => [['cvss' => [['source' => 'x', 'version' => '2.0', 'score' => 9.3]], 'rcvss' => []], null],
'own v2 falls back to NVD v3' => [['cvss' => [
['source' => 'x', 'version' => '2.0', 'score' => 9.3], ['source' => 'nvd@nist.gov', 'version' => '3.1', 'score' => 7.4],
]], 7.4],
'no entries' => [['cvss' => [], 'rcvss' => []], null],
'zero score is no score' => [['cvss' => [['source' => 'x', 'version' => '3.1', 'score' => 0], ['source' => 'nvd@nist.gov', 'version' => '3.1', 'score' => 9.8]]], 9.8],
'string score ignored' => [['cvss' => [['source' => 'x', 'version' => '3.1', 'score' => 'high'], ['source' => 'nvd@nist.gov', 'version' => '3.1', 'score' => 9.8]]], 9.8],
]);
it('treats every entry of an NVD namespace finding as its own source', function () {
$f = parsedFinding(['ns' => 'nvd:cpe', 'cvss' => [
['source' => '134c704f-9b21', 'version' => '3.1', 'score' => 7.4],
['source' => 'nvd@nist.gov', 'version' => '3.1', 'score' => 7.5],
]]);
expect($f->cvssScore)->toBe(7.5);
expect(parsedFinding(['ns' => 'nvd:cpe', 'cvss' => [['source' => 'f86ef6dc', 'version' => '3.1', 'score' => 8.1]]])->cvssScore)->toBe(8.1);
});
it('has no score when the line has no cvss data', function () {
expect(parsedFinding()->cvssScore)->toBeNull();
});
it('splits the title at the first sentence end followed by a capital letter', function () {
$f = parsedFinding(['desc' => 'In OpenSSL before 3.0.15, the X.400 type handling failed. Second sentence here.']);
expect($f->title)->toBe('In OpenSSL before 3.0.15, the X.400 type handling failed.');
expect(parsedFinding(['desc' => 'Is this a problem? Yes it is a big problem.'])->title)->toBe('Is this a problem?');
expect(parsedFinding(['desc' => 'A heap overflow in the parser. lower case continues here and more words'])->title)
->toBe('A heap overflow in the parser. lower case continues here and more words');
});
it('cuts a long title at a word boundary and adds an ellipsis within 120 characters', function () {
$f = parsedFinding(['desc' => str_repeat('overflow ', 40)]);
expect(mb_strlen($f->title))->toBeLessThanOrEqual(120)
->and($f->title)->toEndWith('overflow…')
->and($f->title)->not->toContain(' ');
$long = parsedFinding(['desc' => str_repeat('a', 100).str_repeat(' bb', 40)]);
expect(mb_strlen($long->title))->toBeLessThanOrEqual(120)->and($long->title)->toEndWith('bb…');
expect(parsedFinding(['desc' => str_repeat('a', 300).' b c d', 'rdesc' => ''])->title)->toBe('openssl 1.0: high vulnerability');
});
it('removes Markdown, URLs, and control and format characters from the title', function () {
expect(parsedFinding(['desc' => 'Fixes [the parser bug](https://e.com/x) in `libfoo` with **bold** and __two words__ at https://example.org/a?b=1 now'])->title)
->toBe('Fixes the parser bug in libfoo with bold and two words at now');
expect(parsedFinding(['desc' => 'Shows ![the alt text](https://e.com/i.png) and more words'])->title)
->toBe('Shows the alt text and more words');
expect(parsedFinding(['desc' => "Evil \u{202E}title with\u{200B} hidden\ncharacters and more"])->title)
->toBe('Evil title with hidden characters and more');
expect(parsedFinding(['desc' => 'Reads client_encoding from PQescapeLiteral() calls today'])->title)
->toBe('Reads client_encoding from PQescapeLiteral() calls today');
});
it('removes a leading label from the title', function (string $desc, string $title) {
expect(parsedFinding(['desc' => $desc])->title)->toBe($title);
})->with([
'issue summary' => ['Issue summary: a crafted packet crashes the server. Impact: high.', 'A crafted packet crashes the server.'],
'summary' => ['SUMMARY: Heap overflow in the parser of headers', 'Heap overflow in the parser of headers'],
'description' => ['Description: Vulnerability: the parser reads past the buffer end', 'The parser reads past the buffer end'],
'no label' => ['The summary: is not a label in the middle', 'The summary: is not a label in the middle'],
]);
it('prefers the related description over a changelog, and cleans changelog tokens', function () {
$fips = '[3.0.7-28.0.1_fips] - Replace upstream references in fips man pages [Orabug: 35824276] - Add FIPS package change: add a provider';
$brotli = '[1.0.9-10.0.1] - Fix out-of-bounds write in the Brotli decoder [CVE-2026-111497]';
expect(parsedFinding(['desc' => $fips, 'rdesc' => 'Issue summary: Processing a crafted certificate may crash OpenSSL. Impact: denial of service.'])->title)
->toBe('Processing a crafted certificate may crash OpenSSL.')
->and(parsedFinding(['desc' => $fips, 'rdesc' => ''])->title)->toBe('Replace upstream references in fips man pages')
->and(parsedFinding(['desc' => $brotli, 'rdesc' => ''])->title)->toBe('Fix out-of-bounds write in the Brotli decoder')
->and(parsedFinding(['desc' => '- Resolves: RHEL-1234 heap overflow in the zlib inflate code', 'rdesc' => 'A heap overflow in inflate allows a crash'])->title)
->toBe('A heap overflow in inflate allows a crash');
});
it('cleans changelog tokens of stored titles at render time', function () {
expect(ScannerText::tidyTitle('[3.0.7-28.0.1_fips] - Replace upstream references in fips man pages [Orabug: 35824276] - Add FIPS package change: add…'))
->toBe('Replace upstream references in fips man pages')
->and(ScannerText::tidyTitle('[1.0.9-10.0.1] - Fix out-of-bounds write in the Brotli decoder [CVE-2026-111497]'))
->toBe('Fix out-of-bounds write in the Brotli decoder')
->and(ScannerText::tidyTitle('A heap overflow in x-y - and more [see CVE-2026-1]'))->toBe('A heap overflow in x-y - and more [see CVE-2026-1]');
});
it('uses the package fallback title for fewer than four words', function (string $desc) {
expect(parsedFinding(['desc' => $desc, 'rdesc' => '', 'pkg' => 'openssl', 'ver' => '1.0', 'sev' => 'High'])->title)
->toBe('openssl 1.0: high vulnerability');
})->with(['empty' => '', 'two words' => 'Buffer overflow', 'markdown only' => '**[x](https://e.com)**', 'three words' => 'Heap buffer overflow']);
it('uses the related description when the description is empty', function () {
$f = parsedFinding(['desc' => '', 'rdesc' => 'Related vulnerability allows remote code execution']);
expect($f->title)->toBe('Related vulnerability allows remote code execution');
$g = parsedFinding(['desc' => 'Own description wins over the related one', 'rdesc' => 'Related vulnerability allows remote code execution']);
expect($g->title)->toBe('Own description wins over the related one');
});
it('keeps the 300 character summary independent of the title', function () {
$f = parsedFinding(['desc' => str_repeat('d', 400)]);
expect(strlen($f->summary))->toBe(300);
});
it('reads old arrays without the new keys and round trips them', function () {
$old = [
'id' => 'GHSA-aaaa-bbbb-cccc', 'aliases' => ['CVE-2023-1234'], 'severity' => 'high', 'package' => 'openssl', 'version' => '1.0',
'type' => 'apk', 'artifactId' => 'abc', 'fixedIn' => '1.1', 'fixState' => 'fixed', 'kev' => false, 'epss' => null,
'risk' => null, 'summary' => 'x', 'layer' => 'app',
];
$f = Finding::fromArray($old);
expect($f->cvssScore)->toBeNull()
->and($f->title)->toBe('openssl 1.0: high vulnerability')
->and($f->displayId)->toBe('CVE-2023-1234')
->and($f->namespace)->toBe('')
->and($f->matchType)->toBe('')
->and(Finding::fromArray($f->toArray()))->toEqual($f);
expect(Finding::fromArray([...$old, 'id' => 'CVE-2024-1', 'aliases' => []])->displayId)->toBe('CVE-2024-1');
});
it('round trips the new fields', function () {
$f = parsedFinding(['ns' => 'alpine:distro:alpine:3.19', 'mt' => 'exact-indirect-match', 'cvss' => [['source' => 'a', 'version' => '3.1', 'score' => 6.5]], 'desc' => 'Something dangerous happens in the library']);
$array = $f->toArray();
expect($array['cvssScore'])->toBe(6.5)->and($array['title'])->toBe('Something dangerous happens in the library')
->and($array['displayId'])->toBe('CVE-2024-0001')->and($array['namespace'])->toBe('alpine:distro:alpine:3.19')
->and($array['matchType'])->toBe('exact-indirect-match')
->and(Finding::fromArray($array))->toEqual($f);
});
it('keeps code-like tokens in the title intact', function (string $desc) {
expect(parsedFinding(['desc' => $desc])->title)->toBe($desc);
})->with([
'__proto__' => 'Prototype pollution through __proto__ in merge',
'lone backtick' => 'Escaping of the character (`) allows command injection',
'tilde version' => 'Crash when parsing version 1.2.3~rc1 of the package',
'backslash path' => 'Path traversal in C:\Windows\system32 handling code',
'star glob' => 'Matching of *.js files skips the permission check',
'arithmetic stars' => 'Overflow when computing 2**3 and 4**5 values',
]);
it('keeps the inner text of paired inline code', function () {
expect(parsedFinding(['desc' => 'The `parse()` call and the (`) character fail in `a b` mode'])->title)
->toBe('The parse() call and the (`) character fail in a b mode');
});
it('keeps punctuation after a removed URL and still splits the sentence', function () {
expect(parsedFinding(['desc' => 'Issue at (see https://example.com/a). Next sentence is here and long.'])->title)
->toBe('Issue at (see ).');
expect(parsedFinding(['desc' => 'Details at https://example.com/a, plus more words here. Another one.'])->title)
->toBe('Details at , plus more words here.');
expect(parsedFinding(['desc' => 'Empty parens (https://e.com/x) and [](https://e.com) vanish from PQescapeLiteral() titles'])->title)
->toBe('Empty parens and vanish from PQescapeLiteral() titles');
});
it('does not split after e.g. or i.e.', function () {
expect(parsedFinding(['desc' => 'Unsafe input (e.g. A crafted header) leads to a crash. Then more.'])->title)
->toBe('Unsafe input (e.g. A crafted header) leads to a crash.');
expect(parsedFinding(['desc' => 'Some formats, I.E. Another format, are affected by it. Next one.'])->title)
->toBe('Some formats, I.E. Another format, are affected by it.');
});
it('builds the fallback title without double spaces and with an unknown severity label', function () {
expect(ScannerText::fallbackTitle('openssl', '1.0', ''))->toBe('openssl 1.0: unknown vulnerability')
->and(ScannerText::fallbackTitle('openssl', '', 'high'))->toBe('openssl: high vulnerability')
->and(ScannerText::fallbackTitle('', '', 'high'))->toBe('high vulnerability')
->and(ScannerText::fallbackTitle(' open ssl ', ' 1.0 ', ''))->toBe('open ssl 1.0: unknown vulnerability');
});
@@ -0,0 +1,81 @@
<?php
use App\Models\ImageScanIgnore;
use App\Services\ImageScan\Finding;
use App\Services\ImageScan\IgnoreMatcher;
function ignoreMatcherFinding(array $overrides = []): Finding
{
return Finding::fromArray(array_merge([
'id' => 'GHSA-jfh8-c2jp-5v3q',
'aliases' => ['CVE-2021-44228'],
'severity' => 'critical',
'package' => 'log4j-core',
'version' => '2.14.0',
'type' => 'java-archive',
'artifactId' => 'abc',
'fixState' => 'fixed',
], $overrides));
}
function ignoreMatcherFor(array ...$rows): IgnoreMatcher
{
return IgnoreMatcher::fromIgnores(collect(array_map(
fn (array $row) => new ImageScanIgnore(['reason' => 'test', ...$row]),
$rows,
)));
}
it('matches by the finding id', function () {
$ignore = ignoreMatcherFor(['vulnerability_id' => 'GHSA-jfh8-c2jp-5v3q'])->matches(ignoreMatcherFinding());
expect($ignore)->toBeInstanceOf(ImageScanIgnore::class)
->and($ignore->vulnerability_id)->toBe('GHSA-jfh8-c2jp-5v3q');
});
it('matches an ignore for a CVE against a GHSA finding through its alias', function () {
expect(ignoreMatcherFor(['vulnerability_id' => 'CVE-2021-44228'])->matches(ignoreMatcherFinding()))
->not->toBeNull();
});
it('compares vulnerability ids case-insensitively', function () {
expect(ignoreMatcherFor(['vulnerability_id' => 'cve-2021-44228'])->matches(ignoreMatcherFinding()))
->not->toBeNull()
->and(ignoreMatcherFor(['vulnerability_id' => 'ghsa-JFH8-c2jp-5v3q'])->matches(ignoreMatcherFinding()))
->not->toBeNull();
});
it('does not match another vulnerability', function () {
expect(ignoreMatcherFor(['vulnerability_id' => 'CVE-2024-0001'])->matches(ignoreMatcherFinding()))->toBeNull();
});
it('matches by package name exactly', function () {
expect(ignoreMatcherFor(['package' => 'log4j-core'])->matches(ignoreMatcherFinding()))->not->toBeNull()
->and(ignoreMatcherFor(['package' => 'Log4j-Core'])->matches(ignoreMatcherFinding()))->toBeNull()
->and(ignoreMatcherFor(['package' => 'log4j'])->matches(ignoreMatcherFinding()))->toBeNull();
});
it('requires both fields to match when an ignore sets a vulnerability and a package', function () {
$matcher = ignoreMatcherFor(['vulnerability_id' => 'CVE-2021-44228', 'package' => 'openssl']);
expect($matcher->matches(ignoreMatcherFinding()))->toBeNull()
->and($matcher->matches(ignoreMatcherFinding(['package' => 'openssl'])))->not->toBeNull();
expect(ignoreMatcherFor(['vulnerability_id' => 'CVE-2021-44228', 'package' => 'log4j-core'])->matches(ignoreMatcherFinding()))
->not->toBeNull();
});
it('never matches an ignore with neither a vulnerability nor a package', function () {
expect(ignoreMatcherFor([])->matches(ignoreMatcherFinding()))->toBeNull();
});
it('matches a team-wide ignore with no resource', function () {
$ignore = ignoreMatcherFor(['vulnerability_id' => 'CVE-2021-44228', 'resource_type' => null, 'resource_id' => null])
->matches(ignoreMatcherFinding());
expect($ignore)->not->toBeNull();
});
it('matches nothing when there are no ignores', function () {
expect(IgnoreMatcher::fromIgnores(collect())->matches(ignoreMatcherFinding()))->toBeNull();
});
+100
View File
@@ -0,0 +1,100 @@
<?php
use App\Services\ImageScan\ImageRef;
$imageRefDigest = 'sha256:'.str_repeat('ab', 32);
it('parses a Docker Hub official image', function () {
$ref = ImageRef::parse('nginx');
expect($ref->registry)->toBe('docker.io')
->and($ref->repository)->toBe('library/nginx')
->and($ref->tag)->toBeNull()
->and($ref->digest)->toBeNull()
->and($ref->isDockerOfficial())->toBeTrue()
->and($ref->qualifiedName())->toBe('docker.io/library/nginx')
->and((string) $ref)->toBe('nginx');
});
it('treats namespaced and other registry images as not official', function (string $input) {
expect(ImageRef::parse($input)->isDockerOfficial())->toBeFalse();
})->with(['grafana/grafana', 'grafana/grafana:10.4', 'ghcr.io/x/y', 'quay.io/library/nginx', 'localhost:5000/nginx']);
it('normalizes explicit Docker Hub names', function (string $input) {
$ref = ImageRef::parse($input);
expect($ref->registry)->toBe('docker.io')
->and($ref->repository)->toBe('library/postgres')
->and($ref->tag)->toBe('15.0-alpine')
->and($ref->isDockerOfficial())->toBeTrue()
->and((string) $ref)->toBe('postgres:15.0-alpine');
})->with(['postgres:15.0-alpine', 'docker.io/postgres:15.0-alpine', 'docker.io/library/postgres:15.0-alpine', 'index.docker.io/library/postgres:15.0-alpine']);
it('splits a registry with a port from the tag', function () {
$ref = ImageRef::parse('localhost:5000/team/app:1.2');
expect($ref->registry)->toBe('localhost:5000')
->and($ref->repository)->toBe('team/app')
->and($ref->tag)->toBe('1.2')
->and((string) $ref)->toBe('localhost:5000/team/app:1.2');
expect(ImageRef::parse('localhost:5000/app')->tag)->toBeNull()
->and(ImageRef::parse('localhost:5000/app')->repository)->toBe('app');
});
it('reads the first path component as a registry only when it looks like a host', function () {
expect(ImageRef::parse('localhost/app:1')->registry)->toBe('localhost')
->and(ImageRef::parse('localhost/app:1')->repository)->toBe('app')
->and(ImageRef::parse('team/app:1')->registry)->toBe('docker.io')
->and(ImageRef::parse('team/app:1')->repository)->toBe('team/app')
->and(ImageRef::parse('my.registry/team/app')->registry)->toBe('my.registry');
});
it('parses a digest reference without a tag', function () use ($imageRefDigest) {
$ref = ImageRef::parse("ghcr.io/a/b@{$imageRefDigest}");
expect($ref->registry)->toBe('ghcr.io')
->and($ref->repository)->toBe('a/b')
->and($ref->tag)->toBeNull()
->and($ref->digest)->toBe($imageRefDigest)
->and((string) $ref)->toBe("ghcr.io/a/b@{$imageRefDigest}");
});
it('refuses references that are not valid image names', function (string $input) {
ImageRef::parse($input);
})->with([
'shell injection' => 'nginx;rm -rf /',
'command substitution' => 'nginx:$(id)',
'space' => 'nginx latest',
'newline' => "nginx\nid",
'quote' => "nginx'",
'empty tag' => 'nginx:',
'digest without hash' => 'nginx@sha256',
'uppercase digest' => 'nginx@sha256:'.str_repeat('AB', 32),
'tag and digest' => 'nginx:1@sha256:'.str_repeat('ab', 32),
'empty' => '',
])->throws(InvalidArgumentException::class);
it('replaces the tag and drops the digest', function () use ($imageRefDigest) {
$ref = ImageRef::parse("postgres@{$imageRefDigest}")->withTag('15-alpine');
expect($ref->tag)->toBe('15-alpine')
->and($ref->digest)->toBeNull()
->and((string) $ref)->toBe('postgres:15-alpine');
ImageRef::parse('postgres')->withTag('bad tag');
})->throws(InvalidArgumentException::class);
it('validates tags and digests', function () use ($imageRefDigest) {
expect(ImageRef::isValidTag('15.0-alpine'))->toBeTrue()
->and(ImageRef::isValidTag('_x'))->toBeTrue()
->and(ImageRef::isValidTag(str_repeat('a', 128)))->toBeTrue()
->and(ImageRef::isValidTag(str_repeat('a', 129)))->toBeFalse()
->and(ImageRef::isValidTag('.hidden'))->toBeFalse()
->and(ImageRef::isValidTag('-x'))->toBeFalse()
->and(ImageRef::isValidTag("1\n"))->toBeFalse()
->and(ImageRef::isValidDigest($imageRefDigest))->toBeTrue()
->and(ImageRef::isValidDigest(strtoupper($imageRefDigest)))->toBeFalse()
->and(ImageRef::isValidDigest('sha256:abc'))->toBeFalse()
->and(ImageRef::isValidDigest($imageRefDigest."\n"))->toBeFalse();
});
@@ -0,0 +1,28 @@
<?php
use App\Models\Application;
use App\Services\ImageScan\ResourceScans;
use Symfony\Component\Yaml\Yaml;
function composeNamesApp(string $compose): Application
{
return new Application(['build_pack' => 'dockercompose', 'docker_compose' => $compose]);
}
it('reads the images of a Compose file again after the file changes, also on the same app', function () {
$application = composeNamesApp(Yaml::dump(['services' => ['web' => ['image' => 'nginx:1.27'], 'db' => ['image' => 'postgres:16']]]));
expect(ResourceScans::composeServiceNames($application))->toBe(['nginx' => 'web', 'postgres' => 'db'])
->and(ResourceScans::composeServiceNames($application))->toBe(['nginx' => 'web', 'postgres' => 'db']);
$application->docker_compose = Yaml::dump(['services' => ['web' => ['image' => 'caddy:2'], 'cache' => ['image' => '${CACHE_IMAGE:-redis:7}']]]);
expect(ResourceScans::composeServiceNames($application))->toBe(['caddy' => 'web']);
});
it('reads no images of a Compose file that is not valid YAML, every time', function () {
$application = composeNamesApp("services:\n web: [");
expect(ResourceScans::composeServiceNames($application))->toBe([])
->and(ResourceScans::composeServiceNames($application))->toBe([]);
});
@@ -0,0 +1,524 @@
<?php
use App\Models\Server;
use App\Services\ImageScan\ScannerCommands;
use Tests\TestCase;
uses(TestCase::class);
const SCANNER_TEST_RUN_ID = 'run1234abcd';
const SCANNER_TEST_INIT_IMAGE = 'alpine@sha256:2e0b81d60597f449baa56dc25d355367869d2dca4cd57e866f1e2b52af3eb55f';
const SCANNER_TEST_HEX = 'aaaabbbbccccddddeeeeffff0000111122223333444455556666777788889999';
beforeEach(function () {
$this->server = Mockery::mock(Server::class)->makePartial();
$this->server->shouldReceive('getAttribute')->with('user')->andReturn('ubuntu');
$this->server->shouldReceive('setAttribute')->andReturnSelf();
$this->server->user = 'ubuntu';
$this->commands = new ScannerCommands($this->server, SCANNER_TEST_RUN_ID);
});
afterEach(function () {
Mockery::close();
});
/**
* @return array<string, list<string>>
*/
function scannerCommandLists(ScannerCommands $commands): array
{
$auth = base64_encode(json_encode(['auths' => ['ghcr.io' => ['auth' => base64_encode('user:token')]]]));
return [
'prepare' => $commands->prepare(),
'dbUpdate' => $commands->dbUpdate(null),
'dbUpdate with url' => $commands->dbUpdate('https://mirror.example.com/grype/databases'),
'dbStatus' => $commands->dbStatus(),
'inspect' => $commands->inspect('localhost:5000/team/app:1.2'),
'pull' => $commands->pull('localhost:5000/team/app:1.2'),
'diskFree' => $commands->diskFree(),
'diskUsage' => $commands->diskUsage(),
'sbomFromLocal' => $commands->sbomFromLocal('nginx', SCANNER_TEST_HEX),
'sbomFromRegistry' => $commands->sbomFromRegistry('ghcr.io/a/b@sha256:'.str_repeat('ab', 32), 'linux/amd64', 'cand-'.SCANNER_TEST_HEX, null),
'sbomFromRegistry with auth' => $commands->sbomFromRegistry('ghcr.io/a/b:1', 'linux/arm64/v8', 'cand-1', $auth),
'grype' => $commands->grype(SCANNER_TEST_HEX),
'packages' => $commands->packages(SCANNER_TEST_HEX),
'tags' => $commands->tags('nginx', null),
'tags with auth' => $commands->tags('ghcr.io/a/b', $auth),
'digest' => $commands->digest('nginx:1.27', null, null),
'digest with platform and auth' => $commands->digest('ghcr.io/a/b:1', 'linux/amd64', $auth),
'cleanup' => $commands->cleanup(),
'deleteScannerData' => $commands->deleteScannerData(),
'runningContainers' => $commands->runningContainers(),
'deleteSboms' => $commands->deleteSboms(['sha256:'.SCANNER_TEST_HEX]),
];
}
/**
* Unwraps `sh -c '<script>'` lines so assertions read the script as the shell runs it.
*
* @param list<string> $commands
* @return list<string>
*/
function scannerUnwrapped(array $commands): array
{
return array_map(function (string $line): string {
if (! str_starts_with($line, "sh -c '")) {
return $line;
}
return str_replace("'\\''", "'", substr($line, 7, -1));
}, $commands);
}
/**
* @param list<string> $commands
* @return list<string>
*/
function scannerDockerRuns(array $commands): array
{
return array_values(array_filter(scannerUnwrapped($commands), fn (string $line) => str_contains($line, 'docker run') && ! str_contains($line, SCANNER_TEST_INIT_IMAGE)));
}
it('runs syft with one worker and without file metadata, so a large image fits in the memory limit', function () {
$syft = str_replace("'\\''", "'", (string) collect($this->commands->sbomFromLocal('nginx', SCANNER_TEST_HEX))->first(fn (string $line) => str_contains($line, 'docker-archive:')));
expect($syft)->toContain("-e 'SYFT_PARALLELISM=1' ")
->and($syft)->toContain("-e 'SYFT_FILE_METADATA_SELECTION=none' ")
->and($syft)->toContain('--memory 1g --memory-swap 1g');
});
it('asks Docker whether the memory limit stopped a scanner, then removes its container in the same script', function () {
$name = 'coolify-scan-'.SCANNER_TEST_RUN_ID;
$check = "if [ \"\$(docker inspect -f '{{.State.OOMKilled}}' {$name} 2>/dev/null)\" = true ]; then echo 'coolify-scan-oom %s'; fi; docker rm -f {$name} >/dev/null 2>&1";
$runs = [
'sbom' => $this->commands->sbomFromLocal('nginx', SCANNER_TEST_HEX),
'grype' => $this->commands->grype(SCANNER_TEST_HEX),
'packages' => $this->commands->packages(SCANNER_TEST_HEX),
];
foreach ($runs as $step => $commands) {
$script = collect(scannerUnwrapped($commands))->first(fn (string $line) => str_contains($line, 'docker run'));
expect($script)->toContain(sprintf($check, $step))
->and(strpos($script, 'docker run'))->toBeLessThan(strpos($script, 'docker inspect'))
->and(strpos($script, 'docker inspect'))->toBeLessThan(strpos($script, 'echo "coolify-scan-exit'));
}
$registry = collect(scannerUnwrapped($this->commands->sbomFromRegistry('ghcr.io/a/b:1', 'linux/amd64', 'cand-1', null)))->first(fn (string $line) => str_contains($line, 'docker run'));
expect($registry)->toContain(sprintf($check, 'sbom'))
->and(collect($this->commands->sbomFromLocal('nginx', SCANNER_TEST_HEX))->first(fn (string $line) => str_contains($line, 'docker save')))->not->toContain('OOMKilled');
});
it('reads the out of memory line only before the payload', function () {
$parsed = ScannerCommands::parseOutput("coolify-scan-oom grype\ncoolify-scan-exit grype 137 0\ncoolify-scan-payload\ncoolify-scan-oom packages");
expect($parsed['oom'])->toBe(['grype'])
->and($parsed['exit_codes'])->toBe(['grype' => 137])
->and(ScannerCommands::parseOutput("coolify-scan-exit grype 137 0\ncoolify-scan-payload\n")['oom'])->toBe([]);
});
it('starts every command list by removing a leftover scan container', function () {
foreach (scannerCommandLists($this->commands) as $name => $commands) {
expect($commands[0])->toBe('docker rm -f coolify-scan-'.SCANNER_TEST_RUN_ID.' >/dev/null 2>&1 || true', $name);
}
});
it('runs every scanner container with each hardening flag exactly once', function () {
$flags = ['--name coolify-scan-'.SCANNER_TEST_RUN_ID.' ', '--cap-drop ALL ', '--security-opt no-new-privileges ', '--read-only ',
'--pids-limit 256 ', '--user 65534:65534 ', '--cpus 1 ', '--memory 1g ', '--memory-swap 1g ',
"-e 'SYFT_CHECK_FOR_APP_UPDATE=false' ", "-e 'GRYPE_CHECK_FOR_APP_UPDATE=false' ", "-e 'XDG_CACHE_HOME=/scratch/cache' "];
$runs = 0;
foreach (scannerCommandLists($this->commands) as $name => $commands) {
foreach (scannerDockerRuns($commands) as $line) {
$runs++;
expect($line)->toMatch('/timeout -k 10 \d+ docker run /')
->not->toContain('--rm ');
foreach ($flags as $flag) {
expect(substr_count($line, $flag))->toBe(1, "{$name}: {$flag}");
}
}
}
expect($runs)->toBe(12);
});
it('cuts the network only for runs that do not need a registry or the database download', function () {
$lists = scannerCommandLists($this->commands);
foreach (['dbStatus', 'sbomFromLocal', 'grype', 'packages'] as $name) {
expect(scannerDockerRuns($lists[$name]))->each->toContain('--network none ');
}
foreach (['dbUpdate', 'sbomFromRegistry', 'tags', 'digest'] as $name) {
expect(scannerDockerRuns($lists[$name]))->each->not->toContain('--network');
}
});
it('gives the work directory to the scanner user before a container uses it', function () {
$chown = "chown -R 65534:65534 '/data/coolify/image-scans/tmp-".SCANNER_TEST_RUN_ID."'";
foreach (scannerCommandLists($this->commands) as $name => $commands) {
$run = collect($commands)->search(fn (string $line) => str_contains($line, 'docker run') && ! str_contains($line, SCANNER_TEST_INIT_IMAGE));
if ($run === false) {
continue;
}
expect(array_search($chown, $commands, true))->toBeInt($name)->toBeLessThan($run, $name);
}
});
it('never gives a scanner the Docker socket', function () {
foreach (scannerCommandLists($this->commands) as $commands) {
expect(implode("\n", $commands))->not->toContain('docker.sock');
}
});
it('pins scanner images by digest', function () {
$all = implode("\n", array_merge(...array_values(scannerCommandLists($this->commands))));
expect($all)->toContain("'anchore/syft@sha256:4cc92b64ca74ffca4c94d4c8fcc01f217731375300be14d0b6eeb2e82eaa110a'")
->toContain("'anchore/grype@sha256:621d56d1d68106b787022c81546f7e0a36202a25d1239f0c20195fab8c16f7ea'")
->toContain("'regclient/regctl@sha256:45b57933c2b67bb1e280b4baaa220ef1ab212c81268ab19589fa1e83719c7c82'")
->not->toMatch('/(syft|grype|regctl):latest/');
});
it('refuses to build commands with a scanner image digest that is not pinned', function (string $digest) {
config()->set('constants.image_scan.images.grype.digest', $digest);
(new ScannerCommands($this->server, SCANNER_TEST_RUN_ID))->grype(SCANNER_TEST_HEX);
})->with(['latest', 'sha256:abc', 'sha256:'.str_repeat('A', 64), 'sha256:'.str_repeat('a', 64)."\n"])->throws(InvalidArgumentException::class);
it('refuses a run ID that is not lowercase letters and digits', function (string $runId) {
new ScannerCommands($this->server, $runId);
})->with(['short', 'UPPERCASE123', 'run-1234567', 'a;rm -rf /', str_repeat('a', 33)])->throws(InvalidArgumentException::class);
it('passes image refs as escaped shell arguments', function () {
$digestRef = 'ghcr.io/a/b@sha256:'.str_repeat('ab', 32);
$script = fn (array $commands) => implode("\n", scannerUnwrapped($commands));
expect($script($this->commands->inspect('localhost:5000/team/app:1.2')))->toContain("'localhost:5000/team/app:1.2'")
->and($script($this->commands->sbomFromRegistry($digestRef, 'linux/amd64', 'c1', null)))->toContain("'--platform' 'linux/amd64' 'registry:{$digestRef}'")
->and($script($this->commands->digest($digestRef, null, null)))->toContain("'image' 'digest' '{$digestRef}'")
->and($script($this->commands->tags('nginx:1', null)))->toContain("'tag' 'ls' 'docker.io/library/nginx'");
});
it('refuses values that could break out of the command', function (string $method, array $arguments) {
$this->commands->{$method}(...$arguments);
})->with([
'image ref' => ['inspect', ['nginx;rm -rf /']],
'local sbom ref' => ['sbomFromLocal', ['nginx$(id)', SCANNER_TEST_HEX]],
'image id' => ['sbomFromLocal', ['nginx', 'sha256:'.SCANNER_TEST_HEX]],
'sbom name' => ['grype', ['../etc/passwd']],
'package sbom name' => ['packages', ['a b']],
'registry sbom name' => ['sbomFromRegistry', ['nginx', 'linux/amd64', 'x;id', null]],
'platform' => ['sbomFromRegistry', ['nginx', 'linux/amd64;id', 'c1', null]],
'digest platform' => ['digest', ['nginx', 'linux', null]],
'tags repository' => ['tags', ['nginx|id', null]],
'auth not base64' => ['tags', ['nginx', "abc'; id; '"]],
'auth with two registries' => ['tags', ['nginx', base64_encode(json_encode(['auths' => ['a.io' => ['auth' => 'x'], 'b.io' => ['auth' => 'y']]]))]],
'auth with a credential helper' => ['tags', ['nginx', base64_encode(json_encode(['auths' => ['a.io' => ['auth' => 'x']], 'credsStore' => 'desktop']))]],
'db url' => ['dbUpdate', ["https://x.io/db';id;'"]],
'db url scheme' => ['dbUpdate', ['file:///etc/passwd']],
'db url with a pipe' => ['dbUpdate', ['https://x.io/a|b']],
'db url with an ampersand' => ['dbUpdate', ['https://x.io/a?b=1&&c=2']],
])->throws(InvalidArgumentException::class);
it('writes the SBOM to a temp file and moves it into place after Syft succeeds', function () {
$commands = $this->commands->sbomFromLocal('nginx', SCANNER_TEST_HEX);
$index = collect($commands)->search(fn (string $line) => str_contains($line, 'docker-archive:'));
$syft = scannerUnwrapped($commands)[$index];
$tmp = '/data/coolify/image-scans/sboms/'.SCANNER_TEST_HEX.'.json.tmp-'.SCANNER_TEST_RUN_ID;
$final = '/data/coolify/image-scans/sboms/'.SCANNER_TEST_HEX.'.json';
expect($commands[$index])->toStartWith("sh -c 'ulimit -f 2097152; ")
->and($syft)->toContain("> '{$tmp}' 2>/dev/null; rc=\$?; ")
->and($syft)->toContain("if [ \$rc -eq 0 ]; then chown 65534:65534 '{$tmp}' && chmod 600 '{$tmp}' && mv -f '{$tmp}' '{$final}'; rc=\$?; fi; echo \"coolify-scan-exit sbom \$rc\"")
->and($commands)->toContain("rm -f '{$tmp}' || true");
});
it('saves the local image by its ID so a moved tag cannot change the cached SBOM', function () {
$save = collect($this->commands->sbomFromLocal('nginx:1', SCANNER_TEST_HEX))->first(fn (string $line) => str_contains($line, 'docker save'));
expect($save)->toContain("docker save -o '/data/coolify/image-scans/tmp-".SCANNER_TEST_RUN_ID."/in/image.tar' 'sha256:".SCANNER_TEST_HEX."' >/dev/null 2>&1")
->not->toContain('nginx');
});
it('reads tool output through a size cap and prints an exit marker', function () {
foreach (['grype' => $this->commands->grype(SCANNER_TEST_HEX), 'packages' => $this->commands->packages(SCANNER_TEST_HEX), 'tags' => $this->commands->tags('nginx', null), 'digest' => $this->commands->digest('nginx', null, null), 'db-status' => $this->commands->dbStatus()] as $step => $commands) {
$all = implode("\n", scannerUnwrapped($commands));
expect($all)->toContain("head -c 10485761 '/data/coolify/image-scans/tmp-".SCANNER_TEST_RUN_ID."/out' 2>/dev/null || true")
->toContain('ulimit -f 40960; timeout ')
->toContain("rc=\$?; if [ \"\$(docker inspect -f '{{.State.OOMKilled}}' coolify-scan-".SCANNER_TEST_RUN_ID." 2>/dev/null)\" = true ]; then echo 'coolify-scan-oom {$step}'; fi; docker rm -f coolify-scan-".SCANNER_TEST_RUN_ID." >/dev/null 2>&1; size=\$(wc -c < '/data/coolify/image-scans/tmp-".SCANNER_TEST_RUN_ID."/out' 2>/dev/null | tr -cd 0-9); size=\${size:-0}; if [ \"\$size\" -ge 20971520 ]; then rc=0; fi; echo \"coolify-scan-exit {$step} \$rc \$size\"")
->and(array_search('echo coolify-scan-payload', $commands, true))->toBeLessThan(collect($commands)->search(fn (string $line) => str_starts_with($line, 'head -c')))
->and(end($commands))->toBe("rm -rf '/data/coolify/image-scans/tmp-".SCANNER_TEST_RUN_ID."' || true");
}
});
it('caps tool output on disk at twice the read limit in the blocks that sh counts', function () {
expect(ScannerCommands::OUTPUT_FILE_LIMIT_BLOCKS * 512)->toBe(2 * ScannerCommands::OUTPUT_LIMIT)
->and(ScannerCommands::SBOM_FILE_LIMIT_BLOCKS * 512)->toBe(1024 ** 3);
foreach (scannerCommandLists($this->commands) as $name => $commands) {
foreach ($commands as $line) {
if (str_contains($line, 'docker run') && str_contains($line, '> ')) {
expect($line)->toStartWith("sh -c 'ulimit -f ", $name);
}
}
}
});
it('mounts the database and the SBOM read-only for Grype', function () {
$run = scannerDockerRuns($this->commands->grype(SCANNER_TEST_HEX))[0];
expect($run)->toContain("--mount 'type=volume,src=coolify-grype-db,dst=/db,readonly'")
->toContain("--mount 'type=bind,src=/data/coolify/image-scans/sboms/".SCANNER_TEST_HEX.".json,dst=/sbom/sbom.json,readonly'")
->toContain("-e 'GRYPE_DB_AUTO_UPDATE=false'")
->toContain('sbom:/sbom/sbom.json');
});
it('uploads the output templates from the resources', function () {
$templateLine = collect($this->commands->grype(SCANNER_TEST_HEX))->first(fn (string $line) => str_contains($line, 'base64 -d'));
preg_match("/^echo '([A-Za-z0-9+\\/=]+)' \\| base64 -d \\| tee '([^']+)' >\\/dev\\/null$/", $templateLine, $matches);
expect(base64_decode($matches[1], true))->toBe(file_get_contents(resource_path('image-scan/grype.tmpl')))
->and($matches[2])->toBe('/data/coolify/image-scans/tmp-'.SCANNER_TEST_RUN_ID.'/tpl/grype.tmpl');
});
it('writes registry credentials for one registry into a private file mounted read-only', function () {
$auth = base64_encode(json_encode(['auths' => ['ghcr.io' => ['auth' => base64_encode('user:token')]]]));
$commands = $this->commands->tags('ghcr.io/a/b', $auth);
$config = '/data/coolify/image-scans/tmp-'.SCANNER_TEST_RUN_ID.'/auth/config.json';
expect($commands)->toContain("chmod 600 '{$config}'")
->and(implode("\n", $commands))->toContain("| base64 -d | tee '{$config}' >/dev/null")
->and(scannerDockerRuns($commands)[0])->toContain("-e 'DOCKER_CONFIG=/auth'")
->toContain('dst=/auth,readonly');
expect(implode("\n", $this->commands->tags('ghcr.io/a/b', null)))->not->toContain('DOCKER_CONFIG')->not->toContain('/auth');
});
it('keeps registry credentials out of sudo arguments for a non-root SSH user', function () {
$secret = base64_encode('user:secret-token');
$auth = base64_encode(json_encode(['auths' => ['ghcr.io' => ['auth' => $secret]]]));
$written = base64_encode(json_encode(['auths' => ['ghcr.io' => ['auth' => $secret]]], JSON_UNESCAPED_SLASHES));
$lists = [
$this->commands->tags('ghcr.io/a/b', $auth),
$this->commands->digest('ghcr.io/a/b:1', null, $auth),
$this->commands->sbomFromRegistry('ghcr.io/a/b:1', 'linux/amd64', 'c1', $auth),
];
foreach ($lists as $commands) {
$parsed = parseCommandsByLineForSudo(collect($commands), $this->server);
expect(implode("\n", $parsed))->not->toContain('sudo printf')->toContain("echo '{$written}' | sudo base64 -d");
foreach ($parsed as $line) {
$sudo = strpos($line, 'sudo');
$secretAt = strpos($line, $written);
expect($secretAt === false || $sudo === false || $sudo > $secretAt)->toBeTrue($line);
}
}
});
it('accepts credentials only for the registry of the image', function (string $key, string $ref) {
$auth = base64_encode(json_encode(['auths' => [$key => ['auth' => 'eA==']]]));
expect($this->commands->digest($ref, null, $auth))->toBeArray();
})->with([
['ghcr.io', 'ghcr.io/a/b:1'],
['https://ghcr.io', 'ghcr.io/a/b:1'],
['localhost:5000', 'localhost:5000/team/app:1.2'],
['https://index.docker.io/v1/', 'nginx'],
['index.docker.io', 'grafana/grafana'],
['docker.io', 'nginx'],
['registry-1.docker.io', 'nginx'],
]);
it('refuses credentials for another registry', function (string $key, string $ref) {
$auth = base64_encode(json_encode(['auths' => [$key => ['auth' => 'eA==']]]));
$this->commands->tags($ref, $auth);
})->with([
['ghcr.io', 'nginx'],
['https://index.docker.io/v1/', 'ghcr.io/a/b'],
['ghcr.io.evil.com', 'ghcr.io/a/b'],
['localhost:5001', 'localhost:5000/app'],
])->throws(InvalidArgumentException::class);
it('removes the work directory, the leftover temp files, and the container in cleanup', function () {
expect($this->commands->cleanup())->toContain("rm -rf '/data/coolify/image-scans/tmp-".SCANNER_TEST_RUN_ID."' || true")
->toContain("find '/data/coolify/image-scans/sboms' -maxdepth 1 -name '*.json.tmp-*' -mmin +60 -delete 2>/dev/null || true")
->and($this->commands->deleteScannerData())->toContain("rm -rf '/data/coolify/image-scans' || true")
->toContain('docker volume rm -f coolify-grype-db >/dev/null 2>&1 || true');
});
it('keeps every command list working for a non-root SSH user', function () {
foreach (scannerCommandLists($this->commands) as $name => $commands) {
$parsed = parseCommandsByLineForSudo(collect($commands), $this->server);
foreach ($parsed as $index => $line) {
$original = $commands[$index];
expect($line)->not->toContain('sudo sudo')
->not->toContain('chown -h ubuntu');
if (str_starts_with($original, 'sh -c ')) {
expect($line)->toBe('sudo '.$original, $name)
->and(isSingleSudoShellScript($line))->toBeTrue($name)
->and(substr_count($line, 'sudo'))->toBe(1, $name);
continue;
}
if (str_starts_with($original, 'if ')) {
expect($line)->toMatch('/^if sudo (DOCKER_CONTEXT=default DOCKER_CONFIG="\$\(.+\)" )?(timeout -k 10 \d+ docker|docker image inspect) /', $name)
->toContain("; then echo 'coolify-scan-exit ")
->toEndWith('$?"; fi', $name);
continue;
}
if (str_starts_with($original, 'echo ')) {
expect($line)->toBe(str_replace(' | ', ' | sudo ', $original), $name)
->toMatch("/^echo (coolify-scan-payload|'[A-Za-z0-9+\\/=]+' \\| sudo base64 -d \\| sudo tee '[^']+' >\\/dev\\/null)$/", $name);
continue;
}
expect($line)->toMatch('/^sudo (docker|mkdir|chown|chmod|rm|head|df|find) /', $name);
if (str_ends_with($original, ' || true')) {
expect($line)->toEndWith(' || sudo true', $name);
}
}
}
});
it('pulls with the registry login of the non-root SSH user', function () {
$parsed = parseCommandsByLineForSudo(collect($this->commands->pull('ghcr.io/a/b:1')), $this->server);
expect($parsed[1])->toBe('if sudo DOCKER_CONTEXT=default DOCKER_CONFIG="$(sudo find "$HOME/.docker/config.json" -maxdepth 0 -exec dirname {} \\; 2>/dev/null)" timeout -k 10 600 docker pull \'ghcr.io/a/b:1\' >/dev/null 2>&1; then echo \'coolify-scan-exit pull 0\'; else echo "coolify-scan-exit pull $?"; fi');
});
it('keeps the docker root lookup inside the disk check for a non-root SSH user', function () {
$parsed = parseCommandsByLineForSudo(collect($this->commands->diskFree()), $this->server);
expect($parsed[2])->toBe("sudo df -P -B1 \"\$(sudo docker info -f '{{.DockerRootDir}}')\" | sudo awk 'NR==2 {printf \"%s \", \$4}'")
->and($parsed[3])->toBe("sudo df -P -B1 '/data/coolify/image-scans' | sudo awk 'NR==2 {print \$4}'");
});
it('prints the image ID, the repo digest, the platform, and the size in one line', function () {
$commands = $this->commands->inspect('nginx:1');
expect($commands[2])->toBe('echo coolify-scan-payload')
->and($commands[3])->toBe("docker image inspect --format '{{.Id}}|{{if .RepoDigests}}{{index .RepoDigests 0}}{{end}}|{{.Os}}/{{.Architecture}}|{{.Size}}' 'nginx:1' 2>/dev/null || true");
});
it('parses exit markers before the payload separator', function () {
$output = "coolify-scan-exit save 0\ncoolify-scan-exit sbom 0\ncoolify-scan-exit grype 0 12\ncoolify-scan-payload\n{\"a\":1}\ncoolify-scan-payload\n{\"b\":2}";
expect(ScannerCommands::parseOutput($output))->toBe([
'exit_codes' => ['save' => 0, 'sbom' => 0, 'grype' => 0],
'sizes' => ['grype' => 12],
'oom' => [],
'payload' => "{\"a\":1}\ncoolify-scan-payload\n{\"b\":2}",
])->and(ScannerCommands::parseOutput("coolify-scan-exit save 0\ncoolify-scan-exit sbom 1"))->toBe([
'exit_codes' => ['save' => 0, 'sbom' => 1], 'sizes' => [], 'oom' => [], 'payload' => '',
])->and(ScannerCommands::parseOutput(null))->toBe(['exit_codes' => [], 'sizes' => [], 'oom' => [], 'payload' => '']);
});
it('ignores payload lines that look like exit markers', function () {
$output = "coolify-scan-exit grype 2 9\ncoolify-scan-payload\ncoolify-scan-exit grype 0 9\n{}";
expect(ScannerCommands::parseOutput($output))->toBe([
'exit_codes' => ['grype' => 2],
'sizes' => ['grype' => 9],
'oom' => [],
'payload' => "coolify-scan-exit grype 0 9\n{}",
]);
});
it('prints the bytes used by the database volume and the SBOM directory as markers', function () {
$commands = $this->commands->diskUsage();
expect($commands)->toHaveCount(3)
->and($commands[1])->toContain('coolify-scan-exit disk-db 0')->toContain('docker volume inspect -f')->toContain('coolify-grype-db')
->and($commands[2])->toContain('coolify-scan-exit disk-sboms 0')->toContain("'/data/coolify/image-scans'");
$parsed = ScannerCommands::parseOutput("coolify-scan-exit disk-db 0 1048576\ncoolify-scan-exit disk-sboms 0 0");
expect($parsed['sizes'])->toBe(['disk-db' => 1048576, 'disk-sboms' => 0]);
});
it('runs the disk usage check as one sudo shell script per line for a non-root SSH user', function () {
$commands = $this->commands->diskUsage();
$parsed = parseCommandsByLineForSudo(collect($commands), $this->server);
foreach (array_slice($parsed, 1, null, true) as $index => $line) {
expect($line)->toBe('sudo '.$commands[$index])
->and(substr_count($line, 'sudo'))->toBe(1);
}
});
it('lists the image ID, the image ref, and the labels of every running container as JSON lines', function () {
$commands = $this->commands->runningContainers();
$script = scannerUnwrapped($commands)[1];
expect($commands)->toHaveCount(2)
->and($script)->toContain('docker ps -q --no-trunc')
->toContain('coolify-scan-exit containers')
->toContain('echo coolify-scan-payload')
->toContain('{"id":{{json .Image}},"ref":{{json .Config.Image}},"labels":{{json .Config.Labels}}}')
->toContain('head -c '.(ScannerCommands::OUTPUT_LIMIT + 1));
});
it('deletes SBOM files by image ID in one command', function () {
$other = str_repeat('1', 64);
$commands = $this->commands->deleteSboms(['sha256:'.SCANNER_TEST_HEX, 'sha256:'.$other]);
$script = scannerUnwrapped($commands)[1];
expect($commands)->toHaveCount(2)
->and($script)->toBe("rm -f '/data/coolify/image-scans/sboms/".SCANNER_TEST_HEX.".json' '/data/coolify/image-scans/sboms/{$other}.json'; echo \"coolify-scan-exit sbom-delete \$?\"");
});
it('refuses to delete an SBOM by an invalid image ID', function (string $imageId) {
$this->commands->deleteSboms([$imageId]);
})->with([
'path' => ['sha256:../../etc/passwd'],
'short' => ['sha256:abc'],
'no prefix' => [SCANNER_TEST_HEX],
])->throws(InvalidArgumentException::class);
it('sets up the database volume inside a container instead of through a host path', function () {
foreach (['prepare' => $this->commands->prepare(), 'dbUpdate' => $this->commands->dbUpdate(null)] as $name => $commands) {
$all = implode("\n", $commands);
$setup = collect(scannerUnwrapped($commands))->first(fn (string $line) => str_contains($line, 'coolify-scan-exit db-volume'));
expect($all)->not->toContain('Mountpoint', $name)
->and($setup)->toBeString($name)
->toContain('docker volume create coolify-grype-db')
->toContain('coolify-scan-exit db-volume')
->toContain('--mount type=volume,src=coolify-grype-db,dst=/db ')
->toContain('--network none ')
->toContain('--cap-drop ALL --cap-add CHOWN --cap-add DAC_OVERRIDE --cap-add FOWNER ')
->toContain('--security-opt no-new-privileges --read-only ')
->toMatch('/timeout -k 10 120 docker run /')
->toContain('chown 65534:65534 /db')
->toContain('chown -h 65534:65534 /db/tmp')
->not->toContain('docker.sock');
}
});
it('pulls the volume setup image before it is used', function () {
$commands = $this->commands->prepare();
$pull = collect($commands)->search(fn (string $line) => str_contains($line, 'docker pull '.escapeshellarg(SCANNER_TEST_INIT_IMAGE)));
$setup = collect($commands)->search(fn (string $line) => str_contains($line, 'coolify-scan-exit db-volume'));
expect($pull)->toBeInt()->and($setup)->toBeInt()->toBeGreaterThan($pull);
});
it('keeps the volume setup as one sudo shell line for a non-root SSH user', function () {
$setup = collect($this->commands->prepare())->first(fn (string $line) => str_contains($line, 'coolify-scan-exit db-volume'));
$parsed = parseCommandsByLineForSudo(collect([$setup]), $this->server);
expect($parsed)->toBe(['sudo '.$setup]);
});
@@ -0,0 +1,532 @@
<?php
use App\Actions\ImageScan\ScanImage;
use App\Enums\ImageScanStatus;
use App\Enums\SecurityActionType;
use App\Models\Application;
use App\Models\ApplicationSetting;
use App\Models\ImageScan;
use App\Models\Server;
use App\Models\Service;
use App\Models\ServiceApplication;
use App\Models\StandalonePostgresql;
use App\Services\ImageScan\RelevanceRules;
use App\Services\ImageScan\SecurityAction;
use App\Services\ImageScan\SecurityActions;
use Illuminate\Database\Eloquent\Model;
/**
* A relevant finding row (critical, OS package with a package fix) unless overridden.
*
* @return array<string, mixed>
*/
function actionFinding(string $id, array $overrides = []): array
{
return [
'id' => $id, 'aliases' => [], 'severity' => 'critical', 'package' => 'openssl', 'version' => '3.0.13',
'type' => 'apk', 'artifactId' => 'a-'.$id, 'fixedIn' => '3.0.15', 'fixState' => 'fixed', 'kev' => false,
'namespace' => 'alpine:distro:alpine:3.20', ...$overrides,
];
}
/**
* @template T of Model
*
* @param T $model
* @return T
*/
function actionOwner(Model $model, int $id = 5): Model
{
$model->id = $id;
$model->syncOriginal();
return $model;
}
function actionApp(string $buildPack, int $id = 5): Application
{
return actionOwner(new Application(['build_pack' => $buildPack]), $id);
}
/**
* @param list<array<string, mixed>> $findings
* @param array<string, mixed> $attributes
*/
function actionScan(Model $owner, array $findings, array $attributes = [], ?Server $server = null): ImageScan
{
$scan = new ImageScan([
'status' => ImageScanStatus::Completed,
'image_ref' => 'postgres:15.0-alpine',
'builds_image' => false,
'findings' => $findings,
'candidates' => null,
...$attributes,
]);
$scan->setRelation('resource', $owner);
$scan->setRelation('server', $server);
return $scan;
}
/**
* @param list<string> $fixedIds
* @return array<string, mixed>
*/
function actionEntry(string $ref, string $kind, array $fixedIds, bool $better = true, ?array $eol = null): array
{
return ['ref' => $ref, 'digest' => 'sha256:'.md5($ref), 'kind' => $kind, 'better' => $better, 'fixed_ids' => $fixedIds, 'fixes' => [], 'left' => [], 'eol' => $eol];
}
/**
* @param list<array<string, mixed>> $entries
* @return array<string, mixed>
*/
function actionCandidates(array $entries = [], ?array $base = null): array
{
return ['status' => 'ok', 'checked_at' => now()->toIso8601String(), 'entries' => $entries, ...($base === null ? [] : ['base' => $base])];
}
/**
* @param list<string> $attributableIds
* @param list<array<string, mixed>> $entries
* @return array<string, mixed>
*/
function actionBase(array $attributableIds, array $entries, string $image = 'node:20.1-alpine'): array
{
return ['image' => $image, 'status' => 'ok', 'attributable' => count($attributableIds), 'attributable_ids' => $attributableIds, 'current_digest' => 'sha256:'.md5($image), 'entries' => $entries];
}
/**
* @return array{actions: list<SecurityAction>, byFinding: array<string, SecurityAction>, eol: ?SecurityAction}
*/
function actionsFor(ImageScan $scan): array
{
return SecurityActions::forScan($scan, RelevanceRules::fromIgnores(collect()));
}
/**
* A Dockerfile app scan. The default base check is for the current base image and found no finding of the base.
*/
function dockerfileScan(array $findings, ?array $base = [], array $attributes = []): ImageScan
{
$base = $base === [] ? actionBase([], []) : $base;
return actionScan(actionApp('dockerfile'), $findings, [
'image_ref' => 'abc123:latest',
'builds_image' => true,
'base_image_ref' => 'node:20.1-alpine',
'candidates' => $base === null ? null : ['status' => 'skipped', 'checked_at' => now()->toIso8601String(), 'entries' => [], 'base' => $base],
...$attributes,
]);
}
function composeApp(bool $raw = false): Application
{
$app = actionApp('dockercompose');
$app->uuid = 'k8w2c9';
$app->compose_parsing_version = '5';
$app->setRelation('settings', new ApplicationSetting(['is_raw_compose_deployment_enabled' => $raw]));
$app->docker_compose = "services:\n web:\n build: .\n image: 'k8w2c9_web:4f1c2d3'\n worker:\n build:\n context: ./worker\n image: 'ghcr.io/acme/worker:2.0'\n db:\n image: 'postgres:16'\n";
$app->docker_compose_raw = "services:\n web:\n build: .\n worker:\n build:\n context: ./worker\n image: 'ghcr.io/acme/worker:2.0'\n db:\n image: 'postgres:16'\n";
$app->syncOriginal();
return $app;
}
function ownDockerImageScan(array $findings, ?array $candidates = null): ImageScan
{
return actionScan(actionApp('dockerimage'), $findings, ['image_ref' => 'acme/api:1.4', 'builds_image' => true, 'candidates' => $candidates]);
}
describe('third-party images', function () {
it('updates to a better line candidate that no longer has the finding', function () {
$scan = actionScan(actionOwner(new StandalonePostgresql), [actionFinding('CVE-2025-1094', ['kev' => true])], [
'candidates' => actionCandidates([actionEntry('postgres:15.15-alpine', 'line', ['CVE-2025-1094'])]),
]);
$result = actionsFor($scan);
$action = $result['byFinding']['CVE-2025-1094'];
expect($result['actions'])->toHaveCount(1)
->and($action->type)->toBe(SecurityActionType::UpdateImage)
->and($action->sentence)->toBe('Update to 15.15-alpine.')
->and($action->target)->toBe('15.15-alpine')
->and($action->ref)->toBe('postgres:15.15-alpine')
->and($action->image)->toBe('postgres:15.0-alpine')
->and($action->fixedIds)->toBe(['CVE-2025-1094'])
->and($action->button)->toBeTrue()
->and($action->copy)->toBeNull();
});
it('matches the fixed IDs of a candidate by alias and in any case', function () {
$scan = actionScan(actionOwner(new StandalonePostgresql), [actionFinding('GHSA-aaaa-bbbb-cccc', ['aliases' => ['CVE-2025-1094']])], [
'candidates' => actionCandidates([actionEntry('postgres:15.15-alpine', 'line', ['cve-2025-1094'])]),
]);
expect(actionsFor($scan)['byFinding']['GHSA-aaaa-bbbb-cccc']->type)->toBe(SecurityActionType::UpdateImage);
});
it('pulls the newest build when only a better same_tag candidate fixes the finding', function () {
$scan = actionScan(actionOwner(new StandalonePostgresql), [actionFinding('CVE-1'), actionFinding('CVE-2')], [
'candidates' => actionCandidates([
actionEntry('postgres:15.15-alpine', 'line', ['CVE-1']),
actionEntry('postgres:15.0-alpine', 'same_tag', ['CVE-1', 'CVE-2']),
]),
]);
$result = actionsFor($scan);
expect($result['byFinding']['CVE-1']->type)->toBe(SecurityActionType::UpdateImage)
->and($result['byFinding']['CVE-2']->type)->toBe(SecurityActionType::PullLatest)
->and($result['byFinding']['CVE-2']->sentence)->toBe('Pull the newer build of postgres:15.0-alpine and restart.')
->and($result['byFinding']['CVE-2']->button)->toBeTrue()
->and($result['actions'])->toHaveCount(2);
});
it('never offers a major version or a candidate that is not better', function () {
$scan = actionScan(actionOwner(new StandalonePostgresql), [actionFinding('CVE-1')], [
'candidates' => actionCandidates([
actionEntry('postgres:16.4-alpine', 'major', ['CVE-1']),
actionEntry('postgres:15.15-alpine', 'line', ['CVE-1'], better: false),
]),
]);
expect(actionsFor($scan)['actions'])->toBe([]);
});
it('gives no action to a finding no candidate fixes, nor to a non-relevant finding', function () {
$scan = actionScan(actionOwner(new StandalonePostgresql), [
actionFinding('CVE-1'),
actionFinding('CVE-2', ['severity' => 'medium']),
], ['candidates' => actionCandidates([actionEntry('postgres:15.15-alpine', 'line', ['CVE-2'])])]);
$result = actionsFor($scan);
expect($result['byFinding'])->toBe([])->and($result['actions'])->toBe([]);
});
it('gives a third-party image no package action', function () {
$scan = actionScan(actionOwner(new StandalonePostgresql), [actionFinding('CVE-1', ['type' => 'go-module', 'package' => 'stdlib'])]);
expect(actionsFor($scan)['actions'])->toBe([]);
});
it('updates an image that leaves an end-of-life OS', function () {
$scan = actionScan(actionOwner(new StandalonePostgresql), [], [
'summary' => ['eol_distro' => ['name' => 'alpine', 'version' => '3.16']],
'candidates' => actionCandidates([actionEntry('postgres:15.15-alpine', 'line', [])]),
]);
$result = actionsFor($scan);
expect($result['eol']?->type)->toBe(SecurityActionType::UpdateImage)
->and($result['actions'])->toHaveCount(1);
});
});
describe('buttons', function () {
it('gives Coolify resources (id 0) no button and the Coolify update text', function () {
$scan = actionScan(actionOwner(new StandalonePostgresql, 0), [actionFinding('CVE-1')], [
'candidates' => actionCandidates([actionEntry('postgres:15.15-alpine', 'line', ['CVE-1'])]),
]);
$action = actionsFor($scan)['byFinding']['CVE-1'];
expect($action->button)->toBeFalse()
->and($action->sentence)->toBe('Update Coolify to get a newer version.');
});
it('gives the images of a Coolify service (id 0) no button', function () {
$service = actionOwner(new Service, 0);
$application = actionOwner(new ServiceApplication(['image' => 'postgres:15.0-alpine']), 3);
$application->setRelation('service', $service);
$scan = actionScan($application, [actionFinding('CVE-1')], [
'candidates' => actionCandidates([actionEntry('postgres:15.15-alpine', 'line', ['CVE-1'])]),
]);
expect(actionsFor($scan)['byFinding']['CVE-1']->button)->toBeFalse();
});
it('gives no button on a transferred-away server', function () {
$server = new Server(['server_metadata' => ['transfer' => ['status' => 'transferred']]]);
$scan = actionScan(actionOwner(new StandalonePostgresql), [actionFinding('CVE-1')], [
'candidates' => actionCandidates([actionEntry('postgres:15.15-alpine', 'line', ['CVE-1'])]),
], $server);
expect(actionsFor($scan)['byFinding']['CVE-1']->button)->toBeFalse();
});
it('gives no button when the compose file sets the image in a way Coolify cannot change', function () {
$service = actionOwner(new Service, 9);
$application = actionOwner(new ServiceApplication(['image' => '${PG_REPO}:${PG_VERSION}']), 3);
$application->setRelation('service', $service);
$scan = actionScan($application, [actionFinding('CVE-1')], [
'candidates' => actionCandidates([actionEntry('postgres:15.15-alpine', 'line', ['CVE-1'])]),
]);
$action = actionsFor($scan)['byFinding']['CVE-1'];
expect($action->type)->toBe(SecurityActionType::UpdateImage)->and($action->button)->toBeFalse();
});
});
describe('own Dockerfile images', function () {
it('changes the base image for a finding from the base that a better base candidate fixes', function () {
$scan = dockerfileScan([actionFinding('CVE-1', ['kev' => true])], actionBase(['CVE-1'], [actionEntry('node:20.18-alpine', 'line', ['CVE-1'])]));
$action = actionsFor($scan)['byFinding']['CVE-1'];
expect($action->type)->toBe(SecurityActionType::ChangeBase)
->and($action->sentence)->toBe('Change the base image to node:20.18-alpine in your Dockerfile, then redeploy.')
->and($action->target)->toBe('node:20.18-alpine')
->and($action->copy)->toBe('FROM node:20.18-alpine')
->and($action->button)->toBeFalse();
});
it('gives no package or rebuild action while the base check does not know the base findings', function (?array $base) {
$scan = dockerfileScan([
actionFinding('CVE-1', ['type' => 'npm', 'package' => 'cross-spawn', 'version' => '7.0.3', 'fixedIn' => '7.0.5']),
actionFinding('CVE-2'),
], $base);
expect(actionsFor($scan)['actions'])->toBe([]);
})->with([
'not run yet' => [null],
'auth required' => [['image' => 'node:20.1-alpine', 'status' => 'auth_required', 'attributable' => null, 'attributable_ids' => [], 'entries' => []]],
'failed' => [['image' => 'node:20.1-alpine', 'status' => 'failed', 'attributable' => null, 'attributable_ids' => [], 'entries' => []]],
'for another base image' => [actionBase([], [], 'node:18.1-alpine')],
]);
it('acts on the findings when the base check knows the base has none of them', function () {
$scan = dockerfileScan([
actionFinding('CVE-1', ['type' => 'npm', 'package' => 'cross-spawn', 'version' => '7.0.3', 'fixedIn' => '7.0.5']),
actionFinding('CVE-2'),
], ['image' => 'node:20.1-alpine', 'status' => 'no_better', 'attributable' => 0, 'attributable_ids' => [], 'entries' => []]);
$result = actionsFor($scan);
expect($result['byFinding']['CVE-1']->type)->toBe(SecurityActionType::UpgradePackage)
->and($result['byFinding']['CVE-2']->type)->toBe(SecurityActionType::Rebuild);
});
it('acts on the findings of a Dockerfile app without a known base image', function () {
$scan = dockerfileScan([actionFinding('CVE-1')], null, ['base_image_ref' => null]);
expect(actionsFor($scan)['byFinding']['CVE-1']->type)->toBe(SecurityActionType::Rebuild);
});
it('rebuilds without cache for a fixed OS package that is not in the current base', function () {
$scan = dockerfileScan([actionFinding('CVE-1'), actionFinding('CVE-2', ['package' => 'zlib'])], actionBase([], []));
$result = actionsFor($scan);
$action = $result['byFinding']['CVE-1'];
expect($result['actions'])->toHaveCount(1)
->and($result['byFinding']['CVE-2'])->toBe($action)
->and($action->type)->toBe(SecurityActionType::Rebuild)
->and($action->sentence)->toBe('Rebuild without cache to get the fixed system packages.')
->and($action->fixedIds)->toBe(['CVE-1', 'CVE-2'])
->and($action->button)->toBeTrue();
});
it('does not rebuild for an OS package without a package fix', function () {
$scan = dockerfileScan([actionFinding('CVE-1', ['fixState' => 'not-fixed', 'fixedIn' => null])]);
expect(actionsFor($scan)['actions'])->toBe([]);
});
it('gives an OS package from the base without a better base candidate no action', function () {
$scan = dockerfileScan([actionFinding('CVE-1')], actionBase(['CVE-1'], []));
expect(actionsFor($scan)['actions'])->toBe([]);
});
it('upgrades a language package that does not come from the base, one action per package', function () {
$scan = dockerfileScan([
actionFinding('CVE-1', ['type' => 'npm', 'package' => 'express', 'version' => '4.17.1', 'fixedIn' => '4.19.2']),
actionFinding('CVE-2', ['type' => 'npm', 'package' => 'express', 'version' => '4.17.1', 'fixedIn' => '4.20.0']),
actionFinding('CVE-3', ['type' => 'npm', 'package' => 'express', 'version' => '4.17.1', 'fixedIn' => '4.18.0']),
actionFinding('CVE-4', ['type' => 'python', 'package' => 'django', 'version' => '4.2.0', 'fixedIn' => '4.2.16']),
]);
$result = actionsFor($scan);
$express = $result['byFinding']['CVE-1'];
expect($result['actions'])->toHaveCount(2)
->and($result['byFinding']['CVE-2'])->toBe($express)
->and($result['byFinding']['CVE-3'])->toBe($express)
->and($express->type)->toBe(SecurityActionType::UpgradePackage)
->and($express->sentence)->toBe('Upgrade express to 4.20.0 or later, then redeploy.')
->and($express->target)->toBe('express')
->and($express->copy)->toBe('express 4.20.0')
->and($express->button)->toBeFalse()
->and($express->fixedIds)->toBe(['CVE-1', 'CVE-2', 'CVE-3'])
->and($result['byFinding']['CVE-4']->sentence)->toBe('Upgrade django to 4.2.16 or later, then redeploy.');
});
it('notes a major version change', function () {
$scan = dockerfileScan([actionFinding('CVE-1', ['type' => 'go-module', 'package' => 'golang.org/x/net', 'version' => 'v0.17.0', 'fixedIn' => 'v1.0.1'])]);
expect(actionsFor($scan)['byFinding']['CVE-1']->sentence)
->toBe('Upgrade golang.org/x/net to v1.0.1 or later, then redeploy. This is a major version change. Check for breaking changes.');
});
it('keeps the first fixed version when versions cannot be compared', function () {
$scan = dockerfileScan([
actionFinding('CVE-1', ['type' => 'npm', 'package' => 'lib', 'version' => '1.0.0', 'fixedIn' => '1.2.0']),
actionFinding('CVE-2', ['type' => 'npm', 'package' => 'lib', 'version' => '1.0.0', 'fixedIn' => 'next-release']),
]);
expect(actionsFor($scan)['byFinding']['CVE-1']->target)->toBe('lib')
->and(actionsFor($scan)['byFinding']['CVE-2']->copy)->toBe('lib 1.2.0');
});
it('prefers a fixed version to a value that is not one', function () {
$scan = dockerfileScan([
actionFinding('CVE-1', ['type' => 'npm', 'package' => 'lib', 'version' => '1.0.0', 'fixedIn' => 'next-release']),
actionFinding('CVE-2', ['type' => 'npm', 'package' => 'lib', 'version' => '1.0.0', 'fixedIn' => '1.2.0']),
]);
expect(actionsFor($scan)['byFinding']['CVE-1']->copy)->toBe('lib 1.2.0');
});
it('never upgrades a binary or a language package from the base', function () {
$scan = dockerfileScan([
actionFinding('CVE-1', ['type' => 'binary', 'package' => 'node', 'version' => '20.1.0', 'fixedIn' => '20.1.1']),
actionFinding('CVE-2', ['type' => 'npm', 'package' => 'npm', 'version' => '9.6.0', 'fixedIn' => '9.6.7']),
], actionBase(['CVE-1', 'CVE-2'], []));
expect(actionsFor($scan)['actions'])->toBe([]);
});
it('changes the base for a binary from the base that the base candidate fixes', function () {
$scan = dockerfileScan([
actionFinding('CVE-1', ['type' => 'binary', 'package' => 'node', 'version' => '20.1.0', 'fixedIn' => '20.1.1']),
], actionBase(['CVE-1'], [actionEntry('node:20.18-alpine', 'line', ['CVE-1'])]));
expect(actionsFor($scan)['byFinding']['CVE-1']->type)->toBe(SecurityActionType::ChangeBase);
});
it('gives a Dockerfile image no image update', function () {
$scan = dockerfileScan([actionFinding('CVE-1', ['fixState' => 'not-fixed', 'fixedIn' => null])], null, [
'candidates' => actionCandidates([actionEntry('abc123:2', 'line', ['CVE-1'])]),
]);
expect(actionsFor($scan)['actions'])->toBe([]);
});
});
describe('own Docker image apps', function () {
it('updates to a better candidate first', function () {
$scan = ownDockerImageScan([actionFinding('CVE-1')], actionCandidates([actionEntry('acme/api:1.5', 'line', ['CVE-1'])]));
expect(actionsFor($scan)['byFinding']['CVE-1']->sentence)->toBe('Update to 1.5.');
});
it('rebuilds in CI for an OS package with a package fix', function () {
$scan = ownDockerImageScan([actionFinding('CVE-1')]);
$action = actionsFor($scan)['byFinding']['CVE-1'];
expect($action->type)->toBe(SecurityActionType::RebuildInCi)
->and($action->sentence)->toBe('Rebuild the image in your CI, then redeploy.')
->and($action->button)->toBeFalse();
});
it('upgrades a language package', function () {
$scan = ownDockerImageScan([actionFinding('CVE-1', ['type' => 'gem', 'package' => 'rack', 'version' => '2.2.3', 'fixedIn' => '2.2.8'])]);
expect(actionsFor($scan)['byFinding']['CVE-1']->type)->toBe(SecurityActionType::UpgradePackage);
});
});
describe('compose builds and builder images', function () {
it('gives the OS packages of a compose build no action, but upgrades its language packages', function () {
$app = composeApp();
$imageRef = 'k8w2c9_web:4f1c2d3';
$scan = actionScan($app, [
actionFinding('CVE-1'),
actionFinding('CVE-2', ['type' => 'php-composer', 'package' => 'guzzlehttp/psr7', 'version' => '2.4.0', 'fixedIn' => '2.4.5']),
], ['image_ref' => $imageRef, 'builds_image' => ScanImage::buildsImage($app, $imageRef)]);
$result = actionsFor($scan);
expect(array_keys($result['byFinding']))->toBe(['CVE-2'])
->and($result['byFinding']['CVE-2']->type)->toBe(SecurityActionType::UpgradePackage);
});
it('treats a pulled image of a compose app as third-party, with a button that keeps the new image', function () {
$app = composeApp();
$scan = actionScan($app, [
actionFinding('CVE-1'),
actionFinding('CVE-2', ['type' => 'go-module', 'package' => 'stdlib', 'version' => 'go1.22.1', 'fixedIn' => '1.22.5']),
], [
'image_ref' => 'postgres:16', 'builds_image' => ScanImage::buildsImage($app, 'postgres:16'),
'candidates' => actionCandidates([actionEntry('postgres:16.4', 'line', ['CVE-1'])]),
]);
$result = actionsFor($scan);
expect($scan->builds_image)->toBeFalse()
->and(array_keys($result['byFinding']))->toBe(['CVE-1'])
->and($result['byFinding']['CVE-1']->type)->toBe(SecurityActionType::UpdateImage)
->and($result['byFinding']['CVE-1']->sentence)->toBe('Update to 16.4.')
->and($result['byFinding']['CVE-1']->target)->toBe('16.4')
->and($result['byFinding']['CVE-1']->button)->toBeTrue();
});
it('tells a compose app that deploys its file as written to change the version in the file', function () {
$app = composeApp(raw: true);
$scan = actionScan($app, [actionFinding('CVE-1')], [
'image_ref' => 'postgres:16', 'builds_image' => ScanImage::buildsImage($app, 'postgres:16'),
'candidates' => actionCandidates([actionEntry('postgres:16.4', 'line', ['CVE-1'])]),
]);
$action = actionsFor($scan)['byFinding']['CVE-1'];
expect($action->sentence)->toBe('Change the version to 16.4 in the compose file of this app, then redeploy.')
->and($action->button)->toBeFalse();
});
it('pulls the scanned build and redeploys a compose app for a newer build of a pulled image', function () {
$app = composeApp();
$scan = actionScan($app, [actionFinding('CVE-1')], [
'image_ref' => 'postgres:16', 'builds_image' => ScanImage::buildsImage($app, 'postgres:16'),
'candidates' => actionCandidates([actionEntry('postgres:16', 'same_tag', ['CVE-1'])]),
]);
$action = actionsFor($scan)['byFinding']['CVE-1'];
expect($action->type)->toBe(SecurityActionType::PullLatest)
->and($action->sentence)->toBe('Pull the newer build of postgres:16 and redeploy.')
->and($action->target)->toBe('16')
->and($action->button)->toBeTrue();
});
it('decides which compose images Coolify builds', function (string $imageRef, bool $builds) {
expect(ScanImage::buildsImage(composeApp(), $imageRef))->toBe($builds);
})->with([
'built service without an image' => ['k8w2c9_web:4f1c2d3', true],
'built preview' => ['k8w2c9_web:pr-4', true],
'built service with an image' => ['ghcr.io/acme/worker:2.0', true],
'built service with an image, other tag' => ['ghcr.io/acme/worker:2.1', true],
'pulled image' => ['postgres:16', false],
'pulled image of another app' => ['other_web:4f1c2d3', false],
]);
it('gives the OS packages of a builder image no action', function (string $buildPack) {
$scan = actionScan(actionApp($buildPack), [actionFinding('CVE-1', ['type' => 'deb'])], ['image_ref' => 'abc123:latest', 'builds_image' => true]);
expect(actionsFor($scan)['actions'])->toBe([]);
})->with(['nixpacks', 'railpack', 'static']);
it('upgrades the language packages of a builder image', function () {
$scan = actionScan(actionApp('nixpacks'), [actionFinding('CVE-1', ['type' => 'npm', 'package' => 'express', 'version' => '4.17.1', 'fixedIn' => '4.19.2'])], ['image_ref' => 'abc123:latest', 'builds_image' => true]);
expect(actionsFor($scan)['byFinding']['CVE-1']->type)->toBe(SecurityActionType::UpgradePackage);
});
it('has the builder text for findings without action', function () {
expect(SecurityActions::BUILDER_TEXT)->toBe('A fix exists, but it reaches this app only with a builder update in a Coolify release. To choose the base image yourself, use the Dockerfile build pack.');
});
});
+403
View File
@@ -0,0 +1,403 @@
<?php
use App\Enums\ImageScanErrorCode;
use App\Enums\ImageScanOnError;
use App\Enums\ImageScanStatus;
use App\Models\Application;
use App\Models\ImageScan;
use App\Models\ImageScanIgnore;
use App\Services\ImageScan\RelevanceRules;
use App\Services\ImageScan\SecurityGate;
function securityGateFinding(string $id, array $overrides = []): array
{
return [
'id' => $id, 'aliases' => [], 'severity' => 'high', 'package' => 'openssl', 'version' => '3.0.13', 'type' => 'deb',
'artifactId' => 'a-'.$id, 'fixedIn' => null, 'fixState' => 'not-fixed', 'kev' => false, 'epss' => null,
'cvssScore' => 8.1, 'namespace' => 'debian:distro:debian:12', ...$overrides,
];
}
function securityGateExploited(string $id, array $overrides = []): array
{
return securityGateFinding($id, ['kev' => true, ...$overrides]);
}
function securityGateScan(array $findings, array $attributes = []): ImageScan
{
$scan = new ImageScan([
'status' => ImageScanStatus::Completed,
'builds_image' => true,
'findings' => $findings,
'summary' => [],
...$attributes,
]);
$scan->setRelation('resource', new Application(['build_pack' => 'dockerfile']));
return $scan;
}
function securityGateIgnores(array ...$rows): RelevanceRules
{
return RelevanceRules::fromIgnores(collect(array_map(fn (array $row) => new ImageScanIgnore(['reason' => 'test', ...$row]), $rows)));
}
describe('mode on', function () {
it('blocks a new finding that attackers are using', function () {
$result = SecurityGate::evaluate(
securityGateScan([securityGateExploited('CVE-2026-1')]),
securityGateScan([]),
'on',
securityGateIgnores(),
);
expect($result['blocked'])->toBeTrue()
->and($result['error'])->toBeNull()
->and($result['headline'])->toBe('Deploy blocked: this image has a security problem that attackers are using.')
->and(array_column($result['new'], 'id'))->toBe(['CVE-2026-1'])
->and($result['carried'])->toBe([]);
});
it('passes a finding that the running image has too and lists it as a warning', function () {
$result = SecurityGate::evaluate(
securityGateScan([securityGateExploited('CVE-2026-1', ['cvssScore' => 9.8, 'severity' => 'critical'])]),
securityGateScan([securityGateExploited('CVE-2026-1')]),
'on',
securityGateIgnores(),
);
expect($result['blocked'])->toBeFalse()
->and($result['headline'])->toBe('')
->and(array_column($result['carried'], 'id'))->toBe(['CVE-2026-1'])
->and(SecurityGate::logLines($result))->toBe([
'Image scan: passed.',
'Already in the running image: a security problem that attackers are using.',
' CVE-2026-1 9.8 Critical openssl 3.0.13 already running',
]);
});
it('does not block without an earlier scan of an app that ran before, and says this deploy is the baseline', function () {
$result = SecurityGate::evaluate(
securityGateScan([securityGateExploited('CVE-2026-1')]),
null,
'on',
securityGateIgnores(),
baselineMissing: true,
);
expect($result['blocked'])->toBeFalse()
->and($result['baseline_missing'])->toBeTrue()
->and(SecurityGate::logLines($result))->toBe([
'Image scan: passed.',
'Coolify has no earlier scan of this app to compare with. This deploy becomes the baseline.',
'This image has a security problem that attackers are using.',
' CVE-2026-1 8.1 High openssl 3.0.13 used by attackers',
]);
});
it('still blocks in strict mode without a baseline', function () {
$result = SecurityGate::evaluate(securityGateScan([securityGateExploited('CVE-2026-1')]), null, 'strict', securityGateIgnores(), baselineMissing: true);
expect($result['blocked'])->toBeTrue()->and($result['baseline_missing'])->toBeFalse();
});
it('matches a carried over finding by its aliases', function () {
$result = SecurityGate::evaluate(
securityGateScan([securityGateExploited('GHSA-aaaa-bbbb-cccc', ['aliases' => ['CVE-2026-7']])]),
securityGateScan([securityGateFinding('CVE-2026-7', ['severity' => 'low'])]),
'on',
securityGateIgnores(),
);
expect($result['blocked'])->toBeFalse()->and($result['carried'])->toHaveCount(1);
});
it('treats every finding as new on the first deploy', function () {
$result = SecurityGate::evaluate(
securityGateScan([securityGateExploited('CVE-2026-1'), securityGateFinding('CVE-2026-2', ['epss' => 0.4])]),
null,
'on',
securityGateIgnores(),
);
expect($result['blocked'])->toBeTrue()
->and($result['headline'])->toBe('Deploy blocked: this image has 2 security problems that attackers are using.')
->and(array_column($result['new'], 'id'))->toBe(['CVE-2026-1', 'CVE-2026-2']);
});
it('does not block when the EPSS of a finding that already runs went up', function () {
$result = SecurityGate::evaluate(
securityGateScan([securityGateFinding('CVE-2026-1', ['epss' => 0.35])]),
securityGateScan([securityGateFinding('CVE-2026-1', ['epss' => 0.04])]),
'on',
securityGateIgnores(),
);
expect($result['blocked'])->toBeFalse()
->and(array_column($result['carried'], 'signal'))->toBe(['likely'])
->and(SecurityGate::logLines($result)[1])->toBe('Already in the running image: a security problem that attackers are likely to use.');
});
it('says likely to use when no blocking finding is exploited', function () {
$result = SecurityGate::evaluate(securityGateScan([securityGateFinding('CVE-2026-1', ['epss' => 0.2])]), null, 'on', securityGateIgnores());
expect($result['headline'])->toBe('Deploy blocked: this image has a security problem that attackers are likely to use.');
});
it('does not block on critical findings with a fix and no exploit signal', function () {
$result = SecurityGate::evaluate(
securityGateScan([securityGateFinding('CVE-2026-1', ['severity' => 'critical', 'fixState' => 'fixed', 'fixedIn' => '3.0.15'])], ['summary' => ['eol_distro' => ['name' => 'debian', 'version' => '9']]]),
null,
'on',
securityGateIgnores(),
);
expect($result['blocked'])->toBeFalse()->and($result['new'])->toBe([]);
});
it('skips ignored findings and CPE-only OS matches', function () {
$result = SecurityGate::evaluate(
securityGateScan([
securityGateExploited('CVE-2026-1'),
securityGateExploited('CVE-2026-2', ['namespace' => 'nvd:cpe']),
]),
null,
'on',
securityGateIgnores(['vulnerability_id' => 'CVE-2026-1']),
);
expect($result['blocked'])->toBeFalse();
});
});
it('does not block a relevant medium finding in mode on: it has no exploit signal', function (bool $countMedium) {
$medium = securityGateFinding('CVE-2026-1', ['severity' => 'medium', 'cvssScore' => 5.3, 'epss' => 0.5, 'fixState' => 'fixed', 'fixedIn' => '3.0.15']);
$result = SecurityGate::evaluate(securityGateScan([$medium]), securityGateScan([]), 'on', RelevanceRules::fromIgnores(collect(), $countMedium));
expect($result['blocked'])->toBeFalse()
->and($result['carried'])->toBe([]);
})->with(['default' => false, 'medium problems count' => true]);
describe('mode strict', function () {
it('blocks a critical finding with a package fix', function () {
$result = SecurityGate::evaluate(
securityGateScan([securityGateFinding('CVE-2026-1', ['severity' => 'critical', 'fixState' => 'fixed', 'fixedIn' => '3.0.15'])]),
null,
'strict',
securityGateIgnores(),
);
expect($result['blocked'])->toBeTrue()
->and($result['headline'])->toBe('Deploy blocked: this image has a security problem that needs a security update.')
->and(SecurityGate::logLines($result))->toBe([
'Deploy blocked: this image has a security problem that needs a security update.',
' CVE-2026-1 8.1 Critical openssl 3.0.13 fixed in 3.0.15',
'See the Security page for all problems.',
]);
});
it('blocks a medium finding with a package fix only when it is relevant', function (array $overrides, bool $countMedium, bool $blocked) {
$medium = securityGateFinding('CVE-2026-1', ['severity' => 'medium', 'cvssScore' => 5.3, 'fixState' => 'fixed', 'fixedIn' => '3.0.15', ...$overrides]);
$result = SecurityGate::evaluate(securityGateScan([$medium]), null, 'strict', RelevanceRules::fromIgnores(collect(), $countMedium));
expect($result['blocked'])->toBe($blocked);
})->with([
'medium without EPSS' => [[], false, false],
'medium with EPSS 0.10 or more' => [['epss' => 0.2], false, true],
'medium when medium problems count' => [[], true, true],
'low when medium problems count' => [['severity' => 'low', 'cvssScore' => 3.1], true, false],
]);
it('does not block a relevant medium finding without a package fix', function () {
$medium = securityGateFinding('CVE-2026-1', ['severity' => 'medium', 'cvssScore' => 5.3, 'epss' => 0.2]);
expect(SecurityGate::evaluate(securityGateScan([$medium]), null, 'strict', securityGateIgnores())['blocked'])->toBeFalse();
});
it('blocks an end-of-life OS', function () {
$result = SecurityGate::evaluate(
securityGateScan([], ['summary' => ['eol_distro' => ['name' => 'debian', 'version' => '9']]]),
null,
'strict',
securityGateIgnores(),
);
expect($result['blocked'])->toBeTrue()
->and($result['headline'])->toBe('Deploy blocked: this image has a security problem that needs a security update.')
->and(SecurityGate::logLines($result))->toContain(' This image uses Debian 9, which no longer gets security fixes.')
->and(SecurityGate::stored($result)['count'])->toBe(1);
});
it('uses the plural for several problems that need an update', function () {
$result = SecurityGate::evaluate(
securityGateScan([securityGateFinding('CVE-2026-1', ['fixState' => 'fixed', 'fixedIn' => '1.1'])], ['summary' => ['eol_distro' => ['name' => 'alpine', 'version' => '3.16']]]),
null,
'strict',
securityGateIgnores(),
);
expect($result['headline'])->toBe('Deploy blocked: this image has 2 security problems that need a security update.');
});
it('blocks an exploited finding that the running image has too', function () {
$result = SecurityGate::evaluate(
securityGateScan([securityGateExploited('CVE-2026-1')]),
securityGateScan([securityGateExploited('CVE-2026-1')]),
'strict',
securityGateIgnores(),
);
expect($result['blocked'])->toBeTrue()
->and($result['headline'])->toBe('Deploy blocked: this image has a security problem that attackers are using.')
->and($result['carried'])->toBe([]);
});
it('does not block on medium findings or findings without a package fix', function () {
$result = SecurityGate::evaluate(
securityGateScan([
securityGateFinding('CVE-2026-1', ['severity' => 'medium', 'cvssScore' => 5.0, 'fixState' => 'fixed', 'fixedIn' => '1.1']),
securityGateFinding('CVE-2026-2', ['severity' => 'critical']),
]),
null,
'strict',
securityGateIgnores(),
);
expect($result['blocked'])->toBeFalse();
});
});
it('never blocks when the mode is off', function (?ImageScanErrorCode $error) {
$result = SecurityGate::evaluate(
securityGateScan([securityGateExploited('CVE-2026-1')], ['summary' => ['eol_distro' => ['name' => 'debian', 'version' => '9']]]),
null,
'off',
securityGateIgnores(),
$error,
onScanError: ImageScanOnError::Block,
);
expect($result['blocked'])->toBeFalse()->and($result['headline'])->toBe('');
})->with([
'scan ran' => [null],
'scan error' => [ImageScanErrorCode::Timeout],
]);
it('follows "If the scan cannot run" for a scan error', function (ImageScanOnError $onScanError, bool $blocked, string $headline) {
$result = SecurityGate::evaluate(null, null, 'on', securityGateIgnores(), ImageScanErrorCode::DiskSpace, ['server' => 'prod', 'n' => 3], $onScanError);
expect($result['blocked'])->toBe($blocked)
->and($result['scan_ran'])->toBeFalse()
->and($result['error'])->toBe('disk_space')
->and($result['headline'])->toBe($headline)
->and(SecurityGate::logLines($result))->toBe([$headline]);
})->with([
'deploy anyway' => [ImageScanOnError::Deploy, false, 'Scan skipped: Not enough disk space on prod: 3 GB needed.'],
'block' => [ImageScanOnError::Block, true, 'Deploy blocked: Not enough disk space on prod: 3 GB needed.'],
]);
it('treats a cut scan result as a scan that could not run', function (string $mode, ImageScanOnError $onScanError, bool $blocked, string $headline) {
$result = SecurityGate::evaluate(
securityGateScan([], ['summary' => ['truncated' => true]]),
null,
$mode,
securityGateIgnores(),
onScanError: $onScanError,
);
expect($result['blocked'])->toBe($blocked)
->and($result['scan_ran'])->toBeFalse()
->and($result['error'])->toBe('truncated')
->and($result['headline'])->toBe($headline);
})->with([
'on, deploy anyway' => ['on', ImageScanOnError::Deploy, false, 'Scan skipped: The scan result was too large to read completely.'],
'strict, block' => ['strict', ImageScanOnError::Block, true, 'Deploy blocked: The scan result was too large to read completely.'],
]);
it('treats a failed scan as an unknown error', function () {
$result = SecurityGate::evaluate(securityGateScan([], ['status' => ImageScanStatus::Failed]), null, 'strict', securityGateIgnores());
expect($result['blocked'])->toBeFalse()
->and($result['error'])->toBe('unknown')
->and($result['headline'])->toBe('Scan skipped: The scan could not run.');
});
it('orders the blocking problems exploited first and caps the log at five', function () {
$result = SecurityGate::evaluate(securityGateScan([
securityGateFinding('LIKELY-1', ['epss' => 0.5, 'cvssScore' => 9.9]),
securityGateExploited('KEV-LOW', ['cvssScore' => 7.0]),
securityGateExploited('KEV-HIGH', ['cvssScore' => 9.0]),
securityGateFinding('LIKELY-2', ['epss' => 0.5, 'cvssScore' => 7.5]),
securityGateFinding('LIKELY-3', ['epss' => 0.5, 'cvssScore' => 7.4]),
securityGateFinding('LIKELY-4', ['epss' => 0.5, 'cvssScore' => 7.3]),
]), null, 'on', securityGateIgnores());
$lines = SecurityGate::logLines($result);
$stored = SecurityGate::stored($result);
expect(array_column($result['new'], 'id'))->toBe(['KEV-HIGH', 'KEV-LOW', 'LIKELY-1', 'LIKELY-2', 'LIKELY-3', 'LIKELY-4'])
->and($lines[1])->toBe(' KEV-HIGH 9.0 High openssl 3.0.13 used by attackers')
->and($lines[3])->toBe(' LIKELY-1 9.9 High openssl 3.0.13 likely to be exploited')
->and($lines[6])->toBe(' and 1 more')
->and($stored['count'])->toBe(6)
->and($stored['top'])->toHaveCount(5)
->and($stored)->toMatchArray(['blocked' => true, 'scan_ran' => true, 'error' => null, 'mode' => 'on']);
});
it('leaves out the score of a finding without one', function () {
$result = SecurityGate::evaluate(securityGateScan([securityGateExploited('CVE-2026-1', ['cvssScore' => null])]), null, 'on', securityGateIgnores());
expect(SecurityGate::logLines($result)[1])->toBe(' CVE-2026-1 High openssl 3.0.13 used by attackers');
});
it('counts a finding once when it appears for several packages', function () {
$result = SecurityGate::evaluate(securityGateScan([
securityGateExploited('CVE-2026-1'),
securityGateExploited('CVE-2026-1', ['package' => 'libssl3']),
]), null, 'on', securityGateIgnores());
expect($result['new'])->toHaveCount(1)
->and($result['headline'])->toBe('Deploy blocked: this image has a security problem that attackers are using.');
});
it('reads candidate entries from the wrapped shape and from a bare list', function (array $candidates) {
$scan = securityGateScan([], ['builds_image' => false, 'candidates' => $candidates]);
expect(array_column($scan->candidateEntries(), 'ref'))->toBe(['postgres:15-alpine', 'postgres:16-alpine']);
})->with([
'wrapped' => [['status' => 'ok', 'checked_at' => '2026-10-26T00:00:00+00:00', 'entries' => [
['ref' => 'postgres:15-alpine', 'kind' => 'line', 'better' => true, 'fixed_ids' => ['CVE-1']],
['ref' => 'postgres:16-alpine', 'kind' => 'major', 'better' => false, 'fixed_ids' => ['CVE-2']],
]]],
'bare list' => [[
['ref' => 'postgres:15-alpine', 'kind' => 'line', 'better' => true, 'fixed_ids' => ['CVE-1']],
['ref' => 'postgres:16-alpine', 'kind' => 'major', 'better' => false, 'fixed_ids' => ['CVE-2']],
]],
]);
it('reads no candidate entries of a wrapped result without entries', function () {
$scan = securityGateScan([], ['candidates' => ['status' => 'auth_required', 'checked_at' => null, 'entries' => []]]);
expect($scan->candidateEntries())->toBe([])->and($scan->candidateStatus())->toBe('auth_required');
});
it('writes a headline without counts for chat and webhook channels', function (array $findings, array $summary, string $mode, string $counted, string $plain) {
$result = SecurityGate::evaluate(securityGateScan($findings, ['summary' => $summary]), null, $mode, securityGateIgnores());
expect($result['headline'])->toBe($counted)
->and($result['headline_plain'])->toBe($plain)
->and(SecurityGate::stored($result)['headline_plain'])->toBe($plain)
->and($plain)->not->toMatch('/\d/');
})->with([
'exploited' => [[securityGateExploited('CVE-2026-1'), securityGateExploited('CVE-2026-2')], [], 'on',
'Deploy blocked: this image has 2 security problems that attackers are using.',
'Deploy blocked: this image has security problems that attackers are using.'],
'likely' => [[securityGateFinding('CVE-2026-1', ['epss' => 0.3])], [], 'on',
'Deploy blocked: this image has a security problem that attackers are likely to use.',
'Deploy blocked: this image has security problems that attackers are likely to use.'],
'update' => [[securityGateFinding('CVE-2026-1', ['fixState' => 'fixed', 'fixedIn' => '1.1'])], ['eol_distro' => ['name' => 'debian', 'version' => '9']], 'strict',
'Deploy blocked: this image has 2 security problems that need a security update.',
'Deploy blocked: this image needs a security update.'],
]);
+130
View File
@@ -0,0 +1,130 @@
<?php
use App\Models\ImageScanIgnore;
use App\Services\ImageScan\Finding;
use App\Services\ImageScan\GrypeReportParser;
use App\Services\ImageScan\RelevanceRules;
use App\Services\ImageScan\SecurityRules;
function securityFinding(array $overrides = []): Finding
{
return Finding::fromArray([
'id' => 'CVE-2024-0001', 'aliases' => [], 'severity' => 'medium', 'package' => 'openssl', 'version' => '1.0',
'type' => 'apk', 'artifactId' => 'a', 'fixedIn' => '1.1', 'fixState' => 'fixed', 'kev' => false,
'namespace' => 'alpine:distro:alpine:3.16', ...$overrides,
]);
}
function securityIgnores(string ...$ids): RelevanceRules
{
return RelevanceRules::fromIgnores(collect(array_map(fn (string $id) => new ImageScanIgnore(['reason' => 'test', 'vulnerability_id' => $id]), $ids)));
}
it('gives a known exploited finding the exploited signal', function () {
expect(SecurityRules::exploitSignal(securityFinding(['kev' => true, 'severity' => 'low'])))->toBe('exploited');
});
it('gives a likely signal only with an EPSS of 0.10 and a score of 7.0 or more', function (?float $epss, ?float $score, string $severity, ?string $signal) {
expect(SecurityRules::exploitSignal(securityFinding(['epss' => $epss, 'cvssScore' => $score, 'severity' => $severity])))->toBe($signal);
})->with([
'epss 0.2 and score 7.5' => [0.2, 7.5, 'high', 'likely'],
'epss 0.10 and score 7.0' => [0.10, 7.0, 'high', 'likely'],
'epss 0.2 and score 5.3' => [0.2, 5.3, 'high', null],
'epss 0.09 and score 9.8' => [0.09, 9.8, 'critical', null],
'no epss' => [null, 9.8, 'critical', null],
'epss 0.2, no score, high' => [0.2, null, 'high', 'likely'],
'epss 0.2, no score, critical' => [0.2, null, 'critical', 'likely'],
'epss 0.2, no score, medium' => [0.2, null, 'medium', null],
]);
it('gives CVE-2023-2650 of the fixture no exploit signal but counts it: a medium with a high EPSS and a score of 6.5', function () {
$findings = (new GrypeReportParser)->parse(file_get_contents(dirname(__DIR__, 2).'/Fixtures/image-scan/grype.ndjson'))['findings'];
$finding = collect($findings)->firstWhere('id', 'CVE-2023-2650');
expect($finding->epss)->toBeGreaterThan(0.1)
->and(SecurityRules::exploitSignal($finding))->toBeNull()
->and(SecurityRules::isRelevant($finding, securityIgnores()))->toBeTrue();
});
it('counts a medium finding with an EPSS of 0.10 or more as relevant without an exploit signal', function (?float $epss, ?float $score, bool $relevant) {
$finding = securityFinding(['severity' => 'medium', 'epss' => $epss, 'cvssScore' => $score]);
expect(SecurityRules::isRelevant($finding, securityIgnores()))->toBe($relevant)
->and(SecurityRules::exploitSignal($finding))->toBeNull();
})->with([
'epss 0.10 and score 5.3' => [0.10, 5.3, true],
'epss 0.5 and no score' => [0.5, null, true],
'epss 0.09 and score 6.5' => [0.09, 6.5, false],
'no epss' => [null, 6.5, false],
]);
it('counts every medium finding as relevant only when the team counts medium problems', function () {
$finding = securityFinding(['severity' => 'medium']);
expect(SecurityRules::isRelevant($finding, securityIgnores()))->toBeFalse()
->and(SecurityRules::isRelevant($finding, RelevanceRules::fromIgnores(collect(), countMedium: true)))->toBeTrue()
->and(SecurityRules::exploitSignal($finding))->toBeNull();
});
it('does not count a low finding without KEV as relevant, also when medium problems count', function () {
$counting = RelevanceRules::fromIgnores(collect(), countMedium: true);
expect(SecurityRules::isRelevant(securityFinding(['severity' => 'low', 'epss' => 0.9]), $counting))->toBeFalse()
->and(SecurityRules::isRelevant(securityFinding(['severity' => 'low', 'kev' => true]), securityIgnores()))->toBeTrue();
});
it('keeps a medium finding in KEV exploited', function () {
$finding = securityFinding(['severity' => 'medium', 'kev' => true]);
expect(SecurityRules::exploitSignal($finding))->toBe('exploited')
->and(SecurityRules::isRelevant($finding, securityIgnores()))->toBeTrue();
});
it('does not count an ignored medium finding when medium problems count', function () {
$rules = RelevanceRules::fromIgnores(collect([new ImageScanIgnore(['reason' => 'test', 'vulnerability_id' => 'CVE-2024-0001'])]), countMedium: true);
expect(SecurityRules::isRelevant(securityFinding(['severity' => 'medium']), $rules))->toBeFalse();
});
it('counts high and critical findings and findings with an exploit signal as relevant', function () {
$none = securityIgnores();
expect(SecurityRules::isRelevant(securityFinding(['severity' => 'high']), $none))->toBeTrue()
->and(SecurityRules::isRelevant(securityFinding(['severity' => 'critical']), $none))->toBeTrue()
->and(SecurityRules::isRelevant(securityFinding(['severity' => 'medium']), $none))->toBeFalse()
->and(SecurityRules::isRelevant(securityFinding(['severity' => 'low']), $none))->toBeFalse()
->and(SecurityRules::isRelevant(securityFinding(['severity' => 'low', 'kev' => true]), $none))->toBeTrue()
->and(SecurityRules::isRelevant(securityFinding(['severity' => 'medium', 'epss' => 0.5, 'cvssScore' => 7.2]), $none))->toBeTrue();
});
it('does not count an ignored finding as relevant', function () {
$finding = securityFinding(['id' => 'GHSA-xxxx', 'aliases' => ['CVE-2024-9999'], 'severity' => 'critical', 'kev' => true]);
expect(SecurityRules::isRelevant($finding, securityIgnores('cve-2024-9999')))->toBeFalse();
});
it('does not count an OS package matched only by CPE as relevant', function (string $type, string $namespace, bool $cpeOnly) {
$finding = securityFinding(['severity' => 'critical', 'kev' => true, 'type' => $type, 'namespace' => $namespace]);
expect(SecurityRules::isCpeOnlyOsMatch($finding))->toBe($cpeOnly)
->and(SecurityRules::isRelevant($finding, securityIgnores()))->toBe(! $cpeOnly);
})->with([
'apk by cpe' => ['apk', 'nvd:cpe', true],
'deb by cpe' => ['deb', 'nvd:cpe', true],
'rpm by cpe' => ['rpm', 'NVD:CPE', true],
'apk by distro' => ['apk', 'alpine:distro:alpine:3.16', false],
'binary by cpe' => ['binary', 'nvd:cpe', false],
'npm by cpe' => ['npm', 'nvd:cpe', false],
'old row without namespace' => ['apk', '', false],
]);
it('lists the IDs and aliases of the relevant findings in lowercase', function () {
$findings = [
securityFinding(['id' => 'GHSA-AAAA', 'aliases' => ['CVE-2024-1'], 'severity' => 'high']),
securityFinding(['id' => 'CVE-2024-2', 'severity' => 'medium']),
securityFinding(['id' => 'CVE-2024-3', 'severity' => 'critical']),
securityFinding(['id' => 'CVE-2024-1', 'severity' => 'high', 'package' => 'libssl']),
];
expect(SecurityRules::relevantIds($findings, securityIgnores('CVE-2024-3')))->toBe(['ghsa-aaaa', 'cve-2024-1']);
});
@@ -0,0 +1,652 @@
<?php
use App\Enums\SecurityActionType;
use App\Enums\SecurityStatusType;
use App\Models\Application;
use App\Models\ImageScan;
use App\Services\ImageScan\ResourceSecurity;
use App\Services\ImageScan\SecurityAction;
use App\Services\ImageScan\SecurityActions;
use App\Services\ImageScan\SecuritySentences;
use Carbon\CarbonImmutable;
function sentenceAction(SecurityActionType $type = SecurityActionType::UpdateImage, ?string $sentence = null, ?string $tag = '16.4-alpine'): SecurityAction
{
return new SecurityAction(
type: $type,
sentence: $sentence ?? "Update to {$tag}.",
target: $tag,
copy: null,
fixedIds: [],
button: true,
image: 'postgres:16.1-alpine',
ref: "postgres:{$tag}",
);
}
/**
* @param array<string, mixed> $overrides
*/
function sentenceSecurity(SecurityStatusType $status, array $overrides = []): ResourceSecurity
{
return new ResourceSecurity(...[...$overrides, 'status' => $status]);
}
function sentenceUpdateNow(array $overrides = []): ResourceSecurity
{
return sentenceSecurity(SecurityStatusType::UpdateNow, [
'actions' => [sentenceAction()], 'problemCount' => 1, 'fixedByPrimary' => 1, 'exploitedCount' => 1, 'anyExploited' => true,
...$overrides,
]);
}
function sentenceUpdateAvailable(array $overrides = []): ResourceSecurity
{
return sentenceSecurity(SecurityStatusType::UpdateAvailable, [
'actions' => [sentenceAction()], 'problemCount' => 3, 'criticalCount' => 1, 'highCount' => 2, 'fixedByPrimary' => 3,
'primaryCritical' => 1, 'primaryHigh' => 2,
...$overrides,
]);
}
it('writes the update_now headline for one problem that attackers are using', function () {
expect(SecuritySentences::headline(sentenceUpdateNow()))
->toBe('This image has a security problem that attackers are using. Update to 16.4-alpine to fix it.');
});
it('writes the update_now headline for many problems', function () {
$security = sentenceUpdateNow(['problemCount' => 3, 'fixedByPrimary' => 3, 'exploitedCount' => 3]);
expect(SecuritySentences::headline($security))
->toBe('This image has 3 security problems that attackers are using. Update to 16.4-alpine to fix them.');
});
it('says "to fix {k} of them" when the action fixes only some', function () {
$security = sentenceUpdateNow(['problemCount' => 3, 'fixedByPrimary' => 2, 'exploitedCount' => 3]);
expect(SecuritySentences::headline($security))
->toBe('This image has 3 security problems that attackers are using. Update to 16.4-alpine to fix 2 of them.');
});
it('uses the likely wording when no finding is exploited', function () {
$security = sentenceUpdateNow(['problemCount' => 2, 'fixedByPrimary' => 2, 'exploitedCount' => 2, 'anyExploited' => false]);
expect(SecuritySentences::headline($security))
->toBe('This image has 2 security problems that attackers are likely to use. Update to 16.4-alpine to fix them.');
});
it('adds nothing to the sentence of an action other than update_image', function () {
$pull = sentenceAction(SecurityActionType::PullLatest, 'A newer build of this image fixes it. Pull it and restart.');
$rebuild = sentenceAction(SecurityActionType::Rebuild, 'Rebuild without cache to get the fixed system packages.');
expect(SecuritySentences::headline(sentenceUpdateNow(['actions' => [$pull]])))
->toBe('This image has a security problem that attackers are using. A newer build of this image fixes it. Pull it and restart.')
->and(SecuritySentences::headline(sentenceUpdateNow(['actions' => [$rebuild]])))
->toBe('This image has a security problem that attackers are using. Rebuild without cache to get the fixed system packages.');
});
it('does not append "to fix" to an update_image sentence that is not "Update to {tag}."', function () {
$coolify = sentenceAction(SecurityActionType::UpdateImage, SecurityActions::COOLIFY_TEXT);
$compose = sentenceAction(SecurityActionType::UpdateImage, 'Change the version to 2.10.4 in the compose file of this app, then redeploy.');
expect(SecuritySentences::headline(sentenceUpdateNow(['actions' => [$coolify]])))->toEndWith(' Update Coolify to get a newer version.')
->and(SecuritySentences::headline(sentenceUpdateNow(['actions' => [$compose]])))->toEndWith('compose file of this app, then redeploy.');
});
it('appends "to fix" to a merged service update sentence', function () {
$merged = sentenceAction(SecurityActionType::UpdateImage, 'Update n8n and n8n runners to 2.10.4.');
expect(SecuritySentences::headline(sentenceUpdateNow(['actions' => [$merged], 'problemCount' => 2, 'fixedByPrimary' => 2])))
->toEndWith('Update n8n and n8n runners to 2.10.4 to fix them.');
});
it('writes the update_available headline', function () {
expect(SecuritySentences::headline(sentenceUpdateAvailable()))
->toBe('Update to 16.4-alpine. This fixes 3 serious problems (1 critical, 2 high). Attackers are not known to use them.');
});
it('writes update_available for one problem and leaves out a zero part', function () {
$one = sentenceUpdateAvailable(['fixedByPrimary' => 1, 'primaryCritical' => 0, 'primaryHigh' => 1]);
$criticalOnly = sentenceUpdateAvailable(['fixedByPrimary' => 2, 'primaryCritical' => 2, 'primaryHigh' => 0]);
expect(SecuritySentences::headline($one))
->toBe('Update to 16.4-alpine. This fixes 1 serious problem (1 high). Attackers are not known to use it.')
->and(SecuritySentences::headline($criticalOnly))
->toBe('Update to 16.4-alpine. This fixes 2 serious problems (2 critical). Attackers are not known to use them.');
});
it('counts medium problems in update_available without calling them serious', function () {
$mixed = sentenceUpdateAvailable(['fixedByPrimary' => 6, 'primaryCritical' => 1, 'primaryHigh' => 3, 'primaryMedium' => 2]);
$oneMedium = sentenceUpdateAvailable(['fixedByPrimary' => 1, 'primaryCritical' => 0, 'primaryHigh' => 0, 'primaryMedium' => 1]);
expect(SecuritySentences::headline($mixed))
->toBe('Update to 16.4-alpine. This fixes 6 problems (1 critical, 3 high, 2 medium). Attackers are not known to use them.')
->and(SecuritySentences::headline($oneMedium))
->toBe('Update to 16.4-alpine. This fixes 1 problem (1 medium). Attackers are not known to use it.');
});
it('leaves out "Attackers are not known to use" when a problem that attackers use has no fix', function () {
$security = sentenceUpdateAvailable(['exploitedWithoutFix' => true, 'exploitedCount' => 1]);
expect(SecuritySentences::headline($security))
->toBe('Update to 16.4-alpine. This fixes 3 serious problems (1 critical, 2 high).');
});
it('writes the exploited_without_fix extra line for update_now and update_available', function () {
$kev = ['counts' => ['kev_unfixed' => 1]];
expect(SecuritySentences::lines(sentenceUpdateNow(['exploitedWithoutFix' => true, 'exploitedCount' => 2, 'facts' => $kev])))
->toBe(['One more problem that attackers are using has no fix yet.'])
->and(SecuritySentences::lines(sentenceUpdateNow(['exploitedWithoutFix' => true, 'exploitedCount' => 4, 'facts' => $kev])))
->toBe(['3 more problems that attackers are using have no fix yet.'])
->and(SecuritySentences::lines(sentenceUpdateAvailable(['exploitedWithoutFix' => true, 'exploitedCount' => 1, 'facts' => $kev])))
->toBe(['One more problem that attackers are using has no fix yet.'])
->and(SecuritySentences::lines(sentenceUpdateAvailable(['exploitedWithoutFix' => true, 'exploitedCount' => 2, 'facts' => $kev])))
->toBe(['2 more problems that attackers are using have no fix yet.']);
});
it('says "likely to use" in the extra line when no problem without a fix is a known exploited one', function () {
expect(SecuritySentences::lines(sentenceUpdateNow(['exploitedWithoutFix' => true, 'exploitedCount' => 2])))
->toBe(['One more problem that attackers are likely to use has no fix yet.'])
->and(SecuritySentences::lines(sentenceUpdateAvailable(['exploitedWithoutFix' => true, 'exploitedCount' => 3, 'facts' => ['counts' => ['kev_unfixed' => 0]]])))
->toBe(['3 more problems that attackers are likely to use have no fix yet.']);
});
it('writes no extra line when nothing is left without a fix', function () {
expect(SecuritySentences::lines(sentenceUpdateNow(['exploitedWithoutFix' => true, 'exploitedCount' => 1])))->toBe([])
->and(SecuritySentences::lines(sentenceUpdateAvailable(['exploitedWithoutFix' => true, 'exploitedCount' => 0])))->toBe([]);
});
it('writes no extra line without the exploited_without_fix flag or for other statuses', function () {
expect(SecuritySentences::lines(sentenceUpdateNow()))->toBe([])
->and(SecuritySentences::lines(sentenceSecurity(SecurityStatusType::NoFix, ['exploitedWithoutFix' => true, 'exploitedCount' => 1])))->toBe([])
->and(SecuritySentences::lines(sentenceSecurity(SecurityStatusType::Ok)))->toBe([]);
});
it('says "likely to use" for a no_fix image when no problem is a known exploited one', function () {
$security = sentenceSecurity(SecurityStatusType::NoFix, ['problemCount' => 2, 'anyExploited' => false]);
expect(SecuritySentences::headline($security))
->toStartWith('This image has 2 security problems that attackers are likely to use, and no fixed version exists yet. Coolify checks every day');
});
it('writes the no_fix headline', function () {
$middle = ', and no fixed version exists yet. Coolify checks every day and tells you when a fix is out. Until then, limit who can reach this app, or ';
expect(SecuritySentences::headline(sentenceSecurity(SecurityStatusType::NoFix, ['problemCount' => 1, 'anyExploited' => true])))
->toBe('This image has a security problem that attackers are using'.$middle.'ignore the problem if the affected part is not used.')
->and(SecuritySentences::headline(sentenceSecurity(SecurityStatusType::NoFix, ['problemCount' => 2, 'anyExploited' => true])))
->toBe('This image has 2 security problems that attackers are using'.$middle.'ignore the problems if the affected parts are not used.');
});
it('writes the builder no_fix headline with the problem first', function () {
$builder = fn (bool $kev, int $count): string => SecuritySentences::headline(sentenceSecurity(SecurityStatusType::NoFix, ['problemCount' => $count, 'anyExploited' => $kev, 'builderImage' => true]));
expect($builder(true, 1))->toBe('This image has a security problem that attackers are using. '.SecurityActions::BUILDER_TEXT)
->and($builder(false, 2))->toBe('This image has 2 security problems that attackers are likely to use. '.SecurityActions::BUILDER_TEXT)
->and(SecuritySentences::headline(sentenceSecurity(SecurityStatusType::NoFix, ['problemCount' => 1, 'anyExploited' => true])))
->not->toContain(SecurityActions::BUILDER_TEXT);
});
it('says what a pull_latest action fixes in update_now', function () {
$pull = sentenceAction(SecurityActionType::PullLatest, 'A newer build of this image fixes it. Pull it and restart.');
$compose = sentenceAction(SecurityActionType::PullLatest, 'A newer build of this image fixes it. Redeploy this app to pull it.');
$coolify = sentenceAction(SecurityActionType::PullLatest, SecurityActions::COOLIFY_TEXT);
$head = 'This image has a security problem that attackers are using. ';
expect(SecuritySentences::headline(sentenceUpdateNow(['actions' => [$pull]])))->toBe($head.'A newer build of this image fixes it. Pull it and restart.')
->and(SecuritySentences::headline(sentenceUpdateNow(['actions' => [$compose]])))->toBe($head.'A newer build of this image fixes it. Redeploy this app to pull it.')
->and(SecuritySentences::headline(sentenceUpdateNow(['actions' => [$coolify]])))->toBe($head.SecurityActions::COOLIFY_TEXT)
->and(SecuritySentences::headline(sentenceUpdateNow(['actions' => [$pull], 'problemCount' => 3, 'fixedByPrimary' => 3])))
->toBe('This image has 3 security problems that attackers are using. A newer build of this image fixes them. Pull it and restart.')
->and(SecuritySentences::headline(sentenceUpdateNow(['actions' => [$pull], 'problemCount' => 3, 'fixedByPrimary' => 2])))
->toEndWith('A newer build of this image fixes 2 of them. Pull it and restart.');
});
it('keeps the pull instruction of the new pull_latest action copy', function () {
$restart = sentenceAction(SecurityActionType::PullLatest, 'Pull the newer build of postgres:16-alpine and restart.', '16-alpine');
$redeploy = sentenceAction(SecurityActionType::PullLatest, 'Pull the newer build of postgres:16-alpine and redeploy.', '16-alpine');
expect(SecuritySentences::headline(sentenceUpdateNow(['actions' => [$restart]])))
->toBe('This image has a security problem that attackers are using. A newer build of this image fixes it. Pull it and restart.')
->and(SecuritySentences::headline(sentenceUpdateNow(['actions' => [$redeploy]])))
->toBe('This image has a security problem that attackers are using. A newer build of this image fixes it. Pull it and redeploy.')
->and(SecuritySentences::imageSentence(sentenceUpdateNow(['actions' => [$restart]]), 'postgres:16-alpine'))
->toBe('postgres:16-alpine has a security problem that attackers are using. A newer build fixes it. Pull it and restart.');
});
it('says a newer build is available for a pull_latest action in update_available', function () {
$pull = sentenceAction(SecurityActionType::PullLatest, 'A newer build of this image fixes it. Pull it and restart.');
$compose = sentenceAction(SecurityActionType::PullLatest, 'A newer build of this image fixes it. Redeploy this app to pull it.');
$coolify = sentenceAction(SecurityActionType::PullLatest, SecurityActions::COOLIFY_TEXT);
expect(SecuritySentences::headline(sentenceUpdateAvailable(['actions' => [$pull]])))
->toBe('A newer build of this image is available. Pull it and restart. This fixes 3 serious problems (1 critical, 2 high). Attackers are not known to use them.')
->and(SecuritySentences::headline(sentenceUpdateAvailable(['actions' => [$compose]])))
->toStartWith('A newer build of this image is available. Redeploy this app to pull it. This fixes')
->and(SecuritySentences::headline(sentenceUpdateAvailable(['actions' => [$coolify]])))
->toStartWith(SecurityActions::COOLIFY_TEXT.' This fixes');
});
it('puts "This fixes" before the major version note', function () {
$upgrade = sentenceAction(SecurityActionType::UpgradePackage, 'Upgrade lodash to 5.0.0 or later, then redeploy. This is a major version change. Check for breaking changes.');
expect(SecuritySentences::headline(sentenceUpdateAvailable(['actions' => [$upgrade], 'fixedByPrimary' => 1, 'primaryCritical' => 1, 'primaryHigh' => 0])))
->toBe('Upgrade lodash to 5.0.0 or later, then redeploy. This fixes 1 serious problem (1 critical). This is a major version change. Check for breaking changes. Attackers are not known to use it.');
});
it('says when the primary action moves the image off an end-of-life OS', function () {
$eol = ['name' => 'Alpine', 'version' => '3.16', 'fixed' => true];
$stats = ['action_stats' => [['eol' => 1]]];
$sentence = 'It moves off Alpine 3.16, which no longer gets security fixes.';
expect(SecuritySentences::headline(sentenceUpdateAvailable(['eol' => $eol, 'facts' => $stats])))
->toBe('Update to 16.4-alpine. This fixes 3 serious problems (1 critical, 2 high). '.$sentence.' Attackers are not known to use them.')
->and(SecuritySentences::headline(sentenceUpdateNow(['eol' => $eol, 'facts' => $stats])))
->toEndWith('Update to 16.4-alpine to fix it. '.$sentence)
->and(SecuritySentences::headline(sentenceUpdateAvailable(['eol' => $eol, 'facts' => ['action_stats' => [['eol' => 0]]]])))
->not->toContain('no longer gets')
->and(SecuritySentences::headline(sentenceUpdateAvailable(['eol' => ['name' => 'Alpine', 'version' => '3.16', 'fixed' => false]])))
->not->toContain('no longer gets');
});
it('says the last check is incomplete for an unknown check_needed reason', function () {
expect(SecuritySentences::headline(sentenceSecurity(SecurityStatusType::CheckNeeded, ['checkReason' => 'something_new'])))
->toBe('Coolify needs to check this image again: the last check is incomplete.')
->and(SecuritySentences::headline(sentenceSecurity(SecurityStatusType::CheckNeeded)))
->toBe('Coolify needs to check this image again: the last check is incomplete.');
});
it('writes the ok headline', function () {
expect(SecuritySentences::headline(sentenceSecurity(SecurityStatusType::Ok)))->toBe('No action needed.');
});
it('writes the check_needed headline with each reason', function () {
$cases = [
['images_unchecked', ['checked' => 1, 'total' => 3], '1 of 3 images were checked'],
['db_outdated', [], 'the vulnerability database is out of date'],
['image_changed', [], 'the running image is not the checked image'],
['versions_unchecked', [], 'Coolify has not checked for newer versions yet'],
];
foreach ($cases as [$code, $context, $reason]) {
$security = sentenceSecurity(SecurityStatusType::CheckNeeded, ['checkReason' => $code, 'checkContext' => $context]);
expect(SecuritySentences::checkReason($code, $context))->toBe($reason)
->and(SecuritySentences::headline($security))->toBe("Coolify needs to check this image again: {$reason}.");
}
});
it('writes the stale reason with the time of the last check', function () {
$time = CarbonImmutable::now()->subDays(3);
expect(SecuritySentences::checkReason('stale', ['time' => $time->toIso8601String()]))->toBe('the last check was 3 days ago');
$security = sentenceSecurity(SecurityStatusType::CheckNeeded, ['checkReason' => 'stale', 'checkContext' => ['time' => $time->toIso8601String()], 'checkedAt' => $time]);
expect(SecuritySentences::headline($security))->toBe('Coolify needs to check this image again: the last check was 3 days ago.');
});
it('falls back to the checked time when the stale context has no time', function () {
$time = CarbonImmutable::now()->subDays(4);
$security = sentenceSecurity(SecurityStatusType::CheckNeeded, ['checkReason' => 'stale', 'checkedAt' => $time]);
expect(SecuritySentences::headline($security))->toBe('Coolify needs to check this image again: the last check was 4 days ago.');
});
it('writes a neutral text for not_checked', function () {
$texts = [
'instance' => 'Image scanning is off for this Coolify instance.',
'server' => 'Image scanning is off for this server.',
'application' => 'Image scanning is off for this app.',
'not_scanned' => 'This image has not been scanned yet.',
];
foreach ($texts as $code => $text) {
expect(SecuritySentences::headline(sentenceSecurity(SecurityStatusType::NotChecked, ['checkReason' => $code])))->toBe($text);
}
expect(SecuritySentences::headline(sentenceSecurity(SecurityStatusType::NotChecked)))->toBe('This image has not been scanned yet.');
});
it('writes the why-more line in the right grammar', function () {
expect(SecuritySentences::whyMore(0))->toBe('')
->and(SecuritySentences::whyMore(1))->toBe('and 1 more problem that this update fixes')
->and(SecuritySentences::whyMore(4))->toBe('and 4 more problems that this update fixes');
});
it('names the badge of each status', function () {
expect(SecuritySentences::badge(SecurityStatusType::UpdateNow))->toBe('Update now')
->and(SecuritySentences::badge(SecurityStatusType::UpdateAvailable))->toBe('Security update')
->and(SecuritySentences::badge(SecurityStatusType::NoFix))->toBe('No fix yet')
->and(SecuritySentences::badge(SecurityStatusType::CheckNeeded))->toBe('Check needed')
->and(SecuritySentences::badge(SecurityStatusType::Ok))->toBeNull()
->and(SecuritySentences::badge(SecurityStatusType::NotChecked))->toBeNull();
});
it('writes the database update confirmation for a patch release', function () {
expect(SecuritySentences::databaseUpdateConfirmation('PostgreSQL', '16.1-alpine', '16.4-alpine'))
->toBe('This is a patch release of PostgreSQL 16. Your data stays as it is. The database restarts and is unavailable for a few seconds.');
});
it('takes the major version of the confirmation from the new tag', function () {
expect(SecuritySentences::databaseUpdateConfirmation('MySQL', 'v8.0.35', 'v8.0.40'))
->toContain('patch release of MySQL 8.')
->and(SecuritySentences::databaseUpdateConfirmation('Redis', 'latest', 'latest'))
->toContain('patch release of Redis.');
});
it('never writes an em dash, "safe" or "secure" in any sentence', function () {
$time = CarbonImmutable::now()->subDays(3);
$all = [];
$securities = [
sentenceUpdateNow(), sentenceUpdateNow(['problemCount' => 3, 'fixedByPrimary' => 2, 'anyExploited' => false, 'exploitedWithoutFix' => true, 'exploitedCount' => 5]),
sentenceUpdateAvailable(), sentenceUpdateAvailable(['exploitedWithoutFix' => true, 'exploitedCount' => 1, 'fixedByPrimary' => 1, 'primaryCritical' => 1, 'primaryHigh' => 0]),
sentenceSecurity(SecurityStatusType::NoFix, ['problemCount' => 1]), sentenceSecurity(SecurityStatusType::NoFix, ['problemCount' => 3]),
sentenceSecurity(SecurityStatusType::Ok),
sentenceSecurity(SecurityStatusType::NotChecked),
sentenceSecurity(SecurityStatusType::NoFix, ['problemCount' => 2, 'builderImage' => true]),
sentenceSecurity(SecurityStatusType::CheckNeeded, ['checkReason' => 'unknown']),
sentenceUpdateNow(['actions' => [sentenceAction(SecurityActionType::PullLatest, 'A newer build of this image fixes it. Pull it and restart.')], 'eol' => ['name' => 'Alpine', 'version' => '3.16', 'fixed' => true], 'facts' => ['action_stats' => [['eol' => 1]]]]),
sentenceUpdateAvailable(['actions' => [sentenceAction(SecurityActionType::PullLatest, 'A newer build of this image fixes it. Pull it and restart.')], 'eol' => ['name' => 'Alpine', 'version' => '3.16', 'fixed' => true], 'facts' => ['action_stats' => [['eol' => 1]]]]),
];
foreach (['instance', 'server', 'application', 'not_scanned'] as $code) {
$securities[] = sentenceSecurity(SecurityStatusType::NotChecked, ['checkReason' => $code]);
}
foreach (['images_unchecked', 'stale', 'db_outdated', 'image_changed', 'versions_unchecked'] as $code) {
$securities[] = sentenceSecurity(SecurityStatusType::CheckNeeded, ['checkReason' => $code, 'checkContext' => ['checked' => 1, 'total' => 2, 'time' => $time->toIso8601String()], 'checkedAt' => $time]);
}
foreach ($securities as $security) {
$all[] = SecuritySentences::headline($security);
$all[] = SecuritySentences::headline(sentenceSecurity($security->status, [...get_object_vars($security), 'builderImage' => true]));
array_push($all, ...SecuritySentences::lines($security));
$all[] = (string) SecuritySentences::badge($security->status);
}
foreach ([0, 1, 5] as $n) {
$all[] = SecuritySentences::whyMore($n);
}
$all[] = SecuritySentences::databaseUpdateConfirmation('PostgreSQL', '16.1', '16.4');
$all[] = SecurityActions::BUILDER_TEXT;
foreach ($all as $text) {
expect($text)->not->toContain('—')
->and(strtolower($text))->not->toMatch('/\b(safe|secure)\b/');
}
});
/**
* @param array<string, mixed> $attributes
*/
function sentenceScan(array $attributes = []): ImageScan
{
return (new ImageScan)->forceFill([
'image_ref' => 'postgres:16-alpine',
'candidates' => ['status' => 'ok', 'checked_at' => CarbonImmutable::now()->subHours(3)->toIso8601String(), 'entries' => []],
...$attributes,
]);
}
/**
* @return array<string, mixed>
*/
function sentenceCandidate(string $ref, string $kind): array
{
return ['ref' => $ref, 'kind' => $kind, 'better' => true, 'fixed_ids' => [], 'scanned_at' => CarbonImmutable::now()->subHours(2)->toIso8601String()];
}
describe('image rows', function () {
it('starts the sentence of an image with its ref', function () {
expect(SecuritySentences::imageSentence(sentenceUpdateNow(), 'n8nio/n8n:1.68.0'))
->toBe('n8nio/n8n:1.68.0 has a security problem that attackers are using. Update to 16.4-alpine to fix it.')
->and(SecuritySentences::imageSentence(sentenceUpdateNow(['problemCount' => 2, 'fixedByPrimary' => 2, 'exploitedCount' => 2]), 'n8nio/n8n:1.68.0'))
->toBe('n8nio/n8n:1.68.0 has 2 security problems that attackers are using. Update to 16.4-alpine to fix them.');
});
it('says that a newer build fixes critical problems', function (int $critical, string $problems) {
$pull = sentenceAction(SecurityActionType::PullLatest, 'A newer build of this image fixes it. Pull it and restart.', '16-alpine');
$security = sentenceUpdateAvailable(['actions' => [$pull], 'fixedByPrimary' => $critical, 'primaryCritical' => $critical, 'primaryHigh' => 0]);
expect(SecuritySentences::imageSentence($security, 'postgres:16-alpine'))->toBe("postgres:16-alpine has a newer build that fixes {$problems}.");
})->with([
'one' => [1, 'a critical problem'],
'two' => [2, '2 critical problems'],
]);
it('names the severities of an update and the action', function () {
expect(SecuritySentences::imageSentence(sentenceUpdateAvailable(), 'postgres:16.1-alpine'))
->toBe('postgres:16.1-alpine has 3 serious problems (1 critical, 2 high). Update to 16.4-alpine to fix them.');
});
it('names medium problems in the sentence of an image', function (int $critical, int $high, int $medium, string $problems) {
$security = sentenceUpdateAvailable(['fixedByPrimary' => $critical + $high + $medium, 'primaryCritical' => $critical, 'primaryHigh' => $high, 'primaryMedium' => $medium]);
expect(SecuritySentences::imageSentence($security, 'postgres:16.1-alpine'))->toBe("postgres:16.1-alpine has {$problems}. Update to 16.4-alpine to fix ".($critical + $high + $medium === 1 ? 'it.' : 'them.'));
})->with([
'one medium' => [0, 0, 1, 'a medium problem'],
'only mediums' => [0, 0, 2, '2 medium problems'],
'mixed' => [1, 3, 2, '6 problems (1 critical, 3 high, 2 medium)'],
]);
it('adds the attack note only when the sentence does not mention attackers', function () {
expect(SecuritySentences::attackNote(sentenceUpdateAvailable()))->toBe('No known attacks on these problems.')
->and(SecuritySentences::attackNote(sentenceUpdateAvailable(['fixedByPrimary' => 1])))->toBe('No known attacks on this problem.')
->and(SecuritySentences::attackNote(sentenceUpdateAvailable(['exploitedWithoutFix' => true])))->toBeNull()
->and(SecuritySentences::attackNote(sentenceUpdateNow()))->toBeNull()
->and(SecuritySentences::attackNote(sentenceSecurity(SecurityStatusType::NoFix, ['problemCount' => 1, 'anyExploited' => true])))->toBeNull()
->and(SecuritySentences::attackNote(sentenceSecurity(SecurityStatusType::Ok)))->toBeNull();
});
it('says that no fixed version exists yet, with the advice on its own line', function (int $count, bool $exploited, string $sentence, string $advice) {
$security = sentenceSecurity(SecurityStatusType::NoFix, ['problemCount' => $count, 'anyExploited' => $exploited]);
expect(SecuritySentences::imageSentence($security, 'mysql:8'))->toBe($sentence)
->and(SecuritySentences::noFixAdvice($security))->toBe($advice);
})->with([
'one, using' => [1, true, 'mysql:8 has a security problem that attackers are using. No fixed version exists yet, and Coolify checks every day.',
'Until then, limit who can reach this app, or ignore the problem if the affected part is not used.'],
'two, likely' => [2, false, 'mysql:8 has 2 security problems that attackers are likely to use. No fixed version exists yet, and Coolify checks every day.',
'Until then, limit who can reach this app, or ignore the problems if the affected parts are not used.'],
]);
it('gives no advice to a builder image, whose sentence names the builder update', function () {
$security = sentenceSecurity(SecurityStatusType::NoFix, ['problemCount' => 1, 'anyExploited' => true, 'builderImage' => true]);
expect(SecuritySentences::imageSentence($security, 'app:abc'))->toBe('app:abc has a security problem that attackers are using. '.SecurityActions::BUILDER_TEXT)
->and(SecuritySentences::noFixAdvice($security))->toBeNull();
});
it('starts the sentence of an image that Coolify builds with "This app" instead of its long ref', function (string $buildPack, bool $buildsImage, string $start) {
$scan = sentenceScan(['image_ref' => 'k8s0w4c:3f2a1c9e8b7d6a5f4e3d2c1b0a9f8e7d6c5b4a39', 'builds_image' => $buildsImage]);
$scan->setRelation('resource', new Application(['build_pack' => $buildPack]));
$ref = (string) $scan->image_ref;
expect(SecuritySentences::imageSentence(sentenceUpdateNow(), $ref, $scan))->toStartWith($start.' has a security problem that attackers are using.')
->and(SecuritySentences::imageSentence(sentenceSecurity(SecurityStatusType::Ok), $ref, $scan))->toBe("{$start} needs no action.")
->and(SecuritySentences::imageSentence(sentenceSecurity(SecurityStatusType::NotChecked), $ref, $scan))->toBe("{$start} has not been checked yet.")
->and(SecuritySentences::imageSentence(sentenceSecurity(SecurityStatusType::NoFix, ['problemCount' => 1, 'anyExploited' => true, 'builderImage' => true]), $ref, $scan))
->toBe("{$start} has a security problem that attackers are using. ".SecurityActions::BUILDER_TEXT);
})->with([
'dockerfile' => ['dockerfile', true, 'This app'],
'nixpacks' => ['nixpacks', true, 'This app'],
'compose build' => ['dockercompose', true, 'This app'],
'docker image built in CI' => ['dockerimage', true, 'k8s0w4c:3f2a1c9e8b7d6a5f4e3d2c1b0a9f8e7d6c5b4a39'],
'pulled image' => ['dockercompose', false, 'k8s0w4c:3f2a1c9e8b7d6a5f4e3d2c1b0a9f8e7d6c5b4a39'],
]);
it('says that the fix is not in the newest image of the line when only the package has a fix, like the provenance', function (array $entries, string $tail) {
$finding = ['id' => 'CVE-2024-6119', 'aliases' => [], 'severity' => 'high', 'package' => 'openssl', 'version' => '3.5.8', 'type' => 'rpm',
'artifactId' => 'a', 'fixedIn' => '3.5.9', 'fixState' => 'fixed', 'kev' => false, 'epss' => 0.5, 'risk' => 1.0, 'summary' => '', 'displayId' => 'CVE-2024-6119'];
$why = [['title' => 'TLS name checks', 'display_id' => 'CVE-2024-6119', 'link' => null, 'score' => 7.5, 'severity' => 'high', 'signal' => 'likely', 'package' => 'openssl', 'version' => '3.5.8']];
$scan = sentenceScan(['image_ref' => 'mysql:8.0', 'findings' => [$finding], 'candidates' => ['status' => 'no_better', 'checked_at' => CarbonImmutable::now()->subHours(3)->toIso8601String(), 'entries' => $entries]]);
$security = sentenceSecurity(SecurityStatusType::NoFix, ['problemCount' => 1, 'why' => $why]);
expect(SecuritySentences::imageSentence($security, 'mysql:8.0', $scan))->toBe('mysql:8.0 has a security problem that attackers are likely to use. '.$tail)
->and(SecuritySentences::provenance($scan, null, $security))->toContain('Fixed versions exist, but the image does not include them yet.')
->and(SecuritySentences::provenance($scan, null, $security))->not->toContain('no fixed version');
})->with([
'runs the newest' => [[], 'The fix is not in the newest mysql 8.x image yet. Coolify checks every day.'],
'newer tag without the fix' => [[[...sentenceCandidate('mysql:8.4', 'line'), 'better' => false, 'fixed_ids' => []]], 'The fix is not in the newest mysql 8.x image yet. Coolify checks every day.'],
'newer tag with the fix, but not better' => [[[...sentenceCandidate('mysql:8.4', 'line'), 'better' => false, 'fixed_ids' => ['CVE-2024-6119']]], 'A fix exists, but the image does not include it yet. Coolify checks every day.'],
]);
it('speaks of one fix when one problem is found in two packages', function () {
$finding = ['id' => 'CVE-2024-6119', 'aliases' => [], 'severity' => 'high', 'package' => 'openssl', 'version' => '3.5.8', 'type' => 'rpm',
'artifactId' => 'a', 'fixedIn' => '3.5.9', 'fixState' => 'fixed', 'kev' => false, 'epss' => 0.5, 'risk' => 1.0, 'summary' => '', 'displayId' => 'CVE-2024-6119'];
$why = [['title' => 'TLS name checks', 'display_id' => 'CVE-2024-6119', 'link' => null, 'score' => 7.5, 'severity' => 'high', 'signal' => 'likely', 'package' => 'openssl', 'version' => '3.5.8']];
$scan = sentenceScan(['image_ref' => 'mysql:8.0', 'findings' => [$finding, [...$finding, 'package' => 'openssl-libs', 'artifactId' => 'b']],
'candidates' => ['status' => 'no_better', 'checked_at' => CarbonImmutable::now()->subHours(3)->toIso8601String(), 'entries' => []]]);
$security = sentenceSecurity(SecurityStatusType::NoFix, ['problemCount' => 1, 'why' => $why]);
expect(SecuritySentences::imageSentence($security, 'mysql:8.0', $scan))
->toBe('mysql:8.0 has a security problem that attackers are likely to use. The fix is not in the newest mysql 8.x image yet. Coolify checks every day.');
});
it('says that a fix exists without naming a line when the version check did not cover it', function () {
$finding = ['id' => 'CVE-2024-6119', 'aliases' => [], 'severity' => 'high', 'package' => 'openssl', 'version' => '3.5.8', 'type' => 'rpm',
'artifactId' => 'a', 'fixedIn' => '3.5.9', 'fixState' => 'fixed', 'kev' => true, 'epss' => 0.5, 'risk' => 1.0, 'summary' => '', 'displayId' => 'CVE-2024-6119'];
$why = [['title' => 'TLS name checks', 'display_id' => 'CVE-2024-6119', 'link' => null, 'score' => 7.5, 'severity' => 'high', 'signal' => 'exploited', 'package' => 'openssl', 'version' => '3.5.8']];
$security = sentenceSecurity(SecurityStatusType::NoFix, ['problemCount' => 2, 'anyExploited' => true, 'why' => [...$why, [...$why[0], 'display_id' => 'CVE-2024-0001']]]);
$failed = sentenceScan(['image_ref' => 'mysql:8.0', 'findings' => [$finding, [...$finding, 'id' => 'CVE-2024-0001', 'displayId' => 'CVE-2024-0001']], 'candidates' => ['status' => 'failed', 'entries' => []]]);
$mixed = sentenceScan(['image_ref' => 'mysql:8.0', 'findings' => [$finding, [...$finding, 'id' => 'CVE-2024-0001', 'displayId' => 'CVE-2024-0001', 'fixState' => 'not-fixed', 'fixedIn' => null]], 'candidates' => ['status' => 'failed', 'entries' => []]]);
expect(SecuritySentences::imageSentence($security, 'mysql:8.0', $failed))
->toBe('mysql:8.0 has 2 security problems that attackers are using. A fix exists for each, but the image does not include them yet. Coolify checks every day.')
->and(SecuritySentences::imageSentence($security, 'mysql:8.0', $mixed))
->toBe('mysql:8.0 has 2 security problems that attackers are using. Some of them have a fix that the image does not include yet. Coolify checks every day.')
->and(SecuritySentences::imageSentence($security, 'mysql:8.0'))
->toBe('mysql:8.0 has 2 security problems that attackers are using. No fixed version exists yet, and Coolify checks every day.');
});
it('gives a no_fix image a short action text for the team page', function () {
expect(SecuritySentences::noFixAction(sentenceSecurity(SecurityStatusType::NoFix, ['problemCount' => 1])))->toBe('No fix released yet. Coolify checks daily.')
->and(SecuritySentences::noFixAction(sentenceSecurity(SecurityStatusType::NoFix, ['problemCount' => 1, 'builderImage' => true])))->toBe('Needs a builder update in a Coolify release.')
->and(SecuritySentences::noFixAction(sentenceUpdateNow()))->toBeNull()
->and(SecuritySentences::noFixAction(sentenceSecurity(SecurityStatusType::Ok)))->toBeNull();
});
it('counts the known issues of an image', function () {
expect(SecuritySentences::knownIssues(12))->toBe('12 known issues')
->and(SecuritySentences::knownIssues(1))->toBe('1 known issue')
->and(SecuritySentences::knownIssues(0))->toBe('No known issues')
->and(SecuritySentences::imagesNeedingUpdate(2, 3))->toBe('2 of 3 images need an update')
->and(SecuritySentences::imagesNeedingUpdate(1, 3))->toBe('1 of 3 images needs an update')
->and(SecuritySentences::imagesNeedingUpdate(0, 3))->toBeNull()
->and(SecuritySentences::shortDigest('sha256:3f1a2b4c5d6e7f8091a2b3c4d5e6f708'))->toBe('3f1a2b4c5d6e');
});
});
describe('provenance', function () {
it('says that the registry has a newer build of the same tag, with what its scan found', function () {
$scan = sentenceScan(['candidates' => ['status' => 'ok', 'entries' => [[
...sentenceCandidate('postgres:16-alpine', 'same_tag'), 'fixes' => ['critical' => 2, 'high' => 0, 'medium' => 0], 'added' => 0,
]]]]);
$action = new SecurityAction(SecurityActionType::PullLatest, 'A newer build of this image fixes it. Pull it and restart.', '16-alpine', null, [], true, 'postgres:16-alpine', 'postgres:16-alpine');
expect(SecuritySentences::provenance($scan, $action, sentenceUpdateAvailable()))
->toBe('Docker Hub has a newer build of 16-alpine. Coolify scanned it 2 hours ago: it fixes these problems and adds none that attackers use.');
});
it('says that the tag is the newest release of the line, and the other problems it adds', function (array $stored, string $other) {
$scan = sentenceScan([
'image_ref' => 'ghcr.io/n8n-io/n8n:1.68.0',
'candidates' => ['status' => 'ok', 'entries' => [[...sentenceCandidate('ghcr.io/n8n-io/n8n:1.71.2', 'line'), ...$stored]]],
]);
$action = new SecurityAction(SecurityActionType::UpdateImage, 'Update to 1.71.2.', '1.71.2', null, [], true, 'ghcr.io/n8n-io/n8n:1.68.0', 'ghcr.io/n8n-io/n8n:1.71.2');
expect(SecuritySentences::provenance($scan, $action, sentenceUpdateNow()))
->toBe('1.71.2 is the newest 1.x release on GitHub Container Registry. Coolify scanned it 2 hours ago: it fixes these problems and adds none that attackers use.'.$other);
})->with([
'adds several' => [['fixes' => ['critical' => 1, 'high' => 0, 'medium' => 0], 'added' => 3], ' It has 3 other problems that attackers are not known to use.'],
'adds one' => [['added' => 1], ' It has 1 other problem that attackers are not known to use.'],
'adds none' => [['added' => 0], ''],
'old entry without added' => [['fixes' => ['critical' => 0, 'high' => 2, 'medium' => 1]], ''],
]);
it('claims no scan when no candidate entry is stored', function () {
$action = new SecurityAction(SecurityActionType::UpdateImage, 'Update to 1.71.2.', '1.71.2', null, [], true, 'n8nio/n8n:1.68.0', 'n8nio/n8n:1.71.2');
expect(SecuritySentences::provenance(sentenceScan(['image_ref' => 'n8nio/n8n:1.68.0']), $action, sentenceUpdateNow()))->toBeNull();
});
it('says that the new base image is the newest release', function () {
$scan = sentenceScan([
'image_ref' => 'app:abc1234',
'base_image_ref' => 'node:20.11-alpine',
'candidates' => ['status' => 'skipped', 'entries' => [], 'base' => [
'image' => 'node:20.11-alpine', 'status' => 'ok', 'entries' => [sentenceCandidate('node:20.18-alpine', 'line')],
]],
]);
$action = new SecurityAction(SecurityActionType::ChangeBase, 'Change the base image to node:20.18-alpine in your Dockerfile, then redeploy.', 'node:20.18-alpine', 'FROM node:20.18-alpine', [], false, 'app:abc1234');
expect(SecuritySentences::provenance($scan, $action, sentenceUpdateAvailable()))
->toBe('node:20.18-alpine is the newest release of your base image. Coolify scanned it 2 hours ago: it fixes these problems and adds none that attackers use.');
});
it('names the first fixed version of a package upgrade', function (SecurityActionType $type) {
$action = new SecurityAction($type, 'Upgrade lodash to 4.17.21 or later, then redeploy.', 'lodash', 'lodash 4.17.21', [], false, 'app:abc1234');
expect(SecuritySentences::provenance(sentenceScan(), $action, sentenceUpdateAvailable(), ['fixed_in' => '4.17.21']))
->toBe('The advisory lists lodash 4.17.21 as the first fixed version.');
})->with([SecurityActionType::UpgradePackage, SecurityActionType::UpgradeInDockerfile]);
it('says that the current base image has the fixed system packages', function () {
$action = new SecurityAction(SecurityActionType::Rebuild, 'Rebuild without cache to get the fixed system packages.', null, null, [], true, 'app:abc1234');
expect(SecuritySentences::provenance(sentenceScan(['base_image_ref' => 'node:20.18-alpine']), $action, sentenceUpdateAvailable()))
->toBe('The current node:20.18-alpine image already has the fixed system packages.');
});
it('says when Coolify last checked the registry for a fix', function () {
$noFix = sentenceSecurity(SecurityStatusType::NoFix, ['problemCount' => 1, 'anyExploited' => true]);
expect(SecuritySentences::provenance(sentenceScan(['candidates' => ['status' => 'no_better', 'checked_at' => CarbonImmutable::now()->subHours(3)->toIso8601String(), 'entries' => []]]), null, $noFix))
->toBe('Coolify checked Docker Hub 3 hours ago: you already run the newest 16.x version.')
->and(SecuritySentences::provenance(sentenceScan(['image_ref' => 'quay.io/keycloak/keycloak:26.0', 'candidates' => ['status' => 'no_better', 'checked_at' => CarbonImmutable::now()->subHours(3)->toIso8601String(), 'entries' => [
[...sentenceCandidate('quay.io/keycloak/keycloak:26.0', 'same_tag'), 'better' => false],
[...sentenceCandidate('quay.io/keycloak/keycloak:26.4', 'line'), 'better' => false],
]]]), null, sentenceSecurity(SecurityStatusType::NoFix, ['problemCount' => 2])))
->toBe('Coolify checked Quay 3 hours ago: a newer build of 26.0 and 26.4 are newer, but not better.')
->and(SecuritySentences::provenance(sentenceScan(['candidates' => ['status' => 'failed', 'entries' => []]]), null, $noFix))->toBeNull()
->and(SecuritySentences::provenance(sentenceScan(), null, sentenceSecurity(SecurityStatusType::NoFix, ['problemCount' => 1, 'builderImage' => true])))->toBeNull();
});
it('names the packages of a no-fix problem and whether a fixed version of them exists', function (string $fixState, ?string $fixedIn, string $text) {
$finding = ['id' => 'CVE-2024-6119', 'aliases' => [], 'severity' => 'medium', 'package' => 'openssl', 'version' => '3.5.8', 'type' => 'rpm',
'artifactId' => 'a', 'fixedIn' => $fixedIn, 'fixState' => $fixState, 'kev' => false, 'epss' => 0.5, 'risk' => 1.0, 'summary' => '', 'displayId' => 'CVE-2024-6119'];
$why = [['title' => 'TLS name checks', 'display_id' => 'CVE-2024-6119', 'link' => null, 'score' => 7.5, 'severity' => 'medium', 'signal' => 'likely', 'package' => 'openssl', 'version' => '3.5.8']];
$scan = sentenceScan(['image_ref' => 'mysql:8', 'findings' => [$finding], 'candidates' => ['status' => 'no_better', 'checked_at' => CarbonImmutable::now()->subHours(3)->toIso8601String(), 'entries' => []]]);
expect(SecuritySentences::provenance($scan, null, sentenceSecurity(SecurityStatusType::NoFix, ['problemCount' => 1, 'why' => $why])))
->toBe('Coolify checked Docker Hub 3 hours ago: you already run the newest 8.x version. '.$text);
})->with([
'no fixed package' => ['not-fixed', null, 'The problem is in openssl, and no fixed version of it exists yet.'],
'a fixed package the image does not include' => ['fixed', '3.5.9', 'The problem is in openssl. Fixed versions exist, but the image does not include them yet.'],
]);
it('names only the package of a no-fix problem when no stored finding matches it', function () {
$why = [['title' => 'TLS name checks', 'display_id' => 'CVE-2024-6119', 'link' => null, 'score' => 7.5, 'severity' => 'medium', 'signal' => 'likely', 'package' => 'openssl', 'version' => '3.5.8']];
$scan = sentenceScan(['image_ref' => 'mysql:8', 'findings' => [], 'candidates' => ['status' => 'no_better', 'checked_at' => CarbonImmutable::now()->subHours(3)->toIso8601String(), 'entries' => []]]);
expect(SecuritySentences::provenance($scan, null, sentenceSecurity(SecurityStatusType::NoFix, ['problemCount' => 1, 'why' => $why])))
->toBe('Coolify checked Docker Hub 3 hours ago: you already run the newest 8.x version. The problem is in openssl.');
});
it('says whether the newest major version would fix the problems, without offering it', function (bool $fixes, array $fixedIds, string $text) {
$why = [['title' => 'TLS name checks', 'display_id' => 'CVE-2024-6119', 'link' => null, 'score' => 7.5, 'severity' => 'medium', 'signal' => 'likely', 'package' => '', 'version' => '']];
$major = [...sentenceCandidate('mysql:26', 'major'), 'better' => false, 'fixes_problems' => $fixes, 'fixed_ids' => $fixedIds];
$scan = sentenceScan(['image_ref' => 'mysql:8', 'candidates' => ['status' => 'no_better', 'checked_at' => CarbonImmutable::now()->subHours(3)->toIso8601String(), 'entries' => [$major]]]);
expect(SecuritySentences::provenance($scan, null, sentenceSecurity(SecurityStatusType::NoFix, ['problemCount' => 1, 'why' => $why])))
->toBe('Coolify checked Docker Hub 3 hours ago: you already run the newest 8.x version. '.$text);
})->with([
'fixes it' => [true, ['CVE-2024-6119'], 'mysql:26, a new major version, fixes it. Coolify does not change major versions for you, because they can need a data migration or code changes: read its upgrade notes first.'],
'not safer' => [false, ['CVE-2024-6119'], 'The newest major version, mysql:26, is not better either.'],
'safer, but not for these problems' => [true, [], 'The newest major version, mysql:26, is not better either.'],
]);
it('names the patch line of an exact tag', function () {
$scan = sentenceScan(['image_ref' => 'mysql:8.0.40', 'candidates' => ['status' => 'ok', 'checked_at' => CarbonImmutable::now()->subHours(3)->toIso8601String(), 'entries' => [sentenceCandidate('mysql:8.0.45', 'line')]]]);
$action = new SecurityAction(SecurityActionType::UpdateImage, 'Update to 8.0.45.', '8.0.45', null, [], true, 'mysql:8.0.40', 'mysql:8.0.45');
expect(SecuritySentences::provenance($scan, $action, sentenceUpdateAvailable()))
->toBe('8.0.45 is the newest 8.0.x release on Docker Hub. Coolify scanned it 2 hours ago: it fixes these problems and adds none that attackers use.');
});
});
@@ -0,0 +1,235 @@
<?php
/**
* Severity dots are non-text UI (WCAG 1.4.11: 3:1) on the x-status-badge
* background, and "medium" must not read as the accent or the warning color.
* Color math lives here so the app carries none of it.
*/
/**
* @return array{0: float, 1: float, 2: float} linear sRGB
*/
function severityOklchToLinear(float $l, float $c, float $hueDegrees): array
{
$a = $c * cos(deg2rad($hueDegrees));
$b = $c * sin(deg2rad($hueDegrees));
$lp = ($l + 0.3963377774 * $a + 0.2158037573 * $b) ** 3;
$mp = ($l - 0.1055613458 * $a - 0.0638541728 * $b) ** 3;
$sp = ($l - 0.0894841775 * $a - 1.2914855480 * $b) ** 3;
return [
4.0767416621 * $lp - 3.3077115913 * $mp + 0.2309699292 * $sp,
-1.2684380046 * $lp + 2.6097574011 * $mp - 0.3413193965 * $sp,
-0.0041960863 * $lp - 0.7034186147 * $mp + 1.7076147010 * $sp,
];
}
function severityCubeRoot(float $value): float
{
return $value < 0 ? -((-$value) ** (1 / 3)) : $value ** (1 / 3);
}
/**
* @param array{0: float, 1: float, 2: float} $rgb
* @return array{0: float, 1: float, 2: float} OKLab
*/
function severityLinearToOklab(array $rgb): array
{
[$r, $g, $b] = $rgb;
$l = severityCubeRoot(0.4122214708 * $r + 0.5363325363 * $g + 0.0514459929 * $b);
$m = severityCubeRoot(0.2119034982 * $r + 0.6806995451 * $g + 0.1073969566 * $b);
$s = severityCubeRoot(0.0883024619 * $r + 0.2817188376 * $g + 0.6299787005 * $b);
return [
0.2104542553 * $l + 0.7936177850 * $m - 0.0040720468 * $s,
1.9779984951 * $l - 2.4285922050 * $m + 0.4505937099 * $s,
0.0259040371 * $l + 0.7827717662 * $m - 0.8086757660 * $s,
];
}
function severityEncode(float $linear): float
{
return $linear <= 0.0031308 ? 12.92 * $linear : 1.055 * $linear ** (1 / 2.4) - 0.055;
}
function severityDecode(float $encoded): float
{
return $encoded <= 0.04045 ? $encoded / 12.92 : (($encoded + 0.055) / 1.055) ** 2.4;
}
/**
* Parses `oklch(56% 0.135 50)` or `#a391ff` into linear sRGB.
*
* @return array{0: float, 1: float, 2: float}
*/
function severityParseColor(string $value): array
{
$value = trim($value);
if (preg_match('/^#([0-9a-f]{6})$/i', $value, $hex)) {
return array_map(
fn (string $pair): float => severityDecode(hexdec($pair) / 255),
str_split($hex[1], 2),
);
}
if (preg_match('/^oklch\(\s*([\d.]+)%\s+([\d.]+)\s+([\d.]+)\s*\)$/', $value, $m)) {
return severityOklchToLinear((float) $m[1] / 100, (float) $m[2], (float) $m[3]);
}
throw new InvalidArgumentException("Unsupported color: {$value}");
}
function severityInGamut(array $rgb): bool
{
foreach ($rgb as $channel) {
if ($channel < -0.0005 || $channel > 1.0005) {
return false;
}
}
return true;
}
function severityLuminance(array $rgb): float
{
[$r, $g, $b] = array_map(fn (float $c): float => max(0.0, min(1.0, $c)), $rgb);
return 0.2126 * $r + 0.7152 * $g + 0.0722 * $b;
}
function severityContrast(float $first, float $second): float
{
return (max($first, $second) + 0.05) / (min($first, $second) + 0.05);
}
/** OKLab Euclidean distance times 100. */
function severityDeltaE(array $first, array $second): float
{
$a = severityLinearToOklab($first);
$b = severityLinearToOklab($second);
return 100 * sqrt(($a[0] - $b[0]) ** 2 + ($a[1] - $b[1]) ** 2 + ($a[2] - $b[2]) ** 2);
}
/**
* Value of a custom property in the first CSS block that starts with
* $blockStart and defines it (app.css has more than one `.dark` block).
*/
function severityToken(string $css, string $blockStart, string $name): string
{
$offset = 0;
while (($start = strpos($css, "\n{$blockStart} {", $offset)) !== false) {
$end = strpos($css, "\n}", $start);
$block = substr($css, $start, $end - $start);
if (preg_match('/^\s*'.preg_quote($name, '/').':\s*([^;]+);/m', $block, $m) === 1) {
return $m[1];
}
$offset = $end;
}
throw new RuntimeException("{$name} not defined in {$blockStart}");
}
/**
* Surface the badge paints over: white at 7% over a dark page surface,
* blended in sRGB like the browser does.
*
* @param array{0: float, 1: float, 2: float} $surface linear sRGB
* @return array{0: float, 1: float, 2: float}
*/
function severityDarkBadgeBackground(array $surface): array
{
return array_map(
fn (float $channel): float => severityDecode(0.93 * severityEncode($channel) + 0.07),
$surface,
);
}
beforeEach(function () {
$this->css = file_get_contents(__DIR__.'/../../../resources/css/app.css');
$this->light = fn (string $name): array => severityParseColor(severityToken($this->css, '@theme', $name));
$this->dark = fn (string $name): array => severityParseColor(severityToken($this->css, '.dark', $name));
// The dark page surfaces live in @theme; the page is dark by default.
$this->darkSurface = fn (string $name): array => severityParseColor(severityToken($this->css, '@theme', $name));
});
dataset('severity tokens', ['--color-severity-high', '--color-severity-medium']);
it('keeps the light severity dot at 3:1 or more on the badge background', function (string $token) {
$color = ($this->light)($token);
// x-status-badge: bg-neutral-100 is oklch(97% 0 0). White cards are lighter.
$badge = severityParseColor('oklch(97% 0 0)');
expect(severityInGamut($color))->toBeTrue()
->and(severityContrast(severityLuminance($color), severityLuminance($badge)))->toBeGreaterThanOrEqual(3.0);
})->with('severity tokens');
it('keeps the dark severity dot at 3:1 or more on the badge over every dark surface', function (string $token) {
$color = ($this->dark)($token);
expect(severityInGamut($color))->toBeTrue();
foreach (['--color-app', '--color-panel', '--color-surface', '--color-raised', '--color-selected'] as $surfaceToken) {
$badge = severityDarkBadgeBackground(($this->darkSurface)($surfaceToken));
expect(severityContrast(severityLuminance($color), severityLuminance($badge)))
->toBeGreaterThanOrEqual(3.0, "{$token} on {$surfaceToken}");
}
})->with('severity tokens');
it('keeps the critical dot (error) at 3:1 or more in both themes', function () {
$lightBadge = severityParseColor('oklch(97% 0 0)');
$lightError = severityParseColor(severityToken($this->css, 'html:not(.dark)', '--color-error'));
expect(severityContrast(severityLuminance($lightError), severityLuminance($lightBadge)))->toBeGreaterThanOrEqual(3.0);
foreach (['--color-app', '--color-panel', '--color-surface', '--color-raised', '--color-selected'] as $surfaceToken) {
$badge = severityDarkBadgeBackground(($this->darkSurface)($surfaceToken));
expect(severityContrast(severityLuminance(($this->dark)('--color-error')), severityLuminance($badge)))
->toBeGreaterThanOrEqual(3.0, "error on {$surfaceToken}");
}
});
it('keeps medium visibly different from the accent and the warning color', function () {
$css = $this->css;
$theme = fn (string $name): array => severityParseColor(severityToken($css, '@theme', $name));
$lightWarning = severityParseColor(severityToken($css, 'html:not(.dark)', '--color-warning'));
$lightMedium = $theme('--color-severity-medium');
$darkMedium = ($this->dark)('--color-severity-medium');
// Light: accent purple and the darkened warning. Dark: the brand accent
// (--color-accent is --color-brand), its light tint, and the warning yellow.
$lightAgainst = [
'light accent' => severityParseColor(severityToken($css, '@theme', '--color-accent')),
'light warning' => $lightWarning,
];
$darkAgainst = [
'dark accent (brand)' => $theme('--color-brand'),
'dark accent text (brand-light)' => $theme('--color-brand-light'),
'dark warning' => $theme('--color-warning'),
];
foreach ($lightAgainst as $name => $other) {
expect(severityDeltaE($lightMedium, $other))->toBeGreaterThan(10.0, "light medium vs {$name}");
}
foreach ($darkAgainst as $name => $other) {
expect(severityDeltaE($darkMedium, $other))->toBeGreaterThan(10.0, "dark medium vs {$name}");
}
// Warning is yellow in dark and brown in light, so check each against both.
expect(severityDeltaE($lightMedium, $theme('--color-warning')))->toBeGreaterThan(10.0)
->and(severityDeltaE($darkMedium, $lightWarning))->toBeGreaterThan(10.0);
});
it('keeps high distinct from medium and from the warning color', function () {
$css = $this->css;
$theme = fn (string $name): array => severityParseColor(severityToken($css, '@theme', $name));
expect(severityDeltaE($theme('--color-severity-high'), $theme('--color-severity-medium')))->toBeGreaterThan(10.0)
->and(severityDeltaE(($this->dark)('--color-severity-high'), ($this->dark)('--color-severity-medium')))->toBeGreaterThan(10.0);
});
@@ -0,0 +1,51 @@
<?php
use App\Services\ImageScan\Package;
use App\Services\ImageScan\SyftPackageParser;
function syftLine(array $overrides = []): string
{
return json_encode(array_merge([
'id' => 'abc123', 'name' => 'openssl', 'version' => '1.0', 'type' => 'apk',
'licenses' => ['MIT'], 'purl' => 'pkg:apk/alpine/openssl@1.0', 'layer' => 'sha256:'.str_repeat('a', 64),
], $overrides));
}
it('parses the postgres 15.0 package list', function () {
$r = (new SyftPackageParser)->parse(file_get_contents(dirname(__DIR__, 2).'/Fixtures/image-scan/syft-packages.ndjson'));
expect($r['packages'])->toHaveCount(46)->and($r['truncated'])->toBeFalse();
$p = $r['packages'][0];
expect($p)->toBeInstanceOf(Package::class)
->and($p->name)->toBe('.postgresql-rundeps')->and($p->type)->toBe('apk')
->and($p->layerId)->toStartWith('sha256:')->and($p->licenses)->toBe([])
->and($p->purl)->toStartWith('pkg:apk/alpine/.postgresql-rundeps@');
});
it('ignores the layers trailer of older templates', function () {
$r = (new SyftPackageParser)->parse(syftLine()."\n".'{"layers":["sha256:1","sha256:2"]}');
expect($r)->toEqual(['packages' => [(new SyftPackageParser)->parse(syftLine())['packages'][0]], 'truncated' => false]);
});
it('caps packages and marks truncation', function () {
$r = (new SyftPackageParser)->parse(file_get_contents(dirname(__DIR__, 2).'/Fixtures/image-scan/syft-packages.ndjson'), 10);
expect($r['packages'])->toHaveCount(10)->and($r['truncated'])->toBeTrue();
});
it('drops invalid lines, strips control characters and cuts long strings', function () {
$good = syftLine(['name' => "a\u{1b}b\n", 'licenses' => ["MIT\u{1b}", ['x'], ''], 'version' => str_repeat('v', 300)]);
$r = (new SyftPackageParser)->parse("not json\n{$good}\n".json_encode(['name' => 'no id']));
expect($r['packages'])->toHaveCount(1)
->and($r['packages'][0]->name)->toBe('ab')
->and($r['packages'][0]->licenses)->toBe(['MIT'])
->and(strlen($r['packages'][0]->version))->toBe(128);
});
it('rejects input over 10 MB as truncated and empty', function () {
$r = (new SyftPackageParser)->parse(str_repeat('a', 10 * 1024 * 1024 + 1));
expect($r)->toBe(['packages' => [], 'truncated' => true]);
});
it('round trips a package through toArray and fromArray', function () {
$p = (new SyftPackageParser)->parse(syftLine())['packages'][0];
expect(Package::fromArray($p->toArray()))->toEqual($p);
});
+48
View File
@@ -0,0 +1,48 @@
<?php
use App\Services\ImageScan\ImageRef;
use App\Services\ImageScan\TagSelector;
it('selects the newest tag of the line and the newest higher major', function (string $current, array $tags, ?string $line, ?string $major, bool $floating) {
expect((new TagSelector)->select($current, $tags))->toMatchArray(['line' => $line, 'major' => $major, 'floating' => $floating]);
})->with([
'exact tag with suffix' => ['15.0-alpine', ['15.11-alpine', '15.15-alpine', '16.4-alpine', '15.16-rc1-alpine', '15-alpine'], '15.15-alpine', '16.4-alpine', false],
'floating tag keeps the floating line tag' => ['15-alpine', ['15.11-alpine', '15.15-alpine', '16.4-alpine', '15.16-rc1-alpine', '15-alpine', '16-alpine'], '15-alpine', '16-alpine', true],
'floating minor tag moves to the newest minor' => ['1.27', ['1.27', '1.27.3', '1.28', '1.28.0', '1.29', '1.29.1', '2.0'], '1.29', '2.0', true],
'other suffixes are another line' => ['15.0', ['15.11-alpine', '15.12-bookworm', '15.10'], '15.10', null, false],
'v prefix is kept' => ['v1.2.3', ['v1.2.4', 'v1.10.0', '1.11.0', 'v2.0.0', 'v1.9.9-beta.1'], 'v1.10.0', 'v2.0.0', false],
'no newer tag' => ['15.15-alpine', ['15.11-alpine', '15.15-alpine'], null, null, false],
'pre-release words are dropped' => ['2.1.0', ['2.2.0-rc1', '2.2.0-beta', '2.2.0-alpha.2', '2.2.0-dev', '2.2.0-nightly', '2.2.0-edge', '2.2.0-preview', '2.2.0-canary', '3.0.0-RC2'], null, null, false],
'git sha tag' => ['sha-3f2a1c', ['sha-3f2a1c', 'sha-4a5b6c', '1.0.0'], null, null, false],
'branch tag' => ['main', ['main', 'develop', '1.0.0'], null, null, false],
'latest tag' => ['latest', ['latest', '1.0.0', '2.0.0'], null, null, false],
'GHCR cosign tags are ignored' => ['2024.10.1', ['2024.10.1', '2024.10.4', 'sha256-abc.sig', 'sha256-0123456789abcdef.att', '2024.11.0.sbom', '2025.1.0'], '2024.10.4', '2025.1.0', false],
'invalid tags are ignored' => ['1.0', ['1.1', '1.2 ', '-1.3', '1.4$(id)', '..1.5'], '1.1', null, false],
]);
it('selects the newest patch of the same minor before the newest tag of the line', function (string $current, array $tags, ?string $patch, ?string $line) {
expect((new TagSelector)->select($current, $tags))->toMatchArray(['patch' => $patch, 'line' => $line]);
})->with([
'patch and newer minor' => ['8.0.40', ['8.0.40', '8.0.41', '8.0.45', '8.4.13', '9.7.4'], '8.0.45', '8.4.13'],
'v prefix and suffix are kept' => ['v1.2.3-alpine', ['v1.2.9-alpine', 'v1.2.10', 'v1.5.0-alpine'], 'v1.2.9-alpine', 'v1.5.0-alpine'],
'the newest patch is the line tag' => ['1.27.1', ['1.27.1', '1.27.4'], null, '1.27.4'],
'no newer patch' => ['8.0.45', ['8.0.45', '8.4.13'], null, '8.4.13'],
'two version parts have no patch line' => ['15.4', ['15.4', '15.13'], null, '15.13'],
'a floating tag has no patch line' => ['1.27', ['1.27', '1.27.3', '1.28', '1.28.0'], null, '1.28'],
]);
it('selects tags for an image of a registry with a port', function () {
$ref = ImageRef::parse('localhost:5000/team/app:1.2');
expect($ref->tag)->toBe('1.2')
->and($ref->qualifiedName())->toBe('localhost:5000/team/app')
->and((new TagSelector)->select($ref->tag, ['1.2', '1.3', '2.0']))->toBe(['line' => '1.3', 'patch' => null, 'major' => '2.0', 'floating' => false]);
});
it('reads at most 10,000 tags', function () {
$tags = array_map(fn (int $patch) => "1.0.{$patch}", range(1, 10_000));
$tags[] = '1.0.99999';
expect(TagSelector::MAX_TAGS)->toBe(10_000)
->and((new TagSelector)->select('1.0.0', $tags)['line'])->toBe('1.0.10000');
});