mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-06 13:10:37 -04:00
v4.7 (#12478)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: peaklabs-dev <122374094+peaklabs-dev@users.noreply.github.com> Co-authored-by: Aditya Tripathi <aditya@climactic.co>
This commit is contained in:
co-authored by
Claude Opus 5.5
peaklabs-dev
Aditya Tripathi
parent
b7dc2e435b
commit
033f0c84da
@@ -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  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();
|
||||
});
|
||||
@@ -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.');
|
||||
});
|
||||
});
|
||||
@@ -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.'],
|
||||
]);
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
Reference in New Issue
Block a user