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)) | ||