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.
Files changed (64) hide show
  1. package/40-checker/fixtures/security/sec11-cred-declared/index.mjs +3 -0
  2. package/40-checker/fixtures/security/sec11-cred-declared/package.json +21 -0
  3. package/40-checker/fixtures/security/sec11-cred-list/index.mjs +3 -0
  4. package/40-checker/fixtures/security/sec11-cred-list/package.json +21 -0
  5. package/40-checker/fixtures/security/sec11-cred-undeclared/index.mjs +3 -0
  6. package/40-checker/fixtures/security/sec11-cred-undeclared/package.json +19 -0
  7. package/40-checker/fixtures/security/sec15-install-cred/index.mjs +7 -0
  8. package/40-checker/fixtures/security/sec15-install-cred/install.js +8 -0
  9. package/40-checker/fixtures/security/sec15-install-cred/package.json +22 -0
  10. package/40-checker/fixtures/security/sec15-install-curl/index.mjs +7 -0
  11. package/40-checker/fixtures/security/sec15-install-curl/package.json +22 -0
  12. package/40-checker/fixtures/security/sec15-install-curl/scripts/install.sh +4 -0
  13. package/40-checker/fixtures/security/sec15-install-danger/index.mjs +7 -0
  14. package/40-checker/fixtures/security/sec15-install-danger/install.js +5 -0
  15. package/40-checker/fixtures/security/sec15-install-danger/package.json +22 -0
  16. package/40-checker/fixtures/security/sec15-install-download-safe/index.js +2 -0
  17. package/40-checker/fixtures/security/sec15-install-download-safe/package.json +22 -0
  18. package/40-checker/fixtures/security/sec15-install-download-safe/scripts/install.js +11 -0
  19. package/40-checker/fixtures/security/sec15-install-download-safe/scripts/local-util.cjs +1 -0
  20. package/40-checker/fixtures/security/sec15-install-dynamic/index.js +2 -0
  21. package/40-checker/fixtures/security/sec15-install-dynamic/package.json +22 -0
  22. package/40-checker/fixtures/security/sec15-install-dynamic/scripts/install.js +11 -0
  23. package/40-checker/fixtures/security/sec15-install-safe/index.mjs +7 -0
  24. package/40-checker/fixtures/security/sec15-install-safe/install.js +5 -0
  25. package/40-checker/fixtures/security/sec15-install-safe/package.json +22 -0
  26. package/40-checker/fixtures/security/sec16-base64-clean/index.mjs +4 -0
  27. package/40-checker/fixtures/security/sec16-base64-clean/package.json +19 -0
  28. package/40-checker/fixtures/security/sec16-base64-eval/index.mjs +4 -0
  29. package/40-checker/fixtures/security/sec16-base64-eval/package.json +19 -0
  30. package/40-checker/fixtures/security/sec16-exec-curl/index.mjs +4 -0
  31. package/40-checker/fixtures/security/sec16-exec-curl/package.json +19 -0
  32. package/40-checker/fixtures/security/sec16-import-only/index.mjs +4 -0
  33. package/40-checker/fixtures/security/sec16-import-only/package.json +19 -0
  34. package/40-checker/fixtures/security/sec16-spawn-shell/index.mjs +4 -0
  35. package/40-checker/fixtures/security/sec16-spawn-shell/package.json +19 -0
  36. package/40-checker/fixtures/security/sec16-subprocess-safe/index.mjs +4 -0
  37. package/40-checker/fixtures/security/sec16-subprocess-safe/package.json +19 -0
  38. package/40-checker/fixtures/security/sec17-alias-clean/data.txt +1 -0
  39. package/40-checker/fixtures/security/sec17-alias-clean/index.mjs +5 -0
  40. package/40-checker/fixtures/security/sec17-alias-clean/package.json +19 -0
  41. package/40-checker/fixtures/security/sec17-alias-read/index.mjs +7 -0
  42. package/40-checker/fixtures/security/sec17-alias-read/package.json +19 -0
  43. package/40-checker/fixtures/security/sec17-base64-path/index.mjs +11 -0
  44. package/40-checker/fixtures/security/sec17-base64-path/package.json +19 -0
  45. package/40-checker/fixtures/security/sec17-base64-path-clean/data/config.json +1 -0
  46. package/40-checker/fixtures/security/sec17-base64-path-clean/index.mjs +5 -0
  47. package/40-checker/fixtures/security/sec17-base64-path-clean/package.json +19 -0
  48. package/40-checker/fixtures/security/sec17-cred-exfil/index.mjs +22 -0
  49. package/40-checker/fixtures/security/sec17-cred-exfil/package.json +19 -0
  50. package/40-checker/fixtures/security/sec17-home-env/index.js +8 -0
  51. package/40-checker/fixtures/security/sec17-home-env/package.json +19 -0
  52. package/40-checker/fixtures/security/sec17-home-env-clean/index.mjs +6 -0
  53. package/40-checker/fixtures/security/sec17-home-env-clean/package.json +19 -0
  54. package/40-checker/fixtures/security/sec18-split-fetch/index.mjs +5 -0
  55. package/40-checker/fixtures/security/sec18-split-fetch/package.json +19 -0
  56. package/40-checker/fixtures/security/sec18-split-fetch-clean/index.mjs +4 -0
  57. package/40-checker/fixtures/security/sec18-split-fetch-clean/package.json +24 -0
  58. package/40-checker/src/declarations.mjs +6 -1
  59. package/40-checker/src/remediation.mjs +1 -1
  60. package/40-checker/src/security.mjs +568 -44
  61. package/CHANGELOG.md +23 -0
  62. package/docs/coverage.md +17 -7
  63. package/package.json +1 -1
  64. 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
