Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feature: add support for allowlists #123

Merged
merged 6 commits into from
Oct 3, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
28 changes: 28 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"
```
137 changes: 137 additions & 0 deletions grant/evalutation/license_evaluation_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package evalutation

import (
"github.com/gobwas/glob"
"github.com/google/go-cmp/cmp"
"testing"

"github.com/anchore/grant/grant"
Expand Down Expand Up @@ -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)
}
})
}
}
39 changes: 15 additions & 24 deletions grant/evalutation/license_evalutation.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Comment on lines -47 to -58
Copy link
Contributor Author

@NyanKiyoshi NyanKiyoshi Sep 4, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If I'm not mistaken, the behavior was the same no matter whether it's SPDX or not here:

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{{
Detail: ReasonLicenseDeniedOSI,
RuleName: RuleNameNotOSIApproved,
}}, 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)
}

I believe ec.CheckNonSPDX should be passed to ec.Policy.IsDenied(l, pkg) but I do not know what is the expected behavior. If indeed the code previous code was invalid, then it could be handled in another PR.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can take this on and move ec.CheckNonSPDX into the ec.Policy.IsDenied

The previous code was invalid.


if ec.OsiApproved && l.IsSPDX() {
if !l.IsOsiApproved {
return NewLicenseEvaluation(l, pkg, ec.Policy, []Reason{{
Expand All @@ -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
Expand Down
11 changes: 5 additions & 6 deletions grant/policy.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,28 +51,27 @@ 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 {
toMatch = strings.ToLower(license.Name)
}

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
}
spiffcs marked this conversation as resolved.
Show resolved Hide resolved
}
return false, nil
Expand Down
53 changes: 52 additions & 1 deletion grant/policy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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) {
Expand Down
Loading