@nidvue/teamwork 0.0.0-stage → 1.1.0
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.
- package/CHANGELOG.md +329 -0
- package/LICENSE +661 -0
- package/NOTICE +16 -0
- package/README.md +462 -2
- package/SECURITY.md +64 -0
- package/package.json +71 -4
- package/prompts/teamwork-pulse.md +12 -0
- package/prompts/teamwork-review.md +16 -0
- package/skills/teamwork/AGENTS.md +462 -0
- package/skills/teamwork/SKILL.md +795 -0
- package/skills/teamwork/references/Adapters.md +112 -0
- package/skills/teamwork/references/Audit.md +120 -0
- package/skills/teamwork/references/Concurrency.md +160 -0
- package/skills/teamwork/references/Dissent.md +85 -0
- package/skills/teamwork/references/Docs.md +74 -0
- package/skills/teamwork/references/Engineering.md +410 -0
- package/skills/teamwork/references/Evidence.md +140 -0
- package/skills/teamwork/references/Remotes.md +144 -0
- package/skills/teamwork/references/Roles.md +201 -0
- package/skills/teamwork/references/State.md +64 -0
- package/skills/teamwork/references/Testing.md +144 -0
- package/skills/teamwork/roles/architect.md +153 -0
- package/skills/teamwork/roles/ceo.md +218 -0
- package/skills/teamwork/roles/cfo.md +150 -0
- package/skills/teamwork/roles/coo.md +147 -0
- package/skills/teamwork/roles/cto.md +162 -0
- package/skills/teamwork/roles/data-engineer.md +120 -0
- package/skills/teamwork/roles/designer.md +349 -0
- package/skills/teamwork/roles/developer.md +166 -0
- package/skills/teamwork/roles/product-manager.md +151 -0
- package/skills/teamwork/roles/project-manager.md +159 -0
- package/skills/teamwork/roles/release-engineer.md +245 -0
- package/skills/teamwork/roles/reviewer.md +254 -0
- package/skills/teamwork/roles/security-auditor.md +157 -0
- package/skills/teamwork/roles/technical-writer.md +121 -0
- package/skills/teamwork/roles/tester.md +180 -0
- package/skills/teamwork/scripts/build_pi_package.py +200 -0
- package/skills/teamwork/scripts/privacy_scan.py +1185 -0
- package/skills/teamwork/scripts/sync_skill.py +108 -0
- package/skills/teamwork/scripts/teamwork.py +28 -0
- package/skills/teamwork/scripts/verify_release.py +502 -0
- package/skills/teamwork/src/teamwork/__init__.py +18 -0
- package/skills/teamwork/src/teamwork/__main__.py +10 -0
- package/skills/teamwork/src/teamwork/bundle.py +157 -0
- package/skills/teamwork/src/teamwork/cli.py +16 -0
- package/skills/teamwork/src/teamwork/config.py +396 -0
- package/skills/teamwork/src/teamwork/engine.py +10119 -0
- package/skills/teamwork/src/teamwork/gitutil.py +172 -0
- package/skills/teamwork/src/teamwork/imaging.py +124 -0
- package/skills/teamwork/src/teamwork/io.py +144 -0
- package/skills/teamwork/src/teamwork/layout.py +253 -0
- package/skills/teamwork/src/teamwork/names.py +191 -0
- package/skills/teamwork/src/teamwork/portable.py +79 -0
- package/skills/teamwork/src/teamwork/records.py +1220 -0
- package/skills/teamwork/src/teamwork/resources.py +120 -0
- package/skills/teamwork/src/teamwork/version.py +13 -0
- package/skills/teamwork/templates/AGENTS.md +460 -0
- package/skills/teamwork/templates/Docs/Architecture.md +40 -0
- package/skills/teamwork/templates/Docs/Archive.md +8 -0
- package/skills/teamwork/templates/Docs/Board.md +28 -0
- package/skills/teamwork/templates/Docs/Costs.md +30 -0
- package/skills/teamwork/templates/Docs/Decisions.md +26 -0
- package/skills/teamwork/templates/Docs/Design.md +106 -0
- package/skills/teamwork/templates/Docs/Plan.md +24 -0
- package/skills/teamwork/templates/Docs/Project.md +40 -0
- package/skills/teamwork/templates/Docs/Release.md +43 -0
- package/skills/teamwork/templates/Docs/Research.md +36 -0
- package/skills/teamwork/templates/Docs/Risks.md +18 -0
- package/skills/teamwork/templates/TeamMap.md +100 -0
- 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())
|