@nidvue/teamwork 0.0.0-stage → 1.1.1

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.
Files changed (70) hide show
  1. package/CHANGELOG.md +343 -0
  2. package/LICENSE +661 -0
  3. package/NOTICE +16 -0
  4. package/README.md +462 -2
  5. package/SECURITY.md +64 -0
  6. package/package.json +71 -4
  7. package/prompts/teamwork-pulse.md +12 -0
  8. package/prompts/teamwork-review.md +16 -0
  9. package/skills/teamwork/AGENTS.md +462 -0
  10. package/skills/teamwork/SKILL.md +795 -0
  11. package/skills/teamwork/references/Adapters.md +112 -0
  12. package/skills/teamwork/references/Audit.md +120 -0
  13. package/skills/teamwork/references/Concurrency.md +160 -0
  14. package/skills/teamwork/references/Dissent.md +85 -0
  15. package/skills/teamwork/references/Docs.md +74 -0
  16. package/skills/teamwork/references/Engineering.md +410 -0
  17. package/skills/teamwork/references/Evidence.md +140 -0
  18. package/skills/teamwork/references/Remotes.md +144 -0
  19. package/skills/teamwork/references/Roles.md +201 -0
  20. package/skills/teamwork/references/State.md +64 -0
  21. package/skills/teamwork/references/Testing.md +144 -0
  22. package/skills/teamwork/roles/architect.md +153 -0
  23. package/skills/teamwork/roles/ceo.md +218 -0
  24. package/skills/teamwork/roles/cfo.md +150 -0
  25. package/skills/teamwork/roles/coo.md +147 -0
  26. package/skills/teamwork/roles/cto.md +162 -0
  27. package/skills/teamwork/roles/data-engineer.md +120 -0
  28. package/skills/teamwork/roles/designer.md +349 -0
  29. package/skills/teamwork/roles/developer.md +166 -0
  30. package/skills/teamwork/roles/product-manager.md +151 -0
  31. package/skills/teamwork/roles/project-manager.md +159 -0
  32. package/skills/teamwork/roles/release-engineer.md +245 -0
  33. package/skills/teamwork/roles/reviewer.md +254 -0
  34. package/skills/teamwork/roles/security-auditor.md +157 -0
  35. package/skills/teamwork/roles/technical-writer.md +121 -0
  36. package/skills/teamwork/roles/tester.md +180 -0
  37. package/skills/teamwork/scripts/build_pi_package.py +200 -0
  38. package/skills/teamwork/scripts/privacy_scan.py +1185 -0
  39. package/skills/teamwork/scripts/sync_skill.py +108 -0
  40. package/skills/teamwork/scripts/teamwork.py +28 -0
  41. package/skills/teamwork/scripts/verify_release.py +502 -0
  42. package/skills/teamwork/src/teamwork/__init__.py +18 -0
  43. package/skills/teamwork/src/teamwork/__main__.py +10 -0
  44. package/skills/teamwork/src/teamwork/bundle.py +157 -0
  45. package/skills/teamwork/src/teamwork/cli.py +16 -0
  46. package/skills/teamwork/src/teamwork/config.py +396 -0
  47. package/skills/teamwork/src/teamwork/engine.py +10119 -0
  48. package/skills/teamwork/src/teamwork/gitutil.py +172 -0
  49. package/skills/teamwork/src/teamwork/imaging.py +124 -0
  50. package/skills/teamwork/src/teamwork/io.py +144 -0
  51. package/skills/teamwork/src/teamwork/layout.py +253 -0
  52. package/skills/teamwork/src/teamwork/names.py +191 -0
  53. package/skills/teamwork/src/teamwork/portable.py +79 -0
  54. package/skills/teamwork/src/teamwork/records.py +1220 -0
  55. package/skills/teamwork/src/teamwork/resources.py +120 -0
  56. package/skills/teamwork/src/teamwork/version.py +13 -0
  57. package/skills/teamwork/templates/AGENTS.md +460 -0
  58. package/skills/teamwork/templates/Docs/Architecture.md +40 -0
  59. package/skills/teamwork/templates/Docs/Archive.md +8 -0
  60. package/skills/teamwork/templates/Docs/Board.md +28 -0
  61. package/skills/teamwork/templates/Docs/Costs.md +30 -0
  62. package/skills/teamwork/templates/Docs/Decisions.md +26 -0
  63. package/skills/teamwork/templates/Docs/Design.md +106 -0
  64. package/skills/teamwork/templates/Docs/Plan.md +24 -0
  65. package/skills/teamwork/templates/Docs/Project.md +40 -0
  66. package/skills/teamwork/templates/Docs/Release.md +43 -0
  67. package/skills/teamwork/templates/Docs/Research.md +36 -0
  68. package/skills/teamwork/templates/Docs/Risks.md +18 -0
  69. package/skills/teamwork/templates/TeamMap.md +100 -0
  70. package/skills/teamwork-pi/SKILL.md +77 -0
