Skip to content

Commit 509e030

Browse files
nikpivkinknqyf263
andauthored
feat(image): prevent scanning oversized container images (#8178)
Signed-off-by: nikpivkin <[email protected]> Signed-off-by: knqyf263 <[email protected]> Co-authored-by: knqyf263 <[email protected]>
1 parent cc66d6d commit 509e030

File tree

15 files changed

+323
-3
lines changed

15 files changed

+323
-3
lines changed

cmd/trivy/main.go

+6
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,12 @@ func main() {
2121
if errors.As(err, &exitError) {
2222
os.Exit(exitError.Code)
2323
}
24+
25+
var userErr *types.UserError
26+
if errors.As(err, &userErr) {
27+
log.Fatal("Error", log.Err(userErr))
28+
}
29+
2430
log.Fatal("Fatal error", log.Err(err))
2531
}
2632
}

docs/docs/references/configuration/cli/trivy_image.md

+1
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ trivy image [flags] IMAGE_NAME
7979
--license-confidence-level float specify license classifier's confidence level (default 0.9)
8080
--license-full eagerly look for licenses in source code headers and license files
8181
--list-all-pkgs output all packages in the JSON report regardless of vulnerability
82+
--max-image-size string [EXPERIMENTAL] maximum image size to process, specified in a human-readable format (e.g., '44kB', '17MB'); an error will be returned if the image exceeds this size
8283
--misconfig-scanners strings comma-separated list of misconfig scanners to use for misconfiguration scanning (default [azure-arm,cloudformation,dockerfile,helm,kubernetes,terraform,terraformplan-json,terraformplan-snapshot])
8384
--module-dir string specify directory to the wasm modules that will be loaded (default "$HOME/.trivy/modules")
8485
--no-progress suppress progress bar

docs/docs/references/configuration/config-file.md

+3
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,9 @@ image:
137137
# Same as '--input'
138138
input: ""
139139

140+
# Same as '--max-image-size'
141+
max-size: ""
142+
140143
# Same as '--platform'
141144
platform: ""
142145

docs/docs/target/container_image.md

+18
Original file line numberDiff line numberDiff line change
@@ -518,3 +518,21 @@ You can configure Podman daemon socket with `--podman-host`.
518518
```shell
519519
$ trivy image --podman-host /run/user/1000/podman/podman.sock YOUR_IMAGE
520520
```
521+
522+
### Prevent scanning oversized container images
523+
Use the `--max-image-size` flag to avoid scanning images that exceed a specified size. The size is specified in a human-readable format (e.g., `100MB`, `10GB`). If the compressed image size exceeds the specified threshold, an error is returned immediately. Otherwise, all layers are pulled, stored in a temporary folder, and their uncompressed size is verified before scanning. Temporary layers are always cleaned up, even after a successful scan.
524+
525+
!!! warning "EXPERIMENTAL"
526+
This feature might change without preserving backwards compatibility.
527+
528+
529+
Example Usage:
530+
```bash
531+
# Limit uncompressed image size to 10GB
532+
$ trivy image --max-image-size=10GB myapp:latest
533+
```
534+
535+
Error Output:
536+
```bash
537+
Error: uncompressed image size (15GB) exceeds maximum allowed size (10GB)
538+
```

go.mod

