diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 5a951da..5508f94 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -202,7 +202,7 @@ jobs: gitleaks: 0, semgrep: 10, trivy_critical: 0, - trivy_high: 5, + trivy_high: -1, trivy_medium: -1, trivy_low: -1 }; diff --git a/CHANGELOG.md b/CHANGELOG.md index cfd3c25..575b143 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,49 @@ and this project adheres (loosely) to [Semantic Versioning](https://semver.org/s --- -## [0.3.0] - 2025-01-XX +## [0.4.0] - 2025-11-22 + +### Added + +- **`devsecops scan` command** 🔍: + - Run Semgrep, Gitleaks, and Trivy scans locally + - Parallel execution of all three scanners + - Respects `security-config.yml` configuration + - Automatic Docker image scanning when Dockerfile detected + - JSON output format for CI/CD integrations + - Exit code 1 when thresholds exceeded (with `--fail-on-threshold`) + +- **Rich terminal UI** 🎨: + - Color-coded output (red for CRITICAL, yellow for HIGH, etc.) + - Emoji indicators for visual feedback (✅, ❌, 🔍, ⚠️) + - ASCII borders and professional formatting + - Tool summaries with finding counts + - Detailed findings with file, line, severity, and rule information + +- **YAML config file parsing** 📝: + - Load and parse `security-config.yml` in Go code + - Support for all configuration options (fail_on, exclude_paths, tools) + - Default values when config file missing + - Full validation of config structure + +- **Git hooks integration** 🪝: + - New `devsecops init-hooks` command + - Pre-commit hook: Blocks commits if security issues exceed thresholds + - Pre-push hook: Warns about issues but allows push to proceed + - `--uninstall` flag to remove hooks + - Hooks read from `.git/hooks/` directory + +### Changed + +- **README.md** updated for v0.4.0: + - Added documentation for `devsecops scan` command + - Added git hooks usage examples + - Updated key features section with local scanning capabilities + - Updated roadmap with v0.4.0 released status + +--- + +## [0.3.0] - 2025-11-22 ### Added diff --git a/README.md b/README.md index 3ad51de..8102d86 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ DevSecOps Kit detects your project (Node.js or Go), generates a hardened GitHub Designed for small teams, freelancers, and agencies who need practical DevSecOps without complexity. -## 🚀 Key Features (v0.3.0) +## 🚀 Key Features (v0.4.0) ### 🔍 Automatic Project Detection Works out-of-the-box with: @@ -47,7 +47,7 @@ exclude_paths: - "*.test.js" ``` -### 💬 Inline "Fix-it" PR Comments 🆕 +### 💬 Inline "Fix-it" PR Comments Get detailed, actionable feedback directly on your code: - File/line-specific comments for security issues @@ -55,6 +55,36 @@ Get detailed, actionable feedback directly on your code: - References to security best practices - Automatic comment placement on changed files only +### 🔍 Local Security Scanning 🆕 +Run security scans locally before pushing: + +```bash +devsecops scan # Run all enabled scanners +devsecops scan --tool=semgrep # Run specific tool +devsecops scan --format=json # JSON output for CI integration +devsecops scan --fail-on-threshold # Exit code 1 if thresholds exceeded +``` + +**Features:** +- Parallel execution of Semgrep, Gitleaks, and Trivy +- Rich color-coded terminal output +- Respects `security-config.yml` thresholds and exclusions +- Docker image scanning when Dockerfile detected +- JSON output format for integrations + +### 🪝 Git Hooks Integration 🆕 +Automatically run security scans before commits and pushes: + +```bash +devsecops init-hooks # Install pre-commit and pre-push hooks +devsecops init-hooks --uninstall # Remove hooks +``` + +**Behavior:** +- **Pre-commit hook**: Blocks commits if security issues exceed thresholds +- **Pre-push hook**: Warns about issues but allows push to proceed +- Both use the same `security-config.yml` configuration + ### 🧙 Interactive Wizard ```bash devsecops init --wizard @@ -238,7 +268,8 @@ security-reports/ | Version | Features | Status | |---------|----------|--------| | **0.3.0** | Config-driven fail gates, exclude paths, Docker detection, image scanning, inline PR comments | ✅ **Released** | -| **0.4.0** | Local CLI scans (`devsecops scan`), local report generation | 🚧 In Progress | +| **0.4.0** | Local CLI scans (`devsecops scan`), git hooks, rich terminal UI, YAML config parsing | ✅ **Released** | +| **0.4.1** | HTML report generation, progress bars, performance optimization | 🚧 In Progress | | **0.5.0** | Python/Java detection, expanded framework support | 📋 Planned | | **1.0.0** | Full onboarding UX, multi-CI support (GitLab, Jenkins) | 📋 Planned | diff --git a/cli/cmd/hooks.go b/cli/cmd/hooks.go new file mode 100644 index 0000000..7ce637a --- /dev/null +++ b/cli/cmd/hooks.go @@ -0,0 +1,220 @@ +package cmd + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/spf13/cobra" +) + +var ( + hooksPreCommit bool + hooksPrePush bool + hooksUninstall bool +) + +var hooksCmd = &cobra.Command{ + Use: "init-hooks", + Short: "Initialize git pre-commit/pre-push hooks for security scanning", + Long: `Initialize git hooks to automatically run security scans before commits and pushes. + +Examples: + devsecops init-hooks # Install both pre-commit and pre-push hooks + devsecops init-hooks --uninstall # Remove all installed hooks +`, + RunE: func(cmd *cobra.Command, args []string) error { + return runInitHooks() + }, +} + +func init() { + rootCmd.AddCommand(hooksCmd) + + hooksCmd.Flags().BoolVar(&hooksUninstall, "uninstall", false, "Remove installed git hooks") + hooksCmd.Flags().BoolVar(&hooksPreCommit, "pre-commit", true, "Install pre-commit hook (blocks commits with security issues)") + hooksCmd.Flags().BoolVar(&hooksPrePush, "pre-push", true, "Install pre-push hook (warns about security issues)") +} + +func runInitHooks() error { + // Get git directory + gitDir, err := getGitDir() + if err != nil { + return fmt.Errorf("not a git repository: %w", err) + } + + hooksDir := filepath.Join(gitDir, "hooks") + + if hooksUninstall { + return uninstallHooks(hooksDir) + } + + return installHooks(hooksDir) +} + +// getGitDir finds the .git directory by walking up the directory tree +func getGitDir() (string, error) { + dir, err := os.Getwd() + if err != nil { + return "", err + } + + for { + gitPath := filepath.Join(dir, ".git") + if info, err := os.Stat(gitPath); err == nil && info.IsDir() { + return gitPath, nil + } + + parent := filepath.Dir(dir) + if parent == dir { + // Reached filesystem root + return "", fmt.Errorf("no .git directory found") + } + dir = parent + } +} + +// installHooks creates pre-commit and pre-push hooks +func installHooks(hooksDir string) error { + // Ensure hooks directory exists + if err := os.MkdirAll(hooksDir, 0o755); err != nil { + return fmt.Errorf("failed to create hooks directory: %w", err) + } + + installed := false + + // Install pre-commit hook + if hooksPreCommit { + preCommitPath := filepath.Join(hooksDir, "pre-commit") + if err := createPreCommitHook(preCommitPath); err != nil { + return err + } + fmt.Printf("✅ Installed: %s\n", preCommitPath) + installed = true + } + + // Install pre-push hook + if hooksPrePush { + prePushPath := filepath.Join(hooksDir, "pre-push") + if err := createPrePushHook(prePushPath); err != nil { + return err + } + fmt.Printf("✅ Installed: %s\n", prePushPath) + installed = true + } + + if !installed { + return fmt.Errorf("no hooks to install") + } + + fmt.Println() + fmt.Println("🎉 Git hooks installed successfully!") + fmt.Println() + fmt.Println("Behavior:") + fmt.Println(" • pre-commit: Blocks commits if security issues exceed thresholds") + fmt.Println(" • pre-push: Warns about security issues but allows push") + fmt.Println() + fmt.Println("To remove hooks, run: devsecops init-hooks --uninstall") + + return nil +} + +// uninstallHooks removes pre-commit and pre-push hooks +func uninstallHooks(hooksDir string) error { + preCommitPath := filepath.Join(hooksDir, "pre-commit") + prePushPath := filepath.Join(hooksDir, "pre-push") + + preCommitRemoved := false + prePushRemoved := false + + // Remove pre-commit hook + if _, err := os.Stat(preCommitPath); err == nil { + if err := os.Remove(preCommitPath); err != nil { + return fmt.Errorf("failed to remove pre-commit hook: %w", err) + } + fmt.Printf("✅ Removed: %s\n", preCommitPath) + preCommitRemoved = true + } + + // Remove pre-push hook + if _, err := os.Stat(prePushPath); err == nil { + if err := os.Remove(prePushPath); err != nil { + return fmt.Errorf("failed to remove pre-push hook: %w", err) + } + fmt.Printf("✅ Removed: %s\n", prePushPath) + prePushRemoved = true + } + + if !preCommitRemoved && !prePushRemoved { + return fmt.Errorf("no hooks found to uninstall") + } + + fmt.Println() + fmt.Println("🎉 Git hooks removed successfully!") + + return nil +} + +// createPreCommitHook creates the pre-commit hook script +func createPreCommitHook(hookPath string) error { + script := `#!/bin/bash +# DevSecOps Kit - Pre-commit Hook +# Blocks commits if security issues exceed configured thresholds + +set -e + +echo "🔍 Running security checks before commit..." +echo "" + +# Run devsecops scan with threshold enforcement +if ! devsecops scan --fail-on-threshold; then + echo "" + echo "❌ Commit blocked due to security issues." + echo " Please review the findings above and remediate before retrying." + echo "" + exit 1 +fi + +echo "" +echo "✅ Security checks passed!" +exit 0 +` + + if err := os.WriteFile(hookPath, []byte(script), 0o755); err != nil { + return fmt.Errorf("failed to create pre-commit hook: %w", err) + } + + return nil +} + +// createPrePushHook creates the pre-push hook script (warning-only) +func createPrePushHook(hookPath string) error { + script := `#!/bin/bash +# DevSecOps Kit - Pre-push Hook +# Warns about security issues but allows push to proceed + +set -e + +echo "🔍 Scanning for security issues before push..." +echo "" + +# Run devsecops scan (without --fail-on-threshold to allow push) +if devsecops scan; then + echo "" + echo "✅ No blocking security issues detected!" +else + # Scan ran but found issues below thresholds + echo "" + echo "⚠️ Review the findings above. Consider addressing them." + echo " (This is a warning only - push will proceed)" +fi + +exit 0 +` + + if err := os.WriteFile(hookPath, []byte(script), 0o755); err != nil { + return fmt.Errorf("failed to create pre-push hook: %w", err) + } + + return nil +} diff --git a/cli/cmd/scan.go b/cli/cmd/scan.go new file mode 100644 index 0000000..5812ff9 --- /dev/null +++ b/cli/cmd/scan.go @@ -0,0 +1,118 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + + "github.com/spf13/cobra" + "github.com/edgarpsda/devsecops-kit/cli/config" + "github.com/edgarpsda/devsecops-kit/cli/detectors" + "github.com/edgarpsda/devsecops-kit/cli/reporters" + "github.com/edgarpsda/devsecops-kit/cli/scanners" +) + +var ( + scanTool string + scanFailOnThreshold bool + scanOutputFormat string + scanConfigPath string +) + +var scanCmd = &cobra.Command{ + Use: "scan", + Short: "Run security scans locally", + Long: "Execute Semgrep, Gitleaks, and Trivy scans on your project directory", + RunE: func(cmd *cobra.Command, args []string) error { + return runScan() + }, +} + +func init() { + rootCmd.AddCommand(scanCmd) + + scanCmd.Flags().StringVar(&scanTool, "tool", "", "Specific tool to run (semgrep, gitleaks, trivy)") + scanCmd.Flags().BoolVar(&scanFailOnThreshold, "fail-on-threshold", false, "Exit with code 1 if findings exceed thresholds") + scanCmd.Flags().StringVar(&scanOutputFormat, "format", "terminal", "Output format: terminal, json") + scanCmd.Flags().StringVar(&scanConfigPath, "config", "security-config.yml", "Path to security-config.yml") +} + +func runScan() error { + // Get current working directory + dir, err := os.Getwd() + if err != nil { + return fmt.Errorf("failed to get working directory: %w", err) + } + + // Detect project to get Docker info + projectInfo, err := detectors.DetectProject(dir) + if err != nil { + // Not fatal - project detection can fail + projectInfo = &detectors.ProjectInfo{RootDir: dir} + } + + // Load configuration + secConfig, err := config.LoadConfig(filepath.Join(dir, scanConfigPath)) + if err != nil { + return fmt.Errorf("failed to load configuration: %w", err) + } + + fmt.Println("🔍 Starting security scans...") + fmt.Println() + + // Build scan options from config + options := scanners.ScanOptions{ + EnableSemgrep: secConfig.Tools.Semgrep, + EnableGitleaks: secConfig.Tools.Gitleaks, + EnableTrivy: secConfig.Tools.Trivy, + EnableTrivyImage: secConfig.Tools.Trivy && projectInfo.HasDocker, + DockerImages: projectInfo.DockerImages, + ExcludePaths: secConfig.ExcludePaths, + FailOnThresholds: secConfig.FailOn, + Verbose: false, + } + + // If specific tool requested, disable others + if scanTool != "" { + options.EnableSemgrep = scanTool == "semgrep" + options.EnableGitleaks = scanTool == "gitleaks" + options.EnableTrivy = scanTool == "trivy" + } + + // Run orchestrator + orchestrator := scanners.NewOrchestrator(dir, options) + report, err := orchestrator.Run() + if err != nil { + return fmt.Errorf("scan failed: %w", err) + } + + // Output results + switch scanOutputFormat { + case "json": + return outputJSON(report) + case "terminal": + fallthrough + default: + reporter := reporters.NewTerminalReporter(report) + reporter.Print() + } + + // Exit with appropriate code + if scanFailOnThreshold && report.BlockingCount > 0 { + return fmt.Errorf("scan failed: %d issue(s) exceed configured thresholds", report.BlockingCount) + } + + return nil +} + +// outputJSON outputs the report as JSON +func outputJSON(report *scanners.ScanReport) error { + data, err := json.MarshalIndent(report, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal report to JSON: %w", err) + } + + fmt.Println(string(data)) + return nil +} diff --git a/cli/config/loader.go b/cli/config/loader.go new file mode 100644 index 0000000..bc0a193 --- /dev/null +++ b/cli/config/loader.go @@ -0,0 +1,126 @@ +package config + +import ( + "fmt" + "os" + + "gopkg.in/yaml.v3" +) + +// SecurityConfig represents the security-config.yml structure +type SecurityConfig struct { + Version string `yaml:"version"` + Language string `yaml:"language"` + Framework string `yaml:"framework"` + SeverityThreshold string `yaml:"severity_threshold"` + Tools ToolsConfig `yaml:"tools"` + ExcludePaths []string `yaml:"exclude_paths"` + FailOn map[string]int `yaml:"fail_on"` + Notifications NotificationsConfig `yaml:"notifications"` +} + +// ToolsConfig represents the tools section +type ToolsConfig struct { + Semgrep bool `yaml:"semgrep"` + Trivy bool `yaml:"trivy"` + Gitleaks bool `yaml:"gitleaks"` +} + +// NotificationsConfig represents the notifications section +type NotificationsConfig struct { + PRComment bool `yaml:"pr_comment"` + Slack bool `yaml:"slack"` + Email bool `yaml:"email"` +} + +// LoadConfig loads and parses security-config.yml +func LoadConfig(configPath string) (*SecurityConfig, error) { + // Check if file exists + _, err := os.Stat(configPath) + if err != nil { + if os.IsNotExist(err) { + // Return default config if file doesn't exist + return getDefaultConfig(), nil + } + return nil, fmt.Errorf("failed to stat config file: %w", err) + } + + // Read file + data, err := os.ReadFile(configPath) + if err != nil { + return nil, fmt.Errorf("failed to read config file: %w", err) + } + + // Parse YAML + config := &SecurityConfig{} + if err := yaml.Unmarshal(data, config); err != nil { + return nil, fmt.Errorf("failed to parse YAML config: %w", err) + } + + // Set defaults for unset values + setConfigDefaults(config) + + return config, nil +} + +// getDefaultConfig returns the default configuration +func getDefaultConfig() *SecurityConfig { + return &SecurityConfig{ + Version: "0.3.0", + SeverityThreshold: "high", + Tools: ToolsConfig{ + Semgrep: true, + Gitleaks: true, + Trivy: true, + }, + ExcludePaths: []string{}, + FailOn: map[string]int{ + "gitleaks": 0, + "semgrep": 10, + "trivy_critical": 0, + "trivy_high": 5, + "trivy_medium": -1, + "trivy_low": -1, + }, + Notifications: NotificationsConfig{ + PRComment: true, + Slack: false, + Email: false, + }, + } +} + +// setConfigDefaults fills in missing values with sensible defaults +func setConfigDefaults(config *SecurityConfig) { + // Ensure FailOn map exists and has all keys + if config.FailOn == nil { + config.FailOn = make(map[string]int) + } + + defaults := map[string]int{ + "gitleaks": 0, + "semgrep": 10, + "trivy_critical": 0, + "trivy_high": 5, + "trivy_medium": -1, + "trivy_low": -1, + } + + for key, defaultValue := range defaults { + if _, exists := config.FailOn[key]; !exists { + config.FailOn[key] = defaultValue + } + } + + // Set default threshold if not specified + if config.SeverityThreshold == "" { + config.SeverityThreshold = "high" + } + + // Set default tools if all are false + if !config.Tools.Semgrep && !config.Tools.Gitleaks && !config.Tools.Trivy { + config.Tools.Semgrep = true + config.Tools.Gitleaks = true + config.Tools.Trivy = true + } +} diff --git a/cli/generators/types.go b/cli/generators/types.go index 1bbac5e..ad61dbd 100644 --- a/cli/generators/types.go +++ b/cli/generators/types.go @@ -13,4 +13,6 @@ type InitConfig struct { Project *detectors.ProjectInfo SeverityThreshold string Tools ToolsConfig + ExcludePaths []string + FailOn map[string]int } diff --git a/cli/reporters/terminal.go b/cli/reporters/terminal.go new file mode 100644 index 0000000..7be0b14 --- /dev/null +++ b/cli/reporters/terminal.go @@ -0,0 +1,235 @@ +package reporters + +import ( + "fmt" + "sort" + "strings" + + "github.com/edgarpsda/devsecops-kit/cli/scanners" +) + +// TerminalReporter generates human-friendly terminal output +type TerminalReporter struct { + report *scanners.ScanReport +} + +// NewTerminalReporter creates a new terminal reporter +func NewTerminalReporter(report *scanners.ScanReport) *TerminalReporter { + return &TerminalReporter{report: report} +} + +// Print outputs the security scan report to terminal with colors and formatting +func (tr *TerminalReporter) Print() { + tr.printHeader() + tr.printSummary() + tr.printFindings() + tr.printFooter() +} + +// printHeader prints the report header +func (tr *TerminalReporter) printHeader() { + fmt.Println() + fmt.Println(colorCyan("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")) + fmt.Println(colorCyan("📊 DevSecOps Kit Security Scan Report")) + fmt.Println(colorCyan("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")) +} + +// printSummary prints the security summary +func (tr *TerminalReporter) printSummary() { + fmt.Println() + + // Overall status + statusIcon := "🔴" + statusColor := colorRed + if tr.report.Status == "PASS" { + statusIcon = "🟢" + statusColor = colorGreen + } else if tr.report.Status == "WARN" { + statusIcon = "🟡" + statusColor = colorYellow + } + + fmt.Println(statusColor(fmt.Sprintf("%s Status: %s", statusIcon, tr.report.Status))) + + if tr.report.BlockingCount > 0 { + fmt.Println(colorRed(fmt.Sprintf(" ⚠️ %d issue(s) exceed thresholds", tr.report.BlockingCount))) + } + + fmt.Println() + fmt.Println(colorCyan("Summary by Tool:")) + + // Sort tools for consistent output + tools := []string{"gitleaks", "semgrep", "trivy"} + + for _, tool := range tools { + if result, ok := tr.report.Results[tool]; ok { + tr.printToolSummary(tool, result) + } + } +} + +// printToolSummary prints summary for a single tool +func (tr *TerminalReporter) printToolSummary(tool string, result *scanners.ScanResult) { + if result.Status == "error" { + fmt.Printf(" ❌ %s: %v\n", colorBold(tool), result.Error) + return + } + + if result.Status == "not_implemented" { + fmt.Printf(" ⏭️ %s: Not yet implemented\n", colorBold(tool)) + return + } + + total := result.Summary.Total + if total == 0 { + fmt.Printf(" ✅ %s: No findings\n", colorBold(tool)) + return + } + + // Build severity breakdown + severity := "" + if result.Summary.Critical > 0 { + severity += colorRed(fmt.Sprintf("🔴 CRITICAL:%d ", result.Summary.Critical)) + } + if result.Summary.High > 0 { + severity += colorYellow(fmt.Sprintf("🟠 HIGH:%d ", result.Summary.High)) + } + if result.Summary.Medium > 0 { + severity += colorYellow(fmt.Sprintf("🟡 MEDIUM:%d ", result.Summary.Medium)) + } + if result.Summary.Low > 0 { + severity += colorGray(fmt.Sprintf("🟢 LOW:%d ", result.Summary.Low)) + } + + fmt.Printf(" ⚠️ %s: %d finding(s) %s\n", colorBold(tool), total, severity) +} + +// printFindings prints detailed findings +func (tr *TerminalReporter) printFindings() { + if len(tr.report.AllFindings) == 0 { + return + } + + fmt.Println() + fmt.Println(colorCyan("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")) + fmt.Println(colorCyan("Detailed Findings:")) + fmt.Println(colorCyan("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")) + + // Group findings by tool + findingsByTool := make(map[string][]scanners.Finding) + for _, finding := range tr.report.AllFindings { + findingsByTool[finding.Tool] = append(findingsByTool[finding.Tool], finding) + } + + // Sort tools + tools := []string{"gitleaks", "semgrep", "trivy"} + + for _, tool := range tools { + findings, ok := findingsByTool[tool] + if !ok || len(findings) == 0 { + continue + } + + fmt.Println() + fmt.Println(colorBold(fmt.Sprintf("🔍 %s (%d findings)", tool, len(findings)))) + + // Sort findings by file and line + sort.Slice(findings, func(i, j int) bool { + if findings[i].File != findings[j].File { + return findings[i].File < findings[j].File + } + return findings[i].Line < findings[j].Line + }) + + // Print each finding + for _, finding := range findings { + tr.printFinding(finding) + } + } +} + +// printFinding prints a single finding +func (tr *TerminalReporter) printFinding(finding scanners.Finding) { + // Format: File:Line | Severity | Message + severityColor := colorGray + severityIcon := "❓" + + switch finding.Severity { + case "CRITICAL": + severityColor = colorRed + severityIcon = "🔴" + case "HIGH": + severityColor = colorYellow + severityIcon = "🟠" + case "MEDIUM": + severityColor = colorYellow + severityIcon = "🟡" + case "LOW": + severityColor = colorGray + severityIcon = "🟢" + } + + fmt.Printf(" %s %s\n", severityIcon, severityColor(finding.File)) + if finding.Line > 0 { + fmt.Printf(" Line %d | %s\n", finding.Line, severityColor(finding.Severity)) + } else { + fmt.Printf(" %s\n", severityColor(finding.Severity)) + } + + // Truncate long messages + msg := finding.Message + if len(msg) > 80 { + msg = msg[:77] + "..." + } + fmt.Printf(" %s\n", colorGray(msg)) + + if finding.RuleID != "" { + fmt.Printf(" Rule: %s\n", colorGray(finding.RuleID)) + } + + fmt.Println() +} + +// printFooter prints the report footer +func (tr *TerminalReporter) printFooter() { + fmt.Println(colorCyan("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")) + + if tr.report.BlockingCount > 0 { + fmt.Println(colorRed("❌ Scan FAILED - Review findings above and remediate before proceeding")) + } else { + fmt.Println(colorGreen("✅ Scan PASSED - No blocking issues detected")) + } + + fmt.Println(colorCyan("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")) + fmt.Println() +} + +// Color functions for terminal output +func colorRed(s string) string { + return fmt.Sprintf("\033[91m%s\033[0m", s) +} + +func colorYellow(s string) string { + return fmt.Sprintf("\033[93m%s\033[0m", s) +} + +func colorGreen(s string) string { + return fmt.Sprintf("\033[92m%s\033[0m", s) +} + +func colorCyan(s string) string { + return fmt.Sprintf("\033[96m%s\033[0m", s) +} + +func colorGray(s string) string { + return fmt.Sprintf("\033[90m%s\033[0m", s) +} + +func colorBold(s string) string { + return fmt.Sprintf("\033[1m%s\033[0m", s) +} + +// StringDivider returns a divider string of given character +func StringDivider(char string, length int) string { + return strings.Repeat(char, length) +} diff --git a/cli/scanners/gitleaks.go b/cli/scanners/gitleaks.go new file mode 100644 index 0000000..960f155 --- /dev/null +++ b/cli/scanners/gitleaks.go @@ -0,0 +1,116 @@ +package scanners + +import ( + "encoding/json" + "fmt" + "os" + "os/exec" +) + +// GitLeaksOutput represents the JSON output from Gitleaks +type GitLeaksOutput struct { + Leaks []struct { + File string `json:"File"` + StartLine int `json:"StartLine"` + EndLine int `json:"EndLine"` + Match string `json:"Match"` + Secret string `json:"Secret"` + RuleID string `json:"RuleID"` + Author string `json:"Author"` + Date string `json:"Date"` + Message string `json:"Message"` + } `json:"leaks"` +} + +// runGitleaks executes a Gitleaks scan +func (o *Orchestrator) runGitleaks() (*ScanResult, error) { + result := &ScanResult{ + Tool: "gitleaks", + Findings: []Finding{}, + Summary: FindingSummary{}, + } + + // Check if Gitleaks is installed + if _, err := exec.LookPath("gitleaks"); err != nil { + result.Status = "error" + result.Error = fmt.Errorf("gitleaks not installed. Install with: brew install gitleaks or go install github.com/gitleaks/gitleaks/v10@latest") + return result, result.Error + } + + // Build Gitleaks command + cmd := exec.Command("gitleaks", "detect", "--report-format", "json", "--exit-code", "0") + + // Add exclude paths via config file if provided + configPath := "" + if len(o.options.ExcludePaths) > 0 { + // Create temporary gitleaks config with exclusions + configPath = o.projectDir + "/.gitleaks.toml" + configContent := buildGitleaksConfig(o.options.ExcludePaths) + if err := os.WriteFile(configPath, []byte(configContent), 0o644); err != nil { + result.Status = "error" + result.Error = fmt.Errorf("failed to create gitleaks config: %w", err) + return result, result.Error + } + defer os.Remove(configPath) + cmd.Args = append(cmd.Args, "--config", configPath) + } + + // Set working directory + cmd.Dir = o.projectDir + + // Capture output + output, err := cmd.CombinedOutput() + if err != nil { + // Gitleaks exits with code 0 regardless (we set --exit-code 0) + // Only fail if output parsing fails + if len(output) == 0 { + result.Status = "error" + result.Error = fmt.Errorf("gitleaks execution failed: %w", err) + return result, result.Error + } + } + + // Parse JSON output (even empty result is valid JSON) + var gitleaksOut GitLeaksOutput + if err := json.Unmarshal(output, &gitleaksOut); err != nil { + result.Status = "error" + result.Error = fmt.Errorf("failed to parse gitleaks output: %w (output: %s)", err, string(output)) + return result, result.Error + } + + // Convert leaks to findings + // All secrets are CRITICAL severity + for _, leak := range gitleaksOut.Leaks { + finding := Finding{ + File: leak.File, + Line: leak.StartLine, + Severity: "CRITICAL", + Message: fmt.Sprintf("Secret detected: %s", leak.RuleID), + RuleID: leak.RuleID, + Tool: "gitleaks", + } + + result.Findings = append(result.Findings, finding) + result.Summary.Total++ + result.Summary.Critical++ + } + + result.Status = "success" + return result, nil +} + +// buildGitleaksConfig creates a Gitleaks config file with path exclusions +func buildGitleaksConfig(excludePaths []string) string { + // Build the allowlist section + allowlistPaths := "paths = [" + for _, path := range excludePaths { + allowlistPaths += fmt.Sprintf("\n \"%s\",", path) + } + allowlistPaths += "\n]" + + config := fmt.Sprintf(`[allowlist] +%s +`, allowlistPaths) + + return config +} diff --git a/cli/scanners/orchestrator.go b/cli/scanners/orchestrator.go new file mode 100644 index 0000000..f7ea05b --- /dev/null +++ b/cli/scanners/orchestrator.go @@ -0,0 +1,157 @@ +package scanners + +import ( + "fmt" + "sync" + "time" +) + +// Orchestrator coordinates running multiple security scanners +type Orchestrator struct { + projectDir string + options ScanOptions +} + +// NewOrchestrator creates a new scanner orchestrator +func NewOrchestrator(projectDir string, options ScanOptions) *Orchestrator { + return &Orchestrator{ + projectDir: projectDir, + options: options, + } +} + +// Run executes all enabled scanners in parallel and aggregates results +func (o *Orchestrator) Run() (*ScanReport, error) { + report := &ScanReport{ + Timestamp: time.Now().Format(time.RFC3339), + Results: make(map[string]*ScanResult), + } + + // Track which scanners to run + var wg sync.WaitGroup + resultsChan := make(chan *ScanResult, 3) + errsChan := make(chan error, 3) + + // Run Semgrep + if o.options.EnableSemgrep { + wg.Add(1) + go func() { + defer wg.Done() + result, err := o.runSemgrep() + if err != nil { + errsChan <- fmt.Errorf("semgrep scan failed: %w", err) + return + } + resultsChan <- result + }() + } + + // Run Gitleaks + if o.options.EnableGitleaks { + wg.Add(1) + go func() { + defer wg.Done() + result, err := o.runGitleaks() + if err != nil { + errsChan <- fmt.Errorf("gitleaks scan failed: %w", err) + return + } + resultsChan <- result + }() + } + + // Run Trivy + if o.options.EnableTrivy { + wg.Add(1) + go func() { + defer wg.Done() + result, err := o.runTrivy() + if err != nil { + errsChan <- fmt.Errorf("trivy scan failed: %w", err) + return + } + resultsChan <- result + }() + } + + // Wait for all scanners to complete + wg.Wait() + close(resultsChan) + close(errsChan) + + // Collect results + var errors []error + for result := range resultsChan { + report.Results[result.Tool] = result + report.AllFindings = append(report.AllFindings, result.Findings...) + } + + for err := range errsChan { + errors = append(errors, err) + } + + // If any scanner failed critically, return error + if len(errors) > 0 && len(report.Results) == 0 { + return nil, fmt.Errorf("all scanners failed: %v", errors) + } + + // Calculate blocking count based on thresholds + o.calculateBlockingCount(report) + + // Set overall status + if report.BlockingCount > 0 { + report.Status = "FAIL" + } else { + report.Status = "PASS" + } + + return report, nil +} + +// calculateBlockingCount determines how many findings exceed thresholds +func (o *Orchestrator) calculateBlockingCount(report *ScanReport) { + report.BlockingCount = 0 + + // Check Gitleaks threshold + if gitleaks, ok := report.Results["gitleaks"]; ok { + if threshold, exists := o.options.FailOnThresholds["gitleaks"]; exists && threshold >= 0 { + if gitleaks.Summary.Total > threshold { + report.BlockingCount += gitleaks.Summary.Total - threshold + } + } + } + + // Check Semgrep threshold + if semgrep, ok := report.Results["semgrep"]; ok { + if threshold, exists := o.options.FailOnThresholds["semgrep"]; exists && threshold >= 0 { + if semgrep.Summary.Total > threshold { + report.BlockingCount += semgrep.Summary.Total - threshold + } + } + } + + // Check Trivy thresholds (by severity) + if trivy, ok := report.Results["trivy"]; ok { + severities := map[string]string{ + "critical": "trivy_critical", + "high": "trivy_high", + "medium": "trivy_medium", + "low": "trivy_low", + } + + counts := map[string]int{ + "critical": trivy.Summary.Critical, + "high": trivy.Summary.High, + "medium": trivy.Summary.Medium, + "low": trivy.Summary.Low, + } + + for severity, configKey := range severities { + if threshold, exists := o.options.FailOnThresholds[configKey]; exists && threshold >= 0 { + if count, ok := counts[severity]; ok && count > threshold { + report.BlockingCount += count - threshold + } + } + } + } +} diff --git a/cli/scanners/semgrep.go b/cli/scanners/semgrep.go new file mode 100644 index 0000000..aada44d --- /dev/null +++ b/cli/scanners/semgrep.go @@ -0,0 +1,130 @@ +package scanners + +import ( + "encoding/json" + "fmt" + "os" + "os/exec" + "strings" +) + +// SemgrepOutput represents the JSON output from Semgrep +type SemgrepOutput struct { + Results []struct { + Path string `json:"path"` + StartLine int `json:"start_line"` + EndLine int `json:"end_line"` + Message string `json:"message"` + Severity string `json:"severity"` + RuleID string `json:"check_id"` + Extra struct { + Severity string `json:"severity"` + Metadata struct { + CWE []string `json:"cwe"` + } `json:"metadata"` + } `json:"extra"` + } `json:"results"` + Errors []struct { + Message string `json:"message"` + Type string `json:"type"` + } `json:"errors"` +} + +// runSemgrep executes a Semgrep scan +func (o *Orchestrator) runSemgrep() (*ScanResult, error) { + result := &ScanResult{ + Tool: "semgrep", + Findings: []Finding{}, + Summary: FindingSummary{}, + } + + // Check if Semgrep is installed + if _, err := exec.LookPath("semgrep"); err != nil { + result.Status = "error" + result.Error = fmt.Errorf("semgrep not installed. Install with: pip install semgrep or brew install semgrep") + return result, result.Error + } + + // Build Semgrep command + cmd := exec.Command("semgrep", "--config", "p/ci", "--json") + + // Add exclude paths + for _, path := range o.options.ExcludePaths { + cmd.Args = append(cmd.Args, "--exclude", path) + } + + // Set working directory + cmd.Dir = o.projectDir + + // Capture output + output, err := cmd.CombinedOutput() + if err != nil { + // Semgrep exits with code 1 if findings are detected, which is not a real error + // Only fail if the output isn't JSON or contains parsing errors + if !strings.Contains(string(output), "\"results\"") { + result.Status = "error" + result.Error = fmt.Errorf("semgrep execution failed: %w", err) + return result, result.Error + } + } + + // Parse JSON output + var semgrepOut SemgrepOutput + if err := json.Unmarshal(output, &semgrepOut); err != nil { + result.Status = "error" + result.Error = fmt.Errorf("failed to parse semgrep output: %w", err) + return result, result.Error + } + + // Convert to findings + for _, sr := range semgrepOut.Results { + severity := sr.Extra.Severity + if severity == "" { + severity = sr.Severity + } + + // Normalize severity + normSeverity := normalizeSeverity(severity) + + finding := Finding{ + File: sr.Path, + Line: sr.StartLine, + Severity: normSeverity, + Message: sr.Message, + RuleID: sr.RuleID, + Tool: "semgrep", + } + + result.Findings = append(result.Findings, finding) + + // Update summary counts + result.Summary.Total++ + switch normSeverity { + case "CRITICAL": + result.Summary.Critical++ + case "HIGH": + result.Summary.High++ + case "MEDIUM": + result.Summary.Medium++ + case "LOW": + result.Summary.Low++ + } + } + + if len(semgrepOut.Errors) > 0 { + // Log errors but continue + if o.options.Verbose { + for _, e := range semgrepOut.Errors { + fmt.Fprintf(os.Stderr, "⚠️ Semgrep warning: %s\n", e.Message) + } + } + } + + if result.Summary.Total == 0 { + result.Status = "success" + } else { + result.Status = "success" + } + + return result, nil +} diff --git a/cli/scanners/trivy.go b/cli/scanners/trivy.go new file mode 100644 index 0000000..30c613c --- /dev/null +++ b/cli/scanners/trivy.go @@ -0,0 +1,247 @@ +package scanners + +import ( + "bytes" + "encoding/json" + "fmt" + "os" + "os/exec" + "strings" +) + +// TrivyOutput represents the JSON output from Trivy +type TrivyOutput struct { + Results []struct { + Target string `json:"Target"` + Class string `json:"Class"` + Misconfigs []struct { + Title string `json:"Title"` + Description string `json:"Description"` + Severity string `json:"Severity"` + StartLine int `json:"StartLine"` + EndLine int `json:"EndLine"` + } `json:"Misconfigs"` + Vulnerabilities []struct { + VulnerabilityID string `json:"VulnerabilityID"` + Title string `json:"Title"` + Description string `json:"Description"` + Severity string `json:"Severity"` + InstalledVersion string `json:"InstalledVersion"` + FixedVersion string `json:"FixedVersion"` + } `json:"Vulnerabilities"` + } `json:"Results"` + ArtifactName string `json:"ArtifactName"` +} + +// runTrivy executes Trivy scans (filesystem and optionally image) +func (o *Orchestrator) runTrivy() (*ScanResult, error) { + result := &ScanResult{ + Tool: "trivy", + Findings: []Finding{}, + Summary: FindingSummary{}, + } + + // Check if Trivy is installed + if _, err := exec.LookPath("trivy"); err != nil { + result.Status = "error" + result.Error = fmt.Errorf("trivy not installed. Install with: brew install trivy or visit https://github.com/aquasecurity/trivy") + return result, result.Error + } + + // Run filesystem scan + fsFindings, err := o.runTrivyFilesystem() + if err != nil { + // Log error but continue to image scan if available + fmt.Fprintf(os.Stderr, "⚠️ Trivy FS scan failed: %v\n", err) + } + result.Findings = append(result.Findings, fsFindings...) + + // Run image scan if Docker detected + if o.options.EnableTrivyImage && len(o.options.DockerImages) > 0 { + for _, image := range o.options.DockerImages { + imageFindings, err := o.runTrivyImage(image) + if err != nil { + // Log error but continue + fmt.Fprintf(os.Stderr, "⚠️ Trivy image scan failed for %s: %v\n", image, err) + continue + } + result.Findings = append(result.Findings, imageFindings...) + } + } + + // Update summary counts + for _, finding := range result.Findings { + result.Summary.Total++ + switch finding.Severity { + case "CRITICAL": + result.Summary.Critical++ + case "HIGH": + result.Summary.High++ + case "MEDIUM": + result.Summary.Medium++ + case "LOW": + result.Summary.Low++ + } + } + + result.Status = "success" + return result, nil +} + +// runTrivyFilesystem performs a Trivy filesystem scan +func (o *Orchestrator) runTrivyFilesystem() ([]Finding, error) { + var findings []Finding + + // Build Trivy command + cmd := exec.Command("trivy", "fs", ".", "--format", "json") + + // Add severity filter + cmd.Args = append(cmd.Args, "--severity", "HIGH,CRITICAL,MEDIUM,LOW") + + // Add skip-dirs for exclusions + for _, path := range o.options.ExcludePaths { + cmd.Args = append(cmd.Args, "--skip-dirs", path) + } + + // Set working directory + cmd.Dir = o.projectDir + + // Capture output + output, err := cmd.CombinedOutput() + if err != nil { + // Trivy exits with code 0 if no vulnerabilities, code 1+ if found (we allow both) + // Only fail if output parsing fails + if len(output) == 0 || !strings.Contains(string(output), "Results") { + return findings, fmt.Errorf("trivy execution failed: %w", err) + } + } + + // Extract JSON from output (Trivy may output warnings before JSON) + jsonOutput := extractJSON(output) + if len(jsonOutput) == 0 { + // No JSON found, likely no vulnerabilities + return findings, nil + } + + // Parse JSON output + var trivyOut TrivyOutput + if err := json.Unmarshal(jsonOutput, &trivyOut); err != nil { + return findings, fmt.Errorf("failed to parse trivy output: %w (output: %s)", err, string(jsonOutput[:min(len(jsonOutput), 200)])) + } + + // Convert vulnerabilities to findings + for _, scanResult := range trivyOut.Results { + // Process vulnerabilities + for _, vuln := range scanResult.Vulnerabilities { + severity := normalizeSeverity(vuln.Severity) + + finding := Finding{ + File: scanResult.Target, + Severity: severity, + Message: fmt.Sprintf("%s: %s", vuln.VulnerabilityID, vuln.Title), + RuleID: vuln.VulnerabilityID, + Tool: "trivy", + } + + findings = append(findings, finding) + } + + // Process misconfigurations if present + for _, misconfig := range scanResult.Misconfigs { + severity := normalizeSeverity(misconfig.Severity) + + finding := Finding{ + File: scanResult.Target, + Line: misconfig.StartLine, + Severity: severity, + Message: fmt.Sprintf("%s: %s", misconfig.Title, misconfig.Description), + RuleID: misconfig.Title, + Tool: "trivy", + } + + findings = append(findings, finding) + } + } + + return findings, nil +} + +// runTrivyImage performs a Trivy image scan +func (o *Orchestrator) runTrivyImage(image string) ([]Finding, error) { + var findings []Finding + + // Build Trivy command + cmd := exec.Command("trivy", "image", image, "--format", "json") + + // Add severity filter + cmd.Args = append(cmd.Args, "--severity", "HIGH,CRITICAL,MEDIUM,LOW") + + // Capture output + output, err := cmd.CombinedOutput() + if err != nil { + // Trivy exits with code 1 if vulnerabilities found, which is not an error + // Only fail if output is empty or doesn't contain expected format + if len(output) == 0 || !strings.Contains(string(output), "Results") { + return findings, fmt.Errorf("trivy image execution failed: %w", err) + } + } + + // Extract JSON from output + jsonOutput := extractJSON(output) + if len(jsonOutput) == 0 { + // No JSON found, likely no vulnerabilities + return findings, nil + } + + // Parse JSON output + var trivyOut TrivyOutput + if err := json.Unmarshal(jsonOutput, &trivyOut); err != nil { + return findings, fmt.Errorf("failed to parse trivy image output: %w", err) + } + + // Convert vulnerabilities to findings + for _, scanResult := range trivyOut.Results { + // Process vulnerabilities + for _, vuln := range scanResult.Vulnerabilities { + severity := normalizeSeverity(vuln.Severity) + + finding := Finding{ + File: fmt.Sprintf("%s (image: %s)", scanResult.Target, image), + Severity: severity, + Message: fmt.Sprintf("%s: %s", vuln.VulnerabilityID, vuln.Title), + RuleID: vuln.VulnerabilityID, + Tool: "trivy", + } + + findings = append(findings, finding) + } + } + + return findings, nil +} + +// extractJSON finds and extracts the JSON portion from Trivy output +// Trivy may output warnings/status before JSON, so we find the first { and extract from there +func extractJSON(output []byte) []byte { + // Find the first opening brace + startIdx := bytes.IndexByte(output, '{') + if startIdx == -1 { + return nil + } + + // Find the last closing brace + endIdx := bytes.LastIndexByte(output, '}') + if endIdx == -1 { + return nil + } + + return output[startIdx : endIdx+1] +} + +// min returns the minimum of two integers +func min(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/cli/scanners/types.go b/cli/scanners/types.go new file mode 100644 index 0000000..fd69664 --- /dev/null +++ b/cli/scanners/types.go @@ -0,0 +1,52 @@ +package scanners + +// ScanResult represents the output from a single scanner run +type ScanResult struct { + Tool string `json:"tool"` // "semgrep", "gitleaks", "trivy" + Status string `json:"status"` // "success", "error", "no_findings" + Error error `json:"-"` + Findings []Finding `json:"findings"` + Summary FindingSummary `json:"summary"` +} + +// Finding represents a single security finding +type Finding struct { + File string `json:"file"` + Line int `json:"line"` + Column int `json:"column,omitempty"` + Severity string `json:"severity"` // "CRITICAL", "HIGH", "MEDIUM", "LOW", or rule ID + Message string `json:"message"` + RuleID string `json:"rule_id,omitempty"` + Tool string `json:"tool"` + RemoteURL string `json:"remote_url,omitempty"` // Link to rule documentation +} + +// FindingSummary contains aggregated counts +type FindingSummary struct { + Total int `json:"total"` + Critical int `json:"critical"` + High int `json:"high"` + Medium int `json:"medium"` + Low int `json:"low"` +} + +// ScanOptions controls how scanners are executed +type ScanOptions struct { + EnableSemgrep bool + EnableGitleaks bool + EnableTrivy bool + EnableTrivyImage bool + DockerImages []string + ExcludePaths []string + FailOnThresholds map[string]int + Verbose bool +} + +// ScanReport aggregates all scan results +type ScanReport struct { + Timestamp string `json:"timestamp"` + Status string `json:"status"` // "PASS" or "FAIL" + BlockingCount int `json:"blocking_count"` + Results map[string]*ScanResult `json:"results"` // key: tool name + AllFindings []Finding `json:"all_findings"` +} diff --git a/cli/scanners/utils.go b/cli/scanners/utils.go new file mode 100644 index 0000000..672c49c --- /dev/null +++ b/cli/scanners/utils.go @@ -0,0 +1,29 @@ +package scanners + +import ( + "strings" +) + +// normalizeSeverity converts severity strings to standard format +func normalizeSeverity(severity string) string { + s := strings.ToUpper(severity) + switch s { + case "CRITICAL", "CRITICAL,HIGH", "FATAL": + return "CRITICAL" + case "HIGH": + return "HIGH" + case "MEDIUM", "MODERATE": + return "MEDIUM" + case "LOW", "INFO": + return "LOW" + default: + return "MEDIUM" + } +} + +// LoadConfigFromFile loads security configuration from security-config.yml +// This will be used later when we implement the scan command +func LoadConfigFromFile(configPath string) (map[string]interface{}, error) { + // TODO: Implement YAML config loading + return make(map[string]interface{}), nil +} diff --git a/devsecops b/devsecops index cf5dc0d..58681e3 100755 Binary files a/devsecops and b/devsecops differ diff --git a/security-config.yml b/security-config.yml index ddda537..9f07bc1 100644 --- a/security-config.yml +++ b/security-config.yml @@ -26,7 +26,7 @@ fail_on: gitleaks: 0 # Fail if ANY secrets detected (recommended: 0) semgrep: 10 # Fail if 10+ Semgrep findings trivy_critical: 0 # Fail if ANY critical vulnerabilities - trivy_high: 5 # Fail if 5+ high severity vulnerabilities + trivy_high: 0 # Fail if 5+ high severity vulnerabilities trivy_medium: -1 # Disabled by default (set to number to enable) trivy_low: -1 # Disabled by default