Skip to content

Commit ac935bb

Browse files
committed
redactor add: Add optional filtering like env vars
1 parent 3458da3 commit ac935bb

File tree

2 files changed

+107
-32
lines changed

2 files changed

+107
-32
lines changed

clicommand/redactor_add.go

Lines changed: 32 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ import (
1111
"slices"
1212
"strings"
1313

14+
"github.com/buildkite/agent/v3/env"
15+
"github.com/buildkite/agent/v3/internal/redact"
1416
"github.com/buildkite/agent/v3/jobapi"
1517
"github.com/buildkite/agent/v3/logger"
1618
"github.com/urfave/cli"
@@ -38,8 +40,10 @@ type RedactorAddConfig struct {
3840
GlobalConfig
3941
APIConfig
4042

41-
File string `cli:"arg:0"`
42-
Format string `cli:"format"`
43+
File string `cli:"arg:0"`
44+
Format string `cli:"format"`
45+
ApplyVarsFilter bool `cli:"apply-vars-filter"`
46+
RedactedVars []string `cli:"redacted-vars"`
4347
}
4448

4549
var RedactorAddCommand = cli.Command{
@@ -86,6 +90,12 @@ JSON does not allow duplicate keys. If you repeat the same key ("key"), the JSON
8690
EnvVar: "BUILDKITE_AGENT_REDACT_ADD_FORMAT",
8791
Value: FormatStringNone,
8892
},
93+
cli.BoolFlag{
94+
Name: "apply-vars-filter",
95+
Usage: fmt.Sprintf("When the input is in 'json' format, filters the secrets to redact using the same rules used to detect secrets from environment variables: secrets must be at least %d characters long, and names must match the patterns defined by --redacted-vars or $BUILDKITE_REDACTED_VARS.", redact.LengthMin),
96+
EnvVar: "BUILDKITE_AGENT_REDACT_VARS_FILTER",
97+
},
98+
RedactedVars,
8999
}),
90100
Action: func(c *cli.Context) error {
91101
ctx := context.Background()
@@ -144,14 +154,29 @@ func ParseSecrets(
144154
) ([]string, error) {
145155
switch cfg.Format {
146156
case FormatStringJSON:
147-
secrets := &map[string]string{}
157+
secrets := map[string]string{}
148158
if err := json.NewDecoder(secretsReader).Decode(&secrets); err != nil {
149159
return nil, fmt.Errorf("failed to parse as string valued JSON: %w", err)
150160
}
151161

152-
parsedSecrets := make([]string, 0, len(*secrets))
153-
for _, secret := range *secrets {
154-
parsedSecrets = append(parsedSecrets, secret)
162+
var parsedSecrets []string
163+
if cfg.ApplyVarsFilter {
164+
matched, short, err := redact.Vars(cfg.RedactedVars, env.FromMap(secrets).DumpPairs())
165+
if err != nil {
166+
return nil, fmt.Errorf("couldn't match object keys against redacted-vars: %w", err)
167+
}
168+
if len(short) > 0 {
169+
l.Warn("Some object keys had values below minimum length (%d bytes) and will not be redacted: %s", redact.LengthMin, strings.Join(short, ", "))
170+
}
171+
parsedSecrets = make([]string, 0, len(matched))
172+
for _, m := range matched {
173+
parsedSecrets = append(parsedSecrets, m.Value)
174+
}
175+
} else {
176+
parsedSecrets = make([]string, 0, len(secrets))
177+
for _, secret := range secrets {
178+
parsedSecrets = append(parsedSecrets, secret)
179+
}
155180
}
156181

157182
return parsedSecrets, nil
@@ -165,7 +190,7 @@ func ParseSecrets(
165190
return []string{strings.TrimSpace(string(readSecret))}, nil
166191

167192
default:
168-
return nil, fmt.Errorf("%s: %w", cfg.Format, errUnknownFormat)
193+
return nil, fmt.Errorf("%w %q", errUnknownFormat, cfg.Format)
169194
}
170195
}
171196

clicommand/redactor_add_test.go

Lines changed: 75 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,58 +1,108 @@
11
package clicommand_test
22

33
import (
4+
"encoding/json"
5+
"errors"
46
"slices"
57
"strings"
68
"testing"
79

810
"github.com/buildkite/agent/v3/clicommand"
911
"github.com/buildkite/agent/v3/logger"
10-
"gotest.tools/v3/assert"
12+
"github.com/google/go-cmp/cmp"
1113
)
1214

1315
func TestParseSecrets(t *testing.T) {
1416
t.Parallel()
1517

1618
for _, tc := range []struct {
17-
name string
18-
inputData string
19-
formatString string
20-
expectedSecrets []string
21-
errorTextContains string
19+
name string
20+
inputData string
21+
formatString string
22+
applyVarsFilter bool
23+
wantSecrets []string
2224
}{
2325
{
24-
name: "json",
25-
inputData: `{"hello": "world", "password": "hunter2"}`,
26-
formatString: clicommand.FormatStringJSON,
27-
expectedSecrets: []string{"world", "hunter2"},
26+
name: "json",
27+
inputData: `{"hello": "world", "password": "hunter2"}`,
28+
formatString: clicommand.FormatStringJSON,
29+
wantSecrets: []string{"world", "hunter2"},
2830
},
2931
{
30-
name: "plaintext",
31-
inputData: "hunter2\n",
32-
formatString: clicommand.FormatStringNone,
33-
expectedSecrets: []string{"hunter2"},
32+
name: "plaintext",
33+
inputData: "hunter2\n",
34+
formatString: clicommand.FormatStringNone,
35+
wantSecrets: []string{"hunter2"},
3436
},
37+
3538
{
36-
name: "invalid_json",
37-
inputData: `{"hello": 1, "password": "hunter2"}`,
38-
formatString: clicommand.FormatStringJSON,
39-
errorTextContains: "failed to parse as string valued JSON",
39+
name: "vars filter",
40+
inputData: `{"HELLO": "1", "MY_PASSWORD": "hunter2"}`,
41+
applyVarsFilter: true,
42+
formatString: clicommand.FormatStringJSON,
43+
wantSecrets: []string{"hunter2"},
4044
},
4145
} {
4246
t.Run(tc.name, func(t *testing.T) {
4347
t.Parallel()
4448

4549
input := strings.NewReader(tc.inputData)
46-
secrets, err := clicommand.ParseSecrets(logger.Discard, clicommand.RedactorAddConfig{Format: tc.formatString}, input)
47-
if tc.errorTextContains != "" {
48-
assert.ErrorContains(t, err, tc.errorTextContains)
49-
return
50+
secrets, err := clicommand.ParseSecrets(
51+
logger.Discard,
52+
clicommand.RedactorAddConfig{
53+
Format: tc.formatString,
54+
ApplyVarsFilter: tc.applyVarsFilter,
55+
RedactedVars: *clicommand.RedactedVars.Value,
56+
},
57+
input,
58+
)
59+
if err != nil {
60+
t.Errorf("clicommand.ParseSecrets(logger, cfg, %q) error = %v", input, err)
5061
}
51-
assert.NilError(t, err)
5262

5363
slices.Sort(secrets)
54-
slices.Sort(tc.expectedSecrets)
55-
assert.DeepEqual(t, secrets, tc.expectedSecrets)
64+
slices.Sort(tc.wantSecrets)
65+
if diff := cmp.Diff(secrets, tc.wantSecrets); diff != "" {
66+
t.Errorf("clicommand.ParseSecrets(logger, cfg, %q) secrets diff (-got +want):\n%s", input, diff)
67+
}
68+
})
69+
}
70+
}
71+
72+
func TestParseSecrets_JSON(t *testing.T) {
73+
t.Parallel()
74+
75+
for _, tc := range []struct {
76+
name string
77+
inputData string
78+
wantError any
79+
}{
80+
{
81+
name: "type mismatch",
82+
inputData: `{"hello": 1, "password": "hunter2"}`,
83+
wantError: new(*json.UnmarshalTypeError),
84+
},
85+
{
86+
name: "syntax error",
87+
inputData: `}}{"hello": , "pas: "hun'}`,
88+
wantError: new(*json.SyntaxError),
89+
},
90+
} {
91+
t.Run(tc.name, func(t *testing.T) {
92+
t.Parallel()
93+
94+
input := strings.NewReader(tc.inputData)
95+
96+
_, err := clicommand.ParseSecrets(
97+
logger.Discard,
98+
clicommand.RedactorAddConfig{
99+
Format: clicommand.FormatStringJSON,
100+
},
101+
input,
102+
)
103+
if !errors.As(err, tc.wantError) {
104+
t.Errorf("clicommand.ParseSecrets(logger, cfg, %q) error = %v, want error wrapping %T", input, err, tc.wantError)
105+
}
56106
})
57107
}
58108
}

0 commit comments

Comments
 (0)