nbdevAuto 0.3.70__tar.gz → 0.3.73__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nbdevAuto
3
- Version: 0.3.70
3
+ Version: 0.3.73
4
4
  Summary: automating nbdev
5
5
  Author-email: Benedict Thekkel <bthekkel1@gmail.com>
6
6
  License-Expression: Apache-2.0
@@ -0,0 +1 @@
1
+ __version__ = "0.3.73"
@@ -44,6 +44,8 @@ d = { 'settings': { 'branch': 'main',
44
44
  'nbdevAuto.fleet._count': ('fleet.html#_count', 'nbdevAuto/fleet.py'),
45
45
  'nbdevAuto.fleet._fail_parts': ('fleet.html#_fail_parts', 'nbdevAuto/fleet.py'),
46
46
  'nbdevAuto.fleet._git_ok': ('fleet.html#_git_ok', 'nbdevAuto/fleet.py'),
47
+ 'nbdevAuto.fleet._hits': ('fleet.html#_hits', 'nbdevAuto/fleet.py'),
48
+ 'nbdevAuto.fleet._line_of': ('fleet.html#_line_of', 'nbdevAuto/fleet.py'),
47
49
  'nbdevAuto.fleet._porcelain': ('fleet.html#_porcelain', 'nbdevAuto/fleet.py'),
48
50
  'nbdevAuto.fleet._render': ('fleet.html#_render', 'nbdevAuto/fleet.py'),
49
51
  'nbdevAuto.fleet.ahead_of_remote': ('fleet.html#ahead_of_remote', 'nbdevAuto/fleet.py'),
@@ -54,16 +56,22 @@ d = { 'settings': { 'branch': 'main',
54
56
  'nbdevAuto.fleet.fleet': ('fleet.html#fleet', 'nbdevAuto/fleet.py'),
55
57
  'nbdevAuto.fleet.fleet_copy': ('fleet.html#fleet_copy', 'nbdevAuto/fleet.py'),
56
58
  'nbdevAuto.fleet.fleet_push': ('fleet.html#fleet_push', 'nbdevAuto/fleet.py'),
59
+ 'nbdevAuto.fleet.fleet_scan': ('fleet.html#fleet_scan', 'nbdevAuto/fleet.py'),
57
60
  'nbdevAuto.fleet.fleet_status': ('fleet.html#fleet_status', 'nbdevAuto/fleet.py'),
58
61
  'nbdevAuto.fleet.fleet_sync': ('fleet.html#fleet_sync', 'nbdevAuto/fleet.py'),
59
62
  'nbdevAuto.fleet.fleet_update': ('fleet.html#fleet_update', 'nbdevAuto/fleet.py'),
60
63
  'nbdevAuto.fleet.fleet_upload': ('fleet.html#fleet_upload', 'nbdevAuto/fleet.py'),
61
64
  'nbdevAuto.fleet.format_summary': ('fleet.html#format_summary', 'nbdevAuto/fleet.py'),
62
65
  'nbdevAuto.fleet.init_new': ('fleet.html#init_new', 'nbdevAuto/fleet.py'),
66
+ 'nbdevAuto.fleet.is_published': ('fleet.html#is_published', 'nbdevAuto/fleet.py'),
67
+ 'nbdevAuto.fleet.looks_secret': ('fleet.html#looks_secret', 'nbdevAuto/fleet.py'),
68
+ 'nbdevAuto.fleet.notebook_text': ('fleet.html#notebook_text', 'nbdevAuto/fleet.py'),
63
69
  'nbdevAuto.fleet.parse_verb': ('fleet.html#parse_verb', 'nbdevAuto/fleet.py'),
64
70
  'nbdevAuto.fleet.pull_root': ('fleet.html#pull_root', 'nbdevAuto/fleet.py'),
65
71
  'nbdevAuto.fleet.push_one': ('fleet.html#push_one', 'nbdevAuto/fleet.py'),
66
72
  'nbdevAuto.fleet.run_verb': ('fleet.html#run_verb', 'nbdevAuto/fleet.py'),
73
+ 'nbdevAuto.fleet.scan_file': ('fleet.html#scan_file', 'nbdevAuto/fleet.py'),
74
+ 'nbdevAuto.fleet.scan_one': ('fleet.html#scan_one', 'nbdevAuto/fleet.py'),
67
75
  'nbdevAuto.fleet.script_path': ('fleet.html#script_path', 'nbdevAuto/fleet.py'),
68
76
  'nbdevAuto.fleet.status_one': ('fleet.html#status_one', 'nbdevAuto/fleet.py'),
69
77
  'nbdevAuto.fleet.update_one': ('fleet.html#update_one', 'nbdevAuto/fleet.py'),
@@ -5,12 +5,15 @@ Docs: https://bthek1.github.io/nbdevAuto/fleet.html.md"""
5
5
  # AUTOGENERATED! DO NOT EDIT! File to edit: ../nbs/04_Fleet.ipynb.
6
6
 
7
7
  # %% auto #0
8
- __all__ = ['VERBS', 'Skip', 'Outcome', 'Report', 'format_summary', 'walk', 'finish', 'script_path', 'upload_one', 'fleet_upload',
9
- 'ahead_of_remote', 'push_one', 'copy_one', 'fleet_push', 'fleet_copy', 'status_one', 'fleet_status',
10
- 'fetch_all', 'update_one', 'dirty_submodules', 'init_new', 'pull_root', 'fleet_update', 'fleet_sync',
11
- 'parse_verb', 'usage', 'run_verb', 'fleet']
8
+ __all__ = ['RAW_DOC_SUFFIXES', 'KNOWN_TOKEN', 'SECRET_PATTERNS', 'ALLOW', 'RENDERED_SUFFIXES', 'TEXT_SUFFIXES', 'EMAIL',
9
+ 'RESERVED_DOMAINS', 'BULK_EMAIL_MIN', 'VERBS', 'Skip', 'Outcome', 'Report', 'format_summary', 'walk',
10
+ 'finish', 'script_path', 'upload_one', 'fleet_upload', 'ahead_of_remote', 'push_one', 'copy_one',
11
+ 'fleet_push', 'fleet_copy', 'status_one', 'fleet_status', 'fetch_all', 'update_one', 'dirty_submodules',
12
+ 'init_new', 'pull_root', 'fleet_update', 'fleet_sync', 'looks_secret', 'notebook_text', 'is_published',
13
+ 'scan_file', 'scan_one', 'fleet_scan', 'parse_verb', 'usage', 'run_verb', 'fleet']
12
14
 
13
15
  # %% ../nbs/04_Fleet.ipynb #d361ee5f-9f09-464f-aec6-0e27cec90fe9
16
+ import json
14
17
  import re
15
18
  import shutil
16
19
  import subprocess
@@ -397,9 +400,178 @@ def fleet_sync(
397
400
  rep = walk(lambda p, s: update_one(p, s, dry_run=dry_run), root=base, workers=1, style="line")
398
401
  sys.exit(finish(rep, "would update" if dry_run else "updated"))
399
402
 
403
+ # %% ../nbs/04_Fleet.ipynb #a2907233
404
+ # A finding's severity is which of these two lists a file falls in. `_quarto.yml`
405
+ # renders every notebook EXCEPT `p_*`, so "published" is a filename test, not a
406
+ # guess: a hit in a published file is live on the web, a hit in a `p_` one is
407
+ # only in git.
408
+ RAW_DOC_SUFFIXES = {".pdf", ".csv", ".ods", ".xlsx", ".xls", ".db", ".sqlite3"}
409
+
410
+ # Vendor prefixes that are a secret by construction, whatever they are assigned to.
411
+ KNOWN_TOKEN = re.compile(
412
+ r"\b(?:AKIA[0-9A-Z]{16}|gh[pousr]_[A-Za-z0-9]{20,}|glpat-[A-Za-z0-9_-]{20,}"
413
+ r"|sk-[A-Za-z0-9]{20,}|xox[baprs]-[A-Za-z0-9-]{10,})\b")
414
+
415
+ SECRET_PATTERNS = [
416
+ # The header alone is not a key. Every VPN and SSH page in the fleet shows the
417
+ # armour wrapped around a "(Insert Client Key)" placeholder, so real base64
418
+ # key material has to follow before this counts.
419
+ ("private key", re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----\s*[\\n\s]*"
420
+ r"[A-Za-z0-9+/]{40,}")),
421
+ ("known token", KNOWN_TOKEN),
422
+ ("password hash", re.compile(r"\b(?:pbkdf2_sha256|argon2[a-z]*)\$"
423
+ r"[^\s\"']*\$[A-Za-z0-9+/]{16,}")),
424
+ ("tax file no", re.compile(r"(?i)\btax file number\b[^\n]{0,20}?\b\d{8,9}\b")),
425
+ ("passport no", re.compile(r"(?i)\bpassport\b[^\n]{0,30}?\b[A-Z]{1,2}\d{7}\b")),
426
+ ("hex secret", re.compile(r"(?i)\b(?:secret|token|api[_ -]?key|client[_ -]?id)\b"
427
+ r"\W{0,4}\b([0-9a-f]{32,})\b")),
428
+ ("credential", re.compile(r"(?i)\b(?:password|passwd|api[_ -]?key|secret|token)\b"
429
+ r"\s*[:=]\s*[\"']?([^\s\"',}{]{8,})")),
430
+ ]
431
+
432
+ def looks_secret(v):
433
+ """Is this captured value a literal secret, or just code that mentions one?
434
+
435
+ The credential regex cannot tell `token = 'b4bTbQ...'` from
436
+ `password = forms.CharField(...)`, and the second is what every Django,
437
+ Stripe and Grafana page in the fleet is FULL of. Ruling those out is the
438
+ whole difference between a scan worth running and one everybody ignores.
439
+
440
+ So the value has to survive four filters: be a plain literal rather than an
441
+ expression, not be an identifier or env-var name, not already be masked, and
442
+ mix character classes the way a generated secret does and a word does not."""
443
+ if len(v) < 16: return False
444
+ if not re.fullmatch(r"[A-Za-z0-9_\-+/=.~]+", v): return False # an expression
445
+ if re.fullmatch(r"[A-Z0-9_]+", v): return False # ENV_VAR_NAME
446
+ if re.fullmatch(r"[A-Za-z_][\w.]*", v) and "." in v:return False # settings.API_KEY
447
+ if re.search(r"[*x]{4,}|\.{3,}", v): return False # already masked
448
+ classes = sum(bool(re.search(c, v)) for c in (r"[a-z]", r"[A-Z]", r"\d"))
449
+ return classes >= 3
450
+
451
+ # An escape hatch for the deliberately secret-shaped: this module's own test
452
+ # vectors, and a doc that has to show what a real token looks like. Put the
453
+ # marker on the same line. It is a line comment, so it survives `nbdev-export`.
454
+ ALLOW = "scan:allow"
455
+
456
+ def _line_of(text, pos):
457
+ "The whole line containing offset `pos`, so an inline marker can be seen"
458
+ return text[text.rfind("\n", 0, pos) + 1 : (text.find("\n", pos) + 1 or None)]
459
+
460
+ def _hits(text):
461
+ "Every secret-pattern name matching `text`, in pattern order, without duplicates"
462
+ out = []
463
+ for name, rx in SECRET_PATTERNS:
464
+ for m in rx.finditer(text):
465
+ if ALLOW in _line_of(text, m.start()): continue
466
+ # A pattern with a group captures the VALUE, so it can be judged.
467
+ # A pattern without one matched something already unambiguous.
468
+ if m.groups() and name == "credential" and not looks_secret(m.group(1)): continue
469
+ out.append(name); break
470
+ return out
471
+
472
+ def notebook_text(path):
473
+ """Cell sources plus TEXT outputs, with image payloads dropped.
474
+
475
+ Scanning outputs is the point, not an extra: a code cell rewritten to drop its
476
+ figures still carries the OLD numbers in its stored output, and Quarto
477
+ publishes that output verbatim. That is the exact leak issue #18 found.
478
+
479
+ Skipping `image/*` is not just speed. A notebook of plots is mostly base64,
480
+ and feeding megabytes of it to these regexes manufactures false positives out
481
+ of pure entropy."""
482
+ try: nb = json.loads(path.read_text(encoding="utf-8", errors="replace"))
483
+ except Exception: return ""
484
+ parts = []
485
+ for c in nb.get("cells", []):
486
+ parts.append("".join(c.get("source", [])))
487
+ for o in c.get("outputs", []):
488
+ parts.append("".join(o.get("text", [])))
489
+ for mime, val in (o.get("data") or {}).items():
490
+ if not mime.startswith("image/"):
491
+ parts.append("".join(val) if isinstance(val, list) else str(val))
492
+ return "\n".join(parts)
493
+
494
+ # Rendered by `_quarto.yml`, so a hit here is on the public web. Everything else
495
+ # tracked is only in git, which is a different and smaller problem.
496
+ RENDERED_SUFFIXES = {".ipynb", ".md"}
497
+ # Read for content. A binary document cannot be scanned without dragging in a
498
+ # parser, so it gets counted instead of judged.
499
+ TEXT_SUFFIXES = RENDERED_SUFFIXES | {".py", ".yml", ".yaml", ".txt", ".json", ".csv",
500
+ ".ini", ".cfg", ".toml", ".sh", ".env"}
501
+
502
+ EMAIL = re.compile(r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b")
503
+ # RFC 2606 reserves these for documentation, so a page full of them is a tutorial,
504
+ # not a contact list. Without this every mail and auth page in the fleet reports.
505
+ RESERVED_DOMAINS = re.compile(r"(?i)@(?:[\w.-]+\.)?(?:example\.(?:com|org|net)|"
506
+ r"test|localhost|invalid|example)$")
507
+ # One address is a contact detail; a column of them is somebody's user table.
508
+ BULK_EMAIL_MIN = 5
509
+
510
+ def is_published(path):
511
+ "Does the site render this file? `p_*` is excluded by `project.render` in `_quarto.yml`"
512
+ return path.suffix in RENDERED_SUFFIXES and not path.name.startswith("p_")
513
+
514
+ def scan_file(path):
515
+ "Pattern names found in `path`, or [] for nothing. Non-text files are never scanned"
516
+ if path.suffix.lower() not in TEXT_SUFFIXES: return []
517
+ text = notebook_text(path) if path.suffix == ".ipynb" else \
518
+ path.read_text(encoding="utf-8", errors="replace")
519
+ names = _hits(text)
520
+ addrs = {m.group(0).lower() for m in EMAIL.finditer(text)
521
+ if not RESERVED_DOMAINS.search(m.group(0))}
522
+ if len(addrs) >= BULK_EMAIL_MIN: names.append(f"{len(addrs)} email addresses")
523
+ return names
524
+
525
+
526
+ # %% ../nbs/04_Fleet.ipynb #55d1232c
527
+ def scan_one(path, sub=None, detail=False, nbs="nbs"):
528
+ """Secret and personal-data findings for one submodule.
529
+
530
+ Only TRACKED files are scanned. An untracked file is on this disk and nowhere
531
+ else, which is exactly where `.gitignore` is supposed to put a payslip, so
532
+ flagging it would punish the fix.
533
+
534
+ Binary documents are counted rather than read. The count is informational and
535
+ never fails the scan: a tracked PDF may be a payslip or may be a datasheet,
536
+ and this cannot tell without a parser it has no business importing."""
537
+ root = path/nbs
538
+ if not root.is_dir(): raise Skip("without nbs")
539
+ tracked = [l for l in git(path, "ls-files", "-z", nbs).split("\0") if l]
540
+ pub, tracked_hits, docs = [], [], 0
541
+ for rel in sorted(tracked):
542
+ f = path/rel
543
+ if not f.is_file(): continue
544
+ if f.suffix.lower() in RAW_DOC_SUFFIXES and f.suffix.lower() != ".csv":
545
+ docs += 1; continue
546
+ names = scan_file(f)
547
+ if not names: continue
548
+ (pub if is_published(f) else tracked_hits).append(f"{rel}: {', '.join(names)}")
549
+ if not (pub or tracked_hits): raise Skip("clean")
550
+ line = f"{len(pub):>3} published {len(tracked_hits):>3} tracked {docs:>4} binary docs"
551
+ if not detail: return [line]
552
+ return [line] + [f"PUBLISHED {h}" for h in pub] + [f"tracked {h}" for h in tracked_hits]
553
+
554
+ def fleet_scan(
555
+ detail:bool = False, # List every finding, not just the per-submodule counts
556
+ strict:bool = False, # Exit 1 on any finding, not only on a published one
557
+ root:str = None, # Superproject root (default: nearest ancestor with .gitmodules)
558
+ ):
559
+ "Find personal data and credentials in tracked files, published ones first"
560
+ style = "block" if detail else "line"
561
+ rep = walk(lambda p, s: scan_one(p, s, detail=detail), root=root,
562
+ workers=8, style=style)
563
+ code = finish(rep, "with findings")
564
+ if code: sys.exit(code)
565
+ # A published hit is live on the web and always fails. A tracked-but-unpublished
566
+ # one is a cleanup job, so it only fails under --strict.
567
+ published = sum(int(o.detail[0].split()[0]) for o in rep.ran if o.detail)
568
+ sys.exit(1 if published or (strict and rep.ran) else 0)
569
+
570
+
400
571
  # %% ../nbs/04_Fleet.ipynb #0281bd2b-91ba-4e49-90dc-d1a95d54d5a6
401
572
  VERBS = {"upload": fleet_upload, "push": fleet_push, "copy": fleet_copy,
402
- "status": fleet_status, "update": fleet_update, "sync": fleet_sync}
573
+ "status": fleet_status, "update": fleet_update, "sync": fleet_sync,
574
+ "scan": fleet_scan}
403
575
 
404
576
  def parse_verb(argv):
405
577
  "(verb, remaining args) for `fleet ...`, or None when `argv` does not name a verb"
@@ -179,7 +179,8 @@ async def gather_status(subs, token=None, root=None):
179
179
 
180
180
  # %% ../nbs/03_GitHub.ipynb #d4d8fd92-b4a2-4479-bd76-df3cc7fc9f77
181
181
  FAILED = {"failure", "timed_out", "cancelled", "action_required", "startup_failure"}
182
- RUNNING = {"in_progress", "queued", "requested", "waiting", "pending"}
182
+ RUNNING = {"in_progress", "queued", "requested", "waiting", "pending",
183
+ "building"} # "building" is the Pages build in flight
183
184
  BOLD, RED, YELLOW, CYAN, DIM, OFF = "\033[1m", "\033[31m", "\033[33m", "\033[36m", "\033[2m", "\033[0m"
184
185
 
185
186
  def _state(v):
@@ -214,7 +215,12 @@ def failures(rows):
214
215
 
215
216
  def has_failure(rows):
216
217
  "True when any workflow failed or a Pages build errored"
217
- return bool(failures(rows)) or any(r["built"] not in (None, "built") for r in rows)
218
+ # A Pages build in flight is not a verdict: "building" is the state every
219
+ # healthy deploy passes through, and counting it red made `just ci` exit 1
220
+ # on a fleet that was merely mid-build, with no red row in the table to
221
+ # explain it. Same shape as the `_latest_run` bug in issue #8: a transient
222
+ # state read as a result. Unknown states still count as failures.
223
+ return bool(failures(rows)) or any(r["built"] not in (None, "built", *RUNNING) for r in rows)
218
224
 
219
225
  # %% ../nbs/03_GitHub.ipynb #25434173-a101-4ecd-802a-f349f6233fa3
220
226
  def git(path, *args):
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nbdevAuto
3
- Version: 0.3.70
3
+ Version: 0.3.73
4
4
  Summary: automating nbdev
5
5
  Author-email: Benedict Thekkel <bthekkel1@gmail.com>
6
6
  License-Expression: Apache-2.0
@@ -1 +0,0 @@
1
- __version__ = "0.3.70"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes