@dotenvx/dotenvx 0.3.9 → 0.4.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.4.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",
@@ -2,6 +2,7 @@
2
2
 
3
3
  const fs = require('fs')
4
4
  const { Command } = require('commander')
5
+ const dotenv = require('dotenv')
5
6
  const program = new Command()
6
7
 
7
8
  // constants
@@ -23,7 +24,7 @@ program
23
24
 
24
25
  if (options.logLevel) {
25
26
  logger.level = options.logLevel
26
- logger.debug(`Setting log level to ${options.logLevel}`)
27
+ logger.debug(`setting log level to ${options.logLevel}`)
27
28
  }
28
29
 
29
30
  // --quiet overides --log-level. only errors will be shown
@@ -48,14 +49,14 @@ program
48
49
  .description(packageJson.description)
49
50
  .version(packageJson.version)
50
51
 
52
+ // dotenvx run -- node index.js
51
53
  program.command('run')
52
54
  .description('inject env variables into your application process')
53
- .option('-f, --env-file <paths...>', 'path to your env file', '.env')
55
+ .option('-f, --env-file <paths...>', 'path(s) to your env file(s)', '.env')
54
56
  .option('-o, --overload', 'override existing env variables')
55
57
  .action(function () {
56
- // injecting 1 environment variable from ${options.envFile}
57
58
  const options = this.opts()
58
- logger.debug('Configuring options')
59
+ logger.debug('configuring options')
59
60
  logger.debug(options)
60
61
 
61
62
  // convert to array if needed
@@ -66,39 +67,38 @@ program.command('run')
66
67
 
67
68
  const env = {}
68
69
  const readableFilepaths = new Set()
69
- const populated = new Set()
70
+ const written = new Set()
70
71
 
71
72
  for (const envFilepath of optionEnvFile) {
72
73
  const filepath = helpers.resolvePath(envFilepath)
73
74
 
74
- logger.verbose(`Loading env from ${filepath}`)
75
+ logger.verbose(`injecting env from ${filepath}`)
75
76
 
76
77
  try {
77
- logger.debug(`Reading env from ${filepath}`)
78
+ logger.debug(`reading env from ${filepath}`)
78
79
  const src = fs.readFileSync(filepath, { encoding: ENCODING })
79
80
 
80
- logger.debug(`Parsing env from ${filepath}`)
81
+ logger.debug(`parsing env from ${filepath}`)
81
82
  const parsed = main.parse(src)
82
83
 
83
- logger.debug(`Populating env from ${filepath}`)
84
- const result = main.populate(process.env, parsed, options.overload)
84
+ logger.debug(`writing env from ${filepath}`)
85
+ const result = main.write(process.env, parsed, options.overload)
85
86
 
86
87
  readableFilepaths.add(envFilepath)
87
- result.populated.forEach(key => populated.add(key))
88
+ result.written.forEach(key => written.add(key))
88
89
  } catch (e) {
89
90
  logger.warn(e)
90
91
  }
91
92
  }
92
93
 
93
94
  if (readableFilepaths.size > 0) {
94
- logger.info(`Injecting ${populated.size} environment variables from ${[...readableFilepaths]}`)
95
+ logger.info(`injecting ${written.size} environment ${helpers.pluralize('variable', written.size)} from ${[...readableFilepaths]}`)
95
96
  }
96
97
 
97
98
  // Extract command and arguments after '--'
98
99
  const commandIndex = process.argv.indexOf('--')
99
100
  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')
101
+ logger.error('at least one argument is required after the run command, received 0.')
102
102
  process.exit(1)
103
103
  } else {
104
104
  const subCommand = process.argv.slice(commandIndex + 1)
@@ -107,4 +107,112 @@ program.command('run')
107
107
  }
108
108
  })
109
109
 
