From ea29a164b860a5b578f0ad5f4c03e0da8c40fde6 Mon Sep 17 00:00:00 2001 From: Nate Hardison Date: Thu, 2 Jul 2026 14:55:19 -0700 Subject: [PATCH 1/2] feat(credentialsource): add process source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a host-command credential source: `type: process` runs a configured command with `sh -c` and serves its trimmed stdout as the credential value. Any helper that prints a credential works — OS keychain CLIs, pass, 1Password's op, or AWS credential_process helpers — without a dedicated Go backend per tool. The source implements RefreshingSource so the existing background refresh loop drives re-execution. When the output is AWS credential_process-format JSON, TTL derives from the embedded Expiration (clamped at zero so an expired credential refreshes immediately); other output refreshes on a fixed 5-minute interval. No safety margin is subtracted in the source: the refresh loop's 75%-of-TTL schedule provides it. Hardening: header-invalid control characters (RFC 7230) are stripped with a logged count (the value is never logged); stderr is included (truncated to 256 bytes) in fetch errors so helper failures like an expired SSO session are diagnosable, while stdout never appears in errors; empty output is an error; WaitDelay ensures a grandchild process holding the stdout pipe cannot block Fetch past context cancellation. Config: SourceConfig gains a `command` field; ResolveSource wires `type: process` and every existing source type rejects the new field as extraneous, matching the established validation pattern. Testing: 13 new tests (9 source, 4 config resolution) covering the happy path, STS expiry sniffing, expired credentials, default TTL fallback, sanitization, context cancellation, command failure with stderr surfacing, empty output, and config validation both ways. go test -race ./..., go vet ./..., and gofmt are clean. --- AGENTS.md | 3 +- CHANGELOG.md | 6 + README.md | 10 ++ config.go | 3 +- config_credential.go | 20 ++- config_credential_test.go | 38 +++++ credentialsource/process.go | 134 ++++++++++++++++++ credentialsource/process_test.go | 128 +++++++++++++++++ .../content/concepts/03-credential-sources.md | 3 +- .../getting-started/01-introduction.md | 5 +- .../reference/03-credential-sources.md | 52 ++++++- 11 files changed, 389 insertions(+), 13 deletions(-) create mode 100644 credentialsource/process.go create mode 100644 credentialsource/process_test.go 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..9f1059c 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, the credential refreshes on the embedded `Expiration` (at the standard 75%-of-TTL schedule); other output refreshes every 5 minutes. Header-invalid control characters are stripped from the output (logged as a count, never the value); 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 ([#NNN](https://github.com/majorcontext/gatekeeper/pull/NNN)) + ## 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..70d31be 100644 --- a/config.go +++ b/config.go @@ -77,9 +77,10 @@ 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` 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..93299e5 100644 --- a/config_credential.go +++ b/config_credential.go @@ -16,15 +16,23 @@ 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.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'; found extraneous fields") + } + return credentialsource.NewProcessSource(cfg.Command, 0), 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.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 +40,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.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 +51,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.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 +68,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.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 +95,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.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 != "" { diff --git a/config_credential_test.go b/config_credential_test.go index 0199c30..3fb50e7 100644 --- a/config_credential_test.go +++ b/config_credential_test.go @@ -611,3 +611,41 @@ 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 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..f3005cd --- /dev/null +++ b/credentialsource/process.go @@ -0,0 +1,134 @@ +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), s.Type())) + if val == "" { + return "", fmt.Errorf("credential command produced no output") + } + + s.mu.Lock() + s.expiresAt = sniffExpiration(val) + s.mu.Unlock() + + return val, nil +} + +// TTL implements RefreshingSource. When the last output carried an STS-style +// Expiration, TTL is the time until that expiry (clamped at 0 so an expired +// credential forces an immediate refresh). Otherwise the configured default +// interval applies. The refresh loop's safety margin (it refreshes at 75% of +// TTL) means no margin is subtracted here. +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]") + +// sanitizeHeaderValue strips header-invalid bytes from a credential value, +// logging a count (never the value) so users learn their helper is emitting +// garbage instead of debugging opaque 400s from the upstream API. +func sanitizeHeaderValue(raw, sourceType string) string { + cleaned := controlChars.ReplaceAllString(raw, "") + if len(cleaned) != len(raw) { + slog.Warn("credential output contained header-invalid control characters; stripped", + "source", sourceType, + "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). Returns the +// zero time for non-JSON output or JSON without a parseable Expiration — +// the caller falls back to the default interval. +func sniffExpiration(output string) time.Time { + var payload struct { + Expiration string `json:"Expiration"` + } + if err := json.Unmarshal([]byte(output), &payload); err != nil || payload.Expiration == "" { + return time.Time{} + } + exp, err := time.Parse(time.RFC3339, payload.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..027698e --- /dev/null +++ b/credentialsource/process_test.go @@ -0,0 +1,128 @@ +package credentialsource + +import ( + "context" + "fmt" + "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 TestProcessSourceTTLExpiredCredential(t *testing.T) { + exp := time.Now().Add(-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 != 0 { + t.Fatalf("TTL() = %v, want 0 for already-expired credential (forces immediate refresh)", ttl) + } +} + +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..4ab8cdb 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 5-minute interval. ## 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..df678cc 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 fixed interval) | | `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,53 @@ 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:** — + +### Behavior + +- **Expiry-aware refresh.** When stdout is AWS `credential_process`-format + JSON with an `Expiration` field (RFC 3339), the credential refreshes on + that expiry (at 75% of the remaining lifetime, like other refreshing + sources). The JSON is passed through as the credential value; consumers + that need the individual fields parse it themselves. Output without expiry + information refreshes every 5 minutes. +- **Sanitization.** Control characters that are invalid in HTTP header + values (RFC 7230) are stripped, with a warning logged (count only — the + value is never logged). +- **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.). From 2c8080964b0e5b2d8e7e4fcca6c756a303364c8b Mon Sep 17 00:00:00 2001 From: Nate Hardison Date: Thu, 2 Jul 2026 15:23:29 -0700 Subject: [PATCH 2/2] fix(credentialsource): harden process source from self-review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Findings from a pre-review pass on PR #38, all test-first: Validation: the token-exchange extraneous-field check lives in gatekeeper_tokenexchange.go and was missed when the other six source types learned to reject the new fields — a stray `command:` on `type: token-exchange` was silently ignored. Both new fields are now rejected there and the test table gained the companion rows. Expiry sniffing now requires the credential_process shape: exact-case Version, AccessKeyId, and Expiration keys. encoding/json matches field names case-insensitively, so any JSON output with an unrelated `expiration` field could silently hijack the refresh schedule. Already-expired output now fails the fetch (with the timestamp in the error) instead of installing credentials that would 401 every request for a full refresh interval; failing engages the refresh loop's backoff, which previously reset on every successful-but-stale fetch, re-running the helper every 30 seconds indefinitely. New `ttl` config field wires the previously unreachable defaultTTL constructor parameter, so helpers that are interactive or rate-limited can slow the default 5m schedule (and short-lived tokens can speed it up). Rejected as extraneous on every other source type. The stripped-control-characters warning now fires only for non-whitespace control bytes: trailing newlines and pretty-printed JSON are normal helper output and previously warned on every fetch. Docs: refresh cadence claims corrected (75% of TTL, not "every 5 minutes"), ResolveCredentialSource's type enumeration gained process, the changelog PR link placeholder is filled in, and the reference page documents ttl, the format check, and the expired-output error. Testing: 7 new tests (expired-is-error, near-expiry-serves, non-credential-JSON ignored, whitespace-no-warn, garbage-warns, ttl honored, invalid ttl rejected) plus companion validation rows; go test -race ./..., go vet, and gofmt are clean. --- CHANGELOG.md | 2 +- config.go | 1 + config_credential.go | 34 ++++++--- config_credential_test.go | 42 +++++++++++ credentialsource/process.go | 65 +++++++++++----- credentialsource/process_test.go | 74 ++++++++++++++++++- .../content/concepts/03-credential-sources.md | 2 +- .../reference/03-credential-sources.md | 35 +++++++-- gatekeeper_tokenexchange.go | 2 +- 9 files changed, 212 insertions(+), 45 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f1059c..df4c343 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ Gatekeeper is pre-1.0. The configuration schema and credential source interface ### 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, the credential refreshes on the embedded `Expiration` (at the standard 75%-of-TTL schedule); other output refreshes every 5 minutes. Header-invalid control characters are stripped from the output (logged as a count, never the value); 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 ([#NNN](https://github.com/majorcontext/gatekeeper/pull/NNN)) +- **`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 diff --git a/config.go b/config.go index 70d31be..726becf 100644 --- a/config.go +++ b/config.go @@ -81,6 +81,7 @@ type SourceConfig struct { 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 93299e5..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,7 +17,7 @@ func ResolveSource(cfg SourceConfig) (credentialsource.CredentialSource, error) if cfg.Var == "" { return nil, fmt.Errorf("env source requires 'var' field") } - if cfg.Command != "" || 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 @@ -25,14 +26,25 @@ func ResolveSource(cfg SourceConfig) (credentialsource.CredentialSource, error) 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'; found extraneous fields") + return nil, fmt.Errorf("process source only uses 'command' and 'ttl'; found extraneous fields") } - return credentialsource.NewProcessSource(cfg.Command, 0), nil + 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.Command != "" || 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 @@ -40,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.Command != "" || 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) @@ -51,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.Command != "" || 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) @@ -68,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.Command != "" || 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) @@ -95,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.Command != "" || 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 != "" { @@ -134,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 3fb50e7..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) { @@ -629,6 +633,44 @@ func TestResolveSourceProcess(t *testing.T) { } } +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 { diff --git a/credentialsource/process.go b/credentialsource/process.go index f3005cd..1a7e8e4 100644 --- a/credentialsource/process.go +++ b/credentialsource/process.go @@ -56,23 +56,31 @@ func (s *ProcessSource) Fetch(ctx context.Context) (string, error) { } return "", fmt.Errorf("running credential command: %w", err) } - val := strings.TrimSpace(sanitizeHeaderValue(string(out), s.Type())) + 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 = sniffExpiration(val) + s.expiresAt = expiresAt s.mu.Unlock() return val, nil } -// TTL implements RefreshingSource. When the last output carried an STS-style -// Expiration, TTL is the time until that expiry (clamped at 0 so an expired -// credential forces an immediate refresh). Otherwise the configured default -// interval applies. The refresh loop's safety margin (it refreshes at 75% of -// TTL) means no margin is subtracted here. +// 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() @@ -94,31 +102,48 @@ func (s *ProcessSource) TTL() time.Duration { // pretty-printed JSON collapses to a single line (still valid JSON). var controlChars = regexp.MustCompile("[\x00-\x08\x0a-\x1f\x7f]") -// sanitizeHeaderValue strips header-invalid bytes from a credential value, -// logging a count (never the value) so users learn their helper is emitting -// garbage instead of debugging opaque 400s from the upstream API. -func sanitizeHeaderValue(raw, sourceType string) string { +// 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 len(cleaned) != len(raw) { + if garbageChars.MatchString(raw) { slog.Warn("credential output contained header-invalid control characters; stripped", - "source", sourceType, + "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). Returns the -// zero time for non-JSON output or JSON without a parseable Expiration — -// the caller falls back to the default interval. +// 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 struct { - Expiration string `json:"Expiration"` + 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{} } - if err := json.Unmarshal([]byte(output), &payload); err != nil || payload.Expiration == "" { + var expiration string + if raw, ok := payload["Expiration"]; !ok || json.Unmarshal(raw, &expiration) != nil { return time.Time{} } - exp, err := time.Parse(time.RFC3339, payload.Expiration) + exp, err := time.Parse(time.RFC3339, expiration) if err != nil { return time.Time{} } diff --git a/credentialsource/process_test.go b/credentialsource/process_test.go index 027698e..bbbe83e 100644 --- a/credentialsource/process_test.go +++ b/credentialsource/process_test.go @@ -1,8 +1,10 @@ package credentialsource import ( + "bytes" "context" "fmt" + "log/slog" "strings" "testing" "time" @@ -33,15 +35,81 @@ func TestProcessSourceTTLFromSTSExpiration(t *testing.T) { } } -func TestProcessSourceTTLExpiredCredential(t *testing.T) { +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 ttl := src.TTL(); ttl != 0 { - t.Fatalf("TTL() = %v, want 0 for already-expired credential (forces immediate refresh)", ttl) + if !strings.Contains(logs.String(), "control characters") { + t.Fatalf("non-whitespace control bytes should warn; logs: %s", logs.String()) } } diff --git a/docs/content/concepts/03-credential-sources.md b/docs/content/concepts/03-credential-sources.md index 4ab8cdb..ddebbca 100644 --- a/docs/content/concepts/03-credential-sources.md +++ b/docs/content/concepts/03-credential-sources.md @@ -57,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 `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 5-minute interval. +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/reference/03-credential-sources.md b/docs/content/reference/03-credential-sources.md index df678cc..352d911 100644 --- a/docs/content/reference/03-credential-sources.md +++ b/docs/content/reference/03-credential-sources.md @@ -14,7 +14,7 @@ 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 fixed interval) | +| `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) | @@ -98,17 +98,36 @@ value. - **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 with an `Expiration` field (RFC 3339), the credential refreshes on - that expiry (at 75% of the remaining lifetime, like other refreshing - sources). The JSON is passed through as the credential value; consumers - that need the individual fields parse it themselves. Output without expiry - information refreshes every 5 minutes. + 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, with a warning logged (count only — the - value is never logged). + 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 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") }