Skip to content

Commit a7ac145

Browse files
search for Dockerfile to auto determine the images to scan
1 parent 25dcb9b commit a7ac145

File tree

2 files changed

+448
-28
lines changed

2 files changed

+448
-28
lines changed

cmd/container_scan.go

Lines changed: 240 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
package cmd
22

33
import (
4+
"bufio"
45
"fmt"
56
"os"
67
"os/exec"
8+
"path/filepath"
79
"regexp"
810
"strings"
911

@@ -12,49 +14,69 @@ import (
1214
"github.com/fatih/color"
1315
"github.com/sirupsen/logrus"
1416
"github.com/spf13/cobra"
17+
"gopkg.in/yaml.v3"
1518
)
1619

1720
// validImageNamePattern validates Docker image references
1821
// Allows: registry/namespace/image:tag or image@sha256:digest
1922
// Based on Docker image reference specification
2023
var validImageNamePattern = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._\-/:@]*$`)
2124

25+
// dockerfileFromPattern matches FROM instructions in Dockerfiles
26+
var dockerfileFromPattern = regexp.MustCompile(`(?i)^\s*FROM\s+([^\s]+)`)
27+
2228
// Flag variables for container-scan command
2329
var (
2430
severityFlag string
2531
pkgTypesFlag string
2632
ignoreUnfixedFlag bool
33+
dockerfileFlag string
34+
composeFileFlag string
2735
)
2836

2937
func init() {
3038
containerScanCmd.Flags().StringVar(&severityFlag, "severity", "", "Comma-separated list of severities to scan for (default: HIGH,CRITICAL)")
3139
containerScanCmd.Flags().StringVar(&pkgTypesFlag, "pkg-types", "", "Comma-separated list of package types to scan (default: os)")
3240
containerScanCmd.Flags().BoolVar(&ignoreUnfixedFlag, "ignore-unfixed", true, "Ignore unfixed vulnerabilities")
41+
containerScanCmd.Flags().StringVar(&dockerfileFlag, "dockerfile", "", "Path to Dockerfile for image auto-detection (useful in CI)")
42+
containerScanCmd.Flags().StringVar(&composeFileFlag, "compose-file", "", "Path to docker-compose.yml for image auto-detection (useful in CI)")
3343
rootCmd.AddCommand(containerScanCmd)
3444
}
3545

3646
var containerScanCmd = &cobra.Command{
37-
Use: "container-scan [FLAGS] <IMAGE_NAME>",
47+
Use: "container-scan [FLAGS] [IMAGE_NAME]",
3848
Short: "Scan container images for vulnerabilities using Trivy",
3949
Long: `Scan container images for vulnerabilities using Trivy.
4050
4151
By default, scans for HIGH and CRITICAL vulnerabilities in OS packages,
4252
ignoring unfixed issues. Use flags to override these defaults.
4353
54+
If no image is specified, the command will auto-detect images from:
55+
1. Dockerfile (FROM instruction) - scans the base image
56+
2. docker-compose.yml (image fields) - scans all referenced images
57+
58+
Use --dockerfile or --compose-file flags to specify explicit paths (useful in CI/CD).
59+
4460
The --exit-code 1 flag is always applied (not user-configurable) to ensure
4561
the command fails when vulnerabilities are found.`,
46-
Example: ` # Default behavior (HIGH,CRITICAL severity, os packages only)
62+
Example: ` # Auto-detect from Dockerfile or docker-compose.yml in current directory
63+
codacy-cli container-scan
64+
65+
# Specify Dockerfile path (useful in CI/CD)
66+
codacy-cli container-scan --dockerfile ./docker/Dockerfile.prod
67+
68+
# Specify docker-compose file path
69+
codacy-cli container-scan --compose-file ./deploy/docker-compose.yml
70+
71+
# Scan a specific image
4772
codacy-cli container-scan myapp:latest
4873
4974
# Scan only for CRITICAL vulnerabilities
5075
codacy-cli container-scan --severity CRITICAL myapp:latest
5176
52-
# Scan all severities and package types
53-
codacy-cli container-scan --severity LOW,MEDIUM,HIGH,CRITICAL --pkg-types os,library myapp:latest
54-
55-
# Include unfixed vulnerabilities
56-
codacy-cli container-scan --ignore-unfixed=false myapp:latest`,
57-
Args: cobra.ExactArgs(1),
77+
# CI/CD example: scan all images before deploy
78+
codacy-cli container-scan --dockerfile ./Dockerfile --severity HIGH,CRITICAL`,
79+
Args: cobra.MaximumNArgs(1),
5880
Run: runContainerScan,
5981
}
6082

@@ -124,25 +146,223 @@ func handleTrivyResult(err error, imageName string) {
124146
}
125147

126148
func runContainerScan(cmd *cobra.Command, args []string) {
127-
imageName := args[0]
149+
var images []string
150+
151+
if len(args) > 0 {
152+
images = []string{args[0]}
153+
} else {
154+
images = detectImages()
155+
if len(images) == 0 {
156+
color.Red("❌ Error: No image specified and none found in Dockerfile or docker-compose.yml")
157+
fmt.Println("Usage: codacy-cli container-scan <IMAGE_NAME>")
158+
os.Exit(1)
159+
}
160+
}
161+
162+
scanImages(images)
163+
}
164+
165+
// scanImages validates and scans multiple images
166+
func scanImages(images []string) {
167+
trivyPath := getTrivyPath()
168+
hasFailures := false
169+
170+
for _, imageName := range images {
171+
if err := validateImageName(imageName); err != nil {
172+
logger.Error("Invalid image name", logrus.Fields{"image": imageName, "error": err.Error()})
173+
color.Red("❌ Error: %v", err)
174+
hasFailures = true
175+
continue
176+
}
177+
178+
logger.Info("Starting container scan", logrus.Fields{"image": imageName})
179+
fmt.Printf("🔍 Scanning container image: %s\n\n", imageName)
180+
181+
trivyCmd := exec.Command(trivyPath, buildTrivyArgs(imageName)...)
182+
trivyCmd.Stdout = os.Stdout
183+
trivyCmd.Stderr = os.Stderr
184+
185+
logger.Info("Running Trivy container scan", logrus.Fields{"command": trivyCmd.String()})
128186

129-
if err := validateImageName(imageName); err != nil {
130-
logger.Error("Invalid image name", logrus.Fields{"image": imageName, "error": err.Error()})
131-
color.Red("❌ Error: %v", err)
187+
if err := trivyCmd.Run(); err != nil {
188+
hasFailures = true
189+
handleScanError(err, imageName)
190+
} else {
191+
logger.Info("Container scan completed successfully", logrus.Fields{"image": imageName})
192+
fmt.Println()
193+
color.Green("✅ Success: No vulnerabilities found in %s", imageName)
194+
}
195+
196+
if len(images) > 1 {
197+
fmt.Println("\n" + strings.Repeat("-", 60) + "\n")
198+
}
199+
}
200+
201+
if hasFailures {
132202
os.Exit(1)
133203
}
204+
}
134205

135-
logger.Info("Starting container scan", logrus.Fields{"image": imageName})
206+
// handleScanError processes scan errors without exiting (for multi-image scans)
207+
func handleScanError(err error, imageName string) {
208+
if exitError, ok := err.(*exec.ExitError); ok && exitError.ExitCode() == 1 {
209+
logger.Warn("Container scan completed with vulnerabilities", logrus.Fields{
210+
"image": imageName, "exit_code": 1,
211+
})
212+
fmt.Println()
213+
color.Red("❌ Vulnerabilities found in %s", imageName)
214+
return
215+
}
216+
logger.Error("Failed to run Trivy", logrus.Fields{"error": err.Error()})
217+
color.Red("❌ Error scanning %s: %v", imageName, err)
218+
}
136219

137-
trivyPath := getTrivyPath()
138-
trivyCmd := exec.Command(trivyPath, buildTrivyArgs(imageName)...)
139-
trivyCmd.Stdout = os.Stdout
140-
trivyCmd.Stderr = os.Stderr
220+
// detectImages auto-detects images from Dockerfile or docker-compose.yml
221+
func detectImages() []string {
222+
// Priority 0: Check explicit --dockerfile flag
223+
if dockerfileFlag != "" {
224+
if images := parseDockerfile(dockerfileFlag); len(images) > 0 {
225+
color.Cyan("📄 Found images in %s:", dockerfileFlag)
226+
for _, img := range images {
227+
fmt.Printf(" • %s\n", img)
228+
}
229+
fmt.Println()
230+
return images
231+
}
232+
color.Yellow("⚠️ No FROM instructions found in %s", dockerfileFlag)
233+
return nil
234+
}
141235

142-
logger.Info("Running Trivy container scan", logrus.Fields{"command": trivyCmd.String()})
143-
fmt.Printf("🔍 Scanning container image: %s\n\n", imageName)
236+
// Priority 0: Check explicit --compose-file flag
237+
if composeFileFlag != "" {
238+
if images := parseDockerCompose(composeFileFlag); len(images) > 0 {
239+
color.Cyan("📄 Found images in %s:", composeFileFlag)
240+
for _, img := range images {
241+
fmt.Printf(" • %s\n", img)
242+
}
243+
fmt.Println()
244+
return images
245+
}
246+
color.Yellow("⚠️ No images found in %s", composeFileFlag)
247+
return nil
248+
}
249+
250+
// Priority 1: Auto-detect Dockerfile in current directory
251+
if images := parseDockerfile("Dockerfile"); len(images) > 0 {
252+
color.Cyan("📄 Found images in Dockerfile:")
253+
for _, img := range images {
254+
fmt.Printf(" • %s\n", img)
255+
}
256+
fmt.Println()
257+
return images
258+
}
259+
260+
// Priority 2: Auto-detect docker-compose files
261+
composeFiles := []string{"docker-compose.yml", "docker-compose.yaml", "compose.yml", "compose.yaml"}
262+
for _, composeFile := range composeFiles {
263+
if images := parseDockerCompose(composeFile); len(images) > 0 {
264+
color.Cyan("📄 Found images in %s:", composeFile)
265+
for _, img := range images {
266+
fmt.Printf(" • %s\n", img)
267+
}
268+
fmt.Println()
269+
return images
270+
}
271+
}
272+
273+
return nil
274+
}
275+
276+
// parseDockerfile extracts FROM images from a Dockerfile
277+
func parseDockerfile(path string) []string {
278+
file, err := os.Open(path)
279+
if err != nil {
280+
return nil
281+
}
282+
defer file.Close()
283+
284+
var images []string
285+
seen := make(map[string]bool)
286+
scanner := bufio.NewScanner(file)
287+
288+
for scanner.Scan() {
289+
line := scanner.Text()
290+
matches := dockerfileFromPattern.FindStringSubmatch(line)
291+
if len(matches) > 1 {
292+
image := matches[1]
293+
// Skip build stage aliases (e.g., FROM golang:1.21 AS builder)
294+
// and scratch images
295+
if image != "scratch" && !seen[image] {
296+
seen[image] = true
297+
images = append(images, image)
298+
}
299+
}
300+
}
301+
302+
return images
303+
}
304+
305+
// dockerComposeConfig represents the structure of docker-compose.yml
306+
type dockerComposeConfig struct {
307+
Services map[string]struct {
308+
Image string `yaml:"image"`
309+
Build *struct {
310+
Context string `yaml:"context"`
311+
Dockerfile string `yaml:"dockerfile"`
312+
} `yaml:"build"`
313+
} `yaml:"services"`
314+
}
315+
316+
// parseDockerCompose extracts images from docker-compose.yml
317+
func parseDockerCompose(path string) []string {
318+
data, err := os.ReadFile(path)
319+
if err != nil {
320+
return nil
321+
}
322+
323+
var config dockerComposeConfig
324+
if err := yaml.Unmarshal(data, &config); err != nil {
325+
logger.Warn("Failed to parse docker-compose file", logrus.Fields{"path": path, "error": err.Error()})
326+
return nil
327+
}
328+
329+
var images []string
330+
seen := make(map[string]bool)
331+
332+
for serviceName, service := range config.Services {
333+
// If service has an image defined, use it
334+
if service.Image != "" && !seen[service.Image] {
335+
seen[service.Image] = true
336+
images = append(images, service.Image)
337+
}
338+
339+
// If service has a build context with Dockerfile, parse it
340+
if service.Build != nil {
341+
dockerfilePath := "Dockerfile"
342+
if service.Build.Dockerfile != "" {
343+
dockerfilePath = service.Build.Dockerfile
344+
}
345+
if service.Build.Context != "" {
346+
dockerfilePath = filepath.Join(service.Build.Context, dockerfilePath)
347+
}
348+
349+
if dockerfileImages := parseDockerfile(dockerfilePath); len(dockerfileImages) > 0 {
350+
for _, img := range dockerfileImages {
351+
if !seen[img] {
352+
seen[img] = true
353+
images = append(images, img)
354+
logger.Info("Found base image from Dockerfile", logrus.Fields{
355+
"service": serviceName,
356+
"dockerfile": dockerfilePath,
357+
"image": img,
358+
})
359+
}
360+
}
361+
}
362+
}
363+
}
144364

145-
handleTrivyResult(trivyCmd.Run(), imageName)
365+
return images
146366
}
147367

148368
// buildTrivyArgs constructs the Trivy command arguments based on flags

0 commit comments

Comments
 (0)