Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
4 changes: 2 additions & 2 deletions integration/plugin_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,8 @@ func TestPlugin(t *testing.T) {

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// We can overwrite stdout for `_default_Manager` only once.
// So we need to clear the temporary stdout file before each test case.
// The output plugin appends to os.Stdout (redirected to tempStdOut),
// so clear it before each test case.
clearFile(t, tempStdOut)

t.Setenv("XDG_DATA_HOME", t.TempDir())
Expand Down
39 changes: 30 additions & 9 deletions pkg/commands/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,17 @@ func loadPluginCommands() []*cobra.Command {
ctx := context.Background()

var commands []*cobra.Command
plugins, err := plugin.NewManager().LoadAll(ctx)
// Avoid creating the plugin directory on every run; nothing to load if it is absent.
if !plugin.DirExists() {
return nil
}
manager, err := plugin.NewManager()
if err != nil {
log.WarnContext(ctx, "Failed to initialize the plugin manager", log.Err(err))
return nil
}
defer manager.Close()
plugins, err := manager.LoadAll(ctx)
if err != nil {
log.DebugContext(ctx, "No plugins loaded")
return nil
Expand Down Expand Up @@ -750,6 +760,7 @@ func NewConfigCommand(globalFlags *flag.GlobalFlagGroup) *cobra.Command {

func NewPluginCommand(globalFlags *flag.GlobalFlagGroup) *cobra.Command {
var pluginOptions flag.Options
var manager *plugin.Manager
pluginFlags := &flag.Flags{
globalFlags,
}
Expand All @@ -766,6 +777,16 @@ func NewPluginCommand(globalFlags *flag.GlobalFlagGroup) *cobra.Command {
if err != nil {
return err
}
manager, err = plugin.NewManager()
if err != nil {
return xerrors.Errorf("plugin manager error: %w", err)
}
return nil
},
PersistentPostRunE: func(_ *cobra.Command, _ []string) error {
if manager != nil {
return manager.Close()
}
return nil
},
}
Expand All @@ -787,7 +808,7 @@ func NewPluginCommand(globalFlags *flag.GlobalFlagGroup) *cobra.Command {
DisableFlagsInUseLine: true,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
if _, err := plugin.Install(cmd.Context(), args[0], plugin.Options{Insecure: pluginOptions.Insecure}); err != nil {
if _, err := manager.Install(cmd.Context(), args[0], plugin.Options{Insecure: pluginOptions.Insecure}); err != nil {
return xerrors.Errorf("plugin install error: %w", err)
}
return nil
Expand All @@ -802,7 +823,7 @@ func NewPluginCommand(globalFlags *flag.GlobalFlagGroup) *cobra.Command {
SilenceUsage: true,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
if err := plugin.Uninstall(cmd.Context(), args[0]); err != nil {
if err := manager.Uninstall(cmd.Context(), args[0]); err != nil {
return xerrors.Errorf("plugin uninstall error: %w", err)
}
return nil
Expand All @@ -817,7 +838,7 @@ func NewPluginCommand(globalFlags *flag.GlobalFlagGroup) *cobra.Command {
Short: "List installed plugin",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
if err := plugin.List(cmd.Context()); err != nil {
if err := manager.List(cmd.Context()); err != nil {
return xerrors.Errorf("plugin list display error: %w", err)
}
return nil
Expand All @@ -831,7 +852,7 @@ func NewPluginCommand(globalFlags *flag.GlobalFlagGroup) *cobra.Command {
SilenceUsage: true,
Args: cobra.ExactArgs(1),
RunE: func(_ *cobra.Command, args []string) error {
if err := plugin.Information(args[0]); err != nil {
if err := manager.Information(args[0]); err != nil {
return xerrors.Errorf("plugin information display error: %w", err)
}
return nil
Expand All @@ -846,7 +867,7 @@ func NewPluginCommand(globalFlags *flag.GlobalFlagGroup) *cobra.Command {
Short: "Run a plugin on the fly",
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
return plugin.Run(cmd.Context(), args[0], plugin.Options{
return manager.Run(cmd.Context(), args[0], plugin.Options{
Args: args[1:],
Insecure: pluginOptions.Insecure,
})
Expand All @@ -860,7 +881,7 @@ func NewPluginCommand(globalFlags *flag.GlobalFlagGroup) *cobra.Command {
SilenceUsage: true,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
if err := plugin.Update(cmd.Context(), plugin.Options{Insecure: pluginOptions.Insecure}); err != nil {
if err := manager.Update(cmd.Context(), plugin.Options{Insecure: pluginOptions.Insecure}); err != nil {
return xerrors.Errorf("plugin update error: %w", err)
}
return nil
Expand All @@ -878,7 +899,7 @@ func NewPluginCommand(globalFlags *flag.GlobalFlagGroup) *cobra.Command {
if len(args) == 1 {
keyword = args[0]
}
return plugin.Search(cmd.Context(), keyword)
return manager.Search(cmd.Context(), keyword)
},
},
&cobra.Command{
Expand All @@ -888,7 +909,7 @@ func NewPluginCommand(globalFlags *flag.GlobalFlagGroup) *cobra.Command {
SilenceErrors: true,
SilenceUsage: true,
RunE: func(cmd *cobra.Command, args []string) error {
if err := plugin.Upgrade(cmd.Context(), args); err != nil {
if err := manager.Upgrade(cmd.Context(), args); err != nil {
return xerrors.Errorf("plugin upgrade error: %w", err)
}
return nil
Expand Down
21 changes: 20 additions & 1 deletion pkg/iac/scanners/helm/parser/parser_tar.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import (
"path"
"path/filepath"

"helm.sh/helm/v3/pkg/chart/loader"

"github.com/aquasecurity/trivy/pkg/iac/detection"
"github.com/aquasecurity/trivy/pkg/log"
"github.com/aquasecurity/trivy/pkg/mapfs"
Expand Down Expand Up @@ -41,6 +43,11 @@ func (p *Parser) unpackArchive(srcFS fs.FS, targetFS *mapfs.FS, archivePath stri
checkExistedChart := true
symlinks := make(map[string]string)

// Bound how much the archive is allowed to decompress to. Without this a small
// crafted .tgz can expand to gigabytes and OOM the scanner. The limits are the
// Helm SDK's own, so a chart Helm would refuse to load is refused here too.
remainingSize := loader.MaxDecompressedChartSize

for {
header, err := tr.Next()
if err != nil {
Expand Down Expand Up @@ -71,10 +78,22 @@ func (p *Parser) unpackArchive(srcFS fs.FS, targetFS *mapfs.FS, archivePath stri
return err
}
case tar.TypeReg:
data, err := io.ReadAll(tr)
if header.Size > loader.MaxDecompressedFileSize {
return fmt.Errorf("decompressed chart file %q is larger than the maximum file size %d",
name, loader.MaxDecompressedFileSize)
}

// Read at most the remaining budget plus one byte, so an entry whose real
// size exceeds the header (or the budget) is detected rather than trusted.
data, err := io.ReadAll(io.LimitReader(tr, remainingSize+1))
if err != nil {
return fmt.Errorf("read file: %w", err)
}
if int64(len(data)) > remainingSize {
return fmt.Errorf("decompressed chart is larger than the maximum size %d",
loader.MaxDecompressedChartSize)
}
remainingSize -= int64(len(data))

p.logger.Debug("Unpacking tar entry", log.FilePath(targetPath))
if err := writeFile(targetFS, data, targetPath); err != nil {
Expand Down
81 changes: 81 additions & 0 deletions pkg/iac/scanners/helm/parser/parser_tar_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package parser

import (
"archive/tar"
"bytes"
"compress/gzip"
"testing"
"testing/fstest"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"helm.sh/helm/v3/pkg/chart/loader"
)

// buildArchive returns a gzipped tar holding one regular entry per size in sizes.
// Entries are zero bytes, so the archive stays tiny however large they decompress
// to - the shape of a decompression bomb.
func buildArchive(t *testing.T, sizes ...int64) []byte {
t.Helper()

var buf bytes.Buffer
zw := gzip.NewWriter(&buf)
tw := tar.NewWriter(zw)

require.NoError(t, tw.WriteHeader(&tar.Header{
Name: "chart/", Typeflag: tar.TypeDir, Mode: 0o755,
}))

for i, size := range sizes {
require.NoError(t, tw.WriteHeader(&tar.Header{
Name: "chart/file" + string(rune('a'+i)) + ".yaml",
Typeflag: tar.TypeReg,
Mode: 0o644,
Size: size,
}))
_, err := tw.Write(bytes.Repeat([]byte{'x'}, int(size)))
require.NoError(t, err)
}

require.NoError(t, tw.Close())
require.NoError(t, zw.Close())
return buf.Bytes()
}

func parseArchive(t *testing.T, data []byte) error {
t.Helper()
p, err := New(".")
require.NoError(t, err)
return p.ParseFS(t.Context(), fstest.MapFS{
"chart.tar.gz": {Data: data},
}, "chart.tar.gz")
}

// CVE-2026-54448: unpackArchive used to read every entry with an unbounded
// io.ReadAll, so a small archive could decompress to gigabytes and OOM the
// scanner. It now enforces the Helm SDK limits.
func TestUnpackArchiveEnforcesDecompressionLimits(t *testing.T) {
// Shrink the Helm limits so the test does not have to build a 100 MiB archive.
origFile, origChart := loader.MaxDecompressedFileSize, loader.MaxDecompressedChartSize
loader.MaxDecompressedFileSize, loader.MaxDecompressedChartSize = 1024, 2048
t.Cleanup(func() {
loader.MaxDecompressedFileSize, loader.MaxDecompressedChartSize = origFile, origChart
})

t.Run("single entry over the per-file limit is rejected", func(t *testing.T) {
err := parseArchive(t, buildArchive(t, loader.MaxDecompressedFileSize+1))
require.Error(t, err)
assert.Contains(t, err.Error(), "larger than the maximum file size")
})

t.Run("entries summing over the per-archive limit are rejected", func(t *testing.T) {
// Each entry is within the per-file limit; together they blow the budget.
err := parseArchive(t, buildArchive(t, 1024, 1024, 1024))
require.Error(t, err)
assert.Contains(t, err.Error(), "larger than the maximum size")
})

t.Run("archive within the limits still parses", func(t *testing.T) {
require.NoError(t, parseArchive(t, buildArchive(t, 512, 512)))
})
}
6 changes: 6 additions & 0 deletions pkg/oci/artifact.go
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,12 @@ func (a *Artifact) Download(ctx context.Context, dir string, opt DownloadOption)
fileName = v
}

// Reject filenames that are not local relative paths to prevent path traversal
// (e.g., "../foo", "/foo", or "C:\foo" on Windows).
if !filepath.IsLocal(fileName) {
return xerrors.Errorf("invalid filename %q: must be a local relative path", fileName)
}

layerMediaType, err := layer.MediaType()
if err != nil {
return xerrors.Errorf("media type error: %w", err)
Expand Down
31 changes: 30 additions & 1 deletion pkg/oci/artifact_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package oci_test

import (
"cmp"
"errors"
"os"
"path/filepath"
Expand Down Expand Up @@ -42,6 +43,7 @@ func TestArtifact_Download(t *testing.T) {
name string
input string
mediaType string
annotation string
layersReturns layersReturns
want string
wantErr string
Expand Down Expand Up @@ -90,6 +92,33 @@ func TestArtifact_Download(t *testing.T) {
},
wantErr: "unacceptable media type",
},
{
name: "sad: filename with parent traversal",
mediaType: "application/vnd.cncf.openpolicyagent.layer.v1.tar+gzip",
annotation: "../../../etc/passwd",
layersReturns: layersReturns{
layers: []v1.Layer{flayer},
},
wantErr: `invalid filename "../../../etc/passwd"`,
},
{
name: "sad: filename with absolute path",
mediaType: "application/vnd.cncf.openpolicyagent.layer.v1.tar+gzip",
annotation: "/etc/passwd",
layersReturns: layersReturns{
layers: []v1.Layer{flayer},
},
wantErr: `invalid filename "/etc/passwd"`,
},
{
name: "sad: filename with subdirectory traversal",
mediaType: "application/vnd.cncf.openpolicyagent.layer.v1.tar+gzip",
annotation: "foo/../../bar",
layersReturns: layersReturns{
layers: []v1.Layer{flayer},
},
wantErr: `invalid filename "foo/../../bar"`,
},
}

for _, tt := range tests {
Expand All @@ -109,7 +138,7 @@ func TestArtifact_Download(t *testing.T) {
Hex: "cba33656188782852f58993f45b68bfb8577f64cdcf02a604e3fc2afbeb5f2d8",
},
Annotations: map[string]string{
"org.opencontainers.image.title": "bundle.tar.gz",
"org.opencontainers.image.title": cmp.Or(tt.annotation, "bundle.tar.gz"),
},
},
},
Expand Down
12 changes: 7 additions & 5 deletions pkg/plugin/index_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,9 @@ func TestManager_Update(t *testing.T) {
}))
t.Cleanup(ts.Close)

manager := plugin.NewManager(plugin.WithIndexURL(ts.URL + "/index.yaml"))
err := manager.Update(t.Context(), plugin.Options{})
manager, err := plugin.NewManager(plugin.WithIndexURL(ts.URL + "/index.yaml"))
require.NoError(t, err)
err = manager.Update(t.Context(), plugin.Options{})
require.NoError(t, err)

indexPath := filepath.Join(tempDir, ".trivy", "plugins", "index.yaml")
Expand Down Expand Up @@ -65,7 +66,7 @@ bar A bar plugin
{
name: "no index",
keyword: "",
dir: "unknown",
dir: t.TempDir(), // a fresh dir without index.yaml
wantErr: "plugin index not found",
},
}
Expand All @@ -74,8 +75,9 @@ bar A bar plugin
t.Setenv("XDG_DATA_HOME", tt.dir)

var got bytes.Buffer
m := plugin.NewManager(plugin.WithWriter(&got))
err := m.Search(t.Context(), tt.keyword)
m, err := plugin.NewManager(plugin.WithWriter(&got))
require.NoError(t, err)
err = m.Search(t.Context(), tt.keyword)
if tt.wantErr != "" {
require.ErrorContains(t, err, tt.wantErr)
return
Expand Down
Loading
Loading