From b96035fc0684fbba6e952349a9f7391200ebd7f9 Mon Sep 17 00:00:00 2001 From: Shashank Mittal Date: Tue, 22 Sep 2026 13:27:49 +0530 Subject: [PATCH 1/3] fix: redact secrets in command and cluster API context Keep password, private_key, and similar fields in responses but replace their values so GET /commands and /clusters no longer leak credentials. --- pkg/context/context.go | 85 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 81 insertions(+), 4 deletions(-) diff --git a/pkg/context/context.go b/pkg/context/context.go index fa39bb18..10a20506 100644 --- a/pkg/context/context.go +++ b/pkg/context/context.go @@ -2,13 +2,33 @@ package context import ( "encoding/json" + "strings" ) +const redactedValue = `REDACTED` + +// sensitiveKeys are context field names whose values must never leave the +// process via JSON APIs. Matching is case-insensitive. Values are retained +// in-memory and when persisting via String(). +var sensitiveKeys = map[string]struct{}{ + `password`: {}, + `private_key`: {}, + `secret`: {}, + `token`: {}, + `api_key`: {}, + `access_key`: {}, + `secret_key`: {}, + `client_secret`: {}, + `secret_access_key`: {}, + `aws_secret_access_key`: {}, +} + type Context map[string]any func New(v any) *Context { - data, err := json.Marshal(v) + // Avoid Context.MarshalJSON so secrets are not redacted when cloning. + data, err := marshalRaw(v) if err != nil { panic(`cannot marshal json`) } @@ -23,6 +43,20 @@ func New(v any) *Context { } +func marshalRaw(v any) ([]byte, error) { + switch t := v.(type) { + case Context: + return json.Marshal(map[string]any(t)) + case *Context: + if t == nil { + return []byte(`null`), nil + } + return json.Marshal(map[string]any(*t)) + default: + return json.Marshal(v) + } +} + func (c *Context) UnmarshalYAML(unmarshal func(any) error) error { value := make(map[string]any) @@ -51,10 +85,20 @@ func (c *Context) UnmarshalJSON(data []byte) error { } +// MarshalJSON redacts sensitive fields for API responses. Internal helpers +// (String, Unmarshal) marshal the underlying map so plugins and DB storage +// still see real secrets. +func (c Context) MarshalJSON() ([]byte, error) { + if c == nil { + return []byte(`null`), nil + } + return json.Marshal(redactMap(c)) +} + func (c *Context) Unmarshal(v any) error { - // let's marshal our data first - data, err := json.Marshal(*c) + // Marshal the underlying map so sensitive values are not redacted. + data, err := json.Marshal(map[string]any(*c)) if err != nil { return err @@ -70,8 +114,41 @@ func (c *Context) String() string { return `` } - data, _ := json.Marshal(*c) + // Persist the real context; do not go through MarshalJSON redaction. + data, _ := json.Marshal(map[string]any(*c)) return string(data) } + +func isSensitiveKey(key string) bool { + _, ok := sensitiveKeys[strings.ToLower(key)] + return ok +} + +func redactMap(m map[string]any) map[string]any { + out := make(map[string]any, len(m)) + for k, v := range m { + if isSensitiveKey(k) { + out[k] = redactedValue + continue + } + out[k] = redactValue(v) + } + return out +} + +func redactValue(v any) any { + switch t := v.(type) { + case map[string]any: + return redactMap(t) + case []any: + out := make([]any, len(t)) + for i, item := range t { + out[i] = redactValue(item) + } + return out + default: + return v + } +} From 9a82f7a428cfe9aaf514db87d8f67ae05ab5d6fd Mon Sep 17 00:00:00 2001 From: Shashank Mittal Date: Thu, 24 Sep 2026 14:22:22 +0530 Subject: [PATCH 2/3] feat: configure additional sensitive context keys --- README.md | 12 +++++++++++ internal/pkg/heimdall/heimdall.go | 36 +++++++++++++++++-------------- pkg/context/context.go | 24 ++++++++++++--------- 3 files changed, 46 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 553d320a..550b3f8e 100644 --- a/README.md +++ b/README.md @@ -150,6 +150,18 @@ Initially, Commands and Clusters are configured via a static config file (see [c * Health-based routing * API-based dynamic configuration +Context keys named `password`, `private_key`, or `token` are always redacted from JSON API +responses. Additional key names can be configured globally (matching is case-insensitive): + +```yaml +sensitive_context_keys: + - api_key + - client_secret +``` + +Redaction affects API output only. The original values remain available to plugins and database +persistence. + --- ## 🔁 Command & Cluster Matching Logic diff --git a/internal/pkg/heimdall/heimdall.go b/internal/pkg/heimdall/heimdall.go index c23d50dc..68128f98 100644 --- a/internal/pkg/heimdall/heimdall.go +++ b/internal/pkg/heimdall/heimdall.go @@ -18,6 +18,7 @@ import ( "github.com/patterninc/heimdall/internal/pkg/pool" "github.com/patterninc/heimdall/internal/pkg/rbac" "github.com/patterninc/heimdall/internal/pkg/server" + heimdallContext "github.com/patterninc/heimdall/pkg/context" "github.com/patterninc/heimdall/pkg/object/cluster" "github.com/patterninc/heimdall/pkg/object/command" "github.com/patterninc/heimdall/pkg/object/job" @@ -42,26 +43,29 @@ const ( ) type Heimdall struct { - Server *server.Server `yaml:"server,omitempty" json:"server,omitempty"` - Commands command.Commands `yaml:"commands,omitempty" json:"commands,omitempty"` - Clusters cluster.Clusters `yaml:"clusters,omitempty" json:"clusters,omitempty"` - RBACs rbac.RBACs `yaml:"rbacs,omitempty" json:"rbacs,omitempty"` - JobsDirectory string `yaml:"jobs_directory,omitempty" json:"jobs_directory,omitempty"` - ArchiveDirectory string `yaml:"archive_directory,omitempty" json:"archive_directory,omitempty"` - ResultDirectory string `yaml:"result_directory,omitempty" json:"result_directory,omitempty"` - PluginsDirectory string `yaml:"plugin_directory,omitempty" json:"plugin_directory,omitempty"` - Database *database.Database `yaml:"database,omitempty" json:"database,omitempty"` - Pool *pool.Pool[*job.Job] `yaml:"pool,omitempty" json:"pool,omitempty"` - Auth *auth.Auth `yaml:"auth,omitempty" json:"auth,omitempty"` - Janitor *janitor.Janitor `yaml:"janitor,omitempty" json:"janitor,omitempty"` - HealthCheck *healthCheckConfig `yaml:"health_check,omitempty" json:"health_check,omitempty"` - Version string `yaml:"-" json:"-"` - agentName string - commandHandlers map[string]plugin.Handler + Server *server.Server `yaml:"server,omitempty" json:"server,omitempty"` + Commands command.Commands `yaml:"commands,omitempty" json:"commands,omitempty"` + Clusters cluster.Clusters `yaml:"clusters,omitempty" json:"clusters,omitempty"` + RBACs rbac.RBACs `yaml:"rbacs,omitempty" json:"rbacs,omitempty"` + JobsDirectory string `yaml:"jobs_directory,omitempty" json:"jobs_directory,omitempty"` + ArchiveDirectory string `yaml:"archive_directory,omitempty" json:"archive_directory,omitempty"` + ResultDirectory string `yaml:"result_directory,omitempty" json:"result_directory,omitempty"` + PluginsDirectory string `yaml:"plugin_directory,omitempty" json:"plugin_directory,omitempty"` + Database *database.Database `yaml:"database,omitempty" json:"database,omitempty"` + Pool *pool.Pool[*job.Job] `yaml:"pool,omitempty" json:"pool,omitempty"` + Auth *auth.Auth `yaml:"auth,omitempty" json:"auth,omitempty"` + Janitor *janitor.Janitor `yaml:"janitor,omitempty" json:"janitor,omitempty"` + HealthCheck *healthCheckConfig `yaml:"health_check,omitempty" json:"health_check,omitempty"` + SensitiveContextKeys []string `yaml:"sensitive_context_keys,omitempty" json:"sensitive_context_keys,omitempty"` + Version string `yaml:"-" json:"-"` + agentName string + commandHandlers map[string]plugin.Handler } func (h *Heimdall) Init() error { + heimdallContext.AddSensitiveKeys(h.SensitiveContextKeys) + // set jobs directory if not set if h.JobsDirectory == `` { h.JobsDirectory = defaultJobsDirectory diff --git a/pkg/context/context.go b/pkg/context/context.go index 10a20506..3d4c97f1 100644 --- a/pkg/context/context.go +++ b/pkg/context/context.go @@ -11,16 +11,9 @@ const redactedValue = `REDACTED` // process via JSON APIs. Matching is case-insensitive. Values are retained // in-memory and when persisting via String(). var sensitiveKeys = map[string]struct{}{ - `password`: {}, - `private_key`: {}, - `secret`: {}, - `token`: {}, - `api_key`: {}, - `access_key`: {}, - `secret_key`: {}, - `client_secret`: {}, - `secret_access_key`: {}, - `aws_secret_access_key`: {}, + `password`: {}, + `private_key`: {}, + `token`: {}, } type Context map[string]any @@ -57,6 +50,17 @@ func marshalRaw(v any) ([]byte, error) { } } +// AddSensitiveKeys registers additional context keys that should be redacted +// from JSON responses. Keys are matched case-insensitively. +func AddSensitiveKeys(keys []string) { + for _, key := range keys { + key = strings.ToLower(strings.TrimSpace(key)) + if key != `` { + sensitiveKeys[key] = struct{}{} + } + } +} + func (c *Context) UnmarshalYAML(unmarshal func(any) error) error { value := make(map[string]any) From 448183d59086407ca7b6ce57e4c112abfc10c33e Mon Sep 17 00:00:00 2001 From: Shashank Mittal Date: Fri, 25 Sep 2026 14:53:08 +0530 Subject: [PATCH 3/3] docs: move context redaction note to Security by Design Keep the API table focused and document redaction next to the other credential-handling guidance. --- README.md | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 550b3f8e..a500e322 100644 --- a/README.md +++ b/README.md @@ -150,18 +150,6 @@ Initially, Commands and Clusters are configured via a static config file (see [c * Health-based routing * API-based dynamic configuration -Context keys named `password`, `private_key`, or `token` are always redacted from JSON API -responses. Additional key names can be configured globally (matching is case-insensitive): - -```yaml -sensitive_context_keys: - - api_key - - client_secret -``` - -Redaction affects API output only. The original values remain available to plugins and database -persistence. - --- ## 🔁 Command & Cluster Matching Logic @@ -187,6 +175,8 @@ It centralizes execution logic, logging, and auditing—all accessible via API o Commands may also restrict invocation via `allowed_callers` — a list of anchored regex patterns matched against `X-Heimdall-User`. Omitted/empty means open; non-matching callers are rejected at submit. +Command, cluster, and job `context` keys `password`, `private_key`, and `token` are redacted in API responses. Extra names can be added via `sensitive_context_keys`. + --- ## 📦 API Overview