globalproof-ai 0.2.3__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.
globalproof.py ADDED
@@ -0,0 +1,1202 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ GlobalProof AI — Local-first AI training data compliance scanner.
4
+
5
+ Scans a directory for open-source license contamination and PII leakage.
6
+ Only SHA-256 hashes, file paths, and finding counts are sent to the backend.
7
+ Raw file contents never leave your machine.
8
+
9
+ Usage:
10
+ python globalproof.py register
11
+ python globalproof.py login
12
+ python globalproof.py scan ./training-data --project acme-llm-v3
13
+ python globalproof.py passport
14
+ python globalproof.py pdf
15
+ python globalproof.py verify
16
+
17
+ Environment:
18
+ GLOBALPROOF_API_URL Override backend URL
19
+ GLOBALPROOF_TOKEN Override stored token
20
+ GLOBALPROOF_CONFIG Override config file path
21
+ """
22
+
23
+ __version__ = "0.2.3"
24
+
25
+ # ────────────────────────────────────────────────────────────────────────────
26
+ # SELF-IDENTIFICATION MARKER
27
+ # ────────────────────────────────────────────────────────────────────────────
28
+ # This unique string is embedded in the scanner's own source. The scanner
29
+ # uses it to detect and skip itself during a scan, so that it never matches
30
+ # its own pattern-definition tables as findings. If you fork this file and
31
+ # change the marker, the scanner will no longer recognize your copy — that
32
+ # is intentional. To skip a renamed fork, add its name to DEFAULT_SKIP.
33
+ GLOBALPROOF_SELF_MARKER = b"GLOBALPROOF_SCANNER_SOURCE_MARKER_7f3a9c2e4b8d"
34
+
35
+ import argparse
36
+ import hashlib
37
+ import json
38
+ import os
39
+ import re
40
+ import secrets
41
+ import sys
42
+ import tempfile
43
+ from pathlib import Path
44
+ from typing import Any, Dict, List, Optional, Tuple
45
+
46
+ try:
47
+ import requests
48
+ except ImportError:
49
+ sys.stderr.write("Missing dependency: pip install requests\n")
50
+ sys.exit(2)
51
+
52
+ try:
53
+ import getpass
54
+ except ImportError:
55
+ getpass = None
56
+
57
+
58
+ # ────────────────────────────────────────────────────────────────────────────
59
+ # CONFIG
60
+ # ────────────────────────────────────────────────────────────────────────────
61
+
62
+ DEFAULT_BASE = "http://localhost:8080"
63
+
64
+
65
+ def _config_path() -> Path:
66
+ override = os.environ.get("GLOBALPROOF_CONFIG")
67
+ if override:
68
+ return Path(override)
69
+ return Path.home() / ".globalproof" / "config.json"
70
+
71
+
72
+ CFG = _config_path()
73
+
74
+
75
+ def load() -> Dict[str, Any]:
76
+ try:
77
+ return json.loads(CFG.read_text())
78
+ except FileNotFoundError:
79
+ return {}
80
+ except json.JSONDecodeError:
81
+ backup = CFG.with_suffix(".corrupt.json")
82
+ try:
83
+ CFG.rename(backup)
84
+ except OSError:
85
+ pass
86
+ print("Warning: config was corrupt. Backed up to %s" % backup,
87
+ file=sys.stderr)
88
+ return {}
89
+
90
+
91
+ def save(c: Dict[str, Any]) -> None:
92
+ CFG.parent.mkdir(parents=True, exist_ok=True)
93
+ fd, tmp = tempfile.mkstemp(dir=str(CFG.parent), prefix=".config-", suffix=".json")
94
+ try:
95
+ with os.fdopen(fd, "w") as f:
96
+ json.dump(c, f, indent=2)
97
+ os.replace(tmp, CFG)
98
+ try:
99
+ os.chmod(CFG, 0o600)
100
+ except OSError:
101
+ pass
102
+ except Exception:
103
+ try:
104
+ os.unlink(tmp)
105
+ except OSError:
106
+ pass
107
+ raise
108
+
109
+
110
+ def get_base(c: Optional[Dict[str, Any]] = None) -> str:
111
+ if c is None:
112
+ c = load()
113
+ return (
114
+ os.environ.get("GLOBALPROOF_API_URL")
115
+ or c.get("baseUrl")
116
+ or DEFAULT_BASE
117
+ ).rstrip("/")
118
+
119
+
120
+ def get_token(c: Optional[Dict[str, Any]] = None) -> Optional[str]:
121
+ return os.environ.get("GLOBALPROOF_TOKEN") or (c or load()).get("token")
122
+
123
+
124
+ # ────────────────────────────────────────────────────────────────────────────
125
+ # API CLIENT
126
+ # ────────────────────────────────────────────────────────────────────────────
127
+
128
+ class ApiError(Exception):
129
+ def __init__(self, status: int, message: str, url: str = "",
130
+ session_expired: bool = False):
131
+ self.status = status
132
+ self.message = message
133
+ self.url = url
134
+ self.session_expired = session_expired
135
+ super().__init__("HTTP %d: %s" % (status, message[:300]))
136
+
137
+
138
+ def _looks_like_expired_token(status: int, body: str) -> bool:
139
+ """True if the error body signals an expired or invalid JWT."""
140
+ if status != 401:
141
+ return False
142
+ low = (body or "").lower()
143
+ # Match the specific error codes the backend emits.
144
+ return ("token_invalid" in low
145
+ or "token_expired" in low
146
+ or "invalid or expired token" in low)
147
+
148
+
149
+ def api(method: str, path: str, body: Optional[dict] = None,
150
+ tok: Optional[str] = None, base: Optional[str] = None) -> dict:
151
+ base = base or get_base()
152
+ url = base + path
153
+ headers = {"Content-Type": "application/json",
154
+ "User-Agent": "globalproof-cli/%s" % __version__}
155
+ if tok:
156
+ headers["Authorization"] = "Bearer " + tok
157
+ try:
158
+ r = requests.request(method, url, headers=headers,
159
+ data=json.dumps(body) if body is not None else None,
160
+ timeout=30)
161
+ except requests.RequestException as e:
162
+ raise ApiError(0, "network error: %s" % e, url) from e
163
+ if r.status_code >= 400:
164
+ text = r.text or r.reason or ""
165
+ expired = _looks_like_expired_token(r.status_code, text)
166
+ raise ApiError(r.status_code, text, url, session_expired=expired)
167
+ if not r.text:
168
+ return {}
169
+ try:
170
+ return r.json()
171
+ except ValueError:
172
+ raise ApiError(r.status_code, "non-JSON response: %r" % r.text[:120], url)
173
+
174
+
175
+ def api_download(path: str, tok: Optional[str] = None,
176
+ base: Optional[str] = None) -> bytes:
177
+ base = base or get_base()
178
+ url = base + path
179
+ headers = {"User-Agent": "globalproof-cli/%s" % __version__}
180
+ if tok:
181
+ headers["Authorization"] = "Bearer " + tok
182
+ try:
183
+ r = requests.get(url, headers=headers, timeout=120)
184
+ except requests.RequestException as e:
185
+ raise ApiError(0, "network error: %s" % e, url) from e
186
+ if r.status_code >= 400:
187
+ text = r.text or r.reason or ""
188
+ expired = _looks_like_expired_token(r.status_code, text)
189
+ raise ApiError(r.status_code, text, url, session_expired=expired)
190
+ return r.content
191
+
192
+
193
+ def require_token(c: Optional[Dict[str, Any]] = None) -> str:
194
+ tok = get_token(c)
195
+ if not tok:
196
+ die("Not logged in. Run: globalproof login --email <your-email>")
197
+ return tok
198
+
199
+
200
+ def die(msg: str, code: int = 1) -> None:
201
+ print(msg, file=sys.stderr)
202
+ sys.exit(code)
203
+
204
+
205
+ # ────────────────────────────────────────────────────────────────────────────
206
+ # LICENSE DETECTION
207
+ # ────────────────────────────────────────────────────────────────────────────
208
+
209
+ LICENSE_PATTERNS = [
210
+ ("gnu affero general public license", "AGPL-3.0", "NETWORK_COPYLEFT"),
211
+ ("gnu general public license, version 3", "GPL-3.0", "STRONG_COPYLEFT"),
212
+ ("gnu general public license version 3", "GPL-3.0", "STRONG_COPYLEFT"),
213
+ ("gnu general public license, version 2", "GPL-2.0", "STRONG_COPYLEFT"),
214
+ ("gnu general public license version 2", "GPL-2.0", "STRONG_COPYLEFT"),
215
+ ("gnu lesser general public license", "LGPL-3.0", "WEAK_COPYLEFT"),
216
+ ("mozilla public license version 2", "MPL-2.0", "WEAK_COPYLEFT"),
217
+ ("eclipse public license", "EPL-2.0", "WEAK_COPYLEFT"),
218
+ ("apache license, version 2.0", "Apache-2.0", "PERMISSIVE"),
219
+ ("apache license version 2.0", "Apache-2.0", "PERMISSIVE"),
220
+ ("permission is hereby granted, free of charge", "MIT", "PERMISSIVE"),
221
+ ("isc license", "ISC", "PERMISSIVE"),
222
+ ("redistribution and use in source and binary forms", "BSD-3-Clause", "PERMISSIVE"),
223
+ ("this is free and unencumbered software released into the public domain",
224
+ "Unlicense", "PERMISSIVE"),
225
+ ("server side public license", "SSPL-1.0", "STRONG_COPYLEFT"),
226
+ ("business source license", "BSL-1.1", "PROPRIETARY"),
227
+ ("creative commons attribution-noncommercial",
228
+ "CC-BY-NC-4.0", "PROPRIETARY"),
229
+ ]
230
+
231
+
232
+ def license_risk(spdx: str) -> str:
233
+ s = (spdx or "").upper()
234
+ if s.startswith("AGPL"):
235
+ return "NETWORK_COPYLEFT"
236
+ if s.startswith(("GPL-2", "GPL-3")):
237
+ return "STRONG_COPYLEFT"
238
+ if s.startswith(("LGPL", "MPL", "EPL")):
239
+ return "WEAK_COPYLEFT"
240
+ if s.startswith(("MIT", "APACHE", "BSD", "ISC", "UNLICENSE")):
241
+ return "PERMISSIVE"
242
+ if s.startswith(("SSPL", "BSL")):
243
+ return "STRONG_COPYLEFT"
244
+ if s.startswith("CC-BY-NC"):
245
+ return "PROPRIETARY"
246
+ return "UNKNOWN"
247
+
248
+
249
+ LICENSE_FILENAMES = {
250
+ "license", "license.txt", "license.md", "license.rst",
251
+ "copying", "copying.txt", "notice", "notice.txt", "notice.md",
252
+ "unlicense", "unlicense.txt",
253
+ }
254
+
255
+ CODE_EXTENSIONS = {
256
+ ".py", ".js", ".jsx", ".ts", ".tsx", ".java", ".kt", ".scala",
257
+ ".go", ".rs", ".rb", ".php", ".swift", ".m", ".mm",
258
+ ".c", ".h", ".cc", ".cpp", ".hpp", ".cs",
259
+ ".sh", ".bash", ".zsh", ".fish",
260
+ ".lua", ".pl", ".r", ".jl", ".dart", ".ex", ".exs", ".erl", ".hs",
261
+ }
262
+
263
+ TEXT_EXTENSIONS = CODE_EXTENSIONS | {
264
+ ".txt", ".md", ".rst", ".json", ".yaml", ".yml", ".toml", ".ini",
265
+ ".cfg", ".conf", ".properties", ".env",
266
+ ".csv", ".tsv", ".log", ".sql", ".xml", ".html", ".css", ".scss",
267
+ ".tf", ".tfvars", ".hcl", ".dockerfile", ".makefile",
268
+ ".ipynb",
269
+ }
270
+
271
+ # Canonical SPDX suffixes are lowercase. When a source file writes
272
+ # "GPL-3.0-ONLY", normalizing to "GPL-3.0-only" makes the passport match
273
+ # what SPDX-aware auditors expect.
274
+ _SPDX_SUFFIX_NORMALIZE = re.compile(r"-(ONLY|OR-LATER)$", re.IGNORECASE)
275
+
276
+
277
+ def normalize_spdx_id(spdx: str) -> str:
278
+ """Return the SPDX identifier in canonical form.
279
+
280
+ Preserves the casing of the base identifier (GPL-3.0, MIT, Apache-2.0)
281
+ and lowercases the recognized suffix (``-only``, ``-or-later``). Any
282
+ other identifier is returned unchanged.
283
+ """
284
+ if not spdx:
285
+ return spdx
286
+ return _SPDX_SUFFIX_NORMALIZE.sub(
287
+ lambda m: "-" + m.group(1).lower(), spdx
288
+ )
289
+
290
+
291
+ def detect_license(path: Path) -> Tuple[str, str]:
292
+ """Return (license_id, risk). SPDX header wins; then content match."""
293
+ try:
294
+ raw = path.read_bytes()[:16384]
295
+ except OSError:
296
+ return "UNKNOWN", "UNKNOWN"
297
+
298
+ text = raw.decode("utf-8", errors="ignore").lower()
299
+
300
+ m = re.search(r"spdx-license-identifier:\s*([a-z0-9.\-+()]+)", text)
301
+ if m:
302
+ # The regex is case-sensitive only in the operator; the identifier
303
+ # portion is matched loosely. Read the identifier as it appears in
304
+ # the file so we do not lose suffix casing.
305
+ raw_expr = m.group(1)
306
+ # Handle OR/AND/WITH expressions — take the first identifier.
307
+ first = re.split(r"\s+(?:or|and|with)\s+", raw_expr)[0]
308
+ # Uppercase the base name for enum-consistency but normalize suffixes.
309
+ spdx = normalize_spdx_id(first.upper().strip("()"))
310
+ return spdx, license_risk(spdx)
311
+
312
+ name_lower = path.name.lower()
313
+ ext_lower = path.suffix.lower()
314
+ is_license_file = name_lower in LICENSE_FILENAMES
315
+ is_code_file = ext_lower in CODE_EXTENSIONS
316
+ if not (is_license_file or is_code_file):
317
+ return "UNKNOWN", "UNKNOWN"
318
+
319
+ if is_license_file:
320
+ for needle, lid, risk in LICENSE_PATTERNS:
321
+ if needle in text:
322
+ return lid, risk
323
+
324
+ m = re.search(r"\b(agpl-3\.0|gpl-3\.0|gpl-2\.0|lgpl-3\.0|mpl-2\.0|"
325
+ r"apache-2\.0|mit|bsd-3-clause|isc)\b", text)
326
+ if m:
327
+ spdx = normalize_spdx_id(m.group(1).upper())
328
+ return spdx, license_risk(spdx)
329
+
330
+ return "UNKNOWN", "UNKNOWN"
331
+
332
+
333
+ # ────────────────────────────────────────────────────────────────────────────
334
+ # PII DETECTION
335
+ # ────────────────────────────────────────────────────────────────────────────
336
+
337
+ PII_PATTERNS = {
338
+ "EMAIL": re.compile(r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b"),
339
+ "AWS_ACCESS_KEY": re.compile(r"\bAKIA[0-9A-Z]{16}\b"),
340
+ "AWS_SECRET_KEY": re.compile(
341
+ r"(?i)aws[_\- ]?(?:secret|access)[_\- ]?(?:access)?[_\- ]?key['\"\s:=]+"
342
+ r"([0-9a-zA-Z/+]{40})\b"
343
+ ),
344
+ "GITHUB_TOKEN": re.compile(r"\bgh[psru]_[A-Za-z0-9_]{36,255}\b"),
345
+ "GITLAB_TOKEN": re.compile(r"\bglpat-[A-Za-z0-9_-]{20}\b"),
346
+ "STRIPE_KEY": re.compile(r"\b(?:sk|pk|rk)_(?:live|test)_[0-9a-zA-Z]{24,99}\b"),
347
+ "SLACK_TOKEN": re.compile(r"\bxox[baprs]-[0-9a-zA-Z-]{10,}\b"),
348
+ "GOOGLE_API_KEY": re.compile(r"\bAIza[0-9A-Za-z\-_]{35}\b"),
349
+ "JWT": re.compile(
350
+ r"\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b"
351
+ ),
352
+ "PRIVATE_KEY": re.compile(
353
+ r"-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP |ENCRYPTED )?PRIVATE KEY-----"
354
+ ),
355
+ "CREDIT_CARD": re.compile(
356
+ r"\b(?:4[0-9]{12}(?:[0-9]{3})?|5[1-5][0-9]{14}|"
357
+ r"3[47][0-9]{13}|6(?:011|5[0-9]{2})[0-9]{12})\b"
358
+ ),
359
+ "US_SSN": re.compile(r"\b\d{3}-\d{2}-\d{4}\b"),
360
+ "ZA_ID": re.compile(r"\b\d{13}\b"),
361
+ "IBAN": re.compile(r"\b[A-Z]{2}\d{2}[A-Z0-9]{10,30}\b"),
362
+ "SA_PHONE": re.compile(r"\b(?:\+27|0)[6-8][0-9]{8}\b"),
363
+ "IPV4": re.compile(
364
+ r"\b(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}"
365
+ r"(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\b"
366
+ ),
367
+ "PHONE_US": re.compile(
368
+ r"\b(?:\+1[-.\s]?)?\(?[2-9][0-9]{2}\)?[-.\s]?[2-9][0-9]{2}[-.\s]?[0-9]{4}\b"
369
+ ),
370
+ }
371
+
372
+
373
+ def luhn_valid(digits: str) -> bool:
374
+ nums = [int(c) for c in digits if c.isdigit()]
375
+ if len(nums) < 13:
376
+ return False
377
+ total = 0
378
+ for i, d in enumerate(reversed(nums)):
379
+ if i % 2 == 1:
380
+ d *= 2
381
+ if d > 9:
382
+ d -= 9
383
+ total += d
384
+ return total % 10 == 0
385
+
386
+
387
+ def detect_pii(path: Path) -> Dict[str, int]:
388
+ """Return a dict mapping entity type → count. Never returns raw values."""
389
+ if path.suffix.lower() not in TEXT_EXTENSIONS:
390
+ return {}
391
+ try:
392
+ raw = path.read_bytes()[:2 * 1024 * 1024]
393
+ except OSError:
394
+ return {}
395
+ text = raw.decode("utf-8", errors="ignore")
396
+
397
+ counts: Dict[str, int] = {}
398
+ for kind, pattern in PII_PATTERNS.items():
399
+ matches = pattern.findall(text)
400
+ if not matches:
401
+ continue
402
+ if kind == "CREDIT_CARD":
403
+ matches = [m for m in matches if luhn_valid(m)]
404
+ if kind == "ZA_ID":
405
+ matches = [m for m in matches if luhn_valid(m)]
406
+ if kind == "IPV4":
407
+ matches = [
408
+ m for m in matches
409
+ if not m.startswith(("127.", "0.", "255.", "10.", "192.168.", "169.254."))
410
+ and not m.startswith("172.")
411
+ ]
412
+ if matches:
413
+ counts[kind] = len(matches)
414
+ return counts
415
+
416
+
417
+ # ────────────────────────────────────────────────────────────────────────────
418
+ # FILESYSTEM SCAN
419
+ # ────────────────────────────────────────────────────────────────────────────
420
+
421
+ # Directories to skip entirely. Any directory named in this set is pruned
422
+ # before its contents are examined.
423
+ DEFAULT_SKIP = {
424
+ ".git", ".hg", ".svn",
425
+ "node_modules", "__pycache__", ".venv", "venv", "env",
426
+ ".idea", ".vscode", ".vs",
427
+ "dist", "build", "target", "out", "bin", "obj",
428
+ ".next", ".nuxt", ".cache", ".parcel-cache",
429
+ ".pytest_cache", ".mypy_cache", ".ruff_cache", ".tox",
430
+ ".gradle", ".mvn",
431
+ "checkpoints", "runs", "logs",
432
+ ".huggingface", "wandb", ".ipynb_checkpoints",
433
+ "site-packages",
434
+ }
435
+
436
+ # Filenames to skip. The scanner never reports findings against its own
437
+ # source file — doing so would match its pattern tables against themselves
438
+ # and produce false positives on every scan.
439
+ SCANNER_FILENAMES = {
440
+ "globalproof.py",
441
+ "globalproof",
442
+ "globalproof.pyc",
443
+ }
444
+
445
+ MAX_FILE_BYTES = 25 * 1024 * 1024 # 25 MB
446
+
447
+
448
+ def load_ignore_patterns(root: Path) -> List[re.Pattern]:
449
+ patterns: List[re.Pattern] = []
450
+ ignore_file = root / ".globalproofignore"
451
+ if not ignore_file.is_file():
452
+ return patterns
453
+ try:
454
+ lines = ignore_file.read_text().splitlines()
455
+ except OSError:
456
+ return patterns
457
+ for line in lines:
458
+ line = line.strip()
459
+ if not line or line.startswith("#"):
460
+ continue
461
+ regex = re.escape(line).replace(r"\*", ".*").replace(r"\?", ".")
462
+ try:
463
+ patterns.append(re.compile(regex))
464
+ except re.error:
465
+ continue
466
+ return patterns
467
+
468
+
469
+ def is_ignored(rel_path: str, extra: List[re.Pattern]) -> bool:
470
+ for pat in extra:
471
+ if pat.search(rel_path):
472
+ return True
473
+ return False
474
+
475
+
476
+ def is_scanner_source(name: str, data: bytes) -> bool:
477
+ """True if the file is the GlobalProof scanner itself (or a copy).
478
+
479
+ Two independent checks:
480
+ 1. The filename matches a known scanner filename.
481
+ 2. The file content contains the unique self-identification marker.
482
+
483
+ Either match means the file is the scanner and must be skipped.
484
+ """
485
+ if name.lower() in SCANNER_FILENAMES:
486
+ return True
487
+ if GLOBALPROOF_SELF_MARKER in data[:65536]:
488
+ return True
489
+ return False
490
+
491
+
492
+ def sha256_bytes(b: bytes) -> str:
493
+ return hashlib.sha256(b).hexdigest()
494
+
495
+
496
+ def sha256_str(s: str) -> str:
497
+ return hashlib.sha256(s.encode("utf-8")).hexdigest()
498
+
499
+
500
+ def scan_directory(root: Path, verbose: bool = False,
501
+ no_self_skip: bool = False) -> Dict[str, Any]:
502
+ """Walk root and produce the scan manifest.
503
+
504
+ Never reads more than MAX_FILE_BYTES per file. Never sends file content.
505
+ """
506
+ if not root.is_dir():
507
+ die("Not a directory: %s" % root)
508
+
509
+ extra_ignore = load_ignore_patterns(root)
510
+ artifacts: List[Dict[str, Any]] = []
511
+ pii_totals: Dict[str, int] = {}
512
+ license_by_risk: Dict[str, int] = {}
513
+ license_by_id: Dict[str, int] = {}
514
+ copyleft_findings: List[Dict[str, Any]] = []
515
+ pii_findings: List[Dict[str, Any]] = []
516
+ total_bytes = 0
517
+ file_count = 0
518
+ skipped_large = 0
519
+ skipped_self = 0
520
+
521
+ for dirpath, dirnames, filenames in os.walk(root):
522
+ dirnames[:] = sorted(
523
+ d for d in dirnames
524
+ if d not in DEFAULT_SKIP and not d.startswith(".")
525
+ )
526
+ for name in sorted(filenames):
527
+ if name.startswith("."):
528
+ continue
529
+ path = Path(dirpath) / name
530
+ rel = str(path.relative_to(root)).replace("\\", "/")
531
+ if is_ignored(rel, extra_ignore):
532
+ continue
533
+ try:
534
+ size = path.stat().st_size
535
+ except OSError:
536
+ continue
537
+ if size > MAX_FILE_BYTES:
538
+ skipped_large += 1
539
+ if verbose:
540
+ print(" skip (too large): %s" % rel, file=sys.stderr)
541
+ continue
542
+ try:
543
+ data = path.read_bytes()
544
+ except OSError:
545
+ continue
546
+
547
+ if not no_self_skip and is_scanner_source(name, data):
548
+ skipped_self += 1
549
+ if verbose:
550
+ print(" skip (scanner source): %s" % rel, file=sys.stderr)
551
+ continue
552
+
553
+ license_id, risk = detect_license(path)
554
+ pii = detect_pii(path)
555
+
556
+ artifacts.append({
557
+ "path": rel,
558
+ "sha256": sha256_bytes(data),
559
+ "license": license_id,
560
+ "bytes": len(data),
561
+ })
562
+ total_bytes += len(data)
563
+ file_count += 1
564
+
565
+ license_by_risk[risk] = license_by_risk.get(risk, 0) + 1
566
+ if license_id and license_id != "UNKNOWN":
567
+ license_by_id[license_id] = license_by_id.get(license_id, 0) + 1
568
+
569
+ if risk in ("NETWORK_COPYLEFT", "STRONG_COPYLEFT", "WEAK_COPYLEFT"):
570
+ copyleft_findings.append({
571
+ "path": rel,
572
+ "licenseId": license_id,
573
+ "risk": risk,
574
+ })
575
+
576
+ for kind, n in pii.items():
577
+ pii_totals[kind] = pii_totals.get(kind, 0) + n
578
+ pii_findings.append({
579
+ "path": rel,
580
+ "entityType": kind,
581
+ "count": n,
582
+ })
583
+
584
+ if not artifacts:
585
+ die("No files found in %s (after ignore rules)." % root)
586
+
587
+ if skipped_large:
588
+ print(" (skipped %d file(s) larger than %d MB)"
589
+ % (skipped_large, MAX_FILE_BYTES // (1024 * 1024)))
590
+ if skipped_self:
591
+ print(" (skipped %d scanner source file(s))" % skipped_self)
592
+
593
+ return {
594
+ "artifacts": artifacts,
595
+ "file_count": file_count,
596
+ "total_bytes": total_bytes,
597
+ "license_by_risk": license_by_risk,
598
+ "license_by_id": license_by_id,
599
+ "pii_totals": pii_totals,
600
+ "copyleft_findings": copyleft_findings,
601
+ "pii_findings": pii_findings,
602
+ }
603
+
604
+
605
+ # ────────────────────────────────────────────────────────────────────────────
606
+ # HELPERS
607
+ # ────────────────────────────────────────────────────────────────────────────
608
+
609
+ def _unwrap_page(resp: dict) -> List[dict]:
610
+ data = resp.get("data", resp)
611
+ if isinstance(data, list):
612
+ return data
613
+ if isinstance(data, dict):
614
+ for key in ("content", "items", "results"):
615
+ if isinstance(data.get(key), list):
616
+ return data[key]
617
+ return []
618
+
619
+
620
+ def _extract_token(resp: dict) -> Optional[str]:
621
+ for container in (resp.get("data"), resp):
622
+ if isinstance(container, dict):
623
+ for key in ("accessToken", "access_token", "token"):
624
+ v = container.get(key)
625
+ if isinstance(v, str) and v:
626
+ return v
627
+ return None
628
+
629
+
630
+ def _highest_risk(by_risk: Dict[str, int]) -> Optional[str]:
631
+ order = ["NETWORK_COPYLEFT", "STRONG_COPYLEFT", "WEAK_COPYLEFT",
632
+ "PROPRIETARY", "PERMISSIVE"]
633
+ for r in order:
634
+ if by_risk.get(r):
635
+ return r
636
+ return None
637
+
638
+
639
+ def _human_bytes(n: int) -> str:
640
+ for unit in ("B", "KB", "MB", "GB", "TB"):
641
+ if n < 1024 or unit == "TB":
642
+ return "%.1f %s" % (n, unit) if unit != "B" else "%d B" % n
643
+ n /= 1024.0
644
+ return "%d B" % n
645
+
646
+
647
+ def _clear_scan_context_if_user_changed(c: Dict[str, Any], email: str) -> None:
648
+ """Preserve scan/passport context when re-logging in as the same user."""
649
+ previous = c.get("email")
650
+ if previous and previous != email:
651
+ for k in ("lastScanId", "lastProjectId", "lastPassportId"):
652
+ c.pop(k, None)
653
+
654
+
655
+ # ────────────────────────────────────────────────────────────────────────────
656
+ # COMMANDS — AUTH
657
+ # ────────────────────────────────────────────────────────────────────────────
658
+
659
+ def cmd_register(a) -> None:
660
+ c = load()
661
+ email = a.email or input("Email: ").strip()
662
+ password = a.password or (getpass.getpass("Password: ") if getpass else "")
663
+ if not email or not password:
664
+ die("Email and password required.")
665
+ base = a.api_url or get_base(c)
666
+ r = api("POST", "/api/v1/auth/register", {
667
+ "email": email,
668
+ "password": password,
669
+ "displayName": email.split("@")[0],
670
+ "acceptedTerms": True,
671
+ }, base=base)
672
+ tok = _extract_token(r)
673
+ if not tok:
674
+ die("Registration succeeded but no access token in response.")
675
+ _clear_scan_context_if_user_changed(c, email)
676
+ c.update({"baseUrl": base, "token": tok, "email": email})
677
+ save(c)
678
+ print("OK Registered %s" % email)
679
+
680
+
681
+ def cmd_login(a) -> None:
682
+ c = load()
683
+ email = a.email or input("Email: ").strip()
684
+ password = a.password or (getpass.getpass("Password: ") if getpass else "")
685
+ if not email or not password:
686
+ die("Email and password required.")
687
+ base = a.api_url or get_base(c)
688
+ r = api("POST", "/api/v1/auth/login",
689
+ {"email": email, "password": password}, base=base)
690
+ tok = _extract_token(r)
691
+ if not tok:
692
+ die("Login succeeded but no access token in response.")
693
+ _clear_scan_context_if_user_changed(c, email)
694
+ c.update({"baseUrl": base, "token": tok, "email": email})
695
+ save(c)
696
+ print("OK Logged in as %s" % email)
697
+
698
+
699
+ def cmd_logout(a) -> None:
700
+ c = load()
701
+ for k in ("token", "lastScanId", "lastProjectId", "lastPassportId"):
702
+ c.pop(k, None)
703
+ save(c)
704
+ print("OK Logged out.")
705
+
706
+
707
+ def cmd_status(a) -> None:
708
+ c = load()
709
+ if a.json:
710
+ safe = dict(c)
711
+ if safe.get("token"):
712
+ safe["token"] = safe["token"][:6] + "…" + safe["token"][-4:]
713
+ print(json.dumps(safe, indent=2))
714
+ return
715
+ if not c.get("token"):
716
+ print("Not logged in.")
717
+ return
718
+ base = get_base(c)
719
+ print("Email: %s" % c.get("email", "?"))
720
+ print("Backend: %s" % base)
721
+ if c.get("lastProjectId"):
722
+ print("Project: %s" % c["lastProjectId"])
723
+ if c.get("lastScanId"):
724
+ print("Scan: %s" % c["lastScanId"])
725
+ if c.get("lastPassportId"):
726
+ pid = c["lastPassportId"]
727
+ print("Passport: %s" % pid)
728
+ print("Verify: %s/public/verify/%s" % (base, pid))
729
+ print("Verify JSON:%s/public/verify/%s.json" % (base, pid))
730
+ print("Config: %s" % CFG)
731
+
732
+
733
+ # ────────────────────────────────────────────────────────────────────────────
734
+ # COMMANDS — SCAN
735
+ # ────────────────────────────────────────────────────────────────────────────
736
+
737
+ def cmd_scan(a) -> None:
738
+ c = load()
739
+ tok = require_token(c)
740
+ base = a.api_url or get_base(c)
741
+
742
+ root = Path(a.directory).expanduser().resolve()
743
+ if not root.is_dir():
744
+ die("Not a directory: %s" % root)
745
+
746
+ project = a.project or ("proj-" + secrets.token_hex(3))
747
+
748
+ print()
749
+ print(" Project: %s" % project)
750
+ print(" Directory: %s" % root)
751
+ print()
752
+
753
+ try:
754
+ api("POST", "/api/v1/projects", {
755
+ "projectId": project,
756
+ "displayName": root.name,
757
+ "description": "CLI scan",
758
+ }, tok, base=base)
759
+ except ApiError as e:
760
+ if e.status in (409, 422):
761
+ pass
762
+ elif e.session_expired:
763
+ die("Session expired. Run: globalproof login --email <your-email>")
764
+ else:
765
+ die("Could not create project: HTTP %d %s" % (e.status, e.message))
766
+
767
+ print(" Scanning files...")
768
+ result = scan_directory(root, verbose=a.verbose,
769
+ no_self_skip=getattr(a, "no_self_skip", False))
770
+
771
+ file_count = result["file_count"]
772
+ total_bytes = result["total_bytes"]
773
+ license_by_risk = result["license_by_risk"]
774
+ license_by_id = result["license_by_id"]
775
+ pii_totals = result["pii_totals"]
776
+ copyleft_findings = result["copyleft_findings"]
777
+ pii_findings = result["pii_findings"]
778
+ artifacts = result["artifacts"]
779
+
780
+ manifest = json.dumps(
781
+ {"schemaVersion": "1.0", "artifacts": artifacts},
782
+ separators=(",", ":"),
783
+ sort_keys=True,
784
+ )
785
+ manifest_hash = "SHA256:" + sha256_str(manifest)
786
+
787
+ copyleft_count = len(copyleft_findings)
788
+ pii_count = sum(pii_totals.values())
789
+
790
+ summary = {
791
+ "totalFindings": copyleft_count + pii_count,
792
+ "piiByType": pii_totals,
793
+ "licenseByRisk": {k: v for k, v in license_by_risk.items() if k != "UNKNOWN"},
794
+ "licenseById": license_by_id,
795
+ "copyleftLicenses": copyleft_findings,
796
+ "piiFindings": pii_findings,
797
+ "highestPiiSeverity": None,
798
+ "highestLicenseRisk": _highest_risk(license_by_risk),
799
+ "detectors": [
800
+ "cli-python-%s" % __version__,
801
+ "license-regex-v1",
802
+ "pii-regex-v1",
803
+ ],
804
+ }
805
+
806
+ print()
807
+ print(" %d files, %s" % (file_count, _human_bytes(total_bytes)))
808
+ print()
809
+ if license_by_risk:
810
+ print(" License risk:")
811
+ for k in ("NETWORK_COPYLEFT", "STRONG_COPYLEFT", "WEAK_COPYLEFT",
812
+ "PROPRIETARY", "PERMISSIVE", "UNKNOWN"):
813
+ n = license_by_risk.get(k, 0)
814
+ if n:
815
+ marker = "!!" if k in ("STRONG_COPYLEFT", "NETWORK_COPYLEFT") else " "
816
+ print(" %s %5d %s" % (marker, n, k))
817
+ if pii_totals:
818
+ print(" PII entities:")
819
+ for k in sorted(pii_totals):
820
+ print(" %5d %s" % (pii_totals[k], k))
821
+ if not license_by_risk and not pii_totals:
822
+ print(" No findings.")
823
+ print()
824
+ print(" Manifest: %s" % manifest_hash)
825
+
826
+ scan_id = "sc_" + secrets.token_hex(8)
827
+ print()
828
+ print(" Uploading scan %s ..." % scan_id)
829
+
830
+ try:
831
+ resp = api("POST", "/api/v1/scans", {
832
+ "projectId": project,
833
+ "scanId": scan_id,
834
+ "scanManifestHash": manifest_hash,
835
+ "canonicalManifestJson": manifest,
836
+ "fileCount": file_count,
837
+ "totalBytes": total_bytes,
838
+ "findingSummary": summary,
839
+ "scannerCliVersion": __version__,
840
+ }, tok, base=base)
841
+ except ApiError as e:
842
+ if e.session_expired:
843
+ die("Session expired. Run: globalproof login --email <your-email>")
844
+ die("Upload failed: HTTP %d %s" % (e.status, e.message))
845
+
846
+ data = resp.get("data") or resp
847
+ real_id = data.get("scanId") or data.get("id") or scan_id
848
+ if real_id != scan_id:
849
+ print(" (server returned existing scan: %s)" % real_id)
850
+ print(" note: deduplicated scans may belong to a different project;")
851
+ print(" if passport issuance 404s, pass --project explicitly.")
852
+
853
+ c["lastScanId"] = real_id
854
+ c["lastProjectId"] = project
855
+ c.pop("lastPassportId", None)
856
+ save(c)
857
+
858
+ print()
859
+ print(" OK Scan accepted: %s" % real_id)
860
+ print()
861
+ print(" Next: globalproof passport")
862
+
863
+
864
+ # ────────────────────────────────────────────────────────────────────────────
865
+ # COMMANDS — PASSPORT
866
+ # ────────────────────────────────────────────────────────────────────────────
867
+
868
+ def cmd_passport(a) -> None:
869
+ c = load()
870
+ tok = require_token(c)
871
+ base = a.api_url or get_base(c)
872
+
873
+ scan_id = a.scan_id or c.get("lastScanId")
874
+ project = a.project or c.get("lastProjectId")
875
+ if not scan_id or not project:
876
+ die("No scan in config. Run: globalproof scan <dir>")
877
+
878
+ print(" Issuing passport for scan %s (project %s) ..." % (scan_id, project))
879
+
880
+ try:
881
+ resp = api("POST", "/api/v1/passports", {
882
+ "projectId": project,
883
+ "scanId": scan_id,
884
+ "expiresInMonths": a.expires_in_months,
885
+ }, tok, base=base)
886
+ except ApiError as e:
887
+ if e.session_expired:
888
+ die("Session expired. Run: globalproof login --email <your-email>")
889
+ die("Passport issuance failed: HTTP %d %s" % (e.status, e.message))
890
+
891
+ data = resp.get("data") or resp
892
+ pid = data.get("passportId") or data.get("id")
893
+ if not pid:
894
+ die("No passportId in response:\n" + json.dumps(resp, indent=2))
895
+
896
+ c["lastPassportId"] = pid
897
+ save(c)
898
+
899
+ if a.json:
900
+ print(json.dumps(resp, indent=2))
901
+ return
902
+
903
+ print()
904
+ print(" OK Passport: %s" % pid)
905
+ print()
906
+ print(" Verify (page): %s/public/verify/%s" % (base, pid))
907
+ print(" Verify (JSON): %s/public/verify/%s.json" % (base, pid))
908
+ print()
909
+ print(" Download PDF: globalproof pdf %s" % pid)
910
+ print(" Show details: globalproof show %s" % pid)
911
+ print(" Revoke: globalproof revoke %s" % pid)
912
+
913
+
914
+ def cmd_pdf(a) -> None:
915
+ c = load()
916
+ tok = require_token(c)
917
+ base = a.api_url or get_base(c)
918
+ pid = a.passport_id or c.get("lastPassportId")
919
+ if not pid:
920
+ die("No passport id. Pass one, or run: globalproof passport")
921
+
922
+ out = Path(a.output) if a.output else Path("%s.pdf" % pid)
923
+ try:
924
+ body = api_download("/api/v1/passports/%s/pdf" % pid, tok, base=base)
925
+ except ApiError as e:
926
+ if e.session_expired:
927
+ die("Session expired. Run: globalproof login --email <your-email>")
928
+ die("PDF download failed: HTTP %d %s" % (e.status, e.message))
929
+ out.write_bytes(body)
930
+
931
+ is_pdf = body[:5] == b"%PDF-"
932
+ if not is_pdf:
933
+ print("Warning: response is not a PDF (head=%r)" % body[:16], file=sys.stderr)
934
+ sys.exit(1)
935
+ print("OK Saved %s (%s)" % (out, _human_bytes(len(body))))
936
+
937
+
938
+ def cmd_verify(a) -> None:
939
+ c = load()
940
+ base = a.api_url or get_base(c)
941
+ pid = a.passport_id or c.get("lastPassportId")
942
+ if not pid:
943
+ die("No passport id. Pass one, or run: globalproof passport", 2)
944
+
945
+ try:
946
+ r = requests.get(base + "/public/verify/%s.json" % pid, timeout=30)
947
+ except requests.RequestException as e:
948
+ die("Network error: %s" % e, 2)
949
+
950
+ if r.status_code >= 400:
951
+ die("HTTP %d: %s" % (r.status_code, r.text[:300]), 2)
952
+
953
+ try:
954
+ d = r.json()
955
+ except ValueError:
956
+ die("Non-JSON response from verify endpoint.", 2)
957
+
958
+ if a.json:
959
+ print(json.dumps(d, indent=2))
960
+ sys.exit(0 if d.get("valid") else 1)
961
+
962
+ ok = bool(d.get("valid"))
963
+ print("%s %s" % ("VALID " if ok else "INVALID", pid))
964
+ for label, key in [
965
+ ("status", "status"),
966
+ ("signatureValid", "signatureValid"),
967
+ ("timestampValid", "timestampValid"),
968
+ ("expired", "expired"),
969
+ ("issuedAt", "issuedAt"),
970
+ ("expiresAt", "expiresAt"),
971
+ ]:
972
+ print(" %-16s %s" % (label + ":", d.get(key)))
973
+ iss = d.get("issuer") or {}
974
+ print(" %-16s %s" % ("keyId:", iss.get("signingKeyId")))
975
+ print(" %-16s %s" % ("fingerprint:", iss.get("publicKeyFingerprint")))
976
+ for w in (d.get("warnings") or []):
977
+ print(" warning: %s" % w)
978
+ sys.exit(0 if ok else 1)
979
+
980
+
981
+ def cmd_show(a) -> None:
982
+ c = load()
983
+ tok = require_token(c)
984
+ base = a.api_url or get_base(c)
985
+ pid = a.passport_id or c.get("lastPassportId")
986
+ if not pid:
987
+ die("No passport id. Pass one, or run: globalproof passport")
988
+ try:
989
+ r = api("GET", "/api/v1/passports/" + pid, tok=tok, base=base)
990
+ except ApiError as e:
991
+ if e.session_expired:
992
+ die("Session expired. Run: globalproof login --email <your-email>")
993
+ die("HTTP %d: %s" % (e.status, e.message))
994
+ print(json.dumps(r, indent=2))
995
+
996
+
997
+ def cmd_revoke(a) -> None:
998
+ c = load()
999
+ tok = require_token(c)
1000
+ base = a.api_url or get_base(c)
1001
+ pid = a.passport_id or c.get("lastPassportId")
1002
+ if not pid:
1003
+ die("No passport id. Pass one, or run: globalproof passport")
1004
+ reason = a.reason or "revoked by user"
1005
+ try:
1006
+ r = api("POST", "/api/v1/passports/%s/revoke" % pid, {"reason": reason},
1007
+ tok, base=base)
1008
+ except ApiError as e:
1009
+ if e.session_expired:
1010
+ die("Session expired. Run: globalproof login --email <your-email>")
1011
+ die("Revoke failed: HTTP %d %s" % (e.status, e.message))
1012
+ print("OK Revoked %s (%s)" % (pid, reason))
1013
+ if a.json:
1014
+ print(json.dumps(r, indent=2))
1015
+
1016
+
1017
+ # ────────────────────────────────────────────────────────────────────────────
1018
+ # COMMANDS — LIST
1019
+ # ────────────────────────────────────────────────────────────────────────────
1020
+
1021
+ def cmd_scans(a) -> None:
1022
+ c = load()
1023
+ tok = require_token(c)
1024
+ base = a.api_url or get_base(c)
1025
+ try:
1026
+ r = api("GET", "/api/v1/scans?page=0&size=%d" % a.limit, tok=tok, base=base)
1027
+ except ApiError as e:
1028
+ if e.session_expired:
1029
+ die("Session expired. Run: globalproof login --email <your-email>")
1030
+ die("HTTP %d: %s" % (e.status, e.message))
1031
+ items = _unwrap_page(r)
1032
+ if a.json:
1033
+ print(json.dumps(items, indent=2))
1034
+ return
1035
+ if not items:
1036
+ print("(no scans)")
1037
+ return
1038
+ for s in items:
1039
+ print(" %-20s %-20s %5s files %10s %s"
1040
+ % (s.get("scanId", "?"),
1041
+ s.get("projectId", "?"),
1042
+ s.get("fileCount", "?"),
1043
+ _human_bytes(int(s.get("totalBytes", 0) or 0)),
1044
+ s.get("createdAt", "")))
1045
+
1046
+
1047
+ def cmd_passports(a) -> None:
1048
+ c = load()
1049
+ tok = require_token(c)
1050
+ base = a.api_url or get_base(c)
1051
+ try:
1052
+ r = api("GET", "/api/v1/passports?page=0&size=%d" % a.limit, tok=tok, base=base)
1053
+ except ApiError as e:
1054
+ if e.session_expired:
1055
+ die("Session expired. Run: globalproof login --email <your-email>")
1056
+ die("HTTP %d: %s" % (e.status, e.message))
1057
+ items = _unwrap_page(r)
1058
+ if a.json:
1059
+ print(json.dumps(items, indent=2))
1060
+ return
1061
+ if not items:
1062
+ print("(no passports)")
1063
+ return
1064
+ for p in items:
1065
+ print(" %-28s %-10s %s expires %s"
1066
+ % (p.get("passportId", "?"),
1067
+ p.get("status", "?"),
1068
+ p.get("issuedAt", ""),
1069
+ p.get("expiresAt", "")))
1070
+
1071
+
1072
+ def cmd_projects(a) -> None:
1073
+ c = load()
1074
+ tok = require_token(c)
1075
+ base = a.api_url or get_base(c)
1076
+ try:
1077
+ r = api("GET", "/api/v1/projects?page=0&size=%d" % a.limit, tok=tok, base=base)
1078
+ except ApiError as e:
1079
+ if e.session_expired:
1080
+ die("Session expired. Run: globalproof login --email <your-email>")
1081
+ die("HTTP %d: %s" % (e.status, e.message))
1082
+ items = _unwrap_page(r)
1083
+ if a.json:
1084
+ print(json.dumps(items, indent=2))
1085
+ return
1086
+ if not items:
1087
+ print("(no projects)")
1088
+ return
1089
+ for p in items:
1090
+ print(" %-24s %s" % (p.get("projectId", "?"),
1091
+ p.get("displayName", "")))
1092
+
1093
+
1094
+ # ────────────────────────────────────────────────────────────────────────────
1095
+ # ARGUMENT PARSING
1096
+ # ────────────────────────────────────────────────────────────────────────────
1097
+
1098
+ def build_parser() -> argparse.ArgumentParser:
1099
+ common = argparse.ArgumentParser(add_help=False)
1100
+ common.add_argument("--json", action="store_true",
1101
+ help="Emit machine-readable JSON output.")
1102
+ common.add_argument("--api-url", default=None,
1103
+ help="Override backend URL (default: %s or config)."
1104
+ % DEFAULT_BASE)
1105
+ common.add_argument("--verbose", "-v", action="store_true",
1106
+ help="Verbose progress output.")
1107
+
1108
+ p = argparse.ArgumentParser(
1109
+ prog="globalproof",
1110
+ description="Local-first AI training data compliance scanner.",
1111
+ epilog="Docs: https://globalproof.ai",
1112
+ )
1113
+ p.add_argument("--version", action="version", version="globalproof %s" % __version__)
1114
+
1115
+ sub = p.add_subparsers(dest="cmd", required=True)
1116
+
1117
+ s = sub.add_parser("register", parents=[common], help="Create a new account.")
1118
+ s.add_argument("--email")
1119
+ s.add_argument("--password")
1120
+ s.set_defaults(f=cmd_register)
1121
+
1122
+ s = sub.add_parser("login", parents=[common], help="Log in to an existing account.")
1123
+ s.add_argument("--email")
1124
+ s.add_argument("--password")
1125
+ s.set_defaults(f=cmd_login)
1126
+
1127
+ s = sub.add_parser("logout", parents=[common], help="Remove stored credentials.")
1128
+ s.set_defaults(f=cmd_logout)
1129
+
1130
+ s = sub.add_parser("status", parents=[common], help="Show current login status.")
1131
+ s.set_defaults(f=cmd_status)
1132
+
1133
+ s = sub.add_parser("scan", parents=[common], help="Scan a directory.")
1134
+ s.add_argument("directory")
1135
+ s.add_argument("--project", help="Project identifier (default: random).")
1136
+ s.add_argument("--no-self-skip", action="store_true", dest="no_self_skip",
1137
+ help="Disable the scanner self-skip heuristic (debugging only).")
1138
+ s.set_defaults(f=cmd_scan)
1139
+
1140
+ s = sub.add_parser("passport", parents=[common], help="Issue a passport for the last scan.")
1141
+ s.add_argument("scan_id", nargs="?")
1142
+ s.add_argument("--project")
1143
+ s.add_argument("--expires-in-months", type=int, default=12, dest="expires_in_months")
1144
+ s.set_defaults(f=cmd_passport)
1145
+
1146
+ s = sub.add_parser("pdf", parents=[common], help="Download the passport PDF.")
1147
+ s.add_argument("passport_id", nargs="?")
1148
+ s.add_argument("-o", "--output")
1149
+ s.set_defaults(f=cmd_pdf)
1150
+
1151
+ s = sub.add_parser("verify", parents=[common], help="Verify a passport (public).")
1152
+ s.add_argument("passport_id", nargs="?")
1153
+ s.set_defaults(f=cmd_verify)
1154
+
1155
+ s = sub.add_parser("show", parents=[common], help="Show full passport details (auth).")
1156
+ s.add_argument("passport_id", nargs="?")
1157
+ s.set_defaults(f=cmd_show)
1158
+
1159
+ s = sub.add_parser("revoke", parents=[common], help="Revoke a passport.")
1160
+ s.add_argument("passport_id", nargs="?")
1161
+ s.add_argument("--reason")
1162
+ s.set_defaults(f=cmd_revoke)
1163
+
1164
+ s = sub.add_parser("scans", parents=[common], help="List scans.")
1165
+ s.add_argument("--limit", type=int, default=20)
1166
+ s.set_defaults(f=cmd_scans)
1167
+
1168
+ s = sub.add_parser("passports", parents=[common], help="List passports.")
1169
+ s.add_argument("--limit", type=int, default=20)
1170
+ s.set_defaults(f=cmd_passports)
1171
+
1172
+ s = sub.add_parser("projects", parents=[common], help="List projects.")
1173
+ s.add_argument("--limit", type=int, default=20)
1174
+ s.set_defaults(f=cmd_projects)
1175
+
1176
+ return p
1177
+
1178
+
1179
+ def main(argv: Optional[List[str]] = None) -> int:
1180
+ parser = build_parser()
1181
+ args = parser.parse_args(argv)
1182
+ try:
1183
+ args.f(args)
1184
+ return 0
1185
+ except ApiError as e:
1186
+ if e.session_expired:
1187
+ print("Session expired. Run: globalproof login --email <your-email>",
1188
+ file=sys.stderr)
1189
+ else:
1190
+ print("HTTP %d: %s" % (e.status, e.message), file=sys.stderr)
1191
+ if getattr(args, "verbose", False) and e.url:
1192
+ print(" URL: %s" % e.url, file=sys.stderr)
1193
+ return 1
1194
+ except KeyboardInterrupt:
1195
+ print("\nInterrupted.", file=sys.stderr)
1196
+ return 130
1197
+ except BrokenPipeError:
1198
+ return 0
1199
+
1200
+
1201
+ if __name__ == "__main__":
1202
+ sys.exit(main())
@@ -0,0 +1,57 @@
1
+ Metadata-Version: 2.4
2
+ Name: globalproof-ai
3
+ Version: 0.2.3
4
+ Summary: Local-first AI training data compliance scanner
5
+ Author-email: Mandla Lucky <mandlalucky99@gmail.com>
6
+ License: Apache-2.0
7
+ Project-URL: Homepage, https://github.com/globalproof/globalproof
8
+ Project-URL: Documentation, https://github.com/globalproof/globalproof#readme
9
+ Project-URL: Issues, https://github.com/globalproof/globalproof/issues
10
+ Keywords: ai,compliance,cli,license,pii
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: Apache Software License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Topic :: Security
17
+ Classifier: Topic :: Software Development :: Quality Assurance
18
+ Requires-Python: >=3.10
19
+ Description-Content-Type: text/markdown
20
+ Requires-Dist: requests>=2.28
21
+
22
+ # GlobalProof
23
+
24
+ Local-first AI training data compliance scanner.
25
+
26
+ Scans a dataset for open-source license contamination and PII leakage.
27
+ Only SHA-256 hashes and finding counts are sent to the backend.
28
+ Raw training data never leaves your machine.
29
+
30
+ ## Install
31
+
32
+ pip install globalproof
33
+
34
+ ## Usage
35
+
36
+ globalproof register --email you@company.com
37
+ globalproof scan ./training-data --project my-model
38
+ globalproof passport
39
+ globalproof pdf
40
+ globalproof verify
41
+
42
+ ## What it detects
43
+
44
+ - Copyleft licenses (GPL, AGPL, LGPL, MPL, SSPL)
45
+ - Undeclared open-source components
46
+ - PII (emails, phone numbers, SSNs, IBANs, API keys, credit cards)
47
+ - Provenance gaps (missing C2PA manifests)
48
+
49
+ ## Links
50
+
51
+ - [GitHub](https://github.com/globalproof/globalproof)
52
+ - [Sample passport](https://globalproof.ai/verify/gp_6bdd4e2948cfb11e)
53
+ - [Documentation](https://github.com/globalproof/globalproof#readme)
54
+
55
+ ## License
56
+
57
+ Apache-2.0
@@ -0,0 +1,6 @@
1
+ globalproof.py,sha256=oPu_Jsw4ZJ2WxFax_NoJ33lFbld-2MmIYuQQjpAf0cU,46024
2
+ globalproof_ai-0.2.3.dist-info/METADATA,sha256=_dmQUYbbe_2VlFh81xHAUOJDzbQ4enDDGkDBKX30g_I,1837
3
+ globalproof_ai-0.2.3.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
4
+ globalproof_ai-0.2.3.dist-info/entry_points.txt,sha256=AMjkQVvKkzlqdcmKqeEHAg3v2QIy8MS-OcSrSs-TpmA,49
5
+ globalproof_ai-0.2.3.dist-info/top_level.txt,sha256=tFvwfK2oT0jnQdbaPYyWRFi21j1X1Plp8HD3CirvtJc,12
6
+ globalproof_ai-0.2.3.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ globalproof = globalproof:main
@@ -0,0 +1 @@
1
+ globalproof