-
Notifications
You must be signed in to change notification settings - Fork 0
feat: security hardening — TLS, token blacklist, field encryption, sandbox, secret rotation #210
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
6aecfc6
feat: security hardening — TLS, token blacklist, field-level encrypti…
intel352 a6042be
fix: address CI lint and build failures
intel352 d5bafa2
fix: field protection wiring, error handling, and Kafka integration
intel352 9bd419f
fix: implement CopyIn/CopyOut with CreateContainer/RemoveContainer li…
intel352 ba249f7
fix: return errors from token revoke step for invalid tokens
intel352 bac3b4f
fix: add field-protection-wiring to manifest and set env in test
intel352 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,163 @@ | ||
| package module | ||
|
|
||
| import ( | ||
| "context" | ||
| "fmt" | ||
| "sync" | ||
| "time" | ||
|
|
||
| "github.com/CrisisTextLine/modular" | ||
| "github.com/redis/go-redis/v9" | ||
| ) | ||
|
|
||
| // TokenBlacklist is the interface for checking and adding revoked JWT IDs. | ||
| type TokenBlacklist interface { | ||
| Add(jti string, expiresAt time.Time) | ||
| IsBlacklisted(jti string) bool | ||
| } | ||
|
|
||
| // TokenBlacklistModule maintains a set of revoked JWT IDs (JTIs). | ||
| // It supports two backends: "memory" (default) and "redis". | ||
| type TokenBlacklistModule struct { | ||
| name string | ||
| backend string | ||
| redisURL string | ||
| cleanupInterval time.Duration | ||
|
|
||
| // memory backend | ||
| entries sync.Map // jti (string) -> expiry (time.Time) | ||
|
|
||
| // redis backend | ||
| redisClient *redis.Client | ||
|
|
||
| logger modular.Logger | ||
| stopCh chan struct{} | ||
| } | ||
|
|
||
| // NewTokenBlacklistModule creates a new TokenBlacklistModule. | ||
| func NewTokenBlacklistModule(name, backend, redisURL string, cleanupInterval time.Duration) *TokenBlacklistModule { | ||
| if backend == "" { | ||
| backend = "memory" | ||
| } | ||
| if cleanupInterval <= 0 { | ||
| cleanupInterval = 5 * time.Minute | ||
| } | ||
| return &TokenBlacklistModule{ | ||
| name: name, | ||
| backend: backend, | ||
| redisURL: redisURL, | ||
| cleanupInterval: cleanupInterval, | ||
| stopCh: make(chan struct{}), | ||
| } | ||
| } | ||
|
|
||
| // Name returns the module name. | ||
| func (m *TokenBlacklistModule) Name() string { return m.name } | ||
|
|
||
| // Init initializes the module. | ||
| func (m *TokenBlacklistModule) Init(app modular.Application) error { | ||
| m.logger = app.Logger() | ||
| return nil | ||
| } | ||
|
|
||
| // Start connects to Redis (if configured) and starts the cleanup goroutine. | ||
| func (m *TokenBlacklistModule) Start(ctx context.Context) error { | ||
| if m.backend == "redis" { | ||
| if m.redisURL == "" { | ||
| return fmt.Errorf("auth.token-blacklist %q: redis_url is required for redis backend", m.name) | ||
| } | ||
| opts, err := redis.ParseURL(m.redisURL) | ||
| if err != nil { | ||
| return fmt.Errorf("auth.token-blacklist %q: invalid redis_url: %w", m.name, err) | ||
| } | ||
| m.redisClient = redis.NewClient(opts) | ||
| if err := m.redisClient.Ping(ctx).Err(); err != nil { | ||
| _ = m.redisClient.Close() | ||
| m.redisClient = nil | ||
| return fmt.Errorf("auth.token-blacklist %q: redis ping failed: %w", m.name, err) | ||
| } | ||
| m.logger.Info("token blacklist started", "name", m.name, "backend", "redis") | ||
| return nil | ||
| } | ||
|
|
||
| // memory backend: start cleanup goroutine | ||
| go m.runCleanup() | ||
| m.logger.Info("token blacklist started", "name", m.name, "backend", "memory") | ||
| return nil | ||
| } | ||
|
|
||
| // Stop shuts down the module. | ||
| func (m *TokenBlacklistModule) Stop(_ context.Context) error { | ||
| select { | ||
| case <-m.stopCh: | ||
| // already closed | ||
| default: | ||
| close(m.stopCh) | ||
| } | ||
| if m.redisClient != nil { | ||
| return m.redisClient.Close() | ||
| } | ||
| return nil | ||
| } | ||
|
|
||
| // Add marks a JTI as revoked until expiresAt. | ||
| func (m *TokenBlacklistModule) Add(jti string, expiresAt time.Time) { | ||
| if m.backend == "redis" && m.redisClient != nil { | ||
| ttl := time.Until(expiresAt) | ||
| if ttl <= 0 { | ||
| return // already expired, nothing to blacklist | ||
| } | ||
| _ = m.redisClient.Set(context.Background(), m.redisKey(jti), "1", ttl).Err() | ||
| return | ||
| } | ||
| m.entries.Store(jti, expiresAt) | ||
| } | ||
|
|
||
| // IsBlacklisted returns true if the JTI is revoked and has not yet expired. | ||
| func (m *TokenBlacklistModule) IsBlacklisted(jti string) bool { | ||
| if m.backend == "redis" && m.redisClient != nil { | ||
| n, err := m.redisClient.Exists(context.Background(), m.redisKey(jti)).Result() | ||
| return err == nil && n > 0 | ||
| } | ||
| val, ok := m.entries.Load(jti) | ||
| if !ok { | ||
| return false | ||
| } | ||
| expiry, ok := val.(time.Time) | ||
| return ok && time.Now().Before(expiry) | ||
| } | ||
|
|
||
| func (m *TokenBlacklistModule) redisKey(jti string) string { | ||
| return "blacklist:" + jti | ||
| } | ||
|
|
||
| func (m *TokenBlacklistModule) runCleanup() { | ||
| ticker := time.NewTicker(m.cleanupInterval) | ||
| defer ticker.Stop() | ||
| for { | ||
| select { | ||
| case <-m.stopCh: | ||
| return | ||
| case <-ticker.C: | ||
| now := time.Now() | ||
| m.entries.Range(func(key, value any) bool { | ||
| if expiry, ok := value.(time.Time); ok && now.After(expiry) { | ||
| m.entries.Delete(key) | ||
| } | ||
| return true | ||
| }) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| // ProvidesServices registers this module as a service. | ||
| func (m *TokenBlacklistModule) ProvidesServices() []modular.ServiceProvider { | ||
| return []modular.ServiceProvider{ | ||
| {Name: m.name, Description: "JWT token blacklist", Instance: m}, | ||
| } | ||
| } | ||
|
|
||
| // RequiresServices returns service dependencies (none). | ||
| func (m *TokenBlacklistModule) RequiresServices() []modular.ServiceDependency { | ||
| return nil | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,89 @@ | ||
| package module | ||
|
|
||
| import ( | ||
| "context" | ||
| "testing" | ||
| "time" | ||
| ) | ||
|
|
||
| func TestTokenBlacklistMemory_AddAndCheck(t *testing.T) { | ||
| bl := NewTokenBlacklistModule("test-bl", "memory", "", time.Minute) | ||
|
|
||
| if bl.IsBlacklisted("jti-1") { | ||
| t.Fatal("expected jti-1 to not be blacklisted initially") | ||
| } | ||
|
|
||
| bl.Add("jti-1", time.Now().Add(time.Hour)) | ||
| if !bl.IsBlacklisted("jti-1") { | ||
| t.Fatal("expected jti-1 to be blacklisted after Add") | ||
| } | ||
|
|
||
| if bl.IsBlacklisted("jti-unknown") { | ||
| t.Fatal("expected jti-unknown to not be blacklisted") | ||
| } | ||
| } | ||
|
|
||
| func TestTokenBlacklistMemory_ExpiredEntry(t *testing.T) { | ||
| bl := NewTokenBlacklistModule("test-bl", "memory", "", time.Minute) | ||
|
|
||
| // Add a JTI that has already expired. | ||
| bl.Add("jti-expired", time.Now().Add(-time.Second)) | ||
| if bl.IsBlacklisted("jti-expired") { | ||
| t.Fatal("expected already-expired JTI to not be blacklisted") | ||
| } | ||
| } | ||
|
|
||
| func TestTokenBlacklistMemory_Cleanup(t *testing.T) { | ||
| bl := NewTokenBlacklistModule("test-bl", "memory", "", 50*time.Millisecond) | ||
|
|
||
| app := NewMockApplication() | ||
| if err := bl.Init(app); err != nil { | ||
| t.Fatalf("Init: %v", err) | ||
| } | ||
| if err := bl.Start(context.Background()); err != nil { | ||
| t.Fatalf("Start: %v", err) | ||
| } | ||
| defer func() { _ = bl.Stop(context.Background()) }() | ||
|
|
||
| // Add a JTI that expires in 10ms. | ||
| bl.Add("jti-cleanup", time.Now().Add(10*time.Millisecond)) | ||
|
|
||
| // Give it time to expire and be cleaned up by the cleanup goroutine. | ||
| time.Sleep(300 * time.Millisecond) | ||
|
|
||
| if bl.IsBlacklisted("jti-cleanup") { | ||
| t.Fatal("expected cleaned-up JTI to no longer be blacklisted") | ||
| } | ||
| } | ||
|
|
||
| func TestTokenBlacklistModule_StopIdempotent(t *testing.T) { | ||
| bl := NewTokenBlacklistModule("test-bl", "memory", "", time.Minute) | ||
| app := NewMockApplication() | ||
| if err := bl.Init(app); err != nil { | ||
| t.Fatalf("Init: %v", err) | ||
| } | ||
| if err := bl.Start(context.Background()); err != nil { | ||
| t.Fatalf("Start: %v", err) | ||
| } | ||
| // Calling Stop twice must not panic. | ||
| if err := bl.Stop(context.Background()); err != nil { | ||
| t.Fatalf("first Stop: %v", err) | ||
| } | ||
| if err := bl.Stop(context.Background()); err != nil { | ||
| t.Fatalf("second Stop: %v", err) | ||
| } | ||
| } | ||
|
|
||
| func TestTokenBlacklistModule_ProvidesServices(t *testing.T) { | ||
| bl := NewTokenBlacklistModule("my-bl", "memory", "", time.Minute) | ||
| svcs := bl.ProvidesServices() | ||
| if len(svcs) != 1 { | ||
| t.Fatalf("expected 1 service, got %d", len(svcs)) | ||
| } | ||
| if svcs[0].Name != "my-bl" { | ||
| t.Fatalf("expected service name 'my-bl', got %q", svcs[0].Name) | ||
| } | ||
| if _, ok := svcs[0].Instance.(TokenBlacklist); !ok { | ||
| t.Fatal("expected service instance to implement TokenBlacklist") | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Redis backend operations ignore errors (e.g.,
Setin Add). If Redis is unavailable, tokens may not actually be blacklisted and there’s no signal to the operator. At minimum, log errors via the module logger; ideally consider whether the interface should allow propagating errors for security-critical revocation paths.