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..cdd4508 --- /dev/null +++ b/cmd/container_scan.go @@ -0,0 +1,269 @@ +// Package cmd implements the CLI commands for the Codacy CLI tool. +package cmd + +import ( + "bytes" + "fmt" + "io" + "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 + // RunWithStderr runs the command; if stderr is not nil, Trivy stderr is written to both os.Stderr and stderr. + RunWithStderr(name string, args []string, stderr io.Writer) 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 { + return r.RunWithStderr(name, args, nil) +} + +// RunWithStderr runs the command; if stderr is not nil, command stderr is written to both os.Stderr and stderr. +func (r *ExecCommandRunner) RunWithStderr(name string, args []string, stderr io.Writer) 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 + if stderr != nil { + cmd.Stderr = io.MultiWriter(os.Stderr, stderr) + } else { + 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 ( + ignoreUnfixedFlag bool +) + +func init() { + 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. + +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 + + # 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.") + 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) + 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) +} + +// isScanFailure returns true if Trivy stderr indicates the scan failed (e.g. image not found, no runtime) +// rather than a successful scan that found vulnerabilities. Trivy uses exit code 1 for both cases. +func isScanFailure(stderr []byte) bool { + s := string(stderr) + return strings.Contains(s, "FATAL") || + strings.Contains(s, "run error") || + strings.Contains(s, "image scan error") || + strings.Contains(s, "unable to find the specified image") +} + +// 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)}) + + var stderrBuf bytes.Buffer + if err := commandRunner.RunWithStderr(trivyPath, args, &stderrBuf); err != nil { + code := getExitCode(err) + if code == 1 && isScanFailure(stderrBuf.Bytes()) { + logger.Error("Scan failed (e.g. image not found or no container runtime)", logrus.Fields{"image": imageName, "error": err.Error()}) + color.Red("❌ Scanning failed: unable to scan the container image (e.g. image not found or no container runtime)") + return -1 + } + if code == 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) + 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") + 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") + } + + // Fixed severity and package types (not user-configurable) + args = append(args, "--severity", "HIGH,CRITICAL", "--pkg-types", "os") + + // 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..d69da7e --- /dev/null +++ b/cmd/container_scan_test.go @@ -0,0 +1,475 @@ +package cmd + +import ( + "errors" + "io" + "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 + RunWithStderrFunc func(name string, args []string, stderr io.Writer) error + Calls []struct { + Name string + Args []string + } +} + +func (m *MockCommandRunner) Run(name string, args []string) error { + return m.RunWithStderr(name, args, nil) +} + +func (m *MockCommandRunner) RunWithStderr(name string, args []string, stderr io.Writer) error { + m.Calls = append(m.Calls, struct { + Name string + Args []string + }{Name: name, Args: args}) + if m.RunWithStderrFunc != nil { + return m.RunWithStderrFunc(name, args, stderr) + } + 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 + ignoreUnfixed bool +} + +func saveState() testState { + return testState{ + getTrivyPathResolver: getTrivyPathResolver, + exitFunc: exitFunc, + commandRunner: commandRunner, + ignoreUnfixed: ignoreUnfixedFlag, + } +} + +func (s testState) restore() { + getTrivyPathResolver = s.getTrivyPathResolver + exitFunc = s.exitFunc + commandRunner = s.commandRunner + 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 + 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 + + 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 + + ignoreUnfixedFlag = true + + exitCode := executeContainerScan("alpine:latest") + assert.Equal(t, 2, exitCode) +} + +func TestExecuteContainerScan_ScanFailureExit1(t *testing.T) { + state := saveState() + defer state.restore() + + getTrivyPathResolver = func() (string, error) { + return "/usr/local/bin/trivy", nil + } + + // Trivy exits 1 with FATAL/run error in stderr (e.g. image not found) -> we treat as scan error, not vulnerabilities + scanFailureStderr := "FATAL Fatal error run error: image scan error: unable to find the specified image" + mockRunner := &MockCommandRunner{ + RunWithStderrFunc: func(_ string, _ []string, stderr io.Writer) error { + if stderr != nil { + _, _ = stderr.Write([]byte(scanFailureStderr)) + } + return &mockExitError{code: 1} + }, + } + commandRunner = mockRunner + + ignoreUnfixedFlag = true + + exitCode := executeContainerScan("random-string") + assert.Equal(t, 2, exitCode, "Should return exit code 2 when scan failed (not vulnerabilities found)") + assert.Len(t, mockRunner.Calls, 1) +} + +func TestIsScanFailure(t *testing.T) { + assert.False(t, isScanFailure(nil), "nil stderr is not a scan failure") + assert.False(t, isScanFailure([]byte("")), "empty stderr is not a scan failure") + assert.False(t, isScanFailure([]byte("Total: 5 (HIGH: 2, CRITICAL: 3)")), "vulnerability table is not a scan failure") + assert.True(t, isScanFailure([]byte("FATAL Fatal error")), "FATAL indicates scan failure") + assert.True(t, isScanFailure([]byte("run error: image scan error")), "run error indicates scan failure") + assert.True(t, isScanFailure([]byte("unable to find the specified image")), "unable to find image indicates scan failure") +} + +// 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 + ignoreUnfixed bool + expectedArgs []string + expectedContains []string + expectedNotContains []string +} + +var trivyArgsTestCases = []trivyArgsTestCase{ + { + name: "default flags", + imageName: "myapp:latest", + ignoreUnfixed: true, + expectedArgs: []string{ + "image", "--scanners", "vuln", "--ignore-unfixed", + "--severity", "HIGH,CRITICAL", "--pkg-types", "os", + "--exit-code", "1", "myapp:latest", + }, + }, + { + name: "ignore-unfixed disabled", + imageName: "alpine:latest", + ignoreUnfixed: false, + expectedContains: []string{"--severity", "HIGH,CRITICAL", "--pkg-types", "os", "alpine:latest"}, + expectedNotContains: []string{"--ignore-unfixed"}, + }, + { + name: "exit-code always present", + imageName: "test:v1", + ignoreUnfixed: false, + expectedContains: []string{"--exit-code", "1"}, + }, + { + name: "image with registry prefix", + imageName: "ghcr.io/codacy/codacy-cli:latest", + ignoreUnfixed: true, + expectedContains: []string{"ghcr.io/codacy/codacy-cli:latest"}, + }, + { + name: "image with digest", + imageName: "nginx@sha256:abc123", + ignoreUnfixed: true, + expectedContains: []string{"nginx@sha256:abc123"}, + }, +} + +func TestBuildTrivyArgs(t *testing.T) { + for _, tt := range trivyArgsTestCases { + t.Run(tt.name, func(t *testing.T) { + 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) { + 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) { + ignoreUnfixedFlagDef := containerScanCmd.Flags().Lookup("ignore-unfixed") + + assert.NotNil(t, ignoreUnfixedFlagDef, "ignore-unfixed flag should exist") + 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) { + 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], "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], "Pkg-types should be 'os'") + + assert.Contains(t, args, "--ignore-unfixed", "--ignore-unfixed should be present when enabled") +} + +func TestBuildTrivyArgsWithDifferentImages(t *testing.T) { + 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, "HIGH,CRITICAL", "Should use fixed severity HIGH,CRITICAL") + } +} + +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 { diff --git a/plugins/tools/trivy/test/expected.sarif b/plugins/tools/trivy/test/expected.sarif index ebd7830..e138915 100644 --- a/plugins/tools/trivy/test/expected.sarif +++ b/plugins/tools/trivy/test/expected.sarif @@ -34,7 +34,7 @@ "text": "Package: django\nInstalled Version: 1.11.29\nVulnerability CVE-2021-33203\nSeverity: MEDIUM\nFixed Version: 2.2.24, 3.1.12, 3.2.4\nLink: [CVE-2021-33203](https://avd.aquasec.com/nvd/cve-2021-33203)" }, "ruleId": "CVE-2021-33203", - "ruleIndex": 7 + "ruleIndex": 8 }, { "level": "error", @@ -61,7 +61,7 @@ "text": "Package: django\nInstalled Version: 1.11.29\nVulnerability CVE-2022-36359\nSeverity: HIGH\nFixed Version: 3.2.15, 4.0.7\nLink: [CVE-2022-36359](https://avd.aquasec.com/nvd/cve-2022-36359)" }, "ruleId": "CVE-2022-36359", - "ruleIndex": 4 + "ruleIndex": 5 }, { "level": "error", @@ -115,7 +115,7 @@ "text": "Package: django\nInstalled Version: 1.11.29\nVulnerability CVE-2024-45231\nSeverity: MEDIUM\nFixed Version: 5.1.1, 5.0.9, 4.2.16\nLink: [CVE-2024-45231](https://avd.aquasec.com/nvd/cve-2024-45231)" }, "ruleId": "CVE-2024-45231", - "ruleIndex": 8 + "ruleIndex": 9 }, { "level": "warning", @@ -142,7 +142,34 @@ "text": "Package: django\nInstalled Version: 1.11.29\nVulnerability CVE-2025-48432\nSeverity: MEDIUM\nFixed Version: 5.2.2, 5.1.10, 4.2.22\nLink: [CVE-2025-48432](https://avd.aquasec.com/nvd/cve-2025-48432)" }, "ruleId": "CVE-2025-48432", - "ruleIndex": 9 + "ruleIndex": 10 + }, + { + "level": "warning", + "locations": [ + { + "message": { + "text": "package-lock.json: eslint@9.3.0" + }, + "physicalLocation": { + "artifactLocation": { + "uri": "package-lock.json", + "uriBaseId": "ROOTPATH" + }, + "region": { + "endColumn": 1, + "endLine": 633, + "startColumn": 1, + "startLine": 584 + } + } + } + ], + "message": { + "text": "Package: eslint\nInstalled Version: 9.3.0\nVulnerability CVE-2025-50537\nSeverity: MEDIUM\nFixed Version: 9.26.0\nLink: [CVE-2025-50537](https://avd.aquasec.com/nvd/cve-2025-50537)" + }, + "ruleId": "CVE-2025-50537", + "ruleIndex": 2 }, { "level": "error", @@ -169,7 +196,7 @@ "text": "Package: django\nInstalled Version: 1.11.29\nVulnerability CVE-2025-57833\nSeverity: HIGH\nFixed Version: 4.2.24, 5.1.12, 5.2.6\nLink: [CVE-2025-57833](https://avd.aquasec.com/nvd/cve-2025-57833)" }, "ruleId": "CVE-2025-57833", - "ruleIndex": 5 + "ruleIndex": 6 }, { "level": "note", @@ -223,7 +250,7 @@ "text": "Package: django\nInstalled Version: 1.11.29\nVulnerability CVE-2025-64458\nSeverity: HIGH\nFixed Version: 5.2.8, 5.1.14, 4.2.26\nLink: [CVE-2025-64458](https://avd.aquasec.com/nvd/cve-2025-64458)" }, "ruleId": "CVE-2025-64458", - "ruleIndex": 6 + "ruleIndex": 7 }, { "level": "error", @@ -250,7 +277,7 @@ "text": "Package: django\nInstalled Version: 1.11.29\nVulnerability CVE-2025-64459\nSeverity: CRITICAL\nFixed Version: 5.2.8, 5.1.14, 4.2.26\nLink: [CVE-2025-64459](https://avd.aquasec.com/nvd/cve-2025-64459)" }, "ruleId": "CVE-2025-64459", - "ruleIndex": 3 + "ruleIndex": 4 }, { "level": "warning", @@ -277,7 +304,7 @@ "text": "Package: js-yaml\nInstalled Version: 4.1.0\nVulnerability CVE-2025-64718\nSeverity: MEDIUM\nFixed Version: 4.1.1, 3.14.2\nLink: [CVE-2025-64718](https://avd.aquasec.com/nvd/cve-2025-64718)" }, "ruleId": "CVE-2025-64718", - "ruleIndex": 2 + "ruleIndex": 3 } ], "tool": { @@ -292,4 +319,4 @@ } ], "version": "2.1.0" -} \ No newline at end of file +}