Add support for docker daemon as a source (#4306)
Lint / golangci-lint (push) Waiting to run
Lint / semgrep (push) Waiting to run
Release / Release (push) Waiting to run
Scan for secrets / test (push) Waiting to run
Test / test (push) Waiting to run
Test / test-community (push) Waiting to run

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 562dd7242b, 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.
This commit is contained in:
Stephen Aghaulor
2025-07-15 14:21:41 -04:00
committed by GitHub
parent 4c67ab3bfe
commit eafb8c5f6a
4 changed files with 143 additions and 11 deletions
+8 -1
View File
@@ -280,7 +280,14 @@ trufflehog gcs --project-id=<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.
+1 -1
View File
@@ -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()
+37 -9
View File
@@ -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) {
+97
View File
@@ -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