Merge PR #122 (OPS-08/09/10 Haertungsbuendel)
This commit is contained in:
commit
2cb48e6e73
47 files changed
+1093
-114
No files matched your search
@@ -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()
|
||||
|
||||
@@ -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
@@ -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
@@ -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=
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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).
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
Executable
+61
@@ -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
|
||||
Reference in new issue
Block a user