diff --git a/AGENTS.md b/AGENTS.md index b9e05ac..559ef1b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -821,3 +821,14 @@ When uncertain, choose the simplest implementation that preserves the domain mod Do not add libraries unless there is a clear reason. Do not introduce global state management unless needed. + +## Check stamps & release (baseline update 2026-10-17) +- `make stamp` shows which checks are validly green for HEAD (`reports/stamps/`, local only). Stamped + checks are skipped by `make prepush` and `make release-checks`. Valid only for a clean working tree; + docs-only commits afterwards keep the stamp valid. +- Before deploy via `pre-release-gate`: `make release-checks` (check + mutation, with `IMAGE=` also + container). ZAP/fuzz run after the deploy against our own instance and do not block the Go. +- Deliberately accepted H/M items: `docs/RELEASE-AUSNAHMEN.md` (reason + review date, never K items, + entries only after CJ decides). +- After deploy: record `docs/releases/-.md` (`make release-report`) and tag + `release-` on the deployed commit. diff --git a/Makefile b/Makefile index d9296fa..ffa4eff 100644 --- a/Makefile +++ b/Makefile @@ -8,7 +8,12 @@ # Wann was läuft: # beim Coden make quick (Lint + Tests; TESTS=... nur betroffene + Sicherheitstests) # vor dem Push make prepush (nur was der Diff braucht, siehe scripts/security/changed_scope.py) -# vor Deploy make check (alles, über Skill pre-release-gate) +# vor Deploy make release-checks (check + mutation [+ container], nur ohne gültigen Stempel; +# über Skill pre-release-gate) +# nach Deploy make zap / make fuzz (gegen die eigene Live-Instanz, blockiert das Go nicht) +# +# Stempel: check, prepush, mutation, container, zap und fuzz merken sich in reports/stamps/, für +# welchen Commit sie grün waren (scripts/security/stamp.py). `make stamp` zeigt den Stand. # wöchentlich make weekly (check + Trivy, zeitabhängige Funde wie neue CVEs) -include baseline.mk @@ -19,6 +24,7 @@ NODE_DIR ?= frontend SEMGREP_RULES ?= .semgrep SCRIPTS ?= scripts/security MUTATIONS ?= security-mutations.toml +STAMP ?= python3 $(SCRIPTS)/stamp.py NODE_IMAGE ?= node:24 OPENAPI_PATH ?= /openapi.json FUZZ_CHECKS ?= not_a_server_error @@ -65,7 +71,7 @@ GITLEAKS :=docker run --rm -u $(UID_GID) -v "$(CURDIR)":/repo $(GIT_MOUNT) $(G NPM ?= $(if $(shell command -v npm 2>/dev/null),npm --prefix $(NODE_DIR),docker run --rm -u $(UID_GID) -e HOME=/tmp -v "$(CURDIR)":/w -w /w/$(NODE_DIR) $(NODE_IMAGE) npm) .DEFAULT_GOAL := help -.PHONY: help setup quick prepush check weekly lint test secrets secrets-diff sast sast-diff audit trivy-fs container zap fuzz mutation mutmut vet-deps +.PHONY: help setup quick prepush check release-checks release-report stamp weekly lint test secrets secrets-diff sast sast-diff audit trivy-fs container zap fuzz mutation mutmut vet-deps help: ## Diese Übersicht @grep -hE '^[a-z-]+:.*## ' $(MAKEFILE_LIST) | awk -F':.*## ' '{printf " make %-12s %s\n", $$1, $$2}' @@ -86,12 +92,30 @@ endif quick: lint test ## Lint + Tests beim Coden; nur betroffene: make quick TESTS="tests/test_x.py" prepush: ## Vor dem Push: nur die Prüfungen, die der Diff braucht (BASE=ref setzt die Basis) + @$(STAMP) begin prepush @python3 $(SCRIPTS)/changed_scope.py explain $(BASE) - @t=$$(python3 $(SCRIPTS)/changed_scope.py targets $(BASE)) && $(MAKE) --no-print-directory $$t && echo "make prepush: grün ($$t)" + @t=$$(python3 $(SCRIPTS)/changed_scope.py targets $(BASE)) && t=$$($(STAMP) pending $$t) && if [ -z "$$t" ]; then echo "make prepush: für diesen Commit schon alles grün (make stamp)"; else $(MAKE) --no-print-directory $$t && $(STAMP) record prepush $$t && echo "make prepush: grün ($$t)"; fi -check: secrets lint test sast audit ## Alles: Secrets, Lint, Tests, SAST, Audit (vor Deploy, bei Werkzeug-Änderung) +check: ## Alles: Secrets, Lint, Tests, SAST, Audit (vor Deploy, bei Werkzeug-Änderung) + @$(STAMP) begin check + @$(MAKE) --no-print-directory secrets lint test sast audit + @$(STAMP) record check @echo "make check: alles grün" +RELEASE_CHECKS = check $(if $(wildcard $(MUTATIONS)),mutation) $(if $(IMAGE),container=$(IMAGE)) +release-checks: ## Vor Deploy: check, mutation, container (IMAGE=) nur, wenn für den Commit kein gültiger Stempel da ist + @$(STAMP) clean || (echo "release-checks: Arbeitsbaum nicht sauber, erst committen" && exit 1) + @$(STAMP) status check >/dev/null || $(MAKE) --no-print-directory check + @if [ -f "$(MUTATIONS)" ]; then $(STAMP) status mutation >/dev/null || $(MAKE) --no-print-directory mutation; fi + @if [ -n "$(IMAGE)" ]; then $(STAMP) status "container=$(IMAGE)" >/dev/null || $(MAKE) --no-print-directory container IMAGE="$(IMAGE)"; fi + @$(STAMP) status $(RELEASE_CHECKS) + +release-report: ## Markdown-Tabelle der Prüfungen fürs Release-Protokoll (docs/releases/) + @$(STAMP) report $(patsubst container=%,container,$(RELEASE_CHECKS)) zap fuzz + +stamp: ## Stempel für den aktuellen Commit anzeigen + @$(STAMP) status + weekly: ## Wochenlauf: check + Trivy, läuft nach Fehlern weiter (neue CVEs kommen ohne Code-Änderung) $(MAKE) -k --no-print-directory secrets lint test sast audit trivy-fs @echo "make weekly: alles grün" @@ -150,21 +174,29 @@ trivy-fs: ## Trivy übers Repo: Abhängigkeiten, Secrets, Fehlkonfiguration (HIG container: ## Image + Dockerfile/Compose scannen: make container IMAGE=name:tag @test -n "$(IMAGE)" || (echo "IMAGE=name:tag angeben" && exit 2) + @$(STAMP) begin container docker run --rm -v /var/run/docker.sock:/var/run/docker.sock $(TRIVY_IMAGE) image --severity HIGH,CRITICAL --exit-code 1 --ignore-unfixed $(IMAGE) $(DOCKER) $(TRIVY_IMAGE) config --severity HIGH,CRITICAL --exit-code 1 /src for f in $$(ls Dockerfile* 2>/dev/null); do docker run --rm -i $(HADOLINT_IMAGE) < $$f || exit 1; done + @$(STAMP) record container $(IMAGE) zap: ## ZAP-Baseline (bricht nur bei FAIL ab, ZAP_ARGS= für strikt): make zap TARGET=https://... @test -n "$(TARGET)" || (echo "TARGET=url angeben" && exit 2) mkdir -p reports + @$(STAMP) begin zap docker run --rm --network host -u $(UID_GID) -v "$(CURDIR)/reports":/zap/wrk $(ZAP_IMAGE) zap-baseline.py -t $(TARGET) -r zap-baseline.html $(ZAP_ARGS) + @$(STAMP) record zap $(TARGET) fuzz: ## API-Fuzzing (5xx-Suche; FUZZ_CHECKS=all für volle Spec-Prüfung): make fuzz TARGET=http://... @test -n "$(TARGET)" || (echo "TARGET=url angeben" && exit 2) + @$(STAMP) begin fuzz docker run --rm --network host $(SCHEMATHESIS_IMAGE) run $(TARGET)$(OPENAPI_PATH) --checks $(FUZZ_CHECKS) $(FUZZ_ARGS) + @$(STAMP) record fuzz $(TARGET) mutation: ## Sicherheits-Mutationen: jede muss einen Test rot machen + @$(STAMP) begin mutation python3 $(SCRIPTS)/mutation_check.py $(MUTATIONS) + @$(STAMP) record mutation mutmut: ## Mutation-Testing des ganzen Python-Codes (langsam) cd $(PY_DIR) && .venv/bin/mutmut run && .venv/bin/mutmut results diff --git a/docs/RELEASE-AUSNAHMEN.md b/docs/RELEASE-AUSNAHMEN.md new file mode 100644 index 0000000..8fb181d --- /dev/null +++ b/docs/RELEASE-AUSNAHMEN.md @@ -0,0 +1,15 @@ +# Release-Ausnahmen + +Bekannte H-/M-Punkte (ASVS-light) und Scanner-Funde, die bewusst akzeptiert sind. Das +`pre-release-gate` zählt einen Punkt aus dieser Liste nicht erneut als Auflage, solange +„Prüfen bis“ nicht überschritten ist. Danach ist er wieder offen und braucht eine neue Entscheidung. + +Regeln: +- **K-Punkte gehören nie hierher.** Ein offener K-Punkt bleibt No-Go. +- Jede Zeile braucht eine Begründung und ein Datum. Ohne Datum gilt die Ausnahme nicht. +- Technische Ausnahmen gehören zusätzlich an den Ort, den das Werkzeug liest + (`.trivyignore`, `# nosec Bxxx` mit Grund, `.gitleaks.toml`), mit Verweis auf die ID hier. +- Wer eine Ausnahme einträgt, ist CJ (Entscheidung), nicht der Agent. + +| ID | Stufe | Punkt / Fund | Entscheidung | Begründung | Eingetragen | Prüfen bis | +|----|-------|--------------|--------------|------------|-------------|------------| diff --git a/scripts/security/stamp.py b/scripts/security/stamp.py new file mode 100644 index 0000000..e2af147 --- /dev/null +++ b/scripts/security/stamp.py @@ -0,0 +1,330 @@ +#!/usr/bin/env python3 +"""Prüfstempel: merkt sich, welche Baseline-Prüfung für welchen Commit grün war. + +Damit läuft vor dem Deploy nicht noch einmal, was für genau diesen Commit grün war. + +Aufruf (aus dem Repo-Root, so wie das Makefile es tut): + stamp.py begin NAME vor dem Lauf: Ausgangszustand merken + stamp.py record NAME [DETAIL] nach grünem Lauf: Stempel für HEAD schreiben + stamp.py status [NAME[=DETAIL]] Exit 0, wenn alle genannten Stempel für HEAD gelten + und der Arbeitsbaum sauber ist; ohne NAME Übersicht + stamp.py report [NAME ...] Markdown-Tabelle fürs Release-Protokoll + stamp.py clean Exit 0, wenn der Arbeitsbaum sauber ist + stamp.py pending ZIEL ... gibt die make-Ziele aus, die kein gültiger Stempel + abdeckt (für `make prepush`) + +Ein Stempel gilt nur, wenn genau der Commit geprüft wurde: + - Arbeitsbaum beim Start sauber (keine geänderten, keine neuen Dateien), + - HEAD am Ende gleich HEAD am Start, + - keine versionierte Datei vom Lauf verändert (neue Artefakte wie Logs erlaubt). +Ein Stempel eines älteren Commits gilt weiter, wenn danach nur Doku kam +(Einteilung wie `make prepush`, siehe changed_scope.py). +`prepush` mit vollem Programm (secrets lint test sast audit) zählt auch als `check`. + +Ablage: reports/stamps/.json (STAMP_DIR, relativ zum Repo-Root). +Nur lokal, nie committen. +""" + +import datetime +import json +import os +import re +import shutil +import subprocess # nosec B404 - nur feste git-Aufrufe ohne Shell +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from changed_scope import classify # noqa: E402, RUF100 + +GIT = shutil.which("git") or "git" +STAMP_DIR = os.environ.get("STAMP_DIR") or os.path.join("reports", "stamps") +NAME_RE = re.compile(r"^[a-z][a-z0-9-]{0,39}$") +FULL_CHECK = {"secrets", "lint", "test", "sast", "audit"} +DETAIL_NAMES = {"container", "zap", "fuzz"} +# Welche prepush-Ziele ein gültiger Stempel abdeckt. +COVERS = { + "check": {"secrets", "secrets-diff", "lint", "test", "sast", "sast-diff", "audit"}, + "mutation": {"mutation"}, +} +SEARCH_DEPTH = 50 + + +def _git(*args): + cmd = [GIT, *args] + # Feste git-Aufrufe ohne Shell, Argumente nur aus diesem Skript bzw. Commit-Hashes. + r = subprocess.run(cmd, capture_output=True, text=True, check=False) # noqa: S603, RUF100 # nosec B603 + return r.returncode, r.stdout.strip() + + +def _outside_stamps(): + return ["--", ".", f":(exclude){STAMP_DIR}"] + + +def head(): + rc, out = _git("rev-parse", "HEAD") + return out if rc == 0 else None + + +def tree_state(): + """Geänderte versionierte und neue Dateien, ohne den Stempelordner selbst.""" + args = ("status", "--porcelain=v1", "--untracked-files=all") + rc, out = _git(*args, *_outside_stamps()) + tracked, untracked = [], [] + for line in out.splitlines() if rc == 0 else []: + (untracked if line.startswith("??") else tracked).append(line[3:]) + return tracked, untracked + + +def _now(): + return datetime.datetime.now().astimezone().isoformat(timespec="seconds") + + +def _check_name(name): + if not NAME_RE.match(name): + print(f"stamp: ungültiger Name {name!r}", file=sys.stderr) + sys.exit(2) + + +def _stamp_file(commit): + return os.path.join(STAMP_DIR, commit + ".json") + + +def _begin_file(name): + return os.path.join(STAMP_DIR, f".begin-{name}.json") + + +def _load(path, default): + try: + with open(path, encoding="utf-8") as f: + return json.load(f) + except (OSError, ValueError): + return default + + +def _entries(commit): + return _load(_stamp_file(commit), {}).get("entries", []) + + +def _save(path, data): + os.makedirs(STAMP_DIR, exist_ok=True) + tmp = path + ".tmp" + with open(tmp, "w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False, indent=1) + os.replace(tmp, path) + + +def begin(name): + tracked, untracked = tree_state() + state = {"head": head(), "tracked": tracked, "untracked": untracked} + _save(_begin_file(name), state) + return 0 + + +def _entry(name, detail, start, end_head, end_tracked, end_untracked): + notes, valid = [], True + if start is None: + valid, notes = False, ["kein Startzustand (begin fehlt)"] + else: + if start.get("tracked") or start.get("untracked"): + valid = False + notes.append("Arbeitsbaum beim Start nicht sauber") + if start.get("head") != end_head: + valid = False + notes.append("HEAD hat sich während des Laufs geändert") + if end_tracked: + valid = False + changed = ", ".join(end_tracked[:5]) + notes.append("Lauf hat versionierte Dateien verändert: " + changed) + new = sorted(set(end_untracked) - set(start.get("untracked") or [])) + if new: + notes.append("neue Dateien aus dem Lauf: " + ", ".join(new[:5])) + return { + "name": name, + "detail": detail, + "valid": valid, + "at": _now(), + "notes": notes, + } + + +def record(name, detail): + start = _load(_begin_file(name), None) + end_head = head() + if end_head is None: + print("stamp: kein Commit (HEAD fehlt)", file=sys.stderr) + return 1 + tracked, untracked = tree_state() + entries = [_entry(name, detail, start, end_head, tracked, untracked)] + if name == "prepush" and FULL_CHECK <= set(detail.split()): + entries.append(dict(entries[0], name="check", detail="über prepush")) + data = _load(_stamp_file(end_head), {"commit": end_head, "entries": []}) + data["entries"].extend(entries) + _save(_stamp_file(end_head), data) + try: + os.remove(_begin_file(name)) + except OSError: + pass + e = entries[0] + if e["valid"]: + print(f"stamp: {name} gültig für {end_head[:7]}") + else: + why = "; ".join(e["notes"]) + print(f"stamp: {name} UNGÜLTIG für {end_head[:7]} ({why})") + return 0 + + +def _latest(commit, name, detail): + """Jüngster Eintrag für name (und detail, falls angegeben) in einem Commit.""" + for e in reversed(_entries(commit)): + if e.get("name") != name: + continue + if detail is None or e.get("detail") == detail: + return e + return None + + +def _docs_only(old, new): + rc, out = _git("diff", "--name-only", "--no-renames", old, new, *_outside_stamps()) + if rc != 0: + return False + files = [f for f in out.splitlines() if f] + return len(classify(files)["docs"]) == len(files) + + +def lookup(name, detail): + """(Zustand, Eintrag, Commit): Zustand ist gültig, ungültig oder fehlt.""" + cur = head() + if cur is None: + return "fehlt", None, None + rc, out = _git("rev-list", f"--max-count={SEARCH_DEPTH}", "HEAD") + commits = out.splitlines() if rc == 0 else [cur] + first_seen = None + for c in commits: + if c != cur and not _docs_only(c, cur): + break + e = _latest(c, name, detail) + if e is None: + continue + if e.get("valid"): + return "gültig", e, c + first_seen = first_seen or (e, c) + if first_seen: + return "ungültig", first_seen[0], first_seen[1] + return "fehlt", None, None + + +def _parse(spec): + name, _, detail = spec.partition("=") + _check_name(name) + return name, (detail or None) + + +def _describe(name, detail, state, e, c, cur): + label = name + (f" ({detail})" if detail else "") + if state == "fehlt": + return f" {label:<24} fehlt" + via = "" if c == cur else f", von {c[:7]}, danach nur Doku" + notes = "; ".join(e.get("notes") or []) + line = f" {label:<24} {state} {e.get('at', '')}{via}" + return line + (f" [{notes}]" if notes else "") + + +def _known_names(): + cur = head() + names = [("check", None), ("mutation", None)] + for e in _entries(cur) if cur else []: + name = e.get("name") + key = (name, e.get("detail") if name in DETAIL_NAMES else None) + if key not in names: + names.append(key) + return names + + +def status(specs): + cur = head() + tracked, untracked = tree_state() + dirty = tracked + untracked + tree = "sauber" if not dirty else f"nicht sauber ({len(dirty)} Dateien)" + print(f"Stempel für {cur[:7] if cur else '-'}: Arbeitsbaum {tree}") + wanted = [_parse(s) for s in specs] + ok = True + for name, detail in wanted or _known_names(): + state, e, c = lookup(name, detail) + print(_describe(name, detail, state, e, c, cur)) + ok = ok and state == "gültig" + if not wanted: + return 0 + return 0 if ok and not dirty else 1 + + +def _report_row(name, cur): + state, e, c = lookup(name, None) + if state == "fehlt": + return f"| {name} | nicht ausgeführt | | | |" + result = "grün" if state == "gültig" else "ungültig" + via = c[:7] + (" (danach nur Doku)" if c != cur else "") + hints = ([e["detail"]] if e.get("detail") else []) + (e.get("notes") or []) + return f"| {name} | {result} | {e.get('at', '')} | {via} | {'; '.join(hints)} |" + + +def report(names): + for n in names: + _check_name(n) + cur = head() + rows = [ + "| Prüfung | Ergebnis | Zeit | Commit | Hinweise |", + "|---|---|---|---|---|", + ] + for name in names or [n for n, _ in _known_names()]: + rows.append(_report_row(name, cur)) + print("\n".join(rows)) + return 0 + + +def clean(): + tracked, untracked = tree_state() + for path in (tracked + untracked)[:10]: + print(f" nicht committet: {path}") + return 0 if not tracked + untracked else 1 + + +def pending(targets): + """Ziele ohne gültigen Stempel; bei unsauberem Arbeitsbaum alle.""" + tracked, untracked = tree_state() + covered = set() + if not tracked + untracked: + for name, targets_covered in COVERS.items(): + if lookup(name, None)[0] == "gültig": + covered |= targets_covered + skipped = [t for t in targets if t in covered] + if skipped: + print(" Stempel gültig, entfällt: " + " ".join(skipped), file=sys.stderr) + print(" ".join(t for t in targets if t not in covered)) + return 0 + + +def main(argv): + modes = {"begin", "record", "status", "report", "clean", "pending"} + if len(argv) < 2 or argv[1] not in modes: + print(__doc__, file=sys.stderr) + return 2 + mode, args = argv[1], argv[2:] + if mode == "clean": + return clean() + if mode == "status": + return status(args) + if mode == "report": + return report(args) + if mode == "pending": + return pending(args) + if not args: + print(__doc__, file=sys.stderr) + return 2 + _check_name(args[0]) + if mode == "begin": + return begin(args[0]) + return record(args[0], " ".join(args[1:])) + + +if __name__ == "__main__": + sys.exit(main(sys.argv))