mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-04 10:42:23 +00:00
refactor(image-scan): remove unused SBOM layer code and the unused fix state
- The Syft template no longer prints layer IDs; the parser ignores the line from older templates - Remove Finding::withLayer() - ImageScanFixState has two cases, fixed and no_fix, as every caller only used those
This commit is contained in:
10 files changed
+32
-70
No files matched your search
@@ -2,9 +2,9 @@
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
/** The fix states of the findings lists; the values are the keys of `Vulnerabilities::FIX_STATES`. */
|
||||
enum ImageScanFixState: string
|
||||
{
|
||||
case FixableNow = 'fixable_now';
|
||||
case NotReleased = 'not_released';
|
||||
case Fixed = 'fixed';
|
||||
case NoFix = 'no_fix';
|
||||
}
|
||||
@@ -5,7 +5,6 @@ namespace App\Livewire\ImageScan;
|
||||
use App\Actions\ImageScan\ScanImage;
|
||||
use App\Actions\ImageScan\UpdateResourceImage;
|
||||
use App\Enums\ImageScanErrorCode;
|
||||
use App\Enums\ImageScanFixState;
|
||||
use App\Jobs\RefreshImageScanSummariesJob;
|
||||
use App\Jobs\ScanResourceImagesJob;
|
||||
use App\Models\Application;
|
||||
@@ -763,7 +762,7 @@ class Vulnerabilities extends Component
|
||||
'key' => $scan->uuid.'-'.$index,
|
||||
'scan' => (string) $scan->uuid,
|
||||
'finding' => $finding,
|
||||
'state' => $classify($finding) === ImageScanFixState::NoFix ? 'no_fix' : 'fixed',
|
||||
'state' => $classify($finding)->value,
|
||||
'ignore' => $matcher->matches($finding),
|
||||
'image' => (string) $scan->image_ref,
|
||||
'owner' => (string) $owner->getAttribute('uuid'),
|
||||
|
||||
@@ -21,6 +21,7 @@ final readonly class Finding
|
||||
public ?float $epss,
|
||||
public ?float $risk,
|
||||
public string $summary,
|
||||
/** New findings are always "app"; kept because stored findings carry it. */
|
||||
public string $layer = 'app',
|
||||
public ?float $cvssScore = null,
|
||||
public string $title = '',
|
||||
@@ -29,11 +30,6 @@ final readonly class Finding
|
||||
public string $matchType = '',
|
||||
) {}
|
||||
|
||||
public function withLayer(string $layer): self
|
||||
{
|
||||
return self::fromArray([...$this->toArray(), 'layer' => $layer]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{id: string, aliases: list<string>, severity: string, package: string, version: string, type: string, artifactId: string, fixedIn: ?string, fixState: string, kev: bool, epss: ?float, risk: ?float, summary: string, layer: string, cvssScore: ?float, title: string, displayId: string, namespace: string, matchType: string}
|
||||
*/
|
||||
|
||||
@@ -34,8 +34,8 @@ final class SecurityActions
|
||||
private const BUTTON_TYPES = [SecurityActionType::UpdateImage, SecurityActionType::PullLatest, SecurityActionType::Rebuild];
|
||||
|
||||
/**
|
||||
* The fix state of each finding of a scan for the findings lists: fixed by an action of the scan, a package fix
|
||||
* that no action brings to this image, or no fix.
|
||||
* The fix state of each finding of a scan for the findings lists: fixed when an action of the scan fixes it or a
|
||||
* fixed package version exists, else no fix.
|
||||
*
|
||||
* @return \Closure(Finding): ImageScanFixState
|
||||
*/
|
||||
@@ -43,11 +43,9 @@ final class SecurityActions
|
||||
{
|
||||
$byFinding = self::forScan($scan, $ignores)['byFinding'];
|
||||
|
||||
return fn (Finding $finding): ImageScanFixState => match (true) {
|
||||
isset($byFinding[$finding->id]) => ImageScanFixState::FixableNow,
|
||||
$finding->fixState === 'fixed' && filled($finding->fixedIn) => ImageScanFixState::NotReleased,
|
||||
default => ImageScanFixState::NoFix,
|
||||
};
|
||||
return fn (Finding $finding): ImageScanFixState => isset($byFinding[$finding->id]) || ($finding->fixState === 'fixed' && filled($finding->fixedIn))
|
||||
? ImageScanFixState::Fixed
|
||||
: ImageScanFixState::NoFix;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -9,21 +9,22 @@ class SyftPackageParser
|
||||
private const MAX_LICENSES = 32;
|
||||
|
||||
/**
|
||||
* @return array{packages: list<Package>, truncated: bool, layerIds: list<string>}
|
||||
* Rows without a package `id` (such as the `{"layers":…}` trailer of older templates) are skipped.
|
||||
*
|
||||
* @return array{packages: list<Package>, truncated: bool}
|
||||
*/
|
||||
public function parse(string $ndjson, int $limit = 10000): array
|
||||
{
|
||||
$packages = [];
|
||||
$truncated = false;
|
||||
$layerIds = [];
|
||||
|
||||
if (strlen($ndjson) > self::MAX_BYTES) {
|
||||
return ['packages' => [], 'truncated' => true, 'layerIds' => []];
|
||||
return ['packages' => [], 'truncated' => true];
|
||||
}
|
||||
|
||||
foreach (explode("\n", $ndjson) as $line) {
|
||||
$line = trim($line);
|
||||
if ($line === '' || ($truncated && ! str_starts_with($line, '{"layers"'))) {
|
||||
if ($line === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -32,12 +33,6 @@ class SyftPackageParser
|
||||
continue;
|
||||
}
|
||||
|
||||
if (array_key_exists('layers', $row) && ! array_key_exists('id', $row)) {
|
||||
$layerIds = $this->layerIds($row['layers']);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$package = $this->package($row);
|
||||
if ($package === null) {
|
||||
continue;
|
||||
@@ -46,29 +41,13 @@ class SyftPackageParser
|
||||
if (count($packages) >= $limit) {
|
||||
$truncated = true;
|
||||
|
||||
continue;
|
||||
break;
|
||||
}
|
||||
|
||||
$packages[] = $package;
|
||||
}
|
||||
|
||||
return ['packages' => $packages, 'truncated' => $truncated, 'layerIds' => $layerIds];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private function layerIds(mixed $layers): array
|
||||
{
|
||||
$ids = [];
|
||||
foreach (is_array($layers) ? $layers : [] as $layer) {
|
||||
$id = ScannerText::clean($layer, 80);
|
||||
if ($id !== '') {
|
||||
$ids[] = $id;
|
||||
}
|
||||
}
|
||||
|
||||
return $ids;
|
||||
return ['packages' => $packages, 'truncated' => $truncated];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
namespace App\Services\ImageScan;
|
||||
|
||||
use App\Enums\ImageScanFixState;
|
||||
use App\Enums\ImageScanStatus;
|
||||
use App\Enums\SecurityStatusType;
|
||||
use App\Models\Application;
|
||||
@@ -314,11 +313,7 @@ final class TeamVulnerabilities
|
||||
'version' => $finding->version,
|
||||
'fixedIn' => $finding->fixedIn,
|
||||
'severity' => self::severityOf($finding),
|
||||
'state' => match (true) {
|
||||
$matcher->matches($finding) !== null => 'ignored',
|
||||
$classify($finding) === ImageScanFixState::NoFix => 'no_fix',
|
||||
default => 'fixed',
|
||||
},
|
||||
'state' => $matcher->matches($finding) !== null ? 'ignored' : $classify($finding)->value,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,5 +3,3 @@
|
||||
{{- $layer := "" }}{{- range .locations }}{{- if and (not $layer) .layerID }}{{- $layer = .layerID }}{{- end }}{{- end }}
|
||||
{{ dict "id" .id "name" .name "version" .version "type" .type "licenses" $lic "purl" .purl "layer" $layer | toJson }}
|
||||
{{- end }}
|
||||
{{- $layers := list }}{{- range .source.metadata.layers }}{{- $layers = append $layers .digest }}{{- end }}
|
||||
{{ dict "layers" $layers | toJson }}
|
||||
@@ -607,10 +607,9 @@ it('marks the findings that a better candidate fixes as fixed by an update', fun
|
||||
RefreshCandidates::run($scan);
|
||||
|
||||
$scan = $scan->fresh();
|
||||
$classify = SecurityActions::fixStates($scan, IgnoreMatcher::forResource($scan->resource));
|
||||
$finding = collect(candidatesFindings())->firstWhere('id', 'CVE-2025-1094');
|
||||
$byFinding = SecurityActions::forScan($scan, IgnoreMatcher::forResource($scan->resource))['byFinding'];
|
||||
|
||||
expect($classify($finding)->value)->toBe('fixable_now');
|
||||
expect($byFinding)->toHaveKey('CVE-2025-1094');
|
||||
});
|
||||
|
||||
it('is a unique queued job per scan', function () {
|
||||
@@ -888,7 +887,7 @@ it('never makes base image candidates fix an image that the team does not build'
|
||||
$finding = collect(candidatesFindings())->first(fn (Finding $finding) => $finding->id === $fixedId || in_array($fixedId, $finding->aliases, true));
|
||||
$scan->builds_image = false;
|
||||
|
||||
expect(SecurityActions::fixStates($scan, IgnoreMatcher::forResource($scan->resource))($finding)->value)->not->toBe('fixable_now');
|
||||
expect(SecurityActions::forScan($scan, IgnoreMatcher::forResource($scan->resource))['byFinding'])->not->toHaveKey($finding->id);
|
||||
});
|
||||
|
||||
it('says that no newer base image is better when the line candidate has as many findings as the current base', function () {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
use App\Services\ImageScan\Finding;
|
||||
use App\Services\ImageScan\GrypeReportParser;
|
||||
use App\Services\ImageScan\ScannerText;
|
||||
use Illuminate\Support\Arr;
|
||||
|
||||
function imageScanFixture(string $name): string
|
||||
{
|
||||
@@ -143,10 +144,12 @@ it('ignores lines nested deeper than the allowed depth', function () {
|
||||
expect($r['findings'])->toHaveCount(1);
|
||||
});
|
||||
|
||||
it('keeps the other fields when setting the layer', function () {
|
||||
it('keeps the layer of a stored finding', function () {
|
||||
$f = (new GrypeReportParser)->parse(imageScanFixture('grype-kev.ndjson'))['findings'][0];
|
||||
$base = $f->withLayer('base');
|
||||
expect($base->layer)->toBe('base')->and([...$base->toArray(), 'layer' => 'app'])->toBe($f->toArray());
|
||||
$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 () {
|
||||
@@ -332,8 +335,7 @@ it('round trips the new fields', function () {
|
||||
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)
|
||||
->and($f->withLayer('base')->cvssScore)->toBe(6.5);
|
||||
->and(Finding::fromArray($array))->toEqual($f);
|
||||
});
|
||||
|
||||
it('keeps code-like tokens in the title intact', function (string $desc) {
|
||||
|
||||
@@ -18,21 +18,17 @@ it('parses the postgres 15.0 package list', function () {
|
||||
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@')
|
||||
->and($r['layerIds'])->toHaveCount(8)
|
||||
->and($r['layerIds'][0])->toBe('sha256:994393dc58e7931862558d06e46aa2bb17487044f670f310dffe1d24e4d1eec7')
|
||||
->and($r['layerIds'][3])->toBe('sha256:427eca9eca256d5db3002eb8993b59d663e44ac34a8098f3dcfe151c8bb32d54');
|
||||
expect(collect($r['packages'])->pluck('layerId')->unique()->all())->each->toBeIn($r['layerIds']);
|
||||
->and($p->purl)->toStartWith('pkg:apk/alpine/.postgresql-rundeps@');
|
||||
});
|
||||
|
||||
it('does not count the layers trailer as a package', function () {
|
||||
it('ignores the layers trailer of older templates', function () {
|
||||
$r = (new SyftPackageParser)->parse(syftLine()."\n".'{"layers":["sha256:1","sha256:2"]}');
|
||||
expect($r['packages'])->toHaveCount(1)->and($r['layerIds'])->toBe(['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()->and($r['layerIds'])->toHaveCount(8);
|
||||
expect($r['packages'])->toHaveCount(10)->and($r['truncated'])->toBeTrue();
|
||||
});
|
||||
|
||||
it('drops invalid lines, strips control characters and cuts long strings', function () {
|
||||
@@ -46,7 +42,7 @@ it('drops invalid lines, strips control characters and cuts long strings', funct
|
||||
|
||||
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, 'layerIds' => []]);
|
||||
expect($r)->toBe(['packages' => [], 'truncated' => true]);
|
||||
});
|
||||
|
||||
it('round trips a package through toArray and fromArray', function () {
|
||||
|
||||
Reference in new issue
Block a user