110
+ // dotenvx encrypt
111
+ program.command('encrypt')
112
+ .description('encrypt .env.* to .env.vault')
113
+ .option('-f, --env-file <paths...>', 'path(s) to your env file(s)', helpers.findEnvFiles('./'))
114
+ .action(function () {
115
+ const options = this.opts()
116
+ logger.debug('configuring options')
117
+ logger.debug(options)
118
+
119
+ let optionEnvFile = options.envFile
120
+ if (!Array.isArray(optionEnvFile)) {
121
+ optionEnvFile = [optionEnvFile]
122
+ }
123
+
124
+ try {
125
+ logger.verbose(`generating .env.keys from ${optionEnvFile}`)
126
+
127
+ const dotenvKeys = (dotenv.configDotenv({ path: '.env.keys' }).parsed || {})
128
+
129
+ for (const envFilepath of optionEnvFile) {
130
+ const filepath = helpers.resolvePath(envFilepath)
131
+ if (!fs.existsSync(filepath)) {
132
+ throw new Error(`file does not exist: ${filepath}`)
133
+ }
134
+
135
+ const environment = helpers.guessEnvironment(filepath)
136
+ const key = `DOTENV_KEY_${environment.toUpperCase()}`
137
+
138
+ let value = dotenvKeys[key]
139
+
140
+ // first time seeing new DOTENV_KEY_${environment}
141
+ if (!value || value.length === 0) {
142
+ logger.verbose(`generating ${key}`)
143
+ value = helpers.generateDotenvKey(environment)
144
+ logger.debug(`generating ${key} as ${value}`)
145
+
146
+ dotenvKeys[key] = value
147
+ } else {
148
+ logger.verbose(`existing ${key}`)
149
+ logger.debug(`existing ${key} as ${value}`)
150
+ }
151
+ }
152
+
153
+ let keysData = `#/!!!!!!!!!!!!!!!!!!!.env.keys!!!!!!!!!!!!!!!!!!!!!!/
154
+ #/ DOTENV_KEYs. DO NOT commit to source control /
155
+ #/ [how it works](https://dotenv.org/env-keys) /
156
+ #/--------------------------------------------------/\n`
157
+
158
+ for (const key in dotenvKeys) {
159
+ const value = dotenvKeys[key]
160
+ keysData += `${key}="${value}"\n`
161
+ }
162
+
163
+ fs.writeFileSync('.env.keys', keysData)
164
+ } catch (e) {
165
+ logger.error(e)
166
+ process.exit(1)
167
+ }
168
+
169
+ try {
170
+ logger.verbose(`generating .env.vault from ${optionEnvFile}`)
171
+
172
+ const dotenvKeys = (dotenv.configDotenv({ path: '.env.keys' }).parsed || {})
173
+ const dotenvVaults = (dotenv.configDotenv({ path: '.env.vault' }).parsed || {})
174
+
175
+ for (const envFilepath of optionEnvFile) {
176
+ const filepath = helpers.resolvePath(envFilepath)
177
+ const environment = helpers.guessEnvironment(filepath)
178
+ const vault = `DOTENV_VAULT_${environment.toUpperCase()}`
179
+
180
+ let ciphertext = dotenvVaults[vault]
181
+ const dotenvKey = dotenvKeys[`DOTENV_KEY_${environment.toUpperCase()}`]
182
+
183
+ if (!ciphertext || ciphertext.length === 0 || helpers.changed(ciphertext, dotenvKey, filepath, ENCODING)) {
184
+ logger.verbose(`encrypting ${vault}`)
185
+ ciphertext = helpers.encryptFile(filepath, dotenvKey, ENCODING)
186
+ logger.verbose(`encrypting ${vault} as ${ciphertext}`)
187
+
188
+ dotenvVaults[vault] = ciphertext
189
+ } else {
190
+ logger.verbose(`existing ${vault}`)
191
+ logger.debug(`existing ${vault} as ${ciphertext}`)
192
+ }
193
+ }
194
+
195
+ let vaultData = `#/-------------------.env.vault---------------------/
196
+ #/ cloud-agnostic vaulting standard /
197
+ #/ [how it works](https://dotenv.org/env-vault) /
198
+ #/--------------------------------------------------/\n\n`
199
+
200
+ for (const vault in dotenvVaults) {
201
+ const value = dotenvVaults[vault]
202
+ const environment = vault.replace('DOTENV_VAULT_', '').toLowerCase()
203
+ vaultData += `# ${environment}\n`
204
+ vaultData += `${vault}="${value}"\n\n`
205
+ }
206
+
207
+ fs.writeFileSync('.env.vault', vaultData)
208
+ } catch (e) {
209
+ logger.error(e)
210
+ process.exit(1)
211
+ }
212
+
213
+ logger.info(`encrypted ${optionEnvFile} to .env.vault`)
214
+
215
+ // logger.info(`encrypting`)
216
+ })
217
+
110
218
  program.parse(process.argv)
