copyleft-audit 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- copyleft_audit-0.1.0/LICENSE +21 -0
- copyleft_audit-0.1.0/PKG-INFO +175 -0
- copyleft_audit-0.1.0/README.md +146 -0
- copyleft_audit-0.1.0/copyleft_audit/__init__.py +30 -0
- copyleft_audit-0.1.0/copyleft_audit/__main__.py +10 -0
- copyleft_audit-0.1.0/copyleft_audit/classify.py +256 -0
- copyleft_audit-0.1.0/copyleft_audit/cli.py +286 -0
- copyleft_audit-0.1.0/copyleft_audit.egg-info/PKG-INFO +175 -0
- copyleft_audit-0.1.0/copyleft_audit.egg-info/SOURCES.txt +12 -0
- copyleft_audit-0.1.0/copyleft_audit.egg-info/dependency_links.txt +1 -0
- copyleft_audit-0.1.0/copyleft_audit.egg-info/entry_points.txt +2 -0
- copyleft_audit-0.1.0/copyleft_audit.egg-info/top_level.txt +1 -0
- copyleft_audit-0.1.0/pyproject.toml +56 -0
- copyleft_audit-0.1.0/setup.cfg +4 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 copyleft-audit contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: copyleft-audit
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Fail your CI build when a dependency's licence breaks policy. Detects GPL, AGPL, SSPL and LGPL in installed packages. Zero dependencies, stdlib only.
|
|
5
|
+
Project-URL: Homepage, https://pypi.org/project/copyleft-audit/
|
|
6
|
+
Project-URL: Changelog, https://pypi.org/project/copyleft-audit/#history
|
|
7
|
+
Keywords: copyleft,gpl,agpl,lgpl,sspl,license,licence,license-compliance,license-checker,open-source-compliance,sbom,ci,audit,due-diligence,dependencies
|
|
8
|
+
Classifier: Development Status :: 4 - Beta
|
|
9
|
+
Classifier: Environment :: Console
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Intended Audience :: Legal Industry
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
22
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
23
|
+
Classifier: Topic :: System :: Software Distribution
|
|
24
|
+
Classifier: Topic :: Utilities
|
|
25
|
+
Requires-Python: >=3.8
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
License-File: LICENSE
|
|
28
|
+
Dynamic: license-file
|
|
29
|
+
|
|
30
|
+
# copyleft-audit
|
|
31
|
+
|
|
32
|
+
**Fail your CI build when a dependency's licence breaks policy.**
|
|
33
|
+
Detects GPL, AGPL, SSPL, LGPL, MPL and friends in the packages actually
|
|
34
|
+
installed in your environment — and exits non-zero so the pipeline stops.
|
|
35
|
+
|
|
36
|
+
Zero runtime dependencies. Standard library only. It will not touch your
|
|
37
|
+
dependency tree, and it makes no network calls.
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
pip install copyleft-audit
|
|
41
|
+
copyleft-audit
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Why this exists
|
|
45
|
+
|
|
46
|
+
`pip-licenses` and similar tools *list* licences for a human to read. That is a
|
|
47
|
+
different job from *enforcing* a policy in CI. If you ship closed-source
|
|
48
|
+
software, the question is not "what licences are in here" but "does the build
|
|
49
|
+
stop when someone adds an AGPL package". This tool answers the second question:
|
|
50
|
+
one command, an exit code, no configuration file required.
|
|
51
|
+
|
|
52
|
+
Typical triggers for needing it: legal asks you to prove no copyleft code ships
|
|
53
|
+
in the product; an acquirer's due-diligence checklist asks the same; or you just
|
|
54
|
+
want the check to run automatically instead of once a year.
|
|
55
|
+
|
|
56
|
+
## Usage
|
|
57
|
+
|
|
58
|
+
```
|
|
59
|
+
copyleft-audit [--fail-on {strong,weak,unknown}] [--ignore PKG[,PKG...]]
|
|
60
|
+
[--format {text,json}] [--quiet]
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
| Exit code | Meaning |
|
|
64
|
+
|---|---|
|
|
65
|
+
| `0` | Clean — no policy violations |
|
|
66
|
+
| `1` | Policy violation |
|
|
67
|
+
| `2` | Usage or internal error |
|
|
68
|
+
|
|
69
|
+
GitHub Actions:
|
|
70
|
+
|
|
71
|
+
```yaml
|
|
72
|
+
- name: Licence gate
|
|
73
|
+
run: |
|
|
74
|
+
pip install copyleft-audit
|
|
75
|
+
copyleft-audit --fail-on strong
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Stricter, for a product that cannot even take weak copyleft or unresolvable
|
|
79
|
+
metadata:
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
copyleft-audit --fail-on unknown --ignore mypackage,internal-sdk
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Machine-readable output, for feeding another tool:
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
copyleft-audit --format json
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
```json
|
|
92
|
+
{
|
|
93
|
+
"packages": [{"name": "...", "version": "...", "license": "...",
|
|
94
|
+
"license_source": "...", "tier": "...", "ignored": false}],
|
|
95
|
+
"violations": ["... same shape ..."],
|
|
96
|
+
"warnings": ["... same shape ..."],
|
|
97
|
+
"summary": {"tool_version": "...", "fail_on": "strong", "scanned": 0,
|
|
98
|
+
"unreadable": 0, "ignored": [], "counts": {},
|
|
99
|
+
"violation_count": 0, "warning_count": 0, "exit_code": 0}
|
|
100
|
+
}
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
The JSON schema is stable within the `0.1.x` line; new keys may be added, and
|
|
104
|
+
existing keys will not change meaning without a version bump.
|
|
105
|
+
|
|
106
|
+
## Risk tiers
|
|
107
|
+
|
|
108
|
+
| Tier | Licences | Default |
|
|
109
|
+
|---|---|---|
|
|
110
|
+
| `STRONG_COPYLEFT` | GPL-2.0, GPL-3.0, AGPL-3.0, SSPL, OSL, EUPL | **fail** |
|
|
111
|
+
| `WEAK_COPYLEFT` | LGPL-2.1, LGPL-3.0, MPL-2.0, EPL-2.0, CDDL, CPL | warn |
|
|
112
|
+
| `PERMISSIVE` | MIT, BSD, Apache-2.0, ISC, PSF, Unlicense, CC0, Zlib, … | pass |
|
|
113
|
+
| `UNKNOWN` | no resolvable licence metadata | warn |
|
|
114
|
+
|
|
115
|
+
`--fail-on weak` promotes weak copyleft to a failure. `--fail-on unknown`
|
|
116
|
+
promotes both weak copyleft and unknown metadata to a failure.
|
|
117
|
+
|
|
118
|
+
## How it decides, precisely
|
|
119
|
+
|
|
120
|
+
Licence metadata is read in this order, and the first source that resolves wins:
|
|
121
|
+
`License-Expression` (PEP 639) → `License ::` trove classifiers → the free-text
|
|
122
|
+
`License` field. The source used is reported in `license_source`, so you can see
|
|
123
|
+
what the verdict rests on.
|
|
124
|
+
|
|
125
|
+
Deliberate design choices worth knowing about:
|
|
126
|
+
|
|
127
|
+
- **`UNKNOWN` is a real answer.** When metadata is missing, empty, or not
|
|
128
|
+
recognised, the tool says `UNKNOWN`. It never guesses a tier. A confident
|
|
129
|
+
wrong answer here would be worse than no answer.
|
|
130
|
+
- **A package whose metadata cannot be parsed is reported as `UNKNOWN`, never
|
|
131
|
+
skipped.** Silently dropping a package would produce a false clean report.
|
|
132
|
+
- **`LGPL` and `AGPL` are never read as `GPL`.** Matching is on whole tokens and
|
|
133
|
+
masked phrases, not substrings — the classic mis-tiering bug in naive
|
|
134
|
+
scanners.
|
|
135
|
+
- **`MIT OR GPL-3.0` resolves to permissive**, because you may take the MIT
|
|
136
|
+
option. `AND` resolves to the stricter operand. Parenthesised SPDX expressions
|
|
137
|
+
are not parsed and are scored conservatively (highest risk wins).
|
|
138
|
+
- **Multiple licence classifiers are read as a choice**, so the lowest-risk one
|
|
139
|
+
governs — but every licence string found is printed, so nothing is hidden.
|
|
140
|
+
|
|
141
|
+
## Limits — please read before trusting it
|
|
142
|
+
|
|
143
|
+
- It reads **declared package metadata only**. It does not scan source files,
|
|
144
|
+
vendored code, bundled binaries, or transitively linked native libraries. A
|
|
145
|
+
package that declares MIT while vendoring GPL code will read as MIT.
|
|
146
|
+
- It classifies what is **installed in the current environment**, so run it in
|
|
147
|
+
the same environment your build produces.
|
|
148
|
+
- **This is not legal advice**, and the output is not a legal opinion. It is a
|
|
149
|
+
metadata heuristic that helps engineers catch the obvious cases early. Real
|
|
150
|
+
licence compliance decisions belong with your counsel.
|
|
151
|
+
|
|
152
|
+
## Paid: auditor-ready attestation report
|
|
153
|
+
|
|
154
|
+
The tool is free and stays free — nothing is gated behind payment.
|
|
155
|
+
|
|
156
|
+
If you need the output as a **document you can hand to an acquirer, an auditor,
|
|
157
|
+
or your own legal team**, there is a one-time paid report for a single project:
|
|
158
|
+
every installed dependency with its declared licence, the metadata field that
|
|
159
|
+
licence was read from, its risk tier, and an explicit list of everything that
|
|
160
|
+
came back `UNKNOWN` with what is missing. Delivered as PDF plus the raw JSON.
|
|
161
|
+
$149, one project, one-time.
|
|
162
|
+
|
|
163
|
+
→ **[Order the attestation report](https://buy.stripe.com/3cI8wP9libJt5Xrdd7a7C08)**
|
|
164
|
+
|
|
165
|
+
Same honesty applies: the report documents declared metadata, and it is not a
|
|
166
|
+
legal opinion.
|
|
167
|
+
|
|
168
|
+
## Disclosure
|
|
169
|
+
|
|
170
|
+
This package was written by an AI agent (Claude) operating autonomously, and is
|
|
171
|
+
published and maintained under human ownership. It is deliberately small,
|
|
172
|
+
dependency-free and standard-library-only so that it can be read end to end and
|
|
173
|
+
verified by inspection before you trust it in a pipeline. Please do read it.
|
|
174
|
+
|
|
175
|
+
Licensed MIT.
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# copyleft-audit
|
|
2
|
+
|
|
3
|
+
**Fail your CI build when a dependency's licence breaks policy.**
|
|
4
|
+
Detects GPL, AGPL, SSPL, LGPL, MPL and friends in the packages actually
|
|
5
|
+
installed in your environment — and exits non-zero so the pipeline stops.
|
|
6
|
+
|
|
7
|
+
Zero runtime dependencies. Standard library only. It will not touch your
|
|
8
|
+
dependency tree, and it makes no network calls.
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
pip install copyleft-audit
|
|
12
|
+
copyleft-audit
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Why this exists
|
|
16
|
+
|
|
17
|
+
`pip-licenses` and similar tools *list* licences for a human to read. That is a
|
|
18
|
+
different job from *enforcing* a policy in CI. If you ship closed-source
|
|
19
|
+
software, the question is not "what licences are in here" but "does the build
|
|
20
|
+
stop when someone adds an AGPL package". This tool answers the second question:
|
|
21
|
+
one command, an exit code, no configuration file required.
|
|
22
|
+
|
|
23
|
+
Typical triggers for needing it: legal asks you to prove no copyleft code ships
|
|
24
|
+
in the product; an acquirer's due-diligence checklist asks the same; or you just
|
|
25
|
+
want the check to run automatically instead of once a year.
|
|
26
|
+
|
|
27
|
+
## Usage
|
|
28
|
+
|
|
29
|
+
```
|
|
30
|
+
copyleft-audit [--fail-on {strong,weak,unknown}] [--ignore PKG[,PKG...]]
|
|
31
|
+
[--format {text,json}] [--quiet]
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
| Exit code | Meaning |
|
|
35
|
+
|---|---|
|
|
36
|
+
| `0` | Clean — no policy violations |
|
|
37
|
+
| `1` | Policy violation |
|
|
38
|
+
| `2` | Usage or internal error |
|
|
39
|
+
|
|
40
|
+
GitHub Actions:
|
|
41
|
+
|
|
42
|
+
```yaml
|
|
43
|
+
- name: Licence gate
|
|
44
|
+
run: |
|
|
45
|
+
pip install copyleft-audit
|
|
46
|
+
copyleft-audit --fail-on strong
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Stricter, for a product that cannot even take weak copyleft or unresolvable
|
|
50
|
+
metadata:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
copyleft-audit --fail-on unknown --ignore mypackage,internal-sdk
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Machine-readable output, for feeding another tool:
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
copyleft-audit --format json
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
```json
|
|
63
|
+
{
|
|
64
|
+
"packages": [{"name": "...", "version": "...", "license": "...",
|
|
65
|
+
"license_source": "...", "tier": "...", "ignored": false}],
|
|
66
|
+
"violations": ["... same shape ..."],
|
|
67
|
+
"warnings": ["... same shape ..."],
|
|
68
|
+
"summary": {"tool_version": "...", "fail_on": "strong", "scanned": 0,
|
|
69
|
+
"unreadable": 0, "ignored": [], "counts": {},
|
|
70
|
+
"violation_count": 0, "warning_count": 0, "exit_code": 0}
|
|
71
|
+
}
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
The JSON schema is stable within the `0.1.x` line; new keys may be added, and
|
|
75
|
+
existing keys will not change meaning without a version bump.
|
|
76
|
+
|
|
77
|
+
## Risk tiers
|
|
78
|
+
|
|
79
|
+
| Tier | Licences | Default |
|
|
80
|
+
|---|---|---|
|
|
81
|
+
| `STRONG_COPYLEFT` | GPL-2.0, GPL-3.0, AGPL-3.0, SSPL, OSL, EUPL | **fail** |
|
|
82
|
+
| `WEAK_COPYLEFT` | LGPL-2.1, LGPL-3.0, MPL-2.0, EPL-2.0, CDDL, CPL | warn |
|
|
83
|
+
| `PERMISSIVE` | MIT, BSD, Apache-2.0, ISC, PSF, Unlicense, CC0, Zlib, … | pass |
|
|
84
|
+
| `UNKNOWN` | no resolvable licence metadata | warn |
|
|
85
|
+
|
|
86
|
+
`--fail-on weak` promotes weak copyleft to a failure. `--fail-on unknown`
|
|
87
|
+
promotes both weak copyleft and unknown metadata to a failure.
|
|
88
|
+
|
|
89
|
+
## How it decides, precisely
|
|
90
|
+
|
|
91
|
+
Licence metadata is read in this order, and the first source that resolves wins:
|
|
92
|
+
`License-Expression` (PEP 639) → `License ::` trove classifiers → the free-text
|
|
93
|
+
`License` field. The source used is reported in `license_source`, so you can see
|
|
94
|
+
what the verdict rests on.
|
|
95
|
+
|
|
96
|
+
Deliberate design choices worth knowing about:
|
|
97
|
+
|
|
98
|
+
- **`UNKNOWN` is a real answer.** When metadata is missing, empty, or not
|
|
99
|
+
recognised, the tool says `UNKNOWN`. It never guesses a tier. A confident
|
|
100
|
+
wrong answer here would be worse than no answer.
|
|
101
|
+
- **A package whose metadata cannot be parsed is reported as `UNKNOWN`, never
|
|
102
|
+
skipped.** Silently dropping a package would produce a false clean report.
|
|
103
|
+
- **`LGPL` and `AGPL` are never read as `GPL`.** Matching is on whole tokens and
|
|
104
|
+
masked phrases, not substrings — the classic mis-tiering bug in naive
|
|
105
|
+
scanners.
|
|
106
|
+
- **`MIT OR GPL-3.0` resolves to permissive**, because you may take the MIT
|
|
107
|
+
option. `AND` resolves to the stricter operand. Parenthesised SPDX expressions
|
|
108
|
+
are not parsed and are scored conservatively (highest risk wins).
|
|
109
|
+
- **Multiple licence classifiers are read as a choice**, so the lowest-risk one
|
|
110
|
+
governs — but every licence string found is printed, so nothing is hidden.
|
|
111
|
+
|
|
112
|
+
## Limits — please read before trusting it
|
|
113
|
+
|
|
114
|
+
- It reads **declared package metadata only**. It does not scan source files,
|
|
115
|
+
vendored code, bundled binaries, or transitively linked native libraries. A
|
|
116
|
+
package that declares MIT while vendoring GPL code will read as MIT.
|
|
117
|
+
- It classifies what is **installed in the current environment**, so run it in
|
|
118
|
+
the same environment your build produces.
|
|
119
|
+
- **This is not legal advice**, and the output is not a legal opinion. It is a
|
|
120
|
+
metadata heuristic that helps engineers catch the obvious cases early. Real
|
|
121
|
+
licence compliance decisions belong with your counsel.
|
|
122
|
+
|
|
123
|
+
## Paid: auditor-ready attestation report
|
|
124
|
+
|
|
125
|
+
The tool is free and stays free — nothing is gated behind payment.
|
|
126
|
+
|
|
127
|
+
If you need the output as a **document you can hand to an acquirer, an auditor,
|
|
128
|
+
or your own legal team**, there is a one-time paid report for a single project:
|
|
129
|
+
every installed dependency with its declared licence, the metadata field that
|
|
130
|
+
licence was read from, its risk tier, and an explicit list of everything that
|
|
131
|
+
came back `UNKNOWN` with what is missing. Delivered as PDF plus the raw JSON.
|
|
132
|
+
$149, one project, one-time.
|
|
133
|
+
|
|
134
|
+
→ **[Order the attestation report](https://buy.stripe.com/3cI8wP9libJt5Xrdd7a7C08)**
|
|
135
|
+
|
|
136
|
+
Same honesty applies: the report documents declared metadata, and it is not a
|
|
137
|
+
legal opinion.
|
|
138
|
+
|
|
139
|
+
## Disclosure
|
|
140
|
+
|
|
141
|
+
This package was written by an AI agent (Claude) operating autonomously, and is
|
|
142
|
+
published and maintained under human ownership. It is deliberately small,
|
|
143
|
+
dependency-free and standard-library-only so that it can be read end to end and
|
|
144
|
+
verified by inspection before you trust it in a pipeline. Please do read it.
|
|
145
|
+
|
|
146
|
+
Licensed MIT.
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""copyleft-audit: fail a CI build when a dependency's licence breaks policy.
|
|
2
|
+
|
|
3
|
+
Zero runtime dependencies, standard library only. This is a metadata-based
|
|
4
|
+
heuristic aid for engineers, not legal advice.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
__version__ = "0.1.0"
|
|
10
|
+
|
|
11
|
+
from .classify import ( # noqa: F401
|
|
12
|
+
PERMISSIVE,
|
|
13
|
+
STRONG_COPYLEFT,
|
|
14
|
+
TIER_RISK,
|
|
15
|
+
UNKNOWN,
|
|
16
|
+
WEAK_COPYLEFT,
|
|
17
|
+
classify_metadata,
|
|
18
|
+
classify_text,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"__version__",
|
|
23
|
+
"STRONG_COPYLEFT",
|
|
24
|
+
"WEAK_COPYLEFT",
|
|
25
|
+
"PERMISSIVE",
|
|
26
|
+
"UNKNOWN",
|
|
27
|
+
"TIER_RISK",
|
|
28
|
+
"classify_text",
|
|
29
|
+
"classify_metadata",
|
|
30
|
+
]
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
"""Classify a licence string into a copyleft risk tier.
|
|
2
|
+
|
|
3
|
+
Pure standard library. This module performs no I/O of any kind: no network
|
|
4
|
+
access, no filesystem access, no subprocesses. It is a *metadata heuristic*
|
|
5
|
+
and not a legal determination.
|
|
6
|
+
|
|
7
|
+
Design rules, in order of importance:
|
|
8
|
+
|
|
9
|
+
1. Never guess. If a licence string is absent, empty, or not recognised, the
|
|
10
|
+
answer is ``UNKNOWN``. A confident wrong answer is worse than no answer.
|
|
11
|
+
2. Be conservative inside a single licence name: if several markers appear in
|
|
12
|
+
one string, the highest-risk one wins.
|
|
13
|
+
3. Treat an SPDX ``OR`` as a genuine choice: ``MIT OR GPL-3.0`` resolves to the
|
|
14
|
+
lowest-risk operand, because the user may take that operand. ``AND``
|
|
15
|
+
resolves to the highest-risk operand.
|
|
16
|
+
4. Never rely on substring matching for ``GPL``. ``AGPL`` and ``LGPL`` both
|
|
17
|
+
contain the substring ``GPL`` and mis-tiering them is the classic failure
|
|
18
|
+
of naive licence scanners. Matching here is on whole tokens and on masked
|
|
19
|
+
phrases, so ``LGPL`` can never be read as ``GPL``.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import re
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"STRONG_COPYLEFT",
|
|
28
|
+
"WEAK_COPYLEFT",
|
|
29
|
+
"PERMISSIVE",
|
|
30
|
+
"UNKNOWN",
|
|
31
|
+
"TIER_RISK",
|
|
32
|
+
"classify_text",
|
|
33
|
+
"classify_metadata",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
STRONG_COPYLEFT = "STRONG_COPYLEFT"
|
|
37
|
+
WEAK_COPYLEFT = "WEAK_COPYLEFT"
|
|
38
|
+
PERMISSIVE = "PERMISSIVE"
|
|
39
|
+
UNKNOWN = "UNKNOWN"
|
|
40
|
+
|
|
41
|
+
#: Ordering used by min()/max(). UNKNOWN sits between permissive and weak so
|
|
42
|
+
#: that it sorts sensibly, but it is excluded from every comparison that
|
|
43
|
+
#: resolves an expression -- see _known().
|
|
44
|
+
TIER_RISK = {PERMISSIVE: 0, UNKNOWN: 1, WEAK_COPYLEFT: 2, STRONG_COPYLEFT: 3}
|
|
45
|
+
|
|
46
|
+
#: Some projects paste an entire licence text into the free-text ``License``
|
|
47
|
+
#: metadata field. Only the head of such a blob is classified, because the body
|
|
48
|
+
#: of the LGPL quotes the GPL repeatedly and would otherwise mis-tier.
|
|
49
|
+
MAX_TEXT = 300
|
|
50
|
+
|
|
51
|
+
# Phrases are matched most-specific-first and masked out once matched, so that
|
|
52
|
+
# "Lesser General Public License" can never also fire "General Public License".
|
|
53
|
+
_PHRASES = (
|
|
54
|
+
("AFFERO GENERAL PUBLIC LICENSE", STRONG_COPYLEFT),
|
|
55
|
+
("SERVER SIDE PUBLIC LICENSE", STRONG_COPYLEFT),
|
|
56
|
+
("OPEN SOFTWARE LICENSE", STRONG_COPYLEFT),
|
|
57
|
+
("EUROPEAN UNION PUBLIC LICENCE", STRONG_COPYLEFT),
|
|
58
|
+
("EUROPEAN UNION PUBLIC LICENSE", STRONG_COPYLEFT),
|
|
59
|
+
("LESSER GENERAL PUBLIC LICENSE", WEAK_COPYLEFT),
|
|
60
|
+
("LIBRARY GENERAL PUBLIC LICENSE", WEAK_COPYLEFT),
|
|
61
|
+
("GENERAL PUBLIC LICENSE", STRONG_COPYLEFT),
|
|
62
|
+
("MOZILLA PUBLIC LICENSE", WEAK_COPYLEFT),
|
|
63
|
+
("ECLIPSE PUBLIC LICENSE", WEAK_COPYLEFT),
|
|
64
|
+
("COMMON DEVELOPMENT AND DISTRIBUTION LICENSE", WEAK_COPYLEFT),
|
|
65
|
+
("COMMON PUBLIC LICENSE", WEAK_COPYLEFT),
|
|
66
|
+
("PYTHON SOFTWARE FOUNDATION LICENSE", PERMISSIVE),
|
|
67
|
+
("APACHE SOFTWARE LICENSE", PERMISSIVE),
|
|
68
|
+
("BOOST SOFTWARE LICENSE", PERMISSIVE),
|
|
69
|
+
("UNIVERSAL PERMISSIVE LICENSE", PERMISSIVE),
|
|
70
|
+
("HISTORICAL PERMISSION NOTICE", PERMISSIVE),
|
|
71
|
+
("ZOPE PUBLIC LICENSE", PERMISSIVE),
|
|
72
|
+
("ACADEMIC FREE LICENSE", PERMISSIVE),
|
|
73
|
+
("PUBLIC DOMAIN", PERMISSIVE),
|
|
74
|
+
("BSD LICENSE", PERMISSIVE),
|
|
75
|
+
("MIT LICENSE", PERMISSIVE),
|
|
76
|
+
("MIT NO ATTRIBUTION", PERMISSIVE),
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
# Whole-token markers, applied after digits are stripped and a trailing "v"
|
|
80
|
+
# (as in GPLv3 -> GPLV -> GPL) is removed.
|
|
81
|
+
_WORDS = {
|
|
82
|
+
"AGPL": STRONG_COPYLEFT,
|
|
83
|
+
"SSPL": STRONG_COPYLEFT,
|
|
84
|
+
"OSL": STRONG_COPYLEFT,
|
|
85
|
+
"EUPL": STRONG_COPYLEFT,
|
|
86
|
+
"LGPL": WEAK_COPYLEFT,
|
|
87
|
+
"GPL": STRONG_COPYLEFT,
|
|
88
|
+
"MPL": WEAK_COPYLEFT,
|
|
89
|
+
"EPL": WEAK_COPYLEFT,
|
|
90
|
+
"CDDL": WEAK_COPYLEFT,
|
|
91
|
+
"CPL": WEAK_COPYLEFT,
|
|
92
|
+
"MIT": PERMISSIVE,
|
|
93
|
+
"BSD": PERMISSIVE,
|
|
94
|
+
"APACHE": PERMISSIVE,
|
|
95
|
+
"ISC": PERMISSIVE,
|
|
96
|
+
"PSF": PERMISSIVE,
|
|
97
|
+
"NCSA": PERMISSIVE,
|
|
98
|
+
"AFL": PERMISSIVE,
|
|
99
|
+
"ZLIB": PERMISSIVE,
|
|
100
|
+
"UNLICENSE": PERMISSIVE,
|
|
101
|
+
"WTFPL": PERMISSIVE,
|
|
102
|
+
"POSTGRESQL": PERMISSIVE,
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
# Tokens whose digits are meaningful and must not be stripped. "CC" on its own
|
|
106
|
+
# is deliberately absent: CC0 is a public-domain dedication, but CC-BY-NC is
|
|
107
|
+
# not a permissive software licence, so anything else Creative Commons stays
|
|
108
|
+
# UNKNOWN rather than being guessed at.
|
|
109
|
+
_EXACT_TOKENS = {"CC0": PERMISSIVE}
|
|
110
|
+
|
|
111
|
+
_OR_RE = re.compile(r"\s+OR\s+", re.IGNORECASE)
|
|
112
|
+
_AND_RE = re.compile(r"\s+AND\s+", re.IGNORECASE)
|
|
113
|
+
_WITH_RE = re.compile(r"\s+WITH\s+.*$", re.IGNORECASE | re.DOTALL)
|
|
114
|
+
_TOKEN_RE = re.compile(r"[A-Z0-9]+")
|
|
115
|
+
_NON_ALPHA_RE = re.compile(r"[^A-Z]+")
|
|
116
|
+
_DIGIT_RE = re.compile(r"[0-9]")
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _known(tiers):
|
|
120
|
+
"""Drop UNKNOWN, which must never win a min()/max() against a real tier."""
|
|
121
|
+
return [t for t in tiers if t != UNKNOWN]
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _norm_token(token):
|
|
125
|
+
"""GPLV3 -> GPL, 0BSD -> BSD, LGPL-2.1 -> LGPL."""
|
|
126
|
+
stripped = _DIGIT_RE.sub("", token)
|
|
127
|
+
if stripped.endswith("V"):
|
|
128
|
+
stripped = stripped[:-1]
|
|
129
|
+
return stripped
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _atom_tier(text):
|
|
133
|
+
"""Highest-risk tier detectable within a single licence name.
|
|
134
|
+
|
|
135
|
+
Conservative on purpose: within one name, any strong-copyleft marker wins.
|
|
136
|
+
"""
|
|
137
|
+
upper = text.upper()
|
|
138
|
+
found = []
|
|
139
|
+
|
|
140
|
+
for token in _TOKEN_RE.findall(upper):
|
|
141
|
+
if token in _EXACT_TOKENS:
|
|
142
|
+
found.append(_EXACT_TOKENS[token])
|
|
143
|
+
|
|
144
|
+
norm = " " + " ".join(_NON_ALPHA_RE.sub(" ", upper).split()) + " "
|
|
145
|
+
for phrase, tier in _PHRASES:
|
|
146
|
+
padded = " " + phrase + " "
|
|
147
|
+
if padded in norm:
|
|
148
|
+
found.append(tier)
|
|
149
|
+
norm = norm.replace(padded, " ")
|
|
150
|
+
|
|
151
|
+
for token in norm.split():
|
|
152
|
+
word = _norm_token(token)
|
|
153
|
+
if word in _WORDS:
|
|
154
|
+
found.append(_WORDS[word])
|
|
155
|
+
|
|
156
|
+
known = _known(found)
|
|
157
|
+
if not known:
|
|
158
|
+
return UNKNOWN
|
|
159
|
+
return max(known, key=lambda t: TIER_RISK[t])
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _conjunction_tier(text):
|
|
163
|
+
"""Resolve an SPDX AND: the strictest operand governs."""
|
|
164
|
+
tiers = [_atom_tier(_WITH_RE.sub("", part)) for part in _AND_RE.split(text)]
|
|
165
|
+
known = _known(tiers)
|
|
166
|
+
if not known:
|
|
167
|
+
return UNKNOWN
|
|
168
|
+
return max(known, key=lambda t: TIER_RISK[t])
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def classify_text(text):
|
|
172
|
+
"""Classify one licence string. Returns a tier constant.
|
|
173
|
+
|
|
174
|
+
Handles whitespace-delimited SPDX ``OR`` / ``AND`` operators. Parenthesised
|
|
175
|
+
expressions are not parsed -- SPDX grouping is out of scope for a
|
|
176
|
+
dependency-free heuristic -- so a string containing parentheses is scored
|
|
177
|
+
conservatively as a single atom (highest risk wins). This is also what
|
|
178
|
+
makes PyPI trove classifiers such as
|
|
179
|
+
``GNU General Public License v3 (GPLv3)`` behave correctly.
|
|
180
|
+
"""
|
|
181
|
+
if not text:
|
|
182
|
+
return UNKNOWN
|
|
183
|
+
raw = text.strip()
|
|
184
|
+
if not raw:
|
|
185
|
+
return UNKNOWN
|
|
186
|
+
if len(raw) > MAX_TEXT:
|
|
187
|
+
raw = raw[:MAX_TEXT]
|
|
188
|
+
if "(" in raw or ")" in raw:
|
|
189
|
+
return _atom_tier(raw)
|
|
190
|
+
tiers = [_conjunction_tier(part) for part in _OR_RE.split(raw)]
|
|
191
|
+
known = _known(tiers)
|
|
192
|
+
if not known:
|
|
193
|
+
return UNKNOWN
|
|
194
|
+
return min(known, key=lambda t: TIER_RISK[t])
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _classifier_tail(classifier):
|
|
198
|
+
"""'License :: OSI Approved :: MIT License' -> 'MIT License'."""
|
|
199
|
+
parts = [p.strip() for p in classifier.split("::")]
|
|
200
|
+
return parts[-1] if parts else ""
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _shorten(text, limit=120):
|
|
204
|
+
flat = " ".join((text or "").split())
|
|
205
|
+
if len(flat) <= limit:
|
|
206
|
+
return flat
|
|
207
|
+
return flat[: limit - 3] + "..."
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def classify_metadata(expression=None, classifiers=None, license_field=None):
|
|
211
|
+
"""Classify one distribution from its metadata fields.
|
|
212
|
+
|
|
213
|
+
Sources are consulted in the order defined by PEP 639 practice:
|
|
214
|
+
``License-Expression`` first, then ``License ::`` trove classifiers, then
|
|
215
|
+
the free-text ``License`` field. The first source that yields anything
|
|
216
|
+
other than UNKNOWN wins.
|
|
217
|
+
|
|
218
|
+
Several licence classifiers on one distribution are read as a disjunction
|
|
219
|
+
(the project offers a choice), so the lowest-risk one governs -- but every
|
|
220
|
+
licence string found is returned for display, so nothing is hidden from
|
|
221
|
+
the reader.
|
|
222
|
+
|
|
223
|
+
Returns ``(tier, licence_string, source)``.
|
|
224
|
+
"""
|
|
225
|
+
if expression and expression.strip():
|
|
226
|
+
tier = classify_text(expression)
|
|
227
|
+
if tier != UNKNOWN:
|
|
228
|
+
return tier, _shorten(expression), "License-Expression"
|
|
229
|
+
|
|
230
|
+
licence_classifiers = [
|
|
231
|
+
c for c in (classifiers or [])
|
|
232
|
+
if c and c.strip().upper().startswith("LICENSE ::")
|
|
233
|
+
]
|
|
234
|
+
if licence_classifiers:
|
|
235
|
+
tails = [_classifier_tail(c) for c in licence_classifiers]
|
|
236
|
+
tiers = [classify_text(t) for t in tails]
|
|
237
|
+
known = _known(tiers)
|
|
238
|
+
if known:
|
|
239
|
+
tier = min(known, key=lambda t: TIER_RISK[t])
|
|
240
|
+
return tier, _shorten("; ".join(tails)), "Classifier"
|
|
241
|
+
|
|
242
|
+
if license_field and license_field.strip():
|
|
243
|
+
tier = classify_text(license_field)
|
|
244
|
+
if tier != UNKNOWN:
|
|
245
|
+
return tier, _shorten(license_field), "License"
|
|
246
|
+
|
|
247
|
+
# Nothing conclusive. Show whatever was declared so a human can judge.
|
|
248
|
+
if licence_classifiers:
|
|
249
|
+
shown = _shorten("; ".join(_classifier_tail(c) for c in licence_classifiers))
|
|
250
|
+
elif license_field and license_field.strip():
|
|
251
|
+
shown = _shorten(license_field)
|
|
252
|
+
elif expression and expression.strip():
|
|
253
|
+
shown = _shorten(expression)
|
|
254
|
+
else:
|
|
255
|
+
shown = ""
|
|
256
|
+
return UNKNOWN, shown, "none"
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
"""Command-line interface for copyleft-audit.
|
|
2
|
+
|
|
3
|
+
Reads the licence metadata of the distributions installed in the current
|
|
4
|
+
environment and exits non-zero when one of them breaks the configured policy.
|
|
5
|
+
Intended to be dropped into a CI job. Standard library only.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import json
|
|
12
|
+
import re
|
|
13
|
+
import sys
|
|
14
|
+
|
|
15
|
+
from . import __version__
|
|
16
|
+
from .classify import (
|
|
17
|
+
PERMISSIVE,
|
|
18
|
+
STRONG_COPYLEFT,
|
|
19
|
+
UNKNOWN,
|
|
20
|
+
WEAK_COPYLEFT,
|
|
21
|
+
classify_metadata,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
try: # Python 3.8+
|
|
25
|
+
from importlib import metadata as importlib_metadata
|
|
26
|
+
except ImportError: # pragma: no cover
|
|
27
|
+
importlib_metadata = None
|
|
28
|
+
|
|
29
|
+
EXIT_OK = 0
|
|
30
|
+
EXIT_VIOLATION = 1
|
|
31
|
+
EXIT_ERROR = 2
|
|
32
|
+
|
|
33
|
+
FAIL_ON_TIERS = {
|
|
34
|
+
"strong": (STRONG_COPYLEFT,),
|
|
35
|
+
"weak": (STRONG_COPYLEFT, WEAK_COPYLEFT),
|
|
36
|
+
"unknown": (STRONG_COPYLEFT, WEAK_COPYLEFT, UNKNOWN),
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
TIER_ORDER_FOR_DISPLAY = (STRONG_COPYLEFT, WEAK_COPYLEFT, UNKNOWN, PERMISSIVE)
|
|
40
|
+
|
|
41
|
+
_NAME_NORM_RE = re.compile(r"[-_.]+")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def normalize_name(name):
|
|
45
|
+
"""PEP 503 name normalisation."""
|
|
46
|
+
return _NAME_NORM_RE.sub("-", (name or "").strip()).lower()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _get(metadata, key):
|
|
50
|
+
try:
|
|
51
|
+
return metadata.get(key)
|
|
52
|
+
except Exception:
|
|
53
|
+
return None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _get_all(metadata, key):
|
|
57
|
+
try:
|
|
58
|
+
return metadata.get_all(key) or []
|
|
59
|
+
except Exception:
|
|
60
|
+
return []
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def collect_packages():
|
|
64
|
+
"""Return (packages, unreadable_count) for the current environment.
|
|
65
|
+
|
|
66
|
+
A distribution whose metadata cannot be parsed is reported as UNKNOWN
|
|
67
|
+
rather than skipped. Silently dropping a package would produce a false
|
|
68
|
+
clean report, which is the single worst outcome for a tool like this.
|
|
69
|
+
"""
|
|
70
|
+
if importlib_metadata is None:
|
|
71
|
+
raise RuntimeError("importlib.metadata is unavailable; Python 3.8+ is required")
|
|
72
|
+
|
|
73
|
+
seen = {}
|
|
74
|
+
unreadable = 0
|
|
75
|
+
for dist in importlib_metadata.distributions():
|
|
76
|
+
try:
|
|
77
|
+
metadata = dist.metadata
|
|
78
|
+
name = _get(metadata, "Name") if metadata is not None else None
|
|
79
|
+
except Exception:
|
|
80
|
+
unreadable += 1
|
|
81
|
+
continue
|
|
82
|
+
if not name:
|
|
83
|
+
unreadable += 1
|
|
84
|
+
continue
|
|
85
|
+
key = normalize_name(name)
|
|
86
|
+
if key in seen:
|
|
87
|
+
continue
|
|
88
|
+
try:
|
|
89
|
+
tier, licence, source = classify_metadata(
|
|
90
|
+
expression=_get(metadata, "License-Expression"),
|
|
91
|
+
classifiers=_get_all(metadata, "Classifier"),
|
|
92
|
+
license_field=_get(metadata, "License"),
|
|
93
|
+
)
|
|
94
|
+
version = _get(metadata, "Version") or ""
|
|
95
|
+
except Exception:
|
|
96
|
+
tier, licence, source, version = UNKNOWN, "", "unreadable", ""
|
|
97
|
+
unreadable += 1
|
|
98
|
+
seen[key] = {
|
|
99
|
+
"name": name,
|
|
100
|
+
"version": version,
|
|
101
|
+
"license": licence,
|
|
102
|
+
"license_source": source,
|
|
103
|
+
"tier": tier,
|
|
104
|
+
"ignored": False,
|
|
105
|
+
}
|
|
106
|
+
return [seen[k] for k in sorted(seen)], unreadable
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def build_parser():
|
|
110
|
+
parser = argparse.ArgumentParser(
|
|
111
|
+
prog="copyleft-audit",
|
|
112
|
+
description=(
|
|
113
|
+
"Fail the build when an installed dependency's licence breaks policy. "
|
|
114
|
+
"Reads declared package metadata only. This is an engineering "
|
|
115
|
+
"heuristic, not legal advice."
|
|
116
|
+
),
|
|
117
|
+
epilog="Exit codes: 0 clean, 1 policy violation, 2 usage or internal error.",
|
|
118
|
+
)
|
|
119
|
+
parser.add_argument(
|
|
120
|
+
"--fail-on",
|
|
121
|
+
choices=("strong", "weak", "unknown"),
|
|
122
|
+
default="strong",
|
|
123
|
+
help=(
|
|
124
|
+
"Lowest tier that fails the build. 'strong' (default) fails only on "
|
|
125
|
+
"strong copyleft; 'weak' also fails on weak copyleft; 'unknown' also "
|
|
126
|
+
"fails on packages with no resolvable licence metadata."
|
|
127
|
+
),
|
|
128
|
+
)
|
|
129
|
+
parser.add_argument(
|
|
130
|
+
"--ignore",
|
|
131
|
+
default="",
|
|
132
|
+
metavar="PKG[,PKG...]",
|
|
133
|
+
help="Comma-separated distribution names to exempt from the policy check.",
|
|
134
|
+
)
|
|
135
|
+
parser.add_argument(
|
|
136
|
+
"--format",
|
|
137
|
+
dest="fmt",
|
|
138
|
+
choices=("text", "json"),
|
|
139
|
+
default="text",
|
|
140
|
+
help="Output format. 'json' emits a stable machine-readable schema.",
|
|
141
|
+
)
|
|
142
|
+
parser.add_argument(
|
|
143
|
+
"--quiet",
|
|
144
|
+
action="store_true",
|
|
145
|
+
help="Print only violations. A clean run prints nothing.",
|
|
146
|
+
)
|
|
147
|
+
parser.add_argument(
|
|
148
|
+
"--version",
|
|
149
|
+
action="version",
|
|
150
|
+
version="copyleft-audit " + __version__,
|
|
151
|
+
)
|
|
152
|
+
return parser
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _render_text(stream, packages, violations, warnings, summary, quiet):
|
|
156
|
+
if not quiet:
|
|
157
|
+
stream.write(
|
|
158
|
+
"copyleft-audit %s | policy: fail on %s | %d distributions scanned\n"
|
|
159
|
+
% (__version__, summary["fail_on"], summary["scanned"])
|
|
160
|
+
)
|
|
161
|
+
counts = summary["counts"]
|
|
162
|
+
stream.write(
|
|
163
|
+
" strong=%d weak=%d unknown=%d permissive=%d\n"
|
|
164
|
+
% (
|
|
165
|
+
counts[STRONG_COPYLEFT],
|
|
166
|
+
counts[WEAK_COPYLEFT],
|
|
167
|
+
counts[UNKNOWN],
|
|
168
|
+
counts[PERMISSIVE],
|
|
169
|
+
)
|
|
170
|
+
)
|
|
171
|
+
if summary["unreadable"]:
|
|
172
|
+
stream.write(
|
|
173
|
+
" note: %d distribution(s) had unreadable metadata and are "
|
|
174
|
+
"reported as UNKNOWN\n" % summary["unreadable"]
|
|
175
|
+
)
|
|
176
|
+
stream.write("\n")
|
|
177
|
+
|
|
178
|
+
if violations:
|
|
179
|
+
stream.write("VIOLATIONS (%d)\n" % len(violations))
|
|
180
|
+
for pkg in violations:
|
|
181
|
+
stream.write(
|
|
182
|
+
" %-28s %-12s %-16s %s\n"
|
|
183
|
+
% (
|
|
184
|
+
pkg["name"][:28],
|
|
185
|
+
pkg["version"][:12],
|
|
186
|
+
pkg["tier"],
|
|
187
|
+
pkg["license"] or "<no licence metadata>",
|
|
188
|
+
)
|
|
189
|
+
)
|
|
190
|
+
stream.write("\n")
|
|
191
|
+
|
|
192
|
+
if warnings and not quiet:
|
|
193
|
+
stream.write("WARNINGS, not failing under this policy (%d)\n" % len(warnings))
|
|
194
|
+
for pkg in warnings:
|
|
195
|
+
stream.write(
|
|
196
|
+
" %-28s %-12s %-16s %s\n"
|
|
197
|
+
% (
|
|
198
|
+
pkg["name"][:28],
|
|
199
|
+
pkg["version"][:12],
|
|
200
|
+
pkg["tier"],
|
|
201
|
+
pkg["license"] or "<no licence metadata>",
|
|
202
|
+
)
|
|
203
|
+
)
|
|
204
|
+
stream.write("\n")
|
|
205
|
+
|
|
206
|
+
if not quiet:
|
|
207
|
+
if violations:
|
|
208
|
+
stream.write("RESULT: FAIL - %d policy violation(s).\n" % len(violations))
|
|
209
|
+
else:
|
|
210
|
+
stream.write("RESULT: PASS - no policy violations.\n")
|
|
211
|
+
stream.write(
|
|
212
|
+
"Declared metadata only; no source scanning. Not legal advice.\n"
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def main(argv=None):
|
|
217
|
+
parser = build_parser()
|
|
218
|
+
try:
|
|
219
|
+
args = parser.parse_args(argv)
|
|
220
|
+
except SystemExit as exc: # --help / --version / usage error
|
|
221
|
+
code = exc.code
|
|
222
|
+
return code if isinstance(code, int) else EXIT_ERROR
|
|
223
|
+
|
|
224
|
+
ignored = set()
|
|
225
|
+
for item in args.ignore.split(","):
|
|
226
|
+
if item.strip():
|
|
227
|
+
ignored.add(normalize_name(item))
|
|
228
|
+
|
|
229
|
+
try:
|
|
230
|
+
packages, unreadable = collect_packages()
|
|
231
|
+
except Exception as exc: # pragma: no cover
|
|
232
|
+
sys.stderr.write("copyleft-audit: internal error: %s\n" % exc)
|
|
233
|
+
return EXIT_ERROR
|
|
234
|
+
|
|
235
|
+
fail_tiers = FAIL_ON_TIERS[args.fail_on]
|
|
236
|
+
counts = {STRONG_COPYLEFT: 0, WEAK_COPYLEFT: 0, UNKNOWN: 0, PERMISSIVE: 0}
|
|
237
|
+
violations = []
|
|
238
|
+
warnings = []
|
|
239
|
+
for pkg in packages:
|
|
240
|
+
counts[pkg["tier"]] = counts.get(pkg["tier"], 0) + 1
|
|
241
|
+
pkg["ignored"] = normalize_name(pkg["name"]) in ignored
|
|
242
|
+
if pkg["tier"] == PERMISSIVE:
|
|
243
|
+
continue
|
|
244
|
+
if pkg["ignored"]:
|
|
245
|
+
continue
|
|
246
|
+
if pkg["tier"] in fail_tiers:
|
|
247
|
+
violations.append(pkg)
|
|
248
|
+
else:
|
|
249
|
+
warnings.append(pkg)
|
|
250
|
+
|
|
251
|
+
def _sort_key(pkg):
|
|
252
|
+
return (TIER_ORDER_FOR_DISPLAY.index(pkg["tier"]), normalize_name(pkg["name"]))
|
|
253
|
+
|
|
254
|
+
violations.sort(key=_sort_key)
|
|
255
|
+
warnings.sort(key=_sort_key)
|
|
256
|
+
|
|
257
|
+
exit_code = EXIT_VIOLATION if violations else EXIT_OK
|
|
258
|
+
summary = {
|
|
259
|
+
"tool": "copyleft-audit",
|
|
260
|
+
"tool_version": __version__,
|
|
261
|
+
"fail_on": args.fail_on,
|
|
262
|
+
"scanned": len(packages),
|
|
263
|
+
"unreadable": unreadable,
|
|
264
|
+
"ignored": sorted(ignored),
|
|
265
|
+
"counts": counts,
|
|
266
|
+
"violation_count": len(violations),
|
|
267
|
+
"warning_count": len(warnings),
|
|
268
|
+
"exit_code": exit_code,
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if args.fmt == "json":
|
|
272
|
+
payload = {
|
|
273
|
+
"packages": packages,
|
|
274
|
+
"violations": violations,
|
|
275
|
+
"warnings": warnings,
|
|
276
|
+
"summary": summary,
|
|
277
|
+
}
|
|
278
|
+
sys.stdout.write(json.dumps(payload, indent=2, sort_keys=True) + "\n")
|
|
279
|
+
else:
|
|
280
|
+
_render_text(sys.stdout, packages, violations, warnings, summary, args.quiet)
|
|
281
|
+
|
|
282
|
+
return exit_code
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
if __name__ == "__main__": # pragma: no cover
|
|
286
|
+
sys.exit(main())
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: copyleft-audit
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Fail your CI build when a dependency's licence breaks policy. Detects GPL, AGPL, SSPL and LGPL in installed packages. Zero dependencies, stdlib only.
|
|
5
|
+
Project-URL: Homepage, https://pypi.org/project/copyleft-audit/
|
|
6
|
+
Project-URL: Changelog, https://pypi.org/project/copyleft-audit/#history
|
|
7
|
+
Keywords: copyleft,gpl,agpl,lgpl,sspl,license,licence,license-compliance,license-checker,open-source-compliance,sbom,ci,audit,due-diligence,dependencies
|
|
8
|
+
Classifier: Development Status :: 4 - Beta
|
|
9
|
+
Classifier: Environment :: Console
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Intended Audience :: Legal Industry
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
22
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
23
|
+
Classifier: Topic :: System :: Software Distribution
|
|
24
|
+
Classifier: Topic :: Utilities
|
|
25
|
+
Requires-Python: >=3.8
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
License-File: LICENSE
|
|
28
|
+
Dynamic: license-file
|
|
29
|
+
|
|
30
|
+
# copyleft-audit
|
|
31
|
+
|
|
32
|
+
**Fail your CI build when a dependency's licence breaks policy.**
|
|
33
|
+
Detects GPL, AGPL, SSPL, LGPL, MPL and friends in the packages actually
|
|
34
|
+
installed in your environment — and exits non-zero so the pipeline stops.
|
|
35
|
+
|
|
36
|
+
Zero runtime dependencies. Standard library only. It will not touch your
|
|
37
|
+
dependency tree, and it makes no network calls.
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
pip install copyleft-audit
|
|
41
|
+
copyleft-audit
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Why this exists
|
|
45
|
+
|
|
46
|
+
`pip-licenses` and similar tools *list* licences for a human to read. That is a
|
|
47
|
+
different job from *enforcing* a policy in CI. If you ship closed-source
|
|
48
|
+
software, the question is not "what licences are in here" but "does the build
|
|
49
|
+
stop when someone adds an AGPL package". This tool answers the second question:
|
|
50
|
+
one command, an exit code, no configuration file required.
|
|
51
|
+
|
|
52
|
+
Typical triggers for needing it: legal asks you to prove no copyleft code ships
|
|
53
|
+
in the product; an acquirer's due-diligence checklist asks the same; or you just
|
|
54
|
+
want the check to run automatically instead of once a year.
|
|
55
|
+
|
|
56
|
+
## Usage
|
|
57
|
+
|
|
58
|
+
```
|
|
59
|
+
copyleft-audit [--fail-on {strong,weak,unknown}] [--ignore PKG[,PKG...]]
|
|
60
|
+
[--format {text,json}] [--quiet]
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
| Exit code | Meaning |
|
|
64
|
+
|---|---|
|
|
65
|
+
| `0` | Clean — no policy violations |
|
|
66
|
+
| `1` | Policy violation |
|
|
67
|
+
| `2` | Usage or internal error |
|
|
68
|
+
|
|
69
|
+
GitHub Actions:
|
|
70
|
+
|
|
71
|
+
```yaml
|
|
72
|
+
- name: Licence gate
|
|
73
|
+
run: |
|
|
74
|
+
pip install copyleft-audit
|
|
75
|
+
copyleft-audit --fail-on strong
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Stricter, for a product that cannot even take weak copyleft or unresolvable
|
|
79
|
+
metadata:
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
copyleft-audit --fail-on unknown --ignore mypackage,internal-sdk
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Machine-readable output, for feeding another tool:
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
copyleft-audit --format json
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
```json
|
|
92
|
+
{
|
|
93
|
+
"packages": [{"name": "...", "version": "...", "license": "...",
|
|
94
|
+
"license_source": "...", "tier": "...", "ignored": false}],
|
|
95
|
+
"violations": ["... same shape ..."],
|
|
96
|
+
"warnings": ["... same shape ..."],
|
|
97
|
+
"summary": {"tool_version": "...", "fail_on": "strong", "scanned": 0,
|
|
98
|
+
"unreadable": 0, "ignored": [], "counts": {},
|
|
99
|
+
"violation_count": 0, "warning_count": 0, "exit_code": 0}
|
|
100
|
+
}
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
The JSON schema is stable within the `0.1.x` line; new keys may be added, and
|
|
104
|
+
existing keys will not change meaning without a version bump.
|
|
105
|
+
|
|
106
|
+
## Risk tiers
|
|
107
|
+
|
|
108
|
+
| Tier | Licences | Default |
|
|
109
|
+
|---|---|---|
|
|
110
|
+
| `STRONG_COPYLEFT` | GPL-2.0, GPL-3.0, AGPL-3.0, SSPL, OSL, EUPL | **fail** |
|
|
111
|
+
| `WEAK_COPYLEFT` | LGPL-2.1, LGPL-3.0, MPL-2.0, EPL-2.0, CDDL, CPL | warn |
|
|
112
|
+
| `PERMISSIVE` | MIT, BSD, Apache-2.0, ISC, PSF, Unlicense, CC0, Zlib, … | pass |
|
|
113
|
+
| `UNKNOWN` | no resolvable licence metadata | warn |
|
|
114
|
+
|
|
115
|
+
`--fail-on weak` promotes weak copyleft to a failure. `--fail-on unknown`
|
|
116
|
+
promotes both weak copyleft and unknown metadata to a failure.
|
|
117
|
+
|
|
118
|
+
## How it decides, precisely
|
|
119
|
+
|
|
120
|
+
Licence metadata is read in this order, and the first source that resolves wins:
|
|
121
|
+
`License-Expression` (PEP 639) → `License ::` trove classifiers → the free-text
|
|
122
|
+
`License` field. The source used is reported in `license_source`, so you can see
|
|
123
|
+
what the verdict rests on.
|
|
124
|
+
|
|
125
|
+
Deliberate design choices worth knowing about:
|
|
126
|
+
|
|
127
|
+
- **`UNKNOWN` is a real answer.** When metadata is missing, empty, or not
|
|
128
|
+
recognised, the tool says `UNKNOWN`. It never guesses a tier. A confident
|
|
129
|
+
wrong answer here would be worse than no answer.
|
|
130
|
+
- **A package whose metadata cannot be parsed is reported as `UNKNOWN`, never
|
|
131
|
+
skipped.** Silently dropping a package would produce a false clean report.
|
|
132
|
+
- **`LGPL` and `AGPL` are never read as `GPL`.** Matching is on whole tokens and
|
|
133
|
+
masked phrases, not substrings — the classic mis-tiering bug in naive
|
|
134
|
+
scanners.
|
|
135
|
+
- **`MIT OR GPL-3.0` resolves to permissive**, because you may take the MIT
|
|
136
|
+
option. `AND` resolves to the stricter operand. Parenthesised SPDX expressions
|
|
137
|
+
are not parsed and are scored conservatively (highest risk wins).
|
|
138
|
+
- **Multiple licence classifiers are read as a choice**, so the lowest-risk one
|
|
139
|
+
governs — but every licence string found is printed, so nothing is hidden.
|
|
140
|
+
|
|
141
|
+
## Limits — please read before trusting it
|
|
142
|
+
|
|
143
|
+
- It reads **declared package metadata only**. It does not scan source files,
|
|
144
|
+
vendored code, bundled binaries, or transitively linked native libraries. A
|
|
145
|
+
package that declares MIT while vendoring GPL code will read as MIT.
|
|
146
|
+
- It classifies what is **installed in the current environment**, so run it in
|
|
147
|
+
the same environment your build produces.
|
|
148
|
+
- **This is not legal advice**, and the output is not a legal opinion. It is a
|
|
149
|
+
metadata heuristic that helps engineers catch the obvious cases early. Real
|
|
150
|
+
licence compliance decisions belong with your counsel.
|
|
151
|
+
|
|
152
|
+
## Paid: auditor-ready attestation report
|
|
153
|
+
|
|
154
|
+
The tool is free and stays free — nothing is gated behind payment.
|
|
155
|
+
|
|
156
|
+
If you need the output as a **document you can hand to an acquirer, an auditor,
|
|
157
|
+
or your own legal team**, there is a one-time paid report for a single project:
|
|
158
|
+
every installed dependency with its declared licence, the metadata field that
|
|
159
|
+
licence was read from, its risk tier, and an explicit list of everything that
|
|
160
|
+
came back `UNKNOWN` with what is missing. Delivered as PDF plus the raw JSON.
|
|
161
|
+
$149, one project, one-time.
|
|
162
|
+
|
|
163
|
+
→ **[Order the attestation report](https://buy.stripe.com/3cI8wP9libJt5Xrdd7a7C08)**
|
|
164
|
+
|
|
165
|
+
Same honesty applies: the report documents declared metadata, and it is not a
|
|
166
|
+
legal opinion.
|
|
167
|
+
|
|
168
|
+
## Disclosure
|
|
169
|
+
|
|
170
|
+
This package was written by an AI agent (Claude) operating autonomously, and is
|
|
171
|
+
published and maintained under human ownership. It is deliberately small,
|
|
172
|
+
dependency-free and standard-library-only so that it can be read end to end and
|
|
173
|
+
verified by inspection before you trust it in a pipeline. Please do read it.
|
|
174
|
+
|
|
175
|
+
Licensed MIT.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
copyleft_audit/__init__.py
|
|
5
|
+
copyleft_audit/__main__.py
|
|
6
|
+
copyleft_audit/classify.py
|
|
7
|
+
copyleft_audit/cli.py
|
|
8
|
+
copyleft_audit.egg-info/PKG-INFO
|
|
9
|
+
copyleft_audit.egg-info/SOURCES.txt
|
|
10
|
+
copyleft_audit.egg-info/dependency_links.txt
|
|
11
|
+
copyleft_audit.egg-info/entry_points.txt
|
|
12
|
+
copyleft_audit.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
copyleft_audit
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "copyleft-audit"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Fail your CI build when a dependency's licence breaks policy. Detects GPL, AGPL, SSPL and LGPL in installed packages. Zero dependencies, stdlib only."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.8"
|
|
11
|
+
keywords = [
|
|
12
|
+
"copyleft",
|
|
13
|
+
"gpl",
|
|
14
|
+
"agpl",
|
|
15
|
+
"lgpl",
|
|
16
|
+
"sspl",
|
|
17
|
+
"license",
|
|
18
|
+
"licence",
|
|
19
|
+
"license-compliance",
|
|
20
|
+
"license-checker",
|
|
21
|
+
"open-source-compliance",
|
|
22
|
+
"sbom",
|
|
23
|
+
"ci",
|
|
24
|
+
"audit",
|
|
25
|
+
"due-diligence",
|
|
26
|
+
"dependencies",
|
|
27
|
+
]
|
|
28
|
+
classifiers = [
|
|
29
|
+
"Development Status :: 4 - Beta",
|
|
30
|
+
"Environment :: Console",
|
|
31
|
+
"Intended Audience :: Developers",
|
|
32
|
+
"Intended Audience :: Legal Industry",
|
|
33
|
+
"License :: OSI Approved :: MIT License",
|
|
34
|
+
"Operating System :: OS Independent",
|
|
35
|
+
"Programming Language :: Python :: 3",
|
|
36
|
+
"Programming Language :: Python :: 3.8",
|
|
37
|
+
"Programming Language :: Python :: 3.9",
|
|
38
|
+
"Programming Language :: Python :: 3.10",
|
|
39
|
+
"Programming Language :: Python :: 3.11",
|
|
40
|
+
"Programming Language :: Python :: 3.12",
|
|
41
|
+
"Programming Language :: Python :: 3.13",
|
|
42
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
43
|
+
"Topic :: Software Development :: Quality Assurance",
|
|
44
|
+
"Topic :: System :: Software Distribution",
|
|
45
|
+
"Topic :: Utilities",
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
[project.urls]
|
|
49
|
+
Homepage = "https://pypi.org/project/copyleft-audit/"
|
|
50
|
+
Changelog = "https://pypi.org/project/copyleft-audit/#history"
|
|
51
|
+
|
|
52
|
+
[project.scripts]
|
|
53
|
+
copyleft-audit = "copyleft_audit.cli:main"
|
|
54
|
+
|
|
55
|
+
[tool.setuptools]
|
|
56
|
+
packages = ["copyleft_audit"]
|