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
75 changes: 75 additions & 0 deletions cmd/context/get_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package context

import (
"bytes"
"io"
"os"
"strings"
"testing"

"github.com/spf13/cobra"
"github.com/spf13/viper"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

const getSentinelToken = "sentinel-secret-token"

func executeGetCommand(t *testing.T, format string, showSensitive bool) string {
t.Helper()
viper.Reset()
t.Cleanup(viper.Reset)
viper.Set("instances", []any{map[string]any{
"name": "production",
"fqdn": "https://coolify.example.com",
"token": getSentinelToken,
"default": true,
}})

root := &cobra.Command{Use: "test"}
root.PersistentFlags().String("format", "table", "")
root.PersistentFlags().Bool("show-sensitive", false, "")
root.AddCommand(NewGetCommand())
args := []string{"get", "production", "--format", format}
if showSensitive {
args = append(args, "--show-sensitive")
}
root.SetArgs(args)

read, write, err := os.Pipe()
require.NoError(t, err)
originalStdout := os.Stdout
os.Stdout = write
t.Cleanup(func() { os.Stdout = originalStdout })

var output bytes.Buffer
require.NoError(t, root.Execute())
require.NoError(t, write.Close())
os.Stdout = originalStdout
_, err = io.Copy(&output, read)
require.NoError(t, err)
require.NoError(t, read.Close())
return output.String()
}

// TestGetCommand_NeverExposesTokenByDefault guards against the general
// json/pretty sensitive-field leak (fixed in internal/output/redact.go)
// for the one context command that renders the real config.Instance
// (including its token) rather than a stripped-down summary.
func TestGetCommand_NeverExposesTokenByDefault(t *testing.T) {
for _, format := range []string{"table", "json", "pretty"} {
t.Run(format, func(t *testing.T) {
rendered := executeGetCommand(t, format, false)
assert.NotContains(t, rendered, getSentinelToken)
})
}
}

func TestGetCommand_ShowSensitiveRevealsToken(t *testing.T) {
for _, format := range []string{"table", "json", "pretty"} {
t.Run(format, func(t *testing.T) {
rendered := executeGetCommand(t, format, true)
assert.True(t, strings.Contains(rendered, getSentinelToken), "expected token to be revealed with --show-sensitive")
})
}
}
43 changes: 42 additions & 1 deletion internal/api/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,47 @@ func (c *Client) doRequestOnce(ctx context.Context, method, path string, body, r
return nil
}

// sensitiveDebugLogKeys are the JSON field names of every struct field
// tagged `sensitive:"true"` across internal/models and internal/config.
// Debug logging works on decoded, untyped JSON (request/response bodies can
// be arbitrary maps, not just tagged structs), so it can't walk Go struct
// tags directly like internal/output's formatters do; this set is the
// closest equivalent for that generic shape.
//
// TestSensitiveDebugLogKeys_CoversEveryTaggedField fails if a new
// `sensitive:"true"` tag lands without its JSON name being added here.
var sensitiveDebugLogKeys = map[string]struct{}{
"token": {},
"value": {},
"real_value": {},
"key": {},
"secret": {},
"script": {},
"client_secret": {},
"webhook_secret": {},
"webhook_token": {},
"manual_webhook_secret_github": {},
"manual_webhook_secret_gitlab": {},
"manual_webhook_secret_bitbucket": {},
"manual_webhook_secret_gitea": {},
"public_key": {},
"private_key": {},
"email": {},
"ip": {},
"user": {},
"port": {},
"postgres_password": {},
"mysql_root_password": {},
"mysql_password": {},
"mariadb_root_password": {},
"mariadb_password": {},
"mongo_initdb_root_password": {},
"redis_password": {},
"keydb_password": {},
"clickhouse_admin_password": {},
"dragonfly_password": {},
}

func redactJSONForLog(jsonBody []byte) string {
var value any
if err := json.Unmarshal(jsonBody, &value); err != nil {
Expand All @@ -232,7 +273,7 @@ func redactSensitiveFields(value any) {
switch typed := value.(type) {
case map[string]any:
for key, child := range typed {
if key == "token" {
if _, sensitive := sensitiveDebugLogKeys[key]; sensitive {
typed[key] = "********"
continue
}
Expand Down
42 changes: 42 additions & 0 deletions internal/api/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,48 @@ func TestClient_DebugRedactsSensitiveResponseFields(t *testing.T) {
assert.Contains(t, logs.String(), `"token":"********"`)
}

func TestClient_DebugRedactsPrivateKeyMaterial(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{"uuid":"key-1","public_key":"ssh-ed25519 dummy-public","private_key":"-----BEGIN OPENSSH PRIVATE KEY-----\ndummy-private-material"}`))
}))
defer server.Close()

var logs bytes.Buffer
previousWriter := log.Writer()
log.SetOutput(&logs)
t.Cleanup(func() { log.SetOutput(previousWriter) })

client := NewClient(server.URL, "coolify-token", WithDebug(true), WithRetries(0))
var response map[string]any
err := client.Get(context.Background(), "security/keys/key-1", &response)
require.NoError(t, err)
assert.NotContains(t, logs.String(), "dummy-public")
assert.NotContains(t, logs.String(), "dummy-private-material")
assert.Contains(t, logs.String(), `"private_key":"********"`)
assert.Contains(t, logs.String(), `"public_key":"********"`)
}

func TestClient_DebugRedactsWebhookSecret(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{"uuid":"app-1"}`))
}))
defer server.Close()

var logs bytes.Buffer
previousWriter := log.Writer()
log.SetOutput(&logs)
t.Cleanup(func() { log.SetOutput(previousWriter) })

client := NewClient(server.URL, "coolify-token", WithDebug(true), WithRetries(0))
var response map[string]any
err := client.Post(context.Background(), "github-apps", map[string]string{"client_secret": "dummy-client-secret", "webhook_secret": "dummy-webhook-secret"}, &response)
require.NoError(t, err)
assert.NotContains(t, logs.String(), "dummy-client-secret")
assert.NotContains(t, logs.String(), "dummy-webhook-secret")
assert.Contains(t, logs.String(), `"client_secret":"********"`)
assert.Contains(t, logs.String(), `"webhook_secret":"********"`)
}

func TestNewClient(t *testing.T) {
t.Run("creates client with defaults", func(t *testing.T) {
client := NewClient("https://app.coolify.io", "test-token")
Expand Down
85 changes: 85 additions & 0 deletions internal/api/sensitive_keys_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
package api

import (
"go/ast"
"go/parser"
"go/token"
"os"
"reflect"
"strconv"
"strings"
"testing"

"github.com/stretchr/testify/require"
)

// TestSensitiveDebugLogKeys_CoversEveryTaggedField is the guard that keeps
// sensitiveDebugLogKeys from drifting. The debug logger redacts by JSON key
// name because it works on untyped bodies, so it cannot discover new
// `sensitive:"true"` tags on its own. This test parses the model and config
// sources and fails if any tagged field's JSON name is missing from the set,
// turning a silent leak into a build failure.
func TestSensitiveDebugLogKeys_CoversEveryTaggedField(t *testing.T) {
tagged := sensitiveJSONNamesInPackages(t, "../models", "../config")
require.NotEmpty(t, tagged, "found no sensitive:\"true\" tags — the source scan is broken, not the code")

for name, where := range tagged {
if _, ok := sensitiveDebugLogKeys[name]; !ok {
t.Errorf("%s is tagged sensitive:\"true\" but its JSON name %q is missing from sensitiveDebugLogKeys in client.go — --debug would log it in full", where, name)
}
}
}

// sensitiveJSONNamesInPackages returns JSON name -> "Type.Field" for every
// struct field tagged `sensitive:"true"` in the given source directories.
func sensitiveJSONNamesInPackages(t *testing.T, dirs ...string) map[string]string {
t.Helper()
found := make(map[string]string)

for _, dir := range dirs {
packages, err := parser.ParseDir(token.NewFileSet(), dir, func(info os.FileInfo) bool {
return !strings.HasSuffix(info.Name(), "_test.go")
}, 0)
require.NoError(t, err, "parsing %s", dir)

for _, pkg := range packages {
for _, file := range pkg.Files {
ast.Inspect(file, func(node ast.Node) bool {
typeSpec, ok := node.(*ast.TypeSpec)
if !ok {
return true
}
structType, ok := typeSpec.Type.(*ast.StructType)
if !ok {
return true
}
for _, field := range structType.Fields.List {
if field.Tag == nil {
continue
}
tag, err := strconv.Unquote(field.Tag.Value)
if err != nil {
continue
}
structTag := reflect.StructTag(tag)
if structTag.Get("sensitive") != "true" {
continue
}
name := strings.Split(structTag.Get("json"), ",")[0]
if name == "" || name == "-" {
continue
}
fieldName := name
if len(field.Names) > 0 {
fieldName = field.Names[0].Name
}
found[name] = typeSpec.Name.Name + "." + fieldName
}
return true
})
}
}
}

return found
}
20 changes: 10 additions & 10 deletions internal/models/database.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,46 +25,46 @@ type Database struct {

// PostgreSQL specific
PostgresUser *string `json:"postgres_user,omitempty" table:"-"`
PostgresPassword *string `json:"postgres_password,omitempty" table:"-"`
PostgresPassword *string `json:"postgres_password,omitempty" table:"-" sensitive:"true"`
PostgresDB *string `json:"postgres_db,omitempty" table:"-"`
PostgresInitdbArgs *string `json:"postgres_initdb_args,omitempty" table:"-"`
PostgresHostAuthMethod *string `json:"postgres_host_auth_method,omitempty" table:"-"`
PostgresConf *string `json:"postgres_conf,omitempty" table:"-"`

// MySQL specific
MysqlRootPassword *string `json:"mysql_root_password,omitempty" table:"-"`
MysqlPassword *string `json:"mysql_password,omitempty" table:"-"`
MysqlRootPassword *string `json:"mysql_root_password,omitempty" table:"-" sensitive:"true"`
MysqlPassword *string `json:"mysql_password,omitempty" table:"-" sensitive:"true"`
MysqlUser *string `json:"mysql_user,omitempty" table:"-"`
MysqlDatabase *string `json:"mysql_database,omitempty" table:"-"`
MysqlConf *string `json:"mysql_conf,omitempty" table:"-"`

// MariaDB specific
MariadbRootPassword *string `json:"mariadb_root_password,omitempty" table:"-"`
MariadbPassword *string `json:"mariadb_password,omitempty" table:"-"`
MariadbRootPassword *string `json:"mariadb_root_password,omitempty" table:"-" sensitive:"true"`
MariadbPassword *string `json:"mariadb_password,omitempty" table:"-" sensitive:"true"`
MariadbUser *string `json:"mariadb_user,omitempty" table:"-"`
MariadbDatabase *string `json:"mariadb_database,omitempty" table:"-"`
MariadbConf *string `json:"mariadb_conf,omitempty" table:"-"`

// MongoDB specific
MongoInitdbRootUsername *string `json:"mongo_initdb_root_username,omitempty" table:"-"`
MongoInitdbRootPassword *string `json:"mongo_initdb_root_password,omitempty" table:"-"`
MongoInitdbRootPassword *string `json:"mongo_initdb_root_password,omitempty" table:"-" sensitive:"true"`
MongoInitdbDatabase *string `json:"mongo_initdb_database,omitempty" table:"-"`
MongoConf *string `json:"mongo_conf,omitempty" table:"-"`

// Redis specific
RedisPassword *string `json:"redis_password,omitempty" table:"-"`
RedisPassword *string `json:"redis_password,omitempty" table:"-" sensitive:"true"`
RedisConf *string `json:"redis_conf,omitempty" table:"-"`

// KeyDB specific
KeydbPassword *string `json:"keydb_password,omitempty" table:"-"`
KeydbPassword *string `json:"keydb_password,omitempty" table:"-" sensitive:"true"`
KeydbConf *string `json:"keydb_conf,omitempty" table:"-"`

// Clickhouse specific
ClickhouseAdminUser *string `json:"clickhouse_admin_user,omitempty" table:"-"`
ClickhouseAdminPassword *string `json:"clickhouse_admin_password,omitempty" table:"-"`
ClickhouseAdminPassword *string `json:"clickhouse_admin_password,omitempty" table:"-" sensitive:"true"`

// Dragonfly specific
DragonflyPassword *string `json:"dragonfly_password,omitempty" table:"-"`
DragonflyPassword *string `json:"dragonfly_password,omitempty" table:"-" sensitive:"true"`

// Relationship IDs - internal database IDs (hidden from output)
ServerID *int `json:"-" table:"-"`
Expand Down
2 changes: 1 addition & 1 deletion internal/output/json.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,5 +17,5 @@ func NewJSONFormatter(opts Options) *JSONFormatter {
// Format formats the data as compact JSON
func (f *JSONFormatter) Format(data interface{}) error {
encoder := json.NewEncoder(f.opts.Writer)
return encoder.Encode(data)
return encoder.Encode(redactSensitive(data, f.opts.ShowSensitive))
}
2 changes: 1 addition & 1 deletion internal/output/pretty.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,5 +18,5 @@ func NewPrettyFormatter(opts Options) *PrettyFormatter {
func (f *PrettyFormatter) Format(data interface{}) error {
encoder := json.NewEncoder(f.opts.Writer)
encoder.SetIndent("", " ")
return encoder.Encode(data)
return encoder.Encode(redactSensitive(data, f.opts.ShowSensitive))
}
Loading