chore(baseline): make prepush/weekly, scanner fix for git worktrees
Some checks failed
security-baseline / secrets (push) Has been cancelled
security-baseline / node (push) Has been cancelled
security-baseline / semgrep (push) Has been cancelled
security-baseline / trivy (push) Has been cancelled

Update the Test & Security Baseline from cj/security-baseline (364d756):
- make prepush runs only the checks the diff needs (scripts/security/changed_scope.py)
- make weekly = check + trivy-fs, run by the weekly cron on cj-ki
- in git worktrees gitleaks scanned 0 commits and Semgrep ignored .gitignore (.next/); fixed

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Grovy311 2026-10-16 07:25:51 +02:00
parent afd8019a7b
commit ad9ca4cae5
3 changed files with 251 additions and 13 deletions

View file

@ -0,0 +1,179 @@
#!/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))