feat(image): secret scanning on container image config (#3495)

This commit is contained in:
Teppei Fukuda
2023-01-30 16:50:56 +02:00
committed by GitHub
parent 1eca973cbf
commit 6eec9ac0a4
13 changed files with 373 additions and 70 deletions

View File

@@ -21,6 +21,7 @@ linters-settings:
local-prefixes: github.com/aquasecurity
gosec:
excludes:
- G101
- G114
- G204
- G402

View File

@@ -6,6 +6,7 @@ import (
_ "github.com/aquasecurity/trivy/pkg/fanal/analyzer/executable"
_ "github.com/aquasecurity/trivy/pkg/fanal/analyzer/imgconf/apk"
_ "github.com/aquasecurity/trivy/pkg/fanal/analyzer/imgconf/dockerfile"
_ "github.com/aquasecurity/trivy/pkg/fanal/analyzer/imgconf/secret"
_ "github.com/aquasecurity/trivy/pkg/fanal/analyzer/language/c/conan"
_ "github.com/aquasecurity/trivy/pkg/fanal/analyzer/language/conda/meta"
_ "github.com/aquasecurity/trivy/pkg/fanal/analyzer/language/dart/pub"

View File

@@ -39,6 +39,7 @@ type ConfigAnalyzerOptions struct {
FilePatterns []string
DisabledAnalyzers []Type
MisconfScannerOption misconf.ScannerOption
SecretScannerOption SecretScannerOption
}
type ConfigAnalysisInput struct {
@@ -48,6 +49,7 @@ type ConfigAnalysisInput struct {
type ConfigAnalysisResult struct {
Misconfiguration *types.Misconfiguration
Secret *types.Secret
HistoryPackages types.Packages
}
@@ -58,6 +60,9 @@ func (r *ConfigAnalysisResult) Merge(new *ConfigAnalysisResult) {
if new.Misconfiguration != nil {
r.Misconfiguration = new.Misconfiguration
}
if new.Secret != nil {
r.Secret = new.Secret
}
if new.HistoryPackages != nil {
r.HistoryPackages = new.HistoryPackages
}

View File

@@ -96,6 +96,7 @@ const (
// ============
TypeApkCommand Type = "apk-command"
TypeHistoryDockerfile Type = "history-dockerfile"
TypeImageConfigSecret Type = "image-config-secret"
// =================
// Structured Config
@@ -127,30 +128,93 @@ const (
var (
// TypeOSes has all OS-related analyzers
TypeOSes = []Type{
TypeOSRelease, TypeAlpine, TypeAmazon, TypeCBLMariner, TypeDebian, TypePhoton, TypeCentOS,
TypeRocky, TypeAlma, TypeFedora, TypeOracle, TypeRedHatBase, TypeSUSE, TypeUbuntu,
TypeApk, TypeDpkg, TypeDpkgLicense, TypeRpm, TypeRpmqa,
TypeOSRelease,
TypeAlpine,
TypeAmazon,
TypeCBLMariner,
TypeDebian,
TypePhoton,
TypeCentOS,
TypeRocky,
TypeAlma,
TypeFedora,
TypeOracle,
TypeRedHatBase,
TypeSUSE,
TypeUbuntu,
TypeApk,
TypeDpkg,
TypeDpkgLicense,
TypeRpm,
TypeRpmqa,
TypeApkRepo,
}
// TypeLanguages has all language analyzers
TypeLanguages = []Type{
TypeBundler, TypeGemSpec, TypeCargo, TypeComposer, TypeJar, TypePom, TypeGradleLock,
TypeNpmPkgLock, TypeNodePkg, TypeYarn, TypePnpm, TypeNuget, TypeDotNetCore, TypeCondaPkg,
TypePythonPkg, TypePip, TypePipenv, TypePoetry, TypeGoBinary, TypeGoMod, TypeRustBinary, TypeConanLock,
TypeCocoaPods, TypePubSpecLock, TypeMixLock,
TypeBundler,
TypeGemSpec,
TypeCargo,
TypeComposer,
TypeJar,
TypePom,
TypeGradleLock,
TypeNpmPkgLock,
TypeNodePkg,
TypeYarn,
TypePnpm,
TypeNuget,
TypeDotNetCore,
TypeCondaPkg,
TypePythonPkg,
TypePip,
TypePipenv,
TypePoetry,
TypeGoBinary,
TypeGoMod,
TypeRustBinary,
TypeConanLock,
TypeCocoaPods,
TypePubSpecLock,
TypeMixLock,
}
// TypeLockfiles has all lock file analyzers
TypeLockfiles = []Type{
TypeBundler, TypeNpmPkgLock, TypeYarn,
TypePnpm, TypePip, TypePipenv, TypePoetry, TypeGoMod, TypePom, TypeConanLock, TypeGradleLock,
TypeCocoaPods, TypePubSpecLock, TypeMixLock,
TypeBundler,
TypeNpmPkgLock,
TypeYarn,
TypePnpm,
TypePip,
TypePipenv,
TypePoetry,
TypeGoMod,
TypePom,
TypeConanLock,
TypeGradleLock,
TypeCocoaPods,
TypePubSpecLock,
TypeMixLock,
}
// TypeIndividualPkgs has all analyzers for individual packages
TypeIndividualPkgs = []Type{TypeGemSpec, TypeNodePkg, TypeCondaPkg, TypePythonPkg, TypeGoBinary, TypeJar, TypeRustBinary}
TypeIndividualPkgs = []Type{
TypeGemSpec,
TypeNodePkg,
TypeCondaPkg,
TypePythonPkg,
TypeGoBinary,
TypeJar,
TypeRustBinary,
}
// TypeConfigFiles has all config file analyzers
TypeConfigFiles = []Type{TypeYaml, TypeJSON, TypeDockerfile, TypeTerraform, TypeCloudFormation, TypeHelm}
TypeConfigFiles = []Type{
TypeYaml,
TypeJSON,
TypeDockerfile,
TypeTerraform,
TypeCloudFormation,
TypeHelm,
}
)

View File

@@ -123,6 +123,7 @@ func Test_historyAnalyzer_Analyze(t *testing.T) {
if got != nil && got.Misconfiguration != nil {
got.Misconfiguration.Successes = nil // Not compare successes in this test
}
require.NoError(t, err)
assert.Equal(t, tt.want, got)
})
}

View File

@@ -0,0 +1,74 @@
package secret
import (
"context"
"encoding/json"
"golang.org/x/xerrors"
"github.com/aquasecurity/trivy/pkg/fanal/analyzer"
"github.com/aquasecurity/trivy/pkg/fanal/secret"
"github.com/aquasecurity/trivy/pkg/fanal/types"
"github.com/aquasecurity/trivy/pkg/log"
)
const analyzerVersion = 1
func init() {
analyzer.RegisterConfigAnalyzer(analyzer.TypeImageConfigSecret, newSecretAnalyzer)
}
// secretAnalyzer detects secrets in container image config.
type secretAnalyzer struct {
scanner secret.Scanner
}
func newSecretAnalyzer(opts analyzer.ConfigAnalyzerOptions) (analyzer.ConfigAnalyzer, error) {
configPath := opts.SecretScannerOption.ConfigPath
c, err := secret.ParseConfig(configPath)
if err != nil {
return nil, xerrors.Errorf("secret config error: %w", err)
}
scanner := secret.NewScanner(c)
return &secretAnalyzer{
scanner: scanner,
}, nil
}
func (a *secretAnalyzer) Analyze(_ context.Context, input analyzer.ConfigAnalysisInput) (*analyzer.
ConfigAnalysisResult, error) {
if input.Config == nil {
return nil, nil
}
b, err := json.MarshalIndent(input.Config, " ", "")
if err != nil {
return nil, xerrors.Errorf("json marshal error: %w", err)
}
result := a.scanner.Scan(secret.ScanArgs{
FilePath: "config.json",
Content: b,
})
if len(result.Findings) == 0 {
log.Logger.Debug("No secrets found in container image config")
return nil, nil
}
return &analyzer.ConfigAnalysisResult{
Secret: &result,
}, nil
}
func (a *secretAnalyzer) Required(_ types.OS) bool {
return true
}
func (a *secretAnalyzer) Type() analyzer.Type {
return analyzer.TypeImageConfigSecret
}
func (a *secretAnalyzer) Version() int {
return analyzerVersion
}

View File

@@ -0,0 +1,109 @@
package secret
import (
"context"
"testing"
v1 "github.com/google/go-containerregistry/pkg/v1"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/aquasecurity/trivy/pkg/fanal/analyzer"
"github.com/aquasecurity/trivy/pkg/fanal/types"
)
func Test_secretAnalyzer_Analyze(t *testing.T) {
tests := []struct {
name string
config *v1.ConfigFile
want *analyzer.ConfigAnalysisResult
wantErr bool
}{
{
name: "happy path",
config: &v1.ConfigFile{
Config: v1.Config{
Env: []string{
"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
"secret=ghp_eifae6eigh3aeSah1shahd6oi1tague6vaey", // dummy token
},
},
},
want: &analyzer.ConfigAnalysisResult{
Secret: &types.Secret{
FilePath: "config.json",
Findings: []types.SecretFinding{
{
RuleID: "github-pat",
Category: "GitHub",
Severity: "CRITICAL",
Title: "GitHub Personal Access Token",
StartLine: 12,
EndLine: 12,
Code: types.Code{
Lines: []types.Line{
{
Number: 10,
Content: " \"Env\": [",
Highlighted: " \"Env\": [",
},
{
Number: 11,
Content: " \"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin\",",
Highlighted: " \"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin\",",
},
{
Number: 12,
Content: " \"secret=****************************************\"",
IsCause: true,
Highlighted: " \"secret=****************************************\"",
FirstCause: true,
LastCause: true,
},
{
Number: 13,
Content: " ]",
Highlighted: " ]",
},
},
},
Match: " \"secret=****************************************\"",
},
},
},
},
},
{
name: "no secret",
config: &v1.ConfigFile{
Config: v1.Config{
Env: []string{
"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
},
},
},
want: nil,
},
{
name: "nil config",
config: nil,
want: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
a, err := newSecretAnalyzer(analyzer.ConfigAnalyzerOptions{})
require.NoError(t, err)
got, err := a.Analyze(context.Background(), analyzer.ConfigAnalysisInput{
Config: tt.config,
})
if tt.wantErr {
assert.Error(t, err)
return
}
require.NoError(t, err)
assert.Equal(t, tt.want, got)
})
}
}

View File

@@ -29,8 +29,11 @@ func (a Applier) ApplyLayers(imageID string, layerKeys []string) (types.Artifact
mergedLayer := ApplyLayers(layers)
imageInfo, _ := a.cache.GetArtifact(imageID) // nolint
mergedLayer.HistoryPackages = imageInfo.HistoryPackages
mergedLayer.ImageMisconfiguration = imageInfo.Misconfiguration
mergedLayer.ImageConfig = types.ImageConfigDetail{
Packages: imageInfo.HistoryPackages,
Misconfiguration: imageInfo.Misconfiguration,
Secret: imageInfo.Secret,
}
if !mergedLayer.OS.Detected() {
return mergedLayer, analyzer.ErrUnknownOS // send back package and apps info regardless

View File

@@ -337,38 +337,40 @@ func TestApplier_ApplyLayers(t *testing.T) {
},
},
},
HistoryPackages: []types.Package{
{
Name: "musl",
Version: "1.1.23",
},
{
Name: "busybox",
Version: "1.31",
},
{
Name: "ncurses-libs",
Version: "6.1_p20190518-r0",
},
{
Name: "ncurses-terminfo-base",
Version: "6.1_p20190518-r0",
},
{
Name: "ncurses",
Version: "6.1_p20190518-r0",
},
{
Name: "ncurses-terminfo",
Version: "6.1_p20190518-r0",
},
{
Name: "bash",
Version: "5.0.0-r0",
},
{
Name: "readline",
Version: "8.0.0-r0",
ImageConfig: types.ImageConfigDetail{
Packages: []types.Package{
{
Name: "musl",
Version: "1.1.23",
},
{
Name: "busybox",
Version: "1.31",
},
{
Name: "ncurses-libs",
Version: "6.1_p20190518-r0",
},
{
Name: "ncurses-terminfo-base",
Version: "6.1_p20190518-r0",
},
{
Name: "ncurses",
Version: "6.1_p20190518-r0",
},
{
Name: "ncurses-terminfo",
Version: "6.1_p20190518-r0",
},
{
Name: "bash",
Version: "5.0.0-r0",
},
{
Name: "readline",
Version: "8.0.0-r0",
},
},
},
},

View File

@@ -379,6 +379,7 @@ func (a Artifact) inspectConfig(ctx context.Context, imageID string, osFound typ
DockerVersion: config.DockerVersion,
OS: config.OS,
Misconfiguration: result.Misconfiguration,
Secret: result.Secret,
HistoryPackages: result.HistoryPackages,
}

View File

@@ -47,56 +47,83 @@ var tests = []testCase{
name: "happy path, alpine:3.10",
remoteImageName: "ghcr.io/aquasecurity/trivy-test-images:alpine-310",
imageFile: "../../../../integration/testdata/fixtures/images/alpine-310.tar.gz",
wantOS: types.OS{Name: "3.10.2", Family: "alpine"},
wantOS: types.OS{
Name: "3.10.2",
Family: "alpine",
},
},
{
name: "happy path, amazonlinux:2",
remoteImageName: "ghcr.io/aquasecurity/trivy-test-images:amazon-2",
imageFile: "../../../../integration/testdata/fixtures/images/amazon-2.tar.gz",
wantOS: types.OS{Name: "2 (Karoo)", Family: "amazon"},
wantOS: types.OS{
Name: "2 (Karoo)",
Family: "amazon",
},
},
{
name: "happy path, debian:buster",
remoteImageName: "ghcr.io/aquasecurity/trivy-test-images:debian-buster",
imageFile: "../../../../integration/testdata/fixtures/images/debian-buster.tar.gz",
wantOS: types.OS{Name: "10.1", Family: "debian"},
wantOS: types.OS{
Name: "10.1",
Family: "debian",
},
},
{
name: "happy path, photon:3.0",
remoteImageName: "ghcr.io/aquasecurity/trivy-test-images:photon-30",
imageFile: "../../../../integration/testdata/fixtures/images/photon-30.tar.gz",
wantOS: types.OS{Name: "3.0", Family: "photon"},
wantOS: types.OS{
Name: "3.0",
Family: "photon",
},
},
{
name: "happy path, registry.redhat.io/ubi7",
remoteImageName: "ghcr.io/aquasecurity/trivy-test-images:ubi-7",
imageFile: "../../../../integration/testdata/fixtures/images/ubi-7.tar.gz",
wantOS: types.OS{Name: "7.7", Family: "redhat"},
wantOS: types.OS{
Name: "7.7",
Family: "redhat",
},
},
{
name: "happy path, opensuse leap 15.1",
remoteImageName: "ghcr.io/aquasecurity/trivy-test-images:opensuse-leap-151",
imageFile: "../../../../integration/testdata/fixtures/images/opensuse-leap-151.tar.gz",
wantOS: types.OS{Name: "15.1", Family: "opensuse.leap"},
wantOS: types.OS{
Name: "15.1",
Family: "opensuse.leap",
},
},
{
// from registry.suse.com/suse/sle15:15.3.17.8.16
name: "happy path, suse 15.3 (NDB)",
remoteImageName: "ghcr.io/aquasecurity/trivy-test-images:suse-15.3_ndb",
imageFile: "../../../../integration/testdata/fixtures/images/suse-15.3_ndb.tar.gz",
wantOS: types.OS{Name: "15.3", Family: "suse linux enterprise server"},
wantOS: types.OS{
Name: "15.3",
Family: "suse linux enterprise server",
},
},
{
name: "happy path, Fedora 35",
remoteImageName: "ghcr.io/aquasecurity/trivy-test-images:fedora-35",
imageFile: "../../../../integration/testdata/fixtures/images/fedora-35.tar.gz",
wantOS: types.OS{Name: "35", Family: "fedora"},
wantOS: types.OS{
Name: "35",
Family: "fedora",
},
},
{
name: "happy path, vulnimage with lock files",
remoteImageName: "ghcr.io/aquasecurity/trivy-test-images:vulnimage",
imageFile: "../../../../integration/testdata/fixtures/images/vulnimage.tar.gz",
wantOS: types.OS{Name: "3.7.1", Family: "alpine"},
name: "happy path, vulnimage with lock files",
remoteImageName: "ghcr.io/aquasecurity/trivy-test-images:vulnimage",
imageFile: "../../../../integration/testdata/fixtures/images/vulnimage.tar.gz",
wantOS: types.OS{
Name: "3.7.1",
Family: "alpine",
},
wantApplicationFile: "testdata/goldens/vuln-image1.2.3.expectedlibs.golden",
wantPkgsFromCmds: "testdata/goldens/vuln-image1.2.3.expectedpkgsfromcmds.golden",
},
@@ -333,8 +360,8 @@ func checkPackageFromCommands(t *testing.T, detail types.ArtifactDetail, tc test
err := json.Unmarshal(data, &expectedPkgsFromCmds)
require.NoError(t, err)
assert.ElementsMatch(t, expectedPkgsFromCmds, detail.HistoryPackages, tc.name)
assert.ElementsMatch(t, expectedPkgsFromCmds, detail.ImageConfig.Packages, tc.name)
} else {
assert.Equal(t, []types.Package(nil), detail.HistoryPackages, tc.name)
assert.Equal(t, []types.Package(nil), detail.ImageConfig.Packages, tc.name)
}
}

View File

@@ -202,6 +202,9 @@ type ArtifactInfo struct {
// Misconfiguration holds misconfiguration in container image config
Misconfiguration *Misconfiguration `json:",omitempty"`
// Secret holds secrets in container image config such as environment variables
Secret *Secret `json:",omitempty"`
// HistoryPackages are packages extracted from RUN instructions
HistoryPackages Packages `json:",omitempty"`
}
@@ -246,17 +249,26 @@ type ArtifactDetail struct {
Secrets []Secret `json:",omitempty"`
Licenses []LicenseFile `json:",omitempty"`
// ImageMisconfiguration holds misconfigurations in container image config
ImageMisconfiguration *Misconfiguration `json:",omitempty"`
// HistoryPackages are packages extracted from RUN instructions
HistoryPackages []Package `json:",omitempty"`
// ImageConfig has information from container image config
ImageConfig ImageConfigDetail
// CustomResources hold analysis results from custom analyzers.
// It is for extensibility and not used in OSS.
CustomResources []CustomResource `json:",omitempty"`
}
// ImageConfigDetail has information from container image config
type ImageConfigDetail struct {
// Packages are packages extracted from RUN instructions in history
Packages []Package `json:",omitempty"`
// Misconfiguration holds misconfigurations in container image config
Misconfiguration *Misconfiguration `json:",omitempty"`
// Secret holds secrets in container image config
Secret *Secret `json:",omitempty"`
}
// ToBlobInfo is used to store a merged layer in cache.
func (a *ArtifactDetail) ToBlobInfo() BlobInfo {
return BlobInfo{

View File

@@ -149,9 +149,9 @@ func (s Scanner) Scan(ctx context.Context, target, artifactKey string, blobKeys
results = append(results, licenseResults...)
}
// Scan misconfiguration on container image config
// Scan misconfigurations on container image config
if options.ImageConfigScanners.Enabled(types.MisconfigScanner) {
if im := artifactDetail.ImageMisconfiguration; im != nil {
if im := artifactDetail.ImageConfig.Misconfiguration; im != nil {
im.FilePath = target // Set the target name to the file path as container image config is not a real file.
results = append(results, s.MisconfsToResults([]ftypes.Misconfiguration{*im})...)
}
@@ -159,7 +159,10 @@ func (s Scanner) Scan(ctx context.Context, target, artifactKey string, blobKeys
// Scan secrets on container image config
if options.ImageConfigScanners.Enabled(types.SecretScanner) {
// TODO
if is := artifactDetail.ImageConfig.Secret; is != nil {
is.FilePath = target // Set the target name to the file path as container image config is not a real file.
results = append(results, s.secretsToResults([]ftypes.Secret{*is})...)
}
}
// For WASM plugins and custom analyzers
@@ -191,7 +194,7 @@ func (s Scanner) osPkgsToResult(target string, detail ftypes.ArtifactDetail, opt
pkgs := detail.Packages
if options.ScanRemovedPackages {
pkgs = mergePkgs(pkgs, detail.HistoryPackages)
pkgs = mergePkgs(pkgs, detail.ImageConfig.Packages)
}
sort.Sort(pkgs)
return &types.Result{
@@ -260,7 +263,7 @@ func (s Scanner) scanOSPkgs(target string, detail ftypes.ArtifactDetail, options
pkgs := detail.Packages
if options.ScanRemovedPackages {
pkgs = mergePkgs(pkgs, detail.HistoryPackages)
pkgs = mergePkgs(pkgs, detail.ImageConfig.Packages)
}
if detail.OS.Extended {