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.
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"))