Skip to content

Commit eafb8c5

Browse files
authored
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 562dd72, 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.
1 parent 4c67ab3 commit eafb8c5

File tree

4 files changed

+143
-11
lines changed

4 files changed

+143
-11
lines changed

README.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -280,7 +280,14 @@ trufflehog gcs --project-id=<project-ID> --cloud-environment --results=verified,
280280
Use the `--image` flag multiple times to scan multiple images.
281281

282282
```bash
283+
# to scan from a remote registry
283284
trufflehog docker --image trufflesecurity/secrets --results=verified,unknown
285+
286+
# to scan from the local docker daemon
287+
trufflehog docker --image docker://new_image:tag --results=verified,unknown
288+
289+
# to scan from an image saved as a tarball
290+
trufflehog docker --image file://path_to_image.tar --results=verified,unknown
284291
```
285292

286293
## 12: Scan in CI
@@ -672,7 +679,7 @@ TruffleHog will send a JSON POST request containing the regex matches to a
672679
configured webhook endpoint. If the endpoint responds with a `200 OK` response
673680
status code, the secret is considered verified.
674681

675-
Custom Detectors support a few different filtering mechanisms: entropy, regex targeting the entire match, regex targeting the captured secret,
682+
Custom Detectors support a few different filtering mechanisms: entropy, regex targeting the entire match, regex targeting the captured secret,
676683
and excluded word lists checked against the secret (captured group if present, entire match if capture group is not present). Note that if
677684
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.
678685

