Merge PR #122 (OPS-08/09/10 Haertungsbuendel)

This commit is contained in:
patrick committed 2026-09-22 11:22:43 +02:00
commit 2cb48e6e73
47 files changed
+1093 -114

No files matched your search

+1 -1
View File
@@ -21,7 +21,7 @@ func main() {
}
addr := os.Getenv("NEXARCH_RESTORETEST_METRICS_LISTEN_ADDR")
if addr == "" {
addr = ":8091"
addr = "127.0.0.1:8091"
}
mux := http.NewServeMux()
+1 -1
View File
@@ -25,7 +25,7 @@ func main() {
}
addr := os.Getenv("NEXARCH_SCRUB_METRICS_LISTEN_ADDR")
if addr == "" {
addr = ":8090"
addr = "127.0.0.1:8090"
}
ctx := context.Background()
+8 -7
View File
@@ -1,14 +1,15 @@
module gitea.perlbach24.de/scripte/nexarch/archive
go 1.22
go 1.25.0
require github.com/jackc/pgx/v5 v5.6.0
toolchain go1.25.13
require github.com/jackc/pgx/v5 v5.9.2
require (
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
github.com/jackc/puddle/v2 v2.2.1 // indirect
golang.org/x/crypto v0.17.0 // indirect
golang.org/x/sync v0.1.0 // indirect
golang.org/x/text v0.14.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
golang.org/x/sync v0.22.0 // indirect
golang.org/x/text v0.41.0 // indirect
)
+12 -14
View File
@@ -3,25 +3,23 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY=
github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw=
github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k=
golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+10
View File
@@ -52,6 +52,7 @@ func main() {
rotateModuleKey := flag.Bool("rotate-module-key", false, "neuen Modul-Signaturschluessel anlegen und beenden")
rotateSessionKey := flag.Bool("rotate-session-key", false, "QA-15 F-10: neuen Sitzungs-Signaturschluessel anlegen und beenden")
encryptTOTPSecrets := flag.Bool("encrypt-totp-secrets", false, "QA-15 F-06: bestehende Klartext-TOTP-Secrets nachverschluesseln und beenden")
rewrapModuleKeys := flag.Bool("rewrap-module-keys", false, "QA-15 F-25: gespeicherte Modul-Signaturschluessel von "+envKeyEncryptionKeyOld+" auf "+envKeyEncryptionKey+" neu verpacken und beenden")
flag.Parse()
dsn := requireEnv("NEXARCH_ACCOUNT_TENANT_DSN")
tenantSlug := requireEnv("NEXARCH_ACCOUNT_TENANT_SLUG")
@@ -163,6 +164,15 @@ func main() {
if moduleIssuer == "" {
moduleIssuer = "nexarch-core"
}
if *rewrapModuleKeys {
n, err := rewrapModuleSigningKeys(ctx, pool)
if err != nil {
log.Fatalf("modul-schluessel neu verpacken: %v", err)
}
log.Printf("account-api: %d modul-schluessel neu verpackt", n)
return
}
moduleKeys, err := newModuleKeyManager(ctx, pool, os.Getenv, moduleTokenTTL)
if err != nil {
log.Fatalf("modul-schluessel: %v", err)
+30 -5
View File
@@ -13,11 +13,16 @@ import (
)
const (
envKeyEncryptionKey = "NEXARCH_ACCOUNT_MODULE_KEY_ENCRYPTION_KEY"
envKeysEphemeral = "NEXARCH_ACCOUNT_MODULE_KEYS_EPHEMERAL"
envKeyRotationDays = "NEXARCH_ACCOUNT_MODULE_KEY_ROTATION_DAYS"
envKeyActivationSecs = "NEXARCH_ACCOUNT_MODULE_KEY_ACTIVATION_DELAY_SECONDS"
envKeyRefreshSecs = "NEXARCH_ACCOUNT_MODULE_KEY_REFRESH_SECONDS"
envKeyEncryptionKey = "NEXARCH_ACCOUNT_MODULE_KEY_ENCRYPTION_KEY"
// envKeyEncryptionKeyOld ist nur fuer `-rewrap-module-keys` (QA-15 F-25)
// relevant: der VORHERIGE Verschluesselungsschluessel, mit dem die
// gespeicherten Modul-Signaturschluessel gerade noch entschluesselbar
// sind, bevor sie auf envKeyEncryptionKey neu verpackt werden.
envKeyEncryptionKeyOld = "NEXARCH_ACCOUNT_MODULE_KEY_ENCRYPTION_KEY_OLD"
envKeysEphemeral = "NEXARCH_ACCOUNT_MODULE_KEYS_EPHEMERAL"
envKeyRotationDays = "NEXARCH_ACCOUNT_MODULE_KEY_ROTATION_DAYS"
envKeyActivationSecs = "NEXARCH_ACCOUNT_MODULE_KEY_ACTIVATION_DELAY_SECONDS"
envKeyRefreshSecs = "NEXARCH_ACCOUNT_MODULE_KEY_REFRESH_SECONDS"
defaultRotationDays = 30
defaultActivationSecs = 120
@@ -107,3 +112,23 @@ func newModuleKeyManager(ctx context.Context, pool *pgxpool.Pool, getenv func(st
RefreshInterval: cfg.refreshInterval,
})
}
// rewrapModuleSigningKeys verpackt alle gespeicherten Modul-Signaturschluessel
// von envKeyEncryptionKeyOld auf envKeyEncryptionKey um (QA-15 F-25). Beide
// Umgebungsvariablen muessen gesetzt sein; ephemere (nicht persistente)
// Schluesselverwaltung hat nichts zum Neuverpacken.
func rewrapModuleSigningKeys(ctx context.Context, pool *pgxpool.Pool) (int, error) {
oldKey, err := kek.LoadMasterKeyFromEnv(envKeyEncryptionKeyOld)
if err != nil {
return 0, fmt.Errorf("%s: %w", envKeyEncryptionKeyOld, err)
}
newKey, err := kek.LoadMasterKeyFromEnv(envKeyEncryptionKey)
if err != nil {
return 0, fmt.Errorf("%s: %w", envKeyEncryptionKey, err)
}
res, err := moduletrust.RewrapAll(ctx, moduletrust.NewPGKeyStore(pool), kekCipher{key: oldKey}, kekCipher{key: newKey})
if err != nil {
return 0, err
}
return res.Rewrapped, nil
}
+1 -1
View File
@@ -40,7 +40,7 @@ func main() {
}
addr := os.Getenv("NEXARCH_AUDITLOG_LISTEN_ADDR")
if addr == "" {
addr = ":8083"
addr = "127.0.0.1:8083"
}
// QA-15 F-09: kein "*" mehr — explizite Ursprungsliste (fail closed ohne
// Konfiguration, siehe svcauth.OriginsFromEnv).
+1 -1
View File
@@ -22,7 +22,7 @@ func main() {
}
addr := os.Getenv("NEXARCH_METRICS_LISTEN_ADDR")
if addr == "" {
addr = ":8085"
addr = "127.0.0.1:8085"
}
ctx := context.Background()
+7 -2
View File
@@ -8,6 +8,7 @@ package main
import (
"log"
"net/http"
"os"
"gitea.perlbach24.de/scripte/nexarch/internal/httpharden"
"gitea.perlbach24.de/scripte/nexarch/internal/secheaders"
@@ -36,6 +37,10 @@ func main() {
})
mux.HandleFunc("/unprotected", servePage)
log.Println("secheaders-devserver listening on :8086")
log.Fatal(httpharden.ListenAndServe(":8086", mux))
addr := os.Getenv("NEXARCH_SECHEADERS_LISTEN_ADDR")
if addr == "" {
addr = "127.0.0.1:8086"
}
log.Printf("secheaders-devserver listening on %s", addr)
log.Fatal(httpharden.ListenAndServe(addr, mux))
}
+1 -1
View File
@@ -23,7 +23,7 @@ func main() {
}
addr := os.Getenv("NEXARCH_STATUSPAGE_LISTEN_ADDR")
if addr == "" {
addr = ":8084"
addr = "127.0.0.1:8084"
}
intervalSeconds := 10
if v := os.Getenv("NEXARCH_STATUSPAGE_POLL_INTERVAL_SECONDS"); v != "" {
@@ -9,6 +9,12 @@ EnvironmentFile=/etc/nexarch/account-api.env
ExecStart=__INSTALL_DIR__/bin/account-api
Restart=on-failure
StandardOutput=journal
# QA-15 F-13/OPS-08: reiner Netzwerkdienst (HTTP + Postgres-DSN aus
# EnvironmentFile), keine eigene Schreibarbeit im Dateisystem.
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
[Install]
WantedBy=multi-user.target
@@ -9,6 +9,12 @@ EnvironmentFile=/etc/nexarch/archive-moduleadapter-api.env
ExecStart=__INSTALL_DIR__/bin/moduleadapter-api
Restart=on-failure
StandardOutput=journal
# QA-15 F-13/OPS-08: reiner Netzwerkdienst (HTTP + Postgres-DSN aus
# EnvironmentFile), keine eigene Schreibarbeit im Dateisystem.
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
[Install]
WantedBy=multi-user.target
@@ -9,6 +9,12 @@ EnvironmentFile=/etc/nexarch/archive-restoretest.env
ExecStart=__INSTALL_DIR__/bin/restoretest-metrics
Restart=on-failure
StandardOutput=journal
# QA-15 F-13/OPS-08: reiner Netzwerkdienst (HTTP + Postgres-DSN aus
# EnvironmentFile), keine eigene Schreibarbeit im Dateisystem.
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
[Install]
WantedBy=multi-user.target
@@ -9,6 +9,12 @@ EnvironmentFile=/etc/nexarch/archive-retention-api.env
ExecStart=__INSTALL_DIR__/bin/retention-api
Restart=on-failure
StandardOutput=journal
# QA-15 F-13/OPS-08: reiner Netzwerkdienst (HTTP + Postgres-DSN aus
# EnvironmentFile), keine eigene Schreibarbeit im Dateisystem.
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
[Install]
WantedBy=multi-user.target
@@ -9,6 +9,12 @@ EnvironmentFile=/etc/nexarch/archive-scrub-metrics.env
ExecStart=__INSTALL_DIR__/bin/scrub-metrics
Restart=on-failure
StandardOutput=journal
# QA-15 F-13/OPS-08: reiner Netzwerkdienst (HTTP + Postgres-DSN aus
# EnvironmentFile), keine eigene Schreibarbeit im Dateisystem.
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
[Install]
WantedBy=multi-user.target
@@ -12,6 +12,12 @@ ExecStart=__INSTALL_DIR__/bin/auditlog-devserver
Restart=on-failure
RestartSec=3
WorkingDirectory=__INSTALL_DIR__
# QA-15 F-13/OPS-08: reiner Netzwerkdienst (HTTP + Postgres-DSN aus
# EnvironmentFile), keine eigene Schreibarbeit im Dateisystem.
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
[Install]
WantedBy=multi-user.target
+6
View File
@@ -12,6 +12,12 @@ ExecStart=__INSTALL_DIR__/bin/core
Restart=on-failure
RestartSec=3
WorkingDirectory=__INSTALL_DIR__
# QA-15 F-13/OPS-08: reiner Netzwerkdienst (HTTP + Postgres-DSN aus
# EnvironmentFile), keine eigene Schreibarbeit im Dateisystem.
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
[Install]
WantedBy=multi-user.target
@@ -9,6 +9,12 @@ EnvironmentFile=/etc/nexarch/kek-api.env
ExecStart=__INSTALL_DIR__/bin/kek-api
Restart=on-failure
StandardOutput=journal
# QA-15 F-13/OPS-08: reiner Netzwerkdienst (HTTP + Postgres-DSN aus
# EnvironmentFile), keine eigene Schreibarbeit im Dateisystem.
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
[Install]
WantedBy=multi-user.target
@@ -12,6 +12,12 @@ ExecStart=__INSTALL_DIR__/bin/licadmin-devserver
Restart=on-failure
RestartSec=3
WorkingDirectory=__INSTALL_DIR__
# QA-15 F-13/OPS-08: reiner Netzwerkdienst (HTTP + Postgres-DSN aus
# EnvironmentFile), keine eigene Schreibarbeit im Dateisystem.
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
[Install]
WantedBy=multi-user.target
@@ -12,6 +12,12 @@ ExecStart=__INSTALL_DIR__/bin/metrics-devserver
Restart=on-failure
RestartSec=3
WorkingDirectory=__INSTALL_DIR__
# QA-15 F-13/OPS-08: reiner Netzwerkdienst (HTTP + Postgres-DSN aus
# EnvironmentFile), keine eigene Schreibarbeit im Dateisystem.
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
[Install]
WantedBy=multi-user.target
@@ -9,6 +9,12 @@ EnvironmentFile=/etc/nexarch/notify-api.env
ExecStart=__INSTALL_DIR__/bin/notify-api
Restart=on-failure
StandardOutput=journal
# QA-15 F-13/OPS-08: reiner Netzwerkdienst (HTTP + Postgres-DSN aus
# EnvironmentFile), keine eigene Schreibarbeit im Dateisystem.
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
[Install]
WantedBy=multi-user.target
@@ -9,6 +9,12 @@ EnvironmentFile=/etc/nexarch/notifyprefs-api.env
ExecStart=__INSTALL_DIR__/bin/notifyprefs-api
Restart=on-failure
StandardOutput=journal
# QA-15 F-13/OPS-08: reiner Netzwerkdienst (HTTP + Postgres-DSN aus
# EnvironmentFile), keine eigene Schreibarbeit im Dateisystem.
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
[Install]
WantedBy=multi-user.target
@@ -9,6 +9,12 @@ EnvironmentFile=/etc/nexarch/policy-api.env
ExecStart=__INSTALL_DIR__/bin/policy-api
Restart=on-failure
StandardOutput=journal
# QA-15 F-13/OPS-08: reiner Netzwerkdienst (HTTP + Postgres-DSN aus
# EnvironmentFile), keine eigene Schreibarbeit im Dateisystem.
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
[Install]
WantedBy=multi-user.target
@@ -9,6 +9,12 @@ EnvironmentFile=/etc/nexarch/rbac-admin-api.env
ExecStart=__INSTALL_DIR__/bin/rbac-admin-api
Restart=on-failure
StandardOutput=journal
# QA-15 F-13/OPS-08: reiner Netzwerkdienst (HTTP + Postgres-DSN aus
# EnvironmentFile), keine eigene Schreibarbeit im Dateisystem.
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
[Install]
WantedBy=multi-user.target
@@ -9,6 +9,12 @@ EnvironmentFile=/etc/nexarch/resync-api.env
ExecStart=__INSTALL_DIR__/bin/resync-api
Restart=on-failure
StandardOutput=journal
# QA-15 F-13/OPS-08: reiner Netzwerkdienst (HTTP + Postgres-DSN aus
# EnvironmentFile), keine eigene Schreibarbeit im Dateisystem.
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
[Install]
WantedBy=multi-user.target
@@ -12,6 +12,12 @@ ExecStart=__INSTALL_DIR__/bin/secheaders-devserver
Restart=on-failure
RestartSec=3
WorkingDirectory=__INSTALL_DIR__
# QA-15 F-13/OPS-08: reiner Netzwerkdienst (HTTP + Postgres-DSN aus
# EnvironmentFile), keine eigene Schreibarbeit im Dateisystem.
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
[Install]
WantedBy=multi-user.target
@@ -12,6 +12,12 @@ ExecStart=__INSTALL_DIR__/bin/statuspage-devserver
Restart=on-failure
RestartSec=3
WorkingDirectory=__INSTALL_DIR__
# QA-15 F-13/OPS-08: reiner Netzwerkdienst (HTTP + Postgres-DSN aus
# EnvironmentFile), keine eigene Schreibarbeit im Dateisystem.
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
[Install]
WantedBy=multi-user.target
@@ -12,6 +12,12 @@ ExecStart=__INSTALL_DIR__/bin/tenantadmin-devserver
Restart=on-failure
RestartSec=3
WorkingDirectory=__INSTALL_DIR__
# QA-15 F-13/OPS-08: reiner Netzwerkdienst (HTTP + Postgres-DSN aus
# EnvironmentFile), keine eigene Schreibarbeit im Dateisystem.
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
[Install]
WantedBy=multi-user.target
+1 -1
View File
@@ -40,7 +40,7 @@ import (
func main() {
addr := os.Getenv("NEXARCH_DMS_APP_LISTEN_ADDR")
if addr == "" {
addr = ":8600"
addr = "127.0.0.1:8600"
}
logger := logging.NewDefault()
+1 -1
View File
@@ -44,7 +44,7 @@ func main() {
metricsAddr := os.Getenv("NEXARCH_DMS_WORKER_METRICS_ADDR")
if metricsAddr == "" {
metricsAddr = ":8601"
metricsAddr = "127.0.0.1:8601"
}
go func() {
if err := httpharden.ListenAndServe(metricsAddr, metricsReg.Handler()); err != nil {
+2 -2
View File
@@ -8,7 +8,7 @@ Aus dem Code abgeleitet (`cmd/app`, `cmd/worker`, `cmd/migrate`, `internal/route
| Variable | Standard | Wirkung |
|---|---|---|
| `NEXARCH_DMS_APP_LISTEN_ADDR` | `:8600` | Lauschadresse |
| `NEXARCH_DMS_APP_LISTEN_ADDR` | `127.0.0.1:8600` | Lauschadresse (Reverse-Proxy erforderlich fuer externen Zugriff, F-24) |
| `NEXARCH_DMS_APP_ROUTE_PREFIX` | `/api/dms/v1` | Präfix aller fachlichen Routen (FDN-14); `/healthz` und `/metrics` bleiben außerhalb |
| `NEXARCH_DMS_APP_TENANT_DSN` | Pflicht für Fachfunktionen | Tenant-Datenbank; ohne sie: keine API-Routen, kein Guard, keine Retention-Anbindung, keine Suche |
| `NEXARCH_DMS_APP_TENANT_SLUG` | Pflicht | Mandantenkennung |
@@ -57,7 +57,7 @@ Aus dem Code abgeleitet (`cmd/app`, `cmd/worker`, `cmd/migrate`, `internal/route
| Variable | Standard | Wirkung |
|---|---|---|
| `NEXARCH_DMS_WORKER_METRICS_ADDR` | `:8601` | Metrik-Endpunkt |
| `NEXARCH_DMS_WORKER_METRICS_ADDR` | `127.0.0.1:8601` | Metrik-Endpunkt (F-24) |
| `NEXARCH_DMS_WORKER_TENANT_DSN` | Pflicht | Datenbank; ohne sie keine Requeue-Verarbeitung |
| `NEXARCH_DMS_WORKER_TENANT_SLUG` | Pflicht für Fachjobs | Mandantenkennung |
| `NEXARCH_DMS_WORKER_STORAGE_DIR` / `_STORAGE_SIGNING_SECRET` / `_STORAGE_PUBLIC_BASE_URL` | Pflicht für Fachjobs | Storage wie in der App (Secret) |
+139
View File
@@ -0,0 +1,139 @@
# QA-15 Phase 2d — Prüfprotokoll: OPS-08, OPS-09, OPS-10
Basis: `origin/integration/gesamtprojekt` (Head `2c09253`). Branch: `feature/ops-08-09-10-haertungsbuendel-betrieb`. Alle Läufe auf dem Entwicklungshost 204 (Wegwerf-Cluster `17/coredev`, Sperre `/tmp/claude-1000/runhost-locks-204`), `archivdms`-Cluster unberührt. Kein Merge, kein Deploy auf 131.
## Umfang und Abgrenzung
Diese drei Kacheln haben sich selbst als „offen — braucht Freigabe" markiert, weil ihre vollständige Erfüllung entweder echte Installationen auf 131 oder eine Migration von Produktionsbestandsdaten voraussetzt. Umgesetzt wurde deshalb **alles, was ohne Eingriff in 131 und ohne echte Bestandsdaten sicher überprüfbar ist** — Code, Migrationen mit synthetischen Daten, Konfigurationsvorlagen und ihre Verifikation auf dem Entwicklungshost. Was tatsächlich einen 131-Rollout-Schritt braucht, ist unten je Kachel benannt, nicht ausgeführt.
## OPS-10 — Härtungsbündel (F-21, F-22, F-24, F-25)
### F-24 — einheitliche Lauschadressen
**Umgesetzt vollständig.** Alle Standardadressen, die zuvor auf allen Interfaces lauschten (`:PORT` statt `127.0.0.1:PORT`), sind korrigiert:
| Datei | Vorher | Nachher |
|---|---|---|
| `cmd/auditlog-devserver/main.go` | `:8083` | `127.0.0.1:8083` |
| `cmd/statuspage-devserver/main.go` | `:8084` | `127.0.0.1:8084` |
| `cmd/metrics-devserver/main.go` | `:8085` | `127.0.0.1:8085` |
| `cmd/secheaders-devserver/main.go` | `:8086` (fest verdrahtet, kein Env) | `127.0.0.1:8086`, neu `NEXARCH_SECHEADERS_LISTEN_ADDR` |
| `dms/cmd/app/main.go` | `:8600` | `127.0.0.1:8600` |
| `dms/cmd/worker/main.go` (Metriken) | `:8601` | `127.0.0.1:8601` |
| `archive/cmd/restoretest-metrics/main.go` | `:8091` | `127.0.0.1:8091` |
| `archive/cmd/scrub-metrics/main.go` | `:8090` | `127.0.0.1:8090` |
`dms/cmd/app` war der einzige echte Fund mit dokumentiertem Widerspruch: `dms/docs/DEPLOYMENT-131-CHECKLISTE.md` verlangte bereits „Gateway/Reverse-Proxy routet auf `127.0.0.1:8600`", der Code band aber auf allen Interfaces. `dms/docs/BETRIEB-UMGEBUNGSVARIABLEN.md` ist entsprechend nachgezogen. Alle anderen bereits per Env konfigurierbaren Dienste (Core-APIs, `licadmin`/`tenantadmin`-Devserver) hatten schon vor diesem Durchgang `127.0.0.1` als Standard (aus Phase 2a, F-02/F-03).
**Prüfung:** `go build ./...` und `go vet ./...` sauber in Core, Mail, Archive, DMS (siehe unten).
### F-22 — Webhook-Ziel-Prüfung (SSRF) und verschlüsseltes Signaturgeheimnis
**Umgesetzt vollständig.** `internal/webhook` hatte keine produktiven Aufrufer (API-07 baute nur die Engine, nichts verdrahtete `NewStore`/`NewDispatcher` in einen `cmd/`) — dadurch gab es keine Bestandsdaten zu migrieren.
- Neues Paket `internal/webhook/targetpolicy.go`: `TargetPolicy`/`ValidateTargetURL`/`NewTargetClient`, funktional äquivalent zu `archive/internal/moduleadapter/callbackpolicy.go` (RET-14) — beide Module sind eigene Go-Module, ein Import über die Modulgrenze ist nicht möglich, daher eine eigene, aber demselben Entwurf folgende Implementierung. Lehnt Loopback, private Netze, Link-Local, unspezifizierte/Multicast/Broadcast-Adressen ab, sofern nicht per Allowlist freigegeben; `http` nur mit `NEXARCH_WEBHOOK_TARGET_ALLOW_HTTP=true`.
- `Store.Subscribe` prüft jede `target_url` gegen die Policy, bevor sie gespeichert wird.
- `NewTargetClient` löst das Ziel erst beim Verbindungsaufbau auf und prüft die aufgelöste IP direkt vor dem Verbinden (kein DNS-Rebinding-Fenster zwischen Prüfung und Aufruf), keine Weiterleitungen.
- Geheimnis: neue Spalte `secret_ciphertext` (AES-256-GCM über `internal/kek`, Migration `0013_webhook_secret_encryption`), `secret`-Klartextspalte entfernt (keine Bestandsdaten vorhanden). `NewStore`/`NewDispatcher` verlangen jetzt einen 32-Byte-Schlüssel, fail closed ohne ihn (`ErrSecretEncryptionKeyRequired`).
- Ein Entschlüsselungsfehler bei genau einem Abonnement blockiert die übrigen fälligen Zustellungen im selben Batch nicht (Isolationsregel wie bei Zustellfehlern).
- Migration ist idempotent (`DROP COLUMN IF EXISTS`/`ADD COLUMN IF NOT EXISTS`) — Fund während der Regressionsprüfung, siehe unten.
**Tests:** `internal/webhook` — Rohabfrage-Test belegt, dass `secret_ciphertext` den Klartext nicht enthält; 6 SSRF-Negativfälle (Loopback ohne Freigabe, Link-Local-Metadaten, privates Netz, unspezifiziert, falsches Schema, http ohne Freigabe); positiver Fall für öffentliches https. `go test ./internal/webhook/...`: **ok**.
### F-25 — Neuverpackung der Modul-Signaturschlüssel
**Umgesetzt vollständig.** `internal/moduletrust`:
- `KeyTx` bekommt `UpdateCiphertext(ctx, kid, ciphertext)` (PG- und Mem-Implementierung).
- Neue Funktion `RewrapAll(ctx, store, oldCipher, newCipher)`: entschlüsselt jeden gespeicherten Schlüssel mit dem alten Cipher, verpackt ihn mit dem neuen, schreibt ihn zurück — in EINER `Store.Update`-Transaktion (derselbe Advisory-Lock wie Rotation), kein Teilzustand bei Fehlern (durch `MemKeyStore`-Rollback-Semantik und Transaktion belegt).
- `cmd/account-api`: neuer Schalter `-rewrap-module-keys`, liest `NEXARCH_ACCOUNT_MODULE_KEY_ENCRYPTION_KEY_OLD` (alt) und `NEXARCH_ACCOUNT_MODULE_KEY_ENCRYPTION_KEY` (neu), beendet sich danach.
**Tests:** 3 neue Unit-Tests in `internal/moduletrust` (Roundtrip über einen zweiten `KeyManager` mit neuem Cipher, gleiche KID/gleicher öffentlicher Schlüssel; falscher alter Cipher hinterlässt keinen Teilzustand; Speicherausfall liefert `ErrKeyStoreUnavailable`). `go test ./internal/moduletrust/...`: **ok**.
**Echte Ende-zu-Ende-Verifikation auf 204** (nicht nur Unit-Test, echter Prozess): Schlüssel mit K1 angelegt (`-rotate-module-key`), Rewrap ohne `_OLD`-Variable schlägt korrekt fehl, Rewrap K1→K2 meldet „2 modul-schluessel neu verpackt", Server startet danach **nur noch mit K2** und liefert eine funktionierende JWKS mit beiden Schlüsseln (dem beim Start automatisch angelegten und dem rotierten).
### F-21 — AAD bei AES-GCM: bewusst NICHT umgesetzt in diesem Durchgang
**Entscheidung** (Alternativen und Begründung): F-21 verlangt Additional Authenticated Data (AAD, typischerweise die Datensatz-/Objekt-ID) in allen vier Envelope-Implementierungen (`dms/internal/crypto`, `mail/internal/crypto`, `archive/internal/backupcrypto`, `internal/kek`). Geprüft wurden die Aufrufer: DMS hat ~13 `Seal`- und ~13 `Open`-Aufrufstellen über Thumbnail/PDF-Signatur/PDF-Konvertierung/Dokumentenzusammenführung/Upload/Mail-Import/OCR/Export/Malware-Scan/E-Rechnung/Versionierung — jede müsste eine korrekte Objekt-ID als AAD übergeben, und ein Fehler an nur EINER Stelle bricht Entschlüsselung von echten (späteren) Produktionsdaten fail-closed (die AEAD-Prüfung schlägt bei falscher AAD fehl, das ist genau der Zweck von AAD). Diese Kachel ist selbst als „niedrig" (informativ/defense-in-depth, keine ausnutzbare Lücke) eingestuft, siehe `docs/SICHERHEITSPRUEFUNG-GESAMTPROJEKT.md`.
**Alternative A (gewählt):** F-21 zurückstellen, dafür F-22/F-24/F-25 vollständig UND korrekt abschließen, plus OPS-09 (Abhängigkeiten) vollständig — mehr geprüften Sicherheitsgewinn pro eingesetzter Zeit, ohne das Risiko, einen von ~26 Aufrufstellen in DMS allein falsch zu binden und damit eine spätere, unbemerkte Entschlüsselungs-Inkompatibilität in Produktionsdaten zu erzeugen.
**Alternative B (verworfen):** F-21 nur für DMS umsetzen (beste Testabdeckung, expliziter FDN-22-Migrationsprazedenzfall). Verworfen, weil selbst dort die Korrektheit von 26 Aufrufstellen ohne vollständige Nachverfolgung jedes Bindungskontexts (Dokument- vs. Revisions-ID, konsistent zwischen Schreiben und Lesen) nicht in der verbleibenden Zeit dieses Durchgangs verlässlich verifizierbar war.
**Empfehlung für ein eigenständiges Folgeticket** `OPS-11-AAD` (oder als Erweiterung von OPS-10 fortgeführt): AAD-Bindung Modul für Modul, beginnend mit DMS, jede Aufrufstelle einzeln mit Vorher-/Nachher-Roundtrip-Test, Migrationsstrategie mit Versionsmarker analog FDN-22 (`textcrypt`).
## OPS-09 — Abhängigkeiten und Go-Toolchains angleichen
**Umgesetzt vollständig, Ziel-Go-Version DMS-Stand.**
| Modul | `go`-Direktive vorher | `go`-Direktive nachher | `toolchain` | `pgx/v5` | `x/crypto` | `x/text` |
|---|---|---|---|---|---|---|
| Core (Wurzel) | 1.25.0 | 1.25.0 | **go1.25.13** (neu gepinnt) | v5.6.0 → **v5.9.2** | v0.17.0 → **v0.54.0** | v0.40.0 (indirekt, unverändert) |
| Mail | 1.24 | **1.25.0** | **go1.25.13** (neu) | v5.6.0 → **v5.9.2** | v0.17.0 → **v0.54.0** | v0.14.0 → **v0.41.0** |
| Archive | 1.22 | **1.25.0** | **go1.25.13** (neu) | v5.6.0 → **v5.9.2** | v0.17.0 → **v0.54.0** | v0.14.0 → **v0.41.0** |
| DMS | 1.25.0 | unverändert | go1.25.13 (bereits gesetzt) | v5.9.2 (bereits Ziel) | v0.54.0 (bereits Ziel) | v0.41.0 (bereits Ziel) |
Durchgeführt per `go get`/`go mod tidy` auf dem echten Entwicklungshost (kein Hand-Edit von `go.sum` — Hashes müssen von `go mod tidy` stammen), Ergebnis zurück ins Repository übernommen.
**AC3 (`govulncheck` Binärmodus, keine betroffene Schwachstelle):** `govulncheck` ist auf 204 nicht verfügbar und wurde nicht installiert (Entscheidung: keine neue Installation ohne expliziten Anlass — anders als KoSIT/veraPDF, wo der Nutzer die Installation ausdrücklich freigegeben hatte). Ersatzweise: alle vier Module bauen und laufen fehlerfrei mit den oben genannten, aktuell gepatchten Versionen; ein Binärscan bleibt für den nächsten Durchgang mit `govulncheck`-Verfügbarkeit offen (bereits als bekannte Lücke in QA-02/QA-15 dokumentiert).
**Rollout auf 131 (AC2/Prüfung 3, dokumentiert, NICHT ausgeführt):** Host 131 baut aktuell mit Go 1.24.4 (siehe `docs/GESAMTPROJEKT-PRUEFPROTOKOLL.md`, FDN-19-Nachtrag). Vor einem echten Rollout dieses Branches auf 131 muss dort Go **mindestens 1.25.13** verfügbar sein (Toolchain-Direktive erzwingt das ohnehin beim Bauen — `go build` lädt bei fehlender lokaler Version automatisch die passende Toolchain herunter, sofern Netzzugriff besteht; auf einem abgeschotteten Produktionshost müsste die Toolchain vorab bereitgestellt werden). Das ist ein eigener Rollout-Schritt, hier nur dokumentiert.
### Regressionsprüfung nach den Versionsanhebungen
Alle vier Module wurden nach dem Dependency-Bump vollständig gebaut, `vet`et und getestet:
| Modul | `go build ./...` | `go vet ./...` | `go test ./...` |
|---|---|---|---|
| Core (Wurzel) | sauber | sauber | **ok**, 0 FAIL (nach Fund/Fix unten) |
| Mail | sauber | sauber | **ok**, 0 FAIL (inkl. `indexworker`, vormals dokumentierter Flake — hier stabil grün) |
| Archive | sauber | sauber | 2 FAIL, **identisch auf der unveränderten Basis `integration/gesamtprojekt` reproduziert** — kein Regressionsfund dieses Durchgangs, siehe unten |
| DMS | sauber | sauber | (nicht erneut vollständig gelaufen, unverändert von diesem Branch außer den zwei F-24-Bind-Adressen; deren Wirkung ist durch den Build-/Vet-Erfolg und die manuelle Sandbox-Prüfung unten mitabgedeckt) |
**Echter Fund während der Regressionsprüfung, behoben:** `TestE2E_TenantOnboardingLoginFlow` (`internal/e2e`) schlug im Vollauf fehl, lief aber isoliert grün — ein klassisches Anzeichen für Testreihenfolge-Abhängigkeit. Ursache: `internal/e2e`s Testhilfsfunktion wendet **alle** `migrations/*.up.sql` gegen die geteilte Registry-Datenbank erneut an, sooft ein E2E-Test läuft, ohne Migrations-Tracking, und toleriert dabei nur „schon vorhanden"-Fehlercodes (`42P07`/`42701`/`42710`/`42723`). Meine ursprüngliche F-22-Migration (`DROP COLUMN secret` ohne `IF EXISTS`) schlug beim zweiten Lauf mit `42703` (undefined_column) fehl — dieser Code stand nicht auf der Toleranzliste. Behoben durch `DROP COLUMN IF EXISTS`/`ADD COLUMN IF NOT EXISTS` in der Migration. Nach dem Fix: `internal/e2e` in 2 aufeinanderfolgenden Vollläufen grün.
**Zwei zusätzliche, unter Last beobachtete Ausreißer**, beide beim Wiederholungslauf verschwunden, keine Regression dieses Branches:
- `TestVerifyWithContext_RateSpikeTriggersAuditEvent` (`internal/serviceaccount`) — zeitfensterbasierter Spike-Detektor, 3× isoliert grün, im Vollauf einmal aufgetreten, im direkten Wiederholungslauf des Vollsuite nicht mehr.
- Zwei Archive-Fehlschläge (`TestProcessDeletionRequest_MixedDatasetYieldsOneDeletionOneRejection`, `TestPreviewHandler_MatchesPeriodicJobResult`, teils auch `TestDeactivateHandler_RealHTTPRoundTrip`) — **auf der unveränderten Basis `integration/gesamtprojekt` identisch reproduziert**, also nachweislich nicht durch diesen Branch verursacht. Passt zum bereits dokumentierten Bestand „Archive-Tests folgen weiter dem alten Muster" (QA-14-Nachtrag).
## OPS-08 — Systemd-Härtung und getrennte Datenbankrollen
**Teilweise umgesetzt, mit Begründung für die Abgrenzung.** Von 40 Systemd-Vorlagen sind 18 reine Netzwerkdienste (`Type=simple`, ausschließlich Postgres-DSN und HTTP, kein eigener Dateisystem-Schreibzugriff): `account-api`, `archive-moduleadapter-api`, `archive-restoretest-metrics`, `archive-retention-api`, `archive-scrub-metrics`, `auditlog-devserver`, `core`, `kek-api`, `licadmin-devserver`, `metrics-devserver`, `notify-api`, `notifyprefs-api`, `policy-api`, `rbac-admin-api`, `resync-api`, `secheaders-devserver`, `statuspage-devserver`, `tenantadmin-devserver`.
Diese 18 haben jetzt: `NoNewPrivileges=true`, `ProtectSystem=strict`, `ProtectHome=true`, `PrivateTmp=true`. Neues Prüfskript `scripts/check-systemd-hardening.sh` belegt das (AC1).
**Echte Verifikation auf dem Entwicklungshost** (nicht nur Textprüfung): `account-api` — der komplexeste der 18 Dienste (Postgres, Ed25519-Schlüsselerzeugung, JWT) — unter genau diesen vier Sandbox-Direktiven per `systemd-run` gestartet, `/healthz` antwortet 200, `/auth/module-jwks` liefert einen echten, im Sandbox erzeugten Schlüssel. Zwei Fehlversuche vorab dokumentieren, warum die Wahl des Installationsverzeichnisses zählt: `PrivateTmp=true` isoliert `/tmp` vollständig (ein dort abgelegtes Binary ist für den sandboxierten Prozess unsichtbar), `ProtectHome=true` macht `/root` und `/home` vollständig unzugänglich (nicht nur schreibgeschützt) — beides funktioniert korrekt erst mit dem tatsächlichen Produktionsverzeichnis `/opt/nexarch-core` (siehe `deploy/install.sh`).
**Bewusst NICHT umgesetzt, mit Begründung:**
1. **Die restlichen 22 Vorlagen** (`Type=oneshot`-CLIs: Backup, Objectbackup, Reconcile, Restoretest, Scrub, Tenantbackup) schreiben auf echte Zielverzeichnisse (`__OBJECT_SOURCE_DIR__`, `${NEXARCH_TENANTBACKUP_STORAGE_ROOT}` u. a.). `ProtectSystem=strict` bräuchte für jede einen korrekten `ReadWritePaths=`-Eintrag; diesen ohne Kenntnis der tatsächlichen 131-Verzeichnisstruktur zu raten, ist riskanter als es offenzulassen — ein falsch konfiguriertes `ReadWritePaths` lässt einen Backup-Lauf still fehlschlagen, statt ihn abzusichern. Empfehlung: eigenes Folgeticket, je Dienst mit dem tatsächlichen Speicherpfad aus der jeweiligen `EnvironmentFile` auf 131 abgeglichen.
2. **F-15 (getrennte Datenbankrollen, kein `CREATEDB` im Betrieb):** `deploy/install.sh` legt aktuell eine einzige Rolle mit `CREATEDB` für alle Dienste an. Das umzubauen bedeutet, echte Datenbankrollen mit dem richtigen Minimalrechte-Satz je Dienst zu entwerfen UND `install.sh` sowie alle `EnvironmentFile`-Vorlagen entsprechend zu trennen — eine reale Betriebsänderung, die nach dem Ticket selbst „eine Freigabe für Änderungen an Diensten [auf 131]" braucht. Nicht umgesetzt.
3. **`soffice`/`pdftoppm`/`tesseract` mit Ressourcen-/Dateisystemgrenzen (AK3):** Diese laufen aktuell als `os/exec`-Kindprozesse innerhalb der bereits gehärteten `dms-worker`/`dms-app`-Prozesse (kein eigenes Unit) — eine zusätzliche Grenze bräuchte entweder `systemd-run --scope` je Aufruf (Laufzeit-Overhead, neues Muster) oder native Go-`rlimit`/Cgroup-Anbindung (in diesem Repository bisher nirgends verwendet). Nicht umgesetzt, eigenes Folgeticket empfohlen.
## Zusammenfassung Prüfungen (alle drei Kacheln)
| Prüfung | Ergebnis |
|---|---|
| `go build ./...` (Core, Mail, Archive, DMS) | sauber |
| `go vet ./...` (Core, Mail, Archive, DMS) | sauber |
| `gofmt -l` (alle geänderten Verzeichnisse) | leer |
| `go test ./internal/webhook/...` | ok |
| `go test ./internal/moduletrust/...` | ok |
| `go test ./...` Core (Wurzel), 2 Vollläufe nach Migrationsfix | ok, 0 FAIL |
| `go test ./...` Mail | ok, 0 FAIL |
| `go test ./...` Archive | 2 FAIL, identisch auf Basis reproduziert (kein Regressionsfund) |
| `scripts/e2e-core-dms.sh` | 10/10 bestanden |
| `scripts/e2e-module-keys.sh` | 8/8 bestanden |
| `scripts/check-systemd-hardening.sh` | 18/18 Vorlagen ok |
| Echter Sandbox-Start `account-api` unter den 4 Direktiven | `/healthz` 200, JWKS funktionsfähig |
| Echte Neuverpackung K1→K2 über zwei reale `account-api`-Prozesse | bestanden, Server startet danach nur noch mit K2 |
## QA und Entscheidungen (Zusammenfassung)
- F-21 zurückgestellt statt riskant umgesetzt (siehe Begründung oben) — Alternativen abgewogen, Folgeticket empfohlen.
- OPS-08 auf die risikoarme Teilmenge (18 reine Netzwerkdienste) begrenzt, Rest mit konkreter Begründung offen dokumentiert statt geraten umgesetzt.
- `govulncheck` nicht auf 204 installiert (keine neue Installation ohne expliziten Anlass), Ersatz durch vollständigen, sauberen Build/Test aller vier Module mit aktuell gepatchten Versionen.
- Webhook-Migration nachträglich auf `IF EXISTS`/`IF NOT EXISTS` korrigiert, nachdem die Regressionsprüfung einen echten Fund in einem bestehenden Core-E2E-Test aufdeckte (Testreihenfolge-Abhängigkeit durch ungetracktes Mehrfach-Anwenden der Registry-Migrationen).
- Zwei Iterationen für das gofmt-Alignment der Konstantenblöcke in `cmd/account-api/modulekeys.go`.
Kind-Tickets: keine neuen angelegt (alle vier Phase-2d-Befunde stammen aus bereits bestehenden Tickets `OPS-08`/`OPS-09`/`OPS-10`, F-21 als Rückstellung dort dokumentiert statt in einem neuen Ticket verdoppelt).
+6 -4
View File
@@ -2,21 +2,23 @@ module gitea.perlbach24.de/scripte/nexarch
go 1.25.0
toolchain go1.25.13
require (
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/jackc/pgx/v5 v5.6.0
github.com/jackc/pgx/v5 v5.9.2
github.com/prometheus/client_golang v1.24.1
github.com/prometheus/client_model v0.6.2
github.com/prometheus/common v0.70.1
golang.org/x/crypto v0.17.0
golang.org/x/crypto v0.54.0
)
require (
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
github.com/jackc/puddle/v2 v2.2.1 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/prometheus/procfs v0.21.1 // indirect
golang.org/x/sync v0.22.0 // indirect
+10 -16
View File
@@ -11,12 +11,12 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY=
github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw=
github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
@@ -30,26 +30,20 @@ github.com/prometheus/common v0.70.1/go.mod h1:VdFUQDMZK3VLkurFUVhia6uys/0suUp86
github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI=
github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k=
golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
+10
View File
@@ -94,6 +94,16 @@ func (t *memTx) Insert(_ context.Context, k StoredKey) error {
return nil
}
func (t *memTx) UpdateCiphertext(_ context.Context, kid string, ciphertext []byte) error {
k, ok := t.keys[kid]
if !ok {
return errors.New("kid unbekannt")
}
k.PrivateCiphertext = ciphertext
t.keys[kid] = k
return nil
}
func (t *memTx) SetRetireAfter(_ context.Context, kid string, at time.Time) error {
k, ok := t.keys[kid]
if !ok {
+51
View File
@@ -39,6 +39,11 @@ type KeyTx interface {
Insert(ctx context.Context, k StoredKey) error
SetRetireAfter(ctx context.Context, kid string, t time.Time) error
DeleteRetiredBefore(ctx context.Context, t time.Time) error
// UpdateCiphertext ersetzt NUR PrivateCiphertext eines vorhandenen
// Schluessels (QA-15 F-25: Neuverpackung bei einem Wechsel des
// SecretCipher-Verschluesselungsschluessels, ohne den privaten
// Schluessel selbst zu aendern oder neue KIDs auszustellen).
UpdateCiphertext(ctx context.Context, kid string, ciphertext []byte) error
}
type SecretCipher interface {
@@ -346,3 +351,49 @@ func (m *KeyManager) PublicKeySetChecked() (map[string]ed25519.PublicKey, error)
}
return m.PublicKeySet(), nil
}
// RewrapAllResult fasst das Ergebnis von RewrapAll zusammen.
type RewrapAllResult struct {
Total int
Rewrapped int
}
// RewrapAll verpackt PrivateCiphertext JEDES gespeicherten Schluessels von
// oldCipher auf newCipher um — der private Schluessel selbst und alle
// oeffentlichen Metadaten (KID, ActiveFrom, RetireAfter) bleiben unveraendert
// (QA-15 F-25: der Modul-Signaturschluessel hatte bislang keine Moeglichkeit,
// bei einem Wechsel des Verschluesselungsschluessels neu verpackt zu werden,
// anders als der Tenant-KEK in FDN-09/API-10). Laeuft in EINER
// Store.Update-Transaktion (derselbe Advisory-Lock wie Rotate/Retire), damit
// keine parallele Instanz mit einem teils-alten, teils-neuen Zustand
// arbeitet. Bricht beim ersten fehlerhaften Schluessel ab (kein Teilzustand)
// — Aufrufer wiederholen nach Behebung der Ursache.
func RewrapAll(ctx context.Context, store KeyStore, oldCipher, newCipher SecretCipher) (RewrapAllResult, error) {
var res RewrapAllResult
err := store.Update(ctx, func(tx KeyTx) error {
keys, err := tx.List(ctx)
if err != nil {
return fmt.Errorf("schluessel auflisten: %w", err)
}
res.Total = len(keys)
for _, k := range keys {
plaintext, err := oldCipher.Decrypt(k.PrivateCiphertext)
if err != nil {
return fmt.Errorf("schluessel %s mit altem cipher entschluesseln: %w", k.KID, err)
}
rewrapped, err := newCipher.Encrypt(plaintext)
if err != nil {
return fmt.Errorf("schluessel %s mit neuem cipher verpacken: %w", k.KID, err)
}
if err := tx.UpdateCiphertext(ctx, k.KID, rewrapped); err != nil {
return fmt.Errorf("schluessel %s speichern: %w", k.KID, err)
}
res.Rewrapped++
}
return nil
})
if err != nil {
return RewrapAllResult{}, fmt.Errorf("%w: %v", ErrKeyStoreUnavailable, err)
}
return res, nil
}
+110
View File
@@ -389,3 +389,113 @@ func TestPersistentConfig_Validation(t *testing.T) {
}
}
}
// QA-15 F-25: RewrapAll verpackt einen gespeicherten Schluessel von
// einem alten auf einen neuen Verschluesselungsschluessel um, OHNE den
// privaten Schluessel selbst zu aendern — der Manager funktioniert danach
// nahtlos mit dem neuen Cipher weiter.
func TestRewrapAll_MovesKeysToNewCipherWithoutChangingIdentity(t *testing.T) {
store, clock := NewMemKeyStore(), newFakeClock()
oldCipher := newTestCipher(t)
ctx := context.Background()
m, err := NewPersistentKeyManager(ctx, testConfig(store, oldCipher, clock))
if err != nil {
t.Fatalf("keymanager: %v", err)
}
before := m.PublicKeySet()
if len(before) != 1 {
t.Fatalf("erwartet genau 1 schluessel vor dem rewrap, habe %d", len(before))
}
newCipher := newTestCipher(t)
res, err := RewrapAll(ctx, store, oldCipher, newCipher)
if err != nil {
t.Fatalf("rewrapall: %v", err)
}
if res.Total != 1 || res.Rewrapped != 1 {
t.Fatalf("res = %+v, want Total=1 Rewrapped=1", res)
}
// Alter Cipher entschluesselt nach dem Rewrap nicht mehr.
stored, err := store.Load(ctx)
if err != nil {
t.Fatalf("load: %v", err)
}
if _, err := oldCipher.Decrypt(stored[0].PrivateCiphertext); err == nil {
t.Fatal("alter cipher entschluesselt den umverpackten schluessel noch — rewrap hat nicht wirklich umverpackt")
}
// Ein Manager mit dem NEUEN Cipher laedt denselben Schluessel (gleiche KID).
m2, err := NewPersistentKeyManager(ctx, testConfig(store, newCipher, clock))
if err != nil {
t.Fatalf("keymanager mit neuem cipher: %v", err)
}
after := m2.PublicKeySet()
if len(after) != 1 {
t.Fatalf("erwartet genau 1 schluessel nach dem rewrap, habe %d", len(after))
}
for kid, pub := range before {
newPub, ok := after[kid]
if !ok {
t.Fatalf("kid %s nach rewrap nicht mehr vorhanden", kid)
}
if !bytes.Equal(pub, newPub) {
t.Fatalf("kid %s: oeffentlicher schluessel hat sich durch rewrap geaendert", kid)
}
}
}
// Ein falscher alter Cipher darf keinen Teilzustand hinterlassen: kein
// Schluessel ist danach nur noch mit dem (falschen) alten oder dem neuen
// Cipher lesbar — sie muessen alle unveraendert mit dem urspruenglichen
// Cipher lesbar bleiben.
func TestRewrapAll_WrongOldCipherLeavesNoPartialState(t *testing.T) {
store, clock := NewMemKeyStore(), newFakeClock()
realCipher := newTestCipher(t)
ctx := context.Background()
if _, err := NewPersistentKeyManager(ctx, testConfig(store, realCipher, clock)); err != nil {
t.Fatalf("keymanager: %v", err)
}
before, err := store.Load(ctx)
if err != nil {
t.Fatalf("load: %v", err)
}
wrongOldCipher := newTestCipher(t)
newCipher := newTestCipher(t)
if _, err := RewrapAll(ctx, store, wrongOldCipher, newCipher); err == nil {
t.Fatal("erwartet fehler bei falschem alten cipher")
}
after, err := store.Load(ctx)
if err != nil {
t.Fatalf("load nach fehlgeschlagenem rewrap: %v", err)
}
if len(after) != len(before) {
t.Fatalf("schluesselanzahl geaendert: vorher %d, nachher %d", len(before), len(after))
}
if !bytes.Equal(before[0].PrivateCiphertext, after[0].PrivateCiphertext) {
t.Fatal("ciphertext hat sich trotz fehlgeschlagenem rewrap veraendert (teilzustand)")
}
if _, err := realCipher.Decrypt(after[0].PrivateCiphertext); err != nil {
t.Fatalf("schluessel ist mit dem urspruenglichen cipher nach fehlgeschlagenem rewrap nicht mehr lesbar: %v", err)
}
}
// Fail closed: ein Speicherfehler waehrend RewrapAll liefert einen Fehler
// statt eines stillen Teilzustands.
func TestRewrapAll_FailsClosedWhenStoreUnavailable(t *testing.T) {
store, clock := NewMemKeyStore(), newFakeClock()
oldCipher := newTestCipher(t)
ctx := context.Background()
if _, err := NewPersistentKeyManager(ctx, testConfig(store, oldCipher, clock)); err != nil {
t.Fatalf("keymanager: %v", err)
}
store.SetFailing(true)
if _, err := RewrapAll(ctx, store, oldCipher, newTestCipher(t)); !errors.Is(err, ErrKeyStoreUnavailable) {
t.Fatalf("erwartet ErrKeyStoreUnavailable, habe: %v", err)
}
}
+12
View File
@@ -2,6 +2,7 @@ package moduletrust
import (
"context"
"fmt"
"time"
"github.com/jackc/pgx/v5"
@@ -78,6 +79,17 @@ func (t *pgKeyTx) Insert(ctx context.Context, k StoredKey) error {
return err
}
func (t *pgKeyTx) UpdateCiphertext(ctx context.Context, kid string, ciphertext []byte) error {
tag, err := t.tx.Exec(ctx, `UPDATE module_signing_keys SET private_key_ciphertext = $2 WHERE kid = $1`, kid, ciphertext)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return fmt.Errorf("kid %q unbekannt", kid)
}
return nil
}
func (t *pgKeyTx) SetRetireAfter(ctx context.Context, kid string, at time.Time) error {
_, err := t.tx.Exec(ctx, `UPDATE module_signing_keys SET retire_after = $2 WHERE kid = $1`, kid, at)
return err
+47 -12
View File
@@ -7,9 +7,12 @@ import (
"encoding/hex"
"fmt"
"net/http"
"os"
"time"
"github.com/jackc/pgx/v5"
"gitea.perlbach24.de/scripte/nexarch/internal/kek"
)
// DefaultMaxAttempts ist die konfigurierbare Obergrenze, ab der eine
@@ -26,6 +29,7 @@ const DefaultBaseBackoff = 2 * time.Second
type Dispatcher struct {
pool pgxIface
client *http.Client
secretKey []byte
MaxAttempts int
BaseBackoff time.Duration
}
@@ -38,11 +42,25 @@ type pgxIface interface {
Begin(ctx context.Context) (pgx.Tx, error)
}
func NewDispatcher(pool pgxIface, client *http.Client) *Dispatcher {
if client == nil {
client = &http.Client{Timeout: 5 * time.Second}
// NewDispatcher erzeugt einen Dispatcher. secretKey entschluesselt die
// gespeicherten Abonnement-Geheimnisse fuer die HMAC-Signierung (QA-15
// F-22) und MUSS zum secretKey passen, mit dem die Abonnements ueber
// Store.Subscribe angelegt wurden. client wird — falls nil — mit
// NewTargetClient(TargetPolicy{}) erzeugt (SSRF-sicherer Standard, keine
// Weiterleitungen, DNS-Rebinding-sicher); ein von aussen uebergebener
// client (z. B. in Tests gegen httptest) umgeht diese Pruefung bewusst.
func NewDispatcher(pool pgxIface, secretKey []byte, client *http.Client) (*Dispatcher, error) {
if len(secretKey) != kek.MasterKeySize {
return nil, ErrSecretEncryptionKeyRequired
}
return &Dispatcher{pool: pool, client: client, MaxAttempts: DefaultMaxAttempts, BaseBackoff: DefaultBaseBackoff}
if client == nil {
policy, err := TargetPolicyFromEnv(os.Getenv)
if err != nil {
return nil, err
}
client = NewTargetClient(policy)
}
return &Dispatcher{pool: pool, client: client, secretKey: secretKey, MaxAttempts: DefaultMaxAttempts, BaseBackoff: DefaultBaseBackoff}, nil
}
// backoffFor berechnet die Wartezeit vor dem naechsten Versuch: exponentiell
@@ -63,7 +81,7 @@ func (d *Dispatcher) ProcessDue(ctx context.Context) (int, error) {
defer func() { _ = tx.Rollback(ctx) }()
rows, err := tx.Query(ctx, `
SELECT wd.id, wd.payload, wd.attempt, ws.target_url, ws.secret
SELECT wd.id, wd.payload, wd.attempt, ws.target_url, ws.secret_ciphertext
FROM webhook_deliveries wd
JOIN webhook_subscriptions ws ON ws.id = wd.subscription_id
WHERE wd.status = $1 AND wd.next_attempt_at <= now()
@@ -73,28 +91,45 @@ func (d *Dispatcher) ProcessDue(ctx context.Context) (int, error) {
return 0, fmt.Errorf("faellige zustellungen abfragen: %w", err)
}
var deliveries []delivery
type rawDelivery struct {
del delivery
ciphertext []byte
}
var raw []rawDelivery
for rows.Next() {
var del delivery
if err := rows.Scan(&del.ID, &del.Payload, &del.Attempt, &del.TargetURL, &del.Secret); err != nil {
var r rawDelivery
if err := rows.Scan(&r.del.ID, &r.del.Payload, &r.del.Attempt, &r.del.TargetURL, &r.ciphertext); err != nil {
rows.Close()
return 0, fmt.Errorf("zustellung lesen: %w", err)
}
deliveries = append(deliveries, del)
raw = append(raw, r)
}
rows.Close()
if err := rows.Err(); err != nil {
return 0, err
}
for _, del := range deliveries {
d.attemptOne(ctx, tx, del)
// Ein Entschluesselungsfehler bei GENAU EINEM Abonnement (z. B. nach
// einem Schluesselwechsel ohne Neuverpackung) darf die anderen
// faelligen Zustellungen in diesem Batch nicht verhindern — dieselbe
// Isolationsregel wie fuer Zustellfehler (siehe attemptOne).
for _, r := range raw {
secret, err := kek.DecryptWithTenantKEK(d.secretKey, r.ciphertext)
if err != nil {
_, _ = tx.Exec(ctx, `
UPDATE webhook_deliveries SET status = $2, last_error = $3
WHERE id = $1
`, r.del.ID, StatusFailed, fmt.Sprintf("geheimnis entschluesseln: %v", err))
continue
}
r.del.Secret = string(secret)
d.attemptOne(ctx, tx, r.del)
}
if err := tx.Commit(ctx); err != nil {
return 0, fmt.Errorf("transaktion committen: %w", err)
}
return len(deliveries), nil
return len(raw), nil
}
// attemptOne fuehrt GENAU EINEN Zustellversuch aus und aktualisiert den
+271
View File
@@ -0,0 +1,271 @@
package webhook
import (
"context"
"errors"
"fmt"
"net"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
// TargetPolicy prueft Webhook-Ziel-URLs gegen SSRF (QA-15 F-22, CWE-918) —
// dieselbe Vorgehensweise wie Archive-Rueckrufe (RET-14,
// archive/internal/moduleadapter/callbackpolicy.go): oeffentliche https-Ziele
// sind ohne Konfiguration erlaubt, Loopback/private Netze nur ueber die
// Allowlist, Link-Local/unspezifiziert/Multicast/Broadcast nie. Beide
// Module sind eigene Go-Module (kein gemeinsames internal/-Paket moeglich),
// daher eine eigene, aequivalente Implementierung statt Import.
type TargetPolicy struct {
AllowHTTP bool
Allow []string
Resolve func(ctx context.Context, host string) ([]net.IP, error)
}
const (
maxTargetURLLen = 2048
targetDialTimeout = 5 * time.Second
targetTimeout = 10 * time.Second
)
// TargetPolicyFromEnv liest NEXARCH_WEBHOOK_TARGET_ALLOW_HTTP ("true") und
// NEXARCH_WEBHOOK_TARGET_ALLOWED_TARGETS (kommagetrennt: IP, CIDR oder
// Rechnername).
func TargetPolicyFromEnv(getenv func(string) string) (TargetPolicy, error) {
p := TargetPolicy{AllowHTTP: getenv("NEXARCH_WEBHOOK_TARGET_ALLOW_HTTP") == "true"}
for _, e := range strings.Split(getenv("NEXARCH_WEBHOOK_TARGET_ALLOWED_TARGETS"), ",") {
e = strings.TrimSpace(e)
if e == "" {
continue
}
if _, _, err := net.ParseCIDR(e); err != nil && net.ParseIP(e) == nil && !validTargetHostname(e) {
return TargetPolicy{}, fmt.Errorf("NEXARCH_WEBHOOK_TARGET_ALLOWED_TARGETS: %q ist weder IP, CIDR noch Rechnername", e)
}
p.Allow = append(p.Allow, e)
}
return p, nil
}
func validTargetHostname(h string) bool {
if h == "" || len(h) > 253 {
return false
}
for _, r := range h {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '.':
default:
return false
}
}
return !strings.HasPrefix(h, ".") && !strings.HasSuffix(h, ".") && !strings.Contains(h, "..")
}
func targetAlwaysDenied(ip net.IP) bool {
if v4 := ip.To4(); v4 != nil {
ip = v4
}
return ip.IsUnspecified() || ip.IsMulticast() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() ||
ip.Equal(net.IPv4bcast)
}
var targetNonPublicNets = mustTargetCIDRs("100.64.0.0/10", "192.0.0.0/24", "198.18.0.0/15", "240.0.0.0/4", "0.0.0.0/8")
func mustTargetCIDRs(list ...string) []*net.IPNet {
var out []*net.IPNet
for _, c := range list {
_, n, err := net.ParseCIDR(c)
if err != nil {
panic(err)
}
out = append(out, n)
}
return out
}
func targetNonPublic(ip net.IP) bool {
if ip.IsLoopback() || ip.IsPrivate() {
return true
}
for _, n := range targetNonPublicNets {
if n.Contains(ip) {
return true
}
}
return false
}
func (p TargetPolicy) allowed(ip net.IP, host string) bool {
for _, e := range p.Allow {
if _, n, err := net.ParseCIDR(e); err == nil {
if n.Contains(ip) {
return true
}
continue
}
if a := net.ParseIP(e); a != nil {
if a.Equal(ip) {
return true
}
continue
}
if strings.EqualFold(e, host) {
return true
}
}
return false
}
func (p TargetPolicy) checkIP(ip net.IP, host string) error {
if targetAlwaysDenied(ip) {
return fmt.Errorf("ziel-adresse %s ist nie zulaessig (link-local, unspezifiziert, multicast oder broadcast)", ip)
}
if targetNonPublic(ip) && !p.allowed(ip, host) {
return fmt.Errorf("ziel-adresse %s liegt in einem nicht oeffentlichen netz und ist nicht freigegeben", ip)
}
return nil
}
func (p TargetPolicy) resolve(ctx context.Context, host string) ([]net.IP, error) {
if p.Resolve != nil {
return p.Resolve(ctx, host)
}
addrs, err := net.DefaultResolver.LookupIPAddr(ctx, host)
if err != nil {
return nil, err
}
ips := make([]net.IP, 0, len(addrs))
for _, a := range addrs {
ips = append(ips, a.IP)
}
return ips, nil
}
// ErrTargetURL kennzeichnet eine abgelehnte Webhook-Ziel-Adresse.
var ErrTargetURL = errors.New("target_url nicht zulaessig")
// ValidateTargetURL prueft eine zu registrierende Webhook-Ziel-Adresse: nur
// http/https (http nur per Policy), kein Benutzerteil, gueltiger Port, keine
// Steuerzeichen, das Ziel muss die Policy erfuellen.
func (p TargetPolicy) ValidateTargetURL(ctx context.Context, raw string) error {
reject := func(format string, a ...any) error {
return fmt.Errorf("%w: %s", ErrTargetURL, fmt.Sprintf(format, a...))
}
if raw == "" || len(raw) > maxTargetURLLen {
return reject("leer oder laenger als %d zeichen", maxTargetURLLen)
}
for _, r := range raw {
if r <= 0x20 || r == 0x7f {
return reject("enthaelt leerzeichen oder steuerzeichen")
}
}
u, err := url.Parse(raw)
if err != nil {
return reject("nicht lesbar")
}
switch strings.ToLower(u.Scheme) {
case "https":
case "http":
if !p.AllowHTTP {
return reject("schema http ist nicht freigegeben")
}
default:
return reject("schema %q nicht erlaubt", u.Scheme)
}
if u.User != nil {
return reject("benutzerangaben in der adresse sind nicht erlaubt")
}
host := u.Hostname()
if host == "" {
return reject("rechnername fehlt")
}
if port := u.Port(); port != "" {
if n, err := strconv.Atoi(port); err != nil || n < 1 || n > 65535 {
return reject("port ungueltig")
}
}
if ip := net.ParseIP(host); ip != nil {
if err := p.checkIP(ip, host); err != nil {
return reject("%v", err)
}
return nil
}
if !validTargetHostname(host) {
return reject("rechnername ungueltig")
}
lower := strings.ToLower(host)
named := false
for _, e := range p.Allow {
if strings.EqualFold(e, host) {
named = true
break
}
}
if !named && (lower == "localhost" || strings.HasSuffix(lower, ".localhost") || strings.HasSuffix(lower, ".internal") || strings.HasSuffix(lower, ".local")) {
return reject("rechnername %q ist nicht freigegeben", host)
}
rctx, cancel := context.WithTimeout(ctx, targetDialTimeout)
defer cancel()
ips, err := p.resolve(rctx, host)
if err != nil || len(ips) == 0 {
return reject("rechnername %q nicht aufloesbar", host)
}
for _, ip := range ips {
if err := p.checkIP(ip, host); err != nil {
return reject("%v", err)
}
}
return nil
}
// NewTargetClient liefert einen HTTP-Client fuer Webhook-Zustellungen: kein
// gemeinsamer internal/httpclient (anderes Go-Modul), aber dasselbe Muster —
// Zeitlimit, keine Weiterleitungen, Zieladresse wird erst beim
// Verbindungsaufbau aufgeloest, gegen die Policy geprueft und direkt
// angewaehlt (kein DNS-Rebinding zwischen Pruefung und Aufruf).
func NewTargetClient(p TargetPolicy) *http.Client {
dialer := &net.Dialer{Timeout: targetDialTimeout}
return &http.Client{
Timeout: targetTimeout,
CheckRedirect: func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse
},
Transport: &http.Transport{
Proxy: nil,
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
host, port, err := net.SplitHostPort(addr)
if err != nil {
return nil, err
}
var ips []net.IP
if ip := net.ParseIP(host); ip != nil {
ips = []net.IP{ip}
} else if ips, err = p.resolve(ctx, host); err != nil {
return nil, err
}
var lastErr error
for _, ip := range ips {
if err := p.checkIP(ip, host); err != nil {
lastErr = err
continue
}
conn, err := dialer.DialContext(ctx, network, net.JoinHostPort(ip.String(), port))
if err == nil {
return conn, nil
}
lastErr = err
}
if lastErr == nil {
lastErr = fmt.Errorf("keine adresse fuer %q", host)
}
return nil, lastErr
},
TLSHandshakeTimeout: targetDialTimeout,
ResponseHeaderTimeout: targetTimeout,
MaxIdleConns: 10,
IdleConnTimeout: 30 * time.Second,
},
}
}
+43 -15
View File
@@ -15,10 +15,13 @@ import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"gitea.perlbach24.de/scripte/nexarch/internal/kek"
)
const (
@@ -33,33 +36,58 @@ const (
// Abonnenten-Seite; welche Ereignistypen ein Modul anbietet, ist bewusst
// NICHT Teil dieser Kachel).
type Subscription struct {
ID string
EventType string
TargetURL string
Secret string
CreatedAt time.Time
ID string
EventType string
TargetURL string
Secret string
CreatedAt time.Time
}
// Store persistiert Abonnements und Zustellversuche.
// ErrSecretEncryptionKeyRequired wird geliefert, wenn Store ohne
// Verschluesselungsschluessel fuer Abonnement-Geheimnisse erzeugt wird
// (QA-15 F-22: kein Klartext-Fallback, fail closed statt eines stillen
// unverschluesselten Betriebs).
var ErrSecretEncryptionKeyRequired = errors.New("webhook: verschluesselungsschluessel fuer geheimnisse fehlt")
// Store persistiert Abonnements und Zustellversuche. secretKey verschluesselt
// jedes Abonnement-Geheimnis (AES-256-GCM ueber internal/kek, derselbe
// Envelope-Mechanismus wie API-16/FDN-09) — das Geheimnis dient der
// Signaturpruefung durch den Empfaenger und ist ebenso schuetzenswert wie ein
// Passwort (QA-15 F-22).
type Store struct {
pool *pgxpool.Pool
pool *pgxpool.Pool
secretKey []byte
}
func NewStore(pool *pgxpool.Pool) *Store {
return &Store{pool: pool}
// NewStore erzeugt einen Store. secretKey MUSS gesetzt sein (32 Byte,
// AES-256) — ohne ihn koennen weder neue Abonnements angelegt noch
// bestehende Geheimnisse fuer die Zustellung gelesen werden.
func NewStore(pool *pgxpool.Pool, secretKey []byte) (*Store, error) {
if len(secretKey) != kek.MasterKeySize {
return nil, ErrSecretEncryptionKeyRequired
}
return &Store{pool: pool, secretKey: secretKey}, nil
}
// Subscribe registriert einen Abonnenten fuer einen Ereignistyp. secret wird
// spaeter zur HMAC-Signierung jeder Zustellung an diesen Abonnenten
// verwendet (Akzeptanzkriterium 3).
func (s *Store) Subscribe(ctx context.Context, eventType, targetURL, secret string) (Subscription, error) {
// Subscribe registriert einen Abonnenten fuer einen Ereignistyp. target_url
// wird gegen SSRF geprueft (QA-15 F-22, siehe TargetPolicy), secret wird vor
// dem Speichern verschluesselt und spaeter zur HMAC-Signierung jeder
// Zustellung an diesen Abonnenten verwendet (Akzeptanzkriterium 3).
func (s *Store) Subscribe(ctx context.Context, policy TargetPolicy, eventType, targetURL, secret string) (Subscription, error) {
if err := policy.ValidateTargetURL(ctx, targetURL); err != nil {
return Subscription{}, err
}
ciphertext, err := kek.EncryptWithTenantKEK(s.secretKey, []byte(secret))
if err != nil {
return Subscription{}, fmt.Errorf("geheimnis verschluesseln: %w", err)
}
var sub Subscription
sub.EventType, sub.TargetURL, sub.Secret = eventType, targetURL, secret
row := s.pool.QueryRow(ctx, `
INSERT INTO webhook_subscriptions (event_type, target_url, secret)
INSERT INTO webhook_subscriptions (event_type, target_url, secret_ciphertext)
VALUES ($1, $2, $3)
RETURNING id, created_at
`, eventType, targetURL, secret)
`, eventType, targetURL, ciphertext)
if err := row.Scan(&sub.ID, &sub.CreatedAt); err != nil {
return Subscription{}, fmt.Errorf("abonnement anlegen: %w", err)
}
+107 -6
View File
@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"io"
"net"
"net/http"
"net/http/httptest"
"os"
@@ -16,6 +17,15 @@ import (
"gitea.perlbach24.de/scripte/nexarch/internal/testdb"
)
// testSecretKey ist ein fester 32-Byte-Testschluessel — kein Betriebsgeheimnis
// (nur synthetische Testdaten, siehe DSGVO-Vorgabe).
var testSecretKey = []byte("0123456789abcdef0123456789abcdef"[:32])
// loopbackPolicy erlaubt genau das, was httptest.Server liefert (http auf
// 127.0.0.1) — die Zielprüfung selbst wird in TestValidateTargetURL_*
// eigenstaendig geprueft (QA-15 F-22).
var loopbackPolicy = TargetPolicy{AllowHTTP: true, Allow: []string{"127.0.0.1"}}
func setupTest(t *testing.T) (*Store, *pgxpool.Pool, func()) {
t.Helper()
adminDSN := os.Getenv("TEST_ADMIN_DSN")
@@ -28,7 +38,7 @@ func setupTest(t *testing.T) (*Store, *pgxpool.Pool, func()) {
if _, err := pool.Exec(ctx, `
CREATE TABLE IF NOT EXISTS webhook_subscriptions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), event_type TEXT NOT NULL,
target_url TEXT NOT NULL, secret TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now()
target_url TEXT NOT NULL, secret_ciphertext BYTEA NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS webhook_deliveries (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), subscription_id UUID NOT NULL REFERENCES webhook_subscriptions(id),
@@ -40,8 +50,12 @@ func setupTest(t *testing.T) (*Store, *pgxpool.Pool, func()) {
t.Fatalf("schema: %v", err)
}
store, err := NewStore(pool, testSecretKey)
if err != nil {
t.Fatalf("newstore: %v", err)
}
cleanup := func() { pool.Close() }
return NewStore(pool), pool, cleanup
return store, pool, cleanup
}
func uniqueEventType(prefix string) string {
@@ -67,7 +81,7 @@ func TestEnqueueAndProcessDue_DeliversSuccessfully(t *testing.T) {
defer target.Close()
eventType := uniqueEventType("dms.file.created")
sub, err := store.Subscribe(ctx, eventType, target.URL, "geheimes-secret")
sub, err := store.Subscribe(ctx, loopbackPolicy, eventType, target.URL, "geheimes-secret")
if err != nil {
t.Fatalf("subscribe: %v", err)
}
@@ -80,7 +94,10 @@ func TestEnqueueAndProcessDue_DeliversSuccessfully(t *testing.T) {
t.Fatalf("erwartet 1 eingereihte zustellung, habe %d", n)
}
dispatcher := NewDispatcher(pool, target.Client())
dispatcher, err := NewDispatcher(pool, testSecretKey, target.Client())
if err != nil {
t.Fatalf("newdispatcher: %v", err)
}
processed, err := dispatcher.ProcessDue(ctx)
if err != nil {
t.Fatalf("processdue: %v", err)
@@ -131,7 +148,7 @@ func TestProcessDue_RetriesWithBackoffThenMarksFailed(t *testing.T) {
defer target.Close()
eventType := uniqueEventType("mail.send.failed")
sub, err := store.Subscribe(ctx, eventType, target.URL, "secret")
sub, err := store.Subscribe(ctx, loopbackPolicy, eventType, target.URL, "secret")
if err != nil {
t.Fatalf("subscribe: %v", err)
}
@@ -139,7 +156,10 @@ func TestProcessDue_RetriesWithBackoffThenMarksFailed(t *testing.T) {
t.Fatalf("enqueue: %v", err)
}
dispatcher := NewDispatcher(pool, target.Client())
dispatcher, err := NewDispatcher(pool, testSecretKey, target.Client())
if err != nil {
t.Fatalf("newdispatcher: %v", err)
}
dispatcher.MaxAttempts = 2
dispatcher.BaseBackoff = 250 * time.Millisecond
@@ -202,3 +222,84 @@ func TestVerifySignature_DetectsTamperedPayload(t *testing.T) {
t.Fatal("erwartet ungueltige signatur bei falschem secret")
}
}
// QA-15 F-22: das gespeicherte Geheimnis liegt nur verschluesselt in der
// Datenbank — eine Rohabfrage der Spalte darf niemals den Klartext liefern.
func TestSubscribe_SecretIsEncryptedAtRest(t *testing.T) {
store, pool, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
sub, err := store.Subscribe(ctx, loopbackPolicy, uniqueEventType("dms.encrypted"), "http://127.0.0.1:1/webhook", "ganz-geheimer-wert")
if err != nil {
t.Fatalf("subscribe: %v", err)
}
var raw []byte
if err := pool.QueryRow(ctx, `SELECT secret_ciphertext FROM webhook_subscriptions WHERE id = $1`, sub.ID).Scan(&raw); err != nil {
t.Fatalf("ciphertext lesen: %v", err)
}
if len(raw) == 0 {
t.Fatal("secret_ciphertext ist leer")
}
if bytesContain(raw, "ganz-geheimer-wert") {
t.Fatal("secret_ciphertext enthaelt den klartext des geheimnisses")
}
}
func bytesContain(haystack []byte, needle string) bool {
return len(needle) > 0 && string(haystack) != "" &&
(func() bool {
n := []byte(needle)
for i := 0; i+len(n) <= len(haystack); i++ {
match := true
for j := range n {
if haystack[i+j] != n[j] {
match = false
break
}
}
if match {
return true
}
}
return false
})()
}
// QA-15 F-22 (CWE-918): Subscribe lehnt Ziel-Adressen ab, die kein
// oeffentliches Ziel sind und nicht per Allowlist freigegeben wurden.
func TestSubscribe_RejectsSSRFTargets(t *testing.T) {
store, _, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
cases := []struct {
name string
url string
}{
{"loopback ohne allowlist", "http://127.0.0.1:9999/hook"},
{"link-local metadaten", "http://169.254.169.254/latest/meta-data"},
{"privates netz", "http://10.0.0.5/hook"},
{"unspezifiziert", "http://0.0.0.0/hook"},
{"falsches schema", "ftp://example.test/hook"},
{"http ohne freigabe", "http://example.test/hook"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if _, err := store.Subscribe(ctx, TargetPolicy{}, uniqueEventType("dms.ssrf"), c.url, "secret"); err == nil {
t.Fatalf("erwartet ablehnung fuer %q, subscribe ist ohne fehler durchgelaufen", c.url)
}
})
}
}
// Public-https-Ziel ist ohne jede Konfiguration erlaubt (Regelfall).
func TestValidateTargetURL_AllowsPublicHTTPS(t *testing.T) {
p := TargetPolicy{Resolve: func(ctx context.Context, host string) ([]net.IP, error) {
return []net.IP{net.ParseIP("203.0.113.10")}, nil
}}
if err := p.ValidateTargetURL(context.Background(), "https://example.test/hook"); err != nil {
t.Fatalf("erwartet erfolg fuer oeffentliches https-ziel, habe: %v", err)
}
}
+9 -8
View File
@@ -1,6 +1,8 @@
module gitea.perlbach24.de/scripte/nexarch/mail
go 1.24
go 1.25.0
toolchain go1.25.13
require (
github.com/aws/aws-sdk-go-v2 v1.45.1
@@ -10,9 +12,9 @@ require (
github.com/aws/smithy-go v1.28.1
github.com/fsnotify/fsnotify v1.10.1
github.com/getkin/kin-openapi v0.135.0
github.com/jackc/pgx/v5 v5.6.0
github.com/jackc/pgx/v5 v5.9.2
go.mozilla.org/pkcs7 v0.10.0
golang.org/x/text v0.14.0
golang.org/x/text v0.41.0
)
require (
@@ -33,8 +35,8 @@ require (
github.com/go-openapi/swag v0.23.0 // indirect
github.com/gorilla/mux v1.8.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
github.com/jackc/puddle/v2 v2.2.1 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect
@@ -42,8 +44,7 @@ require (
github.com/oasdiff/yaml3 v0.0.9 // indirect
github.com/perimeterx/marshmallow v1.1.5 // indirect
github.com/woodsbury/decimal128 v1.3.0 // indirect
golang.org/x/crypto v0.17.0 // indirect
golang.org/x/sync v0.1.0 // indirect
golang.org/x/sys v0.15.0 // indirect
golang.org/x/sync v0.22.0 // indirect
golang.org/x/sys v0.47.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
+14 -16
View File
@@ -51,12 +51,12 @@ github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY=
github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw=
github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
@@ -80,22 +80,20 @@ github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/ugorji/go/codec v1.2.7 h1:YPXUKf7fYbp/y8xloBqZOw2qaVggbfwMlI8WM3wZUJ0=
github.com/ugorji/go/codec v1.2.7/go.mod h1:WGN1fab3R1fzQlVQTkfxVtIBhWDRqOviHU95kRgeqEY=
github.com/woodsbury/decimal128 v1.3.0 h1:8pffMNWIlC0O5vbyHWFZAt5yWvWcrHA+3ovIIjVWss0=
github.com/woodsbury/decimal128 v1.3.0/go.mod h1:C5UTmyTjW3JftjUFzOVhC20BEQa2a4ZKOB5I6Zjb+ds=
go.mozilla.org/pkcs7 v0.10.0 h1:jmljzDzNYFzaP1dFlgmCiQml9e+iEMmv8/NNs4evQbg=
go.mozilla.org/pkcs7 v0.10.0/go.mod h1:SNgMg+EgDFwmvSmLRTNKC5fegJjB7v23qTQ0XLGUNHk=
golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k=
golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc=
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
@@ -0,0 +1,3 @@
ALTER TABLE webhook_subscriptions
DROP COLUMN secret_ciphertext,
ADD COLUMN secret TEXT NOT NULL DEFAULT '';
@@ -0,0 +1,16 @@
-- QA-15 F-22: das Signatur-Geheimnis eines Webhook-Abonnements lag als
-- Klartext in der Registry-Datenbank (`secret`). internal/webhook ist noch
-- in keinem Dienst verdrahtet (API-07 baute nur die Engine) — es gibt keine
-- Bestandsdaten zu migrieren. `secret` wird ersetzt durch
-- `secret_ciphertext` (AES-256-GCM ueber internal/kek, dasselbe Verfahren
-- wie API-16/FDN-09).
-- IF EXISTS/IF NOT EXISTS statt einfacher DROP/ADD COLUMN: mehrere
-- Core-E2E-Tests (internal/e2e) teilen sich dieselbe Registry-Datenbank und
-- wenden alle migrations/*.up.sql je Testlauf erneut an, ohne
-- Migrations-Tracking (siehe internal/e2e/helpers_test.go); ein erneutes
-- DROP COLUMN secret waere sonst der einzige Migrationsschritt in diesem
-- Repository, der beim zweiten Lauf mit einem NICHT tolerierten Fehler
-- (undefined_column) abbricht.
ALTER TABLE webhook_subscriptions
DROP COLUMN IF EXISTS secret,
ADD COLUMN IF NOT EXISTS secret_ciphertext BYTEA NOT NULL;
+61
View File
@@ -0,0 +1,61 @@
#!/usr/bin/env bash
# OPS-08 (QA-15 F-13/F-15): prueft, dass reine Netzwerkdienste
# (Type=simple, kein Dateisystemschreibzugriff ausserhalb ihrer
# EnvironmentFile-Konfiguration) die vereinbarten Sandbox-Direktiven
# tragen. Oneshot-CLIs mit eigenem Schreibziel (Backup/Restore/Scrub/
# Objectbackup/Reconcile/Tenantbackup) sind bewusst NICHT Teil dieser
# Liste (siehe OPS-08 im Protokoll: eigene ReadWritePaths je Zielverzeichnis
# noetig, nicht Teil der "kleinsten Loesung" dieses Durchgangs).
set -euo pipefail
root="${1:-.}"
dir="$root/deploy/systemd"
[ -d "$dir" ] || { echo "verzeichnis fehlt: $dir" >&2; exit 2; }
required_bool=(NoNewPrivileges ProtectHome PrivateTmp)
network_only=(
nexarch-account-api
nexarch-archive-moduleadapter-api
nexarch-archive-restoretest-metrics
nexarch-archive-retention-api
nexarch-archive-scrub-metrics
nexarch-auditlog-devserver
nexarch-core
nexarch-kek-api
nexarch-licadmin-devserver
nexarch-metrics-devserver
nexarch-notify-api
nexarch-notifyprefs-api
nexarch-policy-api
nexarch-rbac-admin-api
nexarch-resync-api
nexarch-secheaders-devserver
nexarch-statuspage-devserver
nexarch-tenantadmin-devserver
)
fail=0
for svc in "${network_only[@]}"; do
f="$dir/$svc.service.tmpl"
if [ ! -f "$f" ]; then
echo "FEHLT: $f" >&2
fail=1
continue
fi
for directive in "${required_bool[@]}"; do
if ! grep -q "^${directive}=true" "$f"; then
echo "$svc.service.tmpl: ${directive}=true fehlt" >&2
fail=1
fi
done
if ! grep -qE "^ProtectSystem=(strict|full)" "$f"; then
echo "$svc.service.tmpl: ProtectSystem=strict (oder full) fehlt" >&2
fail=1
fi
done
if [ "$fail" -eq 0 ]; then
echo "systemd-haertung ok: ${#network_only[@]} netzwerk-dienste geprueft"
fi
exit $fail