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
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 3 additions & 0 deletions cli/src/internal/cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ var (
include bool
allowHosts []string
redactPaths []string
redactSecrets bool
tableColumns []string
dumpHeaders string
fail bool
Expand Down Expand Up @@ -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)")
Expand Down Expand Up @@ -299,6 +301,7 @@ func snapshotConfig() config.Config {
Include: include,
AllowedHosts: allowHosts,
Redact: redactPaths,
RedactSecrets: redactSecrets,
TableColumns: tableColumns,
DumpHeaders: dumpHeaders,
Fail: fail,
Expand Down
1 change: 1 addition & 0 deletions cli/src/internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ type Config struct {
Include bool
AllowedHosts []string
Redact []string
RedactSecrets bool
TableColumns []string
DumpHeaders string
Fail bool
Expand Down
99 changes: 99 additions & 0 deletions cli/src/internal/service/redact_secrets.go
Original file line number Diff line number Diff line change
@@ -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()
}
159 changes: 159 additions & 0 deletions cli/src/internal/service/redact_secrets_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
15 changes: 15 additions & 0 deletions cli/src/internal/service/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions web/src/pages/reference.astro
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ const base = import.meta.env.BASE_URL;
<tr><td><code>--output-file</code></td><td></td><td>string</td><td>""</td><td>Write response to file</td></tr>
<tr><td><code>--raw-output</code></td><td><code>-r</code></td><td>bool</td><td>false</td><td>With <code>--query</code>, print a string result unquoted and an array of strings one per line, like <code>jq -r</code>. Requires <code>--query</code>.</td></tr>
<tr><td><code>--redact</code></td><td></td><td>string[]</td><td>[]</td><td>Mask a JSON response field before output (repeatable). Dotted path, <code>*</code> matches array elements. Applies to <code>json</code>, <code>auto</code>, <code>table</code>, and <code>jsonl</code>.</td></tr>
<tr><td><code>--redact-secrets</code></td><td></td><td>bool</td><td>false</td><td>Mask any JSON field whose name looks sensitive (<code>password</code>, <code>secret</code>, <code>connectionString</code>, <code>accountKey</code>, <code>sasToken</code>, and similar) at any depth. Runs alongside <code>--redact</code> and needs parsed JSON.</td></tr>
<tr><td><code>--flatten</code></td><td></td><td>bool</td><td>false</td><td>Flatten a JSON response into a single-level object keyed by dotted paths (<code>properties.provisioningState</code>, <code>value[0].name</code>). Leaf values keep their type. Applies to the <code>json</code> and <code>auto</code> output paths and composes with <code>--query</code>.</td></tr>
<tr><td><code>--dump-headers</code></td><td></td><td>string</td><td>""</td><td>Write response status line and headers to a file (use <code>-</code> for stderr). Sensitive values are redacted.</td></tr>
<tr><td><code>--binary</code></td><td></td><td>bool</td><td>false</td><td>Stream as binary without transformation</td></tr>
Expand Down
Loading