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.
- package/README.md +31 -0
- package/package.json +10 -3
- package/plugins/cloudformation/README.md +79 -0
- package/plugins/cloudformation/credentials.js +231 -0
- package/plugins/cloudformation/index.js +204 -0
- package/plugins/cloudformation/package-lock.json +657 -0
- package/plugins/cloudformation/package.json +24 -0
- package/plugins/onepassword/README.md +175 -0
- package/plugins/onepassword/fields.js +126 -0
- package/plugins/onepassword/index.js +345 -0
- package/plugins/onepassword/normalize.js +145 -0
- package/plugins/onepassword/op-cli.js +117 -0
- package/plugins/onepassword/package.json +15 -0
- package/plugins/onepassword/parser.js +70 -0
- package/plugins/onepassword/sync-factory.js +15 -0
- package/src/index.js +8 -1
- package/src/main.js +6 -3
- package/src/metadata.js +7 -0
- package/src/parsers/dotenv.js +13 -0
- package/src/resolvers/valueFromFile.js +16 -6
- package/src/sync.js +27 -0
- package/src/utils/PromiseTracker.js +11 -4
- package/src/utils/PromiseTracker.test.js +48 -0
- package/src/utils/introspection/audit.js +48 -11
- package/src/utils/introspection/model.js +36 -6
- package/src/utils/parsing/enrichMetadata.js +21 -4
- package/src/utils/parsing/parse.js +10 -2
- package/src/utils/paths/fileType.js +27 -0
- package/src/utils/paths/fileType.test.js +41 -0
- package/src/utils/paths/findLineForKey.js +24 -1
- package/src/utils/paths/findLineForKey.test.js +20 -1
- package/src/utils/security/dotenvFileRefs.js +134 -0
- package/src/utils/security/dotenvFileRefs.test.js +47 -0
|
@@ -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
|
-
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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:
|
|
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 :
|
|
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
|
-
|
|
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
|
|
115
|
-
severity:
|
|
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
|
|
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
|
-
//
|
|
76
|
-
|
|
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
|
-
|
|
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()
|