main.js 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423
  1. const fs = require('fs')
  2. const path = require('path')
  3. const os = require('os')
  4. const crypto = require('crypto')
  5. // Array of tips to display randomly
  6. const TIPS = [
  7. '◈ encrypted .env [www.dotenvx.com]',
  8. '◈ secrets for agents [www.dotenvx.com]',
  9. '⌁ auth for agents [www.vestauth.com]',
  10. '⌘ custom filepath { path: \'/custom/path/.env\' }',
  11. '⌘ enable debugging { debug: true }',
  12. '⌘ override existing { override: true }',
  13. '⌘ suppress logs { quiet: true }',
  14. '⌘ multiple files { path: [\'.env.local\', \'.env\'] }'
  15. ]
  16. // Get a random tip from the tips array
  17. function _getRandomTip () {
  18. return TIPS[Math.floor(Math.random() * TIPS.length)]
  19. }
  20. function parseBoolean (value) {
  21. if (typeof value === 'string') {
  22. return !['false', '0', 'no', 'off', ''].includes(value.toLowerCase())
  23. }
  24. return Boolean(value)
  25. }
  26. function supportsAnsi () {
  27. return process.stdout.isTTY // && process.env.TERM !== 'dumb'
  28. }
  29. function dim (text) {
  30. return supportsAnsi() ? `\x1b[2m${text}\x1b[0m` : text
  31. }
  32. const LINE = /(?:^|^)\s*(?:export\s+)?([\w.-]+)(?:\s*=\s*?|:\s+?)(\s*'(?:\\'|[^'])*'|\s*"(?:\\"|[^"])*"|\s*`(?:\\`|[^`])*`|[^#\r\n]+)?\s*(?:#.*)?(?:$|$)/mg
  33. // Parse src into an Object
  34. function parse (src) {
  35. const obj = {}
  36. // Convert buffer to string
  37. let lines = src.toString()
  38. // Convert line breaks to same format
  39. lines = lines.replace(/\r\n?/mg, '\n')
  40. let match
  41. while ((match = LINE.exec(lines)) != null) {
  42. const key = match[1]
  43. // Default undefined or null to empty string
  44. let value = (match[2] || '')
  45. // Remove whitespace
  46. value = value.trim()
  47. // Check if double quoted
  48. const maybeQuote = value[0]
  49. // Remove surrounding quotes
  50. value = value.replace(/^(['"`])([\s\S]*)\1$/mg, '$2')
  51. // Expand newlines if double quoted
  52. if (maybeQuote === '"') {
  53. value = value.replace(/\\n/g, '\n')
  54. value = value.replace(/\\r/g, '\r')
  55. }
  56. // Add to object
  57. obj[key] = value
  58. }
  59. return obj
  60. }
  61. function _parseVault (options) {
  62. options = options || {}
  63. const vaultPath = _vaultPath(options)
  64. options.path = vaultPath // parse .env.vault
  65. const result = DotenvModule.configDotenv(options)
  66. if (!result.parsed) {
  67. const err = new Error(`MISSING_DATA: Cannot parse ${vaultPath} for an unknown reason`)
  68. err.code = 'MISSING_DATA'
  69. throw err
  70. }
  71. // handle scenario for comma separated keys - for use with key rotation
  72. // example: DOTENV_KEY="dotenv://:key_1234@dotenvx.com/vault/.env.vault?environment=prod,dotenv://:key_7890@dotenvx.com/vault/.env.vault?environment=prod"
  73. const keys = _dotenvKey(options).split(',')
  74. const length = keys.length
  75. let decrypted
  76. for (let i = 0; i < length; i++) {
  77. try {
  78. // Get full key
  79. const key = keys[i].trim()
  80. // Get instructions for decrypt
  81. const attrs = _instructions(result, key)
  82. // Decrypt
  83. decrypted = DotenvModule.decrypt(attrs.ciphertext, attrs.key)
  84. break
  85. } catch (error) {
  86. // last key
  87. if (i + 1 >= length) {
  88. throw error
  89. }
  90. // try next key
  91. }
  92. }
  93. // Parse decrypted .env string
  94. return DotenvModule.parse(decrypted)
  95. }
  96. function _warn (message) {
  97. console.error(`⚠ ${message}`)
  98. }
  99. function _debug (message) {
  100. console.log(`┆ ${message}`)
  101. }
  102. function _log (message) {
  103. console.log(`◇ ${message}`)
  104. }
  105. function _dotenvKey (options) {
  106. // prioritize developer directly setting options.DOTENV_KEY
  107. if (options && options.DOTENV_KEY && options.DOTENV_KEY.length > 0) {
  108. return options.DOTENV_KEY
  109. }
  110. // secondary infra already contains a DOTENV_KEY environment variable
  111. if (process.env.DOTENV_KEY && process.env.DOTENV_KEY.length > 0) {
  112. return process.env.DOTENV_KEY
  113. }
  114. // fallback to empty string
  115. return ''
  116. }
  117. function _instructions (result, dotenvKey) {
  118. // Parse DOTENV_KEY. Format is a URI
  119. let uri
  120. try {
  121. uri = new URL(dotenvKey)
  122. } catch (error) {
  123. if (error.code === 'ERR_INVALID_URL') {
  124. const err = new Error('INVALID_DOTENV_KEY: Wrong format. Must be in valid uri format like dotenv://:key_1234@dotenvx.com/vault/.env.vault?environment=development')
  125. err.code = 'INVALID_DOTENV_KEY'
  126. throw err
  127. }
  128. throw error
  129. }
  130. // Get decrypt key
  131. const key = uri.password
  132. if (!key) {
  133. const err = new Error('INVALID_DOTENV_KEY: Missing key part')
  134. err.code = 'INVALID_DOTENV_KEY'
  135. throw err
  136. }
  137. // Get environment
  138. const environment = uri.searchParams.get('environment')
  139. if (!environment) {
  140. const err = new Error('INVALID_DOTENV_KEY: Missing environment part')
  141. err.code = 'INVALID_DOTENV_KEY'
  142. throw err
  143. }
  144. // Get ciphertext payload
  145. const environmentKey = `DOTENV_VAULT_${environment.toUpperCase()}`
  146. const ciphertext = result.parsed[environmentKey] // DOTENV_VAULT_PRODUCTION
  147. if (!ciphertext) {
  148. const err = new Error(`NOT_FOUND_DOTENV_ENVIRONMENT: Cannot locate environment ${environmentKey} in your .env.vault file.`)
  149. err.code = 'NOT_FOUND_DOTENV_ENVIRONMENT'
  150. throw err
  151. }
  152. return { ciphertext, key }
  153. }
  154. function _vaultPath (options) {
  155. let possibleVaultPath = null
  156. if (options && options.path && options.path.length > 0) {
  157. if (Array.isArray(options.path)) {
  158. for (const filepath of options.path) {
  159. if (fs.existsSync(filepath)) {
  160. possibleVaultPath = filepath.endsWith('.vault') ? filepath : `${filepath}.vault`
  161. }
  162. }
  163. } else {
  164. possibleVaultPath = options.path.endsWith('.vault') ? options.path : `${options.path}.vault`
  165. }
  166. } else {
  167. possibleVaultPath = path.resolve(process.cwd(), '.env.vault')
  168. }
  169. if (fs.existsSync(possibleVaultPath)) {
  170. return possibleVaultPath
  171. }
  172. return null
  173. }
  174. function _resolveHome (envPath) {
  175. return envPath[0] === '~' ? path.join(os.homedir(), envPath.slice(1)) : envPath
  176. }
  177. function _configVault (options) {
  178. const debug = parseBoolean(process.env.DOTENV_CONFIG_DEBUG || (options && options.debug))
  179. const quiet = parseBoolean(process.env.DOTENV_CONFIG_QUIET || (options && options.quiet))
  180. if (debug || !quiet) {
  181. _log('loading env from encrypted .env.vault')
  182. }
  183. const parsed = DotenvModule._parseVault(options)
  184. let processEnv = process.env
  185. if (options && options.processEnv != null) {
  186. processEnv = options.processEnv
  187. }
  188. DotenvModule.populate(processEnv, parsed, options)
  189. return { parsed }
  190. }
  191. function configDotenv (options) {
  192. const dotenvPath = path.resolve(process.cwd(), '.env')
  193. let encoding = 'utf8'
  194. let processEnv = process.env
  195. if (options && options.processEnv != null) {
  196. processEnv = options.processEnv
  197. }
  198. let debug = parseBoolean(processEnv.DOTENV_CONFIG_DEBUG || (options && options.debug))
  199. let quiet = parseBoolean(processEnv.DOTENV_CONFIG_QUIET || (options && options.quiet))
  200. if (options && options.encoding) {
  201. encoding = options.encoding
  202. } else {
  203. if (debug) {
  204. _debug('no encoding is specified (UTF-8 is used by default)')
  205. }
  206. }
  207. let optionPaths = [dotenvPath] // default, look for .env
  208. if (options && options.path) {
  209. if (!Array.isArray(options.path)) {
  210. optionPaths = [_resolveHome(options.path)]
  211. } else {
  212. optionPaths = [] // reset default
  213. for (const filepath of options.path) {
  214. optionPaths.push(_resolveHome(filepath))
  215. }
  216. }
  217. }
  218. // Build the parsed data in a temporary object (because we need to return it). Once we have the final
  219. // parsed data, we will combine it with process.env (or options.processEnv if provided).
  220. let lastError
  221. const parsedAll = {}
  222. for (const path of optionPaths) {
  223. try {
  224. // Specifying an encoding returns a string instead of a buffer
  225. const parsed = DotenvModule.parse(fs.readFileSync(path, { encoding }))
  226. DotenvModule.populate(parsedAll, parsed, options)
  227. } catch (e) {
  228. if (debug) {
  229. _debug(`failed to load ${path} ${e.message}`)
  230. }
  231. lastError = e
  232. }
  233. }
  234. const populated = DotenvModule.populate(processEnv, parsedAll, options)
  235. // handle user settings DOTENV_CONFIG_ options inside .env file(s)
  236. debug = parseBoolean(processEnv.DOTENV_CONFIG_DEBUG || debug)
  237. quiet = parseBoolean(processEnv.DOTENV_CONFIG_QUIET || quiet)
  238. if (debug || !quiet) {
  239. const keysCount = Object.keys(populated).length
  240. const shortPaths = []
  241. for (const filePath of optionPaths) {
  242. try {
  243. const relative = path.relative(process.cwd(), filePath)
  244. shortPaths.push(relative)
  245. } catch (e) {
  246. if (debug) {
  247. _debug(`failed to load ${filePath} ${e.message}`)
  248. }
  249. lastError = e
  250. }
  251. }
  252. _log(`injected env (${keysCount}) from ${shortPaths.join(',')} ${dim(`// tip: ${_getRandomTip()}`)}`)
  253. }
  254. if (lastError) {
  255. return { parsed: parsedAll, error: lastError }
  256. } else {
  257. return { parsed: parsedAll }
  258. }
  259. }
  260. // Populates process.env from .env file
  261. function config (options) {
  262. // fallback to original dotenv if DOTENV_KEY is not set
  263. if (_dotenvKey(options).length === 0) {
  264. return DotenvModule.configDotenv(options)
  265. }
  266. const vaultPath = _vaultPath(options)
  267. // dotenvKey exists but .env.vault file does not exist
  268. if (!vaultPath) {
  269. _warn(`you set DOTENV_KEY but you are missing a .env.vault file at ${vaultPath}`)
  270. return DotenvModule.configDotenv(options)
  271. }
  272. return DotenvModule._configVault(options)
  273. }
  274. function decrypt (encrypted, keyStr) {
  275. const key = Buffer.from(keyStr.slice(-64), 'hex')
  276. let ciphertext = Buffer.from(encrypted, 'base64')
  277. const nonce = ciphertext.subarray(0, 12)
  278. const authTag = ciphertext.subarray(-16)
  279. ciphertext = ciphertext.subarray(12, -16)
  280. try {
  281. const aesgcm = crypto.createDecipheriv('aes-256-gcm', key, nonce)
  282. aesgcm.setAuthTag(authTag)
  283. return `${aesgcm.update(ciphertext)}${aesgcm.final()}`
  284. } catch (error) {
  285. const isRange = error instanceof RangeError
  286. const invalidKeyLength = error.message === 'Invalid key length'
  287. const decryptionFailed = error.message === 'Unsupported state or unable to authenticate data'
  288. if (isRange || invalidKeyLength) {
  289. const err = new Error('INVALID_DOTENV_KEY: It must be 64 characters long (or more)')
  290. err.code = 'INVALID_DOTENV_KEY'
  291. throw err
  292. } else if (decryptionFailed) {
  293. const err = new Error('DECRYPTION_FAILED: Please check your DOTENV_KEY')
  294. err.code = 'DECRYPTION_FAILED'
  295. throw err
  296. } else {
  297. throw error
  298. }
  299. }
  300. }
  301. // Populate process.env with parsed values
  302. function populate (processEnv, parsed, options = {}) {
  303. const debug = Boolean(options && options.debug)
  304. const override = Boolean(options && options.override)
  305. const populated = {}
  306. if (typeof parsed !== 'object') {
  307. const err = new Error('OBJECT_REQUIRED: Please check the processEnv argument being passed to populate')
  308. err.code = 'OBJECT_REQUIRED'
  309. throw err
  310. }
  311. // Set process.env
  312. for (const key of Object.keys(parsed)) {
  313. if (Object.prototype.hasOwnProperty.call(processEnv, key)) {
  314. if (override === true) {
  315. processEnv[key] = parsed[key]
  316. populated[key] = parsed[key]
  317. }
  318. if (debug) {
  319. if (override === true) {
  320. _debug(`"${key}" is already defined and WAS overwritten`)
  321. } else {
  322. _debug(`"${key}" is already defined and was NOT overwritten`)
  323. }
  324. }
  325. } else {
  326. processEnv[key] = parsed[key]
  327. populated[key] = parsed[key]
  328. }
  329. }
  330. return populated
  331. }
  332. const DotenvModule = {
  333. configDotenv,
  334. _configVault,
  335. _parseVault,
  336. config,
  337. decrypt,
  338. parse,
  339. populate
  340. }
  341. module.exports.configDotenv = DotenvModule.configDotenv
  342. module.exports._configVault = DotenvModule._configVault
  343. module.exports._parseVault = DotenvModule._parseVault
  344. module.exports.config = DotenvModule.config
  345. module.exports.decrypt = DotenvModule.decrypt
  346. module.exports.parse = DotenvModule.parse
  347. module.exports.populate = DotenvModule.populate
  348. module.exports = DotenvModule