Skip to content

Commit f678468

Browse files
intel352claude
andauthored
feat: security hardening — TLS, token blacklist, field encryption, sandbox, secret rotation (#210)
* feat: security hardening — TLS, token blacklist, field-level encryption, sandbox, secret rotation Phase 1: TLS support for all transports - pkg/tlsutil: shared TLS config (manual, autocert, mTLS) - HTTP server: Let's Encrypt autocert + manual TLS + mTLS - Redis: TLS with client cert support - Kafka: SASL (PLAIN/SCRAM-SHA-256/512) + TLS - NATS: TLS via nats.Secure() - Database: explicit TLS fields (sslmode, ca_file) Phase 2: Token blacklist - auth.token-blacklist module (memory + redis backends) - step.token_revoke pipeline step - JTI generation + blacklist check in JWTAuthModule Phase 3: Field-level data protection - pkg/fieldcrypt: AES-256-GCM encryption, masking, HKDF key derivation - Tenant-isolated KeyRing with versioned keys - ProtectedFieldManager module (security.field-protection) - step.field_reencrypt for key rotation re-encryption - Backward compat: legacy enc:: prefix handled alongside new epf:v{n}: format Phase 4: Docker sandbox hardening - seccomp profiles, capability dropping, read-only rootfs, no-new-privileges - step.sandbox_exec with strict/standard/permissive security profiles - Default secure config with Wolfi base image (cgr.dev/chainguard/wolfi-base) Phase 5: Secret rotation - RotationProvider interface in secrets package - Vault provider Rotate() + GetPrevious() via versioned KV v2 - step.secret_rotate pipeline step Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: address CI lint and build failures - Fix nilerr: use separate parseErr variable in token revoke step - Fix staticcheck: remove redundant embedded field selector in SCRAM client - Remove unused alwaysErrorApp type and fmt import in test - Update example/go.sum with xdg-go/scram dependency Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: field protection wiring, error handling, and Kafka integration - Require master_key (error instead of zero-key fallback) - Handle error in field-protection module factory - Add field-protection-wiring hook: connects ProtectedFieldManager to KafkaBroker - KafkaBroker.SetFieldProtection() for field-level encrypt/decrypt in JSON payloads - Add Registry.Len() method - Add TestFieldProtectionRequiresMasterKey test - Update wiring hook count in auth plugin tests Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: implement CopyIn/CopyOut with CreateContainer/RemoveContainer lifecycle Replace stub CopyIn/CopyOut methods with real implementations that use the Docker API. Added CreateContainer() to create a container and store its ID, and RemoveContainer() to clean up. CopyIn delegates to the existing copyToContainer helper; CopyOut uses client.CopyFromContainer. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: return errors from token revoke step for invalid tokens Return parseErr when JWT parsing fails and fmt.Errorf for invalid claims type, instead of swallowing the error. Fixes nilerr lint violation. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: add field-protection-wiring to manifest and set env in test The manifest WiringHooks list was missing the field-protection-wiring entry, and TestModuleFactories needed FIELD_ENCRYPTION_KEY set for the security.field-protection factory to succeed. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 3eb2876 commit f678468

41 files changed

Lines changed: 4390 additions & 55 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

example/go.mod

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,9 @@ require (
151151
github.com/santhosh-tekuri/jsonschema/v6 v6.0.1 // indirect
152152
github.com/spf13/pflag v1.0.10 // indirect
153153
github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect
154+
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
155+
github.com/xdg-go/scram v1.2.0 // indirect
156+
github.com/xdg-go/stringprep v1.0.4 // indirect
154157
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
155158
go.opentelemetry.io/contrib/detectors/gcp v1.39.0 // indirect
156159
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0 // indirect

example/go.sum

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -407,6 +407,12 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
407407
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
408408
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
409409
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
410+
github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
411+
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
412+
github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs=
413+
github.com/xdg-go/scram v1.2.0/go.mod h1:3dlrS0iBaWKYVt2ZfA4cj48umJZ+cAEbR6/SjLA88I8=
414+
github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8=
415+
github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
410416
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
411417
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
412418
github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
@@ -499,6 +505,7 @@ golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
499505
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
500506
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
501507
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
508+
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
502509
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
503510
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
504511
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=

go.mod

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,9 @@ require (
207207
github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect
208208
github.com/tliron/commonlog v0.2.8 // indirect
209209
github.com/tliron/kutil v0.3.11 // indirect
210+
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
211+
github.com/xdg-go/scram v1.2.0 // indirect
212+
github.com/xdg-go/stringprep v1.0.4 // indirect
210213
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
211214
github.com/yuin/gopher-lua v1.1.1 // indirect
212215
go.opentelemetry.io/auto/sdk v1.2.1 // indirect

go.sum

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -506,6 +506,12 @@ github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6Kllzaw
506506
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
507507
github.com/wsxiaoys/terminal v0.0.0-20160513160801-0940f3fc43a0 h1:3UeQBvD0TFrlVjOeLOBz+CPAI8dnbqNSVwUwRrkp7vQ=
508508
github.com/wsxiaoys/terminal v0.0.0-20160513160801-0940f3fc43a0/go.mod h1:IXCdmsXIht47RaVFLEdVnh1t+pgYtTAhQGj73kz+2DM=
509+
github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
510+
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
511+
github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs=
512+
github.com/xdg-go/scram v1.2.0/go.mod h1:3dlrS0iBaWKYVt2ZfA4cj48umJZ+cAEbR6/SjLA88I8=
513+
github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8=
514+
github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
509515
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
510516
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
511517
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
@@ -606,6 +612,7 @@ golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
606612
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
607613
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
608614
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
615+
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
609616
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
610617
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
611618
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=

module/auth_token_blacklist.go

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
package module
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"sync"
7+
"time"
8+
9+
"github.com/CrisisTextLine/modular"
10+
"github.com/redis/go-redis/v9"
11+
)
12+
13+
// TokenBlacklist is the interface for checking and adding revoked JWT IDs.
14+
type TokenBlacklist interface {
15+
Add(jti string, expiresAt time.Time)
16+
IsBlacklisted(jti string) bool
17+
}
18+
19+
// TokenBlacklistModule maintains a set of revoked JWT IDs (JTIs).
20+
// It supports two backends: "memory" (default) and "redis".
21+
type TokenBlacklistModule struct {
22+
name string
23+
backend string
24+
redisURL string
25+
cleanupInterval time.Duration
26+
27+
// memory backend
28+
entries sync.Map // jti (string) -> expiry (time.Time)
29+
30+
// redis backend
31+
redisClient *redis.Client
32+
33+
logger modular.Logger
34+
stopCh chan struct{}
35+
}
36+
37+
// NewTokenBlacklistModule creates a new TokenBlacklistModule.
38+
func NewTokenBlacklistModule(name, backend, redisURL string, cleanupInterval time.Duration) *TokenBlacklistModule {
39+
if backend == "" {
40+
backend = "memory"
41+
}
42+
if cleanupInterval <= 0 {
43+
cleanupInterval = 5 * time.Minute
44+
}
45+
return &TokenBlacklistModule{
46+
name: name,
47+
backend: backend,
48+
redisURL: redisURL,
49+
cleanupInterval: cleanupInterval,
50+
stopCh: make(chan struct{}),
51+
}
52+
}
53+
54+
// Name returns the module name.
55+
func (m *TokenBlacklistModule) Name() string { return m.name }
56+
57+
// Init initializes the module.
58+
func (m *TokenBlacklistModule) Init(app modular.Application) error {
59+
m.logger = app.Logger()
60+
return nil
61+
}
62+
63+
// Start connects to Redis (if configured) and starts the cleanup goroutine.
64+
func (m *TokenBlacklistModule) Start(ctx context.Context) error {
65+
if m.backend == "redis" {
66+
if m.redisURL == "" {
67+
return fmt.Errorf("auth.token-blacklist %q: redis_url is required for redis backend", m.name)
68+
}
69+
opts, err := redis.ParseURL(m.redisURL)
70+
if err != nil {
71+
return fmt.Errorf("auth.token-blacklist %q: invalid redis_url: %w", m.name, err)
72+
}
73+
m.redisClient = redis.NewClient(opts)
74+
if err := m.redisClient.Ping(ctx).Err(); err != nil {
75+
_ = m.redisClient.Close()
76+
m.redisClient = nil
77+
return fmt.Errorf("auth.token-blacklist %q: redis ping failed: %w", m.name, err)
78+
}
79+
m.logger.Info("token blacklist started", "name", m.name, "backend", "redis")
80+
return nil
81+
}
82+
83+
// memory backend: start cleanup goroutine
84+
go m.runCleanup()
85+
m.logger.Info("token blacklist started", "name", m.name, "backend", "memory")
86+
return nil
87+
}
88+
89+
// Stop shuts down the module.
90+
func (m *TokenBlacklistModule) Stop(_ context.Context) error {
91+
select {
92+
case <-m.stopCh:
93+
// already closed
94+
default:
95+
close(m.stopCh)
96+
}
97+
if m.redisClient != nil {
98+
return m.redisClient.Close()
99+
}
100+
return nil
101+
}
102+
103+
// Add marks a JTI as revoked until expiresAt.
104+
func (m *TokenBlacklistModule) Add(jti string, expiresAt time.Time) {
105+
if m.backend == "redis" && m.redisClient != nil {
106+
ttl := time.Until(expiresAt)
107+
if ttl <= 0 {
108+
return // already expired, nothing to blacklist
109+
}
110+
_ = m.redisClient.Set(context.Background(), m.redisKey(jti), "1", ttl).Err()
111+
return
112+
}
113+
m.entries.Store(jti, expiresAt)
114+
}
115+
116+
// IsBlacklisted returns true if the JTI is revoked and has not yet expired.
117+
func (m *TokenBlacklistModule) IsBlacklisted(jti string) bool {
118+
if m.backend == "redis" && m.redisClient != nil {
119+
n, err := m.redisClient.Exists(context.Background(), m.redisKey(jti)).Result()
120+
return err == nil && n > 0
121+
}
122+
val, ok := m.entries.Load(jti)
123+
if !ok {
124+
return false
125+
}
126+
expiry, ok := val.(time.Time)
127+
return ok && time.Now().Before(expiry)
128+
}
129+
130+
func (m *TokenBlacklistModule) redisKey(jti string) string {
131+
return "blacklist:" + jti
132+
}
133+
134+
func (m *TokenBlacklistModule) runCleanup() {
135+
ticker := time.NewTicker(m.cleanupInterval)
136+
defer ticker.Stop()
137+
for {
138+
select {
139+
case <-m.stopCh:
140+
return
141+
case <-ticker.C:
142+
now := time.Now()
143+
m.entries.Range(func(key, value any) bool {
144+
if expiry, ok := value.(time.Time); ok && now.After(expiry) {
145+
m.entries.Delete(key)
146+
}
147+
return true
148+
})
149+
}
150+
}
151+
}
152+
153+
// ProvidesServices registers this module as a service.
154+
func (m *TokenBlacklistModule) ProvidesServices() []modular.ServiceProvider {
155+
return []modular.ServiceProvider{
156+
{Name: m.name, Description: "JWT token blacklist", Instance: m},
157+
}
158+
}
159+
160+
// RequiresServices returns service dependencies (none).
161+
func (m *TokenBlacklistModule) RequiresServices() []modular.ServiceDependency {
162+
return nil
163+
}
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
package module
2+
3+
import (
4+
"context"
5+
"testing"
6+
"time"
7+
)
8+
9+
func TestTokenBlacklistMemory_AddAndCheck(t *testing.T) {
10+
bl := NewTokenBlacklistModule("test-bl", "memory", "", time.Minute)
11+
12+
if bl.IsBlacklisted("jti-1") {
13+
t.Fatal("expected jti-1 to not be blacklisted initially")
14+
}
15+
16+
bl.Add("jti-1", time.Now().Add(time.Hour))
17+
if !bl.IsBlacklisted("jti-1") {
18+
t.Fatal("expected jti-1 to be blacklisted after Add")
19+
}
20+
21+
if bl.IsBlacklisted("jti-unknown") {
22+
t.Fatal("expected jti-unknown to not be blacklisted")
23+
}
24+
}
25+
26+
func TestTokenBlacklistMemory_ExpiredEntry(t *testing.T) {
27+
bl := NewTokenBlacklistModule("test-bl", "memory", "", time.Minute)
28+
29+
// Add a JTI that has already expired.
30+
bl.Add("jti-expired", time.Now().Add(-time.Second))
31+
if bl.IsBlacklisted("jti-expired") {
32+
t.Fatal("expected already-expired JTI to not be blacklisted")
33+
}
34+
}
35+
36+
func TestTokenBlacklistMemory_Cleanup(t *testing.T) {
37+
bl := NewTokenBlacklistModule("test-bl", "memory", "", 50*time.Millisecond)
38+
39+
app := NewMockApplication()
40+
if err := bl.Init(app); err != nil {
41+
t.Fatalf("Init: %v", err)
42+
}
43+
if err := bl.Start(context.Background()); err != nil {
44+
t.Fatalf("Start: %v", err)
45+
}
46+
defer func() { _ = bl.Stop(context.Background()) }()
47+
48+
// Add a JTI that expires in 10ms.
49+
bl.Add("jti-cleanup", time.Now().Add(10*time.Millisecond))
50+
51+
// Give it time to expire and be cleaned up by the cleanup goroutine.
52+
time.Sleep(300 * time.Millisecond)
53+
54+
if bl.IsBlacklisted("jti-cleanup") {
55+
t.Fatal("expected cleaned-up JTI to no longer be blacklisted")
56+
}
57+
}
58+
59+
func TestTokenBlacklistModule_StopIdempotent(t *testing.T) {
60+
bl := NewTokenBlacklistModule("test-bl", "memory", "", time.Minute)
61+
app := NewMockApplication()
62+
if err := bl.Init(app); err != nil {
63+
t.Fatalf("Init: %v", err)
64+
}
65+
if err := bl.Start(context.Background()); err != nil {
66+
t.Fatalf("Start: %v", err)
67+
}
68+
// Calling Stop twice must not panic.
69+
if err := bl.Stop(context.Background()); err != nil {
70+
t.Fatalf("first Stop: %v", err)
71+
}
72+
if err := bl.Stop(context.Background()); err != nil {
73+
t.Fatalf("second Stop: %v", err)
74+
}
75+
}
76+
77+
func TestTokenBlacklistModule_ProvidesServices(t *testing.T) {
78+
bl := NewTokenBlacklistModule("my-bl", "memory", "", time.Minute)
79+
svcs := bl.ProvidesServices()
80+
if len(svcs) != 1 {
81+
t.Fatalf("expected 1 service, got %d", len(svcs))
82+
}
83+
if svcs[0].Name != "my-bl" {
84+
t.Fatalf("expected service name 'my-bl', got %q", svcs[0].Name)
85+
}
86+
if _, ok := svcs[0].Instance.(TokenBlacklist); !ok {
87+
t.Fatal("expected service instance to implement TokenBlacklist")
88+
}
89+
}

module/cache_redis.go

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"time"
88

99
"github.com/CrisisTextLine/modular"
10+
"github.com/GoCodeAlone/workflow/pkg/tlsutil"
1011
"github.com/redis/go-redis/v9"
1112
)
1213

@@ -30,10 +31,11 @@ type RedisClient interface {
3031
// RedisCacheConfig holds configuration for the cache.redis module.
3132
type RedisCacheConfig struct {
3233
Address string
33-
Password string //nolint:gosec // G117: config struct field, not a hardcoded secret
34+
Password string //nolint:gosec // G117: config struct field, not a hardcoded secret
3435
DB int
3536
Prefix string
3637
DefaultTTL time.Duration
38+
TLS tlsutil.TLSConfig `yaml:"tls" json:"tls"`
3739
}
3840

3941
// RedisCache is a module that connects to a Redis instance and exposes
@@ -87,6 +89,14 @@ func (r *RedisCache) Start(ctx context.Context) error {
8789
opts.Password = r.cfg.Password
8890
}
8991

92+
if r.cfg.TLS.Enabled {
93+
tlsCfg, err := tlsutil.LoadTLSConfig(r.cfg.TLS)
94+
if err != nil {
95+
return fmt.Errorf("cache.redis %q: TLS config: %w", r.name, err)
96+
}
97+
opts.TLSConfig = tlsCfg
98+
}
99+
90100
r.client = redis.NewClient(opts)
91101

92102
if err := r.client.Ping(ctx).Err(); err != nil {

0 commit comments

Comments
 (0)