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