From 9f93dd635e9b8cf3ef264942c5422b8573c6a895 Mon Sep 17 00:00:00 2001 From: Alex Gustafsson Date: Tue, 14 Jan 2025 19:54:59 +0100 Subject: [PATCH] Add support for custom registries, registry auth --- cmd/cupdate/main.go | 23 ++- docs/config.md | 1 + internal/oci/auth.go | 141 +++++++++++++++ internal/oci/auth_test.go | 160 ++++++++++++++++++ internal/oci/client.go | 20 +-- internal/worker/worker.go | 13 +- internal/workflow/imageworkflow/data.go | 1 + .../imageworkflow/setupregistryclient.go | 40 +++-- internal/workflow/imageworkflow/workflow.go | 3 +- 9 files changed, 360 insertions(+), 42 deletions(-) create mode 100644 internal/oci/auth.go create mode 100644 internal/oci/auth_test.go diff --git a/cmd/cupdate/main.go b/cmd/cupdate/main.go index ed25b75..0b913cf 100644 --- a/cmd/cupdate/main.go +++ b/cmd/cupdate/main.go @@ -2,6 +2,7 @@ package main import ( "context" + "encoding/json" "fmt" "log/slog" "net/http" @@ -86,6 +87,10 @@ type Config struct { Target string `env:"TARGET"` Insecure bool `env:"INSECURE"` } `envPrefix:"OTEL_"` + + Registry struct { + Secrets string `env:"SECRETS"` + } `envPrefix:"REGISTRY_"` } func main() { @@ -118,6 +123,22 @@ func main() { slog.Debug("Parsed config", slog.Any("config", config)) + var dockerConfig *oci.DockerConfig + if config.Registry.Secrets != "" { + file, err := os.Open(config.Registry.Secrets) + if err != nil { + slog.Error("Failed to read registry secrets", slog.Any("error", err)) + os.Exit(1) + } + + err = json.NewDecoder(file).Decode(&dockerConfig) + file.Close() + if err != nil { + slog.Error("Failed to parse registry secrets", slog.Any("error", err)) + os.Exit(1) + } + } + ctx, cancel := context.WithCancel(context.Background()) if config.OTEL.Target != "" { @@ -249,7 +270,7 @@ func main() { httpClient.UserAgent = config.HTTP.UserAgent prometheus.DefaultRegisterer.MustRegister(httpClient) - worker := worker.New(httpClient, writeStore) + worker := worker.New(httpClient, writeStore, dockerConfig) prometheus.DefaultRegisterer.MustRegister(worker) gauge := prometheus.NewGauge(prometheus.GaugeOpts{ diff --git a/docs/config.md b/docs/config.md index 7dd7b3f..fe13a79 100644 --- a/docs/config.md +++ b/docs/config.md @@ -29,3 +29,4 @@ done using environment variables. | `CUPDATE_DOCKER_INCLUDE_ALL_CONTAINERS` | Whether or not to include containers in any state, not just running containers. | `false` | | `CUPDATE_OTEL_TARGET` | Target URL to an Open Telemetry GRPC ingest endpoint. | Required to use Open Telemetry. | | `CUPDATE_OTEL_INSECURE` | Disable client transport security for the Open Telemetry GRPC connection. | `false` | +| `CUPDATE_REGISTRY_SECRETS` | Path to a JSON file containing registry secrets. See Docker's dockfig.json and Kubernete's `imagePullSecrets`. | None | diff --git a/internal/oci/auth.go b/internal/oci/auth.go new file mode 100644 index 0000000..e08da53 --- /dev/null +++ b/internal/oci/auth.go @@ -0,0 +1,141 @@ +package oci + +import ( + "context" + "encoding/base64" + "net/http" + "net/url" + "strings" +) + +type Authorizer interface { + AuthorizeOCIRequest(context.Context, Reference, *http.Request) error +} + +type AuthorizeFunc func(context.Context, Reference, *http.Request) error + +func (f AuthorizeFunc) AuthorizeOCIRequest(ctx context.Context, image Reference, req *http.Request) error { + return f(ctx, image, req) +} + +type AuthorizerToken string + +func (s AuthorizerToken) AuthorizeOCIRequest(ctx context.Context, image Reference, req *http.Request) error { + req.Header.Set("Authorization", "Bearer "+string(s)) + return nil +} + +// See: https://github.com/docker/docker/pull/12009. +// See: https://kubernetes.io/docs/concepts/containers/images/#config-json. +// See: https://github.com/kubernetes/kubernetes/blob/1a9feed0cd89f3299ddb6f5eaa5663496c59342c/pkg/credentialprovider/config.go. +// See: https://github.com/kubernetes/kubernetes/blob/1a9feed0cd89f3299ddb6f5eaa5663496c59342c/pkg/credentialprovider/keyring_test.go#L26. +type DockerConfig struct { + Auths map[string]DockerConfigEntry `json:"auths"` + HttpHeaders map[string]string `json:"HttpHeaders,omitempty"` +} + +type DockerConfigEntry struct { + Username string `json:"username"` + Password string `json:"password"` + Email string `json:"email"` +} + +type DockerConfigAuthorizer struct { + Config *DockerConfig + Fallback Authorizer +} + +func (a *DockerConfigAuthorizer) AuthorizeOCIRequest(ctx context.Context, image Reference, req *http.Request) error { + if a.Config != nil { + for k, v := range a.Config.HttpHeaders { + req.Header.Set(k, v) + } + + matchedPattern := a.Match(req.URL) + if matchedPattern != "" { + config := a.Config.Auths[matchedPattern] + + credentials := base64.StdEncoding.EncodeToString([]byte(config.Username + ":" + config.Password)) + req.Header.Set("Authorization", "Basic "+credentials) + return nil + } + } + + if a.Fallback != nil { + return a.Fallback.AuthorizeOCIRequest(ctx, image, req) + } + + return nil +} + +func (a *DockerConfigAuthorizer) Match(url *url.URL) string { + // TODO: This code is not especially nice or efficient. For example, it parses + // the URLs every iteration + for pattern := range a.Config.Auths { + // Compare scheme, if set in pattern - otherwise allow any scheme + if !strings.HasPrefix(pattern, "https://") && !strings.HasPrefix(pattern, "http://") { + pattern = url.Scheme + "://" + pattern + } + + u, err := url.Parse(pattern) + if err != nil { + continue + } + + if url.Scheme != u.Scheme { + continue + } + + // The Docker client matches either the image or hostname, where the API can + // have a /v2/ or /v1/ prefix + if strings.HasPrefix(u.Path, "/v2/") || strings.HasPrefix(u.Path, "/v1/") { + u.Path = u.Path[3:] + } + + // If the pattern has a path specified, match it + if u.Path != "" && u.Path != "/" { + p := url.Path + if !strings.HasSuffix(p, "/") { + p += "/" + } + + // Make sure paths in patterns match full segments + if !strings.HasSuffix(u.Path, "/") { + u.Path += "/" + } + + if !strings.HasPrefix(p, u.Path) { + continue + } + } + + urlParts := strings.Split(url.Host, ".") + patternParts := strings.Split(u.Host, ".") + + // The pattern has more parts of its hostname than the URL + if len(urlParts) < len(patternParts) { + continue + } + + matched := true + for i := 0; i < len(patternParts); i++ { + if strings.HasPrefix(patternParts[i], "*") { + if !strings.HasSuffix(urlParts[i], patternParts[i][1:]) { + matched = false + break + } + } else if strings.HasSuffix(patternParts[i], "*") { + // TODO: + } else if urlParts[i] != patternParts[i] { + matched = false + break + } + } + + if matched { + return pattern + } + } + + return "" +} diff --git a/internal/oci/auth_test.go b/internal/oci/auth_test.go new file mode 100644 index 0000000..35c654c --- /dev/null +++ b/internal/oci/auth_test.go @@ -0,0 +1,160 @@ +package oci + +import ( + "fmt" + "net/url" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDockerConfigAuthorizer_MatchPattern(t *testing.T) { + testCases := []struct { + Pattern string + URL string + Expected bool + }{ + // From: https://kubernetes.io/docs/concepts/containers/images/#config-json + { + Pattern: "*.kubernetes.io", + URL: "abc.kubernetes.io", + Expected: true, + }, + { + Pattern: "*.kubernetes.io", + URL: "kubernetes.io", + Expected: false, + }, + { + Pattern: "*.*.kubernetes.io", + URL: "abc.kubernetes.io", + Expected: false, + }, + { + Pattern: "*.*.kubernetes.io", + URL: "abc.def.kubernetes.io", + Expected: true, + }, + { + Pattern: "prefix.*.io", + URL: "prefix.kubernetes.io", + Expected: true, + }, + { + Pattern: "*-good.kubernetes.io", + URL: "prefix-good.kubernetes.io", + Expected: true, + }, + { + Pattern: "my-registry.io/images", + URL: "my-registry.io/images", + Expected: true, + }, + { + Pattern: "my-registry.io/images", + URL: "my-registry.io/images/my-image", + Expected: true, + }, + { + Pattern: "my-registry.io/images", + URL: "my-registry.io/images/another-image", + Expected: true, + }, + { + Pattern: "*.my-registry.io/images", + URL: "sub.my-registry.io/images/my-image", + Expected: true, + }, + { + Pattern: "*.my-registry.io/images", + URL: "a.sub.my-registry.io/images/my-image", + Expected: false, + }, + { + Pattern: "*.my-registry.io/images", + URL: "a.b.sub.my-registry.io/images/my-image", + Expected: false, + }, + { + Pattern: "my-registry.io/images", + URL: "a.sub.my-registry.io/images/my-image", + Expected: false, + }, + { + Pattern: "my-registry.io/images", + URL: "a.b.sub.my-registry.io/images/my-image", + Expected: false, + }, + // HTTP / HTTPS + { + Pattern: "https://example.com", + URL: "https://example.com/images", + Expected: true, + }, + { + Pattern: "https://example.com", + URL: "http://example.com/images", + Expected: false, + }, + { + Pattern: "example.com", + URL: "https://example.com/images", + Expected: true, + }, + { + Pattern: "example.com", + URL: "http://example.com/images", + Expected: true, + }, + // IP / port + { + Pattern: "example.com:8080", + URL: "example.com:8080/alpine", + Expected: true, + }, + { + Pattern: "192.168.1.100:8080", + URL: "192.168.1.100:8080/alpine", + Expected: true, + }, + { + Pattern: "192.168.1.100", + URL: "192.168.1.100/alpine", + Expected: true, + }, + { + Pattern: "example.com", + URL: "example.com:8080/alpine", + Expected: false, + }, + { + Pattern: "192.168.1.100", + URL: "192.168.1.100:8080/alpine", + Expected: false, + }, + } + + for _, testCase := range testCases { + t.Run(fmt.Sprintf("%s->%s", testCase.Pattern, testCase.URL), func(t *testing.T) { + a := &DockerConfigAuthorizer{ + Config: &DockerConfig{ + Auths: map[string]DockerConfigEntry{ + testCase.Pattern: {}, + }, + }, + } + + if !strings.HasPrefix(testCase.URL, "https://") && !strings.HasPrefix(testCase.URL, "http://") { + testCase.URL = "https://" + testCase.URL + } + + u, err := url.Parse(testCase.URL) + require.NoError(t, err) + + actual := a.Match(u) + assert.Equal(t, testCase.Expected, actual != "") + }) + } +} diff --git a/internal/oci/client.go b/internal/oci/client.go index f126ed8..b87ddf9 100644 --- a/internal/oci/client.go +++ b/internal/oci/client.go @@ -14,23 +14,6 @@ import ( "github.com/AlexGustafsson/cupdate/internal/httputil" ) -type Authorizer interface { - AuthorizeOCIRequest(context.Context, Reference, *http.Request) error -} - -type AuthorizeFunc func(context.Context, Reference, *http.Request) error - -func (f AuthorizeFunc) AuthorizeOCIRequest(ctx context.Context, image Reference, req *http.Request) error { - return f(ctx, image, req) -} - -type AuthorizerToken string - -func (s AuthorizerToken) AuthorizeOCIRequest(ctx context.Context, image Reference, req *http.Request) error { - req.Header.Set("Authorization", "Bearer "+string(s)) - return nil -} - type Client struct { Client *httputil.Client Authorizer Authorizer @@ -50,6 +33,9 @@ func (c *Client) GetManifests(ctx context.Context, image Reference) ([]Manifest, return nil, fmt.Errorf("unsupported reference type: must be tagged or digested") } + // TODO: Change the URL AFTER running through authorizers as the docker fix is + // not apparent + // NOTE: It's rather unclear why we need to do this dance manually and why // docker.io simply doesn't just redirect us domain := strings.Replace(image.Domain, "docker.io", "registry-1.docker.io", 1) diff --git a/internal/worker/worker.go b/internal/worker/worker.go index 9539abd..078eed5 100644 --- a/internal/worker/worker.go +++ b/internal/worker/worker.go @@ -19,18 +19,20 @@ import ( var _ prometheus.Collector = (*Worker)(nil) type Worker struct { - httpClient *httputil.Client - store *store.Store + httpClient *httputil.Client + store *store.Store + dockerConfig *oci.DockerConfig processedCounter prometheus.Counter processingDuration prometheus.Counter processingGauge prometheus.Gauge } -func New(httpClient *httputil.Client, store *store.Store) *Worker { +func New(httpClient *httputil.Client, store *store.Store, dockerConfig *oci.DockerConfig) *Worker { return &Worker{ - httpClient: httpClient, - store: store, + httpClient: httpClient, + store: store, + dockerConfig: dockerConfig, processedCounter: prometheus.NewCounter(prometheus.CounterOpts{ Namespace: "cupdate", @@ -89,6 +91,7 @@ func (w *Worker) ProcessRawImage(ctx context.Context, reference oci.Reference) e Links: make([]models.ImageLink, 0), Vulnerabilities: make([]models.ImageVulnerability, 0), Graph: image.Graph, + DockerConfig: w.dockerConfig, } for _, tag := range image.Tags { diff --git a/internal/workflow/imageworkflow/data.go b/internal/workflow/imageworkflow/data.go index 4ab8286..47bfbc9 100644 --- a/internal/workflow/imageworkflow/data.go +++ b/internal/workflow/imageworkflow/data.go @@ -23,6 +23,7 @@ type Data struct { Links []models.ImageLink Vulnerabilities []models.ImageVulnerability Graph models.Graph + DockerConfig *oci.DockerConfig } func (d *Data) InsertTag(tag string) { diff --git a/internal/workflow/imageworkflow/setupregistryclient.go b/internal/workflow/imageworkflow/setupregistryclient.go index 6a94be4..b1c5b81 100644 --- a/internal/workflow/imageworkflow/setupregistryclient.go +++ b/internal/workflow/imageworkflow/setupregistryclient.go @@ -25,36 +25,40 @@ func SetupRegistryClient() workflow.Step { return nil, err } - // TODO: Support other registries (gitlab etc.) - var client *oci.Client + dockerConfig, err := workflow.GetInput[*oci.DockerConfig](ctx, "dockerConfig", true) + if err != nil { + return nil, err + } + + // Identify a fallback used if the Docker config doesn't match the request + var fallback oci.Authorizer switch image.Domain { case "docker.io": - client = &oci.Client{ + fallback = &dockerhub.Client{ Client: httpClient, - Authorizer: &dockerhub.Client{ - Client: httpClient, - }, } case "ghcr.io", "lscr.io": - client = &oci.Client{ + fallback = &ghcr.Client{ Client: httpClient, - Authorizer: &ghcr.Client{ - Client: httpClient, - }, } case "registry.gitlab.com": - client = &oci.Client{ + fallback = &gitlab.Client{ Client: httpClient, - Authorizer: &gitlab.Client{ - Client: httpClient, - }, } case "k8s.gcr.io", "gcr.io", "gke.gcr.io", "quay.io", "registry.k8s.io": - client = &oci.Client{ - Client: httpClient, - } + // No auth required default: - return nil, fmt.Errorf("unsupported registry domain: %s", image.Domain) + if dockerConfig == nil { + return nil, fmt.Errorf("unsupported registry domain: %s", image.Domain) + } + } + + client := &oci.Client{ + Client: httpClient, + Authorizer: &oci.DockerConfigAuthorizer{ + Config: dockerConfig, + Fallback: fallback, + }, } return workflow.Batch( diff --git a/internal/workflow/imageworkflow/workflow.go b/internal/workflow/imageworkflow/workflow.go index 706c1ad..2ad9aa5 100644 --- a/internal/workflow/imageworkflow/workflow.go +++ b/internal/workflow/imageworkflow/workflow.go @@ -25,7 +25,8 @@ func New(httpClient *httputil.Client, data *Data) workflow.Workflow { SetupRegistryClient(). WithID("registry"). With("httpClient", httpClient). - With("reference", data.ImageReference), + With("reference", data.ImageReference). + With("dockerConfig", data.DockerConfig), GetManifests(). WithID("manifests"). With("registryClient", workflow.Ref{Key: "step.registry.client"}).