Skip to content

Commit

Permalink
fix: improve conversion of image config to Dockerfile (#8308)
Browse files Browse the repository at this point in the history
Signed-off-by: nikpivkin <[email protected]>
  • Loading branch information
nikpivkin authored Jan 29, 2025
1 parent f258fd5 commit 2e8e38a
Show file tree
Hide file tree
Showing 2 changed files with 160 additions and 37 deletions.
97 changes: 60 additions & 37 deletions pkg/fanal/analyzer/imgconf/dockerfile/dockerfile.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"fmt"
"strings"

v1 "github.com/google/go-containerregistry/pkg/v1"
"golang.org/x/xerrors"

"github.com/aquasecurity/trivy/pkg/fanal/analyzer"
Expand Down Expand Up @@ -49,76 +50,98 @@ func (a *historyAnalyzer) Analyze(ctx context.Context, input analyzer.ConfigAnal
if input.Config == nil {
return nil, nil
}

fsys := mapfs.New()
if err := fsys.WriteVirtualFile(
"Dockerfile", imageConfigToDockerfile(input.Config), 0600); err != nil {
return nil, xerrors.Errorf("mapfs write error: %w", err)
}

misconfs, err := a.scanner.Scan(ctx, fsys)
if err != nil {
return nil, xerrors.Errorf("history scan error: %w", err)
}
// The result should be a single element as it passes one Dockerfile.
if len(misconfs) != 1 {
return nil, nil
}

return &analyzer.ConfigAnalysisResult{
Misconfiguration: &misconfs[0],
}, nil
}

func imageConfigToDockerfile(cfg *v1.ConfigFile) []byte {
dockerfile := new(bytes.Buffer)
var userFound bool
baseLayerIndex := image.GuessBaseImageIndex(input.Config.History)
for i := baseLayerIndex + 1; i < len(input.Config.History); i++ {
h := input.Config.History[i]
baseLayerIndex := image.GuessBaseImageIndex(cfg.History)
for i := baseLayerIndex + 1; i < len(cfg.History); i++ {
h := cfg.History[i]
var createdBy string
switch {
case strings.HasPrefix(h.CreatedBy, "/bin/sh -c #(nop)"):
// Instruction other than RUN
createdBy = strings.TrimPrefix(h.CreatedBy, "/bin/sh -c #(nop)")
case strings.HasPrefix(h.CreatedBy, "/bin/sh -c"):
// RUN instruction
createdBy = strings.ReplaceAll(h.CreatedBy, "/bin/sh -c", "RUN")
createdBy = buildRunInstruction(createdBy)
case strings.HasSuffix(h.CreatedBy, "# buildkit"):
// buildkit instructions
// COPY ./foo /foo # buildkit
// ADD ./foo.txt /foo.txt # buildkit
// RUN /bin/sh -c ls -hl /foo # buildkit
createdBy = strings.TrimSuffix(h.CreatedBy, "# buildkit")
if strings.HasPrefix(h.CreatedBy, "RUN /bin/sh -c") {
createdBy = strings.ReplaceAll(createdBy, "RUN /bin/sh -c", "RUN")
}
createdBy = buildRunInstruction(createdBy)
case strings.HasPrefix(h.CreatedBy, "USER"):
// USER instruction
createdBy = h.CreatedBy
userFound = true
case strings.HasPrefix(h.CreatedBy, "HEALTHCHECK"):
// HEALTHCHECK instruction
var interval, timeout, startPeriod, retries, command string
if input.Config.Config.Healthcheck.Interval != 0 {
interval = fmt.Sprintf("--interval=%s ", input.Config.Config.Healthcheck.Interval)
createdBy = buildHealthcheckInstruction(cfg.Config.Healthcheck)
default:
for _, prefix := range []string{"ARG", "ENV", "ENTRYPOINT"} {
strings.HasPrefix(h.CreatedBy, prefix)
createdBy = h.CreatedBy
break
}
if input.Config.Config.Healthcheck.Timeout != 0 {
timeout = fmt.Sprintf("--timeout=%s ", input.Config.Config.Healthcheck.Timeout)
}
if input.Config.Config.Healthcheck.StartPeriod != 0 {
startPeriod = fmt.Sprintf("--startPeriod=%s ", input.Config.Config.Healthcheck.StartPeriod)
}
if input.Config.Config.Healthcheck.Retries != 0 {
retries = fmt.Sprintf("--retries=%d ", input.Config.Config.Healthcheck.Retries)
}
command = strings.Join(input.Config.Config.Healthcheck.Test, " ")
command = strings.ReplaceAll(command, "CMD-SHELL", "CMD")
createdBy = fmt.Sprintf("HEALTHCHECK %s%s%s%s%s", interval, timeout, startPeriod, retries, command)
}
dockerfile.WriteString(strings.TrimSpace(createdBy) + "\n")
}

if !userFound && input.Config.Config.User != "" {
user := fmt.Sprintf("USER %s", input.Config.Config.User)
if !userFound && cfg.Config.User != "" {
user := fmt.Sprintf("USER %s", cfg.Config.User)
dockerfile.WriteString(user)
}

fsys := mapfs.New()
if err := fsys.WriteVirtualFile("Dockerfile", dockerfile.Bytes(), 0600); err != nil {
return nil, xerrors.Errorf("mapfs write error: %w", err)
return dockerfile.Bytes()
}

func buildRunInstruction(s string) string {
pos := strings.Index(s, "/bin/sh -c")
if pos == -1 {
return s
}
return "RUN" + s[pos+len("/bin/sh -c"):]
}

misconfs, err := a.scanner.Scan(ctx, fsys)
if err != nil {
return nil, xerrors.Errorf("history scan error: %w", err)
func buildHealthcheckInstruction(health *v1.HealthConfig) string {
var interval, timeout, startPeriod, retries, command string
if health.Interval != 0 {
interval = fmt.Sprintf("--interval=%s ", health.Interval)
}
// The result should be a single element as it passes one Dockerfile.
if len(misconfs) != 1 {
return nil, nil
if health.Timeout != 0 {
timeout = fmt.Sprintf("--timeout=%s ", health.Timeout)
}

return &analyzer.ConfigAnalysisResult{
Misconfiguration: &misconfs[0],
}, nil
if health.StartPeriod != 0 {
startPeriod = fmt.Sprintf("--startPeriod=%s ", health.StartPeriod)
}
if health.Retries != 0 {
retries = fmt.Sprintf("--retries=%d ", health.Retries)
}
command = strings.Join(health.Test, " ")
command = strings.ReplaceAll(command, "CMD-SHELL", "CMD")
return fmt.Sprintf("HEALTHCHECK %s%s%s%s%s", interval, timeout, startPeriod, retries, command)
}

func (a *historyAnalyzer) Required(_ types.OS) bool {
Expand Down
100 changes: 100 additions & 0 deletions pkg/fanal/analyzer/imgconf/dockerfile/dockerfile_test.go
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
package dockerfile

import (
"bytes"
"context"
"testing"
"time"

v1 "github.com/google/go-containerregistry/pkg/v1"
"github.com/moby/buildkit/frontend/dockerfile/parser"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

Expand Down Expand Up @@ -343,3 +345,101 @@ func Test_historyAnalyzer_Analyze(t *testing.T) {
})
}
}

func Test_ImageConfigToDockerfile(t *testing.T) {
tests := []struct {
name string
input *v1.ConfigFile
expected string
}{
{
name: "run instruction with build args",
input: &v1.ConfigFile{
History: []v1.History{
{
CreatedBy: "RUN |1 pkg=curl /bin/sh -c apk add $pkg # buildkit",
},
},
},
expected: "RUN apk add $pkg\n",
},
{
name: "healthcheck instruction with system's default shell",
input: &v1.ConfigFile{
History: []v1.History{
{
CreatedBy: "HEALTHCHECK &{[\"CMD-SHELL\" \"curl -f http://localhost/ || exit 1\"] \"5m0s\" \"3s\" \"1s\" \"5s\" '\\x03'}",
},
},
Config: v1.Config{
Healthcheck: &v1.HealthConfig{
Test: []string{"CMD-SHELL", "curl -f http://localhost/ || exit 1"},
Interval: time.Minute * 5,
Timeout: time.Second * 3,
StartPeriod: time.Second * 1,
Retries: 3,
},
},
},
expected: "HEALTHCHECK --interval=5m0s --timeout=3s --startPeriod=1s --retries=3 CMD curl -f http://localhost/ || exit 1\n",
},
{
name: "healthcheck instruction exec arguments directly",
input: &v1.ConfigFile{
History: []v1.History{
{
CreatedBy: "HEALTHCHECK &{[\"CMD\" \"curl\" \"-f\" \"http://localhost/\" \"||\" \"exit 1\"] \"0s\" \"0s\" \"0s\" \"0s\" '\x03'}",
},
},
Config: v1.Config{
Healthcheck: &v1.HealthConfig{
Test: []string{"CMD", "curl", "-f", "http://localhost/", "||", "exit 1"},
Retries: 3,
},
},
},
expected: "HEALTHCHECK --retries=3 CMD curl -f http://localhost/ || exit 1\n",
},
{
name: "nop, no run instruction",
input: &v1.ConfigFile{
History: []v1.History{
{
CreatedBy: "/bin/sh -c #(nop) ARG TAG=latest",
},
},
},
expected: "ARG TAG=latest\n",
},
{
name: "buildkit metadata instructions",
input: &v1.ConfigFile{
History: []v1.History{
{
CreatedBy: "ARG TAG=latest",
},
{
CreatedBy: "ENV TAG=latest",
},
{
CreatedBy: "ENTRYPOINT [\"/bin/sh\" \"-c\" \"echo test\"]",
},
},
},
expected: `ARG TAG=latest
ENV TAG=latest
ENTRYPOINT ["/bin/sh" "-c" "echo test"]
`,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := imageConfigToDockerfile(tt.input)
_, err := parser.Parse(bytes.NewReader(got))
require.NoError(t, err)

assert.Equal(t, tt.expected, string(got))
})
}
}

0 comments on commit 2e8e38a

Please sign in to comment.