Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions docs/man/trufflehog.1
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
94 changes: 89 additions & 5 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -1014,21 +1015,38 @@ 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 *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")
}

// Sanitize registry host to remove protocol prefixes and paths
if *dockerScanRegistry != "" {
*dockerScanRegistry = sanitizeRegistryHost(*dockerScanRegistry)
Comment thread
cursor[bot] marked this conversation as resolved.
if *dockerScanRegistry == "" {
return scanMetrics, fmt.Errorf("invalid config: --registry value is empty after removing protocol/path (e.g., 'https://' or ' ')")
}
}

cfg := sources.DockerConfig{
BearerToken: *dockerScanToken,
Images: *dockerScanImages,
UseDockerKeychain: *dockerScanToken == "",
UseDockerKeychain: *dockerScanToken == "" && *dockerScanRegistry == "",
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)
Expand Down Expand Up @@ -1287,6 +1305,72 @@ func validateClonePath(clonePath string, noCleanup bool) error {
return nil
}

// 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 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",
"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
}
Comment thread
cursor[bot] marked this conversation as resolved.
Comment thread
cursor[bot] marked this conversation as resolved.

// 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"
// - "HTTPS://harbor.corp.io" -> "harbor.corp.io"
// - "http://localhost:5000/path" -> "localhost:5000"
// - "registry.example.com" -> "registry.example.com"
func sanitizeRegistryHost(host string) string {
normalized, _ := normalizeRegistryHost(host)
return normalized
}

// isPreCommitHook detects if trufflehog is running as a pre-commit hook
func isPreCommitHook() bool {
// Pre-commit.com framework detection
Expand Down
1 change: 1 addition & 0 deletions pkg/engine/docker.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
8 changes: 8 additions & 0 deletions pkg/pb/sourcespb/sources.pb.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

15 changes: 15 additions & 0 deletions pkg/sources/docker/docker.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
126 changes: 126 additions & 0 deletions pkg/sources/docker/registries.go
Original file line number Diff line number Diff line change
Expand Up @@ -413,3 +413,129 @@ 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))
}

linkHeader := resp.Header.Get("Link")
if linkHeader != "" {
var err error
nextURL, err = resolveNextURL(baseURL, linkHeader)
if err != nil {
return nil, fmt.Errorf("pagination failed: %w", err)
}
} else {
nextURL = ""
}
}

return allImages, nil
}

func resolveNextURL(baseURL *url.URL, linkHeader string) (string, error) {
nextLink := parseNextLinkURL(linkHeader)
if nextLink == "" {
return "", nil
}

parsedNext, err := url.Parse(nextLink)
if err != nil {
return "", fmt.Errorf("failed to parse next link URL %q: %w", nextLink, err)
}

return baseURL.ResolveReference(parsedNext).String(), nil
}
Comment thread
cursor[bot] marked this conversation as resolved.

// MakeRegistryFromHost returns a GenericOCIRegistry for the given registry host.
func MakeRegistryFromHost(host string) Registry {
return &GenericOCIRegistry{Host: host}
}
Loading
Loading