dsh-standards 1.6.0 → 1.6.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/40-checker/fixtures/security/sec11-cred-declared/index.mjs +3 -0
- package/40-checker/fixtures/security/sec11-cred-declared/package.json +21 -0
- package/40-checker/fixtures/security/sec11-cred-list/index.mjs +3 -0
- package/40-checker/fixtures/security/sec11-cred-list/package.json +21 -0
- package/40-checker/fixtures/security/sec11-cred-undeclared/index.mjs +3 -0
- package/40-checker/fixtures/security/sec11-cred-undeclared/package.json +19 -0
- package/40-checker/fixtures/security/sec15-install-cred/index.mjs +7 -0
- package/40-checker/fixtures/security/sec15-install-cred/install.js +8 -0
- package/40-checker/fixtures/security/sec15-install-cred/package.json +22 -0
- package/40-checker/fixtures/security/sec15-install-curl/index.mjs +7 -0
- package/40-checker/fixtures/security/sec15-install-curl/package.json +22 -0
- package/40-checker/fixtures/security/sec15-install-curl/scripts/install.sh +4 -0
- package/40-checker/fixtures/security/sec15-install-danger/index.mjs +7 -0
- package/40-checker/fixtures/security/sec15-install-danger/install.js +5 -0
- package/40-checker/fixtures/security/sec15-install-danger/package.json +22 -0
- package/40-checker/fixtures/security/sec15-install-download-safe/index.js +2 -0
- package/40-checker/fixtures/security/sec15-install-download-safe/package.json +22 -0
- package/40-checker/fixtures/security/sec15-install-download-safe/scripts/install.js +11 -0
- package/40-checker/fixtures/security/sec15-install-download-safe/scripts/local-util.cjs +1 -0
- package/40-checker/fixtures/security/sec15-install-dynamic/index.js +2 -0
- package/40-checker/fixtures/security/sec15-install-dynamic/package.json +22 -0
- package/40-checker/fixtures/security/sec15-install-dynamic/scripts/install.js +11 -0
- package/40-checker/fixtures/security/sec15-install-safe/index.mjs +7 -0
- package/40-checker/fixtures/security/sec15-install-safe/install.js +5 -0
- package/40-checker/fixtures/security/sec15-install-safe/package.json +22 -0
- package/40-checker/fixtures/security/sec16-base64-clean/index.mjs +4 -0
- package/40-checker/fixtures/security/sec16-base64-clean/package.json +19 -0
- package/40-checker/fixtures/security/sec16-base64-eval/index.mjs +4 -0
- package/40-checker/fixtures/security/sec16-base64-eval/package.json +19 -0
- package/40-checker/fixtures/security/sec16-exec-curl/index.mjs +4 -0
- package/40-checker/fixtures/security/sec16-exec-curl/package.json +19 -0
- package/40-checker/fixtures/security/sec16-import-only/index.mjs +4 -0
- package/40-checker/fixtures/security/sec16-import-only/package.json +19 -0
- package/40-checker/fixtures/security/sec16-spawn-shell/index.mjs +4 -0
- package/40-checker/fixtures/security/sec16-spawn-shell/package.json +19 -0
- package/40-checker/fixtures/security/sec16-subprocess-safe/index.mjs +4 -0
- package/40-checker/fixtures/security/sec16-subprocess-safe/package.json +19 -0
- package/40-checker/fixtures/security/sec17-alias-clean/data.txt +1 -0
- package/40-checker/fixtures/security/sec17-alias-clean/index.mjs +5 -0
- package/40-checker/fixtures/security/sec17-alias-clean/package.json +19 -0
- package/40-checker/fixtures/security/sec17-alias-read/index.mjs +7 -0
- package/40-checker/fixtures/security/sec17-alias-read/package.json +19 -0
- package/40-checker/fixtures/security/sec17-base64-path/index.mjs +11 -0
- package/40-checker/fixtures/security/sec17-base64-path/package.json +19 -0
- package/40-checker/fixtures/security/sec17-base64-path-clean/data/config.json +1 -0
- package/40-checker/fixtures/security/sec17-base64-path-clean/index.mjs +5 -0
- package/40-checker/fixtures/security/sec17-base64-path-clean/package.json +19 -0
- package/40-checker/fixtures/security/sec17-cred-exfil/index.mjs +22 -0
- package/40-checker/fixtures/security/sec17-cred-exfil/package.json +19 -0
- package/40-checker/fixtures/security/sec17-home-env/index.js +8 -0
- package/40-checker/fixtures/security/sec17-home-env/package.json +19 -0
- package/40-checker/fixtures/security/sec17-home-env-clean/index.mjs +6 -0
- package/40-checker/fixtures/security/sec17-home-env-clean/package.json +19 -0
- package/40-checker/fixtures/security/sec18-split-fetch/index.mjs +5 -0
- package/40-checker/fixtures/security/sec18-split-fetch/package.json +19 -0
- package/40-checker/fixtures/security/sec18-split-fetch-clean/index.mjs +4 -0
- package/40-checker/fixtures/security/sec18-split-fetch-clean/package.json +24 -0
- package/40-checker/src/declarations.mjs +6 -1
- package/40-checker/src/remediation.mjs +1 -1
- package/40-checker/src/security.mjs +568 -44
- package/CHANGELOG.md +23 -0
- package/docs/coverage.md +17 -7
- package/package.json +1 -1
- package/spec/coverage-contract.md +10 -7
|
@@ -45,6 +45,18 @@ const READ_APIS = new Set([
|
|
|
45
45
|
'open', 'openSync', 'createReadStream', 'readdir', 'readdirSync',
|
|
46
46
|
])
|
|
47
47
|
const CHILD_PROCESS = /^(?:node:)?child_process$/
|
|
48
|
+
const COMMAND_EXEC_APIS = new Set(['exec', 'execSync', 'spawn', 'spawnSync', 'execFile', 'execFileSync'])
|
|
49
|
+
// A subprocess call is only a FAIL when its statically-known command is
|
|
50
|
+
// unambiguously a download-and-run / obfuscated payload, never for a plain
|
|
51
|
+
// `git status` or an import that is never called.
|
|
52
|
+
const DANGEROUS_COMMAND = [
|
|
53
|
+
{ pattern: /\b(?:curl|wget)\b[^\n|]*\|\s*(?:sudo\s+)?(?:sh|bash|zsh|dash|ksh|fish)\b/, label: 'remote script piped to a shell' },
|
|
54
|
+
{ pattern: /\b(?:curl|wget)\b[^\n]{0,200}(?:&&|;|\|\|)\s*(?:sudo\s+)?(?:sh|bash|zsh|dash|ksh|fish|node)\b/, label: 'downloaded file executed by a shell' },
|
|
55
|
+
{ pattern: /\bbase64\s+(?:-d|--decode|-D)\b/, label: 'base64 decode command' },
|
|
56
|
+
{ pattern: /\bnode\s+(?:-e|--eval)\b/, label: 'inline node code' },
|
|
57
|
+
{ pattern: /\bpowershell(?:\.exe)?\b[^\n]*(?:-enc|-encodedcommand)\b/i, label: 'powershell encoded command' },
|
|
58
|
+
{ pattern: /\b(?:bash|sh|zsh|dash|ksh)\s+-[a-z]*c\b[^\n]{0,200}\b(?:curl|wget|base64|nc|eval)\b/, label: 'shell -c with a download/decode payload' },
|
|
59
|
+
]
|
|
48
60
|
const INSTRUCTION_PATTERN = /\b(?:ignore|disregard|forget)\b[^\n]{0,60}\b(?:previous|prior|above|earlier|system)\b|\byou are now\b|\bdo not (?:tell|reveal|mention|disclose)\b|\bsystem prompt\b|\bact as\b/i
|
|
49
61
|
const SUSPICIOUS_HOST = /(?:^|\.)(?:webhook|pastebin|paste|ngrok\.io|ngrok-free\.app|requestbin|pipedream|interact\.sh|burpcollaborator|canarytokens)\./i
|
|
50
62
|
const TEXT_EXTENSIONS = /\.(?:[cm]?[jt]sx?|json|md|txt|ya?ml|html|css)$/i
|
|
@@ -122,6 +134,91 @@ function collectBindings(ast) {
|
|
|
122
134
|
return bindings
|
|
123
135
|
}
|
|
124
136
|
|
|
137
|
+
const BASE64_TEXT = /^[A-Za-z0-9+/]+={0,2}$/
|
|
138
|
+
|
|
139
|
+
/** Decode a base64 literal only when the result is printable text. */
|
|
140
|
+
function decodeBase64Literal(text) {
|
|
141
|
+
if (typeof text !== 'string' || text.length < 8 || text.length % 4 !== 0) return null
|
|
142
|
+
if (!BASE64_TEXT.test(text)) return null
|
|
143
|
+
let decoded
|
|
144
|
+
try {
|
|
145
|
+
decoded = Buffer.from(text, 'base64').toString('utf8')
|
|
146
|
+
} catch {
|
|
147
|
+
return null
|
|
148
|
+
}
|
|
149
|
+
if (decoded === '' || decoded.includes('\uFFFD')) return null
|
|
150
|
+
if (!/^[\t\n\r\x20-\x7e]*$/.test(decoded)) return null
|
|
151
|
+
return decoded
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* `Buffer.from(<string>, 'base64').toString()` / `atob(<string>)` when the
|
|
156
|
+
* payload is a static literal (also reachable through a local binding).
|
|
157
|
+
* Returns null for runtime-joined or non-base64 encodings.
|
|
158
|
+
*/
|
|
159
|
+
function base64DecodedFrom(node, bindings, depth = 0, seen = new Set()) {
|
|
160
|
+
if (!node || node.type !== 'CallExpression' || depth > 4 || seen.has(node)) return null
|
|
161
|
+
const names = chainNames(node.callee)
|
|
162
|
+
const last = names[names.length - 1]
|
|
163
|
+
if (last === 'atob') {
|
|
164
|
+
const payload = stringFromNode(node.arguments[0], bindings, depth + 1, new Set(seen).add(node))
|
|
165
|
+
return payload === null ? null : decodeBase64Literal(payload)
|
|
166
|
+
}
|
|
167
|
+
if (last !== 'from' || names[names.length - 2] !== 'Buffer') return null
|
|
168
|
+
const encodingNode = node.arguments[1]
|
|
169
|
+
const encoding = encodingNode === undefined ? 'utf8' : (encodingNode.type === 'Literal' ? encodingNode.value : null)
|
|
170
|
+
if (encoding !== 'base64' && encoding !== 'base64url') return null
|
|
171
|
+
const payload = stringFromNode(node.arguments[0], bindings, depth + 1, new Set(seen).add(node))
|
|
172
|
+
return payload === null ? null : decodeBase64Literal(payload)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function isBase64DecodeCall(node, bindings) {
|
|
176
|
+
if (!node || node.type !== 'CallExpression') return false
|
|
177
|
+
const names = chainNames(node.callee)
|
|
178
|
+
const last = names[names.length - 1]
|
|
179
|
+
if (last === 'from' && names[names.length - 2] === 'Buffer') {
|
|
180
|
+
const encoding = node.arguments[1]
|
|
181
|
+
if (encoding?.type !== 'Literal' || (encoding.value !== 'base64' && encoding.value !== 'base64url')) return false
|
|
182
|
+
return stringFromNode(node.arguments[0], bindings) !== null
|
|
183
|
+
}
|
|
184
|
+
if (last === 'toString' && node.callee.type === 'MemberExpression') {
|
|
185
|
+
const encoding = node.arguments[0]
|
|
186
|
+
if (encoding?.type === 'Literal' && (encoding.value === 'base64' || encoding.value === 'base64url')) return false
|
|
187
|
+
return base64DecodedFrom(node.callee.object, bindings) !== null
|
|
188
|
+
}
|
|
189
|
+
if (last === 'atob' && names[0] === 'atob') return base64DecodedFrom(node, bindings) !== null
|
|
190
|
+
return false
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function isProcessLike(node) {
|
|
194
|
+
if (identifierName(node) === 'process') return true
|
|
195
|
+
return node?.type === 'MemberExpression'
|
|
196
|
+
&& memberPropertyName(node) === 'process'
|
|
197
|
+
&& ['globalThis', 'global', 'window', 'self'].includes(identifierName(node.object))
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* `process.env.HOME` / `process.env['DSH_HOME']` / `globalThis.process.env.HOME`.
|
|
202
|
+
* The old check compared identifierName(node.object) with 'process', but
|
|
203
|
+
* node.object is the `process.env` MemberExpression, so it never matched.
|
|
204
|
+
*/
|
|
205
|
+
function envPropertyOf(member) {
|
|
206
|
+
if (!member || member.type !== 'MemberExpression') return null
|
|
207
|
+
const env = member.object
|
|
208
|
+
if (env?.type !== 'MemberExpression' || memberPropertyName(env) !== 'env') return null
|
|
209
|
+
if (!isProcessLike(env.object)) return null
|
|
210
|
+
return memberPropertyName(member)
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Property name with static computed keys folded: globalThis['fe' + 'tch']. */
|
|
214
|
+
function foldedPropertyName(member, bindings) {
|
|
215
|
+
if (!member || member.type !== 'MemberExpression') return null
|
|
216
|
+
const direct = memberPropertyName(member)
|
|
217
|
+
if (direct !== null) return direct
|
|
218
|
+
if (!member.computed) return null
|
|
219
|
+
return stringFromNode(member.property, bindings)
|
|
220
|
+
}
|
|
221
|
+
|
|
125
222
|
function stringFromNode(node, bindings, depth = 0, seen = new Set()) {
|
|
126
223
|
if (!node || depth > 5 || seen.has(node)) return null
|
|
127
224
|
if (node.type === 'Literal' && typeof node.value === 'string') return node.value
|
|
@@ -161,12 +258,9 @@ function stringFromNode(node, bindings, depth = 0, seen = new Set()) {
|
|
|
161
258
|
const property = memberPropertyName(node)
|
|
162
259
|
const objectName = identifierName(node.object)
|
|
163
260
|
if (objectName === 'process' && property === 'cwd') return '/work/package'
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
if (objectName === 'process' && node.object.type === 'MemberExpression'
|
|
168
|
-
&& memberPropertyName(node.object) === 'env'
|
|
169
|
-
&& ['DSH_HOME', 'XDG_CONFIG_HOME'].includes(property)) return '/home/dsh-user/.dsh'
|
|
261
|
+
const envProperty = envPropertyOf(node)
|
|
262
|
+
if (envProperty === 'HOME' || envProperty === 'USERPROFILE') return '/home/dsh-user'
|
|
263
|
+
if (envProperty === 'DSH_HOME' || envProperty === 'XDG_CONFIG_HOME') return '/home/dsh-user/.dsh'
|
|
170
264
|
return null
|
|
171
265
|
}
|
|
172
266
|
if (node.type === 'CallExpression') {
|
|
@@ -194,7 +288,22 @@ function stringFromNode(node, bindings, depth = 0, seen = new Set()) {
|
|
|
194
288
|
}
|
|
195
289
|
if (last === 'toString') {
|
|
196
290
|
const object = node.callee.type === 'MemberExpression' ? node.callee.object : null
|
|
291
|
+
const encoding = node.arguments[0]
|
|
292
|
+
const reEncoding = encoding?.type === 'Literal' && (encoding.value === 'base64' || encoding.value === 'base64url')
|
|
293
|
+
if (!reEncoding) {
|
|
294
|
+
const decoded = base64DecodedFrom(object, bindings, depth + 1, new Set(seen).add(node))
|
|
295
|
+
if (decoded !== null) return decoded
|
|
296
|
+
}
|
|
197
297
|
if (object?.type === 'Literal') return String(object.value)
|
|
298
|
+
return null
|
|
299
|
+
}
|
|
300
|
+
if (last === 'atob' && names[0] === 'atob') {
|
|
301
|
+
const payload = stringFromNode(node.arguments[0], bindings, depth + 1, new Set(seen).add(node))
|
|
302
|
+
if (payload !== null) {
|
|
303
|
+
const decoded = decodeBase64Literal(payload)
|
|
304
|
+
if (decoded !== null) return decoded
|
|
305
|
+
}
|
|
306
|
+
return null
|
|
198
307
|
}
|
|
199
308
|
if (names[0] === 'String') return stringFromNode(node.arguments[0], bindings, depth + 1, new Set(seen).add(node))
|
|
200
309
|
return null
|
|
@@ -258,21 +367,95 @@ function analyseClosure(sources, entryRelative) {
|
|
|
258
367
|
// DSC-15: install lifecycle scripts
|
|
259
368
|
// ---------------------------------------------------------------------------
|
|
260
369
|
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
370
|
+
const SCRIPT_FILE_EXTENSION = /\.(?:[cm]?[jt]s|sh|bash|zsh|ps1)$/i
|
|
371
|
+
const SCRIPT_INSPECT_MAX_BYTES = 512 * 1024
|
|
372
|
+
const SCRIPT_DANGER_RULES = [
|
|
373
|
+
{ pattern: /\b(?:curl|wget)\b[^\n|]*\|\s*(?:sudo\s+)?(?:sh|bash|zsh|dash|ksh|fish)\b/, label: 'remote script piped to a shell' },
|
|
374
|
+
{ pattern: /\bbase64\s+(?:-d|--decode)\b|\bbase64\s+-D\b/, label: 'base64 decode' },
|
|
375
|
+
{ pattern: /\beval\b\s*\)?\s*\(|\bnew\s+Function\s*\(|\bvm\.(?:runIn\w+|compileFunction)\s*\(/, label: 'eval/Function of a string' },
|
|
376
|
+
{ pattern: /\bnode\s+(?:-e|--eval)\b|\b(?:sh|bash|zsh|dash|ksh)\s+-[a-z]*c\b/, label: 'inline code passed to an interpreter' },
|
|
377
|
+
{ pattern: /\bpowershell(?:\.exe)?\b[^\n]*(?:-enc|-encodedcommand)\b/i, label: 'powershell encoded command' },
|
|
378
|
+
{ pattern: /(?:readFile(?:Sync)?|createReadStream|open(?:Sync)?)\s*\([^\n]{0,200}(?:\.ssh|\.dsh|credential|id_rsa|id_ed25519|\.aws|\.npmrc|\.netrc|tokens?)/i, label: 'reads a credential-like path' },
|
|
379
|
+
{ pattern: /\b(?:cat|cp|scp|tar|zip)\b[^\n]{0,160}(?:\.ssh\/|\.dsh\/|credentials|id_rsa|\.aws\/|\.npmrc|\.netrc)/i, label: 'collects credential-like files' },
|
|
380
|
+
{ pattern: /(?:os\.homedir\(\)|process\.env\.(?:HOME|USERPROFILE|DSH_HOME))[^\n]{0,160}(?:\.dsh|\.ssh|credential|tokens?|id_rsa)/i, label: 'combines the home directory with a secret path' },
|
|
381
|
+
]
|
|
382
|
+
const SCRIPT_NETWORK = /\b(?:fetch|axios\.(?:get|post)|https?\.(?:get|request))\s*\(|\bcurl\b|\bwget\b/
|
|
383
|
+
const SCRIPT_EXECUTE = /\beval\b|\bnew\s+Function\b|\b(?:exec|execSync|spawn|spawnSync|execFile|execFileSync)\s*\(|\bvm\.(?:runIn\w+|compileFunction)/
|
|
384
|
+
// require(<variable>) / import(<variable>): loading code that is not a static
|
|
385
|
+
// literal. Only dangerous when the same script also downloads or pipes code.
|
|
386
|
+
const SCRIPT_DYNAMIC_LOAD = /\brequire\s*\(\s*(?!['"`])[A-Za-z_$]|\bimport\s*\(\s*(?!['"`])[A-Za-z_$]/
|
|
387
|
+
const SCRIPT_CHILD_DYNAMIC = [
|
|
388
|
+
/\b(?:exec|execSync|spawn|spawnSync|execFile|execFileSync)\s*\(\s*`[^`]*\$\{/,
|
|
389
|
+
/\b(?:exec|execSync|spawn|spawnSync|execFile|execFileSync)\s*\(\s*[A-Za-z_$][\w$]*\s*[,)]/,
|
|
390
|
+
]
|
|
391
|
+
|
|
392
|
+
function scriptDangerLabels(text) {
|
|
393
|
+
const labels = []
|
|
394
|
+
const add = (label) => { if (!labels.includes(label)) labels.push(label) }
|
|
395
|
+
for (const rule of SCRIPT_DANGER_RULES) if (rule.pattern.test(text)) add(rule.label)
|
|
396
|
+
if (SCRIPT_NETWORK.test(text) && SCRIPT_EXECUTE.test(text)) add('downloads remote content and executes it')
|
|
397
|
+
if (SCRIPT_NETWORK.test(text) && SCRIPT_DYNAMIC_LOAD.test(text)) add('downloads remote content and dynamically loads it')
|
|
398
|
+
if (/\bchild_process\b/.test(text) && SCRIPT_CHILD_DYNAMIC.some(pattern => pattern.test(text))) {
|
|
399
|
+
add('child_process with a dynamic command')
|
|
400
|
+
}
|
|
401
|
+
return labels
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/** Local script paths referenced by one lifecycle command (node/sh/tsx/direct). */
|
|
405
|
+
function commandScriptReferences(command) {
|
|
406
|
+
const references = []
|
|
407
|
+
const add = (value) => {
|
|
408
|
+
if (!value || value.startsWith('-')) return
|
|
409
|
+
if (value.includes('://')) return
|
|
410
|
+
if (!SCRIPT_FILE_EXTENSION.test(value)) return
|
|
411
|
+
if (!references.includes(value)) references.push(value)
|
|
412
|
+
}
|
|
413
|
+
for (const match of command.matchAll(/\bnode\s+(?!-e\b|--eval\b)(?:(?:--?[\w-]+(?:=[^\s]+)?)\s+)*([^\s;&|()]+)/g)) add(match[1])
|
|
414
|
+
for (const match of command.matchAll(/\b(?:sh|bash|zsh|dash|ksh|fish)\s+(?!-[a-z]*c\b)([^\s;&|()]+)/g)) add(match[1])
|
|
415
|
+
for (const match of command.matchAll(/(?:^|[;&|]\s*|\b(?:then|do|else)\s+)((?:\.{1,2}\/)?[\w@./-]+\.(?:[cm]?[jt]s|sh|ps1))/g)) add(match[1])
|
|
416
|
+
return references
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/** Follow `npm run x` one level so `postinstall: npm run setup` is inspectable. */
|
|
420
|
+
function collectScriptFacts(command, dir, scripts, depth = 0, seen = new Set(), acc = null) {
|
|
421
|
+
const facts = acc ?? { references: [], missing: [] }
|
|
422
|
+
for (const reference of commandScriptReferences(command)) {
|
|
423
|
+
if (!facts.references.includes(reference)) facts.references.push(reference)
|
|
269
424
|
}
|
|
270
|
-
for (const match of command.matchAll(/\bnode\s+(?!-e\b|--eval\b)([^\s;&|]+)/g)) checkRef(match[1])
|
|
271
425
|
for (const match of command.matchAll(/\b(?:npm|pnpm|yarn)\s+run\s+([\w:.@-]+)/g)) {
|
|
272
|
-
|
|
426
|
+
const name = match[1]
|
|
427
|
+
if (!Object.hasOwn(scripts, name)) {
|
|
428
|
+
if (!facts.missing.includes(`npm run ${name}`)) facts.missing.push(`npm run ${name}`)
|
|
429
|
+
continue
|
|
430
|
+
}
|
|
431
|
+
if (depth >= 2 || seen.has(name)) continue
|
|
432
|
+
seen.add(name)
|
|
433
|
+
collectScriptFacts(String(scripts[name] ?? ''), dir, scripts, depth + 1, seen, facts)
|
|
273
434
|
}
|
|
274
|
-
|
|
275
|
-
|
|
435
|
+
return facts
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Read a referenced local script and look for install-time danger. The file is
|
|
440
|
+
* never executed; only text is inspected and the walk is bounded to the
|
|
441
|
+
* package directory so a lifecycle command cannot make the checker read an
|
|
442
|
+
* arbitrary host file.
|
|
443
|
+
*/
|
|
444
|
+
function inspectInstallScript(reference, dir) {
|
|
445
|
+
const root = path.resolve(dir)
|
|
446
|
+
const absolute = path.resolve(root, reference)
|
|
447
|
+
if (absolute !== root && !absolute.startsWith(root + path.sep)) {
|
|
448
|
+
return { reference, skipped: 'outside the package tree' }
|
|
449
|
+
}
|
|
450
|
+
let stats
|
|
451
|
+
try { stats = fs.lstatSync(absolute) } catch { return { reference, missing: true } }
|
|
452
|
+
if (stats.isSymbolicLink()) return { reference, skipped: 'symlink not followed' }
|
|
453
|
+
if (!stats.isFile()) return { reference, missing: true }
|
|
454
|
+
if (stats.size > SCRIPT_INSPECT_MAX_BYTES) return { reference, skipped: `larger than ${SCRIPT_INSPECT_MAX_BYTES} bytes` }
|
|
455
|
+
let text
|
|
456
|
+
try { text = fs.readFileSync(absolute, 'utf8') } catch { return { reference, skipped: 'unreadable' } }
|
|
457
|
+
if (text.includes('\0')) return { reference, skipped: 'binary content' }
|
|
458
|
+
return { reference, labels: scriptDangerLabels(text) }
|
|
276
459
|
}
|
|
277
460
|
|
|
278
461
|
export function checkLifecycleScripts(manifest, dir) {
|
|
@@ -286,7 +469,16 @@ export function checkLifecycleScripts(manifest, dir) {
|
|
|
286
469
|
present += 1
|
|
287
470
|
const evidence = [`package.json#scripts.${name}`]
|
|
288
471
|
const dangerous = DANGEROUS_SCRIPT.filter(rule => rule.pattern.test(command)).map(rule => rule.label)
|
|
289
|
-
const
|
|
472
|
+
const facts = dir ? collectScriptFacts(command, dir, scripts) : { references: [], missing: [] }
|
|
473
|
+
const missing = [...new Set(facts.missing)]
|
|
474
|
+
const contentFindings = []
|
|
475
|
+
const skipped = []
|
|
476
|
+
for (const reference of facts.references) {
|
|
477
|
+
const inspected = inspectInstallScript(reference, dir)
|
|
478
|
+
if (inspected.missing) missing.push(reference)
|
|
479
|
+
else if (inspected.skipped) skipped.push(`${reference}: ${inspected.skipped}`)
|
|
480
|
+
else if (inspected.labels.length > 0) contentFindings.push(`${reference}: ${inspected.labels.join(', ')}`)
|
|
481
|
+
}
|
|
290
482
|
if (name === 'prepare' && dangerous.length > 0) {
|
|
291
483
|
details.push(detail('C-15.1', 'WARN', `scripts.prepare uses a dangerous form (${dangerous.join('; ')}); prepare is allowBuilds-gated, so this is WARN not FAIL`, evidence))
|
|
292
484
|
findings.push('warn')
|
|
@@ -297,10 +489,20 @@ export function checkLifecycleScripts(manifest, dir) {
|
|
|
297
489
|
// `prepare` also runs for git/source installs, which pnpm gates behind
|
|
298
490
|
// allowBuilds; a missing prepare target is therefore WARN, not FAIL.
|
|
299
491
|
const level = name === 'prepare' ? 'WARN' : 'FAIL'
|
|
300
|
-
details.push(detail('C-15.3', level,
|
|
492
|
+
details.push(detail('C-15.3', level, `scripts.${name} references missing script(s): ${missing.join(', ')}${name === 'prepare' ? ' (prepare is allowBuilds-gated)' : ''}`, evidence))
|
|
301
493
|
findings.push(level === 'WARN' ? 'warn' : 'fail')
|
|
494
|
+
} else if (contentFindings.length > 0) {
|
|
495
|
+
const level = name === 'prepare' ? 'WARN' : 'FAIL'
|
|
496
|
+
details.push(detail('C-15.1', level, `referenced install script is dangerous: ${contentFindings.join('; ')}${name === 'prepare' ? ' (prepare is allowBuilds-gated)' : ''}`, evidence))
|
|
497
|
+
findings.push(level === 'WARN' ? 'warn' : 'fail')
|
|
498
|
+
} else if (skipped.length > 0) {
|
|
499
|
+
details.push(detail('C-15.2', 'WARN', `referenced install script was not inspected: ${skipped.join('; ')}`, evidence))
|
|
500
|
+
findings.push('warn')
|
|
302
501
|
} else {
|
|
303
502
|
details.push(detail('C-15.2', 'WARN', `lifecycle script scripts.${name} is present; install-time commands are not executed by the checker`, evidence))
|
|
503
|
+
if (facts.references.length > 0) {
|
|
504
|
+
details.push(detail('C-15.2', 'PASS', `inspected referenced script(s): ${facts.references.join(', ')}; no dangerous pattern found`))
|
|
505
|
+
}
|
|
304
506
|
findings.push('warn')
|
|
305
507
|
}
|
|
306
508
|
}
|
|
@@ -346,6 +548,50 @@ function functionConstructorExempt(node, ancestors) {
|
|
|
346
548
|
return allowsEvalContext(ancestors)
|
|
347
549
|
}
|
|
348
550
|
|
|
551
|
+
/** exec/execSync/spawn/... called directly or through a one-level alias. */
|
|
552
|
+
function commandExecApiOf(callee, bindings) {
|
|
553
|
+
if (!callee) return null
|
|
554
|
+
if (callee.type === 'Identifier') {
|
|
555
|
+
if (COMMAND_EXEC_APIS.has(callee.name)) return callee.name
|
|
556
|
+
const init = bindings.get(callee.name)
|
|
557
|
+
if (init?.type === 'MemberExpression') {
|
|
558
|
+
const property = memberPropertyName(init)
|
|
559
|
+
if (property && COMMAND_EXEC_APIS.has(property)) return property
|
|
560
|
+
}
|
|
561
|
+
return null
|
|
562
|
+
}
|
|
563
|
+
if (callee.type === 'MemberExpression') {
|
|
564
|
+
const property = memberPropertyName(callee)
|
|
565
|
+
if (property && COMMAND_EXEC_APIS.has(property)) return property
|
|
566
|
+
}
|
|
567
|
+
return null
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/** Join the statically resolvable command/argv parts; options objects are skipped. */
|
|
571
|
+
function commandTextOf(node, bindings) {
|
|
572
|
+
const parts = []
|
|
573
|
+
for (const argument of node.arguments ?? []) {
|
|
574
|
+
if (!argument || argument.type === 'ObjectExpression') continue
|
|
575
|
+
if (argument.type === 'ArrayExpression') {
|
|
576
|
+
for (const element of argument.elements) {
|
|
577
|
+
if (!element) continue
|
|
578
|
+
const value = stringFromNode(element, bindings)
|
|
579
|
+
if (value !== null) parts.push(value)
|
|
580
|
+
}
|
|
581
|
+
continue
|
|
582
|
+
}
|
|
583
|
+
const value = stringFromNode(argument, bindings)
|
|
584
|
+
if (value !== null) parts.push(value)
|
|
585
|
+
}
|
|
586
|
+
return parts.length > 0 ? parts.join(' ') : null
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
function dangerousCommandLabels(text) {
|
|
590
|
+
const labels = []
|
|
591
|
+
for (const rule of DANGEROUS_COMMAND) if (rule.pattern.test(text)) labels.push(rule.label)
|
|
592
|
+
return labels
|
|
593
|
+
}
|
|
594
|
+
|
|
349
595
|
export function checkDynamicExecution(manifest, sources, entry, kind = null) {
|
|
350
596
|
if (kind === 'client') {
|
|
351
597
|
return check({
|
|
@@ -375,10 +621,19 @@ export function checkDynamicExecution(manifest, sources, entry, kind = null) {
|
|
|
375
621
|
const details = [...closure.notes]
|
|
376
622
|
const dynamic = []
|
|
377
623
|
const subprocess = []
|
|
624
|
+
const fileFacts = new Map()
|
|
378
625
|
for (const relative of closure.visited) {
|
|
379
626
|
const parsed = parseModule(closure.byRelative.get(relative))
|
|
380
627
|
if (parsed.error) continue
|
|
628
|
+
let facts = fileFacts.get(relative)
|
|
629
|
+
if (!facts) {
|
|
630
|
+
facts = { base64Line: null, evalLike: false }
|
|
631
|
+
fileFacts.set(relative, facts)
|
|
632
|
+
}
|
|
381
633
|
walkAst(parsed.ast, (node, ancestors) => {
|
|
634
|
+
if (isBase64DecodeCall(node, parsed.bindings)) {
|
|
635
|
+
if (facts.base64Line === null) facts.base64Line = lineOf(node)
|
|
636
|
+
}
|
|
382
637
|
if (node.type === 'ImportDeclaration' && typeof node.source?.value === 'string' && CHILD_PROCESS.test(node.source.value)) {
|
|
383
638
|
subprocess.push({ file: relative, line: lineOf(node), what: `import ${node.source.value}` })
|
|
384
639
|
return
|
|
@@ -393,13 +648,26 @@ export function checkDynamicExecution(manifest, sources, entry, kind = null) {
|
|
|
393
648
|
subprocess.push({ file: relative, line: lineOf(node), what: `import('${node.source.value}')` })
|
|
394
649
|
}
|
|
395
650
|
if (node.type === 'CallExpression' && node.callee.type === 'Identifier' && node.callee.name === 'eval') {
|
|
651
|
+
facts.evalLike = true
|
|
396
652
|
dynamic.push({ file: relative, line: lineOf(node), what: 'eval()', item: 'C-16.1' })
|
|
397
653
|
return
|
|
398
654
|
}
|
|
399
655
|
if (isFunctionConstructor(node)) {
|
|
656
|
+
facts.evalLike = true
|
|
400
657
|
if (!functionConstructorExempt(node, ancestors)) dynamic.push({ file: relative, line: lineOf(node), what: 'new Function()', item: 'C-16.1' })
|
|
401
658
|
return
|
|
402
659
|
}
|
|
660
|
+
if (node.type === 'CallExpression') {
|
|
661
|
+
const execApi = commandExecApiOf(node.callee, parsed.bindings)
|
|
662
|
+
if (execApi) {
|
|
663
|
+
const command = commandTextOf(node, parsed.bindings)
|
|
664
|
+
const labels = command === null ? [] : dangerousCommandLabels(command)
|
|
665
|
+
if (labels.length > 0) {
|
|
666
|
+
dynamic.push({ file: relative, line: lineOf(node), what: `child_process.${execApi} executes a dangerous command (${labels.join('; ')})`, item: 'C-16.1' })
|
|
667
|
+
return
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
}
|
|
403
671
|
if (node.type === 'CallExpression' || node.type === 'NewExpression') {
|
|
404
672
|
const names = chainNames(node.callee)
|
|
405
673
|
const joined = names.join('.')
|
|
@@ -416,11 +684,21 @@ export function checkDynamicExecution(manifest, sources, entry, kind = null) {
|
|
|
416
684
|
}
|
|
417
685
|
})
|
|
418
686
|
}
|
|
687
|
+
const obfuscated = []
|
|
688
|
+
for (const [file, facts] of fileFacts) {
|
|
689
|
+
if (facts.base64Line !== null && facts.evalLike) {
|
|
690
|
+
obfuscated.push({ file, line: facts.base64Line, what: 'base64-decoded payload in the same file as eval/new Function' })
|
|
691
|
+
}
|
|
692
|
+
}
|
|
419
693
|
const dynamicEvidence = dynamic.slice(0, 5).map(item => `${item.file}:${item.line}`)
|
|
694
|
+
const obfuscatedEvidence = obfuscated.slice(0, 5).map(item => `${item.file}:${item.line}`)
|
|
420
695
|
const subprocessEvidence = subprocess.slice(0, 5).map(item => `${item.file}:${item.line}`)
|
|
421
696
|
for (const item of dynamic.slice(0, 8)) {
|
|
422
697
|
details.push(detail(item.item, 'FAIL', `dynamic execution (code:dynamic): ${item.what} at ${item.file}:${item.line}`, [`${item.file}:${item.line}`]))
|
|
423
698
|
}
|
|
699
|
+
for (const item of obfuscated.slice(0, 4)) {
|
|
700
|
+
details.push(detail('C-16.1', 'FAIL', `dynamic execution (code:obfuscated): ${item.what} at ${item.file}:${item.line}`, [`${item.file}:${item.line}`]))
|
|
701
|
+
}
|
|
424
702
|
for (const item of subprocess.slice(0, 8)) {
|
|
425
703
|
details.push(detail('C-16.3', 'WARN', `subprocess capability observed: ${item.what} at ${item.file}:${item.line}`, [`${item.file}:${item.line}`]))
|
|
426
704
|
}
|
|
@@ -433,9 +711,10 @@ export function checkDynamicExecution(manifest, sources, entry, kind = null) {
|
|
|
433
711
|
const coverage = closure.parseErrors.length > 0 || closure.packageImports.length > 0 || closure.unresolved.length > 0 ? 'partial' : 'full'
|
|
434
712
|
let result
|
|
435
713
|
let reason = ''
|
|
436
|
-
if (dynamic.length > 0) {
|
|
714
|
+
if (dynamic.length > 0 || obfuscated.length > 0) {
|
|
437
715
|
result = 'FAIL'
|
|
438
716
|
reason = `${dynamic.length} dynamic-execution site(s) in the server entry closure (code:dynamic)`
|
|
717
|
+
if (obfuscated.length > 0) reason += `; ${obfuscated.length} base64-obfuscated execution clue(s)`
|
|
439
718
|
if (closure.parseErrors.length > 0) reason += `; ${closure.parseErrors.length} file(s) could not be parsed`
|
|
440
719
|
} else if (closure.parseErrors.length > 0) {
|
|
441
720
|
result = 'ERROR'
|
|
@@ -455,7 +734,7 @@ export function checkDynamicExecution(manifest, sources, entry, kind = null) {
|
|
|
455
734
|
coverage: result === 'ERROR' ? 'not-run' : coverage,
|
|
456
735
|
reason,
|
|
457
736
|
details,
|
|
458
|
-
evidence: dynamicEvidence.length > 0 ? dynamicEvidence : subprocessEvidence.length > 0 ? subprocessEvidence : [...closure.visited].slice(0, 3),
|
|
737
|
+
evidence: dynamicEvidence.length > 0 ? dynamicEvidence : obfuscatedEvidence.length > 0 ? obfuscatedEvidence : subprocessEvidence.length > 0 ? subprocessEvidence : [...closure.visited].slice(0, 3),
|
|
459
738
|
})
|
|
460
739
|
}
|
|
461
740
|
|
|
@@ -474,6 +753,175 @@ function classifySecretPath(value) {
|
|
|
474
753
|
return null
|
|
475
754
|
}
|
|
476
755
|
|
|
756
|
+
const HOME_ENV_NAMES = new Set(['HOME', 'USERPROFILE', 'DSH_HOME', 'XDG_CONFIG_HOME'])
|
|
757
|
+
const SECRET_STRONG = /(?:^|[^a-z0-9])(?:\.ssh|id_rsa|id_ed25519|credentials?(?![a-z0-9])|tokens?(?![a-z0-9-]))(?:[^a-z0-9]|$)/i
|
|
758
|
+
const SECRET_WEAK = /(?:^|[^a-z0-9])\.dsh(?:[^a-z0-9]|$)/i
|
|
759
|
+
const FUNCTION_SCOPE_NODES = new Set([
|
|
760
|
+
'FunctionDeclaration', 'FunctionExpression', 'ArrowFunctionExpression',
|
|
761
|
+
'ObjectMethod', 'ClassMethod', 'ClassPrivateMethod', 'Program',
|
|
762
|
+
])
|
|
763
|
+
const FUNCTION_LIKE_NODES = new Set([...FUNCTION_SCOPE_NODES].filter(type => type !== 'Program'))
|
|
764
|
+
const PATH_LIKE_NAME = /(?:path|file|dir|folder|config|secret|cred|token|home)/i
|
|
765
|
+
|
|
766
|
+
function secretStrengthOfText(text) {
|
|
767
|
+
if (typeof text !== 'string' || text === '') return null
|
|
768
|
+
if (SECRET_STRONG.test(text)) return 'strong'
|
|
769
|
+
if (SECRET_WEAK.test(text)) return 'weak'
|
|
770
|
+
return null
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
function strongerSecret(left, right) {
|
|
774
|
+
if (left === 'strong' || right === 'strong') return 'strong'
|
|
775
|
+
return left ?? right ?? null
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
/** Literal text, template text or regex pattern; comments never reach the AST. */
|
|
779
|
+
function literalPathText(node) {
|
|
780
|
+
if (!node) return null
|
|
781
|
+
if (node.type === 'Literal') {
|
|
782
|
+
if (node.regex && typeof node.regex.pattern === 'string') return node.regex.pattern
|
|
783
|
+
if (typeof node.value === 'string') return node.value
|
|
784
|
+
}
|
|
785
|
+
if (node.type === 'TemplateElement' && node.value) {
|
|
786
|
+
return typeof node.value.cooked === 'string' ? node.value.cooked : (typeof node.value.raw === 'string' ? node.value.raw : null)
|
|
787
|
+
}
|
|
788
|
+
return null
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
/**
|
|
792
|
+
* Only path-like literals count. A sentence such as "invalid credentials" is
|
|
793
|
+
* not a clue; `'.dsh'`, `'~/.ssh/id_rsa'`, `'credentials.json'` and a regex like
|
|
794
|
+
* `/^\.?credentials/` are.
|
|
795
|
+
*/
|
|
796
|
+
function isPathLikeSecretLiteral(node, text) {
|
|
797
|
+
const strength = secretStrengthOfText(text)
|
|
798
|
+
if (!strength) return null
|
|
799
|
+
if (node.type === 'Literal' && node.regex) return strength
|
|
800
|
+
const trimmed = String(text).trim()
|
|
801
|
+
if (trimmed === '') return null
|
|
802
|
+
if (trimmed.startsWith('.') || /[\\/]/.test(trimmed)) return strength
|
|
803
|
+
if (/^(?:credentials?|tokens?|id_rsa|id_ed25519|\.dsh|\.ssh)$/i.test(trimmed)) return strength
|
|
804
|
+
if (/^[\w@.-]*(?:credentials?|tokens?|id_rsa|id_ed25519)[\w@.-]*\.(?:json|ya?ml|txt|pem|key|env|ini|conf)$/i.test(trimmed)) return strength
|
|
805
|
+
return null
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
/** Follow the argument through local assignments for a secret-path clue. */
|
|
809
|
+
function containsSecretSource(node, bindings, depth = 0, seen = new Set()) {
|
|
810
|
+
if (!node || depth > 4 || seen.has(node)) return null
|
|
811
|
+
const text = literalPathText(node)
|
|
812
|
+
if (text !== null) {
|
|
813
|
+
const strength = isPathLikeSecretLiteral(node, text)
|
|
814
|
+
if (strength) return strength
|
|
815
|
+
}
|
|
816
|
+
if (node.type === 'Identifier') {
|
|
817
|
+
const init = bindings.get(node.name)
|
|
818
|
+
if (!init || FUNCTION_LIKE_NODES.has(init.type)) return null
|
|
819
|
+
return containsSecretSource(init, bindings, depth + 1, new Set(seen).add(node))
|
|
820
|
+
}
|
|
821
|
+
let best = null
|
|
822
|
+
for (const key of Object.keys(node)) {
|
|
823
|
+
if (key === 'loc') continue
|
|
824
|
+
const value = node[key]
|
|
825
|
+
const children = Array.isArray(value) ? value : [value]
|
|
826
|
+
for (const child of children) {
|
|
827
|
+
if (!child || typeof child.type !== 'string' || FUNCTION_LIKE_NODES.has(child.type)) continue
|
|
828
|
+
const found = containsSecretSource(child, bindings, depth + 1, new Set(seen).add(node))
|
|
829
|
+
if (found === 'strong') return 'strong'
|
|
830
|
+
if (found === 'weak') best = 'weak'
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
return best
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
/** Home markers: os.homedir(), process.env.HOME/USERPROFILE/DSH_HOME/XDG_CONFIG_HOME. */
|
|
837
|
+
function containsHomeSource(node, bindings, depth = 0, seen = new Set()) {
|
|
838
|
+
if (!node || depth > 4 || seen.has(node)) return false
|
|
839
|
+
if (node.type === 'CallExpression') {
|
|
840
|
+
const names = chainNames(node.callee)
|
|
841
|
+
if (names[names.length - 1] === 'homedir') return true
|
|
842
|
+
}
|
|
843
|
+
if (node.type === 'MemberExpression') {
|
|
844
|
+
const property = memberPropertyName(node)
|
|
845
|
+
const object = node.object
|
|
846
|
+
if (property && HOME_ENV_NAMES.has(property) && object?.type === 'MemberExpression'
|
|
847
|
+
&& identifierName(object.object) === 'process' && memberPropertyName(object) === 'env') return true
|
|
848
|
+
}
|
|
849
|
+
if (node.type === 'Identifier') {
|
|
850
|
+
if (HOME_ENV_NAMES.has(node.name)) return true
|
|
851
|
+
const init = bindings.get(node.name)
|
|
852
|
+
if (!init || FUNCTION_LIKE_NODES.has(init.type)) return false
|
|
853
|
+
return containsHomeSource(init, bindings, depth + 1, new Set(seen).add(node))
|
|
854
|
+
}
|
|
855
|
+
for (const key of Object.keys(node)) {
|
|
856
|
+
if (key === 'loc') continue
|
|
857
|
+
const value = node[key]
|
|
858
|
+
const children = Array.isArray(value) ? value : [value]
|
|
859
|
+
for (const child of children) {
|
|
860
|
+
if (!child || typeof child.type !== 'string' || FUNCTION_LIKE_NODES.has(child.type)) continue
|
|
861
|
+
if (containsHomeSource(child, bindings, depth + 1, new Set(seen).add(node))) return true
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
return false
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
/** A variable argument whose name suggests a path, e.g. configPath or secretFile. */
|
|
868
|
+
function argumentLooksPathLike(node) {
|
|
869
|
+
let found = false
|
|
870
|
+
walkAst(node, (child) => {
|
|
871
|
+
if (found) return
|
|
872
|
+
if (child.type === 'Identifier' && PATH_LIKE_NAME.test(child.name)) found = true
|
|
873
|
+
if (child.type === 'MemberExpression') {
|
|
874
|
+
const property = memberPropertyName(child)
|
|
875
|
+
if (property && PATH_LIKE_NAME.test(property)) found = true
|
|
876
|
+
}
|
|
877
|
+
})
|
|
878
|
+
return found
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
function isDshDirectoryPath(value) {
|
|
882
|
+
const normalized = String(value).replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase()
|
|
883
|
+
return /(?:^|\/)\.dsh$/.test(normalized)
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
function securityScopeOf(ancestors, root) {
|
|
887
|
+
for (let index = ancestors.length - 1; index >= 0; index -= 1) {
|
|
888
|
+
if (FUNCTION_SCOPE_NODES.has(ancestors[index].type)) return ancestors[index]
|
|
889
|
+
}
|
|
890
|
+
return root
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
function resolveMemberCallee(callee, bindings, depth = 0, seen = new Set()) {
|
|
894
|
+
if (!callee || depth > 3 || seen.has(callee)) return null
|
|
895
|
+
if (callee.type === 'MemberExpression') return callee
|
|
896
|
+
if (callee.type !== 'Identifier') return null
|
|
897
|
+
const init = bindings.get(callee.name)
|
|
898
|
+
if (!init) return null
|
|
899
|
+
if (init.type === 'MemberExpression') return init
|
|
900
|
+
if (init.type === 'Identifier') return resolveMemberCallee(init, bindings, depth + 1, new Set(seen).add(callee))
|
|
901
|
+
return null
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
/**
|
|
905
|
+
* fetch / http(s).get called directly, through a one-level alias, or through a
|
|
906
|
+
* statically folded computed property such as globalThis['fe' + 'tch'].
|
|
907
|
+
*/
|
|
908
|
+
function egressKindOf(node, bindings) {
|
|
909
|
+
if (!node || node.type !== 'CallExpression') return null
|
|
910
|
+
const callee = node.callee
|
|
911
|
+
if (callee.type === 'Identifier' && callee.name === 'fetch') return 'fetch'
|
|
912
|
+
const member = resolveMemberCallee(callee, bindings)
|
|
913
|
+
if (!member) return null
|
|
914
|
+
const name = foldedPropertyName(member, bindings)
|
|
915
|
+
if (name === 'fetch') return 'fetch'
|
|
916
|
+
const objectName = identifierName(member.object)
|
|
917
|
+
if ((objectName === 'http' || objectName === 'https') && (name === 'get' || name === 'request')) return name
|
|
918
|
+
return null
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
function isEgressCall(node, bindings) {
|
|
922
|
+
return egressKindOf(node, bindings) !== null
|
|
923
|
+
}
|
|
924
|
+
|
|
477
925
|
function isEnvEnumeration(node) {
|
|
478
926
|
return node.type === 'CallExpression'
|
|
479
927
|
&& node.callee.type === 'MemberExpression'
|
|
@@ -484,17 +932,37 @@ function isEnvEnumeration(node) {
|
|
|
484
932
|
&& memberPropertyName(node.arguments[0]) === 'env'
|
|
485
933
|
}
|
|
486
934
|
|
|
487
|
-
function hasEgressCall(ast) {
|
|
935
|
+
function hasEgressCall(ast, bindings) {
|
|
488
936
|
let found = false
|
|
489
937
|
walkAst(ast, (node) => {
|
|
490
|
-
if (found
|
|
491
|
-
const names = chainNames(node.callee)
|
|
492
|
-
if (names[names.length - 1] === 'fetch') found = true
|
|
493
|
-
if (names.length >= 2 && ['http', 'https'].includes(names[0]) && ['get', 'request'].includes(names[names.length - 1])) found = true
|
|
938
|
+
if (!found && isEgressCall(node, bindings)) found = true
|
|
494
939
|
})
|
|
495
940
|
return found
|
|
496
941
|
}
|
|
497
942
|
|
|
943
|
+
/**
|
|
944
|
+
* Direct read API or a one-level alias: `const read = fs.readFileSync;
|
|
945
|
+
* read(path)`. Only a direct identifier declaration is resolved; destructuring
|
|
946
|
+
* aliases such as `{ readFileSync: read }` remain out of scope.
|
|
947
|
+
*/
|
|
948
|
+
function readApiOf(callee, bindings) {
|
|
949
|
+
if (!callee) return null
|
|
950
|
+
if (callee.type === 'Identifier') {
|
|
951
|
+
if (READ_APIS.has(callee.name)) return callee.name
|
|
952
|
+
const init = bindings.get(callee.name)
|
|
953
|
+
if (init?.type === 'MemberExpression') {
|
|
954
|
+
const property = memberPropertyName(init)
|
|
955
|
+
if (property && READ_APIS.has(property)) return property
|
|
956
|
+
}
|
|
957
|
+
return null
|
|
958
|
+
}
|
|
959
|
+
if (callee.type === 'MemberExpression') {
|
|
960
|
+
const property = memberPropertyName(callee)
|
|
961
|
+
return property && READ_APIS.has(property) ? property : null
|
|
962
|
+
}
|
|
963
|
+
return null
|
|
964
|
+
}
|
|
965
|
+
|
|
498
966
|
export function checkSensitiveFileReads(manifest, sources) {
|
|
499
967
|
if (!sources || sources.length === 0) {
|
|
500
968
|
return check({
|
|
@@ -512,28 +980,85 @@ export function checkSensitiveFileReads(manifest, sources) {
|
|
|
512
980
|
const parsed = parseModule(source)
|
|
513
981
|
if (parsed.error) { parseErrors += 1; continue }
|
|
514
982
|
let envEnumeration = false
|
|
515
|
-
|
|
516
|
-
|
|
983
|
+
const scopeFacts = new Map()
|
|
984
|
+
walkAst(parsed.ast, (node, ancestors) => {
|
|
985
|
+
const scope = securityScopeOf(ancestors, parsed.ast)
|
|
986
|
+
let facts = scopeFacts.get(scope)
|
|
987
|
+
if (!facts) {
|
|
988
|
+
facts = { secretLiteral: null, egress: false, reads: [] }
|
|
989
|
+
scopeFacts.set(scope, facts)
|
|
990
|
+
}
|
|
991
|
+
if (isEgressCall(node, parsed.bindings)) facts.egress = true
|
|
992
|
+
const literal = literalPathText(node)
|
|
993
|
+
if (literal !== null) {
|
|
994
|
+
const strength = isPathLikeSecretLiteral(node, literal)
|
|
995
|
+
if (strength) facts.secretLiteral = strongerSecret(facts.secretLiteral, strength)
|
|
996
|
+
}
|
|
517
997
|
if (isEnvEnumeration(node)) envEnumeration = true
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
998
|
+
if (node.type !== 'CallExpression' || node.arguments.length === 0) return
|
|
999
|
+
const api = readApiOf(node.callee, parsed.bindings)
|
|
1000
|
+
if (!api) return
|
|
1001
|
+
const argument = node.arguments[0]
|
|
1002
|
+
facts.reads.push({
|
|
1003
|
+
api,
|
|
1004
|
+
line: lineOf(node),
|
|
1005
|
+
value: stringFromNode(argument, parsed.bindings),
|
|
1006
|
+
homeDerived: containsHomeSource(argument, parsed.bindings),
|
|
1007
|
+
secretDerived: containsSecretSource(argument, parsed.bindings),
|
|
1008
|
+
pathNamed: argumentLooksPathLike(argument),
|
|
1009
|
+
})
|
|
527
1010
|
})
|
|
528
|
-
|
|
529
|
-
|
|
1011
|
+
for (const facts of scopeFacts.values()) {
|
|
1012
|
+
for (const read of facts.reads) {
|
|
1013
|
+
const where = `${source.relative}:${read.line}`
|
|
1014
|
+
if (read.value !== null && read.value !== '') {
|
|
1015
|
+
const classification = classifySecretPath(read.value)
|
|
1016
|
+
if (classification === 'fail') {
|
|
1017
|
+
failures.push({ file: source.relative, line: read.line, api: read.api, reason: `${read.api} reads a sensitive path at ${where}` })
|
|
1018
|
+
continue
|
|
1019
|
+
}
|
|
1020
|
+
if (classification === 'warn') {
|
|
1021
|
+
warnings.push({ file: source.relative, line: read.line, api: read.api, item: 'C-17.2', reason: `${read.api} reads a dotenv/environment surface at ${where}` })
|
|
1022
|
+
continue
|
|
1023
|
+
}
|
|
1024
|
+
if (isDshDirectoryPath(read.value)) {
|
|
1025
|
+
if (facts.egress && facts.secretLiteral) {
|
|
1026
|
+
failures.push({ file: source.relative, line: read.line, api: read.api, reason: `${read.api} lists the DSH home directory while the same function handles credential-like names and network egress at ${where}` })
|
|
1027
|
+
} else {
|
|
1028
|
+
warnings.push({ file: source.relative, line: read.line, api: read.api, item: 'C-17.2', reason: `${read.api} lists the DSH home directory at ${where}` })
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
continue
|
|
1032
|
+
}
|
|
1033
|
+
// Variable argument: approximate taint using the argument's assignment
|
|
1034
|
+
// chain, a sibling credential-like literal, and network egress.
|
|
1035
|
+
if (!read.homeDerived && !read.secretDerived) {
|
|
1036
|
+
// A sibling literal alone is weak: only a path-named variable plus a
|
|
1037
|
+
// credential literal (and egress for FAIL) is actionable.
|
|
1038
|
+
if (!facts.secretLiteral || !read.pathNamed) continue
|
|
1039
|
+
if (facts.egress && facts.secretLiteral === 'strong') {
|
|
1040
|
+
failures.push({ file: source.relative, line: read.line, api: read.api, reason: `${read.api} reads a path-named variable while the same function contains a credential-like literal and network egress at ${where}` })
|
|
1041
|
+
} else {
|
|
1042
|
+
warnings.push({ file: source.relative, line: read.line, api: read.api, item: 'C-17.2', reason: `${read.api} reads a path-named variable while the same function contains a credential-like literal at ${where}` })
|
|
1043
|
+
}
|
|
1044
|
+
continue
|
|
1045
|
+
}
|
|
1046
|
+
if (facts.egress) {
|
|
1047
|
+
failures.push({ file: source.relative, line: read.line, api: read.api, reason: `${read.api} reads a home/secret-derived path and the same function performs network egress at ${where}` })
|
|
1048
|
+
} else {
|
|
1049
|
+
warnings.push({ file: source.relative, line: read.line, api: read.api, item: 'C-17.2', reason: `${read.api} uses a home/secret-derived path that cannot be fully resolved at ${where}` })
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
if (envEnumeration && hasEgressCall(parsed.ast, parsed.bindings)) {
|
|
1054
|
+
warnings.push({ file: source.relative, line: 1, api: 'Object.keys(process.env)', item: 'C-17.2', reason: 'environment enumeration with egress' })
|
|
530
1055
|
}
|
|
531
1056
|
}
|
|
532
1057
|
for (const item of failures.slice(0, 8)) {
|
|
533
|
-
details.push(detail('C-17.1', 'FAIL',
|
|
1058
|
+
details.push(detail('C-17.1', 'FAIL', item.reason, [`${item.file}:${item.line}`]))
|
|
534
1059
|
}
|
|
535
1060
|
for (const item of warnings.slice(0, 8)) {
|
|
536
|
-
details.push(detail('C-17.2', 'WARN',
|
|
1061
|
+
details.push(detail(item.item ?? 'C-17.2', 'WARN', item.reason, [`${item.file}:${item.line}`]))
|
|
537
1062
|
}
|
|
538
1063
|
if (parseErrors > 0) {
|
|
539
1064
|
details.push(detail('C-17.3', 'ERROR', `not-run: AST parse failed in ${parseErrors} source file(s)`, []))
|
|
@@ -616,8 +1141,7 @@ export function checkEgressEndpoints(manifest, sources) {
|
|
|
616
1141
|
let target = null
|
|
617
1142
|
if (node.type === 'CallExpression') {
|
|
618
1143
|
const names = chainNames(node.callee)
|
|
619
|
-
|
|
620
|
-
if (last === 'fetch' || (names.length >= 2 && ['http', 'https'].includes(names[0]) && ['get', 'request'].includes(last)) || names[0] === 'axios') {
|
|
1144
|
+
if (egressKindOf(node, parsed.bindings) !== null || names[0] === 'axios') {
|
|
621
1145
|
target = egressEndpointFrom(node.arguments[0], parsed.bindings)
|
|
622
1146
|
}
|
|
623
1147
|
} else if (node.type === 'NewExpression' && chainNames(node.callee).join('.') === 'WebSocket') {
|