Skip to content

Commit 7bcc79c

Browse files
committed
feat: add sealed-secrets toolset for managing Bitnami SealedSecrets
Two MCP tools: - sealed_secrets_update_key: add/update a key in an existing SealedSecret - sealed_secrets_create: create a new SealedSecret with multiple keys Security: agent never sees plaintext values. It provides regex patterns, the tool generates random values, encrypts via kubeseal --raw, and returns only the encrypted YAML for use in merge requests. Fetches the controller's public cert via k8s service proxy so kubeseal does pure local encryption without needing its own k8s access.
1 parent 3dda777 commit 7bcc79c

5 files changed

Lines changed: 876 additions & 0 deletions

File tree

pkg/mcp/modules.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,5 +7,6 @@ import (
77
_ "github.com/containers/kubernetes-mcp-server/pkg/toolsets/kcp"
88
_ "github.com/containers/kubernetes-mcp-server/pkg/toolsets/kiali"
99
_ "github.com/containers/kubernetes-mcp-server/pkg/toolsets/kubevirt"
10+
_ "github.com/containers/kubernetes-mcp-server/pkg/toolsets/sealedsecrets"
1011
_ "github.com/containers/kubernetes-mcp-server/pkg/toolsets/tekton"
1112
)
Lines changed: 262 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,262 @@
1+
package sealedsecrets
2+
3+
import (
4+
"crypto/rand"
5+
"fmt"
6+
"math/big"
7+
"strings"
8+
"unicode"
9+
)
10+
11+
// generateFromPattern produces a random string matching a simplified regex-like
12+
// pattern. Supported syntax covers the most common secret-generation use cases:
13+
//
14+
// - Literal characters: abc
15+
// - Character classes: [a-zA-Z0-9], [a-f], [!@#$%]
16+
// - Shorthand classes: \d (digit), \w (word), \l (lowercase), \u (uppercase)
17+
// - Repetition: {n} exact, {n,m} range (inclusive)
18+
// - Dot: . (any printable ASCII, 33-126)
19+
//
20+
// Examples:
21+
//
22+
// "[a-zA-Z0-9]{32}" → 32-char alphanumeric
23+
// "[a-f0-9]{64}" → 64-char hex
24+
// "[A-Za-z0-9!@#$%]{16}" → 16-char password with specials
25+
// "sk_live_\w{24}" → prefixed API key
26+
func generateFromPattern(pattern string) (string, error) {
27+
var result strings.Builder
28+
runes := []rune(pattern)
29+
i := 0
30+
31+
for i < len(runes) {
32+
ch := runes[i]
33+
34+
switch {
35+
case ch == '[':
36+
// Parse character class
37+
end := indexRune(runes, i+1, ']')
38+
if end == -1 {
39+
return "", fmt.Errorf("unclosed character class at position %d", i)
40+
}
41+
chars, err := expandCharClass(runes[i+1 : end])
42+
if err != nil {
43+
return "", fmt.Errorf("invalid character class at position %d: %w", i, err)
44+
}
45+
i = end + 1
46+
47+
count, advance, err := parseRepetition(runes, i)
48+
if err != nil {
49+
return "", err
50+
}
51+
i += advance
52+
53+
for range count {
54+
c, err := pickRandom(chars)
55+
if err != nil {
56+
return "", err
57+
}
58+
result.WriteRune(c)
59+
}
60+
61+
case ch == '\\' && i+1 < len(runes):
62+
chars := expandShorthand(runes[i+1])
63+
if chars == nil {
64+
// Escaped literal
65+
result.WriteRune(runes[i+1])
66+
i += 2
67+
continue
68+
}
69+
i += 2
70+
71+
count, advance, err := parseRepetition(runes, i)
72+
if err != nil {
73+
return "", err
74+
}
75+
i += advance
76+
77+
for range count {
78+
c, err := pickRandom(chars)
79+
if err != nil {
80+
return "", err
81+
}
82+
result.WriteRune(c)
83+
}
84+
85+
case ch == '.':
86+
i++
87+
count, advance, err := parseRepetition(runes, i)
88+
if err != nil {
89+
return "", err
90+
}
91+
i += advance
92+
93+
printable := make([]rune, 0, 94)
94+
for c := rune(33); c <= 126; c++ {
95+
printable = append(printable, c)
96+
}
97+
for range count {
98+
c, err := pickRandom(printable)
99+
if err != nil {
100+
return "", err
101+
}
102+
result.WriteRune(c)
103+
}
104+
105+
default:
106+
// Literal character
107+
result.WriteRune(ch)
108+
i++
109+
}
110+
}
111+
112+
return result.String(), nil
113+
}
114+
115+
// expandCharClass expands a character class body (contents between [ and ]) into
116+
// a slice of runes. Supports ranges like a-z and individual characters.
117+
func expandCharClass(body []rune) ([]rune, error) {
118+
var chars []rune
119+
i := 0
120+
for i < len(body) {
121+
if body[i] == '\\' && i+1 < len(body) {
122+
sh := expandShorthand(body[i+1])
123+
if sh != nil {
124+
chars = append(chars, sh...)
125+
i += 2
126+
continue
127+
}
128+
chars = append(chars, body[i+1])
129+
i += 2
130+
continue
131+
}
132+
133+
if i+2 < len(body) && body[i+1] == '-' {
134+
lo := body[i]
135+
hi := body[i+2]
136+
if hi < lo {
137+
return nil, fmt.Errorf("invalid range %c-%c", lo, hi)
138+
}
139+
for c := lo; c <= hi; c++ {
140+
chars = append(chars, c)
141+
}
142+
i += 3
143+
} else {
144+
chars = append(chars, body[i])
145+
i++
146+
}
147+
}
148+
return chars, nil
149+
}
150+
151+
// expandShorthand returns the character set for a shorthand class, or nil if
152+
// the rune is not a recognized shorthand.
153+
func expandShorthand(ch rune) []rune {
154+
switch ch {
155+
case 'd':
156+
return rangeRunes('0', '9')
157+
case 'w':
158+
return append(append(append(rangeRunes('a', 'z'), rangeRunes('A', 'Z')...), rangeRunes('0', '9')...), '_')
159+
case 'l':
160+
return rangeRunes('a', 'z')
161+
case 'u':
162+
return rangeRunes('A', 'Z')
163+
default:
164+
return nil
165+
}
166+
}
167+
168+
// parseRepetition reads an optional {n} or {n,m} repetition suffix starting at
169+
// position i. Returns the count to repeat, the number of runes consumed, and
170+
// any error. If no repetition is found, returns (1, 0, nil).
171+
func parseRepetition(runes []rune, i int) (int, int, error) {
172+
if i >= len(runes) || runes[i] != '{' {
173+
return 1, 0, nil
174+
}
175+
end := indexRune(runes, i+1, '}')
176+
if end == -1 {
177+
return 0, 0, fmt.Errorf("unclosed repetition at position %d", i)
178+
}
179+
body := string(runes[i+1 : end])
180+
advance := end - i + 1
181+
182+
if idx := strings.Index(body, ","); idx != -1 {
183+
minStr := strings.TrimSpace(body[:idx])
184+
maxStr := strings.TrimSpace(body[idx+1:])
185+
min, err := parsePositiveInt(minStr)
186+
if err != nil {
187+
return 0, 0, fmt.Errorf("invalid repetition min %q: %w", minStr, err)
188+
}
189+
max, err := parsePositiveInt(maxStr)
190+
if err != nil {
191+
return 0, 0, fmt.Errorf("invalid repetition max %q: %w", maxStr, err)
192+
}
193+
if max < min {
194+
return 0, 0, fmt.Errorf("repetition max %d < min %d", max, min)
195+
}
196+
n, err := cryptoRandIntn(max - min + 1)
197+
if err != nil {
198+
return 0, 0, err
199+
}
200+
return min + n, advance, nil
201+
}
202+
203+
n, err := parsePositiveInt(strings.TrimSpace(body))
204+
if err != nil {
205+
return 0, 0, fmt.Errorf("invalid repetition count %q: %w", body, err)
206+
}
207+
return n, advance, nil
208+
}
209+
210+
func rangeRunes(lo, hi rune) []rune {
211+
out := make([]rune, 0, hi-lo+1)
212+
for c := lo; c <= hi; c++ {
213+
out = append(out, c)
214+
}
215+
return out
216+
}
217+
218+
func indexRune(runes []rune, start int, target rune) int {
219+
for i := start; i < len(runes); i++ {
220+
if runes[i] == target {
221+
return i
222+
}
223+
}
224+
return -1
225+
}
226+
227+
func pickRandom(chars []rune) (rune, error) {
228+
if len(chars) == 0 {
229+
return 0, fmt.Errorf("empty character set")
230+
}
231+
idx, err := cryptoRandIntn(len(chars))
232+
if err != nil {
233+
return 0, err
234+
}
235+
return chars[idx], nil
236+
}
237+
238+
func cryptoRandIntn(n int) (int, error) {
239+
max := big.NewInt(int64(n))
240+
val, err := rand.Int(rand.Reader, max)
241+
if err != nil {
242+
return 0, fmt.Errorf("crypto/rand failed: %w", err)
243+
}
244+
return int(val.Int64()), nil
245+
}
246+
247+
func parsePositiveInt(s string) (int, error) {
248+
if s == "" {
249+
return 0, fmt.Errorf("empty number")
250+
}
251+
n := 0
252+
for _, ch := range s {
253+
if !unicode.IsDigit(ch) {
254+
return 0, fmt.Errorf("non-digit character %q", ch)
255+
}
256+
n = n*10 + int(ch-'0')
257+
}
258+
if n > 1024 {
259+
return 0, fmt.Errorf("repetition count %d exceeds maximum 1024", n)
260+
}
261+
return n, nil
262+
}
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
package sealedsecrets
2+
3+
import (
4+
"regexp"
5+
"testing"
6+
7+
"github.com/stretchr/testify/suite"
8+
)
9+
10+
type ReggenSuite struct {
11+
suite.Suite
12+
}
13+
14+
func (s *ReggenSuite) TestGenerateFromPattern() {
15+
s.Run("alphanumeric 32 chars", func() {
16+
result, err := generateFromPattern("[a-zA-Z0-9]{32}")
17+
s.Require().NoError(err)
18+
s.Len(result, 32)
19+
s.Regexp(regexp.MustCompile(`^[a-zA-Z0-9]{32}$`), result)
20+
})
21+
22+
s.Run("hex 64 chars", func() {
23+
result, err := generateFromPattern("[a-f0-9]{64}")
24+
s.Require().NoError(err)
25+
s.Len(result, 64)
26+
s.Regexp(regexp.MustCompile(`^[a-f0-9]{64}$`), result)
27+
})
28+
29+
s.Run("prefixed key", func() {
30+
result, err := generateFromPattern("sk_live_\\w{24}")
31+
s.Require().NoError(err)
32+
s.True(len(result) == 8+24, "expected 32 chars, got %d", len(result))
33+
s.Regexp(regexp.MustCompile(`^sk_live_[a-zA-Z0-9_]{24}$`), result)
34+
})
35+
36+
s.Run("literal characters only", func() {
37+
result, err := generateFromPattern("hello")
38+
s.Require().NoError(err)
39+
s.Equal("hello", result)
40+
})
41+
42+
s.Run("special characters in class", func() {
43+
result, err := generateFromPattern("[!@#$%]{8}")
44+
s.Require().NoError(err)
45+
s.Len(result, 8)
46+
s.Regexp(regexp.MustCompile(`^[!@#$%]{8}$`), result)
47+
})
48+
49+
s.Run("digit shorthand", func() {
50+
result, err := generateFromPattern("\\d{6}")
51+
s.Require().NoError(err)
52+
s.Len(result, 6)
53+
s.Regexp(regexp.MustCompile(`^\d{6}$`), result)
54+
})
55+
56+
s.Run("range repetition", func() {
57+
result, err := generateFromPattern("[a-z]{8,16}")
58+
s.Require().NoError(err)
59+
s.True(len(result) >= 8 && len(result) <= 16,
60+
"expected length 8-16, got %d", len(result))
61+
s.Regexp(regexp.MustCompile(`^[a-z]+$`), result)
62+
})
63+
64+
s.Run("dot any char", func() {
65+
result, err := generateFromPattern(".{10}")
66+
s.Require().NoError(err)
67+
s.Len(result, 10)
68+
})
69+
70+
s.Run("mixed literal and pattern", func() {
71+
result, err := generateFromPattern("prefix_[a-z]{4}_suffix")
72+
s.Require().NoError(err)
73+
s.Regexp(regexp.MustCompile(`^prefix_[a-z]{4}_suffix$`), result)
74+
})
75+
76+
s.Run("single char no repetition", func() {
77+
result, err := generateFromPattern("[a-z]")
78+
s.Require().NoError(err)
79+
s.Len(result, 1)
80+
s.Regexp(regexp.MustCompile(`^[a-z]$`), result)
81+
})
82+
}
83+
84+
func (s *ReggenSuite) TestGenerateFromPatternErrors() {
85+
s.Run("unclosed character class", func() {
86+
_, err := generateFromPattern("[abc")
87+
s.Error(err)
88+
s.Contains(err.Error(), "unclosed character class")
89+
})
90+
91+
s.Run("unclosed repetition", func() {
92+
_, err := generateFromPattern("[a-z]{3")
93+
s.Error(err)
94+
s.Contains(err.Error(), "unclosed repetition")
95+
})
96+
97+
s.Run("invalid range", func() {
98+
_, err := generateFromPattern("[z-a]{3}")
99+
s.Error(err)
100+
s.Contains(err.Error(), "invalid range")
101+
})
102+
103+
s.Run("repetition exceeds max", func() {
104+
_, err := generateFromPattern("[a-z]{2000}")
105+
s.Error(err)
106+
s.Contains(err.Error(), "exceeds maximum")
107+
})
108+
}
109+
110+
func (s *ReggenSuite) TestRandomnessIsUnique() {
111+
s.Run("two generated values differ", func() {
112+
a, err := generateFromPattern("[a-zA-Z0-9]{32}")
113+
s.Require().NoError(err)
114+
b, err := generateFromPattern("[a-zA-Z0-9]{32}")
115+
s.Require().NoError(err)
116+
// With 32 chars from 62 options, collision probability is negligible
117+
s.NotEqual(a, b, "two independently generated values should differ")
118+
})
119+
}
120+
121+
func TestReggen(t *testing.T) {
122+
suite.Run(t, new(ReggenSuite))
123+
}

0 commit comments

Comments
 (0)