402 lines
12 KiB
Go
402 lines
12 KiB
Go
/*
|
|
©AngelaMos | 2026
|
|
dockerfile.go
|
|
|
|
DockerfileAnalyzer scans Dockerfile instructions for CIS Section 4
|
|
violations
|
|
|
|
Parses the Dockerfile using the buildkit frontend parser and checks for:
|
|
missing USER instruction, missing or disabled HEALTHCHECK, ADD vs COPY
|
|
usage, hardcoded secrets in ENV/ARG/RUN/LABEL, implicit or explicit
|
|
:latest tags, curl-pipe-to-shell patterns, and sudo in RUN instructions.
|
|
|
|
Key exports:
|
|
DockerfileAnalyzer - implements Analyzer for Dockerfiles
|
|
NewDockerfileAnalyzer - constructor taking file path
|
|
|
|
Connects to:
|
|
analyzer.go - implements Analyzer interface, uses CategoryDockerfile
|
|
rules/secrets.go - DetectSecrets, IsSensitiveEnvName,
|
|
IsHighEntropyString
|
|
benchmark/controls.go - fetches CIS Section 4 controls by ID
|
|
config/constants.go - reads MinSecretLength and MinEntropyForSecret
|
|
finding.go - creates findings with line-accurate locations
|
|
*/
|
|
|
|
package analyzer
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"strings"
|
|
|
|
"github.com/CarterPerez-dev/docksec/internal/benchmark"
|
|
"github.com/CarterPerez-dev/docksec/internal/config"
|
|
"github.com/CarterPerez-dev/docksec/internal/finding"
|
|
"github.com/CarterPerez-dev/docksec/internal/rules"
|
|
"github.com/moby/buildkit/frontend/dockerfile/parser"
|
|
)
|
|
|
|
type DockerfileAnalyzer struct {
|
|
path string
|
|
}
|
|
|
|
func NewDockerfileAnalyzer(path string) *DockerfileAnalyzer {
|
|
return &DockerfileAnalyzer{path: path}
|
|
}
|
|
|
|
func (a *DockerfileAnalyzer) Name() string {
|
|
return "dockerfile:" + a.path
|
|
}
|
|
|
|
func (a *DockerfileAnalyzer) Analyze(
|
|
ctx context.Context,
|
|
) (finding.Collection, error) {
|
|
file, err := os.Open(a.path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer func() { _ = file.Close() }()
|
|
|
|
result, err := parser.Parse(file)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
target := finding.Target{
|
|
Type: finding.TargetDockerfile,
|
|
Name: a.path,
|
|
}
|
|
|
|
var findings finding.Collection
|
|
|
|
findings = append(findings, a.checkUserInstruction(target, result.AST)...)
|
|
findings = append(findings, a.checkHealthcheck(target, result.AST)...)
|
|
findings = append(findings, a.checkAddInstruction(target, result.AST)...)
|
|
findings = append(findings, a.checkSecrets(target, result.AST)...)
|
|
findings = append(findings, a.checkLatestTag(target, result.AST)...)
|
|
findings = append(findings, a.checkCurlPipe(target, result.AST)...)
|
|
findings = append(findings, a.checkSudo(target, result.AST)...)
|
|
|
|
return findings, nil
|
|
}
|
|
|
|
func (a *DockerfileAnalyzer) checkUserInstruction(
|
|
target finding.Target,
|
|
ast *parser.Node,
|
|
) finding.Collection {
|
|
var findings finding.Collection
|
|
|
|
hasUser := false
|
|
var lastFromLine int
|
|
|
|
for _, node := range ast.Children {
|
|
switch strings.ToUpper(node.Value) {
|
|
case "FROM":
|
|
lastFromLine = node.StartLine
|
|
hasUser = false
|
|
case "USER":
|
|
hasUser = true
|
|
user := ""
|
|
if node.Next != nil {
|
|
user = node.Next.Value
|
|
}
|
|
if user == "root" || user == "0" {
|
|
loc := &finding.Location{Path: a.path, Line: node.StartLine}
|
|
f := finding.New("DS-USER-ROOT", "USER instruction sets root user", finding.SeverityMedium, target).
|
|
WithDescription("Dockerfile explicitly sets USER to root, which should be avoided.").
|
|
WithCategory(string(CategoryDockerfile)).
|
|
WithLocation(loc).
|
|
WithRemediation("Create and use a non-root user in the Dockerfile.")
|
|
findings = append(findings, f)
|
|
}
|
|
}
|
|
}
|
|
|
|
if !hasUser && lastFromLine > 0 {
|
|
control, _ := benchmark.Get("4.1")
|
|
loc := &finding.Location{Path: a.path, Line: lastFromLine}
|
|
f := finding.New("CIS-4.1", control.Title, finding.SeverityMedium, target).
|
|
WithDescription(control.Description).
|
|
WithCategory(string(CategoryDockerfile)).
|
|
WithLocation(loc).
|
|
WithRemediation(control.Remediation).
|
|
WithReferences(control.References...).
|
|
WithCISControl(control.ToCISControl())
|
|
findings = append(findings, f)
|
|
}
|
|
|
|
return findings
|
|
}
|
|
|
|
func (a *DockerfileAnalyzer) checkHealthcheck(
|
|
target finding.Target,
|
|
ast *parser.Node,
|
|
) finding.Collection {
|
|
var findings finding.Collection
|
|
|
|
hasHealthcheck := false
|
|
for _, node := range ast.Children {
|
|
if strings.ToUpper(node.Value) == "HEALTHCHECK" {
|
|
hasHealthcheck = true
|
|
if node.Next != nil &&
|
|
strings.ToUpper(node.Next.Value) == "NONE" {
|
|
loc := &finding.Location{Path: a.path, Line: node.StartLine}
|
|
control, _ := benchmark.Get("4.6")
|
|
f := finding.New("CIS-4.6", "HEALTHCHECK explicitly disabled", finding.SeverityLow, target).
|
|
WithDescription("Dockerfile disables health checks with HEALTHCHECK NONE.").
|
|
WithCategory(string(CategoryDockerfile)).
|
|
WithLocation(loc).
|
|
WithRemediation(control.Remediation).
|
|
WithReferences(control.References...).
|
|
WithCISControl(control.ToCISControl())
|
|
findings = append(findings, f)
|
|
}
|
|
break
|
|
}
|
|
}
|
|
|
|
if !hasHealthcheck {
|
|
control, _ := benchmark.Get("4.6")
|
|
f := finding.New("CIS-4.6", control.Title, finding.SeverityLow, target).
|
|
WithDescription(control.Description).
|
|
WithCategory(string(CategoryDockerfile)).
|
|
WithRemediation(control.Remediation).
|
|
WithReferences(control.References...).
|
|
WithCISControl(control.ToCISControl())
|
|
findings = append(findings, f)
|
|
}
|
|
|
|
return findings
|
|
}
|
|
|
|
func (a *DockerfileAnalyzer) checkAddInstruction(
|
|
target finding.Target,
|
|
ast *parser.Node,
|
|
) finding.Collection {
|
|
var findings finding.Collection
|
|
|
|
for _, node := range ast.Children {
|
|
if strings.ToUpper(node.Value) == "ADD" {
|
|
src := ""
|
|
if node.Next != nil {
|
|
src = node.Next.Value
|
|
}
|
|
|
|
isURL := strings.HasPrefix(src, "http://") ||
|
|
strings.HasPrefix(src, "https://")
|
|
isArchive := strings.HasSuffix(src, ".tar") ||
|
|
strings.HasSuffix(src, ".tar.gz") ||
|
|
strings.HasSuffix(src, ".tgz") ||
|
|
strings.HasSuffix(src, ".tar.bz2")
|
|
|
|
if !isURL && !isArchive {
|
|
control, _ := benchmark.Get("4.9")
|
|
loc := &finding.Location{Path: a.path, Line: node.StartLine}
|
|
f := finding.New("CIS-4.9", control.Title, finding.SeverityLow, target).
|
|
WithDescription(control.Description).
|
|
WithCategory(string(CategoryDockerfile)).
|
|
WithLocation(loc).
|
|
WithRemediation(control.Remediation).
|
|
WithReferences(control.References...).
|
|
WithCISControl(control.ToCISControl())
|
|
findings = append(findings, f)
|
|
}
|
|
|
|
if isURL {
|
|
loc := &finding.Location{Path: a.path, Line: node.StartLine}
|
|
f := finding.New("DS-ADD-URL", "ADD instruction fetches from URL", finding.SeverityMedium, target).
|
|
WithDescription("ADD with URLs can introduce security risks. Use curl/wget with verification instead.").
|
|
WithCategory(string(CategoryDockerfile)).
|
|
WithLocation(loc).
|
|
WithRemediation("Use RUN with curl or wget and verify checksums of downloaded files.")
|
|
findings = append(findings, f)
|
|
}
|
|
}
|
|
}
|
|
|
|
return findings
|
|
}
|
|
|
|
func (a *DockerfileAnalyzer) checkSecrets(
|
|
target finding.Target,
|
|
ast *parser.Node,
|
|
) finding.Collection {
|
|
var findings finding.Collection
|
|
|
|
for _, node := range ast.Children {
|
|
cmd := strings.ToUpper(node.Value)
|
|
if cmd != "ENV" && cmd != "ARG" && cmd != "RUN" && cmd != "LABEL" {
|
|
continue
|
|
}
|
|
|
|
line := getFullLine(node)
|
|
|
|
if cmd == "ENV" || cmd == "ARG" {
|
|
varName := ""
|
|
varValue := ""
|
|
if node.Next != nil {
|
|
parts := strings.SplitN(node.Next.Value, "=", 2)
|
|
varName = parts[0]
|
|
if len(parts) > 1 {
|
|
varValue = parts[1]
|
|
}
|
|
}
|
|
if rules.IsSensitiveEnvName(varName) {
|
|
control, _ := benchmark.Get("4.10")
|
|
loc := &finding.Location{Path: a.path, Line: node.StartLine}
|
|
f := finding.New("CIS-4.10", "Sensitive variable in "+cmd+": "+varName, finding.SeverityHigh, target).
|
|
WithDescription(control.Description).
|
|
WithCategory(string(CategoryDockerfile)).
|
|
WithLocation(loc).
|
|
WithRemediation(control.Remediation).
|
|
WithReferences(control.References...).
|
|
WithCISControl(control.ToCISControl())
|
|
findings = append(findings, f)
|
|
}
|
|
|
|
if varValue != "" &&
|
|
rules.IsHighEntropyString(
|
|
varValue,
|
|
config.MinSecretLength,
|
|
config.MinEntropyForSecret,
|
|
) {
|
|
loc := &finding.Location{Path: a.path, Line: node.StartLine}
|
|
f := finding.New("DS-HIGH-ENTROPY", "High entropy string in "+cmd+" (potential secret)", finding.SeverityMedium, target).
|
|
WithDescription("Value in " + varName + " has high entropy, indicating a potential hardcoded secret or key.").
|
|
WithCategory(string(CategoryDockerfile)).
|
|
WithLocation(loc).
|
|
WithRemediation("Use Docker secrets, build arguments, or environment variables at runtime instead of hardcoding sensitive values.")
|
|
findings = append(findings, f)
|
|
}
|
|
}
|
|
|
|
secrets := rules.DetectSecrets(line)
|
|
for _, secret := range secrets {
|
|
control, _ := benchmark.Get("4.10")
|
|
loc := &finding.Location{Path: a.path, Line: node.StartLine}
|
|
f := finding.New("CIS-4.10", "Potential "+string(secret.Type)+" detected in Dockerfile", finding.SeverityHigh, target).
|
|
WithDescription(secret.Description + ". " + control.Description).
|
|
WithCategory(string(CategoryDockerfile)).
|
|
WithLocation(loc).
|
|
WithRemediation(control.Remediation).
|
|
WithReferences(control.References...).
|
|
WithCISControl(control.ToCISControl())
|
|
findings = append(findings, f)
|
|
}
|
|
}
|
|
|
|
return findings
|
|
}
|
|
|
|
func (a *DockerfileAnalyzer) checkLatestTag(
|
|
target finding.Target,
|
|
ast *parser.Node,
|
|
) finding.Collection {
|
|
var findings finding.Collection
|
|
|
|
for _, node := range ast.Children {
|
|
if strings.ToUpper(node.Value) == "FROM" {
|
|
image := ""
|
|
if node.Next != nil {
|
|
image = node.Next.Value
|
|
}
|
|
|
|
if image != "" && !strings.Contains(image, ":") {
|
|
loc := &finding.Location{Path: a.path, Line: node.StartLine}
|
|
f := finding.New("DS-LATEST-TAG", "FROM uses implicit :latest tag", finding.SeverityMedium, target).
|
|
WithDescription("Using implicit :latest tag makes builds non-reproducible and may introduce unexpected changes.").
|
|
WithCategory(string(CategoryDockerfile)).
|
|
WithLocation(loc).
|
|
WithRemediation("Pin images to specific versions or digests (e.g., alpine:3.18 or alpine@sha256:...).")
|
|
findings = append(findings, f)
|
|
}
|
|
|
|
if strings.HasSuffix(image, ":latest") {
|
|
loc := &finding.Location{Path: a.path, Line: node.StartLine}
|
|
f := finding.New("DS-LATEST-TAG", "FROM uses explicit :latest tag", finding.SeverityMedium, target).
|
|
WithDescription("Using :latest tag makes builds non-reproducible and may introduce unexpected changes.").
|
|
WithCategory(string(CategoryDockerfile)).
|
|
WithLocation(loc).
|
|
WithRemediation("Pin images to specific versions or digests (e.g., alpine:3.18 or alpine@sha256:...).")
|
|
findings = append(findings, f)
|
|
}
|
|
}
|
|
}
|
|
|
|
return findings
|
|
}
|
|
|
|
func (a *DockerfileAnalyzer) checkCurlPipe(
|
|
target finding.Target,
|
|
ast *parser.Node,
|
|
) finding.Collection {
|
|
var findings finding.Collection
|
|
|
|
dangerousPatterns := []string{
|
|
"curl|sh", "curl|bash", "wget|sh", "wget|bash",
|
|
"curl | sh", "curl | bash", "wget | sh", "wget | bash",
|
|
}
|
|
|
|
for _, node := range ast.Children {
|
|
if strings.ToUpper(node.Value) != "RUN" {
|
|
continue
|
|
}
|
|
|
|
line := strings.ToLower(getFullLine(node))
|
|
for _, pattern := range dangerousPatterns {
|
|
if strings.Contains(line, pattern) {
|
|
loc := &finding.Location{Path: a.path, Line: node.StartLine}
|
|
f := finding.New("DS-CURL-PIPE", "Piping curl/wget to shell detected", finding.SeverityHigh, target).
|
|
WithDescription("Piping downloaded content directly to a shell is dangerous and can execute malicious code.").
|
|
WithCategory(string(CategoryDockerfile)).
|
|
WithLocation(loc).
|
|
WithRemediation("Download files first, verify checksums, then execute.")
|
|
findings = append(findings, f)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
return findings
|
|
}
|
|
|
|
func (a *DockerfileAnalyzer) checkSudo(
|
|
target finding.Target,
|
|
ast *parser.Node,
|
|
) finding.Collection {
|
|
var findings finding.Collection
|
|
|
|
for _, node := range ast.Children {
|
|
if strings.ToUpper(node.Value) != "RUN" {
|
|
continue
|
|
}
|
|
|
|
line := getFullLine(node)
|
|
if strings.Contains(line, "sudo ") {
|
|
loc := &finding.Location{Path: a.path, Line: node.StartLine}
|
|
f := finding.New("DS-SUDO", "sudo used in RUN instruction", finding.SeverityLow, target).
|
|
WithDescription("Using sudo in Dockerfiles is usually unnecessary since commands run as root by default.").
|
|
WithCategory(string(CategoryDockerfile)).
|
|
WithLocation(loc).
|
|
WithRemediation("Remove sudo from commands or use USER instruction to switch users.")
|
|
findings = append(findings, f)
|
|
}
|
|
}
|
|
|
|
return findings
|
|
}
|
|
|
|
func getFullLine(node *parser.Node) string {
|
|
if node.Original != "" {
|
|
return node.Original
|
|
}
|
|
|
|
var parts []string
|
|
parts = append(parts, node.Value)
|
|
for n := node.Next; n != nil; n = n.Next {
|
|
parts = append(parts, n.Value)
|
|
}
|
|
return strings.Join(parts, " ")
|
|
}
|