192 lines
5.7 KiB
Plaintext
192 lines
5.7 KiB
Plaintext
|
#!/usr/bin/python3
|
|||
|
# -*- coding: utf-8 -*-
|
|||
|
|
|||
|
import json
|
|||
|
from argparse import ArgumentParser, FileType, RawDescriptionHelpFormatter
|
|||
|
from pathlib import Path
|
|||
|
from sys import exit, stderr
|
|||
|
|
|||
|
import toml
|
|||
|
|
|||
|
|
|||
|
def main():
|
|||
|
args = parse_args()
|
|||
|
problem = False
|
|||
|
if not args.tree.is_dir():
|
|||
|
return f"Not a directory: {args.tree}"
|
|||
|
for pjpath in args.tree.glob("**/package.json"):
|
|||
|
name, version, license = parse(pjpath)
|
|||
|
identity = f"{name} {version}"
|
|||
|
if version in args.exceptions.get(name, ()):
|
|||
|
continue # Do not even check the license
|
|||
|
elif license is None:
|
|||
|
problem = True
|
|||
|
print(
|
|||
|
f"Missing license in package.json for {identity}", file=stderr
|
|||
|
)
|
|||
|
elif isinstance(license, dict):
|
|||
|
if isinstance(license.get("type"), str):
|
|||
|
continue
|
|||
|
print(
|
|||
|
(
|
|||
|
"Missing type for (deprecated) license object in "
|
|||
|
f"package.json for {identity}: {license}"
|
|||
|
),
|
|||
|
file=stderr,
|
|||
|
)
|
|||
|
elif isinstance(license, list):
|
|||
|
if license and all(
|
|||
|
isinstance(entry, dict) and isinstance(entry.get("type"), str)
|
|||
|
for entry in license
|
|||
|
):
|
|||
|
continue
|
|||
|
print(
|
|||
|
(
|
|||
|
"Defective (deprecated) licenses array-of objects in "
|
|||
|
f"package.json for {identity}: {license}"
|
|||
|
),
|
|||
|
file=stderr,
|
|||
|
)
|
|||
|
elif isinstance(license, str):
|
|||
|
continue
|
|||
|
else:
|
|||
|
print(
|
|||
|
(
|
|||
|
"Weird type for license in "
|
|||
|
f"package.json for {identity}: {license}"
|
|||
|
),
|
|||
|
file=stderr,
|
|||
|
)
|
|||
|
problem = True
|
|||
|
if problem:
|
|||
|
return "At least one missing license was found."
|
|||
|
|
|||
|
|
|||
|
def check_exception(exceptions, name, version):
|
|||
|
x = args.exceptions
|
|||
|
|
|||
|
|
|||
|
def parse(package_json_path):
|
|||
|
with package_json_path.open("rb") as pjfile:
|
|||
|
pj = json.load(pjfile)
|
|||
|
try:
|
|||
|
license = pj["license"]
|
|||
|
except KeyError:
|
|||
|
license = pj.get("licenses")
|
|||
|
try:
|
|||
|
name = pj["name"]
|
|||
|
except KeyError:
|
|||
|
name = package_json_path.parent.name
|
|||
|
version = pj.get("version", "<unknown version>")
|
|||
|
|
|||
|
return name, version, license
|
|||
|
|
|||
|
|
|||
|
def parse_args():
|
|||
|
parser = ArgumentParser(
|
|||
|
formatter_class=RawDescriptionHelpFormatter,
|
|||
|
description=(
|
|||
|
"Search for bundled dependencies without declared licenses"
|
|||
|
),
|
|||
|
epilog="""
|
|||
|
|
|||
|
The exceptions file must be a TOML file with zero or more tables. Each table’s
|
|||
|
keys are package names; the corresponding values values are exact version
|
|||
|
number strings, or arrays of version number strings, that have been manually
|
|||
|
audited to determine their license status and should therefore be ignored.
|
|||
|
|
|||
|
Exceptions in a table called “any” are always applied. Otherwise, exceptions
|
|||
|
are applied only if a corresponding --with TABLENAME argument is given;
|
|||
|
multiple such arguments may be given.
|
|||
|
|
|||
|
For
|
|||
|
example:
|
|||
|
|
|||
|
[any]
|
|||
|
example-foo = "1.0.0"
|
|||
|
|
|||
|
[prod]
|
|||
|
example-bar = [ "2.0.0", "2.0.1",]
|
|||
|
|
|||
|
[dev]
|
|||
|
example-bat = [ "3.7.4",]
|
|||
|
|
|||
|
would always ignore version 1.0.0 of example-foo. It would ignore example-bar
|
|||
|
2.0.1 only when called with “--with prod”.
|
|||
|
|
|||
|
Comments may (and should) be used to describe the manual audits upon which the
|
|||
|
exclusions are based.
|
|||
|
|
|||
|
Otherwise, any package.json with missing or null license field in the tree is
|
|||
|
considered an error, and the program returns with nonzero status.
|
|||
|
""",
|
|||
|
)
|
|||
|
parser.add_argument(
|
|||
|
"-x",
|
|||
|
"--exceptions",
|
|||
|
type=FileType("r"),
|
|||
|
help="Manually audited package versions file",
|
|||
|
)
|
|||
|
parser.add_argument(
|
|||
|
"-w",
|
|||
|
"--with",
|
|||
|
action="append",
|
|||
|
default=[],
|
|||
|
help="Enable a table in the exceptions file",
|
|||
|
)
|
|||
|
parser.add_argument(
|
|||
|
"tree",
|
|||
|
metavar="node_modules_dir",
|
|||
|
type=Path,
|
|||
|
help="Path to search recursively",
|
|||
|
default=".",
|
|||
|
)
|
|||
|
args = parser.parse_args()
|
|||
|
|
|||
|
if args.exceptions is None:
|
|||
|
args.exceptions = {}
|
|||
|
xname = None
|
|||
|
else:
|
|||
|
with args.exceptions as xfile:
|
|||
|
xname = getattr(xfile, "name", "<exceptions>")
|
|||
|
args.exceptions = toml.load(args.exceptions)
|
|||
|
if not isinstance(args.exceptions, dict):
|
|||
|
parser.error(f"Invalid format in {xname}: not an object")
|
|||
|
for tablename, table in args.exceptions.items():
|
|||
|
if not isinstance(table, dict):
|
|||
|
parser.error(
|
|||
|
f"Non-table entry in {xname}: {tablename} = {table!r}"
|
|||
|
)
|
|||
|
overlay = {}
|
|||
|
for key, value in table.items():
|
|||
|
if isinstance(value, str):
|
|||
|
overlay[key] = [value]
|
|||
|
elif not isinstance(value, list) or not all(
|
|||
|
isinstance(entry, str) for entry in value
|
|||
|
):
|
|||
|
parser.error(
|
|||
|
f"Invalid format in {xname} in [{tablename}]: "
|
|||
|
f"{key!r} = {value!r}"
|
|||
|
)
|
|||
|
table.update(overlay)
|
|||
|
|
|||
|
x = args.exceptions.get("any", {})
|
|||
|
for add in getattr(args, "with"):
|
|||
|
try:
|
|||
|
x.update(args.exceptions[add])
|
|||
|
except KeyError:
|
|||
|
if xname is None:
|
|||
|
parser.error(
|
|||
|
f"No table {add}, as no exceptions file was given"
|
|||
|
)
|
|||
|
else:
|
|||
|
parser.error(f"No table {add} in {xname}")
|
|||
|
# Store the merged dictionary
|
|||
|
args.exceptions = x
|
|||
|
|
|||
|
return args
|
|||
|
|
|||
|
|
|||
|
if __name__ == "__main__":
|
|||
|
exit(main())
|