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
gls/cli.py
ADDED
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Command-line interface for the GitHub License Scanner.
|
|
3
|
+
|
|
4
|
+
Usage examples:
|
|
5
|
+
gls scan https://github.com/psf/requests
|
|
6
|
+
gls batch urls.txt
|
|
7
|
+
gls history
|
|
8
|
+
gls ui # web interface (requires the [ui] extra)
|
|
9
|
+
|
|
10
|
+
Exit codes for `scan` / `batch`:
|
|
11
|
+
0 — no strong copyleft force-open signal
|
|
12
|
+
1 — forces_open_source is True (useful in CI gates)
|
|
13
|
+
2 — hard failure (bad URL, network, usage error)
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import argparse
|
|
19
|
+
import asyncio
|
|
20
|
+
import sys
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
from . import __version__
|
|
24
|
+
from .history_store import append_scan, load_history
|
|
25
|
+
from .license_analyzer import analyze_repository
|
|
26
|
+
from .models import ScanResult
|
|
27
|
+
from .report import render_markdown_report
|
|
28
|
+
from .sbom_export import render_sbom
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _print_result(result: ScanResult, verbose: bool = True) -> None:
|
|
32
|
+
"""Pretty-print a scan result to stdout."""
|
|
33
|
+
title = f"{result.owner}/{result.repo}" if result.owner else result.url
|
|
34
|
+
print("=" * 72)
|
|
35
|
+
print(f"Repository : {title}")
|
|
36
|
+
print(f"URL : {result.url}")
|
|
37
|
+
print(f"Scanned at : {result.scanned_at}")
|
|
38
|
+
print(f"Repo license: {result.repo_license or 'Unknown'}")
|
|
39
|
+
print(f"Language : {result.primary_language or 'n/a'}")
|
|
40
|
+
print("-" * 72)
|
|
41
|
+
|
|
42
|
+
if result.errors and not result.packages and not result.repo_license:
|
|
43
|
+
print("ERRORS:")
|
|
44
|
+
for err in result.errors:
|
|
45
|
+
print(f" - {err}")
|
|
46
|
+
print("=" * 72)
|
|
47
|
+
return
|
|
48
|
+
|
|
49
|
+
if not result.scan_complete:
|
|
50
|
+
print("Scan complete : NO (incomplete — do not treat verdict as reliable)")
|
|
51
|
+
flag = "YES — strong copyleft may force opening source" if result.forces_open_source else "NO"
|
|
52
|
+
if result.scan_complete and result.can_sell_closed:
|
|
53
|
+
sell = "POSSIBLY (heuristic; attribution & counsel still required)"
|
|
54
|
+
else:
|
|
55
|
+
sell = "NO / UNDETERMINED"
|
|
56
|
+
print(f"Risk score : {result.risk_score}/100 ({result.risk_score_label})")
|
|
57
|
+
print(f"Forces open source : {flag}")
|
|
58
|
+
print(f"Can sell closed : {sell}")
|
|
59
|
+
if result.has_network_copyleft:
|
|
60
|
+
print("Network copyleft : YES (AGPL/SSPL-class — SaaS obligations possible)")
|
|
61
|
+
if result.strong_copyleft_dev_only:
|
|
62
|
+
print("Dev-only copyleft : YES (strong copyleft only in dev deps)")
|
|
63
|
+
print()
|
|
64
|
+
print("Verdict:")
|
|
65
|
+
print(f" {result.verdict_summary}")
|
|
66
|
+
print()
|
|
67
|
+
|
|
68
|
+
counts = result.risk_counts()
|
|
69
|
+
prod_counts = result.risk_counts(prod_only=True)
|
|
70
|
+
print(
|
|
71
|
+
f"Packages analyzed: {len(result.packages)} "
|
|
72
|
+
f"(prod={result.prod_package_count}, dev={result.dev_package_count})"
|
|
73
|
+
)
|
|
74
|
+
print(
|
|
75
|
+
f" all [permissive={counts['permissive']}, "
|
|
76
|
+
f"weak_copyleft={counts['weak_copyleft']}, "
|
|
77
|
+
f"strong_copyleft={counts['strong_copyleft']}, "
|
|
78
|
+
f"unknown={counts['unknown']}]"
|
|
79
|
+
)
|
|
80
|
+
print(
|
|
81
|
+
f" prod [permissive={prod_counts['permissive']}, "
|
|
82
|
+
f"weak_copyleft={prod_counts['weak_copyleft']}, "
|
|
83
|
+
f"strong_copyleft={prod_counts['strong_copyleft']}, "
|
|
84
|
+
f"unknown={prod_counts['unknown']}]"
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
if verbose and result.grouped:
|
|
88
|
+
print()
|
|
89
|
+
print("Packages by license:")
|
|
90
|
+
for lic, pkgs in result.grouped.items():
|
|
91
|
+
risks = {p.risk for p in pkgs}
|
|
92
|
+
risk_label = ",".join(sorted(risks))
|
|
93
|
+
print(f" [{risk_label}] {lic} ({len(pkgs)})")
|
|
94
|
+
for p in sorted(pkgs, key=lambda x: x.name.lower())[:15]:
|
|
95
|
+
scope = "dev" if p.is_dev else "prod"
|
|
96
|
+
print(f" - {p.name} ({p.ecosystem}/{scope}) from {p.source_file}")
|
|
97
|
+
if len(pkgs) > 15:
|
|
98
|
+
print(f" … and {len(pkgs) - 15} more")
|
|
99
|
+
|
|
100
|
+
if result.replacements:
|
|
101
|
+
print()
|
|
102
|
+
print("Replacement suggestions (heuristic):")
|
|
103
|
+
for rep in result.replacements[:20]:
|
|
104
|
+
print(f" * {rep.package} [{rep.license_id or '?'}] ({rep.ecosystem})")
|
|
105
|
+
for alt in rep.alternatives:
|
|
106
|
+
print(f" → {alt}")
|
|
107
|
+
|
|
108
|
+
if result.deploy_advice:
|
|
109
|
+
print()
|
|
110
|
+
print("Deploy recommendations:")
|
|
111
|
+
for adv in result.deploy_advice:
|
|
112
|
+
print(f" • {adv.platform} (score={adv.score})")
|
|
113
|
+
for reason in adv.reasons[:3]:
|
|
114
|
+
print(f" - {reason}")
|
|
115
|
+
print(f" docs: {adv.docs_url}")
|
|
116
|
+
|
|
117
|
+
if result.copyright_notice:
|
|
118
|
+
print()
|
|
119
|
+
print("Copyright notice:")
|
|
120
|
+
print("-" * 40)
|
|
121
|
+
print(result.copyright_notice)
|
|
122
|
+
print("-" * 40)
|
|
123
|
+
|
|
124
|
+
if result.errors:
|
|
125
|
+
print()
|
|
126
|
+
print("Notes / soft errors:")
|
|
127
|
+
for err in result.errors:
|
|
128
|
+
print(f" - {err}")
|
|
129
|
+
|
|
130
|
+
print("=" * 72)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
async def cmd_scan(
|
|
134
|
+
url: str,
|
|
135
|
+
save: bool = True,
|
|
136
|
+
verbose: bool = True,
|
|
137
|
+
markdown_path: str | None = None,
|
|
138
|
+
sbom_path: str | None = None,
|
|
139
|
+
sbom_format: str = "cyclonedx",
|
|
140
|
+
) -> int:
|
|
141
|
+
"""Scan a single repository URL. Returns process exit code."""
|
|
142
|
+
print(f"Scanning {url} …")
|
|
143
|
+
result = await analyze_repository(url)
|
|
144
|
+
_print_result(result, verbose=verbose)
|
|
145
|
+
|
|
146
|
+
if markdown_path:
|
|
147
|
+
md = render_markdown_report(result)
|
|
148
|
+
out = Path(markdown_path)
|
|
149
|
+
out.write_text(md, encoding="utf-8")
|
|
150
|
+
print(f"Markdown report written to {out}")
|
|
151
|
+
|
|
152
|
+
if sbom_path:
|
|
153
|
+
try:
|
|
154
|
+
doc = render_sbom(result, sbom_format)
|
|
155
|
+
out = Path(sbom_path)
|
|
156
|
+
out.write_text(doc, encoding="utf-8")
|
|
157
|
+
print(f"SBOM ({sbom_format}) written to {out}")
|
|
158
|
+
except ValueError as exc:
|
|
159
|
+
print(f"SBOM export failed: {exc}", file=sys.stderr)
|
|
160
|
+
return 2
|
|
161
|
+
|
|
162
|
+
if save and result.owner and result.scan_complete:
|
|
163
|
+
append_scan(result)
|
|
164
|
+
print("Saved to history.")
|
|
165
|
+
|
|
166
|
+
if result.errors and not result.owner:
|
|
167
|
+
return 2
|
|
168
|
+
if not result.scan_complete:
|
|
169
|
+
return 2
|
|
170
|
+
if result.errors and not result.packages and result.repo_license is None:
|
|
171
|
+
if any(
|
|
172
|
+
any(k in e.lower() for k in ("not found", "rate limit", "forbidden", "exceeded"))
|
|
173
|
+
for e in result.errors
|
|
174
|
+
):
|
|
175
|
+
return 2
|
|
176
|
+
return 1 if result.forces_open_source else 0
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
async def cmd_batch(file_path: str, save: bool = True) -> int:
|
|
180
|
+
"""Scan multiple URLs listed in a text file (one per line)."""
|
|
181
|
+
path = Path(file_path)
|
|
182
|
+
if not path.exists():
|
|
183
|
+
print(f"File not found: {file_path}", file=sys.stderr)
|
|
184
|
+
return 2
|
|
185
|
+
|
|
186
|
+
urls = []
|
|
187
|
+
for line in path.read_text(encoding="utf-8").splitlines():
|
|
188
|
+
line = line.strip()
|
|
189
|
+
if line and not line.startswith("#"):
|
|
190
|
+
urls.append(line)
|
|
191
|
+
|
|
192
|
+
if not urls:
|
|
193
|
+
print("No URLs found in file.", file=sys.stderr)
|
|
194
|
+
return 2
|
|
195
|
+
|
|
196
|
+
exit_code = 0
|
|
197
|
+
for i, url in enumerate(urls, 1):
|
|
198
|
+
print(f"\n[{i}/{len(urls)}] ", end="")
|
|
199
|
+
code = await cmd_scan(url, save=save, verbose=True)
|
|
200
|
+
if code == 2:
|
|
201
|
+
exit_code = 2
|
|
202
|
+
elif code == 1 and exit_code != 2:
|
|
203
|
+
exit_code = 1
|
|
204
|
+
return exit_code
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def cmd_history() -> int:
|
|
208
|
+
"""Print saved scan history."""
|
|
209
|
+
entries = load_history()
|
|
210
|
+
if not entries:
|
|
211
|
+
print("No history yet. Run a scan first.")
|
|
212
|
+
return 0
|
|
213
|
+
print(f"History ({len(entries)} entries):\n")
|
|
214
|
+
for e in entries:
|
|
215
|
+
sell = "closed-OK" if e.get("can_sell_closed") else "OPEN-FORCE"
|
|
216
|
+
print(
|
|
217
|
+
f" {e.get('scanned_at', '?'):<22} "
|
|
218
|
+
f"{e.get('owner', '?')}/{e.get('repo', '?'):<30} "
|
|
219
|
+
f"lic={e.get('repo_license') or '?':<12} "
|
|
220
|
+
f"pkgs={e.get('package_count', 0):<4} "
|
|
221
|
+
f"{sell}"
|
|
222
|
+
)
|
|
223
|
+
return 0
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def cmd_ui(host: str | None, port: int | None) -> int:
|
|
227
|
+
"""Launch the web interface. NiceGUI is an optional extra, so import late."""
|
|
228
|
+
try:
|
|
229
|
+
from .webui import run
|
|
230
|
+
except ImportError:
|
|
231
|
+
print(
|
|
232
|
+
"The web interface is not installed.\n"
|
|
233
|
+
"Install it with: pipx install 'github-license-scanner[ui]'",
|
|
234
|
+
file=sys.stderr,
|
|
235
|
+
)
|
|
236
|
+
return 2
|
|
237
|
+
run(host=host, port=port)
|
|
238
|
+
return 0
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
242
|
+
"""Create the CLI argument parser."""
|
|
243
|
+
parser = argparse.ArgumentParser(
|
|
244
|
+
prog="gls",
|
|
245
|
+
description="GitHub License Scanner — analyze repo & dependency licenses.",
|
|
246
|
+
)
|
|
247
|
+
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
248
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
249
|
+
|
|
250
|
+
p_scan = sub.add_parser("scan", help="Scan a single GitHub repository URL")
|
|
251
|
+
p_scan.add_argument("url", help="GitHub repository URL or owner/repo")
|
|
252
|
+
p_scan.add_argument("--no-save", action="store_true", help="Do not write history")
|
|
253
|
+
p_scan.add_argument(
|
|
254
|
+
"--markdown",
|
|
255
|
+
metavar="PATH",
|
|
256
|
+
help="Write a Markdown report to PATH (e.g. report.md)",
|
|
257
|
+
)
|
|
258
|
+
p_scan.add_argument(
|
|
259
|
+
"--sbom",
|
|
260
|
+
metavar="PATH",
|
|
261
|
+
help="Write an SBOM JSON file to PATH (CycloneDX or SPDX)",
|
|
262
|
+
)
|
|
263
|
+
p_scan.add_argument(
|
|
264
|
+
"--sbom-format",
|
|
265
|
+
choices=("cyclonedx", "spdx"),
|
|
266
|
+
default="cyclonedx",
|
|
267
|
+
help="SBOM format when using --sbom (default: cyclonedx)",
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
p_batch = sub.add_parser("batch", help="Scan URLs from a text file")
|
|
271
|
+
p_batch.add_argument("file", help="Path to text file with one URL per line")
|
|
272
|
+
p_batch.add_argument("--no-save", action="store_true", help="Do not write history")
|
|
273
|
+
|
|
274
|
+
sub.add_parser("history", help="Show previous scan history")
|
|
275
|
+
|
|
276
|
+
p_ui = sub.add_parser("ui", help="Launch the web interface (needs the [ui] extra)")
|
|
277
|
+
p_ui.add_argument("--host", default=None, help="Bind address (default: GLS_HOST)")
|
|
278
|
+
p_ui.add_argument("--port", type=int, default=None, help="Port (default: GLS_PORT)")
|
|
279
|
+
|
|
280
|
+
p_user = sub.add_parser("user-add", help="Add a web UI user (multi-user auth)")
|
|
281
|
+
p_user.add_argument("username", help="Username (letters, digits, _ . -)")
|
|
282
|
+
sub.add_parser("user-list", help="List web UI users")
|
|
283
|
+
|
|
284
|
+
return parser
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _force_utf8_output() -> None:
|
|
288
|
+
"""
|
|
289
|
+
Windows consoles and redirected pipes default to a legacy codepage (cp1252)
|
|
290
|
+
that cannot encode the arrows and dashes used in reports, which crashed the
|
|
291
|
+
scan halfway through with UnicodeEncodeError. Degrade instead of dying.
|
|
292
|
+
"""
|
|
293
|
+
for stream in (sys.stdout, sys.stderr):
|
|
294
|
+
reconfigure = getattr(stream, "reconfigure", None)
|
|
295
|
+
if reconfigure is None:
|
|
296
|
+
continue
|
|
297
|
+
try:
|
|
298
|
+
reconfigure(encoding="utf-8", errors="replace")
|
|
299
|
+
except (ValueError, OSError): # detached or already-closed stream
|
|
300
|
+
pass
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def main(argv: list[str] | None = None) -> int:
|
|
304
|
+
"""CLI entrypoint."""
|
|
305
|
+
_force_utf8_output()
|
|
306
|
+
parser = build_parser()
|
|
307
|
+
args = parser.parse_args(argv)
|
|
308
|
+
|
|
309
|
+
if args.command == "scan":
|
|
310
|
+
return asyncio.run(
|
|
311
|
+
cmd_scan(
|
|
312
|
+
args.url,
|
|
313
|
+
save=not args.no_save,
|
|
314
|
+
markdown_path=args.markdown,
|
|
315
|
+
sbom_path=args.sbom,
|
|
316
|
+
sbom_format=args.sbom_format,
|
|
317
|
+
)
|
|
318
|
+
)
|
|
319
|
+
if args.command == "batch":
|
|
320
|
+
return asyncio.run(cmd_batch(args.file, save=not args.no_save))
|
|
321
|
+
if args.command == "history":
|
|
322
|
+
return cmd_history()
|
|
323
|
+
if args.command == "ui":
|
|
324
|
+
return cmd_ui(args.host, args.port)
|
|
325
|
+
if args.command == "user-add":
|
|
326
|
+
from .auth import add_user
|
|
327
|
+
|
|
328
|
+
import getpass
|
|
329
|
+
|
|
330
|
+
pw = getpass.getpass("Password: ")
|
|
331
|
+
pw2 = getpass.getpass("Confirm: ")
|
|
332
|
+
if pw != pw2:
|
|
333
|
+
print("Passwords do not match", file=sys.stderr)
|
|
334
|
+
return 2
|
|
335
|
+
try:
|
|
336
|
+
add_user(args.username, pw)
|
|
337
|
+
except ValueError as exc:
|
|
338
|
+
print(exc, file=sys.stderr)
|
|
339
|
+
return 2
|
|
340
|
+
print(f"User {args.username!r} created. Set GLS_AUTH_ENABLED=1 to require login.")
|
|
341
|
+
return 0
|
|
342
|
+
if args.command == "user-list":
|
|
343
|
+
from .auth import list_usernames
|
|
344
|
+
|
|
345
|
+
names = list_usernames()
|
|
346
|
+
if not names:
|
|
347
|
+
print("No users yet. Run: gls user-add <name>")
|
|
348
|
+
else:
|
|
349
|
+
for n in names:
|
|
350
|
+
print(n)
|
|
351
|
+
return 0
|
|
352
|
+
|
|
353
|
+
parser.print_help()
|
|
354
|
+
return 2
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
if __name__ == "__main__":
|
|
358
|
+
sys.exit(main())
|
gls/config.py
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Runtime configuration for GitHub License Scanner.
|
|
3
|
+
|
|
4
|
+
Environment variables are the source of truth. A .env file is a convenience
|
|
5
|
+
for local development only: one is looked for in the current directory and in
|
|
6
|
+
the user config directory, and neither is required. Values already present in
|
|
7
|
+
the environment always win.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
import secrets
|
|
14
|
+
import warnings
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from platformdirs import user_config_dir, user_data_dir
|
|
18
|
+
|
|
19
|
+
APP_NAME = "github-license-scanner"
|
|
20
|
+
APP_AUTHOR = "NezbiT"
|
|
21
|
+
|
|
22
|
+
# ---------------------------------------------------------------------------
|
|
23
|
+
# Writable locations (must never be inside the installed package)
|
|
24
|
+
# ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
DATA_DIR: Path = Path(
|
|
27
|
+
os.environ.get("GLS_DATA_DIR") or user_data_dir(APP_NAME, APP_AUTHOR)
|
|
28
|
+
)
|
|
29
|
+
CONFIG_DIR: Path = Path(
|
|
30
|
+
os.environ.get("GLS_CONFIG_DIR") or user_config_dir(APP_NAME, APP_AUTHOR)
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
# Optional .env loading. Never assume a file is present: installed globally,
|
|
34
|
+
# the current directory has nothing to do with the package.
|
|
35
|
+
try:
|
|
36
|
+
from dotenv import load_dotenv
|
|
37
|
+
except ImportError: # python-dotenv is an optional convenience
|
|
38
|
+
pass
|
|
39
|
+
else:
|
|
40
|
+
for _candidate in (Path.cwd() / ".env", CONFIG_DIR / ".env"):
|
|
41
|
+
if _candidate.is_file():
|
|
42
|
+
# override=False: real environment variables take precedence.
|
|
43
|
+
load_dotenv(_candidate, override=False)
|
|
44
|
+
|
|
45
|
+
# ---------------------------------------------------------------------------
|
|
46
|
+
# Server
|
|
47
|
+
# ---------------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
HOST: str = os.environ.get("GLS_HOST", "127.0.0.1")
|
|
50
|
+
PORT: int = int(os.environ.get("GLS_PORT", "8080"))
|
|
51
|
+
# When True, open browser on start (local UX). Disable in containers/CI.
|
|
52
|
+
SHOW_BROWSER: bool = os.environ.get("GLS_SHOW_BROWSER", "1").strip().lower() not in {
|
|
53
|
+
"0",
|
|
54
|
+
"false",
|
|
55
|
+
"no",
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
# ---------------------------------------------------------------------------
|
|
59
|
+
# Session signing secret (CRITICAL for multi-user / public deploys)
|
|
60
|
+
# ---------------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
_DEFAULT_DEV_SECRET = "github-license-scanner-dev-only-not-for-production"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _resolve_storage_secret() -> str:
|
|
66
|
+
"""
|
|
67
|
+
Resolve NiceGUI storage_secret.
|
|
68
|
+
|
|
69
|
+
Prefer GLS_STORAGE_SECRET / NICEGUI_STORAGE_SECRET.
|
|
70
|
+
In production-like hosts (0.0.0.0), refuse the insecure default.
|
|
71
|
+
"""
|
|
72
|
+
secret = (
|
|
73
|
+
os.environ.get("GLS_STORAGE_SECRET")
|
|
74
|
+
or os.environ.get("NICEGUI_STORAGE_SECRET")
|
|
75
|
+
or ""
|
|
76
|
+
).strip()
|
|
77
|
+
if secret:
|
|
78
|
+
return secret
|
|
79
|
+
|
|
80
|
+
host = os.environ.get("GLS_HOST", "127.0.0.1").strip()
|
|
81
|
+
if host in {"0.0.0.0", "::", "[::]"}:
|
|
82
|
+
# Generate an ephemeral secret so the process still starts, but warn:
|
|
83
|
+
# sessions will not survive restarts and operators must set a fixed secret.
|
|
84
|
+
generated = secrets.token_urlsafe(48)
|
|
85
|
+
warnings.warn(
|
|
86
|
+
"GLS_STORAGE_SECRET is not set while binding a public interface. "
|
|
87
|
+
"Using an ephemeral secret (sessions reset on restart). "
|
|
88
|
+
"Set GLS_STORAGE_SECRET to a long random value in production.",
|
|
89
|
+
stacklevel=2,
|
|
90
|
+
)
|
|
91
|
+
return generated
|
|
92
|
+
|
|
93
|
+
warnings.warn(
|
|
94
|
+
"Using development storage_secret. Set GLS_STORAGE_SECRET before "
|
|
95
|
+
"any multi-user or public deployment.",
|
|
96
|
+
stacklevel=2,
|
|
97
|
+
)
|
|
98
|
+
return _DEFAULT_DEV_SECRET
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def __getattr__(name: str) -> str:
|
|
102
|
+
"""
|
|
103
|
+
Resolve STORAGE_SECRET lazily (PEP 562).
|
|
104
|
+
|
|
105
|
+
Computing it at import time made every CLI invocation emit the
|
|
106
|
+
development-secret warning, even though only the web UI signs sessions.
|
|
107
|
+
"""
|
|
108
|
+
if name == "STORAGE_SECRET":
|
|
109
|
+
value = _resolve_storage_secret()
|
|
110
|
+
globals()["STORAGE_SECRET"] = value # cache: warn at most once
|
|
111
|
+
return value
|
|
112
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
113
|
+
|
|
114
|
+
# ---------------------------------------------------------------------------
|
|
115
|
+
# Abuse / resource limits
|
|
116
|
+
# ---------------------------------------------------------------------------
|
|
117
|
+
|
|
118
|
+
# Max concurrent registry lookups (also in analyzer; kept here for docs)
|
|
119
|
+
MAX_CONCURRENT_LOOKUPS: int = int(os.environ.get("GLS_MAX_CONCURRENT_LOOKUPS", "12"))
|
|
120
|
+
MAX_PACKAGES_LOOKUP: int = int(os.environ.get("GLS_MAX_PACKAGES_LOOKUP", "80"))
|
|
121
|
+
MAX_DEPENDENCY_FILES: int = int(os.environ.get("GLS_MAX_DEPENDENCY_FILES", "30"))
|
|
122
|
+
MAX_BATCH_URLS: int = int(os.environ.get("GLS_MAX_BATCH_URLS", "15"))
|
|
123
|
+
|
|
124
|
+
# Scan rate limit per client key (IP / session): N scans per window
|
|
125
|
+
RATE_LIMIT_SCANS: int = int(os.environ.get("GLS_RATE_LIMIT_SCANS", "20"))
|
|
126
|
+
RATE_LIMIT_WINDOW_SECONDS: int = int(os.environ.get("GLS_RATE_LIMIT_WINDOW", "3600"))
|
|
127
|
+
|
|
128
|
+
# In-process registry license cache
|
|
129
|
+
LICENSE_CACHE_TTL_SECONDS: int = int(os.environ.get("GLS_LICENSE_CACHE_TTL", "3600"))
|
|
130
|
+
LICENSE_CACHE_MAX_ENTRIES: int = int(os.environ.get("GLS_LICENSE_CACHE_MAX", "2000"))
|
|
131
|
+
|
|
132
|
+
# History retention
|
|
133
|
+
HISTORY_MAX_ENTRIES: int = int(os.environ.get("GLS_HISTORY_MAX", "100"))
|
|
134
|
+
# Auto-prune history entries older than this many days (0 = disabled)
|
|
135
|
+
HISTORY_MAX_AGE_DAYS: int = int(os.environ.get("GLS_HISTORY_MAX_AGE_DAYS", "90"))
|
|
136
|
+
|
|
137
|
+
# ---------------------------------------------------------------------------
|
|
138
|
+
# GitHub
|
|
139
|
+
# ---------------------------------------------------------------------------
|
|
140
|
+
|
|
141
|
+
GITHUB_TOKEN: str | None = (
|
|
142
|
+
os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN") or None
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
USER_AGENT: str = os.environ.get(
|
|
146
|
+
"GLS_USER_AGENT",
|
|
147
|
+
"github-license-scanner/1.2 (+https://github.com/NezbiT/github-license-scanner; educational)",
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
# ---------------------------------------------------------------------------
|
|
151
|
+
# Multi-user authentication (web UI)
|
|
152
|
+
# ---------------------------------------------------------------------------
|
|
153
|
+
|
|
154
|
+
AUTH_ENABLED: bool = os.environ.get("GLS_AUTH_ENABLED", "").strip().lower() in {
|
|
155
|
+
"1",
|
|
156
|
+
"true",
|
|
157
|
+
"yes",
|
|
158
|
+
"on",
|
|
159
|
+
}
|
|
160
|
+
# JSON file of {username: {salt, hash, iterations}} — see auth.py
|
|
161
|
+
USERS_FILE: str = os.environ.get("GLS_USERS_FILE", str(DATA_DIR / "users.json"))
|