configorama 1.0.1 → 1.0.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "configorama",
3
- "version": "1.0.1",
3
+ "version": "1.0.2",
4
4
  "description": "Variable support for configuration files",
5
5
  "main": "src/index.js",
6
6
  "types": "index.d.ts",
package/src/metadata.js CHANGED
@@ -9,6 +9,7 @@ const { normalizePath, extractFilePath, resolveInnerVariables } = require('./uti
9
9
  const { shouldIgnorePath } = require('./utils/paths/ignorePaths')
10
10
  const { findNestedVariables } = require('./utils/variables/findNestedVariables')
11
11
  const { splitOnPipe } = require('./utils/strings/splitOnPipe')
12
+ const { applyDotenvFileRefMetadata } = require('./utils/security/dotenvFileRefs')
12
13
 
13
14
  /**
14
15
  * Collect metadata about all variables found in the configuration
@@ -329,6 +330,12 @@ function collectVariableMetadata({
329
330
  containsVariables,
330
331
  exists: fileExists,
331
332
  }
333
+ applyDotenvFileRefMetadata(configPathEntry, {
334
+ filePath: absolutePath,
335
+ relativePath: resolvedPath,
336
+ originalVariableString: rawValue,
337
+ resolvedVariableString: resolvedVarString,
338
+ })
332
339
  if (globPattern) {
333
340
  configPathEntry.pattern = globPattern
334
341
  }
@@ -9,6 +9,7 @@ const { findNestedVariables } = require('../utils/variables/findNestedVariables'
9
9
  const { makeBox } = require('@davidwells/box-logger')
10
10
  const { encodeJsSyntax, decodeJsonInVariable, hasEncodedJson } = require('../utils/encoders/js-fixes')
11
11
  const { checkFileAccess } = require('../utils/security/safetyPolicy')
12
+ const { applyDotenvFileRefMetadata, isIniLikeFilePath } = require('../utils/security/dotenvFileRefs')
12
13
 
13
14
  /* File Parsers */
14
15
  const YAML = require('../parsers/yaml')
@@ -85,7 +86,7 @@ function parseFileContents(content, filePath) {
85
86
  if (ext === 'toml' || ext === 'tml') {
86
87
  return TOML.parse(content)
87
88
  }
88
- if (isIniLikeExtension(ext)) {
89
+ if (isIniLikeExtension(ext, filePath)) {
89
90
  return INI.parse(content)
90
91
  }
91
92
 
@@ -93,8 +94,8 @@ function parseFileContents(content, filePath) {
93
94
  return content
94
95
  }
95
96
 
96
- function isIniLikeExtension(ext) {
97
- return ext === 'ini' || ext === 'env'
97
+ function isIniLikeExtension(ext, filePath) {
98
+ return ext === 'ini' || ext === 'env' || isIniLikeFilePath(filePath)
98
99
  }
99
100
 
100
101
  /**
@@ -205,6 +206,14 @@ async function getValueFromFile(ctx, variableString, options) {
205
206
  containsVariables: options.context.value !== options.context.originalSource,
206
207
  exists,
207
208
  }
209
+ applyDotenvFileRefMetadata(fileRefEntry, {
210
+ filePath: fullFilePath,
211
+ relativePath,
212
+ resolvedPath,
213
+ variableString,
214
+ originalVariableString: options.context.originalSource,
215
+ resolvedVariableString: options.context.value,
216
+ })
208
217
 
209
218
  if (wasOverridden) {
210
219
  fileRefEntry.wasOverridden = true
@@ -411,7 +420,7 @@ ${JSON.stringify(options.context, null, 2)}`,
411
420
  if (fileExtension === 'toml' || fileExtension === 'tml') {
412
421
  valueToPopulate = JSON.stringify(TOML.parse(valueToPopulate))
413
422
  }
414
- if (isIniLikeExtension(fileExtension)) {
423
+ if (isIniLikeExtension(fileExtension, relativePath)) {
415
424
  valueToPopulate = INI.toJson(valueToPopulate)
416
425
  }
417
426
  if (fileExtension === 'tf' || fileExtension === 'hcl') {
@@ -451,7 +460,7 @@ Please use ":" or "." to reference sub properties. ${deepPropertiesStr}`
451
460
  return Promise.resolve(valueToPopulate)
452
461
  }
453
462
 
454
- if (isIniLikeExtension(fileExtension)) {
463
+ if (isIniLikeExtension(fileExtension, relativePath)) {
455
464
  valueToPopulate = INI.parse(valueToPopulate)
456
465
  return Promise.resolve(valueToPopulate)
457
466
  }
@@ -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) {
@@ -68,6 +73,8 @@ function buildAuditReport(introspection, options = {}) {
68
73
  function messageForNode(node) {
69
74
  if (node.risk === 'executable_code') return 'Reference may execute JavaScript or TypeScript.'
70
75
  if (node.risk === 'process_spawn') return 'Reference may spawn a git process.'
76
+ if (node.sensitivityReason === 'dotenv_file' && node.dotenvReadScope === 'full_file') return 'Reference reads an entire dotenv file; resolved output may contain secrets.'
77
+ if (node.sensitivityReason === 'dotenv_file') return 'Reference reads a key from a dotenv file.'
71
78
  if (node.risk === 'local_file_read') return 'Reference reads a local file.'
72
79
  if (node.risk === 'data_flow_expression') return 'Expression can read resolved config values but is not JavaScript execution.'
73
80
  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
  }
@@ -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()