-
Notifications
You must be signed in to change notification settings - Fork 1.5k
Expand file tree
/
Copy pathusersecretsimport.go
More file actions
368 lines (329 loc) · 10.9 KB
/
Copy pathusersecretsimport.go
File metadata and controls
368 lines (329 loc) · 10.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
package codersdk
import (
"encoding/json"
"errors"
"io"
"strings"
"golang.org/x/xerrors"
"gopkg.in/yaml.v3"
)
// SecretsFileFormat identifies the on-disk format of a secrets file.
type SecretsFileFormat string
const (
// SecretsFileFormatEnv is a dotenv-style file of KEY=VALUE lines.
SecretsFileFormatEnv SecretsFileFormat = "env"
// SecretsFileFormatJSON is a flat JSON object of string values.
SecretsFileFormatJSON SecretsFileFormat = "json"
// SecretsFileFormatYAML is a flat YAML mapping of string values.
SecretsFileFormatYAML SecretsFileFormat = "yaml"
)
// MaxSecretsFileBytes bounds the raw size of a secrets file before parsing.
const MaxSecretsFileBytes = 1 << 20 // 1 MiB
type secretEntry struct {
key string
value string
line int
}
// ParseSecretsFile parses a secrets file into CreateUserSecretRequests.
// It checks structure and duplicate keys; per-entry validation is left to
// ValidateCreateUserSecretRequest. EnvName is set only when the key passes
// env-name validation (best-effort; keys like MY-TOKEN or PATH get an empty
// EnvName so they are still imported without env injection).
func ParseSecretsFile(format SecretsFileFormat, content string) ([]CreateUserSecretRequest, error) {
if len(content) > MaxSecretsFileBytes {
return nil, xerrors.Errorf("secrets file exceeds the maximum allowed size of %d bytes", MaxSecretsFileBytes)
}
switch format {
case SecretsFileFormatEnv, SecretsFileFormatJSON, SecretsFileFormatYAML:
case "":
return nil, xerrors.New("a secrets file format is required")
default:
return nil, xerrors.Errorf("unknown secrets file format %q", format)
}
if strings.TrimSpace(content) == "" {
return nil, xerrors.New("no secrets found in file")
}
var (
entries []secretEntry
err error
)
switch format {
case SecretsFileFormatEnv:
entries, err = parseEnvSecrets(content)
case SecretsFileFormatJSON:
entries, err = parseJSONSecrets(content)
case SecretsFileFormatYAML:
entries, err = parseYAMLSecrets(content)
}
if err != nil {
return nil, err
}
if len(entries) == 0 {
return nil, xerrors.New("no secrets found in file")
}
if len(entries) > MaxUserSecretsPerUserCount {
return nil, xerrors.Errorf("secrets file contains %d secrets, which exceeds the maximum of %d secrets per user", len(entries), MaxUserSecretsPerUserCount)
}
if err := detectDuplicateKeys(entries); err != nil {
return nil, err
}
reqs := make([]CreateUserSecretRequest, 0, len(entries))
for _, e := range entries {
req := CreateUserSecretRequest{Name: e.key, Value: e.value}
// env_name uses a partial unique index (WHERE env_name != ''),
// so multiple empty env_names are allowed.
if UserSecretEnvNameValid(e.key) == nil {
req.EnvName = e.key
} else {
// Keys that cannot be env-injected (reserved names, invalid
// identifiers) are imported without an injection target, so
// they must be disabled: an enabled secret always has at
// least one of env_name or file_path set. The user can add
// a target and re-enable the secret afterwards.
disabled := false
req.Enabled = &disabled
}
reqs = append(reqs, req)
}
return reqs, nil
}
// Duplicate keys are rejected up front (citing the line for env files)
// instead of surfacing as a per-row uniqueness violation later.
func detectDuplicateKeys(entries []secretEntry) error {
seen := make(map[string]struct{}, len(entries))
for _, e := range entries {
if _, ok := seen[e.key]; ok {
if e.line > 0 {
return xerrors.Errorf("duplicate key %q on line %d", e.key, e.line)
}
return xerrors.Errorf("duplicate key %q", e.key)
}
seen[e.key] = struct{}{}
}
return nil
}
// parseEnvSecrets parses a dotenv-style file into ordered entries. It supports a
// deliberately small subset of dotenv:
// - KEY=VALUE lines, an optional "export " prefix, and full-line "#" comments.
// - Single-quoted values are literal; double-quoted values support \n, \t,
// \r, \\, and \" escapes and keep unknown escapes literal.
//
// It intentionally does NOT:
// - expand $VAR or ${VAR}. Secrets frequently contain "$", and expansion would
// silently corrupt them.
// - strip inline comments, so PASS=abc#123 keeps the trailing #123.
// - support multiline values. Use the JSON or YAML format for PEM keys or
// certs.
//
// Duplicate keys are an error (see detectDuplicateKeys); a silent last-wins
// would drop a secret.
//
// This is hand-rolled rather than using joho/godotenv or hashicorp/go-envparse
// because those expand variables and/or strip inline comments (silent secret
// corruption) and return unordered maps, so we lose source order, line numbers,
// and duplicate detection. They would also add a dependency to the public
// codersdk package.
func parseEnvSecrets(content string) ([]secretEntry, error) {
content = strings.ReplaceAll(content, "\r\n", "\n")
content = strings.TrimPrefix(content, "\ufeff")
var entries []secretEntry
for i, raw := range strings.Split(content, "\n") {
lineNum := i + 1
if t := strings.TrimSpace(raw); t == "" || strings.HasPrefix(t, "#") {
continue
}
work := stripExportPrefix(strings.TrimLeft(raw, " \t"))
eq := strings.IndexByte(work, '=')
if eq < 0 {
return nil, xerrors.Errorf("line %d: expected KEY=VALUE but found no '='", lineNum)
}
key := strings.TrimSpace(work[:eq])
if key == "" {
return nil, xerrors.Errorf("line %d: missing key before '='", lineNum)
}
value, err := parseEnvValue(work[eq+1:], lineNum)
if err != nil {
return nil, err
}
entries = append(entries, secretEntry{key: key, value: value, line: lineNum})
}
return entries, nil
}
// stripExportPrefix removes a leading "export " (the word export
// followed by whitespace). A line like "export=foo" is left untouched
// so the key becomes "export".
func stripExportPrefix(s string) string {
const kw = "export"
if !strings.HasPrefix(s, kw) {
return s
}
rest := s[len(kw):]
if rest == "" || (rest[0] != ' ' && rest[0] != '\t') {
return s
}
return strings.TrimLeft(rest, " \t")
}
func parseEnvValue(rhs string, lineNum int) (string, error) {
v := strings.TrimLeft(rhs, " \t")
if v == "" {
return "", nil
}
switch v[0] {
case '"':
inner, err := doubleQuotedInner(v, lineNum)
if err != nil {
return "", err
}
return unescapeDoubleQuoted(inner), nil
case '\'':
return singleQuotedInner(v, lineNum)
default:
return strings.TrimSpace(v), nil
}
}
func doubleQuotedInner(v string, lineNum int) (string, error) {
for i := 1; i < len(v); i++ {
if v[i] != '"' || hasOddBackslashRun(v, i) {
continue
}
if strings.Trim(v[i+1:], " \t") != "" {
return "", xerrors.Errorf("line %d: unexpected data after closing double quote", lineNum)
}
return v[1:i], nil
}
return "", xerrors.Errorf("line %d: missing closing double quote", lineNum)
}
func hasOddBackslashRun(s string, before int) bool {
count := 0
for i := before - 1; i >= 0 && s[i] == '\\'; i-- {
count++
}
return count%2 == 1
}
// singleQuotedInner returns the content between the opening single
// quote at index 0 and the first closing single quote. Single quotes
// have no escape sequences, so the first quote after the opener always
// closes the value. Only whitespace may follow the closing quote.
func singleQuotedInner(v string, lineNum int) (string, error) {
for i := 1; i < len(v); i++ {
if v[i] != '\'' {
continue
}
if strings.Trim(v[i+1:], " \t") != "" {
return "", xerrors.Errorf("line %d: unexpected data after closing single quote", lineNum)
}
return v[1:i], nil
}
return "", xerrors.Errorf("line %d: missing closing single quote", lineNum)
}
func unescapeDoubleQuoted(s string) string {
if !strings.Contains(s, "\\") {
return s
}
buf := make([]byte, 0, len(s))
for i := 0; i < len(s); i++ {
c := s[i]
if c != '\\' || i == len(s)-1 {
buf = append(buf, c)
continue
}
switch next := s[i+1]; next {
case 'n':
buf = append(buf, '\n')
case 't':
buf = append(buf, '\t')
case 'r':
buf = append(buf, '\r')
case '\\':
buf = append(buf, '\\')
case '"':
buf = append(buf, '"')
default:
buf = append(buf, '\\', next)
}
i++
}
return string(buf)
}
func parseJSONSecrets(content string) ([]secretEntry, error) {
dec := json.NewDecoder(strings.NewReader(content))
tok, err := dec.Token()
if err != nil {
return nil, xerrors.Errorf("invalid JSON: %w", err)
}
if delim, ok := tok.(json.Delim); !ok || delim != '{' {
return nil, xerrors.New("JSON content must be an object mapping secret names to string values")
}
var entries []secretEntry
for dec.More() {
keyTok, err := dec.Token()
if err != nil {
return nil, xerrors.Errorf("invalid JSON: %w", err)
}
key, ok := keyTok.(string)
if !ok {
return nil, xerrors.New("invalid JSON object key")
}
valTok, err := dec.Token()
if err != nil {
return nil, xerrors.Errorf("invalid JSON: %w", err)
}
switch val := valTok.(type) {
case string:
entries = append(entries, secretEntry{key: key, value: val})
case json.Delim:
return nil, xerrors.Errorf("value for key %q must be a string, not a nested object or array", key)
default:
return nil, xerrors.Errorf("value for key %q must be a string", key)
}
}
if _, err := dec.Token(); err != nil {
return nil, xerrors.Errorf("invalid JSON: %w", err)
}
var extra any
if err := dec.Decode(&extra); errors.Is(err, io.EOF) {
return entries, nil
} else if err != nil {
return nil, xerrors.Errorf("invalid JSON: %w", err)
}
return nil, xerrors.New("unexpected trailing data after JSON object")
}
func parseYAMLSecrets(content string) ([]secretEntry, error) {
dec := yaml.NewDecoder(strings.NewReader(content))
var root yaml.Node
if err := dec.Decode(&root); err != nil {
if errors.Is(err, io.EOF) {
return nil, nil
}
return nil, xerrors.Errorf("invalid YAML: %w", err)
}
var extra yaml.Node
if err := dec.Decode(&extra); err == nil {
return nil, xerrors.New("YAML content must be a single document mapping secret names to string values")
} else if !errors.Is(err, io.EOF) {
return nil, xerrors.Errorf("invalid YAML: %w", err)
}
if root.Kind == 0 || len(root.Content) == 0 {
return nil, nil
}
doc := root.Content[0]
if doc.Kind != yaml.MappingNode {
return nil, xerrors.New("YAML content must be a mapping of secret names to string values")
}
entries := make([]secretEntry, 0, len(doc.Content)/2)
for i := 0; i+1 < len(doc.Content); i += 2 {
keyNode := doc.Content[i]
valNode := doc.Content[i+1]
if keyNode.Kind != yaml.ScalarNode || (keyNode.Tag != "" && keyNode.Tag != "!!str") {
return nil, xerrors.New("YAML keys must be strings")
}
if valNode.Kind != yaml.ScalarNode {
return nil, xerrors.Errorf("value for key %q must be a string, not a nested mapping or sequence", keyNode.Value)
}
if valNode.Tag != "" && valNode.Tag != "!!str" {
return nil, xerrors.Errorf("value for key %q must be a string (quote the value if it is numeric or boolean)", keyNode.Value)
}
entries = append(entries, secretEntry{key: keyNode.Value, value: valNode.Value, line: keyNode.Line})
}
return entries, nil
}