#!/usr/bin/env python3 """Wächter: keine Zeichenkette sieht für einen Geheimnis-Scanner wie ein echtes Geheimnis aus. Ohne Argument prüft er die Dateien im Repository, mit einem Ordner alle Dateien darin (so ruft ihn `scripts/export_public.sh` auf). Attrappen in Tests setzen solche Werte zur Laufzeit aus Teilen zusammen. Exit-Code 0 = sauber, 1 = Fund. """ from __future__ import annotations import os import re import subprocess import sys from dataclasses import dataclass from pathlib import Path REPO_ROOT = Path(__file__).resolve().parent.parent # Aus Teilen zusammengesetzt, damit sich der Wächter nicht selbst meldet. _KEY = "PRIVATE" + " KEY" _AGE = "AGE-SECRET" + "-KEY-1" RULES: tuple[tuple[str, re.Pattern[str]], ...] = ( # Echte Werte enthalten fast immer eine Ziffer (Basic auch `+/`); Prosa wie „Bearer Authentifizierung“ bleibt frei. ("Bearer-Wert", re.compile(r"\b(?i:bearer) (?=[A-Za-z0-9._~+/-]*[0-9])[A-Za-z0-9._~+/-]{16,}")), ("Basic-Anmeldung", re.compile(r"\bBasic (?=[A-Za-z0-9+/]*[0-9+/])[A-Za-z0-9+/]{16,}={0,2}")), ("GitHub-Token", re.compile(r"\b(?:gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,})")), ("GitLab-Token", re.compile(r"\bglpat-[A-Za-z0-9_-]{20,}")), ("AWS-Schlüssel", re.compile(r"\b(?:AKIA|ASIA)[0-9A-Z]{16}\b")), ("Slack-Token", re.compile(r"\bxox[abposr]-[A-Za-z0-9-]{10,}|hooks\.slack\.com/(?:services|workflows|triggers)/[A-Z0-9]")), ("Discord-Webhook", re.compile(r"discord(?:app)?\.com/api/(?:v[0-9]+/)?webhooks/[0-9]")), ("npm-Token", re.compile(r"\bnpm_[A-Za-z0-9]{36}(?![A-Za-z0-9])")), # Nicht mitten in Namen wie `disk-usage-sk-…`, und ein echter Schlüssel enthält Ziffern. ("API-Schlüssel", re.compile(r"(?]+@" ), ), ) # Ein Schlüssel zählt erst mit Kopf und Fuß in derselben Datei: Der Kopf allein steht als Platzhalter im Formular. KEY_BEGIN = re.compile(r"-----BEGIN [A-Z0-9 ]*" + _KEY + r"(?: BLOCK)?-----") KEY_END = re.compile(r"-----END [A-Z0-9 ]*" + _KEY + r"(?: BLOCK)?-----") @dataclass(frozen=True) class Finding: path: str line: int rule: str text: str def __str__(self) -> str: return f"{self.path}:{self.line}: {self.rule}: {self.text}" def check_text(path: str, text: str) -> list[Finding]: findings: list[Finding] = [] has_key_end = KEY_END.search(text) is not None for number, line in enumerate(text.splitlines(), 1): rules = [name for name, pattern in RULES if pattern.search(line)] if has_key_end and KEY_BEGIN.search(line): rules.append("privater Schlüssel") findings.extend(Finding(path, number, rule, line.strip()[:160]) for rule in rules) return findings def tracked_files(root: Path = REPO_ROOT) -> list[str]: out = subprocess.run(["git", "-C", str(root), "ls-files", "-z"], capture_output=True, check=True).stdout return [name for name in out.decode().split("\0") if name] def files_below(root: Path) -> list[str]: names: list[str] = [] for current, dirs, files in os.walk(root): dirs[:] = sorted(d for d in dirs if d != ".git") names.extend(str((Path(current) / name).relative_to(root)) for name in sorted(files)) return names def find_violations(root: Path = REPO_ROOT, *, tracked: bool = True) -> list[Finding]: findings: list[Finding] = [] for name in tracked_files(root) if tracked else files_below(root): path = root / name if path.is_symlink() or not path.is_file(): continue try: text = path.read_text(encoding="utf-8") except (UnicodeDecodeError, OSError): continue findings.extend(check_text(name, text)) return findings def main(argv: list[str]) -> int: if len(argv) > 1: print("Aufruf: check_secret_lookalikes.py [ORDNER]", file=sys.stderr) return 2 # Die Windows-Konsole schreibt cp1252; ein „→“ aus einer gefundenen Zeile bräche sonst die Ausgabe ab. reconfigure = getattr(sys.stdout, "reconfigure", None) if reconfigure is not None: reconfigure(errors="replace") findings = find_violations(Path(argv[0]), tracked=False) if argv else find_violations() for finding in findings: print(finding) if findings: print(f"\n{len(findings)} Stelle(n) sehen wie echte Geheimnisse aus. Attrappen zur Laufzeit aus Teilen zusammensetzen.") return 1 print("OK: Nichts sieht wie ein echtes Geheimnis aus.") return 0 if __name__ == "__main__": sys.exit(main(sys.argv[1:]))