configorama 0.11.2 → 1.0.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 +429 -123
- package/cli.js +282 -49
- package/index.d.ts +43 -1
- package/package.json +5 -1
- package/src/capabilities.js +59 -0
- package/src/capabilities.test.js +44 -0
- package/src/display.js +70 -7
- package/src/display.test.js +82 -0
- package/src/errors.js +73 -0
- package/src/index.js +91 -1
- package/src/main.js +117 -15
- package/src/resolvers/valueFromEval.js +11 -1
- package/src/resolvers/valueFromFile.js +5 -0
- package/src/resolvers/valueFromGit.js +43 -17
- package/src/resolvers/valueFromOptions.js +5 -4
- package/src/utils/filters/filterArgs.js +57 -0
- package/src/utils/filters/oneOf.js +77 -0
- package/src/utils/introspection/audit.js +78 -0
- package/src/utils/introspection/graph.js +43 -0
- package/src/utils/introspection/model.js +150 -0
- package/src/utils/introspection/model.test.js +93 -0
- package/src/utils/parsing/commentAnnotations.js +107 -0
- package/src/utils/parsing/commentAnnotations.test.js +123 -0
- package/src/utils/parsing/enrichMetadata.js +64 -1
- package/src/utils/parsing/enrichMetadata.test.js +84 -0
- package/src/utils/parsing/extractComment.js +145 -0
- package/src/utils/parsing/extractComment.test.js +182 -0
- package/src/utils/parsing/preProcess.js +2 -1
- package/src/utils/paths/findLineForKey.js +2 -2
- package/src/utils/paths/ignorePaths.js +21 -9
- package/src/utils/redaction/redact.js +78 -0
- package/src/utils/redaction/redact.test.js +38 -0
- package/src/utils/redaction/setupRedaction.js +47 -0
- package/src/utils/redaction/setupRedaction.test.js +68 -0
- package/src/utils/requirements/configRequirements.js +351 -0
- package/src/utils/requirements/configRequirements.test.js +380 -0
- package/src/utils/requirements/serializeRequirements.js +120 -0
- package/src/utils/requirements/serializeRequirements.test.js +211 -0
- package/src/utils/security/evalSafety.js +86 -0
- package/src/utils/security/evalSafety.test.js +61 -0
- package/src/utils/security/safetyPolicy.js +110 -0
- package/src/utils/security/safetyPolicy.test.js +29 -0
- package/src/utils/strings/didYouMean.js +70 -0
- package/src/utils/strings/didYouMean.test.js +52 -0
- package/src/utils/strings/formatFunctionArgs.js +6 -1
- package/src/utils/strings/splitByComma.js +5 -0
- package/src/utils/ui/configWizard.js +208 -34
- package/src/utils/ui/createEditorLink.js +17 -1
- package/src/utils/ui/promptDescriptors.js +196 -0
- package/src/utils/ui/promptDescriptors.test.js +162 -0
- package/src/utils/variables/cleanVariable.js +22 -0
- package/src/utils/variables/getVariableType.js +1 -0
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/* eslint-disable no-template-curly-in-string */
|
|
2
|
+
const { test } = require('uvu')
|
|
3
|
+
const assert = require('uvu/assert')
|
|
4
|
+
const configorama = require('../../index')
|
|
5
|
+
const {
|
|
6
|
+
getHow,
|
|
7
|
+
serializeRequirements,
|
|
8
|
+
shouldAsk,
|
|
9
|
+
} = require('./serializeRequirements')
|
|
10
|
+
|
|
11
|
+
test('serializeRequirements emits stable top-level contract', async () => {
|
|
12
|
+
delete process.env.CONFIGORAMA_REQUIREMENTS_API_KEY
|
|
13
|
+
const analysis = await configorama.analyze({
|
|
14
|
+
apiKey: '${env:CONFIGORAMA_REQUIREMENTS_API_KEY | help("API key")}',
|
|
15
|
+
stage: '${opt:stage}',
|
|
16
|
+
region: '${env:CONFIGORAMA_REQUIREMENTS_REGION, "us-east-1"}',
|
|
17
|
+
}, { options: {} })
|
|
18
|
+
|
|
19
|
+
const result = serializeRequirements(analysis, { configPathOrObject: null })
|
|
20
|
+
|
|
21
|
+
assert.is(result.schemaVersion, 1)
|
|
22
|
+
assert.is(result.config, null)
|
|
23
|
+
assert.equal(result.summary, {
|
|
24
|
+
total: 3,
|
|
25
|
+
required: 2,
|
|
26
|
+
optional: 1,
|
|
27
|
+
sensitive: 1,
|
|
28
|
+
})
|
|
29
|
+
assert.equal(result.ask.map(item => item.variable), [
|
|
30
|
+
'env:CONFIGORAMA_REQUIREMENTS_API_KEY',
|
|
31
|
+
'opt:stage',
|
|
32
|
+
])
|
|
33
|
+
assert.equal(result.ask.map(item => item.how), [
|
|
34
|
+
'Set environment variable CONFIGORAMA_REQUIREMENTS_API_KEY',
|
|
35
|
+
'Pass --stage on the CLI',
|
|
36
|
+
])
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
test('analyze instructions mode returns requirements JSON', async () => {
|
|
40
|
+
delete process.env.CONFIGORAMA_REQUIREMENTS_TOKEN
|
|
41
|
+
const result = await configorama.analyze({
|
|
42
|
+
token: '${env:CONFIGORAMA_REQUIREMENTS_TOKEN}',
|
|
43
|
+
}, {
|
|
44
|
+
instructions: true,
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
assert.is(result.schemaVersion, 1)
|
|
48
|
+
assert.is(result.summary.total, 1)
|
|
49
|
+
assert.equal(result.ask.map(item => item.variable), ['env:CONFIGORAMA_REQUIREMENTS_TOKEN'])
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
test('ask is environment-aware for env variables', async () => {
|
|
53
|
+
const envName = 'CONFIGORAMA_REQUIREMENTS_PRESENT'
|
|
54
|
+
const previous = process.env[envName]
|
|
55
|
+
process.env[envName] = 'available'
|
|
56
|
+
|
|
57
|
+
try {
|
|
58
|
+
const result = await configorama.analyze({
|
|
59
|
+
present: '${env:CONFIGORAMA_REQUIREMENTS_PRESENT}',
|
|
60
|
+
}, {
|
|
61
|
+
instructions: true,
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
assert.equal(result.ask, [])
|
|
65
|
+
assert.is(result.requirements[0].default, 'available')
|
|
66
|
+
assert.is(result.requirements[0].required, false)
|
|
67
|
+
} finally {
|
|
68
|
+
if (previous === undefined) delete process.env[envName]
|
|
69
|
+
else process.env[envName] = previous
|
|
70
|
+
}
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
test('ask includes concrete missing files and excludes dynamic unresolved files', () => {
|
|
74
|
+
const result = serializeRequirements({
|
|
75
|
+
uniqueVariables: {
|
|
76
|
+
'file(./missing.yml)': {
|
|
77
|
+
variable: 'file(./missing.yml)',
|
|
78
|
+
variableType: 'file',
|
|
79
|
+
variableSourceType: 'config',
|
|
80
|
+
fileExists: false,
|
|
81
|
+
occurrences: [{ path: 'config', isRequired: true }]
|
|
82
|
+
},
|
|
83
|
+
'file(./missing-${opt:stage}.yml)': {
|
|
84
|
+
variable: 'file(./missing-${opt:stage}.yml)',
|
|
85
|
+
variableType: 'file',
|
|
86
|
+
variableSourceType: 'config',
|
|
87
|
+
innerVariables: [{ variable: 'opt:stage' }],
|
|
88
|
+
occurrences: [{ path: 'dynamicConfig', isRequired: true }]
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
assert.equal(result.ask.map(item => item.variable), ['file(./missing.yml)'])
|
|
94
|
+
assert.is(result.ask[0].how, 'Provide file at path ./missing.yml')
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
test('how strings are derived from variableType', () => {
|
|
98
|
+
assert.is(getHow({ variableType: 'env', name: 'TOKEN' }), 'Set environment variable TOKEN')
|
|
99
|
+
assert.is(getHow({ variableType: 'option', name: 'stage' }), 'Pass --stage on the CLI')
|
|
100
|
+
assert.is(getHow({ variableType: 'param', name: 'domain' }), 'Pass --param domain=<value>')
|
|
101
|
+
assert.is(getHow({ variableType: 'file', name: './secrets.yml' }), 'Provide file at path ./secrets.yml')
|
|
102
|
+
assert.is(getHow({ variableType: 'git', name: 'sha' }), null)
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
test('readonly derived sources are excluded from ask', () => {
|
|
106
|
+
assert.is(shouldAsk({
|
|
107
|
+
variableType: 'git',
|
|
108
|
+
required: true,
|
|
109
|
+
default: null,
|
|
110
|
+
sourceClass: 'config',
|
|
111
|
+
}), false)
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
test('serializer hard-errors on model conflicts', () => {
|
|
115
|
+
assert.throws(() => serializeRequirements({
|
|
116
|
+
uniqueVariables: {
|
|
117
|
+
'opt:stage': {
|
|
118
|
+
variable: 'opt:stage',
|
|
119
|
+
variableType: 'options',
|
|
120
|
+
variableSourceType: 'user',
|
|
121
|
+
occurrences: [
|
|
122
|
+
{ path: 'stage', type: 'String', isRequired: true },
|
|
123
|
+
{ path: 'provider.stage', type: 'Number', isRequired: true },
|
|
124
|
+
]
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}), /opt:stage type conflict at stage, provider.stage/)
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
test('serializeRequirements includes annotation fields in requirements and ask', () => {
|
|
131
|
+
const result = serializeRequirements({
|
|
132
|
+
uniqueVariables: {
|
|
133
|
+
'env:STRIPE_SECRET_KEY': {
|
|
134
|
+
variable: 'env:STRIPE_SECRET_KEY',
|
|
135
|
+
variableType: 'env',
|
|
136
|
+
variableSourceType: 'user',
|
|
137
|
+
occurrences: [
|
|
138
|
+
{
|
|
139
|
+
path: 'secrets.stripeSecret',
|
|
140
|
+
description: 'Stripe live secret key',
|
|
141
|
+
descriptionSource: 'commentTag',
|
|
142
|
+
obtainHint: 'Stripe dashboard > Developers > API keys',
|
|
143
|
+
examples: ['sk_live_...'],
|
|
144
|
+
defaultHint: 'Set in CI',
|
|
145
|
+
group: 'Payments',
|
|
146
|
+
deprecationMessage: 'Use STRIPE_RESTRICTED_KEY instead',
|
|
147
|
+
sensitive: true,
|
|
148
|
+
sensitiveSource: 'commentTag',
|
|
149
|
+
isRequired: true,
|
|
150
|
+
}
|
|
151
|
+
]
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
const requirement = result.requirements[0]
|
|
157
|
+
assert.is(requirement.description, 'Stripe live secret key')
|
|
158
|
+
assert.is(requirement.descriptionSource, 'commentTag')
|
|
159
|
+
assert.is(requirement.obtainHint, 'Stripe dashboard > Developers > API keys')
|
|
160
|
+
assert.equal(requirement.examples, ['sk_live_...'])
|
|
161
|
+
assert.is(requirement.defaultHint, 'Set in CI')
|
|
162
|
+
assert.is(requirement.group, 'Payments')
|
|
163
|
+
assert.is(requirement.deprecationMessage, 'Use STRIPE_RESTRICTED_KEY instead')
|
|
164
|
+
assert.is(requirement.sensitive, true)
|
|
165
|
+
assert.is(requirement.sensitiveSource, 'commentTag')
|
|
166
|
+
|
|
167
|
+
assert.equal(result.ask, [
|
|
168
|
+
{
|
|
169
|
+
name: 'STRIPE_SECRET_KEY',
|
|
170
|
+
variable: 'env:STRIPE_SECRET_KEY',
|
|
171
|
+
variableType: 'env',
|
|
172
|
+
type: 'string',
|
|
173
|
+
sensitive: true,
|
|
174
|
+
description: 'Stripe live secret key',
|
|
175
|
+
obtainHint: 'Stripe dashboard > Developers > API keys',
|
|
176
|
+
examples: ['sk_live_...'],
|
|
177
|
+
defaultHint: 'Set in CI',
|
|
178
|
+
group: 'Payments',
|
|
179
|
+
deprecationMessage: 'Use STRIPE_RESTRICTED_KEY instead',
|
|
180
|
+
paths: ['secrets.stripeSecret'],
|
|
181
|
+
how: 'Set environment variable STRIPE_SECRET_KEY',
|
|
182
|
+
}
|
|
183
|
+
])
|
|
184
|
+
assert.is(Object.prototype.hasOwnProperty.call(result.ask[0], 'sensitiveSource'), false)
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
test('serializer hard-errors on annotation conflicts', () => {
|
|
188
|
+
assert.throws(() => serializeRequirements({
|
|
189
|
+
uniqueVariables: {
|
|
190
|
+
'env:PAYMENT_TOKEN': {
|
|
191
|
+
variable: 'env:PAYMENT_TOKEN',
|
|
192
|
+
variableType: 'env',
|
|
193
|
+
variableSourceType: 'user',
|
|
194
|
+
occurrences: [
|
|
195
|
+
{
|
|
196
|
+
path: 'stripe.token',
|
|
197
|
+
obtainHint: 'Stripe dashboard',
|
|
198
|
+
isRequired: true,
|
|
199
|
+
},
|
|
200
|
+
{
|
|
201
|
+
path: 'github.token',
|
|
202
|
+
obtainHint: 'GitHub settings',
|
|
203
|
+
isRequired: true,
|
|
204
|
+
},
|
|
205
|
+
]
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}), /env:PAYMENT_TOKEN obtainHint conflict at stripe.token, github.token: "Stripe dashboard", "GitHub settings"/)
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
test.run()
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// Blocks prototype-chain escapes in ${eval(...)} / ${if(...)} expressions.
|
|
2
|
+
// Literals like "" or [] expose .constructor, which reaches the Function
|
|
3
|
+
// constructor and arbitrary code execution. We reject access to those property
|
|
4
|
+
// names (statically or via dynamic computed keys) before any evaluation runs.
|
|
5
|
+
|
|
6
|
+
// Property names that walk the prototype chain toward the Function constructor.
|
|
7
|
+
const FORBIDDEN_KEYS = new Set([
|
|
8
|
+
'constructor',
|
|
9
|
+
'prototype',
|
|
10
|
+
'__proto__',
|
|
11
|
+
'__defineGetter__',
|
|
12
|
+
'__defineSetter__',
|
|
13
|
+
'__lookupGetter__',
|
|
14
|
+
'__lookupSetter__',
|
|
15
|
+
])
|
|
16
|
+
|
|
17
|
+
// Fast pre-filter on the raw source. Catches the dotted / literal-bracket forms
|
|
18
|
+
// directly; the AST walk below also catches concatenated keys it cannot see.
|
|
19
|
+
const SOURCE_ESCAPE_PATTERN = /\b(?:constructor|prototype|__proto__|__define[GS]etter__|__lookup[GS]etter__)\b/
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Walk a subscript/justin AST and return the first disallowed access found.
|
|
23
|
+
* Node shapes: [null, literal] | ['.', obj, propName] | ['[]', obj, keyNode] |
|
|
24
|
+
* [op, ...children]; bare identifiers are plain strings.
|
|
25
|
+
* @param {*} node - a subscript AST node
|
|
26
|
+
* @returns {string|null} the offending key (or '<computed-key>'), else null
|
|
27
|
+
*/
|
|
28
|
+
function findForbiddenAccess(node) {
|
|
29
|
+
if (!Array.isArray(node)) return null
|
|
30
|
+
const op = node[0]
|
|
31
|
+
// Literal nodes are [<hole>, value]; the hole reads back as undefined.
|
|
32
|
+
if (op == null) return null
|
|
33
|
+
|
|
34
|
+
// Static and optional member access: ['.', obj, name] / ['?.', obj, name]
|
|
35
|
+
if (op === '.' || op === '?.') {
|
|
36
|
+
const prop = node[2]
|
|
37
|
+
if (typeof prop === 'string' && FORBIDDEN_KEYS.has(prop)) return prop
|
|
38
|
+
return findForbiddenAccess(node[1])
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (op === '[]') {
|
|
42
|
+
const key = node[2]
|
|
43
|
+
if (Array.isArray(key) && key[0] == null) {
|
|
44
|
+
const value = key[1]
|
|
45
|
+
if (typeof value === 'string' && FORBIDDEN_KEYS.has(value)) return value
|
|
46
|
+
} else {
|
|
47
|
+
// Non-literal key (e.g. "con" + "structor"); cannot be verified statically.
|
|
48
|
+
return '<computed-key>'
|
|
49
|
+
}
|
|
50
|
+
return findForbiddenAccess(node[1])
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
for (let i = 1; i < node.length; i++) {
|
|
54
|
+
const found = findForbiddenAccess(node[i])
|
|
55
|
+
if (found) return found
|
|
56
|
+
}
|
|
57
|
+
return null
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Throw if an eval expression attempts a prototype-chain escape.
|
|
62
|
+
* @param {string} expression - the raw expression inside eval(...)
|
|
63
|
+
* @param {*} [ast] - the parsed subscript AST, when available
|
|
64
|
+
*/
|
|
65
|
+
function assertSafeEvalExpression(expression, ast) {
|
|
66
|
+
if (ast) {
|
|
67
|
+
// Precise: walk the parsed AST so literal strings like "constructor" used as
|
|
68
|
+
// data are allowed, while actual member access to them is blocked.
|
|
69
|
+
const bad = findForbiddenAccess(ast)
|
|
70
|
+
if (bad) {
|
|
71
|
+
throw new Error(`Blocked eval expression "${expression}": disallowed member access (${bad}).`)
|
|
72
|
+
}
|
|
73
|
+
return
|
|
74
|
+
}
|
|
75
|
+
// Fallback when the expression could not be parsed: blunt source scan.
|
|
76
|
+
if (SOURCE_ESCAPE_PATTERN.test(String(expression))) {
|
|
77
|
+
throw new Error(`Blocked eval expression "${expression}": access to constructor/prototype is not allowed.`)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
module.exports = {
|
|
82
|
+
FORBIDDEN_KEYS,
|
|
83
|
+
SOURCE_ESCAPE_PATTERN,
|
|
84
|
+
findForbiddenAccess,
|
|
85
|
+
assertSafeEvalExpression,
|
|
86
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
const { test } = require('uvu')
|
|
2
|
+
const assert = require('uvu/assert')
|
|
3
|
+
const { findForbiddenAccess, assertSafeEvalExpression } = require('./evalSafety')
|
|
4
|
+
|
|
5
|
+
let parse
|
|
6
|
+
test.before(async () => {
|
|
7
|
+
await import('subscript/justin') // registers operators on the shared parser
|
|
8
|
+
const mod = await import('subscript/parse')
|
|
9
|
+
parse = mod.default
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
function check(expression) {
|
|
13
|
+
let ast = null
|
|
14
|
+
try { ast = parse(expression) } catch (e) { ast = null }
|
|
15
|
+
let caught
|
|
16
|
+
try { assertSafeEvalExpression(expression, ast) } catch (e) { caught = e }
|
|
17
|
+
return caught
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
test('allows arithmetic and comparison expressions', () => {
|
|
21
|
+
assert.is(check('1 + 1'), undefined)
|
|
22
|
+
assert.is(check('"yes" === "yes"'), undefined)
|
|
23
|
+
assert.is(check('true ? 1 : 2'), undefined)
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
test('allows safe static member access on context values', () => {
|
|
27
|
+
assert.is(check('provider.stage === "prod"'), undefined)
|
|
28
|
+
assert.is(check('custom.nullValue === null'), undefined)
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
test('allows literal-key index access', () => {
|
|
32
|
+
assert.is(check('a[0]'), undefined)
|
|
33
|
+
assert.is(check('obj["key"]'), undefined)
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
test('blocks static constructor access', () => {
|
|
37
|
+
assert.ok(check('"".constructor.constructor("return 1")()'))
|
|
38
|
+
assert.ok(check('[].constructor'))
|
|
39
|
+
assert.ok(check('x.__proto__'))
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
test('blocks optional-chaining constructor access', () => {
|
|
43
|
+
assert.ok(check('a?.constructor'))
|
|
44
|
+
assert.ok(check('a?.["constructor"]'))
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
test('blocks literal-bracket constructor access', () => {
|
|
48
|
+
assert.ok(check('(0)["constructor"]["constructor"]("return 1")()'))
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
test('blocks concatenated computed-key escape', () => {
|
|
52
|
+
assert.ok(check('(0)["con" + "structor"]'))
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
test('findForbiddenAccess flags a hand-built dynamic key', () => {
|
|
56
|
+
// ['[]', 'obj', ['+', [null,'con'], [null,'structor']]]
|
|
57
|
+
const ast = ['[]', 'obj', ['+', [null, 'con'], [null, 'structor']]]
|
|
58
|
+
assert.is(findForbiddenAccess(ast), '<computed-key>')
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
test.run()
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
const fs = require('fs')
|
|
2
|
+
const path = require('path')
|
|
3
|
+
const { ConfigoramaError } = require('../../errors')
|
|
4
|
+
|
|
5
|
+
const EXECUTABLE_EXTENSIONS = new Set(['.js', '.cjs', '.mjs', '.esm', '.ts', '.tsx', '.mts', '.cts'])
|
|
6
|
+
|
|
7
|
+
function asArray(value) {
|
|
8
|
+
if (value === undefined || value === null || value === false) return []
|
|
9
|
+
return Array.isArray(value) ? value : [value]
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function resolveRoot(root, baseDir) {
|
|
13
|
+
if (!root) return null
|
|
14
|
+
return path.resolve(baseDir || process.cwd(), String(root))
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function normalizeSafetyPolicy(settings = {}, context = {}) {
|
|
18
|
+
const configDir = context.configDir || settings.configDir || process.cwd()
|
|
19
|
+
const safeMode = settings.safeMode === true || settings.safe === true
|
|
20
|
+
const restrictFileRoots = settings.restrictFileRoots === true || safeMode
|
|
21
|
+
const configuredRoots = asArray(settings.allowedFileRoots || settings.fileRoots || settings.safeRoots)
|
|
22
|
+
const roots = configuredRoots.length
|
|
23
|
+
? configuredRoots.map(root => resolveRoot(root, configDir)).filter(Boolean)
|
|
24
|
+
: (restrictFileRoots ? [path.resolve(configDir)] : [])
|
|
25
|
+
|
|
26
|
+
return {
|
|
27
|
+
safeMode,
|
|
28
|
+
blockExecutableFiles: settings.blockExecutableFiles !== false && safeMode,
|
|
29
|
+
blockCustomResolvers: settings.blockCustomResolvers !== false && safeMode,
|
|
30
|
+
blockCustomFunctions: settings.blockCustomFunctions !== false && safeMode,
|
|
31
|
+
blockDotEnv: settings.blockDotEnv !== false && safeMode,
|
|
32
|
+
restrictFileRoots,
|
|
33
|
+
allowedFileRoots: roots,
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function realPathIfExists(value) {
|
|
38
|
+
try {
|
|
39
|
+
return fs.realpathSync(value)
|
|
40
|
+
} catch (error) {
|
|
41
|
+
return path.resolve(value)
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function isPathInsideRoot(filePath, rootPath) {
|
|
46
|
+
const fileReal = realPathIfExists(filePath)
|
|
47
|
+
const rootReal = realPathIfExists(rootPath)
|
|
48
|
+
const relative = path.relative(rootReal, fileReal)
|
|
49
|
+
return relative === '' || (!!relative && !relative.startsWith('..') && !path.isAbsolute(relative))
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function checkFileAccess(filePath, policy, context = {}) {
|
|
53
|
+
const ext = path.extname(filePath).toLowerCase()
|
|
54
|
+
if (policy.blockExecutableFiles && EXECUTABLE_EXTENSIONS.has(ext)) {
|
|
55
|
+
throw new ConfigoramaError('blocked_by_safe_mode', `Blocked executable config reference in safe mode: ${filePath}`, {
|
|
56
|
+
surface: 'executable_file',
|
|
57
|
+
filePath,
|
|
58
|
+
variable: context.variableString,
|
|
59
|
+
})
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (policy.restrictFileRoots && policy.allowedFileRoots.length) {
|
|
63
|
+
const allowed = policy.allowedFileRoots.some(root => isPathInsideRoot(filePath, root))
|
|
64
|
+
if (!allowed) {
|
|
65
|
+
throw new ConfigoramaError('file_root_forbidden', `File reference is outside allowed roots: ${filePath}`, {
|
|
66
|
+
filePath,
|
|
67
|
+
allowedRoots: policy.allowedFileRoots,
|
|
68
|
+
variable: context.variableString,
|
|
69
|
+
})
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function assertSafeConfigInput(filePath, policy) {
|
|
75
|
+
if (!filePath || !policy.safeMode) return
|
|
76
|
+
const ext = path.extname(filePath).toLowerCase()
|
|
77
|
+
if (EXECUTABLE_EXTENSIONS.has(ext)) {
|
|
78
|
+
throw new ConfigoramaError('blocked_by_safe_mode', `Blocked executable config file in safe mode: ${filePath}`, {
|
|
79
|
+
surface: 'executable_config',
|
|
80
|
+
filePath,
|
|
81
|
+
})
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function assertCustomResolversAllowed(variableSources, policy) {
|
|
86
|
+
if (policy.blockCustomResolvers && Array.isArray(variableSources) && variableSources.length > 0) {
|
|
87
|
+
throw new ConfigoramaError('blocked_by_safe_mode', 'Custom variable resolvers are blocked in safe mode', {
|
|
88
|
+
surface: 'custom_resolver',
|
|
89
|
+
count: variableSources.length,
|
|
90
|
+
})
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function assertCustomFunctionsAllowed(functions, policy) {
|
|
95
|
+
if (policy.blockCustomFunctions && functions && Object.keys(functions).length > 0) {
|
|
96
|
+
throw new ConfigoramaError('blocked_by_safe_mode', 'Custom functions are blocked in safe mode', {
|
|
97
|
+
surface: 'custom_function',
|
|
98
|
+
names: Object.keys(functions).sort(),
|
|
99
|
+
})
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
module.exports = {
|
|
104
|
+
EXECUTABLE_EXTENSIONS,
|
|
105
|
+
assertCustomFunctionsAllowed,
|
|
106
|
+
assertCustomResolversAllowed,
|
|
107
|
+
assertSafeConfigInput,
|
|
108
|
+
checkFileAccess,
|
|
109
|
+
normalizeSafetyPolicy,
|
|
110
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
const { test } = require('uvu')
|
|
2
|
+
const assert = require('uvu/assert')
|
|
3
|
+
const path = require('path')
|
|
4
|
+
const {
|
|
5
|
+
checkFileAccess,
|
|
6
|
+
normalizeSafetyPolicy,
|
|
7
|
+
} = require('./safetyPolicy')
|
|
8
|
+
|
|
9
|
+
test('safe mode blocks executable file references', () => {
|
|
10
|
+
const policy = normalizeSafetyPolicy({ safeMode: true }, { configDir: __dirname })
|
|
11
|
+
|
|
12
|
+
assert.throws(() => {
|
|
13
|
+
checkFileAccess(path.join(__dirname, 'config.js'), policy, { variableString: 'file(./config.js)' })
|
|
14
|
+
}, /Blocked executable config reference/)
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
test('safe mode allows data file references inside config root', () => {
|
|
18
|
+
const policy = normalizeSafetyPolicy({ safeMode: true }, { configDir: __dirname })
|
|
19
|
+
checkFileAccess(path.join(__dirname, 'fixture.yml'), policy, { variableString: 'file(./fixture.yml)' })
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
test('safe mode blocks traversal outside allowed roots', () => {
|
|
23
|
+
const policy = normalizeSafetyPolicy({ safeMode: true }, { configDir: __dirname })
|
|
24
|
+
assert.throws(() => {
|
|
25
|
+
checkFileAccess(path.resolve(__dirname, '../../../package.json'), policy, { variableString: 'file(../../../package.json)' })
|
|
26
|
+
}, /outside allowed roots/)
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
test.run()
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// Suggests the closest known string to a possibly-misspelled input.
|
|
2
|
+
// Used for command and flag "did you mean ...?" hints in the CLI.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Optimal string alignment (Damerau) edit distance between two strings.
|
|
6
|
+
* Counts an adjacent transposition (e.g. "fromat" -> "format") as one edit,
|
|
7
|
+
* which keeps real single typos close while keeping unrelated words far apart.
|
|
8
|
+
* @param {string} a
|
|
9
|
+
* @param {string} b
|
|
10
|
+
* @returns {number} minimum edits (insert/delete/substitute/transpose)
|
|
11
|
+
*/
|
|
12
|
+
function editDistance(a, b) {
|
|
13
|
+
a = String(a)
|
|
14
|
+
b = String(b)
|
|
15
|
+
if (a === b) return 0
|
|
16
|
+
const m = a.length
|
|
17
|
+
const n = b.length
|
|
18
|
+
if (!m) return n
|
|
19
|
+
if (!n) return m
|
|
20
|
+
|
|
21
|
+
const d = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0))
|
|
22
|
+
for (let i = 0; i <= m; i++) d[i][0] = i
|
|
23
|
+
for (let j = 0; j <= n; j++) d[0][j] = j
|
|
24
|
+
|
|
25
|
+
for (let i = 1; i <= m; i++) {
|
|
26
|
+
for (let j = 1; j <= n; j++) {
|
|
27
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1
|
|
28
|
+
d[i][j] = Math.min(
|
|
29
|
+
d[i - 1][j] + 1, // deletion
|
|
30
|
+
d[i][j - 1] + 1, // insertion
|
|
31
|
+
d[i - 1][j - 1] + cost // substitution
|
|
32
|
+
)
|
|
33
|
+
if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) {
|
|
34
|
+
d[i][j] = Math.min(d[i][j], d[i - 2][j - 2] + 1) // transposition
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return d[m][n]
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Returns the candidate closest to input within an edit-distance threshold.
|
|
44
|
+
* Defaults to threshold 1 so only near-certain typos are suggested; this avoids
|
|
45
|
+
* hijacking legitimate passthrough options (e.g. `--stage`) that happen to sit a
|
|
46
|
+
* couple of edits away from a known flag.
|
|
47
|
+
* @param {string} input - the user-provided (possibly misspelled) token
|
|
48
|
+
* @param {string[]} candidates - known valid strings
|
|
49
|
+
* @param {{ threshold?: number }} [options]
|
|
50
|
+
* @returns {string|null} closest candidate, or null if none is close enough
|
|
51
|
+
*/
|
|
52
|
+
function didYouMean(input, candidates, options = {}) {
|
|
53
|
+
const threshold = options.threshold === undefined ? 1 : options.threshold
|
|
54
|
+
const value = String(input)
|
|
55
|
+
let best = null
|
|
56
|
+
let bestDist = Infinity
|
|
57
|
+
|
|
58
|
+
for (const candidate of candidates) {
|
|
59
|
+
const dist = editDistance(value, candidate)
|
|
60
|
+
if (dist < bestDist) {
|
|
61
|
+
bestDist = dist
|
|
62
|
+
best = candidate
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (best === null || bestDist > threshold) return null
|
|
67
|
+
return best
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
module.exports = { editDistance, didYouMean }
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
const { test } = require('uvu')
|
|
2
|
+
const assert = require('uvu/assert')
|
|
3
|
+
const { editDistance, didYouMean } = require('./didYouMean')
|
|
4
|
+
|
|
5
|
+
const COMMANDS = ['requirements', 'audit', 'graph', 'inspect', 'setup', 'capabilities']
|
|
6
|
+
|
|
7
|
+
test('editDistance - identical strings are distance 0', () => {
|
|
8
|
+
assert.is(editDistance('graph', 'graph'), 0)
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
test('editDistance - single edit is distance 1', () => {
|
|
12
|
+
assert.is(editDistance('grph', 'graph'), 1)
|
|
13
|
+
assert.is(editDistance('audi', 'audit'), 1)
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
test('editDistance - adjacent transposition counts as one edit', () => {
|
|
17
|
+
assert.is(editDistance('fromat', 'format'), 1)
|
|
18
|
+
assert.is(editDistance('setpu', 'setup'), 1)
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
test('didYouMean - suggests closest command for a near typo', () => {
|
|
22
|
+
assert.is(didYouMean('inspekt', COMMANDS), 'inspect')
|
|
23
|
+
assert.is(didYouMean('audi', COMMANDS), 'audit')
|
|
24
|
+
assert.is(didYouMean('grph', COMMANDS), 'graph')
|
|
25
|
+
assert.is(didYouMean('capabilites', COMMANDS), 'capabilities')
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
test('didYouMean - returns null when nothing is close enough', () => {
|
|
29
|
+
assert.is(didYouMean('myconfig', COMMANDS), null)
|
|
30
|
+
assert.is(didYouMean('config.yml', COMMANDS), null)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
test('didYouMean - exact match returns the candidate itself', () => {
|
|
34
|
+
assert.is(didYouMean('audit', COMMANDS), 'audit')
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
test('didYouMean - respects a custom threshold', () => {
|
|
38
|
+
assert.is(didYouMean('frmt', ['format'], { threshold: 2 }), 'format')
|
|
39
|
+
assert.is(didYouMean('frmt', ['format'], { threshold: 1 }), null)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
test('didYouMean - flag typos resolve, passthrough options do not', () => {
|
|
43
|
+
const FLAGS = ['format', 'view', 'output', 'safe', 'help']
|
|
44
|
+
assert.is(didYouMean('fromat', FLAGS), 'format')
|
|
45
|
+
assert.is(didYouMean('veiw', FLAGS), 'view')
|
|
46
|
+
// common Serverless-style passthrough options must NOT be hijacked
|
|
47
|
+
assert.is(didYouMean('stage', FLAGS), null)
|
|
48
|
+
assert.is(didYouMean('domain', FLAGS), null)
|
|
49
|
+
assert.is(didYouMean('region', FLAGS), null)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
test.run()
|
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
const { trim } = require('../lodash')
|
|
2
2
|
const { trimSurroundingQuotes } = require('./quoteUtils')
|
|
3
|
+
const { decodeFilterArg, isEncodedFilterArg } = require('../filters/filterArgs')
|
|
3
4
|
|
|
4
5
|
function formatArg(arg) {
|
|
5
|
-
const
|
|
6
|
+
const trimmed = trim(arg)
|
|
7
|
+
if (isEncodedFilterArg(trimmed)) {
|
|
8
|
+
return decodeFilterArg(trimmed)
|
|
9
|
+
}
|
|
10
|
+
const cleanArg = trimSurroundingQuotes(trimmed, false)
|
|
6
11
|
if (cleanArg.match(/^{([^}]+)}$/)) {
|
|
7
12
|
return JSON.parse(cleanArg)
|
|
8
13
|
}
|
|
@@ -21,6 +21,11 @@ function splitByComma(string, regexPattern) {
|
|
|
21
21
|
return [""]
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
// No comma anywhere means no split, regardless of quotes/brackets/placeholders.
|
|
25
|
+
if (string.indexOf(",") === -1) {
|
|
26
|
+
return [string.trim()]
|
|
27
|
+
}
|
|
28
|
+
|
|
24
29
|
// Extract regex patterns to protect them
|
|
25
30
|
const placeholders = []
|
|
26
31
|
let protectedString = string
|