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>
179 lines
6.7 KiB
Python
179 lines
6.7 KiB
Python
#!/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))
|