configorama 0.11.0 → 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 +159 -19
- package/src/parsers/esm.js +1 -16
- package/src/parsers/typescript.js +1 -48
- package/src/resolvers/valueFromCron.js +4 -25
- package/src/resolvers/valueFromEval.js +11 -1
- package/src/resolvers/valueFromFile.js +8 -1
- 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 +22 -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
- package/types/src/index.d.ts +0 -24
- package/types/src/index.d.ts.map +1 -1
- package/types/src/main.d.ts +16 -8
- package/types/src/main.d.ts.map +1 -1
- package/types/src/resolvers/valueFromFile.d.ts +0 -2
- package/types/src/resolvers/valueFromFile.d.ts.map +1 -1
- package/types/src/resolvers/valueFromGit.d.ts.map +1 -1
- package/types/src/resolvers/valueFromSelf.d.ts +1 -0
- package/types/src/resolvers/valueFromSelf.d.ts.map +1 -0
- package/types/src/utils/parsing/parse.d.ts.map +1 -1
- package/types/src/utils/parsing/preProcess.d.ts.map +1 -1
- package/types/src/utils/paths/findLineForKey.d.ts +0 -9
- package/types/src/utils/paths/findLineForKey.d.ts.map +1 -1
- package/types/src/utils/strings/replaceAll.d.ts.map +1 -1
- package/types/src/utils/variables/variableUtils.d.ts +1 -1
- package/types/src/display.d.ts +0 -62
- package/types/src/display.d.ts.map +0 -1
- package/types/src/metadata.d.ts +0 -28
- package/types/src/metadata.d.ts.map +0 -1
- package/types/src/utils/BoundedMap.d.ts +0 -10
- package/types/src/utils/BoundedMap.d.ts.map +0 -1
- package/types/src/utils/paths/ignorePaths.d.ts +0 -5
- package/types/src/utils/paths/ignorePaths.d.ts.map +0 -1
|
@@ -114,6 +114,32 @@ const GIT_KEYS = {
|
|
|
114
114
|
}
|
|
115
115
|
|
|
116
116
|
function createResolver(cwd) {
|
|
117
|
+
let gitRepo
|
|
118
|
+
const gitResultCache = new Map()
|
|
119
|
+
|
|
120
|
+
function isCurrentGitRepo() {
|
|
121
|
+
if (typeof gitRepo === 'undefined') {
|
|
122
|
+
gitRepo = isGitRepo(cwd)
|
|
123
|
+
}
|
|
124
|
+
return gitRepo
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function cachedSafeGit(key, cmdFn) {
|
|
128
|
+
if (!gitResultCache.has(key)) {
|
|
129
|
+
gitResultCache.set(key, _safeGit(cmdFn))
|
|
130
|
+
}
|
|
131
|
+
return gitResultCache.get(key)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function gitExec(args) {
|
|
135
|
+
const key = `git:${JSON.stringify(args)}`
|
|
136
|
+
return cachedSafeGit(key, () => _execFile('git', args))
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function gitRemote(name = 'origin') {
|
|
140
|
+
return cachedSafeGit(`remote:${name}`, () => getGitRemote(name))
|
|
141
|
+
}
|
|
142
|
+
|
|
117
143
|
async function _getValueFromGit(variableString) {
|
|
118
144
|
const variable = variableString.split(`${GIT_PREFIX}:`)[1]
|
|
119
145
|
let value = null
|
|
@@ -123,14 +149,14 @@ function createResolver(cwd) {
|
|
|
123
149
|
// undefined. This lets fallbacks like `${git:branch, "main"}` work, and
|
|
124
150
|
// when there's no fallback the outer resolver throws a clear "Unable to
|
|
125
151
|
// resolve config variable" error pointing at the config path.
|
|
126
|
-
if (!
|
|
152
|
+
if (!isCurrentGitRepo()) {
|
|
127
153
|
return undefined
|
|
128
154
|
}
|
|
129
155
|
|
|
130
156
|
if (variable.match(/^remote/i)) {
|
|
131
157
|
const hasParams = functionRegex.exec(variableString)
|
|
132
158
|
const remoteName = (hasParams && hasParams[2]) ? formatFunctionArgs(hasParams[2]) : 'origin'
|
|
133
|
-
return
|
|
159
|
+
return gitRemote(remoteName)
|
|
134
160
|
}
|
|
135
161
|
|
|
136
162
|
const normalizedVar = (variable || '').toLowerCase()
|
|
@@ -152,7 +178,7 @@ function createResolver(cwd) {
|
|
|
152
178
|
case 'repository':
|
|
153
179
|
case 'reposlug':
|
|
154
180
|
case 'repo-slug': {
|
|
155
|
-
const urla = await
|
|
181
|
+
const urla = await gitRemote()
|
|
156
182
|
if (!urla) return undefined
|
|
157
183
|
const parseda = GitUrlParse(urla)
|
|
158
184
|
value = parseda.full_name
|
|
@@ -162,7 +188,7 @@ function createResolver(cwd) {
|
|
|
162
188
|
case GIT_KEYS.name:
|
|
163
189
|
case 'reponame': // repoName
|
|
164
190
|
case 'repo-name': {
|
|
165
|
-
const toplevel = await
|
|
191
|
+
const toplevel = await gitExec(['rev-parse', '--show-toplevel'])
|
|
166
192
|
if (!toplevel) return undefined
|
|
167
193
|
value = path.basename(toplevel)
|
|
168
194
|
break
|
|
@@ -173,7 +199,7 @@ function createResolver(cwd) {
|
|
|
173
199
|
case 'organization':
|
|
174
200
|
case 'repoowner': // repoOwner
|
|
175
201
|
case 'repo-owner': {
|
|
176
|
-
const url = await
|
|
202
|
+
const url = await gitRemote()
|
|
177
203
|
if (!url) return undefined
|
|
178
204
|
const parsed = GitUrlParse(url)
|
|
179
205
|
value = parsed.organization || parsed.owner
|
|
@@ -185,12 +211,12 @@ function createResolver(cwd) {
|
|
|
185
211
|
case 'dirpath': // dirPath
|
|
186
212
|
case 'dir-path':
|
|
187
213
|
case 'dir_path': {
|
|
188
|
-
const gitBasePath = await
|
|
214
|
+
const gitBasePath = await gitExec(['rev-parse', '--show-toplevel'])
|
|
189
215
|
if (!gitBasePath) return undefined
|
|
190
216
|
if (cwd) {
|
|
191
217
|
const subPath = cwd.replace(gitBasePath, '')
|
|
192
|
-
const branch = await
|
|
193
|
-
const url = await
|
|
218
|
+
const branch = await gitExec(['rev-parse', '--abbrev-ref', 'HEAD'])
|
|
219
|
+
const url = await gitRemote()
|
|
194
220
|
if (!url) return undefined
|
|
195
221
|
value = (subPath && branch) ? `${url}/tree/${branch}${subPath}` : url
|
|
196
222
|
}
|
|
@@ -200,12 +226,12 @@ function createResolver(cwd) {
|
|
|
200
226
|
case GIT_KEYS.url:
|
|
201
227
|
case 'repourl': // repoUrl
|
|
202
228
|
case 'repo-url':
|
|
203
|
-
value = await
|
|
229
|
+
value = await gitRemote()
|
|
204
230
|
break
|
|
205
231
|
// Current commit sha
|
|
206
232
|
case 'sha':
|
|
207
233
|
case 'sha1':
|
|
208
|
-
value = await
|
|
234
|
+
value = await gitExec(['rev-parse', '--short', 'HEAD'])
|
|
209
235
|
break
|
|
210
236
|
// Current commit full sha
|
|
211
237
|
case GIT_KEYS.commit:
|
|
@@ -213,7 +239,7 @@ function createResolver(cwd) {
|
|
|
213
239
|
case 'commit-sha':
|
|
214
240
|
case 'commithash':
|
|
215
241
|
case 'commit-hash':
|
|
216
|
-
value = await
|
|
242
|
+
value = await gitExec(['rev-parse', 'HEAD'])
|
|
217
243
|
break
|
|
218
244
|
// Branches
|
|
219
245
|
case GIT_KEYS.branch:
|
|
@@ -221,7 +247,7 @@ function createResolver(cwd) {
|
|
|
221
247
|
case 'branch-name':
|
|
222
248
|
case 'currentbranch': // currentBranch
|
|
223
249
|
case 'current-branch':
|
|
224
|
-
value = await
|
|
250
|
+
value = await gitExec(['rev-parse', '--abbrev-ref', 'HEAD'])
|
|
225
251
|
break
|
|
226
252
|
// Commit msg
|
|
227
253
|
case GIT_KEYS.message:
|
|
@@ -230,26 +256,26 @@ function createResolver(cwd) {
|
|
|
230
256
|
case 'commit-message':
|
|
231
257
|
case 'commitmsg': // commitMsg
|
|
232
258
|
case 'commit-msg':
|
|
233
|
-
value = await
|
|
259
|
+
value = await gitExec(['log', '-1', '--pretty=%B'])
|
|
234
260
|
break
|
|
235
261
|
// Git tags
|
|
236
262
|
case GIT_KEYS.tag:
|
|
237
263
|
case 'describe':
|
|
238
|
-
value = await
|
|
264
|
+
value = await gitExec(['describe', '--always'])
|
|
239
265
|
break
|
|
240
266
|
// Git tags
|
|
241
267
|
case 'describeLight':
|
|
242
268
|
case 'describelight':
|
|
243
269
|
case 'describe-light':
|
|
244
|
-
value = await
|
|
270
|
+
value = await gitExec(['describe', '--always', '--tags'])
|
|
245
271
|
break
|
|
246
272
|
// Is branch dirty
|
|
247
273
|
case 'isDirty':
|
|
248
274
|
case 'isdirty':
|
|
249
275
|
case 'is-dirty': {
|
|
250
|
-
const writeTree = await
|
|
276
|
+
const writeTree = await gitExec(['write-tree'])
|
|
251
277
|
if (!writeTree) return undefined
|
|
252
|
-
const changes = await
|
|
278
|
+
const changes = await gitExec(['diff-index', writeTree.trim(), '--'])
|
|
253
279
|
if (changes === undefined) return undefined
|
|
254
280
|
value = `${changes.length > 0}`
|
|
255
281
|
break
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Resolves values from CLI option flags
|
|
2
|
-
// Matches ${opt:FLAG_NAME} syntax with optional fallback values
|
|
3
|
-
const optRefSyntax = RegExp(/^opt:/g)
|
|
2
|
+
// Matches ${opt:FLAG_NAME} and ${option:FLAG_NAME} syntax with optional fallback values
|
|
3
|
+
const optRefSyntax = RegExp(/^(?:opt|option):/g)
|
|
4
4
|
|
|
5
5
|
function getValueFromOptions(variableString, options) {
|
|
6
6
|
const requestedOption = variableString.split(':')[1]
|
|
@@ -12,8 +12,9 @@ module.exports = {
|
|
|
12
12
|
type: 'options',
|
|
13
13
|
source: 'user',
|
|
14
14
|
prefix: 'opt',
|
|
15
|
-
|
|
16
|
-
|
|
15
|
+
prefixes: ['opt', 'option'],
|
|
16
|
+
syntax: '${option:flagName}',
|
|
17
|
+
description: 'Resolves CLI option flags. Examples: ${option:stage}, ${opt:stage}, ${option:other, "fallbackValue"}',
|
|
17
18
|
match: optRefSyntax,
|
|
18
19
|
resolver: getValueFromOptions
|
|
19
20
|
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
const MARKER = '__CONFIGORAMA_FILTER_ARG__'
|
|
2
|
+
|
|
3
|
+
class ResolvedFilterArg {
|
|
4
|
+
constructor(value) {
|
|
5
|
+
this.value = value
|
|
6
|
+
this.__resolvedFilterArg = true
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
toString() {
|
|
10
|
+
return String(this.value)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
valueOf() {
|
|
14
|
+
return this.value
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function encodeBase64Url(value) {
|
|
19
|
+
return Buffer.from(value).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function decodeBase64Url(value) {
|
|
23
|
+
let base64 = value.replace(/-/g, '+').replace(/_/g, '/')
|
|
24
|
+
while (base64.length % 4) base64 += '='
|
|
25
|
+
return Buffer.from(base64, 'base64').toString('utf8')
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function encodeFilterArg(value) {
|
|
29
|
+
return `${MARKER}:${encodeBase64Url(JSON.stringify(value))}`
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function isEncodedFilterArg(value) {
|
|
33
|
+
return typeof value === 'string' && value.startsWith(`${MARKER}:`)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function decodeFilterArg(value) {
|
|
37
|
+
if (!isEncodedFilterArg(value)) return value
|
|
38
|
+
const encoded = value.slice(MARKER.length + 1)
|
|
39
|
+
return new ResolvedFilterArg(JSON.parse(decodeBase64Url(encoded)))
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function isResolvedFilterArg(value) {
|
|
43
|
+
return Boolean(value && value.__resolvedFilterArg)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function unwrapFilterArg(value) {
|
|
47
|
+
return isResolvedFilterArg(value) ? value.value : value
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
module.exports = {
|
|
51
|
+
ResolvedFilterArg,
|
|
52
|
+
decodeFilterArg,
|
|
53
|
+
encodeFilterArg,
|
|
54
|
+
isEncodedFilterArg,
|
|
55
|
+
isResolvedFilterArg,
|
|
56
|
+
unwrapFilterArg,
|
|
57
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
const { splitCsv } = require('../strings/splitCsv')
|
|
2
|
+
const { trimSurroundingQuotes } = require('../strings/quoteUtils')
|
|
3
|
+
const { isResolvedFilterArg, unwrapFilterArg } = require('./filterArgs')
|
|
4
|
+
|
|
5
|
+
function isRuntimeContextArg(value) {
|
|
6
|
+
return typeof value === 'string' && value.startsWith('from ')
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function stripRuntimeContext(args) {
|
|
10
|
+
if (!args.length) return args
|
|
11
|
+
const last = args[args.length - 1]
|
|
12
|
+
return isRuntimeContextArg(last) ? args.slice(0, -1) : args
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function hasDynamicArgument(value) {
|
|
16
|
+
return typeof value === 'string' && value.includes('${')
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function parseOneOfLiteral(rawValue) {
|
|
20
|
+
const trimmed = String(rawValue).trim()
|
|
21
|
+
const unquoted = trimSurroundingQuotes(trimmed, false)
|
|
22
|
+
if (unquoted !== trimmed) return unquoted
|
|
23
|
+
if (/^-?(?:\d+|\d*\.\d+)(?:e[+-]?\d+)?$/i.test(trimmed)) return Number(trimmed)
|
|
24
|
+
return trimmed
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function parseOneOfFilter(filter) {
|
|
28
|
+
if (typeof filter !== 'string') return null
|
|
29
|
+
const match = filter.match(/^oneOf\(([\s\S]*)\)$/)
|
|
30
|
+
if (!match) return null
|
|
31
|
+
|
|
32
|
+
const args = splitCsv(match[1], ',', { protectVariables: true })
|
|
33
|
+
.filter(arg => arg !== '')
|
|
34
|
+
|
|
35
|
+
if (args.some(hasDynamicArgument)) {
|
|
36
|
+
return {
|
|
37
|
+
dynamic: true,
|
|
38
|
+
allowedValues: null,
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
dynamic: false,
|
|
44
|
+
allowedValues: args.map(arg => String(parseOneOfLiteral(arg))),
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function validateOneOf(value, ...rawArgs) {
|
|
49
|
+
const args = stripRuntimeContext(rawArgs)
|
|
50
|
+
if (!args.length) {
|
|
51
|
+
throw new Error('Configorama Error: oneOf() requires at least one allowed value')
|
|
52
|
+
}
|
|
53
|
+
if (args.some(hasDynamicArgument)) {
|
|
54
|
+
throw new Error('Configorama Error: oneOf(${...}) dynamic arguments are not supported yet')
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const hasResolvedFilterArg = args.some(isResolvedFilterArg)
|
|
58
|
+
const unwrappedArgs = args.map(unwrapFilterArg)
|
|
59
|
+
if (hasResolvedFilterArg) {
|
|
60
|
+
if (unwrappedArgs.length !== 1 || !Array.isArray(unwrappedArgs[0])) {
|
|
61
|
+
throw new Error('Configorama Error: oneOf(${...}) must resolve to an array')
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const allowed = hasResolvedFilterArg ? unwrappedArgs[0] : unwrappedArgs
|
|
66
|
+
const allowedValues = allowed.map(String)
|
|
67
|
+
if (!allowedValues.some(allowed => allowed === String(value))) {
|
|
68
|
+
throw new Error(`Configorama Error: Value "${value}" is not oneOf(${allowedValues.join(', ')})`)
|
|
69
|
+
}
|
|
70
|
+
return value
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
module.exports = {
|
|
74
|
+
parseOneOfFilter,
|
|
75
|
+
parseOneOfLiteral,
|
|
76
|
+
validateOneOf,
|
|
77
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
const { severityForRisk } = require('./model')
|
|
2
|
+
|
|
3
|
+
function sortFindings(a, b) {
|
|
4
|
+
const severityOrder = { high: 0, medium: 1, low: 2, info: 3 }
|
|
5
|
+
const severityDiff = (severityOrder[a.severity] ?? 99) - (severityOrder[b.severity] ?? 99)
|
|
6
|
+
if (severityDiff !== 0) return severityDiff
|
|
7
|
+
return String(a.id).localeCompare(String(b.id))
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function buildAuditReport(introspection, options = {}) {
|
|
11
|
+
const findings = []
|
|
12
|
+
|
|
13
|
+
for (const node of introspection.nodes || []) {
|
|
14
|
+
if (!node.risk || node.risk === 'none') continue
|
|
15
|
+
findings.push({
|
|
16
|
+
id: node.id,
|
|
17
|
+
severity: node.severity || severityForRisk(node.risk),
|
|
18
|
+
risk: node.risk,
|
|
19
|
+
kind: node.kind,
|
|
20
|
+
variable: node.variable,
|
|
21
|
+
path: node.path,
|
|
22
|
+
relativePath: node.relativePath,
|
|
23
|
+
configPaths: node.paths || [],
|
|
24
|
+
message: messageForNode(node),
|
|
25
|
+
})
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (options.dotenv === true) {
|
|
29
|
+
findings.push({
|
|
30
|
+
id: 'dotenv:useDotenv',
|
|
31
|
+
severity: 'high',
|
|
32
|
+
risk: 'environment_mutation',
|
|
33
|
+
kind: 'source',
|
|
34
|
+
message: 'Configuration requests dotenv loading, which mutates process.env.',
|
|
35
|
+
})
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (options.customResolvers && options.customResolvers.length) {
|
|
39
|
+
for (const resolver of options.customResolvers.slice().sort()) {
|
|
40
|
+
findings.push({
|
|
41
|
+
id: `customResolver:${resolver}`,
|
|
42
|
+
severity: 'high',
|
|
43
|
+
risk: 'custom_extension',
|
|
44
|
+
kind: 'source',
|
|
45
|
+
variableType: resolver,
|
|
46
|
+
message: `Custom resolver "${resolver}" can execute user-provided code.`,
|
|
47
|
+
})
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
findings.sort(sortFindings)
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
schemaVersion: 1,
|
|
55
|
+
safeMode: options.safeMode === true,
|
|
56
|
+
findings,
|
|
57
|
+
diagnostics: introspection.diagnostics || [],
|
|
58
|
+
summary: {
|
|
59
|
+
high: findings.filter(finding => finding.severity === 'high').length,
|
|
60
|
+
medium: findings.filter(finding => finding.severity === 'medium').length,
|
|
61
|
+
low: findings.filter(finding => finding.severity === 'low').length,
|
|
62
|
+
info: findings.filter(finding => finding.severity === 'info').length,
|
|
63
|
+
total: findings.length,
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function messageForNode(node) {
|
|
69
|
+
if (node.risk === 'executable_code') return 'Reference may execute JavaScript or TypeScript.'
|
|
70
|
+
if (node.risk === 'process_spawn') return 'Reference may spawn a git process.'
|
|
71
|
+
if (node.risk === 'local_file_read') return 'Reference reads a local file.'
|
|
72
|
+
if (node.risk === 'data_flow_expression') return 'Expression can read resolved config values but is not JavaScript execution.'
|
|
73
|
+
return `Risk surface: ${node.risk}`
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
module.exports = {
|
|
77
|
+
buildAuditReport,
|
|
78
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
function quote(value) {
|
|
2
|
+
return JSON.stringify(String(value))
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
function toMermaid(graph) {
|
|
6
|
+
const lines = ['graph TD']
|
|
7
|
+
for (const node of graph.nodes) {
|
|
8
|
+
lines.push(` ${sanitizeId(node.id)}[${quote(node.label || node.variable || node.relativePath || node.id).slice(1, -1)}]`)
|
|
9
|
+
}
|
|
10
|
+
for (const edge of graph.edges) {
|
|
11
|
+
lines.push(` ${sanitizeId(edge.from)} -->|${edge.kind}| ${sanitizeId(edge.to)}`)
|
|
12
|
+
}
|
|
13
|
+
return lines.join('\n') + '\n'
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function sanitizeId(value) {
|
|
17
|
+
return String(value).replace(/[^A-Za-z0-9_]/g, '_')
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function toDot(graph) {
|
|
21
|
+
const lines = ['digraph configorama {']
|
|
22
|
+
for (const node of graph.nodes) {
|
|
23
|
+
lines.push(` ${quote(node.id)} [label=${quote(node.label || node.variable || node.relativePath || node.id)}];`)
|
|
24
|
+
}
|
|
25
|
+
for (const edge of graph.edges) {
|
|
26
|
+
lines.push(` ${quote(edge.from)} -> ${quote(edge.to)} [label=${quote(edge.kind)}];`)
|
|
27
|
+
}
|
|
28
|
+
lines.push('}')
|
|
29
|
+
return lines.join('\n') + '\n'
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function formatGraph(graph, format = 'json') {
|
|
33
|
+
const normalized = String(format || 'json').toLowerCase()
|
|
34
|
+
if (normalized === 'mermaid' || normalized === 'mmd') return toMermaid(graph)
|
|
35
|
+
if (normalized === 'dot' || normalized === 'graphviz') return toDot(graph)
|
|
36
|
+
return JSON.stringify(graph, null, 2)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
module.exports = {
|
|
40
|
+
formatGraph,
|
|
41
|
+
toDot,
|
|
42
|
+
toMermaid,
|
|
43
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
const path = require('path')
|
|
2
|
+
const { EXECUTABLE_EXTENSIONS } = require('../security/safetyPolicy')
|
|
3
|
+
const { redactRequirementValue } = require('../redaction/redact')
|
|
4
|
+
|
|
5
|
+
const SCHEMA_VERSION = 1
|
|
6
|
+
|
|
7
|
+
function sortBy(keys) {
|
|
8
|
+
return (a, b) => {
|
|
9
|
+
for (const key of keys) {
|
|
10
|
+
const av = a[key] === undefined || a[key] === null ? '' : String(a[key])
|
|
11
|
+
const bv = b[key] === undefined || b[key] === null ? '' : String(b[key])
|
|
12
|
+
if (av < bv) return -1
|
|
13
|
+
if (av > bv) return 1
|
|
14
|
+
}
|
|
15
|
+
return 0
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function normalizeVariableType(type) {
|
|
20
|
+
if (type === 'options' || type === 'opt') return 'option'
|
|
21
|
+
if (type === 'dot.prop') return 'dotProp'
|
|
22
|
+
if (type === 'if') return 'eval'
|
|
23
|
+
return type || 'unknown'
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function riskForVariable(variableType, variable) {
|
|
27
|
+
const type = normalizeVariableType(variableType)
|
|
28
|
+
if (type === 'eval') return 'data_flow_expression'
|
|
29
|
+
if (type === 'git') return 'process_spawn'
|
|
30
|
+
if (type === 'file' || type === 'text') {
|
|
31
|
+
const match = String(variable || '').match(/^(?:file|text)\((.+?)\)/)
|
|
32
|
+
const ext = match ? path.extname(match[1]).toLowerCase() : ''
|
|
33
|
+
return EXECUTABLE_EXTENSIONS.has(ext) ? 'executable_code' : 'local_file_read'
|
|
34
|
+
}
|
|
35
|
+
return 'none'
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function severityForRisk(risk) {
|
|
39
|
+
if (risk === 'executable_code' || risk === 'custom_extension' || risk === 'environment_mutation') return 'high'
|
|
40
|
+
if (risk === 'process_spawn') return 'medium'
|
|
41
|
+
if (risk === 'local_file_read' || risk === 'data_flow_expression') return 'low'
|
|
42
|
+
return 'info'
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function buildIntrospection(enrichedMetadata = {}, options = {}) {
|
|
46
|
+
const uniqueVariables = enrichedMetadata.uniqueVariables || {}
|
|
47
|
+
const requirements = options.requirements || []
|
|
48
|
+
const requirementsByVariable = new Map(requirements.map(req => [req.variable, req]))
|
|
49
|
+
const nodes = []
|
|
50
|
+
const edges = []
|
|
51
|
+
const diagnostics = []
|
|
52
|
+
|
|
53
|
+
for (const [key, entry] of Object.entries(uniqueVariables).sort(([a], [b]) => a.localeCompare(b))) {
|
|
54
|
+
const variable = entry.variable || key
|
|
55
|
+
const variableType = normalizeVariableType(entry.variableType)
|
|
56
|
+
const requirement = requirementsByVariable.get(variable)
|
|
57
|
+
const risk = riskForVariable(variableType, variable)
|
|
58
|
+
const node = {
|
|
59
|
+
id: `variable:${variable}`,
|
|
60
|
+
kind: variableType === 'file' || variableType === 'text'
|
|
61
|
+
? 'file'
|
|
62
|
+
: (risk === 'executable_code' ? 'executable' : 'variable'),
|
|
63
|
+
variable,
|
|
64
|
+
variableType,
|
|
65
|
+
sourceClass: entry.variableSourceType || entry.sourceClass || requirement?.sourceClass || null,
|
|
66
|
+
risk,
|
|
67
|
+
severity: severityForRisk(risk),
|
|
68
|
+
paths: [...new Set((entry.occurrences || []).map(occ => occ.path).filter(Boolean))].sort(),
|
|
69
|
+
sensitive: requirement ? requirement.sensitive === true : false,
|
|
70
|
+
}
|
|
71
|
+
if (requirement) {
|
|
72
|
+
node.required = requirement.required
|
|
73
|
+
node.default = redactRequirementValue(requirement, requirement.default)
|
|
74
|
+
node.description = requirement.description
|
|
75
|
+
}
|
|
76
|
+
nodes.push(node)
|
|
77
|
+
|
|
78
|
+
for (const configPath of node.paths) {
|
|
79
|
+
edges.push({
|
|
80
|
+
from: `configPath:${configPath}`,
|
|
81
|
+
to: node.id,
|
|
82
|
+
kind: 'uses',
|
|
83
|
+
})
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
for (const inner of entry.innerVariables || []) {
|
|
87
|
+
edges.push({
|
|
88
|
+
from: node.id,
|
|
89
|
+
to: `variable:${inner.variable}`,
|
|
90
|
+
kind: 'depends_on',
|
|
91
|
+
})
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if ((variableType === 'file' || variableType === 'text') && String(variable).includes('${')) {
|
|
95
|
+
diagnostics.push({
|
|
96
|
+
code: 'dynamic_file_target',
|
|
97
|
+
severity: 'info',
|
|
98
|
+
variable,
|
|
99
|
+
message: 'File target contains variables; static introspection records a partial edge.',
|
|
100
|
+
})
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const fileDeps = enrichedMetadata.fileDependencies || {}
|
|
105
|
+
for (const dep of fileDeps.byConfigPath || []) {
|
|
106
|
+
const id = `file:${dep.relativePath || dep.filePath}`
|
|
107
|
+
if (!nodes.some(node => node.id === id)) {
|
|
108
|
+
nodes.push({
|
|
109
|
+
id,
|
|
110
|
+
kind: EXECUTABLE_EXTENSIONS.has(path.extname(dep.relativePath || dep.filePath || '').toLowerCase()) ? 'executable' : 'file',
|
|
111
|
+
path: dep.filePath,
|
|
112
|
+
relativePath: dep.relativePath,
|
|
113
|
+
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
|
+
})
|
|
117
|
+
}
|
|
118
|
+
if (dep.location) {
|
|
119
|
+
edges.push({
|
|
120
|
+
from: `configPath:${dep.location}`,
|
|
121
|
+
to: id,
|
|
122
|
+
kind: 'reads',
|
|
123
|
+
})
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
nodes.sort(sortBy(['kind', 'id']))
|
|
128
|
+
edges.sort(sortBy(['from', 'kind', 'to']))
|
|
129
|
+
diagnostics.sort(sortBy(['code', 'variable']))
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
schemaVersion: SCHEMA_VERSION,
|
|
133
|
+
nodes,
|
|
134
|
+
edges,
|
|
135
|
+
diagnostics,
|
|
136
|
+
summary: {
|
|
137
|
+
nodes: nodes.length,
|
|
138
|
+
edges: edges.length,
|
|
139
|
+
diagnostics: diagnostics.length,
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
module.exports = {
|
|
145
|
+
SCHEMA_VERSION,
|
|
146
|
+
buildIntrospection,
|
|
147
|
+
normalizeVariableType,
|
|
148
|
+
riskForVariable,
|
|
149
|
+
severityForRisk,
|
|
150
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
const { test } = require('uvu')
|
|
2
|
+
const assert = require('uvu/assert')
|
|
3
|
+
const { buildIntrospection, riskForVariable } = require('./model')
|
|
4
|
+
const { buildAuditReport } = require('./audit')
|
|
5
|
+
const { formatGraph } = require('./graph')
|
|
6
|
+
|
|
7
|
+
test('riskForVariable classifies eval as data-flow and js file refs as executable', () => {
|
|
8
|
+
assert.is(riskForVariable('eval', 'eval(1 + 1)'), 'data_flow_expression')
|
|
9
|
+
assert.is(riskForVariable('if', 'if(true)'), 'data_flow_expression')
|
|
10
|
+
assert.is(riskForVariable('file', 'file(./config.js)'), 'executable_code')
|
|
11
|
+
assert.is(riskForVariable('file', 'file(./config.yml)'), 'local_file_read')
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
test('buildIntrospection creates deterministic nodes edges and dynamic diagnostics', () => {
|
|
15
|
+
const graph = buildIntrospection({
|
|
16
|
+
uniqueVariables: {
|
|
17
|
+
'file(./${opt:stage}.yml)': {
|
|
18
|
+
variable: 'file(./${opt:stage}.yml)',
|
|
19
|
+
variableType: 'file',
|
|
20
|
+
variableSourceType: 'config',
|
|
21
|
+
occurrences: [{ path: 'database' }],
|
|
22
|
+
innerVariables: [{ variable: 'opt:stage', variableType: 'options' }]
|
|
23
|
+
},
|
|
24
|
+
'opt:stage': {
|
|
25
|
+
variable: 'opt:stage',
|
|
26
|
+
variableType: 'options',
|
|
27
|
+
variableSourceType: 'user',
|
|
28
|
+
occurrences: [{ path: 'stage' }]
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
fileDependencies: {
|
|
32
|
+
byConfigPath: []
|
|
33
|
+
}
|
|
34
|
+
}, {
|
|
35
|
+
requirements: [{ variable: 'opt:stage', sensitive: false, required: true, default: null }]
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
assert.is(graph.schemaVersion, 1)
|
|
39
|
+
assert.ok(graph.nodes.some(node => node.id === 'variable:file(./${opt:stage}.yml)'))
|
|
40
|
+
assert.ok(graph.edges.some(edge => edge.kind === 'depends_on'))
|
|
41
|
+
assert.is(graph.diagnostics[0].code, 'dynamic_file_target')
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
test('buildIntrospection redacts sensitive requirement defaults', () => {
|
|
45
|
+
const graph = buildIntrospection({
|
|
46
|
+
uniqueVariables: {
|
|
47
|
+
'env:API_KEY': {
|
|
48
|
+
variable: 'env:API_KEY',
|
|
49
|
+
variableType: 'env',
|
|
50
|
+
variableSourceType: 'user',
|
|
51
|
+
occurrences: [{ path: 'apiKey' }]
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
fileDependencies: { byConfigPath: [] }
|
|
55
|
+
}, {
|
|
56
|
+
requirements: [{
|
|
57
|
+
variable: 'env:API_KEY',
|
|
58
|
+
sensitive: true,
|
|
59
|
+
required: false,
|
|
60
|
+
default: 'secret-value',
|
|
61
|
+
description: 'API key'
|
|
62
|
+
}]
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
const node = graph.nodes.find(item => item.variable === 'env:API_KEY')
|
|
66
|
+
assert.is(node.sensitive, true)
|
|
67
|
+
assert.is(node.default, '********')
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
test('audit report sorts executable findings before lower severity surfaces', () => {
|
|
71
|
+
const report = buildAuditReport({
|
|
72
|
+
nodes: [
|
|
73
|
+
{ id: 'variable:eval(1)', risk: 'data_flow_expression', severity: 'low', kind: 'variable' },
|
|
74
|
+
{ id: 'variable:file(./x.js)', risk: 'executable_code', severity: 'high', kind: 'executable' }
|
|
75
|
+
],
|
|
76
|
+
diagnostics: []
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
assert.is(report.summary.total, 2)
|
|
80
|
+
assert.is(report.findings[0].severity, 'high')
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
test('formatGraph emits mermaid and dot formats', () => {
|
|
84
|
+
const graph = {
|
|
85
|
+
nodes: [{ id: 'configPath:stage', label: 'stage' }, { id: 'variable:opt:stage', variable: 'opt:stage' }],
|
|
86
|
+
edges: [{ from: 'configPath:stage', to: 'variable:opt:stage', kind: 'uses' }]
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
assert.match(formatGraph(graph, 'mermaid'), /graph TD/)
|
|
90
|
+
assert.match(formatGraph(graph, 'dot'), /digraph configorama/)
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
test.run()
|