Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(image): prevent scanning oversized container images #8178

Open
wants to merge 8 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions cmd/trivy/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package main
import (
"context"
"errors"
"fmt"
"os"

"golang.org/x/xerrors"
Expand All @@ -21,6 +22,13 @@ func main() {
if errors.As(err, &exitError) {
os.Exit(exitError.Code)
}

var userErr *types.UserError
if errors.As(err, &userErr) {
fmt.Println("Error: " + userErr.Error())
DmitriyLewen marked this conversation as resolved.
Show resolved Hide resolved
os.Exit(1)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can wait for feedback from users.
But perhaps some users will want to set custom exit code.

}

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 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
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ require (
github.com/docker/cli v27.4.1+incompatible
github.com/docker/docker v27.4.1+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.12.0
github.com/go-openapi/runtime v0.28.0 // indirect
Expand Down Expand Up @@ -217,7 +218,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: "1mb",
wantErr: "uncompressed image size 5.8MB exceeds maximum allowed size 1MB",
},
}

// 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
91 changes: 89 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")
DmitriyLewen marked this conversation as resolved.
Show resolved Hide resolved
if err != nil {
return nil, xerrors.Errorf("failed to create a temp dir: %w", err)
DmitriyLewen marked this conversation as resolved.
Show resolved Hide resolved
}

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

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

Expand All @@ -88,6 +100,13 @@ 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))

if err := a.checkImageSize(ctx, diffIDs); err != nil {
if err := os.RemoveAll(a.layerCacheDir); err != nil {
log.Error("Failed to remove layer cache", log.Err(err))
}
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,8 +160,8 @@ func (a Artifact) Inspect(ctx context.Context) (artifact.Reference, error) {
}, nil
}

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

func (a Artifact) calcCacheKeys(imageID string, diffIDs []string) (string, []string, error) {
Expand Down Expand Up @@ -198,6 +217,69 @@ func (a Artifact) consolidateCreatedBy(diffIDs, layerKeys []string, configFile *
return layerKeyMap
}

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

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: fmt.Sprintf(
"uncompressed image size %s exceeds maximum allowed size %s",
units.HumanSizeWithPrecision(float64(imageSize), 3),
units.HumanSize(float64(maxSize)),
),
}
}
return 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) {
_, 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))
DmitriyLewen marked this conversation as resolved.
Show resolved Hide resolved
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 +443,11 @@ 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 {
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
12 changes: 12 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,14 @@ func TestArtifact_Inspect(t *testing.T) {
},
wantErr: "put artifact failed",
},
{
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 * 1},
},
wantErr: "uncompressed image size 5.86MB exceeds maximum allowed size 1MB",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
Expand All @@ -2262,6 +2272,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
1 change: 1 addition & 0 deletions pkg/fanal/test/integration/registry_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,7 @@ func analyze(ctx context.Context, imageRef string, opt types.ImageOptions) (*typ
if err != nil {
return nil, err
}
defer ar.Clean(imageInfo)

imageDetail, err := ap.ApplyLayers(imageInfo.ID, imageInfo.BlobIDs)
if err != nil {
Expand Down
1 change: 1 addition & 0 deletions pkg/fanal/types/image.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ type ImageOptions struct {
PodmanOptions PodmanOptions
ContainerdOptions ContainerdOptions
ImageSources ImageSources
MaxImageSize int64
}

type DockerOptions struct {
Expand Down
Loading
Loading