+1-1
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ require (
4646
github.com/docker/cli v27.5.0+incompatible
4747
github.com/docker/docker v27.5.0+incompatible
4848
github.com/docker/go-connections v0.5.0
49+
github.com/docker/go-units v0.5.0
4950
github.com/fatih/color v1.18.0
5051
github.com/go-git/go-git/v5 v5.13.1
5152
github.com/go-openapi/runtime v0.28.0 // indirect
@@ -222,7 +223,6 @@ require (
222223
github.com/docker/distribution v2.8.3+incompatible // indirect
223224
github.com/docker/docker-credential-helpers v0.8.2 // indirect
224225
github.com/docker/go-metrics v0.0.1 // indirect
225-
github.com/docker/go-units v0.5.0 // indirect
226226
github.com/docker/libtrust v0.0.0-20160708172513-aabc10ec26b7 // indirect
227227
github.com/dsnet/compress v0.0.1 // indirect
228228
github.com/dustin/go-humanize v1.0.1 // indirect

integration/docker_engine_test.go

+18
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ func TestDockerEngine(t *testing.T) {
2525
ignoreStatus []string
2626
severity []string
2727
ignoreIDs []string
28+
maxImageSize string
2829
input string
2930
golden string
3031
wantErr string
@@ -34,6 +35,12 @@ func TestDockerEngine(t *testing.T) {
3435
input: "testdata/fixtures/images/alpine-39.tar.gz",
3536
golden: "testdata/alpine-39.json.golden",
3637
},
38+
{
39+
name: "alpine:3.9, with max image size",
40+
maxImageSize: "100mb",
41+
input: "testdata/fixtures/images/alpine-39.tar.gz",
42+
golden: "testdata/alpine-39.json.golden",
43+
},
3744
{
3845
name: "alpine:3.9, with high and critical severity",
3946
severity: []string{
@@ -195,6 +202,12 @@ func TestDockerEngine(t *testing.T) {
195202
input: "badimage:latest",
196203
wantErr: "unable to inspect the image (badimage:latest)",
197204
},
205+
{
206+
name: "sad path, image size is larger than the maximum",
207+
input: "testdata/fixtures/images/alpine-39.tar.gz",
208+
maxImageSize: "3mb",
209+
wantErr: "uncompressed image size 5.8MB exceeds maximum allowed size 3MB",
210+
},
198211
}
199212

200213
// Set up testing DB
@@ -263,6 +276,11 @@ func TestDockerEngine(t *testing.T) {
263276
require.NoError(t, err, "failed to write .trivyignore")
264277
defer os.Remove(trivyIgnore)
265278
}
279+
280+
if tt.maxImageSize != "" {
281+
osArgs = append(osArgs, []string{"--max-image-size", tt.maxImageSize}...)
282+
}
283+
266284
osArgs = append(osArgs, tt.input)
267285

268286
// Run Trivy

pkg/commands/artifact/run.go

+1
Original file line numberDiff line numberDiff line change
@@ -587,6 +587,7 @@ func (r *runner) initScannerConfig(ctx context.Context, opts flag.Options) (Scan
587587
Host: opts.PodmanHost,
588588
},
589589
ImageSources: opts.ImageSources,
590+
MaxImageSize: opts.MaxImageSize,
590591
},
591592

592593
// For misconfiguration scanning

pkg/fanal/artifact/image/image.go

+130-2
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,16 @@ package image
33
import (
44
"context"
55
"errors"
6+
"fmt"
67
"io"
78
"os"
9+
"path/filepath"
810
"reflect"
911
"slices"
1012
"strings"
1113
"sync"
1214

15+
"github.com/docker/go-units"
1316
v1 "github.com/google/go-containerregistry/pkg/v1"
1417
"github.com/samber/lo"
1518
"golang.org/x/xerrors"
@@ -24,6 +27,7 @@ import (
2427
"github.com/aquasecurity/trivy/pkg/log"
2528
"github.com/aquasecurity/trivy/pkg/parallel"
2629
"github.com/aquasecurity/trivy/pkg/semaphore"
30+
trivyTypes "github.com/aquasecurity/trivy/pkg/types"
2731
)
2832

2933
type Artifact struct {
@@ -36,6 +40,8 @@ type Artifact struct {
3640
handlerManager handler.Manager
3741

3842
artifactOption artifact.Option
43+
44+
layerCacheDir string
3945
}
4046

4147
type LayerInfo struct {
@@ -60,6 +66,11 @@ func NewArtifact(img types.Image, c cache.ArtifactCache, opt artifact.Option) (a
6066
return nil, xerrors.Errorf("config analyzer group error: %w", err)
6167
}
6268

69+
cacheDir, err := os.MkdirTemp("", "layers")
70+
if err != nil {
71+
return nil, xerrors.Errorf("failed to create a cache layers temp dir: %w", err)
72+
}
73+
6374
return Artifact{
6475
logger: log.WithPrefix("image"),
6576
image: img,
@@ -70,10 +81,11 @@ func NewArtifact(img types.Image, c cache.ArtifactCache, opt artifact.Option) (a
7081
handlerManager: handlerManager,
7182

7283
artifactOption: opt,
84+
layerCacheDir: cacheDir,
7385
}, nil
7486
}
7587

76-
func (a Artifact) Inspect(ctx context.Context) (artifact.Reference, error) {
88+
func (a Artifact) Inspect(ctx context.Context) (ref artifact.Reference, err error) {
7789
imageID, err := a.image.ID()
7890
if err != nil {
7991
return artifact.Reference{}, xerrors.Errorf("unable to get the image ID: %w", err)
@@ -88,6 +100,15 @@ func (a Artifact) Inspect(ctx context.Context) (artifact.Reference, error) {
88100
diffIDs := a.diffIDs(configFile)
89101
a.logger.Debug("Detected diff ID", log.Any("diff_ids", diffIDs))
90102

103+
defer func() {
104+
if rerr := os.RemoveAll(a.layerCacheDir); rerr != nil {
105+
log.Error("Failed to remove layer cache", log.Err(rerr))
106+
}
107+
}()
108+
if err := a.checkImageSize(ctx, diffIDs); err != nil {
109+
return artifact.Reference{}, err
110+
}
111+
91112
// Try retrieving a remote SBOM document
92113
if res, err := a.retrieveRemoteSBOM(ctx); err == nil {
93114
// Found SBOM
@@ -141,7 +162,7 @@ func (a Artifact) Inspect(ctx context.Context) (artifact.Reference, error) {
141162
}, nil
142163
}
143164

144-
func (Artifact) Clean(_ artifact.Reference) error {
165+
func (a Artifact) Clean(_ artifact.Reference) error {
145166
return nil
146167
}
147168

@@ -198,6 +219,107 @@ func (a Artifact) consolidateCreatedBy(diffIDs, layerKeys []string, configFile *
198219
return layerKeyMap
199220
}
200221

222+
func limitErrorMessage(typ string, maxSize, imageSize int64) string {
223+
return fmt.Sprintf(
224+
"%s image size %s exceeds maximum allowed size %s", typ,
225+
units.HumanSizeWithPrecision(float64(imageSize), 3),
226+
units.HumanSize(float64(maxSize)),
227+
)
228+
}
229+
230+
func (a Artifact) checkImageSize(ctx context.Context, diffIDs []string) error {
231+
maxSize := a.artifactOption.ImageOption.MaxImageSize
232+
if maxSize == 0 {
233+
return nil
234+
}
235+
236+
compressedSize, err := a.compressedImageSize(diffIDs)
237+
if err != nil {
238+
return xerrors.Errorf("failed to get compressed image size: %w", err)
239+
}
240+
241+
if compressedSize > maxSize {
242+
return &trivyTypes.UserError{
243+
Message: limitErrorMessage("compressed", maxSize, compressedSize),
244+
}
245+
}
246+
247+
imageSize, err := a.imageSize(ctx, diffIDs)
248+
if err != nil {
249+
return xerrors.Errorf("failed to calculate image size: %w", err)
250+
}
251+
252+
if imageSize > maxSize {
253+
return &trivyTypes.UserError{
254+
Message: limitErrorMessage("uncompressed", maxSize, imageSize),
255+
}
256+
}
257+
return nil
258+
}
259+
260+
func (a Artifact) compressedImageSize(diffIDs []string) (int64, error) {
261+
var totalSize int64
262+
for _, diffID := range diffIDs {
263+
h, err := v1.NewHash(diffID)
264+
if err != nil {
265+
return -1, xerrors.Errorf("invalid layer ID (%s): %w", diffID, err)
266+
}
267+
268+
layer, err := a.image.LayerByDiffID(h)
269+
if err != nil {
270+
return -1, xerrors.Errorf("failed to get the layer (%s): %w", diffID, err)
271+
}
272+
layerSize, err := layer.Size()
273+
if err != nil {
274+
return -1, xerrors.Errorf("failed to get layer size: %w", err)
275+
}
276+
totalSize += layerSize
277+
}
278+
279+
return totalSize, nil
280+
}
281+
282+
func (a Artifact) imageSize(ctx context.Context, diffIDs []string) (int64, error) {
283+
var imageSize int64
284+
285+
p := parallel.NewPipeline(a.artifactOption.Parallel, false, diffIDs,
286+
func(_ context.Context, diffID string) (int64, error) {
287+
layerSize, err := a.saveLayer(diffID)
288+
if err != nil {
289+
return -1, xerrors.Errorf("failed to save layer: %w", err)
290+
}
291+
return layerSize, nil
292+
},
293+
func(layerSize int64) error {
294+
imageSize += layerSize
295+
return nil
296+
},
297+
)
298+
299+
if err := p.Do(ctx); err != nil {
300+
return -1, xerrors.Errorf("pipeline error: %w", err)
301+
}
302+
303+
return imageSize, nil
304+
}
305+
306+
func (a Artifact) saveLayer(diffID string) (int64, error) {
307+
a.logger.Debug("Pulling the layer to the local cache", log.String("diff_id", diffID))
308+
_, rc, err := a.uncompressedLayer(diffID)
309+
if err != nil {
310+
return -1, xerrors.Errorf("unable to get uncompressed layer %s: %w", diffID, err)
311+
}
312+
defer rc.Close()
313+
314+
f, err := os.Create(filepath.Join(a.layerCacheDir, diffID))
315+
if err != nil {
316+
return -1, xerrors.Errorf("failed to create a file: %w", err)
317+
}
318+
defer f.Close()
319+
320+
return io.Copy(f, rc)
321+
}
322+
201323
func (a Artifact) inspect(ctx context.Context, missingImage string, layerKeys, baseDiffIDs []string,
202324
layerKeyMap map[string]LayerInfo, configFile *v1.ConfigFile) error {
203325

@@ -361,6 +483,12 @@ func (a Artifact) uncompressedLayer(diffID string) (string, io.ReadCloser, error
361483
digest = d.String()
362484
}
363485

486+
f, err := os.Open(filepath.Join(a.layerCacheDir, diffID))
487+
if err == nil {
488+
a.logger.Debug("Loaded the layer from the local cache", log.String("diff_id", diffID))
489+
return digest, f, nil
490+
}
491+
364492
rc, err := layer.Uncompressed()
365493
if err != nil {
366494
return "", nil, xerrors.Errorf("failed to get the layer content (%s): %w", diffID, err)

pkg/fanal/artifact/image/image_test.go

+20
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"testing"
77
"time"
88

9+
"github.com/docker/go-units"
910
v1 "github.com/google/go-containerregistry/pkg/v1"
1011
"github.com/stretchr/testify/assert"
1112
"github.com/stretchr/testify/require"
@@ -348,6 +349,7 @@ func TestArtifact_Inspect(t *testing.T) {
348349
imagePath: "../../test/testdata/alpine-311.tar.gz",
349350
artifactOpt: artifact.Option{
350351
LicenseScannerOption: analyzer.LicenseScannerOption{Full: true},
352+
ImageOption: types.ImageOptions{MaxImageSize: units.GB},
351353
},
352354
missingBlobsExpectation: cache.ArtifactCacheMissingBlobsExpectation{
353355
Args: cache.ArtifactCacheMissingBlobsArgs{
@@ -2243,6 +2245,22 @@ func TestArtifact_Inspect(t *testing.T) {
22432245
},
22442246
wantErr: "put artifact failed",
22452247
},
2248+
{
2249+
name: "sad path, compressed image size is larger than the maximum",
2250+
imagePath: "../../test/testdata/alpine-311.tar.gz",
2251+
artifactOpt: artifact.Option{
2252+
ImageOption: types.ImageOptions{MaxImageSize: units.MB * 1},
2253+
},
2254+
wantErr: "compressed image size 3.03MB exceeds maximum allowed size 1MB",
2255+
},
2256+
{
2257+
name: "sad path, image size is larger than the maximum",
2258+
imagePath: "../../test/testdata/alpine-311.tar.gz",
2259+
artifactOpt: artifact.Option{
2260+
ImageOption: types.ImageOptions{MaxImageSize: units.MB * 4},
2261+
},
2262+
wantErr: "uncompressed image size 5.86MB exceeds maximum allowed size 4MB",
2263+
},
22462264
}
22472265
for _, tt := range tests {
22482266
t.Run(tt.name, func(t *testing.T) {
@@ -2262,6 +2280,8 @@ func TestArtifact_Inspect(t *testing.T) {
22622280
assert.ErrorContains(t, err, tt.wantErr, tt.name)
22632281
return
22642282
}
2283+
defer a.Clean(got)
2284+
22652285
require.NoError(t, err, tt.name)
22662286
assert.Equal(t, tt.want, got)
22672287
})

pkg/fanal/artifact/image/remote_sbom_test.go

+3
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,8 @@ func TestArtifact_InspectRekorAttestation(t *testing.T) {
170170
assert.ErrorContains(t, err, tt.wantErr)
171171
return
172172
}
173+
defer a.Clean(got)
174+
173175
require.NoError(t, err, tt.name)
174176
got.BOM = nil
175177
assert.Equal(t, tt.want, got)
@@ -312,6 +314,7 @@ func TestArtifact_inspectOCIReferrerSBOM(t *testing.T) {
312314
assert.ErrorContains(t, err, tt.wantErr)
313315
return
314316
}
317+
defer a.Clean(got)
315318

316319
require.NoError(t, err, tt.name)
317320
got.BOM = nil

0 commit comments

Comments
 (0)