summaryrefslogtreecommitdiff
path: root/check-null-licenses
blob: fe0e4eb1c982ba1a2cb5955385d96ee0c71d90cb (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
#!/usr/bin/python3
# -*- coding: utf-8 -*-

import json
from argparse import ArgumentParser, FileType, RawDescriptionHelpFormatter
from pathlib import Path
from sys import exit, stderr

import tomllib


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 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("rb"),
        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 = tomllib.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())