Skip to content

Commit

Permalink
Add support for "**" in image glob matching
Browse files Browse the repository at this point in the history
Signed-off-by: Jason Hall <jason@chainguard.dev>
  • Loading branch information
imjasonh committed May 23, 2022
1 parent d5f2c32 commit 07b0d1f
Show file tree
Hide file tree
Showing 3 changed files with 97 additions and 86 deletions.
70 changes: 41 additions & 29 deletions pkg/apis/config/glob.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,11 @@
package config

import (
"net/url"
"path/filepath"
"fmt"
"regexp"
"strings"

"github.com/google/go-containerregistry/pkg/name"
)

const (
Expand All @@ -28,40 +30,50 @@ const (
DockerhubPublicRepository = "library/"
)

// GlobMatch will attempt to:
// 1. match the glob first
// 2. When the pattern is <repository>/*, therefore missing a host,
// it should match for the resolved image digest in the form of index.docker.io/<repository>/*
// 3. When the pattern is <image>, it should match for the resolved image digest
// against the official Dockerhub repository in the form of index.docker.io/library/*
func GlobMatch(glob, image string) (bool, error) {
matched, err := filepath.Match(glob, image)
if err != nil {
return false, err
}
var validGlob = regexp.MustCompile(`^[a-zA-Z0-9-_:\/\*\.]+$`)

// If matched, return early
if matched {
return matched, nil
// GlobMatch will return true if the image reference matches the requested glob pattern.
//
// If the image reference is invalid, an error will be returned.
//
// In the glob pattern, the "*" character matches any non-"/" character, and "**" matches any character, including "/".
//
// If the image is a DockerHub official image like "ubuntu" or "debian", the glob that matches it must be something like index.docker.io/library/ubuntu.
// If the image is a DockerHub used-owned image like "myuser/myapp", then the glob that matches it must be something like index.docker.io/myuser/myapp.
// This means that the glob patterns "*" will not match the image name "ubuntu", and "*/*" will not match "myuser/myapp"; the "index.docker.io" prefix is required.
//
// If the image does not specify a tag (e.g., :latest or :v1.2.3), the tag ":latest" will be assumed.
//
// Note that the tag delimiter (":") does not act as a breaking separator for the purposes of a "*" glob.
// To match any tag, the glob should end with ":**".
func GlobMatch(glob, image string) (match bool, warnings []string, err error) {
if glob == "*/*" {
warnings = []string{`The glob match "*/*" should be "index.docker.io/*/*"`}
glob = "index.docker.io/*/*"
}
if glob == "*" {
warnings = []string{`The glob match "*" should be "index.docker.io/library/*"`}
glob = "index.docker.io/library/*"
}

// If not matched, check if missing host and default to index.docker.io
u, err := url.Parse(glob)
ref, err := name.ParseReference(image, name.WeakValidation)
if err != nil {
return false, err
return false, warnings, err
}

if u.Host == "" {
dockerhubGlobPattern := ResolvedDockerhubHost
// Reject that glob doesn't look like a regexp
if !validGlob.MatchString(glob) {
return false, warnings, fmt.Errorf("invalid glob %q", glob)
}

// If the image is expected to be part of the Dockerhub official "library" repository
if len(strings.Split(u.Path, "/")) < 2 {
dockerhubGlobPattern += DockerhubPublicRepository
}
// Translate glob to regexp.
glob = strings.ReplaceAll(glob, ".", `\.`) // . in glob means \. in regexp
glob = strings.ReplaceAll(glob, "**", ".+") // ** in glob means .* in regexp
glob = strings.ReplaceAll(glob, "*", "[^/]+") // * in glob means any non-/ in regexp
glob = fmt.Sprintf("^%s$", glob) // glob must match the whole string

dockerhubGlobPattern += glob
return filepath.Match(dockerhubGlobPattern, image)
}
// TODO: do we want ":" to count as a separator like "/" is?

return matched, nil
match, err = regexp.MatchString(glob, ref.Name())
return match, warnings, err
}
111 changes: 55 additions & 56 deletions pkg/apis/config/glob_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,67 +15,66 @@
package config

import (
"runtime"
"reflect"
"testing"
)

func TestGlobMatch(t *testing.T) {
tests := []struct {
glob string
input string
match bool
errString string
windowsMatch *struct {
match bool
}
for _, c := range []struct {
image, glob string
wantMatch bool
wantWarnings []string
wantErr bool
}{
{glob: "foo", input: "foo", match: true}, // exact match
{glob: "fooo*", input: "foo", match: false}, // prefix too long
{glob: "foo*", input: "foobar", match: true}, // works
{glob: "foo*", input: "foo", match: true}, // works
{glob: "*", input: "foo", match: true}, // matches anything
{glob: "*", input: "bar", match: true}, // matches anything
{glob: "*foo*", input: "1foo2", match: true}, // matches wildcard around
{glob: "*repository/*", input: "repository/image", match: true},
{glob: "*.repository/*", input: "other.repository/image", match: true},
{glob: "repository/*", input: "repository/image", match: true},
{glob: "repository/*", input: "other.repository/image", match: false},
{glob: "repository/*", input: "index.docker.io/repository/image", match: true}, // Testing resolved digest
{glob: "image", input: "index.docker.io/library/image", match: true}, // Testing resolved digest and official dockerhub public repository
{glob: "[", input: "[", match: false, errString: "syntax error in pattern"}, // Invalid glob pattern
{glob: "gcr.io/projectsigstore/*", input: "gcr.io/projectsigstore/cosign", match: true},
{glob: "gcr.io/projectsigstore/*", input: "us.gcr.io/projectsigstore/cosign", match: false},
{glob: "*gcr.io/projectsigstore/*", input: "gcr.io/projectsigstore/cosign", match: true},
{glob: "*gcr.io/projectsigstore/*", input: "gcr.io/projectsigstore2/cosign", match: false},
{glob: "*gcr.io/*/*", input: "us.gcr.io/projectsigstore/cosign", match: true}, // Does match with multiple '*'
{glob: "us.gcr.io/*/*", input: "us.gcr.io/projectsigstore/cosign", match: true},
{glob: "us.gcr.io/*/*", input: "gcr.io/projectsigstore/cosign", match: false},
{glob: "*.gcr.io/*/*", input: "asia.gcr.io/projectsigstore/cosign", match: true},
{glob: "*.gcr.io/*/*", input: "gcr.io/projectsigstore/cosign", match: false},
// Does not match since '*' only handles until next non-separator character '/'
// On Windows, '/' is not the separator and therefore it passes
{glob: "*gcr.io/*", input: "us.gcr.io/projectsigstore/cosign", match: false, windowsMatch: &struct{ match bool }{match: true}},
}
for _, tc := range tests {
got, err := GlobMatch(tc.glob, tc.input)

if tc.errString != "" {
if tc.errString != err.Error() {
t.Errorf("expected %s for error: %s", tc.errString, err.Error())
{image: "foo", glob: "index.docker.io/library/foo:latest", wantMatch: true},
{image: "foo", glob: "index.docker.io/library/foo:*", wantMatch: true},
{image: "foo", glob: "index.docker.io/library/*", wantMatch: true},
{image: "foo", glob: "index.docker.io/library/*:latest", wantMatch: true},
{image: "foo", glob: "index.docker.io/*/*", wantMatch: true},
{image: "foo", glob: "index.docker.io/**", wantMatch: true},
{image: "foo", glob: "index.docker.**", wantMatch: true},
{image: "foo", glob: "inde**", wantMatch: true},
{image: "foo", glob: "**", wantMatch: true},
{image: "foo", glob: "foo", wantMatch: false}, // must have index.docker.io/library prefix.
{image: "myuser/myapp", glob: "index.docker.io/myuser/myapp:latest", wantMatch: true},
{image: "myuser/myapp", glob: "index.docker.io/myuser/myapp:*", wantMatch: true},
{image: "myuser/myapp", glob: "index.docker.io/myuser/*", wantMatch: true},
{image: "myuser/myapp", glob: "index.docker.io/myuser/*:latest", wantMatch: true},
{image: "myuser/myapp", glob: "index.docker.io/*/*", wantMatch: true},
{image: "myuser/myapp", glob: "index.docker.io/**", wantMatch: true},
{image: "myuser/myapp", glob: "index.docker.**", wantMatch: true},
{image: "myuser/myapp", glob: "inde**", wantMatch: true},
{image: "myuser/myapp", glob: "**", wantMatch: true},
{image: "myuser/myapp", glob: "myuser/myapp", wantMatch: false}, // must have index.docker.io prefix.
{image: "ghcr.io/foo/bar", glob: "ghcr.io/*/*", wantMatch: true},
{image: "ghcr.io/foo/bar", glob: "ghcr.io/**", wantMatch: true},
{image: "ghcr.io/foo", glob: "ghcr.io/*/*", wantMatch: false}, // doesn't match second *
{image: "ghcr.io/foo", glob: "ghcr.io/**", wantMatch: true},
{image: "ghcr.io/foo", glob: "ghc**", wantMatch: true},
{image: "ghcr.io/foo", glob: "**", wantMatch: true},
{image: "ghcr.io/foo", glob: "*/**", wantMatch: true},
{image: "prefix-ghcr.io/foo", glob: "ghcr.io/foo", wantMatch: false}, // glob starts at beginning.
{image: "ghcr.io/foo-suffix", glob: "ghcr.io/foo", wantMatch: false}, // glob ends at the end.
{image: "ghcrxio/foo", glob: "ghcr.io/**", wantMatch: false}, // dots in glob are replaced with \., not treated as regexp .
{image: "invalid&name", glob: "**", wantMatch: false, wantErr: true}, // invalid refs are not matched.
{image: "invalid-glob", glob: ".+", wantMatch: false, wantErr: true}, // invalid globs are rejected.
{image: "invalid-glob", glob: "[a-z]*", wantMatch: false, wantErr: true}, // invalid globs are rejected.
{image: "foo", glob: "*", wantMatch: true,
wantWarnings: []string{`The glob match "*" should be "index.docker.io/library/*"`}},
{image: "myuser/myapp", glob: "*/*", wantMatch: true,
wantWarnings: []string{`The glob match "*/*" should be "index.docker.io/*/*"`}},
} {
t.Run(c.image+"|"+c.glob, func(t *testing.T) {
match, warnings, err := GlobMatch(c.glob, c.image)
if match != c.wantMatch {
t.Errorf("match: got %t, want %t", match, c.wantMatch)
}
} else if err != nil {
t.Errorf("unexpected error: %v for glob: %q input: %q", err, tc.glob, tc.input)
}

want := tc.match

// If OS is Windows, check if there is a different expected match value
if runtime.GOOS == "windows" && tc.windowsMatch != nil {
want = tc.windowsMatch.match
}

if got != want {
t.Errorf("expected %v for glob: %q input: %q", want, tc.glob, tc.input)
}
if !reflect.DeepEqual(warnings, c.wantWarnings) {
t.Errorf("warnings: got %v, want %v", warnings, c.wantWarnings)
}
if gotErr := err != nil; gotErr != c.wantErr {
t.Errorf("err: got %v, want %t", err, c.wantErr)
}
})
}
}
2 changes: 1 addition & 1 deletion pkg/apis/config/image_policies.go
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ func (p *ImagePolicyConfig) GetMatchingPolicies(image string) (map[string]webhoo
for k, v := range p.Policies {
for _, pattern := range v.Images {
if pattern.Glob != "" {
if matched, err := GlobMatch(pattern.Glob, image); err != nil {
if matched, _, err := GlobMatch(pattern.Glob, image); err != nil {
lastError = err
} else if matched {
ret[k] = v
Expand Down

0 comments on commit 07b0d1f

Please sign in to comment.