Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
134 changes: 134 additions & 0 deletions internal/license/allowlist_manifest_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
171 changes: 171 additions & 0 deletions internal/license/modcache_cdx_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading