@dotenvx/dotenvx 2.26.0 → 2.27.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.
Files changed (45) hide show
  1. package/CHANGELOG.md +14 -2
  2. package/README.md +48 -40
  3. package/package.json +12 -2
  4. package/src/cli/actions/decrypt.js +3 -3
  5. package/src/cli/actions/encrypt.js +3 -3
  6. package/src/cli/actions/get.js +2 -2
  7. package/src/cli/actions/keypair.js +1 -1
  8. package/src/cli/actions/run.js +46 -42
  9. package/src/cli/actions/set.js +2 -2
  10. package/src/cli/actions/validate.js +21 -33
  11. package/src/cli/commands/armor.js +1 -1
  12. package/src/cli/commands/custody.js +43 -0
  13. package/src/cli/commands/native.js +1 -1
  14. package/src/cli/dotenvx.js +11 -6
  15. package/src/lib/grammars/envfile.peggy +98 -0
  16. package/src/lib/helpers/bitwardenCustody.js +9 -1
  17. package/src/lib/helpers/encryptedSources.js +15 -0
  18. package/src/lib/helpers/envfileParser.js +2731 -0
  19. package/src/lib/helpers/errors.js +16 -16
  20. package/src/lib/helpers/executeCommand.js +6 -2
  21. package/src/lib/helpers/formatEnvfileSyntaxError.js +24 -0
  22. package/src/lib/helpers/isValidEmail.js +11 -0
  23. package/src/lib/helpers/isValidUrl.js +9 -0
  24. package/src/lib/helpers/onePasswordCustody.js +10 -2
  25. package/src/lib/helpers/parseWithDecryptor.js +9 -1
  26. package/src/lib/helpers/readEnvfile.js +128 -0
  27. package/src/lib/helpers/selectKeyStorage.js +2 -2
  28. package/src/lib/helpers/validate.js +83 -14
  29. package/src/lib/helpers/validateEnvfile.js +24 -0
  30. package/src/lib/main.js +7 -7
  31. package/src/lib/providers/index.js +1 -1
  32. package/src/lib/proxy/configureProxy.js +55 -0
  33. package/src/lib/proxy/eligibleHeaders.js +16 -0
  34. package/src/lib/proxy/prepareProxy.js +24 -0
  35. package/src/lib/proxy/proxyCertificates.js +44 -0
  36. package/src/lib/proxy/proxyForward.js +65 -0
  37. package/src/lib/proxy/proxyPreload.js +21 -0
  38. package/src/lib/proxy/proxyPreloadSource.js +3 -0
  39. package/src/lib/proxy/proxyServer.js +175 -0
  40. package/src/lib/resolvers/envs.js +15 -5
  41. package/src/lib/resolvers/get.js +1 -1
  42. package/src/lib/services/custodyTransfer.js +50 -0
  43. package/src/lib/services/validate.js +58 -0
  44. package/src/lib/transforms/set.js +2 -2
  45. package/src/lib/helpers/validateEnvExample.js +0 -24
