市场夺宝奇兵
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

434 lines
12 KiB

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