raw · 5998 bytes
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:])) |