From 0eba045104807883a65ece3986370ea7d5fed396 Mon Sep 17 00:00:00 2001 From: yusing Date: Tue, 24 Feb 2026 15:17:28 +0800 Subject: [PATCH] feat(rules): add $redacted dynamic variable for masking sensitive values Introduces a new `$redacted` dynamic variable that wraps its single argument with `strutils.Redact`, allowing sensitive values (e.g., authorization headers, query parameters) to be masked in rule expressions. The variable accepts exactly one argument, which may itself be a nested dynamic variable expression such as `$header(Authorization)` or `$arg(token)`, enabling patterns like `$redacted($header(Authorization))`. Adds corresponding tests covering plain string redaction, nested header and query arg wrapping, and the error case when no argument is provided. --- internal/route/rules/vars_dynamic.go | 13 +++++ internal/route/rules/vars_test.go | 79 ++++++++++++++++++++++++++++ 2 files changed, 92 insertions(+) diff --git a/internal/route/rules/vars_dynamic.go b/internal/route/rules/vars_dynamic.go index 075d030a..775a93c1 100644 --- a/internal/route/rules/vars_dynamic.go +++ b/internal/route/rules/vars_dynamic.go @@ -6,6 +6,7 @@ import ( "strconv" httputils "github.com/yusing/goutils/http" + strutils "github.com/yusing/goutils/strings" ) var ( @@ -15,6 +16,7 @@ var ( VarQuery = "arg" VarForm = "form" VarPostForm = "postform" + VarRedacted = "redacted" ) type dynamicVarGetter struct { @@ -94,6 +96,17 @@ var dynamicVarSubsMap = map[string]dynamicVarGetter{ return getValueByKeyAtIndex(req.PostForm, key, index) }, }, + // VarRedacted wraps the result of its single argument (which may be another dynamic var + // expression, already expanded by expandArgs) with strutils.Redact. + VarRedacted: { + phase: PhaseNone, + get: func(args []string, w *httputils.ResponseModifier, req *http.Request) (string, error) { + if len(args) != 1 { + return "", ErrExpectOneArg + } + return strutils.Redact(args[0]), nil + }, + }, } func getValueByKeyAtIndex[Values http.Header | url.Values](values Values, key string, index int) (string, error) { diff --git a/internal/route/rules/vars_test.go b/internal/route/rules/vars_test.go index 279a51ad..b4d6c251 100644 --- a/internal/route/rules/vars_test.go +++ b/internal/route/rules/vars_test.go @@ -189,6 +189,64 @@ func TestExtractArgs(t *testing.T) { } } +func TestExtractArgs_NestedFunc(t *testing.T) { + tests := []struct { + name string + src string + startPos int + funcName string + wantArgs []string + wantNextIdx int + wantErr bool + }{ + { + name: "nested func as single arg", + src: "redacted($header(Authorization))", + startPos: 0, + funcName: "redacted", + wantArgs: []string{"$header(Authorization)"}, + wantNextIdx: 31, + }, + { + name: "nested func with quoted arg inside", + src: `redacted($header("X-Secret"))`, + startPos: 0, + funcName: "redacted", + wantArgs: []string{`$header("X-Secret")`}, + wantNextIdx: 28, + }, + { + name: "nested func with two args inside", + src: "redacted($header(X-Multi, 1))", + startPos: 0, + funcName: "redacted", + wantArgs: []string{"$header(X-Multi, 1)"}, + wantNextIdx: 28, + }, + { + name: "nested func missing closing paren", + src: "redacted($header(Authorization)", + startPos: 0, + funcName: "redacted", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + args, nextIdx, err := extractArgs(tt.src, tt.startPos, tt.funcName) + + if tt.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + require.Equal(t, tt.wantArgs, args) + require.Equal(t, tt.wantNextIdx, nextIdx) + } + }) + } +} + func TestExpandVars(t *testing.T) { // Create a comprehensive test request with form data formData := url.Values{} @@ -446,6 +504,27 @@ func TestExpandVars(t *testing.T) { input: "Header: $header(User-Agent), Status: $status_code", want: "Header: test-agent/1.0, Status: 200", }, + // $redacted function + { + name: "redacted with plain string arg", + input: "$redacted(secret)", + want: "se**et", + }, + { + name: "redacted wrapping header", + input: "$redacted($header(User-Agent))", + want: "te**********.0", + }, + { + name: "redacted wrapping arg", + input: "$redacted($arg(param1))", + want: "va**e1", + }, + { + name: "redacted with no args", + input: "$redacted()", + wantErr: true, + }, // Escaped dollar signs { name: "escaped dollar",