feat: add support for registry mirrors (#8244)

Signed-off-by: knqyf263 <knqyf263@gmail.com>
Co-authored-by: Teppei Fukuda <knqyf263@gmail.com>
This commit is contained in:
DmitriyLewen
2025-01-22 13:46:02 +06:00
committed by GitHub
parent 2acd8e39c1
commit 4316bcbc5b
8 changed files with 260 additions and 24 deletions

View File

@@ -117,3 +117,46 @@ The following example will fail when a critical vulnerability is found or the OS
```
$ trivy image --exit-code 1 --exit-on-eol 1 --severity CRITICAL alpine:3.16.3
```
## Mirror Registries
!!! warning "EXPERIMENTAL"
This feature might change without preserving backwards compatibility.
Trivy supports mirrors for [remote container images](../target/container_image.md#container-registry) and [databases](./db.md).
To configure them, add a list of mirrors along with the host to the [trivy config file](../references/configuration/config-file.md#registry-options).
!!! note
Use the `index.docker.io` host for images from `Docker Hub`, even if you don't use that prefix.
Example for `index.docker.io`:
```yaml
registry:
mirrors:
index.docker.io:
- mirror.gcr.io
```
### Registry check procedure
Trivy uses the following registry order to get the image:
- mirrors in the same order as they are specified in the configuration file
- source registry
In cases where we can't get the image from the mirror registry (e.g. when authentication fails, image doesn't exist, etc.) - Trivy will check other mirrors (or the source registry if all mirrors have already been checked).
Example:
```yaml
registry:
mirrors:
index.docker.io:
- mirror.with.bad.auth // We don't have credentials for this registry
- mirror.without.image // Registry doesn't have this image
```
When we want to get the image `alpine` with the settings above. The logic will be as follows:
1. Try to get the image from `mirror.with.bad.auth/library/alpine`, but we get an error because there are no credentials for this registry.
2. Try to get the image from `mirror.without.image/library/alpine`, but we get an error because this registry doesn't have this image (but most likely it will be an error about authorization).
3. Get the image from `index.docker.io` (the original registry).

View File

@@ -461,6 +461,8 @@ pkg:
```yaml
registry:
mirrors:
# Same as '--password'
password: []

View File

@@ -147,6 +147,14 @@ func writeFlagValue(val any, ind string, w *os.File) {
} else {
w.WriteString(" []\n")
}
case map[string][]string:
w.WriteString("\n")
for k, vv := range v {
fmt.Fprintf(w, "%s %s:\n", ind, k)
for _, vvv := range vv {
fmt.Fprintf(w, " %s - %s\n", ind, vvv)
}
}
case string:
fmt.Fprintf(w, " %q\n", v)
default:

View File

@@ -81,6 +81,9 @@ type RegistryOptions struct {
// RegistryToken is a bearer token to be sent to a registry
RegistryToken string
// RegistryMirrors is a map of hosts with mirrors for them
RegistryMirrors map[string][]string
// SSL/TLS
Insecure bool

View File

@@ -30,7 +30,7 @@ import (
)
type FlagType interface {
int | string | []string | bool | time.Duration | float64
int | string | []string | bool | time.Duration | float64 | map[string][]string
}
type Flag[T FlagType] struct {
@@ -161,6 +161,8 @@ func (f *Flag[T]) cast(val any) any {
return cast.ToFloat64(val)
case time.Duration:
return cast.ToDuration(val)
case map[string][]string:
return cast.ToStringMapStringSlice(val)
case []string:
if s, ok := val.(string); ok && strings.Contains(s, ",") {
// Split environmental variables by comma as it is not done by viper.
@@ -467,11 +469,12 @@ func (o *Options) ScanOpts() types.ScanOptions {
// RegistryOpts returns options for OCI registries
func (o *Options) RegistryOpts() ftypes.RegistryOptions {
return ftypes.RegistryOptions{
Credentials: o.Credentials,
RegistryToken: o.RegistryToken,
Insecure: o.Insecure,
Platform: o.Platform,
AWSRegion: o.AWSOptions.Region,
Credentials: o.Credentials,
RegistryToken: o.RegistryToken,
Insecure: o.Insecure,
Platform: o.Platform,
AWSRegion: o.AWSOptions.Region,
RegistryMirrors: o.RegistryMirrors,
}
}

View File

@@ -31,26 +31,33 @@ var (
ConfigName: "registry.token",
Usage: "registry token",
}
RegistryMirrorsFlag = Flag[map[string][]string]{
ConfigName: "registry.mirrors",
Usage: "map of hosts and registries for them.",
}
)
type RegistryFlagGroup struct {
Username *Flag[[]string]
Password *Flag[[]string]
PasswordStdin *Flag[bool]
RegistryToken *Flag[string]
Username *Flag[[]string]
Password *Flag[[]string]
PasswordStdin *Flag[bool]
RegistryToken *Flag[string]
RegistryMirrors *Flag[map[string][]string]
}
type RegistryOptions struct {
Credentials []types.Credential
RegistryToken string
Credentials []types.Credential
RegistryToken string
RegistryMirrors map[string][]string
}
func NewRegistryFlagGroup() *RegistryFlagGroup {
return &RegistryFlagGroup{
Username: UsernameFlag.Clone(),
Password: PasswordFlag.Clone(),
PasswordStdin: PasswordStdinFlag.Clone(),
RegistryToken: RegistryTokenFlag.Clone(),
Username: UsernameFlag.Clone(),
Password: PasswordFlag.Clone(),
PasswordStdin: PasswordStdinFlag.Clone(),
RegistryToken: RegistryTokenFlag.Clone(),
RegistryMirrors: RegistryMirrorsFlag.Clone(),
}
}
@@ -64,6 +71,7 @@ func (f *RegistryFlagGroup) Flags() []Flagger {
f.Password,
f.PasswordStdin,
f.RegistryToken,
f.RegistryMirrors,
}
}
@@ -97,7 +105,8 @@ func (f *RegistryFlagGroup) ToOptions() (RegistryOptions, error) {
}
return RegistryOptions{
Credentials: credentials,
RegistryToken: f.RegistryToken.Value(),
Credentials: credentials,
RegistryToken: f.RegistryToken.Value(),
RegistryMirrors: f.RegistryMirrors.Value(),
}, nil
}

View File

@@ -3,9 +3,11 @@ package remote
import (
"context"
"crypto/tls"
"errors"
"fmt"
"net"
"net/http"
"strings"
"time"
"github.com/google/go-containerregistry/pkg/authn"
@@ -35,8 +37,14 @@ func Get(ctx context.Context, ref name.Reference, option types.RegistryOptions)
return nil, xerrors.Errorf("failed to create http transport: %w", err)
}
return tryWithMirrors(ref, option, func(r name.Reference) (*Descriptor, error) {
return tryGet(ctx, tr, r, option)
})
}
// tryGet checks all auth options and tries to get Descriptor.
func tryGet(ctx context.Context, tr http.RoundTripper, ref name.Reference, option types.RegistryOptions) (*Descriptor, error) {
var errs error
// Try each authentication method until it succeeds
for _, authOpt := range authOptions(ctx, ref, option) {
remoteOpts := []remote.Option{
remote.WithTransport(tr),
@@ -67,8 +75,6 @@ func Get(ctx context.Context, ref name.Reference, option types.RegistryOptions)
}
return desc, nil
}
// No authentication succeeded
return nil, errs
}
@@ -80,8 +86,49 @@ func Image(ctx context.Context, ref name.Reference, option types.RegistryOptions
return nil, xerrors.Errorf("failed to create http transport: %w", err)
}
return tryWithMirrors(ref, option, func(r name.Reference) (v1.Image, error) {
return tryImage(ctx, tr, r, option)
})
}
// tryWithMirrors handles common mirror logic for Get and Image functions
func tryWithMirrors[T any](ref name.Reference, option types.RegistryOptions, fn func(name.Reference) (T, error)) (T, error) {
var zero T
mirrors, err := registryMirrors(ref, option)
if err != nil {
return zero, xerrors.Errorf("unable to parse mirrors: %w", err)
}
// Try each mirrors/host until it succeeds
var errs error
for _, r := range append(mirrors, ref) {
result, err := fn(r)
if err != nil {
var multiErr *multierror.Error
// All auth options failed, try the next mirror/host
if errors.As(err, &multiErr) {
errs = multierror.Append(errs, multiErr.Errors...)
continue
}
// Other errors
return zero, err
}
if ref.Context().RegistryStr() != r.Context().RegistryStr() {
log.WithPrefix("remote").Info("Using the mirror registry to get the image",
log.String("image", ref.String()), log.String("mirror", r.Context().RegistryStr()))
}
return result, nil
}
// No authentication for mirrors/host succeeded
return zero, errs
}
// tryImage checks all auth options and tries to get v1.Image.
// If none of the auth options work - function returns multierrors for each auth option.
func tryImage(ctx context.Context, tr http.RoundTripper, ref name.Reference, option types.RegistryOptions) (v1.Image, error) {
var errs error
// Try each authentication method until it succeeds
for _, authOpt := range authOptions(ctx, ref, option) {
remoteOpts := []remote.Option{
remote.WithTransport(tr),
@@ -92,10 +139,9 @@ func Image(ctx context.Context, ref name.Reference, option types.RegistryOptions
errs = multierror.Append(errs, err)
continue
}
return index, nil
}
// No authentication succeeded
return nil, errs
}
@@ -126,6 +172,31 @@ func Referrers(ctx context.Context, d name.Digest, option types.RegistryOptions)
return nil, errs
}
// registryMirrors returns a list of mirrors for ref, obtained from options.RegistryMirrors
// `go-containerregistry` doesn't support mirrors, so we need to handle them ourselves.
// TODO: use `WithMirror` when `go-containerregistry` will support mirrors.
// cf. https://github.com/google/go-containerregistry/pull/2010
func registryMirrors(hostRef name.Reference, option types.RegistryOptions) ([]name.Reference, error) {
var mirrors []name.Reference
reg := hostRef.Context().RegistryStr()
if ms, ok := option.RegistryMirrors[reg]; ok {
for _, m := range ms {
var nameOpts []name.Option
if option.Insecure {
nameOpts = append(nameOpts, name.Insecure)
}
mirrorImageName := strings.Replace(hostRef.Name(), reg, m, 1)
ref, err := name.ParseReference(mirrorImageName, nameOpts...)
if err != nil {
return nil, xerrors.Errorf("unable to parse image from mirror registry: %w", err)
}
mirrors = append(mirrors, ref)
}
}
return mirrors, nil
}
func httpTransport(option types.RegistryOptions) (http.RoundTripper, error) {
d := &net.Dialer{
Timeout: 10 * time.Minute,

View File

@@ -93,6 +93,81 @@ func TestGet(t *testing.T) {
},
},
},
{
name: "mirror",
args: args{
imageName: "foo.bar.io/library/alpine:3.10",
option: types.RegistryOptions{
Credentials: []types.Credential{
{
Username: "test",
Password: "testpass",
},
},
RegistryMirrors: map[string][]string{
"foo.bar.io": {
serverAddr,
},
},
Insecure: true,
},
},
},
{
name: "mirror for dockerhub",
args: args{
imageName: "alpine:3.10",
option: types.RegistryOptions{
Credentials: []types.Credential{
{
Username: "test",
Password: "testpass",
},
},
RegistryMirrors: map[string][]string{
"index.docker.io": {
serverAddr,
},
},
Insecure: true,
},
},
},
{
name: "non-existent mirror image - use image from host",
args: args{
imageName: fmt.Sprintf("%s/library/alpine:3.10", serverAddr),
option: types.RegistryOptions{
Credentials: []types.Credential{
{
Username: "test",
Password: "testpass",
},
},
RegistryMirrors: map[string][]string{
serverAddr: {
"wrong.repository",
},
},
Insecure: true,
},
},
},
{
name: "wrong mirror",
args: args{
imageName: fmt.Sprintf("%s/library/alpine:3.10", serverAddr),
option: types.RegistryOptions{
RegistryMirrors: map[string][]string{
serverAddr: {
"wrong.repository:tag@digest",
},
},
Insecure: true,
},
},
wantErr: "could not parse reference: wrong.repository:tag@digest/library/alpine:3.10",
},
{
name: "multiple credential",
args: args{
@@ -182,6 +257,28 @@ func TestGet(t *testing.T) {
},
wantErr: "invalid username/password",
},
{
name: "bad credential for multiple mirrors",
args: args{
imageName: fmt.Sprintf("%s/library/alpine:3.10", serverAddr),
option: types.RegistryOptions{
Credentials: []types.Credential{
{
Username: "foo",
Password: "bar",
},
},
Insecure: true,
RegistryMirrors: map[string][]string{
serverAddr: {
serverAddr,
serverAddr,
},
},
},
},
wantErr: "6 errors occurred:", // 2 errors for each repository (for 2 mirrors and the original repository)
},
{
name: "bad keychain",
args: args{