configorama 1.0.2 → 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.
@@ -0,0 +1,145 @@
1
+ /* Normalizes 1Password references into canonical secretRef/item/privateLink forms
2
+ Validates aliases, parses private item links, rejects public share links */
3
+
4
+ const ALIAS_PATTERN = /^[A-Za-z0-9_]+$/
5
+ const PRIVATE_LINK_PREFIXES = ['https://start.1password.com/open/i', 'onepassword://open/i']
6
+
7
+ /**
8
+ * Validate an alias name from the refs config.
9
+ * Dots are reserved as the key path separator so they cannot appear in aliases.
10
+ * @param {string} alias - Alias name to validate
11
+ */
12
+ function validateAliasName(alias) {
13
+ if (!ALIAS_PATTERN.test(alias)) {
14
+ throw new Error(`Invalid 1Password alias "${alias}". Aliases may contain only letters, numbers, and underscores.`)
15
+ }
16
+ }
17
+
18
+ /**
19
+ * Check whether a string is a private item link.
20
+ * @param {string} value - Candidate string
21
+ * @returns {boolean} True when the string is a private item link
22
+ */
23
+ function isPrivateLink(value) {
24
+ return PRIVATE_LINK_PREFIXES.some((prefix) => value.startsWith(prefix))
25
+ }
26
+
27
+ /**
28
+ * Recognize abbreviated private-link forms where the scheme, host, and/or
29
+ * path have been stripped, leaving the query params (e.g. "open/i?...&i=ID",
30
+ * or a bare "v=VAULT&i=ID"). Requires an "i=" param plus a 1Password marker
31
+ * or a pure query string, so ordinary item names and unrelated URLs are not
32
+ * misread as links.
33
+ * @param {string} value - Candidate string
34
+ * @returns {boolean} True when the string looks like private-link query params
35
+ */
36
+ function looksLikeLinkParams(value) {
37
+ const query = value.includes('?') ? value.slice(value.indexOf('?') + 1) : value
38
+ if (!/(?:^|&)i=[^&]/.test(query)) return false
39
+ return /1password\.com/.test(value) || /(?:^|\/)open\/i/.test(value) || !value.includes('/')
40
+ }
41
+
42
+ /**
43
+ * Parse a "Copy Private Link" URL (or an abbreviated form) into item and
44
+ * vault IDs. The a (account) and h (host) params identify the account and are
45
+ * intentionally dropped; item + vault IDs are all op item get needs.
46
+ * @param {string} url - Private item link or its query params
47
+ * @returns {{kind: string, item: string, vault: string|undefined, warnings: Array<{code: string, message: string}>}} Normalized reference
48
+ */
49
+ function parsePrivateLink(url) {
50
+ const query = url.includes('?') ? url.slice(url.indexOf('?') + 1) : url
51
+ const params = new URLSearchParams(query)
52
+ const item = params.get('i')
53
+ const vault = params.get('v')
54
+
55
+ if (!item) {
56
+ throw new Error('Invalid 1Password private link. Expected query parameter "i" with the item ID.')
57
+ }
58
+
59
+ const warnings = []
60
+ if (!vault) {
61
+ warnings.push({
62
+ level: 'warning',
63
+ code: 'op_private_link_missing_vault',
64
+ message: '1Password private link did not include a vault ID; service accounts and duplicate item names may require vault scoping.',
65
+ })
66
+ }
67
+
68
+ return { kind: 'privateLink', item, vault: vault || undefined, warnings }
69
+ }
70
+
71
+ /**
72
+ * Normalize a string or object ref value into a canonical reference.
73
+ * Accepted forms: op:// string, item name string, private link string,
74
+ * { item, vault, section, field }, { ref }, { url }.
75
+ * @param {string|object} value - Ref value from config or direct spec
76
+ * @returns {object} Normalized reference ({ kind: 'secretRef'|'item'|'privateLink', ... })
77
+ */
78
+ function normalizeRefValue(value) {
79
+ if (typeof value === 'string') {
80
+ return normalizeStringRef(value)
81
+ }
82
+ if (value && typeof value === 'object') {
83
+ return normalizeObjectRef(value)
84
+ }
85
+ throw new Error(`Invalid 1Password reference. Expected a string or object, got ${typeof value}.`)
86
+ }
87
+
88
+ /**
89
+ * @param {string} value - String ref (secret ref, link, or item name)
90
+ * @returns {object} Normalized reference
91
+ */
92
+ function normalizeStringRef(value) {
93
+ if (value.startsWith('op://')) {
94
+ return { kind: 'secretRef', ref: value }
95
+ }
96
+ // Public share links are rejected in any form (full URL or share token).
97
+ if (value.includes('share.1password.com') || /1password\.com\/s#/.test(value)) {
98
+ throw new Error('Public 1Password share links are not supported. Use Copy Private Link or an op:// secret reference.')
99
+ }
100
+ // Private links: full URL, onepassword://, or an abbreviated form carrying
101
+ // the query params (scheme/host/path stripped).
102
+ if (isPrivateLink(value) || looksLikeLinkParams(value)) {
103
+ return parsePrivateLink(value)
104
+ }
105
+ if (/^https?:\/\/|^onepassword:\/\//.test(value)) {
106
+ throw new Error('Unrecognized 1Password link. Use Copy Private Link or an op:// secret reference.')
107
+ }
108
+ return { kind: 'item', item: value, vault: undefined, section: undefined, field: undefined }
109
+ }
110
+
111
+ /**
112
+ * @param {object} value - Object ref ({ item }, { ref }, or { url })
113
+ * @returns {object} Normalized reference
114
+ */
115
+ function normalizeObjectRef(value) {
116
+ const specifiers = ['item', 'ref', 'url'].filter((key) => value[key] !== undefined)
117
+ if (specifiers.length !== 1) {
118
+ throw new Error('Invalid 1Password reference. Object refs must specify exactly one of "item", "ref", or "url".')
119
+ }
120
+
121
+ if (value.ref !== undefined) {
122
+ return normalizeStringRef(value.ref)
123
+ }
124
+ if (value.url !== undefined) {
125
+ const normalized = normalizeStringRef(value.url)
126
+ if (normalized.kind !== 'privateLink') {
127
+ throw new Error('Invalid 1Password reference. "url" must be a private item link.')
128
+ }
129
+ return normalized
130
+ }
131
+
132
+ if (value.section !== undefined && value.field === undefined) {
133
+ throw new Error('Invalid 1Password reference. "section" is only meaningful together with "field".')
134
+ }
135
+
136
+ return {
137
+ kind: 'item',
138
+ item: value.item,
139
+ vault: value.vault,
140
+ section: value.section,
141
+ field: value.field,
142
+ }
143
+ }
144
+
145
+ module.exports = { validateAliasName, normalizeRefValue, parsePrivateLink, isPrivateLink }
@@ -0,0 +1,117 @@
1
+ /* Executes the 1Password CLI with execFile and sanitized error translation
2
+ Never logs stdout/stderr; never passes resolved secret values as arguments */
3
+ const childProcess = require('child_process')
4
+
5
+ const AUTH_ERROR_PATTERN = /sign ?in|session|authoriz|authenticat|not currently signed|locked|service account token/i
6
+ const NOT_FOUND_PATTERN = /isn'?t an? (item|vault)|not found|no item|no vault|doesn'?t exist|more than one item/i
7
+
8
+ /**
9
+ * Run the op binary with the given args.
10
+ * No `command -v op` preflight: translating ENOENT is the existence check.
11
+ * @param {string[]} args - CLI arguments
12
+ * @param {object} [options] - { execFile, opPath, account, configDir, subject }
13
+ * @returns {Promise<string>} stdout
14
+ */
15
+ function runOp(args, options = {}) {
16
+ const execFile = options.execFile || childProcess.execFile
17
+ const binary = options.opPath || 'op'
18
+ const finalArgs = args.slice()
19
+ if (options.account) {
20
+ finalArgs.push('--account', options.account)
21
+ }
22
+ if (options.configDir) {
23
+ finalArgs.push('--config', options.configDir)
24
+ }
25
+
26
+ return new Promise((resolve, reject) => {
27
+ execFile(binary, finalArgs, { maxBuffer: 10 * 1024 * 1024 }, (err, stdout, stderr) => {
28
+ if (err) {
29
+ return reject(translateError(err, stderr, options.subject))
30
+ }
31
+ resolve(stdout)
32
+ })
33
+ })
34
+ }
35
+
36
+ /**
37
+ * Translate op failures into sanitized, actionable errors.
38
+ * stderr is inspected for classification but never included in messages.
39
+ * @param {Error & {code?: string|number}} err - execFile error
40
+ * @param {string} stderr - Captured stderr (classification only)
41
+ * @param {string} [subject] - Item/ref identifier already present in user config
42
+ * @returns {Error} Sanitized error
43
+ */
44
+ function translateError(err, stderr, subject) {
45
+ if (err.code === 'ENOENT') {
46
+ return new Error('1Password CLI "op" was not found on PATH. Install 1Password CLI or remove the op resolver.')
47
+ }
48
+ const detail = String(stderr || err.message || '')
49
+ if (AUTH_ERROR_PATTERN.test(detail)) {
50
+ return new Error('1Password CLI could not read the item. Run op signin, unlock 1Password app integration, or configure OP_SERVICE_ACCOUNT_TOKEN.')
51
+ }
52
+ if (NOT_FOUND_PATTERN.test(detail)) {
53
+ const what = subject ? `"${subject}"` : 'the requested item, vault, or field'
54
+ return new Error(`1Password item, vault, or field could not be found (${what}).`)
55
+ }
56
+ return new Error('1Password CLI command failed.')
57
+ }
58
+
59
+ /**
60
+ * Reject config-controlled values that would be parsed as an op flag.
61
+ * execFile takes no shell, so this is not command injection - but a
62
+ * leading "-" turns a positional/vault value into an option that changes
63
+ * op's behavior. Secret refs are exempt: they always start with "op://".
64
+ * @param {string} value - Value destined for the op argument list
65
+ * @param {string} label - Human label for the error message
66
+ */
67
+ function assertNotFlag(value, label) {
68
+ if (typeof value === 'string' && value.startsWith('-')) {
69
+ throw new Error(`Invalid 1Password ${label} "${value}": ${label} cannot start with "-" (it would be read as an op flag). Use the item ID or an op:// secret reference.`)
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Read a secret reference with op read.
75
+ * @param {string} ref - op://vault/item/field reference
76
+ * @param {object} [options] - { execFile, account, configDir }
77
+ * @returns {Promise<string>} Secret value
78
+ */
79
+ function readSecretRef(ref, options = {}) {
80
+ return runOp(['read', '--no-newline', ref], {
81
+ execFile: options.execFile,
82
+ opPath: options.opPath,
83
+ account: options.account,
84
+ configDir: options.configDir,
85
+ subject: ref,
86
+ })
87
+ }
88
+
89
+ /**
90
+ * Fetch an item as JSON with op item get.
91
+ * --reveal is required to include concealed field values in the JSON.
92
+ * @param {string} spec - Item ID or item name
93
+ * @param {object} [options] - { execFile, account, configDir, vault }
94
+ * @returns {Promise<object>} Parsed item JSON
95
+ */
96
+ async function getItem(spec, options = {}) {
97
+ assertNotFlag(spec, 'item')
98
+ const args = ['item', 'get', spec, '--format', 'json', '--reveal']
99
+ if (options.vault) {
100
+ assertNotFlag(options.vault, 'vault')
101
+ args.push('--vault', options.vault)
102
+ }
103
+ const stdout = await runOp(args, {
104
+ execFile: options.execFile,
105
+ opPath: options.opPath,
106
+ account: options.account,
107
+ configDir: options.configDir,
108
+ subject: spec,
109
+ })
110
+ try {
111
+ return JSON.parse(stdout)
112
+ } catch (err) {
113
+ throw new Error(`Could not parse 1Password CLI output as JSON for item "${spec}".`)
114
+ }
115
+ }
116
+
117
+ module.exports = { runOp, readSecretRef, getItem }
@@ -0,0 +1,15 @@
1
+ {
2
+ "name": "configorama-onepassword",
3
+ "version": "0.1.0",
4
+ "description": "1Password variable source plugin for configorama",
5
+ "main": "index.js",
6
+ "scripts": {
7
+ "test": "node parser.test.js && node normalize.test.js && node op-cli.test.js && node fields.test.js && node index.test.js && node smoke.test.js"
8
+ },
9
+ "keywords": [
10
+ "configorama",
11
+ "1password",
12
+ "secrets"
13
+ ],
14
+ "license": "MIT"
15
+ }
@@ -0,0 +1,70 @@
1
+ /* Parses 1Password field text as INI/dotenv for key path reads
2
+ Raw field text is only parsed when a config reference asks for a key path */
3
+ const ini = require('ini')
4
+
5
+ /**
6
+ * Restore ini module coercions so secret values stay strings.
7
+ * The ini parser coerces true/false/null which corrupts literal
8
+ * secret text like FLAG=true.
9
+ * @param {*} value - Parsed ini node
10
+ * @returns {*} Node with primitives restored to strings
11
+ */
12
+ function stringifyPrimitives(value) {
13
+ if (value === true) return 'true'
14
+ if (value === false) return 'false'
15
+ if (value === null) return 'null'
16
+ if (typeof value === 'object') {
17
+ const result = {}
18
+ for (const key of Object.keys(value)) {
19
+ result[key] = stringifyPrimitives(value[key])
20
+ }
21
+ return result
22
+ }
23
+ return value
24
+ }
25
+
26
+ /**
27
+ * Parse structured secret text.
28
+ * V1 supports INI/dotenv only. Future format detection order:
29
+ * 1. explicit format option on the ref
30
+ * 2. JSON if the trimmed value starts with { or [
31
+ * 3. YAML only with proof it is not confused with INI
32
+ * 4. INI fallback
33
+ * @param {string} value - Raw field text
34
+ * @param {object} [options] - Reserved for future format selection
35
+ * @returns {object} Parsed key/value structure
36
+ */
37
+ function parseStructuredSecret(value, options = {}) {
38
+ let parsed
39
+ try {
40
+ parsed = ini.parse(value)
41
+ } catch (err) {
42
+ throw new Error(`Could not parse 1Password field "${options.fieldName || 'value'}" as INI/dotenv.`)
43
+ }
44
+ return stringifyPrimitives(parsed)
45
+ }
46
+
47
+ /**
48
+ * Read a dot-separated key path from a parsed structure.
49
+ * Error messages never include secret values.
50
+ * @param {object} parsed - Result of parseStructuredSecret
51
+ * @param {string} keyPath - Dot-separated path, e.g. "database.password"
52
+ * @param {string} fieldName - 1Password field name for error messages
53
+ * @returns {string} Selected value
54
+ */
55
+ function getKeyPath(parsed, keyPath, fieldName) {
56
+ const segments = keyPath.split('.')
57
+ let current = parsed
58
+ for (const segment of segments) {
59
+ if (current === null || typeof current !== 'object' || !(segment in current)) {
60
+ throw new Error(`Key path "${keyPath}" was not found in 1Password field "${fieldName}".`)
61
+ }
62
+ current = current[segment]
63
+ }
64
+ if (typeof current === 'object') {
65
+ throw new Error(`Key path "${keyPath}" was not found in 1Password field "${fieldName}".`)
66
+ }
67
+ return current
68
+ }
69
+
70
+ module.exports = { parseStructuredSecret, getKeyPath }
@@ -0,0 +1,15 @@
1
+ /* Factory entry for configorama.sync() workers
2
+ Rebuilds the 1Password resolver from JSON-serializable options inside the sync worker */
3
+ const createOnePasswordResolver = require('./index')
4
+
5
+ /**
6
+ * @param {object} options - JSON-round-tripped syncOptions ({ refs, account, configDir, skipResolution })
7
+ * @returns {object} Variable source
8
+ */
9
+ module.exports = function createSyncOnePasswordResolver(options = {}) {
10
+ if (options.hasInjectedExecFile) {
11
+ throw new Error('1Password resolver options are not serializable for sync usage. Remove the injected execFile or use the async API.')
12
+ }
13
+ const { hasInjectedExecFile, ...factoryOptions } = options
14
+ return createOnePasswordResolver(factoryOptions)
15
+ }
package/src/index.js CHANGED
@@ -163,7 +163,14 @@ module.exports.audit = async (configPathOrObject, settings = {}) => {
163
163
  const requirements = buildConfigRequirements(analysis)
164
164
  const introspection = buildIntrospection(analysis, { requirements })
165
165
  const customResolvers = Array.isArray(settings.variableSources)
166
- ? settings.variableSources.map(source => source.type).filter(Boolean)
166
+ ? settings.variableSources
167
+ .filter(source => source.type)
168
+ .map(source => ({
169
+ type: source.type,
170
+ sensitive: source.sensitive,
171
+ risk: source.risk,
172
+ description: source.description,
173
+ }))
167
174
  : []
168
175
  const originalConfig = analysis.originalConfig || {}
169
176
  return buildAuditReport(introspection, {
package/src/main.js CHANGED
@@ -92,6 +92,7 @@ const { mergeByKeys } = require('./utils/parsing/mergeByKeys')
92
92
  const { arrayToJsonPath } = require('./utils/parsing/arrayToJsonPath')
93
93
  /* Utils - paths */
94
94
  const { findLineByPath } = require('./utils/paths/findLineForKey')
95
+ const { configFileType } = require('./utils/paths/fileType')
95
96
  const { normalizeIgnorePaths, compileIgnorePaths, shouldIgnorePath } = require('./utils/paths/ignorePaths')
96
97
  /* Utils - regex */
97
98
  const { combineRegexes, funcRegex, fileRefSyntax, textRefSyntax } = require('./utils/regex')
@@ -2574,8 +2575,8 @@ Missing Value ${missingValue} - ${matchedString}
2574
2575
  if (valueCount.length === 1 && noNestedVars) {
2575
2576
  let lineInfo = ''
2576
2577
  if (this.originalString && this.configFilePath && valueObject.path) {
2577
- const ext = path.extname(this.configFilePath)
2578
- if (ext === '.yml' || ext === '.yaml' || ext === '.json') {
2578
+ const ext = configFileType(this.configFilePath)
2579
+ if (ext === '.yml' || ext === '.yaml' || ext === '.json' || ext === '.env') {
2579
2580
  const rawLines = this.originalString.split('\n')
2580
2581
  const lineNum = findLineByPath(arrayToJsonPath(valueObject.path), rawLines, ext)
2581
2582
  if (lineNum) lineInfo = ` at line ${lineNum},`
@@ -3049,7 +3050,9 @@ Missing Value ${missingValue} - ${matchedString}
3049
3050
  // ###############
3050
3051
  initialCall(func) {
3051
3052
  this.deep = []
3052
- this.tracker.start()
3053
+ // Progress reporting is a debug-only aid; the tracker still coordinates
3054
+ // promises and detects cycles regardless.
3055
+ this.tracker.start(DEBUG)
3053
3056
  return func().finally(() => {
3054
3057
  this.tracker.stop()
3055
3058
  this.deep = []
@@ -0,0 +1,13 @@
1
+ // Parses .env file contents into a flat key/value object
2
+ // Values keep their raw text so configorama can resolve ${...} references in them
3
+ const dotenv = require('dotenv')
4
+
5
+ /**
6
+ * @param {string} contents - Raw .env file text
7
+ * @returns {Object} Flat map of KEY -> raw string value
8
+ */
9
+ function parse(contents) {
10
+ return dotenv.parse(contents)
11
+ }
12
+
13
+ module.exports = { parse }
@@ -266,7 +266,8 @@ Default fallback value will be used if provided.
266
266
 
267
267
  ${JSON.stringify(options.context, null, 2)}`,
268
268
  })
269
- console.log(errorMsg)
269
+ // Diagnostic goes to stderr so it never pollutes resolved-config stdout
270
+ console.error(errorMsg)
270
271
  }
271
272
  // TODO maybe reject. YAML does not allow for null/undefined values
272
273
  // return Promise.reject(new Error(errorMsg))
package/src/sync.js CHANGED
@@ -9,6 +9,21 @@ const enrichMetadata = require('./utils/parsing/enrichMetadata')
9
9
  */
10
10
  module.exports = function configoramaSync(variableSources = []) {
11
11
  const customVariableSources = variableSources.map((varSrc) => {
12
+ /* Plugin factories: match/resolver do not survive the JSON trip through
13
+ sync-rpc, so plugins carry a syncFactory path + JSON syncOptions and
14
+ the real source is rebuilt here inside the worker process */
15
+ if (varSrc.syncFactory) {
16
+ const factoryPath = getFullPath(varSrc.syncFactory)
17
+ if (!fs.existsSync(factoryPath)) {
18
+ throw new Error(`Sync factory missing. Can't find ${factoryPath}`)
19
+ }
20
+ const createSource = require(factoryPath)
21
+ if (typeof createSource !== 'function') {
22
+ throw new Error(`Sync factory must export a function. Check ${factoryPath}`)
23
+ }
24
+ return createSource(varSrc.syncOptions || {})
25
+ }
26
+
12
27
  if (!varSrc.match || typeof varSrc.match !== 'string') {
13
28
  throw new Error('Variable source must be string for .sync usage')
14
29
  }
@@ -67,6 +82,18 @@ module.exports = function configoramaSync(variableSources = []) {
67
82
  instance.variableTypes
68
83
  )
69
84
 
85
+ /* Collect custom metadata from worker-side sources, mirroring the
86
+ collectMetadata loop the async API runs in src/index.js */
87
+ for (const source of customVariableSources) {
88
+ if (typeof source.collectMetadata === 'function') {
89
+ const customData = source.collectMetadata()
90
+ if (customData !== undefined && customData !== null) {
91
+ const metadataKey = source.metadataKey || `${source.type}References`
92
+ enrichedMetadata[metadataKey] = customData
93
+ }
94
+ }
95
+ }
96
+
70
97
  return {
71
98
  variableSyntax: instance.variableSyntax,
72
99
  variableTypes: instance.variableTypes,
@@ -13,15 +13,21 @@ class PromiseTracker {
13
13
  this.startTime = Date.now()
14
14
  this.cursor = 0
15
15
  }
16
- start() {
16
+ start(enableProgress = false) {
17
17
  this.reset()
18
- this.interval = setInterval(this.report.bind(this), 2500)
18
+ // The tracker's job is cycle detection and promise coordination; the
19
+ // periodic progress report is a debug-only aid, off unless requested.
20
+ if (enableProgress) {
21
+ this.interval = setInterval(this.report.bind(this), 2500)
22
+ }
19
23
  }
20
24
  report() {
21
25
  const delta = Date.now() - this.startTime
22
26
  const pending = this.getPending()
23
27
  const dots = dotDotDot(this.cursor++, 100, '...')
24
- console.log([
28
+ // Diagnostic progress goes to stderr so it never pollutes stdout (resolved
29
+ // config output, or `configx --export` lines consumed by eval).
30
+ console.error([
25
31
  `Fetching Async values${dots}`,
26
32
  ].concat(
27
33
  pending.map((promise) => `- ${promise.waitList}`)
@@ -40,7 +46,8 @@ class PromiseTracker {
40
46
  /**/
41
47
  }
42
48
  stop() {
43
- clearInterval(this.interval)
49
+ if (this.interval) clearInterval(this.interval)
50
+ this.interval = null
44
51
  this.reset()
45
52
  }
46
53
  add(variable, promise, specifier, hasFilter, promiseKey) {
@@ -0,0 +1,48 @@
1
+ /* Tests that resolution progress output goes to stderr, not stdout */
2
+ const { test } = require('uvu')
3
+ const assert = require('uvu/assert')
4
+ const PromiseTracker = require('./PromiseTracker')
5
+
6
+ test('start() does not enable progress logging by default', () => {
7
+ const tracker = new PromiseTracker()
8
+ tracker.start()
9
+ const hasInterval = !!tracker.interval
10
+ tracker.stop()
11
+ assert.is(hasInterval, false, 'no interval timer unless progress is requested')
12
+ })
13
+
14
+ test('start(true) enables the progress interval; stop() clears it', () => {
15
+ const tracker = new PromiseTracker()
16
+ tracker.start(true)
17
+ assert.ok(tracker.interval, 'interval set when progress requested')
18
+ tracker.stop()
19
+ assert.is(tracker.interval, null, 'interval cleared on stop')
20
+ })
21
+
22
+ test('report() writes progress to stderr, not stdout', () => {
23
+ const tracker = new PromiseTracker()
24
+ const pending = Promise.resolve()
25
+ tracker.add('${op(a?x=1&y=2)}', pending, '${op(a?x=1&y=2)}')
26
+ // add() attaches a .then that flips state to resolved on next tick; force pending
27
+ tracker.promiseList[0].state = 'pending'
28
+
29
+ const outLines = []
30
+ const errLines = []
31
+ const origLog = console.log
32
+ const origErr = console.error
33
+ console.log = (...args) => outLines.push(args.join(' '))
34
+ console.error = (...args) => errLines.push(args.join(' '))
35
+ try {
36
+ tracker.report()
37
+ } finally {
38
+ console.log = origLog
39
+ console.error = origErr
40
+ }
41
+
42
+ assert.is(outLines.length, 0, 'nothing on stdout')
43
+ assert.ok(errLines.some((line) => line.includes('Fetching Async values')), 'progress on stderr')
44
+ // the pending variable (with & in it) must not leak to stdout
45
+ assert.ok(outLines.every((line) => !line.includes('&')))
46
+ })
47
+
48
+ test.run()
@@ -41,15 +41,11 @@ function buildAuditReport(introspection, options = {}) {
41
41
  }
42
42
 
43
43
  if (options.customResolvers && options.customResolvers.length) {
44
- for (const resolver of options.customResolvers.slice().sort()) {
45
- findings.push({
46
- id: `customResolver:${resolver}`,
47
- severity: 'high',
48
- risk: 'custom_extension',
49
- kind: 'source',
50
- variableType: resolver,
51
- message: `Custom resolver "${resolver}" can execute user-provided code.`,
52
- })
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))
53
49
  }
54
50
  }
55
51
 
@@ -70,6 +66,40 @@ function buildAuditReport(introspection, options = {}) {
70
66
  }
71
67
  }
72
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
+
73
103
  function messageForNode(node) {
74
104
  if (node.risk === 'executable_code') return 'Reference may execute JavaScript or TypeScript.'
75
105
  if (node.risk === 'process_spawn') return 'Reference may spawn a git process.'
@@ -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)) {