feat(image): enforce image platform (#4083)

This commit is contained in:
Teppei Fukuda
2023-05-08 21:04:22 +03:00
committed by GitHub
parent 9c87cb2710
commit 55fb723a6e
8 changed files with 142 additions and 64 deletions

View File

@@ -550,7 +550,6 @@ func initScannerConfig(opts flag.Options, cacheClient cache.Cache) (ScannerConfi
Scanners: opts.Scanners, Scanners: opts.Scanners,
ImageConfigScanners: opts.ImageConfigScanners, // this is valid only for 'image' subcommand ImageConfigScanners: opts.ImageConfigScanners, // this is valid only for 'image' subcommand
ScanRemovedPackages: opts.ScanRemovedPkgs, // this is valid only for 'image' subcommand ScanRemovedPackages: opts.ScanRemovedPkgs, // this is valid only for 'image' subcommand
Platform: opts.Platform, // this is valid only for 'image' subcommand
ListAllPackages: opts.ListAllPkgs, ListAllPackages: opts.ListAllPkgs,
LicenseCategories: opts.LicenseCategories, LicenseCategories: opts.LicenseCategories,
FilePatterns: opts.FilePatterns, FilePatterns: opts.FilePatterns,
@@ -642,11 +641,10 @@ func initScannerConfig(opts flag.Options, cacheClient cache.Cache) (ScannerConfi
RepoTag: opts.RepoTag, RepoTag: opts.RepoTag,
SBOMSources: opts.SBOMSources, SBOMSources: opts.SBOMSources,
RekorURL: opts.RekorURL, RekorURL: opts.RekorURL,
Platform: opts.Platform, //Platform: opts.Platform,
DockerHost: opts.DockerHost, Slow: opts.Slow,
Slow: opts.Slow, AWSRegion: opts.Region,
AWSRegion: opts.Region, FileChecksum: fileChecksum,
FileChecksum: fileChecksum,
// For image scanning // For image scanning
ImageOption: ftypes.ImageOptions{ ImageOption: ftypes.ImageOptions{

View File

@@ -23,8 +23,6 @@ type Option struct {
AppDirs []string AppDirs []string
SBOMSources []string SBOMSources []string
RekorURL string RekorURL string
Platform string
DockerHost string
Slow bool // Lower CPU and memory Slow bool // Lower CPU and memory
AWSRegion string AWSRegion string
FileChecksum bool // For SPDX FileChecksum bool // For SPDX

View File

@@ -529,7 +529,12 @@ func TestDockerPlatformArguments(t *testing.T) {
}, },
}, },
Insecure: true, Insecure: true,
Platform: "arm/linux", Platform: types.Platform{
Platform: &v1.Platform{
Architecture: "arm",
OS: "linux",
},
},
}, },
}, },
}, },
@@ -543,8 +548,7 @@ func TestDockerPlatformArguments(t *testing.T) {
defer cleanup() defer cleanup()
if tt.wantErr != "" { if tt.wantErr != "" {
assert.NotNil(t, err) assert.ErrorContains(t, err, tt.wantErr, err)
assert.Contains(t, err.Error(), tt.wantErr, err)
} else { } else {
assert.NoError(t, err) assert.NoError(t, err)
} }

View File

@@ -2,6 +2,14 @@ package types
import v1 "github.com/google/go-containerregistry/pkg/v1" import v1 "github.com/google/go-containerregistry/pkg/v1"
type Platform struct {
*v1.Platform
// Force returns an error if the specified platform is not found.
// This option is for Aqua, and cannot be configured via Trivy CLI.
Force bool
}
type Image interface { type Image interface {
v1.Image v1.Image
ImageExtension ImageExtension
@@ -44,7 +52,7 @@ type RegistryOptions struct {
Insecure bool Insecure bool
// Architecture // Architecture
Platform string Platform Platform
// ECR // ECR
AWSAccessKey string AWSAccessKey string

View File

@@ -1,8 +1,10 @@
package flag package flag
import ( import (
v1 "github.com/google/go-containerregistry/pkg/v1"
"golang.org/x/xerrors" "golang.org/x/xerrors"
ftypes "github.com/aquasecurity/trivy/pkg/fanal/types"
"github.com/aquasecurity/trivy/pkg/types" "github.com/aquasecurity/trivy/pkg/types"
) )
@@ -56,7 +58,7 @@ type ImageOptions struct {
Input string Input string
ImageConfigScanners types.Scanners ImageConfigScanners types.Scanners
ScanRemovedPkgs bool ScanRemovedPkgs bool
Platform string Platform ftypes.Platform
DockerHost string DockerHost string
} }
@@ -89,11 +91,24 @@ func (f *ImageFlagGroup) ToOptions() (ImageOptions, error) {
if err != nil { if err != nil {
return ImageOptions{}, xerrors.Errorf("unable to parse image config scanners: %w", err) return ImageOptions{}, xerrors.Errorf("unable to parse image config scanners: %w", err)
} }
var platform ftypes.Platform
if p := getString(f.Platform); p != "" {
pl, err := v1.ParsePlatform(p)
if err != nil {
return ImageOptions{}, xerrors.Errorf("unable to parse platform: %w", err)
}
if pl.OS == "*" {
pl.OS = "" // Empty OS means any OS
}
platform = ftypes.Platform{Platform: pl}
}
return ImageOptions{ return ImageOptions{
Input: getString(f.Input), Input: getString(f.Input),
ImageConfigScanners: scanners, ImageConfigScanners: scanners,
ScanRemovedPkgs: getBool(f.ScanRemovedPkgs), ScanRemovedPkgs: getBool(f.ScanRemovedPkgs),
Platform: getString(f.Platform), Platform: platform,
DockerHost: getString(f.DockerHost), DockerHost: getString(f.DockerHost),
}, nil }, nil
} }

View File

@@ -5,7 +5,6 @@ import (
"crypto/tls" "crypto/tls"
"net" "net"
"net/http" "net/http"
"strings"
"time" "time"
"github.com/google/go-containerregistry/pkg/authn" "github.com/google/go-containerregistry/pkg/authn"
@@ -37,14 +36,14 @@ func Get(ctx context.Context, ref name.Reference, option types.RegistryOptions)
authOpt, authOpt,
} }
if option.Platform != "" { if option.Platform.Platform != nil {
s, err := parsePlatform(ref, option.Platform, remoteOpts) p, err := resolvePlatform(ref, option.Platform, remoteOpts)
if err != nil { if err != nil {
return nil, xerrors.Errorf("platform error: %w", err) return nil, xerrors.Errorf("platform error: %w", err)
} }
// Don't pass platform when the specified image is single-arch. // Don't pass platform when the specified image is single-arch.
if s != nil { if p.Platform != nil {
remoteOpts = append(remoteOpts, remote.WithPlatform(*s)) remoteOpts = append(remoteOpts, remote.WithPlatform(*p.Platform))
} }
} }
@@ -54,6 +53,11 @@ func Get(ctx context.Context, ref name.Reference, option types.RegistryOptions)
continue continue
} }
if option.Platform.Force {
if err = satisfyPlatform(desc, lo.FromPtr(option.Platform.Platform)); err != nil {
return nil, err
}
}
return desc, nil return desc, nil
} }
@@ -113,12 +117,11 @@ func httpTransport(insecure bool) *http.Transport {
d := &net.Dialer{ d := &net.Dialer{
Timeout: 10 * time.Minute, Timeout: 10 * time.Minute,
} }
return &http.Transport{ tr := http.DefaultTransport.(*http.Transport).Clone()
Proxy: http.ProxyFromEnvironment, tr.DialContext = d.DialContext
DisableKeepAlives: true, tr.TLSClientConfig = &tls.Config{InsecureSkipVerify: insecure}
DialContext: d.DialContext,
TLSClientConfig: &tls.Config{InsecureSkipVerify: insecure}, return tr
}
} }
func authOptions(ctx context.Context, ref name.Reference, option types.RegistryOptions) []remote.Option { func authOptions(ctx context.Context, ref name.Reference, option types.RegistryOptions) []remote.Option {
@@ -147,45 +150,69 @@ func authOptions(ctx context.Context, ref name.Reference, option types.RegistryO
} }
} }
func parsePlatform(ref name.Reference, p string, options []remote.Option) (*v1.Platform, error) { // resolvePlatform resolves the OS platform for a given image reference.
// If the platform has an empty OS, the function will attempt to find the first OS
// in the image's manifest list and return the platform with the detected OS.
// It ignores the specified platform if the image is not multi-arch.
func resolvePlatform(ref name.Reference, p types.Platform, options []remote.Option) (types.Platform, error) {
if p.OS != "" {
return p, nil
}
// OS wildcard, implicitly pick up the first os found in the image list. // OS wildcard, implicitly pick up the first os found in the image list.
// e.g. */amd64 // e.g. */amd64
if strings.HasPrefix(p, "*/") { d, err := remote.Get(ref, options...)
d, err := remote.Get(ref, options...)
if err != nil {
return nil, xerrors.Errorf("image get error: %w", err)
}
switch d.MediaType {
case v1types.OCIManifestSchema1, v1types.DockerManifestSchema2:
// We want an index but the registry has an image, not multi-arch. We just ignore "--platform".
log.Logger.Debug("Ignore --platform as the image is not multi-arch")
return nil, nil
case v1types.OCIImageIndex, v1types.DockerManifestList:
// These are expected.
}
index, err := d.ImageIndex()
if err != nil {
return nil, xerrors.Errorf("image index error: %w", err)
}
m, err := index.IndexManifest()
if err != nil {
return nil, xerrors.Errorf("remote index manifest error: %w", err)
}
if len(m.Manifests) == 0 {
log.Logger.Debug("Ignore --platform as the image is not multi-arch")
return nil, nil
}
if m.Manifests[0].Platform != nil {
// Replace with the detected OS
// e.g. */amd64 => linux/amd64
p = m.Manifests[0].Platform.OS + strings.TrimPrefix(p, "*")
}
}
platform, err := v1.ParsePlatform(p)
if err != nil { if err != nil {
return nil, xerrors.Errorf("platform parse error: %w", err) return types.Platform{}, xerrors.Errorf("image get error: %w", err)
} }
return platform, nil switch d.MediaType {
case v1types.OCIManifestSchema1, v1types.DockerManifestSchema2:
// We want an index but the registry has an image, not multi-arch. We just ignore "--platform".
log.Logger.Debug("Ignore --platform as the image is not multi-arch")
return types.Platform{}, nil
case v1types.OCIImageIndex, v1types.DockerManifestList:
// These are expected.
}
index, err := d.ImageIndex()
if err != nil {
return types.Platform{}, xerrors.Errorf("image index error: %w", err)
}
m, err := index.IndexManifest()
if err != nil {
return types.Platform{}, xerrors.Errorf("remote index manifest error: %w", err)
}
if len(m.Manifests) == 0 {
log.Logger.Debug("Ignore '--platform' as the image is not multi-arch")
return types.Platform{}, nil
}
if m.Manifests[0].Platform != nil {
newPlatform := p.DeepCopy()
// Replace with the detected OS
// e.g. */amd64 => linux/amd64
newPlatform.OS = m.Manifests[0].Platform.OS
// Return the platform with the found OS
return types.Platform{
Platform: newPlatform,
Force: p.Force,
}, nil
}
return types.Platform{}, nil
}
func satisfyPlatform(desc *remote.Descriptor, platform v1.Platform) error {
img, err := desc.Image()
if err != nil {
return err
}
c, err := img.ConfigFile()
if err != nil {
return err
}
if !lo.FromPtr(c.Platform()).Satisfies(platform) {
return xerrors.Errorf("the specified platform not found")
}
return nil
} }

