From c8c51829d27e2f53e49469cd4ce44f9976ceef60 Mon Sep 17 00:00:00 2001 From: franciscoazevedo Date: Mon, 19 Jan 2026 14:42:48 +0000 Subject: [PATCH 1/5] add container-scan command to trivy scan containers --- cli-v2.go | 4 +- cmd/container_scan.go | 155 ++++++++++++++++++++++++++++++++++++++++++ cmd/validation.go | 1 + 3 files changed, 158 insertions(+), 2 deletions(-) create mode 100644 cmd/container_scan.go diff --git a/cli-v2.go b/cli-v2.go index bf747c69..6d563e8d 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 00000000..f14bc706 --- /dev/null +++ b/cmd/container_scan.go @@ -0,0 +1,155 @@ +package cmd + +import ( + "fmt" + "os" + "os/exec" + + "codacy/cli-v2/utils/logger" + + "github.com/fatih/color" + "github.com/sirupsen/logrus" + "github.com/spf13/cobra" +) + +// Flag variables for container-scan command +var ( + severityFlag string + pkgTypesFlag string + ignoreUnfixedFlag bool +) + +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") + rootCmd.AddCommand(containerScanCmd) +} + +var containerScanCmd = &cobra.Command{ + Use: "container-scan [FLAGS] ", + 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. + +The --exit-code 1 flag is always applied (not user-configurable) to ensure +the command fails when vulnerabilities are found.`, + Example: ` # Default behavior (HIGH,CRITICAL severity, os packages only) + codacy-cli container-scan myapp:latest + + # Scan only for CRITICAL vulnerabilities + codacy-cli container-scan --severity CRITICAL myapp:latest + + # Scan all severities and package types + codacy-cli container-scan --severity LOW,MEDIUM,HIGH,CRITICAL --pkg-types os,library myapp:latest + + # Include unfixed vulnerabilities + codacy-cli container-scan --ignore-unfixed=false myapp:latest`, + Args: cobra.ExactArgs(1), + Run: runContainerScan, +} + +func runContainerScan(cmd *cobra.Command, args []string) { + imageName := args[0] + + logger.Info("Starting container scan", logrus.Fields{ + "image": imageName, + }) + + // Check if Trivy is installed + 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, + }) + + // Build Trivy command arguments + trivyArgs := buildTrivyArgs(imageName) + + trivyCmd := exec.Command(trivyPath, trivyArgs...) + trivyCmd.Stdout = os.Stdout + trivyCmd.Stderr = os.Stderr + + logger.Info("Running Trivy container scan", logrus.Fields{ + "command": trivyCmd.String(), + }) + + fmt.Printf("🔍 Scanning container image: %s\n\n", imageName) + + err = trivyCmd.Run() + if err != nil { + // Check if the error is due to exit code 1 (vulnerabilities found) + if exitError, ok := err.(*exec.ExitError); ok { + exitCode := exitError.ExitCode() + logger.Warn("Container scan completed with vulnerabilities", logrus.Fields{ + "image": imageName, + "exit_code": exitCode, + }) + if exitCode == 1 { + fmt.Println() + color.Red("❌ Scanning failed: vulnerabilities found in the container image") + os.Exit(1) + } + } + + // Other errors + logger.Error("Failed to run Trivy", logrus.Fields{ + "error": err.Error(), + }) + color.Red("❌ Error: Failed to run Trivy: %v", err) + os.Exit(1) + } + + logger.Info("Container scan completed successfully", logrus.Fields{ + "image": imageName, + }) + + fmt.Println() + color.Green("✅ Success: No vulnerabilities found matching the specified criteria") +} + +// 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/validation.go b/cmd/validation.go index ae1bb784..ea3cea74 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 { From 25dcb9b77949ec27e02879c0a184134bb199aeb4 Mon Sep 17 00:00:00 2001 From: franciscoazevedo Date: Mon, 19 Jan 2026 17:02:31 +0000 Subject: [PATCH 2/5] add tests and codacy suggestion improvments --- cmd/container_scan.go | 122 ++++++----- cmd/container_scan_test.go | 410 +++++++++++++++++++++++++++++++++++++ 2 files changed, 484 insertions(+), 48 deletions(-) create mode 100644 cmd/container_scan_test.go diff --git a/cmd/container_scan.go b/cmd/container_scan.go index f14bc706..c09fbb84 100644 --- a/cmd/container_scan.go +++ b/cmd/container_scan.go @@ -4,6 +4,8 @@ import ( "fmt" "os" "os/exec" + "regexp" + "strings" "codacy/cli-v2/utils/logger" @@ -12,6 +14,11 @@ import ( "github.com/spf13/cobra" ) +// 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._\-/:@]*$`) + // Flag variables for container-scan command var ( severityFlag string @@ -51,72 +58,91 @@ the command fails when vulnerabilities are found.`, Run: runContainerScan, } -func runContainerScan(cmd *cobra.Command, args []string) { - imageName := args[0] +// 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") + } - logger.Info("Starting container scan", logrus.Fields{ - "image": imageName, - }) + // Check for maximum length (Docker has a practical limit) + if len(imageName) > 256 { + return fmt.Errorf("image name is too long (max 256 characters)") + } - // Check if Trivy is installed + // 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(), - }) + 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 +} - logger.Info("Found Trivy", logrus.Fields{ - "path": trivyPath, - }) - - // Build Trivy command arguments - trivyArgs := buildTrivyArgs(imageName) - - trivyCmd := exec.Command(trivyPath, trivyArgs...) - trivyCmd.Stdout = os.Stdout - trivyCmd.Stderr = os.Stderr +// 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 + } - logger.Info("Running Trivy container scan", logrus.Fields{ - "command": trivyCmd.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("❌ Scanning failed: vulnerabilities found in the container image") + os.Exit(1) + } - fmt.Printf("🔍 Scanning container image: %s\n\n", imageName) + logger.Error("Failed to run Trivy", logrus.Fields{"error": err.Error()}) + color.Red("❌ Error: Failed to run Trivy: %v", err) + os.Exit(1) +} - err = trivyCmd.Run() - if err != nil { - // Check if the error is due to exit code 1 (vulnerabilities found) - if exitError, ok := err.(*exec.ExitError); ok { - exitCode := exitError.ExitCode() - logger.Warn("Container scan completed with vulnerabilities", logrus.Fields{ - "image": imageName, - "exit_code": exitCode, - }) - if exitCode == 1 { - fmt.Println() - color.Red("❌ Scanning failed: vulnerabilities found in the container image") - os.Exit(1) - } - } +func runContainerScan(cmd *cobra.Command, args []string) { + imageName := args[0] - // Other errors - logger.Error("Failed to run Trivy", logrus.Fields{ - "error": err.Error(), - }) - color.Red("❌ Error: Failed to run Trivy: %v", err) + if err := validateImageName(imageName); err != nil { + logger.Error("Invalid image name", logrus.Fields{"image": imageName, "error": err.Error()}) + color.Red("❌ Error: %v", err) os.Exit(1) } - logger.Info("Container scan completed successfully", logrus.Fields{ - "image": imageName, - }) + logger.Info("Starting container scan", logrus.Fields{"image": imageName}) + + trivyPath := getTrivyPath() + 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()}) + fmt.Printf("🔍 Scanning container image: %s\n\n", imageName) - fmt.Println() - color.Green("✅ Success: No vulnerabilities found matching the specified criteria") + handleTrivyResult(trivyCmd.Run(), imageName) } // buildTrivyArgs constructs the Trivy command arguments based on flags diff --git a/cmd/container_scan_test.go b/cmd/container_scan_test.go new file mode 100644 index 00000000..a080521b --- /dev/null +++ b/cmd/container_scan_test.go @@ -0,0 +1,410 @@ +package cmd + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestBuildTrivyArgs(t *testing.T) { + tests := []struct { + name string + imageName string + severity string + pkgTypes string + ignoreUnfixed bool + expectedArgs []string + expectedContains []string + expectedNotContains []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", + "--ignore-unfixed", + "codacy/engine:1.0.0", + }, + expectedNotContains: []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", + "nginx:alpine", + }, + }, + { + 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", + "--ignore-unfixed", + "ubuntu:22.04", + }, + }, + { + name: "ignore-unfixed disabled", + imageName: "alpine:latest", + severity: "", + pkgTypes: "", + ignoreUnfixed: false, + expectedContains: []string{ + "--severity", "HIGH,CRITICAL", + "--pkg-types", "os", + "alpine:latest", + }, + expectedNotContains: []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", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Set the global flag variables + severityFlag = tt.severity + pkgTypesFlag = tt.pkgTypes + ignoreUnfixedFlag = tt.ignoreUnfixed + + // Build the args + args := buildTrivyArgs(tt.imageName) + + // Check exact match if expectedArgs is provided + if tt.expectedArgs != nil { + assert.Equal(t, tt.expectedArgs, args, "Args should match exactly") + } + + // Check that expected strings are present + for _, exp := range tt.expectedContains { + assert.Contains(t, args, exp, "Args should contain %s", exp) + } + + // Check that not-expected strings are absent + for _, notExp := range tt.expectedNotContains { + assert.NotContains(t, args, notExp, "Args should not contain %s", notExp) + } + + // Always verify base requirements + assert.Contains(t, args, "image", "First arg should be 'image'") + assert.Contains(t, args, "--scanners", "Should contain --scanners") + assert.Contains(t, args, "vuln", "Should contain 'vuln' scanner") + assert.Contains(t, args, "--exit-code", "Should always contain --exit-code") + assert.Contains(t, args, "1", "Exit code should be 1") + + // Verify image name is always last + assert.Equal(t, tt.imageName, args[len(args)-1], "Image name should be the last argument") + }) + } +} + +func TestBuildTrivyArgsOrder(t *testing.T) { + // Reset flags to defaults + severityFlag = "" + pkgTypesFlag = "" + ignoreUnfixedFlag = true + + args := buildTrivyArgs("test:latest") + + // Verify the order of arguments + // image should be first + assert.Equal(t, "image", args[0], "First arg should be 'image'") + + // image name should be last + assert.Equal(t, "test:latest", args[len(args)-1], "Image name should be last") + + // --exit-code and 1 should be consecutive and before image name + exitCodeIdx := -1 + for i, arg := range args { + if arg == "--exit-code" { + exitCodeIdx = i + break + } + } + assert.NotEqual(t, -1, exitCodeIdx, "--exit-code should be present") + assert.Equal(t, "1", args[exitCodeIdx+1], "1 should follow --exit-code") +} + +func TestContainerScanCommandSkipsValidation(t *testing.T) { + // Test that container-scan is in the skip validation list + result := shouldSkipValidation("container-scan") + assert.True(t, result, "container-scan should skip validation") +} + +func TestContainerScanCommandRequiresArg(t *testing.T) { + // Test that the command requires exactly one argument + assert.Equal(t, "container-scan [FLAGS] ", containerScanCmd.Use, "Command use should match expected format") + + // Verify Args is set to ExactArgs(1) + err := containerScanCmd.Args(containerScanCmd, []string{}) + assert.Error(t, err, "Should error when no args provided") + + err = containerScanCmd.Args(containerScanCmd, []string{"image1", "image2"}) + assert.Error(t, err, "Should error when too many args provided") + + err = containerScanCmd.Args(containerScanCmd, []string{"myapp:latest"}) + assert.NoError(t, err, "Should not error when exactly one arg provided") +} + +func TestContainerScanFlagDefaults(t *testing.T) { + // Get the flags from the command + severityFlagDef := containerScanCmd.Flags().Lookup("severity") + pkgTypesFlagDef := containerScanCmd.Flags().Lookup("pkg-types") + ignoreUnfixedFlagDef := containerScanCmd.Flags().Lookup("ignore-unfixed") + + // Verify flags exist + assert.NotNil(t, severityFlagDef, "severity flag should exist") + assert.NotNil(t, pkgTypesFlagDef, "pkg-types flag should exist") + assert.NotNil(t, ignoreUnfixedFlagDef, "ignore-unfixed flag should exist") + + // Verify default values + assert.Equal(t, "", severityFlagDef.DefValue, "severity default should be empty (uses HIGH,CRITICAL in buildTrivyArgs)") + assert.Equal(t, "", pkgTypesFlagDef.DefValue, "pkg-types default should be empty (uses 'os' in buildTrivyArgs)") + assert.Equal(t, "true", ignoreUnfixedFlagDef.DefValue, "ignore-unfixed default should be true") +} + +func TestValidateImageName(t *testing.T) { + tests := []struct { + name string + imageName string + expectError bool + errorMsg string + }{ + // Valid image names + { + name: "simple image name", + imageName: "nginx", + expectError: false, + }, + { + name: "image with tag", + imageName: "nginx:latest", + expectError: false, + }, + { + name: "image with version tag", + imageName: "nginx:1.21.0", + expectError: false, + }, + { + name: "image with registry", + imageName: "docker.io/library/nginx:latest", + expectError: false, + }, + { + name: "image with private registry", + imageName: "ghcr.io/codacy/codacy-cli:v1.0.0", + expectError: false, + }, + { + name: "image with digest", + imageName: "nginx@sha256:abc123def456", + expectError: false, + }, + { + name: "image with underscore", + imageName: "my_app:latest", + expectError: false, + }, + { + name: "image with hyphen", + imageName: "my-app:latest", + expectError: false, + }, + { + name: "image with dots", + imageName: "my.app:v1.0.0", + expectError: false, + }, + // Invalid image names - command injection attempts + { + name: "command injection with semicolon", + imageName: "nginx; rm -rf /", + expectError: true, + errorMsg: "disallowed character", + }, + { + name: "command injection with pipe", + imageName: "nginx | cat /etc/passwd", + expectError: true, + errorMsg: "disallowed character", + }, + { + name: "command injection with ampersand", + imageName: "nginx && malicious", + expectError: true, + errorMsg: "disallowed character", + }, + { + name: "command injection with backticks", + imageName: "nginx`whoami`", + expectError: true, + errorMsg: "disallowed character", + }, + { + name: "command injection with dollar", + imageName: "nginx$(whoami)", + expectError: true, + errorMsg: "disallowed character", + }, + { + name: "command injection with newline", + imageName: "nginx\nmalicious", + expectError: true, + errorMsg: "disallowed character", + }, + { + name: "command injection with quotes", + imageName: "nginx'malicious'", + expectError: true, + errorMsg: "disallowed character", + }, + { + name: "command injection with double quotes", + imageName: "nginx\"malicious\"", + expectError: true, + errorMsg: "disallowed character", + }, + { + name: "command injection with redirect", + imageName: "nginx > /tmp/output", + expectError: true, + errorMsg: "disallowed character", + }, + { + name: "command injection with backslash", + imageName: "nginx\\malicious", + expectError: true, + errorMsg: "disallowed character", + }, + // Invalid format + { + name: "empty image name", + imageName: "", + expectError: true, + errorMsg: "cannot be empty", + }, + { + name: "image name too long", + imageName: string(make([]byte, 300)), + expectError: true, + errorMsg: "too long", + }, + { + name: "image starting with hyphen", + imageName: "-nginx", + expectError: true, + errorMsg: "invalid image name format", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateImageName(tt.imageName) + + if tt.expectError { + assert.Error(t, err, "Expected error for image name: %s", tt.imageName) + if tt.errorMsg != "" { + assert.Contains(t, err.Error(), tt.errorMsg, "Error message should contain: %s", tt.errorMsg) + } + } else { + assert.NoError(t, err, "Did not expect error for image name: %s", tt.imageName) + } + }) + } +} + +func TestBuildTrivyArgsDefaultsApplied(t *testing.T) { + // Test that when flags are empty, defaults are applied + severityFlag = "" + pkgTypesFlag = "" + ignoreUnfixedFlag = true + + args := buildTrivyArgs("test:latest") + + // Find severity value + severityIdx := -1 + for i, arg := range args { + if arg == "--severity" { + severityIdx = i + break + } + } + assert.NotEqual(t, -1, severityIdx, "--severity should be present") + assert.Equal(t, "HIGH,CRITICAL", args[severityIdx+1], "Default severity should be HIGH,CRITICAL") + + // Find pkg-types value + pkgTypesIdx := -1 + for i, arg := range args { + if arg == "--pkg-types" { + pkgTypesIdx = i + break + } + } + assert.NotEqual(t, -1, pkgTypesIdx, "--pkg-types should be present") + assert.Equal(t, "os", args[pkgTypesIdx+1], "Default pkg-types should be 'os'") + + // Verify --ignore-unfixed is present + assert.Contains(t, args, "--ignore-unfixed", "--ignore-unfixed should be present when enabled") +} From a7ac1456f00bb9c0746b4975615973679c31bf4e Mon Sep 17 00:00:00 2001 From: franciscoazevedo Date: Tue, 20 Jan 2026 13:43:56 +0000 Subject: [PATCH 3/5] search for Dockerfile to auto determine the images to scan --- cmd/container_scan.go | 260 ++++++++++++++++++++++++++++++++++--- cmd/container_scan_test.go | 216 ++++++++++++++++++++++++++++-- 2 files changed, 448 insertions(+), 28 deletions(-) diff --git a/cmd/container_scan.go b/cmd/container_scan.go index c09fbb84..41b650c1 100644 --- a/cmd/container_scan.go +++ b/cmd/container_scan.go @@ -1,9 +1,11 @@ package cmd import ( + "bufio" "fmt" "os" "os/exec" + "path/filepath" "regexp" "strings" @@ -12,6 +14,7 @@ import ( "github.com/fatih/color" "github.com/sirupsen/logrus" "github.com/spf13/cobra" + "gopkg.in/yaml.v3" ) // validImageNamePattern validates Docker image references @@ -19,42 +22,61 @@ import ( // 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] ", + 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: ` # Default behavior (HIGH,CRITICAL severity, os packages only) + 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 - # Scan all severities and package types - codacy-cli container-scan --severity LOW,MEDIUM,HIGH,CRITICAL --pkg-types os,library myapp:latest - - # Include unfixed vulnerabilities - codacy-cli container-scan --ignore-unfixed=false myapp:latest`, - Args: cobra.ExactArgs(1), + # CI/CD example: scan all images before deploy + codacy-cli container-scan --dockerfile ./Dockerfile --severity HIGH,CRITICAL`, + Args: cobra.MaximumNArgs(1), Run: runContainerScan, } @@ -124,25 +146,223 @@ func handleTrivyResult(err error, imageName string) { } func runContainerScan(cmd *cobra.Command, args []string) { - imageName := args[0] + 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 := validateImageName(imageName); err != nil { - logger.Error("Invalid image name", logrus.Fields{"image": imageName, "error": err.Error()}) - color.Red("❌ Error: %v", err) + 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) } +} - logger.Info("Starting container scan", logrus.Fields{"image": imageName}) +// 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) +} - trivyPath := getTrivyPath() - trivyCmd := exec.Command(trivyPath, buildTrivyArgs(imageName)...) - trivyCmd.Stdout = os.Stdout - trivyCmd.Stderr = os.Stderr +// detectImages auto-detects images from Dockerfile or docker-compose.yml +func detectImages() []string { + // Priority 0: Check explicit --dockerfile flag + if dockerfileFlag != "" { + if images := parseDockerfile(dockerfileFlag); len(images) > 0 { + color.Cyan("📄 Found images in %s:", dockerfileFlag) + for _, img := range images { + fmt.Printf(" • %s\n", img) + } + fmt.Println() + return images + } + color.Yellow("⚠️ No FROM instructions found in %s", dockerfileFlag) + return nil + } - logger.Info("Running Trivy container scan", logrus.Fields{"command": trivyCmd.String()}) - fmt.Printf("🔍 Scanning container image: %s\n\n", imageName) + // Priority 0: Check explicit --compose-file flag + if composeFileFlag != "" { + if images := parseDockerCompose(composeFileFlag); len(images) > 0 { + color.Cyan("📄 Found images in %s:", composeFileFlag) + for _, img := range images { + fmt.Printf(" • %s\n", img) + } + fmt.Println() + return images + } + color.Yellow("⚠️ No images found in %s", composeFileFlag) + return nil + } + + // Priority 1: Auto-detect Dockerfile in current directory + if images := parseDockerfile("Dockerfile"); len(images) > 0 { + color.Cyan("📄 Found images in Dockerfile:") + for _, img := range images { + fmt.Printf(" • %s\n", img) + } + fmt.Println() + 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 := parseDockerCompose(composeFile); len(images) > 0 { + color.Cyan("📄 Found images in %s:", composeFile) + for _, img := range images { + fmt.Printf(" • %s\n", img) + } + fmt.Println() + return images + } + } + + 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 { + // If service has an image defined, use it + if service.Image != "" && !seen[service.Image] { + seen[service.Image] = true + images = append(images, service.Image) + } + + // If service has a build context with Dockerfile, parse it + if service.Build != nil { + dockerfilePath := "Dockerfile" + if service.Build.Dockerfile != "" { + dockerfilePath = service.Build.Dockerfile + } + if service.Build.Context != "" { + dockerfilePath = filepath.Join(service.Build.Context, dockerfilePath) + } + + if dockerfileImages := parseDockerfile(dockerfilePath); len(dockerfileImages) > 0 { + 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, + }) + } + } + } + } + } - handleTrivyResult(trivyCmd.Run(), imageName) + return images } // buildTrivyArgs constructs the Trivy command arguments based on flags diff --git a/cmd/container_scan_test.go b/cmd/container_scan_test.go index a080521b..be36aa83 100644 --- a/cmd/container_scan_test.go +++ b/cmd/container_scan_test.go @@ -1,6 +1,8 @@ package cmd import ( + "os" + "path/filepath" "testing" "github.com/stretchr/testify/assert" @@ -192,19 +194,21 @@ func TestContainerScanCommandSkipsValidation(t *testing.T) { assert.True(t, result, "container-scan should skip validation") } -func TestContainerScanCommandRequiresArg(t *testing.T) { - // Test that the command requires exactly one argument - assert.Equal(t, "container-scan [FLAGS] ", containerScanCmd.Use, "Command use should match expected format") +func TestContainerScanCommandArgs(t *testing.T) { + // Test that the command accepts 0 or 1 arguments (MaximumNArgs(1)) + assert.Equal(t, "container-scan [FLAGS] [IMAGE_NAME]", containerScanCmd.Use, "Command use should match expected format") - // Verify Args is set to ExactArgs(1) + // Verify Args allows 0 args (for auto-detection) err := containerScanCmd.Args(containerScanCmd, []string{}) - assert.Error(t, err, "Should error when no args provided") + assert.NoError(t, err, "Should not error when no args provided (auto-detection mode)") + // Verify Args allows 1 arg + err = containerScanCmd.Args(containerScanCmd, []string{"myapp:latest"}) + assert.NoError(t, err, "Should not error when one arg provided") + + // Verify Args rejects 2+ args err = containerScanCmd.Args(containerScanCmd, []string{"image1", "image2"}) assert.Error(t, err, "Should error when too many args provided") - - err = containerScanCmd.Args(containerScanCmd, []string{"myapp:latest"}) - assert.NoError(t, err, "Should not error when exactly one arg provided") } func TestContainerScanFlagDefaults(t *testing.T) { @@ -212,16 +216,22 @@ func TestContainerScanFlagDefaults(t *testing.T) { severityFlagDef := containerScanCmd.Flags().Lookup("severity") pkgTypesFlagDef := containerScanCmd.Flags().Lookup("pkg-types") ignoreUnfixedFlagDef := containerScanCmd.Flags().Lookup("ignore-unfixed") + dockerfileFlagDef := containerScanCmd.Flags().Lookup("dockerfile") + composeFileFlagDef := containerScanCmd.Flags().Lookup("compose-file") // Verify flags exist assert.NotNil(t, severityFlagDef, "severity flag should exist") assert.NotNil(t, pkgTypesFlagDef, "pkg-types flag should exist") assert.NotNil(t, ignoreUnfixedFlagDef, "ignore-unfixed flag should exist") + assert.NotNil(t, dockerfileFlagDef, "dockerfile flag should exist") + assert.NotNil(t, composeFileFlagDef, "compose-file flag should exist") // Verify default values assert.Equal(t, "", severityFlagDef.DefValue, "severity default should be empty (uses HIGH,CRITICAL in buildTrivyArgs)") assert.Equal(t, "", pkgTypesFlagDef.DefValue, "pkg-types default should be empty (uses 'os' in buildTrivyArgs)") assert.Equal(t, "true", ignoreUnfixedFlagDef.DefValue, "ignore-unfixed default should be true") + assert.Equal(t, "", dockerfileFlagDef.DefValue, "dockerfile default should be empty") + assert.Equal(t, "", composeFileFlagDef.DefValue, "compose-file default should be empty") } func TestValidateImageName(t *testing.T) { @@ -375,6 +385,196 @@ func TestValidateImageName(t *testing.T) { } } +func TestParseDockerfile(t *testing.T) { + tests := []struct { + name string + content string + expectedImages []string + }{ + { + name: "simple FROM", + content: "FROM alpine:3.16\nRUN echo hello", + expectedImages: []string{"alpine:3.16"}, + }, + { + name: "FROM with AS", + content: "FROM golang:1.21 AS builder\nRUN go build\nFROM alpine:latest\nCOPY --from=builder /app /app", + expectedImages: []string{"golang:1.21", "alpine:latest"}, + }, + { + name: "multiple FROM stages", + content: "FROM node:18 AS build\nRUN npm install\nFROM nginx:alpine\nCOPY --from=build /app /usr/share/nginx/html", + expectedImages: []string{"node:18", "nginx:alpine"}, + }, + { + name: "FROM with registry", + content: "FROM ghcr.io/codacy/base:1.0.0\nRUN echo test", + expectedImages: []string{"ghcr.io/codacy/base:1.0.0"}, + }, + { + name: "skip scratch", + content: "FROM golang:1.21 AS builder\nRUN go build\nFROM scratch\nCOPY --from=builder /app /app", + expectedImages: []string{"golang:1.21"}, + }, + { + name: "case insensitive FROM", + content: "from ubuntu:22.04\nrun apt-get update", + expectedImages: []string{"ubuntu:22.04"}, + }, + { + name: "empty dockerfile", + content: "", + expectedImages: nil, + }, + { + name: "no FROM instruction", + content: "# Just a comment\nRUN echo hello", + expectedImages: nil, + }, + { + name: "duplicate images", + content: "FROM alpine:3.16\nRUN echo 1\nFROM alpine:3.16\nRUN echo 2", + expectedImages: []string{"alpine:3.16"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create temp directory and Dockerfile + tmpDir := t.TempDir() + dockerfilePath := filepath.Join(tmpDir, "Dockerfile") + err := os.WriteFile(dockerfilePath, []byte(tt.content), 0644) + assert.NoError(t, err) + + images := parseDockerfile(dockerfilePath) + assert.Equal(t, tt.expectedImages, images) + }) + } +} + +func TestParseDockerfileNotFound(t *testing.T) { + images := parseDockerfile("/nonexistent/Dockerfile") + assert.Nil(t, images, "Should return nil for nonexistent file") +} + +func TestParseDockerCompose(t *testing.T) { + tests := []struct { + name string + content string + expectedImages []string + }{ + { + name: "simple service with image", + content: `services: + web: + image: nginx:latest`, + expectedImages: []string{"nginx:latest"}, + }, + { + name: "multiple services with images", + content: `services: + web: + image: nginx:alpine + db: + image: postgres:15 + cache: + image: redis:7`, + expectedImages: []string{"nginx:alpine", "postgres:15", "redis:7"}, + }, + { + name: "service without image (build only)", + content: `services: + app: + build: .`, + expectedImages: nil, + }, + { + name: "mixed services", + content: `services: + web: + image: nginx:latest + app: + build: + context: . + dockerfile: Dockerfile`, + expectedImages: []string{"nginx:latest"}, + }, + { + name: "empty compose", + content: "", + expectedImages: nil, + }, + { + name: "duplicate images", + content: `services: + web1: + image: nginx:latest + web2: + image: nginx:latest`, + expectedImages: []string{"nginx:latest"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tmpDir := t.TempDir() + composePath := filepath.Join(tmpDir, "docker-compose.yml") + err := os.WriteFile(composePath, []byte(tt.content), 0644) + assert.NoError(t, err) + + images := parseDockerCompose(composePath) + + // Sort both slices for comparison since map iteration order is random + if tt.expectedImages == nil { + assert.Nil(t, images) + } else { + assert.ElementsMatch(t, tt.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) { + // Create temp directory and change to it for relative path resolution + tmpDir := t.TempDir() + originalDir, _ := os.Getwd() + defer os.Chdir(originalDir) + os.Chdir(tmpDir) + + // Create a Dockerfile in a subdirectory + appDir := filepath.Join(tmpDir, "app") + err := os.MkdirAll(appDir, 0755) + assert.NoError(t, err) + + dockerfileContent := "FROM python:3.11\nRUN pip install flask" + err = os.WriteFile(filepath.Join(appDir, "Dockerfile"), []byte(dockerfileContent), 0644) + 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), 0644) + assert.NoError(t, err) + + images := parseDockerCompose(composePath) + + // Should include both the direct image and the base image from Dockerfile + assert.Contains(t, images, "nginx:alpine", "Should include direct image reference") + assert.Contains(t, images, "python:3.11", "Should include base image from Dockerfile") +} + func TestBuildTrivyArgsDefaultsApplied(t *testing.T) { // Test that when flags are empty, defaults are applied severityFlag = "" From 844da9628fe3de7d38d1840b6b4968658fecaba8 Mon Sep 17 00:00:00 2001 From: franciscoazevedo Date: Tue, 20 Jan 2026 14:20:12 +0000 Subject: [PATCH 4/5] codacy issues fix commit --- .codacy/codacy.yaml | 12 +- cmd/container_scan.go | 148 +++--- cmd/container_scan_test.go | 664 +++++--------------------- cmd/container_scan_validation_test.go | 197 ++++++++ cmd/doc.go | 4 + 5 files changed, 407 insertions(+), 618 deletions(-) create mode 100644 cmd/container_scan_validation_test.go create mode 100644 cmd/doc.go diff --git a/.codacy/codacy.yaml b/.codacy/codacy.yaml index 9929d638..15365c77 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/cmd/container_scan.go b/cmd/container_scan.go index 41b650c1..4c10715d 100644 --- a/cmd/container_scan.go +++ b/cmd/container_scan.go @@ -145,7 +145,7 @@ func handleTrivyResult(err error, imageName string) { os.Exit(1) } -func runContainerScan(cmd *cobra.Command, args []string) { +func runContainerScan(_ *cobra.Command, args []string) { var images []string if len(args) > 0 { @@ -217,55 +217,36 @@ func handleScanError(err error, imageName string) { 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 != "" { - if images := parseDockerfile(dockerfileFlag); len(images) > 0 { - color.Cyan("📄 Found images in %s:", dockerfileFlag) - for _, img := range images { - fmt.Printf(" • %s\n", img) - } - fmt.Println() - return images - } - color.Yellow("⚠️ No FROM instructions found in %s", dockerfileFlag) - return nil + return detectFromDockerfile(dockerfileFlag, true) } // Priority 0: Check explicit --compose-file flag if composeFileFlag != "" { - if images := parseDockerCompose(composeFileFlag); len(images) > 0 { - color.Cyan("📄 Found images in %s:", composeFileFlag) - for _, img := range images { - fmt.Printf(" • %s\n", img) - } - fmt.Println() - return images - } - color.Yellow("⚠️ No images found in %s", composeFileFlag) - return nil + return detectFromCompose(composeFileFlag, true) } // Priority 1: Auto-detect Dockerfile in current directory - if images := parseDockerfile("Dockerfile"); len(images) > 0 { - color.Cyan("📄 Found images in Dockerfile:") - for _, img := range images { - fmt.Printf(" • %s\n", img) - } - fmt.Println() + 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 := parseDockerCompose(composeFile); len(images) > 0 { - color.Cyan("📄 Found images in %s:", composeFile) - for _, img := range images { - fmt.Printf(" • %s\n", img) - } - fmt.Println() + if images := detectFromCompose(composeFile, false); images != nil { return images } } @@ -273,6 +254,32 @@ func detectImages() []string { 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) @@ -330,41 +337,60 @@ func parseDockerCompose(path string) []string { seen := make(map[string]bool) for serviceName, service := range config.Services { - // If service has an image defined, use it - if service.Image != "" && !seen[service.Image] { - seen[service.Image] = true - images = append(images, service.Image) - } + images = processServiceImage(service.Image, images, seen) + images = processServiceBuild(serviceName, service.Build, images, seen) + } - // If service has a build context with Dockerfile, parse it - if service.Build != nil { - dockerfilePath := "Dockerfile" - if service.Build.Dockerfile != "" { - dockerfilePath = service.Build.Dockerfile - } - if service.Build.Context != "" { - dockerfilePath = filepath.Join(service.Build.Context, dockerfilePath) - } + return images +} - if dockerfileImages := parseDockerfile(dockerfilePath); len(dockerfileImages) > 0 { - 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, - }) - } - } - } - } +// 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{ diff --git a/cmd/container_scan_test.go b/cmd/container_scan_test.go index be36aa83..10c83f75 100644 --- a/cmd/container_scan_test.go +++ b/cmd/container_scan_test.go @@ -1,610 +1,172 @@ package cmd import ( - "os" - "path/filepath" "testing" "github.com/stretchr/testify/assert" ) -func TestBuildTrivyArgs(t *testing.T) { - tests := []struct { - name string - imageName string - severity string - pkgTypes string - ignoreUnfixed bool - expectedArgs []string - expectedContains []string - expectedNotContains []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", - "--ignore-unfixed", - "codacy/engine:1.0.0", - }, - expectedNotContains: []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", - "nginx:alpine", - }, - }, - { - 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", - "--ignore-unfixed", - "ubuntu:22.04", - }, - }, - { - name: "ignore-unfixed disabled", - imageName: "alpine:latest", - severity: "", - pkgTypes: "", - ignoreUnfixed: false, - expectedContains: []string{ - "--severity", "HIGH,CRITICAL", - "--pkg-types", "os", - "alpine:latest", - }, - expectedNotContains: []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", - }, - }, - } +// 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"}, + }, +} - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // Set the global flag variables - severityFlag = tt.severity - pkgTypesFlag = tt.pkgTypes - ignoreUnfixedFlag = tt.ignoreUnfixed +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 - // Build the args - args := buildTrivyArgs(tt.imageName) + args := buildTrivyArgs(tc.imageName) - // Check exact match if expectedArgs is provided - if tt.expectedArgs != nil { - assert.Equal(t, tt.expectedArgs, args, "Args should match exactly") + if tc.expectedArgs != nil { + assert.Equal(t, tc.expectedArgs, args, "Args should match exactly") } - - // Check that expected strings are present - for _, exp := range tt.expectedContains { + for _, exp := range tc.expectedContains { assert.Contains(t, args, exp, "Args should contain %s", exp) } - - // Check that not-expected strings are absent - for _, notExp := range tt.expectedNotContains { + for _, notExp := range tc.notContains { assert.NotContains(t, args, notExp, "Args should not contain %s", notExp) } - - // Always verify base requirements - assert.Contains(t, args, "image", "First arg should be 'image'") - assert.Contains(t, args, "--scanners", "Should contain --scanners") - assert.Contains(t, args, "vuln", "Should contain 'vuln' scanner") - assert.Contains(t, args, "--exit-code", "Should always contain --exit-code") - assert.Contains(t, args, "1", "Exit code should be 1") - - // Verify image name is always last - assert.Equal(t, tt.imageName, args[len(args)-1], "Image name should be the last argument") + assert.Equal(t, tc.imageName, args[len(args)-1], "Image name should be last") }) } } func TestBuildTrivyArgsOrder(t *testing.T) { - // Reset flags to defaults severityFlag = "" pkgTypesFlag = "" ignoreUnfixedFlag = true args := buildTrivyArgs("test:latest") - // Verify the order of arguments - // image should be first assert.Equal(t, "image", args[0], "First arg should be 'image'") - - // image name should be last assert.Equal(t, "test:latest", args[len(args)-1], "Image name should be last") - // --exit-code and 1 should be consecutive and before image name - exitCodeIdx := -1 - for i, arg := range args { - if arg == "--exit-code" { - exitCodeIdx = i - break - } - } + 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) { - // Test that container-scan is in the skip validation list result := shouldSkipValidation("container-scan") assert.True(t, result, "container-scan should skip validation") } func TestContainerScanCommandArgs(t *testing.T) { - // Test that the command accepts 0 or 1 arguments (MaximumNArgs(1)) - assert.Equal(t, "container-scan [FLAGS] [IMAGE_NAME]", containerScanCmd.Use, "Command use should match expected format") + assert.Equal(t, "container-scan [FLAGS] [IMAGE_NAME]", containerScanCmd.Use) - // Verify Args allows 0 args (for auto-detection) + // Verify Args allows 0 args (auto-detection) err := containerScanCmd.Args(containerScanCmd, []string{}) - assert.NoError(t, err, "Should not error when no args provided (auto-detection mode)") + assert.NoError(t, err) // Verify Args allows 1 arg err = containerScanCmd.Args(containerScanCmd, []string{"myapp:latest"}) - assert.NoError(t, err, "Should not error when one arg provided") + assert.NoError(t, err) // Verify Args rejects 2+ args err = containerScanCmd.Args(containerScanCmd, []string{"image1", "image2"}) - assert.Error(t, err, "Should error when too many args provided") + assert.Error(t, err) } func TestContainerScanFlagDefaults(t *testing.T) { - // Get the flags from the command - severityFlagDef := containerScanCmd.Flags().Lookup("severity") - pkgTypesFlagDef := containerScanCmd.Flags().Lookup("pkg-types") - ignoreUnfixedFlagDef := containerScanCmd.Flags().Lookup("ignore-unfixed") - dockerfileFlagDef := containerScanCmd.Flags().Lookup("dockerfile") - composeFileFlagDef := containerScanCmd.Flags().Lookup("compose-file") - - // Verify flags exist - assert.NotNil(t, severityFlagDef, "severity flag should exist") - assert.NotNil(t, pkgTypesFlagDef, "pkg-types flag should exist") - assert.NotNil(t, ignoreUnfixedFlagDef, "ignore-unfixed flag should exist") - assert.NotNil(t, dockerfileFlagDef, "dockerfile flag should exist") - assert.NotNil(t, composeFileFlagDef, "compose-file flag should exist") - - // Verify default values - assert.Equal(t, "", severityFlagDef.DefValue, "severity default should be empty (uses HIGH,CRITICAL in buildTrivyArgs)") - assert.Equal(t, "", pkgTypesFlagDef.DefValue, "pkg-types default should be empty (uses 'os' in buildTrivyArgs)") - assert.Equal(t, "true", ignoreUnfixedFlagDef.DefValue, "ignore-unfixed default should be true") - assert.Equal(t, "", dockerfileFlagDef.DefValue, "dockerfile default should be empty") - assert.Equal(t, "", composeFileFlagDef.DefValue, "compose-file default should be empty") -} - -func TestValidateImageName(t *testing.T) { - tests := []struct { - name string - imageName string - expectError bool - errorMsg string - }{ - // Valid image names - { - name: "simple image name", - imageName: "nginx", - expectError: false, - }, - { - name: "image with tag", - imageName: "nginx:latest", - expectError: false, - }, - { - name: "image with version tag", - imageName: "nginx:1.21.0", - expectError: false, - }, - { - name: "image with registry", - imageName: "docker.io/library/nginx:latest", - expectError: false, - }, - { - name: "image with private registry", - imageName: "ghcr.io/codacy/codacy-cli:v1.0.0", - expectError: false, - }, - { - name: "image with digest", - imageName: "nginx@sha256:abc123def456", - expectError: false, - }, - { - name: "image with underscore", - imageName: "my_app:latest", - expectError: false, - }, - { - name: "image with hyphen", - imageName: "my-app:latest", - expectError: false, - }, - { - name: "image with dots", - imageName: "my.app:v1.0.0", - expectError: false, - }, - // Invalid image names - command injection attempts - { - name: "command injection with semicolon", - imageName: "nginx; rm -rf /", - expectError: true, - errorMsg: "disallowed character", - }, - { - name: "command injection with pipe", - imageName: "nginx | cat /etc/passwd", - expectError: true, - errorMsg: "disallowed character", - }, - { - name: "command injection with ampersand", - imageName: "nginx && malicious", - expectError: true, - errorMsg: "disallowed character", - }, - { - name: "command injection with backticks", - imageName: "nginx`whoami`", - expectError: true, - errorMsg: "disallowed character", - }, - { - name: "command injection with dollar", - imageName: "nginx$(whoami)", - expectError: true, - errorMsg: "disallowed character", - }, - { - name: "command injection with newline", - imageName: "nginx\nmalicious", - expectError: true, - errorMsg: "disallowed character", - }, - { - name: "command injection with quotes", - imageName: "nginx'malicious'", - expectError: true, - errorMsg: "disallowed character", - }, - { - name: "command injection with double quotes", - imageName: "nginx\"malicious\"", - expectError: true, - errorMsg: "disallowed character", - }, - { - name: "command injection with redirect", - imageName: "nginx > /tmp/output", - expectError: true, - errorMsg: "disallowed character", - }, - { - name: "command injection with backslash", - imageName: "nginx\\malicious", - expectError: true, - errorMsg: "disallowed character", - }, - // Invalid format - { - name: "empty image name", - imageName: "", - expectError: true, - errorMsg: "cannot be empty", - }, - { - name: "image name too long", - imageName: string(make([]byte, 300)), - expectError: true, - errorMsg: "too long", - }, - { - name: "image starting with hyphen", - imageName: "-nginx", - expectError: true, - errorMsg: "invalid image name format", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - err := validateImageName(tt.imageName) - - if tt.expectError { - assert.Error(t, err, "Expected error for image name: %s", tt.imageName) - if tt.errorMsg != "" { - assert.Contains(t, err.Error(), tt.errorMsg, "Error message should contain: %s", tt.errorMsg) - } - } else { - assert.NoError(t, err, "Did not expect error for image name: %s", tt.imageName) - } - }) - } -} - -func TestParseDockerfile(t *testing.T) { - tests := []struct { - name string - content string - expectedImages []string - }{ - { - name: "simple FROM", - content: "FROM alpine:3.16\nRUN echo hello", - expectedImages: []string{"alpine:3.16"}, - }, - { - name: "FROM with AS", - content: "FROM golang:1.21 AS builder\nRUN go build\nFROM alpine:latest\nCOPY --from=builder /app /app", - expectedImages: []string{"golang:1.21", "alpine:latest"}, - }, - { - name: "multiple FROM stages", - content: "FROM node:18 AS build\nRUN npm install\nFROM nginx:alpine\nCOPY --from=build /app /usr/share/nginx/html", - expectedImages: []string{"node:18", "nginx:alpine"}, - }, - { - name: "FROM with registry", - content: "FROM ghcr.io/codacy/base:1.0.0\nRUN echo test", - expectedImages: []string{"ghcr.io/codacy/base:1.0.0"}, - }, - { - name: "skip scratch", - content: "FROM golang:1.21 AS builder\nRUN go build\nFROM scratch\nCOPY --from=builder /app /app", - expectedImages: []string{"golang:1.21"}, - }, - { - name: "case insensitive FROM", - content: "from ubuntu:22.04\nrun apt-get update", - expectedImages: []string{"ubuntu:22.04"}, - }, - { - name: "empty dockerfile", - content: "", - expectedImages: nil, - }, - { - name: "no FROM instruction", - content: "# Just a comment\nRUN echo hello", - expectedImages: nil, - }, - { - name: "duplicate images", - content: "FROM alpine:3.16\nRUN echo 1\nFROM alpine:3.16\nRUN echo 2", - expectedImages: []string{"alpine:3.16"}, - }, + flags := map[string]string{ + "severity": "", + "pkg-types": "", + "ignore-unfixed": "true", + "dockerfile": "", + "compose-file": "", } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // Create temp directory and Dockerfile - tmpDir := t.TempDir() - dockerfilePath := filepath.Join(tmpDir, "Dockerfile") - err := os.WriteFile(dockerfilePath, []byte(tt.content), 0644) - assert.NoError(t, err) - - images := parseDockerfile(dockerfilePath) - assert.Equal(t, tt.expectedImages, images) - }) + 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) } } -func TestParseDockerfileNotFound(t *testing.T) { - images := parseDockerfile("/nonexistent/Dockerfile") - assert.Nil(t, images, "Should return nil for nonexistent file") -} - -func TestParseDockerCompose(t *testing.T) { - tests := []struct { - name string - content string - expectedImages []string - }{ - { - name: "simple service with image", - content: `services: - web: - image: nginx:latest`, - expectedImages: []string{"nginx:latest"}, - }, - { - name: "multiple services with images", - content: `services: - web: - image: nginx:alpine - db: - image: postgres:15 - cache: - image: redis:7`, - expectedImages: []string{"nginx:alpine", "postgres:15", "redis:7"}, - }, - { - name: "service without image (build only)", - content: `services: - app: - build: .`, - expectedImages: nil, - }, - { - name: "mixed services", - content: `services: - web: - image: nginx:latest - app: - build: - context: . - dockerfile: Dockerfile`, - expectedImages: []string{"nginx:latest"}, - }, - { - name: "empty compose", - content: "", - expectedImages: nil, - }, - { - name: "duplicate images", - content: `services: - web1: - image: nginx:latest - web2: - image: nginx:latest`, - expectedImages: []string{"nginx:latest"}, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - tmpDir := t.TempDir() - composePath := filepath.Join(tmpDir, "docker-compose.yml") - err := os.WriteFile(composePath, []byte(tt.content), 0644) - assert.NoError(t, err) - - images := parseDockerCompose(composePath) - - // Sort both slices for comparison since map iteration order is random - if tt.expectedImages == nil { - assert.Nil(t, images) - } else { - assert.ElementsMatch(t, tt.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) { - // Create temp directory and change to it for relative path resolution - tmpDir := t.TempDir() - originalDir, _ := os.Getwd() - defer os.Chdir(originalDir) - os.Chdir(tmpDir) - - // Create a Dockerfile in a subdirectory - appDir := filepath.Join(tmpDir, "app") - err := os.MkdirAll(appDir, 0755) - assert.NoError(t, err) - - dockerfileContent := "FROM python:3.11\nRUN pip install flask" - err = os.WriteFile(filepath.Join(appDir, "Dockerfile"), []byte(dockerfileContent), 0644) - 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), 0644) - assert.NoError(t, err) - - images := parseDockerCompose(composePath) - - // Should include both the direct image and the base image from Dockerfile - assert.Contains(t, images, "nginx:alpine", "Should include direct image reference") - assert.Contains(t, images, "python:3.11", "Should include base image from Dockerfile") -} - -func TestBuildTrivyArgsDefaultsApplied(t *testing.T) { - // Test that when flags are empty, defaults are applied - severityFlag = "" - pkgTypesFlag = "" - ignoreUnfixedFlag = true - - args := buildTrivyArgs("test:latest") - - // Find severity value - severityIdx := -1 - for i, arg := range args { - if arg == "--severity" { - severityIdx = i - break +// 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 } } - assert.NotEqual(t, -1, severityIdx, "--severity should be present") - assert.Equal(t, "HIGH,CRITICAL", args[severityIdx+1], "Default severity should be HIGH,CRITICAL") - - // Find pkg-types value - pkgTypesIdx := -1 - for i, arg := range args { - if arg == "--pkg-types" { - pkgTypesIdx = i - break - } - } - assert.NotEqual(t, -1, pkgTypesIdx, "--pkg-types should be present") - assert.Equal(t, "os", args[pkgTypesIdx+1], "Default pkg-types should be 'os'") - - // Verify --ignore-unfixed is present - assert.Contains(t, args, "--ignore-unfixed", "--ignore-unfixed should be present when enabled") + return -1 } diff --git a/cmd/container_scan_validation_test.go b/cmd/container_scan_validation_test.go new file mode 100644 index 00000000..5e8b9989 --- /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/doc.go b/cmd/doc.go new file mode 100644 index 00000000..4583f5e4 --- /dev/null +++ b/cmd/doc.go @@ -0,0 +1,4 @@ +// Package cmd implements the CLI commands for Codacy CLI. +// It provides commands for code analysis, configuration management, +// container scanning, and integration with Codacy's quality platform. +package cmd From 41cedbc4b18223555e121245ec7186a34dccc3ca Mon Sep 17 00:00:00 2001 From: franciscoazevedo Date: Tue, 20 Jan 2026 14:46:57 +0000 Subject: [PATCH 5/5] codacy issues fix commit 2 --- cmd/container_scan.go | 1 + cmd/doc.go | 4 ---- 2 files changed, 1 insertion(+), 4 deletions(-) delete mode 100644 cmd/doc.go diff --git a/cmd/container_scan.go b/cmd/container_scan.go index 4c10715d..5b904190 100644 --- a/cmd/container_scan.go +++ b/cmd/container_scan.go @@ -1,3 +1,4 @@ +// Package cmd implements the CLI commands for Codacy CLI. package cmd import ( diff --git a/cmd/doc.go b/cmd/doc.go deleted file mode 100644 index 4583f5e4..00000000 --- a/cmd/doc.go +++ /dev/null @@ -1,4 +0,0 @@ -// Package cmd implements the CLI commands for Codacy CLI. -// It provides commands for code analysis, configuration management, -// container scanning, and integration with Codacy's quality platform. -package cmd