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/vole.git

Write over SSH with a registered key:

git clone ssh://git@rodent.software:2222/vole.git

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

Fail a push that adds a license this project cannot ship

70ba9a69c091edf3c4ddc80f3881bf44e0234f78 · Keenan Nemetz · 10d ago · parent 122a697b

Fail a push that adds a license this project cannot ship

The build failed on a reachable vulnerability but looked at no license, so a
dependency the project may not distribute could land in a change request without
a reviewer seeing it. NOTICE was the only inventory, and nothing kept it true:
canvas-confetti had been shipped for weeks without an entry.

licenses.toml at the repository root lists what the project can ship, each entry
with the reason. verify builds a bill of materials and fails when a linked module
carries anything else; a module with no detected license fails too, because
unknown is not permission. NOTICE is checked both ways against the license files
beside the vendored assets. The release run checks as well, since a tag can point
at a commit that never passed a push.

Closes #95.

scripts/check-licenses.py +114

@@ -0,0 +1,114 @@
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
18 unnoticed, which is how the confetti library arrived.
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))