main.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -184,7 +184,7 @@ var (
184184
circleCiScanToken = circleCiScan.Flag("token", "CircleCI token. Can also be provided with environment variable").Envar("CIRCLECI_TOKEN").Required().String()
185185

186186
dockerScan = cli.Command("docker", "Scan Docker Image")
187-
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()
187+
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()
188188
dockerScanToken = dockerScan.Flag("token", "Docker bearer token. Can also be provided with environment variable").Envar("DOCKER_TOKEN").String()
189189
dockerExcludePaths = dockerScan.Flag("exclude-paths", "Comma separated list of paths to exclude from scan").String()
190190

pkg/sources/docker/docker.go

Lines changed: 37 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313
"github.com/google/go-containerregistry/pkg/authn"
1414
"github.com/google/go-containerregistry/pkg/name"
1515
v1 "github.com/google/go-containerregistry/pkg/v1"
16+
"github.com/google/go-containerregistry/pkg/v1/daemon"
1617
"github.com/google/go-containerregistry/pkg/v1/remote"
1718
"github.com/google/go-containerregistry/pkg/v1/tarball"
1819
gzip "github.com/klauspost/pgzip"
@@ -179,8 +180,8 @@ func (s *Source) Chunks(ctx context.Context, chunksChan chan *sources.Chunk, _ .
179180
func (s *Source) processImage(ctx context.Context, image string) (imageInfo, error) {
180181
var (
181182
imgInfo imageInfo
182-
hasDigest bool
183183
imageName name.Reference
184+
err error
184185
)
185186

186187
remoteOpts, err := s.remoteOpts()
@@ -189,25 +190,29 @@ func (s *Source) processImage(ctx context.Context, image string) (imageInfo, err
189190
}
190191

191192
const filePrefix = "file://"
193+
const dockerPrefix = "docker://"
192194
if strings.HasPrefix(image, filePrefix) {
193195
image = strings.TrimPrefix(image, filePrefix)
194196
imgInfo.base = image
195197
imgInfo.image, err = tarball.ImageFromPath(image, nil)
196198
if err != nil {
197199
return imgInfo, err
198200
}
199-
} else {
200-
imgInfo.base, imgInfo.tag, hasDigest = baseAndTagFromImage(image)
201-
202-
if hasDigest {
203-
imageName, err = name.NewDigest(image)
204-
} else {
205-
imageName, err = name.NewTag(image)
201+
} else if strings.HasPrefix(image, dockerPrefix) {
202+
image = strings.TrimPrefix(image, dockerPrefix)
203+
imgInfo, imageName, err = s.extractImageNameTagDigest(image)
204+
if err != nil {
205+
return imgInfo, err
206206
}
207+
imgInfo.image, err = daemon.Image(imageName)
208+
if err != nil {
209+
return imgInfo, err
210+
}
211+
} else {
212+
imgInfo, imageName, err = s.extractImageNameTagDigest(image)
207213
if err != nil {
208214
return imgInfo, err
209215
}
210-
211216
imgInfo.image, err = remote.Image(imageName, remoteOpts...)
212217
if err != nil {
213218
return imgInfo, err
@@ -219,6 +224,29 @@ func (s *Source) processImage(ctx context.Context, image string) (imageInfo, err
219224
return imgInfo, nil
220225
}
221226

227+
// extractImageNameTagDigest parses the provided Docker image string and returns a name.Reference
228+
// representing either the image's tag or digest, and any error encountered during parsing.
229+
func (*Source) extractImageNameTagDigest(image string) (imageInfo, name.Reference, error) {
230+
var (
231+
hasDigest bool
232+
imgInfo imageInfo
233+
imgName name.Reference
234+
err error
235+
)
236+
imgInfo.base, imgInfo.tag, hasDigest = baseAndTagFromImage(image)
237+
238+
if hasDigest {
239+
imgName, err = name.NewDigest(image)
240+
} else {
241+
imgName, err = name.NewTag(image)
242+
}
243+
if err != nil {
244+
return imgInfo, imgName, err
245+
}
246+
247+
return imgInfo, imgName, nil
248+
}
249+
222250
// getHistoryEntries collates an image's configuration history together with the
223251
// corresponding layer digests for any non-empty layers.
224252
func getHistoryEntries(ctx context.Context, imgInfo imageInfo, layers []v1.Layer) ([]historyEntryInfo, error) {

pkg/sources/docker/docker_test.go

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
package docker
22

33
import (
4+
"io"
45
"strings"
56
"sync"
67
"testing"
78

9+
image "github.com/docker/docker/api/types/image"
10+
dockerClient "github.com/docker/docker/client"
811
"github.com/stretchr/testify/assert"
912
"google.golang.org/protobuf/types/known/anypb"
1013

@@ -134,6 +137,100 @@ func TestDockerImageScanWithDigest(t *testing.T) {
134137
assert.Equal(t, 1, historyCounter)
135138
}
136139

140+
func TestDockerImageScanFromLocalDaemon(t *testing.T) {
141+
dockerDaemonTestCases := []struct {
142+
name string
143+
image string
144+
}{
145+
{
146+
name: "TestDockerImageScanFromLocalDaemon",
147+
image: "docker://trufflesecurity/secrets",
148+
},
149+
{
150+
name: "TestDockerImageScanFromLocalDaemonWithDigest",
151+
image: "docker://trufflesecurity/secrets@sha256:864f6d41209462d8e37fc302ba1532656e265f7c361f11e29fed6ca1f4208e11",
152+
},
153+
{
154+
name: "TestDockerImageScanFromLocalDaemonWithTag",
155+
image: "docker://trufflesecurity/secrets:latest",
156+
},
157+
}
158+
159+
// pull the image here to ensure it exists locally
160+
img := "docker.io/trufflesecurity/secrets:latest"
161+
162+
client, err := dockerClient.NewClientWithOpts(dockerClient.FromEnv, dockerClient.WithAPIVersionNegotiation())
163+
if err != nil {
164+
t.Errorf("Failed to create Docker client: %v", err)
165+
return
166+
}
167+
168+
resp, err := client.ImagePull(context.TODO(), img, image.PullOptions{})
169+
if err != nil {
170+
t.Errorf("Failed to load image %s: %v", img, err)
171+
return
172+
}
173+
174+
defer resp.Close()
175+
176+
// if we don't read the response, the image will not be available in the local Docker daemon
177+
_, err = io.ReadAll(resp)
178+
if err != nil {
179+
t.Errorf("Failed to read response body: %v", err)
180+
}
181+
182+
for _, tt := range dockerDaemonTestCases {
183+
t.Run(tt.name, func(t *testing.T) {
184+
// This test assumes the local Docker daemon is running
185+
dockerConn := &sourcespb.Docker{
186+
Credential: &sourcespb.Docker_Unauthenticated{
187+
Unauthenticated: &credentialspb.Unauthenticated{},
188+
},
189+
Images: []string{tt.image},
190+
}
191+
192+
conn := &anypb.Any{}
193+
err = conn.MarshalFrom(dockerConn)
194+
assert.NoError(t, err)
195+
196+
s := &Source{}
197+
err = s.Init(context.TODO(), "test source", 0, 0, false, conn, 1)
198+
assert.NoError(t, err)
199+
200+
var wg sync.WaitGroup
201+
chunksChan := make(chan *sources.Chunk, 1)
202+
chunkCounter := 0
203+
layerCounter := 0
204+
historyCounter := 0
205+
206+
wg.Add(1)
207+
go func() {
208+
defer wg.Done()
209+
for chunk := range chunksChan {
210+
assert.NotEmpty(t, chunk)
211+
chunkCounter++
212+
213+
if isHistoryChunk(t, chunk) {
214+
historyCounter++
215+
} else {
216+
layerCounter++
217+
}
218+
}
219+
}()
220+
221+
err = s.Chunks(context.TODO(), chunksChan)
222+
assert.NoError(t, err)
223+
224+
close(chunksChan)
225+
wg.Wait()
226+
227+
assert.Equal(t, 2, chunkCounter)
228+
assert.Equal(t, 1, layerCounter)
229+
assert.Equal(t, 1, historyCounter)
230+
})
231+
}
232+
}
233+
137234
func TestBaseAndTagFromImage(t *testing.T) {
138235
tests := []struct {
139236
image string

0 commit comments

Comments
 (0)