From 7a4ef2a4f52d659da869b3b26d20033203161ac3 Mon Sep 17 00:00:00 2001 From: Lakshya Jain Date: Fri, 17 Apr 2026 17:48:39 -0500 Subject: [PATCH 1/9] feat: add --registry flag for scanning all images in a registry Implements GitHub issue #1739 Added --registry flag to scan all images in an OCI Distribution Spec compliant registry (Harbor, Nexus, Artifactory, etc.) using the /v2/_catalog endpoint. Changes: - Added --registry CLI flag with validation - Implemented GenericOCIRegistry for /v2/_catalog enumeration - Added Link header pagination support - Added bearer token authentication via --registry-token - Fixed UseDockerKeychain logic to not activate for registry scans - Added comprehensive test coverage Usage: trufflehog docker --registry registry.example.com trufflehog docker --registry harbor.corp.io --registry-token Resolves: #1739 --- main.go | 16 ++-- pkg/engine/docker.go | 1 + pkg/pb/sourcespb/sources.pb.go | 8 ++ pkg/sources/docker/docker.go | 15 ++++ pkg/sources/docker/registries.go | 103 ++++++++++++++++++++++++++ pkg/sources/docker/registries_test.go | 94 +++++++++++++++++++++++ pkg/sources/sources.go | 2 + proto/sources.proto | 1 + 8 files changed, 235 insertions(+), 5 deletions(-) diff --git a/main.go b/main.go index d01a5fb50db9..16a11c1ef386 100644 --- a/main.go +++ b/main.go @@ -202,6 +202,7 @@ var ( dockerExcludePaths = dockerScan.Flag("exclude-paths", "Comma separated list of paths to exclude from scan").String() dockerScanNamespace = dockerScan.Flag("namespace", "Docker namespace (organization or user). For non-Docker Hub registries, include the registry address as well (e.g., ghcr.io/namespace or quay.io/namespace).").String() dockerScanRegistryToken = dockerScan.Flag("registry-token", "Optional Docker registry access token. Provide this if you want to include private images within the specified namespace.").String() + dockerScanRegistry = dockerScan.Flag("registry", "Scan all images in a registry host. Supports OCI Distribution Spec compliant registries (Harbor, Nexus, Artifactory, etc.). Use --registry-token for authentication.").String() travisCiScan = cli.Command("travisci", "Scan TravisCI") travisCiScanToken = travisCiScan.Flag("token", "TravisCI token. Can also be provided with environment variable").Envar("TRAVISCI_TOKEN").Required().String() @@ -1008,21 +1009,26 @@ func runSingleScan(ctx context.Context, cmd string, cfg engine.Config) (metrics, return scanMetrics, fmt.Errorf("invalid config: you cannot specify both images and namespace at the same time") } - if *dockerScanImages == nil && *dockerScanNamespace == "" { - return scanMetrics, fmt.Errorf("invalid config: both images and namespace cannot be empty; one is required") + if *dockerScanImages == nil && *dockerScanNamespace == "" && *dockerScanRegistry == "" { + return scanMetrics, fmt.Errorf("invalid config: one of --image, --namespace, or --registry is required") } - if *dockerScanRegistryToken != "" && *dockerScanNamespace == "" { - return scanMetrics, fmt.Errorf("invalid config: registry token can only be used with registry namespace") + if *dockerScanRegistry != "" && (*dockerScanImages != nil || *dockerScanNamespace != "") { + return scanMetrics, fmt.Errorf("invalid config: --registry cannot be combined with --image or --namespace") + } + + if *dockerScanRegistryToken != "" && *dockerScanNamespace == "" && *dockerScanRegistry == "" { + return scanMetrics, fmt.Errorf("invalid config: --registry-token requires --namespace or --registry") } cfg := sources.DockerConfig{ BearerToken: *dockerScanToken, Images: *dockerScanImages, - UseDockerKeychain: *dockerScanToken == "", + UseDockerKeychain: *dockerScanToken == "" && *dockerScanRegistry == "" && *dockerScanNamespace == "", ExcludePaths: strings.Split(*dockerExcludePaths, ","), Namespace: *dockerScanNamespace, RegistryToken: *dockerScanRegistryToken, + Registry: *dockerScanRegistry, } if ref, err := eng.ScanDocker(ctx, cfg); err != nil { return scanMetrics, fmt.Errorf("failed to scan Docker: %v", err) diff --git a/pkg/engine/docker.go b/pkg/engine/docker.go index c269d056467b..ea259df88520 100644 --- a/pkg/engine/docker.go +++ b/pkg/engine/docker.go @@ -19,6 +19,7 @@ func (e *Engine) ScanDocker(ctx context.Context, c sources.DockerConfig) (source ExcludePaths: c.ExcludePaths, Namespace: c.Namespace, RegistryToken: c.RegistryToken, + Registry: c.Registry, } switch { diff --git a/pkg/pb/sourcespb/sources.pb.go b/pkg/pb/sourcespb/sources.pb.go index 37424d7eee7c..6296c332712a 100644 --- a/pkg/pb/sourcespb/sources.pb.go +++ b/pkg/pb/sourcespb/sources.pb.go @@ -1182,6 +1182,7 @@ type Docker struct { ExcludePaths []string `protobuf:"bytes,6,rep,name=exclude_paths,json=excludePaths,proto3" json:"exclude_paths,omitempty"` Namespace string `protobuf:"bytes,7,opt,name=namespace,proto3" json:"namespace,omitempty"` RegistryToken string `protobuf:"bytes,8,opt,name=registry_token,json=registryToken,proto3" json:"registry_token,omitempty"` + Registry string `protobuf:"bytes,9,opt,name=registry,proto3" json:"registry,omitempty"` } func (x *Docker) Reset() { @@ -1279,6 +1280,13 @@ func (x *Docker) GetRegistryToken() string { return "" } +func (x *Docker) GetRegistry() string { + if x != nil { + return x.Registry + } + return "" +} + type isDocker_Credential interface { isDocker_Credential() } diff --git a/pkg/sources/docker/docker.go b/pkg/sources/docker/docker.go index 135bb10c40a1..c67e30862330 100644 --- a/pkg/sources/docker/docker.go +++ b/pkg/sources/docker/docker.go @@ -140,6 +140,21 @@ func (s *Source) Chunks(ctx context.Context, chunksChan chan *sources.Chunk, _ . s.conn.Images = append(s.conn.Images, namespaceImages...) } + // if a registry host is set, enumerate all images from that registry via /v2/_catalog. + if registryHost := s.conn.GetRegistry(); registryHost != "" { + start := time.Now() + registry := MakeRegistryFromHost(registryHost) + if token := s.conn.GetRegistryToken(); token != "" { + registry.WithRegistryToken(token) + } + registryImages, err := registry.ListImages(ctx, "") + if err != nil { + return fmt.Errorf("failed to list registry %s images: %w", registryHost, err) + } + dockerListImagesAPIDuration.WithLabelValues(s.name).Observe(time.Since(start).Seconds()) + s.conn.Images = append(s.conn.Images, registryImages...) + } + for _, image := range s.conn.GetImages() { if common.IsDone(ctx) { return nil diff --git a/pkg/sources/docker/registries.go b/pkg/sources/docker/registries.go index b4402c50835b..206221aec29c 100644 --- a/pkg/sources/docker/registries.go +++ b/pkg/sources/docker/registries.go @@ -413,3 +413,106 @@ func discardBody(resp *http.Response) { _ = resp.Body.Close() } } + +// === Generic OCI Registry === + +// GenericOCIRegistry implements the Registry interface for any OCI Distribution Spec +// compliant registry (Harbor, Nexus, Artifactory, etc.) using the /v2/_catalog endpoint. +type GenericOCIRegistry struct { + Host string + Token string + Client *http.Client + scheme string // defaults to "https"; overridable for testing +} + +// catalogResp models the JSON response from the /v2/_catalog endpoint. +type catalogResp struct { + Repositories []string `json:"repositories"` +} + +func (g *GenericOCIRegistry) Name() string { + return g.Host +} + +func (g *GenericOCIRegistry) WithRegistryToken(token string) { + g.Token = token +} + +func (g *GenericOCIRegistry) WithClient(client *http.Client) { + g.Client = client +} + +// ListImages enumerates all repositories from an OCI Distribution Spec compliant registry +// using the /v2/_catalog endpoint. The namespace parameter is unused. +// Pagination is handled via the Link response header. +func (g *GenericOCIRegistry) ListImages(ctx context.Context, _ string) ([]string, error) { + scheme := g.scheme + if scheme == "" { + scheme = "https" + } + + baseURL := &url.URL{ + Scheme: scheme, + Host: g.Host, + Path: "v2/_catalog", + } + + query := baseURL.Query() + query.Set("n", fmt.Sprint(maxRegistryPageSize)) + baseURL.RawQuery = query.Encode() + + allImages := []string{} + nextURL := baseURL.String() + + for nextURL != "" { + if err := registryRateLimiter.Wait(ctx); err != nil { + return nil, err + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, nextURL, http.NoBody) + if err != nil { + return nil, err + } + + if g.Token != "" { + req.Header.Set("Authorization", "Bearer "+g.Token) + } + + client := g.Client + if client == nil { + client = defaultHTTPClient + } + resp, err := client.Do(req) + if err != nil { + return nil, err + } + + body, err := io.ReadAll(resp.Body) + discardBody(resp) + if err != nil { + return nil, err + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("failed to list registry images: unexpected status code: %d", resp.StatusCode) + } + + var page catalogResp + if err := json.Unmarshal(body, &page); err != nil { + return nil, err + } + + for _, repo := range page.Repositories { + allImages = append(allImages, fmt.Sprintf("%s/%s", g.Host, repo)) + } + + nextURL = parseNextLinkURL(resp.Header.Get("Link")) + } + + return allImages, nil +} + +// MakeRegistryFromHost returns a GenericOCIRegistry for the given registry host. +func MakeRegistryFromHost(host string) Registry { + return &GenericOCIRegistry{Host: host} +} diff --git a/pkg/sources/docker/registries_test.go b/pkg/sources/docker/registries_test.go index f6f3c04047dc..d5fe5ae165c4 100644 --- a/pkg/sources/docker/registries_test.go +++ b/pkg/sources/docker/registries_test.go @@ -1,8 +1,10 @@ package docker import ( + "encoding/json" "fmt" "net/http" + "net/http/httptest" "slices" "testing" @@ -100,3 +102,95 @@ func TestGHCRListImages_RateLimitError(t *testing.T) { assert.Error(t, err) assert.Nil(t, ghcrImages) } + +func TestGenericOCIRegistryListImages(t *testing.T) { + t.Parallel() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/v2/_catalog", r.URL.Path) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(catalogResp{Repositories: []string{"myapp", "mydb"}}) + })) + defer srv.Close() + + reg := &GenericOCIRegistry{Host: srv.Listener.Addr().String()} + reg.WithClient(srv.Client()) + + // Override scheme to http for the test server. + reg.scheme = "http" + + images, err := reg.ListImages(context.Background(), "") + assert.NoError(t, err) + + expected := []string{ + srv.Listener.Addr().String() + "/myapp", + srv.Listener.Addr().String() + "/mydb", + } + slices.Sort(images) + slices.Sort(expected) + assert.Equal(t, expected, images) +} + +func TestGenericOCIRegistryListImages_Pagination(t *testing.T) { + t.Parallel() + + page := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if page == 0 { + w.Header().Set("Link", fmt.Sprintf(`<%s/v2/_catalog?n=2&last=repo2>; rel="next"`, "http://"+r.Host)) + _ = json.NewEncoder(w).Encode(catalogResp{Repositories: []string{"repo1", "repo2"}}) + page++ + } else { + _ = json.NewEncoder(w).Encode(catalogResp{Repositories: []string{"repo3"}}) + } + })) + defer srv.Close() + + reg := &GenericOCIRegistry{Host: srv.Listener.Addr().String(), scheme: "http"} + reg.WithClient(srv.Client()) + + images, err := reg.ListImages(context.Background(), "") + assert.NoError(t, err) + assert.Len(t, images, 3) +} + +func TestGenericOCIRegistryListImages_AuthHeader(t *testing.T) { + t.Parallel() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "Bearer mytoken", r.Header.Get("Authorization")) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(catalogResp{Repositories: []string{"secured-app"}}) + })) + defer srv.Close() + + reg := &GenericOCIRegistry{Host: srv.Listener.Addr().String(), scheme: "http"} + reg.WithClient(srv.Client()) + reg.WithRegistryToken("mytoken") + + images, err := reg.ListImages(context.Background(), "") + assert.NoError(t, err) + assert.Equal(t, []string{srv.Listener.Addr().String() + "/secured-app"}, images) +} + +func TestGenericOCIRegistryListImages_ErrorStatus(t *testing.T) { + t.Parallel() + + reg := &GenericOCIRegistry{Host: "127.0.0.1:9"} + reg.WithClient(common.ConstantResponseHttpClient(http.StatusUnauthorized, "{}")) + reg.scheme = "http" + + images, err := reg.ListImages(context.Background(), "") + assert.Error(t, err) + assert.Nil(t, images) +} + +func TestMakeRegistryFromHost(t *testing.T) { + t.Parallel() + + reg := MakeRegistryFromHost("registry.example.com") + assert.Equal(t, "registry.example.com", reg.Name()) + _, ok := reg.(*GenericOCIRegistry) + assert.True(t, ok) +} diff --git a/pkg/sources/sources.go b/pkg/sources/sources.go index 88b11b85a5ea..c44c67b4d114 100644 --- a/pkg/sources/sources.go +++ b/pkg/sources/sources.go @@ -233,6 +233,8 @@ type DockerConfig struct { Namespace string // RegistryToken is an optional authentication token used to access private images within the namespace. RegistryToken string + // Registry is the full registry host to enumerate all images from (e.g., registry.example.com). + Registry string } // GCSConfig defines the optional configuration for a GCS source. diff --git a/proto/sources.proto b/proto/sources.proto index cf64caca8687..40a1b8edf7df 100644 --- a/proto/sources.proto +++ b/proto/sources.proto @@ -168,6 +168,7 @@ message Docker { repeated string exclude_paths = 6; string namespace = 7; string registry_token = 8; + string registry = 9; } message ECR { From 2561f387e2be14caa264e2a59166330851043140 Mon Sep 17 00:00:00 2001 From: Lakshya Jain Date: Fri, 17 Apr 2026 18:17:54 -0500 Subject: [PATCH 2/9] fix: handle relative URLs in OCI registry pagination Fixed pagination bug where relative URLs from OCI Distribution Spec registries (Docker Distribution, Harbor, Nexus) would fail with 'unsupported protocol scheme' error. The OCI spec allows registries to return relative URLs in Link headers like ; rel="next". These need to be resolved against the base URL before making the next request. Changes: - Added resolveNextURL() to resolve relative URLs against base URL - Modified ListImages() to use URL resolution for pagination - Updated test to use relative URL (matching real OCI behavior) - Added test for absolute URL pagination (GHCR-style) Both relative and absolute URLs now work correctly. --- pkg/sources/docker/registries.go | 21 ++++++++++++++++++++- pkg/sources/docker/registries_test.go | 24 ++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/pkg/sources/docker/registries.go b/pkg/sources/docker/registries.go index 206221aec29c..ec73d7179787 100644 --- a/pkg/sources/docker/registries.go +++ b/pkg/sources/docker/registries.go @@ -506,12 +506,31 @@ func (g *GenericOCIRegistry) ListImages(ctx context.Context, _ string) ([]string allImages = append(allImages, fmt.Sprintf("%s/%s", g.Host, repo)) } - nextURL = parseNextLinkURL(resp.Header.Get("Link")) + linkHeader := resp.Header.Get("Link") + if linkHeader != "" { + nextURL = resolveNextURL(baseURL, linkHeader) + } else { + nextURL = "" + } } return allImages, nil } +func resolveNextURL(baseURL *url.URL, linkHeader string) string { + nextLink := parseNextLinkURL(linkHeader) + if nextLink == "" { + return "" + } + + parsedNext, err := url.Parse(nextLink) + if err != nil { + return "" + } + + return baseURL.ResolveReference(parsedNext).String() +} + // MakeRegistryFromHost returns a GenericOCIRegistry for the given registry host. func MakeRegistryFromHost(host string) Registry { return &GenericOCIRegistry{Host: host} diff --git a/pkg/sources/docker/registries_test.go b/pkg/sources/docker/registries_test.go index d5fe5ae165c4..a89b7a02aee5 100644 --- a/pkg/sources/docker/registries_test.go +++ b/pkg/sources/docker/registries_test.go @@ -134,6 +134,30 @@ func TestGenericOCIRegistryListImages(t *testing.T) { func TestGenericOCIRegistryListImages_Pagination(t *testing.T) { t.Parallel() + page := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if page == 0 { + w.Header().Set("Link", `; rel="next"`) + _ = json.NewEncoder(w).Encode(catalogResp{Repositories: []string{"repo1", "repo2"}}) + page++ + } else { + _ = json.NewEncoder(w).Encode(catalogResp{Repositories: []string{"repo3"}}) + } + })) + defer srv.Close() + + reg := &GenericOCIRegistry{Host: srv.Listener.Addr().String(), scheme: "http"} + reg.WithClient(srv.Client()) + + images, err := reg.ListImages(context.Background(), "") + assert.NoError(t, err) + assert.Len(t, images, 3) +} + +func TestGenericOCIRegistryListImages_PaginationAbsoluteURL(t *testing.T) { + t.Parallel() + page := 0 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") From 78d1dfa58a0cb56f5171049a660aa7356cbd62dd Mon Sep 17 00:00:00 2001 From: Lakshya Jain Date: Fri, 17 Apr 2026 18:37:10 -0500 Subject: [PATCH 3/9] fix: preserve Docker keychain auth for namespace scans Only disable Docker keychain for registry scans (which use registry API token). Namespace and image scans should still use Docker keychain when no bearer token is provided. --- main.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/main.go b/main.go index 16a11c1ef386..6af96c4850d9 100644 --- a/main.go +++ b/main.go @@ -1024,7 +1024,7 @@ func runSingleScan(ctx context.Context, cmd string, cfg engine.Config) (metrics, cfg := sources.DockerConfig{ BearerToken: *dockerScanToken, Images: *dockerScanImages, - UseDockerKeychain: *dockerScanToken == "" && *dockerScanRegistry == "" && *dockerScanNamespace == "", + UseDockerKeychain: *dockerScanToken == "" && *dockerScanRegistry == "", ExcludePaths: strings.Split(*dockerExcludePaths, ","), Namespace: *dockerScanNamespace, RegistryToken: *dockerScanRegistryToken, From c46397aa16fb12e2ec3cc4679140be92b65882a8 Mon Sep 17 00:00:00 2001 From: Lakshya Jain Date: Mon, 20 Apr 2026 13:53:10 -0500 Subject: [PATCH 4/9] fix: return error when pagination Link header parsing fails Previously resolveNextURL silently returned empty string on parse errors, causing pagination to stop without warning and potentially skipping repositories. Now returns explicit error so caller knows scanning is incomplete. --- pkg/sources/docker/registries.go | 14 +++++++++----- pkg/sources/docker/registries_test.go | 20 ++++++++++++++++++++ 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/pkg/sources/docker/registries.go b/pkg/sources/docker/registries.go index ec73d7179787..a0ee6463c57b 100644 --- a/pkg/sources/docker/registries.go +++ b/pkg/sources/docker/registries.go @@ -508,7 +508,11 @@ func (g *GenericOCIRegistry) ListImages(ctx context.Context, _ string) ([]string linkHeader := resp.Header.Get("Link") if linkHeader != "" { - nextURL = resolveNextURL(baseURL, linkHeader) + var err error + nextURL, err = resolveNextURL(baseURL, linkHeader) + if err != nil { + return nil, fmt.Errorf("pagination failed: %w", err) + } } else { nextURL = "" } @@ -517,18 +521,18 @@ func (g *GenericOCIRegistry) ListImages(ctx context.Context, _ string) ([]string return allImages, nil } -func resolveNextURL(baseURL *url.URL, linkHeader string) string { +func resolveNextURL(baseURL *url.URL, linkHeader string) (string, error) { nextLink := parseNextLinkURL(linkHeader) if nextLink == "" { - return "" + return "", nil } parsedNext, err := url.Parse(nextLink) if err != nil { - return "" + return "", fmt.Errorf("failed to parse next link URL %q: %w", nextLink, err) } - return baseURL.ResolveReference(parsedNext).String() + return baseURL.ResolveReference(parsedNext).String(), nil } // MakeRegistryFromHost returns a GenericOCIRegistry for the given registry host. diff --git a/pkg/sources/docker/registries_test.go b/pkg/sources/docker/registries_test.go index a89b7a02aee5..ed14c789dff7 100644 --- a/pkg/sources/docker/registries_test.go +++ b/pkg/sources/docker/registries_test.go @@ -210,6 +210,26 @@ func TestGenericOCIRegistryListImages_ErrorStatus(t *testing.T) { assert.Nil(t, images) } +func TestGenericOCIRegistryListImages_MalformedLinkHeader(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Link", `; rel="next"`) + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"repositories":["repo1","repo2"]}`)) + })) + defer server.Close() + + reg := &GenericOCIRegistry{Host: server.URL[7:]} + reg.scheme = "http" + + images, err := reg.ListImages(context.Background(), "") + assert.Error(t, err) + assert.Contains(t, err.Error(), "pagination failed") + assert.Nil(t, images) +} + func TestMakeRegistryFromHost(t *testing.T) { t.Parallel() From 11d4342c4679a3fb7a4dc63eecce64263c4055c6 Mon Sep 17 00:00:00 2001 From: Lakshya Jain Date: Tue, 21 Apr 2026 13:46:21 -0500 Subject: [PATCH 5/9] feat: block public registries from --registry flag Add validation to prevent --registry from accepting public registry hosts (hub.docker.com, quay.io, ghcr.io) since they are already properly handled by --namespace with dedicated implementations using custom APIs. Public registries use different endpoints: - DockerHub: /v2/namespaces//repositories - Quay: /api/v1/repository?namespace= - GHCR: api.github.com/users//packages The --registry flag is designed for private OCI registries (Harbor, Nexus, Artifactory) that implement the standard /v2/_catalog endpoint. --- main.go | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/main.go b/main.go index 7bc41c58c924..fcd47308a33f 100644 --- a/main.go +++ b/main.go @@ -1023,6 +1023,10 @@ func runSingleScan(ctx context.Context, cmd string, cfg engine.Config) (metrics, return scanMetrics, fmt.Errorf("invalid config: --registry cannot be combined with --image or --namespace") } + if *dockerScanRegistry != "" && isPublicRegistry(*dockerScanRegistry) { + return scanMetrics, fmt.Errorf("invalid config: --registry is for private registries only. Use --namespace for public registries (hub.docker.com, quay.io, ghcr.io)") + } + if *dockerScanRegistryToken != "" && *dockerScanNamespace == "" && *dockerScanRegistry == "" { return scanMetrics, fmt.Errorf("invalid config: --registry-token requires --namespace or --registry") } @@ -1293,6 +1297,41 @@ func validateClonePath(clonePath string, noCleanup bool) error { return nil } +// isPublicRegistry checks if the given registry host is a known public registry. +// Public registries (DockerHub, Quay, GHCR) should use --namespace flag instead of --registry +// because they have dedicated implementations with custom APIs. +func isPublicRegistry(host string) bool { + host = strings.ToLower(strings.TrimSpace(host)) + + // Remove common prefixes + host = strings.TrimPrefix(host, "https://") + host = strings.TrimPrefix(host, "http://") + + // Remove trailing slashes and paths + if idx := strings.Index(host, "/"); idx != -1 { + host = host[:idx] + } + + // Check against known public registries + publicRegistries := []string{ + "hub.docker.com", + "docker.io", + "registry-1.docker.io", + "index.docker.io", + "registry.hub.docker.com", + "quay.io", + "ghcr.io", + } + + for _, registry := range publicRegistries { + if host == registry { + return true + } + } + + return false +} + // isPreCommitHook detects if trufflehog is running as a pre-commit hook func isPreCommitHook() bool { // Pre-commit.com framework detection From af0ba1ae6579db2192d3f7ad330805ddfef03008 Mon Sep 17 00:00:00 2001 From: Lakshya Jain Date: Tue, 21 Apr 2026 14:07:18 -0500 Subject: [PATCH 6/9] fix: sanitize registry host to prevent malformed URLs Add sanitizeRegistryHost function to strip protocol prefixes and paths from --registry values before passing to DockerConfig. This prevents malformed URLs like https://https://harbor.corp.io/v2/_catalog. Users can now provide registry hosts in any format: - --registry https://harbor.corp.io - --registry http://localhost:5000 - --registry harbor.corp.io All are sanitized to clean hostnames for proper URL construction. --- main.go | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/main.go b/main.go index fcd47308a33f..de7735fcd7cb 100644 --- a/main.go +++ b/main.go @@ -1031,6 +1031,11 @@ func runSingleScan(ctx context.Context, cmd string, cfg engine.Config) (metrics, return scanMetrics, fmt.Errorf("invalid config: --registry-token requires --namespace or --registry") } + // Sanitize registry host to remove protocol prefixes and paths + if *dockerScanRegistry != "" { + *dockerScanRegistry = sanitizeRegistryHost(*dockerScanRegistry) + } + cfg := sources.DockerConfig{ BearerToken: *dockerScanToken, Images: *dockerScanImages, @@ -1332,6 +1337,27 @@ func isPublicRegistry(host string) bool { return false } +// sanitizeRegistryHost removes protocol prefixes and paths from registry host. +// This ensures clean hostnames are passed to the registry implementation. +// Examples: +// - "https://harbor.corp.io" -> "harbor.corp.io" +// - "http://localhost:5000/path" -> "localhost:5000" +// - "registry.example.com" -> "registry.example.com" +func sanitizeRegistryHost(host string) string { + host = strings.TrimSpace(host) + + // Remove protocol prefixes + host = strings.TrimPrefix(host, "https://") + host = strings.TrimPrefix(host, "http://") + + // Remove trailing slashes and paths + if idx := strings.Index(host, "/"); idx != -1 { + host = host[:idx] + } + + return host +} + // isPreCommitHook detects if trufflehog is running as a pre-commit hook func isPreCommitHook() bool { // Pre-commit.com framework detection From 3f5dc7c7b894c990599a295509075fb64b57da42 Mon Sep 17 00:00:00 2001 From: Lakshya Jain Date: Tue, 21 Apr 2026 14:20:53 -0500 Subject: [PATCH 7/9] fix: handle case-insensitive protocol prefixes in sanitizeRegistryHost Use case-insensitive prefix detection to prevent hostname corruption when users provide mixed-case protocols like HTTPS:// or Http://. Previously, these would fail to match TrimPrefix and the path-stripping logic would truncate at the first / in ://, producing garbage like HTTPS: or Http:. Now correctly handles: - HTTPS://harbor.corp.io -> harbor.corp.io - Http://localhost:5000 -> localhost:5000 - HtTpS://registry.io -> registry.io --- main.go | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/main.go b/main.go index de7735fcd7cb..50714d006044 100644 --- a/main.go +++ b/main.go @@ -1341,14 +1341,19 @@ func isPublicRegistry(host string) bool { // This ensures clean hostnames are passed to the registry implementation. // Examples: // - "https://harbor.corp.io" -> "harbor.corp.io" +// - "HTTPS://harbor.corp.io" -> "harbor.corp.io" // - "http://localhost:5000/path" -> "localhost:5000" // - "registry.example.com" -> "registry.example.com" func sanitizeRegistryHost(host string) string { host = strings.TrimSpace(host) - // Remove protocol prefixes - host = strings.TrimPrefix(host, "https://") - host = strings.TrimPrefix(host, "http://") + // Remove protocol prefixes (case-insensitive) + lowerHost := strings.ToLower(host) + if strings.HasPrefix(lowerHost, "https://") { + host = host[8:] // len("https://") = 8 + } else if strings.HasPrefix(lowerHost, "http://") { + host = host[7:] // len("http://") = 7 + } // Remove trailing slashes and paths if idx := strings.Index(host, "/"); idx != -1 { From 6f4b0e0129b283eaecb466aadc17c3497f8bae05 Mon Sep 17 00:00:00 2001 From: Lakshya Jain Date: Tue, 21 Apr 2026 14:35:22 -0500 Subject: [PATCH 8/9] refactor: extract shared normalization logic and validate after sanitization 1. Extract normalizeRegistryHost helper to eliminate duplicated URL sanitization logic between isPublicRegistry and sanitizeRegistryHost. This reduces maintenance burden and ensures consistent behavior. 2. Add validation after sanitization to catch empty registry values that pass initial validation but become empty after normalization (e.g., 'https://', ' ', 'http://'). This prevents silent no-op scans with confusing behavior. Both functions now use the same normalization logic, and invalid inputs are caught with clear error messages. --- main.go | 56 ++++++++++++++++++++++++++++++++------------------------ 1 file changed, 32 insertions(+), 24 deletions(-) diff --git a/main.go b/main.go index 50714d006044..72116eef894f 100644 --- a/main.go +++ b/main.go @@ -1034,6 +1034,9 @@ func runSingleScan(ctx context.Context, cmd string, cfg engine.Config) (metrics, // Sanitize registry host to remove protocol prefixes and paths if *dockerScanRegistry != "" { *dockerScanRegistry = sanitizeRegistryHost(*dockerScanRegistry) + if *dockerScanRegistry == "" { + return scanMetrics, fmt.Errorf("invalid config: --registry value is empty after removing protocol/path (e.g., 'https://' or ' ')") + } } cfg := sources.DockerConfig{ @@ -1302,21 +1305,40 @@ func validateClonePath(clonePath string, noCleanup bool) error { return nil } -// isPublicRegistry checks if the given registry host is a known public registry. -// Public registries (DockerHub, Quay, GHCR) should use --namespace flag instead of --registry -// because they have dedicated implementations with custom APIs. -func isPublicRegistry(host string) bool { - host = strings.ToLower(strings.TrimSpace(host)) +// normalizeRegistryHost removes protocol prefixes and paths from a registry host string. +// This is a shared helper used by both isPublicRegistry and sanitizeRegistryHost. +// Returns the normalized hostname and a boolean indicating if it's empty after normalization. +func normalizeRegistryHost(host string) (string, bool) { + host = strings.TrimSpace(host) - // Remove common prefixes - host = strings.TrimPrefix(host, "https://") - host = strings.TrimPrefix(host, "http://") + // Remove protocol prefixes (case-insensitive) + lowerHost := strings.ToLower(host) + if strings.HasPrefix(lowerHost, "https://") { + host = host[8:] // len("https://") = 8 + } else if strings.HasPrefix(lowerHost, "http://") { + host = host[7:] // len("http://") = 7 + } // Remove trailing slashes and paths if idx := strings.Index(host, "/"); idx != -1 { host = host[:idx] } + host = strings.TrimSpace(host) + return host, host == "" +} + +// isPublicRegistry checks if the given registry host is a known public registry. +// Public registries (DockerHub, Quay, GHCR) should use --namespace flag instead of --registry +// because they have dedicated implementations with custom APIs. +func isPublicRegistry(host string) bool { + host, empty := normalizeRegistryHost(host) + if empty { + return false + } + + host = strings.ToLower(host) + // Check against known public registries publicRegistries := []string{ "hub.docker.com", @@ -1345,22 +1367,8 @@ func isPublicRegistry(host string) bool { // - "http://localhost:5000/path" -> "localhost:5000" // - "registry.example.com" -> "registry.example.com" func sanitizeRegistryHost(host string) string { - host = strings.TrimSpace(host) - - // Remove protocol prefixes (case-insensitive) - lowerHost := strings.ToLower(host) - if strings.HasPrefix(lowerHost, "https://") { - host = host[8:] // len("https://") = 8 - } else if strings.HasPrefix(lowerHost, "http://") { - host = host[7:] // len("http://") = 7 - } - - // Remove trailing slashes and paths - if idx := strings.Index(host, "/"); idx != -1 { - host = host[:idx] - } - - return host + normalized, _ := normalizeRegistryHost(host) + return normalized } // isPreCommitHook detects if trufflehog is running as a pre-commit hook From 3312d8392c504dd2af6b16722c8cb25efd973e53 Mon Sep 17 00:00:00 2001 From: Lakshya Jain Date: Wed, 22 Apr 2026 20:18:08 -0500 Subject: [PATCH 9/9] docs: update man page for --registry flag Run make man to regenerate documentation with new --registry flag. --- docs/man/trufflehog.1 | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/man/trufflehog.1 b/docs/man/trufflehog.1 index 666b21e51a0d..a85c40d1ecef 100644 --- a/docs/man/trufflehog.1 +++ b/docs/man/trufflehog.1 @@ -395,6 +395,9 @@ Docker namespace (organization or user). For non-Docker Hub registries, include .TP \fB--registry-token=REGISTRY-TOKEN\fR Optional Docker registry access token. Provide this if you want to include private images within the specified namespace. +.TP +\fB--registry=REGISTRY\fR +Scan all images in a registry host. Supports OCI Distribution Spec compliant registries (Harbor, Nexus, Artifactory, etc.). Use --registry-token for authentication. .SS \fBtravisci --token=TOKEN\fR Scan TravisCI