mirror of
https://github.com/hanzoai/visor.git
synced 2026-09-24 17:04:14 +00:00
966 lines
29 KiB
Go
966 lines
29 KiB
Go
// Copyright 2026 The gVisor Authors.
|
|
//
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
|
|
// Package licensecheck audits the licenses of gVisor's external dependencies.
|
|
//
|
|
// Dependencies are enumerated by asking Bazel (`make mod`, which wraps `bazel
|
|
// mod` in the dockerized build environment): the root module's direct
|
|
// bazel_deps, the http_archive/http_file repos declared in MODULE.bazel, and
|
|
// the Go repos imported from the go_deps extension, unioned with go.mod's
|
|
// requirements. Fetch downloads each dependency's license text, from the Go
|
|
// module proxy for Go modules and from GitHub for everything else, classifies
|
|
// it, and records it in a YAML file. Verify checks that the YAML file has an
|
|
// entry for every dependency, without fetching any licenses.
|
|
//
|
|
// Entries whose license cannot be fetched automatically (e.g.
|
|
// @google_root_pem) are maintained by hand: Fetch preserves an existing entry
|
|
// whenever fetching fails.
|
|
package licensecheck
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"io/fs"
|
|
"net/http"
|
|
"os"
|
|
"os/exec"
|
|
"regexp"
|
|
"slices"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"archive/zip"
|
|
"golang.org/x/mod/modfile"
|
|
"golang.org/x/mod/module"
|
|
"golang.org/x/mod/semver"
|
|
yaml "gopkg.in/yaml.v3"
|
|
)
|
|
|
|
// Paths locates the audit's inputs and output, relative to the repo root.
|
|
type Paths struct {
|
|
GoMod string
|
|
YAML string
|
|
Policy string
|
|
}
|
|
|
|
// Entry records the license of a single dependency.
|
|
type Entry struct {
|
|
Dependency string `yaml:"dependency"`
|
|
Version string `yaml:"version"`
|
|
Retrieved string `yaml:"retrieved"`
|
|
Commit string `yaml:"commit"`
|
|
SHA256 string `yaml:"sha256"`
|
|
License Licenses `yaml:"license"`
|
|
}
|
|
|
|
// License is an SPDX license identifier (https://spdx.org/licenses/), one of
|
|
// knownLicenses.
|
|
type License string
|
|
|
|
const (
|
|
apache2 License = "Apache-2.0"
|
|
apache2LLVM License = "Apache-2.0 WITH LLVM-exception"
|
|
bsd2 License = "BSD-2-Clause"
|
|
bsd3 License = "BSD-3-Clause"
|
|
bsd4 License = "BSD-4-Clause"
|
|
gpl2 License = "GPL-2.0-only"
|
|
gpl3 License = "GPL-3.0-only"
|
|
isc License = "ISC"
|
|
lgpl21 License = "LGPL-2.1-only"
|
|
lgpl3 License = "LGPL-3.0-only"
|
|
mit License = "MIT"
|
|
mpl2 License = "MPL-2.0"
|
|
unlicense License = "Unlicense"
|
|
// noAssertion is the SPDX token for dependencies to which no software
|
|
// license applies, e.g. a certificate bundle.
|
|
noAssertion License = "NOASSERTION"
|
|
)
|
|
|
|
// knownLicenses is the set of known license identifiers.
|
|
var knownLicenses = map[License]bool{
|
|
apache2: true,
|
|
apache2LLVM: true,
|
|
bsd2: true,
|
|
bsd3: true,
|
|
bsd4: true,
|
|
gpl2: true,
|
|
gpl3: true,
|
|
isc: true,
|
|
lgpl21: true,
|
|
lgpl3: true,
|
|
mit: true,
|
|
mpl2: true,
|
|
unlicense: true,
|
|
noAssertion: true,
|
|
}
|
|
|
|
// Licenses is the sorted set of licenses that apply to a dependency. It
|
|
// marshals as a plain string when there is a single license and as a list
|
|
// otherwise.
|
|
type Licenses []License
|
|
|
|
// String implements fmt.Stringer.String.
|
|
func (l Licenses) String() string {
|
|
s := make([]string, len(l))
|
|
for i, license := range l {
|
|
s[i] = string(license)
|
|
}
|
|
return strings.Join(s, ", ")
|
|
}
|
|
|
|
// MarshalYAML implements yaml.Marshaler.MarshalYAML.
|
|
func (l Licenses) MarshalYAML() (any, error) {
|
|
if len(l) == 1 {
|
|
return string(l[0]), nil
|
|
}
|
|
s := make([]string, len(l))
|
|
for i, license := range l {
|
|
s[i] = string(license)
|
|
}
|
|
return s, nil
|
|
}
|
|
|
|
// UnmarshalYAML implements yaml.Unmarshaler.UnmarshalYAML.
|
|
func (l *Licenses) UnmarshalYAML(node *yaml.Node) error {
|
|
switch node.Kind {
|
|
case yaml.ScalarNode:
|
|
var s string
|
|
if err := node.Decode(&s); err != nil {
|
|
return err
|
|
}
|
|
*l = Licenses{License(s)}
|
|
return nil
|
|
case yaml.SequenceNode:
|
|
var s []string
|
|
if err := node.Decode(&s); err != nil {
|
|
return err
|
|
}
|
|
if len(s) < 2 {
|
|
return errors.New("a license list must have at least two entries; use a plain string for a single license")
|
|
}
|
|
*l = make(Licenses, len(s))
|
|
for i, license := range s {
|
|
(*l)[i] = License(license)
|
|
}
|
|
return nil
|
|
default:
|
|
return errors.New("license must be a string or a list of strings")
|
|
}
|
|
}
|
|
|
|
type depKind int
|
|
|
|
const (
|
|
kindGoModule depKind = iota
|
|
kindArchive
|
|
)
|
|
|
|
// dep is a single external dependency.
|
|
type dep struct {
|
|
name string
|
|
kind depKind
|
|
version string // kindGoModule.
|
|
url string // kindArchive.
|
|
sha256 string // kindArchive: the hash Bazel pins for the archive.
|
|
}
|
|
|
|
// source identifies the audited version of a dependency: the Go module
|
|
// version, or the source archive URL.
|
|
func (d dep) source() string {
|
|
if d.kind == kindGoModule {
|
|
return d.version
|
|
}
|
|
return d.url
|
|
}
|
|
|
|
const (
|
|
yamlHeader = `# Licenses of gVisor's external dependencies, as declared in MODULE.bazel and
|
|
# go.mod.
|
|
# Regenerate with: make run TARGETS=//tools/licensecheck/main:licensecheck ARGS=--mode=fetch
|
|
# Check completeness with ARGS=--mode=verify.
|
|
# Entries whose license cannot be fetched automatically are maintained by hand and preserved
|
|
# by --mode=fetch, so you can hand-edit the file for these.
|
|
`
|
|
dateFormat = "2006-01-02"
|
|
fetchWorkers = 8
|
|
)
|
|
|
|
// Fetch retrieves the license of every dependency and rewrites the YAML file.
|
|
func Fetch(p Paths) error {
|
|
deps, err := enumerate(p)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
old, err := readEntries(p.YAML)
|
|
if err != nil && !errors.Is(err, fs.ErrNotExist) {
|
|
return err
|
|
}
|
|
oldByName := make(map[string]Entry)
|
|
for _, e := range old {
|
|
oldByName[e.Dependency] = e
|
|
}
|
|
|
|
type result struct {
|
|
fetched *fetched
|
|
err error
|
|
}
|
|
results := make([]result, len(deps))
|
|
sem := make(chan struct{}, fetchWorkers)
|
|
var wg sync.WaitGroup
|
|
for i, d := range deps {
|
|
wg.Add(1)
|
|
go func(i int, d dep) {
|
|
defer wg.Done()
|
|
sem <- struct{}{}
|
|
defer func() { <-sem }()
|
|
f, err := fetchLicense(d)
|
|
results[i] = result{f, err}
|
|
}(i, d)
|
|
}
|
|
wg.Wait()
|
|
|
|
today := time.Now().UTC().Format(dateFormat)
|
|
var entries []Entry
|
|
var failed []string
|
|
for i, d := range deps {
|
|
r := results[i]
|
|
if r.err != nil {
|
|
if e, ok := oldByName[d.name]; ok && len(e.License) > 0 {
|
|
fmt.Fprintf(os.Stderr, "%-60s %s (kept existing entry: %v)\n", d.name, e.License, r.err)
|
|
entries = append(entries, e)
|
|
continue
|
|
}
|
|
fmt.Fprintf(os.Stderr, "%-60s FAILED: %v\n", d.name, r.err)
|
|
failed = append(failed, d.name)
|
|
continue
|
|
}
|
|
e := Entry{
|
|
Dependency: d.name,
|
|
Version: d.source(),
|
|
Retrieved: today,
|
|
Commit: r.fetched.commit,
|
|
SHA256: r.fetched.sha256,
|
|
License: r.fetched.license,
|
|
}
|
|
// Keep the old retrieval date when nothing changed, so that re-running
|
|
// fetch does not churn the file.
|
|
if o, ok := oldByName[d.name]; ok && o.Version == e.Version && o.Commit == e.Commit &&
|
|
o.SHA256 == e.SHA256 && slices.Equal(o.License, e.License) {
|
|
e.Retrieved = o.Retrieved
|
|
}
|
|
fmt.Fprintf(os.Stderr, "%-60s %s\n", d.name, e.License)
|
|
entries = append(entries, e)
|
|
}
|
|
if err := writeEntries(p.YAML, entries); err != nil {
|
|
return err
|
|
}
|
|
if len(failed) > 0 {
|
|
return fmt.Errorf("cannot fetch licenses for %s; add entries to %s by hand", strings.Join(failed, ", "), p.YAML)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Verify checks that the YAML file has a well-formed, up-to-date entry for
|
|
// every dependency and no entries for dependencies that no longer exist.
|
|
func Verify(p Paths) error {
|
|
deps, err := enumerate(p)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
entries, err := readEntries(p.YAML)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
policy, err := ReadPolicy(p.Policy)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
problems := append(verifyProblems(deps, entries), CheckPolicy(entries, policy)...)
|
|
if len(problems) > 0 {
|
|
sort.Strings(problems)
|
|
for _, problem := range problems {
|
|
fmt.Fprintln(os.Stderr, problem)
|
|
}
|
|
return fmt.Errorf("%d problems; regenerate %s with licensecheck --mode=fetch, or amend %s", len(problems), p.YAML, p.Policy)
|
|
}
|
|
fmt.Printf("all %d dependencies have up-to-date license entries that conform to %s\n", len(deps), p.Policy)
|
|
return nil
|
|
}
|
|
|
|
// verifyProblems returns one problem per missing, malformed, out-of-date, or
|
|
// stale entry.
|
|
func verifyProblems(deps []dep, entries []Entry) []string {
|
|
byName := make(map[string]Entry)
|
|
var problems []string
|
|
for _, e := range entries {
|
|
if _, ok := byName[e.Dependency]; ok {
|
|
problems = append(problems, fmt.Sprintf("duplicate entry for %s", e.Dependency))
|
|
}
|
|
byName[e.Dependency] = e
|
|
}
|
|
depSet := make(map[string]bool)
|
|
for _, d := range deps {
|
|
depSet[d.name] = true
|
|
e, ok := byName[d.name]
|
|
switch {
|
|
case !ok:
|
|
problems = append(problems, fmt.Sprintf("missing entry for %s", d.name))
|
|
case len(e.License) == 0:
|
|
problems = append(problems, fmt.Sprintf("%s has no license", d.name))
|
|
default:
|
|
if e.Version != d.source() {
|
|
problems = append(problems, fmt.Sprintf("%s was audited at %q, but is now %q", d.name, e.Version, d.source()))
|
|
}
|
|
// Archive hashes are pinned by Bazel, so verify can cross-check
|
|
// them offline. Go module hashes are only computed by fetch.
|
|
if d.sha256 != "" && e.SHA256 != d.sha256 {
|
|
problems = append(problems, fmt.Sprintf("%s was audited with sha256 %q, but is now pinned to %q", d.name, e.SHA256, d.sha256))
|
|
}
|
|
for i, license := range e.License {
|
|
if !knownLicenses[license] {
|
|
problems = append(problems, fmt.Sprintf("%s has unknown license %q", d.name, license))
|
|
}
|
|
if i > 0 && e.License[i-1] >= license {
|
|
problems = append(problems, fmt.Sprintf("licenses of %s are not sorted and unique", d.name))
|
|
break
|
|
}
|
|
}
|
|
if _, err := time.Parse(dateFormat, e.Retrieved); err != nil {
|
|
problems = append(problems, fmt.Sprintf("%s has invalid retrieval date %q", d.name, e.Retrieved))
|
|
}
|
|
}
|
|
}
|
|
for name := range byName {
|
|
if !depSet[name] {
|
|
problems = append(problems, fmt.Sprintf("stale entry for %s, which is no longer a dependency", name))
|
|
}
|
|
}
|
|
return problems
|
|
}
|
|
|
|
// enumerate returns all external dependencies, sorted by name.
|
|
func enumerate(p Paths) ([]dep, error) {
|
|
graphOut, err := makeMod("graph", "--depth", "1", "--extension_info=all", "--output", "json")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
graph, err := parseModGraph(graphOut)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// refs are the repos to audit: modules as name@version, everything else
|
|
// by apparent repo name.
|
|
type repoRef struct {
|
|
ref, name string
|
|
}
|
|
var refs []repoRef
|
|
for _, m := range graph.Dependencies {
|
|
refs = append(refs, repoRef{m.Key, m.Name})
|
|
}
|
|
for _, u := range graph.ExtensionUsages {
|
|
switch {
|
|
case strings.HasPrefix(u.Key, "@@//:MODULE.bazel%") &&
|
|
(strings.HasSuffix(u.Key, " http_archive") || strings.HasSuffix(u.Key, " http_file")):
|
|
// http_archive/http_file declared directly in MODULE.bazel.
|
|
case strings.HasSuffix(u.Key, "%go_deps"):
|
|
// Go repos imported from the gazelle go_deps extension.
|
|
default:
|
|
// Toolchain extensions (crosstool, llvm_zlib, python, go_sdk,
|
|
// ...) are not audited.
|
|
continue
|
|
}
|
|
for _, r := range u.UsedRepos {
|
|
refs = append(refs, repoRef{"@" + r, r})
|
|
}
|
|
}
|
|
refNames := make([]string, 0, len(refs)+1)
|
|
refNames = append(refNames, "show_repo")
|
|
for _, r := range refs {
|
|
refNames = append(refNames, r.ref)
|
|
}
|
|
showOut, err := makeMod(refNames...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
repos, err := parseShowRepos(showOut)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
goModData, err := os.ReadFile(p.GoMod)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
goMod, err := modfile.Parse(p.GoMod, goModData, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
goVersions := make(map[string]string)
|
|
for _, r := range goMod.Require {
|
|
goVersions[r.Mod.Path] = r.Mod.Version
|
|
}
|
|
|
|
var deps []dep
|
|
for _, r := range refs {
|
|
repo, ok := repos[r.ref]
|
|
if !ok {
|
|
return nil, fmt.Errorf("bazel mod show_repo did not report %s", r.ref)
|
|
}
|
|
switch repo.rule {
|
|
case "go_repository":
|
|
path, version := repo.first("importpath"), repo.first("version")
|
|
if path == "" || version == "" {
|
|
return nil, fmt.Errorf("go_repository %s has no importpath or version", r.name)
|
|
}
|
|
// Bazel resolves Go modules across go.mod and go_deps.module
|
|
// declarations; for modules in both, pick the higher version.
|
|
if v, ok := goVersions[path]; !ok || semver.Compare(version, v) > 0 {
|
|
goVersions[path] = version
|
|
}
|
|
case "http_archive", "http_file":
|
|
urls := append(repo.attrs["url"], repo.attrs["urls"]...)
|
|
if len(urls) == 0 {
|
|
return nil, fmt.Errorf("%s %s has no URL", repo.rule, r.name)
|
|
}
|
|
deps = append(deps, dep{name: r.name, kind: kindArchive, url: pickURL(urls), sha256: repoSHA256(repo)})
|
|
case "local_repository", "new_local_repository":
|
|
// Local paths are part of the gVisor checkout, not external
|
|
// dependencies.
|
|
default:
|
|
return nil, fmt.Errorf("unsupported repository rule %s for %s", repo.rule, r.name)
|
|
}
|
|
}
|
|
for path, version := range goVersions {
|
|
deps = append(deps, dep{name: path, kind: kindGoModule, version: version})
|
|
}
|
|
sort.Slice(deps, func(i, j int) bool { return deps[i].name < deps[j].name })
|
|
for i := 1; i < len(deps); i++ {
|
|
if deps[i].name == deps[i-1].name {
|
|
return nil, fmt.Errorf("duplicate dependency name %q", deps[i].name)
|
|
}
|
|
}
|
|
return deps, nil
|
|
}
|
|
|
|
// makeMod runs `make mod TARGETS="..."`, which wraps `bazel mod`.
|
|
func makeMod(args ...string) (string, error) {
|
|
cmd := exec.Command("make", "-s", "mod", "OPTIONS=", "TARGETS="+strings.Join(args, " "))
|
|
var stdout, stderr bytes.Buffer
|
|
cmd.Stdout = &stdout
|
|
cmd.Stderr = &stderr
|
|
if err := cmd.Run(); err != nil {
|
|
return "", fmt.Errorf("cannot run make mod %s: %w\n%s", strings.Join(args, " "), err, stderr.String())
|
|
}
|
|
return stdout.String(), nil
|
|
}
|
|
|
|
// modGraph is the subset of `bazel mod graph --output json` that enumerate
|
|
// uses.
|
|
type modGraph struct {
|
|
Dependencies []struct {
|
|
Key string `json:"key"` // "name@version".
|
|
Name string `json:"name"` // Module name.
|
|
} `json:"dependencies"`
|
|
ExtensionUsages []struct {
|
|
Key string `json:"key"`
|
|
UsedRepos []string `json:"used_repos"`
|
|
} `json:"extensionUsages"`
|
|
}
|
|
|
|
// parseModGraph parses `bazel mod graph --output json` output, skipping any
|
|
// container-management noise preceding the JSON document.
|
|
func parseModGraph(out string) (*modGraph, error) {
|
|
start := strings.Index(out, "{")
|
|
if start < 0 {
|
|
return nil, fmt.Errorf("no JSON in bazel mod graph output %q", out)
|
|
}
|
|
var graph modGraph
|
|
if err := json.Unmarshal([]byte(out[start:]), &graph); err != nil {
|
|
return nil, fmt.Errorf("cannot parse bazel mod graph output: %w", err)
|
|
}
|
|
return &graph, nil
|
|
}
|
|
|
|
// repoInfo is one repo definition reported by `bazel mod show_repo`: the
|
|
// repository rule name and, for each attribute, the string literals in its
|
|
// value.
|
|
type repoInfo struct {
|
|
rule string
|
|
attrs map[string][]string
|
|
}
|
|
|
|
func (r repoInfo) first(attr string) string {
|
|
if v := r.attrs[attr]; len(v) > 0 {
|
|
return v[0]
|
|
}
|
|
return ""
|
|
}
|
|
|
|
var (
|
|
repoHeaderRE = regexp.MustCompile(`(?m)^## (.+):$`)
|
|
repoRuleRE = regexp.MustCompile(`^([A-Za-z_]\w*)\($`)
|
|
repoAttrRE = regexp.MustCompile(`^ (\w+) = (.*),$`)
|
|
quotedRE = regexp.MustCompile(`"((?:[^"\\]|\\.)*)"`)
|
|
)
|
|
|
|
// parseShowRepos parses `bazel mod show_repo` output into one repoInfo per
|
|
// "## <ref>:" section.
|
|
func parseShowRepos(out string) (map[string]repoInfo, error) {
|
|
repos := make(map[string]repoInfo)
|
|
headers := repoHeaderRE.FindAllStringSubmatchIndex(out, -1)
|
|
for i, h := range headers {
|
|
ref := out[h[2]:h[3]]
|
|
end := len(out)
|
|
if i+1 < len(headers) {
|
|
end = headers[i+1][0]
|
|
}
|
|
info := repoInfo{attrs: make(map[string][]string)}
|
|
for _, line := range strings.Split(out[h[1]:end], "\n") {
|
|
if strings.HasPrefix(line, "#") {
|
|
continue
|
|
}
|
|
if m := repoRuleRE.FindStringSubmatch(line); m != nil && info.rule == "" {
|
|
info.rule = m[1]
|
|
continue
|
|
}
|
|
if m := repoAttrRE.FindStringSubmatch(line); m != nil {
|
|
var values []string
|
|
for _, q := range quotedRE.FindAllStringSubmatch(m[2], -1) {
|
|
values = append(values, q[1])
|
|
}
|
|
info.attrs[m[1]] = values
|
|
}
|
|
}
|
|
if info.rule == "" {
|
|
return nil, fmt.Errorf("no repository rule in show_repo output for %s", ref)
|
|
}
|
|
repos[ref] = info
|
|
}
|
|
if len(repos) == 0 {
|
|
return nil, fmt.Errorf("no repos in bazel mod show_repo output %q", out)
|
|
}
|
|
return repos, nil
|
|
}
|
|
|
|
// pickURL returns the first URL whose license source is known, defaulting to
|
|
// the first URL.
|
|
func pickURL(urls []string) string {
|
|
for _, u := range urls {
|
|
if _, _, _, err := parseGitHubURL(u); err == nil {
|
|
return u
|
|
}
|
|
}
|
|
return urls[0]
|
|
}
|
|
|
|
// repoSHA256 returns the hex sha256 that Bazel pins for an archive repo,
|
|
// from its sha256 or "sha256-"-prefixed integrity attribute.
|
|
// Empty string if the repo is unpinned (e.g. @google_root_pem).
|
|
func repoSHA256(r repoInfo) string {
|
|
if s := r.first("sha256"); s != "" {
|
|
return s
|
|
}
|
|
if integrity := r.first("integrity"); strings.HasPrefix(integrity, "sha256-") {
|
|
if b, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(integrity, "sha256-")); err == nil {
|
|
return hex.EncodeToString(b)
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
const goProxy = "https://proxy.golang.org"
|
|
|
|
var (
|
|
errNotFound = errors.New("not found")
|
|
httpClient = &http.Client{Timeout: 2 * time.Minute}
|
|
)
|
|
|
|
// httpGet fetches a URL, retrying transient failures once. 404 and 410 are
|
|
// permanent and reported as errNotFound.
|
|
func httpGet(url string, header map[string]string) ([]byte, error) {
|
|
var lastErr error
|
|
for attempt := 0; attempt < 2; attempt++ {
|
|
req, err := http.NewRequest("GET", url, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header.Set("User-Agent", "gvisor.dev licensecheck")
|
|
for k, v := range header {
|
|
req.Header.Set(k, v)
|
|
}
|
|
resp, err := httpClient.Do(req)
|
|
if err != nil {
|
|
lastErr = err
|
|
continue
|
|
}
|
|
body, err := io.ReadAll(resp.Body)
|
|
resp.Body.Close()
|
|
switch {
|
|
case err != nil:
|
|
lastErr = err
|
|
case resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusGone:
|
|
return nil, fmt.Errorf("%s: %w", url, errNotFound)
|
|
case resp.StatusCode != http.StatusOK:
|
|
lastErr = fmt.Errorf("GET %s: %s", url, resp.Status)
|
|
default:
|
|
return body, nil
|
|
}
|
|
}
|
|
return nil, lastErr
|
|
}
|
|
|
|
// fetched is the result of fetching one dependency's license.
|
|
type fetched struct {
|
|
commit string
|
|
sha256 string
|
|
license Licenses
|
|
}
|
|
|
|
// fetchLicense returns the upstream commit, artifact hash, and licenses of a
|
|
// dependency.
|
|
func fetchLicense(d dep) (*fetched, error) {
|
|
if d.kind == kindGoModule {
|
|
return fetchGoModule(d.name, d.version)
|
|
}
|
|
f, err := fetchGitHub(d.url)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
f.sha256 = d.sha256
|
|
return f, nil
|
|
}
|
|
|
|
// licenseFileNames are candidate top-level license files, in priority order.
|
|
var licenseFileNames = []string{
|
|
"LICENSE", "LICENSE.txt", "LICENSE.md", "LICENSE.TXT", "LICENSE.MIT",
|
|
"LICENSE-APACHE-2.0.txt", "LICENCE", "COPYING", "License", "license.md",
|
|
}
|
|
|
|
var pseudoVersionRE = regexp.MustCompile(`\d{14}-([0-9a-f]{12})$`)
|
|
|
|
// fetchGoModule downloads a module from the Go module proxy and classifies
|
|
// the license file at its root.
|
|
func fetchGoModule(path, version string) (*fetched, error) {
|
|
escPath, err := module.EscapePath(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
escVersion, err := module.EscapeVersion(version)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
base := fmt.Sprintf("%s/%s/@v/%s", goProxy, escPath, escVersion)
|
|
var commit string
|
|
if body, err := httpGet(base+".info", nil); err == nil {
|
|
var info struct {
|
|
Origin struct{ Hash string }
|
|
}
|
|
if json.Unmarshal(body, &info) == nil {
|
|
commit = info.Origin.Hash
|
|
}
|
|
}
|
|
if commit == "" {
|
|
if m := pseudoVersionRE.FindStringSubmatch(version); m != nil {
|
|
commit = m[1]
|
|
}
|
|
}
|
|
// Older modules have no Origin metadata in the proxy; for GitHub-hosted
|
|
// ones, fall back to resolving the version tag. Best-effort: the commit
|
|
// stays empty if this fails.
|
|
if commit == "" && strings.HasPrefix(path, "github.com/") {
|
|
prefix, _, _ := module.SplitPathVersion(path)
|
|
if parts := strings.SplitN(prefix, "/", 4); len(parts) >= 3 {
|
|
tag, _, _ := strings.Cut(version, "+")
|
|
if len(parts) == 4 {
|
|
// Tags of modules in a subdirectory are prefixed with it.
|
|
tag = parts[3] + "/" + tag
|
|
}
|
|
if c, err := resolveGitHubCommit(parts[1], parts[2], tag); err == nil {
|
|
commit = c
|
|
}
|
|
}
|
|
}
|
|
zipBody, err := httpGet(base+".zip", nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("cannot download module zip: %w", err)
|
|
}
|
|
zipSum := sha256.Sum256(zipBody)
|
|
zipReader, err := zip.NewReader(bytes.NewReader(zipBody), int64(len(zipBody)))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("cannot read module zip: %w", err)
|
|
}
|
|
rootFiles := make(map[string]*zip.File)
|
|
prefix := path + "@" + version + "/"
|
|
for _, f := range zipReader.File {
|
|
if rest, ok := strings.CutPrefix(f.Name, prefix); ok && !strings.Contains(rest, "/") {
|
|
rootFiles[rest] = f
|
|
}
|
|
}
|
|
for _, name := range licenseFileNames {
|
|
f, ok := rootFiles[name]
|
|
if !ok {
|
|
continue
|
|
}
|
|
r, err := f.Open()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
text, err := io.ReadAll(r)
|
|
r.Close()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
license, err := classify(string(text))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%s: %w", name, err)
|
|
}
|
|
return &fetched{commit: commit, sha256: hex.EncodeToString(zipSum[:]), license: license}, nil
|
|
}
|
|
return nil, errors.New("no license file at module root")
|
|
}
|
|
|
|
var githubURLRegexps = []*regexp.Regexp{
|
|
regexp.MustCompile(`^https://github\.com/([^/]+)/([^/]+)/releases/download/([^/]+)/`),
|
|
regexp.MustCompile(`^https://github\.com/([^/]+)/([^/]+)/archive/refs/tags/(.+?)\.(?:tar\.gz|tar\.xz|tar\.bz2|zip)$`),
|
|
regexp.MustCompile(`^https://github\.com/([^/]+)/([^/]+)/archive/(.+?)\.(?:tar\.gz|tar\.xz|tar\.bz2|zip)$`),
|
|
regexp.MustCompile(`^https://raw\.githubusercontent\.com/([^/]+)/([^/]+)/([^/]+)/`),
|
|
}
|
|
|
|
// parseGitHubURL extracts (owner, repo, ref) from the archive and raw-file
|
|
// URL used by MODULE.bazel and the Bazel Central Registry.
|
|
func parseGitHubURL(url string) (owner, repo, ref string, err error) {
|
|
for _, re := range githubURLRegexps {
|
|
if m := re.FindStringSubmatch(url); m != nil {
|
|
return m[1], m[2], m[3], nil
|
|
}
|
|
}
|
|
return "", "", "", fmt.Errorf("cannot determine license source for %q", url)
|
|
}
|
|
|
|
// fetchGitHub resolves a source URL to a GitHub repo and classifies the
|
|
// license file at its root.
|
|
func fetchGitHub(url string) (*fetched, error) {
|
|
owner, repo, ref, err := parseGitHubURL(url)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
commit, err := resolveGitHubCommit(owner, repo, ref)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("cannot resolve commit for %s/%s@%s: %w", owner, repo, ref, err)
|
|
}
|
|
for _, name := range licenseFileNames {
|
|
body, err := httpGet(fmt.Sprintf("https://raw.githubusercontent.com/%s/%s/%s/%s", owner, repo, ref, name), nil)
|
|
if errors.Is(err, errNotFound) {
|
|
continue
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
license, err := classify(string(body))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%s: %w", name, err)
|
|
}
|
|
return &fetched{commit: commit, license: license}, nil
|
|
}
|
|
return nil, fmt.Errorf("no license file in %s/%s@%s", owner, repo, ref)
|
|
}
|
|
|
|
var commitRE = regexp.MustCompile(`^[0-9a-f]{40}$`)
|
|
|
|
// resolveGitHubCommit resolves a tag or abbreviated hash to a full commit
|
|
// hash. GITHUB_TOKEN, if set, raises the API rate limit.
|
|
func resolveGitHubCommit(owner, repo, ref string) (string, error) {
|
|
if commitRE.MatchString(ref) {
|
|
return ref, nil
|
|
}
|
|
header := map[string]string{"Accept": "application/vnd.github.sha"}
|
|
if token := os.Getenv("GITHUB_TOKEN"); token != "" {
|
|
header["Authorization"] = "Bearer " + token
|
|
}
|
|
body, err := httpGet(fmt.Sprintf("https://api.github.com/repos/%s/%s/commits/%s", owner, repo, ref), header)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
commit := strings.TrimSpace(string(body))
|
|
if !commitRE.MatchString(commit) {
|
|
return "", fmt.Errorf("unexpected GitHub API response %q", commit)
|
|
}
|
|
return commit, nil
|
|
}
|
|
|
|
// classify maps license text to the set of licenses it contains.
|
|
// Detection looks for phrases unique to each license and reports all that
|
|
// match. The GNU patterns match the dated titles of the full license texts,
|
|
// so that passing references (e.g. in MPL-2.0's "Secondary License" clause)
|
|
// do not trigger them.
|
|
func classify(text string) (Licenses, error) {
|
|
t := strings.ToLower(strings.Join(strings.Fields(text), " "))
|
|
var ids Licenses
|
|
if strings.Contains(t, "apache license") && strings.Contains(t, "version 2.0") {
|
|
if strings.Contains(t, "llvm exceptions") {
|
|
ids = append(ids, apache2LLVM)
|
|
} else {
|
|
ids = append(ids, apache2)
|
|
}
|
|
}
|
|
if strings.Contains(t, "permission is hereby granted, free of charge") {
|
|
ids = append(ids, mit)
|
|
}
|
|
if strings.Contains(t, "redistribution and use in source and binary forms") {
|
|
switch {
|
|
case strings.Contains(t, "all advertising materials"):
|
|
ids = append(ids, bsd4)
|
|
case strings.Contains(t, "neither the name"):
|
|
ids = append(ids, bsd3)
|
|
default:
|
|
ids = append(ids, bsd2)
|
|
}
|
|
}
|
|
if strings.Contains(t, "mozilla public license version 2.0") {
|
|
ids = append(ids, mpl2)
|
|
}
|
|
if strings.Contains(t, "gnu lesser general public license version 2.1, february 1999") {
|
|
ids = append(ids, lgpl21)
|
|
}
|
|
if strings.Contains(t, "gnu lesser general public license version 3, 29 june 2007") {
|
|
ids = append(ids, lgpl3)
|
|
}
|
|
if strings.Contains(t, "gnu general public license version 2, june 1991") {
|
|
ids = append(ids, gpl2)
|
|
}
|
|
if strings.Contains(t, "gnu general public license version 3, 29 june 2007") {
|
|
ids = append(ids, gpl3)
|
|
}
|
|
if strings.Contains(t, "permission to use, copy, modify") && strings.Contains(t, "distribute this software for any purpose") {
|
|
ids = append(ids, isc)
|
|
}
|
|
if strings.Contains(t, "this is free and unencumbered software") {
|
|
ids = append(ids, unlicense)
|
|
}
|
|
if len(ids) == 0 {
|
|
return nil, errors.New("cannot classify license text")
|
|
}
|
|
slices.Sort(ids)
|
|
return ids, nil
|
|
}
|
|
|
|
// Policy is a dependency licensing policy (governance/licensing.yaml).
|
|
type Policy struct {
|
|
AllowedLicenses []License `yaml:"allowed_licenses"`
|
|
Exceptions []Exception `yaml:"exceptions"`
|
|
}
|
|
|
|
// Exception allows one dependency to use licenses outside
|
|
// Policy.AllowedLicenses. License must match the dependency's entry exactly,
|
|
// so that license changes appear during review.
|
|
type Exception struct {
|
|
Dependency string `yaml:"dependency"`
|
|
License Licenses `yaml:"license"`
|
|
ExceptionRationale string `yaml:"exception_rationale"`
|
|
}
|
|
|
|
// ReadPolicy loads a licensing policy file.
|
|
func ReadPolicy(path string) (*Policy, error) {
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var policy Policy
|
|
if err := yaml.Unmarshal(data, &policy); err != nil {
|
|
return nil, fmt.Errorf("%s: %w", path, err)
|
|
}
|
|
return &policy, nil
|
|
}
|
|
|
|
// CheckPolicy returns a problem for every dependency whose licenses are
|
|
// neither all in policy.AllowedLicenses nor covered by an exception, and for
|
|
// every malformed, stale, or unnecessary exception.
|
|
func CheckPolicy(entries []Entry, policy *Policy) []string {
|
|
allowed := make(map[License]bool)
|
|
for _, license := range policy.AllowedLicenses {
|
|
allowed[license] = true
|
|
}
|
|
conforms := func(l Licenses) bool {
|
|
for _, license := range l {
|
|
if !allowed[license] {
|
|
return false
|
|
}
|
|
}
|
|
return len(l) > 0
|
|
}
|
|
byName := make(map[string]Entry)
|
|
for _, e := range entries {
|
|
byName[e.Dependency] = e
|
|
}
|
|
var problems []string
|
|
exceptions := make(map[string]bool)
|
|
for i, x := range policy.Exceptions {
|
|
if exceptions[x.Dependency] {
|
|
problems = append(problems, fmt.Sprintf("duplicate exception for %s", x.Dependency))
|
|
}
|
|
exceptions[x.Dependency] = true
|
|
if i > 0 && policy.Exceptions[i-1].Dependency >= x.Dependency {
|
|
problems = append(problems, fmt.Sprintf("exceptions are not sorted by dependency at %s", x.Dependency))
|
|
}
|
|
if x.ExceptionRationale == "" {
|
|
problems = append(problems, fmt.Sprintf("exception for %s has no rationale", x.Dependency))
|
|
}
|
|
e, ok := byName[x.Dependency]
|
|
switch {
|
|
case !ok:
|
|
problems = append(problems, fmt.Sprintf("exception for %s, which is not a dependency", x.Dependency))
|
|
case !slices.Equal(x.License, e.License):
|
|
problems = append(problems, fmt.Sprintf("exception for %s lists licenses %v, but the dependency uses %v", x.Dependency, x.License, e.License))
|
|
case conforms(e.License):
|
|
problems = append(problems, fmt.Sprintf("unnecessary exception for %s, whose licenses are all allowed", x.Dependency))
|
|
}
|
|
}
|
|
for _, e := range entries {
|
|
if !conforms(e.License) && !exceptions[e.Dependency] {
|
|
problems = append(problems, fmt.Sprintf("%s uses disallowed licenses %v and has no exception in the policy", e.Dependency, e.License))
|
|
}
|
|
}
|
|
return problems
|
|
}
|
|
|
|
// readEntries loads the YAML file.
|
|
func readEntries(path string) ([]Entry, error) {
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var entries []Entry
|
|
if err := yaml.Unmarshal(data, &entries); err != nil {
|
|
return nil, fmt.Errorf("%s: %w", path, err)
|
|
}
|
|
return entries, nil
|
|
}
|
|
|
|
// writeEntries writes the YAML file.
|
|
func writeEntries(path string, entries []Entry) error {
|
|
data, err := yaml.Marshal(entries)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return os.WriteFile(path, append([]byte(yamlHeader), data...), 0644)
|
|
}
|