diff --git a/.codacy/codacy.yaml b/.codacy/codacy.yaml index efc9a772..c94e0717 100644 --- a/.codacy/codacy.yaml +++ b/.codacy/codacy.yaml @@ -2,3 +2,4 @@ runtimes: - node@22.2.0 tools: - eslint@9.3.0 + - trivy@0.47.0 \ No newline at end of file diff --git a/.examples/code.js b/.examples/code.js new file mode 100644 index 00000000..c4eee068 --- /dev/null +++ b/.examples/code.js @@ -0,0 +1,7 @@ +import { tryInvoke } from '@ember/utils'; + +class FooComponent extends Component { + foo() { + tryInvoke(this.args, 'bar', ['baz']); + } +} diff --git a/.examples/go.mod b/.examples/go.mod new file mode 100644 index 00000000..8be244f4 --- /dev/null +++ b/.examples/go.mod @@ -0,0 +1,10 @@ +module trivy-example + +go 1.22.3 + +require ( + github.com/aquasecurity/trivy v0.49.1 // MEDIUM ERROR + github.com/spf13/cobra v1.8.0 + github.com/sirupsen/logrus v1.4.2 + github.com/dexidp/dex v0.0.0-20200121184102-3b39c6440888 // CRITICAL ERROR - CVE-2020-26160 - Insecure JWT implementation +) \ No newline at end of file diff --git a/.gitignore b/.gitignore index 1ebd21ae..2a584d17 100644 --- a/.gitignore +++ b/.gitignore @@ -23,4 +23,7 @@ go.work.sum .idea/ -cli-v2 \ No newline at end of file +cli-v2 + +# ESLint config +eslint.config.mjs \ No newline at end of file diff --git a/README.md b/README.md index 87d2dca6..15e5bd09 100644 --- a/README.md +++ b/README.md @@ -4,15 +4,15 @@ This is a POC for what could be a new CLI for us. The idea is to rely on the nat ## Overview -The `codacy-cli-v2` is a command-line tool for Codacy that supports analyzing code using ESLint and uploading the results in SARIF format to Codacy. It provides two main commands: `analyze` and `upload`. +The `codacy-cli-v2` is a command-line tool for Codacy that supports analyzing code using ESLint, Trivy, and uploading the results in SARIF format to Codacy. It provides two main commands: `analyze` and `upload`. ### Commands -- **`analyze` Command**: Runs ESLint analysis on the codebase. +- **`analyze` Command**: Runs analysis tools on the codebase. - `--output, -o`: Output file for the results. - - `--tool, -t`: Specifies the tool to run analysis with (e.g., ESLint). + - `--tool, -t`: Specifies the tool to run analysis with (e.g., ESLint, Trivy). - `--format`: Output format (use 'sarif' for SARIF format to terminal). - - `--fix, -f`: Automatically fixes issues when possible. + - `--fix, -f`: Automatically fixes issues when possible (only applicable to certain tools). - `--new-pr`: Creates a new GitHub PR with fixed issues. - **`upload` Command With Project Token**: Uploads a SARIF file containing analysis results to Codacy. @@ -30,14 +30,15 @@ The `codacy-cli-v2` is a command-line tool for Codacy that supports analyzing co ### Important Concepts -- **`.codacy/codacy.yaml`**: Configuration file to specify `node` and `eslint` versions for the CLI. +- **`.codacy/codacy.yaml`**: Configuration file to specify runtimes and tools versions for the CLI. ```yaml runtimes: - node@22.2.0 tools: - eslint@9.3.0 + - trivy@0.50.0 -- **`codacy-cli-v2 install`**: Command to install the specified node and eslint versions before running analysis. +- **`codacy-cli-v2 install`**: Command to install the specified runtimes and tools before running analysis. ## Download @@ -78,18 +79,32 @@ To run ESLint and output the results to the terminal: codacy-cli analyze --tool eslint ``` +To run Trivy vulnerability scanner: + +```bash +codacy-cli analyze --tool trivy +``` + To output results in SARIF format to the terminal: ```bash codacy-cli analyze --tool eslint --format sarif ``` +```bash +codacy-cli analyze --tool trivy --format sarif +``` + To store the results as SARIF in a file: ```bash codacy-cli analyze -t eslint -o eslint.sarif ``` +```bash +codacy-cli analyze -t trivy -o trivy.sarif +``` + ## Upload Results To upload a SARIF file to Codacy: diff --git a/cmd/analyze.go b/cmd/analyze.go index 90c24f83..77920666 100644 --- a/cmd/analyze.go +++ b/cmd/analyze.go @@ -202,8 +202,10 @@ var analyzeCmd = &cobra.Command{ switch toolToAnalyze { case "eslint": // nothing + case "trivy": + // nothing case "": - log.Fatal("You need to specify a tool to run analysis with, e.g., '--tool eslint'", toolToAnalyze) + log.Fatal("You need to specify a tool to run analysis with, e.g., '--tool eslint' or '--tool trivy'") default: log.Fatal("Trying to run unsupported tool: ", toolToAnalyze) } @@ -215,11 +217,6 @@ var analyzeCmd = &cobra.Command{ failIfThereArePendingChanges() } - eslint := config.Config.Tools()["eslint"] - eslintInstallationDirectory := eslint.Info()["installDir"] - nodeRuntime := config.Config.Runtimes()["node"] - nodeBinary := nodeRuntime.Info()["node"] - log.Printf("Running %s...\n", toolToAnalyze) if outputFile != "" { log.Println("Output will be available at", outputFile) @@ -227,7 +224,23 @@ var analyzeCmd = &cobra.Command{ log.Println("Output will be in SARIF format") } - tools.RunEslint(workDirectory, eslintInstallationDirectory, nodeBinary, args, autoFix, outputFile, outputFormat) + switch toolToAnalyze { + case "eslint": + eslint := config.Config.Tools()["eslint"] + eslintInstallationDirectory := eslint.Info()["installDir"] + nodeRuntime := config.Config.Runtimes()["node"] + nodeBinary := nodeRuntime.Info()["node"] + + tools.RunEslint(workDirectory, eslintInstallationDirectory, nodeBinary, args, autoFix, outputFile, outputFormat) + case "trivy": + trivy := config.Config.Tools()["trivy"] + trivyBinary := trivy.Info()["trivy"] + + err := tools.RunTrivy(workDirectory, trivyBinary, args, outputFile, outputFormat) + if err != nil { + log.Printf("Error running Trivy: %v", err) + } + } if doNewPr { utils.CreatePr(false) diff --git a/cmd/init.go b/cmd/init.go index 1356624a..9548fb0e 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -72,20 +72,23 @@ func createConfigurationFile(tools []tools.Tool) error { func configFileTemplate(tools []tools.Tool) string { - // Default version + // Default versions eslintVersion := "9.3.0" + trivyVersion := "0.50.0" // Use the latest stable version for _, tool := range tools { if tool.Uuid == "f8b29663-2cb2-498d-b923-a10c6a8c05cd" { eslintVersion = tool.Version } + // If Codacy API provides UUID for Trivy, you would check it here } return fmt.Sprintf(`runtimes: - node@22.2.0 tools: - eslint@%s -`, eslintVersion) + - trivy@%s +`, eslintVersion, trivyVersion) } func buildRepositoryConfigurationFiles(token string) error { diff --git a/cmd/install.go b/cmd/install.go index 284f2c96..31f2906e 100644 --- a/cmd/install.go +++ b/cmd/install.go @@ -52,6 +52,12 @@ func fetchTools(config *cfg.ConfigType) { fmt.Println(err.Error()) log.Fatal(err) } + case "trivy": + err := cfg.InstallTrivy(tool) + if err != nil { + fmt.Println(err.Error()) + log.Fatal(err) + } default: log.Fatal("Unknown tool:", tool.Name()) } diff --git a/config/runtime.go b/config/runtime.go index 645da86f..715803e6 100644 --- a/config/runtime.go +++ b/config/runtime.go @@ -32,6 +32,8 @@ func (r *Runtime) populateInfo() { r.info = genInfoNode(r) case "eslint": r.info = genInfoEslint(r) + case "trivy": + r.info = genInfoTrivy(r) } } diff --git a/config/trivy-utils.go b/config/trivy-utils.go new file mode 100644 index 00000000..27d6384d --- /dev/null +++ b/config/trivy-utils.go @@ -0,0 +1,211 @@ +package config + +import ( + "codacy/cli-v2/utils" + "fmt" + "io" + "log" + "os" + "os/exec" + "path" + "path/filepath" + "runtime" + "strings" +) + +func genInfoTrivy(r *Runtime) map[string]string { + trivyFolder := fmt.Sprintf("%s@%s", r.Name(), r.Version()) + installDir := path.Join(Config.ToolsDirectory(), trivyFolder) + + // Path to the binary depends on OS + binaryName := "trivy" + if runtime.GOOS == "windows" { + binaryName = "trivy.exe" + } + + return map[string]string{ + "installDir": installDir, + "trivy": path.Join(installDir, binaryName), + } +} + +// InstallTrivy downloads Trivy binary based on the specified version +func InstallTrivy(trivyRuntime *Runtime) error { + log.Println("Installing Trivy...") + + // Create installation directory if it doesn't exist + installDir := trivyRuntime.Info()["installDir"] + if err := os.MkdirAll(installDir, 0755); err != nil { + return fmt.Errorf("failed to create installation directory: %w", err) + } + + // Determine OS and architecture + goos := runtime.GOOS + goarch := runtime.GOARCH + + // Map Go architecture to Trivy architecture + var trivyArch string + switch goarch { + case "386": + trivyArch = "32bit" + case "amd64": + trivyArch = "64bit" + case "arm": + trivyArch = "ARM" + case "arm64": + trivyArch = "ARM64" + default: + trivyArch = goarch + } + + // Determine file extension and platform + extension := "tar.gz" + var platform string + + switch goos { + case "linux": + platform = "Linux" + case "darwin": + platform = "macOS" + case "windows": + platform = "Windows" + extension = "zip" + default: + return fmt.Errorf("unsupported OS: %s", goos) + } + + // Construct the download URL + version := trivyRuntime.Version() + fileName := fmt.Sprintf("trivy_%s_%s-%s.%s", version, platform, trivyArch, extension) + downloadURL := fmt.Sprintf("https://github.com/aquasecurity/trivy/releases/download/v%s/%s", version, fileName) + + log.Printf("Downloading Trivy from: %s", downloadURL) + + // Download the archive to a temporary directory + tempDir, err := os.MkdirTemp("", "trivy-download") + if err != nil { + return fmt.Errorf("failed to create temporary directory: %w", err) + } + defer os.RemoveAll(tempDir) // Clean up temp dir when done + + archivePath, err := utils.DownloadFile(downloadURL, tempDir) + if err != nil { + return fmt.Errorf("failed to download Trivy: %w", err) + } + + // Use system commands for extraction to handle directory structure correctly + if extension == "tar.gz" { + // For Unix-like systems, use tar command + cmd := exec.Command("tar", "-xzf", archivePath, "-C", installDir) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("failed to extract Trivy archive: %w", err) + } + } else { + // For Windows, use the ExtractZip function but we need to handle the directory structure + err = utils.ExtractZip(archivePath, tempDir) + if err != nil { + return fmt.Errorf("failed to extract Trivy archive: %w", err) + } + + // Find the trivy binary in the extracted content and copy it to the install directory + err = filepath.Walk(tempDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + if !info.IsDir() && (info.Name() == "trivy" || info.Name() == "trivy.exe") { + // Copy the binary to the installation directory + srcFile, err := os.Open(path) + if err != nil { + return err + } + defer srcFile.Close() + + destPath := filepath.Join(installDir, info.Name()) + destFile, err := os.Create(destPath) + if err != nil { + return err + } + defer destFile.Close() + + _, err = io.Copy(destFile, srcFile) + if err != nil { + return err + } + + // Make it executable + if err := os.Chmod(destPath, 0755); err != nil { + return err + } + + return nil + } + + return nil + }) + + if err != nil { + return fmt.Errorf("failed to locate and copy Trivy binary: %w", err) + } + } + + // Verify trivy binary is available and executable + trivyBinaryPath := trivyRuntime.Info()["trivy"] + if _, err := os.Stat(trivyBinaryPath); os.IsNotExist(err) { + // If not found in root of install dir, try to find it + err := findAndMoveTrivyBinary(installDir, trivyBinaryPath) + if err != nil { + return fmt.Errorf("trivy binary not found after extraction: %w", err) + } + } + + // Make the binary executable (for Unix-like systems) + if goos != "windows" { + if err := os.Chmod(trivyBinaryPath, 0755); err != nil { + return fmt.Errorf("failed to make Trivy binary executable: %w", err) + } + } + + log.Println("Trivy installed successfully.") + return nil +} + +// findAndMoveTrivyBinary searches for the trivy binary in the directory structure and moves it to the target path +func findAndMoveTrivyBinary(rootDir, targetPath string) error { + var foundBinaryPath string + + err := filepath.Walk(rootDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + if !info.IsDir() && (strings.HasSuffix(info.Name(), "trivy") || strings.HasSuffix(info.Name(), "trivy.exe")) { + foundBinaryPath = path + return filepath.SkipDir // Stop walking once found + } + + return nil + }) + + if err != nil { + return err + } + + if foundBinaryPath == "" { + return fmt.Errorf("trivy binary not found in extracted archive") + } + + // If binary is already at target location, we're done + if foundBinaryPath == targetPath { + return nil + } + + // Copy the binary to the target location + if err := utils.CopyFile(foundBinaryPath, targetPath); err != nil { + return fmt.Errorf("failed to move trivy binary: %w", err) + } + + return nil +} diff --git a/tools/trivyRunner.go b/tools/trivyRunner.go new file mode 100644 index 00000000..a61016b1 --- /dev/null +++ b/tools/trivyRunner.go @@ -0,0 +1,55 @@ +package tools + +import ( + "fmt" + "os" + "os/exec" +) + +// RunTrivy executes Trivy vulnerability scanner with the specified options +func RunTrivy(repositoryToAnalyseDirectory string, trivyBinary string, pathsToCheck []string, outputFile string, outputFormat string) error { + cmd := exec.Command(trivyBinary, "fs") + + // Add format options + if outputFile != "" { + // When writing to file, use SARIF format + cmd.Args = append(cmd.Args, "--format", "sarif", "--output", outputFile) + } else if outputFormat == "sarif" { + // When outputting to terminal in SARIF format + cmd.Args = append(cmd.Args, "--format", "sarif") + } + + // Add severity filtering to match common expectations + // cmd.Args = append(cmd.Args, "--severity", "HIGH,CRITICAL") + + // Add specific targets or use current directory + if len(pathsToCheck) > 0 { + cmd.Args = append(cmd.Args, pathsToCheck...) + } else { + cmd.Args = append(cmd.Args, ".") + } + + // Set working directory + cmd.Dir = repositoryToAnalyseDirectory + cmd.Stderr = os.Stderr + + // If outputting to terminal and not in SARIF format, direct to stdout + if outputFile == "" && outputFormat != "sarif" { + cmd.Stdout = os.Stdout + return cmd.Run() + } + + // If outputting SARIF to terminal, capture output and print + if outputFile == "" && outputFormat == "sarif" { + output, err := cmd.Output() + if err != nil { + return fmt.Errorf("trivy scan failed: %w", err) + } + + fmt.Println(string(output)) + return nil + } + + // If outputting to file, just run the command + return cmd.Run() +} diff --git a/utils/extract.go b/utils/extract.go index 5e4b21c2..8492bc60 100644 --- a/utils/extract.go +++ b/utils/extract.go @@ -2,10 +2,11 @@ package utils import ( "context" - "github.com/mholt/archiver/v4" "io" "os" "path/filepath" + + "github.com/mholt/archiver/v4" ) func ExtractTarGz(archive *os.File, targetDir string) error { @@ -64,3 +65,51 @@ func ExtractTarGz(archive *os.File, targetDir string) error { } return nil } + +// ExtractZip extracts a zip archive to the target directory +func ExtractZip(zipPath string, targetDir string) error { + format := archiver.Zip{} + + handler := func(ctx context.Context, f archiver.File) error { + path := filepath.Join(targetDir, f.NameInArchive) + + switch f.IsDir() { + case true: + // create a directory + err := os.MkdirAll(path, 0777) + if err != nil { + return err + } + + case false: + // write a file + w, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE|os.O_TRUNC, f.Mode()) + if err != nil { + return err + } + + stream, _ := f.Open() + defer stream.Close() + + _, err = io.Copy(w, stream) + if err != nil { + return err + } + w.Close() + } + + return nil + } + + file, err := os.Open(zipPath) + if err != nil { + return err + } + defer file.Close() + + err = format.Extract(context.Background(), file, nil, handler) + if err != nil { + return err + } + return nil +} diff --git a/utils/file.go b/utils/file.go new file mode 100644 index 00000000..55fee2e6 --- /dev/null +++ b/utils/file.go @@ -0,0 +1,40 @@ +package utils + +import ( + "fmt" + "io" + "os" +) + +// CopyFile copies a file from sourcePath to destPath +func CopyFile(sourcePath, destPath string) error { + sourceFile, err := os.Open(sourcePath) + if err != nil { + return fmt.Errorf("failed to open source file: %w", err) + } + defer sourceFile.Close() + + destFile, err := os.Create(destPath) + if err != nil { + return fmt.Errorf("failed to create destination file: %w", err) + } + defer destFile.Close() + + _, err = io.Copy(destFile, sourceFile) + if err != nil { + return fmt.Errorf("failed to copy file: %w", err) + } + + // Copy file permissions + sourceInfo, err := os.Stat(sourcePath) + if err != nil { + return fmt.Errorf("failed to get source file info: %w", err) + } + + err = os.Chmod(destPath, sourceInfo.Mode()) + if err != nil { + return fmt.Errorf("failed to set file permissions: %w", err) + } + + return nil +}