#!/usr/bin/env python3 """Leitet aus dem Git-Diff ab, welche Baseline-Prüfungen vor dem Push nötig sind. Aufruf (aus dem Repo-Root, so wie das Makefile es tut): changed_scope.py targets [BASE] make-Ziele für `make prepush`, eine Zeile changed_scope.py files [BASE] geänderte, noch vorhandene Dateien (für `make sast-diff`) changed_scope.py range [BASE] Commit-Bereich für gitleaks, leer = keine Basis gefunden changed_scope.py explain [BASE] Begründung für Menschen BASE ist die Git-Referenz, gegen die verglichen wird. Ohne BASE: Upstream des Branches, sonst origin/HEAD, origin/main, origin/master, main, master. Findet sich keine Basis, wird alles geprüft (sicherer Standard). Geprüft wird der Unterschied zwischen Basis und Arbeitsverzeichnis: die Commits seit der Basis plus noch nicht committete und neue Dateien. Einteilung (Reihenfolge zählt, erste passende Regel gewinnt): Werkzeug Makefile, baseline.mk, Semgrep-Regeln, Mutationen, pre-commit, CI -> volles check Abhängigk. requirements*, pyproject, package*.json, Lockfiles -> lint test audit ohne Code Doku, Bilder -> nur Secrets Code alles andere -> lint test sast-diff Zusätzlich: Pfad passt auf SECURITY_PATHS (Auth, Rollen, Routen, Schemas ...) -> + mutation. Anpassbar per Umgebung (in baseline.mk setzen, das Makefile exportiert sie): SECURITY_PATHS, DOCS_PATHS, DEPS_PATHS, TOOLING_PATHS (reguläre Ausdrücke), MUTATIONS (Datei). """ import os import re import shutil import subprocess # nosec B404 - nur feste git-Aufrufe ohne Shell import sys GIT = shutil.which("git") or "git" DEFAULT_TOOLING = ( r"(^|/)(Makefile|baseline\.mk|\.pre-commit-config\.yaml|\.gitleaks\.toml|" r"[^/]*mutations[^/]*\.toml|\.semgrepignore)$|(^|/)\.semgrep/|(^|/)scripts/security/|" r"(^|/)\.forgejo/|(^|/)\.github/workflows/" ) DEFAULT_DEPS = ( r"(^|/)(requirements[^/]*\.(txt|in)|pyproject\.toml|poetry\.lock|uv\.lock|" r"Pipfile(\.lock)?|package(-lock)?\.json|npm-shrinkwrap\.json|pnpm-lock\.yaml|yarn\.lock)$" ) DEFAULT_DOCS = ( r"(\.(md|rst|adoc|txt|png|jpe?g|gif|webp|ico|pdf)$|^docs/|(^|/)LICENSE[^/]*$|" r"(^|/)\.gitignore$|(^|/)\.gitattributes$)" ) DEFAULT_SECURITY = ( r"(?i)(auth|login|logout|session|token|passw|secret|permission|role|rbac|acl|owner|" r"tenant|route|router|middleware|schema|model|cors|csrf|security|config|settings|" r"(^|/)(main|app|deps|dependencies)\.py$|(^|/)(server|app|index)\.[cm]?[jt]sx?$)" ) def _git(*args): cmd = [GIT, *args] # Feste git-Aufrufe ohne Shell, Argumente nur aus diesem Skript bzw. Git-Referenzen. # RUF100 im noqa: Apps ohne S-Regeln würden das noqa sonst als unnötig melden. r = subprocess.run(cmd, capture_output=True, text=True, check=False) # noqa: S603, RUF100 # nosec B603 return r.returncode, r.stdout.strip() def _rx(name, default): return re.compile(os.environ.get(name) or default) def find_base(arg): """Gibt den Merge-Base-Commit zurück oder None.""" candidates = [arg] if arg else [] if not arg: rc, up = _git("rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}") if rc == 0 and up: candidates.append(up) candidates += ["origin/HEAD", "origin/main", "origin/master", "main", "master"] for ref in candidates: rc, _ = _git("rev-parse", "--verify", "-q", ref + "^{commit}") if rc != 0: continue rc, mb = _git("merge-base", ref, "HEAD") if rc == 0 and mb: return mb return None def changed_files(base): files = set() for args in ( ("diff", "--name-only", "--no-renames", base, "HEAD"), ("diff", "--name-only", "--no-renames", "HEAD"), ("ls-files", "--others", "--exclude-standard"), ): rc, out = _git(*args) if rc == 0 and out: files.update(line for line in out.splitlines() if line) return sorted(files) def classify(files): tooling = _rx("TOOLING_PATHS", DEFAULT_TOOLING) deps = _rx("DEPS_PATHS", DEFAULT_DEPS) docs = _rx("DOCS_PATHS", DEFAULT_DOCS) sec = _rx("SECURITY_PATHS", DEFAULT_SECURITY) groups = {"tooling": [], "deps": [], "docs": [], "code": [], "security": []} for f in files: if tooling.search(f): groups["tooling"].append(f) elif deps.search(f): groups["deps"].append(f) elif docs.search(f): groups["docs"].append(f) else: groups["code"].append(f) if sec.search(f): groups["security"].append(f) return groups def targets(base, groups): mutations = os.environ.get("MUTATIONS") or "security-mutations.toml" has_mut = os.path.isfile(mutations) if base is None or groups["tooling"]: first = "secrets" if base is None else "secrets-diff" t = [first, "lint", "test", "sast", "audit"] return t + (["mutation"] if has_mut else []) t = ["secrets-diff"] if groups["code"] or groups["deps"]: t += ["lint", "test"] if groups["code"]: t.append("sast-diff") if groups["deps"]: t.append("audit") if groups["security"] and has_mut: t.append("mutation") return t def explain(base, files, groups, t): if base is None: return "prepush: keine Basis (Upstream/main) gefunden -> volles Programm" short = _git("rev-parse", "--short", base)[1] lines = [f"prepush: Basis {short}, {len(files)} geänderte Datei(en)"] labels = { "tooling": "Baseline-Werkzeug (-> volles check)", "deps": "Abhängigkeiten (-> audit)", "security": "sicherheitsrelevant (-> mutation)", "code": "Code (-> lint, test, sast-diff)", "docs": "ohne Code (-> nur Secrets)", } for key, label in labels.items(): if groups[key]: more = " ..." if len(groups[key]) > 4 else "" sample = ", ".join(groups[key][:4]) + more lines.append(f" {label}: {sample}") lines.append(" Ziele: " + " ".join(t)) return "\n".join(lines) def main(argv): if len(argv) < 2 or argv[1] not in {"targets", "files", "range", "explain"}: print(__doc__, file=sys.stderr) return 2 mode, arg = argv[1], (argv[2] if len(argv) > 2 else "") base = find_base(arg) if mode == "range": print(f"{base}..HEAD" if base else "") return 0 files = changed_files(base) if base else [] if mode == "files": print("\n".join(f for f in files if os.path.isfile(f))) return 0 groups = classify(files) t = targets(base, groups) print(" ".join(t) if mode == "targets" else explain(base, files, groups, t)) return 0 if __name__ == "__main__": sys.exit(main(sys.argv))