onesecret 0.0.0-stage → 0.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.md +21 -0
- package/README.md +313 -2
- package/bin/help.txt +37 -0
- package/bin/index.js +14 -0
- package/package.json +106 -3
- package/src/age.js +363 -0
- package/src/bech32.js +89 -0
- package/src/cli.js +347 -0
- package/src/deploy.js +113 -0
- package/src/dotenv.js +67 -0
- package/src/error.js +12 -0
- package/src/fly.js +109 -0
- package/src/format.js +61 -0
- package/src/index.js +20 -0
- package/src/keys.js +197 -0
- package/src/regexp.js +91 -0
- package/src/secrets.js +331 -0
- package/src/sops.js +434 -0
- package/src/store.js +257 -0
- package/src/write.js +288 -0
- package/src/yaml.js +361 -0
package/src/index.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const { config, load, names } = require('./secrets')
|
|
4
|
+
const { fly, kubernetes } = require('./deploy')
|
|
5
|
+
const { checksum, toDotenv, toEnv } = require('./format')
|
|
6
|
+
const { withoutAgeKey } = require('./keys')
|
|
7
|
+
const { OnesecretError } = require('./error')
|
|
8
|
+
|
|
9
|
+
module.exports = {
|
|
10
|
+
checksum,
|
|
11
|
+
config,
|
|
12
|
+
fly,
|
|
13
|
+
kubernetes,
|
|
14
|
+
load,
|
|
15
|
+
names,
|
|
16
|
+
toDotenv,
|
|
17
|
+
toEnv,
|
|
18
|
+
withoutAgeKey,
|
|
19
|
+
OnesecretError
|
|
20
|
+
}
|
package/src/keys.js
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const { execSync } = require('node:child_process')
|
|
4
|
+
const fs = require('node:fs')
|
|
5
|
+
const os = require('node:os')
|
|
6
|
+
const path = require('node:path')
|
|
7
|
+
const age = require('./age')
|
|
8
|
+
const { OnesecretError } = require('./error')
|
|
9
|
+
|
|
10
|
+
const KEYS_FILE = path.join('sops', 'age', 'keys.txt')
|
|
11
|
+
const AGE_KEY_VARIABLES = [
|
|
12
|
+
'SOPS_AGE_KEY',
|
|
13
|
+
'SOPS_AGE_KEY_FILE',
|
|
14
|
+
'SOPS_AGE_KEY_CMD'
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
const sopsKeysFile = () => {
|
|
18
|
+
if (process.env.XDG_CONFIG_HOME) {
|
|
19
|
+
return path.join(process.env.XDG_CONFIG_HOME, KEYS_FILE)
|
|
20
|
+
}
|
|
21
|
+
if (process.platform === 'darwin') {
|
|
22
|
+
return path.join(os.homedir(), 'Library', 'Application Support', KEYS_FILE)
|
|
23
|
+
}
|
|
24
|
+
return path.join(os.homedir(), '.config', KEYS_FILE)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const defaultKeyFiles = () => [
|
|
28
|
+
...new Set([sopsKeysFile(), path.join(os.homedir(), '.config', KEYS_FILE)])
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
const readKeyFile = file => {
|
|
32
|
+
try {
|
|
33
|
+
return fs.readFileSync(file, 'utf8')
|
|
34
|
+
} catch (error) {
|
|
35
|
+
throw new OnesecretError(
|
|
36
|
+
`cannot read age key file ${file}: ${error.code || error.message}`,
|
|
37
|
+
'EKEY'
|
|
38
|
+
)
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const parseSource = (label, text) => {
|
|
43
|
+
try {
|
|
44
|
+
return age.parseIdentities(text)
|
|
45
|
+
} catch (error) {
|
|
46
|
+
throw new OnesecretError(
|
|
47
|
+
`${label} holds an invalid age key: ${error.message}`,
|
|
48
|
+
'EKEY'
|
|
49
|
+
)
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const localSources = () => [
|
|
54
|
+
...(process.env.SOPS_AGE_KEY
|
|
55
|
+
? [['SOPS_AGE_KEY', () => process.env.SOPS_AGE_KEY]]
|
|
56
|
+
: []),
|
|
57
|
+
...(process.env.SOPS_AGE_KEY_FILE
|
|
58
|
+
? [
|
|
59
|
+
[
|
|
60
|
+
process.env.SOPS_AGE_KEY_FILE,
|
|
61
|
+
() => readKeyFile(process.env.SOPS_AGE_KEY_FILE)
|
|
62
|
+
]
|
|
63
|
+
]
|
|
64
|
+
: []),
|
|
65
|
+
...defaultKeyFiles()
|
|
66
|
+
.filter(file => fs.existsSync(file))
|
|
67
|
+
.map(file => [file, () => readKeyFile(file)])
|
|
68
|
+
]
|
|
69
|
+
|
|
70
|
+
const localIdentities = () => {
|
|
71
|
+
const identities = []
|
|
72
|
+
const invalid = []
|
|
73
|
+
for (const [label, read] of localSources()) {
|
|
74
|
+
try {
|
|
75
|
+
identities.push(...parseSource(label, read()))
|
|
76
|
+
} catch (error) {
|
|
77
|
+
invalid.push(error)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return { identities, invalid }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const withSkippedSources = (error, invalid) => {
|
|
84
|
+
if (error.code !== 'EKEY' || invalid.length === 0) return error
|
|
85
|
+
const skipped = invalid.map(({ reason }) => reason).join('; ')
|
|
86
|
+
return new OnesecretError(`${error.reason} (skipped: ${skipped})`, 'EKEY')
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const commandCache = new Map()
|
|
90
|
+
|
|
91
|
+
const runKeyCommand = command => {
|
|
92
|
+
if (!commandCache.has(command)) {
|
|
93
|
+
try {
|
|
94
|
+
commandCache.set(command, { identities: readKeyCommand(command) })
|
|
95
|
+
} catch (error) {
|
|
96
|
+
commandCache.set(command, { error })
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
const { identities, error } = commandCache.get(command)
|
|
100
|
+
if (error) throw error
|
|
101
|
+
return identities
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const readKeyCommand = command => {
|
|
105
|
+
let output
|
|
106
|
+
try {
|
|
107
|
+
output = execSync(command, {
|
|
108
|
+
encoding: 'utf8',
|
|
109
|
+
stdio: ['ignore', 'pipe', 'pipe']
|
|
110
|
+
})
|
|
111
|
+
} catch (error) {
|
|
112
|
+
const detail = error.stderr
|
|
113
|
+
? String(error.stderr).trim().split('\n')[0]
|
|
114
|
+
: ''
|
|
115
|
+
throw new OnesecretError(
|
|
116
|
+
`key command "${command}" failed${detail ? `: ${detail}` : ''}`,
|
|
117
|
+
'EKEY'
|
|
118
|
+
)
|
|
119
|
+
}
|
|
120
|
+
const identities = parseSource(`key command "${command}"`, output)
|
|
121
|
+
if (identities.length === 0) {
|
|
122
|
+
throw new OnesecretError(
|
|
123
|
+
`key command "${command}" printed no age identity`,
|
|
124
|
+
'EKEY'
|
|
125
|
+
)
|
|
126
|
+
}
|
|
127
|
+
return identities
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const keyCommandOf = keyCommand => process.env.SOPS_AGE_KEY_CMD || keyCommand
|
|
131
|
+
|
|
132
|
+
const withIdentities = (keyCommand, use, { allowKeyCommand = true } = {}) => {
|
|
133
|
+
const { identities, invalid } = localIdentities()
|
|
134
|
+
const command = allowKeyCommand ? keyCommandOf(keyCommand) : undefined
|
|
135
|
+
if (identities.length === 0 && !command) {
|
|
136
|
+
if (invalid.length > 0) throw invalid[0]
|
|
137
|
+
const sources = `set SOPS_AGE_KEY or SOPS_AGE_KEY_FILE, or add it to ${sopsKeysFile()}`
|
|
138
|
+
const hint = allowKeyCommand
|
|
139
|
+
? `${sources}, or configure a keyCommand`
|
|
140
|
+
: `${sources} (keyCommand is not run here)`
|
|
141
|
+
throw new OnesecretError(`no age key found: ${hint}`, 'ENOKEY')
|
|
142
|
+
}
|
|
143
|
+
if (identities.length > 0) {
|
|
144
|
+
try {
|
|
145
|
+
return use(identities)
|
|
146
|
+
} catch (error) {
|
|
147
|
+
if (error.code !== 'EKEY' || !command) {
|
|
148
|
+
throw withSkippedSources(error, invalid)
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return use(runKeyCommand(command))
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const withoutAgeKey = env =>
|
|
156
|
+
Object.fromEntries(
|
|
157
|
+
Object.entries(env).filter(([name]) => !AGE_KEY_VARIABLES.includes(name))
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
const EXPORTED_KEY = /AGE-SECRET-KEY-1[0-9A-Z]+/g
|
|
161
|
+
|
|
162
|
+
const exportedKeyText = () => {
|
|
163
|
+
const texts = [process.env.SOPS_AGE_KEY || '']
|
|
164
|
+
if (process.env.SOPS_AGE_KEY_FILE) {
|
|
165
|
+
try {
|
|
166
|
+
texts.push(fs.readFileSync(process.env.SOPS_AGE_KEY_FILE, 'utf8'))
|
|
167
|
+
} catch {}
|
|
168
|
+
}
|
|
169
|
+
return texts
|
|
170
|
+
.join('\n')
|
|
171
|
+
.split('\n')
|
|
172
|
+
.filter(line => !line.trim().startsWith('#'))
|
|
173
|
+
.join('\n')
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const recipientOrNull = identity => {
|
|
177
|
+
try {
|
|
178
|
+
return age.recipientOf(identity)
|
|
179
|
+
} catch {
|
|
180
|
+
return null
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const exportedRecipients = () => [
|
|
185
|
+
...new Set(
|
|
186
|
+
(exportedKeyText().match(EXPORTED_KEY) || [])
|
|
187
|
+
.map(recipientOrNull)
|
|
188
|
+
.filter(Boolean)
|
|
189
|
+
)
|
|
190
|
+
]
|
|
191
|
+
|
|
192
|
+
module.exports = {
|
|
193
|
+
exportedRecipients,
|
|
194
|
+
sopsKeysFile,
|
|
195
|
+
withIdentities,
|
|
196
|
+
withoutAgeKey
|
|
197
|
+
}
|
package/src/regexp.js
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const { OnesecretError } = require('./error')
|
|
4
|
+
|
|
5
|
+
const POSIX_CLASSES = {
|
|
6
|
+
alnum: '0-9A-Za-z',
|
|
7
|
+
alpha: 'A-Za-z',
|
|
8
|
+
ascii: '\\x00-\\x7F',
|
|
9
|
+
blank: '\\t ',
|
|
10
|
+
cntrl: '\\x00-\\x1F\\x7F',
|
|
11
|
+
digit: '0-9',
|
|
12
|
+
graph: '!-~',
|
|
13
|
+
lower: 'a-z',
|
|
14
|
+
print: ' -~',
|
|
15
|
+
punct: '!-\\/:-@\\[-`{-~',
|
|
16
|
+
space: '\\t\\n\\v\\f\\r ',
|
|
17
|
+
upper: 'A-Z',
|
|
18
|
+
word: '0-9A-Za-z_',
|
|
19
|
+
xdigit: '0-9A-Fa-f'
|
|
20
|
+
}
|
|
21
|
+
const LEADING_FLAGS = /^\(\?([imsU]+)\)/
|
|
22
|
+
const POSIX_CLASS = /^\[:(\^?)([a-z]+):\]/
|
|
23
|
+
const SUPPORTED_FLAGS = new Set(['i', 'm', 's'])
|
|
24
|
+
const ESCAPES = { A: '(?<![\\s\\S])', z: '(?![\\s\\S])' }
|
|
25
|
+
const UNSUPPORTED_ESCAPES = new Set(['Q', 'E', 'p', 'P', 'C', 'Z'])
|
|
26
|
+
const LITERAL_BRACKET_CLASS = /^\[\^?\]/
|
|
27
|
+
|
|
28
|
+
const cache = new Map()
|
|
29
|
+
|
|
30
|
+
const translateBody = pattern => {
|
|
31
|
+
let output = ''
|
|
32
|
+
let inClass = false
|
|
33
|
+
for (let index = 0; index < pattern.length; index++) {
|
|
34
|
+
const char = pattern[index]
|
|
35
|
+
const rest = pattern.slice(index)
|
|
36
|
+
if (char === '\\') {
|
|
37
|
+
const next = pattern[index + 1]
|
|
38
|
+
if (
|
|
39
|
+
UNSUPPORTED_ESCAPES.has(next) ||
|
|
40
|
+
(next === 'x' && pattern[index + 2] === '{')
|
|
41
|
+
) {
|
|
42
|
+
throw new Error(`the Go escape \\${next} has no JavaScript equivalent`)
|
|
43
|
+
}
|
|
44
|
+
output += !inClass && ESCAPES[next] ? ESCAPES[next] : char + (next ?? '')
|
|
45
|
+
index++
|
|
46
|
+
} else if (inClass && POSIX_CLASS.test(rest)) {
|
|
47
|
+
const [match, negated, name] = POSIX_CLASS.exec(rest)
|
|
48
|
+
if (!POSIX_CLASSES[name]) { throw new Error(`unknown character class [:${name}:]`) }
|
|
49
|
+
if (negated) { throw new Error(`negated class [:^${name}:] is not supported`) }
|
|
50
|
+
output += POSIX_CLASSES[name]
|
|
51
|
+
index += match.length - 1
|
|
52
|
+
} else if (!inClass && LITERAL_BRACKET_CLASS.test(rest)) {
|
|
53
|
+
throw new Error('a class that starts with ] is not supported')
|
|
54
|
+
} else if (rest.startsWith('(?P<')) {
|
|
55
|
+
output += '(?<'
|
|
56
|
+
index += 3
|
|
57
|
+
} else {
|
|
58
|
+
if (char === '[' && !inClass) inClass = true
|
|
59
|
+
else if (char === ']' && inClass) inClass = false
|
|
60
|
+
output += char
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return output
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const translate = pattern => {
|
|
67
|
+
const leading = LEADING_FLAGS.exec(pattern)
|
|
68
|
+
const flags = leading
|
|
69
|
+
? [...leading[1]].filter(flag => SUPPORTED_FLAGS.has(flag)).join('')
|
|
70
|
+
: ''
|
|
71
|
+
return new RegExp(
|
|
72
|
+
translateBody(leading ? pattern.slice(leading[0].length) : pattern),
|
|
73
|
+
flags
|
|
74
|
+
)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const goRegExp = pattern => {
|
|
78
|
+
if (!cache.has(pattern)) {
|
|
79
|
+
try {
|
|
80
|
+
cache.set(pattern, translate(pattern))
|
|
81
|
+
} catch (error) {
|
|
82
|
+
throw new OnesecretError(
|
|
83
|
+
`regular expression "${pattern}" is not supported: ${error.message}`,
|
|
84
|
+
'EFORMAT'
|
|
85
|
+
)
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return cache.get(pattern)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
module.exports = { goRegExp }
|
package/src/secrets.js
ADDED
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs')
|
|
4
|
+
const path = require('node:path')
|
|
5
|
+
const sops = require('./sops')
|
|
6
|
+
const store = require('./store')
|
|
7
|
+
const { withIdentities } = require('./keys')
|
|
8
|
+
const { isEnvName, isEnvScalar, toEnvValue } = require('./format')
|
|
9
|
+
const { OnesecretError } = require('./error')
|
|
10
|
+
|
|
11
|
+
const DEFAULT_DIR = 'config'
|
|
12
|
+
const DEFAULT_ENV = 'development'
|
|
13
|
+
const SKIP_VARIABLE = 'ONESECRET_SKIP'
|
|
14
|
+
const DEPLOYED_ENVS = ['production', 'staging']
|
|
15
|
+
const EXPORTABLE_TYPES = ['str', 'int', 'float', 'bool', 'time', 'null']
|
|
16
|
+
const FILE_LIKE_ENV = /(^|\.)enc(\.|$)|\.(ya?ml|json)$/i
|
|
17
|
+
const KEY_ERRORS = ['ENOKEY', 'EKEY']
|
|
18
|
+
|
|
19
|
+
const packageSettings = cwd => {
|
|
20
|
+
try {
|
|
21
|
+
return (
|
|
22
|
+
JSON.parse(fs.readFileSync(path.join(cwd, 'package.json'), 'utf8'))
|
|
23
|
+
.onesecret || {}
|
|
24
|
+
)
|
|
25
|
+
} catch {
|
|
26
|
+
return {}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const ENV_NAME = /^[a-z0-9_-][a-z0-9_.-]*$/
|
|
31
|
+
|
|
32
|
+
const assertEnv = env => {
|
|
33
|
+
if (!ENV_NAME.test(env) || env.includes('..') || FILE_LIKE_ENV.test(env)) {
|
|
34
|
+
throw new OnesecretError(
|
|
35
|
+
`"${env}" is not a valid env name, use lowercase letters, digits, ".", "_", or "-"`,
|
|
36
|
+
'EVALUE'
|
|
37
|
+
)
|
|
38
|
+
}
|
|
39
|
+
return env
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const optionsOf = input =>
|
|
43
|
+
typeof input === 'string' ? { env: input } : input || {}
|
|
44
|
+
|
|
45
|
+
const resolveOptions = input => {
|
|
46
|
+
const options = optionsOf(input)
|
|
47
|
+
const cwd = path.resolve(options.cwd || process.cwd())
|
|
48
|
+
const settings = packageSettings(cwd)
|
|
49
|
+
return {
|
|
50
|
+
cwd,
|
|
51
|
+
dir: path.resolve(cwd, options.dir || settings.dir || DEFAULT_DIR),
|
|
52
|
+
env:
|
|
53
|
+
options.env ||
|
|
54
|
+
(options.processEnv || process.env).NODE_ENV ||
|
|
55
|
+
DEFAULT_ENV,
|
|
56
|
+
keyCommand: options.keyCommand || settings.keyCommand
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const readLayer = (dir, name) => {
|
|
61
|
+
const plainFile = store.findPlainFile(dir, name)
|
|
62
|
+
const encryptedFile = store.findLayerFile(dir, name)
|
|
63
|
+
return {
|
|
64
|
+
name,
|
|
65
|
+
plainFile,
|
|
66
|
+
encryptedFile,
|
|
67
|
+
values: plainFile ? store.readPlain(plainFile) : {},
|
|
68
|
+
document: encryptedFile ? readEncrypted(encryptedFile) : null
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const readEncrypted = file => {
|
|
73
|
+
const document = store.readDocument(file)
|
|
74
|
+
if (document === null) return null
|
|
75
|
+
try {
|
|
76
|
+
sops.names(document)
|
|
77
|
+
} catch (error) {
|
|
78
|
+
throw new OnesecretError(`${file}: ${error.reason}`, error.code)
|
|
79
|
+
}
|
|
80
|
+
return document
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const readLayers = input => {
|
|
84
|
+
const { dir, env, keyCommand } = resolveOptions(input)
|
|
85
|
+
assertEnv(env)
|
|
86
|
+
const layers = store.layersFor(env).map(name => readLayer(dir, name))
|
|
87
|
+
return { dir, env, keyCommand, layers }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const documentsOf = layers =>
|
|
91
|
+
layers.map(({ document }) => document).filter(Boolean)
|
|
92
|
+
|
|
93
|
+
const hasFiles = layers =>
|
|
94
|
+
layers.some(({ plainFile, encryptedFile }) => plainFile || encryptedFile)
|
|
95
|
+
|
|
96
|
+
const isPlainObject = value =>
|
|
97
|
+
Object.prototype.toString.call(value) === '[object Object]'
|
|
98
|
+
|
|
99
|
+
const secretsOf = layers => {
|
|
100
|
+
const topmost = new Map()
|
|
101
|
+
for (const layer of layers) {
|
|
102
|
+
for (const [name, value] of Object.entries(layer.values)) {
|
|
103
|
+
const merges =
|
|
104
|
+
isPlainObject(value) && topmost.get(name)?.type === 'mapping'
|
|
105
|
+
if (!merges) topmost.set(name, null)
|
|
106
|
+
}
|
|
107
|
+
if (!layer.document) continue
|
|
108
|
+
for (const [name, type] of Object.entries(
|
|
109
|
+
sops.valueTypes(layer.document)
|
|
110
|
+
)) {
|
|
111
|
+
topmost.set(name, { type, file: layer.encryptedFile })
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return new Map([...topmost].filter(([, secret]) => secret))
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const secretNamesOf = layers => [...secretsOf(layers).keys()]
|
|
118
|
+
|
|
119
|
+
const decryptDocuments = (documents, keyCommand, keyOptions) => {
|
|
120
|
+
if (documents.length === 0) return []
|
|
121
|
+
return withIdentities(
|
|
122
|
+
keyCommand,
|
|
123
|
+
identities => documents.map(document => sops.decrypt(document, identities)),
|
|
124
|
+
keyOptions
|
|
125
|
+
)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const decryptDocument = (document, keyCommand) =>
|
|
129
|
+
decryptDocuments([document], keyCommand)[0]
|
|
130
|
+
|
|
131
|
+
const UNSAFE_NAME = '__proto__'
|
|
132
|
+
|
|
133
|
+
const assign = (target, name, value) =>
|
|
134
|
+
Object.defineProperty(target, name, {
|
|
135
|
+
value,
|
|
136
|
+
enumerable: true,
|
|
137
|
+
writable: true,
|
|
138
|
+
configurable: true
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
const deepMerge = (base, override) => {
|
|
142
|
+
const merged = { ...base }
|
|
143
|
+
for (const [name, value] of Object.entries(override)) {
|
|
144
|
+
if (name === UNSAFE_NAME) continue
|
|
145
|
+
const current = Object.hasOwn(merged, name) ? merged[name] : undefined
|
|
146
|
+
assign(
|
|
147
|
+
merged,
|
|
148
|
+
name,
|
|
149
|
+
isPlainObject(value)
|
|
150
|
+
? deepMerge(isPlainObject(current) ? current : {}, value)
|
|
151
|
+
: value
|
|
152
|
+
)
|
|
153
|
+
}
|
|
154
|
+
return merged
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const withoutMetadata = document => pick(document, sops.names(document))
|
|
158
|
+
|
|
159
|
+
const mergeLayers = (layers, decrypted) => {
|
|
160
|
+
const trees = decrypted
|
|
161
|
+
? [...decrypted]
|
|
162
|
+
: documentsOf(layers).map(withoutMetadata)
|
|
163
|
+
const merged = layers.reduce((merged, layer) => {
|
|
164
|
+
const withPlain = deepMerge(merged, layer.values)
|
|
165
|
+
return layer.document ? deepMerge(withPlain, trees.shift()) : withPlain
|
|
166
|
+
}, {})
|
|
167
|
+
if (decrypted) return merged
|
|
168
|
+
for (const name of secretNamesOf(layers)) delete merged[name]
|
|
169
|
+
return merged
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const pick = (values, names) => {
|
|
173
|
+
const picked = {}
|
|
174
|
+
for (const name of names) {
|
|
175
|
+
if (Object.hasOwn(values, name)) assign(picked, name, values[name])
|
|
176
|
+
}
|
|
177
|
+
return picked
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const names = input => secretNamesOf(readLayers(input).layers)
|
|
181
|
+
|
|
182
|
+
const loadLayers = ({ layers, keyCommand }) =>
|
|
183
|
+
mergeLayers(layers, decryptDocuments(documentsOf(layers), keyCommand))
|
|
184
|
+
|
|
185
|
+
const load = input => loadLayers(readLayers(input))
|
|
186
|
+
|
|
187
|
+
const get = (input, name) => {
|
|
188
|
+
const layered = readLayers(input)
|
|
189
|
+
return secretNamesOf(layered.layers).includes(name)
|
|
190
|
+
? loadLayers(layered)
|
|
191
|
+
: mergeLayers(layered.layers, null)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const loadSecrets = layered =>
|
|
195
|
+
pick(loadLayers(layered), secretNamesOf(layered.layers))
|
|
196
|
+
|
|
197
|
+
const isUnset = (target, name) =>
|
|
198
|
+
!Object.hasOwn(target, name) || target[name] === undefined
|
|
199
|
+
|
|
200
|
+
const nameOrder = layers => [
|
|
201
|
+
...new Set(
|
|
202
|
+
layers.flatMap(layer => [
|
|
203
|
+
...Object.keys(layer.values),
|
|
204
|
+
...(layer.document ? sops.names(layer.document) : [])
|
|
205
|
+
])
|
|
206
|
+
)
|
|
207
|
+
]
|
|
208
|
+
|
|
209
|
+
const decryptForConfig = (
|
|
210
|
+
{ layers, env, keyCommand },
|
|
211
|
+
missing,
|
|
212
|
+
allowKeyCommand = !DEPLOYED_ENVS.includes(env)
|
|
213
|
+
) => {
|
|
214
|
+
try {
|
|
215
|
+
return decryptDocuments(documentsOf(layers), keyCommand, {
|
|
216
|
+
allowKeyCommand
|
|
217
|
+
})
|
|
218
|
+
} catch (error) {
|
|
219
|
+
if (!KEY_ERRORS.includes(error.code)) throw error
|
|
220
|
+
const verb = missing.length === 1 ? 'is' : 'are'
|
|
221
|
+
throw new OnesecretError(
|
|
222
|
+
`${missing.join(', ')} ${verb} not set and cannot be decrypted, ${
|
|
223
|
+
error.reason
|
|
224
|
+
}`,
|
|
225
|
+
'EMISSING'
|
|
226
|
+
)
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const isExportable = (name, value) =>
|
|
231
|
+
isEnvName(name) && (value === null || isEnvScalar(value))
|
|
232
|
+
|
|
233
|
+
const ENV_PARSERS = {
|
|
234
|
+
int: text => {
|
|
235
|
+
if (!/^-?\d+$/.test(text)) return undefined
|
|
236
|
+
const number = Number(text)
|
|
237
|
+
return Number.isSafeInteger(number) ? number : BigInt(text)
|
|
238
|
+
},
|
|
239
|
+
float: text => Number(text),
|
|
240
|
+
null: text => (text === '' ? null : undefined),
|
|
241
|
+
bool: text => ({ true: true, false: false }[text])
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const envStringOf = value => (value === null ? '' : String(value))
|
|
245
|
+
|
|
246
|
+
const roundTrips = (value, text) =>
|
|
247
|
+
value !== undefined && envStringOf(value) === text
|
|
248
|
+
|
|
249
|
+
const typedSecret = (text, secret) => {
|
|
250
|
+
const parse = secret && ENV_PARSERS[secret.type]
|
|
251
|
+
const value = parse ? parse(text) : undefined
|
|
252
|
+
return roundTrips(value, text) ? value : text
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const typedFromEnv = (name, text, merged, secret) => {
|
|
256
|
+
if (secret || !Object.hasOwn(merged, name)) {
|
|
257
|
+
return typedSecret(text, secret)
|
|
258
|
+
}
|
|
259
|
+
const value = merged[name]
|
|
260
|
+
return isExportable(name, value) && roundTrips(value, text) ? value : text
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const assertScalarSecrets = (secrets, missing) => {
|
|
264
|
+
for (const name of missing) {
|
|
265
|
+
const { type, file } = secrets.get(name)
|
|
266
|
+
if (!isEnvName(name)) {
|
|
267
|
+
throw new OnesecretError(
|
|
268
|
+
`"${name}" in ${file} cannot be an environment variable name, so config() could never find it set`,
|
|
269
|
+
'EFORMAT'
|
|
270
|
+
)
|
|
271
|
+
}
|
|
272
|
+
if (!EXPORTABLE_TYPES.includes(type)) {
|
|
273
|
+
throw new OnesecretError(
|
|
274
|
+
`"${name}" in ${file} has type ${type}, config() needs each secret to be a string, number, or boolean`,
|
|
275
|
+
'EFORMAT'
|
|
276
|
+
)
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const config = input => fill(input)
|
|
282
|
+
|
|
283
|
+
const fill = (input, { allowKeyCommand } = {}) => {
|
|
284
|
+
const options = optionsOf(input)
|
|
285
|
+
const target = options.processEnv || process.env
|
|
286
|
+
const skip = target[SKIP_VARIABLE] === '1'
|
|
287
|
+
const layered = readLayers(options)
|
|
288
|
+
if (!hasFiles(layered.layers)) {
|
|
289
|
+
if (skip) return {}
|
|
290
|
+
throw new OnesecretError(
|
|
291
|
+
`no config files for "default" or "${layered.env}" found in ${layered.dir}`,
|
|
292
|
+
'EMISSING'
|
|
293
|
+
)
|
|
294
|
+
}
|
|
295
|
+
const secrets = secretsOf(layered.layers)
|
|
296
|
+
const missing = [...secrets.keys()].filter(name => isUnset(target, name))
|
|
297
|
+
if (!skip) assertScalarSecrets(secrets, missing)
|
|
298
|
+
const decrypted =
|
|
299
|
+
missing.length > 0 && !skip
|
|
300
|
+
? decryptForConfig(layered, missing, allowKeyCommand)
|
|
301
|
+
: null
|
|
302
|
+
const merged = mergeLayers(layered.layers, decrypted)
|
|
303
|
+
const result = {}
|
|
304
|
+
for (const name of nameOrder(layered.layers)) {
|
|
305
|
+
if (!isUnset(target, name)) {
|
|
306
|
+
assign(
|
|
307
|
+
result,
|
|
308
|
+
name,
|
|
309
|
+
typedFromEnv(name, target[name], merged, secrets.get(name))
|
|
310
|
+
)
|
|
311
|
+
} else if (Object.hasOwn(merged, name)) {
|
|
312
|
+
const value = merged[name]
|
|
313
|
+
assign(result, name, value)
|
|
314
|
+
if (isExportable(name, value)) target[name] = toEnvValue(name, value)
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
return result
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
module.exports = {
|
|
321
|
+
assertEnv,
|
|
322
|
+
config,
|
|
323
|
+
decryptDocument,
|
|
324
|
+
fill,
|
|
325
|
+
get,
|
|
326
|
+
load,
|
|
327
|
+
loadSecrets,
|
|
328
|
+
names,
|
|
329
|
+
readLayers,
|
|
330
|
+
resolveOptions
|
|
331
|
+
}
|