Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions cli/cmd/scan.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down
4 changes: 3 additions & 1 deletion cli/config/loader.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -113,6 +114,7 @@ func setConfigDefaults(config *SecurityConfig) {
"trivy_medium": -1,
"trivy_low": -1,
"license_violations": -1,
"checkov": -1,
}

for key, defaultValue := range defaults {
Expand All @@ -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
Expand Down
173 changes: 173 additions & 0 deletions cli/scanners/checkov.go
Original file line number Diff line number Diff line change
@@ -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
}
124 changes: 124 additions & 0 deletions cli/scanners/checkov_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
27 changes: 25 additions & 2 deletions cli/scanners/orchestrator.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
}
}
}
}
1 change: 1 addition & 0 deletions cli/scanners/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ type ScanOptions struct {
EnableTrivy bool
EnableTrivyImage bool
EnableLicenses bool
EnableCheckov bool
DockerImages []string
ExcludePaths []string
FailOnThresholds map[string]int
Expand Down
Loading
Loading