From eafb8c5f6a06175141c27f17bcc17941853d0047 Mon Sep 17 00:00:00 2001 From: Stephen Aghaulor Date: Tue, 15 Jul 2025 11:21:41 -0700 Subject: [PATCH] Add support for docker daemon as a source (#4306) Now you can scan an image directly after building it with docker build by using the docker:// prefix. This is ideal for local development and CI/CD pipelines that want to ensure images do not contain leaked secrets before pushing to an image registry. This resolves "Add support for scanning images from the Docker daemon" #4275. This reverts commit 562dd7242b97645f97cfddf27cb46b8d78bf6473, which reverted the original version of this change that had some issues with its tests that we did not notice until after we merged it. --- README.md | 9 ++- main.go | 2 +- pkg/sources/docker/docker.go | 46 ++++++++++++--- pkg/sources/docker/docker_test.go | 97 +++++++++++++++++++++++++++++++ 4 files changed, 143 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 115e46125..4438adc3b 100644 --- a/README.md +++ b/README.md @@ -280,7 +280,14 @@ trufflehog gcs --project-id= --cloud-environment --results=verified, Use the `--image` flag multiple times to scan multiple images. ```bash +# to scan from a remote registry trufflehog docker --image trufflesecurity/secrets --results=verified,unknown + +# to scan from the local docker daemon +trufflehog docker --image docker://new_image:tag --results=verified,unknown + +# to scan from an image saved as a tarball +trufflehog docker --image file://path_to_image.tar --results=verified,unknown ``` ## 12: Scan in CI @@ -672,7 +679,7 @@ TruffleHog will send a JSON POST request containing the regex matches to a configured webhook endpoint. If the endpoint responds with a `200 OK` response status code, the secret is considered verified. -Custom Detectors support a few different filtering mechanisms: entropy, regex targeting the entire match, regex targeting the captured secret, +Custom Detectors support a few different filtering mechanisms: entropy, regex targeting the entire match, regex targeting the captured secret, and excluded word lists checked against the secret (captured group if present, entire match if capture group is not present). Note that if your custom detector has multiple `regex` set (in this example `hogID`, and `hogToken`), then the filters get applied to each regex. [Here](examples/generic_with_filters.yml) is an example of a custom detector using these filters. diff --git a/main.go b/main.go index 553b4b398..50b49cb79 100644 --- a/main.go +++ b/main.go @@ -184,7 +184,7 @@ var ( circleCiScanToken = circleCiScan.Flag("token", "CircleCI token. Can also be provided with environment variable").Envar("CIRCLECI_TOKEN").Required().String() dockerScan = cli.Command("docker", "Scan Docker Image") - dockerScanImages = dockerScan.Flag("image", "Docker image to scan. Use the file:// prefix to point to a local tarball, otherwise a image registry is assumed.").Required().Strings() + 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() dockerScanToken = dockerScan.Flag("token", "Docker bearer token. Can also be provided with environment variable").Envar("DOCKER_TOKEN").String() dockerExcludePaths = dockerScan.Flag("exclude-paths", "Comma separated list of paths to exclude from scan").String() diff --git a/pkg/sources/docker/docker.go b/pkg/sources/docker/docker.go index fca5b920f..b3c50e2d9 100644 --- a/pkg/sources/docker/docker.go +++ b/pkg/sources/docker/docker.go @@ -13,6 +13,7 @@ import ( "github.com/google/go-containerregistry/pkg/authn" "github.com/google/go-containerregistry/pkg/name" v1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/go-containerregistry/pkg/v1/daemon" "github.com/google/go-containerregistry/pkg/v1/remote" "github.com/google/go-containerregistry/pkg/v1/tarball" gzip "github.com/klauspost/pgzip" @@ -179,8 +180,8 @@ func (s *Source) Chunks(ctx context.Context, chunksChan chan *sources.Chunk, _ . func (s *Source) processImage(ctx context.Context, image string) (imageInfo, error) { var ( imgInfo imageInfo - hasDigest bool imageName name.Reference + err error ) remoteOpts, err := s.remoteOpts() @@ -189,6 +190,7 @@ func (s *Source) processImage(ctx context.Context, image string) (imageInfo, err } const filePrefix = "file://" + const dockerPrefix = "docker://" if strings.HasPrefix(image, filePrefix) { image = strings.TrimPrefix(image, filePrefix) imgInfo.base = image @@ -196,18 +198,21 @@ func (s *Source) processImage(ctx context.Context, image string) (imageInfo, err if err != nil { return imgInfo, err } + } else if strings.HasPrefix(image, dockerPrefix) { + image = strings.TrimPrefix(image, dockerPrefix) + imgInfo, imageName, err = s.extractImageNameTagDigest(image) + if err != nil { + return imgInfo, err + } + imgInfo.image, err = daemon.Image(imageName) + if err != nil { + return imgInfo, err + } } else { - imgInfo.base, imgInfo.tag, hasDigest = baseAndTagFromImage(image) - - if hasDigest { - imageName, err = name.NewDigest(image) - } else { - imageName, err = name.NewTag(image) - } + imgInfo, imageName, err = s.extractImageNameTagDigest(image) if err != nil { return imgInfo, err } - imgInfo.image, err = remote.Image(imageName, remoteOpts...) if err != nil { return imgInfo, err @@ -219,6 +224,29 @@ func (s *Source) processImage(ctx context.Context, image string) (imageInfo, err return imgInfo, nil } +// extractImageNameTagDigest parses the provided Docker image string and returns a name.Reference +// representing either the image's tag or digest, and any error encountered during parsing. +func (*Source) extractImageNameTagDigest(image string) (imageInfo, name.Reference, error) { + var ( + hasDigest bool + imgInfo imageInfo + imgName name.Reference + err error + ) + imgInfo.base, imgInfo.tag, hasDigest = baseAndTagFromImage(image) + + if hasDigest { + imgName, err = name.NewDigest(image) + } else { + imgName, err = name.NewTag(image) + } + if err != nil { + return imgInfo, imgName, err + } + + return imgInfo, imgName, nil +} + // getHistoryEntries collates an image's configuration history together with the // corresponding layer digests for any non-empty layers. func getHistoryEntries(ctx context.Context, imgInfo imageInfo, layers []v1.Layer) ([]historyEntryInfo, error) { diff --git a/pkg/sources/docker/docker_test.go b/pkg/sources/docker/docker_test.go index 2402f935f..0e25cd663 100644 --- a/pkg/sources/docker/docker_test.go +++ b/pkg/sources/docker/docker_test.go @@ -1,10 +1,13 @@ package docker import ( + "io" "strings" "sync" "testing" + image "github.com/docker/docker/api/types/image" + dockerClient "github.com/docker/docker/client" "github.com/stretchr/testify/assert" "google.golang.org/protobuf/types/known/anypb" @@ -134,6 +137,100 @@ func TestDockerImageScanWithDigest(t *testing.T) { assert.Equal(t, 1, historyCounter) } +func TestDockerImageScanFromLocalDaemon(t *testing.T) { + dockerDaemonTestCases := []struct { + name string + image string + }{ + { + name: "TestDockerImageScanFromLocalDaemon", + image: "docker://trufflesecurity/secrets", + }, + { + name: "TestDockerImageScanFromLocalDaemonWithDigest", + image: "docker://trufflesecurity/secrets@sha256:864f6d41209462d8e37fc302ba1532656e265f7c361f11e29fed6ca1f4208e11", + }, + { + name: "TestDockerImageScanFromLocalDaemonWithTag", + image: "docker://trufflesecurity/secrets:latest", + }, + } + + // pull the image here to ensure it exists locally + img := "docker.io/trufflesecurity/secrets:latest" + + client, err := dockerClient.NewClientWithOpts(dockerClient.FromEnv, dockerClient.WithAPIVersionNegotiation()) + if err != nil { + t.Errorf("Failed to create Docker client: %v", err) + return + } + + resp, err := client.ImagePull(context.TODO(), img, image.PullOptions{}) + if err != nil { + t.Errorf("Failed to load image %s: %v", img, err) + return + } + + defer resp.Close() + + // if we don't read the response, the image will not be available in the local Docker daemon + _, err = io.ReadAll(resp) + if err != nil { + t.Errorf("Failed to read response body: %v", err) + } + + for _, tt := range dockerDaemonTestCases { + t.Run(tt.name, func(t *testing.T) { + // This test assumes the local Docker daemon is running + dockerConn := &sourcespb.Docker{ + Credential: &sourcespb.Docker_Unauthenticated{ + Unauthenticated: &credentialspb.Unauthenticated{}, + }, + Images: []string{tt.image}, + } + + conn := &anypb.Any{} + err = conn.MarshalFrom(dockerConn) + assert.NoError(t, err) + + s := &Source{} + err = s.Init(context.TODO(), "test source", 0, 0, false, conn, 1) + assert.NoError(t, err) + + var wg sync.WaitGroup + chunksChan := make(chan *sources.Chunk, 1) + chunkCounter := 0 + layerCounter := 0 + historyCounter := 0 + + wg.Add(1) + go func() { + defer wg.Done() + for chunk := range chunksChan { + assert.NotEmpty(t, chunk) + chunkCounter++ + + if isHistoryChunk(t, chunk) { + historyCounter++ + } else { + layerCounter++ + } + } + }() + + err = s.Chunks(context.TODO(), chunksChan) + assert.NoError(t, err) + + close(chunksChan) + wg.Wait() + + assert.Equal(t, 2, chunkCounter) + assert.Equal(t, 1, layerCounter) + assert.Equal(t, 1, historyCounter) + }) + } +} + func TestBaseAndTagFromImage(t *testing.T) { tests := []struct { image string