diff --git a/cli/cmd/scan.go b/cli/cmd/scan.go index dc6d3b0..27b3266 100644 --- a/cli/cmd/scan.go +++ b/cli/cmd/scan.go @@ -72,6 +72,7 @@ func runScan() error { EnableTrivy: secConfig.Tools.Trivy, EnableTrivyImage: secConfig.Tools.Trivy && projectInfo.HasDocker, EnableLicenses: secConfig.Licenses.Enabled, + EnableCheckov: secConfig.Tools.Checkov, DockerImages: projectInfo.DockerImages, ExcludePaths: secConfig.ExcludePaths, FailOnThresholds: secConfig.FailOn, @@ -89,6 +90,7 @@ func runScan() error { options.EnableGitleaks = scanTool == "gitleaks" options.EnableTrivy = scanTool == "trivy" options.EnableLicenses = scanTool == "licenses" + options.EnableCheckov = scanTool == "checkov" } // Run orchestrator diff --git a/cli/config/loader.go b/cli/config/loader.go index 9e0c5c1..cdf74f5 100644 --- a/cli/config/loader.go +++ b/cli/config/loader.go @@ -25,6 +25,7 @@ type ToolsConfig struct { Semgrep bool `yaml:"semgrep"` Trivy bool `yaml:"trivy"` Gitleaks bool `yaml:"gitleaks"` + Checkov bool `yaml:"checkov"` } // LicensesConfig represents the licenses section @@ -113,6 +114,7 @@ func setConfigDefaults(config *SecurityConfig) { "trivy_medium": -1, "trivy_low": -1, "license_violations": -1, + "checkov": -1, } for key, defaultValue := range defaults { @@ -126,7 +128,7 @@ func setConfigDefaults(config *SecurityConfig) { config.SeverityThreshold = "high" } - // Set default tools if all are false + // Set default tools if all core tools are false (Checkov is opt-in, excluded from this check) if !config.Tools.Semgrep && !config.Tools.Gitleaks && !config.Tools.Trivy { config.Tools.Semgrep = true config.Tools.Gitleaks = true diff --git a/cli/scanners/checkov.go b/cli/scanners/checkov.go new file mode 100644 index 0000000..2abd2a2 --- /dev/null +++ b/cli/scanners/checkov.go @@ -0,0 +1,173 @@ +package scanners + +import ( + "encoding/json" + "fmt" + "os/exec" + "strings" +) + +// checkovOutput represents the JSON output from a Checkov scan +type checkovOutput struct { + Results struct { + FailedChecks []checkovCheck `json:"failed_checks"` + } `json:"results"` + Summary struct { + Failed int `json:"failed"` + Passed int `json:"passed"` + } `json:"summary"` +} + +type checkovCheck struct { + CheckID string `json:"check_id"` + CheckName string `json:"check_name"` + FilePath string `json:"file_path"` + FileLineRange []int `json:"file_line_range"` + Resource string `json:"resource"` + Severity string `json:"severity"` // HIGH, MEDIUM, LOW (populated in newer versions) +} + +// runCheckov executes Checkov IaC scanning +func (o *Orchestrator) runCheckov() (*ScanResult, error) { + result := &ScanResult{ + Tool: "checkov", + Findings: []Finding{}, + Summary: FindingSummary{}, + } + + if _, err := exec.LookPath("checkov"); err != nil { + result.Status = "error" + result.Error = fmt.Errorf("checkov not installed: run 'pip install checkov'") + return result, result.Error + } + + args := []string{"-d", ".", "--output", "json", "--compact", "--quiet"} + for _, p := range o.options.ExcludePaths { + args = append(args, "--skip-path", p) + } + + cmd := exec.Command("checkov", args...) + cmd.Dir = o.projectDir + + output, err := cmd.Output() + // Checkov exits with code 1 when it finds violations — that's normal + if err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + if exitErr.ExitCode() != 1 { + result.Status = "error" + result.Error = fmt.Errorf("checkov exited with code %d: %s", exitErr.ExitCode(), string(exitErr.Stderr)) + return result, result.Error + } + } + } + + if len(output) == 0 { + result.Status = "success" + return result, nil + } + + // Checkov may output multiple JSON objects (one per framework) — parse the first valid one + // or handle an array of results + findings, err := parseCheckovOutput(output) + if err != nil { + result.Status = "error" + result.Error = fmt.Errorf("failed to parse checkov output: %w", err) + return result, result.Error + } + + result.Findings = findings + result.Summary = summarizeFindings(findings) + result.Status = "success" + return result, nil +} + +// parseCheckovOutput handles both single-object and array Checkov JSON output +func parseCheckovOutput(data []byte) ([]Finding, error) { + var findings []Finding + + // Try single object first + var single checkovOutput + if err := json.Unmarshal(data, &single); err == nil { + findings = append(findings, extractCheckovFindings(single)...) + return findings, nil + } + + // Try array of objects (multiple frameworks scanned) + var multi []checkovOutput + if err := json.Unmarshal(data, &multi); err == nil { + for _, out := range multi { + findings = append(findings, extractCheckovFindings(out)...) + } + return findings, nil + } + + return nil, fmt.Errorf("unrecognised checkov JSON structure") +} + +func extractCheckovFindings(out checkovOutput) []Finding { + var findings []Finding + + for _, check := range out.Results.FailedChecks { + line := 0 + if len(check.FileLineRange) > 0 { + line = check.FileLineRange[0] + } + + severity := mapCheckovSeverity(check.CheckID, check.Severity) + msg := check.CheckName + if check.Resource != "" { + msg = fmt.Sprintf("%s [%s]", check.CheckName, check.Resource) + } + + findings = append(findings, Finding{ + File: check.FilePath, + Line: line, + Severity: severity, + Message: msg, + RuleID: check.CheckID, + Tool: "checkov", + }) + } + + return findings +} + +// mapCheckovSeverity maps Checkov check IDs to severities. +// Newer Checkov versions populate the Severity field directly; +// for older versions we fall back to check-ID prefix heuristics. +func mapCheckovSeverity(checkID, rawSeverity string) string { + if raw := strings.ToUpper(rawSeverity); raw == "CRITICAL" || raw == "HIGH" || raw == "MEDIUM" || raw == "LOW" { + return raw + } + + // Heuristic fallback based on check-ID prefix + upper := strings.ToUpper(checkID) + switch { + case strings.HasPrefix(upper, "CKV2_"): + return "HIGH" + case strings.HasPrefix(upper, "CKV_K8S_"): + return "MEDIUM" + case strings.HasPrefix(upper, "CKV_AWS_"), strings.HasPrefix(upper, "CKV_AZURE_"), strings.HasPrefix(upper, "CKV_GCP_"): + return "HIGH" + default: + return "MEDIUM" + } +} + +// summarizeFindings builds a FindingSummary from a slice of findings +func summarizeFindings(findings []Finding) FindingSummary { + s := FindingSummary{Total: len(findings)} + for _, f := range findings { + switch strings.ToUpper(f.Severity) { + case "CRITICAL": + s.Critical++ + case "HIGH": + s.High++ + case "MEDIUM": + s.Medium++ + case "LOW": + s.Low++ + } + } + return s +} diff --git a/cli/scanners/checkov_test.go b/cli/scanners/checkov_test.go new file mode 100644 index 0000000..c6454be --- /dev/null +++ b/cli/scanners/checkov_test.go @@ -0,0 +1,124 @@ +package scanners + +import ( + "encoding/json" + "testing" +) + +func TestParseCheckovOutputSingleObject(t *testing.T) { + data := checkovOutput{} + data.Results.FailedChecks = []checkovCheck{ + {CheckID: "CKV_AWS_1", CheckName: "Ensure S3 bucket has access logging enabled", FilePath: "main.tf", FileLineRange: []int{10, 20}, Resource: "aws_s3_bucket.example"}, + {CheckID: "CKV_K8S_1", CheckName: "Do not admit root containers", FilePath: "deployment.yaml", FileLineRange: []int{5, 15}}, + {CheckID: "CKV2_AWS_5", CheckName: "Ensure SG is attached", FilePath: "sg.tf", FileLineRange: []int{1, 10}}, + } + + raw, _ := json.Marshal(data) + findings, err := parseCheckovOutput(raw) + if err != nil { + t.Fatalf("parseCheckovOutput failed: %v", err) + } + + if len(findings) != 3 { + t.Fatalf("expected 3 findings, got %d", len(findings)) + } + + if findings[0].RuleID != "CKV_AWS_1" { + t.Errorf("expected rule_id CKV_AWS_1, got %s", findings[0].RuleID) + } + if findings[0].Line != 10 { + t.Errorf("expected line 10, got %d", findings[0].Line) + } + if findings[0].Tool != "checkov" { + t.Errorf("expected tool checkov, got %s", findings[0].Tool) + } +} + +func TestParseCheckovOutputArray(t *testing.T) { + obj1 := checkovOutput{} + obj1.Results.FailedChecks = []checkovCheck{ + {CheckID: "CKV_AWS_1", CheckName: "S3 logging", FilePath: "main.tf"}, + } + obj2 := checkovOutput{} + obj2.Results.FailedChecks = []checkovCheck{ + {CheckID: "CKV_K8S_1", CheckName: "No root containers", FilePath: "deploy.yaml"}, + } + + raw, _ := json.Marshal([]checkovOutput{obj1, obj2}) + findings, err := parseCheckovOutput(raw) + if err != nil { + t.Fatalf("parseCheckovOutput failed: %v", err) + } + + if len(findings) != 2 { + t.Fatalf("expected 2 findings, got %d", len(findings)) + } +} + +func TestMapCheckovSeverity(t *testing.T) { + tests := []struct { + checkID string + raw string + expected string + }{ + {"CKV_AWS_1", "", "HIGH"}, + {"CKV_AZURE_1", "", "HIGH"}, + {"CKV_GCP_1", "", "HIGH"}, + {"CKV_K8S_1", "", "MEDIUM"}, + {"CKV2_AWS_5", "", "HIGH"}, + {"CKV_DOCKER_1", "", "MEDIUM"}, + {"CKV_AWS_1", "CRITICAL", "CRITICAL"}, + {"CKV_AWS_1", "LOW", "LOW"}, + } + + for _, tt := range tests { + got := mapCheckovSeverity(tt.checkID, tt.raw) + if got != tt.expected { + t.Errorf("mapCheckovSeverity(%q, %q) = %q, want %q", tt.checkID, tt.raw, got, tt.expected) + } + } +} + +func TestSummarizeFindings(t *testing.T) { + findings := []Finding{ + {Severity: "CRITICAL"}, + {Severity: "HIGH"}, + {Severity: "HIGH"}, + {Severity: "MEDIUM"}, + {Severity: "LOW"}, + } + + s := summarizeFindings(findings) + if s.Total != 5 { + t.Errorf("expected Total=5, got %d", s.Total) + } + if s.Critical != 1 { + t.Errorf("expected Critical=1, got %d", s.Critical) + } + if s.High != 2 { + t.Errorf("expected High=2, got %d", s.High) + } + if s.Medium != 1 { + t.Errorf("expected Medium=1, got %d", s.Medium) + } + if s.Low != 1 { + t.Errorf("expected Low=1, got %d", s.Low) + } +} + +func TestCheckovResourceInMessage(t *testing.T) { + out := checkovOutput{} + out.Results.FailedChecks = []checkovCheck{ + {CheckID: "CKV_AWS_1", CheckName: "S3 logging", FilePath: "main.tf", Resource: "aws_s3_bucket.my_bucket"}, + } + + raw, _ := json.Marshal(out) + findings, _ := parseCheckovOutput(raw) + + if len(findings) == 0 { + t.Fatal("expected findings") + } + if findings[0].Message != "S3 logging [aws_s3_bucket.my_bucket]" { + t.Errorf("unexpected message: %s", findings[0].Message) + } +} diff --git a/cli/scanners/orchestrator.go b/cli/scanners/orchestrator.go index 7d977ea..e126e80 100644 --- a/cli/scanners/orchestrator.go +++ b/cli/scanners/orchestrator.go @@ -29,8 +29,8 @@ func (o *Orchestrator) Run() (*ScanReport, error) { // Track which scanners to run var wg sync.WaitGroup - resultsChan := make(chan *ScanResult, 4) - errsChan := make(chan error, 4) + resultsChan := make(chan *ScanResult, 5) + errsChan := make(chan error, 5) // Run Semgrep if o.options.EnableSemgrep { @@ -88,6 +88,20 @@ func (o *Orchestrator) Run() (*ScanReport, error) { }() } + // Run Checkov + if o.options.EnableCheckov { + wg.Add(1) + go func() { + defer wg.Done() + result, err := o.runCheckov() + if err != nil { + errsChan <- fmt.Errorf("checkov scan failed: %w", err) + return + } + resultsChan <- result + }() + } + // Wait for all scanners to complete wg.Wait() close(resultsChan) @@ -177,4 +191,13 @@ func (o *Orchestrator) calculateBlockingCount(report *ScanReport) { } } } + + // Check Checkov threshold + if checkov, ok := report.Results["checkov"]; ok { + if threshold, exists := o.options.FailOnThresholds["checkov"]; exists && threshold >= 0 { + if checkov.Summary.Total > threshold { + report.BlockingCount += checkov.Summary.Total - threshold + } + } + } } diff --git a/cli/scanners/types.go b/cli/scanners/types.go index dd4b207..a0737a6 100644 --- a/cli/scanners/types.go +++ b/cli/scanners/types.go @@ -37,6 +37,7 @@ type ScanOptions struct { EnableTrivy bool EnableTrivyImage bool EnableLicenses bool + EnableCheckov bool DockerImages []string ExcludePaths []string FailOnThresholds map[string]int diff --git a/cli/templates/security-config.yml.tmpl b/cli/templates/security-config.yml.tmpl index 486118a..49c6be1 100644 --- a/cli/templates/security-config.yml.tmpl +++ b/cli/templates/security-config.yml.tmpl @@ -1,6 +1,6 @@ # Security config generated by DevSecOps Kit -version: "0.3.0" +version: "0.6.0" language: "{{ .Project.Language }}" framework: "{{ .Project.Framework }}" @@ -11,6 +11,7 @@ tools: semgrep: {{ .Tools.Semgrep }} trivy: {{ .Tools.Trivy }} gitleaks: {{ .Tools.Gitleaks }} + checkov: false # IaC scanning (Terraform, CloudFormation, K8s, Dockerfile) — requires: pip install checkov # Path exclusions for scanners (applies to all enabled tools) # Common paths to exclude: vendor/, node_modules/, test/, dist/, build/ @@ -29,6 +30,7 @@ fail_on: trivy_high: 5 # Fail if 5+ high severity vulnerabilities trivy_medium: -1 # Disabled by default (set to number to enable) trivy_low: -1 # Disabled by default + checkov: -1 # Disabled by default (set to 0 to block on any IaC violation) # Notification settings (PR comment enabled by default) notifications: