Skip to content

Commit 59798c1

Browse files
authored
Merge pull request #8 from EdgarPsda/v0.5.0/license-compliance
Add license compliance scanning
2 parents 882ca60 + 8ae03b9 commit 59798c1

File tree

5 files changed

+372
-8
lines changed

5 files changed

+372
-8
lines changed

cli/config/loader.go

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ type SecurityConfig struct {
1616
Tools ToolsConfig `yaml:"tools"`
1717
ExcludePaths []string `yaml:"exclude_paths"`
1818
FailOn map[string]int `yaml:"fail_on"`
19+
Licenses LicensesConfig `yaml:"licenses"`
1920
Notifications NotificationsConfig `yaml:"notifications"`
2021
}
2122

@@ -26,6 +27,13 @@ type ToolsConfig struct {
2627
Gitleaks bool `yaml:"gitleaks"`
2728
}
2829

30+
// LicensesConfig represents the licenses section
31+
type LicensesConfig struct {
32+
Enabled bool `yaml:"enabled"`
33+
Deny []string `yaml:"deny"`
34+
Allow []string `yaml:"allow"`
35+
}
36+
2937
// NotificationsConfig represents the notifications section
3038
type NotificationsConfig struct {
3139
PRComment bool `yaml:"pr_comment"`
@@ -98,12 +106,13 @@ func setConfigDefaults(config *SecurityConfig) {
98106
}
99107

100108
defaults := map[string]int{
101-
"gitleaks": 0,
102-
"semgrep": 10,
103-
"trivy_critical": 0,
104-
"trivy_high": 5,
105-
"trivy_medium": -1,
106-
"trivy_low": -1,
109+
"gitleaks": 0,
110+
"semgrep": 10,
111+
"trivy_critical": 0,
112+
"trivy_high": 5,
113+
"trivy_medium": -1,
114+
"trivy_low": -1,
115+
"license_violations": -1,
107116
}
108117

109118
for key, defaultValue := range defaults {

cli/scanners/licenses.go

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
package scanners
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"os/exec"
7+
"strings"
8+
)
9+
10+
// TrivyLicenseOutput represents the JSON output from Trivy license scan
11+
type TrivyLicenseOutput struct {
12+
Results []struct {
13+
Target string `json:"Target"`
14+
Class string `json:"Class"`
15+
Licenses []struct {
16+
Severity string `json:"Severity"`
17+
Category string `json:"Category"`
18+
PkgName string `json:"PkgName"`
19+
FilePath string `json:"FilePath"`
20+
Name string `json:"Name"`
21+
Confidence float64 `json:"Confidence"`
22+
Link string `json:"Link"`
23+
} `json:"Licenses"`
24+
} `json:"Results"`
25+
}
26+
27+
// LicenseConfig holds license scanning configuration
28+
type LicenseConfig struct {
29+
Enabled bool
30+
Deny []string
31+
Allow []string
32+
}
33+
34+
// runLicenseScan executes Trivy license scanning
35+
func (o *Orchestrator) runLicenseScan() (*ScanResult, error) {
36+
result := &ScanResult{
37+
Tool: "licenses",
38+
Findings: []Finding{},
39+
Summary: FindingSummary{},
40+
}
41+
42+
// Check if Trivy is installed
43+
if _, err := exec.LookPath("trivy"); err != nil {
44+
result.Status = "error"
45+
result.Error = fmt.Errorf("trivy not installed (required for license scanning)")
46+
return result, result.Error
47+
}
48+
49+
// Run trivy with license scanning
50+
cmd := exec.Command("trivy", "fs", ".", "--scanners", "license", "--format", "json")
51+
52+
// Add skip-dirs for exclusions
53+
for _, path := range o.options.ExcludePaths {
54+
cmd.Args = append(cmd.Args, "--skip-dirs", path)
55+
}
56+
57+
cmd.Dir = o.projectDir
58+
59+
output, err := cmd.CombinedOutput()
60+
if err != nil {
61+
if len(output) == 0 || !strings.Contains(string(output), "Results") {
62+
result.Status = "error"
63+
result.Error = fmt.Errorf("trivy license scan failed: %w", err)
64+
return result, result.Error
65+
}
66+
}
67+
68+
// Extract JSON
69+
jsonOutput := extractJSON(output)
70+
if len(jsonOutput) == 0 {
71+
result.Status = "success"
72+
return result, nil
73+
}
74+
75+
// Parse output
76+
var licenseOut TrivyLicenseOutput
77+
if err := json.Unmarshal(jsonOutput, &licenseOut); err != nil {
78+
result.Status = "error"
79+
result.Error = fmt.Errorf("failed to parse trivy license output: %w", err)
80+
return result, result.Error
81+
}
82+
83+
// Convert to findings, applying deny/allow lists
84+
for _, scanResult := range licenseOut.Results {
85+
for _, lic := range scanResult.Licenses {
86+
// Check if this license is a violation
87+
if !o.isLicenseViolation(lic.Name) {
88+
continue
89+
}
90+
91+
severity := classifyLicenseSeverity(lic.Name, lic.Category)
92+
93+
finding := Finding{
94+
File: scanResult.Target,
95+
Severity: severity,
96+
Message: fmt.Sprintf("License violation: %s uses %s license", lic.PkgName, lic.Name),
97+
RuleID: fmt.Sprintf("license-%s", strings.ToLower(strings.ReplaceAll(lic.Name, " ", "-"))),
98+
Tool: "licenses",
99+
}
100+
101+
result.Findings = append(result.Findings, finding)
102+
}
103+
}
104+
105+
// Update summary
106+
for _, finding := range result.Findings {
107+
result.Summary.Total++
108+
switch finding.Severity {
109+
case "CRITICAL":
110+
result.Summary.Critical++
111+
case "HIGH":
112+
result.Summary.High++
113+
case "MEDIUM":
114+
result.Summary.Medium++
115+
case "LOW":
116+
result.Summary.Low++
117+
}
118+
}
119+
120+
result.Status = "success"
121+
return result, nil
122+
}
123+
124+
// isLicenseViolation checks if a license should be flagged based on deny/allow lists
125+
func (o *Orchestrator) isLicenseViolation(licenseName string) bool {
126+
lower := strings.ToLower(licenseName)
127+
128+
// If deny list is configured, only flag denied licenses
129+
if len(o.options.LicenseConfig.Deny) > 0 {
130+
for _, denied := range o.options.LicenseConfig.Deny {
131+
if matchLicense(lower, strings.ToLower(denied)) {
132+
return true
133+
}
134+
}
135+
return false
136+
}
137+
138+
// If allow list is configured, flag anything not allowed
139+
if len(o.options.LicenseConfig.Allow) > 0 {
140+
for _, allowed := range o.options.LicenseConfig.Allow {
141+
if matchLicense(lower, strings.ToLower(allowed)) {
142+
return false
143+
}
144+
}
145+
return true
146+
}
147+
148+
// No lists configured: flag known copyleft licenses by default
149+
copyleftPrefixes := []string{"gpl", "agpl", "lgpl", "sspl", "eupl"}
150+
for _, prefix := range copyleftPrefixes {
151+
if strings.Contains(lower, prefix) {
152+
return true
153+
}
154+
}
155+
156+
return false
157+
}
158+
159+
// matchLicense checks if a license name matches a pattern (supports trailing wildcard)
160+
func matchLicense(licenseName, pattern string) bool {
161+
if strings.HasSuffix(pattern, "*") {
162+
prefix := strings.TrimSuffix(pattern, "*")
163+
return strings.HasPrefix(licenseName, prefix)
164+
}
165+
return licenseName == pattern
166+
}
167+
168+
// classifyLicenseSeverity assigns severity based on license type
169+
func classifyLicenseSeverity(licenseName, category string) string {
170+
lower := strings.ToLower(licenseName)
171+
172+
// Strong copyleft = HIGH
173+
if strings.Contains(lower, "agpl") || strings.Contains(lower, "sspl") {
174+
return "HIGH"
175+
}
176+
177+
// Weak copyleft = LOW (check before GPL since LGPL contains "gpl")
178+
if strings.Contains(lower, "lgpl") || strings.Contains(lower, "mpl") {
179+
return "LOW"
180+
}
181+
182+
// Copyleft = MEDIUM
183+
if strings.Contains(lower, "gpl") || strings.Contains(lower, "eupl") {
184+
return "MEDIUM"
185+
}
186+
187+
// Restricted category from Trivy
188+
if strings.ToLower(category) == "restricted" {
189+
return "HIGH"
190+
}
191+
192+
return "MEDIUM"
193+
}

cli/scanners/licenses_test.go

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
package scanners
2+
3+
import (
4+
"testing"
5+
)
6+
7+
func TestMatchLicense(t *testing.T) {
8+
tests := []struct {
9+
licenseName string
10+
pattern string
11+
expected bool
12+
}{
13+
{"mit", "mit", true},
14+
{"apache-2.0", "apache-2.0", true},
15+
{"gpl-3.0", "gpl-3.0", true},
16+
{"bsd-2-clause", "bsd-*", true},
17+
{"bsd-3-clause", "bsd-*", true},
18+
{"mit", "apache-2.0", false},
19+
{"gpl-3.0", "mit", false},
20+
}
21+
22+
for _, tt := range tests {
23+
got := matchLicense(tt.licenseName, tt.pattern)
24+
if got != tt.expected {
25+
t.Errorf("matchLicense(%q, %q) = %v, want %v", tt.licenseName, tt.pattern, got, tt.expected)
26+
}
27+
}
28+
}
29+
30+
func TestClassifyLicenseSeverity(t *testing.T) {
31+
tests := []struct {
32+
licenseName string
33+
category string
34+
expected string
35+
}{
36+
{"AGPL-3.0", "", "HIGH"},
37+
{"SSPL-1.0", "", "HIGH"},
38+
{"GPL-3.0", "", "MEDIUM"},
39+
{"GPL-2.0", "", "MEDIUM"},
40+
{"LGPL-2.1", "", "LOW"},
41+
{"MIT", "", "MEDIUM"},
42+
{"Apache-2.0", "restricted", "HIGH"},
43+
}
44+
45+
for _, tt := range tests {
46+
got := classifyLicenseSeverity(tt.licenseName, tt.category)
47+
if got != tt.expected {
48+
t.Errorf("classifyLicenseSeverity(%q, %q) = %q, want %q", tt.licenseName, tt.category, got, tt.expected)
49+
}
50+
}
51+
}
52+
53+
func TestIsLicenseViolation_DenyList(t *testing.T) {
54+
o := &Orchestrator{
55+
options: ScanOptions{
56+
LicenseConfig: LicenseConfig{
57+
Enabled: true,
58+
Deny: []string{"GPL-3.0", "AGPL-*"},
59+
},
60+
},
61+
}
62+
63+
tests := []struct {
64+
license string
65+
expected bool
66+
}{
67+
{"GPL-3.0", true},
68+
{"AGPL-3.0", true},
69+
{"MIT", false},
70+
{"Apache-2.0", false},
71+
}
72+
73+
for _, tt := range tests {
74+
got := o.isLicenseViolation(tt.license)
75+
if got != tt.expected {
76+
t.Errorf("isLicenseViolation(%q) with deny list = %v, want %v", tt.license, got, tt.expected)
77+
}
78+
}
79+
}
80+
81+
func TestIsLicenseViolation_AllowList(t *testing.T) {
82+
o := &Orchestrator{
83+
options: ScanOptions{
84+
LicenseConfig: LicenseConfig{
85+
Enabled: true,
86+
Allow: []string{"MIT", "Apache-2.0", "BSD-*"},
87+
},
88+
},
89+
}
90+
91+
tests := []struct {
92+
license string
93+
expected bool
94+
}{
95+
{"MIT", false},
96+
{"Apache-2.0", false},
97+
{"BSD-3-Clause", false},
98+
{"GPL-3.0", true},
99+
{"AGPL-3.0", true},
100+
}
101+
102+
for _, tt := range tests {
103+
got := o.isLicenseViolation(tt.license)
104+
if got != tt.expected {
105+
t.Errorf("isLicenseViolation(%q) with allow list = %v, want %v", tt.license, got, tt.expected)
106+
}
107+
}
108+
}
109+
110+
func TestIsLicenseViolation_DefaultCopyleft(t *testing.T) {
111+
o := &Orchestrator{
112+
options: ScanOptions{
113+
LicenseConfig: LicenseConfig{
114+
Enabled: true,
115+
},
116+
},
117+
}
118+
119+
tests := []struct {
120+
license string
121+
expected bool
122+
}{
123+
{"GPL-3.0", true},
124+
{"AGPL-3.0", true},
125+
{"LGPL-2.1", true},
126+
{"MIT", false},
127+
{"Apache-2.0", false},
128+
{"BSD-3-Clause", false},
129+
}
130+
131+
for _, tt := range tests {
132+
got := o.isLicenseViolation(tt.license)
133+
if got != tt.expected {
134+
t.Errorf("isLicenseViolation(%q) with defaults = %v, want %v", tt.license, got, tt.expected)
135+
}
136+
}
137+
}

0 commit comments

Comments
 (0)