chore(baseline): make prepush/weekly, scanner fix for git worktrees
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:
parent
afd8019a7b
commit
ad9ca4cae5
3 changed files with 251 additions and 13 deletions
179
scripts/security/changed_scope.py
Normal file
179
scripts/security/changed_scope.py
Normal 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))
|
||||
Loading…
Add table
Add a link
Reference in a new issue