@@ -1,5 +1,13 @@
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
+
8
+ const main = require('./../lib/main')
9
+
10
+ const RESERVED_ENV_FILES = ['.env.vault', '.env.projects', '.env.keys', '.env.me', '.env.x']
3
11
 
4
12
  // resolve path based on current running process location
5
13
  const resolvePath = function (filepath) {
@@ -22,7 +30,118 @@ const executeCommand = function (subCommand, env) {
22
30
  })
23
31
  }
24
32
 
33
+ const pluralize = function (word, count) {
34
+ // simple pluralization: add 's' at the end
35
+ if (count === 0 || count > 1) {
36
+ return word + 's'
37
+ } else {
38
+ return word
39
+ }
40
+ }
41
+
42
+ const findEnvFiles = function (directory) {
43
+ const files = fs.readdirSync(directory)
44
+
45
+ const envFiles = files.filter(file =>
46
+ file.startsWith('.env') &&
47
+ !file.endsWith('.previous') &&
48
+ !RESERVED_ENV_FILES.includes(file)
49
+ )
50
+
51
+ return envFiles
52
+ }
53
+
54
+ const guessEnvironment = function (file) {
55
+ const splitFile = file.split('.')
56
+ const possibleEnvironment = splitFile[2] // ['', 'env', environment']
57
+
58
+ if (!possibleEnvironment || possibleEnvironment.length === 0) {
59
+ return 'development'
60
+ }
61
+
62
+ return possibleEnvironment
63
+ }
64
+
65
+ const generateDotenvKey = function (environment) {
66
+ const rand = crypto.randomBytes(32).toString('hex')
67
+
68
+ return `dotenv://:key_${rand}@dotenvx.com/vault/.env.vault?environment=${environment.toLowerCase()}`
69
+ }
70
+
71
+ const encryptFile = function (filepath, dotenvKey, encoding) {
72
+ const key = this._parseEncryptionKeyFromDotenvKey(dotenvKey)
73
+ const message = fs.readFileSync(filepath, encoding)
74
+
75
+ const ciphertext = this.encrypt(key, message)
76
+
77
+ return ciphertext
78
+ }
79
+
80
+ const encrypt = function (key, message) {
81
+ // set up nonce
82
+ const nonce = this._generateNonce()
83
+
84
+ // set up cipher
85
+ const cipher = crypto.createCipheriv('aes-256-gcm', key, nonce)
86
+
87
+ // generate ciphertext
88
+ let ciphertext = ''
89
+ ciphertext += cipher.update(message, 'utf8', 'hex')
90
+ ciphertext += cipher.final('hex')
91
+ ciphertext += cipher.getAuthTag().toString('hex')
92
+
93
+ // prepend nonce
94
+ ciphertext = nonce.toString('hex') + ciphertext
95
+
96
+ // base64 encode output
97
+ return Buffer.from(ciphertext, 'hex').toString('base64')
98
+ }
99
+
100
+ const changed = function (ciphertext, dotenvKey, filepath, encoding) {
101
+ const key = this._parseEncryptionKeyFromDotenvKey(dotenvKey)
102
+ const decrypted = main.decrypt(ciphertext, key)
103
+ const raw = fs.readFileSync(filepath, encoding)
104
+
105
+ return this.hash(decrypted) !== this.hash(raw)
106
+ }
107
+
108
+ const hash = function (str) {
109
+ return xxhash.h32(str, XXHASH_SEED).toString(16)
110
+ }
111
+
112
+ const _parseEncryptionKeyFromDotenvKey = function (dotenvKey) {
113
+ // Parse DOTENV_KEY. Format is a URI
114
+ const uri = new URL(dotenvKey)
115
+
116
+ // Get decrypt key
117
+ const key = uri.password
118
+ if (!key) {
119
+ throw new Error('INVALID_DOTENV_KEY: Missing key part')
120
+ }
121
+
122
+ return Buffer.from(key.slice(-64), 'hex')
123
+ }
124
+
125
+ const _generateNonce = function () {
126
+ return crypto.randomBytes(this._nonceBytes())
127
+ }
128
+
129
+ const _nonceBytes = function () {
130
+ return 12
131
+ }
132
+
25
133
  module.exports = {
26
134
  resolvePath,
27
- executeCommand
135
+ executeCommand,
136
+ pluralize,
137
+ findEnvFiles,
138
+ guessEnvironment,
139
+ generateDotenvKey,
140
+ encryptFile,
141
+ encrypt,
142
+ changed,
143
+ hash,
144
+ _parseEncryptionKeyFromDotenvKey,
145
+ _generateNonce,
146
+ _nonceBytes
28
147
  }
