Skip to content

Commit

Permalink
feat(image): prevent scanning oversized container images (#8178)
Browse files Browse the repository at this point in the history
Signed-off-by: nikpivkin <[email protected]>
Signed-off-by: knqyf263 <[email protected]>
Co-authored-by: knqyf263 <[email protected]>
  • Loading branch information
nikpivkin and knqyf263 authored Jan 27, 2025
1 parent cc66d6d commit 509e030
Show file tree
Hide file tree
Showing 15 changed files with 323 additions and 3 deletions.
6 changes: 6 additions & 0 deletions cmd/trivy/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,12 @@ func main() {
if errors.As(err, &exitError) {
os.Exit(exitError.Code)
}

var userErr *types.UserError
if errors.As(err, &userErr) {
log.Fatal("Error", log.Err(userErr))
}

log.Fatal("Fatal error", log.Err(err))
}
}
Expand Down
1 change: 1 addition & 0 deletions docs/docs/references/configuration/cli/trivy_image.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ trivy image [flags] IMAGE_NAME
--license-confidence-level float specify license classifier's confidence level (default 0.9)
--license-full eagerly look for licenses in source code headers and license files
--list-all-pkgs output all packages in the JSON report regardless of vulnerability
--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
--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])
--module-dir string specify directory to the wasm modules that will be loaded (default "$HOME/.trivy/modules")
--no-progress suppress progress bar
Expand Down
3 changes: 3 additions & 0 deletions docs/docs/references/configuration/config-file.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,9 @@ image:
# Same as '--input'
input: ""

# Same as '--max-image-size'
max-size: ""

# Same as '--platform'
platform: ""

Expand Down
18 changes: 18 additions & 0 deletions docs/docs/target/container_image.md
Original file line number Diff line number Diff line change
Expand Up @@ -518,3 +518,21 @@ You can configure Podman daemon socket with `--podman-host`.
```shell
$ trivy image --podman-host /run/user/1000/podman/podman.sock YOUR_IMAGE
```

### Prevent scanning oversized container images
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.

!!! warning "EXPERIMENTAL"
This feature might change without preserving backwards compatibility.


Example Usage:
```bash
# Limit uncompressed image size to 10GB
$ trivy image --max-image-size=10GB myapp:latest
```

