configorama 1.0.1 → 1.1.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.
@@ -12,7 +12,7 @@ function buildAuditReport(introspection, options = {}) {
12
12
 
13
13
  for (const node of introspection.nodes || []) {
14
14
  if (!node.risk || node.risk === 'none') continue
15
- findings.push({
15
+ const finding = {
16
16
  id: node.id,
17
17
  severity: node.severity || severityForRisk(node.risk),
18
18
  risk: node.risk,
@@ -22,7 +22,12 @@ function buildAuditReport(introspection, options = {}) {
22
22
  relativePath: node.relativePath,
23
23
  configPaths: node.paths || [],
24
24
  message: messageForNode(node),
25
- })
25
+ }
26
+ if (node.sensitive === true) finding.sensitive = true
27
+ if (node.sensitivityReason) finding.sensitivityReason = node.sensitivityReason
28
+ if (node.dotenvFile !== undefined) finding.dotenvFile = node.dotenvFile
29
+ if (node.dotenvReadScope) finding.dotenvReadScope = node.dotenvReadScope
30
+ findings.push(finding)
26
31
  }
27
32
 
28
33
  if (options.dotenv === true) {
@@ -36,15 +41,11 @@ function buildAuditReport(introspection, options = {}) {
36
41
  }
37
42
 
38
43
  if (options.customResolvers && options.customResolvers.length) {
39
- for (const resolver of options.customResolvers.slice().sort()) {
40
- findings.push({
41
- id: `customResolver:${resolver}`,
42
- severity: 'high',
43
- risk: 'custom_extension',
44
- kind: 'source',
45
- variableType: resolver,
46
- message: `Custom resolver "${resolver}" can execute user-provided code.`,
47
- })
44
+ const sorted = options.customResolvers.slice().sort((a, b) => {
45
+ return String(a.type || a).localeCompare(String(b.type || b))
46
+ })
47
+ for (const resolver of sorted) {
48
+ findings.push(customResolverFinding(resolver))
48
49
  }
49
50
  }
50
51
 
@@ -65,9 +66,45 @@ function buildAuditReport(introspection, options = {}) {
65
66
  }
66
67
  }
67
68
 
69
+ /**
70
+ * Build the audit finding for one custom resolver.
71
+ * Resolvers that self-describe as sensitive/risky get a specific finding
72
+ * instead of the generic custom_extension one - never both.
73
+ * @param {string|{type: string, sensitive?: boolean, risk?: string, description?: string}} resolver
74
+ * @returns {object} Audit finding
75
+ */
76
+ function customResolverFinding(resolver) {
77
+ const source = typeof resolver === 'string' ? { type: resolver } : resolver
78
+ const { type, sensitive, risk, description } = source
79
+
80
+ if (sensitive === true || risk) {
81
+ const finding = {
82
+ id: `customResolver:${type}`,
83
+ severity: 'high',
84
+ risk: risk || 'custom_extension',
85
+ kind: 'source',
86
+ variableType: type,
87
+ message: `Custom resolver "${type}" reads secret values.${description ? ` ${description}.` : ''}`,
88
+ }
89
+ if (sensitive === true) finding.sensitive = true
90
+ return finding
91
+ }
92
+
93
+ return {
94
+ id: `customResolver:${type}`,
95
+ severity: 'high',
96
+ risk: 'custom_extension',
97
+ kind: 'source',
98
+ variableType: type,
99
+ message: `Custom resolver "${type}" can execute user-provided code.`,
100
+ }
101
+ }
102
+
68
103
  function messageForNode(node) {
69
104
  if (node.risk === 'executable_code') return 'Reference may execute JavaScript or TypeScript.'
70
105
  if (node.risk === 'process_spawn') return 'Reference may spawn a git process.'
106
+ if (node.sensitivityReason === 'dotenv_file' && node.dotenvReadScope === 'full_file') return 'Reference reads an entire dotenv file; resolved output may contain secrets.'
107
+ if (node.sensitivityReason === 'dotenv_file') return 'Reference reads a key from a dotenv file.'
71
108
  if (node.risk === 'local_file_read') return 'Reference reads a local file.'
72
109
  if (node.risk === 'data_flow_expression') return 'Expression can read resolved config values but is not JavaScript execution.'
73
110
  return `Risk surface: ${node.risk}`
@@ -1,6 +1,7 @@
1
1
  const path = require('path')
2
2
  const { EXECUTABLE_EXTENSIONS } = require('../security/safetyPolicy')
3
3
  const { redactRequirementValue } = require('../redaction/redact')
4
+ const { getDotenvFileRefMetadata } = require('../security/dotenvFileRefs')
4
5
 
5
6
  const SCHEMA_VERSION = 1
6
7
 
@@ -42,6 +43,25 @@ function severityForRisk(risk) {
42
43
  return 'info'
43
44
  }
44
45
 
46
+ function severityForNode(risk, metadata) {
47
+ if (metadata && metadata.dotenvFile && metadata.dotenvReadScope === 'full_file') return 'medium'
48
+ return severityForRisk(risk)
49
+ }
50
+
51
+ function firstOccurrenceVariable(entry) {
52
+ const occurrence = entry && Array.isArray(entry.occurrences) ? entry.occurrences[0] : null
53
+ return occurrence ? (occurrence.varMatch || occurrence.originalString) : null
54
+ }
55
+
56
+ function assignSensitivity(target, metadata) {
57
+ if (!metadata) return target
58
+ if (metadata.sensitive === true) target.sensitive = true
59
+ if (metadata.sensitivityReason) target.sensitivityReason = metadata.sensitivityReason
60
+ if (metadata.dotenvFile !== undefined) target.dotenvFile = metadata.dotenvFile
61
+ if (metadata.dotenvReadScope) target.dotenvReadScope = metadata.dotenvReadScope
62
+ return target
63
+ }
64
+
45
65
  function buildIntrospection(enrichedMetadata = {}, options = {}) {
46
66
  const uniqueVariables = enrichedMetadata.uniqueVariables || {}
47
67
  const requirements = options.requirements || []
@@ -55,6 +75,11 @@ function buildIntrospection(enrichedMetadata = {}, options = {}) {
55
75
  const variableType = normalizeVariableType(entry.variableType)
56
76
  const requirement = requirementsByVariable.get(variable)
57
77
  const risk = riskForVariable(variableType, variable)
78
+ const dotenvMetadata = getDotenvFileRefMetadata({
79
+ ...entry,
80
+ variable,
81
+ originalVariableString: firstOccurrenceVariable(entry),
82
+ })
58
83
  const node = {
59
84
  id: `variable:${variable}`,
60
85
  kind: variableType === 'file' || variableType === 'text'
@@ -64,10 +89,11 @@ function buildIntrospection(enrichedMetadata = {}, options = {}) {
64
89
  variableType,
65
90
  sourceClass: entry.variableSourceType || entry.sourceClass || requirement?.sourceClass || null,
66
91
  risk,
67
- severity: severityForRisk(risk),
92
+ severity: severityForNode(risk, dotenvMetadata || entry),
68
93
  paths: [...new Set((entry.occurrences || []).map(occ => occ.path).filter(Boolean))].sort(),
69
- sensitive: requirement ? requirement.sensitive === true : false,
94
+ sensitive: requirement ? requirement.sensitive === true : entry.sensitive === true,
70
95
  }
96
+ assignSensitivity(node, dotenvMetadata || entry)
71
97
  if (requirement) {
72
98
  node.required = requirement.required
73
99
  node.default = redactRequirementValue(requirement, requirement.default)
@@ -104,16 +130,20 @@ function buildIntrospection(enrichedMetadata = {}, options = {}) {
104
130
  const fileDeps = enrichedMetadata.fileDependencies || {}
105
131
  for (const dep of fileDeps.byConfigPath || []) {
106
132
  const id = `file:${dep.relativePath || dep.filePath}`
133
+ const dotenvMetadata = getDotenvFileRefMetadata(dep)
134
+ const risk = EXECUTABLE_EXTENSIONS.has(path.extname(dep.relativePath || dep.filePath || '').toLowerCase()) ? 'executable_code' : 'local_file_read'
107
135
  if (!nodes.some(node => node.id === id)) {
108
- nodes.push({
136
+ const node = {
109
137
  id,
110
138
  kind: EXECUTABLE_EXTENSIONS.has(path.extname(dep.relativePath || dep.filePath || '').toLowerCase()) ? 'executable' : 'file',
111
139
  path: dep.filePath,
112
140
  relativePath: dep.relativePath,
113
141
  exists: dep.exists,
114
- risk: EXECUTABLE_EXTENSIONS.has(path.extname(dep.relativePath || dep.filePath || '').toLowerCase()) ? 'executable_code' : 'local_file_read',
115
- severity: EXECUTABLE_EXTENSIONS.has(path.extname(dep.relativePath || dep.filePath || '').toLowerCase()) ? 'high' : 'low',
116
- })
142
+ risk,
143
+ severity: severityForNode(risk, dotenvMetadata || dep),
144
+ }
145
+ assignSensitivity(node, dotenvMetadata || dep)
146
+ nodes.push(node)
117
147
  }
118
148
  if (dep.location) {
119
149
  edges.push({
@@ -5,6 +5,7 @@ const { normalizePath, extractFilePath, normalizeFileVariable, resolveInnerVaria
5
5
  const { preResolveString, preResolveSingle } = require('../resolution/preResolveVariable')
6
6
  const { parseOneOfFilter } = require('../filters/oneOf')
7
7
  const { extractComment } = require('./extractComment')
8
+ const { applyDotenvFileRefMetadata, normalizeDotenvFileVariable } = require('../security/dotenvFileRefs')
8
9
 
9
10
  // Type filters that indicate expected value types
10
11
  const TYPE_FILTERS = ['Boolean', 'String', 'Number', 'Array', 'Object', 'Json']
@@ -109,6 +110,12 @@ function getSourceForType(variableType, variableTypes) {
109
110
  return typeDef?.source
110
111
  }
111
112
 
113
+ function sameFileRefPath(left, right) {
114
+ if (!left || !right) return false
115
+ const normalize = value => String(value).replace(/^\.\//, '')
116
+ return normalize(left) === normalize(right)
117
+ }
118
+
112
119
  /**
113
120
  * Enriches variable metadata with resolution tracking data.
114
121
  * @param {object} metadata - The metadata object from collectVariableMetadata.
@@ -388,6 +395,7 @@ async function enrichMetadata(
388
395
  containsVariables: !!ref.hasInnerVariable,
389
396
  exists: details.exists,
390
397
  }
398
+ applyDotenvFileRefMetadata(confDetails, details)
391
399
  if (ref.pattern) {
392
400
  confDetails.pattern = ref.pattern
393
401
  }
@@ -478,7 +486,7 @@ async function enrichMetadata(
478
486
  }
479
487
 
480
488
  // Normalize file() and text() references
481
- baseVar = normalizeFileVariable(baseVar)
489
+ baseVar = normalizeDotenvFileVariable(baseVar) || normalizeFileVariable(baseVar)
482
490
 
483
491
  if (!uniqueVariablesMap.has(baseVar)) {
484
492
  uniqueVariablesMap.set(baseVar, {
@@ -536,7 +544,7 @@ async function enrichMetadata(
536
544
  const siblingBaseVar = detail.valueBeforeFallback || detail.variable
537
545
 
538
546
  // Normalize file/text references for sibling too
539
- const normalizedSiblingVar = normalizeFileVariable(siblingBaseVar)
547
+ const normalizedSiblingVar = normalizeDotenvFileVariable(siblingBaseVar) || normalizeFileVariable(siblingBaseVar)
540
548
 
541
549
  // Create or get entry for this sibling variable
542
550
  if (!uniqueVariablesMap.has(normalizedSiblingVar)) {
@@ -665,7 +673,7 @@ async function enrichMetadata(
665
673
  }
666
674
 
667
675
  // Normalize file paths after variable substitution
668
- resolvedVariable = normalizeFileVariable(resolvedVariable)
676
+ resolvedVariable = normalizeDotenvFileVariable(resolvedVariable) || normalizeFileVariable(resolvedVariable)
669
677
 
670
678
  // Update the variable to the resolved version and update map key
671
679
  if (resolvedVariable !== baseVar) {
@@ -712,13 +720,22 @@ async function enrichMetadata(
712
720
 
713
721
  if (!hasVariables) {
714
722
  // Look up in fileRefsFound to see if file exists
715
- const fileRef = fileRefsFound.find(ref => ref.relativePath === filePath)
723
+ const fileRef = fileRefsFound.find(ref => sameFileRefPath(ref.relativePath, filePath))
716
724
  if (fileRef) {
717
725
  entry.fileExists = fileRef.exists
726
+ applyDotenvFileRefMetadata(entry, {
727
+ ...fileRef,
728
+ variable: entry.variable,
729
+ })
718
730
  } else if (configPath) {
719
731
  const thePath = path.resolve(path.dirname(configPath), filePath)
720
732
  const fileExists = fs.existsSync(thePath)
721
733
  entry.fileExists = fileExists
734
+ applyDotenvFileRefMetadata(entry, {
735
+ filePath: thePath,
736
+ relativePath: filePath,
737
+ variable: entry.variable,
738
+ })
722
739
  }
723
740
  }
724
741
  }
@@ -4,8 +4,10 @@ const path = require('path')
4
4
  const YAML = require('../../parsers/yaml')
5
5
  const TOML = require('../../parsers/toml')
6
6
  const INI = require('../../parsers/ini')
7
+ const DOTENV = require('../../parsers/dotenv')
7
8
  const JSON5 = require('../../parsers/json5')
8
9
  const HCL = require('../../parsers/hcl')
10
+ const { isEnvFile } = require('../paths/fileType')
9
11
  const { executeTypeScriptFileSync } = require('../../parsers/typescript')
10
12
  const { executeESMFileSync } = require('../../parsers/esm')
11
13
  const cloudFormationSchema = require('./cloudformationSchema')
@@ -72,8 +74,12 @@ function detectFormat(contents) {
72
74
  function parseFileContents({ contents, filePath, varRegex, dynamicArgs }) {
73
75
  let fileType = path.extname(filePath)
74
76
 
75
- // Content-based detection for extensionless or unrecognized files
76
- if (!fileType || !KNOWN_EXTENSIONS.has(fileType.toLowerCase())) {
77
+ // Dotenv files have no extension (path.extname('.env') === ''), so detect
78
+ // them by name before falling back to content sniffing.
79
+ if (isEnvFile(filePath)) {
80
+ fileType = '.env'
81
+ } else if (!fileType || !KNOWN_EXTENSIONS.has(fileType.toLowerCase())) {
82
+ // Content-based detection for extensionless or unrecognized files
77
83
  fileType = detectFormat(contents)
78
84
  }
79
85
 
@@ -105,6 +111,8 @@ function parseFileContents({ contents, filePath, varRegex, dynamicArgs }) {
105
111
  configObject = TOML.parse(contents)
106
112
  } else if (fileType.match(/\.(ini)/i)) {
107
113
  configObject = INI.parse(contents)
114
+ } else if (fileType === '.env') {
115
+ configObject = DOTENV.parse(contents)
108
116
  } else if (fileType.match(/\.(json|json5|jsonc)/i)) {
109
117
  configObject = JSON5.parse(contents)
110
118
  } else if (fileType.match(/\.(tf|hcl)$/i) || filePath.match(/\.tf\.json$/i)) {
@@ -0,0 +1,27 @@
1
+ // Classifies config files by type, treating dotenv files as ".env"
2
+ // so extension-less names like .env / .env.local / deploy.env resolve consistently
3
+ const path = require('path')
4
+
5
+ /**
6
+ * Whether a path is a dotenv file (.env, .env.local, deploy.env, ...).
7
+ * `path.extname('.env')` is '' (dotfile), so detection is by basename.
8
+ * @param {string} filePath - File path or name
9
+ * @returns {boolean} True for dotenv files
10
+ */
11
+ function isEnvFile(filePath) {
12
+ const base = path.basename(String(filePath || ''))
13
+ return base === '.env' || base.startsWith('.env.') || base.endsWith('.env')
14
+ }
15
+
16
+ /**
17
+ * Normalized config file type: '.env' for dotenv files, else the lowercased
18
+ * extension.
19
+ * @param {string} filePath - File path or name
20
+ * @returns {string} File type (e.g. '.yml', '.env', '')
21
+ */
22
+ function configFileType(filePath) {
23
+ if (isEnvFile(filePath)) return '.env'
24
+ return path.extname(String(filePath || '')).toLowerCase()
25
+ }
26
+
27
+ module.exports = { isEnvFile, configFileType }
@@ -0,0 +1,41 @@
1
+ /* Tests for config file type classification (dotenv detection) */
2
+ const { test } = require('uvu')
3
+ const assert = require('uvu/assert')
4
+ const { isEnvFile, configFileType } = require('./fileType')
5
+
6
+ test('isEnvFile detects the env-stage-loader precedence names', () => {
7
+ // .env.{environment}.local, .env.{environment}, .env.local, .env
8
+ assert.ok(isEnvFile('.env'))
9
+ assert.ok(isEnvFile('.env.local'))
10
+ assert.ok(isEnvFile('.env.production'))
11
+ assert.ok(isEnvFile('.env.production.local'))
12
+ assert.ok(isEnvFile('/path/to/.env.staging.local'))
13
+ assert.ok(isEnvFile('deploy.env'))
14
+ })
15
+
16
+ test('isEnvFile rejects non-dotenv names', () => {
17
+ assert.is(isEnvFile('config.yml'), false)
18
+ assert.is(isEnvFile('env.js'), false)
19
+ assert.is(isEnvFile('environment.json'), false)
20
+ assert.is(isEnvFile('.environment'), false)
21
+ assert.is(isEnvFile(''), false)
22
+ })
23
+
24
+ test('configFileType returns .env for staged dotenv names', () => {
25
+ assert.is(configFileType('.env.production.local'), '.env')
26
+ assert.is(configFileType('.env.staging'), '.env')
27
+ })
28
+
29
+ test('configFileType returns .env for dotenv files', () => {
30
+ assert.is(configFileType('.env'), '.env')
31
+ assert.is(configFileType('/x/.env.local'), '.env')
32
+ assert.is(configFileType('deploy.env'), '.env')
33
+ })
34
+
35
+ test('configFileType returns lowercased extension otherwise', () => {
36
+ assert.is(configFileType('config.YML'), '.yml')
37
+ assert.is(configFileType('data.json'), '.json')
38
+ assert.is(configFileType('noext'), '')
39
+ })
40
+
41
+ test.run()
@@ -31,6 +31,10 @@ function findLineForKey(keyToFind, lines, fileType) {
31
31
  if (fileType === '.ini') {
32
32
  return new RegExp(`^\\s*${escapedKey}\\s*=`).test(line)
33
33
  }
34
+ // dotenv: KEY= or export KEY=
35
+ if (fileType === '.env') {
36
+ return new RegExp(`^\\s*(?:export\\s+)?${escapedKey}\\s*=`).test(line)
37
+ }
34
38
  // JS/TS/ESM: key: or "key": or 'key': or `key`: or [`key`]:
35
39
  if (['.js', '.mjs', '.cjs', '.ts', '.mts', '.cts'].includes(fileType)) {
36
40
  return new RegExp(`(?:${escapedKey}|"${escapedKey}"|'${escapedKey}'|\`${escapedKey}\`|\\[\`${escapedKey}\`\\])\\s*:`).test(line)
@@ -55,13 +59,32 @@ function findLineByPath(configPath, lines, fileType) {
55
59
 
56
60
  const isYaml = fileType === '.yml' || fileType === '.yaml'
57
61
  const isJson = fileType === '.json' || fileType === '.json5' || fileType === '.jsonc'
58
- if (!isYaml && !isJson) return 0
62
+ const isEnv = fileType === '.env'
63
+ if (!isYaml && !isJson && !isEnv) return 0
59
64
 
60
65
  const segments = configPath.split('.')
66
+ if (isEnv) return findLineByPathEnv(segments, lines)
61
67
  if (isYaml) return findLineByPathYaml(segments, lines)
62
68
  return findLineByPathJson(segments, lines)
63
69
  }
64
70
 
71
+ /**
72
+ * Find a dotenv key's line. .env is flat, so the key is the first path segment.
73
+ * Handles leading whitespace and an optional `export ` prefix.
74
+ * @param {string[]} segments
75
+ * @param {string[]} lines
76
+ * @returns {number}
77
+ */
78
+ function findLineByPathEnv(segments, lines) {
79
+ const key = segments[0]
80
+ const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
81
+ const pattern = new RegExp(`^\\s*(?:export\\s+)?${escaped}\\s*=`)
82
+ for (let li = 0; li < lines.length; li++) {
83
+ if (pattern.test(lines[li])) return li + 1
84
+ }
85
+ return 0
86
+ }
87
+
65
88
  /**
66
89
  * @param {string[]} segments
67
90
  * @param {string[]} lines
@@ -3,7 +3,7 @@
3
3
  */
4
4
  const { test } = require('uvu')
5
5
  const assert = require('uvu/assert')
6
- const { findLineForKey } = require('./findLineForKey')
6
+ const { findLineForKey, findLineByPath } = require('./findLineForKey')
7
7
 
8
8
  // YAML tests
9
9
  test('YAML - finds key at start of line', () => {
@@ -123,4 +123,23 @@ test('unknown file type falls back to YAML-style', () => {
123
123
  assert.equal(findLineForKey('foo', lines, '.unknown'), 1)
124
124
  })
125
125
 
126
+ // dotenv
127
+ test('dotenv - finds KEY= line', () => {
128
+ const lines = ['API_URL=https://x', 'stage=${opt:stage}']
129
+ assert.equal(findLineForKey('stage', lines, '.env'), 2)
130
+ assert.equal(findLineForKey('API_URL', lines, '.env'), 1)
131
+ })
132
+
133
+ test('dotenv - finds export-prefixed and whitespace-padded keys', () => {
134
+ const lines = ['# comment', 'export TOKEN=abc', ' PADDED = x']
135
+ assert.equal(findLineForKey('TOKEN', lines, '.env'), 2)
136
+ assert.equal(findLineForKey('PADDED', lines, '.env'), 3)
137
+ })
138
+
139
+ test('findLineByPath - dotenv key lookup', () => {
140
+ const lines = ['A=1', 'stage=${opt:stage}', 'B=2']
141
+ assert.equal(findLineByPath('stage', lines, '.env'), 2)
142
+ assert.equal(findLineByPath('missing', lines, '.env'), 0)
143
+ })
144
+
126
145
  test.run()
@@ -0,0 +1,134 @@
1
+ const path = require('path')
2
+ const { normalizePath } = require('../paths/filePathUtils')
3
+ const { splitCsv } = require('../strings/splitCsv')
4
+ const { trimSurroundingQuotes } = require('../strings/quoteUtils')
5
+
6
+ const DOTENV_SENSITIVITY_REASON = 'dotenv_file'
7
+
8
+ function isDotenvFilePath(filePath) {
9
+ if (!filePath || typeof filePath !== 'string') return false
10
+ const cleanPath = filePath.trim().replace(/^["']|["']$/g, '')
11
+ const baseName = path.basename(cleanPath)
12
+ return baseName === '.env' || baseName.startsWith('.env.')
13
+ }
14
+
15
+ function hasFileAccessor(variableString) {
16
+ const expression = extractFileExpression(variableString)
17
+ if (!expression) return false
18
+ const suffix = String(variableString || '').slice(expression.end).trim()
19
+ return suffix.startsWith(':') || suffix.startsWith('.')
20
+ }
21
+
22
+ function extractFileExpression(variableString) {
23
+ const value = String(variableString || '')
24
+ const prefixMatch = /(?:^|[^\w.])((?:file|text)\()/.exec(value)
25
+ if (!prefixMatch) return null
26
+
27
+ const expressionStart = prefixMatch.index + prefixMatch[0].indexOf(prefixMatch[1])
28
+ const openParenIndex = expressionStart + prefixMatch[1].length - 1
29
+ let depth = 1
30
+ let index = openParenIndex + 1
31
+
32
+ while (index < value.length && depth > 0) {
33
+ if (value[index] === '(') depth++
34
+ else if (value[index] === ')') depth--
35
+ index++
36
+ }
37
+
38
+ if (depth !== 0) return null
39
+
40
+ const fileContent = value.substring(openParenIndex + 1, index - 1).trim()
41
+ if (!fileContent) return null
42
+
43
+ const parts = splitCsv(fileContent, undefined, { protectVariables: true })
44
+ const filePath = trimSurroundingQuotes(parts[0].trim(), false)
45
+
46
+ return {
47
+ filePath,
48
+ expression: value.slice(expressionStart, index),
49
+ start: expressionStart,
50
+ end: index,
51
+ }
52
+ }
53
+
54
+ function extractDotenvPath(source) {
55
+ const candidates = [
56
+ source.relativePath,
57
+ source.resolvedPath,
58
+ source.filePath,
59
+ source.fullFilePath,
60
+ ].filter(Boolean)
61
+
62
+ for (const candidate of candidates) {
63
+ if (isDotenvFilePath(candidate)) return candidate
64
+ }
65
+
66
+ const variableCandidates = [
67
+ source.variable,
68
+ source.variableString,
69
+ source.originalVariableString,
70
+ source.resolvedVariableString,
71
+ ].filter(Boolean)
72
+
73
+ for (const candidate of variableCandidates) {
74
+ const extracted = extractFileExpression(String(candidate))
75
+ if (extracted && isDotenvFilePath(extracted.filePath)) {
76
+ return extracted.filePath
77
+ }
78
+ }
79
+
80
+ return null
81
+ }
82
+
83
+ function getDotenvFileRefMetadata(source = {}) {
84
+ const dotenvPath = extractDotenvPath(source)
85
+ if (!dotenvPath) return null
86
+
87
+ const scopeSource = source.variableString ||
88
+ source.variable ||
89
+ source.originalVariableString ||
90
+ source.resolvedVariableString ||
91
+ ''
92
+ const accessorScope = hasFileAccessor(scopeSource) ? 'key' : null
93
+
94
+ return {
95
+ sensitive: true,
96
+ sensitivityReason: DOTENV_SENSITIVITY_REASON,
97
+ dotenvFile: true,
98
+ dotenvReadScope: accessorScope || source.dotenvReadScope || 'full_file',
99
+ }
100
+ }
101
+
102
+ function normalizeDotenvFileVariable(variableString) {
103
+ const value = String(variableString || '')
104
+ const expression = extractFileExpression(value)
105
+ if (!expression || !isDotenvFilePath(expression.filePath)) return null
106
+
107
+ const normalizedPath = normalizePath(expression.filePath) || expression.filePath
108
+ const normalizedExpression = expression.expression.replace(/\(([\s\S]*)\)$/, `(${normalizedPath})`)
109
+ const suffix = value.slice(expression.end).trim()
110
+ const accessor = suffix.match(/^([:.][\w.[\]-]+)/)
111
+
112
+ return accessor ? `${normalizedExpression}${accessor[1]}` : normalizedExpression
113
+ }
114
+
115
+ function isIniLikeFilePath(filePath) {
116
+ const ext = path.extname(String(filePath || '')).slice(1).toLowerCase()
117
+ return ext === 'ini' || isDotenvFilePath(filePath)
118
+ }
119
+
120
+ function applyDotenvFileRefMetadata(target, source) {
121
+ const metadata = getDotenvFileRefMetadata(source)
122
+ if (metadata) Object.assign(target, metadata)
123
+ return target
124
+ }
125
+
126
+ module.exports = {
127
+ DOTENV_SENSITIVITY_REASON,
128
+ applyDotenvFileRefMetadata,
129
+ getDotenvFileRefMetadata,
130
+ hasFileAccessor,
131
+ normalizeDotenvFileVariable,
132
+ isDotenvFilePath,
133
+ isIniLikeFilePath,
134
+ }
@@ -0,0 +1,47 @@
1
+ const { test } = require('uvu')
2
+ const assert = require('uvu/assert')
3
+ const {
4
+ getDotenvFileRefMetadata,
5
+ hasFileAccessor,
6
+ isDotenvFilePath,
7
+ normalizeDotenvFileVariable,
8
+ } = require('./dotenvFileRefs')
9
+
10
+ test('dotenv file detection matches .env and .env variants', () => {
11
+ assert.is(isDotenvFilePath('.env'), true)
12
+ assert.is(isDotenvFilePath('./.env.production'), true)
13
+ assert.is(isDotenvFilePath('/tmp/app/.env.local'), true)
14
+ assert.is(isDotenvFilePath('./env.yml'), false)
15
+ })
16
+
17
+ test('dotenv accessors are detected without assuming ${} syntax', () => {
18
+ assert.is(hasFileAccessor('file(.env).API_KEY'), true)
19
+ assert.is(hasFileAccessor('file(.env):TOKEN'), true)
20
+ assert.is(hasFileAccessor('${file(.env).API_KEY}'), true)
21
+ assert.is(hasFileAccessor('[[file(.env).API_KEY]]'), true)
22
+ assert.is(hasFileAccessor('file(.env)'), false)
23
+ })
24
+
25
+ test('dotenv metadata classifies full-file and key reads', () => {
26
+ assert.equal(getDotenvFileRefMetadata({ variable: 'file(.env)' }), {
27
+ sensitive: true,
28
+ sensitivityReason: 'dotenv_file',
29
+ dotenvFile: true,
30
+ dotenvReadScope: 'full_file',
31
+ })
32
+ assert.equal(getDotenvFileRefMetadata({ variable: '[[file(.env):TOKEN]]' }), {
33
+ sensitive: true,
34
+ sensitivityReason: 'dotenv_file',
35
+ dotenvFile: true,
36
+ dotenvReadScope: 'key',
37
+ })
38
+ })
39
+
40
+ test('dotenv variable normalization preserves key accessors', () => {
41
+ assert.is(normalizeDotenvFileVariable('file(.env)'), 'file(./.env)')
42
+ assert.is(normalizeDotenvFileVariable('file(.env).API_KEY'), 'file(./.env).API_KEY')
43
+ assert.is(normalizeDotenvFileVariable('file(.env):TOKEN'), 'file(./.env):TOKEN')
44
+ assert.is(normalizeDotenvFileVariable('file(config.yml):value'), null)
45
+ })
46
+
47
+ test.run()