@dotenvx/dotenvx 0.3.9 → 0.5.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/README.md CHANGED
@@ -157,7 +157,12 @@ $ dotenvx run --env-file=.env.local --env-file=.env -- node index.js
157
157
 
158
158
  ## Encrypt Your Env Files
159
159
 
160
- WIP
160
+ ```
161
+ $ dotenvx encrypt
162
+ ```
163
+
164
+ > This will encrypt your `.env` file to a `.env.vault` file. Commit your `.env.vault` file safely to code.
165
+ > This will also generate a `.env.keys` file. Do NOT commit this file to code. Keep your `.env.keys` secret. 🤫
161
166
 
162
167
   
163
168
 
package/package.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.3.9",
2
+ "version": "0.5.0",
3
3
  "name": "@dotenvx/dotenvx",
4
4
  "description": "a better dotenv–from the creator of `dotenv`",
5
5
  "author": "@motdotla",
@@ -26,7 +26,8 @@
26
26
  "dependencies": {
27
27
  "commander": "^11.1.0",
28
28
  "dotenv": "^16.3.1",
29
- "winston": "^3.11.0"
29
+ "winston": "^3.11.0",
30
+ "xxhashjs": "^0.2.2"
30
31
  },
31
32
  "devDependencies": {
32
33
  "jest": "^29.7.0",
@@ -23,7 +23,7 @@ program
23
23
 
24
24
  if (options.logLevel) {
25
25
  logger.level = options.logLevel
26
- logger.debug(`Setting log level to ${options.logLevel}`)
26
+ logger.debug(`setting log level to ${options.logLevel}`)
27
27
  }
28
28
 
29
29
  // --quiet overides --log-level. only errors will be shown
@@ -48,63 +48,225 @@ program
48
48
  .description(packageJson.description)
49
49
  .version(packageJson.version)
50
50
 
51
+ // dotenvx run -- node index.js
51
52
  program.command('run')
52
53
  .description('inject env variables into your application process')
53
- .option('-f, --env-file <paths...>', 'path to your env file', '.env')
54
+ .option('-f, --env-file <paths...>', 'path(s) to your env file(s)', '.env')
54
55
  .option('-o, --overload', 'override existing env variables')
55
56
  .action(function () {
56
- // injecting 1 environment variable from ${options.envFile}
57
57
  const options = this.opts()
58
- logger.debug('Configuring options')
58
+ logger.debug('configuring options')
59
59
  logger.debug(options)
60
60
 
61
- // convert to array if needed
62
- let optionEnvFile = options.envFile
63
- if (!Array.isArray(optionEnvFile)) {
64
- optionEnvFile = [optionEnvFile]
65
- }
61
+ // load from .env.vault file
62
+ if (process.env.DOTENV_KEY && process.env.DOTENV_KEY.length > 0) {
63
+ const filepath = helpers.resolvePath('.env.vault')
64
+
65
+ if (!fs.existsSync(filepath)) {
66
+ logger.error(`you set DOTENV_KEY but your .env.vault file is missing: ${filepath}`)
67
+ } else {
68
+ logger.verbose(`injecting encrypted env from ${filepath}`)
69
+
70
+ try {
71
+ logger.debug(`reading encrypted env from ${filepath}`)
72
+ const src = fs.readFileSync(filepath, { encoding: ENCODING })
73
+
74
+ logger.debug(`parsing encrypted env from ${filepath}`)
75
+ const parsedVault = main.parse(src)
76
+
77
+ logger.debug(`decrypting encrypted env from ${filepath}`)
78
+ // handle scenario for comma separated keys - for use with key rotation
79
+ // example: DOTENV_KEY="dotenv://:key_1234@dotenv.org/vault/.env.vault?environment=prod,dotenv://:key_7890@dotenv.org/vault/.env.vault?environment=prod"
80
+ const dotenvKeys = process.env.DOTENV_KEY.split(',')
81
+ const length = dotenvKeys.length
66
82
 
67
- const env = {}
68
- const readableFilepaths = new Set()
69
- const populated = new Set()
83
+ let decrypted
84
+ for (let i = 0; i < length; i++) {
85
+ try {
86
+ // Get full dotenvKey
87
+ const dotenvKey = dotenvKeys[i].trim()
70
88
 
71
- for (const envFilepath of optionEnvFile) {
72
- const filepath = helpers.resolvePath(envFilepath)
89
+ const key = helpers._parseEncryptionKeyFromDotenvKey(dotenvKey)
90
+ const ciphertext = helpers._parseCipherTextFromDotenvKeyAndParsedVault(dotenvKey, parsedVault)
73
91
 
74
- logger.verbose(`Loading env from ${filepath}`)
92
+ // Decrypt
93
+ decrypted = main.decrypt(ciphertext, key)
75
94
 
76
- try {
77
- logger.debug(`Reading env from ${filepath}`)
78
- const src = fs.readFileSync(filepath, { encoding: ENCODING })
95
+ break
96
+ } catch (error) {
97
+ // last key
98
+ if (i + 1 >= length) {
99
+ throw error
100
+ }
101
+ // try next key
102
+ }
103
+ }
104
+ logger.debug(decrypted)
79
105
 
80
- logger.debug(`Parsing env from ${filepath}`)
81
- const parsed = main.parse(src)
106
+ logger.debug(`parsing decrypted env from ${filepath}`)
107
+ const parsed = main.parse(decrypted)
82
108
 
83
- logger.debug(`Populating env from ${filepath}`)
84
- const result = main.populate(process.env, parsed, options.overload)
109
+ logger.debug(`writing decrypted env from ${filepath}`)
110
+ const result = main.write(process.env, parsed, options.overload)
85
111
 
86
- readableFilepaths.add(envFilepath)
87
- result.populated.forEach(key => populated.add(key))
88
- } catch (e) {
89
- logger.warn(e)
112
+ logger.info(`injecting ${result.written.size} environment ${helpers.pluralize('variable', result.written.size)} from encrypted .env.vault`)
113
+ } catch (e) {
114
+ logger.error(e)
115
+ }
90
116
  }
91
- }
117
+ } else {
118
+ // convert to array if needed
119
+ let optionEnvFile = options.envFile
120
+ if (!Array.isArray(optionEnvFile)) {
121
+ optionEnvFile = [optionEnvFile]
122
+ }
123
+
124
+ const readableFilepaths = new Set()
125
+ const written = new Set()
126
+
127
+ for (const envFilepath of optionEnvFile) {
128
+ const filepath = helpers.resolvePath(envFilepath)
129
+
130
+ logger.verbose(`injecting env from ${filepath}`)
131
+
132
+ try {
133
+ logger.debug(`reading env from ${filepath}`)
134
+ const src = fs.readFileSync(filepath, { encoding: ENCODING })
92
135
 
93
- if (readableFilepaths.size > 0) {
94
- logger.info(`Injecting ${populated.size} environment variables from ${[...readableFilepaths]}`)
136
+ logger.debug(`parsing env from ${filepath}`)
137
+ const parsed = main.parse(src)
138
+
139
+ logger.debug(`writing env from ${filepath}`)
140
+ const result = main.write(process.env, parsed, options.overload)
141
+
142
+ readableFilepaths.add(envFilepath)
143
+ result.written.forEach(key => written.add(key))
144
+ } catch (e) {
145
+ logger.warn(e)
146
+ }
147
+ }
148
+
149
+ if (readableFilepaths.size > 0) {
150
+ logger.info(`injecting ${written.size} environment ${helpers.pluralize('variable', written.size)} from ${[...readableFilepaths]}`)
151
+ }
95
152
  }
96
153
 
97
154
  // Extract command and arguments after '--'
98
155
  const commandIndex = process.argv.indexOf('--')
99
156
  if (commandIndex === -1 || commandIndex === process.argv.length - 1) {
100
- logger.error('At least one argument is required after the run command, received 0.')
101
- logger.error('Exiting')
157
+ logger.error('at least one argument is required after the run command, received 0.')
102
158
  process.exit(1)
103
159
  } else {
104
160
  const subCommand = process.argv.slice(commandIndex + 1)
105
161
 
106
- helpers.executeCommand(subCommand, env)
162
+ helpers.executeCommand(subCommand, process.env)
163
+ }
164
+ })
165
+
166
+ // dotenvx encrypt
167
+ program.command('encrypt')
168
+ .description('encrypt .env.* to .env.vault')
169
+ .option('-f, --env-file <paths...>', 'path(s) to your env file(s)', helpers.findEnvFiles('./'))
170
+ .action(function () {
171
+ const options = this.opts()
172
+ logger.debug('configuring options')
173
+ logger.debug(options)
174
+
175
+ let optionEnvFile = options.envFile
176
+ if (!Array.isArray(optionEnvFile)) {
177
+ optionEnvFile = [optionEnvFile]
178
+ }
179
+
180
+ try {
181
+ logger.verbose(`generating .env.keys from ${optionEnvFile}`)
182
+
183
+ const dotenvKeys = (main.configDotenv({ path: '.env.keys' }).parsed || {})
184
+
185
+ for (const envFilepath of optionEnvFile) {
186
+ const filepath = helpers.resolvePath(envFilepath)
187
+ if (!fs.existsSync(filepath)) {
188
+ throw new Error(`file does not exist: ${filepath}`)
189
+ }
190
+
191
+ const environment = helpers.guessEnvironment(filepath)
192
+ const key = `DOTENV_KEY_${environment.toUpperCase()}`
193
+
194
+ let value = dotenvKeys[key]
195
+
196
+ // first time seeing new DOTENV_KEY_${environment}
197
+ if (!value || value.length === 0) {
198
+ logger.verbose(`generating ${key}`)
199
+ value = helpers.generateDotenvKey(environment)
200
+ logger.debug(`generating ${key} as ${value}`)
201
+
202
+ dotenvKeys[key] = value
203
+ } else {
204
+ logger.verbose(`existing ${key}`)
205
+ logger.debug(`existing ${key} as ${value}`)
206
+ }
207
+ }
208
+
209
+ let keysData = `#/!!!!!!!!!!!!!!!!!!!.env.keys!!!!!!!!!!!!!!!!!!!!!!/
210
+ #/ DOTENV_KEYs. DO NOT commit to source control /
211
+ #/ [how it works](https://dotenv.org/env-keys) /
212
+ #/--------------------------------------------------/\n`
213
+
214
+ for (const key in dotenvKeys) {
215
+ const value = dotenvKeys[key]
216
+ keysData += `${key}="${value}"\n`
217
+ }
218
+
219
+ fs.writeFileSync('.env.keys', keysData)
220
+ } catch (e) {
221
+ logger.error(e)
222
+ process.exit(1)
107
223
  }
224
+
225
+ try {
226
+ logger.verbose(`generating .env.vault from ${optionEnvFile}`)
227
+
228
+ const dotenvKeys = (main.configDotenv({ path: '.env.keys' }).parsed || {})
229
+ const dotenvVaults = (main.configDotenv({ path: '.env.vault' }).parsed || {})
230
+
231
+ for (const envFilepath of optionEnvFile) {
232
+ const filepath = helpers.resolvePath(envFilepath)
233
+ const environment = helpers.guessEnvironment(filepath)
234
+ const vault = `DOTENV_VAULT_${environment.toUpperCase()}`
235
+
236
+ let ciphertext = dotenvVaults[vault]
237
+ const dotenvKey = dotenvKeys[`DOTENV_KEY_${environment.toUpperCase()}`]
238
+
239
+ if (!ciphertext || ciphertext.length === 0 || helpers.changed(ciphertext, dotenvKey, filepath, ENCODING)) {
240
+ logger.verbose(`encrypting ${vault}`)
241
+ ciphertext = helpers.encryptFile(filepath, dotenvKey, ENCODING)
242
+ logger.verbose(`encrypting ${vault} as ${ciphertext}`)
243
+
244
+ dotenvVaults[vault] = ciphertext
245
+ } else {
246
+ logger.verbose(`existing ${vault}`)
247
+ logger.debug(`existing ${vault} as ${ciphertext}`)
248
+ }
249
+ }
250
+
251
+ let vaultData = `#/-------------------.env.vault---------------------/
252
+ #/ cloud-agnostic vaulting standard /
253
+ #/ [how it works](https://dotenv.org/env-vault) /
254
+ #/--------------------------------------------------/\n\n`
255
+
256
+ for (const vault in dotenvVaults) {
257
+ const value = dotenvVaults[vault]
258
+ const environment = vault.replace('DOTENV_VAULT_', '').toLowerCase()
259
+ vaultData += `# ${environment}\n`
260
+ vaultData += `${vault}="${value}"\n\n`
261
+ }
262
+
263
+ fs.writeFileSync('.env.vault', vaultData)
264
+ } catch (e) {
265
+ logger.error(e)
266
+ process.exit(1)
267
+ }
268
+
269
+ logger.info(`encrypted ${optionEnvFile} to .env.vault`)
108
270
  })
109
271
 
110
272
  program.parse(process.argv)
@@ -1,5 +1,14 @@
1
+ const fs = require('fs')
1
2
  const path = require('path')
3
+ const crypto = require('crypto')
2
4
  const { spawn } = require('child_process')
5
+ const xxhash = require('xxhashjs')
6
+ const XXHASH_SEED = 0xABCD
7
+ const NONCE_BYTES = 12
8
+
9
+ const main = require('./../lib/main')
10
+
11
+ const RESERVED_ENV_FILES = ['.env.vault', '.env.projects', '.env.keys', '.env.me', '.env.x']
3
12
 
4
13
  // resolve path based on current running process location
5
14
  const resolvePath = function (filepath) {
@@ -22,7 +31,139 @@ const executeCommand = function (subCommand, env) {
22
31
  })
23
32
  }
24
33
 
34
+ const pluralize = function (word, count) {
35
+ // simple pluralization: add 's' at the end
36
+ if (count === 0 || count > 1) {
37
+ return word + 's'
38
+ } else {
39
+ return word
40
+ }
41
+ }
42
+
43
+ const findEnvFiles = function (directory) {
44
+ const files = fs.readdirSync(directory)
45
+
46
+ const envFiles = files.filter(file =>
47
+ file.startsWith('.env') &&
48
+ !file.endsWith('.previous') &&
49
+ !RESERVED_ENV_FILES.includes(file)
50
+ )
51
+
52
+ return envFiles
53
+ }
54
+
55
+ const guessEnvironment = function (file) {
56
+ const splitFile = file.split('.')
57
+ const possibleEnvironment = splitFile[2] // ['', 'env', environment']
58
+
59
+ if (!possibleEnvironment || possibleEnvironment.length === 0) {
60
+ return 'development'
61
+ }
62
+
63
+ return possibleEnvironment
64
+ }
65
+
66
+ const generateDotenvKey = function (environment) {
67
+ const rand = crypto.randomBytes(32).toString('hex')
68
+
69
+ return `dotenv://:key_${rand}@dotenvx.com/vault/.env.vault?environment=${environment.toLowerCase()}`
70
+ }
71
+
72
+ const encryptFile = function (filepath, dotenvKey, encoding) {
73
+ const key = _parseEncryptionKeyFromDotenvKey(dotenvKey)
74
+ const message = fs.readFileSync(filepath, encoding)
75
+
76
+ const ciphertext = encrypt(key, message)
77
+
78
+ return ciphertext
79
+ }
80
+
81
+ const encrypt = function (key, message) {
82
+ // set up nonce
83
+ const nonce = crypto.randomBytes(NONCE_BYTES)
84
+
85
+ // set up cipher
86
+ const cipher = crypto.createCipheriv('aes-256-gcm', key, nonce)
87
+
88
+ // generate ciphertext
89
+ let ciphertext = ''
90
+ ciphertext += cipher.update(message, 'utf8', 'hex')
91
+ ciphertext += cipher.final('hex')
92
+ ciphertext += cipher.getAuthTag().toString('hex')
93
+
94
+ // prepend nonce
95
+ ciphertext = nonce.toString('hex') + ciphertext
96
+
97
+ // base64 encode output
98
+ return Buffer.from(ciphertext, 'hex').toString('base64')
99
+ }
100
+
101
+ const changed = function (ciphertext, dotenvKey, filepath, encoding) {
102
+ const key = _parseEncryptionKeyFromDotenvKey(dotenvKey)
103
+ const decrypted = main.decrypt(ciphertext, key)
104
+ const raw = fs.readFileSync(filepath, encoding)
105
+
106
+ return hash(decrypted) !== hash(raw)
107
+ }
108
+
109
+ const hash = function (str) {
110
+ return xxhash.h32(str, XXHASH_SEED).toString(16)
111
+ }
112
+
113
+ const _parseEncryptionKeyFromDotenvKey = function (dotenvKey) {
114
+ // Parse DOTENV_KEY. Format is a URI
115
+ let uri
116
+ try {
117
+ uri = new URL(dotenvKey)
118
+ } catch (e) {
119
+ throw new Error(`INVALID_DOTENV_KEY: ${e.message}`)
120
+ }
121
+
122
+ // Get decrypt key
123
+ const key = uri.password
124
+ if (!key) {
125
+ throw new Error('INVALID_DOTENV_KEY: Missing key part')
126
+ }
127
+
128
+ return Buffer.from(key.slice(-64), 'hex')
129
+ }
130
+
131
+ const _parseCipherTextFromDotenvKeyAndParsedVault = function (dotenvKey, parsedVault) {
132
+ // Parse DOTENV_KEY. Format is a URI
133
+ let uri
134
+ try {
135
+ uri = new URL(dotenvKey)
136
+ } catch (e) {
137
+ throw new Error(`INVALID_DOTENV_KEY: ${e.message}`)
138
+ }
139
+
140
+ // Get environment
141
+ const environment = uri.searchParams.get('environment')
142
+ if (!environment) {
143
+ throw new Error('INVALID_DOTENV_KEY: Missing environment part')
144
+ }
145
+
146
+ // Get ciphertext payload
147
+ const environmentKey = `DOTENV_VAULT_${environment.toUpperCase()}`
148
+ const ciphertext = parsedVault[environmentKey] // DOTENV_VAULT_PRODUCTION
149
+ if (!ciphertext) {
150
+ throw new Error(`NOT_FOUND_DOTENV_ENVIRONMENT: cannot locate environment ${environmentKey} in your .env.vault file`)
151
+ }
152
+
153
+ return ciphertext
154
+ }
155
+
25
156
  module.exports = {
26
157
  resolvePath,
27
- executeCommand
158
+ executeCommand,
159
+ pluralize,
160
+ findEnvFiles,
161
+ guessEnvironment,
162
+ generateDotenvKey,
163
+ encryptFile,
164
+ encrypt,
165
+ changed,
166
+ hash,
167
+ _parseEncryptionKeyFromDotenvKey,
168
+ _parseCipherTextFromDotenvKeyAndParsedVault
28
169
  }
package/src/lib/main.js CHANGED
@@ -5,6 +5,14 @@ const config = function (options) {
5
5
  return dotenv.config(options)
6
6
  }
7
7
 
8
+ const decrypt = function (encrypted, keyStr) {
9
+ return dotenv.decrypt(encrypted, keyStr)
10
+ }
11
+
12
+ const configDotenv = function (options) {
13
+ return dotenv.configDotenv(options)
14
+ }
15
+
8
16
  const parse = function (src) {
9
17
  const result = dotenv.parse(src)
10
18
 
@@ -13,12 +21,12 @@ const parse = function (src) {
13
21
  return result
14
22
  }
15
23
 
16
- const populate = function (processEnv = {}, parsed = {}, overload = false) {
24
+ const write = function (processEnv = {}, parsed = {}, overload = false) {
17
25
  if (typeof parsed !== 'object') {
18
- throw new Error('OBJECT_REQUIRED: Please check the parsed argument being passed to populate')
26
+ throw new Error('OBJECT_REQUIRED: Please check the parsed argument being passed to write')
19
27
  }
20
28
 
21
- const populated = new Set()
29
+ const written = new Set()
22
30
  const preExisting = new Set()
23
31
 
24
32
  // set processEnv
@@ -26,7 +34,7 @@ const populate = function (processEnv = {}, parsed = {}, overload = false) {
26
34
  if (Object.prototype.hasOwnProperty.call(processEnv, key)) {
27
35
  if (overload === true) {
28
36
  processEnv[key] = parsed[key]
29
- populated.add(key)
37
+ written.add(key)
30
38
 
31
39
  logger.verbose(`${key} set`)
32
40
  logger.debug(`${key} set to ${parsed[key]}`)
@@ -38,7 +46,7 @@ const populate = function (processEnv = {}, parsed = {}, overload = false) {
38
46
  }
39
47
  } else {
40
48
  processEnv[key] = parsed[key]
41
- populated.add(key)
49
+ written.add(key)
42
50
 
43
51
  logger.verbose(`${key} set`)
44
52
  logger.debug(`${key} set to ${parsed[key]}`)
@@ -46,13 +54,15 @@ const populate = function (processEnv = {}, parsed = {}, overload = false) {
46
54
  }
47
55
 
48
56
  return {
49
- populated,
57
+ written,
50
58
  preExisting
51
59
  }
52
60
  }
53
61
 
54
62
  module.exports = {
55
63
  config,
64
+ configDotenv,
65
+ decrypt,
56
66
  parse,
57
- populate
67
+ write
58
68
  }
@@ -7,10 +7,14 @@ const transports = winston.transports
7
7
 
8
8
  const packageJson = require('./packageJson')
9
9
 
10
+ function pad (word) {
11
+ return word.padEnd(9, ' ')
12
+ }
13
+
10
14
  const dotenvxFormat = printf(({ level, message, label, timestamp }) => {
11
15
  const formattedMessage = typeof message === 'object' ? JSON.stringify(message) : message
12
16
 
13
- return `[dotenvx@${packageJson.version}][${level.toUpperCase()}] ${formattedMessage}`
17
+ return `[dotenvx@${packageJson.version}]${pad(`[${level.toUpperCase()}]`)} ${formattedMessage}`
14
18
  })
15
19
 
16
20
  const logger = createLogger({