github-license-scanner 0.1.0__py3-none-any.whl
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.
- github_license_scanner-0.1.0.dist-info/METADATA +307 -0
- github_license_scanner-0.1.0.dist-info/RECORD +24 -0
- github_license_scanner-0.1.0.dist-info/WHEEL +4 -0
- github_license_scanner-0.1.0.dist-info/entry_points.txt +3 -0
- github_license_scanner-0.1.0.dist-info/licenses/LICENSE +21 -0
- gls/__init__.py +1 -0
- gls/auth.py +229 -0
- gls/cli.py +358 -0
- gls/config.py +161 -0
- gls/dependency_scanner.py +621 -0
- gls/deploy_advisor.py +286 -0
- gls/docs/LEGAL_DISCLAIMER.md +57 -0
- gls/docs/PRIVACY.md +69 -0
- gls/docs/TERMS.md +52 -0
- gls/github_api.py +514 -0
- gls/history_store.py +177 -0
- gls/i18n.py +530 -0
- gls/license_analyzer.py +855 -0
- gls/models.py +148 -0
- gls/rate_limit.py +64 -0
- gls/report.py +150 -0
- gls/sbom_export.py +312 -0
- gls/spdx_engine.py +441 -0
- gls/webui.py +1828 -0
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: github-license-scanner
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Analyze GitHub repo and dependency licenses, copyleft risk, and closed-source sellability.
|
|
5
|
+
Project-URL: Homepage, https://github.com/NezbiT/github-license-scanner
|
|
6
|
+
Project-URL: Issues, https://github.com/NezbiT/github-license-scanner/issues
|
|
7
|
+
Author: Mario Alvarez
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: agpl,compliance,copyleft,dependencies,gpl,license,sbom
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
18
|
+
Requires-Python: >=3.11
|
|
19
|
+
Requires-Dist: httpx>=0.27.0
|
|
20
|
+
Requires-Dist: packaging>=24.0
|
|
21
|
+
Requires-Dist: platformdirs>=4.0
|
|
22
|
+
Requires-Dist: python-dotenv>=1.0.0
|
|
23
|
+
Provides-Extra: ui
|
|
24
|
+
Requires-Dist: nicegui>=2.0.0; extra == 'ui'
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# GitHub License Scanner
|
|
28
|
+
|
|
29
|
+
<p align="center">
|
|
30
|
+
<img src="docs/images/hero.jpg" alt="GitHub License Scanner hero banner" width="100%" />
|
|
31
|
+
</p>
|
|
32
|
+
|
|
33
|
+
<p align="center">
|
|
34
|
+
<strong>Analyze GitHub repo licenses + dependencies</strong><br/>
|
|
35
|
+
Know if you can sell closed-source — or if copyleft (GPL / AGPL) forces open source.<br/>
|
|
36
|
+
<em>NiceGUI web UI · CLI · bilingual ES/EN · light & dark mode</em>
|
|
37
|
+
</p>
|
|
38
|
+
|
|
39
|
+
<p align="center">
|
|
40
|
+
<a href="#quick-start"><img src="https://img.shields.io/badge/Python-3.11%2B-2c3e50?style=flat-square" alt="Python" /></a>
|
|
41
|
+
<a href="#web-ui"><img src="https://img.shields.io/badge/UI-NiceGUI-8b5e3c?style=flat-square" alt="NiceGUI" /></a>
|
|
42
|
+
<a href="#cli"><img src="https://img.shields.io/badge/CLI-supported-3f6f4e?style=flat-square" alt="CLI" /></a>
|
|
43
|
+
<a href="#disclaimer"><img src="https://img.shields.io/badge/Not-legal%20advice-9b2c2c?style=flat-square" alt="Disclaimer" /></a>
|
|
44
|
+
<a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-2c3e50?style=flat-square" alt="MIT License" /></a>
|
|
45
|
+
</p>
|
|
46
|
+
|
|
47
|
+
---
|
|
48
|
+
|
|
49
|
+
## Screenshots
|
|
50
|
+
|
|
51
|
+
### Light workspace (full-width layout)
|
|
52
|
+
|
|
53
|
+
<p align="center">
|
|
54
|
+
<img src="docs/images/ui-light.jpg" alt="Light mode UI — scan workspace and results" width="100%" />
|
|
55
|
+
</p>
|
|
56
|
+
|
|
57
|
+
### Dark mode
|
|
58
|
+
|
|
59
|
+
<p align="center">
|
|
60
|
+
<img src="docs/images/ui-dark.jpg" alt="Dark mode UI — copyleft warning and package groups" width="100%" />
|
|
61
|
+
</p>
|
|
62
|
+
|
|
63
|
+
### How it works
|
|
64
|
+
|
|
65
|
+
<p align="center">
|
|
66
|
+
<img src="docs/images/flow.jpg" alt="Flow: URL → fetch → registries → verdict → deploy" width="100%" />
|
|
67
|
+
</p>
|
|
68
|
+
|
|
69
|
+
| Step | What happens |
|
|
70
|
+
|------|----------------|
|
|
71
|
+
| 1 | Paste a GitHub URL (`owner/repo`) |
|
|
72
|
+
| 2 | Read repo license + dependency manifests |
|
|
73
|
+
| 3 | Look up package licenses on npm, PyPI, crates.io, … |
|
|
74
|
+
| 4 | Verdict: closed sale OK vs strong copyleft |
|
|
75
|
+
| 5 | Deploy tips + copyright notice to copy |
|
|
76
|
+
|
|
77
|
+
---
|
|
78
|
+
|
|
79
|
+
## Features
|
|
80
|
+
|
|
81
|
+
- **Repo license** via GitHub REST API
|
|
82
|
+
- **Dependency scan** for `package.json`, `requirements.txt`, `pyproject.toml`, `Cargo.toml`, `go.mod`, `Gemfile`, `composer.json`, Maven/Gradle (best-effort)
|
|
83
|
+
- **Registry license lookup** (npm, PyPI, crates.io, RubyGems, Packagist)
|
|
84
|
+
- **Risk colors**: green (permissive) · orange (weak/unknown) · red (strong copyleft)
|
|
85
|
+
- **Closed-source sellability** signal + GPL/AGPL force-open flag
|
|
86
|
+
- **Permissive replacement** suggestions for problematic packages
|
|
87
|
+
- **Batch mode** (many URLs) + **scan history**
|
|
88
|
+
- **Copy copyright notice** button
|
|
89
|
+
- **Deploy advisor** (Vercel, Railway, Render, Fly.io, …)
|
|
90
|
+
- **ES / EN** UI · **light / dark** theme · **full-width** responsive layout
|
|
91
|
+
|
|
92
|
+
---
|
|
93
|
+
|
|
94
|
+
## Quick start
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
pipx install github-license-scanner
|
|
98
|
+
gls scan psf/requests
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
The web interface is an optional extra, so the CLI stays light:
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
pipx install 'github-license-scanner[ui]'
|
|
105
|
+
gls ui
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Optional configuration (recommended):
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
# PowerShell example
|
|
112
|
+
$env:GITHUB_TOKEN = "ghp_..."
|
|
113
|
+
$env:GLS_STORAGE_SECRET = "long-random-string"
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Environment variables are the source of truth. A `.env` file is only a local
|
|
117
|
+
convenience: one is read from the current directory or from your user config
|
|
118
|
+
directory if present, and real environment variables always win.
|
|
119
|
+
|
|
120
|
+
| Variable | Purpose |
|
|
121
|
+
|----------|---------|
|
|
122
|
+
| `GITHUB_TOKEN` | Higher GitHub API rate limits / private repos |
|
|
123
|
+
| `GLS_STORAGE_SECRET` | Signs session cookies (**required** for public deploys) |
|
|
124
|
+
| `GLS_HOST` / `GLS_PORT` | Bind address (default `127.0.0.1:8080`) |
|
|
125
|
+
| `GLS_MAX_BATCH_URLS` | Cap batch scans (default 15) |
|
|
126
|
+
| `GLS_RATE_LIMIT_SCANS` | Scans per window per client (default 20/hour) |
|
|
127
|
+
| `GLS_AUTH_ENABLED` | Require web login (`1` / `true`) |
|
|
128
|
+
| `GLS_USERS_FILE` | JSON users DB (default: `users.json` in the data dir) |
|
|
129
|
+
| `GLS_DATA_DIR` | Override where history and users are stored |
|
|
130
|
+
|
|
131
|
+
See [`.env.example`](.env.example) for the full list.
|
|
132
|
+
|
|
133
|
+
History and users live in your per-user data directory, never inside the
|
|
134
|
+
installed package:
|
|
135
|
+
|
|
136
|
+
| OS | Path |
|
|
137
|
+
|----|------|
|
|
138
|
+
| Windows | `%LOCALAPPDATA%\NezbiT\github-license-scanner` |
|
|
139
|
+
| macOS | `~/Library/Application Support/github-license-scanner` |
|
|
140
|
+
| Linux | `~/.local/share/github-license-scanner` |
|
|
141
|
+
|
|
142
|
+
### Multi-user auth + private history
|
|
143
|
+
|
|
144
|
+
```bash
|
|
145
|
+
gls user-add alice # interactive password (≥8 chars)
|
|
146
|
+
|
|
147
|
+
export GLS_AUTH_ENABLED=1
|
|
148
|
+
export GLS_STORAGE_SECRET=long-random-string
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
Each user gets `history/<username>.json` in that data directory. Without auth,
|
|
152
|
+
history stays in a single `history.json`.
|
|
153
|
+
|
|
154
|
+
---
|
|
155
|
+
|
|
156
|
+
## Web UI
|
|
157
|
+
|
|
158
|
+
```bash
|
|
159
|
+
pipx install 'github-license-scanner[ui]'
|
|
160
|
+
gls ui # or: gls ui --host 0.0.0.0 --port 9000
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
Open **[http://127.0.0.1:8080](http://127.0.0.1:8080)** (binds to localhost by default)
|
|
164
|
+
|
|
165
|
+
- Switch **ES | EN** in the top bar
|
|
166
|
+
- Toggle **light / dark** with the sun/moon button
|
|
167
|
+
- Try example chips (`psf/requests`, `encode/httpx`, …)
|
|
168
|
+
|
|
169
|
+
---
|
|
170
|
+
|
|
171
|
+
## CLI
|
|
172
|
+
|
|
173
|
+
```bash
|
|
174
|
+
# Single repository
|
|
175
|
+
gls scan https://github.com/psf/requests
|
|
176
|
+
|
|
177
|
+
# Shorthand
|
|
178
|
+
gls scan psf/requests
|
|
179
|
+
|
|
180
|
+
# Batch (one URL per line)
|
|
181
|
+
gls batch urls.example.txt
|
|
182
|
+
|
|
183
|
+
# History
|
|
184
|
+
gls history
|
|
185
|
+
|
|
186
|
+
# Markdown + SBOM export
|
|
187
|
+
gls scan psf/requests --markdown report.md --sbom bom.cdx.json
|
|
188
|
+
gls scan psf/requests --sbom bom.spdx.json --sbom-format spdx
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
`github-license-scanner` is available as a longer alias for `gls`.
|
|
192
|
+
|
|
193
|
+
| Exit code | Meaning |
|
|
194
|
+
|-----------|---------|
|
|
195
|
+
| `0` | No strong copyleft force-open signal |
|
|
196
|
+
| `1` | Strong copyleft detected |
|
|
197
|
+
| `2` | Hard failure (bad URL, API error, …) |
|
|
198
|
+
|
|
199
|
+
---
|
|
200
|
+
|
|
201
|
+
## Project layout
|
|
202
|
+
|
|
203
|
+
```text
|
|
204
|
+
github-license-scanner/
|
|
205
|
+
├── pyproject.toml # Packaging (hatchling)
|
|
206
|
+
├── gls/
|
|
207
|
+
│ ├── __init__.py # __version__
|
|
208
|
+
│ ├── cli.py # Command-line mode + entry point
|
|
209
|
+
│ ├── webui.py # NiceGUI interface (optional [ui] extra)
|
|
210
|
+
│ ├── config.py # Env-based configuration + data paths
|
|
211
|
+
│ ├── rate_limit.py # Scan rate limiter
|
|
212
|
+
│ ├── auth.py # Optional multi-user auth (PBKDF2)
|
|
213
|
+
│ ├── spdx_engine.py # SPDX expression parser + risk
|
|
214
|
+
│ ├── sbom_export.py # CycloneDX 1.5 + SPDX 2.3 JSON
|
|
215
|
+
│ ├── github_api.py # URL parse + GitHub REST
|
|
216
|
+
│ ├── dependency_scanner.py # Manifest parsers
|
|
217
|
+
│ ├── license_analyzer.py # Registry licenses + verdict
|
|
218
|
+
│ ├── deploy_advisor.py # Deploy recommendations
|
|
219
|
+
│ ├── history_store.py # JSON history (shared or per-user)
|
|
220
|
+
│ ├── models.py # Dataclasses
|
|
221
|
+
│ ├── i18n.py # ES/EN strings
|
|
222
|
+
│ ├── report.py # Markdown export
|
|
223
|
+
│ └── docs/ # Legal pages served by the web UI
|
|
224
|
+
│ ├── LEGAL_DISCLAIMER.md
|
|
225
|
+
│ ├── PRIVACY.md
|
|
226
|
+
│ └── TERMS.md
|
|
227
|
+
├── scripts/ # Dev smoke tests / audit tooling
|
|
228
|
+
├── .env.example
|
|
229
|
+
├── urls.example.txt
|
|
230
|
+
└── docs/
|
|
231
|
+
├── AUDIT_REPORT.pdf
|
|
232
|
+
└── images/ # README screenshots
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
---
|
|
236
|
+
|
|
237
|
+
## License risk legend
|
|
238
|
+
|
|
239
|
+
| Color | Risk | Examples |
|
|
240
|
+
|-------|------|----------|
|
|
241
|
+
| Green | Permissive | MIT, Apache-2.0, BSD, ISC |
|
|
242
|
+
| Orange | Weak copyleft / unknown | LGPL, MPL, EUPL, missing metadata |
|
|
243
|
+
| Red | Strong copyleft | GPL, AGPL, SSPL |
|
|
244
|
+
|
|
245
|
+
---
|
|
246
|
+
|
|
247
|
+
## Security & privacy notes
|
|
248
|
+
|
|
249
|
+
- Default bind is **localhost only** (`GLS_HOST=127.0.0.1`).
|
|
250
|
+
- Set a strong **`GLS_STORAGE_SECRET`** before exposing the UI.
|
|
251
|
+
- Scan history is **instance-local** and shared if multi-user — use **Clear history** or prune via config.
|
|
252
|
+
- Rate limits and batch caps reduce GitHub API abuse.
|
|
253
|
+
- Docs: [Privacy](gls/docs/PRIVACY.md) · [Terms](gls/docs/TERMS.md) · [Legal disclaimer](gls/docs/LEGAL_DISCLAIMER.md)
|
|
254
|
+
|
|
255
|
+
---
|
|
256
|
+
|
|
257
|
+
## Disclaimer
|
|
258
|
+
|
|
259
|
+
This tool provides **automated heuristics only**. It is **not legal advice** and not a
|
|
260
|
+
license-compatibility opinion. Dual-licensing, linking models, SaaS (AGPL/SSPL),
|
|
261
|
+
attribution duties, and contracts can change obligations.
|
|
262
|
+
Always review with a qualified attorney before commercial closed-source distribution.
|
|
263
|
+
See [gls/docs/LEGAL_DISCLAIMER.md](gls/docs/LEGAL_DISCLAIMER.md).
|
|
264
|
+
|
|
265
|
+
---
|
|
266
|
+
|
|
267
|
+
## Development
|
|
268
|
+
|
|
269
|
+
```bash
|
|
270
|
+
git clone https://github.com/NezbiT/github-license-scanner.git
|
|
271
|
+
cd github-license-scanner
|
|
272
|
+
|
|
273
|
+
python -m venv .venv
|
|
274
|
+
.venv\Scripts\activate # macOS / Linux: source .venv/bin/activate
|
|
275
|
+
|
|
276
|
+
pip install -e '.[ui]'
|
|
277
|
+
python -m gls.cli scan psf/requests
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
Build and check a release locally before tagging:
|
|
281
|
+
|
|
282
|
+
```bash
|
|
283
|
+
pip install build twine
|
|
284
|
+
python -m build
|
|
285
|
+
twine check dist/*
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
Releases are published to PyPI by [`.github/workflows/publish.yml`](.github/workflows/publish.yml)
|
|
289
|
+
using Trusted Publishing (OIDC) — no API tokens are stored anywhere.
|
|
290
|
+
|
|
291
|
+
---
|
|
292
|
+
|
|
293
|
+
## License
|
|
294
|
+
|
|
295
|
+
Released under the **[MIT License](LICENSE)**.
|
|
296
|
+
|
|
297
|
+
You may use, modify, and redistribute this project commercially or privately,
|
|
298
|
+
as long as you keep the copyright and license notice. See `LICENSE` for full text.
|
|
299
|
+
|
|
300
|
+
> The MIT license applies to **this tool’s source code**.
|
|
301
|
+
> It does **not** change the licenses of the GitHub repositories or packages you scan.
|
|
302
|
+
|
|
303
|
+
---
|
|
304
|
+
|
|
305
|
+
<p align="center">
|
|
306
|
+
Made with NiceGUI · httpx · packaging
|
|
307
|
+
</p>
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
gls/__init__.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
|
|
2
|
+
gls/auth.py,sha256=vExnbGUgXacZC2aTwRx6kWkQIydsWKug7jGZGwRUaxg,6729
|
|
3
|
+
gls/cli.py,sha256=N7ZDBEHE7rbh8uPbkGR97xMuiDampspIpIKwLQZ0q2A,12125
|
|
4
|
+
gls/config.py,sha256=mrM0cGaMW_Jzk6Bgy8EFHiY7u4vOa-Oi8OGmJSl47y0,6071
|
|
5
|
+
gls/dependency_scanner.py,sha256=lDZURfxbUKPU1t38JEbcYEHKEiK6DahQRtwL8fUnSGg,21243
|
|
6
|
+
gls/deploy_advisor.py,sha256=RWDzCX0QrpauHdJ9X3KwFShM6K4ip12hXc-AarKTXww,10938
|
|
7
|
+
gls/github_api.py,sha256=BScDkBrLg6KaGCnCTiZiVGCp0GCSxD-OWX7o6y_nWrg,15951
|
|
8
|
+
gls/history_store.py,sha256=B-9RTpiQKRJ8kKkOB19-MwZvim06AA4A5Cq_ns5CVbs,5451
|
|
9
|
+
gls/i18n.py,sha256=lggZk-TL-PHV7xVP5RiCfZKH-MdW_57N8qJ1TW8Era8,18860
|
|
10
|
+
gls/license_analyzer.py,sha256=4tgpMsGIzyoLwmVqpXPguoSw_leNkElBa7QuX6x_R0g,31270
|
|
11
|
+
gls/models.py,sha256=6Vj8QdCnfn7J0F3sM6B79PpcNIRRQjQU_6M28g8E5Dg,4839
|
|
12
|
+
gls/rate_limit.py,sha256=QSzAP2Gk096dkPIlYTjz-y1JLtTGoBTQIEXjEnmysMg,2221
|
|
13
|
+
gls/report.py,sha256=vsmF9B_6GUXGcNT6ZCqJOIhd16m-DTTxGRlXaGu4cOY,5659
|
|
14
|
+
gls/sbom_export.py,sha256=EKltiV20i9Ne5ZZbLW6GMZ_xKOH_Uw0jZIQLbl5bqGQ,11009
|
|
15
|
+
gls/spdx_engine.py,sha256=qcuHe0dYY0P9U71Nt_SuuLfPyDZ0DjpDXQMvCdqPwwA,12487
|
|
16
|
+
gls/webui.py,sha256=vgjht82pJ2wqpgxFkwoGF8T0Q_3z_jQppHK8cQkxrkQ,70298
|
|
17
|
+
gls/docs/LEGAL_DISCLAIMER.md,sha256=zS1pWfTntaS_nwyBEd_y1JTbNAqj2Z8nEZeZSyztwIc,2870
|
|
18
|
+
gls/docs/PRIVACY.md,sha256=0tl39MuXUNnOLKvP9jdIVsU96nLDLZxutPSBBSy-JOk,3561
|
|
19
|
+
gls/docs/TERMS.md,sha256=siSl-uZ7lgQ_HvIY7a99UoJyPvOWxXCUKeB7GkjtGmg,2486
|
|
20
|
+
github_license_scanner-0.1.0.dist-info/METADATA,sha256=6FZ_c_bKPLGshOlHVGxbeSzMoapPMnnfpCjw4rIiIBs,10143
|
|
21
|
+
github_license_scanner-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
22
|
+
github_license_scanner-0.1.0.dist-info/entry_points.txt,sha256=NPdnZXh3qeBtU4vB5rdxZIiDHwMP4kEuZX0llI8uxpA,75
|
|
23
|
+
github_license_scanner-0.1.0.dist-info/licenses/LICENSE,sha256=iiCqcmQF6PZDr5vfURi3xqOG4b2K0ydcAvlGD2qaXAU,1063
|
|
24
|
+
github_license_scanner-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 NezbiT
|
|
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.
|
gls/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
gls/auth.py
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Optional multi-user authentication for the web UI.
|
|
3
|
+
|
|
4
|
+
When GLS_AUTH_ENABLED is true, the NiceGUI app requires a login.
|
|
5
|
+
Each user gets an isolated history file under <data_dir>/history/<user>.json.
|
|
6
|
+
|
|
7
|
+
Password storage uses PBKDF2-HMAC-SHA256 (stdlib only).
|
|
8
|
+
Users file format (JSON):
|
|
9
|
+
{
|
|
10
|
+
"alice": {"salt": "hex", "hash": "hex", "iterations": 200000},
|
|
11
|
+
...
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
CLI:
|
|
15
|
+
gls user-add alice
|
|
16
|
+
gls user-list
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import getpass
|
|
22
|
+
import hashlib
|
|
23
|
+
import json
|
|
24
|
+
import os
|
|
25
|
+
import re
|
|
26
|
+
import secrets
|
|
27
|
+
import sys
|
|
28
|
+
import tempfile
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
from typing import Any
|
|
31
|
+
|
|
32
|
+
try:
|
|
33
|
+
from .config import DATA_DIR # type: ignore
|
|
34
|
+
except Exception: # noqa: BLE001
|
|
35
|
+
from platformdirs import user_data_dir
|
|
36
|
+
|
|
37
|
+
DATA_DIR = Path(user_data_dir("github-license-scanner", "NezbiT"))
|
|
38
|
+
|
|
39
|
+
USERS_PATH = Path(os.environ.get("GLS_USERS_FILE") or (DATA_DIR / "users.json"))
|
|
40
|
+
|
|
41
|
+
DEFAULT_ITERATIONS = 200_000
|
|
42
|
+
USERNAME_RE = re.compile(r"^[A-Za-z0-9_.-]{1,64}$")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def auth_enabled() -> bool:
|
|
46
|
+
"""Return True when multi-user auth is required for the web UI."""
|
|
47
|
+
return os.environ.get("GLS_AUTH_ENABLED", "").strip().lower() in {
|
|
48
|
+
"1",
|
|
49
|
+
"true",
|
|
50
|
+
"yes",
|
|
51
|
+
"on",
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _atomic_write(path: Path, text: str) -> None:
|
|
56
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
57
|
+
fd, tmp = tempfile.mkstemp(dir=str(path.parent), prefix=".users_", suffix=".tmp")
|
|
58
|
+
try:
|
|
59
|
+
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|
60
|
+
fh.write(text)
|
|
61
|
+
fh.flush()
|
|
62
|
+
os.fsync(fh.fileno())
|
|
63
|
+
Path(tmp).replace(path)
|
|
64
|
+
except Exception:
|
|
65
|
+
try:
|
|
66
|
+
os.unlink(tmp)
|
|
67
|
+
except OSError:
|
|
68
|
+
pass
|
|
69
|
+
raise
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def load_users() -> dict[str, dict[str, Any]]:
|
|
73
|
+
"""Load users database (empty dict if missing)."""
|
|
74
|
+
if not USERS_PATH.exists():
|
|
75
|
+
return {}
|
|
76
|
+
try:
|
|
77
|
+
data = json.loads(USERS_PATH.read_text(encoding="utf-8") or "{}")
|
|
78
|
+
return data if isinstance(data, dict) else {}
|
|
79
|
+
except (json.JSONDecodeError, OSError):
|
|
80
|
+
return {}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def save_users(users: dict[str, dict[str, Any]]) -> None:
|
|
84
|
+
_atomic_write(USERS_PATH, json.dumps(users, indent=2, ensure_ascii=False))
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def hash_password(password: str, *, salt: bytes | None = None, iterations: int = DEFAULT_ITERATIONS) -> dict[str, Any]:
|
|
88
|
+
"""Create a password record."""
|
|
89
|
+
if not password or len(password) < 8:
|
|
90
|
+
raise ValueError("Password must be at least 8 characters")
|
|
91
|
+
salt = salt or secrets.token_bytes(16)
|
|
92
|
+
dk = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, iterations)
|
|
93
|
+
return {
|
|
94
|
+
"salt": salt.hex(),
|
|
95
|
+
"hash": dk.hex(),
|
|
96
|
+
"iterations": iterations,
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def verify_password(password: str, record: dict[str, Any]) -> bool:
|
|
101
|
+
"""Constant-time-ish verify of password against stored record."""
|
|
102
|
+
try:
|
|
103
|
+
salt = bytes.fromhex(record["salt"])
|
|
104
|
+
expected = bytes.fromhex(record["hash"])
|
|
105
|
+
iterations = int(record.get("iterations") or DEFAULT_ITERATIONS)
|
|
106
|
+
except (KeyError, ValueError, TypeError):
|
|
107
|
+
return False
|
|
108
|
+
got = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, iterations)
|
|
109
|
+
return secrets.compare_digest(got, expected)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def authenticate(username: str, password: str) -> bool:
|
|
113
|
+
"""Return True if credentials are valid."""
|
|
114
|
+
user = (username or "").strip()
|
|
115
|
+
if not user or not password:
|
|
116
|
+
return False
|
|
117
|
+
users = load_users()
|
|
118
|
+
rec = users.get(user)
|
|
119
|
+
if not rec:
|
|
120
|
+
# Also try case-insensitive match
|
|
121
|
+
for k, v in users.items():
|
|
122
|
+
if k.lower() == user.lower():
|
|
123
|
+
rec = v
|
|
124
|
+
user = k
|
|
125
|
+
break
|
|
126
|
+
if not rec:
|
|
127
|
+
# Dummy work to reduce timing oracle on missing users
|
|
128
|
+
hash_password("dummy-password-xx", salt=b"\x00" * 16)
|
|
129
|
+
return False
|
|
130
|
+
return verify_password(password, rec)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def add_user(username: str, password: str) -> None:
|
|
134
|
+
"""Create or update a user password."""
|
|
135
|
+
user = (username or "").strip()
|
|
136
|
+
if not USERNAME_RE.match(user):
|
|
137
|
+
raise ValueError(
|
|
138
|
+
"Username must be 1–64 chars: letters, digits, _ . -"
|
|
139
|
+
)
|
|
140
|
+
users = load_users()
|
|
141
|
+
users[user] = hash_password(password)
|
|
142
|
+
save_users(users)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def delete_user(username: str) -> bool:
|
|
146
|
+
users = load_users()
|
|
147
|
+
if username in users:
|
|
148
|
+
del users[username]
|
|
149
|
+
save_users(users)
|
|
150
|
+
return True
|
|
151
|
+
return False
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def list_usernames() -> list[str]:
|
|
155
|
+
return sorted(load_users().keys())
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def normalize_username(username: str) -> str:
|
|
159
|
+
return (username or "").strip()
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def history_user_key(username: str | None) -> str | None:
|
|
163
|
+
"""
|
|
164
|
+
Return a filesystem-safe history key.
|
|
165
|
+
|
|
166
|
+
None means shared/local anonymous history (auth disabled).
|
|
167
|
+
"""
|
|
168
|
+
if not username:
|
|
169
|
+
return None
|
|
170
|
+
u = normalize_username(username)
|
|
171
|
+
if not USERNAME_RE.match(u):
|
|
172
|
+
return None
|
|
173
|
+
return u.lower()
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
# ---------------------------------------------------------------------------
|
|
177
|
+
# CLI helpers
|
|
178
|
+
# ---------------------------------------------------------------------------
|
|
179
|
+
|
|
180
|
+
def _cli(argv: list[str]) -> int:
|
|
181
|
+
if len(argv) < 2 or argv[1] in {"-h", "--help"}:
|
|
182
|
+
print(__doc__)
|
|
183
|
+
return 0
|
|
184
|
+
cmd = argv[1]
|
|
185
|
+
if cmd == "list-users":
|
|
186
|
+
names = list_usernames()
|
|
187
|
+
if not names:
|
|
188
|
+
print("No users. Create one with: python -m gls.auth add-user <name>")
|
|
189
|
+
print(f"Users file: {USERS_PATH}")
|
|
190
|
+
return 0
|
|
191
|
+
for n in names:
|
|
192
|
+
print(n)
|
|
193
|
+
return 0
|
|
194
|
+
if cmd == "add-user":
|
|
195
|
+
if len(argv) < 3:
|
|
196
|
+
print("Usage: python -m gls.auth add-user <username>", file=sys.stderr)
|
|
197
|
+
return 2
|
|
198
|
+
username = argv[2]
|
|
199
|
+
pw = getpass.getpass("Password: ")
|
|
200
|
+
pw2 = getpass.getpass("Confirm: ")
|
|
201
|
+
if pw != pw2:
|
|
202
|
+
print("Passwords do not match", file=sys.stderr)
|
|
203
|
+
return 2
|
|
204
|
+
try:
|
|
205
|
+
add_user(username, pw)
|
|
206
|
+
except ValueError as exc:
|
|
207
|
+
print(exc, file=sys.stderr)
|
|
208
|
+
return 2
|
|
209
|
+
print(f"User {username!r} saved to {USERS_PATH}")
|
|
210
|
+
print("Enable auth with GLS_AUTH_ENABLED=1")
|
|
211
|
+
return 0
|
|
212
|
+
if cmd == "hash-password":
|
|
213
|
+
pw = getpass.getpass("Password: ")
|
|
214
|
+
rec = hash_password(pw)
|
|
215
|
+
print(json.dumps(rec, indent=2))
|
|
216
|
+
return 0
|
|
217
|
+
if cmd == "delete-user":
|
|
218
|
+
if len(argv) < 3:
|
|
219
|
+
print("Usage: python -m gls.auth delete-user <username>", file=sys.stderr)
|
|
220
|
+
return 2
|
|
221
|
+
ok = delete_user(argv[2])
|
|
222
|
+
print("Deleted" if ok else "Not found")
|
|
223
|
+
return 0 if ok else 1
|
|
224
|
+
print(f"Unknown command: {cmd}", file=sys.stderr)
|
|
225
|
+
return 2
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
if __name__ == "__main__":
|
|
229
|
+
raise SystemExit(_cli(sys.argv))
|