chore(security): Baseline-Update Prüfstempel und release-checks

Makefile und scripts/security/stamp.py aus cj/security-baseline, leere
docs/RELEASE-AUSNAHMEN.md, AGENTS.md-Abschnitt zu Stempeln und Release.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Grovy311 2026-10-17 10:46:29 +02:00
parent ad9ca4cae5
commit e2cc00dccc
4 changed files with 392 additions and 4 deletions

View file

@ -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/<date>-<commit>.md` (`make release-report`) and tag
`release-<date>` on the deployed commit.

View file

@ -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

15
docs/RELEASE-AUSNAHMEN.md Normal file
View file

@ -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 |
|----|-------|--------------|--------------|------------|-------------|------------|

330
scripts/security/stamp.py Normal file
View file

@ -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/<commit>.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))