Skip to content

Commit 831834c

Browse files
c0deltinwerne2j
authored andcommitted
feat: add secret-path validation
Signed-off-by: Valentin Süß <[email protected]>
1 parent ec1bd7a commit 831834c

File tree

8 files changed

+249
-13
lines changed

8 files changed

+249
-13
lines changed

cmd/generate.go

+9-1
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package cmd
22

33
import (
44
"fmt"
5+
"regexp"
56
"strconv"
67
"strings"
78

@@ -76,8 +77,15 @@ func NewGenerateCommand() *cobra.Command {
7677
}
7778

7879
for _, manifest := range manifests {
80+
var pathValidation *regexp.Regexp
81+
if rexp := v.GetString(types.EnvPathValidation); rexp != "" {
82+
pathValidation, err = regexp.Compile(rexp)
83+
if err != nil {
84+
return fmt.Errorf("%s is not a valid regular expression: %s", rexp, err)
85+
}
86+
}
7987

80-
template, err := kube.NewTemplate(manifest, cmdConfig.Backend)
88+
template, err := kube.NewTemplate(manifest, cmdConfig.Backend, pathValidation)
8189
if err != nil {
8290
return err
8391
}

cmd/generate_test.go

+27
Original file line numberDiff line numberDiff line change
@@ -224,10 +224,37 @@ func TestMain(t *testing.T) {
224224
}
225225
})
226226

227+
t.Run("will return that path validation env is not valid", func(t *testing.T) {
228+
args := []string{"../fixtures/input/nonempty"}
229+
cmd := NewGenerateCommand()
230+
231+
// set specific env and register cleanup func
232+
os.Setenv("AVP_PATH_VALIDATION", `^\/(?!\/)(.*?)`)
233+
t.Cleanup(func() {
234+
os.Unsetenv("AVP_PATH_VALIDATION")
235+
})
236+
237+
b := bytes.NewBufferString("")
238+
cmd.SetArgs(args)
239+
cmd.SetErr(b)
240+
cmd.SetOut(bytes.NewBufferString(""))
241+
cmd.Execute()
242+
out, err := ioutil.ReadAll(b) // Read buffer to bytes
243+
if err != nil {
244+
t.Fatal(err)
245+
}
246+
247+
expected := "^\\/(?!\\/)(.*?) is not a valid regular expression: error parsing regexp: invalid or unsupported Perl syntax: `(?!`"
248+
if !strings.Contains(string(out), expected) {
249+
t.Fatalf("expected to contain: %s but got %s", expected, out)
250+
}
251+
})
252+
227253
os.Unsetenv("AVP_TYPE")
228254
os.Unsetenv("VAULT_ADDR")
229255
os.Unsetenv("AVP_AUTH_TYPE")
230256
os.Unsetenv("AVP_SECRET_ID")
231257
os.Unsetenv("AVP_ROLE_ID")
232258
os.Unsetenv("VAULT_SKIP_VERIFY")
259+
os.Unsetenv("AVP_PATH_VALIDATION")
233260
}

docs/config.md

+3-2
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ We support all the backend specific environment variables each backend's SDK wil
7272
We also support these AVP specific variables:
7373

7474
| Name | Description | Notes |
75-
| -------------------------- | --------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
75+
| -------------------------- |-----------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
7676
| AVP_TYPE | The type of Vault backend | Supported values: `vault`, `ibmsecretsmanager`, `awssecretsmanager`, `gcpsecretmanager`, `yandexcloudlockbox` and `1passwordconnect` |
7777
| AVP_KV_VERSION | The vault secret engine | Supported values: `1` and `2` (defaults to 2). KV_VERSION will be ignored if the `avp.kubernetes.io/kv-version` annotation is present in a YAML resource. |
7878
| AVP_AUTH_TYPE | The type of authentication | Supported values: vault: `approle, github, k8s, token`. Only honored for `AVP_TYPE` of `vault` |
@@ -89,6 +89,7 @@ We also support these AVP specific variables:
8989
| AVP_YCL_SERVICE_ACCOUNT_ID | Yandex Cloud Lockbox service account ID | Required with `TYPE` of `yandexcloudlockbox` |
9090
| AVP_YCL_KEY_ID | Yandex Cloud Lockbox service account Key ID | Required with `TYPE` of `yandexcloudlockbox` |
9191
| AVP_YCL_PRIVATE_KEY | Yandex Cloud Lockbox service account private key | Required with `TYPE` of `yandexcloudlockbox` |
92+
| AVP_PATH_VALIDATION | Regular Expression to validate the Vault path | Optional. Can be used for e.g. to prevent path traversals. |
9293

