From cf27a5e603d481c3bb4e8d14e0019c1000cfe8ed Mon Sep 17 00:00:00 2001 From: EdgarPsda Date: Fri, 13 Mar 2026 21:24:57 -0700 Subject: [PATCH] Add license compliance scanning Integrate license scanning via Trivy with configurable deny/allow lists in security-config.yml. Flags copyleft licenses (GPL, AGPL, LGPL) by default. Includes severity classification, fail gate threshold support, and parallel execution in the scan orchestrator. Co-Authored-By: Claude Sonnet 4.6 --- cli/config/loader.go | 21 ++-- cli/scanners/licenses.go | 193 ++++++++++++++++++++++++++++++++++ cli/scanners/licenses_test.go | 137 ++++++++++++++++++++++++ cli/scanners/orchestrator.go | 27 ++++- cli/scanners/types.go | 2 + 5 files changed, 372 insertions(+), 8 deletions(-) create mode 100644 cli/scanners/licenses.go create mode 100644 cli/scanners/licenses_test.go diff --git a/cli/config/loader.go b/cli/config/loader.go index bc0a193..9e0c5c1 100644 --- a/cli/config/loader.go +++ b/cli/config/loader.go @@ -16,6 +16,7 @@ type SecurityConfig struct { Tools ToolsConfig `yaml:"tools"` ExcludePaths []string `yaml:"exclude_paths"` FailOn map[string]int `yaml:"fail_on"` + Licenses LicensesConfig `yaml:"licenses"` Notifications NotificationsConfig `yaml:"notifications"` } @@ -26,6 +27,13 @@ type ToolsConfig struct { Gitleaks bool `yaml:"gitleaks"` } +// LicensesConfig represents the licenses section +type LicensesConfig struct { + Enabled bool `yaml:"enabled"` + Deny []string `yaml:"deny"` + Allow []string `yaml:"allow"` +} + // NotificationsConfig represents the notifications section type NotificationsConfig struct { PRComment bool `yaml:"pr_comment"` @@ -98,12 +106,13 @@ func setConfigDefaults(config *SecurityConfig) { } defaults := map[string]int{ - "gitleaks": 0, - "semgrep": 10, - "trivy_critical": 0, - "trivy_high": 5, - "trivy_medium": -1, - "trivy_low": -1, + "gitleaks": 0, + "semgrep": 10, + "trivy_critical": 0, + "trivy_high": 5, + "trivy_medium": -1, + "trivy_low": -1, + "license_violations": -1, } for key, defaultValue := range defaults { diff --git a/cli/scanners/licenses.go b/cli/scanners/licenses.go new file mode 100644 index 0000000..eec98b9 --- /dev/null +++ b/cli/scanners/licenses.go @@ -0,0 +1,193 @@ +package scanners + +import ( + "encoding/json" + "fmt" + "os/exec" + "strings" +) + +// TrivyLicenseOutput represents the JSON output from Trivy license scan +type TrivyLicenseOutput struct { + Results []struct { + Target string `json:"Target"` + Class string `json:"Class"` + Licenses []struct { + Severity string `json:"Severity"` + Category string `json:"Category"` + PkgName string `json:"PkgName"` + FilePath string `json:"FilePath"` + Name string `json:"Name"` + Confidence float64 `json:"Confidence"` + Link string `json:"Link"` + } `json:"Licenses"` + } `json:"Results"` +} + +// LicenseConfig holds license scanning configuration +type LicenseConfig struct { + Enabled bool + Deny []string + Allow []string +} + +// runLicenseScan executes Trivy license scanning +func (o *Orchestrator) runLicenseScan() (*ScanResult, error) { + result := &ScanResult{ + Tool: "licenses", + 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 (required for license scanning)") + return result, result.Error + } + + // Run trivy with license scanning + cmd := exec.Command("trivy", "fs", ".", "--scanners", "license", "--format", "json") + + // Add skip-dirs for exclusions + for _, path := range o.options.ExcludePaths { + cmd.Args = append(cmd.Args, "--skip-dirs", path) + } + + cmd.Dir = o.projectDir + + output, err := cmd.CombinedOutput() + if err != nil { + if len(output) == 0 || !strings.Contains(string(output), "Results") { + result.Status = "error" + result.Error = fmt.Errorf("trivy license scan failed: %w", err) + return result, result.Error + } + } + + // Extract JSON + jsonOutput := extractJSON(output) + if len(jsonOutput) == 0 { + result.Status = "success" + return result, nil + } + + // Parse output + var licenseOut TrivyLicenseOutput + if err := json.Unmarshal(jsonOutput, &licenseOut); err != nil { + result.Status = "error" + result.Error = fmt.Errorf("failed to parse trivy license output: %w", err) + return result, result.Error + } + + // Convert to findings, applying deny/allow lists + for _, scanResult := range licenseOut.Results { + for _, lic := range scanResult.Licenses { + // Check if this license is a violation + if !o.isLicenseViolation(lic.Name) { + continue + } + + severity := classifyLicenseSeverity(lic.Name, lic.Category) + + finding := Finding{ + File: scanResult.Target, + Severity: severity, + Message: fmt.Sprintf("License violation: %s uses %s license", lic.PkgName, lic.Name), + RuleID: fmt.Sprintf("license-%s", strings.ToLower(strings.ReplaceAll(lic.Name, " ", "-"))), + Tool: "licenses", + } + + result.Findings = append(result.Findings, finding) + } + } + + // Update summary + 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 +} + +// isLicenseViolation checks if a license should be flagged based on deny/allow lists +func (o *Orchestrator) isLicenseViolation(licenseName string) bool { + lower := strings.ToLower(licenseName) + + // If deny list is configured, only flag denied licenses + if len(o.options.LicenseConfig.Deny) > 0 { + for _, denied := range o.options.LicenseConfig.Deny { + if matchLicense(lower, strings.ToLower(denied)) { + return true + } + } + return false + } + + // If allow list is configured, flag anything not allowed + if len(o.options.LicenseConfig.Allow) > 0 { + for _, allowed := range o.options.LicenseConfig.Allow { + if matchLicense(lower, strings.ToLower(allowed)) { + return false + } + } + return true + } + + // No lists configured: flag known copyleft licenses by default + copyleftPrefixes := []string{"gpl", "agpl", "lgpl", "sspl", "eupl"} + for _, prefix := range copyleftPrefixes { + if strings.Contains(lower, prefix) { + return true + } + } + + return false +} + +// matchLicense checks if a license name matches a pattern (supports trailing wildcard) +func matchLicense(licenseName, pattern string) bool { + if strings.HasSuffix(pattern, "*") { + prefix := strings.TrimSuffix(pattern, "*") + return strings.HasPrefix(licenseName, prefix) + } + return licenseName == pattern +} + +// classifyLicenseSeverity assigns severity based on license type +func classifyLicenseSeverity(licenseName, category string) string { + lower := strings.ToLower(licenseName) + + // Strong copyleft = HIGH + if strings.Contains(lower, "agpl") || strings.Contains(lower, "sspl") { + return "HIGH" + } + + // Weak copyleft = LOW (check before GPL since LGPL contains "gpl") + if strings.Contains(lower, "lgpl") || strings.Contains(lower, "mpl") { + return "LOW" + } + + // Copyleft = MEDIUM + if strings.Contains(lower, "gpl") || strings.Contains(lower, "eupl") { + return "MEDIUM" + } + + // Restricted category from Trivy + if strings.ToLower(category) == "restricted" { + return "HIGH" + } + + return "MEDIUM" +} diff --git a/cli/scanners/licenses_test.go b/cli/scanners/licenses_test.go new file mode 100644 index 0000000..be9580a --- /dev/null +++ b/cli/scanners/licenses_test.go @@ -0,0 +1,137 @@ +package scanners + +import ( + "testing" +) + +func TestMatchLicense(t *testing.T) { + tests := []struct { + licenseName string + pattern string + expected bool + }{ + {"mit", "mit", true}, + {"apache-2.0", "apache-2.0", true}, + {"gpl-3.0", "gpl-3.0", true}, + {"bsd-2-clause", "bsd-*", true}, + {"bsd-3-clause", "bsd-*", true}, + {"mit", "apache-2.0", false}, + {"gpl-3.0", "mit", false}, + } + + for _, tt := range tests { + got := matchLicense(tt.licenseName, tt.pattern) + if got != tt.expected { + t.Errorf("matchLicense(%q, %q) = %v, want %v", tt.licenseName, tt.pattern, got, tt.expected) + } + } +} + +func TestClassifyLicenseSeverity(t *testing.T) { + tests := []struct { + licenseName string + category string + expected string + }{ + {"AGPL-3.0", "", "HIGH"}, + {"SSPL-1.0", "", "HIGH"}, + {"GPL-3.0", "", "MEDIUM"}, + {"GPL-2.0", "", "MEDIUM"}, + {"LGPL-2.1", "", "LOW"}, + {"MIT", "", "MEDIUM"}, + {"Apache-2.0", "restricted", "HIGH"}, + } + + for _, tt := range tests { + got := classifyLicenseSeverity(tt.licenseName, tt.category) + if got != tt.expected { + t.Errorf("classifyLicenseSeverity(%q, %q) = %q, want %q", tt.licenseName, tt.category, got, tt.expected) + } + } +} + +func TestIsLicenseViolation_DenyList(t *testing.T) { + o := &Orchestrator{ + options: ScanOptions{ + LicenseConfig: LicenseConfig{ + Enabled: true, + Deny: []string{"GPL-3.0", "AGPL-*"}, + }, + }, + } + + tests := []struct { + license string + expected bool + }{ + {"GPL-3.0", true}, + {"AGPL-3.0", true}, + {"MIT", false}, + {"Apache-2.0", false}, + } + + for _, tt := range tests { + got := o.isLicenseViolation(tt.license) + if got != tt.expected { + t.Errorf("isLicenseViolation(%q) with deny list = %v, want %v", tt.license, got, tt.expected) + } + } +} + +func TestIsLicenseViolation_AllowList(t *testing.T) { + o := &Orchestrator{ + options: ScanOptions{ + LicenseConfig: LicenseConfig{ + Enabled: true, + Allow: []string{"MIT", "Apache-2.0", "BSD-*"}, + }, + }, + } + + tests := []struct { + license string + expected bool + }{ + {"MIT", false}, + {"Apache-2.0", false}, + {"BSD-3-Clause", false}, + {"GPL-3.0", true}, + {"AGPL-3.0", true}, + } + + for _, tt := range tests { + got := o.isLicenseViolation(tt.license) + if got != tt.expected { + t.Errorf("isLicenseViolation(%q) with allow list = %v, want %v", tt.license, got, tt.expected) + } + } +} + +func TestIsLicenseViolation_DefaultCopyleft(t *testing.T) { + o := &Orchestrator{ + options: ScanOptions{ + LicenseConfig: LicenseConfig{ + Enabled: true, + }, + }, + } + + tests := []struct { + license string + expected bool + }{ + {"GPL-3.0", true}, + {"AGPL-3.0", true}, + {"LGPL-2.1", true}, + {"MIT", false}, + {"Apache-2.0", false}, + {"BSD-3-Clause", false}, + } + + for _, tt := range tests { + got := o.isLicenseViolation(tt.license) + if got != tt.expected { + t.Errorf("isLicenseViolation(%q) with defaults = %v, want %v", tt.license, got, tt.expected) + } + } +} diff --git a/cli/scanners/orchestrator.go b/cli/scanners/orchestrator.go index f7ea05b..7d977ea 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, 3) - errsChan := make(chan error, 3) + resultsChan := make(chan *ScanResult, 4) + errsChan := make(chan error, 4) // Run Semgrep if o.options.EnableSemgrep { @@ -74,6 +74,20 @@ func (o *Orchestrator) Run() (*ScanReport, error) { }() } + // Run License scan + if o.options.EnableLicenses { + wg.Add(1) + go func() { + defer wg.Done() + result, err := o.runLicenseScan() + if err != nil { + errsChan <- fmt.Errorf("license scan failed: %w", err) + return + } + resultsChan <- result + }() + } + // Wait for all scanners to complete wg.Wait() close(resultsChan) @@ -154,4 +168,13 @@ func (o *Orchestrator) calculateBlockingCount(report *ScanReport) { } } } + + // Check License violations threshold + if licenses, ok := report.Results["licenses"]; ok { + if threshold, exists := o.options.FailOnThresholds["license_violations"]; exists && threshold >= 0 { + if licenses.Summary.Total > threshold { + report.BlockingCount += licenses.Summary.Total - threshold + } + } + } } diff --git a/cli/scanners/types.go b/cli/scanners/types.go index fd69664..dd4b207 100644 --- a/cli/scanners/types.go +++ b/cli/scanners/types.go @@ -36,9 +36,11 @@ type ScanOptions struct { EnableGitleaks bool EnableTrivy bool EnableTrivyImage bool + EnableLicenses bool DockerImages []string ExcludePaths []string FailOnThresholds map[string]int + LicenseConfig LicenseConfig Verbose bool }