diff --git a/internal/license/allowlist_manifest_test.go b/internal/license/allowlist_manifest_test.go new file mode 100644 index 0000000000..6db180c977 --- /dev/null +++ b/internal/license/allowlist_manifest_test.go @@ -0,0 +1,134 @@ +package license + +import ( + "os" + "path/filepath" + "reflect" + "testing" +) + +func writeTemp(t *testing.T, dir, name, body string) string { + t.Helper() + p := filepath.Join(dir, name) + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + return p +} + +func TestLoadAllowListFromFile(t *testing.T) { + dir := t.TempDir() + p := writeTemp(t, dir, "allow.yaml", "licenses:\n - mit\n - apache-2.0\n - Not-A-Known-Id\n") + al, err := LoadAllowListFromFile(p) + if err != nil { + t.Fatal(err) + } + // Known ids are normalised to their SPDX spelling; unknown ones are kept. + if want := []string{"MIT", "Apache-2.0", "Not-A-Known-Id"}; !reflect.DeepEqual(al.Licenses, want) { + t.Errorf("licenses = %v, want %v", al.Licenses, want) + } + + if _, err := LoadAllowListFromFile(filepath.Join(dir, "missing.yaml")); err == nil { + t.Error("a missing file is an error") + } + bad := writeTemp(t, dir, "bad.yaml", "licenses: [unclosed\n") + if _, err := LoadAllowListFromFile(bad); err == nil { + t.Error("invalid YAML is an error") + } +} + +func TestParseAllowListCSV(t *testing.T) { + al := ParseAllowListCSV(" mit, ,Apache-2.0,,bsd-3-clause ") + if want := []string{"MIT", "Apache-2.0", "BSD-3-Clause"}; !reflect.DeepEqual(al.Licenses, want) { + t.Errorf("licenses = %v, want %v", al.Licenses, want) + } + if empty := ParseAllowListCSV(""); empty.IsActive() || empty.Licenses != nil { + t.Errorf("an empty list = %+v", empty) + } +} + +func TestAllowListContainsAndIsActive(t *testing.T) { + var none *AllowList + if !none.Contains("GPL-3.0-only") || none.IsActive() { + t.Error("no allow list allows everything and is not active") + } + if empty := (&AllowList{}); !empty.Contains("GPL-3.0-only") || empty.IsActive() { + t.Error("an empty allow list allows everything and is not active") + } + + al := ParseAllowListCSV("MIT,Apache-2.0") + if !al.IsActive() { + t.Error("a list with entries is active") + } + for id, want := range map[string]bool{"MIT": true, "mit": true, "apache-2.0": true, "GPL-3.0-only": false, "": false} { + if got := al.Contains(id); got != want { + t.Errorf("Contains(%q) = %v, want %v", id, got, want) + } + } +} + +func TestExtractManifestLicenses(t *testing.T) { + dir := t.TempDir() + for _, tt := range []struct { + name string + file string + body string + want map[string]string + }{ + {"package.json string", "a/package.json", `{"license":"mit"}`, map[string]string{"": "MIT"}}, + {"package.json object", "b/package.json", `{"license":{"type":"Apache-2.0","url":"x"}}`, map[string]string{"": "Apache-2.0"}}, + {"package.json without license", "c/package.json", `{"name":"x"}`, map[string]string{}}, + {"package.json garbage", "d/package.json", `{not json`, nil}, + {"composer.json string", "e/composer.json", `{"license":"MIT"}`, map[string]string{"": "MIT"}}, + {"composer.json array takes the first", "f/composer.json", `{"license":["GPL-2.0-only","MIT"]}`, map[string]string{"": "GPL-2.0-only"}}, + {"composer.json without license", "g/composer.json", `{"name":"x"}`, nil}, + {"Cargo.toml", "h/Cargo.toml", "[package]\nname = \"x\"\nlicense = \"MIT\"\n", map[string]string{"": "MIT"}}, + {"Cargo.toml outside [package]", "i/Cargo.toml", "[dependencies]\nlicense = \"MIT\"\n[package]\nname = \"x\"\n", map[string]string{}}, + {"Cargo.toml license-file is not a license", "j/Cargo.toml", "[package]\nlicense-file = \"LICENSE\"\n", map[string]string{}}, + {"pyproject string", "k/pyproject.toml", "[project]\nlicense = 'BSD-3-Clause'\n", map[string]string{"": "BSD-3-Clause"}}, + {"pyproject table", "l/pyproject.toml", "[project]\nlicense = {text = \"MIT\"}\n", map[string]string{"": "MIT"}}, + {"pyproject outside [project]", "m/pyproject.toml", "[tool.x]\nlicense = \"MIT\"\n", map[string]string{}}, + {"other manifest", "n/go.mod", "module x\n", nil}, + } { + t.Run(tt.name, func(t *testing.T) { + p := writeTemp(t, dir, tt.file, tt.body) + if got := extractManifestLicenses(p); !reflect.DeepEqual(got, tt.want) { + t.Errorf("extractManifestLicenses(%s) = %#v, want %#v", tt.file, got, tt.want) + } + }) + } + if got := extractManifestLicenses(filepath.Join(dir, "nope", "package.json")); got != nil { + t.Errorf("an unreadable manifest = %v, want nil", got) + } +} + +func TestManifestBasename(t *testing.T) { + for in, want := range map[string]string{"a/b/package.json": "package.json", "Cargo.toml": "Cargo.toml", "": ""} { + if got := manifestBasename(in); got != want { + t.Errorf("manifestBasename(%q) = %q, want %q", in, got, want) + } + } +} + +func TestExtractLicenseFromDescription(t *testing.T) { + for desc, want := range map[string]string{ + "This image is licensed under the MIT license.": "MIT", + "Released under the Apache License, Version 2.0": "Apache-2.0", + "apache license 2.0 applies": "Apache-2.0", + "SPDX: Apache-2.0": "Apache-2.0", + "BSD 3-Clause": "BSD-3-Clause", + "bsd 2-clause license": "BSD-2-Clause", + "Distributed under GPL-2.0": "GPL-2.0-only", + "GPL-3.0 or later": "GPL-3.0-only", + "Mozilla MPL-2.0": "MPL-2.0", + "No licence information here": "", + "": "", + } { + if got := extractLicenseFromDescription(desc); got != want { + t.Errorf("extractLicenseFromDescription(%q) = %q, want %q", desc, got, want) + } + } +} diff --git a/internal/license/modcache_cdx_test.go b/internal/license/modcache_cdx_test.go new file mode 100644 index 0000000000..d725aabbfc --- /dev/null +++ b/internal/license/modcache_cdx_test.go @@ -0,0 +1,171 @@ +package license + +import ( + "path/filepath" + "testing" +) + +const mitText = "MIT License\n\nPermission is hereby granted, free of charge, to any person" + +func TestGoModCaseEncode(t *testing.T) { + for in, want := range map[string]string{ + "github.com/Masterminds/semver": "github.com/!masterminds/semver", + "github.com/BurntSushi/toml": "github.com/!burnt!sushi/toml", + "golang.org/x/net": "golang.org/x/net", + } { + if got := goModCaseEncode(in); got != want { + t.Errorf("goModCaseEncode(%q) = %q, want %q", in, got, want) + } + } +} + +func TestGoModCachePath(t *testing.T) { + t.Setenv("GOMODCACHE", "/cache/mod") + t.Setenv("GOPATH", "/gopath") + if got := goModCachePath(); got != "/cache/mod" { + t.Errorf("GOMODCACHE wins: %q", got) + } + + t.Setenv("GOMODCACHE", "") + if got := goModCachePath(); got != filepath.Join("/gopath", "pkg", "mod") { + t.Errorf("then GOPATH/pkg/mod: %q", got) + } + + t.Setenv("GOPATH", "") + t.Setenv("HOME", "/home/someone") + if got := goModCachePath(); got != filepath.Join("/home/someone", "go", "pkg", "mod") { + t.Errorf("then ~/go/pkg/mod: %q", got) + } + + t.Setenv("HOME", "") + if got := goModCachePath(); got != "" { + t.Errorf("no home directory: %q, want \"\"", got) + } +} + +func TestFindLicenseInModuleCache(t *testing.T) { + cache := t.TempDir() + t.Setenv("GOMODCACHE", cache) + + // Case-encoded directory, as the go command writes it. + writeTemp(t, cache, "github.com/!masterminds/semver@v3.2.0/LICENSE.txt", mitText) + // A layout that kept the original case. + writeTemp(t, cache, "example.com/Plain@v1.0.0/COPYING", "Apache License\nVersion 2.0, January 2004") + // A licence file that says nothing classifiable. + writeTemp(t, cache, "example.com/odd@v0.1.0/LICENSE", "All rights reserved, ask us.") + + for _, tt := range []struct { + module, version, want string + }{ + {"github.com/Masterminds/semver", "v3.2.0", "MIT"}, + {"github.com/Masterminds/semver", "3.2.0", "MIT"}, // the v prefix is added + {"example.com/Plain", "v1.0.0", "Apache-2.0"}, // falls back to the plain path + {"example.com/odd", "v0.1.0", ""}, + {"example.com/missing", "v1.0.0", ""}, + } { + if got := FindLicenseInModuleCache(tt.module, tt.version); got != tt.want { + t.Errorf("FindLicenseInModuleCache(%s, %s) = %q, want %q", tt.module, tt.version, got, tt.want) + } + } + + t.Setenv("GOMODCACHE", "") + t.Setenv("GOPATH", "") + t.Setenv("HOME", "") + if got := FindLicenseInModuleCache("github.com/Masterminds/semver", "v3.2.0"); got != "" { + t.Errorf("no module cache at all: %q", got) + } +} + +func TestFindLicenseInDirTakesTheFirstClassifiableFile(t *testing.T) { + dir := t.TempDir() + // LICENSE comes first in priority but cannot be classified; COPYING can. + writeTemp(t, dir, "LICENSE", "see the COPYING file") + writeTemp(t, dir, "COPYING", "GNU GENERAL PUBLIC LICENSE\nVersion 3") + if got := FindLicenseInDir(dir); got != "GPL-3.0-only" { + t.Errorf("FindLicenseInDir = %q", got) + } + if got := FindLicenseInDir(t.TempDir()); got != "" { + t.Errorf("no licence file = %q", got) + } +} + +func TestFindingsToCDXVulnerabilities(t *testing.T) { + packages := []PackageLicense{ + {PackageName: "left-pad", PackageVersion: "1.0.0", Ecosystem: "NPM"}, + {PackageName: "left-pad", PackageVersion: "1.0.0", Ecosystem: "other"}, // first wins + } + findings := []Finding{ + { + ID: "LIC-1", Description: "copyleft", Severity: "high", Confidence: 0.9, Category: "copyleft", + Package: PackageLicense{ + PackageName: "left-pad", PackageVersion: "1.0.0", LicenseSpdxID: "GPL-3.0-only", + SourceFile: "package.json", IsDirect: true, + }, + PathCount: 2, + IntroducedPaths: [][]string{{"app", "left-pad"}, {"app", "lib", "left-pad"}}, + Evidence: []EvidenceStep{{Rule: "category", Result: "FAIL"}}, + }, + { + ID: "LIC-2", Severity: "low", Confidence: 0.5, Category: "unknown", + Package: PackageLicense{PackageName: "not-in-the-bom", PackageVersion: "2.0.0"}, + }, + } + + vulns := FindingsToCDXVulnerabilities(findings, packages) + if len(vulns) != 2 { + t.Fatalf("got %d vulnerabilities", len(vulns)) + } + + v := vulns[0] + if v.ID != "LIC-1" || v.BOMRef != "LIC-1" || v.Source == nil || v.Source.Name != CDXSourceName { + t.Errorf("identity = %+v", v) + } + if len(v.Ratings) != 1 || v.Ratings[0].Score == nil || *v.Ratings[0].Score != 0.9 || v.Ratings[0].Method != "other" || v.Ratings[0].Severity != "high" { + t.Errorf("ratings = %+v", v.Ratings) + } + if len(v.Affects) != 1 || v.Affects[0].Ref != "pkg:npm/left-pad@1.0.0" { + t.Errorf("affects = %+v", v.Affects) + } + props := map[string]string{} + for _, p := range v.Properties { + props[p.Name] = p.Value + } + for name, want := range map[string]string{ + "vulnetix:license-category": "copyleft", + "vulnetix:license-severity": "high", + "vulnetix:license-confidence": "0.90", + "vulnetix:license-spdx-id": "GPL-3.0-only", + "vulnetix:source-file": "package.json", + "vulnetix:is-direct": "true", + "vulnetix:path-count": "2", + "vulnetix:introduced-path-0": "app → left-pad", + "vulnetix:introduced-path-1": "app → lib → left-pad", + "vulnetix:evidence-0:rule": "category", + "vulnetix:evidence-0:result": "FAIL", + } { + if props[name] != want { + t.Errorf("%s = %q, want %q", name, props[name], want) + } + } + + w := vulns[1] + if w.Affects != nil { + t.Errorf("a package not in the BOM has no affects: %+v", w.Affects) + } + wprops := map[string]string{} + for _, p := range w.Properties { + wprops[p.Name] = p.Value + } + if wprops["vulnetix:is-direct"] != "false" { + t.Errorf("is-direct = %q", wprops["vulnetix:is-direct"]) + } + for _, absent := range []string{"vulnetix:license-spdx-id", "vulnetix:source-file", "vulnetix:path-count", "vulnetix:introduced-path-0"} { + if _, ok := wprops[absent]; ok { + t.Errorf("%s set without data", absent) + } + } + + if got := FindingsToCDXVulnerabilities(nil, nil); got == nil || len(got) != 0 { + t.Errorf("no findings = %#v, want an empty slice", got) + } +}