9394
### Full List of Supported Annotation
9495

@@ -237,4 +238,4 @@ spec:
237238
value: foo-team-namespace
238239
```
239240

240-
**Note**: Exposing tokens (like `AVP_ROLE_ID` or `AVP_SECRET_ID`) in plain-text in Argo CD app manifests should be avoided. Prefer to pass those tokens through one of the means mentioned above.
241+
**Note**: Exposing tokens (like `AVP_ROLE_ID` or `AVP_SECRET_ID`) in plain-text in Argo CD app manifests should be avoided. Prefer to pass those tokens through one of the means mentioned above.

pkg/kube/template.go

+12-6
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package kube
22

33
import (
44
"fmt"
5+
"regexp"
56
"strings"
67

78
"github.com/argoproj-labs/argocd-vault-plugin/pkg/types"
@@ -18,6 +19,7 @@ type Resource struct {
1819
replacementErrors []error // Any errors encountered in performing replacements
1920
Data map[string]interface{} // The data to replace with, from Vault
2021
Annotations map[string]string
22+
PathValidation *regexp.Regexp
2123
}
2224

2325
// Template is the template for Kubernetes
@@ -26,14 +28,17 @@ type Template struct {
2628
}
2729

2830
// NewTemplate returns a *Template given the template's data, and a VaultType
29-
func NewTemplate(template unstructured.Unstructured, backend types.Backend) (*Template, error) {
31+
func NewTemplate(template unstructured.Unstructured, backend types.Backend, pathValidation *regexp.Regexp) (*Template, error) {
3032
annotations := template.GetAnnotations()
3133
path := annotations[types.AVPPathAnnotation]
3234
version := annotations[types.AVPSecretVersionAnnotation]
3335

3436
var err error
3537
var data map[string]interface{}
3638
if path != "" {
39+
if pathValidation != nil && !pathValidation.MatchString(path) {
40+
return nil, fmt.Errorf("the path %s is disallowed by %s restriction", path, types.EnvPathValidation)
41+
}
3742
data, err = backend.GetSecrets(path, version, annotations)
3843
if err != nil {
3944
return nil, err
@@ -44,11 +49,12 @@ func NewTemplate(template unstructured.Unstructured, backend types.Backend) (*Te
4449

4550
return &Template{
4651
Resource{
47-
Kind: template.GetKind(),
48-
TemplateData: template.Object,
49-
Backend: backend,
50-
Data: data,
51-
Annotations: annotations,
52+
Kind: template.GetKind(),
53+
TemplateData: template.Object,
54+
Backend: backend,
55+
Data: data,
56+
Annotations: annotations,
57+
PathValidation: pathValidation,
5258
},
5359
}, nil
5460
}

pkg/kube/template_test.go

+108-4
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package kube
33
import (
44
"io/ioutil"
55
"reflect"
6+
"regexp"
67
"strings"
78
"testing"
89

@@ -219,7 +220,7 @@ func TestNewTemplate(t *testing.T) {
219220
},
220221
},
221222
},
222-
}, &mv)
223+
}, &mv, nil)
223224
if template.Resource.Kind != "Service" {
224225
t.Fatalf("template should have Kind of %s, instead it has %s", "Service", template.Resource.Kind)
225226
}
@@ -253,7 +254,7 @@ func TestNewTemplate(t *testing.T) {
253254
},
254255
},
255256
},
256-
}, &mv)
257+
}, &mv, nil)
257258
if mv.GetSecretsCalled {
258259
t.Fatalf("template contains avp.kubernetes.io/ignore:True so GetSecrets should NOT be called")
259260
}
@@ -286,7 +287,7 @@ func TestNewTemplate(t *testing.T) {
286287
"old-value": "<password>",
287288
},
288289
},
289-
}, &mv)
290+
}, &mv, nil)
290291

291292
if template.Resource.Kind != "Secret" {
292293
t.Fatalf("template should have Kind of %s, instead it has %s", "Secret", template.Resource.Kind)
@@ -349,7 +350,110 @@ func TestNewTemplate(t *testing.T) {
349350
"new-value": "<password>",
350351
},
351352
},
352-
}, &mv)
353+
}, &mv, nil)
354+
355+
if template.Resource.Kind != "Secret" {
356+
t.Fatalf("template should have Kind of %s, instead it has %s", "Secret", template.Resource.Kind)
357+
}
358+
359+
if !mv.GetSecretsCalled {
360+
t.Fatalf("template does contain <placeholders> so GetSecrets should be called")
361+
}
362+
363+
err := template.Replace()
364+
if err != nil {
365+
t.Fatalf(err.Error())
366+
}
367+
368+
expected := map[string]interface{}{
369+
"kind": "Secret",
370+
"apiVersion": "v1",
371+
"metadata": map[string]interface{}{
372+
"annotations": map[string]interface{}{
373+
types.AVPPathAnnotation: "path/to/secret",
374+
},
375+
"namespace": "default",
376+
"name": "my-app",
377+
},
378+
"data": map[string]interface{}{
379+
"new-value": "changed-value",
380+
"old-value": "original-value",
381+
},
382+
}
383+
384+
if !reflect.DeepEqual(expected, template.TemplateData) {
385+
t.Fatalf("expected %s got %s", expected, template.TemplateData)
386+
}
387+
})
388+
389+
t.Run("GetSecrets with path validation and invalid path", func(t *testing.T) {
390+
mv := helpers.MockVault{}
391+
392+
mv.LoadData(map[string]interface{}{
393+
"password": "original-value",
394+
})
395+
mv.LoadData(map[string]interface{}{
396+
"password": "changed-value",
397+
})
398+
399+
template, err := NewTemplate(unstructured.Unstructured{
400+
Object: map[string]interface{}{
401+
"kind": "Secret",
402+
"apiVersion": "v1",
403+
"metadata": map[string]interface{}{
404+
"annotations": map[string]interface{}{
405+
types.AVPPathAnnotation: "path/to/secret",
406+
},
407+
"namespace": "default",
408+
"name": "my-app",
409+
},
410+
"data": map[string]interface{}{
411+
"old-value": "<path:/path/to/secret#password#1>",
412+
"new-value": "<password>",
413+
},
414+
},
415+
}, &mv, regexp.MustCompile(`/[A-Z]/`))
416+
417+
if template != nil {
418+
t.Fatalf("expected template to be nil got %s", template)
419+
}
420+
if err == nil {
421+
t.Fatalf("expected error got nil")
422+
}
423+
424+
expected := "the path path/to/secret is disallowed by AVP_PATH_VALIDATION restriction"
425+
if err.Error() != expected {
426+
t.Fatalf("expected %s got %s", expected, err.Error())
427+
}
428+
})
429+
430+
t.Run("will GetSecrets with latest version by default and path validation regexp", func(t *testing.T) {
431+
mv := helpers.MockVault{}
432+
433+
mv.LoadData(map[string]interface{}{
434+
"password": "original-value",
435+
})
436+
mv.LoadData(map[string]interface{}{
437+
"password": "changed-value",
438+
})
439+
440+
template, _ := NewTemplate(unstructured.Unstructured{
441+
Object: map[string]interface{}{
442+
"kind": "Secret",
443+
"apiVersion": "v1",
444+
"metadata": map[string]interface{}{
445+
"annotations": map[string]interface{}{
446+
types.AVPPathAnnotation: "path/to/secret",
447+
},
448+
"namespace": "default",
449+
"name": "my-app",
450+
},
451+
"data": map[string]interface{}{
452+
"old-value": "<path:/path/to/secret#password#1>",
453+
"new-value": "<password>",
454+
},
455+
},
456+
}, &mv, regexp.MustCompile(`^([A-Za-z/]*)$`))
353457

354458
if template.Resource.Kind != "Secret" {
355459
t.Fatalf("template should have Kind of %s, instead it has %s", "Secret", template.Resource.Kind)

pkg/kube/util.go

+5
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,11 @@ func genericReplacement(key, value string, resource Resource) (_ interface{}, er
133133
key := indivSecretMatches[indivPlaceholderSyntax.SubexpIndex("key")]
134134
version := indivSecretMatches[indivPlaceholderSyntax.SubexpIndex("version")]
135135

136+
if resource.PathValidation != nil && !resource.PathValidation.MatchString(path) {
137+
err = append(err, fmt.Errorf("the path %s is disallowed by %s restriction", path, types.EnvPathValidation))
138+
return match
139+
}
140+
136141
utils.VerboseToStdErr("calling GetIndividualSecret for secret %s from path %s at version %s", key, path, version)
137142
secretValue, secretErr = resource.Backend.GetIndividualSecret(path, strings.TrimSpace(key), version, resource.Annotations)
138143
if secretErr != nil {

pkg/kube/util_test.go

+84
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"encoding/json"
66
"fmt"
77
"reflect"
8+
"regexp"
89
"testing"
910

1011
"github.com/argoproj-labs/argocd-vault-plugin/pkg/helpers"
@@ -111,6 +112,89 @@ func TestGenericReplacement_specificPath(t *testing.T) {
111112
assertSuccessfulReplacement(&dummyResource, &expected, t)
112113
}
113114

115+
func TestGenericReplacement_specificPathWithValidation(t *testing.T) {
116+
// Test that the specific-path placeholder syntax is used to find/replace placeholders
117+
// along with the generic syntax, since the generic Vault path is defined
118+
mv := helpers.MockVault{}
119+
mv.LoadData(map[string]interface{}{
120+
"namespace": "default",
121+
})
122+
123+
t.Run("valid path", func(t *testing.T) {
124+
dummyResource := Resource{
125+
TemplateData: map[string]interface{}{
126+
"namespace": "<path:blah/blah#namespace>",
127+
"name": "<name>",
128+
},
129+
Data: map[string]interface{}{
130+
"namespace": "something-else",
131+
"name": "foo",
132+
},
133+
Backend: &mv,
134+
Annotations: map[string]string{
135+
(types.AVPPathAnnotation): "",
136+
},
137+
PathValidation: regexp.MustCompile(`^([A-Za-z/]*)$`),
138+
}
139+
140+
replaceInner(&dummyResource, &dummyResource.TemplateData, genericReplacement)
141+
142+
if !mv.GetIndividualSecretCalled {
143+
t.Fatalf("expected GetSecrets to be called since placeholder contains explicit path so Vault lookup is neeed")
144+
}
145+
146+
expected := Resource{
147+
TemplateData: map[string]interface{}{
148+
"namespace": "default",
149+
"name": "foo",
150+
},
151+
Data: map[string]interface{}{
152+
"namespace": "something-else",
153+
"name": "foo",
154+
},
155+
replacementErrors: []error{},
156+
}
157+
158+
assertSuccessfulReplacement(&dummyResource, &expected, t)
159+
})
160+
161+
t.Run("invalid path", func(t *testing.T) {
162+
dummyResource := Resource{
163+
TemplateData: map[string]interface{}{
164+
"namespace": "<path:../blah/blah#namespace>",
165+
},
166+
Data: map[string]interface{}{
167+
"namespace": "something-else",
168+
},
169+
Backend: &mv,
170+
Annotations: map[string]string{
171+
(types.AVPPathAnnotation): "",
172+
},
173+
PathValidation: regexp.MustCompile(`^([A-Za-z/]*)$`),
174+
}
175+
176+
replaceInner(&dummyResource, &dummyResource.TemplateData, genericReplacement)
177+
178+
if !mv.GetIndividualSecretCalled {
179+
t.Fatalf("expected GetSecrets to be called since placeholder contains explicit path so Vault lookup is neeed")
180+
}
181+
182+
expected := Resource{
183+
TemplateData: map[string]interface{}{
184+
"namespace": "<path:../blah/blah#namespace>",
185+
},
186+
Data: map[string]interface{}{
187+
"namespace": "something-else",
188+
},
189+
replacementErrors: []error{
190+
fmt.Errorf("the path ../blah/blah is disallowed by AVP_PATH_VALIDATION restriction"),
191+
},
192+
}
193+
194+
assertFailedReplacement(&dummyResource, &expected, t)
195+
})
196+
}
197+
114198
func TestGenericReplacement_specificPathVersioned(t *testing.T) {
115199
// Test that the specific-path placeholder syntax with versioning is used to find/replace placeholders
116200
mv := helpers.MockVault{}

0 commit comments

Comments
 (0)