diff --git a/.codacy/codacy.yaml b/.codacy/codacy.yaml index 9929d63..15365c7 100644 --- a/.codacy/codacy.yaml +++ b/.codacy/codacy.yaml @@ -1,15 +1,15 @@ runtimes: + - dart@3.7.2 - go@1.22.3 - java@17.0.10 - node@22.2.0 - python@3.11.11 - - flutter@3.7.2 tools: - - eslint@9.38.0 + - dartanalyzer@3.7.2 + - eslint@8.57.0 - lizard@1.17.31 - - pmd@6.55.0 - - pylint@3.3.9 - - revive@1.12.0 + - pmd@7.11.0 + - pylint@3.3.6 + - revive@1.7.0 - semgrep@1.78.0 - trivy@0.66.0 - - dartanalyzer@3.7.2 diff --git a/cli-v2.go b/cli-v2.go index bf747c6..6d563e8 100644 --- a/cli-v2.go +++ b/cli-v2.go @@ -39,10 +39,10 @@ func main() { } } - // Check if command is init/update/version/help - these don't require configuration + // Check if command is init/update/version/help/container-scan - these don't require configuration if len(os.Args) > 1 { cmdName := os.Args[1] - if cmdName == "init" || cmdName == "update" || cmdName == "version" || cmdName == "help" { + if cmdName == "init" || cmdName == "update" || cmdName == "version" || cmdName == "help" || cmdName == "container-scan" { cmd.Execute() return } diff --git a/cmd/container_scan.go b/cmd/container_scan.go new file mode 100644 index 0000000..5b90419 --- /dev/null +++ b/cmd/container_scan.go @@ -0,0 +1,428 @@ +// Package cmd implements the CLI commands for Codacy CLI. +package cmd + +import ( + "bufio" + "fmt" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + + "codacy/cli-v2/utils/logger" + + "github.com/fatih/color" + "github.com/sirupsen/logrus" + "github.com/spf13/cobra" + "gopkg.in/yaml.v3" +) + +// validImageNamePattern validates Docker image references +// Allows: registry/namespace/image:tag or image@sha256:digest +// Based on Docker image reference specification +var validImageNamePattern = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._\-/:@]*$`) + +// dockerfileFromPattern matches FROM instructions in Dockerfiles +var dockerfileFromPattern = regexp.MustCompile(`(?i)^\s*FROM\s+([^\s]+)`) + +// Flag variables for container-scan command +var ( + severityFlag string + pkgTypesFlag string + ignoreUnfixedFlag bool + dockerfileFlag string + composeFileFlag string +) + +func init() { + containerScanCmd.Flags().StringVar(&severityFlag, "severity", "", "Comma-separated list of severities to scan for (default: HIGH,CRITICAL)") + containerScanCmd.Flags().StringVar(&pkgTypesFlag, "pkg-types", "", "Comma-separated list of package types to scan (default: os)") + containerScanCmd.Flags().BoolVar(&ignoreUnfixedFlag, "ignore-unfixed", true, "Ignore unfixed vulnerabilities") + containerScanCmd.Flags().StringVar(&dockerfileFlag, "dockerfile", "", "Path to Dockerfile for image auto-detection (useful in CI)") + containerScanCmd.Flags().StringVar(&composeFileFlag, "compose-file", "", "Path to docker-compose.yml for image auto-detection (useful in CI)") + rootCmd.AddCommand(containerScanCmd) +} + +var containerScanCmd = &cobra.Command{ + Use: "container-scan [FLAGS] [IMAGE_NAME]", + Short: "Scan container images for vulnerabilities using Trivy", + Long: `Scan container images for vulnerabilities using Trivy. + +By default, scans for HIGH and CRITICAL vulnerabilities in OS packages, +ignoring unfixed issues. Use flags to override these defaults. + +If no image is specified, the command will auto-detect images from: +1. Dockerfile (FROM instruction) - scans the base image +2. docker-compose.yml (image fields) - scans all referenced images + +Use --dockerfile or --compose-file flags to specify explicit paths (useful in CI/CD). + +The --exit-code 1 flag is always applied (not user-configurable) to ensure +the command fails when vulnerabilities are found.`, + Example: ` # Auto-detect from Dockerfile or docker-compose.yml in current directory + codacy-cli container-scan + + # Specify Dockerfile path (useful in CI/CD) + codacy-cli container-scan --dockerfile ./docker/Dockerfile.prod + + # Specify docker-compose file path + codacy-cli container-scan --compose-file ./deploy/docker-compose.yml + + # Scan a specific image + codacy-cli container-scan myapp:latest + + # Scan only for CRITICAL vulnerabilities + codacy-cli container-scan --severity CRITICAL myapp:latest + + # CI/CD example: scan all images before deploy + codacy-cli container-scan --dockerfile ./Dockerfile --severity HIGH,CRITICAL`, + Args: cobra.MaximumNArgs(1), + Run: runContainerScan, +} + +// validateImageName checks if the image name is a valid Docker image reference +// and doesn't contain shell metacharacters that could be used for command injection +func validateImageName(imageName string) error { + if imageName == "" { + return fmt.Errorf("image name cannot be empty") + } + + // Check for maximum length (Docker has a practical limit) + if len(imageName) > 256 { + return fmt.Errorf("image name is too long (max 256 characters)") + } + + // Validate against allowed pattern + if !validImageNamePattern.MatchString(imageName) { + return fmt.Errorf("invalid image name format: contains disallowed characters") + } + + // Additional check for dangerous shell metacharacters + dangerousChars := []string{";", "&", "|", "$", "`", "(", ")", "{", "}", "<", ">", "!", "\\", "\n", "\r", "'", "\""} + for _, char := range dangerousChars { + if strings.Contains(imageName, char) { + return fmt.Errorf("invalid image name: contains disallowed character '%s'", char) + } + } + + return nil +} + +// getTrivyPath returns the path to the Trivy binary or exits if not found +func getTrivyPath() string { + trivyPath, err := exec.LookPath("trivy") + if err != nil { + logger.Error("Trivy not found", logrus.Fields{"error": err.Error()}) + color.Red("❌ Error: Trivy is not installed or not found in PATH") + fmt.Println("Please install Trivy to use container scanning.") + fmt.Println("Visit: https://trivy.dev/latest/getting-started/installation/") + os.Exit(1) + } + logger.Info("Found Trivy", logrus.Fields{"path": trivyPath}) + return trivyPath +} + +// handleTrivyResult processes the Trivy command result and exits appropriately +func handleTrivyResult(err error, imageName string) { + if err == nil { + logger.Info("Container scan completed successfully", logrus.Fields{"image": imageName}) + fmt.Println() + color.Green("✅ Success: No vulnerabilities found matching the specified criteria") + return + } + + if exitError, ok := err.(*exec.ExitError); ok && exitError.ExitCode() == 1 { + logger.Warn("Container scan completed with vulnerabilities", logrus.Fields{ + "image": imageName, "exit_code": 1, + }) + fmt.Println() + color.Red("❌ Scanning failed: vulnerabilities found in the container image") + os.Exit(1) + } + + logger.Error("Failed to run Trivy", logrus.Fields{"error": err.Error()}) + color.Red("❌ Error: Failed to run Trivy: %v", err) + os.Exit(1) +} + +func runContainerScan(_ *cobra.Command, args []string) { + var images []string + + if len(args) > 0 { + images = []string{args[0]} + } else { + images = detectImages() + if len(images) == 0 { + color.Red("❌ Error: No image specified and none found in Dockerfile or docker-compose.yml") + fmt.Println("Usage: codacy-cli container-scan ") + os.Exit(1) + } + } + + scanImages(images) +} + +// scanImages validates and scans multiple images +func scanImages(images []string) { + trivyPath := getTrivyPath() + hasFailures := false + + for _, imageName := range images { + if err := validateImageName(imageName); err != nil { + logger.Error("Invalid image name", logrus.Fields{"image": imageName, "error": err.Error()}) + color.Red("❌ Error: %v", err) + hasFailures = true + continue + } + + logger.Info("Starting container scan", logrus.Fields{"image": imageName}) + fmt.Printf("🔍 Scanning container image: %s\n\n", imageName) + + trivyCmd := exec.Command(trivyPath, buildTrivyArgs(imageName)...) + trivyCmd.Stdout = os.Stdout + trivyCmd.Stderr = os.Stderr + + logger.Info("Running Trivy container scan", logrus.Fields{"command": trivyCmd.String()}) + + if err := trivyCmd.Run(); err != nil { + hasFailures = true + handleScanError(err, imageName) + } else { + logger.Info("Container scan completed successfully", logrus.Fields{"image": imageName}) + fmt.Println() + color.Green("✅ Success: No vulnerabilities found in %s", imageName) + } + + if len(images) > 1 { + fmt.Println("\n" + strings.Repeat("-", 60) + "\n") + } + } + + if hasFailures { + os.Exit(1) + } +} + +// handleScanError processes scan errors without exiting (for multi-image scans) +func handleScanError(err error, imageName string) { + if exitError, ok := err.(*exec.ExitError); ok && exitError.ExitCode() == 1 { + logger.Warn("Container scan completed with vulnerabilities", logrus.Fields{ + "image": imageName, "exit_code": 1, + }) + fmt.Println() + color.Red("❌ Vulnerabilities found in %s", imageName) + return + } + logger.Error("Failed to run Trivy", logrus.Fields{"error": err.Error()}) + color.Red("❌ Error scanning %s: %v", imageName, err) +} + +// printFoundImages displays the found images to the user +func printFoundImages(source string, images []string) { + color.Cyan("📄 Found images in %s:", source) + for _, img := range images { + fmt.Printf(" • %s\n", img) + } + fmt.Println() +} + +// detectImages auto-detects images from Dockerfile or docker-compose.yml +func detectImages() []string { + // Priority 0: Check explicit --dockerfile flag + if dockerfileFlag != "" { + return detectFromDockerfile(dockerfileFlag, true) + } + + // Priority 0: Check explicit --compose-file flag + if composeFileFlag != "" { + return detectFromCompose(composeFileFlag, true) + } + + // Priority 1: Auto-detect Dockerfile in current directory + if images := detectFromDockerfile("Dockerfile", false); images != nil { + return images + } + + // Priority 2: Auto-detect docker-compose files + composeFiles := []string{"docker-compose.yml", "docker-compose.yaml", "compose.yml", "compose.yaml"} + for _, composeFile := range composeFiles { + if images := detectFromCompose(composeFile, false); images != nil { + return images + } + } + + return nil +} + +// detectFromDockerfile tries to detect images from a Dockerfile +func detectFromDockerfile(path string, showWarning bool) []string { + images := parseDockerfile(path) + if len(images) > 0 { + printFoundImages(path, images) + return images + } + if showWarning { + color.Yellow("⚠️ No FROM instructions found in %s", path) + } + return nil +} + +// detectFromCompose tries to detect images from a docker-compose file +func detectFromCompose(path string, showWarning bool) []string { + images := parseDockerCompose(path) + if len(images) > 0 { + printFoundImages(path, images) + return images + } + if showWarning { + color.Yellow("⚠️ No images found in %s", path) + } + return nil +} + +// parseDockerfile extracts FROM images from a Dockerfile +func parseDockerfile(path string) []string { + file, err := os.Open(path) + if err != nil { + return nil + } + defer file.Close() + + var images []string + seen := make(map[string]bool) + scanner := bufio.NewScanner(file) + + for scanner.Scan() { + line := scanner.Text() + matches := dockerfileFromPattern.FindStringSubmatch(line) + if len(matches) > 1 { + image := matches[1] + // Skip build stage aliases (e.g., FROM golang:1.21 AS builder) + // and scratch images + if image != "scratch" && !seen[image] { + seen[image] = true + images = append(images, image) + } + } + } + + return images +} + +// dockerComposeConfig represents the structure of docker-compose.yml +type dockerComposeConfig struct { + Services map[string]struct { + Image string `yaml:"image"` + Build *struct { + Context string `yaml:"context"` + Dockerfile string `yaml:"dockerfile"` + } `yaml:"build"` + } `yaml:"services"` +} + +// parseDockerCompose extracts images from docker-compose.yml +func parseDockerCompose(path string) []string { + data, err := os.ReadFile(path) + if err != nil { + return nil + } + + var config dockerComposeConfig + if err := yaml.Unmarshal(data, &config); err != nil { + logger.Warn("Failed to parse docker-compose file", logrus.Fields{"path": path, "error": err.Error()}) + return nil + } + + var images []string + seen := make(map[string]bool) + + for serviceName, service := range config.Services { + images = processServiceImage(service.Image, images, seen) + images = processServiceBuild(serviceName, service.Build, images, seen) + } + + return images +} + +// processServiceImage adds a service's image to the list if not already seen +func processServiceImage(image string, images []string, seen map[string]bool) []string { + if image != "" && !seen[image] { + seen[image] = true + images = append(images, image) + } + return images +} + +// processServiceBuild extracts images from a service's build context Dockerfile +func processServiceBuild(serviceName string, build *struct { + Context string `yaml:"context"` + Dockerfile string `yaml:"dockerfile"` +}, images []string, seen map[string]bool) []string { + if build == nil { + return images + } + + dockerfilePath := resolveDockerfilePath(build.Context, build.Dockerfile) + dockerfileImages := parseDockerfile(dockerfilePath) + + for _, img := range dockerfileImages { + if !seen[img] { + seen[img] = true + images = append(images, img) + logger.Info("Found base image from Dockerfile", logrus.Fields{ + "service": serviceName, + "dockerfile": dockerfilePath, + "image": img, + }) + } + } + return images +} + +// resolveDockerfilePath constructs the full path to a Dockerfile +func resolveDockerfilePath(context, dockerfile string) string { + path := "Dockerfile" + if dockerfile != "" { + path = dockerfile + } + if context != "" { + path = filepath.Join(context, path) + } + return path +} + +// buildTrivyArgs constructs the Trivy command arguments based on flags +func buildTrivyArgs(imageName string) []string { + args := []string{ + "image", + "--scanners", "vuln", + } + + // Apply --ignore-unfixed if enabled (default: true) + if ignoreUnfixedFlag { + args = append(args, "--ignore-unfixed") + } + + // Apply --severity (use default if not specified) + severity := severityFlag + if severity == "" { + severity = "HIGH,CRITICAL" + } + args = append(args, "--severity", severity) + + // Apply --pkg-types (use default if not specified) + pkgTypes := pkgTypesFlag + if pkgTypes == "" { + pkgTypes = "os" + } + args = append(args, "--pkg-types", pkgTypes) + + // Always apply --exit-code 1 (not user-configurable) + args = append(args, "--exit-code", "1") + + // Add the image name as the last argument + args = append(args, imageName) + + return args +} diff --git a/cmd/container_scan_test.go b/cmd/container_scan_test.go new file mode 100644 index 0000000..10c83f7 --- /dev/null +++ b/cmd/container_scan_test.go @@ -0,0 +1,172 @@ +package cmd + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +// Test cases for buildTrivyArgs +var trivyArgsCases = []struct { + name string + imageName string + severity string + pkgTypes string + ignoreUnfixed bool + expectedArgs []string + expectedContains []string + notContains []string +}{ + { + name: "default flags", + imageName: "myapp:latest", + severity: "", + pkgTypes: "", + ignoreUnfixed: true, + expectedArgs: []string{ + "image", "--scanners", "vuln", "--ignore-unfixed", + "--severity", "HIGH,CRITICAL", "--pkg-types", "os", + "--exit-code", "1", "myapp:latest", + }, + }, + { + name: "custom severity only", imageName: "codacy/engine:1.0.0", + severity: "CRITICAL", pkgTypes: "", ignoreUnfixed: true, + expectedContains: []string{"--severity", "CRITICAL", "--pkg-types", "os"}, + notContains: []string{"HIGH,CRITICAL"}, + }, + { + name: "custom pkg-types only", imageName: "nginx:alpine", + severity: "", pkgTypes: "os,library", ignoreUnfixed: true, + expectedContains: []string{"--severity", "HIGH,CRITICAL", "--pkg-types", "os,library"}, + }, + { + name: "all custom flags", imageName: "ubuntu:22.04", + severity: "LOW,MEDIUM,HIGH,CRITICAL", pkgTypes: "os,library", ignoreUnfixed: true, + expectedContains: []string{"--severity", "LOW,MEDIUM,HIGH,CRITICAL", "--pkg-types", "os,library"}, + }, + { + name: "ignore-unfixed disabled", imageName: "alpine:latest", + severity: "", pkgTypes: "", ignoreUnfixed: false, + expectedContains: []string{"--severity", "HIGH,CRITICAL", "--pkg-types", "os"}, + notContains: []string{"--ignore-unfixed"}, + }, + { + name: "exit-code always present", imageName: "test:v1", + severity: "MEDIUM", pkgTypes: "library", ignoreUnfixed: false, + expectedContains: []string{"--exit-code", "1"}, + }, + { + name: "image with registry prefix", imageName: "ghcr.io/codacy/codacy-cli:latest", + severity: "", pkgTypes: "", ignoreUnfixed: true, + expectedContains: []string{"ghcr.io/codacy/codacy-cli:latest"}, + }, + { + name: "image with digest", imageName: "nginx@sha256:abc123", + severity: "", pkgTypes: "", ignoreUnfixed: true, + expectedContains: []string{"nginx@sha256:abc123"}, + }, +} + +func TestBuildTrivyArgs(t *testing.T) { + for _, tc := range trivyArgsCases { + t.Run(tc.name, func(t *testing.T) { + severityFlag = tc.severity + pkgTypesFlag = tc.pkgTypes + ignoreUnfixedFlag = tc.ignoreUnfixed + + args := buildTrivyArgs(tc.imageName) + + if tc.expectedArgs != nil { + assert.Equal(t, tc.expectedArgs, args, "Args should match exactly") + } + for _, exp := range tc.expectedContains { + assert.Contains(t, args, exp, "Args should contain %s", exp) + } + for _, notExp := range tc.notContains { + assert.NotContains(t, args, notExp, "Args should not contain %s", notExp) + } + assert.Equal(t, tc.imageName, args[len(args)-1], "Image name should be last") + }) + } +} + +func TestBuildTrivyArgsOrder(t *testing.T) { + severityFlag = "" + pkgTypesFlag = "" + ignoreUnfixedFlag = true + + args := buildTrivyArgs("test:latest") + + assert.Equal(t, "image", args[0], "First arg should be 'image'") + assert.Equal(t, "test:latest", args[len(args)-1], "Image name should be last") + + exitCodeIdx := findIndex(args, "--exit-code") + assert.NotEqual(t, -1, exitCodeIdx, "--exit-code should be present") + assert.Equal(t, "1", args[exitCodeIdx+1], "1 should follow --exit-code") +} + +func TestBuildTrivyArgsDefaultsApplied(t *testing.T) { + severityFlag = "" + pkgTypesFlag = "" + ignoreUnfixedFlag = true + + args := buildTrivyArgs("test:latest") + + severityIdx := findIndex(args, "--severity") + assert.NotEqual(t, -1, severityIdx) + assert.Equal(t, "HIGH,CRITICAL", args[severityIdx+1]) + + pkgTypesIdx := findIndex(args, "--pkg-types") + assert.NotEqual(t, -1, pkgTypesIdx) + assert.Equal(t, "os", args[pkgTypesIdx+1]) + + assert.Contains(t, args, "--ignore-unfixed") +} + +func TestContainerScanCommandSkipsValidation(t *testing.T) { + result := shouldSkipValidation("container-scan") + assert.True(t, result, "container-scan should skip validation") +} + +func TestContainerScanCommandArgs(t *testing.T) { + assert.Equal(t, "container-scan [FLAGS] [IMAGE_NAME]", containerScanCmd.Use) + + // Verify Args allows 0 args (auto-detection) + err := containerScanCmd.Args(containerScanCmd, []string{}) + assert.NoError(t, err) + + // Verify Args allows 1 arg + err = containerScanCmd.Args(containerScanCmd, []string{"myapp:latest"}) + assert.NoError(t, err) + + // Verify Args rejects 2+ args + err = containerScanCmd.Args(containerScanCmd, []string{"image1", "image2"}) + assert.Error(t, err) +} + +func TestContainerScanFlagDefaults(t *testing.T) { + flags := map[string]string{ + "severity": "", + "pkg-types": "", + "ignore-unfixed": "true", + "dockerfile": "", + "compose-file": "", + } + + for name, expected := range flags { + flag := containerScanCmd.Flags().Lookup(name) + assert.NotNil(t, flag, "%s flag should exist", name) + assert.Equal(t, expected, flag.DefValue, "%s default should be %s", name, expected) + } +} + +// Helper function to find index of element in slice +func findIndex(slice []string, target string) int { + for i, v := range slice { + if v == target { + return i + } + } + return -1 +} diff --git a/cmd/container_scan_validation_test.go b/cmd/container_scan_validation_test.go new file mode 100644 index 0000000..5e8b998 --- /dev/null +++ b/cmd/container_scan_validation_test.go @@ -0,0 +1,197 @@ +package cmd + +import ( + "os" + "path/filepath" + "testing" + + "codacy/cli-v2/constants" + + "github.com/stretchr/testify/assert" +) + +// Test cases for image name validation +var validImageNameCases = []struct { + name string + imageName string +}{ + {"simple image name", "nginx"}, + {"image with tag", "nginx:latest"}, + {"image with version tag", "nginx:1.21.0"}, + {"image with registry", "docker.io/library/nginx:latest"}, + {"image with private registry", "ghcr.io/codacy/codacy-cli:v1.0.0"}, + {"image with digest", "nginx@sha256:abc123def456"}, + {"image with underscore", "my_app:latest"}, + {"image with hyphen", "my-app:latest"}, + {"image with dots", "my.app:v1.0.0"}, +} + +// Test cases for command injection attempts +var invalidImageNameCases = []struct { + name string + imageName string + errorMsg string +}{ + {"command injection with semicolon", "nginx; rm -rf /", "disallowed character"}, + {"command injection with pipe", "nginx | cat /etc/passwd", "disallowed character"}, + {"command injection with ampersand", "nginx && malicious", "disallowed character"}, + {"command injection with backticks", "nginx`whoami`", "disallowed character"}, + {"command injection with dollar", "nginx$(whoami)", "disallowed character"}, + {"command injection with newline", "nginx\nmalicious", "disallowed character"}, + {"command injection with quotes", "nginx'malicious'", "disallowed character"}, + {"command injection with double quotes", "nginx\"malicious\"", "disallowed character"}, + {"command injection with redirect", "nginx > /tmp/output", "disallowed character"}, + {"command injection with backslash", "nginx\\malicious", "disallowed character"}, + {"empty image name", "", "cannot be empty"}, + {"image name too long", string(make([]byte, 300)), "too long"}, + {"image starting with hyphen", "-nginx", "invalid image name format"}, +} + +func TestValidImageNames(t *testing.T) { + for _, tc := range validImageNameCases { + t.Run(tc.name, func(t *testing.T) { + err := validateImageName(tc.imageName) + assert.NoError(t, err, "Did not expect error for image name: %s", tc.imageName) + }) + } +} + +func TestInvalidImageNames(t *testing.T) { + for _, tc := range invalidImageNameCases { + t.Run(tc.name, func(t *testing.T) { + err := validateImageName(tc.imageName) + assert.Error(t, err, "Expected error for image name: %s", tc.imageName) + if tc.errorMsg != "" { + assert.Contains(t, err.Error(), tc.errorMsg, "Error should contain: %s", tc.errorMsg) + } + }) + } +} + +// Test cases for Dockerfile parsing +var dockerfileParseCases = []struct { + name string + content string + expectedImages []string +}{ + {"simple FROM", "FROM alpine:3.16\nRUN echo hello", []string{"alpine:3.16"}}, + {"FROM with AS", "FROM golang:1.21 AS builder\nRUN go build\nFROM alpine:latest\nCOPY --from=builder /app /app", []string{"golang:1.21", "alpine:latest"}}, + {"multiple FROM stages", "FROM node:18 AS build\nRUN npm install\nFROM nginx:alpine\nCOPY --from=build /app /usr/share/nginx/html", []string{"node:18", "nginx:alpine"}}, + {"FROM with registry", "FROM ghcr.io/codacy/base:1.0.0\nRUN echo test", []string{"ghcr.io/codacy/base:1.0.0"}}, + {"skip scratch", "FROM golang:1.21 AS builder\nRUN go build\nFROM scratch\nCOPY --from=builder /app /app", []string{"golang:1.21"}}, + {"case insensitive FROM", "from ubuntu:22.04\nrun apt-get update", []string{"ubuntu:22.04"}}, + {"empty dockerfile", "", nil}, + {"no FROM instruction", "# Just a comment\nRUN echo hello", nil}, + {"duplicate images", "FROM alpine:3.16\nRUN echo 1\nFROM alpine:3.16\nRUN echo 2", []string{"alpine:3.16"}}, +} + +func TestParseDockerfileContent(t *testing.T) { + for _, tc := range dockerfileParseCases { + t.Run(tc.name, func(t *testing.T) { + tmpDir := t.TempDir() + dockerfilePath := filepath.Join(tmpDir, "Dockerfile") + err := os.WriteFile(dockerfilePath, []byte(tc.content), constants.DefaultFilePerms) + assert.NoError(t, err) + + images := parseDockerfile(dockerfilePath) + assert.Equal(t, tc.expectedImages, images) + }) + } +} + +func TestParseDockerfileNotFound(t *testing.T) { + images := parseDockerfile("/nonexistent/Dockerfile") + assert.Nil(t, images, "Should return nil for nonexistent file") +} + +// Test cases for docker-compose parsing +var dockerComposeParseCases = []struct { + name string + content string + expectedImages []string +}{ + { + "simple service with image", + "services:\n web:\n image: nginx:latest", + []string{"nginx:latest"}, + }, + { + "multiple services with images", + "services:\n web:\n image: nginx:alpine\n db:\n image: postgres:15\n cache:\n image: redis:7", + []string{"nginx:alpine", "postgres:15", "redis:7"}, + }, + { + "service without image", + "services:\n app:\n build: .", + nil, + }, + { + "mixed services", + "services:\n web:\n image: nginx:latest\n app:\n build:\n context: .\n dockerfile: Dockerfile", + []string{"nginx:latest"}, + }, + {"empty compose", "", nil}, + { + "duplicate images", + "services:\n web1:\n image: nginx:latest\n web2:\n image: nginx:latest", + []string{"nginx:latest"}, + }, +} + +func TestParseDockerComposeContent(t *testing.T) { + for _, tc := range dockerComposeParseCases { + t.Run(tc.name, func(t *testing.T) { + tmpDir := t.TempDir() + composePath := filepath.Join(tmpDir, "docker-compose.yml") + err := os.WriteFile(composePath, []byte(tc.content), constants.DefaultFilePerms) + assert.NoError(t, err) + + images := parseDockerCompose(composePath) + + if tc.expectedImages == nil { + assert.Nil(t, images) + } else { + assert.ElementsMatch(t, tc.expectedImages, images) + } + }) + } +} + +func TestParseDockerComposeNotFound(t *testing.T) { + images := parseDockerCompose("/nonexistent/docker-compose.yml") + assert.Nil(t, images, "Should return nil for nonexistent file") +} + +func TestParseDockerComposeWithBuildDockerfile(t *testing.T) { + tmpDir := t.TempDir() + originalDir, _ := os.Getwd() + defer func() { _ = os.Chdir(originalDir) }() + _ = os.Chdir(tmpDir) + + // Create a Dockerfile in a subdirectory + appDir := filepath.Join(tmpDir, "app") + err := os.MkdirAll(appDir, constants.DefaultDirPerms) + assert.NoError(t, err) + + dockerfileContent := "FROM python:3.11\nRUN pip install flask" + err = os.WriteFile(filepath.Join(appDir, "Dockerfile"), []byte(dockerfileContent), constants.DefaultFilePerms) + assert.NoError(t, err) + + // Create docker-compose.yml that references the Dockerfile + composeContent := `services: + api: + build: + context: ./app + dockerfile: Dockerfile + web: + image: nginx:alpine` + + composePath := filepath.Join(tmpDir, "docker-compose.yml") + err = os.WriteFile(composePath, []byte(composeContent), constants.DefaultFilePerms) + assert.NoError(t, err) + + images := parseDockerCompose(composePath) + + assert.Contains(t, images, "nginx:alpine", "Should include direct image reference") + assert.Contains(t, images, "python:3.11", "Should include base image from Dockerfile") +} diff --git a/cmd/validation.go b/cmd/validation.go index ae1bb78..ea3cea7 100644 --- a/cmd/validation.go +++ b/cmd/validation.go @@ -83,6 +83,7 @@ func shouldSkipValidation(cmdName string) bool { "reset", // config reset should work even with empty/invalid codacy.yaml "codacy-cli", // root command when called without subcommands "update", + "container-scan", // container scanning doesn't need codacy.yaml } for _, skipCmd := range skipCommands {