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.

Code

392 commit(s) on main

Merge change request 01M70WES5ZZDJ6RP226WB11BBZ: Let people rename and remove their own passkeys keenan · 50m ago 4b8c9e1d
history blame raw 4.5 KiB
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,
5then call this:
6
7 python3 scripts/check-licenses.py bom.json
8
9Two inventories are checked, because they arrive by different routes.
10
11Go modules have metadata, so the scanner reports a license for each one and this
12script compares it against `licenses.toml`. A module with no detected license is a
13failure, not a pass: unknown is not permission.
14
15Vendored browser assets have no metadata at all, so `NOTICE` is their inventory.
16Every 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
18unnoticed.
19"""
20
21import json
22import os
23import re
24import sys
25import tomllib
26
27ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
28STATIC = os.path.join(ROOT, "server", "web", "static")
29ALLOWLIST = os.path.join(ROOT, "licenses.toml")
30NOTICE = 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.
34LICENSE_FILE = re.compile(r"(?:^|/)[\w.-]*LICENSE[\w.-]*$|(?:^|/)OFL-[\w.-]+\.txt$")
35
36
37def 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
66def 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
89def 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
113if __name__ == "__main__":
114 sys.exit(main(sys.argv))
115