Skip to content

Commit 0b93357

Browse files
authored
Merge pull request #672 from fmount/pwd_validation
Add password validation and introduce Validator interface
2 parents cc9e0fc + 51c5d98 commit 0b93357

4 files changed

Lines changed: 436 additions & 0 deletions

File tree

modules/common/secret/password.go

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
/*
2+
Copyright 2026 Red Hat
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package secret
18+
19+
import (
20+
"errors"
21+
"regexp"
22+
)
23+
24+
// PasswordValidator implements the Validator interface
25+
type PasswordValidator struct{}
26+
27+
// Validate - implements the Validator interface and calls the underlying
28+
// ValidatePassword
29+
func (v PasswordValidator) Validate(value string) error {
30+
return ValidatePassword(value)
31+
}
32+
33+
// Rule - pattern to match when rejecting or accepting a string
34+
// +kubebuilder:object:generate=false
35+
type Rule struct {
36+
description string
37+
pattern regexp.Regexp
38+
}
39+
40+
// the requirements variable defines password complexity rules that must be
41+
// satisfied. It is currently empty to allow any password content that does
42+
// not contain shell expansion patterns.
43+
// Add rules as needed based on security requirements.
44+
//
45+
// Example requirements:
46+
//
47+
// {
48+
// description: "Must contain at least one digit",
49+
// pattern: *regexp.MustCompile(`.*\d.*`),
50+
// },
51+
// {
52+
// description: "Must contain at least one lowercase letter",
53+
// pattern: *regexp.MustCompile(`.*[a-z].*`),
54+
// },
55+
// {
56+
// description: "Must contain at least one uppercase letter",
57+
// pattern: *regexp.MustCompile(`.*[A-Z].*`),
58+
// },
59+
// {
60+
// description: "Must be at least 8 characters long",
61+
// pattern: *regexp.MustCompile(`^.{8,}$`),
62+
// },
63+
var requirements []Rule = []Rule{}
64+
65+
var rejects []Rule = []Rule{
66+
{
67+
description: "Password contains shell expansion patterns ($variable, ${variable}, $(command), `command`)",
68+
pattern: *regexp.MustCompile(`\$[A-Za-z_][A-Za-z0-9_]*|\$\{[^}]*\}|\$\([^)]*\)|` + "`[^`]*`"),
69+
},
70+
}
71+
72+
var (
73+
// ErrEmptyPassword -
74+
ErrEmptyPassword = errors.New("empty password not allowed")
75+
// ErrPasswordRequirements -
76+
ErrPasswordRequirements = errors.New("password does not meet the requirements")
77+
)
78+
79+
// ValidatePassword validates a password against security requirements
80+
// Returns error when invalid/rejected
81+
func ValidatePassword(pwd string) error {
82+
// Check if password is empty
83+
if pwd == "" {
84+
return ErrEmptyPassword
85+
}
86+
87+
for _, rule := range requirements {
88+
if !rule.pattern.MatchString(pwd) {
89+
return ErrPasswordRequirements
90+
}
91+
}
92+
93+
for _, rule := range rejects {
94+
if rule.pattern.MatchString(pwd) {
95+
return ErrPasswordRequirements
96+
}
97+
}
98+
return nil
99+
}
Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
/*
2+
Copyright 2026 Red Hat
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package secret
18+
19+
import (
20+
. "github.com/onsi/gomega" // nolint:revive
21+
"regexp"
22+
"testing"
23+
)
24+
25+
const ErrMsg string = "password does not meet the requirements"
26+
27+
var testRequirements []Rule = []Rule{
28+
{
29+
description: "Must contain at least one digit",
30+
pattern: *regexp.MustCompile(`.*\d.*`),
31+
},
32+
{
33+
description: "Must contain at least one lowercase letter",
34+
pattern: *regexp.MustCompile(`.*[a-z].*`),
35+
},
36+
{
37+
description: "Must contain at least one uppercase letter",
38+
pattern: *regexp.MustCompile(`.*[A-Z].*`),
39+
},
40+
{
41+
description: "Must be at least 8 characters long",
42+
pattern: *regexp.MustCompile(`^.{8,}$`),
43+
},
44+
}
45+
46+
var testRejects []Rule = []Rule{
47+
{
48+
description: "Password contains shell expansion patterns ($variable, ${variable}, $(command), `command`)",
49+
pattern: *regexp.MustCompile(`\$[A-Za-z_][A-Za-z0-9_]*|\$\{[^}]*\}|\$\([^)]*\)|` + "`[^`]*`"),
50+
},
51+
}
52+
53+
func TestValidatePassword(t *testing.T) {
54+
// Save original values
55+
originalRequirements := requirements
56+
originalRejects := rejects
57+
58+
// Validate testRequirements and testRejects rules
59+
requirements = testRequirements
60+
rejects = testRejects
61+
62+
// Restore after test
63+
defer func() {
64+
requirements = originalRequirements
65+
rejects = originalRejects
66+
}()
67+
68+
tests := []struct {
69+
name string
70+
password string
71+
wantErr bool
72+
errMsg string
73+
}{
74+
// Valid password scenarios
75+
{
76+
name: "valid password with all requirements",
77+
password: "Password123",
78+
wantErr: false,
79+
},
80+
{
81+
name: "valid password with minimum length",
82+
password: "Abcdef12",
83+
wantErr: false,
84+
},
85+
{
86+
name: "valid password with longer length",
87+
password: "MySecurePassword123",
88+
wantErr: false,
89+
},
90+
{
91+
name: "valid password with allowed special characters",
92+
password: "Password123!@+=._-",
93+
wantErr: false,
94+
},
95+
{
96+
name: "valid password with semicolon",
97+
password: "Password123;allowed",
98+
wantErr: false,
99+
},
100+
{
101+
name: "valid password with angle brackets",
102+
password: "Password123<valid>",
103+
wantErr: false,
104+
},
105+
{
106+
name: "valid password with caret character",
107+
password: "Password123^valid",
108+
wantErr: false,
109+
},
110+
{
111+
name: "valid password with percent character",
112+
password: "Password123%valid",
113+
wantErr: false,
114+
},
115+
{
116+
name: "valid password with safe metacharacters",
117+
password: "Password123*?{}[]|&~#'\"",
118+
wantErr: false,
119+
},
120+
{
121+
name: "valid password with isolated dollar and number",
122+
password: "Password123$123",
123+
wantErr: false,
124+
},
125+
126+
// Invalid password scenarios - shell expansion patterns
127+
{
128+
name: "password made by all numbers",
129+
password: "12345678",
130+
wantErr: true,
131+
},
132+
{
133+
name: "empty password",
134+
password: "",
135+
wantErr: true,
136+
errMsg: "empty password not allowed",
137+
},
138+
{
139+
name: "password without uppercase",
140+
password: "password123",
141+
wantErr: true,
142+
},
143+
{
144+
name: "password without lowercase",
145+
password: "PASSWORD123",
146+
wantErr: true,
147+
},
148+
{
149+
name: "password without digit",
150+
password: "PasswordABC",
151+
wantErr: true,
152+
},
153+
{
154+
name: "password too short",
155+
password: "Pass12",
156+
wantErr: true,
157+
},
158+
{
159+
name: "password with variable expansion",
160+
password: "Password123$HOME",
161+
wantErr: true,
162+
errMsg: ErrMsg,
163+
},
164+
{
165+
name: "password with variable expansion underscore",
166+
password: "Password123$USER_NAME",
167+
wantErr: true,
168+
errMsg: ErrMsg,
169+
},
170+
{
171+
name: "password with braced variable expansion",
172+
password: "Password123${HOME}",
173+
wantErr: true,
174+
errMsg: ErrMsg,
175+
},
176+
{
177+
name: "password with command substitution",
178+
password: "Password123$(echo bad)",
179+
wantErr: true,
180+
errMsg: ErrMsg,
181+
},
182+
{
183+
name: "password with backtick command substitution",
184+
password: "Password123`echo bad`",
185+
wantErr: true,
186+
errMsg: ErrMsg,
187+
},
188+
{
189+
name: "password with complex variable expansion",
190+
password: "Password123${VARIABLE_NAME}",
191+
wantErr: true,
192+
errMsg: ErrMsg,
193+
},
194+
{
195+
name: "shell expansion attack example",
196+
password: "c^sometext02%text%text02$someText&",
197+
wantErr: true,
198+
errMsg: ErrMsg,
199+
},
200+
}
201+
for _, tt := range tests {
202+
t.Run(tt.name, func(t *testing.T) {
203+
g := NewWithT(t)
204+
205+
err := ValidatePassword(tt.password)
206+
207+
if tt.wantErr {
208+
g.Expect(err).To(HaveOccurred())
209+
g.Expect(err.Error()).To(ContainSubstring(tt.errMsg))
210+
} else {
211+
g.Expect(err).ToNot(HaveOccurred())
212+
}
213+
})
214+
}
215+
}

modules/common/secret/secret.go

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,21 @@ import (
3737
"sigs.k8s.io/controller-runtime/pkg/log"
3838
)
3939

40+
// Validator - interface implemented by specialized validators
41+
// +kubebuilder:object:generate=false
42+
type Validator interface {
43+
Validate(value string) error
44+
}
45+
46+
// NoOpValidator for fields that don't need validation
47+
type NoOpValidator struct{}
48+
49+
// Validate - Validation function implemented by NoOpValidator. It can be used
50+
// by service operators when no validation is required on a secret field.
51+
func (v NoOpValidator) Validate(value string) error {
52+
return nil
53+
}
54+
4055
// Hash function creates a hash of a Secret's Data and StringData fields and
4156
// returns it as a safe encoded string.
4257
func Hash(secret *corev1.Secret) (string, error) {
@@ -447,3 +462,57 @@ func VerifySecret(
447462

448463
return hash, ctrl.Result{}, nil
449464
}
465+
466+
// VerifySecretFields - verifies if the Secret object exists and the expected
467+
// fields are in the Secret. It also performs field validation using provided
468+
// validators and returns an error if validation fails. Returns a hash of the
469+
// values of the expected fields.
470+
//
471+
// Example usage for password validation:
472+
//
473+
// validators := map[string]Validator{
474+
// "password": PasswordValidator{},
475+
// "username": NoOpValidator{}, // no validation needed
476+
// }
477+
// hash, result, err := secret.VerifySecretFields(
478+
// ctx, secretName, validators, reader, timeout)
479+
func VerifySecretFields(
480+
ctx context.Context,
481+
secretName types.NamespacedName,
482+
expectedFields map[string]Validator,
483+
reader client.Reader,
484+
requeueTimeout time.Duration,
485+
) (string, ctrl.Result, error) {
486+
secret := &corev1.Secret{}
487+
err := reader.Get(ctx, secretName, secret)
488+
if err != nil {
489+
if k8s_errors.IsNotFound(err) {
490+
log.FromContext(ctx).Info("Secret not found", "secretName", secretName)
491+
return "",
492+
ctrl.Result{RequeueAfter: requeueTimeout},
493+
nil
494+
}
495+
return "", ctrl.Result{}, fmt.Errorf("get secret %s failed: %w", secretName, err)
496+
}
497+
498+
// collect the secret values the caller expects to exist
499+
values := [][]byte{}
500+
for field, validator := range expectedFields {
501+
val, ok := secret.Data[field]
502+
if !ok {
503+
err := fmt.Errorf("%w: field %s not found in Secret %s", util.ErrFieldNotFound, field, secretName)
504+
return "", ctrl.Result{}, err
505+
}
506+
if err := validator.Validate(string(val)); err != nil {
507+
return "", ctrl.Result{}, err
508+
}
509+
values = append(values, val)
510+
}
511+
512+
hash, err := util.ObjectHash(values)
513+
if err != nil {
514+
return "", ctrl.Result{}, err
515+
}
516+
517+
return hash, ctrl.Result{}, nil
518+
}

0 commit comments

Comments
 (0)