Error Output:
```bash
Error: uncompressed image size (15GB) exceeds maximum allowed size (10GB)
```
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ require (
github.com/docker/cli v27.5.0+incompatible
github.com/docker/docker v27.5.0+incompatible
github.com/docker/go-connections v0.5.0
github.com/docker/go-units v0.5.0
github.com/fatih/color v1.18.0
github.com/go-git/go-git/v5 v5.13.1
github.com/go-openapi/runtime v0.28.0 // indirect
Expand Down Expand Up @@ -222,7 +223,6 @@ require (
github.com/docker/distribution v2.8.3+incompatible // indirect
github.com/docker/docker-credential-helpers v0.8.2 // indirect
github.com/docker/go-metrics v0.0.1 // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/docker/libtrust v0.0.0-20160708172513-aabc10ec26b7 // indirect
github.com/dsnet/compress v0.0.1 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
Expand Down
18 changes: 18 additions & 0 deletions integration/docker_engine_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ func TestDockerEngine(t *testing.T) {
ignoreStatus []string
severity []string
ignoreIDs []string
maxImageSize string
input string
golden string
wantErr string
Expand All @@ -34,6 +35,12 @@ func TestDockerEngine(t *testing.T) {
input: "testdata/fixtures/images/alpine-39.tar.gz",
golden: "testdata/alpine-39.json.golden",
},
{
name: "alpine:3.9, with max image size",
maxImageSize: "100mb",
input: "testdata/fixtures/images/alpine-39.tar.gz",
golden: "testdata/alpine-39.json.golden",
},
{
name: "alpine:3.9, with high and critical severity",
severity: []string{
Expand Down Expand Up @@ -195,6 +202,12 @@ func TestDockerEngine(t *testing.T) {
input: "badimage:latest",
wantErr: "unable to inspect the image (badimage:latest)",
},
{
name: "sad path, image size is larger than the maximum",
input: "testdata/fixtures/images/alpine-39.tar.gz",
maxImageSize: "3mb",
wantErr: "uncompressed image size 5.8MB exceeds maximum allowed size 3MB",
},
}

// Set up testing DB
Expand Down Expand Up @@ -263,6 +276,11 @@ func TestDockerEngine(t *testing.T) {
require.NoError(t, err, "failed to write .trivyignore")
defer os.Remove(trivyIgnore)
}

if tt.maxImageSize != "" {
osArgs = append(osArgs, []string{"--max-image-size", tt.maxImageSize}...)
}

osArgs = append(osArgs, tt.input)

// Run Trivy
Expand Down
1 change: 1 addition & 0 deletions pkg/commands/artifact/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -587,6 +587,7 @@ func (r *runner) initScannerConfig(ctx context.Context, opts flag.Options) (Scan
Host: opts.PodmanHost,
},
ImageSources: opts.ImageSources,
MaxImageSize: opts.MaxImageSize,
},

// For misconfiguration scanning
Expand Down
132 changes: 130 additions & 2 deletions pkg/fanal/artifact/image/image.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,16 @@ package image
import (
"context"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"reflect"
"slices"
"strings"
"sync"

"github.com/docker/go-units"
v1 "github.com/google/go-containerregistry/pkg/v1"
"github.com/samber/lo"
"golang.org/x/xerrors"
Expand All @@ -24,6 +27,7 @@ import (
"github.com/aquasecurity/trivy/pkg/log"
"github.com/aquasecurity/trivy/pkg/parallel"
"github.com/aquasecurity/trivy/pkg/semaphore"
trivyTypes "github.com/aquasecurity/trivy/pkg/types"
)

type Artifact struct {
Expand All @@ -36,6 +40,8 @@ type Artifact struct {
handlerManager handler.Manager

artifactOption artifact.Option

layerCacheDir string
}

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

cacheDir, err := os.MkdirTemp("", "layers")
if err != nil {
return nil, xerrors.Errorf("failed to create a cache layers temp dir: %w", err)
}

return Artifact{
logger: log.WithPrefix("image"),
image: img,
Expand All @@ -70,10 +81,11 @@ func NewArtifact(img types.Image, c cache.ArtifactCache, opt artifact.Option) (a
handlerManager: handlerManager,

artifactOption: opt,
layerCacheDir: cacheDir,
}, nil
}

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

defer func() {
if rerr := os.RemoveAll(a.layerCacheDir); rerr != nil {
log.Error("Failed to remove layer cache", log.Err(rerr))
}
}()
if err := a.checkImageSize(ctx, diffIDs); err != nil {
return artifact.Reference{}, err
}

// Try retrieving a remote SBOM document
if res, err := a.retrieveRemoteSBOM(ctx); err == nil {
// Found SBOM
Expand Down Expand Up @@ -141,7 +162,7 @@ func (a Artifact) Inspect(ctx context.Context) (artifact.Reference, error) {
}, nil
}

func (Artifact) Clean(_ artifact.Reference) error {
func (a Artifact) Clean(_ artifact.Reference) error {
return nil
}

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

func limitErrorMessage(typ string, maxSize, imageSize int64) string {
return fmt.Sprintf(
"%s image size %s exceeds maximum allowed size %s", typ,
units.HumanSizeWithPrecision(float64(imageSize), 3),
units.HumanSize(float64(maxSize)),
)
}

func (a Artifact) checkImageSize(ctx context.Context, diffIDs []string) error {
maxSize := a.artifactOption.ImageOption.MaxImageSize
if maxSize == 0 {
return nil
}

compressedSize, err := a.compressedImageSize(diffIDs)
if err != nil {
return xerrors.Errorf("failed to get compressed image size: %w", err)
}

if compressedSize > maxSize {
return &trivyTypes.UserError{
Message: limitErrorMessage("compressed", maxSize, compressedSize),
}
}

imageSize, err := a.imageSize(ctx, diffIDs)
if err != nil {
return xerrors.Errorf("failed to calculate image size: %w", err)
}

if imageSize > maxSize {
return &trivyTypes.UserError{
Message: limitErrorMessage("uncompressed", maxSize, imageSize),
}
}
return nil
}

func (a Artifact) compressedImageSize(diffIDs []string) (int64, error) {
var totalSize int64
for _, diffID := range diffIDs {
h, err := v1.NewHash(diffID)
if err != nil {
return -1, xerrors.Errorf("invalid layer ID (%s): %w", diffID, err)
}

layer, err := a.image.LayerByDiffID(h)
if err != nil {
return -1, xerrors.Errorf("failed to get the layer (%s): %w", diffID, err)
}
layerSize, err := layer.Size()
if err != nil {
return -1, xerrors.Errorf("failed to get layer size: %w", err)
}
totalSize += layerSize
}

return totalSize, nil
}

func (a Artifact) imageSize(ctx context.Context, diffIDs []string) (int64, error) {
var imageSize int64

p := parallel.NewPipeline(a.artifactOption.Parallel, false, diffIDs,
func(_ context.Context, diffID string) (int64, error) {
layerSize, err := a.saveLayer(diffID)
if err != nil {
return -1, xerrors.Errorf("failed to save layer: %w", err)
}
return layerSize, nil
},
func(layerSize int64) error {
imageSize += layerSize
return nil
},
)

if err := p.Do(ctx); err != nil {
return -1, xerrors.Errorf("pipeline error: %w", err)
}

return imageSize, nil
}

func (a Artifact) saveLayer(diffID string) (int64, error) {
a.logger.Debug("Pulling the layer to the local cache", log.String("diff_id", diffID))
_, rc, err := a.uncompressedLayer(diffID)
if err != nil {
return -1, xerrors.Errorf("unable to get uncompressed layer %s: %w", diffID, err)
}
defer rc.Close()

f, err := os.Create(filepath.Join(a.layerCacheDir, diffID))
if err != nil {
return -1, xerrors.Errorf("failed to create a file: %w", err)
}
defer f.Close()

return io.Copy(f, rc)
}

func (a Artifact) inspect(ctx context.Context, missingImage string, layerKeys, baseDiffIDs []string,
layerKeyMap map[string]LayerInfo, configFile *v1.ConfigFile) error {

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

f, err := os.Open(filepath.Join(a.layerCacheDir, diffID))
if err == nil {
a.logger.Debug("Loaded the layer from the local cache", log.String("diff_id", diffID))
return digest, f, nil
}

rc, err := layer.Uncompressed()
if err != nil {
return "", nil, xerrors.Errorf("failed to get the layer content (%s): %w", diffID, err)
Expand Down
20 changes: 20 additions & 0 deletions pkg/fanal/artifact/image/image_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"testing"
"time"

"github.com/docker/go-units"
v1 "github.com/google/go-containerregistry/pkg/v1"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
Expand Down Expand Up @@ -348,6 +349,7 @@ func TestArtifact_Inspect(t *testing.T) {
imagePath: "../../test/testdata/alpine-311.tar.gz",
artifactOpt: artifact.Option{
LicenseScannerOption: analyzer.LicenseScannerOption{Full: true},
ImageOption: types.ImageOptions{MaxImageSize: units.GB},
},
missingBlobsExpectation: cache.ArtifactCacheMissingBlobsExpectation{
Args: cache.ArtifactCacheMissingBlobsArgs{
Expand Down Expand Up @@ -2243,6 +2245,22 @@ func TestArtifact_Inspect(t *testing.T) {
},
wantErr: "put artifact failed",
},
{
name: "sad path, compressed image size is larger than the maximum",
imagePath: "../../test/testdata/alpine-311.tar.gz",
artifactOpt: artifact.Option{
ImageOption: types.ImageOptions{MaxImageSize: units.MB * 1},
},
wantErr: "compressed image size 3.03MB exceeds maximum allowed size 1MB",
},
{
name: "sad path, image size is larger than the maximum",
imagePath: "../../test/testdata/alpine-311.tar.gz",
artifactOpt: artifact.Option{
ImageOption: types.ImageOptions{MaxImageSize: units.MB * 4},
},
wantErr: "uncompressed image size 5.86MB exceeds maximum allowed size 4MB",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
Expand All @@ -2262,6 +2280,8 @@ func TestArtifact_Inspect(t *testing.T) {
assert.ErrorContains(t, err, tt.wantErr, tt.name)
return
}
defer a.Clean(got)

require.NoError(t, err, tt.name)
assert.Equal(t, tt.want, got)
})
Expand Down
3 changes: 3 additions & 0 deletions pkg/fanal/artifact/image/remote_sbom_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,8 @@ func TestArtifact_InspectRekorAttestation(t *testing.T) {
assert.ErrorContains(t, err, tt.wantErr)
return
}
defer a.Clean(got)

require.NoError(t, err, tt.name)
got.BOM = nil
assert.Equal(t, tt.want, got)
Expand Down Expand Up @@ -312,6 +314,7 @@ func TestArtifact_inspectOCIReferrerSBOM(t *testing.T) {
assert.ErrorContains(t, err, tt.wantErr)
return
}
defer a.Clean(got)

require.NoError(t, err, tt.name)
got.BOM = nil
Expand Down
Loading

0 comments on commit 509e030

Please sign in to comment.