From bd24482180899824b6a19e70370f1256c206726d Mon Sep 17 00:00:00 2001 From: Husni Adil Makmur Date: Sun, 2 Aug 2026 13:10:59 +0700 Subject: [PATCH 1/7] fix: preserve JSON value types when building environment variables MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Secrets whose payload is JSON were rendered with fmt.Sprintf("%v"), which is Go's debug formatting rather than JSON. The value a process received was then not the value that was stored: 1754110382 -> "1.754110382e+09" ["a","b"] -> "[a b]" {"token":"secret"} -> "map[token:secret]" The first case is the damaging one. JSON has no integer type, so every number decodes as float64, and %v prints large float64 values in scientific notation. Any large integer in a secret — a Unix timestamp, an account id, a port — arrived corrupted, with no error to indicate it. Nothing surfaced this because the conversion cannot fail: %v accepts any value and always produces a string, so a type it handles badly still looks like a successful fetch. The failure appears later, in the process that receives the value. DecodeSecretJSON keeps numbers as json.Number so their original text survives, and StringifyValue re-encodes arrays and objects as JSON so the receiving process can parse them back. Scalars keep their literal form. Four providers decode JSON payloads and are affected: aws_secretsmanager, gcloud_secretmanager, azure_keyvault, and bitwarden in 'note' format. 1password, infisical and bitwarden_sm build string-only maps from their SDKs, so %v was already a no-op there; they move to the shared helper so the behaviour cannot drift apart later. Payloads that are not JSON still fall back to being treated as a single value, including the case of trailing content after a JSON object, which json.Unmarshal rejected and a bare json.Decoder would not. CONFIGURATION.md gains a Value Types table under Key Mappings, since the conversion applies to every provider that parses JSON rather than to one. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Hmh2p2Bg6kmxxvzpFDW2WL --- CONFIGURATION.md | 18 +++ internal/provider/aws/secretsmanager.go | 6 +- .../provider/azurekeyvault/azurekeyvault.go | 6 +- internal/provider/bitwarden/bitwarden.go | 6 +- internal/provider/bitwarden/bitwarden_sm.go | 2 +- internal/provider/gcsm/gcsm.go | 6 +- internal/provider/infisical/infisical.go | 2 +- internal/provider/onepassword/onepassword.go | 2 +- internal/provider/value.go | 63 ++++++++++ internal/provider/value_test.go | 110 ++++++++++++++++++ tests/end2end/json_value_types_test.go | 88 ++++++++++++++ tests/end2end/testhelpers.go | 13 ++- 12 files changed, 306 insertions(+), 16 deletions(-) create mode 100644 internal/provider/value.go create mode 100644 internal/provider/value_test.go create mode 100644 tests/end2end/json_value_types_test.go diff --git a/CONFIGURATION.md b/CONFIGURATION.md index af48f3b..39ce841 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -737,6 +737,24 @@ keys: - Use `==` to keep the source key name as the target name - Keys are case-sensitive +### Value Types + +Environment variables are always strings, so values from a JSON secret are +converted: + +| JSON value | Environment variable | +|---|---| +| `"token"` | `token` | +| `1754110382` | `1754110382` | +| `1.5` | `1.5` | +| `true` | `true` | +| `["read","write"]` | `["read","write"]` | +| `{"token":"secret"}` | `{"token":"secret"}` | +| `null` | (empty string) | + +Numbers keep the digits they were written with. Arrays and objects are passed +through as JSON, so the receiving process can parse them back. + ## Environment Inheritance By default, sstart inherits all system environment variables and adds secrets on top. To create a clean environment with only secrets (no system environment variables), set `inherit: false`: diff --git a/internal/provider/aws/secretsmanager.go b/internal/provider/aws/secretsmanager.go index 915f622..a9d69a1 100644 --- a/internal/provider/aws/secretsmanager.go +++ b/internal/provider/aws/secretsmanager.go @@ -88,8 +88,8 @@ func (p *SecretsManagerProvider) Fetch(secretContext provider.SecretContext, map } // Parse the secret value (assuming JSON format) - var secretData map[string]interface{} - if err := json.Unmarshal([]byte(*result.SecretString), &secretData); err != nil { + secretData, err := provider.DecodeSecretJSON([]byte(*result.SecretString)) + if err != nil { // If not JSON, treat as a single value secretKey := strings.ToUpper(strings.ReplaceAll(mapID, "-", "_")) + "_SECRET" log.Printf("WARN: Secret from provider '%s' is not JSON format. Secret loaded to %s", mapID, secretKey) @@ -118,7 +118,7 @@ func (p *SecretsManagerProvider) Fetch(secretContext provider.SecretContext, map continue } - value := fmt.Sprintf("%v", v) + value := provider.StringifyValue(v) kvs = append(kvs, provider.KeyValue{ Key: targetKey, Value: value, diff --git a/internal/provider/azurekeyvault/azurekeyvault.go b/internal/provider/azurekeyvault/azurekeyvault.go index b086483..6e1f421 100644 --- a/internal/provider/azurekeyvault/azurekeyvault.go +++ b/internal/provider/azurekeyvault/azurekeyvault.go @@ -88,8 +88,8 @@ func (p *AzureKeyVaultProvider) Fetch(secretContext provider.SecretContext, mapI } // Try to parse as JSON first - var secretData map[string]interface{} - if err := json.Unmarshal([]byte(secretValue), &secretData); err != nil { + secretData, err := provider.DecodeSecretJSON([]byte(secretValue)) + if err != nil { // If not JSON, treat as a single value secretKey := strings.ToUpper(strings.ReplaceAll(mapID, "-", "_")) + "_SECRET" log.Printf("WARN: Secret from provider '%s' is not JSON format. Secret loaded to %s", mapID, secretKey) @@ -118,7 +118,7 @@ func (p *AzureKeyVaultProvider) Fetch(secretContext provider.SecretContext, mapI continue } - value := fmt.Sprintf("%v", v) + value := provider.StringifyValue(v) kvs = append(kvs, provider.KeyValue{ Key: targetKey, Value: value, diff --git a/internal/provider/bitwarden/bitwarden.go b/internal/provider/bitwarden/bitwarden.go index fa133c7..fc3b642 100644 --- a/internal/provider/bitwarden/bitwarden.go +++ b/internal/provider/bitwarden/bitwarden.go @@ -195,9 +195,11 @@ func (p *BitwardenProvider) Fetch(secretContext provider.SecretContext, mapID st if item.Notes == "" { return nil, fmt.Errorf("bitwarden item '%s' has no notes for 'note' format", cfg.ItemID) } - if err := json.Unmarshal([]byte(item.Notes), &secretData); err != nil { + parsedNotes, err := provider.DecodeSecretJSON([]byte(item.Notes)) + if err != nil { return nil, fmt.Errorf("failed to parse notes as JSON for bitwarden item '%s': %w", cfg.ItemID, err) } + secretData = parsedNotes case "login": // Extract login credentials if item.Login == nil { @@ -301,7 +303,7 @@ func (p *BitwardenProvider) Fetch(secretContext provider.SecretContext, mapID st continue } - value := fmt.Sprintf("%v", v) + value := provider.StringifyValue(v) kvs = append(kvs, provider.KeyValue{ Key: targetKey, Value: value, diff --git a/internal/provider/bitwarden/bitwarden_sm.go b/internal/provider/bitwarden/bitwarden_sm.go index abb842b..3baac24 100644 --- a/internal/provider/bitwarden/bitwarden_sm.go +++ b/internal/provider/bitwarden/bitwarden_sm.go @@ -140,7 +140,7 @@ func (p *BitwardenSMProvider) Fetch(secretContext provider.SecretContext, mapID continue } - value := fmt.Sprintf("%v", v) + value := provider.StringifyValue(v) kvs = append(kvs, provider.KeyValue{ Key: targetKey, Value: value, diff --git a/internal/provider/gcsm/gcsm.go b/internal/provider/gcsm/gcsm.go index aea5047..70ed6a1 100644 --- a/internal/provider/gcsm/gcsm.go +++ b/internal/provider/gcsm/gcsm.go @@ -84,9 +84,9 @@ func (p *GCSMProvider) Fetch(secretContext provider.SecretContext, mapID string, } // Parse the secret value (assuming JSON format) - secretData := make(map[string]interface{}) secretString := string(result.Payload.Data) - if err := json.Unmarshal([]byte(secretString), &secretData); err != nil { + secretData, err := provider.DecodeSecretJSON([]byte(secretString)) + if err != nil { // If not JSON, treat as a single value secretKey := strings.ToUpper(strings.ReplaceAll(mapID, "-", "_")) + "_SECRET" log.Printf("WARN: Secret from provider '%s' is not JSON format. Secret loaded to %s", mapID, secretKey) @@ -115,7 +115,7 @@ func (p *GCSMProvider) Fetch(secretContext provider.SecretContext, mapID string, continue } - value := fmt.Sprintf("%v", v) + value := provider.StringifyValue(v) kvs = append(kvs, provider.KeyValue{ Key: targetKey, Value: value, diff --git a/internal/provider/infisical/infisical.go b/internal/provider/infisical/infisical.go index b40db5b..9c59d9e 100644 --- a/internal/provider/infisical/infisical.go +++ b/internal/provider/infisical/infisical.go @@ -126,7 +126,7 @@ func (p *InfisicalProvider) Fetch(secretContext provider.SecretContext, mapID st continue } - value := fmt.Sprintf("%v", v) + value := provider.StringifyValue(v) kvs = append(kvs, provider.KeyValue{ Key: targetKey, Value: value, diff --git a/internal/provider/onepassword/onepassword.go b/internal/provider/onepassword/onepassword.go index 6fc139d..c9912bc 100644 --- a/internal/provider/onepassword/onepassword.go +++ b/internal/provider/onepassword/onepassword.go @@ -399,7 +399,7 @@ func mapSecretKeys(secretData map[string]interface{}, keys map[string]string) [] continue } - value := fmt.Sprintf("%v", v) + value := provider.StringifyValue(v) kvs = append(kvs, provider.KeyValue{ Key: targetKey, Value: value, diff --git a/internal/provider/value.go b/internal/provider/value.go new file mode 100644 index 0000000..09f1a8d --- /dev/null +++ b/internal/provider/value.go @@ -0,0 +1,63 @@ +package provider + +import ( + "bytes" + "encoding/json" + "fmt" + "strconv" +) + +// DecodeSecretJSON parses a secret payload into a generic map, keeping numbers +// as json.Number so that their original text survives the round trip. +// +// Decoding into interface{} the usual way turns every JSON number into a +// float64, and large integers such as Unix timestamps then stringify as +// scientific notation. +func DecodeSecretJSON(data []byte) (map[string]interface{}, error) { + dec := json.NewDecoder(bytes.NewReader(data)) + dec.UseNumber() + + var parsed map[string]interface{} + if err := dec.Decode(&parsed); err != nil { + return nil, err + } + + // json.Unmarshal rejects trailing content; Decoder does not. Keep the + // stricter behaviour so a payload that is only partly JSON still falls + // back to being treated as a plain value. + if dec.More() { + return nil, fmt.Errorf("unexpected trailing content after JSON value") + } + + return parsed, nil +} + +// StringifyValue renders a decoded JSON value as the string that goes into an +// environment variable. +// +// Scalars keep their literal JSON form. Arrays and objects are re-encoded as +// JSON so the value stays parseable by the receiving process; Go's default +// formatting would emit map[k:v] instead. +func StringifyValue(v interface{}) string { + switch value := v.(type) { + case nil: + return "" + case string: + return value + case json.Number: + return value.String() + case bool: + return strconv.FormatBool(value) + case float64: + return strconv.FormatFloat(value, 'f', -1, 64) + case int: + return strconv.Itoa(value) + case int64: + return strconv.FormatInt(value, 10) + default: + if encoded, err := json.Marshal(value); err == nil { + return string(encoded) + } + return fmt.Sprintf("%v", value) + } +} diff --git a/internal/provider/value_test.go b/internal/provider/value_test.go new file mode 100644 index 0000000..dcfd647 --- /dev/null +++ b/internal/provider/value_test.go @@ -0,0 +1,110 @@ +package provider + +import ( + "encoding/json" + "testing" +) + +func TestStringifyValue(t *testing.T) { + tests := []struct { + name string + value interface{} + want string + }{ + {"string", "plain", "plain"}, + {"empty string", "", ""}, + {"nil", nil, ""}, + {"bool true", true, "true"}, + {"bool false", false, "false"}, + {"json number int", json.Number("42"), "42"}, + {"json number float", json.Number("1.5"), "1.5"}, + {"float64", float64(1.5), "1.5"}, + {"int", 7, "7"}, + {"int64", int64(7), "7"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := StringifyValue(tt.value); got != tt.want { + t.Errorf("StringifyValue(%v) = %q, want %q", tt.value, got, tt.want) + } + }) + } +} + +// A Unix timestamp is the case that motivated this helper: decoded as float64 +// and rendered with %v it becomes "1.754110382e+09". +func TestStringifyValue_LargeIntegerKeepsItsDigits(t *testing.T) { + parsed, err := DecodeSecretJSON([]byte(`{"expiresAt":1754110382}`)) + if err != nil { + t.Fatalf("DecodeSecretJSON() error = %v", err) + } + + if got := StringifyValue(parsed["expiresAt"]); got != "1754110382" { + t.Errorf("expiresAt = %q, want %q", got, "1754110382") + } +} + +func TestStringifyValue_ContainersStayParseableJSON(t *testing.T) { + parsed, err := DecodeSecretJSON([]byte(`{"list":["a","b"],"nested":{"token":"sk-secret"}}`)) + if err != nil { + t.Fatalf("DecodeSecretJSON() error = %v", err) + } + + tests := []struct { + key string + want string + }{ + {"list", `["a","b"]`}, + {"nested", `{"token":"sk-secret"}`}, + } + + for _, tt := range tests { + t.Run(tt.key, func(t *testing.T) { + got := StringifyValue(parsed[tt.key]) + if got != tt.want { + t.Errorf("%s = %q, want %q", tt.key, got, tt.want) + } + // The point of re-encoding is that the receiver can parse it back. + var back interface{} + if err := json.Unmarshal([]byte(got), &back); err != nil { + t.Errorf("%s is not valid JSON: %v", tt.key, err) + } + }) + } +} + +func TestDecodeSecretJSON(t *testing.T) { + tests := []struct { + name string + payload string + wantErr bool + }{ + {"object", `{"a":"b"}`, false}, + {"empty object", `{}`, false}, + {"plain string is not an object", `hunter2`, true}, + {"array is not an object", `["a"]`, true}, + {"truncated", `{"a":`, true}, + {"trailing content", `{"a":"b"} extra`, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := DecodeSecretJSON([]byte(tt.payload)) + if (err != nil) != tt.wantErr { + t.Errorf("DecodeSecretJSON(%q) error = %v, wantErr %v", tt.payload, err, tt.wantErr) + } + }) + } +} + +func TestDecodeSecretJSON_NumbersAreNotFloats(t *testing.T) { + parsed, err := DecodeSecretJSON([]byte(`{"n":1754110382}`)) + if err != nil { + t.Fatalf("DecodeSecretJSON() error = %v", err) + } + + if _, ok := parsed["n"].(json.Number); !ok { + t.Errorf("n decoded as %T, want json.Number", parsed["n"]) + } +} diff --git a/tests/end2end/json_value_types_test.go b/tests/end2end/json_value_types_test.go new file mode 100644 index 0000000..4f8eadd --- /dev/null +++ b/tests/end2end/json_value_types_test.go @@ -0,0 +1,88 @@ +package end2end + +import ( + "context" + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/dirathea/sstart/internal/config" + _ "github.com/dirathea/sstart/internal/provider/aws" + "github.com/dirathea/sstart/internal/secrets" +) + +// TestE2E_JSONValueTypes covers secret payloads whose values are not strings. +// +// Rendering decoded JSON with %v turned large integers into scientific +// notation and containers into Go map syntax, so the value that reached the +// child process was not the value that was stored. +func TestE2E_JSONValueTypes(t *testing.T) { + ctx := context.Background() + + localstack := SetupLocalStack(ctx, t) + defer func() { + if err := localstack.Cleanup(); err != nil { + t.Errorf("Failed to terminate localstack container: %v", err) + } + }() + + secretName := "test/myapp/mixed-types" + payload := `{ + "API_KEY": "plain-string", + "EXPIRES_AT": 1754110382, + "PORT": 5432, + "RATIO": 1.5, + "ENABLED": true, + "SCOPES": ["read", "write"], + "NESTED": {"token": "sk-secret"} + }` + SetupAWSSecretRaw(ctx, t, localstack, secretName, payload) + + tmpDir := t.TempDir() + configFile := filepath.Join(tmpDir, ".sstart.yml") + + configYAML := fmt.Sprintf(` +providers: + - kind: aws_secretsmanager + id: aws-types + secret_id: %s + region: us-east-1 + endpoint: %s +`, secretName, localstack.Endpoint) + + if err := os.WriteFile(configFile, []byte(configYAML), 0644); err != nil { + t.Fatalf("Failed to write config file: %v", err) + } + + cfg, err := config.Load(configFile) + if err != nil { + t.Fatalf("Failed to load config: %v", err) + } + + collected, err := secrets.NewCollector(cfg).Collect(ctx, nil) + if err != nil { + t.Fatalf("Failed to collect secrets: %v", err) + } + + expected := map[string]string{ + "API_KEY": "plain-string", + "EXPIRES_AT": "1754110382", + "PORT": "5432", + "RATIO": "1.5", + "ENABLED": "true", + "SCOPES": `["read","write"]`, + "NESTED": `{"token":"sk-secret"}`, + } + + for key, want := range expected { + got, exists := collected[key] + if !exists { + t.Errorf("Expected secret '%s' not found", key) + continue + } + if got != want { + t.Errorf("Secret '%s': expected '%s', got '%s'", key, want, got) + } + } +} diff --git a/tests/end2end/testhelpers.go b/tests/end2end/testhelpers.go index 319ab78..43c3e3f 100644 --- a/tests/end2end/testhelpers.go +++ b/tests/end2end/testhelpers.go @@ -151,12 +151,21 @@ func SetupAllContainers(ctx context.Context, t *testing.T) (*LocalStackContainer func SetupAWSSecret(ctx context.Context, t *testing.T, localstack *LocalStackContainer, secretName string, secretData map[string]string) { t.Helper() - awsRegion := "us-east-1" secretJSON, err := json.Marshal(secretData) if err != nil { t.Fatalf("Failed to marshal secret data: %v", err) } + SetupAWSSecretRaw(ctx, t, localstack, secretName, string(secretJSON)) +} + +// SetupAWSSecretRaw stores a secret payload verbatim, for cases where the +// payload is not a flat map of strings. +func SetupAWSSecretRaw(ctx context.Context, t *testing.T, localstack *LocalStackContainer, secretName string, payload string) { + t.Helper() + + awsRegion := "us-east-1" + awsCfg, err := awsconfig.LoadDefaultConfig(ctx, awsconfig.WithRegion(awsRegion), awsconfig.WithCredentialsProvider(credentials.NewStaticCredentialsProvider("test", "test", "")), @@ -171,7 +180,7 @@ func SetupAWSSecret(ctx context.Context, t *testing.T, localstack *LocalStackCon _, err = secretsManagerClient.CreateSecret(ctx, &secretsmanager.CreateSecretInput{ Name: aws.String(secretName), - SecretString: aws.String(string(secretJSON)), + SecretString: aws.String(payload), }) if err != nil { t.Fatalf("Failed to create secret in AWS Secrets Manager: %v", err) From bb4c96acc2a4b54a237c672ae5cb6b963a96236a Mon Sep 17 00:00:00 2001 From: Husni Adil Makmur Date: Sun, 2 Aug 2026 13:37:54 +0700 Subject: [PATCH 2/7] refactor: decode secret payloads into an untyped value A JSON pointer can address an array element or a bare scalar, neither of which fits map[string]interface{}. DecodeSecretJSON keeps its object-only contract and is now expressed in terms of the general decoder. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Hmh2p2Bg6kmxxvzpFDW2WL --- internal/provider/value.go | 33 +++++++++++++++++++----- internal/provider/value_test.go | 45 +++++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 7 deletions(-) diff --git a/internal/provider/value.go b/internal/provider/value.go index 09f1a8d..2166827 100644 --- a/internal/provider/value.go +++ b/internal/provider/value.go @@ -7,17 +7,16 @@ import ( "strconv" ) -// DecodeSecretJSON parses a secret payload into a generic map, keeping numbers -// as json.Number so that their original text survives the round trip. +// DecodeSecretJSONValue parses a secret payload into a generic value, keeping +// numbers as json.Number so that their original text survives the round trip. // -// Decoding into interface{} the usual way turns every JSON number into a -// float64, and large integers such as Unix timestamps then stringify as -// scientific notation. -func DecodeSecretJSON(data []byte) (map[string]interface{}, error) { +// Unlike DecodeSecretJSON it accepts any JSON document, not only an object, +// because a JSON pointer may address an array element or a bare scalar. +func DecodeSecretJSONValue(data []byte) (interface{}, error) { dec := json.NewDecoder(bytes.NewReader(data)) dec.UseNumber() - var parsed map[string]interface{} + var parsed interface{} if err := dec.Decode(&parsed); err != nil { return nil, err } @@ -32,6 +31,26 @@ func DecodeSecretJSON(data []byte) (map[string]interface{}, error) { return parsed, nil } +// DecodeSecretJSON parses a secret payload into a generic map, keeping numbers +// as json.Number so that their original text survives the round trip. +// +// Decoding into interface{} the usual way turns every JSON number into a +// float64, and large integers such as Unix timestamps then stringify as +// scientific notation. +func DecodeSecretJSON(data []byte) (map[string]interface{}, error) { + parsed, err := DecodeSecretJSONValue(data) + if err != nil { + return nil, err + } + + object, ok := parsed.(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("secret payload is not a JSON object") + } + + return object, nil +} + // StringifyValue renders a decoded JSON value as the string that goes into an // environment variable. // diff --git a/internal/provider/value_test.go b/internal/provider/value_test.go index dcfd647..136d806 100644 --- a/internal/provider/value_test.go +++ b/internal/provider/value_test.go @@ -2,6 +2,7 @@ package provider import ( "encoding/json" + "reflect" "testing" ) @@ -108,3 +109,47 @@ func TestDecodeSecretJSON_NumbersAreNotFloats(t *testing.T) { t.Errorf("n decoded as %T, want json.Number", parsed["n"]) } } + +func TestDecodeSecretJSONValue(t *testing.T) { + tests := []struct { + name string + payload string + want interface{} + wantErr bool + }{ + {"object", `{"a":"b"}`, map[string]interface{}{"a": "b"}, false}, + {"array", `["a","b"]`, []interface{}{"a", "b"}, false}, + {"bare string", `"hello"`, "hello", false}, + {"bare bool", `true`, true, false}, + {"not json", `hunter2`, nil, true}, + {"trailing content", `{"a":"b"} extra`, nil, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := DecodeSecretJSONValue([]byte(tt.payload)) + if (err != nil) != tt.wantErr { + t.Fatalf("DecodeSecretJSONValue(%q) error = %v, wantErr %v", tt.payload, err, tt.wantErr) + } + if tt.wantErr { + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("DecodeSecretJSONValue(%q) = %#v, want %#v", tt.payload, got, tt.want) + } + }) + } +} + +func TestDecodeSecretJSONValue_NumbersKeepTheirText(t *testing.T) { + got, err := DecodeSecretJSONValue([]byte(`1754110382`)) + if err != nil { + t.Fatalf("DecodeSecretJSONValue() error = %v", err) + } + if _, ok := got.(json.Number); !ok { + t.Fatalf("decoded as %T, want json.Number", got) + } + if StringifyValue(got) != "1754110382" { + t.Errorf("StringifyValue = %q, want %q", StringifyValue(got), "1754110382") + } +} From 5db3336dea518b70b9dcdc787933a5241e8b6b64 Mon Sep 17 00:00:00 2001 From: Husni Adil Makmur Date: Sun, 2 Aug 2026 13:38:39 +0700 Subject: [PATCH 3/7] feat: resolve RFC 6901 JSON pointers against secret payloads Credential blobs nest, and their keys contain dots, pipes and colons, so a dotted path would need an escaping rule invented here. RFC 6901 already defines one, and gives array indexing for free. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Hmh2p2Bg6kmxxvzpFDW2WL --- internal/provider/jsonpointer.go | 55 ++++++++++++ internal/provider/jsonpointer_test.go | 119 ++++++++++++++++++++++++++ 2 files changed, 174 insertions(+) create mode 100644 internal/provider/jsonpointer.go create mode 100644 internal/provider/jsonpointer_test.go diff --git a/internal/provider/jsonpointer.go b/internal/provider/jsonpointer.go new file mode 100644 index 0000000..9c7ad1c --- /dev/null +++ b/internal/provider/jsonpointer.go @@ -0,0 +1,55 @@ +package provider + +import ( + "fmt" + "strconv" + "strings" +) + +// ResolvePointer resolves an RFC 6901 JSON pointer against a decoded JSON +// document. +// +// A pointer is used rather than a dotted path because real key names contain +// dots, pipes and colons; RFC 6901 already defines how to escape the two +// characters that are special to it. +func ResolvePointer(document interface{}, pointer string) (interface{}, error) { + if pointer == "" { + return document, nil + } + + if !strings.HasPrefix(pointer, "/") { + return nil, fmt.Errorf("JSON pointer %q must start with '/'", pointer) + } + + current := document + // A leading "/" produces an empty first element, which is not a token. + for _, token := range strings.Split(pointer, "/")[1:] { + // ~1 must be decoded before ~0, otherwise "~01" would become "/". + token = strings.ReplaceAll(token, "~1", "/") + token = strings.ReplaceAll(token, "~0", "~") + + switch container := current.(type) { + case map[string]interface{}: + value, exists := container[token] + if !exists { + return nil, fmt.Errorf("JSON pointer %q: no key %q", pointer, token) + } + current = value + + case []interface{}: + index, err := strconv.Atoi(token) + if err != nil { + return nil, fmt.Errorf("JSON pointer %q: %q is not an array index", pointer, token) + } + if index < 0 || index >= len(container) { + return nil, fmt.Errorf("JSON pointer %q: index %d is out of range, length is %d", pointer, index, len(container)) + } + current = container[index] + + default: + return nil, fmt.Errorf("JSON pointer %q: cannot descend into %T at %q", pointer, current, token) + } + } + + return current, nil +} diff --git a/internal/provider/jsonpointer_test.go b/internal/provider/jsonpointer_test.go new file mode 100644 index 0000000..2e3ef87 --- /dev/null +++ b/internal/provider/jsonpointer_test.go @@ -0,0 +1,119 @@ +package provider + +import ( + "encoding/json" + "testing" +) + +func TestResolvePointer(t *testing.T) { + document, err := DecodeSecretJSONValue([]byte(`{ + "claudeAiOauth": {"accessToken": "sk-secret", "expiresAt": 1754110382, "scopes": ["read", "write"]}, + "mcpOAuth": {"plugin:engineering:github|1eea5f27": {"accessToken": "gh-token"}}, + "a/b": "slash", + "m~n": "tilde", + "": "empty key" + }`)) + if err != nil { + t.Fatalf("DecodeSecretJSONValue() error = %v", err) + } + + tests := []struct { + name string + pointer string + want string + }{ + {"nested scalar", "/claudeAiOauth/accessToken", "sk-secret"}, + {"number keeps digits", "/claudeAiOauth/expiresAt", "1754110382"}, + {"array index", "/claudeAiOauth/scopes/0", "read"}, + {"array index last", "/claudeAiOauth/scopes/1", "write"}, + {"key with pipe and colon", "/mcpOAuth/plugin:engineering:github|1eea5f27/accessToken", "gh-token"}, + {"escaped slash", "/a~1b", "slash"}, + {"escaped tilde", "/m~0n", "tilde"}, + {"empty key", "/", "empty key"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ResolvePointer(document, tt.pointer) + if err != nil { + t.Fatalf("ResolvePointer(%q) error = %v", tt.pointer, err) + } + if StringifyValue(got) != tt.want { + t.Errorf("ResolvePointer(%q) = %q, want %q", tt.pointer, StringifyValue(got), tt.want) + } + }) + } +} + +func TestResolvePointer_WholeDocument(t *testing.T) { + document, err := DecodeSecretJSONValue([]byte(`{"a":"b"}`)) + if err != nil { + t.Fatalf("DecodeSecretJSONValue() error = %v", err) + } + + got, err := ResolvePointer(document, "") + if err != nil { + t.Fatalf("ResolvePointer(\"\") error = %v", err) + } + if StringifyValue(got) != `{"a":"b"}` { + t.Errorf("ResolvePointer(\"\") = %q, want the whole document", StringifyValue(got)) + } +} + +func TestResolvePointer_Container(t *testing.T) { + document, err := DecodeSecretJSONValue([]byte(`{"outer":{"inner":"v"}}`)) + if err != nil { + t.Fatalf("DecodeSecretJSONValue() error = %v", err) + } + + got, err := ResolvePointer(document, "/outer") + if err != nil { + t.Fatalf("ResolvePointer() error = %v", err) + } + if _, ok := got.(map[string]interface{}); !ok { + t.Errorf("ResolvePointer(\"/outer\") returned %T, want a map", got) + } +} + +func TestResolvePointer_Errors(t *testing.T) { + document, err := DecodeSecretJSONValue([]byte(`{"a":{"b":"c"},"list":["x"],"scalar":"s"}`)) + if err != nil { + t.Fatalf("DecodeSecretJSONValue() error = %v", err) + } + + tests := []struct { + name string + pointer string + }{ + {"missing leading slash", "a/b"}, + {"unknown key", "/nope"}, + {"unknown nested key", "/a/nope"}, + {"index out of range", "/list/5"}, + {"non numeric index", "/list/x"}, + {"negative index", "/list/-1"}, + {"descend into scalar", "/scalar/deeper"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if _, err := ResolvePointer(document, tt.pointer); err == nil { + t.Errorf("ResolvePointer(%q) succeeded, want an error", tt.pointer) + } + }) + } +} + +func TestResolvePointer_NumberIndexIsNotAFloat(t *testing.T) { + document, err := DecodeSecretJSONValue([]byte(`{"list":[1754110382]}`)) + if err != nil { + t.Fatalf("DecodeSecretJSONValue() error = %v", err) + } + + got, err := ResolvePointer(document, "/list/0") + if err != nil { + t.Fatalf("ResolvePointer() error = %v", err) + } + if _, ok := got.(json.Number); !ok { + t.Fatalf("resolved as %T, want json.Number", got) + } +} From 5f42a99a5e602e5c1f58b62bc021495c0788366a Mon Sep 17 00:00:00 2001 From: Husni Adil Makmur Date: Sun, 2 Aug 2026 13:39:51 +0700 Subject: [PATCH 4/7] feat: add a keyring provider for the OS credential store sstart already depends on go-keyring for its cache and OIDC tokens but never exposed the OS credential store as a source of secrets. Reading fails loudly when the store is unavailable: unlike the cache, an empty result here hands the child process an environment with no secrets in it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Hmh2p2Bg6kmxxvzpFDW2WL --- internal/cli/root.go | 1 + internal/provider/keyring/keyring.go | 95 +++++++++++ internal/provider/keyring/keyring_test.go | 187 ++++++++++++++++++++++ 3 files changed, 283 insertions(+) create mode 100644 internal/provider/keyring/keyring.go create mode 100644 internal/provider/keyring/keyring_test.go diff --git a/internal/cli/root.go b/internal/cli/root.go index f36a864..26ab121 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -10,6 +10,7 @@ import ( _ "github.com/dirathea/sstart/internal/provider/dotenv" _ "github.com/dirathea/sstart/internal/provider/gcsm" _ "github.com/dirathea/sstart/internal/provider/infisical" + _ "github.com/dirathea/sstart/internal/provider/keyring" _ "github.com/dirathea/sstart/internal/provider/onepassword" _ "github.com/dirathea/sstart/internal/provider/template" _ "github.com/dirathea/sstart/internal/provider/vault" diff --git a/internal/provider/keyring/keyring.go b/internal/provider/keyring/keyring.go new file mode 100644 index 0000000..491bf19 --- /dev/null +++ b/internal/provider/keyring/keyring.go @@ -0,0 +1,95 @@ +// Package keyring reads secrets from the operating system's credential store: +// Keychain on macOS, Credential Manager on Windows, Secret Service on Linux. +package keyring + +import ( + "encoding/json" + "errors" + "fmt" + "strings" + + "github.com/dirathea/sstart/internal/provider" + gokeyring "github.com/zalando/go-keyring" +) + +// KeyringConfig represents the configuration for the keyring provider +type KeyringConfig struct { + // Service is the item's service name (required) + Service string `json:"service" yaml:"service"` + // User is the item's account name (required) + User string `json:"user" yaml:"user"` + // Pointer optionally selects a node inside a JSON payload, as an RFC 6901 + // JSON pointer (for example /claudeAiOauth/accessToken). + // + // Not named 'path': in dotenv, infisical and vault that field already means + // where the secret lives, not where to look inside it. + Pointer string `json:"pointer,omitempty" yaml:"pointer,omitempty"` + // Key optionally names the environment variable used when the payload is + // not a JSON object. Defaults to _SECRET. + Key string `json:"key,omitempty" yaml:"key,omitempty"` +} + +// KeyringProvider implements the provider interface for the OS credential store +type KeyringProvider struct{} + +func init() { + provider.Register("keyring", func() provider.Provider { + return &KeyringProvider{} + }) +} + +// Name returns the provider name +func (p *KeyringProvider) Name() string { + return "keyring" +} + +// Fetch reads one item from the OS credential store. +func (p *KeyringProvider) Fetch(secretContext provider.SecretContext, mapID string, config map[string]interface{}, keys map[string]string) ([]provider.KeyValue, error) { + cfg, err := parseConfig(config) + if err != nil { + return nil, err + } + if cfg.Service == "" { + return nil, fmt.Errorf("keyring provider requires 'service' field in configuration") + } + if cfg.User == "" { + return nil, fmt.Errorf("keyring provider requires 'user' field in configuration") + } + + secretValue, err := gokeyring.Get(cfg.Service, cfg.User) + if err != nil { + if errors.Is(err, gokeyring.ErrNotFound) { + return nil, fmt.Errorf("no keyring item for service '%s' and user '%s'", cfg.Service, cfg.User) + } + return nil, fmt.Errorf("failed to read from the system keyring for service '%s' and user '%s': %w. "+ + "On Linux this usually means no Secret Service is running, which is common on headless hosts", cfg.Service, cfg.User, err) + } + + return singleValue(cfg, mapID, secretValue), nil +} + +// singleValue emits the one variable used when the payload is not a JSON +// object. The default name follows the convention the cloud providers use for +// their non-JSON payloads; the 'key' config field overrides it. +func singleValue(cfg *KeyringConfig, mapID string, value string) []provider.KeyValue { + name := cfg.Key + if name == "" { + name = strings.ToUpper(strings.ReplaceAll(mapID, "-", "_")) + "_SECRET" + } + + return []provider.KeyValue{{Key: name, Value: value}} +} + +func parseConfig(config map[string]interface{}) (*KeyringConfig, error) { + jsonData, err := json.Marshal(config) + if err != nil { + return nil, fmt.Errorf("failed to marshal config: %w", err) + } + + var cfg KeyringConfig + if err := json.Unmarshal(jsonData, &cfg); err != nil { + return nil, fmt.Errorf("failed to unmarshal config: %w", err) + } + + return &cfg, nil +} diff --git a/internal/provider/keyring/keyring_test.go b/internal/provider/keyring/keyring_test.go new file mode 100644 index 0000000..cfa3852 --- /dev/null +++ b/internal/provider/keyring/keyring_test.go @@ -0,0 +1,187 @@ +package keyring + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/dirathea/sstart/internal/provider" + "github.com/dirathea/sstart/internal/secrets" + gokeyring "github.com/zalando/go-keyring" +) + +func TestKeyringProvider_Name(t *testing.T) { + p := &KeyringProvider{} + if got := p.Name(); got != "keyring" { + t.Errorf("Name() = %v, want %v", got, "keyring") + } +} + +func TestParseConfig(t *testing.T) { + tests := []struct { + name string + config map[string]interface{} + wantService string + wantUser string + wantPointer string + wantKey string + }{ + { + name: "service and user", + config: map[string]interface{}{"service": "myapp", "user": "postgres"}, + wantService: "myapp", + wantUser: "postgres", + }, + { + name: "with pointer and key", + config: map[string]interface{}{"service": "Claude Code-credentials", "user": "husni", "pointer": "/claudeAiOauth/accessToken", "key": "CLAUDE_TOKEN"}, + wantService: "Claude Code-credentials", + wantUser: "husni", + wantPointer: "/claudeAiOauth/accessToken", + wantKey: "CLAUDE_TOKEN", + }, + { + name: "unknown fields are ignored", + config: map[string]interface{}{"service": "s", "user": "u", "extra": 1}, + wantService: "s", + wantUser: "u", + }, + { + name: "path is not mistaken for pointer", + config: map[string]interface{}{"service": "s", "user": "u", "path": "/a"}, + wantService: "s", + wantUser: "u", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg, err := parseConfig(tt.config) + if err != nil { + t.Fatalf("parseConfig() error = %v", err) + } + if cfg.Service != tt.wantService { + t.Errorf("Service = %q, want %q", cfg.Service, tt.wantService) + } + if cfg.User != tt.wantUser { + t.Errorf("User = %q, want %q", cfg.User, tt.wantUser) + } + if cfg.Pointer != tt.wantPointer { + t.Errorf("Pointer = %q, want %q", cfg.Pointer, tt.wantPointer) + } + if cfg.Key != tt.wantKey { + t.Errorf("Key = %q, want %q", cfg.Key, tt.wantKey) + } + }) + } +} + +func TestFetch_RequiredFields(t *testing.T) { + tests := []struct { + name string + config map[string]interface{} + wantMsg string + }{ + {"missing service", map[string]interface{}{"user": "u"}, "requires 'service' field"}, + {"empty service", map[string]interface{}{"service": "", "user": "u"}, "requires 'service' field"}, + {"missing user", map[string]interface{}{"service": "s"}, "requires 'user' field"}, + {"empty user", map[string]interface{}{"service": "s", "user": ""}, "requires 'user' field"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := fetchForTest(t, tt.config, nil) + if err == nil { + t.Fatal("Fetch() succeeded, want an error") + } + if !strings.Contains(err.Error(), tt.wantMsg) { + t.Errorf("Fetch() error = %q, want it to contain %q", err.Error(), tt.wantMsg) + } + }) + } +} + +func TestFetch_PlainValue(t *testing.T) { + gokeyring.MockInit() + if err := gokeyring.Set("myapp", "postgres", "hunter2"); err != nil { + t.Fatalf("Set() error = %v", err) + } + + kvs, err := fetchForTest(t, map[string]interface{}{"service": "myapp", "user": "postgres"}, nil) + if err != nil { + t.Fatalf("Fetch() error = %v", err) + } + + if len(kvs) != 1 { + t.Fatalf("got %d variables, want 1: %v", len(kvs), kvs) + } + if kvs[0].Key != "KEYRING_TEST_SECRET" { + t.Errorf("Key = %q, want %q", kvs[0].Key, "KEYRING_TEST_SECRET") + } + if kvs[0].Value != "hunter2" { + t.Errorf("Value = %q, want %q", kvs[0].Value, "hunter2") + } +} + +func TestFetch_PlainValueNamedByKeyField(t *testing.T) { + gokeyring.MockInit() + if err := gokeyring.Set("myapp", "postgres", "hunter2"); err != nil { + t.Fatalf("Set() error = %v", err) + } + + kvs, err := fetchForTest(t, + map[string]interface{}{"service": "myapp", "user": "postgres", "key": "DB_PASSWORD"}, + nil, + ) + if err != nil { + t.Fatalf("Fetch() error = %v", err) + } + + if len(kvs) != 1 { + t.Fatalf("got %d variables, want 1: %v", len(kvs), kvs) + } + if kvs[0].Key != "DB_PASSWORD" { + t.Errorf("Key = %q, want %q", kvs[0].Key, "DB_PASSWORD") + } + if kvs[0].Value != "hunter2" { + t.Errorf("Value = %q, want %q", kvs[0].Value, "hunter2") + } +} + +func TestFetch_ItemNotFound(t *testing.T) { + gokeyring.MockInit() + + _, err := fetchForTest(t, map[string]interface{}{"service": "absent", "user": "nobody"}, nil) + if err == nil { + t.Fatal("Fetch() succeeded, want an error") + } + // Both identifiers must appear: a typo in either produces this error. + for _, want := range []string{"absent", "nobody"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("error = %q, want it to contain %q", err.Error(), want) + } + } +} + +func TestFetch_KeyringUnavailable(t *testing.T) { + gokeyring.MockInitWithError(errors.New("no secret service available")) + t.Cleanup(gokeyring.MockInit) + + _, err := fetchForTest(t, map[string]interface{}{"service": "myapp", "user": "postgres"}, nil) + if err == nil { + t.Fatal("Fetch() succeeded, want an error rather than an empty result") + } + if !strings.Contains(err.Error(), "keyring") { + t.Errorf("error = %q, want it to mention the keyring", err.Error()) + } +} + +// fetchForTest calls Fetch with the provider id "keyring-test", which is what +// makes the default variable name KEYRING_TEST_SECRET. +func fetchForTest(t *testing.T, config map[string]interface{}, keys map[string]string) ([]provider.KeyValue, error) { + t.Helper() + p := &KeyringProvider{} + secretContext := secrets.NewEmptySecretContext(context.Background()) + return p.Fetch(secretContext, "keyring-test", config, keys) +} From 2af5e4100daa8285a2b2236f861999b797931a3b Mon Sep 17 00:00:00 2001 From: Husni Adil Makmur Date: Sun, 2 Aug 2026 13:40:30 +0700 Subject: [PATCH 5/7] feat: expand a JSON keyring item into one variable per key A flat JSON secret behaves the same whichever provider holds it; a keyring item that yielded one opaque blob instead would read as a bug. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Hmh2p2Bg6kmxxvzpFDW2WL --- internal/provider/keyring/keyring.go | 47 +++++++++- internal/provider/keyring/keyring_test.go | 108 ++++++++++++++++++++++ 2 files changed, 154 insertions(+), 1 deletion(-) diff --git a/internal/provider/keyring/keyring.go b/internal/provider/keyring/keyring.go index 491bf19..09eb271 100644 --- a/internal/provider/keyring/keyring.go +++ b/internal/provider/keyring/keyring.go @@ -65,7 +65,52 @@ func (p *KeyringProvider) Fetch(secretContext provider.SecretContext, mapID stri "On Linux this usually means no Secret Service is running, which is common on headless hosts", cfg.Service, cfg.User, err) } - return singleValue(cfg, mapID, secretValue), nil + return mapValue(cfg, mapID, keys, secretValue), nil +} + +// mapValue turns an item's raw value into variables. A JSON object becomes one +// variable per key, matching what the cloud providers do with a JSON payload; +// anything else becomes a single variable. +// +// A payload that is not JSON at all is emitted verbatim, so a plain password is +// passed through untouched rather than round-tripped. +func mapValue(cfg *KeyringConfig, mapID string, keys map[string]string, raw string) []provider.KeyValue { + decoded, err := provider.DecodeSecretJSONValue([]byte(raw)) + if err != nil { + return singleValue(cfg, mapID, raw) + } + + if object, ok := decoded.(map[string]interface{}); ok { + return expandObject(object, keys) + } + + return singleValue(cfg, mapID, provider.StringifyValue(decoded)) +} + +// expandObject maps each key of a JSON object to a variable, applying the keys +// mapping the same way every other provider does. +func expandObject(object map[string]interface{}, keys map[string]string) []provider.KeyValue { + kvs := make([]provider.KeyValue, 0, len(object)) + + for key, value := range object { + targetKey := key + + if mappedKey, exists := keys[key]; exists { + if mappedKey != "==" { + targetKey = mappedKey + } + } else if len(keys) != 0 { + // Keys were specified and this one is not among them. + continue + } + + kvs = append(kvs, provider.KeyValue{ + Key: targetKey, + Value: provider.StringifyValue(value), + }) + } + + return kvs } // singleValue emits the one variable used when the payload is not a JSON diff --git a/internal/provider/keyring/keyring_test.go b/internal/provider/keyring/keyring_test.go index cfa3852..c8be40a 100644 --- a/internal/provider/keyring/keyring_test.go +++ b/internal/provider/keyring/keyring_test.go @@ -185,3 +185,111 @@ func fetchForTest(t *testing.T, config map[string]interface{}, keys map[string]s secretContext := secrets.NewEmptySecretContext(context.Background()) return p.Fetch(secretContext, "keyring-test", config, keys) } + +func TestFetch_FlatJSONExpandsToSeveralVariables(t *testing.T) { + gokeyring.MockInit() + if err := gokeyring.Set("myapp", "bundle", `{"DB_USER":"admin","DB_PASS":"x","PORT":5432}`); err != nil { + t.Fatalf("Set() error = %v", err) + } + + kvs, err := fetchForTest(t, map[string]interface{}{"service": "myapp", "user": "bundle"}, nil) + if err != nil { + t.Fatalf("Fetch() error = %v", err) + } + + got := map[string]string{} + for _, kv := range kvs { + got[kv.Key] = kv.Value + } + + want := map[string]string{"DB_USER": "admin", "DB_PASS": "x", "PORT": "5432"} + if len(got) != len(want) { + t.Fatalf("got %d variables, want %d: %v", len(got), len(want), got) + } + for key, value := range want { + if got[key] != value { + t.Errorf("%s = %q, want %q", key, got[key], value) + } + } +} + +func TestFetch_JSONHonoursKeysMapping(t *testing.T) { + gokeyring.MockInit() + if err := gokeyring.Set("myapp", "bundle", `{"DB_USER":"admin","DB_PASS":"x","IGNORED":"y"}`); err != nil { + t.Fatalf("Set() error = %v", err) + } + + kvs, err := fetchForTest(t, + map[string]interface{}{"service": "myapp", "user": "bundle"}, + map[string]string{"DB_USER": "POSTGRES_USER", "DB_PASS": "=="}, + ) + if err != nil { + t.Fatalf("Fetch() error = %v", err) + } + + got := map[string]string{} + for _, kv := range kvs { + got[kv.Key] = kv.Value + } + + if len(got) != 2 { + t.Fatalf("got %d variables, want 2: %v", len(got), got) + } + if got["POSTGRES_USER"] != "admin" { + t.Errorf("POSTGRES_USER = %q, want %q", got["POSTGRES_USER"], "admin") + } + if got["DB_PASS"] != "x" { + t.Errorf("DB_PASS = %q, want %q", got["DB_PASS"], "x") + } + if _, present := got["IGNORED"]; present { + t.Error("IGNORED was mapped despite not being listed in keys") + } +} + +func TestFetch_NestedValuesFollowTheHouseRule(t *testing.T) { + gokeyring.MockInit() + payload := `{"EXPIRES_AT":1754110382,"SCOPES":["read","write"],"NESTED":{"token":"sk-secret"}}` + if err := gokeyring.Set("myapp", "mixed", payload); err != nil { + t.Fatalf("Set() error = %v", err) + } + + kvs, err := fetchForTest(t, map[string]interface{}{"service": "myapp", "user": "mixed"}, nil) + if err != nil { + t.Fatalf("Fetch() error = %v", err) + } + + got := map[string]string{} + for _, kv := range kvs { + got[kv.Key] = kv.Value + } + + want := map[string]string{ + "EXPIRES_AT": "1754110382", + "SCOPES": `["read","write"]`, + "NESTED": `{"token":"sk-secret"}`, + } + for key, value := range want { + if got[key] != value { + t.Errorf("%s = %q, want %q", key, got[key], value) + } + } +} + +func TestFetch_JSONArrayIsASingleValue(t *testing.T) { + gokeyring.MockInit() + if err := gokeyring.Set("myapp", "arr", `["a","b"]`); err != nil { + t.Fatalf("Set() error = %v", err) + } + + kvs, err := fetchForTest(t, map[string]interface{}{"service": "myapp", "user": "arr"}, nil) + if err != nil { + t.Fatalf("Fetch() error = %v", err) + } + + if len(kvs) != 1 { + t.Fatalf("got %d variables, want 1: %v", len(kvs), kvs) + } + if kvs[0].Value != `["a","b"]` { + t.Errorf("Value = %q, want %q", kvs[0].Value, `["a","b"]`) + } +} From 47b4372bc883d8f120a2be83dc33e08766d7c44b Mon Sep 17 00:00:00 2001 From: Husni Adil Makmur Date: Sun, 2 Aug 2026 13:41:30 +0700 Subject: [PATCH 6/7] feat: narrow into a keyring payload with a JSON pointer Reading a credential blob whole exposes every secret inside it to the child process. A pointer lets a config ask for the one value it needs; the schema knowledge lives in the user's config, not in sstart. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Hmh2p2Bg6kmxxvzpFDW2WL --- internal/provider/keyring/keyring.go | 20 ++- internal/provider/keyring/keyring_test.go | 145 ++++++++++++++++++++++ 2 files changed, 164 insertions(+), 1 deletion(-) diff --git a/internal/provider/keyring/keyring.go b/internal/provider/keyring/keyring.go index 09eb271..cf12bea 100644 --- a/internal/provider/keyring/keyring.go +++ b/internal/provider/keyring/keyring.go @@ -65,7 +65,25 @@ func (p *KeyringProvider) Fetch(secretContext provider.SecretContext, mapID stri "On Linux this usually means no Secret Service is running, which is common on headless hosts", cfg.Service, cfg.User, err) } - return mapValue(cfg, mapID, keys, secretValue), nil + if cfg.Pointer == "" { + return mapValue(cfg, mapID, keys, secretValue), nil + } + + document, err := provider.DecodeSecretJSONValue([]byte(secretValue)) + if err != nil { + return nil, fmt.Errorf("keyring provider has 'pointer' set for service '%s' and user '%s', but the item's value is not JSON: %w", cfg.Service, cfg.User, err) + } + + node, err := provider.ResolvePointer(document, cfg.Pointer) + if err != nil { + return nil, fmt.Errorf("keyring provider for service '%s' and user '%s': %w", cfg.Service, cfg.User, err) + } + + if object, ok := node.(map[string]interface{}); ok { + return expandObject(object, keys), nil + } + + return singleValue(cfg, mapID, provider.StringifyValue(node)), nil } // mapValue turns an item's raw value into variables. A JSON object becomes one diff --git a/internal/provider/keyring/keyring_test.go b/internal/provider/keyring/keyring_test.go index c8be40a..d502e1b 100644 --- a/internal/provider/keyring/keyring_test.go +++ b/internal/provider/keyring/keyring_test.go @@ -293,3 +293,148 @@ func TestFetch_JSONArrayIsASingleValue(t *testing.T) { t.Errorf("Value = %q, want %q", kvs[0].Value, `["a","b"]`) } } + +const claudeShapedPayload = `{ + "claudeAiOauth": {"accessToken": "sk-secret", "expiresAt": 1754110382, "scopes": ["read", "write"]}, + "mcpOAuth": {"plugin:engineering:github|1eea5f27": {"accessToken": "gh-token"}} +}` + +func TestFetch_PointerToScalar(t *testing.T) { + gokeyring.MockInit() + if err := gokeyring.Set("Claude Code-credentials", "husni", claudeShapedPayload); err != nil { + t.Fatalf("Set() error = %v", err) + } + + kvs, err := fetchForTest(t, map[string]interface{}{ + "service": "Claude Code-credentials", + "user": "husni", + "pointer": "/claudeAiOauth/accessToken", + "key": "CLAUDE_TOKEN", + }, nil) + if err != nil { + t.Fatalf("Fetch() error = %v", err) + } + + if len(kvs) != 1 { + t.Fatalf("got %d variables, want 1: %v", len(kvs), kvs) + } + if kvs[0].Key != "CLAUDE_TOKEN" { + t.Errorf("Key = %q, want %q", kvs[0].Key, "CLAUDE_TOKEN") + } + if kvs[0].Value != "sk-secret" { + t.Errorf("Value = %q, want %q", kvs[0].Value, "sk-secret") + } +} + +// The point of narrowing: the other tokens in a real credential blob must not +// reach the child process when one token was asked for. +func TestFetch_PointerDoesNotLeakSiblings(t *testing.T) { + gokeyring.MockInit() + if err := gokeyring.Set("Claude Code-credentials", "husni", claudeShapedPayload); err != nil { + t.Fatalf("Set() error = %v", err) + } + + kvs, err := fetchForTest(t, map[string]interface{}{ + "service": "Claude Code-credentials", + "user": "husni", + "pointer": "/claudeAiOauth/accessToken", + }, nil) + if err != nil { + t.Fatalf("Fetch() error = %v", err) + } + + for _, kv := range kvs { + if strings.Contains(kv.Value, "gh-token") { + t.Errorf("variable %s leaked an unrelated token: %q", kv.Key, kv.Value) + } + } +} + +func TestFetch_PointerToObjectExpands(t *testing.T) { + gokeyring.MockInit() + if err := gokeyring.Set("Claude Code-credentials", "husni", claudeShapedPayload); err != nil { + t.Fatalf("Set() error = %v", err) + } + + kvs, err := fetchForTest(t, map[string]interface{}{ + "service": "Claude Code-credentials", + "user": "husni", + "pointer": "/claudeAiOauth", + }, nil) + if err != nil { + t.Fatalf("Fetch() error = %v", err) + } + + got := map[string]string{} + for _, kv := range kvs { + got[kv.Key] = kv.Value + } + + if got["accessToken"] != "sk-secret" { + t.Errorf("accessToken = %q, want %q", got["accessToken"], "sk-secret") + } + if got["expiresAt"] != "1754110382" { + t.Errorf("expiresAt = %q, want %q", got["expiresAt"], "1754110382") + } + if got["scopes"] != `["read","write"]` { + t.Errorf("scopes = %q, want %q", got["scopes"], `["read","write"]`) + } +} + +func TestFetch_PointerThroughAwkwardKey(t *testing.T) { + gokeyring.MockInit() + if err := gokeyring.Set("Claude Code-credentials", "husni", claudeShapedPayload); err != nil { + t.Fatalf("Set() error = %v", err) + } + + kvs, err := fetchForTest(t, map[string]interface{}{ + "service": "Claude Code-credentials", + "user": "husni", + "pointer": "/mcpOAuth/plugin:engineering:github|1eea5f27/accessToken", + "key": "GITHUB_TOKEN", + }, nil) + if err != nil { + t.Fatalf("Fetch() error = %v", err) + } + + if len(kvs) != 1 || kvs[0].Key != "GITHUB_TOKEN" || kvs[0].Value != "gh-token" { + t.Fatalf("got %v, want a single GITHUB_TOKEN=gh-token", kvs) + } +} + +func TestFetch_PointerErrors(t *testing.T) { + gokeyring.MockInit() + if err := gokeyring.Set("myapp", "json", `{"a":"b"}`); err != nil { + t.Fatalf("Set() error = %v", err) + } + if err := gokeyring.Set("myapp", "plain", "hunter2"); err != nil { + t.Fatalf("Set() error = %v", err) + } + + tests := []struct { + name string + user string + pointer string + wantMsg string + }{ + {"pointer matches nothing", "json", "/nope", "/nope"}, + {"pointer against a non JSON payload", "plain", "/a", "not JSON"}, + {"malformed pointer", "json", "a", "must start with"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := fetchForTest(t, map[string]interface{}{ + "service": "myapp", + "user": tt.user, + "pointer": tt.pointer, + }, nil) + if err == nil { + t.Fatal("Fetch() succeeded, want an error") + } + if !strings.Contains(err.Error(), tt.wantMsg) { + t.Errorf("error = %q, want it to contain %q", err.Error(), tt.wantMsg) + } + }) + } +} From 2d3e217933a11e194e2a3e989308d72145836b7b Mon Sep 17 00:00:00 2001 From: Husni Adil Makmur Date: Sun, 2 Aug 2026 13:42:20 +0700 Subject: [PATCH 7/7] docs: document the keyring provider Includes how to populate the store on each platform, since the provider only reads and there is no sstart command that writes. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Hmh2p2Bg6kmxxvzpFDW2WL --- CONFIGURATION.md | 66 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/CONFIGURATION.md b/CONFIGURATION.md index 39ce841..6dd0886 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -34,6 +34,7 @@ providers: | `dotenv` | Stable | | `gcloud_secretmanager` | Stable | | `infisical` | Stable | +| `keyring` | Stable | | `template` | Stable | | `vault` | Stable | @@ -375,6 +376,71 @@ The provider uses the Infisical Go SDK to authenticate with Infisical using Univ - When `include_imports: true`, secrets imported from other projects are included - When `expand_secrets: true`, secret references (e.g., `${OTHER_SECRET}`) are expanded to their actual values +### Keyring (`keyring`) + +Reads a secret from the operating system's credential store: Keychain on macOS, Credential Manager on Windows, and Secret Service on Linux. + +**Dependencies:** +- No CLI required. On Linux a Secret Service implementation must be running (for example `gnome-keyring`), which is often absent on headless hosts. + +**Configuration:** +- `service` (required): The item's service name +- `user` (required): The item's account name +- `pointer` (optional): An [RFC 6901](https://datatracker.ietf.org/doc/html/rfc6901) JSON pointer selecting a node inside a JSON payload, for example `/claudeAiOauth/accessToken` +- `key` (optional): The environment variable name to use when the payload is not a JSON object. Defaults to `_SECRET` + +Both `service` and `user` are required because the credential store cannot be enumerated — an item is only reachable through its exact identity. + +**Example:** +```yaml +providers: + - kind: keyring + id: db + service: myapp + user: postgres +``` + +**Example with a JSON pointer:** +```yaml +providers: + - kind: keyring + id: claude + service: Claude Code-credentials + user: husni + pointer: /claudeAiOauth/accessToken + key: CLAUDE_TOKEN +``` + +**JSON Secrets:** +If the item's value is a JSON object, it is parsed and each key-value pair is mapped according to the `keys` configuration, the same as the cloud providers. If `keys` is empty, all keys are mapped. See [Value Types](#value-types) for how non-string JSON values become environment variables. + +**Plain Text Secrets:** +If the value is not a JSON object, it is mapped to a single environment variable named `_SECRET` (the provider's `id` uppercased, with hyphens converted to underscores). Set `key` to choose a different name. + +**Narrowing with `pointer`:** +Without `pointer`, the whole item is read. A credential blob often holds many unrelated secrets, and every one of them would then be exported to the child process. `pointer` selects a single node first: if it resolves to an object the object is expanded, otherwise it becomes one variable named by `key`. + +`pointer` requires the item's value to be JSON. Pointing at a value that is not JSON, or at a node that does not exist, is an error rather than an empty result. + +The field is called `pointer` rather than `path` because in `dotenv`, `infisical` and `vault`, `path` already means where the secret lives, not where to look inside it. + +**Populating the store:** + +This provider only reads. Create items with the tools your platform provides: + +```bash +# macOS +security add-generic-password -s myapp -a postgres -w + +# Windows (PowerShell or cmd) +cmdkey /generic:myapp /user:postgres /pass + +# Linux +secret-tool store --label=myapp service myapp username postgres +``` + +**Note:** sstart can never read an item that the OS does not release to it. On macOS, an item created by another application carries an access control list, and the system prompts before releasing it to a different binary. + ### HashiCorp Vault / OpenBao (`vault`) Retrieves secrets from HashiCorp Vault or OpenBao. Supports both KV v1 and KV v2 secret engines. OpenBao is a community-driven fork of HashiCorp Vault that maintains API compatibility, so the same `vault` provider works with both systems.