Skip to content

Commit da0cd76

Browse files
Feature/container scan (#194)
* add container-scan command (with trivy) --------- Co-authored-by: franciscoazevedo <francisco.azevedo@codacy.com>
1 parent e1bb1bc commit da0cd76

File tree

5 files changed

+783
-11
lines changed

5 files changed

+783
-11
lines changed

cli-v2.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,10 +39,10 @@ func main() {
3939
}
4040
}
4141

42-
// Check if command is init/update/version/help - these don't require configuration
42+
// Check if command is init/update/version/help/container-scan - these don't require configuration
4343
if len(os.Args) > 1 {
4444
cmdName := os.Args[1]
45-
if cmdName == "init" || cmdName == "update" || cmdName == "version" || cmdName == "help" {
45+
if cmdName == "init" || cmdName == "update" || cmdName == "version" || cmdName == "help" || cmdName == "container-scan" {
4646
cmd.Execute()
4747
return
4848
}

cmd/container_scan.go

Lines changed: 269 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,269 @@
1+
// Package cmd implements the CLI commands for the Codacy CLI tool.
2+
package cmd
3+
4+
import (
5+
"bytes"
6+
"fmt"
7+
"io"
8+
"os"
9+
"os/exec"
10+
"regexp"
11+
"strings"
12+
13+
"codacy/cli-v2/config"
14+
config_file "codacy/cli-v2/config-file"
15+
"codacy/cli-v2/utils/logger"
16+
17+
"github.com/fatih/color"
18+
"github.com/sirupsen/logrus"
19+
"github.com/spf13/cobra"
20+
)
21+
22+
// validImageNamePattern validates Docker image references
23+
// Allows: registry/namespace/image:tag or image@sha256:digest
24+
// Based on Docker image reference specification
25+
var validImageNamePattern = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._\-/:@]*$`)
26+
27+
// exitFunc is a variable to allow mocking os.Exit in tests
28+
var exitFunc = os.Exit
29+
30+
// CommandRunner interface for running external commands (allows mocking in tests)
31+
type CommandRunner interface {
32+
Run(name string, args []string) error
33+
// RunWithStderr runs the command; if stderr is not nil, Trivy stderr is written to both os.Stderr and stderr.
34+
RunWithStderr(name string, args []string, stderr io.Writer) error
35+
}
36+
37+
// ExecCommandRunner runs commands using exec.Command
38+
type ExecCommandRunner struct{}
39+
40+
// Run executes a command and returns its exit error
41+
func (r *ExecCommandRunner) Run(name string, args []string) error {
42+
return r.RunWithStderr(name, args, nil)
43+
}
44+
45+
// RunWithStderr runs the command; if stderr is not nil, command stderr is written to both os.Stderr and stderr.
46+
func (r *ExecCommandRunner) RunWithStderr(name string, args []string, stderr io.Writer) error {
47+
// #nosec G204 -- name comes from config (codacy-installed Trivy path),
48+
// and args are validated by validateImageName() which checks for shell metacharacters.
49+
// exec.Command passes arguments directly without shell interpretation.
50+
cmd := exec.Command(name, args...)
51+
cmd.Stdout = os.Stdout
52+
if stderr != nil {
53+
cmd.Stderr = io.MultiWriter(os.Stderr, stderr)
54+
} else {
55+
cmd.Stderr = os.Stderr
56+
}
57+
return cmd.Run()
58+
}
59+
60+
// commandRunner is the default command runner, can be replaced in tests
61+
var commandRunner CommandRunner = &ExecCommandRunner{}
62+
63+
// ExitCoder interface for errors that have an exit code
64+
type ExitCoder interface {
65+
ExitCode() int
66+
}
67+
68+
// getExitCode returns the exit code from an error if it implements ExitCoder
69+
func getExitCode(err error) int {
70+
if exitErr, ok := err.(ExitCoder); ok {
71+
return exitErr.ExitCode()
72+
}
73+
return -1
74+
}
75+
76+
// Flag variables for container-scan command
77+
var (
78+
ignoreUnfixedFlag bool
79+
)
80+
81+
func init() {
82+
containerScanCmd.Flags().BoolVar(&ignoreUnfixedFlag, "ignore-unfixed", true, "Ignore unfixed vulnerabilities")
83+
rootCmd.AddCommand(containerScanCmd)
84+
}
85+
86+
var containerScanCmd = &cobra.Command{
87+
Use: "container-scan <IMAGE_NAME>",
88+
Short: "Scan a container image for vulnerabilities using Trivy",
89+
Long: `Scan a container image for vulnerabilities using Trivy.
90+
91+
By default, scans for HIGH and CRITICAL vulnerabilities in OS packages,
92+
ignoring unfixed issues.
93+
94+
The --exit-code 1 flag is always applied (not user-configurable) to ensure
95+
the command fails when vulnerabilities are found.`,
96+
Example: ` # Scan an image
97+
codacy-cli container-scan myapp:latest
98+
99+
# Include unfixed vulnerabilities
100+
codacy-cli container-scan --ignore-unfixed=false myapp:latest`,
101+
Args: cobra.ExactArgs(1),
102+
Run: runContainerScan,
103+
}
104+
105+
// validateImageName checks if the image name is a valid Docker image reference
106+
// and doesn't contain shell metacharacters that could be used for command injection
107+
func validateImageName(imageName string) error {
108+
if imageName == "" {
109+
return fmt.Errorf("image name cannot be empty")
110+
}
111+
112+
// Check for maximum length (Docker has a practical limit)
113+
if len(imageName) > 256 {
114+
return fmt.Errorf("image name is too long (max 256 characters)")
115+
}
116+
117+
// Check for dangerous shell metacharacters first for specific error messages
118+
dangerousChars := []string{";", "&", "|", "$", "`", "(", ")", "{", "}", "<", ">", "!", "\\", "\n", "\r", "'", "\""}
119+
for _, char := range dangerousChars {
120+
if strings.Contains(imageName, char) {
121+
return fmt.Errorf("invalid image name: contains disallowed character '%s'", char)
122+
}
123+
}
124+
125+
// Validate against allowed pattern for any other invalid characters
126+
if !validImageNamePattern.MatchString(imageName) {
127+
return fmt.Errorf("invalid image name format: contains disallowed characters")
128+
}
129+
130+
return nil
131+
}
132+
133+
// getTrivyPathResolver is set by tests to mock Trivy path resolution; when nil, real config/install logic is used
134+
var getTrivyPathResolver func() (string, error)
135+
136+
// getTrivyPath returns the path to the Trivy binary (codacy-installed, installed on demand if needed) and an error if not found
137+
func getTrivyPath() (string, error) {
138+
if getTrivyPathResolver != nil {
139+
return getTrivyPathResolver()
140+
}
141+
if err := config.Config.CreateCodacyDirs(); err != nil {
142+
return "", fmt.Errorf("failed to create codacy directories: %w", err)
143+
}
144+
_ = config_file.ReadConfigFile(config.Config.ProjectConfigFile())
145+
tool := config.Config.Tools()["trivy"]
146+
if tool == nil || !config.Config.IsToolInstalled("trivy", tool) {
147+
if err := config.InstallTool("trivy", tool, ""); err != nil {
148+
return "", fmt.Errorf("failed to install Trivy: %w", err)
149+
}
150+
tool = config.Config.Tools()["trivy"]
151+
}
152+
if tool == nil {
153+
return "", fmt.Errorf("trivy not in config after install")
154+
}
155+
trivyPath, ok := tool.Binaries["trivy"]
156+
if !ok || trivyPath == "" {
157+
return "", fmt.Errorf("trivy binary path not found")
158+
}
159+
logger.Info("Found Trivy", logrus.Fields{"path": trivyPath})
160+
return trivyPath, nil
161+
}
162+
163+
// handleTrivyNotFound prints error message and exits with code 2
164+
func handleTrivyNotFound(err error) {
165+
logger.Error("Trivy not found", logrus.Fields{"error": err.Error()})
166+
color.Red("❌ Error: Trivy could not be installed or found")
167+
fmt.Println("Run 'codacy-cli init' if you have no project yet, then try container-scan again so Trivy can be installed automatically.")
168+
exitFunc(2)
169+
}
170+
171+
func runContainerScan(_ *cobra.Command, args []string) {
172+
exitCode := executeContainerScan(args[0])
173+
exitFunc(exitCode)
174+
}
175+
176+
// executeContainerScan performs the container scan and returns an exit code
177+
// Exit codes: 0 = success, 1 = vulnerabilities found, 2 = error
178+
func executeContainerScan(imageName string) int {
179+
if err := validateImageName(imageName); err != nil {
180+
logger.Error("Invalid image name", logrus.Fields{"image": imageName, "error": err.Error()})
181+
color.Red("❌ Error: %v", err)
182+
return 2
183+
}
184+
logger.Info("Starting container scan", logrus.Fields{"image": imageName})
185+
186+
trivyPath, err := getTrivyPath()
187+
if err != nil {
188+
handleTrivyNotFound(err)
189+
return 2
190+
}
191+
192+
hasVulnerabilities := scanImage(imageName, trivyPath)
193+
if hasVulnerabilities == -1 {
194+
return 2
195+
}
196+
return printScanSummary(hasVulnerabilities == 1)
197+
}
198+
199+
// isScanFailure returns true if Trivy stderr indicates the scan failed (e.g. image not found, no runtime)
200+
// rather than a successful scan that found vulnerabilities. Trivy uses exit code 1 for both cases.
201+
func isScanFailure(stderr []byte) bool {
202+
s := string(stderr)
203+
return strings.Contains(s, "FATAL") ||
204+
strings.Contains(s, "run error") ||
205+
strings.Contains(s, "image scan error") ||
206+
strings.Contains(s, "unable to find the specified image")
207+
}
208+
209+
// scanImage scans the image and returns: 0=no vulns, 1=vulns found, -1=error
210+
func scanImage(imageName, trivyPath string) int {
211+
fmt.Printf("🔍 Scanning container image: %s\n\n", imageName)
212+
args := buildTrivyArgs(imageName)
213+
logger.Info("Running Trivy container scan", logrus.Fields{"command": fmt.Sprintf("%s %v", trivyPath, args)})
214+
215+
var stderrBuf bytes.Buffer
216+
if err := commandRunner.RunWithStderr(trivyPath, args, &stderrBuf); err != nil {
217+
code := getExitCode(err)
218+
if code == 1 && isScanFailure(stderrBuf.Bytes()) {
219+
logger.Error("Scan failed (e.g. image not found or no container runtime)", logrus.Fields{"image": imageName, "error": err.Error()})
220+
color.Red("❌ Scanning failed: unable to scan the container image (e.g. image not found or no container runtime)")
221+
return -1
222+
}
223+
if code == 1 {
224+
logger.Warn("Vulnerabilities found in image", logrus.Fields{"image": imageName})
225+
return 1
226+
}
227+
logger.Error("Failed to run Trivy", logrus.Fields{"error": err.Error(), "image": imageName})
228+
color.Red("❌ Error: Failed to run Trivy for %s: %v", imageName, err)
229+
return -1
230+
}
231+
logger.Info("No vulnerabilities found in image", logrus.Fields{"image": imageName})
232+
return 0
233+
}
234+
235+
func printScanSummary(hasVulnerabilities bool) int {
236+
fmt.Println()
237+
if hasVulnerabilities {
238+
logger.Warn("Container scan completed with vulnerabilities", logrus.Fields{})
239+
color.Red("❌ Scanning failed: vulnerabilities found in the container image")
240+
return 1
241+
}
242+
logger.Info("Container scan completed successfully", logrus.Fields{})
243+
color.Green("✅ Success: No vulnerabilities found matching the specified criteria")
244+
return 0
245+
}
246+
247+
// buildTrivyArgs constructs the Trivy command arguments based on flags
248+
func buildTrivyArgs(imageName string) []string {
249+
args := []string{
250+
"image",
251+
"--scanners", "vuln",
252+
}
253+
254+
// Apply --ignore-unfixed if enabled (default: true)
255+
if ignoreUnfixedFlag {
256+
args = append(args, "--ignore-unfixed")
257+
}
258+
259+
// Fixed severity and package types (not user-configurable)
260+
args = append(args, "--severity", "HIGH,CRITICAL", "--pkg-types", "os")
261+
262+
// Always apply --exit-code 1 (not user-configurable)
263+
args = append(args, "--exit-code", "1")
264+
265+
// Add the image name as the last argument
266+
args = append(args, imageName)
267+
268+
return args
269+
}

0 commit comments

Comments
 (0)