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