@@ -0,0 +1,1185 @@
1
+ #!/usr/bin/env python3
2
+ """privacy_scan.py - zero-personal-data gate for the Teamwork repository.
3
+
4
+ Scans three sources and exits non-zero on any hit:
5
+
6
+ * ``--tree`` tracked files (``git ls-files``), read from the working tree;
7
+ * ``--staged`` index content of tracked files (``git ls-files -s`` + ``git cat-file``);
8
+ * ``--history`` every blob reachable from every ref (``git rev-list --objects --all``
9
+ + ``git cat-file --batch``) **and** every commit's author/committer/message
10
+ metadata (the place a personal identity usually survives a blob-level scan).
11
+
12
+ ``--all`` combines the three. Flags combine freely.
13
+
14
+ It fails on: personal email addresses that are not role mailboxes at the neutral
15
+ identity domain; the retired private domain(s); absolute personal home paths
16
+ (``/home/<name>``, ``/Users/<name>``, ``C:\\Users\\<name>``); API keys and tokens
17
+ (``sk-``, ``ghp_``/``gho_``/``ghu_``/``ghs_``/``ghr_``, ``github_pat_``, ``AIza``,
18
+ ``wandb_``, ``xoxb-``, ``Bearer`` tokens, provider keys such as ``gsk_``/``xai-``/
19
+ ``pplx-``/``glpat-``/``npm_``/``hf_``/``sk_live_``, standalone high-entropy
20
+ secrets); private-key headers; ``.env``-style and JSON assignments of secret-named
21
+ variables; ``TODO``/``FIXME`` comments that carry credentials.
22
+
23
+ Forge-generated merge refs are not repository history. A commit whose subject is
24
+ exactly ``Merge <40-hex> into <40-hex>`` and that has two or more parents is a
25
+ pull-request merge artifact created by the forge with the PR author's identity;
26
+ its message is still scanned, but its author/committer identity is skipped so a
27
+ private mailbox on the forge account does not fail a clean repository.
28
+
29
+ It also fails on **agent-host configuration leakage**, which is the easiest way
30
+ for a local secret to reach a public tree:
31
+
32
+ * a tracked file that is private host state - ``.pi/agent/`` (auth, settings,
33
+ sessions, memory, missions, installs), ``.env``, ``auth.json``,
34
+ ``credentials.json``, ``settings.local.json``, ``.netrc``/``.npmrc``/``.pypirc``,
35
+ shell history, SSH keys, ``.aws/credentials``, ``.kube/config``,
36
+ ``.docker/config.json``, and the equivalent files of other agent hosts;
37
+ * a line that quotes a private host path (agent auth, settings or session
38
+ state) or a JSON config object that carries a live credential
39
+ (``"apiKey": "..."``).
40
+
41
+ Quoting the team workspace (``.pi/Teamwork/...``) in a document is not host
42
+ configuration, so the line rule stays quiet about it. A **tracked file under**
43
+ that workspace is local state and does fail, because the workspace itself is
44
+ never committed.
45
+
46
+ Design notes
47
+ ------------
48
+ * Stdlib only, no network, no third-party import.
49
+ * The scanner is itself published, so it must not become the leak it detects.
50
+ Private domains are therefore not stored as plain text (they are reconstructed
51
+ from code points at runtime) and matched values are **masked by default** so a
52
+ failing CI log cannot republish the secret. Unmasked values are **never**
53
+ printed: ``--reveal`` prints a stable fingerprint (SHA-256 prefix) instead and
54
+ ``--reveal-to PATH`` exports the raw values to a ``0600`` file for local
55
+ remediation only.
56
+ * The allowlist file (``--allow``) holds one regular expression per line with an
57
+ optional ``# reason`` suffix. A finding is exempt when its matched text matches
58
+ any allowlist expression. Keep it minimal and justify each entry.
59
+
60
+ Revealing a matched value
61
+ -------------------------
62
+ Unmasked values are **never** written to stdout or stderr. A log line is a
63
+ broadcast sink - CI logs are public, terminals are recorded, and ``tee``ed
64
+ output outlives the session - so the scanner has no flag that prints a secret.
65
+
66
+ * ``--reveal`` adds ``(sha256:<12 hex>)`` to each finding line. The fingerprint
67
+ is a stable, unsalted SHA-256 prefix: it identifies which finding is which and
68
+ lets an operator confirm a rotated value, but it cannot be used as the
69
+ credential.
70
+ * ``--reveal-to PATH`` writes the raw values to a file the operator names, for
71
+ local remediation only. The file is created with ``O_EXCL`` and mode ``0600``
72
+ (an existing path is a usage error, never overwritten), every line is
73
+ ``location<TAB>rule<TAB>fingerprint<TAB>value``, a warning naming the file is
74
+ printed to stderr, and a half-written file is removed on error. Delete it
75
+ after rotating the secret; never commit it, paste it into a ticket or a chat,
76
+ or leave it in a shared workspace.
77
+
78
+ Exit codes: ``0`` clean, ``1`` findings, ``2`` usage / git error.
79
+ """
80
+
81
+ from __future__ import annotations
82
+
83
+ import argparse
84
+ import hashlib
85
+ import json
86
+ import math
87
+ import os
88
+ import re
89
+ import subprocess
90
+ import sys
91
+ from collections import Counter
92
+ from pathlib import Path
93
+
94
+ SCANNER_VERSION = "1.3.0"
95
+
96
+ # --------------------------------------------------------------------------- #
97
+ # Neutral identity / allowlisted email domains
98
+ # --------------------------------------------------------------------------- #
99
+
100
+ IDENTITY_DOMAIN = os.environ.get("TEAMWORK_IDENTITY_DOMAIN", "agents.nidvue.com")
101
+
102
+ # Documentation / RFC 6761-2606 placeholders and service mailboxes. These are
103
+ # not personal data: they are reserved names used in examples and by git hosts.
104
+ _ALLOWED_EMAIL_DOMAINS = {
105
+ IDENTITY_DOMAIN,
106
+ "example.com",
107
+ "example.org",
108
+ "example.net",
109
+ "example.edu",
110
+ "invalid",
111
+ "test",
112
+ "localhost",
113
+ }
114
+ _ALLOWED_EMAIL_SUFFIXES = (
115
+ ".example.com",
116
+ ".example.org",
117
+ ".example.net",
118
+ ".invalid",
119
+ ".test",
120
+ ".localhost",
121
+ )
122
+ _ALLOWED_SERVICE_EMAILS = re.compile(
123
+ r"^(?:noreply@github\.com|support@github\.com|[^@]+@users\.noreply\.github\.com|"
124
+ r"git@(?:github\.com|gitlab\.com|bitbucket\.org))$"
125
+ )
126
+
127
+ # --------------------------------------------------------------------------- #
128
+ # Retired private domains
129
+ #
130
+ # Kept out of plain text on purpose: this file ships in the public tree and the
131
+ # history, and must not itself contain the string it is meant to detect. The
132
+ # list is built from code points and can be extended at runtime with the
133
+ # comma-separated TEAMWORK_PRIVATE_DOMAINS environment variable.
134
+ # --------------------------------------------------------------------------- #
135
+
136
+
137
+ def _decode(*codes: int) -> str:
138
+ return "".join(chr(c) for c in codes)
139
+
140
+
141
+ _DEFAULT_PRIVATE_DOMAINS = (
142
+ # The role-mailbox domain is the project's own (IDENTITY_DOMAIN, agents.nidvue.com) and
143
+ # is allowed above; the domains below are the *personal* ones that must never
144
+ # appear. The operator's decision is recorded in docs/Team/Decisions.md.
145
+ _decode(122, 104, 97, 111, 46, 110, 111), # personal identity domain
146
+ _decode(
147
+ 98, 117, 108, 98, 105, 116, 101, 99, 104, 46, 99, 111, 109
148
+ ), # personal work-mail domain
149
+ )
150
+
151
+
152
+ def _private_domains() -> list:
153
+ extra = os.environ.get("TEAMWORK_PRIVATE_DOMAINS", "")
154
+ values = [d.strip().lower() for d in extra.split(",") if d.strip()]
155
+ seen = []
156
+ for domain in list(_DEFAULT_PRIVATE_DOMAINS) + values:
157
+ if domain and domain not in seen:
158
+ seen.append(domain)
159
+ return seen
160
+
161
+
162
+ # Non-personal CI / placeholder usernames that may legitimately appear in a home
163
+ # path (GitHub Actions uses /home/runner). Everything else is reported.
164
+ _GENERIC_HOME_USERS = {
165
+ "runner",
166
+ "runneradmin",
167
+ "user",
168
+ "username",
169
+ "youruser",
170
+ "yourname",
171
+ "you",
172
+ "example",
173
+ "app",
174
+ "docker",
175
+ "vscode",
176
+ "ubuntu",
177
+ "node",
178
+ "root",
179
+ "admin",
180
+ "me",
181
+ "someone",
182
+ "teamwork",
183
+ }
184
+
185
+ # Placeholder values that are obviously not real secrets.
186
+ _PLACEHOLDERS = re.compile(
187
+ r"^(?:\$\{?[A-Za-z0-9_]+\}?|%[A-Za-z0-9_]+%|<[^>]+>|your[-_].*|"
188
+ r".*[-_]here|xxx+|\.\.\.|redacted|placeholder|dummy|fake|changeme|change_me|"
189
+ r"example|sample|test|none|null|nil|todo|secret|token|password|value|"
190
+ r"replace_me|"
191
+ r"string|str|varname)$",
192
+ re.IGNORECASE,
193
+ )
194
+
195
+ # --------------------------------------------------------------------------- #
196
+ # Detection rules
197
+ # --------------------------------------------------------------------------- #
198
+
199
+ _RE_EMAIL = re.compile(r"[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}")
200
+
201
+ _RE_HOME = re.compile(
202
+ r"(?:(?<![A-Za-z0-9_])/(?:home|Users)/([A-Za-z0-9][A-Za-z0-9._\-]*))"
203
+ r"|(?:(?<![A-Za-z0-9])[A-Za-z]:[\\/]Users[\\/]([^\\/\s\"']+))"
204
+ # WSL and container mounts of a real home directory
205
+ r"|(?:(?<![A-Za-z0-9])/mnt/[A-Za-z]/(?:Users|home)/([A-Za-z0-9][A-Za-z0-9._\-]*))"
206
+ )
207
+
208
+ # Built from fragments so the scanner's own source does not contain the
209
+ # contiguous header it is meant to detect.
210
+ _RE_PRIVATE_KEY = re.compile(
211
+ r"-{5}BEGIN (?:[A-Z0-9]+ )*PRIVATE KEY(?: BLOCK)?-{5}"
212
+ r"|-{5}BEGIN SSH2 ENCRYPTED PRIVATE KEY-{5}"
213
+ )
214
+
215
+ _RE_TOKEN = re.compile(
216
+ r"\bsk-[A-Za-z0-9_\-]{16,}"
217
+ r"|\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{20,}"
218
+ r"|\bgithub_pat_[A-Za-z0-9_]{20,}"
219
+ r"|\bAIza[0-9A-Za-z_\-]{20,}"
220
+ r"|\bwandb_[A-Za-z0-9]{20,}"
221
+ r"|\bxox[baprs]-[A-Za-z0-9\-]{10,}"
222
+ r"|\bAKIA[0-9A-Z]{16}\b"
223
+ r"|\bgsk_[A-Za-z0-9]{20,}"
224
+ r"|\bxai-[A-Za-z0-9]{20,}"
225
+ r"|\bpplx-[A-Za-z0-9]{20,}"
226
+ r"|\bnpm_[A-Za-z0-9]{30,}"
227
+ r"|\bglpat-[A-Za-z0-9_\-]{20,}"
228
+ r"|\bhf_[A-Za-z0-9]{30,}"
229
+ r"|\bsk_live_[A-Za-z0-9]{20,}"
230
+ r"|\bSG\.[A-Za-z0-9_\-]{16,}\.[A-Za-z0-9_\-]{16,}"
231
+ r"|\b(?i:Bearer)\s+[A-Za-z0-9._+/=\-]{16,}"
232
+ )
233
+
234
+ # Assignment values that contain code punctuation (calls, indexing, punctuation) are
235
+ # not credentials - they are source lines such as ``API_KEY = re.compile(...)``.
236
+ _RE_CODE_LIKE = re.compile(r"[(){}\[\]<>,;'\"\s]")
237
+ _CODE_KEYWORDS = {
238
+ "function",
239
+ "return",
240
+ "lambda",
241
+ "class",
242
+ "import",
243
+ "def",
244
+ "const",
245
+ "let",
246
+ "var",
247
+ "async",
248
+ "await",
249
+ "typeof",
250
+ "none",
251
+ "true",
252
+ "false",
253
+ "null",
254
+ "undefined",
255
+ "this",
256
+ "new",
257
+ }
258
+
259
+
260
+ _RE_DOTTED_IDENT = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)+$")
261
+
262
+
263
+ def _looks_like_secret_value(value: str) -> bool:
264
+ if value.startswith(("re.", "os.", "self.", "match.", "str(", "Path(")):
265
+ return False
266
+ # A dotted identifier is code (``tokenize.generate_tokens``), not a credential.
267
+ if _RE_DOTTED_IDENT.match(value):
268
+ return False
269
+ if value.lower() in _CODE_KEYWORDS:
270
+ return False
271
+ # Paths, URLs and pure digit strings are configuration, not credentials.
272
+ if value.startswith(("/", "\\", "http://", "https://")) or "://" in value:
273
+ return False
274
+ if value.isdigit():
275
+ return False
276
+ # A bare word such as ``candidate`` is an identifier, not a credential.
277
+ if value.isalpha() and len(value) < 24:
278
+ return False
279
+ return not bool(_RE_CODE_LIKE.search(value))
280
+
281
+
282
+ # .env-style assignment of a secret-named variable to a non-placeholder value.
283
+ # The value is restricted to a credential charset so that a call expression is
284
+ # not mistaken for an assignment.
285
+ _RE_ENV_SECRET = re.compile(
286
+ r"(?i)(?:[A-Za-z0-9_]*(?:SECRET|TOKEN|PASSWORD|PASSWD|API[_-]?KEY|"
287
+ r"ACCESS[_-]?KEY|PRIVATE[_-]?KEY|CLIENT[_-]?SECRET|AUTH[_-]?KEY|"
288
+ r"CREDENTIAL)[A-Za-z0-9_]*)\s*[:=]\s*"
289
+ r"(?:[\"']([^\"'\n]{8,})[\"']|([A-Za-z0-9+/=_\-.:@!#$%^&]{8,}))"
290
+ )
291
+
292
+ # Standalone high-entropy secret: 32+ base64-ish characters, not pure hex (a raw
293
+ # hash such as a git sha must not be reported). Hex secrets are still caught when
294
+ # they are assigned to a secret-named variable by the .env rule above.
295
+ _RE_HIGH_ENTROPY = re.compile(r"(?<![A-Za-z0-9+/=])[A-Za-z0-9+/]{32,}={0,2}(?![A-Za-z0-9+/=])")
296
+
297
+ _RE_TODO = re.compile(r"(?i)\b(?:TODO|FIXME|XXX|HACK)\b")
298
+
299
+ # A forge-created PR merge commit: "Merge <sha> into <sha>" with two parents.
300
+ _RE_FORGE_MERGE = re.compile(r"^Merge [0-9a-f]{40} into [0-9a-f]{40}$")
301
+
302
+
303
+ _RE_FORGE_MERGE_SUBJECTS = (
304
+ re.compile(r"^Merge [0-9a-f]{40} into [0-9a-f]{40}$"),
305
+ re.compile(r"^Merge pull request #\d+ from \S+$"),
306
+ re.compile(r"^Merge branch '[^']+' into '[^']+'$"),
307
+ re.compile(r"^Merge remote-tracking branch '[^']+'$"),
308
+ )
309
+
310
+
311
+ def is_forge_merge(subject: str, parents: list) -> bool:
312
+ """True for the synthetic merge ref a forge creates for a pull request."""
313
+ if len(parents) < 2:
314
+ return False
315
+ text = subject.strip()
316
+ return any(pattern.match(text) for pattern in _RE_FORGE_MERGE_SUBJECTS)
317
+
318
+
319
+ def _host_state(*parts: str) -> str:
320
+ """Join path fragments so this published file never quotes a private path."""
321
+ return "".join(parts)
322
+
323
+
324
+ # Private state of an agent host. The skill directory itself
325
+ # (``<agent-dir>/skills/<name>``) is a normal install target and is not listed.
326
+ _AGENT_CONFIG_NAMES = (
327
+ r"auth(?:[^\s\"'`]*)?|settings\.json|trust\.json|"
328
+ r"mcp(?:-auth|-adapter|-onboarding)?\.json|models(?:-store)?\.json|"
329
+ r"prompt-history\.json|sessions?|memory|missions|agents|install|vendor|npm|cache|git|bin"
330
+ )
331
+ _RE_AGENT_CONFIG = re.compile(
332
+ _host_state(r"\.pi[\\/]", r"agent[\\/]")
333
+ + r"(?:"
334
+ + _AGENT_CONFIG_NAMES
335
+ + r")(?=[\\/]|$)"
336
+ + "|"
337
+ + _host_state(r"\.claude[\\/]", "settings", r"\.local\.json")
338
+ + "|"
339
+ + _host_state(r"\.codex[\\/]", "auth", r"\.json")
340
+ + "|"
341
+ + _host_state(r"\.gemini[\\/]", "oauth_creds", r"\.json"),
342
+ re.IGNORECASE,
343
+ )
344
+
345
+ # JSON config object carrying a live credential: ``"apiKey": "..."``. A
346
+ # placeholder (``${TOKEN}``, ``<your-key>``) is filtered like ``.env`` values.
347
+ _RE_JSON_SECRET = re.compile(
348
+ r"(?i)[\"'][A-Za-z0-9_.\-]*(?:api[_-]?key|apikey|access[_-]?token|"
349
+ r"refresh[_-]?token|id[_-]?token|client[_-]?secret|private[_-]?key|secret|"
350
+ r"password|passwd|token)[\"']\s*:\s*[\"']([^\"'\n]{8,})[\"']"
351
+ )
352
+
353
+ # A tracked path that must never be published: host config, credentials, keys,
354
+ # environment files, history. ``.env.example`` and friends stay allowed.
355
+ _SENSITIVE_NAME = re.compile(
356
+ r"(?i)^(?:\.env(?:\.(?!example$|sample$|template$|dist$)[^/]+)?"
357
+ r"|auth(?:\.json)?(?:\.bak)?|auth\.json|credentials\.json|secrets?\.json"
358
+ r"|mcp-auth\.json|settings\.local\.json|\.netrc|\.npmrc|\.pypirc"
359
+ r"|\.bash_history|\.zsh_history|\.python_history"
360
+ r"|id_(?:rsa|dsa|ecdsa|ed25519)"
361
+ r"|\.git-credentials|\.htpasswd)$"
362
+ )
363
+ _SENSITIVE_SUFFIX = re.compile(r"(?i)\.(?:pem|p12|pfx|keystore|jks)$")
364
+ # Local working state: an agent workspace, a virtualenv, build output or a cache.
365
+ # None of it is project source, so a tracked file here leaked by accident.
366
+ _LOCAL_STATE_PARTS = (
367
+ ".pi", # the team workspace; a project skill under .agents/ stays tracked
368
+ ".venv",
369
+ "venv",
370
+ "node_modules",
371
+ "site",
372
+ "dist",
373
+ "build",
374
+ "__pycache__",
375
+ ".pytest_cache",
376
+ ".mypy_cache",
377
+ ".ruff_cache",
378
+ "coverage",
379
+ ".cache",
380
+ "target",
381
+ "Scratch",
382
+ )
383
+ _SENSITIVE_PARTS = (
384
+ _host_state(".pi/", "agent"),
385
+ _host_state(".claude/", "settings", ".local.json"),
386
+ _host_state(".claude/", ".credentials.json"),
387
+ _host_state(".claude/", "projects"),
388
+ _host_state(".claude/", "todos"),
389
+ _host_state(".codex/", "auth", ".json"),
390
+ _host_state(".codex/", "sessions"),
391
+ _host_state(".gemini/", "oauth_creds", ".json"),
392
+ _host_state(".gemini/", "tmp"),
393
+ _host_state(".config/", "opencode/", "auth.json"),
394
+ _host_state(".aws/", "credentials"),
395
+ _host_state(".kube/", "config"),
396
+ _host_state(".docker/", "config.json"),
397
+ _host_state(".ssh/", ""),
398
+ )
399
+ # Shell history and dotfile names above are matched on the basename only; a
400
+ # project source file called ``auth.py`` must stay allowed.
401
+ _SENSITIVE_BASENAMES = {
402
+ "auth.json",
403
+ "auth.json.bak",
404
+ _host_state(".aider.", "chat.history.md"),
405
+ _host_state(".aider.", "input.history"),
406
+ }
407
+
408
+ # Editor and build residue: local artefacts that are never project source.
409
+ _LOCAL_JUNK = re.compile(r"(?i)^(?:\.DS_Store|Thumbs\.db|.*\.(?:swp|swo|orig|rej|bak|pyc|pyo))$")
410
+
411
+
412
+ def sensitive_path(rel: str):
413
+ """Return a reason when a tracked path is private local state, else ``None``."""
414
+ normalised = rel.replace("\\", "/").lower()
415
+ while normalised.startswith("./"):
416
+ normalised = normalised[2:]
417
+ normalised = normalised.lstrip("/")
418
+ for part in _SENSITIVE_PARTS:
419
+ lowered = part.lower()
420
+ if normalised == lowered or normalised.startswith(lowered.rstrip("/") + "/"):
421
+ return f"private host state: {part}"
422
+ for part in _LOCAL_STATE_PARTS:
423
+ lowered = part.lower()
424
+ if normalised == lowered or normalised.startswith(lowered.rstrip("/") + "/"):
425
+ return f"local state: {part}/"
426
+ name = normalised.rsplit("/", 1)[-1]
427
+ if name in _SENSITIVE_BASENAMES:
428
+ return "credential file"
429
+ if _SENSITIVE_NAME.match(name):
430
+ return "secret-bearing file"
431
+ if _SENSITIVE_SUFFIX.search(name):
432
+ return "key material"
433
+ if _LOCAL_JUNK.match(name):
434
+ return "local state: editor or build artefact"
435
+ return None
436
+
437
+
438
+ # An archive entry's quoted-evidence fields, mirroring the link checker's scope:
439
+ # the author of the entry is not the author of the quotation.
440
+ _QUOTED_EVIDENCE = re.compile(
441
+ r"^- (?:self-check|author evidence|reviewer evidence|findings history): "
442
+ )
443
+ # Rules that describe a *path shape* rather than a secret. They are quiet inside a
444
+ # quotation; every credential rule below stays armed there.
445
+ _QUOTED_PATH_RULES = frozenset({"home-path", "agent-config"})
446
+
447
+ RULES = (
448
+ {"id": "private-key", "severity": "critical", "regex": _RE_PRIVATE_KEY},
449
+ {"id": "api-token", "severity": "critical", "regex": _RE_TOKEN},
450
+ {"id": "env-secret", "severity": "critical", "regex": _RE_ENV_SECRET},
451
+ {"id": "json-secret", "severity": "critical", "regex": _RE_JSON_SECRET},
452
+ {"id": "agent-config", "severity": "high", "regex": _RE_AGENT_CONFIG},
453
+ {"id": "private-domain", "severity": "high", "regex": None}, # compiled in scan()
454
+ {"id": "personal-email", "severity": "high", "regex": _RE_EMAIL},
455
+ {"id": "home-path", "severity": "high", "regex": _RE_HOME},
456
+ {"id": "high-entropy-secret", "severity": "high", "regex": _RE_HIGH_ENTROPY},
457
+ )
458
+
459
+
460
+ def _shannon_entropy(value: str) -> float:
461
+ if not value:
462
+ return 0.0
463
+ counts = Counter(value)
464
+ length = len(value)
465
+ return -sum((n / length) * math.log2(n / length) for n in counts.values())
466
+
467
+
468
+ def _looks_like_secret(value: str, line: str = "", index: int = 0) -> bool:
469
+ """Filter out hashes, identifiers, URL paths and natural words."""
470
+ if len(value) < 32:
471
+ return False
472
+ if re.fullmatch(r"[0-9a-fA-F]+", value): # pure hex -> treat as a hash
473
+ return False
474
+ # A long run inside a URL path is not a credential. A separate token later
475
+ # on the same line (after whitespace) is still checked.
476
+ scheme = line.find("://")
477
+ if scheme != -1 and index > scheme:
478
+ between = line[scheme + 3 : index]
479
+ if not re.search(r"[\s\"']", between):
480
+ return False
481
+ has_digit = any(c.isdigit() for c in value)
482
+ has_upper = any(c.isupper() for c in value)
483
+ has_lower = any(c.islower() for c in value)
484
+ if not (has_digit and has_upper and has_lower):
485
+ return False
486
+ return _shannon_entropy(value) >= 3.5
487
+
488
+
489
+ def _user_is_elided(user: str) -> bool:
490
+ """Is this the elided form of a path, i.e. a segment with no characters left?
491
+
492
+ Logs and commit messages routinely abbreviate a home path to its parent
493
+ directories followed by an ellipsis and then the rest of the path. That form
494
+ carries no account name, so it identifies nobody: flagging it makes the gate
495
+ cry wolf on every quoted CI log, and a gate that cries wolf gets bypassed.
496
+
497
+ Only an ellipsis standing as its own segment is accepted: three or more dots
498
+ followed by nothing or by a separator. A name that merely starts with dots
499
+ (``....name``) or a two-dot parent hop (``..``) still reports, and so does a
500
+ partly masked name.
501
+ """
502
+ return re.fullmatch(r"\.{3,}(?:[\\/].*)?", user) is not None
503
+
504
+
505
+ def _email_allowed(value: str) -> bool:
506
+ if _ALLOWED_SERVICE_EMAILS.match(value):
507
+ return True
508
+ domain = value.rsplit("@", 1)[-1].lower()
509
+ if domain in _ALLOWED_EMAIL_DOMAINS:
510
+ return True
511
+ return any(domain.endswith(suffix) for suffix in _ALLOWED_EMAIL_SUFFIXES)
512
+
513
+
514
+ def _mask(value: str) -> str:
515
+ if len(value) <= 4:
516
+ return "*" * len(value)
517
+ if len(value) <= 10:
518
+ return value[0] + "*" * (len(value) - 2) + value[-1]
519
+ return value[:3] + "*" * (len(value) - 6) + value[-3:]
520
+
521
+
522
+ def _fingerprint(value: str, length: int = 12) -> str:
523
+ """A stable, non-usable identifier for a matched value.
524
+
525
+ Enough to tell two findings apart and to find the secret again once its
526
+ location is known, but not enough to reconstruct or use it. The hash is
527
+ unsalted SHA-256 of the full value, so the same secret always yields the
528
+ same fingerprint across runs and machines.
529
+ """
530
+ digest = hashlib.sha256(value.encode("utf-8", "replace")).hexdigest()
531
+ return "sha256:" + digest[:length]
532
+
533
+
534
+ def write_reveal_file(path, pairs):
535
+ """Export ``(finding, raw_value)`` pairs to a new ``0600`` file.
536
+
537
+ Unmasked values must never reach stdout/stderr (CI logs, terminals, ``tee``
538
+ output). A file the operator names keeps the remediation use case while the
539
+ log stays clean. The file is created with ``O_EXCL`` and mode ``0600`` so an
540
+ existing path is never silently overwritten and no other user can read it.
541
+ Returns the number of values written.
542
+ """
543
+ target = Path(path)
544
+ flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
545
+ fd = os.open(str(target), flags, 0o600)
546
+ written = 0
547
+ try:
548
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
549
+ for finding, value in pairs:
550
+ handle.write(
551
+ f"{finding['location']}\t{finding['rule']}\t{_fingerprint(value)}\t{value}\n"
552
+ )
553
+ written += 1
554
+ os.chmod(str(target), 0o600)
555
+ except BaseException:
556
+ # Do not leave a half-written plaintext secrets file behind.
557
+ try:
558
+ os.unlink(str(target))
559
+ except OSError:
560
+ pass
561
+ raise
562
+ return written
563
+
564
+
565
+ def _clip(line: str, value: str, width: int = 160) -> str:
566
+ """A short, single-line context snippet around the match."""
567
+ index = line.find(value)
568
+ if index < 0:
569
+ return line[:width]
570
+ start = max(0, index - 40)
571
+ end = min(len(line), index + len(value) + 40)
572
+ snippet = line[start:end].strip()
573
+ if start > 0:
574
+ snippet = "..." + snippet
575
+ if end < len(line):
576
+ snippet = snippet + "..."
577
+ return snippet[:width]
578
+
579
+
580
+ # --------------------------------------------------------------------------- #
581
+ # Findings
582
+ # --------------------------------------------------------------------------- #
583
+
584
+
585
+ def make_finding(rule_id, severity, path, line_no, value, line, source, commit=None):
586
+ masked = _mask(value)
587
+ # Never ship the surrounding line: a second credential in the same window
588
+ # would be republished in plaintext (the scanner is published too).
589
+ context = ""
590
+ return {
591
+ "rule": rule_id,
592
+ "severity": severity,
593
+ "path": path,
594
+ "line": line_no,
595
+ "source": source,
596
+ "commit": commit,
597
+ "location": location(path, line_no, commit),
598
+ "evidence": masked,
599
+ "match": value,
600
+ "context": context,
601
+ }
602
+
603
+
604
+ def location(path, line_no, commit=None):
605
+ if commit:
606
+ return f"{path}:{line_no}" if line_no else path
607
+ return f"{path}:{line_no}"
608
+
609
+
610
+ def scan_lines(text, path, source, rules, allow, commit=None):
611
+ """Yield findings for one text blob (a file or a commit message body)."""
612
+ private_domains = _private_domains()
613
+ private_re = None
614
+ if private_domains:
615
+ private_re = re.compile("|".join(re.escape(d) for d in private_domains), re.IGNORECASE)
616
+
617
+ compiled = []
618
+ for rule in rules:
619
+ if rule["id"] == "json-secret":
620
+ continue # whole-text pass below: pretty-printed values span lines
621
+ if rule["id"] == "private-domain":
622
+ if private_re is None:
623
+ continue
624
+ compiled.append((rule, private_re))
625
+ elif rule["regex"] is not None:
626
+ compiled.append((rule, rule["regex"]))
627
+
628
+ ignore_file = path.endswith(".gitignore")
629
+ archive = path.endswith("Archive.md")
630
+ for line_no, line in enumerate(text.splitlines(), 1):
631
+ # An archive entry quotes another agent's evidence verbatim. A path inside
632
+ # such a quotation is that agent's measurement, not a path this repository
633
+ # owns, and the quotation cannot be reworded after it is committed - so a
634
+ # path-shape rule would fail every future scan on immutable history. Every
635
+ # credential rule stays armed on the same line.
636
+ #
637
+ # The predicate has its own name on purpose: the env-secret arm below
638
+ # assigns a local `quoted`, and sharing that name silently disarmed both
639
+ # path rules in every file whenever an env-secret matched.
640
+ quoted_evidence = archive and _QUOTED_EVIDENCE.match(line) is not None
641
+ for rule, regex in compiled:
642
+ # A .gitignore entry denies a private path; it is not a leak. The
643
+ # project's own deny list must therefore not fail the deny list test.
644
+ if rule["id"] == "agent-config" and ignore_file:
645
+ continue
646
+ if quoted_evidence and rule["id"] in _QUOTED_PATH_RULES:
647
+ continue
648
+ for match in regex.finditer(line):
649
+ value = match.group(0)
650
+ if rule["id"] == "personal-email" and _email_allowed(value):
651
+ continue
652
+ if rule["id"] == "home-path":
653
+ user = match.group(1) or match.group(2) or match.group(3) or ""
654
+ if user.lower() in _GENERIC_HOME_USERS:
655
+ continue
656
+ if _user_is_elided(user):
657
+ continue
658
+ if rule["id"] == "env-secret":
659
+ quoted = match.group(1) is not None
660
+ secret = match.group(1) or match.group(2)
661
+ if _PLACEHOLDERS.match(secret):
662
+ continue
663
+ if quoted:
664
+ # A quoted passphrase may legitimately contain spaces.
665
+ if not re.search(r"\s", secret) and not _looks_like_secret_value(secret):
666
+ continue
667
+ elif not _looks_like_secret_value(secret):
668
+ continue
669
+ value = secret
670
+ if rule["id"] == "high-entropy-secret":
671
+ prefix = line[: match.start()].lower()
672
+ if "data:" in prefix and "base64" in prefix:
673
+ continue
674
+ if re.search(r"(?:sha(?:256|384|512)-|integrity=)[\"']?$", prefix):
675
+ continue
676
+ if not _looks_like_secret(value, line, match.start()):
677
+ continue
678
+ if any(pattern.search(value) for pattern in allow):
679
+ continue
680
+ yield make_finding(
681
+ rule["id"], rule["severity"], path, line_no, value, line, source, commit
682
+ )
683
+
684
+ # A marker that carries a credential next to it is reported too.
685
+ if _RE_TODO.search(line):
686
+ credential = None
687
+ for probe in (_RE_TOKEN, _RE_PRIVATE_KEY):
688
+ match = probe.search(line)
689
+ if match:
690
+ credential = match.group(0)
691
+ break
692
+ if credential is None and private_re is not None:
693
+ match = private_re.search(line)
694
+ if match:
695
+ credential = match.group(0)
696
+ if credential is None:
697
+ match = _RE_ENV_SECRET.search(line)
698
+ if match:
699
+ candidate = match.group(1) or match.group(2)
700
+ if not _PLACEHOLDERS.match(candidate) and (
701
+ match.group(1) is not None or _looks_like_secret_value(candidate)
702
+ ):
703
+ credential = candidate
704
+ if credential and not any(p.search(credential) for p in allow):
705
+ yield make_finding(
706
+ "todo-credential", "medium", path, line_no, credential, line, source, commit
707
+ )
708
+
709
+ # A JSON config may put the key and the value on different lines; matching the
710
+ # whole text keeps a pretty-printed `"apiKey":` from slipping through. This is
711
+ # deliberately not skipped for `.gitignore`: only the agent-config line rule is
712
+ # a deny list, and a pasted JSON credential is a leak in any file.
713
+ for match in _RE_JSON_SECRET.finditer(text):
714
+ value = match.group(1)
715
+ if _PLACEHOLDERS.match(value):
716
+ continue
717
+ if not _looks_like_secret_value(value) and not (
718
+ " " in value
719
+ and any(ch.isdigit() or (not ch.isalnum() and not ch.isspace()) for ch in value)
720
+ ):
721
+ continue
722
+ line_no = text.count("\n", 0, match.start()) + 1
723
+ line = text.splitlines()[line_no - 1]
724
+ if any(pattern.search(value) for pattern in allow):
725
+ continue
726
+ yield make_finding("json-secret", "critical", path, line_no, value, line, source, commit)
727
+
728
+
729
+ def scan_commit_metadata(root, allow, stats=None):
730
+ """Scan author/committer name+email and the message of every commit."""
731
+ fmt = "%H%x1f%an%x1f%ae%x1f%cn%x1f%ce%x1f%P%x1f%B%x1e"
732
+ proc = _run(["git", "-C", str(root), "log", "--all", "--format=" + fmt])
733
+ if proc.returncode != 0:
734
+ return []
735
+ findings = []
736
+ text = proc.stdout.decode("utf-8", "replace")
737
+ for record in text.split("\x1e"):
738
+ record = record.strip("\n")
739
+ if not record:
740
+ continue
741
+ parts = record.split("\x1f")
742
+ if len(parts) < 5:
743
+ continue
744
+ if stats is not None:
745
+ stats["commits"] += 1
746
+ sha, an, ae, cn, ce = parts[0], parts[1], parts[2], parts[3], parts[4]
747
+ parents = parts[5].split() if len(parts) > 5 and parts[5] else []
748
+ body = parts[6] if len(parts) > 6 else ""
749
+ short = sha[:12]
750
+ subject = body.splitlines()[0] if body else ""
751
+ if not is_forge_merge(subject, parents):
752
+ for label, value in (
753
+ ("author name", an),
754
+ ("author email", ae),
755
+ ("committer name", cn),
756
+ ("committer email", ce),
757
+ ):
758
+ if not value:
759
+ continue
760
+ for finding in scan_lines(
761
+ value, f"commit:{short}", "history:commit", RULES, allow, commit=sha
762
+ ):
763
+ finding["detail"] = label
764
+ finding["context"] = f"{label}: {finding['context']}"
765
+ findings.append(finding)
766
+ for finding in scan_lines(
767
+ body, f"commit:{short}", "history:commit", RULES, allow, commit=sha
768
+ ):
769
+ finding["detail"] = "message"
770
+ findings.append(finding)
771
+ return findings
772
+
773
+
774
+ # --------------------------------------------------------------------------- #
775
+ # Git helpers
776
+ # --------------------------------------------------------------------------- #
777
+
778
+
779
+ def _run(args, input_bytes=None):
780
+ try:
781
+ return subprocess.run(args, input=input_bytes, capture_output=True, timeout=300)
782
+ except subprocess.TimeoutExpired as exc:
783
+ raise RuntimeError(f"command timed out: {' '.join(args)}") from exc
784
+
785
+
786
+ def _cat_file_batch(root, shas):
787
+ """Return {sha: bytes} for blob objects, via one ``git cat-file --batch`` run."""
788
+ if not shas:
789
+ return {}
790
+ proc = _run(
791
+ ["git", "-C", str(root), "cat-file", "--batch"],
792
+ input_bytes=("\n".join(shas) + "\n").encode("utf-8"),
793
+ )
794
+ if proc.returncode != 0:
795
+ raise RuntimeError(
796
+ "git cat-file --batch failed: " + proc.stderr.decode("utf-8", "replace").strip()
797
+ )
798
+ out = proc.stdout
799
+ result = {}
800
+ pos = 0
801
+ while pos < len(out):
802
+ newline = out.find(b"\n", pos)
803
+ if newline < 0:
804
+ break
805
+ header = out[pos:newline].decode("utf-8", "replace").split()
806
+ pos = newline + 1
807
+ if len(header) == 2 and header[1] in ("missing", "ambiguous"):
808
+ continue
809
+ if len(header) != 3 or header[1] not in ("blob", "tree", "commit", "tag"):
810
+ raise RuntimeError(f"unexpected git cat-file batch header: {header!r}")
811
+ sha, kind, raw_size = header
812
+ try:
813
+ size = int(raw_size)
814
+ except ValueError as exc:
815
+ raise RuntimeError(f"invalid git object size in {header!r}") from exc
816
+ payload = out[pos : pos + size]
817
+ # Skip the payload of every object type. Previously a tree payload was
818
+ # re-read as headers, which desynchronised the stream and could hide a
819
+ # real blob (or crash on a crafted tree entry name).
820
+ pos += size + 1 # git writes a trailing LF after each payload
821
+ if kind == "blob":
822
+ result[sha] = payload
823
+ return result
824
+
825
+
826
+ def _tracked_paths(root):
827
+ proc = _run(["git", "-C", str(root), "ls-files", "-z"])
828
+ if proc.returncode != 0:
829
+ raise RuntimeError("git ls-files failed: " + proc.stderr.decode("utf-8", "replace").strip())
830
+ return [p.decode("utf-8", "replace") for p in proc.stdout.split(b"\x00") if p]
831
+
832
+
833
+ def _scan_bytes(data, path, source, allow, commit=None):
834
+ head = data[:8192]
835
+ if head.startswith((b"\xff\xfe", b"\xfe\xff")):
836
+ text = data.decode("utf-16", "replace")
837
+ elif b"\x00" in head and head.count(b"\x00") / max(len(head), 1) > 0.01:
838
+ # UTF-16 without a BOM: try both byte orders and accept only a decode
839
+ # that is overwhelmingly printable. A real binary file stays skipped.
840
+ text = ""
841
+ for encoding in ("utf-16-le", "utf-16-be"):
842
+ candidate = data.decode(encoding, "replace")
843
+ printable = sum(1 for ch in candidate if ch.isprintable() or ch in "\n\r\t") / max(
844
+ len(candidate), 1
845
+ )
846
+ if printable > 0.9 and "\x00" not in candidate:
847
+ text = candidate
848
+ break
849
+ if not text:
850
+ return []
851
+ else:
852
+ text = data.decode("utf-8", "replace")
853
+ return list(scan_lines(text, path, source, RULES, allow, commit=commit))
854
+
855
+
856
+ def config_path_finding(rel, source, allow=(), commit=None):
857
+ """A tracked path that is private host state is a finding on its own."""
858
+ reason = sensitive_path(rel)
859
+ if reason is None:
860
+ return None
861
+ if any(pattern.search(rel) for pattern in allow):
862
+ return None
863
+ local_state = reason.startswith("local state")
864
+ return {
865
+ "rule": "local-state" if local_state else "local-config",
866
+ "severity": "high" if local_state else "critical",
867
+ "path": rel,
868
+ "line": 0,
869
+ "source": source,
870
+ "commit": commit,
871
+ "location": rel,
872
+ "evidence": rel,
873
+ "match": rel,
874
+ "context": reason,
875
+ }
876
+
877
+
878
+ def scan_tree(root, allow, stats=None):
879
+ findings = []
880
+ for rel in _tracked_paths(root):
881
+ if stats is not None:
882
+ stats["tracked_files"] += 1
883
+ path_finding = config_path_finding(rel, "tree", allow)
884
+ if path_finding is not None:
885
+ findings.append(path_finding)
886
+ full = root / rel
887
+ try:
888
+ data = full.read_bytes()
889
+ except OSError as exc:
890
+ findings.append(
891
+ {
892
+ "rule": "unreadable-tracked-file",
893
+ "severity": "medium",
894
+ "path": rel,
895
+ "line": 0,
896
+ "source": "tree",
897
+ "commit": None,
898
+ "location": rel,
899
+ "evidence": rel,
900
+ "match": rel,
901
+ "context": f"tracked file could not be read: {exc}",
902
+ }
903
+ )
904
+ continue
905
+ findings.extend(_scan_bytes(data, rel, "tree", allow))
906
+ return findings
907
+
908
+
909
+ def scan_staged(root, allow, stats=None):
910
+ """Scan the index content (what ``git commit`` would record)."""
911
+ proc = _run(["git", "-C", str(root), "ls-files", "-s", "-z"])
912
+ if proc.returncode != 0:
913
+ raise RuntimeError(
914
+ "git ls-files -s failed: " + proc.stderr.decode("utf-8", "replace").strip()
915
+ )
916
+ entries = {}
917
+ for record in proc.stdout.split(b"\x00"):
918
+ if not record:
919
+ continue
920
+ try:
921
+ meta, path = record.split(b"\t", 1)
922
+ except ValueError:
923
+ continue
924
+ fields = meta.split()
925
+ if len(fields) < 2:
926
+ continue
927
+ entries.setdefault(fields[1].decode("ascii"), []).append(path.decode("utf-8", "replace"))
928
+ blobs = _cat_file_batch(root, list(entries))
929
+ findings = []
930
+ for sha, paths in entries.items():
931
+ data = blobs.get(sha)
932
+ if data is None:
933
+ continue
934
+ for path in paths:
935
+ if stats is not None:
936
+ stats["staged_files"] += 1
937
+ path_finding = config_path_finding(path, "staged", allow)
938
+ if path_finding is not None:
939
+ findings.append(path_finding)
940
+ findings.extend(_scan_bytes(data, path, "staged", allow))
941
+ return findings
942
+
943
+
944
+ def scan_history(root, allow, stats=None):
945
+ # --reflog also reaches objects a reset or a deleted branch left behind, which
946
+ # a plain --all scan reports as PASS even though `git fsck` still finds them.
947
+ proc = _run(["git", "-C", str(root), "rev-list", "--objects", "--all", "--reflog"])
948
+ if proc.returncode != 0:
949
+ raise RuntimeError("git rev-list failed: " + proc.stderr.decode("utf-8", "replace").strip())
950
+ sha_to_paths = {}
951
+ order = []
952
+ for line in proc.stdout.decode("utf-8", "replace").splitlines():
953
+ parts = line.split(" ", 1)
954
+ sha = parts[0]
955
+ path = parts[1] if len(parts) > 1 else ""
956
+ if not path:
957
+ continue
958
+ if sha not in sha_to_paths:
959
+ sha_to_paths[sha] = set()
960
+ order.append(sha)
961
+ sha_to_paths[sha].add(path)
962
+ blobs = _cat_file_batch(root, order)
963
+ findings = []
964
+ for sha in order:
965
+ data = blobs.get(sha)
966
+ if data is None:
967
+ continue
968
+ if stats is not None:
969
+ stats["history_blobs"] += 1
970
+ for rel in sorted(sha_to_paths[sha]):
971
+ path_finding = config_path_finding(rel, "history:blob", allow, commit=sha)
972
+ if path_finding is not None:
973
+ findings.append(path_finding)
974
+ findings.extend(_scan_bytes(data, rel, "history:blob", allow, commit=sha))
975
+ findings.extend(scan_commit_metadata(root, allow, stats=stats))
976
+ return findings
977
+
978
+
979
+ # --------------------------------------------------------------------------- #
980
+ # Allowlist
981
+ # --------------------------------------------------------------------------- #
982
+
983
+
984
+ def load_allow(path):
985
+ patterns = []
986
+ if not path:
987
+ return patterns
988
+ text = Path(path).read_text(encoding="utf-8")
989
+ for line_no, raw in enumerate(text.splitlines(), 1):
990
+ line = raw.strip()
991
+ if not line or line.startswith("#"):
992
+ continue
993
+ expression = line.split("#", 1)[0].strip()
994
+ if not expression:
995
+ continue
996
+ try:
997
+ patterns.append(re.compile(expression))
998
+ except re.error as exc:
999
+ print(f"privacy_scan: {path}:{line_no}: bad allow regex: {exc}", file=sys.stderr)
1000
+ raise SystemExit(2) from exc
1001
+ return patterns
1002
+
1003
+
1004
+ # --------------------------------------------------------------------------- #
1005
+ # Reporting
1006
+ # --------------------------------------------------------------------------- #
1007
+
1008
+
1009
+ def summarize(findings):
1010
+ by_severity = Counter(f["severity"] for f in findings)
1011
+ by_rule = Counter(f["rule"] for f in findings)
1012
+ return {"by_severity": dict(by_severity), "by_rule": dict(by_rule)}
1013
+
1014
+
1015
+ def dedupe(findings):
1016
+ seen = set()
1017
+ unique = []
1018
+ for finding in findings:
1019
+ key = (finding["rule"], finding["location"], finding["match"], finding.get("commit") or "")
1020
+ if key in seen:
1021
+ continue
1022
+ seen.add(key)
1023
+ unique.append(finding)
1024
+ return unique
1025
+
1026
+
1027
+ def main(argv=None):
1028
+ parser = argparse.ArgumentParser(
1029
+ prog="privacy_scan.py",
1030
+ description="Fail on personal data, credentials and secrets in the tree, "
1031
+ "the index and the full git history.",
1032
+ )
1033
+ parser.add_argument(
1034
+ "--tree", action="store_true", help="scan tracked files from the working tree"
1035
+ )
1036
+ parser.add_argument("--staged", action="store_true", help="scan the staged index content")
1037
+ parser.add_argument(
1038
+ "--history", action="store_true", help="scan every blob of every commit and commit metadata"
1039
+ )
1040
+ parser.add_argument("--all", action="store_true", help="combine --tree, --staged and --history")
1041
+ parser.add_argument(
1042
+ "--allow",
1043
+ metavar="FILE",
1044
+ default=None,
1045
+ help="allowlist: one regex per line with an optional '# reason'",
1046
+ )
1047
+ parser.add_argument(
1048
+ "--root", metavar="DIR", default=None, help="repository root (default: current directory)"
1049
+ )
1050
+ parser.add_argument("--json", action="store_true", help="emit JSON")
1051
+ parser.add_argument(
1052
+ "--reveal",
1053
+ action="store_true",
1054
+ help="print a stable fingerprint (sha256 prefix) per finding; never the value itself",
1055
+ )
1056
+ parser.add_argument(
1057
+ "--reveal-to",
1058
+ metavar="PATH",
1059
+ default=None,
1060
+ help="write unmasked matched values to PATH (created 0600, never overwritten)",
1061
+ )
1062
+ parser.add_argument("--quiet", action="store_true", help="only print the one-line summary")
1063
+ args = parser.parse_args(argv)
1064
+
1065
+ if args.all:
1066
+ args.tree = args.staged = args.history = True
1067
+ if not (args.tree or args.staged or args.history):
1068
+ args.tree = True # safe default
1069
+
1070
+ root = _git_root(args.root)
1071
+ if root is None:
1072
+ print("privacy_scan: not inside a git repository", file=sys.stderr)
1073
+ return 2
1074
+
1075
+ try:
1076
+ allow = load_allow(args.allow)
1077
+ except OSError as exc:
1078
+ print(f"privacy_scan: cannot read allowlist: {exc}", file=sys.stderr)
1079
+ return 2
1080
+ modes = [
1081
+ name
1082
+ for name, flag in (("tree", args.tree), ("staged", args.staged), ("history", args.history))
1083
+ if flag
1084
+ ]
1085
+
1086
+ findings = []
1087
+ stats = {"tracked_files": 0, "staged_files": 0, "history_blobs": 0, "commits": 0}
1088
+ try:
1089
+ if args.tree:
1090
+ findings.extend(scan_tree(root, allow, stats))
1091
+ if args.staged:
1092
+ findings.extend(scan_staged(root, allow, stats))
1093
+ if args.history:
1094
+ findings.extend(scan_history(root, allow, stats))
1095
+ except (RuntimeError, OSError, ValueError) as exc:
1096
+ print(f"privacy_scan: {exc}", file=sys.stderr)
1097
+ return 2
1098
+
1099
+ findings = dedupe(findings)
1100
+ # Unmasked values are never printed. A log line - CI output, a terminal, a
1101
+ # `tee`d file - is a broadcast sink: --reveal only adds a stable fingerprint
1102
+ # and --reveal-to exports the raw values to a file the operator names.
1103
+ reveal_path = args.reveal_to
1104
+ reveal_values = []
1105
+ for finding in findings:
1106
+ match = finding.pop("match", None)
1107
+ if match is None:
1108
+ continue
1109
+ if args.reveal:
1110
+ finding["fingerprint"] = _fingerprint(match)
1111
+ if reveal_path:
1112
+ reveal_values.append((finding, match))
1113
+
1114
+ reveal_written = 0
1115
+ if reveal_path:
1116
+ try:
1117
+ reveal_written = write_reveal_file(reveal_path, reveal_values)
1118
+ except FileExistsError:
1119
+ print(
1120
+ f"privacy_scan: refusing to overwrite existing file {reveal_path}",
1121
+ file=sys.stderr,
1122
+ )
1123
+ return 2
1124
+ except OSError as exc:
1125
+ print(f"privacy_scan: cannot write reveal file: {exc}", file=sys.stderr)
1126
+ return 2
1127
+ print(
1128
+ f"privacy_scan: WARNING - {reveal_written} unmasked value(s) written to "
1129
+ f"{reveal_path} (mode 0600). Delete it after remediation; never commit, "
1130
+ f"copy into a ticket, or paste it into a chat.",
1131
+ file=sys.stderr,
1132
+ )
1133
+
1134
+ report = {
1135
+ "scanner_version": SCANNER_VERSION,
1136
+ # The absolute root is deliberately not emitted: a privacy scanner must
1137
+ # not republish the operator's home path into public CI logs.
1138
+ "root": root.name,
1139
+ "modes": modes,
1140
+ # Never publish an absolute (possibly home) path in the report.
1141
+ "allow_file": Path(args.allow).name if args.allow else None,
1142
+ "identity_domain": IDENTITY_DOMAIN,
1143
+ "scanned": stats,
1144
+ "finding_count": len(findings),
1145
+ "findings": findings,
1146
+ }
1147
+ report.update(summarize(findings))
1148
+
1149
+ if args.json:
1150
+ print(json.dumps(report, indent=2, sort_keys=False))
1151
+ else:
1152
+ if not args.quiet:
1153
+ for finding in findings:
1154
+ # The unmasked value is never printed: stdout is a broadcast
1155
+ # sink. --reveal only adds a non-usable fingerprint.
1156
+ shown = finding["evidence"]
1157
+ fingerprint = finding.get("fingerprint")
1158
+ if fingerprint:
1159
+ shown = f"{shown} ({fingerprint})"
1160
+ print(
1161
+ f"{finding['location']}: [{finding['severity']}/"
1162
+ f"{finding['rule']}] {shown} {finding.get('detail', '')}"
1163
+ )
1164
+ state = "FAIL" if findings else "PASS"
1165
+ print(f"privacy_scan: {state} - {len(findings)} finding(s) in {', '.join(modes)}")
1166
+ return 1 if findings else 0
1167
+
1168
+
1169
+ def _git_root(explicit):
1170
+ start = Path(explicit) if explicit else Path.cwd()
1171
+ try:
1172
+ proc = subprocess.run(
1173
+ ["git", "-C", str(start), "rev-parse", "--show-toplevel"],
1174
+ capture_output=True,
1175
+ timeout=60,
1176
+ )
1177
+ except (OSError, subprocess.TimeoutExpired):
1178
+ return None
1179
+ if proc.returncode != 0:
1180
+ return None
1181
+ return Path(proc.stdout.decode("utf-8", "replace").strip())
1182
+
1183
+
1184
+ if __name__ == "__main__":
1185
+ sys.exit(main())