Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion main.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ func cli() *cobra.Command {
var mappingsFile string
var updateFlag bool
var noBuiltInFlag bool
var multistageFlag bool
var strictFlag bool
var warnMissingPackagesFlag bool

Expand Down Expand Up @@ -112,7 +113,8 @@ func cli() *cobra.Command {
Registry: registry,
Update: updateFlag,
NoBuiltIn: noBuiltInFlag,
Strict: strictFlag,
ConvertToMultistage: multistageFlag,
Strict: strictFlag,
WarnMissingPackages: warnMissingPackagesFlag,
}

Expand Down Expand Up @@ -200,6 +202,7 @@ func cli() *cobra.Command {
cmd.Flags().StringVarP(&mappingsFile, "mappings", "m", "", "path to a custom package mappings YAML file (instead of the default)")
cmd.Flags().BoolVar(&updateFlag, "update", false, "check for and apply available updates")
cmd.Flags().BoolVar(&noBuiltInFlag, "no-builtin", false, "skip built-in package/image mappings, still apply default conversion logic")
cmd.Flags().BoolVar(&multistageFlag, "multistage", false, "convert single-stage Dockerfiles to secure multistage builds")
cmd.Flags().Var(&level, "log-level", "log level (e.g. debug, info, warn, error)")
cmd.Flags().BoolVar(&strictFlag, "strict", false, "when true, fail if any package is unknown")
cmd.Flags().BoolVar(&warnMissingPackagesFlag, "warn-missing-packages", false, "when true, warn about missing package mappings")
Expand Down
195 changes: 190 additions & 5 deletions pkg/dfc/dfc.go
Original file line number Diff line number Diff line change
Expand Up @@ -499,6 +499,7 @@ type Options struct {
NoBuiltIn bool // When true, don't use built-in mappings, only ExtraMappings
FromLineConverter FromLineConverter // Optional custom converter for FROM lines
RunLineConverter RunLineConverter // Optional custom converter for RUN lines
ConvertToMultistage bool // When true, convert single-stage builds to multistage for security
Strict bool // When true, fail if any package is unknown
WarnMissingPackages bool // When true, warn about missing package mappings instead of using the original package name
}
Expand All @@ -523,6 +524,20 @@ func parseImageReference(imageRef string) (base, tag string) {

// Convert applies the conversion to the Dockerfile and returns a new converted Dockerfile
func (d *Dockerfile) Convert(ctx context.Context, opts Options) (*Dockerfile, error) {
dockerfileToConvert := d
if opts.ConvertToMultistage && shouldConvertToMultistage(d.Lines) {
converted, err := convertSingleStageToMultistageGeneric(d, MultistageOptions{
BuildAlias: "builder",
RuntimeAlias: "",
PreserveAliases: true,
CopyStrategy: DefaultCopyStrategy,
})
if err != nil {
return nil, fmt.Errorf("converting to multistage: %w", err)
}
dockerfileToConvert = converted
}

// Initialize mappings
var mappings MappingsConfig

Expand Down Expand Up @@ -556,7 +571,7 @@ func (d *Dockerfile) Convert(ctx context.Context, opts Options) (*Dockerfile, er

// Create a new Dockerfile for the converted content
converted := &Dockerfile{
Lines: make([]*DockerfileLine, len(d.Lines)),
Lines: make([]*DockerfileLine, len(dockerfileToConvert.Lines)),
}

// Track packages installed per stage
Expand All @@ -567,13 +582,13 @@ func (d *Dockerfile) Convert(ctx context.Context, opts Options) (*Dockerfile, er
argsUsedAsBase := make(map[string]bool)

// Track stages with RUN commands for determining if we need -dev suffix
stagesWithRunCommands := detectStagesWithRunCommands(d.Lines)
stagesWithRunCommands := detectStagesWithRunCommands(dockerfileToConvert.Lines)

// First pass: collect all ARG definitions and identify which ones are used as base images
identifyArgsUsedAsBaseImages(d.Lines, argNameToDockerfileLine, argsUsedAsBase)
identifyArgsUsedAsBaseImages(dockerfileToConvert.Lines, argNameToDockerfileLine, argsUsedAsBase)

// Convert each line
for i, line := range d.Lines {
for i, line := range dockerfileToConvert.Lines {
// Create a deep copy of the line
newLine := &DockerfileLine{
Raw: line.Raw,
Expand Down Expand Up @@ -608,7 +623,7 @@ func (d *Dockerfile) Convert(ctx context.Context, opts Options) (*Dockerfile, er
FromLineConverter: opts.FromLineConverter,
RunLineConverter: opts.RunLineConverter,
}
argLine, argDetails := convertArgLine(line.Arg, d.Lines, stagesWithRunCommands, optsWithMappings)
argLine, argDetails := convertArgLine(line.Arg, dockerfileToConvert.Lines, stagesWithRunCommands, optsWithMappings)
newLine.Converted = argLine
newLine.Arg = argDetails
}
Expand Down Expand Up @@ -1630,3 +1645,173 @@ func createApkPackageSpec(name string, spec PackageSpec) string {

return pkg
}

// shouldConvertToMultistage determines if a single-stage Dockerfile should be converted to multistage
func shouldConvertToMultistage(lines []*DockerfileLine) bool {
stageCount := 0
hasPackageInstallCommands := false

for _, line := range lines {
if line.From != nil {
stageCount++
}

// Check for RUN commands that contain package manager commands
if line.Run != nil && line.Run.Shell != nil && line.Run.Shell.Before != nil {
for _, part := range line.Run.Shell.Before.Parts {
switch part.Command {
case "apt-get", "apt", "yum", "dnf", "microdnf", "apk", "pip", "pip3":
for _, arg := range part.Args {
if arg == "install" || arg == "add" {
hasPackageInstallCommands = true
break
}
}
}
if hasPackageInstallCommands {
break
}
}
}

if hasPackageInstallCommands {
break
}
}

// Convert to multistage if:
// 1. It's a single-stage build (only one FROM)
// 2. It has RUN commands that install packages
return stageCount == 1 && hasPackageInstallCommands
}

// MultistageOptions allows customization of multistage conversion
type MultistageOptions struct {
BuildAlias string
RuntimeAlias string
PreserveAliases bool
CopyStrategy func(line string, buildAlias string) string
}

// DefaultCopyStrategy adds --from=buildAlias if not present
func DefaultCopyStrategy(line string, buildAlias string) string {
if strings.Contains(line, "--from=") {
return line
}
if strings.HasPrefix(strings.TrimSpace(line), "COPY ") {
return strings.Replace(line, "COPY ", "COPY --from="+buildAlias+" ", 1)
}
return line
}

// convertSingleStageToMultistageGeneric converts a single-stage Dockerfile to a multistage build
func convertSingleStageToMultistageGeneric(
d *Dockerfile,
opts MultistageOptions,
) (*Dockerfile, error) {
if len(d.Lines) == 0 {
return d, nil
}

fromLineIndex := -1
var buildLines []*DockerfileLine
var runtimeLines []*DockerfileLine
var copyLines []*DockerfileLine

inBuildSection := true
buildAlias := opts.BuildAlias
if buildAlias == "" {
buildAlias = "builder"
}

for i, line := range d.Lines {
if line.From != nil {
fromLineIndex = i
alias := buildAlias
if opts.PreserveAliases && line.From.Alias != "" {
alias = line.From.Alias
}
buildFromLine := &DockerfileLine{
Raw: line.Raw,
Extra: line.Extra,
Stage: 1,
From: &FromDetails{
Base: line.From.Base,
Tag: line.From.Tag,
Digest: line.From.Digest,
Alias: alias,
Parent: line.From.Parent,
BaseDynamic: line.From.BaseDynamic,
TagDynamic: line.From.TagDynamic,
Orig: line.From.Orig,
Platform: line.From.Platform,
},
}
buildLines = append(buildLines, buildFromLine)
continue
}

if inBuildSection {
if line.Run != nil && line.Run.Manager != "" {
buildLines = append(buildLines, line)
} else if strings.Contains(strings.ToUpper(line.Raw), "COPY") &&
!strings.Contains(strings.ToUpper(line.Raw), "--FROM=") {
inBuildSection = false
copyLines = append(copyLines, line)
} else if strings.Contains(strings.ToUpper(line.Raw), "WORKDIR") ||
strings.Contains(strings.ToUpper(line.Raw), "ENV") ||
strings.Contains(strings.ToUpper(line.Raw), "ARG") {
buildLines = append(buildLines, line)
} else {
buildLines = append(buildLines, line)
}
} else {
runtimeLines = append(runtimeLines, line)
}
}

var newLines []*DockerfileLine
newLines = append(newLines, buildLines...)

if fromLineIndex >= 0 {
originalFrom := d.Lines[fromLineIndex]
runtimeFromLine := &DockerfileLine{
Raw: "",
Stage: 2,
From: &FromDetails{
Base: originalFrom.From.Base,
Tag: originalFrom.From.Tag,
Digest: originalFrom.From.Digest,
Alias: opts.RuntimeAlias,
Parent: 0,
BaseDynamic: originalFrom.From.BaseDynamic,
TagDynamic: originalFrom.From.TagDynamic,
Orig: originalFrom.From.Orig,
Platform: originalFrom.From.Platform,
},
}
newLines = append(newLines, runtimeFromLine)

if len(copyLines) > 0 {
for _, copyLine := range copyLines {
newCopyRaw := copyLine.Raw
if opts.CopyStrategy != nil {
newCopyRaw = opts.CopyStrategy(copyLine.Raw, buildAlias)
}
newCopyLine := &DockerfileLine{
Raw: newCopyRaw,
Extra: copyLine.Extra,
Stage: 2,
}
newLines = append(newLines, newCopyLine)
}
}

for _, line := range runtimeLines {
line.Stage = 2
newLines = append(newLines, line)
}
}

return &Dockerfile{Lines: newLines}, nil
}
Loading