Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for "**" in image glob matching #1914

Merged
merged 2 commits into from May 24, 2022
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
70 changes: 41 additions & 29 deletions pkg/apis/config/glob.go
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
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
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