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:
Aditya Tripathi committed 2026-10-29 09:03:20 +00:00
1 parent 4c60cc21d6
commit 2b820b4444
10 files changed
+32 -70

No files matched your search

+2 -2
View File
@@ -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';
}
+1 -2
View File
@@ -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'),
+1 -5
View File
@@ -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}
*/
+5 -7
View File
@@ -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;
}
/**
+7 -28
View File
@@ -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,
];
}
}
-2
View File
@@ -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 () {