diff options
| author | godosa <godosa@godosa.eu> | 2026-10-07 07:44:16 +0200 |
|---|---|---|
| committer | godosa <godosa@godosa.eu> | 2026-10-07 07:44:16 +0200 |
| commit | 9d5641fe6585a61894f7b7427a68c794e5efe76d (patch) | |
| tree | 79ebae3131df1c50542420db9e5ecb44e3d22076 /scripts/denylist_check.py | |
| parent | 81d4e80fd5aabe4e80f58e960affa795cf7d34ec (diff) | |
| download | workflow-9d5641fe6585a61894f7b7427a68c794e5efe76d.tar.gz workflow-9d5641fe6585a61894f7b7427a68c794e5efe76d.zip | |
Denylist guard at commit time (pre-commit + commit-msg hooks); drop publish_snapshot
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JEAjUkQRrCYX5MZhWxdtj2
Diffstat (limited to 'scripts/denylist_check.py')
| -rwxr-xr-x | scripts/denylist_check.py | 158 |
1 files changed, 158 insertions, 0 deletions
diff --git a/scripts/denylist_check.py b/scripts/denylist_check.py new file mode 100755 index 0000000..08cf985 --- /dev/null +++ b/scripts/denylist_check.py @@ -0,0 +1,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:])) |