@@ -14,7 +14,6 @@ const ISSUE_BY_CODE = {
14
14
  MALFORMED_ENCRYPTED_DATA: 'https://github.com/dotenvx/dotenvx/issues/467',
15
15
  MISPAIRED_PRIVATE_KEY: 'https://github.com/dotenvx/dotenvx/issues/752',
16
16
  MISSING_DIRECTORY: 'https://github.com/dotenvx/dotenvx/issues/758',
17
- MISSING_ENV_EXAMPLE: 'https://github.com/dotenvx/dotenvx/issues/905',
18
17
  MISSING_ENV_FILE: 'https://github.com/dotenvx/dotenvx/issues/484',
19
18
  MISSING_ENV_KEYS_FILE: 'https://github.com/dotenvx/dotenvx/issues/775',
20
19
  MISSING_ENV_FILES: 'https://github.com/dotenvx/dotenvx/issues/760',
@@ -25,7 +24,6 @@ const ISSUE_BY_CODE = {
25
24
  MISSING_VALUE: 'https://github.com/dotenvx/dotenvx/issues/864',
26
25
  FILE_NOT_WRITABLE: 'https://github.com/dotenvx/dotenvx/issues/890',
27
26
  PRECOMMIT_HOOK_MODIFY_FAILED: 'try again or report error',
28
- VALIDATION_FAILED: 'https://github.com/dotenvx/dotenvx/issues/907',
29
27
  WRONG_PRIVATE_KEY: 'https://github.com/dotenvx/dotenvx/issues/466'
30
28
  }
31
29
 
@@ -235,15 +233,10 @@ class Errors {
235
233
  return e
236
234
  }
237
235
 
238
- missingEnvExample () {
239
- const code = 'MISSING_ENV_EXAMPLE'
240
- const message = `[${code}] missing .env.example file`
241
- const help = `fix: [${ISSUE_BY_CODE[code]}]`
242
-
243
- const e = new Error(message)
244
- e.code = code
245
- e.help = help
246
- e.messageWithHelp = `${message}. ${help}`
236
+ envfileRequired () {
237
+ const e = new Error('[ENVFILE_REQUIRED] validate requires an Envfile')
238
+ e.code = 'ENVFILE_REQUIRED'
239
+ e.messageWithHelp = e.message
247
240
  return e
248
241
  }
249
242
 
@@ -345,15 +338,22 @@ class Errors {
345
338
  return e
346
339
  }
347
340
 
348
- validationFailed () {
349
- const code = 'VALIDATION_FAILED'
341
+ malformedEnvfile () {
342
+ const code = 'MALFORMED_ENVFILE'
343
+ const message = `[${code}] ${this.message}`
344
+ const e = new Error(message)
345
+ e.code = code
346
+ e.messageWithHelp = message
347
+ return e
348
+ }
349
+
350
+ invalidEnv () {
351
+ const code = 'INVALID_ENV'
350
352
  const message = `[${code}] ${this.message}`
351
- const help = `fix: [${ISSUE_BY_CODE[code]}]`
352
353
 
353
354
  const e = new Error(message)
354
355
  e.code = code
355
- e.help = help
356
- e.messageWithHelp = `${message}. ${help}`
356
+ e.messageWithHelp = message
357
357
  return e
358
358
  }
359
359
 
@@ -6,7 +6,7 @@ const Errors = require('./errors')
6
6
  const { createRedactedStreamWriter, redactOutput } = require('./redactOutput')
7
7
  const ptyCommand = require('./ptyCommand')
8
8
 
9
- async function executeCommand (commandArgs, env, sensitiveValues = []) {
9
+ async function executeCommand (commandArgs, env, sensitiveValues = [], onComplete) {
10
10
  const FORWARD_SIGNAL_GRACE_MS = 1000
11
11
  const FORCE_KILL_GRACE_MS = 1000
12
12
  const signals = [
@@ -17,6 +17,7 @@ async function executeCommand (commandArgs, env, sensitiveValues = []) {
17
17
  logger.debug(`executing process command [${commandArgs.join(' ')}]`)
18
18
 
19
19
  let child
20
+ let commandExitCode
20
21
  let signalSent
21
22
  let sigintCount = 0
22
23
  const signalForwardTimers = new Set()
@@ -175,7 +176,7 @@ async function executeCommand (commandArgs, env, sensitiveValues = []) {
175
176
  }
176
177
 
177
178
  // Exit with the error code from the command process, or 1 if unavailable
178
- process.exit(error.exitCode || 1)
179
+ commandExitCode = error.exitCode || 1
179
180
  } finally {
180
181
  signalForwardTimers.forEach(timer => clearTimeout(timer))
181
182
  signalForwardTimers.clear()
@@ -188,7 +189,10 @@ async function executeCommand (commandArgs, env, sensitiveValues = []) {
188
189
  otherSignalHandlers.forEach((handler, signal) => {
189
190
  process.removeListener(signal, handler)
190
191
  })
192
+ if (onComplete) await onComplete()
191
193
  }
194
+
195
+ if (commandExitCode) process.exit(commandExitCode)
192
196
  }
193
197
 
194
198
  module.exports = executeCommand
@@ -0,0 +1,24 @@
1
+ const path = require('node:path')
2
+
3
+ module.exports = function formatEnvfileSyntaxError (error, src, filepath) {
4
+ const location = error.location?.start
5
+ if (!location) return error.message
6
+
7
+ const filename = path.relative(process.cwd(), filepath) || path.basename(filepath)
8
+ const line = src.split(/\r\n|\n|\r/)[location.line - 1] || ''
9
+ let message = error.message
10
+ if (error.expected) {
11
+ const remaining = src.slice(location.offset)
12
+ const token = remaining.match(/^(?:"[^"\r\n]*"|'[^'\r\n]*'|[A-Za-z0-9_]+|[^\r\n])/u)?.[0]
13
+ const displayToken = token && /^["']/.test(token) ? token : JSON.stringify(token)
14
+ message = token ? `Unexpected ${displayToken}` : 'Unexpected end of input'
15
+ const expected = [...new Set(error.expected.flatMap(item => {
16
+ if (item.type === 'other') return [item.description]
17
+ if (item.type === 'literal' && item.text.trim() && item.text !== '#') return [JSON.stringify(item.text)]
18
+ return []
19
+ }))]
20
+ if (expected.length > 0 && expected.length <= 3) message += `; expected ${expected.join(' or ')}`
21
+ }
22
+ const prefix = `${location.line} | `
23
+ return `${filename}:${location.line}:${location.column}: ${message}\n${prefix}${line}\n${' '.repeat(prefix.length)}${line.slice(0, location.column - 1).replace(/[^\t]/g, ' ')}^`
24
+ }
@@ -0,0 +1,11 @@
1
+ module.exports = function isValidEmail (value) {
2
+ if (value.length > 254 || /\s/.test(value)) return false
3
+ const parts = value.split('@')
4
+ if (parts.length !== 2) return false
5
+ const [local, domain] = parts
6
+ if (local.length > 64 || !/^[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*$/i.test(local)) return false
7
+ const labels = domain.split('.')
8
+ return labels.length > 1 &&
9
+ labels.every(label => /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/i.test(label)) &&
10
+ /^(?:[a-z]{2,63}|xn--[a-z0-9-]+)$/i.test(labels[labels.length - 1])
11
+ }
@@ -0,0 +1,9 @@
1
+ const { URL } = require('node:url')
2
+
3
+ module.exports = function isValidUrl (value) {
4
+ try {
5
+ return new URL(value).protocol !== ''
6
+ } catch {
7
+ return false
8
+ }
9
+ }
@@ -15,7 +15,7 @@ function failure (message) {
15
15
 
16
16
  function commandError (args, stderr) {
17
17
  const step = args[0] === 'item'
18
- ? 'create the key item'
18
+ ? (args[1] === 'delete' ? 'delete the key item' : 'create the key item')
19
19
  : {
20
20
  whoami: 'check the signed-in account',
21
21
  signin: 'sign in',
@@ -156,4 +156,12 @@ async function set (publicKey, privateKey) {
156
156
  new Session().createStore().set(`${PREFIX}${publicKey}`, `${account}|${reference}`)
157
157
  }
158
158
 
159
- module.exports = { available, configured, get, getSync, set }
159
+ async function remove (publicKey) {
160
+ const loc = location(publicKey)
161
+ if (!loc) return
162
+ const [vault, item] = loc.reference.slice('op://'.length).split('/')
163
+ await run(['item', 'delete', item, `--vault=${vault}`, `--account=${loc.account}`])
164
+ new Session().openStore().delete(`${PREFIX}${publicKey}`)
165
+ }
166
+
167
+ module.exports = { available, configured, get, getSync, set, delete: remove }
@@ -1,4 +1,5 @@
1
- const { parse, parseSync, parsearrays } = require('@dotenvx/primitives')
1
+ const { parse, parseSync, parsearrays, scan, encrypted } = require('@dotenvx/primitives')
2
+ const prepareProxy = require('../proxy/prepareProxy')
2
3
  const SERVER_SIDE_DECRYPTION_REQUIRED = 'SERVER_SIDE_DECRYPTION_REQUIRED'
3
4
 
4
5
  function decryptOptions (error) {
@@ -27,6 +28,13 @@ function failedKeyAccessFallback (result, error) {
27
28
  }
28
29
 
29
30
  async function parseWithDecryptor (src, options = {}) {
31
+ if (options.proxyCredentials) {
32
+ const original = src
33
+ src = prepareProxy(src, options.proxyCredentials, options.processEnv, options.proxyRules)
34
+ if (src !== original && !Object.values(scan(src).parsed).flat().some(encrypted)) {
35
+ options = parseOptionsWithoutProvider(options)
36
+ }
37
+ }
30
38
  return parseWith(src, options, parse)
31
39
  }
32
40
 
@@ -0,0 +1,128 @@
1
+ const fs = require('node:fs')
2
+ const path = require('node:path')
3
+ const { isIP } = require('node:net')
4
+ const parser = require('./envfileParser')
5
+ const Errors = require('./errors')
6
+ const isValidUrl = require('./isValidUrl')
7
+ const isValidEmail = require('./isValidEmail')
8
+ const formatEnvfileSyntaxError = require('./formatEnvfileSyntaxError')
9
+
10
+ function compileDeclarations (declarations) {
11
+ const proxyRules = new Map()
12
+ const requiredKeys = []
13
+ const types = new Map()
14
+ const enums = new Map()
15
+ const ranges = new Map()
16
+ const encryptedKeys = []
17
+ const names = new Set()
18
+ for (const input of declarations) {
19
+ const declaration = { ...input }
20
+ if (names.has(declaration.name)) throw new Errors({ message: `Duplicate Envfile declaration: ${declaration.name}` }).malformedEnvfile()
21
+ names.add(declaration.name)
22
+ if (declaration.encrypted) encryptedKeys.push(declaration.name)
23
+ if (declaration.type === 'port') {
24
+ declaration.type = 'integer'
25
+ if (declaration.min === undefined || BigInt(declaration.min) < 0n) declaration.min = '0'
26
+ if (declaration.max === undefined || BigInt(declaration.max) > 65535n) declaration.max = '65535'
27
+ }
28
+ if (declaration.required) requiredKeys.push(declaration.name)
29
+ if (declaration.type) types.set(declaration.name, declaration.type)
30
+ if (declaration.min !== undefined || declaration.max !== undefined) {
31
+ if (declaration.type !== 'integer') {
32
+ throw new Errors({ message: `Invalid Envfile range for ${declaration.name}: min and max require type: "integer" or "port".` }).malformedEnvfile()
33
+ }
34
+ if (declaration.min !== undefined && declaration.max !== undefined && BigInt(declaration.min) > BigInt(declaration.max)) {
35
+ throw new Errors({ message: `Invalid Envfile range for ${declaration.name}: min must be less than or equal to max.` }).malformedEnvfile()
36
+ }
37
+ ranges.set(declaration.name, { min: declaration.min, max: declaration.max })
38
+ }
39
+ if (declaration.enum) {
40
+ if (declaration.type === 'integer' && declaration.enum.some(value => !/^[+-]?\d+$/.test(value.trim()))) {
41
+ throw new Errors({ message: `Invalid Envfile enum for ${declaration.name}: expected integer choices.` }).malformedEnvfile()
42
+ }
43
+ if (declaration.type === 'boolean' && declaration.enum.some(value => !['true', 'false', '1', '0'].includes(value))) {
44
+ throw new Errors({ message: `Invalid Envfile enum for ${declaration.name}: expected true, false, 1, or 0 choices.` }).malformedEnvfile()
45
+ }
46
+ if (declaration.type === 'url' && declaration.enum.some(value => !isValidUrl(value))) {
47
+ throw new Errors({ message: `Invalid Envfile enum for ${declaration.name}: expected URL choices.` }).malformedEnvfile()
48
+ }
49
+ if (declaration.type === 'email' && declaration.enum.some(value => !isValidEmail(value))) {
50
+ throw new Errors({ message: `Invalid Envfile enum for ${declaration.name}: expected email choices.` }).malformedEnvfile()
51
+ }
52
+ if (declaration.type === 'ip' && declaration.enum.some(value => isIP(value) === 0)) {
53
+ throw new Errors({ message: `Invalid Envfile enum for ${declaration.name}: expected IPv4 or IPv6 choices.` }).malformedEnvfile()
54
+ }
55
+ enums.set(declaration.name, declaration.enum)
56
+ }
57
+ if (declaration.proxy) {
58
+ const host = declaration.proxy.domain.toLowerCase()
59
+ if (host.length > 253 || isIP(host) || !/^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(host)) {
60
+ throw new Errors({ message: `Invalid Envfile proxy host for ${declaration.name}: expected a DNS hostname without a scheme, port, path or wildcard.` }).malformedEnvfile()
61
+ }
62
+ proxyRules.set(declaration.name, host)
63
+ }
64
+ }
65
+ return { exists: true, proxyRules, requiredKeys, types, enums, ranges, encryptedKeys }
66
+ }
67
+
68
+ module.exports = function readEnvfile (filepath = path.resolve('Envfile'), envFiles = ['.env']) {
69
+ let src
70
+ try {
71
+ src = fs.readFileSync(filepath, 'utf8')
72
+ } catch (error) {
73
+ if (error.code === 'ENOENT') return { ...compileDeclarations([]), exists: false }
74
+ throw error
75
+ }
76
+
77
+ let document
78
+ try {
79
+ document = parser.parse(src)
80
+ } catch (error) {
81
+ throw new Errors({ message: formatEnvfileSyntaxError(error, src, filepath) }).malformedEnvfile()
82
+ }
83
+
84
+ const defaults = { proxy: false, required: true, encrypted: document.encrypted }
85
+ const base = document.declarations.map(item => ({ ...defaults, ...item }))
86
+ const baseSchema = compileDeclarations(base)
87
+ const selected = new Set(envFiles.map(file => path.resolve(file)))
88
+ const seen = new Set()
89
+ const active = []
90
+ for (const block of document.files) {
91
+ if (/[*?[\]]/.test(block.file)) {
92
+ throw new Errors({ message: `File blocks require an exact filename: ${block.file}` }).malformedEnvfile()
93
+ }
94
+ const file = path.resolve(path.dirname(filepath), block.file)
95
+ if (seen.has(file)) throw new Errors({ message: `Duplicate Envfile file block: ${block.file}` }).malformedEnvfile()
96
+ seen.add(file)
97
+ const merged = new Map(base.map(item => [item.name, { ...item }]))
98
+ if (block.encrypted !== null && block.encrypted !== undefined) {
99
+ for (const item of merged.values()) item.encrypted = block.encrypted
100
+ }
101
+ const names = new Set()
102
+ for (const item of block.declarations) {
103
+ if (names.has(item.name)) throw new Errors({ message: `Duplicate Envfile declaration in ${block.file}: ${item.name}` }).malformedEnvfile()
104
+ names.add(item.name)
105
+ merged.set(item.name, {
106
+ ...defaults,
107
+ ...(block.encrypted === null || block.encrypted === undefined ? {} : { encrypted: block.encrypted }),
108
+ ...merged.get(item.name),
109
+ ...item
110
+ })
111
+ }
112
+ const schema = compileDeclarations([...merged.values()])
113
+ if (selected.has(file)) active.push(schema)
114
+ }
115
+ if (active.length === 0) return baseSchema
116
+
117
+ // Each selected file policy must hold, regardless of file loading order.
118
+ const proxyRules = new Map()
119
+ for (const schema of active) {
120
+ for (const [key, host] of schema.proxyRules) {
121
+ if (proxyRules.has(key) && proxyRules.get(key) !== host) {
122
+ throw new Errors({ message: `Conflicting Envfile proxy domains for ${key} in selected file blocks` }).malformedEnvfile()
123
+ }
124
+ proxyRules.set(key, host)
125
+ }
126
+ }
127
+ return { ...active[0], proxyRules, schemas: active }
128
+ }
@@ -9,12 +9,12 @@ const secretStoreNames = {
9
9
  }
10
10
 
11
11
  async function selectKeyStorage (options = {}) {
12
- const useNative = !options.noKeychain && !process.env.CI && ['darwin', 'linux', 'win32'].includes(process.platform)
12
+ const useNative = !options.noNative && process.env.DOTENVX_NO_NATIVE !== 'true' && !process.env.CI && ['darwin', 'linux', 'win32'].includes(process.platform)
13
13
  const defaultStorage = useNative ? 'native' : 'file'
14
14
  if (process.env.CI || options.noCreate || !process.stdin.isTTY || !process.stderr.isTTY) return defaultStorage
15
15
 
16
16
  const choices = [
17
- ...(useNative ? [{ name: `□ Local Custody (${secretStoreNames[process.platform]})`, value: 'native' }] : [])
17
+ ...(useNative ? [{ name: `□ Local Custody (Native ${secretStoreNames[process.platform]})`, value: 'native' }] : [])
18
18
  ]
19
19
  if (!options.no1Password && process.env.DOTENVX_NO_1PASSWORD !== 'true' && await onePasswordCustody.available()) {
20
20
  choices.push({ name: '□ Local Custody (1Password)', value: 'onepassword' })
@@ -1,24 +1,15 @@
1
- function optionalKeys (comments) {
2
- const keys = new Set()
3
-
4
- for (const [key, values] of Object.entries(comments)) {
5
- if (values.some(comment => comment && /\boptional\b/i.test(comment))) {
6
- keys.add(key)
7
- }
8
- }
9
-
10
- return keys
11
- }
1
+ const isValidUrl = require('./isValidUrl')
2
+ const isValidEmail = require('./isValidEmail')
3
+ const { isIP } = require('node:net')
12
4
 
13
5
  function validate (example = {}, env = {}, options = {}) {
14
6
  const errors = []
15
7
  const missingRequired = []
16
- const optional = optionalKeys(options.comments || {})
17
8
 
18
9
  for (const key of Object.keys(example)) {
19
10
  const value = env[key]
20
11
  const missing = !Object.prototype.hasOwnProperty.call(env, key) || value.trim() === ''
21
- if (!optional.has(key) && missing) {
12
+ if (missing) {
22
13
  missingRequired.push(key)
23
14
  }
24
15
  }
@@ -27,10 +18,88 @@ function validate (example = {}, env = {}, options = {}) {
27
18
  errors.push({
28
19
  code: 'MISSING_REQUIRED',
29
20
  keys: missingRequired,
30
- message: `missing required (${missingRequired.join(', ')})`
21
+ message: missingRequired.map(key => `${key} is required`).join('; ')
31
22
  })
32
23
  }
33
24
 
25
+ const invalidIntegers = []
26
+ const invalidBooleans = []
27
+ const invalidUrls = []
28
+ const invalidEmails = []
29
+ const invalidIps = []
30
+ for (const [key, type] of options.types || []) {
31
+ if (!Object.prototype.hasOwnProperty.call(env, key)) continue
32
+ const value = env[key].trim()
33
+ if (value === '') continue // required checks handle blank values
34
+ if (type === 'integer' && !/^[+-]?\d+$/.test(value)) invalidIntegers.push(key)
35
+ if (type === 'boolean' && !['true', 'false', '1', '0'].includes(env[key])) invalidBooleans.push(key)
36
+ if (type === 'url' && !isValidUrl(env[key])) invalidUrls.push(key)
37
+ if (type === 'email' && !isValidEmail(env[key])) invalidEmails.push(key)
38
+ if (type === 'ip' && isIP(env[key]) === 0) invalidIps.push(key)
39
+ }
40
+ if (invalidIntegers.length > 0) {
41
+ errors.push({
42
+ code: 'INVALID_INTEGER',
43
+ keys: invalidIntegers,
44
+ message: invalidIntegers.map(key => `${key} must be an integer`).join('; ')
45
+ })
46
+ }
47
+
48
+ if (invalidBooleans.length > 0) {
49
+ errors.push({
50
+ code: 'INVALID_BOOLEAN',
51
+ keys: invalidBooleans,
52
+ message: invalidBooleans.map(key => `${key} must be true, false, 1, or 0`).join('; ')
53
+ })
54
+ }
55
+ if (invalidUrls.length > 0) {
56
+ errors.push({ code: 'INVALID_URL', keys: invalidUrls, message: invalidUrls.map(key => `${key} must be a valid URL`).join('; ') })
57
+ }
58
+ if (invalidEmails.length > 0) {
59
+ errors.push({ code: 'INVALID_EMAIL', keys: invalidEmails, message: invalidEmails.map(key => `${key} must be a valid email address`).join('; ') })
60
+ }
61
+ if (invalidIps.length > 0) {
62
+ errors.push({ code: 'INVALID_IP', keys: invalidIps, message: invalidIps.map(key => `${key} must be a valid IPv4 or IPv6 address`).join('; ') })
63
+ }
64
+ const invalidEnums = []
65
+ for (const [key, choices] of options.enums || []) {
66
+ if (!Object.prototype.hasOwnProperty.call(env, key) || env[key].trim() === '') continue
67
+ if (invalidIntegers.includes(key) || invalidBooleans.includes(key) || invalidUrls.includes(key) || invalidEmails.includes(key) || invalidIps.includes(key)) continue
68
+ const value = env[key]
69
+ const matches = options.types?.get(key) === 'integer'
70
+ ? choices.some(choice => BigInt(choice.trim()) === BigInt(value.trim()))
71
+ : choices.includes(value)
72
+ if (!matches) invalidEnums.push(key)
73
+ }
74
+ if (invalidEnums.length > 0) {
75
+ errors.push({
76
+ code: 'INVALID_ENUM',
77
+ keys: invalidEnums,
78
+ message: invalidEnums.map(key => `${key} is not an allowed value`).join('; ')
79
+ })
80
+ }
81
+
82
+ for (const [key, { min, max }] of options.ranges || []) {
83
+ if (!Object.prototype.hasOwnProperty.call(env, key) || env[key].trim() === '') continue
84
+ if (invalidIntegers.includes(key)) continue
85
+ const value = BigInt(env[key].trim())
86
+ if (min !== undefined && value < BigInt(min)) {
87
+ errors.push({ code: 'BELOW_MIN', keys: [key], message: `${key} must be at least ${min}` })
88
+ }
89
+ if (max !== undefined && value > BigInt(max)) {
90
+ errors.push({ code: 'ABOVE_MAX', keys: [key], message: `${key} must be at most ${max}` })
91
+ }
92
+ }
93
+
94
+ const unencrypted = []
95
+ for (const key of options.encryptedKeys || []) {
96
+ if (!Object.prototype.hasOwnProperty.call(env, key) || env[key].trim() === '') continue
97
+ if (!options.encryptedSources?.has(key)) unencrypted.push(key)
98
+ }
99
+ if (unencrypted.length > 0) {
100
+ errors.push({ code: 'EXPECTED_ENCRYPTED', keys: unencrypted, message: unencrypted.map(key => `${key} is not encrypted`).join('; ') })
101
+ }
102
+
34
103
  return {
35
104
  valid: errors.length === 0,
36
105
  errors
@@ -0,0 +1,24 @@
1
+ const validate = require('./validate')
2
+ const encryptedSources = require('./encryptedSources')
3
+ const Errors = require('./errors')
4
+
5
+ module.exports = function validateEnvfile (schema, env, processedEnvs) {
6
+ const messages = new Set()
7
+ const schemas = schema.schemas || [schema]
8
+ const sources = schemas.some(rules => rules.encryptedKeys.length > 0) ? encryptedSources(processedEnvs) : undefined
9
+ for (const rules of schemas) {
10
+ const { requiredKeys, types, enums, ranges, encryptedKeys } = rules
11
+ const required = Object.fromEntries(requiredKeys.map(key => [key, '']))
12
+ const validation = validate(required, env, {
13
+ types,
14
+ enums,
15
+ ranges,
16
+ encryptedKeys,
17
+ encryptedSources: sources
18
+ })
19
+ for (const error of validation.errors) messages.add(error.message)
20
+ }
21
+ if (messages.size > 0) {
22
+ return new Errors({ message: [...messages].join('; ') }).invalidEnv()
23
+ }
24
+ }
package/src/lib/main.js CHANGED
@@ -73,7 +73,7 @@ const config = function (options = {}) {
73
73
 
74
74
  // dotenvx-armor related
75
75
  const noArmor = resolveNoArmor(options)
76
- const noKeychain = resolveNoKeychain(options)
76
+ const noNative = resolveNoNative(options)
77
77
 
78
78
  try {
79
79
  let envs = normalizeDotenvConfigPath(buildConfigEnvs(options))
@@ -89,7 +89,7 @@ const config = function (options = {}) {
89
89
  processEnv,
90
90
  envKeysFile,
91
91
  noArmor,
92
- noKeychain,
92
+ noNative,
93
93
  no1Password: options.no1Password,
94
94
  noBitwarden: options.noBitwarden,
95
95
  noSpinner: options.noSpinner,
@@ -243,7 +243,7 @@ const set = async function (key, value, options = {}) {
243
243
  const envKeysFilepath = options.envKeysFile
244
244
  const noCreate = options.create === false
245
245
  const noArmor = resolveNoArmor(options)
246
- const noKeychain = resolveNoKeychain(options)
246
+ const noNative = resolveNoNative(options)
247
247
 
248
248
  const {
249
249
  keysSrc,
@@ -256,7 +256,7 @@ const set = async function (key, value, options = {}) {
256
256
  value,
257
257
  fk: envKeysFilepath,
258
258
  noArmor,
259
- noKeychain,
259
+ noNative,
260
260
  no1Password: options.no1Password,
261
261
  noBitwarden: options.noBitwarden,
262
262
  noCreate,
@@ -327,7 +327,7 @@ const get = async function (key, options = {}) {
327
327
 
328
328
  const envs = buildEnvs(options)
329
329
  const noArmor = resolveNoArmor(options)
330
- const noKeychain = resolveNoKeychain(options)
330
+ const noNative = resolveNoNative(options)
331
331
 
332
332
  // ignore
333
333
  const ignore = options.ignore || []
@@ -339,7 +339,7 @@ const get = async function (key, options = {}) {
339
339
  all: options.all,
340
340
  envKeysFile: options.envKeysFile,
341
341
  noArmor,
342
- noKeychain,
342
+ noNative,
343
343
  no1Password: options.no1Password,
344
344
  noBitwarden: options.noBitwarden
345
345
  })
@@ -410,7 +410,7 @@ function resolveNoArmor (options = {}) {
410
410
  return options.noArmor === true || (!options.token && sesh.noArmorSync())
411
411
  }
412
412
 
413
- function resolveNoKeychain (options = {}) {
413
+ function resolveNoNative (options = {}) {
414
414
  return options.noNative === true || options.native === false
415
415
  }
416
416
 
@@ -48,7 +48,7 @@ function armorProviderForOptions (options) {
48
48
  function useNative (options) {
49
49
  if (!['darwin', 'linux', 'win32'].includes(process.platform)) return false
50
50
  if (process.env.CI) return false
51
- return options.noNative !== true && options.native !== false && options.noKeychain !== true
51
+ return options.noNative !== true && options.native !== false && process.env.DOTENVX_NO_NATIVE !== 'true'
52
52
  }
53
53
 
54
54
  function useOnePassword (options) {
@@ -0,0 +1,55 @@
1
+ const path = require('node:path')
2
+ const fs = require('node:fs')
3
+ const startProxy = require('./proxyServer')
4
+
5
+ module.exports = async function configureProxy (commandArgs, env, credentials = [], session, explicitToken) {
6
+ const active = credentials.filter(credential => env[credential.name] === credential.placeholder)
7
+ if (active.length === 0) return { commandArgs, env }
8
+ const token = explicitToken || session.token()
9
+ if (!token || token.startsWith('encrypted:')) throw new Error('Credential proxy requires Armor login. Run [dotenvx armor login].')
10
+ const hostname = session.hostname()
11
+ const url = new URL(hostname)
12
+ if (url.protocol !== 'https:' || url.username || url.password || url.search || url.hash) {
13
+ throw new Error('Credential proxy requires an HTTPS Armor hostname.')
14
+ }
15
+ const server = await startProxy({ credentials: active, token, hostname: url.href.replace(/\/$/, ''), devicePublicKey: session.devicePublicKey(), env })
16
+ const preload = path.join(path.dirname(server.caPath), 'proxy-preload.cjs')
17
+ try {
18
+ fs.writeFileSync(preload, require('./proxyPreloadSource'), { mode: 0o600 })
19
+ } catch (error) {
20
+ await server.close()
21
+ throw error
22
+ }
23
+ const nodeOptions = `${env.NODE_OPTIONS || ''} --require ${JSON.stringify(preload)}`.trim()
24
+ const args = /\.(mjs|cjs|js)$/.test(commandArgs[0])
25
+ ? [process.pkg ? 'node' : process.execPath, path.resolve(commandArgs[0]), ...commandArgs.slice(1)]
26
+ : commandArgs
27
+ return {
28
+ commandArgs: args,
29
+ close: server.close,
30
+ env: {
31
+ ...env,
32
+ NODE_OPTIONS: nodeOptions,
33
+ HTTP_PROXY: server.proxyUrl,
34
+ HTTPS_PROXY: server.proxyUrl,
35
+ ALL_PROXY: server.proxyUrl,
36
+ http_proxy: server.proxyUrl,
37
+ https_proxy: server.proxyUrl,
38
+ all_proxy: server.proxyUrl,
39
+ NO_PROXY: 'localhost,127.0.0.1,::1',
40
+ no_proxy: 'localhost,127.0.0.1,::1',
41
+ NODE_EXTRA_CA_CERTS: server.caPath,
42
+ SSL_CERT_FILE: server.caPath,
43
+ REQUESTS_CA_BUNDLE: server.caPath,
44
+ CURL_CA_BUNDLE: server.caPath,
45
+ GIT_SSL_CAINFO: server.caPath,
46
+ CARGO_HTTP_CAINFO: server.caPath,
47
+ DENO_CERT: server.caPath,
48
+ DOTENVX_PROXY_URL: server.proxyUrl,
49
+ DOTENVX_PROXY_SOCKET: undefined,
50
+ DOTENVX_PROXY_CONFIG: undefined,
51
+ DOTENVX_TOKEN: undefined,
52
+ DOTENVX_ARMOR_TOKEN: undefined
53
+ }
54
+ }
55
+ }
@@ -0,0 +1,16 @@
1
+ // Keep this policy in sync with Radar's GatewayService::EligibleHeaders.
2
+ // Authentication headers (Authorization, X-Api-Key, etc.) are eligible. Routing,
3
+ // transport, browser context, cookies and proxy control metadata are not.
4
+ const EXCLUDED = new Set([
5
+ 'host', 'connection', 'keep-alive', 'transfer-encoding', 'content-length',
6
+ 'te', 'trailer', 'upgrade', 'expect', 'accept-encoding',
7
+ 'cookie', 'set-cookie', 'forwarded', 'via', 'referer', 'origin', 'user-agent',
8
+ 'x-real-ip', 'x-original-url', 'x-rewrite-url', 'x-http-method-override'
9
+ ])
10
+ const PREFIXES = ['proxy-', 'dotenvx-', 'x-forwarded-', 'sec-']
11
+
12
+ module.exports = function eligibleHeaders (name) {
13
+ const lower = name.toLowerCase()
14
+ return /^[!#$%&'*+.^_`|~0-9a-z-]+$/.test(lower) &&
15
+ !EXCLUDED.has(lower) && !PREFIXES.some(prefix => lower.startsWith(prefix))
16
+ }
@@ -0,0 +1,24 @@
1
+ const { randomBytes } = require('node:crypto')
2
+ const { scan, upsert, encrypted } = require('@dotenvx/primitives')
3
+
4
+ // Remove proxy credentials before any local or Armor decryption is attempted.
5
+ module.exports = function prepareProxy (src, credentials, processEnv = {}, proxyRules = new Map()) {
6
+ const { parsed } = scan(src)
7
+ const publicKeys = Object.entries(parsed).filter(([name]) => name.startsWith('DOTENV_PUBLIC_KEY'))
8
+ for (const [name, values] of Object.entries(parsed)) {
9
+ if (!proxyRules.has(name)) continue
10
+ const replaced = values.map(value => {
11
+ if (!encrypted(value)) return value
12
+ if (publicKeys.length > 1) throw new Error('Credential proxy requires an unambiguous DOTENV_PUBLIC_KEY in its env file.')
13
+ const publicKey = publicKeys.length === 1 ? publicKeys[0][1].at(-1) : processEnv.DOTENV_PUBLIC_KEY
14
+ if (!publicKey || !/^(02|03)[0-9a-f]{64}$/i.test(publicKey)) {
15
+ throw new Error('Credential proxy requires DOTENV_PUBLIC_KEY alongside the encrypted credential.')
16
+ }
17
+ const placeholder = `dotenvx_proxy_${randomBytes(24).toString('hex')}`
18
+ credentials.push({ name, host: proxyRules.get(name), placeholder, ciphertext: value, publicKey })
19
+ return placeholder
20
+ })
21
+ src = upsert(src, name, replaced)
22
+ }
23
+ return src
24
+ }