diff --git a/README.md b/README.md index e3157eb..cd2e75b 100644 --- a/README.md +++ b/README.md @@ -137,6 +137,9 @@ azd rest put https://management.azure.com/subscriptions/{sub}/resourceGroups/{rg azd rest get https://management.azure.com/subscriptions/{sub}/resourceGroups/{rg}?api-version=2021-04-01 \ --flatten +# Mask any sensitive-looking field (passwords, keys, tokens) before sharing a response +azd rest get https://myvault.vault.azure.net/secrets/mysecret?api-version=7.4 --redact-secrets + # Diagnose authentication issues azd rest doctor diff --git a/cli/src/internal/cmd/root.go b/cli/src/internal/cmd/root.go index 35e9e2a..944014a 100644 --- a/cli/src/internal/cmd/root.go +++ b/cli/src/internal/cmd/root.go @@ -59,6 +59,7 @@ var ( include bool allowHosts []string redactPaths []string + redactSecrets bool tableColumns []string dumpHeaders string fail bool @@ -224,6 +225,7 @@ Examples: rootCmd.PersistentFlags().BoolVarP(&include, "include", "i", false, "Include the HTTP status line and response headers in the output") rootCmd.PersistentFlags().StringArrayVar(&allowHosts, "allow-host", []string{}, "Restrict requests to hosts matching a pattern (repeatable; leading *. matches subdomains). Env: AZD_REST_ALLOWED_HOSTS (comma separated)") rootCmd.PersistentFlags().StringArrayVar(&redactPaths, "redact", []string{}, "Mask a JSON response field before output (repeatable, dotted path, * matches array elements)") + rootCmd.PersistentFlags().BoolVar(&redactSecrets, "redact-secrets", false, "Mask JSON response fields whose name looks sensitive (password, secret, connectionString, accountKey, and similar) at any depth") rootCmd.PersistentFlags().StringSliceVar(&tableColumns, "table-columns", nil, "Comma-separated columns to show, in order, for --format table (ignored for other formats)") rootCmd.PersistentFlags().StringVar(&dumpHeaders, "dump-headers", "", "Write response status line and headers to a file (use - for stderr)") rootCmd.PersistentFlags().BoolVar(&fail, "fail", false, "Exit with code 22 when the response status is 400 or higher (the response body is still printed)") @@ -299,6 +301,7 @@ func snapshotConfig() config.Config { Include: include, AllowedHosts: allowHosts, Redact: redactPaths, + RedactSecrets: redactSecrets, TableColumns: tableColumns, DumpHeaders: dumpHeaders, Fail: fail, diff --git a/cli/src/internal/config/config.go b/cli/src/internal/config/config.go index 0e8015c..4185a51 100644 --- a/cli/src/internal/config/config.go +++ b/cli/src/internal/config/config.go @@ -45,6 +45,7 @@ type Config struct { Include bool AllowedHosts []string Redact []string + RedactSecrets bool TableColumns []string DumpHeaders string Fail bool diff --git a/cli/src/internal/service/redact_secrets.go b/cli/src/internal/service/redact_secrets.go new file mode 100644 index 0000000..294073c --- /dev/null +++ b/cli/src/internal/service/redact_secrets.go @@ -0,0 +1,99 @@ +package service + +// cspell:ignore connectionstring sharedaccesskey sharedaccesssignature accountkey primarykey secondarykey accesskey apikey encryptionkey accesstoken refreshtoken idtoken bearertoken sastoken + +import ( + "bytes" + "encoding/json" + "strings" +) + +// sensitiveKeyTerms are normalized substrings that mark a JSON object key as +// likely to hold a secret value. Matching runs against a normalized key +// (lowercased with non-alphanumeric characters removed) so connectionString, +// connection_string, and connection-string all match "connectionstring". The +// bare words "key" and "token" are deliberately excluded to avoid masking +// common identifier fields such as "partitionKey" or a CSRF "token" name. +var sensitiveKeyTerms = []string{ + "password", "passwd", "pwd", + "secret", + "credential", + "connectionstring", + "sharedaccesskey", "sharedaccesssignature", + "accountkey", "primarykey", "secondarykey", "accesskey", "apikey", + "privatekey", "encryptionkey", + "accesstoken", "refreshtoken", "idtoken", "bearertoken", "sastoken", +} + +// redactSecretsJSONBody parses body as JSON and replaces the value of every +// object key whose name looks sensitive with a fixed placeholder, at any depth. +// It returns re-encoded JSON. An error is returned only when body is not valid +// JSON, so callers can leave the body unchanged. +func redactSecretsJSONBody(body []byte) ([]byte, error) { + dec := json.NewDecoder(bytes.NewReader(body)) + dec.UseNumber() + + var parsed any + if err := dec.Decode(&parsed); err != nil { + return nil, err + } + + redactSecretsNode(parsed) + + var b bytes.Buffer + enc := json.NewEncoder(&b) + enc.SetEscapeHTML(false) + if err := enc.Encode(parsed); err != nil { + return nil, err + } + return bytes.TrimRight(b.Bytes(), "\n"), nil +} + +// redactSecretsNode walks node in place. For objects it masks the value of any +// sensitive key and recurses into the rest; for arrays it recurses into every +// element. Masking a key replaces its whole value, so a nested object or array +// under a sensitive key is masked as a unit. +func redactSecretsNode(node any) { + switch v := node.(type) { + case map[string]any: + for key, child := range v { + if isSensitiveKey(key) { + v[key] = redactedPlaceholder + continue + } + redactSecretsNode(child) + } + case []any: + for _, item := range v { + redactSecretsNode(item) + } + } +} + +// isSensitiveKey reports whether a JSON key name contains a known sensitive +// term after normalization. +func isSensitiveKey(key string) bool { + normalized := normalizeKey(key) + if normalized == "" { + return false + } + for _, term := range sensitiveKeyTerms { + if strings.Contains(normalized, term) { + return true + } + } + return false +} + +// normalizeKey lowercases key and drops every character that is not a lowercase +// letter or digit, so different casing and separators compare equal. +func normalizeKey(key string) string { + var b strings.Builder + b.Grow(len(key)) + for _, r := range strings.ToLower(key) { + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') { + b.WriteRune(r) + } + } + return b.String() +} diff --git a/cli/src/internal/service/redact_secrets_test.go b/cli/src/internal/service/redact_secrets_test.go new file mode 100644 index 0000000..94105d8 --- /dev/null +++ b/cli/src/internal/service/redact_secrets_test.go @@ -0,0 +1,159 @@ +package service + +import ( + "context" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/jongio/azd-rest/src/internal/config" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRedactSecretsJSONBody_MasksSensitiveKeys(t *testing.T) { + body := []byte(`{"name":"kv","password":"p","connectionString":"c","account_key":"k","sasToken":"s","accessToken":"a"}`) + got, err := redactSecretsJSONBody(body) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + out := string(got) + for _, secret := range []string{`"p"`, `"c"`, `"k"`, `"s"`, `"a"`} { + if strings.Contains(out, secret) { + t.Fatalf("secret value %s was not masked: %s", secret, out) + } + } + if !strings.Contains(out, `"name":"kv"`) { + t.Fatalf("non-sensitive field was changed: %s", out) + } + if strings.Count(out, redactedPlaceholder) != 5 { + t.Fatalf("expected 5 masked values, got: %s", out) + } +} + +func TestRedactSecretsJSONBody_PreservesNonSensitiveKeys(t *testing.T) { + body := []byte(`{"id":"1","location":"eastus","partitionKey":"pk","tokenType":"Bearer","displayName":"n"}`) + got, err := redactSecretsJSONBody(body) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if string(got) != `{"displayName":"n","id":"1","location":"eastus","partitionKey":"pk","tokenType":"Bearer"}` { + t.Fatalf("non-sensitive keys should be untouched, got: %s", string(got)) + } +} + +func TestRedactSecretsJSONBody_NestedAndArrays(t *testing.T) { + body := []byte(`{"items":[{"name":"a","clientSecret":"x"},{"name":"b","clientSecret":"y"}],"nested":{"apiKey":"z"}}`) + got, err := redactSecretsJSONBody(body) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + out := string(got) + for _, secret := range []string{`"x"`, `"y"`, `"z"`} { + if strings.Contains(out, secret) { + t.Fatalf("nested secret %s not masked: %s", secret, out) + } + } + if strings.Count(out, redactedPlaceholder) != 3 { + t.Fatalf("expected 3 masked values, got: %s", out) + } +} + +func TestRedactSecretsJSONBody_MasksWholeSubtree(t *testing.T) { + body := []byte(`{"credentials":{"user":"u","pass":"p"},"public":"ok"}`) + got, err := redactSecretsJSONBody(body) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + want := `{"credentials":"REDACTED","public":"ok"}` + if string(got) != want { + t.Fatalf("body = %q, want %q", string(got), want) + } +} + +func TestRedactSecretsJSONBody_CaseInsensitive(t *testing.T) { + body := []byte(`{"PASSWORD":"p","Connection-String":"c"}`) + got, err := redactSecretsJSONBody(body) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + out := string(got) + if strings.Contains(out, `"p"`) || strings.Contains(out, `"c"`) { + t.Fatalf("expected case-insensitive masking, got: %s", out) + } +} + +func TestRedactSecretsJSONBody_PreservesNumbers(t *testing.T) { + body := []byte(`{"port":8443,"password":"x"}`) + got, err := redactSecretsJSONBody(body) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(string(got), `"port":8443`) { + t.Fatalf("expected port to stay numeric, got %q", string(got)) + } + if !strings.Contains(string(got), `"password":"REDACTED"`) { + t.Fatalf("expected password redacted, got %q", string(got)) + } +} + +func TestRedactSecretsJSONBody_InvalidJSON(t *testing.T) { + if _, err := redactSecretsJSONBody([]byte("not json")); err == nil { + t.Fatal("expected error for non-JSON body") + } +} + +func TestExecute_RedactSecrets_MasksSensitiveField(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"name":"kv","connectionString":"s3cr3t"}`)) + })) + defer srv.Close() + + tmp := filepath.Join(t.TempDir(), "out.json") + cfg := config.Defaults() + cfg.NoAuth = true + cfg.OutputFile = tmp + cfg.RedactSecrets = true + + err := newTestService().Execute(context.Background(), cfg, "GET", srv.URL+"/secret") + require.NoError(t, err) + + out, err := os.ReadFile(tmp) + require.NoError(t, err) + body := string(out) + + assert.Contains(t, body, "REDACTED") + assert.NotContains(t, body, "s3cr3t") + assert.Contains(t, body, "kv") +} + +func TestExecute_RedactSecrets_RawLeftUnchanged(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"password":"s3cr3t"}`)) + })) + defer srv.Close() + + tmp := filepath.Join(t.TempDir(), "out.txt") + cfg := config.Defaults() + cfg.NoAuth = true + cfg.OutputFormat = "raw" + cfg.OutputFile = tmp + cfg.RedactSecrets = true + + err := newTestService().Execute(context.Background(), cfg, "GET", srv.URL+"/secret") + require.NoError(t, err) + + out, err := os.ReadFile(tmp) + require.NoError(t, err) + body := string(out) + + assert.Contains(t, body, "s3cr3t") + assert.NotContains(t, body, "REDACTED") +} diff --git a/cli/src/internal/service/service.go b/cli/src/internal/service/service.go index c12646c..57e82e4 100644 --- a/cli/src/internal/service/service.go +++ b/cli/src/internal/service/service.go @@ -497,6 +497,21 @@ func (s *RequestService) writeResponseOutput(cfg config.Config, resp *client.Res } } + // Secret redaction (#265): mask the value of any key that looks sensitive + // anywhere in the response. Like --redact it needs parsed JSON, so raw and + // binary output are left unchanged with a note on stderr. It runs after + // --redact so both can apply in one invocation. + if cfg.RedactSecrets { + isBinary := cfg.Binary || client.DetectContentType(resp.Body, resp.Headers.Get("Content-Type")) + if isBinary || cfg.OutputFormat == formatRaw { + writeDiagnostic(os.Stderr, cfg.Silent, "> --redact-secrets needs parsed JSON; leaving raw or binary output unchanged\n") + } else if redacted, err := redactSecretsJSONBody(resp.Body); err != nil { + writeDiagnostic(os.Stderr, cfg.Silent, "> --redact-secrets could not parse the response as JSON; leaving it unchanged\n") + } else { + resp.Body = redacted + } + } + // Flatten (#237): collapse a JSON response into a single-level object keyed // by dotted paths. Like redaction it needs the JSON output path, so binary, // raw, and the structured formats (table, jsonl, yaml, csv) are left diff --git a/web/src/pages/reference.astro b/web/src/pages/reference.astro index 446eed5..3ca5a4b 100644 --- a/web/src/pages/reference.astro +++ b/web/src/pages/reference.astro @@ -101,6 +101,7 @@ const base = import.meta.env.BASE_URL; --output-filestring""Write response to file --raw-output-rboolfalseWith --query, print a string result unquoted and an array of strings one per line, like jq -r. Requires --query. --redactstring[][]Mask a JSON response field before output (repeatable). Dotted path, * matches array elements. Applies to json, auto, table, and jsonl. + --redact-secretsboolfalseMask any JSON field whose name looks sensitive (password, secret, connectionString, accountKey, sasToken, and similar) at any depth. Runs alongside --redact and needs parsed JSON. --flattenboolfalseFlatten a JSON response into a single-level object keyed by dotted paths (properties.provisioningState, value[0].name). Leaf values keep their type. Applies to the json and auto output paths and composes with --query. --dump-headersstring""Write response status line and headers to a file (use - for stderr). Sensitive values are redacted. --binaryboolfalseStream as binary without transformation