diff --git a/AGENTS.md b/AGENTS.md index a0fe872..d2f6710 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,7 +8,7 @@ Gatekeeper is a standalone credential-injecting TLS-intercepting proxy. It trans Key capabilities: -- **Credential injection** — Resolve credentials from environment variables, static values, AWS Secrets Manager, GCP Secret Manager, GCP service account keys, GitHub App keys, or RFC 8693 token exchange, then inject them as HTTP headers for matching hosts +- **Credential injection** — Resolve credentials from environment variables, static values, host command output, AWS Secrets Manager, GCP Secret Manager, GCP service account keys, GitHub App keys, or RFC 8693 token exchange, then inject them as HTTP headers for matching hosts - **Postgres data plane** — Credential-injecting Postgres proxy on a second listener: routes by TLS SNI, authenticates clients with their run token (sent as the Postgres password inside gatekeeper's TLS), and resolves per-branch Neon passwords (or a static password) on the fly so no database secret lives in the sandbox - **TLS interception** — MITM proxy with per-host certificate generation from a configured CA - **MCP relay** — Forward Model Context Protocol requests with credential injection and SSE streaming @@ -39,6 +39,7 @@ credentialsource/ Pluggable credential backends source.go Source interface (CredentialSource, RefreshingSource) env.go Environment variable source static.go Literal value source + process.go Host command (credential_process-style) source awssecretsmanager.go AWS Secrets Manager source gcpsecretmanager.go GCP Secret Manager source gcpserviceaccount.go GCP service account OAuth2 token source diff --git a/CHANGELOG.md b/CHANGELOG.md index 2fb3944..df4c343 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ Gatekeeper is a standalone credential-injecting TLS-intercepting proxy. It trans Gatekeeper is pre-1.0. The configuration schema and credential source interface may change between minor versions. +## Unreleased + +### Added + +- **`process` credential source** — run a host command (`sh -c`) and use its stdout as the credential value; any helper that prints a credential works (OS keychain CLIs, `pass`, 1Password's `op`, AWS `credential_process` helpers). Implements `RefreshingSource`: when the output is AWS `credential_process`-format JSON (exact-case `Version`/`AccessKeyId`/`Expiration` keys, so unrelated JSON can't hijack the schedule), the credential refreshes on the embedded `Expiration`, and already-expired output fails the fetch (engaging retry backoff) instead of installing credentials that would 401; other output reports a configurable `ttl` (default 5m; gatekeeper re-fetches at the standard 75%-of-TTL schedule). Header-invalid control characters are stripped from the output, with a warning (count only, never the value) when non-whitespace control bytes were present; stderr is included (truncated) in fetch errors for diagnosability, stdout never; the command string is config-owned and must not be accepted from untrusted config by embedding operators ([#38](https://github.com/majorcontext/gatekeeper/pull/38)) + ## v0.14.1 — 2026-06-23 ### Fixed diff --git a/README.md b/README.md index ad1d2b6..37be4d1 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,16 @@ source: value: "Bearer sk-..." ``` +### Process + +Runs a host command and uses its stdout as the credential. If the output is AWS `credential_process`-format JSON with an `Expiration`, the credential refreshes automatically on that expiry. + +```yaml +source: + type: process + command: "op read op://vault/example/api-key" +``` + ### AWS Secrets Manager ```yaml diff --git a/config.go b/config.go index 51e907e..726becf 100644 --- a/config.go +++ b/config.go @@ -77,9 +77,11 @@ type PostgresCredentialConfig struct { // fields as delimited strings (like Scopes), never slices, and avoid // pointer fields, which would compile but silently break deduplication. type SourceConfig struct { - Type string `yaml:"type"` // "env", "static", "aws-secretsmanager", "gcp-secretmanager", "gcp-service-account", "github-app", "token-exchange" + Type string `yaml:"type"` // "env", "static", "process", "aws-secretsmanager", "gcp-secretmanager", "gcp-service-account", "github-app", "token-exchange" Var string `yaml:"var,omitempty"` // for env source Value string `yaml:"value,omitempty"` // for static source + Command string `yaml:"command,omitempty"` // for process source: host command run with `sh -c` + TTL string `yaml:"ttl,omitempty"` // for process source: refresh interval when output has no expiry (Go duration, default 5m) Secret string `yaml:"secret,omitempty"` // for aws-secretsmanager, gcp-secretmanager; for gcp-service-account, the secret holding the key JSON Region string `yaml:"region,omitempty"` // for aws-secretsmanager Project string `yaml:"project,omitempty"` // for gcp-secretmanager, gcp-service-account diff --git a/config_credential.go b/config_credential.go index 66dbe63..a368463 100644 --- a/config_credential.go +++ b/config_credential.go @@ -3,6 +3,7 @@ package gatekeeper import ( "fmt" "os" + "time" "github.com/majorcontext/gatekeeper/credentialsource" "github.com/majorcontext/gatekeeper/proxy" @@ -16,15 +17,34 @@ func ResolveSource(cfg SourceConfig) (credentialsource.CredentialSource, error) if cfg.Var == "" { return nil, fmt.Errorf("env source requires 'var' field") } - if cfg.Value != "" || cfg.Secret != "" || cfg.Region != "" || cfg.Project != "" || cfg.Version != "" || cfg.AppID != "" || cfg.InstallationID != "" || cfg.PrivateKeyPath != "" || cfg.PrivateKeyEnv != "" || cfg.Scopes != "" || cfg.Endpoint != "" || cfg.ClientID != "" || cfg.ClientSecret != "" || cfg.ClientSecretEnv != "" || cfg.SubjectHeader != "" || cfg.SubjectFrom != "" || cfg.SubjectTokenType != "" || cfg.Resource != "" || cfg.ActorTokenFrom != "" || cfg.ActorTokenType != "" { + if cfg.Command != "" || cfg.TTL != "" || cfg.Value != "" || cfg.Secret != "" || cfg.Region != "" || cfg.Project != "" || cfg.Version != "" || cfg.AppID != "" || cfg.InstallationID != "" || cfg.PrivateKeyPath != "" || cfg.PrivateKeyEnv != "" || cfg.Scopes != "" || cfg.Endpoint != "" || cfg.ClientID != "" || cfg.ClientSecret != "" || cfg.ClientSecretEnv != "" || cfg.SubjectHeader != "" || cfg.SubjectFrom != "" || cfg.SubjectTokenType != "" || cfg.Resource != "" || cfg.ActorTokenFrom != "" || cfg.ActorTokenType != "" { return nil, fmt.Errorf("env source only uses 'var'; found extraneous fields") } return credentialsource.NewEnvSource(cfg.Var), nil + case "process": + if cfg.Command == "" { + return nil, fmt.Errorf("process source requires 'command' field") + } + if cfg.Var != "" || cfg.Value != "" || cfg.Secret != "" || cfg.Region != "" || cfg.Project != "" || cfg.Version != "" || cfg.AppID != "" || cfg.InstallationID != "" || cfg.PrivateKeyPath != "" || cfg.PrivateKeyEnv != "" || cfg.Scopes != "" || cfg.Endpoint != "" || cfg.ClientID != "" || cfg.ClientSecret != "" || cfg.ClientSecretEnv != "" || cfg.SubjectHeader != "" || cfg.SubjectFrom != "" || cfg.SubjectTokenType != "" || cfg.Resource != "" || cfg.ActorTokenFrom != "" || cfg.ActorTokenType != "" { + return nil, fmt.Errorf("process source only uses 'command' and 'ttl'; found extraneous fields") + } + var ttl time.Duration + if cfg.TTL != "" { + d, err := time.ParseDuration(cfg.TTL) + if err != nil { + return nil, fmt.Errorf("process source: invalid 'ttl' %q: %w", cfg.TTL, err) + } + if d <= 0 { + return nil, fmt.Errorf("process source: 'ttl' must be positive, got %q", cfg.TTL) + } + ttl = d + } + return credentialsource.NewProcessSource(cfg.Command, ttl), nil case "static": if cfg.Value == "" { return nil, fmt.Errorf("static source requires 'value' field") } - if cfg.Var != "" || cfg.Secret != "" || cfg.Region != "" || cfg.Project != "" || cfg.Version != "" || cfg.AppID != "" || cfg.InstallationID != "" || cfg.PrivateKeyPath != "" || cfg.PrivateKeyEnv != "" || cfg.Scopes != "" || cfg.Endpoint != "" || cfg.ClientID != "" || cfg.ClientSecret != "" || cfg.ClientSecretEnv != "" || cfg.SubjectHeader != "" || cfg.SubjectFrom != "" || cfg.SubjectTokenType != "" || cfg.Resource != "" || cfg.ActorTokenFrom != "" || cfg.ActorTokenType != "" { + if cfg.Var != "" || cfg.Command != "" || cfg.TTL != "" || cfg.Secret != "" || cfg.Region != "" || cfg.Project != "" || cfg.Version != "" || cfg.AppID != "" || cfg.InstallationID != "" || cfg.PrivateKeyPath != "" || cfg.PrivateKeyEnv != "" || cfg.Scopes != "" || cfg.Endpoint != "" || cfg.ClientID != "" || cfg.ClientSecret != "" || cfg.ClientSecretEnv != "" || cfg.SubjectHeader != "" || cfg.SubjectFrom != "" || cfg.SubjectTokenType != "" || cfg.Resource != "" || cfg.ActorTokenFrom != "" || cfg.ActorTokenType != "" { return nil, fmt.Errorf("static source only uses 'value'; found extraneous fields") } return credentialsource.NewStaticSource(cfg.Value), nil @@ -32,7 +52,7 @@ func ResolveSource(cfg SourceConfig) (credentialsource.CredentialSource, error) if cfg.Secret == "" { return nil, fmt.Errorf("aws-secretsmanager source requires 'secret' field") } - if cfg.Var != "" || cfg.Value != "" || cfg.Project != "" || cfg.Version != "" || cfg.AppID != "" || cfg.InstallationID != "" || cfg.PrivateKeyPath != "" || cfg.PrivateKeyEnv != "" || cfg.Scopes != "" || cfg.Endpoint != "" || cfg.ClientID != "" || cfg.ClientSecret != "" || cfg.ClientSecretEnv != "" || cfg.SubjectHeader != "" || cfg.SubjectFrom != "" || cfg.SubjectTokenType != "" || cfg.Resource != "" || cfg.ActorTokenFrom != "" || cfg.ActorTokenType != "" { + if cfg.Var != "" || cfg.Value != "" || cfg.Command != "" || cfg.TTL != "" || cfg.Project != "" || cfg.Version != "" || cfg.AppID != "" || cfg.InstallationID != "" || cfg.PrivateKeyPath != "" || cfg.PrivateKeyEnv != "" || cfg.Scopes != "" || cfg.Endpoint != "" || cfg.ClientID != "" || cfg.ClientSecret != "" || cfg.ClientSecretEnv != "" || cfg.SubjectHeader != "" || cfg.SubjectFrom != "" || cfg.SubjectTokenType != "" || cfg.Resource != "" || cfg.ActorTokenFrom != "" || cfg.ActorTokenType != "" { return nil, fmt.Errorf("aws-secretsmanager source only uses 'secret' and 'region'; found extraneous fields") } return credentialsource.NewAWSSecretsManagerSource(cfg.Secret, cfg.Region) @@ -43,7 +63,7 @@ func ResolveSource(cfg SourceConfig) (credentialsource.CredentialSource, error) if cfg.Project == "" { return nil, fmt.Errorf("gcp-secretmanager source requires 'project' field") } - if cfg.Var != "" || cfg.Value != "" || cfg.Region != "" || cfg.AppID != "" || cfg.InstallationID != "" || cfg.PrivateKeyPath != "" || cfg.PrivateKeyEnv != "" || cfg.Scopes != "" || cfg.Endpoint != "" || cfg.ClientID != "" || cfg.ClientSecret != "" || cfg.ClientSecretEnv != "" || cfg.SubjectHeader != "" || cfg.SubjectFrom != "" || cfg.SubjectTokenType != "" || cfg.Resource != "" || cfg.ActorTokenFrom != "" || cfg.ActorTokenType != "" { + if cfg.Var != "" || cfg.Value != "" || cfg.Command != "" || cfg.TTL != "" || cfg.Region != "" || cfg.AppID != "" || cfg.InstallationID != "" || cfg.PrivateKeyPath != "" || cfg.PrivateKeyEnv != "" || cfg.Scopes != "" || cfg.Endpoint != "" || cfg.ClientID != "" || cfg.ClientSecret != "" || cfg.ClientSecretEnv != "" || cfg.SubjectHeader != "" || cfg.SubjectFrom != "" || cfg.SubjectTokenType != "" || cfg.Resource != "" || cfg.ActorTokenFrom != "" || cfg.ActorTokenType != "" { return nil, fmt.Errorf("gcp-secretmanager source only uses 'secret', 'project', and 'version'; found extraneous fields") } return credentialsource.NewGCPSecretManagerSource(cfg.Project, cfg.Secret, cfg.Version) @@ -60,7 +80,7 @@ func ResolveSource(cfg SourceConfig) (credentialsource.CredentialSource, error) if cfg.PrivateKeyPath != "" && cfg.PrivateKeyEnv != "" { return nil, fmt.Errorf("github-app source: set 'private_key_path' or 'private_key_env', not both") } - if cfg.Var != "" || cfg.Value != "" || cfg.Secret != "" || cfg.Region != "" || cfg.Project != "" || cfg.Version != "" || cfg.Scopes != "" || cfg.Endpoint != "" || cfg.ClientID != "" || cfg.ClientSecret != "" || cfg.ClientSecretEnv != "" || cfg.SubjectHeader != "" || cfg.SubjectFrom != "" || cfg.SubjectTokenType != "" || cfg.Resource != "" || cfg.ActorTokenFrom != "" || cfg.ActorTokenType != "" { + if cfg.Var != "" || cfg.Value != "" || cfg.Command != "" || cfg.TTL != "" || cfg.Secret != "" || cfg.Region != "" || cfg.Project != "" || cfg.Version != "" || cfg.Scopes != "" || cfg.Endpoint != "" || cfg.ClientID != "" || cfg.ClientSecret != "" || cfg.ClientSecretEnv != "" || cfg.SubjectHeader != "" || cfg.SubjectFrom != "" || cfg.SubjectTokenType != "" || cfg.Resource != "" || cfg.ActorTokenFrom != "" || cfg.ActorTokenType != "" { return nil, fmt.Errorf("github-app source only uses 'app_id', 'installation_id', and one of 'private_key_path'/'private_key_env'; found extraneous fields") } keyPEM, err := readKeyMaterial(cfg.PrivateKeyPath, cfg.PrivateKeyEnv) @@ -87,7 +107,7 @@ func ResolveSource(cfg SourceConfig) (credentialsource.CredentialSource, error) if cfg.Secret == "" && (cfg.Project != "" || cfg.Version != "") { return nil, fmt.Errorf("gcp-service-account source only uses 'project' and 'version' with 'secret'") } - if cfg.Var != "" || cfg.Value != "" || cfg.Region != "" || cfg.AppID != "" || cfg.InstallationID != "" || cfg.Endpoint != "" || cfg.ClientID != "" || cfg.ClientSecret != "" || cfg.ClientSecretEnv != "" || cfg.SubjectHeader != "" || cfg.SubjectFrom != "" || cfg.SubjectTokenType != "" || cfg.Resource != "" || cfg.ActorTokenFrom != "" || cfg.ActorTokenType != "" { + if cfg.Var != "" || cfg.Value != "" || cfg.Command != "" || cfg.TTL != "" || cfg.Region != "" || cfg.AppID != "" || cfg.InstallationID != "" || cfg.Endpoint != "" || cfg.ClientID != "" || cfg.ClientSecret != "" || cfg.ClientSecretEnv != "" || cfg.SubjectHeader != "" || cfg.SubjectFrom != "" || cfg.SubjectTokenType != "" || cfg.Resource != "" || cfg.ActorTokenFrom != "" || cfg.ActorTokenType != "" { return nil, fmt.Errorf("gcp-service-account source only uses a key location ('private_key_path', 'private_key_env', or 'secret'/'project'/'version') and 'scopes'; found extraneous fields") } if cfg.Secret != "" { @@ -126,9 +146,9 @@ func readKeyMaterial(path, env string) ([]byte, error) { // ResolveCredentialSource creates either a static CredentialSource or a dynamic // CredentialResolver from a credential config. For static sources (env, static, -// aws-secretsmanager, gcp-secretmanager, gcp-service-account, github-app), the -// first return is non-nil. For dynamic sources (token-exchange), the second -// return is non-nil. +// process, aws-secretsmanager, gcp-secretmanager, gcp-service-account, +// github-app), the first return is non-nil. For dynamic sources +// (token-exchange), the second return is non-nil. func ResolveCredentialSource(cred CredentialConfig) (credentialsource.CredentialSource, proxy.CredentialResolver, error) { switch cred.Source.Type { case "token-exchange": diff --git a/config_credential_test.go b/config_credential_test.go index 0199c30..78d6dcb 100644 --- a/config_credential_test.go +++ b/config_credential_test.go @@ -9,7 +9,9 @@ import ( "encoding/pem" "os" "path/filepath" + "strings" "testing" + "time" ) func TestResolveSourceEnv(t *testing.T) { @@ -598,6 +600,8 @@ func TestResolveSourceTokenExchangeExtraneousFields(t *testing.T) { {"with installation_id", SourceConfig{Type: "token-exchange", Endpoint: "http://x", ClientID: "gk", ClientSecretEnv: "TEST_TE_SECRET2", SubjectHeader: "X-S", InstallationID: "extra"}}, {"with private_key_path", SourceConfig{Type: "token-exchange", Endpoint: "http://x", ClientID: "gk", ClientSecretEnv: "TEST_TE_SECRET2", SubjectHeader: "X-S", PrivateKeyPath: "extra"}}, {"with private_key_env", SourceConfig{Type: "token-exchange", Endpoint: "http://x", ClientID: "gk", ClientSecretEnv: "TEST_TE_SECRET2", SubjectHeader: "X-S", PrivateKeyEnv: "extra"}}, + {"with command", SourceConfig{Type: "token-exchange", Endpoint: "http://x", ClientID: "gk", ClientSecretEnv: "TEST_TE_SECRET2", SubjectHeader: "X-S", Command: "extra"}}, + {"with ttl", SourceConfig{Type: "token-exchange", Endpoint: "http://x", ClientID: "gk", ClientSecretEnv: "TEST_TE_SECRET2", SubjectHeader: "X-S", TTL: "90s"}}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -611,3 +615,79 @@ func TestResolveSourceTokenExchangeExtraneousFields(t *testing.T) { }) } } + +func TestResolveSourceProcess(t *testing.T) { + src, err := ResolveSource(SourceConfig{Type: "process", Command: "printf 'proc-token'"}) + if err != nil { + t.Fatalf("ResolveSource() error: %v", err) + } + if src.Type() != "process" { + t.Fatalf("Type() = %q, want %q", src.Type(), "process") + } + val, err := src.Fetch(context.Background()) + if err != nil { + t.Fatalf("Fetch() error: %v", err) + } + if val != "proc-token" { + t.Fatalf("Fetch() = %q, want %q", val, "proc-token") + } +} + +func TestResolveSourceProcessTTL(t *testing.T) { + src, err := ResolveSource(SourceConfig{Type: "process", Command: "printf 'proc-token'", TTL: "90s"}) + if err != nil { + t.Fatalf("ResolveSource() error: %v", err) + } + if _, err := src.Fetch(context.Background()); err != nil { + t.Fatalf("Fetch() error: %v", err) + } + rs, ok := src.(interface{ TTL() time.Duration }) + if !ok { + t.Fatal("process source should implement RefreshingSource") + } + if got := rs.TTL(); got != 90*time.Second { + t.Fatalf("TTL() = %v, want configured 90s", got) + } +} + +func TestResolveSourceProcessInvalidTTL(t *testing.T) { + for _, ttl := range []string{"banana", "-5s", "0s"} { + t.Run(ttl, func(t *testing.T) { + _, err := ResolveSource(SourceConfig{Type: "process", Command: "printf x", TTL: ttl}) + if err == nil { + t.Fatalf("expected error for ttl %q, got nil", ttl) + } + if !strings.Contains(err.Error(), "ttl") { + t.Fatalf("error should mention ttl, got: %v", err) + } + }) + } +} + +func TestResolveSourceEnvRejectsTTLField(t *testing.T) { + _, err := ResolveSource(SourceConfig{Type: "env", Var: "SOME_VAR", TTL: "90s"}) + if err == nil { + t.Fatal("expected error: env source must reject the process-only ttl field") + } +} + +func TestResolveSourceProcessMissingCommand(t *testing.T) { + _, err := ResolveSource(SourceConfig{Type: "process"}) + if err == nil { + t.Fatal("expected error for missing command field, got nil") + } +} + +func TestResolveSourceProcessExtraneousField(t *testing.T) { + _, err := ResolveSource(SourceConfig{Type: "process", Command: "printf x", Var: "SOME_VAR"}) + if err == nil { + t.Fatal("expected error for extraneous field on process source, got nil") + } +} + +func TestResolveSourceEnvRejectsCommandField(t *testing.T) { + _, err := ResolveSource(SourceConfig{Type: "env", Var: "SOME_VAR", Command: "printf x"}) + if err == nil { + t.Fatal("expected error: env source must reject the process-only command field") + } +} diff --git a/credentialsource/process.go b/credentialsource/process.go new file mode 100644 index 0000000..1a7e8e4 --- /dev/null +++ b/credentialsource/process.go @@ -0,0 +1,159 @@ +package credentialsource + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log/slog" + "os/exec" + "regexp" + "strings" + "sync" + "time" +) + +// DefaultProcessTTL is the refresh interval reported when the command +// output carries no expiry information and no default was configured. +const DefaultProcessTTL = 5 * time.Minute + +// ProcessSource runs a host command and serves its trimmed stdout as the +// credential value. It is the universal "bring your own backend" source: +// any helper that prints a credential (keychain CLIs, `pass`, 1Password, +// corp credential processes) can back a grant without a dedicated Go +// implementation. It implements both CredentialSource and RefreshingSource. +type ProcessSource struct { + command string + defaultTTL time.Duration + + mu sync.Mutex + expiresAt time.Time +} + +// NewProcessSource creates a credential source that runs command with +// `sh -c` on every fetch. defaultTTL is the refresh interval to report +// when the command output carries no expiry information. +func NewProcessSource(command string, defaultTTL time.Duration) *ProcessSource { + return &ProcessSource{command: command, defaultTTL: defaultTTL} +} + +func (s *ProcessSource) Type() string { return "process" } + +func (s *ProcessSource) Fetch(ctx context.Context) (string, error) { + cmd := exec.CommandContext(ctx, "sh", "-c", s.command) + // Without WaitDelay, Output() keeps waiting for the stdout pipe even + // after ctx cancellation kills sh, because a grandchild process may + // still hold the pipe open. + cmd.WaitDelay = 3 * time.Second + out, err := cmd.Output() + if err != nil { + // Include stderr so helper failures (expired SSO session, missing + // profile) are diagnosable from logs. Never include stdout: it may + // hold a credential. + var exitErr *exec.ExitError + if errors.As(err, &exitErr) && len(exitErr.Stderr) > 0 { + return "", fmt.Errorf("running credential command: %w: %s", err, truncate(string(exitErr.Stderr), 256)) + } + return "", fmt.Errorf("running credential command: %w", err) + } + val := strings.TrimSpace(sanitizeHeaderValue(string(out))) + if val == "" { + return "", fmt.Errorf("credential command produced no output") + } + + expiresAt := sniffExpiration(val) + // Failing the fetch (rather than installing expired credentials that + // 401 every request) engages the refresh loop's backoff. + if !expiresAt.IsZero() && time.Now().After(expiresAt) { + return "", fmt.Errorf("credential command returned already-expired credentials (Expiration %s)", expiresAt.Format(time.RFC3339)) + } + + s.mu.Lock() + s.expiresAt = expiresAt + s.mu.Unlock() + + return val, nil +} + +// TTL implements RefreshingSource. When the last output carried a +// credential_process Expiration, TTL is the time until that expiry (clamped +// at 0; note the refresh loop floors its wait at 30 seconds, so a +// nearly-expired credential refreshes on that floor, not instantly). +// Otherwise the configured default interval applies. No safety margin is +// subtracted here — the refresh loop refreshes at 75% of TTL. +func (s *ProcessSource) TTL() time.Duration { + s.mu.Lock() + defer s.mu.Unlock() + if s.expiresAt.IsZero() { + if s.defaultTTL > 0 { + return s.defaultTTL + } + return DefaultProcessTTL + } + ttl := time.Until(s.expiresAt) + if ttl < 0 { + return 0 + } + return ttl +} + +// controlChars matches bytes that are invalid in HTTP header values per +// RFC 7230: C0 controls except HTAB, plus DEL. Newlines are included, so +// pretty-printed JSON collapses to a single line (still valid JSON). +var controlChars = regexp.MustCompile("[\x00-\x08\x0a-\x1f\x7f]") + +// garbageChars is the stripped set minus whitespace (LF, VT, FF, CR). +// Trailing newlines and pretty-printed JSON are normal helper output; only +// non-whitespace control bytes indicate the helper is emitting garbage. +var garbageChars = regexp.MustCompile("[\x00-\x08\x0e-\x1f\x7f]") + +// sanitizeHeaderValue strips header-invalid bytes from a credential value. +// It warns (with a count, never the value) only when non-whitespace control +// bytes were present, so users learn their helper is emitting garbage +// instead of debugging opaque failures from the upstream API. +func sanitizeHeaderValue(raw string) string { + cleaned := controlChars.ReplaceAllString(raw, "") + if garbageChars.MatchString(raw) { + slog.Warn("credential output contained header-invalid control characters; stripped", + "source", "process", + "stripped_bytes", len(raw)-len(cleaned)) + } + return cleaned +} + +// sniffExpiration extracts the Expiration timestamp when the output is +// credential_process-format JSON (RFC 3339, per the AWS spec). The format +// check requires the spec's exact-case Version, AccessKeyId, and Expiration +// keys — encoding/json alone matches field names case-insensitively, which +// would let any JSON with an unrelated "expiration" field hijack the +// refresh schedule. Returns the zero time for anything else — the caller +// falls back to the default interval. +func sniffExpiration(output string) time.Time { + var payload map[string]json.RawMessage + if err := json.Unmarshal([]byte(output), &payload); err != nil { + return time.Time{} + } + if _, ok := payload["Version"]; !ok { + return time.Time{} + } + if _, ok := payload["AccessKeyId"]; !ok { + return time.Time{} + } + var expiration string + if raw, ok := payload["Expiration"]; !ok || json.Unmarshal(raw, &expiration) != nil { + return time.Time{} + } + exp, err := time.Parse(time.RFC3339, expiration) + if err != nil { + return time.Time{} + } + return exp +} + +func truncate(s string, n int) string { + s = strings.TrimSpace(s) + if len(s) <= n { + return s + } + return s[:n] + "…" +} diff --git a/credentialsource/process_test.go b/credentialsource/process_test.go new file mode 100644 index 0000000..bbbe83e --- /dev/null +++ b/credentialsource/process_test.go @@ -0,0 +1,196 @@ +package credentialsource + +import ( + "bytes" + "context" + "fmt" + "log/slog" + "strings" + "testing" + "time" +) + +// stsOutput builds credential_process-format JSON with the given expiration. +func stsOutput(exp time.Time) string { + return fmt.Sprintf( + `{"Version": 1, "AccessKeyId": "AKIAEXAMPLE", "SecretAccessKey": "sk", "SessionToken": "st", "Expiration": %q}`, + exp.UTC().Format(time.RFC3339)) +} + +func TestProcessSourceTTLFromSTSExpiration(t *testing.T) { + exp := time.Now().Add(15 * time.Minute) + src := NewProcessSource("printf '%s' '"+stsOutput(exp)+"'", 0) + + val, err := src.Fetch(context.Background()) + if err != nil { + t.Fatalf("Fetch() error: %v", err) + } + if !strings.Contains(val, "AKIAEXAMPLE") { + t.Fatalf("Fetch() should return the raw JSON for endpoint consumers, got %q", val) + } + + ttl := src.TTL() + if ttl <= 14*time.Minute || ttl > 15*time.Minute { + t.Fatalf("TTL() = %v, want ~15m derived from Expiration", ttl) + } +} + +func TestProcessSourceExpiredCredentialIsError(t *testing.T) { + // Installing known-expired credentials would 401 every request for a + // full refresh interval; failing the fetch engages the refresh loop's + // backoff instead. + exp := time.Now().Add(-time.Minute) + src := NewProcessSource("printf '%s' '"+stsOutput(exp)+"'", 0) + + _, err := src.Fetch(context.Background()) + if err == nil { + t.Fatal("expected error for already-expired credentials, got nil") + } + if !strings.Contains(err.Error(), "expired") { + t.Fatalf("error should say the credentials are expired, got: %v", err) + } +} + +func TestProcessSourceNearExpiryStillServes(t *testing.T) { + exp := time.Now().Add(2 * time.Minute) + src := NewProcessSource("printf '%s' '"+stsOutput(exp)+"'", 0) + + if _, err := src.Fetch(context.Background()); err != nil { + t.Fatalf("Fetch() error: %v", err) + } + if ttl := src.TTL(); ttl <= time.Minute || ttl > 2*time.Minute { + t.Fatalf("TTL() = %v, want ~2m for near-expiry credential", ttl) + } +} + +func TestProcessSourceIgnoresNonCredentialProcessJSON(t *testing.T) { + // encoding/json matches field names case-insensitively, so without a + // format check any JSON containing an unrelated expiration field would + // hijack the refresh schedule. + src := NewProcessSource(`printf '{"token": "abc", "expiration": "2020-01-01T00:00:00Z"}'`, 0) + + if _, err := src.Fetch(context.Background()); err != nil { + t.Fatalf("Fetch() error: %v", err) + } + if ttl := src.TTL(); ttl != DefaultProcessTTL { + t.Fatalf("TTL() = %v, want default %v: expiry sniffing must require credential_process shape", ttl, DefaultProcessTTL) + } +} + +// captureLogs redirects slog's default logger to a buffer for the test. +func captureLogs(t *testing.T) *bytes.Buffer { + t.Helper() + var buf bytes.Buffer + prev := slog.Default() + slog.SetDefault(slog.New(slog.NewTextHandler(&buf, nil))) + t.Cleanup(func() { slog.SetDefault(prev) }) + return &buf +} + +func TestProcessSourceNoWarningForWhitespace(t *testing.T) { + // Virtually every CLI ends stdout with \n, and pretty-printed JSON has + // interior newlines; neither means the helper is emitting garbage. + logs := captureLogs(t) + src := NewProcessSource(`printf '{\n "Version": 1,\n "AccessKeyId": "A",\n "SecretAccessKey": "s"\n}\n'`, 0) + + if _, err := src.Fetch(context.Background()); err != nil { + t.Fatalf("Fetch() error: %v", err) + } + if strings.Contains(logs.String(), "control characters") { + t.Fatalf("whitespace-only stripping must not warn; logs: %s", logs.String()) + } +} + +func TestProcessSourceWarnsOnGarbageControlChars(t *testing.T) { + logs := captureLogs(t) + src := NewProcessSource(`printf 'tok\aen'`, 0) + + if _, err := src.Fetch(context.Background()); err != nil { + t.Fatalf("Fetch() error: %v", err) + } + if !strings.Contains(logs.String(), "control characters") { + t.Fatalf("non-whitespace control bytes should warn; logs: %s", logs.String()) + } +} + +func TestProcessSourceTTLDefaultWithoutExpiration(t *testing.T) { + src := NewProcessSource("printf 'plain-token'", 90*time.Second) + if _, err := src.Fetch(context.Background()); err != nil { + t.Fatalf("Fetch() error: %v", err) + } + if ttl := src.TTL(); ttl != 90*time.Second { + t.Fatalf("TTL() = %v, want configured default 90s for output without expiry", ttl) + } +} + +func TestProcessSourceTTLZeroDefault(t *testing.T) { + src := NewProcessSource("printf 'plain-token'", 0) + if _, err := src.Fetch(context.Background()); err != nil { + t.Fatalf("Fetch() error: %v", err) + } + if ttl := src.TTL(); ttl != 5*time.Minute { + t.Fatalf("TTL() = %v, want package default 5m when no default configured", ttl) + } +} + +func TestProcessSourceStripsControlCharacters(t *testing.T) { + // A BEL byte inside the token would be an invalid header value + // (RFC 7230) and break injection downstream. + src := NewProcessSource(`printf 'tok\aen'`, 0) + val, err := src.Fetch(context.Background()) + if err != nil { + t.Fatalf("Fetch() error: %v", err) + } + if val != "token" { + t.Fatalf("Fetch() = %q, want %q with control characters stripped", val, "token") + } +} + +func TestProcessSourceContextCancellation(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel() + + start := time.Now() + src := NewProcessSource("sleep 10; printf tok", 0) + _, err := src.Fetch(ctx) + if err == nil { + t.Fatal("expected error when context expires, got nil") + } + if elapsed := time.Since(start); elapsed > 5*time.Second { + t.Fatalf("Fetch() took %v, should abort promptly on context expiry", elapsed) + } +} + +func TestProcessSourceCommandFails(t *testing.T) { + src := NewProcessSource("echo 'sso session expired' >&2; exit 1", 0) + _, err := src.Fetch(context.Background()) + if err == nil { + t.Fatal("expected error for failing command, got nil") + } + if !strings.Contains(err.Error(), "sso session expired") { + t.Fatalf("error should include command stderr for diagnosis, got: %v", err) + } +} + +func TestProcessSourceEmptyOutput(t *testing.T) { + src := NewProcessSource("true", 0) + _, err := src.Fetch(context.Background()) + if err == nil { + t.Fatal("expected error for empty command output, got nil") + } +} + +func TestProcessSource(t *testing.T) { + src := NewProcessSource("printf 'tok-123\\n'", 0) + if src.Type() != "process" { + t.Fatalf("Type() = %q, want %q", src.Type(), "process") + } + + val, err := src.Fetch(context.Background()) + if err != nil { + t.Fatalf("Fetch() error: %v", err) + } + if val != "tok-123" { + t.Fatalf("Fetch() = %q, want %q (trailing newline should be trimmed)", val, "tok-123") + } +} diff --git a/docs/content/concepts/03-credential-sources.md b/docs/content/concepts/03-credential-sources.md index 558d48b..ddebbca 100644 --- a/docs/content/concepts/03-credential-sources.md +++ b/docs/content/concepts/03-credential-sources.md @@ -34,6 +34,7 @@ type CredentialSource interface { | Source | Config | Behavior | |---|---|---| +| `process` | `command: "op read op://vault/item/field"` | Runs a host command, uses its stdout | | `aws-secretsmanager` | `secret: my-secret`, `region: us-east-1` | Fetches from AWS Secrets Manager | | `gcp-secretmanager` | `secret: my-secret`, `project: my-project` | Fetches from GCP Secret Manager | | `gcp-service-account` | key location, `scopes` | Mints GCP OAuth2 access tokens from a service account key | @@ -56,7 +57,7 @@ type RefreshingSource interface { - **Failure backoff.** On fetch failure, gatekeeper retries with exponential backoff starting at 1 second, doubling each attempt, capped at 60 seconds. A random jitter (up to 25% of the backoff) is added to prevent thundering herds. - **Hot-swap.** Refreshed credentials are applied to the proxy immediately via `SetCredentialWithGrant`. In-flight requests use the previous value; subsequent requests use the new one. -The `github-app` and `gcp-service-account` sources are `RefreshingSource`s. Both produce tokens that expire after one hour, so gatekeeper refreshes them every 45 minutes. +The `github-app` and `gcp-service-account` sources are `RefreshingSource`s. Both produce tokens that expire after one hour, so gatekeeper refreshes them every 45 minutes. The `process` source is also a `RefreshingSource`: when its command output is AWS `credential_process`-format JSON, TTL is the time until the embedded `Expiration`; otherwise it reports a fixed interval (`ttl` in the source config, default 5 minutes). ## Source Deduplication diff --git a/docs/content/getting-started/01-introduction.md b/docs/content/getting-started/01-introduction.md index c355da0..b411bd0 100644 --- a/docs/content/getting-started/01-introduction.md +++ b/docs/content/getting-started/01-introduction.md @@ -10,9 +10,9 @@ Gatekeeper is a standalone credential-injecting TLS-intercepting proxy. It sits ## Key capabilities -- **Credential injection** — Resolve credentials from environment variables, static values, AWS Secrets Manager, GCP Secret Manager, or GitHub App tokens, then inject them as HTTP headers for matching hosts. +- **Credential injection** — Resolve credentials from environment variables, static values, host command output, AWS Secrets Manager, GCP Secret Manager, or GitHub App tokens, then inject them as HTTP headers for matching hosts. - **TLS interception** — Man-in-the-middle proxy with per-host certificate generation from a configured CA. The proxy terminates TLS, reads plaintext requests, injects credentials, and forwards to the real server. -- **Multiple credential sources** — Pluggable backend system. Environment variables and static values for development. AWS Secrets Manager, GCP Secret Manager, and GitHub App tokens for production. RFC 8693 token exchange for multi-user deployments. +- **Multiple credential sources** — Pluggable backend system. Environment variables, static values, and host commands (`process`) for development and local helpers. AWS Secrets Manager, GCP Secret Manager, and GitHub App tokens for production. RFC 8693 token exchange for multi-user deployments. - **Network policy** — Allow or deny traffic by host pattern. `permissive` mode allows all traffic. `strict` mode denies all traffic except explicitly allowed hosts. - **MCP relay** — Forward Model Context Protocol requests to upstream servers with credential injection and SSE streaming. - **Observability** — OpenTelemetry traces, metrics, and logs. Canonical log lines per request. Configured entirely via standard `OTEL_*` environment variables. @@ -35,6 +35,7 @@ The client must trust the proxy's CA certificate. Generate one with the included |---|---|---| | Environment variable | `env` | Local development, CI | | Static value | `static` | Fixed API keys | +| Process | `process` | Host command output (keychain CLIs, `op`, `credential_process` helpers); auto-refresh | | AWS Secrets Manager | `aws-secretsmanager` | AWS-hosted credentials | | GCP Secret Manager | `gcp-secretmanager` | GCP-hosted credentials | | GCP service account | `gcp-service-account` | Short-lived GCP access tokens with auto-refresh | diff --git a/docs/content/reference/03-credential-sources.md b/docs/content/reference/03-credential-sources.md index b60b59f..352d911 100644 --- a/docs/content/reference/03-credential-sources.md +++ b/docs/content/reference/03-credential-sources.md @@ -1,6 +1,6 @@ --- title: "Source types" -description: "Reference for all credential source types including env, static, AWS Secrets Manager, GCP Secret Manager, GCP service accounts, GitHub App, and token exchange." +description: "Reference for all credential source types including env, static, process, AWS Secrets Manager, GCP Secret Manager, GCP service accounts, GitHub App, and token exchange." keywords: ["gatekeeper", "credential sources", "source types", "configuration reference"] --- @@ -14,13 +14,14 @@ Each credential entry in gatekeeper.yaml includes a `source` block that determin |------|-------------|---------| | `env` | Read from an environment variable | No | | `static` | Literal inline value | No | +| `process` | Run a host command, use its stdout | Yes (auto-refresh; expiry read from `credential_process`-format JSON, else configurable `ttl`) | | `aws-secretsmanager` | Fetch from AWS Secrets Manager | No | | `gcp-secretmanager` | Fetch from GCP Secret Manager | No | | `gcp-service-account` | Mint GCP OAuth2 access token from a service account key | Yes (auto-refresh before expiry) | | `github-app` | Generate GitHub App installation token | Yes (auto-refresh before expiry) | | `token-exchange` | RFC 8693 token exchange | Yes (per-request, cached with TTL) | -Sources marked **Refresh: Yes** have credentials that expire. `github-app` and `gcp-service-account` implement background credential refresh — gatekeeper re-fetches at 75% of TTL (minimum 30 seconds) and hot-swaps without downtime. `token-exchange` uses per-request lazy caching: on cache miss, gatekeeper calls the STS and caches the result for the token's TTL. +Sources marked **Refresh: Yes** have credentials that expire. `process`, `github-app`, and `gcp-service-account` implement background credential refresh — gatekeeper re-fetches at 75% of TTL (minimum 30 seconds) and hot-swaps without downtime. `token-exchange` uses per-request lazy caching: on cache miss, gatekeeper calls the STS and caches the result for the token's TTL. --- @@ -73,6 +74,72 @@ The credential value. --- +## process + +Run a host command and use its stdout as the credential value. Any helper +that prints a credential works: OS keychain CLIs, `pass`, 1Password's `op`, +or an AWS `credential_process` helper. + +```yaml +credentials: + - host: api.example.com + header: x-api-key + source: + type: process + command: "op read op://vault/example/api-key" +``` + +### command + +Shell command to run with `sh -c`. Stdout (trimmed) becomes the credential +value. + +- **Type:** `string` +- **Required:** Yes +- **Default:** — + +### ttl + +Refresh interval when the command output carries no expiry information, as +a Go duration (`90s`, `30m`, `12h`). Must be positive. + +- **Type:** `string` +- **Required:** No +- **Default:** `5m` + +Like all refreshing sources, gatekeeper re-fetches at 75% of TTL (floor 30 +seconds), so the default re-runs the command every 3m45s. Set a longer +`ttl` for helpers that are expensive or interactive (biometric prompts, +rate limits) and serve values that rarely change. + +### Behavior + +- **Expiry-aware refresh.** When stdout is AWS `credential_process`-format + JSON — recognized by its exact-case `Version`, `AccessKeyId`, and + `Expiration` (RFC 3339) keys — the credential refreshes on that expiry, + and `ttl` is ignored. The JSON is passed through as the credential value; + consumers that need the individual fields parse it themselves. Other JSON + output is treated as an opaque credential and refreshes on `ttl`. +- **Expired output is an error.** If the reported `Expiration` is already + in the past, the fetch fails (with the timestamp in the error) instead of + installing credentials that would be rejected upstream; the standard + retry backoff applies. +- **Sanitization.** Control characters that are invalid in HTTP header + values (RFC 7230) are stripped. A warning is logged (count only — the + value is never logged) when non-whitespace control bytes were present; + trailing newlines and pretty-printed JSON are normal and do not warn. +- **Failures.** A non-zero exit fails the fetch; stderr is included in the + error (truncated) so helper failures such as an expired SSO session are + diagnosable. Empty output is an error. Failed refreshes retry with the + standard backoff. + +> **Security:** The command runs on the host with gatekeeper's privileges. +> Only configure commands you trust, from config files you own. Embedding +> operators (like moat) must not accept this field from repository-controlled +> config. + +--- + ## aws-secretsmanager Fetch the credential from AWS Secrets Manager at startup. Uses the AWS SDK default credential chain (`AWS_ACCESS_KEY_ID`/`AWS_SECRET_ACCESS_KEY`, IAM roles, etc.). diff --git a/gatekeeper_tokenexchange.go b/gatekeeper_tokenexchange.go index 251191b..013023e 100644 --- a/gatekeeper_tokenexchange.go +++ b/gatekeeper_tokenexchange.go @@ -133,7 +133,7 @@ func resolveTokenExchange(cred CredentialConfig) (proxy.CredentialResolver, erro return nil, fmt.Errorf("token-exchange source: set 'client_secret' or 'client_secret_env', not both") } // Reject extraneous fields from other source types - if cfg.Var != "" || cfg.Value != "" || cfg.Secret != "" || cfg.Region != "" || cfg.Project != "" || cfg.Version != "" || cfg.AppID != "" || cfg.InstallationID != "" || cfg.PrivateKeyPath != "" || cfg.PrivateKeyEnv != "" || cfg.Scopes != "" { + if cfg.Var != "" || cfg.Value != "" || cfg.Command != "" || cfg.TTL != "" || cfg.Secret != "" || cfg.Region != "" || cfg.Project != "" || cfg.Version != "" || cfg.AppID != "" || cfg.InstallationID != "" || cfg.PrivateKeyPath != "" || cfg.PrivateKeyEnv != "" || cfg.Scopes != "" { return nil, fmt.Errorf("token-exchange source only uses 'endpoint', 'client_id', 'client_secret'/'client_secret_env', 'subject_header'/'subject_from', 'actor_token_from', 'actor_token_type', 'subject_token_type', and 'resource'; found extraneous fields") }