Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

vole
Clone

Read only, no credentials:

https://rodent.software/git/vole.git

Write over SSH with a registered key:

git clone ssh://git@0.0.0.0:2222/vole.git

A free and open-source forge that lives in your Git repo.

Blame · scripts/check-licenses.py

Line attribution on main · 114 line(s)

70ba9a69
Keenan Nemetz
10d ago
1 #!/usr/bin/env python3
2 """Fail when something this repository ships may not be shipped.
3
4 `vole workflow run verify` and the release run build a bill of materials first,
5 then call this:
6
7 python3 scripts/check-licenses.py bom.json
8
9 Two inventories are checked, because they arrive by different routes.
10
11 Go modules have metadata, so the scanner reports a license for each one and this
12 script compares it against `licenses.toml`. A module with no detected license is a
13 failure, not a pass: unknown is not permission.
14
15 Vendored browser assets have no metadata at all, so `NOTICE` is their inventory.
16 Every license file beside an asset must be named there, and every license file
17 `NOTICE` names must exist. Checking one direction only lets a new asset in
8032d15a
Keenan Nemetz
6d ago
18 unnoticed.
70ba9a69
Keenan Nemetz
10d ago
19 """
20
21 import json
22 import os
23 import re
24 import sys
25 import tomllib
26
27 ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
28 STATIC = os.path.join(ROOT, "server", "web", "static")
29 ALLOWLIST = os.path.join(ROOT, "licenses.toml")
30 NOTICE = os.path.join(ROOT, "NOTICE")
31
32 # A license file a vendored asset carries beside it: htmx.LICENSE, prism.LICENSE,
33 # and the fonts' OFL-*.txt.
34 LICENSE_FILE = re.compile(r"(?:^|/)[\w.-]*LICENSE[\w.-]*$|(?:^|/)OFL-[\w.-]+\.txt$")
35
36
37 def module_findings(bom: dict) -> tuple[list[str], list[str]]:
38 """Compare every linked module's license against the allowlist."""
39 with open(ALLOWLIST, encoding="utf-8") as f:
40 allowed = tomllib.loads(f.read())["modules"]
41 findings, seen = [], []
42 for component in bom.get("components", []):
43 name = component.get("name") or "?"
44 # The standard library ships with the toolchain. Its license is the
45 # toolchain's, not a dependency this project chose.
46 if name == "std":
47 continue
48 licenses = (component.get("evidence") or {}).get("licenses") or component.get("licenses") or []
49 if not licenses:
50 findings.append(f"{name}: no license detected, so it cannot be allowed")
51 continue
52 for entry in licenses:
53 # A compound license arrives as an expression rather than an id; it
54 # has to be allowed as a whole, because reading "MIT OR GPL-3.0" as
55 # MIT would be exactly the mistake this check exists to catch.
56 identifier = entry.get("expression") or (entry.get("license") or {}).get("id")
57 if not identifier:
58 findings.append(f"{name}: a license entry this check cannot read")
59 continue
60 seen.append(identifier)
61 if identifier not in allowed:
62 findings.append(f"{name}: {identifier} is not in licenses.toml")
63 return findings, sorted(set(seen))
64
65
66 def asset_findings() -> list[str]:
67 """Check NOTICE against the license files beside the vendored assets."""
68 with open(NOTICE, encoding="utf-8") as f:
69 notice = f.read()
70 findings = []
71 for root, dirs, files in os.walk(STATIC):
72 dirs.sort()
73 for name in sorted(files):
74 path = os.path.join(root, name)
75 relative = os.path.relpath(path, ROOT).replace(os.sep, "/")
76 if not LICENSE_FILE.search(relative):
77 continue
78 if relative not in notice:
79 findings.append(f"{relative}: not named in NOTICE")
80 # The suffix stops at the identifier, so the full stop that ends the sentence
81 # in NOTICE is not read as part of the path.
82 named = set(re.findall(r"[\w./-]*?(?:LICENSE[\w-]*|OFL-[\w-]+\.txt)", notice))
83 for path in sorted(named):
84 if not os.path.isfile(os.path.join(ROOT, path)):
85 findings.append(f"NOTICE names {path}, which does not exist")
86 return findings
87
88
89 def main(argv: list[str]) -> int:
90 if len(argv) != 2:
91 print("usage: check-licenses.py <sbom.json>", file=sys.stderr)
92 return 2
93 try:
94 with open(argv[1], encoding="utf-8") as f:
95 bom = json.load(f)
96 except OSError as err:
97 print(f"{argv[1]}: {err.strerror}; generate it with cyclonedx-gomod first", file=sys.stderr)
98 return 2
99 except json.JSONDecodeError as err:
100 print(f"{argv[1]}: not a bill of materials: {err}", file=sys.stderr)
101 return 2
102
103 findings, seen = module_findings(bom)
104 findings += asset_findings()
105 if findings:
106 print("\n".join(findings))
107 return 1
108 modules = [c for c in bom.get("components", []) if c.get("name") != "std"]
109 print(f"{len(modules)} modules ({', '.join(seen)}); NOTICE matches the assets")
110 return 0
111
112
113 if __name__ == "__main__":
114 sys.exit(main(sys.argv))