Skip to content

Commit

Permalink
feat: support docker image history scanning (#2882)
Browse files Browse the repository at this point in the history
* feat: support docker image history scanning

* refactor: collapse error handling into return

Style suggestion from review feedback.

* fix: associate layers with history entries

Where possible, add the associated layer to the history entry record. This may help tracing any issues discovered.

This also changes the entry reference format to `image-metadata:history:%d:created-by` which _may_ be more self-explanatory.
  • Loading branch information
jamestelfer committed May 28, 2024
1 parent 18b8101 commit 0024b6c
Show file tree
Hide file tree
Showing 3 changed files with 172 additions and 3 deletions.
107 changes: 106 additions & 1 deletion pkg/sources/docker/docker.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,14 @@ type imageInfo struct {
tag string
}

type historyEntryInfo struct {
index int
entry v1.History
layerDigest string
base string
tag string
}

type layerInfo struct {
digest v1.Hash
base string
Expand Down Expand Up @@ -108,7 +116,24 @@ func (s *Source) Chunks(ctx context.Context, chunksChan chan *sources.Chunk, _ .
}

ctx = context.WithValues(ctx, "image", imgInfo.base, "tag", imgInfo.tag)
ctx.Logger().V(2).Info("scanning image")

ctx.Logger().V(2).Info("scanning image history")

historyEntries, err := getHistoryEntries(ctx, imgInfo)
if err != nil {
scanErrs.Add(err)
return nil
}

for _, historyEntry := range historyEntries {
if err := s.processHistoryEntry(ctx, historyEntry, chunksChan); err != nil {
scanErrs.Add(err)
return nil
}
dockerHistoryEntriesScanned.WithLabelValues(s.name).Inc()
}

ctx.Logger().V(2).Info("scanning image layers")

layers, err := imgInfo.image.Layers()
if err != nil {
Expand Down Expand Up @@ -181,6 +206,86 @@ func (s *Source) processImage(ctx context.Context, image string) (imageInfo, err
return imgInfo, 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) ([]historyEntryInfo, error) {
config, err := imgInfo.image.ConfigFile()
if err != nil {
return nil, err
}

layers, err := imgInfo.image.Layers()
if err != nil {
return nil, err
}

history := config.History
entries := make([]historyEntryInfo, len(history))

layerIndex := 0
for historyIndex, entry := range history {
e := historyEntryInfo{
base: imgInfo.base,
tag: imgInfo.tag,
entry: entry,
index: historyIndex,
}

// Associate with a layer if possible -- failing to do this will not affect
// the scan, just remove some traceability.
if !entry.EmptyLayer {
if layerIndex < len(layers) {
digest, err := layers[layerIndex].Digest()

if err == nil {
e.layerDigest = digest.String()
} else {
ctx.Logger().V(2).Error(err, "cannot associate layer with history entry: layer digest failed",
"layerIndex", layerIndex, "historyIndex", historyIndex)
}
} else {
ctx.Logger().V(2).Info("cannot associate layer with history entry: no correlated layer exists at this index",
"layerIndex", layerIndex, "historyIndex", historyIndex)
}

layerIndex++
}

entries[historyIndex] = e
}

return entries, nil
}

// processHistoryEntry processes a history entry from the image configuration metadata.
func (s *Source) processHistoryEntry(ctx context.Context, historyInfo historyEntryInfo, chunksChan chan *sources.Chunk) error {
// Make up an identifier for this entry that is moderately sensible. There is
// no file name to use here, so the path tries to be a little descriptive.
entryPath := fmt.Sprintf("image-metadata:history:%d:created-by", historyInfo.index)

chunk := &sources.Chunk{
SourceType: s.Type(),
SourceName: s.name,
SourceID: s.SourceID(),
SourceMetadata: &source_metadatapb.MetaData{
Data: &source_metadatapb.MetaData_Docker{
Docker: &source_metadatapb.Docker{
File: entryPath,
Image: historyInfo.base,
Tag: historyInfo.tag,
Layer: historyInfo.layerDigest,
},
},
},
Verify: s.verify,
Data: []byte(historyInfo.entry.CreatedBy),
}

ctx.Logger().V(2).Info("scanning image history entry", "index", historyInfo.index, "layer", historyInfo.layerDigest)

return common.CancellableWrite(ctx, chunksChan, chunk)
}

// processLayer processes an individual layer of an image.
func (s *Source) processLayer(ctx context.Context, layer v1.Layer, imgInfo imageInfo, chunksChan chan *sources.Chunk) error {
layerInfo := layerInfo{
Expand Down
60 changes: 58 additions & 2 deletions pkg/sources/docker/docker_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package docker

import (
"strings"
"sync"
"testing"

Expand All @@ -9,6 +10,7 @@ import (

"github.com/trufflesecurity/trufflehog/v3/pkg/context"
"github.com/trufflesecurity/trufflehog/v3/pkg/pb/credentialspb"
"github.com/trufflesecurity/trufflehog/v3/pkg/pb/source_metadatapb"
"github.com/trufflesecurity/trufflehog/v3/pkg/pb/sourcespb"
"github.com/trufflesecurity/trufflehog/v3/pkg/sources"
)
Expand All @@ -32,12 +34,21 @@ func TestDockerImageScan(t *testing.T) {
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++
}
}
}()

Expand All @@ -47,7 +58,9 @@ func TestDockerImageScan(t *testing.T) {
close(chunksChan)
wg.Wait()

assert.Equal(t, 1, chunkCounter)
assert.Equal(t, 2, chunkCounter)
assert.Equal(t, 1, layerCounter)
assert.Equal(t, 1, historyCounter)
}

func TestDockerImageScanWithDigest(t *testing.T) {
Expand All @@ -69,12 +82,27 @@ func TestDockerImageScanWithDigest(t *testing.T) {
var wg sync.WaitGroup
chunksChan := make(chan *sources.Chunk, 1)
chunkCounter := 0
layerCounter := 0
historyCounter := 0

var historyChunk *source_metadatapb.Docker
var layerChunk *source_metadatapb.Docker

wg.Add(1)
go func() {
defer wg.Done()
for chunk := range chunksChan {
assert.NotEmpty(t, chunk)
chunkCounter++

if isHistoryChunk(t, chunk) {
// save last for later comparison
historyChunk = chunk.SourceMetadata.GetDocker()
historyCounter++
} else {
layerChunk = chunk.SourceMetadata.GetDocker()
layerCounter++
}
}
}()

Expand All @@ -84,7 +112,26 @@ func TestDockerImageScanWithDigest(t *testing.T) {
close(chunksChan)
wg.Wait()

assert.Equal(t, 1, chunkCounter)
// Since this test pins the layer by digest, layers will have consistent
// hashes. This allows layer digest comparison as they will be stable for
// given image digest.
assert.Equal(t, &source_metadatapb.Docker{
Image: "trufflesecurity/secrets",
Tag: "sha256:864f6d41209462d8e37fc302ba1532656e265f7c361f11e29fed6ca1f4208e11",
File: "image-metadata:history:0:created-by",
Layer: "sha256:a794864de8c4ff087813fd66cff74601b84cbef8fe1a1f17f9923b40cf051b59",
}, historyChunk)

assert.Equal(t, &source_metadatapb.Docker{
Image: "trufflesecurity/secrets",
Tag: "sha256:864f6d41209462d8e37fc302ba1532656e265f7c361f11e29fed6ca1f4208e11",
File: "/aws",
Layer: "sha256:a794864de8c4ff087813fd66cff74601b84cbef8fe1a1f17f9923b40cf051b59",
}, layerChunk)

assert.Equal(t, 2, chunkCounter)
assert.Equal(t, 1, layerCounter)
assert.Equal(t, 1, historyCounter)
}

func TestBaseAndTagFromImage(t *testing.T) {
Expand All @@ -110,3 +157,12 @@ func TestBaseAndTagFromImage(t *testing.T) {
}
}
}

func isHistoryChunk(t *testing.T, chunk *sources.Chunk) bool {
t.Helper()

metadata := chunk.SourceMetadata.GetDocker()

return metadata != nil &&
strings.HasPrefix(metadata.File, "image-metadata:history:")
}
8 changes: 8 additions & 0 deletions pkg/sources/docker/metrics.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,14 @@ var (
},
[]string{"source_name"})

dockerHistoryEntriesScanned = promauto.NewGaugeVec(prometheus.GaugeOpts{
Namespace: common.MetricsNamespace,
Subsystem: common.MetricsSubsystem,
Name: "docker_history_entries_scanned",
Help: "Total number of Docker image history entries scanned.",
},
[]string{"source_name"})

dockerImagesScanned = promauto.NewGaugeVec(prometheus.GaugeOpts{
Namespace: common.MetricsNamespace,
Subsystem: common.MetricsSubsystem,
Expand Down

0 comments on commit 0024b6c

Please sign in to comment.