- if (objectName === 'process' && node.object.type === 'MemberExpression'
165
- && memberPropertyName(node.object) === 'env'
166
- && ['HOME', 'USERPROFILE'].includes(property)) return '/home/dsh-user'
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
- function scriptReferenceProblems(name, command, dir, scripts) {
262
- const problems = []
263
- const missing = []
264
- const checkRef = (reference) => {
265
- if (!reference || reference.startsWith('-')) return
266
- if (!/^(?:\.{0,2}\/|[\w@.-]+\/)/.test(reference) && !/\.(?:[cm]?[jt]s|sh|ps1)$/.test(reference)) return
267
- const absolute = path.resolve(dir, reference)
268
- if (!fs.existsSync(absolute)) missing.push(reference)
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
- if (!Object.hasOwn(scripts, match[1])) missing.push(`npm run ${match[1]}`)
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
- if (missing.length > 0) problems.push(`scripts.${name} references missing script(s): ${[...new Set(missing)].join(', ')}`)
275
- return problems
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 missing = scriptReferenceProblems(name, command, dir, scripts)
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, `${missing.join('; ')}${name === 'prepare' ? ' (prepare is allowBuilds-gated)' : ''}`, evidence))
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 || node.type !== 'CallExpression') return
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
- walkAst(parsed.ast, (node) => {
516
- if (node.type !== 'CallExpression') return
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
- const api = node.callee.type === 'Identifier' ? node.callee.name : memberPropertyName(node.callee)
519
- if (!READ_APIS.has(api) || node.arguments.length === 0) return
520
- const value = stringFromNode(node.arguments[0], parsed.bindings)
521
- if (value === null || value === '') return
522
- const classification = classifySecretPath(value)
523
- if (classification === null) return
524
- const record = { file: source.relative, line: lineOf(node), api, value }
525
- if (classification === 'fail') failures.push(record)
526
- else warnings.push(record)
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
- if (envEnumeration && hasEgressCall(parsed.ast)) {
529
- warnings.push({ file: source.relative, line: 1, api: 'Object.keys(process.env)', value: 'environment enumeration with egress' })
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', `${item.api} reads a sensitive path at ${item.file}:${item.line}`, [`${item.file}:${item.line}`]))
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', `${item.api} reads a dotenv/environment surface at ${item.file}:${item.line}`, [`${item.file}:${item.line}`]))
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
- const last = names[names.length - 1]
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') {