Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`:
Expand Down
6 changes: 3 additions & 3 deletions internal/provider/aws/secretsmanager.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down
6 changes: 3 additions & 3 deletions internal/provider/azurekeyvault/azurekeyvault.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down
6 changes: 4 additions & 2 deletions internal/provider/bitwarden/bitwarden.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion internal/provider/bitwarden/bitwarden_sm.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
6 changes: 3 additions & 3 deletions internal/provider/gcsm/gcsm.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion internal/provider/infisical/infisical.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion internal/provider/onepassword/onepassword.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
63 changes: 63 additions & 0 deletions internal/provider/value.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
110 changes: 110 additions & 0 deletions internal/provider/value_test.go
Original file line number Diff line number Diff line change
@@ -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"])
}
}
88 changes: 88 additions & 0 deletions tests/end2end/json_value_types_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
Loading