Skip to content

Commit 20dccb6

Browse files
Scan all images under a namespace for Docker remote registries (#4514)
* Scan all images under a namespace for Docker remote registries * improvements * Resolved charlie's comment * added logs * formatted error messages
1 parent 112f48f commit 20dccb6

File tree

11 files changed

+1180
-702
lines changed

11 files changed

+1180
-702
lines changed

main.go

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -193,10 +193,12 @@ var (
193193
circleCiScan = cli.Command("circleci", "Scan CircleCI")
194194
circleCiScanToken = circleCiScan.Flag("token", "CircleCI token. Can also be provided with environment variable").Envar("CIRCLECI_TOKEN").Required().String()
195195

196-
dockerScan = cli.Command("docker", "Scan Docker Image")
197-
dockerScanImages = dockerScan.Flag("image", "Docker image to scan. Use the file:// prefix to point to a local tarball, the docker:// prefix to point to the docker daemon, otherwise an image registry is assumed.").Required().Strings()
198-
dockerScanToken = dockerScan.Flag("token", "Docker bearer token. Can also be provided with environment variable").Envar("DOCKER_TOKEN").String()
199-
dockerExcludePaths = dockerScan.Flag("exclude-paths", "Comma separated list of paths to exclude from scan").String()
196+
dockerScan = cli.Command("docker", "Scan Docker Image")
197+
dockerScanImages = dockerScan.Flag("image", "Docker image to scan. Use the file:// prefix to point to a local tarball, the docker:// prefix to point to the docker daemon, otherwise an image registry is assumed.").Strings()
198+
dockerScanToken = dockerScan.Flag("token", "Docker bearer token. Can also be provided with environment variable").Envar("DOCKER_TOKEN").String()
199+
dockerExcludePaths = dockerScan.Flag("exclude-paths", "Comma separated list of paths to exclude from scan").String()
200+
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()
201+
dockerScanRegistryToken = dockerScan.Flag("registry-token", "Optional Docker registry access token. Provide this if you want to include private images within the specified namespace.").String()
200202

201203
travisCiScan = cli.Command("travisci", "Scan TravisCI")
202204
travisCiScanToken = travisCiScan.Flag("token", "TravisCI token. Can also be provided with environment variable").Envar("TRAVISCI_TOKEN").Required().String()
@@ -934,11 +936,25 @@ func runSingleScan(ctx context.Context, cmd string, cfg engine.Config) (metrics,
934936
refs = []sources.JobProgressRef{ref}
935937
}
936938
case dockerScan.FullCommand():
939+
if *dockerScanImages != nil && *dockerScanNamespace != "" {
940+
return scanMetrics, fmt.Errorf("invalid config: you cannot specify both images and namespace at the same time")
941+
}
942+
943+
if *dockerScanImages == nil && *dockerScanNamespace == "" {
944+
return scanMetrics, fmt.Errorf("invalid config: both images and namespace cannot be empty; one is required")
945+
}
946+
947+
if *dockerScanRegistryToken != "" && *dockerScanNamespace == "" {
948+
return scanMetrics, fmt.Errorf("invalid config: registry token can only be used with registry namespace")
949+
}
950+
937951
cfg := sources.DockerConfig{
938952
BearerToken: *dockerScanToken,
939953
Images: *dockerScanImages,
940954
UseDockerKeychain: *dockerScanToken == "",
941955
ExcludePaths: strings.Split(*dockerExcludePaths, ","),
956+
Namespace: *dockerScanNamespace,
957+
RegistryToken: *dockerScanRegistryToken,
942958
}
943959
if ref, err := eng.ScanDocker(ctx, cfg); err != nil {
944960
return scanMetrics, fmt.Errorf("failed to scan Docker: %v", err)

pkg/engine/docker.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,10 @@ import (
1515
// ScanDocker scans a given docker connection.
1616
func (e *Engine) ScanDocker(ctx context.Context, c sources.DockerConfig) (sources.JobProgressRef, error) {
1717
connection := &sourcespb.Docker{
18-
Images: c.Images,
19-
ExcludePaths: c.ExcludePaths,
18+
Images: c.Images,
19+
ExcludePaths: c.ExcludePaths,
20+
Namespace: c.Namespace,
21+
RegistryToken: c.RegistryToken,
2022
}
2123

2224
switch {

pkg/pb/sourcespb/sources.pb.go

Lines changed: 688 additions & 667 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pkg/pb/sourcespb/sources.pb.validate.go

Lines changed: 4 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pkg/sources/docker/README.md

Lines changed: 59 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -32,14 +32,15 @@ Docker is a containerization platform that packages applications and their depen
3232
- **Authentication Support**: Multiple authentication methods for private registries
3333
- **File Exclusion**: Configure patterns to skip specific files or directories
3434
- **Size Limits**: Automatically skips files exceeding 50MB to optimize performance
35+
- **Scan All Images Under a Namespace**: Enables automatic discovery and scanning of all container images under a specified namespace (organization or user) in supported registries such as Docker Hub, Quay, and GHCR. Users no longer need to manually list or specify individual image names. The system retrieves all public images within the namespace, and if a valid registry token is provided includes private images as well. This allows for large-scale, automated scanning across all repositories within an organization.
3536

3637
## Configuration
3738

3839
### Connection Types
3940

4041
The Docker source supports several image reference formats:
4142

42-
```go
43+
```text
4344
// Remote registry (default)
4445
"nginx:latest"
4546
"myregistry.com/myapp:v1.0.0"
@@ -51,6 +52,7 @@ The Docker source supports several image reference formats:
5152
// Tarball file
5253
"file:///path/to/image.tar"
5354
```
55+
5456
### Authentication Methods
5557

5658
#### 1. Unauthenticated (Public Images)
@@ -159,6 +161,47 @@ docker login quay.io
159161
cat ~/.docker/config.json
160162
```
161163

164+
---
165+
166+
### Namespace Scanning (This feature is currently in beta version and under testing)
167+
168+
To scan **all images** under a namespace (organization or user):
169+
170+
**CLI Usage:**
171+
```bash
172+
# If no registry prefix is provided, Docker Hub is used by default
173+
trufflehog docker --namespace myorg
174+
175+
# For other registries, include the registry prefix (e.g., quay.io, ghcr.io)
176+
trufflehog docker --namespace quay.io/my_namespace
177+
```
178+
179+
To include private images within that namespace:
180+
```bash
181+
trufflehog docker --namespace myorg --registry-token <access_token>
182+
```
183+
184+
**YAML Configuration:**
185+
```yaml
186+
sources:
187+
- type: docker
188+
name: org-scan
189+
docker:
190+
namespace: myorg
191+
registry_token: "ghp_xxxxxxxxxxxxxxxxxxxx"
192+
```
193+
194+
Supported registries:
195+
- Docker Hub (`docker.io`)
196+
- Quay (`quay.io`)
197+
- GitHub Container Registry (`ghcr.io`)
198+
199+
This mode automatically enumerates all repositories within the specified namespace before scanning.
200+
201+
Note: According to the GHCR documentation, only GitHub Classic Personal Access Tokens (PATs) are currently supported for accessing container packages - including public ones.
202+
Source: [GitHub Roadmap Issue #558](https://github.com/github/roadmap/issues/558)
203+
204+
---
162205

163206
### File Exclusion
164207

@@ -200,6 +243,18 @@ trufflehog docker --image myregistry.com/private-image:latest --exclude-paths **
200243
trufflehog docker --image nginx:latest
201244
```
202245

246+
### Scanning All Images Under a Namespace (Beta Version)
247+
248+
```bash
249+
trufflehog docker --namespace trufflesecurity
250+
```
251+
252+
Including private images:
253+
254+
```bash
255+
trufflehog docker --namespace trufflesecurity --registry-token ghp_xxxxxxxxxxxxxxxxxxxx
256+
```
257+
203258
### Scanning Multiple Images
204259

205260
```bash
@@ -215,10 +270,7 @@ trufflehog docker --image docker://myapp:local
215270
### Scanning a Tarball
216271

217272
```bash
218-
# First, save an image to a tarball
219273
docker save myapp:latest -o myapp.tar
220-
221-
# Then scan it
222274
trufflehog docker --image file:///path/to/myapp.tar
223275
```
224276

@@ -231,15 +283,14 @@ trufflehog docker --image my-registry.io/private-app:v1.0.0
231283

232284
## Testing Results
233285

234-
### Integration Test Results
235-
236286
| Test Case | Status | Command/Configuration | Registry URL | Notes |
237287
|-----------|--------|----------------------|--------------|-------|
238288
| Scan remote image on DockerHub | ✅ Success | `--image <image_name>` | https://hub.docker.com/ | Public images work without authentication |
239289
| Scan specific tag of image on DockerHub | ✅ Success | `--image <image_name>:<tag_name>` | https://hub.docker.com/ | Tag specification working correctly |
290+
| Scan all images under namespace | In Progress | `--namespace <namespace>` | DockerHub, Quay, GHCR | Automatically discovers all public images |
240291
| Scan remote image on Quay.io | ✅ Success | `--image quay.io/prometheus/prometheus` | https://quay.io/search | Public Quay.io registry supported |
241292
| Scan multiple images | ✅ Success | `--image <image_name> --image <image_name>` | Multiple registries | Sequential scanning of multiple images |
242-
| Scan remote image on DockerHub with token | ✅ Success | Generate token using username and password | https://hub.docker.com/ | Basic auth with PAT working |
293+
| Scan remote image on DockerHub with token | ✅ Success | `--token <token>`(Generate token using username and password) | https://hub.docker.com/ | Authenticated scanning for private repos |
243294
| Scan private image on Quay | ⏸️ Halted | N/A | https://quay.io/ | RedHat requires paid account for private repos |
244295
| Scan private image on GHCR | ✅ Success | `--image ghcr.io/<image_name>` | https://github.com/packages | GitHub Container Registry |
245296

@@ -248,23 +299,19 @@ trufflehog docker --image my-registry.io/private-app:v1.0.0
248299
### Common Issues
249300

250301
**Issue**: Authentication failures with private registries
251-
252-
**Solution**: Ensure credentials are correct and have pull permissions. Use `docker login` first when using Docker Keychain method.
302+
**Solution**: Ensure credentials are correct and have pull permissions. Use `docker login` first when using Docker Keychain.
253303

254304
---
255305

256306
**Issue**: Out of memory errors with large images
257-
258307
**Solution**: Reduce concurrency or scan smaller images. Consider increasing available memory.
259308

260309
---
261310

262311
**Issue**: Slow scanning performance
263-
264312
**Solution**: Enable concurrent processing, use local daemon instead of remote registry, or exclude unnecessary directories.
265313

266314
---
267315

268316
**Issue**: Files not being scanned
269-
270317
**Solution**: Check exclude patterns and file size limits. Verify files are under 50MB.

pkg/sources/docker/docker.go

Lines changed: 58 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ func (s *Source) JobID() sources.JobID {
6262
}
6363

6464
// Init initializes the source.
65-
func (s *Source) Init(_ context.Context, name string, jobId sources.JobID, sourceId sources.SourceID, verify bool, connection *anypb.Any, concurrency int) error {
65+
func (s *Source) Init(ctx context.Context, name string, jobId sources.JobID, sourceId sources.SourceID, verify bool, connection *anypb.Any, concurrency int) error {
6666
s.name = name
6767
s.sourceId = sourceId
6868
s.jobId = jobId
@@ -119,6 +119,20 @@ func (s *Source) Chunks(ctx context.Context, chunksChan chan *sources.Chunk, _ .
119119
workers := new(errgroup.Group)
120120
workers.SetLimit(s.concurrency)
121121

122+
// if namespace is set and no images are specified, fetch all images in that namespace.
123+
registryNamespace := s.conn.GetNamespace()
124+
if registryNamespace != "" && len(s.conn.Images) == 0 {
125+
start := time.Now()
126+
namespaceImages, err := GetNamespaceImages(ctx, registryNamespace, s.conn.GetRegistryToken())
127+
if err != nil {
128+
return fmt.Errorf("failed to list namespace: %s images: %w", registryNamespace, err)
129+
}
130+
131+
dockerListImagesAPIDuration.WithLabelValues(s.name).Observe(time.Since(start).Seconds())
132+
133+
s.conn.Images = append(s.conn.Images, namespaceImages...)
134+
}
135+
122136
for _, image := range s.conn.GetImages() {
123137
if common.IsDone(ctx) {
124138
return nil
@@ -127,42 +141,42 @@ func (s *Source) Chunks(ctx context.Context, chunksChan chan *sources.Chunk, _ .
127141
imgInfo, err := s.processImage(ctx, image)
128142
if err != nil {
129143
ctx.Logger().Error(err, "error processing image", "image", image)
130-
return nil
144+
continue
131145
}
132146

133-
ctx = context.WithValues(ctx, "image", imgInfo.base, "tag", imgInfo.tag)
147+
imageCtx := context.WithValues(ctx, "image", imgInfo.base, "tag", imgInfo.tag)
134148

135-
ctx.Logger().V(2).Info("scanning image history")
149+
imageCtx.Logger().V(2).Info("scanning image history")
136150

137151
layers, err := imgInfo.image.Layers()
138152
if err != nil {
139-
ctx.Logger().Error(err, "error getting image layers")
140-
return nil
153+
imageCtx.Logger().Error(err, "error getting image layers")
154+
continue
141155
}
142156

143157
// Get history entries and associate them with layers
144-
historyEntries, err := getHistoryEntries(ctx, imgInfo, layers)
158+
historyEntries, err := getHistoryEntries(imageCtx, imgInfo, layers)
145159
if err != nil {
146-
ctx.Logger().Error(err, "error getting image history entries")
147-
return nil
160+
imageCtx.Logger().Error(err, "error getting image history entries")
161+
continue
148162
}
149163

150164
// Scan each history entry for secrets in build commands
151165
for _, historyEntry := range historyEntries {
152-
if err := s.processHistoryEntry(ctx, historyEntry, chunksChan); err != nil {
153-
ctx.Logger().Error(err, "error processing history entry")
154-
return nil
166+
if err := s.processHistoryEntry(imageCtx, historyEntry, chunksChan); err != nil {
167+
imageCtx.Logger().Error(err, "error processing history entry")
168+
continue
155169
}
156170
dockerHistoryEntriesScanned.WithLabelValues(s.name).Inc()
157171
}
158172

159-
ctx.Logger().V(2).Info("scanning image layers")
173+
imageCtx.Logger().V(2).Info("scanning image layers")
160174

161175
// Process each layer concurrently
162176
for _, layer := range layers {
163177
workers.Go(func() error {
164-
if err := s.processLayer(ctx, layer, imgInfo, chunksChan); err != nil {
165-
ctx.Logger().Error(err, "error processing layer")
178+
if err := s.processLayer(imageCtx, layer, imgInfo, chunksChan); err != nil {
179+
imageCtx.Logger().Error(err, "error processing layer")
166180
return nil
167181
}
168182
dockerLayersScanned.WithLabelValues(s.name).Inc()
@@ -172,8 +186,8 @@ func (s *Source) Chunks(ctx context.Context, chunksChan chan *sources.Chunk, _ .
172186
}
173187

174188
if err := workers.Wait(); err != nil {
175-
ctx.Logger().Error(err, "error processing layers")
176-
return nil
189+
imageCtx.Logger().Error(err, "error processing layers")
190+
continue
177191
}
178192

179193
dockerImagesScanned.WithLabelValues(s.name).Inc()
@@ -185,6 +199,7 @@ func (s *Source) Chunks(ctx context.Context, chunksChan chan *sources.Chunk, _ .
185199
// processImage processes an individual image and prepares it for further processing.
186200
// It handles three image source types: remote registry, local daemon, and tarball file.
187201
func (s *Source) processImage(ctx context.Context, image string) (imageInfo, error) {
202+
ctx.Logger().V(5).Info("Processing individual Image")
188203
var (
189204
imgInfo imageInfo
190205
imageName name.Reference
@@ -261,6 +276,7 @@ func (*Source) extractImageNameTagDigest(image string) (imageInfo, name.Referenc
261276
// getHistoryEntries collates an image's configuration history together with the
262277
// corresponding layer digests for any non-empty layers.
263278
func getHistoryEntries(ctx context.Context, imgInfo imageInfo, layers []v1.Layer) ([]historyEntryInfo, error) {
279+
ctx.Logger().V(5).Info("Getting history entries")
264280
config, err := imgInfo.image.ConfigFile()
265281
if err != nil {
266282
return nil, err
@@ -307,6 +323,7 @@ func getHistoryEntries(ctx context.Context, imgInfo imageInfo, layers []v1.Layer
307323
// processHistoryEntry processes a history entry from the image configuration metadata.
308324
// It scans the CreatedBy field which contains the command used to create that layer.
309325
func (s *Source) processHistoryEntry(ctx context.Context, historyInfo historyEntryInfo, chunksChan chan *sources.Chunk) error {
326+
ctx.Logger().V(5).Info("Processing history entries")
310327
// Create a descriptive identifier for this history entry
311328
// There's no file name here, so we use a synthetic path
312329
entryPath := fmt.Sprintf("image-metadata:history:%d:created-by", historyInfo.index)
@@ -337,6 +354,8 @@ func (s *Source) processHistoryEntry(ctx context.Context, historyInfo historyEnt
337354
// processLayer processes an individual layer of an image.
338355
// It decompresses the layer and extracts all files for scanning.
339356
func (s *Source) processLayer(ctx context.Context, layer v1.Layer, imgInfo imageInfo, chunksChan chan *sources.Chunk) error {
357+
ctx.Logger().V(5).Info("Processing layer")
358+
340359
layerInfo := layerInfo{
341360
base: imgInfo.base,
342361
tag: imgInfo.tag,
@@ -508,6 +527,28 @@ func (s *Source) remoteOpts() ([]remote.Option, error) {
508527
return opts, nil
509528
}
510529

530+
func GetNamespaceImages(ctx context.Context, namespace, registryToken string) ([]string, error) {
531+
ctx.Logger().V(5).Info("Getting namespace images")
532+
533+
registry := MakeRegistryFromNamespace(namespace)
534+
535+
// attach the registry authentication token, if one is available.
536+
if registryToken != "" {
537+
registry.WithRegistryToken(registryToken)
538+
}
539+
540+
ctx.Logger().Info(fmt.Sprintf("using registry: %s", registry.Name()))
541+
542+
namespaceImages, err := registry.ListImages(ctx, namespace)
543+
if err != nil {
544+
return nil, fmt.Errorf("failed to list namespace images: %w", err)
545+
}
546+
547+
ctx.Logger().Info(fmt.Sprintf("namespace: %s has %d images", namespace, len(namespaceImages)))
548+
549+
return namespaceImages, nil
550+
}
551+
511552
// baseAndTagFromImage extracts the base name and tag/digest from an image reference string.
512553
// It handles both digest-based references (image@sha256:...) and tag-based references (image:tag).
513554
func baseAndTagFromImage(image string) (base, tag string, hasDigest bool) {

pkg/sources/docker/metrics.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,4 +31,14 @@ var (
3131
Help: "Total number of Docker images scanned.",
3232
},
3333
[]string{"source_name"})
34+
35+
dockerListImagesAPIDuration = promauto.NewHistogramVec(
36+
prometheus.HistogramOpts{
37+
Namespace: common.MetricsNamespace,
38+
Subsystem: common.MetricsSubsystem,
39+
Name: "docker_list_images_api_duration_seconds",
40+
Help: "Duration of Docker list images API calls.",
41+
Buckets: prometheus.DefBuckets,
42+
},
43+
[]string{"source_name"})
3444
)

0 commit comments

Comments
 (0)