1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
|
#!/usr/bin/env python3
"""Denylist guard for a public repo: refuse private words in commits (git hooks in .githooks/).
Usage: denylist_check.py staged | msg FILE | tree [REF]
staged added lines + paths of the index (pre-commit hook)
msg FILE commit message, '#' lines skipped (commit-msg hook)
tree every tracked file of REF (default HEAD): manual audit
Lists (merged): publish-denylist.local in the worktree top and in the main tree (git-ignored),
plus every `git config denylist.file <path>` (e.g. a global list). Lines: `word` = case-insensitive
substring; `!token` = exemption (a hit inside an occurrence of token does not count); `#` comments.
Output names the place and `word #<line>` only, never the word. Exit 0 clean, 1 hits.
No list at all → warning, exit 0. Bypass once: `git commit --no-verify`.
Enable in a clone: `git config core.hooksPath .githooks`.
"""
import re
import subprocess
import sys
from pathlib import Path
NAME = "publish-denylist.local"
def git(*args):
r = subprocess.run(["git", "-c", "core.quotePath=false", *args], capture_output=True)
if r.returncode:
raise SystemExit(f"denylist-check: git {args[0]} failed: {r.stderr.decode(errors='replace').strip()}")
return r.stdout.decode("utf-8", errors="replace")
def parse(text):
"""([(lineno, word lowercased)], [exempt token lowercased])."""
words, exempt = [], []
for n, line in enumerate(text.splitlines(), 1):
w = line.strip()
if not w or w.startswith("#"):
continue
if w.startswith("!"):
if w[1:]:
exempt.append(w[1:].lower())
else:
words.append((n, w.lower()))
return words, exempt
def _exempt_spans(low, exempt):
spans = []
for t in exempt:
spans += [(m.start(), m.end()) for m in re.finditer(re.escape(t), low)]
return spans
def hits(text, words, exempt):
"""Line numbers (in the list) of words found in text outside exempted tokens."""
low = text.lower()
spans = None
out = []
for n, w in words:
for m in re.finditer(re.escape(w), low):
if spans is None:
spans = _exempt_spans(low, exempt)
if not any(a <= m.start() and m.end() <= b for a, b in spans):
out.append(n)
break
return out
def load_lists():
"""[(label, words, exempt)] of every existing list; label '' for the repo list."""
top = Path(git("rev-parse", "--show-toplevel").strip())
common = Path(git("rev-parse", "--path-format=absolute", "--git-common-dir").strip())
cands = [("", top / NAME), ("", common.parent / NAME)]
r = subprocess.run(["git", "config", "--get-all", "denylist.file"], capture_output=True, text=True)
cands += [(Path(p).name + " ", Path(p).expanduser()) for p in r.stdout.splitlines() if p.strip()]
seen, lists = set(), []
for label, p in cands:
if not p.is_file() or p.resolve() in seen:
continue
seen.add(p.resolve())
lists.append((label, *parse(p.read_text(errors="replace"))))
return lists
def scan(items, lists):
"""items = [(place, text)] → ['place: <label>word #n']; words in place (a path) masked as ***."""
allw = sorted({w for _, words, _ in lists for _, w in words}, key=len, reverse=True)
mask = re.compile("|".join(map(re.escape, allw)), re.I) if allw else None
out = []
for place, text in items:
for label, words, exempt in lists:
out += [f"{mask.sub('***', place)}: {label}word #{n}" for n in hits(text, words, exempt)]
return out
def staged_items():
items, path, line = [], None, 0
for name in git("diff", "--cached", "--name-only", "-z", "--diff-filter=ACMR").split("\0"):
if name:
items.append((f"path {name}", name))
for raw in git("diff", "--cached", "-U0", "--no-color", "--no-ext-diff", "--diff-filter=ACMR").splitlines():
if raw.startswith("+++ "):
path = raw[6:] if raw.startswith("+++ b/") else raw[4:].strip('"')
elif raw.startswith("@@"):
m = re.match(r"@@ -\S+ \+(\d+)", raw)
line = int(m.group(1)) if m else 0
elif raw.startswith("+") and path:
items.append((f"{path}:{line}", raw[1:]))
line += 1
return items
def msg_items(file):
text = Path(file).read_text(errors="replace")
items = []
for n, l in enumerate(text.splitlines(), 1):
if l.startswith("# ------------------------ >8"):
break
if not l.startswith("#"):
items.append((f"commit message:{n}", l))
return items
def tree_items(ref):
items = []
for name in git("ls-tree", "-r", "-z", "--name-only", ref).split("\0"):
if not name:
continue
items.append((f"path {name}", name))
data = subprocess.run(["git", "cat-file", "blob", f"{ref}:{name}"], capture_output=True).stdout
if b"\0" in data[:8000]:
continue
for n, l in enumerate(data.decode("utf-8", errors="replace").splitlines(), 1):
items.append((f"{name}:{n}", l))
return items
def main(argv):
if not argv or argv[0] not in ("staged", "msg", "tree") or (argv[0] == "msg" and len(argv) != 2):
print(__doc__.split("\n\n")[1], file=sys.stderr)
return 2
lists = load_lists()
if not lists:
print(f"denylist-check: warning: no {NAME} (nor denylist.file): nothing checked", file=sys.stderr)
return 0
mode = argv[0]
items = staged_items() if mode == "staged" else msg_items(argv[1]) if mode == "msg" \
else tree_items(argv[1] if len(argv) > 1 else "HEAD")
found = scan(items, lists)
if found:
print("denylist-check: private word found (fix, or `git commit --no-verify` if a false hit):",
file=sys.stderr)
print("\n".join(found), file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))
|