package/src/lib/main.js CHANGED
@@ -5,6 +5,10 @@ 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
+
8
12
  const parse = function (src) {
9
13
  const result = dotenv.parse(src)
10
14
 
@@ -13,12 +17,12 @@ const parse = function (src) {
13
17
  return result
14
18
  }
15
19
 
16
- const populate = function (processEnv = {}, parsed = {}, overload = false) {
20
+ const write = function (processEnv = {}, parsed = {}, overload = false) {
17
21
  if (typeof parsed !== 'object') {
18
- throw new Error('OBJECT_REQUIRED: Please check the parsed argument being passed to populate')
22
+ throw new Error('OBJECT_REQUIRED: Please check the parsed argument being passed to write')
19
23
  }
20
24
 
21
- const populated = new Set()
25
+ const written = new Set()
22
26
  const preExisting = new Set()
23
27
 
24
28
  // set processEnv
@@ -26,7 +30,7 @@ const populate = function (processEnv = {}, parsed = {}, overload = false) {
26
30
  if (Object.prototype.hasOwnProperty.call(processEnv, key)) {
27
31
  if (overload === true) {
28
32
  processEnv[key] = parsed[key]
29
- populated.add(key)
33
+ written.add(key)
30
34
 
31
35
  logger.verbose(`${key} set`)
32
36
  logger.debug(`${key} set to ${parsed[key]}`)
@@ -38,7 +42,7 @@ const populate = function (processEnv = {}, parsed = {}, overload = false) {
38
42
  }
39
43
  } else {
40
44
  processEnv[key] = parsed[key]
41
- populated.add(key)
45
+ written.add(key)
42
46
 
43
47
  logger.verbose(`${key} set`)
44
48
  logger.debug(`${key} set to ${parsed[key]}`)
@@ -46,13 +50,14 @@ const populate = function (processEnv = {}, parsed = {}, overload = false) {
46
50
  }
47
51
 
48
52
  return {
49
- populated,
53
+ written,
50
54
  preExisting
51
55
  }
52
56
  }
53
57
 
54
58
  module.exports = {
55
59
  config,
60
+ decrypt,
56
61
  parse,
57
- populate
62
+ write
58
63
  }
@@ -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({