From 84db6a481f87f6016d2423b10d74526931937c90 Mon Sep 17 00:00:00 2001 From: Mikail Kocak Date: Wed, 4 Sep 2024 21:54:36 +0200 Subject: [PATCH] feature: add support for allowlists This adds supports for denying all packages, and only allow selected ones by implementing support for `mode: "allow"`. Such as: ``` rules: - pattern: "BSD-*" name: "bsd-allow" - pattern: "*" name: "default-deny-all" mode: "deny" ``` Signed-off-by: Mikail Kocak --- README.md | 28 ++++ grant/evalutation/license_evaluation_test.go | 137 +++++++++++++++++++ grant/evalutation/license_evalutation.go | 39 ++---- grant/policy.go | 11 +- grant/policy_test.go | 53 ++++++- 5 files changed, 237 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index f5918a0..23d8757 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,9 @@ It can also be used to allow specific licenses, denying all others. ![json-output](https://github.com/anchore/grant/assets/32073428/c2d89645-e323-4f99-a179-77e5a750ee6a) ## Configuration + +### Example: Deny GPL + ```yaml #.grant.yaml config: ".grant.yaml" @@ -109,3 +112,28 @@ rules: exclusions: - "alpine-base-layout" # We don't link against this package so we don't care about its license ``` + +### Example: Allow Lists + +In this example, all licenses are denied except BSD and MIT: + +```yaml +#.grant.yaml +rules: + - pattern: "BSD-*" + name: "bsd-allow" + mode: "allow" + reason: "BSD is compatible with our project" + exceptions: + # Packages to disallow even if they are licensed under BSD. + - my-package + - pattern: "MIT" + name: "mit-allow" + mode: "allow" + reason: "MIT is compatible with our project" + # Reject the rest. + - pattern: "*" + name: "default-deny-all" + mode: "deny" + reason: "All licenses need to be explicitly allowed" +``` diff --git a/grant/evalutation/license_evaluation_test.go b/grant/evalutation/license_evaluation_test.go index 08b60c2..d84dfd9 100644 --- a/grant/evalutation/license_evaluation_test.go +++ b/grant/evalutation/license_evaluation_test.go @@ -1,6 +1,8 @@ package evalutation import ( + "github.com/gobwas/glob" + "github.com/google/go-cmp/cmp" "testing" "github.com/anchore/grant/grant" @@ -43,3 +45,138 @@ func Test_NewLicenseEvaluations(t *testing.T) { func fixtureCase(fixturePath string) []grant.Case { return grant.NewCases(fixturePath) } + +func Test_checkLicense(t *testing.T) { + tests := []struct { + name string + config EvaluationConfig + license grant.License + wants struct { + Pass bool + Reasons []Reason + } + }{ + { + name: "should reject denied licenses", + license: grant.License{Name: "MIT"}, + // Only allow OSI licenses. + config: EvaluationConfig{CheckNonSPDX: true, Policy: grant.DefaultPolicy()}, + wants: struct { + Pass bool + Reasons []Reason + }{ + Pass: false, + Reasons: []Reason{{ + Detail: ReasonLicenseDeniedPolicy, + RuleName: "default-deny-all", + }}, + }, + }, + { + name: "non-OSI approved licenses should be denied when EvaluationConfig.OsiApproved is true", + license: grant.License{ + IsOsiApproved: false, + LicenseID: "AGPL-1.0-only", + SPDXExpression: "AGPL-1.0-only", + }, + // Only allow OSI licenses. + config: EvaluationConfig{OsiApproved: true}, + wants: struct { + Pass bool + Reasons []Reason + }{ + Pass: false, + Reasons: []Reason{{ + Detail: ReasonLicenseDeniedOSI, + RuleName: RuleNameNotOSIApproved, + }}, + }, + }, + { + name: "non-OSI approved licenses should be allowed when it's not an SPDX expression", + license: grant.License{ + IsOsiApproved: false, + // Non-SPDX license + Name: "AGPL-1.0-only", + }, + // Only allow OSI licenses. + config: EvaluationConfig{OsiApproved: true}, + wants: struct { + Pass bool + Reasons []Reason + }{ + Pass: true, + Reasons: []Reason{{Detail: ReasonLicenseAllowed}}, + }, + }, + { + name: "non-OSI approved licenses should be allowed when EvaluationConfig.OsiApproved is false", + license: grant.License{ + IsOsiApproved: false, + LicenseID: "AGPL-1.0-only", + SPDXExpression: "AGPL-1.0-only", + }, + config: EvaluationConfig{}, + wants: struct { + Pass bool + Reasons []Reason + }{ + Pass: true, + Reasons: []Reason{{Detail: ReasonLicenseAllowed}}, + }, + }, + { + // Verifies rules are evaluated from first to last. + name: "A 'Deny' rule preceding a 'Deny' rule should always take precedence", + license: grant.License{LicenseID: "BSD-3-Clause", SPDXExpression: "BSD-3-Clause"}, + config: EvaluationConfig{ + Policy: grant.Policy{ + Rules: []grant.Rule{ + { + Name: "allow-bsd-licenses", + Glob: glob.MustCompile("bsd-*"), + Exceptions: []glob.Glob{}, + Mode: grant.Allow, + Reason: "BSD licenses are allowed", + }, + { + Name: "deny-all", + Glob: glob.MustCompile("*"), + Exceptions: []glob.Glob{}, + Mode: grant.Deny, + Reason: "No 'Allow' rule matched, unknown licenses are not allowed.", + }, + }, + }, + }, + wants: struct { + Pass bool + Reasons []Reason + }{ + Pass: true, + Reasons: []Reason{{ + Detail: ReasonLicenseAllowed, + RuleName: "allow-bsd-licenses", + }}, + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result := checkLicense(tc.config, &grant.Package{}, tc.license) + if tc.wants.Pass != result.Pass { + t.Errorf("Expected Pass to be %t, got %t", tc.wants.Pass, result.Pass) + } + if diff := cmp.Diff(tc.license, result.License); diff != "" { + t.Errorf("Mismatched 'License' field (-want +got):\n%s", diff) + } + if diff := cmp.Diff(tc.config.Policy, result.Policy); diff != "" { + t.Errorf("Mismatched 'Policy' field (-want +got):\n%s", diff) + } + if diff := cmp.Diff(tc.wants.Reasons, result.Reason); diff != "" { + t.Errorf("Mismatched 'Reasons' field (-want +got):\n%s", diff) + } + }) + } +} diff --git a/grant/evalutation/license_evalutation.go b/grant/evalutation/license_evalutation.go index 2504242..7bfe653 100644 --- a/grant/evalutation/license_evalutation.go +++ b/grant/evalutation/license_evalutation.go @@ -44,19 +44,6 @@ func checkSBOM(ec EvaluationConfig, sb sbom.SBOM) LicenseEvaluations { } func checkLicense(ec EvaluationConfig, pkg *grant.Package, l grant.License) LicenseEvaluation { - if !l.IsSPDX() && ec.CheckNonSPDX { - if denied, rule := ec.Policy.IsDenied(l, pkg); denied { - var reason Reason - if rule != nil { - reason = Reason{ - Detail: ReasonLicenseDeniedPolicy, - RuleName: rule.Name, - } - } - return NewLicenseEvaluation(l, pkg, ec.Policy, []Reason{reason}, false) - } - } - if ec.OsiApproved && l.IsSPDX() { if !l.IsOsiApproved { return NewLicenseEvaluation(l, pkg, ec.Policy, []Reason{{ @@ -65,20 +52,24 @@ func checkLicense(ec EvaluationConfig, pkg *grant.Package, l grant.License) Lice }}, false) } } - if denied, rule := ec.Policy.IsDenied(l, pkg); denied { - var reason Reason - if rule != nil { - reason = Reason{ - Detail: ReasonLicenseDeniedPolicy, - RuleName: rule.Name, - } - } - return NewLicenseEvaluation(l, pkg, ec.Policy, []Reason{reason}, false) + + isDenied, matchedRule := ec.Policy.IsDenied(l, pkg) + + // By default, we allow unmatched rules. + detail := ReasonLicenseAllowed + ruleName := "" + + if isDenied { + detail = ReasonLicenseDeniedPolicy + } + if matchedRule != nil { + ruleName = matchedRule.Name } return NewLicenseEvaluation(l, pkg, ec.Policy, []Reason{{ - Detail: ReasonLicenseAllowed, - }}, true) + Detail: detail, + RuleName: ruleName, + }}, !isDenied) } type LicenseEvaluations []LicenseEvaluation diff --git a/grant/policy.go b/grant/policy.go index 23d2aed..ab38aba 100644 --- a/grant/policy.go +++ b/grant/policy.go @@ -51,11 +51,8 @@ func (p Policy) IsEmpty() bool { // IsDenied returns true if the given license is denied by the policy func (p Policy) IsDenied(license License, pkg *Package) (bool, *Rule) { for _, rule := range p.Rules { - if rule.Mode != Deny { - continue - } - var toMatch string + if license.IsSPDX() { toMatch = strings.ToLower(license.LicenseID) } else { @@ -63,16 +60,18 @@ func (p Policy) IsDenied(license License, pkg *Package) (bool, *Rule) { } toMatch = strings.ToLower(toMatch) + // TODO: write tests for this section if rule.Glob.Match(toMatch) && toMatch != "" { if pkg == nil { return true, &rule } for _, exception := range rule.Exceptions { if exception.Match(pkg.Name) { - return false, &rule + return rule.Mode != Deny, &rule } } - return true, &rule + // true when Mode=Deny, false otherwise + return rule.Mode == Deny, &rule } } return false, nil diff --git a/grant/policy_test.go b/grant/policy_test.go index d563463..61cb6ea 100644 --- a/grant/policy_test.go +++ b/grant/policy_test.go @@ -75,7 +75,7 @@ func Test_NewPolicy(t *testing.T) { } } -func Test_Policy_DenyAll(t *testing.T) { +func Test_Policy_IsDenied(t *testing.T) { tests := []struct { name string p Policy @@ -101,6 +101,57 @@ func Test_Policy_DenyAll(t *testing.T) { }, }, }, + + { + name: "Policy allowing all licenses", + p: Policy{ + Rules: []Rule{{ + Name: "allow-all", + Glob: glob.MustCompile("*"), + Exceptions: []glob.Glob{}, + Mode: Allow, + Reason: "all licenses are allowed", + }}, + }, + want: struct { + denied bool + rule *Rule + }{ + denied: false, + rule: &Rule{ + Name: "allow-all", + Glob: glob.MustCompile("*"), + Exceptions: []glob.Glob{}, + Mode: Allow, + Reason: "all licenses are allowed", + }, + }, + }, + { + name: "Policy ignoring all licenses", + p: Policy{ + Rules: []Rule{{ + Name: "ignore-all", + Glob: glob.MustCompile("*"), + Exceptions: []glob.Glob{}, + Mode: Ignore, + Reason: "all licenses are ignored", + }}, + }, + want: struct { + denied bool + rule *Rule + }{ + denied: false, + rule: &Rule{ + Name: "ignore-all", + Glob: glob.MustCompile("*"), + Exceptions: []glob.Glob{}, + Mode: Ignore, + Reason: "all licenses are ignored", + }, + }, + }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) {