View File

@@ -5,6 +5,7 @@ import (
"encoding/base64" "encoding/base64"
"fmt" "fmt"
"github.com/google/go-containerregistry/pkg/name" "github.com/google/go-containerregistry/pkg/name"
v1 "github.com/google/go-containerregistry/pkg/v1"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"net/http/httptest" "net/http/httptest"
@@ -126,10 +127,38 @@ func TestGet(t *testing.T) {
}, },
}, },
Insecure: true, Insecure: true,
Platform: "*/amd64", Platform: types.Platform{
Platform: &v1.Platform{
OS: "",
Architecture: "amd64",
},
},
}, },
}, },
}, },
{
name: "force platform",
args: args{
imageName: fmt.Sprintf("%s/library/alpine:3.10", serverAddr),
option: types.RegistryOptions{
Credentials: []types.Credential{
{
Username: "test",
Password: "testpass",
},
},
Insecure: true,
Platform: types.Platform{
Force: true,
Platform: &v1.Platform{
OS: "windows",
Architecture: "amd64",
},
},
},
},
wantErr: "the specified platform not found",
},
{ {
name: "bad credential", name: "bad credential",
args: args{ args: args{

View File

@@ -10,7 +10,6 @@ type ScanOptions struct {
Scanners Scanners Scanners Scanners
ImageConfigScanners Scanners // Scanners for container image configuration ImageConfigScanners Scanners // Scanners for container image configuration
ScanRemovedPackages bool ScanRemovedPackages bool
Platform string
ListAllPackages bool ListAllPackages bool
LicenseCategories map[types.LicenseCategory][]string LicenseCategories map[types.LicenseCategory][]string
FilePatterns []string FilePatterns []string