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..ed8ade5 --- /dev/null +++ b/cmd/container_scan.go @@ -0,0 +1,264 @@ +// Package cmd implements the CLI commands for the Codacy CLI tool. +package cmd + +import ( + "fmt" + "os" + "os/exec" + "regexp" + "strings" + + "codacy/cli-v2/config" + config_file "codacy/cli-v2/config-file" + "codacy/cli-v2/utils/logger" + + "github.com/fatih/color" + "github.com/sirupsen/logrus" + "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._\-/:@]*$`) + +// exitFunc is a variable to allow mocking os.Exit in tests +var exitFunc = os.Exit + +// CommandRunner interface for running external commands (allows mocking in tests) +type CommandRunner interface { + Run(name string, args []string) error +} + +// ExecCommandRunner runs commands using exec.Command +type ExecCommandRunner struct{} + +// Run executes a command and returns its exit error +func (r *ExecCommandRunner) Run(name string, args []string) error { + // #nosec G204 -- name comes from config (codacy-installed Trivy path), + // and args are validated by validateImageName() which checks for shell metacharacters. + // exec.Command passes arguments directly without shell interpretation. + cmd := exec.Command(name, args...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} + +// commandRunner is the default command runner, can be replaced in tests +var commandRunner CommandRunner = &ExecCommandRunner{} + +// ExitCoder interface for errors that have an exit code +type ExitCoder interface { + ExitCode() int +} + +// getExitCode returns the exit code from an error if it implements ExitCoder +func getExitCode(err error) int { + if exitErr, ok := err.(ExitCoder); ok { + return exitErr.ExitCode() + } + return -1 +} + +// 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 ", + Short: "Scan a container image for vulnerabilities using Trivy", + Long: `Scan a container image 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: ` # Scan an 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), + 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)") + } + + // Check for dangerous shell metacharacters first for specific error messages + dangerousChars := []string{";", "&", "|", "$", "`", "(", ")", "{", "}", "<", ">", "!", "\\", "\n", "\r", "'", "\""} + for _, char := range dangerousChars { + if strings.Contains(imageName, char) { + return fmt.Errorf("invalid image name: contains disallowed character '%s'", char) + } + } + + // Validate against allowed pattern for any other invalid characters + if !validImageNamePattern.MatchString(imageName) { + return fmt.Errorf("invalid image name format: contains disallowed characters") + } + + return nil +} + +// getTrivyPathResolver is set by tests to mock Trivy path resolution; when nil, real config/install logic is used +var getTrivyPathResolver func() (string, error) + +// getTrivyPath returns the path to the Trivy binary (codacy-installed, installed on demand if needed) and an error if not found +func getTrivyPath() (string, error) { + if getTrivyPathResolver != nil { + return getTrivyPathResolver() + } + if err := config.Config.CreateCodacyDirs(); err != nil { + return "", fmt.Errorf("failed to create codacy directories: %w", err) + } + _ = config_file.ReadConfigFile(config.Config.ProjectConfigFile()) + tool := config.Config.Tools()["trivy"] + if tool == nil || !config.Config.IsToolInstalled("trivy", tool) { + if err := config.InstallTool("trivy", tool, ""); err != nil { + return "", fmt.Errorf("failed to install Trivy: %w", err) + } + tool = config.Config.Tools()["trivy"] + } + if tool == nil { + return "", fmt.Errorf("trivy not in config after install") + } + trivyPath, ok := tool.Binaries["trivy"] + if !ok || trivyPath == "" { + return "", fmt.Errorf("trivy binary path not found") + } + logger.Info("Found Trivy", logrus.Fields{"path": trivyPath}) + return trivyPath, nil +} + +// handleTrivyNotFound prints error message and exits with code 2 +func handleTrivyNotFound(err error) { + logger.Error("Trivy not found", logrus.Fields{"error": err.Error()}) + color.Red("❌ Error: Trivy could not be installed or found") + fmt.Println("Run 'codacy-cli init' if you have no project yet, then try container-scan again so Trivy can be installed automatically.") + fmt.Println("exit-code 2") + exitFunc(2) +} + +func runContainerScan(_ *cobra.Command, args []string) { + exitCode := executeContainerScan(args[0]) + exitFunc(exitCode) +} + +// executeContainerScan performs the container scan and returns an exit code +// Exit codes: 0 = success, 1 = vulnerabilities found, 2 = error +func executeContainerScan(imageName string) int { + if err := validateImageName(imageName); err != nil { + logger.Error("Invalid image name", logrus.Fields{"image": imageName, "error": err.Error()}) + color.Red("❌ Error: %v", err) + fmt.Println("exit-code 2") + return 2 + } + logger.Info("Starting container scan", logrus.Fields{"image": imageName}) + + trivyPath, err := getTrivyPath() + if err != nil { + handleTrivyNotFound(err) + return 2 + } + + hasVulnerabilities := scanImage(imageName, trivyPath) + if hasVulnerabilities == -1 { + return 2 + } + return printScanSummary(hasVulnerabilities == 1) +} + +// scanImage scans the image and returns: 0=no vulns, 1=vulns found, -1=error +func scanImage(imageName, trivyPath string) int { + fmt.Printf("🔍 Scanning container image: %s\n\n", imageName) + args := buildTrivyArgs(imageName) + logger.Info("Running Trivy container scan", logrus.Fields{"command": fmt.Sprintf("%s %v", trivyPath, args)}) + + if err := commandRunner.Run(trivyPath, args); err != nil { + if getExitCode(err) == 1 { + logger.Warn("Vulnerabilities found in image", logrus.Fields{"image": imageName}) + return 1 + } + logger.Error("Failed to run Trivy", logrus.Fields{"error": err.Error(), "image": imageName}) + color.Red("❌ Error: Failed to run Trivy for %s: %v", imageName, err) + fmt.Println("exit-code 2") + return -1 + } + logger.Info("No vulnerabilities found in image", logrus.Fields{"image": imageName}) + return 0 +} + +func printScanSummary(hasVulnerabilities bool) int { + fmt.Println() + if hasVulnerabilities { + logger.Warn("Container scan completed with vulnerabilities", logrus.Fields{}) + color.Red("❌ Scanning failed: vulnerabilities found in the container image") + fmt.Println("exit-code 1") + return 1 + } + logger.Info("Container scan completed successfully", logrus.Fields{}) + color.Green("✅ Success: No vulnerabilities found matching the specified criteria") + return 0 +} + +// 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..a905a09 --- /dev/null +++ b/cmd/container_scan_test.go @@ -0,0 +1,494 @@ +package cmd + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/assert" +) + +// MockCommandRunner is a mock implementation of CommandRunner for testing +type MockCommandRunner struct { + RunFunc func(name string, args []string) error + Calls []struct { + Name string + Args []string + } +} + +func (m *MockCommandRunner) Run(name string, args []string) error { + m.Calls = append(m.Calls, struct { + Name string + Args []string + }{Name: name, Args: args}) + if m.RunFunc != nil { + return m.RunFunc(name, args) + } + return nil +} + +// Helper to save and restore global state for tests +type testState struct { + getTrivyPathResolver func() (string, error) + exitFunc func(code int) + commandRunner CommandRunner + severityFlag string + pkgTypesFlag string + ignoreUnfixed bool +} + +func saveState() testState { + return testState{ + getTrivyPathResolver: getTrivyPathResolver, + exitFunc: exitFunc, + commandRunner: commandRunner, + severityFlag: severityFlag, + pkgTypesFlag: pkgTypesFlag, + ignoreUnfixed: ignoreUnfixedFlag, + } +} + +func (s testState) restore() { + getTrivyPathResolver = s.getTrivyPathResolver + exitFunc = s.exitFunc + commandRunner = s.commandRunner + severityFlag = s.severityFlag + pkgTypesFlag = s.pkgTypesFlag + ignoreUnfixedFlag = s.ignoreUnfixed +} + +// Tests for getTrivyPath + +func TestGetTrivyPath_Found(t *testing.T) { + state := saveState() + defer state.restore() + + getTrivyPathResolver = func() (string, error) { + return "/usr/local/bin/trivy", nil + } + + path, err := getTrivyPath() + assert.NoError(t, err) + assert.Equal(t, "/usr/local/bin/trivy", path) +} + +func TestGetTrivyPath_NotFound(t *testing.T) { + state := saveState() + defer state.restore() + + getTrivyPathResolver = func() (string, error) { + return "", errors.New("trivy not found") + } + + path, err := getTrivyPath() + assert.Error(t, err) + assert.Equal(t, "", path) + assert.Contains(t, err.Error(), "not found") +} + +// Tests for executeContainerScan + +func TestExecuteContainerScan_Success(t *testing.T) { + state := saveState() + defer state.restore() + + getTrivyPathResolver = func() (string, error) { + return "/usr/local/bin/trivy", nil + } + + // Mock successful command execution + mockRunner := &MockCommandRunner{ + RunFunc: func(_ string, _ []string) error { + return nil // Success - no vulnerabilities + }, + } + commandRunner = mockRunner + + // Reset flags to defaults + severityFlag = "" + pkgTypesFlag = "" + ignoreUnfixedFlag = true + + exitCode := executeContainerScan("alpine:latest") + assert.Equal(t, 0, exitCode) + assert.Len(t, mockRunner.Calls, 1) + assert.Equal(t, "/usr/local/bin/trivy", mockRunner.Calls[0].Name) + assert.Contains(t, mockRunner.Calls[0].Args, "alpine:latest") +} + +// mockExitError simulates exec.ExitError with a specific exit code +type mockExitError struct { + code int +} + +func (e *mockExitError) Error() string { + return "exit status 1" +} + +func (e *mockExitError) ExitCode() int { + return e.code +} + +func TestExecuteContainerScan_VulnerabilitiesFound(t *testing.T) { + state := saveState() + defer state.restore() + + getTrivyPathResolver = func() (string, error) { + return "/usr/local/bin/trivy", nil + } + + // Mock trivy finding vulnerabilities (exit code 1) + mockRunner := &MockCommandRunner{ + RunFunc: func(_ string, _ []string) error { + return &mockExitError{code: 1} + }, + } + commandRunner = mockRunner + + severityFlag = "" + pkgTypesFlag = "" + ignoreUnfixedFlag = true + + exitCode := executeContainerScan("alpine:latest") + assert.Equal(t, 1, exitCode, "Should return exit code 1 when vulnerabilities are found") + assert.Len(t, mockRunner.Calls, 1) +} + +func TestExecuteContainerScan_InvalidImageName(t *testing.T) { + state := saveState() + defer state.restore() + + exitCode := executeContainerScan("nginx;rm -rf /") + assert.Equal(t, 2, exitCode) +} + +func TestExecuteContainerScan_TrivyNotFound(t *testing.T) { + state := saveState() + defer state.restore() + + getTrivyPathResolver = func() (string, error) { + return "", errors.New("trivy not in config after install") + } + + // Mock exitFunc to capture exit code instead of exiting + var capturedExitCode int + exitFunc = func(code int) { + capturedExitCode = code + } + + exitCode := executeContainerScan("alpine:latest") + // handleTrivyNotFound calls exitFunc(2), then returns 2 + assert.Equal(t, 2, capturedExitCode) + assert.Equal(t, 2, exitCode) +} + +func TestExecuteContainerScan_TrivyExecutionError(t *testing.T) { + state := saveState() + defer state.restore() + + getTrivyPathResolver = func() (string, error) { + return "/usr/local/bin/trivy", nil + } + + // Mock a non-exit-code-1 error (e.g., trivy crashed) + mockRunner := &MockCommandRunner{ + RunFunc: func(_ string, _ []string) error { + return errors.New("trivy crashed unexpectedly") + }, + } + commandRunner = mockRunner + + severityFlag = "" + pkgTypesFlag = "" + ignoreUnfixedFlag = true + + exitCode := executeContainerScan("alpine:latest") + assert.Equal(t, 2, exitCode) +} + +// Tests for handleTrivyNotFound + +func TestHandleTrivyNotFound(t *testing.T) { + state := saveState() + defer state.restore() + + var capturedExitCode int + exitFunc = func(code int) { + capturedExitCode = code + } + + handleTrivyNotFound(errors.New("trivy not found")) + assert.Equal(t, 2, capturedExitCode) +} + +type trivyArgsTestCase struct { + name string + imageName string + severity string + pkgTypes string + ignoreUnfixed bool + expectedArgs []string + expectedContains []string + expectedNotContains []string +} + +var trivyArgsTestCases = []trivyArgsTestCase{ + { + 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"}, + }, +} + +func TestBuildTrivyArgs(t *testing.T) { + for _, tt := range trivyArgsTestCases { + t.Run(tt.name, func(t *testing.T) { + severityFlag = tt.severity + pkgTypesFlag = tt.pkgTypes + ignoreUnfixedFlag = tt.ignoreUnfixed + + args := buildTrivyArgs(tt.imageName) + + if tt.expectedArgs != nil { + assert.Equal(t, tt.expectedArgs, args, "Args should match exactly") + } + for _, exp := range tt.expectedContains { + assert.Contains(t, args, exp, "Args should contain %s", exp) + } + for _, notExp := range tt.expectedNotContains { + assert.NotContains(t, args, notExp, "Args should not contain %s", notExp) + } + assertTrivyArgsBaseRequirements(t, args, tt.imageName) + }) + } +} + +func assertTrivyArgsBaseRequirements(t *testing.T, args []string, imageName string) { + t.Helper() + 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") + assert.Equal(t, imageName, args[len(args)-1], "Image name should be the last argument") +} + +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 := findArgIndex(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 findArgIndex(args []string, target string) int { + for i, arg := range args { + if arg == target { + return i + } + } + return -1 +} + +func TestContainerScanCommandSkipsValidation(t *testing.T) { + result := shouldSkipValidation("container-scan") + assert.True(t, result, "container-scan should skip validation") +} + +func TestContainerScanCommandRequiresArg(t *testing.T) { + assert.Equal(t, "container-scan ", containerScanCmd.Use, "Command use should match expected format") + + err := containerScanCmd.Args(containerScanCmd, []string{}) + assert.Error(t, err, "Should error when no args provided") + + err = containerScanCmd.Args(containerScanCmd, []string{"myapp:latest"}) + assert.NoError(t, err, "Should not error when one arg provided") + + err = containerScanCmd.Args(containerScanCmd, []string{"image1", "image2"}) + assert.Error(t, err, "Should error when multiple args provided") +} + +func TestContainerScanFlagDefaults(t *testing.T) { + severityFlagDef := containerScanCmd.Flags().Lookup("severity") + pkgTypesFlagDef := containerScanCmd.Flags().Lookup("pkg-types") + ignoreUnfixedFlagDef := containerScanCmd.Flags().Lookup("ignore-unfixed") + + 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.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") +} + +type imageNameTestCase struct { + name string + imageName string + expectError bool + errorMsg string +} + +var validImageNameTestCases = []imageNameTestCase{ + {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}, +} + +var invalidImageNameTestCases = []imageNameTestCase{ + {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"}, + {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"}, +} + +func TestValidateImageNameValid(t *testing.T) { + for _, tt := range validImageNameTestCases { + t.Run(tt.name, func(t *testing.T) { + err := validateImageName(tt.imageName) + assert.NoError(t, err, "Did not expect error for image name: %s", tt.imageName) + }) + } +} + +func TestValidateImageNameInvalid(t *testing.T) { + for _, tt := range invalidImageNameTestCases { + t.Run(tt.name, func(t *testing.T) { + err := validateImageName(tt.imageName) + 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) + } + }) + } +} + +func TestBuildTrivyArgsDefaultsApplied(t *testing.T) { + severityFlag = "" + pkgTypesFlag = "" + ignoreUnfixedFlag = true + + args := buildTrivyArgs("test:latest") + + severityIdx := findArgIndex(args, "--severity") + assert.NotEqual(t, -1, severityIdx, "--severity should be present") + assert.Equal(t, "HIGH,CRITICAL", args[severityIdx+1], "Default severity should be HIGH,CRITICAL") + + pkgTypesIdx := findArgIndex(args, "--pkg-types") + assert.NotEqual(t, -1, pkgTypesIdx, "--pkg-types should be present") + assert.Equal(t, "os", args[pkgTypesIdx+1], "Default pkg-types should be 'os'") + + assert.Contains(t, args, "--ignore-unfixed", "--ignore-unfixed should be present when enabled") +} + +func TestBuildTrivyArgsWithDifferentImages(t *testing.T) { + severityFlag = "CRITICAL" + pkgTypesFlag = "" + ignoreUnfixedFlag = true + + images := []string{"alpine:latest", "nginx:1.21", "redis:7"} + + for _, img := range images { + args := buildTrivyArgs(img) + assert.Equal(t, img, args[len(args)-1], "Image name should be last argument") + assert.Contains(t, args, "--severity", "Should contain severity flag") + assert.Contains(t, args, "CRITICAL", "Should use configured severity") + } +} + +func TestContainerScanCommandAcceptsExactlyOneImage(t *testing.T) { + err := containerScanCmd.Args(containerScanCmd, []string{"alpine:latest"}) + assert.NoError(t, err, "Command should accept single image") +} + +func TestContainerScanCommandRejectsNoImages(t *testing.T) { + err := containerScanCmd.Args(containerScanCmd, []string{}) + assert.Error(t, err, "Command should reject empty image list") +} 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 {