diff --git a/go.mod b/go.mod index 5459864..ffa0b6d 100644 --- a/go.mod +++ b/go.mod @@ -28,6 +28,7 @@ require ( github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e // indirect github.com/golang/protobuf v1.5.2 // indirect github.com/google/uuid v1.3.0 // indirect + github.com/hashicorp/go-version v1.6.0 // indirect github.com/kennygrant/sanitize v1.2.4 // indirect github.com/klauspost/compress v1.16.3 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect diff --git a/go.sum b/go.sum index 843d813..2dce035 100644 --- a/go.sum +++ b/go.sum @@ -62,6 +62,8 @@ github.com/graph-gophers/graphql-go v1.5.0 h1:fDqblo50TEpD0LY7RXk/LFVYEVqo3+tXMN github.com/graph-gophers/graphql-go v1.5.0/go.mod h1:YtmJZDLbF1YYNrlNAuiO5zAStUWc3XZT07iGsVqe1Os= github.com/graph-gophers/graphql-transport-ws v0.0.2 h1:DbmSkbIGzj8SvHei6n8Mh9eLQin8PtA8xY9eCzjRpvo= github.com/graph-gophers/graphql-transport-ws v0.0.2/go.mod h1:5BVKvFzOd2BalVIBFfnfmHjpJi/MZ5rOj8G55mXvZ8g= +github.com/hashicorp/go-version v1.6.0 h1:feTTfFNnjP967rlCxM/I9g701jU+RN74YKx2mOkIeek= +github.com/hashicorp/go-version v1.6.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= github.com/hasura/go-graphql-client v0.9.2 h1:4FyAeVOu+GcS1BaoELWNyxzaLY7s+g72LLH+qYItdEY= github.com/hasura/go-graphql-client v0.9.2/go.mod h1:AarJlxO1I59MPqU/TC7gQP0BMFgPEqUTt5LYPvykasw= github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI= diff --git a/pkg/checks/docker/baseimagecheck.go b/pkg/checks/docker/baseimagecheck.go index 760bce3..668fd06 100644 --- a/pkg/checks/docker/baseimagecheck.go +++ b/pkg/checks/docker/baseimagecheck.go @@ -8,6 +8,7 @@ import ( "github.com/salsadigitalauorg/shipshape/pkg/config" "github.com/salsadigitalauorg/shipshape/pkg/result" "github.com/salsadigitalauorg/shipshape/pkg/utils" + "gopkg.in/yaml.v3" ) @@ -78,14 +79,14 @@ func (c *BaseImageCheck) RunCheck() { defer df.Close() scanner := bufio.NewScanner(df) for scanner.Scan() { - from_regex := regexp.MustCompile("^FROM (.*)") + from_regex := regexp.MustCompile("^FROM (.[^:@]*)?[:@]?([^ latest$]*)") match := from_regex.FindStringSubmatch(scanner.Text()) if len(match) < 1 { continue } - if len(c.Allowed) > 0 && !utils.StringSliceContains(c.Allowed, match[1]) { + if len(c.Allowed) > 0 && !utils.PackageCheckString(c.Allowed, match[1], match[2]) { c.AddFail(name + " is using invalid base image " + match[1]) c.AddBreach(result.KeyValueBreach{ Key: name, @@ -99,15 +100,23 @@ func (c *BaseImageCheck) RunCheck() { } } } else { - if !utils.StringSliceMatch(c.Allowed, def.Image) { - c.AddFail(name + " is using invalid base image " + def.Image) + // Extract image package name and optional version from definition. + image_regex := regexp.MustCompile("^(.[^:@]*)?[:@]?([^ latest$]*)") + match := image_regex.FindStringSubmatch(def.Image) + + if len(match) < 1 { + continue + } + + if !utils.PackageCheckString(c.Allowed, match[1], match[2]) { + c.AddFail(name + " is using invalid base image " + match[1]) c.AddBreach(result.KeyValueBreach{ Key: name, ValueLabel: "invalid base image", Value: def.Image, }) - } else if utils.StringSliceMatch(c.Deprecated, def.Image) { - c.AddWarning(name + " is using deprecated image " + def.Image) + } else if utils.StringSliceMatch(c.Deprecated, match[1]) { + c.AddWarning(name + " is using deprecated image " + match[1]) } else { c.AddPass(name + " is using valid base images") } diff --git a/pkg/checks/docker/baseimagecheck_test.go b/pkg/checks/docker/baseimagecheck_test.go index c6b5572..973167f 100644 --- a/pkg/checks/docker/baseimagecheck_test.go +++ b/pkg/checks/docker/baseimagecheck_test.go @@ -49,7 +49,35 @@ func TestDockerfileCheck(t *testing.T) { func TestInvalidDockerfileCheck(t *testing.T) { assert := assert.New(t) c := docker.BaseImageCheck{ - Allowed: []string{"bitnami/redis"}, + Allowed: []string{"bitnami/redis@latest"}, + Paths: []string{"./fixtures/compose-dockerfile"}, + } + c.RunCheck() + assert.Equal(result.Fail, c.Result.Status) + assert.EqualValues( + []string{"service1 is using invalid base image bitnami/kubectl"}, + c.Result.Failures, + ) +} + +func TestValidDockerfileImageVersion(t *testing.T) { + assert := assert.New(t) + c := docker.BaseImageCheck{ + Allowed: []string{"bitnami/kubectl@1.24"}, + Paths: []string{"./fixtures/compose-dockerfile"}, + } + c.RunCheck() + assert.Equal(result.Pass, c.Result.Status) + assert.EqualValues( + []string{"service1 is using valid base images"}, + c.Result.Passes, + ) +} + +func TestInvalidDockerfileImageVersion(t *testing.T) { + assert := assert.New(t) + c := docker.BaseImageCheck{ + Allowed: []string{"bitnami/kubectl:1.26"}, Paths: []string{"./fixtures/compose-dockerfile"}, } c.RunCheck() @@ -84,6 +112,30 @@ func TestValidImage(t *testing.T) { ) } +func TestValidImageVersions(t *testing.T) { + assert := assert.New(t) + c := docker.BaseImageCheck{ + Allowed: []string{ + "bitnami/kubectl@1.25.12-debian-11-r6", + "bitnami/postgresql:15", + "bitnami/redis", + "bitnami/mongodb@latest", + }, + Paths: []string{"./fixtures/compose-image"}, + } + c.RunCheck() + assert.Equal(result.Pass, c.Result.Status) + assert.ElementsMatch( + []string{ + "service1 is using valid base images", + "service2 is using valid base images", + "service3 is using valid base images", + "service4 is using valid base images", + }, + c.Result.Passes, + ) +} + func TestInvalidImageCheck(t *testing.T) { assert := assert.New(t) c := docker.BaseImageCheck{ @@ -102,6 +154,27 @@ func TestInvalidImageCheck(t *testing.T) { ) } +func TestInvalidImageVersions(t *testing.T) { + assert := assert.New(t) + c := docker.BaseImageCheck{ + Allowed: []string{ + "bitnami/kubectl@latest", + "bitnami/postgresql:17", + "bitnami/redis", + }, + Paths: []string{"./fixtures/compose-image"}, + } + c.RunCheck() + assert.Equal(result.Fail, c.Result.Status) + assert.EqualValues( + []string{ + "service2 is using invalid base image bitnami/postgresql", + "service4 is using invalid base image bitnami/mongodb", + }, + c.Result.Failures, + ) +} + func TestDockerfileWarning(t *testing.T) { assert := assert.New(t) c := docker.BaseImageCheck{ diff --git a/pkg/checks/docker/fixtures/compose-dockerfile/Dockerfile b/pkg/checks/docker/fixtures/compose-dockerfile/Dockerfile index bcd99b1..1045025 100644 --- a/pkg/checks/docker/fixtures/compose-dockerfile/Dockerfile +++ b/pkg/checks/docker/fixtures/compose-dockerfile/Dockerfile @@ -1 +1 @@ -FROM bitnami/kubectl +FROM bitnami/kubectl:1.25.12-debian-11-r6 diff --git a/pkg/checks/docker/fixtures/compose-image/docker-compose.yml b/pkg/checks/docker/fixtures/compose-image/docker-compose.yml index b81af8b..0ae8dfe 100644 --- a/pkg/checks/docker/fixtures/compose-image/docker-compose.yml +++ b/pkg/checks/docker/fixtures/compose-image/docker-compose.yml @@ -3,13 +3,13 @@ version: "3.8" services: service1: - image: bitnami/kubectl + image: bitnami/kubectl:1.25.12-debian-11-r5 service2: - image: bitnami/postgresql + image: bitnami/postgresql@16 service3: - image: bitnami/redis + image: bitnami/redis:{MY_VERSION} service4: - image: bitnami/mongodb + image: bitnami/mongodb:5.0.19-debian-11-r11 diff --git a/pkg/utils/utils.go b/pkg/utils/utils.go index 9d0c209..3520bc2 100644 --- a/pkg/utils/utils.go +++ b/pkg/utils/utils.go @@ -17,6 +17,7 @@ import ( "strings" "github.com/vmware-labs/yaml-jsonpath/pkg/yamlpath" + "github.com/hashicorp/go-version" "gopkg.in/yaml.v3" ) @@ -302,6 +303,38 @@ func StringSliceMatch(slice []string, item string) bool { return false } +// Sift through a slice to determine if it contains eligible package +// with optional version constrains. +func PackageCheckString(slice []string, item string, item_version string) bool { + for _, s := range slice { + // Parse slice with regex to: + // 1 - package name (e.g. "bitnami/kubectl") + // 2 - version (e.g. "8.0") + service_regex := regexp.MustCompile("^(.[^:@]*)?[:@]?([^ latest$]*)") + service_match := service_regex.FindStringSubmatch(s) + // Only proceed if package names were parsed successfully. + if len(service_match[1]) > 0 && len(item) > 0 { + // Check if package name matches. + if service_match[1] == item { + // Package name matched. + // If service does not dictate version than assume any version is allowed. + if len(service_match[2]) < 1 { + return true + } else if len(item_version) > 0 { + // Ensure that item version is not less than slice version. + allowedVersion, err := version.NewVersion(service_match[2]) + imageVersion, err := version.NewVersion(item_version) + // Run version comparison. + if err == nil && allowedVersion.LessThanOrEqual(imageVersion) { + return true + } + } + } + } + } + return false +} + func Glob(dir string, match string) ([]string, error) { files := []string{} err := filepath.Walk(dir, func(path string, f os.FileInfo, err error) error { diff --git a/pkg/utils/utils_test.go b/pkg/utils/utils_test.go index 7619c84..0a32929 100644 --- a/pkg/utils/utils_test.go +++ b/pkg/utils/utils_test.go @@ -477,3 +477,12 @@ func TestHasComposerDependency(t *testing.T) { t.Errorf("expected file not found got %s", err) } } + +func TestPackageCheckString(t *testing.T) { + assert := assert.New(t) + assert.False(PackageCheckString([]string{}, "bitnami/kubectl", "")) + assert.False(PackageCheckString([]string{"bitnami/postgresql@16"}, "bitnami/kubectl", "1.24")) + assert.False(PackageCheckString([]string{"bitnami/postgresql@16", "bitnami/kubectl@1.24-beta"}, "bitnami/kubectl", "1.23-alpha")) + assert.True(PackageCheckString([]string{"bitnami/postgresql@16", "bitnami/kubectl"}, "bitnami/kubectl", "1.24")) + assert.True(PackageCheckString([]string{"bitnami/postgresql@16", "bitnami/kubectl:1.24"}, "bitnami/kubectl", "1.25")) +}