Skip to content

Commit f38e6c8

Browse files
committed
Enable custom password requirements and rejects
Update the Password validation interface so that a one can specify custom requirements, rejects or both. Specifying rules is optional, if none are provided, the validation will be performed by enforcing the default rules defined in the libcommon's package. Assisted-By: Claude (claude-sonnet-4.5)
1 parent e3be8a4 commit f38e6c8

3 files changed

Lines changed: 154 additions & 31 deletions

File tree

modules/common/secret/password.go

Lines changed: 24 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -22,12 +22,12 @@ import (
2222
)
2323

2424
// 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)
25+
// +kubebuilder:object:generate=false
26+
type PasswordValidator struct {
27+
// Patterns that the password should adhere to
28+
Requirements *[]Rule
29+
// Patterns that are forbidden for the password
30+
Rejects *[]Rule
3131
}
3232

3333
// Rule - pattern to match when rejecting or accepting a string
@@ -76,22 +76,32 @@ var (
7676
ErrPasswordRequirements = errors.New("password does not meet the requirements")
7777
)
7878

79-
// ValidatePassword validates a password against security requirements
80-
// Returns error when invalid/rejected
81-
func ValidatePassword(pwd string) error {
79+
// Validate - implements the Validator interface
80+
// If requirements or rejects rules are not specified in the
81+
// structure, the function uses the default rule set defined
82+
// in this package.
83+
func (v PasswordValidator) Validate(value string) error {
8284
// Check if password is empty
83-
if pwd == "" {
85+
if value == "" {
8486
return ErrEmptyPassword
8587
}
8688

87-
for _, rule := range requirements {
88-
if !rule.pattern.MatchString(pwd) {
89+
reqs := &requirements
90+
if v.Requirements != nil {
91+
reqs = v.Requirements
92+
}
93+
for _, rule := range *reqs {
94+
if !rule.pattern.MatchString(value) {
8995
return ErrPasswordRequirements
9096
}
9197
}
9298

93-
for _, rule := range rejects {
94-
if rule.pattern.MatchString(pwd) {
99+
rejs := &rejects
100+
if v.Rejects != nil {
101+
rejs = v.Rejects
102+
}
103+
for _, rule := range *rejs {
104+
if rule.pattern.MatchString(value) {
95105
return ErrPasswordRequirements
96106
}
97107
}

modules/common/secret/password_test.go

Lines changed: 130 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,11 @@ limitations under the License.
1717
package secret
1818

1919
import (
20-
. "github.com/onsi/gomega" // nolint:revive
2120
"regexp"
2221
"slices"
2322
"testing"
23+
24+
. "github.com/onsi/gomega" // nolint:revive
2425
)
2526

2627
const ErrMsg string = "password does not meet the requirements"
@@ -279,12 +280,15 @@ func TestValidatePassword(t *testing.T) {
279280
alphaNumPattern,
280281
fernetPattern,
281282
)
283+
284+
validator := PasswordValidator{}
285+
282286
// Execute ValidatePassword against the generated TestVector
283287
for _, tt := range tests {
284288
t.Run(tt.name, func(t *testing.T) {
285289
g := NewWithT(t)
286290

287-
err := ValidatePassword(tt.password)
291+
err := validator.Validate(tt.password)
288292

289293
if tt.wantErr {
290294
g.Expect(err).To(HaveOccurred())
@@ -295,3 +299,127 @@ func TestValidatePassword(t *testing.T) {
295299
})
296300
}
297301
}
302+
303+
func TestValidatePasswordWithCustomRules(t *testing.T) {
304+
// Define custom requirements (strict rules)
305+
customRequirements := []Rule{
306+
{
307+
description: "Must only contain alphanumerical and safe special characters",
308+
pattern: *regexp.MustCompile(`^[a-zA-Z0-9@#%^*-_=+:,.!~]+$`),
309+
},
310+
}
311+
312+
// Define custom rejects (forbid specific patterns)
313+
customRejects := []Rule{
314+
{
315+
description: "Must not contain carriage return",
316+
pattern: *regexp.MustCompile(`[\n]`),
317+
},
318+
}
319+
320+
tests := []struct {
321+
name string
322+
validator PasswordValidator
323+
password string
324+
wantErr bool
325+
errMsg string
326+
}{
327+
// Test with nil rules (should use defaults)
328+
{
329+
name: "nil rules uses defaults - valid",
330+
validator: PasswordValidator{},
331+
password: "ValidPassword123",
332+
wantErr: false,
333+
},
334+
{
335+
name: "nil rules uses defaults - shell expansion rejected",
336+
validator: PasswordValidator{},
337+
password: "Password123$HOME",
338+
wantErr: true,
339+
errMsg: ErrMsg,
340+
},
341+
{
342+
name: "nil rules uses defaults - empty password rejected",
343+
validator: PasswordValidator{},
344+
password: "",
345+
wantErr: true,
346+
errMsg: "empty password not allowed",
347+
},
348+
349+
// Test with custom requirements only
350+
{
351+
name: "custom requirements - safe special characters",
352+
validator: PasswordValidator{
353+
Requirements: &customRequirements,
354+
},
355+
password: "#S3cure!Pass#",
356+
wantErr: false,
357+
},
358+
359+
// Test with custom rejects only
360+
{
361+
name: "custom rejects - must not contains '\n'",
362+
validator: PasswordValidator{
363+
Rejects: &customRejects,
364+
},
365+
password: "MyPASSWORD123\n",
366+
wantErr: true,
367+
errMsg: ErrMsg,
368+
},
369+
370+
// Test with both custom requirements and rejects
371+
{
372+
name: "custom both - fully valid password",
373+
validator: PasswordValidator{
374+
Requirements: &customRequirements,
375+
Rejects: &customRejects,
376+
},
377+
password: "MyS3cure!Pass",
378+
wantErr: false,
379+
},
380+
381+
// Test empty requirements/rejects slices
382+
{
383+
name: "empty requirements slice - allows any non-empty password",
384+
validator: PasswordValidator{
385+
Requirements: &[]Rule{},
386+
},
387+
password: "a",
388+
wantErr: false,
389+
},
390+
{
391+
name: "empty rejects slice - no rejections",
392+
validator: PasswordValidator{
393+
Rejects: &[]Rule{},
394+
},
395+
password: "anything$HOME$(cmd)`test`",
396+
wantErr: false,
397+
},
398+
{
399+
name: "both empty - allows any non-empty password",
400+
validator: PasswordValidator{
401+
Requirements: &[]Rule{},
402+
Rejects: &[]Rule{},
403+
},
404+
password: "x",
405+
wantErr: false,
406+
},
407+
}
408+
409+
for _, tt := range tests {
410+
t.Run(tt.name, func(t *testing.T) {
411+
g := NewWithT(t)
412+
413+
err := tt.validator.Validate(tt.password)
414+
415+
if tt.wantErr {
416+
g.Expect(err).To(HaveOccurred())
417+
if tt.errMsg != "" {
418+
g.Expect(err.Error()).To(ContainSubstring(tt.errMsg))
419+
}
420+
} else {
421+
g.Expect(err).ToNot(HaveOccurred())
422+
}
423+
})
424+
}
425+
}

modules/common/secret/zz_generated.deepcopy.go

Lines changed: 0 additions & 15 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)